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 | diff --git a/ui/e2e/workspace.mjs b/ui/e2e/workspace.mjs index 742cf850..4e851668 100644 --- a/ui/e2e/workspace.mjs +++ b/ui/e2e/workspace.mjs @@ -93,8 +93,11 @@ 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-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 { await type('.ws-thumb-title', 15, 'EB Garamond') await type('.ws-thumb-arrival', 11, 'IBM Plex Mono') @@ -102,7 +105,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') @@ -151,10 +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 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()) { - assert.ok((await button.boundingBox()).height >= 44) + const reach = await button.evaluate(el => { + 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 { + 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.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) @@ -566,26 +585,65 @@ 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) + // 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 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') + 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 +670,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 +810,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 @@ -837,21 +898,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 +1068,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 => { @@ -1286,15 +1336,24 @@ 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') 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') @@ -1331,6 +1390,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) @@ -1643,7 +1718,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() @@ -1678,12 +1753,27 @@ 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') + 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') + 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') + await poll(p, () => document.querySelector('.ws-navbar .ws-head-worker .ws-worker-control')?.textContent.includes('12 m')) + const dot = head.locator('.ws-worker-dot') + assert.ok(await dot.isVisible(), 'the head shows the 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()) + assert.ok(await p.locator('.ws-navbar .ws-head-worker .ws-turn-active').count(), 'a working turn is marked active') + const headDot = await 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') @@ -1691,27 +1781,28 @@ 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, () => document.querySelector('.ws-navbar .ws-head-worker .ws-worker-control')?.dataset.workerState === 'waiting') + assert.equal(await p.locator('.ws-navbar .ws-head-worker .ws-turn-active').count(), 0, 'a waiting worker does not breathe') + assert.notEqual(await 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) } 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 @@ -1746,7 +1837,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') @@ -1803,9 +1894,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') @@ -1827,7 +1916,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'), @@ -1990,7 +2078,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') @@ -2001,7 +2094,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/FileViewerPanel.ts b/ui/src/board/FileViewerPanel.ts index 6fbe5c6d..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/KanbanModal.css b/ui/src/board/KanbanModal.css index 29ae178e..3ca6dde7 100644 --- a/ui/src/board/KanbanModal.css +++ b/ui/src/board/KanbanModal.css @@ -2234,11 +2234,33 @@ opacity: 1; pointer-events: auto; } - .kbn-card-review-meta-actions .kbn-review-meta-btn { + /* The button is the whole 44px touch target, on a transparent ground; its + compact 28px plate is drawn inside it. Negative margins keep the meta + row's layout at the plate's height, so the card does not grow. */ + .kbn-card-review-meta-actions .kbn-review-meta-btn.kbn-action { + position: relative; + isolation: isolate; height: 44px; + margin-block: -8px; min-width: 44px; - padding: 8px; + padding: 0 10px; + background: transparent; + border-width: 0; } + .kbn-card-review-meta-actions .kbn-review-meta-btn.kbn-action:hover { background: transparent; } + .kbn-card-review-meta-actions .kbn-review-meta-btn.kbn-action:focus-visible { outline: none; } + .kbn-card-review-meta-actions .kbn-review-meta-btn::before { + content: ''; + position: absolute; + inset: 8px 0; + z-index: -1; + background: var(--verdict-tint); + border: 1px solid var(--verdict-edge); + border-radius: 2px; + transition: background 120ms ease, border-color 120ms ease; + } + .kbn-card-review-meta-actions .kbn-review-meta-btn:hover::before { background: var(--verdict-tint-strong); border-color: var(--verdict-pigment); } + .kbn-card-review-meta-actions .kbn-review-meta-btn:focus-visible::before { outline: 2px solid var(--verdict-pigment); outline-offset: 2px; } } @media (prefers-reduced-motion: reduce) { .kbn-card-review-meta-actions { transition: none; } @@ -2534,9 +2556,11 @@ line-height: 1.55; -webkit-line-clamp: 1 !important; } + /* The row clips sideways only, so a verdict's touch target can reach past its plate vertically. */ .kbn-card-meta { flex-wrap: nowrap; - overflow: hidden; + overflow-x: clip; + overflow-y: visible; gap: 5px; } .kbn-card-meta-spacer { flex: 0 1 auto; } 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..2a6a54a4 100644 --- a/ui/src/board/dockWorkerPill.integration.test.ts +++ b/ui/src/board/dockWorkerPill.integration.test.ts @@ -36,12 +36,12 @@ beforeEach(() => { afterEach(() => { dock.reset(); reader.dispose(); vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.replaceChildren() }) function show(card: KanbanCard): HTMLElement | null { reader.show(channel(card), channel(card).documents[0].key, 'Desk', card) - expect(reader.el.querySelector('.ws-navbar .kbn-card-worker')).toBeNull() - const band = dock.bandFor(card).el - expect(band.querySelectorAll('.kbn-card-worker').length).toBeLessThanOrEqual(1) - return band.querySelector('.ws-worker-pill[data-part="act"] .kbn-card-worker') + expect(dock.bandFor(card).el.querySelector('.kbn-card-worker')).toBeNull() + // The head carries the one worker control. + expect(reader.el.querySelectorAll('.ws-navbar .kbn-card-worker').length).toBeLessThanOrEqual(1) + return reader.el.querySelector('.ws-navbar .ws-head-worker .kbn-card-worker') } -describe('the fiber page act zone owns the only worker control', () => { +describe('the worker control lives in the reader head; the fiber page draws none', () => { it('opens the owner terminal without repeating agent or worker state on the fiber', () => { const pill = show(worker())! expect(pill.tagName).toBe('BUTTON') 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/DocumentHost.test.ts b/ui/src/board/workspace/DocumentHost.test.ts index cb25c4a6..853a2ba1 100644 --- a/ui/src/board/workspace/DocumentHost.test.ts +++ b/ui/src/board/workspace/DocumentHost.test.ts @@ -12,9 +12,12 @@ const render = vi.hoisted(() => ({ suspend: vi.fn(), resume: vi.fn(), once: vi.fn(), dispose: vi.fn(), refresh: vi.fn(async () => {}), })) vi.mock('../FileViewerPanel.js', () => ({ + readThumbnailMetadata: vi.fn(async () => {}), buildFileViewer: vi.fn((_base, path, owner, frame, text, options) => { const viewer = document.createElement('div') viewer.dataset.path = path + // A listening page is its own scroller, as the shared renderer builds it. + if (options?.kind === 'audio') viewer.className = 'kbn-fileview-media kbn-fileview-audio' render.calls.push({ viewer, path, owner, options, frame, text }) return viewer }), @@ -252,6 +255,25 @@ describe('stable document frames', () => { expect(host.get(docs[0].key)!.viewer).not.toBe(first.viewer) }) + it('wires the listening page scroller into the scroll hook and restores it after eviction', async () => { + const audio: WorkspaceDocument = { key: 'host-a:/doc/song.mp3', owner: 'host-a', path: '/doc/song.mp3', name: 'song.mp3', kind: 'audio', provenance: [] } + host.setChannel([audio], audio.key) + const first = render.calls.at(-1)! + await ready(first) + first.viewer.scrollTop = 240 + first.viewer.dispatchEvent(new Event('scroll')) + expect(onScroll).toHaveBeenLastCalledWith(audio.key, 240) + for (let n = 1; n <= 10; n++) { + host.setChannel([docs[n]], docs[n].key) + await ready() + } + expect(host.get(audio.key)).toBeUndefined() + host.setChannel([audio], audio.key) + const restored = render.calls.at(-1)! + expect(restored.viewer).not.toBe(first.viewer) + expect(restored.viewer.scrollTop).toBe(240) + }) + it('charges heavy HTML two slots and bounds off-channel frame metadata with the LRU', async () => { for (let n = 0; n < 30; n++) { const document = doc(n) 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) } 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..797b23b3 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,12 @@ export function cacheDocumentTitle(key: string, path: string, source: string | U versions.set(version, next) if (versions.size > 512) versions.delete(versions.keys().next().value!) const held = titles.get(key) - titles.set(key, next) - if (held !== next) for (const listener of listeners) listener(key) + current.delete(key) + current.add(key) + // Bounded for a long session: a forgotten key only means its next render peeks it again. + if (current.size > 2000) current.delete(current.values().next().value!) + touch(key, next) + save() + if (held !== next && (held?.title !== next.title || held?.preview !== next.preview)) for (const listener of listeners) listener(key) return next } 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/Reader.sidebar.test.ts b/ui/src/board/workspace/Reader.sidebar.test.ts index 37ccf43f..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() @@ -119,14 +146,15 @@ 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') 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() @@ -200,8 +228,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..319d0ebb 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,11 @@ 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') + /** 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)) @@ -150,26 +154,26 @@ 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.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' - 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, 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 = '' + this.next.innerHTML = '' const thumbMenu = button('ws-thumb-button', '⋯', () => { const doc = this.document if (doc) this.openMenu(doc, thumbMenu) @@ -181,7 +185,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 +222,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, @@ -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) @@ -430,17 +449,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 +531,35 @@ export class Reader { return } const style = getComputedStyle(this.navbar) - const gap = parseFloat(style.columnGap) || 12 + 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 - 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.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, + // 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) + // 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() @@ -757,7 +789,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 +803,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 +858,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) @@ -893,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/TabPreview.ts b/ui/src/board/workspace/TabPreview.ts new file mode 100644 index 00000000..dc8bfcb3 --- /dev/null +++ b/ui/src/board/workspace/TabPreview.ts @@ -0,0 +1,199 @@ +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 + // 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) + } + 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..e3ce8850 100644 --- a/ui/src/board/workspace/TabStrip.ts +++ b/ui/src/board/workspace/TabStrip.ts @@ -1,17 +1,20 @@ 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 { probeDocumentTitles } from './titleProbe.js' import { declaredTitle } from './DocumentTitles.js' import './tabs.css' 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)) } @@ -34,12 +37,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 +75,11 @@ 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 + private focus: number | null = null + private readonly shuttleBase: string | null 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 +87,15 @@ 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' ? 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 +112,23 @@ 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() + /** 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 - 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) + 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.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 +152,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 +170,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,9 +181,8 @@ 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) + 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) { @@ -225,12 +216,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 +260,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 +290,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/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 76dea370..223e6b9a 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') @@ -394,8 +395,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() @@ -469,8 +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(band.querySelector('.ws-worker-pill .kbn-card-worker')?.textContent).toBe('aloft') + expect(document.querySelectorAll('.kbn-card-worker:not(.ws-sidebar *):not(.ws-navbar *)')).toHaveLength(0) + expect(document.querySelector('.ws-navbar .ws-head-worker .kbn-card-worker')?.textContent).toMatch(/^aloft/) 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 +556,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] @@ -566,11 +567,13 @@ 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() + const head = document.querySelector('.ws-navbar .ws-head-worker button.kbn-card-worker')! + head.click() + expect(openWorker).toHaveBeenLastCalledWith('terminal-alpha', 'daemon-a') + openWorker.mockClear() 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() @@ -605,7 +608,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/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); } diff --git a/ui/src/board/workspace/dock.css b/ui/src/board/workspace/dock.css index ddf93a2d..6906523b 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; } @@ -250,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; @@ -308,5 +303,10 @@ .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 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/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; } } diff --git a/ui/src/board/workspace/reader.css b/ui/src/board/workspace/reader.css index f42bb798..add0b4fb 100644 --- a/ui/src/board/workspace/reader.css +++ b/ui/src/board/workspace/reader.css @@ -36,59 +36,75 @@ .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-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, .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-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; - color: var(--ws-ink); border: 0; background: transparent; animation: none; - text-transform: none; letter-spacing: 0; white-space: nowrap; +.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); } +/* The right end: the worker control, then the page count. */ +.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. */ +.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-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; } +.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-state { font: var(--ws-label-size)/1.2 var(--ws-serif); } .ws-worker-elapsed { font: var(--ws-small-size)/1.4 var(--ws-mono); } -.ws-worker-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ws-machine); flex: none; } +/* A dot is too small for ink-mixed pigments: working is the machine's own cobalt, waiting the undiluted gold. */ +.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-owed); } -.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; } } -.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 +113,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 +223,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; } @@ -254,9 +266,9 @@ .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-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 +277,27 @@ .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; } + /* The top bar's right end: the worker's dot alone, in a full target. */ + .ws-nav-trail { gap: 0; align-self: 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; } + /* 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; } @@ -293,8 +309,9 @@ /* While the keyboard is up the field owns the screen; the page bar would sit under Safari's input accessory. */ .ws-reader:has(.ws-content :is(textarea, input:not([type="checkbox"], [type="radio"], [type="range"], [type="button"]), [contenteditable="true"]):focus) .ws-thumbbar { display: none; } } +/* Under a finger the head's worker control is a full target, as the pill was. */ @media (pointer: coarse) { - .ws-worker-pill .kbn-card-worker { min-height: var(--ws-phone-target); display: inline-flex; align-items: center; } + .ws-navbar .ws-head-worker .kbn-card-worker { min-height: var(--ws-phone-target); } } @media (prefers-reduced-motion: reduce) { .ws-track, .ws-page { transition: none !important; } diff --git a/ui/src/board/workspace/tabs.css b/ui/src/board/workspace/tabs.css index c13d1966..5316f943 100644 --- a/ui/src/board/workspace/tabs.css +++ b/ui/src/board/workspace/tabs.css @@ -1,27 +1,46 @@ -.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 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-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; } -.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; } +/* 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; + 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..e2021266 100644 --- a/ui/src/board/workspace/themes/blueprint.css +++ b/ui/src/board/workspace/themes/blueprint.css @@ -57,12 +57,10 @@ 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); + font-size: 12px; letter-spacing: var(--ws-mono-tracking); text-transform: uppercase; } @@ -155,27 +153,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 +242,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..026c98bb 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); @@ -73,12 +70,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 { @@ -124,20 +116,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 +178,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/titleProbe.test.ts b/ui/src/board/workspace/titleProbe.test.ts new file mode 100644 index 00000000..062eafa2 --- /dev/null +++ b/ui/src/board/workspace/titleProbe.test.ts @@ -0,0 +1,64 @@ +// @vitest-environment jsdom +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') +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) + 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') + // 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']) + }) + + 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 new file mode 100644 index 00000000..ce1bbf98 --- /dev/null +++ b/ui/src/board/workspace/titleProbe.ts @@ -0,0 +1,70 @@ +import { readThumbnailMetadata } from '../FileViewerPanel.js' +import { fileBytesUrl } from '../utils.js' +import { cacheDocumentTitle, titleIsCurrent } from './DocumentTitles.js' +import type { WorkspaceDocument } from './documents.js' + +/** + * 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 +/** A peek that could not read waits this long before a render may try it again. */ +export const PROBE_RETRY_MS = 60_000 +/** 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 + +function pump(): void { + while (running < CONCURRENT && queue.length) { + const job = queue.shift()! + running++ + void job.run().finally(() => { running--; pump() }) + } +} + +/** + * The index names pages by their declared titles, so it reads each titled + * 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] + 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 + // 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(() => { if (probed.get(doc.key) === version) probed.delete(doc.key) }, PROBE_RETRY_MS) + } }) + } + queue.sort((a, b) => a.rank - b.rank) + pump() +} diff --git a/ui/src/board/workspace/tokens.css b/ui/src/board/workspace/tokens.css index c02a3def..dd9446bd 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,17 @@ --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: 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; --ws-reader-layer: 9000; @@ -83,9 +89,16 @@ --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-head-span: 32px; + --ws-title-ceiling: 360px; + /* 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;