.github/9
ISSUE_TEMPLATE/3bug_report.md config.yml feature_request.md
workflows/3ci.yml clean-clone.yml release.yml
CODEOWNERS dependabot.yml pull_request_template.md
apps/630
api/250
src/243
common/30
__tests__/3authorize.test.ts bigint-serialization.interceptor.test.ts files-limits.test.ts
filters/1all-exceptions.filter.ts
guards/3auth.guard.ts internal-service.guard.ts rate-limit.guard.ts
interceptors/2actor-context.interceptor.ts bigint-serialization.interceptor.ts
middleware/2idempotency.test.ts idempotency.ts
pipes/1zod-validation.pipe.ts
actor-context.ts audit.service.ts cache.test.ts cache.ts cookies.ts core.module.ts deprecation.ts email.service.ts errors.ts event-bus.ts log-scrubber.test.ts log-scrubber.ts logger.ts permission-cache-bridge.ts prisma.service.ts redis.service.ts trace-metadata.ts types.ts
config/1env.ts
lib/11
__tests__/5crypto.test.ts filter-builder.test.ts jwt.test.ts pagination.test.ts positions.test.ts
authorize.ts crypto.ts filter-builder.ts jwt.ts pagination.ts positions.ts
modules/197
ai/28
__tests__/4ai-writing.test.ts database-ai.test.ts generate-executor.test.ts prompts.test.ts
generator/4executor.ts generate.controller.ts generate.dto.ts generate.service.ts
ai-chat.service.ts ai-rate-limit.ts ai-writing.controller.ts ai-writing.dto.ts ai-writing.service.ts ai.controller.ts ai.module.ts ai.service.ts budget-guard.service.ts database-ai.controller.ts database-ai.dto.ts database-ai.service.ts db-prompts.ts dto.ts gateway-provider.ts ledger.service.ts prompts.ts rag.controller.ts rag.dto.ts rag.service.ts
api-keys/3api-keys.controller.ts api-keys.module.ts api-keys.service.ts
audit/2audit-log.controller.ts audit.module.ts
auth/4auth.controller.ts auth.module.ts auth.service.ts dto.ts
automations/5automations.controller.ts automations.module.ts automations.service.ts dto.ts webhook-incoming.controller.ts
blocks/5
__tests__/1batch-version-cas.test.ts
block.service.ts blocks.controller.ts blocks.module.ts dto.ts
comments/4comments.controller.ts comments.module.ts comments.service.ts dto.ts
databases/9
__tests__/3normalize-select-options.test.ts option-id-canonicalization.test.ts record-open-query-shape.test.ts
database.service.ts databases.controller.ts databases.module.ts dto.ts property-cache-schema.ts query.service.ts
docs/2docs.controller.ts docs.module.ts
files/10
__tests__/1quota.test.ts
dto.ts files.controller.ts files.module.ts files.service.ts quota.ts record.ts s3.ts uploads.controller.ts uploads.service.ts
forms/18debug.controller.ts form.dto.ts forms.controller.ts forms.module.ts forms.service.ts markdown.test.ts markdown.ts optional-auth.guard.ts public-forms.controller.ts public-forms.service.ts question.dto.ts questions.service.ts rate-limit.ts recaptcha.ts slug.ts submissions.controller.ts submissions.service.ts submit-schema.ts
health/3health.controller.ts health.module.ts health.service.ts
integrations/3integrations.controller.ts integrations.module.ts integrations.service.ts
internal/12
__tests__/2flush-query-shape.test.ts internal.endpoints.test.ts
ai-test-seam.controller.ts ai-test-seam.dto.ts ai-test-seam.service.ts internal.controller.ts internal.dto.ts internal.module.ts internal.service.ts test-seam.controller.ts test-seam.dto.ts test-seam.service.ts
notifications/3notifications.controller.ts notifications.module.ts notifications.service.ts
oauth/6
__tests__/1oauth.scopes.test.ts
oauth.controller.ts oauth.module.ts oauth.service.ts scopes.ts token-keys.ts
org/15audit.service.ts embed-preview.controller.ts guards.ts members.service.ts org-admin.controller.ts org-policy.service.ts org.module.ts overview.service.ts policy-effective.controller.ts policy.schema.ts policy.test.ts retention.service.ts totp.test.ts totp.ts two-factor.service.ts
orgs/5dto.ts orgs.controller.ts orgs.module.ts orgs.service.ts util.ts
pages/4dto.ts page.service.ts pages.controller.ts pages.module.ts
scim/15
__tests__/3email-mapping.test.ts etag.test.ts filters.test.ts
etag.ts filters.ts groups.service.ts schemas.ts scim-admin.controller.ts scim-admin.service.ts scim-auth.guard.ts scim-auth.service.ts scim-errors.ts scim.controller.ts scim.module.ts users.service.ts
search/4
__tests__/1search.service.test.ts
search.controller.ts search.module.ts search.service.ts
sessions/3sessions.controller.ts sessions.module.ts sessions.service.ts
share/3share.controller.ts share.module.ts share.service.ts
sso/16
__tests__/4enforcement.test.ts oidc.verify.test.ts provisioning.test.ts saml.validate.test.ts
enforcement.ts oidc.controller.ts oidc.service.ts oidc.ts provisioning.service.ts saml.controller.ts saml.ts schemas.ts sso-admin.controller.ts sso-admin.service.ts sso-secrets.ts sso.module.ts
teams/4dto.ts teams.controller.ts teams.module.ts teams.service.ts
users/4dto.ts users.controller.ts users.module.ts users.service.ts
webhooks/3webhooks.controller.ts webhooks.module.ts webhooks.service.ts
workspaces/4dto.ts workspaces.controller.ts workspaces.module.ts workspaces.service.ts
types/1xmldom.d.ts
app.module.ts constants.ts main.ts
_probe_rawbody.cjs .env.example Dockerfile package.json README.md tsconfig.json vitest.config.ts
realtime/33
src/17auth.ts comments.ts config.ts event-source.ts http-store.ts index.ts metrics.ts notifications.ts page-updated-source.ts presence.ts prisma-store.ts protocol.ts rate-limit.ts room-key.ts rooms.ts server.ts update-log.ts
test/11auth.test.ts config.test.ts event-source.test.ts http-store.test.ts metrics-merge.test.ts page-updated-source.test.ts presence.test.ts protocol.test.ts rate-limit.test.ts rooms.test.ts task-0094-builders.test.ts
.env.example Dockerfile package.json README.md tsconfig.json
web/255
src/244
app/45
__tests__/1theme-tokens.test.ts
(app)/26
w/25
[workspace]/25
ask/1page.tsx
databases/1
[id]/1page.tsx
favorites/1page.tsx
files/1page.tsx
notifications/1page.tsx
page/4
[id]/4
[block]/1page.tsx
share/1page.tsx
layout.tsx page.tsx
search/1page.tsx
settings/10
appearance/1page.tsx
automations/3
[id]/1page.tsx
new/1page.tsx
page.tsx
billing/1page.tsx
developers/1page.tsx
members/1page.tsx
permissions/1page.tsx
teams/1page.tsx
page.tsx
shared/1page.tsx
templates/1page.tsx
trash/1page.tsx
layout.tsx page.tsx
layout.tsx
(auth)/4
login/1page.tsx
register/1page.tsx
reset-password/1page.tsx
layout.tsx
api/2
metrics/1route.ts
v1/1
health/1route.ts
f/1
[slug]/1page.tsx
health/1
ready/1route.ts
org/2
[orgId]/2
admin/2
integrations/1page.tsx
page.tsx
share/1
[id]/1page.tsx
error.tsx global-error.tsx globals.css layout.tsx not-found.tsx page.tsx providers.tsx
components/118
admin/10
org-admin/8ApiKeysTab.tsx AuditTab.tsx MembersTab.tsx OverviewTab.tsx PoliciesTab.tsx RetentionTab.tsx WebhooksTab.tsx WorkspacePicker.tsx
ScimCard.tsx SsoTabs.tsx
ai/6ai-panel.tsx ask-ai-submenu.tsx db-ai-dialog.tsx generate-page-dialog.tsx writing-actions-menu.tsx writing-diff.tsx
automations/5action-editor.tsx condition-editor.tsx lib.ts run-history.tsx trigger-editor.tsx
collaboration/6CursorLayer.tsx OfflineBanner.tsx PresenceBar.tsx README.md ReviewFailedDialog.tsx StatusChip.tsx
comments/3comment-panel.tsx comment-thread.tsx mention-input.tsx
database/27
__tests__/2cell-editor.test.tsx cell-renderer.test.tsx
AiExtractDialog.tsx AiFormulaDialog.tsx AiGenerateDialog.tsx board-view.tsx buttons-panel.tsx calendar-view.tsx cell-editor.tsx cell-renderer.tsx chart-view.tsx database-page-view.tsx database-toolbar.tsx database-view-renderer.tsx db-embedded.tsx filter-builder.tsx form-builder-view.tsx formula-editor.tsx gallery-view.tsx group-builder.tsx list-view.tsx property-editor.tsx record-header.tsx record-panel.tsx sort-builder.tsx table-view.tsx timeline-view.tsx
dialogs/7
__tests__/1move-to-dialog.test.tsx
create-dialog.tsx dialog.tsx file-permissions-dialog.tsx invite-dialog.tsx move-to-dialog.tsx share-dialog.tsx
editor/19
__tests__/3code-block-text.test.ts code-block.test.tsx expandable.test.tsx
block-card.tsx block-renderer.tsx block-save-queue.ts block-toolbar.tsx context-menu.tsx expandable.tsx history-popover.tsx inline-editor.tsx insert-dialog.tsx page-canvas.tsx page-spinner.tsx page-view.tsx selection-toolbar.tsx slash-menu.tsx template-dialog.tsx type-menu.tsx
files/4FileBlock.tsx FileBrowser.tsx FilePicker.tsx PreviewModal.tsx
form/4form-field.tsx form-renderer.tsx form-responses-view.tsx form-view.tsx
layout/8
__tests__/1page-tree-icon.test.tsx
command-palette.tsx factory-logo.tsx page-tree-node.tsx sidebar.tsx theme-provider.tsx topbar.tsx workspace-shell.tsx
mentions/3
__tests__/1mention-model.test.ts
mention-model.ts MentionPopup.tsx
search/3ask-ai-search.tsx results-list.tsx search-dialog.tsx
settings/10
api-keys/4ApiKeyCreateDialog.tsx ApiKeyRevealDialog.tsx ApiKeyTable.tsx ScopesBadge.tsx
webhooks/5CurlExamples.tsx DeliveryInspector.tsx EventExplorer.tsx OverviewTab.tsx WebhooksTab.tsx
settings-shell.tsx
ui/3confirm-dialog.tsx form.tsx toaster.tsx
hooks/21
__tests__/4realtime-frame-handler.test.ts use-blocks.test.ts use-database-options.test.ts use-workspace.test.tsx
realtime-frame-handler.ts use-ai-stream.ts use-auth.ts use-blocks.ts use-database.ts use-db-ai-stream.ts use-forms.ts use-members.ts use-mention-sources.ts use-notifications.ts use-offline-status.ts use-pages.ts use-presence-announcer.ts use-presence-toasts.ts use-presence.ts use-realtime.ts use-workspace.ts
lib/50
__tests__/21ai-database.test.ts ai-generate.test.ts ai-sse.test.ts ai-writing.test.ts api-keys.test.ts api-members.test.ts auth-refresh.test.ts auth-store.test.ts batch-queue-drop.test.ts block-content.test.ts clipboard-import.test.ts code-highlight.test.ts offline-status.test.ts presence-announcer.test.ts presence-color.test.ts presence-cursor.test.ts presence-emitter.test.ts realtime-url.test.ts search.test.ts utils.test.ts writing-diff.test.ts
files/7
__tests__/2picker.test.ts upload.test.ts
api.ts picker.ts upload-store.ts upload.ts use-file-uploads.ts
ai-database.ts ai.ts api-keys.ts api.ts auth-store.ts batch-queue.ts block-content.ts clipboard-import.ts code-highlight.ts constants.ts offline-status.ts org-admin.ts presence-announcer.ts presence-color.ts presence-cursor.ts presence-editor-views.ts presence-emitter.ts query-client.ts realtime.ts search.ts types.ts utils.ts
stores/8
__tests__/1block-patch-store.test.ts
block-patch-store.ts comment-pill-store.ts database-store.ts editor-store.ts offlineQueue.ts presence-store.ts ui-store.ts
instrumentation.ts middleware.ts
.env.example Dockerfile next-env.d.ts next.config.mjs package.json postcss.config.mjs README.md tailwind.config.ts tsconfig.json tsconfig.tsbuildinfo vitest.config.ts
worker/92
src/87
__tests__/13ai-embedding.test.ts ai-request.test.ts ai-usage-budget.test.ts audit-export.test.ts email-decision.test.ts encrypt.test.ts health-search-index-lag.test.ts mime.test.ts retention.test.ts s3-sign.test.ts search-index.test.ts smtp.test.ts webhook-sign.test.ts
health/1server.ts
lib/12
__tests__/1clamav.test.ts
clamav.ts encrypt.ts job-otel.ts mime.ts notify.ts prisma.ts redis-stream.ts redis.ts s3.ts sharp.ts smtp.ts
processors/56
automations/28
__tests__/3condition-evaluator.spec.ts executors.spec.ts run-pipeline.spec.ts
executors/17assign-user.ts call-integration.ts change-status.ts classify.ts create-notification.ts create-page.ts email-cap.ts helpers.ts integration-registry.ts permission.ts record-write.ts registry.ts run-ai-action.ts send-email.ts send-webhook.ts types.ts update-record.ts
condition-evaluator.ts dead-letter.ts log.ts matcher.ts pipeline.ts run-state.ts schedule.ts watchdog.ts
files/15
__tests__/4errors.test.ts scan-gate.test.ts status.test.ts temp.test.ts
steps/4finalize.ts metadata.ts scan.ts thumbnails.ts
cleanup.ts errors.ts generateThumbs.ts processor.ts status.ts temp.ts types.ts
search-index/4builders.ts grants.ts index.ts processor.ts
ai-embedding.ts ai.processor.ts audit-export.ts automations.processor.ts email-decision.processor.ts email.processor.ts maintenance.processor.ts retention.ts webhooks.processor.ts
config.ts events.ts index.ts logger.ts queues.ts
.env.example Dockerfile package.json README.md tsconfig.json
docs/73
api/17ai.md authentication.md automations.md blocks.md comments.md databases.md files.md forms.md openapi.yaml org-admin.md pages.md scim.md search.md sso.md versioning.md webhooks.md workspaces.md
architecture/10ai-architecture.md automation.md backend-architecture.md database-architecture.md frontend-architecture.md realtime-architecture.md search-architecture.md security-architecture.md storage.md system-architecture.md
database/2database-architecture.md redis-keys.md
development/8admin-guide.md coding-standards.md deployment.md development-guide.md files-pipeline.md perf-report.md performance.md testing.md
product/4functional-requirements.md non-functional-requirements.md product-requirements.md ux-requirements.md
requirements/1requirements-matrix.md
security/5audit-report.md files.md forms.md license-allowlist.md security-architecture.md
tasks/16phase-00-foundation.md phase-01-authentication.md phase-02-workspace.md phase-03-pages.md phase-04-editor.md phase-05-databases.md phase-06-relations.md phase-07-collaboration.md phase-08-search.md phase-09-files.md phase-10-forms.md phase-11-automation.md phase-12-ai.md phase-13-api.md phase-14-enterprise.md phase-15-production.md
ui/9ai.md automations.md design-system.md developers.md files.md forms.md org-admin.md scim.md sso.md
support-matrix.md
infrastructure/71
caddy/1Caddyfile
docker/6
init/101-extensions.sql
opensearch/1init-indices.json
.env.docker docker-compose.yml opensearch-init.sh README.md
kubernetes/40
observability/20
alertmanager/1alertmanager.yaml
grafana/8
dashboards/6ai.json api-overview.json infra.json realtime.json search.json workers-queues.json
provisioning/2
dashboards/1dashboards.yaml
datasources/1datasource.yaml
prometheus/3alerts.yaml recording-rules.yaml scrape.yaml
scripts/1sync-sources.mjs
alertmanager.yaml grafana.yaml kustomization.yaml namespace.yaml node-exporter.yaml otel-collector.yaml prometheus.yaml
postgres/7configmap.yaml cronjob-backup.yaml cronjob-opensearch-snapshot.yaml pgbouncer.yaml replica.yaml statefulset.yaml walg-iam-policy.yaml
api.yaml configmap.yaml ingress.yaml kustomization.yaml namespace.yaml networkpolicy.yaml pdb.yaml README.md realtime.yaml redis.yaml secret.example.yaml web.yaml worker.yaml
nginx/2nginx.conf README.md
observability/14
alertmanager/1alertmanager.yaml
grafana/8
dashboards/6ai.json api-overview.json infra.json realtime.json search.json workers-queues.json
provisioning/2
dashboards/1dashboards.yaml
datasources/1datasource.yaml
prometheus/3alerts.yaml recording-rules.yaml scrape.yaml
scripts/1dashboard-completeness.mjs
README.md
terraform/8compute.tf data.tf main.tf network.tf outputs.tf README.md security.tf variables.tf
packages/289
ai/24
src/20
providers/7anthropic.ts google.ts http.ts index.ts openai-compatible.ts rag.test.ts writing.test.ts
schemas/2database.ts generate.ts
adapters.test.ts config.test.ts config.ts gateway.test.ts gateway.ts index.ts tools.test.ts tools.ts types.ts usage.test.ts usage.ts
package.json README.md tsconfig.json vitest.config.ts
auth/11
src/7index.ts passwords.test.ts passwords.ts sessions.test.ts sessions.ts tokens.test.ts tokens.ts
package.json README.md tsconfig.json vitest.config.ts
collaboration/17
src/13awareness.ts codec.test.ts codec.ts index.ts lww.test.ts lww.ts mapping.ts offline.ts outbox.test.ts outbox.ts provider.test.ts provider.ts types.ts
package.json README.md tsconfig.json vitest.config.ts
config/7
src/4eslint.base.js index.ts prettier.base.json tsconfig.base.json
package.json README.md tsconfig.json
database-client/5
src/2index.test.ts index.ts
package.json README.md tsconfig.json
database-engine/33
src/29
filter/2filter.test.ts index.ts
formula/8evaluator.ts formula.test.ts functions.ts index.ts lexer.ts parser.ts types.ts validate.ts
query/6group.ts index.ts paginate.ts pivot.ts query.test.ts sort.ts
relations/2index.ts relations.test.ts
rollups/2index.ts rollups.test.ts
values/5dates.ts index.ts registry.ts types.ts values.test.ts
views/3buttons.ts index.ts views.test.ts
index.ts
package.json README.md tsconfig.json vitest.config.ts
editor/25
src/21
marks/1highlight.ts
nodes/2equation.ts mention.ts
autosave.test.ts autosave.ts clipboard.test.ts clipboard.ts codec.test.ts codec.ts context-menu.test.ts context-menu.ts diff.test.ts diff.ts factory.ts history.test.ts history.ts index.ts positions.ts slash.test.ts slash.ts types.ts
package.json README.md tsconfig.json vitest.config.ts
observability/20
src/16
__tests__/8db-redis-metrics.test.ts metrics.test.ts otel.test.ts pino-otel.test.ts prometheus.test.ts resource.test.ts scrubber.test.ts traceparent.test.ts
index.ts metrics.ts otel.ts pino-otel.ts prometheus.ts resource.ts scrubber.ts traceparent.ts
package.json README.md tsconfig.json vitest.config.ts
permissions/11
src/7index.ts levels.test.ts levels.ts resolve.test.ts resolve.ts rules.test.ts rules.ts
package.json README.md tsconfig.json vitest.config.ts
search/22
src/18chunking.test.ts chunking.ts client.test.ts client.ts embeddings.test.ts embeddings.ts hybrid.test.ts hybrid.ts index.ts indexer.test.ts indexer.ts mapping.test.ts mapping.ts queries.test.ts queries.ts rrf.test.ts rrf.ts types.ts
package.json README.md tsconfig.json vitest.config.ts
shared/61
src/57
dto/17ai.dto.ts auth.dto.test.ts auth.dto.ts block.dto.ts browser-safety.test.ts comment.dto.ts database.dto.test.ts database.dto.ts file.dto.ts formula-ast.ts index.ts notification.dto.ts page.dto.ts search.dto.ts share.dto.ts webhook.dto.ts workspace.dto.ts
events/8audit-catalog.test.ts audit-catalog.ts audit-csv.test.ts audit-csv.ts audit-scope.test.ts audit-scope.ts catalog.test.ts catalog.ts
realtime/6comment-events.ts frames.test.ts frames.ts notification-policy.ts notification-prefs.ts task-0094.test.ts
automation.test.ts automation.ts constants.ts crypto.test.ts crypto.ts env.ts errors.ts events.test.ts events.ts forms.test.ts forms.ts index.ts markdown.test.ts markdown.ts mentions.test.ts mentions.ts net.test.ts net.ts positions.test.ts positions.ts presence.test.ts presence.ts security-headers.ts webhook-delivery.test.ts webhook-delivery.ts webhook-events.ts
package.json README.md tsconfig.json vitest.config.ts
storage/16
src/12client.test.ts client.ts index.ts keys.test.ts keys.ts mime.test.ts mime.ts presign.test.ts presign.ts thumbnails.test.ts thumbnails.ts types.ts
package.json README.md tsconfig.json vitest.config.ts
ui/37
src/33
components/19avatar.tsx badge.tsx button.tsx checkbox.tsx command.tsx context-menu.tsx dialog.tsx dropdown-menu.tsx empty-state.tsx index.ts popover.tsx separator.tsx skeleton.tsx slider.tsx spinner.tsx switch.tsx tabs.tsx toast.tsx tooltip.tsx
color.ts command-filter.test.ts command-filter.ts focus.test.ts focus.ts formatters.test.ts formatters.ts index.ts theme.test.ts theme.ts toast-store.test.ts toast-store.ts tokens.test.ts tokens.ts
package.json README.md tsconfig.json vitest.config.ts
prisma/16
migrations/13
20260927000000_init/1migration.sql
20260928000000_search_documents_content_hash/1migration.sql
20260929000000_workspace_usage_storage_bytes/1migration.sql
20260930000000_form_submitted_notification_type/1migration.sql
20261001000000_ai_phase12/1migration.sql
20261002000000_webhooks_secret_encrypted_dlq/1migration.sql
20261002111436_webhook_delivery_latency/1migration.sql
20261002120000_org_admin_2fa_retention_unique/1migration.sql
20261002215124_audit_export/1migration.sql
20261003210000_perf_indexes_hot_paths/1migration.sql
20261004004500_perf_indexes_hot_paths_2/1migration.sql
migration_lock.toml README.md
schema.prisma seed.ts verify.ts
scripts/7backup-health.ts docs-completeness.mjs generate-openapi.mjs minio-init.ts opensearch-reindex.ts storage-orphans.ts verify-clean-clone.sh
tests/169
e2e/63
.auth/1user.json
fixtures/7
files/6clip.mp4 clip.wav clip.webm hero-1400.png note.docx note.txt
realtime.ts
mocks/2oidc-issuer.ts saml-idp.ts
playwright-report/3
data/2a6557bae9c2714a658968d4eb8534195c08ebda8.png e3092d07d00a16063e46385c02a559a9054fa847.webm
index.html
reports/1files-security.json
specs/34ai-database.spec.ts ai-generate.spec.ts ai-rag.spec.ts ai-security.spec.ts ai-writing.spec.ts ai.spec.ts api-keys.spec.ts appearance-theme.spec.ts auth.spec.ts automation.spec.ts collab-two-tab.spec.ts comments.spec.ts databases.spec.ts default-workspace-alias.spec.ts developers.spec.ts editor.spec.ts files.spec.ts final-flow.spec.ts forms.spec.ts oauth.spec.ts org-admin.spec.ts pages.spec.ts performance.spec.ts presence-cursors.spec.ts qa-paste-toggle.spec.ts realtime.spec.ts scim.spec.ts search-index-lag.spec.ts search.spec.ts sharing.spec.ts sso.spec.ts task-0094-comments-notifications.spec.ts webhooks.spec.ts workspace.spec.ts
test-results-final-flow/3
final-flow-Release-v1-0-fi-6c06b-w-state-carries-sequential--chromium-desktop/2test-failed-1.png trace.zip
.last-run.json
utils/4api.ts dnd.ts search-lag.ts wait.ts
.gitignore fixtures.ts global-setup.ts package.json playwright.config.ts playwright.final-flow.config.ts README.md tsconfig.json
fixtures/1
files/1make-sparse.ts
integration/60
ai-capability/2ai-app.capability.test.ts gateway.capability.test.ts
drill/2seed-drill.mjs verify-restore.mjs
fixtures/5
files/5clip.mp4 clip.wav doc.pdf hero-1400.png note.txt
helpers/3api.ts containers.ts seed.ts
realtime/11control-channel.test.ts convergence.test.ts flush-rebase-http.test.ts handshake.test.ts heartbeat-limits.test.ts lifecycle.test.ts presence.test.ts rebase.test.ts resume.test.ts rt-helpers.ts task-0094-fanout.test.ts
ai-chat.integration.test.ts ai-database.integration.test.ts ai-generate.integration.test.ts ai-rag.integration.test.ts ai-writing.integration.test.ts api-keys.integration.test.ts audit-completeness.integration.test.ts audit-export-s3.integration.test.ts auth.integration.test.ts automation-pipeline.integration.test.ts automations.integration.test.ts blocks.integration.test.ts cache-invalidation.integration.test.ts comments.integration.test.ts databases.integration.test.ts files-lifecycle.integration.test.ts files-pipeline.integration.test.ts files-security.integration.test.ts files.integration.test.ts forms-responses.integration.test.ts forms.integration.test.ts oauth.integration.test.ts openapi.contract.test.ts org-admin.integration.test.ts package.json pages.integration.test.ts permissions.integration.test.ts README.md replica-routing.integration.test.ts retention-engine.integration.test.ts scim.integration.test.ts search.integration.test.ts sso.integration.test.ts tsconfig.json vitest.config.ts webhooks.integration.test.ts workspace.integration.test.ts
performance/28
fixtures/1search-100k.ts
helpers/2seed-100k.mjs seed-1k-load.mjs
reports/7api-load-smoke.json database-load-smoke.json editor-local.json realtime-load.json search-full.json search-last.json search-reduced.json
api-load.js api.js archive.tar database-load.js database.js editor-local.mjs extract-100k-env.mjs package.json README.md realtime-1k.js realtime-load.js realtime.js run-realtime-load.sh search-search.k6.js search-summary.json seed-100k.mjs threshold-breach-demo.js verify-zero-op-loss.mjs
security/16
helpers/1app.ts
audit-report.test.ts auth-paths.test.ts deps.test.ts formula-fuzz.test.ts gitleaks-config.test.ts headers.test.ts images.test.ts oracle.ts package.json permission-fuzz.test.ts rate-limits.test.ts README.md tsconfig.json upload-abuse.test.ts vitest.config.ts
README.md
.dockerignore .env .env.bak-task0125 .env.example .eslintrc.json .gitignore .npmrc .prettierignore .prettierrc.json AGENTS.md ARCHITECTURE.md CHANGELOG.md CONTRIBUTING.md gitleaks.toml LICENSE package.json pnpm-lock.yaml pnpm-workspace.yaml PRODUCT-SPEC.md README.md ROADMAP.md SECURITY.md tsconfig.json turbo.json
whiteboard-canvas.tsxapps/web/src/components/whiteboard/whiteboard-canvas.tsxraw← whiteboard57,002 B← Back to the app
1"use client";
2 
3/**
4 * WhiteboardCanvas — hand-rolled zero-dep SVG renderer (A-001: OSS canvas
5 * libs evaluated → rejected on dependency grounds; all geometry/ops come from
6 * `@factory/whiteboard`).
7 *
8 * T-168: pan/zoom (wheel + pan tool), marquee selection, sticky + text
9 * create/move/resize/delete, label rendering through SVG `<text>`
10 * (textContent — labels are DATA, never markup: the XSS test asserts an
11 * `<img onerror>` label renders as inert text).
12 * T-169: rect/ellipse drag-create, connectors (straight/orthogonal + arrow,
13 * anchored via `nearestAnchor`), freehand capture (`decimatePoints`),
14 * grouping/ungrouping (package ops — union bounds, dangling connectors die
15 * with a removed child).
16 * T-170: **undo/redo** (package `OpStack` — every edit is a pushed snapshot;
17 * `⌘Z`/`⌘⇧Z`, bounded to 100 states), **save & restore** (explicit
18 * "Save version" snapshot via `POST /pages/:id/versions` + restore a version
19 * via the existing endpoint — the server now snapshots/restores the canvas
20 * sidecar), **page comments** (the page's CommentPanel — comments are
21 * page-scoped, so the drawer is mounted here), **collaboration indicators**
22 * (the page channel the layout already opens for every page route — peers
23 * render as avatars), and **links on elements** (optional `link` field:
24 * internal page reference or http(s) URL — a click navigates / opens).
25 *
26 * An unvalidatable document (`valid:false`) renders read-only with a banner
27 * (the roadmap's unknown-version contract). Editing model: the canvas is
28 * immutable (`ops` return new documents); every gesture applies ops through
29 * the history stack, and a debounced autosave PUTs the full document
30 * (single-user v1 — A-004 — so last-write-wins is the contract).
31 */
32import { useCallback, useEffect, useMemo, useRef, useState } from "react";
33import { useRouter } from "next/navigation";
34import {
35  addElement,
36  anchorPoint,
37  arrowHead,
38  Canvas,
39  CanvasElement,
40  connectorPath,
41  decimatePoints,
42  displayBounds,
43  emptyCanvas,
44  groupElements,
45  hitTest,
46  moveMany,
47  nearestAnchor,
48  nextZ,
49  OpStack,
50  rectsIntersect,
51  removeElement,
52  screenToWorld,
53  ungroupElement,
54  updateElement,
55  viewportToWorldRect,
56  type Anchor,
57  type ElementLink,
58  type Viewport
59} from "@factory/whiteboard";
60import { whiteboardApi, type WhiteboardCanvasResponse } from "@/lib/api";
61import { usePageTree, usePageVersions } from "@/hooks/use-pages";
62import { useBlockPatchStore } from "@/stores/block-patch-store";
63import { useAuthStore } from "@/lib/auth-store";
64import { useWhiteboardCanvas, useWhiteboardSave, whiteboardKey } from "@/hooks/use-whiteboard";
65import { toast } from "@/stores/ui-store";
66import { CommentPanel } from "@/components/comments/comment-panel";
67import { ConfirmDialog } from "@/components/ui/confirm-dialog";
68import { X, Undo2, Redo2, History, MessageSquare, Link2, FilePlus2 } from "lucide-react";
69import type { Presence } from "@/lib/types";
70 
71/** Sticky palette keys → render colors (the schema owns the key set). */
72const STICKY_HEX: Record<string, string> = {
73  yellow: "#FEF3C7",
74  blue: "#DBEAFE",
75  green: "#DCFCE7",
76  pink: "#FCE7F3",
77  purple: "#EDE9FE",
78  orange: "#FFEDD5"
79};
80const SELECTION = "#2563EB";
81const MIN_SIZE = 10;
82/** Undo/redo bound (the OpStack capacity; 100 canvas states). */
83const HISTORY_CAP = 100;
84/** How many versions the restore list shows (newest first). */
85const VERSION_LIMIT = 20;
86 
87type Tool = "select" | "pan" | "sticky" | "text" | "rect" | "ellipse" | "connector" | "pen";
88 
89interface Preview {
90  kind: "shape" | "connector" | "pen";
91  shape?: "rect" | "ellipse";
92  a: { x: number; y: number };
93  b: { x: number; y: number };
94  points?: number[];
95  fromEl?: { elementId: string; anchor: Anchor };
96}
97 
98interface DragState {
99  mode: "pan" | "move" | "resize" | "marquee" | "create-shape" | "connector" | "pen";
100  startWorld: { x: number; y: number };
101  /** For move: the element ids. For resize: the element id. */
102  ids?: string[];
103  /** For create-shape: which shape kind was started. */
104  shape?: "rect" | "ellipse";
105  /** For connector: the endpoint element the drag started on. */
106  fromEl?: { elementId: string; anchor: Anchor };
107  /** For pen: the accumulating world points. */
108  points?: number[];
109  startScreen: { x: number; y: number };
110  startView: Viewport;
111  moved: boolean;
112}
113 
114function newId(prefix: string): string {
115  return `${prefix}_${Math.random().toString(36).slice(2, 12)}`;
116}
117 
118function normRect(a: { x: number; y: number }, b: { x: number; y: number }): { x: number; y: number; w: number; h: number } {
119  return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), w: Math.abs(b.x - a.x), h: Math.abs(b.y - a.y) };
120}
121 
122/** A flat `[x0,y0,x1,y1,…]` array → an SVG `points` attribute. */
123function pointsAttr(points: number[]): string {
124  const out: string[] = [];
125  for (let i = 0; i + 1 < points.length; i += 2) {
126    const x = points[i];
127    const y = points[i + 1];
128    if (x === undefined || y === undefined) break;
129    out.push(`${x},${y}`);
130  }
131  return out.join(" ");
132}
133 
134/** Word-wrap `text` into SVG-friendly lines at an approximate char width. */
135function wrapLines(text: string, width: number, fontSize: number, maxLines: number): string[] {
136  const cpl = Math.max(4, Math.floor(width / (fontSize * 0.58)));
137  const out: string[] = [];
138  for (const raw of text.split("\n")) {
139    let line = raw;
140    while (line.length > cpl) {
141      let cut = line.lastIndexOf(" ", cpl);
142      if (cut < cpl * 0.5) cut = cpl;
143      out.push(line.slice(0, cut).trimEnd());
144      line = line.slice(cut).trimStart();
145    }
146    out.push(line);
147    if (out.length >= maxLines) break;
148  }
149  if (out.length >= maxLines && text.length > out.join("").length) out[maxLines - 1] = (out[maxLines - 1] ?? "") + "…";
150  return out.slice(0, maxLines);
151}
152 
153/** Label text rendered as SVG `<text>` — React escapes children, so a label
154 *  like `<img src onerror=…>` is inert text (never parsed as markup). */
155function Label({ x, y, w, text, fontSize, fill, anchor }: { x: number; y: number; w: number; text: string; fontSize: number; fill: string; anchor: "start" | "middle" }) {
156  const maxLines = Math.max(1, Math.floor(40 / fontSize));
157  const lines = wrapLines(text, w, fontSize, maxLines);
158  return (
159    <text
160      data-testid="wb-label"
161      x={anchor === "middle" ? x + w / 2 : x}
162      y={y}
163      fontSize={fontSize}
164      fill={fill}
165      textAnchor={anchor}
166      style={{ userSelect: "none", fontFamily: "ui-sans-serif, system-ui, sans-serif", pointerEvents: "none" }}
167    >
168      {lines.map((l, i) => (
169        <tspan key={i} x={anchor === "middle" ? x + w / 2 : x} dy={i === 0 ? fontSize * 0.9 : fontSize * 1.25}>
170          {l || " "}
171        </tspan>
172      ))}
173    </text>
174  );
175}
176 
177/** Flatten a (nested) page tree to `{id,title}` for the link dialog picker. */
178function flattenPages(entries: { id: string; title: string; children?: unknown[] }[] | undefined): { id: string; title: string }[] {
179  const out: { id: string; title: string }[] = [];
180  const walk = (list: { id: string; title: string; children?: unknown[] }[] | undefined) => {
181    for (const e of list ?? []) {
182      out.push({ id: e.id, title: e.title });
183      if (Array.isArray(e.children)) walk(e.children as { id: string; title: string; children?: unknown[] }[]);
184    }
185  };
186  walk(entries);
187  return out;
188}
189 
190/** Resolve an element link's URL (internal page link → the app route; url → as-is). */
191function linkHref(link: ElementLink | undefined, workspace: string): string | null {
192  if (!link) return null;
193  if (link.kind === "url" && link.href) return link.href;
194  if ((link.kind === "page" || link.kind === "task") && link.refId) return `/w/${workspace}/page/${link.refId}`;
195  return null;
196}
197 
198interface WhiteboardCanvasProps {
199  pageId: string;
200  workspaceId: string;
201  /** GUEST (VIEW) or an unvalidatable canvas → read-only. */
202  readOnly?: boolean;
203  /** The API's validation flag (false → read-only banner). */
204  canvasValid?: boolean;
205}
206 
207export function WhiteboardCanvas({ pageId, workspaceId, readOnly = false, canvasValid = true }: WhiteboardCanvasProps) {
208  const router = useRouter();
209  const { data, isLoading } = useWhiteboardCanvas(pageId);
210  const save = useWhiteboardSave(pageId);
211  const selfUserId = useAuthStore((s) => s.user?.id ?? null);
212 
213  // ---- the canvas document, owned by the history stack (T-170) ----
214  const historyRef = useRef<OpStack<Canvas> | null>(null);
215  if (historyRef.current === null) historyRef.current = new OpStack(emptyCanvas(), HISTORY_CAP);
216  const history = historyRef.current;
217  const [canvas, setCanvas] = useState<Canvas>(() => history.present);
218  const initialized = useRef(false);
219  useEffect(() => {
220    if (data && !initialized.current) {
221      initialized.current = true;
222      // Seed the stack with the server document — `reset` (not push) so undo
223      // stops at the loaded state; the first edit is the first undoable step.
224      historyRef.current!.reset(data.canvas);
225      setCanvas(historyRef.current!.present);
226    }
227  }, [data]);
228 
229  const [viewport, setViewport] = useState<Viewport>({ scale: 1, tx: 32, ty: 32 });
230  const [selection, setSelection] = useState<string[]>([]);
231  const [tool, setTool] = useState<Tool>(readOnly ? "pan" : "select");
232  const [marquee, setMarquee] = useState<{ a: { x: number; y: number }; b: { x: number; y: number } } | null>(null);
233  const [preview, setPreview] = useState<Preview | null>(null);
234  const [editingId, setEditingId] = useState<string | null>(null);
235  const [dirty, setDirty] = useState(false);
236  /** Connector defaults (T-169: straight vs orthogonal routing + arrowhead). */
237  const [connectorOrthogonal, setConnectorOrthogonal] = useState(true);
238  const [connectorArrow, setConnectorArrow] = useState(true);
239 
240  // ---- T-170 surfaces ----
241  const [commentsOpen, setCommentsOpen] = useState(false);
242  const [versionsOpen, setVersionsOpen] = useState(false);
243  const [restoreTarget, setRestoreTarget] = useState<{ id: string; createdAt: string } | null>(null);
244  const [linkTarget, setLinkTarget] = useState<string | null>(null); // element id for the link dialog
245  const [convertTarget, setConvertTarget] = useState<"page" | "task" | null>(null);
246  const [converting, setConverting] = useState(false);
247  const versionsQuery = usePageVersions(pageId);
248 
249  const svgRef = useRef<SVGSVGElement>(null);
250  const dragRef = useRef<DragState | null>(null);
251  const dragStartCanvas = useRef<Canvas>(emptyCanvas());
252  const containerRef = useRef<HTMLDivElement>(null);
253  /** The canvas wrap's rendered size (viewport culling — 2,000-object gate).
254   *  0×0 until the first layout (jsdom / before mount) → culling is disabled
255   *  (everything renders), so headless tests are unaffected. */
256  const [size, setSize] = useState({ w: 0, h: 0 });
257  useEffect(() => {
258    // The canvas wrap only exists after the data loads (the loading state
259    // renders a different subtree, so the ref is null on the FIRST mount).
260    // Depend on `isLoading` so this re-runs when the container mounts and the
261    // observer attaches — that's the moment culling can engage.
262    const el = containerRef.current;
263    // jsdom has no ResizeObserver (and no layout) — skip; size stays 0×0,
264    // which disables culling (everything renders) in headless tests.
265    if (!el || typeof ResizeObserver === "undefined") return;
266    const ro = new ResizeObserver((entries) => {
267      const r = entries[0]?.contentRect;
268      if (r) setSize({ w: r.width, h: r.height });
269    });
270    ro.observe(el);
271    return () => ro.disconnect();
272  }, [isLoading]);
273 
274  const effectiveReadOnly = readOnly || !canvasValid;
275 
276  /** Apply an immutable op THROUGH the history stack (T-170: every edit is
277   *  undoable). The stack is the source of truth for past/future. */
278  const apply = useCallback((next: Canvas) => {
279    history.push(next);
280    setCanvas(history.present);
281    setDirty(true);
282  }, [history]);
283 
284  /** Undo / redo (⌘Z / ⌘⇧Z). The canvas is swapped wholesale; dirty so the
285   *  autosave persists the undone state. */
286  const undo = useCallback(() => {
287    if (!effectiveReadOnly && history.canUndo()) {
288      const prev = history.undo();
289      setCanvas(prev);
290      setDirty(true);
291    }
292  }, [history, effectiveReadOnly]);
293  const redo = useCallback(() => {
294    if (!effectiveReadOnly && history.canRedo()) {
295      const next = history.redo();
296      setCanvas(next);
297      setDirty(true);
298    }
299  }, [history, effectiveReadOnly]);
300 
301  // ---- autosave (debounced; the renderer owns the document, A-004) ----
302  useEffect(() => {
303    if (!dirty || effectiveReadOnly) return;
304    const t = setTimeout(() => {
305      save.mutate(canvas, {
306        onError: (e) => toast("error", "Whiteboard save failed", (e as Error).message)
307      });
308      setDirty(false);
309    }, 1200);
310    return () => clearTimeout(t);
311  }, [canvas, dirty, effectiveReadOnly, save]);
312 
313  /** T-170 save & restore: an explicit version snapshot (the server also
314   *  snapshots the canvas sidecar). */
315  const [savingVersion, setSavingVersion] = useState(false);
316  const doSaveVersion = async () => {
317    if (savingVersion) return;
318    setSavingVersion(true);
319    try {
320      // Ensure the current document is saved before snapshotting it.
321      if (dirty) await save.mutateAsync(canvas);
322      await whiteboardApi.saveVersion(pageId);
323      void versionsQuery.refetch();
324      toast("success", "Version saved");
325    } catch (e) {
326      toast("error", "Save version failed", e instanceof Error ? e.message : undefined);
327    } finally {
328      setSavingVersion(false);
329    }
330  };
331 
332  const doRestore = async () => {
333    if (!restoreTarget) return;
334    try {
335      await whiteboardApi.restoreVersion(pageId, restoreTarget.id);
336      // The server wrote the canvas back — re-read it (and the versions list).
337      const wb = await whiteboardApi.get(pageId);
338      // `push` (not reset) so the pre-restore board stays one ⌘Z away
339      // (the confirm dialog promises that).
340      historyRef.current!.push(wb.canvas);
341      setCanvas(historyRef.current!.present);
342      setDirty(false);
343      void versionsQuery.refetch();
344      toast("success", "Version restored", `Restored to ${restoreTarget.createdAt}`);
345      setRestoreTarget(null);
346      setVersionsOpen(false);
347    } catch (e) {
348      toast("error", "Restore failed", e instanceof Error ? e.message : undefined);
349    }
350  };
351 
352  /** Screen (client px) → world via the svg's box + viewport. */
353  const toWorld = useCallback(
354    (clientX: number, clientY: number): { x: number; y: number } => {
355      const box = svgRef.current?.getBoundingClientRect();
356      const sx = clientX - (box?.left ?? 0);
357      const sy = clientY - (box?.top ?? 0);
358      return screenToWorld(viewport, sx, sy);
359    },
360    [viewport]
361  );
362 
363  // ---- zoom (wheel: zoom at cursor; non-passive so preventDefault works) ----
364  useEffect(() => {
365    const el = containerRef.current;
366    if (!el) return;
367    const onWheel = (e: WheelEvent) => {
368      e.preventDefault();
369      const box = el.getBoundingClientRect();
370      const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
371      setViewport((vp) => {
372        const scale = Math.min(8, Math.max(0.05, vp.scale * factor));
373        const worldX = (e.clientX - box.left - vp.tx) / vp.scale;
374        const worldY = (e.clientY - box.top - vp.ty) / vp.scale;
375        return { scale, tx: e.clientX - box.left - worldX * scale, ty: e.clientY - box.top - worldY * scale };
376      });
377    };
378    el.addEventListener("wheel", onWheel, { passive: false });
379    return () => el.removeEventListener("wheel", onWheel);
380  }, []);
381 
382  /** Group the current selection (needs ≥ 2, no nested groups). */
383  const doGroup = useCallback(() => {
384    if (effectiveReadOnly || selection.length < 2) return;
385    const gid = newId("g");
386    try {
387      const next = groupElements(canvas, { id: gid }, selection);
388      apply(next);
389      setSelection([gid]);
390    } catch (err) {
391      toast("error", "Cannot group", (err as Error).message);
392    }
393  }, [canvas, selection, effectiveReadOnly, apply]);
394 
395  /** Ungroup a single selected group (children keep their positions). */
396  const doUngroup = useCallback(() => {
397    if (effectiveReadOnly || selection.length !== 1) return;
398    const selId = selection[0];
399    if (selId === undefined) return;
400    const el = canvas.elements[selId];
401    if (!el || el.type !== "group") return;
402    apply(ungroupElement(canvas, el.id));
403    setSelection([...el.children]);
404  }, [canvas, selection, effectiveReadOnly, apply]);
405 
406  /** T-170: attach a link to the selected element (or clear it). */
407  const doSetLink = (link: ElementLink | null) => {
408    if (effectiveReadOnly || selection.length !== 1) return;
409    const id = selection[0];
410    if (id === undefined) return;
411    apply(updateElement(canvas, id, { link: link ?? null }));
412    setLinkTarget(null);
413  };
414 
415  /** T-171: convert the selection into a real page (or task page) — the
416   *  server re-reads the canvas and maps text elements to blocks. */
417  const doConvert = async () => {
418    if (converting || effectiveReadOnly || selection.length === 0 || convertTarget === null) return;
419    setConverting(true);
420    try {
421      const res = await whiteboardApi.convertSelection(pageId, { elementIds: selection, target: convertTarget, parentId: null });
422      toast("success", convertTarget === "task" ? "Task created from selection" : "Page created from selection");
423      router.push(`/w/${workspaceId}/page/${res.page.id}`);
424    } catch (e) {
425      toast("error", "Convert failed", e instanceof Error ? e.message : undefined);
426    } finally {
427      setConverting(false);
428    }
429  };
430 
431  /** T-170: open an element's link (internal → app route, url → new tab). */
432  const openLink = useCallback(
433    (el: CanvasElement) => {
434      const href = linkHref(el.link, workspaceId);
435      if (!href) return;
436      if (el.link!.kind === "url") window.open(href, "_blank", "noopener,noreferrer");
437      else router.push(href);
438    },
439    [workspaceId, router]
440  );
441 
442  // ---- keyboard: undo/redo, delete, tools, group/ungroup, escape, links ----
443  useEffect(() => {
444    const onKey = (e: KeyboardEvent) => {
445      const t = e.target as HTMLElement | null;
446      const tag = t?.tagName;
447      if (tag === "TEXTAREA" || tag === "INPUT" || t?.isContentEditable) return;
448      const mod = e.metaKey || e.ctrlKey;
449      if (mod && e.key.toLowerCase() === "z") {
450        e.preventDefault();
451        if (e.shiftKey) redo();
452        else undo();
453        return;
454      }
455      if (mod && e.key.toLowerCase() === "y") {
456        e.preventDefault();
457        redo();
458        return;
459      }
460      if (mod && e.key.toLowerCase() === "g") {
461        e.preventDefault();
462        if (e.shiftKey) doUngroup();
463        else doGroup();
464        return;
465      }
466      if (mod && e.key.toLowerCase() === "k") {
467        e.preventDefault();
468        // ⌘K: link the single selected element (open the dialog).
469        const sid = selection[0];
470        if (!effectiveReadOnly && selection.length === 1 && sid !== undefined) setLinkTarget(sid);
471        return;
472      }
473      if ((e.key === "Delete" || e.key === "Backspace") && !effectiveReadOnly && selection.length > 0) {
474        e.preventDefault();
475        let c = canvas;
476        for (const id of selection) c = removeElement(c, id);
477        apply(c);
478        setSelection([]);
479        return;
480      }
481      if (e.key === "Enter" && !mod && selection.length === 1) {
482        const eid = selection[0];
483        const el = eid !== undefined ? canvas.elements[eid] : undefined;
484        if (el?.link) {
485          openLink(el);
486          return;
487        }
488      }
489      if (e.key === "Escape") {
490        setSelection([]);
491        setEditingId(null);
492        setPreview(null);
493        setLinkTarget(null);
494        setVersionsOpen(false);
495        return;
496      }
497      if (effectiveReadOnly) return;
498      if (e.metaKey || e.ctrlKey || e.altKey) return;
499      switch (e.key) {
500        case "v": setTool("select"); break;
501        case "h": setTool("pan"); break;
502        case "s": setTool("sticky"); break;
503        case "t": setTool("text"); break;
504        case "r": setTool("rect"); break;
505        case "o": setTool("ellipse"); break;
506        case "c": setTool("connector"); break;
507        case "p": setTool("pen"); break;
508      }
509    };
510    window.addEventListener("keydown", onKey);
511    return () => window.removeEventListener("keydown", onKey);
512  }, [canvas, selection, effectiveReadOnly, apply, doGroup, doUngroup, undo, redo, workspaceId]);
513 
514  // ---- pointer gestures ----
515  const hitAt = (world: { x: number; y: number }): CanvasElement | undefined =>
516    Object.values(canvas.elements)
517      .filter((el) => hitTest(el, world.x, world.y, 6, canvas.elements))
518      .sort((a, b) => b.z - a.z)[0];
519 
520  const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
521    // middle mouse or pan tool → pan
522    if (e.button === 1 || tool === "pan") {
523      e.currentTarget.setPointerCapture(e.pointerId);
524      dragRef.current = {
525        mode: "pan",
526        startWorld: toWorld(e.clientX, e.clientY),
527        startScreen: { x: e.clientX, y: e.clientY },
528        startView: viewport,
529        moved: false
530      };
531      return;
532    }
533    if (effectiveReadOnly) return;
534    const world = toWorld(e.clientX, e.clientY);
535 
536    // click-create tools
537    if (tool === "sticky" || tool === "text") {
538      const id = newId("el");
539      const base = { id, x: world.x - 80, y: world.y - 50, z: nextZ(canvas) };
540      const el: CanvasElement =
541        tool === "sticky"
542          ? { ...base, type: "sticky", w: 160, h: 110, text: "", color: "yellow" }
543          : { ...base, type: "text", w: 220, h: 32, text: "", fontSize: 16 };
544      apply(addElement(canvas, el));
545      setSelection([id]);
546      setEditingId(id);
547      setTool("select");
548      return;
549    }
550 
551    // drag-create tools (rect/ellipse)
552    if (tool === "rect" || tool === "ellipse") {
553      e.currentTarget.setPointerCapture(e.pointerId);
554      dragStartCanvas.current = canvas;
555      dragRef.current = {
556        mode: "create-shape",
557        shape: tool,
558        startWorld: world,
559        startScreen: { x: e.clientX, y: e.clientY },
560        startView: viewport,
561        moved: false
562      };
563      setPreview({ kind: "shape", shape: tool, a: world, b: world });
564      return;
565    }
566 
567    // connector: must start ON an element (endpoints attach to elements)
568    if (tool === "connector") {
569      const hit = hitAt(world);
570      if (!hit) return;
571      e.currentTarget.setPointerCapture(e.pointerId);
572      const anchor = nearestAnchor(hit, world);
573      dragStartCanvas.current = canvas;
574      dragRef.current = {
575        mode: "connector",
576        fromEl: { elementId: hit.id, anchor },
577        startWorld: world,
578        startScreen: { x: e.clientX, y: e.clientY },
579        startView: viewport,
580        moved: false
581      };
582      setPreview({ kind: "connector", a: world, b: world, fromEl: { elementId: hit.id, anchor } });
583      return;
584    }
585 
586    // pen: start accumulating world points
587    if (tool === "pen") {
588      e.currentTarget.setPointerCapture(e.pointerId);
589      dragRef.current = {
590        mode: "pen",
591        startWorld: world,
592        points: [world.x, world.y],
593        startScreen: { x: e.clientX, y: e.clientY },
594        startView: viewport,
595        moved: false
596      };
597      setPreview({ kind: "pen", a: world, b: world, points: [world.x, world.y] });
598      return;
599    }
600 
601    // select tool: hit-test topmost (z desc), else marquee
602    const hit = hitAt(world);
603    e.currentTarget.setPointerCapture(e.pointerId);
604    if (hit) {
605      const multi = e.shiftKey && selection.includes(hit.id);
606      const ids = multi ? selection : [hit.id];
607      setSelection(multi ? selection : ids);
608      // capture the pre-drag document: the gesture re-derives from it on
609      // every move (immutable ops, no float accumulation).
610      dragStartCanvas.current = canvas;
611      dragRef.current = {
612        mode: "move",
613        startWorld: world,
614        ids,
615        startScreen: { x: e.clientX, y: e.clientY },
616        startView: viewport,
617        moved: false
618      };
619    } else {
620      if (!e.shiftKey) setSelection([]);
621      dragRef.current = {
622        mode: "marquee",
623        startWorld: world,
624        startScreen: { x: e.clientX, y: e.clientY },
625        startView: viewport,
626        moved: false
627      };
628      setMarquee({ a: world, b: world });
629    }
630  };
631 
632  const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
633    const d = dragRef.current;
634    if (!d) return;
635    d.moved = true;
636    const world = toWorld(e.clientX, e.clientY);
637    if (d.mode === "pan") {
638      setViewport({ scale: d.startView.scale, tx: d.startView.tx + (e.clientX - d.startScreen.x), ty: d.startView.ty + (e.clientY - d.startScreen.y) });
639      return;
640    }
641    if (d.mode === "marquee") {
642      setMarquee({ a: d.startWorld, b: world });
643      return;
644    }
645    if (d.mode === "create-shape") {
646      setPreview((p) => (p && p.kind === "shape" ? { ...p, b: world } : p));
647      return;
648    }
649    if (d.mode === "connector") {
650      setPreview((p) => (p && p.kind === "connector" ? { ...p, b: world } : p));
651      return;
652    }
653    if (d.mode === "pen") {
654      const pts = d.points!;
655      const n = pts.length;
656      const dx = world.x - (pts[n - 2] ?? world.x);
657      const dy = world.y - (pts[n - 1] ?? world.y);
658      // sample density scales with zoom (≥ ~1 screen px between samples)
659      if (dx * dx + dy * dy > (1 / viewport.scale) ** 2) {
660        pts.push(world.x, world.y);
661        setPreview((p) => (p && p.kind === "pen" ? { ...p, points: [...pts] } : p));
662      }
663      return;
664    }
665    if (d.mode === "move" && d.ids && !effectiveReadOnly) {
666      const dx = world.x - d.startWorld.x;
667      const dy = world.y - d.startWorld.y;
668      if (Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01) return;
669      // Connectors ride on their endpoint elements — only the non-connectors
670      // move; the connectors re-route themselves (endpoints are id refs).
671      const movable = d.ids.filter((id) => {
672        const el = dragStartCanvas.current.elements[id];
673        return el && el.type !== "connector";
674      });
675      if (movable.length === 0) return;
676      setCanvas(moveMany(dragStartCanvas.current, movable, dx, dy));
677      setDirty(true);
678      return;
679    }
680    if (d.mode === "resize" && d.ids && !effectiveReadOnly) {
681      const id = d.ids[0];
682      if (id === undefined) return;
683      const el = dragStartCanvas.current.elements[id];
684      if (!el) return;
685      const w = Math.max(MIN_SIZE, world.x - el.x);
686      const h = Math.max(MIN_SIZE, world.y - el.y);
687      setCanvas((prev) => updateElement(prev, id, { w, h }));
688      setDirty(true);
689    }
690  };
691 
692  const onPointerUp = (e: React.PointerEvent<SVGSVGElement>) => {
693    const d = dragRef.current;
694    dragRef.current = null;
695    setMarquee(null);
696    setPreview(null);
697    if (!d) return;
698    const world = toWorld(e.clientX, e.clientY);
699    if (d.mode === "marquee" && d.moved) {
700      const x = Math.min(d.startWorld.x, world.x);
701      const y = Math.min(d.startWorld.y, world.y);
702      const x2 = Math.max(d.startWorld.x, world.x);
703      const y2 = Math.max(d.startWorld.y, world.y);
704      // Only the VISIBLE elements are selectable (they're the only ones
705      // painted) — a marquee can't extend past the viewport, so this is
706      // also faster on large boards (no full-document scan).
707      const ids = visibleElements
708        .filter((el) => el.x < x2 && el.x + el.w > x && el.y < y2 && el.y + el.h > y)
709        .map((el) => el.id);
710      setSelection((prev) => (e.shiftKey ? [...new Set([...prev, ...ids])] : ids));
711      return;
712    }
713    if (d.mode === "create-shape" && d.moved) {
714      const r = normRect(d.startWorld, world);
715      if (r.w >= MIN_SIZE / viewport.scale || r.h >= MIN_SIZE / viewport.scale) {
716        const id = newId("el");
717        const el: CanvasElement = {
718          id,
719          type: d.shape ?? "rect",
720          x: r.x,
721          y: r.y,
722          w: Math.max(MIN_SIZE, r.w),
723          h: Math.max(MIN_SIZE, r.h),
724          z: nextZ(dragStartCanvas.current),
725          fill: null,
726          stroke: null
727        };
728        apply(addElement(dragStartCanvas.current, el));
729        setSelection([id]);
730      }
731      return;
732    }
733    if (d.mode === "connector" && d.moved && d.fromEl) {
734      const target = hitAt(world);
735      if (target && target.id !== d.fromEl.elementId) {
736        const anchor = nearestAnchor(target, world);
737        const id = newId("c");
738        const el: CanvasElement = {
739          id,
740          type: "connector",
741          x: 0,
742          y: 0,
743          w: 0,
744          h: 0,
745          z: nextZ(dragStartCanvas.current),
746          from: d.fromEl,
747          to: { elementId: target.id, anchor },
748          kind: connectorOrthogonal ? "orthogonal" : "straight",
749          arrow: connectorArrow
750        };
751        apply(addElement(dragStartCanvas.current, el));
752        setSelection([id]);
753      }
754      return;
755    }
756    if (d.mode === "pen" && d.moved && d.points) {
757      // simplify + store (absolute world coords; bounds = the stroke's box)
758      const simplified = decimatePoints(d.points, 1.5);
759      if (simplified.length >= 4) {
760        let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
761        for (let i = 0; i < simplified.length; i += 2) {
762          minX = Math.min(minX, simplified[i]!);
763          maxX = Math.max(maxX, simplified[i]!);
764          minY = Math.min(minY, simplified[i + 1]!);
765          maxY = Math.max(maxY, simplified[i + 1]!);
766        }
767        const id = newId("f");
768        const el: CanvasElement = {
769          id,
770          type: "freehand",
771          x: minX,
772          y: minY,
773          w: Math.max(1, maxX - minX),
774          h: Math.max(1, maxY - minY),
775          z: nextZ(dragStartCanvas.current),
776          points: simplified,
777          strokeWidth: 3
778        };
779        apply(addElement(dragStartCanvas.current, el));
780        setSelection([id]);
781      }
782    }
783  };
784 
785  const onResizeHandleDown = (e: React.PointerEvent<SVGRectElement>) => {
786    e.stopPropagation();
787    if (effectiveReadOnly || selection.length !== 1) return;
788    const id = selection[0];
789    if (id === undefined) return;
790    const el = canvas.elements[id];
791    // freehand/connector/group don't support bounds-resize (v1) — the handle
792    // is only rendered for the resizable types anyway.
793    if (!el || el.type === "connector" || el.type === "freehand" || el.type === "group") return;
794    e.currentTarget.setPointerCapture(e.pointerId);
795    dragStartCanvas.current = canvas;
796    dragRef.current = {
797      mode: "resize",
798      ids: [id],
799      startWorld: toWorld(e.clientX, e.clientY),
800      startScreen: { x: e.clientX, y: e.clientY },
801      startView: viewport,
802      moved: false
803    };
804  };
805 
806  const onDoubleClick = (e: React.MouseEvent<SVGSVGElement>) => {
807    if (effectiveReadOnly) return;
808    const world = toWorld(e.clientX, e.clientY);
809    const hit = Object.values(canvas.elements)
810      .filter((el) => (el.type === "sticky" || el.type === "text") && hitTest(el, world.x, world.y, 6, canvas.elements))
811      .sort((a, b) => b.z - a.z)[0];
812    if (hit) {
813      setSelection([hit.id]);
814      setEditingId(hit.id);
815    }
816  };
817 
818  /** T-170: double-click a LINKED element (any type) opens its link — this
819   *  runs before the edit handler below (stopPropagation) so a linked sticky
820   *  navigates instead of opening the text editor. */
821  const onElementDoubleClick = (el: CanvasElement) => {
822    if (!el.link || effectiveReadOnly) return;
823    openLink(el);
824  };
825 
826  // ---- element rendering (display order = z asc; unknown types skipped) ----
827  // Viewport culling (2,000-object perf gate): the SVG only mounts a DOM node
828  // for elements whose display bounds intersect the visible world rect. A
829  // degenerate size (0×0, jsdom / before layout) disables culling, so the
830  // small interactive boards in the headless tests render in full.
831  const sortedElements = useMemo(
832    () => Object.values(canvas.elements).sort((a, b) => a.z - b.z),
833    [canvas]
834  );
835  const visibleRect = viewportToWorldRect(viewport, size.w, size.h);
836  const visibleElements = useMemo(() => {
837    if (!visibleRect) return sortedElements;
838    return sortedElements.filter((el) => rectsIntersect(displayBounds(el, canvas.elements), visibleRect));
839  }, [sortedElements, visibleRect, canvas.elements]);
840 
841  const elements = useMemo(
842    () =>
843      visibleElements.map((el) => {
844        const selected = selection.includes(el.id);
845        const key = el.id;
846        const linked = Boolean(el.link);
847        const cursor = linked ? "pointer" : undefined;
848        switch (el.type) {
849          case "sticky":
850            return (
851              <g key={key} data-testid="wb-sticky" onDoubleClick={() => { onElementDoubleClick(el); }} style={linked ? { cursor } : undefined}>
852                <rect x={el.x} y={el.y} width={el.w} height={el.h} rx={2} fill={STICKY_HEX[el.color] ?? "#FEF3C7"} stroke={linked ? SELECTION : "#00000022"} strokeWidth={linked ? 1.5 : 1} />
853                <Label x={el.x + 8} y={el.y + 14} w={el.w - 16} text={el.text} fontSize={14} fill="#1F2937" anchor="start" />
854                {linked && <Link2 x={el.x + el.w - 18} y={el.y + el.h - 18} width={12} height={12} color="#2563EB" />}
855              </g>
856            );
857          case "text":
858            return (
859              <g key={key} data-testid="wb-text" onDoubleClick={() => { onElementDoubleClick(el); }} style={linked ? { cursor } : undefined}>
860                <rect x={el.x} y={el.y} width={el.w} height={el.h} fill="transparent" stroke="none" />
861                <Label x={el.x + 2} y={el.y + 4} w={el.w - 4} text={el.text} fontSize={el.fontSize} fill={linked ? SELECTION : "#111827"} anchor="start" />
862                {linked && <Link2 x={el.x + el.w - 16} y={el.y + el.h - 14} width={12} height={12} color="#2563EB" />}
863              </g>
864            );
865          case "rect":
866            return <rect key={key} data-testid="wb-rect" onDoubleClick={() => { onElementDoubleClick(el); }} x={el.x} y={el.y} width={el.w} height={el.h} fill={el.fill ?? "transparent"} stroke={el.stroke ?? "#374151"} strokeWidth={1.5} style={linked ? { cursor } : undefined} />;
867          case "ellipse":
868            return <ellipse key={key} data-testid="wb-ellipse" onDoubleClick={() => { onElementDoubleClick(el); }} cx={el.x + el.w / 2} cy={el.y + el.h / 2} rx={el.w / 2} ry={el.h / 2} fill={el.fill ?? "transparent"} stroke={el.stroke ?? "#374151"} strokeWidth={1.5} style={linked ? { cursor } : undefined} />;
869          case "freehand":
870            return <polyline key={key} data-testid="wb-freehand" onDoubleClick={() => { onElementDoubleClick(el); }} points={pointsAttr(el.points)} fill="none" stroke="#111827" strokeWidth={el.strokeWidth} strokeLinecap="round" strokeLinejoin="round" style={linked ? { cursor } : undefined} />;
871          case "connector": {
872            const from = canvas.elements[el.from.elementId];
873            const to = canvas.elements[el.to.elementId];
874            if (!from || !to) return null;
875            const p1 = anchorPoint(from, el.from.anchor);
876            const p2 = anchorPoint(to, el.to.anchor);
877            const dPath = connectorPath(p1, p2, el.kind);
878            const dx = p2.x - p1.x;
879            const dy = p2.y - p1.y;
880            const len = Math.hypot(dx, dy) || 1;
881            const head = arrowHead(p2, { x: dx / len, y: dy / len }, 10);
882            const headStr = head.map((p) => `${p[0]},${p[1]}`).join(" ");
883            return (
884              <g key={key} data-testid="wb-connector">
885                <path d={dPath} fill="none" stroke="#4B5563" strokeWidth={1.5} />
886                {el.arrow && <polygon points={headStr} fill="#4B5563" />}
887                {/* invisible fat hit area (the thin path is hard to grab) */}
888                <path d={dPath} fill="none" stroke="transparent" strokeWidth={12} data-testid="wb-connector-hit" />
889              </g>
890            );
891          }
892          case "group":
893            return <rect key={key} data-testid="wb-group" onDoubleClick={() => { onElementDoubleClick(el); }} x={el.x} y={el.y} width={el.w} height={el.h} fill="none" stroke="#9CA3AF" strokeWidth={1.5} strokeDasharray="6 4" rx={4} style={linked ? { cursor } : undefined} />;
894          default:
895            return null; // unknown type (newer schema) → skip, not crash
896        }
897      }),
898    [visibleElements, canvas, openLink]
899  );
900 
901  // ---- selection overlay (dashed outline + resize handle) ----
902  const selectedEls = selection
903    .map((id) => canvas.elements[id])
904    .filter((el): el is CanvasElement => Boolean(el));
905  const resizableEl =
906    selection.length === 1 && selectedEls[0] && !["connector", "freehand", "group"].includes(selectedEls[0].type)
907      ? selectedEls[0]
908      : null;
909 
910  const connectorFromPoint =
911    preview?.kind === "connector" && preview.fromEl
912      ? (() => {
913          const from = canvas.elements[preview.fromEl!.elementId];
914          return from ? anchorPoint(from, preview.fromEl!.anchor) : preview.a;
915        })()
916      : null;
917 
918  const editingEl = editingId ? canvas.elements[editingId] : undefined;
919 
920  const commitEdit = useCallback(
921    (text: string) => {
922      if (editingId) {
923        const el = canvas.elements[editingId];
924        if (el && (el.type === "sticky" || el.type === "text")) apply(updateElement(canvas, editingId, { text }));
925      }
926      setEditingId(null);
927    },
928    [editingId, canvas, apply]
929  );
930 
931  // ---- T-170: collaboration indicators (peers on the page channel) ----
932  const presenceMap = useBlockPatchStore((s) => (pageId ? (s.presence[pageId] ?? ({} as Record<string, Presence>)) : ({} as Record<string, Presence>)));
933  const peers = useMemo(
934    () => Object.values(presenceMap).filter((p) => p.userId !== selfUserId),
935    [presenceMap, selfUserId]
936  );
937  const initials = (name: string) =>
938    name
939      .split(/\s+/)
940      .map((s) => s[0])
941      .filter(Boolean)
942      .slice(0, 2)
943      .join("")
944      .toUpperCase();
945 
946  // ---- link dialog data (page tree) ----
947  const { data: pageTree } = usePageTree(workspaceId);
948  const pages = useMemo(() => flattenPages(pageTree), [pageTree]);
949 
950  if (isLoading) {
951    return (
952      <div className="flex h-full items-center justify-center text-sm text-gray-400" data-testid="wb-loading">
953        Loading whiteboard…
954      </div>
955    );
956  }
957 
958  const saveState = save.isPending ? "Saving…" : dirty ? "Unsaved" : "Saved";
959  const canGroup = selection.length >= 2 && !effectiveReadOnly;
960  const firstSel = selection[0];
961  const canUngroup = selection.length === 1 && firstSel !== undefined && canvas.elements[firstSel]?.type === "group" && !effectiveReadOnly;
962  const canLink = selection.length === 1 && !effectiveReadOnly;
963  const linkEl = linkTarget ? canvas.elements[linkTarget] : undefined;
964  const versionList = (versionsQuery.data ?? []).slice(0, VERSION_LIMIT);
965 
966  const tools: Array<{ key: Tool; testid: string; label: string; title: string }> = [
967    { key: "select", testid: "wb-tool-select", label: "Select", title: "Select (V)" },
968    { key: "sticky", testid: "wb-tool-sticky", label: "Sticky", title: "Sticky note (S)" },
969    { key: "text", testid: "wb-tool-text", label: "Text", title: "Text (T)" },
970    { key: "rect", testid: "wb-tool-rect", label: "Rect", title: "Rectangle (R)" },
971    { key: "ellipse", testid: "wb-tool-ellipse", label: "Ellipse", title: "Ellipse (O)" },
972    { key: "connector", testid: "wb-tool-connector", label: "Connector", title: "Connector (C)" },
973    { key: "pen", testid: "wb-tool-pen", label: "Pen", title: "Freehand (P)" }
974  ];
975 
976  return (
977    <div className="flex h-full flex-col" data-testid="wb-root">
978      {!canvasValid && (
979        <div className="border-b border-amber-300 bg-amber-50 px-4 py-1.5 text-xs text-amber-800" data-testid="wb-readonly-banner">
980          This whiteboard uses a newer/unsupported canvas version — shown read-only.
981        </div>
982      )}
983      {effectiveReadOnly && canvasValid && (
984        <div className="border-b border-gray-200 bg-gray-50 px-4 py-1.5 text-xs text-gray-500">Read-only (view access).</div>
985      )}
986      {/* toolbar */}
987      <div className="flex flex-wrap items-center gap-1 border-b border-gray-200 bg-white px-2 py-1" data-testid="wb-toolbar">
988        {!effectiveReadOnly && tools.map((t) => (
989          <button key={t.key} type="button" data-testid={t.testid} className={tool === t.key ? "wb-tool active" : "wb-tool"} onClick={() => setTool(t.key)} title={t.title}>
990            {t.label}
991          </button>
992        ))}
993        <button type="button" data-testid="wb-tool-pan" className={tool === "pan" ? "wb-tool active" : "wb-tool"} onClick={() => setTool("pan")} title="Pan (H)">
994          Pan
995        </button>
996        {!effectiveReadOnly && (
997          <>
998            <button type="button" data-testid="wb-btn-undo" className="wb-tool" disabled={!history.canUndo()} onClick={undo} title="Undo (⌘Z)">
999              <Undo2 className="h-3.5 w-3.5" />
1000            </button>
1001            <button type="button" data-testid="wb-btn-redo" className="wb-tool" disabled={!history.canRedo()} onClick={redo} title="Redo (⌘⇧Z)">
1002              <Redo2 className="h-3.5 w-3.5" />
1003            </button>
1004            <button type="button" data-testid="wb-btn-group" className="wb-tool" disabled={!canGroup} onClick={doGroup} title="Group selection (⌘G)">
1005              Group
1006            </button>
1007            <button type="button" data-testid="wb-btn-ungroup" className="wb-tool" disabled={!canUngroup} onClick={doUngroup} title="Ungroup (⌘⇧G)">
1008              Ungroup
1009            </button>
1010            <button type="button" data-testid="wb-link" className="wb-tool" disabled={!canLink} onClick={() => setLinkTarget(firstSel ?? null)} title="Link this element (⌘K)">
1011              <Link2 className="h-3.5 w-3.5" />
1012            </button>
1013            <button type="button" data-testid="wb-btn-convert" className="wb-tool" disabled={selection.length === 0} onClick={() => setConvertTarget("page")} title="Convert selection to a page or task">
1014              <FilePlus2 className="h-3.5 w-3.5" />
1015            </button>
1016            <label className="ml-1 flex items-center gap-1 text-xs text-gray-500" data-testid="wb-toggle-orthogonal">
1017              <input type="checkbox" checked={connectorOrthogonal} onChange={(e) => setConnectorOrthogonal(e.target.checked)} /> orthogonal
1018            </label>
1019            <label className="flex items-center gap-1 text-xs text-gray-500" data-testid="wb-toggle-arrow">
1020              <input type="checkbox" checked={connectorArrow} onChange={(e) => setConnectorArrow(e.target.checked)} /> arrow
1021            </label>
1022          </>
1023        )}
1024        <span className="ml-2 text-xs text-gray-400">{Math.round(viewport.scale * 100)}%</span>
1025        <span className="text-xs text-gray-400" data-testid="wb-save-state">
1026          {saveState}
1027        </span>
1028        <div className="ml-auto flex items-center gap-1">
1029          {/* T-170: collaboration indicators (peers on the page channel) */}
1030          <div className="flex items-center -space-x-1.5" data-testid="wb-presence">
1031            {peers.slice(0, 5).map((p) => (
1032              <span
1033                key={p.userId}
1034                data-testid="wb-presence-peer"
1035                title={p.name}
1036                className="flex h-5 w-5 items-center justify-center rounded-full border border-white text-[10px] font-semibold text-white"
1037                style={{ backgroundColor: p.color }}
1038              >
1039                {initials(p.name)}
1040              </span>
1041            ))}
1042            {peers.length > 5 && <span className="pl-2 text-[10px] text-gray-400">+{peers.length - 5}</span>}
1043          </div>
1044          <button type="button" data-testid="wb-comments" className="wb-tool" onClick={() => setCommentsOpen(true)} title="Comments">
1045            <MessageSquare className="h-3.5 w-3.5" />
1046          </button>
1047          <button type="button" data-testid="wb-versions" className="wb-tool" onClick={() => setVersionsOpen(true)} title="Save & restore versions">
1048            <History className="h-3.5 w-3.5" />
1049          </button>
1050        </div>
1051      </div>
1052      {/* canvas */}
1053      <div ref={containerRef} className="relative min-h-0 flex-1 overflow-hidden bg-[#F8FAFC]" data-testid="wb-canvas-wrap">
1054        <svg
1055          ref={svgRef}
1056          className="h-full w-full touch-none"
1057          style={{ cursor: tool === "pan" ? "grab" : tool === "select" ? "default" : "crosshair", display: "block" }}
1058          data-testid="wb-canvas"
1059          onPointerDown={onPointerDown}
1060          onPointerMove={onPointerMove}
1061          onPointerUp={onPointerUp}
1062          onDoubleClick={onDoubleClick}
1063        >
1064          <g transform={`translate(${viewport.tx} ${viewport.ty}) scale(${viewport.scale})`}>
1065            {elements}
1066            {/* selection overlay */}
1067            {selectedEls.map((el) => (
1068              <rect
1069                key={`sel-${el.id}`}
1070                x={el.x - 2}
1071                y={el.y - 2}
1072                width={el.w + 4}
1073                height={el.h + 4}
1074                fill="none"
1075                stroke={SELECTION}
1076                strokeWidth={1.5 / viewport.scale}
1077                strokeDasharray="5 3"
1078                style={{ pointerEvents: "none" }}
1079              />
1080            ))}
1081            {resizableEl && (
1082              <rect
1083                data-testid="wb-resize-handle"
1084                x={resizableEl.x + resizableEl.w - 5}
1085                y={resizableEl.y + resizableEl.h - 5}
1086                width={10}
1087                height={10}
1088                fill={SELECTION}
1089                style={{ cursor: "nwse-resize" }}
1090                onPointerDown={onResizeHandleDown}
1091              />
1092            )}
1093            {/* marquee */}
1094            {marquee && (
1095              <rect
1096                x={Math.min(marquee.a.x, marquee.b.x)}
1097                y={Math.min(marquee.a.y, marquee.b.y)}
1098                width={Math.abs(marquee.b.x - marquee.a.x)}
1099                height={Math.abs(marquee.b.y - marquee.a.y)}
1100                fill="#2563EB11"
1101                stroke={SELECTION}
1102                strokeWidth={1}
1103                strokeDasharray="4 3"
1104              />
1105            )}
1106            {/* in-progress previews */}
1107            {preview?.kind === "shape" && normRect(preview.a, preview.b).w > 0 && (
1108              preview.shape === "rect" ? (
1109                <rect x={normRect(preview.a, preview.b).x} y={normRect(preview.a, preview.b).y} width={normRect(preview.a, preview.b).w} height={normRect(preview.a, preview.b).h} fill="#37415111" stroke={SELECTION} strokeWidth={1} strokeDasharray="4 3" />
1110              ) : (
1111                <ellipse cx={normRect(preview.a, preview.b).x + normRect(preview.a, preview.b).w / 2} cy={normRect(preview.a, preview.b).y + normRect(preview.a, preview.b).h / 2} rx={normRect(preview.a, preview.b).w / 2} ry={normRect(preview.a, preview.b).h / 2} fill="#37415111" stroke={SELECTION} strokeWidth={1} strokeDasharray="4 3" />
1112              )
1113            )}
1114            {preview?.kind === "connector" && connectorFromPoint && (
1115              <path d={connectorPath(connectorFromPoint, preview.b, connectorOrthogonal ? "orthogonal" : "straight")} fill="none" stroke={SELECTION} strokeWidth={1.5} strokeDasharray="5 3" />
1116            )}
1117            {preview?.kind === "pen" && preview.points && preview.points.length >= 4 && (
1118              <polyline points={pointsAttr(preview.points)} fill="none" stroke="#111827" strokeWidth={3 / viewport.scale} strokeLinecap="round" strokeLinejoin="round" />
1119            )}
1120            {/* inline text edit: a plain <textarea> (value-bound, no HTML) */}
1121            {editingEl && (editingEl.type === "sticky" || editingEl.type === "text") && (
1122              <foreignObject x={editingEl.x} y={editingEl.y} width={editingEl.w} height={editingEl.h} data-testid="wb-editor">
1123                <textarea
1124                  autoFocus
1125                  defaultValue={editingEl.text}
1126                  data-testid="wb-editor-input"
1127                  onBlur={(e) => commitEdit(e.currentTarget.value)}
1128                  onKeyDown={(e) => {
1129                    if (e.key === "Escape") {
1130                      e.stopPropagation();
1131                      commitEdit((e.target as HTMLTextAreaElement).value);
1132                    }
1133                    e.stopPropagation();
1134                  }}
1135                  style={{
1136                    width: "100%",
1137                    height: "100%",
1138                    resize: "none",
1139                    border: "none",
1140                    outline: "1.5px solid #2563EB",
1141                    background: editingEl.type === "sticky" ? (STICKY_HEX[editingEl.color] ?? "#FEF3C7") : "white",
1142                    padding: "6px 8px",
1143                    fontSize: editingEl.type === "sticky" ? 14 : editingEl.fontSize,
1144                    fontFamily: "ui-sans-serif, system-ui, sans-serif",
1145                    color: "#111827",
1146                    lineHeight: 1.25
1147                  }}
1148                />
1149              </foreignObject>
1150            )}
1151          </g>
1152        </svg>
1153      </div>
1154 
1155      {/* T-170: the page's comment drawer (comments are page-scoped). */}
1156      <CommentPanel pageId={pageId} workspaceId={workspaceId} open={commentsOpen} onClose={() => setCommentsOpen(false)} />
1157 
1158      {/* T-170: versions (save & restore) popover. */}
1159      {versionsOpen && (
1160        <div className="absolute right-2 top-10 z-50 w-80 rounded-md border border-border bg-popover p-3 shadow-xl" data-testid="wb-versions-popover">
1161          <div className="mb-2 flex items-center justify-between">
1162            <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Whiteboard versions</p>
1163            <button type="button" data-testid="wb-versions-close" onClick={() => setVersionsOpen(false)}>
1164              <X className="h-4 w-4 text-muted-foreground" />
1165            </button>
1166          </div>
1167          {!effectiveReadOnly && (
1168            <button type="button" data-testid="wb-save-version" className="mb-2 w-full rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50" disabled={savingVersion} onClick={() => void doSaveVersion()}>
1169              {savingVersion ? "Saving…" : "Save version"}
1170            </button>
1171          )}
1172          {versionsQuery.isLoading ? (
1173            <p className="py-2 text-sm text-muted-foreground">Loading…</p>
1174          ) : versionList.length === 0 ? (
1175            <p className="py-2 text-sm text-muted-foreground">No versions yet.</p>
1176          ) : (
1177            <ul className="max-h-64 space-y-1 overflow-y-auto">
1178              {versionList.map((v) => (
1179                <li key={v.id} className="flex items-center justify-between gap-2 rounded-md px-2 py-1 text-sm hover:bg-muted">
1180                  <span className="truncate text-muted-foreground" data-testid="wb-version-item">
1181                    v{v.id.slice(-4)} · {new Date(v.createdAt).toLocaleString()}
1182                  </span>
1183                  {!effectiveReadOnly && (
1184                    <button type="button" data-testid="wb-version-restore" className="shrink-0 text-xs text-primary hover:underline" onClick={() => setRestoreTarget({ id: v.id, createdAt: v.createdAt })}>
1185                      Restore
1186                    </button>
1187                  )}
1188                </li>
1189              ))}
1190            </ul>
1191          )}
1192        </div>
1193      )}
1194 
1195      {/* T-170: restore confirm (destructive — replaces the current board). */}
1196      <ConfirmDialog
1197        open={Boolean(restoreTarget)}
1198        title="Restore this version?"
1199        description="The current board will be replaced by this version. You can undo it with ⌘Z."
1200        confirmLabel="Restore"
1201        destructive
1202        onConfirm={() => void doRestore()}
1203        onClose={() => setRestoreTarget(null)}
1204      />
1205 
1206      {/* T-170: element link dialog (page reference or http(s) URL). */}
1207      {linkEl && (
1208        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/20" data-testid="wb-link-backdrop" onClick={() => setLinkTarget(null)}>
1209          <div className="w-full max-w-sm rounded-lg border border-border bg-card p-4 shadow-xl" data-testid="wb-link-dialog" onClick={(e) => e.stopPropagation()}>
1210            <div className="mb-3 flex items-center justify-between">
1211              <p className="text-sm font-semibold">Link this element</p>
1212              <button type="button" data-testid="wb-link-close" onClick={() => setLinkTarget(null)}>
1213                <X className="h-4 w-4 text-muted-foreground" />
1214              </button>
1215            </div>
1216            {linkEl.link && (
1217              <button type="button" data-testid="wb-link-clear" className="mb-3 text-xs text-muted-foreground hover:text-destructive" onClick={() => doSetLink(null)}>
1218                Remove link
1219              </button>
1220            )}
1221            <label className="mb-1 block text-xs font-medium text-muted-foreground">Page (internal)</label>
1222            <select
1223              data-testid="wb-link-page"
1224              className="mb-3 w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm"
1225              value=""
1226              onChange={(e) => {
1227                const refId = e.target.value;
1228                if (refId) doSetLink({ kind: "page", refId });
1229              }}
1230            >
1231              <option value="">Choose a page…</option>
1232              {pages.map((p) => (
1233                <option key={p.id} value={p.id}>
1234                  {p.title}
1235                </option>
1236              ))}
1237            </select>
1238            <label className="mb-1 block text-xs font-medium text-muted-foreground">Or an external URL</label>
1239            <div className="flex gap-2">
1240              <input
1241                data-testid="wb-link-url"
1242                type="url"
1243                placeholder="https://…"
1244                className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm"
1245                onKeyDown={(e) => {
1246                  if (e.key === "Enter") {
1247                    const href = (e.target as HTMLInputElement).value.trim();
1248                    if (/^https?:\/\/\S+$/.test(href)) doSetLink({ kind: "url", href });
1249                  }
1250                }}
1251              />
1252              <button
1253                type="button"
1254                data-testid="wb-link-set-url"
1255                className="shrink-0 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90"
1256                onClick={() => {
1257                  const href = (document.querySelector('[data-testid="wb-link-url"]') as HTMLInputElement)?.value.trim();
1258                  if (href && /^https?:\/\/\S+$/.test(href)) doSetLink({ kind: "url", href });
1259                }}
1260              >
1261                Set
1262              </button>
1263            </div>
1264          </div>
1265        </div>
1266      )}
1267 
1268      {/* T-171: convert-selection dialog (page or task). */}
1269      {convertTarget !== null && selection.length > 0 && (
1270        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/20" data-testid="wb-convert-backdrop" onClick={() => setConvertTarget(null)}>
1271          <div className="w-full max-w-sm rounded-lg border border-border bg-card p-4 shadow-xl" data-testid="wb-convert-dialog" onClick={(e) => e.stopPropagation()}>
1272            <div className="mb-3 flex items-center justify-between">
1273              <p className="text-sm font-semibold">Convert {selection.length} selected {selection.length === 1 ? "object" : "objects"}</p>
1274              <button type="button" data-testid="wb-convert-close" onClick={() => setConvertTarget(null)}>
1275                <X className="h-4 w-4 text-muted-foreground" />
1276              </button>
1277            </div>
1278            <p className="mb-3 text-xs text-muted-foreground">
1279              Sticky notes and text objects become blocks in a new {convertTarget === "task" ? "task" : "page"} (shapes and connectors are skipped).
1280            </p>
1281            <label className="mb-1 block text-xs font-medium text-muted-foreground">Create as</label>
1282            <select
1283              data-testid="wb-convert-target"
1284              className="mb-4 w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm"
1285              value={convertTarget}
1286              onChange={(e) => setConvertTarget(e.target.value === "task" ? "task" : "page")}
1287            >
1288              <option value="page">Page</option>
1289              <option value="task">Task</option>
1290            </select>
1291            <button type="button" data-testid="wb-convert-go" className="w-full rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50" disabled={converting} onClick={() => void doConvert()}>
1292              {converting ? "Converting…" : "Convert"}
1293            </button>
1294          </div>
1295        </div>
1296      )}
1297    </div>
1298  );
1299}
1300