diff --git a/packages/ui/README.md b/packages/ui/README.md
index 716f2a5..cbe803c 100644
--- a/packages/ui/README.md
+++ b/packages/ui/README.md
@@ -39,7 +39,7 @@ if a `node:*` import creeps back onto that path.
| `src/lib/units.ts` | Presentation over `timeline()`: a row's text, what quick find searches it by, its badges, its status disc, and the two cross-target capture relations no single target's index can see. Also the tip/open-request split — `requestState()` and the `isClaimed`/`isAssigned`/`isOpen`/`isAwaiting` predicates every list groups and counts by, because `UnitView.state` describes what LANDED and stays `judged` while a successor runs. |
| `src/lib/requests.ts`, `verdicts.ts`, `admin.ts` | What each surface may offer and what it writes, as pure functions: the ⊕ menu, the review form and its findings, space administration. Tested without rendering anything. |
| `src/lib/docs.ts` | The tiered doc viewer: which tier a unit is drawn in, the second drift signal beside `staleness()`, the roadmap tier's derived sections, and the two commands editing a page writes. A tier is a REGISTRY NAME and this is the only module that knows the two — `user-doc` and `roadmap-doc` are artifact types, so the feature adds no record type and every other project-scoped type falls into the dev tier with no code change. Two rules live here rather than in a component: an edit request always names its own author as assignee (an unassigned one is claimable by any operator's daemon), and a new version re-pins its sources to their current heads, which is the only thing that clears source drift. |
-| `src/routes/p/[project]/docs/`, `src/lib/components/DocReader.svelte`, `DocEditor.svelte` | The surface: tier navigation, a page index, and the reader — prose first, then the provenance that makes the page checkable, then the roadmap's road-ahead and road-travelled sections derived from live goal state. The editor is two ordinary `runCli` commands (`request create`, `artifact post`) through `write.ts`, so a private space gets doc editing through the substituted writer with no branch anywhere; a save that failed between them is offered as a resume rather than written twice. Where a tier's type is not registered the pane is a setup card, because `materialize()` drops a request no registry entry names — the entry is load-bearing, not decorative. |
+| `src/routes/p/[project]/docs/`, `src/lib/components/DocReader.svelte`, `DocEditor.svelte` | The surface: tier navigation, a page index, and the reader — prose first, then the provenance that makes the page checkable, then the roadmap's road-ahead and road-travelled sections derived from live goal state. The editor is two ordinary `runCli` commands (`request create`, `artifact post`) through `write.ts`, so a private space gets doc editing through the substituted writer with no branch anywhere; a save that failed between them is offered as a resume rather than written twice. A unit holds at most one open request, so Edit and “Finish it” are the SAME action — Edit continues the open request rather than forking a second one that would leave the first open forever — and the way out is a `request retract` tombstone, which is also how a member changes sources recovery cannot. Where a tier's type is not registered the pane is a setup card, because `materialize()` drops a request no registry entry names — the entry is load-bearing, not decorative. |
| `src/lib/labels.ts` | The reading side of goal labels: the argv a label editor writes (always `--set`, always the whole set — the record has no add or remove), the space's label vocabulary with counts (which IS the registry: there is none on-protocol), and a chip's hue as a pure function of its text. The normalization *rule* is `@radial/core`'s, shared with the sidecar so a label typed here and one typed at a shell cannot differ. |
| `src/lib/filters.ts`, `filters.svelte.ts` | Narrowing a goal list by label and state, and the URL round-trip that makes a narrowed list a link. Pure derivation — nothing here writes, and nothing reads prose: the predicate reads `GoalView.labels` and `GoalView.ended` and nothing else. Composed *with* quick find rather than replacing it, and the labels' own text joins the corpus `matches()` searches. |
| `src/lib/grouping.ts` | Arranging that same list once the filter has decided what is in it: one section per label, in the vocabulary's own order, with the unlabelled goals last. A goal stands under *every* label it carries — a set has no primary member for this module to invent one from — so the sections can hold more rows than the list, and each is counted where it stands. Grouping is not narrowing: it stays per-tab and out of the URL, because a `group` parameter would be one more thing `viewHref` has to reproduce exactly for the rail's active-view highlight to keep matching. |
diff --git a/packages/ui/src/lib/components/DocEditor.svelte b/packages/ui/src/lib/components/DocEditor.svelte
index f2fbc14..4503e84 100644
--- a/packages/ui/src/lib/components/DocEditor.svelte
+++ b/packages/ui/src/lib/components/DocEditor.svelte
@@ -165,7 +165,8 @@
Finishing an edit that was started and never posted. This writes the page against the request
that is already open, rather than opening a second one. Its sources are already recorded and
- cannot be changed while recovering this save.
+ cannot be changed while recovering this save — cancel and discard the unfinished edit if you
+ need different ones.
{/if}
{#if forked}
@@ -203,7 +204,7 @@
{#if resume}
These are the sources recorded on the existing request. Finish this save before making a new
- version with different sources.
+ version with different sources, or discard the unfinished edit and start it again.
{:else}
These travel as strongrefs on the request, pinned at the version shown. Re-pinning them is
what clears this page’s “sources moved on” badge, so tick what you have actually read.
diff --git a/packages/ui/src/routes/p/[project]/docs/+page.svelte b/packages/ui/src/routes/p/[project]/docs/+page.svelte
index 2c545a8..15b8a37 100644
--- a/packages/ui/src/routes/p/[project]/docs/+page.svelte
+++ b/packages/ui/src/routes/p/[project]/docs/+page.svelte
@@ -24,6 +24,7 @@
TIERS,
type TierId,
} from '$lib/docs.js'
+ import { retractArgs } from '$lib/requests.js'
import { currentSpace } from '$lib/session.svelte.js'
import { projectByName, systemHref } from '$lib/space.js'
import { toast } from '$lib/ui.svelte.js'
@@ -67,6 +68,46 @@
let editing = $state(undefined)
let settingUp = $state(false)
let setupError = $state('')
+ let discarding = $state(false)
+ let discardError = $state('')
+
+ /**
+ * Editing the page on screen — and it is the SAME action as "Finish it", because a unit has at
+ * most one open request (`timeline.ts` attaches the first successor and pushes any second one to
+ * `standalone`). An Edit that ignored `resumableHere` would write a second request for the same
+ * edit and leave the first standing: the abandoned one re-attaches as `unit.openRequest` on the
+ * next sync, so this hint comes back forever, the System page keeps a request nobody is answering,
+ * and the edit actually being written shows up as a second doc in the tier until it lands.
+ *
+ * The way OUT of a resume is `discard()` below, not a second request.
+ */
+ const edit = (): void => {
+ if (!current) return
+ editing = { unit: current, ...(resumableHere ? { resume: resumableHere } : {}) }
+ }
+
+ /**
+ * Withdraw the unfinished edit instead of finishing it — the protocol-backed exit from the resume,
+ * and what a member reaches for when the sources they want are not the ones the open request
+ * pinned (recovery cannot change those: they are already signed).
+ *
+ * A tombstone only counts from the request's own author or an active admin, and `resumableEdit`
+ * yields nothing but this reader's own requests, so the fold honours every retraction offered
+ * here. Retracting takes it out of `openRequests`, which is what clears the hint.
+ */
+ async function discard(): Promise {
+ if (!resumableHere || discarding) return
+ discarding = true
+ discardError = ''
+ try {
+ await write(retractArgs(resumableHere))
+ toast('Unfinished edit withdrawn — nothing was deleted')
+ } catch (failure) {
+ discardError = failure instanceof Error ? failure.message : String(failure)
+ } finally {
+ discarding = false
+ }
+ }
// Closing the editor whenever the reader navigates: an open form over a different document than
// the one the URL names is the classic way an edit lands on the wrong page.
@@ -74,6 +115,7 @@
void tier
void opened
editing = undefined
+ discardError = ''
})
async function setUp(): Promise {
@@ -198,19 +240,23 @@
{:else if current}
{#if resumableHere}
- You started an edit of this page and never posted it.
+ You started an edit of this page and never posted it. Editing continues that save rather
+ than opening a second request; its sources were fixed when the request was written.
+
+ disabled={!writable || discarding}
+ onclick={discard}
+ >{discarding ? 'Discarding…' : 'Discard it'}
diff --git a/packages/ui/src/routes/p/[project]/docs/docs-page.svelte.test.ts b/packages/ui/src/routes/p/[project]/docs/docs-page.svelte.test.ts
new file mode 100644
index 0000000..0d02435
--- /dev/null
+++ b/packages/ui/src/routes/p/[project]/docs/docs-page.svelte.test.ts
@@ -0,0 +1,206 @@
+// @vitest-environment jsdom
+import { COLLECTIONS, materialize, MemoryRecordStore, type StoredRecord } from '@radial/core'
+import { FIXTURE_DIDS, fixtureSpace } from '@radial/core/fixture'
+import { flushSync, mount, unmount } from 'svelte'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { buildDirectory } from '$lib/directory.js'
+import { docTitle, docsOf } from '$lib/docs.js'
+import { loadEditor } from '$lib/editor.js'
+import { projectByName, type Space } from '$lib/space.js'
+import Page from './+page.svelte'
+
+// The docs page, mounted, with an edit somebody started and never posted standing in the fold.
+//
+// The claim under test is a page-level one and cannot be made against either component alone: a unit
+// has at most ONE open request (`timeline.ts` attaches the first successor and pushes any second to
+// `standalone`), so the reader's Edit button and the resume hint's "Finish it" must be the same
+// action. An Edit that opened a fresh request would leave the abandoned one open forever — it
+// re-attaches on the next sync, so the hint never goes away, the System page keeps a request nobody
+// is answering, and the edit actually being written draws as a second doc until it lands.
+//
+// And because recovery cannot change the sources a signed request already pinned, the resume needs
+// an exit: discarding writes the `request retract` tombstone the fold honours from its own author.
+
+const writes = vi.hoisted(() => vi.fn())
+vi.mock('$lib/write.js', () => ({ write: writes, uploadImage: vi.fn() }))
+vi.mock('$lib/auth.svelte.js', () => ({
+ account: { status: 'signed-in', did: FIXTURE_DIDS.tim },
+}))
+vi.mock('$app/navigation', () => ({ goto: vi.fn() }))
+vi.mock('$app/state', () => ({
+ page: {
+ get params() {
+ return { project: 'radial-ng' }
+ },
+ get url() {
+ return url
+ },
+ },
+}))
+vi.mock('$lib/session.svelte.js', () => ({ currentSpace: () => space }))
+
+const RESUME_RKEY = '3lbs1dq0resume'
+const RESUME_URI = `at://${FIXTURE_DIDS.tim}/${COLLECTIONS.artifactRequest}/${RESUME_RKEY}`
+
+let url = new URL('http://127.0.0.1/p/radial-ng/docs?tier=user')
+let space: Space
+let host: HTMLDivElement
+let component: Record | undefined
+
+/**
+ * The fixture space, plus one open request of Tim's against the "Review queue" page with nothing
+ * posted against it: the half of a two-write save that survives a dropped connection. It is a real
+ * record through the real fold, so `resumableEdit` finds it the way it would in a browser.
+ *
+ * Its sources deliberately differ from the ones the landed version was written from — that is the
+ * state recovery has to be honest about, since they were signed when the request was written.
+ */
+function spaceWithUnfinishedEdit(): Space {
+ const fixture = fixtureSpace()
+ const store = new MemoryRecordStore()
+ for (const record of fixture.records) store.put(record)
+ const base = materialize(store, { spaceUri: fixture.spaceUri, asOf: fixture.asOf })
+ const project = projectByName(base, 'radial-ng')
+ if (!project) throw new Error('fixture project missing')
+ const docs = docsOf(base, project)
+ const reviewQueue = docs.user.find((unit) => docTitle(unit) === 'Review queue')
+ const conventions = docs.dev.find((unit) => unit.type === 'conventions')?.current?.artifact
+ const landed = reviewQueue?.current?.artifact
+ if (!landed || !conventions) throw new Error('fixture is missing the doc this test edits')
+
+ const open: StoredRecord = {
+ did: FIXTURE_DIDS.tim,
+ collection: COLLECTIONS.artifactRequest,
+ rkey: RESUME_RKEY,
+ uri: RESUME_URI,
+ cid: 'cid-resume',
+ rev: '9999999999999',
+ value: {
+ $type: COLLECTIONS.artifactRequest,
+ project: { uri: project.target.uri, cid: project.target.cid },
+ type: 'user-doc',
+ basedOn: [
+ { uri: landed.uri, cid: landed.cid },
+ { uri: conventions.uri, cid: conventions.cid },
+ ],
+ assignee: FIXTURE_DIDS.tim,
+ createdAt: '2026-07-26T12:40:00Z',
+ },
+ }
+ store.put(open)
+ const index = materialize(store, { spaceUri: fixture.spaceUri, asOf: fixture.asOf })
+ const withEdit = projectByName(index, 'radial-ng')
+ if (!withEdit) throw new Error('fixture project missing')
+ const unit = docsOf(index, withEdit).user.find((candidate) => docTitle(candidate) === 'Review queue')
+ if (unit?.openRequest?.uri !== RESUME_URI) throw new Error('the unfinished edit did not attach')
+ url = new URL(
+ `http://127.0.0.1/p/radial-ng/docs?tier=user&doc=${encodeURIComponent(unit.key)}`,
+ )
+ return {
+ uri: fixture.spaceUri,
+ index,
+ directory: buildDirectory(index, {}),
+ asOf: fixture.asOf,
+ fixture: false,
+ }
+}
+
+async function settle(): Promise {
+ await Promise.resolve()
+ await Promise.resolve()
+ await Promise.resolve()
+ flushSync()
+}
+
+const button = (label: string): HTMLButtonElement | undefined =>
+ [...host.querySelectorAll('button')].find((entry) => entry.textContent?.trim().startsWith(label))
+
+async function render(): Promise {
+ await loadEditor()
+ component = mount(Page, { target: host, props: {} }) as Record
+ await settle()
+}
+
+beforeEach(() => {
+ host = document.createElement('div')
+ document.body.append(host)
+ writes.mockReset()
+ writes.mockResolvedValue({
+ primary: { uri: `at://${FIXTURE_DIDS.tim}/com.disnetdev.radial.artifact/posted`, cid: 'cid-posted' },
+ refs: {},
+ })
+ space = spaceWithUnfinishedEdit()
+})
+
+afterEach(() => {
+ if (component) unmount(component)
+ component = undefined
+ host.remove()
+})
+
+describe('the docs page with an unfinished edit', () => {
+ it('continues the open request from Edit, rather than opening a second one', async () => {
+ await render()
+ expect(host.textContent).toContain('You started an edit of this page and never posted it')
+
+ // The reader's own button, not the hint's: the two entry points must not diverge.
+ button('Edit')?.click()
+ await settle()
+
+ // The editor came up recovering: the sources are the request's, and they are not up for change.
+ expect(host.textContent).toContain('Finishing an edit that was started and never posted')
+ const sources = [...host.querySelectorAll('.sources input')]
+ expect(sources.length).toBeGreaterThan(0)
+ expect(sources.every((input) => input.disabled)).toBe(true)
+
+ button('Save version')?.click()
+ await settle()
+ await Promise.resolve()
+
+ // One write, against the request that was already open. A second `request create` here is the
+ // fork this whole path exists to avoid.
+ expect(writes).toHaveBeenCalledTimes(1)
+ const [post] = writes.mock.calls[0] as [string[]]
+ expect(post.slice(0, 3)).toEqual(['artifact', 'post', '--request'])
+ expect(post[3]).toBe(`${RESUME_URI}#cid-resume`)
+ })
+
+ it('discards the unfinished edit with a retraction, which is the only way out of it', async () => {
+ await render()
+
+ button('Discard it')?.click()
+ await settle()
+
+ expect(writes).toHaveBeenCalledTimes(1)
+ const [retract] = writes.mock.calls[0] as [string[]]
+ expect(retract).toEqual(['request', 'retract', '--request', `${RESUME_URI}#cid-resume`])
+ })
+
+ it('opens a fresh request from Edit once nothing is left open', async () => {
+ // The same button on a page with no unfinished edit: the ordinary two-write save, unchanged.
+ space = { ...space, index: materializeWithout(space) }
+ await render()
+ expect(host.textContent).not.toContain('You started an edit of this page and never posted it')
+
+ button('Edit')?.click()
+ await settle()
+ expect(host.textContent).not.toContain('Finishing an edit that was started and never posted')
+
+ button('Save version')?.click()
+ await settle()
+ await Promise.resolve()
+
+ expect(writes).toHaveBeenCalledTimes(2)
+ const [request] = writes.mock.calls[0] as [string[]]
+ expect(request.slice(0, 2)).toEqual(['request', 'create'])
+ expect(request[request.indexOf('--assignee') + 1]).toBe(FIXTURE_DIDS.tim)
+ })
+})
+
+/** The same space with the unfinished edit retracted — what a discard leaves behind. */
+function materializeWithout(current: Space): Space['index'] {
+ const fixture = fixtureSpace()
+ const store = new MemoryRecordStore()
+ for (const record of fixture.records) store.put(record)
+ return materialize(store, { spaceUri: current.uri, asOf: fixture.asOf })
+}