Compare commits

...

2 Commits

Author SHA1 Message Date
Ben Stull 1d716d0cb8 v0.31.2: landing welcome panel spacing
Visual-only patch. The "/" welcome read-view was jammed against the
catalog divider with no top offset and loose paragraph rhythm: the
.main-pane §8 override (padding:0; display:flex) shadows the padded
read-view rule, so the pane gives no padding, and .welcome (max-width
only) never compensated. The welcome surface now owns its breathing
room — 56px top / 48px side gutters, a 680px measure, a text-3xl hero,
and even --space-8 paragraph spacing at --leading-relaxed. Covers both
the signed-out and signed-in welcome.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:54:29 -07:00
Ben Stull f96883506e v0.31.1: admin Users tab + header UX polish
Visual-only patch atop v0.31.0. CSS plus markup/structure in Admin.jsx;
no API, schema, config, overlay, or secret change — a plain frontend
rebuild applies it.

Two latent CSS defects fixed:
- .invite-badge had no rule, so "(pending invite)" rendered as bare
  parenthetical text; it's now a quiet amber pill.
- .btn-link-quiet never reset native <button> chrome, so the admin
  Revoke/Grant/Remove buttons, the modal close ×, and Login/BetaPending
  link-buttons kept the browser's grey button box. The reset the
  .otc-login scope already carried is folded into the base rule.

Users tab: table headers no longer wrap (WRITE-MUTED), timestamps
render as a date-over-time stack, the duplicated subline email is
de-duped, the Create-user-+-invite action moves flush-right beside the
title, and intro DB-column refs read as quiet chips.

Header: the Inbox (§15.2) trigger was styled for a light surface
(gray-200 border, gray-50 hover) and rendered as a pale box that went
white-on-white on hover; restyled to the nav-link vocabulary
(borderless, gray-300 icon → white on a faint translucent hover).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:32:47 -07:00
5 changed files with 173 additions and 35 deletions
+59
View File
@@ -23,6 +23,65 @@ skip versions are the composition of each intervening adjacent
release's steps in order — no A-to-B path is pre-computed beyond release's steps in order — no A-to-B path is pre-computed beyond
that. that.
## 0.31.2 — 2026-05-29
**Patch — landing (`/`) welcome panel spacing. Visual only: CSS in
`App.css` (`.welcome`). A plain frontend rebuild applies it.**
The welcome read-view was jammed against the catalog divider with no
top offset and loose, uneven paragraph spacing. Root cause: `.main-pane`
carries a bare `.main-pane { padding: 0; display: flex }` override (the
§8 three-column RFC shell) that shadows the earlier padded read-view
rule, so the pane provides no padding — and `.welcome` (just
`max-width`) never compensated. The welcome surface now owns its own
breathing room: 56px top / 48px side gutters, a capped 680px measure,
a stronger `text-3xl` "Welcome." hero, and even `--space-8` paragraph
rhythm at `--leading-relaxed`. Applies to both the signed-out and
signed-in welcome (same `.welcome` class).
Upgrade steps: none. **SHOULD** deploy as a normal code deploy.
## 0.31.1 — 2026-05-29
**Patch — admin Users tab + header UX polish. Visual only: CSS plus
markup/structure in `Admin.jsx` (no API, schema, config, overlay, or
secret change). A plain frontend rebuild applies it.**
Two latent CSS defects fixed:
- **`.invite-badge` had no rule.** The "(pending invite)" marker on
admin-created-but-unclaimed user rows rendered as bare parenthetical
text. It's now a quiet amber pill, consistent with the other status
badges.
- **`.btn-link-quiet` never reset native button chrome.** Used as a
bare link-style `<button>` (admin Revoke / Grant / Remove, the modal
close ×, and link-buttons in Login / BetaPending), it kept the
browser's default grey button box. The reset that the `.otc-login`
scope already carried is folded into the base rule, so every
`btn-link-quiet` is now a true quiet link.
Users-tab cleanups, all token-based:
- Table column headers no longer wrap (`WRITE-MUTED` was breaking onto
two lines); timestamps render as an intentional date-over-time stack
instead of a ragged mid-value wrap; the duplicated email in a row's
subline (the handle already *is* the email when there's no Gitea
login) is de-duplicated; the "Create user + invite" action moves
flush-right beside the title; inline DB-column references in the
intro copy read as quiet chips.
Header:
- **Inbox (§15.2) trigger restyled for the dark header.** It carried a
light-surface treatment — a `gray-200` border and a `gray-50` hover —
that rendered as a pale box in the nav and went white-background /
white-icon (invisible) on hover. It now speaks the nav-link
vocabulary (`.header-about` et al.): borderless, `gray-300` icon
brightening to white on a faint translucent hover, unread badge
unchanged.
Upgrade steps: none. **SHOULD** deploy as a normal code deploy.
## 0.31.0 — 2026-05-29 ## 0.31.0 — 2026-05-29
**Minor — meta-only repository topology (SPEC §1, ROADMAP #36). RFCs no **Minor — meta-only repository topology (SPEC §1, ROADMAP #36). RFCs no
+1 -1
View File
@@ -1 +1 @@
0.31.0 0.31.2
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "rfc-app-frontend", "name": "rfc-app-frontend",
"private": true, "private": true,
"version": "0.31.0", "version": "0.31.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+67 -11
View File
@@ -189,9 +189,27 @@
padding: 32px 48px; padding: 32px 48px;
} }
.welcome { max-width: 640px; } /* `.main-pane` is the §8 flex shell and carries no padding (the bare
.welcome h1 { font-size: var(--text-2xl); font-weight: 600; margin: 0 0 16px; } `.main-pane` override below shadows the padded read-view rule), so the
.welcome p { line-height: 1.7; color: var(--c-gray-600); } welcome surface owns its own breathing room — top offset, comfortable
side gutters, and a capped measure for readable line length. */
.welcome {
max-width: 680px;
padding: 56px 48px 64px;
}
.welcome h1 {
font-size: var(--text-3xl); font-weight: 600;
letter-spacing: -0.01em;
margin: 0 0 var(--space-8);
}
.welcome p {
font-size: var(--text-lg);
line-height: var(--leading-relaxed);
color: var(--c-gray-600);
margin: 0 0 var(--space-8);
}
.welcome p:last-child { margin-bottom: 0; }
.welcome strong { color: var(--c-gray-800); }
/* --- RFC / Proposal view (read-only for slice 1) --- */ /* --- RFC / Proposal view (read-only for slice 1) --- */
@@ -542,7 +560,10 @@
font-size: var(--text-md); font-weight: 600; text-decoration: none; font-size: var(--text-md); font-weight: 600; text-decoration: none;
} }
.beta-pending-actions .btn-primary:hover { background: var(--c-gray-700); } .beta-pending-actions .btn-primary:hover { background: var(--c-gray-700); }
.btn-link-quiet { color: var(--c-gray-500); text-decoration: none; font-size: var(--text-base); } .btn-link-quiet {
background: none; border: none; padding: 0; cursor: pointer;
color: var(--c-gray-500); text-decoration: none; font-size: var(--text-base);
}
.btn-link-quiet:hover { color: var(--c-ink); text-decoration: underline; } .btn-link-quiet:hover { color: var(--c-ink); text-decoration: underline; }
/* v0.8.0 — thin "your beta access is in review" banner. Shown on every /* v0.8.0 — thin "your beta access is in review" banner. Shown on every
@@ -1607,12 +1628,18 @@
/* ---- §15 / Slice 6: inbox, badge, toasts ---- */ /* ---- §15 / Slice 6: inbox, badge, toasts ---- */
/* Lives on the dark header — so it speaks the nav-link vocabulary
(.header-about et al.): borderless, gray-300 icon brightening to white
on a faint translucent-white hover. The old light-gray border + gray-50
hover were styled for a light surface and rendered as a pale box that
went white-on-white (invisible icon) on hover. */
.inbox-trigger { .inbox-trigger {
position: relative; background: transparent; border: 1px solid var(--c-gray-200); position: relative; display: inline-flex; align-items: center; justify-content: center;
border-radius: var(--radius-md); padding: 4px 10px; cursor: pointer; font-size: var(--text-lg); background: transparent; border: none;
margin-right: 12px; color: var(--c-gray-300); cursor: pointer;
padding: 5px 8px; border-radius: var(--radius-sm);
} }
.inbox-trigger:hover { background: var(--c-gray-50); } .inbox-trigger:hover { color: var(--c-white); background: rgba(255,255,255,0.08); }
.inbox-trigger .badge { .inbox-trigger .badge {
position: absolute; top: -6px; right: -6px; position: absolute; top: -6px; right: -6px;
background: #dc2626; color: white; font-size: var(--text-2xs); background: #dc2626; color: white; font-size: var(--text-2xs);
@@ -1856,7 +1883,7 @@
} }
.settings-table th, .admin-table th { .settings-table th, .admin-table th {
text-align: left; padding: 6px 8px; text-align: left; padding: 6px 8px;
font-size: var(--text-xs); text-transform: uppercase; font-size: var(--text-xs); text-transform: uppercase; white-space: nowrap;
color: var(--c-gray-500); letter-spacing: 0.05em; font-weight: 600; color: var(--c-gray-500); letter-spacing: 0.05em; font-weight: 600;
border-bottom: 1px solid var(--c-gray-200); border-bottom: 1px solid var(--c-gray-200);
} }
@@ -1937,7 +1964,21 @@
.admin-tab-header h2 { .admin-tab-header h2 {
margin: 0 0 4px; font-size: var(--text-xl); font-weight: 700; margin: 0 0 4px; font-size: var(--text-xl); font-weight: 700;
} }
.admin-tab-header p { margin: 0 0 24px; font-size: var(--text-base); } .admin-tab-header p { margin: 0 0 24px; font-size: var(--text-base); max-width: 70ch; line-height: var(--leading-normal); }
/* Title row: heading on the left, primary action flush right. */
.admin-tab-heading {
display: flex; align-items: flex-start; justify-content: space-between;
gap: var(--space-7); margin-bottom: var(--space-2);
}
.admin-tab-heading h2 { margin: 0; }
.admin-tab-actions { flex-shrink: 0; }
/* Inline DB-column references in admin copy read as quiet chips, not raw
monospace runs jammed against the sans body. */
.admin-tab-header code {
font-family: var(--font-mono); font-size: var(--text-sm);
background: var(--c-gray-100); color: var(--c-gray-700);
padding: 1px 5px; border-radius: var(--radius-sm);
}
.admin-section-h { .admin-section-h {
font-size: var(--text-base); text-transform: uppercase; font-size: var(--text-base); text-transform: uppercase;
letter-spacing: 0.05em; color: var(--c-gray-500); letter-spacing: 0.05em; color: var(--c-gray-500);
@@ -1977,8 +2018,23 @@
.allowlist-add .btn-primary:hover:not(:disabled) { background: var(--c-gray-700); } .allowlist-add .btn-primary:hover:not(:disabled) { background: var(--c-gray-700); }
.allowlist-add .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } .allowlist-add .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.user-cell { display: flex; flex-direction: column; gap: 1px; } .user-cell { display: flex; flex-direction: column; gap: 2px; }
.user-cell-handle { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
.user-handle { font-weight: 500; color: var(--c-gray-900); } .user-handle { font-weight: 500; color: var(--c-gray-900); }
/* "(pending invite)" — an unclaimed admin-created row. A quiet amber pill
so the admin spots it at a glance without it shouting. */
.invite-badge {
font-size: var(--text-2xs); font-weight: 600;
text-transform: uppercase; letter-spacing: 0.04em;
padding: 1px 6px; border-radius: var(--radius-pill);
background: var(--c-warning-bg); color: var(--c-warning-fg);
white-space: nowrap;
}
/* Timestamps: an intentional date-over-time stack rather than a ragged
mid-value wrap. nowrap keeps each line whole. */
.user-when { white-space: nowrap; }
.user-when-date { display: block; color: var(--c-gray-700); }
.user-when-time { display: block; font-size: var(--text-xs); }
.mute-toggle { .mute-toggle {
display: inline-flex; align-items: center; gap: 6px; display: inline-flex; align-items: center; gap: 6px;
font-size: var(--text-base); cursor: pointer; font-size: var(--text-base); cursor: pointer;
+45 -22
View File
@@ -181,7 +181,20 @@ function UsersTab() {
return ( return (
<div className="admin-tab"> <div className="admin-tab">
<header className="admin-tab-header"> <header className="admin-tab-header">
<h2>Users</h2> <div className="admin-tab-heading">
<h2>Users</h2>
{/* v0.17.0 roadmap item #16. The "Create user + invite"
affordance opens a modal that provisions a fresh users row
with the chosen role and sends an invite email with a
single-use claim link. */}
<div className="admin-tab-actions">
<button
type="button"
className="btn-primary"
onClick={() => setInviteModalOpen(true)}
>Create user + invite</button>
</div>
</div>
<p className="muted"> <p className="muted">
The pending bucket is the beta-access review queue (§6.1 / The pending bucket is the beta-access review queue (§6.1 /
v0.8.0). Grant or revoke writes to <code>permission_events</code> v0.8.0). Grant or revoke writes to <code>permission_events</code>
@@ -190,17 +203,6 @@ function UsersTab() {
retain their v0.7.0 semantics promote to admin to remove a retain their v0.7.0 semantics promote to admin to remove a
user's ability to write without silencing them. user's ability to write without silencing them.
</p> </p>
{/* v0.17.0 roadmap item #16. The "Create user + invite"
affordance opens a modal that provisions a fresh users row
with the chosen role and sends an invite email with a
single-use claim link. */}
<div className="admin-tab-actions">
<button
type="button"
className="btn-primary"
onClick={() => setInviteModalOpen(true)}
>Create user + invite</button>
</div>
</header> </header>
{error && <p className="settings-note warning">{error}</p>} {error && <p className="settings-note warning">{error}</p>}
{inviteModalOpen && ( {inviteModalOpen && (
@@ -270,21 +272,27 @@ function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }
// the admin sees at a glance which rows are real users vs. unclaimed // the admin sees at a glance which rows are real users vs. unclaimed
// invites. // invites.
const pendingInvite = u.pending_invite const pendingInvite = u.pending_invite
// When there's no gitea_login the handle already IS the email, so the
// subline would otherwise repeat it. Only append the email when it adds
// something the handle doesn't already show.
const showEmail = u.email && u.email !== handle
return ( return (
<> <>
<tr> <tr>
<td> <td>
<div className="user-cell"> <div className="user-cell">
<span className="user-handle">{handle}</span> <div className="user-cell-handle">
{pendingInvite && ( <span className="user-handle">{handle}</span>
<span {pendingInvite && (
className="invite-badge" <span
title={`Admin-created invite; expires ${pendingInvite.expires_at}`} className="invite-badge"
>(pending invite)</span> title={`Admin-created invite; expires ${pendingInvite.expires_at}`}
)} >pending invite</span>
)}
</div>
<span className="muted"> <span className="muted">
{fullName || u.display_name} {fullName || u.display_name}
{u.email ? ` · ${u.email}` : ''} {showEmail ? ` · ${u.email}` : ''}
</span> </span>
</div> </div>
</td> </td>
@@ -317,8 +325,8 @@ function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }
<span className="muted">N/A</span> <span className="muted">N/A</span>
)} )}
</td> </td>
<td className="muted">{u.created_at || '—'}</td> <TimeCell value={u.created_at} />
<td className="muted">{u.last_seen_at || '—'}</td> <TimeCell value={u.last_seen_at} />
</tr> </tr>
{state === 'pending' && u.beta_request_reason ? ( {state === 'pending' && u.beta_request_reason ? (
<tr className="user-row-reason"> <tr className="user-row-reason">
@@ -334,6 +342,21 @@ function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }
) )
} }
// Render a "YYYY-MM-DD HH:MM:SS" timestamp as an intentional date-over-time
// stack (date prominent, time quiet below) rather than letting a narrow
// column wrap the value mid-string. Falls back to an em-dash when absent.
function TimeCell({ value }) {
if (!value) return <td className="muted"></td>
const [date, ...rest] = String(value).split(' ')
const time = rest.join(' ')
return (
<td className="user-when">
<span className="user-when-date">{date}</span>
{time && <span className="user-when-time muted">{time}</span>}
</td>
)
}
function PermissionCell({ user: u, busy, onFlipPermission }) { function PermissionCell({ user: u, busy, onFlipPermission }) {
const state = u.permission_state || 'granted' const state = u.permission_state || 'granted'
const decidedSuffix = u.permission_decided_at const decidedSuffix = u.permission_decided_at