Initial commit: Plannotator - Plan review UI for Claude Code

A React-based annotation tool that intercepts Claude Code's ExitPlanMode
to provide visual plan review with collaborative features.

Features:
- Markdown plan rendering with syntax highlighting
- Text selection annotations (delete, replace, insert, comment)
- Redline mode for quick deletion marking
- URL-based sharing via compressed hash (no server storage)
- Tater identity system for anonymous collaboration
- Export to diff format
- Dark/light theme support
- Tater sprite easter egg

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Michael Ramos
2025-12-27 18:14:10 -08:00
commit 92d4f73b66
37 changed files with 6874 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+573
View File
@@ -0,0 +1,573 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { parseMarkdownToBlocks, exportDiff } from './utils/parser';
import { Viewer, ViewerHandle } from './components/Viewer';
import { AnnotationPanel } from './components/AnnotationPanel';
import { Landing } from './components/Landing';
import { ExportModal } from './components/ExportModal';
import { Annotation, Block, AnnotationType, EditorMode } from './types';
import { ThemeProvider } from './components/ThemeProvider';
import { ModeToggle } from './components/ModeToggle';
import { ModeSwitcher } from './components/ModeSwitcher';
import { TaterSpriteRunning } from './components/TaterSpriteRunning';
import { TaterSpritePullup } from './components/TaterSpritePullup';
import { Settings } from './components/Settings';
import { useSharing } from './hooks/useSharing';
const PLAN_CONTENT = `# Implementation Plan: Real-time Collaboration
## Overview
Add real-time collaboration features to the editor using WebSocket connections and operational transforms.
## Phase 1: Infrastructure
### WebSocket Server
Set up a WebSocket server to handle concurrent connections:
\`\`\`typescript
const server = new WebSocketServer({ port: 8080 });
server.on('connection', (socket, request) => {
const sessionId = generateSessionId();
sessions.set(sessionId, socket);
socket.on('message', (data) => {
broadcast(sessionId, data);
});
});
\`\`\`
### Client Connection
- Establish persistent connection on document load
- Implement reconnection logic with exponential backoff
- Handle offline state gracefully
### Database Schema
\`\`\`sql
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL,
content JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE TABLE collaborators (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID REFERENCES documents(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
role VARCHAR(50) DEFAULT 'editor',
cursor_position JSONB,
last_seen_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_collaborators_document ON collaborators(document_id);
\`\`\`
## Phase 2: Operational Transforms
> The key insight is that we need to transform operations against concurrent operations to maintain consistency.
Key requirements:
- Transform insert against insert
- Transform insert against delete
- Transform delete against delete
- Maintain cursor positions across transforms
### Transform Implementation
\`\`\`typescript
interface Operation {
type: 'insert' | 'delete';
position: number;
content?: string;
length?: number;
userId: string;
timestamp: number;
}
class OperationalTransform {
private pendingOps: Operation[] = [];
private history: Operation[] = [];
transform(op1: Operation, op2: Operation): [Operation, Operation] {
if (op1.type === 'insert' && op2.type === 'insert') {
if (op1.position <= op2.position) {
return [op1, { ...op2, position: op2.position + (op1.content?.length || 0) }];
} else {
return [{ ...op1, position: op1.position + (op2.content?.length || 0) }, op2];
}
}
if (op1.type === 'delete' && op2.type === 'delete') {
// Complex delete vs delete transformation
const op1End = op1.position + (op1.length || 0);
const op2End = op2.position + (op2.length || 0);
if (op1End <= op2.position) {
return [op1, { ...op2, position: op2.position - (op1.length || 0) }];
}
// ... more cases
}
return [op1, op2];
}
apply(doc: string, op: Operation): string {
if (op.type === 'insert') {
return doc.slice(0, op.position) + op.content + doc.slice(op.position);
} else {
return doc.slice(0, op.position) + doc.slice(op.position + (op.length || 0));
}
}
}
\`\`\`
## Phase 3: UI Updates
1. Show collaborator cursors in real-time
2. Display presence indicators
3. Add conflict resolution UI
4. Implement undo/redo stack per user
### React Component for Cursors
\`\`\`tsx
import React, { useEffect, useState } from 'react';
import { useCollaboration } from '../hooks/useCollaboration';
interface CursorOverlayProps {
documentId: string;
containerRef: React.RefObject<HTMLDivElement>;
}
export const CursorOverlay: React.FC<CursorOverlayProps> = ({
documentId,
containerRef
}) => {
const { collaborators, currentUser } = useCollaboration(documentId);
const [positions, setPositions] = useState<Map<string, DOMRect>>(new Map());
useEffect(() => {
const updatePositions = () => {
const newPositions = new Map<string, DOMRect>();
collaborators.forEach(collab => {
if (collab.userId !== currentUser.id && collab.cursorPosition) {
const rect = getCursorRect(containerRef.current, collab.cursorPosition);
if (rect) newPositions.set(collab.userId, rect);
}
});
setPositions(newPositions);
};
const interval = setInterval(updatePositions, 50);
return () => clearInterval(interval);
}, [collaborators, currentUser, containerRef]);
return (
<>
{Array.from(positions.entries()).map(([userId, rect]) => (
<div
key={userId}
className="absolute pointer-events-none transition-all duration-75"
style={{
left: rect.left,
top: rect.top,
height: rect.height,
}}
>
<div className="w-0.5 h-full bg-blue-500 animate-pulse" />
<div className="absolute -top-5 left-0 px-1.5 py-0.5 bg-blue-500
text-white text-xs rounded whitespace-nowrap">
{collaborators.find(c => c.userId === userId)?.userName}
</div>
</div>
))}
</>
);
};
\`\`\`
### Configuration
\`\`\`json
{
"collaboration": {
"enabled": true,
"maxCollaborators": 10,
"cursorColors": ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"],
"syncInterval": 100,
"reconnect": {
"maxAttempts": 5,
"backoffMultiplier": 1.5,
"initialDelay": 1000
}
}
}
\`\`\`
---
**Target:** Ship MVP in next sprint
`;
const App: React.FC = () => {
const [page, setPage] = useState<'landing' | 'editor'>(() => {
const path = window.location.pathname;
const hasShareHash = window.location.hash.length > 1;
// Go directly to editor if path indicates or if we have a share hash
return path === '/editor' || path === '/app' || hasShareHash ? 'editor' : 'landing';
});
const [markdown, setMarkdown] = useState(PLAN_CONTENT);
const [annotations, setAnnotations] = useState<Annotation[]>([]);
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
const [blocks, setBlocks] = useState<Block[]>([]);
const [showExport, setShowExport] = useState(false);
const [showFeedbackPrompt, setShowFeedbackPrompt] = useState(false);
const [isPanelOpen, setIsPanelOpen] = useState(true);
const [editorMode, setEditorMode] = useState<EditorMode>('selection');
const [taterMode, setTaterMode] = useState(() => {
const stored = localStorage.getItem('plannotator-tater-mode');
return stored === 'true';
});
const [isApiMode, setIsApiMode] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitted, setSubmitted] = useState<'approved' | 'denied' | null>(null);
const viewerRef = useRef<ViewerHandle>(null);
// URL-based sharing
const {
isSharedSession,
isLoadingShared,
shareUrl,
shareUrlSize,
pendingSharedAnnotations,
clearPendingSharedAnnotations,
} = useSharing(
markdown,
annotations,
setMarkdown,
setAnnotations,
() => {
// When loaded from share, go directly to editor
setPage('editor');
setIsLoading(false);
}
);
// Apply shared annotations to DOM after they're loaded
useEffect(() => {
if (pendingSharedAnnotations && pendingSharedAnnotations.length > 0) {
// Small delay to ensure DOM is rendered
const timer = setTimeout(() => {
// Clear existing highlights first (important when loading new share URL)
viewerRef.current?.clearAllHighlights();
viewerRef.current?.applySharedAnnotations(pendingSharedAnnotations);
clearPendingSharedAnnotations();
}, 100);
return () => clearTimeout(timer);
}
}, [pendingSharedAnnotations, clearPendingSharedAnnotations]);
const goToEditor = () => {
window.history.pushState({}, '', '/editor');
setPage('editor');
};
const handleTaterModeChange = (enabled: boolean) => {
setTaterMode(enabled);
localStorage.setItem('plannotator-tater-mode', String(enabled));
};
// Check if we're in API mode (served from Bun hook server)
// Skip if we loaded from a shared URL
useEffect(() => {
if (isLoadingShared) return; // Wait for share check to complete
if (isSharedSession) return; // Already loaded from share
fetch('/api/plan')
.then(res => {
if (!res.ok) throw new Error('Not in API mode');
return res.json();
})
.then((data: { plan: string }) => {
setMarkdown(data.plan);
setIsApiMode(true);
setPage('editor'); // Skip landing page in API mode
})
.catch(() => {
// Not in API mode - use default content
setIsApiMode(false);
})
.finally(() => setIsLoading(false));
}, [isLoadingShared, isSharedSession]);
useEffect(() => {
setBlocks(parseMarkdownToBlocks(markdown));
}, [markdown]);
// API mode handlers
const handleApprove = async () => {
setIsSubmitting(true);
try {
await fetch('/api/approve', { method: 'POST' });
setSubmitted('approved');
} catch {
setIsSubmitting(false);
}
};
const handleDeny = async () => {
setIsSubmitting(true);
try {
await fetch('/api/deny', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ feedback: diffOutput })
});
setSubmitted('denied');
} catch {
setIsSubmitting(false);
}
};
const handleAddAnnotation = (ann: Annotation) => {
setAnnotations(prev => [...prev, ann]);
setSelectedAnnotationId(ann.id);
setIsPanelOpen(true);
};
const handleDeleteAnnotation = (id: string) => {
viewerRef.current?.removeHighlight(id);
setAnnotations(prev => prev.filter(a => a.id !== id));
if (selectedAnnotationId === id) setSelectedAnnotationId(null);
};
const handleIdentityChange = (oldIdentity: string, newIdentity: string) => {
setAnnotations(prev => prev.map(ann =>
ann.author === oldIdentity ? { ...ann, author: newIdentity } : ann
));
};
const diffOutput = useMemo(() => exportDiff(blocks, annotations), [blocks, annotations]);
if (page === 'landing') {
return (
<ThemeProvider defaultTheme="dark">
<Landing onEnter={goToEditor} />
</ThemeProvider>
);
}
return (
<ThemeProvider defaultTheme="dark">
<div className="h-screen flex flex-col bg-background overflow-hidden">
{/* Tater sprites */}
{taterMode && <TaterSpriteRunning />}
{/* Minimal Header */}
<header className="h-12 flex items-center justify-between px-2 md:px-4 border-b border-border/50 bg-card/50 backdrop-blur-xl z-50">
<div className="flex items-center gap-2 md:gap-3">
<button
onClick={() => { window.history.pushState({}, '', '/'); setPage('landing'); }}
className="flex items-center gap-1.5 md:gap-2 hover:opacity-80 transition-opacity"
>
<div className="w-6 h-6 rounded-md bg-primary/20 flex items-center justify-center">
<svg className="w-3.5 h-3.5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<span className="text-sm font-semibold tracking-tight hidden sm:inline">Plannotator</span>
</button>
<span className="text-xs text-muted-foreground font-mono opacity-60 hidden md:inline">v0.1</span>
</div>
<div className="flex items-center gap-1 md:gap-2">
{isApiMode && (
<>
<button
onClick={() => {
if (annotations.length === 0) {
setShowFeedbackPrompt(true);
} else {
handleDeny();
}
}}
disabled={isSubmitting}
className={`p-1.5 md:px-2.5 md:py-1 rounded-md text-xs font-medium transition-all ${
isSubmitting
? 'opacity-50 cursor-not-allowed bg-muted text-muted-foreground'
: 'bg-accent/15 text-accent hover:bg-accent/25 border border-accent/30'
}`}
title="Provide Feedback"
>
<svg className="w-4 h-4 md:hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<span className="hidden md:inline">{isSubmitting ? 'Sending...' : 'Provide Feedback'}</span>
</button>
<button
onClick={handleApprove}
disabled={isSubmitting}
className={`px-2 py-1 md:px-2.5 rounded-md text-xs font-medium transition-all ${
isSubmitting
? 'opacity-50 cursor-not-allowed bg-muted text-muted-foreground'
: 'bg-green-600 text-white hover:bg-green-500'
}`}
>
<span className="md:hidden">{isSubmitting ? '...' : 'OK'}</span>
<span className="hidden md:inline">{isSubmitting ? 'Approving...' : 'Approve'}</span>
</button>
<div className="w-px h-5 bg-border/50 mx-1 hidden md:block" />
</>
)}
<ModeToggle />
<Settings taterMode={taterMode} onTaterModeChange={handleTaterModeChange} onIdentityChange={handleIdentityChange} />
<button
onClick={() => setIsPanelOpen(!isPanelOpen)}
className={`p-1.5 rounded-md text-xs font-medium transition-all ${
isPanelOpen
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
}`}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
</button>
<button
onClick={() => setShowExport(true)}
className="p-1.5 md:px-2.5 md:py-1 rounded-md text-xs font-medium bg-muted hover:bg-muted/80 transition-colors"
title="Export"
>
<svg className="w-4 h-4 md:hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
<span className="hidden md:inline">Export</span>
</button>
</div>
</header>
{/* Main Content */}
<div className="flex-1 flex overflow-hidden">
{/* Document Area */}
<main className="flex-1 overflow-y-auto bg-grid">
<div className="min-h-full flex flex-col items-center p-3 md:p-8">
{/* Mode Switcher */}
<div className="w-full max-w-3xl mb-3 md:mb-4 flex justify-start">
<ModeSwitcher mode={editorMode} onChange={setEditorMode} taterMode={taterMode} />
</div>
<Viewer
ref={viewerRef}
blocks={blocks}
annotations={annotations}
onAddAnnotation={handleAddAnnotation}
onSelectAnnotation={setSelectedAnnotationId}
selectedAnnotationId={selectedAnnotationId}
mode={editorMode}
taterMode={taterMode}
/>
</div>
</main>
{/* Annotation Panel */}
<AnnotationPanel
isOpen={isPanelOpen}
blocks={blocks}
annotations={annotations}
selectedId={selectedAnnotationId}
onSelect={setSelectedAnnotationId}
onDelete={handleDeleteAnnotation}
shareUrl={shareUrl}
/>
</div>
{/* Export Modal */}
<ExportModal
isOpen={showExport}
onClose={() => setShowExport(false)}
shareUrl={shareUrl}
shareUrlSize={shareUrlSize}
diffOutput={diffOutput}
annotationCount={annotations.length}
taterSprite={taterMode ? <TaterSpritePullup /> : undefined}
/>
{/* Feedback prompt dialog */}
{showFeedbackPrompt && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<div className="bg-card border border-border rounded-xl w-full max-w-sm shadow-2xl p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-accent/20 flex items-center justify-center">
<svg className="w-5 h-5 text-accent" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
</div>
<h3 className="font-semibold">Add Annotations First</h3>
</div>
<p className="text-sm text-muted-foreground mb-6">
To provide feedback, select text in the plan and add annotations. Claude will use your annotations to revise the plan.
</p>
<div className="flex justify-end">
<button
onClick={() => setShowFeedbackPrompt(false)}
className="px-4 py-2 rounded-md text-sm font-medium bg-primary text-primary-foreground hover:opacity-90 transition-opacity"
>
Got it
</button>
</div>
</div>
</div>
)}
{/* Completion overlay - shown after approve/deny */}
{submitted && (
<div className="fixed inset-0 z-[100] bg-background flex items-center justify-center">
<div className="text-center space-y-6 max-w-md px-8">
<div className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center ${
submitted === 'approved'
? 'bg-green-500/20 text-green-500'
: 'bg-accent/20 text-accent'
}`}>
{submitted === 'approved' ? (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
)}
</div>
<div className="space-y-2">
<h2 className="text-xl font-semibold text-foreground">
{submitted === 'approved' ? 'Plan Approved' : 'Feedback Sent'}
</h2>
<p className="text-muted-foreground">
{submitted === 'approved'
? 'Claude will proceed with the implementation.'
: 'Claude will revise the plan based on your annotations.'}
</p>
</div>
<div className="pt-4 border-t border-border">
<p className="text-sm text-muted-foreground">
Return to your <span className="text-foreground font-medium">Claude Code terminal</span> to continue.
</p>
</div>
</div>
</div>
)}
</div>
</ThemeProvider>
);
};
export default App;
+240
View File
@@ -0,0 +1,240 @@
# Plannotator
A plan review UI for Claude Code that intercepts `ExitPlanMode` and lets you approve or request changes with annotated feedback.
## How It Works
### Hook Flow
```
Claude calls ExitPlanMode
│
▼
PermissionRequest hook fires
│
▼
hooks/exit-plan-mode.sh reads plan from stdin JSON
│
▼
Bun server starts (random port)
│
▼
Browser opens to Plannotator UI
│
▼
User reviews plan, optionally adds annotations
│
├─── Approve ───► JSON output: {"decision": {"behavior": "allow"}}
│ │
│ ▼
│ Claude proceeds with implementation
│
└─── Request Changes ───► JSON output: {"decision": {"behavior": "deny", "message": "..."}}
│
▼
Claude receives feedback, revises plan
```
### Key Files
| File | Purpose |
|------|---------|
| `hooks/exit-plan-mode.sh` | Shell script invoked by Claude Code hook system |
| `server/index.ts` | Bun server that serves UI and handles approve/deny API |
| `App.tsx` | React app with API mode detection |
| `dist/index.html` | Built single-file app served by Bun |
### Hook Configuration
The hook is configured in `.claude/settings.local.json`:
```json
{
"hooks": {
"PermissionRequest": [
{
"matcher": "ExitPlanMode",
"hooks": [
{
"type": "command",
"command": "/path/to/planning-hook/hooks/exit-plan-mode.sh",
"timeout": 600
}
]
}
]
}
}
```
**Why PermissionRequest instead of PreToolUse?**
- `PermissionRequest` fires when Claude Code is about to show a permission dialog
- Hook can return JSON to `allow` or `deny` directly — no second dialog
- `PreToolUse` would require a second approval step in Claude Code UI
### Server API
The Bun server exposes three endpoints:
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/api/plan` | GET | Returns plan content as JSON |
| `/api/approve` | POST | User approved the plan |
| `/api/deny` | POST | User denied with feedback in request body |
### Multi-Instance Support
Each Plannotator instance runs on a random port (`Bun.serve({ port: 0 })`). The browser's same-origin policy ensures each tab talks to its own server — no cross-instance interference.
### Hook Output Format
The server outputs JSON to stdout for the PermissionRequest hook:
**Approve:**
```json
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "allow"
}
}
}
```
**Deny:**
```json
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "deny",
"message": "# Plan Feedback\n\n..."
}
}
}
```
## Annotation Data Model
### Types (`types.ts`)
```typescript
enum AnnotationType {
DELETION = 'DELETION', // Remove this text
INSERTION = 'INSERTION', // Add new text (originalText is context)
REPLACEMENT = 'REPLACEMENT', // Replace originalText with text
COMMENT = 'COMMENT', // Comment on originalText
}
interface Annotation {
id: string; // From web-highlighter or `codeblock-{timestamp}`
blockId: string; // References Block.id (legacy, for sorting)
startOffset: number; // Char offset in block (legacy)
endOffset: number; // End char offset (legacy)
type: AnnotationType;
text?: string; // User text for comment/replacement/insertion
originalText: string; // The selected/highlighted text
createdA: number; // Timestamp for ordering
author?: string; // Tater identity for collaborative sharing
startMeta?: { parentTagName, parentIndex, textOffset }; // web-highlighter DOM pos
endMeta?: { parentTagName, parentIndex, textOffset };
}
interface Block {
id: string; // e.g., "block-0"
type: 'paragraph' | 'heading' | 'blockquote' | 'list-item' | 'code' | 'hr';
content: string;
level?: number; // For headings (1-6)
order: number;
startLine: number; // 1-based line in original markdown
}
```
### Annotation Creation Flow
1. User selects text → `web-highlighter` fires `CREATE` event
2. In **selection mode**: Toolbar appears with type options
3. In **redline mode**: Auto-creates `DELETION` annotation
4. `createAnnotationFromSource()` builds `Annotation` object
5. Annotation stored in `App.tsx` state: `useState<Annotation[]>([])`
### Code Block Annotations
Code blocks can't use web-highlighter (content not directly selectable). Instead:
- Hover triggers `CodeBlockToolbar`
- Manual `<mark>` wrapper created with `range.surroundContents()`
- ID format: `codeblock-{timestamp}`
- Must handle removal separately via `data-bind-id` attribute
## URL-Based Sharing
Plannotator supports decentralized sharing via URL hash. The entire plan and annotations are compressed into the URL — no server storage required.
### Implementation
| File | Purpose |
|------|---------|
| `utils/sharing.ts` | Compress/decompress using native `CompressionStream` (deflate-raw) + base64url |
| `hooks/useSharing.ts` | URL state management, hash change detection |
| `components/ExportModal.tsx` | Tabbed modal with Share and Raw Diff tabs |
### Share Format
```typescript
interface SharePayload {
p: string; // Plan markdown
a: ShareableAnnotation[]; // Minimal annotation tuples
}
// Annotations compressed to tuples: [type, originalText, text?, author?]
type ShareableAnnotation =
| ['D', string, string | null] // Deletion
| ['R', string, string, string | null] // Replacement
| ['C', string, string, string | null] // Comment
| ['I', string, string, string | null]; // Insertion
```
### How It Works
**Sharing:**
1. User clicks Share → payload compressed with `deflate-raw` → base64url encoded
2. URL becomes `plannotator.app/#<compressed-data>`
3. Typical plan + annotations: ~1-2KB URL
**Loading:**
1. On mount, check `location.hash`
2. If present, decompress → restore plan and annotations
3. Find annotation text in DOM using `TreeWalker`
4. Apply `<mark>` highlights programmatically
### Tater Identity System
Anonymous identities for collaborative annotation:
- Format: `{adjective}-{noun}-tater` (e.g., "swift-falcon-tater")
- Generated via `unique-username-generator` library
- Stored in localStorage, shown on annotation cards
- Regenerate anytime in Settings
## Development
```bash
# Install dependencies
npm install
# Run dev server (for UI development)
npm run dev
# Build for production
npm run build
# Test server standalone
bun run server/index.ts "# Test Plan\n\nSome content"
```
## Requirements
- Bun runtime
- Claude Code with hooks support
- macOS (uses `open` command for browser)
+20
View File
@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/1T58blWi735nbP9jxqohfRrCa584GUPIB
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`
+235
View File
@@ -0,0 +1,235 @@
import React, { useState } from 'react';
import { Annotation, AnnotationType, Block } from '../types';
import { isCurrentUser } from '../utils/identity';
interface PanelProps {
isOpen: boolean;
annotations: Annotation[];
blocks: Block[];
onSelect: (id: string) => void;
onDelete: (id: string) => void;
selectedId: string | null;
shareUrl?: string;
}
export const AnnotationPanel: React.FC<PanelProps> = ({
isOpen,
annotations,
blocks,
onSelect,
onDelete,
selectedId,
shareUrl
}) => {
const [copied, setCopied] = useState(false);
const sortedAnnotations = [...annotations].sort((a, b) => a.createdA - b.createdA);
const handleQuickShare = async () => {
if (!shareUrl) return;
try {
await navigator.clipboard.writeText(shareUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (e) {
console.error('Failed to copy:', e);
}
};
if (!isOpen) return null;
return (
<aside className="w-72 border-l border-border/50 bg-card/30 backdrop-blur-sm flex flex-col">
{/* Header */}
<div className="p-3 border-b border-border/50">
<div className="flex items-center justify-between">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Annotations
</h2>
<span className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground">
{annotations.length}
</span>
</div>
</div>
{/* List */}
<div className="flex-1 overflow-y-auto p-2 space-y-1.5">
{sortedAnnotations.length === 0 ? (
<div className="flex flex-col items-center justify-center h-40 text-center px-4">
<div className="w-10 h-10 rounded-full bg-muted/50 flex items-center justify-center mb-3">
<svg className="w-5 h-5 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
</div>
<p className="text-xs text-muted-foreground">
Select text to add annotations
</p>
</div>
) : (
sortedAnnotations.map(ann => (
<AnnotationCard
key={ann.id}
annotation={ann}
isSelected={selectedId === ann.id}
onSelect={() => onSelect(ann.id)}
onDelete={() => onDelete(ann.id)}
/>
))
)}
</div>
{/* Quick Share Footer */}
{shareUrl && annotations.length > 0 && (
<div className="p-2 border-t border-border/50">
<button
onClick={handleQuickShare}
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-xs font-medium transition-all text-muted-foreground hover:text-foreground hover:bg-muted/50"
>
{copied ? (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
Copied
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
Quick Share
</>
)}
</button>
</div>
)}
</aside>
);
};
function formatTimestamp(ts: number): string {
const now = Date.now();
const diff = now - ts;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return 'now';
if (minutes < 60) return `${minutes}m`;
if (hours < 24) return `${hours}h`;
if (days < 7) return `${days}d`;
return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
const AnnotationCard: React.FC<{
annotation: Annotation;
isSelected: boolean;
onSelect: () => void;
onDelete: () => void;
}> = ({ annotation, isSelected, onSelect, onDelete }) => {
const typeConfig = {
[AnnotationType.DELETION]: {
label: 'Delete',
color: 'text-destructive',
bg: 'bg-destructive/10',
icon: (
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
)
},
[AnnotationType.INSERTION]: {
label: 'Insert',
color: 'text-secondary',
bg: 'bg-secondary/10',
icon: (
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
)
},
[AnnotationType.REPLACEMENT]: {
label: 'Replace',
color: 'text-primary',
bg: 'bg-primary/10',
icon: (
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
)
},
[AnnotationType.COMMENT]: {
label: 'Comment',
color: 'text-accent',
bg: 'bg-accent/10',
icon: (
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
)
}
};
const config = typeConfig[annotation.type];
return (
<div
onClick={onSelect}
className={`
group relative p-2.5 rounded-lg border cursor-pointer transition-all
${isSelected
? 'bg-primary/5 border-primary/30 shadow-sm'
: 'border-transparent hover:bg-muted/50 hover:border-border/50'
}
`}
>
{/* Author */}
{annotation.author && (
<div className={`flex items-center gap-1.5 text-[10px] font-mono truncate mb-1.5 ${isCurrentUser(annotation.author) ? 'text-muted-foreground/60' : 'text-muted-foreground'}`}>
<svg className="w-3 h-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span className="truncate">{annotation.author}{isCurrentUser(annotation.author) && ' (me)'}</span>
</div>
)}
{/* Type Badge + Timestamp + Delete */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className={`flex items-center gap-1.5 ${config.color}`}>
<span className={`p-1 rounded ${config.bg}`}>
{config.icon}
</span>
<span className="text-[10px] font-semibold uppercase tracking-wide">
{config.label}
</span>
</div>
<span className="text-[10px] text-muted-foreground/50">
{formatTimestamp(annotation.createdA)}
</span>
</div>
<button
onClick={(e) => { e.stopPropagation(); onDelete(); }}
className="opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-all"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Original Text */}
<div className="text-[11px] font-mono text-muted-foreground bg-muted/50 rounded px-2 py-1.5 truncate">
"{annotation.originalText}"
</div>
{/* Comment/Replacement Text */}
{annotation.text && annotation.type !== AnnotationType.DELETION && (
<div className="mt-2 text-xs text-foreground/90 pl-2 border-l-2 border-primary/50">
{annotation.text}
</div>
)}
</div>
);
};
+88
View File
@@ -0,0 +1,88 @@
import React from 'react';
import { Annotation, AnnotationType, Block } from '../types';
interface SidebarProps {
annotations: Annotation[];
blocks: Block[];
onSelect: (id: string) => void;
onDelete: (id: string) => void;
selectedId: string | null;
}
export const AnnotationSidebar: React.FC<SidebarProps> = ({
annotations,
blocks,
onSelect,
onDelete,
selectedId
}) => {
const sortedAnnotations = [...annotations].sort((a, b) => {
const blockA = blocks.findIndex(blk => blk.id === a.blockId);
const blockB = blocks.findIndex(blk => blk.id === b.blockId);
if (blockA !== blockB) return blockA - blockB;
return a.startOffset - b.startOffset;
});
return (
<div className="w-80 border-l border-border/50 bg-card/50 backdrop-blur-sm h-full flex flex-col transition-colors">
<div className="p-4 border-b border-border/50 flex items-center justify-between">
<h2 className="font-semibold text-foreground">Review Changes</h2>
<span className="text-xs bg-muted text-muted-foreground px-2 py-1 rounded-full">
{annotations.length}
</span>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{sortedAnnotations.length === 0 ? (
<div className="text-center text-muted-foreground mt-10 text-sm">
<p>No annotations yet.</p>
<p className="mt-2 text-xs">Select text in the document to add comments or suggest changes.</p>
</div>
) : (
sortedAnnotations.map(ann => (
<div
key={ann.id}
onClick={() => onSelect(ann.id)}
className={`
group relative p-3 rounded-lg border transition-all cursor-pointer
${selectedId === ann.id
? 'bg-accent/20 border-accent shadow-sm'
: 'bg-card border-border hover:border-accent hover:shadow-sm'
}
`}
>
<div className="flex justify-between items-start mb-1">
<span className={`
text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded
${ann.type === AnnotationType.DELETION ? 'bg-destructive/20 text-destructive' :
ann.type === AnnotationType.INSERTION ? 'bg-secondary/20 text-secondary' :
ann.type === AnnotationType.REPLACEMENT ? 'bg-primary/20 text-primary' :
'bg-accent/20 text-accent'}
`}>
{ann.type}
</span>
<button
onClick={(e) => { e.stopPropagation(); onDelete(ann.id); }}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive transition-all p-1 rounded hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
title="Remove Annotation"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div className="text-sm text-muted-foreground mb-2 font-mono bg-muted p-2 rounded-lg text-xs truncate">
"{ann.originalText}"
</div>
{(ann.text && ann.type !== AnnotationType.DELETION) && (
<div className="text-sm text-foreground font-medium pl-3 border-l-2 border-primary">
{ann.type === AnnotationType.REPLACEMENT ? '→ ' : ''}{ann.text}
</div>
)}
</div>
))
)}
</div>
</div>
);
};
+131
View File
@@ -0,0 +1,131 @@
import React, { useState } from 'react';
interface DecisionBarProps {
onApprove: () => Promise<void>;
onDeny: (feedback: string) => Promise<void>;
annotationCount: number;
getFeedback: () => string;
}
export const DecisionBar: React.FC<DecisionBarProps> = ({
onApprove,
onDeny,
annotationCount,
getFeedback
}) => {
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitted, setSubmitted] = useState<'approved' | 'denied' | null>(null);
const handleApprove = async () => {
setIsSubmitting(true);
try {
await onApprove();
setSubmitted('approved');
} catch (error) {
console.error('Failed to approve:', error);
setIsSubmitting(false);
}
};
const handleDeny = async () => {
const feedback = getFeedback();
setIsSubmitting(true);
try {
await onDeny(feedback);
setSubmitted('denied');
} catch (error) {
console.error('Failed to deny:', error);
setIsSubmitting(false);
}
};
if (submitted) {
return (
<div className="fixed inset-0 z-[100] bg-background flex items-center justify-center">
<div className="text-center space-y-6 max-w-md px-8">
{/* Icon */}
<div className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center ${
submitted === 'approved'
? 'bg-green-500/20 text-green-500'
: 'bg-accent/20 text-accent'
}`}>
{submitted === 'approved' ? (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
)}
</div>
{/* Message */}
<div className="space-y-2">
<h2 className="text-xl font-semibold text-foreground">
{submitted === 'approved' ? 'Plan Approved' : 'Feedback Sent'}
</h2>
<p className="text-muted-foreground">
{submitted === 'approved'
? 'Claude will proceed with the implementation.'
: 'Claude will revise the plan based on your annotations.'}
</p>
</div>
{/* Instruction */}
<div className="pt-4 border-t border-border">
<p className="text-sm text-muted-foreground">
Return to your <span className="text-foreground font-medium">Claude Code terminal</span> to continue.
</p>
</div>
</div>
</div>
);
}
return (
<div className="fixed bottom-0 left-0 right-0 p-4 bg-card/95 backdrop-blur-xl border-t border-border z-50">
<div className="max-w-3xl mx-auto flex items-center gap-4">
{/* Status info */}
<div className="flex-1 text-sm text-muted-foreground">
{annotationCount > 0 ? (
<span>{annotationCount} annotation{annotationCount !== 1 ? 's' : ''} to send as feedback</span>
) : (
<span>Review the plan, then approve or request changes</span>
)}
</div>
{/* Action buttons */}
<div className="flex gap-3">
<button
onClick={handleDeny}
disabled={isSubmitting}
className={`
px-4 py-2 rounded-lg text-sm font-medium transition-all
${isSubmitting
? 'opacity-50 cursor-not-allowed bg-muted text-muted-foreground'
: 'bg-accent/15 text-accent hover:bg-accent/25 border border-accent/30'
}
`}
>
{isSubmitting ? 'Sending...' : 'Request Changes'}
</button>
<button
onClick={handleApprove}
disabled={isSubmitting}
className={`
px-4 py-2 rounded-lg text-sm font-medium transition-all
${isSubmitting
? 'opacity-50 cursor-not-allowed bg-muted text-muted-foreground'
: 'bg-green-600 text-white hover:bg-green-500'
}
`}
>
{isSubmitting ? 'Approving...' : 'Approve Plan'}
</button>
</div>
</div>
</div>
);
};
+190
View File
@@ -0,0 +1,190 @@
/**
* Export Modal with tabs for Share and Raw Diff
*
* Share tab (default): Shows shareable URL with copy button
* Raw Diff tab: Shows human-readable diff output with copy/download
*/
import React, { useState } from 'react';
interface ExportModalProps {
isOpen: boolean;
onClose: () => void;
shareUrl: string;
shareUrlSize: string;
diffOutput: string;
annotationCount: number;
taterSprite?: React.ReactNode;
}
type Tab = 'share' | 'diff';
export const ExportModal: React.FC<ExportModalProps> = ({
isOpen,
onClose,
shareUrl,
shareUrlSize,
diffOutput,
annotationCount,
taterSprite,
}) => {
const [activeTab, setActiveTab] = useState<Tab>('share');
const [copied, setCopied] = useState(false);
if (!isOpen) return null;
const handleCopyUrl = async () => {
try {
await navigator.clipboard.writeText(shareUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (e) {
console.error('Failed to copy:', e);
}
};
const handleCopyDiff = async () => {
try {
await navigator.clipboard.writeText(diffOutput);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (e) {
console.error('Failed to copy:', e);
}
};
const handleDownloadDiff = () => {
const blob = new Blob([diffOutput], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'annotations.diff';
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<div
className="bg-card border border-border rounded-xl w-full max-w-2xl flex flex-col max-h-[80vh] shadow-2xl relative"
onClick={e => e.stopPropagation()}
>
{taterSprite}
{/* Header with tabs */}
<div className="p-4 border-b border-border">
<div className="flex justify-between items-center mb-3">
<h3 className="font-semibold text-sm">Export</h3>
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">
{annotationCount} annotation{annotationCount !== 1 ? 's' : ''}
</span>
<button
onClick={onClose}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 bg-muted rounded-lg p-1">
<button
onClick={() => setActiveTab('share')}
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
activeTab === 'share'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
Share
</button>
<button
onClick={() => setActiveTab('diff')}
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
activeTab === 'diff'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
Raw Diff
</button>
</div>
</div>
{/* Tab content */}
<div className="flex-1 overflow-auto p-4">
{activeTab === 'share' ? (
<div className="space-y-4">
<div>
<label className="block text-xs font-medium text-muted-foreground mb-2">
Shareable URL
</label>
<div className="relative group">
<textarea
readOnly
value={shareUrl}
className="w-full h-32 bg-muted rounded-lg p-3 pr-20 text-xs font-mono resize-none focus:outline-none focus:ring-2 focus:ring-accent/50"
onClick={e => (e.target as HTMLTextAreaElement).select()}
/>
<button
onClick={handleCopyUrl}
className="absolute top-2 right-2 px-2 py-1 rounded text-xs font-medium bg-background/80 hover:bg-background border border-border/50 transition-colors flex items-center gap-1"
>
{copied ? (
<>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
Copied
</>
) : (
<>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
Copy
</>
)}
</button>
<div className="absolute bottom-2 right-2 text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{shareUrlSize}
</div>
</div>
</div>
<p className="text-xs text-muted-foreground">
This URL contains the full plan and all annotations. Anyone with this link can view and add to your annotations.
</p>
</div>
) : (
<pre className="bg-muted rounded-lg p-4 text-xs font-mono leading-relaxed overflow-x-auto whitespace-pre-wrap">
{diffOutput}
</pre>
)}
</div>
{/* Footer actions - only show for Raw Diff tab */}
{activeTab === 'diff' && (
<div className="p-4 border-t border-border flex justify-end gap-2">
<button
onClick={handleCopyDiff}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-muted hover:bg-muted/80 transition-colors"
>
{copied ? 'Copied!' : 'Copy'}
</button>
<button
onClick={handleDownloadDiff}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-primary text-primary-foreground hover:opacity-90 transition-opacity"
>
Download .diff
</button>
</div>
)}
</div>
</div>
);
};
+273
View File
@@ -0,0 +1,273 @@
import React from 'react';
import logoImage from '../plannotator.webp';
interface LandingProps {
onEnter: () => void;
}
export const Landing: React.FC<LandingProps> = ({ onEnter }) => {
return (
<div className="min-h-screen bg-background text-foreground">
{/* Nav */}
<nav className="fixed top-0 left-0 right-0 h-12 flex items-center justify-between px-6 bg-background/80 backdrop-blur-sm border-b border-border/30 z-50">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-md bg-primary/20 flex items-center justify-center">
<svg className="w-3.5 h-3.5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<span className="text-sm font-semibold tracking-tight">Plannotator</span>
</div>
<a
href="https://github.com/anthropics/claude-code"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Claude Code Docs
</a>
</nav>
{/* Content wrapper with vertical borders */}
<div className="max-w-4xl mx-auto border-x border-border/30">
{/* Hero */}
<section className="pt-32 pb-20 px-8">
<div className="max-w-2xl">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-primary/10 text-primary text-xs font-medium mb-6">
<span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
For Claude Code
</div>
<div className="flex items-center gap-6 mb-4">
<h1 className="text-4xl md:text-5xl font-bold tracking-tight leading-[1.1]">
Annotate plans.<br />
<span className="text-muted-foreground">Not in the terminal.</span>
</h1>
<img
src={logoImage}
alt="Plannotator logo"
className="w-20 h-20 md:w-24 md:h-24 object-contain shrink-0"
/>
</div>
<p className="text-lg text-muted-foreground mb-8 max-w-lg">
A visual interface for reviewing Claude Code plans. Select text, add feedback, export structured annotations that Claude understands.
</p>
<button
onClick={onEnter}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-primary text-primary-foreground font-medium hover:opacity-90 transition-opacity"
>
Open Editor
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</button>
</div>
</section>
{/* Problem → Solution */}
<section className="py-16 px-8 border-t border-border/30">
<div className="grid md:grid-cols-2 gap-8 max-w-2xl">
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">The Problem</h3>
<p className="text-foreground/90">
Claude Code shows plans in the terminal. You read them, approve or deny, but giving specific feedback means typing everything out. Hard to reference exact sections.
</p>
</div>
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">The Solution</h3>
<p className="text-foreground/90">
Select the exact text you want to change. Mark it for deletion, add a comment, or suggest a replacement. Export as structured feedback Claude can act on.
</p>
</div>
</div>
</section>
{/* Privacy */}
<section className="py-16 px-8 border-t border-border/30">
<div className="flex items-start gap-4 max-w-2xl">
<div className="w-10 h-10 rounded-lg bg-secondary/10 flex items-center justify-center shrink-0">
<svg className="w-5 h-5 text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div>
<h3 className="font-semibold mb-1">Runs locally. Your data stays private.</h3>
<p className="text-muted-foreground text-sm">
No server. No network requests. Plannotator is a lightweight, local HTML page that runs in your browser. Plans never leave your machine.
</p>
</div>
</div>
</section>
{/* Preview */}
<section className="py-16 px-8 border-t border-border/30">
<h2 className="text-xl font-semibold mb-8 text-center">What it looks like</h2>
{/* App Preview */}
<div className="rounded-xl border border-border/50 bg-card/50 overflow-hidden shadow-2xl">
{/* Mock Header */}
<div className="h-10 bg-card/80 border-b border-border/30 flex items-center px-4 gap-2">
<div className="flex gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-destructive/60" />
<div className="w-2.5 h-2.5 rounded-full bg-accent/60" />
<div className="w-2.5 h-2.5 rounded-full bg-secondary/60" />
</div>
<div className="flex-1 flex justify-center">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<div className="w-4 h-4 rounded bg-primary/20" />
<span>Plannotator</span>
</div>
</div>
<div className="w-16" />
</div>
{/* Mock Content */}
<div className="flex h-80">
{/* Document Area */}
<div className="flex-1 p-6 bg-background/50 flex items-start justify-center">
<div className="w-full max-w-sm bg-card rounded-lg border border-border/30 p-5 shadow-lg">
{/* Mock heading */}
<div className="h-3 w-3/4 bg-foreground/80 rounded mb-4" />
{/* Mock paragraph */}
<div className="space-y-2 mb-4">
<div className="h-2 w-full bg-muted-foreground/30 rounded" />
<div className="h-2 w-5/6 bg-muted-foreground/30 rounded" />
<div className="flex items-center gap-1">
<div className="h-2 w-1/4 bg-muted-foreground/30 rounded" />
<div className="h-2 w-1/3 bg-destructive/40 rounded line-through" />
<div className="h-2 w-1/6 bg-muted-foreground/30 rounded" />
</div>
</div>
{/* Mock subheading */}
<div className="h-2.5 w-1/2 bg-foreground/60 rounded mb-3 mt-5" />
{/* Mock list */}
<div className="space-y-2 mb-4">
<div className="flex items-center gap-2">
<div className="w-1 h-1 rounded-full bg-primary/60" />
<div className="h-2 w-4/5 bg-muted-foreground/30 rounded" />
</div>
<div className="flex items-center gap-2">
<div className="w-1 h-1 rounded-full bg-primary/60" />
<div className="h-2 w-2/3 bg-accent/40 rounded border-b border-accent" />
</div>
<div className="flex items-center gap-2">
<div className="w-1 h-1 rounded-full bg-primary/60" />
<div className="h-2 w-3/4 bg-muted-foreground/30 rounded" />
</div>
</div>
{/* Mock code block */}
<div className="bg-muted/50 rounded p-2 space-y-1.5">
<div className="h-1.5 w-1/2 bg-primary/40 rounded" />
<div className="h-1.5 w-2/3 bg-muted-foreground/30 rounded" />
<div className="h-1.5 w-1/3 bg-secondary/40 rounded" />
</div>
</div>
</div>
{/* Mock Sidebar */}
<div className="w-48 border-l border-border/30 bg-card/30 p-3">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-3 flex items-center justify-between">
<span>Annotations</span>
<span className="bg-muted px-1.5 py-0.5 rounded text-[9px]">2</span>
</div>
{/* Mock annotation cards */}
<div className="space-y-2">
<div className="p-2 rounded-md border border-destructive/30 bg-destructive/5">
<div className="flex items-center gap-1 mb-1.5">
<div className="w-3 h-3 rounded bg-destructive/20" />
<span className="text-[9px] font-semibold text-destructive uppercase">Delete</span>
</div>
<div className="h-1.5 w-full bg-muted rounded" />
</div>
<div className="p-2 rounded-md border border-accent/30 bg-accent/5">
<div className="flex items-center gap-1 mb-1.5">
<div className="w-3 h-3 rounded bg-accent/20" />
<span className="text-[9px] font-semibold text-accent uppercase">Comment</span>
</div>
<div className="h-1.5 w-3/4 bg-muted rounded mb-1" />
<div className="h-1.5 w-full bg-accent/20 rounded" />
</div>
</div>
</div>
</div>
</div>
<p className="text-center text-sm text-muted-foreground mt-6">
Select text → annotate → export feedback
</p>
</section>
{/* How it works */}
<section className="py-16 px-8 border-t border-border/30 bg-card/30">
<h2 className="text-xl font-semibold mb-8">How it works</h2>
<div className="space-y-6 max-w-2xl">
<Step num={1} title="Hook into ExitPlanMode">
<code className="text-xs bg-muted px-2 py-1 rounded font-mono">
ExitPlanMode → opens Plannotator with plan content
</code>
</Step>
<Step num={2} title="Annotate visually">
Select text → choose action (delete, comment) → annotations appear in the sidebar
</Step>
<Step num={3} title="Export feedback">
Structured markdown that Claude Code understands. Paste it back, Claude revises the plan.
</Step>
</div>
</section>
{/* Technical */}
<section className="py-16 px-8 border-t border-border/30 bg-card/30">
<h2 className="text-xl font-semibold mb-6">Technical details</h2>
<ul className="space-y-2 text-sm text-muted-foreground max-w-2xl">
<li className="flex items-center gap-2">
<span className="text-primary">•</span>
Single HTML file build — easy to distribute
</li>
<li className="flex items-center gap-2">
<span className="text-primary">•</span>
Uses Claude Code hooks (PreToolUse/PostToolUse)
</li>
<li className="flex items-center gap-2">
<span className="text-primary">•</span>
Plan content injected at runtime via shell script
</li>
<li className="flex items-center gap-2">
<span className="text-primary">•</span>
React + Tailwind + Vite — hackable
</li>
</ul>
</section>
{/* Footer */}
<footer className="py-8 px-8 border-t border-border/30 text-center">
<p className="text-xs text-muted-foreground">
Built for Claude Code users who want better plan reviews.
</p>
</footer>
</div>
</div>
);
};
const Step: React.FC<{ num: number; title: string; children: React.ReactNode }> = ({ num, title, children }) => (
<div className="flex gap-4">
<div className="w-7 h-7 rounded-full bg-primary/10 text-primary text-sm font-semibold flex items-center justify-center shrink-0">
{num}
</div>
<div>
<h4 className="font-medium mb-1">{title}</h4>
<p className="text-sm text-muted-foreground">{children}</p>
</div>
</div>
);
+112
View File
@@ -0,0 +1,112 @@
import React, { useState } from 'react';
import { EditorMode } from '../types';
import { TaterSpritePullup } from './TaterSpritePullup';
interface ModeSwitcherProps {
mode: EditorMode;
onChange: (mode: EditorMode) => void;
taterMode?: boolean;
}
export const ModeSwitcher: React.FC<ModeSwitcherProps> = ({ mode, onChange, taterMode }) => {
const [showHelp, setShowHelp] = useState(false);
return (
<>
<div className="inline-flex items-center bg-muted/50 rounded-lg p-0.5 border border-border/30">
<ModeButton
active={mode === 'selection'}
onClick={() => onChange('selection')}
icon={
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M12 20h-1a2 2 0 0 1-2-2 2 2 0 0 1-2 2H6"/>
<path d="M13 8h7a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-7"/>
<path d="M5 16H4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h1"/>
<path d="M6 4h1a2 2 0 0 1 2 2 2 2 0 0 1 2-2h1"/>
<path d="M9 6v12"/>
</svg>
}
label="Selection"
/>
<ModeButton
active={mode === 'redline'}
onClick={() => onChange('redline')}
icon={
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9.75L14.25 12m0 0l2.25 2.25M14.25 12l2.25-2.25M14.25 12L12 14.25m-2.58 4.92l-6.375-6.375a1.125 1.125 0 010-1.59L9.42 4.83c.211-.211.498-.33.796-.33H19.5a2.25 2.25 0 012.25 2.25v10.5a2.25 2.25 0 01-2.25 2.25h-9.284c-.298 0-.585-.119-.796-.33z" />
</svg>
}
label="Redline"
destructive
/>
</div>
{/* Help link */}
<button
onClick={() => setShowHelp(true)}
className="ml-2 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
>
how does this work?
</button>
{/* Help Video Dialog */}
{showHelp && (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/80 backdrop-blur-sm p-4"
onClick={() => setShowHelp(false)}
>
<div
className="bg-card border border-border rounded-xl w-full max-w-2xl shadow-2xl overflow-hidden relative"
onClick={e => e.stopPropagation()}
>
{taterMode && <TaterSpritePullup />}
<div className="flex items-center justify-between p-4 border-b border-border">
<h3 className="font-semibold text-sm">How Plannotator Works</h3>
<button
onClick={() => setShowHelp(false)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="aspect-video">
<iframe
width="100%"
height="100%"
src="https://www.youtube.com/embed/dQw4w9WgXcQ?autoplay=1"
title="How Plannotator Works"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
</div>
</div>
)}
</>
);
};
const ModeButton: React.FC<{
active: boolean;
onClick: () => void;
icon: React.ReactNode;
label: string;
destructive?: boolean;
}> = ({ active, onClick, icon, label, destructive }) => (
<button
onClick={onClick}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all ${
active
? destructive
? 'bg-destructive/15 text-destructive shadow-sm'
: 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{icon}
{label}
</button>
);
+68
View File
@@ -0,0 +1,68 @@
import React, { useState, useRef, useEffect } from 'react';
import { useTheme } from './ThemeProvider';
export function ModeToggle() {
const { theme, setTheme } = useTheme();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title="Toggle theme"
>
{/* Sun */}
<svg
className="w-4 h-4 hidden light:block"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
</svg>
{/* Moon */}
<svg
className="w-4 h-4 light:hidden"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
</button>
{isOpen && (
<div className="absolute right-0 mt-1 w-32 rounded-lg border border-border bg-popover shadow-xl z-50 overflow-hidden py-1">
{(['light', 'dark', 'system'] as const).map((t) => (
<button
key={t}
onClick={() => { setTheme(t); setIsOpen(false); }}
className={`w-full px-3 py-1.5 text-left text-xs capitalize transition-colors ${
theme === t
? 'text-primary bg-primary/10 font-medium'
: 'text-popover-foreground hover:bg-muted'
}`}
>
{t}
</button>
))}
</div>
)}
</div>
);
}
+117
View File
@@ -0,0 +1,117 @@
import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { TaterSpritePullup } from './TaterSpritePullup';
import { getIdentity, regenerateIdentity } from '../utils/identity';
interface SettingsProps {
taterMode: boolean;
onTaterModeChange: (enabled: boolean) => void;
onIdentityChange?: (oldIdentity: string, newIdentity: string) => void;
}
export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange, onIdentityChange }) => {
const [showDialog, setShowDialog] = useState(false);
const [identity, setIdentity] = useState('');
useEffect(() => {
if (showDialog) {
setIdentity(getIdentity());
}
}, [showDialog]);
const handleRegenerateIdentity = () => {
const oldIdentity = identity;
const newIdentity = regenerateIdentity();
setIdentity(newIdentity);
onIdentityChange?.(oldIdentity, newIdentity);
};
return (
<>
<button
onClick={() => setShowDialog(true)}
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title="Settings"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
{showDialog && createPortal(
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/80 backdrop-blur-sm p-4"
onClick={() => setShowDialog(false)}
>
<div
className="bg-card border border-border rounded-xl w-full max-w-sm shadow-2xl relative"
onClick={e => e.stopPropagation()}
>
{taterMode && <TaterSpritePullup />}
<div className="flex items-center justify-between p-4 border-b border-border">
<h3 className="font-semibold text-sm">Settings</h3>
<button
onClick={() => setShowDialog(false)}
className="p-1.5 rounded-md bg-muted hover:bg-muted/80 text-foreground transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-4 space-y-4">
{/* Identity Section */}
<div className="space-y-2">
<div className="text-sm font-medium">Your Identity</div>
<div className="text-xs text-muted-foreground">
Used when sharing annotations with others
</div>
<div className="flex items-center gap-2">
<div className="flex-1 px-3 py-2 bg-muted rounded-lg text-xs font-mono truncate">
{identity}
</div>
<button
onClick={handleRegenerateIdentity}
className="p-2 rounded-lg bg-muted hover:bg-muted/80 text-muted-foreground hover:text-foreground transition-colors"
title="Regenerate identity"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</div>
<div className="border-t border-border" />
{/* Tater Mode */}
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">Tater Mode</div>
<div className="text-xs text-muted-foreground">Show animated sprites</div>
</div>
<button
role="switch"
aria-checked={taterMode}
onClick={() => onTaterModeChange(!taterMode)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
taterMode ? 'bg-primary' : 'bg-muted'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
taterMode ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
</div>
</div>,
document.body
)}
</>
);
};
+33
View File
@@ -0,0 +1,33 @@
import React from 'react';
import spriteSheet from '../sprite_package_pulluphang/sprite.png';
// Sprite specs: 96x96 native, 24 frames, 3 seconds
const NATIVE_SIZE = 96;
const DISPLAY_SIZE = 56;
const FRAMES = 24;
const SCALE = DISPLAY_SIZE / NATIVE_SIZE;
const TOTAL_WIDTH = NATIVE_SIZE * FRAMES * SCALE;
export const TaterSpritePullup: React.FC = () => {
return (
<div
className="absolute pointer-events-none hidden lg:block"
style={{
top: 56, // Hangs from bottom of dialog header
left: 8,
width: DISPLAY_SIZE,
height: DISPLAY_SIZE,
backgroundImage: `url(${spriteSheet})`,
backgroundSize: `${TOTAL_WIDTH}px ${DISPLAY_SIZE}px`,
animation: 'tater-pullup 3.5s steps(24) infinite',
imageRendering: 'pixelated',
}}
>
<style>{`
@keyframes tater-pullup {
to { background-position: -${TOTAL_WIDTH}px 0; }
}
`}</style>
</div>
);
};
+53
View File
@@ -0,0 +1,53 @@
import React from 'react';
import spriteSheet from '../sprite_package_additional/sprite.png';
// Sprite: 176x96 native, 24 frames, 5 second animation cycle
// Running across screen should sync with animation speed
const NATIVE_WIDTH = 176;
const NATIVE_HEIGHT = 96;
const DISPLAY_HEIGHT = 64;
const SCALE = DISPLAY_HEIGHT / NATIVE_HEIGHT;
const DISPLAY_WIDTH = NATIVE_WIDTH * SCALE; // ~117px
const FRAMES = 24;
const FRAME_DURATION = 5; // seconds for full run cycle
const TOTAL_SPRITE_WIDTH = NATIVE_WIDTH * FRAMES * SCALE;
// Screen traverse: character runs across in ~12 seconds
// This gives ~2.4 run cycles per screen width, feels natural
const SCREEN_TRAVERSE_TIME = 18;
export const TaterSpriteRunning: React.FC = () => {
return (
<div
className="fixed pointer-events-none hidden lg:block"
style={{
bottom: 0,
right: -DISPLAY_WIDTH, // Start off-screen right
width: DISPLAY_WIDTH,
height: DISPLAY_HEIGHT,
zIndex: 40, // Below plan document (z-50) but above other elements
backgroundImage: `url(${spriteSheet})`,
backgroundSize: `${TOTAL_SPRITE_WIDTH}px ${DISPLAY_HEIGHT}px`,
backgroundPosition: 'left center',
imageRendering: 'pixelated',
animation: `tater-run-sprite ${FRAME_DURATION}s steps(${FRAMES}) infinite, tater-run-across ${SCREEN_TRAVERSE_TIME}s linear infinite`,
}}
>
<style>{`
@keyframes tater-run-sprite {
to {
background-position: -${TOTAL_SPRITE_WIDTH}px 0;
}
}
@keyframes tater-run-across {
from {
right: -${DISPLAY_WIDTH}px;
}
to {
right: 100vw;
}
}
`}</style>
</div>
);
};
+36
View File
@@ -0,0 +1,36 @@
import React from 'react';
import spriteSheet from '../sprite_package_new/sprite.png';
// Sprite: 96x96 native, 12 frames, scaled to 64x64 for display
const NATIVE_SIZE = 96;
const DISPLAY_SIZE = 64;
const FRAMES = 12;
const SCALE = DISPLAY_SIZE / NATIVE_SIZE;
const TOTAL_WIDTH = NATIVE_SIZE * FRAMES * SCALE; // 768px at 64px scale
export const TaterSpriteSitting: React.FC = () => {
return (
<div
className="hidden lg:block absolute pointer-events-none"
style={{
top: -40,
right: -4,
width: DISPLAY_SIZE,
height: DISPLAY_SIZE,
backgroundImage: `url(${spriteSheet})`,
backgroundSize: `${TOTAL_WIDTH}px ${DISPLAY_SIZE}px`,
backgroundPosition: 'left center',
animation: 'tater-sit 3s steps(12) infinite',
imageRendering: 'pixelated',
}}
>
<style>{`
@keyframes tater-sit {
to {
background-position: -${TOTAL_WIDTH}px 0;
}
}
`}</style>
</div>
);
};
+87
View File
@@ -0,0 +1,87 @@
import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'dark' | 'light' | 'system';
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
const ThemeProviderContext = createContext<ThemeProviderState>({
theme: 'dark',
setTheme: () => null,
});
interface ThemeProviderProps {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
}
export function ThemeProvider({
children,
defaultTheme = 'dark',
storageKey = 'plannotator-theme',
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
);
useEffect(() => {
const root = window.document.documentElement;
// Remove light class (dark is default, no class needed)
root.classList.remove('light');
let effectiveTheme = theme;
if (theme === 'system') {
effectiveTheme = window.matchMedia('(prefers-color-scheme: light)').matches
? 'light'
: 'dark';
}
// Only add 'light' class when in light mode
if (effectiveTheme === 'light') {
root.classList.add('light');
}
}, [theme]);
// Listen for system theme changes
useEffect(() => {
if (theme !== 'system') return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: light)');
const handleChange = () => {
const root = window.document.documentElement;
root.classList.remove('light');
if (mediaQuery.matches) {
root.classList.add('light');
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme]);
const value = {
theme,
setTheme: (newTheme: Theme) => {
localStorage.setItem(storageKey, newTheme);
setTheme(newTheme);
},
};
return (
<ThemeProviderContext.Provider value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};
+168
View File
@@ -0,0 +1,168 @@
import React, { useState, useEffect, useRef } from 'react';
import { AnnotationType } from '../types';
import { createPortal } from 'react-dom';
interface ToolbarProps {
highlightElement: HTMLElement | null;
onAnnotate: (type: AnnotationType, text?: string) => void;
onClose: () => void;
}
export const Toolbar: React.FC<ToolbarProps> = ({ highlightElement, onAnnotate, onClose }) => {
const [step, setStep] = useState<'menu' | 'input'>('menu');
const [activeType, setActiveType] = useState<AnnotationType | null>(null);
const [inputValue, setInputValue] = useState('');
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (step === 'input') inputRef.current?.focus();
}, [step]);
useEffect(() => {
setStep('menu');
setActiveType(null);
setInputValue('');
}, [highlightElement]);
// Update position on scroll/resize
useEffect(() => {
if (!highlightElement) {
setPosition(null);
return;
}
const updatePosition = () => {
const rect = highlightElement.getBoundingClientRect();
const toolbarTop = rect.top - 48;
// If selection scrolled out of viewport, close the toolbar
if (rect.bottom < 0 || rect.top > window.innerHeight) {
onClose();
return;
}
setPosition({
top: toolbarTop,
left: rect.left + rect.width / 2,
});
};
updatePosition();
window.addEventListener('scroll', updatePosition, true);
window.addEventListener('resize', updatePosition);
return () => {
window.removeEventListener('scroll', updatePosition, true);
window.removeEventListener('resize', updatePosition);
};
}, [highlightElement, onClose]);
if (!highlightElement || !position) return null;
const { top, left } = position;
const handleTypeSelect = (type: AnnotationType) => {
if (type === AnnotationType.DELETION) {
onAnnotate(type);
} else {
setActiveType(type);
setStep('input');
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (activeType && inputValue.trim()) {
onAnnotate(activeType, inputValue);
}
};
return createPortal(
<div
className="annotation-toolbar fixed z-[100] bg-popover border border-border rounded-lg shadow-2xl transform -translate-x-1/2 animate-in fade-in slide-in-from-bottom-2 duration-150"
style={{ top, left }}
onMouseDown={e => e.stopPropagation()}
>
{step === 'menu' ? (
<div className="flex items-center p-1 gap-0.5">
<ToolbarButton
onClick={() => handleTypeSelect(AnnotationType.DELETION)}
icon={
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
}
label="Delete"
className="text-destructive hover:bg-destructive/10"
/>
<ToolbarButton
onClick={() => handleTypeSelect(AnnotationType.COMMENT)}
icon={
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
}
label="Comment"
className="text-accent hover:bg-accent/10"
/>
<div className="w-px h-5 bg-border mx-0.5" />
<ToolbarButton
onClick={onClose}
icon={
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
}
label="Cancel"
className="text-muted-foreground hover:bg-muted"
/>
</div>
) : (
<form onSubmit={handleSubmit} className="flex items-center gap-1.5 p-1.5 pl-3">
<input
ref={inputRef}
type="text"
className="bg-transparent border-none outline-none text-sm w-44 placeholder:text-muted-foreground"
placeholder="Add a comment..."
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onKeyDown={e => e.key === 'Escape' && setStep('menu')}
/>
<button
type="submit"
disabled={!inputValue.trim()}
className="px-2 py-1 text-xs font-medium rounded bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-50 transition-opacity"
>
Save
</button>
<button
type="button"
onClick={() => setStep('menu')}
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</form>
)}
</div>,
document.body
);
};
const ToolbarButton: React.FC<{
onClick: () => void;
icon: React.ReactNode;
label: string;
className: string;
}> = ({ onClick, icon, label, className }) => (
<button
onClick={onClick}
title={label}
className={`p-1.5 rounded-md transition-colors ${className}`}
>
{icon}
</button>
);
+747
View File
@@ -0,0 +1,747 @@
import React, { useRef, useState, useEffect, forwardRef, useImperativeHandle, useCallback } from 'react';
import { createPortal } from 'react-dom';
import Highlighter from 'web-highlighter';
import { Block, Annotation, AnnotationType, EditorMode } from '../types';
import { Toolbar } from './Toolbar';
import { TaterSpriteSitting } from './TaterSpriteSitting';
import { getIdentity } from '../utils/identity';
declare const hljs: { highlightAll: () => void };
interface ViewerProps {
blocks: Block[];
annotations: Annotation[];
onAddAnnotation: (ann: Annotation) => void;
onSelectAnnotation: (id: string | null) => void;
selectedAnnotationId: string | null;
mode: EditorMode;
taterMode: boolean;
}
export interface ViewerHandle {
removeHighlight: (id: string) => void;
clearAllHighlights: () => void;
applySharedAnnotations: (annotations: Annotation[]) => void;
}
export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
blocks,
annotations,
onAddAnnotation,
onSelectAnnotation,
selectedAnnotationId,
mode,
taterMode
}, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const highlighterRef = useRef<Highlighter | null>(null);
const modeRef = useRef<EditorMode>(mode);
const onAddAnnotationRef = useRef(onAddAnnotation);
const pendingSourceRef = useRef<any>(null);
const [toolbarState, setToolbarState] = useState<{ element: HTMLElement; source: any } | null>(null);
const [hoveredCodeBlock, setHoveredCodeBlock] = useState<{ block: Block; element: HTMLElement } | null>(null);
const [isCodeBlockToolbarExiting, setIsCodeBlockToolbarExiting] = useState(false);
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Keep refs in sync with props
useEffect(() => {
modeRef.current = mode;
}, [mode]);
useEffect(() => {
onAddAnnotationRef.current = onAddAnnotation;
}, [onAddAnnotation]);
// Helper to create annotation from highlighter source
const createAnnotationFromSource = (
highlighter: Highlighter,
source: any,
type: AnnotationType,
text?: string
) => {
const doms = highlighter.getDoms(source.id);
let blockId = '';
let startOffset = 0;
if (doms?.length > 0) {
const el = doms[0] as HTMLElement;
let parent = el.parentElement;
while (parent && !parent.dataset.blockId) {
parent = parent.parentElement;
}
if (parent?.dataset.blockId) {
blockId = parent.dataset.blockId;
const blockText = parent.textContent || '';
const beforeText = blockText.split(source.text)[0];
startOffset = beforeText?.length || 0;
}
}
const newAnnotation: Annotation = {
id: source.id,
blockId,
startOffset,
endOffset: startOffset + source.text.length,
type,
text,
originalText: source.text,
createdA: Date.now(),
author: getIdentity(),
startMeta: source.startMeta,
endMeta: source.endMeta,
};
if (type === AnnotationType.DELETION) {
highlighter.addClass('deletion', source.id);
} else if (type === AnnotationType.COMMENT) {
highlighter.addClass('comment', source.id);
}
onAddAnnotationRef.current(newAnnotation);
};
// Helper to find text in DOM and create a range
const findTextInDOM = useCallback((searchText: string): Range | null => {
if (!containerRef.current) return null;
const walker = document.createTreeWalker(
containerRef.current,
NodeFilter.SHOW_TEXT,
null
);
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
const text = node.textContent || '';
const index = text.indexOf(searchText);
if (index !== -1) {
const range = document.createRange();
range.setStart(node, index);
range.setEnd(node, index + searchText.length);
return range;
}
}
// Try across multiple text nodes for multi-line content
const fullText = containerRef.current.textContent || '';
const searchIndex = fullText.indexOf(searchText);
if (searchIndex === -1) return null;
// Use Selection API to find and select the text
const selection = window.getSelection();
if (!selection) return null;
// Reset walker
const walker2 = document.createTreeWalker(
containerRef.current,
NodeFilter.SHOW_TEXT,
null
);
let charCount = 0;
let startNode: Text | null = null;
let startOffset = 0;
let endNode: Text | null = null;
let endOffset = 0;
while ((node = walker2.nextNode() as Text | null)) {
const nodeLength = node.textContent?.length || 0;
if (!startNode && charCount + nodeLength > searchIndex) {
startNode = node;
startOffset = searchIndex - charCount;
}
if (startNode && charCount + nodeLength >= searchIndex + searchText.length) {
endNode = node;
endOffset = searchIndex + searchText.length - charCount;
break;
}
charCount += nodeLength;
}
if (startNode && endNode) {
const range = document.createRange();
range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);
return range;
}
return null;
}, []);
useImperativeHandle(ref, () => ({
removeHighlight: (id: string) => {
// Try highlighter first (for regular text selections)
highlighterRef.current?.remove(id);
// Also handle manually created highlights (for code blocks)
const manualHighlight = containerRef.current?.querySelector(`[data-bind-id="${id}"]`);
if (manualHighlight) {
// Unwrap the mark element, preserving its contents
const parent = manualHighlight.parentNode;
while (manualHighlight.firstChild) {
parent?.insertBefore(manualHighlight.firstChild, manualHighlight);
}
manualHighlight.remove();
}
},
clearAllHighlights: () => {
// Clear all manual highlights (shared annotations and code blocks)
const manualHighlights = containerRef.current?.querySelectorAll('[data-bind-id]');
manualHighlights?.forEach(el => {
const parent = el.parentNode;
while (el.firstChild) {
parent?.insertBefore(el.firstChild, el);
}
el.remove();
});
// Clear web-highlighter highlights
const webHighlights = containerRef.current?.querySelectorAll('.annotation-highlight');
webHighlights?.forEach(el => {
const parent = el.parentNode;
while (el.firstChild) {
parent?.insertBefore(el.firstChild, el);
}
el.remove();
});
},
applySharedAnnotations: (sharedAnnotations: Annotation[]) => {
const highlighter = highlighterRef.current;
if (!highlighter || !containerRef.current) return;
sharedAnnotations.forEach(ann => {
// Skip if already highlighted
const existingDoms = highlighter.getDoms(ann.id);
if (existingDoms && existingDoms.length > 0) return;
// Also skip if manually highlighted
const existingManual = containerRef.current?.querySelector(`[data-bind-id="${ann.id}"]`);
if (existingManual) return;
// Find the text in the DOM
const range = findTextInDOM(ann.originalText);
if (!range) {
console.warn(`Could not find text for annotation ${ann.id}: "${ann.originalText.slice(0, 50)}..."`);
return;
}
try {
// Create a unique ID for this restored highlight
const newId = ann.id;
// Wrap the range with a mark element
const wrapper = document.createElement('mark');
wrapper.className = 'annotation-highlight';
wrapper.dataset.bindId = newId;
// Add type-specific class
if (ann.type === AnnotationType.DELETION) {
wrapper.classList.add('deletion');
} else if (ann.type === AnnotationType.COMMENT) {
wrapper.classList.add('comment');
}
// surroundContents can fail if range spans multiple elements
// In that case, we need to use extractContents + appendChild
try {
range.surroundContents(wrapper);
} catch (e) {
// Range spans multiple elements - extract and wrap
const fragment = range.extractContents();
wrapper.appendChild(fragment);
range.insertNode(wrapper);
}
// Make it clickable
wrapper.addEventListener('click', () => {
onSelectAnnotation(ann.id);
});
} catch (e) {
console.warn(`Failed to apply highlight for annotation ${ann.id}:`, e);
}
});
}
}), [findTextInDOM, onSelectAnnotation]);
useEffect(() => {
if (!containerRef.current) return;
const highlighter = new Highlighter({
$root: containerRef.current,
exceptSelectors: ['.annotation-toolbar', 'button'],
wrapTag: 'mark',
style: { className: 'annotation-highlight' }
});
highlighterRef.current = highlighter;
highlighter.on(Highlighter.event.CREATE, ({ sources }: { sources: any[] }) => {
if (sources.length > 0) {
const source = sources[0];
const doms = highlighter.getDoms(source.id);
if (doms?.length > 0) {
// Clean up previous pending highlight if exists
if (pendingSourceRef.current) {
highlighter.remove(pendingSourceRef.current.id);
pendingSourceRef.current = null;
}
if (modeRef.current === 'redline') {
// Auto-delete in redline mode
createAnnotationFromSource(highlighter, source, AnnotationType.DELETION);
window.getSelection()?.removeAllRanges();
} else {
// Show toolbar in selection mode
pendingSourceRef.current = source;
setToolbarState({ element: doms[0] as HTMLElement, source });
}
}
}
});
highlighter.on(Highlighter.event.CLICK, ({ id }: { id: string }) => {
onSelectAnnotation(id);
});
highlighter.run();
return () => highlighter.dispose();
}, [onSelectAnnotation]);
useEffect(() => {
if (typeof hljs !== 'undefined') {
hljs.highlightAll();
}
}, [blocks]);
useEffect(() => {
const highlighter = highlighterRef.current;
if (!highlighter) return;
annotations.forEach(ann => {
try {
const doms = highlighter.getDoms(ann.id);
if (doms?.length > 0) {
if (ann.type === AnnotationType.DELETION) {
highlighter.addClass('deletion', ann.id);
} else if (ann.type === AnnotationType.COMMENT) {
highlighter.addClass('comment', ann.id);
}
}
} catch (e) {}
});
}, [annotations]);
const handleAnnotate = (type: AnnotationType, text?: string) => {
const highlighter = highlighterRef.current;
if (!toolbarState || !highlighter) return;
createAnnotationFromSource(highlighter, toolbarState.source, type, text);
pendingSourceRef.current = null;
setToolbarState(null);
window.getSelection()?.removeAllRanges();
};
const handleToolbarClose = () => {
if (toolbarState && highlighterRef.current) {
highlighterRef.current.remove(toolbarState.source.id);
}
pendingSourceRef.current = null;
setToolbarState(null);
window.getSelection()?.removeAllRanges();
};
const handleCodeBlockAnnotate = (type: AnnotationType, text?: string) => {
const highlighter = highlighterRef.current;
if (!hoveredCodeBlock || !highlighter) return;
// Find the code element inside the pre
const codeEl = hoveredCodeBlock.element.querySelector('code');
if (!codeEl) return;
// Create a range that selects all content in the code block
const range = document.createRange();
range.selectNodeContents(codeEl);
// Set the browser selection to this range
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
// Use highlighter.fromRange which triggers CREATE event internally
// We need to handle this synchronously, so we'll create the annotation directly
const id = `codeblock-${Date.now()}`;
const codeText = codeEl.textContent || '';
// Wrap the content manually
const wrapper = document.createElement('mark');
wrapper.className = 'annotation-highlight';
wrapper.dataset.bindId = id;
// Extract and wrap content
range.surroundContents(wrapper);
// Add the appropriate class
if (type === AnnotationType.DELETION) {
wrapper.classList.add('deletion');
} else if (type === AnnotationType.COMMENT) {
wrapper.classList.add('comment');
}
// Create the annotation
const newAnnotation: Annotation = {
id,
blockId: hoveredCodeBlock.block.id,
startOffset: 0,
endOffset: codeText.length,
type,
text,
originalText: codeText,
createdA: Date.now(),
author: getIdentity(),
};
onAddAnnotationRef.current(newAnnotation);
// Clear selection
selection?.removeAllRanges();
setHoveredCodeBlock(null);
};
const handleCodeBlockToolbarClose = () => {
setHoveredCodeBlock(null);
};
return (
<div className="relative z-50">
{taterMode && <TaterSpriteSitting />}
<article
ref={containerRef}
className="w-full max-w-3xl bg-card border border-border/50 rounded-xl shadow-xl p-5 md:p-10 lg:p-14"
>
{blocks.map(block => (
block.type === 'code' ? (
<CodeBlock
key={block.id}
block={block}
onHover={(element) => {
// Clear any pending leave timeout
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
// Cancel exit animation if re-entering
setIsCodeBlockToolbarExiting(false);
// Only show hover toolbar if no selection toolbar is active
if (!toolbarState) {
setHoveredCodeBlock({ block, element });
}
}}
onLeave={() => {
// Delay then start exit animation
hoverTimeoutRef.current = setTimeout(() => {
setIsCodeBlockToolbarExiting(true);
// After exit animation, unmount
setTimeout(() => {
setHoveredCodeBlock(null);
setIsCodeBlockToolbarExiting(false);
}, 150);
}, 100);
}}
isHovered={hoveredCodeBlock?.block.id === block.id}
/>
) : (
<BlockRenderer key={block.id} block={block} />
)
))}
<Toolbar
highlightElement={toolbarState?.element ?? null}
onAnnotate={handleAnnotate}
onClose={handleToolbarClose}
/>
{/* Code block hover toolbar */}
{hoveredCodeBlock && !toolbarState && (
<CodeBlockToolbar
element={hoveredCodeBlock.element}
onAnnotate={handleCodeBlockAnnotate}
onClose={handleCodeBlockToolbarClose}
isExiting={isCodeBlockToolbarExiting}
onMouseEnter={() => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
setIsCodeBlockToolbarExiting(false);
}}
onMouseLeave={() => {
hoverTimeoutRef.current = setTimeout(() => {
setIsCodeBlockToolbarExiting(true);
setTimeout(() => {
setHoveredCodeBlock(null);
setIsCodeBlockToolbarExiting(false);
}, 150);
}, 100);
}}
/>
)}
</article>
</div>
);
});
const BlockRenderer: React.FC<{ block: Block }> = ({ block }) => {
switch (block.type) {
case 'heading':
const Tag = `h${block.level || 1}` as keyof JSX.IntrinsicElements;
const styles = {
1: 'text-2xl font-bold mb-4 mt-6 first:mt-0 tracking-tight',
2: 'text-xl font-semibold mb-3 mt-8 text-foreground/90',
3: 'text-base font-semibold mb-2 mt-6 text-foreground/80',
}[block.level || 1] || 'text-base font-semibold mb-2 mt-4';
return <Tag className={styles} data-block-id={block.id}>{block.content}</Tag>;
case 'blockquote':
return (
<blockquote
className="border-l-2 border-primary/50 pl-4 my-4 text-muted-foreground italic"
data-block-id={block.id}
>
{block.content}
</blockquote>
);
case 'list-item':
return (
<div className="flex gap-3 my-1.5" data-block-id={block.id}>
<span className="text-primary/60 select-none">•</span>
<span className="text-foreground/90 text-sm leading-relaxed">{block.content}</span>
</div>
);
case 'code':
return <CodeBlock block={block} />;
case 'hr':
return <hr className="border-border/30 my-8" data-block-id={block.id} />;
default:
return (
<p
className="mb-4 leading-relaxed text-foreground/90 text-[15px]"
data-block-id={block.id}
>
{block.content}
</p>
);
}
};
interface CodeBlockProps {
block: Block;
onHover: (element: HTMLElement) => void;
onLeave: () => void;
isHovered: boolean;
}
const CodeBlock: React.FC<CodeBlockProps> = ({ block, onHover, onLeave, isHovered }) => {
const [copied, setCopied] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(block.content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
}, [block.content]);
const handleMouseEnter = () => {
if (containerRef.current) {
onHover(containerRef.current);
}
};
return (
<div
ref={containerRef}
className="relative group my-5"
data-block-id={block.id}
onMouseEnter={handleMouseEnter}
onMouseLeave={onLeave}
>
<button
onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 rounded-md bg-muted/80 hover:bg-muted text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity z-10"
title={copied ? 'Copied!' : 'Copy code'}
>
{copied ? (
<svg className="w-4 h-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
<pre className="rounded-lg text-[13px] overflow-x-auto bg-muted/50 border border-border/30">
<code className="hljs font-mono">{block.content}</code>
</pre>
</div>
);
};
const CodeBlockToolbar: React.FC<{
element: HTMLElement;
onAnnotate: (type: AnnotationType, text?: string) => void;
onClose: () => void;
isExiting: boolean;
onMouseEnter: () => void;
onMouseLeave: () => void;
}> = ({ element, onAnnotate, onClose, isExiting, onMouseEnter, onMouseLeave }) => {
const [step, setStep] = useState<'menu' | 'input'>('menu');
const [inputValue, setInputValue] = useState('');
const [position, setPosition] = useState<{ top: number; right: number }>({ top: 0, right: 0 });
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (step === 'input') inputRef.current?.focus();
}, [step]);
// Update position on scroll/resize
useEffect(() => {
const updatePosition = () => {
const rect = element.getBoundingClientRect();
setPosition({
top: rect.top - 40,
right: window.innerWidth - rect.right,
});
};
updatePosition();
window.addEventListener('scroll', updatePosition, true);
window.addEventListener('resize', updatePosition);
return () => {
window.removeEventListener('scroll', updatePosition, true);
window.removeEventListener('resize', updatePosition);
};
}, [element]);
const { top, right } = position;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (inputValue.trim()) {
onAnnotate(AnnotationType.COMMENT, inputValue);
}
};
return createPortal(
<div
className="annotation-toolbar fixed z-[100] bg-popover border border-border rounded-lg shadow-2xl"
style={{
top,
right,
animation: isExiting ? 'code-toolbar-out 0.15s ease-in forwards' : 'code-toolbar-in 0.2s ease-out',
}}
onMouseDown={e => e.stopPropagation()}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<style>{`
@keyframes code-toolbar-in {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes code-toolbar-out {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(8px);
}
}
`}</style>
{step === 'menu' ? (
<div className="flex items-center p-1 gap-0.5">
<button
onClick={() => onAnnotate(AnnotationType.DELETION)}
title="Delete"
className="p-1.5 rounded-md transition-colors text-destructive hover:bg-destructive/10"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
<button
onClick={() => setStep('input')}
title="Comment"
className="p-1.5 rounded-md transition-colors text-accent hover:bg-accent/10"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
</button>
<div className="w-px h-5 bg-border mx-0.5" />
<button
onClick={onClose}
title="Cancel"
className="p-1.5 rounded-md transition-colors text-muted-foreground hover:bg-muted"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
) : (
<form onSubmit={handleSubmit} className="flex items-center gap-1.5 p-1.5 pl-3">
<input
ref={inputRef}
type="text"
className="bg-transparent border-none outline-none text-sm w-44 placeholder:text-muted-foreground"
placeholder="Add a comment..."
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onKeyDown={e => e.key === 'Escape' && setStep('menu')}
/>
<button
type="submit"
disabled={!inputValue.trim()}
className="px-2 py-1 text-xs font-medium rounded bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-50 transition-opacity"
>
Save
</button>
<button
type="button"
onClick={() => setStep('menu')}
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</form>
)}
</div>,
document.body
);
};
+39
View File
@@ -0,0 +1,39 @@
#!/bin/bash
#
# ExitPlanMode Hook - Launches Plannotator for plan approval/denial
#
# This hook intercepts ExitPlanMode, spawns a Bun server with the plan content,
# and blocks until the user approves or denies (with feedback).
#
# Exit codes:
# 0 = User approved → ExitPlanMode proceeds
# 2 = User denied → feedback sent to Claude via stderr
#
# Get the directory where this hook lives
HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
PLANNING_HOOK_DIR="$(dirname "$HOOK_DIR")"
SERVER_SCRIPT="$PLANNING_HOOK_DIR/server/index.ts"
# Read the event from stdin
EVENT=$(cat)
# Check if this is ExitPlanMode
TOOL_NAME=$(echo "$EVENT" | jq -r '.tool_name // empty')
if [ "$TOOL_NAME" = "ExitPlanMode" ]; then
# Extract plan content
PLAN=$(echo "$EVENT" | jq -r '.tool_input.plan // empty')
if [ -n "$PLAN" ] && [ "$PLAN" != "null" ]; then
# Launch Bun server with plan content
# Server blocks until user makes a decision (approve/deny)
# Server exits 0 for approve, 2 for deny (with feedback to stderr)
cd "$PLANNING_HOOK_DIR"
bun run "$SERVER_SCRIPT" "$PLAN"
exit $?
fi
fi
# If not ExitPlanMode or no plan, allow through
exit 0
+141
View File
@@ -0,0 +1,141 @@
/**
* Hook for URL-based state sharing in Plannotator
*
* Handles:
* - Loading shared state from URL hash on mount
* - Generating shareable URLs
* - Tracking whether current session is from a shared link
*/
import { useState, useEffect, useCallback } from 'react';
import { Annotation } from '../types';
import {
parseShareHash,
generateShareUrl,
fromShareable,
formatUrlSize,
} from '../utils/sharing';
interface UseSharingResult {
/** Whether the current session was loaded from a shared URL */
isSharedSession: boolean;
/** Whether we're currently loading from a shared URL */
isLoadingShared: boolean;
/** The current shareable URL (updates when annotations change) */
shareUrl: string;
/** Human-readable size of the share URL */
shareUrlSize: string;
/** Annotations loaded from share that need to be applied to DOM */
pendingSharedAnnotations: Annotation[] | null;
/** Call after applying shared annotations to clear the pending state */
clearPendingSharedAnnotations: () => void;
/** Manually trigger share URL generation */
refreshShareUrl: () => Promise<void>;
}
export function useSharing(
markdown: string,
annotations: Annotation[],
setMarkdown: (m: string) => void,
setAnnotations: (a: Annotation[]) => void,
onSharedLoad?: () => void
): UseSharingResult {
const [isSharedSession, setIsSharedSession] = useState(false);
const [isLoadingShared, setIsLoadingShared] = useState(true);
const [shareUrl, setShareUrl] = useState('');
const [shareUrlSize, setShareUrlSize] = useState('');
const [pendingSharedAnnotations, setPendingSharedAnnotations] = useState<Annotation[] | null>(null);
const clearPendingSharedAnnotations = useCallback(() => {
setPendingSharedAnnotations(null);
}, []);
// Load shared state from URL hash
const loadFromHash = useCallback(async () => {
try {
const payload = await parseShareHash();
if (payload) {
// Set plan content
setMarkdown(payload.p);
// Convert shareable annotations to full annotations
const restoredAnnotations = fromShareable(payload.a);
setAnnotations(restoredAnnotations);
// Store for later application to DOM
setPendingSharedAnnotations(restoredAnnotations);
setIsSharedSession(true);
// Notify parent that we loaded from a share
onSharedLoad?.();
// Clear the hash from URL to prevent re-loading on refresh
// but keep the state in memory
window.history.replaceState(
{},
'',
window.location.pathname
);
return true;
}
return false;
} catch (e) {
console.error('Failed to load from share hash:', e);
return false;
}
}, [setMarkdown, setAnnotations, onSharedLoad]);
// Load from hash on mount
useEffect(() => {
loadFromHash().finally(() => setIsLoadingShared(false));
}, []); // Only run on mount
// Listen for hash changes (when user pastes a new share URL)
useEffect(() => {
const handleHashChange = () => {
if (window.location.hash.length > 1) {
loadFromHash();
}
};
window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
}, [loadFromHash]);
// Generate share URL when markdown or annotations change
const refreshShareUrl = useCallback(async () => {
try {
const url = await generateShareUrl(markdown, annotations);
setShareUrl(url);
setShareUrlSize(formatUrlSize(url));
} catch (e) {
console.error('Failed to generate share URL:', e);
setShareUrl('');
setShareUrlSize('');
}
}, [markdown, annotations]);
// Auto-refresh share URL when dependencies change
useEffect(() => {
refreshShareUrl();
}, [refreshShareUrl]);
return {
isSharedSession,
isLoadingShared,
shareUrl,
shareUrlSize,
pendingSharedAnnotations,
clearPendingSharedAnnotations,
refreshShareUrl,
};
}
+213
View File
@@ -0,0 +1,213 @@
@import "tailwindcss";
:root {
--background: oklch(0.13 0.02 260);
--foreground: oklch(0.95 0.01 260);
--card: oklch(0.16 0.02 260);
--card-foreground: oklch(0.95 0.01 260);
--popover: oklch(0.18 0.025 260);
--popover-foreground: oklch(0.95 0.01 260);
--primary: oklch(0.75 0.18 280);
--primary-foreground: oklch(0.13 0.02 260);
--secondary: oklch(0.65 0.15 180);
--secondary-foreground: oklch(0.13 0.02 260);
--muted: oklch(0.22 0.02 260);
--muted-foreground: oklch(0.65 0.02 260);
--accent: oklch(0.70 0.20 60);
--accent-foreground: oklch(0.13 0.02 260);
--destructive: oklch(0.65 0.22 25);
--destructive-foreground: oklch(0.98 0 0);
--border: oklch(0.28 0.02 260);
--input: oklch(0.22 0.02 260);
--ring: oklch(0.75 0.18 280);
--font-sans: 'Inter', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
--radius: 0.75rem;
}
.light {
--background: oklch(0.97 0.005 260);
--foreground: oklch(0.18 0.02 260);
--card: oklch(1 0 0);
--card-foreground: oklch(0.18 0.02 260);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.18 0.02 260);
--primary: oklch(0.50 0.25 280);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.50 0.18 180);
--secondary-foreground: oklch(1 0 0);
--muted: oklch(0.92 0.01 260);
--muted-foreground: oklch(0.40 0.02 260);
--accent: oklch(0.60 0.22 50);
--accent-foreground: oklch(0.18 0.02 260);
--destructive: oklch(0.50 0.25 25);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.88 0.01 260);
--input: oklch(0.92 0.01 260);
--ring: oklch(0.50 0.25 280);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
* {
border-color: var(--border);
}
body {
font-family: var(--font-sans);
background: var(--background);
color: var(--foreground);
font-feature-settings: "ss01", "ss02", "cv01";
}
/* Subtle grid background */
.bg-grid {
background-image:
linear-gradient(to right, oklch(0.25 0.02 260 / 0.5) 1px, transparent 1px),
linear-gradient(to bottom, oklch(0.25 0.02 260 / 0.5) 1px, transparent 1px);
background-size: 24px 24px;
}
.light .bg-grid {
background-image:
linear-gradient(to right, oklch(0.90 0.01 260 / 0.6) 1px, transparent 1px),
linear-gradient(to bottom, oklch(0.90 0.01 260 / 0.6) 1px, transparent 1px);
}
/* Glow effects */
.glow-primary {
box-shadow: 0 0 20px oklch(0.75 0.18 280 / 0.15), 0 0 40px oklch(0.75 0.18 280 / 0.05);
}
.glow-sm {
box-shadow: 0 0 10px oklch(0.75 0.18 280 / 0.1);
}
/* Custom scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--muted-foreground); }
/* Code blocks */
pre code.hljs {
display: block;
padding: 1rem;
border-radius: var(--radius);
background: var(--muted) !important;
font-size: 0.8125rem;
line-height: 1.6;
}
/* Light mode code - override hljs dark theme */
.light pre code.hljs {
color: oklch(0.25 0.02 260) !important;
}
.light .hljs-keyword,
.light .hljs-selector-tag,
.light .hljs-built_in,
.light .hljs-name,
.light .hljs-tag {
color: oklch(0.45 0.25 280) !important;
}
.light .hljs-string,
.light .hljs-title,
.light .hljs-section,
.light .hljs-attribute,
.light .hljs-literal,
.light .hljs-template-tag,
.light .hljs-template-variable,
.light .hljs-type {
color: oklch(0.45 0.18 150) !important;
}
.light .hljs-comment,
.light .hljs-quote {
color: oklch(0.55 0.02 260) !important;
font-style: italic;
}
.light .hljs-number,
.light .hljs-symbol,
.light .hljs-bullet {
color: oklch(0.50 0.20 50) !important;
}
.light .hljs-attr,
.light .hljs-variable,
.light .hljs-template-variable,
.light .hljs-class .hljs-title,
.light .hljs-function {
color: oklch(0.45 0.20 280) !important;
}
/* Selection */
::selection {
background: oklch(0.75 0.18 280 / 0.3);
}
/* Smooth transitions */
* {
transition-property: color, background-color, border-color, box-shadow, opacity, transform;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
/* Focus states */
:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
/* Annotation highlights */
.annotation-highlight {
border-radius: 2px;
padding: 0 2px;
margin: 0 -2px;
}
.annotation-highlight.deletion {
background: oklch(0.65 0.22 25 / 0.25);
text-decoration: line-through;
text-decoration-color: var(--destructive);
text-decoration-thickness: 2px;
}
.annotation-highlight.comment {
background: oklch(0.70 0.20 60 / 0.2);
border-bottom: 2px solid var(--accent);
}
.annotation-highlight:hover {
filter: brightness(1.2);
cursor: pointer;
}
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Plannotator</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<!-- Syntax Highlighting -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script type="importmap">
{
"imports": {
"react/": "https://esm.sh/react@^19.2.3/",
"react": "https://esm.sh/react@^19.2.3",
"react-dom": "https://esm.sh/react-dom@^19.2.3",
"react-dom/": "https://esm.sh/react-dom@^19.2.3/"
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body class="min-h-screen antialiased">
<div id="root" class="h-full"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
Executable
+129
View File
@@ -0,0 +1,129 @@
#!/bin/bash
#
# Redline Planning Hook Installer
#
# Usage: ./install.sh [target-project-dir]
#
# This script:
# 1. Builds the standalone Redline HTML if needed
# 2. Adds the ExitPlanMode hook to the target project's Claude settings
#
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
HOOK_SCRIPT="$SCRIPT_DIR/hooks/exit-plan-mode.sh"
REDLINE_HTML="$SCRIPT_DIR/dist/redline.html"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
echo -e "${BLUE}╔════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Redline Planning Hook Installer ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════════════════╝${NC}"
echo ""
# Check for required tools
if ! command -v jq &> /dev/null; then
echo -e "${RED}Error: jq is required but not installed.${NC}"
echo "Install with: brew install jq"
exit 1
fi
# Determine target directory
TARGET_DIR="${1:-$(pwd)}"
if [ ! -d "$TARGET_DIR" ]; then
echo -e "${RED}Error: Target directory does not exist: $TARGET_DIR${NC}"
exit 1
fi
echo -e "${YELLOW}Target project: $TARGET_DIR${NC}"
echo ""
# Build if needed
if [ ! -f "$REDLINE_HTML" ]; then
echo -e "${YELLOW}Building Redline HTML...${NC}"
cd "$SCRIPT_DIR"
npm install
npm run build
echo -e "${GREEN}✓ Build complete${NC}"
echo ""
fi
# Create .claude directory if needed
CLAUDE_DIR="$TARGET_DIR/.claude"
mkdir -p "$CLAUDE_DIR"
SETTINGS_FILE="$CLAUDE_DIR/settings.local.json"
# Create or merge settings
if [ -f "$SETTINGS_FILE" ]; then
echo -e "${YELLOW}Merging with existing settings...${NC}"
# Read existing settings
EXISTING=$(cat "$SETTINGS_FILE")
# Check if hook already exists
if echo "$EXISTING" | jq -e '.hooks.PreToolUse[]?.hooks[]? | select(.command | contains("exit-plan-mode.sh"))' > /dev/null 2>&1; then
echo -e "${GREEN}✓ Hook already installed${NC}"
else
# Add the new hook
NEW_HOOK=$(cat <<EOF
{
"matcher": "ExitPlanMode",
"hooks": [
{
"type": "command",
"command": "$HOOK_SCRIPT"
}
]
}
EOF
)
# Merge into existing settings
UPDATED=$(echo "$EXISTING" | jq --argjson hook "$NEW_HOOK" '
.hooks.PreToolUse = (.hooks.PreToolUse // []) + [$hook]
')
echo "$UPDATED" > "$SETTINGS_FILE"
echo -e "${GREEN}✓ Hook added to existing settings${NC}"
fi
else
echo -e "${YELLOW}Creating new settings file...${NC}"
# Create new settings with hook
cat > "$SETTINGS_FILE" <<EOF
{
"hooks": {
"PreToolUse": [
{
"matcher": "ExitPlanMode",
"hooks": [
{
"type": "command",
"command": "$HOOK_SCRIPT"
}
]
}
]
}
}
EOF
echo -e "${GREEN}✓ Settings file created${NC}"
fi
echo ""
echo -e "${GREEN}════════════════════════════════════════════════════${NC}"
echo -e "${GREEN} Installation complete!${NC}"
echo -e "${GREEN}════════════════════════════════════════════════════${NC}"
echo ""
echo "When Claude calls ExitPlanMode in this project,"
echo "the Redline editor will open with the plan content."
echo ""
echo -e "${BLUE}Settings file: $SETTINGS_FILE${NC}"
echo ""
+5
View File
@@ -0,0 +1,5 @@
{
"name": "Redline - Markdown Editor",
"description": "An interactive Markdown annotation and redlining tool. Collaborate, review, and export structured diffs with ease.",
"requestFramePermissions": []
}
+2383
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "redline-planning-hook",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build && cp dist/index.html dist/redline.html",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"tailwindcss": "^4.1.18",
"unique-username-generator": "^1.5.1",
"web-highlighter": "^0.7.4"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0",
"vite-plugin-singlefile": "^2.0.3"
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+138
View File
@@ -0,0 +1,138 @@
/**
* Plannotator Ephemeral Server
*
* Spawned by ExitPlanMode hook to serve Plannotator UI and handle approve/deny decisions.
* Uses random port to support multiple concurrent Claude Code sessions.
*
* Usage: bun run server/index.ts "<plan markdown content>"
*/
import { $ } from "bun";
import { join, dirname } from "path";
const planContent = process.argv[2] || Bun.env.PLAN_CONTENT || "";
if (!planContent) {
console.error("No plan content provided. Usage: bun run server/index.ts \"<plan>\"");
process.exit(1);
}
// Promise that resolves when user makes a decision
let resolveDecision: (result: { approved: boolean; feedback?: string }) => void;
const decisionPromise = new Promise<{ approved: boolean; feedback?: string }>(
(resolve) => { resolveDecision = resolve; }
);
// Resolve paths relative to this script
const serverDir = dirname(import.meta.path);
const distDir = join(serverDir, "..", "dist");
const server = Bun.serve({
port: 0, // Random available port - critical for multi-instance support
async fetch(req) {
const url = new URL(req.url);
// API: Get plan content
if (url.pathname === "/api/plan") {
return Response.json({ plan: planContent });
}
// API: Approve plan
if (url.pathname === "/api/approve" && req.method === "POST") {
resolveDecision({ approved: true });
return Response.json({ ok: true });
}
// API: Deny with feedback
if (url.pathname === "/api/deny" && req.method === "POST") {
try {
const body = await req.json() as { feedback?: string };
resolveDecision({ approved: false, feedback: body.feedback || "Plan rejected by user" });
} catch {
resolveDecision({ approved: false, feedback: "Plan rejected by user" });
}
return Response.json({ ok: true });
}
// Serve static files from dist/
let filePath = url.pathname;
if (filePath === "/" || filePath === "") {
filePath = "/index.html";
}
const file = Bun.file(join(distDir, filePath));
if (await file.exists()) {
// Set appropriate content types
const contentType = getContentType(filePath);
return new Response(file, {
headers: contentType ? { "Content-Type": contentType } : {}
});
}
// Fallback to index.html for SPA routing
const indexFile = Bun.file(join(distDir, "index.html"));
if (await indexFile.exists()) {
return new Response(indexFile, {
headers: { "Content-Type": "text/html" }
});
}
return new Response("Not found", { status: 404 });
},
});
function getContentType(path: string): string | null {
if (path.endsWith(".html")) return "text/html";
if (path.endsWith(".js")) return "application/javascript";
if (path.endsWith(".css")) return "text/css";
if (path.endsWith(".json")) return "application/json";
if (path.endsWith(".svg")) return "image/svg+xml";
if (path.endsWith(".png")) return "image/png";
if (path.endsWith(".ico")) return "image/x-icon";
return null;
}
// Log to stderr so it doesn't interfere with hook stdout
console.error(`Plannotator server running on http://localhost:${server.port}`);
// Open browser
try {
await $`open http://localhost:${server.port}`.quiet();
} catch {
// Fallback for non-macOS
console.error(`Open browser manually: http://localhost:${server.port}`);
}
// Wait for user decision (blocks until approve/deny)
const result = await decisionPromise;
// Give browser time to receive response and update UI
await Bun.sleep(1500);
// Cleanup
server.stop();
// Output JSON for PermissionRequest hook decision control
if (result.approved) {
console.log(JSON.stringify({
hookSpecificOutput: {
hookEventName: "PermissionRequest",
decision: {
behavior: "allow"
}
}
}));
} else {
console.log(JSON.stringify({
hookSpecificOutput: {
hookEventName: "PermissionRequest",
decision: {
behavior: "deny",
message: result.feedback || "Plan changes requested"
}
}
}));
}
process.exit(0);
+4
View File
@@ -0,0 +1,4 @@
// AI Features have been disabled.
export const getGeminiSuggestions = async (markdownContent: string): Promise<any[]> => {
return [];
};
+29
View File
@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}
+46
View File
@@ -0,0 +1,46 @@
export enum AnnotationType {
DELETION = 'DELETION',
INSERTION = 'INSERTION',
REPLACEMENT = 'REPLACEMENT',
COMMENT = 'COMMENT',
}
export type EditorMode = 'selection' | 'redline';
export interface Annotation {
id: string;
blockId: string; // Legacy - not used with web-highlighter
startOffset: number; // Legacy
endOffset: number; // Legacy
type: AnnotationType;
text?: string; // For comments
originalText: string; // The text that was selected
createdA: number;
author?: string; // Tater identity for collaborative sharing
// web-highlighter metadata for cross-element selections
startMeta?: {
parentTagName: string;
parentIndex: number;
textOffset: number;
};
endMeta?: {
parentTagName: string;
parentIndex: number;
textOffset: number;
};
}
export interface Block {
id: string;
type: 'paragraph' | 'heading' | 'blockquote' | 'list-item' | 'code' | 'hr';
content: string; // Plain text content
level?: number; // For headings (1-6)
order: number; // Sorting order
startLine: number; // 1-based line number in source
}
export interface DiffResult {
original: string;
modified: string;
diffText: string;
}
+79
View File
@@ -0,0 +1,79 @@
/**
* Tater Identity System
*
* Generates anonymous identities for collaborative annotation sharing.
* Format: {adjective}-tater-{noun}
* Examples: "swift-tater-falcon", "gentle-tater-crystal"
*/
import { uniqueUsernameGenerator, adjectives, nouns } from 'unique-username-generator';
const STORAGE_KEY = 'plannotator-identity';
/**
* Generate a new tater identity
*/
export function generateIdentity(): string {
// Use a unique separator to split adjective from noun, avoiding issues
// with compound words that contain hyphens (e.g., "behind-the-scenes")
const generated = uniqueUsernameGenerator({
dictionaries: [adjectives, nouns],
separator: '|||',
style: 'lowerCase',
randomDigits: 0,
length: 50, // Prevent word truncation (default is too short)
});
const [adjective, noun] = generated.split('|||');
return `${adjective}-${noun}-tater`;
}
/**
* Get current identity from localStorage, or generate one if none exists
*/
export function getIdentity(): string {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
return stored;
}
} catch (e) {
// localStorage not available
}
const identity = generateIdentity();
saveIdentity(identity);
return identity;
}
/**
* Save identity to localStorage
*/
export function saveIdentity(identity: string): void {
try {
localStorage.setItem(STORAGE_KEY, identity);
} catch (e) {
// localStorage not available
}
}
/**
* Regenerate identity and save to localStorage
*/
export function regenerateIdentity(): string {
const identity = generateIdentity();
saveIdentity(identity);
return identity;
}
/**
* Check if an identity belongs to the current user
*/
export function isCurrentUser(author: string | undefined): boolean {
if (!author) return false;
try {
return localStorage.getItem(STORAGE_KEY) === author;
} catch (e) {
return false;
}
}
+184
View File
@@ -0,0 +1,184 @@
import { Block } from '../types';
/**
* A simplified markdown parser that splits content into linear blocks.
* For a production app, we would use a robust AST walker (remark),
* but for this demo, we want predictable text-anchoring.
*/
export const parseMarkdownToBlocks = (markdown: string): Block[] => {
const lines = markdown.split('\n');
const blocks: Block[] = [];
let currentId = 0;
let buffer: string[] = [];
let currentType: Block['type'] = 'paragraph';
let currentLevel = 0;
let bufferStartLine = 1; // Track the start line of the current buffer
const flush = () => {
if (buffer.length > 0) {
const content = buffer.join('\n');
blocks.push({
id: `block-${currentId++}`,
type: currentType,
content: content,
level: currentLevel,
order: currentId,
startLine: bufferStartLine
});
buffer = [];
}
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
const currentLineNum = i + 1; // 1-based index
// Headings
if (trimmed.startsWith('#')) {
flush();
const level = trimmed.match(/^#+/)?.[0].length || 1;
blocks.push({
id: `block-${currentId++}`,
type: 'heading',
content: trimmed.replace(/^#+\s*/, ''),
level,
order: currentId,
startLine: currentLineNum
});
continue;
}
// Horizontal Rule
if (trimmed === '---' || trimmed === '***') {
flush();
blocks.push({
id: `block-${currentId++}`,
type: 'hr',
content: '',
order: currentId,
startLine: currentLineNum
});
continue;
}
// List Items (Simple detection)
if (trimmed.match(/^(\*|-|\d+\.)\s/)) {
flush(); // Treat each list item as a separate block for easier annotation
blocks.push({
id: `block-${currentId++}`,
type: 'list-item',
content: trimmed.replace(/^(\*|-|\d+\.)\s/, ''),
order: currentId,
startLine: currentLineNum
});
continue;
}
// Blockquotes
if (trimmed.startsWith('>')) {
// Check if previous was blockquote, if so, merge? No, separate for now
flush();
blocks.push({
id: `block-${currentId++}`,
type: 'blockquote',
content: trimmed.replace(/^>\s*/, ''),
order: currentId,
startLine: currentLineNum
});
continue;
}
// Code blocks (naive)
if (trimmed.startsWith('```')) {
flush();
const codeStartLine = currentLineNum;
// Fast forward until end of code block
let codeContent = [];
i++; // Skip start fence
while(i < lines.length && !lines[i].trim().startsWith('```')) {
codeContent.push(lines[i]);
i++;
}
blocks.push({
id: `block-${currentId++}`,
type: 'code',
content: codeContent.join('\n'),
order: currentId,
startLine: codeStartLine
});
continue;
}
// Empty lines separate paragraphs
if (trimmed === '') {
flush();
currentType = 'paragraph';
continue;
}
// Accumulate paragraph text
if (buffer.length === 0) {
bufferStartLine = currentLineNum;
}
buffer.push(line);
}
flush(); // Final flush
return blocks;
};
export const exportDiff = (blocks: Block[], annotations: any[]): string => {
if (annotations.length === 0) {
return 'No changes detected.';
}
// Sort annotations by block and offset
const sortedAnns = [...annotations].sort((a, b) => {
const blockA = blocks.findIndex(blk => blk.id === a.blockId);
const blockB = blocks.findIndex(blk => blk.id === b.blockId);
if (blockA !== blockB) return blockA - blockB;
return a.startOffset - b.startOffset;
});
let output = `# Plan Feedback\n\n`;
output += `I've reviewed this plan and have ${annotations.length} piece${annotations.length > 1 ? 's' : ''} of feedback:\n\n`;
sortedAnns.forEach((ann, index) => {
const block = blocks.find(b => b.id === ann.blockId);
output += `## ${index + 1}. `;
switch (ann.type) {
case 'DELETION':
output += `Remove this\n`;
output += `\`\`\`\n${ann.originalText}\n\`\`\`\n`;
output += `> I don't want this in the plan.\n`;
break;
case 'INSERTION':
output += `Add this\n`;
output += `\`\`\`\n${ann.text}\n\`\`\`\n`;
break;
case 'REPLACEMENT':
output += `Change this\n`;
output += `**From:**\n\`\`\`\n${ann.originalText}\n\`\`\`\n`;
output += `**To:**\n\`\`\`\n${ann.text}\n\`\`\`\n`;
break;
case 'COMMENT':
output += `Feedback on: "${ann.originalText}"\n`;
output += `> ${ann.text}\n`;
break;
}
output += '\n';
});
output += `---\n`;
return output;
};
+168
View File
@@ -0,0 +1,168 @@
/**
* Portable sharing utilities for Plannotator
*
* Enables sharing plan + annotations via URL hash using:
* - Native CompressionStream/DecompressionStream (deflate-raw)
* - Base64url encoding for URL safety
*
* Inspired by textarea.my's approach.
*/
import { Annotation, AnnotationType } from '../types';
// Minimal shareable annotation format: [type, originalText, text?, author?]
export type ShareableAnnotation =
| ['D', string, string | null] // Deletion: type, original, author
| ['R', string, string, string | null] // Replacement: type, original, replacement, author
| ['C', string, string, string | null] // Comment: type, original, comment, author
| ['I', string, string, string | null]; // Insertion: type, context, new text, author
export interface SharePayload {
p: string; // plan markdown
a: ShareableAnnotation[];
}
/**
* Compress a SharePayload to a base64url string
*/
export async function compress(payload: SharePayload): Promise<string> {
const json = JSON.stringify(payload);
const byteArray = new TextEncoder().encode(json);
const stream = new CompressionStream('deflate-raw');
const writer = stream.writable.getWriter();
writer.write(byteArray);
writer.close();
const buffer = await new Response(stream.readable).arrayBuffer();
const compressed = new Uint8Array(buffer);
// Convert to base64url (URL-safe base64)
const base64 = btoa(String.fromCharCode(...compressed));
return base64
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
/**
* Decompress a base64url string back to SharePayload
*/
export async function decompress(b64: string): Promise<SharePayload> {
// Restore standard base64
const base64 = b64
.replace(/-/g, '+')
.replace(/_/g, '/');
const binary = atob(base64);
const byteArray = Uint8Array.from(binary, c => c.charCodeAt(0));
const stream = new DecompressionStream('deflate-raw');
const writer = stream.writable.getWriter();
writer.write(byteArray);
writer.close();
const buffer = await new Response(stream.readable).arrayBuffer();
const json = new TextDecoder().decode(buffer);
return JSON.parse(json) as SharePayload;
}
/**
* Convert full Annotation objects to minimal shareable format
*/
export function toShareable(annotations: Annotation[]): ShareableAnnotation[] {
return annotations.map(ann => {
const type = ann.type[0] as 'D' | 'R' | 'C' | 'I';
const author = ann.author || null;
if (type === 'D') {
return ['D', ann.originalText, author] as ShareableAnnotation;
}
// R, C, I all have text
return [type, ann.originalText, ann.text || '', author] as ShareableAnnotation;
});
}
/**
* Convert shareable format back to full Annotation objects
* Note: blockId, offsets, and meta will need to be populated separately
* by finding the text in the rendered document.
*/
export function fromShareable(data: ShareableAnnotation[]): Annotation[] {
const typeMap: Record<string, AnnotationType> = {
'D': AnnotationType.DELETION,
'R': AnnotationType.REPLACEMENT,
'C': AnnotationType.COMMENT,
'I': AnnotationType.INSERTION,
};
return data.map((item, index) => {
const type = item[0];
const originalText = item[1];
// For deletion: [type, original, author]
// For others: [type, original, text, author]
const text = type === 'D' ? undefined : item[2] as string;
const author = type === 'D' ? item[2] as string | null : item[3] as string | null;
return {
id: `shared-${index}-${Date.now()}`,
blockId: '', // Will be populated during highlight restoration
startOffset: 0,
endOffset: 0,
type: typeMap[type],
text: text || undefined,
originalText,
createdA: Date.now() + index, // Preserve order
author: author || undefined,
// startMeta/endMeta will be set by web-highlighter
};
});
}
/**
* Generate a full shareable URL from plan and annotations
*/
export async function generateShareUrl(
markdown: string,
annotations: Annotation[]
): Promise<string> {
const payload: SharePayload = {
p: markdown,
a: toShareable(annotations),
};
const hash = await compress(payload);
return `${window.location.origin}${window.location.pathname}#${hash}`;
}
/**
* Parse a share URL hash and return the payload
* Returns null if no valid hash or parsing fails
*/
export async function parseShareHash(): Promise<SharePayload | null> {
const hash = window.location.hash.slice(1); // Remove leading #
if (!hash) {
return null;
}
try {
return await decompress(hash);
} catch (e) {
console.warn('Failed to parse share hash:', e);
return null;
}
}
/**
* Get the size of a URL in a human-readable format
*/
export function formatUrlSize(url: string): string {
const bytes = new Blob([url]).size;
if (bytes < 1024) {
return `${bytes} B`;
}
return `${(bytes / 1024).toFixed(1)} KB`;
}
+11
View File
@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
declare module '*.webp' {
const src: string;
export default src;
}
declare module '*.png' {
const src: string;
export default src;
}
+36
View File
@@ -0,0 +1,36 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import { viteSingleFile } from 'vite-plugin-singlefile';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react(), tailwindcss(), viteSingleFile()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
},
build: {
target: 'esnext',
assetsInlineLimit: 100000000,
chunkSizeWarningLimit: 100000000,
cssCodeSplit: false,
rollupOptions: {
output: {
inlineDynamicImports: true,
},
},
},
};
});