diff --git a/frontend/package.json b/frontend/package.json
index 2396528..6a4eb16 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
- "version": "0.18.0",
+ "version": "0.19.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/App.css b/frontend/src/App.css
index 8d99fbd..0ddc039 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -2193,3 +2193,199 @@
font-size: 11px; text-transform: uppercase;
color: #6b7280; letter-spacing: 0.05em; font-weight: 600;
}
+
+/* ──────────────────────────────────────────────────────────────────
+ v0.19.0 / roadmap item #30 — /docs/* flyout + sessions browser.
+
+ The shell is `.docs-layout` (header + body). The body is a flex
+ row: `.docs-nav` is the persistent left sidebar on desktop and a
+ slide-out drawer on mobile (toggled by the `.docs-drawer-toggle`
+ icon in `.docs-header`). The content area `.docs-content` mounts
+ the sub-route via React Router's .
+
+ The article body inside each sub-route reuses `.philosophy-body`
+ (defined above) for the markdown rendering — same `marked` lib,
+ same typography. The new classes here just handle the chrome
+ (sidebar + drawer + header).
+ ────────────────────────────────────────────────────────────────── */
+
+.docs-layout {
+ height: 100%;
+ display: flex; flex-direction: column;
+}
+
+.docs-header {
+ display: flex; align-items: center; gap: 12px;
+ padding: 12px 24px 12px 16px;
+ border-bottom: 1px solid #f0f0ee;
+ flex-shrink: 0;
+}
+.docs-back, .docs-drawer-toggle {
+ border: none; background: none; cursor: pointer;
+ color: #4b5563; font-size: 13px;
+ padding: 4px 8px; border-radius: 4px;
+}
+.docs-back:hover, .docs-drawer-toggle:hover {
+ background: #f3f4f6; color: #111;
+}
+.docs-drawer-toggle {
+ display: none;
+ font-size: 18px;
+ line-height: 1;
+}
+.docs-title {
+ font-size: 13px; color: #6b7280;
+ text-transform: uppercase; letter-spacing: 0.08em;
+}
+.docs-signin {
+ margin-left: auto;
+ font-size: 13px; color: #4b5563; text-decoration: none;
+}
+.docs-signin:hover { color: #111; text-decoration: underline; }
+
+.docs-body {
+ flex: 1; min-height: 0;
+ display: flex; flex-direction: row;
+ position: relative;
+}
+
+.docs-nav {
+ flex-shrink: 0;
+ width: 260px;
+ border-right: 1px solid #f0f0ee;
+ padding: 24px 20px;
+ overflow-y: auto;
+ background: #fafaf9;
+}
+
+.docs-nav-inner { display: flex; flex-direction: column; gap: 24px; }
+.docs-nav-section { display: flex; flex-direction: column; gap: 8px; }
+.docs-nav-section-label {
+ font-size: 11px; font-weight: 600;
+ text-transform: uppercase; letter-spacing: 0.08em;
+ color: #6b7280;
+}
+.docs-nav-list {
+ list-style: none; padding: 0; margin: 0;
+ display: flex; flex-direction: column; gap: 2px;
+}
+.docs-nav-list a {
+ display: block;
+ padding: 6px 10px; border-radius: 4px;
+ color: #1f2937; text-decoration: none;
+ font-size: 14px; line-height: 1.4;
+ word-break: break-word;
+}
+.docs-nav-list a:hover { background: #f0f0ee; }
+.docs-nav-list a.active {
+ background: #e7e5e4; color: #111; font-weight: 600;
+}
+
+.docs-nav-skeleton .skeleton-row {
+ display: block;
+ height: 14px; margin: 8px 10px;
+ background: linear-gradient(90deg, #f0f0ee 25%, #e7e5e4 50%, #f0f0ee 75%);
+ background-size: 200% 100%;
+ border-radius: 4px;
+ animation: docs-skeleton-shimmer 1.2s ease-in-out infinite;
+}
+@keyframes docs-skeleton-shimmer {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
+
+.docs-nav-error {
+ padding: 8px 10px;
+ font-size: 13px; color: #9a3412;
+ background: #fff7ed; border: 1px solid #fed7aa; border-radius: 4px;
+ display: flex; flex-direction: column; gap: 6px;
+}
+.docs-nav-error button {
+ align-self: flex-start;
+ background: #fff; border: 1px solid #fed7aa;
+ color: #9a3412; font-size: 12px;
+ padding: 4px 8px; border-radius: 4px; cursor: pointer;
+}
+.docs-nav-error button:hover { background: #fff7ed; }
+
+.docs-content {
+ flex: 1; min-width: 0;
+ overflow-y: auto;
+ padding: 24px 32px 64px;
+}
+
+.docs-article {
+ max-width: 760px;
+ margin: 0 auto;
+}
+.docs-article-title {
+ font-size: 28px; font-weight: 700; margin: 0 0 24px;
+ letter-spacing: -0.01em;
+ color: #111;
+}
+.docs-breadcrumbs { margin: 0 0 16px; font-size: 13px; }
+.docs-breadcrumbs a {
+ color: #4b5563; text-decoration: none;
+}
+.docs-breadcrumbs a:hover { color: #111; text-decoration: underline; }
+
+.docs-session-files {
+ list-style: none; padding: 0; margin: 0;
+ display: flex; flex-direction: column; gap: 6px;
+}
+.docs-session-files a {
+ display: block;
+ padding: 10px 14px; border-radius: 6px;
+ background: #fafaf9; border: 1px solid #f0f0ee;
+ color: #1f2937; text-decoration: none;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 13px; word-break: break-all;
+}
+.docs-session-files a:hover {
+ background: #f3f4f6; border-color: #e5e7eb;
+}
+
+.docs-empty, .docs-error {
+ padding: 16px; border-radius: 6px;
+ background: #fafaf9; border: 1px solid #f0f0ee;
+ font-size: 14px; line-height: 1.6; color: #4b5563;
+}
+.docs-error { background: #fef2f2; border-color: #fecaca; color: #991b1b; }
+.docs-error button {
+ margin-top: 10px;
+ background: #fff; border: 1px solid #fecaca;
+ color: #991b1b; font-size: 13px;
+ padding: 6px 12px; border-radius: 4px; cursor: pointer;
+}
+.docs-error button:hover { background: #fef2f2; }
+
+.docs-drawer-scrim {
+ display: none;
+ position: absolute;
+ inset: 0;
+ background: rgba(0,0,0,0.3);
+ border: none; padding: 0;
+ cursor: pointer;
+ z-index: 5;
+}
+
+/* Mobile: the sidebar becomes a slide-out drawer. */
+@media (max-width: 720px) {
+ .docs-drawer-toggle { display: inline-block; }
+ .docs-nav {
+ position: absolute;
+ top: 0; bottom: 0; left: 0;
+ width: 80%; max-width: 320px;
+ z-index: 10;
+ transform: translateX(-100%);
+ transition: transform 200ms ease-out;
+ box-shadow: 2px 0 8px rgba(0,0,0,0.1);
+ }
+ .docs-body--drawer-open .docs-nav {
+ transform: translateX(0);
+ }
+ .docs-body--drawer-open .docs-drawer-scrim {
+ display: block;
+ }
+ .docs-content { padding: 16px 18px 64px; }
+}
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 988c085..99e15bc 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
-import { Routes, Route, Link, useLocation, useNavigate } from 'react-router-dom'
+import { Routes, Route, Link, Navigate, useLocation, useNavigate } from 'react-router-dom'
import { getMe, subscribeToNotifications } from './api'
import { anonymize, EVENTS, identify, track } from './lib/analytics'
import Catalog from './components/Catalog.jsx'
@@ -12,7 +12,11 @@ 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 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 NotificationSettings from './components/NotificationSettings.jsx'
import Admin from './components/Admin.jsx'
import AcceptInvitation from './components/AcceptInvitation.jsx'
@@ -233,7 +237,13 @@ export default function App() {
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. */}
} />
@@ -303,9 +313,24 @@ function PhilosophyWithSidebar({ viewer }) {
}
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 (
-
+
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
)
}
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 826c858..0214e8a 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -720,6 +720,60 @@ export async function getDocs() {
return jsonOrThrow(await fetch('/api/docs'))
}
+// ---------------------------------------------------------------------------
+// v0.19.0 / roadmap item #30 — /api/docs/sessions/* surface
+// ---------------------------------------------------------------------------
+//
+// The framework mediates reads against the public
+// `wiggleverse/ohm-session-history` gitea repo so the rendered
+// `/docs/sessions/*` surface inherits the same chrome as
+// `/docs/user-guide`. Three text-bearing endpoints return markdown
+// (Content-Type: text/markdown) and the manifest returns JSON. We
+// wrap each into a small helper.
+//
+// 404 from `getSessionAbout` / `getSessionTranscript` / `getSessionIndex`
+// throws an Error with `.status === 404` so the UI can render its own
+// empty-state. 502 (gitea unreachable) throws `.status === 502` so
+// the UI can offer a retry button.
+
+export async function getSessionsManifest() {
+ // Manifest 404 is mapped server-side to HTTP 200 + `{}` so this
+ // helper never throws on the empty-state path.
+ return jsonOrThrow(await fetch('/api/docs/sessions/manifest'))
+}
+
+async function _textOrThrow(res) {
+ if (!res.ok) {
+ let detail = ''
+ try {
+ const body = await res.json()
+ detail = body.detail || JSON.stringify(body)
+ } catch {
+ detail = await res.text()
+ }
+ const error = new Error(detail || `HTTP ${res.status}`)
+ error.status = res.status
+ throw error
+ }
+ return res.text()
+}
+
+export async function getSessionsAbout() {
+ return _textOrThrow(await fetch('/api/docs/sessions/about'))
+}
+
+export async function getSessionTranscript(nnnn, filename) {
+ return _textOrThrow(await fetch(
+ `/api/docs/sessions/${encodeURIComponent(nnnn)}/${encodeURIComponent(filename)}`
+ ))
+}
+
+export async function getSessionIndex(nnnn) {
+ return jsonOrThrow(await fetch(
+ `/api/docs/sessions/${encodeURIComponent(nnnn)}/index`
+ ))
+}
+
// ---------------------------------------------------------------------------
// Slice 7: admin neighborhood (§17 admin/* + user search for the §15.8 mute
// typeahead).
diff --git a/frontend/src/components/Docs.jsx b/frontend/src/components/Docs.jsx
deleted file mode 100644
index 7af0c9d..0000000
--- a/frontend/src/components/Docs.jsx
+++ /dev/null
@@ -1,51 +0,0 @@
-// `/docs` — the user-facing guide.
-//
-// Sibling of Philosophy.jsx: same chrome, same data path, different
-// source file. Renders DOCS.md verbatim with light chrome around it.
-// Reachable anonymously, same as `/philosophy`, so a visitor can read
-// the guide before deciding to sign in.
-
-import { useEffect, useState } from 'react'
-import { Link, useNavigate } from 'react-router-dom'
-import MarkdownPreview from './MarkdownPreview.jsx'
-import { getDocs } from '../api.js'
-
-export default function Docs({ authenticated }) {
- const [body, setBody] = useState('')
- const [error, setError] = useState(null)
- const [loading, setLoading] = useState(true)
- const navigate = useNavigate()
-
- useEffect(() => {
- let active = true
- getDocs()
- .then(r => { if (active) setBody(r.body || '') })
- .catch(e => { if (active) setError(e.message || String(e)) })
- .finally(() => { if (active) setLoading(false) })
- return () => { active = false }
- }, [])
-
- return (
-
-
- (history.length > 1 ? navigate(-1) : navigate('/'))}
- >
- ← Back
-
- User guide
- {!authenticated && (
- Home
- )}
-
-
- {loading && Loading…
}
- {error && Could not load the guide: {error}
}
- {!loading && !error && (
-
- )}
-
-
- )
-}
diff --git a/frontend/src/components/DocsLayout.jsx b/frontend/src/components/DocsLayout.jsx
new file mode 100644
index 0000000..c8abc98
--- /dev/null
+++ b/frontend/src/components/DocsLayout.jsx
@@ -0,0 +1,211 @@
+// DocsLayout.jsx — v0.19.0 / roadmap item #30.
+//
+// Left-side flyout nav + content area for the `/docs/*` route tree:
+//
+// /docs → redirect to /docs/user-guide
+// /docs/user-guide → DOCS.md (existing v0.14.0 content)
+// /docs/sessions → redirect to /docs/sessions/about
+// /docs/sessions/about → README.md from the sessions repo
+// /docs/sessions/:nnnn → per-session index page
+// /docs/sessions/:nnnn/:file → per-transcript view
+//
+// The flyout is a persistent left sidebar on desktop and a slide-out
+// drawer on mobile (toggled by the icon button in the docs header).
+// The session list is driven by the `/api/docs/sessions/manifest`
+// fetch:
+// - loading → skeleton in the nav (three placeholder rows)
+// - manifest 502 → error banner in the nav with "Try again"
+// - empty manifest → only "About" under Sessions; no NNNN rows
+//
+// Amplitude analytics (per SPEC §21):
+// - track('Doc Viewed', { section: '...' }) on each sub-route mount;
+// the sub-route component owns the fire (it knows the section).
+// - flyout buttons + links carry `aria-label` + `data-amp-track-name`
+// so autocapture rows are readable rather than ":nth-child(7)".
+
+import { useEffect, useState, useCallback } from 'react'
+import { Link, useNavigate, useLocation, Outlet } from 'react-router-dom'
+import { getSessionsManifest } from '../api.js'
+
+export default function DocsLayout({ authenticated }) {
+ const [manifest, setManifest] = useState(null)
+ const [manifestState, setManifestState] = useState('loading') // loading | ok | error
+ const [drawerOpen, setDrawerOpen] = useState(false)
+ const [reloadTick, setReloadTick] = useState(0)
+ const navigate = useNavigate()
+ const location = useLocation()
+
+ useEffect(() => {
+ let active = true
+ setManifestState('loading')
+ getSessionsManifest()
+ .then(payload => {
+ if (!active) return
+ setManifest(payload || {})
+ setManifestState('ok')
+ })
+ .catch(() => {
+ if (!active) return
+ setManifest({})
+ setManifestState('error')
+ })
+ return () => { active = false }
+ }, [reloadTick])
+
+ // Close the mobile drawer on every navigation so a click in the nav
+ // doesn't strand the user on a drawer-open view.
+ useEffect(() => {
+ setDrawerOpen(false)
+ }, [location.pathname])
+
+ const retryManifest = useCallback(() => {
+ setReloadTick(t => t + 1)
+ }, [])
+
+ return (
+
+
+ (history.length > 1 ? navigate(-1) : navigate('/'))}
+ aria-label="Back to previous page"
+ data-amp-track-name="Docs Back"
+ >
+ ← Back
+
+ setDrawerOpen(o => !o)}
+ aria-label="Toggle docs navigation"
+ aria-expanded={drawerOpen}
+ data-amp-track-name="Docs Drawer Toggle"
+ >
+ ☰
+
+ Docs
+ {!authenticated && (
+
+ Home
+
+ )}
+
+
+ {drawerOpen && (
+
setDrawerOpen(false)}
+ data-amp-track-name="Docs Drawer Close"
+ />
+ )}
+
+ )
+}
+
+function DocsNav({ manifest, manifestState, onRetry, currentPath }) {
+ const isActive = (path) => currentPath === path || currentPath.startsWith(path + '/')
+
+ // Sort session keys ascending (newest sessions render last). The
+ // manifest's keys are zero-padded 4-digit strings so lexicographic
+ // order is the same as numeric.
+ const sessionKeys = Object.keys(manifest || {}).sort()
+
+ return (
+
+
+
+
+
Sessions
+
+
+ {manifestState === 'loading' && (
+
+ )}
+
+ {manifestState === 'error' && (
+
+ Couldn't load session list.
+
+ Try again
+
+
+ )}
+
+ {manifestState === 'ok' && sessionKeys.length > 0 && (
+
+ {sessionKeys.map(nnnn => {
+ const entry = manifest[nnnn] || {}
+ const title = entry.title || ''
+ const label = title ? `${nnnn} — ${title}` : nnnn
+ const to = `/docs/sessions/${nnnn}`
+ return (
+
+
+ {label}
+
+
+ )
+ })}
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/components/DocsSessionIndex.jsx b/frontend/src/components/DocsSessionIndex.jsx
new file mode 100644
index 0000000..601933e
--- /dev/null
+++ b/frontend/src/components/DocsSessionIndex.jsx
@@ -0,0 +1,126 @@
+// DocsSessionIndex.jsx — v0.19.0 / roadmap item #30.
+//
+// Per-session index page at `/docs/sessions/:nnnn`. Lists every
+// transcript published under the session's NNNN/ folder, linked to
+// the per-transcript view.
+//
+// The transcript list comes from `/api/docs/sessions/:nnnn/index`,
+// which the framework derives via the gitea contents API (see
+// backend/app/docs_sessions.fetch_session_index). We also read the
+// session's `title` from the manifest fetch so the page header
+// matches the flyout nav entry.
+
+import { useEffect, useState, useCallback } from 'react'
+import { Link, useParams } from 'react-router-dom'
+import { getSessionsManifest, getSessionIndex } from '../api.js'
+import { EVENTS, track } from '../lib/analytics'
+
+export default function DocsSessionIndex() {
+ const { nnnn } = useParams()
+ const [title, setTitle] = useState('')
+ const [files, setFiles] = useState([])
+ const [status, setStatus] = useState('loading') // loading | ok | notfound | error
+ const [reloadTick, setReloadTick] = useState(0)
+
+ useEffect(() => {
+ track(EVENTS.DOC_VIEWED, { section: `sessions/${nnnn}` })
+ }, [nnnn])
+
+ // Title from manifest — cheap, manifest is cached server-side.
+ useEffect(() => {
+ let active = true
+ getSessionsManifest()
+ .then(payload => {
+ if (!active) return
+ const entry = payload && payload[nnnn]
+ setTitle((entry && entry.title) || '')
+ })
+ .catch(() => {
+ // Title is decorative; failure to load just leaves the header
+ // showing the bare NNNN. The transcript list fetch below is
+ // the load-bearing one.
+ })
+ return () => { active = false }
+ }, [nnnn])
+
+ // File list from the per-session index endpoint.
+ useEffect(() => {
+ let active = true
+ setStatus('loading')
+ getSessionIndex(nnnn)
+ .then(payload => {
+ if (!active) return
+ setFiles((payload && payload.files) || [])
+ setStatus('ok')
+ })
+ .catch(e => {
+ if (!active) return
+ if (e.status === 404) {
+ setStatus('notfound')
+ } else {
+ setStatus('error')
+ }
+ })
+ return () => { active = false }
+ }, [nnnn, reloadTick])
+
+ const retry = useCallback(() => setReloadTick(t => t + 1), [])
+
+ const header = title ? `${nnnn} — ${title}` : `Session ${nnnn}`
+
+ return (
+
+ {header}
+ {status === 'loading' && Loading…
}
+ {status === 'notfound' && (
+
+
+ No transcripts have been published for this session yet.{' '}
+
+ About sessions
+
+
+
+ )}
+ {status === 'error' && (
+
+
Couldn't reach the session-history repo.
+
+ Try again
+
+
+ )}
+ {status === 'ok' && files.length === 0 && (
+
+
This session has no transcripts published.
+
+ )}
+ {status === 'ok' && files.length > 0 && (
+
+ {files.map(f => (
+
+
+ {f}
+
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/DocsSessionTranscript.jsx b/frontend/src/components/DocsSessionTranscript.jsx
new file mode 100644
index 0000000..4f38d6b
--- /dev/null
+++ b/frontend/src/components/DocsSessionTranscript.jsx
@@ -0,0 +1,96 @@
+// DocsSessionTranscript.jsx — v0.19.0 / roadmap item #30.
+//
+// Per-transcript view at `/docs/sessions/:nnnn/:filename`. Fetches the
+// transcript body via the backend mediator and renders it through the
+// shared MarkdownPreview.
+//
+// Empty-state contract:
+// 404 → "This transcript isn't published yet" with a link back to
+// the parent session index
+// 502 → "Couldn't reach the session-history repo" + retry button
+
+import { useEffect, useState, useCallback } from 'react'
+import { Link, useParams } from 'react-router-dom'
+import MarkdownPreview from './MarkdownPreview.jsx'
+import { getSessionTranscript } from '../api.js'
+import { EVENTS, track } from '../lib/analytics'
+
+export default function DocsSessionTranscript() {
+ const { nnnn, filename } = useParams()
+ const [body, setBody] = useState('')
+ const [status, setStatus] = useState('loading') // loading | ok | notfound | error
+ const [reloadTick, setReloadTick] = useState(0)
+
+ useEffect(() => {
+ track(EVENTS.DOC_VIEWED, { section: `sessions/${nnnn}/${filename}` })
+ }, [nnnn, filename])
+
+ useEffect(() => {
+ let active = true
+ setStatus('loading')
+ getSessionTranscript(nnnn, filename)
+ .then(text => {
+ if (!active) return
+ setBody(text || '')
+ setStatus('ok')
+ })
+ .catch(e => {
+ if (!active) return
+ if (e.status === 404) {
+ setStatus('notfound')
+ } else {
+ setStatus('error')
+ }
+ })
+ return () => { active = false }
+ }, [nnnn, filename, reloadTick])
+
+ const retry = useCallback(() => setReloadTick(t => t + 1), [])
+
+ return (
+
+
+
+ ← Session {nnnn}
+
+
+ {status === 'loading' && Loading…
}
+ {status === 'notfound' && (
+
+
This transcript isn't published yet.
+
+
+ ← Back to session {nnnn}
+
+
+
+ )}
+ {status === 'error' && (
+
+
Couldn't reach the session-history repo.
+
+ Try again
+
+
+ )}
+ {status === 'ok' && (
+
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/DocsSessionsAbout.jsx b/frontend/src/components/DocsSessionsAbout.jsx
new file mode 100644
index 0000000..f6ad0bf
--- /dev/null
+++ b/frontend/src/components/DocsSessionsAbout.jsx
@@ -0,0 +1,99 @@
+// DocsSessionsAbout.jsx — v0.19.0 / roadmap item #30.
+//
+// Renders the README.md of the public `wiggleverse/ohm-session-history`
+// repo at `/docs/sessions/about`. The framework backend mediates the
+// gitea fetch (see backend/app/docs_sessions.py); this component
+// handles three response paths:
+//
+// 200 → render the markdown via MarkdownPreview
+// 404 → "About not yet published" empty-state (the upstream README
+// doesn't exist yet — happens when a deployment hasn't
+// restructured its session-history repo yet, expected at
+// v0.19.0 deploy time per the CHANGELOG)
+// 502 → "Couldn't reach the session-history repo" with a retry
+// button. The retry just re-invokes the fetch — no extra
+// backoff because the backend cache already smoothes
+// repeated 502s.
+
+import { useEffect, useState, useCallback } from 'react'
+import MarkdownPreview from './MarkdownPreview.jsx'
+import { getSessionsAbout } from '../api.js'
+import { EVENTS, track } from '../lib/analytics'
+
+export default function DocsSessionsAbout() {
+ const [body, setBody] = useState('')
+ const [status, setStatus] = useState('loading') // loading | ok | notfound | error
+ const [reloadTick, setReloadTick] = useState(0)
+
+ useEffect(() => {
+ track(EVENTS.DOC_VIEWED, { section: 'sessions/about' })
+ }, [])
+
+ useEffect(() => {
+ let active = true
+ setStatus('loading')
+ getSessionsAbout()
+ .then(text => {
+ if (!active) return
+ setBody(text || '')
+ setStatus('ok')
+ })
+ .catch(e => {
+ if (!active) return
+ if (e.status === 404) {
+ setStatus('notfound')
+ } else {
+ setStatus('error')
+ }
+ })
+ return () => { active = false }
+ }, [reloadTick])
+
+ const retry = useCallback(() => setReloadTick(t => t + 1), [])
+
+ return (
+
+ About sessions
+ {status === 'loading' && Loading…
}
+ {status === 'notfound' && (
+
+
+ The session-history About page isn't published yet. Sessions
+ are still authored — once a few have shipped, this page will
+ render the canonical introduction.
+
+
+ In the meantime, browse the source repo directly at{' '}
+
+ wiggleverse/ohm-session-history
+ .
+
+
+ )}
+ {status === 'error' && (
+
+
Couldn't reach the session-history repo.
+
+ Try again
+
+
+ )}
+ {status === 'ok' && (
+
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/DocsUserGuide.jsx b/frontend/src/components/DocsUserGuide.jsx
new file mode 100644
index 0000000..d2b9588
--- /dev/null
+++ b/frontend/src/components/DocsUserGuide.jsx
@@ -0,0 +1,45 @@
+// DocsUserGuide.jsx — v0.19.0 / roadmap item #30.
+//
+// Renders DOCS.md at `/docs/user-guide`. Was `/docs` before v0.19.0
+// (the v0.14.0 single-route Docs.jsx surface, now superseded). The
+// content path is unchanged: backend reads `DOCS.md` from disk and
+// serves it at `/api/docs`. The body is rendered via the existing
+// `MarkdownPreview` (the same component the `/philosophy` route uses,
+// so we don't introduce a second markdown library).
+
+import { useEffect, useState } from 'react'
+import MarkdownPreview from './MarkdownPreview.jsx'
+import { getDocs } from '../api.js'
+import { EVENTS, track } from '../lib/analytics'
+
+export default function DocsUserGuide() {
+ const [body, setBody] = useState('')
+ const [error, setError] = useState(null)
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ track(EVENTS.DOC_VIEWED, { section: 'user-guide' })
+ }, [])
+
+ useEffect(() => {
+ let active = true
+ getDocs()
+ .then(r => { if (active) setBody(r.body || '') })
+ .catch(e => { if (active) setError(e.message || String(e)) })
+ .finally(() => { if (active) setLoading(false) })
+ return () => { active = false }
+ }, [])
+
+ return (
+
+ User guide
+ {loading && Loading…
}
+ {error && Could not load the guide: {error}
}
+ {!loading && !error && (
+
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/lib/analytics.js b/frontend/src/lib/analytics.js
index 6e0633e..689282d 100644
--- a/frontend/src/lib/analytics.js
+++ b/frontend/src/lib/analytics.js
@@ -97,6 +97,13 @@ export const EVENTS = Object.freeze({
// v0.17.0 / item #16 — admin-create user + invite email.
USER_INVITED: 'User Invited',
INVITE_CLAIMED: 'Invite Claimed',
+ // v0.19.0 / item #30 — `/docs/*` flyout + sessions browser. Carries
+ // `section`: 'user-guide' | 'sessions/about' | 'sessions/' |
+ // 'sessions//' so the dashboard can answer which
+ // docs surfaces get read most. The transcript-section value includes
+ // the filename so an aggregator can group by `sessions/` or by
+ // exact transcript.
+ DOC_VIEWED: 'Doc Viewed',
})
// Internal state.