Phase 4 — Block Editor — Task Specifications (TASK-0047 … TASK-0062)
Purpose. This file is the task spec sheet for Phase 4 of ROADMAP.md. It turns
apps/web + packages/editor into the full block editor specified by
PRODUCT-SPEC.md §4–§8 (block architecture, editor requirements, slash commands,
context menus, drag & drop), docs/product/functional-requirements.md §4, and
docs/product/ux-requirements.md §2 and §10. It builds on Phase 3 (page engine:
GET/POST/PATCH /v1/pages, block tree in blocks, fractional positions) and the
existing BlockModule batch endpoint (POST /v1/blocks, docs/api/blocks.md).
Tasks are strictly sequential; each follows the 14-section format in AGENTS.md.
Phase exit criteria (verbatim from ROADMAP.md, Phase 4):
- [ ] Block types: paragraph, headings, lists, todo, quote, callout, code, image, file, video, divider, toggle, columns
- [ ] Drag & drop (nested, cross-page, multi-block, indicators, auto-scroll)
- [ ] Slash command menu (searchable, dynamic database creation)
- [ ] Right-click context menus (block + page)
- [ ] Full keyboard shortcut set
- [ ] Undo/redo, copy/paste (internal + external markdown/HTML)
- [ ] Fractional positioning engine (
packages/editorpositions module) - [ ] Exit: all Phase-4 interactions E2E-tested; reorder latency < 16 ms locally; ordering survives reload; WAI-ARIA paths for all mouse operations
Task table
| ID | Title | Estimate |
|---|---|---|
| TASK-0047 | Tiptap core: FactoryEditor, block tree ↔ ProseMirror codec, autosave | L |
| TASK-0048 | Text blocks: paragraph, headings 1–3, quote, callout | M |
| TASK-0049 | List blocks: bulleted, numbered, todo, toggle (+ indent/outdent) | M |
| TASK-0050 | Code, divider, spacer blocks | M |
| TASK-0051 | Media blocks: image, video, audio, file card (+ presigned upload) | L |
| TASK-0052 | Columns: column_list + column with resize and cross-column drag |
L |
| TASK-0053 | Bookmark + embed blocks (sandboxed iframes) | M |
| TASK-0054 | Slash command menu (searchable, categories, /database wiring) |
L |
| TASK-0055 | Block right-click context menu (full §7 menu wired) | L |
| TASK-0056 | Drag & drop with dnd-kit (indicators, nesting, multi, auto-scroll) | L |
| TASK-0057 | Keyboard shortcut set (full master table) | M |
| TASK-0058 | Undo/redo (history 100, save-on-settle, autosave integration) | M |
| TASK-0059 | Copy/paste: internal JSON + markdown/HTML import, md/html export | M |
| TASK-0060 | Large-page virtualization (> 200 blocks windowed) | L |
| TASK-0061 | Accessibility: keyboard-only editing, ARIA, drag alternatives | M |
| TASK-0062 | E2E editor suite (tests/e2e/editor.spec.ts) |
L |
Phase Completion Checklist
- [ ]
pnpm --filter @factory/editor testgreen (codec round-trip, positions, commands, clipboard) - [ ]
tests/e2e/editor.spec.tsgreen in CI (all Phase-4 interactions from the exit criteria) - [ ] Reorder latency probe < 16 ms local (
tests/performance/editor-move.js) - [ ] Ordering survives reload (E2E: drag 5 blocks, reload, assert identical
positionset) - [ ] axe-core audit of the page canvas: 0 critical/serious violations
- [ ] Every task above closed against its Definition of Done (
AGENTS.md) - [ ]
docs/api/blocks.mdbehavior matches implementation (no silent drift)
Recorded notes (conflicts/adjustments, per AGENTS.md):
- Task instructions reference "PRODUCT-SPEC §11/§12" for slash/context menus; in the
current
PRODUCT-SPEC.mdthese are §6/§7 (keyboard §5, DnD §8). This file cites the real numbers; content is identical. docs/architecture/frontend-architecture.md§3.3 locates the client position engine atpackages/editor/src/positions.ts; the platform context specifies fractional positions from@factory/shared/positions. We consolidate the isomorphic engine inpackages/shared/src/positions.ts(exported as@factory/shared/positions) andpackages/editorre-exports it. Algorithm unchanged (ADR-002 midpoint, re-batch on overflow); server side keepspackages/database-engineparity per the same spec.- Advanced blocks
table,synced_block,equation,child_page,child_database,linked_databaseare tracked in Phase 5/6/10 perproduct-requirements.mdPR-040; Phase 4 ships the 16 block types in the task table plus thedatabaseblock placeholder (real rendering arrives with Phase 5 views).
TASK-0047
Title
Tiptap core: FactoryEditor, block tree ↔ ProseMirror codec, extension registry, autosave
Objective
Create the editor kernel in packages/editor: a createFactoryEditor() factory that
mounts a Tiptap document where each block is one node carrying stable block ids and
fractional positions in node attrs, a lossless block tree ↔ ProseMirror codec, an
extension registry, and 800 ms debounced autosave into the existing batch endpoint with
version-based conflict handling.
Background
PRODUCT-SPEC §4 forbids one giant HTML document: content is a block tree
(blocks: id, parentId, position, content JSONB, properties JSONB,
version). docs/architecture/frontend-architecture.md §3.1 defines the canonical
model: one Tiptap node per block with attrs {id, position, content, properties}. The
BlockModule batch endpoint (POST /v1/blocks, ops insert|update|move|delete,
transactional, 409 CONFLICT with the actual version in details) already exists
(docs/api/blocks.md §4) and is the autosave target. This task is the foundation for
every later Phase-4 task: nodes, menus, DnD, undo, and clipboard all plug into the
registry and the autosave pipeline.
Requirements
- R1.
packages/editor/src/editor.tsexportscreateFactoryEditor({ pageId, workspaceId, blocks, readOnly?, onSaved?, onConflict? })returning{ editor, destroy(), flush() }; the same factory is used by the page canvas, record pages, and readonly share pages (frontend-architecture §3.2). - R2. Base node
packages/editor/src/nodes/block-node.ts:BlockNodewith attrs{ id: string, position: string, content: unknown, properties: unknown };idandpositionare preserved across all transforms (never regenerated client-side except on paste of external content). - R3. Codec
packages/editor/src/codec.ts:blocksToDoc(blocks: BlockDto[]): ProseMirror DocanddocToBlocks(doc, pageId): BlockOp[]— pure, deterministic (siblings sorted byposition), round-trip lossless for all Phase-4 types; unit-tested with property-based round-trip. - R4. Extension registry
packages/editor/src/extensions/registry.ts: single ordered array of Tiptap extensions (our nodes only — not StarterKit paragraphs; Placeholder, Dropcursor, Gapcursor, CharacterCount, History, custom plugins); registering a new node type is a one-line change. - R5. Autosave
packages/editor/src/autosave.ts: transaction tracking → debounce 800 ms → emit ops{insert, update, move, delete}(max 200/batch, chunk beyond) →POST /v1/blocks(orPOST /v1/pages/:id/blocksfor pure inserts); expose save stateidle|dirty|saving|saved|error|conflictfor the top-bar indicator. - R6. Conflict handling: on
409 CONFLICT(staleversion), fetch the fresh block (GET /v1/blocks/:id), rebase local ops against the newposition/version, and retry once; second failure →onConflictsurfaces a toast with "Reload page" action. Never silently overwrite. - R7.
apps/web/editor/EditorCanvas.tsxwires the factory into the page canvas (/[workspace]/p/[pageId]), hydrating fromuseBlocks(pageId)(REST) until realtime exists in Phase 7; readonly mode for view-only permissions. - R8. Fractional positions come from
@factory/shared/positions(positions.between(before, after), append, re-batch); the editor never persists array indexes (ADR-002).
Acceptance Criteria
- [ ]
pnpm --filter @factory/editor testpasses: codec round-trip on a 500-block fixture (tree + positions + content + properties byte-identical afterdocToBlocks(blocksToDoc(x))). - [ ] Editing a block and waiting 800 ms issues exactly one
POST /v1/blockswith the correct op list; killing the tab mid-debounce on reload shows no partial state (ops are atomic server-side). - [ ] Simulating a stale
version(two clients editing the same block) resolves by rebase without data loss; a forced double-conflict shows the conflict toast. - [ ]
GET /v1/pages/:id/blockshydration renders 200 blocks in < 1 s locally; save-state indicator transitionsdirty → saving → saved. - [ ] Node attrs keep the original
blk_…id andpositionstring through type changes (turn-into) and moves.
Backend Changes
apps/api/src/modules/blocks: verifyPOST /v1/blocksreturns the currentversionindetailson409(backend-architecture §8 rule 4) and acceptsmoveops withtarget.afterfor the autosave op set; addPATCH /v1/blocks/:idalias if missing. No new tables.
Frontend Changes
packages/editor/src/editor.ts,codec.ts,autosave.ts,nodes/block-node.ts,extensions/registry.ts,extensions/placeholder.ts.apps/web/editor/EditorCanvas.tsx;apps/web/hooks/useBlocks.ts;apps/web/stores/editorState.ts(per-page session: caret, selection, save state).
Database Changes
- None (schema from Phase 3:
blockswithposition,content,properties,version).
API Changes
- Consumed:
GET /v1/pages/:id/blocks(tree),POST /v1/blocks(batch),PATCH /v1/blocks/:id,GET /v1/blocks/:id. No new endpoints.
Tests
- Unit (Vitest,
packages/editor/src/__tests__/): codec round-trip (property-based over random trees), autosave debounce/op extraction (fake timers + mocked fetch), conflict rebase (mocked 409),positions.betweenedge cases (imported from@factory/shared/positions). - Integration (
tests/integration/editor-autosave.test.ts): real API — hydrate → 3 edits → flush → assertblocksrows (ids, positions, content) andpage.updatedevent.
Security
- Autosave ops re-validate
EDITon the page per op server-side (batch guard); client ids are never trusted for ownership;content/propertiesvalidated againstBlockContentSchema(Zod) in the pipe.
Documentation
- Update
docs/architecture/frontend-architecture.md§3 with the final file list; note the positions-module consolidation (header note 2).
Dependencies
- Phase 3 (pages API,
blockstable,@factory/sharedpositions),packages/uitokens for placeholder/indicator styling.
Definition of Done
- Backend verified; frontend implemented; no DB migration; API validation via shared Zod; authorization (EDIT) enforced server-side; error + loading + empty states (empty page shows placeholder); mobile: editor renders at < 768 px (gutter hidden, touch tap targets ≥ 44 px); accessibility baseline (contenteditable
role=textbox, focus restoration); unit + integration + E2E smoke tests green; lint + typecheck green; docs updated; logging: autosave failures logged withpageId+ op count; no audit event needed (covered bypage.updated/block audit in Phase 3).
TASK-0048
Title
Text blocks: paragraph, heading_1/2/3, quote, callout
Objective
Implement the six text block types (nodes + slash entries + context-menu turn-into +
content schemas in @factory/shared) so users can author and style the core text
content of a page.
Background
PRODUCT-SPEC §4 groups paragraph, heading_1..3, quote, callout as Text
blocks; docs/api/blocks.md §2 fixes their content shapes ({text, marks},
quote.citation, callout properties.emoji/backgroundColor/textColor). Rich-text
marks (bold, italic, underline, strikethrough, code, highlight, link, mention,
equation-inline) are already specced in docs/api/blocks.md §1; mentions themselves
arrive in Phase 7 (extension registered but inert until then). PR-035 is P0.
Requirements
- R1. Nodes in
packages/editor/src/nodes/:paragraph.ts(base text node + placeholder "Type '/' for commands…"),heading.ts(single node,level1–3 attr; scales 1.875×/1.5×/1.25× via tokens),quote.ts(left border accent, optionalcitation),callout.ts(CalloutNode: emoji + background from token palette, inline content). - R2. Rich-text marks:
packages/editor/src/marks/(bold, italic, underline, strikethrough, code, highlight, link) — stored incontent.marksvia the codec (offsets UTF-16, per blocks API §1). - R3. Content schemas:
packages/shared/src/dtos/blocks.ts—BlockContentSchemadiscriminated-union entries for the six types (e.g.quoteContentSchema = {text: RichText, citation: string?}); server validation already routes through these. - R4. Slash entries (consumed by TASK-0054):
/text,/heading(submenu 1–3),/quote,/callout— metadata{type, label, icon, group: "Text", keywords[], shortcut?}inpackages/editor/src/commands/registry.ts. - R5. Context-menu turn-into (consumed by TASK-0055): all six text types convertible in both directions with content preservation (text + marks survive;
citationdropped on non-quote with a toast). - R6. Enter splits a text block into the same type below; empty heading on Backspace demotes h1→h2→h3→paragraph (TASK-0057 wires the key, this task provides
commands/turnInto.ts+commands/split.ts).
Acceptance Criteria
- [ ] Creating each of the six types via slash and via keyboard (
⌘⇧1/2/3/0) produces the exactcontentshape fromdocs/api/blocks.md§2 (asserted in E2E). - [ ] Round-trip: bold + italic + link + highlight + code marks survive save → reload → copy → paste (unit + E2E).
- [ ] Turn-into quote→heading preserves text and marks; deleting a callout emoji keeps the block as a callout with default icon.
- [ ]
POST /v1/blocksrejects malformedcontentfor each type with400 VALIDATION_ERRORfield details (integration). - [ ] E2E: type
/quote, fill text, add citation, reload — citation persists.
Backend Changes
- None (validation schemas shared via
@factory/shared;BlockModulealready validatescontentper type).
Frontend Changes
packages/editor/src/nodes/{paragraph,heading,quote,callout}.ts,src/marks/*,src/commands/{turnInto,split}.ts,src/commands/registry.ts(slash metadata for the 6 types).apps/web/editor/no changes (registry-driven).
Database Changes
- None.
API Changes
- None new;
BlockContentSchemaextended in@factory/shared(wire contract change is additive — existing enum values).
Tests
- Unit: mark offset math (insert/delete across mark boundaries), turn-into content preservation, callout property defaults.
- Integration: create/turn-into/delete each type via API; invalid content rejected.
- E2E (in
tests/e2e/editor.spec.ts, added by TASK-0062): "create every text block type" scenario depends on this task's commands.
Security
contentvalidated server-side per type; no HTML ever stored (text + marks only); linkattrs.urlallowlisted schemes (http,https,mailto) at paste/import time (TASK-0059).
Documentation
- Slash/command registry table in
packages/editor/README.md.
Dependencies
- TASK-0047 (factory, codec, registry).
Definition of Done
- Backend implemented (N/A — shared schemas only); frontend implemented; no migration; API validation via shared Zod; authorization inherited from block ops; error/loading/empty states (callout icon picker empty state); mobile: blocks reflow at 375 px; accessibility (headings are real
h1–h3, callout iconaria-label); unit + integration + E2E; docs; logging (turn-into emits op with old/new type in autosave debug log); no audit (covered by block ops).
TASK-0049
Title
List blocks: bulleted_list, numbered_list, todo, toggle (nesting via indent/outdent)
Objective
Implement the four list blocks with correct nesting semantics (children = blocks with
parentId), Enter continuation, list-exit behavior, todo checkboxes, and toggle
collapse/expand, wired into slash and turn-into.
Background
PRODUCT-SPEC §4 Lists group; docs/api/blocks.md §2 fixes content shapes
(bulleted_list/numbered_list {text, marks}, numbered_list.start, todo
properties.checked, toggle properties.collapsed). Nesting is expressed purely by
parentId + fractional position (database-architecture §1.2 design note).
PR-036 is P0. Enter inside a list continues the list; empty item + Enter exits;
Tab/Shift+Tab indent/outdent (key wiring is TASK-0057; this task provides the
indent/outdent commands and list-aware split/merge).
Requirements
- R1. Nodes:
packages/editor/src/nodes/{bulleted-list,numbered-list,todo,toggle}.ts.TodoBlockNode: checkbox inproperties.checked, toggling applies strikethrough styling;ToggleBlockNode: collapsible child container (children = blocks withparentId = toggle.id), chevron + drag handle,properties.collapsedpersisted. - R2. Numbering computed per consecutive same-parent same-type sibling run (gap resets to 1);
content.startpersisted only when the user changes the start number. - R3. Commands
packages/editor/src/commands/indent.ts:Tabindents (previous sibling list/toggle becomes parent — newparentId,positioncomputed bypositions.append(newParentSiblings));Shift+Taboutdents (reparent to previous parent at the correct index); top-level blocks can't indent. - R4. Enter on a list item splits into a same-type item below; Enter on an empty list item exits the list (paragraph); toggle: Enter on empty toggle heading opens it, otherwise inserts a child at the top of its child list.
- R5. Backspace at list-item start (non-empty) merges with previous; on an empty item, removes the item and places caret in the previous block (list-exit when the previous sibling is a different type or non-list).
- R6. Slash entries
/bullet,/number,/todo,/toggle+ turn-into between the four types and text types (turn-into list→list preserves nesting; list→paragraph keeps the block at its current parent). - R7. Multi-level nesting up to depth 50 (server container rule) — client refuses deeper with a toast.
Acceptance Criteria
- [ ] E2E: create bullet list of 3, Tab on item 2 → item 2 becomes child of item 1 (assert
parentIdvia API after autosave), reload — nesting persists with fractional positions. - [ ] Numbered list 1–5 with item 3 deleted renumbers 1,2,4→3,5→4 (computed, not stored).
- [ ] Todo checkbox toggle persists
properties.checkedand applies strikethrough; paste "x " at block start converts to checked todo (TASK-0059 uses this conversion helper). - [ ] Toggle collapse persists across reload; expanding shows children with correct positions; Enter in collapsed empty toggle opens it.
- [ ] Indent/outdent on a 3-level nested structure then undo returns the exact previous
parentId/positionset.
Backend Changes
- None (existing batch
insert/update/move+ container validation covers list nesting).
Frontend Changes
packages/editor/src/nodes/{bulleted-list,numbered-list,todo,toggle}.ts,src/commands/indent.ts, list-aware updates insrc/commands/split.ts,src/commands/registry.tsentries.
Database Changes
- None.
API Changes
- None new.
Tests
- Unit: numbering run computation (gaps, mixed types), indent/outdent position math against fixtures, list-exit edge cases (only item, last item, mixed nesting).
- Integration: API-level nesting round-trip (create 5-level tree, reorder, assert positions).
- E2E scenarios consumed by TASK-0062 suite.
Security
- Same as TASK-0048 (per-type
contentvalidation server-side); togglecollapsedis presentation (properties) — no authorization impact.
Documentation
- Add list block behavior table to
packages/editor/README.md.
Dependencies
- TASK-0047, TASK-0048 (split/turn-into commands).
Definition of Done
- Frontend implemented; no migration/API change; authorization inherited; error states (invalid nesting → server 422 surfaces as toast with the offending block); loading/empty states (empty toggle); mobile (indent works via context menu "Indent"/"Outdent" when no Tab key); accessibility (lists
role=list/listitem, todorole=checkbox+aria-checked, togglearia-expanded); unit + integration + E2E; docs; logging (indent/outdent included in autosave op log); no audit.
TASK-0050
Title
Code, divider, spacer blocks
Objective
Implement code (language select + syntax-highlighting stub + copy button), divider,
and spacer (height presets + drag resize) per PRODUCT-SPEC §4 and
docs/api/blocks.md §2 rows 17/21/22.
Background
PR-038 (code P0) and PR-039 (divider P0; spacer P1). Code content is
{code: string, language}; highlight rendering ships as a stub in Phase 4 (plain
monospace render with language metadata + line numbers) and is replaced by Shiki in a
Web Worker in Phase 9 (frontend-architecture §3.2) — the extension point (a
highlight(code, language): Promise<Html> interface in packages/editor/src/highlight/)
is defined now so the swap is mechanical. Spacer content.heightPx is 8–200 with
presets 8/16/32/48/64 (functional-requirements §4.2 row 22). Both are atomic (no text
selection inside divider/spacer; code uses a separate code-editor surface).
Requirements
- R1.
packages/editor/src/nodes/code.ts(CodeBlockNode): contenteditable code area (no rich marks), language select (40+ languages list inpackages/shared/src/constants/languages.ts), line numbers, horizontal scroll, copy button (clipboard write + "Copied" toast),properties.showLineNumbers(default true),wrapoff. - R2. Highlight stub
packages/editor/src/highlight/index.ts:highlight()returns escaped plain text (no tokens) with the interface documented for the Phase-9 Shiki swap; unit-tested contract (deterministic, escapes HTML). - R3.
packages/editor/src/nodes/divider.ts(DividerNode): atomic, no content, 16 px vertical margin,properties.color?token. - R4.
packages/editor/src/nodes/spacer.ts(SpacerNode):content.heightPx8–200; hover shows resize handle; drag snaps to presets 8/16/32/48/64/96/128/160/200; keyboard: focus handle + arrows adjust by 8 px (accessibility path, TASK-0061). - R5. Slash entries
/code,/divider,/spacer; turn-into: text types ↔ code (text becomescodestring), text types not convertible into divider/spacer (atomic — they are inserted, not converted); divider/spacer have no "Turn into" targets in the menu. - R6. Enter inside code inserts a newline (no block split); Enter after code/divider/spacer creates a paragraph below.
Acceptance Criteria
- [ ] E2E: paste 30-line bash snippet into code block, set language "python", reload — language + code persist; copy button copies exact code text.
- [ ] Divider and spacer render atomically: caret cannot be placed inside; Backspace at start of following block deletes the divider/spacer.
- [ ] Spacer resize drag to 120 px persists
content.heightPx: 120; keyboard arrows move it 8 px per press with announced value. - [ ] Code block with 5,000 lines renders with < 100 ms interaction (no syntax processing — stub);
highlight()unit test: HTML-escape contract holds for<script>,&, quotes.
Backend Changes
- None.
Frontend Changes
packages/editor/src/nodes/{code,divider,spacer}.ts,src/highlight/index.ts;packages/shared/src/constants/languages.ts.
Database Changes
- None.
API Changes
- None new (
contentshapes already indocs/api/blocks.md).
Tests
- Unit: highlight stub escaping, spacer preset snapping, code Enter/newline behavior, atomicity (ProseMirror atom: true assertions).
- Integration: code with 100 KB
codestring persists and round-trips exactly (JSONB size check).
Security
- Code content is plain text (no HTML parsing); highlight output is escaped; language list is a closed set (no arbitrary strings in
content.languagebeyond the constant — server validates).
Documentation
packages/editor/README.md: highlight extension point + Phase-9 Shiki swap plan.
Dependencies
- TASK-0047, TASK-0048.
Definition of Done
- Frontend implemented; no migration/API change; authorization inherited; error states (language reset to "text" on unknown value); empty state (code shows "Write code…" placeholder); mobile (language select as bottom sheet); accessibility (divider
role=separator, spacerrole=sliderhandle witharia-valuenow); unit + integration + E2E; docs; logging; no audit.
TASK-0051
Title
Media blocks: image, video, audio, file card (presigned S3/MinIO upload + status polling)
Objective
Implement the four file-backed media blocks with an upload flow that creates a File
row, PUTs bytes directly to presigned S3 (MinIO in dev), and polls file status until
the (Phase-9) pipeline marks it READY — rendering skeleton/pending states meanwhile.
Background
PR-037: image/file P0, video/audio P1. FileModule already exposes
POST /v1/files (creates File row status=UPLOADING + presigned PUT),
GET /v1/files/:id (status), GET /v1/files/:id/download (short-lived signed URL)
(backend-architecture module table; prisma model File with status
UPLOADING/SCANNING/READY/VIRUS_DETECTED/FAILED, blockId, thumbnailUrl). The full
pipeline (scan/metadata/thumbnails) is Phase 9; Phase 4 only needs the upload path +
status states. Content shapes: docs/api/blocks.md §2 rows 11–14
(image {fileId, caption}, properties.widthPct 10–100, video {fileId?|url},
audio {fileId}, file {fileId, name, size, mimeType}).
Requirements
- R1. Nodes:
packages/editor/src/nodes/{image,video,audio,file}.ts. Image: lazyloading="lazy", skeleton whilestatus=UPLOADING|SCANNING, inline width resize handle (snap 25/50/66/75/100 %,properties.widthPct), caption below (editable,content.caption), align property. Video: native player for files, embed player for URLs, poster fromthumbnailUrl. Audio: minimal custom player (play/seek/time). File card: icon by mime, name, size, download button (signed URL via BFFapps/web/app/api/files/[fileId]/route.ts), preview link. - R2. Upload service
packages/editor/src/upload.ts:uploadFile(file, pageId, blockId?)→POST /v1/files {name, mimeType, size, blockId?}→PUTbytes to the returned presigned URL (direct MinIO in dev, S3 in prod; multipart handoff for ≥ 100 MB is Phase 9) →GET /v1/files/:idpolling (1 s × 10, then 3 s × 20) untilREADY|FAILED|VIRUS_DETECTED; emits per-file progress to the block's pending state. - R3. Block insertion: slash
/image,/video,/audio,/fileopen the file picker (or paste/drop of an OS file — drop wiring is TASK-0056); the block is inserted immediately in pending state with a temporary id; on upload completion the blockcontent.fileIdis updated via autosave op. - R4. Terminal states:
FAILED→ block shows retry button (re-PUT);VIRUS_DETECTED→ block shows "File blocked" state, never renders the media (Phase-9 pipeline drives this; Phase 4 renders the state if it arrives);READY→ render media. - R5. Image replacement (click pending/failed image → new file picker) creates a
FileVersion(POST /v1/files/:id/versions) and swapscontent.fileId's target version.
Acceptance Criteria
- [ ] E2E (MinIO dev): pick a 2 MB PNG → block inserts in pending state → skeleton → READY → image renders with caption;
blocksrow hascontent.fileIdand theFilerow hasblockIdset. - [ ] Kill network mid-upload → block shows "Upload failed — Retry" → retry completes (presigned URL re-fetched).
- [ ] Width resize to 50 % persists
properties.widthPct: 50; reload keeps it. - [ ] File card download button hits the BFF route and returns a 200 with a signed URL only for a member with VIEW on the page (integration: guest with no grant gets 403/404).
- [ ]
VIRUS_DETECTEDfixture file renders only the blocked state (no<img>, no<video>).
Backend Changes
apps/api/src/modules/files: verifyPOST /v1/filesacceptsblockIdand setsFile.blockId; verify presigned PUT URL TTL ≥ 10 min andGET /v1/files/:idreturnsstatus+thumbnailUrl; addPOST /v1/files/:id/versionsif missing (alias exists per blocks API convenience table — confirm).
Frontend Changes
packages/editor/src/nodes/{image,video,audio,file}.ts,src/upload.ts;apps/web/app/api/files/[fileId]/route.ts(BFF proxy for signed URLs, so storage keys never reach the client); pending-state UI inpackages/ui.
Database Changes
- None (
filestable exists;File.blockIdplain-ref validated by FileService).
API Changes
- Consumed:
POST /v1/files,GET /v1/files/:id,GET /v1/files/:id/download,POST /v1/files/:id/versions. No new endpoints.
Tests
- Unit: polling state machine (UPLOADING→SCANNING→READY / FAILED / VIRUS_DETECTED transitions with fake timers), presigned-URL retry logic, width snapping.
- Integration: upload → block bind → download auth matrix (owner/member/guest/other-tenant 404).
Security
- Uploads:
mimeTypere-verified server-side against the actual bytes on PUT-complete callback (Phase 9); size capped byworkspace.usageLimits.maxFileSizeMb(FILE_TOO_LARGE); downloads only via signed short-lived URLs after permission check;VIRUS_DETECTEDfiles never served (server check, not client); BFF proxy never leaks the object key.
Documentation
docs/api/blocks.mdmedia rows already spec the shapes — add a "Phase-4 upload flow" note with the poll cadence.
Dependencies
- TASK-0047;
FileModule(Phase 3/9 scaffolding); MinIO in docker-compose (Phase 0).
Definition of Done
- Backend verified (presign/versions); frontend implemented; no migration; API validation (mime/size); authorization (EDIT to insert, VIEW to render/download); error states (failed/blocked/oversize); loading states (skeletons, progress bar); empty state (video without url+file → prompt); mobile (players full-width, tap targets ≥ 44 px); accessibility (image
altprompt on first upload, skippable → "Decorative"; audio/video native controls; file cardaria-label); unit + integration + E2E; docs; logging (upload start/complete/fail withfileId, bytes, ms); audit:file.uploadedemitted by FileModule on READY (existing contract).
TASK-0052
Title
Columns: column_list + column with width resize handles and cross-column drag
Objective
Implement the two layout container blocks: column_list (container of column
children, flex layout, stack below 768 px) and column (width in properties,
holds arbitrary blocks), with drag-resize width handles between columns and
dnd-kit containers enabling drag between columns (drop resolution shared with
TASK-0056).
Background
PR-039 P1. docs/api/blocks.md §2 rows 19–20: column_list/column are
containers — children are blocks with parentId = column.id, laid out
side-by-side; column.properties.widthPct (20–100, functional-requirements allow
drag to 10 %), column_list sums to 100 % across children. Server container rules
(blocks API §2): column_list may only contain column children; column may not
contain a nested column_list/column at top level; violations → 422 invalid_nesting; max depth 50. ADR-002 note (database-architecture §1.2): columns are
just container blocks — no special schema.
Requirements
- R1. Nodes:
packages/editor/src/nodes/{column-list,column}.ts.ColumnListNode: flex row (gap token), children rendered as equal-widthcolumnnodes fromcolumn.properties.widthPct(fallback: even split); stacks to vertical below 768 px.ColumnNode: accepts any block exceptcolumn_list/column. - R2. Create: slash
/columnswith a count picker (2/3/4) insertscolumn_list+ Ncolumnchildren in one batch op (positions viapositions.appendper sibling set). - R3. Width resize: handle between columns (hover/keyboard-focusable) drag adjusts the two adjacent
columnwidths (min 10 %, live,properties.widthPctupdated on release → one autosave op per changed column); keyboard: focus handle,←/→adjust by 2 % (announced "30% / 70%"). - R4. Drag between columns: each
columnis a dnd-kit container (TASK-0056 registers them); dropping a block into a column re-parents (parentId = column.id, new fractional position); dropping acolumn_listinto a column is refused client-side (and server-side 422). - R5. Add/remove:
+inside a column appends a paragraph; context menu "Add column" (append) / "Remove column" (its children move to the previous column, or to the page if it was the first); removing the last column removes thecolumn_list(children hoisted to the list's parent position). - R6. Enter at the top/bottom edge of a column exits to the parent flow; arrows move the caret between columns at column edges (frontend-architecture §3.2 / functional-requirements §4.3 arrows row).
Acceptance Criteria
- [ ] E2E:
/columns→ 3 → type in each; resize middle column to 40 % → all threeproperties.widthPctsum to 100 and persist after reload. - [ ] Drag a paragraph from the page flow into column 2 → autosave
moveop withparentId = <column id>; reload — block is inside column 2 with a valid fractional position. - [ ] Dragging a
column_listinto a column shows an invalid-target indicator and does nothing on drop; the same op via API returns422 invalid_nesting. - [ ] "Remove column" on the middle of 3 moves its 4 children into the left column in order (E2E + unit for the hoisting command).
- [ ] 2/3/4-column layouts render correctly at 1280 px and stack vertically at 375 px (visual regression snapshot).
Backend Changes
- None (container validation + batch move already exist per blocks API §2/§4).
Frontend Changes
packages/editor/src/nodes/{column-list,column}.ts,src/commands/columns.ts(create/add/remove/hoist), resize handle component inpackages/ui(ColumnResizeHandle), container registration hook for TASK-0056.
Database Changes
- None.
API Changes
- None new (batch
insert/movewithparentId).
Tests
- Unit: width normalization (sum=100 after any resize), hoisting command (remove-column child redistribution), container validity predicate shared with the server (imported from
@factory/shared). - Integration: API-level create 2-column layout + move-in/out round-trip; invalid nesting 422.
Security
- Same batch permission model (EDIT on page); container rules enforced server-side regardless of client refusal (defense in depth).
Documentation
packages/editor/README.mdlayout blocks section (container rules, resize contract).
Dependencies
- TASK-0047, TASK-0049 (indent commands reuse container logic); TASK-0056 consumes the container registration (build in parallel, integrate in 0056).
Definition of Done
- Frontend implemented; no migration/API change; authorization inherited; error states (422 invalid nesting toast with block/parent); empty state (empty column shows placeholder); mobile (stacked layout, resize handle replaced by column-width menu 25/33/50/75/100); accessibility (handle
role=slider, drag alternative = context menu "Move to → <column>"); unit + integration + E2E; docs; logging (hoist ops logged with child count); no audit.
TASK-0053
Title
Bookmark + embed blocks (URL card with favicon, sandboxed iframes)
Objective
Implement bookmark (card with url, favicon, title/description from OpenGraph) and
embed (provider iframe with a strict sandbox attribute and height property), per
PRODUCT-SPEC §4 and docs/api/blocks.md §2 rows 15–16.
Background
PR-037 P1. Bookmark content {url, title?, description?}; the card shows favicon +
domain + title + description; OpenGraph metadata is fetched server-side (client
fetches hit CORS) — BlockService resolves og tags on insert/update of bookmark
blocks (10 s timeout, cache in blocks.content once known, fallback to domain name).
Embed content {url} + properties.heightPx (120–800); functional-requirements §4.2
row 16 mandates sandbox="allow-scripts allow-same-origin" and no
allow-top-navigation; provider presets (Loom, Figma, Airtable, YouTube, generic) map
a raw URL to an embeddable URL where known.
Requirements
- R1.
packages/editor/src/nodes/bookmark.ts: card UI (favicon 16 px, domain, title, description truncated to 2 lines); created via/bookmarkwhich opens a URL input; Enter validateshttp(s)URL, resolves og metadata (loading state "Fetching…"), inserts the block; clicking the card opens the URL in a new tab (rel="noopener noreferrer"); edit metadata via context menu "Edit bookmark" (re-fetch button). - R2.
packages/editor/src/nodes/embed.ts: iframe withsandbox="allow-scripts allow-same-origin"(neverallow-top-navigation, never bareallow),loading="lazy",heightPxfrom properties (resize handle, min 120 max 800), provider preset picker on create (auto-detect from URL: youtube →youtube.com/embed/…, figma, loom, airtable, generic);aria-labelfrom resolved title or URL. - R3. Server og fetch:
apps/api/src/modules/blocks/og-metadata.ts— fetch oninsert/updateof bookmark blocks (server-side, 10 s timeout, 5 MB cap,Content-Typehtml check, parse<og:title|description|image>), result written intocontent; on failure fallback{title: hostname}. No SSRF exposure: onlyhttp/https, no private/loopback/link-local IP ranges (resolve DNS and block RFC1918/127.0.0.0/8/169.254/::1), user-agentFactoryBot/1.0. - R4. Slash
/bookmark,/embed; turn-into: none for embed (structural); bookmark convertible to/from paragraph (title becomes text). - R5. CSP:
apps/webshipsframe-ancestors+object-src 'none'headers (extend existing Next.js headers) so embeds can't frame the app.
Acceptance Criteria
- [ ] E2E:
/bookmark+https://www.rfc-editor.org/rfc/rfc9110→ card shows title from og within 5 s; reload keeps stored metadata (no re-fetch). - [ ] Embed of a YouTube URL renders the
youtube.com/embed/…iframe; DOM assertion:sandboxattribute equalsallow-scripts allow-same-origin, noallow-top-navigation,heightwithin 120–800. - [ ] og fetch against
http://127.0.0.1:4000(internal API) is refused (SSRF guard) — block falls back to hostname title (integration test). - [ ] Bookmark with unreachable URL (connection timeout) inserts with hostname title, "re-fetch" available; no 500 returned to the client.
- [ ] Turn-into bookmark→paragraph produces a paragraph with the title text; no og re-fetch on reload of a paragraph.
Backend Changes
apps/api/src/modules/blocks: og-metadata fetch service + hook into insert/update validation forbookmarktype (async post-commit, non-blocking; metadata patched in a follow-upupdateop if it arrives after the insert committed).
Frontend Changes
packages/editor/src/nodes/{bookmark,embed}.ts,src/commands/bookmark.ts(URL input dialog), provider preset table inpackages/shared/src/constants/embed-providers.ts.
Database Changes
- None.
API Changes
- None new (og fetch is server-internal, no public endpoint).
Tests
- Unit: og HTML parser (fixtures: full og, missing tags, script-laden page), embed provider URL mapping table, SSRF URL classifier (private ranges, IPv6, DNS rebinding note — resolve + check).
- Integration: bookmark insert with real og fixture server (local httpbin), SSRF refusal matrix, CSP header presence (supertest).
Security
- SSRF guard on og fetch (mandatory — this is the only server-side outbound HTTP the block path performs); URL allowlist schemes
http/httpson insert; iframesandboxnever extended at runtime;rel="noopener"on all outbound links; CSPframe-ancestors 'none'-equivalent via Next headers; nojavascript:URLs accepted anywhere (validation in@factory/sharedURL schema).
Documentation
docs/api/blocks.md: note that bookmarktitle/descriptionare server-resolved metadata.
Dependencies
- TASK-0047, TASK-0048 (turn-into).
Definition of Done
- Backend implemented (og + SSRF guard); frontend implemented; no migration; API validation (URL scheme, heightPx range); authorization inherited (EDIT to insert; VIEW to render); error states (fetch failed → fallback title + re-fetch); loading states (Fetching… skeleton, iframe spinner); empty state (N/A); mobile (cards full-width); accessibility (bookmark
role=linklabel "Open <title> in new tab", embedaria-label); unit + integration + E2E; docs; logging (og fetch outcome + latency, SSRF refusals logged with the refused host, no URL body); audit: none (covered by block ops).
TASK-0054
Title
Slash command menu (searchable, categorized, all PRODUCT-SPEC §6 commands, /database wiring)
Objective
Implement the slash command menu: typing / opens a searchable, categorized command
palette covering every Phase-4 command, with keyboard-first navigation, and
/database (plus view-variant commands) creating a real database via the
databases module and inserting a DATABASE block.
Background
PRODUCT-SPEC §6 lists /heading /text /todo /bullet /number /toggle /quote /callout /code /image /video /file /table /database /board /calendar /timeline /chart /form /divider; functional-requirements §4.3 adds the full list + fuzzy matching, fixed
group order (Text, Lists, Media, Code, Data, Layout, Embeds), 150 ms debounce,
database-family commands opening a property picker before insertion; PR-045/046/047.
The databases module (apps/api/src/modules/databases, POST /v1/workspaces/:wid/databases,
GET /v1/databases/:id) and packages/database-engine filter-builder already exist
(Phase 5 will build views; Phase 4 only needs create + block). table/form/
linked_database/synced/child_page commands are listed but gated to their phases
(grayed with "Coming in Phase 5/10" — the registry supports the enabled: false flag).
Requirements
- R1.
packages/editor/src/slash/slash-menu.ts(Tiptap plugin + DOM overlay, React-agnostic): opens on/at block start or with a word selected (replaces the query on select); closes on caret leaving the trigger,Esc, or selection; 150 ms query debounce; min 1 result. - R2. Fuzzy search without third-party libs: subsequence + Levenshtein scoring over
label + keywords + aliases(e.g. "h1" → Heading 1, "bullet" → Bulleted list); max 12 visible rows, grouped in fixed order Text → Lists → Media → Code → Data → Layout → Embeds, group headers sticky. - R3. Registry:
packages/editor/src/commands/registry.tsentries{type, label, icon, group, keywords[], shortcut?, enabled, run(ctx) | opens: 'property-picker'}; one entry per Phase-4 block (16 types incl. columns) +/database /board /calendar /timeline /chart /form(view-variants of database creation) + disabled placeholders (/table,/synced,/child-page,/linked-database). - R4. Keyboard:
↑/↓navigate (wraps),Enterselects,Tabcycles groups,Esccloses and restores focus to the block;role=combobox+role=listboxwitharia-activedescendant(ux-requirements §12). - R5.
/databaseflow: property picker dialog (apps/web/editor/slash/DatabasePropertyPicker.tsx) — title input, default property rows (Name/Title, Status select with 3 default options), add row with the 22-type dropdown (Phase-5 property set), "Create 'X'" →POST /v1/workspaces/:wid/databases {title, icon?, pageId, properties[], initialView: 'table'|'board'|'calendar'|'timeline'|'chart'|'form'}→ returnsdatabaseId+ defaultviewId→ insertDATABASEblock{content: {databaseId}, properties: {viewId}}via one batch op./boardetc. pre-select the initial view. - R6.
DATABASEblock node (packages/editor/src/nodes/database.ts): in Phase 4 renders a placeholder card (database icon + title, "Views arrive in Phase 5") that is already the correct node withcontent.databaseId; Phase 5 swaps the renderer to the inline view without touching the codec. - R7. Slash works in empty and non-empty blocks (mid-block slash selects the word), never in code blocks (code has its own line semantics), and in column/toggle children.
Acceptance Criteria
- [ ] E2E: typing
/heashows "Heading 1/2/3" in the Text group with fuzzy match on "h1"/"heading one";↓ ↓ Enterinserts Heading 2 and replaces the query. - [ ] All 16 Phase-4 block types are creatable via slash; disabled commands (
/table) render grayed with their phase hint and are not selectable. - [ ]
/database→ name "Projects" → Create →databasesrow exists (with TITLE + STATUS default properties and a TABLE view),DATABASEblock inserted in the page, andGET /v1/databases/:idreturns the created schema (integration + E2E). - [ ] Group order is exactly Text, Lists, Media, Code, Data, Layout, Embeds regardless of query;
Tabmoves the active item to the next group. - [ ]
Escmid-menu restores the caret to its pre-slash position with the typed/removed; no orphan text node remains (asserted in E2E DOM).
Backend Changes
apps/api/src/modules/databases: confirmPOST /v1/workspaces/:wid/databasesaccepts{pageId, properties[], initialView}and creates Database + TITLE property + default view(s) + (whenpageIdgiven) the caller'sDATABASEblock in one transaction, returning{database, blockId, viewId}; addpageIdbackfill (databases.pageId) if missing. This is the Phase-5 database creation contract — keep it stable.
Frontend Changes
packages/editor/src/slash/slash-menu.ts,src/commands/registry.ts(complete),src/nodes/database.ts(placeholder renderer);apps/web/editor/slash/DatabasePropertyPicker.tsx.
Database Changes
- None (models exist:
databases,database_properties,database_views).
API Changes
POST /v1/workspaces/:wid/databasesextended per R5 (additive; documented indocs/api/databases.md— file added by TASK-0063 with the full contract; the create body is fixed here).
Tests
- Unit: fuzzy scorer (golden query table incl. "h1", "bulleted", "col", "dat"), group ordering, slash trigger edge cases (slash in URL text must not open), property-picker defaults.
- Integration: database create via API with
pageId(assert transactional block + property + view rows,databases.pageIdset). - E2E (TASK-0062 suite): slash search scenario +
/databaseE2E.
Security
- Database creation requires EDIT on the page (same guard as block insert); property names validated (unique per database, ≤ 100 chars); no client can pass
databaseIdfor the block (server assigns); og-style outbound fetches: none.
Documentation
packages/editor/README.mdslash section (registry schema, how to add a command);docs/api/databases.mdcreate endpoint contract (with TASK-0063).
Dependencies
- TASK-0047–0053 (all block nodes exist to be registered), databases module create endpoint (R5).
Definition of Done
- Backend implemented (create endpoint contract); frontend implemented; no migration; API validation (shared Zod: property rows, initialView enum); authorization (EDIT on page; workspace membership); error states (create failure → toast, menu stays open, query preserved); loading state (property picker "Creating…"); empty state (picker with only the TITLE row); mobile (picker as full-screen sheet); accessibility (combobox pattern per ux §12, all commands also in the block
+menu — never palette-only); unit + integration + E2E; docs; logging (database creation logged withdatabaseId+ property count); audit:database.createdevent emitted (Phase-5 module contract, consumed by Phase-8 indexer).
TASK-0055
Title
Block right-click context menu (full PRODUCT-SPEC §7 menu wired)
Objective
Implement the block context menu exactly per PRODUCT-SPEC §7: Turn into · Duplicate ·
Copy · Copy link · Move to · Delete · — · Comment · Ask AI · — · Color · Background · — ·
Convert to · — · More — fully wired to real actions (Ask AI stubbed), for single blocks
and multi-block selections.
Background
PR-048 P0. The page menu is already Phase 3 (sidebar/top-bar ⋯); this task wires the
block menu. Menu semantics (functional-requirements §4.3 + ux-requirements §2.4):
flat list with dividers + submenus, 260 px wide, max 14 visible rows (scroll), opens on
right-click (or ⋮⋮ handle click) of a block/selection, role=menu with roving
tabindex, focus returns to the block on close. "Turn into" = grouped list of all
convertible types (TASK-0048/0049/0050 commands); "Convert to" = quick path to
heading 1–3 / quote / callout / todo; "Move to" = page picker dialog; "Copy link" =
deep #blockId URL; "Color/Background" = 18-color palette (9 text + 9 background
token swatches, user values stored in properties.color/backgroundColor);
"Ask AI" = stub (grayed, tooltip "Available in Phase 12") that reserves the submenu
shape (summarize/rewrite/improve/translate); "More" = duplicate as synced block
(Phase 5), save as template (Phase 19→14? templates are PRODUCT-SPEC §19 — phase 14
scaffold; keep as disabled placeholder), permissions (only for page-root blocks — opens
the Phase-3 share/permissions dialog).
Requirements
- R1.
packages/editor/src/context-menu/block-menu.ts: single menu definition (data-driven rows{label, icon, shortcut?, divider?, submenu?, disabled?, run(ctx)}) rendered by a shared Radix-based menu inpackages/ui; opens on right-click inside a block (menu on the block under the caret) and on⋮⋮click; for a multi-block selection, the menu operates on the whole selection (label adjusts: "Duplicate 3 blocks"). - R2. Actions:
- Turn into ▸ (all convertible types, checkmark on current; uses
commands/turnInto); - Duplicate (deep copy of the block subtree with fresh ids, inserted below;
POST /v1/blocksduplicate op orPOST /v1/blocks/:id/duplicatealias if it exists, else local insert ops); - Copy (selection → clipboard, internal + markdown per TASK-0059 format);
- Copy link (copies
<origin>/<workspace>/p/<pageId>#<blockId>; the page route scrolls to the block on load — hash handling inapps/web/editor/EditorCanvas.tsx); - Move to ▸ (page picker: recent 10 + search box over
GET /v1/workspaces/:wid/pages/tree; on pick → cross-page move: serialize subtree,POST /v1/blocksbatch withmove target.pageId(same workspace) — server handles reparent; both source delete + destination insert in one transaction per blocks APImoveop); - Delete (soft, recursive;
Deletekey also works via TASK-0057; undo restores from history, durable recovery via page versions); - Comment (opens the comments panel anchored to the block — panel is Phase 3 for page-level; block anchor stored in
comments.blockId, panel wiring reuses Phase-3 component; if the panel isn't block-aware yet, fall back to page-level comment with a toast — record in the task notes); - Ask AI ▸ (4 items, all
disabled: true, tooltip "Available in Phase 12" — the submenu structure is frozen so Phase 12 only flipsenabled); - Color ▸ (9 text swatches: default + 8 accent colors from
packages/uitoken palette) and Background ▸ (9 swatches: default + 8 tint backgrounds) → setproperties.color/properties.backgroundColor(autosave); - Convert to ▸ (quick: heading 1–3, quote, callout, todo);
- More ▸ (duplicate as synced block — disabled, Phase 5; save as template — disabled, later phase; permissions — enabled only when the block is page-root, opens Phase-3 permissions dialog; export — Phase 3 page menu covers it).
- Turn into ▸ (all convertible types, checkmark on current; uses
- R3. A11y contract (ux §12):
role=menu/menuitem, roving tabindex,Esccloses + focus to trigger,ArrowUp/Downwrap, submenus on→/hover with 300 ms grace, every row has an accessible name, shortcuts shown askbdhints. - R4. Multi-block selection: Duplicate/Move to/Copy/Delete/Copy-link(→ page)/Color/Background apply to all selected; Turn into/Convert to apply to all when the target type is uniform-safe, else only text-capable blocks (disabled otherwise); the floating selection toolbar (ux §2.2) mirrors duplicate/copy/delete/move-to.
Acceptance Criteria
- [ ] E2E: right-click a paragraph → menu appears with the exact row order and dividers from PRODUCT-SPEC §7; "Turn into → Todo 2" converts; "Color → Blue" persists
properties.color. - [ ] "Copy link" copies a URL whose
#blockIddeep-link scrolls the reloaded page to that block (E2E). - [ ] "Move to" → pick another page → block disappears from source and appears in destination (asserted via both pages' APIs in one batch — single
moveop, transactional); a move to a page in another workspace is impossible (picker lists current-workspace pages only). - [ ] Multi-select 3 blocks (shift-click, TASK-0056) → right-click → "Delete" removes all 3 and
⌘Zrestores all 3 (history, TASK-0058). - [ ] Ask AI submenu rows are present, disabled, with the Phase-12 tooltip (DOM assertion).
- [ ] Keyboard-only: open menu from focused
⋮⋮handle (Enter), navigate with arrows, execute "Duplicate" — no mouse used (E2E keyboard-only scenario).
Backend Changes
apps/api/src/modules/blocks: verifyPOST /v1/blocks/:id/duplicatealias (blocks API §4 convenience table) andmovewithtarget.pageIdcross-page semantics; none new otherwise.
Frontend Changes
packages/editor/src/context-menu/block-menu.ts(+ selection toolbar inpackages/ui);apps/web/editor/context-menus/MoveToDialog.tsx;apps/web/components/dialogsreuse for permissions (Phase 3).
Database Changes
- None.
API Changes
- None new (duplicate/move aliases per
docs/api/blocks.md).
Tests
- Unit: menu row computation (per block type: which turn-into targets, which disabled), color/property write-back, deep-link URL builder, cross-page move op serialization (subtree flatten with fresh ids).
- Integration: duplicate alias returns a new block with subtree; cross-page move transactionality (destination page + source page consistent when batch fails midway → full rollback).
Security
- Every action re-validated server-side (EDIT on source page for delete/duplicate; EDIT on destination page for cross-page move — the move op checks both); "Copy link" does not check permission (client-side) — the deep link itself is permission-gated on load (404 for non-members, never 403 leakage); no new attack surface from color values (closed token palette only, validated in
@factory/shared).
Documentation
docs/api/blocks.mdunchanged;packages/editor/README.mdcontext-menu section (row schema + how Phase 12 enables Ask AI).
Dependencies
- TASK-0047–0050 (turn-into + commands), TASK-0049 selection model, Phase-3 page permissions dialog, Phase-3 comments panel.
Definition of Done
- Frontend implemented (backend: alias verification only); no migration; API validation inherited; authorization verified per action (integration matrix incl. cross-page move destination permission); error states (move destination trashed → 422 toast); loading states (Move-to picker search spinner); empty state (picker with no matching pages); mobile (menu opens on long-press of block too; touch targets ≥ 44 px); accessibility (full keyboard path,
role=menu, focus return — axe clean); unit + integration + E2E; docs; logging (menu actions with block ids in debug log); audit: delete/duplicate/move covered by existing block op audit;page.updatedemitted on cross-page move for both pages.
TASK-0056
Title
Drag & drop with dnd-kit (handle, indicators, nesting, multi-select, auto-scroll, cross-container)
Objective
Implement block drag & drop with dnd-kit per PRODUCT-SPEC §8 and
functional-requirements §4.3 (PR-050…053, 056): ⋮⋮ drag handle, drop indicator
(line = sibling, box = into container), nested drops into toggle/column,
multi-block shift-click range drag, auto-scroll near viewport edges, cross-container
column drops, and cross-page drops (sidebar / "Move to" parity) — every drop resolves
to a fractional position and one transactional batch move op, undoable.
Background
frontend-architecture §3.5 is the binding design: one DndContext per canvas;
containers = each top-level sibling run, each column, each toggle child list,
(and Phase 5) board groups; sensors PointerSensor (4 px activation),
KeyboardSensor (accessibility path), TouchSensor (150 ms long-press); custom
collisionDetection computing target container + index from pointer position;
position from positions.between(before, after); move = one op applied → autosave
batch → PATCH-equivalent move. Multi-block drag: contiguous sibling range moves
with re-keyed interior positions (prefix shift, O(range)). Cross-page: drop on a
sidebar row serializes the subtree, one batch op move target.pageId (atomic with
source removal — blocks API move semantics). Performance target: local reorder
< 16 ms (NFR, ROADMAP Phase-4 exit).
Requirements
- R1.
packages/editor/src/dnd/(dnd-context.tsx-equivalent in vanilla DOM or a React-free store + DOM layer wired fromapps/web/editor/dnd/):DndContextwithmeasuring,collisionDetection(custom: pointer → container + slot:above|below|insidefrom geometry of the target block row and container edges),autoScroll(dnd-kit built-in, 10 px edge zone, disabled underprefers-reduced-motion→ jump instead per ux §12). - R2. Drag handle: ⋮⋮ in the 40 px gutter (appears ≥ 150 ms hover,
role=button,aria-label="Move <type> block"); dragging shows a ghost (transform, no layout shift), the source row dims; valid targets highlight (line indicator for sibling slots, box indicator forinsidetoggle/column/child targets). - R3. Drop resolution: compute
parentId(target container or target block's parent) +positionviapositions.between(before, after)(append =positions.append); single-block drop → onemoveop; multi-block range drop → onemoveper block with interior re-key (relative order preserved); drop onto own descendant refused (client-side, and server 422cycle_detectedas backstop). - R4. Multi-select: shift-click extends a contiguous sibling range (also click-drag box selection in the gutter); selected blocks get the
bg-accent/10+ 2 px left border style; dragging any selected block (or its handle) drags the whole range; the live region announces "<n> blocks selected" / "Dropped <n> blocks at position <m>". - R5. Auto-scroll: during drag, pointer within 60 px of the content pane top/bottom scrolls 8 px/frame (rAF-coalesced); near a nested container edge, scroll that container; respects reduced motion.
- R6. Cross-page drop: sidebar page rows and
child_pageblocks are drop targets (dnd-kit droppable outside the editor, registered via a shareduseDndStore.dropTarget); on drop → cross-pagemoveop (single batch, same workspace); a toast confirms "Moved to <page>"; drop onto a database row (Phase 5: create record from block) — register the droppable contract now (onDrophook point) without behavior. - R7. Undo/redo integration: each completed drag is one history op (TASK-0058) —
⌘Zafter a drag returns the block to its previousparentId/positionexactly. - R8. Drag files from the OS onto the editor: drop of an image → image block upload (TASK-0052 upload service),
.txt/.md→ code block, other → file block, at the drop slot's position (functional-requirements §4.3 last row; PR-055's Phase-9 scope is the database-cell variant — editor drop ships here since it reuses the same upload path).
Acceptance Criteria
- [ ] E2E: drag block 2 to between blocks 5–6 with the indicator visible at each stage; after autosave, the
blockstable shows the new fractionalpositionstrictly between the neighbors; reload keeps the order (ordering-survives-reload exit criterion). - [ ] E2E (nesting): drag a paragraph into an open toggle → it becomes the first child (
parentId = toggle id); drag it out to top level →parentId = nullwith a valid position. - [ ] E2E (multi): select 4 contiguous blocks (shift-click), drag to end of page → all 4 move, relative order preserved, interior positions re-keyed (assert no duplicates/ties via API).
- [ ] E2E (columns): drag a block from column 1 to column 3 (cross-container) — indicator box appears inside column 3; drop reparents.
- [ ] E2E (cross-page): drag a block onto a sidebar page row → source empty, destination has the block; one batch request on the wire (asserted via network log);
⌘Zrestores it to the source page. - [ ] Perf:
tests/performance/editor-move.js— programmatic reorder of 100 blocks on a 2k-block page: median local update (op apply + indicator + autosave flush) < 16 ms (p50) and < 50 ms (p95) on CI hardware. - [ ] Keyboard path: focus handle, Space lifts, arrows move, Space drops — full reorder without pointer (E2E), announced via live region.
Backend Changes
- None (batch
moveops + fractional semantics already exist; verifymove target.after+target.parentIdcombinations and422 cycle_detectedper blocks API §4/§5).
Frontend Changes
packages/editor/src/dnd/*(collision detection, position resolver, container registry, multi-select model inuseDndStore-shaped plain store);apps/web/editor/dnd/(DndContext mount, ghost/drag-overlay layer, auto-scroll wiring, sidebar droppable registration inapps/web/workspace/Sidebar.tsx).
Database Changes
- None.
API Changes
- None new.
Tests
- Unit: collision detection geometry (fixtures: pointer over sibling row edge → above/below; over toggle chevron zone → inside; over column interior → inside column; pointer in gutter dead-zone → no target), position resolver (between/append, no ties after 1000 random inserts — property test), multi-range re-key (order preservation + unique positions).
- Integration: cross-page move transaction (forced failure mid-batch → zero partial state, both pages unchanged); cycle detection (move block into its own child → 422).
- E2E: the seven scenarios in the acceptance criteria (all in
tests/e2e/editor.spec.ts).
Security
- Drops are ordinary
moveops: server re-checks EDIT on source and destination page for cross-page moves; client geometry tricks can only propose positions the server validates (fractional alphabet check + cycle check); OS-file drops reuse the TASK-0051 upload security (mime/size caps, no execution of dropped content).
Documentation
packages/editor/README.mdDnD section (container registry API, drop-target contract for Phase-5 database rows).
Dependencies
- TASK-0047 (codec, autosave, positions), TASK-0049 (toggle containers), TASK-0052 (column containers), TASK-0051 (OS-file upload), TASK-0055 (multi-select state + "Move to" parity), Phase-3 sidebar.
Definition of Done
- Frontend implemented; no migration/API change; authorization (dual-page check on cross-page move, integration-tested); error states (drop refused → indicator red + toast with reason); loading states (N/A — synchronous local); empty state (drag on empty page: only "append" target); mobile (TouchSensor long-press, 44 px handle); accessibility (KeyboardSensor full path, live-region announcements, reduced-motion auto-scroll off, "Move to" menu as non-drag alternative for every drag); unit + integration + E2E; docs; logging (drop ops with from/to container + page ids); audit: block moves covered by existing block-op audit;
page.updatedon cross-page moves.
TASK-0057
Title
Keyboard shortcut set (full master table)
Objective
Implement the complete keyboard model from docs/product/ux-requirements.md §10 and
functional-requirements.md §4.3 (PR-041): block editing keys (Enter, Shift+Enter,
Backspace merge + demotion chain, Delete, Tab/Shift+Tab, arrows), the ⌘/Ctrl set
(A/C/X/V/Z/Shift+Z/Y, B/I/U, Shift+S, E, K, Shift+1/2/3/0, Shift+8, Shift+D,
Shift+C, Shift+M, Shift+L), plus the in-editor ? shortcuts help overlay.
Background
PR-041 is P0 and is a Phase-4 exit item ("full keyboard shortcut set"). The
load-bearing rules (functional-requirements §4.3): Enter splits (new empty block of
the same list type below; empty list item exits; empty toggle opens, else new child);
Shift+Enter = soft line break; Backspace at block start merges with previous
(inherit its type) with the heading demotion chain h1→h2→h3→paragraph on empty
headings; Delete at block end merges the next block upward; Tab/Shift+Tab indent/
outdent (TASK-0049 commands); arrows move the caret across blocks, at the edge scroll,
inside columns cross at column edges; ⌘A ladder ×1 text → ×2 block → ×3 all blocks;
⌘C/X/V internal block JSON + external markdown (TASK-0059); ⌘Z/⌘⇧Z/⌘Y undo/redo
(TASK-0058); ⌘B/I/U + ⌘⇧S strikethrough + ⌘E code (TASK-0048 marks); ⌘K command
palette (Phase-3 palette, wired here); ⌘⇧1/2/3/0 heading/paragraph conversions;
⌘⇧8 toggle; ⌘⇧D duplicate; ⌘⇧C copy block link; ⌘⇧M move block (opens "Move to"
dialog, TASK-0055); ⌘⇧L link-on-selection / share-page-no-selection; / slash menu
(TASK-0054); ? in editor opens the shortcuts help overlay. Database-cell shortcuts
(Enter/Tab commit, ⌘Enter open record) are Phase 5 — this task registers the
shortcuts registry so Phase 5 adds cell-mode entries without refactoring.
Requirements
- R1.
packages/editor/src/shortcuts/registry.ts: declarative table{key, mods, mode: 'editor'|'global'|'database-cell', enabled, run(ctx)}— the single source for the help overlay, the editor keymap, and the E2E matrix (one table, three consumers); default table matches the §10 master table exactly. - R2.
packages/editor/src/shortcuts/keymap.ts: ProseMirror keymap plugin built from the registry; editor-scoped keys never fire when an overlay (slash, menu, palette, dialog) owns the keyboard (overlaykeydowncapture returns true); global keys (⌘K,n,g h/i/f/t, ⌘⇧F, ⌘⇧I, ⌘⇧L-page) dispatched byapps/web/components/shortcuts/GlobalShortcuts.tsx. - R3. Implement each behavior (reusing existing commands where present):
Enter→commands/split(list/toggle-aware from TASK-0049);Shift+Enter→ hard line break inside the block (rendered as a line break, part ofcontent.textvia a\nin the text run — codec round-trips it);Backspaceat start → merge with previous (inherit type); empty heading → demotion chain h1→h2→h3→paragraph (asserted in E2E); at top of page, Backspace in first block does nothing;Deleteat block end → merge next block upward (text concatenation with a space for text blocks, marks preserved);Tab/Shift+Tab→ TASK-0049 indent/outdent;Tabinside a table cell (Phase 5) moves focus — registry entry reserved;- arrows: caret navigation across blocks (up at line 1 col 0 → previous block end; down at block end → next block start; at page edges, scroll the pane); inside columns: left/right at column edges cross columns at the nearest row (functional-requirements §4.3);
⌘A×3 ladder (text in block → whole block incl. children → all blocks);⌘C/X/V→ TASK-0059 clipboard (this task wires the keys, 0059 supplies the handler);⌘Z/⌘⇧Z/⌘Y→ TASK-0058 history;⌘B/I/U,⌘⇧S,⌘E→ marks;⌘⇧1/2/3/0→ turn into heading 1/2/3/paragraph (works on a block selection too);⌘⇧8→ toggle;⌘⇧D→ duplicate (TASK-0055 action);⌘⇧C→ copy block link;⌘⇧M→ open "Move to" dialog;⌘K→ command palette (Phase-3components/command-palette);?(editor focus, no text selected) → shortcuts help overlay (generated from the registry table, grouped, dismiss onEsc/click,role=dialog);Escalways closes the top-most overlay (ux §10 rule).
- R4.
apps/web/components/shortcuts/ShortcutsHelp.tsx: help overlay rendered from the registry (single source of truth — no hand-maintained duplication). - R5. No browser-default collisions: the only captured sequences are the ⌘/Ctrl+letter set,
/,?, andn(only when the editor is focused and the target is not an input/textarea/contenteditable other than the editor).
Acceptance Criteria
- [ ] E2E (demotion chain): type "A" in heading 1, Backspace on the now-empty heading → becomes heading 2 with caret; repeat → heading 3 → paragraph; text "A" is preserved at each step.
- [ ] E2E (merge): two paragraphs "foo" + "bar"; caret at start of "bar", Backspace → single paragraph "foobar" (or "foo bar" for text blocks with marks boundary — assert the documented join rule); marks from both survive.
- [ ] E2E (⌘A ladder): first press selects the block's text, second the block (children included — visible selection style), third all blocks (asserted via selection state + DOM classes).
- [ ] E2E (arrows across columns): 2-column layout, caret at column-1 line-1 start, Left → caret lands in column-2 at the same row (or nearest); verified with 3-column layout.
- [ ]
?overlay lists every enabled shortcut from the registry (DOM count matches registry length);Esccloses and returns focus to the editor caret position. - [ ] With the slash menu open,
⌘Bdoes not toggle bold (overlay owns keyboard); with a date picker open, editor keys do not fire. - [ ] Unit: registry table matches the ux-requirements §10 matrix (snapshot test keyed on the doc text — a drift in the doc fails the test until the table is updated intentionally).
Backend Changes
- None.
Frontend Changes
packages/editor/src/shortcuts/{registry,keymap}.ts;apps/web/components/shortcuts/{GlobalShortcuts,ShortcutsHelp}.tsx; mark/turn-into/duplicate/move-to wiring reuses TASK-0048/0055 commands.
Database Changes
- None.
API Changes
- None.
Tests
- Unit: keymap dispatch (overlay suppression, input-target exclusion), demotion/merge command unit tests (fixtures incl. marks), arrow-navigation state machine (block graph fixtures incl. columns/toggles).
- Integration: none required (client-only; the ops it produces are covered by TASK-0047/0049 autosave tests).
- E2E (TASK-0062 suite): the shortcut scenarios above — the "backspace demotion" and "arrows cross-block" exit-criterion scenarios live here.
Security
- No new network surface; shortcuts can only trigger existing permission-checked actions (duplicate/move-to go through the same ops);
?and the palette expose no privileged commands in Phase 4 (Ask AI commands in the palette are Phase-12-gated).
Documentation
ShortcutsHelpis the living doc;packages/editor/README.mdlinks the registry as the canonical table; note the Phase-5 database-cell entries.
Dependencies
- TASK-0047 (keymap host), TASK-0048 (marks/turn-into), TASK-0049 (indent/split/merge), TASK-0054 (slash trigger
/), TASK-0055 (duplicate/move-to/copy-link), Phase-3 command palette.
Definition of Done
- Frontend implemented; no migration/API change; authorization inherited; error states (N/A — keys are no-ops when preconditions fail, e.g. ⌘⇧1 on a code block is a no-op with a toast); empty states (N/A); mobile (touch keyboards: software keyboard fires the same keymap; on-screen ⌘ row not required in Phase 4 — menu equivalents exist); accessibility (every shortcut has a menu equivalent — verified by a registry lint: each entry has
menuPath); unit + E2E; docs; logging (N/A); no audit.
TASK-0058
Title
Undo/redo (Tiptap history, limit 100, save-on-settle, autosave integration)
Objective
Implement undo/redo as a block-operation history of 100 steps built on Tiptap's history extension, with per-settle points wired into the autosave pipeline so the user can never undo into an already-persisted inconsistent state, and durable recovery via block/page versions.
Background
PR-043 (P0). Spec (functional-requirements §4.3): "Undo/redo (block-operation history,
in-memory 100 steps; versions are the durable history)"; ⌘Z / ⌘⇧Z / ⌘Y.
Tiptap @tiptap/extension-history provides a ProseMirror transaction history
(historyLimit); we configure historyLimit: 100 and treat each settled autosave
flush as a history "settle" — undo past a persisted boundary is still allowed in-memory
within the session, but the next autosave must emit the delta (ops are diffed against
the last persisted state, never against "what we think is in the DB"). Block versions
(block_versions, page_versions) are the durable, cross-session history (Phase 3
snapshots) — Phase 4 does not add new version snapshots per keystroke (versions are
scheduled/named, Phase 3 contract).
Requirements
- R1.
packages/editor/src/undo.ts: configure the history extension (historyLimit: 100, newGroupDelay 500 ms); exposecanUndo/canRedoto the top bar (enabled/disabled icons) and to the?overlay state line ("History: 12/47"). - R2. Coalescing: consecutive text edits in the same block within 500 ms = one undo step; a drag/move = one step; a slash-command insertion = one step; paste of a multi-block range = one step (TASK-0059 marks the whole transform as one group).
- R3. Save-on-settle: after each undo/redo transaction, the autosave pipeline (TASK-0047) diffs the doc against
lastPersistedBlocks(not against the pre-undo doc) and emits the minimal op set; undo-then-reload must reproduce the same persisted state as the pre-undo sequence (E2E property). - R4. Conflict safety: if the doc was modified by a remote source (Phase 7 Yjs; Phase 4: a second tab via REST polling of
page.updatedis out of scope — the conflict path is the TASK-0047409rebase), history groups are re-based: undo after a rebase never resurrects the stale version (the rebased state becomes the new history root —history.reset()on rebase). - R5. Redo stack clears on any new edit after an undo (standard), including after a remote rebase.
- R6. History is per-page-session (the LRU of ≤ 3 live editor sessions from frontend-architecture §2.2 keeps each session's history in memory); navigating away and back within the LRU keeps history (instant back/forward); beyond the LRU, history is empty (durable history = versions).
Acceptance Criteria
- [ ] E2E: perform 20 mixed operations (type, turn-into, drag, paste, delete) →
⌘Z×20 reverts each in reverse order (assert intermediate states at steps 10 and 20);⌘⇧Z×5 restores forward. - [ ] 101st step: after 101 coalesced groups, the oldest group is no longer undoable (
canUndofalse at depth 101) — limit 100 enforced. - [ ] E2E (save-on-settle): type 5 chars (1 group) → wait for save →
⌘Z→ wait for save → reload → the page shows the state without the 5 chars (the delta persisted correctly, no resurrection of old text). - [ ] Two-tab scenario (Phase-4 REST level): tab A edits + saves; tab B (open before the edit, stale) edits the same block and saves → 409 → rebase → tab B's
⌘Zundoes only tab B's edit, never resurrecting tab A's text. - [ ] Paste of 8 blocks = exactly one undo step (step count asserted via the history API).
- [ ] History survives LRU back/forward within one session; after 3 other pages opened (LRU eviction), history is empty and the top-bar icons are disabled.
Backend Changes
- None (autosave batch +
409semantics from TASK-0047; no new version snapshots).
Frontend Changes
packages/editor/src/undo.ts(history config, rebase-on-conflict hook, group markers for slash/paste/move); integration points inautosave.ts(delta vslastPersistedBlocks) andeditor.ts(LRU session history).
Database Changes
- None.
API Changes
- None.
Tests
- Unit: coalescing window (fake timers), limit enforcement (101 groups), delta-vs-persisted diffing (fixtures: undo after partial save emits exactly the inverse ops), rebase-reset (history depth after a 409 rebase).
- Integration: the save-on-settle round-trip against the real API (two clients, version CAS).
- E2E (TASK-0062 suite): the "undo/redo across 20 ops" exit-criterion scenario.
Security
- No new surface; undo can only re-emit ops that re-pass the same permission validation; rebase never applies a stale
version(CAS on the fresh version from409details).
Documentation
packages/editor/README.md: history model (coalescing rules, settle semantics, LRU scope) — Phase 7 reads this to bind YjsdoTransformgroups to the same model.
Dependencies
- TASK-0047 (autosave pipeline, conflict rebase), TASK-0054/0055/0056/0059 (group markers on their transforms).
Definition of Done
- Frontend implemented; no migration/API change; authorization inherited; error states (N/A); loading states (N/A); empty state (fresh page: both icons disabled + "History: 0/0" in
?overlay); mobile (long-press ⌘ equivalents: on-screen undo/redo buttons in the mobile editor toolbar — added toapps/web/editormobile bar); accessibility (iconsaria-label="Undo last change"/ "Redo",aria-disabled); unit + integration + E2E; docs; logging (undo/redo depth + op count on rebase); no audit (versions cover durable history).
TASK-0059
Title
Copy/paste: internal application/x-factory-blocks JSON + markdown/HTML import, md/html export
Objective
Implement the clipboard contract: internal copy carries block JSON (content +
properties + subtree) under application/x-factory-blocks plus rendered markdown for
external apps; paste detects internal JSON → markdown → HTML → plain text (in that
order) and converts to blocks via packages/editor/src/clipboard; page export
produces Markdown/HTML from the page context menu (Phase-3 menu, export action wired
here).
Background
PR-043. frontend-architecture §3.4 rule 4: "Copy/paste into the app parses external
HTML/Markdown via the importer into block ops; copy out serializes the selection to
Markdown/HTML at the boundary only." functional-requirements §4.3: copy selected block
→ clipboard holds (a) rendered markdown for external apps and (b) internal JSON for
cross-block moves; paste prefers internal JSON, falls back to markdown/HTML/RTF/plain;
"x " paste prefix converts to a checked todo; markdown import covers headings,
lists, todos, quotes, code fences, bold/italic/links/images (md links → text links;
md images → pending-upload image blocks); HTML import is a sanitized fallback
(textarea-based parse + allowlist: headings, lists, paragraphs, quotes, pre/code,
strong/em/u/s/code/a[http(s)]/br/hr/img[src http(s)]/table-basic); RTF is out of
scope (degrades to plain text). Page export (Phase-3 page menu "Export ▸ Markdown/HTML")
serializes the whole block tree; PDF export is Phase 15 (button disabled with hint).
Requirements
- R1.
packages/editor/src/clipboard/serialize.ts:serializeSelection(doc, from, to)→{factory: FactoryClipboardPayload, markdown: string, html: string}. Factory payload (v1):{version: 1, workspaceId, blocks: [{type, content, properties, children: [recursive]}]}— no ids (ids are minted on paste), fractional positions computed on insert (paste = insert ops after the caret/selection). - R2.
packages/editor/src/clipboard/parse.ts(the parser required by the phase spec):parseClipboard(event, ctx)decision order — (1)application/x-factory-blockspresent + valid (ZodFactoryClipboardSchema) → blocks directly; (2) text/plain looks like markdown (heuristic: lines starting with#,- [ ], ```,>,1.) → markdown parser; (3) text/html non-trivial (contains<p|ul|ol|h[1-6]|pre|blockquote|hr|table|img) → HTML sanitizer+parser; (4) plain text → split on\ninto paragraphs ("x "/"[ ] "prefixes → todo per functional-requirements §4.2 row 9). - R3. Markdown parser (
clipboard/markdown.ts): headings 1–3, nested bullet/numbered lists (indent 2/4 spaces), todos (- [ ]/- [x]), quotes (>, citation on— namesecond line), code fences (```lang → code block, language default text), inline: bold/italic/strikethrough/code/links/images (images → image block in pending upload state if local file path, or bookmark-style url image if http), horizontal rule → divider, blank line → nothing. Unknown syntax degrades to plain text (never dropped). - R4. HTML parser (
clipboard/html.ts): parse into a detached DOM, walk with an allowlist (whitelist tags/attrs above; everything else → text or drop;script/style/iframe/object/embedalways dropped; URLs scheme-checkedhttp/https/mailto); table → statictableblock only if the table is Phase-5-unblocked, else degrade to paragraphs joined by " | " (record in notes); nested lists → list blocks with nesting. - R5. Paste behavior: replace the current selection with the pasted blocks (one history group, TASK-0058); caret after the last pasted block; external pastes never carry over
id/position/fileIdre-attachments (imagesrcpastes become new image blocks referencing the remote URL as abookmark? no — they become image blocks withcontent.urlpending upload only when the URL is same-origin/BFF; otherwise they render as a remote image via the BFF proxyapps/web/app/api/preview/with a cache key — record the chosen behavior: remote md images are stored asimageblocks withcontent.srcUrl(new optional field) rendered through the proxy; Phase-9 adds the import-to-storage action "Save to files" in the block menu). - R6. Export:
packages/editor/src/clipboard/export.tsexportPage(blocks, fmt: 'markdown'|'html')— markdown: full document (headings, lists, todos, quotes, code fences, divider---, images![]()by file id placeholder[image: fileId], callout as quote with emoji prefix, database block as[database: <title> <id>]); html: semantic standalone document (doctype, tokens-free inline styles minimal, same content mapping); called from the Phase-3 page menu "Export ▸" (wired inapps/web/components/dialogs/ExportDialog.tsx→ downloads via Blob) and the command palette "Export current page". - R7. Copy of multi-block selections (TASK-0055) serializes the subtree including containers (columns → flatten with a comment in markdown
<!-- column -->, html with flex divs).
Acceptance Criteria
- [ ] E2E (internal round-trip): select 3 blocks (heading + todo + bulleted list with a nested child) → ⌘C → ⌘V into another page → identical structure (types, content, marks, nesting) with new block ids (asserted via API) and valid fractional positions.
- [ ] Unit (markdown): a 40-line fixture (headings, 3-level nested list, todos, quote with citation, python fence, inline marks, hr) parses to the exact expected block tree (snapshot); unknown
~~~fence degrades to code/text without dropping. - [ ] Unit (HTML sanitization): fixtures with
<script>alert(1)</script>,<img onerror=...>,<a href="javascript:...">,<iframe>,<div onclick>— all dangerous content dropped or neutralized; allowed content preserved (assert output tree). - [ ] E2E (external markdown): paste a real README excerpt from the system clipboard → expected blocks (headings/lists/code) appear; paste plain text with 5 lines → 5 paragraphs.
- [ ] E2E (export): page with 20 blocks of all Phase-4 types → Export Markdown → file parses back (via the same markdown parser) to a tree isomorphic in type/content (asserted in-test); Export HTML renders the content in a browser (Playwright frame check on headings/lists/code).
- [ ] Copy to an external app (Playwright
clipboard→ notepad/textarea) contains the markdown, not the internal JSON.
Backend Changes
apps/web/app/api/preview/[kind]/[fileId]/route.ts— BFF proxy for remote/external imagesrcUrlrendering (auth: VIEW on the page that references it; cache 5 min). No API module changes.
Frontend Changes
packages/editor/src/clipboard/{serialize,parse,markdown,html,export}.ts;apps/web/components/dialogs/ExportDialog.tsx(wire Phase-3 menu entry);apps/web/app/api/preview/...route handler.
Database Changes
- None (
imagecontent gains an optionalsrcUrlfield — additive toBlockContentSchema.image; no migration).
API Changes
@factory/sharedBlockContentSchema.imageextended with optionalsrcUrl: z.string().url()(additive, backward-compatible); export is client-side (no endpoint in Phase 4).
Tests
- Unit: serialize/parse round-trip (property-based over random block forests), markdown golden fixtures, HTML sanitizer matrix (≥ 20 hostile fixtures), plain-text splitter, export isomorphism (md export → md parse → structural equality modulo ids/positions).
- Integration: BFF preview proxy auth matrix (member/guest/other-tenant 404); export of a page containing a
DATABASEplaceholder block (export text includes the database id reference). - E2E (TASK-0062 suite): the "copy/paste between 2 pages" exit-criterion scenario + export scenario.
Security
- Paste is the main XSS vector: HTML parsed only in a detached DOM, strict tag/attr allowlist, no event-handler attributes ever, URL schemes allowlisted,
javascript:/data:/vbscript:rejected; markdown images with local paths rejected; internal JSON paste re-validates every block againstBlockContentSchemabefore insertion (a crafted clipboard payload cannot smuggle asynced_blockor unknown type — closed enum); the BFF proxy enforces page VIEW before streaming any externalsrcUrl(no SSRF: proxy rejects private IP ranges like the TASK-0053 og guard); copy never includes secrets (ids are fine,fileIdincluded — harmless, download is permission-gated separately).
Documentation
packages/editor/README.mdclipboard section: payload v1 schema + versioning rule (v2 requires a parser that understands v1 — semver onversion).
Dependencies
- TASK-0047 (codec/autosave for paste-insert ops), TASK-0055 (selection model), TASK-0051 (image upload for pasted local files), Phase-3 page menu + command palette (export entry points).
Definition of Done
- Frontend implemented (+ BFF proxy); additive shared schema (no migration); API validation (paste payloads Zod-validated; proxy auth); authorization (proxy VIEW check); error states (unparseable clipboard → plain-text fallback toast "Pasted as text"); loading states (remote image proxy skeleton); empty state (copy of empty selection = no-op); mobile (copy/paste via system long-press menu uses the same handlers); accessibility (paste results announced via live region "Pasted 5 blocks"); unit + integration + E2E; docs; logging (paste source kind + block count:
clipboard.paste {source: 'factory'|'markdown'|'html'|'text', blocks: n}); audit: paste of a new remote-image reference is a normal block op (covered).
TASK-0060
Title
Large-page virtualization (> 200 blocks: windowed rendering, incremental mount, scroll performance)
Objective
Make pages with 100k+ blocks usable: windowed DOM rendering of the block list so only ~60 rows are mounted at a time (frontend-architecture §8), incremental mount on scroll, lazy rendering of heavy blocks, and cursor-based server fetching for blocks beyond the first window — meeting the "100k-block page tree loads in < 2 s (virtualized)" target carried into Phase 4's large-page exit behavior.
Background
frontend-architecture §8: "the Yjs doc holds the whole tree in memory (needed for
CRDT), but the DOM renders virtualized rows; a 100k-block page keeps ~60 rows
mounted; heavy block types (code highlight, KaTeX, charts) render in Web Workers or
lazy-IntersectionObserver subtrees." Phase 7 moves the in-memory doc to Yjs; Phase 4
does the same thing with the ProseMirror doc as the in-memory source of truth (the
virtualizer reads node geometry from ProseMirror, not from a separate data array).
Rules: virtualization is a renderer concern; rAF-coalesced scroll updates;
transforms only (no layout thrash); measured target 100k-block interaction < 50 ms,
reorder < 16 ms. Server side: GET /v1/pages/:id/blocks already cursor-paginates the
top level (blocks API §3, default 100, max 500) — the canvas fetches the first page
eagerly and deeper levels lazily.
Requirements
- R1.
packages/editor/src/virtual/virtualizer.ts: a@tanstack/virtual-core(non-React) virtualizer over the block rows of the current document (top-level rows + rows of open toggles/columns that are "visible context"): row count = document blocks (not the API pages), row height measured (ResizeObserver per mounted row, fallback 28 px), overscan 10, scroll root = the content pane; ProseMirror renders all nodes (it must — selection/history operate on the full doc) but non-mounted rows havecontenteditable=false+visibility:hiddenvia a ProseMirror plugin (DOM still present for PM's sake — the perf win is style/layout cost, JS event cost, and paint; document this honestly in the README: ProseMirror keeps DOM nodes; the virtualizer disables layout work and event wiring for off-screen rows). - R2. Incremental mount: on first render only the viewport window is "active" (event handlers attached,
contenteditabletrue, images loaded); scrolling in activates the next windows (rAF-coalesced, activation is idempotent); scrolling back out deactivates after 5 s grace (keeps images loaded — no re-fetch flicker). - R3. Heavy-block laziness: code blocks render plain text until first activation (highlight stub is cheap anyway; Phase-9 Shiki runs in a Worker on activation); image blocks use
loading="lazy"+IntersectionObserver(never decode off-screen); embed iframes mount only on first activation (then stay mounted). - R4. Server-side window:
useBlocks(TASK-0047) fetches top-levellimit: 200first; the canvas shows a "Loading more blocks…" row at the bottom sentinel (IntersectionObserver) and appends the next cursor page as the user nears the end; the ProseMirror doc grows incrementally (doc rebuild = re-hydrate from the blocks array, history preserved — rebuild is a single transaction group). - R5. Scroll performance: scroll updates coalesced with rAF; the virtualizer writes only
transform: translateYto the window container (positioned rows), nevertop/heightper row during scroll; no React re-render per scroll frame (the editor is DOM-managed by ProseMirror; React only owns the sentinel + toolbar). - R6. Memory: a 100k-block page's ProseMirror doc is the known cost (frontend-architecture §11 budget < 500 MB); heavy
content.code> 50 KB is kept in the doc but its DOM<pre>content is truncated to 5 KB until activated (full text restored on activation — round-trip safe because the doc is the source of truth, the DOM truncation is view-layer only; unit-tested invariant "DOM truncation never mutates doc"). - R7. Find/scroll-to-block (deep link
#blockId, TASK-0055): the virtualizer can activate any row on demand (virtualizer.scrollToIndex) — deep-link scroll works on 100k-block pages.
Acceptance Criteria
- [ ] Perf (CI,
tests/performance/editor-100k.js): seed a 100k-paragraph page (seed script), open it — first meaningful paint < 2 s (metadata + first window), DOM row count ≤ 120 (window + overscan), total JS heap < 500 MB, scrolling at 5000 px/s keeps frame drops < 5 % (Playwright CDPPerformance.getMetricsover a 5 s scripted scroll). - [ ] E2E: on a 5,000-block page,
#blockIddeep link to the 4,800th block scrolls to it in < 1 s and the block is active (editable). - [ ] E2E: type in a block at the bottom sentinel area while blocks stream in (cursor fetch) — typing never loses focus or caret (rAF activation doesn't steal focus); a 300-block page fully loaded via 2 sentinel fetches (network assertion: 2 ×
GET blockswith cursors). - [ ] E2E: reorder a block at row 4,800 of the 5,000-block page (drag or keyboard move) — local update < 50 ms (p95 of 20 samples via performance marks).
- [ ] Memory: opening 3 pages of 50k blocks each in the LRU stays < 1.5 GB (Playwright heap sampling); deactivating off-screen rows releases image decodes (verify via
Image.decodecount). - [ ]
prefers-reduced-motion: scrolling never smooth-scrolls; virtualizer jumps.
Backend Changes
- None (
GET /v1/pages/:id/blockscursor pagination already exists — verifylimitmax 500 and stable cursor ordering over(position)per blocks API §3).
Frontend Changes
packages/editor/src/virtual/{virtualizer,activation}.ts(+ ProseMirror pluginvirtual-plugin.ts),apps/web/hooks/useBlocks.ts(cursor streaming + sentinel), heavy-block lazy renderers in the node implementations (TASK-0050/0051/0053 hooks).
Database Changes
- None.
API Changes
- None new (consumed: blocks cursor pagination).
Tests
- Unit: virtualizer math (window indices for 100k rows, overscan, variable heights from ResizeObserver fixtures), activation idempotency, DOM-truncation-invariant property test (truncated DOM never changes doc), cursor-append doc-rebuild history preservation.
- Integration: cursor pagination stability under concurrent writes (blocks inserted mid-stream → cursor doesn't skip/duplicate — server cursor is position-based, verify).
- E2E/perf: the five acceptance scenarios above (Playwright + CDP metrics; the 100k seed via
prisma/seed.ts--big-pageflag, reusing the Phase-3 seed infrastructure).
Security
- No new surface (same blocks API auth); deep-link scroll does not bypass permissions (the blocks fetch is permission-gated; a 404 on the page ends the flow before any block data loads).
Documentation
packages/editor/README.mdvirtualization section (what is virtualized, the honest ProseMirror-DOM note in R1, the 50 KB code-truncation view-layer rule).
Dependencies
- TASK-0047 (doc model, useBlocks), TASK-0050/0051/0053 (heavy block renderers), Phase-3 seed script,
@tanstack/virtual-core(dependency justification: single virtualization lib mandated by frontend-architecture §8 — no new transitive cost beyond the mandated one).
Definition of Done
- Frontend implemented; no migration/API change; authorization inherited; error states (blocks fetch failure mid-stream → sentinel shows retry, no partial caret loss); loading states (sentinel spinner, per-row activation skeletons for images); empty state (N/A); mobile (same virtualizer; touch scroll); accessibility (virtualized rows keep stable
aria-rowindex-equivalent ordering announcements; deep-link focus lands on an active editable row — never on a hidden row; screen-reader virtual list mode:aria-relevantlive region announces "Showing rows 4700–4820 of 100000"); unit + integration + perf + E2E; docs; logging (virtualizer stats: row count, window, heap) in devtools only — no per-scroll logging; no audit.
TASK-0061
Title
Accessibility: keyboard-only editing path, ARIA roles, focus management, reduced motion, drag alternatives
Objective
Bring the Phase-4 editor to WCAG 2.2 AA (PR-044, PR-165): a complete keyboard-only
editing path for every mouse operation, ARIA roles/labels on all blocks and overlays,
focus management for dialogs/menus/caret restoration, prefers-reduced-motion support,
and non-drag alternatives for every drag — verified by axe-core in the E2E suite.
Background
ux-requirements §12 is the binding matrix (block editor, slash/menu, palette, dialogs,
toasts, motion, target size); frontend-architecture §10 lists the approach (keyboard
paths, non-drag alternatives, ARIA semantics, focus management, live regions, motion).
Phase 4 exit criterion: "WAI-ARIA paths for all mouse operations." The individual tasks
above built in per-feature a11y as they went (handles role=button, menus
role=menu, spacer slider, etc.); this task is the system pass: the keyboard-only
E2E journey, live-region inventory, focus-trap audit, contrast/token verification,
reduced-motion audit, and the axe gate.
Requirements
- R1. Keyboard-only journey (E2E, no pointer ever): create page → type → slash menu (arrows/Enter) → turn into todo → check via keyboard (Space on the checkbox,
role=checkbox+aria-checked) → create columns via slash → resize column with keyboard handle (arrows) → select 3 blocks via keyboard (shift+arrows extend selection — implemented here:shift+↑/↓extends the block selection from the caret block) → duplicate (⌘⇧D) → move to another page via ⌘⇧M → copy link (⌘⇧C) → comment (via context menu opened from keyboard on the block:Shift+F10or F2 when the block handle is focused) → delete → undo ×3 → export markdown. Every step has a focus target and an announcement. - R2. Selection by keyboard:
shift+↑/↓extends contiguous sibling selection (mirrors shift-click, TASK-0056);escclears selection; selection count announced once ("3 blocks selected",role=status); the floating toolbar for a selection is reachable viaTabfrom the selected block (and its buttons work) — no mouse-only floating UI. - R3. ARIA inventory (asserted by a unit test that walks the rendered DOM of a fixture page containing every Phase-4 block type):
- text blocks: real
p/h1–h3/blockquote; the contenteditable region exposesrole=textbox+aria-multilinesemantics (ProseMirror editor role); - lists
role=list/listitem; todo checkboxrole=checkbox+aria-checked; togglearia-expandedon the heading row; - divider
role=separator; spacer handlerole=slider+aria-valuenow/valuetext("32 pixels"); - code block:
role=region+aria-label="Code block, python"+aria-roledescription="code"; language select is a realrole=combobox; - image:
alttext in the DOM (prompted at upload, TASK-0051), "Decorative" →alt=""; - file card / bookmark:
role=link+ full descriptivearia-label; embed:aria-labelfrom resolved title; - drag handles:
role=button+aria-label="Move <type> block"(TASK-0056); - slash menu
role=combobox+listbox; context menusrole=menu/menuitem; paletterole=dialog; toastsrole=status/alert; - live regions (polite): save state ("Saved", "Offline — queued" in Phase 7), selection count, paste result, presence (Phase 7), drop confirmation; all icon-only buttons have
aria-label; decorative iconsaria-hidden.
- text blocks: real
- R4. Focus management: every overlay (slash, menus, dialogs, picker) traps focus (Radix
focusScopeinpackages/ui),Esc/confirm returns focus to the trigger; caret restoration after any editor-adjacent dialog (icon, color, language, Move-to) returns the caret to its exact offset (TASK-0055/0059 covered cases — audited here);skip to contentlink is the first tab stop in the page canvas; focus-visible ring (2 px accent, offset 2 px) on every focusable, never removed,:focus-visibleonly. - R5. Reduced motion:
prefers-reduced-motionzeroes all animations/transitions (tokens--duration-*→ 0 via media query inpackages/ui), disables drag auto-scroll inertia (jump instead, TASK-0056 R6), disables the drop animation (instant placement), disables the presence/cursor animation (Phase 7 reads the same flag); a manual toggle in Settings (Phase-3 settings screen) writesuseUiStateStore.reducedMotionand applies the samedata-reduced-motionattribute. - R6. Contrast & tokens: no hard-coded hex in
apps/webeditor code (existing ESLint rule extended topackages/editor); a token snapshot test asserts AA contrast (4.5:1 text / 3:1 UI) for every token pair used in the editor in both light and dark themes (extending the existing token test inpackages/ui); status is never conveyed by color alone (todo has check + strikethrough, save state has icon + text). - R7. Touch targets: below 1024 px all interactive editor controls ≥ 44 × 44 px, adjacent targets ≥ 8 px apart (measured by a Playwright bounding-box audit on the 375 px canvas).
Acceptance Criteria
- [ ] E2E keyboard-only journey (R1) passes end-to-end with zero pointer events (Playwright
page.keyboard/page.locator.focusonly; a test hook fails the suite on anypage.mousecall in the spec). - [ ] axe-core scan of the full-block fixture page (all 16 block types + open slash menu + open context menu + palette): 0 critical, 0 serious violations (the Phase-4/5 DoD gate).
- [ ] Unit ARIA inventory test: the fixture DOM contains every required role/label from R3 (fails on any missing label; this test is the regression guard for future node types — a new node without a11y attrs fails CI).
- [ ]
prefers-reduced-motionemulation: drag auto-scroll jumps (no smooth), drop is instant, all CSS animations compute toanimation-duration: 0s(asserted viagetComputedStyleon 5 sampled elements). - [ ] Focus audit: open each overlay type, Tab 20 times — focus never leaves the overlay; Esc returns focus to the exact trigger element (element identity assertion); caret offset restored after color-picker close (character-level assertion).
- [ ] 375 px viewport: bounding-box audit — all editor interactive controls ≥ 44 px, spacing ≥ 8 px (gutter handle included — it is 44 × 44 with 8 px gap to the add-below button).
- [ ] Token contrast test: all editor token pairs pass AA in both themes; ESLint
no-restricted-syntax(hex literals) extended topackages/editorand passing.
Backend Changes
- None.
Frontend Changes
apps/web/editor/a11y/(fixture page builder shared by the axe E2E + unit inventory),packages/ui(token test extension,data-reduced-motionvariant, focus-scope audit fixes in menus/dialogs),packages/editor(shift+arrow selection insrc/commands/selection.ts, live-region helperssrc/a11y/live.ts, reduced-motion hooks in dnd/virtualization),apps/web/components/settings(manual reduced-motion toggle).
Database Changes
- None.
API Changes
- None.
Tests
- E2E: keyboard-only journey; axe gate (full fixture + the three MVP surfaces: page canvas, share page — Phase 3 — and table view — Phase 5 — this task adds the page-canvas entry to the gate); reduced-motion emulation; focus audit; touch-target audit.
- Unit: ARIA inventory (DOM walk), live-region message catalog, contrast token snapshot, reduced-motion flag propagation.
Security
- None new (screen-reader text must not leak content across permissions: the fixture/announcement text uses only data the user can already view).
Documentation
docs/product/ux-requirements.md§12 marked verified-for-Phase-4 (checkbox);packages/editor/README.mda11y section (live-region catalog, keyboard map, new-node a11y checklist for future contributors).
Dependencies
- All of TASK-0047…0060 (every interaction must exist to be made accessible),
packages/uitoken test infrastructure, Phase-3 settings screen.
Definition of Done
- Frontend implemented; no migration/API change; authorization inherited; error/loading/empty states audited for announcements (error toasts
role=alert); mobile (touch-target audit at 375 px); accessibility is the deliverable — verified by axe (0 critical/serious), keyboard-only E2E, ARIA inventory, contrast tokens; unit + E2E; docs; logging (N/A); no audit.
TASK-0062
Title
E2E editor suite (tests/e2e/editor.spec.ts) — Phase-4 exit verification
Objective
Author the Playwright E2E suite tests/e2e/editor.spec.ts covering every Phase-4
interaction named in the ROADMAP exit criteria, wire it into the CI contract, and add
the performance probes (reorder < 16 ms local; 100k-page budgets) so the phase-exit
claim is machine-verified, not asserted.
Background
AGENTS.md CI contract: install → lint → typecheck → unit → integration → build →
security scan → E2E; Playwright lives in tests/e2e. The Phase-4 exit criteria are
the test program: "all Phase-4 interactions E2E-tested; reorder latency < 16 ms
locally; ordering survives reload; WAI-ARIA paths for all mouse operations." The
individual tasks specified their E2E scenarios; this task is the suite that runs them
against a real docker-compose environment (postgres, redis, minio from Phase 0) with
seeded fixtures (two users for permission checks, a 5,000-block page, a 100k-block
page behind a --big-page flag, a database created via slash for the /database E2E).
Requirements
- R1.
tests/e2e/editor.spec.ts— scenario list (each scenario = the acceptance criteria of the owning task, run here for the first time):- create-every-block: all 16 Phase-4 block types created via slash, each with type-specific content; reload; assert every block persisted with the correct
type/content/properties(API + DOM). - slash-search: fuzzy queries ("h1", "bulleted", "cal", "emb") return the right groups in the right order; keyboard navigation inserts the right type; disabled commands not selectable.
- slash-database:
/database→ property picker → create "Projects" →databases/database_properties/database_viewsrows exist;DATABASEblock in the page;/boardvariant creates with BOARD initial view. - drag-reorder-indicator: drag 2→between 5-6; indicator visible at each stage (line vs box assertion); fractional position strictly between neighbors; multi-block range drag (4 blocks) preserves order; column cross-container drag; cross-page drag onto sidebar row (single batch on the wire).
- backspace-demotion: h1→h2→h3→paragraph chain on empty headings with text preserved; Backspace merge of two paragraphs (marks preserved).
- undo-redo-20-ops: 20 mixed ops; undo all (intermediate state asserted at 10); redo 5; history depth ≤ 100; save-on-settle reload property (undo state survives reload).
- copy-paste-2-pages: select 3 blocks (heading/todo/nested list) on page A → ⌘C → navigate to page B → ⌘V → structural equality (new ids, valid positions); markdown paste from a real clipboard fixture; internal JSON never leaks to the system clipboard (external textarea contains markdown).
- reload-persistence: after a session of 15 mixed mutations + 3 drags, reload → identical block tree (ids, positions set, content, properties) — the "ordering survives reload" exit criterion.
- keyboard-only (from TASK-0061 R1): full journey, no pointer.
- a11y-axe: axe-core on the full-block fixture + open menus (0 critical/serious).
- deep-link:
#blockIdURL on a 5,000-block page scrolls to and focuses the block. - permissions: user B (VIEW only) opens the page — editor is readonly (no gutter handles, no slash on input, context menu shows only read actions); user C (non-member) gets 404.
- export: markdown export of the fixture page parses back to an isomorphic tree (in-test via the shared parser).
- create-every-block: all 16 Phase-4 block types created via slash, each with type-specific content; reload; assert every block persisted with the correct
- R2. Fixtures:
tests/e2e/fixtures/editor.ts— Playwright fixtures for two authed users (A: EDIT, B: VIEW), page seeds (empty, 20-block mixed, 5,000-block, 100k-block flag), network-spy helper (captures/v1/blocksbatch calls), clipboard helper (system clipboard via Playwright), reduced-motion + 375 px viewport presets. - R3. CI wiring:
turbo.jsonpipelinee2estage runsplaywright test tests/e2e/editor.spec.tsafter the security scan; the spec is tagged@phase4and included in the default E2E job; performance probes run in the same job but as a separate project (perf) with--project=perfand relaxed timing (2× budgets). - R4. Performance probes (Playwright CDP,
tests/e2e/perf/):reorder-latency: on a 2,000-block page, 50 programmatic reorders (move op → autosave flush → DOM settle) viaperformance.mark— p50 < 16 ms, p95 < 50 ms (the ROADMAP "reorder latency < 16 ms locally" is read as p50 on CI hardware; record this interpretation in the file header);100k-page: first paint < 2 s, mounted rows ≤ 120, heap < 500 MB, scroll frame-drop < 5 % (from TASK-0060).
- R5. Flake policy: every test that touches timing (autosave 800 ms, polling) uses
await page.waitForResponse/state assertions instead ofwaitForTimeout; the whole suite must pass 3 consecutive CI runs before the phase is closed (record run hashes in the PR description).
Acceptance Criteria
- [ ]
pnpm test:e2e -- --grep @phase4runs all 13 scenarios green against docker-compose from a clean state (fresh DB, seeded). - [ ] The suite asserts the four exit criteria explicitly: (a) every interaction scenario passes; (b)
reorder-latencyprobe reports p50 < 16 ms (CI artifact JSON published); (c) scenario 8 proves ordering survives reload; (d) scenarios 9–10 prove WAI-ARIA/keyboard paths. - [ ] The
permissionsscenario proves readonly mode for VIEW and 404 for non-members (server-side enforcement visible in the UI). - [ ] CI contract green end-to-end on the PR that lands this file (lint → … → E2E), 3 consecutive runs, no
test.fixme, noexpect.softmasking, zerowaitForTimeoutin the suite (ESLint ruleno-playwright-timeoutsadded totests/e2e). - [ ] 100k-page probe passes its TASK-0060 budgets on CI (artifacts: heap + frame-drop JSON).
- [ ] The suite runs in < 10 minutes total (parallel 4 workers) — budget enforced by the turbo task timeout.
Backend Changes
- None (APIs under test are Phase 3/4 deliverables; the suite may reveal bugs — fix them in the owning module, not by weakening the test).
Frontend Changes
- Test-only:
tests/e2e/editor.spec.ts,tests/e2e/fixtures/editor.ts,tests/e2e/perf/{reorder-latency,100k-page}.spec.ts;prisma/seed.ts--big-pageflag (5,000 + 100k page generators, deterministic ids);package.jsonscripttest:e2e;turbo.jsone2epipeline entry.
Database Changes
- None (seed-only; no migration).
API Changes
- None.
Tests
- This task is the E2E suite; it also includes the two performance probe specs and the ESLint
no-playwright-timeoutsrule.
Security
- The suite includes the permission scenarios (R1.12) and a CSRF check (a state-changing block op with a cookie but without the
factory_csrftoken gets 403CSRF_VIOLATION— added as scenario 14 to the list above, per backend-architecture §3 guard chain).
Documentation
docs/tasks/phase-04-editor.mdphase-completion checklist updated with run hashes;tests/e2e/README.md(setup, fixtures, how to run a single scenario, flake policy).
Dependencies
- TASK-0047…0061 (all interactions exist), Phase 0 docker-compose + CI contract, Phase 3 (users, permissions, seed infra), Phase-5 create-database contract (scenario 3).
Definition of Done
- Backend verified (no changes expected); frontend: suite + fixtures + probes + CI wiring; no migration; API validation exercised by the suite (invalid-content 400s asserted in scenario 1's negative cases); authorization scenarios pass; error/loading/empty states exercised (scenario 1 includes the empty-page placeholder, scenario 12 the readonly loading state); mobile (375 px run of scenarios 1+8 as a preset project); accessibility gate (scenarios 9–10); unit + integration already green from owning tasks; E2E is this task; docs updated; logging (suite emits a timing artifact
e2e-report.jsonwith per-scenario durations for the 10-minute budget); audit events asserted in scenario 12 (VIEW user's denials appear in the audit log via the audit API).