Skip to content

Commit f00fea0

Browse files
committed
feat(new-deepnotes): Tiptap + Yjs page editor, RouterView remount key, and plan progress
1 parent 0478b8c commit f00fea0

6 files changed

Lines changed: 679 additions & 45 deletions

File tree

new-deepnotes/PLAN_PROGRESS.md

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Living checklist for the greenfield work described in [docs/RESTART_PLAN.md](../
1414
| **1** — Legacy repo hygiene | **Optional / n/a** | Parallel track only if still editing the old monorepo. |
1515
| **2** — Repo bootstrap | **Done** | Template DB integration test + CI `DATABASE_ADMIN_URL`; deploy doc: [docs/DEPLOY_CLOUDFLARE.md](./docs/DEPLOY_CLOUDFLARE.md). **`@deepnotes/web`:** Vitest + happy-dom + `@vue/test-utils`; `vite.config` uses `defineConfig` from `vitest/config`. Optional: Wrangler deploy job. |
1616
| **3** — REST + Drizzle features | **In progress** | Account + **2FA** complete. **Shipped:** slices [1](#pagesgroups-rest--slice-1)[5](#pagesgroups-rest--slice-5-privacy-private-re-key); [6](#pages-rest--slice-6-bump-backlinks-snapshots-deletion); [7 — page move](#pages-rest--slice-7-move--group-creation); [8 — create + `groupCreation`](#pagesgroups-rest--slice-8-create--groupcreation); **[slice 9 — membership + join flows](#pagesgroups-rest--slice-9-membership--join-invites--requests)** + **`GET …/members/detail`** ([`performGetGroupMembersDetail`](packages/session/src/group-main-and-members.ts)). **[Stripe / billing](#phase-3--stripe-billing--webhooks--account-hooks).** **[Slice 10 — collab Postgres bootstrap](#pages-rest--slice-10-collab-updates-rest):** `GET`/`POST …/collab-updates`. **Still ahead:** **collab + realtime WebSockets** (JWT upgrade, binary fan-out, optional Redis buffer like legacy) per [RESTART_PLAN §4.3](../docs/RESTART_PLAN.md). |
17-
| **4** — Client MVP | **In progress** | **Shipped:** [OpenAPI client](#phase-4--openapi-typed-client-bootstrap) + [routing + session UI](#phase-4--routing--session-ui) + [pages + editor + register](#phase-4--pages-list-editor--register) + **[collab decrypt + REST append](#phase-4--collab-decrypt--yjs--post-append)** + **[register crypto parity](#phase-4--register-crypto-parity)** + **[groups overview UI](#phase-4--groups-overview-thin-spa)** + **[notifications list + mark read](#phase-4--notifications-thin-spa)** + **[group members / invites admin UI](#phase-4--group-members--invites-admin-ui)** + **[group invite/join E2EE](#phase-4--group-invitejoin-e2ee-spa)** (send invite, accept invite landing, request join, accept join request with crypto). **Next:** Tiptap; **MSW/Playwright**; optional deterministic demo keyring for CI; optional notification **body** decrypt. **Phase 3** still: [collab + realtime WebSocket](#not-started-phase-3--realtime--collab-websocket). |
17+
| **4** — Client MVP | **In progress** | **Shipped:** [OpenAPI client](#phase-4--openapi-typed-client-bootstrap) + [routing + session UI](#phase-4--routing--session-ui) + [pages + editor + register](#phase-4--pages-list-editor--register) + **[collab decrypt + REST append](#phase-4--collab-decrypt--yjs--post-append)** + **[Tiptap + Yjs (SPA)](#phase-4--tiptap--yjs-spa)** + **[register crypto parity](#phase-4--register-crypto-parity)** + **[groups overview UI](#phase-4--groups-overview-thin-spa)** + **[notifications list + mark read](#phase-4--notifications-thin-spa)** + **[group members / invites admin UI](#phase-4--group-members--invites-admin-ui)** + **[group invite/join E2EE](#phase-4--group-invitejoin-e2ee-spa)** (send invite, accept invite landing, request join, accept join request with crypto). **Next:** **MSW/Playwright**; optional deterministic demo keyring for CI; optional notification **body** decrypt. **Phase 3** still: [collab + realtime WebSocket](#not-started-phase-3--realtime--collab-websocket). |
1818
| **5** — Cutover | **Not started** | Canary, redirect, retire `/trpc` when safe. |
1919

2020
---
@@ -333,7 +333,7 @@ Sprints **1–9** (pages / groups / membership), **Stripe**, and **[slice 10 —
333333
| **Routes** | [`/page/:pageId`](apps/web/src/features/pages/PageEditorView.vue) (auth: redirect to login with `?redirect=`); guest [`/register`](apps/web/src/features/auth/RegisterView.vue). |
334334
| **Registration** | [`buildUserRegisterRequest`](apps/web/src/features/auth/build-user-register.ts) = [`buildSessionDemoRequest`](apps/web/src/features/auth/build-demo-session.ts) + `email` + `loginHash` from [`uint8ToBase64(loginPreimage)`](apps/web/src/features/auth/bytes.ts); test [`build-user-register.test.ts`](apps/web/src/features/auth/build-user-register.test.ts). **201** → redirect to `/login?registered=1` + banner. |
335335
| **Page list** | [`useGroupPages`](apps/web/src/features/pages/useGroupPages.ts): `GET /api/users/me/groups` then per group `GET /api/groups/{groupId}/pages` (first window, max 20); [HomeView](apps/web/src/features/home/HomeView.vue) shows links. |
336-
| **Editor + collab REST** | See [Phase 4 — collab decrypt + Yjs + POST append](#phase-4--collab-decrypt--yjs--post-append). |
336+
| **Editor + collab REST** | [collab decrypt + `POST` append](#phase-4--collab-decrypt--yjs--post-append) + [Tiptap + Yjs](#phase-4--tiptap--yjs-spa). |
337337

338338
### Phase 4 — collab decrypt + Yjs + POST append
339339

@@ -346,10 +346,24 @@ Sprints **1–9** (pages / groups / membership), **Stripe**, and **[slice 10 —
346346
| **Session keyrings in `sessionStorage`** | [crypto-storage.ts](apps/web/src/features/auth/crypto-storage.ts), [session-keyrings.ts](apps/web/src/features/auth/session-keyrings.ts): after password login, unwrap `User*` + wrap `SessionUser*` (legacy contexts); store wrapped blobs + raw `sessionKey` for subsequent unwrap; [useSession](apps/web/src/features/auth/useSession.ts) calls **`applyRefreshToStoredKeyrings`** on bootstrap refresh; **clear** on demo / logout / failed refresh. |
347347
| **Collab `GET` payload** | [page-collab-updates.ts](packages/session/src/page-collab-updates.ts) adds `groupId`, `pageEncryptedSymmetricKeyring`, `groupEncryptedContentKeyring`, `groupAccessKeyring` (nullable), `memberEncryptedAccessKeyring` (nullable). [pages-groups Zod + OpenAPI](packages/api/src/schemas/pages-groups.ts); worker JSON; integration asserts non-empty bootstrap fields. |
348348
| **Client unwrap chain** | [page-collab-crypto.ts](apps/web/src/features/pages/page-collab-crypto.ts): member-vs-public access coalesce, asymmetric unwrap with session `PrivateKeyring`, `GroupContentKeyring` + `PageKeyring` contexts; **explicit errors** for group-password / locked layers (not in MVP). |
349-
| **Page UI** | [PageEditorView.vue](apps/web/src/features/pages/PageEditorView.vue): hydrate textarea from server; debounced save; demo / missing-crypto banners. |
349+
| **Page UI** | [PageEditorView.vue](apps/web/src/features/pages/PageEditorView.vue)[Tiptap + Yjs](#phase-4--tiptap--yjs-spa); debounced save; demo / missing-crypto banners. |
350350

351351
**Limits (documented in UI):** **Demo** accounts cannot derive client keys (no `passwordSalt`). **`/register`** uses [build-user-register](apps/web/src/features/auth/build-user-register.ts) (real crypto). **Group password** / non-raw content keyrings are rejected with a clear message.
352352

353+
### Phase 4 — Tiptap + Yjs (SPA)
354+
355+
**Goal:** [RESTART_PLAN §4](../docs/RESTART_PLAN.md) *Phase 4* calls for **Tiptap + Yjs** for the editor. Replace the proof-of-concept `textarea` + `Y.Text` binding with **ProseMirror** state synced through Tiptap’s `@tiptap/extension-collaboration` on a shared `Y.Doc`, using an XmlFragment field distinct from the legacy plain-text `Y.Text("default")` path so stored Yjs can evolve without silent corruption.
356+
357+
| Layer | What shipped |
358+
|-------|----------------|
359+
| **Dependencies** | [`@tiptap/vue-3`](apps/web/package.json), `@tiptap/starter-kit`, `@tiptap/extension-collaboration` (same `yjs` as collab REST). |
360+
| **Editor** | [PageEditorView.vue](apps/web/src/features/pages/PageEditorView.vue): `useEditor` + `EditorContent`; `StarterKit.configure({ undoRedo: false })`; `Collaboration.configure({ document: ydoc, field: "prosemirror" })` (XmlFragment for PM). `onUpdate``encodeStateAsUpdateV2` delta + debounced `POST` (unchanged **PageDocUpdate** AAD + encrypt). |
361+
| **Legacy text** | If, after `applyUpdateV2` of server rows, `Y.Text("default")` has length (earlier **textarea** builds), the client copies it into a **single paragraph** prosemirror doc via `setContent`, then clears the old `Y.Text` so the next save only encodes the XmlFragment + any other Y types from updates. |
362+
| **Route remount** | [App.vue](apps/web/src/App.vue) `RouterView` `:key="route.path"` so `/page/:pageId` changes create a **fresh** `Y.Doc` + editor instance (avoids reusing a polluted doc when navigating home → another page). |
363+
| **Styles** | [globals.css](apps/web/src/styles/globals.css) — minimal `.tiptap-editor .ProseMirror` + list/paragraph spacing (headless Tiptap has no default ProseMirror chrome). |
364+
365+
**Intentional:** not feature-complete rich text (lists/headings via StarterKit are available, but the product may narrow extensions later). **Not done:** [live collab WebSocket](#not-started-phase-3--realtime--collab-websocket) for multi-user low-latency sync.
366+
353367
### Phase 4 — register crypto parity
354368

355369
**Goal:** Replace random `buildSessionDemoRequest`/`buildUserRegisterRequest` blobs with libsodium keyrings + group/page creation matching [legacy `getRegistrationValues`](../apps/client/src/code/areas/auth/register.ts) so new accounts can use the editor E2EE path end-to-end.
@@ -363,7 +377,7 @@ Sprints **1–9** (pages / groups / membership), **Stripe**, and **[slice 10 —
363377
| **`persistSessionKeyringsFromLogin`**: unwrap `UserPrivateKeyring` / `UserSymmetricKeyring` only when `topLayer !== Raw` after server unwrap (legacy double-wrap vs greenfield raw inner) | **Done** |
364378
| Optional: demo path that uses a deterministic dev keyring for CI | **Todo** |
365379

366-
**Intentional gap:** [Phase 3 WS collab](#not-started-phase-3--realtime--collab-websocket); Tiptap; MSW/Playwright.
380+
**Intentional gap:** [Phase 3 WS collab](#not-started-phase-3--realtime--collab-websocket); MSW/Playwright.
367381

368382
**Tests:** [build-user-register.test.ts](apps/web/src/features/auth/build-user-register.test.ts) — raw user keyrings, private group, `UserName` decrypt round-trip.
369383

@@ -427,7 +441,7 @@ Sprints **1–9** (pages / groups / membership), **Stripe**, and **[slice 10 —
427441
- [x] **Tooling (bootstrap):** Vitest + **happy-dom** + `@vue/test-utils` in `@deepnotes/web` (minimal `App` test); same Vite 6 pipeline via `vitest/config` `defineConfig` (RESTART_PLAN §5.8).
428442
- [x] **API client (bootstrap):** typed client from the same OpenAPI document as the Worker—see [Phase 4 — OpenAPI typed client](#phase-4--openapi-typed-client-bootstrap). Runtime bundle does **not** import `@deepnotes/api` (only generated `api-types.generated.ts` + `openapi-fetch`); regenerate after OpenAPI changes.
429443
- [x] **Routing + session UI (slice a):** `vue-router` + [`App.vue` shell](apps/web/src/App.vue) (header, Register / Sign in / Sign out). **`useSession`**: as below; Vite [proxy `/api` → 127.0.0.1:8787](apps/web/vite.config.ts). **CORS:** API must allow this app’s origin; proxy avoids cross-origin in local dev. **Not done:** Playwright E2E.
430-
- [x] **Pages + editor + register (slice b):** [Phase 4 — pages, editor, register](#phase-4--pages-list-editor--register) — routing, list, register, local Yjs. **Extended:** [Phase 4 — collab decrypt + Yjs + POST append](#phase-4--collab-decrypt--yjs--post-append) on [`PageEditorView`](apps/web/src/features/pages/PageEditorView.vue) (`applyUpdateV2`, `encodeStateAsUpdateV2`, `POST` append, `passwordSalt` + `sessionStorage` session keyrings). **Register:** [real register crypto](#phase-4--register-crypto-parity) (`msgpackr` + `@deepnotes/e2ee`; private personal group). **Not done:** [live collab WebSocket](#not-started-phase-3--realtime--collab-websocket).
444+
- [x] **Pages + editor + register (slice b):** [Phase 4 — pages, editor, register](#phase-4--pages-list-editor--register) — routing, list, register, Yjs. **Extended:** [Phase 4 — collab decrypt + Yjs + POST append](#phase-4--collab-decrypt--yjs--post-append) + [Tiptap + Yjs](#phase-4--tiptap--yjs-spa) on [`PageEditorView`](apps/web/src/features/pages/PageEditorView.vue) (`applyUpdateV2`, `encodeStateAsUpdateV2`, `POST` append, `passwordSalt` + `sessionStorage` session keyrings). **Register:** [real register crypto](#phase-4--register-crypto-parity) (`msgpackr` + `@deepnotes/e2ee`; private personal group). **Not done:** [live collab WebSocket](#not-started-phase-3--realtime--collab-websocket).
431445
- [x] **Groups** overview as thin SPA: [`/groups`](apps/web/src/features/groups/GroupsView.vue) + [fetch helper](apps/web/src/features/groups/groups-overview.ts) (list → main page + members count + first page window).
432446
- [x] **Group members / invites (admin):** [`/groups/:groupId`](apps/web/src/features/groups/GroupDetailView.vue) + [`GET …/members/detail`](#phase-4--group-members--invites-admin-ui) — roles, pending rows, leave/remove/`PATCH`, cancel/reject; **[E2EE invite/join](#phase-4--group-invitejoin-e2ee-spa)** (send invite, accept on `/invite`, request join on `/join`, accept join request with crypto).
433447
- [x] **Notifications** as thin SPA: [`/notifications`](apps/web/src/features/notifications/NotificationsView.vue) + [notifications-list.ts](apps/web/src/features/notifications/notifications-list.ts) — see [Phase 4 — notifications (thin SPA)](#phase-4--notifications-thin-spa). **Not done:** decrypt notification payloads in the browser.
@@ -520,6 +534,7 @@ Cross-cutting work so the new SPA does not repeat **legacy `apps/client`** patte
520534

521535
| Date | Change |
522536
|------|--------|
537+
| 2026-04-27 | **Phase 4 — Tiptap + Yjs in page editor:** [`@tiptap/vue-3`](apps/web/package.json) + `StarterKit` (`undoRedo: false`) + `Collaboration` on `Y.Doc` field `prosemirror`; [PageEditorView.vue](apps/web/src/features/pages/PageEditorView.vue) replaces `textarea` + `Y.Text`; migration of legacy `Y.Text("default")` into one paragraph; [App.vue](apps/web/src/App.vue) `RouterView :key="route.path"`; [globals.css](apps/web/src/styles/globals.css) ProseMirror minimal styles. [PLAN_PROGRESS — Tiptap section](#phase-4--tiptap--yjs-spa). **Next (Phase 4):** MSW/Playwright. |
523538
| 2026-04-27 | **Phase 3 + 4 — invite/join E2EE + crypto bootstrap API:** [`performGetGroupInviteCryptoBootstrap`](packages/session/src/group-invite-crypto-bootstrap.ts), [`performGetGroupPublicKeyringForMessaging`](packages/session/src/group-invite-crypto-bootstrap.ts), [`performGetUserPublicKeyring`](packages/session/src/user-public-keyring.ts); OpenAPI + Worker (**74** 503 matrix); [group-membership-crypto.ts](apps/web/src/features/groups/group-membership-crypto.ts); [GroupInviteLandingView.vue](apps/web/src/features/groups/GroupInviteLandingView.vue) `/groups/:id/invite`, [GroupJoinRequestView.vue](apps/web/src/features/groups/GroupJoinRequestView.vue) `/groups/:id/join`; [GroupDetailView](apps/web/src/features/groups/GroupDetailView.vue) invite form + join-request accept; [useGroupMembersDetail](apps/web/src/features/groups/useGroupMembersDetail.ts) `sendJoinInvitation` / `acceptJoinRequestWithCrypto`; [TRPC_REST_MAP](docs/TRPC_REST_MAP.md); regenerated web OpenAPI types. [PLAN_PROGRESS — E2EE section](#phase-4--group-invitejoin-e2ee-spa). **Next:** Tiptap; MSW/Playwright. |
524539
| 2026-04-27 | **Phase 3 + 4 — `GET …/members/detail` + group admin UI:** [`performGetGroupMembersDetail`](packages/session/src/group-main-and-members.ts); OpenAPI + Worker + [integration extensions](packages/session/src/account-flows.integration.test.ts) (slice 9); [GroupDetailView.vue](apps/web/src/features/groups/GroupDetailView.vue) `/groups/:groupId`, [group-members-detail.ts](apps/web/src/features/groups/group-members-detail.ts), [group-role-policy.ts](apps/web/src/features/groups/group-role-policy.ts), [useGroupMembersDetail.ts](apps/web/src/features/groups/useGroupMembersDetail.ts); [GroupsView](apps/web/src/features/groups/GroupsView.vue) link; Vitest **+2** files; api-worker 503 matrix **71**; [TRPC_REST_MAP](docs/TRPC_REST_MAP.md) greenfield row; regenerated web OpenAPI types. [PLAN_PROGRESS — section](#phase-4--group-members--invites-admin-ui). **Next (Phase 4):** E2EE invite/join flows in SPA; Tiptap; MSW/Playwright. |
525540
| 2026-04-27 | **Phase 4 — notifications (thin SPA):** [notifications-list.ts](apps/web/src/features/notifications/notifications-list.ts) (`fetchNotificationsPage`, `readCursorForUnread` for paged `GET`, `markAllNotificationsRead`); [useNotifications.ts](apps/web/src/features/notifications/useNotifications.ts); [NotificationsView.vue](apps/web/src/features/notifications/NotificationsView.vue) at `/notifications` (type + time, unread via `lastNotificationRead`, “Load older”, “Mark all as read”); [App.vue](apps/web/src/App.vue) nav; [notifications-list.test.ts](apps/web/src/features/notifications/notifications-list.test.ts). [PLAN_PROGRESS — section](#phase-4--notifications-thin-spa). |

new-deepnotes/apps/web/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
"dependencies": {
1616
"@deepnotes/e2ee": "workspace:^",
1717
"@tailwindcss/vite": "^4.2.4",
18+
"@tiptap/extension-collaboration": "^3.22.4",
19+
"@tiptap/starter-kit": "^3.22.4",
20+
"@tiptap/vue-3": "^3.22.4",
1821
"@vueuse/core": "^14.2.1",
1922
"class-variance-authority": "^0.7.1",
2023
"clsx": "^2.1.1",

new-deepnotes/apps/web/src/App.vue

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
<script setup lang="ts">
22
import { onMounted } from "vue";
3-
import { RouterLink, RouterView } from "vue-router";
3+
import { RouterLink, RouterView, useRoute } from "vue-router";
44
55
import { Button } from "@/components/ui/button";
66
77
import { useSession } from "./features/auth/useSession";
88
99
const { bootstrap, isAuthenticated, user, bootstrapped, loading, logout } =
1010
useSession();
11+
const route = useRoute();
1112
1213
onMounted(() => {
1314
void bootstrap();
@@ -85,7 +86,7 @@ async function onLogout() {
8586
</div>
8687
</header>
8788
<main class="mx-auto w-full max-w-3xl flex-1 px-4 py-6 sm:px-6">
88-
<RouterView />
89+
<RouterView :key="route.path" />
8990
</main>
9091
</div>
9192
</template>

0 commit comments

Comments
 (0)