From 1b8284acbdb23523be78315c10cdfdde118c58ea Mon Sep 17 00:00:00 2001
From: Enksodsoon <229880765+Enksodsoon@users.noreply.github.com>
Date: Wed, 14 Oct 2026 21:06:37 +0700
Subject: [PATCH 1/2] fix(responsive): preserve Forge workspace across display
changes
Keep docked preferences separate from transient panel state across width changes. Reserve OpenSeadragon capacity before constructing a viewer so a rejected create call leaves ownership with the caller. Verify viewport behavior with synthetic landmarks and state-retention tests.
---
frontend/e2e/accessibility.pw.ts | 72 ++++
frontend/e2e/browserErrors.ts | 17 +
.../e2e/fixtures/TeachingPreviewHarness.tsx | 34 ++
frontend/e2e/fixtures/teaching-preview.css | 2 +
frontend/e2e/fixtures/teaching-preview.html | 12 +
frontend/e2e/responsive.pw.ts | 255 +++++++++++++
frontend/e2e/teaching-landmarks.pw.ts | 154 ++++++++
frontend/e2e/viewer-resource-budget.pw.ts | 321 ++++++++++++++++
frontend/e2e/workspace-states.pw.ts | 136 +++++++
frontend/package.json | 3 +
frontend/playwright.config.ts | 34 ++
frontend/pnpm-lock.yaml | 57 +++
frontend/src/App.tsx | 122 ++++--
frontend/src/SlideViewer.tsx | 42 ++-
frontend/src/StudyAuthoring.tsx | 19 +-
frontend/src/TeachingSlidePreview.tsx | 65 +++-
frontend/src/responsivePanels.ts | 64 ++++
frontend/src/styles.css | 54 +++
frontend/src/test/App.test.tsx | 3 +
frontend/src/test/StudyAuthoring.test.tsx | 23 ++
.../src/test/TeachingSlidePreview.test.tsx | 27 +-
frontend/src/test/responsivePanels.test.ts | 47 +++
frontend/src/test/setup.ts | 3 +
.../src/test/viewerResourceBudget.test.ts | 46 +++
frontend/src/viewerConfig.ts | 3 +
frontend/src/viewerResourceBudget.ts | 347 ++++++++++++++++++
frontend/tsconfig.json | 2 +-
openspec/changes/archive/.gitkeep | 0
.../.openspec.yaml | 4 +
.../responsive-display-coverage/README.md | 3 +
.../responsive-display-coverage/design.md | 29 ++
.../responsive-display-coverage/proposal.md | 27 ++
.../specs/forge-spatial-selection/spec.md | 28 ++
.../forge-viewer-resource-limits/spec.md | 21 ++
.../specs/responsive-forge-workspace/spec.md | 56 +++
.../responsive-display-coverage/tasks.md | 31 ++
openspec/config.yaml | 1 +
openspec/specs/.gitkeep | 0
38 files changed, 2108 insertions(+), 56 deletions(-)
create mode 100644 frontend/e2e/accessibility.pw.ts
create mode 100644 frontend/e2e/browserErrors.ts
create mode 100644 frontend/e2e/fixtures/TeachingPreviewHarness.tsx
create mode 100644 frontend/e2e/fixtures/teaching-preview.css
create mode 100644 frontend/e2e/fixtures/teaching-preview.html
create mode 100644 frontend/e2e/responsive.pw.ts
create mode 100644 frontend/e2e/teaching-landmarks.pw.ts
create mode 100644 frontend/e2e/viewer-resource-budget.pw.ts
create mode 100644 frontend/e2e/workspace-states.pw.ts
create mode 100644 frontend/playwright.config.ts
create mode 100644 frontend/src/responsivePanels.ts
create mode 100644 frontend/src/test/responsivePanels.test.ts
create mode 100644 frontend/src/test/viewerResourceBudget.test.ts
create mode 100644 frontend/src/viewerResourceBudget.ts
create mode 100644 openspec/changes/archive/.gitkeep
create mode 100644 openspec/changes/responsive-display-coverage/.openspec.yaml
create mode 100644 openspec/changes/responsive-display-coverage/README.md
create mode 100644 openspec/changes/responsive-display-coverage/design.md
create mode 100644 openspec/changes/responsive-display-coverage/proposal.md
create mode 100644 openspec/changes/responsive-display-coverage/specs/forge-spatial-selection/spec.md
create mode 100644 openspec/changes/responsive-display-coverage/specs/forge-viewer-resource-limits/spec.md
create mode 100644 openspec/changes/responsive-display-coverage/specs/responsive-forge-workspace/spec.md
create mode 100644 openspec/changes/responsive-display-coverage/tasks.md
create mode 100644 openspec/config.yaml
create mode 100644 openspec/specs/.gitkeep
diff --git a/frontend/e2e/accessibility.pw.ts b/frontend/e2e/accessibility.pw.ts
new file mode 100644
index 0000000..f092df1
--- /dev/null
+++ b/frontend/e2e/accessibility.pw.ts
@@ -0,0 +1,72 @@
+import AxeBuilder from '@axe-core/playwright'
+import { expect, test, type Page } from '@playwright/test'
+import { captureBrowserErrors } from './browserErrors'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const here = path.dirname(fileURLToPath(import.meta.url))
+const dzi = ''
+const slide = {
+ id: 'synthetic-responsive-slide', displayName: 'North-east synthetic landmarks.ome.tif', sourceBytes: 100,
+ format: 'OME_TIFF', status: 'READY', detail: 'Synthetic image ready', outputPath: '', sha256: 'synthetic',
+ selectedSeries: 0, width: 640, height: 320, downsample: 1, estimatedOutputBytes: 0,
+ projectedFileBytes: 0, projectedFileLowerBytes: 0, projectedFileUpperBytes: 0,
+ cropX: 0, cropY: 0, cropWidth: 640, cropHeight: 320, sourceFingerprint: 'synthetic-source',
+ configurationRevision: 'synthetic-configuration', currentArtifactRevision: '', approvedArtifactRevision: '',
+}
+
+async function installFixture(page: Page) {
+ await page.route('**/api/**', async (route) => {
+ const url = new URL(route.request().url())
+ if (url.pathname === '/api/session') return route.fulfill({ json: {}, headers: { 'X-Forge-CSRF': 'fixture' } })
+ if (url.pathname === '/api/datasets') return route.fulfill({ json: { datasets: [slide] }, headers: { ETag: 'synthetic-datasets' } })
+ if (url.pathname === '/api/capabilities') return route.fulfill({ json: {
+ conversionRuntime: 'Synthetic fixture', derivativeRuntime: 'Synthetic fixture', vsiConversion: false,
+ dziGeneration: false, downsamples: [1, 2, 4], usableBytes: 1_000_000, effectiveCapacityBytes: 2_000_000,
+ } })
+ if (url.pathname === '/api/features') return route.fulfill({ json: { features: [] } })
+ if (url.pathname === '/api/viewer/connection') return route.fulfill({ json: {
+ connected: false, viewerUrl: '', deviceName: '', scopes: [], conversionMode: 'PREPARED_DZI_V2',
+ } })
+ if (url.pathname.endsWith('/annotations')) return route.fulfill({ json: { annotations: [] } })
+ if (url.pathname.endsWith('/artifacts')) return route.fulfill({ json: { currentRevision: '', approvedRevision: '', revisions: [] } })
+ if (url.pathname === '/api/analysis/runs') return route.fulfill({ json: [] })
+ if (url.pathname.endsWith('/slide.dzi')) return route.fulfill({ status: 200, contentType: 'application/xml', body: dzi, headers: { 'x-pathlab-preview-mode': 'direct' } })
+ if (url.pathname.endsWith('/thumbnail')) return route.fulfill({ path: path.join(here, 'fixtures', 'teaching-landmarks', 'landmarks_files', '9', '0_0.png') })
+ const tile = url.pathname.match(/\/(\d+)\/(\d+)_(\d+)\.png$/)
+ if (tile) return route.fulfill({ path: path.join(here, 'fixtures', 'teaching-landmarks', 'landmarks_files', tile[1], `${tile[2]}_${tile[3]}.png`) })
+ return route.fulfill({ status: 404, json: { detail: `No synthetic fixture for ${url.pathname}` } })
+ })
+}
+
+for (const viewport of [{ width: 1440, height: 900, label: 'desktop' }, { width: 390, height: 844, label: 'phone' }]) {
+ test(`axe scans the populated Forge workspace at ${viewport.label} size`, async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== 'chromium')
+ const expectNoBrowserErrors = captureBrowserErrors(page)
+ await installFixture(page)
+ await page.setViewportSize({ width: viewport.width, height: viewport.height })
+ await page.goto('/assets/')
+ await expect(page.locator('.forge-viewer-header').getByText(slide.displayName, { exact: true })).toBeVisible()
+ if (viewport.label === 'phone') {
+ const header = page.locator('.forge-viewer-header')
+ const headerBox = await header.boundingBox()
+ const buttons = header.getByRole('button')
+ for (const button of await buttons.all()) {
+ await expect(button).toHaveAccessibleName(/.+/)
+ const box = await button.boundingBox()
+ expect(box).not.toBeNull()
+ expect(box!.x + box!.width).toBeLessThanOrEqual(headerBox!.x + headerBox!.width + 1)
+ expect(box!.height).toBeGreaterThanOrEqual(44)
+ }
+ }
+ const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze()
+ const report = results.violations.map((violation) => ({
+ id: violation.id, impact: violation.impact, description: violation.description,
+ help: violation.help, nodes: violation.nodes.map((node) => ({ target: node.target, summary: node.failureSummary })),
+ }))
+ await testInfo.attach(`axe-${viewport.label}.json`, { body: JSON.stringify(report, null, 2), contentType: 'application/json' })
+ const severe = results.violations.filter((violation) => violation.impact === 'serious' || violation.impact === 'critical')
+ expect(severe, JSON.stringify(report, null, 2)).toEqual([])
+ expectNoBrowserErrors()
+ })
+}
diff --git a/frontend/e2e/browserErrors.ts b/frontend/e2e/browserErrors.ts
new file mode 100644
index 0000000..aa2a00d
--- /dev/null
+++ b/frontend/e2e/browserErrors.ts
@@ -0,0 +1,17 @@
+import { expect, type Page } from '@playwright/test'
+
+export function captureBrowserErrors(page: Page, isExpectedFailedResponse: (status: number, url: string) => boolean = () => false) {
+ const errors: string[] = []
+ page.on('pageerror', (error) => errors.push(`uncaught: ${error.stack || error.message}`))
+ page.on('console', (message) => {
+ if (message.type() === 'error' && !message.text().startsWith('Failed to load resource:')) {
+ errors.push(`console.error: ${message.text()}`)
+ }
+ })
+ page.on('response', (response) => {
+ if (response.status() >= 400 && !isExpectedFailedResponse(response.status(), response.url())) {
+ errors.push(`HTTP ${response.status()}: ${response.url()}`)
+ }
+ })
+ return () => expect(errors, errors.join('\n')).toEqual([])
+}
diff --git a/frontend/e2e/fixtures/TeachingPreviewHarness.tsx b/frontend/e2e/fixtures/TeachingPreviewHarness.tsx
new file mode 100644
index 0000000..c250837
--- /dev/null
+++ b/frontend/e2e/fixtures/TeachingPreviewHarness.tsx
@@ -0,0 +1,34 @@
+import { useState } from 'react'
+import { createRoot } from 'react-dom/client'
+import { TeachingSlidePreview } from '../../src/TeachingSlidePreview'
+import type { TeachingAssociation } from '../../src/teachingAssociations'
+import type OpenSeadragon from 'openseadragon'
+import './teaching-preview.css'
+
+const association: TeachingAssociation = {
+ referenceId: 'synthetic:landmarks',
+ viewerSlideId: '',
+ datasetId: 'synthetic-dataset',
+ artifactRevision: 'synthetic-revision',
+ packageSha256: 'a'.repeat(64),
+ outputWidth: 640,
+ outputHeight: 320,
+}
+
+declare global { interface Window { __teachingViewer?: OpenSeadragon.Viewer | null } }
+
+function TeachingPreviewHarness() {
+ const [location, setLocation] = useState<{ x: number; y: number }>()
+ const [ready, setReady] = useState(false)
+ return
+ Non-square teaching preview fixture
+
+
+ setReady(Boolean(pixels))}
+ onLocation={(x, y) => setLocation({ x, y })} selectedLocation={location}
+ onViewerReady={(viewer) => { window.__teachingViewer = viewer }} />
+
+}
+
+createRoot(document.getElementById('fixture')!).render()
diff --git a/frontend/e2e/fixtures/teaching-preview.css b/frontend/e2e/fixtures/teaching-preview.css
new file mode 100644
index 0000000..6acbdb4
--- /dev/null
+++ b/frontend/e2e/fixtures/teaching-preview.css
@@ -0,0 +1,2 @@
+body{margin:0;background:#f8f7f4;color:#202421;font:16px/1.4 system-ui,sans-serif}main{width:min(1120px,100%);margin:auto;padding:16px;box-sizing:border-box}h1{font-size:20px}output{display:block;margin:4px 0}section[aria-label="Exact offline teaching pixels"]{display:grid;grid-template-columns:repeat(3,minmax(100px,1fr));gap:8px}section[aria-label="Exact offline teaching pixels"]>div{grid-column:1/-1;height:min(62dvh,540px)!important;min-height:220px}section[aria-label="Exact offline teaching pixels"]>.teaching-slide-viewport{justify-self:start;width:min(400px,90vw)!important;height:min(400px,50dvh)!important;min-height:400px!important;aspect-ratio:1/1;background:#111!important}section[aria-label="Exact offline teaching pixels"]>button{min-height:44px}
+span.teaching-location-marker{width:32px;height:32px;display:block;border:2px solid #fff;border-radius:50%;background:#b35439;box-shadow:0 0 0 2px #111;pointer-events:none}
diff --git a/frontend/e2e/fixtures/teaching-preview.html b/frontend/e2e/fixtures/teaching-preview.html
new file mode 100644
index 0000000..11585bc
--- /dev/null
+++ b/frontend/e2e/fixtures/teaching-preview.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Synthetic teaching preview
+
+
+
+
+
+
diff --git a/frontend/e2e/responsive.pw.ts b/frontend/e2e/responsive.pw.ts
new file mode 100644
index 0000000..fbc78f9
--- /dev/null
+++ b/frontend/e2e/responsive.pw.ts
@@ -0,0 +1,255 @@
+import { expect, test, type Page, type TestInfo } from '@playwright/test'
+import { captureBrowserErrors } from './browserErrors'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const here = path.dirname(fileURLToPath(import.meta.url))
+const fixtureDzi = ''
+const slide = {
+ id: 'synthetic-responsive-slide', displayName: 'North-east synthetic landmarks.ome.tif', sourceBytes: 100,
+ format: 'OME_TIFF', status: 'READY', detail: 'Synthetic image ready', outputPath: '', sha256: 'synthetic',
+ selectedSeries: 0, width: 640, height: 320, downsample: 1, estimatedOutputBytes: 0,
+ projectedFileBytes: 0, projectedFileLowerBytes: 0, projectedFileUpperBytes: 0,
+ cropX: 0, cropY: 0, cropWidth: 640, cropHeight: 320, sourceFingerprint: 'synthetic-source',
+ configurationRevision: 'synthetic-configuration', currentArtifactRevision: '', approvedArtifactRevision: '',
+}
+
+async function installFixture(page: Page, testInfo?: TestInfo) {
+ const tileState = { active: 0, maxActive: 0, requests: 0 }
+ if (testInfo) {
+ await page.addInitScript(() => {
+ const originalAdd = EventTarget.prototype.addEventListener
+ const originalRemove = EventTarget.prototype.removeEventListener
+ const counts: Record = {}
+ const listeners = new WeakMap>>()
+ EventTarget.prototype.addEventListener = function (type, listener, options) {
+ if (this === window && listener) {
+ const capture = typeof options === 'boolean' ? options : Boolean(options?.capture)
+ const key = `${type}:${capture}`
+ let target = listeners.get(this)
+ if (!target) { target = new Map(); listeners.set(this, target) }
+ let set = target.get(key)
+ if (!set) { set = new Set(); target.set(key, set) }
+ if (!set.has(listener)) { set.add(listener); counts[type] = (counts[type] || 0) + 1 }
+ }
+ return originalAdd.call(this, type, listener, options)
+ }
+ EventTarget.prototype.removeEventListener = function (type, listener, options) {
+ if (this === window && listener) {
+ const capture = typeof options === 'boolean' ? options : Boolean(options?.capture)
+ const set = listeners.get(this)?.get(`${type}:${capture}`)
+ if (set?.delete(listener)) counts[type] = Math.max(0, (counts[type] || 0) - 1)
+ }
+ return originalRemove.call(this, type, listener, options)
+ }
+ Object.assign(window, { __activeWindowListeners: () => ({ ...counts }) })
+ })
+ }
+ await page.route('**/api/**', async (route) => {
+ const url = new URL(route.request().url())
+ if (url.pathname === '/api/session') return route.fulfill({ json: {}, headers: { 'X-Forge-CSRF': 'fixture' } })
+ if (url.pathname === '/api/datasets') return route.fulfill({ json: { datasets: [slide] }, headers: { ETag: 'synthetic-datasets' } })
+ if (url.pathname === '/api/capabilities') return route.fulfill({ json: {
+ conversionRuntime: 'Synthetic fixture', derivativeRuntime: 'Synthetic fixture', vsiConversion: false,
+ dziGeneration: false, downsamples: [1, 2, 4], usableBytes: 1_000_000, effectiveCapacityBytes: 2_000_000,
+ } })
+ if (url.pathname === '/api/features') return route.fulfill({ json: { features: [] } })
+ if (url.pathname === '/api/viewer/connection') return route.fulfill({ json: {
+ connected: false, viewerUrl: '', deviceName: '', scopes: [], conversionMode: 'PREPARED_DZI_V2',
+ } })
+ if (url.pathname.endsWith('/annotations')) return route.fulfill({ json: { annotations: [] } })
+ if (url.pathname.endsWith('/artifacts')) return route.fulfill({ json: { currentRevision: '', approvedRevision: '', revisions: [] } })
+ if (url.pathname === '/api/analysis/runs') return route.fulfill({ json: [] })
+ if (url.pathname.endsWith('/slide.dzi')) return route.fulfill({ status: 200, contentType: 'application/xml', body: fixtureDzi, headers: { 'x-pathlab-preview-mode': 'direct' } })
+ if (url.pathname.endsWith('/thumbnail')) return route.fulfill({ path: path.join(here, 'fixtures', 'teaching-landmarks', 'landmarks_files', '9', '0_0.png') })
+ const tile = url.pathname.match(/\/(\d+)\/(\d+)_(\d+)\.png$/)
+ if (tile) {
+ tileState.active++
+ tileState.requests++
+ tileState.maxActive = Math.max(tileState.maxActive, tileState.active)
+ await new Promise((resolve) => setTimeout(resolve, 20))
+ const tilePath = path.join(here, 'fixtures', 'teaching-landmarks', 'landmarks_files', tile[1], `${tile[2]}_${tile[3]}.png`)
+ try { return await route.fulfill({ path: tilePath }) } finally { tileState.active-- }
+ }
+ return route.fulfill({ status: 404, json: { detail: `No synthetic fixture for ${url.pathname}` } })
+ })
+ return tileState
+}
+
+const fullChromiumViewports = [
+ [320, 568], [390, 844], [568, 320], [667, 375], [768, 1024], [1024, 768],
+ [1280, 720], [1440, 900], [1920, 1080], [2560, 1440], [3440, 1440], [3840, 2160],
+ [599, 800], [600, 800], [601, 800], [959, 800], [960, 800], [961, 800],
+ [1199, 800], [1200, 800], [1201, 800], [320, 320], [390, 480],
+]
+const crossBrowserViewports = [[390, 844], [568, 320], [768, 1024], [1440, 900]]
+
+for (const [width, height] of fullChromiumViewports) {
+ test(`reflows without document overflow at ${width}x${height}`, async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== 'chromium' && !crossBrowserViewports.some(([w, h]) => w === width && h === height))
+ const fixtureTiles = await installFixture(page)
+ await page.setViewportSize({ width, height })
+ const expectNoBrowserErrors = captureBrowserErrors(page)
+ await page.goto('/assets/')
+ await expect(page.locator('.forge-viewer-header').getByText('North-east synthetic landmarks.ome.tif', { exact: true })).toBeVisible()
+ if (testInfo.project.name.startsWith('chromium') && [[320, 568], [1280, 720]].some(([w, h]) => w === width && h === height)) {
+ await expect.poll(() => fixtureTiles.requests).toBeGreaterThan(0)
+ await expect(page.locator('.forge-preview-loading')).toBeHidden({ timeout: 20_000 })
+ await page.screenshot({ path: testInfo.outputPath(`forge-${width}x${height}.png`) })
+ }
+ const documentWidth = await page.evaluate(() => document.documentElement.scrollWidth)
+ expect(documentWidth).toBeLessThanOrEqual(width)
+ const layout = await page.evaluate(() => ({
+ navigatorPosition: getComputedStyle(document.querySelector('.pathlab-local-navigator')!).position,
+ inspectorPosition: getComputedStyle(document.querySelector('.pathlab-viewer-inspector')!).position,
+ navigatorHidden: (document.querySelector('.pathlab-local-navigator') as HTMLElement).hidden,
+ inspectorHidden: (document.querySelector('.pathlab-viewer-inspector') as HTMLElement).hidden,
+ }))
+ expect(layout.navigatorPosition).toBe(width <= 960 ? 'fixed' : 'static')
+ expect(layout.inspectorPosition).toBe(width <= 1200 ? 'fixed' : 'relative')
+ expect(layout.navigatorHidden).toBe(width <= 960)
+ expect(layout.inspectorHidden).toBe(width <= 1200)
+ if (width <= 600) {
+ const header = page.locator('.forge-viewer-header')
+ const headerBox = await header.boundingBox()
+ const inspectorToggle = header.getByRole('button', { name: /slide inspector/ })
+ await expect(inspectorToggle).toHaveAttribute('aria-label', /slide inspector/)
+ const toggleBox = await inspectorToggle.boundingBox()
+ expect(toggleBox).not.toBeNull()
+ expect(toggleBox!.x + toggleBox!.width).toBeLessThanOrEqual(headerBox!.x + headerBox!.width + 1)
+ expect(toggleBox!.height).toBeGreaterThanOrEqual(44)
+ }
+ expectNoBrowserErrors()
+ })
+}
+
+test('preserves panel preference, focus, and navigator edits through breakpoint changes', async ({ page }) => {
+ await installFixture(page)
+ await page.setViewportSize({ width: 900, height: 700 })
+ await page.goto('/assets/')
+ const navigator = page.locator('.pathlab-local-navigator')
+ const inspector = page.locator('.pathlab-viewer-inspector')
+ const libraryTrigger = page.getByRole('button', { name: 'Local library' })
+ await expect(libraryTrigger).toHaveAttribute('aria-expanded', 'false')
+ await libraryTrigger.click()
+ const search = navigator.getByPlaceholder('Search slides and folders')
+ await expect(navigator.locator(':focus')).toHaveCount(1)
+ await search.fill('landmarks')
+ await expect(search).toBeFocused()
+ await expect(navigator.getByRole('button', { name: 'Collapse slide library' })).toBeVisible()
+ const openInspector = page.getByRole('button', { name: 'Open slide inspector' })
+ await openInspector.click()
+ await expect(navigator).toHaveAttribute('hidden', '')
+ await expect(inspector).not.toHaveAttribute('hidden', '')
+ await expect(inspector.locator(':focus')).toHaveCount(1)
+ await page.keyboard.press('Escape')
+ await expect(inspector).toHaveAttribute('hidden', '')
+ await expect(openInspector).toBeFocused()
+
+ await libraryTrigger.click()
+ await expect(navigator).not.toHaveAttribute('hidden', '')
+ await page.setViewportSize({ width: 1000, height: 700 })
+ await expect(navigator).not.toHaveAttribute('hidden', '')
+ await expect(inspector).toHaveAttribute('hidden', '')
+ await page.getByRole('button', { name: 'Open slide inspector' }).click()
+ await expect(inspector).not.toHaveAttribute('hidden', '')
+ await expect(navigator.getByPlaceholder('Search slides and folders')).toHaveValue('landmarks')
+ await inspector.getByRole('button', { name: 'Collapse slide inspector' }).click()
+ await expect(inspector).toHaveAttribute('hidden', '')
+ await page.setViewportSize({ width: 1201, height: 700 })
+ await expect(inspector).not.toHaveAttribute('hidden', '')
+ await page.setViewportSize({ width: 1200, height: 700 })
+ await expect(inspector).toHaveAttribute('hidden', '')
+ await expect(navigator).not.toHaveAttribute('hidden', '')
+ await page.setViewportSize({ width: 1201, height: 700 })
+ await expect(inspector).not.toHaveAttribute('hidden', '')
+})
+
+test('keeps one viewer and bounded loader settings through twenty alternating resizes', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== 'chromium')
+ const tiles = await installFixture(page, testInfo)
+ await page.setViewportSize({ width: 1440, height: 900 })
+ await page.goto('/assets/')
+ const viewer = page.getByTestId('forge-osd')
+ const primaryViewer = viewer.locator(':scope > .openseadragon-container')
+ const overviewViewer = viewer.locator('.navigator .openseadragon-container')
+ await expect(primaryViewer).toHaveCount(1)
+ await expect(overviewViewer).toHaveCount(1)
+ await expect(viewer.locator('.openseadragon-container')).toHaveCount(2)
+ await expect(viewer).toHaveAttribute('data-image-loader-limit', '1')
+ await expect(viewer).toHaveAttribute('data-image-loader-budget', '2')
+ await expect(viewer).toHaveAttribute('data-max-tile-cache-count', '32')
+ await expect(viewer).toHaveAttribute('data-max-tile-cache-budget', '96')
+ await expect(viewer).toHaveAttribute('data-effective-primary-loader-jobs', '1')
+ await expect(viewer).toHaveAttribute('data-effective-overview-loader-jobs', '1')
+ await expect(viewer).toHaveAttribute('data-effective-primary-tile-cache', '32')
+ await expect(viewer).toHaveAttribute('data-effective-overview-tile-cache', '32')
+ const viewerInstance = await viewer.getAttribute('data-viewer-instance-id')
+ expect(viewerInstance).toBeTruthy()
+ const before = await page.evaluate(() => (window as typeof window & { __activeWindowListeners: () => Record }).__activeWindowListeners())
+ for (let cycle = 0; cycle < 20; cycle++) {
+ await page.setViewportSize({ width: cycle % 2 ? 1440 : 390, height: cycle % 2 ? 900 : 844 })
+ await expect(primaryViewer).toHaveCount(1)
+ await expect(overviewViewer).toHaveCount(1)
+ await expect(viewer.locator('.openseadragon-container')).toHaveCount(2)
+ }
+ await expect(primaryViewer).toHaveCount(1)
+ await expect(overviewViewer).toHaveCount(1)
+ await expect(viewer).toHaveAttribute('data-viewer-instance-id', viewerInstance!)
+ expect(tiles.active).toBe(0)
+ expect(tiles.maxActive).toBeLessThanOrEqual(2)
+ const after = await page.evaluate(() => (window as typeof window & { __activeWindowListeners: () => Record }).__activeWindowListeners())
+ expect(after).toEqual(before)
+})
+
+test('keeps dark theme, reduced motion, forced colors, root-font enlargement emulation, and keyboard focus usable', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== 'chromium')
+ await installFixture(page)
+ await page.emulateMedia({ colorScheme: 'dark', reducedMotion: 'reduce', forcedColors: 'active' })
+ await page.setViewportSize({ width: 390, height: 844 })
+ await page.goto('/assets/')
+ await page.addStyleTag({ content: 'html{font-size:200%!important} /* root-font enlargement emulation, not browser zoom */' })
+ const settings = await page.evaluate(() => ({
+ theme: document.documentElement.dataset.theme,
+ reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches,
+ forcedColors: matchMedia('(forced-colors: active)').matches,
+ width: document.documentElement.scrollWidth,
+ viewportWidth: window.innerWidth,
+ transition: getComputedStyle(document.querySelector('.pathlab-local-navigator')!).transitionDuration,
+ }))
+ expect(settings).toMatchObject({ theme: 'dark', reducedMotion: true, forcedColors: true })
+ expect(settings.width).toBeLessThanOrEqual(settings.viewportWidth)
+ expect(settings.transition).toBe('0s')
+ await page.getByRole('button', { name: 'Local library' }).focus()
+ await page.keyboard.press('Tab')
+ const focused = await page.evaluate(() => {
+ const active = document.activeElement as HTMLElement
+ return { label: active.getAttribute('aria-label'), outline: getComputedStyle(active).outlineStyle, border: getComputedStyle(active).borderStyle }
+ })
+ expect(focused.label).toBe('Viewer library')
+ expect(focused.outline).toBe('solid')
+ expect(focused.border).toBe('solid')
+})
+
+test('supports touch, mouse, and keyboard while the phone navigator opens and closes', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== 'chromium-touch')
+ await installFixture(page)
+ await page.setViewportSize({ width: 390, height: 844 })
+ await page.goto('/assets/')
+ expect(await page.evaluate(() => navigator.maxTouchPoints)).toBeGreaterThan(0)
+ const trigger = page.getByRole('button', { name: 'Local library' })
+ const triggerBox = await trigger.boundingBox()
+ expect(triggerBox).not.toBeNull()
+ await page.touchscreen.tap(triggerBox!.x + triggerBox!.width / 2, triggerBox!.y + triggerBox!.height / 2)
+ const navigator = page.locator('#library-navigator')
+ await expect(navigator).not.toHaveAttribute('hidden', '')
+ await expect(navigator.locator(':focus')).toHaveCount(1)
+ const close = navigator.getByRole('button', { name: 'Collapse slide library' })
+ const closeBox = await close.boundingBox()
+ expect(closeBox).not.toBeNull()
+ expect(closeBox!.height).toBeGreaterThanOrEqual(44)
+ await page.mouse.click(closeBox!.x + closeBox!.width / 2, closeBox!.y + closeBox!.height / 2)
+ await expect(navigator).toHaveAttribute('hidden', '')
+ await page.keyboard.press('Tab')
+ expect(await page.evaluate(() => document.activeElement instanceof HTMLElement && document.activeElement.matches(':focus-visible'))).toBe(true)
+})
diff --git a/frontend/e2e/teaching-landmarks.pw.ts b/frontend/e2e/teaching-landmarks.pw.ts
new file mode 100644
index 0000000..075b4ee
--- /dev/null
+++ b/frontend/e2e/teaching-landmarks.pw.ts
@@ -0,0 +1,154 @@
+import { expect, test } from '@playwright/test'
+import { captureBrowserErrors } from './browserErrors'
+
+const dzi = ''
+
+test('renders non-square synthetic landmarks and records image coordinates through gutters, zoom, and resize', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== 'chromium')
+ const expectNoBrowserErrors = captureBrowserErrors(page)
+ const browserErrors: string[] = []
+ page.on('pageerror', (error) => browserErrors.push(error.stack || error.message))
+ page.on('console', (message) => { if (message.type() === 'error') browserErrors.push(message.text()) })
+ let tileRequests = 0
+ await page.route('**/api/datasets/synthetic-dataset/artifacts/synthetic-revision/teaching-preview/**', async (route) => {
+ const url = new URL(route.request().url())
+ if (url.pathname.endsWith('/slide.dzi')) return route.fulfill({ status: 200, contentType: 'application/xml', body: dzi })
+ const tile = url.pathname.match(/\/(\d+)\/(\d+)_(\d+)\.png$/)
+ if (!tile) return route.fulfill({ status: 404 })
+ tileRequests++
+ return route.fulfill({ path: `e2e/fixtures/teaching-landmarks/landmarks_files/${tile[1]}/${tile[2]}_${tile[3]}.png` })
+ })
+ await page.setViewportSize({ width: 1000, height: 900 })
+ const response = await page.goto('/assets/e2e/fixtures/teaching-preview.html')
+ try { await expect(page.getByTestId('teaching-ready')).toHaveText('Image ready', { timeout: 20_000 }) }
+ catch (error) {
+ const diagnostics = { status: response?.status(), url: page.url(), browserErrors, tileRequests, body: await page.locator('body').innerText() }
+ console.log('TEACHING FIXTURE DIAGNOSTICS', JSON.stringify(diagnostics))
+ await testInfo.attach('teaching-preview-diagnostics.json', { body: JSON.stringify(diagnostics, null, 2), contentType: 'application/json' })
+ throw error
+ }
+ const viewport = page.locator('.teaching-slide-viewport')
+ await expect(viewport.locator('.openseadragon-container')).toHaveCount(1)
+ await expect.poll(() => tileRequests).toBeGreaterThan(0)
+
+ const imageBounds = await page.evaluate(() => {
+ const viewer = window.__teachingViewer!
+ const area = document.querySelector('.teaching-slide-viewport')!.getBoundingClientRect()
+ const topLeft = viewer.viewport.pixelFromPoint(viewer.viewport.imageToViewportCoordinates(0, 0), true)
+ const bottomRight = viewer.viewport.pixelFromPoint(viewer.viewport.imageToViewportCoordinates(640, 320), true)
+ return { area: { left: area.left, top: area.top, right: area.right, bottom: area.bottom, width: area.width, height: area.height }, topLeft, bottomRight }
+ })
+ const hasLetterbox = imageBounds.topLeft.y > imageBounds.area.top + 2 || imageBounds.bottomRight.y < imageBounds.area.bottom - 2
+ || imageBounds.topLeft.x > imageBounds.area.left + 2 || imageBounds.bottomRight.x < imageBounds.area.right - 2
+ expect(hasLetterbox).toBe(true)
+ const gutterPoint = imageBounds.topLeft.y > imageBounds.area.top + 2
+ ? { x: (imageBounds.topLeft.x + imageBounds.bottomRight.x) / 2, y: imageBounds.area.top + 1 }
+ : { x: imageBounds.area.left + 1, y: (imageBounds.topLeft.y + imageBounds.bottomRight.y) / 2 }
+ await page.mouse.click(gutterPoint.x, gutterPoint.y)
+ await expect(page.getByTestId('teaching-location')).toHaveText('No location selected')
+ const expectLocationWithinOneSourcePixel = async (x: number, y: number) => {
+ const [actualX, actualY] = (await page.getByTestId('teaching-location').innerText()).split(',').map(Number)
+ expect(Math.abs(actualX - x)).toBeLessThanOrEqual(1 / 640)
+ expect(Math.abs(actualY - y)).toBeLessThanOrEqual(1 / 320)
+ }
+
+ const clickImagePoint = async (x: number, y: number) => {
+ const point = await page.evaluate(({ x: imageX, y: imageY }) => {
+ const viewer = window.__teachingViewer!
+ const imagePoint = viewer.viewport.pixelFromPoint(viewer.viewport.imageToViewportCoordinates(imageX, imageY), true)
+ const rect = document.querySelector('.teaching-slide-viewport')!.getBoundingClientRect()
+ return { x: rect.left + imagePoint.x, y: rect.top + imagePoint.y }
+ }, { x, y })
+ await page.mouse.click(point.x, point.y)
+ }
+ await clickImagePoint(160, 80)
+ await expectLocationWithinOneSourcePixel(0.25, 0.25)
+ const marker = page.getByTestId('teaching-location-marker')
+ await expect(marker).toBeVisible()
+ const markerBox = await marker.boundingBox()
+ const redLandmark = await page.evaluate(() => {
+ const viewer = window.__teachingViewer!
+ const p = viewer.viewport.pixelFromPoint(viewer.viewport.imageToViewportCoordinates(160, 80), true)
+ const rect = document.querySelector('.teaching-slide-viewport')!.getBoundingClientRect()
+ return { x: rect.left + p.x, y: rect.top + p.y }
+ })
+ expect(Math.abs((markerBox!.x + markerBox!.width / 2) - redLandmark.x)).toBeLessThanOrEqual(1)
+ expect(Math.abs((markerBox!.y + markerBox!.height / 2) - redLandmark.y)).toBeLessThanOrEqual(1)
+
+ await page.getByRole('button', { name: 'Zoom teaching slide in' }).click()
+ await page.waitForFunction(() => {
+ const viewport = window.__teachingViewer?.viewport
+ return Boolean(viewport && Math.abs(viewport.getZoom(true) - viewport.getZoom(false)) < 0.001)
+ })
+ await clickImagePoint(480, 240)
+ await expectLocationWithinOneSourcePixel(0.75, 0.75)
+ await page.screenshot({ path: testInfo.outputPath('synthetic-landmarks-zoomed.png') })
+
+ await page.setViewportSize({ width: 390, height: 844 })
+ await page.waitForFunction(() => {
+ const viewer = window.__teachingViewer
+ const element = document.querySelector('.teaching-slide-viewport')
+ const size = viewer?.viewport.getContainerSize()
+ return Boolean(element && size && size.x === element.clientWidth && size.y === element.clientHeight)
+ })
+ await clickImagePoint(320, 160)
+ await expectLocationWithinOneSourcePixel(0.5, 0.5)
+ await page.setViewportSize({ width: 568, height: 320 })
+ await page.waitForFunction(() => {
+ const viewer = window.__teachingViewer
+ const element = document.querySelector('.teaching-slide-viewport')
+ const size = viewer?.viewport.getContainerSize()
+ return Boolean(element && size && size.x === element.clientWidth && size.y === element.clientHeight)
+ })
+ await clickImagePoint(160, 80)
+ await expectLocationWithinOneSourcePixel(0.25, 0.25)
+ expectNoBrowserErrors()
+})
+
+test('keeps synthetic landmark coordinates correct at DPR 1, 2, and 3', async ({ browser }, testInfo) => {
+ test.skip(testInfo.project.name !== 'chromium')
+
+ for (const deviceScaleFactor of [1, 2, 3]) {
+ const context = await browser.newContext({
+ baseURL: testInfo.project.use.baseURL as string,
+ viewport: { width: 1000, height: 900 },
+ deviceScaleFactor,
+ })
+ try {
+ const page = await context.newPage()
+ const expectNoBrowserErrors = captureBrowserErrors(page)
+ let tileRequests = 0
+ await page.route('**/api/datasets/synthetic-dataset/artifacts/synthetic-revision/teaching-preview/**', async (route) => {
+ const url = new URL(route.request().url())
+ if (url.pathname.endsWith('/slide.dzi')) return route.fulfill({ status: 200, contentType: 'application/xml', body: dzi })
+ const tile = url.pathname.match(/\/(\d+)\/(\d+)_(\d+)\.png$/)
+ if (!tile) return route.fulfill({ status: 404 })
+ tileRequests++
+ return route.fulfill({ path: `e2e/fixtures/teaching-landmarks/landmarks_files/${tile[1]}/${tile[2]}_${tile[3]}.png` })
+ })
+
+ await page.goto('/assets/e2e/fixtures/teaching-preview.html')
+ await expect(page.getByTestId('teaching-ready')).toHaveText('Image ready', { timeout: 20_000 })
+ await expect.poll(() => tileRequests).toBeGreaterThan(0)
+ await expect.poll(() => page.evaluate(() => window.devicePixelRatio)).toBe(deviceScaleFactor)
+
+ const point = await page.evaluate(() => {
+ const viewer = window.__teachingViewer!
+ const imagePoint = viewer.viewport.pixelFromPoint(
+ viewer.viewport.imageToViewportCoordinates(320, 160),
+ true,
+ )
+ const rect = document.querySelector('.teaching-slide-viewport')!.getBoundingClientRect()
+ return { x: rect.left + imagePoint.x, y: rect.top + imagePoint.y }
+ })
+ await page.mouse.click(point.x, point.y)
+ const [actualX, actualY] = (await page.getByTestId('teaching-location').innerText()).split(',').map(Number)
+ expect(Math.abs(actualX - 0.5)).toBeLessThanOrEqual(1 / 640)
+ expect(Math.abs(actualY - 0.5)).toBeLessThanOrEqual(1 / 320)
+ await page.screenshot({ path: testInfo.outputPath(`synthetic-landmarks-dpr-${deviceScaleFactor}.png`) })
+ expectNoBrowserErrors()
+ } finally {
+ await context.close()
+ }
+ }
+})
diff --git a/frontend/e2e/viewer-resource-budget.pw.ts b/frontend/e2e/viewer-resource-budget.pw.ts
new file mode 100644
index 0000000..d2878ac
--- /dev/null
+++ b/frontend/e2e/viewer-resource-budget.pw.ts
@@ -0,0 +1,321 @@
+import { expect, test, type Page, type Route } from '@playwright/test'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { captureBrowserErrors } from './browserErrors'
+
+const here = path.dirname(fileURLToPath(import.meta.url))
+const datasetId = 'ca38d59a-08ce-44a2-aaf2-cb96bd147bdf'
+const replacementDatasetId = 'b293c4f0-a2ba-4242-a81f-5a208a364cbb'
+const artifactRevision = 'fcafc4bf-2350-470c-aa3a-ad5f5a0d9734'
+const packageSha256 = 'a'.repeat(64)
+const slideId = `local:${datasetId}`
+const fixtureDzi = ''
+
+const slide = {
+ id: datasetId, displayName: 'Concurrent synthetic landmarks.ome.tif', sourceBytes: 100,
+ format: 'OME_TIFF', status: 'READY', detail: 'Synthetic image ready', outputPath: '', sha256: packageSha256,
+ selectedSeries: 0, width: 640, height: 320, downsample: 1, estimatedOutputBytes: 0,
+ projectedFileBytes: 0, projectedFileLowerBytes: 0, projectedFileUpperBytes: 0,
+ cropX: 0, cropY: 0, cropWidth: 640, cropHeight: 320, sourceFingerprint: 'synthetic-source',
+ configurationRevision: 'synthetic-configuration', currentArtifactRevision: '', approvedArtifactRevision: '',
+}
+const replacementSlide = {
+ ...slide,
+ id: replacementDatasetId,
+ displayName: 'Replacement synthetic landmarks.ome.tif',
+ sourceFingerprint: 'replacement-synthetic-source',
+ configurationRevision: 'replacement-synthetic-configuration',
+}
+
+const studyDraft = {
+ id: 'concurrent-resource-draft', name: 'Concurrent resource draft', revision: 1,
+ associations: { teachingSlides: { [slideId]: {
+ referenceId: slideId, viewerSlideId: slideId, datasetId, artifactRevision, packageSha256,
+ configurationRevision: 'synthetic-configuration', sourceFingerprint: 'synthetic-source', outputWidth: 640, outputHeight: 320,
+ provenance: { series: 0, viewRevision: 'synthetic-view', crop: { x: 0, y: 0, width: 640, height: 320 }, downsample: 1 },
+ } } },
+ issues: [], previewChecksum: 'exact-synthetic-checksum', reviewedTaskIds: [], approvedChecksum: '', updatedAt: 1,
+ definition: {
+ schema: 'pathlab.study-pack/1', packKey: 'concurrent-resource', version: 1, title: 'Resource test', author: 'Synthetic fixture',
+ license: 'CC-BY-4.0', provenance: 'Synthetic Playwright fixture', revision: 'synthetic-r1', languages: ['en'],
+ slides: [{ viewerSlideId: slideId, sha256: packageSha256, displayName: 'Concurrent synthetic landmarks' }],
+ tasks: [{ id: 'spatial-landmark', type: 'spatial', slideId, prompt: 'Select the synthetic landmark.', targetX: .45, targetY: .4,
+ targetWidth: .1, targetHeight: .2, tolerance: .05, options: [], answerKey: '', hints: ['Synthetic hint'], explanation: 'Synthetic explanation.', sources: [] }],
+ },
+}
+
+type ResourceSnapshot = {
+ viewerCount: number; trackedViewerCount: number; activeLoaderJobs: number; queuedLoaderJobs: number
+ activeLoaders: number; scheduledLoaders: number; waitingLoaders: number; maxLoaderJobs: number; maxCacheTiles: number; cachedTiles: number
+ peaks: { activeLoaderJobs: number; queuedLoaderJobs: number; cachedTiles: number; viewers: number }
+ viewers: Array<{ id: string; role: string; retired: boolean; jobsInProgress: number; queuedJobs: number; runtimeJobLimit: number
+ cacheLimit: number; cachedTiles: number; bounds: { x: number; y: number; width: number; height: number } | null
+ center: { x: number; y: number } | null; zoom: number | null; container: { width: number; height: number } }>
+}
+
+declare global {
+ interface Window { __forgeViewerResourceSnapshot?: () => ResourceSnapshot }
+}
+
+async function installOverlapFixture(page: Page) {
+ const state = { activeTiles: 0, maxActiveTiles: 0, tileRequests: 0, teachingTileRequests: 0, replacementTileRequests: 0, releaseTiles: () => {} }
+ let release!: () => void
+ const tileGate = new Promise((resolve) => { release = resolve })
+ state.releaseTiles = release
+ await page.route('**/api/**', async (route: Route) => {
+ const url = new URL(route.request().url())
+ if (url.pathname === '/api/session') return route.fulfill({ json: {}, headers: { 'X-Forge-CSRF': 'synthetic' } })
+ if (url.pathname === '/api/datasets') return route.fulfill({ json: { datasets: [slide, replacementSlide] }, headers: { ETag: 'resource-budget-fixture' } })
+ if (url.pathname === '/api/capabilities') return route.fulfill({ json: {
+ conversionRuntime: 'Synthetic fixture', derivativeRuntime: 'Synthetic fixture', vsiConversion: false,
+ dziGeneration: false, downsamples: [1, 2, 4], usableBytes: 1_000_000, effectiveCapacityBytes: 2_000_000,
+ } })
+ if (url.pathname === '/api/features') return route.fulfill({ json: { features: [] } })
+ if (url.pathname === '/api/viewer/connection') return route.fulfill({ json: {
+ connected: false, viewerUrl: '', deviceName: '', scopes: [], conversionMode: 'PREPARED_DZI_V2',
+ } })
+ if (url.pathname === '/api/viewer/sync') return route.fulfill({ json: { items: [], folders: [], conflicts: [] } })
+ if (url.pathname === '/api/study/drafts') return route.fulfill({ json: [studyDraft] })
+ if (url.pathname === '/api/analysis/runs') return route.fulfill({ json: [] })
+ if (url.pathname.endsWith('/annotations')) return route.fulfill({ json: { annotations: [] } })
+ if (url.pathname.endsWith('/artifacts')) return route.fulfill({ json: { currentRevision: '', approvedRevision: '', revisions: [] } })
+ if (url.pathname.endsWith('/slide.dzi')) {
+ return route.fulfill({ status: 200, contentType: 'application/xml', body: fixtureDzi, headers: { 'x-pathlab-preview-mode': 'direct' } })
+ }
+ if (url.pathname.endsWith('/thumbnail')) return route.fulfill({ path: path.join(here, 'fixtures', 'teaching-landmarks', 'landmarks_files', '9', '0_0.png') })
+ const tile = url.pathname.match(/\/(\d+)\/(\d+)_(\d+)\.png$/)
+ if (tile) {
+ if (url.pathname.includes('/teaching-preview/')) state.teachingTileRequests++
+ if (url.pathname.includes(replacementDatasetId)) state.replacementTileRequests++
+ state.activeTiles++
+ state.tileRequests++
+ state.maxActiveTiles = Math.max(state.maxActiveTiles, state.activeTiles)
+ try {
+ await tileGate
+ const tilePath = path.join(here, 'fixtures', 'teaching-landmarks', 'landmarks_files', tile[1], `${tile[2]}_${tile[3]}.png`)
+ return await route.fulfill({ path: tilePath })
+ } finally {
+ state.activeTiles--
+ }
+ }
+ return route.fulfill({ status: 404, json: { detail: `No resource fixture for ${url.pathname}` } })
+ })
+ return state
+}
+
+async function resourceSnapshot(page: Page) {
+ return page.evaluate(() => window.__forgeViewerResourceSnapshot?.() || null)
+}
+
+function expectResourceBounds(snapshot: ResourceSnapshot) {
+ expect(snapshot.viewerCount).toBeLessThanOrEqual(3)
+ expect(snapshot.activeLoaderJobs).toBeLessThanOrEqual(2)
+ expect(snapshot.activeLoaders).toBeLessThanOrEqual(2)
+ expect(snapshot.queuedLoaderJobs).toBeGreaterThanOrEqual(0)
+ expect(snapshot.maxLoaderJobs).toBe(2)
+ expect(snapshot.maxCacheTiles).toBe(96)
+ // OSD's maxImageCacheCount is an eviction target that can be exceeded while
+ // tiles are loading/drawing. Assert configured limits and report actual
+ // counts separately instead of treating that soft cap as a byte ceiling.
+ expect(snapshot.cachedTiles).toBeGreaterThanOrEqual(0)
+ const activeCacheOwners = snapshot.viewers.filter((viewer) => !viewer.retired)
+ expect(activeCacheOwners.reduce((total, viewer) => total + viewer.cacheLimit, 0)).toBeLessThanOrEqual(96)
+ expect(activeCacheOwners.every((viewer) => viewer.cacheLimit <= 32 && viewer.cachedTiles >= 0)).toBe(true)
+ expect(snapshot.peaks.activeLoaderJobs).toBeLessThanOrEqual(2)
+ expect(snapshot.peaks.cachedTiles).toBeGreaterThanOrEqual(0)
+}
+
+test('bounds all concurrent workspace and Study preview viewers while resize preserves main slide view', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== 'chromium')
+ test.setTimeout(90_000)
+ const fixture = await installOverlapFixture(page)
+ const expectNoBrowserErrors = captureBrowserErrors(page)
+ await page.setViewportSize({ width: 1440, height: 900 })
+ await page.goto('/assets/')
+
+ const workspace = page.getByTestId('forge-osd')
+ const dialog = page.getByRole('dialog', { name: 'Study authoring workspace' })
+ const teachingViewport = dialog.getByTestId('teaching-slide-viewport')
+ const mainCanvas = workspace.locator('.openseadragon-canvas').first()
+ await expect(page.locator('.forge-viewer-header').getByText(slide.displayName, { exact: true })).toBeVisible()
+ await expect.poll(() => fixture.activeTiles, { timeout: 15_000 }).toBe(2)
+ await expect.poll(async () => (await resourceSnapshot(page))?.viewerCount ?? 0).toBe(2)
+
+ const canvasBox = await mainCanvas.boundingBox()
+ expect(canvasBox).not.toBeNull()
+ await page.mouse.move(canvasBox!.x + canvasBox!.width / 2, canvasBox!.y + canvasBox!.height / 2)
+ await page.mouse.wheel(0, -360)
+ await page.mouse.move(canvasBox!.x + canvasBox!.width / 2, canvasBox!.y + canvasBox!.height / 2)
+ await page.mouse.down()
+ await page.mouse.move(canvasBox!.x + canvasBox!.width / 2 + 54, canvasBox!.y + canvasBox!.height / 2 + 28, { steps: 5 })
+ await page.mouse.up()
+ await page.waitForTimeout(250)
+
+ const viewerInstance = await workspace.getAttribute('data-viewer-instance-id')
+ await page.getByRole('button', { name: 'Zoom in' }).click()
+ await expect.poll(async () => {
+ const snapshot = await resourceSnapshot(page)
+ return snapshot?.viewers.find((viewer) => viewer.role === 'workspace-primary')?.zoom || 0
+ }).toBeGreaterThan(1.01)
+ const zoomedCanvasBox = await mainCanvas.boundingBox()
+ expect(zoomedCanvasBox).not.toBeNull()
+ await page.mouse.move(zoomedCanvasBox!.x + zoomedCanvasBox!.width / 2, zoomedCanvasBox!.y + zoomedCanvasBox!.height / 2)
+ await page.mouse.down()
+ await page.mouse.move(zoomedCanvasBox!.x + zoomedCanvasBox!.width / 2 + 54, zoomedCanvasBox!.y + zoomedCanvasBox!.height / 2 + 28, { steps: 5 })
+ await page.mouse.up()
+ await page.waitForTimeout(250)
+ const before = await resourceSnapshot(page) as ResourceSnapshot | null
+ expect(before).not.toBeNull()
+ expectResourceBounds(before!)
+ const mainBefore = before!.viewers.find((viewer) => viewer.role === 'workspace-primary')
+ expect(mainBefore?.center).not.toBeNull()
+ expect(mainBefore?.zoom).toBeGreaterThan(0)
+
+ try {
+ await page.getByRole('button', { name: 'Study Pack authoring' }).click()
+ const preview = dialog.getByRole('article', { name: 'Exact faculty preview' })
+ await expect(preview).toBeVisible()
+ await preview.scrollIntoViewIfNeeded()
+ await expect(teachingViewport).toBeVisible()
+ await expect.poll(async () => {
+ const snapshot = await resourceSnapshot(page)
+ return snapshot?.viewers.find((viewer) => viewer.role === 'teaching-preview')?.center ? 1 : 0
+ }).toBe(1)
+ const teachingZoomBefore = ((await resourceSnapshot(page)) as ResourceSnapshot).viewers.find((viewer) => viewer.role === 'teaching-preview')!.zoom!
+ await preview.getByRole('button', { name: 'Zoom teaching slide in' }).click()
+ await expect.poll(async () => (await resourceSnapshot(page))?.viewerCount ?? 0).toBe(3)
+ await expect.poll(async () => {
+ const snapshot = await resourceSnapshot(page)
+ return snapshot?.viewers.find((viewer) => viewer.role === 'teaching-preview')?.zoom ?? 0
+ }).toBeGreaterThan(teachingZoomBefore + .05)
+ await expect.poll(async () => (await resourceSnapshot(page))?.waitingLoaders ?? 0).toBeGreaterThan(0)
+ const contended = await resourceSnapshot(page) as ResourceSnapshot
+ expectResourceBounds(contended)
+ expect(contended.activeLoaderJobs).toBe(2)
+ expect(contended.waitingLoaders).toBeGreaterThan(0)
+ expect(fixture.activeTiles).toBe(2)
+ expect(contended.viewers.map((viewer) => viewer.role).sort()).toEqual(['teaching-preview', 'workspace-overview', 'workspace-primary'])
+ expect(contended.viewers.reduce((total, viewer) => total + viewer.cacheLimit, 0)).toBe(96)
+ expect(contended.viewers.every((viewer) => viewer.cacheLimit === 32)).toBe(true)
+ expect(await page.locator('.openseadragon-container').count()).toBe(3)
+
+ for (const viewport of [{ width: 390, height: 844 }, { width: 1440, height: 900 }]) {
+ await page.setViewportSize(viewport)
+ await expect(dialog).toBeVisible()
+ await expect(workspace).toHaveAttribute('data-viewer-instance-id', viewerInstance!)
+ const resized = await resourceSnapshot(page) as ResourceSnapshot
+ expectResourceBounds(resized)
+ const main = resized.viewers.find((viewer) => viewer.role === 'workspace-primary')
+ expect(main?.id).toBe(mainBefore!.id)
+ expect(main?.center?.x).toBeCloseTo(mainBefore!.center!.x, 3)
+ expect(main?.center?.y).toBeCloseTo(mainBefore!.center!.y, 3)
+ expect(main?.zoom).toBeGreaterThan(1)
+ expect(main?.bounds?.width).toBeGreaterThan(0)
+ expect(main?.bounds?.height).toBeGreaterThan(0)
+ const documentWidths = await page.evaluate(() => ({
+ client: document.documentElement.clientWidth,
+ document: document.documentElement.scrollWidth,
+ body: document.body.scrollWidth,
+ }))
+ expect(documentWidths.document).toBeLessThanOrEqual(documentWidths.client)
+ expect(documentWidths.body).toBeLessThanOrEqual(documentWidths.client)
+ const previewWidth = await teachingViewport.evaluate((element) => element.getBoundingClientRect().width)
+ expect(previewWidth).toBeLessThanOrEqual(viewport.width)
+ if (viewport.width === 1440) {
+ expect(main?.zoom).toBeCloseTo(mainBefore!.zoom!, 3)
+ expect(main?.bounds?.x).toBeCloseTo(mainBefore!.bounds!.x, 3)
+ expect(main?.bounds?.y).toBeCloseTo(mainBefore!.bounds!.y, 3)
+ expect(main?.bounds?.width).toBeCloseTo(mainBefore!.bounds!.width, 3)
+ expect(main?.bounds?.height).toBeCloseTo(mainBefore!.bounds!.height, 3)
+ }
+ expect(fixture.maxActiveTiles).toBeLessThanOrEqual(2)
+ }
+ } finally {
+ fixture.releaseTiles()
+ }
+
+ await expect.poll(() => fixture.activeTiles, { timeout: 20_000 }).toBe(0)
+ await expect.poll(() => fixture.teachingTileRequests, { timeout: 20_000 }).toBeGreaterThan(0)
+ await expect.poll(async () => {
+ const snapshot = await resourceSnapshot(page)
+ return snapshot && snapshot.queuedLoaderJobs === 0 && snapshot.waitingLoaders === 0 && snapshot.scheduledLoaders === 0
+ ? 0 : (snapshot?.queuedLoaderJobs || 1)
+ }, { timeout: 20_000 }).toBe(0)
+ await expect(dialog.getByText('Exact associated teaching pixels loaded. Inspect every question location before reviewing.')).toBeVisible()
+ const settled = await resourceSnapshot(page) as ResourceSnapshot
+ expectResourceBounds(settled)
+ expect(settled.activeLoaderJobs).toBe(0)
+ expect(settled.queuedLoaderJobs).toBe(0)
+ expect(settled.waitingLoaders).toBe(0)
+ expect(settled.scheduledLoaders).toBe(0)
+ expect(settled.peaks.activeLoaderJobs).toBeLessThanOrEqual(2)
+ console.log('OSD cache configuration and observed counts', JSON.stringify({
+ configured: settled.viewers.map(({ role, cacheLimit }) => ({ role, cacheLimit })),
+ actualLoadedTiles: settled.viewers.map(({ role, cachedTiles }) => ({ role, cachedTiles })),
+ aggregateActualLoadedTiles: settled.cachedTiles,
+ peakAggregateActualLoadedTiles: settled.peaks.cachedTiles,
+ }))
+ expect(fixture.maxActiveTiles).toBeLessThanOrEqual(2)
+ expect(await page.locator('.openseadragon-container').count()).toBe(3)
+ expect(await workspace.getAttribute('data-viewer-instance-id')).toBe(viewerInstance)
+ await page.screenshot({ path: testInfo.outputPath('study-preview-loaded-landmarks-desktop.png') })
+ await page.setViewportSize({ width: 390, height: 844 })
+ await expect(dialog).toBeVisible()
+ const phoneWidths = await page.evaluate(() => ({ client: document.documentElement.clientWidth, document: document.documentElement.scrollWidth, body: document.body.scrollWidth }))
+ expect(phoneWidths.document).toBeLessThanOrEqual(phoneWidths.client)
+ expect(phoneWidths.body).toBeLessThanOrEqual(phoneWidths.client)
+ expect(await teachingViewport.evaluate((element) => element.getBoundingClientRect().width)).toBeLessThanOrEqual(390)
+ await page.screenshot({ path: testInfo.outputPath('study-preview-loaded-landmarks-phone.png') })
+ expectNoBrowserErrors()
+})
+
+test('replaces the main slide while source tile requests are held and retires the old viewers cleanly', async ({ page }) => {
+ test.skip(test.info().project.name !== 'chromium')
+ test.setTimeout(60_000)
+ const fixture = await installOverlapFixture(page)
+ const expectNoBrowserErrors = captureBrowserErrors(page)
+ await page.setViewportSize({ width: 1440, height: 900 })
+ await page.goto('/assets/')
+
+ const workspace = page.getByTestId('forge-osd')
+ await expect(page.locator('.forge-viewer-header').getByText(slide.displayName, { exact: true })).toBeVisible()
+ await expect.poll(() => fixture.activeTiles, { timeout: 15_000 }).toBe(2)
+ const initialInstanceId = await workspace.getAttribute('data-viewer-instance-id')
+ const initial = await resourceSnapshot(page) as ResourceSnapshot
+ expectResourceBounds(initial)
+ expect(initial.activeLoaderJobs).toBe(2)
+ expect(initial.viewers.map((viewer) => viewer.role).sort()).toEqual(['workspace-overview', 'workspace-primary'])
+
+ await page.getByRole('button', { name: /Replacement synthetic landmarks\.ome\.tif/ }).click()
+ await expect(page.locator('.forge-viewer-header').getByText(replacementSlide.displayName, { exact: true })).toBeVisible()
+ await expect.poll(() => workspace.getAttribute('data-viewer-instance-id')).not.toBe(initialInstanceId)
+ const replacement = await resourceSnapshot(page) as ResourceSnapshot
+ expectResourceBounds(replacement)
+ expect(replacement.viewerCount).toBe(2)
+ expect(replacement.activeLoaderJobs).toBeLessThanOrEqual(2)
+ expect(replacement.viewers.filter((viewer) => !viewer.retired).map((viewer) => viewer.role).sort()).toEqual(['workspace-overview', 'workspace-primary'])
+ const retiringOldPair = replacement.viewers.filter((viewer) => viewer.retired)
+ expect(retiringOldPair).toHaveLength(2)
+ expect(retiringOldPair.every((viewer) => viewer.jobsInProgress > 0)).toBe(true)
+ expect(replacement.trackedViewerCount).toBe(4)
+ expect(await page.locator('.openseadragon-container').count()).toBe(2)
+
+ fixture.releaseTiles()
+ await expect.poll(() => fixture.activeTiles, { timeout: 20_000 }).toBe(0)
+ await expect.poll(() => fixture.replacementTileRequests, { timeout: 20_000 }).toBeGreaterThan(0)
+ await expect.poll(async () => {
+ const snapshot = await resourceSnapshot(page)
+ return snapshot?.viewers.find((viewer) => viewer.role === 'workspace-primary')?.jobsInProgress ?? -1
+ }, { timeout: 20_000 }).toBe(0)
+ await expect.poll(async () => (await resourceSnapshot(page))?.trackedViewerCount ?? -1, { timeout: 20_000 }).toBe(2)
+ const settled = await resourceSnapshot(page) as ResourceSnapshot
+ expectResourceBounds(settled)
+ expect(settled.activeLoaderJobs).toBe(0)
+ expect(settled.queuedLoaderJobs).toBe(0)
+ expect(settled.waitingLoaders).toBe(0)
+ expect(settled.scheduledLoaders).toBe(0)
+ expect(settled.viewers.every((viewer) => !viewer.retired)).toBe(true)
+ expect(await workspace.getAttribute('data-viewer-instance-id')).not.toBe(initialInstanceId)
+ expect(await page.locator('.openseadragon-container').count()).toBe(2)
+ expectNoBrowserErrors()
+})
diff --git a/frontend/e2e/workspace-states.pw.ts b/frontend/e2e/workspace-states.pw.ts
new file mode 100644
index 0000000..f3819f8
--- /dev/null
+++ b/frontend/e2e/workspace-states.pw.ts
@@ -0,0 +1,136 @@
+import { expect, test, type Page, type Route } from '@playwright/test'
+import { captureBrowserErrors } from './browserErrors'
+
+type StateOptions = {
+ datasets?: unknown[]
+ connected?: boolean
+ scopes?: string[]
+ studyFailure?: boolean
+ featureFailureOnOpen?: boolean
+ holdFeatureLoadOnOpen?: boolean
+}
+
+async function installWorkspaceState(page: Page, options: StateOptions = {}) {
+ const expectNoBrowserErrors = captureBrowserErrors(page, (status, responseUrl) => {
+ if (status !== 503) return false
+ const pathname = new URL(responseUrl).pathname
+ return Boolean(options.studyFailure && pathname === '/api/study/drafts')
+ || Boolean(options.featureFailureOnOpen && pathname === '/api/features')
+ })
+ let featuresCalls = 0
+ let releaseFeatureLoad: (() => void) | undefined
+ let signalBootstrapFeatures: (() => void) | undefined
+ const bootstrapFeatures = new Promise((resolve) => { signalBootstrapFeatures = resolve })
+ await page.route('**/api/**', async (route: Route) => {
+ const url = new URL(route.request().url())
+ if (url.pathname === '/api/session') return route.fulfill({ json: {}, headers: { 'X-Forge-CSRF': 'synthetic' } })
+ if (url.pathname === '/api/datasets') return route.fulfill({ json: { datasets: options.datasets ?? [] }, headers: { ETag: 'workspace-state' } })
+ if (url.pathname === '/api/capabilities') return route.fulfill({ json: {
+ conversionRuntime: 'Synthetic fixture', derivativeRuntime: 'Synthetic fixture', vsiConversion: false,
+ dziGeneration: false, downsamples: [1, 2, 4], usableBytes: 1_000_000, effectiveCapacityBytes: 2_000_000,
+ } })
+ if (url.pathname === '/api/viewer/connection') return route.fulfill({ json: {
+ connected: options.connected ?? false, viewerUrl: options.connected ? 'https://viewer.synthetic.invalid' : '',
+ deviceName: options.connected ? 'Synthetic Viewer' : '', scopes: options.scopes ?? [], conversionMode: 'PREPARED_DZI_V2',
+ } })
+ if (url.pathname === '/api/viewer/sync') return route.fulfill({ json: { items: [], folders: [], conflicts: [] } })
+ if (url.pathname === '/api/features') {
+ featuresCalls++
+ if (featuresCalls >= 2) signalBootstrapFeatures?.()
+ if (options.holdFeatureLoadOnOpen && featuresCalls === 3) {
+ await new Promise((resolve) => { releaseFeatureLoad = resolve })
+ }
+ if (options.featureFailureOnOpen && featuresCalls === 3) {
+ return route.fulfill({ status: 503, json: { detail: 'Catalog temporarily unavailable.\nhttps://feature-catalog.synthetic.invalid/very/long/path/that/must-wrap-without-causing-horizontal-overflow' } })
+ }
+ return route.fulfill({ json: { features: [] } })
+ }
+ if (url.pathname === '/api/study/drafts') {
+ if (options.studyFailure) return route.fulfill({ status: 503, json: { detail: 'Draft service unavailable.\nRetry later at https://study.synthetic.invalid/recovery/with/a/long/unbroken/identifier-that-must-wrap-in-a-short-dialog' } })
+ return route.fulfill({ json: [] })
+ }
+ if (url.pathname === '/api/analysis/runs') return route.fulfill({ json: [] })
+ if (url.pathname.endsWith('/annotations')) return route.fulfill({ json: { annotations: [] } })
+ if (url.pathname.endsWith('/artifacts')) return route.fulfill({ json: { currentRevision: '', approvedRevision: '', revisions: [] } })
+ return route.fulfill({ status: 404, json: { detail: `No workspace fixture for ${url.pathname}` } })
+ })
+ return {
+ expectNoBrowserErrors,
+ waitForBootstrapFeatures: () => bootstrapFeatures,
+ releaseFeatureLoad: () => releaseFeatureLoad?.(),
+ }
+}
+
+test('shows the empty local and connected Viewer library states without overflowing a phone', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== 'chromium')
+ const fixture = await installWorkspaceState(page, { connected: true, scopes: ['library:read'] })
+ await page.setViewportSize({ width: 390, height: 480 })
+ await page.goto('/assets/')
+ await page.getByRole('button', { name: 'Local library' }).click()
+ await expect(page.getByText('No local slides')).toBeVisible()
+ expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390)
+ await page.screenshot({ path: testInfo.outputPath('empty-local-library-phone.png') })
+
+ await page.getByRole('button', { name: 'Viewer library' }).click()
+ await expect(page.getByText('No private Viewer slides.')).toBeVisible()
+ expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390)
+ await page.screenshot({ path: testInfo.outputPath('empty-viewer-library-phone.png') })
+ fixture.expectNoBrowserErrors()
+})
+
+test('keeps Study authoring input, error, and close actions reachable in a short viewport without write permission', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== 'chromium')
+ const fixture = await installWorkspaceState(page, { connected: true, scopes: ['library:read'] })
+ await page.setViewportSize({ width: 390, height: 480 })
+ await page.goto('/assets/')
+ await page.getByRole('button', { name: 'Study Pack authoring' }).click()
+ const dialog = page.getByRole('dialog', { name: 'Study authoring workspace' })
+ await expect(dialog.getByRole('heading', { name: 'Study Pack authoring' })).toBeVisible()
+ await expect(dialog.getByRole('button', { name: 'Refresh eligible teaching slides from Viewer' })).toHaveCount(0)
+ const draftName = dialog.getByLabel('Draft name')
+ await draftName.fill('Short viewport draft name stays in state')
+ await page.setViewportSize({ width: 390, height: 320 })
+ await expect(draftName).toHaveValue('Short viewport draft name stays in state')
+ await expect(draftName).toBeFocused()
+ const box = await dialog.boundingBox()
+ expect(box).not.toBeNull()
+ expect(box!.y).toBeGreaterThanOrEqual(0)
+ expect(box!.y + box!.height).toBeLessThanOrEqual(320)
+ await dialog.getByRole('button', { name: 'Close Study authoring' }).scrollIntoViewIfNeeded()
+ await expect(dialog.getByRole('button', { name: 'Close Study authoring' })).toBeInViewport()
+ expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390)
+ await page.screenshot({ path: testInfo.outputPath('study-authoring-short-phone.png'), fullPage: true })
+ fixture.expectNoBrowserErrors()
+})
+
+test('keeps Study draft errors and close control reachable at 320px height', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== 'chromium')
+ const fixture = await installWorkspaceState(page, { studyFailure: true })
+ await page.setViewportSize({ width: 390, height: 320 })
+ await page.goto('/assets/')
+ await page.getByRole('button', { name: 'Study Pack authoring' }).click()
+ const dialog = page.getByRole('dialog', { name: 'Study authoring workspace' })
+ await expect(dialog.getByRole('alert')).toContainText('Draft service unavailable.')
+ await expect(dialog.getByRole('button', { name: 'Retry loading drafts' })).toBeInViewport()
+ await dialog.getByRole('button', { name: 'Close Study authoring' }).scrollIntoViewIfNeeded()
+ await expect(dialog.getByRole('button', { name: 'Close Study authoring' })).toBeInViewport()
+ expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390)
+ fixture.expectNoBrowserErrors()
+})
+
+test('covers Feature Center loading, empty, and multiline catalog error states', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== 'chromium')
+ const fixture = await installWorkspaceState(page, { holdFeatureLoadOnOpen: true, featureFailureOnOpen: true })
+ await page.setViewportSize({ width: 390, height: 480 })
+ await page.goto('/assets/')
+ await fixture.waitForBootstrapFeatures()
+ await page.getByRole('button', { name: 'Feature Center' }).click()
+ const dialog = page.getByRole('dialog', { name: 'Feature Center' })
+ await expect(dialog.getByRole('status')).toContainText('Checking installed features…')
+ fixture.releaseFeatureLoad()
+ await expect(page.getByText(/Catalog temporarily unavailable\./)).toBeVisible()
+ await expect(dialog.getByRole('status')).toContainText('No features are published.')
+ expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390)
+ await page.screenshot({ path: testInfo.outputPath('feature-center-error-phone.png') })
+ fixture.expectNoBrowserErrors()
+})
diff --git a/frontend/package.json b/frontend/package.json
index 6a99ed9..5451bc5 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -6,6 +6,7 @@
"scripts": {
"build": "tsc -b && vite build && node scripts/check-bundle.mjs",
"dev": "vite --host 127.0.0.1",
+ "test:responsive": "playwright test",
"test": "vitest run"
},
"dependencies": {
@@ -17,6 +18,8 @@
"uqr": "0.1.3"
},
"devDependencies": {
+ "@axe-core/playwright": "4.10.2",
+ "@playwright/test": "1.61.1",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "^16.3.0",
"@types/react": "^19.1.0",
diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts
new file mode 100644
index 0000000..3e171a7
--- /dev/null
+++ b/frontend/playwright.config.ts
@@ -0,0 +1,34 @@
+import { tmpdir } from 'node:os'
+import path from 'node:path'
+import { defineConfig, devices } from '@playwright/test'
+
+const outputDir = process.env.PATHLAB_FORGE_PLAYWRIGHT_OUTPUT
+ || path.join(tmpdir(), 'pathlab-forge-responsive-playwright')
+
+export default defineConfig({
+ testDir: './e2e',
+ testMatch: '**/*.pw.ts',
+ outputDir,
+ fullyParallel: true,
+ reporter: 'list',
+ use: {
+ baseURL: 'http://127.0.0.1:5175',
+ screenshot: 'only-on-failure',
+ trace: 'retain-on-failure',
+ viewport: { width: 1280, height: 720 },
+ colorScheme: 'light',
+ reducedMotion: 'no-preference',
+ },
+ projects: [
+ { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
+ { name: 'chromium-touch', use: { ...devices['Pixel 7'] } },
+ { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
+ { name: 'webkit', use: { ...devices['Desktop Safari'] } },
+ ],
+ webServer: {
+ command: 'pnpm exec vite --host 127.0.0.1 --port 5175 --strictPort',
+ url: 'http://127.0.0.1:5175/assets/',
+ reuseExistingServer: false,
+ timeout: 30_000,
+ },
+})
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 1c54cd6..3b4e7dc 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -27,6 +27,12 @@ importers:
specifier: 0.1.3
version: 0.1.3
devDependencies:
+ '@axe-core/playwright':
+ specifier: 4.10.2
+ version: 4.10.2(playwright-core@1.61.1)
+ '@playwright/test':
+ specifier: 1.61.1
+ version: 1.61.1
'@testing-library/jest-dom':
specifier: 6.9.1
version: 6.9.1
@@ -63,6 +69,11 @@ packages:
'@asamuzakjp/css-color@3.2.0':
resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
+ '@axe-core/playwright@4.10.2':
+ resolution: {integrity: sha512-6/b5BJjG6hDaRNtgzLIfKr5DfwyiLHO4+ByTLB0cJgWSM8Ll7KqtdblIS6bEkwSF642/Ex91vNqIl3GLXGlceg==}
+ peerDependencies:
+ playwright-core: '>= 1.0.0'
+
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
@@ -364,6 +375,11 @@ packages:
react: '>= 16.8'
react-dom: '>= 16.8'
+ '@playwright/test@1.61.1':
+ resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==}
+ engines: {node: '>=18'}
+ hasBin: true
+
'@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
@@ -618,6 +634,10 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
+ axe-core@4.10.3:
+ resolution: {integrity: sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==}
+ engines: {node: '>=4'}
+
baseline-browser-mapping@2.11.5:
resolution: {integrity: sha512-xJo6a6YZnwZfnyGmQKWMbVOcii7XRibjOskRh+WJ9UHQoX16xrQrcIgAMQOzfvs8XiLMx6ih/fsLPF73iY2D1A==}
engines: {node: '>=6.0.0'}
@@ -721,6 +741,11 @@ packages:
picomatch:
optional: true
+ fsevents@2.3.2:
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -833,6 +858,16 @@ packages:
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
engines: {node: '>=12'}
+ playwright-core@1.61.1:
+ resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ playwright@1.61.1:
+ resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
+ engines: {node: '>=18'}
+ hasBin: true
+
postcss@8.5.23:
resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
engines: {node: ^10 || ^12 || >=14}
@@ -1094,6 +1129,11 @@ snapshots:
'@csstools/css-tokenizer': 3.0.4
lru-cache: 10.4.3
+ '@axe-core/playwright@4.10.2(playwright-core@1.61.1)':
+ dependencies:
+ axe-core: 4.10.3
+ playwright-core: 1.61.1
+
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
@@ -1335,6 +1375,10 @@ snapshots:
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
+ '@playwright/test@1.61.1':
+ dependencies:
+ playwright: 1.61.1
+
'@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/rollup-android-arm-eabi@4.62.3':
@@ -1550,6 +1594,8 @@ snapshots:
assertion-error@2.0.1: {}
+ axe-core@4.10.3: {}
+
baseline-browser-mapping@2.11.5: {}
browserslist@4.28.7:
@@ -1651,6 +1697,9 @@ snapshots:
optionalDependencies:
picomatch: 4.0.5
+ fsevents@2.3.2:
+ optional: true
+
fsevents@2.3.3:
optional: true
@@ -1755,6 +1804,14 @@ snapshots:
picomatch@4.0.5: {}
+ playwright-core@1.61.1: {}
+
+ playwright@1.61.1:
+ dependencies:
+ playwright-core: 1.61.1
+ optionalDependencies:
+ fsevents: 2.3.2
+
postcss@8.5.23:
dependencies:
nanoid: 3.3.16
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 07494a6..8bb19ef 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -38,7 +38,7 @@ import {
Wrench,
X,
} from '@phosphor-icons/react'
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
import type { FormEvent, ReactNode, Ref } from 'react'
import type OpenSeadragon from 'openseadragon'
import { renderSVG } from 'uqr'
@@ -61,6 +61,7 @@ import { teachingAssociationFor, captureTeachingTarget } from './teachingAssocia
import { BatchReports, type BatchSummary } from './BatchReports'
import type { MaskChannel } from './AnalysisMaskOverlay'
import { DIRECT_PREVIEW_VERSION } from './viewerConfig'
+import { createResponsivePanelsState, isPanelDocked, isPanelOpen, responsivePanelsReducer, type ResponsivePanel } from './responsivePanels'
const ACTIVE_STATUSES = new Set(['VERIFYING_SOURCE', 'INSPECTING', 'QUEUED', 'WAITING_RESOURCES', 'CONVERTING', 'OPTIMIZING_OME', 'VALIDATING', 'GENERATING_DZI', 'DZI_READY'])
const CONVERSION_STATUSES = new Set(['CONVERTING', 'OPTIMIZING_OME', 'VALIDATING', 'GENERATING_DZI', 'DZI_READY'])
@@ -68,7 +69,6 @@ const CANCELLABLE_STATUSES = new Set(['QUEUED', 'WAITING_RESOURCES', ...CONVERSI
const QUEUEABLE_STATUSES = new Set(['READY', 'READY_TO_CONVERT', 'CONVERSION_READY', 'FAILED', 'CANCELLED'])
const NO_ANNOTATIONS: AnnotationRecord[] = []
const DEFAULT_LOCAL_FOLDER = 'Unfiled'
-const isCompactWorkspace = () => typeof window !== 'undefined' && window.innerWidth <= 1180
function viewerVisibility(slide?: Pick) {
if (slide?.visibility) return slide.visibility === 'published' ? 'Published' : 'Private'
@@ -90,10 +90,22 @@ export function App() {
const [seriesByDataset, setSeriesByDataset] = useState>({})
const [artifactByDataset, setArtifactByDataset] = useState>({})
const [viewingRevisionByDataset, setViewingRevisionByDataset] = useState>({})
- const [navigatorOpen, setNavigatorOpen] = useState(
- () => typeof window === 'undefined' || window.innerWidth > 960,
- )
- const [inspectorOpen, setInspectorOpen] = useState(true)
+ const [panels, dispatchPanels] = useReducer(responsivePanelsReducer, typeof window === 'undefined' ? 1440 : window.innerWidth, createResponsivePanelsState)
+ const panelsRef = useRef(panels)
+ panelsRef.current = panels
+ const pendingResponsiveFocus = useRef(null)
+ const previousPanelVisibility = useRef<{ navigator: boolean; inspector: boolean } | null>(null)
+ const updatePanels = (action: Parameters[1]) => {
+ panelsRef.current = responsivePanelsReducer(panelsRef.current, action)
+ dispatchPanels(action)
+ }
+ const closePanel = (panel: ResponsivePanel) => {
+ const root = document.querySelector(panel === 'navigator' ? '#library-navigator' : '.pathlab-viewer-inspector')
+ if (root && document.activeElement instanceof HTMLElement && root.contains(document.activeElement)) pendingResponsiveFocus.current = panel
+ updatePanels({ type: 'close', panel })
+ }
+ const navigatorOpen = isPanelOpen(panels, 'navigator')
+ const inspectorPreferenceOpen = isPanelOpen(panels, 'inspector')
const [railExpanded, setRailExpanded] = useState(false)
const [activeTool, setActiveTool] = useState('pan')
const [selectedAnnotationId, setSelectedAnnotationId] = useState('')
@@ -181,7 +193,7 @@ export function App() {
const selected = datasets.find((item) => item.id === selectedId) ?? datasets[0]
analysisContext.current = `${selected?.id}|${selected?.viewRevision}|${selected?.configurationRevision}`
const overlayRun = analysisRuns.find((run) => run.id === analysisOverlay?.runId)
- const inspectorVisible = inspectorOpen && Boolean(libraryMode === 'viewer' ? remoteLibrary.items.length : selected)
+ const inspectorVisible = inspectorPreferenceOpen && Boolean(libraryMode === 'viewer' ? remoteLibrary.items.length : selected)
const selectedRemote = remoteLibrary.items.find((item) => item.id === selectedRemoteId)
?? remoteLibrary.items[0]
const cropDraft = selected ? cropDrafts[selected.id] : undefined
@@ -273,11 +285,74 @@ export function App() {
}, [folderByDataset])
useEffect(() => {
+ const onResize = () => {
+ const before = panelsRef.current
+ const width = window.innerWidth
+ if (width === before.width) return
+ const next = responsivePanelsReducer(before, { type: 'resize', width })
+ const active = document.activeElement
+ for (const panel of ['navigator', 'inspector'] as const) {
+ if (isPanelOpen(before, panel) && !isPanelOpen(next, panel)
+ && active instanceof HTMLElement && active.closest(panel === 'navigator' ? '#library-navigator' : '.pathlab-viewer-inspector')) {
+ pendingResponsiveFocus.current = panel
+ }
+ }
+ updatePanels({ type: 'resize', width })
+ }
+ window.addEventListener('resize', onResize)
+ return () => window.removeEventListener('resize', onResize)
+ }, [])
+
+ useEffect(() => {
+ const navigator = document.getElementById('library-navigator')
const inspector = document.querySelector('.pathlab-viewer-inspector')
- if (!inspector) return
+ if (!navigator || !inspector) return
+ navigator.inert = !navigatorOpen
+ navigator.setAttribute('aria-hidden', navigatorOpen ? 'false' : 'true')
inspector.inert = !inspectorVisible
inspector.setAttribute('aria-hidden', inspectorVisible ? 'false' : 'true')
- }, [inspectorVisible])
+ const previous = previousPanelVisibility.current ?? { navigator: navigatorOpen, inspector: inspectorVisible }
+ const focusInto = (root: HTMLElement) => {
+ root.setAttribute('tabindex', '-1')
+ const first = root.querySelector('button:not([disabled]), select:not([disabled]), textarea:not([disabled]), input:not([type="hidden"]):not([disabled]), [tabindex="0"]')
+ ;(first ?? root).focus({ preventScroll: true })
+ }
+ if (navigatorOpen && !previous.navigator && !isPanelDocked('navigator', panels.width)) focusInto(navigator)
+ if (inspectorVisible && !previous.inspector && !isPanelDocked('inspector', panels.width)) focusInto(inspector)
+ const restore = pendingResponsiveFocus.current
+ if (restore && !isPanelOpen(panels, restore)) {
+ const replacement = restore === 'navigator'
+ ? document.querySelector(libraryMode === 'viewer' ? '[aria-label="Viewer library"]' : '[aria-label="Local library"]')
+ ?? document.querySelector('[aria-label="Local library"]')
+ : document.querySelector('.pathlab-viewer-stage button[aria-controls="forge-slide-inspector"]')
+ if (replacement && replacement.getClientRects().length) replacement.focus({ preventScroll: true })
+ pendingResponsiveFocus.current = null
+ }
+ previousPanelVisibility.current = { navigator: navigatorOpen, inspector: inspectorVisible }
+ }, [navigatorOpen, inspectorVisible, panels.width, libraryMode])
+
+ useEffect(() => {
+ const surfaces: Array<{ panel: ResponsivePanel; element: HTMLElement | null; visible: boolean }> = [
+ { panel: 'navigator', element: document.getElementById('library-navigator'), visible: navigatorOpen },
+ { panel: 'inspector', element: document.querySelector('.pathlab-viewer-inspector'), visible: inspectorVisible },
+ ]
+ const listeners = surfaces.flatMap(({ panel, element, visible }) => {
+ if (!element || !visible || isPanelDocked(panel, panels.width)) return []
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key !== 'Escape') return
+ const modal = [...document.querySelectorAll('[role="dialog"]')]
+ .find((candidate) => candidate !== element && !candidate.contains(element) && candidate.getClientRects().length > 0)
+ if (modal) return
+ event.preventDefault()
+ event.stopPropagation()
+ if (document.activeElement instanceof HTMLElement && element.contains(document.activeElement)) pendingResponsiveFocus.current = panel
+ updatePanels({ type: 'close', panel })
+ }
+ element.addEventListener('keydown', onKeyDown, true)
+ return [{ element, onKeyDown }]
+ })
+ return () => listeners.forEach(({ element, onKeyDown }) => element.removeEventListener('keydown', onKeyDown, true))
+ }, [navigatorOpen, inspectorVisible, panels.width])
const refresh = useCallback(async () => {
try {
@@ -545,7 +620,7 @@ export function App() {
const syncViewer = async (revealLibrary = true) => {
let epoch = remoteEpoch.current
- if (revealLibrary) { setLibraryMode('viewer'); setNavigatorOpen(true); if (isCompactWorkspace()) setInspectorOpen(false) }
+ if (revealLibrary) { setLibraryMode('viewer'); updatePanels({ type: 'open', panel: 'navigator' }) }
try {
const next = await api.getViewerConnection()
if (epoch !== remoteEpoch.current) return
@@ -899,8 +974,7 @@ export function App() {
onToggleExpanded={() => setRailExpanded((current) => !current)}
onLocalLibrary={() => {
setLibraryMode('local')
- setNavigatorOpen(true)
- if (isCompactWorkspace()) setInspectorOpen(false)
+ updatePanels({ type: 'open', panel: 'navigator' })
}}
onViewerLibrary={() => void syncViewer()}
onImport={() => { setImportError(''); setImportOpen(true) }}
@@ -940,10 +1014,7 @@ export function App() {
folderByDataset={folderByDataset}
onSelect={(id) => {
setSelectedId(id)
- if (isCompactWorkspace()) {
- setNavigatorOpen(false)
- setInspectorOpen(true)
- }
+ if (panels.width <= 1200) updatePanels({ type: 'open', panel: 'inspector' })
}}
onChecked={setSelectedDatasetIds}
onCreateFolder={(name) => setLocalFolders((current) => current.includes(name) ? current : [...current, name])}
@@ -966,8 +1037,7 @@ export function App() {
}}
onResolveConflict={(id, field, resolution) => void remoteAction(() => api.resolveViewerConflict(id, field, resolution), true)}
onCollapse={() => {
- setNavigatorOpen(false)
- window.requestAnimationFrame(() => navigatorButtonRef.current?.focus())
+ closePanel('navigator')
}}
/>
)}
@@ -977,7 +1047,7 @@ export function App() {
viewer={viewer}
onViewer={setViewer}
inspectorOpen={inspectorVisible}
- onInspector={() => setInspectorOpen((current) => !current)}
+ onInspector={() => updatePanels({ type: 'toggle', panel: 'inspector' })}
/>
) : (
{ setSelectedAnnotationId(id); setActiveTool('select') }}
onUpdateAnnotation={(id, geometry) => void updateLocalAnnotation(id, { geometry })}
inspectorOpen={inspectorVisible}
- onInspector={() => setInspectorOpen((current) => !current)}
+ onInspector={() => updatePanels({ type: 'toggle', panel: 'inspector' })}
/>
)}
inspector={libraryMode === 'viewer' ? (
setInspectorOpen(false)}
+ onCollapse={() => closePanel('inspector')}
onKeepOffline={(id) => void remoteAction(() => api.keepViewerSlideOffline(id))}
onCancelOffline={(id) => void remoteAction(() => api.cancelViewerOfflineDownload(id))}
onRemoveOffline={(id) => void remoteAction(() => api.removeViewerSlideOffline(id))}
@@ -1036,7 +1106,7 @@ export function App() {
setCropEditing(editing)
if (editing) setActiveTool('pan')
}}
- onCollapse={() => setInspectorOpen(false)}
+ onCollapse={() => closePanel('inspector')}
onInspect={inspect}
onConfigure={updateConfiguration}
onUpdateView={updateViewDefinition}
@@ -1103,7 +1173,7 @@ export function App() {
isError={Boolean(error)}
onClearError={() => setError('')}
onQueueReady={() => void queueReadySlides()}
- onOpen={() => setInspectorOpen(false)}
+ onOpen={() => closePanel('inspector')}
paused={capabilities?.queuePaused === true}
onPause={() => {
void api.setQueuePaused(capabilities?.queuePaused !== true).then((state) => {
@@ -1223,9 +1293,9 @@ export function App() {
slides={[...teachingSlides, ...datasets.map((dataset) => ({ viewerSlideId: `local:${dataset.id}`, displayName: `${dataset.displayName} (local draft)`, sha256: '' }))]}
teachingArtifacts={Object.entries(artifactByDataset).flatMap(([datasetId, artifacts]) => artifacts.filter((artifact) => artifact.format === 'PREPARED_DZI_V2' && ['READY', 'APPROVED'].includes(artifact.status)).map((artifact) => ({ datasetId, artifactRevision: artifact.id, displayName: `${datasets.find((dataset) => dataset.id === datasetId)?.displayName || datasetId} · ${artifact.name}` })))}
onAssociateTeachingSlide={(id, revision, referenceId, datasetId, artifactRevision) => rememberStudy(api.associateTeachingSlide(id, revision, referenceId, datasetId, artifactRevision))}
- renderSlide={(slide, onLocation, onPixelsLoaded, draft) => {
+ renderSlide={(slide, onLocation, onPixelsLoaded, draft, selectedLocation) => {
const association = teachingAssociationFor(slide, draft.associations)
- return association && slide ? : Select an exact local teaching artifact before reviewing these pixels.
+ return association && slide ? : Select an exact local teaching artifact before reviewing these pixels.
}}
onCaptureSpatial={async (slide, draft) => {
const association = teachingAssociationFor(slide, draft.associations), roi = selectedAnnotations.find((annotation) => annotation.id === selectedAnnotationId)
@@ -1348,7 +1418,7 @@ function ForgeProductRail({
return (