Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d8371dea1 | |||
| 3a51425ec7 | |||
| 7c6c906db2 | |||
| 2ac20b1621 | |||
| 493d6b6eee | |||
| 959fc906de | |||
| b648b3ed45 | |||
| 54736de91c | |||
| adb5d25715 | |||
| cbf02d5507 | |||
| 317738ed79 | |||
| 5be2c48afe |
+109
@@ -23,6 +23,115 @@ 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.22.0 — 2026-05-28
|
||||
|
||||
Roadmap item #26: an optional **"What will you be using this for?"**
|
||||
field on both propose surfaces. Shipped from driver session 0022.0.
|
||||
Additive and backward-compatible — no behavior changes for anyone who
|
||||
leaves the field blank.
|
||||
|
||||
1. **Propose-RFC modal.** Below the required "Why is this RFC needed?"
|
||||
field (`pitch`) sits a new optional **"What will you be using this
|
||||
RFC for?"** textarea. "Needed" is the abstract justification; "using
|
||||
it for" is the concrete ground-truth use case — captured without
|
||||
being forced.
|
||||
2. **Propose-PR modal.** Below the required "Why is this change needed?"
|
||||
field (`description`) sits a new optional **"What will you be using
|
||||
this change for?"** textarea.
|
||||
3. **Display.** The RFC view (`RFCView` / `ProposalView`) and PR review
|
||||
view (`PRView`) render the captured use case alongside the existing
|
||||
"why" text, with a muted "left blank" treatment when absent.
|
||||
4. **Persistence (deployment note).** In this deployment the framework's
|
||||
`rfcs` / PR surfaces are the Gitea-backed cache tables `cached_rfcs`
|
||||
/ `cached_prs`, rebuilt by the reconciler. Because the propose write
|
||||
path is endpoint → Gitea → reconcile (and the reconciler doesn't
|
||||
carry the new field), the durable home is a canonical, reconcile-proof
|
||||
side table `proposed_use_cases` (keyed by `(scope, pr_number)`) that
|
||||
the propose/open endpoints write directly and the view endpoints read
|
||||
back. The literal nullable `proposed_use_case` columns named by the
|
||||
roadmap are also added to `cached_rfcs` / `cached_prs` for parity and
|
||||
any future reconciler that learns to carry the field. NULL/blank use
|
||||
cases simply never write a side-table row — absence is "left blank".
|
||||
|
||||
Migration `021_proposed_use_case.sql` adds the two cache columns
|
||||
(`ALTER TABLE … ADD COLUMN proposed_use_case TEXT`), the
|
||||
`proposed_use_cases` canonical table, and its lookup indexes. The
|
||||
reconciler's upsert (`ON CONFLICT DO UPDATE`) sets only known columns,
|
||||
so the added cache columns survive reconciles. Backend validators accept
|
||||
the field as NULL/omitted (no required-validation) with an 8000-char cap
|
||||
matching the existing PR `description` bound; blank/whitespace is treated
|
||||
as absent.
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
1. Deployments **MUST** apply database migrations; `021_proposed_use_case.sql`
|
||||
runs automatically on next boot (the migration runner globs
|
||||
`backend/migrations/*.sql` and applies any not yet recorded in
|
||||
`schema_migrations`). The migration is additive — nullable cache
|
||||
columns plus a new table — and requires no data backfill.
|
||||
2. No new environment variables, overlay keys, or secrets. No operator
|
||||
action beyond the standard `flotilla deploy ohm-rfc-app`.
|
||||
|
||||
## 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:
|
||||
|
||||
+52
-1
@@ -51,6 +51,11 @@ class ProposeBody(BaseModel):
|
||||
slug: str = Field(min_length=1, max_length=80)
|
||||
pitch: str = Field(min_length=1)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
# Roadmap #26: optional "What will you be using this RFC for?" — the
|
||||
# concrete ground-truth use case, distinct from the `pitch`'s abstract
|
||||
# "why is this needed." Optional (NULL/omitted accepted), no minimum,
|
||||
# generous cap matching the pitch's free-text body bound.
|
||||
proposed_use_case: str | None = Field(default=None, max_length=8000)
|
||||
|
||||
|
||||
class DeclineBody(BaseModel):
|
||||
@@ -557,12 +562,36 @@ def make_router(
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Not found")
|
||||
return _serialize_rfc(row)
|
||||
payload = _serialize_rfc(row)
|
||||
# Roadmap #26: surface the optional propose-time use case on the
|
||||
# RFC view. The idea PR closes on merge, but the canonical row in
|
||||
# `proposed_use_cases` persists; look it up by slug (the latest
|
||||
# 'rfc'-scope row for this slug). NULL == "left blank".
|
||||
uc = db.conn().execute(
|
||||
"""
|
||||
SELECT use_case FROM proposed_use_cases
|
||||
WHERE scope = 'rfc' AND rfc_slug = ?
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
payload["proposed_use_case"] = uc["use_case"] if uc else None
|
||||
return payload
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §7.3 / §9.3: pending ideas
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def _proposal_use_case(pr_number: int) -> str | None:
|
||||
"""Roadmap #26: read the optional use case for an idea PR from the
|
||||
canonical side table. Returns None when none was supplied (the
|
||||
"left blank" sentinel the frontend renders tastefully)."""
|
||||
row = db.conn().execute(
|
||||
"SELECT use_case FROM proposed_use_cases WHERE scope = 'rfc' AND pr_number = ?",
|
||||
(pr_number,),
|
||||
).fetchone()
|
||||
return row["use_case"] if row else None
|
||||
|
||||
@router.get("/api/proposals")
|
||||
async def list_proposals() -> dict[str, Any]:
|
||||
rows = db.conn().execute(
|
||||
@@ -582,6 +611,7 @@ def make_router(
|
||||
"description": r["description"],
|
||||
"opened_by": r["opened_by"],
|
||||
"opened_at": r["opened_at"],
|
||||
"proposed_use_case": _proposal_use_case(r["pr_number"]),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -630,6 +660,7 @@ def make_router(
|
||||
"opened_at": row["opened_at"],
|
||||
"entry": entry_payload,
|
||||
"affordances": affordances,
|
||||
"proposed_use_case": _proposal_use_case(pr_number),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
@@ -706,6 +737,26 @@ def make_router(
|
||||
# cache write is idempotent.)
|
||||
await cache.refresh_meta_pulls(config, gitea)
|
||||
|
||||
# Roadmap #26: persist the optional use case to the canonical,
|
||||
# reconcile-proof side table keyed by the idea PR number. NULL/
|
||||
# blank simply writes no row (absence == "left blank"). Done after
|
||||
# the refresh so the cache row exists; the mirror onto cached_prs
|
||||
# keeps the cache column in parity for any read that uses it.
|
||||
use_case = (payload.proposed_use_case or "").strip()
|
||||
if use_case:
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case)
|
||||
VALUES ('rfc', ?, ?, ?)
|
||||
ON CONFLICT(scope, pr_number) DO UPDATE SET use_case = excluded.use_case
|
||||
""",
|
||||
(slug, pr["number"], use_case),
|
||||
)
|
||||
db.conn().execute(
|
||||
"UPDATE cached_prs SET proposed_use_case = ? WHERE pr_kind = 'idea' AND pr_number = ?",
|
||||
(use_case, pr["number"]),
|
||||
)
|
||||
|
||||
return {"pr_number": pr["number"], "slug": slug}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@@ -42,6 +42,11 @@ RFC_FILE_PATH = "RFC.md"
|
||||
class OpenPRBody(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=240)
|
||||
description: str = Field(max_length=8000)
|
||||
# Roadmap #26: optional "What will you be using this change for?" —
|
||||
# the concrete ground-truth use case sibling to the required
|
||||
# "why is this change needed" (the `description`). Optional, generous
|
||||
# cap matching the description bound.
|
||||
proposed_use_case: str | None = Field(default=None, max_length=8000)
|
||||
|
||||
|
||||
class PRDescriptionBody(BaseModel):
|
||||
@@ -173,6 +178,26 @@ def make_router(
|
||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||
|
||||
await _refresh_after_pr_write(rfc)
|
||||
|
||||
# Roadmap #26: persist the optional use case to the canonical,
|
||||
# reconcile-proof side table keyed by the PR number. Blank/omitted
|
||||
# writes no row (absence == "left blank"). The mirror onto
|
||||
# cached_prs keeps the cache column in parity.
|
||||
use_case = (body.proposed_use_case or "").strip()
|
||||
if use_case:
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case)
|
||||
VALUES ('pr', ?, ?, ?)
|
||||
ON CONFLICT(scope, pr_number) DO UPDATE SET use_case = excluded.use_case
|
||||
""",
|
||||
(slug, pr["number"], use_case),
|
||||
)
|
||||
db.conn().execute(
|
||||
"UPDATE cached_prs SET proposed_use_case = ? WHERE rfc_slug = ? AND pr_number = ?",
|
||||
(use_case, slug, pr["number"]),
|
||||
)
|
||||
|
||||
return {"pr_number": pr["number"], "slug": slug, "branch": branch}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
@@ -300,6 +325,7 @@ def make_router(
|
||||
"pr_number": pr_number,
|
||||
"title": pr_row["title"],
|
||||
"description": pr_row["description"],
|
||||
"proposed_use_case": _pr_use_case(pr_number),
|
||||
"state": pr_row["state"],
|
||||
"opened_by": pr_row["opened_by"],
|
||||
"opened_at": pr_row["opened_at"],
|
||||
@@ -762,6 +788,17 @@ def _can_edit_pr_text(rfc, pr_row, viewer) -> bool:
|
||||
return _can_withdraw(rfc, pr_row, viewer)
|
||||
|
||||
|
||||
def _pr_use_case(pr_number: int) -> str | None:
|
||||
"""Roadmap #26: the optional propose-PR use case from the canonical
|
||||
side table, or None when the change was opened without one ("left
|
||||
blank")."""
|
||||
row = db.conn().execute(
|
||||
"SELECT use_case FROM proposed_use_cases WHERE scope = 'pr' AND pr_number = ?",
|
||||
(pr_number,),
|
||||
).fetchone()
|
||||
return row["use_case"] if row else None
|
||||
|
||||
|
||||
def _pr_capabilities(rfc, pr_row, viewer) -> dict:
|
||||
return {
|
||||
"can_merge": _can_merge(rfc, viewer) and pr_row["state"] == "open",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
-- Roadmap #26 (rfc-app v0.22.0): the optional "What will you be using
|
||||
-- this for?" capture on the two propose surfaces.
|
||||
--
|
||||
-- The roadmap's framing names "the rfcs table" and "the PR-metadata
|
||||
-- table" for a `proposed_use_case TEXT NULL` column. In this deployment
|
||||
-- those two surfaces are the cache tables `cached_rfcs` and `cached_prs`
|
||||
-- (002_cache.sql). We add the nullable column to each, matching the
|
||||
-- existing naming convention (no NOT NULL, no default — NULL is the
|
||||
-- "left blank" sentinel the view surfaces render tastefully).
|
||||
--
|
||||
-- BUT: those tables are *cache*, rebuilt from Gitea by the §4.1
|
||||
-- reconciler (cache.py). The reconciler's INSERT...ON CONFLICT DO UPDATE
|
||||
-- sets only the columns it knows about, so an unlisted column is
|
||||
-- preserved on the update path — yet a propose/open never *writes* the
|
||||
-- column through the cache (the write path is endpoint -> Gitea ->
|
||||
-- reconcile, and the reconciler does not carry this field). So the cache
|
||||
-- column alone would always read NULL.
|
||||
--
|
||||
-- The durable home is therefore a dedicated app-truth table the propose
|
||||
-- /open endpoints write directly (keyed by the PR number, which is the
|
||||
-- stable identity for both idea PRs and rfc_branch PRs) and the view
|
||||
-- endpoints read back. This is not cache — it is canonical and survives
|
||||
-- any reconcile. The cache columns are added too for parity with the
|
||||
-- roadmap's literal shape and for any future reconciler that learns to
|
||||
-- carry the field, but the side table is the source of truth read at
|
||||
-- view time.
|
||||
|
||||
ALTER TABLE cached_rfcs ADD COLUMN proposed_use_case TEXT;
|
||||
ALTER TABLE cached_prs ADD COLUMN proposed_use_case TEXT;
|
||||
|
||||
-- Canonical, reconcile-proof store. One row per propose/open that
|
||||
-- supplied a use case. `scope` distinguishes the propose-RFC surface
|
||||
-- ('rfc') from the propose-PR-against-an-RFC surface ('pr'); `pr_number`
|
||||
-- is the join key the endpoints already have in hand. NULL/omitted use
|
||||
-- cases simply never write a row here, so absence == "left blank".
|
||||
CREATE TABLE proposed_use_cases (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('rfc', 'pr')),
|
||||
rfc_slug TEXT NOT NULL,
|
||||
pr_number INTEGER NOT NULL,
|
||||
use_case TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (scope, pr_number)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_proposed_use_cases_lookup ON proposed_use_cases (scope, pr_number);
|
||||
CREATE INDEX idx_proposed_use_cases_slug ON proposed_use_cases (scope, rfc_slug);
|
||||
@@ -0,0 +1,161 @@
|
||||
"""End-to-end vertical for roadmap #26 (rfc-app v0.22.0): the optional
|
||||
"What will you be using this for?" capture on the two propose surfaces.
|
||||
|
||||
Reuses the FakeGitea + session helpers from test_propose_vertical.py and
|
||||
the active-RFC seed from test_rfc_view_vertical.py. Proves:
|
||||
|
||||
(a) propose-RFC persists and returns `proposed_use_case` when supplied,
|
||||
and the value survives onto the merged super-draft's RFC view;
|
||||
(b) propose-RFC accepts a NULL / omitted use case ("left blank");
|
||||
(c) propose-PR persists and returns `proposed_use_case` when supplied,
|
||||
and accepts a NULL / omitted one.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
from test_pr_flow_vertical import _cut_branch_and_accept_change
|
||||
from test_rfc_view_vertical import SEED_BODY, seed_active_rfc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# propose-RFC
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_propose_rfc_persists_and_returns_use_case(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
|
||||
|
||||
r = client.post("/api/rfcs/propose", json={
|
||||
"title": "Open Human Model",
|
||||
"slug": "open-human-model",
|
||||
"pitch": "A shared definition of what we mean by *human*.",
|
||||
"tags": ["identity"],
|
||||
"proposed_use_case": "Wiring OHM into the OpenXML consent surface.",
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
pr_number = r.json()["pr_number"]
|
||||
|
||||
# The pending-idea list carries the use case.
|
||||
items = client.get("/api/proposals").json()["items"]
|
||||
assert items[0]["proposed_use_case"] == "Wiring OHM into the OpenXML consent surface."
|
||||
|
||||
# The pending-idea detail view carries it too.
|
||||
proposal = client.get(f"/api/proposals/{pr_number}").json()
|
||||
assert proposal["proposed_use_case"] == "Wiring OHM into the OpenXML consent surface."
|
||||
|
||||
# Merge as owner; the use case survives onto the RFC view (looked
|
||||
# up by slug from the canonical side table, since the idea PR
|
||||
# closes on merge).
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner", email="ben@test")
|
||||
r = client.post(f"/api/proposals/{pr_number}/merge")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
view = client.get("/api/rfcs/open-human-model").json()
|
||||
assert view["proposed_use_case"] == "Wiring OHM into the OpenXML consent surface."
|
||||
|
||||
|
||||
def test_propose_rfc_use_case_optional(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=3, login="carol", role="contributor")
|
||||
sign_in_as(client, user_id=3, gitea_login="carol", display_name="Carol", role="contributor")
|
||||
|
||||
# Omitted entirely.
|
||||
r = client.post("/api/rfcs/propose", json={
|
||||
"title": "No Use Case", "slug": "no-use-case", "pitch": "p", "tags": [],
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
pr_a = r.json()["pr_number"]
|
||||
|
||||
# Explicit null.
|
||||
r = client.post("/api/rfcs/propose", json={
|
||||
"title": "Null Use Case", "slug": "null-use-case", "pitch": "p",
|
||||
"tags": [], "proposed_use_case": None,
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
pr_b = r.json()["pr_number"]
|
||||
|
||||
# Blank/whitespace — treated as "left blank", no row written.
|
||||
r = client.post("/api/rfcs/propose", json={
|
||||
"title": "Blank Use Case", "slug": "blank-use-case", "pitch": "p",
|
||||
"tags": [], "proposed_use_case": " ",
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
pr_c = r.json()["pr_number"]
|
||||
|
||||
for pr in (pr_a, pr_b, pr_c):
|
||||
assert client.get(f"/api/proposals/{pr}").json()["proposed_use_case"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# propose-PR (against an active RFC)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_propose_pr_persists_and_returns_use_case(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
branch, _ = _cut_branch_and_accept_change(
|
||||
client, fake, slug="ohm",
|
||||
original="Open Human Model is a framework for representing humans.",
|
||||
proposed="Open Human Model is a framework for representing humans across systems.",
|
||||
)
|
||||
r = client.post(
|
||||
f"/api/rfcs/ohm/branches/{branch}/open-pr",
|
||||
json={
|
||||
"title": "Tighten the opening",
|
||||
"description": "Scope to systems.",
|
||||
"proposed_use_case": "Building a cross-system consent registry.",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
pr_number = r.json()["pr_number"]
|
||||
|
||||
pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json()
|
||||
assert pr["proposed_use_case"] == "Building a cross-system consent registry."
|
||||
|
||||
|
||||
def test_propose_pr_use_case_optional(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
branch, _ = _cut_branch_and_accept_change(
|
||||
client, fake, slug="ohm",
|
||||
original="It defines consent, trait, and agency in compatible terms.",
|
||||
proposed="It defines consent, trait, harm, and agency in compatible terms.",
|
||||
)
|
||||
# No proposed_use_case key at all.
|
||||
r = client.post(
|
||||
f"/api/rfcs/ohm/branches/{branch}/open-pr",
|
||||
json={"title": "Add harm", "description": "Name harm explicitly."},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
pr_number = r.json()["pr_number"]
|
||||
|
||||
pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json()
|
||||
assert pr["proposed_use_case"] is None
|
||||
Generated
+2
-2
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.20.0",
|
||||
"version": "0.22.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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
|
||||
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>
|
||||
)}
|
||||
|
||||
+13
-4
@@ -166,11 +166,19 @@ export async function getProposal(prNumber) {
|
||||
return jsonOrThrow(await fetch(`/api/proposals/${prNumber}`))
|
||||
}
|
||||
|
||||
export async function proposeRFC({ title, slug, pitch, tags }) {
|
||||
export async function proposeRFC({ title, slug, pitch, tags, proposedUseCase }) {
|
||||
const res = await fetch('/api/rfcs/propose', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, slug, pitch, tags: tags || [] }),
|
||||
// #26: proposed_use_case is optional; send null when blank so the
|
||||
// backend treats it as "left blank".
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
slug,
|
||||
pitch,
|
||||
tags: tags || [],
|
||||
proposed_use_case: proposedUseCase || null,
|
||||
}),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
@@ -492,13 +500,14 @@ export async function draftPRText(slug, branch) {
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function openPR(slug, branch, { title, description }) {
|
||||
export async function openPR(slug, branch, { title, description, proposedUseCase }) {
|
||||
const res = await fetch(
|
||||
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/open-pr`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, description }),
|
||||
// #26: proposed_use_case is optional; null when blank.
|
||||
body: JSON.stringify({ title, description, proposed_use_case: proposedUseCase || null }),
|
||||
},
|
||||
)
|
||||
return jsonOrThrow(res)
|
||||
|
||||
@@ -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
|
||||
// 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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import { EVENTS, track } from '../lib/analytics'
|
||||
export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpened }) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
// #26: optional ground-truth use case for this change.
|
||||
const [useCase, setUseCase] = useState('')
|
||||
const [drafting, setDrafting] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [confirmed, setConfirmed] = useState(!branchIsPrivate)
|
||||
@@ -39,7 +41,11 @@ export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpen
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { pr_number } = await openPR(slug, branch, { title: title.trim(), description: description.trim() })
|
||||
const { pr_number } = await openPR(slug, branch, {
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
proposedUseCase: useCase.trim() || null,
|
||||
})
|
||||
// v0.15.0 — analytics: fire on §10.2 PR-open success. slug
|
||||
// and pr_number are the join keys; title/description stay out.
|
||||
track(EVENTS.PR_OPENED, { rfc_slug: slug, pr_number })
|
||||
@@ -104,6 +110,20 @@ export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpen
|
||||
what was argued, what shifted, what the arbiters are asked
|
||||
to consider.
|
||||
</p>
|
||||
<label className="modal-label">What will you be using this change for? (optional)</label>
|
||||
<textarea
|
||||
className="modal-textarea"
|
||||
value={useCase}
|
||||
onChange={e => setUseCase(e.target.value)}
|
||||
placeholder="The concrete thing this change unlocks for you. Optional."
|
||||
disabled={drafting || submitting}
|
||||
rows={3}
|
||||
maxLength={8000}
|
||||
/>
|
||||
<p className="field-help">
|
||||
#26: the concrete ground-truth use case — distinct from "why
|
||||
it's needed" above. Leave blank if you'd rather not say.
|
||||
</p>
|
||||
{error && <p className="field-error">{error}</p>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
|
||||
@@ -220,6 +220,17 @@ export default function PRView({ viewer }) {
|
||||
{pr.description && (
|
||||
<p className="pr-description">{pr.description}</p>
|
||||
)}
|
||||
{/* #26: the optional ground-truth use case for this change,
|
||||
captured when the PR was opened. Muted "left blank"
|
||||
treatment when none was supplied. */}
|
||||
<div className="pr-use-case" style={{ margin: '6px 0', fontSize: 13 }}>
|
||||
<span style={{ fontWeight: 700, color: '#888', textTransform: 'uppercase', letterSpacing: '0.05em', fontSize: 11 }}>
|
||||
Intended use case:
|
||||
</span>{' '}
|
||||
{pr.proposed_use_case
|
||||
? <span style={{ whiteSpace: 'pre-wrap' }}>{pr.proposed_use_case}</span>
|
||||
: <span style={{ color: '#999', fontStyle: 'italic' }}>left blank</span>}
|
||||
</div>
|
||||
{pr.capabilities?.can_edit_text && (
|
||||
<button className="btn-link" onClick={startHeaderEdit}>Edit title & description</button>
|
||||
)}
|
||||
|
||||
@@ -163,6 +163,14 @@ export default function ProposalView({ viewer, onChange }) {
|
||||
className="entry-body"
|
||||
dangerouslySetInnerHTML={{ __html: marked.parse(data.entry?.body || '') }}
|
||||
/>
|
||||
|
||||
{/* #26: the optional ground-truth use case the proposer supplied. */}
|
||||
<h3 style={{ fontSize: 13, fontWeight: 700, color: '#888', textTransform: 'uppercase', letterSpacing: '0.05em', marginTop: 24 }}>
|
||||
Intended use case
|
||||
</h3>
|
||||
{data.proposed_use_case
|
||||
? <div className="entry-body" dangerouslySetInnerHTML={{ __html: marked.parse(data.proposed_use_case) }} />
|
||||
: <p style={{ color: '#999', fontStyle: 'italic' }}>Left blank by the proposer.</p>}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
|
||||
const [slug, setSlug] = useState('')
|
||||
const [slugEdited, setSlugEdited] = useState(false)
|
||||
const [pitch, setPitch] = useState('')
|
||||
// #26: optional ground-truth use case, sibling to the required pitch.
|
||||
const [useCase, setUseCase] = useState('')
|
||||
const [tagInput, setTagInput] = useState('')
|
||||
const [tags, setTags] = useState([])
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
@@ -52,6 +54,7 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
|
||||
slug,
|
||||
pitch: pitch.trim(),
|
||||
tags,
|
||||
proposedUseCase: useCase.trim() || null,
|
||||
})
|
||||
// v0.15.0 — analytics: fire on the §9.1 propose-RFC submit.
|
||||
// Slug is a stable, low-cardinality identifier (kebab-case
|
||||
@@ -105,6 +108,19 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
|
||||
required
|
||||
/>
|
||||
|
||||
<label htmlFor="propose-use-case">What will you be using this RFC for? (optional)</label>
|
||||
<textarea
|
||||
id="propose-use-case"
|
||||
value={useCase}
|
||||
onChange={e => setUseCase(e.target.value)}
|
||||
placeholder="The concrete thing you intend to build or do with this RFC. Optional, but it helps ground the work."
|
||||
rows={3}
|
||||
/>
|
||||
<p className="field-help">
|
||||
The concrete ground-truth use case — distinct from "why it's
|
||||
needed" above. Leave blank if you'd rather not say.
|
||||
</p>
|
||||
|
||||
<label htmlFor="propose-tag">Tags (optional)</label>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 4 }}>
|
||||
<input
|
||||
|
||||
@@ -669,6 +669,19 @@ export default function RFCView({ viewer }) {
|
||||
: 'main is read-only — PRs are the only path to change it. Open a branch to propose edits.'}
|
||||
</div>
|
||||
)}
|
||||
{/* #26: the optional ground-truth use case captured at propose
|
||||
time. Shown on the canonical (main) view; muted "left blank"
|
||||
treatment when the proposer didn't supply one. */}
|
||||
{branchParam === 'main' && (
|
||||
<div className="rfc-use-case" style={{ margin: '8px 0 16px', padding: '10px 14px', borderLeft: '3px solid #e0e0e0', background: '#fafafa' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#888', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>
|
||||
Intended use case
|
||||
</div>
|
||||
{entry.proposed_use_case
|
||||
? <div style={{ whiteSpace: 'pre-wrap' }}>{entry.proposed_use_case}</div>
|
||||
: <span style={{ color: '#999', fontStyle: 'italic' }}>Left blank by the proposer.</span>}
|
||||
</div>
|
||||
)}
|
||||
{inDiscuss && branchParam !== 'main' && (
|
||||
<div className="discuss-mode-banner">
|
||||
Discuss mode on <strong>{branchParam}</strong> — chat freely;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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