docs: add panel UI refresh mockups

Click-through gallery for current and roadmap screens, with a hybrid width proposal and a phone layout for emergency ops. The running panel is unchanged.

Co-Authored-By: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-15 23:47:55 +03:00
parent 2f888dc7be
commit f517e8da41
4 changed files with 1758 additions and 0 deletions
+907
View File
@@ -0,0 +1,907 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SelfPost — panel mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
</head>
<body>
<input class="ctrl" type="radio" name="role" id="role-global" checked>
<input class="ctrl" type="radio" name="role" id="role-domain">
<input class="ctrl" type="radio" name="vp" id="vp-desktop" checked>
<input class="ctrl" type="radio" name="vp" id="vp-phone">
<input class="ctrl" type="radio" name="theme" id="theme-light" checked>
<input class="ctrl" type="radio" name="theme" id="theme-dark">
<input class="ctrl" type="checkbox" id="feat-inbound" checked>
<input class="ctrl" type="checkbox" id="nav-open">
<input class="ctrl" type="checkbox" id="help-open">
<header class="gallery">
<a class="brand-mini" href="index.html">Макеты</a>
<div class="seg">
<span>Роль</span>
<label for="role-global">Global</label>
<label for="role-domain">Domain-admin</label>
</div>
<div class="seg">
<span>Ширина</span>
<label for="vp-desktop">Desktop</label>
<label for="vp-phone">Phone</label>
</div>
<div class="seg">
<span>Тема</span>
<label for="theme-light">Light</label>
<label for="theme-dark">Dark</label>
</div>
<label class="g-only" for="feat-inbound">Inbound</label>
<a href="index.html">Оглавление</a>
</header>
<svg class="sprite" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<symbol id="i-status" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1.25 8.5h2.9L6.2 3.4l3.1 9.4 1.9-4.3h3.55"/></symbol>
<symbol id="i-domains" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6.25"/><path d="M1.9 8h12.2"/><path d="M8 1.75c1.85 1.8 2.8 4 2.8 6.25S9.85 12.45 8 14.25C6.15 12.45 5.2 10.25 5.2 8S6.15 3.55 8 1.75Z"/></symbol>
<symbol id="i-deliveries" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14.25 1.75 1.6 6.6l5 2.05 2.05 5z"/><path d="M14.25 1.75 6.6 8.65"/></symbol>
<symbol id="i-queue" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1.75 9.5h3.3l1 1.75h3.9l1-1.75h3.3v3.05a1.2 1.2 0 0 1-1.2 1.2H2.95a1.2 1.2 0 0 1-1.2-1.2z"/><path d="M1.75 9.5 3.4 3.2a1.25 1.25 0 0 1 1.2-.95h6.8a1.25 1.25 0 0 1 1.2.95l1.65 6.3"/></symbol>
<symbol id="i-log" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3.75 1.75h5.1l3.4 3.4v8.05a1.05 1.05 0 0 1-1.05 1.05H3.75a1.05 1.05 0 0 1-1.05-1.05V2.8a1.05 1.05 0 0 1 1.05-1.05Z"/><path d="M8.85 1.75v3.4h3.4"/><path d="M5.35 8.6h5.3M5.35 11.1h3.5"/></symbol>
<symbol id="i-inbound" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 9.5h11"/><path d="M8 2.75v6.2"/><path d="M5.4 6.4 8 9.05 10.6 6.4"/><path d="M3.2 12.6h9.6"/></symbol>
<symbol id="i-dmarc" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 1.85 2.75 3.7v4.2c0 3.15 2.15 5.2 5.25 6.25 3.1-1.05 5.25-3.1 5.25-6.25V3.7Z"/><path d="M5.4 8.05 7.15 9.8 10.7 6.2"/></symbol>
<symbol id="i-backup" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2.75" y="1.75" width="10.5" height="12.5" rx="1.15"/><path d="M2.75 8h10.5"/><path d="M6.4 4.85h3.2M6.4 11.15h3.2"/></symbol>
<symbol id="i-users" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10.9 3.1a2.1 2.1 0 0 1 0 4.2"/><path d="M14.7 14.25a3.8 3.8 0 0 0-3.9-3.65"/><circle cx="5.5" cy="5.2" r="2.5"/><path d="M1.4 14.25a4.8 4.8 0 0 1 8.2 0"/></symbol>
<symbol id="i-help" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6.25"/><path d="M8 7.2V11.4"/><path d="M8 5.05v.01"/></symbol>
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></symbol>
<symbol id="i-account" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="5.4" r="2.75"/><path d="M2.9 14.25a5.1 5.1 0 0 1 10.2 0"/></symbol>
<symbol id="i-out" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6.1 14.25H3.65a1.15 1.15 0 0 1-1.15-1.15V2.9a1.15 1.15 0 0 1 1.15-1.15H6.1"/><path d="M10.6 11.15 13.75 8 10.6 4.85"/><path d="M13.75 8H6.35"/></symbol>
<symbol id="i-menu" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2.5 4h11M2.5 8h11M2.5 12h11"/></symbol>
</svg>
<div class="app">
<nav class="nav">
<a class="brand" href="#status"><img src="../selfpost-stamp-compact.svg" width="220" height="100" alt="SelfPost"></a>
<div class="links">
<a class="n-status g-only" href="#status"><svg class="icon"><use href="#i-status"/></svg>Status</a>
<a class="n-domains" href="#domains"><svg class="icon"><use href="#i-domains"/></svg>Domains</a>
<a class="n-deliveries" href="#deliveries"><svg class="icon"><use href="#i-deliveries"/></svg>Deliveries</a>
<a class="n-queue g-only" href="#mail-queue"><svg class="icon"><use href="#i-queue"/></svg>Mail queue</a>
<a class="n-log g-only" href="#system-log"><svg class="icon"><use href="#i-log"/></svg>System log</a>
<a class="n-inbound g-only in-only" href="#inbound"><svg class="icon"><use href="#i-inbound"/></svg>Inbound</a>
<a class="n-dmarc g-only" href="#dmarc"><svg class="icon"><use href="#i-dmarc"/></svg>DMARC <span class="tag future">1.x</span></a>
<a class="n-backup g-only" href="#backup"><svg class="icon"><use href="#i-backup"/></svg>Backup</a>
<a class="n-users g-only" href="#users"><svg class="icon"><use href="#i-users"/></svg>Users</a>
<a class="n-help" href="#help"><svg class="icon"><use href="#i-help"/></svg>Help <span class="tag future">1.x</span></a>
</div>
<div class="session">
<span class="session-user muted"><svg class="icon"><use href="#i-account"/></svg>User: admin</span>
<a class="n-settings" href="#settings"><svg class="icon"><use href="#i-settings"/></svg>Settings</a>
<a class="btn-ghost" href="#login"><svg class="icon"><use href="#i-out"/></svg>Sign out</a>
</div>
</nav>
<div class="stage">
<div class="phone-bar">
<label class="nav-burger" for="nav-open" title="Menu"><svg class="icon"><use href="#i-menu"/></svg></label>
<img class="phone-mark" src="../selfpost-icon.svg" width="28" height="28" alt="">
<span class="grow">
<span class="pt pt-status">Status</span>
<span class="pt pt-domains">Domains</span>
<span class="pt pt-domain">example.com</span>
<span class="pt pt-deliveries">Deliveries</span>
<span class="pt pt-delivery">Message</span>
<span class="pt pt-queue">Mail queue</span>
<span class="pt pt-log">System log</span>
<span class="pt pt-inbound">Inbound</span>
<span class="pt pt-dmarc">DMARC</span>
<span class="pt pt-backup">Backup</span>
<span class="pt pt-users">Users</span>
<span class="pt pt-help">Help</span>
<span class="pt pt-settings">Settings</span>
</span>
<label class="help-link" for="help-open" title="Help">?</label>
</div>
<!-- STATUS -->
<main id="status" class="screen ops">
<div class="status-hero attn">
<h1>Status <span class="st st-warn">warn</span></h1>
<p class="lead">3 messages deferred — first retry in about 4 minutes. Everything else is up.</p>
<form class="inline" action="#" onsubmit="return false"><button type="button">Reload configuration</button></form>
</div>
<div class="status-grid">
<article class="card attn">
<div class="card-head">
<h2>Mail queue <span class="st st-warn">warn</span></h2>
<label class="help-link" for="help-open">?</label>
</div>
<p>3 deferred, 0 bounce-hold. Oldest 18 minutes.</p>
<a class="btn" href="#mail-queue">View queue</a>
</article>
<article class="card in-only">
<div class="card-head">
<h2>Inbound <span class="st st-ok">ok</span></h2>
</div>
<p>Accepting on port 25 for 1 domain. Last forward 2 minutes ago.</p>
<a href="#inbound">Inbound domains</a>
</article>
<article class="card in-off">
<div class="card-head"><h2>Inbound</h2></div>
<p class="muted">Module off (<code>INBOUND_RELAY_ENABLE=false</code>). The outbound path is unchanged.</p>
</article>
<article class="card">
<div class="card-head">
<h2>Machine <span class="st st-ok">ok</span></h2>
<label class="help-link" for="help-open">?</label>
</div>
<div class="metric-row"><span class="k">CPU</span><span class="v"><meter value="12" min="0" max="100" low="70" high="90" optimum="10"></meter>12%</span></div>
<div class="metric-row"><span class="k">Memory</span><span class="v"><meter value="41" min="0" max="100" low="70" high="90" optimum="10"></meter>41%</span></div>
<div class="metric-row"><span class="k">Network</span><span class="v">↓ 48 KiB/s · ↑ 12 KiB/s</span></div>
</article>
<article class="card">
<div class="card-head"><h2>Processes <span class="st st-ok">ok</span></h2></div>
<div class="metric-row"><span class="k">postfix</span><span class="st st-ok">RUNNING</span></div>
<div class="metric-row"><span class="k">opendkim</span><span class="st st-ok">RUNNING</span></div>
<div class="metric-row"><span class="k">panel</span><span class="st st-ok">RUNNING</span></div>
</article>
<article class="card">
<div class="card-head">
<h2>TLS certificate <span class="st st-ok">ok</span></h2>
<label class="help-link" for="help-open">?</label>
</div>
<label>Expires</label>
<span class="code">2026-11-02 12:00 UTC</span>
<p class="muted">Lets Encrypt, 78 days left. Mounted for port 465.</p>
</article>
<article class="card">
<div class="card-head"><h2>Milter sockets <span class="st st-ok">ok</span></h2></div>
<div class="metric-row"><span class="k">opendkim</span><span class="st st-ok">ok</span></div>
<div class="metric-row"><span class="k">journal</span><span class="st st-ok">ok</span></div>
</article>
<article class="card">
<div class="card-head">
<h2>Hostname / rDNS <span class="st st-ok">ok</span></h2>
<label class="help-link" for="help-open">?</label>
</div>
<label>Server hostname</label>
<span class="code">mail.example.org</span>
<label>Forward and reverse</label>
<span class="code">mail.example.org → 203.0.113.10 → mail.example.org</span>
<form class="inline" action="#" onsubmit="return false"><button type="button">Re-check DNS</button></form>
</article>
</div>
<p class="version">SelfPost 1.2.3 · © Mixeme · <a href="#">License (AGPL-3.0)</a></p>
</main>
<!-- LOGIN -->
<main id="login" class="screen auth">
<img class="mark" src="../selfpost-stamp.svg" width="330" height="150" alt="SelfPost">
<h1>Sign in</h1>
<div class="card narrow">
<form action="#status" onsubmit="location.hash='status'; return false;">
<label for="u">Username</label>
<input id="u" name="username" autocomplete="username">
<label for="p">Password</label>
<input id="p" name="password" type="password" autocomplete="current-password">
<button type="submit">Sign in</button>
</form>
</div>
<p class="version" style="text-align:center">© Mixeme · <a href="#">License (AGPL-3.0)</a></p>
</main>
<!-- SETUP -->
<main id="setup" class="screen auth">
<img class="mark" src="../selfpost-stamp.svg" width="330" height="150" alt="SelfPost">
<h1>Create administrator</h1>
<div class="card narrow">
<p class="muted">This one-time link creates the single panel administrator. After you submit, the link stops working for good.</p>
<form action="#status" onsubmit="location.hash='status'; return false;">
<label>Username</label>
<input autocomplete="username">
<label>Password</label>
<input type="password" autocomplete="new-password">
<label>Confirm password</label>
<input type="password" autocomplete="new-password">
<button type="submit">Create administrator</button>
</form>
</div>
</main>
<!-- DOMAINS -->
<main id="domains" class="screen ops">
<div class="page-head"><h1>Domains</h1></div>
<div class="card g-only" style="margin-bottom:1rem">
<h2>Add a sending domain</h2>
<form action="#" onsubmit="return false;">
<label for="dname">Domain</label>
<div class="input-row">
<input id="dname" placeholder="example.com" autocomplete="off">
<button type="button">Add domain</button>
</div>
</form>
</div>
<div class="card">
<h2>Domains</h2>
<table class="desk-only">
<thead><tr><th>Domain</th><th>DNS</th><th>Selector</th><th>Apps</th><th></th></tr></thead>
<tbody>
<tr>
<td><a href="#domain">example.com</a></td>
<td><a class="st st-ok" href="#domain">ok</a></td>
<td class="muted">mail</td>
<td>2</td>
<td class="actions g-only"><a class="danger" href="#domain-delete">Delete</a></td>
</tr>
<tr>
<td><a href="#domain">alerts.example.com</a></td>
<td><a class="st st-warn" href="#domain">warn</a></td>
<td class="muted">mail</td>
<td>1</td>
<td class="actions g-only"><a class="danger" href="#domain-delete">Delete</a></td>
</tr>
</tbody>
</table>
<ul class="phone-list phone-only">
<li><a href="#domain"><span>example.com</span><span class="st st-ok">ok</span><span class="pair">2 apps · selector mail</span></a></li>
<li><a href="#domain"><span>alerts.example.com</span><span class="st st-warn">warn</span><span class="pair">1 app · selector mail</span></a></li>
</ul>
<p class="muted">The DNS badge is the worst of DKIM, SPF and DMARC. Open a domain for details.</p>
</div>
</main>
<!-- DOMAIN DETAIL -->
<main id="domain" class="screen ops">
<h1>example.com</h1>
<a class="back" href="#domains">&larr; All domains</a>
<div class="card credential" id="new-credential">
<h2>New application password</h2>
<p class="muted">Shown <strong>once only</strong> and not stored. Copy it now.</p>
<label>Login</label>
<div class="code-row"><span class="code">newsletter</span><button type="button" class="copy">Copy</button></div>
<label>Password</label>
<div class="code-row"><span class="code">xK.9fQ2m-pL7wR</span><button type="button" class="copy">Copy</button></div>
</div>
<div class="card" id="dns-status">
<h2>DNS status <span class="st st-ok">ok</span></h2>
<div class="check-cols">
<div class="check-col">
<label>DKIM <span class="st st-ok">ok</span></label>
<div class="field-pair host-type"><div><label>Host / name</label><span class="code">mail._domainkey.example.com</span></div><div class="field-type"><label>Type</label><span class="code">TXT</span></div></div>
</div>
<div class="check-col">
<label>SPF <span class="st st-ok">ok</span></label>
<div class="field-pair host-type"><div><label>Host / name</label><span class="code">example.com</span></div><div class="field-type"><label>Type</label><span class="code">TXT</span></div></div>
</div>
<div class="check-col">
<label>DMARC <span class="st st-ok">ok</span></label>
<div class="field-pair host-type"><div><label>Host / name</label><span class="code">_dmarc.example.com</span></div><div class="field-type"><label>Type</label><span class="code">TXT</span></div></div>
<p class="muted">p=none; rua points at SelfPost ingest.</p>
</div>
<div class="check-col">
<label>Report authorization</label>
<p class="muted">Not required (rua= is on a domain SelfPost accepts).</p>
</div>
</div>
<form class="inline" action="#" onsubmit="return false"><button type="button">Re-check</button></form>
</div>
<div class="split">
<div class="card">
<h2>DKIM and SPF records</h2>
<p class="check-col-title">DKIM</p>
<div class="field-pair host-type"><div><label>Host / name</label><div class="code-row"><span class="code">mail._domainkey.example.com</span><button type="button" class="copy">Copy</button></div></div><div class="field-type"><label>Type</label><span class="code">TXT</span></div></div>
<label>Value</label>
<div class="code-row"><span class="code">v=DKIM1; k=rsa; p=MIIBIjANBgkqh…</span><button type="button" class="copy">Copy</button></div>
<p class="check-col-title">SPF</p>
<div class="code-row"><span class="code">v=spf1 ip4:203.0.113.10 -all</span><button type="button" class="copy">Copy</button></div>
</div>
<div class="card">
<h2>DMARC record</h2>
<div class="field-pair host-type"><div><label>Host / name</label><div class="code-row"><span class="code">_dmarc.example.com</span><button type="button" class="copy">Copy</button></div></div><div class="field-type"><label>Type</label><span class="code">TXT</span></div></div>
<label>Value <span class="muted">(from Settings)</span></label>
<div class="code-row"><span class="code">v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org</span><button type="button" class="copy">Copy</button></div>
<p class="muted"><a href="#dmarc">Open DMARC reports</a> for this domain.</p>
</div>
</div>
<div class="split">
<div class="card">
<h2>Connection settings</h2>
<label>Server</label>
<div class="code-row"><span class="code">mail.example.org</span><button type="button" class="copy">Copy</button></div>
<label>Port and encryption</label>
<span class="code">465 — SSL/TLS (implicit)
587 — STARTTLS (submission)</span>
</div>
<div class="card">
<h2>Add an application</h2>
<form action="#" onsubmit="return false">
<label>Login</label>
<input placeholder="prod-server" autocomplete="off">
<label>Address mode</label>
<select><option>Any address of the domain</option><option>Specific addresses (list)</option></select>
<button type="button">Create application</button>
</form>
</div>
</div>
<div class="card">
<h2>Applications</h2>
<ul class="apps">
<li class="app-item">
<p class="app-login">newsletter</p>
<p class="app-addr muted">Any address of the domain — *@example.com</p>
<div class="actions">
<input class="panel-toggle t-edit" id="edit-1" type="checkbox" checked>
<label class="toggle for-edit" for="edit-1">Edit <span class="st st-ok">limit</span></label>
<button type="button">New password</button>
<button type="button" class="danger">Delete</button>
<div class="panel panel-edit">
<div class="check-cols">
<div class="check-col">
<p class="check-col-title">Address mode</p>
<select><option selected>Any address of the domain</option><option>Specific addresses (list)</option></select>
</div>
<div class="check-col">
<p class="check-col-title">Trusted-IP override <span class="st st-ok">active</span></p>
<textarea rows="1">203.0.113.40</textarea>
<div class="field-pair">
<div><label>Message limit</label><input value="80"></div>
<div><label>Window (seconds)</label><input value="60"></div>
</div>
</div>
</div>
</div>
</div>
</li>
<li class="app-item">
<p class="app-login">billing</p>
<p class="app-addr muted">Fixed list — invoices@example.com</p>
<div class="actions">
<button type="button">Edit</button>
<button type="button">New password</button>
<button type="button" class="danger">Delete</button>
</div>
</li>
</ul>
</div>
<div class="card">
<h2>Domain settings</h2>
<div class="check-cols">
<div class="check-col">
<p class="check-col-title">DMARC reports</p>
<label>Aggregate reports (rua=)</label>
<select><option selected>Same as Settings (dmarc@mail.example.org)</option><option>No aggregate reports</option><option>Custom address</option></select>
<button type="button">Save DMARC report settings</button>
</div>
<div class="check-col">
<p class="check-col-title">Level-2 rate limit <span class="st st-ok">active</span></p>
<div class="field-pair">
<div><label>Message limit</label><input value="40"></div>
<div><label>Window (seconds)</label><input value="60"></div>
</div>
<button type="button">Save limit</button>
</div>
</div>
</div>
<div class="split">
<div class="card">
<h2>Export domain</h2>
<p class="muted"><strong>Secret file</strong> — transfer securely, or encrypt as <code>.spde</code>.</p>
<div class="encrypt">
<label class="check"><input type="checkbox"> <span>Encrypt with a password</span></label>
<div class="encrypt-fields">
<label>Password</label><input type="password">
<label>Repeat password</label><input type="password">
</div>
</div>
<button type="button">Export domain</button>
</div>
<div class="card g-only">
<h2>Danger zone</h2>
<p class="muted">Deletes the DKIM key and every application on this domain.</p>
<a class="danger" href="#domain-delete">Delete domain</a>
</div>
</div>
</main>
<!-- DOMAIN DELETE -->
<main id="domain-delete" class="screen form">
<h1>Delete example.com</h1>
<a class="back" href="#domain">&larr; Back to example.com</a>
<div class="card">
<h2>Confirm deletion</h2>
<p>You are about to delete <strong>example.com</strong>. This will:</p>
<ul>
<li>permanently delete its DKIM signing key;</li>
<li>delete <strong>all 2 bound applications</strong>, including their SASL credentials;</li>
<li>reload OpenDKIM so the domain is no longer signed.</li>
</ul>
<p class="muted">This cannot be undone.</p>
<button type="button" class="danger">Delete example.com</button>
</div>
</main>
<!-- DELIVERIES -->
<main id="deliveries" class="screen ops">
<h1>Deliveries</h1>
<form class="toolbar" action="#" onsubmit="return false">
<div class="field"><label>Domain</label>
<select><option>All domains</option><option>example.com</option><option>alerts.example.com</option></select>
</div>
<div class="field"><label>Application</label>
<select><option>All applications</option><option>newsletter</option><option>billing</option></select>
</div>
<button type="button">Filter</button>
</form>
<div class="card">
<table class="desk-only">
<thead><tr><th>Time</th><th>From</th><th>To</th><th>Subject</th><th>Status</th><th></th></tr></thead>
<tbody>
<tr>
<td class="time muted">2026-08-15 20:14:02</td>
<td>billing@example.com</td>
<td>ada@example.net</td>
<td class="subject"><span>Invoice #4412</span></td>
<td class="status"><span class="st st-warn">deferred</span></td>
<td class="actions"><a href="#delivery">Details</a></td>
</tr>
<tr>
<td class="time muted">2026-08-15 20:11:40</td>
<td>news@example.com</td>
<td>list-bounces@example.net</td>
<td class="subject"><span>August digest</span></td>
<td class="status"><span class="st st-ok">delivered</span></td>
<td class="actions"><a href="#delivery">Details</a></td>
</tr>
<tr>
<td class="time muted">2026-08-15 19:02:11</td>
<td>alerts@alerts.example.com</td>
<td>noreply@blocked.example</td>
<td class="subject"><span>Disk 92% on web-3</span></td>
<td class="status"><span class="st st-error">bounced</span></td>
<td class="actions"><a href="#delivery">Details</a></td>
</tr>
<tr>
<td class="time muted">2026-08-15 18:44:09</td>
<td>news@example.com</td>
<td>sam@example.org</td>
<td class="subject"><span>August digest</span></td>
<td class="status"><span class="st st-ok">delivered</span></td>
<td class="actions"><a href="#delivery">Details</a></td>
</tr>
</tbody>
</table>
<ul class="phone-list phone-only">
<li>
<a href="#delivery">
<span class="st st-warn">deferred</span><span class="when">20:14</span>
<span class="pair">billing@example.com → ada@example.net</span>
<span class="subj">Invoice #4412</span>
</a>
</li>
<li>
<a href="#delivery">
<span class="st st-ok">delivered</span><span class="when">20:11</span>
<span class="pair">news@example.com → list-bounces@example.net</span>
<span class="subj">August digest</span>
</a>
</li>
<li>
<a href="#delivery">
<span class="st st-error">bounced</span><span class="when">19:02</span>
<span class="pair">alerts@alerts.example.com → noreply@blocked.example</span>
<span class="subj">Disk 92% on web-3</span>
</a>
</li>
</ul>
<p class="muted">Page 1 of 4 · <a href="#deliveries">Older →</a></p>
</div>
</main>
<!-- DELIVERY (deferred + queue-retries) -->
<main id="delivery" class="screen ops">
<h1 class="subject">Invoice #4412</h1>
<p class="route">
<span class="addr">billing@example.com</span>
<span class="arrow"></span>
<span class="addr">ada@example.net</span>
<span class="st st-warn">deferred</span>
</p>
<a class="back" href="#deliveries">&larr; Back to deliveries</a>
<div class="split">
<div class="card">
<h2>Message</h2>
<div class="facts">
<div class="fact"><span class="fact-label">Domain</span><span class="fact-value">example.com</span></div>
<div class="fact"><span class="fact-label">Application</span><span class="fact-value">billing</span></div>
<div class="fact"><span class="fact-label">Accepted</span><span class="fact-value">2026-08-15 20:14:02 UTC</span></div>
<div class="fact"><span class="fact-label">Status reported</span><span class="fact-value">2026-08-15 20:14:08 UTC</span></div>
<div class="fact"><span class="fact-label">Queue id</span><span class="fact-value mono">4C3A1E2F1A</span></div>
<div class="fact"><span class="fact-label">Journal id</span><span class="fact-value mono">1842</span></div>
</div>
</div>
<div class="card">
<h2>History</h2>
<ol class="timeline">
<li class="event lvl-ok">
<p class="event-time">2026-08-15 20:14:02 UTC</p>
<p class="event-title"><span class="st st-ok">accepted</span> Received by the relay</p>
<p class="event-detail muted">SASL login billing, queued as 4C3A1E2F1A.</p>
</li>
<li class="event lvl-warn">
<p class="event-time">2026-08-15 20:14:08 UTC</p>
<p class="event-title"><span class="st st-warn">deferred</span> Receiving MX asked to try later</p>
<p class="event-detail muted">Postfix retries: first after 5 minutes, then with increasing gaps up to 1 hour 7 minutes, for up to 5 days. There is no fixed attempt count — a deferred message stays in the queue until it is delivered or that lifetime runs out.</p>
</li>
<li class="event pending">
<p class="event-time">not yet</p>
<p class="event-title"><span class="st st-unknown">delivery</span> Waiting on the next retry</p>
</li>
</ol>
</div>
</div>
<div class="card">
<h2>Delivery log</h2>
<table class="log desk-only">
<thead><tr><th>Time</th><th>Message</th></tr></thead>
<tbody>
<tr><td class="time muted">20:14:02</td><td class="log-text">postfix/smtpd[221]: 4C3A1E2F1A: client=203.0.113.40, sasl_username=billing</td></tr>
<tr><td class="time muted">20:14:02</td><td class="log-text">postfix/cleanup[224]: 4C3A1E2F1A: message-id=&lt;4412@example.com&gt;</td></tr>
<tr><td class="time muted">20:14:08</td><td class="log-text">postfix/smtp[230]: 4C3A1E2F1A: to=&lt;ada@example.net&gt;, relay=mx.example.net[198.51.100.20]:25, delay=6, status=deferred (450 4.2.1 mailbox busy)</td></tr>
</tbody>
</table>
<div class="phone-only">
<p class="muted" style="margin:0.6rem 0 0.2rem">20:14:08</p>
<span class="code">status=deferred (450 4.2.1 mailbox busy)</span>
<p class="muted" style="margin:0.6rem 0 0.2rem">20:14:02</p>
<span class="code">client=203.0.113.40, sasl_username=billing</span>
</div>
</div>
</main>
<!-- MAIL QUEUE + queue-retries -->
<main id="mail-queue" class="screen ops">
<h1>Mail queue</h1>
<div class="card">
<h2>How delivery retries work</h2>
<p class="muted">This Postfixs policy, read once at panel start. There is no maximum attempt count — only time.</p>
<div class="facts retry-facts">
<div class="fact"><span class="fact-label">First retry</span><span class="fact-value">5 minutes</span></div>
<div class="fact"><span class="fact-label">Later retries</span><span class="fact-value">doubling, cap 1 h 7 min</span></div>
<div class="fact"><span class="fact-label">Kept in queue</span><span class="fact-value">5 days</span></div>
<div class="fact"><span class="fact-label">Then</span><span class="fact-value">bounced</span></div>
</div>
</div>
<div class="card">
<h2>Pending messages</h2>
<table class="desk-only">
<thead><tr><th>Queue id</th><th>Age</th><th>From</th><th>To</th><th>Size</th></tr></thead>
<tbody>
<tr><td class="mono">4C3A1E2F1A</td><td class="time muted">18 min</td><td>billing@example.com</td><td>ada@example.net</td><td>12 KiB</td></tr>
<tr><td class="mono">4C3A1E3010</td><td class="time muted">11 min</td><td>news@example.com</td><td>pat@slow.example</td><td>48 KiB</td></tr>
<tr><td class="mono">4C3A1E3102</td><td class="time muted">4 min</td><td>billing@example.com</td><td>ada@example.net</td><td>9 KiB</td></tr>
</tbody>
</table>
<ul class="phone-list phone-only">
<li><a href="#delivery"><span>4C3A1E2F1A</span><span class="when">18 min</span><span class="pair">billing@example.com → ada@example.net</span></a></li>
<li><a href="#delivery"><span>4C3A1E3010</span><span class="when">11 min</span><span class="pair">news@example.com → pat@slow.example</span></a></li>
<li><a href="#delivery"><span>4C3A1E3102</span><span class="when">4 min</span><span class="pair">billing@example.com → ada@example.net</span></a></li>
</ul>
<label>postqueue -p</label>
<span class="code">-Queue ID- --Size-- ----Arrival Time---- -Sender/Recipient-------
4C3A1E2F1A* 12288 Sat Aug 15 20:14:02 billing@example.com
ada@example.net
4C3A1E3010 49152 Sat Aug 15 20:21:18 news@example.com
pat@slow.example
-- 3 Kbytes in 3 Requests.</span>
</div>
</main>
<!-- SYSTEM LOG -->
<main id="system-log" class="screen ops">
<h1>System log</h1>
<div class="card">
<h2>Recent log entries</h2>
<span class="code">Aug 15 20:14:08 mail postfix/smtp[230]: 4C3A1E2F1A: to=&lt;ada@example.net&gt;, relay=mx.example.net[198.51.100.20]:25, delay=6, delays=0.2/0.1/0.4/5.3, dsn=4.2.1, status=deferred (450 4.2.1 mailbox busy)
Aug 15 20:14:02 mail postfix/qmgr[119]: 4C3A1E2F1A: from=&lt;billing@example.com&gt;, size=12288, nrcpt=1 (queue active)
Aug 15 20:14:02 mail postfix/smtpd[221]: 4C3A1E2F1A: client=203.0.113.40[203.0.113.40], sasl_method=PLAIN, sasl_username=billing
Aug 15 20:11:40 mail postfix/smtp[228]: 4B19D0AA01: to=&lt;list-bounces@example.net&gt;, relay=mx.example.net[198.51.100.20]:25, delay=0.9, status=sent (250 2.0.0 Ok)
Aug 15 20:02:11 mail postfix/smtp[226]: 4B19C0BB12: to=&lt;noreply@blocked.example&gt;, status=bounced (host mx.blocked.example[203.0.113.99] said: 550 5.7.1 rejected)</span>
</div>
</main>
<!-- BACKUP -->
<main id="backup" class="screen form">
<h1>Backup &amp; migration</h1>
<div class="card">
<h2>Full backup</h2>
<p class="muted">Download a full backup of all persistent state. Restore into a container of the <strong>same SelfPost version</strong>. TLS certificates and the mail queue are not included. The file is a secret.</p>
<div class="encrypt">
<label class="check"><input type="checkbox" checked> <span>Encrypt with a password</span></label>
<div class="encrypt-fields">
<label>Password</label><input type="password" placeholder="at least 8 characters">
<label>Repeat password</label><input type="password">
<p class="muted">Keep this password: without it the file cannot be opened.</p>
</div>
</div>
<button type="button">Download full backup</button>
</div>
<div class="card">
<h2>Import a domain</h2>
<p class="muted">Plain <code>.json</code> or encrypted <code>.spde</code>.</p>
<label>Domain export file</label>
<input type="file">
<button type="button">Import domain</button>
</div>
</main>
<!-- USERS -->
<main id="users" class="screen form">
<h1>Users</h1>
<div class="card">
<p><a href="#user-form">Create user</a></p>
<table>
<thead><tr><th>Username</th><th>Role</th><th>Domains</th><th></th></tr></thead>
<tbody>
<tr><td>admin</td><td>Global</td><td class="muted">All</td><td class="actions"><a href="#user-form">Edit</a></td></tr>
<tr><td>ops-alerts</td><td>Domain admin</td><td class="muted">alerts.example.com</td><td class="actions"><a href="#user-form">Edit</a></td></tr>
</tbody>
</table>
</div>
</main>
<!-- USER FORM -->
<main id="user-form" class="screen form">
<h1>Edit user</h1>
<a class="back" href="#users">&larr; Back to users</a>
<div class="card narrow">
<form action="#" onsubmit="return false">
<label>Username</label>
<input value="ops-alerts">
<label>Password (leave empty to keep)</label>
<input type="password">
<label>Role</label>
<select><option selected>Domain administrator</option><option>Global administrator</option></select>
<fieldset>
<legend>Assigned domains</legend>
<p class="muted">Required for domain administrators.</p>
<label class="check"><input type="checkbox"> example.com</label>
<label class="check"><input type="checkbox" checked> alerts.example.com</label>
</fieldset>
<button type="button">Save</button>
</form>
<a class="danger" href="#user-delete">Delete user</a>
</div>
</main>
<!-- USER DELETE -->
<main id="user-delete" class="screen form">
<h1>Delete ops-alerts</h1>
<a class="back" href="#user-form">&larr; Back to ops-alerts</a>
<div class="card narrow">
<h2>Confirm deletion</h2>
<p>You are about to delete the panel user <strong>ops-alerts</strong>. A signed-in session for this user stops working immediately.</p>
<button type="button" class="danger">Delete ops-alerts</button>
</div>
</main>
<!-- SETTINGS -->
<main id="settings" class="screen form">
<h1>Settings</h1>
<div class="card">
<h2>Panel credentials</h2>
<p class="muted">These are the credentials for this control panel only. Applications keep their own logins.</p>
<label>Username</label><input value="admin" autocomplete="username">
<label>Current password</label><input type="password" autocomplete="current-password">
<label>New password</label><input type="password" autocomplete="new-password">
<label>Confirm new password</label><input type="password">
</div>
<div class="card g-only">
<h2>DMARC aggregate reports</h2>
<p class="muted">Default <code>rua=</code> for every sending domain. When ingest is on, this can be an address SelfPost accepts.</p>
<label>Default report address</label>
<input type="email" value="dmarc@mail.example.org">
<p class="muted"><a href="#dmarc">DMARC reports</a> in the panel.</p>
</div>
<button type="button">Save changes</button>
<div class="card" id="rate-limits">
<h2>Sending rate limits</h2>
<p class="muted">Level 1 is set in Compose. Domain and application ceilings live on each domains page.</p>
<div class="facts">
<div class="fact"><span class="fact-label">Level 1 / IP</span><span class="fact-value">100 / 60s</span></div>
<div class="fact"><span class="fact-label">Level 2 domain</span><span class="fact-value">optional, ≤ L1</span></div>
<div class="fact"><span class="fact-label">App override</span><span class="fact-value">trusted IPs only</span></div>
</div>
</div>
</main>
<!-- INBOUND -->
<main id="inbound" class="screen ops">
<div class="page-head">
<h1>Inbound <span class="tag">1.x</span></h1>
</div>
<p class="muted">Backup-MX / forwarder. Accepts on port 25 only for listed domains; unknown recipients are rejected at RCPT. Off by default in Compose.</p>
<div class="card" style="margin-bottom:1rem">
<h2>Add inbound domain</h2>
<form action="#" onsubmit="return false">
<label>Domain</label>
<div class="input-row">
<input placeholder="lists.example.com">
<button type="button">Add domain</button>
</div>
</form>
</div>
<div class="card">
<h2>Forwarding</h2>
<table class="desk-only">
<thead><tr><th>Domain</th><th>Upstream</th><th>Recipients</th><th>TLS</th><th></th></tr></thead>
<tbody>
<tr>
<td><a href="#inbound-domain">lists.example.com</a></td>
<td class="muted">10.0.0.8:25</td>
<td>12 listed</td>
<td><span class="st st-ok">required</span></td>
<td class="actions"><a href="#inbound-domain">Edit</a></td>
</tr>
</tbody>
</table>
<ul class="phone-list phone-only">
<li><a href="#inbound-domain"><span>lists.example.com</span><span class="st st-ok">tls</span><span class="pair">→ 10.0.0.8:25 · 12 recipients</span></a></li>
</ul>
</div>
</main>
<!-- INBOUND DOMAIN -->
<main id="inbound-domain" class="screen ops">
<h1>lists.example.com</h1>
<a class="back" href="#inbound">&larr; All inbound domains</a>
<div class="split">
<div class="card">
<h2>Upstream</h2>
<p class="muted">Where accepted mail is handed off. Not a mailbox.</p>
<label>Host</label><input value="10.0.0.8">
<label>Port</label><input value="25">
<label>TLS to upstream</label>
<select><option>Opportunistic</option><option selected>Required</option><option>Off</option></select>
<button type="button">Save upstream</button>
</div>
<div class="card">
<h2>DNS — MX</h2>
<p class="muted">Unlike outbound, inbound needs an MX pointing at this server.</p>
<label>Host / name</label>
<div class="code-row"><span class="code">lists.example.com</span><button type="button" class="copy">Copy</button></div>
<label>Value</label>
<div class="code-row"><span class="code">10 mail.example.org.</span><button type="button" class="copy">Copy</button></div>
</div>
</div>
<div class="card">
<h2>Valid recipients</h2>
<p class="muted">Unknown recipients are rejected at RCPT so this relay does not generate backscatter. One address per line; empty means reject all until listed.</p>
<textarea rows="6">staff@lists.example.com
postmaster@lists.example.com
abuse@lists.example.com</textarea>
<button type="button">Save recipients</button>
</div>
<div class="card">
<h2>Danger zone</h2>
<p class="muted">Stops accepting mail for this domain. Does not touch outbound sending domains.</p>
<button type="button" class="danger">Remove inbound domain</button>
</div>
</main>
<!-- DMARC (candidate) -->
<main id="dmarc" class="screen ops">
<div class="page-head">
<h1>DMARC reports <span class="tag future">candidate</span></h1>
</div>
<p class="muted">Aggregate reports SelfPost accepted for <code>rua=</code>. Forensic (<code>ruf=</code>) is out of scope.</p>
<div class="status-grid" style="margin-bottom:1rem">
<article class="card">
<h2>Ingest <span class="st st-ok">ok</span></h2>
<p>Last report 6 hours ago. 14 kept, 0 parse failures this week.</p>
</article>
<article class="card">
<h2>example.com <span class="st st-ok">pass</span></h2>
<p>98% aligned last 7 days. Tightening <code>p=</code> looks reasonable.</p>
</article>
<article class="card attn">
<h2>alerts.example.com <span class="st st-warn">fail</span></h2>
<p>A third-party sender is not in SPF/DKIM. See sources.</p>
</article>
</div>
<div class="card">
<h2>Sources · last 7 days</h2>
<table class="desk-only">
<thead><tr><th>Domain</th><th>Source</th><th>Pass</th><th>Fail</th><th>Disposition</th></tr></thead>
<tbody>
<tr><td>example.com</td><td class="muted">203.0.113.10 (this relay)</td><td>412</td><td>2</td><td><span class="st st-ok">none</span></td></tr>
<tr><td>example.com</td><td class="muted">google.com / 66.102.0.0/20</td><td>0</td><td>6</td><td><span class="st st-warn">none</span></td></tr>
<tr><td>alerts.example.com</td><td class="muted">203.0.113.10 (this relay)</td><td>88</td><td>0</td><td><span class="st st-ok">none</span></td></tr>
<tr><td>alerts.example.com</td><td class="muted">unknown / 198.51.100.80</td><td>0</td><td>19</td><td><span class="st st-error">none</span></td></tr>
</tbody>
</table>
<ul class="phone-list phone-only">
<li><a href="#domain"><span>example.com · this relay</span><span class="st st-ok">412 pass</span></a></li>
<li><a href="#domain"><span>alerts.example.com · 198.51.100.80</span><span class="st st-error">19 fail</span></a></li>
</ul>
</div>
</main>
<!-- HELP (candidate) -->
<main id="help" class="screen form">
<div class="page-head">
<h1>Help <span class="tag future">candidate</span></h1>
</div>
<p class="muted">Short operator notes inside the panel — not a second copy of the full guide. Seeded from the Status blurbs that left the cards.</p>
<div class="card">
<h2>On this panel</h2>
<ul>
<li><a href="#help">Machine</a> — kernel counters and the rate window</li>
<li><a href="#help">TLS certificate</a> — port 465, reverse-proxy mount</li>
<li><a href="#help">Hostname / reverse DNS</a> — forward-confirmed PTR at the hosting provider</li>
<li><a href="#help">Mail queue retries</a> — time-based, no attempt budget</li>
<li><a href="#help">Inbound</a> — not mailboxes; reject unknown recipients</li>
</ul>
<p class="muted">The same texts open in the drawer from a cards «?» — so a Status page can stay dense.</p>
</div>
<div class="card">
<h2>Machine</h2>
<p>CPU and memory are the containers own readings, not the hosts spare capacity. Network is a short window, not a daily total. High CPU with an empty queue usually means something else on the box — not SelfPost “being slow to send”.</p>
</div>
<div class="card">
<h2>TLS certificate</h2>
<p>Port 465 presents the certificate the reverse proxy (or the image) mounted. The panel does not issue certificates. A warn here is “expires soon”; an error is “missing or unreadable”, and clients will refuse submission.</p>
</div>
<div class="card">
<h2>Hostname / reverse DNS</h2>
<p>Forward-confirmed reverse DNS: the A/AAAA for <code>SELFPOST_HOSTNAME</code> must reverse to that same name. PTR is set at the hosting provider, not in this panel. Receiving networks use this pair as a cheap reputation check.</p>
</div>
</main>
</div>
</div>
<label class="help-scrim" for="help-open"></label>
<aside class="help-drawer">
<label class="help-close" for="help-open">Close</label>
<h2>Machine</h2>
<p>CPU and memory are the containers readings. Network is a short rate window. These numbers explain load; they do not replace the queue.</p>
<h2>TLS certificate</h2>
<p>Presented on port 465. Issued and renewed outside SelfPost. Warn = expires soon; error = missing, and submission will fail.</p>
<h2>Hostname / reverse DNS</h2>
<p>The hostname must forward to this IP and the PTR must come back to the same name. Set PTR at the provider.</p>
<h2>Mail queue</h2>
<p>Deferred mail is retried on a time schedule (first delay, doubling cap, queue lifetime). There is no “attempt 3 of N”.</p>
</aside>
<script>
(function () {
var q = new URLSearchParams(location.search);
if (q.get("view") === "phone") document.getElementById("vp-phone").checked = true;
if (q.get("role") === "domain") document.getElementById("role-domain").checked = true;
if (q.get("theme") === "dark") document.getElementById("theme-dark").checked = true;
if (q.get("inbound") === "0") document.getElementById("feat-inbound").checked = false;
function gate() {
if (!document.getElementById("role-domain").checked) return;
var h = location.hash.slice(1);
var blocked = {
status: 1, "mail-queue": 1, "system-log": 1, backup: 1, users: 1,
"user-form": 1, "user-delete": 1, inbound: 1, "inbound-domain": 1, dmarc: 1
};
if (blocked[h] || !h) location.hash = "domains";
var brand = document.querySelector(".nav .brand");
if (brand) brand.setAttribute("href", "#domains");
}
gate();
document.getElementById("role-global").addEventListener("change", function () {
var brand = document.querySelector(".nav .brand");
if (brand) brand.setAttribute("href", "#status");
});
document.getElementById("role-domain").addEventListener("change", gate);
window.addEventListener("hashchange", gate);
document.querySelectorAll(".nav a").forEach(function (a) {
a.addEventListener("click", function () {
document.getElementById("nav-open").checked = false;
});
});
})();
</script>
</body>
</html>
+173
View File
@@ -0,0 +1,173 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SelfPost — макеты панели</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
</head>
<body class="doc">
<div class="wrap">
<header>
<p class="eyebrow">макеты · не панель</p>
<h1>Self<b>Post</b> — полное обновление интерфейса</h1>
<p>Click-through прототип всех экранов: текущая панель, согласованный roadmap и кандидаты. Знак и палитра из листа утверждения не меняются. Это HTML в <code>docs/assets/panel-ui/</code>, не вёрстка <code>internal/web</code>.</p>
<div class="cta-row">
<a class="cta" href="app.html#status">Открыть прототип</a>
<a class="cta quiet" href="app.html?view=phone#status">Аварийный телефон</a>
<a class="cta quiet" href="app.html?theme=dark#status">Status, тёмная тема</a>
</div>
</header>
<section>
<h2>Ширина окна</h2>
<p>Сейчас оболочка центрируется, колонка контента упирается в <code>64rem</code>, формы ещё уже — <code>48rem</code>. На широком мониторе поля пустые. Растянуть всё на 100% окна нельзя: поля Settings и Backup становятся нечитаемыми.</p>
<h3>Гибрид</h3>
<p>Навигация прижата влево. Ops-страницы (Status, Deliveries, очередь, лог, DMARC, inbound) занимают остаток до <strong>90rem (~1440px)</strong>. Формы остаются <strong>42rem и выровнены влево</strong> в колонке, а не висят по центру пустого поля. Login и setup — по-прежнему узкий центрированный блок.</p>
<div class="compare">
<div>
<div class="row">сейчас, окно ~1600px</div>
<div class="bar">
<i class="navc">nav</i>
<i class="col" style="width:42%">64rem</i>
<i class="empty">пусто</i>
</div>
</div>
<div>
<div class="row">макет, ops-страница</div>
<div class="bar">
<i class="navc">nav</i>
<i class="fill">контент до 90rem</i>
</div>
</div>
<div>
<div class="row">макет, форма</div>
<div class="bar">
<i class="navc">nav</i>
<i class="col" style="width:28%">42rem</i>
<i class="empty">поле не растягивает инпуты</i>
</div>
</div>
</div>
<div class="note">На телефоне это не «узкий десктоп». Таблицы из шести колонок складываются в список. Навигация — выезжающая колонка на CSS-checkbox, без обязательного JavaScript (как панели Edit у приложений уже сейчас).</div>
</section>
<section>
<h2>Информационная архитектура</h2>
<div class="ia">
<div>
<h3>Global administrator</h3>
<ul>
<li>Status</li>
<li>Domains → domain → delete</li>
<li>Deliveries → message</li>
<li>Mail queue</li>
<li>System log</li>
<li>Inbound <span class="tag">1.x</span> — скрыт, пока <code>INBOUND_RELAY_ENABLE</code> выключен</li>
<li>DMARC reports <span class="tag future">candidate</span></li>
<li>Backup, Users, Settings</li>
<li>Help <span class="tag future">candidate</span> — пункт навбара и drawer с карточек Status</li>
</ul>
</div>
<div>
<h3>Domain-admin</h3>
<ul>
<li>Domains (только назначенные) → domain</li>
<li>Deliveries → message, включая пояснение retry</li>
<li>Settings — только учётные данные панели</li>
<li>Help drawer</li>
<li>Нет Status, очереди, system log, Backup, Users, inbound, глобального DMARC hub</li>
</ul>
</div>
</div>
<p class="muted" style="margin-top:1rem">В прототипе роль, inbound-флаг, ширина и тема переключаются в шапке. <code>contributing</code> и <code>schema-squash</code> экранов не имеют.</p>
</section>
<section>
<h2>Знак и новые иконки</h2>
<p>Утверждённый stamp не заменяется. Для узкой шапки телефона предлагается уже существующий SP-значок (фавикон), а не компактный wordmark: на 390px высота важнее, чем прочитать «SelfPost» второй раз.</p>
<div class="mark-row">
<figure>
<img src="../selfpost-stamp.svg" width="220" height="100" alt="Полный знак">
<figcaption>эталон · login / setup</figcaption>
</figure>
<figure>
<img src="../selfpost-stamp-compact.svg" width="160" height="73" alt="Компактный знак">
<figcaption>колонка навбара</figcaption>
</figure>
<figure>
<img src="../selfpost-icon.svg" width="64" height="64" alt="SP">
<figcaption>телефон · шапка drawer</figcaption>
</figure>
</div>
<h3>Иконки новых пунктов</h3>
<p class="muted">Тот же штрих 1.5, что у Status / Domains. Не пиктограммы-эмодзи и не заливка.</p>
<div class="icons-row">
<div class="icon-card">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 9.5h11"/><path d="M8 2.75v6.2"/><path d="M5.4 6.4 8 9.05 10.6 6.4"/><path d="M3.2 12.6h9.6"/></svg>
<span>Inbound</span>
</div>
<div class="icon-card">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 1.85 2.75 3.7v4.2c0 3.15 2.15 5.2 5.25 6.25 3.1-1.05 5.25-3.1 5.25-6.25V3.7Z"/><path d="M5.4 8.05 7.15 9.8 10.7 6.2"/></svg>
<span>DMARC</span>
</div>
<div class="icon-card">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M8 7.2V11.4"/><path d="M8 5.05v.01"/></svg>
<span>Help</span>
</div>
</div>
</section>
<section>
<h2>Экраны</h2>
<h3>Вход</h3>
<div class="screen-index">
<a href="app.html#login">Sign in</a>
<a href="app.html#setup">Create administrator</a>
</div>
<h3>Текущая панель</h3>
<div class="screen-index">
<a href="app.html#status">Status</a>
<a href="app.html#domains">Domains</a>
<a href="app.html#domain">Domain detail</a>
<a href="app.html#domain-delete">Delete domain</a>
<a href="app.html#deliveries">Deliveries</a>
<a href="app.html#delivery">Delivery (deferred + retries)</a>
<a href="app.html#mail-queue">Mail queue + retry policy</a>
<a href="app.html#system-log">System log</a>
<a href="app.html#backup">Backup</a>
<a href="app.html#users">Users</a>
<a href="app.html#user-form">Edit user</a>
<a href="app.html#user-delete">Delete user</a>
<a href="app.html#settings">Settings</a>
</div>
<h3>Roadmap</h3>
<div class="screen-index">
<a href="app.html#mail-queue">queue-retries — карточка на Mail queue</a>
<a href="app.html#delivery">queue-retries — history deferred</a>
<a href="app.html#inbound">Inbound list</a>
<a href="app.html#inbound-domain">Inbound domain</a>
<a href="app.html?inbound=0#status">Inbound выключен (пункта нет)</a>
<a href="app.html#dmarc">DMARC reports <span class="tag future">candidate</span></a>
<a href="app.html#help">Help <span class="tag future">candidate</span></a>
</div>
<h3>Аварийный телефон</h3>
<div class="screen-index">
<a href="app.html?view=phone#status">Status 390px — overall и warn сверху</a>
<a href="app.html?view=phone#mail-queue">Mail queue 390px</a>
<a href="app.html?view=phone#deliveries">Deliveries 390px — список, не таблица</a>
<a href="app.html?view=phone#delivery">Delivery 390px</a>
</div>
</section>
<section style="border-bottom:none">
<h2>Как смотреть</h2>
<p>Откройте <a href="app.html#status">app.html</a> в браузере. Шапка прототипа — не часть панели: роль, Desktop/Phone, inbound, светлая/тёмная. Копирайт экранов английский, как в продукте. Drawer Help открывается с карточек Status (кнопка «?») или из навбара.</p>
<p class="muted">После утверждения макетов вёрстка <code>panel.css</code> и шаблонов — отдельная задача. CSP и progressive enhancement в этом HTML не воспроизводятся один в один: здесь допустимы вещи, которые в панели останутся в файле стилей.</p>
</section>
</div>
</body>
</html>
+668
View File
@@ -0,0 +1,668 @@
/* SelfPost panel UI mockups. Tokens match internal/web/view/static/panel.css.
This file is a design artifact, not the panel stylesheet. */
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@200;300;400;500;600;700&display=swap");
:root {
color-scheme: light dark;
--font-sans: "IBM Plex Sans", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
--bg: #f4f2ed; --fg: #12161c;
--muted: #6b7684;
--card-bg: #fff;
--border: #dedcd7;
--control-border: #cbc8c1;
--input-bg: #fff;
--code-bg: #efede9;
--surface-bg: #eae7e0; --surface-bg-hover: #e0dcd3; --surface-open-bg: #d8d3c8;
--nav-active-bg: #ede4de;
--accent-fill: #7a3b2e; --accent-fill-hover: #632f25; --accent-text: #7a3b2e;
--on-accent: #fff;
--flash-bg: #edf6ef; --flash-border: #c2e0cc; --flash-fg: #2c6b43;
--credential-bg: #fcf6e4; --credential-border: #e0c874;
--danger-fill: #b42318; --danger-fill-hover: #912018;
--danger-bg: #fbedea; --danger-border: #efccc4; --danger-fg: #b42318; --danger-bg-hover: #f7dfda;
--st-ok-bg: #edf6ef; --st-ok-fg: #2c6b43; --st-ok-border: #c2e0cc;
--st-warn-bg: #fbf2e2; --st-warn-fg: #8a5510; --st-warn-border: #ebd5a6;
--st-error-bg: #fbedea; --st-error-fg: #b42318; --st-error-border: #efccc4;
--st-unknown-bg: #efede9; --st-unknown-fg: #6b7684; --st-unknown-border: #dedcd7;
--nav-w: 14rem;
--ops-max: 90rem;
--form-max: 42rem;
--auth-max: 24rem;
--gallery-h: 3.1rem;
}
.dark-tokens {
--bg: #16181b; --fg: #e9e6e0;
--muted: #9aa1a9;
--card-bg: #1d2024;
--border: #2c2f34;
--control-border: #3a3e44;
--input-bg: #14161a;
--code-bg: #14161a;
--surface-bg: #23262b; --surface-bg-hover: #2c3036; --surface-open-bg: #343941;
--nav-active-bg: #2a1f1b;
--accent-fill: #8e4535; --accent-fill-hover: #a0503e; --accent-text: #ce7b66;
--flash-bg: #132318; --flash-border: #22452f; --flash-fg: #7fcb9b;
--credential-bg: #26210d; --credential-border: #5e5013;
--danger-fill: #9b2c22; --danger-fill-hover: #b0342a;
--danger-bg: #2a1412; --danger-border: #5e2721; --danger-fg: #eb9b92; --danger-bg-hover: #381a17;
--st-ok-bg: #132318; --st-ok-fg: #7fcb9b; --st-ok-border: #22452f;
--st-warn-bg: #2a2109; --st-warn-fg: #e5be72; --st-warn-border: #5e4b12;
--st-error-bg: #2a1412; --st-error-fg: #eb9b92; --st-error-border: #5e2721;
--st-unknown-bg: #23262b; --st-unknown-fg: #9aa1a9; --st-unknown-border: #2c2f34;
}
@media (prefers-color-scheme: dark) {
:root { color-scheme: dark; }
html:not(.force-light) { }
}
* { box-sizing: border-box; }
html { scrollbar-gutter: stable; }
body {
font: 400 15px/1.5 var(--font-sans);
margin: 0; background: var(--bg); color: var(--fg);
}
.ctrl { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
/* —— Gallery chrome (index + prototype toolbar) —— */
.gallery {
position: sticky; top: 0; z-index: 40;
display: flex; flex-wrap: wrap; align-items: center; gap: 0.55rem 1rem;
min-height: var(--gallery-h);
padding: 0.45rem 1rem;
background: var(--surface-bg);
border-bottom: 1px solid var(--border);
font-size: 0.82rem;
}
.gallery a { color: var(--accent-text); }
.gallery .brand-mini {
font-family: var(--font-mono); font-size: 0.7rem; font-weight: 600;
letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent-text);
text-decoration: none;
}
.gallery .seg {
display: flex; flex-wrap: wrap; align-items: center; gap: 0.2rem 0.35rem;
}
.gallery .seg > span { color: var(--muted); margin-right: 0.15rem; }
.gallery .seg label {
margin: 0; font-weight: 500; cursor: pointer;
padding: 0.2rem 0.55rem; border: 1px solid var(--control-border);
border-radius: 5px; background: var(--card-bg); color: var(--fg);
}
#role-global:checked ~ .gallery label[for="role-global"],
#role-domain:checked ~ .gallery label[for="role-domain"],
#vp-desktop:checked ~ .gallery label[for="vp-desktop"],
#vp-phone:checked ~ .gallery label[for="vp-phone"],
#theme-light:checked ~ .gallery label[for="theme-light"],
#theme-dark:checked ~ .gallery label[for="theme-dark"] {
background: var(--nav-active-bg); color: var(--accent-text);
border-color: var(--accent-fill); font-weight: 600;
}
.gallery .check-lab {
display: flex; align-items: center; gap: 0.35rem; margin: 0; font-weight: 500; cursor: pointer;
}
.gallery .check-lab input { width: auto; margin: 0; }
.tag {
display: inline-block; font-family: var(--font-mono); font-size: 0.68rem;
font-weight: 500; letter-spacing: 0.06em; text-transform: uppercase;
padding: 0.12rem 0.4rem; border-radius: 4px;
background: var(--surface-bg); color: var(--muted); border: 1px solid var(--border);
}
.tag.future { color: var(--st-warn-fg); border-color: var(--st-warn-border); background: var(--st-warn-bg); }
body:has(#theme-dark:checked) { background: #16181b; color: #e9e6e0; }
#theme-dark:checked ~ .gallery,
#theme-dark:checked ~ .app,
#theme-dark:checked ~ .help-drawer {
--bg: #16181b; --fg: #e9e6e0;
--muted: #9aa1a9;
--card-bg: #1d2024;
--border: #2c2f34;
--control-border: #3a3e44;
--input-bg: #14161a;
--code-bg: #14161a;
--surface-bg: #23262b; --surface-bg-hover: #2c3036; --surface-open-bg: #343941;
--nav-active-bg: #2a1f1b;
--accent-fill: #8e4535; --accent-fill-hover: #a0503e; --accent-text: #ce7b66;
--flash-bg: #132318; --flash-border: #22452f; --flash-fg: #7fcb9b;
--credential-bg: #26210d; --credential-border: #5e5013;
--danger-fill: #9b2c22; --danger-fill-hover: #b0342a;
--danger-bg: #2a1412; --danger-border: #5e2721; --danger-fg: #eb9b92; --danger-bg-hover: #381a17;
--st-ok-bg: #132318; --st-ok-fg: #7fcb9b; --st-ok-border: #22452f;
--st-warn-bg: #2a2109; --st-warn-fg: #e5be72; --st-warn-border: #5e4b12;
--st-error-bg: #2a1412; --st-error-fg: #eb9b92; --st-error-border: #5e2721;
--st-unknown-bg: #23262b; --st-unknown-fg: #9aa1a9; --st-unknown-border: #2c2f34;
background: var(--bg); color: var(--fg);
}
/* —— Prototype shell: nav docked left, content uses remaining width —— */
.app {
display: flex; align-items: flex-start; min-height: calc(100vh - var(--gallery-h));
background: var(--bg);
}
.stage { flex: 1 1 auto; min-width: 0; }
.nav {
position: sticky; top: var(--gallery-h); align-self: stretch;
flex: none; width: var(--nav-w);
display: flex; flex-direction: column; gap: 0.75rem;
padding: 1.1rem 0.85rem 1.4rem;
border-right: 1px solid var(--border);
background: var(--bg);
max-height: calc(100vh - var(--gallery-h)); overflow-y: auto;
}
.nav .links, .nav .session { display: flex; flex-direction: column; gap: 0.1rem; }
.nav .session { margin-top: auto; padding-top: 0.75rem; border-top: 1px solid var(--border); }
.nav .session .muted { padding: 0 0.6rem; font-size: 0.85rem; overflow-wrap: anywhere; }
.nav .session-user { display: flex; align-items: center; gap: 0.5rem; }
.nav .brand { padding: 0; display: block; }
.nav .brand img { display: block; width: 100%; height: auto; }
.nav a, .nav [aria-current], .nav .current {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.35rem 0.6rem; border-radius: 5px; text-decoration: none;
color: var(--fg);
}
.nav a:hover { background: var(--surface-bg); }
.nav [aria-current], .nav .current {
font-weight: 600; color: var(--accent-text); background: var(--nav-active-bg);
box-shadow: inset 2px 0 0 var(--accent-fill);
}
html:not(:has(.screen:target)) .n-status,
body:has(#status:target) .n-status,
body:has(#domains:target) .n-domains,
body:has(#domain:target) .n-domains,
body:has(#domain-delete:target) .n-domains,
body:has(#deliveries:target) .n-deliveries,
body:has(#delivery:target) .n-deliveries,
body:has(#mail-queue:target) .n-queue,
body:has(#system-log:target) .n-log,
body:has(#inbound:target) .n-inbound,
body:has(#inbound-domain:target) .n-inbound,
body:has(#dmarc:target) .n-dmarc,
body:has(#backup:target) .n-backup,
body:has(#users:target) .n-users,
body:has(#user-form:target) .n-users,
body:has(#user-delete:target) .n-users,
body:has(#help:target) .n-help,
body:has(#settings:target) .n-settings {
font-weight: 600; color: var(--accent-text); background: var(--nav-active-bg);
box-shadow: inset 2px 0 0 var(--accent-fill);
}
.pt { display: none; }
html:not(:has(.screen:target)) .pt-status,
body:has(#status:target) .pt-status,
body:has(#login:target) .pt-login,
body:has(#setup:target) .pt-setup,
body:has(#domains:target) .pt-domains,
body:has(#domain:target) .pt-domain,
body:has(#domain-delete:target) .pt-domain,
body:has(#deliveries:target) .pt-deliveries,
body:has(#delivery:target) .pt-delivery,
body:has(#mail-queue:target) .pt-queue,
body:has(#system-log:target) .pt-log,
body:has(#inbound:target) .pt-inbound,
body:has(#inbound-domain:target) .pt-inbound,
body:has(#dmarc:target) .pt-dmarc,
body:has(#backup:target) .pt-backup,
body:has(#users:target) .pt-users,
body:has(#user-form:target) .pt-users,
body:has(#user-delete:target) .pt-users,
body:has(#help:target) .pt-help,
body:has(#settings:target) .pt-settings { display: inline; }
.nav .icon { width: 1rem; height: 1rem; flex: none; }
.nav button, .nav .btn-ghost {
display: flex; align-items: center; gap: 0.5rem;
margin: 0; padding: 0.35rem 0.6rem; font: inherit; font-size: 0.95rem; font-weight: 400;
color: var(--danger-fg); background: var(--danger-bg); border: 1px solid var(--danger-border);
border-radius: 5px; cursor: pointer; text-decoration: none; width: 100%;
}
.nav button:hover, .nav .btn-ghost:hover { background: var(--danger-bg-hover); }
.phone-bar { display: none; }
.phone-bar .icon { width: 1.1rem; height: 1.1rem; }
.mono { font-family: var(--font-mono); }
.sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
#feat-inbound:checked ~ .gallery label[for="feat-inbound"] {
background: var(--nav-active-bg); color: var(--accent-text);
border-color: var(--accent-fill);
}
.gallery label[for="feat-inbound"] {
margin: 0; font-weight: 500; cursor: pointer;
padding: 0.2rem 0.55rem; border: 1px solid var(--control-border);
border-radius: 5px; background: var(--card-bg); color: var(--fg);
}
#role-domain:checked ~ .gallery .g-only { display: none; }
main {
flex: 1 1 auto; min-width: 0; width: 100%;
padding: 1.5rem 1.5rem 2.5rem;
}
main.ops { max-width: var(--ops-max); }
main.form { max-width: calc(var(--form-max) + 3rem); }
main.form .measure, main.form > :not(.phone-bar) { max-width: var(--form-max); }
main.auth {
max-width: none; display: flex; flex-direction: column; align-items: center;
padding-top: 3rem;
}
main.auth > * { width: 100%; max-width: var(--auth-max); margin-left: auto; margin-right: auto; }
.screen { display: none; }
.screen:target { display: block; }
main.auth.screen:target { display: flex; }
html:not(:has(.screen:target)) #status { display: block; }
body:has(#login:target) .nav,
body:has(#setup:target) .nav,
body:has(#login:target) .phone-bar,
body:has(#setup:target) .phone-bar { display: none !important; }
body:has(#login:target) .app,
body:has(#setup:target) .app { display: block; }
#role-domain:checked ~ .app .g-only { display: none !important; }
#feat-inbound:not(:checked) ~ .app .in-only { display: none !important; }
#feat-inbound:checked ~ .app .in-off { display: none !important; }
h1 { font-size: 1.5rem; font-weight: 300; letter-spacing: -0.01em; margin: 0 0 1rem; }
h1.subject { overflow-wrap: anywhere; }
h2 { font-size: 1.05rem; font-weight: 600; margin: 0 0 0.4rem; }
.mark { display: block; width: 100%; max-width: 24rem; height: auto; margin-bottom: 1.4rem; }
.card {
background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px;
padding: 1.25rem 1.4rem; margin: 0;
}
.card.narrow { max-width: var(--auth-max); }
.card + .card { margin-top: 1rem; }
.card.credential { border-color: var(--credential-border); background: var(--credential-bg); }
.muted { color: var(--muted); }
.error { color: var(--danger-fg); margin: 0.6rem 0 0; font-weight: 600; }
a { color: var(--accent-text); }
.back { display: block; margin: -0.4rem 0 1rem; }
.flash {
background: var(--flash-bg); border: 1px solid var(--flash-border); color: var(--flash-fg);
padding: 0.7rem 1rem; border-radius: 6px; margin-bottom: 1rem;
}
.flash.error { background: var(--danger-bg); border-color: var(--danger-border); color: var(--danger-fg); }
.version { margin-top: 1.6rem; text-align: right; font-size: 0.8rem; color: var(--muted); }
.version a { color: inherit; }
label { display: block; font-weight: 600; margin: 0.9rem 0 0.3rem; }
label.check {
display: flex; align-items: center; gap: 0.5rem; margin: 0.45rem 0 0; font-weight: 600; cursor: pointer;
}
label.check input { width: auto; margin: 0; padding: 0; border: 0; background: none; }
input, select, textarea {
width: 100%; padding: 0.55rem 0.7rem; font-family: inherit; font-size: 1rem;
border: 1px solid var(--control-border); border-radius: 5px; background: var(--input-bg); color: inherit;
}
textarea { resize: vertical; }
fieldset {
margin: 0.9rem 0 0; padding: 0.55rem 0.85rem 0.85rem;
border: 1px solid var(--control-border); border-radius: 5px;
}
fieldset legend { padding: 0 0.25rem; font-weight: 600; }
fieldset > .muted { margin: 0.15rem 0 0.35rem; font-size: 0.85rem; font-weight: 400; }
button, a.btn, a.danger {
display: inline-block; margin-top: 1.1rem; padding: 0.6rem 1.1rem;
font: inherit; font-size: 1rem; font-weight: 600; text-decoration: none;
color: var(--on-accent); background: var(--accent-fill); border: 0; border-radius: 5px; cursor: pointer;
}
button:hover, a.btn:hover { background: var(--accent-fill-hover); }
button.danger, a.danger { background: var(--danger-fill); color: var(--on-accent); }
button.danger:hover, a.danger:hover { background: var(--danger-fill-hover); }
form.inline { display: inline; margin: 0; }
.st {
display: inline-block; padding: 0.14rem 0.45rem 0.28rem; border-radius: 4px;
font-family: var(--font-mono); font-size: 0.78rem; font-weight: 500; line-height: 1;
letter-spacing: 0.02em; vertical-align: middle; border: 1px solid transparent;
}
.st-ok { background: var(--st-ok-bg); color: var(--st-ok-fg); border-color: var(--st-ok-border); }
.st-warn { background: var(--st-warn-bg); color: var(--st-warn-fg); border-color: var(--st-warn-border); }
.st-error { background: var(--st-error-bg); color: var(--st-error-fg); border-color: var(--st-error-border); }
.st-unknown { background: var(--st-unknown-bg); color: var(--st-unknown-fg); border-color: var(--st-unknown-border); }
a.st, a.st:hover { color: inherit; text-decoration: none; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 0.5rem 0.45rem; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
th {
font-family: var(--font-mono); font-size: 0.75rem; font-weight: 500;
text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted);
}
td.time, td.status { white-space: nowrap; }
td.actions { text-align: right; white-space: nowrap; }
td.subject span {
display: block; max-width: 22rem;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.code {
display: block; white-space: pre-wrap; word-break: break-all; font-family: var(--font-mono);
font-size: 0.85rem; background: var(--code-bg); border: 1px solid var(--border);
border-radius: 5px; padding: 0.7rem 0.8rem; margin: 0.3rem 0 0;
}
.code-row { display: flex; align-items: flex-start; gap: 0.5rem; }
.code-row .code { flex: 1; min-width: 0; margin-top: 0; padding-top: 0.45rem; padding-bottom: 0.45rem; }
.input-row { display: flex; align-items: stretch; gap: 0.5rem; }
.input-row input { flex: 1; min-width: 0; }
.input-row button { margin-top: 0; flex: none; white-space: nowrap; }
.split {
display: grid; grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
gap: 1rem; margin-top: 1rem;
}
.split > .card { min-width: 0; }
.split > .card + .card { margin-top: 0; }
.split + .card, .card + .split { margin-top: 1rem; }
.check-cols {
display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
gap: 1rem 1.2rem; margin-top: 1rem;
}
.check-col { min-width: 0; }
.check-col-title { margin: 0.85rem 0 0.35rem; font-size: 1.05rem; font-weight: 600; }
.field-pair {
display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
gap: 0 1rem; margin-top: 0.45rem;
}
.field-pair.host-type { grid-template-columns: minmax(0, 1fr) auto; }
.field-type { width: max-content; }
.field-type .code { width: fit-content; min-width: 2.75rem; text-align: center; padding: 0.45rem; }
.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0.5rem; margin-top: 1rem; }
.fact { min-width: 0; padding: 0.5rem 0.7rem; border-radius: 6px; background: var(--surface-bg); }
.fact-label {
display: block; font-family: var(--font-mono); font-size: 0.7rem; font-weight: 500;
text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted);
}
.fact-value { display: block; margin-top: 0.1rem; overflow-wrap: anywhere; }
.fact-value.mono { font-family: var(--font-mono); font-size: 0.85rem; }
.timeline {
list-style: none; display: flex; flex-direction: column; gap: 1.1rem;
margin: 1rem 0 0; padding: 0.2rem 0 0.2rem 1.4rem; border-left: 2px solid var(--border);
}
.event { position: relative; min-width: 0; }
.event::before {
content: ""; position: absolute; left: -1.85rem; top: 0.3rem;
width: 0.65rem; height: 0.65rem; border-radius: 50%;
background: var(--card-bg); border: 2px solid var(--control-border);
}
.event.lvl-ok::before { border-color: var(--st-ok-fg); background: var(--st-ok-bg); }
.event.lvl-warn::before { border-color: var(--st-warn-fg); background: var(--st-warn-bg); }
.event.lvl-error::before { border-color: var(--st-error-fg); background: var(--st-error-bg); }
.event.pending { opacity: 0.7; }
.event.pending::before { border-style: dashed; }
.event-time { margin: 0; font-family: var(--font-mono); font-size: 0.75rem; color: var(--muted); }
.event-title { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin: 0.15rem 0 0; font-weight: 600; }
.event-detail { margin: 0.2rem 0 0; font-size: 0.9rem; }
.route { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin-top: -0.5rem; margin-bottom: 1rem; }
.route .addr { font-family: var(--font-mono); font-size: 0.9rem; overflow-wrap: anywhere; }
.route .arrow { color: var(--muted); }
table.log { margin-top: 1rem; }
table.log th:first-child, table.log td.time { width: 1%; }
table.log td.log-text { font-family: var(--font-mono); font-size: 0.8rem; white-space: pre-wrap; }
button.copy, .actions button, .actions > label.toggle, .actions a.danger {
margin: 0; padding: 0.45rem 0.7rem; font-size: 0.8rem; font-weight: 600;
border-radius: 5px; white-space: nowrap;
background: var(--surface-bg); color: var(--accent-text); border: 1px solid var(--control-border);
}
button.copy:hover, .actions button:hover, .actions > label.toggle:hover { background: var(--surface-bg-hover); }
.actions button.danger, .actions a.danger { color: var(--danger-fg); background: var(--danger-bg); border-color: var(--danger-border); }
.actions { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; }
.apps { list-style: none; margin: 1rem 0 0; padding: 0; }
.app-item { padding: 0.9rem 0; border-top: 1px solid var(--border); }
.app-item:last-child { padding-bottom: 0; }
.app-login { margin: 0; font-family: var(--font-mono); font-weight: 600; }
.app-addr { margin: 0.15rem 0 0; word-break: break-all; }
.app-item .actions { margin-top: 0.7rem; }
.app-item .actions > .panel-toggle {
position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none;
}
.app-item .actions > .panel { display: none; flex: 1 0 100%; }
.app-item .actions > .t-edit:checked ~ .panel-edit { display: block; }
.app-item .actions > .t-edit:checked ~ .for-edit { background: var(--surface-open-bg); }
.app-item .actions > .panel button { margin-top: 0.9rem; }
.encrypt { margin-top: 1.2rem; }
.encrypt-fields {
display: none; margin-top: 0.8rem; margin-left: 1.6rem; padding-left: 0.9rem;
border-left: 2px solid var(--border);
}
.encrypt:has(input[type="checkbox"]:checked) .encrypt-fields { display: block; }
.encrypt-fields label { margin-top: 0.7rem; }
.toolbar {
display: flex; flex-wrap: wrap; gap: 0.6rem 1rem; align-items: end;
padding: 0.85rem 1rem; margin-bottom: 1rem;
background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px;
}
.toolbar .field { flex: 1 1 10rem; min-width: 8rem; }
.toolbar label { margin-top: 0; font-size: 0.8rem; }
.toolbar button { margin-top: 0; }
/* Status: hero + dense grid */
.status-hero {
display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.75rem 1.2rem;
padding: 1rem 1.25rem; margin-bottom: 1rem;
background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px;
}
.status-hero.attn { border-color: var(--st-warn-border); background: var(--st-warn-bg); }
.status-hero h1 { margin: 0; }
.status-hero .lead { margin: 0; color: var(--fg); }
.status-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(17.5rem, 1fr));
gap: 1rem;
}
.status-grid .card { margin: 0; }
.status-grid .card.attn { border-color: var(--st-warn-border); }
.metric-row {
display: flex; justify-content: space-between; gap: 0.75rem; align-items: baseline;
padding: 0.35rem 0; border-bottom: 1px solid var(--border); font-size: 0.92rem;
}
.metric-row:last-child { border-bottom: 0; }
.metric-row .k { font-weight: 600; }
.metric-row .v { font-family: var(--font-mono); font-size: 0.85rem; }
meter { width: 5rem; height: 0.7rem; vertical-align: middle; margin-right: 0.4rem; }
.card-head {
display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.5rem;
}
.card-head h2 { margin: 0; }
.help-link {
flex: none; width: 1.35rem; height: 1.35rem; display: inline-flex; align-items: center; justify-content: center;
border-radius: 50%; border: 1px solid var(--control-border); color: var(--muted);
text-decoration: none; font-family: var(--font-mono); font-size: 0.75rem; font-weight: 600;
background: var(--surface-bg);
}
.help-link:hover { color: var(--accent-text); border-color: var(--accent-fill); }
.page-head {
display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 0.5rem 1rem;
margin-bottom: 1rem;
}
.page-head h1 { margin: 0; }
/* Compact retry policy */
.retry-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); gap: 0.5rem; margin-top: 0.7rem; }
.phone-list { display: none; list-style: none; margin: 0; padding: 0; }
.phone-list li { border-bottom: 1px solid var(--border); }
.phone-list a {
display: grid; grid-template-columns: 1fr auto; gap: 0.15rem 0.7rem;
padding: 0.75rem 0.1rem; text-decoration: none; color: var(--fg);
}
.phone-list .when { font-family: var(--font-mono); font-size: 0.75rem; color: var(--muted); }
.phone-list .pair { grid-column: 1 / -1; font-family: var(--font-mono); font-size: 0.82rem; overflow-wrap: anywhere; }
.phone-list .subj { grid-column: 1 / -1; margin: 0; }
/* Help drawer — CSS checkbox, no script required */
.help-drawer {
display: none; position: fixed; top: var(--gallery-h); right: 0; bottom: 0;
width: min(26rem, 100vw); z-index: 30;
background: var(--card-bg); border-left: 1px solid var(--border);
padding: 1.25rem 1.35rem 2rem; overflow-y: auto;
}
#help-open:checked ~ .help-drawer { display: block; }
#help-open:checked ~ .help-scrim { display: block; }
.help-scrim {
display: none; position: fixed; inset: var(--gallery-h) 0 0 0; z-index: 25;
background: rgba(18, 22, 28, 0.28);
}
.help-drawer h2 { margin-top: 1.2rem; }
.help-drawer h2:first-of-type { margin-top: 0; }
.help-close {
position: absolute; top: 0.8rem; right: 0.8rem; margin: 0; padding: 0.35rem 0.6rem;
font-size: 0.8rem; font-weight: 600; background: var(--surface-bg); color: var(--fg);
border: 1px solid var(--control-border); border-radius: 5px; cursor: pointer;
}
/* Phone preview (gallery toggle) and real narrow windows */
.nav-burger { display: none; }
#vp-phone:checked ~ .app {
position: relative;
width: 390px; margin: 0.75rem auto 2rem; min-height: 760px;
border: 1px solid var(--border); border-radius: 6px; overflow: hidden;
max-height: none;
}
#vp-phone:checked ~ .app .nav {
position: absolute; top: 0; left: 0; bottom: 0;
height: auto; max-height: none;
width: min(16rem, 82%); z-index: 20;
transform: translateX(-110%);
border-right: 1px solid var(--border); background: var(--card-bg);
}
#nav-open:checked ~ .app .nav { transform: translateX(0); }
#vp-phone:checked ~ .app .phone-bar {
display: flex; align-items: center; gap: 0.55rem;
margin: 0; padding: 0.65rem 0.85rem;
border-bottom: 1px solid var(--border); background: var(--card-bg);
}
#vp-phone:checked ~ .app .nav-burger {
display: inline-flex; align-items: center; justify-content: center;
width: 2.1rem; height: 2.1rem; margin: 0; padding: 0;
border: 1px solid var(--control-border); border-radius: 5px;
background: var(--surface-bg); cursor: pointer;
}
#vp-phone:checked ~ .app .phone-mark { width: 1.7rem; height: 1.7rem; }
#vp-phone:checked ~ .app .phone-bar .grow { flex: 1; font-weight: 600; }
#vp-phone:checked ~ .app main { padding: 1.5rem 0.9rem 2rem; }
#vp-phone:checked ~ .app .desk-only { display: none !important; }
#vp-phone:checked ~ .app .phone-only { display: block; }
#vp-phone:checked ~ .app .phone-list { display: block; }
#vp-phone:checked ~ .app .status-grid { display: flex; flex-direction: column; }
#vp-phone:checked ~ .app .status-grid .attn { order: -1; }
#vp-phone:checked ~ .app .split,
#vp-phone:checked ~ .app .check-cols,
#vp-phone:checked ~ .app .field-pair,
#vp-phone:checked ~ .app .facts,
#vp-phone:checked ~ .app .retry-facts {
grid-template-columns: 1fr;
}
#vp-phone:checked ~ .app td.subject span { max-width: none; }
#vp-phone:checked ~ .help-drawer { width: 100vw; }
.phone-only { display: none; }
@media (max-width: 52rem) {
.app { min-height: calc(100vh - var(--gallery-h)); }
.nav {
position: fixed; top: var(--gallery-h); left: 0; bottom: 0;
z-index: 20; width: min(16rem, 84vw); max-height: none;
transform: translateX(-110%);
border-right: 1px solid var(--border); background: var(--card-bg);
}
#nav-open:checked ~ .app .nav { transform: translateX(0); }
.phone-bar {
display: flex; align-items: center; gap: 0.55rem;
margin: 0; padding: 0.65rem 0.85rem;
border-bottom: 1px solid var(--border); background: var(--card-bg);
}
.nav-burger {
display: inline-flex; align-items: center; justify-content: center;
width: 2.1rem; height: 2.1rem; margin: 0; padding: 0;
border: 1px solid var(--control-border); border-radius: 5px;
background: var(--surface-bg); cursor: pointer;
}
.phone-mark { width: 1.7rem; height: 1.7rem; }
.phone-bar .grow { flex: 1; font-weight: 600; }
main { padding: 1.5rem 0.9rem 2rem; }
.desk-only { display: none !important; }
.phone-only, .phone-list { display: block; }
.status-grid { display: flex; flex-direction: column; }
.status-grid .attn { order: -1; }
.split, .check-cols, .field-pair, .facts, .retry-facts { grid-template-columns: 1fr; }
.help-drawer { width: 100vw; }
}
/* Index page */
.wrap { max-width: 58rem; margin: 0 auto; padding: 2.5rem 1.25rem 5rem; }
.wrap > header { border-bottom: 2px solid var(--accent-fill); padding-bottom: 1.5rem; margin-bottom: 0.5rem; }
.eyebrow {
font-family: var(--font-mono); font-size: 0.72rem; letter-spacing: 0.14em;
text-transform: uppercase; color: var(--accent-text); margin: 0 0 0.7rem;
}
.wrap h1 { font-size: clamp(1.7rem, 4vw, 2.4rem); font-weight: 200; }
.wrap h1 b { font-weight: 600; }
.wrap > header p { margin: 0; max-width: 62ch; color: var(--muted); }
.wrap section { border-bottom: 1px solid var(--border); padding: 2.2rem 0; }
.wrap h2 {
font-size: 0.8rem; font-weight: 500; margin: 0 0 1.1rem;
letter-spacing: 0.1em; text-transform: uppercase; font-family: var(--font-mono); color: var(--muted);
}
.wrap h3 { font-size: 1.05rem; font-weight: 600; margin: 1.2rem 0 0.4rem; }
.note {
background: #f1eae4; border-left: 2px solid var(--accent-fill);
padding: 0.9rem 1rem; font-size: 0.92rem; line-height: 1.6; max-width: 64ch; margin-top: 1rem;
}
#theme-dark:checked ~ .doc .note { background: #2a1f1b; }
.compare {
display: grid; grid-template-columns: 1fr; gap: 0.75rem; margin-top: 0.8rem;
}
.compare .row {
font-family: var(--font-mono); font-size: 0.75rem; color: var(--muted);
}
.bar {
display: flex; height: 2.1rem; border: 1px solid var(--border); border-radius: 4px; overflow: hidden; background: var(--card-bg);
}
.bar i { display: flex; align-items: center; justify-content: center; font-style: normal; font-size: 0.7rem; }
.bar .navc { width: 14%; background: var(--surface-bg); color: var(--fg); border-right: 1px solid var(--border); }
.bar .col { background: var(--nav-active-bg); color: var(--accent-text); }
.bar .empty { flex: 1; background: var(--code-bg); color: var(--muted); }
.bar .fill { flex: 1; background: var(--st-ok-bg); color: var(--st-ok-fg); }
.ia { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 40rem) { .ia { grid-template-columns: 1fr; } }
.ia ul { margin: 0.3rem 0 0; padding-left: 1.1rem; }
.ia li { margin: 0.2rem 0; }
.screen-index { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: 0.4rem 1.5rem; }
.screen-index a { display: block; padding: 0.25rem 0; }
.icons-row { display: flex; flex-wrap: wrap; gap: 1.2rem; align-items: end; margin-top: 0.8rem; }
.icon-card {
background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px;
padding: 1rem 1.1rem 0.85rem; text-align: center; min-width: 7.5rem;
}
.icon-card svg { width: 1.5rem; height: 1.5rem; display: block; margin: 0 auto 0.45rem; }
.icon-card span { display: block; font-family: var(--font-mono); font-size: 0.7rem; color: var(--muted); }
.mark-row { display: flex; flex-wrap: wrap; gap: 1.5rem; align-items: end; }
.mark-row figure { margin: 0; }
.mark-row figcaption { font-family: var(--font-mono); font-size: 0.7rem; color: var(--muted); margin-top: 0.4rem; }
.cta {
display: inline-block; margin-top: 1rem; padding: 0.65rem 1.2rem;
background: var(--accent-fill); color: var(--on-accent); text-decoration: none;
font-weight: 600; border-radius: 5px;
}
.cta:hover { background: var(--accent-fill-hover); color: var(--on-accent); }
.cta-row { display: flex; flex-wrap: wrap; gap: 0.6rem; margin-top: 1rem; }
.cta.quiet {
background: var(--surface-bg); color: var(--accent-text); border: 1px solid var(--control-border);
}
.cta.quiet:hover { background: var(--surface-bg-hover); color: var(--accent-text); }