From b6d42712d0e877e295406d2bc20ec78e6d287eda Mon Sep 17 00:00:00 2001 From: Aaron Steven White Date: Mon, 22 Jun 2026 12:44:44 -0400 Subject: [PATCH 01/11] Add the tour anchor catalog as the typed source of every spotlightable surface and a zod Tour schema that types first-party tours and validates admin-authored tour JSON at load with field-anchored errors. --- .../src/tours/engine/anchorCatalog.ts | 197 ++++++++++++++++++ .../src/tours/engine/tourSchema.test.ts | 62 ++++++ .../src/tours/engine/tourSchema.ts | 167 +++++++++++++++ 3 files changed, 426 insertions(+) create mode 100644 annotation-tool/src/tours/engine/anchorCatalog.ts create mode 100644 annotation-tool/src/tours/engine/tourSchema.test.ts create mode 100644 annotation-tool/src/tours/engine/tourSchema.ts diff --git a/annotation-tool/src/tours/engine/anchorCatalog.ts b/annotation-tool/src/tours/engine/anchorCatalog.ts new file mode 100644 index 00000000..b293b5c4 --- /dev/null +++ b/annotation-tool/src/tours/engine/anchorCatalog.ts @@ -0,0 +1,197 @@ +/** + * The tour anchor catalog: the single source of truth for every UI surface a + * tour step can spotlight, and the published vocabulary an admin authors tours + * against. + * + * `AnchorId` is derived from this object (`keyof typeof anchorCatalog`), so a + * step's `anchor` is type-checked for first-party tours and validated at load + * for admin-authored tours. Each entry carries metadata the in-app anchor + * inspector and the generated anchor-reference doc render for authors. + */ + +/** Metadata for one spotlightable surface. */ +export interface AnchorMeta { + /** One-line, author-facing description shown in the inspector and generated doc. */ + readonly description: string + /** + * Where the anchor lives: a React Router path (e.g. `/app/annotate/:videoId`) + * or a coarse area label (e.g. `dialog:import`). Groups the inspector and tells + * an author which route a step needs. + */ + readonly surface: string + /** + * True when the element only mounts once the workspace enters a sub-state (a + * dialog opens, detection runs, a row exists). A step targeting a conditional + * anchor needs a `reachedBy` chain and/or a `driver` to put the workspace in + * that state. + */ + readonly conditional?: boolean + /** + * Opener anchor id(s) the engine clicks, in order, to mount this anchor (e.g. + * open the dialog it lives in). Each must itself be a catalog id. + */ + readonly reachedBy?: readonly string[] +} + +/** + * The catalog. Grouped by surface for readability; the grouping has no runtime + * meaning. `satisfies` keeps every value typed as `AnchorMeta` while preserving + * the literal keys for the derived `AnchorId` union. + */ +export const anchorCatalog = { + // ---- App shell + navigation (always mounted on /app) ---- + 'app-shell': { description: 'The main application shell (sidebar + content).', surface: '/app' }, + 'app-sidebar': { description: 'The left navigation sidebar.', surface: '/app' }, + + // ---- Video browser (/app) ---- + 'video-browser-root': { description: 'The video browser grid container.', surface: '/app' }, + 'video-browser-card-first': { description: 'The first video card in the grid.', surface: '/app', conditional: true }, + + // ---- Annotation workspace (/app/annotate/:videoId) ---- + 'video-player-scrubber': { description: 'The video player timeline scrubber.', surface: '/app/annotate/:videoId' }, + 'drawing-canvas': { description: 'The bounding-box drawing canvas over the video.', surface: '/app/annotate/:videoId' }, + 'type-assignment-picker': { description: 'The toolbar picker for the annotation type.', surface: '/app/annotate/:videoId' }, + 'event-annotation-button': { description: 'The Type/Object (event) annotation toggle.', surface: '/app/annotate/:videoId' }, + 'role-assignment-panel': { description: 'The role-assignment panel for an event.', surface: '/app/annotate/:videoId', conditional: true }, + 'show-timeline-button': { description: 'The button that expands the annotation timeline.', surface: '/app/annotate/:videoId', conditional: true }, + 'timeline': { description: 'The annotation timeline (expanded).', surface: '/app/annotate/:videoId', conditional: true, reachedBy: ['show-timeline-button'] }, + 'timeline-panel': { description: 'The timeline panel container.', surface: '/app/annotate/:videoId', conditional: true }, + 'save-indicator': { description: 'The auto-save status indicator.', surface: '/app/annotate/:videoId' }, + 'annotation-list-first': { description: 'The first annotation in the annotation list.', surface: '/app/annotate/:videoId', conditional: true }, + 'detect-objects-button': { description: 'The "Detect Objects" model-in-the-loop button.', surface: '/app/annotate/:videoId', conditional: true }, + 'annotation-candidates-list': { description: 'The list of model-proposed annotation candidates.', surface: '/app/annotate/:videoId', conditional: true, reachedBy: ['detect-objects-button'] }, + 'transcribe-audio-button': { description: 'The "Transcribe Audio" button.', surface: '/app/annotate/:videoId', conditional: true }, + 'transcript-dialog': { description: 'The transcript dialog.', surface: '/app/annotate/:videoId', conditional: true, reachedBy: ['transcribe-audio-button'] }, + 'edit-summary-button': { description: 'The "Edit Summary" button.', surface: '/app/annotate/:videoId', conditional: true }, + + // ---- Video summary + claims (dialogs off the annotation workspace) ---- + 'video-summary-editor': { description: 'The video summary editor dialog.', surface: 'dialog:summary', conditional: true, reachedBy: ['edit-summary-button'] }, + 'summary-tab-summary': { description: 'The "Summary" tab in the summary editor.', surface: 'dialog:summary', conditional: true }, + 'summary-tab-claims': { description: 'The "Claims" tab in the summary editor.', surface: 'dialog:summary', conditional: true }, + 'add-manual-claim-button': { description: 'The "Add manual claim" button in the summary editor.', surface: 'dialog:summary', conditional: true, reachedBy: ['summary-tab-claims'] }, + 'extract-claims-button': { description: 'The "Extract claims" button.', surface: 'dialog:summary', conditional: true }, + + // ---- Gloss editor (claim / type definition authoring) ---- + 'gloss-editor': { description: 'The gloss (definition) editor textarea.', surface: 'dialog:gloss', conditional: true }, + 'gloss-preview': { description: 'The rendered gloss preview.', surface: 'dialog:gloss', conditional: true }, + 'gloss-autocomplete-popup': { description: 'The #/@/^ reference autocomplete popup in the gloss editor.', surface: 'dialog:gloss', conditional: true }, + + // ---- Ontology workspace (/app/ontology) ---- + 'ontology-workspace-tabs': { description: 'The ontology workspace tab bar.', surface: '/app/ontology' }, + 'ontology-tab-entities': { description: 'The "Entity types" tab.', surface: '/app/ontology' }, + 'ontology-tab-events': { description: 'The "Event types" tab.', surface: '/app/ontology' }, + 'ontology-tab-roles': { description: 'The "Roles" tab.', surface: '/app/ontology' }, + 'ontology-tab-relations': { description: 'The "Relation types" tab.', surface: '/app/ontology' }, + 'ontology-add-type-button': { description: 'The "Add type" button on the active ontology tab.', surface: '/app/ontology' }, + 'entity-type-editor': { description: 'The entity-type editor dialog.', surface: 'dialog:type-editor', conditional: true, reachedBy: ['ontology-tab-entities', 'ontology-add-type-button'] }, + 'event-type-editor': { description: 'The event-type editor dialog.', surface: 'dialog:type-editor', conditional: true, reachedBy: ['ontology-tab-events', 'ontology-add-type-button'] }, + 'role-type-editor': { description: 'The role-type editor dialog.', surface: 'dialog:type-editor', conditional: true, reachedBy: ['ontology-tab-roles', 'ontology-add-type-button'] }, + 'relation-type-editor': { description: 'The relation-type editor dialog.', surface: 'dialog:type-editor', conditional: true, reachedBy: ['ontology-tab-relations', 'ontology-add-type-button'] }, + 'type-editor-mode-manual': { description: 'The "Manual" authoring-mode toggle in the type editor.', surface: 'dialog:type-editor', conditional: true }, + 'type-editor-mode-wikidata': { description: 'The "Wikidata" authoring-mode toggle in the type editor.', surface: 'dialog:type-editor', conditional: true }, + 'type-editor-mode-copy': { description: 'The "Copy from persona" authoring-mode toggle in the type editor.', surface: 'dialog:type-editor', conditional: true }, + 'type-editor-save': { description: 'The save button in the type editor.', surface: 'dialog:type-editor', conditional: true }, + 'type-editor-cancel': { description: 'The cancel button in the type editor.', surface: 'dialog:type-editor', conditional: true }, + + // ---- Ontology augmentation ---- + 'augmenter-search': { description: 'The Wikidata/ontology augmenter search box.', surface: 'dialog:type-editor', conditional: true }, + 'augmenter-results': { description: 'The augmenter suggestion results list.', surface: 'dialog:type-editor', conditional: true }, + 'augmenter-import-target': { description: 'The import-target selector in the augmenter.', surface: 'dialog:type-editor', conditional: true }, + + // ---- World workspace (/app/world/:personaId) ---- + 'world-panel-tabs': { description: 'The world workspace tab bar.', surface: '/app/world/:personaId' }, + 'world-tab-entities': { description: 'The world "Entities" tab.', surface: '/app/world/:personaId' }, + 'world-tab-events': { description: 'The world "Events" tab.', surface: '/app/world/:personaId' }, + 'world-tab-locations': { description: 'The world "Locations" tab.', surface: '/app/world/:personaId' }, + 'world-tab-times': { description: 'The world "Times" tab.', surface: '/app/world/:personaId' }, + 'world-tab-collections': { description: 'The world "Collections" tab.', surface: '/app/world/:personaId' }, + 'world-add-object-button': { description: 'The "Add object" button on the active world tab.', surface: '/app/world/:personaId' }, + 'world-add-time-collection-button': { description: 'The "Add time collection" button.', surface: '/app/world/:personaId' }, + 'world-add-entity-collection-button': { description: 'The "Add entity collection" button.', surface: '/app/world/:personaId' }, + 'entity-editor': { description: 'The world entity editor dialog.', surface: 'dialog:world', conditional: true, reachedBy: ['world-tab-entities', 'world-add-object-button'] }, + 'event-editor': { description: 'The world event editor dialog.', surface: 'dialog:world', conditional: true, reachedBy: ['world-tab-events', 'world-add-object-button'] }, + 'time-editor': { description: 'The world time editor dialog.', surface: 'dialog:world', conditional: true, reachedBy: ['world-tab-times', 'world-add-object-button'] }, + 'location-map-picker': { description: 'The location map picker.', surface: 'dialog:world', conditional: true }, + 'entity-name-input': { description: 'The name field in the entity editor.', surface: 'dialog:world', conditional: true }, + 'event-name-input': { description: 'The name field in the event editor.', surface: 'dialog:world', conditional: true }, + 'location-name-input': { description: 'The name field in the location editor.', surface: 'dialog:world', conditional: true }, + 'time-label-input': { description: 'The label field in the time editor.', surface: 'dialog:world', conditional: true }, + 'collection-builder': { description: 'The entity-collection builder.', surface: 'dialog:world', conditional: true, reachedBy: ['world-tab-collections', 'world-add-entity-collection-button'] }, + 'time-collection-builder': { description: 'The time-collection builder.', surface: 'dialog:world', conditional: true, reachedBy: ['world-tab-collections', 'world-add-time-collection-button'] }, + + // ---- Claims ---- + 'claim-editor': { description: 'The claim editor dialog.', surface: 'dialog:claims', conditional: true }, + 'claims-viewer': { description: 'The claims viewer.', surface: 'dialog:claims', conditional: true }, + 'claim-relations-viewer': { description: 'The claim-relations viewer.', surface: 'dialog:claims', conditional: true }, + 'claim-span-highlighter': { description: 'The claim span highlighter.', surface: 'dialog:claims', conditional: true }, + 'claims-extraction-dialog': { description: 'The claims-extraction dialog.', surface: 'dialog:claims', conditional: true }, + 'annotation-world-reference': { description: 'The world-object reference badge on an annotation.', surface: '/app/annotate/:videoId', conditional: true }, + + // ---- Detection / tracking ---- + 'detect-dialog': { description: 'The object-detection dialog.', surface: 'dialog:detect', conditional: true }, + 'detect-dialog-run-button': { description: 'The "Run detection" button in the detect dialog.', surface: 'dialog:detect', conditional: true }, + 'object-picker-popover': { description: 'The world-object picker popover.', surface: 'dialog:annotate', conditional: true }, + 'tracking-results-panel': { description: 'The tracking-results panel.', surface: '/app/annotate/:videoId', conditional: true }, + 'temporal-annotator': { description: 'The temporal (event) annotator.', surface: '/app/annotate/:videoId', conditional: true }, + + // ---- Audio / transcript ---- + 'audio-config-panel': { description: 'The audio configuration panel.', surface: 'dialog:audio', conditional: true }, + 'transcript-viewer': { description: 'The transcript viewer.', surface: 'dialog:transcript', conditional: true }, + + // ---- Admin panel (/app/admin) ---- + 'admin-panel': { description: 'The admin panel.', surface: '/app/admin' }, + 'admin-tab-users': { description: 'The admin "Users" tab.', surface: '/app/admin' }, + 'admin-tab-groups': { description: 'The admin "Groups" tab.', surface: '/app/admin' }, + 'admin-tab-projects': { description: 'The admin "Projects" tab.', surface: '/app/admin' }, + 'admin-tab-video-access': { description: 'The admin "Video access" tab.', surface: '/app/admin' }, + 'admin-tab-permissions': { description: 'The admin "Permissions" tab.', surface: '/app/admin' }, + 'admin-tab-sessions': { description: 'The admin "Sessions" tab.', surface: '/app/admin' }, + 'admin-tab-models': { description: 'The admin "Models" tab.', surface: '/app/admin' }, + 'admin-tab-system-config': { description: 'The admin "System config" tab.', surface: '/app/admin' }, + 'admin-tab-settings': { description: 'The admin "Settings" tab.', surface: '/app/admin' }, + 'user-management-page': { description: 'The user-management page.', surface: '/app/admin', reachedBy: ['admin-tab-users'] }, + 'permissions-page': { description: 'The permissions page.', surface: '/app/admin', reachedBy: ['admin-tab-permissions'] }, + 'session-management-page': { description: 'The session-management page.', surface: '/app/admin', reachedBy: ['admin-tab-sessions'] }, + 'model-management-page': { description: 'The model-management page.', surface: '/app/admin', reachedBy: ['admin-tab-models'] }, + 'model-memory-validation': { description: 'The model memory-validation surface.', surface: '/app/admin', reachedBy: ['admin-tab-models'] }, + 'system-config-panel': { description: 'The system-config panel.', surface: '/app/admin', reachedBy: ['admin-tab-system-config'] }, + 'api-keys-page': { description: 'The API-keys management page.', surface: '/app/admin' }, + 'persona-preferences-section': { description: 'The persona-preferences section.', surface: '/app/admin' }, + + // ---- Collaboration (projects / groups / shared) ---- + 'projects-page': { description: 'The projects page.', surface: '/app/projects' }, + 'projects-create-button': { description: 'The "Create project" button.', surface: '/app/projects' }, + 'project-name-input': { description: 'The project name field.', surface: '/app/projects', conditional: true, reachedBy: ['projects-create-button'] }, + 'project-video-assignment': { description: 'The project video-assignment surface.', surface: '/app/projects', conditional: true }, + 'groups-page': { description: 'The groups page.', surface: '/app/groups' }, + 'group-management-page': { description: 'The group-management page.', surface: '/app/groups' }, + 'groups-create-button': { description: 'The "Create group" button.', surface: '/app/groups' }, + 'group-name-input': { description: 'The group name field.', surface: '/app/groups', conditional: true, reachedBy: ['groups-create-button'] }, + 'shared-annotations-page': { description: 'The shared-annotations page.', surface: '/app/shared' }, + + // ---- Import / export ---- + 'import-trigger': { description: 'The header "Import" button.', surface: '/app' }, + 'import-dialog': { description: 'The import dialog.', surface: 'dialog:import', conditional: true, reachedBy: ['import-trigger'] }, + 'import-format-spec-trigger': { description: 'The import format-spec accordion trigger.', surface: 'dialog:import', conditional: true }, + 'import-result-dialog': { description: 'The import-result dialog.', surface: 'dialog:import', conditional: true }, + 'export-trigger': { description: 'The header "Export" button.', surface: '/app' }, + 'export-dialog': { description: 'The export dialog.', surface: 'dialog:export', conditional: true, reachedBy: ['export-trigger'] }, + + // ---- Misc spotlightable surfaces ---- + 'video-summary-card': { description: 'A video summary card.', surface: '/app/annotate/:videoId', conditional: true }, + 'quick-actions-track': { description: 'The quick-actions track.', surface: '/app/annotate/:videoId', conditional: true }, + 'interpolation-mode-selector': { description: 'The interpolation-mode selector.', surface: 'dialog:annotate', conditional: true }, + 'bezier-curve-editor': { description: 'The bezier-curve editor.', surface: 'dialog:annotate', conditional: true }, + 'motion-path-overlay': { description: 'The motion-path overlay.', surface: '/app/annotate/:videoId', conditional: true }, +} as const satisfies Record + +/** Every anchor id, derived from the catalog. A tour step's `anchor` is one of these. */ +export type AnchorId = keyof typeof anchorCatalog + +/** Every known anchor id, as an array. */ +export const allAnchorIds = Object.keys(anchorCatalog) as AnchorId[] + +/** Type guard: is this string a catalog anchor id? */ +export function isAnchorId(value: string): value is AnchorId { + return value in anchorCatalog +} diff --git a/annotation-tool/src/tours/engine/tourSchema.test.ts b/annotation-tool/src/tours/engine/tourSchema.test.ts new file mode 100644 index 00000000..4fbbb295 --- /dev/null +++ b/annotation-tool/src/tours/engine/tourSchema.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest' + +import { allAnchorIds, anchorCatalog, isAnchorId } from './anchorCatalog' +import { parseTour, safeParseTour, TourValidationError } from './tourSchema' + +const validTour = { + id: 'demo', + title: 'Demo', + description: 'A demo tour.', + durationMinutes: 2, + steps: [ + { anchor: 'app-shell', narration: 'Welcome.' }, + { anchor: 'video-browser-card-first', narration: 'Pick a video.', expectAction: 'click' }, + ], +} + +describe('anchorCatalog', () => { + it('derives a non-empty anchor id set with a working guard', () => { + expect(allAnchorIds.length).toBeGreaterThan(50) + expect(isAnchorId('app-shell')).toBe(true) + expect(isAnchorId('not-a-real-anchor')).toBe(false) + }) + + it('resolves every reachedBy opener to a catalog id', () => { + for (const [id, meta] of Object.entries(anchorCatalog)) { + for (const opener of meta.reachedBy ?? []) { + expect(isAnchorId(opener), `${id}.reachedBy references unknown anchor ${opener}`).toBe(true) + } + } + }) +}) + +describe('parseTour', () => { + it('accepts a valid tour', () => { + const tour = parseTour(validTour) + expect(tour.id).toBe('demo') + expect(tour.steps).toHaveLength(2) + expect(tour.steps[1].expectAction).toBe('click') + }) + + it('rejects an unknown anchor and suggests a near match, naming the source', () => { + const result = safeParseTour({ ...validTour, steps: [{ anchor: 'app-shel', narration: 'x' }] }, 'admin.json') + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error).toContain('Unknown tour anchor') + expect(result.error).toContain('app-shell') + expect(result.error).toContain('admin.json') + } + }) + + it('rejects a step missing narration', () => { + expect(() => parseTour({ ...validTour, steps: [{ anchor: 'app-shell' }] })).toThrow(TourValidationError) + }) + + it('rejects an unknown top-level key', () => { + expect(safeParseTour({ ...validTour, bogus: 1 }).ok).toBe(false) + }) + + it('rejects an empty steps array', () => { + expect(safeParseTour({ ...validTour, steps: [] }).ok).toBe(false) + }) +}) diff --git a/annotation-tool/src/tours/engine/tourSchema.ts b/annotation-tool/src/tours/engine/tourSchema.ts new file mode 100644 index 00000000..f78c2b58 --- /dev/null +++ b/annotation-tool/src/tours/engine/tourSchema.ts @@ -0,0 +1,167 @@ +/** + * The `Tour` schema: the single contract for a tour definition. + * + * `z.infer` is the `Tour` type, so first-party tours are + * compile-checked. `parseTour()` runs the same schema as a runtime validator, so + * an admin's tour JSON is validated at load and rejected with a field-anchored + * message (and near-match suggestions for an unknown anchor). + * + * A tour is fully serializable data; it carries no functions. A step that needs + * the workspace in a particular state declares a `driver` (a capability id plus + * params, resolved against the capability registry), so an admin authors the + * same tours an engineer does. + * + * Anchors are validated against the published `anchorCatalog`. The `anchor` + * field's type is `AnchorId`, so a first-party tour referencing a missing anchor + * fails to compile and an admin tour fails to load. + */ +import { z } from 'zod' + +import { type AnchorId, allAnchorIds, isAnchorId } from './anchorCatalog' + +/** Near-match suggestions for an unknown anchor id, ranked by token overlap. */ +function suggestAnchors(value: string): string { + const v = value.toLowerCase() + const tokens = v.split(/[^a-z0-9]+/).filter((t) => t.length > 2) + const ranked = allAnchorIds + .map((id) => { + const lid = id.toLowerCase() + let score = 0 + if (lid.includes(v) || v.includes(lid)) score += 5 + for (const t of tokens) if (lid.includes(t)) score += 1 + return { id, score } + }) + .filter((s) => s.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, 3) + .map((s) => s.id) + return ranked.length ? ` Did you mean: ${ranked.join(', ')}?` : '' +} + +/** + * An anchor id. The output type is the `AnchorId` union (so first-party tours + * are typed); the runtime check accepts any catalog id and otherwise fails with a + * near-match suggestion. + */ +export const anchorIdSchema = z + .string() + .superRefine((val, ctx) => { + if (!isAnchorId(val)) { + ctx.addIssue({ code: 'custom', message: `Unknown tour anchor ${JSON.stringify(val)}.${suggestAnchors(val)}` }) + } + }) + .transform((val) => val as AnchorId) + +/** A capability the engine runs to put the workspace into the state a step needs. */ +export const tourDriverSchema = z + .object({ + /** Capability id, resolved against the capability registry (e.g. 'seed-annotation'). */ + capability: z.string().min(1), + /** Opaque params passed to the capability. */ + params: z.record(z.string(), z.unknown()).optional(), + }) + .strict() + +export const tourStepSchema = z + .object({ + /** The surface to spotlight. */ + anchor: anchorIdSchema, + /** The caption shown on the step card (a short phrase, not a paragraph). */ + narration: z.string().min(1), + /** Markdown body rendered under the narration. */ + body: z.string().optional(), + /** React Router path the step's anchor lives on; the engine navigates here first. */ + route: z.string().optional(), + /** Values for `:param` placeholders in `route`. */ + routeParams: z.record(z.string(), z.string()).optional(), + /** The action the engine simulates and the visitor is expected to take. */ + expectAction: z.enum(['click', 'draw', 'type', 'hover', 'scrub', 'none']).optional(), + /** Text the engine types when `expectAction` is `'type'`. */ + typeText: z.string().optional(), + /** Render a modal spotlight that blocks click-through on this step. */ + modal: z.boolean().optional(), + /** Capability that puts the workspace into the state this step needs. */ + driver: tourDriverSchema.optional(), + }) + .strict() + +/** The audience a tour is shown to. */ +export const tourTargetingSchema = z + .object({ + /** Show only to users holding one of these roles. */ + roles: z.array(z.string()).optional(), + /** Show only in these deployment modes (e.g. 'multi-user', 'public-demo'). */ + deploymentModes: z.array(z.string()).optional(), + /** Show only when these features are enabled. */ + features: z.array(z.string()).optional(), + }) + .strict() + +export const tourSchema = z + .object({ + id: z.string().min(1), + title: z.string().min(1), + description: z.string(), + durationMinutes: z.number().nonnegative(), + /** Feature-area chips shown on the catalogue tile. */ + tags: z.array(z.string()).optional(), + /** Route the engine navigates to before the first step (default `/app`). */ + startRoute: z.string().optional(), + /** Persona pre-selected before the runner mounts, matched by name. */ + personaName: z.string().optional(), + /** One- or two-sentence recap shown on the post-tour page. */ + recap: z.string().optional(), + /** Suggested next tour id, offered on the recap page. */ + followUpTourId: z.string().optional(), + /** The audience this tour is shown to. */ + targeting: tourTargetingSchema.optional(), + /** A value of `false` hides this tour; an admin override file sets it to disable a shipped tour. */ + enabled: z.boolean().optional(), + steps: z.array(tourStepSchema).min(1), + }) + .strict() + +/** A validated tour. First-party tours are authored as `Tour`. */ +export type Tour = z.infer +export type TourStep = z.infer +export type TourDriver = z.infer +export type TourTargeting = z.infer + +/** Thrown when a tour fails schema validation; the message names each offending field. */ +export class TourValidationError extends Error { + constructor( + message: string, + readonly issues: z.ZodError['issues'], + readonly source?: string, + ) { + super(message) + this.name = 'TourValidationError' + } +} + +/** Render zod issues as a field-anchored, multi-line message. */ +function formatIssues(issues: z.ZodError['issues'], source?: string): string { + const where = source ? ` in ${source}` : '' + const lines = issues.map((i) => { + const path = i.path.length ? i.path.join('.') : '(root)' + return ` • ${path}: ${i.message}` + }) + return `Invalid tour${where}:\n${lines.join('\n')}` +} + +/** Validate a tour, throwing `TourValidationError` with a field-anchored message on failure. */ +export function parseTour(data: unknown, source?: string): Tour { + const result = tourSchema.safeParse(data) + if (!result.success) { + throw new TourValidationError(formatIssues(result.error.issues, source), result.error.issues, source) + } + return result.data +} + +/** Validate a tour, returning the tour or the formatted error message without throwing. */ +export function safeParseTour(data: unknown, source?: string): { ok: true; tour: Tour } | { ok: false; error: string } { + const result = tourSchema.safeParse(data) + return result.success + ? { ok: true, tour: result.data } + : { ok: false, error: formatIssues(result.error.issues, source) } +} From 082f7d54c2a763e17f61d581d5de9c44f6b0686f Mon Sep 17 00:00:00 2001 From: Aaron Steven White Date: Mon, 22 Jun 2026 12:47:18 -0400 Subject: [PATCH 02/11] Add the anchor registry so a component publishes its element under a tour anchor id and the engine reads the live element for a step through a synchronous subscription. --- .../src/tours/engine/anchorRegistry.test.tsx | 55 ++++++++++ .../src/tours/engine/anchorRegistry.tsx | 102 ++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 annotation-tool/src/tours/engine/anchorRegistry.test.tsx create mode 100644 annotation-tool/src/tours/engine/anchorRegistry.tsx diff --git a/annotation-tool/src/tours/engine/anchorRegistry.test.tsx b/annotation-tool/src/tours/engine/anchorRegistry.test.tsx new file mode 100644 index 00000000..7a58480c --- /dev/null +++ b/annotation-tool/src/tours/engine/anchorRegistry.test.tsx @@ -0,0 +1,55 @@ +import { render, screen } from '@testing-library/react' +import { describe, it, expect } from 'vitest' + +import { AnchorRegistry, AnchorRegistryProvider, useAnchorElement, useTourAnchor } from './anchorRegistry' + +describe('AnchorRegistry', () => { + it('registers, reads, and unregisters with change notifications', () => { + const registry = new AnchorRegistry() + const changed: string[] = [] + const unsubscribe = registry.subscribe((id) => changed.push(id)) + const element = document.createElement('div') + + registry.register('app-shell', element) + expect(registry.get('app-shell')).toBe(element) + expect(registry.snapshot().get('app-shell')).toBe(element) + + registry.unregister('app-shell') + expect(registry.get('app-shell')).toBeNull() + expect(changed).toEqual(['app-shell', 'app-shell']) + + unsubscribe() + registry.register('app-shell', element) + expect(changed).toHaveLength(2) + }) +}) + +function Probe() { + const element = useAnchorElement('app-shell') + return {element ? 'present' : 'absent'} +} + +function Anchored({ mounted }: { mounted: boolean }) { + const ref = useTourAnchor('app-shell') + return mounted ?
anchored
: null +} + +describe('useTourAnchor + useAnchorElement', () => { + it('reflects an anchor mounting and unmounting', () => { + const { rerender } = render( + + + + , + ) + expect(screen.getByTestId('probe').textContent).toBe('present') + + rerender( + + + + , + ) + expect(screen.getByTestId('probe').textContent).toBe('absent') + }) +}) diff --git a/annotation-tool/src/tours/engine/anchorRegistry.tsx b/annotation-tool/src/tours/engine/anchorRegistry.tsx new file mode 100644 index 00000000..475e27d1 --- /dev/null +++ b/annotation-tool/src/tours/engine/anchorRegistry.tsx @@ -0,0 +1,102 @@ +/** + * The anchor registry: a per-app store mapping each tour `AnchorId` to the live + * DOM element a component currently holds for it. + * + * A component publishes its element with `useTourAnchor(id)`, spreading the + * returned ref onto the node. The element registers in the same commit it + * mounts and unregisters when it unmounts, so a consumer always sees the element + * exactly while it is on screen. The engine reads a step's anchor with + * `useAnchorElement(id)`, which re-renders when that anchor's element appears, + * disappears, or swaps. The anchor inspector reads `snapshot()` to list what is + * currently registered. + */ +import { createContext, useCallback, useContext, useMemo, useSyncExternalStore } from 'react' +import type { ReactNode } from 'react' + +import type { AnchorId } from './anchorCatalog' + +type RegistryListener = (id: AnchorId) => void + +/** Holds the element for each registered anchor and notifies subscribers on change. */ +export class AnchorRegistry { + private readonly elements = new Map() + private readonly listeners = new Set() + + /** Publish `element` as the live node for `id`. */ + register(id: AnchorId, element: HTMLElement): void { + this.elements.set(id, element) + this.notify(id) + } + + /** Remove the element for `id`. */ + unregister(id: AnchorId): void { + if (this.elements.delete(id)) this.notify(id) + } + + /** The element currently registered for `id`, or null. */ + get(id: AnchorId): HTMLElement | null { + return this.elements.get(id) ?? null + } + + /** Subscribe to registrations; the listener receives the changed id. Returns an unsubscribe. */ + subscribe(listener: RegistryListener): () => void { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + /** A copy of the currently registered anchors, for the inspector and tests. */ + snapshot(): Map { + return new Map(this.elements) + } + + private notify(id: AnchorId): void { + for (const listener of this.listeners) listener(id) + } +} + +const AnchorRegistryContext = createContext(null) + +/** Provides one `AnchorRegistry` to the subtree; the engine and every anchored component share it. */ +export function AnchorRegistryProvider({ children }: { children: ReactNode }) { + const registry = useMemo(() => new AnchorRegistry(), []) + return {children} +} + +/** The registry for the current subtree. Throws when used outside an `AnchorRegistryProvider`. */ +export function useAnchorRegistry(): AnchorRegistry { + const registry = useContext(AnchorRegistryContext) + if (!registry) throw new Error('Tour anchors require an ancestor.') + return registry +} + +/** + * Register the calling component's element as the anchor `id`. Spread the + * returned ref onto the node the engine should spotlight; it registers on mount + * and unregisters on unmount. + */ +export function useTourAnchor(id: AnchorId): (element: HTMLElement | null) => void { + const registry = useAnchorRegistry() + return useCallback( + (element: HTMLElement | null) => { + if (element) registry.register(id, element) + else registry.unregister(id) + }, + [registry, id], + ) +} + +/** The element registered for `id`, re-rendering the caller whenever it changes. */ +export function useAnchorElement(id: AnchorId): HTMLElement | null { + const registry = useAnchorRegistry() + return useSyncExternalStore( + useCallback( + (notify: () => void) => registry.subscribe((changedId) => { + if (changedId === id) notify() + }), + [registry, id], + ), + () => registry.get(id), + ) +} From 5adbdc32aca356771eecd3653fb67a119c7f60c2 Mon Sep 17 00:00:00 2001 From: Aaron Steven White Date: Mon, 22 Jun 2026 15:32:32 -0400 Subject: [PATCH 03/11] Build the tour engine on the anchor registry: a port-based runner that resolves anchors by subscription, declarative state-driver capabilities, a config loader that validates admin tour files, an author-mode anchor inspector, and every component publishing its element through useTourAnchor. --- annotation-tool/playwright.tours.config.ts | 17 + annotation-tool/src/App.tsx | 18 +- .../src/components/admin/AdminPanel.tsx | 50 +- .../src/components/admin/DemoAdminPanel.tsx | 98 +-- .../components/admin/GroupManagementPage.tsx | 4 +- .../components/admin/ModelManagementPage.tsx | 51 +- .../src/components/admin/PermissionsPage.tsx | 10 +- .../admin/SessionManagementPage.tsx | 8 +- .../components/admin/SystemConfigPanel.tsx | 8 +- .../components/admin/UserManagementPage.tsx | 8 +- .../components/admin/VideoAssignmentPage.tsx | 8 +- .../annotation/AnnotationCandidatesList.tsx | 4 +- .../annotation/AnnotationEditor.tsx | 4 +- .../annotation/AnnotationWorkspace.tsx | 32 +- .../annotation/BezierCurveEditor.tsx | 4 +- .../annotation/InterpolationModeSelector.tsx | 5 +- .../annotation/MotionPathOverlay.tsx | 15 +- .../components/annotation/ObjectPicker.tsx | 4 +- .../annotation/QuickActionsPanel.tsx | 4 +- .../annotation/TemporalAnnotator.tsx | 4 +- .../annotation/TrackingResultsPanel.tsx | 4 +- .../src/components/annotation/VideoPlayer.tsx | 8 +- .../annotation/timeline/TimelineRoot.tsx | 4 +- .../src/components/claims/ClaimEditor.tsx | 5 +- .../claims/ClaimRelationsViewer.tsx | 5 +- .../claims/ClaimSpanHighlighter.tsx | 5 +- .../claims/ClaimsExtractionDialog.tsx | 5 +- .../src/components/claims/ClaimsViewer.tsx | 5 +- .../data-management/ExportDialog.tsx | 4 +- .../data-management/ImportDataDialog.tsx | 8 +- .../data-management/ImportDialog.tsx | 5 +- .../data-management/ImportResultDialog.tsx | 5 +- .../components/dialogs/DetectionDialog.tsx | 7 +- .../src/components/layout/Layout.tsx | 14 +- .../src/components/ontology/GlossEditor.tsx | 12 +- .../components/ontology/OntologyAugmenter.tsx | 11 +- .../ontology/RelationTypeEditor.tsx | 5 +- .../persona/PersonaPreferencesSection.tsx | 4 +- .../settings/ApiKeyManagementPanel.tsx | 4 +- .../src/components/shared/BaseTypeEditor.tsx | 15 +- .../src/components/shared/ModeSelector.tsx | 11 +- .../shared/SaveStatusIndicator.test.tsx | 52 +- .../components/shared/SaveStatusIndicator.tsx | 16 +- .../src/components/shared/WikidataSearch.tsx | 9 +- annotation-tool/src/components/ui/sidebar.tsx | 39 +- .../src/components/video/AudioConfigPanel.tsx | 5 +- .../src/components/video/TranscriptViewer.tsx | 5 +- .../src/components/video/VideoBrowser.tsx | 34 +- .../src/components/video/VideoSummaryCard.tsx | 4 +- .../components/video/VideoSummaryEditor.tsx | 17 +- .../components/workspaces/ObjectWorkspace.tsx | 41 +- .../workspaces/OntologyWorkspace.tsx | 20 +- .../components/world/CollectionBuilder.tsx | 7 +- .../src/components/world/CollectionEditor.tsx | 4 +- .../src/components/world/EntityEditor.tsx | 7 +- .../src/components/world/EventEditor.tsx | 7 +- .../src/components/world/LocationEditor.tsx | 7 +- .../src/components/world/TimeEditor.tsx | 7 +- annotation-tool/src/demo/DemoShell.tsx | 12 +- .../src/demo/pages/DemoLandingPage.tsx | 8 +- annotation-tool/src/lib/mergeRefs.ts | 20 + annotation-tool/src/main.tsx | 76 ++- annotation-tool/src/pages/GroupsPage.tsx | 11 +- annotation-tool/src/pages/ProjectsPage.tsx | 11 +- .../src/pages/SharedAnnotationsPage.tsx | 4 +- .../src/pages/TourCataloguePage.tsx | 1 - .../src/tours/content/tourLoader.test.ts | 145 ++++ .../src/tours/content/tourLoader.ts | 127 ++++ .../src/tours/engine/AnchorInspector.test.tsx | 119 ++++ .../src/tours/engine/AnchorInspector.tsx | 193 ++++++ .../src/tours/engine/SpotlightOverlay.tsx | 127 ++-- annotation-tool/src/tours/engine/StepCard.tsx | 166 ++--- .../src/tours/engine/TourRunner.tsx | 622 +++++++----------- .../src/tours/engine/capabilities.ts | 49 ++ annotation-tool/src/tours/engine/events.ts | 24 + annotation-tool/src/tours/engine/index.ts | 43 +- annotation-tool/src/tours/engine/ports.ts | 92 +++ annotation-tool/src/tours/engine/seeders.ts | 271 ++++++++ .../src/tours/engine/simulateAction.ts | 392 ++++------- annotation-tool/src/tours/engine/types.ts | 155 ----- .../src/tours/engine/waitForAnchor.test.ts | 80 --- .../src/tours/engine/waitForAnchor.ts | 88 --- annotation-tool/src/tours/index.ts | 4 +- annotation-tool/src/tours/menu/TourMenu.tsx | 8 +- .../src/tours/menu/TourProvider.tsx | 221 +++---- .../src/tours/menu/tour-context.ts | 6 +- annotation-tool/src/tours/scripts/admin.ts | 37 +- .../src/tours/scripts/collaboration.ts | 49 +- .../src/tours/scripts/events-roles-claims.ts | 100 +-- .../src/tours/scripts/first-annotation.ts | 71 +- .../src/tours/scripts/import-export.ts | 53 +- annotation-tool/src/tours/scripts/index.ts | 46 +- .../tours/scripts/keyframes-interpolation.ts | 43 +- .../src/tours/scripts/model-in-the-loop.ts | 60 +- .../src/tours/scripts/ontology-authoring.ts | 86 +-- .../src/tours/scripts/summaries-and-claims.ts | 63 +- annotation-tool/src/tours/scripts/welcome.ts | 22 +- .../tours/scripts/wikidata-augmentation.ts | 89 +-- .../src/tours/scripts/world-layer.ts | 56 +- docker-compose.e2e.mp4.yml | 16 + 100 files changed, 2453 insertions(+), 2196 deletions(-) create mode 100644 annotation-tool/playwright.tours.config.ts create mode 100644 annotation-tool/src/lib/mergeRefs.ts create mode 100644 annotation-tool/src/tours/content/tourLoader.test.ts create mode 100644 annotation-tool/src/tours/content/tourLoader.ts create mode 100644 annotation-tool/src/tours/engine/AnchorInspector.test.tsx create mode 100644 annotation-tool/src/tours/engine/AnchorInspector.tsx create mode 100644 annotation-tool/src/tours/engine/capabilities.ts create mode 100644 annotation-tool/src/tours/engine/events.ts create mode 100644 annotation-tool/src/tours/engine/ports.ts create mode 100644 annotation-tool/src/tours/engine/seeders.ts delete mode 100644 annotation-tool/src/tours/engine/types.ts delete mode 100644 annotation-tool/src/tours/engine/waitForAnchor.test.ts delete mode 100644 annotation-tool/src/tours/engine/waitForAnchor.ts create mode 100644 docker-compose.e2e.mp4.yml 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/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. -
+
}>