Release 0.14.0: public /docs user guide (DOCS.md served at /docs)
Ships DOCS.md and the /docs route — a plain-prose translation of SPEC.md for users, distinct from the binding spec. Originating need was the admin-vs-owner role distinction on /admin/users (§6.1); response is a single guide that covers the framework's user-facing surfaces end-to-end (roles, proposing, branches, PRs, graduation, notifications). Mirrors /philosophy: markdown file at repo root + sibling backend loader + MarkdownPreview render. No schema migration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,79 @@ 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.14.0 — 2026-05-28
|
||||||
|
|
||||||
|
**Minor — no operator action required; new optional env var.** This
|
||||||
|
release ships `DOCS.md` and the `/docs` route — a public-facing user
|
||||||
|
guide that translates `SPEC.md` into plain prose for readers,
|
||||||
|
proposers, and contributors. The originating need was the
|
||||||
|
admin-vs-owner distinction on the `/admin/users` surface (the §6.1
|
||||||
|
role separation was load-bearing but only documented in spec voice);
|
||||||
|
the response was a single guide that covers the framework's user-
|
||||||
|
facing surfaces end-to-end. Mirrors `/philosophy` end-to-end: a
|
||||||
|
markdown file checked into the repo root, served by a sibling backend
|
||||||
|
loader, rendered with `MarkdownPreview`. No schema migration. No
|
||||||
|
required env-var changes. The new "Docs" header link sits alongside
|
||||||
|
the persistent "About" link from §14.3 and is reachable by anonymous
|
||||||
|
viewers per the same v0.3.0 anonymous-read contract.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`DOCS.md`** at the repo root — the user-facing guide. Covers
|
||||||
|
reading anonymously, signing in, proposing an RFC, super-drafts vs
|
||||||
|
active RFCs, the discussion-vs-contribution distinction (§10.10),
|
||||||
|
working on a branch (contribute mode, AI proposals, manual edits,
|
||||||
|
flags, branch visibility, contribute grants, hygiene), opening and
|
||||||
|
reviewing PRs, graduation (§13), withdrawal and reopening, the AI
|
||||||
|
participant (§6.6 / §6.7 / §18), notifications and watch states
|
||||||
|
(§15), and the full roles-and-permissions story (§6 in plain
|
||||||
|
prose: anonymous / contributor / admin / owner, per-RFC
|
||||||
|
owners + arbiters, per-branch contribute grants, the write-mute,
|
||||||
|
and the three structurally distinct "mutes"). Framework-neutral —
|
||||||
|
no deployment-specific names or corpus references; consistent with
|
||||||
|
`CLAUDE.md`'s separation-of-concerns rule.
|
||||||
|
- **`backend/app/docs.py`** — sibling loader for `philosophy.py`.
|
||||||
|
Reads `DOCS.md` from the repo root with the same disk-first,
|
||||||
|
in-process-cached, `refresh()`-on-demand shape. Optional
|
||||||
|
`DOCS_PATH` env var points at an alternative source (e.g. a
|
||||||
|
meta-repo working-tree clone) for deployments that prefer that.
|
||||||
|
- **`§17` endpoint** — `GET /api/docs` returns
|
||||||
|
`{ "body": "<DOCS.md verbatim>" }`. Anonymous-reachable, same
|
||||||
|
contract as `GET /api/philosophy`.
|
||||||
|
- **`frontend/src/components/Docs.jsx`** — the `/docs` reading
|
||||||
|
surface. Mirrors `Philosophy.jsx`: chrome with Back / "USER GUIDE" /
|
||||||
|
Home affordances, body rendered through `MarkdownPreview`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`backend/app/api.py`** — imports `docs as docs_mod` alongside
|
||||||
|
`philosophy` in the relative-import block; registers the new
|
||||||
|
`GET /api/docs` handler immediately after `GET /api/philosophy`.
|
||||||
|
- **`frontend/src/api.js`** — exports `getDocs()` alongside
|
||||||
|
`getPhilosophy()`. Same fetch shape, different endpoint path.
|
||||||
|
- **`frontend/src/App.jsx`** — imports `Docs` alongside `Philosophy`,
|
||||||
|
registers the `/docs` route alongside `/philosophy`, adds the
|
||||||
|
persistent "Docs" header link alongside "About", and adds the
|
||||||
|
`DocsWithSidebar` chrome wrapper alongside `PhilosophyWithSidebar`.
|
||||||
|
|
||||||
|
### Upgrade steps (from 0.13.0)
|
||||||
|
|
||||||
|
- You **MUST** rebuild the frontend and restart the backend after
|
||||||
|
upgrading so the new `/docs` route, the new endpoint, and the new
|
||||||
|
loader are picked up. `frontend/package.json#version` and `VERSION`
|
||||||
|
both move to `0.14.0`. No schema migration; the new endpoint
|
||||||
|
serves a checked-in file.
|
||||||
|
- You **MAY** set `DOCS_PATH` to an absolute path if your deployment
|
||||||
|
hosts `DOCS.md` outside the framework's repo (e.g. as a sync target
|
||||||
|
from a content repo). Unset is supported — the framework's
|
||||||
|
`DOCS.md` at the repo root is the default, mirroring how
|
||||||
|
`PHILOSOPHY_PATH` works for `/api/philosophy`.
|
||||||
|
- You **MAY** customize `DOCS.md` for your deployment if you want
|
||||||
|
deployment-specific phrasing layered on top of the framework's
|
||||||
|
guide. The file is a regular markdown source; standard `vim`/`git`
|
||||||
|
edits suffice. Framework upgrades that ship a new `DOCS.md` will
|
||||||
|
show as a normal merge in your deployment-overlay layer.
|
||||||
|
|
||||||
## 0.13.0 — 2026-05-28
|
## 0.13.0 — 2026-05-28
|
||||||
|
|
||||||
**Minor — schema migration required; new optional env vars.** This
|
**Minor — schema migration required; new optional env vars.** This
|
||||||
|
|||||||
@@ -0,0 +1,605 @@
|
|||||||
|
# Using the RFC app
|
||||||
|
|
||||||
|
This is the user-facing guide to the Wiggleverse RFC framework — how to
|
||||||
|
read what's here, propose a new RFC, contribute to one that already
|
||||||
|
exists, and understand who is allowed to do what.
|
||||||
|
|
||||||
|
This guide describes the framework. Individual deployments brand and
|
||||||
|
configure themselves independently — the name in the header and the
|
||||||
|
corpus the RFCs are about belong to the deployment, not to this
|
||||||
|
document.
|
||||||
|
|
||||||
|
For the *why* of the framework, read the [philosophy](/philosophy).
|
||||||
|
For the binding technical contract, see `SPEC.md` in the repository.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reading without signing in
|
||||||
|
|
||||||
|
You can read the catalog and every public RFC without an account.
|
||||||
|
Anonymous visitors can:
|
||||||
|
|
||||||
|
- Browse the catalog of super-drafts and active RFCs.
|
||||||
|
- Open any RFC and read its canonical body.
|
||||||
|
- Read any public branch — its diff and its chat thread.
|
||||||
|
- Read any pull request — its diff, its conversation, its review
|
||||||
|
comments.
|
||||||
|
- Read the discussion that has accumulated on an RFC's main view.
|
||||||
|
|
||||||
|
Reading is open by design. The framework's claim is that the *argument
|
||||||
|
behind a definition* is the evidence that the definition was earned,
|
||||||
|
and an argument that disappears behind a sign-in wall stops carrying
|
||||||
|
that evidence.
|
||||||
|
|
||||||
|
What you cannot do without an account: chat, propose a new RFC,
|
||||||
|
create a branch, open a PR, drop a flag, or post on a discussion
|
||||||
|
thread. Every write affordance is replaced with a sign-in prompt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Signing in
|
||||||
|
|
||||||
|
While the framework is in private beta, only invited email addresses
|
||||||
|
can complete sign-in. If your email is on the allowlist, the
|
||||||
|
"Sign in" button in the header completes the flow and lands you on
|
||||||
|
the catalog with full read and write access. If your email is not on
|
||||||
|
the allowlist, you'll be sent to a short "pending" page explaining
|
||||||
|
the gate.
|
||||||
|
|
||||||
|
Once you have an account, you're a **contributor** by default — the
|
||||||
|
role that grants every write affordance the app exposes, scoped by
|
||||||
|
the per-RFC and per-branch rules described below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proposing a new RFC
|
||||||
|
|
||||||
|
A new RFC begins as a proposal. The "+ Propose new RFC" button at
|
||||||
|
the bottom of the catalog opens a small modal that collects four
|
||||||
|
things:
|
||||||
|
|
||||||
|
- **Title.** The word, concept, or topic this RFC would define.
|
||||||
|
- **Slug.** A kebab-cased identifier derived from the title. It is
|
||||||
|
the entry's stable handle from this moment until it graduates;
|
||||||
|
collisions with existing entries or open proposals are caught
|
||||||
|
inline.
|
||||||
|
- **Pitch.** One or two paragraphs answering *why this RFC is
|
||||||
|
needed*. This becomes the body of the entry.
|
||||||
|
- **Tags.** Optional. The AI suggests tags from the pitch; you can
|
||||||
|
accept, dismiss, or type your own.
|
||||||
|
|
||||||
|
Submitting the modal does one concrete thing: it opens a pull
|
||||||
|
request against the framework's meta repository, adding one new
|
||||||
|
file under `rfcs/`. There is no other Git artifact and no other
|
||||||
|
side-effect. You are returned to the **pending-idea view** for the
|
||||||
|
new proposal.
|
||||||
|
|
||||||
|
A pending idea is publicly readable but not yet a super-draft. The
|
||||||
|
catalog surfaces it in a "Pending ideas" disclosure at the bottom
|
||||||
|
of the list. A conversation can accumulate on the pending-idea view
|
||||||
|
before it is admitted — contributors can argue, in public, about
|
||||||
|
whether the entry belongs in the catalog at all.
|
||||||
|
|
||||||
|
Three outcomes are possible:
|
||||||
|
|
||||||
|
- **Merge.** An admin or owner merges the proposal PR. The entry
|
||||||
|
becomes a super-draft and graduates from the "Pending ideas"
|
||||||
|
section into the main catalog. Any conversation that accumulated
|
||||||
|
on the pending-idea view migrates with it.
|
||||||
|
- **Decline.** An admin or owner declines, attaching a written
|
||||||
|
comment. You see the comment on your next visit, along with a
|
||||||
|
one-click affordance to revise and re-propose.
|
||||||
|
- **Withdraw.** You can withdraw your own proposal at any time. The
|
||||||
|
entry will not appear in any default view; the conversation that
|
||||||
|
accumulated stays attached to the closed PR as historical record.
|
||||||
|
|
||||||
|
You are automatically the first owner of any RFC you propose. The
|
||||||
|
claim flow described under [Roles & permissions](#roles--permissions)
|
||||||
|
is for *other* contributors to add themselves as owners later, not
|
||||||
|
for the proposer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What a super-draft is
|
||||||
|
|
||||||
|
A super-draft is an entry that has been admitted to the catalog but
|
||||||
|
does not yet have its own dedicated repository. Most of the
|
||||||
|
argument that shapes a definition happens here. The framework
|
||||||
|
assumes — and the philosophy explicitly invites — that many
|
||||||
|
super-drafts will not survive the argument, and that is fine. The
|
||||||
|
entries that do survive earn their place in the catalog by being
|
||||||
|
defensible in public.
|
||||||
|
|
||||||
|
Opening a super-draft from the catalog gives you the same surface
|
||||||
|
an active RFC uses:
|
||||||
|
|
||||||
|
- The canonical body in the centre, read-only by default.
|
||||||
|
- A chat thread on the right where the public conversation lives.
|
||||||
|
- A breadcrumb dropdown listing any in-flight edit branches and
|
||||||
|
any open body-edit PRs against this entry.
|
||||||
|
- A "Start Contributing" affordance that cuts a fresh edit branch
|
||||||
|
and lands you in contribute mode.
|
||||||
|
|
||||||
|
Edits to a super-draft body propagate through pull requests against
|
||||||
|
the meta repository — there is no dedicated RFC repository yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What an active RFC is
|
||||||
|
|
||||||
|
An active RFC is an entry that has been **graduated**. It has its
|
||||||
|
own dedicated repository, an integer `RFC-NNNN` identifier, and a
|
||||||
|
canonical body file (`RFC.md`) inside that repository. The catalog
|
||||||
|
distinguishes super-drafts and active RFCs at a glance.
|
||||||
|
|
||||||
|
Opening an active RFC gives you:
|
||||||
|
|
||||||
|
- `main` — the canonical body, always read-only. Changes to `main`
|
||||||
|
arrive exclusively through pull requests.
|
||||||
|
- A breadcrumb listing every open branch and pull request on this
|
||||||
|
RFC.
|
||||||
|
- A per-branch chat thread on the right. Each branch has its own
|
||||||
|
conversation, including `main` itself.
|
||||||
|
- A "Start Contributing" affordance: on `main` it cuts a new branch
|
||||||
|
and lands you on it in contribute mode; on any other branch you
|
||||||
|
already have push access to, it flips that branch into
|
||||||
|
contribute mode.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Discussion vs contribution
|
||||||
|
|
||||||
|
The framework draws an explicit distinction between two surfaces
|
||||||
|
that other tools tend to conflate:
|
||||||
|
|
||||||
|
- **Discussion** is what the RFC is *for*. The chat thread on an
|
||||||
|
RFC's main view is the place for "what about this part?" or
|
||||||
|
"have we considered…?" questions that don't yet warrant proposing
|
||||||
|
a specific edit. Posting on a discussion thread does not create
|
||||||
|
any Git artifact; the conversation lives in the app database.
|
||||||
|
- **Contribution** is how an RFC *changes*. Editing the canonical
|
||||||
|
body requires opening a branch and, eventually, a pull request.
|
||||||
|
The pull request is the place a specific proposed change is
|
||||||
|
reviewed and merged.
|
||||||
|
|
||||||
|
Reading both surfaces is open to anonymous visitors. Posting on
|
||||||
|
either requires a contributor account.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Working on a branch
|
||||||
|
|
||||||
|
Contribute mode flips one branch into edit-enabled. The centre
|
||||||
|
column splits: a markdown source pane on the left, a live-rendered
|
||||||
|
preview on the right. Fenced `mermaid` blocks render as diagrams in
|
||||||
|
the preview.
|
||||||
|
|
||||||
|
Two kinds of edits accumulate on a branch:
|
||||||
|
|
||||||
|
- **AI-proposed changes.** You ask the AI a question or request a
|
||||||
|
revision in the branch's chat. When the AI proposes a concrete
|
||||||
|
edit, that edit appears as a *change card* in a panel below the
|
||||||
|
chat — not yet applied to the document. You can **accept**,
|
||||||
|
**decline**, or **edit before accepting**. Accepting produces
|
||||||
|
one commit on the branch with the original text, the proposed
|
||||||
|
text, and the AI's reason recorded in the commit body.
|
||||||
|
- **Manual edits.** Typing directly into the source pane buffers
|
||||||
|
locally and flushes as a single commit on an idle window, a
|
||||||
|
branch switch, or an explicit "Save now" button. Manual edits
|
||||||
|
also appear as change cards in the same panel — same evidence
|
||||||
|
shape, different author.
|
||||||
|
|
||||||
|
Every accepted change is one commit. The framework does not
|
||||||
|
support squash-merges or fixup-style cleanups: the per-change
|
||||||
|
commit granularity is the framework's evidence unit, and
|
||||||
|
collapsing it would erase what was earned.
|
||||||
|
|
||||||
|
### Discuss mode vs contribute mode
|
||||||
|
|
||||||
|
A branch defaults to discuss mode — read-only, with chat enabled.
|
||||||
|
AI proposals still appear in chat, but they are *buffered* rather
|
||||||
|
than applied; a single CTA invites you to flip the branch into
|
||||||
|
contribute mode if you want to act on them. The toggle is an
|
||||||
|
*intent* affordance, not a permission one. If you don't have push
|
||||||
|
access to the branch, the toggle is disabled with a sign-in or
|
||||||
|
request-access path.
|
||||||
|
|
||||||
|
`main` is special: contribute mode is never available there. The
|
||||||
|
"Start Contributing" button on `main` always cuts a new branch.
|
||||||
|
|
||||||
|
### Flags
|
||||||
|
|
||||||
|
Anywhere you can read, you can drop a flag. A flag is the
|
||||||
|
lightweight "I'm pointing at this, it's a problem" gesture — a
|
||||||
|
single short declarative statement anchored to a passage. Creating
|
||||||
|
a flag requires a contributor account but does not require push
|
||||||
|
access to the branch: any signed-in contributor who can read a
|
||||||
|
passage can point at it and say it's wrong.
|
||||||
|
|
||||||
|
Flags don't block PR merges by design — making them a merge gate
|
||||||
|
would re-create the failure mode where contributors hastily "resolve"
|
||||||
|
threads to unblock a button. Flags are prominent on PR headers but
|
||||||
|
non-blocking.
|
||||||
|
|
||||||
|
### Branch visibility
|
||||||
|
|
||||||
|
A new branch is publicly readable by default. The branch creator
|
||||||
|
can flip a branch to private, in which case only the creator, any
|
||||||
|
explicit grantees, and the RFC's per-RFC owners and arbiters can
|
||||||
|
read it. Owners and arbiters can flip it back.
|
||||||
|
|
||||||
|
**Opening a PR makes the branch fully public.** If your branch is
|
||||||
|
currently private, the "Open PR" affordance asks you to confirm
|
||||||
|
this before submitting. There is no concept of a private PR — the
|
||||||
|
framework's evidence claim depends on the argument being readable.
|
||||||
|
|
||||||
|
### Who can push to a branch
|
||||||
|
|
||||||
|
Every branch has one of three contribute modes:
|
||||||
|
|
||||||
|
- **`just-me`** (default) — only the branch creator can push.
|
||||||
|
- **`specific`** — only the branch creator and explicitly granted
|
||||||
|
contributors can push.
|
||||||
|
- **`any-contributor`** — any signed-in contributor can push.
|
||||||
|
|
||||||
|
The branch creator and the RFC's per-RFC owners and arbiters can
|
||||||
|
change this setting at any time.
|
||||||
|
|
||||||
|
### Branch hygiene
|
||||||
|
|
||||||
|
A branch with no associated PR auto-closes after 30 days of
|
||||||
|
inactivity. A closed branch is deleted from the Git host 60 days
|
||||||
|
later. Closed branches remain in the catalog under a "show closed"
|
||||||
|
filter — closing is a state, not a censorship event. The chat
|
||||||
|
attached to a closed or deleted branch is preserved as historical
|
||||||
|
record.
|
||||||
|
|
||||||
|
Owners and arbiters can *pin* a branch to disable the auto-close
|
||||||
|
timer if the work is paused but legitimately ongoing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Opening and reviewing a pull request
|
||||||
|
|
||||||
|
A pull request is the deliberate "ready for review" gesture for
|
||||||
|
work that has accumulated on a branch. The "Open PR" affordance is
|
||||||
|
available on any branch with at least one commit ahead of `main`.
|
||||||
|
|
||||||
|
The PR creation modal collects two AI-drafted fields, both editable
|
||||||
|
before submit:
|
||||||
|
|
||||||
|
- **Title.** A one-line description of the change, in spec voice.
|
||||||
|
- **Description.** Two to four sentences pulling from the branch
|
||||||
|
chat, written for an arbiter.
|
||||||
|
|
||||||
|
There is no reviewer picker. The RFC's arbiters are the implicit
|
||||||
|
reviewer set.
|
||||||
|
|
||||||
|
### The PR review page
|
||||||
|
|
||||||
|
The review page shows the diff, the branch's compressed chat
|
||||||
|
(messages that produced accepted changes are expanded, the rest is
|
||||||
|
behind a "Show full conversation" toggle), and the review-comment
|
||||||
|
surface inline below the chat.
|
||||||
|
|
||||||
|
Review comments are not a separate concept from chat — they live in
|
||||||
|
the same thread, anchored to a range in the diff. The framework's
|
||||||
|
claim is that the disagreement an arbiter raises about a proposed
|
||||||
|
change is the same *kind* of thing as the disagreement that
|
||||||
|
produced the proposed change in the first place, and the two should
|
||||||
|
share a surface.
|
||||||
|
|
||||||
|
Each PR records a per-user seen-cursor. New diff hunks and new
|
||||||
|
conversation messages since your last visit render with a subtle
|
||||||
|
accent. The cursor advances on view; you do not have to mark
|
||||||
|
anything as read.
|
||||||
|
|
||||||
|
### Merging a PR
|
||||||
|
|
||||||
|
Per-RFC owners and arbiters can merge; app-wide admins and owners
|
||||||
|
also retain this capability. The merge produces a no-fast-forward
|
||||||
|
commit on `main`, preserving every per-acceptance commit as an
|
||||||
|
individually reachable node in `main`'s history.
|
||||||
|
|
||||||
|
Merge is hard-blocked **only** by Git-level conflicts with `main`.
|
||||||
|
Open review threads, pending change-cards, unresolved chat threads,
|
||||||
|
and open flags do not block merge by design.
|
||||||
|
|
||||||
|
### Conflicts with main
|
||||||
|
|
||||||
|
A conflict surfaces on the PR page as a read-only banner. A "Start
|
||||||
|
resolution branch" affordance cuts a fresh branch off `main`'s
|
||||||
|
current tip, replays the work into it (asking the AI to resolve
|
||||||
|
unambiguous conflicts, surfacing the rest for you), and opens a new
|
||||||
|
PR. The original PR auto-closes when the resolution PR merges.
|
||||||
|
|
||||||
|
Fixup commits on the existing branch are not supported. Per-change
|
||||||
|
commit granularity is the framework's evidence unit; admitting
|
||||||
|
"fix merge conflict with main" commits would dilute it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Graduation: super-draft → active RFC
|
||||||
|
|
||||||
|
Graduation is the moment a super-draft becomes a canonical entry
|
||||||
|
in the catalog. It is initiated by an app-wide admin, an app-wide
|
||||||
|
owner, or one of the RFC's per-RFC owners or arbiters from the
|
||||||
|
super-draft's page.
|
||||||
|
|
||||||
|
Two preconditions block the action:
|
||||||
|
|
||||||
|
- **The super-draft must have at least one owner.** The proposer
|
||||||
|
is automatically the first owner; if they have stepped away, any
|
||||||
|
contributor can use the "Claim ownership" affordance to add
|
||||||
|
themselves.
|
||||||
|
- **No open body-edit PRs against the super-draft's entry.** An
|
||||||
|
open body-edit PR would attempt to re-introduce a body to a
|
||||||
|
frontmatter-only entry after graduation runs. Merge or withdraw
|
||||||
|
them first.
|
||||||
|
|
||||||
|
When the dialog confirms, the framework runs a transactional
|
||||||
|
sequence: create a fresh Git repository for the RFC, seed it with
|
||||||
|
the super-draft's body as `RFC.md`, update the meta-repo entry to
|
||||||
|
`state: active` with the integer ID and the new repository's URL,
|
||||||
|
auto-merge that update. If any step fails partway, the sequence
|
||||||
|
rolls back — the half-created repository is deleted and the
|
||||||
|
unmerged update is abandoned. The dialog shows each step in flight
|
||||||
|
and tells you exactly what happened.
|
||||||
|
|
||||||
|
The chat thread on the super-draft moves to the new repository's
|
||||||
|
`main` chat at graduation. Edit-branch chats from the super-draft
|
||||||
|
phase stay attached to their original branches on the meta repo
|
||||||
|
and surface from the new RFC view under a "Pre-graduation history"
|
||||||
|
section.
|
||||||
|
|
||||||
|
Graduation is not reversible. The path forward from an active RFC
|
||||||
|
is withdrawal, not back to super-draft.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Withdrawing and reopening
|
||||||
|
|
||||||
|
An active RFC or a super-draft can be withdrawn by the proposer
|
||||||
|
(for a super-draft they proposed) or by an admin or owner. A
|
||||||
|
withdrawn entry stays in the catalog as a historical record but is
|
||||||
|
hidden from default views. The entry is filterable back in.
|
||||||
|
|
||||||
|
An admin or owner can reopen a withdrawn entry back into the
|
||||||
|
super-draft state. The history is preserved across the transition.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AI in the chat
|
||||||
|
|
||||||
|
The chat on every RFC, super-draft, branch, and PR has an AI
|
||||||
|
participant by default. The framework treats the AI as one voice
|
||||||
|
among many in a public argument — not an oracle, and not a
|
||||||
|
co-author whose name lands on commits.
|
||||||
|
|
||||||
|
You invoke the AI by writing into the chat composer and submitting.
|
||||||
|
Each message can pick a model from the picker (the option list is
|
||||||
|
configurable per RFC). The AI responds in the chat; when its
|
||||||
|
response includes a concrete change to the document, that change
|
||||||
|
appears as a card you can accept, decline, or edit.
|
||||||
|
|
||||||
|
When you accept an AI's proposed change, the commit's
|
||||||
|
`On-behalf-of:` trailer names *you*, not the AI. The AI's authorship
|
||||||
|
survives only as evidence — the original proposal in the commit body
|
||||||
|
and the message that produced it in the chat record. The framework
|
||||||
|
is explicit about this: AI participation produces evidence; it does
|
||||||
|
not produce authorship.
|
||||||
|
|
||||||
|
Two configuration knobs scope AI participation per RFC:
|
||||||
|
|
||||||
|
- **Which models are available.** The meta-repo entry's frontmatter
|
||||||
|
carries an optional `models:` list. Absent means the RFC inherits
|
||||||
|
whatever models the deployment is provisioned to run. An empty
|
||||||
|
list (`models: []`) opts the RFC out of AI entirely — every AI
|
||||||
|
surface is absent rather than disabled-but-present.
|
||||||
|
- **Whose credentials pay.** By default the deployment operator's
|
||||||
|
API credentials cover AI calls on every RFC. A `funder:`
|
||||||
|
frontmatter field can name a single contributor whose registered
|
||||||
|
credentials pay for AI calls on this RFC instead. The named
|
||||||
|
contributor must explicitly consent from their settings page;
|
||||||
|
either side can revoke at any time.
|
||||||
|
|
||||||
|
Per-RFC AI configuration is edited through the meta-repo PR flow
|
||||||
|
that governs the rest of the entry's frontmatter — by the RFC's
|
||||||
|
per-RFC owners and arbiters, or by app-wide admins or owners.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notifications
|
||||||
|
|
||||||
|
The framework's public-async work model produces signals that
|
||||||
|
shouldn't all reach you the same way. Five surfaces compose:
|
||||||
|
|
||||||
|
- **In-app inbox.** The durable triage surface. One mental space
|
||||||
|
across every RFC you have any relationship to, with per-RFC and
|
||||||
|
per-category filters. Reachable from the inbox icon in the
|
||||||
|
header.
|
||||||
|
- **Badges.** Ambient pull-ins. A single integer beside the inbox
|
||||||
|
icon (count of unread notifications). A small binary dot on
|
||||||
|
individual catalog rows for watched RFCs with unseen activity.
|
||||||
|
No per-row counts and no per-section counts.
|
||||||
|
- **Toasts.** Transient mid-session signals. Used only for your own
|
||||||
|
actions completing, and for events arriving on the view you're
|
||||||
|
currently looking at.
|
||||||
|
- **Email.** The single channel that escapes the app. Opt-in per
|
||||||
|
category, conservative defaults. One-click unsubscribe per
|
||||||
|
category.
|
||||||
|
- **Digest.** Aggregation for activity on watched RFCs you haven't
|
||||||
|
triaged through any other channel.
|
||||||
|
|
||||||
|
### Watch states
|
||||||
|
|
||||||
|
Every RFC has one of three implicit relationship states for you:
|
||||||
|
|
||||||
|
- **Watching.** You receive structural signals for the RFC.
|
||||||
|
- **Following.** You receive only churn-grade signals (new
|
||||||
|
commits, new chat messages on threads you didn't participate
|
||||||
|
in). This is a lighter relationship than watching.
|
||||||
|
- **Muted.** You receive no signals for the RFC. The mute is
|
||||||
|
per-RFC and self-imposed; it does not affect what others see
|
||||||
|
or what reaches you on *other* RFCs.
|
||||||
|
|
||||||
|
Watch states transition automatically based on your participation,
|
||||||
|
with explicit overrides available from each RFC's header and from
|
||||||
|
the notification settings page.
|
||||||
|
|
||||||
|
### Email categories
|
||||||
|
|
||||||
|
Four categories with distinct defaults:
|
||||||
|
|
||||||
|
- **Personal-direct events** — default on. Signals where you are
|
||||||
|
the named subject. The contract is that when your name is on the
|
||||||
|
action, the framework reaches out of band.
|
||||||
|
- **Watched-RFC structural events** — default off. PR opened on a
|
||||||
|
watched RFC, PR merged, graduation, withdrawal. Inbox and badges
|
||||||
|
carry these by default; the email toggle is opt-in.
|
||||||
|
- **Watched-RFC churn** — permanently off, by design. Per-commit
|
||||||
|
and per-message email is intentionally not offered. The digest
|
||||||
|
aggregates this activity weekly.
|
||||||
|
- **Admin-actionable events** — default on for admins and owners,
|
||||||
|
unused for contributors.
|
||||||
|
|
||||||
|
### Quiet hours
|
||||||
|
|
||||||
|
You can set a daily window during which email notifications are
|
||||||
|
held. Messages held during the window are released at window end —
|
||||||
|
bundled into a single "Activity while you were away" email if a
|
||||||
|
threshold accumulated, otherwise sent individually.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roles & permissions
|
||||||
|
|
||||||
|
Authorization in this framework is owned by the app itself, not by
|
||||||
|
the Git host. The Git host sees only a single bot account — every
|
||||||
|
commit, every PR, every merge passes through it on a user's behalf
|
||||||
|
— and the *app* decides which users are authorized to ask the bot
|
||||||
|
to do which things.
|
||||||
|
|
||||||
|
### The four app-wide roles
|
||||||
|
|
||||||
|
Each role is a strict superset of the one below it.
|
||||||
|
|
||||||
|
1. **Anonymous.** Anyone who has not signed in. Can read public
|
||||||
|
RFCs, public branches, and public PRs; cannot chat, propose,
|
||||||
|
create branches, or open PRs.
|
||||||
|
|
||||||
|
2. **Contributor.** The default role for any authenticated
|
||||||
|
account. Adds everything anonymous can do, plus: propose new
|
||||||
|
RFCs, create branches on any RFC repository, open PRs from
|
||||||
|
branches they have push access to, post on chat anywhere they
|
||||||
|
can read, claim ownership of unclaimed super-drafts.
|
||||||
|
|
||||||
|
3. **Admin.** Adds the ability to act on any RFC, anywhere in the
|
||||||
|
framework. Concretely: merge any PR on any RFC, graduate any
|
||||||
|
super-draft, set branch visibility on anyone's behalf, withdraw
|
||||||
|
or reopen any entry, write-mute or restore any contributor,
|
||||||
|
grant or revoke the **admin** role.
|
||||||
|
|
||||||
|
4. **Owner.** Adds two capabilities admin does not have: grant or
|
||||||
|
revoke the **owner** role itself, and disable an account
|
||||||
|
entirely. The framework names a single "owner zero" at
|
||||||
|
bootstrap.
|
||||||
|
|
||||||
|
The practical difference between admin and owner is narrow but
|
||||||
|
load-bearing: admin is the operational tier — it does the day-to-
|
||||||
|
day moderation and stewardship work; owner is the tier that
|
||||||
|
controls the admin tier. Disabling an account and creating other
|
||||||
|
owners are owner-only because they affect the framework's chain of
|
||||||
|
authority itself.
|
||||||
|
|
||||||
|
The app refuses to let the last owner demote themselves silently —
|
||||||
|
losing the last owner would leave nobody able to grant the role
|
||||||
|
back. Role changes are recorded in an append-only `permission_events`
|
||||||
|
log; an admin's own admin/users page shows the log of who promoted,
|
||||||
|
demoted, or muted whom.
|
||||||
|
|
||||||
|
### Per-RFC delegated authority
|
||||||
|
|
||||||
|
The four roles above are framework-wide. Within an individual RFC,
|
||||||
|
the meta-repo entry's frontmatter names two additional groups:
|
||||||
|
|
||||||
|
- **`owners:`** — contributors elevated for this RFC. They can
|
||||||
|
grant push access on any branch in the RFC, merge any PR on the
|
||||||
|
RFC, change branch visibility, and withdraw the RFC.
|
||||||
|
- **`arbiters:`** — contributors with merge authority for this RFC.
|
||||||
|
Functionally similar to per-RFC owners for merge decisions; the
|
||||||
|
distinction matters in some configuration paths.
|
||||||
|
|
||||||
|
Per-RFC owners and arbiters are **not** app-wide admins. Their
|
||||||
|
elevated powers are scoped strictly to the RFC named in the
|
||||||
|
frontmatter. This is what lets the framework distribute work
|
||||||
|
without putting one person on the hook for every action.
|
||||||
|
|
||||||
|
The proposer of an RFC is automatically the first per-RFC owner.
|
||||||
|
Additional per-RFC owners are added through a "Claim ownership"
|
||||||
|
PR against the meta repository; app-wide admins or owners merge
|
||||||
|
it.
|
||||||
|
|
||||||
|
### Per-branch contribute grants
|
||||||
|
|
||||||
|
Within an RFC, the branch creator and the RFC's per-RFC owners
|
||||||
|
and arbiters can grant push access to specific contributors on a
|
||||||
|
specific branch — `specific` contribute mode, described under
|
||||||
|
"Working on a branch."
|
||||||
|
|
||||||
|
### The write-mute
|
||||||
|
|
||||||
|
An app-wide admin or owner can **mute** a contributor. A muted
|
||||||
|
account retains read access and keeps its existing branches, but
|
||||||
|
cannot create new branches, open new PRs, propose new RFCs, or
|
||||||
|
post chat. This is a moderation tool, distinct from removing the
|
||||||
|
account; restoring is the reverse gesture.
|
||||||
|
|
||||||
|
The write-mute applies only to contributors. Promoting a user to
|
||||||
|
admin or owner is the way to remove a user's write-restriction in
|
||||||
|
the structural sense; the write-mute is for *retaining* an account
|
||||||
|
while removing its ability to act.
|
||||||
|
|
||||||
|
Every mute and every restore is recorded in `permission_events`.
|
||||||
|
|
||||||
|
### Three different "mutes"
|
||||||
|
|
||||||
|
The word "mute" appears in three structurally distinct places.
|
||||||
|
They share a word and nothing else.
|
||||||
|
|
||||||
|
- **Write-mute.** Admin-imposed. Removes a contributor's ability
|
||||||
|
to post or push. Described above.
|
||||||
|
- **Per-RFC notification mute.** Self-imposed. Sets your watch
|
||||||
|
state on a specific RFC to *muted* — you stop receiving signals
|
||||||
|
for that RFC, in inbox, badges, and email. Does not affect what
|
||||||
|
others see.
|
||||||
|
- **Per-user notification mute.** Self-imposed. Suppresses
|
||||||
|
notifications produced by a specific other user, anywhere in
|
||||||
|
the framework. Notification-volume only — it does not affect
|
||||||
|
what you can read.
|
||||||
|
|
||||||
|
A write-muted contributor continues to receive notifications
|
||||||
|
normally, so they can triage what they can't act on, and so a
|
||||||
|
restore lands cleanly.
|
||||||
|
|
||||||
|
### Audit trail
|
||||||
|
|
||||||
|
Every gesture that changes app state — role changes, mutes,
|
||||||
|
graduations, withdrawals, grant changes — is recorded in
|
||||||
|
append-only logs the app maintains. Git commit history is for
|
||||||
|
code archaeology; the app's audit log is the accountability
|
||||||
|
record. An admin's page surfaces both `permission_events` (the
|
||||||
|
role/mute log) and `actions` (the state-transition log) for
|
||||||
|
review.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where to learn more
|
||||||
|
|
||||||
|
- The framework's *why* lives in [the philosophy
|
||||||
|
document](/philosophy).
|
||||||
|
- The binding technical contract — section numbers (`§n.n`)
|
||||||
|
referenced throughout this guide — is in `SPEC.md` in the
|
||||||
|
framework's source repository.
|
||||||
|
- Deployment operators have their own recipe in
|
||||||
|
`docs/DEPLOYMENTS.md`.
|
||||||
@@ -26,6 +26,7 @@ from . import (
|
|||||||
api_prs,
|
api_prs,
|
||||||
auth,
|
auth,
|
||||||
db,
|
db,
|
||||||
|
docs as docs_mod,
|
||||||
entry as entry_mod,
|
entry as entry_mod,
|
||||||
cache,
|
cache,
|
||||||
funder,
|
funder,
|
||||||
@@ -122,6 +123,17 @@ def make_router(
|
|||||||
payload = philosophy.load()
|
payload = philosophy.load()
|
||||||
return {"body": payload["body"]}
|
return {"body": payload["body"]}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# /api/docs — DOCS.md served verbatim. Sibling of /api/philosophy:
|
||||||
|
# no auth gate, same disk-first load + cache shape, same intent —
|
||||||
|
# public read surface for a markdown file checked into the repo.
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/api/docs")
|
||||||
|
async def get_docs() -> dict[str, Any]:
|
||||||
|
payload = docs_mod.load()
|
||||||
|
return {"body": payload["body"]}
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# Auth surface — reads role from our users table per §6.
|
# Auth surface — reads role from our users table per §6.
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""User-facing docs source.
|
||||||
|
|
||||||
|
Mirrors `philosophy.py` shape. Serves `DOCS.md` from the repo root —
|
||||||
|
the framework's plain-prose user guide to roles, contribution flow,
|
||||||
|
and notification surfaces, distinct from the binding `SPEC.md`. Read
|
||||||
|
from disk on first call and cached in-process; the periodic
|
||||||
|
reconciler can call `refresh()` to pick up out-of-band edits.
|
||||||
|
|
||||||
|
`DOCS_PATH` overrides the default location if a deployment hosts the
|
||||||
|
file elsewhere (a meta-repo working-tree clone, a sync target, etc.).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULT_PATH = Path(__file__).resolve().parents[2] / "DOCS.md"
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_cache: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolved_path() -> Path:
|
||||||
|
override = os.environ.get("DOCS_PATH", "").strip()
|
||||||
|
if override:
|
||||||
|
return Path(override).expanduser().resolve()
|
||||||
|
return _DEFAULT_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def load(force: bool = False) -> dict:
|
||||||
|
"""Return the cached `{body, path, mtime}` payload, reading from disk
|
||||||
|
on first call or when `force=True`.
|
||||||
|
"""
|
||||||
|
global _cache
|
||||||
|
with _lock:
|
||||||
|
if _cache is not None and not force:
|
||||||
|
return _cache
|
||||||
|
path = _resolved_path()
|
||||||
|
try:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
mtime = path.stat().st_mtime
|
||||||
|
except FileNotFoundError:
|
||||||
|
log.warning("DOCS.md not found at %s — serving placeholder", path)
|
||||||
|
text = (
|
||||||
|
"# DOCS.md not found\n\n"
|
||||||
|
"The deployment is missing its user guide. Set "
|
||||||
|
"DOCS_PATH or place DOCS.md at the project root."
|
||||||
|
)
|
||||||
|
mtime = 0.0
|
||||||
|
_cache = {"body": text, "path": str(path), "mtime": mtime}
|
||||||
|
return _cache
|
||||||
|
|
||||||
|
|
||||||
|
def refresh() -> dict:
|
||||||
|
"""Force-reread from disk. Returns the new payload."""
|
||||||
|
return load(force=True)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.10.0",
|
"version": "0.14.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import Landing from './components/Landing.jsx'
|
|||||||
import Login from './components/Login.jsx'
|
import Login from './components/Login.jsx'
|
||||||
import BetaPending from './components/BetaPending.jsx'
|
import BetaPending from './components/BetaPending.jsx'
|
||||||
import Philosophy from './components/Philosophy.jsx'
|
import Philosophy from './components/Philosophy.jsx'
|
||||||
|
import Docs from './components/Docs.jsx'
|
||||||
import NotificationSettings from './components/NotificationSettings.jsx'
|
import NotificationSettings from './components/NotificationSettings.jsx'
|
||||||
import Admin from './components/Admin.jsx'
|
import Admin from './components/Admin.jsx'
|
||||||
import ToastHost, { showToast } from './components/ToastHost.jsx'
|
import ToastHost, { showToast } from './components/ToastHost.jsx'
|
||||||
@@ -111,6 +112,9 @@ export default function App() {
|
|||||||
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
|
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
|
||||||
About
|
About
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/docs" className="header-about" title="User guide">
|
||||||
|
Docs
|
||||||
|
</Link>
|
||||||
{viewer && (
|
{viewer && (
|
||||||
<Link to="/settings/notifications" className="header-settings" title="Notification settings (§15)">
|
<Link to="/settings/notifications" className="header-settings" title="Notification settings (§15)">
|
||||||
Settings
|
Settings
|
||||||
@@ -153,6 +157,7 @@ export default function App() {
|
|||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/beta-pending" element={<BetaPending viewer={viewer} />} />
|
<Route path="/beta-pending" element={<BetaPending viewer={viewer} />} />
|
||||||
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
|
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
|
||||||
|
<Route path="/docs" element={<DocsWithSidebar viewer={viewer} />} />
|
||||||
{/* §14.5 / §14.6: cookie-consent companions to /philosophy.
|
{/* §14.5 / §14.6: cookie-consent companions to /philosophy.
|
||||||
Available to anonymous and authenticated viewers alike. */}
|
Available to anonymous and authenticated viewers alike. */}
|
||||||
<Route path="/privacy" element={<PolicyShell><Privacy /></PolicyShell>} />
|
<Route path="/privacy" element={<PolicyShell><Privacy /></PolicyShell>} />
|
||||||
@@ -221,6 +226,14 @@ function PhilosophyWithSidebar({ viewer }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DocsWithSidebar({ viewer }) {
|
||||||
|
return (
|
||||||
|
<main className="chrome-pane">
|
||||||
|
<Docs authenticated={!!viewer} />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function NotificationSettingsWithSidebar({ viewer }) {
|
function NotificationSettingsWithSidebar({ viewer }) {
|
||||||
return (
|
return (
|
||||||
<main className="chrome-pane">
|
<main className="chrome-pane">
|
||||||
|
|||||||
@@ -632,6 +632,10 @@ export async function getPhilosophy() {
|
|||||||
return jsonOrThrow(await fetch('/api/philosophy'))
|
return jsonOrThrow(await fetch('/api/philosophy'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getDocs() {
|
||||||
|
return jsonOrThrow(await fetch('/api/docs'))
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Slice 7: admin neighborhood (§17 admin/* + user search for the §15.8 mute
|
// Slice 7: admin neighborhood (§17 admin/* + user search for the §15.8 mute
|
||||||
// typeahead).
|
// typeahead).
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// `/docs` — the user-facing guide.
|
||||||
|
//
|
||||||
|
// Sibling of Philosophy.jsx: same chrome, same data path, different
|
||||||
|
// source file. Renders DOCS.md verbatim with light chrome around it.
|
||||||
|
// Reachable anonymously, same as `/philosophy`, so a visitor can read
|
||||||
|
// the guide before deciding to sign in.
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import MarkdownPreview from './MarkdownPreview.jsx'
|
||||||
|
import { getDocs } from '../api.js'
|
||||||
|
|
||||||
|
export default function Docs({ authenticated }) {
|
||||||
|
const [body, setBody] = useState('')
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
getDocs()
|
||||||
|
.then(r => { if (active) setBody(r.body || '') })
|
||||||
|
.catch(e => { if (active) setError(e.message || String(e)) })
|
||||||
|
.finally(() => { if (active) setLoading(false) })
|
||||||
|
return () => { active = false }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="philosophy-page">
|
||||||
|
<header className="philosophy-header">
|
||||||
|
<button
|
||||||
|
className="philosophy-back"
|
||||||
|
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<span className="philosophy-title">User guide</span>
|
||||||
|
{!authenticated && (
|
||||||
|
<Link className="philosophy-signin" to="/">Home</Link>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
<article className="philosophy-body">
|
||||||
|
{loading && <p className="muted">Loading…</p>}
|
||||||
|
{error && <p className="error">Could not load the guide: {error}</p>}
|
||||||
|
{!loading && !error && (
|
||||||
|
<MarkdownPreview content={body} />
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user