Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ba0d404
Rebuild the reader's top strip as a running head
cailmdaley Oct 5, 2026
a6f5f88
Let a long index take the head's room and hold the selected label ove…
cailmdaley Oct 5, 2026
0114129
Name index labels by declared titles without thumbnails, and dismiss …
cailmdaley Oct 5, 2026
3f5ccf0
Document the running head's theming parts
cailmdaley Oct 5, 2026
0c7208f
Peek reports' titles before PDFs and audio
cailmdaley Oct 5, 2026
305bdc6
Try an unreadable title peek again after a minute
cailmdaley Oct 5, 2026
a6453c6
Ask for title peeks at high priority
cailmdaley Oct 5, 2026
e0bd528
Set the index a step below the fiber's name
cailmdaley Oct 5, 2026
8c50c9e
Draw the worker control at the head's right end
cailmdaley Oct 5, 2026
3f7d85f
Give Portolan's kicker a plain paper ground
cailmdaley Oct 5, 2026
81bf54e
Drop the fiber page's contents line; the tab strip lists the pages
cailmdaley Oct 5, 2026
b527263
Move the phone's worker control to the top bar's conversation dot
cailmdaley Oct 5, 2026
99a096b
Give the worker control and the page count a full gap
cailmdaley Oct 5, 2026
6e59662
Cover the head's worker control in the dock and workspace integration…
cailmdaley Oct 5, 2026
4ec3c55
Let a phone audio page taller than the screen scroll from its top
cailmdaley Oct 5, 2026
d63ea3d
Remember declared titles across visits
cailmdaley Oct 5, 2026
5792aac
Tighten the Desk's phone verdict buttons to a 28px plate with a 44px …
cailmdaley Oct 5, 2026
6bec01a
Withdraw the phone top bar's conversation dot
cailmdaley Oct 5, 2026
f8da8f6
Draw no worker pill on the fiber page; the reader head carries it
cailmdaley Oct 5, 2026
5a420db
Merge branch 'design/document-workspace' into design/ws-strip-a
cailmdaley Oct 5, 2026
64d0758
Merge design/ws-phone2: the head carries the one worker control
cailmdaley Oct 5, 2026
09ce220
Let the Desk's phone verdict buttons reach their 44px touch target pa…
cailmdaley Oct 5, 2026
6bad713
Merge design/ws-strip-a: the running head, worker control in the head…
cailmdaley Oct 5, 2026
dbd5f36
Ring the head's worker dot in its own pigment, and scope it past them…
cailmdaley Oct 5, 2026
c7660f4
Give the Desk's phone verdicts a 44px button with a 28px plate drawn …
cailmdaley Oct 5, 2026
df1c1b8
Merge design/ws-strip-a: worker dot halo and scoping
cailmdaley Oct 5, 2026
bdfaa35
Peek a document's title again when its version moves
cailmdaley Oct 5, 2026
58ace12
Join the listening page's scroller to the reader's scroll path
cailmdaley Oct 5, 2026
388b9fc
Merge main into design/document-workspace
cailmdaley Oct 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions docs/concepts/themes.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Constitution themes

A constitution can give its Shuttle channel a visual identity without changing its documents or the Desk.
Its theme reaches the reader's veil, floating chrome, filmstrip and thumbnail paper, page frames, label bars, fiber header and prose, Markdown or text pages, and audio waveform ink.
Its theme reaches the reader's veil, its running head and hover preview, thumbnail paper, page frames, label bars, fiber header and prose, Markdown or text pages, and audio waveform ink.
On the Board overview, its changed-work rows and every folio density wear the same paper and accents.
Its sidebar card has its own channel boundary, even inside another constitution's reader.
Phone top and bottom bars and the page sheet use the channel's materials without adding a card frame around the edge-to-edge page.
Expand Down Expand Up @@ -106,18 +106,18 @@ These hooks are stable; renaming or removing one is a theme-breaking change.
| Selector | Part |
|---|---|
| `[data-part='veil']` | Still backdrop beneath the reader |
| `[data-part='chrome-plate']` | Floating return/title or filmstrip plate; excludes the worker plate |
| `[data-part='tab-strip']` | Filmstrip container |
| `[data-part='tab']` | Document tab |
| `[data-part='tab'][aria-selected='true']` | Selected tab |
| `[data-part='tab-strip']` | The running head's index of pages |
| `[data-part='tab']` | One page's label in the index; `.ws-tab-label` holds its words |
| `[data-part='tab'][aria-selected='true']` | Selected label, ruled by its text decoration |
| `[data-part='tab-preview']` | Hover preview card beneath an index label |
| `[data-part='label-bar']` | Bottom document label and its controls |
| `[data-part='page-frame']` | Paper sheet enclosing content and label |
| `[data-part='fiber-header']` | Fiber status header |
| `[data-part='fiber-title']` | Constitution title |
| `[data-part='act']` | Scope limit, **not** a styling hook; composer, verdicts, workers and undo toast |
| `[data-part='prose']` | Fiber article or text/Markdown pane |
| `[data-part='prose'] h1`, `h2`, `h3`, `p`, `blockquote`, `code`, `table`, `hr` | Prose elements; prefix each with the prose selector |
| `[data-part='thumbnail']` | Shared preview's UI paper in filmstrip, folios, changed-work rows and page sheet |
| `[data-part='thumbnail']` | Shared preview's UI paper in the hover preview, folios, changed-work rows and page sheet |
| `[data-part='thumbnail-face']` | Designed text face beneath a loaded preview |
| `:scope[data-part='sidebar-card']` | Channel's Desk-card face in the reader sidebar |
| `:scope[data-part='since-row']` | Channel's since-you-were-here row |
Expand Down
200 changes: 146 additions & 54 deletions ui/e2e/workspace.mjs

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions ui/src/board/FileViewerPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,9 +370,9 @@ function buildThumbnail(src: string, path: string, kind: NonNullable<FileViewerO
}

/** Native viewers own their byte streams; metadata peeks stop after the first 64 KiB even if Range is ignored. */
async function readThumbnailMetadata(src: string, signal: AbortSignal, onSource: NonNullable<FileViewerOptions['onThumbnailSource']>): Promise<void> {
export async function readThumbnailMetadata(src: string, signal: AbortSignal, onSource: NonNullable<FileViewerOptions['onThumbnailSource']>, priority: RequestPriority = 'auto'): Promise<void> {
try {
const response = await fetch(src, { signal, headers: { Range: 'bytes=0-65535' } })
const response = await fetch(src, { signal, priority, headers: { Range: 'bytes=0-65535' } })
if (!response.ok || !response.body) return
const reader = response.body.getReader()
const bytes = new Uint8Array(65536)
Expand Down
30 changes: 27 additions & 3 deletions ui/src/board/KanbanModal.css
Original file line number Diff line number Diff line change
Expand Up @@ -2234,11 +2234,33 @@
opacity: 1;
pointer-events: auto;
}
.kbn-card-review-meta-actions .kbn-review-meta-btn {
/* The button is the whole 44px touch target, on a transparent ground; its
compact 28px plate is drawn inside it. Negative margins keep the meta
row's layout at the plate's height, so the card does not grow. */
.kbn-card-review-meta-actions .kbn-review-meta-btn.kbn-action {
position: relative;
isolation: isolate;
height: 44px;
margin-block: -8px;
min-width: 44px;
padding: 8px;
padding: 0 10px;
background: transparent;
border-width: 0;
}
.kbn-card-review-meta-actions .kbn-review-meta-btn.kbn-action:hover { background: transparent; }
.kbn-card-review-meta-actions .kbn-review-meta-btn.kbn-action:focus-visible { outline: none; }
.kbn-card-review-meta-actions .kbn-review-meta-btn::before {
content: '';
position: absolute;
inset: 8px 0;
z-index: -1;
background: var(--verdict-tint);
border: 1px solid var(--verdict-edge);
border-radius: 2px;
transition: background 120ms ease, border-color 120ms ease;
}
.kbn-card-review-meta-actions .kbn-review-meta-btn:hover::before { background: var(--verdict-tint-strong); border-color: var(--verdict-pigment); }
.kbn-card-review-meta-actions .kbn-review-meta-btn:focus-visible::before { outline: 2px solid var(--verdict-pigment); outline-offset: 2px; }
}
@media (prefers-reduced-motion: reduce) {
.kbn-card-review-meta-actions { transition: none; }
Expand Down Expand Up @@ -2534,9 +2556,11 @@
line-height: 1.55;
-webkit-line-clamp: 1 !important;
}
/* The row clips sideways only, so a verdict's touch target can reach past its plate vertically. */
.kbn-card-meta {
flex-wrap: nowrap;
overflow: hidden;
overflow-x: clip;
overflow-y: visible;
gap: 5px;
}
.kbn-card-meta-spacer { flex: 0 1 auto; }
Expand Down
5 changes: 2 additions & 3 deletions ui/src/board/appConversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,16 +73,15 @@ export function appWorkerLink(card: KanbanCard, classes = ''): HTMLAnchorElement
}

/**
* A terminal worker's pill, shared by the Desk, reader navbar and fiber page.
* A terminal worker's pill, shared by the Desk and the reader.
*
* Desktop Claude opening follows this browser's preference: Kitty, the
* Remote Control web link, or the installed Claude app's session route.
* An unavailable Remote Control link is an explicitly labelled terminal
* fallback. Other CLI harnesses open in Kitty. Phones use a recorded Claude
* HTTPS link or a mark of the worker's state. The touch stylesheet keys on the
* element, not on a class: a `.kbn-card-worker` that is not a link takes no
* taps. On the fiber page, the pill uses the band's phone control height;
* on the Desk card it keeps its compact card sizing.
* taps. On the Desk card it keeps its compact card sizing.
*
* `phase` lets the waiting and attention states take the pill over (label,
* colour, title); a card outside In flight passes false and stays "Aloft".
Expand Down
10 changes: 5 additions & 5 deletions ui/src/board/dockWorkerPill.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,12 @@ beforeEach(() => {
afterEach(() => { dock.reset(); reader.dispose(); vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.replaceChildren() })
function show(card: KanbanCard): HTMLElement | null {
reader.show(channel(card), channel(card).documents[0].key, 'Desk', card)
expect(reader.el.querySelector('.ws-navbar .kbn-card-worker')).toBeNull()
const band = dock.bandFor(card).el
expect(band.querySelectorAll('.kbn-card-worker').length).toBeLessThanOrEqual(1)
return band.querySelector('.ws-worker-pill[data-part="act"] .kbn-card-worker')
expect(dock.bandFor(card).el.querySelector('.kbn-card-worker')).toBeNull()
// The head carries the one worker control.
expect(reader.el.querySelectorAll('.ws-navbar .kbn-card-worker').length).toBeLessThanOrEqual(1)
return reader.el.querySelector<HTMLElement>('.ws-navbar .ws-head-worker .kbn-card-worker')
}
describe('the fiber page act zone owns the only worker control', () => {
describe('the worker control lives in the reader head; the fiber page draws none', () => {
it('opens the owner terminal without repeating agent or worker state on the fiber', () => {
const pill = show(worker())!
expect(pill.tagName).toBe('BUTTON')
Expand Down
20 changes: 0 additions & 20 deletions ui/src/board/workspace/Dock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import { PastedImages, buildImageStrip, composeDirective, filesFromTransfer, pas
import { fiberPageColumn } from './fiberPageState.js'
import { anchorPopover, type Release } from './anchoredPopover.js'
import { anchorSelect, dismissSelectPicker } from './selectPicker.js'
import { workerPlate } from './workerPlate.js'
import './tokens.css'
import './dock.css'

Expand Down Expand Up @@ -407,7 +406,6 @@ export class Dock {
private composerBusy: ((on: boolean, except?: HTMLButtonElement) => void) | null = null
private composerDisposers: (() => void)[] = []
private workerPillCard: KanbanCard | null = null
private workerPaint: (() => void) | null = null
private guidance: HTMLElement | null = null
private dismissMeeting: (() => boolean) | null = null
private dismissParent: (() => boolean) | null = null
Expand Down Expand Up @@ -541,7 +539,6 @@ export class Dock {
this.searchRenderToken++
this.fiberIndex = null
this.card = this.workerPillCard = this.transcriptCard = null
this.workerPaint = null
this.transcriptPane = this.guidance = null
this.meetingPaint = this.composerBusy = null
for (const timer of this.timers) window.clearTimeout(timer)
Expand Down Expand Up @@ -703,7 +700,6 @@ export class Dock {
this.historySync?.()
this.composerPaint?.()
this.actPaint?.()
this.workerPaint?.()
for (const [button, blocked] of this.blockedDispatches) {
if (blocked.worker === workerIdentity(card)) continue
button.disabled = false
Expand Down Expand Up @@ -742,22 +738,6 @@ export class Dock {
errorEl.className = 'kbn-detail-error'
errorEl.setAttribute('role', 'alert')
errorEl.style.display = 'none'
// The fiber's worker, drawn as the sidebar card draws it: the pill that
// opens the real conversation, with its state and elapsed time.
const worker = document.createElement('div')
worker.className = 'ws-worker-pill'
worker.dataset.part = 'act'; worker.dataset.act = 'worker'
this.workerPaint = () => {
const focused = worker.contains(document.activeElement)
const pill = this.workerPillFor(card)
worker.replaceChildren(...(pill ? [workerPlate(card, pill)] : []))
worker.hidden = !pill
if (focused) worker.querySelector<HTMLElement>('.kbn-card-worker')?.focus({ preventScroll: true })
}
this.workerPaint()
const clock = window.setInterval(() => this.workerPaint?.(), 30000)
this.composerDisposers.push(() => window.clearInterval(clock))
body.append(worker)
if (shuttleManaged) body.append(this.buildComposer(card))
body.append(this.buildTranscriptPane(card))

Expand Down
22 changes: 22 additions & 0 deletions ui/src/board/workspace/DocumentHost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ const render = vi.hoisted(() => ({
suspend: vi.fn(), resume: vi.fn(), once: vi.fn(), dispose: vi.fn(), refresh: vi.fn(async () => {}),
}))
vi.mock('../FileViewerPanel.js', () => ({
readThumbnailMetadata: vi.fn(async () => {}),
buildFileViewer: vi.fn((_base, path, owner, frame, text, options) => {
const viewer = document.createElement('div')
viewer.dataset.path = path
// A listening page is its own scroller, as the shared renderer builds it.
if (options?.kind === 'audio') viewer.className = 'kbn-fileview-media kbn-fileview-audio'
render.calls.push({ viewer, path, owner, options, frame, text })
return viewer
}),
Expand Down Expand Up @@ -252,6 +255,25 @@ describe('stable document frames', () => {
expect(host.get(docs[0].key)!.viewer).not.toBe(first.viewer)
})

it('wires the listening page scroller into the scroll hook and restores it after eviction', async () => {
const audio: WorkspaceDocument = { key: 'host-a:/doc/song.mp3', owner: 'host-a', path: '/doc/song.mp3', name: 'song.mp3', kind: 'audio', provenance: [] }
host.setChannel([audio], audio.key)
const first = render.calls.at(-1)!
await ready(first)
first.viewer.scrollTop = 240
first.viewer.dispatchEvent(new Event('scroll'))
expect(onScroll).toHaveBeenLastCalledWith(audio.key, 240)
for (let n = 1; n <= 10; n++) {
host.setChannel([docs[n]], docs[n].key)
await ready()
}
expect(host.get(audio.key)).toBeUndefined()
host.setChannel([audio], audio.key)
const restored = render.calls.at(-1)!
expect(restored.viewer).not.toBe(first.viewer)
expect(restored.viewer.scrollTop).toBe(240)
})

it('charges heavy HTML two slots and bounds off-channel frame metadata with the LRU', async () => {
for (let n = 0; n < 30; n++) {
const document = doc(n)
Expand Down
5 changes: 5 additions & 0 deletions ui/src/board/workspace/DocumentHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,11 @@ export class DocumentHost {
state.frame.content.replaceChildren(viewer)
state.frame.viewer = viewer
}
// A listening page scrolls inside its own viewer (audio.css), which joins the reader's scroll path.
if (doc.kind === 'audio' && viewer.classList.contains('kbn-fileview-audio')) {
if (replacement) bindReplacementScroll = () => this.bindScroller(state, viewer)
else this.bindScroller(state, viewer)
}
this.watchVideoPoster(state, viewer)
}

Expand Down
52 changes: 52 additions & 0 deletions ui/src/board/workspace/DocumentTitles.persist.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const load = async () => { vi.resetModules(); return import('./DocumentTitles.js') }
const stored = new Map<string, string>()
beforeEach(() => {
stored.clear()
vi.stubGlobal('localStorage', { getItem: (key: string) => stored.get(key) ?? null, setItem: (key: string, value: string) => stored.set(key, value) })
})
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals() })

describe('remembered titles', () => {
it('recalls a declared title from an earlier visit, and only a fresh read makes it current', async () => {
vi.useFakeTimers()
let titles = await load()
titles.cacheDocumentTitle('host:/r/report.html', '/r/report.html', '<title>The report</title>', 'etag-1')
titles.cacheDocumentTitle('host:/r/untitled.html', '/r/untitled.html', '<p>no title</p>', 'etag-2')
vi.advanceTimersByTime(500)
expect(JSON.parse(localStorage.getItem(titles.TITLE_STORAGE)!)).toEqual([['host:/r/report.html', 'The report', 'etag-1']])
titles = await load()
expect(titles.declaredTitle('host:/r/report.html')?.title).toBe('The report')
expect(titles.titleIsCurrent('host:/r/report.html')).toBe(false)
const heard = vi.fn()
titles.watchDocumentTitles(heard)
titles.cacheDocumentTitle('host:/r/report.html', '/r/report.html', '<title>The report</title>', 'etag-1')
expect(titles.titleIsCurrent('host:/r/report.html')).toBe(true)
expect(heard).not.toHaveBeenCalled()
titles.cacheDocumentTitle('host:/r/report.html', '/r/report.html', '<title>The revised report</title>', 'etag-3')
expect(heard).toHaveBeenCalledWith('host:/r/report.html')
})

it('keeps only the most recently used titles', async () => {
vi.useFakeTimers()
const titles = await load()
for (let i = 0; i < titles.TITLE_STORAGE_LIMIT + 5; i++) titles.cacheDocumentTitle(`k${i}`, `/d${i}.md`, `# Title ${i}`, `e${i}`)
titles.declaredTitle('k0')
vi.advanceTimersByTime(500)
const stored: string[][] = JSON.parse(localStorage.getItem(titles.TITLE_STORAGE)!)
expect(stored).toHaveLength(titles.TITLE_STORAGE_LIMIT)
expect(stored.at(-1)?.[0]).toBe('k0')
expect(stored.some(entry => entry[0] === 'k1')).toBe(false)
})

it('works without storage', async () => {
vi.useFakeTimers()
vi.stubGlobal('localStorage', { getItem: () => { throw new Error('denied') }, setItem: () => { throw new Error('denied') } })
const titles = await load()
expect(titles.cacheDocumentTitle('k', '/d.md', '# Still named', 'e').title).toBe('Still named')
expect(() => vi.advanceTimersByTime(500)).not.toThrow()
expect(titles.declaredTitle('k')?.title).toBe('Still named')
})
})
57 changes: 54 additions & 3 deletions ui/src/board/workspace/DocumentTitles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,53 @@ export interface DocumentTitle { title?: string; preview: string; etag?: string
const titles = new Map<string, DocumentTitle>()
const versions = new Map<string, DocumentTitle>()
const listeners = new Set<(key: string) => void>()
export function declaredTitle(key: string): DocumentTitle | undefined { return titles.get(key) }
/** Keys whose title was read from the document in this session, not recalled from storage. */
const current = new Set<string>()

/**
* Declared titles outlive the session so a return visit names its tabs at
* once: the most recently used titles, each with the validator it was read
* under, kept in localStorage. Storage is optional; every access may throw.
*/
export const TITLE_STORAGE = 'shuttle:workspace:titles'
export const TITLE_STORAGE_LIMIT = 300
type StoredTitle = [key: string, title: string, etag: string | null]
let saveTimer: ReturnType<typeof setTimeout> | undefined
function recall(): void {
try {
const stored: unknown = JSON.parse(localStorage.getItem(TITLE_STORAGE) ?? '[]')
if (!Array.isArray(stored)) return
for (const entry of stored.slice(-TITLE_STORAGE_LIMIT)) {
if (!Array.isArray(entry) || typeof entry[0] !== 'string' || typeof entry[1] !== 'string') continue
titles.set(entry[0], { title: entry[1], preview: '', etag: typeof entry[2] === 'string' ? entry[2] : undefined })
}
} catch { /* Storage is optional. */ }
}
function save(): void {
clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
const entries: StoredTitle[] = []
for (const [key, value] of titles) if (value.title) entries.push([key, value.title, value.etag ?? null])
try { localStorage.setItem(TITLE_STORAGE, JSON.stringify(entries.slice(-TITLE_STORAGE_LIMIT))) } catch { /* Storage is optional. */ }
}, 500)
}
/** Most recently used last: a read or a write moves a title to the end; storage keeps the newest. */
let latest: string | undefined
function touch(key: string, value: DocumentTitle): void {
titles.delete(key)
titles.set(key, value)
latest = key
}
recall()
latest = [...titles.keys()].at(-1)

export function declaredTitle(key: string): DocumentTitle | undefined {
const value = titles.get(key)
if (value && key !== latest) { touch(key, value); if (value.title) save() }
return value
}
/** A recalled title names a tab until the document itself is read again; only then is it current. */
export function titleIsCurrent(key: string): boolean { return current.has(key) }
export function watchDocumentTitles(listener: (key: string) => void): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
Expand Down Expand Up @@ -74,7 +120,12 @@ export function cacheDocumentTitle(key: string, path: string, source: string | U
versions.set(version, next)
if (versions.size > 512) versions.delete(versions.keys().next().value!)
const held = titles.get(key)
titles.set(key, next)
if (held !== next) for (const listener of listeners) listener(key)
current.delete(key)
current.add(key)
// Bounded for a long session: a forgotten key only means its next render peeks it again.
if (current.size > 2000) current.delete(current.values().next().value!)
touch(key, next)
save()
if (held !== next && (held?.title !== next.title || held?.preview !== next.preview)) for (const listener of listeners) listener(key)
return next
}
Loading
Loading