import { useEffect, useRef, useState } from 'react' import { Routes, Route, Link, Navigate, useLocation, useNavigate, useSearchParams } from 'react-router-dom' import { getMe, subscribeToNotifications } from './api' import { anonymize, EVENTS, identify, track } from './lib/analytics' import { useLastState } from './lib/useLastState' import { brandTitle } from './lib/brand' import { entryPath, proposalPath } from './lib/entryPaths' import { useDeployment } from './context/DeploymentProvider' import ProjectLayout from './components/ProjectLayout.jsx' import Directory from './components/Directory.jsx' import ProjectSwitcher from './components/ProjectSwitcher.jsx' 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 ContributeRequestForm from './components/ContributeRequestForm.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 DocsLayout from './components/DocsLayout.jsx' import DocsUserGuide from './components/DocsUserGuide.jsx' import DocsSessionsAbout from './components/DocsSessionsAbout.jsx' import DocsSessionIndex from './components/DocsSessionIndex.jsx' import DocsSessionTranscript from './components/DocsSessionTranscript.jsx' import DocsSpec from './components/DocsSpec.jsx' import DocsSpecsIndex from './components/DocsSpecsIndex.jsx' import NotificationSettings from './components/NotificationSettings.jsx' import Admin from './components/Admin.jsx' import AcceptInvitation from './components/AcceptInvitation.jsx' import InviteClaim from './components/InviteClaim.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 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) // v0.23.0 / item #29 — flips true once the #21-Part-C identify effect // has fired (or once we've confirmed there's no authenticated user to // identify). useLastState gates its resume redirect on this so the // redirect always happens AFTER identify, preserving identify-then- // track ordering. const [identifyReady, setIdentifyReady] = useState(false) const navigate = useNavigate() const location = useLocation() // §22.9 — runtime deployment config (name for the brand, default project id // for building corpus links this slice; see DeploymentProvider). const deployment = useDeployment() // #28 Parts 2–3: the LinkedText create/contribute affordances route via // query params so they need no prop-threading from deep in a comment // list. `?propose=` opens the propose modal pre-filled; // `?contribute=&term=` opens the contribute-request form. const [searchParams, setSearchParams] = useSearchParams() const proposeParam = searchParams.get('propose') const contributeSlug = searchParams.get('contribute') const contributeTerm = searchParams.get('term') const clearParams = (...keys) => { const next = new URLSearchParams(searchParams) keys.forEach(k => next.delete(k)) setSearchParams(next, { replace: true }) } // v0.15.0 — Page Viewed event taxonomy. We fire on every // route change; the analytics wrapper itself decides whether // anything ships out (consent + key check). The first fire is // also covered because `location` is set on mount. const lastPathRef = useRef(null) useEffect(() => { const path = location.pathname + (location.search || '') if (lastPathRef.current === path) return lastPathRef.current = path track(EVENTS.PAGE_VIEWED, { path: location.pathname }) }, [location.pathname, location.search]) // §22.9 — tab title from runtime config. On deployment-chrome routes the // title is the deployment name; under a `/p//` route ProjectLayout // owns it (the project name), so skip those here. useEffect(() => { if (deployment.loading) return if (!location.pathname.startsWith('/p/')) { document.title = brandTitle(deployment.name) } }, [location.pathname, deployment.loading, deployment.name]) // v0.15.0 + #21 Part C — bind the authenticated user id AND // durable user properties to the analytics session when sign-in // lands; reset on sign-out (viewer flips to null). The wrapper // queues these calls until consent + init resolve, so the order // is safe even on a cold load. // // Property bag passed to identify (set vs setOnce per #21 Part C): // set: role, permission_state, passcode_set, device_trusted // (these can change mid-account-life — refresh each sign-in) // setOnce: first_sign_in_at, account_created_at // (immutable user-history markers — set on the first // sign-in that observes them, never overwritten) // // PII discipline: NO email, NO display_name, NO gitea_login passed // through — Amplitude only sees opaque ids + enums + timestamps + // booleans. const lastUserIdRef = useRef(null) useEffect(() => { const uid = me?.authenticated ? me.user?.id : null const viewer = me?.authenticated ? me.user : null if (uid != null && lastUserIdRef.current !== uid) { lastUserIdRef.current = uid const props = {} if (viewer?.role != null) props.role = viewer.role if (viewer?.permission_state != null) props.permission_state = viewer.permission_state if (viewer?.passcode_set != null) props.passcode_set = !!viewer.passcode_set if (viewer?.device_trusted != null) props.device_trusted = !!viewer.device_trusted if (viewer?.first_sign_in_at) props.first_sign_in_at = ['__setOnce__', viewer.first_sign_in_at] if (viewer?.created_at) props.account_created_at = ['__setOnce__', viewer.created_at] identify({ user_id: String(uid), properties: props }) // v0.23.0 / item #29 — identify has now fired for this sign-in; // release useLastState's resume redirect (it waits on this). setIdentifyReady(true) } else if (uid == null && lastUserIdRef.current != null) { // Sign-out edge — App-level reset is handled separately by the // sign-out gesture that fires User Signed Out. Clear our local // memo so a fresh sign-in re-fires identify. lastUserIdRef.current = null } }, [me?.authenticated, me?.user?.id, me?.user?.role, me?.user?.permission_state, me?.user?.passcode_set, me?.user?.device_trusted]) // v0.23.0 / item #29 — once `me` has resolved, if there's no // authenticated user there is nothing to identify, so release the // resume gate immediately (anonymous boots have no resume to do, but // the hook still needs the gate resolved to be a clean no-op). useEffect(() => { if (me != null && !me.authenticated) setIdentifyReady(true) }, [me]) 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)) }, []) // v0.23.0 / item #29 — server-side sign-in state resume. The hook // debounce-posts the current route for authenticated users and, once // identify has fired, redirects a fresh sign-in (which hard-lands on // "/") to the user's stored last route. Anonymous users: no-op. useLastState({ authenticated: !!me?.authenticated, pathname: location.pathname, identifyReady, lastRoute: me?.authenticated ? me.user?.last_route : null, navigate, }) // §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' // §22.10: entry URLs are now /p//e/; match + link on the // generic /e/ segment (default project is the served corpus). const onCurrentSlug = payload.rfc_slug && window.location.pathname.includes(`/e/${payload.rfc_slug}`) if (isPersonal || onCurrentSlug) { showToast({ summary: payload.summary, category: payload.category, link: payload.rfc_slug && deployment.defaultProjectId ? entryPath(deployment.defaultProjectId, payload.rfc_slug) : null, }) } }, onRead: () => { setUnreadCount(c => Math.max(0, c - 1)) setInboxTick(t => t + 1) }, }) return close }, [me?.authenticated, deployment.defaultProjectId]) if (loading) { return
Loading…
} // 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 (
{/* §22.9: deployment name from runtime config (replaces the build-time VITE_APP_NAME); neutral 'RFC' during the pre-fetch paint via brandTitle(). */} {brandTitle(deployment.name)} {/* §22.10: project switcher — deployment chrome; renders only when 2+ projects are visible to the caller. */}
{/* §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. */} About Docs {viewer && ( Settings )} {isAdmin && ( Admin )} {viewer && ( )} {viewer ? ( <> {viewer.display_name} {viewer.role} { // v0.15.0 — fire the sign-out event before the // hard nav. The wrapper's track() is sync-enqueue; // the underlying SDK flush is best-effort across // navigation. anonymize() clears the user binding // so any post-nav anonymous events on the next // page aren't attributed to the prior user. track(EVENTS.USER_SIGNED_OUT) anonymize() }} >Sign out ) : ( Sign in Beta )}
{isPending && }
} /> } /> } /> {/* v0.16.0 (item #12): per-RFC invitation acceptance landing. Anonymous viewers see a sign-in prompt; signed-in users see the preview + accept gesture. */} } /> {/* v0.17.0 — roadmap item #16. The claim landing page for admin-issued invites. Anonymous-reachable; the call itself establishes the session on success. */} } /> } /> {/* v0.19.0 / roadmap item #30 — /docs/* is a hub with sub-nav. The bare /docs path redirects to the user guide; sessions browser lives at /docs/sessions/*. See DocsLayout.jsx for the flyout shape and CHANGELOG v0.19.0 for the upgrade path. */} } /> } /> {/* §14.5 / §14.6: cookie-consent companions to /philosophy. Available to anonymous and authenticated viewers alike. */} } /> } /> {viewer && ( } /> )} {isAdmin && ( } /> )} {/* §22.10: the deployment landing at `/` — redirect into the single visible project (N=1, OHM's "land in the corpus" UX) or show the directory when 2+ are visible. */} } /> {/* §22.10: per-project subtree. ProjectLayout fetches the project, applies its theme, provides ProjectContext, and guards the corpus (served only for the corpus-served default; others get a placeholder). The generic `/e/` segment carries every entry type; the noun ("RFC"/"Spec"/"Feature") is a type-driven label. */} setProposeOpen(true)} version={catalogVersion} />
} /> } /> } /> setCatalogVersion(v => v + 1)} />} />
} /> {/* Any other path (incl. the retired bare-slug corpus URLs that somehow reach the SPA) lands on the deployment landing. */} } />
{(proposeOpen || proposeParam != null) && viewer && ( { setProposeOpen(false); clearParams('propose') }} onSubmitted={({ pr_number }) => { setProposeOpen(false) clearParams('propose') setCatalogVersion(v => v + 1) navigate(proposalPath(deployment.defaultProjectId, pr_number)) }} /> )} {contributeSlug && viewer && ( clearParams('contribute', 'term')} /> )} {inboxOpen && viewer && ( setInboxOpen(false)} lastChangeTick={inboxTick} /> )}
) } function DeploymentLanding() { // §22.10 + design decision 2 — N=1 lands in the single visible project so // OHM's "land in the corpus" UX is preserved; the directory appears only // when 2+ projects are visible to the caller. Visibility is per-caller // (§22.5), so the unlisted projects never count toward the directory. const { projects, defaultProjectId, loading } = useDeployment() if (loading) { return
Loading…
} if (projects.length === 1) { return } if (projects.length === 0 && defaultProjectId) { // No enumerable projects but a default exists (e.g. an anon hitting a // deployment whose only project is unlisted-but-default) — land there. return } return } 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
{children}
} 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 (
) } function DocsWithSidebar({ viewer }) { // v0.19.0 / roadmap item #30 — the `/docs/*` surface is a flyout // shell with sub-routes. The shell (sidebar + content area) is the // DocsLayout outlet host; the sub-routes mount their respective // pages into the outlet. Bare `/docs/sessions` redirects to the // sessions about page so deep-linkers and the flyout's "Sessions" // header both land somewhere coherent. return (
}> } /> } /> } /> } /> } /> } /> {/* v0.20.0 — /docs/specs/* surface (framework spec + flotilla spec at runtime via gitea raw). Bare /docs/specs lands on the client-side redirect to the first configured spec. */} } /> } />
) } function NotificationSettingsWithSidebar({ viewer }) { return (
) } function AdminWithSidebar({ viewer }) { return (
) } 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 (
Your beta access request is in review.{' '} Learn more →
) } 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 } if (!viewer) { return (

Welcome.

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.

Discussion and contribution are in private Beta — read freely, and sign in if your email has been invited.

Wondering why a conversation is public, why graduation costs what it does, or why the model is in the chat? Read the philosophy.

) } return (

Welcome, {viewer.display_name}.

The catalog on the left lists every super-draft and active RFC in the framework. Open one to read the canonical body, or use{' '} Propose New RFC at the bottom of the catalog to open an idea PR against the meta repository.

Wondering why a conversation is public, why graduation costs what it does, or why the model is in the chat? Read the philosophy.

) }