browser crate, various other stuff

Orual 9b12c555 b01b0d49

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

This is a binary file and will not be displayed.

+1
docs/git-history.json
···
··· 1 + []
+2723
docs/graph-data.json
···
··· 1 + { 2 + "nodes": [ 3 + { 4 + "id": 1, 5 + "change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", 6 + "node_type": "goal", 7 + "title": "Build weaver.sh - decentralized notebook platform on AT Protocol", 8 + "description": null, 9 + "status": "pending", 10 + "created_at": "2026-01-06T09:30:44.744866776-05:00", 11 + "updated_at": "2026-01-06T09:30:44.744866776-05:00", 12 + "metadata_json": "{\"confidence\":95,\"prompt\":\"Dissatisfaction with existing long-form writing platforms, especially on ATProto at the time. Inspired by weaver birds building homes, and by notebooks (physical and virtual) used for ideation, documentation, and information. Vision: elegant writing platform with collaborative editing and immediate publishing. Ultimate goal: professional-grade platform for writers/journalists, open alternative to Substack, with reader support mechanisms, all on AT Protocol.\"}" 13 + }, 14 + { 15 + "id": 2, 16 + "change_id": "850c82f8-e6b6-4b8b-bd70-e51bcf43d0a5", 17 + "node_type": "goal", 18 + "title": "Build jacquard - better AT Protocol library for Rust", 19 + "description": null, 20 + "status": "pending", 21 + "created_at": "2026-01-06T09:30:53.847514686-05:00", 22 + "updated_at": "2026-01-06T09:30:53.847514686-05:00", 23 + "metadata_json": "{\"confidence\":95,\"prompt\":\"Frustrated with atrium library - significant boilerplate, poor ergonomics, maintenance concerns. Wanted something better, figured others did too. Built jacquard with zero-copy patterns, better type system, built-in OAuth, cleaner APIs. Developed largely for weaver but designed to benefit the broader Rust/ATProto ecosystem.\"}" 24 + }, 25 + { 26 + "id": 3, 27 + "change_id": "58113fdb-140c-4844-844a-b51eee292c58", 28 + "node_type": "decision", 29 + "title": "Choose frontend technology for weaver-app", 30 + "description": null, 31 + "status": "pending", 32 + "created_at": "2026-01-06T09:30:59.214254222-05:00", 33 + "updated_at": "2026-01-06T09:30:59.214254222-05:00", 34 + "metadata_json": "{\"confidence\":90}" 35 + }, 36 + { 37 + "id": 4, 38 + "change_id": "3c1e5278-cb0d-4f8b-9605-89225dfe66e2", 39 + "node_type": "option", 40 + "title": "Dioxus (Rust → WASM)", 41 + "description": null, 42 + "status": "pending", 43 + "created_at": "2026-01-06T09:30:59.356059174-05:00", 44 + "updated_at": "2026-01-06T09:30:59.356059174-05:00", 45 + "metadata_json": "{\"confidence\":95}" 46 + }, 47 + { 48 + "id": 5, 49 + "change_id": "19e5dad0-b0d8-4eba-8af1-028430ddc69c", 50 + "node_type": "option", 51 + "title": "JavaScript framework (React/Vue/Svelte)", 52 + "description": null, 53 + "status": "pending", 54 + "created_at": "2026-01-06T09:30:59.459096788-05:00", 55 + "updated_at": "2026-01-06T09:30:59.459096788-05:00", 56 + "metadata_json": "{\"confidence\":30}" 57 + }, 58 + { 59 + "id": 6, 60 + "change_id": "ef3edcf6-e8a8-4d48-9e9e-3885abb53a30", 61 + "node_type": "decision", 62 + "title": "Build custom markdown editor vs use existing library", 63 + "description": null, 64 + "status": "pending", 65 + "created_at": "2026-01-06T09:31:11.394522567-05:00", 66 + "updated_at": "2026-01-06T09:31:11.394522567-05:00", 67 + "metadata_json": "{\"confidence\":95}" 68 + }, 69 + { 70 + "id": 7, 71 + "change_id": "5688af7d-61e5-4b47-afaa-05ee5bff36ae", 72 + "node_type": "option", 73 + "title": "Build custom Rust editor from scratch", 74 + "description": null, 75 + "status": "pending", 76 + "created_at": "2026-01-06T09:31:11.508395907-05:00", 77 + "updated_at": "2026-01-06T09:31:11.508395907-05:00", 78 + "metadata_json": "{\"confidence\":95,\"prompt\":\"Chosen because: 1) No web-based rich text editor existed in Rust. 2) Wanted to use custom markdown dialect with additional features (weaver-flavoured markdown forked from pulldown-cmark, porting features from Obsidian-flavoured markdown with further additions). 3) pulldown-cmark's Rust optimization is significantly faster than JS parsers. 4) Wanted documents editable with any text editor (like Obsidian vault files), not block-based formats. 5) Extending JS libs (ProseMirror/Lexical) for custom markdown wasn't appealing.\"}" 79 + }, 80 + { 81 + "id": 8, 82 + "change_id": "d28249c5-955c-45ad-bbff-d76f437055c3", 83 + "node_type": "option", 84 + "title": "Use existing JS editor (ProseMirror/CodeMirror/Lexical)", 85 + "description": null, 86 + "status": "pending", 87 + "created_at": "2026-01-06T09:31:11.619726672-05:00", 88 + "updated_at": "2026-01-06T09:31:11.619726672-05:00", 89 + "metadata_json": "{\"confidence\":20}" 90 + }, 91 + { 92 + "id": 9, 93 + "change_id": "301c77f5-27ce-422d-8be7-eec136c3c856", 94 + "node_type": "decision", 95 + "title": "Editor text storage model", 96 + "description": null, 97 + "status": "pending", 98 + "created_at": "2026-01-06T09:31:18.146739807-05:00", 99 + "updated_at": "2026-01-06T09:31:18.146739807-05:00", 100 + "metadata_json": "{\"confidence\":90}" 101 + }, 102 + { 103 + "id": 10, 104 + "change_id": "5738a9b1-2767-4aab-ad59-b7fe9ee6728e", 105 + "node_type": "option", 106 + "title": "JumpRope (initial choice)", 107 + "description": null, 108 + "status": "pending", 109 + "created_at": "2026-01-06T09:31:18.255664070-05:00", 110 + "updated_at": "2026-01-06T09:31:18.255664070-05:00", 111 + "metadata_json": "{\"confidence\":70}" 112 + }, 113 + { 114 + "id": 11, 115 + "change_id": "d7d9d021-6d36-48ed-a7c1-f7b0a7576c2e", 116 + "node_type": "option", 117 + "title": "Ropey (current/planned for core)", 118 + "description": null, 119 + "status": "pending", 120 + "created_at": "2026-01-06T09:31:18.374214999-05:00", 121 + "updated_at": "2026-01-06T09:31:18.374214999-05:00", 122 + "metadata_json": "{\"confidence\":85}" 123 + }, 124 + { 125 + "id": 12, 126 + "change_id": "aef81c95-7a98-4dc6-bdc8-ab1df9823db8", 127 + "node_type": "option", 128 + "title": "Loro (for CRDT/collab)", 129 + "description": null, 130 + "status": "pending", 131 + "created_at": "2026-01-06T09:31:18.479773207-05:00", 132 + "updated_at": "2026-01-06T09:31:18.479773207-05:00", 133 + "metadata_json": "{\"confidence\":90}" 134 + }, 135 + { 136 + "id": 13, 137 + "change_id": "cf8c5176-4419-47fd-a7d6-f59ee8806b6f", 138 + "node_type": "decision", 139 + "title": "Editor input handling model", 140 + "description": null, 141 + "status": "pending", 142 + "created_at": "2026-01-06T09:31:28.658538075-05:00", 143 + "updated_at": "2026-01-06T09:31:28.658538075-05:00", 144 + "metadata_json": "{\"confidence\":90}" 145 + }, 146 + { 147 + "id": 14, 148 + "change_id": "290a2bda-6f84-4983-bda6-1870dfea8401", 149 + "node_type": "option", 150 + "title": "beforeinput event interception (ProseMirror-style)", 151 + "description": null, 152 + "status": "pending", 153 + "created_at": "2026-01-06T09:31:28.789196438-05:00", 154 + "updated_at": "2026-01-06T09:31:28.789196438-05:00", 155 + "metadata_json": "{\"confidence\":95,\"prompt\":\"Chosen approach: Use contenteditable only for rendering and cursor display. Intercept all edit operations via beforeinput, modify source rope directly, re-render. Single source of truth (the rope), full control over all editing operations, cursor restoration is well-defined. More upfront code but no ongoing sync bugs.\"}" 156 + }, 157 + { 158 + "id": 15, 159 + "change_id": "374172ea-9738-4f5e-a176-1b5e8bd21568", 160 + "node_type": "option", 161 + "title": "Hidden textarea + contenteditable overlay", 162 + "description": null, 163 + "status": "pending", 164 + "created_at": "2026-01-06T09:31:28.906825132-05:00", 165 + "updated_at": "2026-01-06T09:31:28.906825132-05:00", 166 + "metadata_json": "{\"confidence\":20}" 167 + }, 168 + { 169 + "id": 16, 170 + "change_id": "bac8484a-5eb5-42bf-9387-55d90fe486cd", 171 + "node_type": "decision", 172 + "title": "weaver-index backend architecture", 173 + "description": null, 174 + "status": "pending", 175 + "created_at": "2026-01-06T09:31:39.080366535-05:00", 176 + "updated_at": "2026-01-06T09:31:39.080366535-05:00", 177 + "metadata_json": "{\"confidence\":90}" 178 + }, 179 + { 180 + "id": 17, 181 + "change_id": "af0827fd-530e-498c-88ad-082a648a93f4", 182 + "node_type": "option", 183 + "title": "Dual-backend: ClickHouse + SQLite shards", 184 + "description": null, 185 + "status": "pending", 186 + "created_at": "2026-01-06T09:31:39.206228969-05:00", 187 + "updated_at": "2026-01-06T09:31:39.206228969-05:00", 188 + "metadata_json": "{\"confidence\":95,\"prompt\":\"ClickHouse for network-wide data (all records, aggregates, search, discovery). SQLite shards for hot-tier latency-sensitive operations (edit/collab state, sharded by resource URI). Router uses NSID prefix matching to direct records. Materialized views maintain denormalized read tables. Incremental MVs for counts, refreshable MVs for trending scores.\"}" 189 + }, 190 + { 191 + "id": 18, 192 + "change_id": "fa554b5d-8af7-42e4-b03f-e5bec837e31a", 193 + "node_type": "goal", 194 + "title": "Extract editor for external embedding", 195 + "description": null, 196 + "status": "pending", 197 + "created_at": "2026-01-06T09:31:48.503441901-05:00", 198 + "updated_at": "2026-01-06T09:31:48.503441901-05:00", 199 + "metadata_json": "{\"confidence\":90,\"prompt\":\"Extract the weaver markdown editor into a standalone, embeddable package. Target consumers: external apps (MTG deckbuilder, etc.) via JS/WASM, weaver-app itself (dogfooding, potential framework migration), future native apps via Rust crate. Host app controls auth, blob uploads, collab transport, publishing. Clean crate boundary: core (pure Rust, no web_sys/dioxus/loro), crdt (optional Loro), browser (web_sys DOM layer), js (thin wrapper).\"}" 200 + }, 201 + { 202 + "id": 19, 203 + "change_id": "291fe7f0-b962-4663-b7d9-701dd38de1b5", 204 + "node_type": "observation", 205 + "title": "Obsidian-style editing UX is key requirement", 206 + "description": null, 207 + "status": "pending", 208 + "created_at": "2026-01-06T09:31:57.676750492-05:00", 209 + "updated_at": "2026-01-06T09:31:57.676750492-05:00", 210 + "metadata_json": "{\"confidence\":95,\"prompt\":\"Hide formatting characters contextually based on cursor position. Documents should be plain markdown text files, editable with any text editor. Not block-based editing like Notion/Tiptap - documents are files, not proprietary formats. This drove the decision to stick with markdown rather than rich-text block formats that Loro/Yjs natively support.\"}" 211 + }, 212 + { 213 + "id": 20, 214 + "change_id": "8b418708-6db5-42ab-8153-b2dc64f13065", 215 + "node_type": "observation", 216 + "title": "weaver-flavoured markdown extends pulldown-cmark", 217 + "description": null, 218 + "status": "pending", 219 + "created_at": "2026-01-06T09:31:57.795128457-05:00", 220 + "updated_at": "2026-01-06T09:31:57.795128457-05:00", 221 + "metadata_json": "{\"confidence\":90,\"prompt\":\"Custom markdown dialect with additional features, forked from pulldown-cmark. Ports features from Obsidian-flavoured markdown plus further additions. Includes wiki links, embeds, math, sidenotes. The fork lives in a separate repo (markdown-weaver). This customization is a key differentiator and why JS parsers weren't suitable.\"}" 222 + }, 223 + { 224 + "id": 21, 225 + "change_id": "e0a70314-85a3-4561-9348-fce0a887d7f9", 226 + "node_type": "outcome", 227 + "title": "Jacquard migration completed - cleaner codebase, better DX", 228 + "description": null, 229 + "status": "pending", 230 + "created_at": "2026-01-06T09:36:43.586079216-05:00", 231 + "updated_at": "2026-01-06T09:36:43.586079216-05:00", 232 + "metadata_json": "{\"confidence\":95}" 233 + }, 234 + { 235 + "id": 22, 236 + "change_id": "40dd01d3-768c-4b31-8baf-7d027079d73f", 237 + "node_type": "outcome", 238 + "title": "weaver-app live at alpha.weaver.sh", 239 + "description": null, 240 + "status": "pending", 241 + "created_at": "2026-01-06T09:36:43.700890598-05:00", 242 + "updated_at": "2026-01-06T09:36:43.700890598-05:00", 243 + "metadata_json": "{\"confidence\":95}" 244 + }, 245 + { 246 + "id": 23, 247 + "change_id": "708898ff-a587-4cda-9a2e-9376ce22f480", 248 + "node_type": "outcome", 249 + "title": "weaver-index live at index.weaver.sh", 250 + "description": null, 251 + "status": "pending", 252 + "created_at": "2026-01-06T09:36:43.805008490-05:00", 253 + "updated_at": "2026-01-06T09:36:43.805008490-05:00", 254 + "metadata_json": "{\"confidence\":95}" 255 + }, 256 + { 257 + "id": 24, 258 + "change_id": "6b74f268-78fb-42ba-bb09-59e82496ac21", 259 + "node_type": "outcome", 260 + "title": "Custom editor working - Obsidian-style markdown editing functional", 261 + "description": null, 262 + "status": "pending", 263 + "created_at": "2026-01-06T09:36:43.912905400-05:00", 264 + "updated_at": "2026-01-06T09:36:43.912905400-05:00", 265 + "metadata_json": "{\"confidence\":90}" 266 + }, 267 + { 268 + "id": 25, 269 + "change_id": "38fa6c2f-2a36-40d4-97ec-4e029501253b", 270 + "node_type": "outcome", 271 + "title": "CRDT collab via Loro operational", 272 + "description": null, 273 + "status": "pending", 274 + "created_at": "2026-01-06T09:36:44.009269510-05:00", 275 + "updated_at": "2026-01-06T09:36:44.009269510-05:00", 276 + "metadata_json": "{\"confidence\":85}" 277 + }, 278 + { 279 + "id": 26, 280 + "change_id": "28d1b14d-5cb4-490e-87a2-db52aa233342", 281 + "node_type": "decision", 282 + "title": "AT Protocol authentication approach", 283 + "description": null, 284 + "status": "pending", 285 + "created_at": "2026-01-06T09:37:19.161495320-05:00", 286 + "updated_at": "2026-01-06T09:37:19.161495320-05:00", 287 + "metadata_json": "{\"confidence\":90}" 288 + }, 289 + { 290 + "id": 27, 291 + "change_id": "00233162-061d-40bc-86c7-d74fddcd2ac4", 292 + "node_type": "option", 293 + "title": "Public web client (PKCE, no secret)", 294 + "description": null, 295 + "status": "pending", 296 + "created_at": "2026-01-06T09:37:19.272219096-05:00", 297 + "updated_at": "2026-01-06T09:37:19.272219096-05:00", 298 + "metadata_json": "{\"confidence\":90,\"prompt\":\"Current implementation. Auth method: none (public client). PKCE flow for security. Client-side state in browser localStorage/sessionStorage. BrowserAuthStore implements ClientAuthStore. No JWKs required. Works for both dev (localhost loopback) and production modes.\"}" 299 + }, 300 + { 301 + "id": 28, 302 + "change_id": "1a4d6505-b985-40db-9055-b42489331367", 303 + "node_type": "option", 304 + "title": "Confidential client (server-side OAuth)", 305 + "description": null, 306 + "status": "pending", 307 + "created_at": "2026-01-06T09:37:19.389665396-05:00", 308 + "updated_at": "2026-01-06T09:37:19.389665396-05:00", 309 + "metadata_json": "{\"confidence\":60,\"prompt\":\"Future enhancement. Server-side OAuth with JWKs. Would enable more secure flows, refresh token rotation. Planned but not implemented.\"}" 310 + }, 311 + { 312 + "id": 29, 313 + "change_id": "e7f730f7-598a-44c9-9d86-22c86653a65e", 314 + "node_type": "goal", 315 + "title": "Subdomain hosting for notebooks", 316 + "description": null, 317 + "status": "pending", 318 + "created_at": "2026-01-06T09:37:28.977819113-05:00", 319 + "updated_at": "2026-01-06T09:37:28.977819113-05:00", 320 + "metadata_json": "{\"confidence\":70,\"prompt\":\"Design exploration (not yet implemented). Allow notebooks at custom subdomains (e.g., mynotebook.weaver.sh) in addition to path-based routing. Clean URLs for published notebooks. Opens door to custom domains later. Selected approach: single app with host context injection, not separate apps or path rewriting.\"}" 321 + }, 322 + { 323 + "id": 30, 324 + "change_id": "2eeb7345-a8a2-4146-bda5-1f19cfc7501d", 325 + "node_type": "goal", 326 + "title": "JS bindings for weaver-renderer", 327 + "description": null, 328 + "status": "pending", 329 + "created_at": "2026-01-06T09:37:29.103458407-05:00", 330 + "updated_at": "2026-01-06T09:37:29.103458407-05:00", 331 + "metadata_json": "{\"confidence\":70,\"prompt\":\"Design exploration. WASM bindings for weaver-renderer so external JS apps can render weaver content without reimplementing. Package: weaver-renderer-js. No network calls in WASM - JS handles fetches. Good TS types via tsify. Enables embedding weaver content in other atproto apps (e.g., MTG deckbuilder). Related to editor extraction - both expose functionality to JS consumers.\"}" 332 + }, 333 + { 334 + "id": 31, 335 + "change_id": "6d66ca53-5105-4078-bf7e-5f9a0e503012", 336 + "node_type": "decision", 337 + "title": "Editor text buffer migration", 338 + "description": null, 339 + "status": "pending", 340 + "created_at": "2026-01-06T09:38:17.298013844-05:00", 341 + "updated_at": "2026-01-06T09:38:17.298013844-05:00", 342 + "metadata_json": "{\"confidence\":95}" 343 + }, 344 + { 345 + "id": 32, 346 + "change_id": "60829366-0740-47cc-9101-4057c9c7489e", 347 + "node_type": "action", 348 + "title": "Migrated from JumpRope to Loro CRDT", 349 + "description": null, 350 + "status": "pending", 351 + "created_at": "2026-01-06T09:38:17.423583741-05:00", 352 + "updated_at": "2026-01-06T09:38:17.423583741-05:00", 353 + "metadata_json": "{\"confidence\":95,\"prompt\":\"Nov 2025. Replaced JumpRopeBuf with Loro for undo/redo + future collaboration. Key insight: use detached LoroText per paragraph for UTF-16 conversion (chars_to_wchars replacement). OffsetTracker for incremental UTF-16 tracking during render. Bundle size acceptable (~970KB). UndoManager with 300ms merge interval, 100 max steps. Limitation: undo is session-only, can't rebuild history from imported CRDT ops.\"}" 354 + }, 355 + { 356 + "id": 33, 357 + "change_id": "98e78259-5b13-413f-af77-bcb7354aca77", 358 + "node_type": "action", 359 + "title": "Implemented conditional syntax hiding", 360 + "description": null, 361 + "status": "pending", 362 + "created_at": "2026-01-06T09:38:17.551284271-05:00", 363 + "updated_at": "2026-01-06T09:38:17.551284271-05:00", 364 + "metadata_json": "{\"confidence\":90,\"prompt\":\"Obsidian-style: syntax markers hidden when cursor not near, revealed on approach. CSS-based (not re-render) - offset maps unchanged since TreeWalker counts all text nodes including display:none. Each syntax span gets data-syn-id, data-char-start, data-char-end. VisibilityState calculates which spans visible. Inline: show if cursor within 1 char of boundaries. Block: show if cursor anywhere in same paragraph.\"}" 365 + }, 366 + { 367 + "id": 34, 368 + "change_id": "ddc3ed83-2f0f-4c53-8e9f-44076bbf3aac", 369 + "node_type": "action", 370 + "title": "Implemented IME composition support", 371 + "description": null, 372 + "status": "pending", 373 + "created_at": "2026-01-06T09:38:17.681764618-05:00", 374 + "updated_at": "2026-01-06T09:38:17.681764618-05:00", 375 + "metadata_json": "{\"confidence\":90,\"prompt\":\"CJK input, dead keys, emoji pickers. Let browser handle composition preview (underlined text, candidate window). Suppress DOM updates during composition. Block keydown during composition (except Escape). Single insert on compositionend. Selection deleted on compositionstart. Blocked from oninput approach because Dioxus FormData doesn't expose InputEvent's input_type/data/is_composing.\"}" 376 + }, 377 + { 378 + "id": 35, 379 + "change_id": "874d2fd8-29bf-4137-a381-c9c14595da62", 380 + "node_type": "observation", 381 + "title": "sh.weaver lexicon namespace design", 382 + "description": null, 383 + "status": "pending", 384 + "created_at": "2026-01-06T09:38:56.082354182-05:00", 385 + "updated_at": "2026-01-06T09:38:56.082354182-05:00", 386 + "metadata_json": "{\"confidence\":90,\"prompt\":\"Custom AT Protocol lexicons under sh.weaver namespace. Key record types: notebook.entry (markdown content, title, path, tags, authors, embeds), notebook.book (collection), edit.root (CRDT snapshot starting point), edit.diff (incremental edits), collab.session (real-time session with iroh nodeId), collab.invite/accept (collaboration permissions), graph.* (likes, bookmarks, follows, tags). updatedAt for canonicality tiebreaking in multi-author scenarios.\"}" 387 + }, 388 + { 389 + "id": 36, 390 + "change_id": "1f756beb-3275-4cb0-a1f4-df805cbc3a20", 391 + "node_type": "decision", 392 + "title": "Collaborative editing transport", 393 + "description": null, 394 + "status": "pending", 395 + "created_at": "2026-01-06T09:38:56.197277628-05:00", 396 + "updated_at": "2026-01-06T09:38:56.197277628-05:00", 397 + "metadata_json": "{\"confidence\":85}" 398 + }, 399 + { 400 + "id": 37, 401 + "change_id": "aa268350-e43f-4b8f-8d72-2234b1af4940", 402 + "node_type": "option", 403 + "title": "iroh P2P with DERP relay fallback", 404 + "description": null, 405 + "status": "pending", 406 + "created_at": "2026-01-06T09:38:56.311296491-05:00", 407 + "updated_at": "2026-01-06T09:38:56.311296491-05:00", 408 + "metadata_json": "{\"confidence\":85,\"prompt\":\"collab.session record contains iroh NodeId (z-base32) for direct P2P and optional relayUrl for browser clients. Gossip-based CRDT sync. Session TTL with refresh. Session record published on join, deleted on disconnect. Enables P2P without central server, falls back to relay when direct connection impossible.\"}" 409 + }, 410 + { 411 + "id": 38, 412 + "change_id": "68006be2-14d6-4666-b914-16c750ebd7d5", 413 + "node_type": "action", 414 + "title": "Implemented static site theming system", 415 + "description": null, 416 + "status": "pending", 417 + "created_at": "2026-01-06T09:43:18.417607073-05:00", 418 + "updated_at": "2026-01-06T09:43:18.417607073-05:00", 419 + "metadata_json": "{\"confidence\":95,\"prompt\":\"October 2025: Added Theme struct with colors/fonts/spacing, CSS generation, syntax highlighting via syntect, full HTML5 document output\"}" 420 + }, 421 + { 422 + "id": 39, 423 + "change_id": "2481faa6-87fb-4c6b-acc9-10d03b568d02", 424 + "node_type": "action", 425 + "title": "Implemented CLI static site integration", 426 + "description": null, 427 + "status": "pending", 428 + "created_at": "2026-01-06T09:43:18.554408150-05:00", 429 + "updated_at": "2026-01-06T09:43:18.554408150-05:00", 430 + "metadata_json": "{\"confidence\":95,\"prompt\":\"October 2025: Added weaver <source> <dest> command, optional auth loading, graceful degradation without network\"}" 431 + }, 432 + { 433 + "id": 40, 434 + "change_id": "7d31d924-719a-4de6-af48-10d99c603d7b", 435 + "node_type": "action", 436 + "title": "Added static site renderer tests", 437 + "description": null, 438 + "status": "pending", 439 + "created_at": "2026-01-06T09:43:18.693278827-05:00", 440 + "updated_at": "2026-01-06T09:43:18.693278827-05:00", 441 + "metadata_json": "{\"confidence\":90,\"prompt\":\"October 2025: Made reqwest client optional in StaticSiteContext for testability, added snapshot tests via insta\"}" 442 + }, 443 + { 444 + "id": 41, 445 + "change_id": "fd29722c-500b-46ff-924c-bee82d766224", 446 + "node_type": "decision", 447 + "title": "Two-stage AT Protocol rendering pipeline", 448 + "description": null, 449 + "status": "pending", 450 + "created_at": "2026-01-06T09:43:35.760739284-05:00", 451 + "updated_at": "2026-01-06T09:43:35.760739284-05:00", 452 + "metadata_json": "{\"confidence\":95}" 453 + }, 454 + { 455 + "id": 42, 456 + "change_id": "db80f5b5-a719-4782-b46e-8c9affc75a9d", 457 + "node_type": "option", 458 + "title": "Stage 1: Markdown preprocessing (CLI)", 459 + "description": null, 460 + "status": "pending", 461 + "created_at": "2026-01-06T09:43:35.869917830-05:00", 462 + "updated_at": "2026-01-06T09:43:35.869917830-05:00", 463 + "metadata_json": "{\"confidence\":95}" 464 + }, 465 + { 466 + "id": 43, 467 + "change_id": "f3bb7c28-9610-4460-9e4f-b78c9670fe09", 468 + "node_type": "option", 469 + "title": "Stage 2: Client-side rendering (WASM)", 470 + "description": null, 471 + "status": "pending", 472 + "created_at": "2026-01-06T09:43:35.977386900-05:00", 473 + "updated_at": "2026-01-06T09:43:35.977386900-05:00", 474 + "metadata_json": "{\"confidence\":95}" 475 + }, 476 + { 477 + "id": 44, 478 + "change_id": "947d69f2-7be9-403d-9f24-75d835196c37", 479 + "node_type": "action", 480 + "title": "Implemented browser OAuth with BrowserAuthStore", 481 + "description": null, 482 + "status": "pending", 483 + "created_at": "2026-01-06T09:44:01.896313820-05:00", 484 + "updated_at": "2026-01-06T09:44:01.896313820-05:00", 485 + "metadata_json": "{\"confidence\":95,\"prompt\":\"Nov 2025: PKCE flow, localStorage+sessionStorage, Signal-based client swapping in CachedFetcher\"}" 486 + }, 487 + { 488 + "id": 45, 489 + "change_id": "6562e367-8b54-4898-93e7-ca86c49c360d", 490 + "node_type": "decision", 491 + "title": "Record editing interface approach", 492 + "description": null, 493 + "status": "pending", 494 + "created_at": "2026-01-06T09:44:02.121665204-05:00", 495 + "updated_at": "2026-01-06T09:44:02.121665204-05:00", 496 + "metadata_json": "{\"confidence\":90}" 497 + }, 498 + { 499 + "id": 46, 500 + "change_id": "f5c8549c-5baf-46f8-a070-3af48a464a30", 501 + "node_type": "option", 502 + "title": "Two editing modes: JSON Editor + Pretty Editor", 503 + "description": null, 504 + "status": "pending", 505 + "created_at": "2026-01-06T09:44:02.234281881-05:00", 506 + "updated_at": "2026-01-06T09:44:02.234281881-05:00", 507 + "metadata_json": "{\"confidence\":90}" 508 + }, 509 + { 510 + "id": 47, 511 + "change_id": "a1ebe765-1b1d-4b48-bb57-0a0d94d89414", 512 + "node_type": "observation", 513 + "title": "Root Signal pattern for editing nested Data", 514 + "description": null, 515 + "status": "pending", 516 + "created_at": "2026-01-06T09:44:24.994680229-05:00", 517 + "updated_at": "2026-01-06T09:44:24.994680229-05:00", 518 + "metadata_json": "{\"confidence\":90,\"prompt\":\"Pretty Editor architecture uses single root signal with path-based traversal for editing AT Protocol records. All nested components receive root signal + path string.\"}" 519 + }, 520 + { 521 + "id": 48, 522 + "change_id": "d7ece9b4-c1b0-49c3-9dad-0ed8c2e97042", 523 + "node_type": "action", 524 + "title": "Implemented editor integration with route structure", 525 + "description": null, 526 + "status": "pending", 527 + "created_at": "2026-01-06T09:44:25.280737533-05:00", 528 + "updated_at": "2026-01-06T09:44:25.280737533-05:00", 529 + "metadata_json": "{\"confidence\":90,\"prompt\":\"Nov 2025: Added /e/, /drafts, /new, /edit routes. Draft system with local-only vs server-synced states. Profile page actions.\"}" 530 + }, 531 + { 532 + "id": 49, 533 + "change_id": "e8c967ae-7aaf-4a09-b5e0-0eebe0f75da2", 534 + "node_type": "action", 535 + "title": "Draft system fixes - entry context, notebook URI, deletion, auth", 536 + "description": null, 537 + "status": "pending", 538 + "created_at": "2026-01-06T09:44:40.165533396-05:00", 539 + "updated_at": "2026-01-06T09:44:40.165533396-05:00", 540 + "metadata_json": "{\"confidence\":90,\"prompt\":\"Dec 2025: Fixed 4 issues - entry context lost on PDS-only loads, notebook duplication on publish, incomplete draft deletion, missing auth on listDrafts\"}" 541 + }, 542 + { 543 + "id": 50, 544 + "change_id": "49e83f50-4296-433d-b27e-2479353eb214", 545 + "node_type": "action", 546 + "title": "Editor render pipeline redesign", 547 + "description": null, 548 + "status": "pending", 549 + "created_at": "2026-01-06T09:44:57.442056469-05:00", 550 + "updated_at": "2026-01-06T09:44:57.442056469-05:00", 551 + "metadata_json": "{\"confidence\":90,\"prompt\":\"Dec 2025: Fixed cursor/end-of-doc, spaces not typed, paragraph boundary bugs. Single full render pass, content-based IDs (p-{hash}-{idx}), pool-based DOM diffing.\"}" 552 + }, 553 + { 554 + "id": 51, 555 + "change_id": "3862b5d9-17ec-4a8c-9eb3-e70270ef2bbc", 556 + "node_type": "observation", 557 + "title": "Editor cursor bugs - orphan spans, offset map gaps", 558 + "description": null, 559 + "status": "pending", 560 + "created_at": "2026-01-06T09:44:57.561311252-05:00", 561 + "updated_at": "2026-01-06T09:44:57.561311252-05:00", 562 + "metadata_json": "{\"confidence\":85,\"prompt\":\"Dec 2025: Safari/Chrome specific. emit_syntax creating orphan spans before paragraphs, ZWC cleanup issues, offset map not covering document end.\"}" 563 + }, 564 + { 565 + "id": 52, 566 + "change_id": "befcef0e-c831-4d2d-8454-499b69715dac", 567 + "node_type": "goal", 568 + "title": "WCAG 2.2 Level AA accessibility compliance", 569 + "description": null, 570 + "status": "pending", 571 + "created_at": "2026-01-06T09:45:23.468651607-05:00", 572 + "updated_at": "2026-01-06T09:45:23.468651607-05:00", 573 + "metadata_json": "{\"confidence\":75,\"prompt\":\"Dec 2025: Editor lacks most accessibility features. ATAG 2.0 Part A (accessible UI) and Part B (help create accessible content) apply. EAA compliance increasingly important.\"}" 574 + }, 575 + { 576 + "id": 53, 577 + "change_id": "32129a65-4d74-4aba-ae82-e16b98552eda", 578 + "node_type": "observation", 579 + "title": "Current accessibility gaps - contenteditable role, toolbar navigation, focus management", 580 + "description": null, 581 + "status": "pending", 582 + "created_at": "2026-01-06T09:45:23.585309375-05:00", 583 + "updated_at": "2026-01-06T09:45:23.585309375-05:00", 584 + "metadata_json": "{\"confidence\":85,\"prompt\":\"Missing: role=textbox on editor, aria-multiline, roving tabindex in toolbar, focus trap in dialogs, live regions for status updates\"}" 585 + }, 586 + { 587 + "id": 54, 588 + "change_id": "d27b41f2-3146-4493-9216-d33e36177b7f", 589 + "node_type": "action", 590 + "title": "Offset iterator migration for sidenote gap detection", 591 + "description": null, 592 + "status": "pending", 593 + "created_at": "2026-01-06T09:45:31.238356621-05:00", 594 + "updated_at": "2026-01-06T09:45:31.238356621-05:00", 595 + "metadata_json": "{\"confidence\":85,\"prompt\":\"Dec 2025: Switching static_site and atproto writers to offset_iter() tuples. Enables byte-offset gap detection for sidenote paragraph handling (gap <= 2 bytes = continue, > 2 = new paragraph)\"}" 596 + }, 597 + { 598 + "id": 55, 599 + "change_id": "77a50102-1dc0-4009-9715-5c8644745be1", 600 + "node_type": "action", 601 + "title": "Collab worker design - iroh P2P into dedicated web worker", 602 + "description": null, 603 + "status": "pending", 604 + "created_at": "2026-01-06T09:45:48.850879773-05:00", 605 + "updated_at": "2026-01-06T09:45:48.850879773-05:00", 606 + "metadata_json": "{\"confidence\":85,\"prompt\":\"Jan 2025: Move iroh/gossip/loro to web worker. Reactor pattern via gloo-worker. Main thread handles auth'd PDS ops. Reduces main binary size.\"}" 607 + }, 608 + { 609 + "id": 56, 610 + "change_id": "128ce435-efb2-4608-82a7-771702bbc151", 611 + "node_type": "action", 612 + "title": "LoroBuffer type implementing TextBuffer + UndoManager traits", 613 + "description": null, 614 + "status": "pending", 615 + "created_at": "2026-01-06T09:46:07.761169828-05:00", 616 + "updated_at": "2026-01-06T09:46:07.761169828-05:00", 617 + "metadata_json": "{\"confidence\":90,\"prompt\":\"Jan 2026: Bridge between weaver-editor-core traits and Loro CRDT. Wraps LoroDoc + LoroText + loro::UndoManager.\"}" 618 + }, 619 + { 620 + "id": 57, 621 + "change_id": "6ae02804-d3a1-47fb-a51f-e55ce87cf6d3", 622 + "node_type": "observation", 623 + "title": "weaver-index dual-backend detail - ClickHouse for network-wide, SQLite shards for collab", 624 + "description": null, 625 + "status": "pending", 626 + "created_at": "2026-01-06T09:46:07.989448138-05:00", 627 + "updated_at": "2026-01-06T09:46:07.989448138-05:00", 628 + "metadata_json": "{\"confidence\":95,\"prompt\":\"Dec 2025: ClickHouse for all records + aggregates + search. SQLite sharded by resource URI for edit/collab hot tier with sub-ms latency.\"}" 629 + }, 630 + { 631 + "id": 58, 632 + "change_id": "a12049af-71cd-4392-b4d9-4da89c22f221", 633 + "node_type": "observation", 634 + "title": "Editor Phase 1: JumpRope-based text storage with char offsets", 635 + "description": null, 636 + "status": "pending", 637 + "created_at": "2026-01-06T09:50:04.240737150-05:00", 638 + "updated_at": "2026-01-06T09:50:04.240737150-05:00", 639 + "metadata_json": "{\"confidence\":95,\"prompt\":\"Nov 2025: Initial MVP using JumpRopeBuf. CursorState with affinity. LocalStorage persistence. Char offsets (not bytes) for rope operations.\"}" 640 + }, 641 + { 642 + "id": 59, 643 + "change_id": "0dcb444b-61cb-41f2-aa1e-2cb3b5b3f23e", 644 + "node_type": "action", 645 + "title": "Clipboard fixes and Loro CRDT investigation", 646 + "description": null, 647 + "status": "pending", 648 + "created_at": "2026-01-06T09:50:04.478320690-05:00", 649 + "updated_at": "2026-01-06T09:50:04.478320690-05:00", 650 + "metadata_json": "{\"confidence\":90,\"prompt\":\"Nov 2025: Fixed cut/copy/delete. Investigated Loro for undo + collab. Key insight: LoroDoc models entire entry (text + blobs + metadata), not just text.\"}" 651 + }, 652 + { 653 + "id": 60, 654 + "change_id": "15c2d75c-3fd9-45a4-a714-2cbbed916273", 655 + "node_type": "observation", 656 + "title": "Sidenotes: inline footnotes in virtual paragraphs", 657 + "description": null, 658 + "status": "pending", 659 + "created_at": "2026-01-06T09:50:13.862083175-05:00", 660 + "updated_at": "2026-01-06T09:50:13.862083175-05:00", 661 + "metadata_json": "{\"confidence\":85,\"prompt\":\"Sidenotes vs traditional footnotes distinguished by gap size. Single newline = sidenote (inline), blank line = traditional footnote. defer_paragraph_close flag handles HTML output.\"}" 662 + }, 663 + { 664 + "id": 61, 665 + "change_id": "57b416e2-9b1e-41a6-b02f-46fe0f604dae", 666 + "node_type": "action", 667 + "title": "Segmented writer refactor - single full render producing segmented output", 668 + "description": null, 669 + "status": "pending", 670 + "created_at": "2026-01-06T09:50:33.993737496-05:00", 671 + "updated_at": "2026-01-06T09:50:33.993737496-05:00", 672 + "metadata_json": "{\"confidence\":90,\"prompt\":\"Dec 2025: Eliminate double-parsing. SegmentedWriter struct. Per-paragraph data tracking (offset maps, syntax spans, refs). Stable paragraph IDs.\"}" 673 + }, 674 + { 675 + "id": 62, 676 + "change_id": "13053a77-0246-4382-83ea-251c9bd5fd48", 677 + "node_type": "decision", 678 + "title": "Secure session storage for sensitive keys", 679 + "description": null, 680 + "status": "pending", 681 + "created_at": "2026-01-06T09:50:34.221714723-05:00", 682 + "updated_at": "2026-01-06T09:50:34.221714723-05:00", 683 + "metadata_json": "{\"confidence\":85}" 684 + }, 685 + { 686 + "id": 63, 687 + "change_id": "9ddb878e-f0e5-4357-b863-6b4796148478", 688 + "node_type": "option", 689 + "title": "XOR secret sharing between sessionStorage + window.name", 690 + "description": null, 691 + "status": "pending", 692 + "created_at": "2026-01-06T09:50:34.343222603-05:00", 693 + "updated_at": "2026-01-06T09:50:34.343222603-05:00", 694 + "metadata_json": "{\"confidence\":85}" 695 + }, 696 + { 697 + "id": 64, 698 + "change_id": "0e03638a-7ed3-4bc9-9aa4-06484a765c93", 699 + "node_type": "observation", 700 + "title": "weaver-index XRPC endpoints tiered by priority", 701 + "description": null, 702 + "status": "pending", 703 + "created_at": "2026-01-06T09:50:40.729086932-05:00", 704 + "updated_at": "2026-01-06T09:50:40.729086932-05:00", 705 + "metadata_json": "{\"confidence\":90,\"prompt\":\"Tier 1 (critical/editing): getEditHistory, getCollaborationState via SQLite. Tier 2 (core UX): getProfile, getNotebook, getEntry via ClickHouse. Tier 3 (social): follows, likes, search.\"}" 706 + }, 707 + { 708 + "id": 65, 709 + "change_id": "e35bf396-19ec-4f18-8227-044fd7e099a5", 710 + "node_type": "action", 711 + "title": "Editor AT Protocol integration - LoroDoc mirrors entry schema", 712 + "description": null, 713 + "status": "pending", 714 + "created_at": "2026-01-06T09:50:59.518888759-05:00", 715 + "updated_at": "2026-01-06T09:50:59.518888759-05:00", 716 + "metadata_json": "{\"confidence\":90,\"prompt\":\"Nov 2025: LoroDoc contains content, title, path, tags, embeds (nested structure). Image upload flow with PublishedBlob records. Storage key: draft:new:{uuid} or draft:{at-uri}.\"}" 717 + }, 718 + { 719 + "id": 66, 720 + "change_id": "d5463518-b69f-40d0-a6dc-aec0bd78a074", 721 + "node_type": "action", 722 + "title": "Editor performance fix - signal granularity + batched DOM queries", 723 + "description": null, 724 + "status": "pending", 725 + "created_at": "2026-01-06T09:50:59.780023343-05:00", 726 + "updated_at": "2026-01-06T09:50:59.780023343-05:00", 727 + "metadata_json": "{\"confidence\":95,\"prompt\":\"Signal refactor: individual signals for cursor/selection/content instead of Signal<EditorDocument>. Batch DOM visibility with querySelectorAll instead of N query_selector. All phases complete.\"}" 728 + }, 729 + { 730 + "id": 67, 731 + "change_id": "3db38b72-cd99-41ae-b1ae-9ab5d7445b06", 732 + "node_type": "observation", 733 + "title": "weaver-app code review - significant technical debt", 734 + "description": null, 735 + "status": "pending", 736 + "created_at": "2026-01-06T09:51:20.779358167-05:00", 737 + "updated_at": "2026-01-06T09:51:20.779358167-05:00", 738 + "metadata_json": "{\"confidence\":90,\"prompt\":\"Dec 2025: ~17,500 LOC reviewed. 60-70% duplication in data.rs. God functions (800-line start_tag). ~15-20 real bugs. ~3000-4000 lines could be eliminated.\"}" 739 + }, 740 + { 741 + "id": 68, 742 + "change_id": "87027254-16fb-40e4-a0fc-cc9e2089a10b", 743 + "node_type": "decision", 744 + "title": "SQLite shard concurrency pattern", 745 + "description": null, 746 + "status": "pending", 747 + "created_at": "2026-01-06T09:51:21.025710138-05:00", 748 + "updated_at": "2026-01-06T09:51:21.025710138-05:00", 749 + "metadata_json": "{\"confidence\":85}" 750 + }, 751 + { 752 + "id": 69, 753 + "change_id": "909bbc20-cf7c-4b8d-878f-d8ae7ee6d3a9", 754 + "node_type": "option", 755 + "title": "rusqlite + spawn_blocking per request", 756 + "description": null, 757 + "status": "pending", 758 + "created_at": "2026-01-06T09:51:21.141396683-05:00", 759 + "updated_at": "2026-01-06T09:51:21.141396683-05:00", 760 + "metadata_json": "{\"confidence\":85,\"prompt\":\"Simpler. tokio handles thread pool. r2d2 connection pools per shard. Overhead: ~1-2μs per spawn_blocking call.\"}" 761 + }, 762 + { 763 + "id": 70, 764 + "change_id": "42a7505c-c593-4fa2-839f-ca7331324c4e", 765 + "node_type": "decision", 766 + "title": "TextBuffer trait abstraction for editor storage", 767 + "description": null, 768 + "status": "pending", 769 + "created_at": "2026-01-06T09:52:00.188651854-05:00", 770 + "updated_at": "2026-01-06T09:52:00.188651854-05:00", 771 + "metadata_json": "{\"confidence\":90}" 772 + }, 773 + { 774 + "id": 71, 775 + "change_id": "926e5719-d3dc-422b-ba65-af723e0fa49a", 776 + "node_type": "option", 777 + "title": "Generic EditorDocument<T: TextBuffer> with ropey default", 778 + "description": null, 779 + "status": "pending", 780 + "created_at": "2026-01-06T09:52:00.313285622-05:00", 781 + "updated_at": "2026-01-06T09:52:00.313285622-05:00", 782 + "metadata_json": "{\"confidence\":90,\"prompt\":\"Jan 2026: TextBuffer trait provides common interface (insert, delete, slice, offset conversion). EditorRope for local, LoroBuffer for CRDT collab.\"}" 783 + }, 784 + { 785 + "id": 72, 786 + "change_id": "a796cf1c-7ac7-497b-a995-54b57a4e2c30", 787 + "node_type": "action", 788 + "title": "Notebook UI redesign - skeumorphic aesthetics, profile caching", 789 + "description": null, 790 + "status": "pending", 791 + "created_at": "2026-01-06T09:53:17.472885644-05:00", 792 + "updated_at": "2026-01-06T09:53:17.472885644-05:00", 793 + "metadata_json": "{\"confidence\":90,\"prompt\":\"Nov 2025: Paper/blueprint aesthetics, profile sidebar with 30min cache TTL, notebook cover component, multi-author handling (show author only when count > 1), Rose Pine theming.\"}" 794 + }, 795 + { 796 + "id": 73, 797 + "change_id": "2302f391-d481-42a8-8bce-e54c29f6dfe1", 798 + "node_type": "outcome", 799 + "title": "weaver-editor-core extraction nearly complete", 800 + "description": null, 801 + "status": "pending", 802 + "created_at": "2026-01-06T09:56:58.633303649-05:00", 803 + "updated_at": "2026-01-06T09:56:58.633303649-05:00", 804 + "metadata_json": "{\"confidence\":90,\"prompt\":\"Jan 2026: TextBuffer trait + EditorRope impl done. UndoManager + UndoableBuffer done. EditorDocument trait + PlainEditor impl done. EditorWriter rendering engine extracted. No stubs - production ready.\"}" 805 + }, 806 + { 807 + "id": 74, 808 + "change_id": "a6997f6e-06a6-4c9d-ab24-b3ea114207d6", 809 + "node_type": "observation", 810 + "title": "weaver-app editor: 25 files, 30K+ LOC with Loro CRDT", 811 + "description": null, 812 + "status": "pending", 813 + "created_at": "2026-01-06T09:56:58.773096202-05:00", 814 + "updated_at": "2026-01-06T09:56:58.773096202-05:00", 815 + "metadata_json": "{\"confidence\":95,\"prompt\":\"Current state: Fine-grained Signal reactivity working. Worker offloading for snapshots. PDS sync (edit.root + edit.diff) working. Real-time P2P collab scaffolded but iroh integration incomplete.\"}" 816 + }, 817 + { 818 + "id": 75, 819 + "change_id": "a1998766-9d7a-4e59-ba54-881a2626244c", 820 + "node_type": "outcome", 821 + "title": "weaver-index: 19 XRPC endpoints, 40 ClickHouse migrations", 822 + "description": null, 823 + "status": "pending", 824 + "created_at": "2026-01-06T09:56:58.947173272-05:00", 825 + "updated_at": "2026-01-06T09:56:58.947173272-05:00", 826 + "metadata_json": "{\"confidence\":95,\"prompt\":\"Jan 2026: Jetstream/Tap ingestion working. Profile/notebook/entry queries done. Edit history + collab state queries done. TODOs: popular sorting, collab grant validation.\"}" 827 + }, 828 + { 829 + "id": 76, 830 + "change_id": "2d09062b-8e32-4e85-92ed-cb2dfa15ddec", 831 + "node_type": "observation", 832 + "title": "Lexicons: 13 record types, 50+ view/state types", 833 + "description": null, 834 + "status": "pending", 835 + "created_at": "2026-01-06T09:56:59.075238874-05:00", 836 + "updated_at": "2026-01-06T09:56:59.075238874-05:00", 837 + "metadata_json": "{\"confidence\":95,\"prompt\":\"Namespaces: sh.weaver.{notebook, edit, collab, graph, actor}. Records: book, entry, chapter, root, diff, draft, invite, accept, session, like, bookmark, tag, follow, subscribe, list, listitem, profile.\"}" 838 + }, 839 + { 840 + "id": 77, 841 + "change_id": "ef6e7454-2d97-4fe9-97a8-76c2486cf076", 842 + "node_type": "outcome", 843 + "title": "Iroh P2P collab transport fully implemented - feature-gated behind collab-worker flag", 844 + "description": null, 845 + "status": "pending", 846 + "created_at": "2026-01-06T10:35:53.690928004-05:00", 847 + "updated_at": "2026-01-06T10:35:53.690928004-05:00", 848 + "metadata_json": "{\"confidence\":100}" 849 + }, 850 + { 851 + "id": 78, 852 + "change_id": "3af41d3d-93c0-4a8b-a66b-5af3b7c0f048", 853 + "node_type": "outcome", 854 + "title": "Collab architecture complete: CollabCoordinator + EditorReactor worker + gossip protocol + presence tracking + session records", 855 + "description": null, 856 + "status": "pending", 857 + "created_at": "2026-01-06T10:36:07.446463577-05:00", 858 + "updated_at": "2026-01-06T10:36:07.446463577-05:00", 859 + "metadata_json": "{\"confidence\":100}" 860 + }, 861 + { 862 + "id": 79, 863 + "change_id": "33d80413-0ab3-4955-915e-c8aca65cdfc1", 864 + "node_type": "observation", 865 + "title": "Editor extraction status: weaver-editor-core has TextBuffer trait, EditorRope (ropey), UndoableBuffer, PlainEditor, rendering/offset utils, syntect highlighting. weaver-app retains Dioxus editor component, DOM sync, input handling, collab coordinator, storage layer.", 866 + "description": null, 867 + "status": "pending", 868 + "created_at": "2026-01-06T10:36:22.142105793-05:00", 869 + "updated_at": "2026-01-06T10:36:22.142105793-05:00", 870 + "metadata_json": "{\"confidence\":95}" 871 + }, 872 + { 873 + "id": 80, 874 + "change_id": "dcdeb4a1-2dcd-45a2-9038-ec5b59e1b1d2", 875 + "node_type": "observation", 876 + "title": "Loro architecture: weaver-editor-core deliberately Loro-free (uses ropey). Loro lives at weaver-app level - EditorDocument wraps LoroDoc for content/title/path/tags/embeds. weaver-index uses Loro for parsing draft snapshots. Clean separation enables standalone core.", 877 + "description": null, 878 + "status": "pending", 879 + "created_at": "2026-01-06T10:36:33.921891186-05:00", 880 + "updated_at": "2026-01-06T10:36:33.921891186-05:00", 881 + "metadata_json": "{\"confidence\":95}" 882 + }, 883 + { 884 + "id": 81, 885 + "change_id": "5f00148d-b487-40fb-b4b4-66b8d2489e91", 886 + "node_type": "goal", 887 + "title": "Create weaver-editor-browser crate - web_sys DOM layer without Dioxus", 888 + "description": null, 889 + "status": "pending", 890 + "created_at": "2026-01-06T10:39:48.632511769-05:00", 891 + "updated_at": "2026-01-06T10:39:48.632511769-05:00", 892 + "metadata_json": "{\"confidence\":90}" 893 + }, 894 + { 895 + "id": 82, 896 + "change_id": "252f6497-56a2-4f10-ab8c-e7865960c481", 897 + "node_type": "action", 898 + "title": "browser: dom_sync.rs - DOM ↔ document sync", 899 + "description": null, 900 + "status": "pending", 901 + "created_at": "2026-01-06T10:39:56.982339096-05:00", 902 + "updated_at": "2026-01-06T10:39:56.982339096-05:00", 903 + "metadata_json": "{\"confidence\":85}" 904 + }, 905 + { 906 + "id": 83, 907 + "change_id": "63719097-0bec-4805-aa5c-6527256ef708", 908 + "node_type": "action", 909 + "title": "browser: cursor.rs - Selection API handling", 910 + "description": null, 911 + "status": "pending", 912 + "created_at": "2026-01-06T10:39:57.099995278-05:00", 913 + "updated_at": "2026-01-06T10:39:57.099995278-05:00", 914 + "metadata_json": "{\"confidence\":85}" 915 + }, 916 + { 917 + "id": 84, 918 + "change_id": "6c25dd34-ac4d-49d1-88d2-13da6b93c872", 919 + "node_type": "action", 920 + "title": "browser: events.rs - beforeinput, keydown, paste handlers", 921 + "description": null, 922 + "status": "pending", 923 + "created_at": "2026-01-06T10:39:57.208466499-05:00", 924 + "updated_at": "2026-01-06T10:39:57.208466499-05:00", 925 + "metadata_json": "{\"confidence\":85}" 926 + }, 927 + { 928 + "id": 85, 929 + "change_id": "b6e15092-6258-4ca6-9c1b-d1dc58280a92", 930 + "node_type": "action", 931 + "title": "browser: contenteditable.rs - editor element management", 932 + "description": null, 933 + "status": "pending", 934 + "created_at": "2026-01-06T10:39:57.324379851-05:00", 935 + "updated_at": "2026-01-06T10:39:57.324379851-05:00", 936 + "metadata_json": "{\"confidence\":85}" 937 + }, 938 + { 939 + "id": 86, 940 + "change_id": "c50d83f9-d6cb-4697-a24c-50a7707d395c", 941 + "node_type": "action", 942 + "title": "browser: embed worker - image/content resolution in worker thread", 943 + "description": null, 944 + "status": "pending", 945 + "created_at": "2026-01-06T10:39:57.433073329-05:00", 946 + "updated_at": "2026-01-06 16:17:59", 947 + "metadata_json": "{\"confidence\":75}" 948 + }, 949 + { 950 + "id": 87, 951 + "change_id": "1d5c50c0-bfeb-4218-88ab-405d12ba0607", 952 + "node_type": "goal", 953 + "title": "Create weaver-editor-crdt crate - optional Loro integration (+500KB)", 954 + "description": null, 955 + "status": "pending", 956 + "created_at": "2026-01-06T10:40:09.313344768-05:00", 957 + "updated_at": "2026-01-06T10:40:09.313344768-05:00", 958 + "metadata_json": "{\"confidence\":90}" 959 + }, 960 + { 961 + "id": 88, 962 + "change_id": "ed566bdc-7372-4ea5-966c-a7f10e2c5453", 963 + "node_type": "action", 964 + "title": "crdt: LoroDocument impl EditorDocument (wraps LoroDoc + UndoManager)", 965 + "description": null, 966 + "status": "pending", 967 + "created_at": "2026-01-06T10:40:14.817578785-05:00", 968 + "updated_at": "2026-01-06 15:44:26", 969 + "metadata_json": "{\"confidence\":85}" 970 + }, 971 + { 972 + "id": 90, 973 + "change_id": "cdee4656-7495-4a9c-8d24-0f232941763e", 974 + "node_type": "action", 975 + "title": "crdt: sync.rs - export/import updates, version vectors", 976 + "description": null, 977 + "status": "pending", 978 + "created_at": "2026-01-06T10:40:15.025419171-05:00", 979 + "updated_at": "2026-01-06T10:40:15.025419171-05:00", 980 + "metadata_json": "{\"confidence\":85}" 981 + }, 982 + { 983 + "id": 91, 984 + "change_id": "57d3f2a1-bd8d-45f1-8834-97bac872dae8", 985 + "node_type": "goal", 986 + "title": "Phase 2: Update weaver-app to use extracted crates", 987 + "description": null, 988 + "status": "pending", 989 + "created_at": "2026-01-06T10:42:07.645111430-05:00", 990 + "updated_at": "2026-01-06T10:42:07.645111430-05:00", 991 + "metadata_json": "{\"confidence\":85}" 992 + }, 993 + { 994 + "id": 92, 995 + "change_id": "3d62e1bf-b43c-4924-b32e-2a5190b2ed0e", 996 + "node_type": "action", 997 + "title": "app: SignalEditorDocument wrapper adding Dioxus signals to core document", 998 + "description": null, 999 + "status": "pending", 1000 + "created_at": "2026-01-06T10:42:17.840391077-05:00", 1001 + "updated_at": "2026-01-06T10:42:17.840391077-05:00", 1002 + "metadata_json": "{\"confidence\":85}" 1003 + }, 1004 + { 1005 + "id": 93, 1006 + "change_id": "64a17922-3079-4e23-a8f2-114758a3ed6d", 1007 + "node_type": "action", 1008 + "title": "app: Update editor component to use weaver-editor-core + weaver-editor-browser", 1009 + "description": null, 1010 + "status": "pending", 1011 + "created_at": "2026-01-06T10:42:17.965557748-05:00", 1012 + "updated_at": "2026-01-06T10:42:17.965557748-05:00", 1013 + "metadata_json": "{\"confidence\":85}" 1014 + }, 1015 + { 1016 + "id": 94, 1017 + "change_id": "f7f521a5-db62-43fd-8e77-bff00524c9c3", 1018 + "node_type": "action", 1019 + "title": "app: Update collab to use weaver-editor-crdt", 1020 + "description": null, 1021 + "status": "pending", 1022 + "created_at": "2026-01-06T10:42:18.071589891-05:00", 1023 + "updated_at": "2026-01-06T10:42:18.071589891-05:00", 1024 + "metadata_json": "{\"confidence\":85}" 1025 + }, 1026 + { 1027 + "id": 95, 1028 + "change_id": "a0bde70d-28e9-4a27-9bc7-814415c4d61a", 1029 + "node_type": "goal", 1030 + "title": "Phase 3: Create weaver-editor-js - npm package @weaver/editor", 1031 + "description": null, 1032 + "status": "pending", 1033 + "created_at": "2026-01-06T10:42:31.494853791-05:00", 1034 + "updated_at": "2026-01-06T10:42:31.494853791-05:00", 1035 + "metadata_json": "{\"confidence\":80}" 1036 + }, 1037 + { 1038 + "id": 96, 1039 + "change_id": "7917c340-2614-40e5-96b3-ec569aca0053", 1040 + "node_type": "action", 1041 + "title": "js: WASM bindings for Editor and CollabEditor", 1042 + "description": null, 1043 + "status": "pending", 1044 + "created_at": "2026-01-06T10:42:42.460056141-05:00", 1045 + "updated_at": "2026-01-06T10:42:42.460056141-05:00", 1046 + "metadata_json": "{\"confidence\":80}" 1047 + }, 1048 + { 1049 + "id": 97, 1050 + "change_id": "32765e84-b76d-45cf-940e-28bc35e14339", 1051 + "node_type": "action", 1052 + "title": "js: TypeScript view layer - mount/unmount, callbacks, lifecycle", 1053 + "description": null, 1054 + "status": "pending", 1055 + "created_at": "2026-01-06T10:42:42.562764586-05:00", 1056 + "updated_at": "2026-01-06T10:42:42.562764586-05:00", 1057 + "metadata_json": "{\"confidence\":80}" 1058 + }, 1059 + { 1060 + "id": 98, 1061 + "change_id": "371419cc-a552-41d7-9f4e-3239b4d8b5ad", 1062 + "node_type": "action", 1063 + "title": "js: npm publish as @weaver/editor", 1064 + "description": null, 1065 + "status": "pending", 1066 + "created_at": "2026-01-06T10:42:42.662187635-05:00", 1067 + "updated_at": "2026-01-06T10:42:42.662187635-05:00", 1068 + "metadata_json": "{\"confidence\":75}" 1069 + }, 1070 + { 1071 + "id": 99, 1072 + "change_id": "e1275977-76fd-4a21-89d2-1badd40054a2", 1073 + "node_type": "observation", 1074 + "title": "EditorDocument trait is THE shared interface - LoroDocument impls it, SignalEditorDocument wraps it. JS bindings should expose this interface if possible.", 1075 + "description": null, 1076 + "status": "pending", 1077 + "created_at": "2026-01-06T10:44:19.190720503-05:00", 1078 + "updated_at": "2026-01-06T10:44:19.190720503-05:00", 1079 + "metadata_json": "{\"confidence\":95}" 1080 + }, 1081 + { 1082 + "id": 100, 1083 + "change_id": "e48d466a-1b04-4da7-a9db-cd0abe3d8ad2", 1084 + "node_type": "decision", 1085 + "title": "Expose EditorDocument interface in JS bindings?", 1086 + "description": null, 1087 + "status": "pending", 1088 + "created_at": "2026-01-06T10:44:30.449058579-05:00", 1089 + "updated_at": "2026-01-06T10:44:30.449058579-05:00", 1090 + "metadata_json": "{\"confidence\":75}" 1091 + }, 1092 + { 1093 + "id": 101, 1094 + "change_id": "efd6969a-aa78-42fb-9238-a8ba0e474b3d", 1095 + "node_type": "option", 1096 + "title": "Yes - TypeScript interface mirroring Rust trait, enables custom impls", 1097 + "description": null, 1098 + "status": "pending", 1099 + "created_at": "2026-01-06T10:44:37.389767875-05:00", 1100 + "updated_at": "2026-01-06T10:44:37.389767875-05:00", 1101 + "metadata_json": "{\"confidence\":70}" 1102 + }, 1103 + { 1104 + "id": 102, 1105 + "change_id": "384d5ccb-73bc-40c3-901a-b4894809d67d", 1106 + "node_type": "option", 1107 + "title": "No - concrete Editor/CollabEditor classes only, simpler API", 1108 + "description": null, 1109 + "status": "pending", 1110 + "created_at": "2026-01-06T10:44:37.506912185-05:00", 1111 + "updated_at": "2026-01-06T10:44:37.506912185-05:00", 1112 + "metadata_json": "{\"confidence\":70}" 1113 + }, 1114 + { 1115 + "id": 103, 1116 + "change_id": "a83f0c2c-ede4-4407-a048-535462783960", 1117 + "node_type": "decision", 1118 + "title": "Browser crate extraction approach - how to handle EditorDocument trait vs concrete type", 1119 + "description": null, 1120 + "status": "pending", 1121 + "created_at": "2026-01-06T11:08:59.203682173-05:00", 1122 + "updated_at": "2026-01-06T11:08:59.203682173-05:00", 1123 + "metadata_json": "{\"confidence\":85}" 1124 + }, 1125 + { 1126 + "id": 104, 1127 + "change_id": "1fb0e2b9-f6e7-4e1d-a514-8b370712c232", 1128 + "node_type": "option", 1129 + "title": "Option 1: Make app EditorDocument impl core trait first, then extract browser code", 1130 + "description": null, 1131 + "status": "pending", 1132 + "created_at": "2026-01-06T11:09:06.678864362-05:00", 1133 + "updated_at": "2026-01-06T11:09:06.678864362-05:00", 1134 + "metadata_json": "{\"confidence\":80}" 1135 + }, 1136 + { 1137 + "id": 105, 1138 + "change_id": "29fef523-1eb5-497c-905a-16dec60b30df", 1139 + "node_type": "option", 1140 + "title": "Option 2: Extract browser code now with concrete type, refactor to generic later", 1141 + "description": null, 1142 + "status": "pending", 1143 + "created_at": "2026-01-06T11:09:06.795379977-05:00", 1144 + "updated_at": "2026-01-06T11:09:06.795379977-05:00", 1145 + "metadata_json": "{\"confidence\":75}" 1146 + }, 1147 + { 1148 + "id": 106, 1149 + "change_id": "ba8b0f30-09f9-437d-b690-b5053b9991ad", 1150 + "node_type": "option", 1151 + "title": "Option 3: Create browser crate with trait bounds, update app impl simultaneously", 1152 + "description": null, 1153 + "status": "pending", 1154 + "created_at": "2026-01-06T11:09:06.901780924-05:00", 1155 + "updated_at": "2026-01-06T11:09:06.901780924-05:00", 1156 + "metadata_json": "{\"confidence\":70}" 1157 + }, 1158 + { 1159 + "id": 107, 1160 + "change_id": "11c284c0-37c2-4736-aa4e-479f77b7c5be", 1161 + "node_type": "observation", 1162 + "title": "Core blocker: app EditorDocument uses Signal accessors (cursor.read/write) but browser crate needs trait methods (cursor()/set_cursor()) for generic code", 1163 + "description": null, 1164 + "status": "pending", 1165 + "created_at": "2026-01-06T11:09:12.978758503-05:00", 1166 + "updated_at": "2026-01-06T11:09:12.978758503-05:00", 1167 + "metadata_json": "{\"confidence\":95}" 1168 + }, 1169 + { 1170 + "id": 108, 1171 + "change_id": "86195ee9-4147-4874-8f89-7a0e882a5132", 1172 + "node_type": "action", 1173 + "title": "Chosen approach: browser crate impl against EditorDocument trait, extend trait as needed", 1174 + "description": null, 1175 + "status": "pending", 1176 + "created_at": "2026-01-06T11:17:38.648008021-05:00", 1177 + "updated_at": "2026-01-06T11:17:38.648008021-05:00", 1178 + "metadata_json": "{\"confidence\":90}" 1179 + }, 1180 + { 1181 + "id": 109, 1182 + "change_id": "57096f55-c0fd-4c6c-a45f-7877def5ef70", 1183 + "node_type": "observation", 1184 + "title": "Defer weaver-app reintegration until both browser AND crdt crates complete - cleaner dependency graph", 1185 + "description": null, 1186 + "status": "pending", 1187 + "created_at": "2026-01-06T11:17:38.754613630-05:00", 1188 + "updated_at": "2026-01-06T11:17:38.754613630-05:00", 1189 + "metadata_json": "{\"confidence\":90}" 1190 + }, 1191 + { 1192 + "id": 110, 1193 + "change_id": "4dfdbea5-6bcd-4377-90c1-a35e68b59d11", 1194 + "node_type": "decision", 1195 + "title": "Feature-gate dioxus_signals in weaver-editor-browser for framework-agnostic reactive state", 1196 + "description": null, 1197 + "status": "pending", 1198 + "created_at": "2026-01-06T11:17:44.899371901-05:00", 1199 + "updated_at": "2026-01-06T11:17:44.899371901-05:00", 1200 + "metadata_json": "{\"confidence\":85}" 1201 + }, 1202 + { 1203 + "id": 111, 1204 + "change_id": "d05ddf03-4487-4fc6-932c-eb91e58b1ebf", 1205 + "node_type": "observation", 1206 + "title": "Worker placement: collab worker → crdt crate (Loro-specific), embed worker → browser crate", 1207 + "description": null, 1208 + "status": "pending", 1209 + "created_at": "2026-01-06T11:17:45.022215010-05:00", 1210 + "updated_at": "2026-01-06T11:17:45.022215010-05:00", 1211 + "metadata_json": "{\"confidence\":85}" 1212 + }, 1213 + { 1214 + "id": 112, 1215 + "change_id": "1b0e70cb-ebed-4a59-8a94-2314491a9e84", 1216 + "node_type": "action", 1217 + "title": "crdt: collab worker - Loro sync + iroh P2P in worker thread", 1218 + "description": null, 1219 + "status": "pending", 1220 + "created_at": "2026-01-06T11:17:59.598669487-05:00", 1221 + "updated_at": "2026-01-06T11:17:59.598669487-05:00", 1222 + "metadata_json": "{\"confidence\":85}" 1223 + }, 1224 + { 1225 + "id": 113, 1226 + "change_id": "4dd1947c-e4d8-4134-8174-523d037ae728", 1227 + "node_type": "observation", 1228 + "title": "weaver-editor-crdt includes AT Protocol integration: PDS sync (edit.root/diff records), session records for collab discovery", 1229 + "description": null, 1230 + "status": "pending", 1231 + "created_at": "2026-01-06T11:19:19.206906847-05:00", 1232 + "updated_at": "2026-01-06T11:19:19.206906847-05:00", 1233 + "metadata_json": "{\"confidence\":90}" 1234 + }, 1235 + { 1236 + "id": 114, 1237 + "change_id": "e32a4e20-d5c8-40b7-82f8-9519313bcc55", 1238 + "node_type": "action", 1239 + "title": "crdt: PDS sync - edit.root/diff record management, draft persistence", 1240 + "description": null, 1241 + "status": "pending", 1242 + "created_at": "2026-01-06T11:19:19.324015269-05:00", 1243 + "updated_at": "2026-01-06T11:19:19.324015269-05:00", 1244 + "metadata_json": "{\"confidence\":85}" 1245 + }, 1246 + { 1247 + "id": 115, 1248 + "change_id": "f991c3b0-0b5e-43b6-8d3a-59067ce3175e", 1249 + "node_type": "observation", 1250 + "title": "crdt crate generic over document primitive where reasonable - not hardcoded to Entry lexicon, enables reuse for other AT Protocol record types", 1251 + "description": null, 1252 + "status": "pending", 1253 + "created_at": "2026-01-06T11:20:40.227811552-05:00", 1254 + "updated_at": "2026-01-06T11:20:40.227811552-05:00", 1255 + "metadata_json": "{\"confidence\":90}" 1256 + }, 1257 + { 1258 + "id": 116, 1259 + "change_id": "cd5cc522-d717-43a7-b116-94f86845db10", 1260 + "node_type": "observation", 1261 + "title": "Architecture pattern: core defines platform traits, browser/native implement them. Enables multi-platform support.", 1262 + "description": null, 1263 + "status": "pending", 1264 + "created_at": "2026-01-06T11:26:00.681187721-05:00", 1265 + "updated_at": "2026-01-06T11:26:00.681187721-05:00", 1266 + "metadata_json": "{\"confidence\":95}" 1267 + }, 1268 + { 1269 + "id": 117, 1270 + "change_id": "2a2333b1-eceb-4635-877f-26f55790d7ed", 1271 + "node_type": "outcome", 1272 + "title": "weaver-editor-browser crate scaffolded - cursor module with BrowserCursor impl CursorPlatform", 1273 + "description": null, 1274 + "status": "pending", 1275 + "created_at": "2026-01-06T11:32:59.244188632-05:00", 1276 + "updated_at": "2026-01-06T11:32:59.244188632-05:00", 1277 + "metadata_json": "{\"confidence\":90}" 1278 + }, 1279 + { 1280 + "id": 118, 1281 + "change_id": "d6f1e398-007f-4eb6-94dd-33a97695bcbd", 1282 + "node_type": "observation", 1283 + "title": "CursorPlatform API cleanup: removed unused editor_id param from restore_cursor and get_cursor_rect - element lookup uses mapping.node_id directly", 1284 + "description": null, 1285 + "status": "pending", 1286 + "created_at": "2026-01-06T11:35:39.555773236-05:00", 1287 + "updated_at": "2026-01-06T11:35:39.555773236-05:00", 1288 + "metadata_json": "{\"confidence\":95}" 1289 + } 1290 + ], 1291 + "edges": [ 1292 + { 1293 + "id": 1, 1294 + "from_node_id": 1, 1295 + "to_node_id": 3, 1296 + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", 1297 + "to_change_id": "58113fdb-140c-4844-844a-b51eee292c58", 1298 + "edge_type": "leads_to", 1299 + "weight": 1.0, 1300 + "rationale": "Building weaver.sh required choosing a frontend technology", 1301 + "created_at": "2026-01-06T09:32:16.244756482-05:00" 1302 + }, 1303 + { 1304 + "id": 2, 1305 + "from_node_id": 1, 1306 + "to_node_id": 6, 1307 + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", 1308 + "to_change_id": "ef3edcf6-e8a8-4d48-9e9e-3885abb53a30", 1309 + "edge_type": "leads_to", 1310 + "weight": 1.0, 1311 + "rationale": "Building weaver.sh required deciding on editor approach", 1312 + "created_at": "2026-01-06T09:32:16.349062979-05:00" 1313 + }, 1314 + { 1315 + "id": 3, 1316 + "from_node_id": 1, 1317 + "to_node_id": 2, 1318 + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", 1319 + "to_change_id": "850c82f8-e6b6-4b8b-bd70-e51bcf43d0a5", 1320 + "edge_type": "leads_to", 1321 + "weight": 1.0, 1322 + "rationale": "Weaver needed a better AT Protocol library, so jacquard was built", 1323 + "created_at": "2026-01-06T09:32:16.464352957-05:00" 1324 + }, 1325 + { 1326 + "id": 4, 1327 + "from_node_id": 1, 1328 + "to_node_id": 16, 1329 + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", 1330 + "to_change_id": "bac8484a-5eb5-42bf-9387-55d90fe486cd", 1331 + "edge_type": "leads_to", 1332 + "weight": 1.0, 1333 + "rationale": "Weaver needed an indexing backend for efficient queries", 1334 + "created_at": "2026-01-06T09:32:16.582213414-05:00" 1335 + }, 1336 + { 1337 + "id": 5, 1338 + "from_node_id": 3, 1339 + "to_node_id": 4, 1340 + "from_change_id": "58113fdb-140c-4844-844a-b51eee292c58", 1341 + "to_change_id": "3c1e5278-cb0d-4f8b-9605-89225dfe66e2", 1342 + "edge_type": "chosen", 1343 + "weight": 1.0, 1344 + "rationale": "Dioxus chosen - wanted to write frontend in Rust", 1345 + "created_at": "2026-01-06T09:32:24.393657051-05:00" 1346 + }, 1347 + { 1348 + "id": 6, 1349 + "from_node_id": 3, 1350 + "to_node_id": 5, 1351 + "from_change_id": "58113fdb-140c-4844-844a-b51eee292c58", 1352 + "to_change_id": "19e5dad0-b0d8-4eba-8af1-028430ddc69c", 1353 + "edge_type": "rejected", 1354 + "weight": 1.0, 1355 + "rationale": "JS frameworks rejected - wanted pure Rust stack", 1356 + "created_at": "2026-01-06T09:32:24.489371357-05:00" 1357 + }, 1358 + { 1359 + "id": 7, 1360 + "from_node_id": 6, 1361 + "to_node_id": 7, 1362 + "from_change_id": "ef3edcf6-e8a8-4d48-9e9e-3885abb53a30", 1363 + "to_change_id": "5688af7d-61e5-4b47-afaa-05ee5bff36ae", 1364 + "edge_type": "chosen", 1365 + "weight": 1.0, 1366 + "rationale": "Custom editor chosen - no Rust alternatives, custom markdown needs, performance", 1367 + "created_at": "2026-01-06T09:32:24.587177576-05:00" 1368 + }, 1369 + { 1370 + "id": 8, 1371 + "from_node_id": 6, 1372 + "to_node_id": 8, 1373 + "from_change_id": "ef3edcf6-e8a8-4d48-9e9e-3885abb53a30", 1374 + "to_change_id": "d28249c5-955c-45ad-bbff-d76f437055c3", 1375 + "edge_type": "rejected", 1376 + "weight": 1.0, 1377 + "rationale": "JS editors rejected - not Rust, wouldn't support custom markdown well", 1378 + "created_at": "2026-01-06T09:32:24.698525141-05:00" 1379 + }, 1380 + { 1381 + "id": 9, 1382 + "from_node_id": 7, 1383 + "to_node_id": 9, 1384 + "from_change_id": "5688af7d-61e5-4b47-afaa-05ee5bff36ae", 1385 + "to_change_id": "301c77f5-27ce-422d-8be7-eec136c3c856", 1386 + "edge_type": "leads_to", 1387 + "weight": 1.0, 1388 + "rationale": "Custom editor led to text storage decisions", 1389 + "created_at": "2026-01-06T09:32:32.655038840-05:00" 1390 + }, 1391 + { 1392 + "id": 10, 1393 + "from_node_id": 7, 1394 + "to_node_id": 13, 1395 + "from_change_id": "5688af7d-61e5-4b47-afaa-05ee5bff36ae", 1396 + "to_change_id": "cf8c5176-4419-47fd-a7d6-f59ee8806b6f", 1397 + "edge_type": "leads_to", 1398 + "weight": 1.0, 1399 + "rationale": "Custom editor led to input handling decisions", 1400 + "created_at": "2026-01-06T09:32:32.770738756-05:00" 1401 + }, 1402 + { 1403 + "id": 11, 1404 + "from_node_id": 9, 1405 + "to_node_id": 10, 1406 + "from_change_id": "301c77f5-27ce-422d-8be7-eec136c3c856", 1407 + "to_change_id": "5738a9b1-2767-4aab-ad59-b7fe9ee6728e", 1408 + "edge_type": "chosen", 1409 + "weight": 1.0, 1410 + "rationale": "JumpRope initially chosen for fast edits and UTF-16 support", 1411 + "created_at": "2026-01-06T09:32:32.881385871-05:00" 1412 + }, 1413 + { 1414 + "id": 12, 1415 + "from_node_id": 9, 1416 + "to_node_id": 11, 1417 + "from_change_id": "301c77f5-27ce-422d-8be7-eec136c3c856", 1418 + "to_change_id": "d7d9d021-6d36-48ed-a7c1-f7b0a7576c2e", 1419 + "edge_type": "chosen", 1420 + "weight": 1.0, 1421 + "rationale": "Ropey chosen for core extraction - mature, byte/char indexing", 1422 + "created_at": "2026-01-06T09:32:32.986188585-05:00" 1423 + }, 1424 + { 1425 + "id": 13, 1426 + "from_node_id": 9, 1427 + "to_node_id": 12, 1428 + "from_change_id": "301c77f5-27ce-422d-8be7-eec136c3c856", 1429 + "to_change_id": "aef81c95-7a98-4dc6-bdc8-ab1df9823db8", 1430 + "edge_type": "chosen", 1431 + "weight": 1.0, 1432 + "rationale": "Loro chosen for CRDT/collab features - adds ~500KB", 1433 + "created_at": "2026-01-06T09:32:33.088291560-05:00" 1434 + }, 1435 + { 1436 + "id": 14, 1437 + "from_node_id": 13, 1438 + "to_node_id": 14, 1439 + "from_change_id": "cf8c5176-4419-47fd-a7d6-f59ee8806b6f", 1440 + "to_change_id": "290a2bda-6f84-4983-bda6-1870dfea8401", 1441 + "edge_type": "chosen", 1442 + "weight": 1.0, 1443 + "rationale": "beforeinput interception chosen - full control, no sync bugs", 1444 + "created_at": "2026-01-06T09:32:40.200826929-05:00" 1445 + }, 1446 + { 1447 + "id": 15, 1448 + "from_node_id": 13, 1449 + "to_node_id": 15, 1450 + "from_change_id": "cf8c5176-4419-47fd-a7d6-f59ee8806b6f", 1451 + "to_change_id": "374172ea-9738-4f5e-a176-1b5e8bd21568", 1452 + "edge_type": "rejected", 1453 + "weight": 1.0, 1454 + "rationale": "Hidden textarea rejected - sync hell, IME issues", 1455 + "created_at": "2026-01-06T09:32:40.322267642-05:00" 1456 + }, 1457 + { 1458 + "id": 16, 1459 + "from_node_id": 16, 1460 + "to_node_id": 17, 1461 + "from_change_id": "bac8484a-5eb5-42bf-9387-55d90fe486cd", 1462 + "to_change_id": "af0827fd-530e-498c-88ad-082a648a93f4", 1463 + "edge_type": "chosen", 1464 + "weight": 1.0, 1465 + "rationale": "Dual-backend chosen for latency + scale balance", 1466 + "created_at": "2026-01-06T09:32:40.419898633-05:00" 1467 + }, 1468 + { 1469 + "id": 17, 1470 + "from_node_id": 7, 1471 + "to_node_id": 18, 1472 + "from_change_id": "5688af7d-61e5-4b47-afaa-05ee5bff36ae", 1473 + "to_change_id": "fa554b5d-8af7-42e4-b03f-e5bec837e31a", 1474 + "edge_type": "leads_to", 1475 + "weight": 1.0, 1476 + "rationale": "Editor matured, now extracting for embeddability", 1477 + "created_at": "2026-01-06T09:32:40.531324565-05:00" 1478 + }, 1479 + { 1480 + "id": 18, 1481 + "from_node_id": 19, 1482 + "to_node_id": 6, 1483 + "from_change_id": "291fe7f0-b962-4663-b7d9-701dd38de1b5", 1484 + "to_change_id": "ef3edcf6-e8a8-4d48-9e9e-3885abb53a30", 1485 + "edge_type": "leads_to", 1486 + "weight": 1.0, 1487 + "rationale": "UX requirement informed editor approach decision", 1488 + "created_at": "2026-01-06T09:32:46.840847461-05:00" 1489 + }, 1490 + { 1491 + "id": 19, 1492 + "from_node_id": 20, 1493 + "to_node_id": 7, 1494 + "from_change_id": "8b418708-6db5-42ab-8153-b2dc64f13065", 1495 + "to_change_id": "5688af7d-61e5-4b47-afaa-05ee5bff36ae", 1496 + "edge_type": "leads_to", 1497 + "weight": 1.0, 1498 + "rationale": "Custom markdown dialect required custom editor", 1499 + "created_at": "2026-01-06T09:32:46.934676711-05:00" 1500 + }, 1501 + { 1502 + "id": 20, 1503 + "from_node_id": 1, 1504 + "to_node_id": 19, 1505 + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", 1506 + "to_change_id": "291fe7f0-b962-4663-b7d9-701dd38de1b5", 1507 + "edge_type": "leads_to", 1508 + "weight": 1.0, 1509 + "rationale": "Weaver's vision includes Obsidian-style editing", 1510 + "created_at": "2026-01-06T09:32:47.034675391-05:00" 1511 + }, 1512 + { 1513 + "id": 21, 1514 + "from_node_id": 1, 1515 + "to_node_id": 20, 1516 + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", 1517 + "to_change_id": "8b418708-6db5-42ab-8153-b2dc64f13065", 1518 + "edge_type": "leads_to", 1519 + "weight": 1.0, 1520 + "rationale": "Weaver uses custom markdown dialect", 1521 + "created_at": "2026-01-06T09:32:47.143855201-05:00" 1522 + }, 1523 + { 1524 + "id": 22, 1525 + "from_node_id": 2, 1526 + "to_node_id": 21, 1527 + "from_change_id": "850c82f8-e6b6-4b8b-bd70-e51bcf43d0a5", 1528 + "to_change_id": "e0a70314-85a3-4561-9348-fce0a887d7f9", 1529 + "edge_type": "leads_to", 1530 + "weight": 1.0, 1531 + "rationale": "Jacquard built and weaver migrated successfully", 1532 + "created_at": "2026-01-06T09:36:52.144785392-05:00" 1533 + }, 1534 + { 1535 + "id": 23, 1536 + "from_node_id": 4, 1537 + "to_node_id": 22, 1538 + "from_change_id": "3c1e5278-cb0d-4f8b-9605-89225dfe66e2", 1539 + "to_change_id": "40dd01d3-768c-4b31-8baf-7d027079d73f", 1540 + "edge_type": "leads_to", 1541 + "weight": 1.0, 1542 + "rationale": "Dioxus frontend deployed and working", 1543 + "created_at": "2026-01-06T09:36:52.255804622-05:00" 1544 + }, 1545 + { 1546 + "id": 24, 1547 + "from_node_id": 17, 1548 + "to_node_id": 23, 1549 + "from_change_id": "af0827fd-530e-498c-88ad-082a648a93f4", 1550 + "to_change_id": "708898ff-a587-4cda-9a2e-9376ce22f480", 1551 + "edge_type": "leads_to", 1552 + "weight": 1.0, 1553 + "rationale": "Dual-backend architecture deployed", 1554 + "created_at": "2026-01-06T09:36:52.362370733-05:00" 1555 + }, 1556 + { 1557 + "id": 25, 1558 + "from_node_id": 7, 1559 + "to_node_id": 24, 1560 + "from_change_id": "5688af7d-61e5-4b47-afaa-05ee5bff36ae", 1561 + "to_change_id": "6b74f268-78fb-42ba-bb09-59e82496ac21", 1562 + "edge_type": "leads_to", 1563 + "weight": 1.0, 1564 + "rationale": "Custom editor implemented and functional", 1565 + "created_at": "2026-01-06T09:36:52.526238184-05:00" 1566 + }, 1567 + { 1568 + "id": 26, 1569 + "from_node_id": 12, 1570 + "to_node_id": 25, 1571 + "from_change_id": "aef81c95-7a98-4dc6-bdc8-ab1df9823db8", 1572 + "to_change_id": "38fa6c2f-2a36-40d4-97ec-4e029501253b", 1573 + "edge_type": "leads_to", 1574 + "weight": 1.0, 1575 + "rationale": "Loro integration working for collaborative editing", 1576 + "created_at": "2026-01-06T09:36:52.639107954-05:00" 1577 + }, 1578 + { 1579 + "id": 27, 1580 + "from_node_id": 1, 1581 + "to_node_id": 26, 1582 + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", 1583 + "to_change_id": "28d1b14d-5cb4-490e-87a2-db52aa233342", 1584 + "edge_type": "leads_to", 1585 + "weight": 1.0, 1586 + "rationale": "Weaver needs user authentication to publish", 1587 + "created_at": "2026-01-06T09:37:38.981464260-05:00" 1588 + }, 1589 + { 1590 + "id": 28, 1591 + "from_node_id": 26, 1592 + "to_node_id": 27, 1593 + "from_change_id": "28d1b14d-5cb4-490e-87a2-db52aa233342", 1594 + "to_change_id": "00233162-061d-40bc-86c7-d74fddcd2ac4", 1595 + "edge_type": "chosen", 1596 + "weight": 1.0, 1597 + "rationale": "Public client chosen - simpler, works for web app", 1598 + "created_at": "2026-01-06T09:37:39.256369998-05:00" 1599 + }, 1600 + { 1601 + "id": 29, 1602 + "from_node_id": 26, 1603 + "to_node_id": 28, 1604 + "from_change_id": "28d1b14d-5cb4-490e-87a2-db52aa233342", 1605 + "to_change_id": "1a4d6505-b985-40db-9055-b42489331367", 1606 + "edge_type": "considered", 1607 + "weight": 1.0, 1608 + "rationale": "Confidential client planned for future", 1609 + "created_at": "2026-01-06T09:37:39.507880193-05:00" 1610 + }, 1611 + { 1612 + "id": 30, 1613 + "from_node_id": 1, 1614 + "to_node_id": 29, 1615 + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", 1616 + "to_change_id": "e7f730f7-598a-44c9-9d86-22c86653a65e", 1617 + "edge_type": "leads_to", 1618 + "weight": 1.0, 1619 + "rationale": "Subdomain hosting enables cleaner URLs for notebooks", 1620 + "created_at": "2026-01-06T09:37:39.785408714-05:00" 1621 + }, 1622 + { 1623 + "id": 31, 1624 + "from_node_id": 1, 1625 + "to_node_id": 30, 1626 + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", 1627 + "to_change_id": "2eeb7345-a8a2-4146-bda5-1f19cfc7501d", 1628 + "edge_type": "leads_to", 1629 + "weight": 1.0, 1630 + "rationale": "JS bindings enable external apps to render weaver content", 1631 + "created_at": "2026-01-06T09:37:39.970366449-05:00" 1632 + }, 1633 + { 1634 + "id": 32, 1635 + "from_node_id": 18, 1636 + "to_node_id": 30, 1637 + "from_change_id": "fa554b5d-8af7-42e4-b03f-e5bec837e31a", 1638 + "to_change_id": "2eeb7345-a8a2-4146-bda5-1f19cfc7501d", 1639 + "edge_type": "leads_to", 1640 + "weight": 1.0, 1641 + "rationale": "Editor extraction and renderer bindings share JS packaging approach", 1642 + "created_at": "2026-01-06T09:37:40.194803367-05:00" 1643 + }, 1644 + { 1645 + "id": 33, 1646 + "from_node_id": 9, 1647 + "to_node_id": 31, 1648 + "from_change_id": "301c77f5-27ce-422d-8be7-eec136c3c856", 1649 + "to_change_id": "6d66ca53-5105-4078-bf7e-5f9a0e503012", 1650 + "edge_type": "leads_to", 1651 + "weight": 1.0, 1652 + "rationale": "Text storage choice led to buffer migration decisions", 1653 + "created_at": "2026-01-06T09:38:25.723965810-05:00" 1654 + }, 1655 + { 1656 + "id": 34, 1657 + "from_node_id": 31, 1658 + "to_node_id": 32, 1659 + "from_change_id": "6d66ca53-5105-4078-bf7e-5f9a0e503012", 1660 + "to_change_id": "60829366-0740-47cc-9101-4057c9c7489e", 1661 + "edge_type": "leads_to", 1662 + "weight": 1.0, 1663 + "rationale": "Loro chosen for undo + collab foundation", 1664 + "created_at": "2026-01-06T09:38:25.837238235-05:00" 1665 + }, 1666 + { 1667 + "id": 35, 1668 + "from_node_id": 32, 1669 + "to_node_id": 25, 1670 + "from_change_id": "60829366-0740-47cc-9101-4057c9c7489e", 1671 + "to_change_id": "38fa6c2f-2a36-40d4-97ec-4e029501253b", 1672 + "edge_type": "leads_to", 1673 + "weight": 1.0, 1674 + "rationale": "Loro migration enabled CRDT collaboration", 1675 + "created_at": "2026-01-06T09:38:25.942975341-05:00" 1676 + }, 1677 + { 1678 + "id": 36, 1679 + "from_node_id": 19, 1680 + "to_node_id": 33, 1681 + "from_change_id": "291fe7f0-b962-4663-b7d9-701dd38de1b5", 1682 + "to_change_id": "98e78259-5b13-413f-af77-bcb7354aca77", 1683 + "edge_type": "leads_to", 1684 + "weight": 1.0, 1685 + "rationale": "Obsidian-style UX required syntax hiding implementation", 1686 + "created_at": "2026-01-06T09:38:26.049004964-05:00" 1687 + }, 1688 + { 1689 + "id": 37, 1690 + "from_node_id": 7, 1691 + "to_node_id": 34, 1692 + "from_change_id": "5688af7d-61e5-4b47-afaa-05ee5bff36ae", 1693 + "to_change_id": "ddc3ed83-2f0f-4c53-8e9f-44076bbf3aac", 1694 + "edge_type": "leads_to", 1695 + "weight": 1.0, 1696 + "rationale": "Custom editor required IME implementation", 1697 + "created_at": "2026-01-06T09:38:26.156364183-05:00" 1698 + }, 1699 + { 1700 + "id": 38, 1701 + "from_node_id": 1, 1702 + "to_node_id": 35, 1703 + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", 1704 + "to_change_id": "874d2fd8-29bf-4137-a381-c9c14595da62", 1705 + "edge_type": "leads_to", 1706 + "weight": 1.0, 1707 + "rationale": "Weaver defines custom AT Protocol lexicons", 1708 + "created_at": "2026-01-06T09:39:02.799667493-05:00" 1709 + }, 1710 + { 1711 + "id": 39, 1712 + "from_node_id": 25, 1713 + "to_node_id": 36, 1714 + "from_change_id": "38fa6c2f-2a36-40d4-97ec-4e029501253b", 1715 + "to_change_id": "1f756beb-3275-4cb0-a1f4-df805cbc3a20", 1716 + "edge_type": "leads_to", 1717 + "weight": 1.0, 1718 + "rationale": "CRDT collab needed transport mechanism", 1719 + "created_at": "2026-01-06T09:39:02.905930366-05:00" 1720 + }, 1721 + { 1722 + "id": 40, 1723 + "from_node_id": 36, 1724 + "to_node_id": 37, 1725 + "from_change_id": "1f756beb-3275-4cb0-a1f4-df805cbc3a20", 1726 + "to_change_id": "aa268350-e43f-4b8f-8d72-2234b1af4940", 1727 + "edge_type": "chosen", 1728 + "weight": 1.0, 1729 + "rationale": "iroh chosen for P2P with relay fallback", 1730 + "created_at": "2026-01-06T09:39:03.022927850-05:00" 1731 + }, 1732 + { 1733 + "id": 41, 1734 + "from_node_id": 1, 1735 + "to_node_id": 38, 1736 + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", 1737 + "to_change_id": "68006be2-14d6-4666-b914-16c750ebd7d5", 1738 + "edge_type": "leads_to", 1739 + "weight": 1.0, 1740 + "rationale": "Static site theming was part of building weaver.sh MVP", 1741 + "created_at": "2026-01-06T09:43:26.425919855-05:00" 1742 + }, 1743 + { 1744 + "id": 42, 1745 + "from_node_id": 1, 1746 + "to_node_id": 39, 1747 + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", 1748 + "to_change_id": "2481faa6-87fb-4c6b-acc9-10d03b568d02", 1749 + "edge_type": "leads_to", 1750 + "weight": 1.0, 1751 + "rationale": "CLI integration enabled command-line static site generation", 1752 + "created_at": "2026-01-06T09:43:26.442362682-05:00" 1753 + }, 1754 + { 1755 + "id": 43, 1756 + "from_node_id": 1, 1757 + "to_node_id": 40, 1758 + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", 1759 + "to_change_id": "7d31d924-719a-4de6-af48-10d99c603d7b", 1760 + "edge_type": "leads_to", 1761 + "weight": 1.0, 1762 + "rationale": "Tests improved renderer reliability", 1763 + "created_at": "2026-01-06T09:43:26.460684943-05:00" 1764 + }, 1765 + { 1766 + "id": 44, 1767 + "from_node_id": 1, 1768 + "to_node_id": 41, 1769 + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", 1770 + "to_change_id": "fd29722c-500b-46ff-924c-bee82d766224", 1771 + "edge_type": "leads_to", 1772 + "weight": 1.0, 1773 + "rationale": "AT Protocol publishing required a rendering pipeline", 1774 + "created_at": "2026-01-06T09:43:45.127934938-05:00" 1775 + }, 1776 + { 1777 + "id": 45, 1778 + "from_node_id": 41, 1779 + "to_node_id": 42, 1780 + "from_change_id": "fd29722c-500b-46ff-924c-bee82d766224", 1781 + "to_change_id": "db80f5b5-a719-4782-b46e-8c9affc75a9d", 1782 + "edge_type": "leads_to", 1783 + "weight": 1.0, 1784 + "rationale": "chosen - uploads blobs, resolves wikilinks to canonical markdown", 1785 + "created_at": "2026-01-06T09:43:45.144240889-05:00" 1786 + }, 1787 + { 1788 + "id": 46, 1789 + "from_node_id": 41, 1790 + "to_node_id": 43, 1791 + "from_change_id": "fd29722c-500b-46ff-924c-bee82d766224", 1792 + "to_change_id": "f3bb7c28-9610-4460-9e4f-b78c9670fe09", 1793 + "edge_type": "leads_to", 1794 + "weight": 1.0, 1795 + "rationale": "chosen - renders canonical markdown to HTML in browser", 1796 + "created_at": "2026-01-06T09:43:45.160214559-05:00" 1797 + }, 1798 + { 1799 + "id": 47, 1800 + "from_node_id": 27, 1801 + "to_node_id": 44, 1802 + "from_change_id": "00233162-061d-40bc-86c7-d74fddcd2ac4", 1803 + "to_change_id": "947d69f2-7be9-403d-9f24-75d835196c37", 1804 + "edge_type": "leads_to", 1805 + "weight": 1.0, 1806 + "rationale": "Public client OAuth implementation", 1807 + "created_at": "2026-01-06T09:44:02.007365230-05:00" 1808 + }, 1809 + { 1810 + "id": 48, 1811 + "from_node_id": 1, 1812 + "to_node_id": 45, 1813 + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", 1814 + "to_change_id": "6562e367-8b54-4898-93e7-ca86c49c360d", 1815 + "edge_type": "leads_to", 1816 + "weight": 1.0, 1817 + "rationale": "Weaver needs record editing for notebook entries", 1818 + "created_at": "2026-01-06T09:44:07.910484569-05:00" 1819 + }, 1820 + { 1821 + "id": 49, 1822 + "from_node_id": 45, 1823 + "to_node_id": 46, 1824 + "from_change_id": "6562e367-8b54-4898-93e7-ca86c49c360d", 1825 + "to_change_id": "f5c8549c-5baf-46f8-a070-3af48a464a30", 1826 + "edge_type": "leads_to", 1827 + "weight": 1.0, 1828 + "rationale": "chosen - flexible editing with raw JSON and structured form options", 1829 + "created_at": "2026-01-06T09:44:07.926966009-05:00" 1830 + }, 1831 + { 1832 + "id": 50, 1833 + "from_node_id": 46, 1834 + "to_node_id": 47, 1835 + "from_change_id": "f5c8549c-5baf-46f8-a070-3af48a464a30", 1836 + "to_change_id": "a1ebe765-1b1d-4b48-bb57-0a0d94d89414", 1837 + "edge_type": "leads_to", 1838 + "weight": 1.0, 1839 + "rationale": "Pretty Editor implementation detail", 1840 + "created_at": "2026-01-06T09:44:25.108267392-05:00" 1841 + }, 1842 + { 1843 + "id": 51, 1844 + "from_node_id": 24, 1845 + "to_node_id": 48, 1846 + "from_change_id": "6b74f268-78fb-42ba-bb09-59e82496ac21", 1847 + "to_change_id": "d7ece9b4-c1b0-49c3-9dad-0ed8c2e97042", 1848 + "edge_type": "leads_to", 1849 + "weight": 1.0, 1850 + "rationale": "Editor matured, integrated throughout UI", 1851 + "created_at": "2026-01-06T09:44:25.399478423-05:00" 1852 + }, 1853 + { 1854 + "id": 52, 1855 + "from_node_id": 48, 1856 + "to_node_id": 49, 1857 + "from_change_id": "d7ece9b4-c1b0-49c3-9dad-0ed8c2e97042", 1858 + "to_change_id": "e8c967ae-7aaf-4a09-b5e0-0eebe0f75da2", 1859 + "edge_type": "leads_to", 1860 + "weight": 1.0, 1861 + "rationale": "Editor integration revealed draft system issues that needed fixing", 1862 + "created_at": "2026-01-06T09:44:40.273784561-05:00" 1863 + }, 1864 + { 1865 + "id": 53, 1866 + "from_node_id": 24, 1867 + "to_node_id": 50, 1868 + "from_change_id": "6b74f268-78fb-42ba-bb09-59e82496ac21", 1869 + "to_change_id": "49e83f50-4296-433d-b27e-2479353eb214", 1870 + "edge_type": "leads_to", 1871 + "weight": 1.0, 1872 + "rationale": "Editor maturity required fixing deep rendering bugs", 1873 + "created_at": "2026-01-06T09:44:57.677654440-05:00" 1874 + }, 1875 + { 1876 + "id": 54, 1877 + "from_node_id": 50, 1878 + "to_node_id": 51, 1879 + "from_change_id": "49e83f50-4296-433d-b27e-2479353eb214", 1880 + "to_change_id": "3862b5d9-17ec-4a8c-9eb3-e70270ef2bbc", 1881 + "edge_type": "leads_to", 1882 + "weight": 1.0, 1883 + "rationale": "Render redesign surfaced additional cursor issues", 1884 + "created_at": "2026-01-06T09:44:57.694123076-05:00" 1885 + }, 1886 + { 1887 + "id": 55, 1888 + "from_node_id": 1, 1889 + "to_node_id": 52, 1890 + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", 1891 + "to_change_id": "befcef0e-c831-4d2d-8454-499b69715dac", 1892 + "edge_type": "leads_to", 1893 + "weight": 1.0, 1894 + "rationale": "Weaver needs accessibility compliance for public release", 1895 + "created_at": "2026-01-06T09:45:23.705592893-05:00" 1896 + }, 1897 + { 1898 + "id": 56, 1899 + "from_node_id": 52, 1900 + "to_node_id": 53, 1901 + "from_change_id": "befcef0e-c831-4d2d-8454-499b69715dac", 1902 + "to_change_id": "32129a65-4d74-4aba-ae82-e16b98552eda", 1903 + "edge_type": "leads_to", 1904 + "weight": 1.0, 1905 + "rationale": "Gap analysis identified specific requirements", 1906 + "created_at": "2026-01-06T09:45:23.721865873-05:00" 1907 + }, 1908 + { 1909 + "id": 57, 1910 + "from_node_id": 7, 1911 + "to_node_id": 54, 1912 + "from_change_id": "5688af7d-61e5-4b47-afaa-05ee5bff36ae", 1913 + "to_change_id": "d27b41f2-3146-4493-9216-d33e36177b7f", 1914 + "edge_type": "leads_to", 1915 + "weight": 1.0, 1916 + "rationale": "Custom editor needs consistent offset handling across writers", 1917 + "created_at": "2026-01-06T09:45:31.352850673-05:00" 1918 + }, 1919 + { 1920 + "id": 58, 1921 + "from_node_id": 25, 1922 + "to_node_id": 55, 1923 + "from_change_id": "38fa6c2f-2a36-40d4-97ec-4e029501253b", 1924 + "to_change_id": "77a50102-1dc0-4009-9715-5c8644745be1", 1925 + "edge_type": "leads_to", 1926 + "weight": 1.0, 1927 + "rationale": "CRDT collab working but needs architecture improvement for bundle size", 1928 + "created_at": "2026-01-06T09:45:48.961450680-05:00" 1929 + }, 1930 + { 1931 + "id": 59, 1932 + "from_node_id": 18, 1933 + "to_node_id": 56, 1934 + "from_change_id": "fa554b5d-8af7-42e4-b03f-e5bec837e31a", 1935 + "to_change_id": "128ce435-efb2-4608-82a7-771702bbc151", 1936 + "edge_type": "leads_to", 1937 + "weight": 1.0, 1938 + "rationale": "Editor extraction requires LoroBuffer implementing core traits", 1939 + "created_at": "2026-01-06T09:46:07.879401852-05:00" 1940 + }, 1941 + { 1942 + "id": 60, 1943 + "from_node_id": 17, 1944 + "to_node_id": 57, 1945 + "from_change_id": "af0827fd-530e-498c-88ad-082a648a93f4", 1946 + "to_change_id": "6ae02804-d3a1-47fb-a51f-e55ce87cf6d3", 1947 + "edge_type": "leads_to", 1948 + "weight": 1.0, 1949 + "rationale": "Dual-backend architecture implementation detail", 1950 + "created_at": "2026-01-06T09:46:08.092795177-05:00" 1951 + }, 1952 + { 1953 + "id": 61, 1954 + "from_node_id": 10, 1955 + "to_node_id": 58, 1956 + "from_change_id": "5738a9b1-2767-4aab-ad59-b7fe9ee6728e", 1957 + "to_change_id": "a12049af-71cd-4392-b4d9-4da89c22f221", 1958 + "edge_type": "leads_to", 1959 + "weight": 1.0, 1960 + "rationale": "JumpRope was initial choice for editor text storage", 1961 + "created_at": "2026-01-06T09:50:04.359801349-05:00" 1962 + }, 1963 + { 1964 + "id": 62, 1965 + "from_node_id": 58, 1966 + "to_node_id": 59, 1967 + "from_change_id": "a12049af-71cd-4392-b4d9-4da89c22f221", 1968 + "to_change_id": "0dcb444b-61cb-41f2-aa1e-2cb3b5b3f23e", 1969 + "edge_type": "leads_to", 1970 + "weight": 1.0, 1971 + "rationale": "JumpRope lacked undo/collab, leading to Loro investigation", 1972 + "created_at": "2026-01-06T09:50:04.582699646-05:00" 1973 + }, 1974 + { 1975 + "id": 63, 1976 + "from_node_id": 59, 1977 + "to_node_id": 32, 1978 + "from_change_id": "0dcb444b-61cb-41f2-aa1e-2cb3b5b3f23e", 1979 + "to_change_id": "60829366-0740-47cc-9101-4057c9c7489e", 1980 + "edge_type": "leads_to", 1981 + "weight": 1.0, 1982 + "rationale": "Investigation led to Loro migration decision", 1983 + "created_at": "2026-01-06T09:50:04.599510915-05:00" 1984 + }, 1985 + { 1986 + "id": 64, 1987 + "from_node_id": 6, 1988 + "to_node_id": 60, 1989 + "from_change_id": "ef3edcf6-e8a8-4d48-9e9e-3885abb53a30", 1990 + "to_change_id": "15c2d75c-3fd9-45a4-a714-2cbbed916273", 1991 + "edge_type": "leads_to", 1992 + "weight": 1.0, 1993 + "rationale": "Sidenotes are part of weaver-flavoured markdown dialect", 1994 + "created_at": "2026-01-06T09:50:13.965131193-05:00" 1995 + }, 1996 + { 1997 + "id": 65, 1998 + "from_node_id": 50, 1999 + "to_node_id": 61, 2000 + "from_change_id": "49e83f50-4296-433d-b27e-2479353eb214", 2001 + "to_change_id": "57b416e2-9b1e-41a6-b02f-46fe0f604dae", 2002 + "edge_type": "leads_to", 2003 + "weight": 1.0, 2004 + "rationale": "Render pipeline redesign led to segmented writer refactor", 2005 + "created_at": "2026-01-06T09:50:34.108469745-05:00" 2006 + }, 2007 + { 2008 + "id": 66, 2009 + "from_node_id": 44, 2010 + "to_node_id": 62, 2011 + "from_change_id": "947d69f2-7be9-403d-9f24-75d835196c37", 2012 + "to_change_id": "13053a77-0246-4382-83ea-251c9bd5fd48", 2013 + "edge_type": "leads_to", 2014 + "weight": 1.0, 2015 + "rationale": "OAuth tokens need secure browser storage", 2016 + "created_at": "2026-01-06T09:50:34.462256627-05:00" 2017 + }, 2018 + { 2019 + "id": 67, 2020 + "from_node_id": 62, 2021 + "to_node_id": 63, 2022 + "from_change_id": "13053a77-0246-4382-83ea-251c9bd5fd48", 2023 + "to_change_id": "9ddb878e-f0e5-4357-b863-6b4796148478", 2024 + "edge_type": "leads_to", 2025 + "weight": 1.0, 2026 + "rationale": "chosen - neither share alone reveals secret, data dies with tab", 2027 + "created_at": "2026-01-06T09:50:34.478835090-05:00" 2028 + }, 2029 + { 2030 + "id": 68, 2031 + "from_node_id": 57, 2032 + "to_node_id": 64, 2033 + "from_change_id": "6ae02804-d3a1-47fb-a51f-e55ce87cf6d3", 2034 + "to_change_id": "0e03638a-7ed3-4bc9-9aa4-06484a765c93", 2035 + "edge_type": "leads_to", 2036 + "weight": 1.0, 2037 + "rationale": "Dual-backend requires clear endpoint-to-backend mapping", 2038 + "created_at": "2026-01-06T09:50:40.860066365-05:00" 2039 + }, 2040 + { 2041 + "id": 69, 2042 + "from_node_id": 32, 2043 + "to_node_id": 65, 2044 + "from_change_id": "60829366-0740-47cc-9101-4057c9c7489e", 2045 + "to_change_id": "e35bf396-19ec-4f18-8227-044fd7e099a5", 2046 + "edge_type": "leads_to", 2047 + "weight": 1.0, 2048 + "rationale": "Loro migration enabled full entry schema in CRDT", 2049 + "created_at": "2026-01-06T09:50:59.634171197-05:00" 2050 + }, 2051 + { 2052 + "id": 70, 2053 + "from_node_id": 24, 2054 + "to_node_id": 66, 2055 + "from_change_id": "6b74f268-78fb-42ba-bb09-59e82496ac21", 2056 + "to_change_id": "d5463518-b69f-40d0-a6dc-aec0bd78a074", 2057 + "edge_type": "leads_to", 2058 + "weight": 1.0, 2059 + "rationale": "Performance optimization for cursor movement", 2060 + "created_at": "2026-01-06T09:50:59.894918979-05:00" 2061 + }, 2062 + { 2063 + "id": 71, 2064 + "from_node_id": 22, 2065 + "to_node_id": 67, 2066 + "from_change_id": "40dd01d3-768c-4b31-8baf-7d027079d73f", 2067 + "to_change_id": "3db38b72-cd99-41ae-b1ae-9ab5d7445b06", 2068 + "edge_type": "leads_to", 2069 + "weight": 1.0, 2070 + "rationale": "App live but accumulated technical debt during rapid development", 2071 + "created_at": "2026-01-06T09:51:20.892278178-05:00" 2072 + }, 2073 + { 2074 + "id": 72, 2075 + "from_node_id": 57, 2076 + "to_node_id": 68, 2077 + "from_change_id": "6ae02804-d3a1-47fb-a51f-e55ce87cf6d3", 2078 + "to_change_id": "87027254-16fb-40e4-a0fc-cc9e2089a10b", 2079 + "edge_type": "leads_to", 2080 + "weight": 1.0, 2081 + "rationale": "Dual-backend requires concurrency strategy for SQLite shards", 2082 + "created_at": "2026-01-06T09:51:21.256537187-05:00" 2083 + }, 2084 + { 2085 + "id": 73, 2086 + "from_node_id": 68, 2087 + "to_node_id": 69, 2088 + "from_change_id": "87027254-16fb-40e4-a0fc-cc9e2089a10b", 2089 + "to_change_id": "909bbc20-cf7c-4b8d-878f-d8ae7ee6d3a9", 2090 + "edge_type": "leads_to", 2091 + "weight": 1.0, 2092 + "rationale": "chosen - simplest approach, tokio manages blocking threads", 2093 + "created_at": "2026-01-06T09:51:21.272721102-05:00" 2094 + }, 2095 + { 2096 + "id": 74, 2097 + "from_node_id": 18, 2098 + "to_node_id": 70, 2099 + "from_change_id": "fa554b5d-8af7-42e4-b03f-e5bec837e31a", 2100 + "to_change_id": "42a7505c-c593-4fa2-839f-ca7331324c4e", 2101 + "edge_type": "leads_to", 2102 + "weight": 1.0, 2103 + "rationale": "Editor extraction requires storage abstraction", 2104 + "created_at": "2026-01-06T09:52:00.439471372-05:00" 2105 + }, 2106 + { 2107 + "id": 75, 2108 + "from_node_id": 70, 2109 + "to_node_id": 71, 2110 + "from_change_id": "42a7505c-c593-4fa2-839f-ca7331324c4e", 2111 + "to_change_id": "926e5719-d3dc-422b-ba65-af723e0fa49a", 2112 + "edge_type": "leads_to", 2113 + "weight": 1.0, 2114 + "rationale": "chosen - enables swapping between local ropey and collaborative Loro", 2115 + "created_at": "2026-01-06T09:52:00.455990474-05:00" 2116 + }, 2117 + { 2118 + "id": 76, 2119 + "from_node_id": 71, 2120 + "to_node_id": 56, 2121 + "from_change_id": "926e5719-d3dc-422b-ba65-af723e0fa49a", 2122 + "to_change_id": "128ce435-efb2-4608-82a7-771702bbc151", 2123 + "edge_type": "leads_to", 2124 + "weight": 1.0, 2125 + "rationale": "LoroBuffer implements TextBuffer trait", 2126 + "created_at": "2026-01-06T09:52:00.579222711-05:00" 2127 + }, 2128 + { 2129 + "id": 77, 2130 + "from_node_id": 1, 2131 + "to_node_id": 72, 2132 + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", 2133 + "to_change_id": "a796cf1c-7ac7-497b-a995-54b57a4e2c30", 2134 + "edge_type": "leads_to", 2135 + "weight": 1.0, 2136 + "rationale": "UI polish for weaver.sh user experience", 2137 + "created_at": "2026-01-06T09:53:17.613679825-05:00" 2138 + }, 2139 + { 2140 + "id": 78, 2141 + "from_node_id": 70, 2142 + "to_node_id": 73, 2143 + "from_change_id": "42a7505c-c593-4fa2-839f-ca7331324c4e", 2144 + "to_change_id": "2302f391-d481-42a8-8bce-e54c29f6dfe1", 2145 + "edge_type": "leads_to", 2146 + "weight": 1.0, 2147 + "rationale": "TextBuffer extraction progressing - nearly production ready", 2148 + "created_at": "2026-01-06T09:58:23.037192067-05:00" 2149 + }, 2150 + { 2151 + "id": 79, 2152 + "from_node_id": 24, 2153 + "to_node_id": 74, 2154 + "from_change_id": "6b74f268-78fb-42ba-bb09-59e82496ac21", 2155 + "to_change_id": "a6997f6e-06a6-4c9d-ab24-b3ea114207d6", 2156 + "edge_type": "leads_to", 2157 + "weight": 1.0, 2158 + "rationale": "Current editor implementation state observation", 2159 + "created_at": "2026-01-06T09:58:23.150949542-05:00" 2160 + }, 2161 + { 2162 + "id": 80, 2163 + "from_node_id": 57, 2164 + "to_node_id": 75, 2165 + "from_change_id": "6ae02804-d3a1-47fb-a51f-e55ce87cf6d3", 2166 + "to_change_id": "a1998766-9d7a-4e59-ba54-881a2626244c", 2167 + "edge_type": "leads_to", 2168 + "weight": 1.0, 2169 + "rationale": "Dual-backend architecture fully implemented", 2170 + "created_at": "2026-01-06T09:58:23.253017405-05:00" 2171 + }, 2172 + { 2173 + "id": 81, 2174 + "from_node_id": 6, 2175 + "to_node_id": 76, 2176 + "from_change_id": "ef3edcf6-e8a8-4d48-9e9e-3885abb53a30", 2177 + "to_change_id": "2d09062b-8e32-4e85-92ed-cb2dfa15ddec", 2178 + "edge_type": "leads_to", 2179 + "weight": 1.0, 2180 + "rationale": "Lexicon definitions implementing the weaver dialect", 2181 + "created_at": "2026-01-06T09:58:23.355022791-05:00" 2182 + }, 2183 + { 2184 + "id": 82, 2185 + "from_node_id": 36, 2186 + "to_node_id": 77, 2187 + "from_change_id": "1f756beb-3275-4cb0-a1f4-df805cbc3a20", 2188 + "to_change_id": "ef6e7454-2d97-4fe9-97a8-76c2486cf076", 2189 + "edge_type": "leads_to", 2190 + "weight": 1.0, 2191 + "rationale": "Iroh P2P with DERP relay selected and fully implemented", 2192 + "created_at": "2026-01-06T10:36:01.096867537-05:00" 2193 + }, 2194 + { 2195 + "id": 83, 2196 + "from_node_id": 55, 2197 + "to_node_id": 77, 2198 + "from_change_id": "77a50102-1dc0-4009-9715-5c8644745be1", 2199 + "to_change_id": "ef6e7454-2d97-4fe9-97a8-76c2486cf076", 2200 + "edge_type": "leads_to", 2201 + "weight": 1.0, 2202 + "rationale": "Worker architecture design realized in implementation", 2203 + "created_at": "2026-01-06T10:36:01.202154640-05:00" 2204 + }, 2205 + { 2206 + "id": 84, 2207 + "from_node_id": 77, 2208 + "to_node_id": 78, 2209 + "from_change_id": "ef6e7454-2d97-4fe9-97a8-76c2486cf076", 2210 + "to_change_id": "3af41d3d-93c0-4a8b-a66b-5af3b7c0f048", 2211 + "edge_type": "leads_to", 2212 + "weight": 1.0, 2213 + "rationale": "Transport implementation enables full collab stack", 2214 + "created_at": "2026-01-06T10:36:12.709951736-05:00" 2215 + }, 2216 + { 2217 + "id": 85, 2218 + "from_node_id": 25, 2219 + "to_node_id": 78, 2220 + "from_change_id": "38fa6c2f-2a36-40d4-97ec-4e029501253b", 2221 + "to_change_id": "3af41d3d-93c0-4a8b-a66b-5af3b7c0f048", 2222 + "edge_type": "leads_to", 2223 + "weight": 1.0, 2224 + "rationale": "Loro CRDT + iroh transport = complete collab system", 2225 + "created_at": "2026-01-06T10:36:12.808476008-05:00" 2226 + }, 2227 + { 2228 + "id": 86, 2229 + "from_node_id": 18, 2230 + "to_node_id": 79, 2231 + "from_change_id": "fa554b5d-8af7-42e4-b03f-e5bec837e31a", 2232 + "to_change_id": "33d80413-0ab3-4955-915e-c8aca65cdfc1", 2233 + "edge_type": "leads_to", 2234 + "weight": 1.0, 2235 + "rationale": "Current progress on extraction goal", 2236 + "created_at": "2026-01-06T10:36:27.102987828-05:00" 2237 + }, 2238 + { 2239 + "id": 87, 2240 + "from_node_id": 73, 2241 + "to_node_id": 79, 2242 + "from_change_id": "2302f391-d481-42a8-8bce-e54c29f6dfe1", 2243 + "to_change_id": "33d80413-0ab3-4955-915e-c8aca65cdfc1", 2244 + "edge_type": "leads_to", 2245 + "weight": 1.0, 2246 + "rationale": "Detailed breakdown of 'nearly complete' status", 2247 + "created_at": "2026-01-06T10:36:27.205172937-05:00" 2248 + }, 2249 + { 2250 + "id": 88, 2251 + "from_node_id": 12, 2252 + "to_node_id": 80, 2253 + "from_change_id": "aef81c95-7a98-4dc6-bdc8-ab1df9823db8", 2254 + "to_change_id": "dcdeb4a1-2dcd-45a2-9038-ec5b59e1b1d2", 2255 + "edge_type": "leads_to", 2256 + "weight": 1.0, 2257 + "rationale": "Loro option realized with clean architecture separation", 2258 + "created_at": "2026-01-06T10:36:38.490073168-05:00" 2259 + }, 2260 + { 2261 + "id": 89, 2262 + "from_node_id": 70, 2263 + "to_node_id": 80, 2264 + "from_change_id": "42a7505c-c593-4fa2-839f-ca7331324c4e", 2265 + "to_change_id": "dcdeb4a1-2dcd-45a2-9038-ec5b59e1b1d2", 2266 + "edge_type": "leads_to", 2267 + "weight": 1.0, 2268 + "rationale": "TextBuffer abstraction enables Loro-free core", 2269 + "created_at": "2026-01-06T10:36:38.592033235-05:00" 2270 + }, 2271 + { 2272 + "id": 90, 2273 + "from_node_id": 81, 2274 + "to_node_id": 82, 2275 + "from_change_id": "5f00148d-b487-40fb-b4b4-66b8d2489e91", 2276 + "to_change_id": "252f6497-56a2-4f10-ab8c-e7865960c481", 2277 + "edge_type": "leads_to", 2278 + "weight": 1.0, 2279 + "rationale": "browser crate component", 2280 + "created_at": "2026-01-06T10:40:03.196664248-05:00" 2281 + }, 2282 + { 2283 + "id": 91, 2284 + "from_node_id": 81, 2285 + "to_node_id": 83, 2286 + "from_change_id": "5f00148d-b487-40fb-b4b4-66b8d2489e91", 2287 + "to_change_id": "63719097-0bec-4805-aa5c-6527256ef708", 2288 + "edge_type": "leads_to", 2289 + "weight": 1.0, 2290 + "rationale": "browser crate component", 2291 + "created_at": "2026-01-06T10:40:03.213042533-05:00" 2292 + }, 2293 + { 2294 + "id": 92, 2295 + "from_node_id": 81, 2296 + "to_node_id": 84, 2297 + "from_change_id": "5f00148d-b487-40fb-b4b4-66b8d2489e91", 2298 + "to_change_id": "6c25dd34-ac4d-49d1-88d2-13da6b93c872", 2299 + "edge_type": "leads_to", 2300 + "weight": 1.0, 2301 + "rationale": "browser crate component", 2302 + "created_at": "2026-01-06T10:40:03.229908179-05:00" 2303 + }, 2304 + { 2305 + "id": 93, 2306 + "from_node_id": 81, 2307 + "to_node_id": 85, 2308 + "from_change_id": "5f00148d-b487-40fb-b4b4-66b8d2489e91", 2309 + "to_change_id": "b6e15092-6258-4ca6-9c1b-d1dc58280a92", 2310 + "edge_type": "leads_to", 2311 + "weight": 1.0, 2312 + "rationale": "browser crate component", 2313 + "created_at": "2026-01-06T10:40:03.246188772-05:00" 2314 + }, 2315 + { 2316 + "id": 94, 2317 + "from_node_id": 81, 2318 + "to_node_id": 86, 2319 + "from_change_id": "5f00148d-b487-40fb-b4b4-66b8d2489e91", 2320 + "to_change_id": "c50d83f9-d6cb-4697-a24c-50a7707d395c", 2321 + "edge_type": "leads_to", 2322 + "weight": 1.0, 2323 + "rationale": "browser crate component", 2324 + "created_at": "2026-01-06T10:40:03.262252879-05:00" 2325 + }, 2326 + { 2327 + "id": 95, 2328 + "from_node_id": 18, 2329 + "to_node_id": 81, 2330 + "from_change_id": "fa554b5d-8af7-42e4-b03f-e5bec837e31a", 2331 + "to_change_id": "5f00148d-b487-40fb-b4b4-66b8d2489e91", 2332 + "edge_type": "leads_to", 2333 + "weight": 1.0, 2334 + "rationale": "browser crate is part of extraction", 2335 + "created_at": "2026-01-06T10:40:03.365630222-05:00" 2336 + }, 2337 + { 2338 + "id": 96, 2339 + "from_node_id": 87, 2340 + "to_node_id": 88, 2341 + "from_change_id": "1d5c50c0-bfeb-4218-88ab-405d12ba0607", 2342 + "to_change_id": "ed566bdc-7372-4ea5-966c-a7f10e2c5453", 2343 + "edge_type": "leads_to", 2344 + "weight": 1.0, 2345 + "rationale": "crdt crate component", 2346 + "created_at": "2026-01-06T10:40:19.731408592-05:00" 2347 + }, 2348 + { 2349 + "id": 98, 2350 + "from_node_id": 87, 2351 + "to_node_id": 90, 2352 + "from_change_id": "1d5c50c0-bfeb-4218-88ab-405d12ba0607", 2353 + "to_change_id": "cdee4656-7495-4a9c-8d24-0f232941763e", 2354 + "edge_type": "leads_to", 2355 + "weight": 1.0, 2356 + "rationale": "crdt crate component", 2357 + "created_at": "2026-01-06T10:40:19.765135356-05:00" 2358 + }, 2359 + { 2360 + "id": 99, 2361 + "from_node_id": 18, 2362 + "to_node_id": 87, 2363 + "from_change_id": "fa554b5d-8af7-42e4-b03f-e5bec837e31a", 2364 + "to_change_id": "1d5c50c0-bfeb-4218-88ab-405d12ba0607", 2365 + "edge_type": "leads_to", 2366 + "weight": 1.0, 2367 + "rationale": "crdt crate is part of extraction", 2368 + "created_at": "2026-01-06T10:40:19.869970125-05:00" 2369 + }, 2370 + { 2371 + "id": 100, 2372 + "from_node_id": 91, 2373 + "to_node_id": 92, 2374 + "from_change_id": "57d3f2a1-bd8d-45f1-8834-97bac872dae8", 2375 + "to_change_id": "3d62e1bf-b43c-4924-b32e-2a5190b2ed0e", 2376 + "edge_type": "leads_to", 2377 + "weight": 1.0, 2378 + "rationale": "Phase 2 task", 2379 + "created_at": "2026-01-06T10:42:23.415671952-05:00" 2380 + }, 2381 + { 2382 + "id": 101, 2383 + "from_node_id": 91, 2384 + "to_node_id": 93, 2385 + "from_change_id": "57d3f2a1-bd8d-45f1-8834-97bac872dae8", 2386 + "to_change_id": "64a17922-3079-4e23-a8f2-114758a3ed6d", 2387 + "edge_type": "leads_to", 2388 + "weight": 1.0, 2389 + "rationale": "Phase 2 task", 2390 + "created_at": "2026-01-06T10:42:23.431774230-05:00" 2391 + }, 2392 + { 2393 + "id": 102, 2394 + "from_node_id": 91, 2395 + "to_node_id": 94, 2396 + "from_change_id": "57d3f2a1-bd8d-45f1-8834-97bac872dae8", 2397 + "to_change_id": "f7f521a5-db62-43fd-8e77-bff00524c9c3", 2398 + "edge_type": "leads_to", 2399 + "weight": 1.0, 2400 + "rationale": "Phase 2 task", 2401 + "created_at": "2026-01-06T10:42:23.448388245-05:00" 2402 + }, 2403 + { 2404 + "id": 103, 2405 + "from_node_id": 18, 2406 + "to_node_id": 91, 2407 + "from_change_id": "fa554b5d-8af7-42e4-b03f-e5bec837e31a", 2408 + "to_change_id": "57d3f2a1-bd8d-45f1-8834-97bac872dae8", 2409 + "edge_type": "leads_to", 2410 + "weight": 1.0, 2411 + "rationale": "Phase 2 follows extraction", 2412 + "created_at": "2026-01-06T10:42:23.559182344-05:00" 2413 + }, 2414 + { 2415 + "id": 104, 2416 + "from_node_id": 95, 2417 + "to_node_id": 96, 2418 + "from_change_id": "a0bde70d-28e9-4a27-9bc7-814415c4d61a", 2419 + "to_change_id": "7917c340-2614-40e5-96b3-ec569aca0053", 2420 + "edge_type": "leads_to", 2421 + "weight": 1.0, 2422 + "rationale": "Phase 3 task", 2423 + "created_at": "2026-01-06T10:42:51.789378301-05:00" 2424 + }, 2425 + { 2426 + "id": 105, 2427 + "from_node_id": 95, 2428 + "to_node_id": 97, 2429 + "from_change_id": "a0bde70d-28e9-4a27-9bc7-814415c4d61a", 2430 + "to_change_id": "32765e84-b76d-45cf-940e-28bc35e14339", 2431 + "edge_type": "leads_to", 2432 + "weight": 1.0, 2433 + "rationale": "Phase 3 task", 2434 + "created_at": "2026-01-06T10:42:51.805461864-05:00" 2435 + }, 2436 + { 2437 + "id": 106, 2438 + "from_node_id": 95, 2439 + "to_node_id": 98, 2440 + "from_change_id": "a0bde70d-28e9-4a27-9bc7-814415c4d61a", 2441 + "to_change_id": "371419cc-a552-41d7-9f4e-3239b4d8b5ad", 2442 + "edge_type": "leads_to", 2443 + "weight": 1.0, 2444 + "rationale": "Phase 3 task", 2445 + "created_at": "2026-01-06T10:42:51.821821232-05:00" 2446 + }, 2447 + { 2448 + "id": 107, 2449 + "from_node_id": 18, 2450 + "to_node_id": 95, 2451 + "from_change_id": "fa554b5d-8af7-42e4-b03f-e5bec837e31a", 2452 + "to_change_id": "a0bde70d-28e9-4a27-9bc7-814415c4d61a", 2453 + "edge_type": "leads_to", 2454 + "weight": 1.0, 2455 + "rationale": "Phase 3 follows extraction", 2456 + "created_at": "2026-01-06T10:42:51.920361430-05:00" 2457 + }, 2458 + { 2459 + "id": 108, 2460 + "from_node_id": 30, 2461 + "to_node_id": 95, 2462 + "from_change_id": "2eeb7345-a8a2-4146-bda5-1f19cfc7501d", 2463 + "to_change_id": "a0bde70d-28e9-4a27-9bc7-814415c4d61a", 2464 + "edge_type": "leads_to", 2465 + "weight": 1.0, 2466 + "rationale": "JS bindings goal realized by Phase 3", 2467 + "created_at": "2026-01-06T10:42:51.936792833-05:00" 2468 + }, 2469 + { 2470 + "id": 109, 2471 + "from_node_id": 70, 2472 + "to_node_id": 99, 2473 + "from_change_id": "42a7505c-c593-4fa2-839f-ca7331324c4e", 2474 + "to_change_id": "e1275977-76fd-4a21-89d2-1badd40054a2", 2475 + "edge_type": "leads_to", 2476 + "weight": 1.0, 2477 + "rationale": "EditorDocument trait builds on TextBuffer abstraction", 2478 + "created_at": "2026-01-06T10:44:26.376774105-05:00" 2479 + }, 2480 + { 2481 + "id": 110, 2482 + "from_node_id": 99, 2483 + "to_node_id": 88, 2484 + "from_change_id": "e1275977-76fd-4a21-89d2-1badd40054a2", 2485 + "to_change_id": "ed566bdc-7372-4ea5-966c-a7f10e2c5453", 2486 + "edge_type": "leads_to", 2487 + "weight": 1.0, 2488 + "rationale": "LoroDocument is an impl", 2489 + "created_at": "2026-01-06T10:44:26.393929332-05:00" 2490 + }, 2491 + { 2492 + "id": 111, 2493 + "from_node_id": 99, 2494 + "to_node_id": 92, 2495 + "from_change_id": "e1275977-76fd-4a21-89d2-1badd40054a2", 2496 + "to_change_id": "3d62e1bf-b43c-4924-b32e-2a5190b2ed0e", 2497 + "edge_type": "leads_to", 2498 + "weight": 1.0, 2499 + "rationale": "SignalEditorDocument wraps an impl", 2500 + "created_at": "2026-01-06T10:44:26.410025086-05:00" 2501 + }, 2502 + { 2503 + "id": 112, 2504 + "from_node_id": 100, 2505 + "to_node_id": 101, 2506 + "from_change_id": "e48d466a-1b04-4da7-a9db-cd0abe3d8ad2", 2507 + "to_change_id": "efd6969a-aa78-42fb-9238-a8ba0e474b3d", 2508 + "edge_type": "leads_to", 2509 + "weight": 1.0, 2510 + "rationale": "option", 2511 + "created_at": "2026-01-06T10:44:42.717755231-05:00" 2512 + }, 2513 + { 2514 + "id": 113, 2515 + "from_node_id": 100, 2516 + "to_node_id": 102, 2517 + "from_change_id": "e48d466a-1b04-4da7-a9db-cd0abe3d8ad2", 2518 + "to_change_id": "384d5ccb-73bc-40c3-901a-b4894809d67d", 2519 + "edge_type": "leads_to", 2520 + "weight": 1.0, 2521 + "rationale": "option", 2522 + "created_at": "2026-01-06T10:44:42.734135618-05:00" 2523 + }, 2524 + { 2525 + "id": 114, 2526 + "from_node_id": 99, 2527 + "to_node_id": 100, 2528 + "from_change_id": "e1275977-76fd-4a21-89d2-1badd40054a2", 2529 + "to_change_id": "e48d466a-1b04-4da7-a9db-cd0abe3d8ad2", 2530 + "edge_type": "leads_to", 2531 + "weight": 1.0, 2532 + "rationale": "design question from EditorDocument pattern", 2533 + "created_at": "2026-01-06T10:44:42.750688578-05:00" 2534 + }, 2535 + { 2536 + "id": 115, 2537 + "from_node_id": 95, 2538 + "to_node_id": 100, 2539 + "from_change_id": "a0bde70d-28e9-4a27-9bc7-814415c4d61a", 2540 + "to_change_id": "e48d466a-1b04-4da7-a9db-cd0abe3d8ad2", 2541 + "edge_type": "leads_to", 2542 + "weight": 1.0, 2543 + "rationale": "affects JS bindings design", 2544 + "created_at": "2026-01-06T10:44:42.767220258-05:00" 2545 + }, 2546 + { 2547 + "id": 116, 2548 + "from_node_id": 103, 2549 + "to_node_id": 104, 2550 + "from_change_id": "a83f0c2c-ede4-4407-a048-535462783960", 2551 + "to_change_id": "1fb0e2b9-f6e7-4e1d-a514-8b370712c232", 2552 + "edge_type": "leads_to", 2553 + "weight": 1.0, 2554 + "rationale": "option", 2555 + "created_at": "2026-01-06T11:09:12.835452317-05:00" 2556 + }, 2557 + { 2558 + "id": 117, 2559 + "from_node_id": 103, 2560 + "to_node_id": 105, 2561 + "from_change_id": "a83f0c2c-ede4-4407-a048-535462783960", 2562 + "to_change_id": "29fef523-1eb5-497c-905a-16dec60b30df", 2563 + "edge_type": "leads_to", 2564 + "weight": 1.0, 2565 + "rationale": "option", 2566 + "created_at": "2026-01-06T11:09:12.851576977-05:00" 2567 + }, 2568 + { 2569 + "id": 118, 2570 + "from_node_id": 103, 2571 + "to_node_id": 106, 2572 + "from_change_id": "a83f0c2c-ede4-4407-a048-535462783960", 2573 + "to_change_id": "ba8b0f30-09f9-437d-b690-b5053b9991ad", 2574 + "edge_type": "leads_to", 2575 + "weight": 1.0, 2576 + "rationale": "option", 2577 + "created_at": "2026-01-06T11:09:12.867846759-05:00" 2578 + }, 2579 + { 2580 + "id": 119, 2581 + "from_node_id": 107, 2582 + "to_node_id": 103, 2583 + "from_change_id": "11c284c0-37c2-4736-aa4e-479f77b7c5be", 2584 + "to_change_id": "a83f0c2c-ede4-4407-a048-535462783960", 2585 + "edge_type": "leads_to", 2586 + "weight": 1.0, 2587 + "rationale": "observation informs decision", 2588 + "created_at": "2026-01-06T11:09:17.470517267-05:00" 2589 + }, 2590 + { 2591 + "id": 120, 2592 + "from_node_id": 81, 2593 + "to_node_id": 103, 2594 + "from_change_id": "5f00148d-b487-40fb-b4b4-66b8d2489e91", 2595 + "to_change_id": "a83f0c2c-ede4-4407-a048-535462783960", 2596 + "edge_type": "leads_to", 2597 + "weight": 1.0, 2598 + "rationale": "browser crate goal blocked by this decision", 2599 + "created_at": "2026-01-06T11:09:17.487429520-05:00" 2600 + }, 2601 + { 2602 + "id": 121, 2603 + "from_node_id": 103, 2604 + "to_node_id": 108, 2605 + "from_change_id": "a83f0c2c-ede4-4407-a048-535462783960", 2606 + "to_change_id": "86195ee9-4147-4874-8f89-7a0e882a5132", 2607 + "edge_type": "leads_to", 2608 + "weight": 1.0, 2609 + "rationale": "decision resolved", 2610 + "created_at": "2026-01-06T11:17:51.661390358-05:00" 2611 + }, 2612 + { 2613 + "id": 122, 2614 + "from_node_id": 108, 2615 + "to_node_id": 109, 2616 + "from_change_id": "86195ee9-4147-4874-8f89-7a0e882a5132", 2617 + "to_change_id": "57096f55-c0fd-4c6c-a45f-7877def5ef70", 2618 + "edge_type": "leads_to", 2619 + "weight": 1.0, 2620 + "rationale": "implies deferral", 2621 + "created_at": "2026-01-06T11:17:51.677589947-05:00" 2622 + }, 2623 + { 2624 + "id": 123, 2625 + "from_node_id": 81, 2626 + "to_node_id": 110, 2627 + "from_change_id": "5f00148d-b487-40fb-b4b4-66b8d2489e91", 2628 + "to_change_id": "4dfdbea5-6bcd-4377-90c1-a35e68b59d11", 2629 + "edge_type": "leads_to", 2630 + "weight": 1.0, 2631 + "rationale": "browser crate feature", 2632 + "created_at": "2026-01-06T11:17:51.694176679-05:00" 2633 + }, 2634 + { 2635 + "id": 124, 2636 + "from_node_id": 87, 2637 + "to_node_id": 111, 2638 + "from_change_id": "1d5c50c0-bfeb-4218-88ab-405d12ba0607", 2639 + "to_change_id": "d05ddf03-4487-4fc6-932c-eb91e58b1ebf", 2640 + "edge_type": "leads_to", 2641 + "weight": 1.0, 2642 + "rationale": "affects crdt crate scope", 2643 + "created_at": "2026-01-06T11:17:51.710191893-05:00" 2644 + }, 2645 + { 2646 + "id": 125, 2647 + "from_node_id": 87, 2648 + "to_node_id": 112, 2649 + "from_change_id": "1d5c50c0-bfeb-4218-88ab-405d12ba0607", 2650 + "to_change_id": "1b0e70cb-ebed-4a59-8a94-2314491a9e84", 2651 + "edge_type": "leads_to", 2652 + "weight": 1.0, 2653 + "rationale": "crdt crate component", 2654 + "created_at": "2026-01-06T11:18:09.538809855-05:00" 2655 + }, 2656 + { 2657 + "id": 126, 2658 + "from_node_id": 87, 2659 + "to_node_id": 113, 2660 + "from_change_id": "1d5c50c0-bfeb-4218-88ab-405d12ba0607", 2661 + "to_change_id": "4dd1947c-e4d8-4134-8174-523d037ae728", 2662 + "edge_type": "leads_to", 2663 + "weight": 1.0, 2664 + "rationale": "scope clarification", 2665 + "created_at": "2026-01-06T11:19:22.911307605-05:00" 2666 + }, 2667 + { 2668 + "id": 127, 2669 + "from_node_id": 87, 2670 + "to_node_id": 114, 2671 + "from_change_id": "1d5c50c0-bfeb-4218-88ab-405d12ba0607", 2672 + "to_change_id": "e32a4e20-d5c8-40b7-82f8-9519313bcc55", 2673 + "edge_type": "leads_to", 2674 + "weight": 1.0, 2675 + "rationale": "crdt crate component", 2676 + "created_at": "2026-01-06T11:19:22.927827723-05:00" 2677 + }, 2678 + { 2679 + "id": 128, 2680 + "from_node_id": 87, 2681 + "to_node_id": 115, 2682 + "from_change_id": "1d5c50c0-bfeb-4218-88ab-405d12ba0607", 2683 + "to_change_id": "f991c3b0-0b5e-43b6-8d3a-59067ce3175e", 2684 + "edge_type": "leads_to", 2685 + "weight": 1.0, 2686 + "rationale": "design principle", 2687 + "created_at": "2026-01-06T11:20:40.341316533-05:00" 2688 + }, 2689 + { 2690 + "id": 129, 2691 + "from_node_id": 81, 2692 + "to_node_id": 117, 2693 + "from_change_id": "5f00148d-b487-40fb-b4b4-66b8d2489e91", 2694 + "to_change_id": "2a2333b1-eceb-4635-877f-26f55790d7ed", 2695 + "edge_type": "leads_to", 2696 + "weight": 1.0, 2697 + "rationale": "first implementation milestone", 2698 + "created_at": "2026-01-06T11:33:04.235835206-05:00" 2699 + }, 2700 + { 2701 + "id": 130, 2702 + "from_node_id": 116, 2703 + "to_node_id": 117, 2704 + "from_change_id": "cd5cc522-d717-43a7-b116-94f86845db10", 2705 + "to_change_id": "2a2333b1-eceb-4635-877f-26f55790d7ed", 2706 + "edge_type": "leads_to", 2707 + "weight": 1.0, 2708 + "rationale": "pattern applied", 2709 + "created_at": "2026-01-06T11:33:04.252811116-05:00" 2710 + }, 2711 + { 2712 + "id": 131, 2713 + "from_node_id": 117, 2714 + "to_node_id": 118, 2715 + "from_change_id": "2a2333b1-eceb-4635-877f-26f55790d7ed", 2716 + "to_change_id": "d6f1e398-007f-4eb6-94dd-33a97695bcbd", 2717 + "edge_type": "leads_to", 2718 + "weight": 1.0, 2719 + "rationale": "API refinement", 2720 + "created_at": "2026-01-06T11:35:39.734624305-05:00" 2721 + } 2722 + ] 2723 + }