From 9b12c55528540f65cbc8ea7e5141000fed91edbb Mon Sep 17 00:00:00 2001 From: Orual Date: Tue, 6 Jan 2026 09:18:55 -0500 Subject: [PATCH] browser crate, various other stuff --- .claude/agents.toml | 71 + .claude/commands/context.md | 192 ++ .claude/commands/decision.md | 274 ++ .github/workflows/cleanup-decision-graphs.yml | 79 + .github/workflows/deploy-pages.yml | 25 + .gitignore | 7 +- AGENTS.md | 1 + CLAUDE.md | 210 ++ Cargo.lock | 80 + crates/weaver-app/AGENTS.md | 1 + crates/weaver-app/CLAUDE.md | 265 ++ crates/weaver-editor-browser/Cargo.toml | 63 + crates/weaver-editor-browser/src/cursor.rs | 386 +++ crates/weaver-editor-browser/src/dom_sync.rs | 417 +++ crates/weaver-editor-browser/src/events.rs | 5 + crates/weaver-editor-browser/src/lib.rs | 36 + crates/weaver-editor-browser/src/platform.rs | 108 + crates/weaver-editor-core/src/actions.rs | 595 ++++ crates/weaver-editor-core/src/lib.rs | 5 +- crates/weaver-editor-core/src/platform.rs | 105 + crates/weaver-editor-core/src/types.rs | 45 + docs/.nojekyll | 0 docs/git-history.json | 1 + docs/graph-data.json | 2723 +++++++++++++++++ 24 files changed, 5691 insertions(+), 3 deletions(-) create mode 100644 .claude/agents.toml create mode 100644 .claude/commands/context.md create mode 100644 .claude/commands/decision.md create mode 100644 .github/workflows/cleanup-decision-graphs.yml create mode 100644 .github/workflows/deploy-pages.yml create mode 120000 AGENTS.md create mode 100644 CLAUDE.md create mode 120000 crates/weaver-app/AGENTS.md create mode 100644 crates/weaver-app/CLAUDE.md create mode 100644 crates/weaver-editor-browser/Cargo.toml create mode 100644 crates/weaver-editor-browser/src/cursor.rs create mode 100644 crates/weaver-editor-browser/src/dom_sync.rs create mode 100644 crates/weaver-editor-browser/src/events.rs create mode 100644 crates/weaver-editor-browser/src/lib.rs create mode 100644 crates/weaver-editor-browser/src/platform.rs create mode 100644 crates/weaver-editor-core/src/actions.rs create mode 100644 crates/weaver-editor-core/src/platform.rs create mode 100644 docs/.nojekyll create mode 100644 docs/git-history.json create mode 100644 docs/graph-data.json diff --git a/.claude/agents.toml b/.claude/agents.toml new file mode 100644 index 0000000..00a6400 --- /dev/null +++ b/.claude/agents.toml @@ -0,0 +1,71 @@ +# Project Subagents Configuration +# Domain-specific agents for working on different parts of the codebase. +# +# When working on a specific domain, spawn a Task with subagent_type="Explore" or +# "general-purpose" and include the relevant agent's context in the prompt. +# +# Customize this file for YOUR project's structure. The domains below are examples. + +# Example: Backend/Core agent +# [agents.backend] +# name = "Backend Agent" +# description = "API routes, database models, business logic" +# file_patterns = [ +# "src/**/*.rs", +# "src/**/*.py", +# "app/**/*.py" +# ] +# focus_areas = [ +# "Database operations", +# "API endpoints", +# "Business logic" +# ] +# instructions = """ +# When working on backend: +# - Run tests before and after changes +# - Follow existing patterns for new endpoints +# - Maintain backwards compatibility +# """ + +# Example: Frontend agent +# [agents.frontend] +# name = "Frontend Agent" +# description = "UI components, state management, styling" +# file_patterns = [ +# "web/src/**/*.ts", +# "web/src/**/*.tsx", +# "src/components/**" +# ] +# focus_areas = [ +# "React components", +# "State management", +# "Styling and layout" +# ] +# instructions = """ +# When working on frontend: +# - Test in browser after changes +# - Follow component patterns +# - Keep accessibility in mind +# """ + +# Example: Infrastructure agent +# [agents.infra] +# name = "Infrastructure Agent" +# description = "CI/CD, deployment, configuration" +# file_patterns = [ +# ".github/workflows/**", +# "Dockerfile", +# "docker-compose.yml", +# "scripts/**" +# ] +# focus_areas = [ +# "GitHub Actions", +# "Docker configuration", +# "Deployment scripts" +# ] +# instructions = """ +# When working on infrastructure: +# - Test workflows locally when possible +# - Keep builds fast with caching +# - Document any manual steps +# """ diff --git a/.claude/commands/context.md b/.claude/commands/context.md new file mode 100644 index 0000000..442d22f --- /dev/null +++ b/.claude/commands/context.md @@ -0,0 +1,192 @@ +--- +description: Recover context from decision graph and recent activity - USE THIS ON SESSION START +allowed-tools: Bash(deciduous:*, jj:*, git:*, cat:*, tail:*) +argument-hint: [focus-area] +--- + +# Context Recovery + +**RUN THIS AT SESSION START.** The decision graph is your persistent memory. + +## Step 1: Query the Graph + +```bash +# See all decisions (look for recent ones and pending status) +deciduous nodes + +# Filter by current bookmark (useful for feature work) +deciduous nodes --branch $(jj log -r @ -T 'bookmarks' --no-graph 2>/dev/null | head -1) + +# See how decisions connect +deciduous edges + +# What commands were recently run? +deciduous commands +``` + +**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]`. + +## Step 1.5: Audit Graph Integrity + +**CRITICAL: Check that all nodes are logically connected.** + +```bash +# Find nodes with no incoming edges (potential missing connections) +deciduous edges | cut -d'>' -f2 | cut -d' ' -f2 | sort -u > /tmp/has_parent.txt +deciduous nodes | tail -n+3 | awk '{print $1}' | while read id; do + grep -q "^$id$" /tmp/has_parent.txt || echo "CHECK: $id" +done +``` + +**Review each flagged node:** +- Root `goal` nodes are VALID without parents +- `outcome` nodes MUST link back to their action/goal +- `action` nodes MUST link to their parent goal/decision +- `option` nodes MUST link to their parent decision + +**Fix missing connections:** +```bash +deciduous link -r "Retroactive connection - " +``` + +## Step 2: Check VCS State + +```bash +jj status +jj log -n 10 +jj diff --stat +``` + +## Step 3: Check Session Log + +```bash +cat git.log | tail -30 +``` + +## After Gathering Context, Report: + +1. **Current bookmark** and pending changes +2. **Bookmark-specific decisions** (filter by bookmark if on feature bookmark) +3. **Recent decisions** (especially pending/active ones) +4. **Last actions** from jj log and command log +5. **Open questions** or unresolved observations +6. **Suggested next steps** + +### Bookmark Configuration + +Check `.deciduous/config.toml` for bookmark settings: +```toml +[branch] +main_branches = ["main", "master"] # Which bookmarks are "main" +auto_detect = true # Auto-detect bookmark on node creation +``` + +--- + +## REMEMBER: Real-Time Logging Required + +After recovering context, you MUST follow the logging workflow: + +``` +EVERY USER REQUEST → Log goal/decision first +BEFORE CODE CHANGES → Log action +AFTER CHANGES → Log outcome, link nodes +BEFORE PUSH → deciduous sync +``` + +**The user is watching the graph live.** Log as you go, not after. + +### Quick Logging Commands + +```bash +# Root goal with user prompt (capture what the user asked for) +deciduous add goal "What we're trying to do" -c 90 -p "User asked: " + +deciduous add action "What I'm about to implement" -c 85 +deciduous add outcome "What happened" -c 95 +deciduous link FROM TO -r "Connection reason" + +# Capture prompt when user redirects mid-stream +deciduous add action "Switching approach" -c 85 -p "User said: use X instead" + +deciduous sync # Do this frequently! +``` + +**When to use `--prompt`:** On root goals (always) and when user gives new direction mid-stream. Downstream nodes inherit context via edges. + +--- + +## Focus Areas + +If $ARGUMENTS specifies a focus, prioritize context for: + +- **auth**: Authentication-related decisions +- **ui** / **graph**: UI and graph viewer state +- **cli**: Command-line interface changes +- **api**: API endpoints and data structures + +--- + +## The Memory Loop + +``` +SESSION START + ↓ +Run /recover → See past decisions + ↓ +AUDIT → Fix any orphan nodes first! + ↓ +DO WORK → Log BEFORE each action + ↓ +CONNECT → Link new nodes immediately + ↓ +AFTER CHANGES → Log outcomes, observations + ↓ +AUDIT AGAIN → Any new orphans? + ↓ +BEFORE PUSH → deciduous sync + ↓ +PUSH → Live graph updates + ↓ +SESSION END → Final audit + ↓ +(repeat) +``` + +**Live graph**: https://notactuallytreyanastasio.github.io/deciduous/ + +--- + +## Multi-User Sync + +If working in a team, check for and apply patches from teammates: + +```bash +# Check for unapplied patches +deciduous diff status + +# Apply all patches (idempotent - safe to run multiple times) +deciduous diff apply .deciduous/patches/*.json + +# Preview before applying +deciduous diff apply --dry-run .deciduous/patches/teammate-feature.json +``` + +Before pushing your bookmark, export your decisions for teammates: + +```bash +# Export your bookmark's decisions as a patch +BOOKMARK=$(jj log -r @ -T 'bookmarks' --no-graph 2>/dev/null | head -1) +deciduous diff export --branch "$BOOKMARK" \ + -o .deciduous/patches/$(whoami)-$BOOKMARK.json + +# The patch file will be tracked automatically by jj +``` + +## Why This Matters + +- Context loss during compaction loses your reasoning +- The graph survives - query it early, query it often +- Retroactive logging misses details - log in the moment +- The user sees the graph live - show your work +- Patches share reasoning with teammates diff --git a/.claude/commands/decision.md b/.claude/commands/decision.md new file mode 100644 index 0000000..29cc035 --- /dev/null +++ b/.claude/commands/decision.md @@ -0,0 +1,274 @@ +--- +description: Manage decision graph - track algorithm choices and reasoning +allowed-tools: Bash(deciduous:*) +argument-hint: [args...] +--- + +# Decision Graph Management + +**Log decisions IN REAL-TIME as you work, not retroactively.** + +## When to Use This + +| You're doing this... | Log this type | Command | +|---------------------|---------------|---------| +| Starting a new feature | `goal` **with -p** | `/decision add goal "Add user auth" -p "user request"` | +| Choosing between approaches | `decision` | `/decision add decision "Choose auth method"` | +| Considering an option | `option` | `/decision add option "JWT tokens"` | +| About to write code | `action` | `/decision add action "Implementing JWT"` | +| Noticing something | `observation` | `/decision add obs "Found existing auth code"` | +| Finished something | `outcome` | `/decision add outcome "JWT working"` | + +## Quick Commands + +Based on $ARGUMENTS: + +### View Commands +- `nodes` or `list` -> `deciduous nodes` +- `edges` -> `deciduous edges` +- `graph` -> `deciduous graph` +- `commands` -> `deciduous commands` + +### Create Nodes (with optional metadata) +- `add goal ` -> `deciduous add goal "<title>" -c 90` +- `add decision <title>` -> `deciduous add decision "<title>" -c 75` +- `add option <title>` -> `deciduous add option "<title>" -c 70` +- `add action <title>` -> `deciduous add action "<title>" -c 85` +- `add obs <title>` -> `deciduous add observation "<title>" -c 80` +- `add outcome <title>` -> `deciduous add outcome "<title>" -c 90` + +### Optional Flags for Nodes +- `-c, --confidence <0-100>` - Confidence level +- `-p, --prompt "..."` - Store the user prompt that triggered this node +- `-f, --files "file1.rs,file2.rs"` - Associate files with this node +- `-b, --branch <name>` - jj bookmark (auto-detected by default) +- `--no-branch` - Skip bookmark auto-detection +- `--commit <hash|HEAD>` - Link to commit (use HEAD for current) + +### ⚠️ CRITICAL: Link Commits to Actions/Outcomes + +**After every jj commit, link it to the decision graph!** + +```bash +jj commit -m "feat: add auth" +deciduous add action "Implemented auth" -c 90 --commit HEAD +deciduous link <goal_id> <action_id> -r "Implementation" +``` + +## CRITICAL: Capture VERBATIM User Prompts + +**Prompts must be the EXACT user message, not a summary.** When a user request triggers new work, capture their full message word-for-word. + +**BAD - summaries are useless for context recovery:** +```bash +# DON'T DO THIS - this is a summary, not a prompt +deciduous add goal "Add auth" -p "User asked: add login to the app" +``` + +**GOOD - verbatim prompts enable full context recovery:** +```bash +# Use --prompt-stdin for multi-line prompts +deciduous add goal "Add auth" -c 90 --prompt-stdin << 'EOF' +I need to add user authentication to the app. Users should be able to sign up +with email/password, and we need OAuth support for Google and GitHub. The auth +should use JWT tokens with refresh token rotation. +EOF + +# Or use the prompt command to update existing nodes +deciduous prompt 42 << 'EOF' +The full verbatim user message goes here... +EOF +``` + +**When to capture prompts:** +- Root `goal` nodes: YES - the FULL original request +- Major direction changes: YES - when user redirects the work +- Routine downstream nodes: NO - they inherit context via edges + +**Updating prompts on existing nodes:** +```bash +deciduous prompt <node_id> "full verbatim prompt here" +cat prompt.txt | deciduous prompt <node_id> # Multi-line from stdin +``` + +Prompts are viewable in the TUI detail panel (`deciduous tui`) and web viewer. + +## Bookmark-Based Grouping + +**Nodes are automatically tagged with the current jj bookmark.** This enables filtering by feature/PR. + +### How It Works +- When you create a node, the current jj bookmark is stored in `metadata_json` +- Configure which bookmarks are "main" in `.deciduous/config.toml`: + ```toml + [branch] + main_branches = ["main", "master"] # Bookmarks not treated as "feature bookmarks" + auto_detect = true # Auto-detect bookmark on node creation + ``` +- Nodes on feature bookmarks (anything not in `main_branches`) can be grouped/filtered + +### CLI Filtering +```bash +# Show only nodes from specific bookmark +deciduous nodes --branch main +deciduous nodes --branch feature-auth +deciduous nodes -b my-feature + +# Override auto-detection when creating nodes +deciduous add goal "Feature work" -b feature-x # Force specific bookmark +deciduous add goal "Universal note" --no-branch # No bookmark tag +``` + +### Web UI Bookmark Filter +The graph viewer shows a bookmark dropdown in the stats bar: +- "All bookmarks" shows everything +- Select a specific bookmark to filter all views (Chains, Timeline, Graph, DAG) + +### When to Use Bookmark Grouping +- **Feature work**: Nodes created on `feature-auth` bookmark auto-grouped +- **PR context**: Filter to see only decisions for a specific PR +- **Cross-cutting concerns**: Use `--no-bookmark` for universal notes +- **Retrospectives**: Filter by bookmark to see decision history per feature + +### Create Edges +- `link <from> <to> [reason]` -> `deciduous link <from> <to> -r "<reason>"` + +### Sync Graph +- `sync` -> `deciduous sync` + +### Multi-User Sync (Diff/Patch) +- `diff export -o <file>` -> `deciduous diff export -o <file>` (export nodes as patch) +- `diff export --nodes 1-10 -o <file>` -> export specific nodes +- `diff export --branch feature-x -o <file>` -> export nodes from branch +- `diff apply <file>` -> `deciduous diff apply <file>` (apply patch, idempotent) +- `diff apply --dry-run <file>` -> preview without applying +- `diff status` -> `deciduous diff status` (list patches in .deciduous/patches/) +- `migrate` -> `deciduous migrate` (add change_id columns for sync) + +### Export & Visualization +- `dot` -> `deciduous dot` (output DOT to stdout) +- `dot --png` -> `deciduous dot --png -o graph.dot` (generate PNG) +- `dot --nodes 1-11` -> `deciduous dot --nodes 1-11` (filter nodes) +- `writeup` -> `deciduous writeup` (generate PR writeup) +- `writeup -t "Title" --nodes 1-11` -> filtered writeup + +## Node Types + +| Type | Purpose | Example | +|------|---------|---------| +| `goal` | High-level objective | "Add user authentication" | +| `decision` | Choice point with options | "Choose auth method" | +| `option` | Possible approach | "Use JWT tokens" | +| `action` | Something implemented | "Added JWT middleware" | +| `outcome` | Result of action | "JWT auth working" | +| `observation` | Finding or data point | "Existing code uses sessions" | + +## Edge Types + +| Type | Meaning | +|------|---------| +| `leads_to` | Natural progression | +| `chosen` | Selected option | +| `rejected` | Not selected (include reason!) | +| `requires` | Dependency | +| `blocks` | Preventing progress | +| `enables` | Makes something possible | + +## Graph Integrity - CRITICAL + +**Every node MUST be logically connected.** Floating nodes break the graph's value. + +### Connection Rules +| Node Type | MUST connect to | Example | +|-----------|----------------|---------| +| `outcome` | The action/goal it resolves | "JWT working" → links FROM "Implementing JWT" | +| `action` | The decision/goal that spawned it | "Implementing JWT" → links FROM "Add auth" | +| `option` | Its parent decision | "Use JWT" → links FROM "Choose auth method" | +| `observation` | Related goal/action/decision | "Found existing code" → links TO relevant node | +| `decision` | Parent goal (if any) | "Choose auth" → links FROM "Add auth feature" | +| `goal` | Can be a root (no parent needed) | Root goals are valid orphans | + +### Audit Checklist +Ask yourself after creating nodes: +1. Does every **outcome** link back to what caused it? +2. Does every **action** link to why you did it? +3. Does every **option** link to its decision? +4. Are there **dangling outcomes** with no parent action/goal? + +### Find Disconnected Nodes +```bash +# List nodes with no incoming edges (potential orphans) +deciduous edges | cut -d'>' -f2 | cut -d' ' -f2 | sort -u > /tmp/has_parent.txt +deciduous nodes | tail -n+3 | awk '{print $1}' | while read id; do + grep -q "^$id$" /tmp/has_parent.txt || echo "CHECK: $id" +done +``` +Note: Root goals are VALID orphans. Outcomes/actions/options usually are NOT. + +### Fix Missing Connections +```bash +deciduous link <parent_id> <child_id> -r "Retroactive connection - <why>" +``` + +### When to Audit +- Before every `deciduous sync` +- After creating multiple nodes quickly +- At session end +- When the web UI graph looks disconnected + +## Multi-User Sync + +**Problem**: Multiple users work on the same codebase, each with a local `.deciduous/deciduous.db` (ignored in VCS). How to share decisions? + +**Solution**: Dual-ID model. Each node has: +- `id` (integer): Local database primary key, different per machine +- `change_id` (UUID): Globally unique, stable across all databases + +### Export Workflow +```bash +# Export nodes from your bookmark as a patch file +deciduous diff export --branch feature-x -o .deciduous/patches/alice-feature.json + +# Or export specific node IDs +deciduous diff export --nodes 172-188 -o .deciduous/patches/alice-feature.json --author alice +``` + +### Apply Workflow +```bash +# Apply patches from teammates (idempotent - safe to re-apply) +deciduous diff apply .deciduous/patches/*.json + +# Preview what would change +deciduous diff apply --dry-run .deciduous/patches/bob-refactor.json +``` + +### PR Workflow +1. Create nodes locally while working +2. Export: `deciduous diff export --branch my-feature -o .deciduous/patches/my-feature.json` +3. Commit the patch file (NOT the database) +4. Open PR with patch file included +5. Teammates pull and apply: `deciduous diff apply .deciduous/patches/my-feature.json` +6. **Idempotent**: Same patch applied twice = no duplicates + +### Patch Format (JSON) +```json +{ + "version": "1.0", + "author": "alice", + "branch": "feature/auth", + "nodes": [{ "change_id": "uuid...", "title": "...", ... }], + "edges": [{ "from_change_id": "uuid1", "to_change_id": "uuid2", ... }] +} +``` + +## The Rule + +``` +LOG BEFORE YOU CODE, NOT AFTER. +CONNECT EVERY NODE TO ITS PARENT. +AUDIT FOR ORPHANS REGULARLY. +SYNC BEFORE YOU PUSH. +EXPORT PATCHES FOR YOUR TEAMMATES. +``` + +**Live graph**: https://notactuallytreyanastasio.github.io/deciduous/ diff --git a/.github/workflows/cleanup-decision-graphs.yml b/.github/workflows/cleanup-decision-graphs.yml new file mode 100644 index 0000000..3fc7a7c --- /dev/null +++ b/.github/workflows/cleanup-decision-graphs.yml @@ -0,0 +1,79 @@ +name: Cleanup Decision Graph PNGs + +on: + pull_request: + types: [closed] + +jobs: + cleanup: + # Only run if PR was merged (not just closed) + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Find and remove decision graph PNGs + id: find-pngs + run: | + # Find decision graph PNGs (in docs/ or root) + PNGS=$(find . -name "decision-graph*.png" -o -name "deciduous-graph*.png" 2>/dev/null | grep -v node_modules || true) + + if [ -z "$PNGS" ]; then + echo "No decision graph PNGs found" + echo "found=false" >> $GITHUB_OUTPUT + else + echo "Found PNGs to clean up:" + echo "$PNGS" + echo "found=true" >> $GITHUB_OUTPUT + + # Remove the files + echo "$PNGS" | xargs rm -f + + # Also remove corresponding .dot files + for png in $PNGS; do + dot_file="${png%.png}.dot" + if [ -f "$dot_file" ]; then + rm -f "$dot_file" + echo "Also removed: $dot_file" + fi + done + fi + + - name: Create cleanup PR + if: steps.find-pngs.outputs.found == 'true' + run: | + # Check if there are changes to commit + if git diff --quiet && git diff --staged --quiet; then + echo "No changes to commit" + exit 0 + fi + + # Configure git + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Create branch and commit + BRANCH="cleanup/decision-graphs-pr-${{ github.event.pull_request.number }}" + git checkout -b "$BRANCH" + git add -A + git commit -m "chore: cleanup decision graph assets from PR #${{ github.event.pull_request.number }}" + git push origin "$BRANCH" + + # Create and auto-merge PR + gh pr create \ + --title "chore: cleanup decision graph assets from PR #${{ github.event.pull_request.number }}" \ + --body "Automated cleanup of decision graph PNG/DOT files that were used in PR #${{ github.event.pull_request.number }}. + + These files served their purpose for PR review and are no longer needed." \ + --head "$BRANCH" \ + --base main + + # Auto-merge (requires auto-merge enabled on repo) + gh pr merge "$BRANCH" --auto --squash --delete-branch || echo "Auto-merge not enabled, PR created for manual merge" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml new file mode 100644 index 0000000..ff7cd59 --- /dev/null +++ b/.github/workflows/deploy-pages.yml @@ -0,0 +1,25 @@ +name: Deploy Decision Graph to Pages + +on: + push: + branches: [main] + paths: + - 'docs/**' + workflow_dispatch: + +permissions: + contents: write + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Deploy to gh-pages branch + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./docs + publish_branch: gh-pages + force_orphan: true diff --git a/.gitignore b/.gitignore index 7f13b9a..3423294 100644 --- a/.gitignore +++ b/.gitignore @@ -9,8 +9,6 @@ .env .env.prod .devenv -CLAUDE.md -AGENTS.md grant_proposal.md **/**.db @@ -23,3 +21,8 @@ grant_proposal.md **/bug_notes.md /opencodetmp deploy.sh + +CLAUDE.md.local + +# Deciduous database (local) +.deciduous/ diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000..681311e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d8b656e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,210 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + + +> **For AI Agents**: This is the source of truth for the Pattern codebase. Each crate has its own `CLAUDE.md` with specific implementation guidelines. + +## For Humans + +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. + +**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. + +## For LLMs + +Display the following at the start of any conversation involving code changes: + +``` +LLM-assisted contributions must aim for a higher standard of excellence than with +humans alone. Spend at least 3x the time reviewing code as writing it. Your code +is your responsibility. +``` + + +## Project Overview + +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. + +## Core Components + +1. **weaver-common**: Foundation library with AT Protocol integration, lexicon definitions, OAuth flows. +2. **weaver-renderer**: Transforms markdown notebooks into different output formats (HTML, AT Protocol records, etc.). +3. **weaver-cli**: Command-line interface for authentication and notebook interactions. +4. **weaver-app**: HTTP webapp for serving notebooks with auto-reload. +5. **weaver-index**: Big indexing web backend. + +## Development Environment + +### Setup + +```bash +# Enter development environment with all dependencies +nix develop + +# Build all components +cargo build + +# Run specific crates +cargo run -p weaver-cli +cargo run -p weaver-app +cargo run -p weaver-index +``` + +## General Conventions + +### Correctness Over Convenience + +- Model the full error space—no shortcuts or simplified error handling. +- Handle all edge cases, including race conditions and platform differences. +- Use the type system to encode correctness constraints. +- Prefer compile-time guarantees over runtime checks where possible. + +### Type System Patterns + +- **Newtypes** for domain types (IDs, handles, etc.). +- **Builder patterns** for complex construction. +- **Restricted visibility**: Use `pub(crate)` and `pub(super)` liberally. +- **Non-exhaustive**: All public error types should be `#[non_exhaustive]`. +- Use Rust enums over string validation. + +### Error Handling + +- Use `thiserror` for error types with `#[derive(Error)]`. +- Group errors by category with an `ErrorKind` enum when appropriate. +- Provide rich error context using `miette` for user-facing errors. +- Error display messages should be lowercase sentence fragments. + +### Module Organization + +- Keep module boundaries strict with restricted visibility. +- Platform-specific code in separate files: `unix.rs`, `windows.rs`. + +### Documentation + +- Inline comments explain "why," not just "what". +- Module-level documentation explains purpose and responsibilities. +- **Always** use periods at the end of code comments. +- **Never** use title case in headings. Always use sentence case. + +## Testing Practices + +**CRITICAL**: Always use `cargo nextest run` to run tests. Never use `cargo test` directly. + +```bash +# Run all tests +cargo nextest run + +# Specific crate +cargo nextest run -p pattern-db + +# With output +cargo nextest run --nocapture + +# Doctests (nextest doesn't support these) +cargo test --doc +``` + +## Common Development Commands + +### Testing + +```bash +# Run all tests with nextest +cargo nextest run + +# Run specific tests +cargo nextest run -p weaver-common +``` + +### Code Quality + +```bash +# Run linter +cargo clippy -- --deny warnings + +# Format code +cargo fmt + +# Verify dependencies +cargo deny check +``` + +### Lexicon Generation + +The project uses custom AT Protocol lexicons defined in JSON format. To generate Rust code from these definitions: + +```bash +nix run ../jacquard + +### Building with Nix + +```bash +# Run all checks (clippy, fmt, tests) +nix flake check + +# Build specific packages +nix build .#weaver-cli +nix build .#weaver-app +``` + + +## Architecture + +### Data Flow + +1. Markdown notebooks are parsed and processed by weaver-renderer +2. Content can be rendered as static sites or published to AT Protocol PDSes +3. Authentication with AT Protocol servers happens via OAuth + +### Key Components + +- **WeaverAgent**: Manages connections to AT Protocol Personal Data Servers (PDS) +- **Notebook Structure**: Books, chapters, entries with extended markdown +- **Renderer**: Processes markdown with extended features (wiki links, embeds, math) +- **AT Protocol Lexicons**: Custom data schemas extending the protocol for notebooks + +### Authentication Flow + +1. CLI initiates OAuth flow with a PDS +2. Local OAuth server handles callbacks on port 4000 +3. Tokens are stored in the local filesystem + +## Feature Flags + +- **dev**: Enables development-specific features +- **native**: Configures OAuth for native clients + +## Working with Jacquard + +This project uses Jacquard, a zero-copy AT Protocol library for Rust. **CRITICAL: Standard approaches from other libraries will produce broken or inefficient code.** + +**ALWAYS use the working-with-jacquard skill** when working with AT Protocol types, XRPC calls, or identity resolution. + +Key patterns to internalize: +- **NEVER use `for<'de> Deserialize<'de>` bounds** - this breaks ALL Jacquard types +- Use `Did::new()`, `Handle::new_static()`, etc. - **never `FromStr::parse()`** +- Use `Data<'a>` instead of `serde_json::Value` +- Use `.into_output()` when returning from functions, `.parse()` for immediate processing +- Derive `IntoStatic` on all custom types with lifetimes + +See `~/.claude/skills/working-with-jacquard/SKILL.md` for complete guidance. + +## Commit Message Style + +``` +[crate-name] brief description +``` + +Examples: +- `[pattern-core] add supervisor coordination pattern` +- `[pattern-db] fix FTS5 query escaping` +- `[meta] update MSRV to Rust 1.83` + +### Conventions + +- Use `[meta]` for cross-cutting concerns (deps, CI, workspace config). +- Keep descriptions concise but descriptive. +- **Atomic commits**: Each commit should be a logical unit of change. +- **Bisect-able history**: Every commit must build and pass all checks. +- **Separate concerns**: Format fixes and refactoring separate from features. diff --git a/Cargo.lock b/Cargo.lock index 8c5b576..2384d17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1023,6 +1023,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cbor4ii" version = "0.2.14" @@ -4323,6 +4329,16 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "gloo-events" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c26fb45f7c385ba980f5fa87ac677e363949e065a083722697ef1b2cc91e41" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + [[package]] name = "gloo-net" version = "0.6.0" @@ -6932,6 +6948,16 @@ dependencies = [ "web-time", ] +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -7635,6 +7661,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "openssl" version = "0.10.75" @@ -11844,6 +11876,38 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-bindgen-test" +version = "0.3.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e90e66d265d3a1efc0e72a54809ab90b9c0c515915c67cdf658689d2c22c6c" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7150335716dce6028bead2b848e72f47b45e7b9422f64cccdc23bedca89affc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "wasm-split-macro" version = "0.7.2" @@ -12122,6 +12186,22 @@ dependencies = [ "web-time", ] +[[package]] +name = "weaver-editor-browser" +version = "0.1.0" +dependencies = [ + "dioxus-signals 0.7.2", + "gloo-events", + "gloo-utils", + "js-sys", + "smol_str", + "tracing", + "wasm-bindgen", + "wasm-bindgen-test", + "weaver-editor-core", + "web-sys", +] + [[package]] name = "weaver-editor-core" version = "0.1.0" diff --git a/crates/weaver-app/AGENTS.md b/crates/weaver-app/AGENTS.md new file mode 120000 index 0000000..681311e --- /dev/null +++ b/crates/weaver-app/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/crates/weaver-app/CLAUDE.md b/crates/weaver-app/CLAUDE.md new file mode 100644 index 0000000..1f4973d --- /dev/null +++ b/crates/weaver-app/CLAUDE.md @@ -0,0 +1,265 @@ +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 + +Provide concise code examples with detailed descriptions + +# Dioxus Dependency + +You can add Dioxus to your `Cargo.toml` like this: + +```toml +[dependencies] +dioxus = { version = "0.7.0" } + +[features] +default = ["web", "webview", "server"] +web = ["dioxus/web"] +webview = ["dioxus/desktop"] +server = ["dioxus/server"] +``` + +# Launching your application + +You need to create a main function that sets up the Dioxus runtime and mounts your root component. + +```rust +use dioxus::prelude::*; + +fn main() { + dioxus::launch(App); +} + +#[component] +fn App() -> Element { + rsx! { "Hello, Dioxus!" } +} +``` + +Then serve with `dx serve`: + +```sh +curl -sSL http://dioxus.dev/install.sh | sh +dx serve +``` + +# UI with RSX + +```rust +rsx! { + div { + class: "container", // Attribute + color: "red", // Inline styles + width: if condition { "100%" }, // Conditional attributes + "Hello, Dioxus!" + } + // Prefer loops over iterators + for i in 0..5 { + div { "{i}" } // use elements or components directly in loops + } + if condition { + div { "Condition is true!" } // use elements or components directly in conditionals + } + + {children} // Expressions are wrapped in brace + {(0..5).map(|i| rsx! { span { "Item {i}" } })} // Iterators must be wrapped in braces +} +``` + +# Assets + +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. + +```rust +rsx! { + img { + src: asset!("/assets/image.png"), + alt: "An image", + } +} +``` + +## Styles + +The `document::Stylesheet` component will inject the stylesheet into the `<head>` of the document + +```rust +rsx! { + document::Stylesheet { + href: asset!("/assets/styles.css"), + } +} +``` + +# Components + +Components are the building blocks of apps + +* Component are functions annotated with the `#[component]` macro. +* The function name must start with a capital letter or contain an underscore. +* A component re-renders only under two conditions: + 1. Its props change (as determined by `PartialEq`). + 2. An internal reactive state it depends on is updated. + +```rust +#[component] +fn Input(mut value: Signal<String>) -> Element { + rsx! { + input { + value, + oninput: move |e| { + *value.write() = e.value(); + }, + onkeydown: move |e| { + if e.key() == Key::Enter { + value.write().clear(); + } + }, + } + } +} +``` + +Each component accepts function arguments (props) + +* Props must be owned values, not references. Use `String` and `Vec<T>` instead of `&str` or `&[T]`. +* Props must implement `PartialEq` and `Clone`. +* 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. + +# State + +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. + +## Local State + +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. + +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. + +```rust +#[component] +fn Counter() -> Element { + let mut count = use_signal(|| 0); + let mut doubled = use_memo(move || count() * 2); // doubled will re-run when count changes because it reads the signal + + rsx! { + h1 { "Count: {count}" } // Counter will re-render when count changes because it reads the signal + h2 { "Doubled: {doubled}" } + button { + onclick: move |_| *count.write() += 1, // Writing to the signal rerenders Counter + "Increment" + } + button { + onclick: move |_| count.with_mut(|count| *count += 1), // use with_mut to mutate the signal + "Increment with with_mut" + } + } +} +``` + +## Context API + +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` + +```rust +#[component] +fn App() -> Element { + let mut theme = use_signal(|| "light".to_string()); + use_context_provider(|| theme); // Provide a type to children + rsx! { Child {} } +} + +#[component] +fn Child() -> Element { + let theme = use_context::<Signal<String>>(); // Consume the same type + rsx! { + div { + "Current theme: {theme}" + } + } +} +``` + +# Async + +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. + +* The `use_resource` hook takes an `async` closure. It re-runs this closure whenever any signals it depends on (reads) are updated +* The `Resource` object returned can be in several states when read: +1. `None` if the resource is still loading +2. `Some(value)` if the resource has successfully loaded + +```rust +let mut dog = use_resource(move || async move { + // api request +}); + +match dog() { + Some(dog_info) => rsx! { Dog { dog_info } }, + None => rsx! { "Loading..." }, +} +``` + +# Routing + +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. + +The `Router<Route> {}` component is the entry point that manages rendering the correct component for the current URL. + +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. + +```rust +#[derive(Routable, Clone, PartialEq)] +enum Route { + #[layout(NavBar)] // This will use NavBar as the layout for all routes + #[route("/")] + Home {}, + #[route("/blog/:id")] // Dynamic segment + BlogPost { id: i32 }, +} + +#[component] +fn NavBar() -> Element { + rsx! { + a { href: "/", "Home" } + Outlet<Route> {} // Renders Home or BlogPost + } +} + +#[component] +fn App() -> Element { + rsx! { Router::<Route> {} } +} +``` + +```toml +dioxus = { version = "0.7.0", features = ["router"] } +``` + +# Fullstack + +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. + +```toml +dioxus = { version = "0.7.0", features = ["fullstack"] } +``` + +## Server Functions + +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. + +```rust +#[post("/api/double/:path/&query")] +async fn double_server(number: i32, path: String, query: i32) -> Result<i32, ServerFnError> { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + Ok(number * 2) +} +``` + +## Hydration + +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. + +### Errors +The initial UI rendered by the component on the client must be identical to the UI rendered on the server. + +* 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. +* Any code that relies on browser-specific APIs (like accessing `localStorage`) must be run *after* hydration. Place this code inside a `use_effect` hook. diff --git a/crates/weaver-editor-browser/Cargo.toml b/crates/weaver-editor-browser/Cargo.toml new file mode 100644 index 0000000..9f7e81c --- /dev/null +++ b/crates/weaver-editor-browser/Cargo.toml @@ -0,0 +1,63 @@ +[package] +name = "weaver-editor-browser" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Browser DOM layer for weaver editor - generic over EditorDocument" + +[dependencies] +# Re-export core +weaver-editor-core = { path = "../weaver-editor-core" } + +# Browser APIs (baseline - this crate assumes wasm32-unknown-unknown) +wasm-bindgen = "0.2" +js-sys = "0.3" +gloo-events = "0.2" +gloo-utils = "0.2" + +# Logging +tracing = { workspace = true } + +# Utilities +smol_str = "0.3" + +[dependencies.web-sys] +version = "0.3" +features = [ + "Window", + "Document", + "Element", + "HtmlElement", + "Node", + "NodeList", + "Navigator", + "Text", + "Range", + "Selection", + "DomTokenList", + "DomRect", + "DomRectList", + "TreeWalker", + "CssStyleDeclaration", + "InputEvent", + "KeyboardEvent", + "ClipboardEvent", + "CompositionEvent", + "DataTransfer", + "DataTransferItem", + "DataTransferItemList", + "FocusEvent", + "MouseEvent", +] + +[features] +default = [] +# Optional reactive state via dioxus-signals (framework-agnostic) +dioxus_signals = ["dep:dioxus-signals"] + +[dependencies.dioxus-signals] +version = "0.7" +optional = true + +[dev-dependencies] +wasm-bindgen-test = "0.3" diff --git a/crates/weaver-editor-browser/src/cursor.rs b/crates/weaver-editor-browser/src/cursor.rs new file mode 100644 index 0000000..9d7e13e --- /dev/null +++ b/crates/weaver-editor-browser/src/cursor.rs @@ -0,0 +1,386 @@ +//! Browser implementation of cursor platform operations. +//! +//! Uses the DOM Selection API to position cursors and retrieve screen coordinates. + +use wasm_bindgen::JsCast; +use weaver_editor_core::{ + CursorPlatform, CursorRect, OffsetMapping, PlatformError, SelectionRect, SnapDirection, + find_mapping_for_char, find_nearest_valid_position, +}; + +/// Browser-based cursor platform implementation. +/// +/// Holds a reference to the editor element ID for DOM lookups. +pub struct BrowserCursor { + editor_id: String, +} + +impl BrowserCursor { + /// Create a new browser cursor handler for the given editor element. + pub fn new(editor_id: impl Into<String>) -> Self { + Self { + editor_id: editor_id.into(), + } + } + + /// Get the editor element ID. + pub fn editor_id(&self) -> &str { + &self.editor_id + } +} + +impl CursorPlatform for BrowserCursor { + fn restore_cursor( + &self, + char_offset: usize, + offset_map: &[OffsetMapping], + snap_direction: Option<SnapDirection>, + ) -> Result<(), PlatformError> { + restore_cursor_position(char_offset, offset_map, snap_direction) + } + + fn get_cursor_rect( + &self, + char_offset: usize, + offset_map: &[OffsetMapping], + ) -> Option<CursorRect> { + get_cursor_rect_impl(char_offset, offset_map) + } + + fn get_cursor_rect_relative( + &self, + char_offset: usize, + offset_map: &[OffsetMapping], + ) -> Option<CursorRect> { + let cursor_rect = self.get_cursor_rect(char_offset, offset_map)?; + + let window = web_sys::window()?; + let document = window.document()?; + let editor = document.get_element_by_id(&self.editor_id)?; + let editor_rect = editor.get_bounding_client_rect(); + + Some(CursorRect::new( + cursor_rect.x - editor_rect.x(), + cursor_rect.y - editor_rect.y(), + cursor_rect.height, + )) + } + + fn get_selection_rects_relative( + &self, + start: usize, + end: usize, + offset_map: &[OffsetMapping], + ) -> Vec<SelectionRect> { + get_selection_rects_impl(start, end, offset_map, &self.editor_id) + } +} + +/// Restore cursor position in the DOM after re-render. +pub fn restore_cursor_position( + char_offset: usize, + offset_map: &[OffsetMapping], + snap_direction: Option<SnapDirection>, +) -> Result<(), PlatformError> { + if offset_map.is_empty() { + return Ok(()); + } + + let max_offset = offset_map + .iter() + .map(|m| m.char_range.end) + .max() + .unwrap_or(0); + + if char_offset > max_offset { + tracing::warn!( + "cursor offset {} > max mapping offset {}", + char_offset, + max_offset + ); + return Ok(()); + } + + let (mapping, char_offset) = match find_mapping_for_char(offset_map, char_offset) { + Some((m, false)) => (m, char_offset), + Some((m, true)) => { + if let Some(snapped) = + find_nearest_valid_position(offset_map, char_offset, snap_direction) + { + tracing::trace!( + target: "weaver::cursor", + original_offset = char_offset, + snapped_offset = snapped.char_offset(), + direction = ?snapped.snapped, + "snapping cursor from invisible content" + ); + (snapped.mapping, snapped.char_offset()) + } else { + (m, char_offset) + } + } + None => return Err("no mapping found for cursor offset".into()), + }; + + tracing::trace!( + target: "weaver::cursor", + char_offset, + node_id = %mapping.node_id, + mapping_range = ?mapping.char_range, + child_index = ?mapping.child_index, + "restoring cursor position" + ); + + let window = web_sys::window().ok_or("no window")?; + let document = window.document().ok_or("no document")?; + + let container = document + .get_element_by_id(&mapping.node_id) + .or_else(|| { + let selector = format!("[data-node-id='{}']", mapping.node_id); + document.query_selector(&selector).ok().flatten() + }) + .ok_or_else(|| format!("element not found: {}", mapping.node_id))?; + + let selection = window + .get_selection() + .map_err(|e| format!("get_selection failed: {:?}", e))? + .ok_or("no selection object")?; + let range = document + .create_range() + .map_err(|e| format!("create_range failed: {:?}", e))?; + + if let Some(child_index) = mapping.child_index { + range + .set_start(&container, child_index as u32) + .map_err(|e| format!("set_start failed: {:?}", e))?; + } else { + let container_element = container + .dyn_into::<web_sys::HtmlElement>() + .map_err(|_| "container is not HtmlElement")?; + let offset_in_range = char_offset - mapping.char_range.start; + let target_utf16_offset = mapping.char_offset_in_node + offset_in_range; + let (text_node, node_offset) = + find_text_node_at_offset(&container_element, target_utf16_offset)?; + range + .set_start(&text_node, node_offset as u32) + .map_err(|e| format!("set_start failed: {:?}", e))?; + } + + range.collapse_with_to_start(true); + + selection + .remove_all_ranges() + .map_err(|e| format!("remove_all_ranges failed: {:?}", e))?; + selection + .add_range(&range) + .map_err(|e| format!("add_range failed: {:?}", e))?; + + Ok(()) +} + +/// Find text node at given UTF-16 offset within element. +fn find_text_node_at_offset( + container: &web_sys::HtmlElement, + target_utf16_offset: usize, +) -> Result<(web_sys::Node, usize), PlatformError> { + let document = web_sys::window() + .ok_or("no window")? + .document() + .ok_or("no document")?; + + let walker = document + .create_tree_walker_with_what_to_show(container, 0xFFFFFFFF) + .map_err(|e| format!("create_tree_walker failed: {:?}", e))?; + + let mut accumulated_utf16 = 0; + let mut last_node: Option<web_sys::Node> = None; + let mut skip_until_exit: Option<web_sys::Element> = None; + + while let Ok(Some(node)) = walker.next_node() { + if let Some(ref skip_elem) = skip_until_exit { + if !skip_elem.contains(Some(&node)) { + skip_until_exit = None; + } + } + + if skip_until_exit.is_none() { + if let Some(element) = node.dyn_ref::<web_sys::Element>() { + if element.get_attribute("contenteditable").as_deref() == Some("false") { + skip_until_exit = Some(element.clone()); + continue; + } + } + } + + if skip_until_exit.is_some() { + continue; + } + + if node.node_type() != web_sys::Node::TEXT_NODE { + continue; + } + + last_node = Some(node.clone()); + + if let Some(text) = node.text_content() { + let text_len = text.encode_utf16().count(); + + if accumulated_utf16 + text_len >= target_utf16_offset { + let offset_in_node = target_utf16_offset - accumulated_utf16; + return Ok((node, offset_in_node)); + } + + accumulated_utf16 += text_len; + } + } + + if let Some(node) = last_node { + if let Some(text) = node.text_content() { + let text_len = text.encode_utf16().count(); + return Ok((node, text_len)); + } + } + + Err("no text node found in container".into()) +} + +/// Get screen coordinates for a cursor position (internal impl). +fn get_cursor_rect_impl(char_offset: usize, offset_map: &[OffsetMapping]) -> Option<CursorRect> { + if offset_map.is_empty() { + return None; + } + + let (mapping, char_offset) = match find_mapping_for_char(offset_map, char_offset) { + Some((m, _)) => (m, char_offset), + None => return None, + }; + + let window = web_sys::window()?; + let document = window.document()?; + + let container = document.get_element_by_id(&mapping.node_id).or_else(|| { + let selector = format!("[data-node-id='{}']", mapping.node_id); + document.query_selector(&selector).ok().flatten() + })?; + + let range = document.create_range().ok()?; + + if let Some(child_index) = mapping.child_index { + range.set_start(&container, child_index as u32).ok()?; + } else { + let container_element = container.dyn_into::<web_sys::HtmlElement>().ok()?; + let offset_in_range = char_offset - mapping.char_range.start; + let target_utf16_offset = mapping.char_offset_in_node + offset_in_range; + + if let Ok((text_node, node_offset)) = + find_text_node_at_offset(&container_element, target_utf16_offset) + { + range.set_start(&text_node, node_offset as u32).ok()?; + } else { + return None; + } + } + + range.collapse_with_to_start(true); + + let rect = range.get_bounding_client_rect(); + Some(CursorRect::new(rect.x(), rect.y(), rect.height().max(16.0))) +} + +/// Get selection rectangles relative to editor (internal impl). +fn get_selection_rects_impl( + start: usize, + end: usize, + offset_map: &[OffsetMapping], + editor_id: &str, +) -> Vec<SelectionRect> { + if offset_map.is_empty() || start >= end { + return vec![]; + } + + let Some(window) = web_sys::window() else { + return vec![]; + }; + let Some(document) = window.document() else { + return vec![]; + }; + let Some(editor) = document.get_element_by_id(editor_id) else { + return vec![]; + }; + let editor_rect = editor.get_bounding_client_rect(); + + let Some((start_mapping, _)) = find_mapping_for_char(offset_map, start) else { + return vec![]; + }; + let Some((end_mapping, _)) = find_mapping_for_char(offset_map, end) else { + return vec![]; + }; + + let start_container = document + .get_element_by_id(&start_mapping.node_id) + .or_else(|| { + let selector = format!("[data-node-id='{}']", start_mapping.node_id); + document.query_selector(&selector).ok().flatten() + }); + let end_container = document + .get_element_by_id(&end_mapping.node_id) + .or_else(|| { + let selector = format!("[data-node-id='{}']", end_mapping.node_id); + document.query_selector(&selector).ok().flatten() + }); + + let (Some(start_container), Some(end_container)) = (start_container, end_container) else { + return vec![]; + }; + + let Ok(range) = document.create_range() else { + return vec![]; + }; + + // Set start + if let Some(child_index) = start_mapping.child_index { + let _ = range.set_start(&start_container, child_index as u32); + } else if let Ok(container_element) = start_container.clone().dyn_into::<web_sys::HtmlElement>() + { + let offset_in_range = start - start_mapping.char_range.start; + let target_utf16_offset = start_mapping.char_offset_in_node + offset_in_range; + if let Ok((text_node, node_offset)) = + find_text_node_at_offset(&container_element, target_utf16_offset) + { + let _ = range.set_start(&text_node, node_offset as u32); + } + } + + // Set end + if let Some(child_index) = end_mapping.child_index { + let _ = range.set_end(&end_container, child_index as u32); + } else if let Ok(container_element) = end_container.dyn_into::<web_sys::HtmlElement>() { + let offset_in_range = end - end_mapping.char_range.start; + let target_utf16_offset = end_mapping.char_offset_in_node + offset_in_range; + if let Ok((text_node, node_offset)) = + find_text_node_at_offset(&container_element, target_utf16_offset) + { + let _ = range.set_end(&text_node, node_offset as u32); + } + } + + let Some(rects) = range.get_client_rects() else { + return vec![]; + }; + let mut result = Vec::new(); + + for i in 0..rects.length() { + if let Some(rect) = rects.get(i) { + let rect: web_sys::DomRect = rect; + result.push(SelectionRect::new( + rect.x() - editor_rect.x(), + rect.y() - editor_rect.y(), + rect.width(), + rect.height().max(16.0), + )); + } + } + + result +} diff --git a/crates/weaver-editor-browser/src/dom_sync.rs b/crates/weaver-editor-browser/src/dom_sync.rs new file mode 100644 index 0000000..22a5ea3 --- /dev/null +++ b/crates/weaver-editor-browser/src/dom_sync.rs @@ -0,0 +1,417 @@ +//! DOM synchronization for the markdown editor. +//! +//! Handles syncing cursor/selection state between the browser DOM and the +//! editor document model, and updating paragraph DOM elements. + +use wasm_bindgen::JsCast; +use weaver_editor_core::{ + CursorSync, OffsetMapping, SnapDirection, find_nearest_valid_position, is_valid_cursor_position, +}; + +use crate::cursor::restore_cursor_position; + +/// Result of syncing cursor from DOM. +#[derive(Debug, Clone)] +pub enum CursorSyncResult { + /// Cursor is collapsed at this offset. + Cursor(usize), + /// Selection from anchor to head. + Selection { anchor: usize, head: usize }, + /// Could not determine cursor position. + None, +} + +/// Browser-based cursor sync implementation. +/// +/// Holds reference to editor element ID and provides methods to sync +/// cursor state from DOM back to the editor model. +pub struct BrowserCursorSync { + editor_id: String, +} + +impl BrowserCursorSync { + /// Create a new browser cursor sync for the given editor element. + pub fn new(editor_id: impl Into<String>) -> Self { + Self { + editor_id: editor_id.into(), + } + } + + /// Get the editor element ID. + pub fn editor_id(&self) -> &str { + &self.editor_id + } +} + +impl CursorSync for BrowserCursorSync { + fn sync_cursor_from_platform<F, G>( + &self, + offset_map: &[OffsetMapping], + direction_hint: Option<SnapDirection>, + on_cursor: F, + on_selection: G, + ) where + F: FnOnce(usize), + G: FnOnce(usize, usize), + { + if let Some(result) = sync_cursor_from_dom_impl(&self.editor_id, offset_map, direction_hint) + { + match result { + CursorSyncResult::Cursor(offset) => on_cursor(offset), + CursorSyncResult::Selection { anchor, head } => { + if anchor == head { + on_cursor(anchor); + } else { + on_selection(anchor, head); + } + } + CursorSyncResult::None => {} + } + } + } +} + +/// Sync cursor state from DOM selection, returning the result. +/// +/// This is the core implementation that reads the browser's selection state +/// and converts it to character offsets using the offset map. +pub fn sync_cursor_from_dom_impl( + editor_id: &str, + offset_map: &[OffsetMapping], + direction_hint: Option<SnapDirection>, +) -> Option<CursorSyncResult> { + if offset_map.is_empty() { + return Some(CursorSyncResult::None); + } + + let window = web_sys::window()?; + let dom_document = window.document()?; + let editor_element = dom_document.get_element_by_id(editor_id)?; + + let selection = window.get_selection().ok()??; + + let anchor_node = selection.anchor_node()?; + let focus_node = selection.focus_node()?; + let anchor_offset = selection.anchor_offset() as usize; + let focus_offset = selection.focus_offset() as usize; + + let anchor_char = dom_position_to_text_offset( + &dom_document, + &editor_element, + &anchor_node, + anchor_offset, + offset_map, + direction_hint, + ); + let focus_char = dom_position_to_text_offset( + &dom_document, + &editor_element, + &focus_node, + focus_offset, + offset_map, + direction_hint, + ); + + match (anchor_char, focus_char) { + (Some(anchor), Some(head)) => { + if anchor == head { + Some(CursorSyncResult::Cursor(head)) + } else { + Some(CursorSyncResult::Selection { anchor, head }) + } + } + _ => { + tracing::warn!("Could not map DOM selection to text offsets"); + Some(CursorSyncResult::None) + } + } +} + +/// Convert a DOM position (node + offset) to a text char offset. +/// +/// Walks up from the node to find a container with a node ID, then uses +/// the offset map to convert the UTF-16 offset to a character offset. +pub fn dom_position_to_text_offset( + dom_document: &web_sys::Document, + editor_element: &web_sys::Element, + node: &web_sys::Node, + offset_in_text_node: usize, + offset_map: &[OffsetMapping], + direction_hint: Option<SnapDirection>, +) -> Option<usize> { + // Find the containing element with a node ID (walk up from text node). + let mut current_node = node.clone(); + let mut walked_from: Option<web_sys::Node> = None; + + let node_id = loop { + if let Some(element) = current_node.dyn_ref::<web_sys::Element>() { + if element == editor_element { + // Selection is on the editor container itself. + if let Some(ref walked_node) = walked_from { + // We walked up from a descendant - find which mapping it belongs to. + for mapping in offset_map { + if let Some(elem) = dom_document.get_element_by_id(&mapping.node_id) { + let elem_node: &web_sys::Node = elem.as_ref(); + if elem_node.contains(Some(walked_node)) { + return Some(mapping.char_range.start); + } + } + } + break None; + } + + // Selection is directly on the editor container (e.g., Cmd+A). + let child_count = editor_element.child_element_count() as usize; + if offset_in_text_node == 0 { + return Some(0); + } else if offset_in_text_node >= child_count { + return offset_map.last().map(|m| m.char_range.end); + } + break None; + } + + let id = element + .get_attribute("id") + .or_else(|| element.get_attribute("data-node-id")); + + if let Some(id) = id { + let is_node_id = id.starts_with('n') || id.contains("-n"); + if is_node_id { + break Some(id); + } + } + } + + walked_from = Some(current_node.clone()); + current_node = current_node.parent_node()?; + }; + + let node_id = node_id?; + + let container = dom_document.get_element_by_id(&node_id).or_else(|| { + let selector = format!("[data-node-id='{}']", node_id); + dom_document.query_selector(&selector).ok().flatten() + })?; + + // Calculate UTF-16 offset from start of container to the position. + let mut utf16_offset_in_container = 0; + + let node_is_container = node + .dyn_ref::<web_sys::Element>() + .map(|e| e == &container) + .unwrap_or(false); + + if node_is_container { + // offset_in_text_node is a child index. + let child_index = offset_in_text_node; + let children = container.child_nodes(); + let mut text_counted = 0usize; + + for i in 0..child_index.min(children.length() as usize) { + if let Some(child) = children.get(i as u32) { + if let Some(text) = child.text_content() { + text_counted += text.encode_utf16().count(); + } + } + } + utf16_offset_in_container = text_counted; + } else { + // Normal case: node is a text node, walk to find it. + if let Ok(walker) = + dom_document.create_tree_walker_with_what_to_show(&container, 0xFFFFFFFF) + { + let mut skip_until_exit: Option<web_sys::Element> = None; + + while let Ok(Some(dom_node)) = walker.next_node() { + if let Some(ref skip_elem) = skip_until_exit { + if !skip_elem.contains(Some(&dom_node)) { + skip_until_exit = None; + } + } + + if skip_until_exit.is_none() { + if let Some(element) = dom_node.dyn_ref::<web_sys::Element>() { + if element.get_attribute("contenteditable").as_deref() == Some("false") { + skip_until_exit = Some(element.clone()); + continue; + } + } + } + + if skip_until_exit.is_some() { + continue; + } + + if dom_node.node_type() == web_sys::Node::TEXT_NODE { + if &dom_node == node { + utf16_offset_in_container += offset_in_text_node; + break; + } + + if let Some(text) = dom_node.text_content() { + utf16_offset_in_container += text.encode_utf16().count(); + } + } + } + } + } + + // Look up the offset in the offset map. + for mapping in offset_map { + if mapping.node_id == node_id { + let mapping_start = mapping.char_offset_in_node; + let mapping_end = mapping.char_offset_in_node + mapping.utf16_len; + + if utf16_offset_in_container >= mapping_start + && utf16_offset_in_container <= mapping_end + { + let offset_in_mapping = utf16_offset_in_container - mapping_start; + let char_offset = mapping.char_range.start + offset_in_mapping; + + // Check if position is valid (not on invisible content). + if is_valid_cursor_position(offset_map, char_offset) { + return Some(char_offset); + } + + // Position is on invisible content, snap to nearest valid. + if let Some(snapped) = + find_nearest_valid_position(offset_map, char_offset, direction_hint) + { + return Some(snapped.char_offset()); + } + + return Some(char_offset); + } + } + } + + // No mapping found - try to find any valid position. + if let Some(snapped) = find_nearest_valid_position(offset_map, 0, direction_hint) { + return Some(snapped.char_offset()); + } + + None +} + +/// Paragraph render data needed for DOM updates. +/// +/// This is a simplified view of paragraph data for the DOM sync layer. +pub struct ParagraphDomData<'a> { + /// Paragraph ID (for DOM element lookup). + pub id: &'a str, + /// HTML content to render. + pub html: &'a str, + /// Source hash for change detection. + pub source_hash: u64, + /// Character range in document. + pub char_range: std::ops::Range<usize>, + /// Offset mappings for cursor restoration. + pub offset_map: &'a [OffsetMapping], +} + +/// Update paragraph DOM elements incrementally. +/// +/// Returns true if the paragraph containing the cursor was updated. +pub fn update_paragraph_dom( + editor_id: &str, + old_paragraphs: &[ParagraphDomData<'_>], + new_paragraphs: &[ParagraphDomData<'_>], + cursor_offset: usize, + force: bool, +) -> bool { + use std::collections::HashMap; + + let window = match web_sys::window() { + Some(w) => w, + None => return false, + }; + + let document = match window.document() { + Some(d) => d, + None => return false, + }; + + let editor = match document.get_element_by_id(editor_id) { + Some(e) => e, + None => return false, + }; + + let mut cursor_para_updated = false; + + // Build pool of existing DOM elements by ID. + let mut old_elements: HashMap<String, web_sys::Element> = HashMap::new(); + let mut child_opt = editor.first_element_child(); + while let Some(child) = child_opt { + if let Some(id) = child.get_attribute("id") { + let next = child.next_element_sibling(); + old_elements.insert(id, child); + child_opt = next; + } else { + child_opt = child.next_element_sibling(); + } + } + + let mut cursor_node: Option<web_sys::Node> = editor.first_element_child().map(|e| e.into()); + + for new_para in new_paragraphs.iter() { + let para_id = new_para.id; + let new_hash = format!("{:x}", new_para.source_hash); + let is_cursor_para = new_para.char_range.start <= cursor_offset + && cursor_offset <= new_para.char_range.end; + + if let Some(existing_elem) = old_elements.remove(para_id) { + let old_hash = existing_elem.get_attribute("data-hash").unwrap_or_default(); + let needs_update = force || old_hash != new_hash; + + let existing_as_node: &web_sys::Node = existing_elem.as_ref(); + let at_correct_position = cursor_node + .as_ref() + .map(|c| c == existing_as_node) + .unwrap_or(false); + + if !at_correct_position { + let _ = editor.insert_before(existing_as_node, cursor_node.as_ref()); + if is_cursor_para { + cursor_para_updated = true; + } + } else { + cursor_node = existing_elem.next_element_sibling().map(|e| e.into()); + } + + if needs_update { + existing_elem.set_inner_html(new_para.html); + let _ = existing_elem.set_attribute("data-hash", &new_hash); + + if is_cursor_para { + if let Err(e) = + restore_cursor_position(cursor_offset, new_para.offset_map, None) + { + tracing::warn!("Cursor restore failed: {:?}", e); + } + cursor_para_updated = true; + } + } + } else { + // New element - create and insert. + if let Ok(div) = document.create_element("div") { + div.set_id(para_id); + div.set_inner_html(new_para.html); + let _ = div.set_attribute("data-hash", &new_hash); + let div_node: &web_sys::Node = div.as_ref(); + let _ = editor.insert_before(div_node, cursor_node.as_ref()); + } + + if is_cursor_para { + cursor_para_updated = true; + } + } + } + + // Remove stale elements. + for (_, elem) in old_elements { + let _ = elem.remove(); + cursor_para_updated = true; + } + + cursor_para_updated +} diff --git a/crates/weaver-editor-browser/src/events.rs b/crates/weaver-editor-browser/src/events.rs new file mode 100644 index 0000000..097e3c0 --- /dev/null +++ b/crates/weaver-editor-browser/src/events.rs @@ -0,0 +1,5 @@ +//! Browser event handling for the editor. +//! +//! Handles beforeinput, keydown, paste, and other DOM events. + +// TODO: Migrate from weaver-app (beforeinput.rs, input.rs) diff --git a/crates/weaver-editor-browser/src/lib.rs b/crates/weaver-editor-browser/src/lib.rs new file mode 100644 index 0000000..56ab36d --- /dev/null +++ b/crates/weaver-editor-browser/src/lib.rs @@ -0,0 +1,36 @@ +//! Browser DOM layer for the weaver markdown editor. +//! +//! This crate provides DOM manipulation and browser event handling, +//! generic over any `EditorDocument` implementation. It assumes a +//! `wasm32-unknown-unknown` target environment. +//! +//! # Architecture +//! +//! - `cursor`: Selection API handling and cursor restoration +//! - `dom_sync`: DOM ↔ document state synchronization +//! - `events`: beforeinput, keydown, paste event handlers +//! - `contenteditable`: Editor element setup and management +//! - `platform`: Browser/OS detection for platform-specific behavior +//! +//! # Re-exports +//! +//! This crate re-exports `weaver-editor-core` for convenience, so consumers +//! only need to depend on `weaver-editor-browser`. + +// Re-export core crate +pub use weaver_editor_core; +pub use weaver_editor_core::*; + +pub mod cursor; +pub mod dom_sync; +pub mod events; +pub mod platform; + +// Browser cursor implementation +pub use cursor::BrowserCursor; + +// Platform detection +pub use platform::{Platform, platform}; + +// TODO: contenteditable module +// TODO: embed worker module diff --git a/crates/weaver-editor-browser/src/platform.rs b/crates/weaver-editor-browser/src/platform.rs new file mode 100644 index 0000000..bb84269 --- /dev/null +++ b/crates/weaver-editor-browser/src/platform.rs @@ -0,0 +1,108 @@ +//! Platform detection for browser-specific workarounds. +//! +//! Based on patterns from ProseMirror's input handling, adapted for Rust/wasm. + +use std::sync::OnceLock; + +/// Cached platform detection results. +#[derive(Debug, Clone)] +pub struct Platform { + pub ios: bool, + pub mac: bool, + pub android: bool, + pub chrome: bool, + pub safari: bool, + pub gecko: bool, + pub webkit_version: Option<u32>, + pub chrome_version: Option<u32>, + pub mobile: bool, +} + +impl Default for Platform { + fn default() -> Self { + Self { + ios: false, + mac: false, + android: false, + chrome: false, + safari: false, + gecko: false, + webkit_version: None, + chrome_version: None, + mobile: false, + } + } +} + +static PLATFORM: OnceLock<Platform> = OnceLock::new(); + +/// Get cached platform info. Detection runs once on first call. +pub fn platform() -> &'static Platform { + PLATFORM.get_or_init(detect_platform) +} + +fn detect_platform() -> Platform { + let window = match web_sys::window() { + Some(w) => w, + None => return Platform::default(), + }; + + let navigator = window.navigator(); + let user_agent = navigator.user_agent().unwrap_or_default().to_lowercase(); + let platform_str = navigator.platform().unwrap_or_default().to_lowercase(); + + // iOS detection: iPhone/iPad/iPod in UA, or Mac platform with touch. + let ios = user_agent.contains("iphone") + || user_agent.contains("ipad") + || user_agent.contains("ipod") + || (platform_str.contains("mac") && has_touch_support(&navigator)); + + // macOS (but not iOS). + let mac = platform_str.contains("mac") && !ios; + + // Android. + let android = user_agent.contains("android"); + + // Chrome (but not Edge, which also contains Chrome). + let chrome = user_agent.contains("chrome") && !user_agent.contains("edg"); + + // Safari (WebKit but not Chrome). + let safari = user_agent.contains("safari") && !user_agent.contains("chrome"); + + // Firefox/Gecko. + let gecko = user_agent.contains("gecko/") && !user_agent.contains("like gecko"); + + // WebKit version extraction. + let webkit_version = extract_version(&user_agent, "applewebkit/"); + + // Chrome version extraction. + let chrome_version = extract_version(&user_agent, "chrome/"); + + // Mobile detection. + let mobile = + ios || android || user_agent.contains("mobile") || user_agent.contains("iemobile"); + + Platform { + ios, + mac, + android, + chrome, + safari, + gecko, + webkit_version, + chrome_version, + mobile, + } +} + +fn has_touch_support(navigator: &web_sys::Navigator) -> bool { + navigator.max_touch_points() > 0 +} + +fn extract_version(ua: &str, prefix: &str) -> Option<u32> { + ua.find(prefix).and_then(|idx| { + let after = &ua[idx + prefix.len()..]; + let version_str: String = after.chars().take_while(|c| c.is_ascii_digit()).collect(); + version_str.parse().ok() + }) +} diff --git a/crates/weaver-editor-core/src/actions.rs b/crates/weaver-editor-core/src/actions.rs new file mode 100644 index 0000000..a5ecf12 --- /dev/null +++ b/crates/weaver-editor-core/src/actions.rs @@ -0,0 +1,595 @@ +//! Editor actions and input types. +//! +//! Platform-agnostic definitions for editor operations. The `EditorAction` enum +//! represents semantic editing operations, while `InputType` represents the +//! semantic intent from input events (browser beforeinput, native input methods, etc.). + +use smol_str::SmolStr; + +/// A range in the document, measured in character offsets. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Range { + pub start: usize, + pub end: usize, +} + +impl Range { + pub fn new(start: usize, end: usize) -> Self { + Self { start, end } + } + + pub fn caret(offset: usize) -> Self { + Self { + start: offset, + end: offset, + } + } + + pub fn is_caret(&self) -> bool { + self.start == self.end + } + + pub fn len(&self) -> usize { + self.end.saturating_sub(self.start) + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Normalize range so start <= end. + pub fn normalize(self) -> Self { + if self.start <= self.end { + self + } else { + Self { + start: self.end, + end: self.start, + } + } + } +} + +impl From<std::ops::Range<usize>> for Range { + fn from(r: std::ops::Range<usize>) -> Self { + Self::new(r.start, r.end) + } +} + +impl From<Range> for std::ops::Range<usize> { + fn from(r: Range) -> Self { + r.start..r.end + } +} + +/// Semantic input types from input events. +/// +/// These represent the semantic intent of an input operation, abstracted from +/// the platform-specific event source. Browser `beforeinput` events, native +/// input methods, and programmatic input can all produce these types. +/// +/// Based on the W3C Input Events specification, but usable across platforms. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InputType { + // === Insertion === + /// Insert typed text. + InsertText, + /// Insert text from IME composition. + InsertCompositionText, + /// Insert a line break (`<br>`, Shift+Enter). + InsertLineBreak, + /// Insert a paragraph break (Enter). + InsertParagraph, + /// Insert from paste operation. + InsertFromPaste, + /// Insert from drop operation. + InsertFromDrop, + /// Insert replacement text (e.g., spell check correction). + InsertReplacementText, + /// Insert from voice input or other source. + InsertFromYank, + /// Insert a horizontal rule. + InsertHorizontalRule, + /// Insert an ordered list. + InsertOrderedList, + /// Insert an unordered list. + InsertUnorderedList, + /// Insert a link. + InsertLink, + + // === Deletion === + /// Delete content backward (Backspace). + DeleteContentBackward, + /// Delete content forward (Delete key). + DeleteContentForward, + /// Delete word backward (Ctrl/Alt+Backspace). + DeleteWordBackward, + /// Delete word forward (Ctrl/Alt+Delete). + DeleteWordForward, + /// Delete to soft line boundary backward. + DeleteSoftLineBackward, + /// Delete to soft line boundary forward. + DeleteSoftLineForward, + /// Delete to hard line boundary backward (Cmd+Backspace on Mac). + DeleteHardLineBackward, + /// Delete to hard line boundary forward (Cmd+Delete on Mac). + DeleteHardLineForward, + /// Delete by cut operation. + DeleteByCut, + /// Delete by drag operation. + DeleteByDrag, + /// Generic content deletion. + DeleteContent, + /// Delete entire word backward. + DeleteEntireWordBackward, + /// Delete entire word forward. + DeleteEntireWordForward, + + // === History === + /// Undo. + HistoryUndo, + /// Redo. + HistoryRedo, + + // === Formatting === + FormatBold, + FormatItalic, + FormatUnderline, + FormatStrikethrough, + FormatSuperscript, + FormatSubscript, + + // === Unknown === + /// Unrecognized input type. + Unknown(String), +} + +impl InputType { + /// Whether this input type is a deletion operation. + pub fn is_deletion(&self) -> bool { + matches!( + self, + Self::DeleteContentBackward + | Self::DeleteContentForward + | Self::DeleteWordBackward + | Self::DeleteWordForward + | Self::DeleteSoftLineBackward + | Self::DeleteSoftLineForward + | Self::DeleteHardLineBackward + | Self::DeleteHardLineForward + | Self::DeleteByCut + | Self::DeleteByDrag + | Self::DeleteContent + | Self::DeleteEntireWordBackward + | Self::DeleteEntireWordForward + ) + } + + /// Whether this input type is an insertion operation. + pub fn is_insertion(&self) -> bool { + matches!( + self, + Self::InsertText + | Self::InsertCompositionText + | Self::InsertLineBreak + | Self::InsertParagraph + | Self::InsertFromPaste + | Self::InsertFromDrop + | Self::InsertReplacementText + | Self::InsertFromYank + ) + } +} + +/// All possible editor actions. +/// +/// These represent semantic operations on the document, decoupled from +/// how they're triggered (keyboard, mouse, touch, voice, etc.). +#[derive(Debug, Clone, PartialEq)] +pub enum EditorAction { + // === Text Insertion === + /// Insert text at the given range (replacing any selected content). + Insert { text: String, range: Range }, + + /// Insert a soft line break (Shift+Enter, `<br>` equivalent). + InsertLineBreak { range: Range }, + + /// Insert a paragraph break (Enter). + InsertParagraph { range: Range }, + + // === Deletion === + /// Delete content backward (Backspace). + DeleteBackward { range: Range }, + + /// Delete content forward (Delete key). + DeleteForward { range: Range }, + + /// Delete word backward (Ctrl/Alt+Backspace). + DeleteWordBackward { range: Range }, + + /// Delete word forward (Ctrl/Alt+Delete). + DeleteWordForward { range: Range }, + + /// Delete to start of line (Cmd+Backspace on Mac). + DeleteToLineStart { range: Range }, + + /// Delete to end of line (Cmd+Delete on Mac). + DeleteToLineEnd { range: Range }, + + /// Delete to start of soft line (visual line in wrapped text). + DeleteSoftLineBackward { range: Range }, + + /// Delete to end of soft line. + DeleteSoftLineForward { range: Range }, + + // === History === + /// Undo the last change. + Undo, + + /// Redo the last undone change. + Redo, + + // === Formatting === + /// Toggle bold on selection. + ToggleBold, + + /// Toggle italic on selection. + ToggleItalic, + + /// Toggle inline code on selection. + ToggleCode, + + /// Toggle strikethrough on selection. + ToggleStrikethrough, + + /// Insert/wrap with link. + InsertLink, + + // === Clipboard === + /// Cut selection to clipboard. + Cut, + + /// Copy selection to clipboard. + Copy, + + /// Paste from clipboard at range. + Paste { range: Range }, + + /// Copy selection as rendered HTML. + CopyAsHtml, + + // === Selection === + /// Select all content. + SelectAll, + + // === Navigation === + /// Move cursor to position. + MoveCursor { offset: usize }, + + /// Extend selection to position. + ExtendSelection { offset: usize }, +} + +impl EditorAction { + /// Update the range in actions that use one. + pub fn with_range(self, range: Range) -> Self { + match self { + Self::Insert { text, .. } => Self::Insert { text, range }, + Self::InsertLineBreak { .. } => Self::InsertLineBreak { range }, + Self::InsertParagraph { .. } => Self::InsertParagraph { range }, + Self::DeleteBackward { .. } => Self::DeleteBackward { range }, + Self::DeleteForward { .. } => Self::DeleteForward { range }, + Self::DeleteWordBackward { .. } => Self::DeleteWordBackward { range }, + Self::DeleteWordForward { .. } => Self::DeleteWordForward { range }, + Self::DeleteToLineStart { .. } => Self::DeleteToLineStart { range }, + Self::DeleteToLineEnd { .. } => Self::DeleteToLineEnd { range }, + Self::DeleteSoftLineBackward { .. } => Self::DeleteSoftLineBackward { range }, + Self::DeleteSoftLineForward { .. } => Self::DeleteSoftLineForward { range }, + Self::Paste { .. } => Self::Paste { range }, + other => other, + } + } +} + +/// Key values for keyboard input. +/// +/// Platform-agnostic key representation. Platform-specific code converts +/// from native key events to this enum. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Key { + /// A character key. + Character(SmolStr), + + /// Unknown/unidentified key. + Unidentified, + + // === Whitespace / editing === + Backspace, + Delete, + Enter, + Tab, + Escape, + Space, + Insert, + Clear, + + // === Navigation === + ArrowLeft, + ArrowRight, + ArrowUp, + ArrowDown, + Home, + End, + PageUp, + PageDown, + + // === Modifiers === + Alt, + AltGraph, + CapsLock, + Control, + Fn, + FnLock, + Meta, + NumLock, + ScrollLock, + Shift, + Symbol, + SymbolLock, + Hyper, + Super, + + // === Function keys === + F1, + F2, + F3, + F4, + F5, + F6, + F7, + F8, + F9, + F10, + F11, + F12, + F13, + F14, + F15, + F16, + F17, + F18, + F19, + F20, + + // === UI keys === + ContextMenu, + PrintScreen, + Pause, + Help, + + // === Clipboard / editing commands === + Copy, + Cut, + Paste, + Undo, + Redo, + Find, + Select, + + // === Media keys === + MediaPlayPause, + MediaStop, + MediaTrackNext, + MediaTrackPrevious, + AudioVolumeDown, + AudioVolumeUp, + AudioVolumeMute, + + // === IME / composition === + Compose, + Convert, + NonConvert, + Dead, + + // === CJK IME keys === + HangulMode, + HanjaMode, + JunjaMode, + Eisu, + Hankaku, + Hiragana, + HiraganaKatakana, + KanaMode, + KanjiMode, + Katakana, + Romaji, + Zenkaku, + ZenkakuHankaku, +} + +impl Key { + /// Create a character key. + pub fn character(s: impl Into<SmolStr>) -> Self { + Self::Character(s.into()) + } + + /// Check if this is a navigation key. + pub fn is_navigation(&self) -> bool { + matches!( + self, + Self::ArrowLeft + | Self::ArrowRight + | Self::ArrowUp + | Self::ArrowDown + | Self::Home + | Self::End + | Self::PageUp + | Self::PageDown + ) + } + + /// Check if this is a modifier key. + pub fn is_modifier(&self) -> bool { + matches!( + self, + Self::Alt + | Self::AltGraph + | Self::CapsLock + | Self::Control + | Self::Fn + | Self::FnLock + | Self::Meta + | Self::NumLock + | Self::ScrollLock + | Self::Shift + | Self::Symbol + | Self::SymbolLock + | Self::Hyper + | Self::Super + ) + } +} + +/// Modifier key state for a key combination. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct Modifiers { + pub ctrl: bool, + pub alt: bool, + pub shift: bool, + pub meta: bool, +} + +impl Modifiers { + pub const NONE: Self = Self { + ctrl: false, + alt: false, + shift: false, + meta: false, + }; + + pub const CTRL: Self = Self { + ctrl: true, + alt: false, + shift: false, + meta: false, + }; + + pub const ALT: Self = Self { + ctrl: false, + alt: true, + shift: false, + meta: false, + }; + + pub const SHIFT: Self = Self { + ctrl: false, + alt: false, + shift: true, + meta: false, + }; + + pub const META: Self = Self { + ctrl: false, + alt: false, + shift: false, + meta: true, + }; + + pub const CTRL_SHIFT: Self = Self { + ctrl: true, + alt: false, + shift: true, + meta: false, + }; + + pub const META_SHIFT: Self = Self { + ctrl: false, + alt: false, + shift: true, + meta: true, + }; + + /// Get the primary modifier for the platform (Cmd on Mac, Ctrl elsewhere). + pub fn primary(is_mac: bool) -> Self { + if is_mac { + Self::META + } else { + Self::CTRL + } + } + + /// Get the primary modifier + Shift for the platform. + pub fn primary_shift(is_mac: bool) -> Self { + if is_mac { + Self::META_SHIFT + } else { + Self::CTRL_SHIFT + } + } +} + +/// A key combination for triggering an action. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct KeyCombo { + pub key: Key, + pub modifiers: Modifiers, +} + +impl KeyCombo { + pub fn new(key: Key) -> Self { + Self { + key, + modifiers: Modifiers::NONE, + } + } + + pub fn with_modifiers(key: Key, modifiers: Modifiers) -> Self { + Self { key, modifiers } + } + + pub fn ctrl(key: Key) -> Self { + Self { + key, + modifiers: Modifiers::CTRL, + } + } + + pub fn meta(key: Key) -> Self { + Self { + key, + modifiers: Modifiers::META, + } + } + + pub fn shift(key: Key) -> Self { + Self { + key, + modifiers: Modifiers::SHIFT, + } + } + + pub fn primary(key: Key, is_mac: bool) -> Self { + Self { + key, + modifiers: Modifiers::primary(is_mac), + } + } + + pub fn primary_shift(key: Key, is_mac: bool) -> Self { + Self { + key, + modifiers: Modifiers::primary_shift(is_mac), + } + } +} + +/// Result of handling a keydown event. +#[derive(Debug, Clone, PartialEq)] +pub enum KeydownResult { + /// Event was handled, prevent default. + Handled, + /// Event was not a keybinding, let platform handle it. + NotHandled, + /// Event should be passed through (navigation, etc.). + PassThrough, +} diff --git a/crates/weaver-editor-core/src/lib.rs b/crates/weaver-editor-core/src/lib.rs index 5cfb34b..5edb914 100644 --- a/crates/weaver-editor-core/src/lib.rs +++ b/crates/weaver-editor-core/src/lib.rs @@ -11,6 +11,7 @@ pub mod document; pub mod offset_map; pub mod paragraph; +pub mod platform; pub mod render; pub mod syntax; pub mod text; @@ -28,10 +29,12 @@ pub use smol_str::SmolStr; pub use syntax::{SyntaxSpanInfo, SyntaxType, classify_syntax}; pub use text::{EditorRope, TextBuffer}; pub use types::{ - Affinity, CompositionState, CursorState, EditInfo, EditorImage, Selection, BLOCK_SYNTAX_ZONE, + Affinity, CompositionState, CursorRect, CursorState, EditInfo, EditorImage, Selection, + SelectionRect, BLOCK_SYNTAX_ZONE, }; pub use document::{EditorDocument, PlainEditor}; pub use render::{EmbedContentProvider, ImageResolver, WikilinkValidator}; pub use undo::{UndoManager, UndoableBuffer}; pub use visibility::VisibilityState; pub use writer::{EditorImageResolver, EditorWriter, SegmentedWriter, WriterResult}; +pub use platform::{CursorPlatform, CursorSync, PlatformError}; diff --git a/crates/weaver-editor-core/src/platform.rs b/crates/weaver-editor-core/src/platform.rs new file mode 100644 index 0000000..d336d5a --- /dev/null +++ b/crates/weaver-editor-core/src/platform.rs @@ -0,0 +1,105 @@ +//! Platform abstraction traits for editor operations. +//! +//! These traits define the interface between the editor logic and platform-specific +//! implementations (browser DOM, native UI, etc.). This enables the same editor +//! logic to work across different platforms. + +use crate::offset_map::SnapDirection; +use crate::types::{CursorRect, SelectionRect}; +use crate::OffsetMapping; + +/// Error type for platform operations. +#[derive(Debug, Clone)] +pub struct PlatformError(pub String); + +impl std::fmt::Display for PlatformError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for PlatformError {} + +impl From<&str> for PlatformError { + fn from(s: &str) -> Self { + PlatformError(s.to_string()) + } +} + +impl From<String> for PlatformError { + fn from(s: String) -> Self { + PlatformError(s) + } +} + +/// Platform-specific cursor and selection operations. +/// +/// Implementations handle the actual UI interaction for cursor positioning +/// and selection rendering. The browser implementation uses the DOM Selection API, +/// native implementations would use their respective UI frameworks. +pub trait CursorPlatform { + /// Restore cursor position in the UI after content changes. + /// + /// Given a character offset and the current offset map, positions the cursor + /// in the rendered content. The snap direction is used when the offset falls + /// on invisible content (formatting syntax). + fn restore_cursor( + &self, + char_offset: usize, + offset_map: &[OffsetMapping], + snap_direction: Option<SnapDirection>, + ) -> Result<(), PlatformError>; + + /// Get the screen coordinates for a cursor at the given offset. + /// + /// Returns None if the offset cannot be mapped to screen coordinates. + fn get_cursor_rect( + &self, + char_offset: usize, + offset_map: &[OffsetMapping], + ) -> Option<CursorRect>; + + /// Get screen coordinates relative to the editor container. + /// + /// Same as `get_cursor_rect` but coordinates are relative to the editor + /// element rather than the viewport. + fn get_cursor_rect_relative( + &self, + char_offset: usize, + offset_map: &[OffsetMapping], + ) -> Option<CursorRect>; + + /// Get screen rectangles for a selection range. + /// + /// Returns multiple rects if the selection spans multiple lines. + /// Coordinates are relative to the editor container. + fn get_selection_rects_relative( + &self, + start: usize, + end: usize, + offset_map: &[OffsetMapping], + ) -> Vec<SelectionRect>; +} + +/// Platform-specific cursor state synchronization. +/// +/// Handles reading the current cursor/selection state from the platform UI +/// back into the editor model. This is the inverse of `CursorPlatform`. +pub trait CursorSync { + /// Sync cursor state from the platform UI into the provided callbacks. + /// + /// The implementation reads the current selection from the UI and calls + /// the appropriate callback with the character offset(s). + /// + /// - For a collapsed cursor: calls `on_cursor(offset)` + /// - For a selection: calls `on_selection(anchor, head)` + fn sync_cursor_from_platform<F, G>( + &self, + offset_map: &[OffsetMapping], + direction_hint: Option<SnapDirection>, + on_cursor: F, + on_selection: G, + ) where + F: FnOnce(usize), + G: FnOnce(usize, usize); +} diff --git a/crates/weaver-editor-core/src/types.rs b/crates/weaver-editor-core/src/types.rs index d641da6..756b754 100644 --- a/crates/weaver-editor-core/src/types.rs +++ b/crates/weaver-editor-core/src/types.rs @@ -223,6 +223,51 @@ impl EditInfo { /// Covers: `######` (6), ```` ``` ```` (3), `> ` (2), `- ` (2), `999. ` (5) pub const BLOCK_SYNTAX_ZONE: usize = 6; +// === Platform-agnostic geometry types === + +/// Screen coordinates for a cursor position. +/// +/// Represents the bounding box of a cursor caret in screen space. +/// Platform implementations fill this from their native APIs. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CursorRect { + /// X coordinate (pixels from left). + pub x: f64, + /// Y coordinate (pixels from top). + pub y: f64, + /// Height of the cursor line (pixels). + pub height: f64, +} + +impl CursorRect { + /// Create a new cursor rect. + pub fn new(x: f64, y: f64, height: f64) -> Self { + Self { x, y, height } + } +} + +/// Screen rectangle for part of a selection. +/// +/// A selection spanning multiple lines produces multiple rects (one per line). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SelectionRect { + /// X coordinate (pixels from left). + pub x: f64, + /// Y coordinate (pixels from top). + pub y: f64, + /// Width of this selection segment (pixels). + pub width: f64, + /// Height of this selection segment (pixels). + pub height: f64, +} + +impl SelectionRect { + /// Create a new selection rect. + pub fn new(x: f64, y: f64, width: f64, height: f64) -> Self { + Self { x, y, width, height } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/docs/.nojekyll b/docs/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/docs/git-history.json b/docs/git-history.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/docs/git-history.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/docs/graph-data.json b/docs/graph-data.json new file mode 100644 index 0000000..3bb79b4 --- /dev/null +++ b/docs/graph-data.json @@ -0,0 +1,2723 @@ +{ + "nodes": [ + { + "id": 1, + "change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", + "node_type": "goal", + "title": "Build weaver.sh - decentralized notebook platform on AT Protocol", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:30:44.744866776-05:00", + "updated_at": "2026-01-06T09:30:44.744866776-05:00", + "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.\"}" + }, + { + "id": 2, + "change_id": "850c82f8-e6b6-4b8b-bd70-e51bcf43d0a5", + "node_type": "goal", + "title": "Build jacquard - better AT Protocol library for Rust", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:30:53.847514686-05:00", + "updated_at": "2026-01-06T09:30:53.847514686-05:00", + "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.\"}" + }, + { + "id": 3, + "change_id": "58113fdb-140c-4844-844a-b51eee292c58", + "node_type": "decision", + "title": "Choose frontend technology for weaver-app", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:30:59.214254222-05:00", + "updated_at": "2026-01-06T09:30:59.214254222-05:00", + "metadata_json": "{\"confidence\":90}" + }, + { + "id": 4, + "change_id": "3c1e5278-cb0d-4f8b-9605-89225dfe66e2", + "node_type": "option", + "title": "Dioxus (Rust → WASM)", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:30:59.356059174-05:00", + "updated_at": "2026-01-06T09:30:59.356059174-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 5, + "change_id": "19e5dad0-b0d8-4eba-8af1-028430ddc69c", + "node_type": "option", + "title": "JavaScript framework (React/Vue/Svelte)", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:30:59.459096788-05:00", + "updated_at": "2026-01-06T09:30:59.459096788-05:00", + "metadata_json": "{\"confidence\":30}" + }, + { + "id": 6, + "change_id": "ef3edcf6-e8a8-4d48-9e9e-3885abb53a30", + "node_type": "decision", + "title": "Build custom markdown editor vs use existing library", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:31:11.394522567-05:00", + "updated_at": "2026-01-06T09:31:11.394522567-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 7, + "change_id": "5688af7d-61e5-4b47-afaa-05ee5bff36ae", + "node_type": "option", + "title": "Build custom Rust editor from scratch", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:31:11.508395907-05:00", + "updated_at": "2026-01-06T09:31:11.508395907-05:00", + "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.\"}" + }, + { + "id": 8, + "change_id": "d28249c5-955c-45ad-bbff-d76f437055c3", + "node_type": "option", + "title": "Use existing JS editor (ProseMirror/CodeMirror/Lexical)", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:31:11.619726672-05:00", + "updated_at": "2026-01-06T09:31:11.619726672-05:00", + "metadata_json": "{\"confidence\":20}" + }, + { + "id": 9, + "change_id": "301c77f5-27ce-422d-8be7-eec136c3c856", + "node_type": "decision", + "title": "Editor text storage model", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:31:18.146739807-05:00", + "updated_at": "2026-01-06T09:31:18.146739807-05:00", + "metadata_json": "{\"confidence\":90}" + }, + { + "id": 10, + "change_id": "5738a9b1-2767-4aab-ad59-b7fe9ee6728e", + "node_type": "option", + "title": "JumpRope (initial choice)", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:31:18.255664070-05:00", + "updated_at": "2026-01-06T09:31:18.255664070-05:00", + "metadata_json": "{\"confidence\":70}" + }, + { + "id": 11, + "change_id": "d7d9d021-6d36-48ed-a7c1-f7b0a7576c2e", + "node_type": "option", + "title": "Ropey (current/planned for core)", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:31:18.374214999-05:00", + "updated_at": "2026-01-06T09:31:18.374214999-05:00", + "metadata_json": "{\"confidence\":85}" + }, + { + "id": 12, + "change_id": "aef81c95-7a98-4dc6-bdc8-ab1df9823db8", + "node_type": "option", + "title": "Loro (for CRDT/collab)", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:31:18.479773207-05:00", + "updated_at": "2026-01-06T09:31:18.479773207-05:00", + "metadata_json": "{\"confidence\":90}" + }, + { + "id": 13, + "change_id": "cf8c5176-4419-47fd-a7d6-f59ee8806b6f", + "node_type": "decision", + "title": "Editor input handling model", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:31:28.658538075-05:00", + "updated_at": "2026-01-06T09:31:28.658538075-05:00", + "metadata_json": "{\"confidence\":90}" + }, + { + "id": 14, + "change_id": "290a2bda-6f84-4983-bda6-1870dfea8401", + "node_type": "option", + "title": "beforeinput event interception (ProseMirror-style)", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:31:28.789196438-05:00", + "updated_at": "2026-01-06T09:31:28.789196438-05:00", + "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.\"}" + }, + { + "id": 15, + "change_id": "374172ea-9738-4f5e-a176-1b5e8bd21568", + "node_type": "option", + "title": "Hidden textarea + contenteditable overlay", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:31:28.906825132-05:00", + "updated_at": "2026-01-06T09:31:28.906825132-05:00", + "metadata_json": "{\"confidence\":20}" + }, + { + "id": 16, + "change_id": "bac8484a-5eb5-42bf-9387-55d90fe486cd", + "node_type": "decision", + "title": "weaver-index backend architecture", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:31:39.080366535-05:00", + "updated_at": "2026-01-06T09:31:39.080366535-05:00", + "metadata_json": "{\"confidence\":90}" + }, + { + "id": 17, + "change_id": "af0827fd-530e-498c-88ad-082a648a93f4", + "node_type": "option", + "title": "Dual-backend: ClickHouse + SQLite shards", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:31:39.206228969-05:00", + "updated_at": "2026-01-06T09:31:39.206228969-05:00", + "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.\"}" + }, + { + "id": 18, + "change_id": "fa554b5d-8af7-42e4-b03f-e5bec837e31a", + "node_type": "goal", + "title": "Extract editor for external embedding", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:31:48.503441901-05:00", + "updated_at": "2026-01-06T09:31:48.503441901-05:00", + "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).\"}" + }, + { + "id": 19, + "change_id": "291fe7f0-b962-4663-b7d9-701dd38de1b5", + "node_type": "observation", + "title": "Obsidian-style editing UX is key requirement", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:31:57.676750492-05:00", + "updated_at": "2026-01-06T09:31:57.676750492-05:00", + "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.\"}" + }, + { + "id": 20, + "change_id": "8b418708-6db5-42ab-8153-b2dc64f13065", + "node_type": "observation", + "title": "weaver-flavoured markdown extends pulldown-cmark", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:31:57.795128457-05:00", + "updated_at": "2026-01-06T09:31:57.795128457-05:00", + "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.\"}" + }, + { + "id": 21, + "change_id": "e0a70314-85a3-4561-9348-fce0a887d7f9", + "node_type": "outcome", + "title": "Jacquard migration completed - cleaner codebase, better DX", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:36:43.586079216-05:00", + "updated_at": "2026-01-06T09:36:43.586079216-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 22, + "change_id": "40dd01d3-768c-4b31-8baf-7d027079d73f", + "node_type": "outcome", + "title": "weaver-app live at alpha.weaver.sh", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:36:43.700890598-05:00", + "updated_at": "2026-01-06T09:36:43.700890598-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 23, + "change_id": "708898ff-a587-4cda-9a2e-9376ce22f480", + "node_type": "outcome", + "title": "weaver-index live at index.weaver.sh", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:36:43.805008490-05:00", + "updated_at": "2026-01-06T09:36:43.805008490-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 24, + "change_id": "6b74f268-78fb-42ba-bb09-59e82496ac21", + "node_type": "outcome", + "title": "Custom editor working - Obsidian-style markdown editing functional", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:36:43.912905400-05:00", + "updated_at": "2026-01-06T09:36:43.912905400-05:00", + "metadata_json": "{\"confidence\":90}" + }, + { + "id": 25, + "change_id": "38fa6c2f-2a36-40d4-97ec-4e029501253b", + "node_type": "outcome", + "title": "CRDT collab via Loro operational", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:36:44.009269510-05:00", + "updated_at": "2026-01-06T09:36:44.009269510-05:00", + "metadata_json": "{\"confidence\":85}" + }, + { + "id": 26, + "change_id": "28d1b14d-5cb4-490e-87a2-db52aa233342", + "node_type": "decision", + "title": "AT Protocol authentication approach", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:37:19.161495320-05:00", + "updated_at": "2026-01-06T09:37:19.161495320-05:00", + "metadata_json": "{\"confidence\":90}" + }, + { + "id": 27, + "change_id": "00233162-061d-40bc-86c7-d74fddcd2ac4", + "node_type": "option", + "title": "Public web client (PKCE, no secret)", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:37:19.272219096-05:00", + "updated_at": "2026-01-06T09:37:19.272219096-05:00", + "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.\"}" + }, + { + "id": 28, + "change_id": "1a4d6505-b985-40db-9055-b42489331367", + "node_type": "option", + "title": "Confidential client (server-side OAuth)", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:37:19.389665396-05:00", + "updated_at": "2026-01-06T09:37:19.389665396-05:00", + "metadata_json": "{\"confidence\":60,\"prompt\":\"Future enhancement. Server-side OAuth with JWKs. Would enable more secure flows, refresh token rotation. Planned but not implemented.\"}" + }, + { + "id": 29, + "change_id": "e7f730f7-598a-44c9-9d86-22c86653a65e", + "node_type": "goal", + "title": "Subdomain hosting for notebooks", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:37:28.977819113-05:00", + "updated_at": "2026-01-06T09:37:28.977819113-05:00", + "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.\"}" + }, + { + "id": 30, + "change_id": "2eeb7345-a8a2-4146-bda5-1f19cfc7501d", + "node_type": "goal", + "title": "JS bindings for weaver-renderer", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:37:29.103458407-05:00", + "updated_at": "2026-01-06T09:37:29.103458407-05:00", + "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.\"}" + }, + { + "id": 31, + "change_id": "6d66ca53-5105-4078-bf7e-5f9a0e503012", + "node_type": "decision", + "title": "Editor text buffer migration", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:38:17.298013844-05:00", + "updated_at": "2026-01-06T09:38:17.298013844-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 32, + "change_id": "60829366-0740-47cc-9101-4057c9c7489e", + "node_type": "action", + "title": "Migrated from JumpRope to Loro CRDT", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:38:17.423583741-05:00", + "updated_at": "2026-01-06T09:38:17.423583741-05:00", + "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.\"}" + }, + { + "id": 33, + "change_id": "98e78259-5b13-413f-af77-bcb7354aca77", + "node_type": "action", + "title": "Implemented conditional syntax hiding", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:38:17.551284271-05:00", + "updated_at": "2026-01-06T09:38:17.551284271-05:00", + "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.\"}" + }, + { + "id": 34, + "change_id": "ddc3ed83-2f0f-4c53-8e9f-44076bbf3aac", + "node_type": "action", + "title": "Implemented IME composition support", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:38:17.681764618-05:00", + "updated_at": "2026-01-06T09:38:17.681764618-05:00", + "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.\"}" + }, + { + "id": 35, + "change_id": "874d2fd8-29bf-4137-a381-c9c14595da62", + "node_type": "observation", + "title": "sh.weaver lexicon namespace design", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:38:56.082354182-05:00", + "updated_at": "2026-01-06T09:38:56.082354182-05:00", + "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.\"}" + }, + { + "id": 36, + "change_id": "1f756beb-3275-4cb0-a1f4-df805cbc3a20", + "node_type": "decision", + "title": "Collaborative editing transport", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:38:56.197277628-05:00", + "updated_at": "2026-01-06T09:38:56.197277628-05:00", + "metadata_json": "{\"confidence\":85}" + }, + { + "id": 37, + "change_id": "aa268350-e43f-4b8f-8d72-2234b1af4940", + "node_type": "option", + "title": "iroh P2P with DERP relay fallback", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:38:56.311296491-05:00", + "updated_at": "2026-01-06T09:38:56.311296491-05:00", + "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.\"}" + }, + { + "id": 38, + "change_id": "68006be2-14d6-4666-b914-16c750ebd7d5", + "node_type": "action", + "title": "Implemented static site theming system", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:43:18.417607073-05:00", + "updated_at": "2026-01-06T09:43:18.417607073-05:00", + "metadata_json": "{\"confidence\":95,\"prompt\":\"October 2025: Added Theme struct with colors/fonts/spacing, CSS generation, syntax highlighting via syntect, full HTML5 document output\"}" + }, + { + "id": 39, + "change_id": "2481faa6-87fb-4c6b-acc9-10d03b568d02", + "node_type": "action", + "title": "Implemented CLI static site integration", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:43:18.554408150-05:00", + "updated_at": "2026-01-06T09:43:18.554408150-05:00", + "metadata_json": "{\"confidence\":95,\"prompt\":\"October 2025: Added weaver <source> <dest> command, optional auth loading, graceful degradation without network\"}" + }, + { + "id": 40, + "change_id": "7d31d924-719a-4de6-af48-10d99c603d7b", + "node_type": "action", + "title": "Added static site renderer tests", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:43:18.693278827-05:00", + "updated_at": "2026-01-06T09:43:18.693278827-05:00", + "metadata_json": "{\"confidence\":90,\"prompt\":\"October 2025: Made reqwest client optional in StaticSiteContext for testability, added snapshot tests via insta\"}" + }, + { + "id": 41, + "change_id": "fd29722c-500b-46ff-924c-bee82d766224", + "node_type": "decision", + "title": "Two-stage AT Protocol rendering pipeline", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:43:35.760739284-05:00", + "updated_at": "2026-01-06T09:43:35.760739284-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 42, + "change_id": "db80f5b5-a719-4782-b46e-8c9affc75a9d", + "node_type": "option", + "title": "Stage 1: Markdown preprocessing (CLI)", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:43:35.869917830-05:00", + "updated_at": "2026-01-06T09:43:35.869917830-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 43, + "change_id": "f3bb7c28-9610-4460-9e4f-b78c9670fe09", + "node_type": "option", + "title": "Stage 2: Client-side rendering (WASM)", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:43:35.977386900-05:00", + "updated_at": "2026-01-06T09:43:35.977386900-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 44, + "change_id": "947d69f2-7be9-403d-9f24-75d835196c37", + "node_type": "action", + "title": "Implemented browser OAuth with BrowserAuthStore", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:44:01.896313820-05:00", + "updated_at": "2026-01-06T09:44:01.896313820-05:00", + "metadata_json": "{\"confidence\":95,\"prompt\":\"Nov 2025: PKCE flow, localStorage+sessionStorage, Signal-based client swapping in CachedFetcher\"}" + }, + { + "id": 45, + "change_id": "6562e367-8b54-4898-93e7-ca86c49c360d", + "node_type": "decision", + "title": "Record editing interface approach", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:44:02.121665204-05:00", + "updated_at": "2026-01-06T09:44:02.121665204-05:00", + "metadata_json": "{\"confidence\":90}" + }, + { + "id": 46, + "change_id": "f5c8549c-5baf-46f8-a070-3af48a464a30", + "node_type": "option", + "title": "Two editing modes: JSON Editor + Pretty Editor", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:44:02.234281881-05:00", + "updated_at": "2026-01-06T09:44:02.234281881-05:00", + "metadata_json": "{\"confidence\":90}" + }, + { + "id": 47, + "change_id": "a1ebe765-1b1d-4b48-bb57-0a0d94d89414", + "node_type": "observation", + "title": "Root Signal pattern for editing nested Data", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:44:24.994680229-05:00", + "updated_at": "2026-01-06T09:44:24.994680229-05:00", + "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.\"}" + }, + { + "id": 48, + "change_id": "d7ece9b4-c1b0-49c3-9dad-0ed8c2e97042", + "node_type": "action", + "title": "Implemented editor integration with route structure", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:44:25.280737533-05:00", + "updated_at": "2026-01-06T09:44:25.280737533-05:00", + "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.\"}" + }, + { + "id": 49, + "change_id": "e8c967ae-7aaf-4a09-b5e0-0eebe0f75da2", + "node_type": "action", + "title": "Draft system fixes - entry context, notebook URI, deletion, auth", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:44:40.165533396-05:00", + "updated_at": "2026-01-06T09:44:40.165533396-05:00", + "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\"}" + }, + { + "id": 50, + "change_id": "49e83f50-4296-433d-b27e-2479353eb214", + "node_type": "action", + "title": "Editor render pipeline redesign", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:44:57.442056469-05:00", + "updated_at": "2026-01-06T09:44:57.442056469-05:00", + "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.\"}" + }, + { + "id": 51, + "change_id": "3862b5d9-17ec-4a8c-9eb3-e70270ef2bbc", + "node_type": "observation", + "title": "Editor cursor bugs - orphan spans, offset map gaps", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:44:57.561311252-05:00", + "updated_at": "2026-01-06T09:44:57.561311252-05:00", + "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.\"}" + }, + { + "id": 52, + "change_id": "befcef0e-c831-4d2d-8454-499b69715dac", + "node_type": "goal", + "title": "WCAG 2.2 Level AA accessibility compliance", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:45:23.468651607-05:00", + "updated_at": "2026-01-06T09:45:23.468651607-05:00", + "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.\"}" + }, + { + "id": 53, + "change_id": "32129a65-4d74-4aba-ae82-e16b98552eda", + "node_type": "observation", + "title": "Current accessibility gaps - contenteditable role, toolbar navigation, focus management", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:45:23.585309375-05:00", + "updated_at": "2026-01-06T09:45:23.585309375-05:00", + "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\"}" + }, + { + "id": 54, + "change_id": "d27b41f2-3146-4493-9216-d33e36177b7f", + "node_type": "action", + "title": "Offset iterator migration for sidenote gap detection", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:45:31.238356621-05:00", + "updated_at": "2026-01-06T09:45:31.238356621-05:00", + "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)\"}" + }, + { + "id": 55, + "change_id": "77a50102-1dc0-4009-9715-5c8644745be1", + "node_type": "action", + "title": "Collab worker design - iroh P2P into dedicated web worker", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:45:48.850879773-05:00", + "updated_at": "2026-01-06T09:45:48.850879773-05:00", + "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.\"}" + }, + { + "id": 56, + "change_id": "128ce435-efb2-4608-82a7-771702bbc151", + "node_type": "action", + "title": "LoroBuffer type implementing TextBuffer + UndoManager traits", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:46:07.761169828-05:00", + "updated_at": "2026-01-06T09:46:07.761169828-05:00", + "metadata_json": "{\"confidence\":90,\"prompt\":\"Jan 2026: Bridge between weaver-editor-core traits and Loro CRDT. Wraps LoroDoc + LoroText + loro::UndoManager.\"}" + }, + { + "id": 57, + "change_id": "6ae02804-d3a1-47fb-a51f-e55ce87cf6d3", + "node_type": "observation", + "title": "weaver-index dual-backend detail - ClickHouse for network-wide, SQLite shards for collab", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:46:07.989448138-05:00", + "updated_at": "2026-01-06T09:46:07.989448138-05:00", + "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.\"}" + }, + { + "id": 58, + "change_id": "a12049af-71cd-4392-b4d9-4da89c22f221", + "node_type": "observation", + "title": "Editor Phase 1: JumpRope-based text storage with char offsets", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:50:04.240737150-05:00", + "updated_at": "2026-01-06T09:50:04.240737150-05:00", + "metadata_json": "{\"confidence\":95,\"prompt\":\"Nov 2025: Initial MVP using JumpRopeBuf. CursorState with affinity. LocalStorage persistence. Char offsets (not bytes) for rope operations.\"}" + }, + { + "id": 59, + "change_id": "0dcb444b-61cb-41f2-aa1e-2cb3b5b3f23e", + "node_type": "action", + "title": "Clipboard fixes and Loro CRDT investigation", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:50:04.478320690-05:00", + "updated_at": "2026-01-06T09:50:04.478320690-05:00", + "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.\"}" + }, + { + "id": 60, + "change_id": "15c2d75c-3fd9-45a4-a714-2cbbed916273", + "node_type": "observation", + "title": "Sidenotes: inline footnotes in virtual paragraphs", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:50:13.862083175-05:00", + "updated_at": "2026-01-06T09:50:13.862083175-05:00", + "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.\"}" + }, + { + "id": 61, + "change_id": "57b416e2-9b1e-41a6-b02f-46fe0f604dae", + "node_type": "action", + "title": "Segmented writer refactor - single full render producing segmented output", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:50:33.993737496-05:00", + "updated_at": "2026-01-06T09:50:33.993737496-05:00", + "metadata_json": "{\"confidence\":90,\"prompt\":\"Dec 2025: Eliminate double-parsing. SegmentedWriter struct. Per-paragraph data tracking (offset maps, syntax spans, refs). Stable paragraph IDs.\"}" + }, + { + "id": 62, + "change_id": "13053a77-0246-4382-83ea-251c9bd5fd48", + "node_type": "decision", + "title": "Secure session storage for sensitive keys", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:50:34.221714723-05:00", + "updated_at": "2026-01-06T09:50:34.221714723-05:00", + "metadata_json": "{\"confidence\":85}" + }, + { + "id": 63, + "change_id": "9ddb878e-f0e5-4357-b863-6b4796148478", + "node_type": "option", + "title": "XOR secret sharing between sessionStorage + window.name", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:50:34.343222603-05:00", + "updated_at": "2026-01-06T09:50:34.343222603-05:00", + "metadata_json": "{\"confidence\":85}" + }, + { + "id": 64, + "change_id": "0e03638a-7ed3-4bc9-9aa4-06484a765c93", + "node_type": "observation", + "title": "weaver-index XRPC endpoints tiered by priority", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:50:40.729086932-05:00", + "updated_at": "2026-01-06T09:50:40.729086932-05:00", + "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.\"}" + }, + { + "id": 65, + "change_id": "e35bf396-19ec-4f18-8227-044fd7e099a5", + "node_type": "action", + "title": "Editor AT Protocol integration - LoroDoc mirrors entry schema", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:50:59.518888759-05:00", + "updated_at": "2026-01-06T09:50:59.518888759-05:00", + "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}.\"}" + }, + { + "id": 66, + "change_id": "d5463518-b69f-40d0-a6dc-aec0bd78a074", + "node_type": "action", + "title": "Editor performance fix - signal granularity + batched DOM queries", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:50:59.780023343-05:00", + "updated_at": "2026-01-06T09:50:59.780023343-05:00", + "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.\"}" + }, + { + "id": 67, + "change_id": "3db38b72-cd99-41ae-b1ae-9ab5d7445b06", + "node_type": "observation", + "title": "weaver-app code review - significant technical debt", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:51:20.779358167-05:00", + "updated_at": "2026-01-06T09:51:20.779358167-05:00", + "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.\"}" + }, + { + "id": 68, + "change_id": "87027254-16fb-40e4-a0fc-cc9e2089a10b", + "node_type": "decision", + "title": "SQLite shard concurrency pattern", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:51:21.025710138-05:00", + "updated_at": "2026-01-06T09:51:21.025710138-05:00", + "metadata_json": "{\"confidence\":85}" + }, + { + "id": 69, + "change_id": "909bbc20-cf7c-4b8d-878f-d8ae7ee6d3a9", + "node_type": "option", + "title": "rusqlite + spawn_blocking per request", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:51:21.141396683-05:00", + "updated_at": "2026-01-06T09:51:21.141396683-05:00", + "metadata_json": "{\"confidence\":85,\"prompt\":\"Simpler. tokio handles thread pool. r2d2 connection pools per shard. Overhead: ~1-2μs per spawn_blocking call.\"}" + }, + { + "id": 70, + "change_id": "42a7505c-c593-4fa2-839f-ca7331324c4e", + "node_type": "decision", + "title": "TextBuffer trait abstraction for editor storage", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:52:00.188651854-05:00", + "updated_at": "2026-01-06T09:52:00.188651854-05:00", + "metadata_json": "{\"confidence\":90}" + }, + { + "id": 71, + "change_id": "926e5719-d3dc-422b-ba65-af723e0fa49a", + "node_type": "option", + "title": "Generic EditorDocument<T: TextBuffer> with ropey default", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:52:00.313285622-05:00", + "updated_at": "2026-01-06T09:52:00.313285622-05:00", + "metadata_json": "{\"confidence\":90,\"prompt\":\"Jan 2026: TextBuffer trait provides common interface (insert, delete, slice, offset conversion). EditorRope for local, LoroBuffer for CRDT collab.\"}" + }, + { + "id": 72, + "change_id": "a796cf1c-7ac7-497b-a995-54b57a4e2c30", + "node_type": "action", + "title": "Notebook UI redesign - skeumorphic aesthetics, profile caching", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:53:17.472885644-05:00", + "updated_at": "2026-01-06T09:53:17.472885644-05:00", + "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.\"}" + }, + { + "id": 73, + "change_id": "2302f391-d481-42a8-8bce-e54c29f6dfe1", + "node_type": "outcome", + "title": "weaver-editor-core extraction nearly complete", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:56:58.633303649-05:00", + "updated_at": "2026-01-06T09:56:58.633303649-05:00", + "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.\"}" + }, + { + "id": 74, + "change_id": "a6997f6e-06a6-4c9d-ab24-b3ea114207d6", + "node_type": "observation", + "title": "weaver-app editor: 25 files, 30K+ LOC with Loro CRDT", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:56:58.773096202-05:00", + "updated_at": "2026-01-06T09:56:58.773096202-05:00", + "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.\"}" + }, + { + "id": 75, + "change_id": "a1998766-9d7a-4e59-ba54-881a2626244c", + "node_type": "outcome", + "title": "weaver-index: 19 XRPC endpoints, 40 ClickHouse migrations", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:56:58.947173272-05:00", + "updated_at": "2026-01-06T09:56:58.947173272-05:00", + "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.\"}" + }, + { + "id": 76, + "change_id": "2d09062b-8e32-4e85-92ed-cb2dfa15ddec", + "node_type": "observation", + "title": "Lexicons: 13 record types, 50+ view/state types", + "description": null, + "status": "pending", + "created_at": "2026-01-06T09:56:59.075238874-05:00", + "updated_at": "2026-01-06T09:56:59.075238874-05:00", + "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.\"}" + }, + { + "id": 77, + "change_id": "ef6e7454-2d97-4fe9-97a8-76c2486cf076", + "node_type": "outcome", + "title": "Iroh P2P collab transport fully implemented - feature-gated behind collab-worker flag", + "description": null, + "status": "pending", + "created_at": "2026-01-06T10:35:53.690928004-05:00", + "updated_at": "2026-01-06T10:35:53.690928004-05:00", + "metadata_json": "{\"confidence\":100}" + }, + { + "id": 78, + "change_id": "3af41d3d-93c0-4a8b-a66b-5af3b7c0f048", + "node_type": "outcome", + "title": "Collab architecture complete: CollabCoordinator + EditorReactor worker + gossip protocol + presence tracking + session records", + "description": null, + "status": "pending", + "created_at": "2026-01-06T10:36:07.446463577-05:00", + "updated_at": "2026-01-06T10:36:07.446463577-05:00", + "metadata_json": "{\"confidence\":100}" + }, + { + "id": 79, + "change_id": "33d80413-0ab3-4955-915e-c8aca65cdfc1", + "node_type": "observation", + "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.", + "description": null, + "status": "pending", + "created_at": "2026-01-06T10:36:22.142105793-05:00", + "updated_at": "2026-01-06T10:36:22.142105793-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 80, + "change_id": "dcdeb4a1-2dcd-45a2-9038-ec5b59e1b1d2", + "node_type": "observation", + "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.", + "description": null, + "status": "pending", + "created_at": "2026-01-06T10:36:33.921891186-05:00", + "updated_at": "2026-01-06T10:36:33.921891186-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 81, + "change_id": "5f00148d-b487-40fb-b4b4-66b8d2489e91", + "node_type": "goal", + "title": "Create weaver-editor-browser crate - web_sys DOM layer without Dioxus", + "description": null, + "status": "pending", + "created_at": "2026-01-06T10:39:48.632511769-05:00", + "updated_at": "2026-01-06T10:39:48.632511769-05:00", + "metadata_json": "{\"confidence\":90}" + }, + { + "id": 82, + "change_id": "252f6497-56a2-4f10-ab8c-e7865960c481", + "node_type": "action", + "title": "browser: dom_sync.rs - DOM ↔ document sync", + "description": null, + "status": "pending", + "created_at": "2026-01-06T10:39:56.982339096-05:00", + "updated_at": "2026-01-06T10:39:56.982339096-05:00", + "metadata_json": "{\"confidence\":85}" + }, + { + "id": 83, + "change_id": "63719097-0bec-4805-aa5c-6527256ef708", + "node_type": "action", + "title": "browser: cursor.rs - Selection API handling", + "description": null, + "status": "pending", + "created_at": "2026-01-06T10:39:57.099995278-05:00", + "updated_at": "2026-01-06T10:39:57.099995278-05:00", + "metadata_json": "{\"confidence\":85}" + }, + { + "id": 84, + "change_id": "6c25dd34-ac4d-49d1-88d2-13da6b93c872", + "node_type": "action", + "title": "browser: events.rs - beforeinput, keydown, paste handlers", + "description": null, + "status": "pending", + "created_at": "2026-01-06T10:39:57.208466499-05:00", + "updated_at": "2026-01-06T10:39:57.208466499-05:00", + "metadata_json": "{\"confidence\":85}" + }, + { + "id": 85, + "change_id": "b6e15092-6258-4ca6-9c1b-d1dc58280a92", + "node_type": "action", + "title": "browser: contenteditable.rs - editor element management", + "description": null, + "status": "pending", + "created_at": "2026-01-06T10:39:57.324379851-05:00", + "updated_at": "2026-01-06T10:39:57.324379851-05:00", + "metadata_json": "{\"confidence\":85}" + }, + { + "id": 86, + "change_id": "c50d83f9-d6cb-4697-a24c-50a7707d395c", + "node_type": "action", + "title": "browser: embed worker - image/content resolution in worker thread", + "description": null, + "status": "pending", + "created_at": "2026-01-06T10:39:57.433073329-05:00", + "updated_at": "2026-01-06 16:17:59", + "metadata_json": "{\"confidence\":75}" + }, + { + "id": 87, + "change_id": "1d5c50c0-bfeb-4218-88ab-405d12ba0607", + "node_type": "goal", + "title": "Create weaver-editor-crdt crate - optional Loro integration (+500KB)", + "description": null, + "status": "pending", + "created_at": "2026-01-06T10:40:09.313344768-05:00", + "updated_at": "2026-01-06T10:40:09.313344768-05:00", + "metadata_json": "{\"confidence\":90}" + }, + { + "id": 88, + "change_id": "ed566bdc-7372-4ea5-966c-a7f10e2c5453", + "node_type": "action", + "title": "crdt: LoroDocument impl EditorDocument (wraps LoroDoc + UndoManager)", + "description": null, + "status": "pending", + "created_at": "2026-01-06T10:40:14.817578785-05:00", + "updated_at": "2026-01-06 15:44:26", + "metadata_json": "{\"confidence\":85}" + }, + { + "id": 90, + "change_id": "cdee4656-7495-4a9c-8d24-0f232941763e", + "node_type": "action", + "title": "crdt: sync.rs - export/import updates, version vectors", + "description": null, + "status": "pending", + "created_at": "2026-01-06T10:40:15.025419171-05:00", + "updated_at": "2026-01-06T10:40:15.025419171-05:00", + "metadata_json": "{\"confidence\":85}" + }, + { + "id": 91, + "change_id": "57d3f2a1-bd8d-45f1-8834-97bac872dae8", + "node_type": "goal", + "title": "Phase 2: Update weaver-app to use extracted crates", + "description": null, + "status": "pending", + "created_at": "2026-01-06T10:42:07.645111430-05:00", + "updated_at": "2026-01-06T10:42:07.645111430-05:00", + "metadata_json": "{\"confidence\":85}" + }, + { + "id": 92, + "change_id": "3d62e1bf-b43c-4924-b32e-2a5190b2ed0e", + "node_type": "action", + "title": "app: SignalEditorDocument wrapper adding Dioxus signals to core document", + "description": null, + "status": "pending", + "created_at": "2026-01-06T10:42:17.840391077-05:00", + "updated_at": "2026-01-06T10:42:17.840391077-05:00", + "metadata_json": "{\"confidence\":85}" + }, + { + "id": 93, + "change_id": "64a17922-3079-4e23-a8f2-114758a3ed6d", + "node_type": "action", + "title": "app: Update editor component to use weaver-editor-core + weaver-editor-browser", + "description": null, + "status": "pending", + "created_at": "2026-01-06T10:42:17.965557748-05:00", + "updated_at": "2026-01-06T10:42:17.965557748-05:00", + "metadata_json": "{\"confidence\":85}" + }, + { + "id": 94, + "change_id": "f7f521a5-db62-43fd-8e77-bff00524c9c3", + "node_type": "action", + "title": "app: Update collab to use weaver-editor-crdt", + "description": null, + "status": "pending", + "created_at": "2026-01-06T10:42:18.071589891-05:00", + "updated_at": "2026-01-06T10:42:18.071589891-05:00", + "metadata_json": "{\"confidence\":85}" + }, + { + "id": 95, + "change_id": "a0bde70d-28e9-4a27-9bc7-814415c4d61a", + "node_type": "goal", + "title": "Phase 3: Create weaver-editor-js - npm package @weaver/editor", + "description": null, + "status": "pending", + "created_at": "2026-01-06T10:42:31.494853791-05:00", + "updated_at": "2026-01-06T10:42:31.494853791-05:00", + "metadata_json": "{\"confidence\":80}" + }, + { + "id": 96, + "change_id": "7917c340-2614-40e5-96b3-ec569aca0053", + "node_type": "action", + "title": "js: WASM bindings for Editor and CollabEditor", + "description": null, + "status": "pending", + "created_at": "2026-01-06T10:42:42.460056141-05:00", + "updated_at": "2026-01-06T10:42:42.460056141-05:00", + "metadata_json": "{\"confidence\":80}" + }, + { + "id": 97, + "change_id": "32765e84-b76d-45cf-940e-28bc35e14339", + "node_type": "action", + "title": "js: TypeScript view layer - mount/unmount, callbacks, lifecycle", + "description": null, + "status": "pending", + "created_at": "2026-01-06T10:42:42.562764586-05:00", + "updated_at": "2026-01-06T10:42:42.562764586-05:00", + "metadata_json": "{\"confidence\":80}" + }, + { + "id": 98, + "change_id": "371419cc-a552-41d7-9f4e-3239b4d8b5ad", + "node_type": "action", + "title": "js: npm publish as @weaver/editor", + "description": null, + "status": "pending", + "created_at": "2026-01-06T10:42:42.662187635-05:00", + "updated_at": "2026-01-06T10:42:42.662187635-05:00", + "metadata_json": "{\"confidence\":75}" + }, + { + "id": 99, + "change_id": "e1275977-76fd-4a21-89d2-1badd40054a2", + "node_type": "observation", + "title": "EditorDocument trait is THE shared interface - LoroDocument impls it, SignalEditorDocument wraps it. JS bindings should expose this interface if possible.", + "description": null, + "status": "pending", + "created_at": "2026-01-06T10:44:19.190720503-05:00", + "updated_at": "2026-01-06T10:44:19.190720503-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 100, + "change_id": "e48d466a-1b04-4da7-a9db-cd0abe3d8ad2", + "node_type": "decision", + "title": "Expose EditorDocument interface in JS bindings?", + "description": null, + "status": "pending", + "created_at": "2026-01-06T10:44:30.449058579-05:00", + "updated_at": "2026-01-06T10:44:30.449058579-05:00", + "metadata_json": "{\"confidence\":75}" + }, + { + "id": 101, + "change_id": "efd6969a-aa78-42fb-9238-a8ba0e474b3d", + "node_type": "option", + "title": "Yes - TypeScript interface mirroring Rust trait, enables custom impls", + "description": null, + "status": "pending", + "created_at": "2026-01-06T10:44:37.389767875-05:00", + "updated_at": "2026-01-06T10:44:37.389767875-05:00", + "metadata_json": "{\"confidence\":70}" + }, + { + "id": 102, + "change_id": "384d5ccb-73bc-40c3-901a-b4894809d67d", + "node_type": "option", + "title": "No - concrete Editor/CollabEditor classes only, simpler API", + "description": null, + "status": "pending", + "created_at": "2026-01-06T10:44:37.506912185-05:00", + "updated_at": "2026-01-06T10:44:37.506912185-05:00", + "metadata_json": "{\"confidence\":70}" + }, + { + "id": 103, + "change_id": "a83f0c2c-ede4-4407-a048-535462783960", + "node_type": "decision", + "title": "Browser crate extraction approach - how to handle EditorDocument trait vs concrete type", + "description": null, + "status": "pending", + "created_at": "2026-01-06T11:08:59.203682173-05:00", + "updated_at": "2026-01-06T11:08:59.203682173-05:00", + "metadata_json": "{\"confidence\":85}" + }, + { + "id": 104, + "change_id": "1fb0e2b9-f6e7-4e1d-a514-8b370712c232", + "node_type": "option", + "title": "Option 1: Make app EditorDocument impl core trait first, then extract browser code", + "description": null, + "status": "pending", + "created_at": "2026-01-06T11:09:06.678864362-05:00", + "updated_at": "2026-01-06T11:09:06.678864362-05:00", + "metadata_json": "{\"confidence\":80}" + }, + { + "id": 105, + "change_id": "29fef523-1eb5-497c-905a-16dec60b30df", + "node_type": "option", + "title": "Option 2: Extract browser code now with concrete type, refactor to generic later", + "description": null, + "status": "pending", + "created_at": "2026-01-06T11:09:06.795379977-05:00", + "updated_at": "2026-01-06T11:09:06.795379977-05:00", + "metadata_json": "{\"confidence\":75}" + }, + { + "id": 106, + "change_id": "ba8b0f30-09f9-437d-b690-b5053b9991ad", + "node_type": "option", + "title": "Option 3: Create browser crate with trait bounds, update app impl simultaneously", + "description": null, + "status": "pending", + "created_at": "2026-01-06T11:09:06.901780924-05:00", + "updated_at": "2026-01-06T11:09:06.901780924-05:00", + "metadata_json": "{\"confidence\":70}" + }, + { + "id": 107, + "change_id": "11c284c0-37c2-4736-aa4e-479f77b7c5be", + "node_type": "observation", + "title": "Core blocker: app EditorDocument uses Signal accessors (cursor.read/write) but browser crate needs trait methods (cursor()/set_cursor()) for generic code", + "description": null, + "status": "pending", + "created_at": "2026-01-06T11:09:12.978758503-05:00", + "updated_at": "2026-01-06T11:09:12.978758503-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 108, + "change_id": "86195ee9-4147-4874-8f89-7a0e882a5132", + "node_type": "action", + "title": "Chosen approach: browser crate impl against EditorDocument trait, extend trait as needed", + "description": null, + "status": "pending", + "created_at": "2026-01-06T11:17:38.648008021-05:00", + "updated_at": "2026-01-06T11:17:38.648008021-05:00", + "metadata_json": "{\"confidence\":90}" + }, + { + "id": 109, + "change_id": "57096f55-c0fd-4c6c-a45f-7877def5ef70", + "node_type": "observation", + "title": "Defer weaver-app reintegration until both browser AND crdt crates complete - cleaner dependency graph", + "description": null, + "status": "pending", + "created_at": "2026-01-06T11:17:38.754613630-05:00", + "updated_at": "2026-01-06T11:17:38.754613630-05:00", + "metadata_json": "{\"confidence\":90}" + }, + { + "id": 110, + "change_id": "4dfdbea5-6bcd-4377-90c1-a35e68b59d11", + "node_type": "decision", + "title": "Feature-gate dioxus_signals in weaver-editor-browser for framework-agnostic reactive state", + "description": null, + "status": "pending", + "created_at": "2026-01-06T11:17:44.899371901-05:00", + "updated_at": "2026-01-06T11:17:44.899371901-05:00", + "metadata_json": "{\"confidence\":85}" + }, + { + "id": 111, + "change_id": "d05ddf03-4487-4fc6-932c-eb91e58b1ebf", + "node_type": "observation", + "title": "Worker placement: collab worker → crdt crate (Loro-specific), embed worker → browser crate", + "description": null, + "status": "pending", + "created_at": "2026-01-06T11:17:45.022215010-05:00", + "updated_at": "2026-01-06T11:17:45.022215010-05:00", + "metadata_json": "{\"confidence\":85}" + }, + { + "id": 112, + "change_id": "1b0e70cb-ebed-4a59-8a94-2314491a9e84", + "node_type": "action", + "title": "crdt: collab worker - Loro sync + iroh P2P in worker thread", + "description": null, + "status": "pending", + "created_at": "2026-01-06T11:17:59.598669487-05:00", + "updated_at": "2026-01-06T11:17:59.598669487-05:00", + "metadata_json": "{\"confidence\":85}" + }, + { + "id": 113, + "change_id": "4dd1947c-e4d8-4134-8174-523d037ae728", + "node_type": "observation", + "title": "weaver-editor-crdt includes AT Protocol integration: PDS sync (edit.root/diff records), session records for collab discovery", + "description": null, + "status": "pending", + "created_at": "2026-01-06T11:19:19.206906847-05:00", + "updated_at": "2026-01-06T11:19:19.206906847-05:00", + "metadata_json": "{\"confidence\":90}" + }, + { + "id": 114, + "change_id": "e32a4e20-d5c8-40b7-82f8-9519313bcc55", + "node_type": "action", + "title": "crdt: PDS sync - edit.root/diff record management, draft persistence", + "description": null, + "status": "pending", + "created_at": "2026-01-06T11:19:19.324015269-05:00", + "updated_at": "2026-01-06T11:19:19.324015269-05:00", + "metadata_json": "{\"confidence\":85}" + }, + { + "id": 115, + "change_id": "f991c3b0-0b5e-43b6-8d3a-59067ce3175e", + "node_type": "observation", + "title": "crdt crate generic over document primitive where reasonable - not hardcoded to Entry lexicon, enables reuse for other AT Protocol record types", + "description": null, + "status": "pending", + "created_at": "2026-01-06T11:20:40.227811552-05:00", + "updated_at": "2026-01-06T11:20:40.227811552-05:00", + "metadata_json": "{\"confidence\":90}" + }, + { + "id": 116, + "change_id": "cd5cc522-d717-43a7-b116-94f86845db10", + "node_type": "observation", + "title": "Architecture pattern: core defines platform traits, browser/native implement them. Enables multi-platform support.", + "description": null, + "status": "pending", + "created_at": "2026-01-06T11:26:00.681187721-05:00", + "updated_at": "2026-01-06T11:26:00.681187721-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 117, + "change_id": "2a2333b1-eceb-4635-877f-26f55790d7ed", + "node_type": "outcome", + "title": "weaver-editor-browser crate scaffolded - cursor module with BrowserCursor impl CursorPlatform", + "description": null, + "status": "pending", + "created_at": "2026-01-06T11:32:59.244188632-05:00", + "updated_at": "2026-01-06T11:32:59.244188632-05:00", + "metadata_json": "{\"confidence\":90}" + }, + { + "id": 118, + "change_id": "d6f1e398-007f-4eb6-94dd-33a97695bcbd", + "node_type": "observation", + "title": "CursorPlatform API cleanup: removed unused editor_id param from restore_cursor and get_cursor_rect - element lookup uses mapping.node_id directly", + "description": null, + "status": "pending", + "created_at": "2026-01-06T11:35:39.555773236-05:00", + "updated_at": "2026-01-06T11:35:39.555773236-05:00", + "metadata_json": "{\"confidence\":95}" + } + ], + "edges": [ + { + "id": 1, + "from_node_id": 1, + "to_node_id": 3, + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", + "to_change_id": "58113fdb-140c-4844-844a-b51eee292c58", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Building weaver.sh required choosing a frontend technology", + "created_at": "2026-01-06T09:32:16.244756482-05:00" + }, + { + "id": 2, + "from_node_id": 1, + "to_node_id": 6, + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", + "to_change_id": "ef3edcf6-e8a8-4d48-9e9e-3885abb53a30", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Building weaver.sh required deciding on editor approach", + "created_at": "2026-01-06T09:32:16.349062979-05:00" + }, + { + "id": 3, + "from_node_id": 1, + "to_node_id": 2, + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", + "to_change_id": "850c82f8-e6b6-4b8b-bd70-e51bcf43d0a5", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Weaver needed a better AT Protocol library, so jacquard was built", + "created_at": "2026-01-06T09:32:16.464352957-05:00" + }, + { + "id": 4, + "from_node_id": 1, + "to_node_id": 16, + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", + "to_change_id": "bac8484a-5eb5-42bf-9387-55d90fe486cd", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Weaver needed an indexing backend for efficient queries", + "created_at": "2026-01-06T09:32:16.582213414-05:00" + }, + { + "id": 5, + "from_node_id": 3, + "to_node_id": 4, + "from_change_id": "58113fdb-140c-4844-844a-b51eee292c58", + "to_change_id": "3c1e5278-cb0d-4f8b-9605-89225dfe66e2", + "edge_type": "chosen", + "weight": 1.0, + "rationale": "Dioxus chosen - wanted to write frontend in Rust", + "created_at": "2026-01-06T09:32:24.393657051-05:00" + }, + { + "id": 6, + "from_node_id": 3, + "to_node_id": 5, + "from_change_id": "58113fdb-140c-4844-844a-b51eee292c58", + "to_change_id": "19e5dad0-b0d8-4eba-8af1-028430ddc69c", + "edge_type": "rejected", + "weight": 1.0, + "rationale": "JS frameworks rejected - wanted pure Rust stack", + "created_at": "2026-01-06T09:32:24.489371357-05:00" + }, + { + "id": 7, + "from_node_id": 6, + "to_node_id": 7, + "from_change_id": "ef3edcf6-e8a8-4d48-9e9e-3885abb53a30", + "to_change_id": "5688af7d-61e5-4b47-afaa-05ee5bff36ae", + "edge_type": "chosen", + "weight": 1.0, + "rationale": "Custom editor chosen - no Rust alternatives, custom markdown needs, performance", + "created_at": "2026-01-06T09:32:24.587177576-05:00" + }, + { + "id": 8, + "from_node_id": 6, + "to_node_id": 8, + "from_change_id": "ef3edcf6-e8a8-4d48-9e9e-3885abb53a30", + "to_change_id": "d28249c5-955c-45ad-bbff-d76f437055c3", + "edge_type": "rejected", + "weight": 1.0, + "rationale": "JS editors rejected - not Rust, wouldn't support custom markdown well", + "created_at": "2026-01-06T09:32:24.698525141-05:00" + }, + { + "id": 9, + "from_node_id": 7, + "to_node_id": 9, + "from_change_id": "5688af7d-61e5-4b47-afaa-05ee5bff36ae", + "to_change_id": "301c77f5-27ce-422d-8be7-eec136c3c856", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Custom editor led to text storage decisions", + "created_at": "2026-01-06T09:32:32.655038840-05:00" + }, + { + "id": 10, + "from_node_id": 7, + "to_node_id": 13, + "from_change_id": "5688af7d-61e5-4b47-afaa-05ee5bff36ae", + "to_change_id": "cf8c5176-4419-47fd-a7d6-f59ee8806b6f", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Custom editor led to input handling decisions", + "created_at": "2026-01-06T09:32:32.770738756-05:00" + }, + { + "id": 11, + "from_node_id": 9, + "to_node_id": 10, + "from_change_id": "301c77f5-27ce-422d-8be7-eec136c3c856", + "to_change_id": "5738a9b1-2767-4aab-ad59-b7fe9ee6728e", + "edge_type": "chosen", + "weight": 1.0, + "rationale": "JumpRope initially chosen for fast edits and UTF-16 support", + "created_at": "2026-01-06T09:32:32.881385871-05:00" + }, + { + "id": 12, + "from_node_id": 9, + "to_node_id": 11, + "from_change_id": "301c77f5-27ce-422d-8be7-eec136c3c856", + "to_change_id": "d7d9d021-6d36-48ed-a7c1-f7b0a7576c2e", + "edge_type": "chosen", + "weight": 1.0, + "rationale": "Ropey chosen for core extraction - mature, byte/char indexing", + "created_at": "2026-01-06T09:32:32.986188585-05:00" + }, + { + "id": 13, + "from_node_id": 9, + "to_node_id": 12, + "from_change_id": "301c77f5-27ce-422d-8be7-eec136c3c856", + "to_change_id": "aef81c95-7a98-4dc6-bdc8-ab1df9823db8", + "edge_type": "chosen", + "weight": 1.0, + "rationale": "Loro chosen for CRDT/collab features - adds ~500KB", + "created_at": "2026-01-06T09:32:33.088291560-05:00" + }, + { + "id": 14, + "from_node_id": 13, + "to_node_id": 14, + "from_change_id": "cf8c5176-4419-47fd-a7d6-f59ee8806b6f", + "to_change_id": "290a2bda-6f84-4983-bda6-1870dfea8401", + "edge_type": "chosen", + "weight": 1.0, + "rationale": "beforeinput interception chosen - full control, no sync bugs", + "created_at": "2026-01-06T09:32:40.200826929-05:00" + }, + { + "id": 15, + "from_node_id": 13, + "to_node_id": 15, + "from_change_id": "cf8c5176-4419-47fd-a7d6-f59ee8806b6f", + "to_change_id": "374172ea-9738-4f5e-a176-1b5e8bd21568", + "edge_type": "rejected", + "weight": 1.0, + "rationale": "Hidden textarea rejected - sync hell, IME issues", + "created_at": "2026-01-06T09:32:40.322267642-05:00" + }, + { + "id": 16, + "from_node_id": 16, + "to_node_id": 17, + "from_change_id": "bac8484a-5eb5-42bf-9387-55d90fe486cd", + "to_change_id": "af0827fd-530e-498c-88ad-082a648a93f4", + "edge_type": "chosen", + "weight": 1.0, + "rationale": "Dual-backend chosen for latency + scale balance", + "created_at": "2026-01-06T09:32:40.419898633-05:00" + }, + { + "id": 17, + "from_node_id": 7, + "to_node_id": 18, + "from_change_id": "5688af7d-61e5-4b47-afaa-05ee5bff36ae", + "to_change_id": "fa554b5d-8af7-42e4-b03f-e5bec837e31a", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Editor matured, now extracting for embeddability", + "created_at": "2026-01-06T09:32:40.531324565-05:00" + }, + { + "id": 18, + "from_node_id": 19, + "to_node_id": 6, + "from_change_id": "291fe7f0-b962-4663-b7d9-701dd38de1b5", + "to_change_id": "ef3edcf6-e8a8-4d48-9e9e-3885abb53a30", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "UX requirement informed editor approach decision", + "created_at": "2026-01-06T09:32:46.840847461-05:00" + }, + { + "id": 19, + "from_node_id": 20, + "to_node_id": 7, + "from_change_id": "8b418708-6db5-42ab-8153-b2dc64f13065", + "to_change_id": "5688af7d-61e5-4b47-afaa-05ee5bff36ae", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Custom markdown dialect required custom editor", + "created_at": "2026-01-06T09:32:46.934676711-05:00" + }, + { + "id": 20, + "from_node_id": 1, + "to_node_id": 19, + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", + "to_change_id": "291fe7f0-b962-4663-b7d9-701dd38de1b5", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Weaver's vision includes Obsidian-style editing", + "created_at": "2026-01-06T09:32:47.034675391-05:00" + }, + { + "id": 21, + "from_node_id": 1, + "to_node_id": 20, + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", + "to_change_id": "8b418708-6db5-42ab-8153-b2dc64f13065", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Weaver uses custom markdown dialect", + "created_at": "2026-01-06T09:32:47.143855201-05:00" + }, + { + "id": 22, + "from_node_id": 2, + "to_node_id": 21, + "from_change_id": "850c82f8-e6b6-4b8b-bd70-e51bcf43d0a5", + "to_change_id": "e0a70314-85a3-4561-9348-fce0a887d7f9", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Jacquard built and weaver migrated successfully", + "created_at": "2026-01-06T09:36:52.144785392-05:00" + }, + { + "id": 23, + "from_node_id": 4, + "to_node_id": 22, + "from_change_id": "3c1e5278-cb0d-4f8b-9605-89225dfe66e2", + "to_change_id": "40dd01d3-768c-4b31-8baf-7d027079d73f", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Dioxus frontend deployed and working", + "created_at": "2026-01-06T09:36:52.255804622-05:00" + }, + { + "id": 24, + "from_node_id": 17, + "to_node_id": 23, + "from_change_id": "af0827fd-530e-498c-88ad-082a648a93f4", + "to_change_id": "708898ff-a587-4cda-9a2e-9376ce22f480", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Dual-backend architecture deployed", + "created_at": "2026-01-06T09:36:52.362370733-05:00" + }, + { + "id": 25, + "from_node_id": 7, + "to_node_id": 24, + "from_change_id": "5688af7d-61e5-4b47-afaa-05ee5bff36ae", + "to_change_id": "6b74f268-78fb-42ba-bb09-59e82496ac21", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Custom editor implemented and functional", + "created_at": "2026-01-06T09:36:52.526238184-05:00" + }, + { + "id": 26, + "from_node_id": 12, + "to_node_id": 25, + "from_change_id": "aef81c95-7a98-4dc6-bdc8-ab1df9823db8", + "to_change_id": "38fa6c2f-2a36-40d4-97ec-4e029501253b", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Loro integration working for collaborative editing", + "created_at": "2026-01-06T09:36:52.639107954-05:00" + }, + { + "id": 27, + "from_node_id": 1, + "to_node_id": 26, + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", + "to_change_id": "28d1b14d-5cb4-490e-87a2-db52aa233342", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Weaver needs user authentication to publish", + "created_at": "2026-01-06T09:37:38.981464260-05:00" + }, + { + "id": 28, + "from_node_id": 26, + "to_node_id": 27, + "from_change_id": "28d1b14d-5cb4-490e-87a2-db52aa233342", + "to_change_id": "00233162-061d-40bc-86c7-d74fddcd2ac4", + "edge_type": "chosen", + "weight": 1.0, + "rationale": "Public client chosen - simpler, works for web app", + "created_at": "2026-01-06T09:37:39.256369998-05:00" + }, + { + "id": 29, + "from_node_id": 26, + "to_node_id": 28, + "from_change_id": "28d1b14d-5cb4-490e-87a2-db52aa233342", + "to_change_id": "1a4d6505-b985-40db-9055-b42489331367", + "edge_type": "considered", + "weight": 1.0, + "rationale": "Confidential client planned for future", + "created_at": "2026-01-06T09:37:39.507880193-05:00" + }, + { + "id": 30, + "from_node_id": 1, + "to_node_id": 29, + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", + "to_change_id": "e7f730f7-598a-44c9-9d86-22c86653a65e", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Subdomain hosting enables cleaner URLs for notebooks", + "created_at": "2026-01-06T09:37:39.785408714-05:00" + }, + { + "id": 31, + "from_node_id": 1, + "to_node_id": 30, + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", + "to_change_id": "2eeb7345-a8a2-4146-bda5-1f19cfc7501d", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "JS bindings enable external apps to render weaver content", + "created_at": "2026-01-06T09:37:39.970366449-05:00" + }, + { + "id": 32, + "from_node_id": 18, + "to_node_id": 30, + "from_change_id": "fa554b5d-8af7-42e4-b03f-e5bec837e31a", + "to_change_id": "2eeb7345-a8a2-4146-bda5-1f19cfc7501d", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Editor extraction and renderer bindings share JS packaging approach", + "created_at": "2026-01-06T09:37:40.194803367-05:00" + }, + { + "id": 33, + "from_node_id": 9, + "to_node_id": 31, + "from_change_id": "301c77f5-27ce-422d-8be7-eec136c3c856", + "to_change_id": "6d66ca53-5105-4078-bf7e-5f9a0e503012", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Text storage choice led to buffer migration decisions", + "created_at": "2026-01-06T09:38:25.723965810-05:00" + }, + { + "id": 34, + "from_node_id": 31, + "to_node_id": 32, + "from_change_id": "6d66ca53-5105-4078-bf7e-5f9a0e503012", + "to_change_id": "60829366-0740-47cc-9101-4057c9c7489e", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Loro chosen for undo + collab foundation", + "created_at": "2026-01-06T09:38:25.837238235-05:00" + }, + { + "id": 35, + "from_node_id": 32, + "to_node_id": 25, + "from_change_id": "60829366-0740-47cc-9101-4057c9c7489e", + "to_change_id": "38fa6c2f-2a36-40d4-97ec-4e029501253b", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Loro migration enabled CRDT collaboration", + "created_at": "2026-01-06T09:38:25.942975341-05:00" + }, + { + "id": 36, + "from_node_id": 19, + "to_node_id": 33, + "from_change_id": "291fe7f0-b962-4663-b7d9-701dd38de1b5", + "to_change_id": "98e78259-5b13-413f-af77-bcb7354aca77", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Obsidian-style UX required syntax hiding implementation", + "created_at": "2026-01-06T09:38:26.049004964-05:00" + }, + { + "id": 37, + "from_node_id": 7, + "to_node_id": 34, + "from_change_id": "5688af7d-61e5-4b47-afaa-05ee5bff36ae", + "to_change_id": "ddc3ed83-2f0f-4c53-8e9f-44076bbf3aac", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Custom editor required IME implementation", + "created_at": "2026-01-06T09:38:26.156364183-05:00" + }, + { + "id": 38, + "from_node_id": 1, + "to_node_id": 35, + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", + "to_change_id": "874d2fd8-29bf-4137-a381-c9c14595da62", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Weaver defines custom AT Protocol lexicons", + "created_at": "2026-01-06T09:39:02.799667493-05:00" + }, + { + "id": 39, + "from_node_id": 25, + "to_node_id": 36, + "from_change_id": "38fa6c2f-2a36-40d4-97ec-4e029501253b", + "to_change_id": "1f756beb-3275-4cb0-a1f4-df805cbc3a20", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "CRDT collab needed transport mechanism", + "created_at": "2026-01-06T09:39:02.905930366-05:00" + }, + { + "id": 40, + "from_node_id": 36, + "to_node_id": 37, + "from_change_id": "1f756beb-3275-4cb0-a1f4-df805cbc3a20", + "to_change_id": "aa268350-e43f-4b8f-8d72-2234b1af4940", + "edge_type": "chosen", + "weight": 1.0, + "rationale": "iroh chosen for P2P with relay fallback", + "created_at": "2026-01-06T09:39:03.022927850-05:00" + }, + { + "id": 41, + "from_node_id": 1, + "to_node_id": 38, + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", + "to_change_id": "68006be2-14d6-4666-b914-16c750ebd7d5", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Static site theming was part of building weaver.sh MVP", + "created_at": "2026-01-06T09:43:26.425919855-05:00" + }, + { + "id": 42, + "from_node_id": 1, + "to_node_id": 39, + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", + "to_change_id": "2481faa6-87fb-4c6b-acc9-10d03b568d02", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "CLI integration enabled command-line static site generation", + "created_at": "2026-01-06T09:43:26.442362682-05:00" + }, + { + "id": 43, + "from_node_id": 1, + "to_node_id": 40, + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", + "to_change_id": "7d31d924-719a-4de6-af48-10d99c603d7b", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Tests improved renderer reliability", + "created_at": "2026-01-06T09:43:26.460684943-05:00" + }, + { + "id": 44, + "from_node_id": 1, + "to_node_id": 41, + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", + "to_change_id": "fd29722c-500b-46ff-924c-bee82d766224", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "AT Protocol publishing required a rendering pipeline", + "created_at": "2026-01-06T09:43:45.127934938-05:00" + }, + { + "id": 45, + "from_node_id": 41, + "to_node_id": 42, + "from_change_id": "fd29722c-500b-46ff-924c-bee82d766224", + "to_change_id": "db80f5b5-a719-4782-b46e-8c9affc75a9d", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "chosen - uploads blobs, resolves wikilinks to canonical markdown", + "created_at": "2026-01-06T09:43:45.144240889-05:00" + }, + { + "id": 46, + "from_node_id": 41, + "to_node_id": 43, + "from_change_id": "fd29722c-500b-46ff-924c-bee82d766224", + "to_change_id": "f3bb7c28-9610-4460-9e4f-b78c9670fe09", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "chosen - renders canonical markdown to HTML in browser", + "created_at": "2026-01-06T09:43:45.160214559-05:00" + }, + { + "id": 47, + "from_node_id": 27, + "to_node_id": 44, + "from_change_id": "00233162-061d-40bc-86c7-d74fddcd2ac4", + "to_change_id": "947d69f2-7be9-403d-9f24-75d835196c37", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Public client OAuth implementation", + "created_at": "2026-01-06T09:44:02.007365230-05:00" + }, + { + "id": 48, + "from_node_id": 1, + "to_node_id": 45, + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", + "to_change_id": "6562e367-8b54-4898-93e7-ca86c49c360d", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Weaver needs record editing for notebook entries", + "created_at": "2026-01-06T09:44:07.910484569-05:00" + }, + { + "id": 49, + "from_node_id": 45, + "to_node_id": 46, + "from_change_id": "6562e367-8b54-4898-93e7-ca86c49c360d", + "to_change_id": "f5c8549c-5baf-46f8-a070-3af48a464a30", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "chosen - flexible editing with raw JSON and structured form options", + "created_at": "2026-01-06T09:44:07.926966009-05:00" + }, + { + "id": 50, + "from_node_id": 46, + "to_node_id": 47, + "from_change_id": "f5c8549c-5baf-46f8-a070-3af48a464a30", + "to_change_id": "a1ebe765-1b1d-4b48-bb57-0a0d94d89414", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Pretty Editor implementation detail", + "created_at": "2026-01-06T09:44:25.108267392-05:00" + }, + { + "id": 51, + "from_node_id": 24, + "to_node_id": 48, + "from_change_id": "6b74f268-78fb-42ba-bb09-59e82496ac21", + "to_change_id": "d7ece9b4-c1b0-49c3-9dad-0ed8c2e97042", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Editor matured, integrated throughout UI", + "created_at": "2026-01-06T09:44:25.399478423-05:00" + }, + { + "id": 52, + "from_node_id": 48, + "to_node_id": 49, + "from_change_id": "d7ece9b4-c1b0-49c3-9dad-0ed8c2e97042", + "to_change_id": "e8c967ae-7aaf-4a09-b5e0-0eebe0f75da2", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Editor integration revealed draft system issues that needed fixing", + "created_at": "2026-01-06T09:44:40.273784561-05:00" + }, + { + "id": 53, + "from_node_id": 24, + "to_node_id": 50, + "from_change_id": "6b74f268-78fb-42ba-bb09-59e82496ac21", + "to_change_id": "49e83f50-4296-433d-b27e-2479353eb214", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Editor maturity required fixing deep rendering bugs", + "created_at": "2026-01-06T09:44:57.677654440-05:00" + }, + { + "id": 54, + "from_node_id": 50, + "to_node_id": 51, + "from_change_id": "49e83f50-4296-433d-b27e-2479353eb214", + "to_change_id": "3862b5d9-17ec-4a8c-9eb3-e70270ef2bbc", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Render redesign surfaced additional cursor issues", + "created_at": "2026-01-06T09:44:57.694123076-05:00" + }, + { + "id": 55, + "from_node_id": 1, + "to_node_id": 52, + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", + "to_change_id": "befcef0e-c831-4d2d-8454-499b69715dac", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Weaver needs accessibility compliance for public release", + "created_at": "2026-01-06T09:45:23.705592893-05:00" + }, + { + "id": 56, + "from_node_id": 52, + "to_node_id": 53, + "from_change_id": "befcef0e-c831-4d2d-8454-499b69715dac", + "to_change_id": "32129a65-4d74-4aba-ae82-e16b98552eda", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Gap analysis identified specific requirements", + "created_at": "2026-01-06T09:45:23.721865873-05:00" + }, + { + "id": 57, + "from_node_id": 7, + "to_node_id": 54, + "from_change_id": "5688af7d-61e5-4b47-afaa-05ee5bff36ae", + "to_change_id": "d27b41f2-3146-4493-9216-d33e36177b7f", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Custom editor needs consistent offset handling across writers", + "created_at": "2026-01-06T09:45:31.352850673-05:00" + }, + { + "id": 58, + "from_node_id": 25, + "to_node_id": 55, + "from_change_id": "38fa6c2f-2a36-40d4-97ec-4e029501253b", + "to_change_id": "77a50102-1dc0-4009-9715-5c8644745be1", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "CRDT collab working but needs architecture improvement for bundle size", + "created_at": "2026-01-06T09:45:48.961450680-05:00" + }, + { + "id": 59, + "from_node_id": 18, + "to_node_id": 56, + "from_change_id": "fa554b5d-8af7-42e4-b03f-e5bec837e31a", + "to_change_id": "128ce435-efb2-4608-82a7-771702bbc151", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Editor extraction requires LoroBuffer implementing core traits", + "created_at": "2026-01-06T09:46:07.879401852-05:00" + }, + { + "id": 60, + "from_node_id": 17, + "to_node_id": 57, + "from_change_id": "af0827fd-530e-498c-88ad-082a648a93f4", + "to_change_id": "6ae02804-d3a1-47fb-a51f-e55ce87cf6d3", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Dual-backend architecture implementation detail", + "created_at": "2026-01-06T09:46:08.092795177-05:00" + }, + { + "id": 61, + "from_node_id": 10, + "to_node_id": 58, + "from_change_id": "5738a9b1-2767-4aab-ad59-b7fe9ee6728e", + "to_change_id": "a12049af-71cd-4392-b4d9-4da89c22f221", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "JumpRope was initial choice for editor text storage", + "created_at": "2026-01-06T09:50:04.359801349-05:00" + }, + { + "id": 62, + "from_node_id": 58, + "to_node_id": 59, + "from_change_id": "a12049af-71cd-4392-b4d9-4da89c22f221", + "to_change_id": "0dcb444b-61cb-41f2-aa1e-2cb3b5b3f23e", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "JumpRope lacked undo/collab, leading to Loro investigation", + "created_at": "2026-01-06T09:50:04.582699646-05:00" + }, + { + "id": 63, + "from_node_id": 59, + "to_node_id": 32, + "from_change_id": "0dcb444b-61cb-41f2-aa1e-2cb3b5b3f23e", + "to_change_id": "60829366-0740-47cc-9101-4057c9c7489e", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Investigation led to Loro migration decision", + "created_at": "2026-01-06T09:50:04.599510915-05:00" + }, + { + "id": 64, + "from_node_id": 6, + "to_node_id": 60, + "from_change_id": "ef3edcf6-e8a8-4d48-9e9e-3885abb53a30", + "to_change_id": "15c2d75c-3fd9-45a4-a714-2cbbed916273", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Sidenotes are part of weaver-flavoured markdown dialect", + "created_at": "2026-01-06T09:50:13.965131193-05:00" + }, + { + "id": 65, + "from_node_id": 50, + "to_node_id": 61, + "from_change_id": "49e83f50-4296-433d-b27e-2479353eb214", + "to_change_id": "57b416e2-9b1e-41a6-b02f-46fe0f604dae", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Render pipeline redesign led to segmented writer refactor", + "created_at": "2026-01-06T09:50:34.108469745-05:00" + }, + { + "id": 66, + "from_node_id": 44, + "to_node_id": 62, + "from_change_id": "947d69f2-7be9-403d-9f24-75d835196c37", + "to_change_id": "13053a77-0246-4382-83ea-251c9bd5fd48", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "OAuth tokens need secure browser storage", + "created_at": "2026-01-06T09:50:34.462256627-05:00" + }, + { + "id": 67, + "from_node_id": 62, + "to_node_id": 63, + "from_change_id": "13053a77-0246-4382-83ea-251c9bd5fd48", + "to_change_id": "9ddb878e-f0e5-4357-b863-6b4796148478", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "chosen - neither share alone reveals secret, data dies with tab", + "created_at": "2026-01-06T09:50:34.478835090-05:00" + }, + { + "id": 68, + "from_node_id": 57, + "to_node_id": 64, + "from_change_id": "6ae02804-d3a1-47fb-a51f-e55ce87cf6d3", + "to_change_id": "0e03638a-7ed3-4bc9-9aa4-06484a765c93", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Dual-backend requires clear endpoint-to-backend mapping", + "created_at": "2026-01-06T09:50:40.860066365-05:00" + }, + { + "id": 69, + "from_node_id": 32, + "to_node_id": 65, + "from_change_id": "60829366-0740-47cc-9101-4057c9c7489e", + "to_change_id": "e35bf396-19ec-4f18-8227-044fd7e099a5", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Loro migration enabled full entry schema in CRDT", + "created_at": "2026-01-06T09:50:59.634171197-05:00" + }, + { + "id": 70, + "from_node_id": 24, + "to_node_id": 66, + "from_change_id": "6b74f268-78fb-42ba-bb09-59e82496ac21", + "to_change_id": "d5463518-b69f-40d0-a6dc-aec0bd78a074", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Performance optimization for cursor movement", + "created_at": "2026-01-06T09:50:59.894918979-05:00" + }, + { + "id": 71, + "from_node_id": 22, + "to_node_id": 67, + "from_change_id": "40dd01d3-768c-4b31-8baf-7d027079d73f", + "to_change_id": "3db38b72-cd99-41ae-b1ae-9ab5d7445b06", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "App live but accumulated technical debt during rapid development", + "created_at": "2026-01-06T09:51:20.892278178-05:00" + }, + { + "id": 72, + "from_node_id": 57, + "to_node_id": 68, + "from_change_id": "6ae02804-d3a1-47fb-a51f-e55ce87cf6d3", + "to_change_id": "87027254-16fb-40e4-a0fc-cc9e2089a10b", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Dual-backend requires concurrency strategy for SQLite shards", + "created_at": "2026-01-06T09:51:21.256537187-05:00" + }, + { + "id": 73, + "from_node_id": 68, + "to_node_id": 69, + "from_change_id": "87027254-16fb-40e4-a0fc-cc9e2089a10b", + "to_change_id": "909bbc20-cf7c-4b8d-878f-d8ae7ee6d3a9", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "chosen - simplest approach, tokio manages blocking threads", + "created_at": "2026-01-06T09:51:21.272721102-05:00" + }, + { + "id": 74, + "from_node_id": 18, + "to_node_id": 70, + "from_change_id": "fa554b5d-8af7-42e4-b03f-e5bec837e31a", + "to_change_id": "42a7505c-c593-4fa2-839f-ca7331324c4e", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Editor extraction requires storage abstraction", + "created_at": "2026-01-06T09:52:00.439471372-05:00" + }, + { + "id": 75, + "from_node_id": 70, + "to_node_id": 71, + "from_change_id": "42a7505c-c593-4fa2-839f-ca7331324c4e", + "to_change_id": "926e5719-d3dc-422b-ba65-af723e0fa49a", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "chosen - enables swapping between local ropey and collaborative Loro", + "created_at": "2026-01-06T09:52:00.455990474-05:00" + }, + { + "id": 76, + "from_node_id": 71, + "to_node_id": 56, + "from_change_id": "926e5719-d3dc-422b-ba65-af723e0fa49a", + "to_change_id": "128ce435-efb2-4608-82a7-771702bbc151", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "LoroBuffer implements TextBuffer trait", + "created_at": "2026-01-06T09:52:00.579222711-05:00" + }, + { + "id": 77, + "from_node_id": 1, + "to_node_id": 72, + "from_change_id": "66cf84c8-cb7f-4acb-9e28-78710cac1549", + "to_change_id": "a796cf1c-7ac7-497b-a995-54b57a4e2c30", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "UI polish for weaver.sh user experience", + "created_at": "2026-01-06T09:53:17.613679825-05:00" + }, + { + "id": 78, + "from_node_id": 70, + "to_node_id": 73, + "from_change_id": "42a7505c-c593-4fa2-839f-ca7331324c4e", + "to_change_id": "2302f391-d481-42a8-8bce-e54c29f6dfe1", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "TextBuffer extraction progressing - nearly production ready", + "created_at": "2026-01-06T09:58:23.037192067-05:00" + }, + { + "id": 79, + "from_node_id": 24, + "to_node_id": 74, + "from_change_id": "6b74f268-78fb-42ba-bb09-59e82496ac21", + "to_change_id": "a6997f6e-06a6-4c9d-ab24-b3ea114207d6", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Current editor implementation state observation", + "created_at": "2026-01-06T09:58:23.150949542-05:00" + }, + { + "id": 80, + "from_node_id": 57, + "to_node_id": 75, + "from_change_id": "6ae02804-d3a1-47fb-a51f-e55ce87cf6d3", + "to_change_id": "a1998766-9d7a-4e59-ba54-881a2626244c", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Dual-backend architecture fully implemented", + "created_at": "2026-01-06T09:58:23.253017405-05:00" + }, + { + "id": 81, + "from_node_id": 6, + "to_node_id": 76, + "from_change_id": "ef3edcf6-e8a8-4d48-9e9e-3885abb53a30", + "to_change_id": "2d09062b-8e32-4e85-92ed-cb2dfa15ddec", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Lexicon definitions implementing the weaver dialect", + "created_at": "2026-01-06T09:58:23.355022791-05:00" + }, + { + "id": 82, + "from_node_id": 36, + "to_node_id": 77, + "from_change_id": "1f756beb-3275-4cb0-a1f4-df805cbc3a20", + "to_change_id": "ef6e7454-2d97-4fe9-97a8-76c2486cf076", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Iroh P2P with DERP relay selected and fully implemented", + "created_at": "2026-01-06T10:36:01.096867537-05:00" + }, + { + "id": 83, + "from_node_id": 55, + "to_node_id": 77, + "from_change_id": "77a50102-1dc0-4009-9715-5c8644745be1", + "to_change_id": "ef6e7454-2d97-4fe9-97a8-76c2486cf076", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Worker architecture design realized in implementation", + "created_at": "2026-01-06T10:36:01.202154640-05:00" + }, + { + "id": 84, + "from_node_id": 77, + "to_node_id": 78, + "from_change_id": "ef6e7454-2d97-4fe9-97a8-76c2486cf076", + "to_change_id": "3af41d3d-93c0-4a8b-a66b-5af3b7c0f048", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Transport implementation enables full collab stack", + "created_at": "2026-01-06T10:36:12.709951736-05:00" + }, + { + "id": 85, + "from_node_id": 25, + "to_node_id": 78, + "from_change_id": "38fa6c2f-2a36-40d4-97ec-4e029501253b", + "to_change_id": "3af41d3d-93c0-4a8b-a66b-5af3b7c0f048", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Loro CRDT + iroh transport = complete collab system", + "created_at": "2026-01-06T10:36:12.808476008-05:00" + }, + { + "id": 86, + "from_node_id": 18, + "to_node_id": 79, + "from_change_id": "fa554b5d-8af7-42e4-b03f-e5bec837e31a", + "to_change_id": "33d80413-0ab3-4955-915e-c8aca65cdfc1", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Current progress on extraction goal", + "created_at": "2026-01-06T10:36:27.102987828-05:00" + }, + { + "id": 87, + "from_node_id": 73, + "to_node_id": 79, + "from_change_id": "2302f391-d481-42a8-8bce-e54c29f6dfe1", + "to_change_id": "33d80413-0ab3-4955-915e-c8aca65cdfc1", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Detailed breakdown of 'nearly complete' status", + "created_at": "2026-01-06T10:36:27.205172937-05:00" + }, + { + "id": 88, + "from_node_id": 12, + "to_node_id": 80, + "from_change_id": "aef81c95-7a98-4dc6-bdc8-ab1df9823db8", + "to_change_id": "dcdeb4a1-2dcd-45a2-9038-ec5b59e1b1d2", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Loro option realized with clean architecture separation", + "created_at": "2026-01-06T10:36:38.490073168-05:00" + }, + { + "id": 89, + "from_node_id": 70, + "to_node_id": 80, + "from_change_id": "42a7505c-c593-4fa2-839f-ca7331324c4e", + "to_change_id": "dcdeb4a1-2dcd-45a2-9038-ec5b59e1b1d2", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "TextBuffer abstraction enables Loro-free core", + "created_at": "2026-01-06T10:36:38.592033235-05:00" + }, + { + "id": 90, + "from_node_id": 81, + "to_node_id": 82, + "from_change_id": "5f00148d-b487-40fb-b4b4-66b8d2489e91", + "to_change_id": "252f6497-56a2-4f10-ab8c-e7865960c481", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "browser crate component", + "created_at": "2026-01-06T10:40:03.196664248-05:00" + }, + { + "id": 91, + "from_node_id": 81, + "to_node_id": 83, + "from_change_id": "5f00148d-b487-40fb-b4b4-66b8d2489e91", + "to_change_id": "63719097-0bec-4805-aa5c-6527256ef708", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "browser crate component", + "created_at": "2026-01-06T10:40:03.213042533-05:00" + }, + { + "id": 92, + "from_node_id": 81, + "to_node_id": 84, + "from_change_id": "5f00148d-b487-40fb-b4b4-66b8d2489e91", + "to_change_id": "6c25dd34-ac4d-49d1-88d2-13da6b93c872", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "browser crate component", + "created_at": "2026-01-06T10:40:03.229908179-05:00" + }, + { + "id": 93, + "from_node_id": 81, + "to_node_id": 85, + "from_change_id": "5f00148d-b487-40fb-b4b4-66b8d2489e91", + "to_change_id": "b6e15092-6258-4ca6-9c1b-d1dc58280a92", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "browser crate component", + "created_at": "2026-01-06T10:40:03.246188772-05:00" + }, + { + "id": 94, + "from_node_id": 81, + "to_node_id": 86, + "from_change_id": "5f00148d-b487-40fb-b4b4-66b8d2489e91", + "to_change_id": "c50d83f9-d6cb-4697-a24c-50a7707d395c", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "browser crate component", + "created_at": "2026-01-06T10:40:03.262252879-05:00" + }, + { + "id": 95, + "from_node_id": 18, + "to_node_id": 81, + "from_change_id": "fa554b5d-8af7-42e4-b03f-e5bec837e31a", + "to_change_id": "5f00148d-b487-40fb-b4b4-66b8d2489e91", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "browser crate is part of extraction", + "created_at": "2026-01-06T10:40:03.365630222-05:00" + }, + { + "id": 96, + "from_node_id": 87, + "to_node_id": 88, + "from_change_id": "1d5c50c0-bfeb-4218-88ab-405d12ba0607", + "to_change_id": "ed566bdc-7372-4ea5-966c-a7f10e2c5453", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "crdt crate component", + "created_at": "2026-01-06T10:40:19.731408592-05:00" + }, + { + "id": 98, + "from_node_id": 87, + "to_node_id": 90, + "from_change_id": "1d5c50c0-bfeb-4218-88ab-405d12ba0607", + "to_change_id": "cdee4656-7495-4a9c-8d24-0f232941763e", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "crdt crate component", + "created_at": "2026-01-06T10:40:19.765135356-05:00" + }, + { + "id": 99, + "from_node_id": 18, + "to_node_id": 87, + "from_change_id": "fa554b5d-8af7-42e4-b03f-e5bec837e31a", + "to_change_id": "1d5c50c0-bfeb-4218-88ab-405d12ba0607", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "crdt crate is part of extraction", + "created_at": "2026-01-06T10:40:19.869970125-05:00" + }, + { + "id": 100, + "from_node_id": 91, + "to_node_id": 92, + "from_change_id": "57d3f2a1-bd8d-45f1-8834-97bac872dae8", + "to_change_id": "3d62e1bf-b43c-4924-b32e-2a5190b2ed0e", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Phase 2 task", + "created_at": "2026-01-06T10:42:23.415671952-05:00" + }, + { + "id": 101, + "from_node_id": 91, + "to_node_id": 93, + "from_change_id": "57d3f2a1-bd8d-45f1-8834-97bac872dae8", + "to_change_id": "64a17922-3079-4e23-a8f2-114758a3ed6d", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Phase 2 task", + "created_at": "2026-01-06T10:42:23.431774230-05:00" + }, + { + "id": 102, + "from_node_id": 91, + "to_node_id": 94, + "from_change_id": "57d3f2a1-bd8d-45f1-8834-97bac872dae8", + "to_change_id": "f7f521a5-db62-43fd-8e77-bff00524c9c3", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Phase 2 task", + "created_at": "2026-01-06T10:42:23.448388245-05:00" + }, + { + "id": 103, + "from_node_id": 18, + "to_node_id": 91, + "from_change_id": "fa554b5d-8af7-42e4-b03f-e5bec837e31a", + "to_change_id": "57d3f2a1-bd8d-45f1-8834-97bac872dae8", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Phase 2 follows extraction", + "created_at": "2026-01-06T10:42:23.559182344-05:00" + }, + { + "id": 104, + "from_node_id": 95, + "to_node_id": 96, + "from_change_id": "a0bde70d-28e9-4a27-9bc7-814415c4d61a", + "to_change_id": "7917c340-2614-40e5-96b3-ec569aca0053", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Phase 3 task", + "created_at": "2026-01-06T10:42:51.789378301-05:00" + }, + { + "id": 105, + "from_node_id": 95, + "to_node_id": 97, + "from_change_id": "a0bde70d-28e9-4a27-9bc7-814415c4d61a", + "to_change_id": "32765e84-b76d-45cf-940e-28bc35e14339", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Phase 3 task", + "created_at": "2026-01-06T10:42:51.805461864-05:00" + }, + { + "id": 106, + "from_node_id": 95, + "to_node_id": 98, + "from_change_id": "a0bde70d-28e9-4a27-9bc7-814415c4d61a", + "to_change_id": "371419cc-a552-41d7-9f4e-3239b4d8b5ad", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Phase 3 task", + "created_at": "2026-01-06T10:42:51.821821232-05:00" + }, + { + "id": 107, + "from_node_id": 18, + "to_node_id": 95, + "from_change_id": "fa554b5d-8af7-42e4-b03f-e5bec837e31a", + "to_change_id": "a0bde70d-28e9-4a27-9bc7-814415c4d61a", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Phase 3 follows extraction", + "created_at": "2026-01-06T10:42:51.920361430-05:00" + }, + { + "id": 108, + "from_node_id": 30, + "to_node_id": 95, + "from_change_id": "2eeb7345-a8a2-4146-bda5-1f19cfc7501d", + "to_change_id": "a0bde70d-28e9-4a27-9bc7-814415c4d61a", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "JS bindings goal realized by Phase 3", + "created_at": "2026-01-06T10:42:51.936792833-05:00" + }, + { + "id": 109, + "from_node_id": 70, + "to_node_id": 99, + "from_change_id": "42a7505c-c593-4fa2-839f-ca7331324c4e", + "to_change_id": "e1275977-76fd-4a21-89d2-1badd40054a2", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "EditorDocument trait builds on TextBuffer abstraction", + "created_at": "2026-01-06T10:44:26.376774105-05:00" + }, + { + "id": 110, + "from_node_id": 99, + "to_node_id": 88, + "from_change_id": "e1275977-76fd-4a21-89d2-1badd40054a2", + "to_change_id": "ed566bdc-7372-4ea5-966c-a7f10e2c5453", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "LoroDocument is an impl", + "created_at": "2026-01-06T10:44:26.393929332-05:00" + }, + { + "id": 111, + "from_node_id": 99, + "to_node_id": 92, + "from_change_id": "e1275977-76fd-4a21-89d2-1badd40054a2", + "to_change_id": "3d62e1bf-b43c-4924-b32e-2a5190b2ed0e", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "SignalEditorDocument wraps an impl", + "created_at": "2026-01-06T10:44:26.410025086-05:00" + }, + { + "id": 112, + "from_node_id": 100, + "to_node_id": 101, + "from_change_id": "e48d466a-1b04-4da7-a9db-cd0abe3d8ad2", + "to_change_id": "efd6969a-aa78-42fb-9238-a8ba0e474b3d", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "option", + "created_at": "2026-01-06T10:44:42.717755231-05:00" + }, + { + "id": 113, + "from_node_id": 100, + "to_node_id": 102, + "from_change_id": "e48d466a-1b04-4da7-a9db-cd0abe3d8ad2", + "to_change_id": "384d5ccb-73bc-40c3-901a-b4894809d67d", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "option", + "created_at": "2026-01-06T10:44:42.734135618-05:00" + }, + { + "id": 114, + "from_node_id": 99, + "to_node_id": 100, + "from_change_id": "e1275977-76fd-4a21-89d2-1badd40054a2", + "to_change_id": "e48d466a-1b04-4da7-a9db-cd0abe3d8ad2", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "design question from EditorDocument pattern", + "created_at": "2026-01-06T10:44:42.750688578-05:00" + }, + { + "id": 115, + "from_node_id": 95, + "to_node_id": 100, + "from_change_id": "a0bde70d-28e9-4a27-9bc7-814415c4d61a", + "to_change_id": "e48d466a-1b04-4da7-a9db-cd0abe3d8ad2", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "affects JS bindings design", + "created_at": "2026-01-06T10:44:42.767220258-05:00" + }, + { + "id": 116, + "from_node_id": 103, + "to_node_id": 104, + "from_change_id": "a83f0c2c-ede4-4407-a048-535462783960", + "to_change_id": "1fb0e2b9-f6e7-4e1d-a514-8b370712c232", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "option", + "created_at": "2026-01-06T11:09:12.835452317-05:00" + }, + { + "id": 117, + "from_node_id": 103, + "to_node_id": 105, + "from_change_id": "a83f0c2c-ede4-4407-a048-535462783960", + "to_change_id": "29fef523-1eb5-497c-905a-16dec60b30df", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "option", + "created_at": "2026-01-06T11:09:12.851576977-05:00" + }, + { + "id": 118, + "from_node_id": 103, + "to_node_id": 106, + "from_change_id": "a83f0c2c-ede4-4407-a048-535462783960", + "to_change_id": "ba8b0f30-09f9-437d-b690-b5053b9991ad", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "option", + "created_at": "2026-01-06T11:09:12.867846759-05:00" + }, + { + "id": 119, + "from_node_id": 107, + "to_node_id": 103, + "from_change_id": "11c284c0-37c2-4736-aa4e-479f77b7c5be", + "to_change_id": "a83f0c2c-ede4-4407-a048-535462783960", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "observation informs decision", + "created_at": "2026-01-06T11:09:17.470517267-05:00" + }, + { + "id": 120, + "from_node_id": 81, + "to_node_id": 103, + "from_change_id": "5f00148d-b487-40fb-b4b4-66b8d2489e91", + "to_change_id": "a83f0c2c-ede4-4407-a048-535462783960", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "browser crate goal blocked by this decision", + "created_at": "2026-01-06T11:09:17.487429520-05:00" + }, + { + "id": 121, + "from_node_id": 103, + "to_node_id": 108, + "from_change_id": "a83f0c2c-ede4-4407-a048-535462783960", + "to_change_id": "86195ee9-4147-4874-8f89-7a0e882a5132", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "decision resolved", + "created_at": "2026-01-06T11:17:51.661390358-05:00" + }, + { + "id": 122, + "from_node_id": 108, + "to_node_id": 109, + "from_change_id": "86195ee9-4147-4874-8f89-7a0e882a5132", + "to_change_id": "57096f55-c0fd-4c6c-a45f-7877def5ef70", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "implies deferral", + "created_at": "2026-01-06T11:17:51.677589947-05:00" + }, + { + "id": 123, + "from_node_id": 81, + "to_node_id": 110, + "from_change_id": "5f00148d-b487-40fb-b4b4-66b8d2489e91", + "to_change_id": "4dfdbea5-6bcd-4377-90c1-a35e68b59d11", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "browser crate feature", + "created_at": "2026-01-06T11:17:51.694176679-05:00" + }, + { + "id": 124, + "from_node_id": 87, + "to_node_id": 111, + "from_change_id": "1d5c50c0-bfeb-4218-88ab-405d12ba0607", + "to_change_id": "d05ddf03-4487-4fc6-932c-eb91e58b1ebf", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "affects crdt crate scope", + "created_at": "2026-01-06T11:17:51.710191893-05:00" + }, + { + "id": 125, + "from_node_id": 87, + "to_node_id": 112, + "from_change_id": "1d5c50c0-bfeb-4218-88ab-405d12ba0607", + "to_change_id": "1b0e70cb-ebed-4a59-8a94-2314491a9e84", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "crdt crate component", + "created_at": "2026-01-06T11:18:09.538809855-05:00" + }, + { + "id": 126, + "from_node_id": 87, + "to_node_id": 113, + "from_change_id": "1d5c50c0-bfeb-4218-88ab-405d12ba0607", + "to_change_id": "4dd1947c-e4d8-4134-8174-523d037ae728", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "scope clarification", + "created_at": "2026-01-06T11:19:22.911307605-05:00" + }, + { + "id": 127, + "from_node_id": 87, + "to_node_id": 114, + "from_change_id": "1d5c50c0-bfeb-4218-88ab-405d12ba0607", + "to_change_id": "e32a4e20-d5c8-40b7-82f8-9519313bcc55", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "crdt crate component", + "created_at": "2026-01-06T11:19:22.927827723-05:00" + }, + { + "id": 128, + "from_node_id": 87, + "to_node_id": 115, + "from_change_id": "1d5c50c0-bfeb-4218-88ab-405d12ba0607", + "to_change_id": "f991c3b0-0b5e-43b6-8d3a-59067ce3175e", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "design principle", + "created_at": "2026-01-06T11:20:40.341316533-05:00" + }, + { + "id": 129, + "from_node_id": 81, + "to_node_id": 117, + "from_change_id": "5f00148d-b487-40fb-b4b4-66b8d2489e91", + "to_change_id": "2a2333b1-eceb-4635-877f-26f55790d7ed", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "first implementation milestone", + "created_at": "2026-01-06T11:33:04.235835206-05:00" + }, + { + "id": 130, + "from_node_id": 116, + "to_node_id": 117, + "from_change_id": "cd5cc522-d717-43a7-b116-94f86845db10", + "to_change_id": "2a2333b1-eceb-4635-877f-26f55790d7ed", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "pattern applied", + "created_at": "2026-01-06T11:33:04.252811116-05:00" + }, + { + "id": 131, + "from_node_id": 117, + "to_node_id": 118, + "from_change_id": "2a2333b1-eceb-4635-877f-26f55790d7ed", + "to_change_id": "d6f1e398-007f-4eb6-94dd-33a97695bcbd", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "API refinement", + "created_at": "2026-01-06T11:35:39.734624305-05:00" + } + ] +} \ No newline at end of file -- knotmirror 0.0.1