diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1168741..e37d3e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,3 +18,10 @@ jobs: - run: npm run lint - run: npm test - run: npm run build + - run: npx playwright install --with-deps chromium + - run: npm run test:e2e + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: browser-test-results + path: test-results/ diff --git a/.gitignore b/.gitignore index 20c5190..9ecdb81 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ dist/ *.log .vite/ coverage/ +playwright-report/ +test-results/ tools/pipeline/raw/ tools/pipeline/.cache/ __pycache__/ diff --git a/README.md b/README.md index 3674dde..242f6a2 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,12 @@ npm test # unit tests (Vitest) npm run typecheck npm run typecheck:tests # TypeScript checks for tests and Vite config npm run lint # ESLint (type-aware rules on src/**) -npm run build # static site in dist/ (includes a 404.html SPA fallback) -``` +npm run build # static site in dist/ (includes a 404.html SPA fallback) +npx playwright install chromium # once, for browser tests +npm run test:e2e # desktop and phone flows against the production build +``` + +Browser regressions cover selection/dissection Back and Forward, search focus and dismissal, layer scrolling, primary dentition, mobile reading trays, failed-download retry and credits links. CI installs Chromium and runs these after the production build; failed runs retain traces and screenshots in `test-results/`. The built site is fully static. For a sub-path deployment set `DS_BASE`, e.g. `DS_BASE=/dental-scope/ npm run build`; with a relative base (`DS_BASE=./`) deep links switch to hash URLs (`#/tooth/36`) so the build works from any folder. Set `VITE_DS_REPO_URL` to link the “Made by Yoseph” credit and the About dialog to the repository; when it is unset the credit is plain text, so a build never advertises a private repository. The live site is deployed by Vercel from `main`; set `VITE_DS_REPO_URL` and `DS_SITE_URL` in the Vercel project's environment variables. diff --git a/docs/architecture.md b/docs/architecture.md index ff5f8a1..48b4f9a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,7 +14,8 @@ Checked against the code on 2026-09-26. |---|---|---| | Install | `npm install` | Node 22 (as in CI). Nothing else is needed for the app. | | Run | `npm run dev` | Vite dev server on . In dev, `window.ds = { registry, engine }` is exposed for debugging. | -| Unit tests | `npm test` | Vitest, `src/**/*.test.ts`, Node environment (no WebGL) | +| Unit tests | `npm test` | Vitest, `src/**/*.test.ts`, Node environment (no WebGL) | +| Browser tests | `npm run test:e2e` | Playwright, `e2e/`, desktop and phone Chromium against the production build | | Type check | `npm run typecheck` | `tsc -b --noEmit`, strict | | Build | `npm run build` | Type check, then `vite build` into `dist/`. The SEO plugin also writes `404.html`, the tooth entry pages, `robots.txt` and `sitemap.xml` (§9). | | Preview a build | `npm run preview` | | @@ -29,7 +30,7 @@ Build-time environment variables: | `VITE_DS_REPO_URL` | Links the "Made by Yoseph" credit and the About dialog to the repository. When unset these show as plain text, so a build never advertises a private repository. | | `VITE_DS_URL=off` | Stop the app from writing to the address bar (for embedding). | -CI (`.github/workflows/ci.yml`) runs typecheck, test and build on pushes and PRs. Vercel deploys `main` to production and every other branch to a preview. `DS_SITE_URL` and `VITE_DS_REPO_URL` come from the Vercel project's environment variables; unknown deep links fall back to the generated `404.html` app shell. +CI (`.github/workflows/ci.yml`) runs app/test typechecking, lint, unit tests, production build and browser tests on pushes to main and PRs. Browser failures retain screenshots and traces. Vercel deploys `main` to production and every other branch to a preview. `DS_SITE_URL` and `VITE_DS_REPO_URL` come from the Vercel project's environment variables; unknown deep links fall back to the generated `404.html` app shell. ## 2. Principles @@ -65,7 +66,8 @@ src/ en/teeth.json, en/structures.json The text itself (draft) search/search.ts Index builder and scorer state/ - store.ts Zustand store: AppState, initial state, actions, persisted preferences + store.ts Zustand store: AppState, initial state, actions, persisted preferences + sceneTransitions.ts Shared scene reset and adult-scene snapshot for development transitions visibility.ts Pure per-mesh visibility resolver + pluggable filters engine/ Engine.ts Owns renderer, scene, loop, loading, visibility/highlight/explode/section/labels, picking @@ -80,7 +82,7 @@ src/ ui/ React components: App, TopBar, LayersPanel, StructureTree, DetailPanel, Dock (bottom toolbar), CameraControls (orbit, view pictograms / View picker, zoom), SearchPanel, Overlays (About, footer, loading, start hint), icons, - useKeyboard, context (ServicesContext) + useKeyboard, useViewportInsets (panel occlusion), context (ServicesContext) modes/modes.ts 🧩 Mode interface, lesson format, quiz picker (not wired to the UI) styles/ tokens.css (design tokens: paper and ink, one ultramarine accent, light/dark), app.css public/ @@ -97,14 +99,17 @@ docs/ This file, assets, content, sources, research and compar 1. `main.tsx` calls `restorePreferences()` (numbering, theme, orbit mode from `localStorage`), checks WebGL, then `loadManifest()`. 2. It builds the `Registry` (pure, from the manifest), the `Engine` and the search index, and renders `` with all three in `ServicesContext`. 3. `App` mounts the engine on the stage `
`, starts `engine.loadAll()` (staged loading, §7) and `startRouter()`. It sets `data-theme` on ``, installs the keyboard shortcuts (`useKeyboard`) and tells the engine which parts of the canvas are covered by panels (`engine.setInsets`). -4. `startRouter` applies the current URL (select, focus, maybe enter dissection) and then keeps the URL in sync with `selectedId` / `dissectFdi` through `replaceState`. Explicit navigation (search, tree, detail links) calls `pushPath`. `popstate` / `hashchange` re-apply. It also keeps `document.title` in sync. +4. `startRouter` applies the current URL (select, focus, maybe enter dissection) and then keeps the URL in sync with `selectedId` / `dissectFdi` through `replaceState`. Explicit navigation (search, tree, detail links) uses `navigate(command)` to push an entry after the scene commits. `popstate` / `hashchange` re-apply. It also keeps `document.title` in sync. | URL | Effect | |---|---| | `/` | Overview | | `/tooth/36/` | Select and focus tooth 36 (FDI). The build writes a static entry page here, with its own title. | | `/tooth/36/dissect` | Open the dissection of tooth 36 | -| `/structure/` | Select and focus any structure (served through the `noindex` 404.html shell on static hosts) | +| `/structure/` | Select and focus any structure (served through the `noindex` 404.html shell on static hosts) | +| `/credits/` | Open the native credits dialog over the explorer; translated links use `/credits//`. | + +Explicit UI and canvas selections use `navigate(command)`: URL synchronization is suspended while the command loads and commits the scene, then one history entry is pushed. Route replay cancels pending selection and suppresses history writes. The guided demonstration replaces its route instead of adding history entries. ## 5. State (`src/state/store.ts`) @@ -113,13 +118,15 @@ One vanilla Zustand store. React reads it with `useApp(selector)`, and the engin | Group | Fields | |---|---| | loading | `ready`, `loading` (stage → 0…1), `error` | -| selection | `selectedId`, `hoveredId` | +| selection | `selectedId`, `hoveredId`, `selectionRequest` (target, command and loading/error status) | | visibility | `categories` (id → `on`/`ghost`/`off`), `hidden`, `ghosted` (id → true), `isolateId`, `isolateContext`, `ghostOpacity` | | arch view | `explode` (0…1), `explodePhase` (1 in position / 2 laid out), `labels`, `clip` {enabled, axis, offset, flip}, `view` (preset or null), `autoRotate`, `orbitMode` (`fixed`/`free`) | | tooth dissection | `dissectFdi`, `dissectLevel` (0 whole → 4 canals), `toothExplode` | | UI | `numbering`, `theme`, `searchOpen`, `aboutOpen`, `panel` (layers/tree), `mobileSheet`, `mode` 🧩 | -`numbering`, `theme` and `orbitMode` are saved per viewer in `localStorage`. +`numbering`, `theme` and `orbitMode` are saved per viewer in `localStorage`. + +`state/sceneTransitions.ts` owns the shared study/development reset and the adult scene snapshot restored on leaving development. Pending selection is plain state: only the current request may commit after a download, and failed tooth requests are removed from the engine cache so Retry can load them again. `ui/useViewportInsets.ts` measures the panels, mobile controls and tour; the engine receives only the resulting insets. **Visibility** (`state/visibility.ts`). `resolveMesh(meshKey, ctx)` is the only function that decides whether a mesh is `on`, `ghost`, `faint` (a quieter ghost used for context around a dissected tooth) or `off`. It combines categories, hide/ghost on the structure and its ancestors, tooth shell vs internal layers (`layersActive`), dissection-level rules (`dissectRule`), isolation, and any filters added with `registerVisibilityFilter` (the hook for modes and a future timeline). `revealPatch` makes a structure reachable, and `Engine.selectFromUI` uses it for search, tree, label and deep-link navigation. diff --git a/docs/screenshots/explorer-ui-44/desktop-after.png b/docs/screenshots/explorer-ui-44/desktop-after.png new file mode 100644 index 0000000..b2516a2 Binary files /dev/null and b/docs/screenshots/explorer-ui-44/desktop-after.png differ diff --git a/docs/screenshots/explorer-ui-44/desktop-before.png b/docs/screenshots/explorer-ui-44/desktop-before.png new file mode 100644 index 0000000..47673f4 Binary files /dev/null and b/docs/screenshots/explorer-ui-44/desktop-before.png differ diff --git a/docs/screenshots/explorer-ui-44/guide-after.png b/docs/screenshots/explorer-ui-44/guide-after.png new file mode 100644 index 0000000..03b4fee Binary files /dev/null and b/docs/screenshots/explorer-ui-44/guide-after.png differ diff --git a/docs/screenshots/explorer-ui-44/guide-before.png b/docs/screenshots/explorer-ui-44/guide-before.png new file mode 100644 index 0000000..614cfca Binary files /dev/null and b/docs/screenshots/explorer-ui-44/guide-before.png differ diff --git a/docs/screenshots/explorer-ui-44/mobile-after.png b/docs/screenshots/explorer-ui-44/mobile-after.png new file mode 100644 index 0000000..b01d4b8 Binary files /dev/null and b/docs/screenshots/explorer-ui-44/mobile-after.png differ diff --git a/docs/screenshots/explorer-ui-44/mobile-expanded.png b/docs/screenshots/explorer-ui-44/mobile-expanded.png new file mode 100644 index 0000000..a145c3e Binary files /dev/null and b/docs/screenshots/explorer-ui-44/mobile-expanded.png differ diff --git a/e2e/explorer.spec.ts b/e2e/explorer.spec.ts new file mode 100644 index 0000000..47a437f --- /dev/null +++ b/e2e/explorer.spec.ts @@ -0,0 +1,107 @@ +import { expect, test, type Page } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { + await page.addInitScript(() => localStorage.setItem('ds.guide.seen.v1', 'seen')); +}); + +async function searchTooth(page: Page, fdi: number) { + await page.locator('[data-tour="search"]:visible').click(); + const input = page.getByRole('combobox', { name: 'Search anatomy' }); + await input.fill(`fdi ${fdi}`); + await expect(page.locator('#ds-search-results').getByRole('option').first()).toContainText(`FDI ${fdi}`); + await input.press('Enter'); +} + +test('search selections and dissection support Back and Forward', async ({ page }) => { + await page.goto('/'); + await searchTooth(page, 36); + await expect(page).toHaveURL(/\/tooth\/36\/$/); + await searchTooth(page, 11); + await expect(page).toHaveURL(/\/tooth\/11\/$/); + await page.goBack(); + await expect(page.getByRole('heading', { name: 'Mandibular left first molar' })).toBeVisible(); + await page.goForward(); + await expect(page.getByRole('heading', { name: 'Maxillary right central incisor' })).toBeVisible(); + await page.getByRole('button', { name: 'Explore inside this tooth' }).click(); + await expect(page).toHaveURL(/\/tooth\/11\/dissect$/); + await page.goBack(); + await expect(page.getByRole('button', { name: 'Explore inside this tooth' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Back to the full mouth' })).toHaveCount(0); + await page.getByRole('button', { name: 'Close details', exact: true }).click(); + await expect(page).toHaveURL(/\/$/); + await page.goBack(); + await expect(page.getByRole('heading', { name: 'Maxillary right central incisor' })).toBeVisible(); +}); + +test('search contains keyboard focus and restores its opener', async ({ page }) => { + await page.goto('/'); + const opener = page.locator('[data-tour="search"]:visible'); + await opener.click(); + const input = page.getByRole('combobox', { name: 'Search anatomy' }); + await expect(input).toBeFocused(); + await input.press('Shift+Tab'); + await expect.poll(() => page.locator('.ds-search-layer').evaluate(element => element.contains(document.activeElement))).toBe(true); + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog', { name: 'Search anatomy' })).toBeHidden(); + await expect(opener).toBeFocused(); + await opener.click(); + await page.locator('.ds-search-layer').click({ position: { x: 5, y: 5 } }); + await expect(page.getByRole('dialog', { name: 'Search anatomy' })).toBeHidden(); +}); + +test('desktop layers remain reachable with nerve settings expanded', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop'); + await page.goto('/'); + await page.locator('.ds-nerve-controls summary').click(); + await expect.poll(() => page.locator('.ds-layer-scroll').evaluate(element => element.clientHeight)).toBeGreaterThan(100); + await page.getByRole('button', { name: 'Show only Permanent teeth', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Show Permanent teeth', exact: true })).toHaveAttribute('aria-pressed', 'true'); + await expect(page.locator('.ds-count[aria-label="32 teeth"]')).toHaveText('32'); +}); + +test('primary dentition has an explicit development action', async ({ page }, testInfo) => { + await page.goto('/'); + if (testInfo.project.name === 'phone') await page.getByRole('button', { name: 'Layers', exact: true }).click(); + await page.getByRole('button', { name: 'Open primary dentition', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Make Primary teeth translucent' })).toHaveCount(0); + await expect(page.locator('.ds-development-panel')).toBeVisible(); +}); + +test('phone detail actions stay visible and the reading tray expands', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'phone'); + await page.goto('/tooth/11/'); + const action = page.getByRole('button', { name: 'Explore inside this tooth' }); + await expect(action).toBeInViewport(); + await page.locator('.ds-detail-body').evaluate(element => { element.scrollTop = element.scrollHeight; }); + await expect(action).toBeInViewport(); + const tray = page.locator('.ds-detail'); + const initialHeight = await tray.evaluate(element => element.clientHeight); + await page.getByRole('button', { name: 'Expand details' }).click(); + await expect.poll(() => tray.evaluate(element => element.clientHeight)).toBeGreaterThan(initialHeight + 100); + await expect(action).toBeInViewport(); + await page.getByRole('button', { name: 'Collapse details' }).click(); + await action.click(); + await expect(page).toHaveURL(/\/tooth\/11\/dissect$/); +}); + +test('a failed tooth download can be retried without reloading', async ({ page }) => { + await page.route('**/models/teeth/tooth-11.glb', route => route.abort()); + await page.goto('/'); + await searchTooth(page, 11); + await expect(page.getByRole('alert')).toContainText('This anatomy could not be loaded'); + await page.unroute('**/models/teeth/tooth-11.glb'); + await page.getByRole('button', { name: 'Retry', exact: true }).click(); + await expect(page).toHaveURL(/\/tooth\/11\/$/); + await expect(page.getByRole('alert')).toHaveCount(0); +}); + +test('credits deep links retain theme and keyboard dismissal', async ({ page }) => { + await page.addInitScript(() => localStorage.setItem('ds.theme', 'dark')); + await page.goto('/credits/'); + await expect(page.getByRole('dialog', { name: 'Credits', exact: true })).toBeVisible(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + await expect(page.getByRole('link', { name: 'Roy Namo — GitHub' })).toHaveAttribute('href', 'https://github.com/roynamo'); + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog', { name: 'Credits', exact: true })).toBeHidden(); + await expect(page).toHaveURL(/\/$/); +}); diff --git a/e2e/tour.spec.ts b/e2e/tour.spec.ts new file mode 100644 index 0000000..ddf758e --- /dev/null +++ b/e2e/tour.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from '@playwright/test'; + +test('replay advances when the page became visible before the guide opened', async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem('ds.guide.seen.v1', 'seen'); + Object.defineProperty(document, 'hidden', { configurable: true, value: true }); + }); + await page.goto('/'); + await expect(page.getByRole('status')).toHaveCount(0); + await page.evaluate(() => { + Object.defineProperty(document, 'hidden', { configurable: true, value: false }); + document.dispatchEvent(new Event('visibilitychange')); + }); + await page.getByRole('button', { name: 'Replay guide', exact: true }).click(); + await expect(page.locator('#ds-tour-body')).toContainText('Watch a short demonstration'); + await expect(page.locator('.ds-tour-count')).toHaveText('2 / 17', { timeout: 10_000 }); + await page.getByRole('button', { name: 'Close guide', exact: true }).click(); +}); + +test('the complete guide finishes and can be replayed', async ({ page }) => { + await page.addInitScript(() => localStorage.setItem('ds.guide.seen.v1', 'seen')); + await page.goto('/'); + await expect(page.getByRole('status')).toHaveCount(0); + await page.getByRole('button', { name: 'Replay guide', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Start exploring', exact: true })).toBeVisible({ timeout: process.env.CI ? 180_000 : 90_000 }); + await expect(page.locator('.ds-tour-count')).toHaveText('17 / 17'); + await page.getByRole('button', { name: 'Start exploring', exact: true }).click(); + await expect(page.locator('.ds-tour-card')).toHaveCount(0); + await expect(page).toHaveURL(/\/$/); + await expect(page.getByRole('button', { name: 'Replay guide', exact: true })).toBeFocused(); + await page.getByRole('button', { name: 'Replay guide', exact: true }).click(); + await expect(page.locator('.ds-tour-count')).toHaveText('2 / 17'); + await page.keyboard.press('Escape'); + await expect(page.locator('.ds-tour-card')).toHaveCount(0); +}); + +test('the guide card does not change sides repeatedly within a step', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop'); + await page.addInitScript(() => localStorage.setItem('ds.guide.seen.v1', 'seen')); + await page.goto('/'); + await expect(page.getByRole('status')).toHaveCount(0); + await page.getByRole('button', { name: 'Replay guide', exact: true }).click(); + await expect(page.locator('.ds-tour-count')).toHaveText('2 / 17'); + await expect(page.locator('.ds-tour-cursor')).toBeVisible(); + await page.getByRole('button', { name: 'Pause', exact: true }).click(); + const positions = await page.evaluate(async () => { + const target = document.querySelector('[data-tour="development-primary"]')!; + const measure = target.getBoundingClientRect.bind(target); + let calls = 0; + target.getBoundingClientRect = () => new DOMRect(innerWidth * .65 + (++calls % 2 ? 2 : -2), 150, 100, 32); + const values: string[] = []; + for (let frame = 0; frame < 12; frame++) { + await new Promise(resolve => requestAnimationFrame(() => resolve())); + values.push(document.querySelector('.ds-tour-card')!.dataset.position!); + } + target.getBoundingClientRect = measure; + return values; + }); + expect(new Set(positions).size).toBe(1); + await page.getByRole('button', { name: 'Close guide', exact: true }).click(); +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index 38b0c69..b233ba8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,7 +15,7 @@ import jsxA11y from 'eslint-plugin-jsx-a11y'; export default tseslint.config( { - ignores: ['dist/**', 'public/**', 'tools/pipeline/.cache/**', 'docs/**'], + ignores: ['dist/**', 'public/**', 'tools/pipeline/.cache/**', 'docs/**', '.vite/**', 'playwright-report/**', 'test-results/**'], }, js.configs.recommended, { @@ -61,7 +61,7 @@ export default tseslint.config( }, { // Tests run in Node (vitest, `environment: 'node'`). - files: ['**/*.test.ts', 'tools/**'], + files: ['**/*.test.ts', 'tools/**', 'e2e/**', 'playwright.config.ts'], languageOptions: { globals: { ...globals.node } }, }, { diff --git a/package-lock.json b/package-lock.json index 9bb944a..024dc78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@gltf-transform/core": "^4.5.0", "@gltf-transform/extensions": "^4.5.0", "@gltf-transform/functions": "^4.5.0", + "@playwright/test": "^1.64.0", "@types/node": "^22.20.5", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", @@ -1220,6 +1221,22 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@playwright/test": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz", + "integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.11", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", @@ -4977,6 +4994,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index fd52983..34647e8 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "typecheck": "tsc -b --noEmit", "typecheck:tests": "tsc6 -p tsconfig.eslint.json --noEmit", "test": "vitest run", + "test:e2e": "playwright test", "lint": "eslint .", "assets:build": "python3 tools/pipeline/build_assets.py --bp3d tools/pipeline/raw/stl --out tools/pipeline/.cache/build", "assets:compress": "node tools/pipeline/compress.mjs tools/pipeline/.cache/build public/models", @@ -33,6 +34,7 @@ "@gltf-transform/core": "^4.5.0", "@gltf-transform/extensions": "^4.5.0", "@gltf-transform/functions": "^4.5.0", + "@playwright/test": "^1.64.0", "@types/node": "^22.20.5", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..e1854a2 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + // Linux CI renders the full 3D scene in software; multi-step navigation needs more time. + timeout: process.env.CI ? 240_000 : 120_000, + expect: { timeout: 30_000 }, + workers: 1, + retries: process.env.CI ? 1 : 0, + reporter: 'list', + use: { baseURL: 'http://127.0.0.1:5176', reducedMotion: 'reduce', trace: 'retain-on-failure', screenshot: 'only-on-failure' }, + projects: [ + { name: 'desktop', use: { ...devices['Desktop Chrome'], viewport: { width: 1280, height: 720 } } }, + // Layout and touch checks use CSS pixels without the cost of a high-DPI framebuffer. + { name: 'phone', use: { ...devices['Pixel 5'], viewport: { width: 390, height: 844 }, deviceScaleFactor: 1 } }, + ], + webServer: { + command: 'npm run preview -- --host 127.0.0.1 --port 5176 --strictPort', + url: 'http://127.0.0.1:5176', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/src/app/router.test.ts b/src/app/router.test.ts index 7e4eea6..6f50277 100644 --- a/src/app/router.test.ts +++ b/src/app/router.test.ts @@ -4,16 +4,17 @@ import { Registry } from '../anatomy/registry'; import type { Manifest } from '../anatomy/types'; import type { Engine } from '../engine/Engine'; import { actions, getState, initialState, setState } from '../state/store'; -import { parsePath, startRouter } from './router'; +import { navigate, parsePath, startRouter } from './router'; const registry = new Registry(JSON.parse(readFileSync('public/models/manifest.json', 'utf8')) as Manifest); let stop: (() => void) | undefined; let url: URL; let events: EventTarget; -const navigate = (_data: unknown, _title: string, path: string) => { url = new URL(path, url); }; -const pushState = vi.fn(navigate); -const replaceState = vi.fn(navigate); +const updateUrl = (_data: unknown, _title: string, path: string) => { url = new URL(path, url); }; +const pushState = vi.fn(updateUrl); +const replaceState = vi.fn(updateUrl); const engine = { + cancelSelection: vi.fn(), selectFromUI: vi.fn((id: string) => { actions.select(id); return Promise.resolve(); }), exploreTooth: vi.fn((fdi: number) => { actions.enterDissect(fdi); return Promise.resolve(); }), } as unknown as Engine; @@ -32,6 +33,31 @@ beforeEach(() => { vi.stubGlobal('document', { title: '' }); }); +describe('explicit selection navigation', () => { + it('pushes one entry per search selection instead of replacing the previous tooth', async () => { + stop = startRouter(engine, registry); + await navigate(() => engine.selectFromUI('tooth-36')); + await navigate(() => engine.selectFromUI('tooth-11')); + expect(pushState.mock.calls.map(call => call[2])).toEqual(['/tooth/36/', '/tooth/11/']); + expect(replaceState).not.toHaveBeenCalled(); + url = new URL('https://example.org/tooth/36/'); + events.dispatchEvent(new Event('popstate')); + await Promise.resolve(); + expect(getState().selectedId).toBe('tooth-36'); + expect(pushState).toHaveBeenCalledTimes(2); + }); + + it('commits selection and dissection as one navigation and ignores an obsolete completion', async () => { + stop = startRouter(engine, registry); + let finish!: () => void; + const previous = navigate(() => new Promise(resolve => { finish = resolve; })); + await navigate(() => { actions.select('tooth-11'); actions.enterDissect(11); }); + finish(); + await previous; + expect(pushState.mock.calls.map(call => call[2])).toEqual(['/tooth/11/dissect']); + }); +}); + afterEach(() => { stop?.(); stop = undefined; diff --git a/src/app/router.ts b/src/app/router.ts index a271f3a..7e09d17 100644 --- a/src/app/router.ts +++ b/src/app/router.ts @@ -70,8 +70,30 @@ export function pathFor(id: string | null, dissectFdi: number | null, registry: export function startRouter(engine: Engine, registry: Registry): () => void { let applying = false; + let navigating = false; + let revision = 0; + + const runNavigation = async (command: NavigationCommand) => { + const push = !getState().guideOpen; + const request = ++revision; + applying = false; + navigating = true; + try { await command(); } + finally { + if (request === revision) { + navigating = false; + const s = getState(); + const next = s.creditsOpen ? `${HASH ? '' : BASE}/${creditsPath(s.lang)}` : pathFor(s.selectedId, s.dissectFdi, registry); + if (next !== currentPath()) writeUrl(next, push); + } + } + }; + navigationHandler = runNavigation; const apply = async (path: string) => { + const request = ++revision; + navigating = false; + engine.cancelSelection(); const r = parsePath(path, registry); applying = true; try { @@ -92,7 +114,7 @@ export function startRouter(engine: Engine, registry: Registry): () => void { await engine.exploreTooth(s.tooth.fdi); } else await engine.selectFromUI(r.id, { focus: true }); } finally { - applying = false; + if (request === revision) applying = false; } }; @@ -102,7 +124,7 @@ export function startRouter(engine: Engine, registry: Registry): () => void { syncTitle(getState().selectedId); const unsub = store.subscribe((s, p) => { if (s.selectedId !== p.selectedId || s.lang !== p.lang) syncTitle(s.selectedId); - if (applying) return; + if (applying || navigating) return; if (s.creditsOpen) { if (!p.creditsOpen || s.lang !== p.lang) writeUrl(`${HASH ? '' : BASE}/${creditsPath(s.lang)}`, !p.creditsOpen); return; @@ -120,19 +142,19 @@ export function startRouter(engine: Engine, registry: Registry): () => void { if (HASH) window.addEventListener('hashchange', onPop); void apply(currentPath()); return () => { + ++revision; + if (navigationHandler === runNavigation) navigationHandler = undefined; unsub(); window.removeEventListener('popstate', onPop); window.removeEventListener('hashchange', onPop); }; } -/** Explicit navigation (search, tree): pushes a history entry. */ -export function pushPath(path: string) { - if (path !== currentPath()) writeUrl(path, true); -} +type NavigationCommand = () => void | Promise; +let navigationHandler: ((command: NavigationCommand) => Promise) | undefined; -/** Push a history entry for the current selection / dissection. */ -export function pushCurrentPath(registry: Registry) { - const { selectedId, dissectFdi } = getState(); - pushPath(pathFor(selectedId, dissectFdi, registry)); +/** One explicit user action creates one history entry, after the scene commits. */ +export async function navigate(command: NavigationCommand) { + if (navigationHandler) await navigationHandler(command); + else await command(); } diff --git a/src/engine/Engine.ts b/src/engine/Engine.ts index 2f7e791..0036933 100644 --- a/src/engine/Engine.ts +++ b/src/engine/Engine.ts @@ -34,6 +34,7 @@ import { renderPixelRatio } from './renderQuality'; import { resolveContent } from '../content/content'; import { SINUS_NOTE } from '../i18n/study'; import { dentitionFrame, skullOverviewFrame } from './framing'; +import { navigate } from '../app/router'; interface MeshEntry { key: string; @@ -240,7 +241,7 @@ export class Engine { this.startView(0); this.labels = new LabelLayer(this.overlay); -this.labels.onClick = (id) => { void this.selectFromUI(id, { focus: false }); }; + this.labels.onClick = (id) => { void navigate(() => this.selectFromUI(id, { focus: false })); }; this.labels.onHover = (id, rect) => { actions.hover(id); if (id && rect) { @@ -285,6 +286,7 @@ this.labels.onClick = (id) => { void this.selectFromUI(id, { focus: false }); }; /* ============================================================== loading */ async loadAll() { + setState({ error: undefined }); try { for (const st of STAGES) { actions.setLoading(st.id, 0); @@ -297,7 +299,7 @@ this.labels.onClick = (id) => { void this.selectFromUI(id, { focus: false }); }; } // idle prefetch of the detailed exemplar tooth const idle = (window as unknown as { requestIdleCallback?: (cb: () => void) => void }).requestIdleCallback ?? ((cb: () => void) => setTimeout(cb, 600)); - idle(() => void this.ensureTooth(EXEMPLAR_TOOTH)); + idle(() => { if (!this.disposed) void this.ensureTooth(EXEMPLAR_TOOTH).catch(() => {}); }); } catch (e) { console.error(e); setState({ error: 'load' }); // shown translated by the loading card @@ -311,9 +313,13 @@ this.labels.onClick = (id) => { void this.selectFromUI(id, { focus: false }); }; const asset = this.registry.get(`tooth-${fdi}`)?.tooth?.asset; if (!asset) return Promise.resolve(); const p = this.loader.load(asset).then((geos) => { + if (this.disposed) return; this.addGeometries(geos); this.loadedTeeth.add(fdi); this.refreshAll(); + }).catch((error: unknown) => { + this.toothLoads.delete(fdi); + throw error; }); this.toothLoads.set(fdi, p); return p; @@ -492,8 +498,8 @@ this.labels.onClick = (id) => { void this.selectFromUI(id, { focus: false }); }; if (s.selectedId !== p.selectedId || s.hoveredId !== p.hoveredId || s.dissectFdi !== p.dissectFdi) this.refreshHighlight(); if (visChanged || s.lang !== p.lang || s.numbering !== p.numbering) this.pointerDirty = true; if (s.clip !== p.clip || s.dissectFdi !== p.dissectFdi) this.refreshClip(); - if (s.clip.enabled && !p.clip.enabled && s.dissectFdi === null) void this.ensureAllTeeth(); - if (s.dissectFdi !== p.dissectFdi && s.dissectFdi !== null) void this.ensureTooth(s.dissectFdi); + if (s.clip.enabled && !p.clip.enabled && s.dissectFdi === null) void this.ensureAllTeeth().catch(() => setState({ error: 'load' })); + if (s.dissectFdi !== p.dissectFdi && s.dissectFdi !== null) void this.ensureTooth(s.dissectFdi).catch(() => {}); if (s.autoRotate !== p.autoRotate) this.rig.controls.autoRotate = s.autoRotate; if (s.theme !== p.theme) this.applyTheme(s.theme); if (s.orbitMode !== p.orbitMode) this.rig.setMode(s.orbitMode); @@ -883,8 +889,8 @@ this.labels.onClick = (id) => { void this.selectFromUI(id, { focus: false }); }; if (moved > 6 || performance.now() - d.t > 600) return; this.setPointer(ev); const hit = this.pick(); - if (hit) void this.selectFromUI(hit, { focus: false, reveal: false }); - else actions.select(null); + if (hit) void navigate(() => this.selectFromUI(hit, { focus: false, reveal: false })); + else void navigate(() => actions.select(null)); }); dom.addEventListener('dblclick', (ev) => { this.setPointer(ev); @@ -1007,15 +1013,18 @@ this.labels.onClick = (id) => { void this.selectFromUI(id, { focus: false }); }; async selectFromUI(id: string, opts: { focus?: boolean; reveal?: boolean } = {}) { const s = this.registry.get(id); if (!s) return; + if (!await this.prepareSelection(id, 'select', s.toothFdi)) return; if (!getState().developmentStage && this.developmentScene.active) { this.developmentScene.finishTransition(); this.refreshVisibility(); } if (id.startsWith('development-')) { actions.setDevelopmentStage(getState().developmentStage ?? 'incisor-transition'); + this.cancelSelection(); this.setView('three-quarter'); return; } else if (getState().developmentStage) actions.setDevelopmentStage(null); const { focus = true, reveal = true } = opts; const fdi = s.toothFdi; const isToothPart = fdi !== undefined && s.id !== `tooth-${fdi}`; + if (!isToothPart && getState().dissectFdi !== null) actions.exitDissect(); if (reveal) setState(revealPatch(this.registry, id, getState())); if (isToothPart) { const st = getState(); @@ -1027,7 +1036,6 @@ this.labels.onClick = (id) => { void this.selectFromUI(id, { focus: false }); }; actions.setDissectLevel(levelShowing(id)); } else if (inTooth && !levelShows(st.dissectLevel, id)) actions.setDissectLevel(levelShowing(id)); } - if (fdi !== undefined) await this.ensureTooth(fdi); actions.select(id); if (focus) this.focus(id); } @@ -1070,14 +1078,44 @@ this.labels.onClick = (id) => { void this.selectFromUI(id, { focus: false }); }; /** Enter on the tooth's assembled pose, even while the mouth/board is moving back. */ async exploreTooth(fdi: number) { + if (!await this.prepareSelection(`tooth-${fdi}`, 'explore', fdi)) return; actions.enterDissect(fdi); - await this.ensureTooth(fdi); // Let the new control tray render and its viewport insets be measured first. await new Promise((resolve) => requestAnimationFrame(() => resolve())); if (this.disposed || getState().dissectFdi !== fdi) return; this.focus(`tooth-${fdi}`, { restPose: true }); } + /** A newer choice, route, reset or scene transition invalidates this request. */ + private async prepareSelection(id: string, kind: 'select' | 'explore', fdi?: number) { + const request = { id, kind, status: 'loading' as const }; + setState({ selectionRequest: request }); + try { + if (fdi !== undefined) await this.ensureTooth(fdi); + return !this.disposed && getState().selectionRequest === request; + } catch { + if (!this.disposed && getState().selectionRequest === request) setState({ selectionRequest: { ...request, status: 'error' } }); + return false; + } + } + + cancelSelection() { setState({ selectionRequest: null }); } + + async retrySelection() { + const request = getState().selectionRequest; + if (!request || request.status !== 'error') return; + if (request.kind === 'explore') await this.exploreTooth(this.registry.require(request.id).toothFdi!); + else await this.selectFromUI(request.id); + } + + async retryAssets() { + await this.loadAll(); + if (getState().clip.enabled && getState().dissectFdi === null) { + try { await this.ensureAllTeeth(); } + catch { setState({ error: 'load' }); } + } + } + focus(id: string, opts: { restPose?: boolean } = {}) { this.cancelArchPreparation?.(); const s = this.registry.get(id); diff --git a/src/engine/selection.test.ts b/src/engine/selection.test.ts new file mode 100644 index 0000000..d219e64 --- /dev/null +++ b/src/engine/selection.test.ts @@ -0,0 +1,74 @@ +import { readFileSync } from 'node:fs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Registry } from '../anatomy/registry'; +import type { Manifest } from '../anatomy/types'; +import { actions, getState, initialState, setState } from '../state/store'; +import { Engine } from './Engine'; + +const registry = new Registry(JSON.parse(readFileSync('public/models/manifest.json', 'utf8')) as Manifest); + +function viewer() { + const load = vi.fn(() => Promise.resolve(new Map())); + const focus = vi.fn(); + const engine: Engine = Object.assign(Object.create(Engine.prototype) as Engine, { + registry, loader: { load }, toothLoads: new Map(), loadedTeeth: new Set(), + developmentScene: { active: false }, disposed: false, + addGeometries: vi.fn(), refreshAll: vi.fn(), focus, + }); + return { engine, load, focus }; +} + +beforeEach(() => setState({ ...initialState })); +afterEach(() => { vi.unstubAllGlobals(); actions.resetAll(); }); + +describe('selection loading', () => { + it('retries a failed tooth download without reloading the page', async () => { + const { engine, load, focus } = viewer(); + load.mockRejectedValueOnce(new Error('offline')); + await engine.selectFromUI('tooth-11'); + expect(getState()).toMatchObject({ selectedId: null, selectionRequest: { id: 'tooth-11', status: 'error' } }); + expect(focus).not.toHaveBeenCalled(); + await engine.retrySelection(); + expect(load).toHaveBeenCalledTimes(2); + expect(getState()).toMatchObject({ selectedId: 'tooth-11', selectionRequest: null }); + }); + + it('deduplicates concurrent loads but commits only the latest selection', async () => { + const { engine, load, focus } = viewer(); + let resolveFirst!: () => void; + load.mockImplementationOnce(() => new Promise(resolve => { resolveFirst = () => resolve(new Map()); })); + const first = engine.selectFromUI('tooth-11'); + await engine.selectFromUI('tooth-36'); + resolveFirst(); + await first; + expect(getState().selectedId).toBe('tooth-36'); + expect(focus.mock.calls).toEqual([['tooth-36']]); + await engine.ensureTooth(11); + expect(load).toHaveBeenCalledTimes(2); + }); + + it.each(['reset', 'route', 'development'] as const)('does not revive selection after %s while downloading', async choice => { + const { engine, load, focus } = viewer(); + let finish!: () => void; + load.mockImplementationOnce(() => new Promise(resolve => { finish = () => resolve(new Map()); })); + const selecting = engine.selectFromUI('tooth-11'); + if (choice === 'reset') actions.resetAll(); + else if (choice === 'route') engine.cancelSelection(); + else actions.setDevelopmentStage('primary'); + finish(); + await selecting; + expect(getState().selectedId).toBeNull(); + expect(focus).not.toHaveBeenCalled(); + }); + + it('does not enter dissection when loading fails and can retry it', async () => { + const { engine, load, focus } = viewer(); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { callback(0); return 1; }); + load.mockRejectedValueOnce(new Error('offline')); + await engine.exploreTooth(11); + expect(getState().dissectFdi).toBeNull(); + await engine.retrySelection(); + expect(getState()).toMatchObject({ dissectFdi: 11, selectionRequest: null }); + expect(focus).toHaveBeenCalledWith('tooth-11', { restPose: true }); + }); +}); diff --git a/src/i18n/interface.ts b/src/i18n/interface.ts new file mode 100644 index 0000000..7b890ff --- /dev/null +++ b/src/i18n/interface.ts @@ -0,0 +1,19 @@ +import { LANGS, type Lang } from './lang'; + +const text = { + retry: ['Retry', 'Försök igen', 'Erneut versuchen', 'Reintentar', 'Iterum conare'], + loadingSelection: ['Loading anatomy…', 'Laddar anatomi…', 'Anatomie wird geladen…', 'Cargando anatomía…', 'Anatomia oneratur…'], + selectionError: ['This anatomy could not be loaded. Please try again.', 'Anatomin kunde inte laddas. Försök igen.', 'Diese Anatomie konnte nicht geladen werden. Bitte erneut versuchen.', 'No se pudo cargar esta anatomía. Inténtelo de nuevo.', 'Haec anatomia onerari non potuit. Iterum conare.'], + expandDetails: ['Expand details', 'Utöka detaljer', 'Details erweitern', 'Ampliar detalles', 'Singula amplifica'], + collapseDetails: ['Collapse details', 'Minska detaljer', 'Details verkleinern', 'Reducir detalles', 'Singula contrahe'], + toothCount: ['teeth', 'tänder', 'Zähne', 'dientes', 'dentes'], + meshCount: ['mesh parts', 'modelldelar', 'Modellteile', 'partes del modelo', 'partes formae'], + primaryDentition: ['Open primary dentition', 'Visa mjölktandsbett', 'Milchgebiss öffnen', 'Abrir dentición temporal', 'Dentitionem deciduam aperi'], + showAll: ['Show all', 'Visa alla', 'Alle anzeigen', 'Mostrar todo', 'Omnia ostende'], + showLess: ['Show fewer', 'Visa färre', 'Weniger anzeigen', 'Mostrar menos', 'Pauciora ostende'], +} satisfies Record; + +export function interfaceText(lang: Lang) { + const index = LANGS.indexOf(lang); + return Object.fromEntries(Object.entries(text).map(([key, values]) => [key, values[index]])) as Record; +} diff --git a/src/main.tsx b/src/main.tsx index f0a6a5f..43a6a00 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -29,6 +29,7 @@ async function boot() { root.render(

{t().fatalWebgl}

); return; } + root.render(
{t().preparing}
); const manifest = await loadManifest(); const registry = new Registry(manifest); const engine = new Engine(registry); diff --git a/src/state/sceneTransitions.ts b/src/state/sceneTransitions.ts new file mode 100644 index 0000000..70bab00 --- /dev/null +++ b/src/state/sceneTransitions.ts @@ -0,0 +1,23 @@ +import type { AppState } from './store'; + +/** Scene settings temporarily saved while the developmental scene is active. */ +export function adultSceneSnapshot(s: AppState) { + return { + categories: s.categories, hidden: s.hidden, ghosted: s.ghosted, + isolateId: s.isolateId, isolateContext: s.isolateContext, + explode: s.explode, explodePhase: s.explodePhase, clip: s.clip, + jawControls: s.jawControls, jawOpening: s.jawOpening, labels: s.labels, + studyView: s.studyView, vesselSide: s.vesselSide, vesselMode: s.vesselMode, + }; +} + +/** Shared reset when entering an adult study preset or a developmental stage. */ +export function clearSceneTools(s: AppState): Partial { + return { + selectedId: null, hoveredId: null, hidden: {}, ghosted: {}, + isolateId: null, isolateContext: false, passageIds: [], + jawControls: false, jawOpening: 0, jawPlaying: false, + explode: 0, explodePhase: 1, toothExplode: 0, + clip: { ...s.clip, enabled: false }, selectionRequest: null, + }; +} diff --git a/src/state/store.test.ts b/src/state/store.test.ts index dff3b52..65fd51f 100644 --- a/src/state/store.test.ts +++ b/src/state/store.test.ts @@ -25,3 +25,24 @@ describe('orbit mode inside a tooth', () => { expect(getState().orbitMode).toBe('free'); }); }); + +describe('scene transitions', () => { + beforeEach(() => actions.resetAll()); + + it('restores the adult study scene after development without reviving playback', () => { + actions.applyStudyPreset('vessels'); + actions.setVesselSide('left'); + const categories = getState().categories; + actions.setDevelopmentStage('primary'); + expect(getState()).toMatchObject({ studyView: null, jawPlaying: false, selectedId: null }); + actions.setDevelopmentStage('early-mixed'); + actions.setDevelopmentStage(null); + expect(getState()).toMatchObject({ studyView: 'vessels', vesselSide: 'left', categories, developmentPlaying: false }); + }); + + it('clears pending selection and incompatible tools on a study transition', () => { + setState({ selectionRequest: { id: 'tooth-11', kind: 'select', status: 'loading' }, jawPlaying: true, explode: 1, hidden: { maxilla: true }, clip: { enabled: true, axis: 'sagittal', offset: 0, flip: false } }); + actions.applyStudyPreset('dentition'); + expect(getState()).toMatchObject({ selectionRequest: null, jawPlaying: false, explode: 0, hidden: {}, clip: { enabled: false } }); + }); +}); diff --git a/src/state/store.ts b/src/state/store.ts index 84af538..84a764f 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -10,6 +10,7 @@ import type { CategoryId, NumberingSystem } from '../anatomy/types'; import { DEFAULT_LANG, isLang, type Lang } from '../i18n/lang'; import type { NerveSide, NerveView } from '../anatomy/nerveViews'; import { DEVELOPMENT_STAGES, type DevelopmentStageId } from '../anatomy/development'; +import { adultSceneSnapshot, clearSceneTools } from './sceneTransitions'; export type ClipAxis = 'sagittal' | 'coronal' | 'axial' | 'view'; export type ModeId = 'explore' | 'learn' | 'quiz' | 'compare'; @@ -57,6 +58,7 @@ export interface AppState { ready: boolean; loading: Record; // stage → 0…1 error?: string; + selectionRequest: { id: string; kind: 'select' | 'explore'; status: 'loading' | 'error' } | null; selectedId: string | null; hoveredId: string | null; @@ -109,6 +111,7 @@ export const initialState: AppState = { nerveView: 'dental', nerveSide: 'both', passageIds: [], jawControls: false, jawSide: 'right', jawOpening: 0, jawPlaying: false, ready: false, + selectionRequest: null, loading: {}, selectedId: null, hoveredId: null, @@ -179,10 +182,8 @@ export const actions = { if (getState().developmentStage) actions.setDevelopmentStage(null); const preset = PRESETS.find(p => p.id === id); if (!preset) return; - setState({ categories: { ...getState().categories, ...preset.state }, studyView: id === 'vessels' || id === 'sinuses' ? id : null, - vesselSide: 'both', vesselMode: 'both', selectedId: null, hoveredId: null, isolateId: null, isolateContext: false, - hidden: {}, ghosted: {}, passageIds: [], labels: id === 'vessels' || id === 'sinuses' ? true : getState().labels, - jawControls: false, jawPlaying: false, jawOpening: 0, explode: 0, explodePhase: 1, toothExplode: 0, clip: { ...getState().clip, enabled: false } }); + setState(s => ({ ...clearSceneTools(s), categories: { ...s.categories, ...preset.state }, studyView: id === 'vessels' || id === 'sinuses' ? id : null, + vesselSide: 'both', vesselMode: 'both', labels: id === 'vessels' || id === 'sinuses' ? true : s.labels })); }, setVesselSide(vesselSide: AppState['vesselSide']) { setState({ vesselSide, selectedId: null, hoveredId: null, isolateId: null, isolateContext: false }); }, setVesselMode(vesselMode: AppState['vesselMode']) { setState({ vesselMode, selectedId: null, hoveredId: null, isolateId: null, isolateContext: false }); }, @@ -196,11 +197,11 @@ export const actions = { if (getState().dissectFdi !== null) actions.exitDissect(); if (developmentStage && !previous.developmentStage) { const s = getState(); - adultSceneBeforeDevelopment = { categories: s.categories, hidden: s.hidden, ghosted: s.ghosted, isolateId: s.isolateId, isolateContext: s.isolateContext, explode: s.explode, explodePhase: s.explodePhase, clip: s.clip, jawControls: s.jawControls, jawOpening: s.jawOpening, labels: s.labels }; + adultSceneBeforeDevelopment = adultSceneSnapshot(s); } const restore = developmentStage ? {} : adultSceneBeforeDevelopment ?? {}; if (!developmentStage) adultSceneBeforeDevelopment = null; - setState((s) => ({ developmentStage, developmentPlaying: playing && developmentStage !== null, selectedId: null, hoveredId: null, hidden: {}, ghosted: {}, isolateId: null, isolateContext: false, passageIds: [], jawControls: false, jawOpening: 0, jawPlaying: false, explode: 0, explodePhase: 1, clip: { ...s.clip, enabled: false }, categories: { ...s.categories, 'primary-teeth': 'on', 'permanent-teeth': 'on', 'alveolar-bone': 'on' }, ...restore })); + setState((s) => ({ ...clearSceneTools(s), studyView: null, developmentStage, developmentPlaying: playing && developmentStage !== null, categories: { ...s.categories, 'primary-teeth': 'on', 'permanent-teeth': 'on', 'alveolar-bone': 'on' }, ...restore })); }, playDevelopment() { actions.setDevelopmentStage(getState().developmentStage ?? DEVELOPMENT_STAGES[0].id, true); @@ -244,7 +245,7 @@ export const actions = { persist(PREF.orbit, initialState.orbitMode); }, select(id: string | null) { - setState((s) => ({ selectedId: id, mobileSheet: id ? 'detail' : 'none', passageIds: id && s.passageIds.includes(id) ? s.passageIds : [] })); + setState((s) => ({ selectedId: id, selectionRequest: null, mobileSheet: id ? 'detail' : 'none', passageIds: id && s.passageIds.includes(id) ? s.passageIds : [] })); }, hover(id: string | null) { if (getState().hoveredId !== id) setState({ hoveredId: id }); @@ -334,6 +335,7 @@ export const actions = { persist(PREF.orbit, m); }, enterDissect(fdi: number) { + setState({ selectionRequest: null }); if (getState().developmentStage) actions.setDevelopmentStage(null); setState(s => ({ developmentStage: null, studyView: null, jawOpening: 0, jawPlaying: false, jawControls: false, passageIds: [], categories: { ...s.categories, 'permanent-teeth': 'on', enamel: 'on', dentin: 'on', cementum: 'on', 'dental-pulp': 'on', 'root-canals': 'on', 'periodontal-ligament': 'on' } })); // Inside a tooth the free orbit is the useful one (pan and focus on a canal or a root); @@ -342,6 +344,7 @@ export const actions = { setState({ dissectFdi: fdi, dissectLevel: 0, toothExplode: 0, isolateId: `tooth-${fdi}`, isolateContext: true, explode: 0, explodePhase: 1, orbitMode: 'free' }); }, exitDissect() { + setState({ selectionRequest: null }); const restore = orbitBeforeTooth; orbitBeforeTooth = null; if (restore) setState({ orbitMode: restore }); diff --git a/src/styles/app.css b/src/styles/app.css index 75524ca..57fa64f 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -1483,6 +1483,9 @@ button.ds-layer-name:hover { padding: 16px 18px; pointer-events: none; } +.ds-loading[role='alert'] { + pointer-events: auto; +} .ds-loading.is-compact { top: auto; bottom: 232px; @@ -1548,13 +1551,21 @@ button.ds-layer-name:hover { position: fixed; inset: 0; z-index: 20; - display: flex; + display: none; + margin: 0; + width: 100vw; + height: 100dvh; + max-width: none; + max-height: none; + border: 0; justify-content: center; align-items: flex-start; padding: 11vh 16px 16px; - background: var(--scrim); + background: transparent; animation: ds-fade 160ms var(--ease); } +.ds-search-layer[open] { display: flex; } +.ds-search-layer::backdrop { background: var(--scrim); } @keyframes ds-fade { from { opacity: 0; @@ -2313,7 +2324,7 @@ button.ds-layer-name:hover { } .ds-evidence a { color: var(--accent); } .ds-segmented--fill { flex-wrap: wrap; } -.ds-layers > .ds-segmented--fill button { flex: 1 0 auto; white-space: nowrap; } +.ds-layer-scroll > .ds-segmented--fill button { flex: 1 0 auto; white-space: nowrap; } .ds-jaw-controls { padding: 12px 16px; width: 560px; @@ -2487,8 +2498,8 @@ button.ds-layer-name:hover { .ds-view-picker-btn { padding-inline: 8px; } .ds-development-heading { flex-wrap: wrap; } .ds-development-heading strong { text-align: left; } - .ds-layers > .ds-segmented--fill { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); } - .ds-layers > .ds-segmented--fill button { white-space: normal; } + .ds-layer-scroll > .ds-segmented--fill { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); } + .ds-layer-scroll > .ds-segmented--fill button { white-space: normal; } } /* Phone proportions: reserve most of the screen for anatomy. Panels scroll @@ -2661,3 +2672,31 @@ button.ds-layer-name:hover { .ds-tour-circle path { animation: none !important; stroke-dashoffset: 0; } .ds-tour-cursor.is-clicking svg, .is-clicking .ds-tour-click { animation: none !important; } } + +/* One scroll owner keeps layers reachable even with expanded study controls. */ +.ds-layer-scroll { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; scrollbar-width: thin; } +.ds-layer-scroll .ds-layer-list { flex: none; overflow: visible; margin: 0; padding: 0; } +.ds-layers > .ds-tabs, .ds-layers > .ds-panel-footer { flex-shrink: 0; } +.ds-nerve-controls summary { cursor: pointer; padding-block: 8px; } +.ds-nerve-controls[open] summary { margin-bottom: 6px; } +.ds-primary-dentition { text-align: left; color: var(--accent); } +.ds-detail-expand { display: none; } +.ds-frequency summary { cursor: pointer; padding-block: 8px; } + +@media (max-width: 1023px), (pointer: coarse) and (max-width: 1366px) { + .ds-layers .ds-layer-scroll { flex: none; overflow: visible; } + .ds-detail.is-mobile-open { display: flex; flex-direction: column; overflow: hidden; height: min(42dvh, 340px); max-height: min(42dvh, 340px); } + .ds-detail.is-mobile-open.is-expanded { height: min(72dvh, calc(var(--visual-height, 100dvh) - 160px)); max-height: min(72dvh, calc(var(--visual-height, 100dvh) - 160px)); } + .ds-detail-expand { display: block; flex-shrink: 0; min-height: 32px; font-size: 11px; color: var(--muted); border-bottom: 1px solid var(--line); } + .ds-detail.is-mobile-open .ds-detail-body { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; font-size: 13px; } + .ds-detail.is-mobile-open .ds-detail-actions { flex-shrink: 0; background: var(--panel-solid); border-top: 1px solid var(--line); } + .ds-detail.is-mobile-open .ds-detail-summary { font-size: 13px; } +} +@media (pointer: coarse) { + .ds-icon-btn, .ds-dot-toggle, .ds-ghost-btn, .ds-tb-btn, .ds-play, .ds-layer-name, .ds-chip--button, .ds-secondary, .ds-primary, .ds-development-stops button, .ds-development-play, .ds-mobile-bar button, .ds-detail-expand { min-height: 44px; } + .ds-icon-btn, .ds-dot-toggle, .ds-ghost-btn { min-width: 44px; } + .ds-layer-row { height: auto; min-height: 44px; } +} +@media (orientation: landscape) and (max-height: 600px) { + .ds-detail.is-mobile-open, .ds-detail.is-mobile-open.is-expanded { height: auto; max-height: none; } +} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 9175ca6..fbe90a5 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef } from 'react'; -import { pushCurrentPath, startRouter } from '../app/router'; +import { navigate, startRouter } from '../app/router'; import { useT } from '../i18n'; import { actions, useApp } from '../state/store'; import { useServices } from './context'; @@ -11,7 +11,7 @@ import { AboutDialog, Footer, LoadingCard } from './Overlays'; import { SearchPanel } from './SearchPanel'; import { Identity, TopActions } from './TopBar'; import { useKeyboard } from './useKeyboard'; -import { COMPACT_LAYOUT } from '../app/viewport'; +import { useViewportInsets } from './useViewportInsets'; import { GuidedTour } from './GuidedTour'; import { CreditsDialog } from './CreditsDialog'; @@ -22,14 +22,7 @@ export function App() { const selected = useApp((s) => !!s.selectedId); const dissect = useApp((s) => s.dissectFdi !== null); const sheet = useApp((s) => s.mobileSheet); - const laidOut = useApp((s) => s.explodePhase === 2); - const detailHidden = useApp((s) => s.collapsed.detail); - const dockHidden = useApp((s) => s.collapsed.dock); - const layersHidden = useApp((s) => s.collapsed.layers); - const jawControls = useApp((s) => s.jawControls); - const developmentStage = useApp((s) => s.developmentStage); const lang = useApp((s) => s.lang); - const guideOpen = useApp((s) => s.guideOpen); const m = useT(); useEffect(() => { @@ -52,71 +45,7 @@ export function App() { useKeyboard(engine); - // keep the focused anatomy clear of the panels that cover the canvas - useEffect(() => { - const ui = document.querySelector('.ds-ui'); - const compact = window.matchMedia(COMPACT_LAYOUT); - let frame = 0; - const visibleRect = (selector: string) => { - const element = ui?.querySelector(selector); - if (!element || (!compact.matches && element.classList.contains('is-collapsed'))) return null; - const rect = element.getBoundingClientRect(); - return rect.width && rect.height ? rect : null; - }; - const update = () => { - frame = 0; - if (!ui) return; - const bounds = ui.getBoundingClientRect(); - document.documentElement.style.setProperty('--visual-height', `${window.visualViewport?.height ?? bounds.height}px`); - document.documentElement.style.setProperty('--visual-top', `${window.visualViewport?.offsetTop ?? 0}px`); - const toolbar = visibleRect('.ds-dock'); - const bar = visibleRect('.ds-mobile-bar'); - const panel = compact.matches ? visibleRect('.ds-layers.is-mobile-open, .ds-detail.is-mobile-open') : visibleRect('.ds-detail'); - const sideSheet = compact.matches && window.matchMedia('(orientation: landscape) and (max-height: 600px)').matches; - const bottoms = [toolbar, bar, ...(!sideSheet && compact.matches ? [panel] : [])].filter((r): r is DOMRect => !!r); - const bottom = Math.max(0, ...bottoms.map((r) => bounds.bottom - r.top + 12)); - const side = sideSheet ? panel ?? (sheet === 'tools' ? toolbar : null) : !compact.matches ? panel : null; - let right = side ? bounds.right - side.left + 12 : 0; - const layers = !compact.matches && dissect ? visibleRect('.ds-layers') : null; - let left = layers ? layers.right - bounds.left + 12 : 0; - // Landscape tool sheets occupy the side, not the lower half of the model. - const clearance = sideSheet && sheet === 'tools' ? (bar ? bounds.bottom - bar.top + 12 : 0) : bottom; - ui.style.setProperty('--dock-clearance', `${Math.max(64, bottom + 12)}px`); - const tour = document.querySelector('.ds-tour-card'); - const tourBounds = tour?.getBoundingClientRect(); - let top = 0; - let tourBottom = clearance; - if (tour && tourBounds) { - if (!compact.matches) { - if (tour.dataset.position === 'left') left = Math.max(left, tourBounds.right + 12); - else right = Math.max(right, bounds.right - tourBounds.left + 12); - } else if (sideSheet) left = Math.max(left, tourBounds.right + 12); - else if (tour.dataset.position === 'bottom') tourBottom = Math.max(tourBottom, bounds.bottom - tourBounds.top + 12); - else top = tourBounds.bottom + 12; - } - engine.setInsets(Math.min(right, bounds.width - 100), Math.min(tourBottom, bounds.height - 100), Math.min(left, bounds.width - 100), Math.max(0, Math.min(top, bounds.height - tourBottom - 80))); - }; - const schedule = () => { if (!frame) frame = requestAnimationFrame(update); }; - update(); - const observer = new ResizeObserver(schedule); - if (ui) observer.observe(ui); - for (const element of ui?.querySelectorAll('.ds-dock, .ds-layers, .ds-detail, .ds-mobile-bar') ?? []) observer.observe(element); - const tour = document.querySelector('.ds-tour-card'); - if (tour) observer.observe(tour); - const tourPosition = new MutationObserver(schedule); - if (tour) tourPosition.observe(tour, { attributes: true, attributeFilter: ['data-position'] }); - compact.addEventListener('change', schedule); - window.addEventListener('resize', schedule); - window.visualViewport?.addEventListener('resize', schedule); - return () => { - cancelAnimationFrame(frame); - observer.disconnect(); - tourPosition.disconnect(); - compact.removeEventListener('change', schedule); - window.removeEventListener('resize', schedule); - window.visualViewport?.removeEventListener('resize', schedule); - }; - }, [engine, selected, dissect, laidOut, sheet, detailHidden, dockHidden, layersHidden, jawControls, developmentStage, lang, guideOpen]); + useViewportInsets(engine); return (
@@ -156,12 +85,10 @@ export function App() { /** Bottom-left: back to the start view with every setting at its default. */ function ResetButton() { - const { engine, registry } = useServices(); + const { engine } = useServices(); const m = useT(); const reset = () => { - actions.resetAll(); - engine.resetToStart(); - pushCurrentPath(registry); + void navigate(() => { actions.resetAll(); engine.resetToStart(); }); }; return (
} -
+
{text.label} diff --git a/src/ui/LayersPanel.tsx b/src/ui/LayersPanel.tsx index 994e432..0be502f 100644 --- a/src/ui/LayersPanel.tsx +++ b/src/ui/LayersPanel.tsx @@ -13,6 +13,7 @@ import { StructureTree } from './StructureTree'; import { NerveControls } from './NerveControls'; import { DevelopmentPanel } from './DevelopmentControls'; import { DEVELOPMENT_TEXT } from '../i18n/development'; +import { interfaceText } from '../i18n/interface'; export function LayersPanel() { const panel = useApp((s) => s.panel); @@ -56,26 +57,28 @@ function Categories() { return ( <> -
- {PRESETS.map((p) => ( - - ))} -
-

{FEEDBACK_TEXT[lang].transparencyHint}

- - - {cats.nerves !== 'off' && studyView !== 'vessels' && } -
- {groups.map(([group, list]) => ( -
-
{m.group[group] ?? group}
- {list.map((c) => ( - - ))} -
- ))} +
+
+ {PRESETS.map((p) => ( + + ))} +
+

{FEEDBACK_TEXT[lang].transparencyHint}

+ + + {cats.nerves !== 'off' && studyView !== 'vessels' && } +
+ {groups.map(([group, list]) => ( +
+
{m.group[group] ?? group}
+ {list.map((c) => ( + + ))} +
+ ))} +
{visibilityNote} @@ -89,6 +92,7 @@ function Categories() { function CategoryRow({ id, label, color, count, state, planned }: { id: CategoryId; label: string; color: string; count: number; state: CategoryState; planned: boolean }) { const m = useT(); + const text = interfaceText(useLang()); if (planned) { return (
@@ -101,7 +105,13 @@ function CategoryRow({ id, label, color, count, state, planned }: { id: Category ); } const on = state !== 'off'; - const showPrimary = () => actions.setDevelopmentStage('primary'); + if (id === 'primary-teeth') return ( +
+ + {count} +
+ ); + const countLabel = `${count} ${id === 'permanent-teeth' ? text.toothCount : text.meshCount}`; return (
{/* the layer's own colour is the visibility toggle: filled with a check when shown, outlined when hidden */} @@ -109,7 +119,7 @@ function CategoryRow({ id, label, color, count, state, planned }: { id: Category type="button" className={`ds-dot-toggle${on ? ' is-on' : ''}${state === 'ghost' ? ' is-ghost' : ''}`} style={{ '--dot': color, '--dot-ink': inkOn(color) } as React.CSSProperties} - onClick={() => id === 'primary-teeth' ? showPrimary() : actions.setCategory(id, on ? 'off' : 'on')} + onClick={() => actions.setCategory(id, on ? 'off' : 'on')} aria-pressed={on} aria-label={m.showX(label)} title={on ? m.hideX(label) : m.showX(label)} @@ -118,14 +128,14 @@ function CategoryRow({ id, label, color, count, state, planned }: { id: Category - - {count} + {count} )}

{passage ? t.passageActive : t.nerveHint}

-
; + ; } diff --git a/src/ui/Overlays.tsx b/src/ui/Overlays.tsx index b2e59cd..088062e 100644 --- a/src/ui/Overlays.tsx +++ b/src/ui/Overlays.tsx @@ -3,6 +3,10 @@ import { REPO_URL } from '../app/repo'; import { useT } from '../i18n'; import { actions, useApp } from '../state/store'; import { IconClose } from './icons'; +import { interfaceText } from '../i18n/interface'; +import { nameOf } from '../i18n'; +import { useServices } from './context'; +import { navigate } from '../app/router'; /** Loading stages, in order (their names are in the messages, `stage`). */ const STAGES = ['core', 'context', 'neurovascular']; @@ -11,15 +15,27 @@ export function LoadingCard() { const ready = useApp((s) => s.ready); const loading = useApp((s) => s.loading); const error = useApp((s) => s.error); + const request = useApp((s) => s.selectionRequest); + const lang = useApp((s) => s.lang); + const text = interfaceText(lang); + const { engine, registry } = useServices(); const m = useT(); if (error) { return (
{m.somethingWrong}

{error === 'load' ? m.loadError : error}

+
); } + if (request) return ( +
+ {request.status === 'error' ? text.selectionError : text.loadingSelection} +

{nameOf(registry.require(request.id), lang)}

+ {request.status === 'error' && } +
+ ); const pending = STAGES.filter((s) => (loading[s] ?? 0) < 1); if (ready && pending.length === 0) return null; const done = STAGES.reduce((a, s) => a + (loading[s] ?? 0), 0) / STAGES.length; diff --git a/src/ui/SearchPanel.tsx b/src/ui/SearchPanel.tsx index 5313e73..67374c8 100644 --- a/src/ui/SearchPanel.tsx +++ b/src/ui/SearchPanel.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { CATEGORY_BY_ID, primaryCategory } from '../anatomy/categories'; import { formatTooth, NUMBERING_LABEL } from '../anatomy/notation'; -import { pushCurrentPath } from '../app/router'; +import { navigate } from '../app/router'; import { nameOf, useT } from '../i18n'; import { search } from '../search/search'; import { actions, useApp } from '../state/store'; @@ -10,6 +10,7 @@ import { IconClose, IconSearch } from './icons'; export function SearchPanel() { const open = useApp((s) => s.searchOpen); + const guided = useApp((s) => s.guideOpen); const numbering = useApp((s) => s.numbering); const lang = useApp((s) => s.lang); const m = useT(); @@ -19,6 +20,7 @@ export function SearchPanel() { const [active, setActive] = useState(0); const input = useRef(null); const list = useRef(null); + const dialog = useRef(null); const results = useMemo(() => (q.trim() ? search(searchIndex, registry, q, numbering, 30) : []), [q, numbering, searchIndex, registry]); @@ -37,24 +39,41 @@ export function SearchPanel() { } useEffect(() => { + const element = dialog.current!; if (open) { - requestAnimationFrame(() => { + const previousFocus = document.activeElement; + // The guided demonstration owns focus and its controls stay above search. + if (!element.open) { if (guided) element.show(); else element.showModal(); } + const onTab = (event: KeyboardEvent) => { + if (guided || event.key !== 'Tab') return; + const controls = [...element.querySelectorAll('input, button:not(:disabled), a[href]')].filter(control => control.checkVisibility()); + const first = controls[0], last = controls.at(-1); + if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); } + else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); } + }; + element.addEventListener('keydown', onTab); + const focusFrame = requestAnimationFrame(() => { input.current?.focus(); input.current?.select(); }); + return () => { + cancelAnimationFrame(focusFrame); + element.removeEventListener('keydown', onTab); + if (element.open) element.close(); + if (previousFocus instanceof HTMLElement && previousFocus.isConnected) previousFocus.focus(); + }; } - }, [open]); + if (element.open) element.close(); + }, [open, guided]); useEffect(() => { list.current?.querySelector(`[data-idx="${active}"]`)?.scrollIntoView({ block: 'nearest' }); }, [active]); - if (!open) return null; const tip = m.searchTip(NUMBERING_LABEL[numbering]); const choose = async (id: string) => { actions.openSearch(false); - await engine.selectFromUI(id, { focus: true }); - pushCurrentPath(registry); + await navigate(() => engine.selectFromUI(id, { focus: true })); }; const onKey = (e: React.KeyboardEvent) => { @@ -74,8 +93,10 @@ export function SearchPanel() { }; return ( -
e.target === e.currentTarget && actions.openSearch(false)}> -
+ { e.preventDefault(); actions.openSearch(false); }} + onPointerDown={(e) => { if (e.target === e.currentTarget) actions.openSearch(false); }}> +
)}
-
+
); } diff --git a/src/ui/SinusControls.tsx b/src/ui/SinusControls.tsx index 00ee9c8..8e92b1b 100644 --- a/src/ui/SinusControls.tsx +++ b/src/ui/SinusControls.tsx @@ -1,3 +1,4 @@ +import { navigate } from '../app/router'; import { useApp } from '../state/store'; import { useServices } from './context'; import { SINUS_NOTE, STUDY_TEXT } from '../i18n/study'; @@ -12,8 +13,8 @@ export function SinusControls() { return

{text.sinusHint}

{SINUS_NOTE[lang]}

-
{groups.map(id => )}
- { if (e.target.value) void navigate(() => engine.selectFromUI(e.target.value, { focus: true })); }}> {groups.flatMap(id => ['right', 'left'].map(side => ))}
; diff --git a/src/ui/StructureTree.tsx b/src/ui/StructureTree.tsx index af1e327..8ffc1b7 100644 --- a/src/ui/StructureTree.tsx +++ b/src/ui/StructureTree.tsx @@ -7,7 +7,7 @@ import { typeLabel } from '../i18n/anatomy'; import { nameOf, useT, type Lang } from '../i18n'; import type { Registry } from '../anatomy/registry'; import type { Structure } from '../anatomy/types'; -import { pushCurrentPath } from '../app/router'; +import { navigate } from '../app/router'; import { actions, useApp } from '../state/store'; import { useServices } from './context'; import { IconChevron, IconEye, IconEyeOff } from './icons'; @@ -98,8 +98,7 @@ const TreeNode = memo(function TreeNode({ id, depth, open, toggle, activeId, onF const isOpen = open.has(id); const select = async () => { - await engine.selectFromUI(id, { focus: true }); - pushCurrentPath(registry); + await navigate(() => engine.selectFromUI(id, { focus: true })); }; return ( diff --git a/src/ui/SurfaceFeatureControls.tsx b/src/ui/SurfaceFeatureControls.tsx index f9ed74c..22d1233 100644 --- a/src/ui/SurfaceFeatureControls.tsx +++ b/src/ui/SurfaceFeatureControls.tsx @@ -1,3 +1,4 @@ +import { navigate } from '../app/router'; import { actions, useApp } from '../state/store'; import { STUDY_TEXT } from '../i18n/study'; import { nameOf } from '../i18n'; @@ -16,7 +17,7 @@ export function SurfaceFeatureControls({ fdi }: { fdi: number }) {
{text.features}
{(['occlusal', 'inner', 'facial', 'mesial', 'distal'] as const).map(view => )}
- {enabled &&
{features.map(feature => )}
} + {enabled &&
{features.map(feature => )}
}

{text.note}

; } diff --git a/src/ui/VesselControls.tsx b/src/ui/VesselControls.tsx index 11349ce..354f3a0 100644 --- a/src/ui/VesselControls.tsx +++ b/src/ui/VesselControls.tsx @@ -1,3 +1,4 @@ +import { navigate } from '../app/router'; import { actions, useApp } from '../state/store'; import { STUDY_TEXT } from '../i18n/study'; import { useServices } from './context'; @@ -12,7 +13,7 @@ export function VesselControls() {

{text.vesselHint}

{(['both', 'arteries', 'veins'] as const).map(value => )}
{(['both', 'right', 'left'] as const).map(value => )}
- { if (e.target.value) void navigate(() => engine.selectFromUI(e.target.value, { focus: true })); }}> {vessels.map(vessel => )} ; diff --git a/src/ui/useKeyboard.ts b/src/ui/useKeyboard.ts index c8e61dd..41bdbf6 100644 --- a/src/ui/useKeyboard.ts +++ b/src/ui/useKeyboard.ts @@ -1,3 +1,4 @@ +import { navigate } from '../app/router'; import { useEffect } from 'react'; import type { Engine } from '../engine/Engine'; import { actions, getState } from '../state/store'; @@ -23,8 +24,8 @@ export function useKeyboard(engine: Engine) { break; case 'Escape': if (s.searchOpen) actions.openSearch(false); - else if (sel) actions.select(null); - else if (s.dissectFdi !== null) actions.exitDissect(); + else if (sel) void navigate(() => actions.select(null)); + else if (s.dissectFdi !== null) void navigate(() => actions.exitDissect()); else if (s.isolateId) actions.isolate(null); break; case 'f': @@ -45,7 +46,7 @@ export function useKeyboard(engine: Engine) { case 'd': { const fdi = sel ? engine.registry.get(sel)?.toothFdi : undefined; if (fdi !== undefined) { - void engine.exploreTooth(fdi); + void navigate(() => engine.exploreTooth(fdi)); } break; } diff --git a/src/ui/useViewportInsets.ts b/src/ui/useViewportInsets.ts new file mode 100644 index 0000000..b10c497 --- /dev/null +++ b/src/ui/useViewportInsets.ts @@ -0,0 +1,85 @@ +import { useEffect } from 'react'; +import type { Engine } from '../engine/Engine'; +import { useApp } from '../state/store'; +import { COMPACT_LAYOUT } from '../app/viewport'; + +/** Measure UI occlusion once here; the engine only receives viewport insets. */ +export function useViewportInsets(engine: Engine) { + const selected = useApp(s => !!s.selectedId); + const dissect = useApp(s => s.dissectFdi !== null); + const sheet = useApp(s => s.mobileSheet); + const lang = useApp(s => s.lang); + const laidOut = useApp((s) => s.explodePhase === 2); + const detailHidden = useApp((s) => s.collapsed.detail); + const dockHidden = useApp((s) => s.collapsed.dock); + const layersHidden = useApp((s) => s.collapsed.layers); + const jawControls = useApp((s) => s.jawControls); + const developmentStage = useApp((s) => s.developmentStage); + const guideOpen = useApp((s) => s.guideOpen); + // keep the focused anatomy clear of the panels that cover the canvas + useEffect(() => { + const ui = document.querySelector('.ds-ui'); + const compact = window.matchMedia(COMPACT_LAYOUT); + let frame = 0; + const visibleRect = (selector: string) => { + const element = ui?.querySelector(selector); + if (!element || (!compact.matches && element.classList.contains('is-collapsed'))) return null; + const rect = element.getBoundingClientRect(); + return rect.width && rect.height ? rect : null; + }; + const update = () => { + frame = 0; + if (!ui) return; + const bounds = ui.getBoundingClientRect(); + document.documentElement.style.setProperty('--visual-height', `${window.visualViewport?.height ?? bounds.height}px`); + document.documentElement.style.setProperty('--visual-top', `${window.visualViewport?.offsetTop ?? 0}px`); + const toolbar = visibleRect('.ds-dock'); + const bar = visibleRect('.ds-mobile-bar'); + const panel = compact.matches ? visibleRect('.ds-layers.is-mobile-open, .ds-detail.is-mobile-open') : visibleRect('.ds-detail'); + const sideSheet = compact.matches && window.matchMedia('(orientation: landscape) and (max-height: 600px)').matches; + const bottoms = [toolbar, bar, ...(!sideSheet && compact.matches ? [panel] : [])].filter((r): r is DOMRect => !!r); + const bottom = Math.max(0, ...bottoms.map((r) => bounds.bottom - r.top + 12)); + const side = sideSheet ? panel ?? (sheet === 'tools' ? toolbar : null) : !compact.matches ? panel : null; + let right = side ? bounds.right - side.left + 12 : 0; + const layers = !compact.matches && dissect ? visibleRect('.ds-layers') : null; + let left = layers ? layers.right - bounds.left + 12 : 0; + // Landscape tool sheets occupy the side, not the lower half of the model. + const clearance = sideSheet && sheet === 'tools' ? (bar ? bounds.bottom - bar.top + 12 : 0) : bottom; + ui.style.setProperty('--dock-clearance', `${Math.max(64, bottom + 12)}px`); + const tour = document.querySelector('.ds-tour-card'); + const tourBounds = tour?.getBoundingClientRect(); + let top = 0; + let tourBottom = clearance; + if (tour && tourBounds) { + if (!compact.matches) { + if (tour.dataset.position === 'left') left = Math.max(left, tourBounds.right + 12); + else right = Math.max(right, bounds.right - tourBounds.left + 12); + } else if (sideSheet) left = Math.max(left, tourBounds.right + 12); + else if (tour.dataset.position === 'bottom') tourBottom = Math.max(tourBottom, bounds.bottom - tourBounds.top + 12); + else top = tourBounds.bottom + 12; + } + engine.setInsets(Math.min(right, bounds.width - 100), Math.min(tourBottom, bounds.height - 100), Math.min(left, bounds.width - 100), Math.max(0, Math.min(top, bounds.height - tourBottom - 80))); + }; + const schedule = () => { if (!frame) frame = requestAnimationFrame(update); }; + update(); + const observer = new ResizeObserver(schedule); + if (ui) observer.observe(ui); + for (const element of ui?.querySelectorAll('.ds-dock, .ds-layers, .ds-detail, .ds-mobile-bar') ?? []) observer.observe(element); + const tour = document.querySelector('.ds-tour-card'); + if (tour) observer.observe(tour); + const tourPosition = new MutationObserver(schedule); + if (tour) tourPosition.observe(tour, { attributes: true, attributeFilter: ['data-position'] }); + compact.addEventListener('change', schedule); + window.addEventListener('resize', schedule); + window.visualViewport?.addEventListener('resize', schedule); + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); + tourPosition.disconnect(); + compact.removeEventListener('change', schedule); + window.removeEventListener('resize', schedule); + window.visualViewport?.removeEventListener('resize', schedule); + }; + }, [engine, selected, dissect, laidOut, sheet, detailHidden, dockHidden, layersHidden, jawControls, developmentStage, lang, guideOpen]); + +} diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index 8fe8f4f..15461e5 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -4,6 +4,6 @@ // Node types for tests and vite.config.ts; the app typecheck stays DOM-only. "types": ["vite/client", "node"] }, - "include": ["src", "vite.config.ts"], + "include": ["src", "vite.config.ts", "playwright.config.ts", "e2e"], "exclude": [] }