From 108adf8f4b52b06061b9fb6a2d926c51b85e38c7 Mon Sep 17 00:00:00 2001 From: Matt Webb Date: Thu, 9 Apr 2026 14:57:29 +0100 Subject: [PATCH 1/4] Add CI workflow, CriticMarkup docs, remove plans directory - Add GitHub Actions CI with parallel typecheck/lint/test jobs - Add docs/markdown-and-criticmarkup.md documenting the round-trip contract, CriticMarkup support, and frontmatter thread format - Remove internal development plans before going public - Update CLAUDE.md to remove stale plans reference Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 44 ++++ CLAUDE.md | 2 +- docs/markdown-and-criticmarkup.md | 160 +++++++++++++ plans/2026-02-comment-threads.md | 301 ------------------------- plans/2026-02-core-editor.md | 138 ------------ plans/2026-02-scaffolding.md | 55 ----- plans/2026-02-setup.md | 45 ---- plans/2026-02-share-preview-cursors.md | 60 ----- plans/2026-02-stand-up.md | 86 ------- plans/2026-04-open-source-readiness.md | 34 --- plans/PLAN_TEMPLATE.md | 39 ---- 11 files changed, 205 insertions(+), 759 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 docs/markdown-and-criticmarkup.md delete mode 100644 plans/2026-02-comment-threads.md delete mode 100644 plans/2026-02-core-editor.md delete mode 100644 plans/2026-02-scaffolding.md delete mode 100644 plans/2026-02-setup.md delete mode 100644 plans/2026-02-share-preview-cursors.md delete mode 100644 plans/2026-02-stand-up.md delete mode 100644 plans/2026-04-open-source-readiness.md delete mode 100644 plans/PLAN_TEMPLATE.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..fb2149a4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + typecheck: + name: typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run typecheck + + lint: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run lint + + test: + name: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run test diff --git a/CLAUDE.md b/CLAUDE.md index 75302957..6876b336 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ Read project documents to load context: - `docs/design-system.md` — visual design, typography, colours, layout - `docs/technical-architecture.md` — platform, framework stack, directory structure, critical rules -Also check `plans/` for the current active plan. +Also check `plans/` for any active plan. ## Project Overview diff --git a/docs/markdown-and-criticmarkup.md b/docs/markdown-and-criticmarkup.md new file mode 100644 index 00000000..41db13c2 --- /dev/null +++ b/docs/markdown-and-criticmarkup.md @@ -0,0 +1,160 @@ +# Markdown and CriticMarkup + +How MIST stores, imports, and exports document content. + +## Goal + +All content lives in a single markdown file: the document text, formatting, suggested edits, comments, and thread metadata. The file is the canonical format. Success means **round-tripping with no loss**: download a document, upload it again, download it again — the two downloads are identical. + +## Markdown + +MIST documents are plain markdown. The editor stores text as a ProseMirror document (via TipTap) and renders markdown formatting — bold, italic, code, links, headings, lists, blockquotes — as visual decorations. The underlying text retains the markdown characters (`**bold**`, `# heading`, etc.) rather than converting to rich-text nodes. + +This means: + +- The markdown you type is the markdown you get back on download. +- No AST conversion or lossy formatting round-trip. +- Preview mode renders the markdown to HTML using [marked](https://marked.js.org/) and sanitises it with [DOMPurify](https://github.com/cure53/DOMPurify). + +### Limitations + +- The editor is paragraph-based. Each line is a ProseMirror paragraph node. There is no concept of nested block structures (e.g. a list item containing a blockquote) at the editor level — these render correctly in preview but are flat paragraphs in the editor. +- No support for tables, footnotes, or extended markdown syntax. + +## CriticMarkup + +Suggested edits use [CriticMarkup](https://criticmarkup.com/), a plain-text convention for tracking changes in markdown files. MIST supports four of the five CriticMarkup types. + +### Supported syntax + +| Type | Syntax | Example | +|------|--------|---------| +| Addition | `{++ ++}` | `{++new text++}` | +| Deletion | `{-- --}` | `{--removed text--}` | +| Comment | `{>> <<}` | `{>>This needs a citation<<}` | +| Highlight | `{== ==}` | `{==highlighted passage==}` | + +### Not supported + +| Type | Syntax | Alternative | +|------|--------|-------------| +| Substitution | `{~~old~>new~~}` | Use `{--old--}{++new++}` | + +Importing a file with substitution syntax returns a 400 error with a message explaining the alternative. + +### How it works internally + +CriticMarkup is stored as **ProseMirror marks** in the editor, not as literal delimiter text. The delimiters (`{++`, `++}`, etc.) are rendered as non-editable widget decorations. + +- **On import** (`critic-parser.ts`): delimiters are stripped, clean text is inserted, and marks are applied at the correct positions via Yjs `format()`. +- **On export** (`critic-serializer.ts`): the serializer walks the document, finds marks, and wraps marked text in the appropriate delimiters. +- **Parsing** uses the [`critic-markup`](https://www.npmjs.com/package/critic-markup) npm package for additions, deletions, substitutions, and comments. Highlights are parsed separately (the package doesn't handle standalone highlights). + +### Suggest mode + +When suggest mode is active, typing and deleting produce CriticMarkup marks instead of direct edits: + +- **Typing new text** applies a `criticAddition` mark. +- **Deleting text** applies a `criticDeletion` mark (the text remains visible but struck through). +- **Deleting inside an existing addition** removes the added text normally (shrinks the addition). +- **Deleting already-deleted text** is a no-op. + +Mode is stored in the Yjs document state and syncs across all connected clients. + +### Highlight + comment pairing + +A highlight can be paired with a comment to annotate a specific passage: + +``` +{==highlighted text==}{>>This is the comment about the highlighted text<<} +``` + +The parser splits this into two adjacent marks: a highlight and a comment. The comment links to a thread (see below) while the highlight marks the passage being discussed. + +### Accept and reject + +Each suggestion (addition or deletion) can be accepted or rejected: + +- **Accept addition**: the mark is removed, text stays. +- **Reject addition**: the text is removed. +- **Accept deletion**: the text is removed. +- **Reject deletion**: the mark is removed, text stays. + +## Comments and threads + +Comment threads are stored in **YAML frontmatter** under the `mist` key. The frontmatter is prepended on download and stripped on upload. + +### Format + +```yaml +--- +mist: + threads: + - comment: "This needs a citation" + highlight: "highlighted passage" + author: "Alice" + color: "#e06c75" + created: "2026-04-09T12:00:00.000Z" + resolved: false + replies: + - author: "Bob" + color: "#61afef" + text: "Added a citation to Smith 2024" + created: "2026-04-09T12:30:00.000Z" +--- + +Document content with {==highlighted passage==}{>>This needs a citation<<} goes here. +``` + +### How threads connect to marks + +Threads are matched to `criticComment` marks in the document by comparing the `comment` field in the frontmatter with the text content of the comment mark. When a highlight is present, the `highlight` field records which passage the comment refers to. + +On import, threads from frontmatter are stored in a Yjs `Y.Map("threads")` for real-time sync between clients. Each thread gets an `imported-{i}` ID. + +### Thread fields + +| Field | Required | Description | +|-------|----------|-------------| +| `comment` | yes | The comment text (matches `{>>text<<}` in the body) | +| `highlight` | no | The highlighted passage (matches `{==text==}` in the body) | +| `author` | yes | Display name | +| `color` | yes | Author's cursor/avatar colour | +| `created` | yes | ISO 8601 timestamp | +| `resolved` | yes | Whether the thread is resolved | +| `replies` | no | Array of reply objects (author, color, text, created) | + +### Standalone comments + +A comment without a highlight appears as a point marker in the document: + +``` +Some text{>>A note about this point in the document<<} continues here. +``` + +### Preserving other frontmatter + +Any existing YAML frontmatter keys outside `mist` are preserved through the round-trip. MIST only reads and writes the `mist` key. + +## Round-trip contract + +The export/import cycle should produce identical output: + +1. **Download** serializes: ProseMirror marks to CriticMarkup delimiters, threads to YAML frontmatter. +2. **Upload** parses: CriticMarkup delimiters to marks, YAML frontmatter to threads. +3. **Download again** serializes the same state. + +The two downloaded files should be byte-identical. If they are not, it is a bug. + +### Known edge cases + +- **Substitution syntax** is rejected on import — it must be manually converted to `{--old--}{++new++}` before uploading. +- **Multi-paragraph CriticMarkup** is not supported. Each line is parsed independently, so a deletion that spans two paragraphs should be two separate deletions. +- **Mark precedence on export**: if a text node has multiple CriticMarkup marks (which shouldn't happen — marks exclude each other), the serializer uses the first match in order: addition > deletion > comment > highlight. + +## References + +- [CriticMarkup spec](https://criticmarkup.com/) +- [`critic-markup` npm package](https://www.npmjs.com/package/critic-markup) +- [Yjs CRDT](https://yjs.dev/) +- [TipTap editor](https://tiptap.dev/) diff --git a/plans/2026-02-comment-threads.md b/plans/2026-02-comment-threads.md deleted file mode 100644 index f5ec9f18..00000000 --- a/plans/2026-02-comment-threads.md +++ /dev/null @@ -1,301 +0,0 @@ -# Comment Threads: Inline Anchors, Sidebar Panels, Roundtripping - -## Context - -CriticMarkup comments (`{>>text<<}`) and highlights (`{==text==}{>>text<<}`) work inline. We need threaded discussions: reply to comments, resolve them, and see conversations in the right sidebar. Threads must also roundtrip through export/import so a downloaded `.md` file can be re-uploaded with threads intact. - -CriticMarkup has no threading/ID concept. Threads are a layer on top: inline `{>>text<<}` stays format-compatible, thread metadata lives in a Y.Map (live editing) and YAML frontmatter (export/import). - -## Key Requirements - -1. **CriticMarkup compatible** — inline comments stay as `{>>text<<}` and `{==text==}{>>text<<}` -2. **Threads in right sidebar** — each inline comment gets a panel with replies -3. **Multiplayer** — thread data synced via Yjs Y.Map -4. **Roundtripping** — download `.md` with serialized threads in YAML frontmatter, upload on homepage to restore -5. **Obsidian/Pandoc compatible** — standard YAML frontmatter format -6. **iA Annotations coexistence** — frontmatter (top) for threads, annotations (bottom) for attribution — separate concerns, no conflict - -## Architecture - -### Live editing: Yjs relative positions for matching - -Yjs relative positions are CRDT pointers that auto-track through concurrent edits. When a comment is created: - -1. Compute Yjs relative position via `Y.createRelativePositionFromTypeIndex(yText, charIndex)` -2. Serialize with `Y.relativePositionToJSON(rpos)` and store in thread Y.Map entry -3. On render, resolve all positions via `Y.createAbsolutePositionFromRelativePosition(doc, rpos)` to get current char index -4. Match to the `{>>...<<}` at that position - -This is automatic — no manual position tracking. The relative position moves with the text through any concurrent edit. - -### Thread data model - -**Y.Map** — `doc.getMap("threads")`: -``` -threadId (UUID) → JSON string { - id: string, - commentText: string, - highlightText?: string, - author: UserInfo, - createdAt: number, - resolved: boolean, - anchor: object, // Y.relativePositionToJSON() result - replies: ThreadReply[] -} -``` - -JSON strings in Y.Map — concurrent reply conflict is low-risk for comments. Can upgrade to nested Y types later. - -### Export format: YAML frontmatter - -```yaml ---- -mist: - threads: - - comment: "This needs more detail" - highlight: "The introduction paragraph" - author: Jane - color: "#E57373" - created: 2026-02-08T14:30:00Z - resolved: false - replies: - - author: Bob - color: "#64B5F6" - text: "I'll expand this" - created: 2026-02-08T15:15:00Z ---- - -# My Document - -{==The introduction paragraph==}{>>This needs more detail<<} -``` - -- **Top**: YAML frontmatter under `mist.threads` key — Obsidian shows as Properties, Pandoc preserves, Hugo/Jekyll ignore unknown keys -- **Bottom** (future): iA Writer annotations — separate section, no conflict -- **Import matching**: by `comment` field text against `{>>...<<}` in document body. For duplicates, match in document order vs array order. - -### Orphan handling - -When a comment is accepted/rejected (removed from text), the thread's relative position becomes invalid. Auto-resolve orphaned threads on editor update — don't delete (conversation history preserved). - ---- - -## Step 1: Types - -**`app/shared/types.ts`** — add: - -```ts -export interface ThreadReply { - id: string; - author: UserInfo; - text: string; - createdAt: number; -} - -export interface ThreadData { - id: string; - commentText: string; - highlightText?: string; - author: UserInfo; - createdAt: number; - resolved: boolean; - anchor: object | null; // serialized Yjs RelativePosition - replies: ThreadReply[]; -} -``` - -## Step 2: Export/import library (TDD) - -**New file: `app/lib/thread-serialization.ts`** — pure functions, no React/Yjs. - -**Tests first: `tests/unit/lib/thread-serialization.test.ts`**: - -Export tests: -- Document with no threads → no frontmatter added -- Document with one thread → correct YAML frontmatter -- Document with thread + replies → replies serialized -- Resolved thread → `resolved: true` in output -- Thread with highlight → `highlight` field present -- Existing frontmatter preserved (non-mist keys kept) -- Multiple threads → sorted by document order - -Import tests: -- Frontmatter with one thread → ThreadData parsed, matched to inline comment -- Frontmatter with replies → replies array restored -- Missing `mist.threads` key → empty threads array -- No frontmatter → empty threads array -- Thread with no matching inline comment → returned as orphan -- Duplicate comment texts → matched by array/document order -- Malformed YAML → graceful error handling -- Highlight+comment pair → `highlightText` populated - -Edge cases: -- Comment text containing YAML special chars (`:`, `#`, `"`, newlines) -- Very long comment text -- Thread with empty replies array -- Thread referencing highlight that was edited/removed -- Document with CriticMarkup but no comment type (additions/deletions only) -- Frontmatter with extra unknown keys under `mist` → preserved on roundtrip - -Functions: - -```ts -function serializeThreads(markdown: string, threads: ThreadData[]): string -function deserializeThreads(markdown: string): { body: string; threads: Omit[] } -function stripFrontmatter(markdown: string): string -function parseFrontmatter(markdown: string): Record -``` - -## Step 3: Comment scanning + thread matching (TDD) - -**New file: `app/lib/comment-threads.ts`** — pure functions (takes PM doc shape, not React). - -**Tests first: `tests/unit/lib/comment-threads.test.ts`**: -- Finds point comments `{>>text<<}`, returns position + text -- Finds range comments `{==highlight==}{>>comment<<}`, bundles highlight text -- Multiple comments across paragraphs, sorted by position -- Empty document → empty array -- Matching: thread matched to comment by `commentText` -- Matching with duplicates: document order vs `createdAt` order -- Orphan detection: thread with no matching comment - -Functions: - -```ts -interface DocumentComment { - commentText: string; - highlightText?: string; - position: number; // PM pos of {>> start - endPosition: number; // PM pos after <<} -} - -function scanDocumentComments(doc): DocumentComment[] -function matchThreadsToComments(threads, comments): (ThreadData & { position?: number })[] -function findOrphanedThreads(threads, comments): ThreadData[] -``` - -Uses existing `parseCriticRanges()` from `app/lib/critic-markup.ts`. - -## Step 4: Thread sync tests + useThreads hook - -**Tests: `tests/unit/lib/thread-sync.test.ts`** — MockSocket/MockServer pattern from `critic-sync.test.ts`: -- Client A creates thread → Client B sees it -- Client A adds reply → Client B sees updated thread -- Client A resolves thread → Client B sees resolved state -- Threads survive disconnect/reconnect -- Concurrent thread creation from both clients -- Client A deletes thread → removed from Client B - -**New file: `app/lib/useThreads.ts`** — React hook: - -```ts -function useThreads({ doc, editor, user }): { - threads: (ThreadData & { position?: number })[]; - createThread(commentText: string, highlightText?: string): string; - addReply(threadId: string, text: string): void; - resolveThread(threadId: string): void; - deleteThread(threadId: string): void; - activeThreadId: string | null; - setActiveThreadId(id: string | null): void; -} -``` - -- Observes `doc.getMap("threads")` for remote changes -- Scans editor on `update` events, matches threads to positions -- Auto-resolves orphaned threads -- Stores Yjs relative position anchors when creating threads - -## Step 5: Modify CommentInput - -**`app/components/CommentInput.tsx`** — add `onCommentCreated` prop: - -```ts -onCommentCreated?: (commentText: string, highlightText?: string) => void; -``` - -Called in `handleSubmit` after inserting CriticMarkup. Parent wires to `createThread`. - -## Step 6: ThreadPanel + ThreadList components - -**`app/components/ThreadPanel.tsx`** — individual thread: -- Author colour dot + name + relative timestamp (`text-xs text-muted`) -- Highlight context (if present): snippet with `cm-highlight` bg, truncated 80 chars -- Comment text in regular weight -- Replies stacked: colour dot + name + text + timestamp -- Reply input: text input, Enter to submit, Escape to cancel -- Resolve/Reopen button (`text-xs uppercase tracking-wider`) -- Active thread: `border-l-2 border-coral` - -**`app/components/ThreadList.tsx`** — container: -- Header: comment count (`text-xs uppercase tracking-wider text-muted`) -- Empty: "No comments yet" in `text-muted` -- Threads separated by `border-t border-border` -- Resolved threads hidden behind "Show resolved (N)" toggle - -## Step 7: Download button - -**`app/components/DownloadButton.tsx`** — next to share button in header: -- Gets editor text + threads from props -- Calls `serializeThreads()` to produce markdown with YAML frontmatter -- Triggers browser download as `{docId}.md` -- Styled consistently with ShareButton - -## Step 8: Upload on homepage - -**`app/routes/home.tsx`** — add to existing upload/drag-drop flow: -- When a `.md` file is uploaded, call `deserializeThreads()` on the content -- Create the document with the body text -- After Yjs doc is initialised, populate the threads Y.Map with deserialized thread data -- Navigate to `/docs/{id}` - -## Step 9: Wire into route + editor click - -**`app/routes/docs.$id.tsx`**: -- Instantiate `useThreads` hook -- Wire `CommentInput.onCommentCreated` → `createThread` -- Add `ThreadList` to sidebar (scrollable flex-1 area) -- Add `DownloadButton` to header -- Make sidebar `overflow-y-auto` - -**`app/components/Editor.tsx`** — add `CommentClickHandler` extension: -- ProseMirror plugin with `handleClick` -- If click is inside `{>>...<<}`, extract comment text + position, call `onCommentClick` -- Route wires to `setActiveThreadId` - -**`app/app.css`** — add `.cm-comment-active` for highlighted comment when thread is selected. - ---- - -## Files Changed - -| File | Action | -|------|--------| -| `app/shared/types.ts` | **Modify** — add `ThreadData`, `ThreadReply` | -| `app/lib/thread-serialization.ts` | **Create** — YAML frontmatter export/import | -| `tests/unit/lib/thread-serialization.test.ts` | **Create** — export/import tests + edge cases | -| `app/lib/comment-threads.ts` | **Create** — doc scanning + matching | -| `tests/unit/lib/comment-threads.test.ts` | **Create** — scan/match tests | -| `app/lib/useThreads.ts` | **Create** — React hook, Y.Map sync, relative positions | -| `tests/unit/lib/thread-sync.test.ts` | **Create** — multiplayer sync tests | -| `app/components/CommentInput.tsx` | **Modify** — add `onCommentCreated` prop | -| `app/components/ThreadPanel.tsx` | **Create** — thread UI | -| `app/components/ThreadList.tsx` | **Create** — thread list container | -| `app/components/DownloadButton.tsx` | **Create** — export `.md` with frontmatter | -| `app/components/Editor.tsx` | **Modify** — add click handler extension | -| `app/routes/docs.$id.tsx` | **Modify** — wire everything | -| `app/routes/home.tsx` | **Modify** — import with thread deserialization | -| `app/app.css` | **Modify** — `.cm-comment-active` | - -## Verification - -1. `npm run test` — all pass (new + existing) -2. `npm run typecheck` — clean -3. `npm run lint` — clean -4. Manual: create comment → thread appears in sidebar -5. Manual: reply to thread → reply visible to both clients -6. Manual: resolve thread → collapses, hidden behind toggle -7. Manual: accept comment → thread auto-resolves -8. Manual: click comment in editor → thread highlights in sidebar -9. Manual: download → open file → valid YAML frontmatter + markdown body -10. Manual: upload exported file → document + threads restored -11. Manual: open exported file in Obsidian → frontmatter shown as Properties diff --git a/plans/2026-02-core-editor.md b/plans/2026-02-core-editor.md deleted file mode 100644 index a038cf2a..00000000 --- a/plans/2026-02-core-editor.md +++ /dev/null @@ -1,138 +0,0 @@ -# Core Editor Implementation Plan - -## Context - -The scaffolding phase is complete. We need to build the core editor: a TipTap multiplayer markdown editor backed by Yjs and Cloudflare Durable Objects. This covers the 5 "Core Editor" tasks from the stand-up plan, plus the minimal routing and homepage needed to reach the editor. - -## Approach - -**Plain text editor with markdown decorations.** The document model is plain text (paragraphs of text, no rich-text marks). Markdown syntax is displayed visually through ProseMirror decorations — `**bold**` shows "bold" in bold weight with `**` greyed out. Yjs syncs the text content, which IS the markdown source. - -**Custom Yjs sync over Agents SDK WebSocket.** No y-websocket dependency. The DocumentAgent implements the y-protocols sync protocol server-side. A custom `YjsProvider` class bridges the `useAgent` WebSocket with Yjs client-side. - -## Steps - -### 1. Install packages - -``` -npm install yjs y-protocols @tiptap/react @tiptap/pm @tiptap/core \ - @tiptap/extension-document @tiptap/extension-paragraph @tiptap/extension-text \ - @tiptap/extension-collaboration @tiptap/extension-collaboration-cursor \ - @tiptap/y-tiptap lib0 -``` - -If `@tiptap/extension-collaboration-caret` exists (v3 rename), use that instead of `collaboration-cursor`. - -### 2. Update shared constants and types - -**`app/shared/constants.ts`** — add: -- `generateDocumentId()` — 8-char random lowercase alphanumeric -- `USER_COLOURS` — array of 8 cursor colours `{ color, light }` -- `MSG_SYNC = 0`, `MSG_AWARENESS = 1` protocol type constants - -**`app/shared/types.ts`** — add: -- `UserInfo { name: string; color: string; colorLight: string }` - -### 3. Markdown decorations (TDD) — `app/lib/markdown-decorations.ts` - -ProseMirror plugin that scans text nodes for markdown patterns and creates `Decoration.inline()`: -- `**bold**` → content gets `md-bold` class, delimiters get `md-delimiter` -- `*italic*` → `md-italic` + `md-delimiter` -- `` `code` `` → `md-code` + `md-delimiter` -- `~~strike~~` → `md-strikethrough` + `md-delimiter` -- `# heading` → `md-heading-delimiter` on the `#` chars - -Export `MARKDOWN_PATTERNS` for testing. Test regex patterns in `tests/unit/lib/markdown-decorations.test.ts`. - -### 4. Yjs provider — `app/lib/yjs-provider.ts` - -Class that takes a WebSocket + Y.Doc + Awareness: -- Sets `binaryType = "arraybuffer"` on socket -- On open: sends SyncStep1 -- On binary message: dispatches via `syncProtocol.readSyncMessage` / `awarenessProtocol.applyAwarenessUpdate` -- On doc update: sends sync update over socket -- On awareness change: sends awareness update over socket -- `destroy()` cleans up all listeners - -Test in `tests/unit/lib/yjs-provider.test.ts` with mock WebSocket. - -### 5. DocumentAgent — `agents/document.ts` - -Full rewrite with Yjs sync: -- `ensureInitialised()` — creates Y.Doc, loads state from SQLite, sets up update listener -- SQLite table: `doc_state (key TEXT PRIMARY KEY, value BLOB)` -- `onConnect` — sends SyncStep1 + current awareness to new client -- `onMessage` — binary: dispatch sync/awareness protocol; text: handle JSON control messages -- Doc update listener: persist to SQLite + broadcast to other clients -- `onClose` — clean up awareness -- `onRequest` — POST to create doc (sets `exists` flag), GET to check existence - -### 6. Design tokens — `app/app.css` + `app/root.tsx` - -Update CSS with design system tokens: -- System sans-serif (remove Inter), IBM Plex Mono -- Softened black/white: `--color-ink: #1a1a1a`, `--color-paper: #fafafa` -- Display-p3 accents: coral, chartreuse -- 13px base font size -- Editor and markdown decoration CSS classes - -Update root.tsx: swap Inter font link for IBM Plex Mono. - -### 7. Document route — `app/routes/docs.$id.tsx` - -- Loader: validate ID format, check existence via DO fetch, throw 404 if missing -- Two-column layout: flexible left (editor), fixed right (sidebar placeholder) -- Thin divider between columns -- Header strip with connection status - -Add to `app/routes.ts`: `route("docs/:id", "routes/docs.$id.tsx")` - -### 8. Editor component — `app/components/Editor.tsx` - -TipTap editor with minimal schema: -- Extensions: Document, Paragraph, Text, Collaboration (bound to Y.Doc), CollaborationCursor (bound to Awareness), markdownDecorations -- No StarterKit, no marks in schema -- React hook `useYjsEditor(docId)` in `app/lib/useYjsEditor.ts` composes useAgent + YjsProvider - -### 9. ConnectionStatus component — `app/components/ConnectionStatus.tsx` - -Following hawthorn pattern: tracks socket readyState, renders coloured dot + uppercase text. Adapted for mist design tokens. - -### 10. Homepage — update `app/routes/home.tsx` - -Replace template welcome page with: -- "New document" button → generates ID, POSTs to agent `/create`, navigates to `/docs/{id}` -- Minimal layout matching design system - -### 11. Integration test + polish - -- `npm run dev` manual verification: create doc, edit, open in 2 tabs, see cursors, refresh persists -- Run typecheck, lint, test -- Commit - -## Key files - -| File | Action | -|------|--------| -| `agents/document.ts` | Rewrite — Yjs sync + SQLite persistence | -| `app/lib/yjs-provider.ts` | New — WebSocket-to-Yjs bridge | -| `app/lib/useYjsEditor.ts` | New — React hook composing useAgent + provider | -| `app/lib/markdown-decorations.ts` | New — ProseMirror decoration plugin | -| `app/components/Editor.tsx` | New — TipTap editor | -| `app/components/ConnectionStatus.tsx` | New — socket status indicator | -| `app/routes/docs.$id.tsx` | New — document page | -| `app/routes/home.tsx` | Modify — new doc button | -| `app/routes.ts` | Modify — add docs route | -| `app/root.tsx` | Modify — font links | -| `app/app.css` | Modify — design tokens + editor styles | -| `app/shared/constants.ts` | Modify — ID gen, colours, protocol constants | -| `app/shared/types.ts` | Modify — UserInfo type | -| `tests/unit/lib/markdown-decorations.test.ts` | New | -| `tests/unit/lib/yjs-provider.test.ts` | New | - -## Verification - -1. `npm run typecheck` — clean -2. `npm run lint` — clean -3. `npm test` — all pass with coverage thresholds met -4. `npm run dev` — create document from homepage, edit markdown, see decorations, open in 2 tabs for multiplayer with cursors, refresh to verify persistence diff --git a/plans/2026-02-scaffolding.md b/plans/2026-02-scaffolding.md deleted file mode 100644 index 359a9d69..00000000 --- a/plans/2026-02-scaffolding.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -title: "Scaffolding" -slug: "2026-02-scaffolding" -status: "active" -owner: "Matt Webb" -version: "0.2" ---- - -# Problem - -Set up the core technology infrastructure for MIST: Cloudflare Workers, React Router, TipTap, testing, linting, deployment. Nothing application-specific yet. - -# Requirements - -## Goals - -- Working Cloudflare Worker serving React Router with SSR -- Agents SDK integrated with Durable Objects -- Tailwind CSS configured with MIST design tokens -- TypeScript, ESLint, and Vitest configured -- Code coverage enforcement (minimum 80%) -- GitHub Actions running full test suite on every commit -- Reference ~/code/hawthorn-worker for setup patterns - -## Non-goals - -- Application features or UI -- Authentication - -## Open questions - -- Exact TipTap packages and multiplayer configuration needed - -# Tasks - -## Infrastructure - -- [x] Initialise npm project with dependencies from hawthorn-worker reference -- [x] Configure wrangler.jsonc for Cloudflare Workers with Durable Objects -- [x] Set up React Router 7 with SSR on Cloudflare Workers -- [x] Configure Vite with Cloudflare plugin, Tailwind, and tsconfig paths -- [x] Create worker entry point (workers/app.ts) with agent routing and React Router SSR -- [ ] Set up Tailwind CSS with MIST design tokens (colours, fonts, base size) - -## Quality - -- [x] Configure TypeScript with strict settings -- [x] Set up ESLint -- [x] Configure Vitest with coverage thresholds (ramping to 80%) -- [ ] Set up GitHub Actions for test suite on every commit - -## Changelog - -- 2026-02-07: v0.1 created from transcript requirements. -- 2026-02-07: v0.2 scaffolding implemented. Template from `npm create cloudflare@latest` + hawthorn-worker patterns. diff --git a/plans/2026-02-setup.md b/plans/2026-02-setup.md deleted file mode 100644 index 3fcc8177..00000000 --- a/plans/2026-02-setup.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -title: "Project Setup" -slug: "2026-02-setup" -status: "complete" -owner: "Matt Webb" -version: "0.1" ---- - -# Problem - -MIST is a new project starting from an empty repository. Before any code is written, we need documentation, skills, and plans in place to guide development. - -# Requirements - -## Goals - -- Capture all decisions and requirements from the project transcript into structured documents -- Create skill files for design, technical architecture, ways of working, and goals/functionality -- Establish the plans directory and working process -- Install necessary Claude Code skills (napkin, Cloudflare) -- Remove the transcript once all content is captured elsewhere - -## Non-goals - -- Writing any application code -- Setting up build tooling or infrastructure (that's the scaffolding phase) - -# Tasks - -## Setup - -- [x] Create CLAUDE.md with project guidance -- [x] Create plans directory with template -- [x] Create skill: Design System -- [x] Create skill: Technical Architecture -- [x] Create skill: Ways of Working -- [x] Create skill: Goals and Functionality -- [x] Install napkin skill -- [x] Install Cloudflare skills (cloudflare, agents-sdk, durable-objects, wrangler) -- [x] Review transcript against all created documents for completeness -- [x] Delete transcript.md - -## Changelog - -- 2026-02-07: v0.1 created. Setup phase initiated. diff --git a/plans/2026-02-share-preview-cursors.md b/plans/2026-02-share-preview-cursors.md deleted file mode 100644 index 8c96df75..00000000 --- a/plans/2026-02-share-preview-cursors.md +++ /dev/null @@ -1,60 +0,0 @@ -# Share Button, Preview, and Multiplayer Cursors - -## Context - -Core editor is working. Three features needed next: share button with clipboard copy, markdown preview toggle, and fixing multiplayer cursor visibility. - -## 1. Share Button - -**Header button** that copies the current URL to clipboard. Shows a tick on success. Dropdown with two disabled future options. - -### Implementation -- New component `app/components/ShareButton.tsx` -- Click handler: `navigator.clipboard.writeText(window.location.href)` -- State: `copied` boolean, resets after 2 seconds -- Dropdown with "Share read-only" and "Private" (both disabled/strikethrough) -- Lightweight custom dropdown (no ShadCN — not installed, overkill for 2 disabled items) -- Add to header in `app/routes/docs.$id.tsx` - -## 2. Preview Toggle - -**Rendered markdown view** replacing the editor content when toggled. - -### Implementation -- Install `marked` for markdown-to-HTML rendering -- New component `app/components/Preview.tsx` — renders HTML from markdown source -- State in doc route: `previewActive` boolean -- Three trigger mechanisms: - - **Click/tap** the preview button → toggle - - **Hold P key** (when editor not focused) → show while held - - **Hover** preview button for 500ms → show while hovering -- Preview button: large tap target in bottom-right of right sidebar -- When active, the left column shows rendered HTML instead of the TipTap editor -- Preview uses friendly serif/sans fonts (system serif stack), larger line height -- Extract text from Y.Doc (`doc.getText("default").toString()`) for rendering - -### Files -- `app/components/Preview.tsx` — rendered markdown display -- `app/components/PreviewToggle.tsx` — tap target button with hover/key logic -- `app/routes/docs.$id.tsx` — state management, conditional rendering - -## 3. Multiplayer Cursors Fix - -**Bug:** Cursors don't appear for other users. Root cause: `yjs-provider.ts` line 112 filters out local awareness changes with `origin === "local"`, preventing cursor positions from being sent to the server. - -### Fix -- In `yjs-provider.ts`, the `onAwarenessChange` callback should send awareness updates to the server regardless of origin. The `origin` check should only prevent echoing updates that came FROM the server (origin === `this`), not local changes. -- Change: `if (origin === "local") return;` → `if (origin === this) return;` - -### Test -- Update existing yjs-provider tests to verify local awareness changes ARE sent over the socket -- Add test: setting local awareness state triggers a send - -## Verification - -1. `npm run typecheck` — clean -2. `npm run lint` — clean -3. `npm test` — all pass -4. Manual: share button copies URL, tick appears, dropdown shows disabled items -5. Manual: preview toggle works via click, P key hold, and hover -6. Manual: two browser tabs show each other's cursors with colours diff --git a/plans/2026-02-stand-up.md b/plans/2026-02-stand-up.md deleted file mode 100644 index b96cd18c..00000000 --- a/plans/2026-02-stand-up.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -title: "Stand Up (MVP)" -slug: "2026-02-stand-up" -status: "draft" -owner: "Matt Webb" -version: "0.1" ---- - -# Problem - -Build the smallest possible MVP with a real user experience: create, edit, and share multiplayer markdown documents. - -# Requirements - -## Goals - -- Homepage with document creation (drag-and-drop .md, paste, new document button, type directly) -- Document page at /docs/{id} with multiplayer TipTap editor -- Real-time collaborative editing with cursor/highlight tracking -- Live persistence via Durable Objects (no save button) -- Connection status indicator -- Markdown source display with inline formatting (bold renders bold, formatting chars greyed out) -- Preview functionality (click, press P, or hover to see rendered markdown) -- Share button (copies URL to clipboard; read-only and private options shown but disabled) -- Right-hand sidebar with metadata -- Markdown annotations support (IA Writers standard) -- 404 for non-existent document IDs - -## Non-goals - -- Authentication or persistent user identity -- Private or read-only document sharing (shown disabled in UI) -- Track changes (noted for future) - -## Open questions - -- Which TipTap multiplayer packages/strategy to use with Durable Objects -- Which markdown annotation library to use (Markdown XL or alternative) -- Exact right-hand sidebar metadata contents - -# Tasks - -## Core Editor - -- [ ] Research TipTap multiplayer setup backed by Durable Objects -- [ ] Create document Durable Object agent with persistent state -- [ ] Implement TipTap multiplayer editor component -- [ ] Add cursor and highlight tracking with per-user colours -- [ ] Implement markdown source styling (bold rendered, formatting chars greyed) - -## Homepage - -- [ ] Create homepage route with layout -- [ ] Implement "New document" button (generates unique ID via agent) -- [ ] Implement drag-and-drop .md file upload -- [ ] Implement paste-to-create functionality -- [ ] Implement type-directly interface - -## Document Page - -- [ ] Create /docs/{id} route -- [ ] Return 404 if Durable Object does not exist for ID -- [ ] Two-column layout: flexible left (editor), fixed right (sidebar) -- [ ] Connection status indicator (top right) -- [ ] Right-hand sidebar with document metadata - -## Preview - -- [ ] Preview area in bottom-right column — tap/click to toggle rendered markdown -- [ ] Press P (when not focused on editor) to show preview while held -- [ ] Hover preview area for 0.5s to show preview - -## Sharing - -- [ ] Share button in header — click copies URL to clipboard with tick confirmation -- [ ] Dropdown with disabled "Share read-only" and "Private" options - -## Markdown Features - -- [ ] Research and integrate extensible markdown library with annotation support -- [ ] Implement Markdown Annotations (IA Writers standard) — contributor tracking area -- [ ] Markdown XL or similar for comments/additions in text - -## Changelog - -- 2026-02-07: v0.1 created from transcript requirements. diff --git a/plans/2026-04-open-source-readiness.md b/plans/2026-04-open-source-readiness.md deleted file mode 100644 index 7ba065dc..00000000 --- a/plans/2026-04-open-source-readiness.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -status: active -version: 1 ---- - -# Open-Source Readiness - -Prepare the MIST codebase for public release under MIT licence. - -## Critical — Must fix before open-sourcing - -- [x] Add MIT LICENSE file -- [x] Fix XSS in Preview component (`app/components/Preview.tsx`) — sanitise `marked.parse()` output with DOMPurify -- [x] Add README.md with project description, setup instructions, and contributor guide -- [x] Remove hardcoded Cloudflare account ID from `wrangler.jsonc` — use env var -- [x] Make Fathom analytics configurable via `VITE_FATHOM_SITE_ID` / `VITE_FATHOM_DOMAINS` env vars (`app/components/Fathom.tsx`) -- [x] Replace personal footer links with GitHub link and MIT badge (`app/routes/home.tsx`) - -## Important — Should fix - -- [x] Delete dead code: `app/welcome/` directory (template boilerplate) -- [x] Delete dead code: `app/components/DownloadButton.tsx` (unused, duplicates ShareButton) -- [x] Clean up unsafe double casts in `agents/document.ts` — extracted `sqlBlob()` helper -- [x] Replace sync-state polling with `onSyncedChange` callback on `YjsProvider` -- [x] Remove stale `@types/marked` dependency from `package.json` -- [x] Fix incomplete test mock in `tests/helpers/document-context.tsx` — added `isOnboarding` and `clearDocument` - -## Minor — Nice to have - -- [x] Align base font size: updated design system skill to say 14px (matches `app/app.css`) -- [x] Remove unused `DEFAULT_DOCUMENT_TITLE` (`app/shared/constants.ts`) -- [x] Remove unused `ThreadData.anchor` field and all references -- [x] Tidy dark theme CSS — added missing `.preview a` override for explicit dark mode -- [x] Add `.claude/` to `.gitignore` and untrack committed files diff --git a/plans/PLAN_TEMPLATE.md b/plans/PLAN_TEMPLATE.md deleted file mode 100644 index 15d6e638..00000000 --- a/plans/PLAN_TEMPLATE.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -title: "" -slug: "" -status: "draft" # draft | active | parked | archived -owner: "Matt Webb" -version: "0.1" ---- - -# Problem - -# Requirements - -## Goals - -## Non-goals - -## Open questions - -# Concepts - -# Research - -# Architecture - -# Tasks - -## Preparation (example section name) - -- [ ] Task 1 -- [ ] Task 2 - -## Implementation (example section name) - -- [ ] Task 1 -- [ ] Task 2 - -## Changelog - -- YYYY-MM-DD: v0.1 created. From 59539904de4458587759cce48d14630b8c577c13 Mon Sep 17 00:00:00 2001 From: Matt Webb Date: Thu, 9 Apr 2026 15:03:18 +0100 Subject: [PATCH 2/4] Clean up CriticMarkup doc: remove implementation details, fix style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove ProseMirror/Yjs/TipTap references — this is a protocol doc, not an architecture doc. Fix MIST -> mist, markdown -> Markdown. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/markdown-and-criticmarkup.md | 59 ++++++++++++------------------- 1 file changed, 22 insertions(+), 37 deletions(-) diff --git a/docs/markdown-and-criticmarkup.md b/docs/markdown-and-criticmarkup.md index 41db13c2..72d88983 100644 --- a/docs/markdown-and-criticmarkup.md +++ b/docs/markdown-and-criticmarkup.md @@ -1,29 +1,23 @@ # Markdown and CriticMarkup -How MIST stores, imports, and exports document content. +How mist stores, imports, and exports document content. ## Goal -All content lives in a single markdown file: the document text, formatting, suggested edits, comments, and thread metadata. The file is the canonical format. Success means **round-tripping with no loss**: download a document, upload it again, download it again — the two downloads are identical. +All content lives in a single Markdown file: the document text, formatting, suggested edits, comments, and thread metadata. The file is the canonical format. Success means **round-tripping with no loss**: download a document, upload it again, download it again — the two downloads are identical. ## Markdown -MIST documents are plain markdown. The editor stores text as a ProseMirror document (via TipTap) and renders markdown formatting — bold, italic, code, links, headings, lists, blockquotes — as visual decorations. The underlying text retains the markdown characters (`**bold**`, `# heading`, etc.) rather than converting to rich-text nodes. - -This means: - -- The markdown you type is the markdown you get back on download. -- No AST conversion or lossy formatting round-trip. -- Preview mode renders the markdown to HTML using [marked](https://marked.js.org/) and sanitises it with [DOMPurify](https://github.com/cure53/DOMPurify). +mist documents are plain Markdown. The underlying text retains the Markdown characters (`**bold**`, `# heading`, etc.) rather than converting to rich-text nodes. The Markdown you type is the Markdown you get back on download — no AST conversion or lossy formatting round-trip. ### Limitations -- The editor is paragraph-based. Each line is a ProseMirror paragraph node. There is no concept of nested block structures (e.g. a list item containing a blockquote) at the editor level — these render correctly in preview but are flat paragraphs in the editor. -- No support for tables, footnotes, or extended markdown syntax. +- The editor is paragraph-based. Each line is an independent paragraph. There is no concept of nested block structures (e.g. a list item containing a blockquote) — these render correctly in preview but are flat paragraphs in the editor. +- No support for tables, footnotes, or extended Markdown syntax. ## CriticMarkup -Suggested edits use [CriticMarkup](https://criticmarkup.com/), a plain-text convention for tracking changes in markdown files. MIST supports four of the five CriticMarkup types. +Suggested edits use [CriticMarkup](https://criticmarkup.com/), a plain-text convention for tracking changes in Markdown files. mist supports four of the five CriticMarkup types. ### Supported syntax @@ -42,24 +36,16 @@ Suggested edits use [CriticMarkup](https://criticmarkup.com/), a plain-text conv Importing a file with substitution syntax returns a 400 error with a message explaining the alternative. -### How it works internally - -CriticMarkup is stored as **ProseMirror marks** in the editor, not as literal delimiter text. The delimiters (`{++`, `++}`, etc.) are rendered as non-editable widget decorations. - -- **On import** (`critic-parser.ts`): delimiters are stripped, clean text is inserted, and marks are applied at the correct positions via Yjs `format()`. -- **On export** (`critic-serializer.ts`): the serializer walks the document, finds marks, and wraps marked text in the appropriate delimiters. -- **Parsing** uses the [`critic-markup`](https://www.npmjs.com/package/critic-markup) npm package for additions, deletions, substitutions, and comments. Highlights are parsed separately (the package doesn't handle standalone highlights). - ### Suggest mode -When suggest mode is active, typing and deleting produce CriticMarkup marks instead of direct edits: +When suggest mode is active, typing and deleting produce CriticMarkup instead of direct edits: -- **Typing new text** applies a `criticAddition` mark. -- **Deleting text** applies a `criticDeletion` mark (the text remains visible but struck through). +- **Typing new text** inserts it as an addition (`{++new text++}`). +- **Deleting text** marks it as a deletion (`{--deleted text--}`) — the text remains visible but struck through. - **Deleting inside an existing addition** removes the added text normally (shrinks the addition). - **Deleting already-deleted text** is a no-op. -Mode is stored in the Yjs document state and syncs across all connected clients. +Mode syncs across all connected clients. ### Highlight + comment pairing @@ -69,16 +55,21 @@ A highlight can be paired with a comment to annotate a specific passage: {==highlighted text==}{>>This is the comment about the highlighted text<<} ``` -The parser splits this into two adjacent marks: a highlight and a comment. The comment links to a thread (see below) while the highlight marks the passage being discussed. +On import, this is split into two adjacent ranges: a highlight and a comment. The comment links to a thread (see below) while the highlight marks the passage being discussed. ### Accept and reject Each suggestion (addition or deletion) can be accepted or rejected: -- **Accept addition**: the mark is removed, text stays. +- **Accept addition**: the addition markers are removed, text stays. - **Reject addition**: the text is removed. - **Accept deletion**: the text is removed. -- **Reject deletion**: the mark is removed, text stays. +- **Reject deletion**: the deletion markers are removed, text stays. + +### Limitations + +- **Multi-paragraph CriticMarkup** is not supported. Each line is parsed independently, so a deletion that spans two paragraphs should be two separate deletions. +- **Precedence on export**: if text has multiple CriticMarkup types (which shouldn't normally happen), the serializer uses the first match in order: addition > deletion > comment > highlight. ## Comments and threads @@ -106,11 +97,9 @@ mist: Document content with {==highlighted passage==}{>>This needs a citation<<} goes here. ``` -### How threads connect to marks +### How threads connect to the document -Threads are matched to `criticComment` marks in the document by comparing the `comment` field in the frontmatter with the text content of the comment mark. When a highlight is present, the `highlight` field records which passage the comment refers to. - -On import, threads from frontmatter are stored in a Yjs `Y.Map("threads")` for real-time sync between clients. Each thread gets an `imported-{i}` ID. +Threads are matched to comment marks in the document by comparing the `comment` field in the frontmatter with the comment text in the body. When a highlight is present, the `highlight` field records which passage the comment refers to. ### Thread fields @@ -134,13 +123,13 @@ Some text{>>A note about this point in the document<<} continues here. ### Preserving other frontmatter -Any existing YAML frontmatter keys outside `mist` are preserved through the round-trip. MIST only reads and writes the `mist` key. +Any existing YAML frontmatter keys outside `mist` are preserved through the round-trip. mist only reads and writes the `mist` key. ## Round-trip contract The export/import cycle should produce identical output: -1. **Download** serializes: ProseMirror marks to CriticMarkup delimiters, threads to YAML frontmatter. +1. **Download** serializes: CriticMarkup marks to delimiters, threads to YAML frontmatter. 2. **Upload** parses: CriticMarkup delimiters to marks, YAML frontmatter to threads. 3. **Download again** serializes the same state. @@ -149,12 +138,8 @@ The two downloaded files should be byte-identical. If they are not, it is a bug. ### Known edge cases - **Substitution syntax** is rejected on import — it must be manually converted to `{--old--}{++new++}` before uploading. -- **Multi-paragraph CriticMarkup** is not supported. Each line is parsed independently, so a deletion that spans two paragraphs should be two separate deletions. -- **Mark precedence on export**: if a text node has multiple CriticMarkup marks (which shouldn't happen — marks exclude each other), the serializer uses the first match in order: addition > deletion > comment > highlight. ## References - [CriticMarkup spec](https://criticmarkup.com/) - [`critic-markup` npm package](https://www.npmjs.com/package/critic-markup) -- [Yjs CRDT](https://yjs.dev/) -- [TipTap editor](https://tiptap.dev/) From eb6234e100f6c803553bef220d4adce5aac17ce3 Mon Sep 17 00:00:00 2001 From: Matt Webb Date: Thu, 9 Apr 2026 15:10:55 +0100 Subject: [PATCH 3/4] Tone down Markdown section, clarify limitations affect round-tripping Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/markdown-and-criticmarkup.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/markdown-and-criticmarkup.md b/docs/markdown-and-criticmarkup.md index 72d88983..e6097d2b 100644 --- a/docs/markdown-and-criticmarkup.md +++ b/docs/markdown-and-criticmarkup.md @@ -8,10 +8,12 @@ All content lives in a single Markdown file: the document text, formatting, sugg ## Markdown -mist documents are plain Markdown. The underlying text retains the Markdown characters (`**bold**`, `# heading`, etc.) rather than converting to rich-text nodes. The Markdown you type is the Markdown you get back on download — no AST conversion or lossy formatting round-trip. +mist documents are plain Markdown. The underlying text retains the Markdown characters (`**bold**`, `# heading`, etc.) rather than converting to rich-text nodes. The Markdown you type is the Markdown you get back on download. ### Limitations +These are editor limitations that prevent perfect round-tripping in some cases: + - The editor is paragraph-based. Each line is an independent paragraph. There is no concept of nested block structures (e.g. a list item containing a blockquote) — these render correctly in preview but are flat paragraphs in the editor. - No support for tables, footnotes, or extended Markdown syntax. From c11ca67a758191bf70d0bf1441d094b055124587 Mon Sep 17 00:00:00 2001 From: Matt Webb Date: Thu, 9 Apr 2026 15:16:46 +0100 Subject: [PATCH 4/4] Fix lint: suppress set-state-in-effect for hydration patterns These are legitimate uses of setState in useEffect for SSR hydration and external state sync. Suppress the new react-hooks rule inline. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/components/MobilePanel.tsx | 2 +- app/components/ThemeSelector.tsx | 1 + app/lib/useTheme.ts | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/components/MobilePanel.tsx b/app/components/MobilePanel.tsx index 62ff86ac..9c562f94 100644 --- a/app/components/MobilePanel.tsx +++ b/app/components/MobilePanel.tsx @@ -23,7 +23,7 @@ export default function MobilePanel({ className }: { className?: string }) { // Switch to comments tab when a thread is activated (e.g. clicking in editor) useEffect(() => { if (activeThreadId && activeThreadId !== prevThreadIdRef.current) { - setActiveTab("comments"); + setActiveTab("comments"); // eslint-disable-line react-hooks/set-state-in-effect } prevThreadIdRef.current = activeThreadId; }, [activeThreadId]); diff --git a/app/components/ThemeSelector.tsx b/app/components/ThemeSelector.tsx index bec81398..c5c0cb87 100644 --- a/app/components/ThemeSelector.tsx +++ b/app/components/ThemeSelector.tsx @@ -58,6 +58,7 @@ function ChevronDown() { export default function ThemeSelector() { const { theme, setTheme } = useTheme(); const [mounted, setMounted] = useState(false); + // eslint-disable-next-line react-hooks/set-state-in-effect useEffect(() => setMounted(true), []); const Icon = icons[theme]; diff --git a/app/lib/useTheme.ts b/app/lib/useTheme.ts index 731db090..5f225a28 100644 --- a/app/lib/useTheme.ts +++ b/app/lib/useTheme.ts @@ -11,7 +11,7 @@ export function useTheme() { useEffect(() => { const stored = localStorage.getItem(STORAGE_KEY) as Theme | null; if (stored && stored !== theme) { - setThemeState(stored); + setThemeState(stored); // eslint-disable-line react-hooks/set-state-in-effect document.documentElement.setAttribute("data-theme", stored); } }, []); // eslint-disable-line react-hooks/exhaustive-deps