diff --git a/CHANGELOG.md b/CHANGELOG.md index d384fe2a..72e6f497 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,48 @@ All notable changes to the Fovea project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.0] - 2026-06-25 + +The 0.6.0 release rebuilds the guided-tour engine so a deployment can author and tailor tours from data rather than code ([#180](https://github.com/parafovea/fovea/pull/180)). A tour is now a validated data document an administrator ships under `public/tours/`, every UI surface a tour points at is a named entry in a typed anchor catalog the build checks, and the runner drives each step's prerequisites declaratively. The change is breaking for anyone who built on the previous tour module's internals; the built-in tours and the in-app tour menu are unchanged for end users. + +### Added + +#### Typed Anchor Catalog as a Tour Contract + +- `annotation-tool/src/tours/engine/anchorCatalog.ts` declares every tour-addressable UI surface once, as a typed entry recording its description, the route or dialog it lives on, whether it is conditional, and the `reachedBy` controls that open it. `AnchorId` is `keyof typeof anchorCatalog`, so a tour step or seeder that names an anchor the catalog does not define fails to compile. A component publishes its element for an anchor with `useTourAnchor(id)` (`annotation-tool/src/tours/engine/anchorRegistry.tsx`), which registers the live node in an `AnchorRegistry` and tags it `data-tour-anchor` for the inspector and tests. + +#### Validated Tour Schema and File-Based Authoring + +- A tour is a single zod schema (`annotation-tool/src/tours/engine/tourSchema.ts`); `parseTour` validates a document and narrows it to the `Tour` type, so a malformed tour fails loudly with a field-anchored message instead of dropping out silently. `annotation-tool/src/tours/content/tourLoader.ts` assembles the catalogue a deployment runs: the first-party tours merged with an administrator's tours served from `public/tours/` (a manifest at `/tours/index.json` listing tour JSON files). An override replaces the built-in sharing its id, a new id is appended, and a tour marked `enabled: false` is dropped, so an administrator tailors the tour set for their user base without touching application code. + +#### Hexagonal Tour Runner with Declarative Step Drivers + +- `annotation-tool/src/tours/engine/TourRunner.tsx` drives a tour as a state machine that, for each step, navigates to the step's route, runs its `driver` capability to put the workspace into the state the step needs, resolves the anchor by clicking the catalog `reachedBy` controls, and simulates the step's expected action. Capabilities are named side effects (`annotation-tool/src/tours/engine/capabilities.ts`, `annotation-tool/src/tours/engine/seeders.ts`) a step references by name, so a step declares the state it needs rather than scripting how to reach it. The runner emits a `TourEvent` for every transition (`annotation-tool/src/tours/engine/events.ts`) and is policy-free: the host decides which tour to run and what to do on close. + +#### Tour Analytics, a Resume Cursor, and a Pause Control + +- The provider folds the runner's event stream into an analytics log (`annotation-tool/src/tours/menu/tourTelemetry.ts`): a `started`, a `step_viewed` per step left carrying its dwell time, and a terminal `completed` with total time or `abandoned` with the reason and last step. It persists a lightweight `fovea.tour.cursor` so a reload resumes in place, and the step card carries a Pause control that snapshots the step for the resume pill. An author-mode anchor inspector (`annotation-tool/src/tours/engine/AnchorInspector.tsx`) lists the anchors currently on the page so a tour can be authored by reading ids off the running UI. + +### Changed + +#### Dialogs Yield to a Running Tour + +- `annotation-tool/src/components/ui/dialog.tsx` reads whether a tour is active and, while one runs, renders non-modal and ignores outside-press and focus-out close reasons, so the tour's step card stays reachable over an open dialog and the engine can drive a surface inside it. Outside a tour, dialog behavior is unchanged. This replaces a deployment-mode check, so dialogs behave the same whether a tour runs in the public booth or a signed-in workspace. + +### Fixed + +#### Tour Catalogue Load Tolerates a Missing Manifest + +- `annotation-tool/src/tours/content/tourLoader.ts` requested `/tours/index.json` and threw when the response was not JSON. A deployment that ships no administrator manifest still answers that path, since the single-page-app history fallback serves `index.html` with a 200, so every page load logged a catalogue-load error. The loader now reads an HTML response as "no manifest" and returns the built-in tours, while a manifest the server genuinely serves as JSON still validates loudly. + +### Removed + +- The previous tour engine's internal modules and the `data-tour-id` attribute convention are removed; tours and anchors are expressed through the catalog, schema, registry, and capabilities above. + +### Testing + +- Every built-in tour has a regression spec that launches it through the engine and asserts each step's anchor resolves and the tour completes (`annotation-tool/test/e2e/regression/tours/`), run against the real backend and the MP4 video corpus. A comprehensive engine smoke suite (`annotation-tool/test/e2e/smoke/tour-engine.spec.ts`) covers the runner, spotlight, step card, telemetry, the resume cursor, pause and resume, focus, and keyboard handling. + ## [0.5.4] - 2026-06-25 The 0.5.4 patch fixes project-scope and ownership stamping on video summaries and claims ([#181](https://github.com/parafovea/fovea/pull/181)). Project collaborators could not see a teammate's summary or add claims under it because summaries were persisted without their persona's project, and model-generated summaries and extracted claims were left unowned. Nothing is breaking; the API additively gains a `projectId` field on summary and claim responses. diff --git a/annotation-tool/package.json b/annotation-tool/package.json index a9f5c9cc..3cb3526d 100644 --- a/annotation-tool/package.json +++ b/annotation-tool/package.json @@ -1,7 +1,7 @@ { "name": "@fovea/annotation-tool", "private": true, - "version": "0.5.4", + "version": "0.6.0", "type": "module", "scripts": { "dev": "vite", diff --git a/annotation-tool/playwright.tours.config.ts b/annotation-tool/playwright.tours.config.ts new file mode 100644 index 00000000..a95d3253 --- /dev/null +++ b/annotation-tool/playwright.tours.config.ts @@ -0,0 +1,17 @@ +// Tour-run override: the regression/tours end-to-end walkthroughs drive the +// real microvent MP4 corpus and need a browser channel that decodes H.264. +// The base regression project uses Playwright's bundled Chromium (no H.264), +// so this config extends the base and swaps the regression project onto the +// installed Google Chrome (channel: 'chrome'). Run against a backend whose +// STORAGE_PATH points at /videos (see docker-compose.e2e.mp4.yml). +import type { PlaywrightTestConfig } from '@playwright/test' +import base from './playwright.config' + +const projects = (base.projects ?? []).map((p) => + p.name === 'regression' + ? { ...p, use: { ...(p.use ?? {}), channel: 'chrome' as const } } + : p, +) + +const config: PlaywrightTestConfig = { ...base, projects } +export default config diff --git a/annotation-tool/probe-gloss.mjs b/annotation-tool/probe-gloss.mjs deleted file mode 100644 index 1d4a5c81..00000000 --- a/annotation-tool/probe-gloss.mjs +++ /dev/null @@ -1,96 +0,0 @@ -import { chromium } from '@playwright/test' - -const BASE = process.env.PROBE_BASE ?? 'http://localhost:5180' - -// Tours that should fire the gloss-autocomplete popup at some step. -// Each entry: { tourId, label, expectedTriggerAt: substring of the step -// narration that means humanType is about to fire on a gloss field. -// The probe walks the tour, watches the StepCard's narration each step, -// and when it sees the trigger narration it waits up to 4s for the -// [data-tour-id="gloss-autocomplete-popup"] element to mount in the DOM. -// A passing tour must mount that popup at least once. } -const TOURS = [ - { tourId: 'ontology-authoring', triggerNarrationFragment: "Type '#' in the gloss field" }, - { tourId: 'wikidata-augmentation', triggerNarrationFragment: 'autocomplete' }, - { tourId: 'events-roles-claims', triggerNarrationFragment: 'Type # in any gloss field' }, -] - -const browser = await chromium.launch() -const results = [] - -for (const { tourId, triggerNarrationFragment } of TOURS) { - const ctx = await browser.newContext() - const p = await ctx.newPage() - let popupSeen = false - let stepsWalked = 0 - let triggerStepSeen = false - let triggerStepIndex = null - const consoleWarnings = [] - p.on('console', (m) => { - if (m.type() === 'warn' && m.text().includes('[tour]')) consoleWarnings.push(m.text()) - }) - - try { - await p.goto(BASE, { waitUntil: 'load', timeout: 30_000 }) - await p.getByTestId(`launch-${tourId}`).click({ timeout: 10_000 }) - let safety = 40 - while (safety-- > 0) { - // Wait long enough for revealBy + waitForAnchor + simulateAction to play. - // humanType is ~80ms/char and the trigger pause is 140ms, so a 12-char - // typeText with one trigger ≈ 1.2s. Plus the spotlight settle 350ms. - // Add a generous buffer for any redraws. - await p.waitForTimeout(5000) - const stepText = await p.locator('[data-fovea-tour-step-card]').textContent().catch(() => '') - const cardText = stepText || '' - stepsWalked += 1 - if (cardText.includes(triggerNarrationFragment)) { - triggerStepSeen = true - triggerStepIndex = stepsWalked - // The 5s above already covers humanType. Now do a short - // explicit poll for the popup so a slow popup mount doesn't - // false-fail. - for (let i = 0; i < 30; i++) { - const count = await p - .locator('[data-tour-id="gloss-autocomplete-popup"]') - .count() - .catch(() => 0) - if (count > 0) { - popupSeen = true - break - } - await p.waitForTimeout(150) - } - } - const finishBtn = await p.getByRole('button', { name: /^Finish$/ }).count() - const nextBtn = await p.getByRole('button', { name: /^Next$/ }).count() - const skipBtn = await p.getByRole('button', { name: /^Skip$/ }).count() - if (finishBtn > 0) break - if (nextBtn > 0) { - await p.getByRole('button', { name: /^Next$/ }).click({ timeout: 3_000 }).catch(() => {}) - } else if (skipBtn > 0) { - await p.getByRole('button', { name: /^Skip$/ }).click({ timeout: 3_000 }).catch(() => {}) - } else { - break - } - } - results.push({ tourId, triggerStepSeen, triggerStepIndex, popupSeen, stepsWalked, warnings: consoleWarnings.length }) - if (popupSeen) { - console.log(`OK ${tourId}: popup mounted at step ${triggerStepIndex}`) - } else if (triggerStepSeen) { - console.error(`FAIL ${tourId}: trigger narration seen at step ${triggerStepIndex} but popup never mounted`) - } else { - console.error(`FAIL ${tourId}: trigger narration never seen across ${stepsWalked} steps`) - } - } catch (e) { - console.error(`ERR ${tourId}:`, String(e).split('\n')[0]) - results.push({ tourId, error: String(e).split('\n')[0] }) - } - await ctx.close() -} - -await browser.close() -console.log('\n=== gloss summary ===') -console.log(results) -const popupHits = results.filter((r) => r.popupSeen).length -console.log(`Popup mounted in ${popupHits}/${TOURS.length} tours`) -process.exit(popupHits === TOURS.length ? 0 : 1) diff --git a/annotation-tool/probe-one.mjs b/annotation-tool/probe-one.mjs deleted file mode 100644 index 5fadf8d8..00000000 --- a/annotation-tool/probe-one.mjs +++ /dev/null @@ -1,66 +0,0 @@ -// Probe a single tour. Usage: PROBE_TOUR=ontology-authoring node probe-one.mjs -// Walks the tour, records anchor-mount status + banner per step. Faster -// than probe-tours.mjs (one browser, one tour) so debug loops are tight. -import { chromium } from '@playwright/test' - -const BASE = process.env.PROBE_BASE ?? 'http://localhost:5180' -const TOUR = process.env.PROBE_TOUR ?? 'first-annotation' -const WAIT_MS = parseInt(process.env.PROBE_WAIT_MS ?? '10000', 10) - -const browser = await chromium.launch({ headless: process.env.HEADED !== '1' }) -const ctx = await browser.newContext() -const p = await ctx.newPage() -const warnings = [] -p.on('console', (m) => { - if (m.text().includes('[tour]')) warnings.push(`${m.type()}: ${m.text()}`) -}) -try { - await p.goto(BASE, { waitUntil: 'load', timeout: 30_000 }) - await p.getByTestId(`launch-${TOUR}`).click({ timeout: 10_000 }) - const steps = [] - let safety = 40 - while (safety-- > 0) { - await p.waitForTimeout(WAIT_MS) - const banner = await p - .getByText(/Couldn't find this UI element/i) - .count() - .catch(() => 0) - const stepText = - (await p.locator('[data-fovea-tour-step-card]').textContent().catch(() => '')) ?? '' - const popupCount = await p.locator('[data-tour-id="gloss-autocomplete-popup"]').count().catch(() => 0) - const stepIdx = steps.length + 1 - steps.push({ - step: stepIdx, - banner: banner > 0, - popup: popupCount > 0, - snippet: stepText.slice(0, 140), - }) - const finishBtn = await p.getByRole('button', { name: /^Finish$/ }).count() - const nextBtn = await p.getByRole('button', { name: /^Next$/ }).count() - const skipBtn = await p.getByRole('button', { name: /^Skip$/ }).count() - if (finishBtn > 0) break - if (nextBtn > 0) { - await p.getByRole('button', { name: /^Next$/ }).click({ timeout: 3_000 }).catch(() => {}) - } else if (skipBtn > 0) { - await p.getByRole('button', { name: /^Skip$/ }).click({ timeout: 3_000 }).catch(() => {}) - } else { - break - } - } - console.log(`Tour: ${TOUR}, wait=${WAIT_MS}ms, total=${steps.length} steps`) - steps.forEach((s) => { - const flag = s.banner ? 'BANNER' : 'ok' - const popup = s.popup ? ' [popup]' : '' - console.log(` step ${s.step}: [${flag}]${popup} ${s.snippet}`) - }) - const banners = steps.filter((s) => s.banner).length - console.log(`\nbanners=${banners}, popup_mounts=${steps.filter((s) => s.popup).length}, warnings=${warnings.length}`) - if (warnings.length > 0) { - console.log('\nWarnings:') - warnings.slice(0, 30).forEach((w) => console.log(' ' + w)) - } - process.exit(banners > 0 ? 1 : 0) -} finally { - await ctx.close() - await browser.close() -} diff --git a/annotation-tool/probe-state-isolation.mjs b/annotation-tool/probe-state-isolation.mjs deleted file mode 100644 index bd9dfcb2..00000000 --- a/annotation-tool/probe-state-isolation.mjs +++ /dev/null @@ -1,117 +0,0 @@ -// Verify cross-tour state isolation. Launch Tour A, dirty the store -// directly via window eval, abandon, launch Tour B, then read the -// store: every slice must be back at initialState. -import { chromium } from '@playwright/test' -import { mkdirSync } from 'fs' - -const BASE = process.env.PROBE_BASE ?? 'http://127.0.0.1:5173' -const OUT = '/tmp/shots-isolation' -mkdirSync(OUT, { recursive: true }) - -const browser = await chromium.launch() -const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }) -const p = await ctx.newPage() -p.on('console', (msg) => { - const t = msg.text() - if (t.includes('STORE') || msg.type() === 'error') console.log(`[browser:${msg.type()}] ${t}`) -}) - -await p.goto(BASE, { waitUntil: 'load', timeout: 30_000 }) -await p.waitForTimeout(2000) - -// Launch Tour A: ontology-authoring -await p.getByTestId('launch-ontology-authoring').click({ timeout: 10_000 }) -await p.waitForTimeout(3000) - -// Dirty the store directly so the leak scenario is reproducible -// regardless of where in the tour we are. -await p.evaluate(async () => { - const mod = await import('/src/store/zustand/annotationUiStore.ts') - const s = mod.useAnnotationUiStore.getState() - s.setOntologyTabIndex(3) - s.setAnnotationMode('event') - s.setDrawingMode('relation') - s.setSelectedTypeId('dirty-type') - s.setSelectedPersonaId('dirty-persona') - s.setOntologySelectedPersonaId('dirty-ont-persona') - s.setTimelineExpanded(true) - s.setShowDetectionCandidates(true) - s.setLinkTarget('dirty-link', 'entity') - console.log('STORE dirtied') -}) - -// Read state to confirm dirty -const dirty = await p.evaluate(async () => { - const mod = await import('/src/store/zustand/annotationUiStore.ts') - const s = mod.useAnnotationUiStore.getState() - return { - ontologyTabIndex: s.ontologyTabIndex, - annotationMode: s.annotationMode, - drawingMode: s.drawingMode, - selectedTypeId: s.selectedTypeId, - selectedPersonaId: s.selectedPersonaId, - ontologySelectedPersonaId: s.ontologySelectedPersonaId, - timelineExpanded: s.timelineExpanded, - showDetectionCandidates: s.showDetectionCandidates, - linkTargetId: s.linkTargetId, - linkTargetType: s.linkTargetType, - } -}) -console.log('After dirty:', JSON.stringify(dirty)) - -// Abandon current tour (Skip) -const skip = await p.getByRole('button', { name: /^Skip$/ }).count() -if (skip > 0) await p.getByRole('button', { name: /^Skip$/ }).click() -await p.waitForTimeout(1500) -// If still in tour, navigate home -await p.goto(BASE, { waitUntil: 'load', timeout: 15_000 }) -await p.waitForTimeout(1500) - -// Launch Tour B: first-annotation. The TourProvider.launch should -// fire resetAllState() BEFORE the launch routes anywhere. -await p.getByTestId('launch-first-annotation').click({ timeout: 10_000 }) -await p.waitForTimeout(3000) - -const fresh = await p.evaluate(async () => { - const mod = await import('/src/store/zustand/annotationUiStore.ts') - const s = mod.useAnnotationUiStore.getState() - return { - ontologyTabIndex: s.ontologyTabIndex, - annotationMode: s.annotationMode, - drawingMode: s.drawingMode, - selectedTypeId: s.selectedTypeId, - selectedPersonaId: s.selectedPersonaId, - ontologySelectedPersonaId: s.ontologySelectedPersonaId, - timelineExpanded: s.timelineExpanded, - showDetectionCandidates: s.showDetectionCandidates, - linkTargetId: s.linkTargetId, - linkTargetType: s.linkTargetType, - } -}) -console.log('After Tour B launch:', JSON.stringify(fresh)) - -// Expected fresh state per initialState -const expected = { - ontologyTabIndex: 0, - annotationMode: 'type', - drawingMode: null, - selectedTypeId: null, - selectedPersonaId: null, - ontologySelectedPersonaId: null, - timelineExpanded: false, - showDetectionCandidates: false, - linkTargetId: null, - linkTargetType: null, -} -let pass = true -for (const [k, v] of Object.entries(expected)) { - if (fresh[k] !== v) { - console.log(` LEAK: ${k} = ${JSON.stringify(fresh[k])} (expected ${JSON.stringify(v)})`) - pass = false - } -} -await p.screenshot({ path: `${OUT}/tour-b-step-1.png`, fullPage: false }) -console.log(pass ? 'PASS: all slices reset' : 'FAIL: state leaked between tours') -await ctx.close() -await browser.close() -process.exit(pass ? 0 : 1) diff --git a/annotation-tool/probe-tours.mjs b/annotation-tool/probe-tours.mjs deleted file mode 100644 index 783e2e6d..00000000 --- a/annotation-tool/probe-tours.mjs +++ /dev/null @@ -1,85 +0,0 @@ -import { chromium } from '@playwright/test' - -const BASE = process.env.PROBE_BASE ?? 'http://localhost:5180' -const TOURS = [ - 'welcome', - 'first-annotation', - 'ontology-authoring', - 'wikidata-augmentation', - 'events-roles-claims', - 'world-layer', - 'model-in-the-loop', - 'summaries-and-claims', - 'collaboration', - 'admin', - 'import-export', - 'keyframes-interpolation', -] - -const browser = await chromium.launch() -const results = [] - -for (const tourId of TOURS) { - const ctx = await browser.newContext() - const p = await ctx.newPage() - const warnings = [] - p.on('console', (m) => { - if (m.type() === 'warn' && m.text().includes('[tour]')) warnings.push(m.text()) - }) - try { - await p.goto(BASE, { waitUntil: 'load', timeout: 30_000 }) - await p.getByTestId(`launch-${tourId}`).click({ timeout: 10_000 }) - const steps = [] - // Walk forward until Finish appears, capturing per-step missing-anchor banner - let safety = 30 - while (safety-- > 0) { - // waitForAnchor's ceiling is 6s; the banner is rendered when - // the resolver returns null. Wait 8s so the banner has had - // time to mount AND any revealBy chain has had time to walk - // AND any network-bound mount (lazy chunk + /api round-trip) - // has settled before we observe. The probe MUST exceed the - // engine's ceiling — a probe that fires before the banner - // would report 0 banners on a tour that strands every - // visitor on a missing-anchor error. - await p.waitForTimeout(10000) - const banner = await p - .getByText(/Couldn't find this UI element/i) - .count() - .catch(() => 0) - const stepText = await p.locator('[data-fovea-tour-step-card]').textContent().catch(() => '') - const finishBtn = await p.getByRole('button', { name: /^Finish$/ }).count() - const nextBtn = await p.getByRole('button', { name: /^Next$/ }).count() - const skipBtn = await p.getByRole('button', { name: /^Skip$/ }).count() - steps.push({ banner: banner > 0, snippet: (stepText || '').slice(0, 100) }) - if (finishBtn > 0) break - // Skip means the engine showed the banner — record the step - // and advance through Skip so we still walk the whole tour. - if (nextBtn > 0) { - await p.getByRole('button', { name: /^Next$/ }).click({ timeout: 3_000 }).catch(() => {}) - } else if (skipBtn > 0) { - await p.getByRole('button', { name: /^Skip$/ }).click({ timeout: 3_000 }).catch(() => {}) - } else { - break - } - } - const banners = steps.filter((s) => s.banner).length - results.push({ tourId, total: steps.length, banners, warnings: warnings.length }) - if (banners > 0) { - console.log(`>> ${tourId}: ${banners}/${steps.length} banner steps`) - steps.forEach((s, i) => { if (s.banner) console.error(` FAIL ${tourId} step ${i+1}: ${s.snippet}`) }) - } else { - console.log(`OK ${tourId}: ${steps.length} steps clean`) - } - } catch (e) { - console.log(`ERR ${tourId}:`, String(e).split('\n')[0]) - results.push({ tourId, error: String(e).split('\n')[0] }) - } - await ctx.close() -} - -await browser.close() -console.log('\n=== summary ===') -console.log(results) -const totalBanners = results.reduce((a, r) => a + (r.banners || 0), 0) -console.log(`TOTAL banners: ${totalBanners}`) -process.exit(totalBanners > 0 ? 1 : 0) diff --git a/annotation-tool/src/App.tsx b/annotation-tool/src/App.tsx index b4eba1f7..e42590eb 100644 --- a/annotation-tool/src/App.tsx +++ b/annotation-tool/src/App.tsx @@ -12,16 +12,14 @@ import { config } from '@/config' * server round-trips, fully MSW-mocked via VITE_TOUR_DEMO=1) and only * crosses into the authenticated app via the explicit "Sign in" link. * - * Route components are imported eagerly (no React.lazy). An earlier - * lazy-load split shrank the catalogue's first paint, but the - * Suspense fallback for the workspace + ontology + admin chunks - * also unmounted the data-tour-id anchors the tour engine polls - * for, which made step 1 of every annotation-workspace-bound tour - * race a fresh chunk download against the engine's waitForAnchor - * window and intermittently strand visitors on the missing-anchor - * banner. Eager imports keep the data-tour-ids in the DOM from - * first paint; the perf cost of the larger initial download is - * the right trade-off for the demo's reliability. + * Route components are imported eagerly (no React.lazy). A Suspense + * fallback for the workspace, ontology, and admin chunks unmounts the + * tour anchors the engine resolves a step against, so step 1 of every + * annotation-workspace-bound tour would race a fresh chunk download + * against the engine's bounded anchor wait and intermittently strand + * visitors on the missing-anchor banner. Eager imports keep those + * anchors mounted from first paint; the perf cost of the larger initial + * download is the right trade-off for the demo's reliability. */ const DEMO_PUBLIC = config.deploymentMode.publicBooth import AnnotationWorkspace from '@components/annotation/AnnotationWorkspace' diff --git a/annotation-tool/src/components/admin/AdminPanel.tsx b/annotation-tool/src/components/admin/AdminPanel.tsx index 2d5d917b..b7810be2 100644 --- a/annotation-tool/src/components/admin/AdminPanel.tsx +++ b/annotation-tool/src/components/admin/AdminPanel.tsx @@ -45,6 +45,7 @@ import { PermissionsPage } from './PermissionsPage' import { ModelManagementPage } from './ModelManagementPage' import { SystemConfigPanel } from './SystemConfigPanel' import { DemoAdminPanel } from './DemoAdminPanel' +import { useTourAnchor } from '@/tours/engine/anchorRegistry' import { config } from '@/config' /** @@ -53,6 +54,16 @@ import { config } from '@/config' * Redirects non-admin users to home page. */ export function AdminPanel(): JSX.Element { + const panelAnchor = useTourAnchor('admin-panel') + const usersTabAnchor = useTourAnchor('admin-tab-users') + const groupsTabAnchor = useTourAnchor('admin-tab-groups') + const projectsTabAnchor = useTourAnchor('admin-tab-projects') + const videoAccessTabAnchor = useTourAnchor('admin-tab-video-access') + const permissionsTabAnchor = useTourAnchor('admin-tab-permissions') + const sessionsTabAnchor = useTourAnchor('admin-tab-sessions') + const modelsTabAnchor = useTourAnchor('admin-tab-models') + const systemConfigTabAnchor = useTourAnchor('admin-tab-system-config') + const settingsTabAnchor = useTourAnchor('admin-tab-settings') const currentUser = useAuthStore(state => state.currentUser) // Stock deployments keep the original redirect for non-admins so a // user who reaches /app/admin (or /admin) by typing the URL is @@ -66,17 +77,14 @@ export function AdminPanel(): JSX.Element { if (!isDemoPublic && !currentUser?.isAdmin) { return } - // CRITICAL data-leak prevention: when DEMO_PUBLIC is on but the - // visitor is NOT an authenticated admin, render an entirely static - // mock panel instead of the real one. The previous behaviour - // (dropping the redirect, keeping the real components) mounted - // UserManagementPage / SessionManagementPage / PermissionsPage etc., - // each of which issued its own /api/admin/* fetch — and the DEMO_MODE - // backend widened the read scope to make those fetches succeed, so a - // demo visitor could see real admin usernames, real sessions, and - // real role assignments on the production deployment. Mock panel - // shares every data-tour-id anchor the Admin tour walks through so - // the spotlight still lands on the right element. Real admins + // Data-leak prevention: when DEMO_PUBLIC is on but the visitor is NOT + // an authenticated admin, render an entirely static mock panel instead + // of the real one. The live panel mounts UserManagementPage / + // SessionManagementPage / PermissionsPage etc., each of which issues + // its own /api/admin/* fetch, so rendering it for a demo visitor would + // expose real admin usernames, sessions, and role assignments. The + // mock panel registers every tour anchor the Admin tour walks through + // so the spotlight still lands on the right element. Real admins // signing in via /login fall through to the live AdminPanel below // because isDemoPublic + !isAdmin is the only branch this catches. if (isDemoPublic && !currentUser?.isAdmin) { @@ -84,7 +92,7 @@ export function AdminPanel(): JSX.Element { } return ( -
+

Admin Panel @@ -97,39 +105,39 @@ export function AdminPanel(): JSX.Element {
- + Users - + Groups - + Projects - + - + Permissions - + Sessions - + Models - + System Config - + Settings diff --git a/annotation-tool/src/components/admin/DemoAdminPanel.tsx b/annotation-tool/src/components/admin/DemoAdminPanel.tsx index ad1637d6..490dc196 100644 --- a/annotation-tool/src/components/admin/DemoAdminPanel.tsx +++ b/annotation-tool/src/components/admin/DemoAdminPanel.tsx @@ -1,28 +1,20 @@ /** - * DemoAdminPanel — a fully STATIC, zero-fetch render of the admin panel - * for VITE_DEMO_PUBLIC=1 visitors who are not signed-in admins. + * A fully static, zero-fetch render of the admin panel for + * VITE_DEMO_PUBLIC=1 visitors who are not signed-in admins. * - * Why this exists: the live demo at demo.fovea.video previously rendered - * the REAL AdminPanel as a guided-preview so the Admin tour had anchors - * to spotlight. That dropped the auth gate but kept the underlying - * components mounted, which meant every UserManagementPage / - * SessionManagementPage / PermissionsPage / GroupManagementPage child - * issued its own /api/admin/* fetch and SUCCEEDED (the backend's - * DEMO_MODE branch widened the read scope), so a passing demo visitor - * could see real admin usernames, real session rows, and real role - * assignments on the production deployment. That is a data leak. + * The live AdminPanel mounts UserManagementPage, SessionManagementPage, + * PermissionsPage, GroupManagementPage, and so on, each of which issues + * its own /api/admin/* fetch. Rendering it for a demo visitor would + * expose real admin usernames, session rows, and role assignments, so + * AdminPanel renders this component instead whenever DEMO_PUBLIC is on + * and the visitor is not an authenticated admin. Every tab is a + * hardcoded synthetic table that registers the same tour anchors the + * Admin tour expects, so the tour walks through normally while no live + * data is fetched or displayed. * - * Fix: when DEMO_PUBLIC is on and the visitor is not an authenticated - * admin, render THIS component instead of the real AdminPanel. Every - * tab is a fully-hardcoded synthetic table with the same data-tour-id - * anchors the Admin tour expects, so the tour walks through normally - * but no live data is ever fetched or displayed. Real admins logging - * in via /login still see the real AdminPanel because the guard in - * AdminPanel.tsx checks isAdmin BEFORE swapping over. - * - * The synthetic content is deliberately schematic, not realistic — fake - * names like "Demo Operator" and "Test User" make it visually obvious - * to any presenter or visitor that the data is mocked. + * The synthetic content is deliberately schematic; fake names like + * "Demo Operator" and "Test User" make it visually obvious that the + * data is mocked. */ import { @@ -39,6 +31,7 @@ import { import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import { Badge } from '@/components/ui/badge' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { useTourAnchor } from '@/tours/engine/anchorRegistry' const SYNTHETIC_USERS = [ { id: 'demo-u1', name: 'Demo Operator', email: 'operator@demo.example', role: 'admin' }, @@ -87,8 +80,26 @@ const VRAM_USED = SYNTHETIC_TASKS.reduce((sum, t) => sum + t.vramGb, 0) const VRAM_PCT = Math.min(100, Math.round((VRAM_USED / TOTAL_VRAM_GB) * 100)) export function DemoAdminPanel(): JSX.Element { + const panelAnchor = useTourAnchor('admin-panel') + const usersTabAnchor = useTourAnchor('admin-tab-users') + const groupsTabAnchor = useTourAnchor('admin-tab-groups') + const projectsTabAnchor = useTourAnchor('admin-tab-projects') + const videoAccessTabAnchor = useTourAnchor('admin-tab-video-access') + const permissionsTabAnchor = useTourAnchor('admin-tab-permissions') + const sessionsTabAnchor = useTourAnchor('admin-tab-sessions') + const modelsTabAnchor = useTourAnchor('admin-tab-models') + const systemConfigTabAnchor = useTourAnchor('admin-tab-system-config') + const settingsTabAnchor = useTourAnchor('admin-tab-settings') + const permissionsPageAnchor = useTourAnchor('permissions-page') + const modelPageAnchor = useTourAnchor('model-management-page') + const modelMemoryAnchor = useTourAnchor('model-memory-validation') + const systemConfigPanelAnchor = useTourAnchor('system-config-panel') + const userManagementAnchor = useTourAnchor('user-management-page') + const groupManagementAnchor = useTourAnchor('group-management-page') + const videoAssignmentAnchor = useTourAnchor('project-video-assignment') + const sessionManagementAnchor = useTourAnchor('session-management-page') return ( -
+

Admin Panel

@@ -101,38 +112,38 @@ export function DemoAdminPanel(): JSX.Element { - + Users - + Groups - + Projects - + - + Permissions - + Sessions - + Models - + System Config - + Settings

({ @@ -146,7 +157,7 @@ export function DemoAdminPanel(): JSX.Element {
({ @@ -159,7 +170,6 @@ export function DemoAdminPanel(): JSX.Element {
({ @@ -172,7 +182,7 @@ export function DemoAdminPanel(): JSX.Element {
({ @@ -184,7 +194,7 @@ export function DemoAdminPanel(): JSX.Element { -
+

Role permissions

@@ -226,7 +236,7 @@ export function DemoAdminPanel(): JSX.Element {

({ @@ -238,7 +248,7 @@ export function DemoAdminPanel(): JSX.Element { -
+

Model configuration

@@ -269,7 +279,7 @@ export function DemoAdminPanel(): JSX.Element {

- + VRAM budget @@ -292,7 +302,7 @@ export function DemoAdminPanel(): JSX.Element { -
+

System configuration

@@ -342,18 +352,18 @@ interface SectionRow { } function Section({ - anchor, + anchorRef, title, description, rows, }: { - anchor: string + anchorRef?: (element: HTMLElement | null) => void title: string description: string rows: SectionRow[] }) { return ( -

+

{title}

{description}

diff --git a/annotation-tool/src/components/admin/GroupManagementPage.tsx b/annotation-tool/src/components/admin/GroupManagementPage.tsx index fec189c5..2a093b0b 100644 --- a/annotation-tool/src/components/admin/GroupManagementPage.tsx +++ b/annotation-tool/src/components/admin/GroupManagementPage.tsx @@ -44,6 +44,7 @@ import { TableRow, } from '@/components/ui/table' import { useUsers } from '@store/queries/admin/useUsers' +import { useTourAnchor } from '@/tours/engine/anchorRegistry' import { ConfirmDialog } from '../shared/ConfirmDialog' interface GroupMember { @@ -93,6 +94,7 @@ async function fetchGroups(): Promise { * as well as managing group membership. */ export function GroupManagementPage(): JSX.Element { + const pageAnchor = useTourAnchor('group-management-page') const queryClient = useQueryClient() const { data: groups = [], isLoading, error } = useQuery({ queryKey: groupKeys.list(), @@ -249,7 +251,7 @@ export function GroupManagementPage(): JSX.Element { } return ( -
+
@@ -847,7 +859,7 @@ export default function AnnotationWorkspace() { // available the moment the annotate route paints — // no drawing, dialog open, or sidebar selection // needed. -
+
}>