Purpose: This guide helps AI coding assistants (like Claude, GPT, Cursor, etc.) understand the codebase and implement features correctly.
Last Updated: February 27, 2026 Current Phase: Phase 1 (MVP Editor) - 85% Complete
Cipher Draw is a developer-native diagramming and documentation studio that supports Markdown, Mermaid, SVG, and Mixed content with live preview, export, and sharing.
- Phase: Phase 1 (MVP Editor)
- Progress: 85% complete
- Next: Read-only view page, keyboard shortcuts, mobile responsive
- No backend yet - Phase 1 is frontend-only
- State in localStorage - Using Zustand with persistence
- URL-based sharing - lz-string compression in hash
- Monaco Editor - Same as VS Code, dynamically imported
- Rendering - Client-side only, no server rendering of diagrams
apps/web/
├── app/
│ ├── page.tsx # ✅ Main editor (DONE)
│ ├── layout.tsx # ✅ Root layout (DONE)
│ ├── globals.css # ✅ Global styles (DONE)
│ └── view/
│ └── [token]/
│ └── page.tsx # ✅ Read-only view (DONE)
│
├── components/
│ ├── editor/
│ │ └── MonacoEditor.tsx # ✅ Monaco integration (DONE)
│ ├── preview/
│ │ ├── PreviewPane.tsx # ✅ Main preview (DONE)
│ │ └── renderers/
│ │ ├── renderMarkdown.ts # ✅ Markdown (DONE)
│ │ ├── renderMermaid.ts # ✅ Mermaid (DONE)
│ │ ├── renderMixed.ts # ✅ Mixed mode (DONE)
│ │ └── renderSvg.ts # ✅ SVG (DONE)
│ └── ui/
│ ├── button.tsx # ✅ shadcn/ui (DONE)
│ └── select.tsx # ✅ shadcn/ui (DONE)
│
├── lib/
│ ├── export/
│ │ ├── exportSvg.ts # ✅ SVG export (DONE)
│ │ ├── exportPng.ts # ✅ PNG export (DONE)
│ │ └── exportPdf.ts # ✅ PDF export (DONE)
│ ├── share/
│ │ ├── codec.ts # ✅ lz-string compress/decompress (DONE)
│ │ └── hash.ts # ✅ URL hash read/write (DONE)
│ ├── sanitize/
│ │ └── sanitize.ts # ✅ DOMPurify wrapper (DONE)
│ ├── templates.ts # ✅ Sample templates (DONE)
│ ├── debounce.ts # ✅ Debounce utility (DONE)
│ └── utils.ts # ✅ cn() helper (DONE)
│
├── store/
│ └── useDocStore.ts # ✅ Zustand state (DONE)
│
├── tests/
│ ├── codec.test.ts # ✅ Compression tests (DONE)
│ └── renderMixed.test.ts # ✅ Mixed mode tests (DONE)
│
└── types.ts # ✅ TypeScript types (DONE)
What it does: Main application page with editor, preview, and controls
Key patterns:
- Uses
useDocStore()for state management - Loads shared state from URL hash on mount
- Handles export actions via state machine
- Three view modes: editor, split, preview
- Resizable split pane with drag handle
State:
const {
mode, // 'markdown' | 'mermaid' | 'svg' | 'mixed'
title, // Document title
content, // Editor content
theme, // 'dark' | 'light'
previewBg, // 'dark' | 'white' | 'transparent'
setMode, setTitle, setContent, setTheme, setPreviewBg,
applySharedState // Load from URL hash
} = useDocStore();Important: No keyboard shortcuts implemented yet (TODO)
What it does: Zustand store with localStorage persistence
Key points:
- Persists to
localStorageunder key:cipher-draw-doc-v1 applySharedState()used when loading from URL hash- Theme changes also update
editorThemefor Monaco
Persisted fields:
{
mode: DocMode,
title: string,
content: string,
theme: ThemeMode,
editorTheme: string,
previewBg: PreviewBackground
}What it does: Read/write state from URL hash
Flow:
writeStateToHash()→ compress with lz-string → update URL hashreadStateFromHash()→ read hash → decompress → return state
Usage:
// Write
const url = writeStateToHash({ mode, content, theme });
await navigator.clipboard.writeText(url);
// Read (on page load)
const shared = readStateFromHash();
if (shared) {
applySharedState(shared);
}What it does: Renders content based on mode
Key points:
- Debounces render by 300ms
- Calls appropriate renderer based on mode
- Catches errors and displays them
- Reports render status via callback
- Extracts SVG for export
Render flow:
useEffect(() => {
const timer = setTimeout(async () => {
if (mode === 'markdown') {
html = await renderMarkdown(content);
} else if (mode === 'mermaid') {
svg = await renderMermaid(content, theme);
html = `<div>${svg}</div>`;
} // ... etc
setHtml(html);
onSvgChange(svg);
onRenderStatus({ ok: true, message: 'Rendered OK' });
}, 300);
}, [mode, content, theme]);What they do: Transform content to HTML/SVG
renderMarkdown.ts:
- Uses
remark+rehypepipeline - Plugins: remark-gfm, rehype-raw
- Returns sanitized HTML string
renderMermaid.ts:
- Uses
mermaid.render()with unique ID - Theme-aware (dark/light)
- Returns SVG string
- Catches syntax errors
renderSvg.ts:
- Validates and sanitizes raw SVG
- Uses DOMPurify
- Returns sanitized SVG string
renderMixed.ts:
- Parses Markdown for mermaid fences
- Renders Markdown normally
- Extracts and renders each mermaid block
- Injects rendered SVG back into HTML
- Returns combined HTML string
Framework: Tailwind CSS
Theme:
- Dark mode default
- Uses
cn()utility for conditional classes - shadcn/ui components for buttons, selects
Key classes:
/* Background */
bg-background, bg-slate-950, bg-slate-900
/* Text */
text-foreground, text-slate-100, text-muted-foreground
/* Borders */
border, border-slate-700
/* Interactive */
hover:bg-slate-800, focus:ring-2Dark mode toggle:
- Controlled by
themestate in Zustand - Applied as class on root div:
className={theme === 'dark' && 'dark'}
Framework: Vitest
Current tests:
tests/codec.test.ts- Share codec (encode/decode)tests/renderMixed.test.ts- Mixed mode rendering
Running tests:
pnpm test
# or
pnpm -C apps/web testTest patterns:
import { describe, it, expect } from 'vitest';
describe('Feature', () => {
it('should do something', () => {
const result = myFunction();
expect(result).toBe(expected);
});
});pnpm dev
# or
pnpm -C apps/web devRuns at: http://localhost:3000
pnpm buildpnpm lintExample: Adding keyboard shortcuts
// In app/page.tsx
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ctrl+S or Cmd+S
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
// Save logic here
console.log('Saved!');
}
// Ctrl+Enter or Cmd+Enter
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
// Force re-render
setRenderStatus({ ok: true, message: 'Force rendered' });
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);Example: Creating /view/[token] page
// apps/web/app/view/[token]/page.tsx
'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { PreviewPane } from '@/components/preview/PreviewPane';
import { readStateFromHash } from '@/lib/share/hash';
import type { DocState } from '@/types';
export default function ViewPage() {
const params = useParams();
const [state, setState] = useState<Partial<DocState> | null>(null);
useEffect(() => {
// Token is in URL hash, not in params
const shared = readStateFromHash();
if (shared) {
setState(shared);
}
}, []);
if (!state) {
return <div>Loading...</div>;
}
return (
<div className="flex h-screen flex-col">
{/* Minimal navbar */}
<div className="border-b px-4 py-2">
<h1>{state.title || 'Untitled'}</h1>
</div>
{/* Preview only */}
<div className="flex-1 p-4">
<PreviewPane
mode={state.mode || 'markdown'}
content={state.content || ''}
theme={state.theme || 'dark'}
previewBg="dark"
onSvgChange={() => {}}
onRenderStatus={() => {}}
/>
</div>
</div>
);
}Example: Adding D2 support
// components/preview/renderers/renderD2.ts
import { sanitize } from '@/lib/sanitize/sanitize';
export async function renderD2(content: string): Promise<string> {
try {
// Use d2-wasm or API call to render D2
const svg = await d2Render(content);
return sanitize(svg);
} catch (error) {
throw new Error(`D2 render failed: ${error.message}`);
}
}Then update PreviewPane.tsx:
// In PreviewPane.tsx
if (mode === 'd2') {
svg = await renderD2(content);
nextHtml = `<div class="d2-root">${svg}</div>`;
}Example: Adding a new persisted field
// store/useDocStore.ts
// 1. Update types
export type DocState = {
// ... existing fields
newField: string;
};
// 2. Add to default state
const defaultState: DocState = {
// ... existing
newField: 'default value'
};
// 3. Add setter
setNewField: (value: string) => set({ newField: value })
// 4. Add to persist config
partialize: (state) => ({
// ... existing
newField: state.newField
})❌ Don't: Import Monaco directly
import Editor from '@monaco-editor/react'; // Will break SSR✅ Do: Use dynamic import with ssr: false
const Editor = dynamic(() => import('@monaco-editor/react'), {
ssr: false,
loading: () => <div>Loading...</div>
});❌ Don't: Use params for view page
const { token } = useParams(); // Wrong - state is in hash✅ Do: Read from URL hash
const shared = readStateFromHash(); // Correct❌ Don't: Reuse same ID
mermaid.render('diagram', content); // ID collision✅ Do: Generate unique IDs
const id = `mermaid-${Date.now()}-${Math.random()}`;
mermaid.render(id, content);❌ Don't: Inject unsanitized HTML
<div dangerouslySetInnerHTML={{ __html: content }} />✅ Do: Always sanitize
import { sanitize } from '@/lib/sanitize/sanitize';
<div dangerouslySetInnerHTML={{ __html: sanitize(content) }} />{
"@monaco-editor/react": "^4.7.0", // Editor
"mermaid": "^11.12.0", // Diagram rendering
"lz-string": "^1.5.0", // Compression
"dompurify": "^3.2.6", // Sanitization
"html-to-image": "^1.11.11", // PNG export
"jspdf": "^2.5.2", // PDF export
"zustand": "^5.0.8", // State management
"remark": "^15.0.1", // Markdown parser
"remark-gfm": "^4.0.1", // GitHub Flavored Markdown
"rehype-raw": "^7.0.0", // Raw HTML support
"next": "^14.2.32", // Framework
"react": "^18.3.1" // UI library
}- Monaco Editor: https://microsoft.github.io/monaco-editor/api/
- Mermaid: https://mermaid.js.org/config/setup/modules/mermaidAPI.html
- Zustand: https://docs.pmnd.rs/zustand/
- Next.js 14: https://nextjs.org/docs/app
Effort: 2-3 hours Files to create:
apps/web/app/view/[token]/page.tsx
Requirements:
- Read state from URL hash
- Show read-only preview (no editor)
- Add "Fork" button → redirect to home with content
- Show title, mode badge
- Theme toggle
- Minimal navbar
Reference: See "Pattern 2" above
Effort: 1-2 hours Files to modify:
apps/web/app/page.tsx
Requirements:
Ctrl+S/Cmd+S→ Save (show toast notification)Ctrl+Enter/Cmd+Enter→ Force re-render- Prevent default browser behavior
- Add keyboard shortcut help modal (
?key)
Reference: See "Pattern 1" above
Effort: 3-4 hours Files to modify:
apps/web/app/page.tsxapps/web/app/globals.css
Requirements:
- Tab-based view on mobile (< 768px)
- Two tabs: "Code" and "Preview"
- Test touch interactions
- Ensure buttons are 44px min touch target
- Test on iOS Safari and Android Chrome
Effort: 1 hour Files to modify:
apps/web/app/page.tsxorapps/web/store/useDocStore.ts
Implementation:
useEffect(() => {
const savedTheme = localStorage.getItem('cipher-draw-theme');
if (savedTheme) return; // User has preference
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
setTheme(prefersDark ? 'dark' : 'light');
}, []);After implementing any feature, update these docs:
-
STATUS.md
- Mark task as ✅ Complete
- Update progress percentage
- Remove from "What's Missing" section
-
IMPLEMENTATION_GUIDE.md (this file)
- Update codebase structure (❌ → ✅)
- Add new patterns if applicable
- Document any new conventions
-
ROADMAP.md
- Update phase completion if milestone reached
-
Git commit
- Reference the task in commit message
- Example:
feat: add read-only view page (completes #1 from STATUS.md)
<type>: <description>
[optional body]
[optional footer]
Types:
feat:- New featurefix:- Bug fixdocs:- Documentation onlystyle:- Formatting, missing semicolons, etc.refactor:- Code change that neither fixes a bug nor adds a featuretest:- Adding or updating testschore:- Updating build tasks, package manager configs, etc.
Examples:
feat: add read-only view page at /view/[token]
Implements read-only sharing functionality.
Users can now view shared diagrams without editing.
Closes #1 from STATUS.md
feat: add keyboard shortcuts (Ctrl+S, Ctrl+Enter)
- Ctrl+S saves to localStorage and shows toast
- Ctrl+Enter forces re-render
- Prevents default browser behavior
Check:
- Is content valid Mermaid syntax?
- Check browser console for errors
- Try rendering at https://mermaid.live to validate syntax
- Check theme is passed correctly to
renderMermaid()
Check:
- Is URL hash present? (should start with
#) - Try decoding:
console.log(readStateFromHash()) - Check if content is too large (>8KB compressed)
- Verify lz-string is working: test encode → decode
Check:
- Is dynamic import configured correctly?
- Check browser console for loading errors
- Verify height is set on parent container
- Try hard refresh (Ctrl+Shift+R)
Check:
- For SVG/PNG: Is
svgForExportpopulated? - For PDF: Is
previewRef.currentdefined? - Check browser console for errors
- Verify export library loaded (html-to-image, jspdf)
Before starting Phase 2, you must:
- Complete all Phase 1 tasks (see STATUS.md)
- Initialize NestJS in
server/ - Set up PostgreSQL + Prisma
- Set up Redis for sessions
- Create Docker Compose setup
Phase 2 first task: Authentication system (email + OAuth)
This guide is maintained for AI coding assistants. Keep it updated as the codebase evolves.