Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion app/components/MobilePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
1 change: 1 addition & 0 deletions app/components/ThemeSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
2 changes: 1 addition & 1 deletion app/lib/useTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
147 changes: 147 additions & 0 deletions docs/markdown-and-criticmarkup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# 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 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.

## 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.

### Suggest mode

When suggest mode is active, typing and deleting produce CriticMarkup instead of direct edits:

- **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 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<<}
```

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 addition markers are removed, text stays.
- **Reject addition**: the text is removed.
- **Accept deletion**: the text is removed.
- **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

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 the document

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

| 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: 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.

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.

## References

- [CriticMarkup spec](https://criticmarkup.com/)
- [`critic-markup` npm package](https://www.npmjs.com/package/critic-markup)
Loading
Loading