diff --git a/packages/ui/README.md b/packages/ui/README.md index cbe803c..307ec87 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. 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/routes/p/[project]/docs/`, `src/lib/components/DocReader.svelte`, `DocEditor.svelte` | The surface: tier navigation, a page index — grouped by the kind of document, with disclosures where a tier holds more than one registry type, which is the dev tier and only it — 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/docs.test.ts b/packages/ui/src/lib/docs.test.ts index 52fbf34..7fd5d6a 100644 --- a/packages/ui/src/lib/docs.test.ts +++ b/packages/ui/src/lib/docs.test.ts @@ -6,6 +6,7 @@ import { ROADMAP_DOC_TYPE, USER_DOC_TYPE, docDrift, + docGroups, docPostArgs, docTier, docTitle, @@ -70,6 +71,28 @@ describe('tiers', () => { expect(isEditable('roadmap')).toBe(true) }) + it('groups a tier by the kind of document, alphabetically and with nothing hard-coded', () => { + const tiers = docsOf(space.index, project()) + const dev = docGroups(tiers.dev) + expect(dev.map((group) => group.type)).toEqual(['adr', 'architecture', 'conventions']) + // The label is `typeLabel`'s, so an acronym is spoken as one and everything else is shown as the + // registry spells it. Nothing here knows which names exist. + expect(dev.map((group) => group.label)).toEqual(['ADR', 'architecture', 'conventions']) + // Every page still appears exactly once, and a group keeps the reading order `docsOf` sorted. + const byName = (left: string, right: string) => left.localeCompare(right) + expect([...dev.flatMap((group) => group.units.map(docTitle))].sort(byName)).toEqual( + [...tiers.dev.map(docTitle)].sort(byName), + ) + for (const group of dev) { + const titles = group.units.map(docTitle) + expect(titles).toEqual([...titles].sort(byName)) + } + // An editable tier IS one registry type, so it has exactly one group — which is why the page + // draws the disclosures only where there is more than one. + expect(docGroups(tiers.user).map((group) => group.type)).toEqual([USER_DOC_TYPE]) + expect(docGroups([])).toEqual([]) + }) + it('reads the tier out of the URL and puts it back', () => { expect(tierFromParam(null)).toBe('dev') expect(tierFromParam('nonsense')).toBe('dev') diff --git a/packages/ui/src/lib/docs.ts b/packages/ui/src/lib/docs.ts index acbd811..4a6fa93 100644 --- a/packages/ui/src/lib/docs.ts +++ b/packages/ui/src/lib/docs.ts @@ -42,7 +42,7 @@ import { import type { TypeDraft } from './admin.js' import { locator } from './requests.js' import { unitsOf, withSpace } from './space.js' -import type { RowBadge } from './units.js' +import { typeLabel, type RowBadge } from './units.js' /** The registry name of the user-facing documentation tier. */ export const USER_DOC_TYPE = 'user-doc' @@ -127,6 +127,40 @@ export function docsOf( export const docTitle = (unit: UnitView): string => unit.current?.artifact.value.title?.trim() || unit.type +/** One kind of document in a tier: the registry type, as prose, plus the pages that are one. */ +export interface DocGroup { + /** The registry name — the group's identity, and what a collapsed state is keyed on. */ + type: string + /** The name as it is written (`typeLabel`): `adr` is spoken as an acronym, `architecture` is not. */ + label: string + units: UnitView[] +} + +/** + * A tier's pages, grouped by the type of document they are. + * + * The dev tier is every project-scoped type that is not one of the two doc types, so it is a mixed + * list by construction — ADRs, an architecture document, conventions, and whatever an admin + * registers tomorrow. Read as one alphabetical run those interleave, and a reader looking for "the + * ADRs" has to know which titles are ADRs. Grouping is therefore the registry's own structure shown + * rather than a taxonomy this file invents, which is also why nothing here is ordered by a list of + * known names: groups come out alphabetically by label, and a type nobody has seen before takes its + * place among them. + * + * The order WITHIN a group is the order it was given — `docsOf` has already sorted for reading. + */ +export function docGroups(units: readonly UnitView[]): DocGroup[] { + const groups = new Map() + for (const unit of units) { + const group = groups.get(unit.type) ?? { type: unit.type, label: typeLabel(unit.type), units: [] } + group.units.push(unit) + groups.set(unit.type, group) + } + return [...groups.values()].sort( + (left, right) => left.label.localeCompare(right.label) || left.type.localeCompare(right.type), + ) +} + /** Whether a doc in this tier is written by a member in the app rather than asked of an agent. */ export const isEditable = (id: TierId): boolean => id !== 'dev' diff --git a/packages/ui/src/routes/p/[project]/docs/+page.svelte b/packages/ui/src/routes/p/[project]/docs/+page.svelte index 15b8a37..c43d469 100644 --- a/packages/ui/src/routes/p/[project]/docs/+page.svelte +++ b/packages/ui/src/routes/p/[project]/docs/+page.svelte @@ -1,5 +1,7 @@