···1+# Project Subagents Configuration
2+# Domain-specific agents for working on different parts of the codebase.
3+#
4+# When working on a specific domain, spawn a Task with subagent_type="Explore" or
5+# "general-purpose" and include the relevant agent's context in the prompt.
6+#
7+# Customize this file for YOUR project's structure. The domains below are examples.
8+9+# Example: Backend/Core agent
10+# [agents.backend]
11+# name = "Backend Agent"
12+# description = "API routes, database models, business logic"
13+# file_patterns = [
14+# "src/**/*.rs",
15+# "src/**/*.py",
16+# "app/**/*.py"
17+# ]
18+# focus_areas = [
19+# "Database operations",
20+# "API endpoints",
21+# "Business logic"
22+# ]
23+# instructions = """
24+# When working on backend:
25+# - Run tests before and after changes
26+# - Follow existing patterns for new endpoints
27+# - Maintain backwards compatibility
28+# """
29+30+# Example: Frontend agent
31+# [agents.frontend]
32+# name = "Frontend Agent"
33+# description = "UI components, state management, styling"
34+# file_patterns = [
35+# "web/src/**/*.ts",
36+# "web/src/**/*.tsx",
37+# "src/components/**"
38+# ]
39+# focus_areas = [
40+# "React components",
41+# "State management",
42+# "Styling and layout"
43+# ]
44+# instructions = """
45+# When working on frontend:
46+# - Test in browser after changes
47+# - Follow component patterns
48+# - Keep accessibility in mind
49+# """
50+51+# Example: Infrastructure agent
52+# [agents.infra]
53+# name = "Infrastructure Agent"
54+# description = "CI/CD, deployment, configuration"
55+# file_patterns = [
56+# ".github/workflows/**",
57+# "Dockerfile",
58+# "docker-compose.yml",
59+# "scripts/**"
60+# ]
61+# focus_areas = [
62+# "GitHub Actions",
63+# "Docker configuration",
64+# "Deployment scripts"
65+# ]
66+# instructions = """
67+# When working on infrastructure:
68+# - Test workflows locally when possible
69+# - Keep builds fast with caching
70+# - Document any manual steps
71+# """
···1+---
2+description: Recover context from decision graph and recent activity - USE THIS ON SESSION START
3+allowed-tools: Bash(deciduous:*, jj:*, git:*, cat:*, tail:*)
4+argument-hint: [focus-area]
5+---
6+7+# Context Recovery
8+9+**RUN THIS AT SESSION START.** The decision graph is your persistent memory.
10+11+## Step 1: Query the Graph
12+13+```bash
14+# See all decisions (look for recent ones and pending status)
15+deciduous nodes
16+17+# Filter by current bookmark (useful for feature work)
18+deciduous nodes --branch $(jj log -r @ -T 'bookmarks' --no-graph 2>/dev/null | head -1)
19+20+# See how decisions connect
21+deciduous edges
22+23+# What commands were recently run?
24+deciduous commands
25+```
26+27+**Bookmark-scoped context**: If working on a feature bookmark, filter nodes to see only decisions relevant to this bookmark. Main bookmark nodes are tagged with `[bookmark: main]`.
28+29+## Step 1.5: Audit Graph Integrity
30+31+**CRITICAL: Check that all nodes are logically connected.**
32+33+```bash
34+# Find nodes with no incoming edges (potential missing connections)
35+deciduous edges | cut -d'>' -f2 | cut -d' ' -f2 | sort -u > /tmp/has_parent.txt
36+deciduous nodes | tail -n+3 | awk '{print $1}' | while read id; do
37+ grep -q "^$id$" /tmp/has_parent.txt || echo "CHECK: $id"
38+done
39+```
40+41+**Review each flagged node:**
42+- Root `goal` nodes are VALID without parents
43+- `outcome` nodes MUST link back to their action/goal
44+- `action` nodes MUST link to their parent goal/decision
45+- `option` nodes MUST link to their parent decision
46+47+**Fix missing connections:**
48+```bash
49+deciduous link <parent_id> <child_id> -r "Retroactive connection - <reason>"
50+```
51+52+## Step 2: Check VCS State
53+54+```bash
55+jj status
56+jj log -n 10
57+jj diff --stat
58+```
59+60+## Step 3: Check Session Log
61+62+```bash
63+cat git.log | tail -30
64+```
65+66+## After Gathering Context, Report:
67+68+1. **Current bookmark** and pending changes
69+2. **Bookmark-specific decisions** (filter by bookmark if on feature bookmark)
70+3. **Recent decisions** (especially pending/active ones)
71+4. **Last actions** from jj log and command log
72+5. **Open questions** or unresolved observations
73+6. **Suggested next steps**
74+75+### Bookmark Configuration
76+77+Check `.deciduous/config.toml` for bookmark settings:
78+```toml
79+[branch]
80+main_branches = ["main", "master"] # Which bookmarks are "main"
81+auto_detect = true # Auto-detect bookmark on node creation
82+```
83+84+---
85+86+## REMEMBER: Real-Time Logging Required
87+88+After recovering context, you MUST follow the logging workflow:
89+90+```
91+EVERY USER REQUEST → Log goal/decision first
92+BEFORE CODE CHANGES → Log action
93+AFTER CHANGES → Log outcome, link nodes
94+BEFORE PUSH → deciduous sync
95+```
96+97+**The user is watching the graph live.** Log as you go, not after.
98+99+### Quick Logging Commands
100+101+```bash
102+# Root goal with user prompt (capture what the user asked for)
103+deciduous add goal "What we're trying to do" -c 90 -p "User asked: <their request>"
104+105+deciduous add action "What I'm about to implement" -c 85
106+deciduous add outcome "What happened" -c 95
107+deciduous link FROM TO -r "Connection reason"
108+109+# Capture prompt when user redirects mid-stream
110+deciduous add action "Switching approach" -c 85 -p "User said: use X instead"
111+112+deciduous sync # Do this frequently!
113+```
114+115+**When to use `--prompt`:** On root goals (always) and when user gives new direction mid-stream. Downstream nodes inherit context via edges.
116+117+---
118+119+## Focus Areas
120+121+If $ARGUMENTS specifies a focus, prioritize context for:
122+123+- **auth**: Authentication-related decisions
124+- **ui** / **graph**: UI and graph viewer state
125+- **cli**: Command-line interface changes
126+- **api**: API endpoints and data structures
127+128+---
129+130+## The Memory Loop
131+132+```
133+SESSION START
134+ ↓
135+Run /recover → See past decisions
136+ ↓
137+AUDIT → Fix any orphan nodes first!
138+ ↓
139+DO WORK → Log BEFORE each action
140+ ↓
141+CONNECT → Link new nodes immediately
142+ ↓
143+AFTER CHANGES → Log outcomes, observations
144+ ↓
145+AUDIT AGAIN → Any new orphans?
146+ ↓
147+BEFORE PUSH → deciduous sync
148+ ↓
149+PUSH → Live graph updates
150+ ↓
151+SESSION END → Final audit
152+ ↓
153+(repeat)
154+```
155+156+**Live graph**: https://notactuallytreyanastasio.github.io/deciduous/
157+158+---
159+160+## Multi-User Sync
161+162+If working in a team, check for and apply patches from teammates:
163+164+```bash
165+# Check for unapplied patches
166+deciduous diff status
167+168+# Apply all patches (idempotent - safe to run multiple times)
169+deciduous diff apply .deciduous/patches/*.json
170+171+# Preview before applying
172+deciduous diff apply --dry-run .deciduous/patches/teammate-feature.json
173+```
174+175+Before pushing your bookmark, export your decisions for teammates:
176+177+```bash
178+# Export your bookmark's decisions as a patch
179+BOOKMARK=$(jj log -r @ -T 'bookmarks' --no-graph 2>/dev/null | head -1)
180+deciduous diff export --branch "$BOOKMARK" \
181+ -o .deciduous/patches/$(whoami)-$BOOKMARK.json
182+183+# The patch file will be tracked automatically by jj
184+```
185+186+## Why This Matters
187+188+- Context loss during compaction loses your reasoning
189+- The graph survives - query it early, query it often
190+- Retroactive logging misses details - log in the moment
191+- The user sees the graph live - show your work
192+- Patches share reasoning with teammates
···1+---
2+description: Manage decision graph - track algorithm choices and reasoning
3+allowed-tools: Bash(deciduous:*)
4+argument-hint: <action> [args...]
5+---
6+7+# Decision Graph Management
8+9+**Log decisions IN REAL-TIME as you work, not retroactively.**
10+11+## When to Use This
12+13+| You're doing this... | Log this type | Command |
14+|---------------------|---------------|---------|
15+| Starting a new feature | `goal` **with -p** | `/decision add goal "Add user auth" -p "user request"` |
16+| Choosing between approaches | `decision` | `/decision add decision "Choose auth method"` |
17+| Considering an option | `option` | `/decision add option "JWT tokens"` |
18+| About to write code | `action` | `/decision add action "Implementing JWT"` |
19+| Noticing something | `observation` | `/decision add obs "Found existing auth code"` |
20+| Finished something | `outcome` | `/decision add outcome "JWT working"` |
21+22+## Quick Commands
23+24+Based on $ARGUMENTS:
25+26+### View Commands
27+- `nodes` or `list` -> `deciduous nodes`
28+- `edges` -> `deciduous edges`
29+- `graph` -> `deciduous graph`
30+- `commands` -> `deciduous commands`
31+32+### Create Nodes (with optional metadata)
33+- `add goal <title>` -> `deciduous add goal "<title>" -c 90`
34+- `add decision <title>` -> `deciduous add decision "<title>" -c 75`
35+- `add option <title>` -> `deciduous add option "<title>" -c 70`
36+- `add action <title>` -> `deciduous add action "<title>" -c 85`
37+- `add obs <title>` -> `deciduous add observation "<title>" -c 80`
38+- `add outcome <title>` -> `deciduous add outcome "<title>" -c 90`
39+40+### Optional Flags for Nodes
41+- `-c, --confidence <0-100>` - Confidence level
42+- `-p, --prompt "..."` - Store the user prompt that triggered this node
43+- `-f, --files "file1.rs,file2.rs"` - Associate files with this node
44+- `-b, --branch <name>` - jj bookmark (auto-detected by default)
45+- `--no-branch` - Skip bookmark auto-detection
46+- `--commit <hash|HEAD>` - Link to commit (use HEAD for current)
47+48+### ⚠️ CRITICAL: Link Commits to Actions/Outcomes
49+50+**After every jj commit, link it to the decision graph!**
51+52+```bash
53+jj commit -m "feat: add auth"
54+deciduous add action "Implemented auth" -c 90 --commit HEAD
55+deciduous link <goal_id> <action_id> -r "Implementation"
56+```
57+58+## CRITICAL: Capture VERBATIM User Prompts
59+60+**Prompts must be the EXACT user message, not a summary.** When a user request triggers new work, capture their full message word-for-word.
61+62+**BAD - summaries are useless for context recovery:**
63+```bash
64+# DON'T DO THIS - this is a summary, not a prompt
65+deciduous add goal "Add auth" -p "User asked: add login to the app"
66+```
67+68+**GOOD - verbatim prompts enable full context recovery:**
69+```bash
70+# Use --prompt-stdin for multi-line prompts
71+deciduous add goal "Add auth" -c 90 --prompt-stdin << 'EOF'
72+I need to add user authentication to the app. Users should be able to sign up
73+with email/password, and we need OAuth support for Google and GitHub. The auth
74+should use JWT tokens with refresh token rotation.
75+EOF
76+77+# Or use the prompt command to update existing nodes
78+deciduous prompt 42 << 'EOF'
79+The full verbatim user message goes here...
80+EOF
81+```
82+83+**When to capture prompts:**
84+- Root `goal` nodes: YES - the FULL original request
85+- Major direction changes: YES - when user redirects the work
86+- Routine downstream nodes: NO - they inherit context via edges
87+88+**Updating prompts on existing nodes:**
89+```bash
90+deciduous prompt <node_id> "full verbatim prompt here"
91+cat prompt.txt | deciduous prompt <node_id> # Multi-line from stdin
92+```
93+94+Prompts are viewable in the TUI detail panel (`deciduous tui`) and web viewer.
95+96+## Bookmark-Based Grouping
97+98+**Nodes are automatically tagged with the current jj bookmark.** This enables filtering by feature/PR.
99+100+### How It Works
101+- When you create a node, the current jj bookmark is stored in `metadata_json`
102+- Configure which bookmarks are "main" in `.deciduous/config.toml`:
103+ ```toml
104+ [branch]
105+ main_branches = ["main", "master"] # Bookmarks not treated as "feature bookmarks"
106+ auto_detect = true # Auto-detect bookmark on node creation
107+ ```
108+- Nodes on feature bookmarks (anything not in `main_branches`) can be grouped/filtered
109+110+### CLI Filtering
111+```bash
112+# Show only nodes from specific bookmark
113+deciduous nodes --branch main
114+deciduous nodes --branch feature-auth
115+deciduous nodes -b my-feature
116+117+# Override auto-detection when creating nodes
118+deciduous add goal "Feature work" -b feature-x # Force specific bookmark
119+deciduous add goal "Universal note" --no-branch # No bookmark tag
120+```
121+122+### Web UI Bookmark Filter
123+The graph viewer shows a bookmark dropdown in the stats bar:
124+- "All bookmarks" shows everything
125+- Select a specific bookmark to filter all views (Chains, Timeline, Graph, DAG)
126+127+### When to Use Bookmark Grouping
128+- **Feature work**: Nodes created on `feature-auth` bookmark auto-grouped
129+- **PR context**: Filter to see only decisions for a specific PR
130+- **Cross-cutting concerns**: Use `--no-bookmark` for universal notes
131+- **Retrospectives**: Filter by bookmark to see decision history per feature
132+133+### Create Edges
134+- `link <from> <to> [reason]` -> `deciduous link <from> <to> -r "<reason>"`
135+136+### Sync Graph
137+- `sync` -> `deciduous sync`
138+139+### Multi-User Sync (Diff/Patch)
140+- `diff export -o <file>` -> `deciduous diff export -o <file>` (export nodes as patch)
141+- `diff export --nodes 1-10 -o <file>` -> export specific nodes
142+- `diff export --branch feature-x -o <file>` -> export nodes from branch
143+- `diff apply <file>` -> `deciduous diff apply <file>` (apply patch, idempotent)
144+- `diff apply --dry-run <file>` -> preview without applying
145+- `diff status` -> `deciduous diff status` (list patches in .deciduous/patches/)
146+- `migrate` -> `deciduous migrate` (add change_id columns for sync)
147+148+### Export & Visualization
149+- `dot` -> `deciduous dot` (output DOT to stdout)
150+- `dot --png` -> `deciduous dot --png -o graph.dot` (generate PNG)
151+- `dot --nodes 1-11` -> `deciduous dot --nodes 1-11` (filter nodes)
152+- `writeup` -> `deciduous writeup` (generate PR writeup)
153+- `writeup -t "Title" --nodes 1-11` -> filtered writeup
154+155+## Node Types
156+157+| Type | Purpose | Example |
158+|------|---------|---------|
159+| `goal` | High-level objective | "Add user authentication" |
160+| `decision` | Choice point with options | "Choose auth method" |
161+| `option` | Possible approach | "Use JWT tokens" |
162+| `action` | Something implemented | "Added JWT middleware" |
163+| `outcome` | Result of action | "JWT auth working" |
164+| `observation` | Finding or data point | "Existing code uses sessions" |
165+166+## Edge Types
167+168+| Type | Meaning |
169+|------|---------|
170+| `leads_to` | Natural progression |
171+| `chosen` | Selected option |
172+| `rejected` | Not selected (include reason!) |
173+| `requires` | Dependency |
174+| `blocks` | Preventing progress |
175+| `enables` | Makes something possible |
176+177+## Graph Integrity - CRITICAL
178+179+**Every node MUST be logically connected.** Floating nodes break the graph's value.
180+181+### Connection Rules
182+| Node Type | MUST connect to | Example |
183+|-----------|----------------|---------|
184+| `outcome` | The action/goal it resolves | "JWT working" → links FROM "Implementing JWT" |
185+| `action` | The decision/goal that spawned it | "Implementing JWT" → links FROM "Add auth" |
186+| `option` | Its parent decision | "Use JWT" → links FROM "Choose auth method" |
187+| `observation` | Related goal/action/decision | "Found existing code" → links TO relevant node |
188+| `decision` | Parent goal (if any) | "Choose auth" → links FROM "Add auth feature" |
189+| `goal` | Can be a root (no parent needed) | Root goals are valid orphans |
190+191+### Audit Checklist
192+Ask yourself after creating nodes:
193+1. Does every **outcome** link back to what caused it?
194+2. Does every **action** link to why you did it?
195+3. Does every **option** link to its decision?
196+4. Are there **dangling outcomes** with no parent action/goal?
197+198+### Find Disconnected Nodes
199+```bash
200+# List nodes with no incoming edges (potential orphans)
201+deciduous edges | cut -d'>' -f2 | cut -d' ' -f2 | sort -u > /tmp/has_parent.txt
202+deciduous nodes | tail -n+3 | awk '{print $1}' | while read id; do
203+ grep -q "^$id$" /tmp/has_parent.txt || echo "CHECK: $id"
204+done
205+```
206+Note: Root goals are VALID orphans. Outcomes/actions/options usually are NOT.
207+208+### Fix Missing Connections
209+```bash
210+deciduous link <parent_id> <child_id> -r "Retroactive connection - <why>"
211+```
212+213+### When to Audit
214+- Before every `deciduous sync`
215+- After creating multiple nodes quickly
216+- At session end
217+- When the web UI graph looks disconnected
218+219+## Multi-User Sync
220+221+**Problem**: Multiple users work on the same codebase, each with a local `.deciduous/deciduous.db` (ignored in VCS). How to share decisions?
222+223+**Solution**: Dual-ID model. Each node has:
224+- `id` (integer): Local database primary key, different per machine
225+- `change_id` (UUID): Globally unique, stable across all databases
226+227+### Export Workflow
228+```bash
229+# Export nodes from your bookmark as a patch file
230+deciduous diff export --branch feature-x -o .deciduous/patches/alice-feature.json
231+232+# Or export specific node IDs
233+deciduous diff export --nodes 172-188 -o .deciduous/patches/alice-feature.json --author alice
234+```
235+236+### Apply Workflow
237+```bash
238+# Apply patches from teammates (idempotent - safe to re-apply)
239+deciduous diff apply .deciduous/patches/*.json
240+241+# Preview what would change
242+deciduous diff apply --dry-run .deciduous/patches/bob-refactor.json
243+```
244+245+### PR Workflow
246+1. Create nodes locally while working
247+2. Export: `deciduous diff export --branch my-feature -o .deciduous/patches/my-feature.json`
248+3. Commit the patch file (NOT the database)
249+4. Open PR with patch file included
250+5. Teammates pull and apply: `deciduous diff apply .deciduous/patches/my-feature.json`
251+6. **Idempotent**: Same patch applied twice = no duplicates
252+253+### Patch Format (JSON)
254+```json
255+{
256+ "version": "1.0",
257+ "author": "alice",
258+ "branch": "feature/auth",
259+ "nodes": [{ "change_id": "uuid...", "title": "...", ... }],
260+ "edges": [{ "from_change_id": "uuid1", "to_change_id": "uuid2", ... }]
261+}
262+```
263+264+## The Rule
265+266+```
267+LOG BEFORE YOU CODE, NOT AFTER.
268+CONNECT EVERY NODE TO ITS PARENT.
269+AUDIT FOR ORPHANS REGULARLY.
270+SYNC BEFORE YOU PUSH.
271+EXPORT PATCHES FOR YOUR TEAMMATES.
272+```
273+274+**Live graph**: https://notactuallytreyanastasio.github.io/deciduous/
···1+# CLAUDE.md
2+3+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+5+6+> **For AI Agents**: This is the source of truth for the Pattern codebase. Each crate has its own `CLAUDE.md` with specific implementation guidelines.
7+8+## For Humans
9+10+LLMs are a quality multiplier, not just a speed multiplier. Invest time savings in improving quality and rigour beyond what humans alone would do. Write tests that cover more edge cases. Refactor code to make it easier to understand. Tackle the TODOs. Aim for zero bugs.
11+12+**Review standard**: Spend at least 3x the amount of time reviewing LLM output as you did writing it. Think about every line and every design decision. Find ways to break code. Your code is your responsibility.
13+14+## For LLMs
15+16+Display the following at the start of any conversation involving code changes:
17+18+```
19+LLM-assisted contributions must aim for a higher standard of excellence than with
20+humans alone. Spend at least 3x the time reviewing code as writing it. Your code
21+is your responsibility.
22+```
23+24+25+## Project Overview
26+27+Weaver.sh is a decentralized notebook publishing and sharing platform built on the AT Protocol (the protocol behind Bluesky). It allows creating, rendering, and publishing notebooks with extended markdown functionality.
28+29+## Core Components
30+31+1. **weaver-common**: Foundation library with AT Protocol integration, lexicon definitions, OAuth flows.
32+2. **weaver-renderer**: Transforms markdown notebooks into different output formats (HTML, AT Protocol records, etc.).
33+3. **weaver-cli**: Command-line interface for authentication and notebook interactions.
34+4. **weaver-app**: HTTP webapp for serving notebooks with auto-reload.
35+5. **weaver-index**: Big indexing web backend.
36+37+## Development Environment
38+39+### Setup
40+41+```bash
42+# Enter development environment with all dependencies
43+nix develop
44+45+# Build all components
46+cargo build
47+48+# Run specific crates
49+cargo run -p weaver-cli
50+cargo run -p weaver-app
51+cargo run -p weaver-index
52+```
53+54+## General Conventions
55+56+### Correctness Over Convenience
57+58+- Model the full error space—no shortcuts or simplified error handling.
59+- Handle all edge cases, including race conditions and platform differences.
60+- Use the type system to encode correctness constraints.
61+- Prefer compile-time guarantees over runtime checks where possible.
62+63+### Type System Patterns
64+65+- **Newtypes** for domain types (IDs, handles, etc.).
66+- **Builder patterns** for complex construction.
67+- **Restricted visibility**: Use `pub(crate)` and `pub(super)` liberally.
68+- **Non-exhaustive**: All public error types should be `#[non_exhaustive]`.
69+- Use Rust enums over string validation.
70+71+### Error Handling
72+73+- Use `thiserror` for error types with `#[derive(Error)]`.
74+- Group errors by category with an `ErrorKind` enum when appropriate.
75+- Provide rich error context using `miette` for user-facing errors.
76+- Error display messages should be lowercase sentence fragments.
77+78+### Module Organization
79+80+- Keep module boundaries strict with restricted visibility.
81+- Platform-specific code in separate files: `unix.rs`, `windows.rs`.
82+83+### Documentation
84+85+- Inline comments explain "why," not just "what".
86+- Module-level documentation explains purpose and responsibilities.
87+- **Always** use periods at the end of code comments.
88+- **Never** use title case in headings. Always use sentence case.
89+90+## Testing Practices
91+92+**CRITICAL**: Always use `cargo nextest run` to run tests. Never use `cargo test` directly.
93+94+```bash
95+# Run all tests
96+cargo nextest run
97+98+# Specific crate
99+cargo nextest run -p pattern-db
100+101+# With output
102+cargo nextest run --nocapture
103+104+# Doctests (nextest doesn't support these)
105+cargo test --doc
106+```
107+108+## Common Development Commands
109+110+### Testing
111+112+```bash
113+# Run all tests with nextest
114+cargo nextest run
115+116+# Run specific tests
117+cargo nextest run -p weaver-common
118+```
119+120+### Code Quality
121+122+```bash
123+# Run linter
124+cargo clippy -- --deny warnings
125+126+# Format code
127+cargo fmt
128+129+# Verify dependencies
130+cargo deny check
131+```
132+133+### Lexicon Generation
134+135+The project uses custom AT Protocol lexicons defined in JSON format. To generate Rust code from these definitions:
136+137+```bash
138+nix run ../jacquard
139+140+### Building with Nix
141+142+```bash
143+# Run all checks (clippy, fmt, tests)
144+nix flake check
145+146+# Build specific packages
147+nix build .#weaver-cli
148+nix build .#weaver-app
149+```
150+151+152+## Architecture
153+154+### Data Flow
155+156+1. Markdown notebooks are parsed and processed by weaver-renderer
157+2. Content can be rendered as static sites or published to AT Protocol PDSes
158+3. Authentication with AT Protocol servers happens via OAuth
159+160+### Key Components
161+162+- **WeaverAgent**: Manages connections to AT Protocol Personal Data Servers (PDS)
163+- **Notebook Structure**: Books, chapters, entries with extended markdown
164+- **Renderer**: Processes markdown with extended features (wiki links, embeds, math)
165+- **AT Protocol Lexicons**: Custom data schemas extending the protocol for notebooks
166+167+### Authentication Flow
168+169+1. CLI initiates OAuth flow with a PDS
170+2. Local OAuth server handles callbacks on port 4000
171+3. Tokens are stored in the local filesystem
172+173+## Feature Flags
174+175+- **dev**: Enables development-specific features
176+- **native**: Configures OAuth for native clients
177+178+## Working with Jacquard
179+180+This project uses Jacquard, a zero-copy AT Protocol library for Rust. **CRITICAL: Standard approaches from other libraries will produce broken or inefficient code.**
181+182+**ALWAYS use the working-with-jacquard skill** when working with AT Protocol types, XRPC calls, or identity resolution.
183+184+Key patterns to internalize:
185+- **NEVER use `for<'de> Deserialize<'de>` bounds** - this breaks ALL Jacquard types
186+- Use `Did::new()`, `Handle::new_static()`, etc. - **never `FromStr::parse()`**
187+- Use `Data<'a>` instead of `serde_json::Value`
188+- Use `.into_output()` when returning from functions, `.parse()` for immediate processing
189+- Derive `IntoStatic` on all custom types with lifetimes
190+191+See `~/.claude/skills/working-with-jacquard/SKILL.md` for complete guidance.
192+193+## Commit Message Style
194+195+```
196+[crate-name] brief description
197+```
198+199+Examples:
200+- `[pattern-core] add supervisor coordination pattern`
201+- `[pattern-db] fix FTS5 query escaping`
202+- `[meta] update MSRV to Rust 1.83`
203+204+### Conventions
205+206+- Use `[meta]` for cross-cutting concerns (deps, CI, workspace config).
207+- Keep descriptions concise but descriptive.
208+- **Atomic commits**: Each commit should be a logical unit of change.
209+- **Bisect-able history**: Every commit must build and pass all checks.
210+- **Separate concerns**: Format fixes and refactoring separate from features.
···1+You are an expert [0.7 Dioxus](https://dioxuslabs.com/learn/0.7) assistant. Dioxus 0.7 changes every api in dioxus. Only use this up to date documentation. `cx`, `Scope`, and `use_state` are gone
2+3+Provide concise code examples with detailed descriptions
4+5+# Dioxus Dependency
6+7+You can add Dioxus to your `Cargo.toml` like this:
8+9+```toml
10+[dependencies]
11+dioxus = { version = "0.7.0" }
12+13+[features]
14+default = ["web", "webview", "server"]
15+web = ["dioxus/web"]
16+webview = ["dioxus/desktop"]
17+server = ["dioxus/server"]
18+```
19+20+# Launching your application
21+22+You need to create a main function that sets up the Dioxus runtime and mounts your root component.
23+24+```rust
25+use dioxus::prelude::*;
26+27+fn main() {
28+ dioxus::launch(App);
29+}
30+31+#[component]
32+fn App() -> Element {
33+ rsx! { "Hello, Dioxus!" }
34+}
35+```
36+37+Then serve with `dx serve`:
38+39+```sh
40+curl -sSL http://dioxus.dev/install.sh | sh
41+dx serve
42+```
43+44+# UI with RSX
45+46+```rust
47+rsx! {
48+ div {
49+ class: "container", // Attribute
50+ color: "red", // Inline styles
51+ width: if condition { "100%" }, // Conditional attributes
52+ "Hello, Dioxus!"
53+ }
54+ // Prefer loops over iterators
55+ for i in 0..5 {
56+ div { "{i}" } // use elements or components directly in loops
57+ }
58+ if condition {
59+ div { "Condition is true!" } // use elements or components directly in conditionals
60+ }
61+62+ {children} // Expressions are wrapped in brace
63+ {(0..5).map(|i| rsx! { span { "Item {i}" } })} // Iterators must be wrapped in braces
64+}
65+```
66+67+# Assets
68+69+The asset macro can be used to link to local files to use in your project. All links start with `/` and are relative to the root of your project.
70+71+```rust
72+rsx! {
73+ img {
74+ src: asset!("/assets/image.png"),
75+ alt: "An image",
76+ }
77+}
78+```
79+80+## Styles
81+82+The `document::Stylesheet` component will inject the stylesheet into the `<head>` of the document
83+84+```rust
85+rsx! {
86+ document::Stylesheet {
87+ href: asset!("/assets/styles.css"),
88+ }
89+}
90+```
91+92+# Components
93+94+Components are the building blocks of apps
95+96+* Component are functions annotated with the `#[component]` macro.
97+* The function name must start with a capital letter or contain an underscore.
98+* A component re-renders only under two conditions:
99+ 1. Its props change (as determined by `PartialEq`).
100+ 2. An internal reactive state it depends on is updated.
101+102+```rust
103+#[component]
104+fn Input(mut value: Signal<String>) -> Element {
105+ rsx! {
106+ input {
107+ value,
108+ oninput: move |e| {
109+ *value.write() = e.value();
110+ },
111+ onkeydown: move |e| {
112+ if e.key() == Key::Enter {
113+ value.write().clear();
114+ }
115+ },
116+ }
117+ }
118+}
119+```
120+121+Each component accepts function arguments (props)
122+123+* Props must be owned values, not references. Use `String` and `Vec<T>` instead of `&str` or `&[T]`.
124+* Props must implement `PartialEq` and `Clone`.
125+* To make props reactive and copy, you can wrap the type in `ReadOnlySignal`. Any reactive state like memos and resources that read `ReadOnlySignal` props will automatically re-run when the prop changes.
126+127+# State
128+129+A signal is a wrapper around a value that automatically tracks where it's read and written. Changing a signal's value causes code that relies on the signal to rerun.
130+131+## Local State
132+133+The `use_signal` hook creates state that is local to a single component. You can call the signal like a function (e.g. `my_signal()`) to clone the value, or use `.read()` to get a reference. `.write()` gets a mutable reference to the value.
134+135+Use `use_memo` to create a memoized value that recalculates when its dependencies change. Memos are useful for expensive calculations that you don't want to repeat unnecessarily.
136+137+```rust
138+#[component]
139+fn Counter() -> Element {
140+ let mut count = use_signal(|| 0);
141+ let mut doubled = use_memo(move || count() * 2); // doubled will re-run when count changes because it reads the signal
142+143+ rsx! {
144+ h1 { "Count: {count}" } // Counter will re-render when count changes because it reads the signal
145+ h2 { "Doubled: {doubled}" }
146+ button {
147+ onclick: move |_| *count.write() += 1, // Writing to the signal rerenders Counter
148+ "Increment"
149+ }
150+ button {
151+ onclick: move |_| count.with_mut(|count| *count += 1), // use with_mut to mutate the signal
152+ "Increment with with_mut"
153+ }
154+ }
155+}
156+```
157+158+## Context API
159+160+The Context API allows you to share state down the component tree. A parent provides the state using `use_context_provider`, and any child can access it with `use_context`
161+162+```rust
163+#[component]
164+fn App() -> Element {
165+ let mut theme = use_signal(|| "light".to_string());
166+ use_context_provider(|| theme); // Provide a type to children
167+ rsx! { Child {} }
168+}
169+170+#[component]
171+fn Child() -> Element {
172+ let theme = use_context::<Signal<String>>(); // Consume the same type
173+ rsx! {
174+ div {
175+ "Current theme: {theme}"
176+ }
177+ }
178+}
179+```
180+181+# Async
182+183+For state that depends on an asynchronous operation (like a network request), Dioxus provides a hook called `use_resource`. This hook manages the lifecycle of the async task and provides the result to your component.
184+185+* The `use_resource` hook takes an `async` closure. It re-runs this closure whenever any signals it depends on (reads) are updated
186+* The `Resource` object returned can be in several states when read:
187+1. `None` if the resource is still loading
188+2. `Some(value)` if the resource has successfully loaded
189+190+```rust
191+let mut dog = use_resource(move || async move {
192+ // api request
193+});
194+195+match dog() {
196+ Some(dog_info) => rsx! { Dog { dog_info } },
197+ None => rsx! { "Loading..." },
198+}
199+```
200+201+# Routing
202+203+All possible routes are defined in a single Rust `enum` that derives `Routable`. Each variant represents a route and is annotated with `#[route("/path")]`. Dynamic Segments can capture parts of the URL path as parameters by using `:name` in the route string. These become fields in the enum variant.
204+205+The `Router<Route> {}` component is the entry point that manages rendering the correct component for the current URL.
206+207+You can use the `#[layout(NavBar)]` to create a layout shared between pages and place an `Outlet<Route> {}` inside your layout component. The child routes will be rendered in the outlet.
208+209+```rust
210+#[derive(Routable, Clone, PartialEq)]
211+enum Route {
212+ #[layout(NavBar)] // This will use NavBar as the layout for all routes
213+ #[route("/")]
214+ Home {},
215+ #[route("/blog/:id")] // Dynamic segment
216+ BlogPost { id: i32 },
217+}
218+219+#[component]
220+fn NavBar() -> Element {
221+ rsx! {
222+ a { href: "/", "Home" }
223+ Outlet<Route> {} // Renders Home or BlogPost
224+ }
225+}
226+227+#[component]
228+fn App() -> Element {
229+ rsx! { Router::<Route> {} }
230+}
231+```
232+233+```toml
234+dioxus = { version = "0.7.0", features = ["router"] }
235+```
236+237+# Fullstack
238+239+Fullstack enables server rendering and ipc calls. It uses Cargo features (`server` and a client feature like `web`) to split the code into a server and client binaries.
240+241+```toml
242+dioxus = { version = "0.7.0", features = ["fullstack"] }
243+```
244+245+## Server Functions
246+247+Use the `#[post]` / `#[get]` macros to define an `async` function that will only run on the server. On the server, this macro generates an API endpoint. On the client, it generates a function that makes an HTTP request to that endpoint.
248+249+```rust
250+#[post("/api/double/:path/&query")]
251+async fn double_server(number: i32, path: String, query: i32) -> Result<i32, ServerFnError> {
252+ tokio::time::sleep(std::time::Duration::from_secs(1)).await;
253+ Ok(number * 2)
254+}
255+```
256+257+## Hydration
258+259+Hydration is the process of making a server-rendered HTML page interactive on the client. The server sends the initial HTML, and then the client-side runs, attaches event listeners, and takes control of future rendering.
260+261+### Errors
262+The initial UI rendered by the component on the client must be identical to the UI rendered on the server.
263+264+* Use the `use_server_future` hook instead of `use_resource`. It runs the future on the server, serializes the result, and sends it to the client, ensuring the client has the data immediately for its first render.
265+* Any code that relies on browser-specific APIs (like accessing `localStorage`) must be run *after* hydration. Place this code inside a `use_effect` hook.
···1+//! DOM synchronization for the markdown editor.
2+//!
3+//! Handles syncing cursor/selection state between the browser DOM and the
4+//! editor document model, and updating paragraph DOM elements.
5+6+use wasm_bindgen::JsCast;
7+use weaver_editor_core::{
8+ CursorSync, OffsetMapping, SnapDirection, find_nearest_valid_position, is_valid_cursor_position,
9+};
10+11+use crate::cursor::restore_cursor_position;
12+13+/// Result of syncing cursor from DOM.
14+#[derive(Debug, Clone)]
15+pub enum CursorSyncResult {
16+ /// Cursor is collapsed at this offset.
17+ Cursor(usize),
18+ /// Selection from anchor to head.
19+ Selection { anchor: usize, head: usize },
20+ /// Could not determine cursor position.
21+ None,
22+}
23+24+/// Browser-based cursor sync implementation.
25+///
26+/// Holds reference to editor element ID and provides methods to sync
27+/// cursor state from DOM back to the editor model.
28+pub struct BrowserCursorSync {
29+ editor_id: String,
30+}
31+32+impl BrowserCursorSync {
33+ /// Create a new browser cursor sync for the given editor element.
34+ pub fn new(editor_id: impl Into<String>) -> Self {
35+ Self {
36+ editor_id: editor_id.into(),
37+ }
38+ }
39+40+ /// Get the editor element ID.
41+ pub fn editor_id(&self) -> &str {
42+ &self.editor_id
43+ }
44+}
45+46+impl CursorSync for BrowserCursorSync {
47+ fn sync_cursor_from_platform<F, G>(
48+ &self,
49+ offset_map: &[OffsetMapping],
50+ direction_hint: Option<SnapDirection>,
51+ on_cursor: F,
52+ on_selection: G,
53+ ) where
54+ F: FnOnce(usize),
55+ G: FnOnce(usize, usize),
56+ {
57+ if let Some(result) = sync_cursor_from_dom_impl(&self.editor_id, offset_map, direction_hint)
58+ {
59+ match result {
60+ CursorSyncResult::Cursor(offset) => on_cursor(offset),
61+ CursorSyncResult::Selection { anchor, head } => {
62+ if anchor == head {
63+ on_cursor(anchor);
64+ } else {
65+ on_selection(anchor, head);
66+ }
67+ }
68+ CursorSyncResult::None => {}
69+ }
70+ }
71+ }
72+}
73+74+/// Sync cursor state from DOM selection, returning the result.
75+///
76+/// This is the core implementation that reads the browser's selection state
77+/// and converts it to character offsets using the offset map.
78+pub fn sync_cursor_from_dom_impl(
79+ editor_id: &str,
80+ offset_map: &[OffsetMapping],
81+ direction_hint: Option<SnapDirection>,
82+) -> Option<CursorSyncResult> {
83+ if offset_map.is_empty() {
84+ return Some(CursorSyncResult::None);
85+ }
86+87+ let window = web_sys::window()?;
88+ let dom_document = window.document()?;
89+ let editor_element = dom_document.get_element_by_id(editor_id)?;
90+91+ let selection = window.get_selection().ok()??;
92+93+ let anchor_node = selection.anchor_node()?;
94+ let focus_node = selection.focus_node()?;
95+ let anchor_offset = selection.anchor_offset() as usize;
96+ let focus_offset = selection.focus_offset() as usize;
97+98+ let anchor_char = dom_position_to_text_offset(
99+ &dom_document,
100+ &editor_element,
101+ &anchor_node,
102+ anchor_offset,
103+ offset_map,
104+ direction_hint,
105+ );
106+ let focus_char = dom_position_to_text_offset(
107+ &dom_document,
108+ &editor_element,
109+ &focus_node,
110+ focus_offset,
111+ offset_map,
112+ direction_hint,
113+ );
114+115+ match (anchor_char, focus_char) {
116+ (Some(anchor), Some(head)) => {
117+ if anchor == head {
118+ Some(CursorSyncResult::Cursor(head))
119+ } else {
120+ Some(CursorSyncResult::Selection { anchor, head })
121+ }
122+ }
123+ _ => {
124+ tracing::warn!("Could not map DOM selection to text offsets");
125+ Some(CursorSyncResult::None)
126+ }
127+ }
128+}
129+130+/// Convert a DOM position (node + offset) to a text char offset.
131+///
132+/// Walks up from the node to find a container with a node ID, then uses
133+/// the offset map to convert the UTF-16 offset to a character offset.
134+pub fn dom_position_to_text_offset(
135+ dom_document: &web_sys::Document,
136+ editor_element: &web_sys::Element,
137+ node: &web_sys::Node,
138+ offset_in_text_node: usize,
139+ offset_map: &[OffsetMapping],
140+ direction_hint: Option<SnapDirection>,
141+) -> Option<usize> {
142+ // Find the containing element with a node ID (walk up from text node).
143+ let mut current_node = node.clone();
144+ let mut walked_from: Option<web_sys::Node> = None;
145+146+ let node_id = loop {
147+ if let Some(element) = current_node.dyn_ref::<web_sys::Element>() {
148+ if element == editor_element {
149+ // Selection is on the editor container itself.
150+ if let Some(ref walked_node) = walked_from {
151+ // We walked up from a descendant - find which mapping it belongs to.
152+ for mapping in offset_map {
153+ if let Some(elem) = dom_document.get_element_by_id(&mapping.node_id) {
154+ let elem_node: &web_sys::Node = elem.as_ref();
155+ if elem_node.contains(Some(walked_node)) {
156+ return Some(mapping.char_range.start);
157+ }
158+ }
159+ }
160+ break None;
161+ }
162+163+ // Selection is directly on the editor container (e.g., Cmd+A).
164+ let child_count = editor_element.child_element_count() as usize;
165+ if offset_in_text_node == 0 {
166+ return Some(0);
167+ } else if offset_in_text_node >= child_count {
168+ return offset_map.last().map(|m| m.char_range.end);
169+ }
170+ break None;
171+ }
172+173+ let id = element
174+ .get_attribute("id")
175+ .or_else(|| element.get_attribute("data-node-id"));
176+177+ if let Some(id) = id {
178+ let is_node_id = id.starts_with('n') || id.contains("-n");
179+ if is_node_id {
180+ break Some(id);
181+ }
182+ }
183+ }
184+185+ walked_from = Some(current_node.clone());
186+ current_node = current_node.parent_node()?;
187+ };
188+189+ let node_id = node_id?;
190+191+ let container = dom_document.get_element_by_id(&node_id).or_else(|| {
192+ let selector = format!("[data-node-id='{}']", node_id);
193+ dom_document.query_selector(&selector).ok().flatten()
194+ })?;
195+196+ // Calculate UTF-16 offset from start of container to the position.
197+ let mut utf16_offset_in_container = 0;
198+199+ let node_is_container = node
200+ .dyn_ref::<web_sys::Element>()
201+ .map(|e| e == &container)
202+ .unwrap_or(false);
203+204+ if node_is_container {
205+ // offset_in_text_node is a child index.
206+ let child_index = offset_in_text_node;
207+ let children = container.child_nodes();
208+ let mut text_counted = 0usize;
209+210+ for i in 0..child_index.min(children.length() as usize) {
211+ if let Some(child) = children.get(i as u32) {
212+ if let Some(text) = child.text_content() {
213+ text_counted += text.encode_utf16().count();
214+ }
215+ }
216+ }
217+ utf16_offset_in_container = text_counted;
218+ } else {
219+ // Normal case: node is a text node, walk to find it.
220+ if let Ok(walker) =
221+ dom_document.create_tree_walker_with_what_to_show(&container, 0xFFFFFFFF)
222+ {
223+ let mut skip_until_exit: Option<web_sys::Element> = None;
224+225+ while let Ok(Some(dom_node)) = walker.next_node() {
226+ if let Some(ref skip_elem) = skip_until_exit {
227+ if !skip_elem.contains(Some(&dom_node)) {
228+ skip_until_exit = None;
229+ }
230+ }
231+232+ if skip_until_exit.is_none() {
233+ if let Some(element) = dom_node.dyn_ref::<web_sys::Element>() {
234+ if element.get_attribute("contenteditable").as_deref() == Some("false") {
235+ skip_until_exit = Some(element.clone());
236+ continue;
237+ }
238+ }
239+ }
240+241+ if skip_until_exit.is_some() {
242+ continue;
243+ }
244+245+ if dom_node.node_type() == web_sys::Node::TEXT_NODE {
246+ if &dom_node == node {
247+ utf16_offset_in_container += offset_in_text_node;
248+ break;
249+ }
250+251+ if let Some(text) = dom_node.text_content() {
252+ utf16_offset_in_container += text.encode_utf16().count();
253+ }
254+ }
255+ }
256+ }
257+ }
258+259+ // Look up the offset in the offset map.
260+ for mapping in offset_map {
261+ if mapping.node_id == node_id {
262+ let mapping_start = mapping.char_offset_in_node;
263+ let mapping_end = mapping.char_offset_in_node + mapping.utf16_len;
264+265+ if utf16_offset_in_container >= mapping_start
266+ && utf16_offset_in_container <= mapping_end
267+ {
268+ let offset_in_mapping = utf16_offset_in_container - mapping_start;
269+ let char_offset = mapping.char_range.start + offset_in_mapping;
270+271+ // Check if position is valid (not on invisible content).
272+ if is_valid_cursor_position(offset_map, char_offset) {
273+ return Some(char_offset);
274+ }
275+276+ // Position is on invisible content, snap to nearest valid.
277+ if let Some(snapped) =
278+ find_nearest_valid_position(offset_map, char_offset, direction_hint)
279+ {
280+ return Some(snapped.char_offset());
281+ }
282+283+ return Some(char_offset);
284+ }
285+ }
286+ }
287+288+ // No mapping found - try to find any valid position.
289+ if let Some(snapped) = find_nearest_valid_position(offset_map, 0, direction_hint) {
290+ return Some(snapped.char_offset());
291+ }
292+293+ None
294+}
295+296+/// Paragraph render data needed for DOM updates.
297+///
298+/// This is a simplified view of paragraph data for the DOM sync layer.
299+pub struct ParagraphDomData<'a> {
300+ /// Paragraph ID (for DOM element lookup).
301+ pub id: &'a str,
302+ /// HTML content to render.
303+ pub html: &'a str,
304+ /// Source hash for change detection.
305+ pub source_hash: u64,
306+ /// Character range in document.
307+ pub char_range: std::ops::Range<usize>,
308+ /// Offset mappings for cursor restoration.
309+ pub offset_map: &'a [OffsetMapping],
310+}
311+312+/// Update paragraph DOM elements incrementally.
313+///
314+/// Returns true if the paragraph containing the cursor was updated.
315+pub fn update_paragraph_dom(
316+ editor_id: &str,
317+ old_paragraphs: &[ParagraphDomData<'_>],
318+ new_paragraphs: &[ParagraphDomData<'_>],
319+ cursor_offset: usize,
320+ force: bool,
321+) -> bool {
322+ use std::collections::HashMap;
323+324+ let window = match web_sys::window() {
325+ Some(w) => w,
326+ None => return false,
327+ };
328+329+ let document = match window.document() {
330+ Some(d) => d,
331+ None => return false,
332+ };
333+334+ let editor = match document.get_element_by_id(editor_id) {
335+ Some(e) => e,
336+ None => return false,
337+ };
338+339+ let mut cursor_para_updated = false;
340+341+ // Build pool of existing DOM elements by ID.
342+ let mut old_elements: HashMap<String, web_sys::Element> = HashMap::new();
343+ let mut child_opt = editor.first_element_child();
344+ while let Some(child) = child_opt {
345+ if let Some(id) = child.get_attribute("id") {
346+ let next = child.next_element_sibling();
347+ old_elements.insert(id, child);
348+ child_opt = next;
349+ } else {
350+ child_opt = child.next_element_sibling();
351+ }
352+ }
353+354+ let mut cursor_node: Option<web_sys::Node> = editor.first_element_child().map(|e| e.into());
355+356+ for new_para in new_paragraphs.iter() {
357+ let para_id = new_para.id;
358+ let new_hash = format!("{:x}", new_para.source_hash);
359+ let is_cursor_para = new_para.char_range.start <= cursor_offset
360+ && cursor_offset <= new_para.char_range.end;
361+362+ if let Some(existing_elem) = old_elements.remove(para_id) {
363+ let old_hash = existing_elem.get_attribute("data-hash").unwrap_or_default();
364+ let needs_update = force || old_hash != new_hash;
365+366+ let existing_as_node: &web_sys::Node = existing_elem.as_ref();
367+ let at_correct_position = cursor_node
368+ .as_ref()
369+ .map(|c| c == existing_as_node)
370+ .unwrap_or(false);
371+372+ if !at_correct_position {
373+ let _ = editor.insert_before(existing_as_node, cursor_node.as_ref());
374+ if is_cursor_para {
375+ cursor_para_updated = true;
376+ }
377+ } else {
378+ cursor_node = existing_elem.next_element_sibling().map(|e| e.into());
379+ }
380+381+ if needs_update {
382+ existing_elem.set_inner_html(new_para.html);
383+ let _ = existing_elem.set_attribute("data-hash", &new_hash);
384+385+ if is_cursor_para {
386+ if let Err(e) =
387+ restore_cursor_position(cursor_offset, new_para.offset_map, None)
388+ {
389+ tracing::warn!("Cursor restore failed: {:?}", e);
390+ }
391+ cursor_para_updated = true;
392+ }
393+ }
394+ } else {
395+ // New element - create and insert.
396+ if let Ok(div) = document.create_element("div") {
397+ div.set_id(para_id);
398+ div.set_inner_html(new_para.html);
399+ let _ = div.set_attribute("data-hash", &new_hash);
400+ let div_node: &web_sys::Node = div.as_ref();
401+ let _ = editor.insert_before(div_node, cursor_node.as_ref());
402+ }
403+404+ if is_cursor_para {
405+ cursor_para_updated = true;
406+ }
407+ }
408+ }
409+410+ // Remove stale elements.
411+ for (_, elem) in old_elements {
412+ let _ = elem.remove();
413+ cursor_para_updated = true;
414+ }
415+416+ cursor_para_updated
417+}
+5
crates/weaver-editor-browser/src/events.rs
···00000
···1+//! Browser event handling for the editor.
2+//!
3+//! Handles beforeinput, keydown, paste, and other DOM events.
4+5+// TODO: Migrate from weaver-app (beforeinput.rs, input.rs)
+36
crates/weaver-editor-browser/src/lib.rs
···000000000000000000000000000000000000
···1+//! Browser DOM layer for the weaver markdown editor.
2+//!
3+//! This crate provides DOM manipulation and browser event handling,
4+//! generic over any `EditorDocument` implementation. It assumes a
5+//! `wasm32-unknown-unknown` target environment.
6+//!
7+//! # Architecture
8+//!
9+//! - `cursor`: Selection API handling and cursor restoration
10+//! - `dom_sync`: DOM ↔ document state synchronization
11+//! - `events`: beforeinput, keydown, paste event handlers
12+//! - `contenteditable`: Editor element setup and management
13+//! - `platform`: Browser/OS detection for platform-specific behavior
14+//!
15+//! # Re-exports
16+//!
17+//! This crate re-exports `weaver-editor-core` for convenience, so consumers
18+//! only need to depend on `weaver-editor-browser`.
19+20+// Re-export core crate
21+pub use weaver_editor_core;
22+pub use weaver_editor_core::*;
23+24+pub mod cursor;
25+pub mod dom_sync;
26+pub mod events;
27+pub mod platform;
28+29+// Browser cursor implementation
30+pub use cursor::BrowserCursor;
31+32+// Platform detection
33+pub use platform::{Platform, platform};
34+35+// TODO: contenteditable module
36+// TODO: embed worker module
···1+//! Editor actions and input types.
2+//!
3+//! Platform-agnostic definitions for editor operations. The `EditorAction` enum
4+//! represents semantic editing operations, while `InputType` represents the
5+//! semantic intent from input events (browser beforeinput, native input methods, etc.).
6+7+use smol_str::SmolStr;
8+9+/// A range in the document, measured in character offsets.
10+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11+pub struct Range {
12+ pub start: usize,
13+ pub end: usize,
14+}
15+16+impl Range {
17+ pub fn new(start: usize, end: usize) -> Self {
18+ Self { start, end }
19+ }
20+21+ pub fn caret(offset: usize) -> Self {
22+ Self {
23+ start: offset,
24+ end: offset,
25+ }
26+ }
27+28+ pub fn is_caret(&self) -> bool {
29+ self.start == self.end
30+ }
31+32+ pub fn len(&self) -> usize {
33+ self.end.saturating_sub(self.start)
34+ }
35+36+ pub fn is_empty(&self) -> bool {
37+ self.len() == 0
38+ }
39+40+ /// Normalize range so start <= end.
41+ pub fn normalize(self) -> Self {
42+ if self.start <= self.end {
43+ self
44+ } else {
45+ Self {
46+ start: self.end,
47+ end: self.start,
48+ }
49+ }
50+ }
51+}
52+53+impl From<std::ops::Range<usize>> for Range {
54+ fn from(r: std::ops::Range<usize>) -> Self {
55+ Self::new(r.start, r.end)
56+ }
57+}
58+59+impl From<Range> for std::ops::Range<usize> {
60+ fn from(r: Range) -> Self {
61+ r.start..r.end
62+ }
63+}
64+65+/// Semantic input types from input events.
66+///
67+/// These represent the semantic intent of an input operation, abstracted from
68+/// the platform-specific event source. Browser `beforeinput` events, native
69+/// input methods, and programmatic input can all produce these types.
70+///
71+/// Based on the W3C Input Events specification, but usable across platforms.
72+#[derive(Debug, Clone, PartialEq, Eq)]
73+pub enum InputType {
74+ // === Insertion ===
75+ /// Insert typed text.
76+ InsertText,
77+ /// Insert text from IME composition.
78+ InsertCompositionText,
79+ /// Insert a line break (`<br>`, Shift+Enter).
80+ InsertLineBreak,
81+ /// Insert a paragraph break (Enter).
82+ InsertParagraph,
83+ /// Insert from paste operation.
84+ InsertFromPaste,
85+ /// Insert from drop operation.
86+ InsertFromDrop,
87+ /// Insert replacement text (e.g., spell check correction).
88+ InsertReplacementText,
89+ /// Insert from voice input or other source.
90+ InsertFromYank,
91+ /// Insert a horizontal rule.
92+ InsertHorizontalRule,
93+ /// Insert an ordered list.
94+ InsertOrderedList,
95+ /// Insert an unordered list.
96+ InsertUnorderedList,
97+ /// Insert a link.
98+ InsertLink,
99+100+ // === Deletion ===
101+ /// Delete content backward (Backspace).
102+ DeleteContentBackward,
103+ /// Delete content forward (Delete key).
104+ DeleteContentForward,
105+ /// Delete word backward (Ctrl/Alt+Backspace).
106+ DeleteWordBackward,
107+ /// Delete word forward (Ctrl/Alt+Delete).
108+ DeleteWordForward,
109+ /// Delete to soft line boundary backward.
110+ DeleteSoftLineBackward,
111+ /// Delete to soft line boundary forward.
112+ DeleteSoftLineForward,
113+ /// Delete to hard line boundary backward (Cmd+Backspace on Mac).
114+ DeleteHardLineBackward,
115+ /// Delete to hard line boundary forward (Cmd+Delete on Mac).
116+ DeleteHardLineForward,
117+ /// Delete by cut operation.
118+ DeleteByCut,
119+ /// Delete by drag operation.
120+ DeleteByDrag,
121+ /// Generic content deletion.
122+ DeleteContent,
123+ /// Delete entire word backward.
124+ DeleteEntireWordBackward,
125+ /// Delete entire word forward.
126+ DeleteEntireWordForward,
127+128+ // === History ===
129+ /// Undo.
130+ HistoryUndo,
131+ /// Redo.
132+ HistoryRedo,
133+134+ // === Formatting ===
135+ FormatBold,
136+ FormatItalic,
137+ FormatUnderline,
138+ FormatStrikethrough,
139+ FormatSuperscript,
140+ FormatSubscript,
141+142+ // === Unknown ===
143+ /// Unrecognized input type.
144+ Unknown(String),
145+}
146+147+impl InputType {
148+ /// Whether this input type is a deletion operation.
149+ pub fn is_deletion(&self) -> bool {
150+ matches!(
151+ self,
152+ Self::DeleteContentBackward
153+ | Self::DeleteContentForward
154+ | Self::DeleteWordBackward
155+ | Self::DeleteWordForward
156+ | Self::DeleteSoftLineBackward
157+ | Self::DeleteSoftLineForward
158+ | Self::DeleteHardLineBackward
159+ | Self::DeleteHardLineForward
160+ | Self::DeleteByCut
161+ | Self::DeleteByDrag
162+ | Self::DeleteContent
163+ | Self::DeleteEntireWordBackward
164+ | Self::DeleteEntireWordForward
165+ )
166+ }
167+168+ /// Whether this input type is an insertion operation.
169+ pub fn is_insertion(&self) -> bool {
170+ matches!(
171+ self,
172+ Self::InsertText
173+ | Self::InsertCompositionText
174+ | Self::InsertLineBreak
175+ | Self::InsertParagraph
176+ | Self::InsertFromPaste
177+ | Self::InsertFromDrop
178+ | Self::InsertReplacementText
179+ | Self::InsertFromYank
180+ )
181+ }
182+}
183+184+/// All possible editor actions.
185+///
186+/// These represent semantic operations on the document, decoupled from
187+/// how they're triggered (keyboard, mouse, touch, voice, etc.).
188+#[derive(Debug, Clone, PartialEq)]
189+pub enum EditorAction {
190+ // === Text Insertion ===
191+ /// Insert text at the given range (replacing any selected content).
192+ Insert { text: String, range: Range },
193+194+ /// Insert a soft line break (Shift+Enter, `<br>` equivalent).
195+ InsertLineBreak { range: Range },
196+197+ /// Insert a paragraph break (Enter).
198+ InsertParagraph { range: Range },
199+200+ // === Deletion ===
201+ /// Delete content backward (Backspace).
202+ DeleteBackward { range: Range },
203+204+ /// Delete content forward (Delete key).
205+ DeleteForward { range: Range },
206+207+ /// Delete word backward (Ctrl/Alt+Backspace).
208+ DeleteWordBackward { range: Range },
209+210+ /// Delete word forward (Ctrl/Alt+Delete).
211+ DeleteWordForward { range: Range },
212+213+ /// Delete to start of line (Cmd+Backspace on Mac).
214+ DeleteToLineStart { range: Range },
215+216+ /// Delete to end of line (Cmd+Delete on Mac).
217+ DeleteToLineEnd { range: Range },
218+219+ /// Delete to start of soft line (visual line in wrapped text).
220+ DeleteSoftLineBackward { range: Range },
221+222+ /// Delete to end of soft line.
223+ DeleteSoftLineForward { range: Range },
224+225+ // === History ===
226+ /// Undo the last change.
227+ Undo,
228+229+ /// Redo the last undone change.
230+ Redo,
231+232+ // === Formatting ===
233+ /// Toggle bold on selection.
234+ ToggleBold,
235+236+ /// Toggle italic on selection.
237+ ToggleItalic,
238+239+ /// Toggle inline code on selection.
240+ ToggleCode,
241+242+ /// Toggle strikethrough on selection.
243+ ToggleStrikethrough,
244+245+ /// Insert/wrap with link.
246+ InsertLink,
247+248+ // === Clipboard ===
249+ /// Cut selection to clipboard.
250+ Cut,
251+252+ /// Copy selection to clipboard.
253+ Copy,
254+255+ /// Paste from clipboard at range.
256+ Paste { range: Range },
257+258+ /// Copy selection as rendered HTML.
259+ CopyAsHtml,
260+261+ // === Selection ===
262+ /// Select all content.
263+ SelectAll,
264+265+ // === Navigation ===
266+ /// Move cursor to position.
267+ MoveCursor { offset: usize },
268+269+ /// Extend selection to position.
270+ ExtendSelection { offset: usize },
271+}
272+273+impl EditorAction {
274+ /// Update the range in actions that use one.
275+ pub fn with_range(self, range: Range) -> Self {
276+ match self {
277+ Self::Insert { text, .. } => Self::Insert { text, range },
278+ Self::InsertLineBreak { .. } => Self::InsertLineBreak { range },
279+ Self::InsertParagraph { .. } => Self::InsertParagraph { range },
280+ Self::DeleteBackward { .. } => Self::DeleteBackward { range },
281+ Self::DeleteForward { .. } => Self::DeleteForward { range },
282+ Self::DeleteWordBackward { .. } => Self::DeleteWordBackward { range },
283+ Self::DeleteWordForward { .. } => Self::DeleteWordForward { range },
284+ Self::DeleteToLineStart { .. } => Self::DeleteToLineStart { range },
285+ Self::DeleteToLineEnd { .. } => Self::DeleteToLineEnd { range },
286+ Self::DeleteSoftLineBackward { .. } => Self::DeleteSoftLineBackward { range },
287+ Self::DeleteSoftLineForward { .. } => Self::DeleteSoftLineForward { range },
288+ Self::Paste { .. } => Self::Paste { range },
289+ other => other,
290+ }
291+ }
292+}
293+294+/// Key values for keyboard input.
295+///
296+/// Platform-agnostic key representation. Platform-specific code converts
297+/// from native key events to this enum.
298+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
299+pub enum Key {
300+ /// A character key.
301+ Character(SmolStr),
302+303+ /// Unknown/unidentified key.
304+ Unidentified,
305+306+ // === Whitespace / editing ===
307+ Backspace,
308+ Delete,
309+ Enter,
310+ Tab,
311+ Escape,
312+ Space,
313+ Insert,
314+ Clear,
315+316+ // === Navigation ===
317+ ArrowLeft,
318+ ArrowRight,
319+ ArrowUp,
320+ ArrowDown,
321+ Home,
322+ End,
323+ PageUp,
324+ PageDown,
325+326+ // === Modifiers ===
327+ Alt,
328+ AltGraph,
329+ CapsLock,
330+ Control,
331+ Fn,
332+ FnLock,
333+ Meta,
334+ NumLock,
335+ ScrollLock,
336+ Shift,
337+ Symbol,
338+ SymbolLock,
339+ Hyper,
340+ Super,
341+342+ // === Function keys ===
343+ F1,
344+ F2,
345+ F3,
346+ F4,
347+ F5,
348+ F6,
349+ F7,
350+ F8,
351+ F9,
352+ F10,
353+ F11,
354+ F12,
355+ F13,
356+ F14,
357+ F15,
358+ F16,
359+ F17,
360+ F18,
361+ F19,
362+ F20,
363+364+ // === UI keys ===
365+ ContextMenu,
366+ PrintScreen,
367+ Pause,
368+ Help,
369+370+ // === Clipboard / editing commands ===
371+ Copy,
372+ Cut,
373+ Paste,
374+ Undo,
375+ Redo,
376+ Find,
377+ Select,
378+379+ // === Media keys ===
380+ MediaPlayPause,
381+ MediaStop,
382+ MediaTrackNext,
383+ MediaTrackPrevious,
384+ AudioVolumeDown,
385+ AudioVolumeUp,
386+ AudioVolumeMute,
387+388+ // === IME / composition ===
389+ Compose,
390+ Convert,
391+ NonConvert,
392+ Dead,
393+394+ // === CJK IME keys ===
395+ HangulMode,
396+ HanjaMode,
397+ JunjaMode,
398+ Eisu,
399+ Hankaku,
400+ Hiragana,
401+ HiraganaKatakana,
402+ KanaMode,
403+ KanjiMode,
404+ Katakana,
405+ Romaji,
406+ Zenkaku,
407+ ZenkakuHankaku,
408+}
409+410+impl Key {
411+ /// Create a character key.
412+ pub fn character(s: impl Into<SmolStr>) -> Self {
413+ Self::Character(s.into())
414+ }
415+416+ /// Check if this is a navigation key.
417+ pub fn is_navigation(&self) -> bool {
418+ matches!(
419+ self,
420+ Self::ArrowLeft
421+ | Self::ArrowRight
422+ | Self::ArrowUp
423+ | Self::ArrowDown
424+ | Self::Home
425+ | Self::End
426+ | Self::PageUp
427+ | Self::PageDown
428+ )
429+ }
430+431+ /// Check if this is a modifier key.
432+ pub fn is_modifier(&self) -> bool {
433+ matches!(
434+ self,
435+ Self::Alt
436+ | Self::AltGraph
437+ | Self::CapsLock
438+ | Self::Control
439+ | Self::Fn
440+ | Self::FnLock
441+ | Self::Meta
442+ | Self::NumLock
443+ | Self::ScrollLock
444+ | Self::Shift
445+ | Self::Symbol
446+ | Self::SymbolLock
447+ | Self::Hyper
448+ | Self::Super
449+ )
450+ }
451+}
452+453+/// Modifier key state for a key combination.
454+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
455+pub struct Modifiers {
456+ pub ctrl: bool,
457+ pub alt: bool,
458+ pub shift: bool,
459+ pub meta: bool,
460+}
461+462+impl Modifiers {
463+ pub const NONE: Self = Self {
464+ ctrl: false,
465+ alt: false,
466+ shift: false,
467+ meta: false,
468+ };
469+470+ pub const CTRL: Self = Self {
471+ ctrl: true,
472+ alt: false,
473+ shift: false,
474+ meta: false,
475+ };
476+477+ pub const ALT: Self = Self {
478+ ctrl: false,
479+ alt: true,
480+ shift: false,
481+ meta: false,
482+ };
483+484+ pub const SHIFT: Self = Self {
485+ ctrl: false,
486+ alt: false,
487+ shift: true,
488+ meta: false,
489+ };
490+491+ pub const META: Self = Self {
492+ ctrl: false,
493+ alt: false,
494+ shift: false,
495+ meta: true,
496+ };
497+498+ pub const CTRL_SHIFT: Self = Self {
499+ ctrl: true,
500+ alt: false,
501+ shift: true,
502+ meta: false,
503+ };
504+505+ pub const META_SHIFT: Self = Self {
506+ ctrl: false,
507+ alt: false,
508+ shift: true,
509+ meta: true,
510+ };
511+512+ /// Get the primary modifier for the platform (Cmd on Mac, Ctrl elsewhere).
513+ pub fn primary(is_mac: bool) -> Self {
514+ if is_mac {
515+ Self::META
516+ } else {
517+ Self::CTRL
518+ }
519+ }
520+521+ /// Get the primary modifier + Shift for the platform.
522+ pub fn primary_shift(is_mac: bool) -> Self {
523+ if is_mac {
524+ Self::META_SHIFT
525+ } else {
526+ Self::CTRL_SHIFT
527+ }
528+ }
529+}
530+531+/// A key combination for triggering an action.
532+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
533+pub struct KeyCombo {
534+ pub key: Key,
535+ pub modifiers: Modifiers,
536+}
537+538+impl KeyCombo {
539+ pub fn new(key: Key) -> Self {
540+ Self {
541+ key,
542+ modifiers: Modifiers::NONE,
543+ }
544+ }
545+546+ pub fn with_modifiers(key: Key, modifiers: Modifiers) -> Self {
547+ Self { key, modifiers }
548+ }
549+550+ pub fn ctrl(key: Key) -> Self {
551+ Self {
552+ key,
553+ modifiers: Modifiers::CTRL,
554+ }
555+ }
556+557+ pub fn meta(key: Key) -> Self {
558+ Self {
559+ key,
560+ modifiers: Modifiers::META,
561+ }
562+ }
563+564+ pub fn shift(key: Key) -> Self {
565+ Self {
566+ key,
567+ modifiers: Modifiers::SHIFT,
568+ }
569+ }
570+571+ pub fn primary(key: Key, is_mac: bool) -> Self {
572+ Self {
573+ key,
574+ modifiers: Modifiers::primary(is_mac),
575+ }
576+ }
577+578+ pub fn primary_shift(key: Key, is_mac: bool) -> Self {
579+ Self {
580+ key,
581+ modifiers: Modifiers::primary_shift(is_mac),
582+ }
583+ }
584+}
585+586+/// Result of handling a keydown event.
587+#[derive(Debug, Clone, PartialEq)]
588+pub enum KeydownResult {
589+ /// Event was handled, prevent default.
590+ Handled,
591+ /// Event was not a keybinding, let platform handle it.
592+ NotHandled,
593+ /// Event should be passed through (navigation, etc.).
594+ PassThrough,
595+}
+4-1
crates/weaver-editor-core/src/lib.rs
···11pub mod document;
12pub mod offset_map;
13pub mod paragraph;
014pub mod render;
15pub mod syntax;
16pub mod text;
···28pub use syntax::{SyntaxSpanInfo, SyntaxType, classify_syntax};
29pub use text::{EditorRope, TextBuffer};
30pub use types::{
31- Affinity, CompositionState, CursorState, EditInfo, EditorImage, Selection, BLOCK_SYNTAX_ZONE,
032};
33pub use document::{EditorDocument, PlainEditor};
34pub use render::{EmbedContentProvider, ImageResolver, WikilinkValidator};
35pub use undo::{UndoManager, UndoableBuffer};
36pub use visibility::VisibilityState;
37pub use writer::{EditorImageResolver, EditorWriter, SegmentedWriter, WriterResult};
0
···11pub mod document;
12pub mod offset_map;
13pub mod paragraph;
14+pub mod platform;
15pub mod render;
16pub mod syntax;
17pub mod text;
···29pub use syntax::{SyntaxSpanInfo, SyntaxType, classify_syntax};
30pub use text::{EditorRope, TextBuffer};
31pub use types::{
32+ Affinity, CompositionState, CursorRect, CursorState, EditInfo, EditorImage, Selection,
33+ SelectionRect, BLOCK_SYNTAX_ZONE,
34};
35pub use document::{EditorDocument, PlainEditor};
36pub use render::{EmbedContentProvider, ImageResolver, WikilinkValidator};
37pub use undo::{UndoManager, UndoableBuffer};
38pub use visibility::VisibilityState;
39pub use writer::{EditorImageResolver, EditorWriter, SegmentedWriter, WriterResult};
40+pub use platform::{CursorPlatform, CursorSync, PlatformError};
···1+//! Platform abstraction traits for editor operations.
2+//!
3+//! These traits define the interface between the editor logic and platform-specific
4+//! implementations (browser DOM, native UI, etc.). This enables the same editor
5+//! logic to work across different platforms.
6+7+use crate::offset_map::SnapDirection;
8+use crate::types::{CursorRect, SelectionRect};
9+use crate::OffsetMapping;
10+11+/// Error type for platform operations.
12+#[derive(Debug, Clone)]
13+pub struct PlatformError(pub String);
14+15+impl std::fmt::Display for PlatformError {
16+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17+ write!(f, "{}", self.0)
18+ }
19+}
20+21+impl std::error::Error for PlatformError {}
22+23+impl From<&str> for PlatformError {
24+ fn from(s: &str) -> Self {
25+ PlatformError(s.to_string())
26+ }
27+}
28+29+impl From<String> for PlatformError {
30+ fn from(s: String) -> Self {
31+ PlatformError(s)
32+ }
33+}
34+35+/// Platform-specific cursor and selection operations.
36+///
37+/// Implementations handle the actual UI interaction for cursor positioning
38+/// and selection rendering. The browser implementation uses the DOM Selection API,
39+/// native implementations would use their respective UI frameworks.
40+pub trait CursorPlatform {
41+ /// Restore cursor position in the UI after content changes.
42+ ///
43+ /// Given a character offset and the current offset map, positions the cursor
44+ /// in the rendered content. The snap direction is used when the offset falls
45+ /// on invisible content (formatting syntax).
46+ fn restore_cursor(
47+ &self,
48+ char_offset: usize,
49+ offset_map: &[OffsetMapping],
50+ snap_direction: Option<SnapDirection>,
51+ ) -> Result<(), PlatformError>;
52+53+ /// Get the screen coordinates for a cursor at the given offset.
54+ ///
55+ /// Returns None if the offset cannot be mapped to screen coordinates.
56+ fn get_cursor_rect(
57+ &self,
58+ char_offset: usize,
59+ offset_map: &[OffsetMapping],
60+ ) -> Option<CursorRect>;
61+62+ /// Get screen coordinates relative to the editor container.
63+ ///
64+ /// Same as `get_cursor_rect` but coordinates are relative to the editor
65+ /// element rather than the viewport.
66+ fn get_cursor_rect_relative(
67+ &self,
68+ char_offset: usize,
69+ offset_map: &[OffsetMapping],
70+ ) -> Option<CursorRect>;
71+72+ /// Get screen rectangles for a selection range.
73+ ///
74+ /// Returns multiple rects if the selection spans multiple lines.
75+ /// Coordinates are relative to the editor container.
76+ fn get_selection_rects_relative(
77+ &self,
78+ start: usize,
79+ end: usize,
80+ offset_map: &[OffsetMapping],
81+ ) -> Vec<SelectionRect>;
82+}
83+84+/// Platform-specific cursor state synchronization.
85+///
86+/// Handles reading the current cursor/selection state from the platform UI
87+/// back into the editor model. This is the inverse of `CursorPlatform`.
88+pub trait CursorSync {
89+ /// Sync cursor state from the platform UI into the provided callbacks.
90+ ///
91+ /// The implementation reads the current selection from the UI and calls
92+ /// the appropriate callback with the character offset(s).
93+ ///
94+ /// - For a collapsed cursor: calls `on_cursor(offset)`
95+ /// - For a selection: calls `on_selection(anchor, head)`
96+ fn sync_cursor_from_platform<F, G>(
97+ &self,
98+ offset_map: &[OffsetMapping],
99+ direction_hint: Option<SnapDirection>,
100+ on_cursor: F,
101+ on_selection: G,
102+ ) where
103+ F: FnOnce(usize),
104+ G: FnOnce(usize, usize);
105+}