From ba0d4048e3e482ae0dc4d529e5375b71024d1b15 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Mon, 5 Oct 2026 15:25:11 +0200 Subject: [PATCH 01/24] Rebuild the reader's top strip as a running head One row on the veil with no plates: the constitutions glyph on the sidebar's edge, the serif return and fiber name, the verdicts as pigmented verbs, an index of bare serif labels centred over the stage, and the mono page count at the right end. Thumbnails come on demand in a pointer-only hover preview; the filmstrip mode is gone. Themes lose the chrome-plate part and the selected tab's fill and shadow. Co-Authored-By: Claude Opus 5.5 --- ui/src/board/workspace/Reader.sidebar.test.ts | 10 +- ui/src/board/workspace/Reader.ts | 70 ++++--- ui/src/board/workspace/TabPreview.ts | 196 ++++++++++++++++++ ui/src/board/workspace/TabStrip.test.ts | 84 +++++++- ui/src/board/workspace/TabStrip.ts | 117 +++++------ .../workspace/Workspace.integration.test.ts | 4 +- ui/src/board/workspace/reader.css | 101 +++++---- ui/src/board/workspace/tabs.css | 64 +++--- ui/src/board/workspace/themes/blueprint.css | 27 +-- .../workspace/themes/laboratory-paper.css | 29 +-- ui/src/board/workspace/themes/night-chart.css | 25 +-- ui/src/board/workspace/themes/portolan.css | 25 +-- ui/src/board/workspace/tokens.css | 21 +- 13 files changed, 478 insertions(+), 295 deletions(-) create mode 100644 ui/src/board/workspace/TabPreview.ts diff --git a/ui/src/board/workspace/Reader.sidebar.test.ts b/ui/src/board/workspace/Reader.sidebar.test.ts index 37ccf43f..e558d468 100644 --- a/ui/src/board/workspace/Reader.sidebar.test.ts +++ b/ui/src/board/workspace/Reader.sidebar.test.ts @@ -119,8 +119,9 @@ describe('Reader channel sidebar', () => { expect(row.dataset.part).toBe('sidebar-card') expect(row.hasAttribute('data-ws-theme-boundary')).toBe(true) expect(reader.el.hasAttribute('data-ws-theme-boundary')).toBe(true) - expect(reader.el.querySelectorAll('[data-part="chrome-plate"]')).toHaveLength(2) - for (const part of ['tab-strip', 'tab', 'thumbnail', 'thumbnail-face', 'page-sheet', 'page-sheet-panel']) { + // The running head sits on the veil: there are no chrome plates to theme. + expect(reader.el.querySelectorAll('[data-part="chrome-plate"]')).toHaveLength(0) + for (const part of ['tab-strip', 'tab', 'tab-preview', 'page-sheet', 'page-sheet-panel']) { expect(reader.el.querySelector(`[data-part="${part}"]`)).not.toBeNull() } expect(reader.el.querySelector('.ws-navbar')?.getAttribute('data-part')).toBe('phone-topbar') @@ -200,8 +201,9 @@ describe('Reader channel sidebar', () => { const wide = makeReader() const wideToggle = wide.el.querySelector('.ws-sidebar-toggle')! expect(wide.el.classList.contains('ws-with-sidebar')).toBe(true) - expect(wideToggle.textContent).toBe('▥ Constitutions') - expect(wideToggle.title).toBe('Constitutions (s or ⌘\\)') + expect(wideToggle.textContent).toBe('') + expect(wideToggle.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true') + expect(wideToggle.title).toBe('Constitutions · s') expect(wideToggle.getAttribute('aria-expanded')).toBe('true') disposeReader(wide) diff --git a/ui/src/board/workspace/Reader.ts b/ui/src/board/workspace/Reader.ts index ea3ff2af..3888e74d 100644 --- a/ui/src/board/workspace/Reader.ts +++ b/ui/src/board/workspace/Reader.ts @@ -87,7 +87,6 @@ export class Reader { private readonly tabs: TabStrip private readonly navbar: HTMLElement private readonly lead: HTMLElement - private readonly trail: HTMLElement private keyboardInput = false private readonly themeChanged = (): void => this.syncPlainToggle() private readonly title: HTMLButtonElement @@ -96,6 +95,8 @@ export class Reader { private readonly verdicts = element('span', 'ws-nav-verdicts') private verdictKey: string | null = null private readonly position = element('span', 'ws-position') + /** The running head's page count, at its right end. */ + private readonly headPosition = element('span', 'ws-position ws-head-position') private readonly pageTitle = element('span', 'ws-thumb-title') private readonly arrivalSummary = element('span', 'ws-thumb-arrival') private readonly topbar = new PhoneTopbar(hidden => this.el.classList.toggle('ws-topbar-hidden', this.phone.matches && hidden)) @@ -150,26 +151,22 @@ export class Reader { this.verdicts.dataset.part = 'act'; this.verdicts.dataset.act = 'verdict' this.verdicts.hidden = true this.el.inert = true - this.tabs = new TabStrip(i => this.selectIndex(i), () => this.toggleExpand(), { - shuttleBase: opts.shuttleBase, - onHeight: height => this.el.style.setProperty('--ws-strip-h', `${height}px`), - }) + this.tabs = new TabStrip(i => this.selectIndex(i), () => this.toggleExpand(), { shuttleBase: opts.shuttleBase }) + this.sidebarToggle = button('ws-sidebar-toggle', '', () => this.toggleSidebar(), 'Constitutions') + this.sidebarToggle.title = 'Constitutions · s' + this.sidebarToggle.innerHTML = '' this.returnButton = button('ws-return', '‹ Desk', () => opts.onReturn()) this.title = button('ws-channel-title', '', () => this.openSwitcher()) - this.sidebarToggle = button('ws-sidebar-toggle', '▥ Constitutions', () => this.toggleSidebar(), 'Constitutions') - this.sidebarToggle.title = 'Constitutions (s or ⌘\\)' this.lead = element('div', 'ws-nav-lead') - this.lead.dataset.part = 'chrome-plate' - this.lead.append(this.returnButton, this.sidebarToggle, this.title, this.verdicts) - this.trail = element('div', 'ws-nav-trail') + this.lead.append(this.sidebarToggle, this.returnButton, this.title, this.verdicts) + this.headPosition.setAttribute('aria-hidden', 'true') this.navbar = element('nav', 'ws-navbar') this.navbar.dataset.part = 'phone-topbar' - const tabPlate = element('div', 'ws-nav-tabs') - tabPlate.dataset.part = 'chrome-plate' - tabPlate.append(this.tabs.el) - this.navbar.append(this.lead, tabPlate, this.trail) - this.prev = button('ws-thumb-button', '‹', () => this.step(-1), 'Previous document') - this.next = button('ws-thumb-button', '›', () => this.step(1), 'Next document') + this.navbar.append(this.lead, this.tabs.el, this.headPosition) + this.prev = button('ws-thumb-button', '', () => this.step(-1), 'Previous document') + this.next = button('ws-thumb-button', '', () => this.step(1), 'Next document') + this.prev.innerHTML = '' + this.next.innerHTML = '' const thumbMenu = button('ws-thumb-button', '⋯', () => { const doc = this.document if (doc) this.openMenu(doc, thumbMenu) @@ -181,7 +178,9 @@ export class Reader { const pageChoice = button('ws-page-choice', '', () => { this.closeMenu(); this.pageSheet.show(pageChoice) }, 'Choose a page') pageChoice.setAttribute('aria-haspopup', 'dialog') pageChoice.setAttribute('aria-expanded', 'false') - pageChoice.append(this.pageTitle, this.arrivalSummary, this.position) + const pageMeta = element('span', 'ws-thumb-meta') + pageMeta.append(this.position, this.arrivalSummary) + pageChoice.append(this.pageTitle, pageMeta) thumb.append(this.prev, pageChoice, this.next, thumbMenu) this.announcement.setAttribute('aria-live', 'polite') this.announcement.setAttribute('aria-atomic', 'true') @@ -216,6 +215,7 @@ export class Reader { const main = element('div', 'ws-stage-row') main.append(this.sidebar, this.stage) this.el.append(this.veil, this.navbar, main, thumb, this.announcement, this.pageSheet.el) + if (this.tabs.preview) this.el.append(this.tabs.preview.el) this.host = new DocumentHost(this.track, { shuttleBase: opts.shuttleBase, buildProse: opts.buildProse, @@ -430,17 +430,16 @@ export class Reader { frame.el.classList.toggle('ws-expanded', doc.key === this.selected && this.expanded) this.fillLabel(frame, ch.labels[i]) }) - this.tabs.setCompact(this.expanded) const fresh = this.seen.observe(ch, this.selected ?? '', this.channelReady) this.tabs.fresh(fresh) this.pageSheet.update(ch, this.selected ?? '', fresh) this.tabs.mark(index, animate) - this.position.textContent = `${index + 1} / ${ch.documents.length}` + this.position.textContent = this.headPosition.textContent = `${index + 1} / ${ch.documents.length}` const doc = ch.documents[index] if (doc) { const metadata = documentLabelMetadata(doc, ch.labels[index], ch.owner) this.pageTitle.textContent = doc.kind === 'fiber' ? ch.labels[index] : metadata.title - this.arrivalSummary.textContent = metadata.summary + this.arrivalSummary.textContent = metadata.summary && ` · ${metadata.summary.charAt(0).toLowerCase()}${metadata.summary.slice(1)}` this.topbar.select(doc.key) } this.prev.disabled = index <= 0 @@ -513,21 +512,26 @@ export class Reader { return } const style = getComputedStyle(this.navbar) - const gap = parseFloat(style.columnGap) || 12 + const gap = parseFloat(style.columnGap) || 24 const padLeft = parseFloat(style.paddingLeft) || 12 const width = this.navbar.clientWidth - padLeft - (parseFloat(style.paddingRight) || 12) if (!width) return - const lead = this.returnButton.offsetWidth + this.sidebarToggle.offsetWidth + this.verdicts.offsetWidth + 3 * gap + Math.min(280, Math.max(100, this.title.scrollWidth)) - const tabs = this.tabs.buttons.reduce((sum, b) => sum + b.offsetWidth, 0) + Math.max(0, this.tabs.buttons.length - 1) * 2 + 4 - // A fitting strip is centred over the stage, which starts after the sidebar; - // a longer strip takes the remaining band, bounded by both controls. + const leadGap = parseFloat(getComputedStyle(this.lead).columnGap) || 0 + const leadParts = [this.sidebarToggle, this.returnButton, this.title, this.verdicts].filter(el => el.offsetWidth > 0) + const lead = leadParts.reduce((sum, el) => sum + (el === this.title ? Math.min(this.measure('title-ceiling', 360), el.scrollWidth) : el.offsetWidth), 0) + Math.max(0, leadParts.length - 1) * leadGap + const position = this.headPosition.offsetWidth + const stripStyle = getComputedStyle(this.tabs.el) + const tabs = this.tabs.buttons.reduce((sum, b) => sum + b.offsetWidth, 0) + (parseFloat(stripStyle.paddingLeft) || 0) + (parseFloat(stripStyle.paddingRight) || 0) + 1 + // The index is centred over the stage, which starts after the sidebar, + // as wide as it can be without crossing the lead or the page count; + // where that leaves too little, it takes the room between them. const sidebar = this.sidebarShown ? this.sidebar.offsetWidth : 0 const centre = sidebar + (this.navbar.clientWidth - sidebar) / 2 - padLeft - const leadBand = Math.floor(centre - tabs / 2 - gap) - const trailBand = width - leadBand - tabs - 2 * gap - this.navbar.style.gridTemplateColumns = leadBand >= lead && trailBand >= 0 - ? `${leadBand}px ${tabs}px minmax(0, 1fr)` - : `${Math.min(lead, width * 0.4)}px minmax(0, 1fr) 0px` + const half = Math.min(centre - lead - gap, width - position - gap - centre) + const band = Math.min(tabs, 2 * half) + this.navbar.style.gridTemplateColumns = band >= this.measure('index-floor', 240) + ? `${Math.floor(centre - band / 2 - gap)}px ${Math.floor(band)}px minmax(0, 1fr)` + : `${Math.ceil(Math.min(lead, width * 0.45))}px minmax(0, 1fr) auto` } private layout(animate: boolean): void { this.layoutNavbar() @@ -757,7 +761,7 @@ export class Reader { } private stagePlaces(): { page: number; tabs: number; sidebar: number } { const page = this.selected ? this.host.get(this.selected)?.el.getBoundingClientRect().left ?? 0 : 0 - const tabs = this.navbar.querySelector('.ws-nav-tabs')?.getBoundingClientRect().left ?? 0 + const tabs = this.tabs.el.getBoundingClientRect().left return { page, tabs, sidebar: this.sidebar.offsetWidth } } private slideSidebar(shown: boolean, before: { page: number; tabs: number; sidebar: number }): void { @@ -771,7 +775,7 @@ export class Reader { if (el && Math.abs(from) >= 1) animations.push(el.animate([{ translate: `${from}px 0` }, { translate: '0 0' }], options)) } glide(this.parallax, before.page - after.page) - glide(this.navbar.querySelector('.ws-nav-tabs'), before.tabs - after.tabs) + glide(this.tabs.el, before.tabs - after.tabs) const width = Math.max(before.sidebar, after.sidebar) const hidden = { translate: `${-width}px 0`, opacity: 0 }, rest = { translate: '0 0', opacity: 1 } this.el.classList.add('ws-sidebar-sliding') @@ -826,6 +830,8 @@ export class Reader { if (this.pageSheet.isOpen) return this.keyboardModality() if (!this.active || e.isComposing || e.defaultPrevented || blockingDialogOpen()) return + // Any key puts the pointer's preview away; Escape stops there. + if (this.tabs.preview?.dismiss() && e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); return } if ((e.key === 'Enter' || e.key === 'Escape') && (this.picker.el.contains(e.target as Node) || this.sidebarPicker.el.contains(e.target as Node))) return // Alt chords never bypass editable/native control guards; command shortcuts may. const forward = shouldForwardDocumentKey(e) diff --git a/ui/src/board/workspace/TabPreview.ts b/ui/src/board/workspace/TabPreview.ts new file mode 100644 index 00000000..053759f8 --- /dev/null +++ b/ui/src/board/workspace/TabPreview.ts @@ -0,0 +1,196 @@ +import type { Channel, WorkspaceDocument } from './documents.js' +import { documentLabelMetadata } from './documents.js' +import { extractEmbeds } from '../attachments.js' +import { Thumbnail } from './Thumbnail.js' + +/** A first hover waits this long; a preview already up (or just gone) follows the pointer at once. */ +export const PREVIEW_DELAY_MS = 400 +/** The pointer may cross the gap between labels, or come back, within this long without a fresh wait. */ +export const PREVIEW_GRACE_MS = 300 +/** The preview's width, its gap below the label, and its distance from the viewport edges. */ +const CARD_WIDTH = 224 +const CARD_GAP = 6 +const VIEWPORT_MARGIN = 12 + +/** + * The index's thumbnails, on demand: hovering a tab shows its document at a + * legible size beneath it. Pointer only; it never takes focus or input, and + * a key, a press, Escape or leaving the index dismisses it. + */ +export class TabPreview { + readonly el: HTMLElement + private readonly thumbSlot: HTMLElement + private readonly title: HTMLElement + private readonly meta: HTMLElement + private readonly thumbs = new Map() + private readonly aspects = new Map() + private readonly shuttleBase: string + private channel: Channel | null = null + private labels: string[] = [] + private current: string | null = null + private anchor: HTMLElement | null = null + private suppressed: HTMLElement | null = null + private pending: ReturnType | null = null + private leaving: ReturnType | null = null + private warmUntil = 0 + private enabled = true + + constructor(shuttleBase: string) { + this.shuttleBase = shuttleBase + this.el = document.createElement('div') + this.el.className = 'ws-tab-preview' + this.el.dataset.part = 'tab-preview' + this.el.setAttribute('aria-hidden', 'true') + this.el.inert = true + this.el.hidden = true + this.thumbSlot = document.createElement('div') + this.thumbSlot.className = 'ws-tab-preview-thumb' + this.title = document.createElement('div') + this.title.className = 'ws-tab-preview-title' + this.meta = document.createElement('div') + this.meta.className = 'ws-tab-preview-meta' + this.el.append(this.thumbSlot, this.title, this.meta) + } + + get open(): boolean { return !this.el.hidden } + + /** Listen on the index; tabs are found by delegation so re-renders need no rebinding. */ + attach(strip: HTMLElement): void { + strip.addEventListener('pointerover', this.over) + strip.addEventListener('pointerout', this.out) + strip.addEventListener('pointerdown', this.press) + strip.addEventListener('scroll', this.dismissNow, { passive: true }) + strip.addEventListener('wheel', this.dismissNow, { passive: true }) + } + + update(channel: Channel, labels: string[]): void { + this.channel = channel + this.labels = labels + const keys = new Set(channel.documents.map(doc => doc.key)) + for (const [key, thumb] of this.thumbs) if (!keys.has(key)) { thumb.dispose(); this.thumbs.delete(key); this.aspects.delete(key) } + if (this.current && !keys.has(this.current)) this.dismiss() + else if (this.current && this.anchor) this.fill(this.current) + } + + /** The strip is hidden or condensed: nothing may be previewed. */ + setEnabled(enabled: boolean): void { + this.enabled = enabled + if (!enabled) this.dismiss() + } + + /** Hide at once; true when a preview was showing, so Escape can stop there. */ + dismiss(): boolean { + const was = this.open + this.clearTimers() + this.el.hidden = true + this.current = null + this.anchor = null + if (was) this.warmUntil = performance.now() + PREVIEW_GRACE_MS + for (const thumb of this.thumbs.values()) thumb.schedule() + return was + } + + dispose(): void { + this.dismiss() + for (const thumb of this.thumbs.values()) thumb.dispose() + this.thumbs.clear() + this.el.remove() + } + + private readonly over = (e: PointerEvent): void => { + if (e.pointerType === 'touch' || !this.enabled) return + const tab = (e.target as Element).closest('[role="tab"]') + if (!tab || tab === this.suppressed) return + if (this.leaving !== null) { clearTimeout(this.leaving); this.leaving = null } + if (tab === this.anchor) return + if (this.pending !== null) clearTimeout(this.pending) + this.pending = null + if (this.open || performance.now() < this.warmUntil) { this.show(tab); return } + this.pending = setTimeout(() => { this.pending = null; if (tab.isConnected) this.show(tab) }, PREVIEW_DELAY_MS) + } + private readonly out = (e: PointerEvent): void => { + const from = (e.target as Element).closest('[role="tab"]') + const to = (e.relatedTarget as Element | null)?.closest?.('[role="tab"]') + if (!from || from === to) return + if (from === this.suppressed) this.suppressed = null + if (this.pending !== null) { clearTimeout(this.pending); this.pending = null } + if (!this.open || to) return + if (this.leaving !== null) clearTimeout(this.leaving) + this.leaving = setTimeout(() => { this.leaving = null; this.dismiss() }, PREVIEW_GRACE_MS / 3) + } + private readonly press = (e: PointerEvent): void => { + this.suppressed = (e.target as Element).closest('[role="tab"]') + this.dismiss() + } + private readonly dismissNow = (): void => { if (this.open || this.pending !== null) this.dismiss() } + + private clearTimers(): void { + if (this.pending !== null) clearTimeout(this.pending) + if (this.leaving !== null) clearTimeout(this.leaving) + this.pending = this.leaving = null + } + + private show(tab: HTMLElement): void { + const key = tab.dataset.tabKey + if (!key || !this.channel?.documents.some(doc => doc.key === key)) return + this.anchor = tab + this.current = key + this.fill(key) + this.el.hidden = false + this.place(tab) + this.thumbs.get(key)?.schedule() + } + + private fill(key: string): void { + const channel = this.channel + const index = channel?.documents.findIndex(doc => doc.key === key) ?? -1 + const doc = index >= 0 ? channel?.documents[index] : undefined + if (!channel || !doc) return + const thumb = this.thumb(doc, channel) + if (thumb.el.parentElement !== this.thumbSlot) this.thumbSlot.replaceChildren(thumb.el) + this.thumbSlot.dataset.kind = doc.kind + const label = this.labels[index] ?? doc.name + const metadata = documentLabelMetadata(doc, label, channel.owner) + this.title.textContent = doc.kind === 'fiber' ? channel.name : metadata.title + const name = doc.kind === 'fiber' || doc.name === metadata.title ? '' : doc.name + this.meta.textContent = [name, metadata.summary].filter(Boolean).join(' · ') + } + + private thumb(doc: WorkspaceDocument, channel: Channel): Thumbnail { + let thumb = this.thumbs.get(doc.key) + const prose = extractEmbeds(channel.body).body || channel.outcome || '' + if (!thumb) { + const key = doc.key + thumb = new Thumbnail({ + key: `peek:${key}`, shuttleBase: this.shuttleBase, + file: doc.kind === 'fiber' ? undefined : { fullPath: doc.path, owner: doc.owner, basename: doc.name }, + fallback: `\n${prose.slice(0, 400) || channel.name}`, + className: `ws-tab-preview-face ws-tab-kind-${doc.kind}`, captioned: true, + priority: () => this.current === key && this.open ? 3 : 0, + distance: () => 0, + onAspect: aspect => { + this.aspects.set(key, Math.max(0.75, Math.min(2.4, aspect))) + if (this.current === key) this.paintAspect(key) + }, + }) + this.thumbs.set(key, thumb) + } + if (doc.kind === 'fiber') thumb.setProse(prose, channel.name) + this.paintAspect(doc.key) + return thumb + } + + private paintAspect(key: string): void { + const aspect = this.aspects.get(key) + if (aspect) this.thumbSlot.style.setProperty('--ws-preview-aspect', String(aspect)) + else this.thumbSlot.style.removeProperty('--ws-preview-aspect') + } + + private place(tab: HTMLElement): void { + const rect = tab.getBoundingClientRect() + const width = this.el.offsetWidth || CARD_WIDTH + const left = Math.max(VIEWPORT_MARGIN, Math.min(window.innerWidth - VIEWPORT_MARGIN - width, rect.left + rect.width / 2 - width / 2)) + this.el.style.left = `${Math.round(left)}px` + this.el.style.top = `${Math.round(rect.bottom + CARD_GAP)}px` + } +} diff --git a/ui/src/board/workspace/TabStrip.test.ts b/ui/src/board/workspace/TabStrip.test.ts index c54280d1..e2eb914e 100644 --- a/ui/src/board/workspace/TabStrip.test.ts +++ b/ui/src/board/workspace/TabStrip.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { centeredScrollLeft, TAB_CROSSING_MS, TabStrip } from './TabStrip.js' +import { centeredScrollLeft, indexCaptions, TAB_CROSSING_MS, TabStrip } from './TabStrip.js' import type { KeyIntent } from '../keymap.js' import { buildChannel } from './documents.js' import { cacheDocumentTitle } from './DocumentTitles.js' @@ -127,7 +127,7 @@ describe('TabStrip', () => { it('follows document identity through a title change and styles declared titles even when the words match the filename', () => { const channel = buildChannel({ uid: 'u', owner: 'typography', name: 'Note', path: '/note.md', fiberDir: '/', body: '', embeds: [{ path: '/doc.html' }] }) - const strip = new TabStrip(vi.fn(), vi.fn(), { shuttleBase: '', onHeight: vi.fn() }) + const strip = new TabStrip(vi.fn(), vi.fn(), { shuttleBase: '' }) strips.push(strip) strip.render(channel.labels, channel.documents.map(d => d.key), channel) strip.mark(1, false) @@ -141,15 +141,83 @@ describe('TabStrip', () => { expect(selected.getAttribute('aria-label')).toBe('Declared title') }) - it('captions filmstrip faces once beneath the preview, including the fiber page', () => { - const channel = buildChannel({ uid: 'caption', owner: 'caption-host', name: 'A named fiber', path: '/fiber.md', fiberDir: '/', body: 'Preview prose', embeds: [{ path: '/song.mp3' }] }) - const strip = new TabStrip(vi.fn(), vi.fn(), { shuttleBase: '', onHeight: vi.fn() }) + it('indexes pages in words: the fiber page as §, file names without their extension unless that collides', () => { + const channel = buildChannel({ uid: 'words', owner: 'words-host', name: 'A named fiber', path: '/fiber.md', fiberDir: '/', body: 'Preview prose', embeds: [{ path: '/song.mp3' }, { path: '/a/take.wav' }, { path: '/b/take.flac' }] }) + expect(indexCaptions(channel.labels, channel)).toEqual(['§', 'song', 'take.wav', 'take.flac']) + const strip = new TabStrip(vi.fn(), vi.fn(), { shuttleBase: '' }) strips.push(strip) strip.render(channel.labels, channel.documents.map(d => d.key), channel) - for (const button of strip.buttons) { - expect(button.querySelector('.ws-thumbnail-title')?.textContent ?? '').toBe('') - expect(button.querySelector('.ws-tab-label')?.textContent).toBeTruthy() + expect(strip.buttons.map(button => button.textContent)).toEqual(['§', 'song', 'take.wav', 'take.flac']) + expect(strip.buttons.map(button => button.getAttribute('aria-label'))).toEqual(channel.labels) + expect(strip.buttons[0].classList.contains('ws-tab-anchor')).toBe(true) + expect(strip.buttons.some(button => button.hasAttribute('title'))).toBe(false) + expect(strip.el.querySelector('[data-part="thumbnail"]')).toBeNull() + }) + + describe('hover preview', () => { + const pointer = (type: string, target: Element, pointerType = 'mouse', relatedTarget: Element | null = null): void => { + const event = new MouseEvent(type, { bubbles: true, relatedTarget }) + Object.defineProperty(event, 'pointerType', { value: pointerType }) + target.dispatchEvent(event) + } + const setup = (): { strip: TabStrip; preview: NonNullable } => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }) + const channel = buildChannel({ uid: 'peek', owner: 'peek-host', name: 'Peek', path: '/fiber.md', fiberDir: '/', body: 'Body prose', embeds: [{ path: '/one.html' }, { path: '/two.png' }] }) + const strip = new TabStrip(vi.fn(), vi.fn(), { shuttleBase: '' }) + strips.push(strip) + document.body.append(strip.el, strip.preview!.el) + strip.render(channel.labels, channel.documents.map(d => d.key), channel) + return { strip, preview: strip.preview! } } + afterEach(() => { vi.useRealTimers() }) + + it('waits before a first preview, then follows the pointer across labels at once', () => { + const { strip, preview } = setup() + pointer('pointerover', strip.buttons[1]) + vi.advanceTimersByTime(399) + expect(preview.open).toBe(false) + vi.advanceTimersByTime(1) + expect(preview.open).toBe(true) + expect(preview.el.getAttribute('aria-hidden')).toBe('true') + expect(preview.el.querySelector('.ws-tab-preview-title')?.textContent).toBe('one.html') + expect(preview.el.querySelector('[data-part="thumbnail"]')).not.toBeNull() + expect(preview.el.querySelector('[data-part="thumbnail-face"]')).not.toBeNull() + pointer('pointerout', strip.buttons[1], 'mouse', strip.buttons[2]) + pointer('pointerover', strip.buttons[2]) + expect(preview.open).toBe(true) + expect(preview.el.querySelector('.ws-tab-preview-title')?.textContent).toBe('two.png') + expect(document.activeElement).not.toBe(preview.el) + }) + + it('leaves with the pointer, gives way to a press or a key, and ignores touch', () => { + const { strip, preview } = setup() + pointer('pointerover', strip.buttons[0], 'touch') + vi.advanceTimersByTime(1000) + expect(preview.open).toBe(false) + pointer('pointerover', strip.buttons[0]) + vi.advanceTimersByTime(400) + expect(preview.el.querySelector('.ws-tab-preview-title')?.textContent).toBe('Peek') + pointer('pointerout', strip.buttons[0], 'mouse', document.body) + vi.advanceTimersByTime(200) + expect(preview.open).toBe(false) + pointer('pointerover', strip.buttons[1]) + expect(preview.open).toBe(true) + pointer('pointerdown', strip.buttons[1]) + expect(preview.open).toBe(false) + pointer('pointerover', strip.buttons[1]) + vi.advanceTimersByTime(1000) + expect(preview.open).toBe(false) + pointer('pointerover', strip.buttons[2]) + vi.advanceTimersByTime(400) + expect(preview.open).toBe(true) + expect(preview.dismiss()).toBe(true) + expect(preview.dismiss()).toBe(false) + strip.setVisible(false) + vi.advanceTimersByTime(1000) + pointer('pointerover', strip.buttons[0]) + vi.advanceTimersByTime(1000) + expect(preview.open).toBe(false) + }) }) it('supports duplicate labels as distinct stable tabs and clears on dispose', () => { diff --git a/ui/src/board/workspace/TabStrip.ts b/ui/src/board/workspace/TabStrip.ts index 6022dc24..e2e48eb8 100644 --- a/ui/src/board/workspace/TabStrip.ts +++ b/ui/src/board/workspace/TabStrip.ts @@ -1,7 +1,6 @@ import type { KeyIntent } from '../keymap.js' -import type { Channel } from './documents.js' -import { extractEmbeds } from '../attachments.js' -import { Thumbnail } from './Thumbnail.js' +import type { Channel, WorkspaceDocument } from './documents.js' +import { TabPreview } from './TabPreview.js' import { declaredTitle } from './DocumentTitles.js' import './tabs.css' import { ReceiptMotion } from './receiptMotion.js' @@ -34,12 +33,35 @@ function easeCrossing(progress: number): number { return cubicBezier(Math.max(0, Math.min(1, progress)), 0.25, 0.1, 0.25, 1) } -type TabRecord = { key: string; label: string; button: HTMLButtonElement; thumb?: Thumbnail } -interface StripOptions { shuttleBase: string; onHeight(height: number): void } +/** + * The words the index shows. The fiber's own page is its § mark; a label + * that is a file's name drops its extension (the preview and the label bar + * name the kind) unless that would make two captions alike. + */ +export function indexCaptions(labels: string[], channel?: Channel): string[] { + const captions = labels.map((label, index) => { + const doc = channel?.documents[index] + if (doc?.kind === 'fiber') return '§' + const extension = doc?.name.match(/\.[^./]+$/)?.[0] + return doc && extension && label.endsWith(doc.name) && label.length > extension.length ? label.slice(0, -extension.length) : label + }) + const counts = new Map() + for (const caption of captions) counts.set(caption, (counts.get(caption) ?? 0) + 1) + return captions.map((caption, index) => caption !== '§' && (counts.get(caption) ?? 0) > 1 ? labels[index] : caption) +} + +type TabRecord = { key: string; label: string; button: HTMLButtonElement } +interface StripOptions { shuttleBase: string } -/** One roving-focus tablist whose selected tab stays centred through one interruptible crossing. */ +/** + * The running head's index: one roving-focus tablist of bare serif labels + * whose selected tab stays centred through one interruptible crossing. + * Thumbnails come on demand, in a hover preview beneath the hovered label. + */ export class TabStrip { readonly el: HTMLDivElement + /** Pointer-only previews; mount `preview.el` where it can float over the stage. */ + readonly preview: TabPreview | null private readonly onSelect: (index: number) => void private readonly onExpand: () => void private readonly motion: MediaQueryList | null @@ -49,13 +71,9 @@ export class TabStrip { private animationFrame: number | null = null private animationTarget = 0 private disposed = false - private compact = false private visible = true - private readonly phone = typeof window.matchMedia === 'function' ? window.matchMedia('(max-width: 700px)') : null - private readonly options?: StripOptions constructor(onSelect: (index: number) => void, onExpand: () => void, options?: StripOptions) { - this.options = options this.onSelect = onSelect this.onExpand = onExpand this.el = document.createElement('div') @@ -63,13 +81,14 @@ export class TabStrip { this.el.dataset.part = 'tab-strip' this.el.setAttribute('role', 'tablist') this.el.setAttribute('aria-label', 'Documents') + this.preview = options ? new TabPreview(options.shuttleBase) : null + this.preview?.attach(this.el) this.motion = typeof window.matchMedia === 'function' ? window.matchMedia('(prefers-reduced-motion: reduce)') : null this.el.addEventListener('scroll', this.onScroll, { passive: true }) window.addEventListener('resize', this.onResize) this.motion?.addEventListener('change', this.onMotionChange) - this.phone?.addEventListener('change', this.onResize) } /** Current buttons, exposed for keyboard integrations and focused tests. */ @@ -86,31 +105,17 @@ export class TabStrip { for (const record of this.records) if (keys.has(record.key)) this.receiptMotion.tab(record.button) } - setCompact(expanded: boolean): void { - this.compact = expanded - this.updateMode() - } setVisible(visible: boolean): void { this.visible = visible - for (const record of this.records) record.thumb?.schedule() - } - private updateMode(): void { - const compact = this.compact || !!this.phone?.matches - this.el.classList.toggle('ws-strip-film', !!this.options && !compact) - this.options?.onHeight(this.phone?.matches ? 44 : compact ? 36 : 104) - for (const record of this.records) record.thumb?.schedule() + this.preview?.setEnabled(visible) } render(labels: string[], keys?: string[], channel?: Channel): void { if (this.disposed) return + if (channel) this.preview?.update(channel, labels) + const captions = indexCaptions(labels, channel) if (labels.length === this.records.length && labels.every((label, index) => label === this.records[index].label && (!keys || keys[index] === this.records[index].key))) { - if (channel) { - this.records.find(r => r.key === channel.documents[0]?.key)?.thumb?.setProse(extractEmbeds(channel.body).body || channel.outcome || '', channel.name) - this.records.forEach((record, index) => { - const doc = channel.documents[index] - record.button.querySelector('.ws-tab-label')?.classList.toggle('ws-tab-title', !!declaredTitle(doc.key)?.title || doc.provenance.some(p => p.kind === 'embed' && p.title === record.label)) - }) - } + if (channel) this.records.forEach((record, index) => this.paintLabel(record.button, captions[index], record.label, channel.documents[index])) return } @@ -134,33 +139,9 @@ export class TabStrip { const matches = available.get(keys ? key : label) const button = matches?.shift() ?? this.createButton(key) button.dataset.tabKey = key - let thumb = this.records.find(r => r.button === button)?.thumb - if (channel && this.options) { - const doc = channel.documents[index] - if (!thumb) { - const recordKey = key - thumb = new Thumbnail({ key: `tab:${key}`, shuttleBase: this.options.shuttleBase, - file: doc.kind === 'fiber' ? undefined : { fullPath: doc.path, owner: doc.owner, basename: doc.name }, - fallback: `\n${extractEmbeds(channel.body).body.slice(0, 400) || channel.outcome || channel.name}`, - className: `ws-tab-thumb ws-tab-kind-${doc.kind}`, captioned: true, - priority: () => this.visible && !this.disposed && !this.compact && !this.phone?.matches && this.el.isConnected ? (this.onScreen(button) ? 3 : 1) : 0, - distance: () => Math.abs(this.records.findIndex(r => r.key === recordKey) - this.selectedIndex), - onAspect: aspect => { - button.style.setProperty('--ws-tab-aspect', String(Math.max(0.8, Math.min(2.8, aspect)))) - this.mark(this.selectedIndex, false) - }, - }) - if (doc.kind === 'fiber') thumb.setProse(extractEmbeds(channel.body).body || channel.outcome || '', channel.name) - button.replaceChildren(thumb.el) - } - let caption = button.querySelector('.ws-tab-label') - if (!caption) { caption = document.createElement('span'); caption.className = 'ws-tab-label'; button.append(caption) } - caption.textContent = label - caption.classList.toggle('ws-tab-title', !!declaredTitle(doc.key)?.title || doc.provenance.some(p => p.kind === 'embed' && p.title === label)) - } else button.textContent = label - button.title = channel ? channel.documents[index].path : label + this.paintLabel(button, captions[index], label, channel?.documents[index]) button.setAttribute('aria-label', label) - return { key, label, button, thumb } + return { key, label, button } }) const retained = new Set(next.map((record) => record.button)) @@ -176,9 +157,7 @@ export class TabStrip { for (const child of [...this.el.children]) { if (child instanceof HTMLButtonElement && !retained.has(child)) child.remove() } - for (const record of this.records) if (!retained.has(record.button)) record.thumb?.dispose() this.records = next - this.updateMode() this.updateSelection() this.updateFades() this.scheduleClipping() @@ -189,7 +168,6 @@ export class TabStrip { if (this.disposed || index < 0 || index >= this.records.length) return this.selectedIndex = index this.updateSelection() - for (const record of this.records) record.thumb?.schedule() const selected = this.records[index].button const target = centeredScrollLeft(selected.offsetLeft, selected.offsetWidth, this.el.clientWidth, this.el.scrollWidth) this.animationTarget = target @@ -225,12 +203,23 @@ export class TabStrip { this.el.removeEventListener('scroll', this.onScroll) window.removeEventListener('resize', this.onResize) this.motion?.removeEventListener('change', this.onMotionChange) - this.phone?.removeEventListener('change', this.onResize) - for (const record of this.records) record.thumb?.dispose() + this.preview?.dispose() this.el.replaceChildren() this.records = [] } + /** + * One serif label per tab. The fiber's own page is anchored by its § mark + * alone; declared titles and embed titles are marked for themes. + */ + private paintLabel(button: HTMLButtonElement, caption: string, label: string, doc?: WorkspaceDocument): void { + let text = button.querySelector('.ws-tab-label') + if (!text) { text = document.createElement('span'); text.className = 'ws-tab-label'; button.replaceChildren(text) } + text.textContent = caption + button.classList.toggle('ws-tab-anchor', doc?.kind === 'fiber') + text.classList.toggle('ws-tab-title', !!doc && (!!declaredTitle(doc.key)?.title || doc.provenance.some(p => p.kind === 'embed' && p.title === label))) + } + private createButton(key: string): HTMLButtonElement { const button = document.createElement('button') button.type = 'button' @@ -258,10 +247,6 @@ export class TabStrip { }) } - private onScreen(button: HTMLElement): boolean { - const band = this.el.getBoundingClientRect(), tab = button.getBoundingClientRect() - return tab.width > 0 && tab.right > band.left && tab.left < band.right - } private updateFades(): void { this.el.classList.toggle('ws-fade-l', this.el.scrollLeft > 2) this.el.classList.toggle('ws-fade-r', this.el.scrollLeft + this.el.clientWidth < this.el.scrollWidth - 2) @@ -292,8 +277,8 @@ export class TabStrip { this.animationFrame = null } - private readonly onScroll = (): void => { this.updateFades(); for (const record of this.records) record.thumb?.schedule() } - private readonly onResize = (): void => { this.updateMode(); this.mark(this.selectedIndex, false); this.updateFades(); this.scheduleClipping() } + private readonly onScroll = (): void => { this.updateFades() } + private readonly onResize = (): void => { this.mark(this.selectedIndex, false); this.updateFades(); this.scheduleClipping() } private readonly onMotionChange = (): void => { if (!this.motion?.matches || this.animationFrame === null) return this.cancelAnimation() diff --git a/ui/src/board/workspace/Workspace.integration.test.ts b/ui/src/board/workspace/Workspace.integration.test.ts index 76dea370..01be777e 100644 --- a/ui/src/board/workspace/Workspace.integration.test.ts +++ b/ui/src/board/workspace/Workspace.integration.test.ts @@ -394,8 +394,8 @@ describe('workspace reader integration', () => { const iframe = frame.content.querySelector('iframe')! const innerWindow = iframe.contentWindow const toggle = document.querySelector('.ws-sidebar-toggle')! - expect(toggle.textContent).toBe('▥ Constitutions') - expect(toggle.title).toBe('Constitutions (s or ⌘\\)') + expect(toggle.querySelector('svg')).not.toBeNull() + expect(toggle.title).toBe('Constitutions · s') expect(toggle.getAttribute('aria-expanded')).toBe('false') expect(workspace.reader.el.classList.contains('ws-with-sidebar')).toBe(false) toggle.click() diff --git a/ui/src/board/workspace/reader.css b/ui/src/board/workspace/reader.css index f42bb798..3a458968 100644 --- a/ui/src/board/workspace/reader.css +++ b/ui/src/board/workspace/reader.css @@ -36,32 +36,35 @@ .ws-mono, .ws-labelbar, .ws-receipts, .ws-position { font-family: var(--ws-mono); letter-spacing: var(--ws-mono-tracking); } +/* The running head: one row on the veil, no plates. Names and verbs in the + serif, the page count in mono, all on one baseline. */ .ws-navbar { - display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) auto; - align-items: start; gap: var(--ws-inset); padding: var(--ws-chrome-inset) var(--ws-gap) 0; - min-height: max(var(--ws-nav-height), calc(var(--ws-strip-h, var(--ws-control-height)) + var(--ws-chrome-inset))); + display: grid; grid-template-columns: auto minmax(0, 1fr) auto; + align-items: baseline; column-gap: var(--ws-gap); padding: var(--ws-head-top) var(--ws-head-end) 0 var(--ws-head-start); background: transparent; position: relative; z-index: 3; flex: none; } -.ws-nav-lead, .ws-nav-trail { display: flex; align-items: center; min-width: 0; gap: var(--ws-small-gap); } -.ws-nav-lead, .ws-nav-tabs { - padding: var(--ws-chrome-padding); background: var(--ws-paper); - border: var(--ws-line-width) solid var(--ws-hairline); border-radius: var(--ws-radius); - box-shadow: var(--ws-chrome-shadow), var(--ws-paper-light); -} -.ws-nav-tabs { min-width: 0; } -.ws-nav-trail { justify-content: flex-end; } -.ws-return { - flex: none; white-space: nowrap; color: var(--ws-ink-muted) !important; padding: var(--ws-small-gap) var(--ws-small-gap); - border-radius: var(--ws-control-radius); font: var(--ws-label-size)/1.2 var(--ws-serif); - font-variant-caps: all-small-caps; letter-spacing: .025em; +.ws-nav-lead { display: flex; align-items: baseline; min-width: 0; gap: var(--ws-head-gap); } +.ws-return, .ws-channel-title, .ws-sidebar-toggle { + height: var(--ws-control-height); padding: 0 var(--ws-small-gap); border-radius: var(--ws-control-radius); + font: var(--ws-label-size)/var(--ws-control-height) var(--ws-serif); letter-spacing: 0; white-space: nowrap; } +.ws-return { flex: none; color: var(--ws-ink-muted) !important; } .ws-channel-title { - font: 500 var(--ws-label-size)/1.2 var(--ws-serif); padding: var(--ws-small-gap); border-radius: var(--ws-control-radius); - flex: 1; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; min-width: 0; + color: var(--ws-ink); flex: 0 1 auto; max-width: 100%; overflow: hidden; text-overflow: ellipsis; text-align: left; min-width: 0; +} +.ws-return:hover, .ws-channel-title:hover { color: var(--ws-ink) !important; background: var(--ws-hover); } +.ws-channel-title::after { content: ' ▾'; font-size: var(--ws-small-size); color: var(--ws-ink-muted); } +/* The constitutions glyph is the sidebar's handle: its left edge is the sidebar's. */ +.ws-sidebar-toggle { + flex: none; align-self: center; display: grid; place-items: center; width: var(--ws-control-height); padding: 0; + margin-left: calc((var(--ws-control-height) - 16px) / -2); color: var(--ws-ink-muted) !important; } -.ws-return:hover { color: var(--ws-ink) !important; } -.ws-channel-title:hover { background: var(--ws-hover); } -.ws-channel-title::after { content: ' ▾'; font-size: var(--ws-small-size); color: var(--ws-ink-faint); } +.ws-sidebar-toggle svg { fill: none; stroke: currentColor; stroke-width: var(--ws-line-width); } +.ws-sidebar-toggle .ws-toggle-column { fill: currentColor; stroke: none; opacity: 0; } +.ws-sidebar-toggle[aria-expanded='true'] .ws-toggle-column { opacity: .35; } +.ws-sidebar-toggle:hover, .ws-sidebar-toggle[aria-expanded='true'] { color: var(--ws-ink) !important; } +.ws-sidebar-toggle:hover { background: var(--ws-hover); } +.ws-head-position { justify-self: end; color: var(--ws-ink-muted); font-size: var(--ws-small-size); letter-spacing: 0; font-variant-numeric: tabular-nums; white-space: nowrap; } .ws-worker-pill { flex: none; white-space: nowrap; } .ws-worker-pill .ws-worker-control { display: inline-flex; align-items: center; gap: 6px; padding: 0 8px; height: 36px; @@ -78,17 +81,18 @@ .ws-turn-active .ws-worker-dot { animation: ws-worker-breathe 2.4s ease-in-out infinite; } @keyframes ws-worker-breathe { 0%, 100% { opacity: 1; } 50% { opacity: .5; } } @media (prefers-reduced-motion: reduce) { .ws-worker-dot { animation: none !important; } } -.ws-nav-verdicts { flex: none; } +.ws-nav-verdicts { flex: none; margin-left: var(--ws-small-gap); } .ws-nav-verdicts[hidden] { display: none; } -.ws-review-plate, .ws-review-plate .kbn-ctl-verdict { display: flex; align-items: center; gap: 4px; } +.ws-review-plate, .ws-review-plate .kbn-ctl-verdict { display: flex; align-items: baseline; gap: 2px; } .ws-review-plate { font: var(--ws-label-size)/1.2 var(--ws-serif); } -.ws-reader .ws-nav-verdicts .kbn-ctl-btn { - height: var(--ws-control-height); padding: 0 var(--ws-small-gap); border: 1px solid var(--ws-hairline); border-radius: var(--ws-control-radius); - font: var(--ws-label-size)/1.2 var(--ws-serif); +/* In the head the verdicts are verbs in their pigments, plated only under the pointer. */ +.ws-reader .ws-nav-verdicts .kbn-ctl-btn:is(.kbn-ctl-temper, .kbn-ctl-discard) { + height: var(--ws-control-height); padding: 0 var(--ws-small-gap); border: 0; border-radius: var(--ws-control-radius); + background: transparent; font: var(--ws-label-size)/var(--ws-control-height) var(--ws-serif); letter-spacing: 0; text-transform: none; } -.ws-reader .ws-review-plate .kbn-ctl-btn:hover { background: var(--ws-hover); } +.ws-reader .ws-nav-verdicts .kbn-ctl-btn:is(.kbn-ctl-temper, .kbn-ctl-discard):hover:not(:disabled) { background: var(--verdict-tint); } .ws-tabs { - display: flex; gap: var(--ws-tab-gap); overflow-x: auto; scrollbar-width: none; + display: flex; align-self: center; gap: var(--ws-tab-gap); overflow-x: auto; scrollbar-width: none; min-width: 0; position: relative; padding: 0 var(--ws-tab-fade); --ws-fade-left: 0px; --ws-fade-right: 0px; mask-image: linear-gradient(90deg, transparent, var(--ws-mask) var(--ws-fade-left), var(--ws-mask) calc(100% - var(--ws-fade-right)), transparent); @@ -97,14 +101,13 @@ .ws-tabs.ws-fade-l { --ws-fade-left: var(--ws-tab-fade); } .ws-tabs.ws-fade-r { --ws-fade-right: var(--ws-tab-fade); } .ws-tab { - flex: none; min-width: var(--ws-tab-floor); max-width: var(--ws-tab-ceiling); - height: var(--ws-control-height); padding: 0 var(--ws-inset); border-radius: var(--ws-control-radius); + flex: none; display: flex; align-items: center; min-width: var(--ws-tab-floor); max-width: var(--ws-tab-ceiling); + height: var(--ws-control-height); padding: 0 var(--ws-tab-pad); border-radius: var(--ws-control-radius); white-space: nowrap; overflow: hidden; color: var(--ws-ink-muted) !important; - font-size: var(--ws-label-size); font-family: var(--ws-serif); letter-spacing: 0; text-align: center; border: var(--ws-line-width) solid transparent !important; + font-size: var(--ws-label-size); font-family: var(--ws-serif); letter-spacing: 0; justify-content: center; border: 0 !important; } .ws-tab-fresh::after { content: ''; display: inline-block; width: 5px; height: 5px; margin-left: 5px; border-radius: 50%; background: var(--kbn-owed); } -.ws-tab:hover { color: var(--ws-ink) !important; background: var(--ws-hover); } -.ws-tab[aria-selected='true'] { color: var(--ws-ink) !important; background: var(--ws-fill); border-color: var(--ws-hairline) !important; } +.ws-tab:hover, .ws-tab[aria-selected='true'] { color: var(--ws-ink) !important; } .ws-tab.ws-clipped { mask-image: linear-gradient(90deg, var(--ws-mask) calc(100% - var(--ws-tab-fade)), transparent); } .ws-stage-row { display: flex; flex: 1; min-height: 0; } .ws-stage { @@ -208,9 +211,6 @@ .ws-sidebar-leaving .ws-sidebar { display: flex; position: absolute; left: 0; top: 0; bottom: 0; pointer-events: none; } /* The left context page stays under the column; only the right edge peeks into the stage. */ .ws-with-sidebar .ws-page.ws-receded.ws-before { clip-path: inset(0 100% 0 0); pointer-events: none; } -.ws-sidebar-toggle { flex: none; height: var(--ws-control-height); padding: 0 var(--ws-small-gap); border-radius: var(--ws-control-radius); color: var(--ws-ink-muted) !important; font: var(--ws-label-size)/1.2 var(--ws-serif); font-variant-caps: all-small-caps; letter-spacing: .025em; white-space: nowrap; } -.ws-sidebar-toggle:hover, .ws-sidebar-toggle[aria-expanded='true'] { color: var(--ws-ink) !important; } -.ws-sidebar-toggle:hover { background: var(--ws-hover); } .ws-thumbbar { display: none; } .ws-sr-only { position: absolute; width: var(--ws-line-width); height: var(--ws-line-width); overflow: hidden; clip-path: inset(50%); white-space: nowrap; } @@ -256,7 +256,7 @@ @media (max-width: 700px), (max-height: 500px) and (pointer: coarse) { .ws-reader { --ws-stage-inset: 0px; --ws-prose-padding: 24px 16px 48px; --ws-phone-bar-height: calc(var(--ws-phone-nav-height) + env(safe-area-inset-top)); } .ws-veil { background: var(--ws-ground); -webkit-backdrop-filter: none; backdrop-filter: none; } - .ws-navbar { grid-template-columns: minmax(0, 1fr) var(--ws-phone-target); gap: 0; min-height: calc(var(--ws-phone-nav-height) + env(safe-area-inset-top)); padding: env(safe-area-inset-top) var(--ws-small-gap) 0; background: var(--ws-paper); align-items: center; transition: transform 180ms var(--ws-ease), margin-top 180ms var(--ws-ease); } + .ws-navbar { grid-template-columns: minmax(0, 1fr) var(--ws-phone-target); gap: 0; column-gap: 0; min-height: calc(var(--ws-phone-nav-height) + env(safe-area-inset-top)); padding: env(safe-area-inset-top) var(--ws-small-gap) 0; background: var(--ws-paper); align-items: center; transition: transform 180ms var(--ws-ease), margin-top 180ms var(--ws-ease); } .ws-topbar-hidden .ws-navbar { transform: translateY(-100%); margin-top: calc(-1 * var(--ws-phone-bar-height)); } .ws-sheet { border: 0; border-radius: 0; box-shadow: none; } /* Phone neighbours are flat sheets beside the page, seen only while a swipe or crossing carries them in. */ @@ -265,23 +265,22 @@ .ws-sheet::after { display: none; } .ws-labelbar { display: none; } .ws-media-poster { bottom: 0; } - .ws-nav-lead, .ws-nav-tabs { padding: 0; border: 0; border-radius: 0; background: transparent; box-shadow: none; } - .ws-nav-verdicts { display: none !important; } - .ws-nav-tabs { display: none; } - .ws-nav-lead { min-height: var(--ws-phone-nav-height); } - .ws-return { position: relative; width: var(--ws-phone-target); height: var(--ws-phone-target); overflow: hidden; text-indent: -9999px; flex: none; padding: 0; } - .ws-return::before { content: '‹'; position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); font-size: var(--ws-heading-size); text-indent: 0; } - .ws-channel-title { text-align: center; flex: 1; height: 44px; font-size: var(--ws-label-size); } + .ws-nav-verdicts, .ws-tabs, .ws-head-position { display: none !important; } + .ws-nav-lead { min-height: var(--ws-phone-nav-height); align-items: center; gap: 0; } + /* One chevron drawing for back and stepping: a drawn stroke in ink, legible at 44 px. */ + .ws-return { position: relative; width: var(--ws-phone-target); height: var(--ws-phone-target); overflow: hidden; text-indent: -9999px; flex: none; padding: 0; color: var(--ws-ink) !important; } + .ws-return::before { content: ''; position: absolute; left: 50%; top: 50%; width: 11px; height: 11px; border: solid currentColor; border-width: 0 0 1.5px 1.5px; transform: translate(-30%, -50%) rotate(45deg); } + .ws-channel-title { text-align: center; flex: 1; height: var(--ws-phone-target); line-height: var(--ws-phone-target); padding: 0; } .ws-channel-title::after { display: none; } - .ws-tabs { grid-row: 2; grid-column: 1 / -1; padding: 0 var(--ws-small-gap); border-top: var(--ws-line-width) solid var(--ws-hairline); } - .ws-tab { height: var(--ws-phone-target); } .ws-thumbbar { display: grid; grid-template-columns: var(--ws-phone-target) minmax(0, 1fr) var(--ws-phone-target) var(--ws-phone-target); align-items: center; padding: 0 var(--ws-small-gap) env(safe-area-inset-bottom); height: calc(var(--ws-thumb-height) + env(safe-area-inset-bottom)); flex: none; border-top: var(--ws-line-width) solid var(--ws-hairline); background: var(--ws-paper); touch-action: pan-y; user-select: none; } - .ws-page-choice { min-width: 0; height: 55px; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 1px; padding: 0 4px; } - .ws-thumb-title, .ws-thumb-arrival { display: block; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .ws-thumb-title { font: var(--ws-label-size)/1.1 var(--ws-serif); } - .ws-thumb-arrival, .ws-position { font: var(--ws-small-size)/1.2 var(--ws-mono); letter-spacing: 0; color: var(--ws-ink-muted); } - .ws-thumb-button { width: var(--ws-phone-target); height: var(--ws-phone-target); font-size: var(--ws-heading-size) !important; } - .ws-position { text-align: center; color: var(--ws-ink-muted); } + /* Two lines: the page's title, then where it sits and how it arrived. */ + .ws-page-choice { min-width: 0; height: 55px; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 3px; padding: 0 4px; } + .ws-thumb-title, .ws-thumb-meta { display: block; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: center; } + .ws-thumb-title { font: var(--ws-label-size)/1.15 var(--ws-serif); color: var(--ws-ink); } + .ws-thumb-meta, .ws-thumb-meta > * { font: var(--ws-small-size)/1.2 var(--ws-mono); letter-spacing: 0; color: var(--ws-ink-muted); } + .ws-thumb-meta .ws-position { font-variant-numeric: tabular-nums; } + .ws-thumb-button { display: grid; place-items: center; width: var(--ws-phone-target); height: var(--ws-phone-target); color: var(--ws-ink); font-size: var(--ws-section-size) !important; } + .ws-thumb-button svg { fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; } .ws-icon-button, .ws-sidebar, .ws-sidebar-toggle, .ws-page .ws-edge { display: none !important; } .ws-label-title { max-width: 50%; } .ws-prose, .ws-content .kbn-detail-prose { padding: 24px 16px 48px; } diff --git a/ui/src/board/workspace/tabs.css b/ui/src/board/workspace/tabs.css index c13d1966..3246f2f4 100644 --- a/ui/src/board/workspace/tabs.css +++ b/ui/src/board/workspace/tabs.css @@ -1,27 +1,43 @@ -.ws-tabs.ws-strip-film { height: 104px; align-items: center; flex-shrink: 0; padding-block: 5px; gap: 6px; } -.ws-tabs.ws-strip-film .ws-tab { - --ws-tab-aspect: 1.333333; - position: relative; width: clamp(90px, calc(66px * var(--ws-tab-aspect) + 16px), 220px); - height: 94px; padding: 5px 7px 3px; border-radius: 5px; - display: flex; flex-direction: column; align-items: stretch; gap: 4px; - overflow: hidden; background: transparent; +/* The running head's index: bare serif labels in muted ink. The selected + label is in ink over one fine rule; there is no fill, plate or shadow. */ +.ws-tab-label { + flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font: var(--ws-label-size)/var(--ws-control-height) var(--ws-serif); letter-spacing: 0; + text-decoration: underline transparent var(--ws-line-width); text-underline-offset: var(--ws-tab-rule-offset); + text-decoration-skip-ink: none; } -.ws-tab-thumb { display: none; } -.ws-strip-film .ws-tab-thumb { display: block; height: 66px; flex: none; border-radius: 2px; border: 1px solid var(--ws-hairline); } -.ws-strip-film .ws-tab[aria-selected='true'] { background: var(--ws-fill); box-shadow: 0 2px 3px color-mix(in srgb, var(--ws-ink) 20%, transparent), 0 5px 10px color-mix(in srgb, var(--ws-ink) 10%, transparent); } -.ws-tab-label { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--ws-label-size)/1.2 var(--ws-serif); letter-spacing: 0; } -.ws-tab-thumb .ws-thumbnail-face { padding: 5px; font-size: var(--ws-small-size); } -.ws-tab-kind-fiber .ws-thumbnail-face { display: block; text-align: left; white-space: pre-wrap; font: var(--ws-small-size)/1.25 var(--ws-serif); color: var(--ws-ink-soft); } -.ws-tab-thumb .kbn-thumbnail-image img { object-fit: contain; } -.ws-tab-thumb .kbn-thumbnail-glyph { padding: 5px; } -.ws-tab-kind-video video { opacity: 0; } -.ws-tab-kind-video .kbn-thumbnail-glyph { display: flex !important; } -.ws-tab-kind-audio audio { display: none; } -.ws-tab-fresh { position: relative; } -.ws-tab-fresh::after { position: absolute; top: 5px; right: 5px; margin: 0; box-shadow: 0 0 0 2px var(--ws-paper); } +.ws-tab[aria-selected='true'] .ws-tab-label { text-decoration-color: currentColor; } +.ws-tab-anchor .ws-tab-label { font-size: var(--ws-tab-anchor-size); } .ws-tab.ws-clipped:has(.ws-tab-label) { mask-image: none; } -@media (max-width: 700px), (max-height: 500px) and (pointer: coarse) { - .ws-tabs.ws-strip-film { height: 44px; padding-block: 0; } - .ws-tabs.ws-strip-film .ws-tab { display: block; width: auto; height: 44px; padding: 0 var(--ws-inset); } - .ws-strip-film .ws-tab-thumb { display: none; } +/* The gold fresh mark rides at the label's shoulder, like a superior figure. */ +.ws-tab-fresh::after { flex: none; margin: 0 0 0 3px; transform: translateY(-5px); } + +/* The hover preview: the document at a legible size, its title and arrival. */ +.ws-tab-preview { + position: fixed; z-index: 6; width: var(--ws-preview-width); padding: var(--ws-small-gap); + background: var(--ws-paper); color: var(--ws-ink); border: var(--ws-line-width) solid var(--ws-hairline); + border-radius: var(--ws-control-radius); box-shadow: var(--ws-shadow-contact), var(--ws-shadow-mid); + pointer-events: none; user-select: none; +} +.ws-tab-preview[hidden] { display: none; } +.ws-tab-preview-thumb { + --ws-preview-aspect: 1.6; + width: 100%; aspect-ratio: var(--ws-preview-aspect); overflow: hidden; + border: var(--ws-line-width) solid var(--ws-hairline-soft); border-radius: 3px; +} +.ws-tab-preview-thumb[data-kind='pdf'] { --ws-preview-aspect: 0.9; } +.ws-tab-preview-thumb[data-kind='audio'] { --ws-preview-aspect: 3; } +.ws-tab-preview-face { width: 100%; height: 100%; } +.ws-tab-preview-face.ws-tab-kind-fiber .ws-thumbnail-face { display: block; white-space: pre-wrap; font: var(--ws-small-size)/1.3 var(--ws-serif); } +.ws-tab-preview-face .kbn-thumbnail-image img { object-fit: contain; } +.ws-tab-preview-face.ws-tab-kind-video video { opacity: 0; } +.ws-tab-preview-face.ws-tab-kind-video .kbn-thumbnail-glyph { display: flex !important; } +.ws-tab-preview-face.ws-tab-kind-audio audio { display: none; } +.ws-tab-preview-title { + margin: var(--ws-small-gap) 2px 0; font: var(--ws-label-size)/1.25 var(--ws-serif); + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; +} +.ws-tab-preview-meta { + margin: 2px 2px 1px; color: var(--ws-ink-muted); font: var(--ws-small-size)/1.4 var(--ws-mono); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } diff --git a/ui/src/board/workspace/themes/blueprint.css b/ui/src/board/workspace/themes/blueprint.css index 3e4f61b7..75a5f572 100644 --- a/ui/src/board/workspace/themes/blueprint.css +++ b/ui/src/board/workspace/themes/blueprint.css @@ -57,10 +57,7 @@ letter-spacing: var(--ws-mono-tracking); text-transform: uppercase; } -[data-part="tab"][aria-selected="true"] { - color: var(--ws-ink); - background: var(--ws-fill); -} +[data-part="tab"][aria-selected="true"] { color: var(--ws-ink); } [data-part="tab"] .ws-tab-label { font-family: var(--ws-mono); letter-spacing: var(--ws-mono-tracking); @@ -155,27 +152,12 @@ box-shadow: none; } -[data-part="chrome-plate"] { - color: var(--ws-ink); - background-color: var(--ws-paper); - background-image: - linear-gradient(var(--ws-blueprint-grid) 1px, transparent 1px), - linear-gradient(90deg, var(--ws-blueprint-grid) 1px, transparent 1px); - background-size: 24px 24px; - border: 1px solid var(--ws-hairline-soft); - clip-path: polygon(0 8px, 8px 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 0 100%); -} [data-part="tab-strip"] { color: var(--ws-ink-muted); font-family: var(--ws-mono); letter-spacing: var(--ws-mono-tracking); text-transform: uppercase; } -:scope [data-part="tab"][aria-selected="true"] { - box-shadow: inset 0 -2px var(--ws-accent), - 0 2px 3px color-mix(in srgb, var(--ws-ink) 20%, transparent), - 0 5px 10px color-mix(in srgb, var(--ws-ink) 10%, transparent); -} .ws-thumbnail[data-part="thumbnail"] { background-color: var(--ws-paper); border-color: var(--ws-hairline); @@ -259,13 +241,6 @@ [data-part="audio-waveform"] { color: var(--ws-accent); } @media (max-width: 700px), (max-height: 500px) and (pointer: coarse) { - [data-part="chrome-plate"] { - background: transparent; - border: 0; - border-radius: 0; - box-shadow: none; - clip-path: none; - } :scope[data-part="folio"][data-density="full"], :scope[data-part="folio"][data-density="compact"] { padding: 14px 0; } :scope[data-part="folio"][data-density="line"] { padding: 4px 0; } diff --git a/ui/src/board/workspace/themes/laboratory-paper.css b/ui/src/board/workspace/themes/laboratory-paper.css index 6cd759b4..639fe6af 100644 --- a/ui/src/board/workspace/themes/laboratory-paper.css +++ b/ui/src/board/workspace/themes/laboratory-paper.css @@ -51,10 +51,7 @@ } [data-part="tab"] { color: var(--ws-ink-muted); } -[data-part="tab"][aria-selected="true"] { - color: var(--ws-ink); - background: var(--ws-fill); -} +[data-part="tab"][aria-selected="true"] { color: var(--ws-ink); } [data-part="label-bar"] { color: var(--ws-ink-muted); @@ -138,24 +135,7 @@ box-shadow: none; } -[data-part="chrome-plate"] { - color: var(--ws-ink); - background-color: var(--ws-paper); - background-image: - linear-gradient(90deg, transparent 12px, var(--ws-margin-rule) 12px 13px, transparent 13px), - linear-gradient(var(--ws-lab-grid) 1px, transparent 1px); - background-repeat: no-repeat, repeat; - background-size: 30px 100%, 24px 24px; - border: 1px solid var(--ws-hairline-soft); - border-radius: var(--ws-control-radius); - box-shadow: 0 1px 2px rgb(41 51 45 / 9%), 0 6px 16px -8px rgb(41 51 45 / 18%); -} [data-part="tab-strip"] { color: var(--ws-ink-muted); } -:scope [data-part="tab"][aria-selected="true"] { - box-shadow: inset 0 -2px var(--ws-accent), - 0 2px 3px color-mix(in srgb, var(--ws-ink) 20%, transparent), - 0 5px 10px color-mix(in srgb, var(--ws-ink) 10%, transparent); -} .ws-thumbnail[data-part="thumbnail"] { background-color: var(--ws-paper); border-color: var(--ws-hairline); @@ -234,13 +214,6 @@ [data-part="audio-waveform"] { color: var(--ws-accent); } @media (max-width: 700px), (max-height: 500px) and (pointer: coarse) { - [data-part="chrome-plate"] { - background: transparent; - border: 0; - border-radius: 0; - box-shadow: none; - clip-path: none; - } :scope[data-part="folio"][data-density="full"], :scope[data-part="folio"][data-density="compact"] { padding: 14px 0; } :scope[data-part="folio"][data-density="line"] { padding: 4px 0; } diff --git a/ui/src/board/workspace/themes/night-chart.css b/ui/src/board/workspace/themes/night-chart.css index b0ac2eb1..4d53da30 100644 --- a/ui/src/board/workspace/themes/night-chart.css +++ b/ui/src/board/workspace/themes/night-chart.css @@ -51,10 +51,7 @@ } [data-part="tab"] { color: var(--ws-ink-muted); } -[data-part="tab"][aria-selected="true"] { - color: var(--ws-ink); - background: var(--ws-fill); -} +[data-part="tab"][aria-selected="true"] { color: var(--ws-ink); } [data-part="label-bar"] { color: var(--ws-ink-muted); @@ -138,20 +135,7 @@ box-shadow: none; } -[data-part="chrome-plate"] { - color: var(--ws-ink); - background-color: var(--ws-paper); - background-image: radial-gradient(circle at 94% 10%, var(--ws-chart-star) 0.8px, transparent 1.2px); - border: 1px solid var(--ws-hairline-soft); - border-radius: 5px; - box-shadow: 0 1px 2px rgb(0 0 0 / 30%), 0 8px 20px -10px rgb(0 0 0 / 54%); -} [data-part="tab-strip"] { color: var(--ws-ink-muted); } -:scope [data-part="tab"][aria-selected="true"] { - box-shadow: inset 0 -2px var(--ws-accent), - 0 2px 3px rgb(0 0 0 / 32%), - 0 5px 10px rgb(0 0 0 / 25%); -} .ws-thumbnail[data-part="thumbnail"] { background-color: var(--ws-paper); border-color: var(--ws-hairline); @@ -227,13 +211,6 @@ [data-part="audio-waveform"] { color: var(--ws-accent); } @media (max-width: 700px), (max-height: 500px) and (pointer: coarse) { - [data-part="chrome-plate"] { - background: transparent; - border: 0; - border-radius: 0; - box-shadow: none; - clip-path: none; - } :scope[data-part="folio"][data-density="full"], :scope[data-part="folio"][data-density="compact"] { padding: 14px 0; } :scope[data-part="folio"][data-density="line"] { padding: 4px 0; } diff --git a/ui/src/board/workspace/themes/portolan.css b/ui/src/board/workspace/themes/portolan.css index 9653830d..55ef4f2b 100644 --- a/ui/src/board/workspace/themes/portolan.css +++ b/ui/src/board/workspace/themes/portolan.css @@ -58,10 +58,7 @@ } [data-part="tab"] { color: var(--ws-ink-muted); } -[data-part="tab"][aria-selected="true"] { - color: var(--ws-ink); - background: var(--ws-fill); -} +[data-part="tab"][aria-selected="true"] { color: var(--ws-ink); } [data-part="label-bar"] { color: var(--ws-ink-muted); @@ -124,20 +121,7 @@ box-shadow: none; } -[data-part="chrome-plate"] { - color: var(--ws-ink); - background-color: var(--ws-paper); - border: 1px solid var(--ws-hairline-soft); - border-radius: var(--ws-control-radius); - box-shadow: 0 1px 2px color-mix(in srgb, var(--ws-ink) 10%, transparent), - 0 8px 18px -8px color-mix(in srgb, var(--ws-ink) 18%, transparent); -} [data-part="tab-strip"] { color: var(--ws-ink-muted); } -:scope [data-part="tab"][aria-selected="true"] { - box-shadow: inset 0 -2px var(--ws-accent), - 0 2px 3px color-mix(in srgb, var(--ws-ink) 20%, transparent), - 0 5px 10px color-mix(in srgb, var(--ws-ink) 10%, transparent); -} .ws-thumbnail[data-part="thumbnail"] { background-color: var(--ws-paper); border-color: var(--ws-hairline); @@ -199,13 +183,6 @@ [data-part="audio-waveform"] { color: var(--ws-accent); } @media (max-width: 700px), (max-height: 500px) and (pointer: coarse) { - [data-part="chrome-plate"] { - background: transparent; - border: 0; - border-radius: 0; - box-shadow: none; - clip-path: none; - } [data-part="phone-topbar"], [data-part="phone-bottom-bar"] { color: var(--ws-ink); background-color: var(--ws-paper); diff --git a/ui/src/board/workspace/tokens.css b/ui/src/board/workspace/tokens.css index c02a3def..3f7ba2b7 100644 --- a/ui/src/board/workspace/tokens.css +++ b/ui/src/board/workspace/tokens.css @@ -59,7 +59,6 @@ --ws-desk-y: 0px; --ws-page-x: 0px; --ws-page-y: 0px; - --ws-strip-h: var(--ws-control-height); --ws-settle: translateY(6px) scale(0.985); --ws-ease: ease; --ws-receded-scale: 0.94; @@ -70,10 +69,15 @@ --ws-neighbour-fade-end: var(--ws-neighbour-fade); --ws-gap: 24px; --ws-inset: 12px; - --ws-tab-floor: 90px; + --ws-tab-floor: 32px; --ws-tab-ceiling: 220px; --ws-tab-fade: 16px; - --ws-tab-gap: 2px; + --ws-tab-gap: 0px; + /* Half the space between two index labels. */ + --ws-tab-pad: 9px; + --ws-tab-rule-offset: 6px; + --ws-tab-anchor-size: 17px; + --ws-preview-width: 224px; --ws-drag-latch: 4px; --ws-reader-layer: 9000; @@ -83,9 +87,14 @@ --ws-radius: 10px; --ws-control-radius: 7px; --ws-control-height: 32px; - --ws-nav-height: 52px; - --ws-chrome-inset: 18px; - --ws-chrome-padding: 4px; + /* The running head: its top margin, its ends, and the space between its lead controls. + Its start is the sidebar's inset, so the head's first control sits on the sidebar's edge. */ + --ws-head-top: 10px; + --ws-head-start: var(--ws-inset); + --ws-head-end: var(--ws-stage-inset); + --ws-head-gap: 10px; + --ws-title-ceiling: 360px; + --ws-index-floor: 240px; --ws-stage-inset: 28px; --ws-neighbour-drop: 8px; --ws-phone-nav-height: 48px; From a6f5f88d5139ec2be992ce5c49892aebb3e7fd97 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Mon, 5 Oct 2026 15:28:09 +0200 Subject: [PATCH 02/24] Let a long index take the head's room and hold the selected label over the page Co-Authored-By: Claude Opus 5.5 --- ui/src/board/workspace/Reader.ts | 19 ++++++++++++++----- ui/src/board/workspace/TabStrip.ts | 17 +++++++++++++---- ui/src/board/workspace/reader.css | 2 +- ui/src/board/workspace/themes/blueprint.css | 1 + ui/src/board/workspace/tokens.css | 4 +++- 5 files changed, 32 insertions(+), 11 deletions(-) diff --git a/ui/src/board/workspace/Reader.ts b/ui/src/board/workspace/Reader.ts index 3888e74d..74ab755f 100644 --- a/ui/src/board/workspace/Reader.ts +++ b/ui/src/board/workspace/Reader.ts @@ -512,7 +512,7 @@ export class Reader { return } const style = getComputedStyle(this.navbar) - const gap = parseFloat(style.columnGap) || 24 + const gap = parseFloat(style.columnGap) || 32 const padLeft = parseFloat(style.paddingLeft) || 12 const width = this.navbar.clientWidth - padLeft - (parseFloat(style.paddingRight) || 12) if (!width) return @@ -528,10 +528,19 @@ export class Reader { const sidebar = this.sidebarShown ? this.sidebar.offsetWidth : 0 const centre = sidebar + (this.navbar.clientWidth - sidebar) / 2 - padLeft const half = Math.min(centre - lead - gap, width - position - gap - centre) - const band = Math.min(tabs, 2 * half) - this.navbar.style.gridTemplateColumns = band >= this.measure('index-floor', 240) - ? `${Math.floor(centre - band / 2 - gap)}px ${Math.floor(band)}px minmax(0, 1fr)` - : `${Math.ceil(Math.min(lead, width * 0.45))}px minmax(0, 1fr) auto` + // A run that fits is centred over the stage. A longer one takes all the + // room between the lead and the page count, and scrolls so the selected + // label sits over the page's centre. + if (tabs <= 2 * half) { + this.navbar.style.gridTemplateColumns = `${Math.floor(centre - tabs / 2 - gap)}px ${Math.ceil(tabs)}px minmax(0, 1fr)` + this.tabs.setFocus(null) + return + } + const start = Math.ceil(Math.min(lead, width * 0.45)) + this.navbar.style.gridTemplateColumns = `${start}px minmax(0, 1fr) auto` + const bandLeft = start + gap + const band = width - position - gap - bandLeft + this.tabs.setFocus(centre - bandLeft >= this.measure('index-focus-margin', 80) && bandLeft + band - centre >= this.measure('index-focus-margin', 80) ? centre - bandLeft : null) } private layout(animate: boolean): void { this.layoutNavbar() diff --git a/ui/src/board/workspace/TabStrip.ts b/ui/src/board/workspace/TabStrip.ts index e2e48eb8..d6dcbd97 100644 --- a/ui/src/board/workspace/TabStrip.ts +++ b/ui/src/board/workspace/TabStrip.ts @@ -7,10 +7,13 @@ import { ReceiptMotion } from './receiptMotion.js' export const TAB_CROSSING_MS = 280 -/** Clamp the scroll offset that centres a tab inside a horizontally scrolling strip. */ -export function centeredScrollLeft(tabLeft: number, tabWidth: number, viewportWidth: number, contentWidth: number): number { +/** + * Clamp the scroll offset that puts a tab's centre at `focus` inside a + * horizontally scrolling strip (by default, the strip's own centre). + */ +export function centeredScrollLeft(tabLeft: number, tabWidth: number, viewportWidth: number, contentWidth: number, focus = viewportWidth / 2): number { const maximum = Math.max(0, contentWidth - viewportWidth) - const centred = tabLeft + tabWidth / 2 - viewportWidth / 2 + const centred = tabLeft + tabWidth / 2 - focus return Math.max(0, Math.min(maximum, centred)) } @@ -72,6 +75,7 @@ export class TabStrip { private animationTarget = 0 private disposed = false private visible = true + private focus: number | null = null constructor(onSelect: (index: number) => void, onExpand: () => void, options?: StripOptions) { this.onSelect = onSelect @@ -105,6 +109,11 @@ export class TabStrip { for (const record of this.records) if (keys.has(record.key)) this.receiptMotion.tab(record.button) } + /** Where, inside the strip, the selected tab's centre belongs: over the selected page. */ + setFocus(x: number | null): void { + this.focus = x + } + setVisible(visible: boolean): void { this.visible = visible this.preview?.setEnabled(visible) @@ -169,7 +178,7 @@ export class TabStrip { this.selectedIndex = index this.updateSelection() const selected = this.records[index].button - const target = centeredScrollLeft(selected.offsetLeft, selected.offsetWidth, this.el.clientWidth, this.el.scrollWidth) + const target = centeredScrollLeft(selected.offsetLeft, selected.offsetWidth, this.el.clientWidth, this.el.scrollWidth, this.focus ?? this.el.clientWidth / 2) this.animationTarget = target this.cancelAnimation() if (!animate || this.motion?.matches || this.el.clientWidth === 0) { diff --git a/ui/src/board/workspace/reader.css b/ui/src/board/workspace/reader.css index 3a458968..f604af39 100644 --- a/ui/src/board/workspace/reader.css +++ b/ui/src/board/workspace/reader.css @@ -40,7 +40,7 @@ serif, the page count in mono, all on one baseline. */ .ws-navbar { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; - align-items: baseline; column-gap: var(--ws-gap); padding: var(--ws-head-top) var(--ws-head-end) 0 var(--ws-head-start); + align-items: baseline; column-gap: var(--ws-head-span); padding: var(--ws-head-top) var(--ws-head-end) 0 var(--ws-head-start); background: transparent; position: relative; z-index: 3; flex: none; } .ws-nav-lead { display: flex; align-items: baseline; min-width: 0; gap: var(--ws-head-gap); } diff --git a/ui/src/board/workspace/themes/blueprint.css b/ui/src/board/workspace/themes/blueprint.css index 75a5f572..e2021266 100644 --- a/ui/src/board/workspace/themes/blueprint.css +++ b/ui/src/board/workspace/themes/blueprint.css @@ -60,6 +60,7 @@ [data-part="tab"][aria-selected="true"] { color: var(--ws-ink); } [data-part="tab"] .ws-tab-label { font-family: var(--ws-mono); + font-size: 12px; letter-spacing: var(--ws-mono-tracking); text-transform: uppercase; } diff --git a/ui/src/board/workspace/tokens.css b/ui/src/board/workspace/tokens.css index 3f7ba2b7..69395408 100644 --- a/ui/src/board/workspace/tokens.css +++ b/ui/src/board/workspace/tokens.css @@ -93,8 +93,10 @@ --ws-head-start: var(--ws-inset); --ws-head-end: var(--ws-stage-inset); --ws-head-gap: 10px; + --ws-head-span: 32px; --ws-title-ceiling: 360px; - --ws-index-floor: 240px; + /* The selected label is held over the page only while this much index lies on either side of it. */ + --ws-index-focus-margin: 80px; --ws-stage-inset: 28px; --ws-neighbour-drop: 8px; --ws-phone-nav-height: 48px; From 0114129d2832e4d3b3b995303dcc7e1c20e6b1a3 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Mon, 5 Oct 2026 15:39:46 +0200 Subject: [PATCH 03/24] Name index labels by declared titles without thumbnails, and dismiss previews into frames A once-per-session 64 KiB peek reads each titled document's declared title, which the filmstrip's thumbnails used to supply. Leaving the index puts the preview away at once, and frames yield the pointer while a preview is up so a move straight into a report still dismisses it. The e2e suite covers the running head, the preview and its budget. Co-Authored-By: Claude Opus 5.5 --- ui/e2e/workspace.mjs | 71 ++++++++++++++----- ui/src/board/FileViewerPanel.ts | 2 +- ui/src/board/workspace/DocumentHost.test.ts | 1 + ui/src/board/workspace/TabPreview.ts | 3 + ui/src/board/workspace/TabStrip.ts | 4 ++ ui/src/board/workspace/Thumbnail.test.ts | 1 + .../workspace/Workspace.integration.test.ts | 1 + ui/src/board/workspace/tabs.css | 3 + ui/src/board/workspace/titleProbe.ts | 37 ++++++++++ 9 files changed, 105 insertions(+), 18 deletions(-) create mode 100644 ui/src/board/workspace/titleProbe.ts diff --git a/ui/e2e/workspace.mjs b/ui/e2e/workspace.mjs index 742cf850..68208753 100644 --- a/ui/e2e/workspace.mjs +++ b/ui/e2e/workspace.mjs @@ -93,8 +93,9 @@ for (const [device, viewport] of [['desktop', { width: 1440, height: 900 }], ['p await type('.ws-channel-title', 15, 'EB Garamond') await type('.ws-nav-verdicts .ws-review-plate .kbn-ctl-btn', 15, 'EB Garamond') if (device === 'desktop') { - for (const selector of ['.ws-return', '.ws-sidebar-toggle', '.ws-tab-label', '.ws-selected .ws-label-title']) await type(selector, 15, 'EB Garamond') + for (const selector of ['.ws-return', '.ws-tab:not(.ws-tab-anchor) .ws-tab-label', '.ws-selected .ws-label-title']) await type(selector, 15, 'EB Garamond') await type('.ws-selected .ws-provenance', 11, 'IBM Plex Mono') + await type('.ws-head-position', 11, 'IBM Plex Mono') } else { await type('.ws-thumb-title', 15, 'EB Garamond') await type('.ws-thumb-arrival', 11, 'IBM Plex Mono') @@ -566,26 +567,59 @@ test('Desk-opened channel reload and Back restore its Desk return control', asyn assert.equal(await p.locator('.ws-page.ws-selected:visible').count(), 0) }) -test('Filmstrip previews share a safe budget, condense instantly, and retain selection through a fresh re-send', async p => { +test('The running head indexes pages in words, previews on hover within the shared budget, and keeps selection through a fresh re-send', async p => { await open(p); await reportReady(p) const film = p.locator('.ws-tabs') - assert.ok(await film.evaluate(el => el.classList.contains('ws-strip-film'))) - assert.ok((await film.boundingBox()).height <= 104) - await poll(p, () => document.querySelectorAll('.ws-tab-thumb iframe').length > 0) - for (const frame of await p.locator('.ws-tab-kind-html iframe').all()) { + const head = await p.locator('.ws-navbar').boundingBox() + assert.ok(head.height <= 48, `the head is one row: ${head.height}`) + assert.ok((await selected(p).boundingBox()).y <= 80, 'the page starts within 80 px of the top') + assert.equal(await p.locator('[data-part="chrome-plate"]').count(), 0, 'no chrome plates') + assert.equal(await film.locator('[data-part="thumbnail"]').count(), 0, 'the index carries no thumbnails') + const selectedTab = film.locator('.ws-tab[aria-selected="true"]') + const look = await selectedTab.evaluate(el => ({ background: getComputedStyle(el).backgroundColor, shadow: getComputedStyle(el).boxShadow, rule: getComputedStyle(el.querySelector('.ws-tab-label')).textDecorationLine })) + assert.deepEqual(look, { background: 'rgba(0, 0, 0, 0)', shadow: 'none', rule: 'underline' }) + assert.equal(await tab(p, 'Constitution').textContent(), '§') + assert.ok(await tab(p, 'calibration-report').locator('.ws-tab-label').evaluate(el => el.classList.contains('ws-tab-title'))) + assert.equal(await tab(p, 'calibration-report').getAttribute('title'), null, 'the preview, not a native tooltip, names a hovered page') + assert.equal(await p.locator('.ws-tab-fresh').count(), 0, 'first visits are quiet') + const preview = p.locator('.ws-tab-preview') + await p.mouse.move(700, 500) + await tab(p, 'response.pdf').hover() + await p.waitForTimeout(200) + assert.ok(await preview.isHidden(), 'a first hover waits') + await preview.waitFor({ state: 'visible' }) + assert.match(await preview.locator('.ws-tab-preview-meta').textContent(), /sent/) + const anchor = await tab(p, 'response.pdf').boundingBox(), card = await preview.boundingBox() + assert.ok(Math.abs(card.x + card.width / 2 - (anchor.x + anchor.width / 2)) < 2 && card.y >= anchor.y + anchor.height, 'the preview hangs beneath its label') + assert.equal(await p.evaluate(() => document.activeElement?.closest('.ws-tab-preview')), null, 'the preview never takes focus') + await tab(p, 'calibration-report').hover() + assert.ok(await preview.isVisible(), 'a neighbour swaps in at once') + await poll(p, () => document.querySelectorAll('.ws-tab-preview iframe').length > 0) + for (const frame of await preview.locator('iframe').all()) { assert.equal(await frame.getAttribute('sandbox'), '') assert.equal(await frame.getAttribute('tabindex'), '-1') } assert.ok(await p.locator('.ws-thumbnail-body').count() <= 16) - await poll(p, () => document.querySelector('.ws-tab-kind-html.ws-thumbnail-ready')) - assert.equal(await p.locator('.ws-tab-kind-html .ws-thumbnail-face').evaluate(el => getComputedStyle(el).visibility), 'hidden') - assert.equal(await p.locator('.ws-tab-kind-html .kbn-thumbnail-glyph').evaluate(el => getComputedStyle(el).display), 'none') - assert.ok(await tab(p, 'calibration-report').locator('.ws-tab-label').evaluate(el => el.classList.contains('ws-tab-title'))) - assert.match(await tab(p, 'calibration-report').getAttribute('title'), /report\.html$/) - assert.equal(await p.locator('.ws-tab-fresh').count(), 0, 'first visits are quiet') + await poll(p, () => document.querySelector('.ws-tab-preview .ws-tab-kind-html.ws-thumbnail-ready')) + assert.equal(await preview.locator('.ws-thumbnail-face').evaluate(el => getComputedStyle(el).visibility), 'hidden') + await p.keyboard.press('Escape') + assert.ok(await preview.isHidden(), 'Escape dismisses the preview') + assert.ok(await selected(p).isVisible(), 'and stops there') + await tab(p, 'brief.md').hover() + await preview.waitFor({ state: 'visible' }) + // Straight into the report: its frame must not swallow the leave. + await p.mouse.move(700, 500) + await poll(p, () => document.querySelector('.ws-tab-preview').hidden) + if (process.env.WORKSPACE_SHOTS) { + await mkdir(process.env.WORKSPACE_SHOTS, { recursive: true }) + await tab(p, 'calibration-report').hover(); await preview.waitFor({ state: 'visible' }) + await p.screenshot({ path: `${process.env.WORKSPACE_SHOTS}/harness-preview-desktop.png` }) + await p.mouse.move(700, 500) + } await report(p).evaluate(f => { window.__filmReport = f.contentWindow }) + const before = await film.boundingBox() await p.locator('.ws-selected .ws-expand-button').click() - assert.ok(!await film.evaluate(el => el.classList.contains('ws-strip-film'))) + assert.deepEqual(await film.boundingBox(), before, 'expanding leaves the head as it is') await p.locator('.ws-selected .ws-expand-button').click() await p.evaluate(() => { const original = window.fetch @@ -612,8 +646,7 @@ test('Filmstrip previews share a safe budget, condense instantly, and retain sel await p.screenshot({ path: `${process.env.WORKSPACE_SHOTS}/harness-fresh-desktop.png` }) } await p.setViewportSize({ width: 390, height: 844 }) - await poll(p, () => !document.querySelector('.ws-tabs').classList.contains('ws-strip-film')) - assert.equal(await film.locator('.ws-tab-thumb:visible').count(), 0) + await poll(p, () => !document.querySelector('.ws-tabs').checkVisibility()) if (process.env.WORKSPACE_SHOTS) { await p.locator('.ws-page-choice').click() await p.screenshot({ path: `${process.env.WORKSPACE_SHOTS}/harness-fresh-phone.png` }) @@ -753,8 +786,12 @@ async function verdictLook(locator) { const rgb = color => { context.clearRect(0, 0, 1, 1); context.fillStyle = color; context.fillRect(0, 0, 1, 1); return [...context.getImageData(0, 0, 1, 1).data].slice(0, 3) } const luminance = color => rgb(color).map(n => n / 255).map(n => n <= .04045 ? n / 12.92 : ((n + .055) / 1.055) ** 2.4).reduce((sum, n, i) => sum + n * [.2126, .7152, .0722][i], 0) const style = getComputedStyle(el) - const ink = luminance(style.color), fill = luminance(style.backgroundColor === 'rgba(0, 0, 0, 0)' ? getComputedStyle(el.closest('.ws-verdict-toast') ?? document.body).backgroundColor : style.backgroundColor) - return { color: rgb(style.color), fill: rgb(style.backgroundColor), ratio: (Math.max(ink, fill) + .05) / (Math.min(ink, fill) + .05) } + // A bare verb in the head stands on the veil, whose opaque floor is the ground. + const fillColor = style.backgroundColor !== 'rgba(0, 0, 0, 0)' ? style.backgroundColor + : el.closest('.ws-verdict-toast') ? getComputedStyle(el.closest('.ws-verdict-toast')).backgroundColor + : getComputedStyle(el.closest('.ws-reader')?.querySelector('[data-part="veil"]') ?? document.body).getPropertyValue('--ws-ground').trim() || getComputedStyle(document.body).backgroundColor + const ink = luminance(style.color), fill = luminance(fillColor) + return { color: rgb(style.color), fill: rgb(fillColor), ratio: (Math.max(ink, fill) + .05) / (Math.min(ink, fill) + .05) } }) } const tealish = ([r, g, b]) => g > r && b > r diff --git a/ui/src/board/FileViewerPanel.ts b/ui/src/board/FileViewerPanel.ts index 6fbe5c6d..0aa0d636 100644 --- a/ui/src/board/FileViewerPanel.ts +++ b/ui/src/board/FileViewerPanel.ts @@ -370,7 +370,7 @@ function buildThumbnail(src: string, path: string, kind: NonNullable): Promise { +export async function readThumbnailMetadata(src: string, signal: AbortSignal, onSource: NonNullable): Promise { try { const response = await fetch(src, { signal, headers: { Range: 'bytes=0-65535' } }) if (!response.ok || !response.body) return diff --git a/ui/src/board/workspace/DocumentHost.test.ts b/ui/src/board/workspace/DocumentHost.test.ts index cb25c4a6..08526452 100644 --- a/ui/src/board/workspace/DocumentHost.test.ts +++ b/ui/src/board/workspace/DocumentHost.test.ts @@ -12,6 +12,7 @@ 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 diff --git a/ui/src/board/workspace/TabPreview.ts b/ui/src/board/workspace/TabPreview.ts index 053759f8..dc8bfcb3 100644 --- a/ui/src/board/workspace/TabPreview.ts +++ b/ui/src/board/workspace/TabPreview.ts @@ -115,6 +115,9 @@ export class TabPreview { if (from === this.suppressed) this.suppressed = null if (this.pending !== null) { clearTimeout(this.pending); this.pending = null } if (!this.open || to) return + // Leaving the index puts the preview away at once; crossing its own margin keeps a moment's grace. + const strip = e.currentTarget as HTMLElement + if (!(e.relatedTarget instanceof Node && strip.contains(e.relatedTarget))) { this.dismiss(); return } if (this.leaving !== null) clearTimeout(this.leaving) this.leaving = setTimeout(() => { this.leaving = null; this.dismiss() }, PREVIEW_GRACE_MS / 3) } diff --git a/ui/src/board/workspace/TabStrip.ts b/ui/src/board/workspace/TabStrip.ts index d6dcbd97..e3ce8850 100644 --- a/ui/src/board/workspace/TabStrip.ts +++ b/ui/src/board/workspace/TabStrip.ts @@ -1,6 +1,7 @@ import type { KeyIntent } from '../keymap.js' import type { Channel, WorkspaceDocument } from './documents.js' import { TabPreview } from './TabPreview.js' +import { probeDocumentTitles } from './titleProbe.js' import { declaredTitle } from './DocumentTitles.js' import './tabs.css' import { ReceiptMotion } from './receiptMotion.js' @@ -76,6 +77,7 @@ export class TabStrip { private disposed = false private visible = true private focus: number | null = null + private readonly shuttleBase: string | null constructor(onSelect: (index: number) => void, onExpand: () => void, options?: StripOptions) { this.onSelect = onSelect @@ -85,6 +87,7 @@ export class TabStrip { this.el.dataset.part = 'tab-strip' this.el.setAttribute('role', 'tablist') this.el.setAttribute('aria-label', 'Documents') + this.shuttleBase = options?.shuttleBase ?? null this.preview = options ? new TabPreview(options.shuttleBase) : null this.preview?.attach(this.el) this.motion = typeof window.matchMedia === 'function' @@ -122,6 +125,7 @@ export class TabStrip { render(labels: string[], keys?: string[], channel?: Channel): void { if (this.disposed) return if (channel) this.preview?.update(channel, labels) + if (channel && this.shuttleBase !== null) probeDocumentTitles(this.shuttleBase, channel.documents) const captions = indexCaptions(labels, channel) if (labels.length === this.records.length && labels.every((label, index) => label === this.records[index].label && (!keys || keys[index] === this.records[index].key))) { if (channel) this.records.forEach((record, index) => this.paintLabel(record.button, captions[index], record.label, channel.documents[index])) diff --git a/ui/src/board/workspace/Thumbnail.test.ts b/ui/src/board/workspace/Thumbnail.test.ts index f58183c9..1c012221 100644 --- a/ui/src/board/workspace/Thumbnail.test.ts +++ b/ui/src/board/workspace/Thumbnail.test.ts @@ -2,6 +2,7 @@ import { afterEach, expect, it, vi } from 'vitest' const renderer = vi.hoisted(() => ({ finishes: [] as Array<() => void> })) vi.mock('../FileViewerPanel.js', () => ({ + readThumbnailMetadata: vi.fn(async () => {}), buildFileViewer: vi.fn((_base, _path, _owner, _frame, _scroll, options) => { const body = document.createElement('div'); body.append(document.createElement('iframe')) renderer.finishes.push(() => options.onState({ status: 'ready' })) diff --git a/ui/src/board/workspace/Workspace.integration.test.ts b/ui/src/board/workspace/Workspace.integration.test.ts index 01be777e..0a6a109c 100644 --- a/ui/src/board/workspace/Workspace.integration.test.ts +++ b/ui/src/board/workspace/Workspace.integration.test.ts @@ -7,6 +7,7 @@ import { Dock } from './Dock.js' import { docKey } from './documents.js' vi.mock('../FileViewerPanel.js', () => ({ + readThumbnailMetadata: vi.fn(async () => {}), buildFileViewer: vi.fn((_base: string, path: string, _owner: string, onLoad?: (frame: HTMLIFrameElement, refreshed: boolean) => void) => { const wrap = document.createElement('div') const iframe = document.createElement('iframe') diff --git a/ui/src/board/workspace/tabs.css b/ui/src/board/workspace/tabs.css index 3246f2f4..f70214c3 100644 --- a/ui/src/board/workspace/tabs.css +++ b/ui/src/board/workspace/tabs.css @@ -20,6 +20,9 @@ pointer-events: none; user-select: none; } .ws-tab-preview[hidden] { display: none; } +/* A pointer that leaves for a page goes straight into its frame, which would + swallow the leave; while a preview is up the frames let it reach the reader. */ +.ws-reader:has(> .ws-tab-preview:not([hidden])) iframe { pointer-events: none; } .ws-tab-preview-thumb { --ws-preview-aspect: 1.6; width: 100%; aspect-ratio: var(--ws-preview-aspect); overflow: hidden; diff --git a/ui/src/board/workspace/titleProbe.ts b/ui/src/board/workspace/titleProbe.ts new file mode 100644 index 00000000..36e8312f --- /dev/null +++ b/ui/src/board/workspace/titleProbe.ts @@ -0,0 +1,37 @@ +import { readThumbnailMetadata } from '../FileViewerPanel.js' +import { fileBytesUrl } from '../utils.js' +import { cacheDocumentTitle, declaredTitle } from './DocumentTitles.js' +import type { WorkspaceDocument } from './documents.js' + +/** Kinds whose first bytes can declare a title: an HTML or Markdown heading, PDF Info, ID3 or Vorbis tags. */ +const TITLED = new Set(['html', 'text', 'pdf', 'audio']) +/** At most this many peeks are in flight; the rest wait their turn in index order. */ +const CONCURRENT = 2 +const probed = new Set() +const queue: Array<() => Promise> = [] +let running = 0 + +function pump(): void { + while (running < CONCURRENT && queue.length) { + const job = queue.shift()! + running++ + void job().finally(() => { running--; pump() }) + } +} + +/** + * The index names pages by their declared titles, so it reads each titled + * document's first 64 KiB once per session, independent of any thumbnail. + */ +export function probeDocumentTitles(shuttleBase: string, documents: WorkspaceDocument[]): void { + for (const doc of documents) { + if (!TITLED.has(doc.kind) || probed.has(doc.key) || declaredTitle(doc.key)) continue + probed.add(doc.key) + queue.push(() => readThumbnailMetadata(fileBytesUrl(shuttleBase, doc.path, doc.owner), new AbortController().signal, (source, etag) => { + if (declaredTitle(doc.key)) return + const text = doc.kind === 'html' || doc.kind === 'text' + cacheDocumentTitle(doc.key, doc.path, text && typeof source !== 'string' ? new TextDecoder().decode(source) : source, etag) + })) + } + pump() +} From 3f5ccf0f493407722bca07d5d0fb8bc1685b7be8 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Mon, 5 Oct 2026 15:42:29 +0200 Subject: [PATCH 04/24] Document the running head's theming parts Co-Authored-By: Claude Opus 5.5 --- docs/concepts/themes.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/concepts/themes.md b/docs/concepts/themes.md index cae49141..005dcbd6 100644 --- a/docs/concepts/themes.md +++ b/docs/concepts/themes.md @@ -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. @@ -106,10 +106,10 @@ 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 | @@ -117,7 +117,7 @@ These hooks are stable; renaming or removing one is a theme-breaking change. | `[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 | From 0c7208f22c83c724c087b16c81926974935bd90a Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Mon, 5 Oct 2026 15:45:26 +0200 Subject: [PATCH 05/24] Peek reports' titles before PDFs and audio Co-Authored-By: Claude Opus 5.5 --- ui/src/board/workspace/titleProbe.test.ts | 25 +++++++++++++++++++++++ ui/src/board/workspace/titleProbe.ts | 21 +++++++++++-------- 2 files changed, 38 insertions(+), 8 deletions(-) create mode 100644 ui/src/board/workspace/titleProbe.test.ts diff --git a/ui/src/board/workspace/titleProbe.test.ts b/ui/src/board/workspace/titleProbe.test.ts new file mode 100644 index 00000000..12227b63 --- /dev/null +++ b/ui/src/board/workspace/titleProbe.test.ts @@ -0,0 +1,25 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi } from 'vitest' + +const reads: string[] = [] +vi.mock('../FileViewerPanel.js', () => ({ + readThumbnailMetadata: vi.fn(async (src: string, _signal: AbortSignal, onSource: (source: Uint8Array) => void) => { + reads.push(decodeURIComponent(src)) + if (src.includes('report.html')) onSource(new TextEncoder().encode('The report')) + }), +})) +const { probeDocumentTitles } = await import('./titleProbe.js') +const { declaredTitle } = await import('./DocumentTitles.js') +const { buildChannel } = await import('./documents.js') + +describe('title probe', () => { + it('peeks each titled document once, reports before PDFs before audio, and names the index', async () => { + const channel = buildChannel({ uid: 'probe', owner: 'probe-host', name: 'Probe', path: '/f.md', fiberDir: '/', body: '', + embeds: [{ path: '/song.mp3' }, { path: '/plot.png' }, { path: '/paper.pdf' }, { path: '/report.html' }] }) + probeDocumentTitles('', channel.documents) + probeDocumentTitles('', channel.documents) + await vi.waitFor(() => expect(reads).toHaveLength(3)) + expect(reads.map(src => src.match(/\/(\w+\.\w+)/)?.[1])).toEqual(['report.html', 'paper.pdf', 'song.mp3']) + expect(declaredTitle(channel.documents.find(d => d.name === 'report.html')!.key)?.title).toBe('The report') + }) +}) diff --git a/ui/src/board/workspace/titleProbe.ts b/ui/src/board/workspace/titleProbe.ts index 36e8312f..ea6b6ad5 100644 --- a/ui/src/board/workspace/titleProbe.ts +++ b/ui/src/board/workspace/titleProbe.ts @@ -3,19 +3,22 @@ import { fileBytesUrl } from '../utils.js' import { cacheDocumentTitle, declaredTitle } from './DocumentTitles.js' import type { WorkspaceDocument } from './documents.js' -/** Kinds whose first bytes can declare a title: an HTML or Markdown heading, PDF Info, ID3 or Vorbis tags. */ -const TITLED = new Set(['html', 'text', 'pdf', 'audio']) -/** At most this many peeks are in flight; the rest wait their turn in index order. */ +/** + * Kinds whose first bytes can declare a title, most likely first: an HTML or + * Markdown heading, then PDF Info, then ID3 or Vorbis tags. + */ +const TITLED: Partial> = { html: 0, text: 1, pdf: 2, audio: 3 } +/** At most this many peeks are in flight; the rest wait by kind, then index order. */ const CONCURRENT = 2 const probed = new Set() -const queue: Array<() => Promise> = [] +const queue: Array<{ rank: number; run: () => Promise }> = [] let running = 0 function pump(): void { while (running < CONCURRENT && queue.length) { const job = queue.shift()! running++ - void job().finally(() => { running--; pump() }) + void job.run().finally(() => { running--; pump() }) } } @@ -25,13 +28,15 @@ function pump(): void { */ export function probeDocumentTitles(shuttleBase: string, documents: WorkspaceDocument[]): void { for (const doc of documents) { - if (!TITLED.has(doc.kind) || probed.has(doc.key) || declaredTitle(doc.key)) continue + const rank = TITLED[doc.kind] + if (rank === undefined || probed.has(doc.key) || declaredTitle(doc.key)) continue probed.add(doc.key) - queue.push(() => readThumbnailMetadata(fileBytesUrl(shuttleBase, doc.path, doc.owner), new AbortController().signal, (source, etag) => { + queue.push({ rank, run: () => readThumbnailMetadata(fileBytesUrl(shuttleBase, doc.path, doc.owner), new AbortController().signal, (source, etag) => { if (declaredTitle(doc.key)) return const text = doc.kind === 'html' || doc.kind === 'text' cacheDocumentTitle(doc.key, doc.path, text && typeof source !== 'string' ? new TextDecoder().decode(source) : source, etag) - })) + }) }) } + queue.sort((a, b) => a.rank - b.rank) pump() } From 305bdc64365079142c2c2088ef49f5d48faa4c71 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Mon, 5 Oct 2026 15:51:34 +0200 Subject: [PATCH 06/24] Try an unreadable title peek again after a minute A peek that could not read its document kept it marked as probed for the session, so its tab kept the filename. It now becomes eligible again after PROBE_RETRY_MS. Co-Authored-By: Claude Opus 5.5 --- ui/src/board/workspace/titleProbe.test.ts | 12 +++++++++++- ui/src/board/workspace/titleProbe.ts | 18 +++++++++++++----- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/ui/src/board/workspace/titleProbe.test.ts b/ui/src/board/workspace/titleProbe.test.ts index 12227b63..2c936589 100644 --- a/ui/src/board/workspace/titleProbe.test.ts +++ b/ui/src/board/workspace/titleProbe.test.ts @@ -8,12 +8,13 @@ vi.mock('../FileViewerPanel.js', () => ({ if (src.includes('report.html')) onSource(new TextEncoder().encode('The report')) }), })) -const { probeDocumentTitles } = await import('./titleProbe.js') +const { probeDocumentTitles, PROBE_RETRY_MS } = await import('./titleProbe.js') const { declaredTitle } = await import('./DocumentTitles.js') const { buildChannel } = await import('./documents.js') describe('title probe', () => { it('peeks each titled document once, reports before PDFs before audio, and names the index', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }) const channel = buildChannel({ uid: 'probe', owner: 'probe-host', name: 'Probe', path: '/f.md', fiberDir: '/', body: '', embeds: [{ path: '/song.mp3' }, { path: '/plot.png' }, { path: '/paper.pdf' }, { path: '/report.html' }] }) probeDocumentTitles('', channel.documents) @@ -21,5 +22,14 @@ describe('title probe', () => { await vi.waitFor(() => expect(reads).toHaveLength(3)) expect(reads.map(src => src.match(/\/(\w+\.\w+)/)?.[1])).toEqual(['report.html', 'paper.pdf', 'song.mp3']) expect(declaredTitle(channel.documents.find(d => d.name === 'report.html')!.key)?.title).toBe('The report') + // The PDF and the song could not be read, so a render after the retry interval peeks them again; the report is done. + probeDocumentTitles('', channel.documents) + await Promise.resolve() + expect(reads).toHaveLength(3) + vi.advanceTimersByTime(PROBE_RETRY_MS) + probeDocumentTitles('', channel.documents) + await vi.waitFor(() => expect(reads).toHaveLength(5)) + vi.useRealTimers() + expect(reads.slice(3).map(src => src.match(/\/(\w+\.\w+)/)?.[1])).toEqual(['paper.pdf', 'song.mp3']) }) }) diff --git a/ui/src/board/workspace/titleProbe.ts b/ui/src/board/workspace/titleProbe.ts index ea6b6ad5..f5dff02c 100644 --- a/ui/src/board/workspace/titleProbe.ts +++ b/ui/src/board/workspace/titleProbe.ts @@ -10,6 +10,8 @@ import type { WorkspaceDocument } from './documents.js' const TITLED: Partial> = { html: 0, text: 1, pdf: 2, audio: 3 } /** At most this many peeks are in flight; the rest wait by kind, then index order. */ const CONCURRENT = 2 +/** A peek that could not read waits this long before a render may try it again. */ +export const PROBE_RETRY_MS = 60_000 const probed = new Set() const queue: Array<{ rank: number; run: () => Promise }> = [] let running = 0 @@ -31,11 +33,17 @@ export function probeDocumentTitles(shuttleBase: string, documents: WorkspaceDoc const rank = TITLED[doc.kind] if (rank === undefined || probed.has(doc.key) || declaredTitle(doc.key)) continue probed.add(doc.key) - queue.push({ rank, run: () => readThumbnailMetadata(fileBytesUrl(shuttleBase, doc.path, doc.owner), new AbortController().signal, (source, etag) => { - if (declaredTitle(doc.key)) return - const text = doc.kind === 'html' || doc.kind === 'text' - cacheDocumentTitle(doc.key, doc.path, text && typeof source !== 'string' ? new TextDecoder().decode(source) : source, etag) - }) }) + queue.push({ rank, run: async () => { + let read = false + await readThumbnailMetadata(fileBytesUrl(shuttleBase, doc.path, doc.owner), new AbortController().signal, (source, etag) => { + read = true + if (declaredTitle(doc.key)) return + const text = doc.kind === 'html' || doc.kind === 'text' + cacheDocumentTitle(doc.key, doc.path, text && typeof source !== 'string' ? new TextDecoder().decode(source) : source, etag) + }) + // A peek that could not read (an unreachable owner, a refused request) is tried again on a later render. + if (!read) setTimeout(() => probed.delete(doc.key), PROBE_RETRY_MS) + } }) } queue.sort((a, b) => a.rank - b.rank) pump() From a6453c6f0de52bd881f4f443de7ec035e4d02160 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Mon, 5 Oct 2026 16:00:56 +0200 Subject: [PATCH 07/24] Ask for title peeks at high priority On first open the stage's images and frames fill the browser's connections to the daemon, and a report's title peek waited behind them for seconds. Co-Authored-By: Claude Opus 5.5 --- ui/src/board/FileViewerPanel.ts | 4 ++-- ui/src/board/workspace/titleProbe.ts | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/ui/src/board/FileViewerPanel.ts b/ui/src/board/FileViewerPanel.ts index 0aa0d636..304a32ad 100644 --- a/ui/src/board/FileViewerPanel.ts +++ b/ui/src/board/FileViewerPanel.ts @@ -370,9 +370,9 @@ function buildThumbnail(src: string, path: string, kind: NonNullable): Promise { +export async function readThumbnailMetadata(src: string, signal: AbortSignal, onSource: NonNullable, priority: RequestPriority = 'auto'): Promise { 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) diff --git a/ui/src/board/workspace/titleProbe.ts b/ui/src/board/workspace/titleProbe.ts index f5dff02c..e7200534 100644 --- a/ui/src/board/workspace/titleProbe.ts +++ b/ui/src/board/workspace/titleProbe.ts @@ -26,7 +26,8 @@ function pump(): void { /** * The index names pages by their declared titles, so it reads each titled - * document's first 64 KiB once per session, independent of any thumbnail. + * document's first 64 KiB once per session, independent of any thumbnail, + * ahead of the stage's images and frames competing for the same connections. */ export function probeDocumentTitles(shuttleBase: string, documents: WorkspaceDocument[]): void { for (const doc of documents) { @@ -40,7 +41,7 @@ export function probeDocumentTitles(shuttleBase: string, documents: WorkspaceDoc if (declaredTitle(doc.key)) return const text = doc.kind === 'html' || doc.kind === 'text' cacheDocumentTitle(doc.key, doc.path, text && typeof source !== 'string' ? new TextDecoder().decode(source) : source, etag) - }) + }, 'high') // A peek that could not read (an unreachable owner, a refused request) is tried again on a later render. if (!read) setTimeout(() => probed.delete(doc.key), PROBE_RETRY_MS) } }) From e0bd528f6c3babf25ff60a4fa3c6f20799742d1e Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Mon, 5 Oct 2026 16:00:56 +0200 Subject: [PATCH 08/24] Set the index a step below the fiber's name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The index's labels drop to 14 px in muted ink, so the 15 px name in ink leads the row and the § opens the index. A hairline tick between them was tried and left out: with a short run centred over the stage it floated alone after the name. Co-Authored-By: Claude Opus 5.5 --- ui/e2e/workspace.mjs | 4 +++- ui/src/board/workspace/tabs.css | 4 ++-- ui/src/board/workspace/tokens.css | 4 +++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/ui/e2e/workspace.mjs b/ui/e2e/workspace.mjs index 68208753..95d71aa2 100644 --- a/ui/e2e/workspace.mjs +++ b/ui/e2e/workspace.mjs @@ -93,7 +93,9 @@ for (const [device, viewport] of [['desktop', { width: 1440, height: 900 }], ['p await type('.ws-channel-title', 15, 'EB Garamond') await type('.ws-nav-verdicts .ws-review-plate .kbn-ctl-btn', 15, 'EB Garamond') if (device === 'desktop') { - for (const selector of ['.ws-return', '.ws-tab:not(.ws-tab-anchor) .ws-tab-label', '.ws-selected .ws-label-title']) await type(selector, 15, 'EB Garamond') + for (const selector of ['.ws-return', '.ws-selected .ws-label-title']) await type(selector, 15, 'EB Garamond') + // The index sits a step below the head's names. + await type('.ws-tab:not(.ws-tab-anchor) .ws-tab-label', 14, 'EB Garamond') await type('.ws-selected .ws-provenance', 11, 'IBM Plex Mono') await type('.ws-head-position', 11, 'IBM Plex Mono') } else { diff --git a/ui/src/board/workspace/tabs.css b/ui/src/board/workspace/tabs.css index f70214c3..5316f943 100644 --- a/ui/src/board/workspace/tabs.css +++ b/ui/src/board/workspace/tabs.css @@ -1,8 +1,8 @@ -/* The running head's index: bare serif labels in muted ink. The selected +/* The running head's index: bare serif labels a step below the name, in muted ink. The selected label is in ink over one fine rule; there is no fill, plate or shadow. */ .ws-tab-label { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font: var(--ws-label-size)/var(--ws-control-height) var(--ws-serif); letter-spacing: 0; + font: var(--ws-index-size)/var(--ws-control-height) var(--ws-serif); letter-spacing: 0; text-decoration: underline transparent var(--ws-line-width); text-underline-offset: var(--ws-tab-rule-offset); text-decoration-skip-ink: none; } diff --git a/ui/src/board/workspace/tokens.css b/ui/src/board/workspace/tokens.css index 69395408..dd9446bd 100644 --- a/ui/src/board/workspace/tokens.css +++ b/ui/src/board/workspace/tokens.css @@ -76,7 +76,9 @@ /* Half the space between two index labels. */ --ws-tab-pad: 9px; --ws-tab-rule-offset: 6px; - --ws-tab-anchor-size: 17px; + --ws-tab-anchor-size: 16px; + /* The index sits a step below the head's names, so the fiber's name leads. */ + --ws-index-size: 14px; --ws-preview-width: 224px; --ws-drag-latch: 4px; From 8c50c9ebf100af0b76af95f1beee37c458bd1f5c Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Mon, 5 Oct 2026 16:00:56 +0200 Subject: [PATCH 09/24] Draw the worker control at the head's right end The card's own pill, bare: a dot in its pigment, the state in the serif and the elapsed time in mono, before the page count. The phone's top bar shows the dot alone in a 44 px target. It opens the conversation exactly as the pill does and repaints every 30 s. Co-Authored-By: Claude Opus 5.5 --- ui/e2e/workspace.mjs | 29 +++++++++++++-- ui/src/board/workspace/Reader.sidebar.test.ts | 35 ++++++++++++++++--- ui/src/board/workspace/Reader.ts | 24 +++++++++++-- ui/src/board/workspace/reader.css | 22 +++++++++++- 4 files changed, 101 insertions(+), 9 deletions(-) diff --git a/ui/e2e/workspace.mjs b/ui/e2e/workspace.mjs index 95d71aa2..f9d7db1e 100644 --- a/ui/e2e/workspace.mjs +++ b/ui/e2e/workspace.mjs @@ -1334,6 +1334,14 @@ test('Remote worker pill records attach handler without launching a terminal', a assert.match(event.session, /remote-review-01KVBR3H8DYFXNH96683RX89N0-shuttle/) }) +test('The head worker control opens the same remote worker as the pill', async p => { + await p.locator('.kbn-desk .kbn-card').filter({ hasText: 'Remote covariance review' }).click() + await p.locator('.ws-navbar .ws-head-worker .kbn-card-worker').click() + await poll(p, () => window.__harness.events.some(e => e.type === 'open-worker') || window.__harness.handlers.some(h => h.path === '/api/v1/attach')) + const event = await p.evaluate(() => window.__harness.events.find(e => e.type === 'open-worker')) + assert.equal(event.host, 'basalt-login-02') +}) + test('Phone overview single column, reader sheet, footer stepping and Back', async p => { const switcher = await p.locator('.kbn-viewtabs').boundingBox() assert.equal(Math.round(switcher.y + switcher.height), 844, 'view switcher sits at the bottom') @@ -1682,7 +1690,7 @@ for (const [device, viewport] of [['desktop', { width: 1440, height: 900 }], ['p await p.screenshot({ path: resolve(process.env.WORKSPACE_SHOTS, `verdict-${device}-${state}.png`) }) } await open(p) - assert.equal(await p.locator('.ws-navbar .kbn-card-worker, .ws-navbar .ws-worker-pill').count(), 0, 'the navbar carries no worker plate') + assert.ok(await p.locator('.ws-navbar .ws-head-worker').isHidden(), 'a fiber with no worker leaves the head without a worker control') if (device === 'phone') { assert.ok(!await p.locator('.ws-nav-verdicts').isVisible(), 'the phone top bar stays the name alone') await p.locator('.ws-page-choice').click() @@ -1723,6 +1731,20 @@ for (const [device, viewport] of [['desktop', { width: 1440, height: 900 }], ['p assert.ok(await dot.isVisible(), 'the act zone pill shows its worker dot') assert.equal(await dot.evaluate(el => getComputedStyle(el).animationName), 'none', 'reduced motion suppresses breathing') assert.ok(await p.locator('.ws-selected .ws-dock .ws-worker-pill .ws-turn-active').count()) + const head = p.locator('.ws-navbar .ws-head-worker .ws-worker-control') + await poll(p, () => document.querySelector('.ws-navbar .ws-head-worker .ws-worker-control')?.dataset.workerState === 'aloft') + const headDot = await head.locator('.ws-worker-dot').evaluate(el => getComputedStyle(el).backgroundColor) + const headLook = await head.evaluate(el => ({ border: getComputedStyle(el).borderTopWidth, background: getComputedStyle(el).backgroundColor })) + assert.deepEqual(headLook, { border: '0px', background: 'rgba(0, 0, 0, 0)' }, 'the head draws the worker bare') + const headBox = await head.boundingBox(), position = await p.locator('.ws-head-position').boundingBox() + if (device === 'phone') { + assert.ok(await head.locator('.ws-worker-state').isHidden(), 'the phone top bar shows the dot alone') + assert.ok(headBox.width >= 44 && headBox.height >= 44, `the phone dot is a full target: ${JSON.stringify(headBox)}`) + assert.ok(headBox.x + headBox.width >= viewport.width - 16, 'the phone dot sits at the right end of the top bar') + } else { + assert.match(await head.innerText(), /aloft\s*12 m/) + assert.ok(headBox.x + headBox.width <= position.x, 'the worker control precedes the page count') + } await shot('aloft') await p.evaluate(async () => { const row = window.__harness.MOCK_FEED.fibers.find(row => row.fiber.name === 'Remote covariance review') @@ -1732,11 +1754,14 @@ for (const [device, viewport] of [['desktop', { width: 1440, height: 900 }], ['p }) await poll(p, () => document.querySelector('.ws-selected .ws-dock .ws-worker-control')?.dataset.workerState === 'waiting') assert.equal(await p.locator('.ws-selected .ws-dock .ws-worker-pill .ws-turn-active').count(), 0) + await poll(p, () => document.querySelector('.ws-navbar .ws-head-worker .ws-worker-control')?.dataset.workerState === 'waiting') + assert.notEqual(await head.locator('.ws-worker-dot').evaluate(el => getComputedStyle(el).backgroundColor), headDot, 'a waiting worker turns the head dot gold') await shot('waiting') await p.locator('.ws-return').click() await chooseDeskColumn(p, 0) await p.locator('.kbn-desk .kbn-card').filter({ hasText: 'Weekly shear summary' }).click() await poll(p, () => { const pill = document.querySelector('.ws-selected .ws-dock .ws-worker-pill'); return pill?.hidden === true && getComputedStyle(pill).display === 'none' }) + await poll(p, () => document.querySelector('.ws-navbar .ws-head-worker')?.hidden === true) await shot('no-worker') }, viewport) } @@ -1785,7 +1810,7 @@ for (const [device, viewport] of [['desktop', { width: 1440, height: 900 }], ['p return text.join(' ') }) assert.doesNotMatch(passive, /claude-opus|\bhigh\b|umber-workstation|\/fixture-store\/workspace/, 'launch metadata has no passive home outside settings') - assert.equal(await p.locator('.ws-navbar .kbn-card-worker').count(), 0, 'the act zone pill is the only conversation control') + assert.ok(await p.locator('.ws-navbar .kbn-card-worker').count() <= 1, 'the head carries at most one worker control') assert.ok(await selected(p).locator('.kbn-card-worker').count() <= 1, 'the act zone pill is the only conversation control') assert.equal(await selected(p).locator('.ws-fiber-prose > h1:visible').count(), device === 'phone' ? 0 : 1, 'desktop title is the adopted reading anchor; phone navbar owns the name') const navbar = p.locator('.ws-navbar') diff --git a/ui/src/board/workspace/Reader.sidebar.test.ts b/ui/src/board/workspace/Reader.sidebar.test.ts index e558d468..96deb5f1 100644 --- a/ui/src/board/workspace/Reader.sidebar.test.ts +++ b/ui/src/board/workspace/Reader.sidebar.test.ts @@ -97,9 +97,14 @@ describe('Reader channel sidebar', () => { storage.set('shuttle:workspace:sidebar', 'true') const working = { ...beta, workerState: 'running' as const, runtimePhase: 'working', tmuxSession: 'beta-worker', workerStartedAt: Date.now() - 60000 } listedCards = [working] - const target = document.createElement('button'), open = vi.fn() - target.addEventListener('click', open) - const reader = makeReader(working, undefined, () => target) + const open = vi.fn() + let target = document.createElement('button') + // Each call builds a fresh pill, as the dock does; the sidebar's is the last one built. + const reader = makeReader(working, undefined, () => { + target = document.createElement('button') + target.addEventListener('click', open) + return target + }) expect(reader.el.querySelector('.ws-sidebar .ws-worker-control')).toBe(target) expect(target.querySelector('.ws-worker-state')?.textContent).toBe('aloft') expect(target.querySelector('.ws-worker-elapsed')?.textContent).toBe('1 m') @@ -107,6 +112,28 @@ describe('Reader channel sidebar', () => { target.click() expect(open).toHaveBeenCalledOnce() }) + it("draws the card's own worker pill bare at the head's right end, before the page count", () => { + const working = { ...beta, workerState: 'running' as const, runtimePhase: 'working', tmuxSession: 'beta-worker', workerStartedAt: Date.now() - 34 * 60000 } + listedCards = [working] + const open = vi.fn() + const reader = makeReader(working, undefined, () => { + const pill = document.createElement('button') + pill.className = 'kbn-card-worker' + pill.addEventListener('click', open) + return pill + }) + const head = reader.el.querySelector('.ws-navbar .ws-nav-trail .ws-head-worker')! + expect(head.hidden).toBe(false) + expect(head.dataset.part).toBe('act') + expect(head.nextElementSibling?.classList.contains('ws-head-position')).toBe(true) + const control = head.querySelector('.ws-worker-control')! + expect(control.dataset.workerState).toBe('aloft') + expect(control.querySelector('.ws-worker-dot')).not.toBeNull() + expect(control.querySelector('.ws-worker-state')?.textContent).toBe('aloft') + expect(control.querySelector('.ws-worker-elapsed')?.textContent).toBe('34 m') + control.click() + expect(open).toHaveBeenCalledOnce() + }) it('binds retained sidebar roots only while active and visible, through revisions, filtering and hide/show', () => { storage.set('shuttle:workspace:sidebar', 'true') const bound = new Map() @@ -127,7 +154,7 @@ describe('Reader channel sidebar', () => { expect(reader.el.querySelector('.ws-navbar')?.getAttribute('data-part')).toBe('phone-topbar') expect(reader.el.querySelector('.ws-thumbbar')?.getAttribute('data-part')).toBe('phone-bottom-bar') expect(reader.el.querySelector('.ws-nav-verdicts')?.getAttribute('data-act')).toBe('verdict') - expect(reader.el.querySelector('.ws-navbar .ws-worker-pill, .ws-navbar .kbn-card-worker')).toBeNull() + expect(reader.el.querySelector('.ws-navbar .ws-head-worker')?.hidden).toBe(true) const revised = { ...alpha, outcome: 'A new result' } listedCards = [revised, beta] reader.refreshChannels() diff --git a/ui/src/board/workspace/Reader.ts b/ui/src/board/workspace/Reader.ts index 74ab755f..319d0ebb 100644 --- a/ui/src/board/workspace/Reader.ts +++ b/ui/src/board/workspace/Reader.ts @@ -97,6 +97,9 @@ export class Reader { private readonly position = element('span', 'ws-position') /** The running head's page count, at its right end. */ private readonly headPosition = element('span', 'ws-position ws-head-position') + /** The one worker control: the card's own pill, drawn bare in the head's right end. */ + private readonly headWorker = element('span', 'ws-head-worker') + private workerClock = 0 private readonly pageTitle = element('span', 'ws-thumb-title') private readonly arrivalSummary = element('span', 'ws-thumb-arrival') private readonly topbar = new PhoneTopbar(hidden => this.el.classList.toggle('ws-topbar-hidden', this.phone.matches && hidden)) @@ -160,9 +163,13 @@ export class Reader { this.lead = element('div', 'ws-nav-lead') this.lead.append(this.sidebarToggle, this.returnButton, this.title, this.verdicts) this.headPosition.setAttribute('aria-hidden', 'true') + this.headWorker.dataset.part = 'act'; this.headWorker.dataset.act = 'worker' + this.headWorker.hidden = true + const trail = element('div', 'ws-nav-trail') + trail.append(this.headWorker, this.headPosition) this.navbar = element('nav', 'ws-navbar') this.navbar.dataset.part = 'phone-topbar' - this.navbar.append(this.lead, this.tabs.el, this.headPosition) + this.navbar.append(this.lead, this.tabs.el, trail) this.prev = button('ws-thumb-button', '', () => this.step(-1), 'Previous document') this.next = button('ws-thumb-button', '', () => this.step(1), 'Next document') this.prev.innerHTML = '' @@ -269,6 +276,7 @@ export class Reader { this.returnButton.setAttribute('aria-label', `Return to ${origin}`) this.title.textContent = channel.name this.title.title = channel.name + if (!this.workerClock) this.workerClock = window.setInterval(() => this.paintWorker(), 30000) this.paintVerdicts() this.tabs.setVisible(true) this.tabs.render(channel.labels, channel.documents.map(d => d.key), channel) @@ -297,6 +305,7 @@ export class Reader { * sheet wears the same veil, it goes at once. */ hide(animate = false): void { + window.clearInterval(this.workerClock); this.workerClock = 0 this.cancelSwipe() this.cancelResize?.() this.setSidebarVisible(false, animate) @@ -417,8 +426,18 @@ export class Reader { } this.verdicts.hidden = !key || !this.verdicts.firstChild || this.document?.kind === 'fiber' } + /** Repaint the head's worker control from the current card, keeping its focus. */ + private paintWorker(): void { + const card = this.currentCard + const pill = card ? this.opts.workerPill?.(card) ?? null : null + const focused = this.headWorker.contains(document.activeElement) + this.headWorker.replaceChildren(...(card && pill ? [workerPlate(card, pill)] : [])) + this.headWorker.hidden = !pill + if (focused) this.headWorker.querySelector('.kbn-card-worker')?.focus({ preventScroll: true }) + } private paint(animate: boolean): void { this.paintVerdicts() + this.paintWorker() const ch = this.channel if (!ch) return const index = ch.documents.findIndex(d => d.key === this.selected) @@ -519,7 +538,7 @@ export class Reader { const leadGap = parseFloat(getComputedStyle(this.lead).columnGap) || 0 const leadParts = [this.sidebarToggle, this.returnButton, this.title, this.verdicts].filter(el => el.offsetWidth > 0) const lead = leadParts.reduce((sum, el) => sum + (el === this.title ? Math.min(this.measure('title-ceiling', 360), el.scrollWidth) : el.offsetWidth), 0) + Math.max(0, leadParts.length - 1) * leadGap - const position = this.headPosition.offsetWidth + const position = this.headPosition.parentElement?.offsetWidth ?? this.headPosition.offsetWidth const stripStyle = getComputedStyle(this.tabs.el) const tabs = this.tabs.buttons.reduce((sum, b) => sum + b.offsetWidth, 0) + (parseFloat(stripStyle.paddingLeft) || 0) + (parseFloat(stripStyle.paddingRight) || 0) + 1 // The index is centred over the stage, which starts after the sidebar, @@ -908,6 +927,7 @@ export class Reader { scroller.scrollBy?.({ top: (up ? -1 : 1) * amount, behavior: this.motion.matches || repeat ? 'instant' : 'smooth' }) } dispose(): void { + window.clearInterval(this.workerClock) this.cancelSidebarSlide?.() this.opts.themes?.unbind(this.el) this.cancelResize?.() diff --git a/ui/src/board/workspace/reader.css b/ui/src/board/workspace/reader.css index f604af39..35faa9e3 100644 --- a/ui/src/board/workspace/reader.css +++ b/ui/src/board/workspace/reader.css @@ -64,7 +64,22 @@ .ws-sidebar-toggle[aria-expanded='true'] .ws-toggle-column { opacity: .35; } .ws-sidebar-toggle:hover, .ws-sidebar-toggle[aria-expanded='true'] { color: var(--ws-ink) !important; } .ws-sidebar-toggle:hover { background: var(--ws-hover); } -.ws-head-position { justify-self: end; color: var(--ws-ink-muted); font-size: var(--ws-small-size); letter-spacing: 0; font-variant-numeric: tabular-nums; white-space: nowrap; } +/* The right end: the worker control, then the page count. */ +.ws-nav-trail { justify-self: end; display: flex; align-items: baseline; gap: var(--ws-head-gap); min-width: 0; } +.ws-head-worker { flex: none; display: flex; align-items: baseline; } +.ws-head-worker[hidden] { display: none; } +/* The card's own pill, bare: a pigmented dot, the state in the serif, the time in mono. */ +.ws-navbar .ws-head-worker .ws-worker-control { + display: inline-flex; align-items: baseline; gap: 6px; height: var(--ws-control-height); margin: 0 calc(-1 * var(--ws-small-gap)); + padding: 0 var(--ws-small-gap); border: 0; border-radius: var(--ws-control-radius); background: transparent; box-shadow: none; + color: var(--ws-ink-muted); font: var(--ws-label-size)/var(--ws-control-height) var(--ws-serif); + letter-spacing: 0; text-transform: none; text-decoration: none; animation: none; +} +.ws-navbar .ws-head-worker .ws-worker-control:hover { color: var(--ws-ink); background: var(--ws-hover); } +.ws-head-worker .ws-worker-dot { align-self: center; width: 7px; height: 7px; } +.ws-head-worker .ws-worker-state { font: inherit; } +.ws-head-worker .ws-worker-elapsed { font: var(--ws-small-size)/1 var(--ws-mono); letter-spacing: 0; } +.ws-head-position { color: var(--ws-ink-muted); font-size: var(--ws-small-size); letter-spacing: 0; font-variant-numeric: tabular-nums; white-space: nowrap; } .ws-worker-pill { flex: none; white-space: nowrap; } .ws-worker-pill .ws-worker-control { display: inline-flex; align-items: center; gap: 6px; padding: 0 8px; height: 36px; @@ -266,6 +281,11 @@ .ws-labelbar { display: none; } .ws-media-poster { bottom: 0; } .ws-nav-verdicts, .ws-tabs, .ws-head-position { display: none !important; } + /* The top bar's right end: the worker's dot alone, in a full target. */ + .ws-nav-trail { gap: 0; align-self: center; } + .ws-navbar .ws-head-worker .ws-worker-control { width: var(--ws-phone-target); height: var(--ws-phone-target); margin: 0; padding: 0; justify-content: center; align-items: center; } + .ws-head-worker :is(.ws-worker-state, .ws-worker-elapsed) { display: none; } + .ws-head-worker .ws-worker-dot { width: 9px; height: 9px; } .ws-nav-lead { min-height: var(--ws-phone-nav-height); align-items: center; gap: 0; } /* One chevron drawing for back and stepping: a drawn stroke in ink, legible at 44 px. */ .ws-return { position: relative; width: var(--ws-phone-target); height: var(--ws-phone-target); overflow: hidden; text-indent: -9999px; flex: none; padding: 0; color: var(--ws-ink) !important; } From 3f7d85f02767ecd28962a6690e3cef075c4b41a6 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Mon, 5 Oct 2026 16:01:17 +0200 Subject: [PATCH 10/24] Give Portolan's kicker a plain paper ground Co-Authored-By: Claude Opus 5.5 --- ui/src/board/workspace/themes/portolan.css | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/ui/src/board/workspace/themes/portolan.css b/ui/src/board/workspace/themes/portolan.css index 9653830d..86ae2baf 100644 --- a/ui/src/board/workspace/themes/portolan.css +++ b/ui/src/board/workspace/themes/portolan.css @@ -73,12 +73,7 @@ background-color: var(--ws-paper); } -[data-part="fiber-header"] { - background-color: var(--ws-paper); - background-image: - repeating-linear-gradient(32deg, transparent 0 43px, var(--ws-rhumb) 43px 44px), - repeating-linear-gradient(148deg, transparent 0 43px, var(--ws-rhumb) 43px 44px); -} +[data-part="fiber-header"] { background-color: var(--ws-paper); } [data-part="fiber-title"] { color: var(--ws-ink); } [data-part="fiber-title"]::after { From 81bf54eda27a95f98982f1b71d455481167e76c4 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Mon, 5 Oct 2026 16:01:17 +0200 Subject: [PATCH 11/24] Drop the fiber page's contents line; the tab strip lists the pages Co-Authored-By: Claude Opus 5.5 --- ui/src/board/workspace/FiberProse.test.ts | 11 +++----- ui/src/board/workspace/FiberProse.ts | 28 ++----------------- .../workspace/Workspace.integration.test.ts | 1 - ui/src/board/workspace/Workspace.ts | 1 - ui/src/board/workspace/fiber-prose.css | 12 +------- 5 files changed, 7 insertions(+), 46 deletions(-) diff --git a/ui/src/board/workspace/FiberProse.test.ts b/ui/src/board/workspace/FiberProse.test.ts index 99f9d1a2..d2730e08 100644 --- a/ui/src/board/workspace/FiberProse.test.ts +++ b/ui/src/board/workspace/FiberProse.test.ts @@ -42,19 +42,16 @@ describe('fiber prose', () => { expect(ledeHtml('')).toBe('') }) - it('shows status alone in the header and selects documents rather than embedding them', async () => { + it('shows status alone in the header and leaves documents to the tab strip', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 200 }))) - const onSelect = vi.fn(), onFiber = vi.fn(), onFile = vi.fn() - const pane = buildFiberProse(card, channel, { shuttleBase: '', onSelect, onFiber, onFile }) + const onFiber = vi.fn(), onFile = vi.fn() + const pane = buildFiberProse(card, channel, { shuttleBase: '', onFiber, onFile }) expect(pane.querySelector('header')?.textContent).toBe('In flight') expect(pane.querySelector('.ws-prose-status')?.textContent).toBe('In flight') expect(pane.querySelector('.ws-prose-agent, .ws-prose-host')).toBeNull() expect(pane.querySelector('h1')?.textContent).toBe('Task') expect(pane.querySelector('iframe')).toBeNull() expect(pane.querySelector('.ws-prose-documents')).toBeNull() - expect(pane.querySelector('.ws-prose-contents')?.textContent).toBe('2 pages1 report') - pane.querySelector('.ws-prose-contents button')!.click() - expect(onSelect).toHaveBeenCalledWith(channel.documents[1].key) await Promise.resolve() pane.querySelector('a.kbn-wikilink')!.click() expect(onFiber).toHaveBeenCalledWith('notes/other') @@ -83,7 +80,7 @@ describe('fiber prose', () => { it('does not intercept modified clicks and routes bytes through a configured daemon', () => { vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 200 }))) const onFile = vi.fn() - const pane = buildFiberProse(card, channel, { shuttleBase: 'https://daemon.example', onSelect: vi.fn(), onFiber: vi.fn(), onFile }) + const pane = buildFiberProse(card, channel, { shuttleBase: 'https://daemon.example', onFiber: vi.fn(), onFile }) const link = pane.querySelector('a[data-file-path]')! expect(link.href).toContain('https://daemon.example/api/v1/file?') expect(link.dataset.fileUrlAlt).toContain('https://daemon.example/api/v1/file?') diff --git a/ui/src/board/workspace/FiberProse.ts b/ui/src/board/workspace/FiberProse.ts index 14b97ee1..6b5367de 100644 --- a/ui/src/board/workspace/FiberProse.ts +++ b/ui/src/board/workspace/FiberProse.ts @@ -4,7 +4,7 @@ import { basename, renderMarkdown } from '../utils.js' import { installWikilinks } from '../wikilinks.js' import '../prose.css' import './fiber-prose.css' -import type { Channel, DocKey, WorkspaceDocument } from './documents.js' +import type { Channel } from './documents.js' import { fiberPageKicker } from './fiberPageState.js' /** The outcome as the reading surface's lede, including math and references. */ @@ -69,7 +69,6 @@ export function buildFiberProse( opts: { shuttleBase: string controls?: HTMLElement - onSelect: (key: DocKey) => void onFiber: (id: string) => void onFile: (path: string, title?: string) => void }, @@ -93,29 +92,6 @@ export function buildFiberProse( title.dataset.part = 'fiber-title' const outcome = document.createElement('div') outcome.innerHTML = ledeHtml(channel.outcome ?? card.outcome ?? '') - const contents = document.createElement('nav') - contents.className = 'ws-prose-contents' - contents.setAttribute('aria-label', 'Document kinds') - const files = channel.documents.filter(doc => doc.kind !== 'fiber') - if (files.length) { - const total = document.createElement('span') - total.textContent = `${channel.documents.length} pages` - contents.append(total) - const groups: Array<[WorkspaceDocument['kind'], string, string]> = [['html', 'report', 'reports'], ['audio', 'audio', 'audio'], ['pdf', 'PDF', 'PDF'], ['image', 'image', 'images'], ['video', 'video', 'videos'], ['text', 'text', 'texts'], ['other', 'file', 'files']] - for (const [kind, singular, plural] of groups) { - const documents = files.filter(doc => doc.kind === kind) - if (!documents.length) continue - const newest = [...documents].sort((a, b) => { - const time = (doc: WorkspaceDocument) => Math.max(0, ...doc.provenance.flatMap(p => p.kind === 'sent' && Number.isFinite(p.time) ? [p.time] : []), Date.parse(doc.modifiedAt ?? '') || 0) - return time(b) - time(a) || channel.documents.indexOf(a) - channel.documents.indexOf(b) - })[0] - const select = document.createElement('button') - select.type = 'button' - select.textContent = `${documents.length} ${documents.length === 1 ? singular : plural}` - select.addEventListener('click', () => opts.onSelect(newest.key)) - contents.append(select) - } - } const body = document.createElement('div') body.className = 'ws-prose-body' body.innerHTML = renderFiberMarkdown(channel.body, '', { @@ -135,7 +111,7 @@ export function buildFiberProse( } installBodyFileLinks(body, opts.onFile) void installWikilinks(body, { shuttleBase: opts.shuttleBase, onOpen: opts.onFiber }) - article.append(header, title, outcome, ...(files.length ? [contents] : []), ...(opts.controls ? [opts.controls] : []), body) + article.append(header, title, outcome, ...(opts.controls ? [opts.controls] : []), body) scroller.append(article) return scroller } diff --git a/ui/src/board/workspace/Workspace.integration.test.ts b/ui/src/board/workspace/Workspace.integration.test.ts index 76dea370..910428c9 100644 --- a/ui/src/board/workspace/Workspace.integration.test.ts +++ b/ui/src/board/workspace/Workspace.integration.test.ts @@ -605,7 +605,6 @@ describe('workspace reader integration', () => { note.click() const prose = workspace.reader.host.get(`fiber:host-b:second`)!.content expect(prose.querySelector('.ws-prose-documents')).toBeNull() - expect(prose.querySelector('.ws-prose-contents')?.textContent).toBe('3 pages2 reports') expect(labels()).toEqual(['Note', 'table.html', 'shared']) }) diff --git a/ui/src/board/workspace/Workspace.ts b/ui/src/board/workspace/Workspace.ts index 83d8ec10..64bee88a 100644 --- a/ui/src/board/workspace/Workspace.ts +++ b/ui/src/board/workspace/Workspace.ts @@ -266,7 +266,6 @@ export class Workspace { const page = buildFiberProse(state.card, state.channel, { controls: this.controls(state)?.el, shuttleBase: this.opts.shuttleBase, - onSelect: key => this.select(key), onFiber: id => { void this.openFiber(id, state.card.originId) }, onFile: (path, title) => this.openFile(path, title), }) diff --git a/ui/src/board/workspace/fiber-prose.css b/ui/src/board/workspace/fiber-prose.css index 32978329..2584ee19 100644 --- a/ui/src/board/workspace/fiber-prose.css +++ b/ui/src/board/workspace/fiber-prose.css @@ -1,19 +1,9 @@ .ws-fiber-prose .ws-prose-header { border: 0; margin: 0 0 8px; padding: 0; font: var(--ws-label-size)/1.3 var(--ws-serif); font-variant-caps: all-small-caps; letter-spacing: .06em; } .ws-fiber-prose > h1 { font-size: var(--ws-heading-size); margin-bottom: 20px; } .ws-fiber-prose .kbn-detail-lede { background: transparent; padding-block: 0; font-size: var(--ws-lede-size); margin: 0 0 20px; } -/* Every count carries a leading separator; the row is drawn one separator to - the left and clipped back, so whichever count starts a line shows none. */ -.ws-prose-contents { - --ws-contents-sep: 18px; - display: flex; flex-wrap: wrap; align-items: center; margin: 0 0 20px calc(-1 * var(--ws-contents-sep)); - clip-path: inset(-4px -4px -4px var(--ws-contents-sep)); color: var(--ws-ink-muted); font: var(--ws-small-size)/1.5 var(--ws-mono); -} -.ws-reader .ws-prose-contents button { padding: 4px 0; font: inherit !important; } -.ws-prose-contents > *::before { content: '·'; display: inline-block; width: var(--ws-contents-sep); text-align: center; } -.ws-prose-contents button:hover { color: var(--ws-ink); text-decoration: underline; } .ws-fiber-prose > .ws-prose-body { border-top: 1px solid var(--ws-hairline); padding-top: 20px; } .ws-fiber-prose > .ws-dock { border: 0; margin: 0; padding-bottom: 20px; } @media (max-width: 700px), (max-height: 500px) and (pointer: coarse) { .ws-fiber-prose > h1 { display: none; } - .ws-reader .ws-prose-contents button { min-height: 44px; } + .ws-fiber-prose .kbn-detail-lede { line-height: 1.42; } } From b52726343fe01d2d9ab989f5c54f7de52ee5db95 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Mon, 5 Oct 2026 16:05:32 +0200 Subject: [PATCH 12/24] Move the phone's worker control to the top bar's conversation dot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dot sits at the top bar's right as a 44 px target: cobalt while the machine works, gold while it waits or is owed. The fiber page drops its pill on the phone. Alongside: the phone lede steps down to 20 px, the folded settings row takes the fiber's ⋯, and the resting composer underlines its field with the verbs beneath, unruled. Co-Authored-By: Claude Opus 5.5 --- ui/e2e/workspace.mjs | 57 +++++++++++++++++-------------- ui/src/board/workspace/Reader.ts | 24 +++++++++++++ ui/src/board/workspace/dock.css | 12 +++++-- ui/src/board/workspace/reader.css | 15 +++++++- 4 files changed, 80 insertions(+), 28 deletions(-) diff --git a/ui/e2e/workspace.mjs b/ui/e2e/workspace.mjs index 742cf850..d1ea6243 100644 --- a/ui/e2e/workspace.mjs +++ b/ui/e2e/workspace.mjs @@ -102,7 +102,7 @@ for (const [device, viewport] of [['desktop', { width: 1440, height: 900 }], ['p await choose(p, 'Constitution') await type('.ws-selected .ws-prose-status', 15, 'EB Garamond') if (device === 'desktop') await type('.ws-selected .ws-fiber-prose h1', 34, 'EB Garamond') - await type('.ws-selected .kbn-detail-lede', 24, 'EB Garamond') + await type('.ws-selected .kbn-detail-lede', device === 'phone' ? 20 : 24, 'EB Garamond') await type('.ws-selected .kbn-ctl-send', 15, 'EB Garamond') await type('.ws-selected .kbn-ctl-strip', 11, 'IBM Plex Mono') await choose(p, 'tone.mp3') @@ -837,21 +837,13 @@ for (const [device, viewport] of [['desktop', { width: 1440, height: 900 }], ['p } }) -test('Phone fiber page: the folded settings line ends in an ellipsis and counts wrap without orphan separators', async p => { +test('Phone fiber page: the folded settings line ends in an ellipsis', async p => { await open(p); await choose(p, 'Constitution') const toggle = selected(p).locator('.kbn-detail-controls-toggle') const history = selected(p).locator('.kbn-ctl-history-toggle') const [t, h] = await Promise.all([toggle.boundingBox(), history.boundingBox()]) assert.ok(t.x + t.width <= h.x + 0.5 || t.y + t.height <= h.y + 0.5, `settings ${JSON.stringify(t)} and History ${JSON.stringify(h)} do not collide`) assert.ok(await selected(p).locator('.kbn-ctl-place').evaluate(el => el.scrollWidth > el.clientWidth && getComputedStyle(el).textOverflow === 'ellipsis'), 'the path yields and ends in an ellipsis') - const lines = await selected(p).locator('.ws-prose-contents').evaluate(nav => { - const clip = nav.getBoundingClientRect().left + parseFloat(getComputedStyle(nav).getPropertyValue('--ws-contents-sep')) - const items = [...nav.children].map(el => el.getBoundingClientRect()) - const starts = items.filter(r => Math.abs(r.left - items[0].left) < 1) - return { clip, starts: starts.map(r => r.left), tops: new Set(items.map(r => Math.round(r.top))).size } - }) - assert.ok(lines.tops > 1, 'the fixture counts wrap at this width') - for (const left of lines.starts) assert.ok(left + parseFloat('18') <= lines.clip + 0.5, 'a line-leading separator is clipped') }, { width: 402, height: 874 }) test('Landscape phone keeps a one-row top bar while the fiber awaits review', async p => { @@ -1015,12 +1007,9 @@ test('Fiber composer isolates keys; settings and history use mocked daemon', asy await p.getByText('History', { exact: true }).click() }) -test('Fiber contents replace the duplicate file list and select the newest report', async p => { +test('The fiber page leaves its documents to the tab strip', async p => { await open(p); await choose(p, 'Constitution') - assert.equal(await selected(p).locator('.ws-prose-documents').count(), 0) - await selected(p).locator('.ws-prose-contents button').filter({ hasText: /report/ }).click() - await poll(p, () => document.querySelector('.ws-tab[aria-selected="true"]')?.getAttribute('aria-label') === 'Calibration report') - assert.equal(await tab(p, 'calibration-report').getAttribute('aria-selected'), 'true') + assert.equal(await selected(p).locator('.ws-prose-documents, .ws-prose-contents').count(), 0) }) test('Body file link opens a linked document', async p => { @@ -1643,7 +1632,8 @@ for (const [device, viewport] of [['desktop', { width: 1440, height: 900 }], ['p await p.screenshot({ path: resolve(process.env.WORKSPACE_SHOTS, `verdict-${device}-${state}.png`) }) } await open(p) - assert.equal(await p.locator('.ws-navbar .kbn-card-worker, .ws-navbar .ws-worker-pill').count(), 0, 'the navbar carries no worker plate') + assert.equal(await p.locator('.ws-nav-lead .kbn-card-worker, .ws-navbar .ws-worker-pill').count(), 0, 'the navbar carries no worker plate') + assert.equal(await p.locator('.ws-topbar-worker').isVisible(), false, 'a fiber without a worker shows no conversation dot') if (device === 'phone') { assert.ok(!await p.locator('.ws-nav-verdicts').isVisible(), 'the phone top bar stays the name alone') await p.locator('.ws-page-choice').click() @@ -1678,12 +1668,23 @@ for (const [device, viewport] of [['desktop', { width: 1440, height: 900 }], ['p await chooseDeskColumn(p, 1) await p.locator('.kbn-desk .kbn-card').filter({ hasText: 'Remote covariance review' }).click() // Opening the reader is navigation, not a state change: wait for it before the short poll. - await p.locator('.ws-selected .ws-dock .ws-worker-control').waitFor() - await poll(p, () => document.querySelector('.ws-selected .ws-dock .ws-worker-control')?.textContent.includes('12 m')) - const dot = p.locator('.ws-selected .ws-dock .ws-worker-pill .ws-worker-dot') - assert.ok(await dot.isVisible(), 'the act zone pill shows its worker dot') + // The phone's worker control is the top bar's conversation dot; the + // desktop's is the act zone's pill. + const control = device === 'phone' ? '.ws-navbar .ws-topbar-worker' : '.ws-selected .ws-dock .ws-worker-pill' + await p.locator(`${control} .ws-worker-control`).waitFor() + await poll(p, selector => document.querySelector(`${selector} .ws-worker-control`)?.textContent.includes('12 m'), control) + const dot = p.locator(`${control} .ws-worker-dot`) + assert.ok(await dot.isVisible(), 'the worker control shows its dot') assert.equal(await dot.evaluate(el => getComputedStyle(el).animationName), 'none', 'reduced motion suppresses breathing') - assert.ok(await p.locator('.ws-selected .ws-dock .ws-worker-pill .ws-turn-active').count()) + assert.ok(await p.locator(`${control} .ws-turn-active`).count()) + if (device === 'phone') { + assert.equal(await p.locator('.ws-selected .ws-dock .ws-worker-pill').isVisible(), false, 'the fiber page draws no second worker control') + const target = await p.locator(`${control} .ws-worker-control`).boundingBox() + const bar = await p.locator('.ws-navbar').boundingBox() + assert.ok(target.width >= 44 && target.height >= 44, 'the dot is a 44 px target') + assert.ok(target.x + target.width >= bar.x + bar.width - 12, 'the dot sits at the top bar\'s right') + assert.equal(await dot.evaluate(el => getComputedStyle(el).backgroundColor), 'rgb(61, 91, 160)', 'a working machine is cobalt') + } await shot('aloft') await p.evaluate(async () => { const row = window.__harness.MOCK_FEED.fibers.find(row => row.fiber.name === 'Remote covariance review') @@ -1691,13 +1692,14 @@ for (const [device, viewport] of [['desktop', { width: 1440, height: 900 }], ['p row.runtime.last_activity_at = Date.now() - 120000 await window.__harness.modal.fetchAndRender() }) - await poll(p, () => document.querySelector('.ws-selected .ws-dock .ws-worker-control')?.dataset.workerState === 'waiting') - assert.equal(await p.locator('.ws-selected .ws-dock .ws-worker-pill .ws-turn-active').count(), 0) + await poll(p, selector => document.querySelector(`${selector} .ws-worker-control`)?.dataset.workerState === 'waiting', control) + assert.equal(await p.locator(`${control} .ws-turn-active`).count(), 0) + if (device === 'phone') assert.equal(await dot.evaluate(el => getComputedStyle(el).backgroundColor), 'rgb(196, 147, 51)', 'a waiting worker is gold') await shot('waiting') await p.locator('.ws-return').click() await chooseDeskColumn(p, 0) await p.locator('.kbn-desk .kbn-card').filter({ hasText: 'Weekly shear summary' }).click() - await poll(p, () => { const pill = document.querySelector('.ws-selected .ws-dock .ws-worker-pill'); return pill?.hidden === true && getComputedStyle(pill).display === 'none' }) + await poll(p, selector => { const pill = document.querySelector(selector); return pill?.hidden === true && getComputedStyle(pill).display === 'none' }, control) await shot('no-worker') }, viewport) } @@ -1990,7 +1992,12 @@ test('Broken theme falls back to its bundled base', async p => { test('Act zone stops broad button rules and resets theme fonts, sizes and pigments', async p => { await open(p); await choose(p, 'Constitution') await poll(p, () => getComputedStyle(document.querySelector('.ws-reader')).getPropertyValue('--ws-custom-ready').trim() === '1') - assert.equal(await selected(p).locator('.ws-prose-contents button').first().evaluate(el => getComputedStyle(el).color), 'rgb(255, 0, 0)', 'the broad rule is active in the reading zone') + assert.equal(await selected(p).locator('.ws-prose-body').evaluate(body => { + const probe = body.appendChild(document.createElement('button')) + const color = getComputedStyle(probe).color + probe.remove() + return color + }), 'rgb(255, 0, 0)', 'the broad rule is active in the reading zone') const temper = selected(p).getByRole('button', { name: 'Temper', exact: true }) const style = await temper.evaluate(el => ({ color: getComputedStyle(el).color, font: getComputedStyle(el).fontFamily, height: getComputedStyle(el).getPropertyValue('--ws-control-height').trim(), agent: getComputedStyle(el).getPropertyValue('--ws-agent').trim(), transform: getComputedStyle(el).textTransform, pigment: getComputedStyle(el).getPropertyValue('--kbn-agent').trim(), mono: getComputedStyle(el).getPropertyValue('--font-mono').trim() })) assert.notEqual(style.color, 'rgb(255, 0, 0)', 'button { color: red } cannot reach Temper') diff --git a/ui/src/board/workspace/Reader.ts b/ui/src/board/workspace/Reader.ts index ea3ff2af..7dd8cf21 100644 --- a/ui/src/board/workspace/Reader.ts +++ b/ui/src/board/workspace/Reader.ts @@ -95,6 +95,9 @@ export class Reader { /** The awaiting-review verdicts, beside the fiber's name, reachable from any page. */ private readonly verdicts = element('span', 'ws-nav-verdicts') private verdictKey: string | null = null + /** The phone top bar's conversation dot: the worker pill's target, drawn as its state's pigment. */ + private readonly conversation = element('span', 'ws-topbar-worker') + private conversationClock = 0 private readonly position = element('span', 'ws-position') private readonly pageTitle = element('span', 'ws-thumb-title') private readonly arrivalSummary = element('span', 'ws-thumb-arrival') @@ -162,6 +165,7 @@ export class Reader { this.lead.dataset.part = 'chrome-plate' this.lead.append(this.returnButton, this.sidebarToggle, this.title, this.verdicts) this.trail = element('div', 'ws-nav-trail') + this.trail.append(this.conversation) this.navbar = element('nav', 'ws-navbar') this.navbar.dataset.part = 'phone-topbar' const tabPlate = element('div', 'ws-nav-tabs') @@ -234,6 +238,7 @@ export class Reader { document.addEventListener('pointerdown', this.pointerInput, true) this.motion.addEventListener('change', this.relayout) this.phone.addEventListener('change', this.relayout) + this.phone.addEventListener('change', this.phoneChanged) this.wide.addEventListener('change', this.relayout) this.el.addEventListener('workspace-theme-change', this.themeChanged) this.el.addEventListener('mousedown', e => { @@ -270,6 +275,7 @@ export class Reader { this.title.textContent = channel.name this.title.title = channel.name this.paintVerdicts() + this.paintConversation() this.tabs.setVisible(true) this.tabs.render(channel.labels, channel.documents.map(d => d.key), channel) if (!switching) this.tabs.arrive(arrivals) @@ -417,6 +423,21 @@ export class Reader { } this.verdicts.hidden = !key || !this.verdicts.firstChild || this.document?.kind === 'fiber' } + private paintConversation(): void { + const card = this.currentCard + const focused = this.conversation.contains(document.activeElement) + // Only the phone draws it; the desktop's worker control is the act zone's pill. + const pill = card && this.phone.matches ? this.opts.workerPill?.(card) ?? null : null + if (card && pill) { + pill.dataset.part = 'act'; pill.dataset.act = 'worker' + workerPlate(card, pill) + } + this.conversation.replaceChildren(...(pill ? [pill] : [])) + this.conversation.hidden = !pill + if (focused) pill?.focus({ preventScroll: true }) + window.clearTimeout(this.conversationClock) + if (pill) this.conversationClock = window.setTimeout(() => { if (this.active) this.paintConversation() }, 30000) + } private paint(animate: boolean): void { this.paintVerdicts() const ch = this.channel @@ -579,6 +600,7 @@ export class Reader { frame.el.style.setProperty('--ws-neighbour-fade-end', `${end}%`) } } + private readonly phoneChanged = (): void => { if (this.active) this.paintConversation() } private readonly relayout = (): void => { if (!this.phone.matches) { this.pageSheet.close(); this.el.classList.remove('ws-topbar-hidden') } this.renderSidebar() @@ -904,6 +926,7 @@ export class Reader { window.removeEventListener('resize', this.relayout) cancelAnimationFrame(this.instantRaf) cancelAnimationFrame(this.arrival) + window.clearTimeout(this.conversationClock) if (this.departure !== null) clearTimeout(this.departure) this.wide.removeEventListener('change', this.relayout) this.stopTitles() @@ -919,6 +942,7 @@ export class Reader { document.removeEventListener('pointerdown', this.pointerInput, true) this.motion.removeEventListener('change', this.relayout) this.phone.removeEventListener('change', this.relayout) + this.phone.removeEventListener('change', this.phoneChanged) this.el.removeEventListener('workspace-theme-change', this.themeChanged) this.el.remove() } diff --git a/ui/src/board/workspace/dock.css b/ui/src/board/workspace/dock.css index ddf93a2d..ec5f11fe 100644 --- a/ui/src/board/workspace/dock.css +++ b/ui/src/board/workspace/dock.css @@ -238,10 +238,11 @@ @media (max-width: 700px), (max-height: 500px) and (pointer: coarse) { .ws-dock { --ctl-h: var(--ws-phone-target); } - .ws-dock-body { grid-template-columns: minmax(0, 1fr) auto; } + /* Settings, History and the fiber's ⋯ share the one folded row. */ + .ws-dock-body { grid-template-columns: minmax(0, 1fr) auto auto; } .ws-dock .kbn-ctl-composer { grid-template-columns: minmax(0, 1fr); } .ws-dock .kbn-ctl-composer:not(:focus-within, .kbn-ctl-composer-draft) .kbn-ctl-composer-foot { grid-column: 1; grid-row: auto; } - .ws-dock .kbn-ctl-foot { grid-column: 1 / -1; } + .ws-dock .kbn-ctl-foot { grid-column: 3; } .ws-dock .kbn-ctl-foot .kbn-ctl-btn { font-size: var(--ws-label-size); } .ws-dock :is(.kbn-ctl-menu-item, .kbn-detail-parent-option, .kbn-ctl-session-link, .kbn-ctl-session-alt, .kbn-ctl-session-more) { min-height: var(--ws-phone-target); box-sizing: border-box; } .ws-dock .kbn-ctl-image { width: 80px; height: 80px; } @@ -308,5 +309,12 @@ .ws-dock .kbn-ctl-verdict .kbn-ctl-btn, .ws-dock .kbn-ctl-verdict-menu summary, .ws-dock .kbn-ctl-microphone .kbn-ctl-meet-btn { min-height: 44px; min-width: 44px; } /* Two verbs take their own row, so the resting composer keeps its prompt legible. */ .ws-dock .kbn-ctl-composer:has(.kbn-ctl-secondary) .kbn-ctl-composer-foot { grid-column: 1 / -1 !important; grid-row: auto !important; } + /* The top bar's conversation dot is the phone's worker control. */ + .ws-dock-body > .ws-worker-pill { display: none; } + /* The field is underlined beside the microphone; its verbs sit beneath, unruled. */ + .ws-dock .kbn-ctl-composer { border-bottom: 0; row-gap: 0; } + .ws-dock .kbn-detail-directive { min-height: var(--ws-phone-target); padding-block: 10px; border-bottom: var(--ws-line-width) solid var(--ws-hairline); } + .ws-dock .kbn-ctl-composer:focus-within .kbn-detail-directive { border-bottom-color: var(--ws-ink); } + .ws-dock .kbn-ctl-composer .kbn-ctl-composer-foot { justify-content: flex-end; padding: 8px 0 0; } } diff --git a/ui/src/board/workspace/reader.css b/ui/src/board/workspace/reader.css index f42bb798..412bee0d 100644 --- a/ui/src/board/workspace/reader.css +++ b/ui/src/board/workspace/reader.css @@ -78,6 +78,8 @@ .ws-turn-active .ws-worker-dot { animation: ws-worker-breathe 2.4s ease-in-out infinite; } @keyframes ws-worker-breathe { 0%, 100% { opacity: 1; } 50% { opacity: .5; } } @media (prefers-reduced-motion: reduce) { .ws-worker-dot { animation: none !important; } } +/* The phone top bar's conversation dot; the desktop navbar carries no worker state. */ +.ws-topbar-worker { display: none; } .ws-nav-verdicts { flex: none; } .ws-nav-verdicts[hidden] { display: none; } .ws-review-plate, .ws-review-plate .kbn-ctl-verdict { display: flex; align-items: center; gap: 4px; } @@ -254,7 +256,7 @@ .ws-document-status { position: absolute; top: var(--ws-inset); right: var(--ws-inset); background: var(--ws-paper); color: var(--ws-owed); border: var(--ws-line-width) solid var(--ws-owed); border-radius: var(--ws-control-radius); padding: var(--ws-small-gap); font: var(--ws-small-size)/1.4 var(--ws-mono); letter-spacing: var(--ws-mono-tracking); z-index: 2; } @media (max-width: 700px), (max-height: 500px) and (pointer: coarse) { - .ws-reader { --ws-stage-inset: 0px; --ws-prose-padding: 24px 16px 48px; --ws-phone-bar-height: calc(var(--ws-phone-nav-height) + env(safe-area-inset-top)); } + .ws-reader { --ws-stage-inset: 0px; --ws-prose-padding: 24px 16px 48px; --ws-lede-size: 20px; --ws-phone-bar-height: calc(var(--ws-phone-nav-height) + env(safe-area-inset-top)); } .ws-veil { background: var(--ws-ground); -webkit-backdrop-filter: none; backdrop-filter: none; } .ws-navbar { grid-template-columns: minmax(0, 1fr) var(--ws-phone-target); gap: 0; min-height: calc(var(--ws-phone-nav-height) + env(safe-area-inset-top)); padding: env(safe-area-inset-top) var(--ws-small-gap) 0; background: var(--ws-paper); align-items: center; transition: transform 180ms var(--ws-ease), margin-top 180ms var(--ws-ease); } .ws-topbar-hidden .ws-navbar { transform: translateY(-100%); margin-top: calc(-1 * var(--ws-phone-bar-height)); } @@ -273,6 +275,17 @@ .ws-return::before { content: '‹'; position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); font-size: var(--ws-heading-size); text-indent: 0; } .ws-channel-title { text-align: center; flex: 1; height: 44px; font-size: var(--ws-label-size); } .ws-channel-title::after { display: none; } + .ws-topbar-worker:not([hidden]) { display: flex; align-items: center; justify-content: center; } + .ws-reader .ws-topbar-worker .ws-worker-control { + display: grid; place-items: center; width: var(--ws-phone-target); height: var(--ws-phone-target); padding: 0; + border: 0; border-radius: 50%; background: transparent; animation: none; + } + .ws-topbar-worker :is(.ws-worker-state, .ws-worker-elapsed) { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } + /* A dot is a mark, not text: it wears the undiluted pigment. */ + .ws-topbar-worker .ws-worker-dot { width: 10px; height: 10px; background: var(--kbn-agent); box-shadow: 0 0 0 4px var(--ws-machine-halo); } + .ws-topbar-worker [data-worker-state='waiting'] .ws-worker-dot, + .ws-topbar-worker [data-worker-state='attention'] .ws-worker-dot, + .ws-topbar-worker [data-worker-state='blocked'] .ws-worker-dot { background: var(--ws-fresh); box-shadow: 0 0 0 4px color-mix(in srgb, var(--ws-fresh) 22%, transparent); } .ws-tabs { grid-row: 2; grid-column: 1 / -1; padding: 0 var(--ws-small-gap); border-top: var(--ws-line-width) solid var(--ws-hairline); } .ws-tab { height: var(--ws-phone-target); } .ws-thumbbar { display: grid; grid-template-columns: var(--ws-phone-target) minmax(0, 1fr) var(--ws-phone-target) var(--ws-phone-target); align-items: center; padding: 0 var(--ws-small-gap) env(safe-area-inset-bottom); height: calc(var(--ws-thumb-height) + env(safe-area-inset-bottom)); flex: none; border-top: var(--ws-line-width) solid var(--ws-hairline); background: var(--ws-paper); touch-action: pan-y; user-select: none; } From 99a096b2a81f4f2bcf3bc0dedc2cade11437904e Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Mon, 5 Oct 2026 16:06:49 +0200 Subject: [PATCH 13/24] Give the worker control and the page count a full gap Co-Authored-By: Claude Opus 5.5 --- ui/src/board/workspace/reader.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/board/workspace/reader.css b/ui/src/board/workspace/reader.css index 35faa9e3..b68448b3 100644 --- a/ui/src/board/workspace/reader.css +++ b/ui/src/board/workspace/reader.css @@ -65,7 +65,7 @@ .ws-sidebar-toggle:hover, .ws-sidebar-toggle[aria-expanded='true'] { color: var(--ws-ink) !important; } .ws-sidebar-toggle:hover { background: var(--ws-hover); } /* The right end: the worker control, then the page count. */ -.ws-nav-trail { justify-self: end; display: flex; align-items: baseline; gap: var(--ws-head-gap); min-width: 0; } +.ws-nav-trail { justify-self: end; display: flex; align-items: baseline; gap: var(--ws-gap); min-width: 0; } .ws-head-worker { flex: none; display: flex; align-items: baseline; } .ws-head-worker[hidden] { display: none; } /* The card's own pill, bare: a pigmented dot, the state in the serif, the time in mono. */ From 6e5966222efdf802c6c0adaed9306b51b9facf0a Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Mon, 5 Oct 2026 16:10:09 +0200 Subject: [PATCH 14/24] Cover the head's worker control in the dock and workspace integration tests Co-Authored-By: Claude Opus 5.5 --- ui/src/board/dockWorkerPill.integration.test.ts | 13 +++++++++++-- .../board/workspace/Workspace.integration.test.ts | 8 ++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/ui/src/board/dockWorkerPill.integration.test.ts b/ui/src/board/dockWorkerPill.integration.test.ts index cc1a9461..b770b1db 100644 --- a/ui/src/board/dockWorkerPill.integration.test.ts +++ b/ui/src/board/dockWorkerPill.integration.test.ts @@ -36,10 +36,19 @@ 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') + const pill = band.querySelector('.ws-worker-pill[data-part="act"] .kbn-card-worker') + // The head draws the same control, to the same destination, whenever the act zone does. + const head = reader.el.querySelector('.ws-navbar .ws-head-worker .kbn-card-worker') + expect(reader.el.querySelectorAll('.ws-navbar .kbn-card-worker').length).toBeLessThanOrEqual(1) + expect(!!head).toBe(!!pill) + if (head && pill) { + expect(head.dataset.workerState).toBe(pill.dataset.workerState) + expect(head.getAttribute('aria-label')).toBe(pill.getAttribute('aria-label')) + expect(head.getAttribute('href')).toBe(pill.getAttribute('href')) + } + return pill } describe('the fiber page act zone owns the only worker control', () => { it('opens the owner terminal without repeating agent or worker state on the fiber', () => { diff --git a/ui/src/board/workspace/Workspace.integration.test.ts b/ui/src/board/workspace/Workspace.integration.test.ts index 0a6a109c..8afc0dda 100644 --- a/ui/src/board/workspace/Workspace.integration.test.ts +++ b/ui/src/board/workspace/Workspace.integration.test.ts @@ -470,7 +470,8 @@ describe('workspace reader integration', () => { workspace.update() expect(band.querySelector('textarea')).toBe(draft) expect(draft.value).toBe('Keep this draft') - expect(document.querySelectorAll('.kbn-card-worker:not(.ws-sidebar *)')).toHaveLength(1) + expect(document.querySelectorAll('.kbn-card-worker:not(.ws-sidebar *):not(.ws-navbar *)')).toHaveLength(1) + expect(document.querySelector('.ws-navbar .ws-head-worker .kbn-card-worker')?.textContent).toMatch(/^aloft/) expect(band.querySelector('.ws-worker-pill .kbn-card-worker')?.textContent).toBe('aloft') const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false) const button = (name: string): HTMLButtonElement => [...band.querySelectorAll('button')].find(b => b.textContent === name)! @@ -567,7 +568,10 @@ describe('workspace reader integration', () => { }) workspace.open(live) await flush() - expect(document.querySelector('.ws-navbar .kbn-card-worker')).toBeNull() + const head = document.querySelector('.ws-navbar .ws-head-worker button.kbn-card-worker')! + head.click() + expect(openWorker).toHaveBeenLastCalledWith('terminal-alpha', 'daemon-a') + openWorker.mockClear() const pill = document.querySelector('.ws-dock .ws-worker-pill button.kbn-card-worker')! expect(pill).not.toBeNull() expect(document.querySelector('.ws-dock-slot')).toBeNull() From 4ec3c5519996f86f507b365b0a824a592bbfd42e Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Mon, 5 Oct 2026 16:15:53 +0200 Subject: [PATCH 15/24] Let a phone audio page taller than the screen scroll from its top The audio scroller centred its page as flex content, so a page taller than the screen overflowed above the scroller's top, out of reach. The scroller is a block and the page centres with auto margins, which yield to the top edge. A vertical pan begun on the waveform scrolls the page; a sideways drag still scrubs. Co-Authored-By: Claude Opus 5.5 --- ui/e2e/workspace.mjs | 16 ++++++++++++++++ ui/src/board/workspace/audio.css | 8 ++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/ui/e2e/workspace.mjs b/ui/e2e/workspace.mjs index d1ea6243..75f853e9 100644 --- a/ui/e2e/workspace.mjs +++ b/ui/e2e/workspace.mjs @@ -1320,6 +1320,22 @@ test('Phone overview single column, reader sheet, footer stepping and Back', asy assert.ok(await p.getByRole('searchbox', { name: 'Find work or files', exact: true }).isVisible()) }, { width: 390, height: 844 }) +test('Phone audio page taller than the screen scrolls from its top to its bottom', async p => { + await open(p); await choose(p, 'tone.mp3') + const scroller = selected(p).locator('.kbn-fileview-audio') + await selected(p).locator('.ws-audio-page').waitFor() + const reach = () => scroller.evaluate(el => { + const page = el.querySelector('.ws-audio-page').getBoundingClientRect(), box = el.getBoundingClientRect() + return { overflow: el.scrollHeight - el.clientHeight, top: page.top - box.top, bottom: box.bottom - page.bottom } + }) + const start = await reach() + assert.ok(start.overflow > 0, `the fixture outgrows the short screen (${start.overflow}px)`) + assert.ok(Math.abs(start.top) <= 0.5, `the waveform starts in reach at the top (${start.top}px)`) + await scroller.evaluate(el => { el.scrollTop = el.scrollHeight }) + assert.ok((await reach()).bottom >= -0.5, 'the page\'s end scrolls into view') + assert.equal(await selected(p).locator('.ws-audio-waveform').evaluate(el => getComputedStyle(el).touchAction), 'pan-y', 'a vertical pan on the waveform scrolls the page') +}, { width: 390, height: 420 }, 'false', 'reduce', true) + test('Phone HTML reader retains its opaque frame and reading position', async p => { await open(p); const frame = await reportReady(p) const inner = await reportDocument(p) diff --git a/ui/src/board/workspace/audio.css b/ui/src/board/workspace/audio.css index d8475548..79b98801 100644 --- a/ui/src/board/workspace/audio.css +++ b/ui/src/board/workspace/audio.css @@ -1,4 +1,7 @@ -.ws-content .kbn-fileview-audio { background: var(--ws-paper); overflow: auto; padding: 0; } +/* The scroller is a block: centred flex content taller than the page would + overflow above its top, out of reach. The inner page centres itself with + auto margins, which yield to the top edge once it outgrows the page. */ +.ws-content .kbn-fileview-audio { display: block; background: var(--ws-paper); overflow: auto; padding: 0; } .ws-content .kbn-fileview-audio audio[hidden] { display: none !important; } .ws-content .kbn-fileview-audio .kbn-media-page { width: 100%; max-width: none; min-height: 100%; padding: 0; @@ -13,7 +16,8 @@ padding: var(--ws-prose-padding); margin-block: auto; } .ws-audio-plot { position: relative; margin: 0 0 16px; } -.ws-audio-waveform { display: block; width: 100%; height: 160px; cursor: crosshair; touch-action: none; color: var(--ws-ink); } +/* A vertical pan begun on the waveform scrolls the page; a sideways drag scrubs. */ +.ws-audio-waveform { display: block; width: 100%; height: 160px; cursor: crosshair; touch-action: pan-y; color: var(--ws-ink); } .ws-audio-waveform:focus-visible { outline: 1px dashed var(--ws-focus); outline-offset: 4px; } .ws-audio-hover { position: absolute; bottom: 0; transform: translateX(-50%); pointer-events: none; padding: 2px 5px; background: var(--ws-paper); font: var(--ws-small-size)/1.2 var(--ws-mono); } .ws-audio-transport { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; padding-bottom: 20px; border-bottom: 1px solid var(--ws-hairline); } From d63ea3da323f1a596a7cfe1e7f7323ecc5608500 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Mon, 5 Oct 2026 16:17:34 +0200 Subject: [PATCH 16/24] Remember declared titles across visits The 300 most recently used declared titles persist in localStorage with the validator they were read under, so a return visit names its tabs at once. A recalled title is not current: the probe still peeks the document and replaces it only when the title or preview changed. Every storage access is optional. Co-Authored-By: Claude Opus 5.5 --- .../workspace/DocumentTitles.persist.test.ts | 52 ++++++++++++++++++ ui/src/board/workspace/DocumentTitles.ts | 54 +++++++++++++++++-- ui/src/board/workspace/titleProbe.ts | 10 ++-- 3 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 ui/src/board/workspace/DocumentTitles.persist.test.ts diff --git a/ui/src/board/workspace/DocumentTitles.persist.test.ts b/ui/src/board/workspace/DocumentTitles.persist.test.ts new file mode 100644 index 00000000..98c9203e --- /dev/null +++ b/ui/src/board/workspace/DocumentTitles.persist.test.ts @@ -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() +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', 'The report', 'etag-1') + titles.cacheDocumentTitle('host:/r/untitled.html', '/r/untitled.html', '

no title

', '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', 'The report', 'etag-1') + expect(titles.titleIsCurrent('host:/r/report.html')).toBe(true) + expect(heard).not.toHaveBeenCalled() + titles.cacheDocumentTitle('host:/r/report.html', '/r/report.html', 'The revised report', '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') + }) +}) diff --git a/ui/src/board/workspace/DocumentTitles.ts b/ui/src/board/workspace/DocumentTitles.ts index 39426097..7340f5c8 100644 --- a/ui/src/board/workspace/DocumentTitles.ts +++ b/ui/src/board/workspace/DocumentTitles.ts @@ -2,7 +2,53 @@ export interface DocumentTitle { title?: string; preview: string; etag?: string const titles = new Map() const versions = new Map() 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() + +/** + * 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 | 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) @@ -74,7 +120,9 @@ 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.add(key) + touch(key, next) + save() + if (held !== next && (held?.title !== next.title || held?.preview !== next.preview)) for (const listener of listeners) listener(key) return next } diff --git a/ui/src/board/workspace/titleProbe.ts b/ui/src/board/workspace/titleProbe.ts index e7200534..37ba2ede 100644 --- a/ui/src/board/workspace/titleProbe.ts +++ b/ui/src/board/workspace/titleProbe.ts @@ -1,6 +1,6 @@ import { readThumbnailMetadata } from '../FileViewerPanel.js' import { fileBytesUrl } from '../utils.js' -import { cacheDocumentTitle, declaredTitle } from './DocumentTitles.js' +import { cacheDocumentTitle, titleIsCurrent } from './DocumentTitles.js' import type { WorkspaceDocument } from './documents.js' /** @@ -26,19 +26,21 @@ function pump(): void { /** * The index names pages by their declared titles, so it reads each titled - * document's first 64 KiB once per session, independent of any thumbnail, + * document's first 64 KiB once per session, independent of any thumbnail + * (revalidating any title recalled from an earlier visit), * ahead of the stage's images and frames competing for the same connections. */ export function probeDocumentTitles(shuttleBase: string, documents: WorkspaceDocument[]): void { for (const doc of documents) { const rank = TITLED[doc.kind] - if (rank === undefined || probed.has(doc.key) || declaredTitle(doc.key)) continue + // A title recalled from an earlier visit still names the tab; the peek revalidates it. + if (rank === undefined || probed.has(doc.key) || titleIsCurrent(doc.key)) continue probed.add(doc.key) queue.push({ rank, run: async () => { let read = false await readThumbnailMetadata(fileBytesUrl(shuttleBase, doc.path, doc.owner), new AbortController().signal, (source, etag) => { read = true - if (declaredTitle(doc.key)) return + if (titleIsCurrent(doc.key)) return const text = doc.kind === 'html' || doc.kind === 'text' cacheDocumentTitle(doc.key, doc.path, text && typeof source !== 'string' ? new TextDecoder().decode(source) : source, etag) }, 'high') From 5792aac5d1f08bdba0cf6f633963cd12b307485e Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Mon, 5 Oct 2026 16:23:57 +0200 Subject: [PATCH 17/24] Tighten the Desk's phone verdict buttons to a 28px plate with a 44px touch target Co-Authored-By: Claude Opus 5.5 --- ui/src/board/KanbanModal.css | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/ui/src/board/KanbanModal.css b/ui/src/board/KanbanModal.css index 29ae178e..bf7632ce 100644 --- a/ui/src/board/KanbanModal.css +++ b/ui/src/board/KanbanModal.css @@ -2234,10 +2234,17 @@ opacity: 1; pointer-events: auto; } + /* A compact 28px plate; the 44px touch target reaches past it vertically. */ .kbn-card-review-meta-actions .kbn-review-meta-btn { - height: 44px; + position: relative; + height: 28px; min-width: 44px; - padding: 8px; + padding: 0 10px; + } + .kbn-card-review-meta-actions .kbn-review-meta-btn::after { + content: ''; + position: absolute; + inset: -8px 0; } } @media (prefers-reduced-motion: reduce) { From 6bec01ab6cdafb563e05f155e230597c8481bc3f Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Mon, 5 Oct 2026 16:24:11 +0200 Subject: [PATCH 18/24] Withdraw the phone top bar's conversation dot The head lane owns the reader's worker control on desktop and phone. Co-Authored-By: Claude Opus 5.5 --- ui/src/board/workspace/Reader.ts | 24 ------------------------ ui/src/board/workspace/reader.css | 13 ------------- 2 files changed, 37 deletions(-) diff --git a/ui/src/board/workspace/Reader.ts b/ui/src/board/workspace/Reader.ts index 7dd8cf21..ea3ff2af 100644 --- a/ui/src/board/workspace/Reader.ts +++ b/ui/src/board/workspace/Reader.ts @@ -95,9 +95,6 @@ export class Reader { /** The awaiting-review verdicts, beside the fiber's name, reachable from any page. */ private readonly verdicts = element('span', 'ws-nav-verdicts') private verdictKey: string | null = null - /** The phone top bar's conversation dot: the worker pill's target, drawn as its state's pigment. */ - private readonly conversation = element('span', 'ws-topbar-worker') - private conversationClock = 0 private readonly position = element('span', 'ws-position') private readonly pageTitle = element('span', 'ws-thumb-title') private readonly arrivalSummary = element('span', 'ws-thumb-arrival') @@ -165,7 +162,6 @@ export class Reader { this.lead.dataset.part = 'chrome-plate' this.lead.append(this.returnButton, this.sidebarToggle, this.title, this.verdicts) this.trail = element('div', 'ws-nav-trail') - this.trail.append(this.conversation) this.navbar = element('nav', 'ws-navbar') this.navbar.dataset.part = 'phone-topbar' const tabPlate = element('div', 'ws-nav-tabs') @@ -238,7 +234,6 @@ export class Reader { document.addEventListener('pointerdown', this.pointerInput, true) this.motion.addEventListener('change', this.relayout) this.phone.addEventListener('change', this.relayout) - this.phone.addEventListener('change', this.phoneChanged) this.wide.addEventListener('change', this.relayout) this.el.addEventListener('workspace-theme-change', this.themeChanged) this.el.addEventListener('mousedown', e => { @@ -275,7 +270,6 @@ export class Reader { this.title.textContent = channel.name this.title.title = channel.name this.paintVerdicts() - this.paintConversation() this.tabs.setVisible(true) this.tabs.render(channel.labels, channel.documents.map(d => d.key), channel) if (!switching) this.tabs.arrive(arrivals) @@ -423,21 +417,6 @@ export class Reader { } this.verdicts.hidden = !key || !this.verdicts.firstChild || this.document?.kind === 'fiber' } - private paintConversation(): void { - const card = this.currentCard - const focused = this.conversation.contains(document.activeElement) - // Only the phone draws it; the desktop's worker control is the act zone's pill. - const pill = card && this.phone.matches ? this.opts.workerPill?.(card) ?? null : null - if (card && pill) { - pill.dataset.part = 'act'; pill.dataset.act = 'worker' - workerPlate(card, pill) - } - this.conversation.replaceChildren(...(pill ? [pill] : [])) - this.conversation.hidden = !pill - if (focused) pill?.focus({ preventScroll: true }) - window.clearTimeout(this.conversationClock) - if (pill) this.conversationClock = window.setTimeout(() => { if (this.active) this.paintConversation() }, 30000) - } private paint(animate: boolean): void { this.paintVerdicts() const ch = this.channel @@ -600,7 +579,6 @@ export class Reader { frame.el.style.setProperty('--ws-neighbour-fade-end', `${end}%`) } } - private readonly phoneChanged = (): void => { if (this.active) this.paintConversation() } private readonly relayout = (): void => { if (!this.phone.matches) { this.pageSheet.close(); this.el.classList.remove('ws-topbar-hidden') } this.renderSidebar() @@ -926,7 +904,6 @@ export class Reader { window.removeEventListener('resize', this.relayout) cancelAnimationFrame(this.instantRaf) cancelAnimationFrame(this.arrival) - window.clearTimeout(this.conversationClock) if (this.departure !== null) clearTimeout(this.departure) this.wide.removeEventListener('change', this.relayout) this.stopTitles() @@ -942,7 +919,6 @@ export class Reader { document.removeEventListener('pointerdown', this.pointerInput, true) this.motion.removeEventListener('change', this.relayout) this.phone.removeEventListener('change', this.relayout) - this.phone.removeEventListener('change', this.phoneChanged) this.el.removeEventListener('workspace-theme-change', this.themeChanged) this.el.remove() } diff --git a/ui/src/board/workspace/reader.css b/ui/src/board/workspace/reader.css index 412bee0d..0bc9227e 100644 --- a/ui/src/board/workspace/reader.css +++ b/ui/src/board/workspace/reader.css @@ -78,8 +78,6 @@ .ws-turn-active .ws-worker-dot { animation: ws-worker-breathe 2.4s ease-in-out infinite; } @keyframes ws-worker-breathe { 0%, 100% { opacity: 1; } 50% { opacity: .5; } } @media (prefers-reduced-motion: reduce) { .ws-worker-dot { animation: none !important; } } -/* The phone top bar's conversation dot; the desktop navbar carries no worker state. */ -.ws-topbar-worker { display: none; } .ws-nav-verdicts { flex: none; } .ws-nav-verdicts[hidden] { display: none; } .ws-review-plate, .ws-review-plate .kbn-ctl-verdict { display: flex; align-items: center; gap: 4px; } @@ -275,17 +273,6 @@ .ws-return::before { content: '‹'; position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); font-size: var(--ws-heading-size); text-indent: 0; } .ws-channel-title { text-align: center; flex: 1; height: 44px; font-size: var(--ws-label-size); } .ws-channel-title::after { display: none; } - .ws-topbar-worker:not([hidden]) { display: flex; align-items: center; justify-content: center; } - .ws-reader .ws-topbar-worker .ws-worker-control { - display: grid; place-items: center; width: var(--ws-phone-target); height: var(--ws-phone-target); padding: 0; - border: 0; border-radius: 50%; background: transparent; animation: none; - } - .ws-topbar-worker :is(.ws-worker-state, .ws-worker-elapsed) { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } - /* A dot is a mark, not text: it wears the undiluted pigment. */ - .ws-topbar-worker .ws-worker-dot { width: 10px; height: 10px; background: var(--kbn-agent); box-shadow: 0 0 0 4px var(--ws-machine-halo); } - .ws-topbar-worker [data-worker-state='waiting'] .ws-worker-dot, - .ws-topbar-worker [data-worker-state='attention'] .ws-worker-dot, - .ws-topbar-worker [data-worker-state='blocked'] .ws-worker-dot { background: var(--ws-fresh); box-shadow: 0 0 0 4px color-mix(in srgb, var(--ws-fresh) 22%, transparent); } .ws-tabs { grid-row: 2; grid-column: 1 / -1; padding: 0 var(--ws-small-gap); border-top: var(--ws-line-width) solid var(--ws-hairline); } .ws-tab { height: var(--ws-phone-target); } .ws-thumbbar { display: grid; grid-template-columns: var(--ws-phone-target) minmax(0, 1fr) var(--ws-phone-target) var(--ws-phone-target); align-items: center; padding: 0 var(--ws-small-gap) env(safe-area-inset-bottom); height: calc(var(--ws-thumb-height) + env(safe-area-inset-bottom)); flex: none; border-top: var(--ws-line-width) solid var(--ws-hairline); background: var(--ws-paper); touch-action: pan-y; user-select: none; } From f8da8f6d89fb93b41daf1656aa93167a077d7c1b Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Mon, 5 Oct 2026 16:24:11 +0200 Subject: [PATCH 19/24] Draw no worker pill on the fiber page; the reader head carries it The act zone's pill goes on every viewport, so the worker control is said once. c still opens the worker's conversation through the same destination, and the Desk and sidebar cards keep their pills. Co-Authored-By: Claude Opus 5.5 --- ui/e2e/workspace.mjs | 51 ++++--------------- ui/src/board/appConversation.ts | 5 +- .../board/dockWorkerPill.integration.test.ts | 9 ++-- ui/src/board/workspace/Dock.ts | 20 -------- .../workspace/Workspace.integration.test.ts | 10 ++-- ui/src/board/workspace/dock.css | 8 --- 6 files changed, 21 insertions(+), 82 deletions(-) diff --git a/ui/e2e/workspace.mjs b/ui/e2e/workspace.mjs index 75f853e9..ee2a88f6 100644 --- a/ui/e2e/workspace.mjs +++ b/ui/e2e/workspace.mjs @@ -1275,9 +1275,10 @@ test('Native PDF renderer loads fixture; owner route and first-page preview', as await poll(p, () => [...document.querySelectorAll('.ws-overview iframe')].some(f => f.src.includes('#page=1'))) }) -test('Remote worker pill records attach handler without launching a terminal', async p => { +test('Remote worker conversation records attach handler without launching a terminal', async p => { await p.locator('.kbn-desk .kbn-card').filter({ hasText: 'Remote covariance review' }).click() - await p.locator('.ws-selected .ws-dock .ws-worker-pill .kbn-card-worker').click() + await choose(p, 'Constitution') + await p.keyboard.press('c') await poll(p, () => window.__harness.events.some(e => e.type === 'open-worker') || window.__harness.handlers.some(h => h.path === '/api/v1/attach')) const event = await p.evaluate(() => window.__harness.events.find(e => e.type === 'open-worker')) assert.equal(event.host, 'basalt-login-02') @@ -1648,8 +1649,7 @@ for (const [device, viewport] of [['desktop', { width: 1440, height: 900 }], ['p await p.screenshot({ path: resolve(process.env.WORKSPACE_SHOTS, `verdict-${device}-${state}.png`) }) } await open(p) - assert.equal(await p.locator('.ws-nav-lead .kbn-card-worker, .ws-navbar .ws-worker-pill').count(), 0, 'the navbar carries no worker plate') - assert.equal(await p.locator('.ws-topbar-worker').isVisible(), false, 'a fiber without a worker shows no conversation dot') + assert.equal(await p.locator('.ws-navbar .kbn-card-worker, .ws-navbar .ws-worker-pill').count(), 0, 'the navbar carries no worker plate') if (device === 'phone') { assert.ok(!await p.locator('.ws-nav-verdicts').isVisible(), 'the phone top bar stays the name alone') await p.locator('.ws-page-choice').click() @@ -1683,53 +1683,26 @@ for (const [device, viewport] of [['desktop', { width: 1440, height: 900 }], ['p await p.locator('.ws-return').click() await chooseDeskColumn(p, 1) await p.locator('.kbn-desk .kbn-card').filter({ hasText: 'Remote covariance review' }).click() - // Opening the reader is navigation, not a state change: wait for it before the short poll. - // The phone's worker control is the top bar's conversation dot; the - // desktop's is the act zone's pill. - const control = device === 'phone' ? '.ws-navbar .ws-topbar-worker' : '.ws-selected .ws-dock .ws-worker-pill' - await p.locator(`${control} .ws-worker-control`).waitFor() - await poll(p, selector => document.querySelector(`${selector} .ws-worker-control`)?.textContent.includes('12 m'), control) - const dot = p.locator(`${control} .ws-worker-dot`) - assert.ok(await dot.isVisible(), 'the worker control shows its dot') - assert.equal(await dot.evaluate(el => getComputedStyle(el).animationName), 'none', 'reduced motion suppresses breathing') - assert.ok(await p.locator(`${control} .ws-turn-active`).count()) - if (device === 'phone') { - assert.equal(await p.locator('.ws-selected .ws-dock .ws-worker-pill').isVisible(), false, 'the fiber page draws no second worker control') - const target = await p.locator(`${control} .ws-worker-control`).boundingBox() - const bar = await p.locator('.ws-navbar').boundingBox() - assert.ok(target.width >= 44 && target.height >= 44, 'the dot is a 44 px target') - assert.ok(target.x + target.width >= bar.x + bar.width - 12, 'the dot sits at the top bar\'s right') - assert.equal(await dot.evaluate(el => getComputedStyle(el).backgroundColor), 'rgb(61, 91, 160)', 'a working machine is cobalt') - } + await choose(p, 'Constitution') + assert.equal(await selected(p).locator('.kbn-card-worker, [data-act="worker"]').count(), 0, 'the fiber page draws no worker control; the reader head carries it') await shot('aloft') - await p.evaluate(async () => { - const row = window.__harness.MOCK_FEED.fibers.find(row => row.fiber.name === 'Remote covariance review') - row.runtime.phase = 'waiting' - row.runtime.last_activity_at = Date.now() - 120000 - await window.__harness.modal.fetchAndRender() - }) - await poll(p, selector => document.querySelector(`${selector} .ws-worker-control`)?.dataset.workerState === 'waiting', control) - assert.equal(await p.locator(`${control} .ws-turn-active`).count(), 0) - if (device === 'phone') assert.equal(await dot.evaluate(el => getComputedStyle(el).backgroundColor), 'rgb(196, 147, 51)', 'a waiting worker is gold') - await shot('waiting') await p.locator('.ws-return').click() await chooseDeskColumn(p, 0) await p.locator('.kbn-desk .kbn-card').filter({ hasText: 'Weekly shear summary' }).click() - await poll(p, selector => { const pill = document.querySelector(selector); return pill?.hidden === true && getComputedStyle(pill).display === 'none' }, control) await shot('no-worker') }, viewport) } test('Only the owner-reported working phase breathes, on a 2.4 s opacity cycle', async p => { await p.locator('.kbn-desk .kbn-card').filter({ hasText: 'Remote covariance review' }).click() - const dot = p.locator('.ws-selected .ws-dock .ws-worker-pill .ws-worker-dot') + const dot = p.locator('.ws-sidebar [aria-current="true"] .ws-worker-dot') await dot.waitFor() const timing = await p.evaluate(() => { - const css = getComputedStyle(document.querySelector('.ws-selected .ws-dock .ws-worker-pill .ws-worker-dot')) + const css = getComputedStyle(document.querySelector('.ws-sidebar [aria-current="true"] .ws-worker-dot')) return { name: css.animationName, duration: css.animationDuration, easing: css.animationTimingFunction } }) assert.deepEqual(timing, { name: 'ws-worker-breathe', duration: '2.4s', easing: 'ease-in-out' }) -}, undefined, 'false', 'no-preference') +}, undefined, 'true', 'no-preference') // Say-it-once checks cover the selected page and its chrome. Tabs and the // constitution switcher repeat names as navigation actions. The desktop fiber @@ -1821,9 +1794,7 @@ for (const [device, viewport] of [['desktop', { width: 1440, height: 900 }], ['p } await p.locator('.kbn-desk .kbn-card').filter({ hasText: 'Remote covariance review' }).click() await choose(p, 'Constitution') - const pill = selected(p).locator('.ws-dock .ws-worker-pill') - assert.match((await pill.textContent()).trim(), /^aloft\s*12 m$/i, 'the act zone pill names state and elapsed time, not agent') - assert.equal(await selected(p).locator('.kbn-card-worker').count(), 1, 'one worker control on the fiber page') + assert.equal(await selected(p).locator('.kbn-card-worker').count(), 0, 'the fiber page draws no worker control') const cadence = selected(p).locator('.kbn-detail-controls-toggle .kbn-ctl-cadence') assert.equal(await cadence.count(), 1) assert.ok((await cadence.innerText()).trim(), 'standing cadence lives in the settings line') @@ -1845,7 +1816,6 @@ for (const [device, viewport] of [['desktop', { width: 1440, height: 900 }], ['p const texts = selector => [...document.querySelectorAll(selector)].map(e => e.textContent.trim()) return { header: texts('.ws-selected .ws-prose-header'), - band: texts('.ws-selected .ws-dock .ws-worker-pill'), settings: texts('.ws-selected .kbn-detail-controls-toggle'), navbar: texts('.ws-nav-verdicts'), title: texts('.ws-selected .ws-label-title'), @@ -2024,7 +1994,6 @@ test('Act zone stops broad button rules and resets theme fonts, sizes and pigmen assert.notEqual(style.pigment, 'red', 'theme aliases cannot change a control pigment') assert.ok(!style.mono.includes('fantasy'), 'theme aliases cannot change a control font') assert.equal(await selected(p).locator('.ws-dock').getAttribute('data-part'), 'act') - assert.equal(await selected(p).locator('.ws-dock .ws-worker-pill').getAttribute('data-part'), 'act') assert.equal(await p.locator('.ws-nav-verdicts').getAttribute('data-part'), 'act') }, { width: 1379, height: 900 }) diff --git a/ui/src/board/appConversation.ts b/ui/src/board/appConversation.ts index c81d07e1..68fa666e 100644 --- a/ui/src/board/appConversation.ts +++ b/ui/src/board/appConversation.ts @@ -73,7 +73,7 @@ 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. @@ -81,8 +81,7 @@ export function appWorkerLink(card: KanbanCard, classes = ''): HTMLAnchorElement * 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". diff --git a/ui/src/board/dockWorkerPill.integration.test.ts b/ui/src/board/dockWorkerPill.integration.test.ts index cc1a9461..654d6eb8 100644 --- a/ui/src/board/dockWorkerPill.integration.test.ts +++ b/ui/src/board/dockWorkerPill.integration.test.ts @@ -4,6 +4,7 @@ import type { KanbanCard } from './KanbanTypes.js' import { card } from './testFixtures.js' import { Dock } from './workspace/Dock.js' import { Reader } from './workspace/Reader.js' +import { workerPlate } from './workspace/workerPlate.js' import type { Channel } from './workspace/documents.js' import { saveClaudeOpening } from './conversationOpening.js' @@ -37,11 +38,11 @@ afterEach(() => { dock.reset(); reader.dispose(); vi.restoreAllMocks(); vi.unstu 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() + const pill = dock.workerPillFor(card) + return pill && workerPlate(card, pill) } -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') diff --git a/ui/src/board/workspace/Dock.ts b/ui/src/board/workspace/Dock.ts index 227c177b..40244d48 100644 --- a/ui/src/board/workspace/Dock.ts +++ b/ui/src/board/workspace/Dock.ts @@ -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' @@ -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 @@ -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) @@ -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 @@ -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('.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)) diff --git a/ui/src/board/workspace/Workspace.integration.test.ts b/ui/src/board/workspace/Workspace.integration.test.ts index 910428c9..0fc324c0 100644 --- a/ui/src/board/workspace/Workspace.integration.test.ts +++ b/ui/src/board/workspace/Workspace.integration.test.ts @@ -469,8 +469,7 @@ describe('workspace reader integration', () => { workspace.update() expect(band.querySelector('textarea')).toBe(draft) expect(draft.value).toBe('Keep this draft') - expect(document.querySelectorAll('.kbn-card-worker:not(.ws-sidebar *)')).toHaveLength(1) - expect(band.querySelector('.ws-worker-pill .kbn-card-worker')?.textContent).toBe('aloft') + expect(document.querySelectorAll('.kbn-card-worker:not(.ws-sidebar *)')).toHaveLength(0) const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false) const button = (name: string): HTMLButtonElement => [...band.querySelectorAll('button')].find(b => b.textContent === name)! band.querySelector('.kbn-ctl-sends .kbn-ctl-send:not(.kbn-ctl-resume)')!.click() @@ -555,7 +554,7 @@ describe('workspace reader integration', () => { expect(visibility).toHaveBeenLastCalledWith(false) }) - it('opens the real worker conversation from the act zone pill without a panel', async () => { + it('opens the real worker conversation with c, without a panel or a pill on the fiber page', async () => { const live = { ...cards[0], shuttleKind: 'oneshot' as const, shuttleAgent: 'codex-sol', tmuxSession: 'terminal-alpha', shuttleHost: 'daemon-a' } const openWorker = vi.fn() bodyCards = [live] @@ -567,10 +566,9 @@ describe('workspace reader integration', () => { workspace.open(live) await flush() expect(document.querySelector('.ws-navbar .kbn-card-worker')).toBeNull() - const pill = document.querySelector('.ws-dock .ws-worker-pill button.kbn-card-worker')! - expect(pill).not.toBeNull() + expect(document.querySelector('.ws-dock .kbn-card-worker')).toBeNull() expect(document.querySelector('.ws-dock-slot')).toBeNull() - pill.click() + document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'c', bubbles: true })) expect(openWorker).toHaveBeenCalledWith('terminal-alpha', 'daemon-a') expect(workspace.isActive).toBe(true) expect(document.querySelector('.ws-dock-slot')).toBeNull() diff --git a/ui/src/board/workspace/dock.css b/ui/src/board/workspace/dock.css index ec5f11fe..6906523b 100644 --- a/ui/src/board/workspace/dock.css +++ b/ui/src/board/workspace/dock.css @@ -251,12 +251,6 @@ .ws-dock .kbn-ctl-image-remove { width: var(--ws-phone-target); height: var(--ws-phone-target); } } -/* The fiber's worker pill: the sidebar card's own plate, on its own row. */ -.ws-dock-body > .ws-worker-pill { grid-column: 1 / -1; display: flex; justify-content: flex-start; } -.ws-dock-body > .ws-worker-pill[hidden] { display: none; } -.ws-dock .ws-worker-pill .ws-worker-control { padding: 0 10px; border: var(--ws-line-width) solid var(--ws-hairline) !important; border-radius: 999px; background: var(--ws-paper); } -.ws-dock .ws-worker-pill .ws-worker-control:hover { background: var(--ws-hover); } - /* The agent and effort lists, opened as anchored popovers under their select. */ .ws-dock .ws-select-picker { display: flex; flex-direction: column; gap: 1px; box-sizing: border-box; @@ -309,8 +303,6 @@ .ws-dock .kbn-ctl-verdict .kbn-ctl-btn, .ws-dock .kbn-ctl-verdict-menu summary, .ws-dock .kbn-ctl-microphone .kbn-ctl-meet-btn { min-height: 44px; min-width: 44px; } /* Two verbs take their own row, so the resting composer keeps its prompt legible. */ .ws-dock .kbn-ctl-composer:has(.kbn-ctl-secondary) .kbn-ctl-composer-foot { grid-column: 1 / -1 !important; grid-row: auto !important; } - /* The top bar's conversation dot is the phone's worker control. */ - .ws-dock-body > .ws-worker-pill { display: none; } /* The field is underlined beside the microphone; its verbs sit beneath, unruled. */ .ws-dock .kbn-ctl-composer { border-bottom: 0; row-gap: 0; } .ws-dock .kbn-detail-directive { min-height: var(--ws-phone-target); padding-block: 10px; border-bottom: var(--ws-line-width) solid var(--ws-hairline); } From 09ce220359ae13cb2c94146e633ec7b0d37e63b4 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Mon, 5 Oct 2026 16:40:35 +0200 Subject: [PATCH 20/24] Let the Desk's phone verdict buttons reach their 44px touch target past the meta row Co-Authored-By: Claude Opus 5.5 --- ui/src/board/KanbanModal.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/src/board/KanbanModal.css b/ui/src/board/KanbanModal.css index bf7632ce..a53972a3 100644 --- a/ui/src/board/KanbanModal.css +++ b/ui/src/board/KanbanModal.css @@ -2541,9 +2541,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; } From dbd5f36dc40dbdd11d12969953d63ca5c71c63fb Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Mon, 5 Oct 2026 16:45:23 +0200 Subject: [PATCH 21/24] Ring the head's worker dot in its own pigment, and scope it past theme tints Co-Authored-By: Claude Opus 5.5 --- ui/src/board/workspace/reader.css | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/ui/src/board/workspace/reader.css b/ui/src/board/workspace/reader.css index 7c18278d..add0b4fb 100644 --- a/ui/src/board/workspace/reader.css +++ b/ui/src/board/workspace/reader.css @@ -69,13 +69,13 @@ .ws-head-worker { flex: none; display: flex; align-items: baseline; } .ws-head-worker[hidden] { display: none; } /* The card's own pill, bare: a pigmented dot, the state in the serif, the time in mono. */ -.ws-navbar .ws-head-worker .ws-worker-control { +.ws-reader .ws-navbar .ws-head-worker .ws-worker-control { display: inline-flex; align-items: baseline; gap: 6px; height: var(--ws-control-height); margin: 0 calc(-1 * var(--ws-small-gap)); padding: 0 var(--ws-small-gap); border: 0; border-radius: var(--ws-control-radius); background: transparent; box-shadow: none; color: var(--ws-ink-muted); font: var(--ws-label-size)/var(--ws-control-height) var(--ws-serif); letter-spacing: 0; text-transform: none; text-decoration: none; animation: none; } -.ws-navbar .ws-head-worker .ws-worker-control:hover { color: var(--ws-ink); background: var(--ws-hover); } +.ws-reader .ws-navbar .ws-head-worker .ws-worker-control:hover { color: var(--ws-ink); background: var(--ws-hover); } .ws-head-worker .ws-worker-dot { align-self: center; width: 7px; height: 7px; } .ws-head-worker .ws-worker-state { font: inherit; } .ws-head-worker .ws-worker-elapsed { font: var(--ws-small-size)/1 var(--ws-mono); letter-spacing: 0; } @@ -83,11 +83,13 @@ .ws-worker-state { font: var(--ws-label-size)/1.2 var(--ws-serif); } .ws-worker-elapsed { font: var(--ws-small-size)/1.4 var(--ws-mono); } /* A dot is too small for ink-mixed pigments: working is the machine's own cobalt, waiting the undiluted gold. */ -.ws-worker-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ws-machine); flex: none; } +.ws-worker-dot { --ws-dot: var(--ws-machine); width: 8px; height: 8px; border-radius: 50%; background: var(--ws-dot); flex: none; } .ws-worker-control[data-worker-state='waiting'] .ws-worker-dot, .ws-worker-control[data-worker-state='attention'] .ws-worker-dot, -.ws-worker-control[data-worker-state='blocked'] .ws-worker-dot { background: var(--ws-fresh); } -.ws-worker-control[data-worker-state='no worker'] .ws-worker-dot { background: var(--ws-ink-muted); } +.ws-worker-control[data-worker-state='blocked'] .ws-worker-dot { --ws-dot: var(--ws-fresh); } +.ws-worker-control[data-worker-state='no worker'] .ws-worker-dot { --ws-dot: var(--ws-ink-muted); } +/* In the head the dot wears a faint ring of its own pigment, so it reads at dot size on any paper. */ +.ws-reader .ws-head-worker .ws-worker-dot { box-shadow: 0 0 0 3px color-mix(in srgb, var(--ws-dot) 20%, transparent); } .ws-turn-active .ws-worker-dot { animation: ws-worker-breathe 2.4s ease-in-out infinite; } @keyframes ws-worker-breathe { 0%, 100% { opacity: 1; } 50% { opacity: .5; } } @media (prefers-reduced-motion: reduce) { .ws-worker-dot { animation: none !important; } } @@ -278,7 +280,7 @@ .ws-nav-verdicts, .ws-tabs, .ws-head-position { display: none !important; } /* The top bar's right end: the worker's dot alone, in a full target. */ .ws-nav-trail { gap: 0; align-self: center; } - .ws-navbar .ws-head-worker .ws-worker-control { width: var(--ws-phone-target); height: var(--ws-phone-target); margin: 0; padding: 0; justify-content: center; align-items: center; } + .ws-reader .ws-navbar .ws-head-worker .ws-worker-control { width: var(--ws-phone-target); height: var(--ws-phone-target); margin: 0; padding: 0; justify-content: center; align-items: center; } .ws-head-worker :is(.ws-worker-state, .ws-worker-elapsed) { display: none; } .ws-head-worker .ws-worker-dot { width: 9px; height: 9px; } .ws-nav-lead { min-height: var(--ws-phone-nav-height); align-items: center; gap: 0; } From c7660f46a880c5c4f2fc688e3518dd10fd67bbef Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Mon, 5 Oct 2026 16:52:23 +0200 Subject: [PATCH 22/24] Give the Desk's phone verdicts a 44px button with a 28px plate drawn inside The verdict button is now the whole touch target on a transparent ground, with its tinted plate drawn by ::before; negative margins keep the meta row, and the card, at the plate's height. The e2e shows the Awaiting folio first, so it probes a button on screen, and times the index's first hover in the page. Co-Authored-By: Claude Opus 5.5 --- ui/e2e/workspace.mjs | 27 +++++++++++++++++++++------ ui/src/board/KanbanModal.css | 25 ++++++++++++++++++++----- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/ui/e2e/workspace.mjs b/ui/e2e/workspace.mjs index 7170410f..4e851668 100644 --- a/ui/e2e/workspace.mjs +++ b/ui/e2e/workspace.mjs @@ -154,17 +154,26 @@ test('Awaiting-review actions reveal without shifting and remain thumb-sized on }, undefined, undefined, 'reduce') test('Awaiting-review actions stay visible and thumb-sized without hover', async p => { + // The column is a folio on the phone: show it, so every probe lands on a button really on screen. + await chooseDeskColumn(p, 2) const actions = p.locator('[data-column="awaitingReview"] .kbn-card-review-meta-actions').first() assert.equal(await actions.evaluate(el => getComputedStyle(el).opacity), '1') - // A compact plate whose touch target reaches 8 px past it above and below. + // A compact 28 px plate inside a 44 px button, whose reach does not grow the card's meta row. for (const button of await actions.locator('button').all()) { const reach = await button.evaluate(el => { - el.scrollIntoView({ block: 'center', inline: 'center' }) const box = el.getBoundingClientRect(), x = box.left + box.width / 2 + const plate = getComputedStyle(el, '::before'), meta = el.closest('.kbn-card-meta').getBoundingClientRect() const hit = y => el.contains(document.elementFromPoint(x, y)) - return { height: box.height, above: hit(box.top - 7), below: hit(box.bottom + 7) } + return { + onScreen: box.left >= 0 && box.right <= innerWidth && box.top >= 0 && box.bottom <= innerHeight, + height: box.height, plate: parseFloat(plate.height) + parseFloat(plate.borderTopWidth) + parseFloat(plate.borderBottomWidth), + meta: meta.height, top: hit(box.top + 1), bottom: hit(box.bottom - 1), + } }) - assert.ok(reach.height + 16 >= 44 && reach.above && reach.below, `verdict touch target: ${JSON.stringify(reach)}`) + assert.ok(reach.onScreen, `the verdict is on screen: ${JSON.stringify(reach)}`) + assert.ok(reach.height >= 44 && reach.top && reach.bottom, `verdict touch target: ${JSON.stringify(reach)}`) + assert.equal(reach.plate, 28, 'the plate reads compact') + assert.equal(reach.meta, 28, 'the touch reach does not grow the meta row') } }, { width: 390, height: 844 }, undefined, 'reduce', true) @@ -593,10 +602,16 @@ test('The running head indexes pages in words, previews on hover within the shar assert.equal(await p.locator('.ws-tab-fresh').count(), 0, 'first visits are quiet') const preview = p.locator('.ws-tab-preview') await p.mouse.move(700, 500) + // Time the first hover in the page, so a slow runner cannot blur the wait. + await p.evaluate(() => { + const strip = document.querySelector('.ws-tabs'), card = document.querySelector('.ws-tab-preview') + strip.addEventListener('pointerover', () => { window.__hoverAt ??= performance.now() }) + new MutationObserver(() => { if (!card.hidden) window.__shownAt ??= performance.now() }).observe(card, { attributes: true, attributeFilter: ['hidden'] }) + }) await tab(p, 'response.pdf').hover() - await p.waitForTimeout(200) - assert.ok(await preview.isHidden(), 'a first hover waits') await preview.waitFor({ state: 'visible' }) + const wait = await p.evaluate(() => window.__shownAt - window.__hoverAt) + assert.ok(wait >= 390, `a first hover waits: ${wait} ms`) assert.match(await preview.locator('.ws-tab-preview-meta').textContent(), /sent/) const anchor = await tab(p, 'response.pdf').boundingBox(), card = await preview.boundingBox() assert.ok(Math.abs(card.x + card.width / 2 - (anchor.x + anchor.width / 2)) < 2 && card.y >= anchor.y + anchor.height, 'the preview hangs beneath its label') diff --git a/ui/src/board/KanbanModal.css b/ui/src/board/KanbanModal.css index a53972a3..3ca6dde7 100644 --- a/ui/src/board/KanbanModal.css +++ b/ui/src/board/KanbanModal.css @@ -2234,18 +2234,33 @@ opacity: 1; pointer-events: auto; } - /* A compact 28px plate; the 44px touch target reaches past it vertically. */ - .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; - height: 28px; + isolation: isolate; + height: 44px; + margin-block: -8px; min-width: 44px; padding: 0 10px; + background: transparent; + border-width: 0; } - .kbn-card-review-meta-actions .kbn-review-meta-btn::after { + .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; + 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; } From bdfaa35743db377606805bdb5d440d86b798bdff Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Mon, 5 Oct 2026 16:57:54 +0200 Subject: [PATCH 23/24] Peek a document's title again when its version moves The title probe remembered documents by key alone, so a file replaced in an open channel kept its old title for the session. It now remembers the version each document was peeked at (modification time and latest receipt) and peeks again when that moves; a stale peek's result is dropped. The probe's and the title cache's session memory are bounded. Co-Authored-By: Claude Opus 5.5 --- ui/src/board/workspace/DocumentTitles.ts | 3 +++ ui/src/board/workspace/titleProbe.test.ts | 29 +++++++++++++++++++++ ui/src/board/workspace/titleProbe.ts | 31 ++++++++++++++++++----- 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/ui/src/board/workspace/DocumentTitles.ts b/ui/src/board/workspace/DocumentTitles.ts index 7340f5c8..797b23b3 100644 --- a/ui/src/board/workspace/DocumentTitles.ts +++ b/ui/src/board/workspace/DocumentTitles.ts @@ -120,7 +120,10 @@ 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) + 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) diff --git a/ui/src/board/workspace/titleProbe.test.ts b/ui/src/board/workspace/titleProbe.test.ts index 2c936589..062eafa2 100644 --- a/ui/src/board/workspace/titleProbe.test.ts +++ b/ui/src/board/workspace/titleProbe.test.ts @@ -2,10 +2,13 @@ import { describe, expect, it, vi } from 'vitest' const reads: string[] = [] +const bodies = new Map() vi.mock('../FileViewerPanel.js', () => ({ readThumbnailMetadata: vi.fn(async (src: string, _signal: AbortSignal, onSource: (source: Uint8Array) => void) => { reads.push(decodeURIComponent(src)) if (src.includes('report.html')) onSource(new TextEncoder().encode('The report')) + const body = [...bodies].find(([name]) => decodeURIComponent(src).includes(name))?.[1] + if (body) onSource(new TextEncoder().encode(body)) }), })) const { probeDocumentTitles, PROBE_RETRY_MS } = await import('./titleProbe.js') @@ -32,4 +35,30 @@ describe('title probe', () => { vi.useRealTimers() expect(reads.slice(3).map(src => src.match(/\/(\w+\.\w+)/)?.[1])).toEqual(['paper.pdf', 'song.mp3']) }) + + it('peeks a document again when its modification time or latest receipt moves', async () => { + const reads0 = reads.length + bodies.set('/replaced.html', 'First draft') + const build = (modifiedAt: string, sentAt: number) => buildChannel({ uid: 'replaced', owner: 'replaced-host', name: 'Replaced', path: '/f.md', fiberDir: '/', body: '', + embeds: [{ path: '/replaced.html' }], + sent: [{ path: '/replaced.html', owner: 'replaced-host', time: sentAt }], + fileModifiedAt: new Map([['replaced-host:/replaced.html', modifiedAt]]), + }) + const first = build('2026-10-05T10:00:00Z', 1000) + const key = first.documents.find(d => d.name === 'replaced.html')!.key + probeDocumentTitles('', first.documents) + await vi.waitFor(() => expect(declaredTitle(key)?.title).toBe('First draft')) + probeDocumentTitles('', build('2026-10-05T10:00:00Z', 1000).documents) + await Promise.resolve() + expect(reads.length - reads0).toBe(1) + bodies.set('/replaced.html', 'Second draft') + probeDocumentTitles('', build('2026-10-05T11:00:00Z', 1000).documents) + await vi.waitFor(() => expect(declaredTitle(key)?.title).toBe('Second draft')) + expect(reads.length - reads0).toBe(2) + // A fresh receipt alone (a re-send) is a new version too. + bodies.set('/replaced.html', 'Third draft') + probeDocumentTitles('', build('2026-10-05T11:00:00Z', 2000).documents) + await vi.waitFor(() => expect(declaredTitle(key)?.title).toBe('Third draft')) + expect(reads.length - reads0).toBe(3) + }) }) diff --git a/ui/src/board/workspace/titleProbe.ts b/ui/src/board/workspace/titleProbe.ts index 37ba2ede..ce1bbf98 100644 --- a/ui/src/board/workspace/titleProbe.ts +++ b/ui/src/board/workspace/titleProbe.ts @@ -12,7 +12,10 @@ const TITLED: Partial> = { html: 0, te const CONCURRENT = 2 /** A peek that could not read waits this long before a render may try it again. */ export const PROBE_RETRY_MS = 60_000 -const probed = new Set() +/** The version each document was last peeked at; a changed file is peeked again. */ +const probed = new Map() +/** Probe memory stays bounded across a long session; the oldest entries are forgotten first. */ +const PROBED_LIMIT = 2000 const queue: Array<{ rank: number; run: () => Promise }> = [] let running = 0 @@ -26,26 +29,40 @@ function pump(): void { /** * The index names pages by their declared titles, so it reads each titled - * document's first 64 KiB once per session, independent of any thumbnail + * document's first 64 KiB once per version, independent of any thumbnail * (revalidating any title recalled from an earlier visit), * ahead of the stage's images and frames competing for the same connections. */ +/** What the channel knows of a document's version: its modification time and its latest receipt. */ +export function documentVersion(doc: WorkspaceDocument): string { + const sent = doc.provenance.reduce((latest, p) => p.kind === 'sent' && Number.isFinite(p.time) ? Math.max(latest, p.time) : latest, 0) + return `${doc.modifiedAt ?? ''}|${sent || ''}` +} + export function probeDocumentTitles(shuttleBase: string, documents: WorkspaceDocument[]): void { for (const doc of documents) { const rank = TITLED[doc.kind] - // A title recalled from an earlier visit still names the tab; the peek revalidates it. - if (rank === undefined || probed.has(doc.key) || titleIsCurrent(doc.key)) continue - probed.add(doc.key) + if (rank === undefined) continue + const version = documentVersion(doc) + const seen = probed.get(doc.key) + // A title read this session (by this probe or the page's own load) stands until the + // document's version moves; one recalled from an earlier visit is revalidated. + if (seen === version) continue + probed.delete(doc.key) + probed.set(doc.key, version) + if (probed.size > PROBED_LIMIT) probed.delete(probed.keys().next().value!) + if (seen === undefined && titleIsCurrent(doc.key)) continue queue.push({ rank, run: async () => { let read = false await readThumbnailMetadata(fileBytesUrl(shuttleBase, doc.path, doc.owner), new AbortController().signal, (source, etag) => { read = true - if (titleIsCurrent(doc.key)) return + // A newer version's peek supersedes this one. + if (probed.get(doc.key) !== version) return const text = doc.kind === 'html' || doc.kind === 'text' cacheDocumentTitle(doc.key, doc.path, text && typeof source !== 'string' ? new TextDecoder().decode(source) : source, etag) }, 'high') // A peek that could not read (an unreachable owner, a refused request) is tried again on a later render. - if (!read) setTimeout(() => probed.delete(doc.key), PROBE_RETRY_MS) + if (!read) setTimeout(() => { if (probed.get(doc.key) === version) probed.delete(doc.key) }, PROBE_RETRY_MS) } }) } queue.sort((a, b) => a.rank - b.rank) From 58ace1260437053cf60840cdfb568a491c1bba3b Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Mon, 5 Oct 2026 16:58:48 +0200 Subject: [PATCH 24/24] Join the listening page's scroller to the reader's scroll path On the phone the audio viewer itself scrolls, but nothing watched it: the top bar never hid and the reading position was neither saved nor restored after eviction. The host now binds that viewer as the page's scroller. Co-Authored-By: Claude Opus 5.5 --- ui/src/board/workspace/DocumentHost.test.ts | 21 +++++++++++++++++++++ ui/src/board/workspace/DocumentHost.ts | 5 +++++ 2 files changed, 26 insertions(+) diff --git a/ui/src/board/workspace/DocumentHost.test.ts b/ui/src/board/workspace/DocumentHost.test.ts index 08526452..853a2ba1 100644 --- a/ui/src/board/workspace/DocumentHost.test.ts +++ b/ui/src/board/workspace/DocumentHost.test.ts @@ -16,6 +16,8 @@ vi.mock('../FileViewerPanel.js', () => ({ 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 }), @@ -253,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) diff --git a/ui/src/board/workspace/DocumentHost.ts b/ui/src/board/workspace/DocumentHost.ts index c71085bc..16ee853b 100644 --- a/ui/src/board/workspace/DocumentHost.ts +++ b/ui/src/board/workspace/DocumentHost.ts @@ -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) }