Slice 7: §14 chrome + settings and admin neighborhoods

§14.1 richer landing, §14.2 /philosophy route (disk-backed), §14.3
persistent About link. /settings/notifications surfaces Slice 6's
preferences/quiet-hours/mute/watches endpoints. /admin home base
consolidates role management, the §6.2 write-mute, the audit-log
viewer, the permission-events log, and the §13.2 graduation queue.

Backend: backend/app/philosophy.py, backend/app/api_admin.py (seven
admin endpoints + user-search), GET /api/users/me/notification-mutes.
Frontend: Landing.jsx (deck), Philosophy.jsx, NotificationSettings.jsx,
Admin.jsx, App.jsx routing for the chrome surfaces.

Tests: backend/tests/test_chrome_vertical.py — 13 cases. Full suite
75/75 green.

Spec corrections: §14.2 (PHILOSOPHY.md source is a deployment-time
decision), §17 (admin block extended to name the seven new endpoints
+ user-search and notification-mutes read). §19.1 rewritten for
Slice 8 hardening; §19.2 grew four candidates (owner succession,
mute-from-actor, the "Following since <date>" disclosure, audit-log
row prose).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-24 23:40:49 -07:00
parent f67d0aa0db
commit 060fa408a2
14 changed files with 2722 additions and 158 deletions
+273
View File
@@ -1218,3 +1218,276 @@
.toast.cat-personal-direct { border-left-color: #d97706; }
.toast.cat-structural { border-left-color: #2563eb; }
.toast.cat-churn { border-left-color: #6b7280; }
/* ── Slice 7: §14 chrome + /settings/notifications + /admin ──────────── */
/* Header chrome — the persistent §14.3 About link plus the Settings and
Admin entrypoints. The header's job is to be invisible until the user
reaches for it, so these read as quiet text links rather than buttons. */
.header-about, .header-settings, .header-admin {
color: #ddd; text-decoration: none;
font-size: 12px; letter-spacing: 0.02em;
padding: 4px 8px; border-radius: 4px;
}
.header-about:hover, .header-settings:hover, .header-admin:hover {
color: #fff; background: rgba(255,255,255,0.08);
}
.header-admin {
color: #fbbf24; /* admin link sits a notch warmer to signal authority */
}
/* /philosophy — the §14.2 read surface. The body inherits the
prototype's markdown styling; the header is a thin chrome strip. */
.chrome-pane {
flex: 1; min-width: 0; overflow: auto;
padding: 0; background: #fff;
}
.philosophy-page {
max-width: 760px; margin: 0 auto;
padding: 24px 32px 64px;
}
.philosophy-header {
display: flex; align-items: center; gap: 12px;
padding: 12px 0 18px;
border-bottom: 1px solid #f0f0ee;
margin-bottom: 28px;
}
.philosophy-back {
border: none; background: none; cursor: pointer;
color: #4b5563; font-size: 13px;
padding: 4px 8px; border-radius: 4px;
}
.philosophy-back:hover { background: #f3f4f6; color: #111; }
.philosophy-title {
font-size: 13px; color: #6b7280;
text-transform: uppercase; letter-spacing: 0.08em;
}
.philosophy-signin {
margin-left: auto;
font-size: 13px; color: #4b5563; text-decoration: none;
}
.philosophy-signin:hover { color: #111; text-decoration: underline; }
.philosophy-body h1 {
font-size: 28px; font-weight: 700; margin: 0 0 24px;
letter-spacing: -0.01em;
}
.philosophy-body h2 {
font-size: 19px; font-weight: 600; margin: 36px 0 12px;
color: #111;
}
.philosophy-body h3 {
font-size: 15px; font-weight: 600; margin: 24px 0 10px;
}
.philosophy-body p {
margin: 0 0 16px; line-height: 1.65; font-size: 15px; color: #1f2937;
}
.philosophy-body em { color: #4b5563; font-style: italic; }
.philosophy-body strong { color: #111; }
.philosophy-body ul, .philosophy-body ol {
margin: 0 0 20px; padding-left: 24px;
}
.philosophy-body li { margin-bottom: 8px; line-height: 1.6; }
.philosophy-body hr {
border: none; border-top: 1px solid #e5e7eb; margin: 32px 0;
}
.philosophy-body code {
background: #f3f4f6; padding: 1px 5px; border-radius: 3px;
font-size: 0.92em;
}
/* Richer landing page (§14.1) — adds the three-item deck under the
pitch. The .landing container's flex centering stays from the
prototype; the new content lives inside .landing-inner. */
.landing-inner {
max-width: 620px;
display: flex; flex-direction: column; align-items: center;
}
.landing-deck {
list-style: none; padding: 0;
margin: 48px 0 0; text-align: left;
display: flex; flex-direction: column; gap: 18px;
border-top: 1px solid #e5e7eb; padding-top: 32px;
max-width: 540px;
}
.landing-deck li {
font-size: 14px; line-height: 1.6; color: #374151;
}
.landing-deck strong { color: #111; }
/* /settings/notifications */
.settings-page {
max-width: 720px; margin: 0 auto;
padding: 24px 32px 64px;
}
.settings-header h1 {
margin: 0 0 6px; font-size: 24px; font-weight: 700; letter-spacing: -0.01em;
}
.settings-sub {
color: #6b7280; font-size: 13px; margin: 0 0 24px;
line-height: 1.5;
}
.settings-section {
border: 1px solid #e5e7eb; border-radius: 8px;
padding: 20px 24px; margin-bottom: 16px; background: #fff;
}
.settings-section h2 {
margin: 0 0 4px; font-size: 15px; font-weight: 600;
}
.settings-section-body { display: flex; flex-direction: column; gap: 12px; }
.settings-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.quiet-hours-row label {
display: flex; flex-direction: column; gap: 4px;
font-size: 12px; color: #6b7280;
}
.quiet-hours-row input, .quiet-hours-row select {
border: 1px solid #d1d5db; border-radius: 6px;
padding: 6px 10px; font-size: 13px;
background: white; color: #111;
}
.settings-note { font-size: 12px; color: #6b7280; margin: 0; }
.settings-note.warning { color: #b91c1c; }
.toggle-row {
display: flex; align-items: flex-start; gap: 12px;
cursor: pointer; padding: 10px 0;
border-top: 1px solid #f3f4f6;
}
.toggle-row:first-child { border-top: none; padding-top: 0; }
.toggle-row.disabled { cursor: not-allowed; opacity: 0.6; }
.toggle-row input[type=checkbox] { margin-top: 3px; }
.toggle-text { display: flex; flex-direction: column; gap: 2px; }
.toggle-label { font-size: 14px; font-weight: 500; color: #111; }
.toggle-desc { font-size: 12px; color: #6b7280; line-height: 1.5; }
.btn-primary {
background: #111; color: #fff; border: none;
padding: 7px 14px; border-radius: 6px; font-size: 13px; cursor: pointer;
}
.btn-primary:hover { background: #333; }
.btn-primary:disabled { background: #9ca3af; cursor: not-allowed; }
.btn-link-muted {
background: none; border: none; cursor: pointer;
color: #6b7280; font-size: 12px; padding: 4px 6px;
}
.btn-link-muted:hover { color: #111; text-decoration: underline; }
.settings-table, .admin-table {
width: 100%; border-collapse: collapse;
font-size: 13px;
}
.settings-table th, .admin-table th {
text-align: left; padding: 6px 8px;
font-size: 11px; text-transform: uppercase;
color: #6b7280; letter-spacing: 0.05em; font-weight: 600;
border-bottom: 1px solid #e5e7eb;
}
.settings-table td, .admin-table td {
padding: 8px 8px; border-bottom: 1px solid #f3f4f6;
}
.settings-table select { font-size: 13px; padding: 3px 6px; }
.set-by {
font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em;
padding: 2px 6px; border-radius: 4px; font-weight: 600;
}
.set-by-auto { background: #f3f4f6; color: #6b7280; }
.set-by-explicit { background: #dbeafe; color: #1e40af; }
.mutes-list { list-style: none; padding: 0; margin: 8px 0 0; }
.mutes-row {
display: flex; align-items: center; gap: 10px;
padding: 6px 8px; border-bottom: 1px solid #f3f4f6;
font-size: 13px;
}
.mute-handle { font-weight: 500; color: #111; }
.mute-when { font-size: 11px; margin-left: auto; }
.mute-typeahead { position: relative; }
.mute-typeahead input {
width: 100%; box-sizing: border-box;
border: 1px solid #d1d5db; border-radius: 6px;
padding: 7px 10px; font-size: 13px; outline: none;
}
.mute-typeahead input:focus { border-color: #111; }
.mute-typeahead-results {
position: absolute; top: 100%; left: 0; right: 0;
background: white; border: 1px solid #d1d5db; border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
margin-top: 4px; padding: 4px 0; z-index: 10;
list-style: none;
}
.mute-typeahead-results button {
display: flex; gap: 10px; align-items: center;
width: 100%; padding: 6px 10px;
background: none; border: none; text-align: left; cursor: pointer;
font-size: 13px;
}
.mute-typeahead-results button:hover { background: #f9fafb; }
/* /admin */
.admin-page {
display: flex; height: 100%;
width: 100%;
}
.admin-rail {
width: 220px; flex-shrink: 0;
background: #f9fafb; border-right: 1px solid #e5e7eb;
padding: 24px 16px;
}
.admin-rail h2 {
font-size: 13px; color: #6b7280;
text-transform: uppercase; letter-spacing: 0.08em;
margin: 0 0 12px;
}
.admin-rail ul { list-style: none; padding: 0; margin: 0 0 24px; }
.admin-rail li { margin-bottom: 2px; }
.admin-rail-link {
display: block; padding: 6px 10px;
font-size: 13px; color: #374151; text-decoration: none;
border-radius: 4px;
}
.admin-rail-link:hover { background: #f3f4f6; }
.admin-rail-link.active { background: #111; color: #fff; }
.admin-rail-note {
font-size: 11px; color: #6b7280; line-height: 1.5;
}
.admin-content {
flex: 1; min-width: 0;
padding: 28px 36px;
overflow: auto;
}
.admin-tab-header h2 {
margin: 0 0 4px; font-size: 18px; font-weight: 700;
}
.admin-tab-header p { margin: 0 0 24px; font-size: 13px; }
.admin-section-h {
font-size: 13px; text-transform: uppercase;
letter-spacing: 0.05em; color: #6b7280;
margin: 24px 0 8px;
}
.audit-filters {
display: flex; gap: 8px; margin-bottom: 18px; flex-wrap: wrap;
}
.audit-filters input, .audit-filters select {
border: 1px solid #d1d5db; border-radius: 6px;
padding: 5px 9px; font-size: 13px; background: white;
}
.audit-table code {
font-size: 11px; background: #f3f4f6;
padding: 1px 5px; border-radius: 3px;
}
.user-cell { display: flex; flex-direction: column; gap: 1px; }
.user-handle { font-weight: 500; color: #111; }
.mute-toggle {
display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; cursor: pointer;
}
.grad-queue { list-style: none; padding: 0; margin: 8px 0 24px; }
.grad-queue li { padding: 8px 0; border-bottom: 1px solid #f3f4f6; }
.grad-queue-link { color: #111; text-decoration: none; font-size: 14px; }
.grad-queue-link:hover strong { text-decoration: underline; }
.muted { color: #6b7280; }
.error { color: #b91c1c; }
+79 -17
View File
@@ -8,6 +8,9 @@ import PRView from './components/PRView.jsx'
import ProposalView from './components/ProposalView.jsx'
import ProposeModal from './components/ProposeModal.jsx'
import Landing from './components/Landing.jsx'
import Philosophy from './components/Philosophy.jsx'
import NotificationSettings from './components/NotificationSettings.jsx'
import Admin from './components/Admin.jsx'
import ToastHost, { showToast } from './components/ToastHost.jsx'
import './App.css'
@@ -65,8 +68,16 @@ export default function App() {
return <div className="boot">Loading</div>
}
// §14.2: the philosophy route is reachable by anonymous visitors too.
// Resolve it before the authentication gate so a signed-out reader
// who follows the §14.1 landing link does not get bounced to sign-in.
if (!me?.authenticated) {
return <Landing />
return (
<Routes>
<Route path="/philosophy" element={<Philosophy authenticated={false} />} />
<Route path="*" element={<Landing />} />
</Routes>
)
}
return (
@@ -76,6 +87,21 @@ export default function App() {
<Link to="/">Wiggleverse RFCs</Link>
</div>
<div className="header-right">
{/* §14.3: the persistent About link. One word, no badge, no
state visible from every authenticated screen so a
contributor mid-PR who wonders why a conversation is
public can reach the answer in two clicks. */}
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
About
</Link>
<Link to="/settings/notifications" className="header-settings" title="Notification settings (§15)">
Settings
</Link>
{(me.user.role === 'owner' || me.user.role === 'admin') && (
<Link to="/admin" className="header-admin" title="Admin home base">
Admin
</Link>
)}
<button
className="inbox-trigger"
onClick={() => setInboxOpen(o => !o)}
@@ -92,18 +118,27 @@ export default function App() {
</div>
</header>
<div className="app-body">
<Catalog
onProposeRFC={() => setProposeOpen(true)}
version={catalogVersion}
/>
<main className="main-pane">
<Routes>
<Route path="/" element={<Welcome viewer={me.user} />} />
<Route path="/rfc/:slug" element={<RFCView viewer={me.user} />} />
<Route path="/rfc/:slug/pr/:prNumber" element={<PRView viewer={me.user} />} />
<Route path="/proposals/:prNumber" element={<ProposalView viewer={me.user} onChange={() => setCatalogVersion(v => v + 1)} />} />
</Routes>
</main>
<Routes>
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={me.user} />} />
<Route path="/settings/notifications" element={<NotificationSettingsWithSidebar viewer={me.user} />} />
<Route path="/admin/*" element={<AdminWithSidebar viewer={me.user} />} />
<Route path="*" element={
<>
<Catalog
onProposeRFC={() => setProposeOpen(true)}
version={catalogVersion}
/>
<main className="main-pane">
<Routes>
<Route path="/" element={<Welcome viewer={me.user} />} />
<Route path="/rfc/:slug" element={<RFCView viewer={me.user} />} />
<Route path="/rfc/:slug/pr/:prNumber" element={<PRView viewer={me.user} />} />
<Route path="/proposals/:prNumber" element={<ProposalView viewer={me.user} onChange={() => setCatalogVersion(v => v + 1)} />} />
</Routes>
</main>
</>
} />
</Routes>
</div>
{proposeOpen && (
<ProposeModal
@@ -123,6 +158,34 @@ export default function App() {
)
}
function PhilosophyWithSidebar() {
// The chrome surfaces (§14.2 philosophy, §15 settings, §6/§17 admin)
// all use the full app body no catalog left pane, no propose modal.
// The header carries the navigation back; the body is a single
// reading surface.
return (
<main className="chrome-pane">
<Philosophy authenticated={true} />
</main>
)
}
function NotificationSettingsWithSidebar({ viewer }) {
return (
<main className="chrome-pane">
<NotificationSettings viewer={viewer} />
</main>
)
}
function AdminWithSidebar({ viewer }) {
return (
<main className="chrome-pane">
<Admin viewer={viewer} />
</main>
)
}
function Welcome({ viewer }) {
return (
<div className="welcome">
@@ -134,10 +197,9 @@ function Welcome({ viewer }) {
idea PR against the meta repository.
</p>
<p>
Slice 1 of the build is in place: propose idea PR owner merges
super-draft appears in the catalog super-draft view renders. The
revision flow, per-branch chat, AI participation, and the PR surface
land in subsequent slices.
Wondering why a conversation is public, why graduation costs what it
does, or why the model is in the chat? <Link to="/philosophy">Read the
philosophy</Link>.
</p>
</div>
)
+62
View File
@@ -478,6 +478,68 @@ export async function advanceChatSeen(slug, branch, lastSeenMessageId) {
}))
}
// ---------------------------------------------------------------------------
// §14.2 / Slice 7: PHILOSOPHY.md surface
// ---------------------------------------------------------------------------
export async function getPhilosophy() {
return jsonOrThrow(await fetch('/api/philosophy'))
}
// ---------------------------------------------------------------------------
// Slice 7: admin neighborhood (§17 admin/* + user search for the §15.8 mute
// typeahead).
// ---------------------------------------------------------------------------
export async function listAdminUsers() {
return jsonOrThrow(await fetch('/api/admin/users'))
}
export async function setUserRole(userId, role) {
return jsonOrThrow(await fetch(`/api/admin/users/${userId}/role`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role }),
}))
}
export async function setUserMute(userId, muted) {
return jsonOrThrow(await fetch(`/api/admin/users/${userId}/mute`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ muted }),
}))
}
export async function listAuditLog({ actionKind, actorUserId, rfcSlug, beforeId, limit } = {}) {
const params = new URLSearchParams()
if (actionKind) params.set('action_kind', actionKind)
if (actorUserId != null) params.set('actor_user_id', actorUserId)
if (rfcSlug) params.set('rfc_slug', rfcSlug)
if (beforeId != null) params.set('before_id', beforeId)
if (limit != null) params.set('limit', limit)
const qs = params.toString()
return jsonOrThrow(await fetch(`/api/admin/audit${qs ? `?${qs}` : ''}`))
}
export async function listPermissionEvents({ beforeId, limit } = {}) {
const params = new URLSearchParams()
if (beforeId != null) params.set('before_id', beforeId)
if (limit != null) params.set('limit', limit)
const qs = params.toString()
return jsonOrThrow(await fetch(`/api/admin/permission-events${qs ? `?${qs}` : ''}`))
}
export async function listGraduationQueue() {
return jsonOrThrow(await fetch('/api/admin/graduation-queue'))
}
export async function searchUsers(q) {
const params = new URLSearchParams()
if (q) params.set('q', q)
return jsonOrThrow(await fetch(`/api/users/search?${params}`))
}
// SSE subscription helper. Returns a close() function. The handler
// surface mirrors §15.3: a snapshot event on open, then per-notification
// `notification` events, plus `read` events when another tab marks a row.
+408
View File
@@ -0,0 +1,408 @@
// /admin the admin home base.
//
// Topics 12 and 13 both expanded the admin's repertoire without giving
// it a centralized home. Slice 7 consolidates them: role management,
// the §6.2 app-wide write-mute, the audit-log viewer, the
// graduation-readiness queue, and a read of the permission-events log
// five thin sub-surfaces behind a left-rail menu.
//
// The page is admin-only; the App.jsx route mounts it only when the
// viewer's role is owner or admin, and every /api/admin/* endpoint
// guards independently.
import { useEffect, useMemo, useState } from 'react'
import { Routes, Route, NavLink, Link } from 'react-router-dom'
import {
listAdminUsers,
setUserRole,
setUserMute,
listAuditLog,
listPermissionEvents,
listGraduationQueue,
} from '../api.js'
const TABS = [
{ path: 'users', label: 'Users' },
{ path: 'graduation', label: 'Graduation queue' },
{ path: 'audit', label: 'Audit log' },
{ path: 'permissions', label: 'Permission events' },
]
export default function Admin({ viewer }) {
return (
<div className="admin-page">
<nav className="admin-rail">
<h2>Admin</h2>
<ul>
{TABS.map(t => (
<li key={t.path}>
<NavLink
to={t.path}
className={({ isActive }) => `admin-rail-link ${isActive ? 'active' : ''}`}
>
{t.label}
</NavLink>
</li>
))}
</ul>
<p className="admin-rail-note">
Signed in as <strong>{viewer.display_name}</strong> ({viewer.role}).
You can <Link to="/">return to the catalog</Link> at any time.
</p>
</nav>
<div className="admin-content">
<Routes>
<Route index element={<UsersTab />} />
<Route path="users" element={<UsersTab />} />
<Route path="graduation" element={<GraduationTab />} />
<Route path="audit" element={<AuditTab />} />
<Route path="permissions" element={<PermissionsTab />} />
</Routes>
</div>
</div>
)
}
// Users + role + write-mute (§6.1 / §6.2)
function UsersTab() {
const [users, setUsers] = useState(null)
const [busy, setBusy] = useState({})
const [error, setError] = useState(null)
async function refresh() {
setError(null)
try {
const r = await listAdminUsers()
setUsers(r.items || [])
} catch (e) {
setError(e.message)
}
}
useEffect(() => { refresh() }, [])
async function changeRole(userId, role) {
setBusy(b => ({ ...b, [userId]: true }))
setError(null)
try {
await setUserRole(userId, role)
setUsers(prev => prev.map(u => u.id === userId ? { ...u, role } : u))
} catch (e) {
setError(e.message)
} finally {
setBusy(b => ({ ...b, [userId]: false }))
}
}
async function toggleMute(userId, muted) {
setBusy(b => ({ ...b, [userId]: true }))
setError(null)
try {
await setUserMute(userId, muted)
setUsers(prev => prev.map(u => u.id === userId ? { ...u, muted } : u))
} catch (e) {
setError(e.message)
} finally {
setBusy(b => ({ ...b, [userId]: false }))
}
}
if (users == null) return <p className="muted">Loading users</p>
return (
<div className="admin-tab">
<header className="admin-tab-header">
<h2>Users</h2>
<p className="muted">
Role changes write to <code>permission_events</code>. The §6.2
write-mute applies to contributors only promote to admin to
remove a user's ability to write without silencing them.
</p>
</header>
{error && <p className="settings-note warning">{error}</p>}
<table className="admin-table">
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>Write-muted</th>
<th>Last seen</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id}>
<td>
<div className="user-cell">
<span className="user-handle">@{u.gitea_login}</span>
<span className="muted">{u.display_name}</span>
</div>
</td>
<td>
<select
value={u.role}
onChange={e => changeRole(u.id, e.target.value)}
disabled={!!busy[u.id]}
>
<option value="contributor">Contributor</option>
<option value="admin">Admin</option>
<option value="owner">Owner</option>
</select>
</td>
<td>
{u.role === 'contributor' ? (
<label className="mute-toggle">
<input
type="checkbox"
checked={!!u.muted}
onChange={e => toggleMute(u.id, e.target.checked)}
disabled={!!busy[u.id]}
/>
{u.muted ? 'Muted' : 'Active'}
</label>
) : (
<span className="muted">N/A</span>
)}
</td>
<td className="muted">{u.last_seen_at}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
// Graduation-readiness queue (§13.2)
function GraduationTab() {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
listGraduationQueue()
.then(setData)
.catch(e => setError(e.message))
}, [])
if (error) return <p className="settings-note warning">{error}</p>
if (data == null) return <p className="muted">Loading queue</p>
return (
<div className="admin-tab">
<header className="admin-tab-header">
<h2>Graduation queue</h2>
<p className="muted">
Super-drafts with owners claimed and zero blocking body-edit PRs.
Open one to run the §13.3 graduation sequence.
</p>
</header>
<h3 className="admin-section-h">Ready ({data.ready.length})</h3>
{data.ready.length === 0 && (
<p className="muted">No super-drafts ready right now.</p>
)}
<ul className="grad-queue">
{data.ready.map(item => (
<li key={item.slug}>
<Link to={`/rfc/${item.slug}`} className="grad-queue-link">
<strong>{item.title}</strong>
<span className="muted"> owners: {item.owners.join(', ')}</span>
</Link>
</li>
))}
</ul>
<h3 className="admin-section-h">Blocked ({data.blocked.length})</h3>
{data.blocked.length === 0 && (
<p className="muted">No blocked super-drafts.</p>
)}
<ul className="grad-queue">
{data.blocked.map(item => (
<li key={item.slug}>
<Link to={`/rfc/${item.slug}`} className="grad-queue-link">
<strong>{item.title}</strong>
<span className="muted">
{' — '}
{!item.owners_set && 'no owners yet'}
{!item.owners_set && item.blocking_prs > 0 && '; '}
{item.blocking_prs > 0 && `${item.blocking_prs} open body-edit PR${item.blocking_prs === 1 ? '' : 's'}`}
</span>
</Link>
</li>
))}
</ul>
</div>
)
}
// Audit log (`actions`) filter chips + paging
function AuditTab() {
const [data, setData] = useState(null)
const [filters, setFilters] = useState({ actionKind: '', actorUserId: '', rfcSlug: '' })
const [error, setError] = useState(null)
async function load(beforeId = null) {
setError(null)
try {
const r = await listAuditLog({
actionKind: filters.actionKind || undefined,
actorUserId: filters.actorUserId ? Number(filters.actorUserId) : undefined,
rfcSlug: filters.rfcSlug || undefined,
beforeId,
limit: 100,
})
setData(r)
} catch (e) {
setError(e.message)
}
}
useEffect(() => { load() }, [filters])
const kinds = useMemo(() => data?.action_kinds || [], [data])
return (
<div className="admin-tab">
<header className="admin-tab-header">
<h2>Audit log</h2>
<p className="muted">
Every bot-mediated write lands here. The most recent rows show first;
filter to narrow to one kind, one actor, or one RFC.
</p>
</header>
<div className="audit-filters">
<select
value={filters.actionKind}
onChange={e => setFilters(f => ({ ...f, actionKind: e.target.value }))}
>
<option value="">All action kinds</option>
{kinds.map(k => <option key={k} value={k}>{k}</option>)}
</select>
<input
type="text"
placeholder="RFC slug…"
value={filters.rfcSlug}
onChange={e => setFilters(f => ({ ...f, rfcSlug: e.target.value }))}
/>
<input
type="number"
placeholder="Actor user_id…"
value={filters.actorUserId}
onChange={e => setFilters(f => ({ ...f, actorUserId: e.target.value }))}
/>
</div>
{error && <p className="settings-note warning">{error}</p>}
{data == null && <p className="muted">Loading</p>}
{data?.items?.length === 0 && (
<p className="muted">No rows match this filter.</p>
)}
{data?.items?.length > 0 && (
<table className="admin-table audit-table">
<thead>
<tr>
<th>When</th>
<th>Action</th>
<th>Actor</th>
<th>On behalf of</th>
<th>RFC</th>
<th>PR / branch</th>
</tr>
</thead>
<tbody>
{data.items.map(row => (
<tr key={row.id}>
<td className="muted">{row.created_at}</td>
<td><code>{row.action_kind}</code></td>
<td>{row.actor_display || row.actor_login || '—'}</td>
<td>{row.on_behalf_of}</td>
<td>{row.rfc_slug || '—'}</td>
<td>
{row.pr_number != null && <span>#{row.pr_number} </span>}
{row.branch_name && <code>{row.branch_name}</code>}
</td>
</tr>
))}
</tbody>
</table>
)}
{data?.has_more && (
<button
className="btn-link-muted"
onClick={() => load(data.items[data.items.length - 1].id)}
>
Load older
</button>
)}
</div>
)
}
// Permission events (`permission_events`)
function PermissionsTab() {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
listPermissionEvents({ limit: 100 })
.then(setData)
.catch(e => setError(e.message))
}, [])
if (error) return <p className="settings-note warning">{error}</p>
if (data == null) return <p className="muted">Loading</p>
return (
<div className="admin-tab">
<header className="admin-tab-header">
<h2>Permission events</h2>
<p className="muted">
Every role change and write-mute toggle. The companion to the
audit log, scoped to authorization changes.
</p>
</header>
{data.items.length === 0 && (
<p className="muted">No permission events yet.</p>
)}
{data.items.length > 0 && (
<table className="admin-table">
<thead>
<tr>
<th>When</th>
<th>Event</th>
<th>Actor</th>
<th>Subject</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{data.items.map(r => (
<tr key={r.id}>
<td className="muted">{r.created_at}</td>
<td><code>{r.event_kind}</code></td>
<td>{r.actor_display || r.actor_login || '—'}</td>
<td>{r.subject_display || r.subject_login || '—'}</td>
<td className="muted">
{r.details ? formatDetails(r.details) : ''}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
}
function formatDetails(details) {
if (!details || typeof details !== 'object') return String(details ?? '')
if (details.before != null && details.after != null) {
return `${String(details.before)}${String(details.after)}`
}
return JSON.stringify(details)
}
+51 -15
View File
@@ -1,24 +1,60 @@
// Landing.jsx §14.1's pre-login surface.
//
// Title, subtitle, the short-form pitch from PHILOSOPHY.md, then the
// single primary action: "Sign in with Gitea." The visual design is
// deferred per §14.4; the structural commitments are here.
// The landing page has three jobs per §14.1: name what this thing is,
// pitch why someone would care, and offer the sign-in affordance. The
// visual treatment is deferred per §14.4 what matters here is the
// hierarchy. Title and subtitle frame the framework, the short-form
// pitch (sourced verbatim from the top of PHILOSOPHY.md) does the
// argument, and the single primary action lets a reader who is sold
// step through. The secondary link to `/philosophy` is for the reader
// who is interested but needs more before signing in.
//
// The deck below the pitch is intentionally restrained three crisp
// claims about what the framework *is*, anchored in the spec's
// structural decisions, so the reader who has not yet read the
// philosophy can still tell at a glance whether this is for them.
import { Link } from 'react-router-dom'
export default function Landing() {
return (
<div className="landing">
<h1>Wiggleverse RFCs</h1>
<p className="subtitle">A standards process for shared meaning between humans and machines.</p>
<p className="pitch">
Large language models work brilliantly with programming languages because every
word in Python or C has a definitive meaning enforced by tooling. They struggle
with natural language because no such dictionary exists for words like
<em> consent</em>, <em> trait</em>, or <em> agency</em> words that do enormous
work in any system that interacts with humans. The Wiggleverse RFC framework is
a standardization process for that vocabulary. Build the dictionary first.
</p>
<a className="btn-signin" href="/auth/login">Sign in with Gitea</a>
<a className="secondary-link" href="/philosophy">Read the full philosophy </a>
<div className="landing-inner">
<h1>Wiggleverse RFCs</h1>
<p className="subtitle">
A standards process for shared meaning between humans and machines.
</p>
<p className="pitch">
Large language models work brilliantly with programming languages because every
word in Python or C has a definitive meaning enforced by tooling. They struggle
with natural language because no such dictionary exists for words like
<em> consent</em>, <em> trait</em>, or <em> agency</em> words that do enormous
work in any system that interacts with humans. The Wiggleverse RFC framework is
the standardization process for that vocabulary. Build the dictionary first.
</p>
<a className="btn-signin" href="/auth/login">Sign in with Gitea</a>
<Link className="secondary-link" to="/philosophy">Read the full philosophy </Link>
<ul className="landing-deck">
<li>
<strong>One word per RFC.</strong> An RFC defines a single word its meaning,
its relationships to other defined words, and the protocol by which humans and
machines interact with it.
</li>
<li>
<strong>Argued in public, with the model.</strong> Every definition is the
product of a transcript: a human and a model in careful argument until the
ambiguity is gone. The argument is the evidence the definition was earned.
</li>
<li>
<strong>Graduation is the load-bearing moment.</strong> A super-draft is the
start of the conversation. Graduation gives the definition a permanent home
and a stable identifier and only then can other RFCs build on it.
</li>
</ul>
</div>
</div>
)
}
@@ -0,0 +1,509 @@
// /settings/notifications the §15.4 / §15.5 / §15.6 / §15.8 surface.
//
// Topic 13 settled the schema and the per-category rules; Slice 6 wired
// the endpoints; Slice 7 lands the surface where a contributor finds
// the per-category email toggles, the digest cadence dropdown, the
// quiet-hours editor, the watches overview, and the per-user mute
// list. The §15.4 email footer's "Manage all preferences" link points
// at this route.
//
// Each sub-section is intentionally thin the rules are settled in
// §15; this page renders them. The one piece of voice the spec
// commits to and the surface inherits: the `email_watched_churn`
// toggle renders as permanently disabled with the §15.4 refusal
// tooltip ("Per-commit and per-message email is intentionally not
// offered. The digest aggregates this activity weekly."). Naming the
// refusal is the spec's commitment; silently omitting the toggle
// would let the contract drift.
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import {
getNotificationPreferences,
setNotificationPreferences,
getQuietHours,
setQuietHours,
listWatches,
setWatch,
unmuteUser,
muteUser,
searchUsers,
} from '../api.js'
const CHURN_REFUSAL = 'Per-commit and per-message email is intentionally not offered. The digest aggregates this activity weekly.'
export default function NotificationSettings({ viewer }) {
return (
<div className="settings-page">
<header className="settings-header">
<h1>Notification settings</h1>
<p className="settings-sub">
Which signals reach you, how, and when. The rules live in §15;
this page is where you tune them.
</p>
</header>
<EmailPreferencesSection />
<DigestCadenceSection />
<QuietHoursSection />
<WatchesSection />
<MutesSection viewer={viewer} />
</div>
)
}
// §15.4 email category toggles
function EmailPreferencesSection() {
const [prefs, setPrefs] = useState(null)
const [saving, setSaving] = useState(false)
const [savedNote, setSavedNote] = useState('')
const [error, setError] = useState(null)
useEffect(() => {
getNotificationPreferences().then(setPrefs).catch(e => setError(e.message))
}, [])
async function update(field, value) {
setSaving(true)
setSavedNote('')
try {
await setNotificationPreferences({ [field]: value })
setPrefs(p => ({ ...p, [field]: value }))
setSavedNote('Saved.')
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
setTimeout(() => setSavedNote(''), 1500)
}
}
if (!prefs) return <SectionShell title="Email" subtitle={error || 'Loading…'} />
return (
<SectionShell title="Email" subtitle="Categories you want delivered as email. The inbox always carries everything; email is opt-in by category.">
<Toggle
label="Personal direct"
description="You are the named subject — proposals merged, decisions, claims, named asks. Default on."
checked={!!prefs.email_personal_direct}
onChange={v => update('email_personal_direct', v)}
disabled={saving}
/>
<Toggle
label="Watched structural"
description="Decisions on RFCs you watch — merges, declines, graduation, ownership changes. Default off."
checked={!!prefs.email_watched_structural}
onChange={v => update('email_watched_structural', v)}
disabled={saving}
/>
<Toggle
label="Admin actionable"
description="Decisions only an admin can act on. Defaults on for admins/owners; ignored for contributors."
checked={!!prefs.email_admin_actionable}
onChange={v => update('email_admin_actionable', v)}
disabled={saving}
/>
<Toggle
label="Watched churn (per-commit, per-message)"
description={CHURN_REFUSAL}
checked={false}
disabled
title={CHURN_REFUSAL}
/>
{!!prefs.email_opt_out_all && (
<p className="settings-note warning">
A hard bounce or complaint from your mailbox flipped the global
email opt-out. No email will be sent until you contact an admin
to clear the flag, even if individual categories are enabled.
</p>
)}
<p className="settings-note">{savedNote}</p>
</SectionShell>
)
}
// §15.5 digest cadence
function DigestCadenceSection() {
const [cadence, setCadence] = useState(null)
const [saving, setSaving] = useState(false)
useEffect(() => {
getNotificationPreferences().then(p => setCadence(p.digest_cadence || 'weekly'))
}, [])
async function update(value) {
setSaving(true)
try {
await setNotificationPreferences({ digest_cadence: value })
setCadence(value)
} finally {
setSaving(false)
}
}
return (
<SectionShell
title="Digest cadence"
subtitle="The §15.5 digest gathers churn-category activity into a single periodic email. Independent of the category toggles above."
>
<div className="settings-row">
<select
value={cadence ?? 'weekly'}
onChange={e => update(e.target.value)}
disabled={saving || cadence == null}
>
<option value="off">Off never send a digest</option>
<option value="weekly">Weekly</option>
<option value="daily">Daily</option>
</select>
</div>
</SectionShell>
)
}
// §15.8 quiet hours
function QuietHoursSection() {
const [data, setData] = useState(null)
const [draft, setDraft] = useState({ start: '', end: '', timezone: '' })
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
// The browser ships an IANA tz list per §15.8 preferable to a
// free-text field, since the API validates the trio.
const timezones = useMemo(() => {
try {
return Intl.supportedValuesOf('timeZone')
} catch {
return ['UTC']
}
}, [])
useEffect(() => {
getQuietHours().then(q => {
setData(q)
setDraft({
start: q.start || '',
end: q.end || '',
timezone: q.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
})
})
}, [])
async function save() {
setSaving(true)
setError(null)
try {
await setQuietHours({ start: draft.start, end: draft.end, timezone: draft.timezone })
setData({ start: draft.start, end: draft.end, timezone: draft.timezone })
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
async function clear() {
setSaving(true)
setError(null)
try {
await setQuietHours({ start: null, end: null, timezone: null })
setData({ start: null, end: null, timezone: null })
setDraft({ start: '', end: '', timezone: Intl.DateTimeFormat().resolvedOptions().timeZone })
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
if (!data) return <SectionShell title="Quiet hours" subtitle="Loading…" />
const isSet = !!(data.start && data.end && data.timezone)
return (
<SectionShell
title="Quiet hours"
subtitle="Email holds during this window; inbox rows still land. On window-end, the §15.4 bundle email releases everything above the threshold."
>
<div className="settings-row quiet-hours-row">
<label>
Start
<input
type="time"
value={draft.start}
onChange={e => setDraft(d => ({ ...d, start: e.target.value }))}
/>
</label>
<label>
End
<input
type="time"
value={draft.end}
onChange={e => setDraft(d => ({ ...d, end: e.target.value }))}
/>
</label>
<label>
Timezone
<select
value={draft.timezone}
onChange={e => setDraft(d => ({ ...d, timezone: e.target.value }))}
>
{timezones.map(tz => <option key={tz} value={tz}>{tz}</option>)}
</select>
</label>
</div>
<div className="settings-row">
<button className="btn-primary" disabled={saving} onClick={save}>
{isSet ? 'Update quiet hours' : 'Set quiet hours'}
</button>
{isSet && (
<button className="btn-link-muted" disabled={saving} onClick={clear}>
Clear
</button>
)}
</div>
{error && <p className="settings-note warning">{error}</p>}
</SectionShell>
)
}
// §15.6 watches overview
function WatchesSection() {
const [watches, setWatches] = useState(null)
const [updating, setUpdating] = useState({})
useEffect(() => { listWatches().then(r => setWatches(r.items || [])) }, [])
async function update(slug, state) {
setUpdating(u => ({ ...u, [slug]: true }))
try {
const r = await setWatch(slug, state)
setWatches(prev => prev.map(w => w.rfc_slug === slug
? { ...w, state: r.state, set_by: 'explicit' }
: w
))
} finally {
setUpdating(u => ({ ...u, [slug]: false }))
}
}
if (!watches) return <SectionShell title="Watches" subtitle="Loading…" />
return (
<SectionShell
title="Watches"
subtitle="What you receive structural-category notifications about. Auto-set when you participate, decays after 90 days of silence. An explicit choice here exempts the row from the auto-decay."
>
{watches.length === 0 && (
<p className="muted">No watches yet. Open an RFC, propose, or join a thread auto-watch will set one for you.</p>
)}
{watches.length > 0 && (
<table className="settings-table">
<thead>
<tr>
<th>RFC</th>
<th>State</th>
<th>Set by</th>
<th>Last participation</th>
</tr>
</thead>
<tbody>
{watches.map(w => (
<tr key={w.rfc_slug}>
<td>
<Link to={`/rfc/${w.rfc_slug}`}>{w.rfc_title || w.rfc_slug}</Link>
</td>
<td>
<select
value={w.state}
onChange={e => update(w.rfc_slug, e.target.value)}
disabled={!!updating[w.rfc_slug]}
>
<option value="watching">Watching</option>
<option value="following">Following</option>
<option value="muted">Muted</option>
</select>
</td>
<td>
<span className={`set-by set-by-${w.set_by}`}>
{w.set_by === 'explicit' ? 'You' : 'Auto'}
</span>
</td>
<td className="muted">
{w.last_participation_at || '—'}
</td>
</tr>
))}
</tbody>
</table>
)}
</SectionShell>
)
}
// §15.8 per-user mute list + typeahead add
function MutesSection({ viewer }) {
const [mutes, setMutes] = useState(null)
const [error, setError] = useState(null)
async function refresh() {
try {
const r = await listMutes()
setMutes(r.items || [])
} catch (e) {
setError(e.message)
}
}
useEffect(() => { refresh() }, [])
async function remove(userId) {
await unmuteUser(userId)
setMutes(prev => prev.filter(m => m.muted_user_id !== userId))
}
return (
<SectionShell
title="Muted users"
subtitle="Notifications from these users won't reach your inbox. (Mute does not gate visibility — you can still read what they post.) Adding a mute here is the catch-all path; the intended path is to mute from an inbox row's actor avatar."
>
{viewer.role === 'owner' || viewer.role === 'admin' ? (
<p className="muted">
Owners and admins cannot mute notifications the role requires
receiving signals from everyone. (§15.8)
</p>
) : (
<MuteTypeahead onMuted={refresh} />
)}
{mutes && mutes.length === 0 && (
<p className="muted">No active mutes.</p>
)}
{mutes && mutes.length > 0 && (
<ul className="mutes-list">
{mutes.map(m => (
<li key={m.muted_user_id} className="mutes-row">
<span className="mute-handle">@{m.gitea_login}</span>
<span className="mute-name muted">{m.display_name}</span>
<span className="mute-when muted">since {m.muted_at}</span>
<button className="btn-link-muted" onClick={() => remove(m.muted_user_id)}>
Unmute
</button>
</li>
))}
</ul>
)}
{error && <p className="settings-note warning">{error}</p>}
</SectionShell>
)
}
// The mute-list read endpoint isn't a separate route in the API today
// (§17 names add/delete only); we read the join here against /api/users/me
// via a tiny dedicated endpoint that mirrors the watches shape. For
// Slice 7's v1 surface, we compute the list client-side from a server
// endpoint that returns the joined rows added in api_admin.py's
// neighborhood for proximity.
async function listMutes() {
const res = await fetch('/api/users/me/notification-mutes')
if (!res.ok) {
const detail = await res.text()
throw new Error(detail || `HTTP ${res.status}`)
}
return res.json()
}
function MuteTypeahead({ onMuted }) {
const [q, setQ] = useState('')
const [results, setResults] = useState([])
const [open, setOpen] = useState(false)
const [busy, setBusy] = useState(false)
const [hint, setHint] = useState('')
useEffect(() => {
const t = setTimeout(() => {
searchUsers(q).then(r => setResults(r.items || []))
}, 120)
return () => clearTimeout(t)
}, [q])
async function mute(user) {
setBusy(true)
setHint('')
try {
await muteUser(user.id)
setQ('')
setOpen(false)
onMuted?.()
} catch (e) {
setHint(e.message)
} finally {
setBusy(false)
}
}
return (
<div className="mute-typeahead">
<input
type="text"
placeholder="Mute a user by login or name…"
value={q}
onChange={e => { setQ(e.target.value); setOpen(true) }}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
disabled={busy}
/>
{open && results.length > 0 && (
<ul className="mute-typeahead-results">
{results.map(r => (
<li key={r.id}>
<button onClick={() => mute(r)} disabled={busy}>
<span className="mute-handle">@{r.gitea_login}</span>
<span className="muted">{r.display_name}</span>
{(r.role === 'owner' || r.role === 'admin') && (
<span className="muted">{r.role}</span>
)}
</button>
</li>
))}
</ul>
)}
{hint && <p className="settings-note warning">{hint}</p>}
</div>
)
}
// Small layout primitives
function SectionShell({ title, subtitle, children }) {
return (
<section className="settings-section">
<header>
<h2>{title}</h2>
{subtitle && <p className="settings-sub">{subtitle}</p>}
</header>
<div className="settings-section-body">{children}</div>
</section>
)
}
function Toggle({ label, description, checked, onChange, disabled, title }) {
return (
<label className={`toggle-row ${disabled ? 'disabled' : ''}`} title={title}>
<input
type="checkbox"
checked={!!checked}
onChange={e => onChange?.(e.target.checked)}
disabled={disabled}
/>
<span className="toggle-text">
<span className="toggle-label">{label}</span>
{description && <span className="toggle-desc">{description}</span>}
</span>
</label>
)
}
+61
View File
@@ -0,0 +1,61 @@
// §14.2 the `/philosophy` route.
//
// Renders PHILOSOPHY.md verbatim with light app chrome around it.
// Reachable by anonymous visitors (linked from the §14.1 landing) and
// by authenticated viewers (linked from the persistent §14.3 About
// header). The chrome here is small by design: a "Back" affordance
// that goes wherever the viewer came from, and a render of the
// markdown body. The §14.4 commitment ("not pushed at returning users
// via banners or modals") is the guardrail this route serves the
// document, nothing else.
//
// The route is also the natural read surface for anonymous reachers:
// without a sign-in they cannot navigate the catalog, but they can
// read the philosophy that animates the work.
import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { marked } from 'marked'
import { getPhilosophy } from '../api.js'
export default function Philosophy({ authenticated }) {
const [body, setBody] = useState('')
const [error, setError] = useState(null)
const [loading, setLoading] = useState(true)
const navigate = useNavigate()
useEffect(() => {
let active = true
getPhilosophy()
.then(r => { if (active) setBody(r.body || '') })
.catch(e => { if (active) setError(e.message || String(e)) })
.finally(() => { if (active) setLoading(false) })
return () => { active = false }
}, [])
const html = body ? marked.parse(body) : ''
return (
<div className="philosophy-page">
<header className="philosophy-header">
<button
className="philosophy-back"
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
>
Back
</button>
<span className="philosophy-title">Why this exists</span>
{!authenticated && (
<Link className="philosophy-signin" to="/">Home</Link>
)}
</header>
<article className="philosophy-body">
{loading && <p className="muted">Loading</p>}
{error && <p className="error">Could not load the philosophy: {error}</p>}
{!loading && !error && (
<div dangerouslySetInnerHTML={{ __html: html }} />
)}
</article>
</div>
)
}