.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.test.tsxapps/web/src/components/whiteboard/__tests__/whiteboard-canvas.test.tsxraw← __tests__24,113 B← Back to the app
1// @vitest-environment jsdom
2/**
3 * WhiteboardCanvas (T-168/T-169/T-170) — label escaping (XSS), read-only
4 * contract, the canvas gestures, and the T-170 surfaces.
5 *
6 * Pins:
7 *  - a sticky label containing an HTML payload renders as INERT SVG text:
8 *    the payload is the text content, and no `<img>` (or any injected element)
9 *    exists in the DOM. Labels flow through React `<text>` children (textContent
10 *    semantics), never through `dangerouslySetInnerHTML`.
11 *  - `canvasValid: false` (server validation failed — e.g. an unknown canvas
12 *    version) renders the read-only banner and hides the editing tools —
13 *    the roadmap's unknown-version contract.
14 *  - T-169 gestures: rect drag-create, connector (element→element, anchored
15 *    via nearestAnchor + arrowhead), freehand pen capture (decimated),
16 *    marquee select + group/ungroup (package ops).
17 *  - T-170: undo/redo (⌘Z/⌘⇧Z over the package OpStack), an element link
18 *    (⌘K dialog → page reference) renders the link marker and is stored on
19 *    the element, the comments button mounts the page's CommentPanel, and
20 *    the collaboration indicator shows the page channel's peers.
21 *
22 * Viewport math: default viewport = {scale:1, tx:32, ty:32}, and the test
23 * stubs the svg's getBoundingClientRect to (0,0), so clientX/Y map to the
24 * world as (clientX−32, clientY−32).
25 */
26(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
27import { act } from "react";
28import { createRoot, type Root } from "react-dom/client";
29import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
30import type { Canvas } from "@factory/whiteboard";
31 
32const XSS_PAYLOAD = '<img src=x onerror="window.__wb_pwned = true">';
33 
34const mockCanvas: Canvas = {
35  version: 1,
36  elements: {
37    s1: { id: "s1", type: "sticky", x: 0, y: 0, w: 400, h: 110, z: 1, text: XSS_PAYLOAD, color: "yellow" },
38    t1: { id: "t1", type: "text", x: 0, y: 200, w: 220, h: 32, z: 2, text: "plain text", fontSize: 16 }
39  }
40};
41 
42/** Two rects with a gap — for the connector test (A right → B left). */
43const connectorCanvas: Canvas = {
44  version: 1,
45  elements: {
46    a: { id: "a", type: "rect", x: 0, y: 0, w: 100, h: 50, z: 1, fill: null, stroke: null },
47    b: { id: "b", type: "rect", x: 200, y: 0, w: 100, h: 50, z: 2, fill: null, stroke: null }
48  }
49};
50 
51/** Shared mock state — `vi.hoisted` so the (hoisted) vi.mock factories can
52 *  reference it without a TDZ error. */
53const { mocks, presenceBox, routerBox } = vi.hoisted(() => ({
54  mocks: {
55    canvasValid: true,
56    canvas: {} as unknown,
57    saveMutate: vi.fn(),
58    mutateAsync: vi.fn(),
59    saveVersion: vi.fn(),
60    restoreVersion: vi.fn(),
61    wbGet: vi.fn()
62  },
63  /** The page channel's roster for page "p1" (the mock presence store). */
64  presenceBox: { page: {} as Record<string, { userId: string; name: string; color: string; lastSeen: number }> },
65  /** Captured router.push calls (the convert flow navigates to the new page). */
66  routerBox: {
67    push: vi.fn(),
68    convert: vi.fn(async () => ({ page: { id: "newpage1", kind: "page", title: "t" }, task: null, blockCount: 2 }))
69  }
70}));
71const PEER = { userId: "peer1", name: "Ada Lovelace", color: "#7C3AED" } as const;
72 
73vi.mock("next/navigation", () => ({
74  useRouter: () => ({ push: routerBox.push })
75}));
76 
77vi.mock("@/stores/ui-store", () => ({
78  toast: vi.fn()
79}));
80 
81vi.mock("@/hooks/use-whiteboard", () => ({
82  useWhiteboardCanvas: () => ({
83    data: { pageId: "p1", schemaVersion: 1, canvas: mocks.canvas, valid: mocks.canvasValid, updatedAt: "2026-01-01T00:00:00Z", updatedBy: "u1" },
84    isLoading: false
85  }),
86  useWhiteboardSave: () => ({ mutate: mocks.saveMutate, mutateAsync: mocks.mutateAsync, isPending: false }),
87  whiteboardKey: (pageId: string) => ["pages", pageId, "whiteboard"]
88}));
89 
90vi.mock("@/lib/api", () => ({
91  whiteboardApi: {
92    get: mocks.wbGet,
93    save: vi.fn(),
94    saveVersion: mocks.saveVersion,
95    restoreVersion: mocks.restoreVersion,
96    convertSelection: routerBox.convert
97  }
98}));
99 
100vi.mock("@/hooks/use-pages", () => ({
101  usePageTree: () => ({ data: [{ id: "pg1", title: "Roadmap", children: [] }], isLoading: false }),
102  usePageVersions: () => ({
103    data: [
104      { id: "pv-abc123", pageId: "p1", title: "t", blockCount: 2, createdBy: "u1", createdAt: "2026-01-02T00:00:00Z", source: "manual" },
105      { id: "pv-def456", pageId: "p1", title: "t", blockCount: 1, createdBy: "u1", createdAt: "2026-01-01T00:00:00Z", source: "auto" }
106    ],
107    isLoading: false,
108    refetch: vi.fn()
109  })
110}));
111 
112vi.mock("@/lib/auth-store", () => ({
113  useAuthStore: (sel: (s: { user: { id: string } | null }) => unknown) => sel({ user: { id: "me" } })
114}));
115 
116// The renderer reads `s.presence[pageId]` via the store's selector — a
117// selector-based mock (the real zustand store shape) keeps it honest.
118vi.mock("@/stores/block-patch-store", () => ({
119  useBlockPatchStore: (
120    sel: (s: { presence: Record<string, Record<string, { userId: string; name: string; color: string; lastSeen: number }>> }) => unknown
121  ) => sel({ presence: { p1: presenceBox.page } })
122}));
123 
124vi.mock("@/components/comments/comment-panel", () => ({
125  CommentPanel: (props: { pageId: string; open: boolean }) =>
126    props.open ? <div data-testid="wb-mock-comment-panel">{props.pageId}</div> : null
127}));
128 
129vi.mock("@/components/ui/confirm-dialog", () => ({
130  ConfirmDialog: (props: { open: boolean; onConfirm: () => void; onClose: () => void }) =>
131    props.open ? (
132      <div data-testid="wb-mock-confirm">
133        <button type="button" data-testid="wb-mock-confirm-btn" onClick={props.onConfirm}>
134          Confirm
135        </button>
136      </div>
137    ) : null
138}));
139 
140import { WhiteboardCanvas } from "../whiteboard-canvas";
141 
142let root: Root;
143let host: HTMLElement;
144 
145beforeEach(() => {
146  host = document.createElement("div");
147  document.body.appendChild(host);
148  root = createRoot(host);
149  mocks.canvasValid = true;
150  mocks.canvas = mockCanvas;
151  mocks.saveMutate.mockReset();
152  mocks.mutateAsync.mockReset().mockResolvedValue({ pageId: "p1", schemaVersion: 1, canvas: mockCanvas, updatedAt: "2026-01-01T00:00:00Z" });
153  mocks.saveVersion.mockReset().mockResolvedValue({ id: "pv1", version: 1 });
154  mocks.restoreVersion.mockReset().mockResolvedValue({ restored: true, version: 1 });
155  mocks.wbGet.mockReset().mockImplementation(async () => ({ pageId: "p1", schemaVersion: 1, canvas: mocks.canvas, valid: mocks.canvasValid, updatedAt: "2026-01-01T00:00:00Z", updatedBy: "u1" }));
156  routerBox.push.mockReset();
157  routerBox.convert.mockReset().mockResolvedValue({ page: { id: "newpage1", kind: "page", title: "t" }, task: null, blockCount: 2 });
158  presenceBox.page = { peer1: { ...PEER, lastSeen: Date.now() } };
159  // jsdom has no pointer capture — the renderer calls it on drag start.
160  (HTMLElement.prototype as unknown as { setPointerCapture?: unknown }).setPointerCapture = vi.fn();
161  (SVGElement.prototype as unknown as { setPointerCapture?: unknown }).setPointerCapture = vi.fn();
162});
163 
164afterEach(() => {
165  act(() => root.unmount());
166  host.remove();
167});
168 
169const renderBoard = (props: { canvasValid?: boolean } = {}) =>
170  act(() => root.render(<WhiteboardCanvas pageId="p1" workspaceId="ws1" canvasValid={props.canvasValid ?? true} />));
171 
172/** Make world math exact: the svg sits at (0,0) on screen. */
173const fixSvgBox = () => {
174  const svg = host.querySelector<SVGElement>('[data-testid="wb-canvas"]')!;
175  svg.getBoundingClientRect = () =>
176    ({ left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect;
177  return svg;
178};
179 
180const clickTool = (testid: string) =>
181  act(() => {
182    host.querySelector(`[data-testid="${testid}"]`)!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
183  });
184 
185const pointer = (svg: SVGElement, type: string, clientX: number, clientY: number) =>
186  act(() => {
187    svg.dispatchEvent(new MouseEvent(type, { bubbles: true, clientX, clientY, button: 0 }));
188  });
189 
190describe("WhiteboardCanvas (T-168)", () => {
191  it("renders a sticky with its label as INERT text — an HTML payload is never parsed as markup (XSS)", () => {
192    renderBoard();
193    const sticky = host.querySelector('[data-testid="wb-sticky"]');
194    expect(sticky).not.toBeNull();
195    const label = host.querySelector('[data-testid="wb-label"]');
196    expect(label).not.toBeNull();
197    // the payload survives as TEXT content…
198    expect(label!.textContent).toBe(XSS_PAYLOAD);
199    // …and no element from the payload exists anywhere in the canvas DOM.
200    expect(host.querySelector("img")).toBeNull();
201    expect(host.innerHTML.includes("onerror=")).toBe(true); // present only as escaped text
202    expect((window as unknown as { __wb_pwned?: boolean }).__wb_pwned).toBeUndefined();
203  });
204 
205  it("renders a text element with its label", () => {
206    renderBoard();
207    const texts = Array.from(host.querySelectorAll('[data-testid="wb-label"]')).map((t) => t.textContent);
208    expect(texts).toContain("plain text");
209  });
210 
211  it("an unvalidatable canvas (valid:false) → read-only banner, no editing tools", () => {
212    renderBoard({ canvasValid: false });
213    const banner = host.querySelector('[data-testid="wb-readonly-banner"]');
214    expect(banner).not.toBeNull();
215    expect(host.querySelector('[data-testid="wb-tool-select"]')).toBeNull();
216    expect(host.querySelector('[data-testid="wb-tool-sticky"]')).toBeNull();
217    expect(host.querySelector('[data-testid="wb-tool-text"]')).toBeNull();
218    expect(host.querySelector('[data-testid="wb-tool-rect"]')).toBeNull();
219    expect(host.querySelector('[data-testid="wb-tool-pen"]')).toBeNull();
220    // the pan tool stays (a read-only user can still move around).
221    expect(host.querySelector('[data-testid="wb-tool-pan"]')).not.toBeNull();
222    // …but the content still renders.
223    expect(host.querySelector('[data-testid="wb-sticky"]')).not.toBeNull();
224  });
225 
226  it("a valid canvas shows all tools (select/sticky/text/rect/ellipse/connector/pen/pan)", () => {
227    renderBoard();
228    expect(host.querySelector('[data-testid="wb-readonly-banner"]')).toBeNull();
229    for (const t of ["wb-tool-select", "wb-tool-sticky", "wb-tool-text", "wb-tool-rect", "wb-tool-ellipse", "wb-tool-connector", "wb-tool-pen", "wb-tool-pan"]) {
230      expect(host.querySelector(`[data-testid="${t}"]`)).not.toBeNull();
231    }
232  });
233 
234  it("creates a sticky with the sticky tool (click on the canvas) and opens its editor", () => {
235    renderBoard();
236    clickTool("wb-tool-sticky");
237    const svg = fixSvgBox();
238    // pointer-down at client (100,50) → world (68,18)
239    pointer(svg, "pointerdown", 100, 50);
240    expect(host.querySelectorAll('[data-testid="wb-sticky"]').length).toBe(2); // the seeded one + the new one
241    // the new element is selected and its inline editor opened
242    expect(host.querySelector('[data-testid="wb-editor-input"]')).not.toBeNull();
243  });
244 
245  it("deletes the selected element with the Delete key", () => {
246    renderBoard();
247    expect(host.querySelectorAll('[data-testid="wb-sticky"]').length).toBe(1);
248    // select the seeded sticky (world (68,18) is inside 0,0,400,110)
249    const svg = fixSvgBox();
250    pointer(svg, "pointerdown", 100, 50);
251    pointer(svg, "pointerup", 100, 50);
252    act(() => {
253      window.dispatchEvent(new KeyboardEvent("keydown", { key: "Delete" }));
254    });
255    expect(host.querySelectorAll('[data-testid="wb-sticky"]').length).toBe(0);
256  });
257});
258 
259describe("WhiteboardCanvas (T-169)", () => {
260  it("creates a rect by dragging with the rect tool", () => {
261    renderBoard();
262    clickTool("wb-tool-rect");
263    const svg = fixSvgBox();
264    pointer(svg, "pointerdown", 100, 100); // world (68,68)
265    pointer(svg, "pointermove", 200, 150); // world (168,118)
266    pointer(svg, "pointerup", 200, 150);
267    const rect = host.querySelector('[data-testid="wb-rect"]');
268    expect(rect).not.toBeNull();
269    expect(rect!.getAttribute("x")).toBe("68");
270    expect(rect!.getAttribute("y")).toBe("68");
271    expect(rect!.getAttribute("width")).toBe("100");
272    expect(rect!.getAttribute("height")).toBe("50");
273    // the new element is selected (resize handle visible)
274    expect(host.querySelector('[data-testid="wb-resize-handle"]')).not.toBeNull();
275  });
276 
277  it("creates an ellipse by dragging with the ellipse tool", () => {
278    renderBoard();
279    clickTool("wb-tool-ellipse");
280    const svg = fixSvgBox();
281    pointer(svg, "pointerdown", 100, 100);
282    pointer(svg, "pointermove", 200, 150);
283    pointer(svg, "pointerup", 200, 150);
284    const el = host.querySelector('[data-testid="wb-ellipse"]');
285    expect(el).not.toBeNull();
286    // center (68+50, 68+25) = (118,93), rx=50 ry=25
287    expect(el!.getAttribute("cx")).toBe("118");
288    expect(el!.getAttribute("cy")).toBe("93");
289  });
290 
291  it("creates a connector by dragging from one element to another (anchored + arrowhead)", () => {
292    mocks.canvas = connectorCanvas;
293    renderBoard();
294    clickTool("wb-tool-connector");
295    const svg = fixSvgBox();
296    // start on the RIGHT edge of rect A (world 90,25 → client 122,57): nearest anchor = "right" (100,25)
297    pointer(svg, "pointerdown", 122, 57);
298    // drag to rect B (world 225,25 → client 257,57): nearest anchor = "left" (200,25)
299    pointer(svg, "pointermove", 257, 57);
300    pointer(svg, "pointerup", 257, 57);
301    const conn = host.querySelector('[data-testid="wb-connector"]');
302    expect(conn).not.toBeNull();
303    const path = host.querySelector('[data-testid="wb-connector"] path')!;
304    // orthogonal (default) between (100,25) and (200,25): H-V-H with mid (150,25)
305    expect(path.getAttribute("d")).toBe("M 100 25 L 150 25 L 150 25 L 200 25");
306    // the arrowhead polygon is rendered (arrow on by default)
307    expect(host.querySelector('[data-testid="wb-connector"] polygon')).not.toBeNull();
308  });
309 
310  it("captures a freehand stroke with the pen tool (decimated polyline)", () => {
311    renderBoard();
312    clickTool("wb-tool-pen");
313    const svg = fixSvgBox();
314    pointer(svg, "pointerdown", 100, 100); // world (68,68)
315    pointer(svg, "pointermove", 150, 150); // world (118,118)
316    pointer(svg, "pointermove", 200, 100); // world (168,68)
317    pointer(svg, "pointermove", 250, 150); // world (218,118)
318    pointer(svg, "pointerup", 250, 150);
319    const stroke = host.querySelector('[data-testid="wb-freehand"]');
320    expect(stroke).not.toBeNull();
321    const pts = (stroke!.getAttribute("points") ?? "").split(" ").filter(Boolean);
322    expect(pts.length).toBeGreaterThanOrEqual(3);
323    // the first point is the stroke start (world 68,68)
324    expect(pts[0]).toBe("68,68");
325  });
326 
327  it("marquee-selects two elements and groups/ungroups them (package ops)", () => {
328    mocks.canvas = connectorCanvas;
329    renderBoard();
330    const svg = fixSvgBox();
331    // marquee from empty space above A (world 50,-10) to below-B gap (world 250,60)
332    // → box x 50-250, y -10-60 overlaps A(0,0,100,50) and B(200,0,100,50)
333    pointer(svg, "pointerdown", 82, 22);
334    pointer(svg, "pointermove", 282, 92);
335    pointer(svg, "pointerup", 282, 92);
336    // two elements selected → the group button enables
337    expect((host.querySelector('[data-testid="wb-btn-group"]') as HTMLButtonElement).disabled).toBe(false);
338    // ⌘G groups them
339    act(() => {
340      window.dispatchEvent(new KeyboardEvent("keydown", { key: "g", metaKey: true }));
341    });
342    const group = host.querySelector('[data-testid="wb-group"]');
343    expect(group).not.toBeNull();
344    // the group's bounds are the union of the children (0,0 → 300,50)
345    expect(group!.getAttribute("x")).toBe("0");
346    expect(group!.getAttribute("y")).toBe("0");
347    expect(group!.getAttribute("width")).toBe("300");
348    expect(group!.getAttribute("height")).toBe("50");
349    // ⌘⇧G ungroups (the container goes, the children stay)
350    act(() => {
351      window.dispatchEvent(new KeyboardEvent("keydown", { key: "g", metaKey: true, shiftKey: true }));
352    });
353    expect(host.querySelector('[data-testid="wb-group"]')).toBeNull();
354    expect(host.querySelectorAll('[data-testid="wb-rect"]').length).toBe(2);
355  });
356});
357 
358describe("WhiteboardCanvas (T-170)", () => {
359  it("undo (⌘Z) reverts an edit, redo (⌘⇧Z) reapplies it", () => {
360    renderBoard();
361    // create a sticky (pushes an undoable step)
362    clickTool("wb-tool-sticky");
363    const svg = fixSvgBox();
364    pointer(svg, "pointerdown", 100, 50);
365    expect(host.querySelectorAll('[data-testid="wb-sticky"]').length).toBe(2);
366    // ⌘Z → back to one sticky; the undo button now disables (nothing left)
367    act(() => {
368      window.dispatchEvent(new KeyboardEvent("keydown", { key: "z", metaKey: true }));
369    });
370    expect(host.querySelectorAll('[data-testid="wb-sticky"]').length).toBe(1);
371    expect((host.querySelector('[data-testid="wb-btn-undo"]') as HTMLButtonElement).disabled).toBe(true);
372    expect((host.querySelector('[data-testid="wb-btn-redo"]') as HTMLButtonElement).disabled).toBe(false);
373    // ⌘⇧Z → the sticky is back
374    act(() => {
375      window.dispatchEvent(new KeyboardEvent("keydown", { key: "z", metaKey: true, shiftKey: true }));
376    });
377    expect(host.querySelectorAll('[data-testid="wb-sticky"]').length).toBe(2);
378  });
379 
380  it("attaches a page link via the link dialog (⌘K) — the element gets the link marker", () => {
381    renderBoard();
382    const svg = fixSvgBox();
383    // select the seeded sticky (world 68,18 is inside 0,0,400,110)
384    pointer(svg, "pointerdown", 100, 50);
385    // ⌘K opens the link dialog for the single selected element
386    act(() => {
387      window.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true }));
388    });
389    const dialog = host.querySelector('[data-testid="wb-link-dialog"]');
390    expect(dialog).not.toBeNull();
391    // choose the internal page "Roadmap" (pg1)
392    const select = host.querySelector<HTMLSelectElement>('[data-testid="wb-link-page"]')!;
393    act(() => {
394      select.value = "pg1";
395      select.dispatchEvent(new Event("change", { bubbles: true }));
396    });
397    // the dialog closed…
398    expect(host.querySelector('[data-testid="wb-link-dialog"]')).toBeNull();
399    // …and the sticky now shows the link marker (a lucide <svg> inside it)
400    const sticky = host.querySelector('[data-testid="wb-sticky"]')!;
401    expect(sticky.querySelector("svg")).not.toBeNull();
402  });
403 
404  it("opens the page's comment drawer (comments are page-scoped)", () => {
405    renderBoard();
406    expect(host.querySelector('[data-testid="wb-mock-comment-panel"]')).toBeNull();
407    act(() => {
408      host.querySelector('[data-testid="wb-comments"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
409    });
410    const panel = host.querySelector('[data-testid="wb-mock-comment-panel"]');
411    expect(panel).not.toBeNull();
412    expect(panel!.textContent).toBe("p1");
413  });
414 
415  it("shows the page channel's peers as the collaboration indicator (self excluded)", () => {
416    // self ("me") is in the roster too — must NOT render an avatar.
417    presenceBox.page.me = { userId: "me", name: "Self", color: "#111111", lastSeen: Date.now() };
418    renderBoard();
419    const avatars = host.querySelectorAll('[data-testid="wb-presence-peer"]');
420    expect(avatars.length).toBe(1);
421    expect(avatars[0]!.getAttribute("title")).toBe(PEER.name);
422  });
423 
424  it("save & restore: the versions popover snapshots + restores (server round-trip)", async () => {
425    renderBoard();
426    act(() => {
427      host.querySelector('[data-testid="wb-versions"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
428    });
429    const popover = host.querySelector('[data-testid="wb-versions-popover"]');
430    expect(popover).not.toBeNull();
431    // "Save version" → the explicit snapshot endpoint
432    await act(async () => {
433      host.querySelector('[data-testid="wb-save-version"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
434    });
435    expect(mocks.saveVersion).toHaveBeenCalledWith("p1");
436    // restore the first listed version → confirm → server round-trip
437    act(() => {
438      host.querySelector('[data-testid="wb-version-restore"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
439    });
440    expect(host.querySelector('[data-testid="wb-mock-confirm"]')).not.toBeNull();
441    await act(async () => {
442      host.querySelector('[data-testid="wb-mock-confirm-btn"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
443    });
444    expect(mocks.restoreVersion).toHaveBeenCalledWith("p1", "pv-abc123");
445    // the restored document is re-read from the server (GET whiteboard)
446    expect(mocks.wbGet).toHaveBeenCalledWith("p1");
447  });
448 
449  it("converts a selection to a page/task (T-171) — server maps the text, then navigates", async () => {
450    renderBoard();
451    const svg = fixSvgBox();
452    // select the seeded sticky (world 68,18)
453    pointer(svg, "pointerdown", 100, 50);
454    // the Convert button is enabled for any non-empty selection
455    expect((host.querySelector('[data-testid="wb-btn-convert"]') as HTMLButtonElement).disabled).toBe(false);
456    act(() => {
457      host.querySelector('[data-testid="wb-btn-convert"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
458    });
459    const dialog = host.querySelector('[data-testid="wb-convert-dialog"]');
460    expect(dialog).not.toBeNull();
461    // pick "task" and convert
462    const select = host.querySelector<HTMLSelectElement>('[data-testid="wb-convert-target"]')!;
463    act(() => {
464      select.value = "task";
465      select.dispatchEvent(new Event("change", { bubbles: true }));
466    });
467    await act(async () => {
468      host.querySelector('[data-testid="wb-convert-go"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
469    });
470    expect(routerBox.convert).toHaveBeenCalledWith("p1", { elementIds: ["s1"], target: "task", parentId: null });
471    // …and the user is taken to the new page
472    expect(routerBox.push).toHaveBeenCalledWith("/w/ws1/page/newpage1");
473  });
474});
475 
476describe("WhiteboardCanvas — viewport culling (2,000-object perf gate)", () => {
477  /** A stub that reports a fixed 800×600 content rect on observe (jsdom has
478   *  no layout, so the real ResizeObserver never fires). */
479  function installROStub(width: number, height: number) {
480    class FakeRO {
481      constructor(cb: ResizeObserverCallback) {
482        this.cb = cb;
483      }
484      cb: ResizeObserverCallback;
485      observe() {
486        this.cb([{ contentRect: { width, height } } as unknown as ResizeObserverEntry], this);
487      }
488      unobserve() {}
489      disconnect() {}
490    }
491    (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = FakeRO;
492    return () => {
493      delete (globalThis as Record<string, unknown>).ResizeObserver;
494    };
495  }
496 
497  it("only mounts DOM nodes for elements inside the viewport (off-viewport elements are culled)", () => {
498    // One rect on the default viewport (world 0..100), one far off-screen.
499    const cullCanvas: Canvas = {
500      version: 1,
501      elements: {
502        near: { id: "near", type: "rect", x: 0, y: 0, w: 100, h: 50, z: 1, fill: null, stroke: null },
503        far: { id: "far", type: "rect", x: 5000, y: 5000, w: 100, h: 50, z: 2, fill: null, stroke: null }
504      }
505    };
506    mocks.canvas = cullCanvas;
507    const cleanup = installROStub(800, 600);
508    try {
509      renderBoard();
510      // Without culling both rects render (2). With culling, only the
511      // on-viewport one is mounted — the off-viewport node never enters the DOM.
512      expect(host.querySelectorAll('[data-testid="wb-rect"]').length).toBe(1);
513    } finally {
514      cleanup();
515    }
516  });
517 
518  it("renders ALL elements when the container has no layout (size 0×0 → culling off, headless-safe)", () => {
519    const cullCanvas: Canvas = {
520      version: 1,
521      elements: {
522        near: { id: "near", type: "rect", x: 0, y: 0, w: 100, h: 50, z: 1, fill: null, stroke: null },
523        far: { id: "far", type: "rect", x: 5000, y: 5000, w: 100, h: 50, z: 2, fill: null, stroke: null }
524      }
525    };
526    mocks.canvas = cullCanvas;
527    // No ResizeObserver stub → size stays 0×0 → viewportToWorldRect null → no culling.
528    renderBoard();
529    expect(host.querySelectorAll('[data-testid="wb-rect"]').length).toBe(2);
530  });
531});
532