···11+# Project Subagents Configuration
22+# Domain-specific agents for working on different parts of the codebase.
33+#
44+# When working on a specific domain, spawn a Task with subagent_type="Explore" or
55+# "general-purpose" and include the relevant agent's context in the prompt.
66+#
77+# Customize this file for YOUR project's structure. The domains below are examples.
88+99+# Example: Backend/Core agent
1010+# [agents.backend]
1111+# name = "Backend Agent"
1212+# description = "API routes, database models, business logic"
1313+# file_patterns = [
1414+# "src/**/*.rs",
1515+# "src/**/*.py",
1616+# "app/**/*.py"
1717+# ]
1818+# focus_areas = [
1919+# "Database operations",
2020+# "API endpoints",
2121+# "Business logic"
2222+# ]
2323+# instructions = """
2424+# When working on backend:
2525+# - Run tests before and after changes
2626+# - Follow existing patterns for new endpoints
2727+# - Maintain backwards compatibility
2828+# """
2929+3030+# Example: Frontend agent
3131+# [agents.frontend]
3232+# name = "Frontend Agent"
3333+# description = "UI components, state management, styling"
3434+# file_patterns = [
3535+# "web/src/**/*.ts",
3636+# "web/src/**/*.tsx",
3737+# "src/components/**"
3838+# ]
3939+# focus_areas = [
4040+# "React components",
4141+# "State management",
4242+# "Styling and layout"
4343+# ]
4444+# instructions = """
4545+# When working on frontend:
4646+# - Test in browser after changes
4747+# - Follow component patterns
4848+# - Keep accessibility in mind
4949+# """
5050+5151+# Example: Infrastructure agent
5252+# [agents.infra]
5353+# name = "Infrastructure Agent"
5454+# description = "CI/CD, deployment, configuration"
5555+# file_patterns = [
5656+# ".github/workflows/**",
5757+# "Dockerfile",
5858+# "docker-compose.yml",
5959+# "scripts/**"
6060+# ]
6161+# focus_areas = [
6262+# "GitHub Actions",
6363+# "Docker configuration",
6464+# "Deployment scripts"
6565+# ]
6666+# instructions = """
6767+# When working on infrastructure:
6868+# - Test workflows locally when possible
6969+# - Keep builds fast with caching
7070+# - Document any manual steps
7171+# """
+192
.claude/commands/context.md
···11+---
22+description: Recover context from decision graph and recent activity - USE THIS ON SESSION START
33+allowed-tools: Bash(deciduous:*, jj:*, git:*, cat:*, tail:*)
44+argument-hint: [focus-area]
55+---
66+77+# Context Recovery
88+99+**RUN THIS AT SESSION START.** The decision graph is your persistent memory.
1010+1111+## Step 1: Query the Graph
1212+1313+```bash
1414+# See all decisions (look for recent ones and pending status)
1515+deciduous nodes
1616+1717+# Filter by current bookmark (useful for feature work)
1818+deciduous nodes --branch $(jj log -r @ -T 'bookmarks' --no-graph 2>/dev/null | head -1)
1919+2020+# See how decisions connect
2121+deciduous edges
2222+2323+# What commands were recently run?
2424+deciduous commands
2525+```
2626+2727+**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]`.
2828+2929+## Step 1.5: Audit Graph Integrity
3030+3131+**CRITICAL: Check that all nodes are logically connected.**
3232+3333+```bash
3434+# Find nodes with no incoming edges (potential missing connections)
3535+deciduous edges | cut -d'>' -f2 | cut -d' ' -f2 | sort -u > /tmp/has_parent.txt
3636+deciduous nodes | tail -n+3 | awk '{print $1}' | while read id; do
3737+ grep -q "^$id$" /tmp/has_parent.txt || echo "CHECK: $id"
3838+done
3939+```
4040+4141+**Review each flagged node:**
4242+- Root `goal` nodes are VALID without parents
4343+- `outcome` nodes MUST link back to their action/goal
4444+- `action` nodes MUST link to their parent goal/decision
4545+- `option` nodes MUST link to their parent decision
4646+4747+**Fix missing connections:**
4848+```bash
4949+deciduous link <parent_id> <child_id> -r "Retroactive connection - <reason>"
5050+```
5151+5252+## Step 2: Check VCS State
5353+5454+```bash
5555+jj status
5656+jj log -n 10
5757+jj diff --stat
5858+```
5959+6060+## Step 3: Check Session Log
6161+6262+```bash
6363+cat git.log | tail -30
6464+```
6565+6666+## After Gathering Context, Report:
6767+6868+1. **Current bookmark** and pending changes
6969+2. **Bookmark-specific decisions** (filter by bookmark if on feature bookmark)
7070+3. **Recent decisions** (especially pending/active ones)
7171+4. **Last actions** from jj log and command log
7272+5. **Open questions** or unresolved observations
7373+6. **Suggested next steps**
7474+7575+### Bookmark Configuration
7676+7777+Check `.deciduous/config.toml` for bookmark settings:
7878+```toml
7979+[branch]
8080+main_branches = ["main", "master"] # Which bookmarks are "main"
8181+auto_detect = true # Auto-detect bookmark on node creation
8282+```
8383+8484+---
8585+8686+## REMEMBER: Real-Time Logging Required
8787+8888+After recovering context, you MUST follow the logging workflow:
8989+9090+```
9191+EVERY USER REQUEST → Log goal/decision first
9292+BEFORE CODE CHANGES → Log action
9393+AFTER CHANGES → Log outcome, link nodes
9494+BEFORE PUSH → deciduous sync
9595+```
9696+9797+**The user is watching the graph live.** Log as you go, not after.
9898+9999+### Quick Logging Commands
100100+101101+```bash
102102+# Root goal with user prompt (capture what the user asked for)
103103+deciduous add goal "What we're trying to do" -c 90 -p "User asked: <their request>"
104104+105105+deciduous add action "What I'm about to implement" -c 85
106106+deciduous add outcome "What happened" -c 95
107107+deciduous link FROM TO -r "Connection reason"
108108+109109+# Capture prompt when user redirects mid-stream
110110+deciduous add action "Switching approach" -c 85 -p "User said: use X instead"
111111+112112+deciduous sync # Do this frequently!
113113+```
114114+115115+**When to use `--prompt`:** On root goals (always) and when user gives new direction mid-stream. Downstream nodes inherit context via edges.
116116+117117+---
118118+119119+## Focus Areas
120120+121121+If $ARGUMENTS specifies a focus, prioritize context for:
122122+123123+- **auth**: Authentication-related decisions
124124+- **ui** / **graph**: UI and graph viewer state
125125+- **cli**: Command-line interface changes
126126+- **api**: API endpoints and data structures
127127+128128+---
129129+130130+## The Memory Loop
131131+132132+```
133133+SESSION START
134134+ ↓
135135+Run /recover → See past decisions
136136+ ↓
137137+AUDIT → Fix any orphan nodes first!
138138+ ↓
139139+DO WORK → Log BEFORE each action
140140+ ↓
141141+CONNECT → Link new nodes immediately
142142+ ↓
143143+AFTER CHANGES → Log outcomes, observations
144144+ ↓
145145+AUDIT AGAIN → Any new orphans?
146146+ ↓
147147+BEFORE PUSH → deciduous sync
148148+ ↓
149149+PUSH → Live graph updates
150150+ ↓
151151+SESSION END → Final audit
152152+ ↓
153153+(repeat)
154154+```
155155+156156+**Live graph**: https://notactuallytreyanastasio.github.io/deciduous/
157157+158158+---
159159+160160+## Multi-User Sync
161161+162162+If working in a team, check for and apply patches from teammates:
163163+164164+```bash
165165+# Check for unapplied patches
166166+deciduous diff status
167167+168168+# Apply all patches (idempotent - safe to run multiple times)
169169+deciduous diff apply .deciduous/patches/*.json
170170+171171+# Preview before applying
172172+deciduous diff apply --dry-run .deciduous/patches/teammate-feature.json
173173+```
174174+175175+Before pushing your bookmark, export your decisions for teammates:
176176+177177+```bash
178178+# Export your bookmark's decisions as a patch
179179+BOOKMARK=$(jj log -r @ -T 'bookmarks' --no-graph 2>/dev/null | head -1)
180180+deciduous diff export --branch "$BOOKMARK" \
181181+ -o .deciduous/patches/$(whoami)-$BOOKMARK.json
182182+183183+# The patch file will be tracked automatically by jj
184184+```
185185+186186+## Why This Matters
187187+188188+- Context loss during compaction loses your reasoning
189189+- The graph survives - query it early, query it often
190190+- Retroactive logging misses details - log in the moment
191191+- The user sees the graph live - show your work
192192+- Patches share reasoning with teammates
+274
.claude/commands/decision.md
···11+---
22+description: Manage decision graph - track algorithm choices and reasoning
33+allowed-tools: Bash(deciduous:*)
44+argument-hint: <action> [args...]
55+---
66+77+# Decision Graph Management
88+99+**Log decisions IN REAL-TIME as you work, not retroactively.**
1010+1111+## When to Use This
1212+1313+| You're doing this... | Log this type | Command |
1414+|---------------------|---------------|---------|
1515+| Starting a new feature | `goal` **with -p** | `/decision add goal "Add user auth" -p "user request"` |
1616+| Choosing between approaches | `decision` | `/decision add decision "Choose auth method"` |
1717+| Considering an option | `option` | `/decision add option "JWT tokens"` |
1818+| About to write code | `action` | `/decision add action "Implementing JWT"` |
1919+| Noticing something | `observation` | `/decision add obs "Found existing auth code"` |
2020+| Finished something | `outcome` | `/decision add outcome "JWT working"` |
2121+2222+## Quick Commands
2323+2424+Based on $ARGUMENTS:
2525+2626+### View Commands
2727+- `nodes` or `list` -> `deciduous nodes`
2828+- `edges` -> `deciduous edges`
2929+- `graph` -> `deciduous graph`
3030+- `commands` -> `deciduous commands`
3131+3232+### Create Nodes (with optional metadata)
3333+- `add goal <title>` -> `deciduous add goal "<title>" -c 90`
3434+- `add decision <title>` -> `deciduous add decision "<title>" -c 75`
3535+- `add option <title>` -> `deciduous add option "<title>" -c 70`
3636+- `add action <title>` -> `deciduous add action "<title>" -c 85`
3737+- `add obs <title>` -> `deciduous add observation "<title>" -c 80`
3838+- `add outcome <title>` -> `deciduous add outcome "<title>" -c 90`
3939+4040+### Optional Flags for Nodes
4141+- `-c, --confidence <0-100>` - Confidence level
4242+- `-p, --prompt "..."` - Store the user prompt that triggered this node
4343+- `-f, --files "file1.rs,file2.rs"` - Associate files with this node
4444+- `-b, --branch <name>` - jj bookmark (auto-detected by default)
4545+- `--no-branch` - Skip bookmark auto-detection
4646+- `--commit <hash|HEAD>` - Link to commit (use HEAD for current)
4747+4848+### ⚠️ CRITICAL: Link Commits to Actions/Outcomes
4949+5050+**After every jj commit, link it to the decision graph!**
5151+5252+```bash
5353+jj commit -m "feat: add auth"
5454+deciduous add action "Implemented auth" -c 90 --commit HEAD
5555+deciduous link <goal_id> <action_id> -r "Implementation"
5656+```
5757+5858+## CRITICAL: Capture VERBATIM User Prompts
5959+6060+**Prompts must be the EXACT user message, not a summary.** When a user request triggers new work, capture their full message word-for-word.
6161+6262+**BAD - summaries are useless for context recovery:**
6363+```bash
6464+# DON'T DO THIS - this is a summary, not a prompt
6565+deciduous add goal "Add auth" -p "User asked: add login to the app"
6666+```
6767+6868+**GOOD - verbatim prompts enable full context recovery:**
6969+```bash
7070+# Use --prompt-stdin for multi-line prompts
7171+deciduous add goal "Add auth" -c 90 --prompt-stdin << 'EOF'
7272+I need to add user authentication to the app. Users should be able to sign up
7373+with email/password, and we need OAuth support for Google and GitHub. The auth
7474+should use JWT tokens with refresh token rotation.
7575+EOF
7676+7777+# Or use the prompt command to update existing nodes
7878+deciduous prompt 42 << 'EOF'
7979+The full verbatim user message goes here...
8080+EOF
8181+```
8282+8383+**When to capture prompts:**
8484+- Root `goal` nodes: YES - the FULL original request
8585+- Major direction changes: YES - when user redirects the work
8686+- Routine downstream nodes: NO - they inherit context via edges
8787+8888+**Updating prompts on existing nodes:**
8989+```bash
9090+deciduous prompt <node_id> "full verbatim prompt here"
9191+cat prompt.txt | deciduous prompt <node_id> # Multi-line from stdin
9292+```
9393+9494+Prompts are viewable in the TUI detail panel (`deciduous tui`) and web viewer.
9595+9696+## Bookmark-Based Grouping
9797+9898+**Nodes are automatically tagged with the current jj bookmark.** This enables filtering by feature/PR.
9999+100100+### How It Works
101101+- When you create a node, the current jj bookmark is stored in `metadata_json`
102102+- Configure which bookmarks are "main" in `.deciduous/config.toml`:
103103+ ```toml
104104+ [branch]
105105+ main_branches = ["main", "master"] # Bookmarks not treated as "feature bookmarks"
106106+ auto_detect = true # Auto-detect bookmark on node creation
107107+ ```
108108+- Nodes on feature bookmarks (anything not in `main_branches`) can be grouped/filtered
109109+110110+### CLI Filtering
111111+```bash
112112+# Show only nodes from specific bookmark
113113+deciduous nodes --branch main
114114+deciduous nodes --branch feature-auth
115115+deciduous nodes -b my-feature
116116+117117+# Override auto-detection when creating nodes
118118+deciduous add goal "Feature work" -b feature-x # Force specific bookmark
119119+deciduous add goal "Universal note" --no-branch # No bookmark tag
120120+```
121121+122122+### Web UI Bookmark Filter
123123+The graph viewer shows a bookmark dropdown in the stats bar:
124124+- "All bookmarks" shows everything
125125+- Select a specific bookmark to filter all views (Chains, Timeline, Graph, DAG)
126126+127127+### When to Use Bookmark Grouping
128128+- **Feature work**: Nodes created on `feature-auth` bookmark auto-grouped
129129+- **PR context**: Filter to see only decisions for a specific PR
130130+- **Cross-cutting concerns**: Use `--no-bookmark` for universal notes
131131+- **Retrospectives**: Filter by bookmark to see decision history per feature
132132+133133+### Create Edges
134134+- `link <from> <to> [reason]` -> `deciduous link <from> <to> -r "<reason>"`
135135+136136+### Sync Graph
137137+- `sync` -> `deciduous sync`
138138+139139+### Multi-User Sync (Diff/Patch)
140140+- `diff export -o <file>` -> `deciduous diff export -o <file>` (export nodes as patch)
141141+- `diff export --nodes 1-10 -o <file>` -> export specific nodes
142142+- `diff export --branch feature-x -o <file>` -> export nodes from branch
143143+- `diff apply <file>` -> `deciduous diff apply <file>` (apply patch, idempotent)
144144+- `diff apply --dry-run <file>` -> preview without applying
145145+- `diff status` -> `deciduous diff status` (list patches in .deciduous/patches/)
146146+- `migrate` -> `deciduous migrate` (add change_id columns for sync)
147147+148148+### Export & Visualization
149149+- `dot` -> `deciduous dot` (output DOT to stdout)
150150+- `dot --png` -> `deciduous dot --png -o graph.dot` (generate PNG)
151151+- `dot --nodes 1-11` -> `deciduous dot --nodes 1-11` (filter nodes)
152152+- `writeup` -> `deciduous writeup` (generate PR writeup)
153153+- `writeup -t "Title" --nodes 1-11` -> filtered writeup
154154+155155+## Node Types
156156+157157+| Type | Purpose | Example |
158158+|------|---------|---------|
159159+| `goal` | High-level objective | "Add user authentication" |
160160+| `decision` | Choice point with options | "Choose auth method" |
161161+| `option` | Possible approach | "Use JWT tokens" |
162162+| `action` | Something implemented | "Added JWT middleware" |
163163+| `outcome` | Result of action | "JWT auth working" |
164164+| `observation` | Finding or data point | "Existing code uses sessions" |
165165+166166+## Edge Types
167167+168168+| Type | Meaning |
169169+|------|---------|
170170+| `leads_to` | Natural progression |
171171+| `chosen` | Selected option |
172172+| `rejected` | Not selected (include reason!) |
173173+| `requires` | Dependency |
174174+| `blocks` | Preventing progress |
175175+| `enables` | Makes something possible |
176176+177177+## Graph Integrity - CRITICAL
178178+179179+**Every node MUST be logically connected.** Floating nodes break the graph's value.
180180+181181+### Connection Rules
182182+| Node Type | MUST connect to | Example |
183183+|-----------|----------------|---------|
184184+| `outcome` | The action/goal it resolves | "JWT working" → links FROM "Implementing JWT" |
185185+| `action` | The decision/goal that spawned it | "Implementing JWT" → links FROM "Add auth" |
186186+| `option` | Its parent decision | "Use JWT" → links FROM "Choose auth method" |
187187+| `observation` | Related goal/action/decision | "Found existing code" → links TO relevant node |
188188+| `decision` | Parent goal (if any) | "Choose auth" → links FROM "Add auth feature" |
189189+| `goal` | Can be a root (no parent needed) | Root goals are valid orphans |
190190+191191+### Audit Checklist
192192+Ask yourself after creating nodes:
193193+1. Does every **outcome** link back to what caused it?
194194+2. Does every **action** link to why you did it?
195195+3. Does every **option** link to its decision?
196196+4. Are there **dangling outcomes** with no parent action/goal?
197197+198198+### Find Disconnected Nodes
199199+```bash
200200+# List nodes with no incoming edges (potential orphans)
201201+deciduous edges | cut -d'>' -f2 | cut -d' ' -f2 | sort -u > /tmp/has_parent.txt
202202+deciduous nodes | tail -n+3 | awk '{print $1}' | while read id; do
203203+ grep -q "^$id$" /tmp/has_parent.txt || echo "CHECK: $id"
204204+done
205205+```
206206+Note: Root goals are VALID orphans. Outcomes/actions/options usually are NOT.
207207+208208+### Fix Missing Connections
209209+```bash
210210+deciduous link <parent_id> <child_id> -r "Retroactive connection - <why>"
211211+```
212212+213213+### When to Audit
214214+- Before every `deciduous sync`
215215+- After creating multiple nodes quickly
216216+- At session end
217217+- When the web UI graph looks disconnected
218218+219219+## Multi-User Sync
220220+221221+**Problem**: Multiple users work on the same codebase, each with a local `.deciduous/deciduous.db` (ignored in VCS). How to share decisions?
222222+223223+**Solution**: Dual-ID model. Each node has:
224224+- `id` (integer): Local database primary key, different per machine
225225+- `change_id` (UUID): Globally unique, stable across all databases
226226+227227+### Export Workflow
228228+```bash
229229+# Export nodes from your bookmark as a patch file
230230+deciduous diff export --branch feature-x -o .deciduous/patches/alice-feature.json
231231+232232+# Or export specific node IDs
233233+deciduous diff export --nodes 172-188 -o .deciduous/patches/alice-feature.json --author alice
234234+```
235235+236236+### Apply Workflow
237237+```bash
238238+# Apply patches from teammates (idempotent - safe to re-apply)
239239+deciduous diff apply .deciduous/patches/*.json
240240+241241+# Preview what would change
242242+deciduous diff apply --dry-run .deciduous/patches/bob-refactor.json
243243+```
244244+245245+### PR Workflow
246246+1. Create nodes locally while working
247247+2. Export: `deciduous diff export --branch my-feature -o .deciduous/patches/my-feature.json`
248248+3. Commit the patch file (NOT the database)
249249+4. Open PR with patch file included
250250+5. Teammates pull and apply: `deciduous diff apply .deciduous/patches/my-feature.json`
251251+6. **Idempotent**: Same patch applied twice = no duplicates
252252+253253+### Patch Format (JSON)
254254+```json
255255+{
256256+ "version": "1.0",
257257+ "author": "alice",
258258+ "branch": "feature/auth",
259259+ "nodes": [{ "change_id": "uuid...", "title": "...", ... }],
260260+ "edges": [{ "from_change_id": "uuid1", "to_change_id": "uuid2", ... }]
261261+}
262262+```
263263+264264+## The Rule
265265+266266+```
267267+LOG BEFORE YOU CODE, NOT AFTER.
268268+CONNECT EVERY NODE TO ITS PARENT.
269269+AUDIT FOR ORPHANS REGULARLY.
270270+SYNC BEFORE YOU PUSH.
271271+EXPORT PATCHES FOR YOUR TEAMMATES.
272272+```
273273+274274+**Live graph**: https://notactuallytreyanastasio.github.io/deciduous/
+79
.github/workflows/cleanup-decision-graphs.yml
···11+name: Cleanup Decision Graph PNGs
22+33+on:
44+ pull_request:
55+ types: [closed]
66+77+jobs:
88+ cleanup:
99+ # Only run if PR was merged (not just closed)
1010+ if: github.event.pull_request.merged == true
1111+ runs-on: ubuntu-latest
1212+1313+ steps:
1414+ - name: Checkout
1515+ uses: actions/checkout@v4
1616+ with:
1717+ fetch-depth: 0
1818+ token: ${{ secrets.GITHUB_TOKEN }}
1919+2020+ - name: Find and remove decision graph PNGs
2121+ id: find-pngs
2222+ run: |
2323+ # Find decision graph PNGs (in docs/ or root)
2424+ PNGS=$(find . -name "decision-graph*.png" -o -name "deciduous-graph*.png" 2>/dev/null | grep -v node_modules || true)
2525+2626+ if [ -z "$PNGS" ]; then
2727+ echo "No decision graph PNGs found"
2828+ echo "found=false" >> $GITHUB_OUTPUT
2929+ else
3030+ echo "Found PNGs to clean up:"
3131+ echo "$PNGS"
3232+ echo "found=true" >> $GITHUB_OUTPUT
3333+3434+ # Remove the files
3535+ echo "$PNGS" | xargs rm -f
3636+3737+ # Also remove corresponding .dot files
3838+ for png in $PNGS; do
3939+ dot_file="${png%.png}.dot"
4040+ if [ -f "$dot_file" ]; then
4141+ rm -f "$dot_file"
4242+ echo "Also removed: $dot_file"
4343+ fi
4444+ done
4545+ fi
4646+4747+ - name: Create cleanup PR
4848+ if: steps.find-pngs.outputs.found == 'true'
4949+ run: |
5050+ # Check if there are changes to commit
5151+ if git diff --quiet && git diff --staged --quiet; then
5252+ echo "No changes to commit"
5353+ exit 0
5454+ fi
5555+5656+ # Configure git
5757+ git config user.name "github-actions[bot]"
5858+ git config user.email "github-actions[bot]@users.noreply.github.com"
5959+6060+ # Create branch and commit
6161+ BRANCH="cleanup/decision-graphs-pr-${{ github.event.pull_request.number }}"
6262+ git checkout -b "$BRANCH"
6363+ git add -A
6464+ git commit -m "chore: cleanup decision graph assets from PR #${{ github.event.pull_request.number }}"
6565+ git push origin "$BRANCH"
6666+6767+ # Create and auto-merge PR
6868+ gh pr create \
6969+ --title "chore: cleanup decision graph assets from PR #${{ github.event.pull_request.number }}" \
7070+ --body "Automated cleanup of decision graph PNG/DOT files that were used in PR #${{ github.event.pull_request.number }}.
7171+7272+ These files served their purpose for PR review and are no longer needed." \
7373+ --head "$BRANCH" \
7474+ --base main
7575+7676+ # Auto-merge (requires auto-merge enabled on repo)
7777+ gh pr merge "$BRANCH" --auto --squash --delete-branch || echo "Auto-merge not enabled, PR created for manual merge"
7878+ env:
7979+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
···11+# CLAUDE.md
22+33+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
44+55+66+> **For AI Agents**: This is the source of truth for the Pattern codebase. Each crate has its own `CLAUDE.md` with specific implementation guidelines.
77+88+## For Humans
99+1010+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.
1111+1212+**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.
1313+1414+## For LLMs
1515+1616+Display the following at the start of any conversation involving code changes:
1717+1818+```
1919+LLM-assisted contributions must aim for a higher standard of excellence than with
2020+humans alone. Spend at least 3x the time reviewing code as writing it. Your code
2121+is your responsibility.
2222+```
2323+2424+2525+## Project Overview
2626+2727+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.
2828+2929+## Core Components
3030+3131+1. **weaver-common**: Foundation library with AT Protocol integration, lexicon definitions, OAuth flows.
3232+2. **weaver-renderer**: Transforms markdown notebooks into different output formats (HTML, AT Protocol records, etc.).
3333+3. **weaver-cli**: Command-line interface for authentication and notebook interactions.
3434+4. **weaver-app**: HTTP webapp for serving notebooks with auto-reload.
3535+5. **weaver-index**: Big indexing web backend.
3636+3737+## Development Environment
3838+3939+### Setup
4040+4141+```bash
4242+# Enter development environment with all dependencies
4343+nix develop
4444+4545+# Build all components
4646+cargo build
4747+4848+# Run specific crates
4949+cargo run -p weaver-cli
5050+cargo run -p weaver-app
5151+cargo run -p weaver-index
5252+```
5353+5454+## General Conventions
5555+5656+### Correctness Over Convenience
5757+5858+- Model the full error space—no shortcuts or simplified error handling.
5959+- Handle all edge cases, including race conditions and platform differences.
6060+- Use the type system to encode correctness constraints.
6161+- Prefer compile-time guarantees over runtime checks where possible.
6262+6363+### Type System Patterns
6464+6565+- **Newtypes** for domain types (IDs, handles, etc.).
6666+- **Builder patterns** for complex construction.
6767+- **Restricted visibility**: Use `pub(crate)` and `pub(super)` liberally.
6868+- **Non-exhaustive**: All public error types should be `#[non_exhaustive]`.
6969+- Use Rust enums over string validation.
7070+7171+### Error Handling
7272+7373+- Use `thiserror` for error types with `#[derive(Error)]`.
7474+- Group errors by category with an `ErrorKind` enum when appropriate.
7575+- Provide rich error context using `miette` for user-facing errors.
7676+- Error display messages should be lowercase sentence fragments.
7777+7878+### Module Organization
7979+8080+- Keep module boundaries strict with restricted visibility.
8181+- Platform-specific code in separate files: `unix.rs`, `windows.rs`.
8282+8383+### Documentation
8484+8585+- Inline comments explain "why," not just "what".
8686+- Module-level documentation explains purpose and responsibilities.
8787+- **Always** use periods at the end of code comments.
8888+- **Never** use title case in headings. Always use sentence case.
8989+9090+## Testing Practices
9191+9292+**CRITICAL**: Always use `cargo nextest run` to run tests. Never use `cargo test` directly.
9393+9494+```bash
9595+# Run all tests
9696+cargo nextest run
9797+9898+# Specific crate
9999+cargo nextest run -p pattern-db
100100+101101+# With output
102102+cargo nextest run --nocapture
103103+104104+# Doctests (nextest doesn't support these)
105105+cargo test --doc
106106+```
107107+108108+## Common Development Commands
109109+110110+### Testing
111111+112112+```bash
113113+# Run all tests with nextest
114114+cargo nextest run
115115+116116+# Run specific tests
117117+cargo nextest run -p weaver-common
118118+```
119119+120120+### Code Quality
121121+122122+```bash
123123+# Run linter
124124+cargo clippy -- --deny warnings
125125+126126+# Format code
127127+cargo fmt
128128+129129+# Verify dependencies
130130+cargo deny check
131131+```
132132+133133+### Lexicon Generation
134134+135135+The project uses custom AT Protocol lexicons defined in JSON format. To generate Rust code from these definitions:
136136+137137+```bash
138138+nix run ../jacquard
139139+140140+### Building with Nix
141141+142142+```bash
143143+# Run all checks (clippy, fmt, tests)
144144+nix flake check
145145+146146+# Build specific packages
147147+nix build .#weaver-cli
148148+nix build .#weaver-app
149149+```
150150+151151+152152+## Architecture
153153+154154+### Data Flow
155155+156156+1. Markdown notebooks are parsed and processed by weaver-renderer
157157+2. Content can be rendered as static sites or published to AT Protocol PDSes
158158+3. Authentication with AT Protocol servers happens via OAuth
159159+160160+### Key Components
161161+162162+- **WeaverAgent**: Manages connections to AT Protocol Personal Data Servers (PDS)
163163+- **Notebook Structure**: Books, chapters, entries with extended markdown
164164+- **Renderer**: Processes markdown with extended features (wiki links, embeds, math)
165165+- **AT Protocol Lexicons**: Custom data schemas extending the protocol for notebooks
166166+167167+### Authentication Flow
168168+169169+1. CLI initiates OAuth flow with a PDS
170170+2. Local OAuth server handles callbacks on port 4000
171171+3. Tokens are stored in the local filesystem
172172+173173+## Feature Flags
174174+175175+- **dev**: Enables development-specific features
176176+- **native**: Configures OAuth for native clients
177177+178178+## Working with Jacquard
179179+180180+This project uses Jacquard, a zero-copy AT Protocol library for Rust. **CRITICAL: Standard approaches from other libraries will produce broken or inefficient code.**
181181+182182+**ALWAYS use the working-with-jacquard skill** when working with AT Protocol types, XRPC calls, or identity resolution.
183183+184184+Key patterns to internalize:
185185+- **NEVER use `for<'de> Deserialize<'de>` bounds** - this breaks ALL Jacquard types
186186+- Use `Did::new()`, `Handle::new_static()`, etc. - **never `FromStr::parse()`**
187187+- Use `Data<'a>` instead of `serde_json::Value`
188188+- Use `.into_output()` when returning from functions, `.parse()` for immediate processing
189189+- Derive `IntoStatic` on all custom types with lifetimes
190190+191191+See `~/.claude/skills/working-with-jacquard/SKILL.md` for complete guidance.
192192+193193+## Commit Message Style
194194+195195+```
196196+[crate-name] brief description
197197+```
198198+199199+Examples:
200200+- `[pattern-core] add supervisor coordination pattern`
201201+- `[pattern-db] fix FTS5 query escaping`
202202+- `[meta] update MSRV to Rust 1.83`
203203+204204+### Conventions
205205+206206+- Use `[meta]` for cross-cutting concerns (deps, CI, workspace config).
207207+- Keep descriptions concise but descriptive.
208208+- **Atomic commits**: Each commit should be a logical unit of change.
209209+- **Bisect-able history**: Every commit must build and pass all checks.
210210+- **Separate concerns**: Format fixes and refactoring separate from features.
···11+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
22+33+Provide concise code examples with detailed descriptions
44+55+# Dioxus Dependency
66+77+You can add Dioxus to your `Cargo.toml` like this:
88+99+```toml
1010+[dependencies]
1111+dioxus = { version = "0.7.0" }
1212+1313+[features]
1414+default = ["web", "webview", "server"]
1515+web = ["dioxus/web"]
1616+webview = ["dioxus/desktop"]
1717+server = ["dioxus/server"]
1818+```
1919+2020+# Launching your application
2121+2222+You need to create a main function that sets up the Dioxus runtime and mounts your root component.
2323+2424+```rust
2525+use dioxus::prelude::*;
2626+2727+fn main() {
2828+ dioxus::launch(App);
2929+}
3030+3131+#[component]
3232+fn App() -> Element {
3333+ rsx! { "Hello, Dioxus!" }
3434+}
3535+```
3636+3737+Then serve with `dx serve`:
3838+3939+```sh
4040+curl -sSL http://dioxus.dev/install.sh | sh
4141+dx serve
4242+```
4343+4444+# UI with RSX
4545+4646+```rust
4747+rsx! {
4848+ div {
4949+ class: "container", // Attribute
5050+ color: "red", // Inline styles
5151+ width: if condition { "100%" }, // Conditional attributes
5252+ "Hello, Dioxus!"
5353+ }
5454+ // Prefer loops over iterators
5555+ for i in 0..5 {
5656+ div { "{i}" } // use elements or components directly in loops
5757+ }
5858+ if condition {
5959+ div { "Condition is true!" } // use elements or components directly in conditionals
6060+ }
6161+6262+ {children} // Expressions are wrapped in brace
6363+ {(0..5).map(|i| rsx! { span { "Item {i}" } })} // Iterators must be wrapped in braces
6464+}
6565+```
6666+6767+# Assets
6868+6969+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.
7070+7171+```rust
7272+rsx! {
7373+ img {
7474+ src: asset!("/assets/image.png"),
7575+ alt: "An image",
7676+ }
7777+}
7878+```
7979+8080+## Styles
8181+8282+The `document::Stylesheet` component will inject the stylesheet into the `<head>` of the document
8383+8484+```rust
8585+rsx! {
8686+ document::Stylesheet {
8787+ href: asset!("/assets/styles.css"),
8888+ }
8989+}
9090+```
9191+9292+# Components
9393+9494+Components are the building blocks of apps
9595+9696+* Component are functions annotated with the `#[component]` macro.
9797+* The function name must start with a capital letter or contain an underscore.
9898+* A component re-renders only under two conditions:
9999+ 1. Its props change (as determined by `PartialEq`).
100100+ 2. An internal reactive state it depends on is updated.
101101+102102+```rust
103103+#[component]
104104+fn Input(mut value: Signal<String>) -> Element {
105105+ rsx! {
106106+ input {
107107+ value,
108108+ oninput: move |e| {
109109+ *value.write() = e.value();
110110+ },
111111+ onkeydown: move |e| {
112112+ if e.key() == Key::Enter {
113113+ value.write().clear();
114114+ }
115115+ },
116116+ }
117117+ }
118118+}
119119+```
120120+121121+Each component accepts function arguments (props)
122122+123123+* Props must be owned values, not references. Use `String` and `Vec<T>` instead of `&str` or `&[T]`.
124124+* Props must implement `PartialEq` and `Clone`.
125125+* 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.
126126+127127+# State
128128+129129+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.
130130+131131+## Local State
132132+133133+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.
134134+135135+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.
136136+137137+```rust
138138+#[component]
139139+fn Counter() -> Element {
140140+ let mut count = use_signal(|| 0);
141141+ let mut doubled = use_memo(move || count() * 2); // doubled will re-run when count changes because it reads the signal
142142+143143+ rsx! {
144144+ h1 { "Count: {count}" } // Counter will re-render when count changes because it reads the signal
145145+ h2 { "Doubled: {doubled}" }
146146+ button {
147147+ onclick: move |_| *count.write() += 1, // Writing to the signal rerenders Counter
148148+ "Increment"
149149+ }
150150+ button {
151151+ onclick: move |_| count.with_mut(|count| *count += 1), // use with_mut to mutate the signal
152152+ "Increment with with_mut"
153153+ }
154154+ }
155155+}
156156+```
157157+158158+## Context API
159159+160160+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`
161161+162162+```rust
163163+#[component]
164164+fn App() -> Element {
165165+ let mut theme = use_signal(|| "light".to_string());
166166+ use_context_provider(|| theme); // Provide a type to children
167167+ rsx! { Child {} }
168168+}
169169+170170+#[component]
171171+fn Child() -> Element {
172172+ let theme = use_context::<Signal<String>>(); // Consume the same type
173173+ rsx! {
174174+ div {
175175+ "Current theme: {theme}"
176176+ }
177177+ }
178178+}
179179+```
180180+181181+# Async
182182+183183+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.
184184+185185+* The `use_resource` hook takes an `async` closure. It re-runs this closure whenever any signals it depends on (reads) are updated
186186+* The `Resource` object returned can be in several states when read:
187187+1. `None` if the resource is still loading
188188+2. `Some(value)` if the resource has successfully loaded
189189+190190+```rust
191191+let mut dog = use_resource(move || async move {
192192+ // api request
193193+});
194194+195195+match dog() {
196196+ Some(dog_info) => rsx! { Dog { dog_info } },
197197+ None => rsx! { "Loading..." },
198198+}
199199+```
200200+201201+# Routing
202202+203203+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.
204204+205205+The `Router<Route> {}` component is the entry point that manages rendering the correct component for the current URL.
206206+207207+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.
208208+209209+```rust
210210+#[derive(Routable, Clone, PartialEq)]
211211+enum Route {
212212+ #[layout(NavBar)] // This will use NavBar as the layout for all routes
213213+ #[route("/")]
214214+ Home {},
215215+ #[route("/blog/:id")] // Dynamic segment
216216+ BlogPost { id: i32 },
217217+}
218218+219219+#[component]
220220+fn NavBar() -> Element {
221221+ rsx! {
222222+ a { href: "/", "Home" }
223223+ Outlet<Route> {} // Renders Home or BlogPost
224224+ }
225225+}
226226+227227+#[component]
228228+fn App() -> Element {
229229+ rsx! { Router::<Route> {} }
230230+}
231231+```
232232+233233+```toml
234234+dioxus = { version = "0.7.0", features = ["router"] }
235235+```
236236+237237+# Fullstack
238238+239239+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.
240240+241241+```toml
242242+dioxus = { version = "0.7.0", features = ["fullstack"] }
243243+```
244244+245245+## Server Functions
246246+247247+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.
248248+249249+```rust
250250+#[post("/api/double/:path/&query")]
251251+async fn double_server(number: i32, path: String, query: i32) -> Result<i32, ServerFnError> {
252252+ tokio::time::sleep(std::time::Duration::from_secs(1)).await;
253253+ Ok(number * 2)
254254+}
255255+```
256256+257257+## Hydration
258258+259259+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.
260260+261261+### Errors
262262+The initial UI rendered by the component on the client must be identical to the UI rendered on the server.
263263+264264+* 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.
265265+* Any code that relies on browser-specific APIs (like accessing `localStorage`) must be run *after* hydration. Place this code inside a `use_effect` hook.
···11+//! Browser implementation of cursor platform operations.
22+//!
33+//! Uses the DOM Selection API to position cursors and retrieve screen coordinates.
44+55+use wasm_bindgen::JsCast;
66+use weaver_editor_core::{
77+ CursorPlatform, CursorRect, OffsetMapping, PlatformError, SelectionRect, SnapDirection,
88+ find_mapping_for_char, find_nearest_valid_position,
99+};
1010+1111+/// Browser-based cursor platform implementation.
1212+///
1313+/// Holds a reference to the editor element ID for DOM lookups.
1414+pub struct BrowserCursor {
1515+ editor_id: String,
1616+}
1717+1818+impl BrowserCursor {
1919+ /// Create a new browser cursor handler for the given editor element.
2020+ pub fn new(editor_id: impl Into<String>) -> Self {
2121+ Self {
2222+ editor_id: editor_id.into(),
2323+ }
2424+ }
2525+2626+ /// Get the editor element ID.
2727+ pub fn editor_id(&self) -> &str {
2828+ &self.editor_id
2929+ }
3030+}
3131+3232+impl CursorPlatform for BrowserCursor {
3333+ fn restore_cursor(
3434+ &self,
3535+ char_offset: usize,
3636+ offset_map: &[OffsetMapping],
3737+ snap_direction: Option<SnapDirection>,
3838+ ) -> Result<(), PlatformError> {
3939+ restore_cursor_position(char_offset, offset_map, snap_direction)
4040+ }
4141+4242+ fn get_cursor_rect(
4343+ &self,
4444+ char_offset: usize,
4545+ offset_map: &[OffsetMapping],
4646+ ) -> Option<CursorRect> {
4747+ get_cursor_rect_impl(char_offset, offset_map)
4848+ }
4949+5050+ fn get_cursor_rect_relative(
5151+ &self,
5252+ char_offset: usize,
5353+ offset_map: &[OffsetMapping],
5454+ ) -> Option<CursorRect> {
5555+ let cursor_rect = self.get_cursor_rect(char_offset, offset_map)?;
5656+5757+ let window = web_sys::window()?;
5858+ let document = window.document()?;
5959+ let editor = document.get_element_by_id(&self.editor_id)?;
6060+ let editor_rect = editor.get_bounding_client_rect();
6161+6262+ Some(CursorRect::new(
6363+ cursor_rect.x - editor_rect.x(),
6464+ cursor_rect.y - editor_rect.y(),
6565+ cursor_rect.height,
6666+ ))
6767+ }
6868+6969+ fn get_selection_rects_relative(
7070+ &self,
7171+ start: usize,
7272+ end: usize,
7373+ offset_map: &[OffsetMapping],
7474+ ) -> Vec<SelectionRect> {
7575+ get_selection_rects_impl(start, end, offset_map, &self.editor_id)
7676+ }
7777+}
7878+7979+/// Restore cursor position in the DOM after re-render.
8080+pub fn restore_cursor_position(
8181+ char_offset: usize,
8282+ offset_map: &[OffsetMapping],
8383+ snap_direction: Option<SnapDirection>,
8484+) -> Result<(), PlatformError> {
8585+ if offset_map.is_empty() {
8686+ return Ok(());
8787+ }
8888+8989+ let max_offset = offset_map
9090+ .iter()
9191+ .map(|m| m.char_range.end)
9292+ .max()
9393+ .unwrap_or(0);
9494+9595+ if char_offset > max_offset {
9696+ tracing::warn!(
9797+ "cursor offset {} > max mapping offset {}",
9898+ char_offset,
9999+ max_offset
100100+ );
101101+ return Ok(());
102102+ }
103103+104104+ let (mapping, char_offset) = match find_mapping_for_char(offset_map, char_offset) {
105105+ Some((m, false)) => (m, char_offset),
106106+ Some((m, true)) => {
107107+ if let Some(snapped) =
108108+ find_nearest_valid_position(offset_map, char_offset, snap_direction)
109109+ {
110110+ tracing::trace!(
111111+ target: "weaver::cursor",
112112+ original_offset = char_offset,
113113+ snapped_offset = snapped.char_offset(),
114114+ direction = ?snapped.snapped,
115115+ "snapping cursor from invisible content"
116116+ );
117117+ (snapped.mapping, snapped.char_offset())
118118+ } else {
119119+ (m, char_offset)
120120+ }
121121+ }
122122+ None => return Err("no mapping found for cursor offset".into()),
123123+ };
124124+125125+ tracing::trace!(
126126+ target: "weaver::cursor",
127127+ char_offset,
128128+ node_id = %mapping.node_id,
129129+ mapping_range = ?mapping.char_range,
130130+ child_index = ?mapping.child_index,
131131+ "restoring cursor position"
132132+ );
133133+134134+ let window = web_sys::window().ok_or("no window")?;
135135+ let document = window.document().ok_or("no document")?;
136136+137137+ let container = document
138138+ .get_element_by_id(&mapping.node_id)
139139+ .or_else(|| {
140140+ let selector = format!("[data-node-id='{}']", mapping.node_id);
141141+ document.query_selector(&selector).ok().flatten()
142142+ })
143143+ .ok_or_else(|| format!("element not found: {}", mapping.node_id))?;
144144+145145+ let selection = window
146146+ .get_selection()
147147+ .map_err(|e| format!("get_selection failed: {:?}", e))?
148148+ .ok_or("no selection object")?;
149149+ let range = document
150150+ .create_range()
151151+ .map_err(|e| format!("create_range failed: {:?}", e))?;
152152+153153+ if let Some(child_index) = mapping.child_index {
154154+ range
155155+ .set_start(&container, child_index as u32)
156156+ .map_err(|e| format!("set_start failed: {:?}", e))?;
157157+ } else {
158158+ let container_element = container
159159+ .dyn_into::<web_sys::HtmlElement>()
160160+ .map_err(|_| "container is not HtmlElement")?;
161161+ let offset_in_range = char_offset - mapping.char_range.start;
162162+ let target_utf16_offset = mapping.char_offset_in_node + offset_in_range;
163163+ let (text_node, node_offset) =
164164+ find_text_node_at_offset(&container_element, target_utf16_offset)?;
165165+ range
166166+ .set_start(&text_node, node_offset as u32)
167167+ .map_err(|e| format!("set_start failed: {:?}", e))?;
168168+ }
169169+170170+ range.collapse_with_to_start(true);
171171+172172+ selection
173173+ .remove_all_ranges()
174174+ .map_err(|e| format!("remove_all_ranges failed: {:?}", e))?;
175175+ selection
176176+ .add_range(&range)
177177+ .map_err(|e| format!("add_range failed: {:?}", e))?;
178178+179179+ Ok(())
180180+}
181181+182182+/// Find text node at given UTF-16 offset within element.
183183+fn find_text_node_at_offset(
184184+ container: &web_sys::HtmlElement,
185185+ target_utf16_offset: usize,
186186+) -> Result<(web_sys::Node, usize), PlatformError> {
187187+ let document = web_sys::window()
188188+ .ok_or("no window")?
189189+ .document()
190190+ .ok_or("no document")?;
191191+192192+ let walker = document
193193+ .create_tree_walker_with_what_to_show(container, 0xFFFFFFFF)
194194+ .map_err(|e| format!("create_tree_walker failed: {:?}", e))?;
195195+196196+ let mut accumulated_utf16 = 0;
197197+ let mut last_node: Option<web_sys::Node> = None;
198198+ let mut skip_until_exit: Option<web_sys::Element> = None;
199199+200200+ while let Ok(Some(node)) = walker.next_node() {
201201+ if let Some(ref skip_elem) = skip_until_exit {
202202+ if !skip_elem.contains(Some(&node)) {
203203+ skip_until_exit = None;
204204+ }
205205+ }
206206+207207+ if skip_until_exit.is_none() {
208208+ if let Some(element) = node.dyn_ref::<web_sys::Element>() {
209209+ if element.get_attribute("contenteditable").as_deref() == Some("false") {
210210+ skip_until_exit = Some(element.clone());
211211+ continue;
212212+ }
213213+ }
214214+ }
215215+216216+ if skip_until_exit.is_some() {
217217+ continue;
218218+ }
219219+220220+ if node.node_type() != web_sys::Node::TEXT_NODE {
221221+ continue;
222222+ }
223223+224224+ last_node = Some(node.clone());
225225+226226+ if let Some(text) = node.text_content() {
227227+ let text_len = text.encode_utf16().count();
228228+229229+ if accumulated_utf16 + text_len >= target_utf16_offset {
230230+ let offset_in_node = target_utf16_offset - accumulated_utf16;
231231+ return Ok((node, offset_in_node));
232232+ }
233233+234234+ accumulated_utf16 += text_len;
235235+ }
236236+ }
237237+238238+ if let Some(node) = last_node {
239239+ if let Some(text) = node.text_content() {
240240+ let text_len = text.encode_utf16().count();
241241+ return Ok((node, text_len));
242242+ }
243243+ }
244244+245245+ Err("no text node found in container".into())
246246+}
247247+248248+/// Get screen coordinates for a cursor position (internal impl).
249249+fn get_cursor_rect_impl(char_offset: usize, offset_map: &[OffsetMapping]) -> Option<CursorRect> {
250250+ if offset_map.is_empty() {
251251+ return None;
252252+ }
253253+254254+ let (mapping, char_offset) = match find_mapping_for_char(offset_map, char_offset) {
255255+ Some((m, _)) => (m, char_offset),
256256+ None => return None,
257257+ };
258258+259259+ let window = web_sys::window()?;
260260+ let document = window.document()?;
261261+262262+ let container = document.get_element_by_id(&mapping.node_id).or_else(|| {
263263+ let selector = format!("[data-node-id='{}']", mapping.node_id);
264264+ document.query_selector(&selector).ok().flatten()
265265+ })?;
266266+267267+ let range = document.create_range().ok()?;
268268+269269+ if let Some(child_index) = mapping.child_index {
270270+ range.set_start(&container, child_index as u32).ok()?;
271271+ } else {
272272+ let container_element = container.dyn_into::<web_sys::HtmlElement>().ok()?;
273273+ let offset_in_range = char_offset - mapping.char_range.start;
274274+ let target_utf16_offset = mapping.char_offset_in_node + offset_in_range;
275275+276276+ if let Ok((text_node, node_offset)) =
277277+ find_text_node_at_offset(&container_element, target_utf16_offset)
278278+ {
279279+ range.set_start(&text_node, node_offset as u32).ok()?;
280280+ } else {
281281+ return None;
282282+ }
283283+ }
284284+285285+ range.collapse_with_to_start(true);
286286+287287+ let rect = range.get_bounding_client_rect();
288288+ Some(CursorRect::new(rect.x(), rect.y(), rect.height().max(16.0)))
289289+}
290290+291291+/// Get selection rectangles relative to editor (internal impl).
292292+fn get_selection_rects_impl(
293293+ start: usize,
294294+ end: usize,
295295+ offset_map: &[OffsetMapping],
296296+ editor_id: &str,
297297+) -> Vec<SelectionRect> {
298298+ if offset_map.is_empty() || start >= end {
299299+ return vec![];
300300+ }
301301+302302+ let Some(window) = web_sys::window() else {
303303+ return vec![];
304304+ };
305305+ let Some(document) = window.document() else {
306306+ return vec![];
307307+ };
308308+ let Some(editor) = document.get_element_by_id(editor_id) else {
309309+ return vec![];
310310+ };
311311+ let editor_rect = editor.get_bounding_client_rect();
312312+313313+ let Some((start_mapping, _)) = find_mapping_for_char(offset_map, start) else {
314314+ return vec![];
315315+ };
316316+ let Some((end_mapping, _)) = find_mapping_for_char(offset_map, end) else {
317317+ return vec![];
318318+ };
319319+320320+ let start_container = document
321321+ .get_element_by_id(&start_mapping.node_id)
322322+ .or_else(|| {
323323+ let selector = format!("[data-node-id='{}']", start_mapping.node_id);
324324+ document.query_selector(&selector).ok().flatten()
325325+ });
326326+ let end_container = document
327327+ .get_element_by_id(&end_mapping.node_id)
328328+ .or_else(|| {
329329+ let selector = format!("[data-node-id='{}']", end_mapping.node_id);
330330+ document.query_selector(&selector).ok().flatten()
331331+ });
332332+333333+ let (Some(start_container), Some(end_container)) = (start_container, end_container) else {
334334+ return vec![];
335335+ };
336336+337337+ let Ok(range) = document.create_range() else {
338338+ return vec![];
339339+ };
340340+341341+ // Set start
342342+ if let Some(child_index) = start_mapping.child_index {
343343+ let _ = range.set_start(&start_container, child_index as u32);
344344+ } else if let Ok(container_element) = start_container.clone().dyn_into::<web_sys::HtmlElement>()
345345+ {
346346+ let offset_in_range = start - start_mapping.char_range.start;
347347+ let target_utf16_offset = start_mapping.char_offset_in_node + offset_in_range;
348348+ if let Ok((text_node, node_offset)) =
349349+ find_text_node_at_offset(&container_element, target_utf16_offset)
350350+ {
351351+ let _ = range.set_start(&text_node, node_offset as u32);
352352+ }
353353+ }
354354+355355+ // Set end
356356+ if let Some(child_index) = end_mapping.child_index {
357357+ let _ = range.set_end(&end_container, child_index as u32);
358358+ } else if let Ok(container_element) = end_container.dyn_into::<web_sys::HtmlElement>() {
359359+ let offset_in_range = end - end_mapping.char_range.start;
360360+ let target_utf16_offset = end_mapping.char_offset_in_node + offset_in_range;
361361+ if let Ok((text_node, node_offset)) =
362362+ find_text_node_at_offset(&container_element, target_utf16_offset)
363363+ {
364364+ let _ = range.set_end(&text_node, node_offset as u32);
365365+ }
366366+ }
367367+368368+ let Some(rects) = range.get_client_rects() else {
369369+ return vec![];
370370+ };
371371+ let mut result = Vec::new();
372372+373373+ for i in 0..rects.length() {
374374+ if let Some(rect) = rects.get(i) {
375375+ let rect: web_sys::DomRect = rect;
376376+ result.push(SelectionRect::new(
377377+ rect.x() - editor_rect.x(),
378378+ rect.y() - editor_rect.y(),
379379+ rect.width(),
380380+ rect.height().max(16.0),
381381+ ));
382382+ }
383383+ }
384384+385385+ result
386386+}
+417
crates/weaver-editor-browser/src/dom_sync.rs
···11+//! DOM synchronization for the markdown editor.
22+//!
33+//! Handles syncing cursor/selection state between the browser DOM and the
44+//! editor document model, and updating paragraph DOM elements.
55+66+use wasm_bindgen::JsCast;
77+use weaver_editor_core::{
88+ CursorSync, OffsetMapping, SnapDirection, find_nearest_valid_position, is_valid_cursor_position,
99+};
1010+1111+use crate::cursor::restore_cursor_position;
1212+1313+/// Result of syncing cursor from DOM.
1414+#[derive(Debug, Clone)]
1515+pub enum CursorSyncResult {
1616+ /// Cursor is collapsed at this offset.
1717+ Cursor(usize),
1818+ /// Selection from anchor to head.
1919+ Selection { anchor: usize, head: usize },
2020+ /// Could not determine cursor position.
2121+ None,
2222+}
2323+2424+/// Browser-based cursor sync implementation.
2525+///
2626+/// Holds reference to editor element ID and provides methods to sync
2727+/// cursor state from DOM back to the editor model.
2828+pub struct BrowserCursorSync {
2929+ editor_id: String,
3030+}
3131+3232+impl BrowserCursorSync {
3333+ /// Create a new browser cursor sync for the given editor element.
3434+ pub fn new(editor_id: impl Into<String>) -> Self {
3535+ Self {
3636+ editor_id: editor_id.into(),
3737+ }
3838+ }
3939+4040+ /// Get the editor element ID.
4141+ pub fn editor_id(&self) -> &str {
4242+ &self.editor_id
4343+ }
4444+}
4545+4646+impl CursorSync for BrowserCursorSync {
4747+ fn sync_cursor_from_platform<F, G>(
4848+ &self,
4949+ offset_map: &[OffsetMapping],
5050+ direction_hint: Option<SnapDirection>,
5151+ on_cursor: F,
5252+ on_selection: G,
5353+ ) where
5454+ F: FnOnce(usize),
5555+ G: FnOnce(usize, usize),
5656+ {
5757+ if let Some(result) = sync_cursor_from_dom_impl(&self.editor_id, offset_map, direction_hint)
5858+ {
5959+ match result {
6060+ CursorSyncResult::Cursor(offset) => on_cursor(offset),
6161+ CursorSyncResult::Selection { anchor, head } => {
6262+ if anchor == head {
6363+ on_cursor(anchor);
6464+ } else {
6565+ on_selection(anchor, head);
6666+ }
6767+ }
6868+ CursorSyncResult::None => {}
6969+ }
7070+ }
7171+ }
7272+}
7373+7474+/// Sync cursor state from DOM selection, returning the result.
7575+///
7676+/// This is the core implementation that reads the browser's selection state
7777+/// and converts it to character offsets using the offset map.
7878+pub fn sync_cursor_from_dom_impl(
7979+ editor_id: &str,
8080+ offset_map: &[OffsetMapping],
8181+ direction_hint: Option<SnapDirection>,
8282+) -> Option<CursorSyncResult> {
8383+ if offset_map.is_empty() {
8484+ return Some(CursorSyncResult::None);
8585+ }
8686+8787+ let window = web_sys::window()?;
8888+ let dom_document = window.document()?;
8989+ let editor_element = dom_document.get_element_by_id(editor_id)?;
9090+9191+ let selection = window.get_selection().ok()??;
9292+9393+ let anchor_node = selection.anchor_node()?;
9494+ let focus_node = selection.focus_node()?;
9595+ let anchor_offset = selection.anchor_offset() as usize;
9696+ let focus_offset = selection.focus_offset() as usize;
9797+9898+ let anchor_char = dom_position_to_text_offset(
9999+ &dom_document,
100100+ &editor_element,
101101+ &anchor_node,
102102+ anchor_offset,
103103+ offset_map,
104104+ direction_hint,
105105+ );
106106+ let focus_char = dom_position_to_text_offset(
107107+ &dom_document,
108108+ &editor_element,
109109+ &focus_node,
110110+ focus_offset,
111111+ offset_map,
112112+ direction_hint,
113113+ );
114114+115115+ match (anchor_char, focus_char) {
116116+ (Some(anchor), Some(head)) => {
117117+ if anchor == head {
118118+ Some(CursorSyncResult::Cursor(head))
119119+ } else {
120120+ Some(CursorSyncResult::Selection { anchor, head })
121121+ }
122122+ }
123123+ _ => {
124124+ tracing::warn!("Could not map DOM selection to text offsets");
125125+ Some(CursorSyncResult::None)
126126+ }
127127+ }
128128+}
129129+130130+/// Convert a DOM position (node + offset) to a text char offset.
131131+///
132132+/// Walks up from the node to find a container with a node ID, then uses
133133+/// the offset map to convert the UTF-16 offset to a character offset.
134134+pub fn dom_position_to_text_offset(
135135+ dom_document: &web_sys::Document,
136136+ editor_element: &web_sys::Element,
137137+ node: &web_sys::Node,
138138+ offset_in_text_node: usize,
139139+ offset_map: &[OffsetMapping],
140140+ direction_hint: Option<SnapDirection>,
141141+) -> Option<usize> {
142142+ // Find the containing element with a node ID (walk up from text node).
143143+ let mut current_node = node.clone();
144144+ let mut walked_from: Option<web_sys::Node> = None;
145145+146146+ let node_id = loop {
147147+ if let Some(element) = current_node.dyn_ref::<web_sys::Element>() {
148148+ if element == editor_element {
149149+ // Selection is on the editor container itself.
150150+ if let Some(ref walked_node) = walked_from {
151151+ // We walked up from a descendant - find which mapping it belongs to.
152152+ for mapping in offset_map {
153153+ if let Some(elem) = dom_document.get_element_by_id(&mapping.node_id) {
154154+ let elem_node: &web_sys::Node = elem.as_ref();
155155+ if elem_node.contains(Some(walked_node)) {
156156+ return Some(mapping.char_range.start);
157157+ }
158158+ }
159159+ }
160160+ break None;
161161+ }
162162+163163+ // Selection is directly on the editor container (e.g., Cmd+A).
164164+ let child_count = editor_element.child_element_count() as usize;
165165+ if offset_in_text_node == 0 {
166166+ return Some(0);
167167+ } else if offset_in_text_node >= child_count {
168168+ return offset_map.last().map(|m| m.char_range.end);
169169+ }
170170+ break None;
171171+ }
172172+173173+ let id = element
174174+ .get_attribute("id")
175175+ .or_else(|| element.get_attribute("data-node-id"));
176176+177177+ if let Some(id) = id {
178178+ let is_node_id = id.starts_with('n') || id.contains("-n");
179179+ if is_node_id {
180180+ break Some(id);
181181+ }
182182+ }
183183+ }
184184+185185+ walked_from = Some(current_node.clone());
186186+ current_node = current_node.parent_node()?;
187187+ };
188188+189189+ let node_id = node_id?;
190190+191191+ let container = dom_document.get_element_by_id(&node_id).or_else(|| {
192192+ let selector = format!("[data-node-id='{}']", node_id);
193193+ dom_document.query_selector(&selector).ok().flatten()
194194+ })?;
195195+196196+ // Calculate UTF-16 offset from start of container to the position.
197197+ let mut utf16_offset_in_container = 0;
198198+199199+ let node_is_container = node
200200+ .dyn_ref::<web_sys::Element>()
201201+ .map(|e| e == &container)
202202+ .unwrap_or(false);
203203+204204+ if node_is_container {
205205+ // offset_in_text_node is a child index.
206206+ let child_index = offset_in_text_node;
207207+ let children = container.child_nodes();
208208+ let mut text_counted = 0usize;
209209+210210+ for i in 0..child_index.min(children.length() as usize) {
211211+ if let Some(child) = children.get(i as u32) {
212212+ if let Some(text) = child.text_content() {
213213+ text_counted += text.encode_utf16().count();
214214+ }
215215+ }
216216+ }
217217+ utf16_offset_in_container = text_counted;
218218+ } else {
219219+ // Normal case: node is a text node, walk to find it.
220220+ if let Ok(walker) =
221221+ dom_document.create_tree_walker_with_what_to_show(&container, 0xFFFFFFFF)
222222+ {
223223+ let mut skip_until_exit: Option<web_sys::Element> = None;
224224+225225+ while let Ok(Some(dom_node)) = walker.next_node() {
226226+ if let Some(ref skip_elem) = skip_until_exit {
227227+ if !skip_elem.contains(Some(&dom_node)) {
228228+ skip_until_exit = None;
229229+ }
230230+ }
231231+232232+ if skip_until_exit.is_none() {
233233+ if let Some(element) = dom_node.dyn_ref::<web_sys::Element>() {
234234+ if element.get_attribute("contenteditable").as_deref() == Some("false") {
235235+ skip_until_exit = Some(element.clone());
236236+ continue;
237237+ }
238238+ }
239239+ }
240240+241241+ if skip_until_exit.is_some() {
242242+ continue;
243243+ }
244244+245245+ if dom_node.node_type() == web_sys::Node::TEXT_NODE {
246246+ if &dom_node == node {
247247+ utf16_offset_in_container += offset_in_text_node;
248248+ break;
249249+ }
250250+251251+ if let Some(text) = dom_node.text_content() {
252252+ utf16_offset_in_container += text.encode_utf16().count();
253253+ }
254254+ }
255255+ }
256256+ }
257257+ }
258258+259259+ // Look up the offset in the offset map.
260260+ for mapping in offset_map {
261261+ if mapping.node_id == node_id {
262262+ let mapping_start = mapping.char_offset_in_node;
263263+ let mapping_end = mapping.char_offset_in_node + mapping.utf16_len;
264264+265265+ if utf16_offset_in_container >= mapping_start
266266+ && utf16_offset_in_container <= mapping_end
267267+ {
268268+ let offset_in_mapping = utf16_offset_in_container - mapping_start;
269269+ let char_offset = mapping.char_range.start + offset_in_mapping;
270270+271271+ // Check if position is valid (not on invisible content).
272272+ if is_valid_cursor_position(offset_map, char_offset) {
273273+ return Some(char_offset);
274274+ }
275275+276276+ // Position is on invisible content, snap to nearest valid.
277277+ if let Some(snapped) =
278278+ find_nearest_valid_position(offset_map, char_offset, direction_hint)
279279+ {
280280+ return Some(snapped.char_offset());
281281+ }
282282+283283+ return Some(char_offset);
284284+ }
285285+ }
286286+ }
287287+288288+ // No mapping found - try to find any valid position.
289289+ if let Some(snapped) = find_nearest_valid_position(offset_map, 0, direction_hint) {
290290+ return Some(snapped.char_offset());
291291+ }
292292+293293+ None
294294+}
295295+296296+/// Paragraph render data needed for DOM updates.
297297+///
298298+/// This is a simplified view of paragraph data for the DOM sync layer.
299299+pub struct ParagraphDomData<'a> {
300300+ /// Paragraph ID (for DOM element lookup).
301301+ pub id: &'a str,
302302+ /// HTML content to render.
303303+ pub html: &'a str,
304304+ /// Source hash for change detection.
305305+ pub source_hash: u64,
306306+ /// Character range in document.
307307+ pub char_range: std::ops::Range<usize>,
308308+ /// Offset mappings for cursor restoration.
309309+ pub offset_map: &'a [OffsetMapping],
310310+}
311311+312312+/// Update paragraph DOM elements incrementally.
313313+///
314314+/// Returns true if the paragraph containing the cursor was updated.
315315+pub fn update_paragraph_dom(
316316+ editor_id: &str,
317317+ old_paragraphs: &[ParagraphDomData<'_>],
318318+ new_paragraphs: &[ParagraphDomData<'_>],
319319+ cursor_offset: usize,
320320+ force: bool,
321321+) -> bool {
322322+ use std::collections::HashMap;
323323+324324+ let window = match web_sys::window() {
325325+ Some(w) => w,
326326+ None => return false,
327327+ };
328328+329329+ let document = match window.document() {
330330+ Some(d) => d,
331331+ None => return false,
332332+ };
333333+334334+ let editor = match document.get_element_by_id(editor_id) {
335335+ Some(e) => e,
336336+ None => return false,
337337+ };
338338+339339+ let mut cursor_para_updated = false;
340340+341341+ // Build pool of existing DOM elements by ID.
342342+ let mut old_elements: HashMap<String, web_sys::Element> = HashMap::new();
343343+ let mut child_opt = editor.first_element_child();
344344+ while let Some(child) = child_opt {
345345+ if let Some(id) = child.get_attribute("id") {
346346+ let next = child.next_element_sibling();
347347+ old_elements.insert(id, child);
348348+ child_opt = next;
349349+ } else {
350350+ child_opt = child.next_element_sibling();
351351+ }
352352+ }
353353+354354+ let mut cursor_node: Option<web_sys::Node> = editor.first_element_child().map(|e| e.into());
355355+356356+ for new_para in new_paragraphs.iter() {
357357+ let para_id = new_para.id;
358358+ let new_hash = format!("{:x}", new_para.source_hash);
359359+ let is_cursor_para = new_para.char_range.start <= cursor_offset
360360+ && cursor_offset <= new_para.char_range.end;
361361+362362+ if let Some(existing_elem) = old_elements.remove(para_id) {
363363+ let old_hash = existing_elem.get_attribute("data-hash").unwrap_or_default();
364364+ let needs_update = force || old_hash != new_hash;
365365+366366+ let existing_as_node: &web_sys::Node = existing_elem.as_ref();
367367+ let at_correct_position = cursor_node
368368+ .as_ref()
369369+ .map(|c| c == existing_as_node)
370370+ .unwrap_or(false);
371371+372372+ if !at_correct_position {
373373+ let _ = editor.insert_before(existing_as_node, cursor_node.as_ref());
374374+ if is_cursor_para {
375375+ cursor_para_updated = true;
376376+ }
377377+ } else {
378378+ cursor_node = existing_elem.next_element_sibling().map(|e| e.into());
379379+ }
380380+381381+ if needs_update {
382382+ existing_elem.set_inner_html(new_para.html);
383383+ let _ = existing_elem.set_attribute("data-hash", &new_hash);
384384+385385+ if is_cursor_para {
386386+ if let Err(e) =
387387+ restore_cursor_position(cursor_offset, new_para.offset_map, None)
388388+ {
389389+ tracing::warn!("Cursor restore failed: {:?}", e);
390390+ }
391391+ cursor_para_updated = true;
392392+ }
393393+ }
394394+ } else {
395395+ // New element - create and insert.
396396+ if let Ok(div) = document.create_element("div") {
397397+ div.set_id(para_id);
398398+ div.set_inner_html(new_para.html);
399399+ let _ = div.set_attribute("data-hash", &new_hash);
400400+ let div_node: &web_sys::Node = div.as_ref();
401401+ let _ = editor.insert_before(div_node, cursor_node.as_ref());
402402+ }
403403+404404+ if is_cursor_para {
405405+ cursor_para_updated = true;
406406+ }
407407+ }
408408+ }
409409+410410+ // Remove stale elements.
411411+ for (_, elem) in old_elements {
412412+ let _ = elem.remove();
413413+ cursor_para_updated = true;
414414+ }
415415+416416+ cursor_para_updated
417417+}
+5
crates/weaver-editor-browser/src/events.rs
···11+//! Browser event handling for the editor.
22+//!
33+//! Handles beforeinput, keydown, paste, and other DOM events.
44+55+// TODO: Migrate from weaver-app (beforeinput.rs, input.rs)
+36
crates/weaver-editor-browser/src/lib.rs
···11+//! Browser DOM layer for the weaver markdown editor.
22+//!
33+//! This crate provides DOM manipulation and browser event handling,
44+//! generic over any `EditorDocument` implementation. It assumes a
55+//! `wasm32-unknown-unknown` target environment.
66+//!
77+//! # Architecture
88+//!
99+//! - `cursor`: Selection API handling and cursor restoration
1010+//! - `dom_sync`: DOM ↔ document state synchronization
1111+//! - `events`: beforeinput, keydown, paste event handlers
1212+//! - `contenteditable`: Editor element setup and management
1313+//! - `platform`: Browser/OS detection for platform-specific behavior
1414+//!
1515+//! # Re-exports
1616+//!
1717+//! This crate re-exports `weaver-editor-core` for convenience, so consumers
1818+//! only need to depend on `weaver-editor-browser`.
1919+2020+// Re-export core crate
2121+pub use weaver_editor_core;
2222+pub use weaver_editor_core::*;
2323+2424+pub mod cursor;
2525+pub mod dom_sync;
2626+pub mod events;
2727+pub mod platform;
2828+2929+// Browser cursor implementation
3030+pub use cursor::BrowserCursor;
3131+3232+// Platform detection
3333+pub use platform::{Platform, platform};
3434+3535+// TODO: contenteditable module
3636+// TODO: embed worker module
+108
crates/weaver-editor-browser/src/platform.rs
···11+//! Platform detection for browser-specific workarounds.
22+//!
33+//! Based on patterns from ProseMirror's input handling, adapted for Rust/wasm.
44+55+use std::sync::OnceLock;
66+77+/// Cached platform detection results.
88+#[derive(Debug, Clone)]
99+pub struct Platform {
1010+ pub ios: bool,
1111+ pub mac: bool,
1212+ pub android: bool,
1313+ pub chrome: bool,
1414+ pub safari: bool,
1515+ pub gecko: bool,
1616+ pub webkit_version: Option<u32>,
1717+ pub chrome_version: Option<u32>,
1818+ pub mobile: bool,
1919+}
2020+2121+impl Default for Platform {
2222+ fn default() -> Self {
2323+ Self {
2424+ ios: false,
2525+ mac: false,
2626+ android: false,
2727+ chrome: false,
2828+ safari: false,
2929+ gecko: false,
3030+ webkit_version: None,
3131+ chrome_version: None,
3232+ mobile: false,
3333+ }
3434+ }
3535+}
3636+3737+static PLATFORM: OnceLock<Platform> = OnceLock::new();
3838+3939+/// Get cached platform info. Detection runs once on first call.
4040+pub fn platform() -> &'static Platform {
4141+ PLATFORM.get_or_init(detect_platform)
4242+}
4343+4444+fn detect_platform() -> Platform {
4545+ let window = match web_sys::window() {
4646+ Some(w) => w,
4747+ None => return Platform::default(),
4848+ };
4949+5050+ let navigator = window.navigator();
5151+ let user_agent = navigator.user_agent().unwrap_or_default().to_lowercase();
5252+ let platform_str = navigator.platform().unwrap_or_default().to_lowercase();
5353+5454+ // iOS detection: iPhone/iPad/iPod in UA, or Mac platform with touch.
5555+ let ios = user_agent.contains("iphone")
5656+ || user_agent.contains("ipad")
5757+ || user_agent.contains("ipod")
5858+ || (platform_str.contains("mac") && has_touch_support(&navigator));
5959+6060+ // macOS (but not iOS).
6161+ let mac = platform_str.contains("mac") && !ios;
6262+6363+ // Android.
6464+ let android = user_agent.contains("android");
6565+6666+ // Chrome (but not Edge, which also contains Chrome).
6767+ let chrome = user_agent.contains("chrome") && !user_agent.contains("edg");
6868+6969+ // Safari (WebKit but not Chrome).
7070+ let safari = user_agent.contains("safari") && !user_agent.contains("chrome");
7171+7272+ // Firefox/Gecko.
7373+ let gecko = user_agent.contains("gecko/") && !user_agent.contains("like gecko");
7474+7575+ // WebKit version extraction.
7676+ let webkit_version = extract_version(&user_agent, "applewebkit/");
7777+7878+ // Chrome version extraction.
7979+ let chrome_version = extract_version(&user_agent, "chrome/");
8080+8181+ // Mobile detection.
8282+ let mobile =
8383+ ios || android || user_agent.contains("mobile") || user_agent.contains("iemobile");
8484+8585+ Platform {
8686+ ios,
8787+ mac,
8888+ android,
8989+ chrome,
9090+ safari,
9191+ gecko,
9292+ webkit_version,
9393+ chrome_version,
9494+ mobile,
9595+ }
9696+}
9797+9898+fn has_touch_support(navigator: &web_sys::Navigator) -> bool {
9999+ navigator.max_touch_points() > 0
100100+}
101101+102102+fn extract_version(ua: &str, prefix: &str) -> Option<u32> {
103103+ ua.find(prefix).and_then(|idx| {
104104+ let after = &ua[idx + prefix.len()..];
105105+ let version_str: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
106106+ version_str.parse().ok()
107107+ })
108108+}
+595
crates/weaver-editor-core/src/actions.rs
···11+//! Editor actions and input types.
22+//!
33+//! Platform-agnostic definitions for editor operations. The `EditorAction` enum
44+//! represents semantic editing operations, while `InputType` represents the
55+//! semantic intent from input events (browser beforeinput, native input methods, etc.).
66+77+use smol_str::SmolStr;
88+99+/// A range in the document, measured in character offsets.
1010+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1111+pub struct Range {
1212+ pub start: usize,
1313+ pub end: usize,
1414+}
1515+1616+impl Range {
1717+ pub fn new(start: usize, end: usize) -> Self {
1818+ Self { start, end }
1919+ }
2020+2121+ pub fn caret(offset: usize) -> Self {
2222+ Self {
2323+ start: offset,
2424+ end: offset,
2525+ }
2626+ }
2727+2828+ pub fn is_caret(&self) -> bool {
2929+ self.start == self.end
3030+ }
3131+3232+ pub fn len(&self) -> usize {
3333+ self.end.saturating_sub(self.start)
3434+ }
3535+3636+ pub fn is_empty(&self) -> bool {
3737+ self.len() == 0
3838+ }
3939+4040+ /// Normalize range so start <= end.
4141+ pub fn normalize(self) -> Self {
4242+ if self.start <= self.end {
4343+ self
4444+ } else {
4545+ Self {
4646+ start: self.end,
4747+ end: self.start,
4848+ }
4949+ }
5050+ }
5151+}
5252+5353+impl From<std::ops::Range<usize>> for Range {
5454+ fn from(r: std::ops::Range<usize>) -> Self {
5555+ Self::new(r.start, r.end)
5656+ }
5757+}
5858+5959+impl From<Range> for std::ops::Range<usize> {
6060+ fn from(r: Range) -> Self {
6161+ r.start..r.end
6262+ }
6363+}
6464+6565+/// Semantic input types from input events.
6666+///
6767+/// These represent the semantic intent of an input operation, abstracted from
6868+/// the platform-specific event source. Browser `beforeinput` events, native
6969+/// input methods, and programmatic input can all produce these types.
7070+///
7171+/// Based on the W3C Input Events specification, but usable across platforms.
7272+#[derive(Debug, Clone, PartialEq, Eq)]
7373+pub enum InputType {
7474+ // === Insertion ===
7575+ /// Insert typed text.
7676+ InsertText,
7777+ /// Insert text from IME composition.
7878+ InsertCompositionText,
7979+ /// Insert a line break (`<br>`, Shift+Enter).
8080+ InsertLineBreak,
8181+ /// Insert a paragraph break (Enter).
8282+ InsertParagraph,
8383+ /// Insert from paste operation.
8484+ InsertFromPaste,
8585+ /// Insert from drop operation.
8686+ InsertFromDrop,
8787+ /// Insert replacement text (e.g., spell check correction).
8888+ InsertReplacementText,
8989+ /// Insert from voice input or other source.
9090+ InsertFromYank,
9191+ /// Insert a horizontal rule.
9292+ InsertHorizontalRule,
9393+ /// Insert an ordered list.
9494+ InsertOrderedList,
9595+ /// Insert an unordered list.
9696+ InsertUnorderedList,
9797+ /// Insert a link.
9898+ InsertLink,
9999+100100+ // === Deletion ===
101101+ /// Delete content backward (Backspace).
102102+ DeleteContentBackward,
103103+ /// Delete content forward (Delete key).
104104+ DeleteContentForward,
105105+ /// Delete word backward (Ctrl/Alt+Backspace).
106106+ DeleteWordBackward,
107107+ /// Delete word forward (Ctrl/Alt+Delete).
108108+ DeleteWordForward,
109109+ /// Delete to soft line boundary backward.
110110+ DeleteSoftLineBackward,
111111+ /// Delete to soft line boundary forward.
112112+ DeleteSoftLineForward,
113113+ /// Delete to hard line boundary backward (Cmd+Backspace on Mac).
114114+ DeleteHardLineBackward,
115115+ /// Delete to hard line boundary forward (Cmd+Delete on Mac).
116116+ DeleteHardLineForward,
117117+ /// Delete by cut operation.
118118+ DeleteByCut,
119119+ /// Delete by drag operation.
120120+ DeleteByDrag,
121121+ /// Generic content deletion.
122122+ DeleteContent,
123123+ /// Delete entire word backward.
124124+ DeleteEntireWordBackward,
125125+ /// Delete entire word forward.
126126+ DeleteEntireWordForward,
127127+128128+ // === History ===
129129+ /// Undo.
130130+ HistoryUndo,
131131+ /// Redo.
132132+ HistoryRedo,
133133+134134+ // === Formatting ===
135135+ FormatBold,
136136+ FormatItalic,
137137+ FormatUnderline,
138138+ FormatStrikethrough,
139139+ FormatSuperscript,
140140+ FormatSubscript,
141141+142142+ // === Unknown ===
143143+ /// Unrecognized input type.
144144+ Unknown(String),
145145+}
146146+147147+impl InputType {
148148+ /// Whether this input type is a deletion operation.
149149+ pub fn is_deletion(&self) -> bool {
150150+ matches!(
151151+ self,
152152+ Self::DeleteContentBackward
153153+ | Self::DeleteContentForward
154154+ | Self::DeleteWordBackward
155155+ | Self::DeleteWordForward
156156+ | Self::DeleteSoftLineBackward
157157+ | Self::DeleteSoftLineForward
158158+ | Self::DeleteHardLineBackward
159159+ | Self::DeleteHardLineForward
160160+ | Self::DeleteByCut
161161+ | Self::DeleteByDrag
162162+ | Self::DeleteContent
163163+ | Self::DeleteEntireWordBackward
164164+ | Self::DeleteEntireWordForward
165165+ )
166166+ }
167167+168168+ /// Whether this input type is an insertion operation.
169169+ pub fn is_insertion(&self) -> bool {
170170+ matches!(
171171+ self,
172172+ Self::InsertText
173173+ | Self::InsertCompositionText
174174+ | Self::InsertLineBreak
175175+ | Self::InsertParagraph
176176+ | Self::InsertFromPaste
177177+ | Self::InsertFromDrop
178178+ | Self::InsertReplacementText
179179+ | Self::InsertFromYank
180180+ )
181181+ }
182182+}
183183+184184+/// All possible editor actions.
185185+///
186186+/// These represent semantic operations on the document, decoupled from
187187+/// how they're triggered (keyboard, mouse, touch, voice, etc.).
188188+#[derive(Debug, Clone, PartialEq)]
189189+pub enum EditorAction {
190190+ // === Text Insertion ===
191191+ /// Insert text at the given range (replacing any selected content).
192192+ Insert { text: String, range: Range },
193193+194194+ /// Insert a soft line break (Shift+Enter, `<br>` equivalent).
195195+ InsertLineBreak { range: Range },
196196+197197+ /// Insert a paragraph break (Enter).
198198+ InsertParagraph { range: Range },
199199+200200+ // === Deletion ===
201201+ /// Delete content backward (Backspace).
202202+ DeleteBackward { range: Range },
203203+204204+ /// Delete content forward (Delete key).
205205+ DeleteForward { range: Range },
206206+207207+ /// Delete word backward (Ctrl/Alt+Backspace).
208208+ DeleteWordBackward { range: Range },
209209+210210+ /// Delete word forward (Ctrl/Alt+Delete).
211211+ DeleteWordForward { range: Range },
212212+213213+ /// Delete to start of line (Cmd+Backspace on Mac).
214214+ DeleteToLineStart { range: Range },
215215+216216+ /// Delete to end of line (Cmd+Delete on Mac).
217217+ DeleteToLineEnd { range: Range },
218218+219219+ /// Delete to start of soft line (visual line in wrapped text).
220220+ DeleteSoftLineBackward { range: Range },
221221+222222+ /// Delete to end of soft line.
223223+ DeleteSoftLineForward { range: Range },
224224+225225+ // === History ===
226226+ /// Undo the last change.
227227+ Undo,
228228+229229+ /// Redo the last undone change.
230230+ Redo,
231231+232232+ // === Formatting ===
233233+ /// Toggle bold on selection.
234234+ ToggleBold,
235235+236236+ /// Toggle italic on selection.
237237+ ToggleItalic,
238238+239239+ /// Toggle inline code on selection.
240240+ ToggleCode,
241241+242242+ /// Toggle strikethrough on selection.
243243+ ToggleStrikethrough,
244244+245245+ /// Insert/wrap with link.
246246+ InsertLink,
247247+248248+ // === Clipboard ===
249249+ /// Cut selection to clipboard.
250250+ Cut,
251251+252252+ /// Copy selection to clipboard.
253253+ Copy,
254254+255255+ /// Paste from clipboard at range.
256256+ Paste { range: Range },
257257+258258+ /// Copy selection as rendered HTML.
259259+ CopyAsHtml,
260260+261261+ // === Selection ===
262262+ /// Select all content.
263263+ SelectAll,
264264+265265+ // === Navigation ===
266266+ /// Move cursor to position.
267267+ MoveCursor { offset: usize },
268268+269269+ /// Extend selection to position.
270270+ ExtendSelection { offset: usize },
271271+}
272272+273273+impl EditorAction {
274274+ /// Update the range in actions that use one.
275275+ pub fn with_range(self, range: Range) -> Self {
276276+ match self {
277277+ Self::Insert { text, .. } => Self::Insert { text, range },
278278+ Self::InsertLineBreak { .. } => Self::InsertLineBreak { range },
279279+ Self::InsertParagraph { .. } => Self::InsertParagraph { range },
280280+ Self::DeleteBackward { .. } => Self::DeleteBackward { range },
281281+ Self::DeleteForward { .. } => Self::DeleteForward { range },
282282+ Self::DeleteWordBackward { .. } => Self::DeleteWordBackward { range },
283283+ Self::DeleteWordForward { .. } => Self::DeleteWordForward { range },
284284+ Self::DeleteToLineStart { .. } => Self::DeleteToLineStart { range },
285285+ Self::DeleteToLineEnd { .. } => Self::DeleteToLineEnd { range },
286286+ Self::DeleteSoftLineBackward { .. } => Self::DeleteSoftLineBackward { range },
287287+ Self::DeleteSoftLineForward { .. } => Self::DeleteSoftLineForward { range },
288288+ Self::Paste { .. } => Self::Paste { range },
289289+ other => other,
290290+ }
291291+ }
292292+}
293293+294294+/// Key values for keyboard input.
295295+///
296296+/// Platform-agnostic key representation. Platform-specific code converts
297297+/// from native key events to this enum.
298298+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
299299+pub enum Key {
300300+ /// A character key.
301301+ Character(SmolStr),
302302+303303+ /// Unknown/unidentified key.
304304+ Unidentified,
305305+306306+ // === Whitespace / editing ===
307307+ Backspace,
308308+ Delete,
309309+ Enter,
310310+ Tab,
311311+ Escape,
312312+ Space,
313313+ Insert,
314314+ Clear,
315315+316316+ // === Navigation ===
317317+ ArrowLeft,
318318+ ArrowRight,
319319+ ArrowUp,
320320+ ArrowDown,
321321+ Home,
322322+ End,
323323+ PageUp,
324324+ PageDown,
325325+326326+ // === Modifiers ===
327327+ Alt,
328328+ AltGraph,
329329+ CapsLock,
330330+ Control,
331331+ Fn,
332332+ FnLock,
333333+ Meta,
334334+ NumLock,
335335+ ScrollLock,
336336+ Shift,
337337+ Symbol,
338338+ SymbolLock,
339339+ Hyper,
340340+ Super,
341341+342342+ // === Function keys ===
343343+ F1,
344344+ F2,
345345+ F3,
346346+ F4,
347347+ F5,
348348+ F6,
349349+ F7,
350350+ F8,
351351+ F9,
352352+ F10,
353353+ F11,
354354+ F12,
355355+ F13,
356356+ F14,
357357+ F15,
358358+ F16,
359359+ F17,
360360+ F18,
361361+ F19,
362362+ F20,
363363+364364+ // === UI keys ===
365365+ ContextMenu,
366366+ PrintScreen,
367367+ Pause,
368368+ Help,
369369+370370+ // === Clipboard / editing commands ===
371371+ Copy,
372372+ Cut,
373373+ Paste,
374374+ Undo,
375375+ Redo,
376376+ Find,
377377+ Select,
378378+379379+ // === Media keys ===
380380+ MediaPlayPause,
381381+ MediaStop,
382382+ MediaTrackNext,
383383+ MediaTrackPrevious,
384384+ AudioVolumeDown,
385385+ AudioVolumeUp,
386386+ AudioVolumeMute,
387387+388388+ // === IME / composition ===
389389+ Compose,
390390+ Convert,
391391+ NonConvert,
392392+ Dead,
393393+394394+ // === CJK IME keys ===
395395+ HangulMode,
396396+ HanjaMode,
397397+ JunjaMode,
398398+ Eisu,
399399+ Hankaku,
400400+ Hiragana,
401401+ HiraganaKatakana,
402402+ KanaMode,
403403+ KanjiMode,
404404+ Katakana,
405405+ Romaji,
406406+ Zenkaku,
407407+ ZenkakuHankaku,
408408+}
409409+410410+impl Key {
411411+ /// Create a character key.
412412+ pub fn character(s: impl Into<SmolStr>) -> Self {
413413+ Self::Character(s.into())
414414+ }
415415+416416+ /// Check if this is a navigation key.
417417+ pub fn is_navigation(&self) -> bool {
418418+ matches!(
419419+ self,
420420+ Self::ArrowLeft
421421+ | Self::ArrowRight
422422+ | Self::ArrowUp
423423+ | Self::ArrowDown
424424+ | Self::Home
425425+ | Self::End
426426+ | Self::PageUp
427427+ | Self::PageDown
428428+ )
429429+ }
430430+431431+ /// Check if this is a modifier key.
432432+ pub fn is_modifier(&self) -> bool {
433433+ matches!(
434434+ self,
435435+ Self::Alt
436436+ | Self::AltGraph
437437+ | Self::CapsLock
438438+ | Self::Control
439439+ | Self::Fn
440440+ | Self::FnLock
441441+ | Self::Meta
442442+ | Self::NumLock
443443+ | Self::ScrollLock
444444+ | Self::Shift
445445+ | Self::Symbol
446446+ | Self::SymbolLock
447447+ | Self::Hyper
448448+ | Self::Super
449449+ )
450450+ }
451451+}
452452+453453+/// Modifier key state for a key combination.
454454+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
455455+pub struct Modifiers {
456456+ pub ctrl: bool,
457457+ pub alt: bool,
458458+ pub shift: bool,
459459+ pub meta: bool,
460460+}
461461+462462+impl Modifiers {
463463+ pub const NONE: Self = Self {
464464+ ctrl: false,
465465+ alt: false,
466466+ shift: false,
467467+ meta: false,
468468+ };
469469+470470+ pub const CTRL: Self = Self {
471471+ ctrl: true,
472472+ alt: false,
473473+ shift: false,
474474+ meta: false,
475475+ };
476476+477477+ pub const ALT: Self = Self {
478478+ ctrl: false,
479479+ alt: true,
480480+ shift: false,
481481+ meta: false,
482482+ };
483483+484484+ pub const SHIFT: Self = Self {
485485+ ctrl: false,
486486+ alt: false,
487487+ shift: true,
488488+ meta: false,
489489+ };
490490+491491+ pub const META: Self = Self {
492492+ ctrl: false,
493493+ alt: false,
494494+ shift: false,
495495+ meta: true,
496496+ };
497497+498498+ pub const CTRL_SHIFT: Self = Self {
499499+ ctrl: true,
500500+ alt: false,
501501+ shift: true,
502502+ meta: false,
503503+ };
504504+505505+ pub const META_SHIFT: Self = Self {
506506+ ctrl: false,
507507+ alt: false,
508508+ shift: true,
509509+ meta: true,
510510+ };
511511+512512+ /// Get the primary modifier for the platform (Cmd on Mac, Ctrl elsewhere).
513513+ pub fn primary(is_mac: bool) -> Self {
514514+ if is_mac {
515515+ Self::META
516516+ } else {
517517+ Self::CTRL
518518+ }
519519+ }
520520+521521+ /// Get the primary modifier + Shift for the platform.
522522+ pub fn primary_shift(is_mac: bool) -> Self {
523523+ if is_mac {
524524+ Self::META_SHIFT
525525+ } else {
526526+ Self::CTRL_SHIFT
527527+ }
528528+ }
529529+}
530530+531531+/// A key combination for triggering an action.
532532+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
533533+pub struct KeyCombo {
534534+ pub key: Key,
535535+ pub modifiers: Modifiers,
536536+}
537537+538538+impl KeyCombo {
539539+ pub fn new(key: Key) -> Self {
540540+ Self {
541541+ key,
542542+ modifiers: Modifiers::NONE,
543543+ }
544544+ }
545545+546546+ pub fn with_modifiers(key: Key, modifiers: Modifiers) -> Self {
547547+ Self { key, modifiers }
548548+ }
549549+550550+ pub fn ctrl(key: Key) -> Self {
551551+ Self {
552552+ key,
553553+ modifiers: Modifiers::CTRL,
554554+ }
555555+ }
556556+557557+ pub fn meta(key: Key) -> Self {
558558+ Self {
559559+ key,
560560+ modifiers: Modifiers::META,
561561+ }
562562+ }
563563+564564+ pub fn shift(key: Key) -> Self {
565565+ Self {
566566+ key,
567567+ modifiers: Modifiers::SHIFT,
568568+ }
569569+ }
570570+571571+ pub fn primary(key: Key, is_mac: bool) -> Self {
572572+ Self {
573573+ key,
574574+ modifiers: Modifiers::primary(is_mac),
575575+ }
576576+ }
577577+578578+ pub fn primary_shift(key: Key, is_mac: bool) -> Self {
579579+ Self {
580580+ key,
581581+ modifiers: Modifiers::primary_shift(is_mac),
582582+ }
583583+ }
584584+}
585585+586586+/// Result of handling a keydown event.
587587+#[derive(Debug, Clone, PartialEq)]
588588+pub enum KeydownResult {
589589+ /// Event was handled, prevent default.
590590+ Handled,
591591+ /// Event was not a keybinding, let platform handle it.
592592+ NotHandled,
593593+ /// Event should be passed through (navigation, etc.).
594594+ PassThrough,
595595+}
+4-1
crates/weaver-editor-core/src/lib.rs
···1111pub mod document;
1212pub mod offset_map;
1313pub mod paragraph;
1414+pub mod platform;
1415pub mod render;
1516pub mod syntax;
1617pub mod text;
···2829pub use syntax::{SyntaxSpanInfo, SyntaxType, classify_syntax};
2930pub use text::{EditorRope, TextBuffer};
3031pub use types::{
3131- Affinity, CompositionState, CursorState, EditInfo, EditorImage, Selection, BLOCK_SYNTAX_ZONE,
3232+ Affinity, CompositionState, CursorRect, CursorState, EditInfo, EditorImage, Selection,
3333+ SelectionRect, BLOCK_SYNTAX_ZONE,
3234};
3335pub use document::{EditorDocument, PlainEditor};
3436pub use render::{EmbedContentProvider, ImageResolver, WikilinkValidator};
3537pub use undo::{UndoManager, UndoableBuffer};
3638pub use visibility::VisibilityState;
3739pub use writer::{EditorImageResolver, EditorWriter, SegmentedWriter, WriterResult};
4040+pub use platform::{CursorPlatform, CursorSync, PlatformError};
+105
crates/weaver-editor-core/src/platform.rs
···11+//! Platform abstraction traits for editor operations.
22+//!
33+//! These traits define the interface between the editor logic and platform-specific
44+//! implementations (browser DOM, native UI, etc.). This enables the same editor
55+//! logic to work across different platforms.
66+77+use crate::offset_map::SnapDirection;
88+use crate::types::{CursorRect, SelectionRect};
99+use crate::OffsetMapping;
1010+1111+/// Error type for platform operations.
1212+#[derive(Debug, Clone)]
1313+pub struct PlatformError(pub String);
1414+1515+impl std::fmt::Display for PlatformError {
1616+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1717+ write!(f, "{}", self.0)
1818+ }
1919+}
2020+2121+impl std::error::Error for PlatformError {}
2222+2323+impl From<&str> for PlatformError {
2424+ fn from(s: &str) -> Self {
2525+ PlatformError(s.to_string())
2626+ }
2727+}
2828+2929+impl From<String> for PlatformError {
3030+ fn from(s: String) -> Self {
3131+ PlatformError(s)
3232+ }
3333+}
3434+3535+/// Platform-specific cursor and selection operations.
3636+///
3737+/// Implementations handle the actual UI interaction for cursor positioning
3838+/// and selection rendering. The browser implementation uses the DOM Selection API,
3939+/// native implementations would use their respective UI frameworks.
4040+pub trait CursorPlatform {
4141+ /// Restore cursor position in the UI after content changes.
4242+ ///
4343+ /// Given a character offset and the current offset map, positions the cursor
4444+ /// in the rendered content. The snap direction is used when the offset falls
4545+ /// on invisible content (formatting syntax).
4646+ fn restore_cursor(
4747+ &self,
4848+ char_offset: usize,
4949+ offset_map: &[OffsetMapping],
5050+ snap_direction: Option<SnapDirection>,
5151+ ) -> Result<(), PlatformError>;
5252+5353+ /// Get the screen coordinates for a cursor at the given offset.
5454+ ///
5555+ /// Returns None if the offset cannot be mapped to screen coordinates.
5656+ fn get_cursor_rect(
5757+ &self,
5858+ char_offset: usize,
5959+ offset_map: &[OffsetMapping],
6060+ ) -> Option<CursorRect>;
6161+6262+ /// Get screen coordinates relative to the editor container.
6363+ ///
6464+ /// Same as `get_cursor_rect` but coordinates are relative to the editor
6565+ /// element rather than the viewport.
6666+ fn get_cursor_rect_relative(
6767+ &self,
6868+ char_offset: usize,
6969+ offset_map: &[OffsetMapping],
7070+ ) -> Option<CursorRect>;
7171+7272+ /// Get screen rectangles for a selection range.
7373+ ///
7474+ /// Returns multiple rects if the selection spans multiple lines.
7575+ /// Coordinates are relative to the editor container.
7676+ fn get_selection_rects_relative(
7777+ &self,
7878+ start: usize,
7979+ end: usize,
8080+ offset_map: &[OffsetMapping],
8181+ ) -> Vec<SelectionRect>;
8282+}
8383+8484+/// Platform-specific cursor state synchronization.
8585+///
8686+/// Handles reading the current cursor/selection state from the platform UI
8787+/// back into the editor model. This is the inverse of `CursorPlatform`.
8888+pub trait CursorSync {
8989+ /// Sync cursor state from the platform UI into the provided callbacks.
9090+ ///
9191+ /// The implementation reads the current selection from the UI and calls
9292+ /// the appropriate callback with the character offset(s).
9393+ ///
9494+ /// - For a collapsed cursor: calls `on_cursor(offset)`
9595+ /// - For a selection: calls `on_selection(anchor, head)`
9696+ fn sync_cursor_from_platform<F, G>(
9797+ &self,
9898+ offset_map: &[OffsetMapping],
9999+ direction_hint: Option<SnapDirection>,
100100+ on_cursor: F,
101101+ on_selection: G,
102102+ ) where
103103+ F: FnOnce(usize),
104104+ G: FnOnce(usize, usize);
105105+}
+45
crates/weaver-editor-core/src/types.rs
···223223/// Covers: `######` (6), ```` ``` ```` (3), `> ` (2), `- ` (2), `999. ` (5)
224224pub const BLOCK_SYNTAX_ZONE: usize = 6;
225225226226+// === Platform-agnostic geometry types ===
227227+228228+/// Screen coordinates for a cursor position.
229229+///
230230+/// Represents the bounding box of a cursor caret in screen space.
231231+/// Platform implementations fill this from their native APIs.
232232+#[derive(Debug, Clone, Copy, PartialEq)]
233233+pub struct CursorRect {
234234+ /// X coordinate (pixels from left).
235235+ pub x: f64,
236236+ /// Y coordinate (pixels from top).
237237+ pub y: f64,
238238+ /// Height of the cursor line (pixels).
239239+ pub height: f64,
240240+}
241241+242242+impl CursorRect {
243243+ /// Create a new cursor rect.
244244+ pub fn new(x: f64, y: f64, height: f64) -> Self {
245245+ Self { x, y, height }
246246+ }
247247+}
248248+249249+/// Screen rectangle for part of a selection.
250250+///
251251+/// A selection spanning multiple lines produces multiple rects (one per line).
252252+#[derive(Debug, Clone, Copy, PartialEq)]
253253+pub struct SelectionRect {
254254+ /// X coordinate (pixels from left).
255255+ pub x: f64,
256256+ /// Y coordinate (pixels from top).
257257+ pub y: f64,
258258+ /// Width of this selection segment (pixels).
259259+ pub width: f64,
260260+ /// Height of this selection segment (pixels).
261261+ pub height: f64,
262262+}
263263+264264+impl SelectionRect {
265265+ /// Create a new selection rect.
266266+ pub fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
267267+ Self { x, y, width, height }
268268+ }
269269+}
270270+226271#[cfg(test)]
227272mod tests {
228273 use super::*;