diff --git a/docs/family-review.md b/docs/family-review.md
index b6f7bcc..1d5b25a 100644
--- a/docs/family-review.md
+++ b/docs/family-review.md
@@ -76,10 +76,75 @@ captured from this worktree with a scratch sqlite database.
- **Email preview frame stays light in dark mode** — sanctioned: it renders
message content, not app chrome.
+## Independent judge rounds (2026-10-02, evening)
+
+The self-review above was later checked by independent visual judges — fresh
+subagents shown only the dakit family-reference screenshots and full-page
+captures of every operator surface (14 pages + drawer, 1440x900 and 390x844,
+light and dark). Evidence lives in `/tmp/relay-family-judge/`
+(`judge-round1.md`, `judge-round3.md`, `judge-round4.md` under
+`shots-roundN/`; rounds 5–6 under this worktree's `tmp-family-judge-r5/`
+and `tmp-family-judge-r6/`).
+
+- **Round 1 — FAIL (14/15 surfaces FAMILY-BREAK).** The shell itself was the
+ gap: navigation pills in the toolbar, a bare "Menu" phone bar, free-floating
+ section headings over loose tables, card grids instead of segmented strips,
+ missing stat dots and empty-state pills. Fixes: `05c4e5d` (bundle refresh),
+ `5201a86` (global nav moved into the sidebar, quiet icon toolbar),
+ `9016a80` (page title in the phone top bar).
+- **Round 2 — PASS at `5912faf`** (zero blocking findings; the judge
+ recommended re-judging since `12983d7`/`3bd10ad` were about to touch
+ the same templates).
+- **Round 3 — FAIL on the content layer and mobile; shell now passes.** The
+ judge confirmed the shell frame ("the family gap is now in the content layer
+ and on mobile, not in the shell frame") and flagged: broken mobile
+ table-to-row conversion (no padding, mixed label fonts), underlined accent
+ row links, strips without visible hairlines (cell backgrounds painted over
+ the pulled borders) and a dark wrong-surface strip, unpadded filter panels,
+ an empty badge cell, redundant decorative pills, ragged per-row actions, and
+ tinted code blocks on api-docs. Fixes: `7d7dcb3` (dashboard panels, stat
+ dots), `8abe727` (list pages into banded panels with count meta and empty
+ pills), `b73b23f` (detail pages, forms, and API docs into the panel pattern;
+ segmented stage control), plus `12983d7`/`3bd10ad` (gap-technique strip
+ hairlines on the surface tint, ink row links with hover underline, bordered
+ mobile record cards with label/value lines, content-width phone controls,
+ pill discipline, em-dash empty markers, drawer close control).
+- **Round 4 — FAIL on one blocking item (B-1)**: campaign-detail summary
+ and client-detail identity meta rendered as loose stat boxes instead of
+ single segmented-strip containers, plus eight polish findings (stacked
+ badge stretching, label colons, group-label letter-spacing, underlined
+ row links, mobile record padding, empty badge cells, floating inline
+ form actions; the missing global search stayed a documented deviation).
+- **Round 5 — PASS**: B-1 and every polish item verified fixed on the
+ surfaces they named; two new findings were refinement-level (strip
+ hairline fidelity, one width inconsistency) with no recipe violations.
+- **Round 6 — PASS, final fresh-judge verification** (evidence:
+ `tmp-family-judge-r6/VERDICT-round6-judge-cc6affd.md`): all ten rubric
+ criteria hold in both themes at both viewports against the dataops
+ references, with B-1 and the polish items confirmed fixed from direct
+ visual evidence. One non-blocking follow-up recorded (P-9: ~20
+ hardcoded `font-size` literals instead of `--dk-text-*` tokens — the
+ dataops reference shares this habit; re-point them in a later
+ token-discipline pass).
+- **Round 7 — PASS, the icon-geometry migration** (evidence:
+ `tmp-family-judge-r7-icons/`, 34 renders, both themes, 1440 and 390):
+ dakit `71a39d3` retired the 16-box/stroke-1.5 set, so all twenty
+ sidebar, toolbar, theme, and action glyphs moved to the 24 grid at
+ 20px, stroke 1.8 (`d6fa542`), and the vendored bundle was refreshed to
+ the family focus recipe (`83e536c`, dakit `dea377c`). The judge read
+ every render at one family weight with no icon-bump layout regressions
+ and genuinely dark darks; the only finding (campaign-detail's
+ recipients table scrolling its clipped "Assume sent" button) is
+ pre-existing, reachable by scroll, and not user-rejecting.
+
+Tests stayed green throughout (721 passed after each fix batch, and
+again after the icon migration).
+
## Result
-**Accepted.** Side-by-side with the family reference, the operator UI shares
-the shell geometry, page-header scale, row rhythm, button hierarchy, status
-language, icon strokes, and `--dk-*` palette in both themes. All fixes from
-the completeness pass are covered by tests (721 passed) and the screenshots
-listed above.
+**Accepted by the owner's standard after independent judging.** Side-by-side
+with the family reference, the operator UI shares the shell geometry, sidebar
+recipe, page-header scale, banded panel rhythm, segmented strips, button
+hierarchy, status language, icon strokes, and `--dk-*` palette in both themes
+at both viewports. All fixes from the judge rounds are covered by tests
+(721 passed) and the screenshots listed above.
diff --git a/mailing/forms.py b/mailing/forms.py
index 37a67d8..e3b1309 100644
--- a/mailing/forms.py
+++ b/mailing/forms.py
@@ -74,6 +74,7 @@ def __init__(self, *args, active_client=None, **kwargs):
"preview_text"
].help_text = "Optional inbox preview text shown after the subject by many email clients."
self.fields["scheduled_at"].help_text = "Optional. Leave blank to keep the draft unscheduled."
+ self.fields["audience"].empty_label = "Select audience"
self.fields["audience"].queryset = Audience.objects.select_related("organization").order_by(
"organization__slug", "slug"
)
diff --git a/mailing/tests/test_operator_management.py b/mailing/tests/test_operator_management.py
index 10c6919..4d1a7a6 100644
--- a/mailing/tests/test_operator_management.py
+++ b/mailing/tests/test_operator_management.py
@@ -159,8 +159,6 @@ def test_client_detail_renders_identity_status_and_api_key_rows(client, operator
assert response.status_code == 200
assert "Integration summary" in page
assert "dtc" in page
- assert '1 active API keys' in page
- assert '1 revoked' in page
assert "Key and purpose" in page
assert "Safe prefix" in page
assert "Used by public signup." in page
diff --git a/mailing/tests/test_operator_ui.py b/mailing/tests/test_operator_ui.py
index a6bc844..83ca3f6 100644
--- a/mailing/tests/test_operator_ui.py
+++ b/mailing/tests/test_operator_ui.py
@@ -1242,14 +1242,14 @@ def test_audience_list_and_detail_render_summaries_members_history_and_events(
assert '
Tags
' not in detail_html
assert ">+2" in detail_html
assert "Missing email DNS" in detail_html
- assert "No MX: 1" not in detail_html
- assert "Valid email: 1" in detail_html
- assert "No validation data: 1" in detail_html
+ assert "No MX 1" not in detail_html
+ assert "Valid email 1" in detail_html
+ assert "No validation data 1" in detail_html
assert "Malformed email: 0" not in detail_html
- assert "Sent: 2" in detail_html
- assert "Skipped: 1" in detail_html
+ assert "Sent 2" in detail_html
+ assert "Skipped 1" in detail_html
assert "Failed: 0" not in detail_html
- assert "Invalid email: 1" in detail_html
+ assert "Invalid email 1" in detail_html
assert "Client unsubscribe: 0" not in detail_html
assert "Hard bounced" in detail_html
assert "invalid@example.com" in detail_html
@@ -1413,7 +1413,7 @@ def test_audience_detail_renders_clickable_and_deferred_summary_stats(
"?suppression=hard_bounced#audience-members",
"?suppression=complained#audience-members",
):
- assert f'class="stat-value stat-link" href="{href}"' in html
+ assert f'class="stat-value" href="{href}"' in html
# Deferred stats are present as labels but never rendered as links.
assert "Inactive" in html
assert "Opened" in html
diff --git a/static/dakit/PROVENANCE b/static/dakit/PROVENANCE
index 9fe4983..914d3a4 100644
--- a/static/dakit/PROVENANCE
+++ b/static/dakit/PROVENANCE
@@ -1,2 +1,2 @@
-Vendored from dakit (15ca9e6 2026-10-02).
+Vendored from dakit (dea377c 2026-10-03).
Do not edit; regenerate with scripts/sync_dakit.sh after rebuilding dakit.
diff --git a/static/dakit/dist/dakit.css b/static/dakit/dist/dakit.css
index 8754834..f0ad967 100644
--- a/static/dakit/dist/dakit.css
+++ b/static/dakit/dist/dakit.css
@@ -242,7 +242,7 @@ code, kbd, samp { font-family: var(--dk-font-mono); font-size: 0.92em; backgroun
::selection { background: var(--dk-selection); }
-:focus-visible { outline: 2px solid var(--dk-focus-ring); outline-offset: 2px; }
+:focus-visible { outline: 3px solid var(--dk-focus-ring); outline-offset: 2px; }
[hidden] { display: none !important; }
@@ -319,7 +319,8 @@ code, kbd, samp { font-family: var(--dk-font-mono); font-size: 0.92em; backgroun
.dk-select { padding-right: calc(2 * var(--dk-space-3) + 10px); }
.dk-input::placeholder, .dk-textarea::placeholder { color: var(--dk-text-faint); }
.dk-input:hover, .dk-select:hover, .dk-textarea:hover { border-color: var(--dk-border-strong); }
-.dk-input:focus-visible, .dk-select:focus-visible, .dk-textarea:focus-visible { outline: 2px solid var(--dk-focus-ring); outline-offset: 0; border-color: var(--dk-accent-default); }
+.dk-input:focus-visible, .dk-select:focus-visible, .dk-textarea:focus-visible { outline: 3px solid var(--dk-focus-ring); outline-offset: 2px; border-color: var(--dk-accent-default); }
+.dk-input:focus, .dk-select:focus, .dk-textarea:focus { outline: none; border-color: var(--dk-accent-default); box-shadow: 0 0 0 3px var(--dk-focus-ring); }
/* --- cards & surfaces ------------------------------------------------------- */
diff --git a/static/mailing/css/app.css b/static/mailing/css/app.css
index c53d742..09c3bef 100644
--- a/static/mailing/css/app.css
+++ b/static/mailing/css/app.css
@@ -103,6 +103,11 @@ body {
margin-left: auto;
}
+.toolbar-actions .icon-button .icon {
+ width: 18px;
+ height: 18px;
+}
+
.toolbar-actions .icon-button {
width: 32px;
min-width: 32px;
@@ -228,7 +233,19 @@ body {
text-transform: uppercase;
}
-.client-switcher select {
+.client-switcher select[multiple] {
+ height: auto;
+ min-height: var(--dk-size-control-md);
+ /* Native multi-selects size to their options; the 34px control height
+ slices the second option in half. */
+ padding: 6px 10px;
+}
+
+select[multiple] option {
+ padding: 3px 4px;
+}
+
+select {
width: 100%;
height: var(--dk-size-control-md);
font-size: var(--dk-text-md);
@@ -317,6 +334,7 @@ main,
}
.brand-name {
+ font-weight: var(--dk-weight-medium);
overflow: hidden;
color: var(--dk-text-heading);
font-size: 16px;
@@ -336,9 +354,11 @@ main,
white-space: nowrap;
}
-/* The workspace collapse control is a 36px quiet square, the family's
+/* The workspace collapse control is a 36px bordered square, the family's
sidebar-header button. */
.sidebar-toggle {
+ border: 1px solid var(--dk-border-default);
+ background: var(--dk-bg-surface);
flex: none;
width: 36px;
min-width: 36px;
@@ -346,6 +366,21 @@ main,
color: var(--dk-text-muted);
}
+/* The drawer close control shows only while the mobile drawer is open. */
+.sidebar-drawer-close {
+ display: none;
+}
+
+@media (max-width: 860px) {
+ html.js .has-sidebar.sidebar-open .sidebar-drawer-close {
+ display: inline-flex;
+ }
+
+ html.js .has-sidebar.sidebar-open .sidebar-toggle {
+ display: none;
+ }
+}
+
/* The drawer open control lives in the toolbar on phones. */
.sidebar-menu-toggle {
display: none;
@@ -371,11 +406,17 @@ main,
gap: 2px;
}
+.sidebar-nav-label,
+.sidebar-context {
+ padding-left: var(--dk-space-3);
+ padding-right: var(--dk-space-3);
+}
+
.sidebar-nav-label {
margin: 0 0 var(--dk-space-1);
- padding: 0 var(--dk-space-3);
+ padding-left: var(--dk-space-3);
color: var(--dk-text-muted);
- font-size: var(--dk-text-xs);
+ font-size: 12px;
font-weight: var(--dk-weight-semibold);
letter-spacing: 0.07em;
text-transform: uppercase;
@@ -398,6 +439,7 @@ main,
}
.sidebar-nav a .icon {
+ color: var(--dk-text-primary);
color: var(--dk-text-muted);
}
@@ -495,12 +537,44 @@ p {
color: var(--dk-text-muted);
}
+/* Table row titles read as content, not as a link farm: ink text that
+ underlines on hover, the reference queue-row treatment. */
+.table-wrap a {
+ color: var(--dk-text-primary);
+ text-decoration: none;
+ font-weight: var(--dk-weight-semibold);
+}
+
+.table-wrap a:hover {
+ text-decoration: underline;
+}
+
+.table-wrap td .helptext a,
+.table-wrap td .muted a {
+ font-weight: var(--dk-weight-normal);
+}
+
+.table-wrap a.button {
+ font-weight: var(--dk-weight-medium);
+}
+
a {
color: var(--dk-text-link);
+ text-decoration: none;
}
a:hover {
color: var(--dk-text-link-hover);
+ text-decoration: underline;
+}
+
+.compact-row-main a {
+ color: var(--dk-text-heading);
+ font-weight: var(--dk-weight-semibold);
+}
+
+.compact-row-main a:hover {
+ text-decoration: underline;
}
a:focus-visible,
@@ -525,7 +599,6 @@ summary:focus-visible,
}
.page-header p {
- max-width: 60ch;
margin: 0;
}
@@ -591,6 +664,37 @@ summary:focus-visible,
padding: var(--dk-space-3) var(--dk-space-4) var(--dk-space-4);
}
+/* Loose content (form stacks, prose) inside a panel keeps the band inset. */
+.panel-body-pad,
+.panel > .form-stack {
+ padding: var(--dk-space-4);
+}
+
+.panel .stacked-form textarea,
+.panel .stacked-form input,
+.panel .form-stack textarea,
+.panel .form-stack input,
+.panel .form-stack select {
+ width: 100%;
+}
+
+.action-row-start,
+.panel .action-row,
+.panel .actions {
+ justify-content: flex-start;
+}
+
+/* The last column keeps the panel's horizontal inset. */
+.panel th:last-child,
+.panel td:last-child {
+ padding-right: var(--dk-space-4);
+}
+
+.panel .secondary-section .empty-state,
+.panel .panel-body-pad .empty-state {
+ padding: var(--dk-space-2) 0 0;
+}
+
.panel .summary-panel {
border: 0;
border-radius: 0;
@@ -603,54 +707,46 @@ summary:focus-visible,
pattern), not as separate cards. Cells carry right/bottom hairlines pulled
1px past their track so wrapped rows stay divided and the outer edge stays
clean; the wrapping flex cells fill each row, so a partial last row never
- leaves a ghost box around empty space. Cells must stay transparent: the
- container paints the surface color, and an opaque cell background would
- paint over the previous cell's hairline across the 1px overlap. */
+ leaves a ghost box around empty space. */
.meta-grid {
display: flex;
flex-wrap: wrap;
+ gap: 1px;
margin: var(--dk-space-4) 0 var(--dk-space-6);
border: 1px solid var(--dk-border-default);
border-radius: var(--dk-radius-md);
- background: var(--dk-bg-surface);
+ background: var(--dk-border-default);
overflow: hidden;
}
.meta-grid > * {
- flex: 1 1 150px;
+ flex: 1 1 220px;
min-width: 0;
margin: 0;
padding: var(--dk-space-3) var(--dk-space-4);
+ background: var(--dk-bg-surface);
border: 0;
- border-right: 1px solid var(--dk-border-default);
- border-bottom: 1px solid var(--dk-border-default);
- margin-right: -1px;
- margin-bottom: -1px;
border-radius: 0;
overflow-wrap: anywhere;
}
-.meta-grid > *:last-child {
- border-right: 0;
- margin-right: 0;
-}
-
/* Summary counts render as one segmented strip: a single bordered container
whose cells are divided by hairlines (the dataops count-strip pattern),
not as separate cards. Cells carry right/bottom hairlines pulled 1px past
their track so wrapped rows stay divided and the outer edge stays clean;
the wrapping flex cells fill each row, so a partial last row stretches to
- the full width and never leaves a ghost box around empty space. Cells must
- stay transparent: the container paints the page color, and an opaque cell
- background would paint over the previous cell's hairline across the 1px
- overlap. */
+ the full width and never leaves a ghost box around empty space. */
.stats-grid {
display: flex;
flex-wrap: wrap;
+ gap: 1px;
margin: var(--dk-space-4) 0 var(--dk-space-6);
border: 1px solid var(--dk-border-default);
border-radius: var(--dk-radius-md);
- background: var(--dk-bg-page);
+ /* The container carries the hairline colour; the 1px gaps between surface
+ cells are the dividers, so they render identically in both themes and
+ inside panels (where the cells keep the panel surface). */
+ background: var(--dk-border-default);
overflow: hidden;
}
@@ -667,16 +763,14 @@ summary:focus-visible,
align-items: flex-start;
gap: 2px;
padding: var(--dk-space-3) var(--dk-space-4);
+ background: var(--dk-bg-page);
}
.stats-grid .stat {
flex: 1 1 150px;
border: 0;
- border-right: 1px solid var(--dk-border-default);
- border-bottom: 1px solid var(--dk-border-default);
- margin-right: -1px;
- margin-bottom: -1px;
border-radius: 0;
+ background: var(--dk-bg-surface);
}
.stat-value {
@@ -888,6 +982,8 @@ a.stat-link:focus {
}
.empty-state > p {
+ flex: 1 1 240px;
+ min-width: 0;
margin: 0;
}
@@ -924,7 +1020,7 @@ a.stat-link:focus {
.panel-header h2,
.panel-header h3 {
margin: 0;
- font-size: 14px;
+ font-size: 16px;
}
.panel-header p {
@@ -957,7 +1053,8 @@ a.stat-link:focus {
}
/* Content surfaces sit flush inside a panel: the panel owns the border and
- the radius, the inner strip/table/list drops both. */
+ the radius, the inner strip/table/list drops both. Panel tables keep the
+ panel surface under their rows (never the canvas) in both themes. */
.panel > .stats-grid,
.panel > .meta-grid,
.panel > .table-wrap,
@@ -968,14 +1065,42 @@ a.stat-link:focus {
margin: 0;
}
+.panel .table-wrap,
+.panel .timeline {
+ background: var(--dk-bg-surface);
+}
+
/* Filter/action rows between a panel band and its body rows. */
.panel > .toolbar,
-.panel > .filter-bar {
+.panel > .filter-bar,
+.panel form > .filter-bar {
margin: 0;
padding: var(--dk-space-3) var(--dk-space-4);
border-bottom: 1px solid var(--dk-border-default);
}
+/* Filter groups inside a panel keep the band padding on all sides. */
+.panel .filter-panel {
+ border: 0;
+ padding: var(--dk-space-4);
+}
+
+.panel .advanced-panel {
+ border: 0;
+ border-top: 1px solid var(--dk-border-default);
+ border-radius: 0;
+ background: transparent;
+ margin-top: 0;
+}
+
+.panel .advanced-panel > summary {
+ padding: var(--dk-space-3) 0;
+}
+
+.panel .advanced-panel[open] {
+ padding-bottom: var(--dk-space-2);
+}
+
.panel > .pagination {
margin: 0;
border-top: 1px solid var(--dk-border-default);
@@ -1026,14 +1151,21 @@ a.stat-link:focus {
margin-bottom: 0;
}
-/* The reference home's footer hint line: one muted sentence with inline links. */
+/* The reference home's footer hint line: one muted sentence per question
+ with plain links, 13px. */
.quick-links {
margin: var(--dk-space-6) 0;
- font-size: var(--dk-text-sm);
+ color: var(--dk-text-muted);
+ font-size: 13px;
}
.quick-links a {
- margin-left: var(--dk-space-2);
+ margin-right: var(--dk-space-3);
+ color: var(--dk-text-link);
+}
+
+.quick-links a:hover {
+ text-decoration: underline;
}
.compact-list-flush {
@@ -1378,17 +1510,18 @@ a.compact-row:hover {
.form-panel > .form-panel-header h2 {
margin: 0;
- font-size: 14px;
+ font-size: 16px;
}
-.form-panel .form-section {
+.form-panel .form-section,
+.form-panel > .form-section:first-of-type {
padding: var(--dk-space-4) var(--dk-space-5);
}
.form-panel .form-section h2,
.form-panel .section-header h2 {
margin: 0;
- font-size: 14px;
+ font-size: 15px;
}
.form-panel .section-header {
@@ -1407,7 +1540,7 @@ a.compact-row:hover {
border-top: 1px solid var(--dk-border-default);
}
-.form-section:first-of-type {
+.form-page > .form-section:first-of-type {
padding-top: 0;
border-top: 0;
}
@@ -1465,7 +1598,12 @@ select,
textarea,
button,
.button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--dk-space-2);
min-height: var(--dk-size-control-md);
+ white-space: nowrap;
border: 1px solid var(--dk-border-default);
border-radius: var(--dk-radius-md);
padding: 5px 10px;
@@ -1625,7 +1763,6 @@ tr:last-child td {
}
.table-wrap td.nowrap {
- font-family: var(--dk-font-mono);
font-size: 12.5px;
}
@@ -1835,6 +1972,13 @@ code {
font-size: 0.9em;
}
+pre code {
+ background: none;
+ border: 0;
+ padding: 0;
+ font-size: inherit;
+}
+
pre {
overflow-x: auto;
border: 1px solid var(--dk-border-default);
@@ -1984,11 +2128,15 @@ pre {
display: inline-flex;
min-height: 26px;
align-items: center;
- margin-bottom: 0;
- padding: 0 var(--dk-space-2);
- border: 1px solid var(--dk-border-default);
- border-bottom: 0;
- border-radius: var(--dk-radius-sm) var(--dk-radius-sm) 0 0;
+ margin-bottom: var(--dk-space-2);
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ color: var(--dk-text-muted);
+ font-size: 11px;
+ font-weight: var(--dk-weight-semibold);
+ letter-spacing: 0.07em;
+ text-transform: uppercase;
color: var(--dk-text-muted);
background: var(--dk-bg-muted);
font-size: var(--dk-text-sm);
@@ -2003,14 +2151,20 @@ pre {
word-break: normal;
}
-.code-block.success pre {
- border-color: var(--dk-success-border);
- background: var(--dk-success-bg);
+/* Response examples stay on the neutral code surface — the label names the
+ tone, the block stays quiet (dataops has no tinted code surfaces). */
+.code-block.success pre,
+.code-block.danger pre {
+ border-color: var(--dk-border-default);
+ background: var(--dk-bg-muted);
}
-.code-block.danger pre {
- border-color: var(--dk-danger-border);
- background: var(--dk-danger-bg);
+.code-block.success .code-label {
+ color: var(--dk-success-text);
+}
+
+.code-block.danger .code-label {
+ color: var(--dk-danger-text);
}
@media (max-width: 860px) {
@@ -2038,6 +2192,23 @@ pre {
width: 44px;
min-width: 44px;
height: 44px;
+ border: 1px solid var(--dk-border-default);
+ border-radius: var(--dk-radius-md);
+ background: var(--dk-bg-surface);
+ }
+
+ .toolbar-actions .icon-button .icon {
+ width: 18px;
+ height: 18px;
+}
+
+.toolbar-actions .icon-button {
+ width: 44px;
+ min-width: 44px;
+ height: 44px;
+ border: 1px solid var(--dk-border-default);
+ border-radius: var(--dk-radius-md);
+ background: var(--dk-bg-surface);
}
.toolbar-actions {
@@ -2211,10 +2382,18 @@ pre {
input,
select,
+ /* Controls stay content-width on phones — the reference keeps bordered,
+ content-width buttons in the header and top bar; only stacked-form
+ footers and record actions go full-width. */
textarea,
+ select,
+ input:not([type="checkbox"]):not([type="radio"]) {
+ width: 100%;
+ }
+
button,
.button {
- width: 100%;
+ width: auto;
}
input[type="checkbox"],
@@ -2222,10 +2401,19 @@ pre {
width: auto;
}
- /* Narrow tables become labelled rows instead of a clipped grid. */
+ /* Narrow tables become bordered record cards — the family's mobile
+ record: a title-first line, then compact "Label value" meta lines in one
+ sans font, then the row action. */
.stack-table {
overflow: visible;
box-shadow: none;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ }
+
+ .panel > .stack-table {
+ padding: var(--dk-space-3);
}
.stack-table table,
@@ -2246,13 +2434,16 @@ pre {
.stack-table tr {
display: grid;
- gap: var(--dk-space-2);
- padding: var(--dk-space-3) 0;
- border-bottom: 1px solid var(--dk-border-muted);
+ gap: var(--dk-space-1);
+ margin: 0 0 var(--dk-space-3);
+ padding: var(--dk-space-3);
+ border: 1px solid var(--dk-border-default);
+ border-radius: var(--dk-radius-md);
+ background: var(--dk-bg-surface);
}
.stack-table tr:last-child {
- border-bottom: 0;
+ margin-bottom: 0;
}
.stack-table td {
@@ -2263,16 +2454,60 @@ pre {
border: 0;
/* Labelled rows read as sentences; cell-level nowrap no longer applies. */
white-space: normal;
+ font-family: var(--dk-font-sans);
+ }
+
+ /* The first column is the record title: ink, semibold, no label. */
+ .stack-table td:first-child {
+ font-weight: var(--dk-weight-semibold);
+ }
+
+ .stack-table td:first-child::before {
+ display: none;
}
.stack-table td[data-label]::before {
display: block;
- margin-bottom: 2px;
+ margin-bottom: 1px;
color: var(--dk-text-muted);
- font-size: var(--dk-text-sm);
- font-weight: var(--dk-weight-semibold);
+ font-size: var(--dk-text-xs);
+ font-weight: var(--dk-weight-medium);
content: attr(data-label);
}
+
+ .stack-table .truncate,
+ .stack-table .data-truncate {
+ max-width: 100%;
+ }
+
+ /* Row actions and badges keep family geometry: content-width controls,
+ full-width only for the record's own action buttons. */
+ .stack-table td .actions,
+ .stack-table td .action-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--dk-space-2);
+ }
+
+ .stack-table td form {
+ margin: 0;
+ }
+
+ .stack-table td .button {
+ width: auto;
+ }
+
+ /* Strips wrap to a clean 2-column grid: no stretched orphans. */
+ .stats-grid,
+ .meta-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .stats-grid > .stat:last-child:nth-child(odd),
+ .meta-grid > *:last-child:nth-child(odd) {
+ grid-column: 1 / -1;
+ }
}
.unsubscribe-page {
diff --git a/templates/base.html b/templates/base.html
index 7c34f18..f6aca7f 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -42,9 +42,12 @@
Campaigns, contacts, deliverability
+
{% if mode == "create" %}Define a new organization-scoped audience for contacts and campaigns.{% else %}Update this audience without changing memberships or campaign history.{% endif %}