.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
page-view.tsxapps/web/src/components/editor/page-view.tsxraw← editor63,088 B← Back to the app
1"use client";
2 
3/**
4 * PageView — the editor surface for one page:
5 *
6 * - Header: icon + inline title (PATCH page) + meta (updated, author).
7 * - Block list: top-level blocks sorted client-side by fractional position;
8 *   each renders via <BlockRenderer>. Nested content (toggle, columns,
9 *   synced) is loaded lazily per block type by the renderer.
10 * - Trailing empty area: click → creates + focuses a new paragraph.
11 * - Drag & drop (dnd-kit): a drop rewrites only the moved block's position
12 *   (fractional midpoint, via useMoveBlock) — no reindexing (invariant 4).
13 * - Keyboard: Enter in a text block → new sibling; ArrowUp/Down at the
14 *   block boundary → previous/next block; Cmd/Ctrl+Z → Tiptap history
15 *   (inside the editor); Delete on a selected, non-editing row removes it.
16 * - Deep link: focusBlockId scrolls to + highlights the target block.
17 */
18import { useCallback, useEffect, useMemo, useRef, useState } from "react";
19import { useParams } from "next/navigation";
20import {
21  DndContext,
22  PointerSensor,
23  KeyboardSensor,
24  closestCenter,
25  useSensor,
26  useSensors,
27  type DragEndEvent,
28} from "@dnd-kit/core";
29import {
30  SortableContext,
31  arrayMove,
32  sortableKeyboardCoordinates,
33  useSortable,
34  verticalListSortingStrategy,
35} from "@dnd-kit/sortable";
36import { CSS } from "@dnd-kit/utilities";
37import { Clock, FolderOpen, Image as ImageIcon, Link2, MessageSquare, Smile, Sparkles } from "lucide-react";
38import { useBacklinks, usePage, usePageBlocks, useUpdatePage } from "@/hooks/use-pages";
39import { useWorkspace } from "@/hooks/use-workspace";
40import { useDeleteBlock, useInsertBlock, useMoveBlock, useMergedBlocks } from "@/hooks/use-blocks";
41import { isEmptyTextBlock, isContentEmpty, isAutoCleanableType } from "@/lib/block-content";
42import { getBlockBatchQueue } from "@/lib/batch-queue";
43import { usePagePresence } from "@/hooks/use-presence";
44import { usePresenceAnnouncer } from "@/hooks/use-presence-announcer";
45import { usePresenceToasts } from "@/hooks/use-presence-toasts";
46import { useEditorStore } from "@/stores/editor-store";
47import { useCommentPillStore } from "@/stores/comment-pill-store";
48import { toast, useUiStore } from "@/stores/ui-store";
49import { useAuthStore } from "@/lib/auth-store";
50import { realtime } from "@/lib/realtime";
51import { CommentPanel } from "@/components/comments/comment-panel";
52import { RelatedPanel } from "./related-panel";
53import { cn, formatRelativeTime, sortByPosition } from "@/lib/utils";
54import { useFileUploads } from "@/lib/files/use-file-uploads";
55import { BLOCK_TYPE_LABELS } from "@/lib/constants";
56import { PresenceBar } from "@/components/collaboration/PresenceBar";
57import { CursorLayer } from "@/components/collaboration/CursorLayer";
58import { FilePicker } from "@/components/files/FilePicker";
59import { BlockRenderer } from "./block-renderer";
60import { BlockToolbar } from "./block-toolbar";
61import { ContextMenu } from "./context-menu";
62import { InsertDialog } from "./insert-dialog";
63import { TemplateDialog } from "./template-dialog";
64import { SelectionToolbar } from "./selection-toolbar";
65import { WritingDialog } from "@/components/ai/writing-diff";
66import { ReviewBanner } from "./review-banner";
67import { blockApi, templateApi, type BatchBlockOp } from "@/lib/api";
68import { useQueryClient } from "@tanstack/react-query";
69import {
70  htmlHasBlockStructure,
71  pasteHtmlToBlocks,
72  parseClipboardText,
73  type PastedBlockSpec
74} from "@/lib/clipboard-import";
75import type { WritingAction } from "@/lib/ai";
76import type { Block } from "@/lib/types";
77import type { SlashCommand } from "./slash-menu";
78 
79interface PageViewProps {
80  pageId: string;
81  focusBlockId?: string | null;
82}
83 
84export function PageView({ pageId, focusBlockId }: PageViewProps) {
85  const qc = useQueryClient();
86  const { data: page } = usePage(pageId);
87  const { data: serverBlocks } = usePageBlocks(pageId, null);
88  const blocks = useMergedBlocks(pageId, serverBlocks ?? []);
89  const sorted = useMemo(() => sortByPosition(blocks), [blocks]);
90  // Every page carries a title block (properties.isTitle). The Home empty-state
91  // card means "no CONTENT yet" — so count content blocks (title excluded); a
92  // freshly created page has exactly the title and shows the card.
93  const contentBlockCount = useMemo(
94    () => sorted.filter((b) => b.properties?.isTitle !== true).length,
95    [sorted],
96  );
97 
98  // TASK-0126 — the "Generate page with AI" entry points (header button + Home
99  // empty-state card) need EDIT; hidden for GUEST (the server still returns
100  // 403 — this is the UI gate, AC item 6). The role comes from the workspace
101  // loaded by the [workspace] layout (React Query cache hit).
102  const wsParams = useParams<{ workspace: string }>();
103  const { data: wsRoleData } = useWorkspace(wsParams?.workspace ?? "");
104  const canGenerate = (wsRoleData?.role ?? "").toLowerCase() !== "guest";
105 
106  // Presence (TASK-0093): the page's OTHER peers (self excluded by the
107  // server + defensively below), the a11y live-region text (≤ 1/s, state
108  // changes only), the join/leave toasts (the ui-store toggle, default on),
109  // and a block-id → type-label map for the bar's "editing Heading 1".
110  const selfUserId = useAuthStore((s) => s.user?.id ?? null);
111  const presence = usePagePresence(pageId);
112  const others = useMemo(
113    () => (selfUserId ? presence.filter((p) => p.userId !== selfUserId) : presence),
114    [presence, selfUserId],
115  );
116  const announcement = usePresenceAnnouncer(pageId, selfUserId);
117  usePresenceToasts(pageId, selfUserId);
118  const blockTypeFor = useCallback(
119    (blockId: string) => {
120      const b = blocks.find((x) => x.id === blockId);
121      return b ? BLOCK_TYPE_LABELS[b.type] : null;
122    },
123    [blocks],
124  );
125  const blockIds = useMemo(() => sorted.map((b) => b.id), [sorted]);
126 
127  // T-194 — the seeded title block (properties.isTitle) is NOT body content:
128  // the header's `page-title` contenteditable is the canonical title (it PATCHes
129  // the page and the server syncs the title block's text). The render loop maps
130  // only REAL content blocks, so a fresh page (just the title) shows an empty
131  // canvas + the `new-block` trailing surface — clicking it creates + focuses
132  // the first paragraph (onTrailingAreaClick), which is where the slash menu
133  // and typing begin. Consistent with the two existing isTitle exclusions
134  // (contentBlockCount above; handleEmptyBlur's skip). `sorted` stays
135  // title-inclusive for position math (drag/drop, focusSibling, slash anchor).
136  const renderBlocks = useMemo(() => sorted.filter((b) => b.properties?.isTitle !== true), [sorted]);
137 
138  const updatePage = useUpdatePage(page?.workspaceId ?? "");
139  const insertBlock = useInsertBlock(pageId);
140  const moveBlock = useMoveBlock(pageId);
141  const deleteBlock = useDeleteBlock(pageId);
142  const { setSelection } = useEditorStore();
143  const storeSelected = useEditorStore((s) => s.selectedBlockId);
144 
145  // TASK-0103 — upload paths (a) drag-drop from the OS and (b) paste an
146  // image. Both funnel into the SAME upload service (useFileUploads →
147  // lib/files/upload.ts): pre-check → presigned session → XHR PUT (progress
148  // on the block) → complete → poll. The raw-`src` paths in the insert dialog
149  // / legacy blocks are untouched.
150  const { start: startUpload } = useFileUploads(page?.workspaceId ?? null, pageId);
151  const [fileDragActive, setFileDragActive] = useState(false);
152  const fileDragDepth = useRef(0);
153  const lastDropPosition = useRef<string | null>(null);
154  const sortedRef = useRef(sorted);
155  sortedRef.current = sorted;
156 
157  const dropTargetPosition = useCallback(
158    (clientY: number): string | null => {
159      // nearest block ABOVE the pointer → insert after it (null = end)
160      const els = Array.from(
161        pageRef.current?.querySelectorAll<HTMLElement>("[data-block-id]") ?? [],
162      );
163      let best: string | null = null;
164      let bestDist = Infinity;
165      for (const el of els) {
166        const rect = el.getBoundingClientRect();
167        if (clientY < rect.top) continue; // the pointer is above this block
168        const dist = clientY - rect.top;
169        if (dist < bestDist) {
170          bestDist = dist;
171          best = el.dataset.blockId ?? null;
172        }
173      }
174      if (!best) return null;
175      const above = sortedRef.current.find((b) => b.id === best);
176      return above ? above.position : null;
177    },
178    // pageRef is stable
179    [],
180  );
181 
182  const onFilesDropped = useCallback(
183    async (e: React.DragEvent) => {
184      e.preventDefault();
185      fileDragDepth.current = 0;
186      setFileDragActive(false);
187      const files = Array.from(e.dataTransfer?.files ?? []);
188      if (files.length === 0) return;
189      const position = dropTargetPosition(e.clientY);
190      lastDropPosition.current = position;
191      for (const file of files) {
192        await startUpload(file, { parentId: null, position });
193      }
194    },
195    [startUpload, dropTargetPosition],
196  );
197 
198  const onDragEnter = useCallback((e: React.DragEvent) => {
199    if (!e.dataTransfer?.types?.includes("Files")) return;
200    fileDragDepth.current += 1;
201    setFileDragActive(true);
202  }, []);
203  const onDragOver = useCallback((e: React.DragEvent) => {
204    if (fileDragActive) e.preventDefault(); // allow the drop
205  }, [fileDragActive]);
206  const onDragLeave = useCallback(() => {
207    fileDragDepth.current = Math.max(0, fileDragDepth.current - 1);
208    if (fileDragDepth.current === 0) setFileDragActive(false);
209  }, []);
210 
211  /**
212   * Focus a freshly created block: it is not in the DOM yet (the list
213   * refetches after the mutation), so the retry effect below scrolls to it
214   * once it lands in the sorted list. (Declared before the paste handler —
215   * the paste path focuses the first pasted block the same way.)
216   */
217  const focusNewBlock = useCallback((id: string) => {
218    setFocusTarget(id);
219    pendingFocus.current = id;
220    hasScrolledFocus.current = true;
221  }, []);
222 
223  // (b) paste:
224  //  - image blob → upload at the selected block;
225  //  - structural HTML (Notion/Word/Docs: lists, toggles, headings, quotes)
226  //    → BLOCK-level import (lib/clipboard-import) preserving indentation and
227  //    toggle parent-child structure (the QA "copy from Notion" case);
228  //  - the app's internal clipboard payload (multi-block copy) → block import.
229  // Plain inline text/HTML stays with the focused InlineEditor (ProseMirror's
230  // native inline paste) — we never hijack a text-only paste.
231  const runPasteImport = useCallback(
232    async (specs: PastedBlockSpec[], anchorBlockId: string | null) => {
233      // Anchor: the selected block's LAST DESCENDANT (paste under a TOGGLE
234      // lands INSIDE it — matching where the caret is); otherwise top-level.
235      // The page list is top-level only (depth 1), so the anchor's own
236      // descendants are fetched on demand (bounded: one subtree).
237      const sorted = sortedRef.current;
238      const anchor = anchorBlockId ? sorted.find((b) => b.id === anchorBlockId) ?? null : null;
239      let anchorAfter: string | null = null;
240      if (anchor) {
241        anchorAfter = anchor.id;
242        try {
243          // Walk the last-child chain (one direct-children fetch per level;
244          // the list is position-sorted → last element = last sibling).
245          let deepest = anchor.id;
246          for (let d = 0; d < 8; d += 1) {
247            const kids = await blockApi.list(pageId, { parentId: deepest });
248            const last = kids[kids.length - 1];
249            if (!last) break;
250            deepest = last.id;
251            if (deepest === anchor.id) break;
252          }
253          anchorAfter = deepest;
254        } catch {
255          /* fall back to anchoring at the block itself */
256        }
257      }
258 
259      // ONE op per spec (parents precede their children in document order —
260      // the parser guarantees it), chained: each block lands after the
261      // PREVIOUSLY PASTED sibling (first in a parent group: after the parent
262      // itself / the page anchor). The server assigns the fractional
263      // position per op (same transaction, FIFO). Chaining per sibling group
264      // (not all to the anchor) keeps the source order: two inserts "after
265      // the same anchor" inside one tx would interleave reversed.
266      const lastInGroup = new Map<string, string>(); // parentId (or "__top__") → last pasted id
267      const ops: BatchBlockOp[] = specs.map((s) => {
268        const groupKey = s.parentId ?? "__top__";
269        const chainFrom = lastInGroup.get(groupKey) ?? (s.parentId ?? anchorAfter);
270        lastInGroup.set(groupKey, s.id);
271        return {
272          op: "upsert" as const,
273          block: {
274            id: s.id,
275            type: s.type,
276            content: s.content,
277            ...(s.properties ? { properties: s.properties } : { properties: {} }),
278            parentId: s.parentId ?? null,
279            ...(chainFrom ? { afterBlockId: chainFrom } : {})
280          }
281        };
282      });
283 
284      // The batch endpoint caps at 200 ops → chunk (preserving order).
285      try {
286        for (let i = 0; i < ops.length; i += 200) {
287          await blockApi.batch(pageId, ops.slice(i, i + 200));
288        }
289        qc.invalidateQueries({ queryKey: ["pages", pageId, "blocks"] });
290        toast("success", `Pasted ${specs.length} block${specs.length === 1 ? "" : "s"}`);
291        // Focus the first top-level pasted block (it is in the DOM after the
292        // invalidation refetch — the pendingFocus retry effect handles it).
293        const firstRoot = specs.find((s) => s.parentId === null);
294        if (firstRoot) focusNewBlock(firstRoot.id);
295      } catch (err) {
296        toast("error", "Paste failed", err instanceof Error ? err.message : undefined);
297      }
298    },
299    [pageId, focusNewBlock]
300  );
301 
302  useEffect(() => {
303    const onPaste = (e: ClipboardEvent) => {
304      if (useEditorStore.getState().activePageId !== pageId) return;
305      const cd = e.clipboardData;
306      if (!cd) return;
307 
308      // 1) image blob wins (unchanged legacy path).
309      const fileItem = Array.from(cd.items ?? []).find((i) => i.kind === "file" && i.type.startsWith("image/"));
310      if (fileItem) {
311        const file = fileItem.getAsFile();
312        if (file) {
313          e.preventDefault();
314          const selected = useEditorStore.getState().selectedBlockId;
315          const anchor = sortedRef.current.find((b) => b.id === selected);
316          void startUpload(file, { parentId: null, position: anchor ? anchor.position : null });
317          return;
318        }
319      }
320 
321      // 2) internal payload (our Copy, incl. subtree) — highest fidelity.
322      const internal = parseClipboardText(cd.getData("text/plain"));
323      if (internal) {
324        e.preventDefault();
325        void runPasteImport(internal, useEditorStore.getState().selectedBlockId);
326        return;
327      }
328 
329      // 3) structural HTML from other apps (Notion/Word/Docs).
330      const html = cd.getData("text/html");
331      if (html && htmlHasBlockStructure(html)) {
332        e.preventDefault();
333        const specs = pasteHtmlToBlocks(html);
334        if (specs.length > 0) void runPasteImport(specs, useEditorStore.getState().selectedBlockId);
335        return;
336      }
337      // otherwise: inline text paste → the focused editor handles it.
338    };
339    window.addEventListener("paste", onPaste);
340    return () => window.removeEventListener("paste", onPaste);
341  }, [pageId, startUpload, runPasteImport]);
342 
343  const [filesPickerOpen, setFilesPickerOpen] = useState(false);
344  const [focusTarget, setFocusTarget] = useState<string | null>(null);
345  const [menu, setMenu] = useState<{ x: number; y: number; block: Block } | null>(null);
346  // TASK-0038 icon/cover pickers (T-192).
347  const [iconPickerOpen, setIconPickerOpen] = useState(false);
348  const [coverPickerOpen, setCoverPickerOpen] = useState(false);
349  // The title is a contenteditable (NOT an <input>): the E2E contract reads
350  // it via toContainText (tests/README `page-title` = "editable text"), which
351  // only matches an element's TEXT CONTENT — an input's title lives in the
352  // `value` attribute and would never match (T-192). Text is set
353  // imperatively so React re-renders don't fight the caret; we re-sync from
354  // the server only while the field is NOT focused.
355  const titleRef = useRef<HTMLDivElement>(null);
356  useEffect(() => {
357    const el = titleRef.current;
358    if (!el) return;
359    if (document.activeElement === el) return; // don't clobber the caret mid-edit
360    el.textContent = page?.title ?? "";
361  }, [page?.id, page?.title]);
362  // T-192: the title commit is DEBOUNCED — one commit per typing burst,
363  // flushed on blur/Enter/Escape/unmount. Committing on every keystroke fired
364  // N concurrent PATCHes the server applies in arbitrary order, so an
365  // intermediate partial ("Chil") could overwrite the final "Child Page"
366  // (the UI shows the DOM text while the server holds the stale partial — the
367  // nested E2E flaked on exactly this). The commit targets the route `pageId`
368  // (always current, unlike `page?.id` which lags the load), so it is correct
369  // even before the page query resolves.
370  const titleCommitTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
371  const commitTitleNow = () => {
372    if (titleCommitTimer.current) {
373      clearTimeout(titleCommitTimer.current);
374      titleCommitTimer.current = null;
375    }
376    const el = titleRef.current;
377    if (!el) return;
378    updatePage.mutate({ id: pageId, title: el.textContent ?? "" });
379  };
380  const commitTitleRef = useRef(commitTitleNow);
381  commitTitleRef.current = commitTitleNow;
382  useEffect(() => {
383    // Flush a pending title when leaving the page (soft navigation unmounts
384    // without a blur) so the last-typed value still wins.
385    return () => {
386      if (titleCommitTimer.current) {
387        clearTimeout(titleCommitTimer.current);
388        titleCommitTimer.current = null;
389      }
390      const el = titleRef.current;
391      if (el && el.textContent) commitTitleRef.current();
392    };
393  }, []);
394  const [insert, setInsert] = useState<{
395    block: Block;
396    tab: "image" | "link" | "table" | "database";
397  } | null>(null);
398  const [templateOpen, setTemplateOpen] = useState(false);
399  const [commentsOpen, setCommentsOpen] = useState(false);
400  // T-140: the "Related" (backlinks) slide-over.
401  const [relatedOpen, setRelatedOpen] = useState(false);
402  const backlinks = useBacklinks(pageId);
403  const setAiPanel = useUiStore((s) => s.setAiPanel);
404  const setGenerate = useUiStore((s) => s.setGenerate);
405  const commentPill = useCommentPillStore((s) => s.byPage[pageId] ?? 0);
406  const pageRef = useRef<HTMLDivElement>(null);
407  const useCommentsOpenRef = useRef(commentsOpen);
408  useEffect(() => {
409    useCommentsOpenRef.current = commentsOpen;
410  }, [commentsOpen]);
411  const pendingFocus = useRef<string | null>(null);
412  const hasScrolledFocus = useRef(false);
413 
414  // TASK-0094 R2 — the "New comments (n)" pill: a `comment.event` frame
415  // (created / replied) for this page bumps the pill while the panel is
416  // CLOSED. The socket + the `page:<id>` channel are open (the page layout's
417  // usePageRealtime) — the same connection. Opening the panel clears the
418  // pill (the panel refetches, showing the new comments).
419  useEffect(() => {
420    const off = realtime.on("comment.event", (frame) => {
421      if (frame.type !== "comment.event" || frame.pageId !== pageId) return;
422      if (frame.kind !== "created" && frame.kind !== "replied") return;
423      if (useCommentsOpenRef.current) return; // the panel shows it live
424      useCommentPillStore.getState().add(pageId);
425    });
426    return () => off();
427  }, [pageId]);
428 
429  // Single-block selection (the store is the source of truth — set by row
430  // clicks, deep links and the AI panel). `storeSelected` is null until the
431  // first selection; the row ring + the Delete-on-Backspace key use it.
432  const selectedBlockId = storeSelected;
433 
434  // TASK-0123 — multi-block selection (the writing tools' entry point).
435  // ⌘/Ctrl+click toggles a row into the set; Shift+click selects the range
436  // from the last anchor; a plain click keeps the existing single-selection
437  // behavior (backward compatible). The set is page-scoped (cleared on page
438  // change) and drives the floating SelectionToolbar.
439  const selectedBlockIds = useEditorStore((s) => s.selectedBlockIds);
440  const toggleSelected = useEditorStore((s) => s.toggleSelected);
441  const setManySelected = useEditorStore((s) => s.setManySelected);
442  const shiftAnchorId = useRef<string | null>(null);
443  const [writing, setWriting] = useState<{
444    open: boolean;
445    action: WritingAction;
446    language?: string;
447    blockIds: string[];
448  }>({ open: false, action: "summarize", blockIds: [] });
449 
450  const selectedBlocks = useMemo(() => {
451    const byId = new Map(sorted.map((b) => [b.id, b]));
452    return selectedBlockIds.map((id) => byId.get(id)).filter((b): b is Block => !!b);
453  }, [selectedBlockIds, sorted]);
454 
455  // The dialog's blocks: resolve the requested ids against the FULL page
456  // list (not just the multi-selection — the context-menu path opens for a
457  // single block that was never added to the selection).
458  const dialogBlocks = useMemo(
459    () => sorted.filter((b) => writing.blockIds.includes(b.id)),
460    [sorted, writing.blockIds]
461  );
462 
463  const openWriting = useCallback(
464    (action: WritingAction, blockIds: string[], language?: string) => {
465      setWriting({ open: true, action, language, blockIds });
466    },
467    []
468  );
469 
470  /** Handle a row selection, honoring the modifier keys for multi-select. */
471  const selectRow = useCallback(
472    (id: string, e?: { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean }) => {
473      if (e?.metaKey || e?.ctrlKey) {
474        // toggle into the multi-selection
475        const next = selectedBlockIds.includes(id)
476          ? selectedBlockIds.filter((x) => x !== id)
477          : [...selectedBlockIds, id];
478        setManySelected(next);
479        shiftAnchorId.current = id;
480        setSelection(id);
481        return;
482      }
483      if (e?.shiftKey && shiftAnchorId.current) {
484        // range from the anchor to this row
485        const a = sorted.findIndex((b) => b.id === shiftAnchorId.current);
486        const b = sorted.findIndex((x) => x.id === id);
487        if (a !== -1 && b !== -1) {
488          const [lo, hi] = a < b ? [a, b] : [b, a];
489          setManySelected(sorted.slice(lo, hi + 1).map((x) => x.id));
490        } else {
491          setManySelected([id]);
492        }
493        setSelection(id);
494        return;
495      }
496      // plain click: the existing single-selection toggle (backward compat).
497      setSelection(id === selectedBlockId ? null : id);
498      shiftAnchorId.current = id;
499    },
500    [selectedBlockIds, selectedBlockId, sorted, setManySelected, setSelection]
501  );
502 
503  const clearSelection = useCallback(() => {
504    setManySelected([]);
505    shiftAnchorId.current = null;
506  }, [setManySelected]);
507 
508  const scrollAndFlash = useCallback((id: string) => {
509    const el = pageRef.current?.querySelector<HTMLElement>(`[data-block-id="${id}"]`);
510    if (!el) return false;
511    el.scrollIntoView({ block: "center", behavior: "smooth" });
512    el.classList.add("block-focus-flash");
513    setTimeout(() => el.classList.remove("block-focus-flash"), 1600);
514    return true;
515  }, []);
516 
517  // Deep link (route [block] segment or ?focus=): scroll + flash the target.
518  // The block may not be in the DOM yet (fresh query / just-created block),
519  // so the scroll is retried until it appears in the sorted list.
520  useEffect(() => {
521    const target = focusBlockId ?? useEditorStore.getState().focusBlockId;
522    if (!target) return;
523    if (hasScrolledFocus.current && target === pendingFocus.current) return;
524    hasScrolledFocus.current = true;
525    pendingFocus.current = target;
526    const t = setTimeout(() => {
527      if (scrollAndFlash(target)) {
528        pendingFocus.current = null;
529        setFocusTarget(target);
530        useEditorStore.getState().setFocus(null);
531      }
532    }, 80);
533    return () => clearTimeout(t);
534    // eslint-disable-next-line react-hooks/exhaustive-deps
535  }, [focusBlockId, page]);
536 
537  // Retry focus once the (re)sorted list contains the pending block.
538  useEffect(() => {
539    if (!pendingFocus.current) return;
540    if (!sorted.some((b) => b.id === pendingFocus.current)) return;
541    const t = setTimeout(() => {
542      if (scrollAndFlash(pendingFocus.current as string)) {
543        setFocusTarget(pendingFocus.current);
544        pendingFocus.current = null;
545      }
546    }, 40);
547    return () => clearTimeout(t);
548  }, [sorted, scrollAndFlash]);
549 
550  // Slash-menu command: insert a sibling block after the one the menu was
551  // opened in. Plain types go straight to the API; media/database open the
552  // insert dialog; "template" opens the template picker.
553  const onSlashPick = useCallback(
554    async (command: SlashCommand, context: { parentId: string | null; position: string }) => {
555      // The slash menu is opened INSIDE a block: resolve that block (the
556      // anchor) and hand the hook its ID — the API's resolvePosition owns the
557      // fractional position (client-side position math threw on server
558      // base-62 positions). If the anchor is gone (stale sort), fall back to
559      // an append at the end of the same parent.
560      const anchor = sorted.find(
561        (b) => b.position === context.position && b.parentId === context.parentId,
562      );
563      if (command.blockType) {
564        try {
565          const created = await insertBlock.mutateAsync({
566            parentId: context.parentId,
567            afterBlockId: anchor?.id ?? null,
568            type: command.blockType,
569          });
570          focusNewBlock(created.id);
571        } catch (e) {
572          toast("error", "Insert failed", e instanceof Error ? e.message : undefined);
573        }
574        return;
575      }
576      if (command.special === "image") {
577        // One-step image insert: open the insert dialog's Image tab (file
578        // picker → presigned upload → image block with the signed URL). The
579        // anchor is the block the menu was opened in.
580        if (anchor) setInsert({ block: anchor, tab: "image" });
581      } else if (command.special === "table") {
582        // Open the insert dialog's Table tab (rows × cols → grid pre-created).
583        // A bare table block has no columns/rows — the renderer draws nothing.
584        if (anchor) setInsert({ block: anchor, tab: "table" });
585      } else if (command.special === "database") {
586        // The dialog needs the concrete anchor block (its id/parentId).
587        if (anchor) setInsert({ block: anchor, tab: "database" });
588      } else if (command.special === "template") {
589        setTemplateOpen(true);
590      } else if (command.special === "synced") {
591        // Synced blocks reference a target page/block; MVP inserts an empty
592        // synced block the user links afterwards (targetPageId property).
593        try {
594          await insertBlock.mutateAsync({
595            parentId: context.parentId,
596            afterBlockId: anchor?.id ?? null,
597            type: "synced_block",
598            properties: { targetPageId: null, targetBlockId: null },
599          });
600        } catch (e) {
601          toast("error", "Insert failed", e instanceof Error ? e.message : undefined);
602        }
603      }
604    },
605    [insertBlock, sorted, focusNewBlock],
606  );
607 
608  const createSibling = useCallback(
609    (afterBlock: Block | null) => {
610      // Anchor by block ID (the server computes the position); null anchor →
611      // append after the last top-level sibling.
612      void insertBlock
613        .mutateAsync({
614          parentId: null,
615          afterBlockId: afterBlock?.id ?? null,
616          type: "paragraph",
617        })
618        .then((created) => focusNewBlock(created.id));
619    },
620    [insertBlock, focusNewBlock],
621  );
622 
623  /** Focus a block row's editable region (Tiptap contenteditable, the todo
624   *  checkbox, a button or a link) — the shared caret mover for
625   *  ArrowUp/Down navigation and the blank-area click (see
626   *  `onTrailingAreaClick`). */
627  const focusBlockEditable = useCallback((blockId: string) => {
628    const el = pageRef.current?.querySelector<HTMLElement>(
629      `[data-block-id="${blockId}"] [contenteditable], [data-block-id="${blockId}"] input[type="checkbox"], [data-block-id="${blockId}"] button, [data-block-id="${blockId}"] a`,
630    );
631    el?.focus();
632  }, []);
633 
634  /** TASK-0136 — the trailing blank area. Notion semantics: the area is a
635   *  "keep typing" surface, not a "add a row" button.
636   *
637   *   - The page's LAST block is an EMPTY text block (paragraph / heading /
638   *     list / quote / callout / todo — NOT the title, NOT media/structural)
639   *     → focus THAT block. This is what stops the reported pile-up:
640   *     clicking the blank area with nothing typed reuses the empty row
641   *     instead of stacking another one behind it.
642   *   - Otherwise (last block has content, is media/structural, or the page
643   *     is empty) → create + focus a fresh paragraph, exactly as before.
644   */
645  const onTrailingAreaClick = useCallback(() => {
646    const last = sortedRef.current[sortedRef.current.length - 1] ?? null;
647    if (last && isEmptyTextBlock(last)) {
648      focusBlockEditable(last.id);
649      return;
650    }
651    createSibling(last);
652  }, [createSibling, focusBlockEditable]);
653 
654  const focusSibling = useCallback(
655    (from: Block, dir: -1 | 1) => {
656      const idx = sorted.findIndex((b) => b.id === from.id);
657      const next = sorted[idx + dir];
658      if (next) {
659        focusBlockEditable(next.id);
660      } else if (dir === 1) {
661        createSibling(from);
662      }
663    },
664    [sorted, createSibling, focusBlockEditable],
665  );
666 
667  /** TASK-0136 — empty-block cleanup (QA "blank-page input fields keep
668   *  piling up"): a text block (paragraph / heading / list / quote /
669   *  callout / todo) that LOSES FOCUS while still empty (nothing was
670   *  typed) is deleted, so the input field disappears instead of leaving a
671   *  blank row behind.
672   *
673   *  Two guards, two sources of truth:
674   *   - TYPE + TITLE come from the stored block: `isAutoCleanableType`
675   *     (text types only — never media/structural) and `isTitle` (the
676   *     page-title block is protected).
677   *   - EMPTINESS comes from the editor's LIVE html (the blur argument):
678   *     the stored content lags behind the 500 ms debounce window and is
679   *     the empty `{}` default on a freshly created block, so it cannot
680   *     answer "did the user type anything and then clear it?" — the live
681   *     html can. If the live html has real content the block stays.
682   *
683   *  Deleting the last content block is fine: a page with only its title
684   *  is the intended empty state, and the trailing area stays clickable. */
685  const handleEmptyBlur = useCallback(
686    (block: Block, html: string) => {
687      if (block.properties?.isTitle === true) return;
688      if (!isAutoCleanableType(block.type)) return;
689      // Never clean up the anchor of an OPEN insert dialog (table/image/
690      // database/link): it is the dialog's insertion point (`afterBlockId`).
691      // The slash pick that opened the dialog cleared this block, so it is
692      // empty and would blur-delete the moment focus moves into the dialog —
693      // leaving the dialog's insertBlock({afterBlockId}) pointing at a
694      // deleted id (404, dialog never closes). Leave the block while it is
695      // the anchor (matches pre-TASK-0136 behavior for the dialog flow).
696      if (insert && insert.block.id === block.id) return;
697      const live = typeof html === "string" ? html : "";
698      if (live !== "" && !isContentEmpty(live)) return;
699      // The blur save (saveNow) may have raced ahead of this cleanup, or a
700      // queued 500 ms batch may still carry this block's content — drop the
701      // pending ops so the flush never `update`s an id we are deleting
702      // (the batch endpoint 400s on a missing id and aborts the whole batch).
703      getBlockBatchQueue(pageId).dropBlock(block.id);
704      deleteBlock.mutate(block.id, {
705        onError: (e: Error) => toast("error", "Delete failed", e.message),
706      });
707    },
708    [pageId, deleteBlock, insert],
709  );
710 
711  const onDragEnd = (event: DragEndEvent) => {
712    const { active, over } = event;
713    if (!over || active.id === over.id) return;
714    const oldIndex = sorted.findIndex((b) => b.id === active.id);
715    const newIndex = sorted.findIndex((b) => b.id === over.id);
716    if (oldIndex < 0 || newIndex < 0) return;
717    const moved = arrayMove(sorted, oldIndex, newIndex);
718    const block = moved[newIndex];
719    const prev = moved[newIndex - 1];
720    const next = moved[newIndex + 1];
721    if (!block) return;
722    // TASK-0135: delegate the position to the SERVER. The web's base-36 shared
723    // engine rejected the API's base-62 positions (uppercase, e.g. "Zz") and
724    // `positionForInsert` threw on every reorder of an API-created page — DnD
725    // was 100% broken. The server resolves the position from the neighbor
726    // block ids with its own canonical base-62 engine (blocks.service
727    // `resolvePosition`). Server semantics: `afterBlockId` = insert AFTER that
728    // block (it sits ABOVE the new slot), `beforeBlockId` = insert BEFORE it
729    // (it sits BELOW the new slot) — verified against the live endpoint.
730    moveBlock.mutate(
731      {
732        id: block.id,
733        parentId: null,
734        afterBlockId: prev?.id ?? null,
735        beforeBlockId: next?.id ?? null,
736      },
737      { onError: (e: Error) => toast("error", "Move failed", e.message) },
738    );
739  };
740 
741  const sensors = useSensors(
742    useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
743    // TASK-0100 S5: `sortableKeyboardCoordinates` is a keyboard
744    // coordinate-GETTER, not a sensor — passing it bare to useSensor made
745    // `Sensor.activators` undefined and crashed DndContext ("reading 'map'").
746    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
747  );
748 
749  // Delete key removes a selected block that is not currently being edited.
750  const onKeyDown = (e: React.KeyboardEvent) => {
751    if (e.key !== "Delete" || !selectedBlockId) return;
752    const target = e.target as HTMLElement;
753    if (target.isContentEditable || target.tagName === "INPUT" || target.tagName === "TEXTAREA")
754      return;
755    e.preventDefault();
756    deleteBlock.mutate(selectedBlockId, {
757      onSuccess: () => setSelection(null),
758    });
759  };
760 
761  // Numbered runs: consecutive `numbered` blocks share a 1-based counter.
762  const numberedIndices = useMemo(() => {
763    const map = new Map<string, number>();
764    let count = 0;
765    for (const b of sorted) {
766      if (b.type === "numbered_list") {
767        count += 1;
768        map.set(b.id, count);
769      } else {
770        count = 0;
771      }
772    }
773    return map;
774  }, [sorted]);
775 
776  return (
777    <div
778      ref={pageRef}
779      data-testid="editor-root"
780      data-page-view
781      className="editor-page relative mx-auto w-full max-w-3xl px-12 pb-48"
782      onClick={(e) => {
783        if (e.target === e.currentTarget) {
784          setSelection(null);
785          setManySelected([]);
786        }
787      }}
788      onKeyDown={onKeyDown}
789      onDragEnter={onDragEnter}
790      onDragOver={onDragOver}
791      onDragLeave={onDragLeave}
792      onDrop={onFilesDropped}
793      data-file-drop={fileDragActive ? "active" : undefined}
794    >
795      {/* TASK-0103 (a): the drag-drop indicator — a full-canvas overlay while
796          an OS file drag is over the page (the block list is untouched, so
797          dnd-kit's block-reorder drag is unaffected). */}
798      {fileDragActive ? (
799        <div
800          className="pointer-events-none fixed inset-0 z-40 flex items-start justify-center bg-primary/5 p-6"
801          aria-hidden
802        >
803          <div className="rounded-md border-2 border-dashed border-primary bg-background/90 px-4 py-2 text-sm font-medium text-primary shadow-sm">
804            Drop to upload
805          </div>
806        </div>
807      ) : null}
808      {/* T-163: the review workflow banner (state chip + per-role actions;
809          rendered for every state — including "No review yet" on fresh
810          pages, which is where "Request review" starts). */}
811      {page ? <ReviewBanner pageId={page.id} workspaceId={page.workspaceId} /> : null}
812      {/* Header */}
813      <header className="relative mb-4">
814        {/* Cover band (TASK-0038, T-192): the visible `page-cover` — the
815            selected gradient, above the icon/title row. */}
816        {page?.cover ? (
817          <div
818            data-testid="page-cover"
819            aria-hidden
820            className="absolute inset-x-0 top-0 -z-10 h-20 rounded-xl"
821            style={{ background: coverBackground(page.cover) }}
822          />
823        ) : null}
824        <div className="flex items-start gap-3">
825          {/* Icon (when set) + the icon-picker button; the cover-picker
826              button sits beside them. Both persist via PATCH /pages/:id
827              (`icon`, `coverUrl` — see useUpdatePage's field mapping). */}
828          <div className="flex shrink-0 items-start gap-1.5 pt-1.5">
829            {page?.icon ? (
830              <span data-testid="page-icon" className="block text-4xl leading-none" aria-hidden>
831                {page.icon}
832              </span>
833            ) : null}
834            <div className="relative">
835              <button
836                type="button"
837                data-testid="page-icon-button"
838                aria-label="Change icon"
839                title="Change icon"
840                onClick={() => setIconPickerOpen((v) => !v)}
841                className="rounded-md border border-border/60 p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
842              >
843                <Smile className="size-4" aria-hidden />
844              </button>
845              {iconPickerOpen && page ? (
846                <IconPicker
847                  onPick={(icon) => {
848                    setIconPickerOpen(false);
849                    updatePage.mutate({ id: page.id, icon });
850                  }}
851                />
852              ) : null}
853            </div>
854            <div className="relative">
855              <button
856                type="button"
857                data-testid="page-cover-picker"
858                aria-label="Change cover"
859                title="Change cover"
860                onClick={() => setCoverPickerOpen((v) => !v)}
861                className="rounded-md border border-border/60 p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
862              >
863                <ImageIcon className="size-4" aria-hidden />
864              </button>
865              {coverPickerOpen && page ? (
866                <CoverPicker
867                  onPick={(cover) => {
868                    setCoverPickerOpen(false);
869                    updatePage.mutate({ id: page.id, cover });
870                  }}
871                />
872              ) : null}
873            </div>
874          </div>
875          <div className="min-w-0 flex-1">
876            {/* `page-title` (tests/README): an editable TEXT surface — the
877                E2E asserts toContainText, so the title is text content, not
878                an input value. Commits on input (same as the old input's
879                onChange) + on blur; Enter keeps the caret (no form submit). */}
880            <div
881              ref={titleRef}
882              contentEditable
883              suppressContentEditableWarning
884              role="textbox"
885              aria-label="Page title"
886              data-testid="page-title"
887              // The id the title currently commits to (page?.id) — lets tests
888              // gate on "the child page is really loaded" (the persistent
889              // contenteditable lags a client-side route change; a text-based
890              // gate is unreliable, T-192 nested E2E).
891              data-page-id={page?.id}
892              onInput={() => {
893                // Debounced commit (see titleCommitTimer): schedule a flush
894                // 600ms after the last keystroke; blur/Enter/Escape flush
895                // immediately. Never fires N concurrent PATCHes per title.
896                if (titleCommitTimer.current) clearTimeout(titleCommitTimer.current);
897                titleCommitTimer.current = setTimeout(() => commitTitleNow(), 600);
898              }}
899              onKeyDown={(e) => {
900                if (e.key === "Enter") {
901                  // Single-line title (the old <input> never took a newline).
902                  // Flush the pending commit BEFORE blur (blur fires second
903                  // and finds nothing pending).
904                  commitTitleNow();
905                  e.preventDefault();
906                  (e.target as HTMLDivElement).blur();
907                } else if (e.key === "Escape") {
908                  // Flush too — the E2E convention commits on Escape (and a
909                  // user pressing Escape has finished typing the title).
910                  commitTitleNow();
911                  (e.target as HTMLDivElement).blur();
912                }
913              }}
914              onBlur={() => {
915                commitTitleNow();
916              }}
917              className="w-full rounded-sm bg-transparent px-1 text-4xl font-bold tracking-tight outline-none empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground"
918              data-placeholder="Untitled"
919            />
920            <div className="mt-1.5 flex items-center gap-2 px-1 text-xs text-muted-foreground">
921              {page ? (
922                <>
923                  <span className="inline-flex items-center gap-1">
924                    <Clock className="size-3" aria-hidden />
925                    Updated {formatRelativeTime(page.updatedAt)}
926                  </span>
927                  {page.createdByUser ? <span>· by {page.createdByUser.name}</span> : null}
928                </>
929              ) : null}
930            </div>
931          </div>
932          {/* Presence (TASK-0093): the OTHERS' avatar bar — absent at 0
933              others (the empty state). The live region (below) is the SR
934              channel; the bar is a plain list with per-avatar names. */}
935          <PresenceBar
936            peers={others}
937            selfUserId={selfUserId}
938            blockTypeFor={blockTypeFor}
939            className="mt-2 shrink-0"
940          />
941          {/* TASK-0094 R2 — the comment button + the realtime "New comments
942              (n)" pill (bumped by comment.event frames while the panel is
943              closed; cleared when it opens). */}
944          <button
945            type="button"
946            data-testid="comment-button"
947            className="relative mt-2 inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-foreground/80 hover:bg-accent hover:text-foreground"
948            onClick={() => setCommentsOpen(true)}
949            aria-label={
950              commentPill > 0
951                ? `Open comments (${commentPill} new)`
952                : "Open comments"
953            }
954          >
955            <MessageSquare className="size-3.5" aria-hidden />
956            Comments
957            {commentPill > 0 ? (
958              <span
959                className="inline-flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-4 text-primary-foreground"
960                aria-hidden
961              >
962                {commentPill > 99 ? "99+" : commentPill}
963              </span>
964            ) : null}
965          </button>
966          {/* T-140 — Related: the pages that reference this one (backlinks).
967              Mirrors the Comments button; opens a read-only slide-over. */}
968          <button
969            type="button"
970            data-testid="related-button"
971            className="relative mt-2 inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-foreground/80 hover:bg-accent hover:text-foreground"
972            onClick={() => setRelatedOpen(true)}
973            aria-label="Open related pages"
974          >
975            <Link2 className="size-3.5" aria-hidden />
976            Related
977            {(backlinks.data?.length ?? 0) > 0 ? (
978              <span
979                className="inline-flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-4 text-primary-foreground"
980                aria-hidden
981              >
982                {backlinks.data!.length > 99 ? "99+" : backlinks.data!.length}
983              </span>
984            ) : null}
985          </button>
986          {/* TASK-0103 (4): the page file picker — insert an existing file as
987              a block at the cursor (sidebar "Files" for the active page). */}
988          <button
989            type="button"
990            data-testid="files-picker-toggle"
991            onClick={() => setFilesPickerOpen((v) => !v)}
992            aria-expanded={filesPickerOpen}
993            className="relative mt-2 inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-foreground/80 hover:bg-accent hover:text-foreground"
994          >
995            <FolderOpen className="size-3.5" aria-hidden />
996            Files
997          </button>
998          {/* TASK-0122 — Ask AI: opens the panel bound to this page (and the
999              selected block, when one is highlighted). */}
1000          <button
1001            type="button"
1002            data-testid="ask-ai-button"
1003            onClick={() =>
1004              setAiPanel(true, {
1005                pageId,
1006                ...(selectedBlockId ? { blockId: selectedBlockId } : {}),
1007                ...(page?.title?.trim() ? { label: page.title } : {})
1008              })
1009            }
1010            className="relative mt-2 inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-foreground/80 hover:bg-accent hover:text-foreground"
1011            aria-label="Ask AI about this page"
1012          >
1013            <Sparkles className="size-3.5" aria-hidden />
1014            Ask AI
1015          </button>
1016          {/* TASK-0126 — Generate: opens the page-generation dialog (nested
1017              under this page when one is open). Hidden for GUEST (EDIT gate). */}
1018          {canGenerate ? (
1019            <button
1020              type="button"
1021              data-testid="generate-page-button"
1022              onClick={() => setGenerate(true, { parentId: pageId })}
1023              className="relative mt-2 inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-foreground/80 hover:bg-accent hover:text-foreground"
1024              aria-label="Generate a page with AI"
1025            >
1026              <Sparkles className="size-3.5" aria-hidden />
1027              Generate
1028            </button>
1029          ) : null}
1030        </div>
1031      </header>
1032 
1033      {filesPickerOpen && page?.workspaceId ? (
1034        <FilePicker
1035          workspaceId={page.workspaceId}
1036          pageId={pageId}
1037          onClose={() => setFilesPickerOpen(false)}
1038        />
1039      ) : null}
1040 
1041      {/* Presence (TASK-0093 R5): the polite live region — SR announcements
1042          for join/edit-start/edit-stop/leave, ≤ 1/s, state changes only.
1043          Visually hidden (the avatars + toasts are the visual channel). */}
1044      <p role="status" aria-live="polite" className="sr-only">
1045        {announcement}
1046      </p>
1047 
1048      {/* Blocks */}
1049      <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
1050        <SortableContext items={renderBlocks.map((b) => b.id)} strategy={verticalListSortingStrategy}>
1051          <div className="relative">
1052            {renderBlocks.map((block) => (
1053              <SortableBlockRow
1054                key={block.id}
1055                block={block}
1056                selected={selectedBlockId === block.id}
1057                multiSelected={selectedBlockIds.includes(block.id)}
1058                focusTarget={focusTarget}
1059                numberedIndex={numberedIndices.get(block.id)}
1060                onSelect={(id, mods) => selectRow(id, mods)}
1061                onContextMenu={(x, y, b) => setMenu({ x, y, block: b })}
1062                onWritingAction={(action, blockIds, language) => openWriting(action, blockIds, language)}
1063                onEnterKey={createSibling}
1064                onNavigate={(b, dir) => focusSibling(b, dir)}
1065                onSlashPick={(cmd, ctx) => void onSlashPick(cmd, ctx)}
1066                onEmptyBlur={(b, html) => handleEmptyBlur(b, html)}
1067              />
1068            ))}
1069            {/* Presence (TASK-0093 R4): the remote cursors + name labels +
1070                selection highlights, overlaid on the block list (the layer
1071                is an absolute, pointer-events-none, decorative overlay). */}
1072            <CursorLayer peers={others} blockIds={blockIds} />
1073          </div>
1074        </SortableContext>
1075      </DndContext>
1076 
1077      {/* TASK-0126 — empty-state "Describe a page…" card (the Home page with
1078          no blocks; the workspace root redirects here on a fresh workspace).
1079          Opens the page-generation dialog. Hidden for GUEST (EDIT gate). */}
1080      {contentBlockCount === 0 && canGenerate ? (
1081        <button
1082          type="button"
1083          data-testid="home-generate-card"
1084          onClick={() => setGenerate(true, { parentId: pageId })}
1085          className="group mb-4 flex w-full max-w-xl items-center gap-3 rounded-lg border border-dashed border-border bg-background/60 px-4 py-3 text-left hover:border-primary/50 hover:bg-accent/40"
1086        >
1087          <Sparkles className="size-4 shrink-0 text-muted-foreground group-hover:text-primary" aria-hidden />
1088          <span className="min-w-0 flex-1">
1089            <span className="block text-sm font-medium">Describe a page…</span>
1090            <span className="block text-xs text-muted-foreground">
1091              e.g. "A product launch plan with objectives, a timeline, and a tasks database"
1092            </span>
1093          </span>
1094        </button>
1095      ) : null}
1096 
1097      {/* "Add a sub-page" (tests/README.md `subpage-create`, T-192): the
1098          in-page way to nest a child page (the sidebar tree row has the
1099          same action on hover). Reuses the sidebar's `factory:new-page`
1100          event — the handler creates the child + navigates to it, and its
1101          invalidation refreshes the sidebar tree (listener stays mounted
1102          even while the sidebar is collapsed). Hidden for GUEST (no EDIT). */}
1103      {canGenerate ? (
1104        <button
1105          type="button"
1106          data-testid="subpage-create"
1107          onClick={() =>
1108            window.dispatchEvent(
1109              new CustomEvent("factory:new-page", { detail: { parentId: pageId } })
1110            )
1111          }
1112          className="mb-2 flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
1113        >
1114          <FolderOpen className="size-3.5" aria-hidden />
1115          Add a sub-page
1116        </button>
1117      ) : null}
1118 
1119      {/* Trailing empty area — TASK-0136: focus the last block when it is
1120          already an empty text block (reuse, no pile-up), otherwise create
1121          + focus a new paragraph (see onTrailingAreaClick). */}
1122      <div
1123        data-testid="new-block"
1124        className="min-h-[16vh] cursor-text"
1125        onClick={onTrailingAreaClick}
1126        aria-hidden
1127      />
1128 
1129      {/* Right-click context menu */}
1130      {menu ? (
1131        <ContextMenu
1132          x={menu.x}
1133          y={menu.y}
1134          block={menu.block}
1135          pageId={pageId}
1136          onClose={() => setMenu(null)}
1137          onWritingAction={page?.workspaceId ? (action, blockIds, language) => openWriting(action, blockIds, language) : undefined}
1138          canGenerate={canGenerate}
1139        />
1140      ) : null}
1141 
1142      {/* TASK-0123 — the floating multi-selection toolbar (the writing
1143          tools' entry point) + the suggestion dialog (accept-diff). */}
1144      {selectedBlocks.length > 0 && page?.workspaceId ? (
1145        <SelectionToolbar
1146          pageId={pageId}
1147          blocks={selectedBlocks}
1148          onClear={clearSelection}
1149          onAction={(action, language) => openWriting(action, selectedBlocks.map((b) => b.id), language)}
1150        />
1151      ) : null}
1152      {writing.open && page?.workspaceId ? (
1153        <WritingDialog
1154          open
1155          workspaceId={page.workspaceId}
1156          pageId={pageId}
1157          blocks={dialogBlocks}
1158          action={writing.action}
1159          language={writing.language}
1160          onClose={() => setWriting((w) => ({ ...w, open: false }))}
1161        />
1162      ) : null}
1163 
1164      {/* TASK-0094 R2 — the comment drawer (the Phase-3 panel, now live). */}
1165      <CommentPanel
1166        pageId={pageId}
1167        workspaceId={page?.workspaceId ?? ""}
1168        open={commentsOpen}
1169        onClose={() => setCommentsOpen(false)}
1170      />
1171 
1172      {/* T-140 — the "Related" (backlinks) slide-over. The query lives in
1173          useBacklinks (30 s staleTime); the panel is a pure presenter. */}
1174      <RelatedPanel
1175        pageId={pageId}
1176        workspaceSlug={wsParams?.workspace ?? ""}
1177        open={relatedOpen}
1178        onClose={() => setRelatedOpen(false)}
1179        refs={backlinks.data ?? []}
1180        isLoading={backlinks.isLoading}
1181        isError={backlinks.isError}
1182      />
1183 
1184      {/* Slash-command insert flows */}
1185      {insert ? (
1186        <InsertDialog
1187          open
1188          initialTab={insert.tab}
1189          afterBlockId={insert.block.id}
1190          parentId={insert.block.parentId}
1191          pageId={pageId}
1192          workspaceId={page?.workspaceId ?? ""}
1193          onClose={() => setInsert(null)}
1194        />
1195      ) : null}
1196      {templateOpen ? (
1197        <TemplateDialog
1198          workspaceId={page?.workspaceId ?? ""}
1199          onClose={() => setTemplateOpen(false)}
1200          onApply={async (templateId, title) => {
1201            // Apply duplicates the template into this workspace; then focus
1202            // the new page's first block for immediate editing.
1203            const created = await templateApi.apply(templateId, { workspaceId: page?.workspaceId ?? "", parentId: null });
1204            setTemplateOpen(false);
1205            useEditorStore.getState().setSelection(null);
1206            toast("success", "Template inserted", title);
1207            void created;
1208          }}
1209        />
1210      ) : null}
1211    </div>
1212  );
1213}
1214 
1215/** dnd-kit sortable wrapper: selection ring, hover toolbar, the block body. */
1216function SortableBlockRow({
1217  block,
1218  selected,
1219  multiSelected,
1220  focusTarget,
1221  numberedIndex,
1222  onSelect,
1223  onContextMenu,
1224  onEnterKey,
1225  onNavigate,
1226  onSlashPick,
1227  onWritingAction,
1228  onEmptyBlur,
1229}: {
1230  block: Block;
1231  selected: boolean;
1232  /** TASK-0123 — part of the multi-selection (a distinct ring, so a ⌘+click
1233   *  selection is visible even when another row is the single highlight). */
1234  multiSelected?: boolean;
1235  focusTarget: string | null;
1236  numberedIndex?: number;
1237  /** The row's selection click, carrying the modifier keys (multi-select). */
1238  onSelect: (id: string, mods: { metaKey: boolean; ctrlKey: boolean; shiftKey: boolean }) => void;
1239  onContextMenu: (x: number, y: number, block: Block) => void;
1240  onEnterKey: (block: Block) => void;
1241  onNavigate: (block: Block, dir: -1 | 1) => void;
1242  onSlashPick: (
1243    command: SlashCommand,
1244    context: { parentId: string | null; position: string },
1245  ) => void;
1246  onWritingAction?: (action: WritingAction, blockIds: string[], language?: string) => void;
1247  /** TASK-0136 — empty-block cleanup: called with the block's FINAL html on
1248   *  blur so the page can delete it if it is still empty (nothing typed). */
1249  onEmptyBlur?: (block: Block, html: string) => void;
1250}) {
1251  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
1252    id: block.id,
1253    data: { type: "block", blockId: block.id },
1254  });
1255  // TASK-0123: capture the modifier keys at mousedown (the inner block's
1256  // `onSelect` fires without an event, so the row must remember the press).
1257  const modsRef = useRef({ metaKey: false, ctrlKey: false, shiftKey: false });
1258 
1259  return (
1260    <div
1261      ref={setNodeRef}
1262      data-block-id={block.id}
1263      data-testid={`block-${block.id}`}
1264      data-multi-selected={multiSelected || undefined}
1265      style={{ transform: CSS.Transform.toString(transform), transition }}
1266      // TASK-0135: the `group` marker must live on the ROW (the TASK-0100 S5
1267      // note below the .block-row CSS says the marker moved to the JSX "at the
1268      // usage sites" — nested rows in block-renderer.tsx carry `group block-row`,
1269      // but this top-level row lost it). Without it the left gutter's
1270      // `group-hover:opacity-100` + `group-hover:pointer-events-auto` never
1271      // activate, so the grip drag handle stayed opacity-0 AND
1272      // pointer-events-none forever — dnd-kit reordering was silently dead
1273      // (verified: elementFromPoint at the grip's center = #workspace-main).
1274      className={cn("group block-row", (selected || multiSelected) && "is-selected", multiSelected && "is-multi-selected", isDragging && "z-10 opacity-90")}
1275      onMouseDownCapture={(e) => {
1276        modsRef.current = { metaKey: e.metaKey, ctrlKey: e.ctrlKey, shiftKey: e.shiftKey };
1277      }}
1278      onClick={(e) => {
1279        if (e.target === e.currentTarget) onSelect(block.id, modsRef.current);
1280      }}
1281      onContextMenu={(e) => {
1282        e.preventDefault();
1283        onContextMenu(e.clientX, e.clientY, block);
1284      }}
1285    >
1286      {/* Block toolbar gutter. Desktop (≥768px): the LEFT gutter, revealed
1287          on row hover (the `group-hover` utilities). Mobile (<768px): the
1288          RIGHT gutter, ALWAYS visible (docs/product/ux-requirements.md —
1289          "single-column everything; editor: full-width blocks, ⋮⋮ always
1290          visible in the right gutter"; the hover state does not exist for
1291          touch). The element is a single toolbar repositioned per breakpoint
1292          (one `data-testid="drag-handle"`, so the e2e helper's `.first()` is
1293          stable on both). The turn-into/more actions are additionally
1294          reachable on mobile via the block context menu.
1295          `z-30` = the "block toolbar" level of the ux z-index tokens
1296          (content 0 → block toolbar 30): on mobile the toolbar overlaps the
1297          row's own content, and the row content comes later in the DOM, so
1298          without the z-level the paragraph would paint over the grip and
1299          intercept pointer events at the handle. */}
1300      <div className="absolute right-0 top-1 z-30 flex items-center opacity-100 transition-opacity md:left-0 md:right-auto md:-translate-x-full md:pointer-events-none md:opacity-0 md:group-hover:pointer-events-auto md:group-hover:opacity-100">
1301        <BlockToolbar
1302          block={block}
1303          gripProps={{ attributes, listeners }}
1304          onWritingAction={(action, blockIds, language) => onWritingAction?.(action, blockIds, language)}
1305        />
1306      </div>
1307      <BlockRenderer
1308        block={block}
1309        selected={selected || multiSelected}
1310        requestFocus={focusTarget === block.id}
1311        numberedIndex={numberedIndex}
1312        onEnterKey={() => onEnterKey(block)}
1313        onNavigate={(dir) => onNavigate(block, dir)}
1314        onSelect={() => onSelect(block.id, modsRef.current)}
1315        onSlashPick={onSlashPick}
1316        // TASK-0136 — forward the empty-block cleanup down to the renderer;
1317        // without this the row's `onEmptyBlur` prop is dropped and an empty
1318        // text block that loses focus is never deleted (dead feature).
1319        onEmptyBlur={onEmptyBlur}
1320      />
1321    </div>
1322  );
1323}
1324 
1325/* ------------------------------------------------------------------------
1326 * TASK-0038 icon + cover pickers (T-192) — the minimal emoji / solid-
1327 * gradient pickers the E2E contract (`icon-picker`, `cover-picker`,
1328 * `cover-color-{i}`, `page-icon`, `page-cover`) requires. Both write back
1329 * through `updatePage.mutate` (icon / cover → PATCH /pages/:id coverUrl).
1330 * ---------------------------------------------------------------------- */
1331 
1332/** Cover tokens (T-192). The cover is persisted as a STRING that the API's
1333 *  `UpdatePageDto.coverUrl` validates with Zod `.url().max(2048)` — a raw CSS
1334 *  gradient string is NOT a URL and 400s on PATCH (recorded conflict, T-192).
1335 *  So each token is a data-URI SVG (Zod accepts `data:` URIs) built from the
1336 *  same color stops, which renders identically in the picker swatch and the
1337 *  `page-cover` band (exact round-trip, no mapping drift). `btoa` is used
1338 *  (browser-safe; the SVG is pure ASCII) rather than Node's `Buffer`. */
1339const COVER_STOPS: [string, string][] = [
1340  ["#6366f1", "#8b5cf6"], // 0 indigo
1341  ["#f43f5e", "#f97316"], // 1 rose→orange
1342  ["#10b981", "#14b8a6"], // 2 emerald
1343  ["#0ea5e9", "#6366f1"], // 3 sky→indigo
1344  ["#f59e0b", "#f43f5e"], // 4 amber→rose
1345  ["#1e293b", "#475569"]  // 5 slate
1346];
1347 
1348const coverToken = ([from, to]: [string, string]): string => {
1349  const svg =
1350    `<svg xmlns="http://www.w3.org/2000/svg" width="160" height="90">` +
1351    `<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
1352    `<stop offset="0" stop-color="${from}"/><stop offset="1" stop-color="${to}"/>` +
1353    `</linearGradient></defs><rect width="160" height="90" fill="url(#g)"/></svg>`;
1354  return `data:image/svg+xml;base64,${btoa(svg)}`;
1355};
1356 
1357const COVER_TOKENS: string[] = COVER_STOPS.map(coverToken);
1358 
1359function coverBackground(cover: string): string {
1360  // A stored token (our own data-URI SVG) renders as a background image;
1361  // anything else (a future real image URL / free text) degrades to a neutral
1362  // block so the `page-cover` band is still visible + styled.
1363  return COVER_TOKENS.includes(cover)
1364    ? `url("${cover}") center / cover no-repeat`
1365    : "linear-gradient(135deg, #64748b, #334155)";
1366}
1367 
1368const ICON_EMOJI: string[] = [
1369  "🚀", "📄", "📌", "⭐", "💡", "🎯", "🔥", "✅",
1370  "📊", "🗓️", "🔒", "🌱", "☁️", "🧠", "🛠️", "🎉"
1371];
1372 
1373/** Emoji picker popover (anchored to the icon button). */
1374function IconPicker({ onPick }: { onPick: (icon: string) => void }) {
1375  return (
1376    <div
1377      data-testid="icon-picker"
1378      role="menu"
1379      aria-label="Choose icon"
1380      className="absolute left-0 top-8 z-[75] grid w-56 grid-cols-8 gap-1 rounded-md border border-border bg-popover p-2 shadow-xl"
1381    >
1382      {ICON_EMOJI.map((e) => (
1383        <button
1384          key={e}
1385          type="button"
1386          role="menuitem"
1387          onClick={() => onPick(e)}
1388          className="rounded p-1 text-lg hover:bg-accent"
1389        >
1390          {e}
1391        </button>
1392      ))}
1393    </div>
1394  );
1395}
1396 
1397/** Solid-gradient cover picker popover (anchored to the cover button). */
1398function CoverPicker({ onPick }: { onPick: (cover: string) => void }) {
1399  return (
1400    <div
1401      data-testid="cover-picker"
1402      role="menu"
1403      aria-label="Choose cover"
1404      className="absolute left-0 top-8 z-[75] grid w-56 grid-cols-3 gap-1.5 rounded-md border border-border bg-popover p-2 shadow-xl"
1405    >
1406      {COVER_TOKENS.map((g, i) => (
1407        <button
1408          key={g}
1409          type="button"
1410          role="menuitem"
1411          data-testid={`cover-color-${i}`}
1412          aria-label={`Cover color ${i + 1}`}
1413          onClick={() => onPick(g)}
1414          className="h-10 w-full rounded-md border border-border/40"
1415          style={{ background: coverBackground(g) }}
1416        />
1417      ))}
1418    </div>
1419  );
1420}
1421