Release v0.20.0: /docs/specs surface + nested flyout nav + session body-list removal
Wave 9 follow-up to roadmap item #30 (Session 0017.0 shipped #30 as v0.19.0; v0.20.0 lands the operator-feedback follow-ups on top). 1. Specs on /docs/specs/<name> (backend docs_specs.py + frontend DocsSpec.jsx + DocsSpecsIndex.jsx). Configured via OHM_DOCS_SPECS; framework default carries OHM's two specs (rfc-app/SPEC.md + flotilla SPEC.md). Runtime fetch from gitea raw with 5-min TTL cache, mirroring docs_sessions.py. 2. Nested flyout nav hierarchy (DocsLayout.jsx). Sessions render as a tree with transcripts nested under each session row (labeled by .N ordinal). New Specs section between User Guide and Sessions. 3. /docs/sessions/<NNNN> body-list removed (DocsSessionIndex.jsx). Body becomes a session-overview card; navigation lives in the left nav. 19 new pytest cases for docs_specs (332 backend total green). Frontend build clean. Sync frontend/package-lock.json version drift (0.15.0 → 0.20.0) alongside the VERSION + package.json bump. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
// DocsSpec.jsx — v0.20.0.
|
||||
//
|
||||
// Per-spec view at `/docs/specs/:name`. Fetches a configured spec
|
||||
// body via the backend mediator (which proxies the gitea raw URL)
|
||||
// and renders it through the shared MarkdownPreview. The page header
|
||||
// pulls the spec's `title` from the manifest so the breadcrumb-free
|
||||
// page still names what you're looking at.
|
||||
//
|
||||
// Empty-state contract:
|
||||
// 404 → "This spec isn't published yet / unknown name" with a hint
|
||||
// to pick a configured spec from the nav.
|
||||
// 502 → "Couldn't reach the spec source" + retry button.
|
||||
//
|
||||
// History view is intentionally absent — the operator's framing for
|
||||
// v0.20.0 is "current version only; git is the history surface".
|
||||
// A small "View on gitea" link beside the title points at the
|
||||
// upstream source URL the manifest carries so the history gesture
|
||||
// remains one click away.
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import MarkdownPreview from './MarkdownPreview.jsx'
|
||||
import { getSpec, getSpecsManifest } from '../api.js'
|
||||
import { EVENTS, track } from '../lib/analytics'
|
||||
|
||||
export default function DocsSpec() {
|
||||
const { name } = useParams()
|
||||
const [title, setTitle] = useState('')
|
||||
const [sourceUrl, setSourceUrl] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [status, setStatus] = useState('loading') // loading | ok | notfound | error
|
||||
const [reloadTick, setReloadTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
track(EVENTS.DOC_VIEWED, { section: `specs/${name}` })
|
||||
}, [name])
|
||||
|
||||
// Title + upstream URL from the manifest (cheap — the manifest is
|
||||
// derived from an env var on the backend, no network).
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
getSpecsManifest()
|
||||
.then(payload => {
|
||||
if (!active) return
|
||||
const entry = (payload && payload.specs || []).find(s => s.name === name)
|
||||
setTitle((entry && entry.title) || '')
|
||||
setSourceUrl((entry && entry.url) || '')
|
||||
})
|
||||
.catch(() => {
|
||||
// Title + source link are decorative; the body fetch below
|
||||
// is the load-bearing one. A failed manifest fetch just
|
||||
// leaves the page rendering the bare name.
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [name])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setStatus('loading')
|
||||
getSpec(name)
|
||||
.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 }
|
||||
}, [name, reloadTick])
|
||||
|
||||
const retry = useCallback(() => setReloadTick(t => t + 1), [])
|
||||
|
||||
const header = title || `Spec: ${name}`
|
||||
|
||||
return (
|
||||
<article className="docs-article">
|
||||
<header className="docs-article-header">
|
||||
<h1 className="docs-article-title">{header}</h1>
|
||||
{sourceUrl && (
|
||||
<a
|
||||
className="docs-source-link"
|
||||
href={sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="View spec source on gitea"
|
||||
data-amp-track-name="Docs Spec Source Link"
|
||||
data-amp-track-spec={name}
|
||||
>
|
||||
View source
|
||||
</a>
|
||||
)}
|
||||
</header>
|
||||
{status === 'loading' && <p className="muted">Loading…</p>}
|
||||
{status === 'notfound' && (
|
||||
<div className="docs-empty">
|
||||
<p>This spec isn't available.</p>
|
||||
<p>
|
||||
<Link
|
||||
to="/docs/user-guide"
|
||||
aria-label="User guide"
|
||||
data-amp-track-name="Docs Spec Notfound User Guide Link"
|
||||
>
|
||||
← Back to the user guide
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<div className="docs-error" role="alert">
|
||||
<p>Couldn't reach the spec source.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={retry}
|
||||
aria-label="Retry"
|
||||
data-amp-track-name="Docs Spec Retry"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{status === 'ok' && (
|
||||
<div className="philosophy-body">
|
||||
<MarkdownPreview content={body} />
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user