Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ac20b1621 | |||
| 493d6b6eee | |||
| 959fc906de | |||
| b648b3ed45 | |||
| 54736de91c | |||
| adb5d25715 | |||
| cbf02d5507 | |||
| 317738ed79 | |||
| 5be2c48afe |
@@ -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
|
release's steps in order — no A-to-B path is pre-computed beyond
|
||||||
that.
|
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
|
## 0.20.0 — 2026-05-28
|
||||||
|
|
||||||
Wave 9 follow-up to roadmap item #30. Three changes bundled into one minor:
|
Wave 9 follow-up to roadmap item #30. Three changes bundled into one minor:
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"version": "0.20.0",
|
"version": "0.21.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"version": "0.20.0",
|
"version": "0.21.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@amplitude/unified": "^1.1.9",
|
"@amplitude/unified": "^1.1.9",
|
||||||
"@codemirror/commands": "^6.10.3",
|
"@codemirror/commands": "^6.10.3",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.20.0",
|
"version": "0.21.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+793
-738
File diff suppressed because it is too large
Load Diff
+11
-3
@@ -169,7 +169,7 @@ export default function App() {
|
|||||||
wonders why a conversation is public can reach the answer
|
wonders why a conversation is public can reach the answer
|
||||||
in two clicks. Anonymous viewers see it too. */}
|
in two clicks. Anonymous viewers see it too. */}
|
||||||
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
|
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
|
||||||
About
|
Philosophy
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/docs" className="header-about" title="User guide">
|
<Link to="/docs" className="header-about" title="User guide">
|
||||||
Docs
|
Docs
|
||||||
@@ -188,9 +188,17 @@ export default function App() {
|
|||||||
<button
|
<button
|
||||||
className="inbox-trigger"
|
className="inbox-trigger"
|
||||||
onClick={() => setInboxOpen(o => !o)}
|
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 && (
|
{unreadCount > 0 && (
|
||||||
<span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
|
<span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
// Per-session landing at `/docs/sessions/:nnnn`. v0.20.0 rendered a
|
||||||
// transcripts as body links; v0.20.0 drops the body list — navigation
|
// dead-end "N transcript(s) in this session. Select one from the
|
||||||
// is via the left flyout nav (which renders each session's transcripts
|
// navigation." placeholder. v0.21.0 / roadmap item #32 collapses that:
|
||||||
// nested under the session row). The body now serves as a
|
// the session root now renders a transcript INLINE so the URL is never
|
||||||
// session-overview card with the title, file count, and a hint to
|
// an empty stop.
|
||||||
// pick a transcript from the nav.
|
|
||||||
//
|
//
|
||||||
// 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
|
// so the empty-state ("no transcripts yet"), not-found, and error
|
||||||
// paths remain meaningful — the page still does something useful when
|
// paths remain meaningful when the upstream is mid-publish or
|
||||||
// the upstream is mid-publish or unreachable.
|
// 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 { useEffect, useState, useCallback } from 'react'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
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 { 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() {
|
export default function DocsSessionIndex() {
|
||||||
const { nnnn } = useParams()
|
const { nnnn } = useParams()
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
|
const [tldr, setTldr] = useState('')
|
||||||
const [files, setFiles] = useState([])
|
const [files, setFiles] = useState([])
|
||||||
const [status, setStatus] = useState('loading') // loading | ok | notfound | error
|
const [status, setStatus] = useState('loading') // loading | ok | notfound | error
|
||||||
const [reloadTick, setReloadTick] = useState(0)
|
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(() => {
|
useEffect(() => {
|
||||||
track(EVENTS.DOC_VIEWED, { section: `sessions/${nnnn}` })
|
track(EVENTS.DOC_VIEWED, { section: `sessions/${nnnn}` })
|
||||||
}, [nnnn])
|
}, [nnnn])
|
||||||
|
|
||||||
// Title from manifest — cheap, manifest is cached server-side.
|
// Title + optional TL;DR from the manifest — cheap, cached server-side.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
getSessionsManifest()
|
getSessionsManifest()
|
||||||
.then(payload => {
|
.then(payload => {
|
||||||
if (!active) return
|
if (!active) return
|
||||||
const entry = payload && payload[nnnn]
|
const entry = (payload && payload[nnnn]) || {}
|
||||||
setTitle((entry && entry.title) || '')
|
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(() => {
|
.catch(() => {
|
||||||
// Title is decorative; failure to load just leaves the header
|
// Title + TL;DR are decorative; the transcript list + body
|
||||||
// showing the bare NNNN. The transcript list fetch below is
|
// fetches below are the load-bearing ones.
|
||||||
// the load-bearing one.
|
|
||||||
})
|
})
|
||||||
return () => { active = false }
|
return () => { active = false }
|
||||||
}, [nnnn])
|
}, [nnnn])
|
||||||
@@ -66,14 +106,41 @@ export default function DocsSessionIndex() {
|
|||||||
return () => { active = false }
|
return () => { active = false }
|
||||||
}, [nnnn, reloadTick])
|
}, [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 retry = useCallback(() => setReloadTick(t => t + 1), [])
|
||||||
|
|
||||||
const header = title ? `${nnnn} — ${title}` : `Session ${nnnn}`
|
const header = title ? `${nnnn} — ${title}` : `Session ${nnnn}`
|
||||||
|
const siblings = primary ? files.filter(f => f !== primary) : []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="docs-article">
|
<article className="docs-article">
|
||||||
<h1 className="docs-article-title">{header}</h1>
|
<h1 className="docs-article-title">{header}</h1>
|
||||||
|
|
||||||
{status === 'loading' && <p className="muted">Loading…</p>}
|
{status === 'loading' && <p className="muted">Loading…</p>}
|
||||||
|
|
||||||
{status === 'notfound' && (
|
{status === 'notfound' && (
|
||||||
<div className="docs-empty">
|
<div className="docs-empty">
|
||||||
<p>
|
<p>
|
||||||
@@ -88,6 +155,7 @@ export default function DocsSessionIndex() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === 'error' && (
|
{status === 'error' && (
|
||||||
<div className="docs-error" role="alert">
|
<div className="docs-error" role="alert">
|
||||||
<p>Couldn't reach the session-history repo.</p>
|
<p>Couldn't reach the session-history repo.</p>
|
||||||
@@ -101,20 +169,77 @@ export default function DocsSessionIndex() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === 'ok' && files.length === 0 && (
|
{status === 'ok' && files.length === 0 && (
|
||||||
<div className="docs-empty">
|
<div className="docs-empty">
|
||||||
<p>This session has no transcripts published.</p>
|
<p>This session has no transcripts published.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{status === 'ok' && files.length > 0 && (
|
|
||||||
<div className="docs-session-overview">
|
{status === 'ok' && primary && (
|
||||||
<p>
|
<>
|
||||||
{files.length === 1
|
{siblings.length > 0 && (
|
||||||
? '1 transcript in this session.'
|
<nav className="docs-session-siblings" aria-label="Transcripts in this session">
|
||||||
: `${files.length} transcripts in this session.`}{' '}
|
<span className="docs-session-siblings-label">Transcripts</span>
|
||||||
Select one from the navigation on the left.
|
<ul className="docs-session-siblings-list">
|
||||||
</p>
|
<li>
|
||||||
</div>
|
<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>
|
</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
|
// Per-transcript view at `/docs/sessions/:nnnn/:filename`. Fetches the
|
||||||
// transcript body via the backend mediator and renders it through the
|
// transcript body via the backend mediator and renders it through the
|
||||||
// shared MarkdownPreview.
|
// 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:
|
// Empty-state contract:
|
||||||
// 404 → "This transcript isn't published yet" with a link back to
|
// 404 → "This transcript isn't published yet" with a link back to
|
||||||
// the parent session index
|
// the parent session index
|
||||||
@@ -12,12 +21,149 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import MarkdownPreview from './MarkdownPreview.jsx'
|
import MarkdownPreview from './MarkdownPreview.jsx'
|
||||||
import { getSessionTranscript } from '../api.js'
|
import { getSessionTranscript, getSessionsManifest } from '../api.js'
|
||||||
import { EVENTS, track } from '../lib/analytics'
|
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() {
|
export default function DocsSessionTranscript() {
|
||||||
const { nnnn, filename } = useParams()
|
const { nnnn, filename } = useParams()
|
||||||
const [body, setBody] = useState('')
|
const [body, setBody] = useState('')
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [tldr, setTldr] = useState('')
|
||||||
const [status, setStatus] = useState('loading') // loading | ok | notfound | error
|
const [status, setStatus] = useState('loading') // loading | ok | notfound | error
|
||||||
const [reloadTick, setReloadTick] = useState(0)
|
const [reloadTick, setReloadTick] = useState(0)
|
||||||
|
|
||||||
@@ -25,6 +171,22 @@ export default function DocsSessionTranscript() {
|
|||||||
track(EVENTS.DOC_VIEWED, { section: `sessions/${nnnn}/${filename}` })
|
track(EVENTS.DOC_VIEWED, { section: `sessions/${nnnn}/${filename}` })
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
setStatus('loading')
|
setStatus('loading')
|
||||||
@@ -87,9 +249,17 @@ export default function DocsSessionTranscript() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{status === 'ok' && (
|
{status === 'ok' && (
|
||||||
<div className="philosophy-body">
|
<>
|
||||||
<MarkdownPreview content={body} />
|
<TranscriptMetaHeader
|
||||||
</div>
|
nnnn={nnnn}
|
||||||
|
filename={filename}
|
||||||
|
title={title}
|
||||||
|
tldr={tldr}
|
||||||
|
/>
|
||||||
|
<div className="philosophy-body">
|
||||||
|
<MarkdownPreview content={body} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,15 +7,21 @@
|
|||||||
// `MarkdownPreview` (the same component the `/philosophy` route uses,
|
// `MarkdownPreview` (the same component the `/philosophy` route uses,
|
||||||
// so we don't introduce a second markdown library).
|
// 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 MarkdownPreview from './MarkdownPreview.jsx'
|
||||||
import { getDocs } from '../api.js'
|
import { getDocs } from '../api.js'
|
||||||
import { EVENTS, track } from '../lib/analytics'
|
import { EVENTS, track } from '../lib/analytics'
|
||||||
|
|
||||||
export default function DocsUserGuide() {
|
export default function DocsUserGuide() {
|
||||||
const [body, setBody] = useState('')
|
const [body, setBody] = useState('')
|
||||||
const [error, setError] = useState(null)
|
const [status, setStatus] = useState('loading') // loading | ok | error
|
||||||
const [loading, setLoading] = useState(true)
|
const [reloadTick, setReloadTick] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
track(EVENTS.DOC_VIEWED, { section: 'user-guide' })
|
track(EVENTS.DOC_VIEWED, { section: 'user-guide' })
|
||||||
@@ -23,19 +29,49 @@ export default function DocsUserGuide() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
|
setStatus('loading')
|
||||||
getDocs()
|
getDocs()
|
||||||
.then(r => { if (active) setBody(r.body || '') })
|
.then(r => {
|
||||||
.catch(e => { if (active) setError(e.message || String(e)) })
|
if (!active) return
|
||||||
.finally(() => { if (active) setLoading(false) })
|
setBody(r.body || '')
|
||||||
|
setStatus('ok')
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!active) return
|
||||||
|
setStatus('error')
|
||||||
|
})
|
||||||
return () => { active = false }
|
return () => { active = false }
|
||||||
}, [])
|
}, [reloadTick])
|
||||||
|
|
||||||
|
const retry = useCallback(() => setReloadTick(t => t + 1), [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="docs-article">
|
<article className="docs-article">
|
||||||
<h1 className="docs-article-title">User guide</h1>
|
<h1 className="docs-article-title">User guide</h1>
|
||||||
{loading && <p className="muted">Loading…</p>}
|
{status === 'loading' && <p className="muted">Loading…</p>}
|
||||||
{error && <p className="error">Could not load the guide: {error}</p>}
|
{status === 'error' && (
|
||||||
{!loading && !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">
|
<div className="philosophy-body">
|
||||||
<MarkdownPreview content={body} />
|
<MarkdownPreview content={body} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
markNotificationRead,
|
markNotificationRead,
|
||||||
markNotificationsReadByFilter,
|
markNotificationsReadByFilter,
|
||||||
} from '../api.js'
|
} from '../api.js'
|
||||||
|
import './Inbox.css'
|
||||||
|
|
||||||
const CATEGORIES = [
|
const CATEGORIES = [
|
||||||
{ value: '', label: 'All categories' },
|
{ value: '', label: 'All categories' },
|
||||||
@@ -56,11 +57,15 @@ export default function Inbox({ onClose, lastChangeTick }) {
|
|||||||
return Array.from(seen.entries())
|
return Array.from(seen.entries())
|
||||||
}, [items])
|
}, [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) {
|
async function handleRowClick(item) {
|
||||||
if (!item.read_at) {
|
await markOneRead(item)
|
||||||
await markNotificationRead(item.id)
|
|
||||||
setItems(prev => prev.map(p => p.id === item.id ? { ...p, read_at: new Date().toISOString() } : p))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markAllUnderFilter() {
|
async function markAllUnderFilter() {
|
||||||
@@ -121,22 +126,36 @@ export default function Inbox({ onClose, lastChangeTick }) {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="btn-link"
|
className="btn-link inbox-mark-all"
|
||||||
onClick={markAllUnderFilter}
|
onClick={markAllUnderFilter}
|
||||||
disabled={items.every(i => i.read_at)}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="inbox-body">
|
<div className="inbox-body">
|
||||||
{loading && <p className="muted">Loading…</p>}
|
{loading && <p className="inbox-state muted">Loading your inbox…</p>}
|
||||||
{!loading && items.length === 0 && (
|
{!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">
|
<ul className="inbox-list">
|
||||||
{items.map(item => (
|
{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>
|
</ul>
|
||||||
</div>
|
</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 unread = !item.read_at
|
||||||
const target = deepLink(item)
|
const target = deepLink(item)
|
||||||
const handle = async () => {
|
const handle = async () => {
|
||||||
await onClick(item)
|
await onClick(item)
|
||||||
if (target) onClose?.()
|
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 (
|
return (
|
||||||
<li className={`inbox-row ${unread ? 'unread' : ''}`}>
|
<li className={`inbox-row ${unread ? 'unread' : 'read'}`}>
|
||||||
<Link to={target || '#'} onClick={handle} className="inbox-row-link">
|
<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-cat cat-${item.category || 'unknown'}`}>{item.category || '·'}</span>
|
||||||
<span className="inbox-summary">{item.summary}</span>
|
<span className="inbox-summary">{item.summary}</span>
|
||||||
{item.bundled_count > 1 && (
|
{item.bundled_count > 1 && (
|
||||||
@@ -162,6 +189,24 @@ function InboxRow({ item, onClick, onClose }) {
|
|||||||
)}
|
)}
|
||||||
<span className="inbox-when">{formatWhen(item.created_at)}</span>
|
<span className="inbox-when">{formatWhen(item.created_at)}</span>
|
||||||
</Link>
|
</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>
|
</li>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
:root {
|
:root {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
font-family: var(--font-sans);
|
||||||
Arial, sans-serif;
|
color: var(--color-text);
|
||||||
color: #1a1a1a;
|
background: var(--color-bg);
|
||||||
background: #fafaf8;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react'
|
|||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
|
import './styles/tokens.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user