From 051315195af9463b35da339c71d82b54dfb740f4 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Wed, 7 Oct 2026 15:02:50 +0200 Subject: [PATCH] Give a role page its holds and holders; offer a note no launch chrome A fiber outside the Desk's lifecycle (a note or a role) has no settings disclosure, History, composer or verdicts on its page, and no lifecycle glyph on its sidebar card. A role page (exactly roles/) lists, between its lede and body, the holder pages beneath it and the feed's constitutions whose roster names it, in Desk order, each opening in the reader. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XEBcfWbydFqYZm6DhLvCqT --- ui/harness/harness-board.ts | 10 +- ui/harness/workspace-fixtures.ts | 34 +++- ui/src/board/workspace/FiberProse.ts | 8 +- ui/src/board/workspace/Reader.sidebar.test.ts | 13 ++ ui/src/board/workspace/Reader.ts | 4 +- ui/src/board/workspace/RolePage.test.ts | 88 ++++++++++ ui/src/board/workspace/RolePage.ts | 152 ++++++++++++++++++ .../workspace/Workspace.integration.test.ts | 37 ++++- ui/src/board/workspace/Workspace.ts | 17 +- ui/src/board/workspace/fiber-prose.css | 49 ++++++ ui/src/board/workspace/fiberPageState.ts | 5 +- 11 files changed, 398 insertions(+), 19 deletions(-) create mode 100644 ui/src/board/workspace/RolePage.test.ts create mode 100644 ui/src/board/workspace/RolePage.ts diff --git a/ui/harness/harness-board.ts b/ui/harness/harness-board.ts index 287dcc45..cbbab11f 100644 --- a/ui/harness/harness-board.ts +++ b/ui/harness/harness-board.ts @@ -34,7 +34,7 @@ import { KanbanModal } from '../src/board/KanbanModal.js' import { scopeTheme } from '../src/board/workspace/themeScope.js' import { workshopExample } from './workshop-example.js' -import { installWorkspaceNativeURLs, ROLE_NOTES, WORKSPACE_HOST, workspaceExample } from './workspace-fixtures.js' +import { installWorkspaceNativeURLs, NOTE_FIBERS, WORKSPACE_HOST, workspaceExample } from './workspace-fixtures.js' import { openCapture, openStash, openSettings } from '../src/forms/mountForms.js' import { showToast } from '../src/board/utils.js' import type { @@ -1274,14 +1274,14 @@ window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { }, }] }) } - const role = ROLE_NOTES.find(note => note.id === id) - if (role) { + const note = NOTE_FIBERS.find(row => row.id === id) + if (note) { return json({ fibers: [{ origin: WORKSPACE_HOST, felt_store: '/fixture-store/workspace', path: `.felt/${id}/${id.split('/').at(-1)}.md`, dir: `/fixture-store/workspace/.felt/${id}`, - fiber: { id, uid: role.uid, name: role.name, status: 'open', outcome: role.outcome, body: role.body, tags: ['role'] }, + fiber: { id, uid: note.uid, name: note.name, status: 'open', outcome: note.outcome, body: note.body, tags: id.startsWith('roles/') ? ['role'] : ['workspace'] }, }] }) } return json({ fibers: [] }, 404) @@ -1306,7 +1306,7 @@ window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { // The parent picker's index: the feed's rows plus a sibling of the null-test // run, so its picker offers a parent before anything is typed. if (url.endsWith('/api/v1/fibers?fields=index')) { - if (workspaceFixture) return json({ fibers: [...workspaceFixture.feed.fibers, { fiber: { id: 'research/workspace/method-note', name: 'Method note' } }, ...ROLE_NOTES.map(({ id, name }) => ({ fiber: { id, name } }))] }) + if (workspaceFixture) return json({ fibers: [...workspaceFixture.feed.fibers, { fiber: { id: 'research/workspace/method-note', name: 'Method note' } }, ...NOTE_FIBERS.map(({ id, name }) => ({ fiber: { id, name } }))] }) if (docsExample) return json({ fibers: docsExample.feed.fibers }) return json({ fibers: [...MOCK_FEED.fibers, { fiber: { id: 'work/spt3g_papers/bmodes-2d/null-suite', name: 'Null-test suite' } }] }) } diff --git a/ui/harness/workspace-fixtures.ts b/ui/harness/workspace-fixtures.ts index 01acc8b9..b8b49cea 100644 --- a/ui/harness/workspace-fixtures.ts +++ b/ui/harness/workspace-fixtures.ts @@ -101,14 +101,18 @@ export function installWorkspaceNativeURLs(example: WorkspaceExample): Workspace return { blobURLs, rewrites } } -/** The role store's fibers the fixture rosters name: a role and its holder, each a plain note. */ -export const ROLE_NOTES = [ +/** + * Plain notes the fixture links to, none Shuttle-managed: two roles from the + * role store (one held by two holders and named by three rosters, one held + * by no one and named by none), the holder pages, and a note outside it. + */ +export const NOTE_FIBERS = [ { id: 'roles/surveyor', uid: '01KVBR8P3JM2BTP4BB78T245T5', name: 'Surveyor', outcome: 'Walks a project end to end and maps what is there before anyone builds on it.', - body: 'The surveyor reads before it writes: the code, the data products and the open questions, in that order.\n\nHeld by [[roles/surveyor/opus]].', + body: 'The surveyor reads before it writes: the code, the data products and the open questions, in that order.\n\nIts working terms are in [[research/workspace/glossary]]; a role with no work yet is [[roles/scribe]].', }, { id: 'roles/surveyor/opus', @@ -117,6 +121,27 @@ export const ROLE_NOTES = [ outcome: 'Opus holds the surveyor role across the workspace projects.', body: 'Notes this holder keeps between surveys.', }, + { + id: 'roles/surveyor/sonnet', + uid: '01KVBRAR5MP4DWR6DD90W467W7', + name: 'Surveyor: sonnet', + outcome: 'Sonnet holds the surveyor role for quick passes.', + body: 'Notes this holder keeps between passes.', + }, + { + id: 'roles/scribe', + uid: '01KVBRBS6NQ5EXS7EE01X578X8', + name: 'Scribe', + outcome: 'Keeps the record of what was decided and why.', + body: 'No roster names the scribe yet, and no one holds it.', + }, + { + id: 'research/workspace/glossary', + uid: '01KVBRCT7PR6FYT8FF12Y689Y9', + name: 'Glossary', + outcome: 'The terms the workspace projects share.', + body: '**Transfer function**: the ratio of recovered to injected power, per scale.\n\n**Null test**: a difference map that should hold no signal.', + }, ] export const MUSIC_UID = '01KVBR7N2HK1ASN3AA67W134S4' @@ -152,6 +177,7 @@ export function workspaceExample(now: number, options: { music?: boolean } = {}) age: 1, outcome: 'Collect the latest validation results and note what remains uncertain.', host: WORKSPACE_HOST, + collaboration: { surveyor: ['sonnet'] }, }, { id: 'pipeline/spin/remote-review', @@ -384,7 +410,7 @@ export function workspaceExample(now: number, options: { music?: boolean } = {}) return { id: String(fiber.id), name: String(fiber.name) } }), { id: 'research/workspace/method-note', name: 'Method note' }, - ...ROLE_NOTES.map(({ id, name }) => ({ id, name })), + ...NOTE_FIBERS.map(({ id, name }) => ({ id, name })), ] const fileMap = new Map(files.map(item => [key(item.owner, item.path), item])) const fileResponse = (url: string, method: string, requestHeaders?: HeadersInit): Response => { diff --git a/ui/src/board/workspace/FiberProse.ts b/ui/src/board/workspace/FiberProse.ts index cc8c8901..0d001550 100644 --- a/ui/src/board/workspace/FiberProse.ts +++ b/ui/src/board/workspace/FiberProse.ts @@ -7,6 +7,7 @@ import '../prose.css' import './fiber-prose.css' import type { Channel } from './documents.js' import { fiberPageKicker } from './fiberPageState.js' +import { buildRoleLedger, roleSlug } from './RolePage.js' /** The outcome as the reading surface's lede, including math and references. */ export function ledeHtml(outcome: string): string { @@ -99,6 +100,9 @@ export function buildFiberProse( acts?: HTMLElement onFiber: (id: string) => void onFile: (path: string, title?: string) => void + /** On a role page, the constitutions whose roster names the role, in Desk order. */ + holds?: KanbanCard[] + onCard?: (card: KanbanCard) => void }, ): HTMLElement { const scroller = document.createElement('div') @@ -143,7 +147,9 @@ export function buildFiberProse( } installBodyFileLinks(body, opts.onFile) void installWikilinks(body, { shuttleBase: opts.shuttleBase, onOpen: opts.onFiber }) - article.append(header, title, outcome, ...(opts.controls ? [opts.controls] : []), body) + const slug = roleSlug(card) + const ledger = slug ? buildRoleLedger(slug, opts.holds ?? [], { shuttleBase: opts.shuttleBase, onFiber: opts.onFiber, onCard: opts.onCard ?? (() => {}) }) : null + article.append(header, title, outcome, ...(ledger ? [ledger] : []), ...(opts.controls ? [opts.controls] : []), body) scroller.append(article) return scroller } diff --git a/ui/src/board/workspace/Reader.sidebar.test.ts b/ui/src/board/workspace/Reader.sidebar.test.ts index ea84b667..bdf457e4 100644 --- a/ui/src/board/workspace/Reader.sidebar.test.ts +++ b/ui/src/board/workspace/Reader.sidebar.test.ts @@ -378,6 +378,19 @@ describe('Reader channel sidebar', () => { expect(onChannel).toHaveBeenCalledWith(alpha) }) + it('marks only a fiber on the Desk lifecycle with its glyph', () => { + storage.set('shuttle:workspace:sidebar', 'true') + const managed = { ...gamma, shuttleKind: 'oneshot' as const } + channels.splice(2, 1, managed) + listedCards = channels + try { + const reader = makeReader(alpha) + const glyph = (uid: string) => reader.el.querySelector(`.ws-sidebar [data-channel-uid="${uid}"] .kbn-card-glyph`) + expect(glyph('alpha')).toBeNull() + expect(glyph('gamma')?.textContent).toBe('◐') + } finally { channels.splice(2, 1, gamma) } + }) + it('refreshes sidebar rows without clearing the find text', () => { viewport.wide = true const reader = makeReader() diff --git a/ui/src/board/workspace/Reader.ts b/ui/src/board/workspace/Reader.ts index a0609782..4a942a2b 100644 --- a/ui/src/board/workspace/Reader.ts +++ b/ui/src/board/workspace/Reader.ts @@ -1,7 +1,7 @@ import './tokens.css' import './reader.css' import type { KanbanCard } from '../KanbanTypes.js' -import { verdictReachable } from './fiberPageState.js' +import { onDesk, verdictReachable } from './fiberPageState.js' import { keyIntent, shouldForwardDocumentKey, type KeyIntent } from '../keymap.js' import { blockingDialogOpen } from '../views/ViewRegistry.js' import { MOBILE_MEDIA } from '../mobile.js' @@ -827,6 +827,8 @@ export class Reader { captureSidebar(entries: SidebarEntry[]): void { this.sidebarFlight.capture(entries) } private sidebarCard(card: KanbanCard): HTMLElement { const face = buildCardPaper(card) + // A note or role has no lifecycle, so it carries no lifecycle glyph. + if (!onDesk(card)) face.querySelector('.kbn-card-glyph')?.remove() face.classList.add('ws-constitution-card') face.dataset.part = 'sidebar-card' face.dataset.wsThemeBoundary = '' diff --git a/ui/src/board/workspace/RolePage.test.ts b/ui/src/board/workspace/RolePage.test.ts new file mode 100644 index 00000000..980c2d98 --- /dev/null +++ b/ui/src/board/workspace/RolePage.test.ts @@ -0,0 +1,88 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { card } from '../testFixtures.js' +import type { KanbanCard } from '../KanbanTypes.js' +import type { Channel } from './documents.js' +import { buildFiberProse } from './FiberProse.js' +import { roleHolds, roleSlug } from './RolePage.js' + +const role = card({ id: 'roles/surveyor', uid: 'surveyor', name: 'Surveyor', outcome: 'Maps a project.' }) +const channel: Channel = { uid: 'surveyor', owner: 'local', name: 'Surveyor', body: 'Reads first.', labels: ['Note'], documents: [] } +const work = (id: string, over: Partial): KanbanCard => + card({ id, uid: id, name: id, shuttleKind: 'oneshot', roles: ['surveyor'], ...over }) +const draft = work('draft', { status: 'open', outcome: 'A *draft* to write.' }) +const flying = work('flying', { status: 'active', workerState: 'running', tmuxSession: 'flying-shuttle' }) +const review = work('review', { status: 'closed' }) +const tempered = work('tempered', { status: 'closed', tempered: true }) + +let base = 0 +function daemon(ids: string[]): string { + const shuttleBase = `http://role-page-${++base}.invalid` + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ fibers: ids.map(id => ({ fiber: { id, name: id } })) })))) + return shuttleBase +} +const settle = () => new Promise(resolve => setTimeout(resolve, 0)) +const names = (root: ParentNode, selector: string) => [...root.querySelectorAll(selector)].map(el => el.textContent) + +afterEach(() => { vi.unstubAllGlobals() }) + +describe('role page', () => { + it('is exactly a fiber at roles/, never a holder beneath it or a role elsewhere', () => { + expect(roleSlug(role)).toBe('surveyor') + expect(roleSlug(card({ id: 'roles/surveyor/opus' }))).toBeNull() + expect(roleSlug(card({ id: 'projects/roles/surveyor' }))).toBeNull() + expect(roleSlug(card({ id: 'roles' }))).toBeNull() + }) + + it('holds the constitutions whose roster names the role, in the Desk order', () => { + const other = work('other', { roles: ['scribe'] }) + expect(roleHolds([tempered, draft, other, flying, review], 'surveyor').map(c => c.id)).toEqual(['review', 'flying', 'draft', 'tempered']) + }) + + it('lists holds and holders under the lede, before the body, and opens each', async () => { + const shuttleBase = daemon(['roles/surveyor', 'roles/surveyor/opus', 'roles/surveyor/fable', 'roles/surveyor/opus/notes', 'roles/scribe/opus']) + const onCard = vi.fn(), onFiber = vi.fn() + const pane = buildFiberProse(role, channel, { shuttleBase, onFiber, onFile: vi.fn(), holds: [review, flying, draft], onCard }) + await settle(); await settle() + const ledger = pane.querySelector('[data-part="role-ledger"]')! + expect(ledger.previousElementSibling?.querySelector('.kbn-detail-lede')).not.toBeNull() + expect(ledger.nextElementSibling?.classList.contains('ws-prose-body')).toBe(true) + expect(names(ledger, '.ws-role-caption')).toEqual(['Held by', 'Holds']) + // Only the direct children are holders. + expect(names(ledger, '.ws-role-holder')).toEqual(['fable', 'opus']) + ledger.querySelector('.ws-role-holder a.kbn-wikilink-live')!.click() + expect(onFiber).toHaveBeenCalledWith('roles/surveyor/fable') + const rows = [...ledger.querySelectorAll('.ws-role-hold')] + expect(rows.map(row => row.dataset.column)).toEqual(['awaitingReview', 'inFlight', 'drafts']) + expect(names(ledger, '.ws-role-hold-column')).toEqual(['Review', 'In flight', 'Draft']) + expect(rows[2].querySelector('.ws-role-hold-outcome')?.textContent).toBe('A draft to write.') + rows[1].click() + expect(onCard).toHaveBeenCalledWith(flying) + }) + + it('shows live work whole and folds the rest past five rows', async () => { + const settled = Array.from({ length: 6 }, (_, i) => work(`done-${i}`, { status: 'closed', tempered: true })) + const holds = [review, flying, ...settled] + const pane = buildFiberProse(role, channel, { shuttleBase: daemon([]), onFiber: vi.fn(), onFile: vi.fn(), holds, onCard: vi.fn() }) + expect(pane.querySelectorAll('.ws-role-hold')).toHaveLength(5) + const more = pane.querySelector('.ws-role-more')! + expect(more.textContent).toBe('3 more') + more.click() + expect(pane.querySelectorAll('.ws-role-hold')).toHaveLength(8) + expect(pane.querySelector('.ws-role-more')).toBeNull() + }) + + it('draws no heading for a role that holds nothing and no one holds', async () => { + const pane = buildFiberProse(role, channel, { shuttleBase: daemon(['roles/surveyor']), onFiber: vi.fn(), onFile: vi.fn(), holds: [] }) + await settle(); await settle() + expect(pane.querySelector('.ws-role-caption')).toBeNull() + expect(pane.querySelector('[data-part="role-holders"]')?.hidden).toBe(true) + }) + + it('gives a holder page and any other fiber no ledger', () => { + for (const id of ['roles/surveyor/opus', 'notes/task']) { + const pane = buildFiberProse(card({ id }), channel, { shuttleBase: daemon([]), onFiber: vi.fn(), onFile: vi.fn(), holds: [draft] }) + expect(pane.querySelector('[data-part="role-ledger"]')).toBeNull() + } + }) +}) diff --git a/ui/src/board/workspace/RolePage.ts b/ui/src/board/workspace/RolePage.ts new file mode 100644 index 00000000..2859b440 --- /dev/null +++ b/ui/src/board/workspace/RolePage.ts @@ -0,0 +1,152 @@ +import type { KanbanCard } from '../KanbanTypes.js' +import { renderMarkdown } from '../utils.js' +import { fiberIndex, installWikilinks } from '../wikilinks.js' +import { fiberPageColumn } from './fiberPageState.js' + +const ROLE_ID = /^roles\/([a-z0-9]+(?:-[a-z0-9]+)*)$/ + +/** The slug of a role page: a fiber at exactly `roles/`, never a holder beneath it. */ +export function roleSlug(card: KanbanCard): string | null { + return ROLE_ID.exec(card.id)?.[1] ?? null +} + +type HoldColumn = ReturnType +/** The Desk's reading order: work awaiting review, in flight, drafts, then the rest. */ +const HOLD_ORDER: HoldColumn[] = ['awaitingReview', 'inFlight', 'drafts', 'pinned', 'scheduled', 'resting', 'cycles', 'tempered', 'composted'] +const LIVE: ReadonlySet = new Set(['awaitingReview', 'inFlight', 'drafts']) +const HOLD_LABELS: Record = { + awaitingReview: 'Review', inFlight: 'In flight', drafts: 'Draft', pinned: 'Pinned', scheduled: 'Resting', + resting: 'Resting', cycles: 'Cycle', tempered: 'Tempered', composted: 'Discarded', +} +/** Rows shown before the rest fold behind "more"; live work is always shown whole. */ +const HOLDS_SHOWN = 5 + +/** The feed's constitutions whose roster names this role, in Desk order. */ +export function roleHolds(cards: KanbanCard[], slug: string): KanbanCard[] { + const rank = (card: KanbanCard) => HOLD_ORDER.indexOf(fiberPageColumn(card)) + return cards.filter(card => card.roles?.includes(slug)).sort((a, b) => rank(a) - rank(b)) +} + +/** What the page shows of each hold, so a feed poll that changes none of it leaves the page alone. */ +export function holdsRevision(holds: KanbanCard[]): unknown[] { + return holds.map(card => [card.originId, card.uid ?? card.id, card.name, card.outcome, fiberPageColumn(card)]) +} + +function caption(text: string): HTMLElement { + // A caption in the status line's register, not a prose heading a theme restyles. + const el = document.createElement('div') + el.className = 'ws-role-caption' + el.setAttribute('role', 'heading') + el.setAttribute('aria-level', '2') + el.textContent = text + return el +} + +function plainOutcome(outcome: string): string { + const template = document.createElement('template') + template.innerHTML = renderMarkdown(outcome) + return template.content.textContent?.replace(/\s+/g, ' ').trim() ?? '' +} + +function holdRow(card: KanbanCard, onCard: (card: KanbanCard) => void): HTMLElement { + const column = fiberPageColumn(card) + const item = document.createElement('li') + const row = document.createElement('button') + row.type = 'button' + row.className = 'ws-role-hold' + row.dataset.column = column + row.title = card.id + const dot = document.createElement('span') + dot.className = 'ws-role-dot' + dot.setAttribute('aria-hidden', 'true') + const name = document.createElement('span') + name.className = 'ws-role-hold-name' + name.textContent = card.name + const state = document.createElement('span') + state.className = 'ws-role-hold-column' + state.textContent = HOLD_LABELS[column] + row.append(dot, name, state) + const outcome = card.outcome ? plainOutcome(card.outcome) : '' + if (outcome) { + const line = document.createElement('span') + line.className = 'ws-role-hold-outcome' + line.textContent = outcome + row.append(line) + } + row.addEventListener('click', () => onCard(card)) + item.append(row) + return item +} + +function holdsSection(holds: KanbanCard[], onCard: (card: KanbanCard) => void): HTMLElement { + const section = document.createElement('section') + section.className = 'ws-role-holds' + section.dataset.part = 'role-holds' + const list = document.createElement('ol') + list.className = 'ws-role-hold-list' + const live = holds.filter(card => LIVE.has(fiberPageColumn(card))).length + const shown = Math.max(live, HOLDS_SHOWN) + list.append(...holds.slice(0, shown).map(card => holdRow(card, onCard))) + section.append(caption('Holds'), list) + const rest = holds.slice(shown) + if (rest.length) { + const more = document.createElement('button') + more.type = 'button' + more.className = 'ws-role-more' + more.textContent = `${rest.length} more` + more.addEventListener('click', () => { + list.append(...rest.map(card => holdRow(card, onCard))) + more.remove() + }) + section.append(more) + } + return section +} + +/** + * The holder pages `roles//` the fiber index names, as quiet + * links. The line stays hidden until the index lands, and for good when it + * names none. + */ +function holdersLine(slug: string, opts: { shuttleBase: string; onFiber: (id: string) => void }): HTMLElement { + const line = document.createElement('section') + line.className = 'ws-role-holders' + line.dataset.part = 'role-holders' + line.hidden = true + const prefix = `roles/${slug}/` + void fiberIndex(opts.shuttleBase).then(async index => { + const ids = index.map(row => row.id).filter(id => id.startsWith(prefix) && !id.slice(prefix.length).includes('/')).sort() + if (ids.length === 0) return + const names = document.createElement('span') + names.className = 'ws-role-holder-names' + for (const id of ids) { + const holder = document.createElement('span') + holder.className = 'ws-role-holder' + const link = document.createElement('a') + link.className = 'kbn-wikilink' + link.dataset.fiber = id + link.dataset.wikilinkRaw = id.slice(prefix.length) + link.textContent = id.slice(prefix.length) + holder.append(link) + names.append(holder) + } + line.append(caption('Held by'), names) + await installWikilinks(names, { shuttleBase: opts.shuttleBase, onOpen: opts.onFiber, exact: true }) + line.hidden = false + }).catch(() => { /* No index, no holders: the page reads without them. */ }) + return line +} + +/** A role page's ledger under its lede: who holds the role, and the constitutions it holds. */ +export function buildRoleLedger( + slug: string, + holds: KanbanCard[], + opts: { shuttleBase: string; onFiber: (id: string) => void; onCard: (card: KanbanCard) => void }, +): HTMLElement { + const ledger = document.createElement('div') + ledger.className = 'ws-role-ledger' + ledger.dataset.part = 'role-ledger' + ledger.append(holdersLine(slug, opts)) + if (holds.length) ledger.append(holdsSection(holds, opts.onCard)) + return ledger +} diff --git a/ui/src/board/workspace/Workspace.integration.test.ts b/ui/src/board/workspace/Workspace.integration.test.ts index 8be198af..8540a66a 100644 --- a/ui/src/board/workspace/Workspace.integration.test.ts +++ b/ui/src/board/workspace/Workspace.integration.test.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { card } from '../testFixtures.js' import type { KanbanCard } from '../KanbanTypes.js' import { Workspace } from './Workspace.js' @@ -130,6 +130,32 @@ describe('workspace reader integration', () => { } finally { cards[0] = original } }) + it('gives a role page its holds from the feed and none of a constitution\'s controls', async () => { + const original = cards[0] + const role = card({ id: 'roles/surveyor', uid: 'surveyor-uid', name: 'Surveyor', originId: 'host-a', fiberDir: '/roles/surveyor', path: 'roles/surveyor/surveyor.md' }) + cards[0] = { ...original, shuttleKind: 'oneshot', roles: ['surveyor'] } + bodyCards = [...cards, role] + onTestFinished(() => { cards[0] = original }) + const holds = (): string[] => [...document.querySelectorAll('.ws-role-hold-name')].map(name => name.textContent ?? '') + workspace.open(role); await flush(); await flush() + expect(holds()).toEqual(['Alpha']) + const page = document.querySelector('.ws-fiber-prose')! + for (const chrome of ['.ws-dock', '.kbn-detail-controls', '.kbn-ctl-history-toggle', '.kbn-detail-directive', '.kbn-ctl-verdict']) { + expect(page.querySelector(chrome), chrome).toBeNull() + } + // A role is outside the feed; its holds still follow the feed's rosters. + cards[0] = { ...original, shuttleKind: 'oneshot' } + workspace.update(); await flush() + expect(holds()).toEqual([]) + expect(document.querySelector('[data-part="role-holds"]')).toBeNull() + cards[0] = { ...original, shuttleKind: 'oneshot', roles: ['surveyor'] } + workspace.update(); await flush() + document.querySelector('.ws-role-hold')!.click() + await flush(); await flush() + expect(document.querySelector('.ws-selected [data-part="fiber-title"]')?.textContent).toBe('Alpha') + expect(document.querySelector('.ws-fiber-prose .ws-dock')).not.toBeNull() + }) + it('replaces key verdicts with clicks and commits only the live identity after a move', async () => { workspace.dispose() const reviewing = card({ id: 'work/review', uid: 'stable-review', name: 'Review', originId: 'host-a', @@ -501,6 +527,10 @@ describe('workspace reader integration', () => { }) it('keeps body links and embeds while placing the outcome above inline controls', async () => { + // Inline controls belong to a fiber on the Desk's lifecycle. + const original = cards[0] + cards[0] = { ...original, shuttleKind: 'oneshot' } + onTestFinished(() => { cards[0] = original }) workspace.open(cards[0]) await flush() const article = document.querySelector('.ws-fiber-prose')! @@ -511,7 +541,7 @@ describe('workspace reader integration', () => { expect(outcome.compareDocumentPosition(band) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() expect(document.querySelector('.ws-dock-slot')).toBeNull() - const proseTab = [...document.querySelectorAll('.ws-tab')].find(b => b.getAttribute('aria-label') === 'Note')! + const proseTab = [...document.querySelectorAll('.ws-tab')].find(b => b.getAttribute('aria-label') === 'Constitution')! proseTab.click() const link = document.querySelector('.ws-selected a[data-file-path]')! link.click() @@ -633,6 +663,9 @@ describe('workspace reader integration', () => { }) it('Escape gives inline and reader popovers first refusal, then collapses and returns', async () => { + const original = cards[0] + cards[0] = { ...original, shuttleKind: 'oneshot' } + onTestFinished(() => { cards[0] = original }) workspace.open(cards[0]) await flush() const escape = (): void => { document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })) } diff --git a/ui/src/board/workspace/Workspace.ts b/ui/src/board/workspace/Workspace.ts index 62cf7078..9505174f 100644 --- a/ui/src/board/workspace/Workspace.ts +++ b/ui/src/board/workspace/Workspace.ts @@ -1,7 +1,8 @@ import { hasWorkerToStop, type KanbanCard } from '../KanbanTypes.js' import type { Dock } from './Dock.js' import { Verdicts, confirmWorkerStop, type Verdict } from './Verdicts.js' -import { fiberPageColumn, verdictReachable } from './fiberPageState.js' +import { fiberPageColumn, onDesk, verdictReachable } from './fiberPageState.js' +import { holdsRevision, roleHolds, roleSlug } from './RolePage.js' import type { DispatchFailureBody } from '../KanbanModalShared.js' import { readFiber } from './fiberSource.js' import { inLane } from '../requestLanes.js' @@ -271,7 +272,8 @@ export class Workspace { this.overview.cardsChanged() if (!this.current || !this.isActive) return const card = this.opts.cards().find(c => (c.uid ?? c.id) === this.current?.channel.uid && c.originId === this.current.channel.owner) - if (!card) return + // A fiber outside the feed (a role, a note) keeps its card; a role page still follows its holds. + if (!card) { this.refreshProse(this.current); return } this.current.card = card this.current.channel = { ...this.current.channel, name: card.name, outcome: card.outcome ?? this.current.channel.outcome, @@ -331,11 +333,16 @@ export class Workspace { this.controls(state)?.focusComposer() } + /** Settings, history and the composer belong to fibers on the Desk's lifecycle; a note or role has none. */ private controls(state: ChannelState | null): Dock | undefined { - return state?.metadataKnown && !state.channel.uid.startsWith('other:') ? this.dock.bandFor(state.card) : undefined + return state?.metadataKnown && !state.channel.uid.startsWith('other:') && onDesk(state.card) ? this.dock.bandFor(state.card) : undefined + } + private holds(state: ChannelState): KanbanCard[] { + const slug = roleSlug(state.card) + return slug ? roleHolds(this.opts.cards(), slug) : [] } private proseRevision(state: ChannelState): string { - return JSON.stringify([state.channel.body, state.channel.outcome, state.channel.labels, state.channel.documents.map(d => d.key), state.card.status, state.card.tempered, state.card.workerState, state.card.effectiveHorizon, state.card.shuttleAgent, state.card.roles, state.error, state.loaded, state.metadataKnown]) + return JSON.stringify([state.channel.body, state.channel.outcome, state.channel.labels, state.channel.documents.map(d => d.key), state.card.status, state.card.tempered, state.card.workerState, state.card.effectiveHorizon, state.card.shuttleAgent, state.card.roles, holdsRevision(this.holds(state)), state.error, state.loaded, state.metadataKnown]) } private prose(key: DocKey): HTMLElement { const state = [...this.channels.values()].find(s => proseDocument(s.channel)?.key === key) @@ -347,6 +354,8 @@ export class Workspace { shuttleBase: this.opts.shuttleBase, onFiber: id => { void this.openFiber(id, state.card.originId) }, onFile: (path, title) => this.openFile(path, title), + holds: this.holds(state), + onCard: card => { void this.openFiber(card.uid ?? card.id, card.originId) }, }) if (!state.loaded || state.error) { const note = document.createElement('p') diff --git a/ui/src/board/workspace/fiber-prose.css b/ui/src/board/workspace/fiber-prose.css index 219685f2..bd87a851 100644 --- a/ui/src/board/workspace/fiber-prose.css +++ b/ui/src/board/workspace/fiber-prose.css @@ -6,3 +6,52 @@ @media (max-width: 700px), (max-height: 500px) and (pointer: coarse) { .ws-fiber-prose > h1 { display: none; } } +/* A role page's ledger, between the lede and the body: who holds the role, + then the constitutions whose roster names it. Captions and holders share + the status line's register (mono capitals, muted); each hold is a compact + row in the Desk's order, its dot in its column's pigment, the outcome a + single muted line beneath the name. */ +.ws-role-ledger { margin: 0 0 20px; } +.ws-role-caption { + margin: 0; color: var(--ws-ink-muted); + font: var(--ws-small-size)/1.2 var(--ws-mono); letter-spacing: .06em; text-transform: uppercase; +} +.ws-role-holders { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--ws-small-gap) var(--ws-inset); margin: 0 0 var(--ws-gap); } +.ws-role-holders[hidden] { display: none; } +.ws-role-holder-names { color: var(--ws-ink-muted); font: var(--ws-small-size)/1.2 var(--ws-mono); letter-spacing: .06em; text-transform: uppercase; } +.ws-role-holder + .ws-role-holder::before { content: '·'; margin-inline: var(--ws-small-gap); } +.ws-role-holders a.kbn-wikilink-live { color: inherit; text-decoration: none; } +.ws-role-holders a.kbn-wikilink-live:is(:hover, :focus-visible) { + color: var(--ws-ink); text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 3px; +} +.ws-role-holds .ws-role-caption { margin-bottom: var(--ws-small-gap); } +.ws-role-hold-list { list-style: none; margin: 0; padding: 0; } +.ws-role-hold-list > li { margin: 0; padding: 0; } +.ws-role-hold { + display: grid; grid-template-columns: var(--ws-dot-size) minmax(0, 1fr) auto; column-gap: var(--ws-inset); align-items: baseline; + width: calc(100% + 2 * var(--ws-inset)); margin: 0 calc(-1 * var(--ws-inset)); padding: var(--ws-small-gap) var(--ws-inset); + border: 0; border-radius: var(--ws-control-radius); background: transparent; color: var(--ws-ink); + font: 500 var(--ws-label-size)/1.35 var(--ws-serif); text-align: left; cursor: pointer; +} +.ws-role-hold:hover { background: var(--ws-hover); } +.ws-role-hold:focus-visible { outline: var(--ws-focus-width) solid var(--ws-focus); outline-offset: calc(-1 * var(--ws-focus-width)); } +.ws-role-dot { + align-self: center; width: var(--ws-dot-size); height: var(--ws-dot-size); border-radius: 50%; + box-sizing: border-box; border: var(--ws-line-width) solid var(--ws-ink-muted); +} +.ws-role-hold[data-column='awaitingReview'] .ws-role-dot { border-color: var(--ws-fresh); background: var(--ws-fresh); } +.ws-role-hold[data-column='inFlight'] .ws-role-dot { border-color: var(--ws-machine); background: var(--ws-machine); } +.ws-role-hold[data-column='tempered'] .ws-role-dot { border-color: var(--ws-verdict); background: var(--ws-verdict); } +.ws-role-hold[data-column='composted'] .ws-role-dot { border-color: var(--ws-hairline); } +.ws-role-hold[data-column='composted'] .ws-role-hold-name { color: var(--ws-ink-muted); } +.ws-role-hold-name { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.ws-role-hold-column { color: var(--ws-ink-muted); font: var(--ws-small-size)/1.2 var(--ws-mono); letter-spacing: .06em; text-transform: uppercase; white-space: nowrap; } +.ws-role-hold-outcome { + grid-column: 2 / -1; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; + color: var(--ws-ink-muted); font-weight: 400; +} +.ws-role-more { + margin: var(--ws-small-gap) 0 0; padding: 0; border: 0; background: transparent; cursor: pointer; color: var(--ws-ink-muted); + font: var(--ws-small-size)/1.2 var(--ws-mono); letter-spacing: .06em; text-transform: uppercase; +} +.ws-role-more:is(:hover, :focus-visible) { color: var(--ws-ink); text-decoration: underline; text-underline-offset: 3px; } diff --git a/ui/src/board/workspace/fiberPageState.ts b/ui/src/board/workspace/fiberPageState.ts index 96a2e6b3..62299992 100644 --- a/ui/src/board/workspace/fiberPageState.ts +++ b/ui/src/board/workspace/fiberPageState.ts @@ -10,8 +10,9 @@ export function fiberPageColumn(card: KanbanCard) { return classifyFiber({ id: card.id, name: card.name, status: card.status, createdAt: card.createdAt, tags: card.tags, tempered: card.tempered, shuttleKind: card.shuttleKind, hasShuttleBlock: isAgentCard(card) }, { liveWorker: hasLiveWorker(card) }) } /** Only what the Desk admits is on its lifecycle: a Shuttle-managed fiber or a - * cycle. Any other fiber (a note opened from a link, a role) has no column. */ -function onDesk(card: KanbanCard): boolean { + * cycle. Any other fiber (a note opened from a link, a role) has no column, + * and its page offers nothing to launch, set or review. */ +export function onDesk(card: KanbanCard): boolean { return isAgentCard(card) || card.isCycle } /** Temper and Discard reach every fiber on the Desk's lifecycle that has no