.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
block-renderer.tsxapps/web/src/components/editor/block-renderer.tsxraw← editor47,909 B← Back to the app
1"use client";
2 
3/**
4 * BlockRenderer — switch on block.type and render the block body.
5 *
6 * Text-ish types are edited through Tiptap (InlineEditor); keyboard
7 * navigation (Enter → sibling, arrows → neighbor, Cmd+Z → Tiptap history)
8 * is wired through the editor's handleKeyDown. Structural types read
9 * `block.properties`. Database blocks delegate to the parallel-agent
10 * <DatabaseView embedded/>; nested types (toggle/columns/synced) load
11 * their children lazily via usePageBlocks.
12 *
13 * Persistence: every change is queued for the debounced batch flush
14 * (POST /blocks/batch, 500 ms) and re-saved individually on blur as a
15 * fallback so a closed tab never loses the last edit.
16 */
17import { useCallback, useEffect, useRef, useState } from "react";
18import Link from "next/link";
19import { useParams } from "next/navigation";
20import { useQuery } from "@tanstack/react-query";
21import {
22  Bookmark,
23  Check,
24  ChevronRight,
25  Copy,
26  ExternalLink,
27  FileImage,
28  Film,
29  Calculator,
30  FileText,
31  Heading1,
32  Heading2,
33  Heading3,
34  Music,
35  Pencil,
36  Table as TableIcon,
37  Type,
38  Undo2,
39  Upload
40} from "lucide-react";
41import { usePage, usePageBlocks } from "@/hooks/use-pages";
42import { useInsertBlock, useUpdateBlock } from "@/hooks/use-blocks";
43import { blockApi, uploadFile } from "@/lib/api";
44import { cn, sortByPosition } from "@/lib/utils";
45import { DatabaseView } from "@/components/database/db-embedded";
46import { FileBlock } from "@/components/files/FileBlock";
47import { FormRenderer } from "@/components/form/form-renderer";
48import { InlineEditor } from "./inline-editor";
49import { type SlashCommand } from "./slash-menu";
50import { useBlockSaveQueue } from "./block-save-queue";
51import { blockText } from "./version-text";
52import { Expandable } from "./expandable";
53import { CODE_LANGUAGES, codeToHtml } from "@/lib/code-highlight";
54import type { Block } from "@/lib/types";
55 
56/* ---------------------------------------------------------------- types */
57 
58export interface BlockRendererProps {
59  block: Block;
60  selected?: boolean;
61  /** Focus this block's editor (deep link / Enter-created block). */
62  requestFocus?: boolean;
63  /** 1-based index within the current numbered run (block.type=numbered). */
64  numberedIndex?: number;
65  onEnterKey?: () => void;
66  onNavigate?: (dir: -1 | 1) => void;
67  onSelect?: () => void;
68  /**
69   * Slash-menu handler (inherited from the page): insert the picked command
70   * as a sibling after the block the menu was opened in.
71   */
72  onSlashPick?: (command: SlashCommand, context: { parentId: string | null; position: string }) => void;
73  /**
74   * TASK-0136 — empty-block cleanup (inherited from the page): called with
75   * the block's FINAL HTML on blur so the page can delete the block if it is
76   * a text type that lost focus while still empty (nothing typed). Only the
77   * inline text blocks forward this; the page's `handleEmptyBlur` is the
78   * single owner of the guard + the delete mutation.
79   */
80  onEmptyBlur?: (block: Block, html: string) => void;
81}
82 
83/** Slash-menu bridge props shared by all text blocks. */
84function slashProps(block: Block, onSlashPick?: BlockRendererProps["onSlashPick"]) {
85  return { slashContext: { parentId: block.parentId, position: block.position }, onSlashPick };
86}
87 
88/**
89 * TASK-0136 — the shared inline-editor `onBlur`: persist the final content
90 * (the existing blur-save fallback) AND report the final HTML up to the
91 * page, which deletes the block if it is a text type that lost focus while
92 * still empty. Both fire in the same blur — order doesn't matter because the
93 * page's `handleEmptyBlur` re-checks emptiness against the FINAL html and
94 * drops this block's pending queue ops before it deletes.
95 */
96function onBlurHandler(
97  block: Block,
98  saveNow: (blockId: string, content: unknown) => void | Promise<void>,
99  onEmptyBlur?: (block: Block, html: string) => void,
100) {
101  return (blockId: string, content: unknown) => {
102    void saveNow(blockId, content);
103    onEmptyBlur?.(block, typeof content === "string" ? content : "");
104  };
105}
106 
107export function BlockRenderer({
108  block,
109  selected,
110  requestFocus,
111  numberedIndex,
112  onEnterKey,
113  onNavigate,
114  onSelect,
115  onSlashPick,
116  onEmptyBlur
117}: BlockRendererProps) {
118  switch (block.type) {
119    case "heading_1":
120    case "heading_2":
121    case "heading_3":
122      return (
123        <HeadingBlock
124          block={block}
125          level={Number(block.type.split("_")[1]) as 1 | 2 | 3}
126          selected={selected}
127          requestFocus={requestFocus}
128          onEnterKey={onEnterKey}
129          onNavigate={onNavigate}
130          onSelect={onSelect}
131          onSlashPick={onSlashPick}
132          onEmptyBlur={onEmptyBlur}
133        />
134      );
135    case "todo":
136      return (
137        <TodoBlock
138          block={block}
139          selected={selected}
140          requestFocus={requestFocus}
141          onEnterKey={onEnterKey}
142          onNavigate={onNavigate}
143          onSelect={onSelect}
144          onSlashPick={onSlashPick}
145          onEmptyBlur={onEmptyBlur}
146        />
147      );
148    case "bulleted_list":
149      return (
150        <div className="flex gap-2">
151          <span className="mt-[0.6em] size-1.5 shrink-0 rounded-full bg-foreground" aria-hidden />
152          <InlineTextBlock
153            block={block}
154            selected={selected}
155            requestFocus={requestFocus}
156            onEnterKey={onEnterKey}
157            onNavigate={onNavigate}
158            onSelect={onSelect}
159            onSlashPick={onSlashPick}
160            onEmptyBlur={onEmptyBlur}
161            className="flex-1"
162          />
163        </div>
164      );
165    case "numbered_list":
166      return (
167        <div className="flex gap-2">
168          <span className="mt-[0.1em] w-5 shrink-0 text-right text-sm tabular-nums text-foreground" aria-hidden>
169            {numberedIndex ?? 1}.
170          </span>
171          <InlineTextBlock
172            block={block}
173            selected={selected}
174            requestFocus={requestFocus}
175            onEnterKey={onEnterKey}
176            onNavigate={onNavigate}
177            onSelect={onSelect}
178            onSlashPick={onSlashPick}
179            onEmptyBlur={onEmptyBlur}
180            className="flex-1"
181          />
182        </div>
183      );
184    case "quote":
185      return (
186        <div className="border-l-[3px] border-foreground pl-4">
187          <InlineTextBlock
188            block={block}
189            selected={selected}
190            requestFocus={requestFocus}
191            onEnterKey={onEnterKey}
192            onNavigate={onNavigate}
193            onSelect={onSelect}
194            onSlashPick={onSlashPick}
195            onEmptyBlur={onEmptyBlur}
196            className="text-[15px]"
197          />
198        </div>
199      );
200    case "callout":
201      return <CalloutBlock block={block} selected={selected} onEnterKey={onEnterKey} onNavigate={onNavigate} onSelect={onSelect} onSlashPick={onSlashPick} onEmptyBlur={onEmptyBlur} />;
202    case "code":
203      return <CodeBlock block={block} />;
204    case "divider":
205      return (
206        <div className="py-2">
207          <hr className="border-border" />
208        </div>
209      );
210    case "image":
211      return <ImageBlock block={block} />;
212    case "video":
213      return <VideoBlock block={block} />;
214    case "audio":
215      return <AudioBlock block={block} />;
216    case "file":
217      return <FileBlock block={block} />;
218    case "bookmark":
219      return <BookmarkBlock block={block} />;
220    case "embed":
221      return <EmbedBlock block={block} />;
222    case "equation":
223      return <EquationBlock block={block} />;
224    case "toggle":
225      // Enter on a toggle creates a CHILD (Notion semantics) — the toggle
226      // handles its own Enter; the page-level sibling handler is not wired.
227      return <ToggleBlock block={block} selected={selected} requestFocus={requestFocus} onNavigate={onNavigate} onSelect={onSelect} onSlashPick={onSlashPick} />;
228    case "spacer":
229      // 8–200 px per docs/api/blocks.md §3 (clamped).
230      return <div style={{ height: Math.min(Math.max(block.properties.heightPx ?? 32, 8), 200) }} aria-hidden />;
231    case "column_list":
232      return <ColumnsBlock block={block} />;
233    case "table":
234      return <TableBlock block={block} />;
235    case "chart":
236      return <ChartBlock block={block} />;
237    case "form":
238      return <FormBlock block={block} />;
239    case "database":
240    case "linked_database":
241    case "child_database":
242      return <DatabaseBlock block={block} />;
243    case "synced_block":
244      return <SyncedBlock block={block} />;
245    case "toc":
246      // T-147: pure marker block — the renderer derives entries from the
247      // page's headings (component below uses its own page-blocks query).
248      return <TableOfContentsBlock block={block} />;
249    case "child_page":
250      return <ChildPageBlock block={block} />;
251    case "column":
252      // Rendered inside its column_list parent; never top-level.
253      return null;
254    default:
255      return (
256        // Long pasted paragraphs are capped vertically + expandable. The cap
257        // is NOT lifted while the block is selected/focused — otherwise a
258        // just-pasted long block (which is the selected one) would still
259        // stretch the page: the exact complaint. ProseMirror keeps the live
260        // caret scrolled into view INSIDE the cap (the cap is a scrollable
261        // ancestor), so typing at the end of a long block is never clipped;
262        // the user can always press Expand for the full-height view.
263        <Expandable cap={TEXT_CAP} label="text block">
264          <InlineTextBlock
265            block={block}
266            selected={selected}
267            requestFocus={requestFocus}
268            onEnterKey={onEnterKey}
269            onNavigate={onNavigate}
270            onSelect={onSelect}
271            onSlashPick={onSlashPick}
272            onEmptyBlur={onEmptyBlur}
273          />
274        </Expandable>
275      );
276  }
277}
278 
279/* ------------------------------------------------------ text-ish blocks */
280 
281function InlineTextBlock({
282  block,
283  selected,
284  requestFocus,
285  onEnterKey,
286  onNavigate,
287  onSelect,
288  onSlashPick,
289  onEmptyBlur,
290  className
291}: BlockRendererProps & { className?: string }) {
292  const { queueChange, saveNow } = useBlockSaveQueue(block.pageId);
293  return (
294    <div className={cn("min-w-0", className)}>
295      <InlineEditor
296        blockId={block.id}
297        content={block.content}
298        placeholder="Type '/' for commands"
299        selected={selected}
300        requestFocus={requestFocus}
301        onChange={queueChange}
302        onBlur={onBlurHandler(block, saveNow, onEmptyBlur)}
303        onFocusBlock={() => onSelect?.()}
304        onEnterKey={onEnterKey}
305        onNavigate={onNavigate}
306        {...slashProps(block, onSlashPick)}
307      />
308    </div>
309  );
310}
311 
312function HeadingBlock({
313  block,
314  level,
315  selected,
316  requestFocus,
317  onEnterKey,
318  onNavigate,
319  onSelect,
320  onSlashPick,
321  onEmptyBlur
322}: BlockRendererProps & { level: 1 | 2 | 3 }) {
323  const Icon = level === 1 ? Heading1 : level === 2 ? Heading2 : Heading3;
324  const sizes = { 1: "text-3xl font-bold", 2: "text-2xl font-semibold", 3: "text-xl font-semibold" } as const;
325  const { queueChange, saveNow } = useBlockSaveQueue(block.pageId);
326  return (
327    <div className="flex items-center gap-2">
328      <span className="flex shrink-0 select-none items-center gap-1.5 opacity-0 transition-opacity group-hover:opacity-100" aria-hidden>
329        <Icon className="size-4 text-muted-foreground" />
330        <span className="text-xs text-muted-foreground">H{level}</span>
331      </span>
332      <div className="min-w-0 flex-1">
333        <InlineEditor
334          blockId={block.id}
335          content={block.content}
336          placeholder={`Heading ${level}`}
337          selected={selected}
338          requestFocus={requestFocus}
339          onChange={queueChange}
340          onBlur={onBlurHandler(block, saveNow, onEmptyBlur)}
341          onFocusBlock={() => onSelect?.()}
342          onEnterKey={onEnterKey}
343          onNavigate={onNavigate}
344          {...slashProps(block, onSlashPick)}
345          className={cn(sizes[level])}
346        />
347      </div>
348    </div>
349  );
350}
351 
352function TodoBlock({
353  block,
354  selected,
355  requestFocus,
356  onEnterKey,
357  onNavigate,
358  onSelect,
359  onSlashPick,
360  onEmptyBlur
361}: BlockRendererProps) {
362  const checked = Boolean(block.properties.checked);
363  const { queueChange, saveNow } = useBlockSaveQueue(block.pageId);
364  const toggle = () => {
365    void blockApi.update(block.id, { properties: { ...block.properties, checked: !checked } });
366  };
367  return (
368    <div className="flex items-start gap-2">
369      <button
370        type="button"
371        role="checkbox"
372        aria-checked={checked}
373        aria-label={checked ? "Mark as not done" : "Mark as done"}
374        data-testid={`todo-checkbox-${block.id}`}
375        onClick={toggle}
376        className={cn(
377          "mt-[0.2em] flex size-4 shrink-0 items-center justify-center rounded-sm border",
378          checked
379            ? "border-primary bg-primary text-primary-foreground"
380            : "border-muted-foreground/50 hover:border-foreground"
381        )}
382      >
383        {checked ? <Check className="size-3" aria-hidden /> : null}
384      </button>
385      <div className={cn("min-w-0 flex-1", checked && "text-muted-foreground line-through")}>
386        <InlineEditor
387          blockId={block.id}
388          content={block.content}
389          placeholder="To-do"
390          selected={selected}
391          requestFocus={requestFocus}
392          onChange={queueChange}
393          onBlur={onBlurHandler(block, saveNow, onEmptyBlur)}
394          onFocusBlock={() => onSelect?.()}
395          onEnterKey={onEnterKey}
396          onNavigate={onNavigate}
397          {...slashProps(block, onSlashPick)}
398        />
399      </div>
400    </div>
401  );
402}
403 
404/** T-148 — callout visual variants. `variant` is persisted in properties
405 * (additive; legacy callouts without it render as "info"). */
406const CALLOUT_VARIANTS = {
407  info: { label: "Info", border: "border-sky-500/40", bg: "bg-sky-500/10", icon: "ℹ️" },
408  warning: { label: "Warning", border: "border-amber-500/40", bg: "bg-amber-500/10", icon: "⚠️" },
409  tip: { label: "Tip", border: "border-emerald-500/40", bg: "bg-emerald-500/10", icon: "💡" },
410  error: { label: "Error", border: "border-red-500/40", bg: "bg-red-500/10", icon: "⛔" }
411} as const;
412type CalloutVariantKey = keyof typeof CALLOUT_VARIANTS;
413 
414function CalloutBlock({
415  block,
416  selected,
417  onEnterKey,
418  onNavigate,
419  onSelect,
420  onSlashPick,
421  onEmptyBlur
422}: BlockRendererProps) {
423  const { icon, color, variant } = block.properties;
424  const isVariant = (x: unknown): x is CalloutVariantKey =>
425    typeof x === "string" && Object.hasOwn(CALLOUT_VARIANTS, x);
426  const v = CALLOUT_VARIANTS[isVariant(variant) ? variant : "info"];
427  const { queueChange, saveNow } = useBlockSaveQueue(block.pageId);
428  return (
429    <div
430      className={cn("relative flex gap-3 rounded-md border p-3", color ? "border-border" : cn(v.border, v.bg))}
431      style={
432        color
433          ? {
434              background: `var(--factory-color-${color}_bg, transparent)`,
435              borderColor: `var(--factory-color-${color}, transparent)`
436            }
437          : undefined
438      }
439    >
440      {selected && (
441        <select
442          value={(variant as string) ?? "info"}
443          onChange={(e) => void blockApi.update(block.id, { properties: { ...block.properties, variant: e.target.value } })}
444          aria-label="Callout variant"
445          className="absolute -top-2.5 right-2 z-10 rounded border border-border bg-background px-1 py-0 text-xs shadow-sm"
446        >
447          {(Object.keys(CALLOUT_VARIANTS) as CalloutVariantKey[]).map((k) => (
448            <option key={k} value={k}>
449              {CALLOUT_VARIANTS[k].label}
450            </option>
451          ))}
452        </select>
453      )}
454      <span className="text-lg leading-none" aria-hidden>
455        {icon ?? v.icon}
456      </span>
457      <div className="min-w-0 flex-1">
458        <InlineEditor
459          blockId={block.id}
460          content={block.content}
461          placeholder="Callout"
462          selected={selected}
463          onChange={queueChange}
464          onBlur={onBlurHandler(block, saveNow, onEmptyBlur)}
465          onFocusBlock={() => onSelect?.()}
466          onEnterKey={onEnterKey}
467          onNavigate={onNavigate}
468          {...slashProps(block, onSlashPick)}
469        />
470      </div>
471    </div>
472  );
473}
474 
475/* --------------------------------------------------------- media blocks */
476 
477/**
478 * Extract the editable plain text from a code block's stored content.
479 * Content is either a raw string (web edits), the API/AI canonical
480 * `{ text }` envelope (slash-pick inserts and the AI apply path), or
481 * anything else (fallback to the JSON rendering).
482 */
483export function codeBlockText(content: unknown): string {
484  if (typeof content === "string") return content;
485  if (content && typeof content === "object") {
486    const t = (content as { text?: unknown }).text;
487    if (typeof t === "string") return t;
488  }
489  return content == null ? "" : JSON.stringify(content, null, 2);
490}
491 
492/**
493 * Long code is capped VERTICALLY and made expandable (user request: a long
494 * pasted code block shouldn't stretch the page to full height). The cap is a
495 * multiple of the code line height so the cutoff lands cleanly on a line
496 * boundary. `leading-6` = 24px; `p-3` = 12px top+bottom padding.
497 */
498const CODE_LINE_HEIGHT = 24;
499const CODE_CAP_LINES = 15;
500const CODE_CAP = CODE_CAP_LINES * CODE_LINE_HEIGHT + 24; // + one line's padding
501 
502/**
503 * Long PASTED TEXT is capped vertically + expandable too (same request). Body
504 * copy is 14.5px at the inherited line-height 1.5 (Tailwind preflight) →
505 * ≈21.75px/line; the cap is ~16 lines so a normal paragraph is never touched.
506 */
507const TEXT_CAP_LINES = 16;
508const TEXT_CAP = Math.round(TEXT_CAP_LINES * 14.5 * 1.5); // ≈348px
509 
510export function CodeBlock({ block }: { block: Block }) {
511  const { queueChange, saveNow } = useBlockSaveQueue(block.pageId);
512  const [copied, setCopied] = useState(false);
513  const [editing, setEditing] = useState(false);
514  const [text, setText] = useState<string>(() => codeBlockText(block.content));
515  const textareaRef = useRef<HTMLTextAreaElement>(null);
516  const overlayRef = useRef<HTMLPreElement>(null);
517  const language = block.properties.language ?? "plaintext";
518 
519  // The edit-mode textarea is an invisible-text overlay on top of a live
520  // highlighted <pre>, so typing/pasting is already colored. Identical font,
521  // padding, wrapping and line height are what keep the two layers aligned.
522  const codeMetrics =
523    "min-h-[1.5em] bg-[var(--factory-code-bg)] p-3 font-mono leading-6 [font-size:var(--factory-code-font-size)]";
524 
525  // Auto-size the edit overlay to its content (grows with the code being
526  // typed). Wrapping is on, so `scrollHeight` already counts wrapped lines.
527  useEffect(() => {
528    if (!editing) return;
529    const el = textareaRef.current;
530    if (!el) return;
531    el.style.height = "0px";
532    el.style.height = `${el.scrollHeight}px`;
533  }, [editing, text]);
534 
535  // Keep the highlighted overlay scrolled in lockstep with the textarea
536  // (vertical and horizontal) while editing.
537  useEffect(() => {
538    if (!editing) return;
539    const ta = textareaRef.current;
540    const pre = overlayRef.current;
541    if (!ta || !pre) return;
542    const sync = () => {
543      pre.scrollTop = ta.scrollTop;
544      pre.scrollLeft = ta.scrollLeft;
545    };
546    ta.addEventListener("scroll", sync);
547    return () => ta.removeEventListener("scroll", sync);
548  }, [editing]);
549 
550  const startEdit = () => {
551    setText(codeBlockText(block.content));
552    setEditing(true);
553  };
554 
555  const commit = () => {
556    if (!editing) return;
557    setEditing(false);
558    void saveNow(block.id, text);
559  };
560 
561  const current = editing ? text : codeBlockText(block.content);
562 
563  const copy = () => {
564    void navigator.clipboard.writeText(current).then(() => {
565      setCopied(true);
566      setTimeout(() => setCopied(false), 1500);
567    });
568  };
569 
570  return (
571    <div className="factory-codeblock group/code my-1 overflow-hidden rounded-md border border-border">
572      <div className="flex items-center justify-between bg-muted/60 px-3 py-1.5">
573        <select
574          value={language}
575          disabled={editing}
576          onChange={(e) => void blockApi.update(block.id, { properties: { ...block.properties, language: e.target.value } })}
577          aria-label="Code language"
578          className="bg-transparent text-xs text-muted-foreground outline-none"
579        >
580          {CODE_LANGUAGES.map((l) => (
581            <option key={l} value={l}>
582              {l}
583            </option>
584          ))}
585        </select>
586        <div className="flex items-center gap-1">
587          <button
588            type="button"
589            onClick={copy}
590            className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
591            aria-label="Copy code"
592          >
593            {copied ? <Check className="size-3" aria-hidden /> : <Copy className="size-3" aria-hidden />}
594            {copied ? "Copied" : "Copy"}
595          </button>
596          <button
597            type="button"
598            onClick={() => (editing ? commit() : startEdit())}
599            className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
600            aria-label={editing ? "Save code" : "Edit code"}
601          >
602            <Pencil className="size-3" aria-hidden />
603            {editing ? "Save" : "Edit"}
604          </button>
605        </div>
606      </div>
607      {editing ? (
608        // The textarea holds the text but paints it transparent; the
609        // highlighted <pre> behind it (identical font metrics + padding)
610        // provides the colors, so typing/paste is already syntax-highlighted.
611        <div className="relative">
612          <pre
613            ref={overlayRef}
614            aria-hidden
615            className={cn(codeMetrics, "pointer-events-none overflow-hidden whitespace-pre")}
616          >
617            <code dangerouslySetInnerHTML={{ __html: codeToHtml(text, language) }} />
618          </pre>
619          <textarea
620            ref={textareaRef}
621            value={text}
622            spellCheck={false}
623            autoFocus
624            onChange={(e) => {
625              setText(e.target.value);
626              queueChange(block.id, e.target.value);
627            }}
628            onBlur={commit}
629            onKeyDown={(e) => {
630              if (e.key === "Escape") {
631                e.stopPropagation();
632                commit();
633              }
634            }}
635            className={cn(
636              codeMetrics,
637              "absolute inset-0 block w-full resize-none overflow-x-auto overflow-y-auto whitespace-pre bg-transparent text-transparent caret-[var(--factory-code-fg)] outline-none"
638            )}
639            aria-label="Code"
640          />
641        </div>
642      ) : (
643        // Long code is capped vertically + expandable (see CODE_CAP above).
644        // Edit mode below stays FULL height: the edit overlay's own
645        // scroll-sync (textarea ↔ highlighted <pre>) already handles long
646        // code, and a capped editor would clip the caret.
647        <Expandable cap={CODE_CAP} label="code block" lines={current.split("\n").length}>
648          <pre
649            // T-194: E2E contract id — the rendered (non-edit-mode) code.
650            data-testid="code-block-pre"
651            className={cn(codeMetrics, "cursor-text overflow-x-auto whitespace-pre")}
652            onClick={startEdit}
653            title="Click to edit"
654          >
655            <code dangerouslySetInnerHTML={{ __html: codeToHtml(current, language) }} />
656          </pre>
657        </Expandable>
658      )}
659    </div>
660  );
661}
662 
663function ImageBlock({ block }: { block: Block }) {
664  const { src, caption } = block.properties;
665  if (!src) return <ImagePickerBlock block={block} />;
666  return (
667    <figure>
668      {/* eslint-disable-next-line @next/next/no-img-element */}
669      <img src={src} alt={caption || "Image block"} className="max-w-full rounded-md" loading="lazy" />
670      {caption ? <figcaption className="mt-1 text-center text-xs text-muted-foreground">{caption}</figcaption> : null}
671    </figure>
672  );
673}
674 
675/**
676 * An image block with no `src` yet — the "Insert image" entry point
677 * (turn-into / legacy blocks; the slash command opens the insert dialog
678 * instead). Mirrors FilePickerBlock: file picker → presigned upload
679 * (lib/api uploadFile) → patch the block's properties.src with the signed
680 * URL. The upload stores the file on the page (pageId anchor) so it also
681 * appears in Files → page scope.
682 */
683function ImagePickerBlock({ block }: { block: Block }) {
684  const inputRef = useRef<HTMLInputElement>(null);
685  const [uploading, setUploading] = useState(false);
686  const [error, setError] = useState<string | null>(null);
687  const { data: page } = usePage(block.pageId);
688  const workspaceId = page?.workspaceId ?? null;
689  const { updateBlock } = useUpdateBlock(block.pageId);
690 
691  const onFiles = async (list: FileList | null) => {
692    const file = list?.[0];
693    if (!file) return;
694    setUploading(true);
695    setError(null);
696    try {
697      const meta = await uploadFile(file, undefined, { workspaceId: workspaceId ?? undefined, pageId: block.pageId });
698      if (meta.status !== "ready" || !meta.url) {
699        setError("Upload was blocked (virus scan or processing) — the file is in Files.");
700        return;
701      }
702      await updateBlock(block.id, { properties: { ...block.properties, src: meta.url, fileId: meta.id, name: meta.name } });
703    } catch (e) {
704      setError(e instanceof Error ? e.message : "Upload failed");
705    } finally {
706      setUploading(false);
707    }
708  };
709 
710  return (
711    <div className="flex items-center gap-3 rounded-md border border-dashed border-border p-3">
712      <span className="flex size-9 shrink-0 items-center justify-center rounded bg-muted">
713        <FileImage className="size-4 text-muted-foreground" />
714      </span>
715      <span className="min-w-0 flex-1 text-sm text-muted-foreground">
716        {uploading ? "Uploading…" : error ? error : "No image yet — choose a file to attach"}
717      </span>
718      <button
719        type="button"
720        disabled={uploading}
721        onClick={() => inputRef.current?.click()}
722        className="flex shrink-0 items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent disabled:opacity-50"
723      >
724        <Upload className="size-3.5" aria-hidden /> {uploading ? "Uploading…" : "Insert image"}
725      </button>
726      <input
727        ref={inputRef}
728        data-testid="image-input"
729        type="file"
730        accept="image/*"
731        className="sr-only"
732        aria-label="Choose an image to attach"
733        onChange={(e) => void onFiles(e.target.files)}
734      />
735    </div>
736  );
737}
738 
739function EmbedBlock({ block }: { block: Block }) {
740  const { src, caption } = block.properties;
741  if (!src) return <EmptyMedia icon={<Film className="size-4" aria-hidden />} label="No embed URL" />;
742  return (
743    <div className="overflow-hidden rounded-md border border-border">
744      <iframe
745        src={src}
746        title={caption || "Embedded content"}
747        className="h-72 w-full bg-background"
748        sandbox="allow-scripts allow-same-origin"
749        loading="lazy"
750      />
751    </div>
752  );
753}
754 
755function BookmarkBlock({ block }: { block: Block }) {
756  const { src, title, description } = block.properties;
757  const host = safeHost(src);
758  return (
759    <a
760      href={src}
761      target="_blank"
762      rel="noopener noreferrer"
763      className="group/bm flex gap-3 rounded-md border border-border p-3 transition-colors hover:bg-accent/50"
764    >
765      <span className="flex size-9 shrink-0 items-center justify-center rounded bg-muted">
766        <Bookmark className="size-4 text-muted-foreground" aria-hidden />
767      </span>
768      <span className="min-w-0 flex-1">
769        <span className="flex items-center gap-1.5 text-sm font-medium group-hover/bm:underline">
770          {title ?? host ?? "Bookmark"}
771          <ExternalLink className="size-3 text-muted-foreground" aria-hidden />
772        </span>
773        <span className="block truncate text-xs text-muted-foreground">{host}</span>
774        {description ? (
775          <span className="mt-0.5 line-clamp-2 block text-xs text-muted-foreground/80">{description}</span>
776        ) : null}
777      </span>
778    </a>
779  );
780}
781 
782function ToggleBlock({ block, selected, requestFocus, onNavigate, onSelect, onSlashPick }: BlockRendererProps) {
783  // Notion behavior: a fresh toggle starts EXPANDED; the `collapsed`
784  // property (set by the chevron) is remembered across reloads.
785  const [open, setOpen] = useState(() => block.properties?.collapsed !== true);
786  const [focusChildId, setFocusChildId] = useState<string | null>(null);
787  const children = usePageBlocks(block.pageId, block.id);
788  const { queueChange, saveNow } = useBlockSaveQueue(block.pageId);
789  const { updateBlock } = useUpdateBlock(block.pageId);
790  const insertBlock = useInsertBlock(block.pageId);
791  const childBlocks = children.blocks ?? [];
792 
793  /** Chevron click: flip the local state AND persist `collapsed` so the
794   *  state survives a reload (update() replaces the whole properties bag). */
795  const toggleCollapse = useCallback(() => {
796    setOpen((v) => {
797      const next = !v;
798      void updateBlock(block.id, {
799        properties: { ...(block.properties as Record<string, unknown>), collapsed: !next }
800      });
801      return next;
802    });
803  }, [updateBlock, block.id, block.properties]);
804 
805  /** Enter (title or a child) / empty-state click → a CHILD paragraph of
806   *  this toggle (Notion semantics: content goes INSIDE the toggle). The
807   *  old code called the page-level sibling handler, so Enter on the title
808   *  escaped to a top-level block and the toggle stayed empty. */
809  const createChild = useCallback(
810    (after: Block | null) => {
811      void insertBlock
812        .mutateAsync({ parentId: block.id, afterBlockId: after?.id ?? null, type: "paragraph" })
813        .then((created) => {
814          setOpen(true); // the new child must be visible
815          setFocusChildId(created.id);
816        });
817    },
818    [insertBlock, block.id]
819  );
820 
821  const focusChildSibling = useCallback(
822    (from: Block, dir: -1 | 1) => {
823      const idx = childBlocks.findIndex((b) => b.id === from.id);
824      const next = childBlocks[idx + dir];
825      if (next) {
826        const el = document.querySelector<HTMLElement>(
827          `[data-block-id="${next.id}"] [contenteditable], [data-block-id="${next.id}"] input[type="checkbox"]`
828        );
829        el?.focus();
830      } else if (dir === 1) {
831        createChild(from);
832      }
833    },
834    [childBlocks, createChild]
835  );
836 
837  return (
838    <div>
839      {/* Controlled expand/collapse: the NATIVE <details> toggle is
840          disabled (summary click preventDefault'd) so the ONLY way the
841          state changes is the chevron button — deterministic, and clicks
842          in the title editor never collapse the toggle. (Relying on the
843          native toggle event + a state flip made expand/collapse
844          unreliable; the QA report's "toggle doesn't work".) */}
845      <details open={open}>
846        <summary
847          className="flex cursor-default list-none items-center gap-1.5 [&::-webkit-details-marker]:hidden"
848          onClick={(e) => e.preventDefault()}
849        >
850          <button
851            type="button"
852            aria-expanded={open}
853            aria-label={open ? "Collapse toggle" : "Expand toggle"}
854            data-testid={`toggle-chevron-${block.id}`}
855            className="flex size-5 shrink-0 items-center justify-center rounded hover:bg-accent"
856            onMouseDown={(e) => e.preventDefault()} // keep the editor caret
857            onClick={toggleCollapse}
858          >
859            <ChevronRight
860              className={cn("size-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-90")}
861              aria-hidden
862            />
863          </button>
864          <span className="min-w-0 flex-1">
865            <InlineEditor
866              blockId={block.id}
867              content={block.content}
868              placeholder="Toggle"
869              selected={selected}
870              requestFocus={requestFocus}
871              onChange={queueChange}
872              onBlur={saveNow}
873              onFocusBlock={() => onSelect?.()}
874              onEnterKey={() => createChild(null)}
875              onNavigate={onNavigate}
876              {...slashProps(block, onSlashPick)}
877              className="text-sm font-medium"
878            />
879          </span>
880        </summary>
881        <div className="mt-1 pl-5">
882          {childBlocks.length === 0 ? (
883            <button
884              type="button"
885              className="rounded px-1 text-sm text-muted-foreground hover:bg-accent"
886              onClick={() => createChild(null)}
887            >
888              Add content
889            </button>
890          ) : (
891            childBlocks.map((child) => (
892              <div key={child.id} data-block-id={child.id} className="group block-row">
893                <BlockRenderer
894                  block={child}
895                  requestFocus={focusChildId === child.id}
896                  onEnterKey={() => createChild(child)}
897                  onNavigate={(dir) => focusChildSibling(child, dir)}
898                  onSlashPick={onSlashPick}
899                />
900              </div>
901            ))
902          )}
903        </div>
904      </details>
905    </div>
906  );
907}
908 
909function TableBlock({ block }: { block: Block }) {
910  const cols = block.properties.columns ?? [];
911  const rows = block.properties.rows ?? [];
912  const { saveNow, invalidate } = useBlockSaveQueue(block.pageId);
913 
914  // Table grid state lives in `properties` (rows/columns), NOT content — so
915  // every op below patches properties (the API merges the whole object) and
916  // invalidates the block cache (a bare blockApi.update leaves the row/column
917  // count stale on screen until the next refetch; only the old cell onBlur
918  // path refetched, via saveNow).
919  const patch = (properties: Record<string, unknown>) => {
920    void blockApi.update(block.id, { properties: { ...block.properties, ...properties } }).then(
921      () => invalidate(block.pageId),
922    );
923  };
924  const setCell = (r: number, c: number, value: string) => {
925    const next = rows.map((row, ri) => (ri === r ? row.map((cell, ci) => (ci === c ? value : cell)) : row));
926    patch({ rows: next });
927  };
928  const addRow = () => patch({ rows: [...rows, Array(cols.length).fill("")] });
929  const addCol = () =>
930    patch({
931      columns: [
932        ...cols,
933        { id: `col${cols.length + 1}`, type: "text", width: 160, label: `Column ${cols.length + 1}` }
934      ],
935      rows: rows.map((r) => [...r, ""])
936    });
937  // T-146: row/column removal (hover-revealed ×). Minimum one column always —
938  // same invariant as table-ops.ts in @factory/editor.
939  const removeRow = (ri: number) => {
940    patch({ rows: rows.filter((_r, i) => i !== ri) });
941  };
942  const removeCol = (ci: number) => {
943    if (cols.length <= 1) return; // keep at least one column
944    patch({
945      columns: cols.filter((_c, i) => i !== ci),
946      rows: rows.map((r) => r.filter((_cell, i) => i !== ci))
947    });
948  };
949 
950  return (
951    <div className="group/table overflow-x-auto rounded-md border border-border">
952      <table className="w-full border-collapse text-sm">
953        <thead>
954          <tr>
955            {cols.map((col, ci) => (
956              <th key={col.id} className="group/col border-b border-border bg-muted/40 px-3 py-2 text-left font-medium">
957                {col.label ?? col.id}
958                {cols.length > 1 && (
959                  <button
960                    type="button"
961                    onClick={() => removeCol(ci)}
962                    className="ml-2 hidden rounded p-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground group-hover/col:inline"
963                    aria-label={`Remove column ${col.label ?? col.id}`}
964                  >
965                    ×
966                  </button>
967                )}
968              </th>
969            ))}
970            <th className="w-8 border-b border-border" aria-label="Table actions">
971              <button
972                type="button"
973                onClick={addCol}
974                className="rounded p-1 text-muted-foreground hover:bg-accent"
975                aria-label="Add column"
976              >
977                +
978              </button>
979            </th>
980          </tr>
981        </thead>
982        <tbody>
983          {rows.map((row, ri) => (
984            <tr key={ri} className="group/row">
985              {row.map((cell, ci) => (
986                <td key={ci} className="border-b border-border px-1 py-0.5">
987                  <input
988                    value={cell}
989                    onChange={(e) => setCell(ri, ci, e.target.value)}
990                    onBlur={() => saveNow(block.id, block.content)}
991                    className="w-full bg-transparent px-2 py-1.5 outline-none focus:bg-accent/40"
992                    aria-label={`Row ${ri + 1}, column ${ci + 1}`}
993                  />
994                </td>
995              ))}
996              <td className="w-6 border-b border-border">
997                <button
998                  type="button"
999                  onClick={() => removeRow(ri)}
1000                  className="hidden rounded p-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground group-hover/row:inline"
1001                  aria-label={`Remove row ${ri + 1}`}
1002                >
1003                  ×
1004                </button>
1005              </td>
1006            </tr>
1007          ))}
1008        </tbody>
1009      </table>
1010      <div className="flex justify-end border-t border-border bg-muted/20 px-2 py-1">
1011        <button
1012          type="button"
1013          onClick={addRow}
1014          className="rounded px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
1015        >
1016          + Add row
1017        </button>
1018      </div>
1019    </div>
1020  );
1021}
1022 
1023function ChildPageBlock({ block }: { block: Block }) {
1024  const pageId = block.properties.pageId;
1025  const title = (block.properties.title as string | undefined) ?? "Page";
1026  return (
1027    <Link
1028      href={pageId ? `/w/default/page/${pageId}` : "#"}
1029      className="flex items-center gap-2 rounded-md border border-border bg-card p-2.5 transition-colors hover:bg-accent/50"
1030    >
1031      <Type className="size-4 shrink-0 text-blue-600" aria-hidden />
1032      <span className="min-w-0 flex-1">
1033        <span className="block truncate text-sm font-medium">{title}</span>
1034        <span className="block text-xs text-muted-foreground">Link to page</span>
1035      </span>
1036      <ExternalLink className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
1037    </Link>
1038  );
1039}
1040 
1041/**
1042 * Database block — delegates to the database views (components/database/
1043 * db-embedded → DatabaseView, the named export the core editor relies on).
1044 */
1045function DatabaseBlock({ block }: { block: Block }) {
1046  const databaseId = block.properties.databaseId;
1047  if (!databaseId) return <EmptyMedia icon={<TableIcon className="size-4" aria-hidden />} label="Database not linked" />;
1048  return <DatabaseView databaseId={databaseId} embedded />;
1049}
1050 
1051/** Video block — media files land in Phase 9 (files); placeholder until then. */
1052function VideoBlock({ block }: { block: Block }) {
1053  const { src, caption } = block.properties;
1054  if (!src) return <EmptyMedia icon={<Film className="size-4" aria-hidden />} label="No video — media uploads land in Phase 9" />;
1055  return (
1056    <figure>
1057      <video src={src} controls className="max-w-full rounded-md" />
1058      {caption ? <figcaption className="mt-1 text-center text-xs text-muted-foreground">{caption}</figcaption> : null}
1059    </figure>
1060  );
1061}
1062 
1063/** Audio block — media files land in Phase 9 (files); placeholder until then. */
1064function AudioBlock({ block }: { block: Block }) {
1065  const { src, caption } = block.properties;
1066  if (!src) return <EmptyMedia icon={<Music className="size-4" aria-hidden />} label="No audio — media uploads land in Phase 9" />;
1067  return (
1068    <figure>
1069      <audio src={src} controls className="w-full" />
1070      {caption ? <figcaption className="mt-1 text-center text-xs text-muted-foreground">{caption}</figcaption> : null}
1071    </figure>
1072  );
1073}
1074 
1075/** Equation block — raw LaTeX (KaTeX rendering is a later task). */
1076function EquationBlock({ block }: { block: Block }) {
1077  const latex = typeof block.content === "string" ? block.content : String((block.properties as { latex?: string }).latex ?? "");
1078  if (!latex) return <EmptyMedia icon={<Calculator className="size-4" aria-hidden />} label="Empty equation" />;
1079  return <pre className="overflow-x-auto rounded-md border border-border bg-muted/30 p-3 font-mono text-sm">{latex}</pre>;
1080}
1081 
1082/**
1083 * Chart block — the block carries a chartId; live chart rendering over the
1084 * linked database is a later task, so this renders a labelled placeholder.
1085 */
1086function ChartBlock({ block }: { block: Block }) {
1087  const chartId = block.properties.chartId;
1088  if (!chartId) return <EmptyMedia icon={<TableIcon className="size-4" aria-hidden />} label="Chart not linked" />;
1089  return (
1090    <div className="flex h-40 items-center justify-center rounded-md border border-dashed border-border bg-muted/20 text-xs text-muted-foreground">
1091      Chart {chartId} — rendering lands in a later task
1092    </div>
1093  );
1094}
1095 
1096/** Form block — read-only render of the published form (submission via /f/:slug). */
1097function FormBlock({ block }: { block: Block }) {
1098  const formId = block.properties.formId;
1099  if (!formId) return <EmptyMedia icon={<FileText className="size-4" aria-hidden />} label="Form not linked" />;
1100  return (
1101    <div className="my-2">
1102      <FormRenderer formId={formId} />
1103    </div>
1104  );
1105}
1106 
1107/** Synced block — renders the target page's blocks read-only. */
1108function SyncedBlock({ block }: { block: Block }) {
1109  const targetPageId = block.properties.targetPageId;
1110  const targetBlockId = block.properties.targetBlockId;
1111  const query = useQuery({
1112    queryKey: ["pages", targetPageId ?? "", "synced", targetBlockId ?? ""],
1113    queryFn: async () => {
1114      if (!targetPageId) return [] as Block[];
1115      const all = await blockApi.list(targetPageId, {});
1116      return targetBlockId ? all.filter((b) => b.id === targetBlockId) : all;
1117    },
1118    enabled: Boolean(targetPageId)
1119  });
1120  return (
1121    <div className="rounded-md border border-dashed border-border bg-muted/20 p-2">
1122      <p className="mb-1 flex items-center gap-1 text-xs text-muted-foreground">
1123        <Undo2 className="size-3" aria-hidden />
1124        Synced block
1125      </p>
1126      {query.isLoading ? (
1127        <p className="text-sm text-muted-foreground">Loading…</p>
1128      ) : (
1129        sortByPosition(query.data ?? []).map((b) => <BlockRenderer key={b.id} block={b} />)
1130      )}
1131    </div>
1132  );
1133}
1134 
1135function ColumnsBlock({ block }: { block: Block }) {
1136  const children = usePageBlocks(block.pageId, block.id);
1137  const columns = sortByPosition(children.data ?? []).filter((c) => c.type === "column");
1138  return (
1139    <div data-testid="column-list" className="flex flex-col gap-2 md:flex-row">
1140      {columns.length === 0 ? (
1141        <p className="w-full text-sm text-muted-foreground">Empty columns block — add content inside.</p>
1142      ) : (
1143        columns.map((col, i) => (
1144          // T-194: E2E contract ids — the columns container + each column cell.
1145          <div key={col.id} data-testid={`column-${i + 1}`} className="min-w-0 flex-1 rounded-md border border-border p-2">
1146            <ColumnChildren blockId={col.id} pageId={col.pageId} />
1147          </div>
1148        ))
1149      )}
1150    </div>
1151  );
1152}
1153 
1154function ColumnChildren({ blockId, pageId }: { blockId: string; pageId: string }) {
1155  const children = usePageBlocks(pageId, blockId);
1156  return (
1157    <div>
1158      {sortByPosition(children.data ?? []).map((c) => (
1159        <div key={c.id} className="group block-row">
1160          <BlockRenderer block={c} />
1161        </div>
1162      ))}
1163    </div>
1164  );
1165}
1166 
1167/**
1168 * T-147 — Table of contents block. A pure marker: entries are derived from
1169 * the page's top-level headings (levels 1–3). Links deep-link to the block
1170 * route (`/w/<ws>/page/<id>/<blockId>`) and smooth-scroll; Copy link copies
1171 * the full URL. The slug generator lives in @factory/editor (toc.ts) so the
1172 * shared core, the renderer and the export stay consistent.
1173 */
1174function TableOfContentsBlock({ block }: { block: Block }) {
1175  const params = useParams<{ workspace: string }>();
1176  const ws = params?.workspace ?? "default";
1177  const headings = useTocHeadings(block.pageId);
1178  const [copied, setCopied] = useState(false);
1179  const copyLink = (e: React.MouseEvent, blockId: string) => {
1180    e.preventDefault();
1181    e.stopPropagation();
1182    const url = `${window.location.origin}/w/${ws}/page/${block.pageId}/${blockId}`;
1183    void navigator.clipboard?.writeText(url);
1184    setCopied(true);
1185    window.setTimeout(() => setCopied(false), 1500);
1186  };
1187  return (
1188    <nav aria-label="Table of contents" className="rounded-md border border-border bg-muted/20 p-3">
1189      <p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
1190        Table of contents
1191      </p>
1192      {headings.length === 0 ? (
1193        <p className="text-sm text-muted-foreground">No headings yet. Add H1–H3 blocks to populate this list.</p>
1194      ) : (
1195        <ul className="space-y-0.5">
1196          {headings.map((h) => {
1197            const href = `/w/${ws}/page/${block.pageId}/${h.id}`;
1198            return (
1199              <li key={h.id} className="group/toc flex items-center gap-1">
1200                <a
1201                  href={href}
1202                  onClick={(e) => {
1203                    e.preventDefault();
1204                    document.querySelector(`[data-block-id="${h.id}"]`)?.scrollIntoView({ block: "start", behavior: "smooth" });
1205                    history.pushState(null, "", href);
1206                  }}
1207                  className={cn(
1208                    "min-w-0 flex-1 truncate rounded px-1.5 py-0.5 text-sm hover:bg-accent",
1209                    h.level === 2 && "pl-4",
1210                    h.level === 3 && "pl-8 text-muted-foreground"
1211                  )}
1212                >
1213                  {h.title}
1214                </a>
1215                <button
1216                  type="button"
1217                  onClick={(e) => copyLink(e, h.id)}
1218                  className="hidden shrink-0 rounded px-1 text-xs text-muted-foreground hover:bg-accent group-hover/toc:block"
1219                  aria-label={`Copy link to ${h.title}`}
1220                >
1221                  {copied ? "✓" : "⧉"}
1222                </button>
1223              </li>
1224            );
1225          })}
1226        </ul>
1227      )}
1228    </nav>
1229  );
1230}
1231 
1232function useTocHeadings(pageId: string) {
1233  const { data } = usePageBlocks(pageId, null);
1234  return (data ?? [])
1235    .filter((b) => b.type === "heading_1" || b.type === "heading_2" || b.type === "heading_3")
1236    .map((b) => ({
1237      id: b.id,
1238      level: Number(b.type.slice(-1)),
1239      title: extractTitle(b) || "Untitled"
1240    }));
1241}
1242 
1243/* ----------------------------------------------------------------- misc */
1244 
1245function EmptyMedia({ icon, label }: { icon: React.ReactNode; label: string }) {
1246  return (
1247    <div className="flex h-24 items-center justify-center gap-2 rounded-md border border-dashed border-border text-sm text-muted-foreground">
1248      {icon}
1249      {label}
1250    </div>
1251  );
1252}
1253 
1254function safeHost(url?: string): string | null {
1255  if (!url) return null;
1256  try {
1257    return new URL(url).hostname;
1258  } catch {
1259    return null;
1260  }
1261}
1262 
1263function extractTitle(block: Block): string {
1264  // Reuse blockText (version-text): it handles every stored content shape —
1265  // HTML string (legacy), the API canonical { text }/{ title }, and Tiptap
1266  // docs. The old version handled ONLY the HTML string, so a heading saved in
1267  // the canonical { text } shape (every API/AI-authored + most web blocks)
1268  // rendered in the TOC as "Untitled".
1269  return blockText(block.content).slice(0, 80);
1270}
1271