new-card unread dot + pulse fix, relay inbox readable, UI polish pass

Board: new-card unread indicator (public/js/modules/board.js,
public/js/modules/buckets.js, public/js/modules/state.js,
public/css/style.css):

* state.unseenCardIds (Set) tracks card ids that arrived since the
  last visit to the "all" view. Diff runs in renderBoard() against a
  module-private knownCardIds snapshot.
* Bug fix: the first renderBoard runs with state.cards = [] (before
  the API returns). The previous diff treated every card from the
  real load as "new" and pulsed the entire board. Now the diff is
  skipped when knownCardIds is null or empty — the first non-empty
  render is a silent prime.
* When activeBucket === 'all' and unseenCardIds is non-empty, the set
  is drained and every matching board card gets a 3-cycle accent glow
  (`.card-pulse-new`, 0.8s × 3, pinned for anim-fast/slow). Works
  both when navigating TO all and when a poll arrives while already
  on all.
* When the user is on a different bucket: unseenCardIds accumulates
  across polls and a red dot (`.bar-unread-dot`) appears on the "all"
  tab. Position is top:3px right:3px *inside* the tab to survive
  the double overflow clip (`#main-header overflow:hidden` +
  `#bucket-bar overflow-x:auto`). renderBar() bakes the dot into the
  all-tab template on every render so it survives bar re-renders that
  aren't followed by a board render.
* decorateAllUnreadDot() in board.js is a fallback called at the end
  of every renderBoard for cases where the diff changed unseenCardIds
  but renderBar hasn't run yet.

Relay: readable inbox (relay/inc/routes/messages.php,
relay/ui/app.js, relay/ui/layout.php, relay/ui/style.css):

* New session-based GET /api/messages/inbox — returns up to 200
  messages for the logged-in user, decrypted server-side with the
  inter-tenant key (no extra leakage — the relay already sees
  plaintext during the routing hop). Does NOT mark messages as
  fetched; the hyprcrd client is still the canonical receiver.
* loadInbox() now fetches from /api/messages/inbox and renders
  actual inbox items: sender (with user-circle icon + accent2),
  optional title (bold), body (pre-wrap, max-height 120px), relative
  timestamp (formatRelTime: "just now", "2 min ago", "today 14:32",
  "3 d ago", or YYYY-MM-DD for older). Unfetched messages show a
  small "new" accent badge next to the sender name.
* deleteMessage(id, btn) — confirms, calls existing DELETE
  /api/messages/{id} (already session-aware), removes the DOM row,
  re-loads the list, toasts "message deleted".
* Auto-poll: startInboxPoll / stopInboxPoll refresh the inbox every
  15s while the compose view is active. Stops on view switch.
* Header-status badge: mirrors the real message count (not just the
  /api/status count) and toggles the has-unread class + dot so the
  tap-to-scroll-to-inbox target stays fresh.

Relay UI polish (relay/ui/style.css):
* .header-status is now a clickable button with tray icon, hover
  tint, focus-ring, and a red dot (::after pseudo) when has-unread.
  jumpToInbox() scrolls smoothly to #inbox-section and flashes it
  with a 1.6s accent box-shadow pulse.
* .inbox-item .from is flex-row with icon + badge-new pill.
* .inbox-item .del gets hover-background for better mobile tappability.
* @keyframes flash-highlight for the scroll-to-inbox animation.

chore: bump version to 0.9.9

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.6 committed 2026-04-12 09:09:29 +02:00
1 parent a67bedc6f2
commit c16659c2ad
9 files changed
+773 -246

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
<?php
define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '0.9.8');
define('APP_VERSION', '0.9.9');
// Database path — writable directory, outside webroot
define('DB_PATH', __DIR__ . '/data/hyprcrd.db');
+45
View File
@@ -743,6 +743,51 @@ body.detail-image-fs-open { overflow: hidden; }
background: color-mix(in srgb, var(--t-accent) 12%, transparent) !important;
}
/* Unread dot on the fixed `all` bucket-tab — appears when new cards
arrive while the user is viewing a different bucket. Cleared the
moment the user navigates back to all (renderBoard drains the
unseen set + pulses the cards). The dot sits *inside* the tab's
top-right corner because the parent #main-header has overflow:
hidden — anything positioned outside the tab gets clipped twice
(overflow-x-auto on bucket-bar + overflow:hidden on header). */
.bucket-tab.has-unread { position: relative; }
.bar-unread-dot {
position: absolute; top: 3px; right: 3px;
width: 7px; height: 7px; border-radius: 50%;
background: var(--t-danger);
box-shadow: 0 0 6px color-mix(in srgb, var(--t-danger) 75%, transparent);
pointer-events: none;
z-index: 1;
}
/* "Look at me" pulse on cards that just arrived. Triggered by
board.js when the all view is shown with unseen cards in the set —
either because new cards arrived while the user was already on
all, or because the user just navigated to all and the unseen ids
accumulated since the last visit. 3 short accent flashes, then
the class is stripped automatically by setTimeout. */
@keyframes card-pulse-new {
0%, 100% {
box-shadow: var(--ui-shadow-card, none),
0 0 0 0 color-mix(in srgb, var(--t-accent) 80%, transparent);
}
50% {
box-shadow: var(--ui-shadow-card, none),
0 0 18px 4px color-mix(in srgb, var(--t-accent) 75%, transparent),
inset 0 0 0 2px color-mix(in srgb, var(--t-accent) 70%, transparent);
}
}
.board-card.card-pulse-new {
animation: card-pulse-new 0.8s ease-in-out 3;
z-index: 5;
}
/* Pin the cadence so anim-fast/anim-slow can't strobe it */
body.anim-fast .board-card.card-pulse-new,
body.anim-slow .board-card.card-pulse-new {
animation: card-pulse-new 0.8s ease-in-out 3 !important;
animation-duration: 0.8s !important;
}
/* Super-bucket dropdown popover. Positioned via inline styles by
openSuperDropdown() in buckets.js. Lives on document.body (not
inside #bucket-bar) so it can escape the bar's overflow:auto. */
+66
View File
@@ -30,6 +30,48 @@ let dragGhost = null;
// time a background poll completed mid-drag.
const draggingCardIds = new Set();
// Tracks every card id we've ever rendered. On the first render we just
// snapshot whatever's currently in state.cards (everything counts as
// "already known"); after that, the diff against this set tells us
// which cards just arrived from a poll / sync. New cards get pushed into
// state.unseenCardIds and either pulse immediately (if we're already on
// the all view) or wait for the next visit to the all tab to pulse.
let knownCardIds = null;
/** Add an accent pulse class to the matching board cards and strip it
* again after the animation finishes. The animation itself is in
* public/css/style.css `@keyframes card-pulse-new`. */
function pulseCardsNew(ids) {
if (!ids || !ids.size) return;
requestAnimationFrame(() => {
for (const id of ids) {
const el = board.querySelector(`[data-id="${id}"]`);
if (!el) continue;
el.classList.add('card-pulse-new');
setTimeout(() => el.classList.remove('card-pulse-new'), 2400);
}
});
}
/** Add or remove the unread dot on the fixed `all` bucket-tab. Hidden
* while the user is currently looking at the all view. Lives in
* board.js (rather than buckets.js) because the trigger is "new card
* arrived" — buckets.js doesn't know about that, board.js does. */
function decorateAllUnreadDot() {
const allTab = document.querySelector('#bucket-bar [data-bucket="all"]');
if (!allTab) return;
const hasUnread = state.unseenCardIds.size > 0 && state.activeBucket !== 'all';
allTab.classList.toggle('has-unread', hasUnread);
let dot = allTab.querySelector('.bar-unread-dot');
if (hasUnread && !dot) {
dot = document.createElement('span');
dot.className = 'bar-unread-dot';
allTab.appendChild(dot);
} else if (!hasUnread && dot) {
dot.remove();
}
}
// ── Multi-select ──
const selectedIds = state.selectedIds;
const selectBar = $('select-bar');
@@ -450,6 +492,19 @@ export function renderCardHtml(c, inGroup = false, recent = []) {
// ── Render ──
export function renderBoard() {
// Diff state.cards against the last seen ids so we can flag arrivals
// as "unseen". The very first renderBoard runs with state.cards = []
// (initial empty state, before the API has returned), so we wait
// until knownCardIds is non-empty before doing the diff — otherwise
// the first real load looks like "every card just arrived" and the
// entire board pulses on page open.
const currentIds = new Set(state.cards.map(c => String(c.id)));
if (knownCardIds && knownCardIds.size > 0) {
for (const id of currentIds) {
if (!knownCardIds.has(id)) state.unseenCardIds.add(id);
}
}
knownCardIds = currentIds;
let cards;
if (state.activeGroup) {
const parent = state.cards.find(c => c.id == state.activeGroup);
@@ -686,6 +741,17 @@ export function renderBoard() {
// Notification banner
renderNotifs();
// Unread tracking: if we're on the all view, drain the unseen set
// by pulsing every matching card on the board (the "look at me"
// accent flash). If we're on a different view, the unseen ids stay
// accumulated and the all-tab gets a dot until the user opens it.
if (state.activeBucket === 'all' && state.unseenCardIds.size) {
const toPulse = new Set(state.unseenCardIds);
state.unseenCardIds.clear();
pulseCardsNew(toPulse);
}
decorateAllUnreadDot();
}
// ── Notification providers ──
+7 -1
View File
@@ -264,8 +264,14 @@ function renderBar() {
return parent;
};
// Show an unread dot on the fixed `all` tab when state.unseenCardIds
// has anything in it AND the user isn't currently looking at the
// all view. board.js drives the unseen set; we just read it here so
// the dot survives bar re-renders that aren't followed by a board
// render (e.g. bucket reorder, plain renderBar() call).
const allUnread = state.activeBucket !== 'all' && state.unseenCardIds.size > 0;
bar.innerHTML = `
<button class="${tab(state.activeBucket === 'all')}" data-bucket="all" title="all"><i class="ph ph-squares-four text-sm"></i></button>
<button class="${tab(state.activeBucket === 'all')}${allUnread ? ' has-unread' : ''}" data-bucket="all" title="all"><i class="ph ph-squares-four text-sm"></i>${allUnread ? '<span class="bar-unread-dot"></span>' : ''}</button>
<button class="${tab(state.activeBucket === 'current')}" data-bucket="current" title="current"><i class="ph ph-lightning text-sm"></i></button>
<button class="${tab(state.activeBucket === 'favorites')}" data-bucket="favorites" title="favorites"><i class="ph ph-star text-sm"></i></button>
<button class="${tab(state.activeBucket === 'journal')}" data-bucket="journal" title="journal"><i class="ph ph-notebook text-sm"></i></button>
+5
View File
@@ -32,6 +32,11 @@ export const state = {
// pick the title strategy: false → first selected wins, true →
// longest title wins (since "first" isn't meaningful for bulk).
selectionWasBulk: false,
// Card ids that have arrived since the user last entered the "all"
// view. Drives the unread dot on the all bucket-tab and the brief
// accent pulse on the cards themselves once the user looks at them.
// Updated by board.js renderBoard() — see the diff there.
unseenCardIds: new Set(),
// Detail / editor
editingId: null,
+29
View File
@@ -42,6 +42,35 @@ if ($route === '/api/messages' && $method === 'POST' && $sessionTenant && empty(
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId(), 'recipient' => $recipientName]);
}
// ── UI inbox view: session reads decrypted messages ──
//
// The relay already sees plaintext during the routing hop (decrypt with
// sender secret → re-encrypt with inter-tenant key for at-rest storage),
// so reading them back to the session user is no extra leakage. Lets the
// user check their inbox from the relay web UI on a phone without
// needing the full hyprcrd client. Does NOT mark messages as fetched —
// the canonical receiver is still the hyprcrd client.
if ($route === '/api/messages/inbox' && $method === 'GET' && $sessionTenant) {
$interKey = getInterTenantKey($db, $MASTER_KEY);
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
$stmt = $db->prepare('SELECT id, sender_username, title, body, created_at, fetched FROM messages WHERE recipient_id = :r ORDER BY created_at DESC LIMIT 200');
$stmt->execute(['r' => $sessionTenant['id']]);
$rows = $stmt->fetchAll();
$out = [];
foreach ($rows as $row) {
$out[] = [
'id' => (int)$row['id'],
'sender' => $row['sender_username'] ?? '?',
'title' => clientDecrypt($row['title'] ?? '', $interKey),
'body' => clientDecrypt($row['body'] ?? '', $interKey),
'created_at' => $row['created_at'],
'fetched' => (int)$row['fetched'],
];
}
jsonResponse(['messages' => $out]);
}
// ── Client API auth (X-Relay-User + X-Relay-Key) ──
$clientTenant = authClient($db);
+153 -18
View File
@@ -5,12 +5,29 @@ let allTenants = [];
function $(id) { return document.getElementById(id); }
function toast(msg, isError = false) {
const t = $('toast');
t.textContent = msg;
t.classList.toggle('err', isError);
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2200);
/** Show a toast in the bottom-right stack. kind = 'info' | 'ok' | 'err'.
* Multiple toasts stack vertically, oldest at the top, and each one
* slides in from the right + fades out after a fixed timeout. The
* legacy `toast(msg, true)` second-arg style still works (true → err). */
function toast(msg, kind = 'info') {
if (kind === true) kind = 'err';
if (kind === false) kind = 'info';
const stack = $('toast-stack');
if (!stack) return;
const el = document.createElement('div');
el.className = 'toast ' + kind;
const icon = kind === 'err' ? 'ph-warning-circle'
: kind === 'ok' ? 'ph-check-circle'
: 'ph-info';
el.innerHTML = `<i class="ph ${icon} toast-icon"></i><span class="toast-msg"></span>`;
el.querySelector('.toast-msg').textContent = msg;
stack.appendChild(el);
// Trigger the slide-in next frame so the transition runs
requestAnimationFrame(() => el.classList.add('show'));
setTimeout(() => {
el.classList.add('fade-out');
setTimeout(() => el.remove(), 350);
}, 2800);
}
async function api(path, opts = {}) {
@@ -52,8 +69,12 @@ function showView(name) {
if (name === 'compose') {
loadTenants().then(() => populateRecipientPicker());
loadInbox();
startInboxPoll();
$('compose-body')?.focus();
} else if (name === 'admin') {
} else {
stopInboxPoll();
}
if (name === 'admin') {
loadUsers();
} else if (name === 'account') {
loadAccountSecret();
@@ -63,6 +84,23 @@ function showView(name) {
}
}
// ── Inbox auto-poll ──
//
// Refresh the inbox every 15s while the compose view is showing so new
// messages from other tenants surface without a manual reload. Stops
// the moment the user navigates away (account/admin/login) so we don't
// hammer the server in the background.
let inboxPollHandle = null;
function startInboxPoll() {
stopInboxPoll();
inboxPollHandle = setInterval(() => {
if (!$('view-compose')?.classList.contains('hidden')) loadInbox();
}, 15000);
}
function stopInboxPoll() {
if (inboxPollHandle) { clearInterval(inboxPollHandle); inboxPollHandle = null; }
}
/** Burger toggle for the mobile nav. The button is hidden on desktop
* via CSS, on mobile it expands #nav-buttons as a dropdown panel
* positioned under the header. */
@@ -70,6 +108,31 @@ function toggleNavMenu() {
$('nav-buttons')?.classList.toggle('menu-open');
}
window.toggleNavMenu = toggleNavMenu;
/** Tap-target on the header inbox count: switches to the compose view
* if needed, then smooth-scrolls #inbox-section into view and briefly
* highlights it so the user can't miss it after the scroll lands.
* Same handler on desktop and mobile — desktop just scrolls a few px
* if at all (the inbox is already on screen). */
function jumpToInbox() {
if (!me) return;
if ($('view-compose')?.classList.contains('hidden')) {
showView('compose');
}
// Wait one frame so the (possibly just-shown) view has measurable
// dimensions before scrollIntoView fires
requestAnimationFrame(() => {
const target = $('inbox-section');
if (!target) return;
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
target.classList.remove('flash-highlight');
// Restart the animation by toggling the class
void target.offsetWidth;
target.classList.add('flash-highlight');
setTimeout(() => target.classList.remove('flash-highlight'), 1600);
});
}
window.jumpToInbox = jumpToInbox;
// Tap outside the menu closes it
document.addEventListener('click', (e) => {
const nav = $('nav-buttons');
@@ -188,7 +251,9 @@ async function refreshMe() {
};
$('nav-user').innerHTML = `<span>${escapeHtml(me.username)}</span>` + (me.is_admin ? '<span class="badge">admin</span>' : '');
$('nav-admin').classList.toggle('hidden', !me.is_admin);
$('header-status').textContent = (status.inbox_count || 0) + ' in inbox';
const n = status.inbox_count || 0;
$('header-status').innerHTML = `<i class="ph ph-tray"></i><span>${n} in inbox</span>`;
$('header-status').classList.toggle('has-unread', n > 0);
}
return status;
}
@@ -263,31 +328,101 @@ async function sendCompose() {
// ── Inbox (counts only — content is encrypted for the hyprcrd client) ──
async function loadInbox() {
const status = await api('api/status');
$('inbox-count').textContent = status.inbox_count || 0;
const list = $('inbox-list');
if (!status.inbox_count) {
list.innerHTML = '<div class="empty">no messages waiting. they will be picked up by the next hyprcrd poll.</div>';
} else {
list.innerHTML = `<div class="empty">${status.inbox_count} encrypted message${status.inbox_count === 1 ? '' : 's'} waiting for the hyprcrd client to fetch.</div>`;
list.innerHTML = `
<div class="skeleton-row"><div class="skel tall"></div></div>
<div class="skeleton-row"><div class="skel short"></div></div>
<div class="skeleton-row"><div class="skel"></div></div>
`;
const res = await api('api/messages/inbox');
const messages = res?.messages || [];
$('inbox-count').textContent = messages.length;
// Mirror count into the header status so the unread dot stays in sync
$('header-status').innerHTML = `<i class="ph ph-tray"></i><span>${messages.length} in inbox</span>`;
$('header-status').classList.toggle('has-unread', messages.length > 0);
if (!messages.length) {
list.innerHTML = `
<div class="empty-state">
<i class="ph ph-tray icon"></i>
<div class="label">inbox empty</div>
<div class="hint">Messages from other tenants will show up here.</div>
</div>`;
return;
}
list.innerHTML = messages.map(m => `
<div class="inbox-item" data-id="${m.id}">
<div class="content">
<div class="from"><i class="ph ph-user-circle"></i> ${escapeHtml(m.sender)}${m.fetched ? '' : ' <span class="badge-new">new</span>'}</div>
${m.title ? `<div class="title">${escapeHtml(m.title)}</div>` : ''}
<div class="body">${escapeHtml(m.body)}</div>
</div>
<div class="meta">${escapeHtml(formatRelTime(m.created_at))}</div>
<button class="del" onclick="deleteMessage(${m.id}, this)" aria-label="delete"><i class="ph ph-trash"></i></button>
</div>
`).join('');
}
async function deleteMessage(id, btn) {
if (!confirm('Delete this message?')) return;
const res = await api('api/messages/' + id, { method: 'DELETE' });
if (res?.ok) {
btn.closest('.inbox-item')?.remove();
loadInbox();
refreshMe();
toast('message deleted', 'ok');
} else {
toast(res?.error || 'delete failed', 'err');
}
}
window.deleteMessage = deleteMessage;
/** Lightweight relative-time formatter for the inbox. SQLite gives us
* `YYYY-MM-DD HH:MM:SS` (UTC). Returns "2 min ago", "today 14:32",
* or a full date for older messages. */
function formatRelTime(sqliteUtc) {
if (!sqliteUtc) return '';
// Parse SQLite's "YYYY-MM-DD HH:MM:SS" as UTC by appending Z
const t = new Date(sqliteUtc.replace(' ', 'T') + 'Z');
if (isNaN(t.getTime())) return sqliteUtc;
const diff = (Date.now() - t.getTime()) / 1000;
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff / 60) + ' min ago';
if (diff < 86400) {
const today = new Date();
const sameDay = today.toDateString() === t.toDateString();
if (sameDay) return 'today ' + t.toTimeString().slice(0, 5);
return Math.floor(diff / 3600) + ' h ago';
}
if (diff < 7 * 86400) return Math.floor(diff / 86400) + ' d ago';
return t.toISOString().slice(0, 10);
}
// ── Admin: users ──
async function loadUsers() {
const list = $('users-list');
list.innerHTML = `
<div class="skeleton-row"><div class="skel tall"></div></div>
<div class="skeleton-row"><div class="skel short"></div></div>
<div class="skeleton-row"><div class="skel"></div></div>
`;
const res = await api('api/users');
if (res.error) { toast(res.error, true); return; }
if (res.error) { toast(res.error, 'err'); return; }
const users = res.users || [];
$('users-count').textContent = users.length;
const list = $('users-list');
if (!users.length) {
list.innerHTML = '<div class="empty">no users yet</div>';
list.innerHTML = `
<div class="empty-state">
<i class="ph ph-users icon"></i>
<div class="label">no users yet</div>
<div class="hint">Create the first one with the form on the right.</div>
</div>`;
return;
}
list.innerHTML = users.map(u => `
<div class="user-row" data-id="${u.id}">
<i class="ph ph-user-circle" style="font-size:20px;color:var(--accent);"></i>
<i class="ph ph-user-circle user-icon"></i>
<div class="info">
<div class="name">${escapeHtml(u.username)} ${u.is_admin ? '<span class="badge">admin</span>' : ''}</div>
<div class="meta">created ${u.created_at}</div>
+27 -22
View File
@@ -14,8 +14,9 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
<header>
<span class="dot"></span>
<i class="ph ph-broadcast brand-icon"></i>
<h1>hyprcrd relay</h1>
<span id="header-status" class="pixel" style="font-size:9px;color:var(--dim);"></span>
<button id="header-status" class="header-status pixel" onclick="jumpToInbox()" title="jump to inbox"></button>
<span class="spacer"></span>
<button id="nav-burger" class="nav-btn hidden" aria-label="menu" onclick="toggleNavMenu()"><i class="ph ph-list"></i></button>
<div id="nav-buttons" class="hidden">
@@ -31,6 +32,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
<!-- ── Setup view (first install) ── -->
<div id="view-setup" class="auth-form hidden">
<i class="ph ph-broadcast auth-icon"></i>
<h2>welcome to relay</h2>
<p>This is the first time the relay is being set up. The user you create here will become the <strong>admin</strong>. You'll also set the <strong>super-shared secret</strong> — a relay-wide key used to route messages between tenants.</p>
<div class="field">
@@ -44,7 +46,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
<div class="field">
<label>super-shared secret</label>
<input type="password" id="setup-super-secret" placeholder="min 12 chars" autocomplete="new-password">
<p style="font-size:9px;color:var(--dim);margin-top:4px;line-height:1.4;">Used by the relay to encrypt messages at rest and route them between tenants. Set once, can't be changed without re-setup.</p>
<p class="field-hint">Used by the relay to encrypt messages at rest and route them between tenants. Set once, can't be changed without re-setup.</p>
</div>
<button class="btn btn-primary" onclick="doSetup()"><i class="ph ph-user-circle-plus"></i> create admin + activate relay</button>
<div id="setup-err" class="err"></div>
@@ -52,6 +54,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
<!-- ── Login view ── -->
<div id="view-login" class="auth-form hidden">
<i class="ph ph-key auth-icon"></i>
<h2>login</h2>
<div class="field">
<label>username</label>
@@ -96,6 +99,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
<!-- ── Forced password change (first login) ── -->
<div id="view-change-password" class="auth-form hidden">
<i class="ph ph-key auth-icon"></i>
<h2>change your password</h2>
<p>This is your first login. Please choose a new password before you can use the relay.</p>
<div class="field">
@@ -118,35 +122,36 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
<div id="view-account" class="hidden">
<div class="section">
<h2><i class="ph ph-user-circle"></i> your client secret</h2>
<p style="font-size:11px;color:var(--muted);line-height:1.5;margin-bottom:14px;">
<p class="field-hint" style="line-height:1.6;font-size:11px;color:var(--muted);">
Your hyprcrd clients use this secret to encrypt messages before sending them to the relay.
All your devices share the same secret. Other tenants never see it. The relay stores it
encrypted with the master key so it can route your messages.
</p>
<div id="account-no-secret" class="hidden notice warn" style="margin-bottom:14px;">
⚠ no secret set yet — your hyprcrd clients can't connect until you generate or set one below
<div id="account-no-secret" class="hidden notice warn">
<i class="ph ph-warning"></i>
<span>no secret set yet — your hyprcrd clients can't connect until you generate or set one below</span>
</div>
<div id="account-current" class="hidden" style="margin-bottom:14px;">
<label class="field-label" style="display:block;margin-bottom:4px;">current secret</label>
<div style="display:flex;gap:8px;align-items:stretch;">
<input id="account-secret-display" type="password" readonly class="w-full" style="flex:1;background:var(--bg);border:1px solid var(--border);color:var(--bright);padding:9px 12px;font-size:13px;font-family:'JetBrains Mono',monospace;border-radius:3px;">
<div id="account-current" class="hidden account-secret-block">
<label class="field-label">current secret</label>
<div class="account-secret-row">
<input id="account-secret-display" type="password" readonly class="input">
<button class="btn btn-secondary" onclick="toggleAccountSecret()" id="account-secret-toggle"><i class="ph ph-eye"></i> show</button>
<button class="btn btn-secondary" onclick="copyAccountSecret()"><i class="ph ph-copy"></i> copy</button>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr;gap:14px;">
<div>
<label class="field-label">set your own secret</label>
<div style="display:flex;gap:8px;">
<input id="account-secret-input" type="text" placeholder="min 8 chars" style="flex:1;background:var(--bg);border:1px solid var(--border);color:var(--bright);padding:9px 12px;font-size:13px;font-family:'JetBrains Mono',monospace;border-radius:3px;">
<div class="account-secret-grid">
<div class="field">
<label>set your own secret</label>
<div class="account-secret-row">
<input id="account-secret-input" type="text" placeholder="min 8 chars" class="input">
<button class="btn btn-primary" onclick="saveAccountSecret()"><i class="ph ph-floppy-disk"></i> save</button>
</div>
</div>
<div style="text-align:center;color:var(--dim);font-family:var(--pixel);font-size:9px;">— or —</div>
<div class="account-divider">— or —</div>
<button class="btn btn-secondary" onclick="generateAccountSecret()"><i class="ph ph-dice-five"></i> generate random secret</button>
</div>
<div id="account-status" class="status" style="margin-top:12px;"></div>
<div style="margin-top:24px;padding-top:14px;border-top:1px solid var(--border);">
<div class="account-secret-actions">
<button class="btn btn-danger" onclick="clearAccountSecret()"><i class="ph ph-x-circle"></i> clear secret (disconnect all clients)</button>
</div>
</div>
@@ -158,18 +163,18 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
<h2><i class="ph ph-users-three"></i> users <span class="count" id="users-count">0</span></h2>
<div id="users-list"></div>
<div class="create-user-form">
<div>
<label for="new-username" class="field-label">username</label>
<div class="field">
<label for="new-username">username</label>
<input type="text" id="new-username" placeholder="alice" autocomplete="off">
</div>
<div>
<label for="new-password" class="field-label">password</label>
<div class="field">
<label for="new-password">password</label>
<input type="password" id="new-password" placeholder="min 4 chars" autocomplete="new-password">
</div>
<label class="checkbox">
<input type="checkbox" id="new-is-admin"> grant admin privileges
</label>
<p style="font-size:9px;color:var(--dim);line-height:1.4;margin-top:2px;">The new user sets their own client secret on first login.</p>
<p class="field-hint">The new user sets their own client secret on first login.</p>
<div class="actions">
<button class="btn btn-primary" onclick="createUser()"><i class="ph ph-user-plus"></i> create user</button>
<span id="create-user-status" class="status"></span>
@@ -180,7 +185,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
</main>
<div class="toast" id="toast"></div>
<div id="toast-stack"></div>
<script src="ui/app.js"></script>
</body>
+440 -204
View File
@@ -5,55 +5,114 @@
:root {
--bg:#1e293b; --surface:#273349; --raised:#2e3d56; --border:#384a64;
--dim:#64748b; --muted:#94a3b8; --text:#cbd5e1; --bright:#f1f5f9;
--accent:#38bdf8; --accent2:#818cf8; --danger:#fb7185;
--accent:#38bdf8; --accent2:#818cf8; --danger:#fb7185; --ok:#4ade80;
--pixel:'Silkscreen', cursive;
--mono:'JetBrains Mono', monospace;
--radius:3px;
--radius-lg:6px;
--shadow-card:0 1px 2px rgba(0,0,0,0.3), 0 8px 24px -8px rgba(0,0,0,0.4);
--shadow-popover:0 8px 24px rgba(0,0,0,0.4);
--shadow-button:0 4px 12px -4px rgba(56,189,248,0.4);
--focus-ring:0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family:'JetBrains Mono', monospace;
font-family:var(--mono);
background:var(--bg); color:var(--text);
min-height:100dvh; display:flex; flex-direction:column;
-webkit-font-smoothing:antialiased;
}
.pixel { font-family:var(--pixel); }
.hidden { display:none !important; }
.shrink { flex-shrink:0; }
.spacer { flex:1; }
/* ─────────────────────────────────────────────────────────────────
Header
───────────────────────────────────────────────────────────────── */
/* ── Header ── */
header {
padding:12px 20px; border-bottom:1px solid var(--border);
display:flex; align-items:center; gap:12px; flex-shrink:0;
background:var(--surface);
position:relative;
}
header h1 { font-family:var(--pixel); font-size:14px; color:var(--accent); }
header .brand-icon {
color:var(--accent); font-size:18px; line-height:1;
filter: drop-shadow(0 0 8px color-mix(in srgb, var(--accent) 35%, transparent));
}
header h1 {
font-family:var(--pixel); font-size:14px; color:var(--accent);
letter-spacing:0.05em;
}
.header-status {
background:none; border:none; padding:4px 8px; cursor:pointer;
font-family:var(--pixel); font-size:9px; color:var(--dim);
display:inline-flex; align-items:center; gap:6px;
border-radius:var(--radius);
transition: color .15s, background .15s;
}
.header-status .ph { font-size:12px; }
.header-status:hover {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.header-status.has-unread {
color: var(--accent);
position: relative;
}
.header-status.has-unread::after {
content:''; position:absolute; top:2px; right:2px;
width:6px; height:6px; border-radius:50%;
background:var(--danger);
box-shadow: 0 0 6px color-mix(in srgb, var(--danger) 60%, transparent);
}
.header-status:focus-visible { outline:none; box-shadow: var(--focus-ring); }
header .dot {
width:8px; height:8px; border-radius:50%;
background:var(--accent); animation:pulse 2s ease-in-out infinite;
box-shadow: 0 0 8px color-mix(in srgb, var(--accent) 60%, transparent);
}
@keyframes pulse {
0%,100% { opacity:1; transform:scale(1); }
50% { opacity:0.4; transform:scale(0.85); }
}
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
header .spacer { flex:1; }
header .user {
font-family:var(--pixel); font-size:10px; color:var(--muted);
display:flex; align-items:center; gap:6px;
}
header .user .badge {
background:var(--accent); color:var(--bg);
/* Match the .nav-btn box-model exactly so the badge sits flush with
the header buttons instead of being a tiny pill in the middle. */
padding:5px 10px; font-size:9px;
border:1px solid var(--accent); border-radius:3px;
border:1px solid var(--accent); border-radius:var(--radius);
line-height:1; font-family:var(--pixel);
}
header .nav-btn {
background:none; border:1px solid var(--border); color:var(--muted);
cursor:pointer; padding:5px 10px;
font-family:var(--pixel); font-size:9px; border-radius:3px;
font-family:var(--pixel); font-size:9px; border-radius:var(--radius);
transition:all .15s;
}
header .nav-btn:hover { color:var(--accent); border-color:var(--accent); }
header .nav-btn.active { background:var(--accent); color:var(--bg); border-color:var(--accent); }
header .nav-btn.danger:hover { color:var(--danger); border-color:var(--danger); }
header .nav-btn:hover {
color:var(--accent); border-color:var(--accent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
}
header .nav-btn:focus-visible {
outline:none; box-shadow: var(--focus-ring);
}
header .nav-btn.active {
background:var(--accent); color:var(--bg); border-color:var(--accent);
box-shadow: 0 0 12px -2px color-mix(in srgb, var(--accent) 50%, transparent);
}
header .nav-btn.danger:hover {
color:var(--danger); border-color:var(--danger);
background: color-mix(in srgb, var(--danger) 8%, transparent);
}
/* ─────────────────────────────────────────────────────────────────
Layout shell
───────────────────────────────────────────────────────────────── */
/* ── Layouts ── */
main {
flex:1; display:flex; flex-direction:column;
padding:20px; gap:16px; max-width:920px; width:100%;
@@ -61,97 +120,183 @@ main {
}
@media (max-width: 600px) { main { padding:12px; gap:12px; } }
/* ─────────────────────────────────────────────────────────────────
Form primitives — used by every view (auth, compose, account, admin)
───────────────────────────────────────────────────────────────── */
.field { display:flex; flex-direction:column; gap:5px; width:100%; }
.field-label,
.field > label {
font-family:var(--pixel); font-size:9px; color:var(--dim);
text-transform:uppercase; letter-spacing:0.05em;
}
.input,
.field input[type="text"],
.field input[type="password"],
.field input[type="email"],
.field select,
.field textarea {
background:var(--bg); border:1px solid var(--border);
color:var(--bright); padding:9px 12px; font-size:13px;
font-family:var(--mono); border-radius:var(--radius);
transition:border-color .15s, box-shadow .15s, background .15s;
width:100%;
}
.input:focus,
.field input:focus,
.field select:focus,
.field textarea:focus {
border-color:var(--accent); outline:none;
box-shadow: var(--focus-ring);
background: color-mix(in srgb, var(--bg) 96%, var(--accent) 4%);
}
.input::placeholder,
.field input::placeholder,
.field textarea::placeholder { color:var(--dim); }
.field textarea { min-height:140px; resize:vertical; line-height:1.55; }
.field-hint {
font-size:9px; color:var(--dim); line-height:1.4;
font-family:var(--mono);
}
/* Inline label-on-checkbox style */
label.checkbox {
font-family:var(--pixel); font-size:9px; color:var(--dim);
text-transform:uppercase;
display:flex; align-items:center; gap:6px; cursor:pointer;
}
label.checkbox input[type="checkbox"] { accent-color:var(--accent); cursor:pointer; }
/* ─────────────────────────────────────────────────────────────────
Buttons
───────────────────────────────────────────────────────────────── */
.btn {
padding:10px 18px; border:1px solid transparent; border-radius:var(--radius);
font-family:var(--pixel); font-size:11px; cursor:pointer;
transition:all .15s;
display:inline-flex; align-items:center; gap:6px;
justify-content:center;
}
.btn:active { transform: translateY(1px); }
.btn:focus-visible { outline:none; box-shadow: var(--focus-ring); }
.btn-primary { background:var(--accent); color:var(--bg); border-color:var(--accent); }
.btn-primary:hover {
filter:brightness(1.12);
transform: translateY(-1px);
box-shadow: var(--shadow-button);
}
.btn-secondary {
background:transparent; border-color:var(--border); color:var(--muted);
}
.btn-secondary:hover {
color:var(--accent); border-color:var(--accent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
transform: translateY(-1px);
}
.btn-danger {
background:transparent; border-color:var(--danger); color:var(--danger);
}
.btn-danger:hover {
background:var(--danger); color:var(--bg);
transform: translateY(-1px);
}
/* ─────────────────────────────────────────────────────────────────
Cards (compose, sections)
───────────────────────────────────────────────────────────────── */
.compose-card,
.section {
background:var(--surface); border:1px solid var(--border);
border-radius:var(--radius-lg); padding:18px;
display:flex; flex-direction:column; gap:12px;
}
.compose-card h2,
.section h2 {
font-family:var(--pixel); font-size:11px; color:var(--accent);
text-transform:uppercase; letter-spacing:0.05em;
display:flex; align-items:center; gap:8px;
}
.section h2 .count {
background:var(--bg); padding:3px 7px; border-radius:var(--radius);
font-size:9px; color:var(--muted);
border:1px solid var(--border);
}
/* ─────────────────────────────────────────────────────────────────
Compose
───────────────────────────────────────────────────────────────── */
#view-compose:not(.hidden) {
display: flex; flex-direction: column; gap: 16px;
}
.compose-card .actions {
display:flex; gap:8px; align-items:center;
}
.compose-card .actions .spacer { flex:1; }
/* Status text (used in compose, account, create-user) */
.status { font-family:var(--pixel); font-size:9px; color:var(--dim); }
.status.ok { color:var(--ok); }
.status.err { color:var(--danger); }
/* ─────────────────────────────────────────────────────────────────
Auth forms (setup, login, change-password)
───────────────────────────────────────────────────────────────── */
.auth-form {
display:flex; flex-direction:column; align-items:center;
padding:48px 24px; gap:14px; max-width:380px; margin:0 auto;
padding:40px 28px; gap:14px; max-width:400px; margin:48px auto 0;
background:var(--surface); border:1px solid var(--border);
border-radius:var(--radius-lg);
box-shadow: var(--shadow-card);
}
.auth-form .auth-icon {
font-size:36px; color:var(--accent); margin-bottom:4px;
filter: drop-shadow(0 0 16px color-mix(in srgb, var(--accent) 40%, transparent));
}
.auth-form h2 {
font-family:var(--pixel); font-size:14px; color:var(--accent);
margin-bottom:4px; letter-spacing:0.05em;
}
.auth-form p {
font-size:11px; color:var(--muted); text-align:center; line-height:1.55;
margin: 0 0 4px;
}
.auth-form h2 { font-family:var(--pixel); font-size:13px; color:var(--accent); margin-bottom:4px; }
.auth-form p { font-size:11px; color:var(--muted); text-align:center; line-height:1.5; }
.auth-form .field { width:100%; }
.auth-form .field label {
font-family:var(--pixel); font-size:9px; color:var(--dim);
text-transform:uppercase; display:block; margin-bottom:4px;
}
.auth-form input {
width:100%; background:var(--surface); border:1px solid var(--border);
color:var(--bright); padding:9px 12px; font-size:13px;
font-family:'JetBrains Mono', monospace; border-radius:3px;
}
.auth-form input:focus { border-color:var(--accent); outline:none; }
.auth-form .btn { width:100%; }
.auth-form .err {
font-family:var(--pixel); font-size:9px; color:var(--danger);
min-height:11px; text-align:center;
}
/* ── Buttons ── */
.btn {
padding:10px 18px; border:none; border-radius:3px;
font-family:var(--pixel); font-size:11px; cursor:pointer;
transition:all .15s;
/* Flash-highlight: a brief accent pulse on a card after the user
tapped the header inbox link and we just scrolled it into view. */
@keyframes flash-highlight {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 60%, transparent); }
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 30%, transparent),
0 0 24px 4px color-mix(in srgb, var(--accent) 50%, transparent); }
100% { box-shadow: 0 0 0 0 transparent; }
}
.flash-highlight {
animation: flash-highlight 1.6s ease-out;
}
.btn:active { opacity:.7; }
.btn-primary { background:var(--accent); color:var(--bg); }
.btn-primary:hover { filter:brightness(1.1); }
.btn-secondary { background:transparent; border:1px solid var(--border); color:var(--muted); }
.btn-secondary:hover { color:var(--accent); border-color:var(--accent); }
.btn-danger { background:transparent; border:1px solid var(--danger); color:var(--danger); }
.btn-danger:hover { background:var(--danger); color:var(--bg); }
/* ── Compose view ── */
#view-compose:not(.hidden) {
display: flex;
flex-direction: column;
gap: 16px;
}
.compose-card {
background:var(--surface); border:1px solid var(--border);
border-radius:4px; padding:16px;
display:flex; flex-direction:column; gap:10px;
}
.compose-card h2 {
font-family:var(--pixel); font-size:11px; color:var(--accent);
text-transform:uppercase;
}
.compose-card .field { display:flex; flex-direction:column; gap:4px; }
.compose-card .field label {
font-family:var(--pixel); font-size:9px; color:var(--dim);
text-transform:uppercase;
}
.compose-card input, .compose-card select, .compose-card textarea {
background:var(--bg); border:1px solid var(--border); color:var(--bright);
padding:8px 10px; font-size:13px;
font-family:'JetBrains Mono', monospace; border-radius:3px;
}
.compose-card input:focus, .compose-card select:focus, .compose-card textarea:focus {
border-color:var(--accent); outline:none;
}
.compose-card textarea { min-height:140px; resize:vertical; line-height:1.55; }
.compose-card .actions { display:flex; gap:8px; align-items:center; }
.compose-card .actions .spacer { flex:1; }
.compose-card .status { font-family:var(--pixel); font-size:9px; color:var(--dim); }
.compose-card .status.ok { color:var(--accent); }
.compose-card .status.err { color:var(--danger); }
/* ── Inbox + admin lists ── */
.section {
background:var(--surface); border:1px solid var(--border);
border-radius:4px; padding:16px;
}
.section h2 {
font-family:var(--pixel); font-size:11px; color:var(--accent);
text-transform:uppercase; margin-bottom:10px;
display:flex; align-items:center; gap:8px;
}
.section h2 .count {
background:var(--bg); padding:2px 6px; border-radius:2px;
font-size:9px; color:var(--muted);
}
.section .empty { font-size:11px; color:var(--dim); font-style:italic; padding:8px 0; }
/* ─────────────────────────────────────────────────────────────────
Inbox + lists
───────────────────────────────────────────────────────────────── */
.inbox-item {
display:flex; align-items:flex-start; gap:10px;
padding:10px 0; border-bottom:1px solid var(--border);
padding:12px 10px; margin: 0 -10px;
border-bottom:1px solid var(--border);
transition: background .12s;
border-radius:var(--radius);
}
.inbox-item:hover {
background: color-mix(in srgb, var(--raised) 70%, transparent);
}
.inbox-item:last-child { border-bottom:none; }
.inbox-item .meta {
@@ -167,95 +312,218 @@ main {
font-size:11px; color:var(--text); white-space:pre-wrap;
word-break:break-word; max-height:120px; overflow:auto;
}
.inbox-item .from { font-family:var(--pixel); font-size:9px; color:var(--accent2); }
.inbox-item .from {
font-family:var(--pixel); font-size:9px; color:var(--accent2);
display:inline-flex; align-items:center; gap:6px;
margin-bottom:4px;
}
.inbox-item .from .ph { font-size:11px; }
.badge-new {
display:inline-block;
background:var(--accent); color:var(--bg);
padding:1px 6px; border-radius:var(--radius);
font-family:var(--pixel); font-size:8px;
text-transform:uppercase; letter-spacing:0.05em;
margin-left:4px;
}
.inbox-item .del {
background:none; border:none; color:var(--dim); cursor:pointer;
padding:4px; font-size:14px; flex-shrink:0;
padding:6px; font-size:14px; flex-shrink:0;
border-radius:var(--radius);
transition: color .15s, background .15s;
}
.inbox-item .del:hover {
color:var(--danger);
background: color-mix(in srgb, var(--danger) 10%, transparent);
}
.inbox-item .del:hover { color:var(--danger); }
/* User row (admin) */
.user-row {
display:flex; align-items:center; gap:12px;
padding:10px 0; border-bottom:1px solid var(--border);
padding:12px 10px; margin: 0 -10px;
border-bottom:1px solid var(--border);
border-radius:var(--radius);
transition: background .12s;
}
.user-row:hover {
background: color-mix(in srgb, var(--raised) 70%, transparent);
}
.user-row:last-child { border-bottom:none; }
.user-row .info { flex:1; }
.user-row .name { font-family:var(--pixel); font-size:11px; color:var(--bright); }
.user-row .meta { font-size:10px; color:var(--muted); margin-top:2px; }
.user-row .name {
font-family:var(--pixel); font-size:11px; color:var(--bright);
display:flex; align-items:center; gap:6px;
}
.user-row .meta { font-size:10px; color:var(--muted); margin-top:3px; }
.user-row .badge {
background:var(--accent); color:var(--bg);
padding:1px 6px; font-family:var(--pixel); font-size:8px;
text-transform:uppercase; border-radius:2px;
padding:2px 7px; font-family:var(--pixel); font-size:8px;
text-transform:uppercase; border-radius:var(--radius);
}
.user-row .actions { display:flex; gap:6px; }
.user-row .actions .btn { padding:4px 10px; font-size:9px; }
.user-row .actions .btn { padding:5px 10px; font-size:9px; }
/* User-row icon */
.user-row .user-icon { font-size:20px; color:var(--accent); flex-shrink:0; }
/* ─────────────────────────────────────────────────────────────────
Empty state — used by inbox, users list, etc.
───────────────────────────────────────────────────────────────── */
.empty-state {
display:flex; flex-direction:column; align-items:center;
justify-content:center; padding:36px 12px; gap:8px;
color:var(--dim); text-align:center;
}
.empty-state .icon {
font-size:38px; color:var(--dim); opacity:0.6;
}
.empty-state .label {
font-family:var(--pixel); font-size:10px; color:var(--muted);
text-transform:uppercase; letter-spacing:0.05em;
}
.empty-state .hint {
font-size:11px; color:var(--dim); line-height:1.5;
max-width:280px;
}
.empty {
font-size:11px; color:var(--dim); font-style:italic; padding:8px 0;
}
/* ─────────────────────────────────────────────────────────────────
Loading skeleton (used by inbox / users list while fetching)
───────────────────────────────────────────────────────────────── */
.skeleton-row {
display:flex; align-items:center; gap:10px; padding:12px 10px;
border-bottom:1px solid var(--border);
}
.skeleton-row:last-child { border-bottom:none; }
.skel {
background: linear-gradient(90deg,
color-mix(in srgb, var(--raised) 60%, transparent) 0%,
color-mix(in srgb, var(--raised) 90%, transparent) 50%,
color-mix(in srgb, var(--raised) 60%, transparent) 100%);
background-size: 200% 100%;
animation: skeleton 1.4s ease-in-out infinite;
border-radius:2px; height:10px; width:60%;
}
.skel.short { width:30%; }
.skel.tall { height:14px; }
@keyframes skeleton {
0% { background-position:200% 0; }
100% { background-position:-200% 0; }
}
/* ─────────────────────────────────────────────────────────────────
Account view — secret display + input row
───────────────────────────────────────────────────────────────── */
.account-secret-row {
display:flex; gap:8px; align-items:stretch;
}
.account-secret-row .input { flex:1; }
.account-secret-block { margin-bottom:14px; }
.account-divider {
text-align:center; color:var(--dim);
font-family:var(--pixel); font-size:9px;
text-transform:uppercase; letter-spacing:0.05em;
margin: 4px 0;
}
.account-secret-grid {
display:grid; grid-template-columns:1fr; gap:14px;
}
.account-secret-actions {
margin-top:24px; padding-top:14px; border-top:1px solid var(--border);
}
/* ─────────────────────────────────────────────────────────────────
Create user form (admin)
───────────────────────────────────────────────────────────────── */
.create-user-form {
display:flex; flex-direction:column; gap:10px;
display:flex; flex-direction:column; gap:12px;
padding-top:14px; border-top:1px solid var(--border); margin-top:14px;
}
.create-user-form .field-label {
font-family:var(--pixel); font-size:9px; color:var(--dim);
text-transform:uppercase; display:block; margin-bottom:4px;
}
.create-user-form input[type="text"],
.create-user-form input[type="password"] {
width:100%;
background:var(--bg); border:1px solid var(--border); color:var(--bright);
padding:8px 11px; font-size:12px;
font-family:'JetBrains Mono', monospace; border-radius:3px;
}
.create-user-form input:focus { border-color:var(--accent); outline:none; }
.create-user-form label.checkbox {
font-family:var(--pixel); font-size:9px; color:var(--dim);
text-transform:uppercase;
display:flex; align-items:center; gap:6px; cursor:pointer;
}
.create-user-form label.checkbox input[type="checkbox"] {
accent-color:var(--accent); cursor:pointer;
}
.create-user-form .actions {
display:flex; gap:8px; align-items:center;
margin-top:4px;
display:flex; gap:8px; align-items:center; margin-top:4px;
}
.toast {
/* ─────────────────────────────────────────────────────────────────
Toast — single + stack support
───────────────────────────────────────────────────────────────── */
#toast-stack {
position:fixed; top:20px; right:20px;
background:var(--accent); color:var(--bg);
padding:10px 18px; border-radius:3px;
font-family:var(--pixel); font-size:11px;
opacity:0; transition:opacity .3s; z-index:100; pointer-events:none;
display:flex; flex-direction:column; gap:8px;
z-index:200; pointer-events:none;
}
.toast.show { opacity:1; }
.toast.err { background:var(--danger); }
.toast {
background:var(--surface); color:var(--bright);
padding:10px 14px; border-radius:var(--radius-lg);
font-family:var(--pixel); font-size:10px;
display:flex; align-items:center; gap:8px;
border:1px solid var(--border);
box-shadow: var(--shadow-popover);
pointer-events:auto;
transform: translateX(120%);
transition: transform .25s cubic-bezier(0.16, 1, 0.3, 1), opacity .25s;
opacity:0;
min-width:180px; max-width:320px;
}
.toast.show { transform: translateX(0); opacity:1; }
.toast.fade-out { opacity:0; transform: translateX(20%); }
.toast .toast-icon { font-size:14px; flex-shrink:0; }
.toast .toast-msg { line-height:1.3; word-break:break-word; }
.toast.info { border-color: color-mix(in srgb, var(--accent) 60%, var(--border)); }
.toast.info .toast-icon { color: var(--accent); }
.toast.ok { border-color: color-mix(in srgb, var(--ok) 60%, var(--border)); }
.toast.ok .toast-icon { color: var(--ok); }
.toast.err { border-color: color-mix(in srgb, var(--danger) 60%, var(--border)); }
.toast.err .toast-icon { color: var(--danger); }
.notice { padding:10px 14px; border-radius:3px; font-size:11px; margin-bottom:12px; }
.notice.info { background:rgba(56,189,248,0.1); border:1px solid var(--accent); color:var(--accent); }
.notice.warn { background:rgba(251,113,133,0.1); border:1px solid var(--danger); color:var(--danger); }
/* ─────────────────────────────────────────────────────────────────
Notices
───────────────────────────────────────────────────────────────── */
.notice {
padding:10px 14px; border-radius:var(--radius); font-size:11px;
margin-bottom:12px;
display:flex; align-items:flex-start; gap:8px;
line-height:1.5;
}
.notice .ph { font-size:14px; flex-shrink:0; margin-top:1px; }
.notice.info { background:rgba(56,189,248,0.08); border:1px solid color-mix(in srgb, var(--accent) 60%, var(--border)); color:var(--accent); }
.notice.warn { background:rgba(251,113,133,0.08); border:1px solid color-mix(in srgb, var(--danger) 60%, var(--border)); color:var(--danger); }
/* ─────────────────────────────────────────────────────────────────
Phosphor icon defaults
───────────────────────────────────────────────────────────────── */
/* ── Phosphor icons ── */
.ph { display:inline-block; vertical-align:-2px; }
header .nav-btn .ph { font-size:11px; vertical-align:-1px; margin-right:2px; }
header h1 + .ph { font-size:12px; }
.btn .ph { font-size:12px; vertical-align:-2px; margin-right:2px; }
.btn .ph { font-size:12px; vertical-align:-2px; }
.section h2 .ph, .compose-card h2 .ph { font-size:13px; vertical-align:-2px; }
/* ── Default nav layout (desktop) ── */
/* ─────────────────────────────────────────────────────────────────
Default nav layout (desktop)
───────────────────────────────────────────────────────────────── */
#nav-buttons:not(.hidden) {
display:flex; gap:6px; align-items:center;
}
#nav-burger {
display:none; /* desktop hides the burger */
display:none;
padding:5px 10px; font-size:11px;
}
/* ── Mobile: collapse nav into a burger menu ── */
/* ─────────────────────────────────────────────────────────────────
Mobile: collapse nav into a burger menu
───────────────────────────────────────────────────────────────── */
@media (max-width: 700px) {
/* Hide identity tag on mobile — it's redundant with the menu */
#nav-user { display:none !important; }
/* Burger replaces the inline nav row */
#nav-burger { display:inline-flex !important; align-items:center; }
/* Default-hide the nav row; .menu-open re-shows it as a popover */
#nav-buttons:not(.hidden) { display:none; }
#nav-buttons.menu-open {
display:flex !important;
@@ -267,22 +535,19 @@ header h1 + .ph { font-size:12px; }
margin-top:6px;
background:var(--surface);
border:1px solid var(--border);
border-radius:4px;
box-shadow:0 8px 24px rgba(0,0,0,0.4);
border-radius:var(--radius-lg);
box-shadow: var(--shadow-popover);
padding:6px;
gap:4px;
min-width:160px;
min-width:180px;
z-index:50;
}
#nav-buttons.menu-open .nav-btn {
text-align:left;
width:100%;
padding: 8px 12px;
}
/* Anchor the popover to the header row */
header { position:relative; }
/* Admin view on mobile: stack the two columns. The desktop layout
has its own grid; here we just let the section scroll naturally. */
#view-admin .section {
display:block !important;
padding:14px 12px;
@@ -291,32 +556,33 @@ header h1 + .ph { font-size:12px; }
#view-admin .section .create-user-form { margin-top:14px; }
}
/* ── Desktop layout ── */
/* ─────────────────────────────────────────────────────────────────
Desktop layout (≥ 901px)
───────────────────────────────────────────────────────────────── */
@media (min-width: 901px) {
/* Subtle background gradient gives the page some depth */
body {
background:
radial-gradient(ellipse at top left, rgba(56,189,248,0.04), transparent 50%),
radial-gradient(ellipse at bottom right, rgba(129,140,248,0.04), transparent 50%),
radial-gradient(ellipse at top left, rgba(56,189,248,0.05), transparent 50%),
radial-gradient(ellipse at bottom right, rgba(129,140,248,0.05), transparent 50%),
var(--bg);
}
/* Wider canvas, more breathing room */
main {
max-width: 1200px;
padding: 32px 32px 48px;
gap: 24px;
}
/* Header: bigger title, more padding */
header {
padding: 14px 32px;
}
header h1 {
font-size: 15px;
letter-spacing: 1px;
font-size: 16px;
letter-spacing: 0.08em;
}
header .brand-icon { font-size:20px; }
header .nav-btn {
padding: 7px 12px;
}
@@ -324,10 +590,10 @@ header h1 + .ph { font-size:12px; }
padding: 4px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 3px;
border-radius: var(--radius);
}
/* Compose view: 2-column grid (compose | inbox) */
/* Compose view: 2-column grid */
#view-compose:not(.hidden) {
display: grid !important;
grid-template-columns: minmax(0, 1.7fr) minmax(0, 1fr);
@@ -339,31 +605,28 @@ header h1 + .ph { font-size:12px; }
.compose-card,
.section {
padding: 22px 24px;
box-shadow:
0 1px 2px rgba(0,0,0,0.3),
0 8px 24px -8px rgba(0,0,0,0.4);
box-shadow: var(--shadow-card);
border-color: color-mix(in srgb, var(--border) 70%, var(--accent) 12%);
}
.compose-card h2,
.section h2 {
font-size: 12px;
margin-bottom: 16px;
margin-bottom: 6px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
/* Big textarea for desktop typing */
.compose-card textarea {
.compose-card .field textarea {
min-height: 320px;
font-size: 14px;
padding: 12px 14px;
}
.compose-card input,
.compose-card select {
.compose-card .field input,
.compose-card .field select {
padding: 9px 12px;
font-size: 14px;
}
.compose-card .field label {
.compose-card .field > label {
font-size: 10px;
margin-bottom: 6px;
}
@@ -377,11 +640,11 @@ header h1 + .ph { font-size:12px; }
font-size: 12px;
}
/* Inbox card: stick to top of viewport, hover lifts it */
/* Inbox card sticks under the header */
#inbox-section {
position: sticky;
top: 24px;
transition: transform .15s, box-shadow .15s;
transition: box-shadow .15s;
}
#inbox-section:hover {
box-shadow:
@@ -390,7 +653,7 @@ header h1 + .ph { font-size:12px; }
0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent);
}
/* Admin view: 2-column grid (user list | create form) */
/* Admin: 2-column grid */
#view-admin:not(.hidden) .section {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
@@ -411,44 +674,17 @@ header h1 + .ph { font-size:12px; }
border-left: 1px solid var(--border);
}
/* User row polish: hover lift */
.user-row {
padding: 14px 12px;
margin: 0 -12px;
border-radius: 3px;
transition: background .12s;
}
.user-row:hover {
background: color-mix(in srgb, var(--raised) 70%, transparent);
}
.user-row .name {
font-size: 12px;
}
.user-row .name { font-size: 12px; }
/* Auth forms (setup/login) — vertically center on viewport */
/* Auth-forms: full vertical centering, larger icon */
.auth-form {
margin-top: 64px;
padding: 40px 32px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
box-shadow:
0 1px 2px rgba(0,0,0,0.3),
0 16px 48px -16px rgba(0,0,0,0.5);
max-width: 420px;
margin-top: 88px;
padding: 44px 36px;
max-width: 440px;
}
.auth-form h2 {
font-size: 14px;
margin-bottom: 8px;
}
/* Buttons: subtle press */
.btn { transition: all .12s; }
.btn-primary:hover {
filter: brightness(1.15);
transform: translateY(-1px);
box-shadow: 0 4px 12px -4px rgba(56,189,248,0.5);
}
.btn-secondary:hover { transform: translateY(-1px); }
.btn-danger:hover { transform: translateY(-1px); }
.auth-form h2 { font-size: 16px; margin-bottom: 8px; }
.auth-form .auth-icon { font-size: 44px; }
}