diff --git a/docs/adr-tiered-doc-viewer.md b/docs/adr-tiered-doc-viewer.md new file mode 100644 index 0000000..c7b371c --- /dev/null +++ b/docs/adr-tiered-doc-viewer.md @@ -0,0 +1,113 @@ +# ADR — The tiered doc viewer is two registry types and a read-time derivation, not a documents subsystem + +*Status: accepted, as a design spike. Decided while implementing "design spike for tiered doc +viewer". Everything asserted below is covered by tests in this repository — +`packages/core/test/source-drift.test.mjs` (the derivations and the fold's untouchedness), +`packages/ui/src/lib/docs.test.ts` (tiers, the two drift signals, the argument vectors), and +`packages/ui/src/lib/components/DocEditor.svelte.test.ts` (the mounted edit flow).* + +--- + +## 1. Context + +The ask was a "view mode" of Radial with three tiers of project documentation: **dev docs** (the raw +project artifacts — ADRs, architecture, conventions), **user docs** (product documentation covering +the features, linked back to the artifacts they came from so a page can be told it has gone stale), +and **roadmap docs** (user-facing, linked back to goals). Members should be able to edit them in the +app. + +Almost every mechanism this needs already exists. Project-scoped artifact types collect under +`ProjectView.currentSystemArtifacts` (design §8); the artifact-type registry is data rather than code +(design §4); every artifact chains through `prev`, carries reviews pinned per version, and can anchor +a thread; `artifactRequest.basedOn` is an array of strongrefs that may pin any record by `uri#cid`; +`radial artifact post` is a first-class human write and the browser runs the identical `runCli()`. + +What did not exist: a reader-oriented surface (the System page is a work-tracking list), the two doc +tiers, a derivation that notices a page's SOURCES have moved on, an in-app editing flow for +artifacts, and any resolution of a doc's goal references to live goal state. + +## 2. Decision + +**Both editable tiers are entries in the artifact-type registry.** `user-doc` and `roadmap-doc` are +`scope: project, durability: living` — no lexicon change, no fold rule, no new record type. They +inherit versioning, reviews, threads, turn-bundle inclusion and the CLI unchanged. + +**Linkage is `basedOn` on the driving request, never parsed prose.** A user doc's request pins the +system artifacts it documents; a roadmap doc's pins goals. Anything mechanical reads the typed refs; +`at://` mentions inside a body stay presentation-only chips. + +**Staleness is two annotations, side by side and never summed.** `staleness()` already answers "the +CODE moved" — merges under the project since this version landed. `sourceDrift()` +(`core/src/timeline.ts`) answers "the SOURCES moved": for the current version's driving request, each +`basedOn` ref that resolves to an artifact whose chain now has a newer head, with how far behind it +is. Both are read-time derivations over records the fold already holds (design §8: staleness is +annotation, not automation) — nothing is blocked by either, and nothing updates itself. + +**Editing writes a request assigned to its own author, then posts the artifact.** Two ordinary +commands through the one write path. + +## 3. Consequences, and the three things worth knowing + +### 3.1 The registry entry is load-bearing, not a grouping + +The plan this spike was built from assumed a space with no doc types registered would show its doc +records under the dev tier. It would not: `materialize()` drops an `artifactRequest` whose `type` no +registry entry names — it cannot tell which scope the request anchors in — and the artifact answering +it goes with it. So before the types exist, a page written into the space folds into **nothing at +all**. + +That is why the viewer's empty state for tiers 2 and 3 is a *setup card* rather than an invitation to +write the first page, and why "Set up docs" is admin-gated: only an admin's registry record counts. +Registering the type later folds every such record back in, with nothing rewritten and no migration — +which is the one property that makes the ordering forgiving. + +### 3.2 The self-assignment on an edit request is a safety rule, not a nicety + +An **unassigned** open request is claimable by any operator's daemon (`dispatch.ts`), which is the +whole point of open requests. A member half-way through writing a page must therefore never leave one: +`editRequestArgs` always sends `--assignee `, and `dispatch.ts` refuses a +request whose assignee is not an active agent member, so a human-assigned request is dispatched by +nobody, anywhere. + +The same rule makes the two-write save recoverable rather than dangerous. A failure between +`request create` and `artifact post` leaves an open, self-assigned request that no daemon will answer; +the docs page detects it (`resumableEdit`) and offers to finish it, because writing a second request +for the same edit would fork the page's history over a dropped connection. + +Auto-review is deliberately left off for both types: a member fixing a typo must not dispatch a review +turn. Turning it on later is a supported setting, not a code change. + +### 3.3 Re-pinning the sources IS the update + +A new version pins its predecessor as `prev` and re-pins every source at the CURRENT head of its +chain (`repinnedSources`). Carrying the old refs forward would leave a page badged as drifted +immediately after somebody had gone and updated it, which teaches readers to ignore the badge. A ref +the fold cannot resolve — a goal, or a record from a repo this observer has not read — is carried +through untouched: it is the author's stated provenance, and dropping it because this observer cannot +see it would quietly rewrite what the page is about. + +`sourceDrift()` resolves a ref by URI rather than by `uri#cid`, and excludes any ref naming the unit's +own versions. The first is because the store keeps the earliest CID it saw, so an in-place edit of a +source is an edit annotation (design §7) and not a superseding version. The second is because an edit +request names the version it revises — that is what attaches it to the chain — and a page that had +drifted away from itself would badge every second version. + +### 3.4 Concurrent edits fork, and the answer is a sentence rather than a merge + +Two members editing one page in the same ingestion window both post `prev = v3`. The fold resolves a +deterministic head and loses nothing — both records are in the version rail — but one edit stops being +what a reader sees. The form re-reads the head it was opened on and says so (`headMoved`). It does not +refuse the save, and Radial does not grow a merge: this is the same latest-wins trade `labelGoal` +already accepts. + +## 4. What this deliberately does not do + +- **No standalone docs sites.** The thread that prompted this imagined `dev.`/`docs.`/`roadmap.` + subdomains with userinput-request footnotes. The model here supports that — `basedOn` provenance is + exactly the hook — but the spike delivers the view mode inside the existing SPA. +- **No automated doc updates.** A drifted page is annotated and never regenerated. That is design §8's + third surface, and it is generation-causing: it needs an iteration bound before it exists. +- **No second write path.** Editing runs `runCli()` through `write.ts` like everything else, so a + private space gets doc editing through the substituted writer with no branch in any file. +- **No change to the System page.** It stays the work-tracking list of every project-scoped record, + doc tiers included. Docs is the reading surface over the same records, not a replacement for it. diff --git a/packages/core/src/fixture.ts b/packages/core/src/fixture.ts index 343b2f9..1fb7903 100644 --- a/packages/core/src/fixture.ts +++ b/packages/core/src/fixture.ts @@ -230,6 +230,27 @@ export function fixtureSpace(): FixtureSpace { '2026-06-01T09:14:00Z', 'living', ) + // The two doc tiers, and they are nothing but two more registry entries — which is the whole + // design decision of the tiered doc viewer, standing where a reader can see it. The briefs are + // written for an AGENT, because a brief is the turn prompt when an update is ever asked of one. + type( + '3lba2typ0007', + 'user-doc', + 'project', + 'Write the user-facing page for one feature, in the register of product documentation: what it does for a reader, what they see, and what it costs them. The records this is based on are the ground truth — distil them, never restate them, and never mention a record type or an implementation detail a user cannot see.', + 'A user-facing feature page', + '2026-07-26T09:00:00Z', + 'living', + ) + type( + '3lba2typ0008', + 'roadmap-doc', + 'project', + 'Write the community-facing page for one epic. Say where the work has got to and where it is going, in a reader’s words. The goals this is based on supply the road ahead and the road travelled; write the part a list of goals cannot say.', + 'A community-facing roadmap page', + '2026-07-26T09:01:00Z', + 'living', + ) const radialNg = put(tim, '3lba3prj0001', { $type: COLLECTIONS.project, @@ -1311,7 +1332,7 @@ export function fixtureSpace(): FixtureSpace { [{ uri: adr0004.uri, cid: adr0004.cid }], ) - sysVersion( + const adr0005 = sysVersion( '3lbd3kk4ww31f', '3lbd4ll5xx31g', 'adr', @@ -1321,7 +1342,7 @@ export function fixtureSpace(): FixtureSpace { '2026-07-02T14:02:00Z', '2026-07-02T14:20:00Z', ) - sysVersion( + const adr0003 = sysVersion( '3lbc9mm6yy28h', '3lbca0n7zz28j', 'adr', @@ -1332,6 +1353,104 @@ export function fixtureSpace(): FixtureSpace { '2026-06-28T11:10:00Z', ) + // ── the doc tiers (design spike) ──────────────────────────────────────────────────────────── + // Tier 1 is everything above, verbatim. These are tiers 2 and 3, and they are the same records as + // tier 1 — a project-scoped request and the artifact answering it — differing only in the registry + // name they carry and in what their request's `basedOn` pins. + // + // The linkage is the point. A user doc names the system artifacts it documents; a roadmap doc + // names goals. Both are strongrefs on a signed request, so "documents architecture at v1" is a + // fact rather than a sentence somebody wrote in prose, which is what lets `sourceDrift()` say the + // doc is two versions behind its source without anything having to parse a body. + // + // Authored by a HUMAN, request and artifact alike, and the request is assigned to its own author: + // that is exactly what editing a doc in the UI writes, and an unassigned open request is one any + // operator's daemon may claim (`dispatch.ts`), so a half-finished edit must never look open. + // + // Read by a second member, not by an agent. Auto-review is deliberately off for both doc types — + // a member fixing a typo must not dispatch a review turn — but a wrong page is expensive to leave + // standing, so the demo shows the same verdict machinery the rest of the space uses, written by a + // person. Nothing about the record is different; only who signed it. + const doc = ( + author: string, + rkeyRequest: string, + rkeyArtifact: string, + typeName: string, + title: string, + body: string, + at: string, + verdictAt: string, + basedOn: StrongRef[], + prev?: StoredRecord, + ): StoredRecord => { + const req = request({ + author, + rkey: rkeyRequest, + anchor: ngRef, + type: typeName, + assignee: author, + at, + basedOn, + }) + const art = artifact({ + author, + rkey: rkeyArtifact, + request: req, + anchor: ngRef, + type: typeName, + title, + body, + at, + ...(prev ? { prev } : {}), + }) + review(author === ana ? tim : ana, `rev-${rkeyArtifact}`, art, 'approve', verdictAt) + return art + } + + // Drifted on purpose: written against architecture v1, and the architecture is on v3. The doc + // page says "documents Architecture v1 · now at v3" and the obvious click is to update it. + doc( + tim, + '3lbs1dq0aa80a', + '3lbs1dc1bb80b', + 'user-doc', + 'Review queue', + 'Everything waiting on your judgement, on one page.\n\n## What it is\n\nWhen an agent finishes a piece of work, somebody has to say whether it is right. The review queue is the list of those — every version pinned to you for a verdict, across every goal and every project in the space.\n\n## Approving, and asking for changes\n\nA verdict is either an approval or a request for changes, and it can carry findings: a file, a line, a severity, and what you want done. Findings travel with the verdict, so the agent writing the next version reads them in its brief.\n\n## What a verdict does not do\n\nNothing is blocked by one. Asking for changes on a plan does not stop anybody building on that plan, and approving one does not authorise anything to run. A verdict is information for the next person, and every generation has a human click behind it either way.', + '2026-07-26T10:30:00Z', + '2026-07-26T11:02:00Z', + [{ uri: arch1.uri, cid: arch1.cid }, { uri: adr0003.uri, cid: adr0003.cid }], + ) + // Current with both its sources: no drift badge, which is what the badge means. + doc( + tim, + '3lbs2eq2cc80c', + '3lbs2ec3dd80d', + 'user-doc', + 'Artifact types', + 'What your space can ask for, and how to add to it.\n\n## Types are settings, not code\n\nA space starts out able to ask for a plan and an implementation. Adding "security review" is a setting an admin changes: name it, write the brief an agent will be given, say what it produces, and it appears on every goal from that moment. Nothing is deployed and nothing is upgraded.\n\n## Where a type can be asked for\n\nA type belongs to a goal or to a project. A goal type answers a piece of work — a plan, an implementation. A project type outlives every goal under it — an architecture document, a decision record, this page.\n\n## Living and append-only\n\nA living type is revised in place: the architecture document has one page and many versions. An append-only type is never edited, only superseded — decision record 0008 replaces 0004 and both stay readable, because the reason a decision changed is part of the record.', + '2026-07-26T11:15:00Z', + '2026-07-26T11:48:00Z', + [{ uri: adr0005.uri, cid: adr0005.cid }, { uri: arch3.uri, cid: arch3.cid }], + ) + // The roadmap tier: its sources are GOALS, so the page's road-ahead and road-travelled sections + // are derived from live goal state rather than authored — a goal closing moves itself between them. + doc( + tim, + '3lbs3fq4ee80e', + '3lbs3fc5ff80f', + 'roadmap-doc', + 'Ingestion', + 'How records get from somebody’s repo into your view of the space, and what we are doing about it next.\n\nRadial has no server in the middle. Every participant reads the same records from each member’s own repository and folds them into the same view, which means "has it arrived yet" is a real question with a real answer — and that answer is what this epic is about.', + '2026-07-26T12:00:00Z', + '2026-07-26T12:35:00Z', + [ + { uri: g4.uri, cid: g4.cid }, + { uri: g5.uri, cid: g5.cid }, + { uri: g9.uri, cid: g9.cid }, + { uri: arch3.uri, cid: arch3.cid }, + ], + ) + return { records, spaceUri: space.uri, diff --git a/packages/core/src/timeline.ts b/packages/core/src/timeline.ts index d5524b0..87d7fed 100644 --- a/packages/core/src/timeline.ts +++ b/packages/core/src/timeline.ts @@ -423,3 +423,120 @@ export function staleness(index: MaterializedIndex, projectUri: string, unit: Un if (!unit.current || durabilityOf(index, unit.type) === 'append-only') return 0 return mergesSince(index, projectUri, unit.current.artifact.value.createdAt) } + +// ── source drift ──────────────────────────────────────────────────────────────────────────────── +// +// The second drift signal, and the one a documentation tier needs. `staleness()` above answers "the +// CODE moved" — merges under the project since this landed. This answers "the SOURCES moved": a user +// doc distilled from ADR 0007 and the architecture document is out of date the moment either of them +// is superseded, however quiet the repository has been. +// +// It is a read-time annotation over records the fold already holds, exactly like `staleness()` and +// for the same reason (design §8: staleness is annotation, never automation). `materialize()` does +// not change, nothing is blocked by a drifted document, and nothing updates itself because of one. +// +// The linkage it reads is TYPED and never prose: `artifactRequest.basedOn` is an array of strongrefs +// pinning `uri#cid`, so "documents ADR at v2" is a fact a signed record states, not one parsed out of +// a body. An `at://` mention inside a doc stays presentation-only. + +/** A landed version, and the unit it belongs to — what a strongref into this space resolves to. */ +export interface FoundVersion { + unit: UnitView + version: UnitVersion +} + +const versionLookups = new WeakMap>() + +/** + * Every landed version in the space, keyed by artifact URI. + * + * Keyed by URI rather than by `uri#cid` on purpose: a strongref pins the exact bytes it read, and an + * artifact whose author edited it in place keeps the CID the store first saw (design §7 — an in-place + * edit is flagged, not adopted). Resolving by URI therefore finds the record the ref names in both + * cases, and how far behind the ref is stays a question about the CHAIN, which is what a version + * really is. + */ +function versionsByUri(index: MaterializedIndex): Map { + const cached = versionLookups.get(index) + if (cached) return cached + const found = new Map() + for (const unit of allUnits(index)) { + for (const version of unit.versions) found.set(version.artifact.uri, { unit, version }) + } + versionLookups.set(index, found) + return found +} + +/** The unit and version a strongref names, or nothing when it names no artifact this fold holds. */ +export function findVersionByRef( + index: MaterializedIndex, + reference: Pick, +): FoundVersion | undefined { + return versionsByUri(index).get(reference.uri) +} + +/** One source a document was written from, and how far it has moved on since. */ +export interface SourceDrift { + /** The ref the document's driving request pinned. */ + ref: StrongRef + /** The unit that ref lands in, as the fold holds it now. */ + unit: UnitView + /** The version the ref pinned: what the document was written against. */ + pinned: UnitVersion + /** That chain's newest version: what it says today. */ + current: UnitVersion + /** Versions landed since the pinned one. Always ≥ 1 — a ref at the head is not drift and is absent. */ + behind: number +} + +/** + * The sources this document's current version was written from that have since been superseded. + * + * Read off the DRIVING REQUEST of the current version, because that is the record in which somebody + * stated what they were documenting. A ref pinning the head of its chain contributes nothing (the + * document is current with it), a ref naming this unit's own predecessor is skipped (that is the + * chain, not a source), and a ref naming a goal or a record this fold has never seen is skipped here + * — goals are `sourceGoals()` below, and a dangling ref is not evidence of drift. + * + * Ordered as the request pinned them, so the reader sees the sources in the order the author named + * them rather than in one this function invented. + */ +export function sourceDrift(index: MaterializedIndex, unit: UnitView): SourceDrift[] { + const request = unit.current?.request + if (!request) return [] + const drifted: SourceDrift[] = [] + const seen = new Set() + for (const reference of request.value.basedOn) { + if (seen.has(reference.uri)) continue + seen.add(reference.uri) + const found = findVersionByRef(index, reference) + if (!found || found.unit.key === unit.key) continue + const current = found.unit.current + if (!current) continue + const behind = current.version - found.version.version + if (behind <= 0) continue + drifted.push({ ref: reference, unit: found.unit, pinned: found.version, current, behind }) + } + return drifted +} + +/** + * The goals this document's current version was written against. + * + * Matched on the ref's URI alone, and the pinned CID is deliberately provenance rather than a match + * requirement: a goal's primary record is immutable, and everything a roadmap wants to say about one + * — whether it has ended, how it ended, what has merged under it — lives in the overlay records the + * `GoalView` already resolves. Pinning by CID and then rendering the pinned bytes would show a + * roadmap the state of the world on the day it was written. + */ +export function sourceGoals(index: MaterializedIndex, unit: UnitView): GoalView[] { + const request = unit.current?.request ?? unit.openRequest + if (!request) return [] + const byUri = new Map(index.goals.map((goal) => [goal.target.uri, goal])) + const goals: GoalView[] = [] + for (const reference of request.value.basedOn) { + const goal = byUri.get(reference.uri) + if (goal && !goals.includes(goal)) goals.push(goal) + } + return goals +} diff --git a/packages/core/test/fixture.test.mjs b/packages/core/test/fixture.test.mjs index 28f7b88..4c80a82 100644 --- a/packages/core/test/fixture.test.mjs +++ b/packages/core/test/fixture.test.mjs @@ -55,6 +55,9 @@ describe('the comp, as records', () => { ['architecture', 'project'], ['conventions', 'project'], ['security-review', 'goal'], + // The doc viewer's two editable tiers, and the only thing the feature adds to the registry. + ['user-doc', 'project'], + ['roadmap-doc', 'project'], ], ) // Living vs append-only is registry data, not a rule about the name "adr". @@ -241,9 +244,9 @@ describe('the comp, as records', () => { const units = timeline(view, fixture.projects.radialNg) const project = view.projects.find((candidate) => candidate.target.uri === fixture.projects.radialNg) - // Six documents, not the nine artifacts they are made of. Before `prev` was generalized this - // would have been nine rows — the §3.2 bug the comp surfaced. - assert.equal(units.length, 6) + // Nine documents, not the twelve artifacts they are made of. Before `prev` was generalized this + // would have been a row per artifact — the §3.2 bug the comp surfaced. + assert.equal(units.length, 9) // Oldest first, by the request that started each document. assert.deepEqual( units.map((unit) => [unit.type, unit.versions.length]), @@ -254,15 +257,26 @@ describe('the comp, as records', () => { ['adr', 1], // 0006 (Jul 02) ['conventions', 2], // Jul 05 ['adr', 1], // 0007 (Jul 19), captured from the possession plan + // The doc tiers. Ordinary project-scoped units on ordinary project-scoped types — which is + // the whole claim of the tiered viewer, visible here as three more rows and nothing else. + ['user-doc', 1], // Review queue (Jul 26), written against architecture v1 + ['user-doc', 1], // Artifact types (Jul 26), current with both its sources + ['roadmap-doc', 1], // Ingestion (Jul 26), written against three goals ], ) - assert.equal(project.currentSystemArtifacts.length, 6) + assert.equal(project.currentSystemArtifacts.length, 9) const architecture = units.find((unit) => unit.type === 'architecture') assert.match(architecture.current.artifact.value.body, /four moving parts/) // The living/append-only split the System view groups on. const living = units.filter((unit) => durabilityOf(view, unit.type) === 'living') - assert.deepEqual(living.map((unit) => unit.type).sort(), ['architecture', 'conventions']) + assert.deepEqual(living.map((unit) => unit.type).sort(), [ + 'architecture', + 'conventions', + 'roadmap-doc', + 'user-doc', + 'user-doc', + ]) // Drift, computed from state the index already holds: three implementations merged since the // current architecture landed. An append-only record is superseded, never revised, so it is 0. diff --git a/packages/core/test/scenario.ts b/packages/core/test/scenario.ts index 367a5c6..86fe98c 100644 --- a/packages/core/test/scenario.ts +++ b/packages/core/test/scenario.ts @@ -1551,3 +1551,240 @@ export function privateScenario(): PrivateScenario { liveHumanDeviceKeyId: 'human-browser-2', } } + +// ── the docs scenario ─────────────────────────────────────────────────────────────────────────── +// +// A third fixture rather than more records in the golden one, and for the same reason private mode +// got its own: the most valuable thing the golden scenario says about the doc tiers is that NOTHING +// changed there. No record type is added by this feature — both editable tiers are registry +// artifact types — so the golden digest must be byte-identical with the tiers in the world. +// +// What this space holds is the shape `sourceDrift()` and `sourceGoals()` are read over: +// +// - `architecture`, superseded twice (v1 → v2 → v3), and an append-only `adr` on one version. +// - `user-doc` "Notifications", whose driving request pinned architecture v1 and the ADR head: +// drifted by two on one source and current with the other. +// - `user-doc` "Search", pinned at the heads of both: no drift at all. +// - `user-doc` "Imports", pinned to a ref no fold holds and to a goal: neither is drift. +// - `roadmap-doc` "Notifications roadmap", based on one live goal and one ended one. +// - A second `user-doc` version, so the drift read is the CURRENT version's and not v1's. + +const DOCS_HUMAN = 'did:plc:docshuman' +const DOCS_AGENT = 'did:plc:docsagent' + +export interface DocsScenario { + records: StoredRecord[] + spaceUri: string + projectUri: string + /** Live: the roadmap's "road ahead". */ + liveGoalUri: string + /** Ended: the roadmap's "road travelled". */ + endedGoalUri: string + /** Chain root of the architecture document, superseded twice. */ + architectureUri: string + /** Chain root of the ADR, on its only version. */ + adrUri: string + /** Chain root of the notifications user doc — v2 pins architecture v1, so it is two behind. */ + notificationsUri: string + /** Chain root of the search user doc: every source pinned at its head. */ + searchUri: string + /** Chain root of the imports user doc: a dangling ref and a goal ref, neither of them drift. */ + importsUri: string + /** Chain root of the roadmap doc. */ + roadmapUri: string + humanDid: string + agentDid: string +} + +/** The three tiers with something in each, and every drift case a doc page has to draw. */ +export function docsScenario(): DocsScenario { + const records: StoredRecord[] = [] + let revision = 0 + const add = (record: T): T => { + records.push(record) + return record + } + const put = (did: string, rkey: string, value: RadialRecord): StoredRecord => { + revision += 1 + return add(stored(did, rkey, value, revision)) + } + + const space = put(DOCS_HUMAN, 'space', { + $type: COLLECTIONS.space, + name: 'Docs', + description: 'The tiered doc viewer fixture', + createdAt: '2026-01-01T00:00:00Z', + }) + for (const [position, did] of [DOCS_HUMAN, DOCS_AGENT].entries()) { + put(DOCS_HUMAN, `member-${position}`, { + $type: COLLECTIONS.addMember, + space: ref(space), + did, + kind: did === DOCS_AGENT ? 'agent' : 'human', + role: did === DOCS_AGENT ? 'agent' : 'admin', + createdAt: `2026-01-01T00:00:0${position + 1}Z`, + }) + } + + const type = ( + name: string, + scope: 'goal' | 'project', + durability: 'living' | 'append-only', + at: string, + ): StoredRecord => + put(DOCS_HUMAN, `type-${name}`, { + $type: COLLECTIONS.artifactType, + space: ref(space), + name, + brief: `Produce a ${name}.`, + outputSpec: { format: 'markdown', description: `A ${name}` }, + scope, + durability, + createdAt: at, + }) + type('plan', 'goal', 'living', '2026-01-01T00:01:00Z') + type('implementation', 'goal', 'living', '2026-01-01T00:01:01Z') + type('architecture', 'project', 'living', '2026-01-01T00:01:02Z') + type('adr', 'project', 'append-only', '2026-01-01T00:01:03Z') + // The two the viewer's editable tiers are: ordinary registry entries, written by an admin, which + // is the whole of what "add a doc tier" costs. + type('user-doc', 'project', 'living', '2026-01-01T00:01:04Z') + type('roadmap-doc', 'project', 'living', '2026-01-01T00:01:05Z') + + const project = put(DOCS_HUMAN, 'project', { + $type: COLLECTIONS.project, + space: ref(space), + name: 'radial', + gitUrl: 'https://tangled.org/disnetdev.com/radial', + defaultBranch: 'main', + checks: [], + autoReview: {}, + createdAt: '2026-01-01T00:02:00Z', + }) + + const goal = (rkey: string, title: string, at: string): StoredRecord => + put(DOCS_HUMAN, rkey, { + $type: COLLECTIONS.goal, + space: ref(space), + project: ref(project), + title, + body: `${title}, as a goal.`, + createdAt: at, + }) + const liveGoal = goal('goal-push', 'Mobile push notifications', '2026-02-01T09:00:00Z') + const endedGoal = goal('goal-unread', 'Unread tracking', '2026-01-10T09:00:00Z') + put(DOCS_HUMAN, 'close-unread', { + $type: COLLECTIONS.closeGoal, + goal: ref(endedGoal), + closed: true, + disposition: 'shipped', + createdAt: '2026-01-20T17:00:00Z', + }) + + /** One version of a project-scoped document: the request that asked for it, then the artifact. */ + let sequence = 0 + const version = ( + typeName: string, + title: string, + body: string, + at: string, + options: { prev?: StoredRecord; basedOn?: StrongRef[] } = {}, + ): StoredRecord => { + sequence += 1 + const request = put(DOCS_HUMAN, `req-${sequence}`, { + $type: COLLECTIONS.artifactRequest, + project: ref(project), + type: typeName, + basedOn: options.basedOn ?? [], + assignee: DOCS_AGENT, + createdAt: at, + }) + return put(DOCS_AGENT, `art-${sequence}`, { + $type: COLLECTIONS.artifact, + request: ref(request), + project: ref(project), + type: typeName, + ...(options.prev ? { prev: ref(options.prev) } : {}), + title, + body, + links: {}, + createdAt: at, + }) + } + + // ── tier 1: the dev docs, which are the system artifacts verbatim ───────────────────────────── + const arch1 = version('architecture', 'Architecture v1', 'One process.', '2026-01-05T10:00:00Z') + const arch2 = version('architecture', 'Architecture v2', 'Two processes.', '2026-02-05T10:00:00Z', { + prev: arch1, + basedOn: [ref(arch1)], + }) + const arch3 = version('architecture', 'Architecture v3', 'Four processes.', '2026-03-05T10:00:00Z', { + prev: arch2, + basedOn: [ref(arch2)], + }) + const adr = version('adr', '0001 · Notifications are records', 'Status: accepted', '2026-01-06T10:00:00Z') + + // ── tier 2: user docs, each pinning its sources by strongref ────────────────────────────────── + // Notifications was written against architecture v1, revised once against v1 again, and the + // architecture has moved twice since. Two behind, and the ADR it also names is still current. + const notif1 = version( + 'user-doc', + 'Notifications', + 'Radial tells you when a turn lands.', + '2026-01-07T10:00:00Z', + { basedOn: [ref(arch1), ref(adr)] }, + ) + version( + 'user-doc', + 'Notifications', + 'Radial tells you when a turn lands, and when a review is owed.', + '2026-01-20T10:00:00Z', + // The predecessor is named too — that is what attaches an edit request to the chain it revises, + // and it must never read as a source that has drifted away from itself. + { prev: notif1, basedOn: [ref(notif1), ref(arch1), ref(adr)] }, + ) + const search = version( + 'user-doc', + 'Search', + 'Every list filters on the same query.', + '2026-03-06T10:00:00Z', + { basedOn: [ref(arch3), ref(adr)] }, + ) + const imports = version( + 'user-doc', + 'Imports', + 'Bring somebody else’s words in.', + '2026-03-07T10:00:00Z', + { + basedOn: [ + { uri: 'at://did:plc:nobody/com.disnetdev.radial.artifact/gone', cid: 'cid-gone' }, + ref(liveGoal), + ], + }, + ) + + // ── tier 3: a roadmap doc, whose sources are goals rather than artifacts ────────────────────── + const roadmap = version( + 'roadmap-doc', + 'Notifications', + 'Where notifications have got to, and where they are going.', + '2026-03-08T10:00:00Z', + { basedOn: [ref(liveGoal), ref(endedGoal), ref(arch3)] }, + ) + + return { + records, + spaceUri: space.uri, + projectUri: project.uri, + liveGoalUri: liveGoal.uri, + endedGoalUri: endedGoal.uri, + architectureUri: arch1.uri, + adrUri: adr.uri, + notificationsUri: notif1.uri, + searchUri: search.uri, + importsUri: imports.uri, + roadmapUri: roadmap.uri, + humanDid: DOCS_HUMAN, + agentDid: DOCS_AGENT, + } +} diff --git a/packages/core/test/source-drift.test.mjs b/packages/core/test/source-drift.test.mjs new file mode 100644 index 0000000..b874302 --- /dev/null +++ b/packages/core/test/source-drift.test.mjs @@ -0,0 +1,202 @@ +// The doc viewer's second drift signal, and the promise that it cost the fold nothing. +// +// `sourceDrift()` and `sourceGoals()` are read-time derivations over records `materialize()` already +// holds — the same shelf `staleness()` and `mergesSince()` sit on (design §8: staleness is +// annotation, not automation). Nothing here writes a rule into the fold, which is why the last +// describe block asserts the golden scenario's digest is untouched: a feature that adds no record +// type must be provably invisible to the index. + +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { materialize } from '../dist/materializer.js' +import { MemoryRecordStore } from '../dist/store.js' +import { digestDifferences, indexDigest } from '../dist/digest.js' +import { findVersionByRef, sourceDrift, sourceGoals, timeline } from '../dist/timeline.js' +import { docsScenario, goldenScenario } from '../dist/test/scenario.js' + +const AS_OF = '2026-06-01T00:00:00Z' + +function build(records, spaceUri) { + const store = new MemoryRecordStore() + for (const record of records) store.put(record) + return materialize(store, { spaceUri, asOf: AS_OF }) +} + +function shuffled(records, seed) { + const result = [...records] + let state = seed >>> 0 + const random = () => { + state ^= state << 13 + state ^= state >>> 17 + state ^= state << 5 + return state >>> 0 + } + for (let index = result.length - 1; index > 0; index -= 1) { + const target = random() % (index + 1) + ;[result[index], result[target]] = [result[target], result[index]] + } + return result +} + +const docs = () => { + const scenario = docsScenario() + const index = build(scenario.records, scenario.spaceUri) + const units = timeline(index, scenario.projectUri) + const unit = (key) => { + const found = units.find((candidate) => candidate.key === key) + assert.ok(found, `no unit keyed ${key}`) + return found + } + return { scenario, index, units, unit } +} + +describe('the doc tiers are registry types over the existing fold', () => { + it('collects user and roadmap docs as ordinary project-scoped units', () => { + const { units } = docs() + const byType = {} + for (const unit of units) byType[unit.type] = (byType[unit.type] ?? 0) + 1 + assert.deepEqual(byType, { architecture: 1, adr: 1, 'user-doc': 3, 'roadmap-doc': 1 }) + }) + + it('revises a user doc through the ordinary prev chain', () => { + const { scenario, unit } = docs() + const notifications = unit(scenario.notificationsUri) + assert.equal(notifications.versions.length, 2) + assert.equal(notifications.current.version, 2) + }) +}) + +describe('sourceDrift', () => { + it('reports a superseded source with how far behind the document is', () => { + const { scenario, index, unit } = docs() + const drift = sourceDrift(index, unit(scenario.notificationsUri)) + assert.equal(drift.length, 1) + const [architecture] = drift + assert.equal(architecture.unit.key, scenario.architectureUri) + assert.equal(architecture.pinned.version, 1) + assert.equal(architecture.current.version, 3) + assert.equal(architecture.behind, 2) + // The ref is the one the request actually pinned, so a doc page can name the exact bytes read. + assert.equal(architecture.ref.uri, architecture.pinned.artifact.uri) + }) + + it('is silent when every source is pinned at its chain head', () => { + const { scenario, index, unit } = docs() + assert.deepEqual(sourceDrift(index, unit(scenario.searchUri)), []) + }) + + it('never counts the document’s own predecessor as a drifted source', () => { + // A doc's edit request names the version it revises — that is what attaches it to the chain — + // and a unit that had drifted away from itself would badge every second version. + const { scenario, index, unit } = docs() + const notifications = unit(scenario.notificationsUri) + const named = notifications.current.request.value.basedOn.map((reference) => reference.uri) + assert.ok(named.includes(notifications.versions[0].artifact.uri)) + assert.deepEqual( + sourceDrift(index, notifications).map((entry) => entry.unit.key), + [scenario.architectureUri], + ) + }) + + it('skips a goal ref and a ref this fold has never seen', () => { + const { scenario, index, unit } = docs() + assert.deepEqual(sourceDrift(index, unit(scenario.importsUri)), []) + }) + + it('reads the CURRENT version’s request, not the one that started the chain', () => { + const { scenario, index, unit } = docs() + const notifications = unit(scenario.notificationsUri) + // v1 named architecture v1 and nothing else new; v2 named it again. Both are two behind now, so + // what proves the read is the current one is that the predecessor ref v2 added is excluded above + // and the count still comes to exactly one source. + assert.equal(sourceDrift(index, notifications).length, 1) + // A unit with nothing landed has no current version and therefore no sources to have drifted. + const nothingLanded = { ...notifications, current: undefined, versions: [] } + assert.deepEqual(sourceDrift(index, nothingLanded), []) + }) + + it('resolves a ref by URI, so an in-place edit of a source is not drift', () => { + // The store keeps the earliest CID it saw, so an edited artifact's ref no longer matches by + // `uri#cid`. That is an edit annotation (design §7), not a superseding version. + const { scenario, index, unit } = docs() + const architecture = unit(scenario.architectureUri) + const found = findVersionByRef(index, { + uri: architecture.versions[0].artifact.uri, + cid: 'cid-somebody-edited-it', + }) + assert.equal(found?.version.version, 1) + }) + + it('is stable under every arrival order', () => { + const scenario = docsScenario() + for (const seed of [1, 2, 3, 5, 8, 13, 21]) { + const index = build(shuffled(scenario.records, seed), scenario.spaceUri) + const units = timeline(index, scenario.projectUri) + const notifications = units.find((unit) => unit.key === scenario.notificationsUri) + assert.deepEqual( + sourceDrift(index, notifications).map((entry) => [entry.unit.key, entry.behind]), + [[scenario.architectureUri, 2]], + `seed ${seed}`, + ) + } + }) +}) + +describe('sourceGoals', () => { + it('resolves a roadmap doc’s goal refs to live goal state, in the order pinned', () => { + const { scenario, index, unit } = docs() + const goals = sourceGoals(index, unit(scenario.roadmapUri)) + assert.deepEqual( + goals.map((goal) => [goal.target.uri, goal.ended]), + [ + [scenario.liveGoalUri, false], + [scenario.endedGoalUri, true], + ], + ) + // The ending overlay is what the roadmap draws from — the goal record itself never said it. + assert.equal(goals[1].disposition, 'shipped') + }) + + it('ignores refs that name anything but a goal', () => { + const { scenario, index, unit } = docs() + assert.deepEqual(sourceGoals(index, unit(scenario.searchUri)), []) + }) +}) + +describe('the fold is untouched', () => { + it('writes no record type the golden scenario does not already carry', () => { + // "Zero lexicon changes" as an assertion rather than as a sentence in a plan: every record a doc + // tier is made of is one this protocol already had, which is what makes the additive-only + // lexicon invariant safe by construction here. + const golden = new Set(goldenScenario().records.map((record) => record.collection)) + for (const record of docsScenario().records) { + assert.ok(golden.has(record.collection), `${record.collection} is new to this feature`) + } + }) + + it('makes the registry entry load-bearing: an unregistered type folds into nothing', () => { + // Worth pinning because it is stronger than the plan assumed, and the viewer's empty state has + // to say so. `materialize()` drops a request whose `type` no registry entry names (it cannot + // even tell which scope it belongs to), and the artifact answering it goes with it. So a space + // whose admin never ran "Set up docs" does not show the doc records under some other tier — it + // does not hold them at all, and registering the type later folds them back in with no rewrite. + const scenario = docsScenario() + const withTypes = build(scenario.records, scenario.spaceUri) + const without = build( + scenario.records.filter( + (record) => + record.collection !== 'com.disnetdev.radial.artifactType' || + !['user-doc', 'roadmap-doc'].includes(record.value.name), + ), + scenario.spaceUri, + ) + assert.deepEqual(digestDifferences(indexDigest(withTypes), indexDigest(without)), [ + 'chains', + 'records', + ]) + assert.deepEqual( + [...new Set(timeline(without, scenario.projectUri).map((unit) => unit.type))].sort(), + ['adr', 'architecture'], + ) + }) +}) diff --git a/packages/ui/README.md b/packages/ui/README.md index 6bb9762..716f2a5 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -38,6 +38,8 @@ if a `node:*` import creeps back onto that path. | `src/lib/handle-suggest.svelte.ts` | What a sign-in field offers while somebody types: this browser's remembered identities, then the handles a public appview's typeahead names for the prefix. One debounced, abortable ask per field, kept out of the sequence a stale answer could win, and `visibleSuggestions` for which of the merged list the text in the field is about. A row is a handle, the DID it belongs to and the profile picture published for it — all three already in the answer being made, so the face beside a suggestion costs no second request, and a merged row keeps its place while taking the picture the other half of the list knew. It is a dropdown and never a decision — signing in still resolves the handle through `auth.svelte.ts`, nothing here is fetched as a record, and a DID or a PDS address is never sent. `PUBLIC_RADIAL_HANDLE_TYPEAHEAD=off` turns the directory half off; both surfaces disclose it while it is on. | | `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/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/admin.test.ts b/packages/ui/src/lib/admin.test.ts index af3f326..d38bfe6 100644 --- a/packages/ui/src/lib/admin.test.ts +++ b/packages/ui/src/lib/admin.test.ts @@ -201,12 +201,18 @@ describe('the registry', () => { 'conventions', 'implementation', 'plan', + 'roadmap-doc', 'security-review', + 'user-doc', ]) expect(rows.filter((row) => row.scope === 'project').map((row) => row.name)).toEqual([ 'adr', 'architecture', 'conventions', + // The doc viewer's two tiers, which are registry entries and nothing else — this row is the + // whole of what "add a documentation tier" costs. + 'roadmap-doc', + 'user-doc', ]) expect(rows.every((row) => !row.shadowed)).toBe(true) }) @@ -433,7 +439,9 @@ describe('auto-review', () => { 'conventions', 'implementation', 'plan', + 'roadmap-doc', 'security-review', + 'user-doc', ]) expect(new Set(rows.map((row) => row.scope))).toEqual(new Set(['goal', 'project'])) }) diff --git a/packages/ui/src/lib/asks.test.ts b/packages/ui/src/lib/asks.test.ts index f965fb1..1b89019 100644 --- a/packages/ui/src/lib/asks.test.ts +++ b/packages/ui/src/lib/asks.test.ts @@ -304,12 +304,12 @@ describe('an ask is still not a unit', () => { expect(queue).toHaveLength(3) expect(tally(queue)).toEqual({ total: 3, judged: 1 }) - // The project-scoped one is the same claim on the System page: six documents, six rows. + // The project-scoped one is the same claim on the System page: nine documents, nine rows. const project = space.index.projects.find( (entry) => entry.target.uri === fixtureSpace().projects.radialNg, ) if (!project) throw new Error('fixture project missing') - expect(unitsOf(space.index, project)).toHaveLength(6) + expect(unitsOf(space.index, project)).toHaveLength(9) }) }) diff --git a/packages/ui/src/lib/components/DocEditor.svelte b/packages/ui/src/lib/components/DocEditor.svelte new file mode 100644 index 0000000..b4d1c9f --- /dev/null +++ b/packages/ui/src/lib/components/DocEditor.svelte @@ -0,0 +1,235 @@ + + +
+
+ +

{unit ? `Edit ${docTitle(unit)}` : `New ${definition.label.toLowerCase()} page`}

+
+ + {#if resume} +

+ 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. +

+ {/if} + {#if forked} +

+ Somebody else published a new version while this was open. Saving keeps both — the version rail + shows each — but only one of them ends up as the page a reader sees. +

+ {/if} + + + + + + + +
+ Written from — {definition.sources} +

+ 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. +

+ {#if candidates.length === 0} +

Nothing to pin yet.

+ {/if} + {#each candidates as candidate (candidate.ref.uri)} + + {/each} +
+ + + + {#if error}

{error}

{/if} + +
+ + +
+ + + diff --git a/packages/ui/src/lib/components/DocEditor.svelte.test.ts b/packages/ui/src/lib/components/DocEditor.svelte.test.ts new file mode 100644 index 0000000..4291e5d --- /dev/null +++ b/packages/ui/src/lib/components/DocEditor.svelte.test.ts @@ -0,0 +1,205 @@ +// @vitest-environment jsdom +import { FIXTURE_DIDS } from '@radial/core/fixture' +import { flushSync, mount, unmount } from 'svelte' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { loadEditor, type EditorModules } from '$lib/editor.js' +import { docTitle, docsOf } from '$lib/docs.js' +import { buildFixtureSpace } from '$lib/fixture.js' +import { projectByName, type Space } from '$lib/space.js' +import DocEditor from './DocEditor.svelte' + +// Editing a doc in the browser, mounted, against the fixture space. +// +// What is worth asserting here is not that a form submits: it is the two properties the whole edit +// flow rests on, and both of them are about the ARGUMENT VECTORS, because those are the records. +// +// - **Two commands, in order, and the request names its own author.** An unassigned open request +// is claimable by any operator's daemon, so an edit somebody is halfway through must never look +// open to one. The assignee is the editor's own DID, always. +// - **The sources are re-pinned to the heads.** A page whose author has just gone and read the +// superseded architecture must stop being badged as drifted, and re-pinning is the only thing +// that says so on-protocol. + +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: 'did:plc:qv7hjr2mzk4x' }, +})) + +/** The fixture, minus the one thing that would disable every control on it. */ +const live = (): Space => ({ ...buildFixtureSpace(), fixture: false }) + +let host: HTMLDivElement +let component: Record | undefined +let cm: EditorModules + +const project = (space: Space) => { + const found = projectByName(space.index, 'radial-ng') + if (!found) throw new Error('fixture project missing') + return found +} + +const doc = (space: Space, title: string) => { + const found = docsOf(space.index, project(space)).user.find((unit) => docTitle(unit) === title) + if (!found) throw new Error(`fixture doc missing: ${title}`) + return found +} + +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)) + +beforeEach(() => { + host = document.createElement('div') + document.body.append(host) + writes.mockReset() + writes.mockResolvedValue({ + primary: { uri: 'at://did:plc:qv7hjr2mzk4x/com.disnetdev.radial.artifactRequest/new', cid: 'cid-new' }, + refs: {}, + }) +}) + +afterEach(() => { + if (component) unmount(component) + component = undefined + host.remove() +}) + +describe('editing a doc', () => { + it('writes the request assigned to its author, then posts the artifact', async () => { + const space = live() + const unit = doc(space, 'Review queue') + const previous = unit.current + if (!previous) throw new Error('fixture doc has no version') + cm = await loadEditor() + component = mount(DocEditor, { + target: host, + props: { + space, + project: project(space), + tier: 'user' as const, + unit, + onDone: vi.fn(), + onCancel: vi.fn(), + }, + }) as Record + await settle() + + // The form opens holding the version being revised, so a save with nothing typed is still the + // page as it stands rather than an empty one. + const title = host.querySelector('#doc-title') as HTMLInputElement + expect(title.value).toBe('Review queue') + expect(cm.EditorView.findFromDOM(host.querySelector('.cm-content') as HTMLElement)).toBeTruthy() + + const save = button('Save version') + expect(save?.disabled).toBe(false) + save?.click() + await settle() + await Promise.resolve() + + expect(writes).toHaveBeenCalledTimes(2) + const [request] = writes.mock.calls[0] as [string[]] + const [post] = writes.mock.calls[1] as [string[]] + + expect(request.slice(0, 2)).toEqual(['request', 'create']) + expect(request).toContain('--project') + expect(request[request.indexOf('--type') + 1]).toBe('user-doc') + // The load-bearing flag. + expect(request[request.indexOf('--assignee') + 1]).toBe(FIXTURE_DIDS.tim) + + const based = request.flatMap((value, position) => + value === '--based-on' ? [request[position + 1] as string] : [], + ) + // The predecessor first — that is what continues the chain rather than starting a second page. + expect(based[0]).toBe(`${previous.artifact.uri}#${previous.artifact.cid}`) + // …and the drifted architecture re-pinned at its head, not at the version the page documents. + const architecture = docsOf(space.index, project(space)).dev.find( + (candidate) => candidate.type === 'architecture', + ) + const head = architecture?.current?.artifact + if (!head) throw new Error('fixture has no architecture head') + expect(based).toContain(`${head.uri}#${head.cid}`) + expect(based.some((value) => value.startsWith(`${previous.artifact.uri}#`))).toBe(true) + + expect(post.slice(0, 4)).toEqual([ + 'artifact', + 'post', + '--request', + 'at://did:plc:qv7hjr2mzk4x/com.disnetdev.radial.artifactRequest/new#cid-new', + ]) + expect(post[post.indexOf('--prev') + 1]).toBe(`${previous.artifact.uri}#${previous.artifact.cid}`) + expect(post[post.indexOf('--title') + 1]).toBe('Review queue') + }) + + it('finishes a half-written edit with one write instead of opening a second request', async () => { + // The recoverable half of the two-write save. Writing another request here would fork the page's + // history over a dropped connection, which is exactly what the resume path exists to avoid. + const space = live() + const unit = doc(space, 'Review queue') + const openRequest = unit.current?.request + if (!openRequest) throw new Error('fixture doc has no request') + await loadEditor() + component = mount(DocEditor, { + target: host, + props: { + space, + project: project(space), + tier: 'user' as const, + unit, + resume: openRequest, + onDone: vi.fn(), + onCancel: vi.fn(), + }, + }) as Record + await settle() + + button('Save version')?.click() + await settle() + await Promise.resolve() + + 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(`${openRequest.uri}#${openRequest.cid}`) + }) + + it('writes nothing at all for a non-member, and says why', async () => { + const space = live() + // A signed-in DID this space holds no grant for. The button is visible and dead, which is the + // rule the rest of the app follows: hiding it would leave the page with no visible way to act. + space.directory = { + ...space.directory, + get: () => ({ + did: 'did:plc:stranger', + active: false, + role: 'member', + kind: 'human', + name: 'x', + initials: 'X', + color: 'var(--accent)', + artifactTypes: [], + }), + } as Space['directory'] + component = mount(DocEditor, { + target: host, + props: { + space, + project: project(space), + tier: 'roadmap' as const, + onDone: vi.fn(), + onCancel: vi.fn(), + }, + }) as Record + await settle() + + expect(button('Publish')?.disabled).toBe(true) + expect(host.textContent).toContain('Only active members') + expect(writes).not.toHaveBeenCalled() + }) +}) diff --git a/packages/ui/src/lib/components/DocReader.svelte b/packages/ui/src/lib/components/DocReader.svelte new file mode 100644 index 0000000..5065620 --- /dev/null +++ b/packages/ui/src/lib/components/DocReader.svelte @@ -0,0 +1,209 @@ + + +
+
+
+ +

{docTitle(unit)}

+
+

+ {#if shown} + v{shown.version}{isTip ? '' : ' — superseded'} + {stamp(shown.artifact.value.createdAt)} + {#if author}{author.handle ?? author.name}{/if} + {/if} + {#each badges as badge (badge.text)}{/each} + {#if onEdit} + + {/if} +

+

{durabilityNote(space.index, unit)}

+
+ + {#if unit.versions.length > 1} +
+ {#each unit.versions as candidate, position (candidate.artifact.uri)} + {#if position > 0}{/if} + + {/each} +
+ {/if} + + {#if shown} + + {:else} + +

+ Nothing has been written here yet — the request for it is open. +

+ {/if} + + {#if road} + +
+
The road ahead {road.ahead.length}
+ {#if road.ahead.length === 0} +

Nothing open under this epic.

+ {/if} + + +
The road travelled {road.travelled.length}
+ {#if road.travelled.length === 0} +

Nothing under this epic has ended yet.

+ {/if} + +
+ {/if} + + {#if sources.length > 0} +
+
Written from {sources.length}
+

+ Pinned by the request behind this version — the exact records it was distilled from, at the + exact version they were read at. +

+ +
+ {/if} +
+ + diff --git a/packages/ui/src/lib/components/Glyph.svelte b/packages/ui/src/lib/components/Glyph.svelte index 8b99a64..88b983b 100644 --- a/packages/ui/src/lib/components/Glyph.svelte +++ b/packages/ui/src/lib/components/Glyph.svelte @@ -19,6 +19,11 @@ fdr: 'fdr', architecture: 'arch', conventions: 'book', + // The two doc tiers. A user doc is a page somebody reads, so it draws as a book like the + // conventions it is kin to; a roadmap is a route, and drawing it as another book would put the + // two tiers side by side in the docs rail looking like one thing. + 'user-doc': 'book', + 'roadmap-doc': 'route', } const glyph = $derived(TYPE_GLYPH[name] ?? name) @@ -64,6 +69,10 @@ {:else if glyph === 'goal'} + {:else if glyph === 'route'} + + + {:else if glyph === 'mark'} diff --git a/packages/ui/src/lib/components/Rail.svelte b/packages/ui/src/lib/components/Rail.svelte index 674e79b..1a68eda 100644 --- a/packages/ui/src/lib/components/Rail.svelte +++ b/packages/ui/src/lib/components/Rail.svelte @@ -19,6 +19,7 @@ unitsOf, type Space, } from '$lib/space.js' + import { docsHref, docsOf } from '$lib/docs.js' import { agentDids, askWithAgent, isAwaiting, isMoving, liveUnits } from '$lib/units.js' import { reviewQueue, unjudged } from '$lib/verdicts.js' import { toast, ui } from '$lib/ui.svelte.js' @@ -191,6 +192,7 @@ {#each liveProjects(space.index) as project (project.target.uri)} {@const name = project.name} {@const drift = drifting(project)} + {@const docs = docsOf(space.index, project)}
+ + {/if} + + +
+ {#if needsSetup} + +
+

+ This space has not set up {tierById(tier).label.toLowerCase()} docs yet. They are two + entries in the artifact-type registry — no schema and no deploy — and until they exist + the fold has nowhere to put a page of this kind. +

+

+ {#each missing as draft (draft.name)}{draft.name}{' '}{/each} +

+ + + {#if setupError}

{setupError}

{/if} +
+ {:else if editing} + { + editing = undefined + await goto(docsHref(project, tier, { key })) + }} + onCancel={() => (editing = undefined)} + /> + {:else if current} + {#if resumableHere} +

+ You started an edit of this page and never posted it. + +

+ {/if} + (editing = { unit: current }) : undefined} + /> + {:else} +
+

+ {#if tier === 'dev'} + This project has no system records yet. They are written by agents — ask for one from + System. + {:else} + No {tierById(tier).label.toLowerCase()} pages yet. + {/if} +

+
+ {/if} +
+
+{/if} + +