Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ dist/
*.log
.vite/
coverage/
playwright-report/
test-results/
tools/pipeline/raw/
tools/pipeline/.cache/
__pycache__/
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
23 changes: 15 additions & 8 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <http://localhost:5173>. 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` | |
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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/
Expand All @@ -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 `<App>` with all three in `ServicesContext`.
3. `App` mounts the engine on the stage `<div>`, starts `engine.loadAll()` (staged loading, §7) and `startRouter()`. It sets `data-theme` on `<html>`, 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/<id>` | Select and focus any structure (served through the `noindex` 404.html shell on static hosts) |
| `/structure/<id>` | 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/<lang>/`. |

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`)

Expand All @@ -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.

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/explorer-ui-44/guide-after.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/explorer-ui-44/guide-before.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/explorer-ui-44/mobile-after.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
107 changes: 107 additions & 0 deletions e2e/explorer.spec.ts
Original file line number Diff line number Diff line change
@@ -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(/\/$/);
});
61 changes: 61 additions & 0 deletions e2e/tour.spec.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>('[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<void>(resolve => requestAnimationFrame(() => resolve()));
values.push(document.querySelector<HTMLElement>('.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();
});
4 changes: 2 additions & 2 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
{
Expand Down Expand Up @@ -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 } },
},
{
Expand Down
Loading
Loading