de28272914
Ships DOCS.md and the /docs route — a plain-prose translation of SPEC.md for users, distinct from the binding spec. Originating need was the admin-vs-owner role distinction on /admin/users (§6.1); response is a single guide that covers the framework's user-facing surfaces end-to-end (roles, proposing, branches, PRs, graduation, notifications). Mirrors /philosophy: markdown file at repo root + sibling backend loader + MarkdownPreview render. No schema migration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
312 lines
12 KiB
React
312 lines
12 KiB
React
import { useEffect, useState } from 'react'
|
|
import { Routes, Route, Link, useNavigate } from 'react-router-dom'
|
|
import { getMe, subscribeToNotifications } from './api'
|
|
import Catalog from './components/Catalog.jsx'
|
|
import Inbox from './components/Inbox.jsx'
|
|
import RFCView from './components/RFCView.jsx'
|
|
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 Login from './components/Login.jsx'
|
|
import BetaPending from './components/BetaPending.jsx'
|
|
import Philosophy from './components/Philosophy.jsx'
|
|
import Docs from './components/Docs.jsx'
|
|
import NotificationSettings from './components/NotificationSettings.jsx'
|
|
import Admin from './components/Admin.jsx'
|
|
import ToastHost, { showToast } from './components/ToastHost.jsx'
|
|
import CookieConsentBanner from './components/CookieConsentBanner.jsx'
|
|
import Privacy from './pages/Privacy.jsx'
|
|
import Cookies from './pages/Cookies.jsx'
|
|
import './App.css'
|
|
|
|
export default function App() {
|
|
const [me, setMe] = useState(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [proposeOpen, setProposeOpen] = useState(false)
|
|
const [catalogVersion, setCatalogVersion] = useState(0)
|
|
const [inboxOpen, setInboxOpen] = useState(false)
|
|
const [unreadCount, setUnreadCount] = useState(0)
|
|
const [inboxTick, setInboxTick] = useState(0)
|
|
// §14.5: a tick that, when bumped, asks <CookieConsentBanner> to
|
|
// re-open even if the user has already made a choice. The settings
|
|
// "Privacy & cookies" tab dispatches a `rfc-app:cookie-consent-reopen`
|
|
// event that bumps this.
|
|
const [consentReopenTick, setConsentReopenTick] = useState(0)
|
|
const navigate = useNavigate()
|
|
|
|
useEffect(() => {
|
|
const handler = () => setConsentReopenTick(t => t + 1)
|
|
window.addEventListener('rfc-app:cookie-consent-reopen', handler)
|
|
return () => window.removeEventListener('rfc-app:cookie-consent-reopen', handler)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
getMe()
|
|
.then(setMe)
|
|
.catch(() => setMe({ authenticated: false }))
|
|
.finally(() => setLoading(false))
|
|
}, [])
|
|
|
|
// §15.3 — subscribe to the live SSE stream for authenticated viewers
|
|
// so the badge counter and the toast surface stay in lockstep with
|
|
// the inbox. Tabs that miss an event because they were closed pick
|
|
// it up on next sign-in via the snapshot frame.
|
|
useEffect(() => {
|
|
if (!me?.authenticated) return undefined
|
|
const close = subscribeToNotifications({
|
|
onSnapshot: payload => setUnreadCount(payload.unread_count || 0),
|
|
onNotification: payload => {
|
|
setUnreadCount(c => c + 1)
|
|
setInboxTick(t => t + 1)
|
|
// §15.3: personal-direct events get a toast even when the user
|
|
// isn't on the relevant view — they're the named subject.
|
|
// Churn never toasts; structural toasts only when it lands on
|
|
// a slug the user is currently viewing (URL match).
|
|
const isPersonal = payload.category === 'personal-direct'
|
|
const onCurrentSlug = payload.rfc_slug && window.location.pathname.includes(`/rfc/${payload.rfc_slug}`)
|
|
if (isPersonal || onCurrentSlug) {
|
|
showToast({
|
|
summary: payload.summary,
|
|
category: payload.category,
|
|
link: payload.rfc_slug ? `/rfc/${payload.rfc_slug}` : null,
|
|
})
|
|
}
|
|
},
|
|
onRead: () => {
|
|
setUnreadCount(c => Math.max(0, c - 1))
|
|
setInboxTick(t => t + 1)
|
|
},
|
|
})
|
|
return close
|
|
}, [me?.authenticated])
|
|
|
|
if (loading) {
|
|
return <div className="boot">Loading…</div>
|
|
}
|
|
|
|
// The deployment is in private beta: anonymous visitors get the full
|
|
// app in read-only mode (viewer = null is passed through to every
|
|
// component), and write affordances are hidden at the component
|
|
// level. v0.8.0 (§6.1 / item #6): authenticated users with
|
|
// `permission_state='pending'` also pass through as `viewer` with
|
|
// their state attached — every write-gated affordance reads the
|
|
// state and treats pending the same as anonymous, while reads
|
|
// remain open. The /beta-pending page is the home root for a
|
|
// pending user.
|
|
const viewer = me?.authenticated ? me.user : null
|
|
const isAdmin = viewer && (viewer.role === 'owner' || viewer.role === 'admin')
|
|
const isPending = viewer && viewer.permission_state === 'pending'
|
|
|
|
return (
|
|
<div className="app">
|
|
<header className="app-header">
|
|
<div className="app-brand">
|
|
<Link to="/">{import.meta.env.VITE_APP_NAME}</Link>
|
|
</div>
|
|
<div className="header-right">
|
|
{/* §14.3: the persistent About link. One word, no badge, no
|
|
state — visible from every screen so a viewer mid-PR who
|
|
wonders why a conversation is public can reach the answer
|
|
in two clicks. Anonymous viewers see it too. */}
|
|
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
|
|
About
|
|
</Link>
|
|
<Link to="/docs" className="header-about" title="User guide">
|
|
Docs
|
|
</Link>
|
|
{viewer && (
|
|
<Link to="/settings/notifications" className="header-settings" title="Notification settings (§15)">
|
|
Settings
|
|
</Link>
|
|
)}
|
|
{isAdmin && (
|
|
<Link to="/admin" className="header-admin" title="Admin home base">
|
|
Admin
|
|
</Link>
|
|
)}
|
|
{viewer && (
|
|
<button
|
|
className="inbox-trigger"
|
|
onClick={() => setInboxOpen(o => !o)}
|
|
title="Notifications inbox (§15.2)"
|
|
>
|
|
<span aria-hidden>📮</span>
|
|
{unreadCount > 0 && (
|
|
<span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
|
|
)}
|
|
</button>
|
|
)}
|
|
{viewer ? (
|
|
<>
|
|
<span className="user-name">{viewer.display_name}</span>
|
|
<span className={`user-role-badge role-${viewer.role}`}>{viewer.role}</span>
|
|
<a className="btn-link" href="/auth/logout">Sign out</a>
|
|
</>
|
|
) : (
|
|
<Link className="btn-signin-header" to="/login" title="Private beta — only invited emails can sign in">
|
|
Sign in <span className="beta-chip">Beta</span>
|
|
</Link>
|
|
)}
|
|
</div>
|
|
</header>
|
|
{isPending && <PendingAccessBanner />}
|
|
<div className="app-body">
|
|
<Routes>
|
|
<Route path="/welcome" element={<Landing />} />
|
|
<Route path="/login" element={<Login />} />
|
|
<Route path="/beta-pending" element={<BetaPending viewer={viewer} />} />
|
|
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
|
|
<Route path="/docs" element={<DocsWithSidebar viewer={viewer} />} />
|
|
{/* §14.5 / §14.6: cookie-consent companions to /philosophy.
|
|
Available to anonymous and authenticated viewers alike. */}
|
|
<Route path="/privacy" element={<PolicyShell><Privacy /></PolicyShell>} />
|
|
<Route path="/cookies" element={<PolicyShell><Cookies /></PolicyShell>} />
|
|
{viewer && (
|
|
<Route path="/settings/notifications" element={<NotificationSettingsWithSidebar viewer={viewer} />} />
|
|
)}
|
|
{isAdmin && (
|
|
<Route path="/admin/*" element={<AdminWithSidebar viewer={viewer} />} />
|
|
)}
|
|
<Route path="*" element={
|
|
<>
|
|
<Catalog
|
|
viewer={viewer}
|
|
onProposeRFC={() => setProposeOpen(true)}
|
|
version={catalogVersion}
|
|
/>
|
|
<main className="main-pane">
|
|
<Routes>
|
|
<Route path="/" element={<Welcome viewer={viewer} />} />
|
|
<Route path="/rfc/:slug" element={<RFCView viewer={viewer} />} />
|
|
<Route path="/rfc/:slug/pr/:prNumber" element={<PRView viewer={viewer} />} />
|
|
<Route path="/proposals/:prNumber" element={<ProposalView viewer={viewer} onChange={() => setCatalogVersion(v => v + 1)} />} />
|
|
</Routes>
|
|
</main>
|
|
</>
|
|
} />
|
|
</Routes>
|
|
</div>
|
|
{proposeOpen && viewer && (
|
|
<ProposeModal
|
|
viewer={viewer}
|
|
onClose={() => setProposeOpen(false)}
|
|
onSubmitted={({ pr_number }) => {
|
|
setProposeOpen(false)
|
|
setCatalogVersion(v => v + 1)
|
|
navigate(`/proposals/${pr_number}`)
|
|
}}
|
|
/>
|
|
)}
|
|
{inboxOpen && viewer && (
|
|
<Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} />
|
|
)}
|
|
<ToastHost />
|
|
<CookieConsentBanner viewer={viewer} forceOpen={consentReopenTick} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PolicyShell({ children }) {
|
|
// §14.5 / §14.6 policy pages reuse the chrome-pane shape so they
|
|
// render full-width without the catalog rail. The components inside
|
|
// carry their own back affordance per Philosophy.jsx's pattern.
|
|
return <main className="chrome-pane">{children}</main>
|
|
}
|
|
|
|
function PhilosophyWithSidebar({ viewer }) {
|
|
// 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={!!viewer} />
|
|
</main>
|
|
)
|
|
}
|
|
|
|
function DocsWithSidebar({ viewer }) {
|
|
return (
|
|
<main className="chrome-pane">
|
|
<Docs authenticated={!!viewer} />
|
|
</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 PendingAccessBanner() {
|
|
// v0.8.0 — thin banner shown on every page (other than /beta-pending
|
|
// itself, which carries the same message in larger form) when the
|
|
// signed-in user's `permission_state='pending'`. Sign-out works
|
|
// normally via the header affordance.
|
|
return (
|
|
<div className="pending-access-banner">
|
|
Your beta access request is in review.{' '}
|
|
<Link to="/beta-pending">Learn more →</Link>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Welcome({ viewer }) {
|
|
// v0.8.0 — a pending user landing on "/" gets the same page they'd
|
|
// see at /beta-pending, inline. This is the post-OTC home root for
|
|
// a user awaiting admin grant.
|
|
if (viewer && viewer.permission_state === 'pending') {
|
|
return <BetaPending viewer={viewer} />
|
|
}
|
|
if (!viewer) {
|
|
return (
|
|
<div className="welcome">
|
|
<h1>Welcome.</h1>
|
|
<p>
|
|
The catalog on the left lists every super-draft and active RFC in the
|
|
framework. Open one to read the canonical body and the public
|
|
conversation behind each definition.
|
|
</p>
|
|
<p>
|
|
Discussion and contribution are in private <strong>Beta</strong> —
|
|
read freely, and <Link to="/login">sign in</Link> if your email has
|
|
been invited.
|
|
</p>
|
|
<p>
|
|
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>
|
|
)
|
|
}
|
|
return (
|
|
<div className="welcome">
|
|
<h1>Welcome, {viewer.display_name}.</h1>
|
|
<p>
|
|
The catalog on the left lists every super-draft and active RFC in the
|
|
framework. Open one to read the canonical body, or use{' '}
|
|
<strong>Propose New RFC</strong> at the bottom of the catalog to open an
|
|
idea PR against the meta repository.
|
|
</p>
|
|
<p>
|
|
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>
|
|
)
|
|
}
|