Compare commits

...

9 Commits

Author SHA1 Message Date
Ben Stull 2ac20b1621 Merge feature/v0.21.0-ux-polish (UX-polish wave: #31 + #24 + #25 + #32) 2026-05-28 11:52:05 -07:00
Ben Stull 493d6b6eee Release v0.21.0: UX-polish wave (#31 foundation + #24 + #25 + #32)
Token foundation + App.css sweep, header Philosophy rename, inbox
inline-SVG icon + light UX pass, session/transcript page collapse +
metadata header. Pure frontend; 332 backend tests green; build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 11:36:55 -07:00
Ben Stull 959fc906de Merge feature/v0.21.0-docs-32 into v0.21.0 polish wave 2026-05-28 11:34:28 -07:00
Ben Stull b648b3ed45 Merge feature/v0.21.0-header-inbox into v0.21.0 polish wave 2026-05-28 11:34:28 -07:00
Ben Stull 54736de91c Merge feature/v0.21.0-appcss-sweep into v0.21.0 polish wave 2026-05-28 11:34:28 -07:00
Ben Stull adb5d25715 docs(#32): inline-collapse session roots + transcript metadata header
Roadmap item #32 (session/transcript page polish) plus the /docs
surfaces' share of #31, for rfc-app v0.21.0.

- DocsSessionIndex: the session root (/docs/sessions/:nnnn) no longer
  renders a dead-end "N transcript(s) — select from the nav"
  placeholder. It now renders a transcript INLINE: the lone transcript
  for single-transcript sessions, or the `.0` driver transcript (falling
  back to first-by-sort) for multi-transcript sessions, with the
  remaining siblings listed/linked above the body. URL stays stable to
  the session number — inline render, no 301.

- DocsSessionTranscript: adds a compact metadata header above the body
  (title; started/ended parsed from the filename's ISO segments,
  human-readable; derived duration; optional TL;DR from the manifest's
  `tldr` string field, graceful-degrade when absent; external
  "View source on git.wiggleverse.org" link). The parse/header helpers
  are exported so the inline-collapse view reuses identical rendering.

- Docs.css (new): token-based styling for the new metadata-header +
  sibling-list elements only; existing docs classes stay owned by
  App.css to avoid racing the #31 sweep.

- DocsUserGuide: loading/error states brought onto the shared
  .docs-empty/.docs-error convention with a retry button.

No backend change: /api/docs/sessions/manifest already passes the full
sessions.json entry through, so a `tldr` field on an entry reaches the
frontend with no docs_sessions.py change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 11:33:03 -07:00
Ben Stull cbf02d5507 v0.21.0: sweep App.css + index.css to design tokens (#31)
Replace hardcoded colors, font-sizes, and radii in App.css/index.css
with the tokens.css design-token system. Consolidate ~80 distinct
hex values onto the neutral ramp + semantic/status families, map
font-size literals to the --text-* scale and border-radius literals
to the --radius-* scale, route the on-dark translucent-white pattern
and header band through their semantic tokens, and point the base
rules at --color-bg/--color-text/--font-sans.

Add an appended interaction-polish layer: a coherent transition
vocabulary (var(--motion-base) var(--ease-out)) on surfaces that
already react to hover, plus one consistent :focus-visible ring using
var(--color-focus-ring). No existing selector renamed or removed;
only property values changed and additive rules appended.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 11:32:17 -07:00
Ben Stull 317738ed79 v0.21.0 header+inbox UX (#24,#25,#31): rename About→Philosophy, inline-SVG inbox icon, light inbox pass
- #24: header link "About" → "Philosophy" (route/title unchanged).
- #25 icon: replace 📮 emoji with a dependency-free inline-SVG envelope
  in .inbox-trigger; aria-label/title reframed to "Inbox". Badge intact.
- #25 inbox UX (light, no redesign): sharper unread/read distinction
  (accent dot + left bar + tint via tokens), per-row "mark as read"
  affordance that marks-without-navigating, clearer "Mark all read"
  label, and a real empty/caught-up state. New Inbox.css is tokenized
  and written one notch more specific than App.css where it overrides
  (Inbox.css injects before App.css under ESM eval order). Data flow,
  API calls, routing-on-click, and badge behavior preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 11:31:34 -07:00
Ben Stull 5be2c48afe v0.21.0 foundation: design-token module (#31)
Establish src/styles/tokens.css — the single source of truth for color,
type, spacing, radius, elevation, and motion. Imported first in main.jsx.
Sweep subagents map literal values to these tokens; no appearance change
is intended beyond consolidating near-duplicate grays (operator reviews
before deploy).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 11:26:56 -07:00
15 changed files with 1718 additions and 801 deletions
+60
View File
@@ -23,6 +23,66 @@ skip versions are the composition of each intervening adjacent
release's steps in order — no A-to-B path is pre-computed beyond
that.
## 0.21.0 — 2026-05-28
UX-polish wave. Roadmap items #31 (comprehensive UX polish — foundation
slice), #24 (header "About" → "Philosophy"), #25 (inbox icon + light UX),
and #32 (session/transcript page polish). Pure frontend; no schema, no
backend changes, no new secret. Shipped from one driver session (0019.0)
via three parallel subagents working on disjoint surfaces.
1. **Design-token foundation (#31).** New `frontend/src/styles/tokens.css`
establishes the app's first coherent design system — semantic color
palette, type scale, spacing scale, radius scale, elevation, and a
motion vocabulary (with `prefers-reduced-motion` honored) — as CSS
custom properties, imported first in `main.jsx`. Before this the app
carried ~98 distinct hardcoded hex colors, font sizes across 16
unscaled values, and radii across 13. `App.css` and `index.css` were
swept to the tokens (~630 color / 280 font-size / 128 radius
references), consolidating near-duplicate grays to the nearest ramp
step and rounding off-scale type to the nearest step. No CSS class was
renamed or removed; an additive `:focus-visible` ring and a subtle
hover/transition layer were added. 35 special-purpose hexes (true
blues/violets, status dots, deep diff-contrast shades) were
deliberately left as literals. This is the polish *foundation*; a
follow-up (#31b) covers the bespoke per-surface re-spacing that wants
operator review against screenshots.
2. **Header: "About" → "Philosophy" (#24).** The persistent header link
now reads "Philosophy" (the route `/philosophy` and its page already
existed; only the label changed).
3. **Inbox icon + light UX (#25).** The header inbox trigger's `📮`
emoji is replaced with a dependency-free inline-SVG envelope icon
(`aria-label="Inbox"`); no icon library was added. The inbox panel
got a light pass — clearer unread/read distinction, mark-all-read and
per-row affordances surfaced, better empty state, tokenized spacing in
a new component-scoped `Inbox.css`. Behavior, filters, deep-links, and
§15 notification data flow are unchanged. A full inbox redesign is
deferred to a #25 follow-up (the operator's reference screenshot did
not transmit).
4. **Session/transcript page polish (#32).** `/docs/sessions/<NNNN>` no
longer dead-ends on a "select a transcript" placeholder: a
single-transcript session renders that transcript inline at the
session root; a multi-transcript session renders its `.0` driver
transcript inline and lists the siblings. Each rendered transcript now
carries a metadata header — session title, Started/Ended (parsed from
the filename's ISO segments), derived Duration, an optional one-line
TL;DR, and a "View source on git.wiggleverse.org" external link to the
canonical raw transcript. The TL;DR reads an optional `tldr` string on
the per-session `sessions.json` manifest entry and degrades gracefully
when absent.
Upgrade steps:
MAY: add a `tldr` string to any per-session entry in
`wiggleverse/ohm-session-history`'s `sessions.json`
(e.g. `"0019": { "title": "…", "tldr": "one-line summary" }`) to surface
a summary in each transcript's metadata header. Absent `tldr` renders
nothing — no deployment action is required. This is a data edit in the
session-history repo, not a `flotilla` gesture.
## 0.20.0 — 2026-05-28
Wave 9 follow-up to roadmap item #30. Three changes bundled into one minor:
+1 -1
View File
@@ -1 +1 @@
0.20.0
0.21.0
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "rfc-app-frontend",
"version": "0.20.0",
"version": "0.21.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rfc-app-frontend",
"version": "0.20.0",
"version": "0.21.0",
"dependencies": {
"@amplitude/unified": "^1.1.9",
"@codemirror/commands": "^6.10.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.20.0",
"version": "0.21.0",
"type": "module",
"scripts": {
"dev": "vite",
+793 -738
View File
File diff suppressed because it is too large Load Diff
+11 -3
View File
@@ -169,7 +169,7 @@ export default function App() {
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
Philosophy
</Link>
<Link to="/docs" className="header-about" title="User guide">
Docs
@@ -188,9 +188,17 @@ export default function App() {
<button
className="inbox-trigger"
onClick={() => setInboxOpen(o => !o)}
title="Notifications inbox (§15.2)"
aria-label="Inbox"
title="Inbox (§15.2)"
>
<span aria-hidden>📮</span>
<svg
width="18" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" strokeWidth="1.75"
strokeLinecap="round" strokeLinejoin="round" aria-hidden
>
<path d="M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Z" />
<path d="m3.5 6.5 8.5 6 8.5-6" />
</svg>
{unreadCount > 0 && (
<span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
)}
+128
View File
@@ -0,0 +1,128 @@
/* Docs.css — docs-surface polish scoped to v0.21.0 / roadmap item #32.
*
* This sheet owns ONLY the classes introduced by item #32 (the
* transcript metadata header and the session-root sibling list). The
* pre-existing docs classes (.docs-article, .docs-empty, .docs-error,
* .docs-source-link, .philosophy-body, .muted) live in App.css and are
* deliberately NOT touched here — redefining them would race the #31
* App.css token sweep for the same selectors. Every value below reads
* a token from tokens.css so the new surfaces sit on the same
* spacing/type/color scale as the rest of the docs chrome.
*
* Imported from DocsSessionTranscript.jsx + DocsSessionIndex.jsx (the
* two components that render these elements). CSS custom properties are
* not import-order-sensitive at use time, so the import site doesn't
* matter for correctness.
*/
/* ── Transcript metadata header ─────────────────────────────────────
* A compact card above the rendered transcript body: title, the
* started/ended/duration grid, an optional TL;DR, and the external
* "view source" link. */
.docs-transcript-meta {
margin: 0 0 var(--space-9);
padding: var(--space-7);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface-sunken);
}
.docs-transcript-meta-title {
margin: 0 0 var(--space-5);
font-size: var(--text-lg);
font-weight: var(--weight-semibold);
line-height: var(--leading-tight);
color: var(--color-text-strong);
font-family: var(--font-mono);
word-break: break-word;
}
.docs-transcript-meta-grid {
margin: 0;
display: grid;
grid-template-columns: max-content 1fr;
gap: var(--space-2) var(--space-7);
align-items: baseline;
}
.docs-transcript-meta-row {
display: contents;
}
.docs-transcript-meta-grid dt {
margin: 0;
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
}
.docs-transcript-meta-grid dd {
margin: 0;
font-size: var(--text-base);
color: var(--color-text);
}
.docs-transcript-meta-tldr {
margin: var(--space-6) 0 0;
padding-top: var(--space-6);
border-top: 1px solid var(--color-border);
font-size: var(--text-base);
line-height: var(--leading-relaxed);
color: var(--color-text);
}
.docs-transcript-meta-source {
display: inline-block;
margin-top: var(--space-6);
}
/* ── Session-root sibling-transcript list ───────────────────────────
* Rendered above the inlined primary transcript when a session has
* more than one transcript (driver `.0` + subagents). The primary is
* marked "(shown below)"; the rest link to their standalone routes. */
.docs-session-siblings {
margin: 0 0 var(--space-9);
padding: var(--space-6) var(--space-7);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface-muted);
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-3) var(--space-6);
}
.docs-session-siblings-label {
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
}
.docs-session-siblings-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: var(--space-2) var(--space-5);
font-family: var(--font-mono);
font-size: var(--text-base);
}
.docs-session-siblings-list a {
color: var(--color-link);
text-decoration: none;
}
.docs-session-siblings-list a:hover {
color: var(--color-accent-strong);
text-decoration: underline;
}
.docs-session-siblings-current {
color: var(--color-text-muted);
}
+151 -26
View File
@@ -1,46 +1,86 @@
// DocsSessionIndex.jsx — v0.20.0 (was v0.19.0 / roadmap item #30).
// DocsSessionIndex.jsx — v0.21.0 (was v0.20.0 / roadmap item #30).
//
// Per-session landing at `/docs/sessions/:nnnn`. v0.19.0 listed the
// transcripts as body links; v0.20.0 drops the body list — navigation
// is via the left flyout nav (which renders each session's transcripts
// nested under the session row). The body now serves as a
// session-overview card with the title, file count, and a hint to
// pick a transcript from the nav.
// Per-session landing at `/docs/sessions/:nnnn`. v0.20.0 rendered a
// dead-end "N transcript(s) in this session. Select one from the
// navigation." placeholder. v0.21.0 / roadmap item #32 collapses that:
// the session root now renders a transcript INLINE so the URL is never
// an empty stop.
//
// The transcript count still comes from `/api/docs/sessions/:nnnn/index`
// - Exactly one transcript → render it inline at the session root.
// - Multiple transcripts → render the `.0` driver transcript
// inline (fall back to the first file by
// sort order if there's no `.0`), AND
// list/link the remaining transcripts so
// the siblings are one click away.
//
// The URL stays stable to the session number — this is an inline
// render, not a 301/redirect. The per-transcript route
// (`/docs/sessions/:nnnn/:filename`) still exists and is what the
// sibling links and the left-nav transcript rows point at.
//
// The transcript count + filenames come from `/api/docs/sessions/:nnnn/index`
// so the empty-state ("no transcripts yet"), not-found, and error
// paths remain meaningful the page still does something useful when
// the upstream is mid-publish or unreachable.
// paths remain meaningful when the upstream is mid-publish or
// unreachable. The metadata header + body rendering are imported from
// DocsSessionTranscript.jsx so the inline view is byte-identical to the
// standalone per-transcript view.
import { useEffect, useState, useCallback } from 'react'
import { Link, useParams } from 'react-router-dom'
import { getSessionsManifest, getSessionIndex } from '../api.js'
import MarkdownPreview from './MarkdownPreview.jsx'
import {
getSessionsManifest,
getSessionIndex,
getSessionTranscript,
} from '../api.js'
import {
TranscriptMetaHeader,
transcriptOrdinal,
} from './DocsSessionTranscript.jsx'
import { EVENTS, track } from '../lib/analytics'
import './Docs.css'
// Pick the transcript to render inline at the session root: prefer the
// `.0` driver transcript; otherwise the first file by sort order. The
// backend already returns the file list sorted, so `files[0]` is a
// stable fallback.
function pickPrimary(files) {
if (!files || files.length === 0) return null
const driver = files.find(f => /^SESSION-\d{4}\.0-TRANSCRIPT/.test(f))
return driver || files[0]
}
export default function DocsSessionIndex() {
const { nnnn } = useParams()
const [title, setTitle] = useState('')
const [tldr, setTldr] = useState('')
const [files, setFiles] = useState([])
const [status, setStatus] = useState('loading') // loading | ok | notfound | error
const [reloadTick, setReloadTick] = useState(0)
// The inline body for the primary transcript.
const [body, setBody] = useState('')
const [bodyStatus, setBodyStatus] = useState('idle') // idle | loading | ok | notfound | error
useEffect(() => {
track(EVENTS.DOC_VIEWED, { section: `sessions/${nnnn}` })
}, [nnnn])
// Title from manifest — cheap, manifest is cached server-side.
// Title + optional TL;DR from the manifest — cheap, cached server-side.
useEffect(() => {
let active = true
getSessionsManifest()
.then(payload => {
if (!active) return
const entry = payload && payload[nnnn]
setTitle((entry && entry.title) || '')
const entry = (payload && payload[nnnn]) || {}
setTitle(entry.title || '')
// `tldr` is an optional manifest field (string). Absent ⇒ the
// header renders no TL;DR line (graceful degrade).
setTldr(typeof entry.tldr === 'string' ? entry.tldr : '')
})
.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.
// Title + TL;DR are decorative; the transcript list + body
// fetches below are the load-bearing ones.
})
return () => { active = false }
}, [nnnn])
@@ -66,14 +106,41 @@ export default function DocsSessionIndex() {
return () => { active = false }
}, [nnnn, reloadTick])
const primary = status === 'ok' ? pickPrimary(files) : null
// Fetch the primary transcript body once we know which file it is.
useEffect(() => {
if (!primary) {
setBody('')
setBodyStatus('idle')
return
}
let active = true
setBodyStatus('loading')
getSessionTranscript(nnnn, primary)
.then(text => {
if (!active) return
setBody(text || '')
setBodyStatus('ok')
})
.catch(e => {
if (!active) return
setBodyStatus(e.status === 404 ? 'notfound' : 'error')
})
return () => { active = false }
}, [nnnn, primary, reloadTick])
const retry = useCallback(() => setReloadTick(t => t + 1), [])
const header = title ? `${nnnn}${title}` : `Session ${nnnn}`
const siblings = primary ? files.filter(f => f !== primary) : []
return (
<article className="docs-article">
<h1 className="docs-article-title">{header}</h1>
{status === 'loading' && <p className="muted">Loading</p>}
{status === 'notfound' && (
<div className="docs-empty">
<p>
@@ -88,6 +155,7 @@ export default function DocsSessionIndex() {
</p>
</div>
)}
{status === 'error' && (
<div className="docs-error" role="alert">
<p>Couldn't reach the session-history repo.</p>
@@ -101,20 +169,77 @@ export default function DocsSessionIndex() {
</button>
</div>
)}
{status === 'ok' && files.length === 0 && (
<div className="docs-empty">
<p>This session has no transcripts published.</p>
</div>
)}
{status === 'ok' && files.length > 0 && (
<div className="docs-session-overview">
<p>
{files.length === 1
? '1 transcript in this session.'
: `${files.length} transcripts in this session.`}{' '}
Select one from the navigation on the left.
</p>
</div>
{status === 'ok' && primary && (
<>
{siblings.length > 0 && (
<nav className="docs-session-siblings" aria-label="Transcripts in this session">
<span className="docs-session-siblings-label">Transcripts</span>
<ul className="docs-session-siblings-list">
<li>
<span
className="docs-session-siblings-current"
aria-current="true"
>
{transcriptOrdinal(primary)} (shown below)
</span>
</li>
{siblings.map(f => (
<li key={f}>
<Link
to={`/docs/sessions/${nnnn}/${f}`}
aria-label={`Transcript ${transcriptOrdinal(f)}`}
data-amp-track-name="Docs Session Sibling Transcript"
data-amp-track-session={nnnn}
data-amp-track-filename={f}
>
{transcriptOrdinal(f)}
</Link>
</li>
))}
</ul>
</nav>
)}
{bodyStatus === 'loading' && <p className="muted">Loading transcript…</p>}
{bodyStatus === 'notfound' && (
<div className="docs-empty">
<p>This transcript isn't published yet.</p>
</div>
)}
{bodyStatus === 'error' && (
<div className="docs-error" role="alert">
<p>Couldn't reach the session-history repo.</p>
<button
type="button"
onClick={retry}
aria-label="Retry"
data-amp-track-name="Docs Session Inline Retry"
>
Try again
</button>
</div>
)}
{bodyStatus === 'ok' && (
<>
<TranscriptMetaHeader
nnnn={nnnn}
filename={primary}
title={title}
tldr={tldr}
/>
<div className="philosophy-body">
<MarkdownPreview content={body} />
</div>
</>
)}
</>
)}
</article>
)
@@ -1,9 +1,18 @@
// DocsSessionTranscript.jsx — v0.19.0 / roadmap item #30.
// DocsSessionTranscript.jsx — v0.21.0 (was 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.
//
// v0.21.0 / roadmap item #32:
// - A compact metadata header now sits above the rendered body
// (title, started/ended, duration, optional TL;DR, and a
// "View source on git.wiggleverse.org" external link). The parse
// + render helpers (`parseTranscriptMeta`, `TranscriptMetaHeader`,
// `gitSourceUrl`) are exported here so the session-root inline-
// collapse view (DocsSessionIndex.jsx) reuses the exact same
// rendering for the transcript(s) it inlines.
//
// Empty-state contract:
// 404 → "This transcript isn't published yet" with a link back to
// the parent session index
@@ -12,12 +21,149 @@
import { useEffect, useState, useCallback } from 'react'
import { Link, useParams } from 'react-router-dom'
import MarkdownPreview from './MarkdownPreview.jsx'
import { getSessionTranscript } from '../api.js'
import { getSessionTranscript, getSessionsManifest } from '../api.js'
import { EVENTS, track } from '../lib/analytics'
import './Docs.css'
// The canonical published-repo source URL for a transcript file, per
// SESSION-PROTOCOL.md §1's folder layout (one folder per session).
export function gitSourceUrl(nnnn, filename) {
return (
'https://git.wiggleverse.org/wiggleverse/ohm-session-history/src/branch/main/' +
`${encodeURIComponent(nnnn)}/${encodeURIComponent(filename)}`
)
}
// Extract the `.N` ordinal from a transcript filename:
// "SESSION-0014.1-TRANSCRIPT-...md" → "0014.1"
// "SESSION-0013.1.1-TRANSCRIPT-...md" → "0013.1.1" (nested subagent)
export function transcriptOrdinal(filename) {
const m = /^SESSION-(\d{4}\.\d+(?:\.\d+)*)-TRANSCRIPT/.exec(filename || '')
return m ? m[1] : filename || ''
}
// Parse the `<start>--<end>` ISO segment out of a transcript filename.
// Per the protocol the segment is `YYYY-MM-DDTHH-MM--YYYY-MM-DDTHH-MM`
// (colons replaced by dashes for filesystem portability, minute
// precision, PST implied). Legacy renamed-letter transcripts omit the
// segment entirely; in that case every derived field comes back null
// and the header degrades gracefully.
//
// Returns { ordinal, start: Date|null, end: Date|null, durationMs: number|null }.
export function parseTranscriptMeta(filename) {
const ordinal = transcriptOrdinal(filename)
const m = /-TRANSCRIPT-(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})--(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})\.md$/.exec(
filename || ''
)
if (!m) {
return { ordinal, start: null, end: null, durationMs: null }
}
const [, sDate, sH, sM, eDate, eH, eM] = m
// Parse as local wall-clock time. The filename carries no timezone
// (PST is implied per the protocol); we render the wall-clock value
// verbatim rather than shifting it, so we build a local Date and read
// it back with the same calendar fields. Duration is a difference of
// two local Dates, so the implied-timezone ambiguity cancels out.
const start = new Date(`${sDate}T${sH}:${sM}:00`)
const end = new Date(`${eDate}T${eH}:${eM}:00`)
const startOk = !Number.isNaN(start.getTime())
const endOk = !Number.isNaN(end.getTime())
const durationMs =
startOk && endOk && end.getTime() >= start.getTime()
? end.getTime() - start.getTime()
: null
return {
ordinal,
start: startOk ? start : null,
end: endOk ? end : null,
durationMs,
}
}
function fmtDateTime(d) {
if (!d) return null
// e.g. "May 28, 2026, 11:11 AM" — human-readable, wall-clock.
try {
return d.toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
} catch {
return d.toISOString()
}
}
function fmtDuration(ms) {
if (ms == null || ms <= 0) return null
const totalMin = Math.round(ms / 60000)
const h = Math.floor(totalMin / 60)
const m = totalMin % 60
if (h > 0 && m > 0) return `${h}h ${m}m`
if (h > 0) return `${h}h`
return `${m}m`
}
// The compact metadata block rendered above every transcript body.
// Shared between the standalone transcript route and the session-root
// inline-collapse view. `tldr` is optional — absent ⇒ rendered nothing
// (graceful degrade, per the manifest schema where `tldr` may be unset).
export function TranscriptMetaHeader({ nnnn, filename, title, tldr }) {
const { ordinal, start, end, durationMs } = parseTranscriptMeta(filename)
const started = fmtDateTime(start)
const ended = fmtDateTime(end)
const duration = fmtDuration(durationMs)
const heading = title ? `${ordinal}${title}` : `Session ${ordinal}`
return (
<header className="docs-transcript-meta">
<h2 className="docs-transcript-meta-title">{heading}</h2>
{(started || ended || duration) && (
<dl className="docs-transcript-meta-grid">
{started && (
<div className="docs-transcript-meta-row">
<dt>Started</dt>
<dd>{started}</dd>
</div>
)}
{ended && (
<div className="docs-transcript-meta-row">
<dt>Ended</dt>
<dd>{ended}</dd>
</div>
)}
{duration && (
<div className="docs-transcript-meta-row">
<dt>Duration</dt>
<dd>{duration}</dd>
</div>
)}
</dl>
)}
{tldr && <p className="docs-transcript-meta-tldr">{tldr}</p>}
<a
className="docs-source-link docs-transcript-meta-source"
href={gitSourceUrl(nnnn, filename)}
target="_blank"
rel="noopener noreferrer"
aria-label={`View transcript ${ordinal} source on git.wiggleverse.org`}
data-amp-track-name="Docs Transcript Source Link"
data-amp-track-session={nnnn}
data-amp-track-filename={filename}
>
View source on git.wiggleverse.org
</a>
</header>
)
}
export default function DocsSessionTranscript() {
const { nnnn, filename } = useParams()
const [body, setBody] = useState('')
const [title, setTitle] = useState('')
const [tldr, setTldr] = useState('')
const [status, setStatus] = useState('loading') // loading | ok | notfound | error
const [reloadTick, setReloadTick] = useState(0)
@@ -25,6 +171,22 @@ export default function DocsSessionTranscript() {
track(EVENTS.DOC_VIEWED, { section: `sessions/${nnnn}/${filename}` })
}, [nnnn, filename])
// Title + optional tl;dr from the manifest — decorative metadata that
// feeds the header. Failure leaves the header showing the bare NNNN
// and no TL;DR; the body fetch below is the load-bearing one.
useEffect(() => {
let active = true
getSessionsManifest()
.then(payload => {
if (!active) return
const entry = (payload && payload[nnnn]) || {}
setTitle(entry.title || '')
setTldr(typeof entry.tldr === 'string' ? entry.tldr : '')
})
.catch(() => {})
return () => { active = false }
}, [nnnn])
useEffect(() => {
let active = true
setStatus('loading')
@@ -87,9 +249,17 @@ export default function DocsSessionTranscript() {
</div>
)}
{status === 'ok' && (
<div className="philosophy-body">
<MarkdownPreview content={body} />
</div>
<>
<TranscriptMetaHeader
nnnn={nnnn}
filename={filename}
title={title}
tldr={tldr}
/>
<div className="philosophy-body">
<MarkdownPreview content={body} />
</div>
</>
)}
</article>
)
+46 -10
View File
@@ -7,15 +7,21 @@
// `MarkdownPreview` (the same component the `/philosophy` route uses,
// so we don't introduce a second markdown library).
import { useEffect, useState } from 'react'
// v0.21.0 / roadmap item #31: the loading + error states are brought
// onto the same `.docs-empty` / `.docs-error` convention every other
// docs surface uses (was a bare `<p className="error">`), with a retry
// button so a transient `/api/docs` failure isn't a dead end.
import { useEffect, useState, useCallback } from 'react'
import { Link } from 'react-router-dom'
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)
const [status, setStatus] = useState('loading') // loading | ok | error
const [reloadTick, setReloadTick] = useState(0)
useEffect(() => {
track(EVENTS.DOC_VIEWED, { section: 'user-guide' })
@@ -23,19 +29,49 @@ export default function DocsUserGuide() {
useEffect(() => {
let active = true
setStatus('loading')
getDocs()
.then(r => { if (active) setBody(r.body || '') })
.catch(e => { if (active) setError(e.message || String(e)) })
.finally(() => { if (active) setLoading(false) })
.then(r => {
if (!active) return
setBody(r.body || '')
setStatus('ok')
})
.catch(() => {
if (!active) return
setStatus('error')
})
return () => { active = false }
}, [])
}, [reloadTick])
const retry = useCallback(() => setReloadTick(t => t + 1), [])
return (
<article className="docs-article">
<h1 className="docs-article-title">User guide</h1>
{loading && <p className="muted">Loading</p>}
{error && <p className="error">Could not load the guide: {error}</p>}
{!loading && !error && (
{status === 'loading' && <p className="muted">Loading</p>}
{status === 'error' && (
<div className="docs-error" role="alert">
<p>Couldn't load the user guide.</p>
<button
type="button"
onClick={retry}
aria-label="Retry"
data-amp-track-name="Docs User Guide Retry"
>
Try again
</button>
<p>
<Link
to="/docs/sessions/about"
aria-label="About sessions"
data-amp-track-name="Docs User Guide Error About Link"
>
About sessions
</Link>
</p>
</div>
)}
{status === 'ok' && (
<div className="philosophy-body">
<MarkdownPreview content={body} />
</div>
+128
View File
@@ -0,0 +1,128 @@
/* Inbox.css §15.2 inbox panel refinements (roadmap #25, light pass).
*
* The base inbox layout/structure lives in App.css (the §15 / Slice 6
* block). This sheet is a TOKENIZED polish layer on top of it: it does
* NOT re-lay-out the panel, it sharpens the unread/read distinction,
* adds the per-row "mark read" affordance + the unread dot, and gives
* the empty/loading states real copy and spacing.
*
* Cascade note: Inbox.jsx is imported by App.jsx (line 6) BEFORE the
* App.css import (line 30), so under ESM depth-first evaluation this
* sheet is injected FIRST and App.css wins on equal specificity. Any
* rule here that must override an App.css value is therefore written
* one notch more specific (e.g. `.inbox-list .inbox-row.unread`).
* New classes that App.css doesn't define need no such guard.
*/
/* ===== Unread vs. read distinction ===== */
/* A clear left accent bar + warmer tint on unread; read rows sit calm. */
.inbox-list .inbox-row {
position: relative;
border-bottom: 1px solid var(--color-border);
transition: background var(--motion-fast) var(--ease-out);
}
.inbox-list .inbox-row.unread {
background: var(--color-warning-bg-soft, var(--c-warning-bg-soft));
box-shadow: inset 3px 0 0 var(--color-accent);
}
.inbox-list .inbox-row.read .inbox-summary {
color: var(--color-text-muted);
font-weight: var(--weight-normal);
}
.inbox-list .inbox-row.unread .inbox-summary {
color: var(--color-text);
font-weight: var(--weight-medium);
}
/* The dot is a NEW affordance: a filled accent dot for unread, hidden
* (but space-reserved) for read so summaries stay column-aligned. */
.inbox-unread-dot {
flex: 0 0 auto;
width: 8px;
height: 8px;
border-radius: var(--radius-pill);
background: var(--color-accent);
}
.inbox-row.read .inbox-unread-dot {
background: transparent;
}
/* ===== Per-row "mark read" affordance ===== */
/* The row is a flex Link followed by this button; pin the button to the
* right edge, revealed on row hover/focus and always visible on touch. */
.inbox-row {
display: flex;
align-items: center;
}
.inbox-row .inbox-row-link {
flex: 1 1 auto;
min-width: 0;
}
.inbox-row-dismiss {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
margin-right: var(--space-5);
padding: 0;
color: var(--color-text-subtle);
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-md);
cursor: pointer;
opacity: 0;
transition:
opacity var(--motion-fast) var(--ease-out),
color var(--motion-fast) var(--ease-out),
background var(--motion-fast) var(--ease-out),
border-color var(--motion-fast) var(--ease-out);
}
.inbox-row:hover .inbox-row-dismiss,
.inbox-row:focus-within .inbox-row-dismiss,
.inbox-row-dismiss:focus-visible {
opacity: 1;
}
.inbox-row-dismiss:hover {
color: var(--color-success-fg);
background: var(--color-success-bg);
border-color: var(--color-success-bg);
}
.inbox-row-dismiss:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: 1px;
}
/* Coarse pointers (touch) have no hover; keep the affordance discoverable. */
@media (hover: none) {
.inbox-row-dismiss { opacity: 1; }
}
/* ===== Mark-all-read button ===== */
.inbox-mark-all {
margin-left: auto;
}
/* ===== Empty / loading states ===== */
.inbox-state {
padding: var(--space-9) var(--space-7);
text-align: center;
}
.inbox-empty {
padding: var(--space-11) var(--space-7);
text-align: center;
}
.inbox-empty-title {
margin: 0 0 var(--space-3);
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--color-text-strong);
}
.inbox-empty .muted {
margin: 0;
font-size: var(--text-base);
line-height: var(--leading-normal);
color: var(--color-text-muted);
}
+56 -11
View File
@@ -15,6 +15,7 @@ import {
markNotificationRead,
markNotificationsReadByFilter,
} from '../api.js'
import './Inbox.css'
const CATEGORIES = [
{ value: '', label: 'All categories' },
@@ -56,11 +57,15 @@ export default function Inbox({ onClose, lastChangeTick }) {
return Array.from(seen.entries())
}, [items])
async function markOneRead(item) {
if (item.read_at) return
await markNotificationRead(item.id)
setItems(prev => prev.map(p => p.id === item.id ? { ...p, read_at: new Date().toISOString() } : p))
setUnreadCount(c => Math.max(0, c - 1))
}
async function handleRowClick(item) {
if (!item.read_at) {
await markNotificationRead(item.id)
setItems(prev => prev.map(p => p.id === item.id ? { ...p, read_at: new Date().toISOString() } : p))
}
await markOneRead(item)
}
async function markAllUnderFilter() {
@@ -121,22 +126,36 @@ export default function Inbox({ onClose, lastChangeTick }) {
</label>
<button
className="btn-link"
className="btn-link inbox-mark-all"
onClick={markAllUnderFilter}
disabled={items.every(i => i.read_at)}
title="Mark every notification matching the current filter as read"
>
Mark all read (under filter)
Mark all read
</button>
</div>
<div className="inbox-body">
{loading && <p className="muted">Loading</p>}
{loading && <p className="inbox-state muted">Loading your inbox</p>}
{!loading && items.length === 0 && (
<p className="muted">No notifications match. Try a different filter, or come back later.</p>
<div className="inbox-empty">
<p className="inbox-empty-title">You're all caught up.</p>
<p className="muted">
{filters.unread || filters.rfcSlug || filters.category
? 'Nothing matches the current filters. Clear them to see everything.'
: 'New activity on RFCs you follow will show up here.'}
</p>
</div>
)}
<ul className="inbox-list">
{items.map(item => (
<InboxRow key={item.id} item={item} onClick={handleRowClick} onClose={onClose} />
<InboxRow
key={item.id}
item={item}
onClick={handleRowClick}
onMarkRead={markOneRead}
onClose={onClose}
/>
))}
</ul>
</div>
@@ -145,16 +164,24 @@ export default function Inbox({ onClose, lastChangeTick }) {
)
}
function InboxRow({ item, onClick, onClose }) {
function InboxRow({ item, onClick, onMarkRead, onClose }) {
const unread = !item.read_at
const target = deepLink(item)
const handle = async () => {
await onClick(item)
if (target) onClose?.()
}
const handleMarkRead = async (e) => {
// Don't let the row's Link fire this affordance only marks read,
// it never navigates.
e.preventDefault()
e.stopPropagation()
await onMarkRead(item)
}
return (
<li className={`inbox-row ${unread ? 'unread' : ''}`}>
<li className={`inbox-row ${unread ? 'unread' : 'read'}`}>
<Link to={target || '#'} onClick={handle} className="inbox-row-link">
<span className="inbox-unread-dot" aria-hidden />
<span className={`inbox-cat cat-${item.category || 'unknown'}`}>{item.category || '·'}</span>
<span className="inbox-summary">{item.summary}</span>
{item.bundled_count > 1 && (
@@ -162,6 +189,24 @@ function InboxRow({ item, onClick, onClose }) {
)}
<span className="inbox-when">{formatWhen(item.created_at)}</span>
</Link>
{unread && (
<button
type="button"
className="inbox-row-dismiss"
onClick={handleMarkRead}
aria-label="Mark as read"
title="Mark as read"
>
{/* check glyph — dependency-free inline SVG */}
<svg
width="14" height="14" viewBox="0 0 24 24"
fill="none" stroke="currentColor" strokeWidth="2.25"
strokeLinecap="round" strokeLinejoin="round" aria-hidden
>
<path d="m5 13 4 4 10-11" />
</svg>
</button>
)}
</li>
)
}
+3 -4
View File
@@ -1,8 +1,7 @@
:root {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, sans-serif;
color: #1a1a1a;
background: #fafaf8;
font-family: var(--font-sans);
color: var(--color-text);
background: var(--color-bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+1
View File
@@ -2,6 +2,7 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import './styles/tokens.css'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
+162
View File
@@ -0,0 +1,162 @@
/* tokens.css the design-token foundation for rfc-app's UI.
*
* Roadmap item #31 (comprehensive UX polish). Before this file the app
* had ~98 distinct hardcoded hex colors, font sizes scattered across 16
* values with no scale, and radii across 13 values classic prototype
* sprawl. This module establishes ONE coherent system; the App.css sweep
* (and component-scoped CSS) reference these custom properties instead of
* literal values, so "what color/size/space is this" has a single answer.
*
* Imported FIRST in main.jsx so :root is defined before any other sheet.
* Custom properties are not cascade-order-sensitive at use time, but
* importing first keeps the dependency obvious.
*
* Conventions for anyone sweeping values to these tokens:
* - Map each literal to the NEAREST semantic token, then fall back to a
* primitive ramp step. Consolidating near-duplicate grays is the point.
* - Never invent a new literal in a component; add a token here instead.
* - Spacing/radii/type use the scales below no off-scale px values.
*/
:root {
/* ===== Color primitives — neutral ramp ===== */
--c-white: #ffffff;
--c-gray-50: #fafafa;
--c-gray-100: #f3f4f6;
--c-gray-150: #f0f0ee; /* the app's warm canvas tint */
--c-gray-200: #e5e7eb;
--c-gray-300: #d1d5db;
--c-gray-400: #9ca3af;
--c-gray-500: #6b7280;
--c-gray-600: #4b5563;
--c-gray-700: #374151;
--c-gray-800: #1f2937;
--c-gray-900: #111111;
--c-ink: #1a1a1a; /* near-black used for the header + body text */
/* ===== Color primitives — accent (indigo/violet) ===== */
--c-accent: #5b5bd6;
--c-accent-strong: #4338ca;
--c-violet: #7c3aed;
/* ===== Color primitives — status ===== */
--c-success-fg: #166534;
--c-success-bg: #dcfce7;
--c-danger-fg: #991b1b;
--c-danger-fg-strong: #b91c1c;
--c-danger-bg: #fef2f2;
--c-danger-border: #fecaca;
--c-warning-fg: #92400e;
--c-warning-accent: #b45309;
--c-warning-bg: #fef3c7;
--c-warning-bg-soft:#fffbeb;
/* ===== Semantic colors ===== */
--color-bg: var(--c-gray-150);
--color-surface: var(--c-white);
--color-surface-sunken: var(--c-gray-50);
--color-surface-muted: var(--c-gray-100);
--color-header-bg: var(--c-ink);
--color-text: var(--c-ink);
--color-text-strong: var(--c-gray-900);
--color-text-muted: var(--c-gray-500);
--color-text-subtle: var(--c-gray-400);
--color-text-inverse: var(--c-white);
--color-border: var(--c-gray-200);
--color-border-strong: var(--c-gray-300);
--color-link: var(--c-accent);
--color-accent: var(--c-accent);
--color-accent-strong: var(--c-accent-strong);
--color-accent-contrast: var(--c-white);
--color-success-fg: var(--c-success-fg);
--color-success-bg: var(--c-success-bg);
--color-danger-fg: var(--c-danger-fg);
--color-danger-bg: var(--c-danger-bg);
--color-warning-fg: var(--c-warning-fg);
--color-warning-bg: var(--c-warning-bg);
/* On the dark header, translucent white is the established pattern. */
--color-on-dark-soft: rgba(255, 255, 255, 0.15);
--color-on-dark-hover: rgba(255, 255, 255, 0.25);
--color-on-dark-muted: #dddddd;
--color-focus-ring: rgba(91, 91, 214, 0.45);
/* ===== Type ===== */
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
monospace;
--text-2xs: 10px;
--text-xs: 11px;
--text-sm: 12px;
--text-base: 13px; /* the app's dominant body size */
--text-md: 14px;
--text-lg: 16px;
--text-xl: 18px;
--text-2xl: 22px;
--text-3xl: 28px;
--leading-tight: 1.25;
--leading-normal: 1.5;
--leading-relaxed: 1.65;
--weight-normal: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
/* ===== Spacing scale (4-based, with the 2/6/10 half-steps the app
* already leans on heavily) ===== */
--space-0: 0;
--space-1: 2px;
--space-2: 4px;
--space-3: 6px;
--space-4: 8px;
--space-5: 10px;
--space-6: 12px;
--space-7: 16px;
--space-8: 20px;
--space-9: 24px;
--space-10: 32px;
--space-11: 48px;
--space-12: 64px;
/* ===== Radius ===== */
--radius-xs: 2px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-pill: 999px;
/* ===== Elevation ===== */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
/* ===== Motion ===== */
--motion-fast: 120ms;
--motion-base: 150ms;
--motion-slow: 200ms;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
/* ===== Layout ===== */
--header-height: 48px;
}
/* Honor reduced-motion globally any transition/animation that reads
* these duration tokens collapses to instant. */
@media (prefers-reduced-motion: reduce) {
:root {
--motion-fast: 0ms;
--motion-base: 0ms;
--motion-slow: 0ms;
}
}