diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..18df005
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,11 @@
+target/
+node_modules/
+.git/
+.gitignore
+*.md
+.vscode/
+.idea/
+.claude/
+_scripts/
+calcpad-web/dist/
+calcpad-wasm/pkg/
diff --git a/Cargo.lock b/Cargo.lock
index 36b8039..10f6d08 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -505,6 +505,7 @@ version = "0.1.0"
dependencies = [
"calcpad-engine",
"chrono",
+ "getrandom 0.2.17",
"js-sys",
"serde",
"serde-wasm-bindgen",
@@ -1580,8 +1581,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"wasi",
+ "wasm-bindgen",
]
[[package]]
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..4440b7c
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,55 @@
+# Stage 1 — Build WASM from Rust
+FROM rust:1.85-bookworm AS wasm-builder
+
+# Install wasm-pack via pre-built binary (much faster than cargo install)
+RUN curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
+
+WORKDIR /app
+
+# Copy workspace Cargo.toml
+COPY Cargo.toml ./
+
+# Copy the crates we actually need to compile
+COPY calcpad-engine/ calcpad-engine/
+COPY calcpad-wasm/ calcpad-wasm/
+
+# Create stub Cargo.toml + src for workspace members we don't need to build.
+# The workspace requires all members to exist, but we never compile these.
+RUN mkdir -p calcpad-cli/src && \
+ printf '[package]\nname = "calcpad-cli"\nversion = "0.1.0"\nedition = "2021"\n\n[[bin]]\nname = "calcpad"\npath = "src/main.rs"\n\n[dependencies]\ncalcpad-engine = { path = "../calcpad-engine" }\n' > calcpad-cli/Cargo.toml && \
+ echo 'fn main() {}' > calcpad-cli/src/main.rs && \
+ mkdir -p calcpad-windows/src && \
+ printf '[package]\nname = "calcpad-windows"\nversion = "0.1.0"\nedition = "2021"\n\n[[bin]]\nname = "calcpad-win"\npath = "src/main.rs"\n' > calcpad-windows/Cargo.toml && \
+ echo 'fn main() {}' > calcpad-windows/src/main.rs
+
+# Build WASM
+RUN wasm-pack build calcpad-wasm --target web --release
+
+# Stage 2 — Build frontend with Vite
+FROM node:22-slim AS web-builder
+
+WORKDIR /app/calcpad-web
+
+# Install dependencies first (layer caching)
+COPY calcpad-web/package.json calcpad-web/package-lock.json* ./
+RUN npm install
+
+# Copy web source
+COPY calcpad-web/ ./
+
+# Copy WASM output into public/wasm/ so Vite includes it in the build
+COPY --from=wasm-builder /app/calcpad-wasm/pkg/calcpad_wasm.js public/wasm/calcpad_wasm.js
+COPY --from=wasm-builder /app/calcpad-wasm/pkg/calcpad_wasm_bg.wasm public/wasm/calcpad_wasm_bg.wasm
+
+# Build (skip tsc type-check — that belongs in CI, not Docker)
+RUN npx vite build
+
+# Stage 3 — Serve with nginx
+FROM nginx:1.27-alpine
+
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+COPY --from=web-builder /app/calcpad-web/dist /usr/share/nginx/html
+
+EXPOSE 8080
+
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/_bmad-output/A-Product-Brief/00-product-brief.md b/_bmad-output/A-Product-Brief/00-product-brief.md
new file mode 100644
index 0000000..d2a7013
--- /dev/null
+++ b/_bmad-output/A-Product-Brief/00-product-brief.md
@@ -0,0 +1,44 @@
+# Product Brief: CalcText
+
+> The strategic foundation — why this product exists, who it serves, and what success looks like.
+
+**Created:** 2026-03-17
+**Phase:** 1 — Product Brief
+**Agent:** Saga (Analyst)
+
+---
+
+## What Belongs Here
+
+The Product Brief answers five strategic questions:
+
+1. **Why** does this product exist? (Vision & business goals)
+2. **Who** is it for? (Target users and their context)
+3. **What** does it need to do? (Core capabilities)
+4. **How** will we know it works? (Success metrics)
+5. **What** are the constraints? (Platform requirements, tech stack)
+
+Everything downstream — trigger maps, scenarios, page specs, design system — traces back to decisions made here. This is the North Star.
+
+---
+
+## For Agents
+
+**Workflow:** `_bmad/wds/workflows/1-project-brief/workflow.md`
+**Agent trigger:** `PB` (Saga)
+**Templates:** `_bmad/wds/workflows/1-project-brief/templates/`
+
+---
+
+## Documents
+
+_This section will be updated as documents are created during Phase 1._
+
+| # | Document | Status |
+|---|----------|--------|
+| 01 | Project Brief (Simplified) | Complete |
+| 02 | Brownfield Analysis | Complete |
+
+---
+
+_Created using Whiteport Design Studio (WDS) methodology_
diff --git a/_bmad-output/A-Product-Brief/01-brownfield-analysis.md b/_bmad-output/A-Product-Brief/01-brownfield-analysis.md
new file mode 100644
index 0000000..5c80ec4
--- /dev/null
+++ b/_bmad-output/A-Product-Brief/01-brownfield-analysis.md
@@ -0,0 +1,205 @@
+# CalcText Web — Brownfield Analysis
+
+**Date:** 2026-03-17
+**Agent:** Freya (WDS Designer)
+**Scope:** calcpad-web current state + cross-platform patterns from macOS
+
+---
+
+## 1. Current Architecture
+
+| Layer | Tech | Status |
+|-------|------|--------|
+| Framework | React 19 + TypeScript | Solid |
+| Editor | CodeMirror 6 (custom language, extensions) | Solid |
+| Engine | Rust WASM in Web Worker (JS fallback for dev) | Solid |
+| Build | Vite 7, ES2022 target, code splitting | Solid |
+| PWA | vite-plugin-pwa, service worker, manifest | Solid |
+| Styling | CSS custom properties, no framework | Flexible — ready for theme system |
+
+---
+
+## 2. Current Layout
+
+```
+┌─────────────────────────────────────────┐
+│ Header (logo + subtitle + status dot) │ 52px
+├─────────────────────────────────────────┤
+│ Align Toolbar (editor + results) │ 28px
+├───────────────────────┬─┬───────────────┤
+│ │ │ │
+│ Editor (flex: 3) │D│ Results │
+│ CodeMirror 6 │i│ Panel │
+│ 15px mono, 24px line │v│ (flex: 1) │
+│ │ │ │
+└───────────────────────┴─┴───────────────┘
+```
+
+**Mobile (< 640px):** Results panel, toolbar, divider, subtitle — all hidden. Editor only.
+
+---
+
+## 3. CSS Design Tokens (Current)
+
+### Colors
+
+| Token | Light | Dark |
+|-------|-------|------|
+| --text | #6b6375 | #9ca3af |
+| --text-h | #08060d | #f3f4f6 |
+| --bg | #fff | #16171d |
+| --bg-secondary | #f8f9fa | #1a1b23 |
+| --border | #e5e4e7 | #2e303a |
+| --code-bg | #f4f3ec | #1f2028 |
+| --accent | #6366f1 | #818cf8 |
+| --accent-bg | rgba(99,102,241,0.1) | rgba(129,140,248,0.15) |
+| --accent-border | rgba(99,102,241,0.5) | rgba(129,140,248,0.5) |
+| --warning | #f59e0b | (same) |
+| --success | #10b981 | (same) |
+| --error | #e53e3e | (same) |
+
+### Typography
+
+| Context | Font | Size | Weight | Line Height |
+|---------|------|------|--------|-------------|
+| Body | system-ui | 16px | 400 | 1.5 |
+| Editor/Results | ui-monospace | 15px | 400/600 | 1.6 (24px) |
+| Header title | system-ui | 20px | — | — |
+| Header subtitle | system-ui | 13px | — | — |
+| Status | system-ui | 12px | — | — |
+| Toolbar labels | system-ui | 11px | — | — |
+| Buttons | system-ui | 14px | 500 | — |
+
+### Spacing
+
+| Context | Value |
+|---------|-------|
+| Header padding | 12px 24px |
+| Editor line padding | 0 16px |
+| Editor content padding | 12px 0 |
+| Results line padding | 0 16px |
+| Results content padding | 12px 0 |
+| Toolbar height | 28px |
+| Toolbar padding | 0 12px |
+| Button gaps | 4px |
+| Divider width | 5px (9px hit area) |
+
+### Breakpoints
+
+| Width | Behavior |
+|-------|----------|
+| > 640px | Full two-column layout |
+| <= 640px | Single column, results/toolbar/divider hidden |
+| <= 480px | Install prompt stacks vertically |
+
+### Z-Index
+
+| Element | Value |
+|---------|-------|
+| Install prompt | 100 |
+| (everything else) | Default stacking |
+
+### Animations
+
+| Element | Type | Duration |
+|---------|------|----------|
+| Divider hover | Transition | 0.15s |
+| Toolbar buttons | Transition | 0.1s |
+| Install button | Transition | 0.2s |
+| Status dot (loading) | Pulse keyframe | 1.5s |
+| Offline icon | Pulse keyframe | 2s |
+
+---
+
+## 4. Component Inventory
+
+| Component | Location | Props | Purpose |
+|-----------|----------|-------|---------|
+| App | App.tsx | — | Shell, state, layout |
+| CalcEditor | editor/CalcEditor.tsx | initialDoc, onDocChange, results, debounceMs, onViewReady | CodeMirror wrapper |
+| ResultsPanel | components/ResultsPanel.tsx | results, align, style | Right-side results display |
+| AlignToolbar | components/AlignToolbar.tsx | editorAlign, resultsAlign, onChange handlers | Text alignment controls |
+| OfflineBanner | components/OfflineBanner.tsx | isOnline | Offline status |
+| InstallPrompt | components/InstallPrompt.tsx | promptEvent, isInstalled, onInstall, onDismiss | PWA install |
+
+### Editor Extensions
+
+| Extension | File | Purpose |
+|-----------|------|---------|
+| calcpadLanguage | calcpad-language.ts | Syntax highlighting (keywords, functions, operators, currencies) |
+| errorDisplayExtension | error-display.ts | Wavy underlines + gutter markers for errors |
+| stripedLinesExtension | inline-results.ts | Even-line zebra striping |
+| calcpadEditorTheme | CalcEditor.tsx | Base theme (font, padding, colors) |
+
+### Hooks
+
+| Hook | File | State |
+|------|------|-------|
+| useEngine | engine/useEngine.ts | ready, results, error, evalSheet() |
+| useOnlineStatus | hooks/useOnlineStatus.ts | isOnline |
+| useInstallPrompt | hooks/useInstallPrompt.ts | promptEvent, isInstalled, handlers |
+
+---
+
+## 5. Accessibility Audit
+
+| Feature | Status | Notes |
+|---------|--------|-------|
+| Semantic HTML | Partial | Header, main present. Missing nav, regions |
+| ARIA roles | Partial | OfflineBanner (status), InstallPrompt (complementary) |
+| Focus indicators | Missing | No custom :focus-visible styles |
+| Keyboard nav | Default | CodeMirror defaults only, toolbar not keyboard-navigable |
+| Screen reader | Partial | Results panel has no aria-live for updates |
+| Color contrast | OK | Meets AA for most combinations |
+| Font scaling | Missing | Fixed 15px, ignores system preferences |
+| Reduced motion | Missing | No prefers-reduced-motion media query |
+
+---
+
+## 6. Cross-Platform Comparison (macOS vs Web)
+
+| Aspect | macOS | Web | Recommendation |
+|--------|-------|-----|----------------|
+| Spacing | Compact (8px) | Spacious (12-16px) | Tighten to match native feel |
+| Results weight | Regular, secondary color | Bold 600, accent color | Reduce to secondary — results are too loud |
+| Font scaling | System Dynamic Type | Fixed 15px | Respect system preferences |
+| Min dimensions | 800x600 window | No minimum | Set min-width guidance |
+| Text substitutions | All disabled | Browser default | Disable smart quotes/dashes |
+| Stripe pattern | Synced, 4% opacity | Synced, 2.5-3.5% opacity | Already close |
+| Error display | Editor only | Editor only | Already matched |
+| Scroll sync | Binding-driven | Event-driven (passive) | Both work well |
+
+---
+
+## 7. What's Missing for Workspace Vision
+
+| Feature | Exists? | Effort | Priority |
+|---------|---------|--------|----------|
+| Theme engine (multi-theme) | No (only system dark/light) | Medium | P0 |
+| Theme switcher UI | No | Low | P0 |
+| Document model (multi-doc) | No (single string in state) | Medium | P0 |
+| Tab bar | No | Medium | P0 |
+| File sidebar | No | Medium | P1 |
+| localStorage persistence | No | Low | P0 |
+| Folder organization | No | Medium | P1 |
+| Templates library | No | Low | P2 |
+| Dark mode toggle (manual) | No (system preference only) | Low | P0 |
+| Mobile results view | No (hidden on mobile) | Medium | P1 |
+| Keyboard shortcuts panel | No | Low | P2 |
+| Status bar | No | Low | P1 |
+
+---
+
+## 8. Strengths to Preserve
+
+1. **Engine isolation** — Web Worker keeps UI responsive
+2. **PWA foundation** — Offline-ready, installable
+3. **CSS custom properties** — Perfect base for theme system
+4. **CodeMirror extensibility** — Custom language, errors, striping
+5. **Scroll sync** — Reliable cross-pane synchronization
+6. **Light/dark adaptation** — prefers-color-scheme already works
+7. **Code splitting** — React and CodeMirror in separate chunks
+
+---
+
+_Created using Whiteport Design Studio (WDS) methodology_
diff --git a/_bmad-output/A-Product-Brief/project-brief.md b/_bmad-output/A-Product-Brief/project-brief.md
new file mode 100644
index 0000000..7f08f76
--- /dev/null
+++ b/_bmad-output/A-Product-Brief/project-brief.md
@@ -0,0 +1,94 @@
+# CalcText — Simplified Product Brief
+
+**Date:** 2026-03-17
+**Type:** Brownfield — Workspace Evolution
+**Brief Level:** Simplified
+
+---
+
+## Project Scope
+
+CalcText is a cross-platform notepad calculator (Web, macOS, Windows) powered by a shared Rust calculation engine with 200+ unit conversions, 180+ fiat currencies, 50+ crypto, date/time math, and 50+ built-in functions.
+
+The web app is evolving from a single-document, two-column calculator into a **full workspace application** with:
+
+- **Multi-theme system** — Light, Dark, Matrix, and custom user-defined themes
+- **Tabbed documents** — Multiple calctext files open simultaneously
+- **File sidebar** — Left panel with folder organization, recent files, favorites, templates
+- **localStorage persistence** — Documents survive page reloads without backend
+- **Elevated results panel** — Richer, subtler result display
+
+**Platform strategy:** Web-first design. Once established, replicate the UX patterns to macOS (SwiftUI) and Windows (iced/Rust). The design must be portable — avoid web-only paradigms that don't translate to native.
+
+---
+
+## Challenge & Opportunity
+
+CalcText has a powerful engine trapped in a basic single-document UI. The current web app is functional but doesn't reflect the depth of what the engine can do, and doesn't invite users to stay.
+
+**The opportunity:** No competitor (Soulver, Numi, Numbr, PCalc) offers a true multi-document workspace with theming and file organization for calculations. CalcText can own this gap — becoming the app users open daily and live in, not just visit for a quick calculation.
+
+**The gap:** Current UI is a text editor with results. The vision is a professional workspace where calculations are organized, styled, and persistent.
+
+---
+
+## Design Goals
+
+### Functional
+- Multi-theme engine with preset themes and custom theme support
+- Tab bar for multi-document management
+- Collapsible file sidebar with tree view, folders, and templates
+- Document model with create, rename, delete, organize
+- localStorage persistence for all documents and preferences
+
+### Experience
+- Professional workspace feel — "VS Code/Notion for numbers"
+- Complete experience shipped as one coherent product, not incremental phases
+- Matrix theme as a brand differentiator and personality statement
+- Intuitive for first-time users, powerful for daily users
+
+### Business
+- Web-first design that establishes reusable UX patterns
+- Cross-platform portable design language
+- Foundation for future premium features (cloud sync, accounts, collaboration)
+
+---
+
+## Constraints
+
+### Technical (locked in)
+- React 19 + CodeMirror 6 + Vite 7
+- Rust WASM engine running in Web Worker
+- Custom CSS with CSS custom properties (no Tailwind, no shadcn)
+- PWA architecture (service worker, manifest, offline support)
+
+### Deferred (out of scope this phase)
+- User accounts and authentication
+- Cloud save and sync (Supabase planned)
+- Collaborative real-time editing (CRDT)
+- Shareable URL links
+- Embeddable widget
+
+### Cross-platform
+- Design patterns must translate to SwiftUI (macOS) and iced (Windows)
+- Avoid web-only interactions that don't port to native
+- Shared engine means consistent calculation behavior across platforms
+
+### Timeline & Budget
+- Flexible — no hard deadline
+- Stakes: enterprise/high — quality over speed
+
+---
+
+## Current State Reference
+
+See `01-brownfield-analysis.md` for complete codebase scan including:
+- All CSS custom properties and values
+- Component inventory and props
+- Responsive breakpoints and behavior
+- Accessibility audit
+- Cross-platform comparison (macOS vs Web)
+
+---
+
+_Created using Whiteport Design Studio (WDS) methodology_
diff --git a/_bmad-output/C-UX-Scenarios/00-ux-scenarios.md b/_bmad-output/C-UX-Scenarios/00-ux-scenarios.md
new file mode 100644
index 0000000..0a5c966
--- /dev/null
+++ b/_bmad-output/C-UX-Scenarios/00-ux-scenarios.md
@@ -0,0 +1,42 @@
+# UX Scenarios: CalcText
+
+> Design experiences, not screens — every page serves a user with a goal and an emotion.
+
+**Created:** 2026-03-17
+**Phase:** 3 (Scenario Outline) + Phase 4 (UX Design)
+**Agents:** Freya (Page Specifications)
+
+---
+
+## Scenarios
+
+| # | Scenario | Pages | Description |
+|---|----------|-------|-------------|
+| 01 | Workspace Shell | App Shell, Status Bar | The overall 3-panel layout that contains everything |
+| 02 | Calculation Experience | Editor, Results Panel | The core — typing calculations, seeing results |
+| 03 | Document Management | Tab Bar, New Document, Rename/Delete | Multi-document workflow |
+| 04 | File Organization | Sidebar, Folder Tree, Templates, Recent/Favorites | Organizing calctext files |
+| 05 | Theming | Theme Picker, Preset Themes, Custom Theme, Accent Color | Personalizing the workspace |
+| 06 | Mobile Experience | Mobile Shell, Mobile Results, Mobile Sidebar | Responsive/touch adaptation |
+
+---
+
+## Page Index
+
+_Updated as page specifications are created during Phase 4._
+
+| Scenario | Page | Status | File |
+|----------|------|--------|------|
+| 01 | App Shell | Specified | 01-workspace-shell/1.1-app-shell/1.1-app-shell.md |
+| 01 | Status Bar | Specified | 01-workspace-shell/1.2-status-bar/1.2-status-bar.md |
+| 02 | Editor | Specified | 02-calculation-experience/2.1-editor/2.1-editor.md |
+| 02 | Results Panel | Specified | 02-calculation-experience/2.2-results-panel/2.2-results-panel.md |
+| 03 | Tab Bar & Document Lifecycle | Specified | 03-document-management/3.1-tab-bar/3.1-tab-bar.md |
+| 04 | Sidebar & File Organization | Specified | 04-file-organization/4.1-sidebar/4.1-sidebar.md |
+| 04 | Templates | Specified | 04-file-organization/4.2-templates/4.2-templates.md |
+| 05 | Theme System | Specified | 05-theming/5.1-theme-system/5.1-theme-system.md |
+| 06 | Mobile Experience | Specified | 06-mobile-experience/6.1-mobile-shell/6.1-mobile-shell.md |
+
+---
+
+_Created using Whiteport Design Studio (WDS) methodology_
diff --git a/_bmad-output/C-UX-Scenarios/01-workspace-shell/1.1-app-shell/1.1-app-shell.md b/_bmad-output/C-UX-Scenarios/01-workspace-shell/1.1-app-shell/1.1-app-shell.md
new file mode 100644
index 0000000..dcb375b
--- /dev/null
+++ b/_bmad-output/C-UX-Scenarios/01-workspace-shell/1.1-app-shell/1.1-app-shell.md
@@ -0,0 +1,558 @@
+# 1.1 — App Shell
+
+**Next Step:** → [Status Bar](../1.2-status-bar/1.2-status-bar.md)
+
+---
+
+## Page Metadata
+
+| Property | Value |
+|----------|-------|
+| **Scenario** | 01 — Workspace Shell |
+| **Page Number** | 1.1 |
+| **Platform** | Web (PWA), portable to macOS/Windows |
+| **Page Type** | Full Application Shell |
+| **Viewport** | Desktop-first, responsive to mobile |
+| **Interaction** | Mouse + keyboard (primary), touch (secondary) |
+| **Visibility** | Public (no auth required) |
+
+---
+
+## Overview
+
+**Page Purpose:** The app shell is the top-level container that defines the spatial structure of the entire CalcText workspace. It arranges all panels (sidebar, editor, results), the tab bar, header, and status bar into a cohesive layout that persists across all user interactions.
+
+**User Situation:** User opens CalcText in a browser. They expect a professional workspace where they can write calculations, manage files, and personalize their environment. First-time users should immediately understand the layout. Returning users should find their documents and preferences exactly as they left them.
+
+**Success Criteria:**
+- User understands the 3-panel layout within 3 seconds
+- All panels are resizable and collapsible
+- Layout state persists across sessions (sidebar width, panel visibility)
+- Feels native — not like a web page
+
+**Entry Points:**
+- Direct URL (calctext.app or localhost)
+- PWA launch from desktop/dock
+- Returning session (restores last state from localStorage)
+
+**Exit Points:**
+- Close browser/PWA (state auto-saves)
+- All navigation is within the shell (no page transitions)
+
+---
+
+## Reference Materials
+
+**Strategic Foundation:**
+- [Product Brief](../../../A-Product-Brief/project-brief.md) — Workspace evolution, web-first
+- [Brownfield Analysis](../../../A-Product-Brief/01-brownfield-analysis.md) — Current CSS tokens, components, gaps
+
+**Related Pages:**
+- [Status Bar](../1.2-status-bar/1.2-status-bar.md) — Bottom status strip
+- Tab Bar (Scenario 03) — Document tabs above editor
+- Sidebar (Scenario 04) — File tree panel
+- Editor (Scenario 02) — Calculation editor
+- Results Panel (Scenario 02) — Results display
+
+---
+
+## Layout Structure
+
+The app shell uses a 4-region layout: Header, Sidebar, Main Area (tabs + editor + results), and Status Bar.
+
+### Desktop (>= 768px)
+
+```
+┌─────────────────────────────────────────────────────────┐
+│ Header [Logo] [Theme] [Settings] [⌘] │ 40px
+├────────┬────────────────────────────────────────────────┤
+│ │ Tab Bar [Budget ×] [Invoice ×] [+] │ 36px
+│ ├─────────────────────────┬──┬──────────────────┤
+│ Side │ │ │ │
+│ bar │ Editor │ D│ Results │
+│ │ (CodeMirror 6) │ iv│ Panel │
+│ 240px │ │ │ │
+│ (coll │ │ │ │
+│ apsi │ │ │ │
+│ ble) │ │ │ │
+│ │ │ │ │
+├────────┴─────────────────────────┴──┴──────────────────┤
+│ Status Bar [Ln 12, Col 8] [Engine ●] [Dark 🎨] │ 24px
+└─────────────────────────────────────────────────────────┘
+```
+
+### Tablet (768px — 1024px)
+
+```
+┌─────────────────────────────────────────┐
+│ Header [Logo] [Theme] [≡] │ 40px
+├─────────────────────────────────────────┤
+│ Tab Bar [Budget ×] [Invoice ×] [+] │ 36px
+├─────────────────────────┬──┬────────────┤
+│ │ │ │
+│ Editor │ D│ Results │
+│ (CodeMirror 6) │ iv│ Panel │
+│ │ │ │
+├─────────────────────────┴──┴────────────┤
+│ Status Bar │ 24px
+└─────────────────────────────────────────┘
+Sidebar: overlay drawer via hamburger [≡]
+```
+
+### Mobile (< 768px)
+
+```
+┌─────────────────────────┐
+│ Header [Logo] [≡] │ 44px (touch)
+├─────────────────────────┤
+│ Tab Bar (scrollable) │ 36px
+├─────────────────────────┤
+│ │
+│ Editor │
+│ (full width) │
+│ │
+│ │
+├─────────────────────────┤
+│ Results Tray (toggle) │ 48px collapsed / 40vh expanded
+├─────────────────────────┤
+│ Status Bar │ 24px
+└─────────────────────────┘
+Sidebar: full-screen drawer
+Results: bottom tray with drag handle
+```
+
+---
+
+## Spacing
+
+**Scale:** [Spacing Scale](../../../D-Design-System/00-design-system.md#spacing-scale)
+
+| Property | Token | Pixels (proposed) |
+|----------|-------|--------------------|
+| Page padding (horizontal) | space-zero | 0px (panels fill edge-to-edge) |
+| Header padding (horizontal) | space-md | 12px |
+| Header padding (vertical) | space-xs | 6px |
+| Sidebar width (default) | — | 240px |
+| Sidebar min width | — | 180px |
+| Sidebar max width | — | 400px |
+| Divider width (sidebar ↔ editor) | — | 1px visible, 8px hit area |
+| Divider width (editor ↔ results) | — | 1px visible, 8px hit area |
+| Tab bar height | — | 36px |
+| Tab padding (horizontal) | space-sm | 8px |
+| Status bar height | — | 24px |
+| Status bar padding (horizontal) | space-md | 12px |
+
+---
+
+## Typography
+
+**Scale:** [Type Scale](../../../D-Design-System/00-design-system.md#type-scale)
+
+| Element | Semantic | Size | Weight | Typeface |
+|---------|----------|------|--------|----------|
+| App title (header) | — | text-sm | 600 | system sans |
+| Tab label | — | text-xs | 400 (normal), 500 (active) | system sans |
+| Status bar text | — | text-3xs | 400 | system mono |
+| Sidebar section title | H3 | text-2xs | 600 | system sans |
+| Sidebar file name | — | text-xs | 400 | system sans |
+| Editor content | — | text-md | 400 | system mono |
+| Results value | — | text-md | 400 | system mono |
+
+---
+
+## Page Sections
+
+### Section: Header
+
+**OBJECT ID:** `shell-header`
+
+| Property | Value |
+|----------|-------|
+| Purpose | App identity, global actions, theme quick-switch |
+| Height | 40px (desktop/tablet), 44px (mobile — touch target) |
+| Background | var(--bg) |
+| Border | bottom 1px solid var(--border) |
+| Layout | Horizontal: logo-left, actions-right |
+
+#### Logo Group
+
+**OBJECT ID:** `shell-header-logo`
+
+| Property | Value |
+|----------|-------|
+| Content | CalcText icon + wordmark |
+| Icon size | 20px × 20px |
+| Wordmark | "CalcText" in text-sm, weight 600 |
+| Gap | space-xs (6px) between icon and wordmark |
+| Mobile | Wordmark hidden < 480px, icon only |
+
+#### Header Actions
+
+**OBJECT ID:** `shell-header-actions`
+
+| Property | Value |
+|----------|-------|
+| Layout | Horizontal, space-xs gap |
+| Items | Theme toggle, Settings button, Keyboard shortcuts (⌘) |
+| Button size | 28px × 28px (icon buttons) |
+| Icon size | 16px |
+| Style | Ghost buttons — transparent bg, var(--text) icon, hover → var(--accent-bg) |
+
+#### Theme Toggle (Header)
+
+**OBJECT ID:** `shell-header-theme-toggle`
+
+| Property | Value |
+|----------|-------|
+| Component | Icon button with dropdown |
+| Icon | Sun (light), Moon (dark), Terminal (matrix), Palette (custom) |
+| Click | Opens theme picker dropdown |
+| Tooltip | "Switch theme (Ctrl+Shift+T)" |
+
+#### ↕ `shell-header-v-space-zero` — Header sits flush against tab bar below
+
+---
+
+### Section: Sidebar
+
+**OBJECT ID:** `shell-sidebar`
+
+| Property | Value |
+|----------|-------|
+| Purpose | File navigation, document organization, templates |
+| Width | 240px default, resizable (180–400px), collapsible to 0 |
+| Background | var(--bg-secondary) |
+| Border | right 1px solid var(--border) |
+| Toggle | Cmd/Ctrl+B to show/hide |
+| Resize | Drag right edge, cursor col-resize |
+| Persistence | Width and visibility stored in localStorage |
+
+#### Sidebar Sections
+
+**OBJECT ID:** `shell-sidebar-sections`
+
+| Section | Icon | Content |
+|---------|------|---------|
+| **Recent** | 🕐 | Last 5 opened documents, sorted by time |
+| **Favorites** | ⭐ | User-pinned documents |
+| **Files** | 📁 | Full folder tree with nested structure |
+| **Templates** | 📋 | Pre-built starting points (Budget, Invoice, Unit Converter, Trip Planner, Blank) |
+
+Each section is collapsible with a chevron toggle.
+
+#### File Tree Item
+
+**OBJECT ID:** `shell-sidebar-file-item`
+
+| Property | Value |
+|----------|-------|
+| Height | 28px |
+| Padding left | 12px + (depth × 16px) for nesting |
+| Icon | 📄 file / 📁 folder (closed) / 📂 folder (open) |
+| Label | File/folder name, text-xs, ellipsis on overflow |
+| Hover | var(--accent-bg) background |
+| Active | var(--accent-bg) + left 2px accent border |
+| Right-click | Context menu: Rename, Delete, Duplicate, Move to folder, Add to favorites |
+| Double-click | Opens document in new tab |
+| Drag | Reorder within folder, drag between folders |
+
+#### Sidebar Footer
+
+**OBJECT ID:** `shell-sidebar-footer`
+
+| Property | Value |
+|----------|-------|
+| Content | [+ New Document] button, [+ New Folder] button |
+| Layout | Horizontal, full width, space-xs gap |
+| Button style | Ghost, text-2xs, var(--text), hover → var(--accent) |
+| Position | Sticky bottom of sidebar |
+| Padding | space-sm |
+| Border | top 1px solid var(--border) |
+
+---
+
+### Section: Tab Bar
+
+**OBJECT ID:** `shell-tabbar`
+
+| Property | Value |
+|----------|-------|
+| Purpose | Multi-document navigation, quick tab management |
+| Height | 36px |
+| Background | var(--bg-secondary) |
+| Border | bottom 1px solid var(--border) |
+| Layout | Horizontal scroll when tabs overflow |
+| Position | Between header and editor area |
+
+#### Tab Item
+
+**OBJECT ID:** `shell-tabbar-tab`
+
+| Property | Value |
+|----------|-------|
+| Min width | 100px |
+| Max width | 200px |
+| Padding | 0 space-sm (0 8px) |
+| Height | 36px (fills bar) |
+| Label | Document title, text-xs, ellipsis on overflow |
+| Modified indicator | Dot (6px) before title when unsaved changes |
+| Close button | × icon, 14px, visible on hover or active tab |
+| Active state | var(--bg) background, no bottom border (connected to editor) |
+| Inactive state | var(--bg-secondary), bottom border 1px solid var(--border) |
+| Hover (inactive) | var(--bg-secondary) lightened slightly |
+| Drag | Reorder tabs via drag-and-drop |
+| Middle-click | Close tab |
+| Double-click | Rename document inline |
+
+#### New Tab Button
+
+**OBJECT ID:** `shell-tabbar-new`
+
+| Property | Value |
+|----------|-------|
+| Icon | + (16px) |
+| Size | 36px × 36px (square, fills bar height) |
+| Style | Ghost, var(--text), hover → var(--accent) |
+| Click | Creates "Untitled" document and switches to it |
+| Position | After last tab, before overflow |
+
+#### Tab Overflow
+
+**OBJECT ID:** `shell-tabbar-overflow`
+
+| Property | Value |
+|----------|-------|
+| Trigger | When tabs exceed container width |
+| Behavior | Horizontal scroll with mouse wheel or trackpad |
+| Indicators | Fade gradient on edges when scrollable |
+| Keyboard | Ctrl+Tab / Ctrl+Shift+Tab to cycle tabs |
+
+---
+
+### Section: Main Area
+
+**OBJECT ID:** `shell-main`
+
+| Property | Value |
+|----------|-------|
+| Purpose | Contains the active document's editor and results panel |
+| Layout | Horizontal flex: editor (flex: 3) + divider + results (flex: 1) |
+| Background | var(--bg) |
+| Content | Delegates to Scenario 02 (Editor + Results Panel) |
+
+#### Editor Pane
+
+**OBJECT ID:** `shell-main-editor`
+
+| Property | Value |
+|----------|-------|
+| Flex | 3 (default ~75%) |
+| Min width | 300px |
+| Content | CodeMirror 6 instance (see Scenario 02) |
+| Overflow | Vertical scroll (editor handles internally) |
+
+#### Pane Divider (Editor ↔ Results)
+
+**OBJECT ID:** `shell-main-divider`
+
+| Property | Value |
+|----------|-------|
+| Width | 1px visible line |
+| Hit area | 8px (invisible padding for easy grabbing) |
+| Cursor | col-resize |
+| Color | var(--border), hover → var(--accent) |
+| Transition | background 0.15s |
+| Double-click | Reset to default 75/25 split |
+| Persistence | Position stored in localStorage |
+
+#### Results Pane
+
+**OBJECT ID:** `shell-main-results`
+
+| Property | Value |
+|----------|-------|
+| Flex | 1 (default ~25%) |
+| Min width | 120px |
+| Content | Results panel (see Scenario 02) |
+| Scroll sync | Mirrors editor scroll position |
+
+---
+
+### Section: Status Bar
+
+**OBJECT ID:** `shell-statusbar`
+
+| Property | Value |
+|----------|-------|
+| Purpose | Contextual info: cursor position, engine status, theme indicator |
+| Height | 24px |
+| Background | var(--bg-secondary) |
+| Border | top 1px solid var(--border) |
+| Font | text-3xs, monospace |
+| Color | var(--text) |
+| Layout | Horizontal: left-info + right-info |
+
+Full specification in [1.2 — Status Bar](../1.2-status-bar/1.2-status-bar.md).
+
+---
+
+## Page States
+
+| State | When | Appearance | Actions |
+|-------|------|------------|---------|
+| **First Launch** | No localStorage data | Sidebar open with Templates section expanded. Single tab "Welcome" with demo calctext. Theme follows OS preference. | User can start typing, explore templates, or create new doc |
+| **Returning User** | localStorage has documents | Restores: last open tabs, active tab, sidebar state, theme, divider positions | Resume exactly where they left off |
+| **Empty Workspace** | All documents deleted | Editor shows subtle placeholder: "Create a new document or choose a template to get started" | + New Document button prominent, Templates section highlighted |
+| **Engine Loading** | WASM initializing | Status bar shows "Engine loading..." with pulse animation. Editor is editable. Results show "—" | Editor works, results appear once engine is ready |
+| **Offline** | No network | Subtle indicator in status bar. All features work (localStorage). Currency rates may be stale. | Full functionality, offline banner only if currency conversion attempted |
+
+---
+
+## Interactions & Keyboard Shortcuts
+
+| Shortcut | Action |
+|----------|--------|
+| Cmd/Ctrl + B | Toggle sidebar |
+| Cmd/Ctrl + N | New document |
+| Cmd/Ctrl + W | Close active tab |
+| Cmd/Ctrl + Tab | Next tab |
+| Cmd/Ctrl + Shift + Tab | Previous tab |
+| Cmd/Ctrl + 1–9 | Switch to tab N |
+| Cmd/Ctrl + Shift + T | Open theme picker |
+| Cmd/Ctrl + , | Open settings |
+| Cmd/Ctrl + S | Force save (visual confirmation — auto-save is default) |
+
+---
+
+## Responsive Behavior
+
+| Breakpoint | Layout Changes |
+|------------|----------------|
+| **>= 1024px** | Full 3-panel: sidebar + editor + results |
+| **768–1023px** | Sidebar becomes overlay drawer (hamburger toggle). Editor + results remain side-by-side |
+| **< 768px** | Single column. Sidebar = full-screen drawer. Results = bottom tray (collapsible). Tabs = horizontal scroll. Header = 44px (touch). |
+
+### Mobile Results Tray
+
+| Property | Value |
+|----------|-------|
+| Collapsed height | 48px (shows last result + drag handle) |
+| Expanded height | 40vh |
+| Drag handle | 32px × 4px pill, centered, var(--border) |
+| Swipe up | Expand tray |
+| Swipe down | Collapse tray |
+| Tap collapsed | Expand tray |
+
+### Mobile Sidebar Drawer
+
+| Property | Value |
+|----------|-------|
+| Width | 85vw (max 320px) |
+| Background | var(--bg) |
+| Overlay | 50% black backdrop |
+| Animation | Slide from left, 200ms ease-out |
+| Close | Tap backdrop, swipe left, or X button |
+
+---
+
+## Theme Integration
+
+The app shell's colors are entirely driven by CSS custom properties. Switching themes means swapping the property values on `:root`. No component changes needed.
+
+| Theme | --bg | --bg-secondary | --text | --accent | Special |
+|-------|------|----------------|--------|----------|---------|
+| **Light** | #fff | #f8f9fa | #6b6375 | #6366f1 | — |
+| **Dark** | #16171d | #1a1b23 | #9ca3af | #818cf8 | — |
+| **Matrix** | #0a0a0a | #0f0f0f | #00ff41 | #00ff41 | Monospace everywhere. Subtle scanline overlay. Cursor blink green. |
+| **Custom** | User-defined | User-defined | User-defined | User-defined | Accent color picker + base tone (warm/cool/neutral) |
+
+Theme selection persisted in localStorage as `calctext-theme`.
+
+---
+
+## localStorage Schema
+
+```typescript
+interface CalcTextStorage {
+ // Documents
+ documents: Document[]
+ folders: Folder[]
+ activeTabId: string
+ openTabIds: string[]
+
+ // Layout
+ sidebarWidth: number
+ sidebarVisible: boolean
+ dividerPosition: number // percentage
+
+ // Preferences
+ theme: 'light' | 'dark' | 'matrix' | string // string for custom
+ customTheme?: ThemeTokens
+ accentColor?: string
+
+ // State
+ lastOpenedAt: string // ISO timestamp
+}
+
+interface Document {
+ id: string
+ title: string
+ content: string
+ folderId: string | null
+ isFavorite: boolean
+ createdAt: string
+ updatedAt: string
+}
+
+interface Folder {
+ id: string
+ name: string
+ parentId: string | null
+ order: number
+}
+```
+
+---
+
+## Technical Notes
+
+- **Cross-platform portability:** All layout patterns map to native equivalents. Sidebar → NavigationView (SwiftUI) / side_panel (iced). Tab bar → TabView. Status bar → standard OS status bar pattern.
+- **Performance:** Only the active tab's CodeMirror instance should be in DOM. Inactive tabs store content in memory, restore on switch.
+- **Auto-save:** Documents save to localStorage on every change (debounced 500ms). No explicit "save" needed, but Cmd+S provides visual confirmation.
+- **Sidebar resize:** Use ResizeObserver + mouse events. Store width in localStorage. Minimum 180px, maximum 400px.
+- **Tab management:** Maximum suggested tabs: 20. Beyond that, show "too many tabs" hint. No hard limit.
+
+---
+
+## Open Questions
+
+| # | Question | Context | Status |
+|---|----------|---------|--------|
+| 1 | Should Matrix theme have a subtle CRT scanline effect? | Could be fun but might impact readability | 🔴 Open |
+| 2 | Should we support tab groups / workspaces? | Multiple sets of tabs for different projects | 🔴 Open — defer to v2 |
+| 3 | Max localStorage size for documents? | ~5MB browser limit. Need strategy for large collections. | 🟡 In Discussion — may need IndexedDB |
+
+---
+
+## Checklist
+
+- [x] Page purpose clear
+- [x] All Object IDs assigned
+- [x] Layout structure defined (desktop, tablet, mobile)
+- [x] Spacing tokens documented
+- [x] Typography scale applied
+- [x] States documented (first launch, returning, empty, loading, offline)
+- [x] Keyboard shortcuts defined
+- [x] Responsive breakpoints specified
+- [x] Theme integration documented
+- [x] localStorage schema defined
+- [x] Cross-platform portability noted
+- [x] Open questions captured
+
+---
+
+**Next Step:** → [Status Bar](../1.2-status-bar/1.2-status-bar.md)
+
+---
+
+_Created using Whiteport Design Studio (WDS) methodology_
diff --git a/_bmad-output/C-UX-Scenarios/01-workspace-shell/1.2-status-bar/1.2-status-bar.md b/_bmad-output/C-UX-Scenarios/01-workspace-shell/1.2-status-bar/1.2-status-bar.md
new file mode 100644
index 0000000..2436410
--- /dev/null
+++ b/_bmad-output/C-UX-Scenarios/01-workspace-shell/1.2-status-bar/1.2-status-bar.md
@@ -0,0 +1,124 @@
+# 1.2 — Status Bar
+
+**Previous Step:** ← [App Shell](../1.1-app-shell/1.1-app-shell.md)
+
+---
+
+## Page Metadata
+
+| Property | Value |
+|----------|-------|
+| **Scenario** | 01 — Workspace Shell |
+| **Page Number** | 1.2 |
+| **Platform** | Web (PWA), portable to macOS/Windows |
+| **Page Type** | Persistent UI Strip (embedded in App Shell) |
+| **Viewport** | All breakpoints |
+
+---
+
+## Overview
+
+**Page Purpose:** Provide at-a-glance contextual information about the current document, engine status, and workspace state. Acts as a persistent information bar at the bottom of the workspace.
+
+**User Situation:** User is working in the editor and glances down for context — current line/column, whether the engine is ready, what theme is active, and document stats.
+
+**Success Criteria:**
+- Information readable at a glance without interrupting flow
+- No interactive elements that accidentally trigger (info-only, with 2 clickable shortcuts)
+- Consistent across all themes
+
+---
+
+## Layout Structure
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│ [Ln 12, Col 8] [42 lines] │ [Engine ● Ready] [Dark 🎨] │
+│ ← left-aligned info │ right-aligned info → │
+└──────────────────────────────────────────────────────────────┘
+```
+
+Height: 24px. Full width. Background: var(--bg-secondary). Border-top: 1px solid var(--border).
+
+---
+
+## Spacing
+
+| Property | Token | Pixels |
+|----------|-------|--------|
+| Height | — | 24px |
+| Padding horizontal | space-md | 12px |
+| Item gap | space-md | 12px |
+| Dot size (engine status) | — | 6px |
+| Dot margin-right | space-2xs | 4px |
+
+---
+
+## Typography
+
+| Element | Size | Weight | Typeface | Color |
+|---------|------|--------|----------|-------|
+| All status text | text-3xs | 400 | system mono | var(--text) |
+| Engine status (ready) | text-3xs | 400 | system mono | var(--success) |
+| Engine status (loading) | text-3xs | 400 | system mono | var(--warning) |
+| Theme name | text-3xs | 500 | system mono | var(--text) |
+
+---
+
+## Status Items
+
+### Left Group
+
+**OBJECT ID:** `statusbar-left`
+
+| Item | Content | Update Trigger |
+|------|---------|----------------|
+| Cursor position | `Ln {line}, Col {col}` | Cursor movement |
+| Line count | `{n} lines` | Document change |
+| Selection (conditional) | `{n} selected` | When text selected |
+
+### Right Group
+
+**OBJECT ID:** `statusbar-right`
+
+| Item | Content | Behavior |
+|------|---------|----------|
+| Engine status | `● Ready` (green dot) or `◌ Loading...` (amber, pulse) | Auto-updates on engine state change |
+| Theme indicator | `{Theme Name} 🎨` | Click → opens theme picker (same as header button) |
+| Offline (conditional) | `📡 Offline` | Only visible when offline |
+
+---
+
+## States
+
+| State | Engine Indicator | Additional |
+|-------|-----------------|------------|
+| Ready | ● green dot, "Ready" | — |
+| Loading | ◌ amber dot, pulse animation, "Loading..." | — |
+| Error | ● red dot, "Engine error" | Tooltip with error message |
+| Offline | ● green dot, "Ready" | + "📡 Offline" appended |
+
+---
+
+## Responsive
+
+| Breakpoint | Behavior |
+|------------|----------|
+| >= 768px | Full status bar, all items visible |
+| < 768px | Simplified: cursor position + engine dot only. Theme and line count hidden. |
+
+---
+
+## Technical Notes
+
+- Status bar reads from editor state (cursor, selection, line count) and engine hook (ready, error)
+- Theme indicator is the only clickable element — keeps status bar non-disruptive
+- On macOS/Windows: maps to native status bar or window title bar info
+
+---
+
+**Previous Step:** ← [App Shell](../1.1-app-shell/1.1-app-shell.md)
+
+---
+
+_Created using Whiteport Design Studio (WDS) methodology_
diff --git a/_bmad-output/C-UX-Scenarios/02-calculation-experience/2.1-editor/2.1-editor.md b/_bmad-output/C-UX-Scenarios/02-calculation-experience/2.1-editor/2.1-editor.md
new file mode 100644
index 0000000..1d6e791
--- /dev/null
+++ b/_bmad-output/C-UX-Scenarios/02-calculation-experience/2.1-editor/2.1-editor.md
@@ -0,0 +1,302 @@
+# 2.1 — Editor
+
+**Next Step:** → [Results Panel](../2.2-results-panel/2.2-results-panel.md)
+
+---
+
+## Page Metadata
+
+| Property | Value |
+|----------|-------|
+| **Scenario** | 02 — Calculation Experience |
+| **Page Number** | 2.1 |
+| **Platform** | Web (PWA), portable to macOS/Windows |
+| **Page Type** | Embedded Panel (within App Shell main area) |
+| **Viewport** | All breakpoints |
+| **Interaction** | Keyboard-primary, mouse secondary |
+
+---
+
+## Overview
+
+**Page Purpose:** The editor is where calculations happen. Users type natural-language math expressions, define variables, add comments, and organize their thinking. It must feel like a fast, responsive text editor — not a calculator widget.
+
+**User Situation:** User is actively thinking through numbers. They're writing a budget, converting units, figuring out a mortgage, or doing quick math. The editor must never get in their way — every keystroke should feel instant.
+
+**Success Criteria:**
+- Typing latency < 16ms (60fps)
+- Results update within 50ms of pause (debounce)
+- Syntax highlighting aids comprehension without distraction
+- Errors are visible but non-intrusive
+- Visual hierarchy makes 50-line documents scannable
+
+**Entry Points:**
+- Opening a document (tab click, sidebar double-click, new document)
+- Returning to active document
+
+**Exit Points:**
+- Switching tabs (editor content swaps)
+- Closing document
+
+---
+
+## Reference Materials
+
+**Existing Implementation:**
+- `calcpad-web/src/editor/CalcEditor.tsx` — Current CodeMirror wrapper
+- `calcpad-web/src/editor/calcpad-language.ts` — Syntax highlighting
+- `calcpad-web/src/editor/error-display.ts` — Error underlines + gutter
+- `calcpad-web/src/editor/inline-results.ts` — Zebra striping
+
+**Design System:**
+- [Spacing Scale](../../../D-Design-System/00-design-system.md#spacing-scale)
+- [Type Scale](../../../D-Design-System/00-design-system.md#type-scale)
+
+---
+
+## Layout Structure
+
+```
+┌──────────────────────────────────────────┐
+│ [Gutter] [Line Content] │
+│ │
+│ 1 │ # Monthly Budget │ ← heading (bold, larger)
+│ 2 │ │ ← empty line
+│ 3 │ // Income │ ← comment (muted)
+│ 4 │ salary = 5000 │ ← variable assignment
+│ 5 │ freelance = 1200 │ ← variable assignment
+│ 6 │ total_income = salary + freelance │ ← expression
+│ 7 │ │
+│ 8 │ // Expenses │ ← comment
+│ 9 │ rent = 1500 │
+│ 10 │ groceries = 400 │
+│ 11 │ utilities = rent * 5% ̰ ̰ ̰ ̰ │ ← error underline
+│ 12 │ │
+│ 13 │ total_expenses = sum │ ← aggregator
+│ 14 │ savings = total_income - total_exp │
+│ │ │
+│ │ │
+└──────────────────────────────────────────┘
+ Zebra striping on alternating lines
+```
+
+---
+
+## Spacing
+
+| Property | Token | Pixels |
+|----------|-------|--------|
+| Content padding top/bottom | space-sm | 8px |
+| Line padding horizontal | space-md | 12px |
+| Gutter width | — | 40px (auto-expands for > 999 lines) |
+| Gutter padding right | space-xs | 6px |
+| Line height | — | 24px (15px font × 1.6) |
+| Error gutter width | — | 20px |
+
+**Change from current:** Reduced line padding from 16px → 12px to match macOS's tighter feel.
+
+---
+
+## Typography
+
+| Element | Size | Weight | Typeface | Color |
+|---------|------|--------|----------|-------|
+| Line content | text-md (15px) | 400 | system mono | var(--text) |
+| Headings (# lines) | text-md (15px) | 700 | system mono | var(--text-h) |
+| Comments (// lines) | text-md (15px) | 400 italic | system mono | var(--text) at 50% opacity |
+| Variable names | text-md (15px) | 400 | system mono | var(--syntax-variable) |
+| Numbers | text-md (15px) | 400 | system mono | var(--syntax-number) |
+| Operators | text-md (15px) | 400 | system mono | var(--syntax-operator) |
+| Keywords | text-md (15px) | 500 | system mono | var(--syntax-keyword) |
+| Functions | text-md (15px) | 400 | system mono | var(--syntax-function) |
+| Currency symbols | text-md (15px) | 400 | system mono | var(--syntax-currency) |
+| Line numbers (gutter) | text-xs (13px) | 400 | system mono | var(--text) at 40% opacity |
+| Active line number | text-xs (13px) | 600 | system mono | var(--text) |
+
+---
+
+## Syntax Highlighting Tokens
+
+New CSS custom properties for syntax colors, per-theme:
+
+| Token | Light | Dark | Matrix |
+|-------|-------|------|--------|
+| --syntax-variable | #4f46e5 (indigo-600) | #a5b4fc (indigo-300) | #00ff41 |
+| --syntax-number | #0d9488 (teal-600) | #5eead4 (teal-300) | #00cc33 |
+| --syntax-operator | #6b6375 (text) | #9ca3af (text) | #00ff41 |
+| --syntax-keyword | #7c3aed (violet-600) | #c4b5fd (violet-300) | #39ff14 |
+| --syntax-function | #2563eb (blue-600) | #93c5fd (blue-300) | #00ff41 |
+| --syntax-currency | #d97706 (amber-600) | #fcd34d (amber-300) | #ffff00 |
+| --syntax-comment | rgba(text, 0.5) | rgba(text, 0.5) | rgba(#00ff41, 0.4) |
+| --syntax-heading | var(--text-h) | var(--text-h) | #00ff41 |
+| --syntax-error | #e53e3e | #fc8181 | #ff0000 |
+
+---
+
+## Visual Hierarchy Improvements
+
+### Headings (`# lines`)
+
+| Property | Value |
+|----------|-------|
+| Font weight | 700 (bold) |
+| Color | var(--text-h) — strongest text color |
+| Top margin | 8px extra (visual section break) — only if preceded by non-empty line |
+| Bottom margin | 0 (heading belongs to content below) |
+| Background | None (clean) |
+
+### Comments (`// lines`)
+
+| Property | Value |
+|----------|-------|
+| Font style | Italic |
+| Opacity | 50% of text color |
+| No background stripe | Comments skip zebra striping (visually distinct already) |
+
+### Empty Lines
+
+| Property | Value |
+|----------|-------|
+| Height | 24px (same as content lines) |
+| Background | Normal zebra stripe pattern |
+| Purpose | Visual breathing room, section separators |
+
+### Active Line
+
+| Property | Value |
+|----------|-------|
+| Background | var(--accent-bg) — subtle accent tint |
+| Gutter | Line number bold + full opacity |
+| Transition | background 0.1s |
+
+---
+
+## Error Display
+
+### Error Underline
+
+**OBJECT ID:** `editor-error-underline`
+
+| Property | Value |
+|----------|-------|
+| Style | wavy underline |
+| Color | var(--syntax-error) |
+| Thickness | 1.5px |
+| Scope | Underlines the specific token/expression that errored |
+
+### Error Gutter Marker
+
+**OBJECT ID:** `editor-error-gutter`
+
+| Property | Value |
+|----------|-------|
+| Icon | ⚠ (warning triangle) |
+| Size | 14px |
+| Color | var(--syntax-error) |
+| Position | Error gutter column (20px wide, left of line numbers) |
+| Hover | Tooltip with error message text |
+
+### Error Tooltip
+
+**OBJECT ID:** `editor-error-tooltip`
+
+| Property | Value |
+|----------|-------|
+| Trigger | Hover over error gutter marker OR underlined text |
+| Background | var(--bg-secondary) |
+| Border | 1px solid var(--border) |
+| Border-radius | 4px |
+| Padding | 4px 8px |
+| Font | text-xs, system sans, var(--syntax-error) |
+| Shadow | 0 2px 8px rgba(0,0,0,0.15) |
+| Max width | 300px |
+| Position | Below the error line, left-aligned with gutter |
+
+---
+
+## Zebra Striping
+
+| Property | Value |
+|----------|-------|
+| Pattern | Even-numbered lines (matching current implementation) |
+| Light mode | rgba(0, 0, 0, 0.02) — reduced from 0.025 |
+| Dark mode | rgba(255, 255, 255, 0.025) — reduced from 0.035 |
+| Matrix mode | rgba(0, 255, 65, 0.03) — green tint |
+| Skip on | Comment lines (already visually distinct) |
+
+---
+
+## Autocomplete
+
+**OBJECT ID:** `editor-autocomplete`
+
+| Property | Value |
+|----------|-------|
+| Trigger | Typing 2+ characters that match a variable, function, or keyword |
+| Panel | Dropdown below cursor, var(--bg) bg, 1px border, 4px radius |
+| Max items | 8 visible, scroll for more |
+| Item height | 28px |
+| Active item | var(--accent-bg) highlight |
+| Categories | Variables (with last value), Functions (with signature), Keywords, Units, Currencies |
+| Keyboard | ↑↓ to navigate, Tab/Enter to accept, Esc to dismiss |
+| Auto-dismiss | On cursor movement away |
+
+---
+
+## Page States
+
+| State | When | Behavior |
+|-------|------|----------|
+| **Active editing** | User is typing | Debounced eval (50ms). Results update. Auto-save (500ms). |
+| **Idle** | User paused | All results current. Document saved. |
+| **Read-only** | Template preview (future) | No cursor, no editing. Gray overlay on gutter. |
+| **Engine loading** | WASM initializing | Editor is fully editable. Results show "—" until engine ready. |
+| **Large document** | > 500 lines | Viewport rendering only (CodeMirror handles this). Performance warning in status bar if > 1000 lines. |
+
+---
+
+## Interactions
+
+| Action | Behavior |
+|--------|----------|
+| Type expression | Debounced eval → results update in 50ms |
+| Define variable (`x = 5`) | Variable registered, available for autocomplete on subsequent lines |
+| Reference variable | Autocomplete suggests matching variables with their current values |
+| Use aggregator (`sum`, `total`) | Aggregates all numeric results above (up to previous heading or empty line block) |
+| Line reference (`#3`) | References result of line 3 |
+| Comment (`// text`) | Line excluded from evaluation, styled as comment |
+| Heading (`# text`) | Section header, not evaluated, used for visual grouping and aggregator scoping |
+| Select text | Selection count shown in status bar |
+| Cmd/Ctrl+Z | Undo (per-document history preserved while tab is open) |
+| Cmd/Ctrl+Shift+Z | Redo |
+| Cmd/Ctrl+D | Duplicate current line |
+| Cmd/Ctrl+/ | Toggle comment on current line |
+| Alt+↑/↓ | Move line up/down |
+
+---
+
+## Technical Notes
+
+- **CodeMirror instance management:** One instance per active tab. Inactive tabs store EditorState (preserves undo history). On tab switch, create new EditorView with stored state.
+- **Eval debounce:** 50ms (current). Consider making configurable in settings (0–200ms range).
+- **Syntax highlighting performance:** StreamLanguage parser runs synchronously — fine for < 1000 lines. For very large documents, consider switching to Lezer grammar.
+- **Font scaling:** Expose font size setting (12–24px range) in settings. Current 15px is good default. Store in localStorage.
+- **Cross-platform:** CodeMirror handles keyboard differences (Cmd vs Ctrl). Same extension stack works everywhere via WASM.
+
+---
+
+## Open Questions
+
+| # | Question | Context | Status |
+|---|----------|---------|--------|
+| 1 | Should headings have extra top margin? | Creates visual sections but breaks 1:1 line alignment with results panel | 🟡 In Discussion — likely yes, results panel adjusts |
+| 2 | Should comments skip zebra striping? | Makes them more visually distinct but breaks the pattern | 🔴 Open |
+| 3 | Font size as user preference? | 12–24px range slider in settings | 🟢 Resolved: Yes, default 15px |
+
+---
+
+**Next Step:** → [Results Panel](../2.2-results-panel/2.2-results-panel.md)
+
+---
+
+_Created using Whiteport Design Studio (WDS) methodology_
diff --git a/_bmad-output/C-UX-Scenarios/02-calculation-experience/2.2-results-panel/2.2-results-panel.md b/_bmad-output/C-UX-Scenarios/02-calculation-experience/2.2-results-panel/2.2-results-panel.md
new file mode 100644
index 0000000..c5bde37
--- /dev/null
+++ b/_bmad-output/C-UX-Scenarios/02-calculation-experience/2.2-results-panel/2.2-results-panel.md
@@ -0,0 +1,302 @@
+# 2.2 — Results Panel
+
+**Previous Step:** ← [Editor](../2.1-editor/2.1-editor.md)
+
+---
+
+## Page Metadata
+
+| Property | Value |
+|----------|-------|
+| **Scenario** | 02 — Calculation Experience |
+| **Page Number** | 2.2 |
+| **Platform** | Web (PWA), portable to macOS/Windows |
+| **Page Type** | Embedded Panel (within App Shell main area) |
+| **Viewport** | Desktop + tablet (side panel), mobile (bottom tray) |
+
+---
+
+## Overview
+
+**Page Purpose:** Display calculation results aligned line-by-line with the editor. Results are the reason the product exists — they must be clear, informative, and subtly styled so they complement the editor without competing for attention.
+
+**User Situation:** User is typing in the editor and their eyes flick right to see results. This happens dozens of times per session. The results must be instantly scannable — the eye should find the answer in milliseconds.
+
+**Success Criteria:**
+- Every result line aligns pixel-perfectly with its editor line
+- Results are readable but visually secondary to the editor
+- Different result types are distinguishable at a glance
+- Scroll synchronization is seamless
+
+**Entry Points:**
+- Always visible on desktop/tablet (side panel)
+- Toggle on mobile (bottom tray)
+
+---
+
+## Layout Structure
+
+### Desktop/Tablet (side panel)
+
+```
+┌──────────────────────────┐
+│ │
+│ ──── │ ← heading (no result)
+│ │ ← empty
+│ ──── │ ← comment (no result)
+│ 5,000 │ ← number
+│ 1,200 │ ← number
+│ 6,200 │ ← expression result
+│ │ ← empty
+│ ──── │ ← comment
+│ 1,500 │ ← number
+│ 400 │ ← number
+│ ⚠ error │ ← error (muted, not red)
+│ │ ← empty
+│ 1,900 │ ← aggregator result
+│ 4,300 │ ← expression result
+│ │
+└──────────────────────────┘
+ Right-aligned, zebra striping matches editor
+```
+
+### Mobile (bottom tray)
+
+```
+┌─────────────────────────────┐
+│ ═══ (drag handle) │ 48px collapsed
+│ Last result: 4,300 │
+├─────────────────────────────┤
+│ Ln 5: salary = 5000 │ ← expanded: shows
+│ Ln 6: freelance = 1200 │ line + result pairs
+│ Ln 7: total_income = 6200 │ scrollable
+│ ... │
+└─────────────────────────────┘
+```
+
+---
+
+## Spacing
+
+| Property | Token | Pixels |
+|----------|-------|--------|
+| Content padding top/bottom | space-sm | 8px (matches editor) |
+| Result line padding horizontal | space-md | 12px (matches editor) |
+| Result line height | — | 24px (matches editor exactly) |
+| Mobile tray collapsed height | — | 48px |
+| Mobile tray expanded height | — | 40vh |
+| Mobile drag handle | — | 32px × 4px pill |
+
+---
+
+## Typography
+
+| Element | Size | Weight | Typeface | Color |
+|---------|------|--------|----------|-------|
+| Numeric result | text-md (15px) | 400 | system mono | var(--result-number) |
+| Unit value | text-md (15px) | 400 | system mono | var(--result-unit) |
+| Currency value | text-md (15px) | 400 | system mono | var(--result-currency) |
+| DateTime result | text-md (15px) | 400 | system mono | var(--result-datetime) |
+| Boolean result | text-md (15px) | 400 | system mono | var(--result-boolean) |
+| Error hint | text-xs (13px) | 400 | system sans | var(--text) at 30% opacity |
+| Non-result lines | — | — | — | Empty (no text rendered) |
+
+**Design change from current:** Weight reduced from 600 → 400. Color changed from var(--accent) to type-specific semantic colors. This makes results secondary to the editor content, matching macOS behavior.
+
+---
+
+## Result Type Colors
+
+New CSS custom properties per theme:
+
+| Token | Light | Dark | Matrix | Purpose |
+|-------|-------|------|--------|---------|
+| --result-number | #374151 (gray-700) | #d1d5db (gray-300) | #00ff41 | Plain numbers |
+| --result-unit | #0d9488 (teal-600) | #5eead4 (teal-300) | #00cc33 | Values with units (5 kg, 3.2 m) |
+| --result-currency | #d97706 (amber-600) | #fcd34d (amber-300) | #ffff00 | Currency values ($50, €42) |
+| --result-datetime | #7c3aed (violet-600) | #c4b5fd (violet-300) | #39ff14 | Dates and times |
+| --result-boolean | #6366f1 (indigo-500) | #818cf8 (indigo-400) | #00ff41 | true/false |
+| --result-error | var(--text) at 30% | var(--text) at 30% | rgba(#ff0000, 0.3) | Error hint text |
+
+**Why type-specific colors:** Users scanning results can instantly distinguish "that's a currency" from "that's a unit" from "that's a date" without reading the value. The colors are muted (not saturated) so they don't compete with the editor.
+
+---
+
+## Result Display Format
+
+| Result Type | Display Format | Example |
+|-------------|----------------|---------|
+| Number | Formatted with thousand separators | `6,200` |
+| Unit value | Value + unit abbreviation | `2.2 kg` · `156.2 mi` |
+| Currency | Symbol + value | `$4,300` · `€3,857.20` |
+| DateTime | Locale-formatted | `Mar 25, 2026` · `14:30` |
+| TimeDelta | Human-readable | `3 days, 4 hours` |
+| Boolean | Lowercase | `true` · `false` |
+| Comment | Empty line (dash marker) | `────` (subtle horizontal line) |
+| Heading | Empty line (dash marker) | `────` |
+| Empty | Empty line | (blank) |
+| Error | Muted hint | `· error` (tiny, de-emphasized) |
+| Variable assignment | Show assigned value | `5,000` |
+
+**Change from current:** Removed the `= ` prefix before results. Just show the value. Cleaner.
+
+### Comment/Heading Marker
+
+| Property | Value |
+|----------|-------|
+| Content | `────` (4 em dashes, or CSS border-bottom) |
+| Color | var(--border) at 50% opacity |
+| Purpose | Visual separator showing this line has no numeric result |
+| Width | 60% of panel width, right-aligned |
+
+### Error Hint
+
+| Property | Value |
+|----------|-------|
+| Content | `· error` or `· invalid` |
+| Color | var(--text) at 30% opacity |
+| Purpose | Subtle indicator that something went wrong — details are in the editor gutter |
+| Font | text-xs (13px), system sans |
+
+---
+
+## Zebra Striping
+
+| Property | Value |
+|----------|-------|
+| Pattern | Matches editor exactly (same even-line pattern) |
+| Colors | Same as editor per theme |
+| Sync | Uses line index, not DOM position, for consistency |
+
+---
+
+## Scroll Synchronization
+
+| Property | Value |
+|----------|-------|
+| Direction | Editor drives, results follows |
+| Method | `scrollTop` mirroring via passive scroll listener |
+| Latency | < 1 frame (requestAnimationFrame if needed) |
+| Results panel | `overflow-y: hidden` — no independent scrolling |
+| Edge case | If editor has heading margins (extra space), results panel inserts matching spacers |
+
+---
+
+## Result Hover Interaction
+
+**OBJECT ID:** `results-hover`
+
+| Property | Value |
+|----------|-------|
+| Trigger | Mouse hover over a result line |
+| Behavior | Show full precision + unit details in tooltip |
+| Tooltip content | Raw value (full precision), type label, conversion hint |
+| Example | Hover `$4,300` → "4300.00 USD (United States Dollar)" |
+| Example | Hover `2.2 kg` → "2.20462 kg (kilogram) · 4.85 lb" |
+| Style | Same as error tooltip (bg-secondary, border, 4px radius) |
+| Delay | 500ms hover delay (don't trigger on casual mouse movement) |
+
+---
+
+## Result Click Interaction
+
+**OBJECT ID:** `results-click`
+
+| Property | Value |
+|----------|-------|
+| Trigger | Single click on a result value |
+| Behavior | Copy raw value to clipboard |
+| Feedback | Brief flash (0.3s) — result text turns var(--success) then fades back |
+| Tooltip | "Copied!" appears for 1.5s |
+| Accessibility | aria-label="Copy result: {value}" |
+
+---
+
+## Mobile Bottom Tray
+
+**OBJECT ID:** `results-mobile-tray`
+
+### Collapsed State (48px)
+
+| Property | Value |
+|----------|-------|
+| Content | Drag handle + last non-empty result value |
+| Drag handle | 32px × 4px pill, var(--border), centered |
+| Result text | "Last: {value}" or "No results" if empty |
+| Font | text-xs, var(--text) |
+| Background | var(--bg-secondary) |
+| Border | top 1px solid var(--border) |
+| Interaction | Tap or swipe up → expand |
+
+### Expanded State (40vh)
+
+| Property | Value |
+|----------|-------|
+| Content | Scrollable list of all line results paired with their expressions |
+| Item format | `Ln {n}: {expression} → {result}` |
+| Item height | 36px (larger for touch) |
+| Active line | Highlighted with var(--accent-bg) |
+| Tap result | Copy to clipboard (same as desktop click) |
+| Interaction | Swipe down → collapse. Tap backdrop → collapse. |
+| Scroll | Independent scroll (not synced with editor in mobile) |
+
+### Transitions
+
+| Property | Value |
+|----------|-------|
+| Expand/collapse | 200ms ease-out |
+| Spring | Optional subtle overshoot on expand |
+
+---
+
+## Page States
+
+| State | When | Results Display |
+|-------|------|-----------------|
+| **Normal** | Engine ready, results computed | Type-colored values per line |
+| **Engine loading** | WASM initializing | All result lines show `—` in var(--text) at 20% |
+| **Empty document** | No lines in editor | Panel is blank |
+| **All errors** | Every line has errors | All lines show muted `· error` hints |
+| **Stale results** | Document changed, eval pending | Previous results stay visible (no flash/flicker) |
+
+---
+
+## Accessibility
+
+| Feature | Implementation |
+|---------|---------------|
+| ARIA | `role="complementary"`, `aria-label="Calculation results"` |
+| Live updates | `aria-live="polite"` on result container — announces new results |
+| Screen reader | Each result: `aria-label="Line {n}: {expression} equals {result}"` |
+| Color-blind | Result types distinguishable by position + format, not just color |
+| Click feedback | `aria-label="Copied"` announced on clipboard copy |
+| Reduced motion | No flash animation; instant color change for copy feedback |
+
+---
+
+## Technical Notes
+
+- **Result alignment:** Line height must match editor exactly (24px). If editor adds heading margins, results panel must add matching spacer divs.
+- **Rendering:** ResultsPanel receives `EngineLineResult[]` from useEngine hook. Re-renders only changed lines (React key by line index).
+- **Copy to clipboard:** Use `navigator.clipboard.writeText()`. Fall back to textarea trick for older browsers.
+- **Hover tooltip positioning:** Position below the result line, right-aligned with panel. Flip above if near viewport bottom.
+- **Mobile tray:** Use CSS `transform: translateY()` for smooth expand/collapse. Touch events for swipe gesture.
+- **Cross-platform:** Side panel → native split view (macOS/Windows). Mobile tray → not applicable on desktop native.
+
+---
+
+## Open Questions
+
+| # | Question | Context | Status |
+|---|----------|---------|--------|
+| 1 | Should heading lines in results show `────` markers? | Helps visual alignment but adds visual noise | 🔴 Open |
+| 2 | Should copy-on-click copy formatted or raw value? | `$4,300` vs `4300` | 🟡 — Likely raw (more useful for pasting) |
+| 3 | Result hover tooltip — show conversion alternatives? | "2.2 kg · 4.85 lb" on hover | 🟢 Resolved: Yes, useful for unit/currency results |
+
+---
+
+**Previous Step:** ← [Editor](../2.1-editor/2.1-editor.md)
+
+---
+
+_Created using Whiteport Design Studio (WDS) methodology_
diff --git a/_bmad-output/C-UX-Scenarios/03-document-management/3.1-tab-bar/3.1-tab-bar.md b/_bmad-output/C-UX-Scenarios/03-document-management/3.1-tab-bar/3.1-tab-bar.md
new file mode 100644
index 0000000..3576946
--- /dev/null
+++ b/_bmad-output/C-UX-Scenarios/03-document-management/3.1-tab-bar/3.1-tab-bar.md
@@ -0,0 +1,257 @@
+# 3.1 — Tab Bar & Document Lifecycle
+
+**Next Step:** → [Sidebar](../../04-file-organization/4.1-sidebar/4.1-sidebar.md)
+
+---
+
+## Page Metadata
+
+| Property | Value |
+|----------|-------|
+| **Scenario** | 03 — Document Management |
+| **Page Number** | 3.1 |
+| **Platform** | Web (PWA), portable to macOS/Windows |
+| **Page Type** | Embedded Strip (within App Shell, between header and editor) |
+
+---
+
+## Overview
+
+**Page Purpose:** Enable multi-document workflow. Users can open, create, close, reorder, and rename documents via tabs. The tab bar is the primary navigation between open documents.
+
+**Success Criteria:**
+- Switching tabs feels instant (< 50ms)
+- Users never lose work (auto-save before switch)
+- Tab state survives page reload
+- 15+ tabs remain usable (horizontal scroll)
+
+---
+
+## Layout Structure
+
+```
+┌──────────────────────────────────────────────────────────┐
+│ [● Budget ×] [ Invoice ×] [ Unit Conv ×] [+] ··· │ 36px
+└──────────────────────────────────────────────────────────┘
+ ● = unsaved changes dot
+ Active tab: connected to editor (no bottom border)
+ [+] = new document button
+ ··· = overflow gradient when scrollable
+```
+
+---
+
+## Spacing
+
+| Property | Token | Pixels |
+|----------|-------|--------|
+| Tab bar height | — | 36px |
+| Tab padding horizontal | space-sm | 8px |
+| Tab gap | — | 0px (tabs are flush, separated by 1px border) |
+| Tab min width | — | 100px |
+| Tab max width | — | 200px |
+| Close button size | — | 16px × 16px |
+| Close button margin-left | space-xs | 6px |
+| Modified dot size | — | 6px |
+| Modified dot margin-right | space-2xs | 4px |
+| New tab button width | — | 36px (square) |
+
+---
+
+## Typography
+
+| Element | Size | Weight | Color |
+|---------|------|--------|-------|
+| Tab label (active) | text-xs | 500 | var(--text-h) |
+| Tab label (inactive) | text-xs | 400 | var(--text) |
+| Close × | text-xs | 400 | var(--text) at 50%, hover → var(--text) |
+| New + icon | text-sm | 300 | var(--text), hover → var(--accent) |
+
+---
+
+## Tab States
+
+### Active Tab
+
+| Property | Value |
+|----------|-------|
+| Background | var(--bg) — same as editor, creates visual connection |
+| Border bottom | None — tab "opens into" the editor |
+| Border left/right | 1px solid var(--border) |
+| Border top | 2px solid var(--accent) — active indicator |
+| Label | Weight 500, var(--text-h) |
+| Close button | Always visible |
+
+### Inactive Tab
+
+| Property | Value |
+|----------|-------|
+| Background | var(--bg-secondary) |
+| Border bottom | 1px solid var(--border) — closed off from editor |
+| Border left/right | 1px solid var(--border) |
+| Border top | 2px solid transparent |
+| Label | Weight 400, var(--text) |
+| Close button | Visible on hover only |
+
+### Hover (Inactive)
+
+| Property | Value |
+|----------|-------|
+| Background | Blend between bg-secondary and bg (subtle lighten) |
+| Transition | background 0.1s |
+
+### Dragging
+
+| Property | Value |
+|----------|-------|
+| Appearance | Tab lifts with subtle shadow (0 2px 8px rgba(0,0,0,0.15)) |
+| Opacity | 90% |
+| Placeholder | 2px var(--accent) vertical line at insertion point |
+| Cursor | grabbing |
+
+---
+
+## Interactions
+
+| Action | Behavior |
+|--------|----------|
+| **Click tab** | Switch to document. Auto-save current. Restore editor state (content, cursor, scroll, undo). |
+| **Click +** | Create "Untitled" doc, open in new tab, focus editor. |
+| **Click ×** | Close tab. If unsaved and modified, no prompt (auto-saved). Remove from openTabIds. |
+| **Middle-click tab** | Close tab (same as ×). |
+| **Double-click tab** | Inline rename — label becomes input field, Enter confirms, Esc cancels. |
+| **Drag tab** | Reorder. Drop position shown by accent line indicator. |
+| **Ctrl/Cmd+Tab** | Next tab (wraps). |
+| **Ctrl/Cmd+Shift+Tab** | Previous tab (wraps). |
+| **Ctrl/Cmd+W** | Close active tab. If last tab, create new "Untitled". |
+| **Ctrl/Cmd+N** | New document + tab. |
+| **Ctrl/Cmd+1–9** | Jump to tab by position. |
+| **Mouse wheel on tab bar** | Horizontal scroll when tabs overflow. |
+
+---
+
+## Tab Overflow
+
+| Property | Value |
+|----------|-------|
+| Trigger | Total tab width > container width |
+| Scroll | Horizontal, smooth, via mouse wheel or trackpad |
+| Indicators | 16px fade gradient on left/right edges when scrollable |
+| Active tab | Auto-scrolls into view when selected via keyboard |
+| New tab button | Sticky right — always visible outside scroll area |
+
+---
+
+## Document Lifecycle
+
+### Create
+
+| Trigger | Behavior |
+|---------|----------|
+| Click [+] | New doc: `{ id: uuid(), title: "Untitled", content: "", folderId: null }` |
+| Sidebar template click | New doc with template content and suggested title |
+| Ctrl/Cmd+N | Same as [+] |
+
+Title auto-increments: "Untitled", "Untitled 2", "Untitled 3"...
+
+### Rename
+
+| Trigger | Behavior |
+|---------|----------|
+| Double-click tab | Label becomes ` `, pre-selected text, 200px max width |
+| Sidebar right-click → Rename | Same inline editing in sidebar |
+| Enter | Confirm rename, update document and sidebar |
+| Esc | Cancel, revert to previous name |
+| Blur | Confirm (same as Enter) |
+| Empty name | Revert to "Untitled" |
+
+### Delete
+
+| Trigger | Behavior |
+|---------|----------|
+| Sidebar right-click → Delete | Confirmation: "Delete '{title}'? This cannot be undone." |
+| Confirm | Remove from documents[], close tab if open, remove from folder |
+| Cancel | No action |
+| Undo | 5-second toast: "Document deleted. [Undo]" — restores document if clicked |
+
+### Duplicate
+
+| Trigger | Behavior |
+|---------|----------|
+| Sidebar right-click → Duplicate | New doc: same content, title = "{original} (copy)", same folder |
+| Opens in new tab automatically |
+
+### Auto-Save
+
+| Property | Value |
+|----------|-------|
+| Trigger | Document content change |
+| Debounce | 500ms after last keystroke |
+| Storage | localStorage (calctext-documents) |
+| Indicator | Modified dot (●) appears immediately on change, disappears on save |
+| Manual save | Ctrl/Cmd+S shows brief checkmark animation in tab (visual confirmation) |
+
+---
+
+## Modified Indicator
+
+| Property | Value |
+|----------|-------|
+| Shape | Filled circle, 6px |
+| Color | var(--text) at 60% |
+| Position | Before tab label, 4px gap |
+| Appears | On first character change |
+| Disappears | On auto-save completion (500ms debounce) |
+| Animation | Fade in 0.2s |
+
+---
+
+## Context Menu (Right-Click Tab)
+
+| Item | Action |
+|------|--------|
+| Close | Close this tab |
+| Close Others | Close all tabs except this one |
+| Close to the Right | Close all tabs to the right |
+| — | (separator) |
+| Rename | Inline rename |
+| Duplicate | Duplicate document |
+| — | (separator) |
+| Reveal in Sidebar | Scroll sidebar to show this file |
+
+---
+
+## Mobile Adaptations
+
+| Property | Value |
+|----------|-------|
+| Tab bar | Horizontal scroll, touch-friendly |
+| Tab height | 40px (larger touch target) |
+| Close button | Hidden — swipe left on tab to reveal close |
+| New tab | [+] button at far right |
+| Rename | Long-press → context menu → Rename |
+| Reorder | Long-press + drag |
+
+---
+
+## Page States
+
+| State | When | Behavior |
+|-------|------|----------|
+| **Single tab** | Only one document open | Tab still shown (establishes pattern). Close creates new "Untitled". |
+| **Many tabs (>10)** | Heavy usage | Horizontal scroll. Active tab auto-scrolls into view. |
+| **All tabs closed** | User closed everything | Auto-create "Untitled" tab (workspace never empty). |
+| **First launch** | No localStorage | Single "Welcome" tab with demo content. |
+
+---
+
+## Technical Notes
+
+- **Tab switching performance:** Store `EditorState` per tab in memory. On switch: destroy current EditorView, create new with stored state. Content swap < 20ms.
+- **Tab order persistence:** `openTabIds: string[]` in localStorage maintains order.
+- **Cross-platform:** Maps to native tab bars. macOS: NSTabView or custom tab strip. Windows: iced tabs widget.
+- **Max tabs:** Soft limit at 20 with performance hint in status bar. No hard limit.
+
+---
+
+_Created using Whiteport Design Studio (WDS) methodology_
diff --git a/_bmad-output/C-UX-Scenarios/04-file-organization/4.1-sidebar/4.1-sidebar.md b/_bmad-output/C-UX-Scenarios/04-file-organization/4.1-sidebar/4.1-sidebar.md
new file mode 100644
index 0000000..047af92
--- /dev/null
+++ b/_bmad-output/C-UX-Scenarios/04-file-organization/4.1-sidebar/4.1-sidebar.md
@@ -0,0 +1,328 @@
+# 4.1 — Sidebar & File Organization
+
+**Previous Step:** ← [Tab Bar](../../03-document-management/3.1-tab-bar/3.1-tab-bar.md)
+**Next Step:** → [Templates](../4.2-templates/4.2-templates.md)
+
+---
+
+## Page Metadata
+
+| Property | Value |
+|----------|-------|
+| **Scenario** | 04 — File Organization |
+| **Page Number** | 4.1 |
+| **Platform** | Web (PWA), portable to macOS/Windows |
+| **Page Type** | Collapsible Side Panel (within App Shell) |
+
+---
+
+## Overview
+
+**Page Purpose:** Organize and navigate calctext documents. The sidebar provides a persistent file tree with folders, recent files, favorites, and templates — transforming CalcText from a single-use calculator into a workspace where calculations are organized and retrievable.
+
+**Success Criteria:**
+- Users find any document in < 3 seconds
+- Folder hierarchy is intuitive (create, nest, rename, delete)
+- Recent and Favorites provide quick access without browsing
+- Sidebar never feels cluttered even with 50+ documents
+
+---
+
+## Layout Structure
+
+```
+┌──────────────────────┐
+│ 🔍 Search... │ 32px — search bar
+├──────────────────────┤
+│ │
+│ ▸ Recent │ section header (collapsible)
+│ 📄 Budget │
+│ 📄 Quick Math │
+│ 📄 Invoice #42 │
+│ │
+│ ▸ Favorites │ section header
+│ ⭐ Monthly Budget │
+│ ⭐ Tax Calculator │
+│ │
+│ ▾ Files │ section header (expanded)
+│ 📁 Work │ folder
+│ │ 📄 Budget │ file in folder
+│ │ 📄 Invoice │
+│ 📁 Personal │ folder
+│ │ 📁 Travel │ nested folder
+│ │ │ 📄 Trip Cost │
+│ 📄 Scratch │ root-level file
+│ │
+│ ▸ Templates │ section header
+│ │
+├──────────────────────┤
+│ [+ Doc] [+ Folder] │ sticky footer
+└──────────────────────┘
+```
+
+---
+
+## Spacing
+
+| Property | Token | Pixels |
+|----------|-------|--------|
+| Sidebar padding top | space-xs | 6px |
+| Search bar height | — | 32px |
+| Search bar margin | space-xs | 6px all sides |
+| Section header height | — | 28px |
+| Section header padding left | space-sm | 8px |
+| File item height | — | 28px |
+| File item padding left (root) | space-md | 12px |
+| File item indent per depth | — | 16px |
+| File icon size | — | 16px |
+| File icon-to-label gap | space-xs | 6px |
+| Section gap | space-xs | 6px |
+| Footer height | — | 40px |
+| Footer padding | space-xs | 6px |
+
+---
+
+## Typography
+
+| Element | Size | Weight | Color |
+|---------|------|--------|-------|
+| Search placeholder | text-xs | 400 | var(--text) at 50% |
+| Section header | text-2xs | 600 | var(--text) at 70% |
+| File name | text-xs | 400 | var(--text) |
+| File name (active) | text-xs | 500 | var(--text-h) |
+| Folder name | text-xs | 500 | var(--text) |
+| Footer buttons | text-2xs | 400 | var(--text), hover → var(--accent) |
+| File count badge | text-3xs | 400 | var(--text) at 40% |
+
+---
+
+## Search Bar
+
+**OBJECT ID:** `sidebar-search`
+
+| Property | Value |
+|----------|-------|
+| Placeholder | "Search documents..." |
+| Background | var(--bg) |
+| Border | 1px solid var(--border), focus → var(--accent-border) |
+| Border radius | 4px |
+| Icon | 🔍 magnifier, 14px, var(--text) at 40% |
+| Padding | 4px 8px 4px 28px (icon offset) |
+| Behavior | Filters file tree in real-time as user types |
+| Clear | × button appears when text entered |
+| Keyboard | Ctrl/Cmd+P opens/focuses search (like VS Code quick open) |
+| Results | Flat list of matching files, ranked by recency. Highlights matching text. |
+| Empty state | "No documents match '{query}'" |
+
+---
+
+## Section: Recent
+
+**OBJECT ID:** `sidebar-recent`
+
+| Property | Value |
+|----------|-------|
+| Content | Last 5 opened documents, sorted by lastOpened timestamp |
+| Collapsible | Yes, chevron toggle |
+| Default state | Expanded on first launch, remembers toggle |
+| Item display | File icon + name only (no folder path) |
+| Empty state | "No recent documents" in text-3xs, muted |
+| Update trigger | Opening any document pushes it to top, bumps oldest |
+
+---
+
+## Section: Favorites
+
+**OBJECT ID:** `sidebar-favorites`
+
+| Property | Value |
+|----------|-------|
+| Content | User-pinned documents, ordered manually (drag) |
+| Collapsible | Yes |
+| Default state | Collapsed if empty, expanded if has items |
+| Item display | ⭐ icon + name |
+| Add to favorites | Right-click file → "Add to Favorites", or drag file into section |
+| Remove | Right-click → "Remove from Favorites" |
+| Empty state | "Drag files here or right-click → Add to Favorites" |
+
+---
+
+## Section: Files (Tree)
+
+**OBJECT ID:** `sidebar-files`
+
+| Property | Value |
+|----------|-------|
+| Content | Complete folder tree with all documents |
+| Collapsible | Yes (section level) |
+| Default state | Expanded |
+| Sort | Folders first, then files. Alphabetical within each group |
+| Max depth | 3 levels (root → folder → subfolder → files). Prevents over-nesting |
+
+### Folder Item
+
+**OBJECT ID:** `sidebar-folder`
+
+| Property | Value |
+|----------|-------|
+| Icon | 📁 (closed) / 📂 (open) — or chevron ▸/▾ |
+| Click | Toggle expand/collapse |
+| Double-click | Rename inline |
+| Right-click | Context menu |
+| Drag | Reorder within parent. Drop files into folder. |
+| Drop target | Highlight with var(--accent-bg) + 2px dashed var(--accent) border |
+| Badge | File count in parentheses: `Work (3)` — text-3xs, muted |
+
+### File Item
+
+**OBJECT ID:** `sidebar-file`
+
+| Property | Value |
+|----------|-------|
+| Icon | 📄 (default) — could be themed per type later |
+| Click | Open in tab (or switch to existing tab if already open) |
+| Double-click | Open + rename inline |
+| Hover | var(--accent-bg) background |
+| Active | var(--accent-bg) + left 2px solid var(--accent) border |
+| Active = | Currently open in active tab |
+| Open indicator | Subtle dot or underline if open in any tab (even if not active) |
+| Drag | Move between folders. Drag to tab bar to open. |
+| Right-click | Context menu |
+
+### File Context Menu
+
+| Item | Action |
+|------|--------|
+| Open | Open in new tab |
+| Open in New Tab | Open without closing current |
+| — | separator |
+| Rename | Inline rename |
+| Duplicate | Copy with "(copy)" suffix |
+| Add to Favorites | Toggle ⭐ |
+| — | separator |
+| Move to... | Submenu with folder list |
+| — | separator |
+| Delete | Confirm dialog → 5-second undo toast |
+
+### Folder Context Menu
+
+| Item | Action |
+|------|--------|
+| New Document Here | Create file inside this folder |
+| New Subfolder | Create nested folder (max depth 3) |
+| — | separator |
+| Rename | Inline rename |
+| — | separator |
+| Delete Folder | Must be empty. If not: "Move contents to root first." |
+
+---
+
+## Section: Templates
+
+**OBJECT ID:** `sidebar-templates`
+
+| Property | Value |
+|----------|-------|
+| Collapsible | Yes |
+| Default state | Expanded on first launch |
+| Content | Pre-built starting documents |
+
+Full specification in [4.2 — Templates](../4.2-templates/4.2-templates.md).
+
+---
+
+## Sidebar Footer
+
+**OBJECT ID:** `sidebar-footer`
+
+| Property | Value |
+|----------|-------|
+| Position | Sticky bottom |
+| Background | var(--bg-secondary) |
+| Border top | 1px solid var(--border) |
+| Layout | Two buttons side-by-side |
+| Buttons | `[+ Document]` `[+ Folder]` — ghost style |
+| New Document | Creates at root level, opens in tab |
+| New Folder | Creates at root level, inline rename active |
+
+---
+
+## Drag and Drop
+
+| Drag Source | Drop Target | Behavior |
+|-------------|-------------|----------|
+| File | Folder | Move file into folder |
+| File | Between files | Reorder within same folder |
+| File | Tab bar | Open file in new tab |
+| File | Favorites section | Add to favorites |
+| Folder | Between folders | Reorder at same depth |
+| Tab | Sidebar folder | Move document to folder |
+
+### Drop Visual Feedback
+
+| State | Appearance |
+|-------|------------|
+| Valid target hover | var(--accent-bg) background, 2px dashed var(--accent) border |
+| Invalid target | No visual change (drop not accepted) |
+| Insertion line | 2px solid var(--accent) horizontal line at insertion point |
+| Dragging item | 60% opacity, subtle shadow |
+
+---
+
+## Resize Handle
+
+| Property | Value |
+|----------|-------|
+| Position | Right edge of sidebar |
+| Width | 1px visible, 8px hit area |
+| Cursor | col-resize |
+| Color | var(--border), hover/drag → var(--accent) |
+| Constraints | Min 180px, max 400px |
+| Double-click | Reset to default 240px |
+| Persistence | Width stored in localStorage |
+
+---
+
+## Responsive Behavior
+
+| Breakpoint | Behavior |
+|------------|----------|
+| >= 1024px | Persistent side panel, resizable |
+| 768–1023px | Overlay drawer, hamburger toggle in header |
+| < 768px | Full-screen drawer (85vw, max 320px) |
+
+### Mobile Drawer
+
+| Property | Value |
+|----------|-------|
+| Trigger | Hamburger menu (≡) in header |
+| Width | 85vw, max 320px |
+| Overlay | 50% black backdrop |
+| Animation | Slide from left, 200ms ease-out |
+| Close | Tap backdrop, swipe left, or × button top-right |
+| File tap | Opens document, auto-closes drawer |
+
+---
+
+## Page States
+
+| State | When | Behavior |
+|-------|------|----------|
+| **Empty** | No documents or folders | Show: "Welcome! Create your first document or pick a template." + prominent buttons |
+| **Few files** (<5) | Early usage | All sections visible, Templates expanded to encourage exploration |
+| **Many files** (>20) | Power user | Search becomes critical. Sections collapsed by default except Files |
+| **Search active** | User typed in search | Tree replaced by flat filtered list. Sections hidden. |
+| **Dragging** | File/folder being moved | Drop targets highlighted. Invalid areas dimmed. |
+
+---
+
+## Technical Notes
+
+- **Virtual scrolling:** Not needed until 500+ items. Standard DOM rendering is fine for typical usage.
+- **Folder persistence:** `folders: Folder[]` in localStorage with `parentId` for tree structure.
+- **Sort stability:** Alphabetical sort is stable — user manual ordering within a folder stored as `order` field.
+- **Cross-platform:** Maps to NSOutlineView (macOS), Tree widget (iced/Windows). Same data model.
+
+---
+
+_Created using Whiteport Design Studio (WDS) methodology_
diff --git a/_bmad-output/C-UX-Scenarios/04-file-organization/4.2-templates/4.2-templates.md b/_bmad-output/C-UX-Scenarios/04-file-organization/4.2-templates/4.2-templates.md
new file mode 100644
index 0000000..44be56c
--- /dev/null
+++ b/_bmad-output/C-UX-Scenarios/04-file-organization/4.2-templates/4.2-templates.md
@@ -0,0 +1,219 @@
+# 4.2 — Templates
+
+**Previous Step:** ← [Sidebar](../4.1-sidebar/4.1-sidebar.md)
+**Next Step:** → [Theme System](../../05-theming/5.1-theme-system/5.1-theme-system.md)
+
+---
+
+## Page Metadata
+
+| Property | Value |
+|----------|-------|
+| **Scenario** | 04 — File Organization |
+| **Page Number** | 4.2 |
+| **Platform** | Web (PWA), portable to macOS/Windows |
+| **Page Type** | Sidebar Section + Modal (template preview) |
+
+---
+
+## Overview
+
+**Page Purpose:** Provide ready-made starting documents that showcase CalcText's capabilities and help users get productive immediately. Templates are the product's best onboarding tool — they show by example.
+
+**Success Criteria:**
+- First-time user finds a relevant template within 10 seconds
+- Templates demonstrate the product's unique features (variables, units, currencies, aggregators)
+- Using a template creates a new document (never modifies the template)
+
+---
+
+## Template Library
+
+| Template | Description | Showcases |
+|----------|-------------|-----------|
+| **Budget** | Monthly income/expenses with categories | Variables, aggregators (sum, total), percentages |
+| **Invoice** | Service invoice with line items and tax | Variables, multiplication, percentages, currency |
+| **Unit Converter** | Common conversions with examples | Unit expressions (kg to lb, km to mi, °C to °F) |
+| **Trip Planner** | Travel budget with currency conversion | Currency conversion, date math, variables |
+| **Loan Calculator** | Mortgage/loan with monthly payments | Financial functions, percentages, variables |
+| **Blank** | Empty document | — (clean start) |
+
+---
+
+## Sidebar Templates Section
+
+**OBJECT ID:** `sidebar-templates`
+
+| Property | Value |
+|----------|-------|
+| Section header | "Templates" with 📋 icon |
+| Collapsible | Yes |
+| Default | Expanded on first launch, collapsed after first document created |
+| Item height | 32px (slightly taller than files — more padding) |
+| Item icon | Colored dot per template (visual distinction) |
+| Item label | Template name, text-xs |
+| Item sublabel | Brief description, text-3xs, muted, truncated |
+
+### Template Item Interaction
+
+| Action | Behavior |
+|--------|----------|
+| Click | Create new document from template. Title = template name. Opens in new tab. |
+| Hover | Show full description in tooltip |
+| Right-click | "Preview" option |
+
+---
+
+## Template Colors (Icon Dots)
+
+| Template | Dot Color |
+|----------|-----------|
+| Budget | #10b981 (emerald) |
+| Invoice | #6366f1 (indigo) |
+| Unit Converter | #0d9488 (teal) |
+| Trip Planner | #f59e0b (amber) |
+| Loan Calculator | #7c3aed (violet) |
+| Blank | var(--border) (gray) |
+
+---
+
+## Template Content
+
+### Budget Template
+```
+# Monthly Budget
+
+// Income
+salary = 5000
+freelance = 1200
+total_income = salary + freelance
+
+// Housing
+rent = 1500
+utilities = 150
+insurance = 80
+
+// Living
+groceries = 400
+transport = 120
+subscriptions = 45
+
+// Summary
+total_expenses = sum
+savings = total_income - total_expenses
+savings_rate = savings / total_income
+```
+
+### Invoice Template
+```
+# Invoice #001
+
+// Client: [Client Name]
+// Date: [Date]
+
+// Services
+web_design = 2500
+development = 4000
+consulting = 150 * 8
+
+// Expenses
+hosting = 29.99
+domain = 12.00
+
+subtotal = sum
+
+// Tax
+tax_rate = 10%
+tax = subtotal * tax_rate
+total = subtotal + tax
+```
+
+### Unit Converter Template
+```
+# Unit Converter
+
+// Weight
+75 kg in lb
+2.5 lb in kg
+100 g in oz
+
+// Distance
+10 km in mi
+26.2 mi in km
+5280 ft in m
+
+// Temperature
+100 °C in °F
+72 °F in °C
+0 °C in K
+
+// Data
+1 GB in MB
+500 MB in GB
+1 TB in GB
+```
+
+### Trip Planner Template
+```
+# Trip Planner
+
+// Budget
+budget = $3000
+
+// Flights
+flight_out = $450
+flight_back = $380
+
+// Hotel
+nights = 7
+rate_per_night = $120
+hotel_total = nights * rate_per_night
+
+// Daily expenses
+daily_food = $50
+daily_transport = $20
+daily_activities = $35
+daily_total = daily_food + daily_transport + daily_activities
+trip_expenses = daily_total * nights
+
+// Summary
+total_cost = flight_out + flight_back + hotel_total + trip_expenses
+remaining = budget - total_cost
+```
+
+### Loan Calculator Template
+```
+# Loan Calculator
+
+// Loan Details
+principal = 250000
+annual_rate = 6.5%
+years = 30
+
+// Monthly Calculation
+monthly_rate = annual_rate / 12
+num_payments = years * 12
+
+// Monthly Payment
+monthly_payment = principal * (monthly_rate * (1 + monthly_rate) ^ num_payments) / ((1 + monthly_rate) ^ num_payments - 1)
+
+// Total Cost
+total_paid = monthly_payment * num_payments
+total_interest = total_paid - principal
+
+// Summary
+interest_ratio = total_interest / principal
+```
+
+---
+
+## Technical Notes
+
+- Templates are hardcoded in the app (not fetched from server). Stored as string constants.
+- Creating from template: deep copy content, assign new id/title, save to documents array.
+- Future: user-created templates (save document as template). Defer to v2.
+- Cross-platform: same template content across all platforms.
+
+---
+
+_Created using Whiteport Design Studio (WDS) methodology_
diff --git a/_bmad-output/C-UX-Scenarios/05-theming/5.1-theme-system/5.1-theme-system.md b/_bmad-output/C-UX-Scenarios/05-theming/5.1-theme-system/5.1-theme-system.md
new file mode 100644
index 0000000..5f2111d
--- /dev/null
+++ b/_bmad-output/C-UX-Scenarios/05-theming/5.1-theme-system/5.1-theme-system.md
@@ -0,0 +1,371 @@
+# 5.1 — Theme System
+
+**Previous Step:** ← [Templates](../../04-file-organization/4.2-templates/4.2-templates.md)
+**Next Step:** → [Mobile Experience](../../06-mobile-experience/6.1-mobile-shell/6.1-mobile-shell.md)
+
+---
+
+## Page Metadata
+
+| Property | Value |
+|----------|-------|
+| **Scenario** | 05 — Theming |
+| **Page Number** | 5.1 |
+| **Platform** | Web (PWA), portable to macOS/Windows |
+| **Page Type** | Dropdown Panel + Settings Section |
+
+---
+
+## Overview
+
+**Page Purpose:** Enable users to personalize their workspace with preset themes and custom accent colors. Theming is a personality differentiator — especially the Matrix theme, which gives CalcText a unique identity in the calculator app market.
+
+**Success Criteria:**
+- Theme switches instantly (< 16ms, no flash of unstyled content)
+- Matrix theme makes users want to screenshot and share
+- Custom accent color gives ownership feeling
+- Theme persists across sessions
+
+---
+
+## Theme Architecture
+
+All theming works through CSS custom properties on `:root`. Switching themes = swapping property values. No component changes, no re-renders beyond what CSS handles natively.
+
+```typescript
+interface ThemeDefinition {
+ id: string
+ name: string
+ icon: string // emoji or svg
+ tokens: {
+ // Backgrounds
+ bg: string
+ bgSecondary: string
+ codeBg: string
+
+ // Text
+ text: string
+ textH: string
+
+ // Borders
+ border: string
+
+ // Accent
+ accent: string
+ accentBg: string
+ accentBorder: string
+
+ // Semantic
+ success: string
+ warning: string
+ error: string
+
+ // Syntax highlighting
+ syntaxVariable: string
+ syntaxNumber: string
+ syntaxOperator: string
+ syntaxKeyword: string
+ syntaxFunction: string
+ syntaxCurrency: string
+ syntaxComment: string
+ syntaxHeading: string
+
+ // Result colors
+ resultNumber: string
+ resultUnit: string
+ resultCurrency: string
+ resultDatetime: string
+ resultBoolean: string
+
+ // Stripes
+ stripe: string
+
+ // Special
+ fontOverride?: string // for Matrix: force monospace everywhere
+ specialEffect?: string // CSS class for effects like scanlines
+ }
+}
+```
+
+---
+
+## Preset Themes
+
+### Light
+
+| Token | Value |
+|-------|-------|
+| --bg | #ffffff |
+| --bg-secondary | #f8f9fa |
+| --text | #6b6375 |
+| --text-h | #08060d |
+| --border | #e5e4e7 |
+| --accent | #6366f1 |
+| --stripe | rgba(0, 0, 0, 0.02) |
+| Feel | Clean, airy, professional |
+
+### Dark
+
+| Token | Value |
+|-------|-------|
+| --bg | #16171d |
+| --bg-secondary | #1a1b23 |
+| --text | #9ca3af |
+| --text-h | #f3f4f6 |
+| --border | #2e303a |
+| --accent | #818cf8 |
+| --stripe | rgba(255, 255, 255, 0.025) |
+| Feel | Calm, focused, modern |
+
+### Matrix
+
+| Token | Value |
+|-------|-------|
+| --bg | #0a0a0a |
+| --bg-secondary | #0f1a0f |
+| --text | #00ff41 |
+| --text-h | #33ff66 |
+| --border | #003300 |
+| --accent | #00ff41 |
+| --stripe | rgba(0, 255, 65, 0.03) |
+| --syntax-* | Green spectrum (#00cc33 to #39ff14) |
+| --result-currency | #ffff00 (yellow — stands out in green) |
+| fontOverride | `'Courier New', 'Fira Code', monospace` |
+| specialEffect | `matrix-scanlines` |
+| Feel | Iconic, hackery, fun |
+
+#### Matrix Special Effects
+
+**Scanlines (optional, subtle):**
+```css
+.matrix-scanlines::after {
+ content: '';
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ background: repeating-linear-gradient(
+ 0deg,
+ transparent,
+ transparent 2px,
+ rgba(0, 0, 0, 0.08) 2px,
+ rgba(0, 0, 0, 0.08) 4px
+ );
+ z-index: 9999;
+}
+```
+
+**Cursor glow:**
+```css
+.matrix-theme .cm-cursor {
+ border-color: #00ff41;
+ box-shadow: 0 0 4px #00ff41, 0 0 8px rgba(0, 255, 65, 0.3);
+}
+```
+
+**Text glow (subtle):**
+```css
+.matrix-theme .result-value {
+ text-shadow: 0 0 2px rgba(0, 255, 65, 0.3);
+}
+```
+
+### Midnight
+
+| Token | Value |
+|-------|-------|
+| --bg | #0f172a |
+| --bg-secondary | #1e293b |
+| --text | #94a3b8 |
+| --text-h | #e2e8f0 |
+| --border | #334155 |
+| --accent | #38bdf8 (sky-400) |
+| --stripe | rgba(56, 189, 248, 0.03) |
+| Feel | Deep blue, serene, late-night coding |
+
+### Warm
+
+| Token | Value |
+|-------|-------|
+| --bg | #fffbf5 |
+| --bg-secondary | #fef3e2 |
+| --text | #78716c |
+| --text-h | #1c1917 |
+| --border | #e7e5e4 |
+| --accent | #f97316 (orange-500) |
+| --stripe | rgba(249, 115, 22, 0.03) |
+| Feel | Paper-like, warm, comfortable for long sessions |
+
+---
+
+## Theme Picker Dropdown
+
+**OBJECT ID:** `theme-picker`
+
+| Property | Value |
+|----------|-------|
+| Trigger | Click theme icon in header or status bar, or Ctrl+Shift+T |
+| Position | Dropdown below header button, right-aligned |
+| Width | 240px |
+| Background | var(--bg) |
+| Border | 1px solid var(--border) |
+| Border radius | 8px |
+| Shadow | 0 4px 16px rgba(0, 0, 0, 0.15) |
+| Padding | space-xs (6px) |
+| Animation | Scale from 95% + fade, 150ms ease-out |
+| Dismiss | Click outside, Esc, or select theme |
+
+### Theme Picker Layout
+
+```
+┌──────────────────────────┐
+│ Themes │ section header
+│ │
+│ ☀️ Light ✓ │ active indicator
+│ 🌙 Dark │
+│ 💻 Matrix │
+│ 🌊 Midnight │
+│ 📜 Warm │
+│ │
+│ ────────────────────── │ separator
+│ │
+│ Accent Color │ section header
+│ [●][●][●][●][●][●][●] │ color swatches
+│ │
+│ ────────────────────── │
+│ ⚙️ System (auto) │ follows OS preference
+└──────────────────────────┘
+```
+
+### Theme Item
+
+| Property | Value |
+|----------|-------|
+| Height | 36px |
+| Padding | 8px 12px |
+| Layout | Icon + name + optional checkmark |
+| Hover | var(--accent-bg) background, border-radius 4px |
+| Active | Checkmark ✓ on right side, weight 500 |
+| Click | Apply theme instantly, close picker |
+| Preview | On hover, show a 4-color mini-swatch (bg, text, accent, secondary) |
+
+### Color Swatch Preview (on hover)
+
+| Property | Value |
+|----------|-------|
+| Size | 4 circles, 12px each, 4px gap |
+| Colors | bg, bg-secondary, accent, text — of the hovered theme |
+| Position | Inline after theme name |
+
+---
+
+## Accent Color Picker
+
+**OBJECT ID:** `theme-accent-picker`
+
+| Property | Value |
+|----------|-------|
+| Location | Inside theme picker dropdown, below themes |
+| Presets | 7 color swatches in a row |
+| Swatch size | 20px circles, 6px gap |
+| Active swatch | 2px ring in var(--text-h) |
+| Custom | Last swatch is rainbow gradient → opens native color picker |
+| Behavior | Overrides --accent, --accent-bg, --accent-border for current theme |
+| Persistence | Stored as `accentColor` in localStorage |
+
+### Preset Accent Colors
+
+| Name | Value | Hex |
+|------|-------|-----|
+| Indigo (default) | Indigo 500/400 | #6366f1 / #818cf8 |
+| Teal | Teal 500/400 | #14b8a6 / #2dd4bf |
+| Rose | Rose 500/400 | #f43f5e / #fb7185 |
+| Amber | Amber 500/400 | #f59e0b / #fbbf24 |
+| Emerald | Emerald 500/400 | #10b981 / #34d399 |
+| Sky | Sky 500/400 | #0ea5e9 / #38bdf8 |
+| Custom | User pick | Via ` ` |
+
+Each preset has light/dark variants. The correct variant is selected based on current base theme.
+
+---
+
+## System Theme Option
+
+**OBJECT ID:** `theme-system`
+
+| Property | Value |
+|----------|-------|
+| Behavior | Follows OS `prefers-color-scheme` |
+| Matches | Light ↔ Light theme, Dark ↔ Dark theme |
+| Override | Selecting any specific theme disables system following |
+| Re-enable | Select "System (auto)" in picker |
+| Label | "⚙️ System (auto)" with current resolved theme name |
+
+---
+
+## Theme Application Flow
+
+```
+1. User clicks theme → store in localStorage
+2. Apply CSS class to : `data-theme="matrix"`
+3. CSS variables resolve from theme class
+4. All components instantly update (CSS-only, no React re-render)
+5. CodeMirror theme needs manual reconfiguration (dispatch theme compartment)
+6. PWA theme-color meta tag updates for status bar color
+```
+
+### CodeMirror Theme Sync
+
+| Step | Action |
+|------|--------|
+| 1 | Theme changes → dispatch new baseTheme to EditorView |
+| 2 | Syntax highlighting colors update via CSS variables (no extension swap) |
+| 3 | Stripe colors update via CSS |
+| 4 | Active line highlight updates via CSS |
+| 5 | Only the base theme extension needs reconfiguration |
+
+---
+
+## Responsive
+
+| Breakpoint | Theme Picker |
+|------------|-------------|
+| >= 768px | Dropdown panel below header button |
+| < 768px | Bottom sheet (slides up from bottom, 60vh max height) |
+
+### Mobile Bottom Sheet
+
+| Property | Value |
+|----------|-------|
+| Width | 100vw |
+| Max height | 60vh |
+| Border radius | 12px 12px 0 0 (top corners) |
+| Drag handle | 32px × 4px pill, centered |
+| Backdrop | 50% black overlay |
+| Animation | Slide up 200ms ease-out |
+| Close | Swipe down, tap backdrop |
+
+---
+
+## Page States
+
+| State | When | Behavior |
+|-------|------|----------|
+| **First launch** | No theme preference | Follow OS (system). If OS is dark, use Dark. |
+| **Theme selected** | User picked a theme | Applied instantly, persisted. System mode disabled. |
+| **System mode** | User selected "System (auto)" | Listens to OS changes in real-time. |
+| **Custom accent** | User picked accent color | Overrides accent tokens. Works with any base theme. |
+| **Matrix active** | Matrix theme selected | Font override applied. Scanline effect enabled. Green cursor glow. |
+
+---
+
+## Technical Notes
+
+- **No Flash of Unstyled Content (FOUC):** Load theme from localStorage in `
diff --git a/calcpad-web/src/App.tsx b/calcpad-web/src/App.tsx
index 716208d..9c29130 100644
--- a/calcpad-web/src/App.tsx
+++ b/calcpad-web/src/App.tsx
@@ -1,80 +1,338 @@
/**
- * CalcPad main application component.
+ * CalcText main application component.
*
- * Two-column layout:
- * Left: CodeMirror 6 editor with CalcPad syntax highlighting
- * Right: Answer gutter (integrated into CodeMirror) + optional standalone AnswerColumn
- *
- * The WASM engine runs in a Web Worker. On each document change (debounced),
- * the editor sends lines to the worker, which evaluates them and posts back
- * results. Results are fed into the CodeMirror answer gutter extension.
+ * Workspace layout: header → tab bar → editor + results panel.
+ * Multi-document support via document store with localStorage persistence.
*/
-import { useCallback } from 'react'
+import { useCallback, useState, useRef, useEffect } from 'react'
+import type { EditorView } from '@codemirror/view'
import { CalcEditor } from './editor/CalcEditor.tsx'
import { useEngine } from './engine/useEngine.ts'
import { useOnlineStatus } from './hooks/useOnlineStatus.ts'
import { useInstallPrompt } from './hooks/useInstallPrompt.ts'
+import { useTheme } from './hooks/useTheme.ts'
+import { useDocumentStore, loadSidebarState, saveSidebarState } from './hooks/useDocumentStore.ts'
import { OfflineBanner } from './components/OfflineBanner.tsx'
import { InstallPrompt } from './components/InstallPrompt.tsx'
+import { ResultsPanel } from './components/ResultsPanel.tsx'
+import { ThemePicker } from './components/ThemePicker.tsx'
+import { TabBar } from './components/TabBar.tsx'
+import { Sidebar } from './components/Sidebar.tsx'
+import { StatusBar } from './components/StatusBar.tsx'
+import { AlignToolbar } from './components/AlignToolbar.tsx'
+import type { Alignment } from './components/AlignToolbar.tsx'
+import { FormatToolbar } from './components/FormatToolbar.tsx'
+import { MobileResultsTray } from './components/MobileResultsTray.tsx'
import './styles/app.css'
-const INITIAL_DOC = `# CalcPad
-
-// Basic arithmetic
-2 + 3
-10 * 4.5
-100 / 7
-
-// Variables
-price = 49.99
-quantity = 3
-subtotal = price * quantity
-
-// Percentages
-tax = subtotal * 8%
-total = subtotal + tax
-
-// Functions
-sqrt(144)
-2 ^ 10
-`
-
function App() {
const engine = useEngine()
const isOnline = useOnlineStatus()
const installPrompt = useInstallPrompt()
+ const themeCtx = useTheme()
+ const store = useDocumentStore()
+
+ const [editorView, setEditorView] = useState(null)
+ const resultsPanelRef = useRef(null)
+ const [modifiedIds, setModifiedIds] = useState>(new Set())
+ const [editorAlign, setEditorAlign] = useState('left')
+ const [resultsAlign, setResultsAlign] = useState('right')
+ const [formatPreview, setFormatPreview] = useState(true)
+
+ // Sidebar state
+ const [sidebarState, setSidebarState] = useState(loadSidebarState)
+ const setSidebarVisible = useCallback((v: boolean) => {
+ const next = { ...sidebarState, visible: v }
+ setSidebarState(next)
+ saveSidebarState(next)
+ }, [sidebarState])
+ const setSidebarWidth = useCallback((w: number) => {
+ const next = { ...sidebarState, width: w }
+ setSidebarState(next)
+ saveSidebarState(next)
+ }, [sidebarState])
+
+ // Track a key to force CalcEditor remount on tab switch
+ const [editorKey, setEditorKey] = useState(store.activeTabId)
+
+ // Draggable divider state
+ const [dividerX, setDividerX] = useState(null)
+ const isDragging = useRef(false)
const handleDocChange = useCallback(
(lines: string[]) => {
engine.evalSheet(lines)
+ // Persist content
+ const content = lines.join('\n')
+ if (store.activeDoc && content !== store.activeDoc.content) {
+ store.updateContent(store.activeTabId, content)
+ setModifiedIds(prev => {
+ const next = new Set(prev)
+ next.add(store.activeTabId)
+ return next
+ })
+ // Clear modified dot after save debounce
+ setTimeout(() => {
+ setModifiedIds(prev => {
+ const next = new Set(prev)
+ next.delete(store.activeTabId)
+ return next
+ })
+ }, 500)
+ }
},
- [engine.evalSheet],
+ [engine.evalSheet, store.activeTabId, store.activeDoc, store.updateContent],
)
+ // Switch tabs
+ const handleTabClick = useCallback((id: string) => {
+ if (id === store.activeTabId) return
+ store.setActiveTab(id)
+ setEditorKey(id)
+ }, [store.activeTabId, store.setActiveTab])
+
+ // New document
+ const handleNewTab = useCallback(() => {
+ const doc = store.createDocument()
+ setEditorKey(doc.id)
+ }, [store.createDocument])
+
+ // Close tab
+ const handleTabClose = useCallback((id: string) => {
+ store.closeTab(id)
+ // If we closed the active tab, editorKey needs updating
+ if (id === store.activeTabId) {
+ // State will update, trigger effect below
+ }
+ }, [store.closeTab, store.activeTabId])
+
+ // Sync editorKey when activeTabId changes externally (e.g. after close)
+ useEffect(() => {
+ if (editorKey !== store.activeTabId) {
+ setEditorKey(store.activeTabId)
+ }
+ }, [store.activeTabId]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ // Apply editor text alignment via CodeMirror
+ useEffect(() => {
+ if (!editorView) return
+ editorView.dom.style.setProperty('--cm-text-align', editorAlign)
+ }, [editorView, editorAlign])
+
+ // Scroll sync: mirror editor scroll position to results panel
+ useEffect(() => {
+ if (!editorView) return
+ const scroller = editorView.scrollDOM
+ const onScroll = () => {
+ if (resultsPanelRef.current) {
+ resultsPanelRef.current.scrollTop = scroller.scrollTop
+ }
+ }
+ scroller.addEventListener('scroll', onScroll, { passive: true })
+ return () => scroller.removeEventListener('scroll', onScroll)
+ }, [editorView])
+
+ // Draggable divider handlers
+ const onDividerMouseDown = useCallback((e: React.MouseEvent) => {
+ e.preventDefault()
+ isDragging.current = true
+ document.body.style.cursor = 'col-resize'
+ document.body.style.userSelect = 'none'
+ }, [])
+
+ useEffect(() => {
+ function onMouseMove(e: MouseEvent) {
+ if (!isDragging.current) return
+ setDividerX(e.clientX)
+ }
+ function onMouseUp() {
+ if (!isDragging.current) return
+ isDragging.current = false
+ document.body.style.cursor = ''
+ document.body.style.userSelect = ''
+ }
+ document.addEventListener('mousemove', onMouseMove)
+ document.addEventListener('mouseup', onMouseUp)
+ return () => {
+ document.removeEventListener('mousemove', onMouseMove)
+ document.removeEventListener('mouseup', onMouseUp)
+ }
+ }, [])
+
+ // Keyboard shortcuts
+ useEffect(() => {
+ function handleKey(e: KeyboardEvent) {
+ const mod = e.metaKey || e.ctrlKey
+
+ // Ctrl+B — toggle sidebar
+ if (mod && e.key === 'b') {
+ e.preventDefault()
+ setSidebarVisible(!sidebarState.visible)
+ return
+ }
+
+ // Ctrl+N — new document
+ if (mod && e.key === 'n') {
+ e.preventDefault()
+ handleNewTab()
+ return
+ }
+
+ // Ctrl+W — close tab
+ if (mod && e.key === 'w') {
+ e.preventDefault()
+ handleTabClose(store.activeTabId)
+ return
+ }
+
+ // Ctrl+Tab / Ctrl+Shift+Tab — cycle tabs
+ if (mod && e.key === 'Tab') {
+ e.preventDefault()
+ const idx = store.openTabIds.indexOf(store.activeTabId)
+ const len = store.openTabIds.length
+ const next = e.shiftKey
+ ? store.openTabIds[(idx - 1 + len) % len]
+ : store.openTabIds[(idx + 1) % len]
+ handleTabClick(next)
+ return
+ }
+
+ // Ctrl+1-9 — jump to tab
+ if (mod && e.key >= '1' && e.key <= '9') {
+ e.preventDefault()
+ const tabIdx = parseInt(e.key) - 1
+ if (tabIdx < store.openTabIds.length) {
+ handleTabClick(store.openTabIds[tabIdx])
+ }
+ return
+ }
+ }
+
+ document.addEventListener('keydown', handleKey)
+ return () => document.removeEventListener('keydown', handleKey)
+ }, [store.activeTabId, store.openTabIds, handleNewTab, handleTabClose, handleTabClick, sidebarState.visible, setSidebarVisible])
+
+ // Compute flex styles from divider position
+ const editorStyle: React.CSSProperties = dividerX !== null
+ ? { width: dividerX, flex: 'none' }
+ : {}
+ const resultsStyle: React.CSSProperties = dividerX !== null
+ ? { flex: 1 }
+ : {}
+
return (
- CalcPad
- Notepad Calculator
-
-
-
{engine.ready ? 'Engine ready' : 'Loading engine...'}
+
setSidebarVisible(!sidebarState.visible)}
+ title="Toggle sidebar (Ctrl+B)"
+ aria-label="Toggle sidebar"
+ >
+ ☰
+
+
CalcText
+
+
+
setFormatPreview(p => !p)}
+ />
+
+
+
-
-
-
+
+
+ {/* Mobile sidebar backdrop */}
+ {sidebarState.visible && (
+
setSidebarVisible(false)}
/>
-
-
+ )}
+
{ store.openDocument(id); setEditorKey(id) }}
+ onNewDocument={(title, content) => {
+ const doc = store.createDocument(title, content)
+ setEditorKey(doc.id)
+ }}
+ onNewFolder={() => store.createFolder()}
+ onRenameDocument={store.renameDocument}
+ onDeleteDocument={store.deleteDocument}
+ onToggleFavorite={store.toggleFavorite}
+ onMoveToFolder={store.moveToFolder}
+ onRenameFolder={store.renameFolder}
+ onDeleteFolder={store.deleteFolder}
+ onWidthChange={setSidebarWidth}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
void
+ onResultsAlignChange: (align: Alignment) => void
+}
+
+function AlignIcon({ type }: { type: 'left' | 'center' | 'right' }) {
+ if (type === 'left') {
+ return (
+
+
+
+
+
+ )
+ }
+ if (type === 'center') {
+ return (
+
+
+
+
+
+ )
+ }
+ return (
+
+
+
+
+
+ )
+}
+
+const alignments: Alignment[] = ['left', 'center', 'right']
+
+export function AlignToolbar({
+ editorAlign,
+ resultsAlign,
+ onEditorAlignChange,
+ onResultsAlignChange,
+}: AlignToolbarProps) {
+ return (
+
+
+
Editor
+ {alignments.map((a) => (
+
onEditorAlignChange(a)}
+ title={`Align editor ${a}`}
+ >
+
+
+ ))}
+
+
+
+
Results
+ {alignments.map((a) => (
+
onResultsAlignChange(a)}
+ title={`Align results ${a}`}
+ >
+
+
+ ))}
+
+
+ )
+}
diff --git a/calcpad-web/src/components/AnswerColumn.tsx b/calcpad-web/src/components/AnswerColumn.tsx
deleted file mode 100644
index bece72a..0000000
--- a/calcpad-web/src/components/AnswerColumn.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-/**
- * Right-side answer column that displays evaluation results
- * as a standalone panel (alternative to the gutter-based display).
- *
- * This component renders results in a scrollable column synced
- * to the editor's line height. Each line shows the display value
- * from the engine, color-coded by result type.
- */
-
-import type { EngineLineResult } from '../engine/types.ts'
-import '../styles/answer-column.css'
-
-export interface AnswerColumnProps {
- results: EngineLineResult[]
-}
-
-function resultClassName(type: string): string {
- switch (type) {
- case 'number':
- case 'unitValue':
- case 'currencyValue':
- case 'dateTime':
- case 'timeDelta':
- case 'boolean':
- return 'answer-value'
- case 'error':
- return 'answer-error'
- default:
- return 'answer-empty'
- }
-}
-
-export function AnswerColumn({ results }: AnswerColumnProps) {
- return (
-
- {results.map((result, i) => (
-
- {result.type === 'error'
- ? 'Error'
- : result.display || '\u00A0'}
-
- ))}
-
- )
-}
diff --git a/calcpad-web/src/components/FormatToolbar.tsx b/calcpad-web/src/components/FormatToolbar.tsx
new file mode 100644
index 0000000..3b53d62
--- /dev/null
+++ b/calcpad-web/src/components/FormatToolbar.tsx
@@ -0,0 +1,148 @@
+/**
+ * Formatting toolbar for the editor.
+ * Inserts/toggles markdown-like syntax in the CodeMirror editor.
+ */
+
+import type { EditorView } from '@codemirror/view'
+import '../styles/format-toolbar.css'
+
+interface FormatToolbarProps {
+ editorView: EditorView | null
+ previewMode: boolean
+ onPreviewToggle: () => void
+}
+
+function insertPrefix(view: EditorView, prefix: string) {
+ const { state } = view
+ const line = state.doc.lineAt(state.selection.main.head)
+ const lineText = line.text
+
+ if (lineText.startsWith(prefix)) {
+ // Remove prefix
+ view.dispatch({
+ changes: { from: line.from, to: line.from + prefix.length, insert: '' },
+ })
+ } else {
+ // Add prefix
+ view.dispatch({
+ changes: { from: line.from, insert: prefix },
+ })
+ }
+ view.focus()
+}
+
+function wrapSelection(view: EditorView, wrapper: string) {
+ const { state } = view
+ const sel = state.selection.main
+ const selected = state.sliceDoc(sel.from, sel.to)
+
+ if (selected.length === 0) {
+ // No selection — insert wrapper pair and place cursor inside
+ const text = `${wrapper}text${wrapper}`
+ view.dispatch({
+ changes: { from: sel.from, insert: text },
+ selection: { anchor: sel.from + wrapper.length, head: sel.from + wrapper.length + 4 },
+ })
+ } else if (selected.startsWith(wrapper) && selected.endsWith(wrapper)) {
+ // Already wrapped — unwrap
+ const inner = selected.slice(wrapper.length, -wrapper.length)
+ view.dispatch({
+ changes: { from: sel.from, to: sel.to, insert: inner },
+ selection: { anchor: sel.from, head: sel.from + inner.length },
+ })
+ } else {
+ // Wrap selection
+ const text = `${wrapper}${selected}${wrapper}`
+ view.dispatch({
+ changes: { from: sel.from, to: sel.to, insert: text },
+ selection: { anchor: sel.from, head: sel.from + text.length },
+ })
+ }
+ view.focus()
+}
+
+function insertColor(view: EditorView, color: string) {
+ const { state } = view
+ const sel = state.selection.main
+ const selected = state.sliceDoc(sel.from, sel.to)
+ const label = selected.length > 0 ? selected : 'label'
+ const text = `[${color}:${label}]`
+
+ view.dispatch({
+ changes: { from: sel.from, to: sel.to, insert: text },
+ selection: { anchor: sel.from, head: sel.from + text.length },
+ })
+ view.focus()
+}
+
+const COLORS = [
+ { name: 'Red', value: '#ef4444' },
+ { name: 'Orange', value: '#f97316' },
+ { name: 'Yellow', value: '#eab308' },
+ { name: 'Green', value: '#22c55e' },
+ { name: 'Blue', value: '#3b82f6' },
+ { name: 'Purple', value: '#a855f7' },
+]
+
+export function FormatToolbar({ editorView, previewMode, onPreviewToggle }: FormatToolbarProps) {
+ return (
+
+
+
+ 👁
+
+
+
+
+
+
+ editorView && insertPrefix(editorView, '# ')}
+ title="Heading (toggle # prefix)"
+ >
+ H
+
+ editorView && wrapSelection(editorView, '**')}
+ title="Bold (**text**)"
+ >
+ B
+
+ editorView && wrapSelection(editorView, '*')}
+ title="Italic (*text*)"
+ >
+ I
+
+ editorView && insertPrefix(editorView, '// ')}
+ title="Comment (toggle // prefix)"
+ >
+ //
+
+
+
+
+
+
+ {COLORS.map(c => (
+ editorView && insertColor(editorView, c.name.toLowerCase())}
+ title={`${c.name} label`}
+ />
+ ))}
+
+
+ )
+}
diff --git a/calcpad-web/src/components/MobileResultsTray.tsx b/calcpad-web/src/components/MobileResultsTray.tsx
new file mode 100644
index 0000000..b6a4f14
--- /dev/null
+++ b/calcpad-web/src/components/MobileResultsTray.tsx
@@ -0,0 +1,105 @@
+/**
+ * Mobile results tray — replaces the side panel on small screens.
+ * Collapsed: shows last result + drag handle (48px).
+ * Expanded: scrollable list of all results (40vh).
+ */
+
+import { useState, useCallback, useRef } from 'react'
+import type { EngineLineResult } from '../engine/types.ts'
+import '../styles/mobile-results-tray.css'
+
+const DISPLAYABLE_TYPES = new Set([
+ 'number', 'unitValue', 'currencyValue', 'dateTime', 'timeDelta', 'boolean',
+])
+
+interface MobileResultsTrayProps {
+ results: EngineLineResult[]
+ docLines: string[]
+}
+
+export function MobileResultsTray({ results, docLines }: MobileResultsTrayProps) {
+ const [expanded, setExpanded] = useState(false)
+ const [copiedIdx, setCopiedIdx] = useState(null)
+ const startY = useRef(null)
+
+ // Find last displayable result
+ let lastResult = ''
+ for (let i = results.length - 1; i >= 0; i--) {
+ if (DISPLAYABLE_TYPES.has(results[i].type) && results[i].display) {
+ lastResult = results[i].display
+ break
+ }
+ }
+
+ const handleCopy = useCallback((idx: number, rawValue: number | null, display: string) => {
+ const text = rawValue != null ? String(rawValue) : display
+ navigator.clipboard.writeText(text).then(() => {
+ setCopiedIdx(idx)
+ setTimeout(() => setCopiedIdx(null), 1200)
+ }).catch(() => {})
+ }, [])
+
+ // Touch swipe handling
+ const handleTouchStart = useCallback((e: React.TouchEvent) => {
+ startY.current = e.touches[0].clientY
+ }, [])
+
+ const handleTouchEnd = useCallback((e: React.TouchEvent) => {
+ if (startY.current === null) return
+ const deltaY = startY.current - e.changedTouches[0].clientY
+ startY.current = null
+ if (deltaY > 40) setExpanded(true) // swipe up
+ if (deltaY < -40) setExpanded(false) // swipe down
+ }, [])
+
+ // Build result items for expanded view
+ const resultItems = results.map((r, i) => {
+ if (!DISPLAYABLE_TYPES.has(r.type) || !r.display) return null
+ const expr = docLines[i]?.trim() ?? ''
+ const isCopied = copiedIdx === i
+ return (
+ handleCopy(i, r.rawValue, r.display)}
+ >
+ Ln {i + 1}
+ {expr}
+ {isCopied ? 'Copied!' : r.display}
+
+ )
+ }).filter(Boolean)
+
+ return (
+
+ {/* Drag handle + collapsed view */}
+
setExpanded(prev => !prev)}
+ >
+
+ {!expanded && (
+
+ {lastResult ? `Last: ${lastResult}` : 'No results'}
+
+ )}
+ {expanded && (
+
{resultItems.length} results
+ )}
+
+
+ {/* Expanded content */}
+ {expanded && (
+
+ {resultItems.length > 0 ? resultItems : (
+
No results yet
+ )}
+
+ )}
+
+ )
+}
diff --git a/calcpad-web/src/components/ResultsPanel.tsx b/calcpad-web/src/components/ResultsPanel.tsx
new file mode 100644
index 0000000..f69ab74
--- /dev/null
+++ b/calcpad-web/src/components/ResultsPanel.tsx
@@ -0,0 +1,109 @@
+/**
+ * Right-side results panel that displays one result per editor line.
+ *
+ * Each result line matches CodeMirror's 24px line height (15px * 1.6)
+ * so that results visually align with their corresponding expressions.
+ * Scroll position is driven externally via a forwarded ref.
+ */
+
+import { forwardRef, useState, useCallback } from 'react'
+import type { EngineLineResult } from '../engine/types.ts'
+import '../styles/results-panel.css'
+
+const DISPLAYABLE_TYPES = new Set([
+ 'number',
+ 'unitValue',
+ 'currencyValue',
+ 'dateTime',
+ 'timeDelta',
+ 'boolean',
+])
+
+const NON_RESULT_TYPES = new Set(['comment', 'text', 'empty'])
+
+/** Map result type to CSS class for type-specific coloring */
+function resultColorClass(type: string): string {
+ switch (type) {
+ case 'currencyValue': return 'result-currency'
+ case 'unitValue': return 'result-unit'
+ case 'dateTime':
+ case 'timeDelta': return 'result-datetime'
+ case 'boolean': return 'result-boolean'
+ default: return 'result-number'
+ }
+}
+
+export interface ResultsPanelProps {
+ results: EngineLineResult[]
+ align: 'left' | 'center' | 'right'
+ style?: React.CSSProperties
+}
+
+export const ResultsPanel = forwardRef(
+ function ResultsPanel({ results, align, style }, ref) {
+ const [copiedIdx, setCopiedIdx] = useState(null)
+
+ const handleClick = useCallback((idx: number, rawValue: number | null, display: string) => {
+ const text = rawValue != null ? String(rawValue) : display
+ navigator.clipboard.writeText(text).then(() => {
+ setCopiedIdx(idx)
+ setTimeout(() => setCopiedIdx(null), 1200)
+ }).catch(() => { /* clipboard unavailable */ })
+ }, [])
+
+ return (
+
+ {results.map((result, i) => {
+ const isEven = (i + 1) % 2 === 0
+ const stripe = isEven ? ' result-stripe' : ''
+
+ if (DISPLAYABLE_TYPES.has(result.type) && result.display) {
+ const colorClass = resultColorClass(result.type)
+ const isCopied = copiedIdx === i
+
+ return (
+
handleClick(i, result.rawValue, result.display)}
+ title="Click to copy"
+ >
+ {isCopied ? 'Copied!' : result.display}
+
+ )
+ }
+
+ // Error hint
+ if (result.type === 'error' && result.error) {
+ return (
+
+ · error
+
+ )
+ }
+
+ // Comment/heading marker
+ if (result.type === 'comment' || result.type === 'text') {
+ return (
+
+ ────
+
+ )
+ }
+
+ return (
+
+
+
+ )
+ })}
+
+ )
+ },
+)
diff --git a/calcpad-web/src/components/Sidebar.tsx b/calcpad-web/src/components/Sidebar.tsx
new file mode 100644
index 0000000..58da600
--- /dev/null
+++ b/calcpad-web/src/components/Sidebar.tsx
@@ -0,0 +1,519 @@
+import { useState, useRef, useEffect, useCallback, type DragEvent } from 'react'
+import type { CalcDocument, CalcFolder } from '../hooks/useDocumentStore.ts'
+import { TEMPLATES } from '../data/templates.ts'
+import '../styles/sidebar.css'
+
+interface SidebarProps {
+ visible: boolean
+ width: number
+ documents: CalcDocument[]
+ folders: CalcFolder[]
+ activeTabId: string
+ openTabIds: string[]
+ onFileClick: (id: string) => void
+ onNewDocument: (title?: string, content?: string) => void
+ onNewFolder: () => void
+ onRenameDocument: (id: string, title: string) => void
+ onDeleteDocument: (id: string) => void
+ onToggleFavorite: (id: string) => void
+ onMoveToFolder: (docId: string, folderId: string | null) => void
+ onRenameFolder: (id: string, name: string) => void
+ onDeleteFolder: (id: string) => void
+ onWidthChange: (width: number) => void
+}
+
+export function Sidebar({
+ visible,
+ width,
+ documents,
+ folders,
+ activeTabId,
+ openTabIds,
+ onFileClick,
+ onNewDocument,
+ onNewFolder,
+ onRenameDocument,
+ onDeleteDocument,
+ onToggleFavorite,
+ onMoveToFolder,
+ onRenameFolder,
+ onDeleteFolder,
+ onWidthChange,
+}: SidebarProps) {
+ const [search, setSearch] = useState('')
+ const [expandedSections, setExpandedSections] = useState>(
+ () => new Set(['files', 'recent'])
+ )
+ const [expandedFolders, setExpandedFolders] = useState>(() => new Set())
+ const [editingId, setEditingId] = useState(null)
+ const [editValue, setEditValue] = useState('')
+ const [editType, setEditType] = useState<'file' | 'folder'>('file')
+ const [contextMenu, setContextMenu] = useState<{
+ x: number; y: number; type: 'file' | 'folder'; id: string
+ } | null>(null)
+ const inputRef = useRef(null)
+ const resizeRef = useRef<{ startX: number; startWidth: number } | null>(null)
+
+ // Focus rename input
+ useEffect(() => {
+ if (editingId && inputRef.current) {
+ inputRef.current.focus()
+ inputRef.current.select()
+ }
+ }, [editingId])
+
+ // Close context menu on click outside
+ useEffect(() => {
+ if (!contextMenu) return
+ const handler = () => setContextMenu(null)
+ document.addEventListener('click', handler)
+ return () => document.removeEventListener('click', handler)
+ }, [contextMenu])
+
+ // Resize handle
+ useEffect(() => {
+ function onMouseMove(e: MouseEvent) {
+ if (!resizeRef.current) return
+ const newWidth = Math.min(400, Math.max(180, resizeRef.current.startWidth + e.clientX - resizeRef.current.startX))
+ onWidthChange(newWidth)
+ }
+ function onMouseUp() {
+ if (!resizeRef.current) return
+ resizeRef.current = null
+ document.body.style.cursor = ''
+ document.body.style.userSelect = ''
+ }
+ document.addEventListener('mousemove', onMouseMove)
+ document.addEventListener('mouseup', onMouseUp)
+ return () => {
+ document.removeEventListener('mousemove', onMouseMove)
+ document.removeEventListener('mouseup', onMouseUp)
+ }
+ }, [onWidthChange])
+
+ const toggleSection = useCallback((section: string) => {
+ setExpandedSections(prev => {
+ const next = new Set(prev)
+ if (next.has(section)) next.delete(section)
+ else next.add(section)
+ return next
+ })
+ }, [])
+
+ const toggleFolder = useCallback((folderId: string) => {
+ setExpandedFolders(prev => {
+ const next = new Set(prev)
+ if (next.has(folderId)) next.delete(folderId)
+ else next.add(folderId)
+ return next
+ })
+ }, [])
+
+ // Rename — use refs to avoid stale closures in blur/keydown handlers
+ const editRef = useRef<{ id: string; value: string; type: 'file' | 'folder' } | null>(null)
+
+ const startRename = useCallback((id: string, currentName: string, type: 'file' | 'folder') => {
+ editRef.current = { id, value: currentName, type }
+ setEditingId(id)
+ setEditValue(currentName)
+ setEditType(type)
+ setContextMenu(null)
+ }, [])
+
+ const commitRename = useCallback(() => {
+ const edit = editRef.current
+ if (!edit) return
+ if (edit.type === 'file') onRenameDocument(edit.id, edit.value)
+ else onRenameFolder(edit.id, edit.value)
+ editRef.current = null
+ setEditingId(null)
+ }, [onRenameDocument, onRenameFolder])
+
+ // Keep ref in sync when editValue changes
+ const handleEditChange = useCallback((val: string) => {
+ setEditValue(val)
+ if (editRef.current) editRef.current.value = val
+ }, [])
+
+ // Derived data
+ const recentDocs = [...documents]
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
+ .slice(0, 5)
+
+ const favoriteDocs = documents.filter(d => d.isFavorite)
+
+ const rootDocs = documents.filter(d => !d.folderId)
+ .sort((a, b) => a.title.localeCompare(b.title))
+
+ const rootFolders = folders.filter(f => !f.parentId)
+ .sort((a, b) => a.name.localeCompare(b.name))
+
+ const getDocsInFolder = (folderId: string) =>
+ documents.filter(d => d.folderId === folderId).sort((a, b) => a.title.localeCompare(b.title))
+
+ // Drag and drop — use ref to avoid stale closure
+ const [dragId, setDragId] = useState(null)
+ const dragIdRef = useRef(null)
+ const [dropTarget, setDropTarget] = useState(null)
+
+ const handleDragStart = useCallback((e: DragEvent, docId: string) => {
+ dragIdRef.current = docId
+ setDragId(docId)
+ e.dataTransfer.effectAllowed = 'move'
+ e.dataTransfer.setData('text/plain', docId)
+ }, [])
+
+ const handleDragOver = useCallback((e: DragEvent, targetId: string) => {
+ e.preventDefault()
+ e.dataTransfer.dropEffect = 'move'
+ setDropTarget(targetId)
+ }, [])
+
+ const handleDragLeave = useCallback(() => {
+ setDropTarget(null)
+ }, [])
+
+ const handleDrop = useCallback((e: DragEvent, folderId: string | null) => {
+ e.preventDefault()
+ e.stopPropagation()
+ const docId = dragIdRef.current ?? e.dataTransfer.getData('text/plain')
+ if (docId) {
+ onMoveToFolder(docId, folderId)
+ if (folderId) {
+ setExpandedFolders(prev => {
+ const next = new Set(prev)
+ next.add(folderId)
+ return next
+ })
+ }
+ }
+ dragIdRef.current = null
+ setDragId(null)
+ setDropTarget(null)
+ }, [onMoveToFolder])
+
+ const handleDragEnd = useCallback(() => {
+ dragIdRef.current = null
+ setDragId(null)
+ setDropTarget(null)
+ }, [])
+
+ // Search filter
+ const searchResults = search.trim()
+ ? documents.filter(d => d.title.toLowerCase().includes(search.toLowerCase()))
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
+ : null
+
+ if (!visible) return null
+
+ const renderFileItem = (doc: CalcDocument, depth = 0) => {
+ const isActive = doc.id === activeTabId
+ const isOpen = openTabIds.includes(doc.id)
+ const isDragged = dragId === doc.id
+
+ return (
+ handleDragStart(e, doc.id)}
+ onDragEnd={handleDragEnd}
+ onClick={() => onFileClick(doc.id)}
+ onContextMenu={e => {
+ e.preventDefault()
+ setContextMenu({ x: e.clientX, y: e.clientY, type: 'file', id: doc.id })
+ }}
+ >
+ 📄
+ {editingId === doc.id ? (
+ handleEditChange(e.target.value)}
+ onBlur={commitRename}
+ onKeyDown={e => {
+ if (e.key === 'Enter') commitRename()
+ if (e.key === 'Escape') setEditingId(null)
+ }}
+ onClick={e => e.stopPropagation()}
+ />
+ ) : (
+ {doc.title}
+ )}
+ {isOpen && !isActive && }
+
+ )
+ }
+
+ const renderFolderItem = (folder: CalcFolder, depth = 0) => {
+ const isExpanded = expandedFolders.has(folder.id)
+ const docsInFolder = getDocsInFolder(folder.id)
+ const subFolders = folders.filter(f => f.parentId === folder.id)
+ const isDropTarget = dropTarget === folder.id
+
+ return (
+
+
{ if (!editingId) toggleFolder(folder.id) }}
+ onDragOver={e => handleDragOver(e, folder.id)}
+ onDragLeave={handleDragLeave}
+ onDrop={e => handleDrop(e, folder.id)}
+ onContextMenu={e => {
+ e.preventDefault()
+ setContextMenu({ x: e.clientX, y: e.clientY, type: 'folder', id: folder.id })
+ }}
+ >
+ {isExpanded ? '▾' : '▸'}
+ {editingId === folder.id ? (
+ handleEditChange(e.target.value)}
+ onBlur={commitRename}
+ onKeyDown={e => {
+ if (e.key === 'Enter') commitRename()
+ if (e.key === 'Escape') setEditingId(null)
+ }}
+ onClick={e => e.stopPropagation()}
+ />
+ ) : (
+ <>
+ {isExpanded ? '📂' : '📁'}
+ {folder.name}
+ ({docsInFolder.length})
+ >
+ )}
+
+ {isExpanded && (
+ <>
+ {subFolders.map(sf => renderFolderItem(sf, depth + 1))}
+ {docsInFolder.map(d => renderFileItem(d, depth + 1))}
+ {docsInFolder.length === 0 && subFolders.length === 0 && (
+
+ Empty folder
+
+ )}
+ >
+ )}
+
+ )
+ }
+
+ return (
+ <>
+
+ {/* Search */}
+
+ 🔍
+ setSearch(e.target.value)}
+ className="sidebar-search-input"
+ />
+ {search && (
+ setSearch('')}>×
+ )}
+
+
+
+ {searchResults ? (
+ /* Search results */
+
+
+ Results ({searchResults.length})
+
+ {searchResults.map(d => renderFileItem(d))}
+ {searchResults.length === 0 && (
+
No documents match '{search}'
+ )}
+
+ ) : (
+ <>
+ {/* Recent */}
+
+
toggleSection('recent')}
+ >
+
+ {expandedSections.has('recent') ? '▾' : '▸'}
+
+ 🕐 Recent
+
+ {expandedSections.has('recent') && (
+ recentDocs.length > 0
+ ? recentDocs.map(d => renderFileItem(d))
+ :
No recent documents
+ )}
+
+
+ {/* Favorites */}
+ {favoriteDocs.length > 0 && (
+
+
toggleSection('favorites')}
+ >
+
+ {expandedSections.has('favorites') ? '▾' : '▸'}
+
+ ⭐ Favorites
+
+ {expandedSections.has('favorites') && favoriteDocs.map(d => renderFileItem(d))}
+
+ )}
+
+ {/* Templates */}
+
+
toggleSection('templates')}
+ >
+
+ {expandedSections.has('templates') ? '▾' : '▸'}
+
+ 📋 Templates
+
+ {expandedSections.has('templates') && (
+
+ {TEMPLATES.map(t => (
+
onNewDocument(t.name, t.content)}
+ title={t.description}
+ >
+
+
+ {t.name}
+ {t.description}
+
+
+ ))}
+
+ )}
+
+
+ {/* Files */}
+
+
toggleSection('files')}
+ onDragOver={e => handleDragOver(e, 'root')}
+ onDragLeave={handleDragLeave}
+ onDrop={e => handleDrop(e, null)}
+ >
+
+ {expandedSections.has('files') ? '▾' : '▸'}
+
+ 📁 Files
+
+ {expandedSections.has('files') && (
+
{ e.preventDefault(); e.dataTransfer.dropEffect = 'move' }}
+ onDrop={e => handleDrop(e, null)}
+ >
+ {rootFolders.map(f => renderFolderItem(f))}
+ {rootDocs.map(d => renderFileItem(d))}
+
+ )}
+
+ >
+ )}
+
+
+ {/* Footer */}
+
+ + Document
+ + Folder
+
+
+ {/* Resize handle */}
+
{
+ e.preventDefault()
+ resizeRef.current = { startX: e.clientX, startWidth: width }
+ document.body.style.cursor = 'col-resize'
+ document.body.style.userSelect = 'none'
+ }}
+ onDoubleClick={() => onWidthChange(240)}
+ />
+
+
+ {/* Context Menu */}
+ {contextMenu && (
+
+ {contextMenu.type === 'file' && (
+ <>
+
{ onFileClick(contextMenu.id); setContextMenu(null) }}>
+ Open
+
+
{
+ const doc = documents.find(d => d.id === contextMenu.id)
+ if (doc) startRename(doc.id, doc.title, 'file')
+ }}>
+ Rename
+
+
{
+ onToggleFavorite(contextMenu.id)
+ setContextMenu(null)
+ }}>
+ {documents.find(d => d.id === contextMenu.id)?.isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}
+
+ {folders.length > 0 && (
+ <>
+
+
Move to...
+
{ onMoveToFolder(contextMenu.id, null); setContextMenu(null) }}>
+ Root
+
+ {folders.map(f => (
+
{ onMoveToFolder(contextMenu.id, f.id); setContextMenu(null) }}>
+ 📁 {f.name}
+
+ ))}
+ >
+ )}
+
+
{
+ onDeleteDocument(contextMenu.id)
+ setContextMenu(null)
+ }}>
+ Delete
+
+ >
+ )}
+ {contextMenu.type === 'folder' && (
+ <>
+
{
+ const folder = folders.find(f => f.id === contextMenu.id)
+ if (folder) startRename(folder.id, folder.name, 'folder')
+ }}>
+ Rename
+
+
+
{
+ onDeleteFolder(contextMenu.id)
+ setContextMenu(null)
+ }}>
+ Delete Folder
+
+ >
+ )}
+
+ )}
+ >
+ )
+}
diff --git a/calcpad-web/src/components/StatusBar.tsx b/calcpad-web/src/components/StatusBar.tsx
new file mode 100644
index 0000000..aad84ea
--- /dev/null
+++ b/calcpad-web/src/components/StatusBar.tsx
@@ -0,0 +1,89 @@
+import type { EditorView } from '@codemirror/view'
+import { useState, useEffect, useCallback } from 'react'
+import '../styles/status-bar.css'
+
+interface StatusBarProps {
+ editorView: EditorView | null
+ engineReady: boolean
+ lineCount: number
+}
+
+export function StatusBar({ editorView, engineReady, lineCount }: StatusBarProps) {
+ const [cursor, setCursor] = useState({ line: 1, col: 1 })
+ const [selection, setSelection] = useState(0)
+ const [showDedication, setShowDedication] = useState(false)
+
+ const toggleDedication = useCallback(() => {
+ setShowDedication(prev => !prev)
+ }, [])
+
+ useEffect(() => {
+ if (!editorView) return
+
+ const update = () => {
+ const state = editorView.state
+ const pos = state.selection.main.head
+ const line = state.doc.lineAt(pos)
+ setCursor({ line: line.number, col: pos - line.from + 1 })
+
+ const sel = state.selection.main
+ setSelection(Math.abs(sel.to - sel.from))
+ }
+
+ // Initial
+ update()
+
+ const handler = () => update()
+ editorView.dom.addEventListener('keyup', handler)
+ editorView.dom.addEventListener('mouseup', handler)
+
+ return () => {
+ editorView.dom.removeEventListener('keyup', handler)
+ editorView.dom.removeEventListener('mouseup', handler)
+ }
+ }, [editorView])
+
+ return (
+
+
+ Ln {cursor.line}, Col {cursor.col}
+ {lineCount} lines
+ {selection > 0 && {selection} selected }
+
+
+
+
+ {engineReady ? 'Ready' : 'Loading...'}
+
+
+ Made with ♥ for Igor Cassel
+
+
+
+ {showDedication && (
+
+
e.stopPropagation()}>
+
♥
+
For Igor Cassel
+
+ CalcText was created in honor of my cousin Igor,
+ who has always had a deep love for text editors
+ and the craft of building beautiful, functional tools.
+
+
+ Every keystroke in this editor carries that inspiration.
+
+
+ Close
+
+
+
+ )}
+
+ )
+}
diff --git a/calcpad-web/src/components/TabBar.tsx b/calcpad-web/src/components/TabBar.tsx
new file mode 100644
index 0000000..6e8e55a
--- /dev/null
+++ b/calcpad-web/src/components/TabBar.tsx
@@ -0,0 +1,130 @@
+import { useState, useRef, useCallback, useEffect } from 'react'
+import type { CalcDocument } from '../hooks/useDocumentStore.ts'
+import '../styles/tab-bar.css'
+
+interface TabBarProps {
+ tabs: CalcDocument[]
+ activeTabId: string
+ onTabClick: (id: string) => void
+ onTabClose: (id: string) => void
+ onTabRename: (id: string, title: string) => void
+ onNewTab: () => void
+ modifiedIds?: Set
+}
+
+export function TabBar({
+ tabs,
+ activeTabId,
+ onTabClick,
+ onTabClose,
+ onTabRename,
+ onNewTab,
+ modifiedIds,
+}: TabBarProps) {
+ const scrollRef = useRef(null)
+ const [editingId, setEditingId] = useState(null)
+ const [editValue, setEditValue] = useState('')
+ const inputRef = useRef(null)
+
+ // Focus input when editing starts
+ useEffect(() => {
+ if (editingId && inputRef.current) {
+ inputRef.current.focus()
+ inputRef.current.select()
+ }
+ }, [editingId])
+
+ // Scroll active tab into view
+ useEffect(() => {
+ if (!scrollRef.current) return
+ const activeEl = scrollRef.current.querySelector('.tab-item.active')
+ activeEl?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
+ }, [activeTabId])
+
+ const startRename = useCallback((id: string, currentTitle: string) => {
+ setEditingId(id)
+ setEditValue(currentTitle)
+ }, [])
+
+ const commitRename = useCallback(() => {
+ if (editingId) {
+ onTabRename(editingId, editValue)
+ setEditingId(null)
+ }
+ }, [editingId, editValue, onTabRename])
+
+ const cancelRename = useCallback(() => {
+ setEditingId(null)
+ }, [])
+
+ const handleMiddleClick = useCallback((e: React.MouseEvent, id: string) => {
+ if (e.button === 1) {
+ e.preventDefault()
+ onTabClose(id)
+ }
+ }, [onTabClose])
+
+ // Horizontal scroll with mouse wheel
+ const handleWheel = useCallback((e: React.WheelEvent) => {
+ if (scrollRef.current && e.deltaY !== 0) {
+ scrollRef.current.scrollLeft += e.deltaY
+ }
+ }, [])
+
+ return (
+
+
+ {tabs.map(tab => (
+
onTabClick(tab.id)}
+ onMouseDown={(e) => handleMiddleClick(e, tab.id)}
+ onDoubleClick={() => startRename(tab.id, tab.title)}
+ title={tab.title}
+ >
+ {modifiedIds?.has(tab.id) && (
+
+ )}
+
+ {editingId === tab.id ? (
+ setEditValue(e.target.value)}
+ onBlur={commitRename}
+ onKeyDown={e => {
+ if (e.key === 'Enter') commitRename()
+ if (e.key === 'Escape') cancelRename()
+ }}
+ onClick={e => e.stopPropagation()}
+ />
+ ) : (
+ {tab.title}
+ )}
+
+ {
+ e.stopPropagation()
+ onTabClose(tab.id)
+ }}
+ aria-label={`Close ${tab.title}`}
+ >
+ ×
+
+
+ ))}
+
+
+ +
+
+
+ )
+}
diff --git a/calcpad-web/src/components/ThemePicker.tsx b/calcpad-web/src/components/ThemePicker.tsx
new file mode 100644
index 0000000..8153046
--- /dev/null
+++ b/calcpad-web/src/components/ThemePicker.tsx
@@ -0,0 +1,116 @@
+import { useState, useRef, useEffect } from 'react'
+import type { ThemeId } from '../hooks/useTheme.ts'
+import { THEMES, ACCENT_COLORS } from '../hooks/useTheme.ts'
+import '../styles/theme-picker.css'
+
+interface ThemePickerProps {
+ theme: ThemeId
+ accentColor: string | null
+ onThemeChange: (id: ThemeId) => void
+ onAccentChange: (color: string | null) => void
+}
+
+export function ThemePicker({ theme, accentColor, onThemeChange, onAccentChange }: ThemePickerProps) {
+ const [open, setOpen] = useState(false)
+ const ref = useRef(null)
+
+ // Close on click outside
+ useEffect(() => {
+ if (!open) return
+ function handleClick(e: MouseEvent) {
+ if (ref.current && !ref.current.contains(e.target as Node)) {
+ setOpen(false)
+ }
+ }
+ function handleKey(e: KeyboardEvent) {
+ if (e.key === 'Escape') setOpen(false)
+ }
+ document.addEventListener('mousedown', handleClick)
+ document.addEventListener('keydown', handleKey)
+ return () => {
+ document.removeEventListener('mousedown', handleClick)
+ document.removeEventListener('keydown', handleKey)
+ }
+ }, [open])
+
+ // Keyboard shortcut: Ctrl+Shift+T
+ useEffect(() => {
+ function handleKey(e: KeyboardEvent) {
+ if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'T') {
+ e.preventDefault()
+ setOpen(prev => !prev)
+ }
+ }
+ document.addEventListener('keydown', handleKey)
+ return () => document.removeEventListener('keydown', handleKey)
+ }, [])
+
+ const currentTheme = THEMES.find(t => t.id === theme)
+ const resolvedTheme = theme === 'system' ? undefined : theme
+ const icon = currentTheme?.icon ?? '⚙️'
+
+ return (
+
+
setOpen(prev => !prev)}
+ title="Switch theme (Ctrl+Shift+T)"
+ aria-label="Switch theme"
+ aria-expanded={open}
+ >
+ {icon}
+
+
+ {open && (
+
+
Themes
+
+ {THEMES.map(t => (
+
{ onThemeChange(t.id); setOpen(false) }}
+ role="menuitem"
+ >
+ {t.icon}
+ {t.name}
+ {resolvedTheme === t.id && ✓ }
+
+ ))}
+
+
+
+
Accent Color
+
+ {ACCENT_COLORS.map(c => (
+ {
+ const isDark = ['dark', 'matrix', 'midnight'].includes(resolvedTheme ?? '')
+ const color = isDark ? c.dark : c.light
+ onAccentChange(accentColor === color ? null : color)
+ }}
+ title={c.name}
+ aria-label={`Accent color: ${c.name}`}
+ />
+ ))}
+
+
+
+
+
{ onThemeChange('system'); setOpen(false) }}
+ role="menuitem"
+ >
+ ⚙️
+ System (auto)
+ {theme === 'system' && ✓ }
+
+
+ )}
+
+ )
+}
diff --git a/calcpad-web/src/data/templates.ts b/calcpad-web/src/data/templates.ts
new file mode 100644
index 0000000..e696e25
--- /dev/null
+++ b/calcpad-web/src/data/templates.ts
@@ -0,0 +1,151 @@
+export interface Template {
+ id: string
+ name: string
+ description: string
+ color: string
+ content: string
+}
+
+export const TEMPLATES: Template[] = [
+ {
+ id: 'budget',
+ name: 'Budget',
+ description: 'Monthly income and expenses',
+ color: '#10b981',
+ content: `# Monthly Budget
+
+// Income
+salary = 5000
+freelance = 1200
+total_income = salary + freelance
+
+// Housing
+rent = 1500
+utilities = 150
+insurance = 80
+
+// Living
+groceries = 400
+transport = 120
+subscriptions = 45
+
+// Summary
+total_expenses = sum
+savings = total_income - total_expenses
+savings_rate = savings / total_income
+`,
+ },
+ {
+ id: 'invoice',
+ name: 'Invoice',
+ description: 'Service invoice with tax',
+ color: '#6366f1',
+ content: `# Invoice #001
+
+// Client: [Client Name]
+// Date: [Date]
+
+// Services
+web_design = 2500
+development = 4000
+consulting = 150 * 8
+
+// Expenses
+hosting = 29.99
+domain = 12.00
+
+subtotal = sum
+
+// Tax
+tax_rate = 10%
+tax = subtotal * tax_rate
+total = subtotal + tax
+`,
+ },
+ {
+ id: 'units',
+ name: 'Unit Converter',
+ description: 'Common unit conversions',
+ color: '#0d9488',
+ content: `# Unit Converter
+
+// Weight
+75 kg in lb
+2.5 lb in kg
+100 g in oz
+
+// Distance
+10 km in mi
+26.2 mi in km
+5280 ft in m
+
+// Temperature
+100 °C in °F
+72 °F in °C
+0 °C in K
+
+// Data
+1 GB in MB
+500 MB in GB
+1 TB in GB
+`,
+ },
+ {
+ id: 'trip',
+ name: 'Trip Planner',
+ description: 'Travel budget with currencies',
+ color: '#f59e0b',
+ content: `# Trip Planner
+
+// Budget
+budget = 3000
+
+// Flights
+flight_out = 450
+flight_back = 380
+
+// Hotel
+nights = 7
+rate_per_night = 120
+hotel_total = nights * rate_per_night
+
+// Daily expenses
+daily_food = 50
+daily_transport = 20
+daily_activities = 35
+daily_total = daily_food + daily_transport + daily_activities
+trip_expenses = daily_total * nights
+
+// Summary
+total_cost = flight_out + flight_back + hotel_total + trip_expenses
+remaining = budget - total_cost
+`,
+ },
+ {
+ id: 'loan',
+ name: 'Loan Calculator',
+ description: 'Mortgage and loan payments',
+ color: '#7c3aed',
+ content: `# Loan Calculator
+
+// Loan Details
+principal = 250000
+annual_rate = 6.5%
+years = 30
+
+// Monthly Calculation
+monthly_rate = annual_rate / 12
+num_payments = years * 12
+
+// Monthly Payment
+monthly_payment = principal * (monthly_rate * (1 + monthly_rate) ^ num_payments) / ((1 + monthly_rate) ^ num_payments - 1)
+
+// Total Cost
+total_paid = monthly_payment * num_payments
+total_interest = total_paid - principal
+
+// Summary
+interest_ratio = total_interest / principal
+`,
+ },
+]
diff --git a/calcpad-web/src/editor/CalcEditor.tsx b/calcpad-web/src/editor/CalcEditor.tsx
index 81837ef..1fb54bb 100644
--- a/calcpad-web/src/editor/CalcEditor.tsx
+++ b/calcpad-web/src/editor/CalcEditor.tsx
@@ -1,7 +1,7 @@
/**
* React wrapper around CodeMirror 6 for the CalcPad editor.
*
- * Integrates the CalcPad language mode, answer gutter, error display,
+ * Integrates the CalcPad language mode, error display,
* and debounced evaluation via the WASM engine Web Worker.
*/
@@ -15,15 +15,17 @@ import {
keymap,
} from '@codemirror/view'
import {
- defaultHighlightStyle,
syntaxHighlighting,
bracketMatching,
indentOnInput,
+ HighlightStyle,
} from '@codemirror/language'
+import { tags } from '@lezer/highlight'
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
import { calcpadLanguage } from './calcpad-language.ts'
-import { answerGutterExtension, setAnswersEffect, type LineAnswer } from './answer-gutter.ts'
import { errorDisplayExtension, setErrorsEffect, type LineError } from './error-display.ts'
+import { stripedLinesExtension } from './inline-results.ts'
+import { formatPreviewExtension, formatPreviewCompartment, formatPreviewEnabled } from './format-preview.ts'
import type { EngineLineResult } from '../engine/types.ts'
export interface CalcEditorProps {
@@ -31,22 +33,27 @@ export interface CalcEditorProps {
initialDoc?: string
/** Called when the document text changes (debounced internally) */
onDocChange?: (lines: string[]) => void
- /** Engine evaluation results to display in the answer gutter */
+ /** Engine evaluation results to display as errors */
results?: EngineLineResult[]
/** Debounce delay in ms before triggering onDocChange */
debounceMs?: number
+ /** Called with the EditorView once created (null on cleanup) */
+ onViewReady?: (view: EditorView | null) => void
+ /** Enable live preview formatting */
+ formatPreview?: boolean
}
/**
* CalcPad editor component built on CodeMirror 6.
- * Handles syntax highlighting, line numbers, answer gutter,
- * and error underlines.
+ * Handles syntax highlighting, line numbers, and error underlines.
*/
export function CalcEditor({
initialDoc = '',
onDocChange,
results,
debounceMs = 50,
+ onViewReady,
+ formatPreview = true,
}: CalcEditorProps) {
const containerRef = useRef(null)
const viewRef = useRef(null)
@@ -86,10 +93,11 @@ export function CalcEditor({
indentOnInput(),
history(),
keymap.of([...defaultKeymap, ...historyKeymap]),
- syntaxHighlighting(defaultHighlightStyle),
+ syntaxHighlighting(calcpadHighlight),
calcpadLanguage(),
- answerGutterExtension(),
errorDisplayExtension(),
+ stripedLinesExtension(),
+ formatPreviewExtension(formatPreview),
updateListener,
calcpadEditorTheme,
],
@@ -101,6 +109,7 @@ export function CalcEditor({
})
viewRef.current = view
+ onViewReady?.(view)
// Trigger initial evaluation
const doc = view.state.doc.toString()
@@ -110,23 +119,33 @@ export function CalcEditor({
if (timerRef.current) clearTimeout(timerRef.current)
view.destroy()
viewRef.current = null
+ onViewReady?.(null)
}
// initialDoc intentionally excluded — we only set it once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scheduleEval])
- // Push engine results into the answer gutter + error display
+ // Toggle format preview mode
+ useEffect(() => {
+ const view = viewRef.current
+ if (!view) return
+ view.dispatch({
+ effects: formatPreviewCompartment.reconfigure(
+ formatPreview ? formatPreviewEnabled : [],
+ ),
+ })
+ }, [formatPreview])
+
+ // Push engine results into the error display
useEffect(() => {
const view = viewRef.current
if (!view || !results) return
- const answers: LineAnswer[] = []
const errors: LineError[] = []
for (let i = 0; i < results.length; i++) {
const lineNum = i + 1
const result = results[i]
- answers.push({ line: lineNum, result })
if (result.type === 'error' && result.error) {
// Map to document positions
@@ -143,7 +162,6 @@ export function CalcEditor({
view.dispatch({
effects: [
- setAnswersEffect.of(answers),
setErrorsEffect.of(errors),
],
})
@@ -152,6 +170,24 @@ export function CalcEditor({
return
}
+/**
+ * Syntax highlighting using CSS variables for theme integration.
+ */
+const calcpadHighlight = HighlightStyle.define([
+ { tag: tags.number, color: 'var(--syntax-number)' },
+ { tag: tags.operator, color: 'var(--syntax-operator)' },
+ { tag: tags.variableName, color: 'var(--syntax-variable)' },
+ { tag: tags.function(tags.variableName), color: 'var(--syntax-function)' },
+ { tag: tags.keyword, color: 'var(--syntax-keyword)' },
+ { tag: tags.lineComment, color: 'var(--syntax-comment)', fontStyle: 'italic' },
+ { tag: tags.heading, color: 'var(--syntax-heading)', fontWeight: '700' },
+ { tag: tags.definitionOperator, color: 'var(--syntax-operator)' },
+ { tag: tags.special(tags.variableName), color: 'var(--syntax-function)' },
+ { tag: tags.constant(tags.variableName), color: 'var(--syntax-number)', fontWeight: '600' },
+ { tag: tags.paren, color: 'var(--syntax-operator)' },
+ { tag: tags.punctuation, color: 'var(--syntax-operator)' },
+])
+
/**
* Base theme for the CalcPad editor.
*/
@@ -159,64 +195,44 @@ const calcpadEditorTheme = EditorView.baseTheme({
'&': {
height: '100%',
fontSize: '15px',
- fontFamily: 'ui-monospace, Consolas, "Courier New", monospace',
+ fontFamily: 'var(--mono, ui-monospace, Consolas, "Courier New", monospace)',
},
'.cm-scroller': {
overflow: 'auto',
},
'.cm-content': {
- padding: '12px 0',
+ padding: '8px 0',
minHeight: '100%',
},
'.cm-line': {
- padding: '0 16px',
+ padding: '0 12px',
lineHeight: '1.6',
+ position: 'relative',
+ textAlign: 'var(--cm-text-align, left)',
},
'.cm-gutters': {
backgroundColor: 'transparent',
borderRight: 'none',
},
'.cm-lineNumbers .cm-gutterElement': {
- padding: '0 8px 0 16px',
- color: '#9ca3af',
+ padding: '0 6px 0 12px',
+ color: 'var(--text, #9ca3af)',
+ opacity: '0.4',
fontSize: '13px',
minWidth: '32px',
},
- '.cm-answer-gutter': {
- minWidth: '140px',
- textAlign: 'right',
- paddingRight: '16px',
- borderLeft: '1px solid #e5e4e7',
- backgroundColor: '#f8f9fa',
- fontFamily: 'ui-monospace, Consolas, monospace',
- fontSize: '14px',
- },
- '&dark .cm-answer-gutter': {
- borderLeft: '1px solid #2e303a',
- backgroundColor: '#1a1b23',
- },
- '.cm-answer-value': {
- color: '#6366f1',
+ '.cm-activeLineGutter .cm-gutterElement': {
+ opacity: '1',
fontWeight: '600',
},
- '&dark .cm-answer-value': {
- color: '#818cf8',
- },
- '.cm-answer-error': {
- color: '#e53e3e',
- fontStyle: 'italic',
- },
- '&dark .cm-answer-error': {
- color: '#fc8181',
+ '.cm-stripe': {
+ backgroundColor: 'var(--stripe, rgba(0, 0, 0, 0.02))',
},
'.cm-activeLine': {
- backgroundColor: 'rgba(99, 102, 241, 0.04)',
- },
- '&dark .cm-activeLine': {
- backgroundColor: 'rgba(129, 140, 248, 0.06)',
+ backgroundColor: 'var(--accent-bg, rgba(99, 102, 241, 0.04))',
},
'.cm-selectionBackground': {
- backgroundColor: 'rgba(99, 102, 241, 0.15) !important',
+ backgroundColor: 'var(--accent-bg, rgba(99, 102, 241, 0.15)) !important',
},
'.cm-focused': {
outline: 'none',
diff --git a/calcpad-web/src/editor/answer-gutter.ts b/calcpad-web/src/editor/answer-gutter.ts
deleted file mode 100644
index 2f7a61d..0000000
--- a/calcpad-web/src/editor/answer-gutter.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-/**
- * Custom gutter for displaying computed results alongside each line.
- * Adapted from epic/9-2-codemirror-6-editor.
- *
- * The answer column is right-aligned and visually distinct from the input.
- */
-
-import { GutterMarker, gutter } from '@codemirror/view'
-import { StateField, StateEffect, type Extension } from '@codemirror/state'
-import type { EngineLineResult } from '../engine/types.ts'
-
-// --- State Effects ---
-
-export interface LineAnswer {
- line: number // 1-indexed line number
- result: EngineLineResult | null
-}
-
-export const setAnswersEffect = StateEffect.define()
-
-// --- Gutter Markers ---
-
-class AnswerMarker extends GutterMarker {
- constructor(
- readonly text: string,
- readonly isError: boolean,
- ) {
- super()
- }
-
- override toDOM(): HTMLElement {
- const span = document.createElement('span')
- span.className = this.isError ? 'cm-answer-error' : 'cm-answer-value'
- span.textContent = this.text
- return span
- }
-
- override eq(other: GutterMarker): boolean {
- return (
- other instanceof AnswerMarker &&
- other.text === this.text &&
- other.isError === this.isError
- )
- }
-}
-
-// --- State Field ---
-
-export const answersField = StateField.define>({
- create() {
- return new Map()
- },
-
- update(answers, tr) {
- for (const effect of tr.effects) {
- if (effect.is(setAnswersEffect)) {
- const newAnswers = new Map()
- for (const { line, result } of effect.value) {
- newAnswers.set(line, result)
- }
- return newAnswers
- }
- }
- return answers
- },
-})
-
-// --- Gutter Extension ---
-
-const DISPLAYABLE_TYPES = new Set(['number', 'unitValue', 'currencyValue', 'dateTime', 'timeDelta', 'boolean'])
-
-export const answerGutter = gutter({
- class: 'cm-answer-gutter',
- lineMarker(view, line) {
- const doc = view.state.doc
- const lineNumber = doc.lineAt(line.from).number
- const answers = view.state.field(answersField)
- const result = answers.get(lineNumber)
-
- if (!result) return null
-
- if (result.type === 'error') {
- return new AnswerMarker('Error', true)
- }
-
- if (DISPLAYABLE_TYPES.has(result.type) && result.display) {
- return new AnswerMarker(result.display, false)
- }
-
- return null
- },
- lineMarkerChange(update) {
- return update.transactions.some((tr) =>
- tr.effects.some((e) => e.is(setAnswersEffect)),
- )
- },
-})
-
-/**
- * Creates the answer gutter extension bundle.
- */
-export function answerGutterExtension(): Extension {
- return [answersField, answerGutter]
-}
diff --git a/calcpad-web/src/editor/error-display.ts b/calcpad-web/src/editor/error-display.ts
index b04755c..0cea049 100644
--- a/calcpad-web/src/editor/error-display.ts
+++ b/calcpad-web/src/editor/error-display.ts
@@ -11,6 +11,8 @@ import {
GutterMarker,
gutter,
EditorView,
+ hoverTooltip,
+ type Tooltip,
} from '@codemirror/view'
import { StateField, StateEffect, type Extension, RangeSet } from '@codemirror/state'
@@ -98,6 +100,48 @@ export const errorLinesField = StateField.define>({
},
})
+// --- Error Messages (for tooltips) ---
+
+export const errorMessagesField = StateField.define>({
+ create() {
+ return new Map()
+ },
+ update(msgs, tr) {
+ for (const effect of tr.effects) {
+ if (effect.is(setErrorsEffect)) {
+ const newMsgs = new Map()
+ for (const error of effect.value) {
+ const lineNumber = tr.state.doc.lineAt(error.from).number
+ newMsgs.set(lineNumber, error.message)
+ }
+ return newMsgs
+ }
+ }
+ return msgs
+ },
+})
+
+// --- Error Tooltip (hover) ---
+
+const errorTooltip = hoverTooltip((view, pos) => {
+ const line = view.state.doc.lineAt(pos)
+ const errorMessages = view.state.field(errorMessagesField)
+ const msg = errorMessages.get(line.number)
+ if (!msg) return null
+
+ return {
+ pos: line.from,
+ end: line.to,
+ above: false,
+ create() {
+ const dom = document.createElement('div')
+ dom.className = 'cm-error-tooltip'
+ dom.textContent = msg
+ return { dom }
+ },
+ } satisfies Tooltip
+})
+
// --- Error Gutter ---
export const errorGutter = gutter({
@@ -118,15 +162,32 @@ export const errorGutter = gutter({
export const errorBaseTheme = EditorView.baseTheme({
'.cm-error-underline': {
- textDecoration: 'underline wavy red',
+ textDecoration: 'underline wavy var(--error, red)',
+ textDecorationThickness: '1.5px',
},
'.cm-error-marker': {
- color: '#e53e3e',
+ color: 'var(--error, #e53e3e)',
fontSize: '14px',
+ cursor: 'pointer',
},
'.cm-error-gutter': {
width: '20px',
},
+ '.cm-error-tooltip': {
+ backgroundColor: 'var(--bg-secondary, #f8f9fa)',
+ color: 'var(--error, #e53e3e)',
+ border: '1px solid var(--border, #e5e4e7)',
+ borderRadius: '4px',
+ padding: '4px 8px',
+ fontSize: '12px',
+ fontFamily: 'var(--sans, system-ui)',
+ maxWidth: '300px',
+ boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
+ },
+ '.cm-tooltip': {
+ border: 'none',
+ backgroundColor: 'transparent',
+ },
})
/**
@@ -136,7 +197,9 @@ export function errorDisplayExtension(): Extension {
return [
errorDecorationsField,
errorLinesField,
+ errorMessagesField,
errorGutter,
+ errorTooltip,
errorBaseTheme,
]
}
diff --git a/calcpad-web/src/editor/format-preview.ts b/calcpad-web/src/editor/format-preview.ts
new file mode 100644
index 0000000..eb9799e
--- /dev/null
+++ b/calcpad-web/src/editor/format-preview.ts
@@ -0,0 +1,202 @@
+/**
+ * Live Preview extension for CodeMirror.
+ *
+ * When enabled, hides markdown syntax markers and applies visual formatting:
+ * - `# text` → heading (bold, larger)
+ * - `**text**` → bold
+ * - `*text*` → italic
+ * - `// text` → comment (dimmed, italic)
+ *
+ * The active line always shows raw markdown for editing.
+ * Toggle with the compartment to switch between raw and preview modes.
+ */
+
+import {
+ EditorView,
+ Decoration,
+ ViewPlugin,
+ type DecorationSet,
+ type ViewUpdate,
+ WidgetType,
+} from '@codemirror/view'
+import { Compartment, type Extension } from '@codemirror/state'
+
+// Invisible widget to replace hidden syntax markers
+class HiddenMarker extends WidgetType {
+ toDOM() {
+ const span = document.createElement('span')
+ span.style.display = 'none'
+ return span
+ }
+}
+
+const hiddenWidget = Decoration.replace({ widget: new HiddenMarker() })
+
+const headingMark = Decoration.mark({ class: 'cm-fmt-heading' })
+const boldMark = Decoration.mark({ class: 'cm-fmt-bold' })
+const italicMark = Decoration.mark({ class: 'cm-fmt-italic' })
+const commentMark = Decoration.mark({ class: 'cm-fmt-comment' })
+
+function buildDecorations(view: EditorView): DecorationSet {
+ const decorations: { from: number; to: number; dec: Decoration }[] = []
+ const doc = view.state.doc
+ const activeLine = doc.lineAt(view.state.selection.main.head).number
+
+ for (let i = 1; i <= doc.lines; i++) {
+ const line = doc.line(i)
+ const text = line.text
+ const isActive = i === activeLine
+
+ // Headings: # text
+ const headingMatch = text.match(/^(#{1,6})\s/)
+ if (headingMatch) {
+ if (!isActive) {
+ // Hide the # prefix
+ decorations.push({
+ from: line.from,
+ to: line.from + headingMatch[0].length,
+ dec: hiddenWidget,
+ })
+ }
+ // Style the rest as heading
+ decorations.push({
+ from: line.from + headingMatch[0].length,
+ to: line.to,
+ dec: headingMark,
+ })
+ continue
+ }
+
+ // Comments: // text
+ if (text.trimStart().startsWith('//')) {
+ const offset = text.indexOf('//')
+ if (!isActive) {
+ // Hide the // prefix
+ decorations.push({
+ from: line.from + offset,
+ to: line.from + offset + 2 + (text[offset + 2] === ' ' ? 1 : 0),
+ dec: hiddenWidget,
+ })
+ }
+ decorations.push({
+ from: line.from,
+ to: line.to,
+ dec: commentMark,
+ })
+ continue
+ }
+
+ // Bold: **text** (only on non-active lines)
+ if (!isActive) {
+ const boldRegex = /\*\*(.+?)\*\*/g
+ let match
+ while ((match = boldRegex.exec(text)) !== null) {
+ const start = line.from + match.index
+ // Hide opening **
+ decorations.push({ from: start, to: start + 2, dec: hiddenWidget })
+ // Bold the content
+ decorations.push({ from: start + 2, to: start + 2 + match[1].length, dec: boldMark })
+ // Hide closing **
+ decorations.push({ from: start + 2 + match[1].length, to: start + match[0].length, dec: hiddenWidget })
+ }
+ }
+
+ // Italic: *text* (only on non-active lines, avoid **bold**)
+ if (!isActive) {
+ const italicRegex = /(? a.from - b.from || a.to - b.to)
+
+ return Decoration.set(decorations.map(d => d.dec.range(d.from, d.to)))
+}
+
+const formatPreviewPlugin = ViewPlugin.fromClass(
+ class {
+ decorations: DecorationSet
+ constructor(view: EditorView) {
+ this.decorations = buildDecorations(view)
+ }
+ update(update: ViewUpdate) {
+ if (update.docChanged || update.selectionSet || update.viewportChanged) {
+ this.decorations = buildDecorations(update.view)
+ }
+ }
+ },
+ { decorations: (v) => v.decorations },
+)
+
+const formatPreviewTheme = EditorView.baseTheme({
+ '.cm-fmt-heading': {
+ fontWeight: '700',
+ fontSize: '1.15em',
+ color: 'var(--text-h)',
+ },
+ '.cm-fmt-bold': {
+ fontWeight: '700',
+ },
+ '.cm-fmt-italic': {
+ fontStyle: 'italic',
+ },
+ '.cm-fmt-comment': {
+ fontStyle: 'italic',
+ opacity: '0.5',
+ },
+ '.cm-fmt-color-red': { color: '#ef4444' },
+ '.cm-fmt-color-orange': { color: '#f97316' },
+ '.cm-fmt-color-yellow': { color: '#eab308' },
+ '.cm-fmt-color-green': { color: '#22c55e' },
+ '.cm-fmt-color-blue': { color: '#3b82f6' },
+ '.cm-fmt-color-purple': { color: '#a855f7' },
+})
+
+// Empty extension for "raw" mode
+const noopExtension: Extension = []
+
+export const formatPreviewCompartment = new Compartment()
+
+/** The extensions to use when preview is enabled */
+export const formatPreviewEnabled = [formatPreviewPlugin, formatPreviewTheme]
+
+export function formatPreviewExtension(enabled: boolean): Extension {
+ return formatPreviewCompartment.of(
+ enabled ? formatPreviewEnabled : noopExtension,
+ )
+}
diff --git a/calcpad-web/src/editor/inline-results.ts b/calcpad-web/src/editor/inline-results.ts
new file mode 100644
index 0000000..79f3686
--- /dev/null
+++ b/calcpad-web/src/editor/inline-results.ts
@@ -0,0 +1,54 @@
+/**
+ * CodeMirror 6 extension for zebra-striped editor lines.
+ *
+ * Previously also contained inline result widgets, but results
+ * are now rendered in a separate ResultsPanel component.
+ */
+
+import { Decoration, EditorView, ViewPlugin } from '@codemirror/view'
+import type { DecorationSet, ViewUpdate } from '@codemirror/view'
+import type { Extension } from '@codemirror/state'
+
+/**
+ * ViewPlugin that applies alternating background colors (zebra striping)
+ * to even-numbered editor lines, helping users visually connect
+ * expressions on the left to their inline results on the right.
+ */
+export const stripedLines = ViewPlugin.fromClass(
+ class {
+ decorations: DecorationSet
+
+ constructor(view: EditorView) {
+ this.decorations = buildStripeDecorations(view)
+ }
+
+ update(update: ViewUpdate) {
+ if (update.docChanged || update.viewportChanged) {
+ this.decorations = buildStripeDecorations(update.view)
+ }
+ }
+ },
+ {
+ decorations: (v) => v.decorations,
+ },
+)
+
+const stripeDeco = Decoration.line({ class: 'cm-stripe' })
+
+function buildStripeDecorations(view: EditorView): DecorationSet {
+ const decorations: Array> = []
+ for (let i = 1; i <= view.state.doc.lines; i++) {
+ if (i % 2 === 0) {
+ const line = view.state.doc.line(i)
+ decorations.push(stripeDeco.range(line.from))
+ }
+ }
+ return Decoration.set(decorations)
+}
+
+/**
+ * Creates the striped-lines extension for the editor.
+ */
+export function stripedLinesExtension(): Extension {
+ return [stripedLines]
+}
diff --git a/calcpad-web/src/engine/worker.ts b/calcpad-web/src/engine/worker.ts
index 427c7fe..94bfe12 100644
--- a/calcpad-web/src/engine/worker.ts
+++ b/calcpad-web/src/engine/worker.ts
@@ -78,7 +78,8 @@ function fallbackEvalSheet(lines: string[]): EngineLineResult[] {
async function initWasm(): Promise {
try {
// Try to load the wasm-pack output
- const wasmModule = await import(/* @vite-ignore */ '/wasm/calcpad_wasm.js') as {
+ const wasmPath = '/wasm/calcpad_wasm.js'
+ const wasmModule = await import(/* @vite-ignore */ wasmPath) as {
default: () => Promise
evalSheet: (lines: string[]) => EngineLineResult[]
}
diff --git a/calcpad-web/src/hooks/useDocumentStore.ts b/calcpad-web/src/hooks/useDocumentStore.ts
new file mode 100644
index 0000000..3907fbd
--- /dev/null
+++ b/calcpad-web/src/hooks/useDocumentStore.ts
@@ -0,0 +1,357 @@
+import { useState, useCallback, useRef } from 'react'
+
+const STORAGE_KEY = 'calctext-documents'
+const FOLDERS_KEY = 'calctext-folders'
+const TABS_KEY = 'calctext-tabs'
+const ACTIVE_KEY = 'calctext-active-tab'
+const SIDEBAR_KEY = 'calctext-sidebar'
+
+export interface CalcDocument {
+ id: string
+ title: string
+ content: string
+ folderId: string | null
+ isFavorite: boolean
+ createdAt: string
+ updatedAt: string
+}
+
+export interface CalcFolder {
+ id: string
+ name: string
+ parentId: string | null
+ order: number
+}
+
+interface StoreState {
+ documents: CalcDocument[]
+ folders: CalcFolder[]
+ openTabIds: string[]
+ activeTabId: string
+}
+
+const WELCOME_DOC: CalcDocument = {
+ id: 'welcome',
+ title: 'Welcome',
+ content: `# CalcText
+
+// Basic arithmetic
+2 + 3
+10 * 4.5
+100 / 7
+
+// Variables
+price = 49.99
+quantity = 3
+subtotal = price * quantity
+
+// Percentages
+tax = subtotal * 8%
+total = subtotal + tax
+
+// Functions
+sqrt(144)
+2 ^ 10
+`,
+ folderId: null,
+ isFavorite: false,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+}
+
+function generateId(): string {
+ return Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
+}
+
+function loadState(): StoreState {
+ try {
+ const docsJson = localStorage.getItem(STORAGE_KEY)
+ const foldersJson = localStorage.getItem(FOLDERS_KEY)
+ const tabsJson = localStorage.getItem(TABS_KEY)
+ const activeId = localStorage.getItem(ACTIVE_KEY)
+
+ const documents: CalcDocument[] = docsJson ? JSON.parse(docsJson) : [WELCOME_DOC]
+ const folders: CalcFolder[] = foldersJson ? JSON.parse(foldersJson) : []
+ const openTabIds: string[] = tabsJson ? JSON.parse(tabsJson) : [documents[0]?.id ?? 'welcome']
+ const activeTabId = activeId && openTabIds.includes(activeId) ? activeId : openTabIds[0] ?? ''
+
+ if (documents.length === 0) documents.push(WELCOME_DOC)
+ if (openTabIds.length === 0) openTabIds.push(documents[0].id)
+
+ return { documents, folders, openTabIds, activeTabId }
+ } catch {
+ return { documents: [WELCOME_DOC], folders: [], openTabIds: ['welcome'], activeTabId: 'welcome' }
+ }
+}
+
+function saveState(state: StoreState) {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state.documents))
+ localStorage.setItem(FOLDERS_KEY, JSON.stringify(state.folders))
+ localStorage.setItem(TABS_KEY, JSON.stringify(state.openTabIds))
+ localStorage.setItem(ACTIVE_KEY, state.activeTabId)
+ } catch { /* */ }
+}
+
+export function loadSidebarState(): { visible: boolean; width: number } {
+ try {
+ const json = localStorage.getItem(SIDEBAR_KEY)
+ if (json) return JSON.parse(json)
+ } catch { /* */ }
+ return { visible: true, width: 240 }
+}
+
+export function saveSidebarState(s: { visible: boolean; width: number }) {
+ try { localStorage.setItem(SIDEBAR_KEY, JSON.stringify(s)) } catch { /* */ }
+}
+
+export function useDocumentStore() {
+ const [state, setState] = useState(loadState)
+ const saveTimerRef = useRef | null>(null)
+ // Use a ref to always have latest state for return values
+ const stateRef = useRef(state)
+ stateRef.current = state
+
+ const persist = useCallback((next: StoreState) => {
+ setState(next)
+ saveState(next)
+ }, [])
+
+ const persistDebounced = useCallback((next: StoreState) => {
+ setState(next)
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
+ saveTimerRef.current = setTimeout(() => saveState(next), 300)
+ }, [])
+
+ // Use functional updates to avoid stale closures
+
+ const setActiveTab = useCallback((id: string) => {
+ setState(prev => {
+ const next = { ...prev, activeTabId: id }
+ saveState(next)
+ return next
+ })
+ }, [])
+
+ const createDocument = useCallback((title?: string, content?: string): CalcDocument => {
+ const id = generateId()
+ const now = new Date().toISOString()
+ // Read current state to count untitled
+ const cur = stateRef.current
+ const existingUntitled = cur.documents.filter(d => d.title.startsWith('Untitled')).length
+ const newDoc: CalcDocument = {
+ id,
+ title: title ?? (existingUntitled === 0 ? 'Untitled' : `Untitled ${existingUntitled + 1}`),
+ content: content ?? '',
+ folderId: null,
+ isFavorite: false,
+ createdAt: now,
+ updatedAt: now,
+ }
+ setState(prev => {
+ const next: StoreState = {
+ ...prev,
+ documents: [...prev.documents, newDoc],
+ openTabIds: [...prev.openTabIds, id],
+ activeTabId: id,
+ }
+ saveState(next)
+ return next
+ })
+ return newDoc
+ }, [])
+
+ const updateContent = useCallback((id: string, content: string) => {
+ setState(prev => {
+ const next: StoreState = {
+ ...prev,
+ documents: prev.documents.map(d =>
+ d.id === id ? { ...d, content, updatedAt: new Date().toISOString() } : d
+ ),
+ }
+ // debounced save
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
+ saveTimerRef.current = setTimeout(() => saveState(next), 300)
+ return next
+ })
+ }, [])
+
+ const renameDocument = useCallback((id: string, title: string) => {
+ setState(prev => {
+ const next: StoreState = {
+ ...prev,
+ documents: prev.documents.map(d =>
+ d.id === id ? { ...d, title: title.trim() || 'Untitled', updatedAt: new Date().toISOString() } : d
+ ),
+ }
+ saveState(next)
+ return next
+ })
+ }, [])
+
+ const closeTab = useCallback((id: string) => {
+ setState(prev => {
+ let nextTabs = prev.openTabIds.filter(tid => tid !== id)
+ let nextActive = prev.activeTabId
+
+ if (nextTabs.length === 0) {
+ const newId = generateId()
+ const newDoc: CalcDocument = {
+ id: newId, title: 'Untitled', content: '',
+ folderId: null, isFavorite: false,
+ createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
+ }
+ const next: StoreState = {
+ ...prev,
+ documents: [...prev.documents, newDoc],
+ openTabIds: [newId],
+ activeTabId: newId,
+ }
+ saveState(next)
+ return next
+ }
+
+ if (nextActive === id) {
+ const closedIdx = prev.openTabIds.indexOf(id)
+ nextActive = nextTabs[Math.min(closedIdx, nextTabs.length - 1)]
+ }
+
+ const next = { ...prev, openTabIds: nextTabs, activeTabId: nextActive }
+ saveState(next)
+ return next
+ })
+ }, [])
+
+ const deleteDocument = useCallback((id: string) => {
+ setState(prev => {
+ const nextDocs = prev.documents.filter(d => d.id !== id)
+ let nextTabs = prev.openTabIds.filter(tid => tid !== id)
+ let nextActive = prev.activeTabId
+
+ if (nextDocs.length === 0) {
+ const wd = { ...WELCOME_DOC, id: generateId() }
+ nextDocs.push(wd)
+ }
+ if (nextTabs.length === 0) nextTabs = [nextDocs[0].id]
+ if (!nextTabs.includes(nextActive)) nextActive = nextTabs[0]
+
+ const next: StoreState = { ...prev, documents: nextDocs, openTabIds: nextTabs, activeTabId: nextActive }
+ saveState(next)
+ return next
+ })
+ }, [])
+
+ const reorderTabs = useCallback((fromIndex: number, toIndex: number) => {
+ setState(prev => {
+ const tabs = [...prev.openTabIds]
+ const [moved] = tabs.splice(fromIndex, 1)
+ tabs.splice(toIndex, 0, moved)
+ const next = { ...prev, openTabIds: tabs }
+ saveState(next)
+ return next
+ })
+ }, [])
+
+ const openDocument = useCallback((id: string) => {
+ setState(prev => {
+ const next = prev.openTabIds.includes(id)
+ ? { ...prev, activeTabId: id }
+ : { ...prev, openTabIds: [...prev.openTabIds, id], activeTabId: id }
+ saveState(next)
+ return next
+ })
+ }, [])
+
+ const toggleFavorite = useCallback((id: string) => {
+ setState(prev => {
+ const next: StoreState = {
+ ...prev,
+ documents: prev.documents.map(d =>
+ d.id === id ? { ...d, isFavorite: !d.isFavorite } : d
+ ),
+ }
+ saveState(next)
+ return next
+ })
+ }, [])
+
+ const moveToFolder = useCallback((docId: string, folderId: string | null) => {
+ setState(prev => {
+ const next: StoreState = {
+ ...prev,
+ documents: prev.documents.map(d =>
+ d.id === docId ? { ...d, folderId } : d
+ ),
+ }
+ saveState(next)
+ return next
+ })
+ }, [])
+
+ const createFolder = useCallback((name?: string, parentId?: string | null): CalcFolder => {
+ const folder: CalcFolder = {
+ id: generateId(),
+ name: name ?? 'New Folder',
+ parentId: parentId ?? null,
+ order: stateRef.current.folders.length,
+ }
+ setState(prev => {
+ const next = { ...prev, folders: [...prev.folders, folder] }
+ saveState(next)
+ return next
+ })
+ return folder
+ }, [])
+
+ const renameFolder = useCallback((id: string, name: string) => {
+ setState(prev => {
+ const next: StoreState = {
+ ...prev,
+ folders: prev.folders.map(f =>
+ f.id === id ? { ...f, name: name.trim() || 'Folder' } : f
+ ),
+ }
+ saveState(next)
+ return next
+ })
+ }, [])
+
+ const deleteFolder = useCallback((id: string) => {
+ setState(prev => {
+ const next: StoreState = {
+ ...prev,
+ documents: prev.documents.map(d => d.folderId === id ? { ...d, folderId: null } : d),
+ folders: prev.folders.filter(f => f.id !== id && f.parentId !== id),
+ }
+ saveState(next)
+ return next
+ })
+ }, [])
+
+ const activeDoc = state.documents.find(d => d.id === state.activeTabId) ?? state.documents[0]
+ const openDocs = state.openTabIds
+ .map(id => state.documents.find(d => d.id === id))
+ .filter((d): d is CalcDocument => d != null)
+
+ return {
+ documents: state.documents,
+ folders: state.folders,
+ openDocs,
+ openTabIds: state.openTabIds,
+ activeTabId: state.activeTabId,
+ activeDoc,
+ setActiveTab,
+ createDocument,
+ updateContent,
+ renameDocument,
+ closeTab,
+ deleteDocument,
+ reorderTabs,
+ openDocument,
+ toggleFavorite,
+ moveToFolder,
+ createFolder,
+ renameFolder,
+ deleteFolder,
+ }
+}
diff --git a/calcpad-web/src/hooks/useTheme.ts b/calcpad-web/src/hooks/useTheme.ts
new file mode 100644
index 0000000..3c05cba
--- /dev/null
+++ b/calcpad-web/src/hooks/useTheme.ts
@@ -0,0 +1,135 @@
+const STORAGE_KEY = 'calctext-theme'
+const ACCENT_KEY = 'calctext-accent'
+
+export type ThemeId = 'system' | 'light' | 'dark' | 'matrix' | 'midnight' | 'warm'
+
+export const THEMES: { id: ThemeId; name: string; icon: string }[] = [
+ { id: 'light', name: 'Light', icon: '☀️' },
+ { id: 'dark', name: 'Dark', icon: '🌙' },
+ { id: 'matrix', name: 'Matrix', icon: '💻' },
+ { id: 'midnight', name: 'Midnight', icon: '🌊' },
+ { id: 'warm', name: 'Warm', icon: '📜' },
+]
+
+export const ACCENT_COLORS = [
+ { name: 'Indigo', light: '#6366f1', dark: '#818cf8' },
+ { name: 'Teal', light: '#14b8a6', dark: '#2dd4bf' },
+ { name: 'Rose', light: '#f43f5e', dark: '#fb7185' },
+ { name: 'Amber', light: '#f59e0b', dark: '#fbbf24' },
+ { name: 'Emerald', light: '#10b981', dark: '#34d399' },
+ { name: 'Sky', light: '#0ea5e9', dark: '#38bdf8' },
+]
+
+function getStoredTheme(): ThemeId {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY)
+ if (stored && ['system', 'light', 'dark', 'matrix', 'midnight', 'warm'].includes(stored)) {
+ return stored as ThemeId
+ }
+ } catch { /* localStorage unavailable */ }
+ return 'system'
+}
+
+function getStoredAccent(): string | null {
+ try {
+ return localStorage.getItem(ACCENT_KEY)
+ } catch { return null }
+}
+
+function resolveSystemTheme(): 'light' | 'dark' {
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
+}
+
+function isDarkTheme(theme: ThemeId): boolean {
+ if (theme === 'system') return resolveSystemTheme() === 'dark'
+ return theme === 'dark' || theme === 'matrix' || theme === 'midnight'
+}
+
+import { useState, useEffect, useCallback } from 'react'
+
+export function useTheme() {
+ const [theme, setThemeState] = useState(getStoredTheme)
+ const [accentColor, setAccentState] = useState(getStoredAccent)
+
+ const resolvedTheme = theme === 'system' ? resolveSystemTheme() : theme
+
+ const setTheme = useCallback((id: ThemeId) => {
+ setThemeState(id)
+ try { localStorage.setItem(STORAGE_KEY, id) } catch { /* */ }
+ applyTheme(id, accentColor)
+ }, [accentColor])
+
+ const setAccent = useCallback((color: string | null) => {
+ setAccentState(color)
+ try {
+ if (color) localStorage.setItem(ACCENT_KEY, color)
+ else localStorage.removeItem(ACCENT_KEY)
+ } catch { /* */ }
+ applyTheme(theme, color)
+ }, [theme])
+
+ // Listen for system theme changes
+ useEffect(() => {
+ if (theme !== 'system') return
+ const mq = window.matchMedia('(prefers-color-scheme: dark)')
+ const handler = () => applyTheme('system', accentColor)
+ mq.addEventListener('change', handler)
+ return () => mq.removeEventListener('change', handler)
+ }, [theme, accentColor])
+
+ // Apply on mount
+ useEffect(() => {
+ applyTheme(theme, accentColor)
+ }, []) // eslint-disable-line react-hooks/exhaustive-deps
+
+ return {
+ theme,
+ resolvedTheme,
+ setTheme,
+ accentColor,
+ setAccent,
+ isDark: isDarkTheme(theme),
+ themes: THEMES,
+ accentColors: ACCENT_COLORS,
+ }
+}
+
+function applyTheme(theme: ThemeId, accent: string | null) {
+ const resolved = theme === 'system'
+ ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
+ : theme
+
+ document.documentElement.setAttribute('data-theme', resolved)
+
+ // Apply custom accent if set
+ if (accent) {
+ const dark = isDarkTheme(theme)
+ document.documentElement.style.setProperty('--accent', accent)
+ document.documentElement.style.setProperty('--accent-bg', hexToRgba(accent, dark ? 0.15 : 0.1))
+ document.documentElement.style.setProperty('--accent-border', hexToRgba(accent, 0.5))
+ } else {
+ document.documentElement.style.removeProperty('--accent')
+ document.documentElement.style.removeProperty('--accent-bg')
+ document.documentElement.style.removeProperty('--accent-border')
+ }
+
+ // Update PWA theme-color
+ const meta = document.querySelector('meta[name="theme-color"]')
+ if (meta) {
+ const bgColors: Record = {
+ light: '#ffffff',
+ dark: '#16171d',
+ matrix: '#0a0a0a',
+ midnight: '#0f172a',
+ warm: '#fffbf5',
+ }
+ meta.setAttribute('content', bgColors[resolved] ?? '#ffffff')
+ }
+}
+
+function hexToRgba(hex: string, alpha: number): string {
+ const r = parseInt(hex.slice(1, 3), 16)
+ const g = parseInt(hex.slice(3, 5), 16)
+ const b = parseInt(hex.slice(5, 7), 16)
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`
+}
diff --git a/calcpad-web/src/styles/align-toolbar.css b/calcpad-web/src/styles/align-toolbar.css
new file mode 100644
index 0000000..1200a37
--- /dev/null
+++ b/calcpad-web/src/styles/align-toolbar.css
@@ -0,0 +1,53 @@
+/* ---------- Align Toolbar (inline in header) ---------- */
+
+.align-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.align-group {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+}
+
+.align-label {
+ font-size: 10px;
+ color: var(--text);
+ opacity: 0.5;
+ margin-right: 2px;
+ user-select: none;
+}
+
+.align-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 20px;
+ padding: 0;
+ border: 1px solid transparent;
+ border-radius: 3px;
+ background: transparent;
+ color: var(--text);
+ cursor: pointer;
+ transition: background 0.1s, border-color 0.1s, color 0.1s;
+}
+
+.align-btn:hover {
+ background: var(--accent-bg);
+ border-color: var(--border);
+}
+
+.align-btn.active {
+ background: var(--accent-bg);
+ border-color: var(--accent-border);
+ color: var(--accent);
+}
+
+@media (max-width: 768px) {
+ .align-toolbar {
+ display: none;
+ }
+}
diff --git a/calcpad-web/src/styles/answer-column.css b/calcpad-web/src/styles/answer-column.css
deleted file mode 100644
index fe49613..0000000
--- a/calcpad-web/src/styles/answer-column.css
+++ /dev/null
@@ -1,47 +0,0 @@
-/* ---------- Answer column (standalone panel mode) ---------- */
-
-.answer-column {
- width: 220px;
- padding: 12px 0;
- border-left: 1px solid var(--border);
- background: var(--bg-secondary);
- overflow-y: auto;
- flex-shrink: 0;
-}
-
-.answer-line {
- padding: 0 16px;
- font-family: var(--mono);
- font-size: 14px;
- line-height: 1.6;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- /* Match CodeMirror's line height for alignment */
- height: 24px;
-}
-
-.answer-value {
- color: var(--accent);
- font-weight: 600;
- text-align: right;
-}
-
-.answer-error {
- color: var(--error);
- font-style: italic;
- text-align: right;
-}
-
-.answer-empty {
- color: transparent;
-}
-
-@media (max-width: 640px) {
- .answer-column {
- width: 100%;
- max-height: 120px;
- border-left: none;
- border-top: 1px solid var(--border);
- }
-}
diff --git a/calcpad-web/src/styles/app.css b/calcpad-web/src/styles/app.css
index e8a0490..fa046b7 100644
--- a/calcpad-web/src/styles/app.css
+++ b/calcpad-web/src/styles/app.css
@@ -13,50 +13,67 @@
display: flex;
align-items: center;
gap: 12px;
- padding: 12px 24px;
+ padding: 6px 12px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
+ height: 40px;
}
.calcpad-header h1 {
margin: 0;
- font-size: 20px;
+ font-size: 14px;
+ font-weight: 600;
letter-spacing: -0.3px;
}
-.calcpad-header .subtitle {
- margin: 0;
- font-size: 13px;
- color: var(--text);
+.header-spacer {
+ flex: 1;
}
-.header-status {
+.header-actions {
margin-left: auto;
display: flex;
align-items: center;
- gap: 8px;
- font-size: 12px;
+ gap: 6px;
+}
+
+.header-divider {
+ width: 1px;
+ height: 16px;
+ background: var(--border);
+}
+
+/* ---------- Sidebar toggle ---------- */
+
+.header-sidebar-toggle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border: none;
+ border-radius: 4px;
+ background: transparent;
color: var(--text);
+ font-size: 16px;
+ cursor: pointer;
+ flex-shrink: 0;
+ transition: background 0.1s;
}
-.status-dot {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: var(--success);
+.header-sidebar-toggle:hover {
+ background: var(--accent-bg);
}
-.status-dot.loading {
- background: var(--warning);
- animation: pulse 1.5s ease-in-out infinite;
+/* ---------- Workspace (sidebar + editor area) ---------- */
+
+.calcpad-workspace {
+ flex: 1;
+ display: flex;
+ overflow: hidden;
}
-@keyframes pulse {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0.3; }
-}
-
-/* ---------- Editor area ---------- */
+/* ---------- Editor area (two-column layout) ---------- */
.calcpad-editor {
flex: 1;
@@ -65,7 +82,7 @@
}
.editor-pane {
- flex: 1;
+ flex: 3;
min-width: 0;
overflow: hidden;
}
@@ -79,14 +96,87 @@
height: 100%;
}
-/* ---------- Responsive ---------- */
+.pane-divider {
+ width: 5px;
+ background: var(--border);
+ flex-shrink: 0;
+ cursor: col-resize;
+ transition: background 0.15s;
+ position: relative;
+}
-@media (max-width: 640px) {
- .calcpad-header {
- padding: 10px 16px;
+.pane-divider:hover {
+ background: var(--accent);
+}
+
+/* Wider invisible hit area */
+.pane-divider::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: -4px;
+ right: -4px;
+}
+
+.results-panel {
+ flex: 1;
+ min-width: 120px;
+}
+
+/* ---------- Responsive: Mobile (< 768px) ---------- */
+
+@media (max-width: 767px) {
+ .calcpad-app {
+ height: 100dvh;
}
- .calcpad-header .subtitle {
+ .calcpad-header {
+ height: 44px;
+ padding: 6px 8px;
+ gap: 8px;
+ }
+
+ .header-sidebar-toggle {
+ width: 36px;
+ height: 36px;
+ font-size: 18px;
+ }
+
+ .header-divider {
+ display: none;
+ }
+
+ .pane-divider {
+ display: none;
+ }
+
+ /* Editor goes full width, results panel hidden (tray replaces it) */
+ .calcpad-editor {
+ flex-direction: column;
+ }
+
+ .editor-pane {
+ flex: 1 !important;
+ width: 100% !important;
+ }
+
+ .results-panel {
display: none;
}
}
+
+/* Safe areas for notched devices */
+@supports (padding: env(safe-area-inset-top)) {
+ .calcpad-app {
+ padding-top: env(safe-area-inset-top);
+ padding-bottom: env(safe-area-inset-bottom);
+ padding-left: env(safe-area-inset-left);
+ padding-right: env(safe-area-inset-right);
+ }
+}
+
+/* Prevent pull-to-refresh in PWA */
+.calcpad-app {
+ overscroll-behavior: none;
+}
diff --git a/calcpad-web/src/styles/format-toolbar.css b/calcpad-web/src/styles/format-toolbar.css
new file mode 100644
index 0000000..3d73535
--- /dev/null
+++ b/calcpad-web/src/styles/format-toolbar.css
@@ -0,0 +1,86 @@
+/* ---------- Format Toolbar ---------- */
+
+.format-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.format-group {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+}
+
+.format-separator {
+ width: 1px;
+ height: 16px;
+ background: var(--border);
+}
+
+.format-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 22px;
+ padding: 0;
+ border: 1px solid transparent;
+ border-radius: 3px;
+ background: transparent;
+ color: var(--text);
+ font-size: 12px;
+ font-family: var(--sans);
+ cursor: pointer;
+ transition: background 0.1s, border-color 0.1s, color 0.1s;
+}
+
+.format-btn:hover {
+ background: var(--accent-bg);
+ border-color: var(--border);
+ color: var(--text-h);
+}
+
+.format-btn:active {
+ background: var(--accent-bg);
+ border-color: var(--accent-border);
+ color: var(--accent);
+}
+
+.format-italic {
+ font-style: italic;
+ font-family: Georgia, serif;
+}
+
+.format-preview-toggle.active {
+ background: var(--accent-bg);
+ border-color: var(--accent-border);
+ color: var(--accent);
+}
+
+/* ---------- Color Buttons ---------- */
+
+.format-colors {
+ gap: 3px;
+}
+
+.format-color-btn {
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ border: 2px solid transparent;
+ padding: 0;
+ cursor: pointer;
+ transition: transform 0.1s, border-color 0.1s;
+}
+
+.format-color-btn:hover {
+ transform: scale(1.25);
+ border-color: var(--text-h);
+}
+
+@media (max-width: 768px) {
+ .format-toolbar {
+ display: none;
+ }
+}
diff --git a/calcpad-web/src/styles/index.css b/calcpad-web/src/styles/index.css
index 3c71baf..fbf8c4b 100644
--- a/calcpad-web/src/styles/index.css
+++ b/calcpad-web/src/styles/index.css
@@ -1,23 +1,16 @@
+/* ---------- Base & Font Setup ---------- */
+
:root {
- --text: #6b6375;
- --text-h: #08060d;
- --bg: #fff;
- --bg-secondary: #f8f9fa;
- --border: #e5e4e7;
- --code-bg: #f4f3ec;
- --accent: #6366f1;
- --accent-bg: rgba(99, 102, 241, 0.1);
- --accent-border: rgba(99, 102, 241, 0.5);
+ --sans: system-ui, 'Segoe UI', Roboto, sans-serif;
+ --heading: system-ui, 'Segoe UI', Roboto, sans-serif;
+ --mono: ui-monospace, Consolas, 'Courier New', monospace;
+
--warning: #f59e0b;
--warning-bg: rgba(245, 158, 11, 0.1);
--success: #10b981;
--success-bg: rgba(16, 185, 129, 0.1);
--error: #e53e3e;
- --sans: system-ui, 'Segoe UI', Roboto, sans-serif;
- --heading: system-ui, 'Segoe UI', Roboto, sans-serif;
- --mono: ui-monospace, Consolas, 'Courier New', monospace;
-
font: 16px/1.5 var(--sans);
color-scheme: light dark;
color: var(--text);
@@ -28,18 +21,180 @@
-moz-osx-font-smoothing: grayscale;
}
-@media (prefers-color-scheme: dark) {
- :root {
- --text: #9ca3af;
- --text-h: #f3f4f6;
- --bg: #16171d;
- --bg-secondary: #1a1b23;
- --border: #2e303a;
- --code-bg: #1f2028;
- --accent: #818cf8;
- --accent-bg: rgba(129, 140, 248, 0.15);
- --accent-border: rgba(129, 140, 248, 0.5);
- }
+/* ---------- Theme: Light (default) ---------- */
+
+:root,
+[data-theme="light"] {
+ --text: #6b6375;
+ --text-h: #08060d;
+ --bg: #fff;
+ --bg-secondary: #f8f9fa;
+ --border: #e5e4e7;
+ --code-bg: #f4f3ec;
+ --accent: #6366f1;
+ --accent-bg: rgba(99, 102, 241, 0.1);
+ --accent-border: rgba(99, 102, 241, 0.5);
+ --stripe: rgba(0, 0, 0, 0.02);
+
+ --syntax-variable: #4f46e5;
+ --syntax-number: #0d9488;
+ --syntax-operator: #6b6375;
+ --syntax-keyword: #7c3aed;
+ --syntax-function: #2563eb;
+ --syntax-currency: #d97706;
+ --syntax-comment: rgba(107, 99, 117, 0.5);
+ --syntax-heading: #08060d;
+
+ --result-number: #374151;
+ --result-unit: #0d9488;
+ --result-currency: #d97706;
+ --result-datetime: #7c3aed;
+ --result-boolean: #6366f1;
+}
+
+/* ---------- Theme: Dark ---------- */
+
+[data-theme="dark"] {
+ --text: #9ca3af;
+ --text-h: #f3f4f6;
+ --bg: #16171d;
+ --bg-secondary: #1a1b23;
+ --border: #2e303a;
+ --code-bg: #1f2028;
+ --accent: #818cf8;
+ --accent-bg: rgba(129, 140, 248, 0.15);
+ --accent-border: rgba(129, 140, 248, 0.5);
+ --stripe: rgba(255, 255, 255, 0.025);
+
+ --syntax-variable: #a5b4fc;
+ --syntax-number: #5eead4;
+ --syntax-operator: #9ca3af;
+ --syntax-keyword: #c4b5fd;
+ --syntax-function: #93c5fd;
+ --syntax-currency: #fcd34d;
+ --syntax-comment: rgba(156, 163, 175, 0.5);
+ --syntax-heading: #f3f4f6;
+
+ --result-number: #d1d5db;
+ --result-unit: #5eead4;
+ --result-currency: #fcd34d;
+ --result-datetime: #c4b5fd;
+ --result-boolean: #818cf8;
+}
+
+/* ---------- Theme: Matrix ---------- */
+
+[data-theme="matrix"] {
+ --text: #00ff41;
+ --text-h: #33ff66;
+ --bg: #0a0a0a;
+ --bg-secondary: #0f1a0f;
+ --border: #003300;
+ --code-bg: #0a0f0a;
+ --accent: #00ff41;
+ --accent-bg: rgba(0, 255, 65, 0.1);
+ --accent-border: rgba(0, 255, 65, 0.4);
+ --stripe: rgba(0, 255, 65, 0.03);
+
+ --syntax-variable: #00ff41;
+ --syntax-number: #00cc33;
+ --syntax-operator: #00ff41;
+ --syntax-keyword: #39ff14;
+ --syntax-function: #00ff41;
+ --syntax-currency: #ffff00;
+ --syntax-comment: rgba(0, 255, 65, 0.4);
+ --syntax-heading: #33ff66;
+
+ --result-number: #00ff41;
+ --result-unit: #00cc33;
+ --result-currency: #ffff00;
+ --result-datetime: #39ff14;
+ --result-boolean: #00ff41;
+
+ --mono: 'Courier New', 'Fira Code', monospace;
+ --success: #00ff41;
+ --error: #ff0000;
+}
+
+/* Matrix special effects */
+[data-theme="matrix"] .calcpad-app::after {
+ content: '';
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ background: repeating-linear-gradient(
+ 0deg,
+ transparent,
+ transparent 2px,
+ rgba(0, 0, 0, 0.06) 2px,
+ rgba(0, 0, 0, 0.06) 4px
+ );
+ z-index: 9999;
+}
+
+[data-theme="matrix"] .cm-cursor {
+ border-color: #00ff41 !important;
+ box-shadow: 0 0 4px #00ff41, 0 0 8px rgba(0, 255, 65, 0.3);
+}
+
+/* ---------- Theme: Midnight ---------- */
+
+[data-theme="midnight"] {
+ --text: #94a3b8;
+ --text-h: #e2e8f0;
+ --bg: #0f172a;
+ --bg-secondary: #1e293b;
+ --border: #334155;
+ --code-bg: #1e293b;
+ --accent: #38bdf8;
+ --accent-bg: rgba(56, 189, 248, 0.12);
+ --accent-border: rgba(56, 189, 248, 0.5);
+ --stripe: rgba(56, 189, 248, 0.03);
+
+ --syntax-variable: #7dd3fc;
+ --syntax-number: #5eead4;
+ --syntax-operator: #94a3b8;
+ --syntax-keyword: #c4b5fd;
+ --syntax-function: #7dd3fc;
+ --syntax-currency: #fcd34d;
+ --syntax-comment: rgba(148, 163, 184, 0.5);
+ --syntax-heading: #e2e8f0;
+
+ --result-number: #cbd5e1;
+ --result-unit: #5eead4;
+ --result-currency: #fcd34d;
+ --result-datetime: #c4b5fd;
+ --result-boolean: #38bdf8;
+}
+
+/* ---------- Theme: Warm ---------- */
+
+[data-theme="warm"] {
+ --text: #78716c;
+ --text-h: #1c1917;
+ --bg: #fffbf5;
+ --bg-secondary: #fef3e2;
+ --border: #e7e5e4;
+ --code-bg: #fef3e2;
+ --accent: #f97316;
+ --accent-bg: rgba(249, 115, 22, 0.1);
+ --accent-border: rgba(249, 115, 22, 0.5);
+ --stripe: rgba(249, 115, 22, 0.03);
+
+ --syntax-variable: #c2410c;
+ --syntax-number: #0d9488;
+ --syntax-operator: #78716c;
+ --syntax-keyword: #7c3aed;
+ --syntax-function: #2563eb;
+ --syntax-currency: #d97706;
+ --syntax-comment: rgba(120, 113, 108, 0.5);
+ --syntax-heading: #1c1917;
+
+ --result-number: #44403c;
+ --result-unit: #0d9488;
+ --result-currency: #d97706;
+ --result-datetime: #7c3aed;
+ --result-boolean: #f97316;
}
*,
diff --git a/calcpad-web/src/styles/mobile-results-tray.css b/calcpad-web/src/styles/mobile-results-tray.css
new file mode 100644
index 0000000..990963d
--- /dev/null
+++ b/calcpad-web/src/styles/mobile-results-tray.css
@@ -0,0 +1,117 @@
+/* ---------- Mobile Results Tray ---------- */
+/* Only visible on mobile (< 768px) */
+
+.mobile-results-tray {
+ display: none;
+}
+
+@media (max-width: 767px) {
+ .mobile-results-tray {
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-secondary);
+ border-top: 1px solid var(--border);
+ flex-shrink: 0;
+ transition: max-height 0.2s ease-out;
+ max-height: 48px;
+ overflow: hidden;
+ }
+
+ .mobile-results-tray.expanded {
+ max-height: 40vh;
+ }
+
+ /* ---------- Header / Collapsed ---------- */
+
+ .tray-header {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 8px 16px;
+ min-height: 48px;
+ cursor: pointer;
+ user-select: none;
+ flex-shrink: 0;
+ }
+
+ .tray-drag-handle {
+ width: 32px;
+ height: 4px;
+ border-radius: 2px;
+ background: var(--border);
+ margin-bottom: 6px;
+ }
+
+ .tray-last-result {
+ font-size: 13px;
+ font-family: var(--mono);
+ color: var(--text);
+ }
+
+ /* ---------- Expanded Content ---------- */
+
+ .tray-content {
+ flex: 1;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ -webkit-overflow-scrolling: touch;
+ }
+
+ .tray-result-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ height: 44px;
+ padding: 0 16px;
+ border-bottom: 1px solid var(--border);
+ cursor: pointer;
+ transition: background 0.1s;
+ }
+
+ .tray-result-item:active {
+ background: var(--accent-bg);
+ }
+
+ .tray-result-item.copied {
+ background: var(--success-bg);
+ }
+
+ .tray-result-line {
+ font-size: 11px;
+ font-family: var(--mono);
+ color: var(--text);
+ opacity: 0.4;
+ width: 40px;
+ flex-shrink: 0;
+ }
+
+ .tray-result-expr {
+ flex: 1;
+ font-size: 13px;
+ font-family: var(--mono);
+ color: var(--text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .tray-result-value {
+ font-size: 13px;
+ font-family: var(--mono);
+ color: var(--result-number, var(--text-h));
+ font-weight: 500;
+ flex-shrink: 0;
+ max-width: 120px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .tray-empty {
+ padding: 16px;
+ text-align: center;
+ font-size: 13px;
+ color: var(--text);
+ opacity: 0.4;
+ }
+}
diff --git a/calcpad-web/src/styles/results-panel.css b/calcpad-web/src/styles/results-panel.css
new file mode 100644
index 0000000..a1250f4
--- /dev/null
+++ b/calcpad-web/src/styles/results-panel.css
@@ -0,0 +1,104 @@
+/* ---------- Results Panel ---------- */
+
+.results-panel {
+ overflow: hidden;
+ background: var(--bg-secondary);
+ padding: 8px 0;
+}
+
+.result-line {
+ padding: 0 12px;
+ font-family: var(--mono);
+ font-size: 15px;
+ line-height: 1.6;
+ height: 24px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* ---------- Type-Specific Colors ---------- */
+
+.result-value {
+ font-weight: 400;
+ cursor: pointer;
+ transition: color 0.15s;
+}
+
+.result-value:hover {
+ filter: brightness(1.2);
+}
+
+.result-number {
+ color: var(--result-number, var(--text));
+}
+
+.result-unit {
+ color: var(--result-unit, #0d9488);
+}
+
+.result-currency {
+ color: var(--result-currency, #d97706);
+}
+
+.result-datetime {
+ color: var(--result-datetime, #7c3aed);
+}
+
+.result-boolean {
+ color: var(--result-boolean, var(--accent));
+}
+
+/* ---------- Copy Feedback ---------- */
+
+.result-value.copied {
+ color: var(--success) !important;
+ font-weight: 500;
+}
+
+/* ---------- Error Hint ---------- */
+
+.result-error-hint {
+ color: var(--text);
+ opacity: 0.25;
+ font-size: 13px;
+ font-family: var(--sans);
+}
+
+/* ---------- Comment/Heading Marker ---------- */
+
+.result-marker {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+}
+
+.result-dash {
+ display: inline-block;
+ width: 60%;
+ height: 1px;
+ background: var(--border);
+ opacity: 0.4;
+ font-size: 0;
+ overflow: hidden;
+}
+
+/* ---------- Empty ---------- */
+
+.result-empty {
+ /* intentionally blank */
+}
+
+/* ---------- Stripes ---------- */
+
+.result-stripe {
+ background: var(--stripe, rgba(0, 0, 0, 0.02));
+}
+
+/* ---------- Responsive ---------- */
+
+@media (max-width: 768px) {
+ .results-panel {
+ display: none;
+ }
+}
diff --git a/calcpad-web/src/styles/sidebar.css b/calcpad-web/src/styles/sidebar.css
new file mode 100644
index 0000000..842b26a
--- /dev/null
+++ b/calcpad-web/src/styles/sidebar.css
@@ -0,0 +1,460 @@
+/* ---------- Sidebar ---------- */
+
+.sidebar {
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-secondary);
+ border-right: 1px solid var(--border);
+ flex-shrink: 0;
+ position: relative;
+ overflow: hidden;
+}
+
+/* ---------- Search ---------- */
+
+.sidebar-search {
+ display: flex;
+ align-items: center;
+ padding: 6px;
+ position: relative;
+ flex-shrink: 0;
+}
+
+.sidebar-search-icon {
+ position: absolute;
+ left: 14px;
+ font-size: 12px;
+ pointer-events: none;
+ opacity: 0.5;
+}
+
+.sidebar-search-input {
+ width: 100%;
+ height: 28px;
+ padding: 4px 24px 4px 28px;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ background: var(--bg);
+ color: var(--text);
+ font-size: 12px;
+ font-family: inherit;
+ outline: none;
+ transition: border-color 0.15s;
+}
+
+.sidebar-search-input:focus {
+ border-color: var(--accent-border);
+}
+
+.sidebar-search-input::placeholder {
+ color: var(--text);
+ opacity: 0.4;
+}
+
+.sidebar-search-clear {
+ position: absolute;
+ right: 10px;
+ border: none;
+ background: none;
+ color: var(--text);
+ cursor: pointer;
+ font-size: 14px;
+ opacity: 0.5;
+ padding: 0 4px;
+}
+
+.sidebar-search-clear:hover {
+ opacity: 1;
+}
+
+/* ---------- Content ---------- */
+
+.sidebar-content {
+ flex: 1;
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+
+/* ---------- Sections ---------- */
+
+.sidebar-section {
+ padding: 4px 0;
+}
+
+.sidebar-section-header {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 8px;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text);
+ opacity: 0.7;
+ cursor: pointer;
+ user-select: none;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+}
+
+.sidebar-section-header:hover {
+ opacity: 1;
+}
+
+.sidebar-section-chevron {
+ font-size: 10px;
+ width: 12px;
+ text-align: center;
+}
+
+/* ---------- File Item ---------- */
+
+.sidebar-file {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ height: 28px;
+ padding-right: 8px;
+ cursor: pointer;
+ user-select: none;
+ transition: background 0.1s;
+ position: relative;
+}
+
+.sidebar-file:hover {
+ background: var(--accent-bg);
+}
+
+.sidebar-file.active {
+ background: var(--accent-bg);
+ border-left: 2px solid var(--accent);
+}
+
+.sidebar-file-icon {
+ font-size: 14px;
+ flex-shrink: 0;
+}
+
+.sidebar-file-label {
+ flex: 1;
+ font-size: 12px;
+ color: var(--text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.sidebar-file.active .sidebar-file-label {
+ font-weight: 500;
+ color: var(--text-h);
+}
+
+.sidebar-open-dot {
+ width: 4px;
+ height: 4px;
+ border-radius: 50%;
+ background: var(--accent);
+ flex-shrink: 0;
+ opacity: 0.6;
+}
+
+/* ---------- Folder Item ---------- */
+
+.sidebar-folder {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ height: 28px;
+ padding-right: 8px;
+ cursor: pointer;
+ user-select: none;
+ transition: background 0.1s;
+}
+
+.sidebar-folder:hover {
+ background: var(--accent-bg);
+}
+
+.sidebar-folder-chevron {
+ font-size: 10px;
+ width: 12px;
+ text-align: center;
+ flex-shrink: 0;
+}
+
+.sidebar-folder-icon {
+ font-size: 14px;
+ flex-shrink: 0;
+}
+
+.sidebar-folder-label {
+ flex: 1;
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.sidebar-folder-count {
+ font-size: 10px;
+ color: var(--text);
+ opacity: 0.4;
+ flex-shrink: 0;
+}
+
+/* ---------- Drag & Drop ---------- */
+
+.sidebar-file.dragging {
+ opacity: 0.4;
+}
+
+.sidebar-folder.drop-target {
+ background: var(--accent-bg);
+ outline: 2px dashed var(--accent);
+ outline-offset: -2px;
+ border-radius: 4px;
+}
+
+.sidebar-section-header.drop-target {
+ background: var(--accent-bg);
+}
+
+.sidebar-files-area {
+ min-height: 8px;
+}
+
+/* ---------- Templates ---------- */
+
+.sidebar-template {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ height: 32px;
+ padding: 0 12px;
+ cursor: pointer;
+ transition: background 0.1s;
+}
+
+.sidebar-template:hover {
+ background: var(--accent-bg);
+}
+
+.sidebar-template-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.sidebar-template-text {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.sidebar-template-name {
+ font-size: 12px;
+ color: var(--text);
+ line-height: 1.2;
+}
+
+.sidebar-template-desc {
+ font-size: 10px;
+ color: var(--text);
+ opacity: 0.5;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1.2;
+}
+
+/* ---------- Empty State ---------- */
+
+.sidebar-empty {
+ padding: 6px 12px;
+ font-size: 11px;
+ color: var(--text);
+ opacity: 0.4;
+ font-style: italic;
+}
+
+/* ---------- Rename Input ---------- */
+
+.sidebar-rename-input {
+ flex: 1;
+ min-width: 0;
+ border: 1px solid var(--accent-border);
+ border-radius: 2px;
+ background: var(--bg);
+ color: var(--text-h);
+ font-size: 12px;
+ padding: 1px 4px;
+ outline: none;
+ font-family: inherit;
+}
+
+/* ---------- Footer ---------- */
+
+.sidebar-footer {
+ display: flex;
+ gap: 4px;
+ padding: 6px;
+ border-top: 1px solid var(--border);
+ flex-shrink: 0;
+}
+
+.sidebar-footer-btn {
+ flex: 1;
+ height: 26px;
+ border: none;
+ border-radius: 4px;
+ background: transparent;
+ color: var(--text);
+ font-size: 11px;
+ font-family: inherit;
+ cursor: pointer;
+ transition: color 0.1s, background 0.1s;
+}
+
+.sidebar-footer-btn:hover {
+ color: var(--accent);
+ background: var(--accent-bg);
+}
+
+/* ---------- Resize Handle ---------- */
+
+.sidebar-resize {
+ position: absolute;
+ top: 0;
+ right: -4px;
+ width: 8px;
+ height: 100%;
+ cursor: col-resize;
+ z-index: 10;
+}
+
+.sidebar-resize:hover::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ right: 3px;
+ width: 2px;
+ height: 100%;
+ background: var(--accent);
+ opacity: 0.5;
+}
+
+/* ---------- Context Menu ---------- */
+
+.sidebar-context-menu {
+ position: fixed;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ padding: 4px;
+ z-index: 300;
+ min-width: 160px;
+}
+
+.sidebar-context-menu button {
+ display: block;
+ width: 100%;
+ padding: 5px 10px;
+ border: none;
+ border-radius: 3px;
+ background: transparent;
+ color: var(--text);
+ font-size: 12px;
+ font-family: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+
+.sidebar-context-menu button:hover {
+ background: var(--accent-bg);
+ color: var(--text-h);
+}
+
+.sidebar-context-separator {
+ height: 1px;
+ background: var(--border);
+ margin: 4px 0;
+}
+
+.sidebar-context-label {
+ padding: 3px 10px;
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--text);
+ opacity: 0.5;
+ text-transform: uppercase;
+}
+
+.sidebar-context-danger {
+ color: var(--error) !important;
+}
+
+/* ---------- Responsive: Mobile Drawer ---------- */
+
+@media (max-width: 767px) {
+ .sidebar {
+ position: fixed;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: 85vw !important;
+ max-width: 320px;
+ z-index: 400;
+ box-shadow: 4px 0 20px rgba(0, 0, 0, 0.25);
+ animation: sidebar-slide-in 0.2s ease-out;
+ }
+
+ @keyframes sidebar-slide-in {
+ from { transform: translateX(-100%); }
+ to { transform: translateX(0); }
+ }
+
+ .sidebar-file {
+ height: 44px;
+ }
+
+ .sidebar-folder {
+ height: 44px;
+ }
+
+ .sidebar-template {
+ height: 44px;
+ }
+
+ .sidebar-search-input {
+ height: 36px;
+ font-size: 14px;
+ }
+
+ .sidebar-resize {
+ display: none;
+ }
+}
+
+/* Mobile sidebar backdrop — rendered from App.tsx */
+.sidebar-backdrop {
+ display: none;
+}
+
+@media (max-width: 767px) {
+ .sidebar-backdrop {
+ display: block;
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.5);
+ z-index: 399;
+ animation: sidebar-backdrop-in 0.2s ease-out;
+ }
+
+ @keyframes sidebar-backdrop-in {
+ from { opacity: 0; }
+ to { opacity: 1; }
+ }
+}
diff --git a/calcpad-web/src/styles/status-bar.css b/calcpad-web/src/styles/status-bar.css
new file mode 100644
index 0000000..64b5312
--- /dev/null
+++ b/calcpad-web/src/styles/status-bar.css
@@ -0,0 +1,159 @@
+/* ---------- Status Bar ---------- */
+
+.status-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ height: 24px;
+ padding: 0 12px;
+ background: var(--bg-secondary);
+ border-top: 1px solid var(--border);
+ flex-shrink: 0;
+ font-family: var(--mono);
+ font-size: 11px;
+ color: var(--text);
+ opacity: 0.8;
+ user-select: none;
+}
+
+.status-bar-left,
+.status-bar-right {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.status-bar-engine {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.status-bar-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+}
+
+.status-bar-dot.ready {
+ background: var(--success);
+}
+
+.status-bar-dot.loading {
+ background: var(--warning);
+ animation: pulse 1.5s ease-in-out infinite;
+}
+
+.status-bar-dedication {
+ opacity: 0.6;
+ font-family: var(--sans);
+ font-size: 10px;
+ letter-spacing: 0.2px;
+ cursor: pointer;
+ transition: opacity 0.15s;
+}
+
+.status-bar-dedication:hover {
+ opacity: 1;
+}
+
+.status-bar-heart {
+ color: #e53e3e;
+ font-size: 11px;
+}
+
+/* ---------- Dedication Overlay ---------- */
+
+.dedication-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.6);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 500;
+ animation: dedication-fade-in 0.2s ease-out;
+}
+
+@keyframes dedication-fade-in {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+.dedication-card {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ padding: 40px;
+ max-width: 420px;
+ text-align: center;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
+ animation: dedication-card-in 0.3s ease-out;
+}
+
+@keyframes dedication-card-in {
+ from { opacity: 0; transform: scale(0.9) translateY(20px); }
+ to { opacity: 1; transform: scale(1) translateY(0); }
+}
+
+.dedication-heart {
+ font-size: 48px;
+ color: #e53e3e;
+ margin-bottom: 16px;
+ animation: dedication-beat 1.2s ease-in-out infinite;
+}
+
+@keyframes dedication-beat {
+ 0%, 100% { transform: scale(1); }
+ 14% { transform: scale(1.15); }
+ 28% { transform: scale(1); }
+ 42% { transform: scale(1.1); }
+ 56% { transform: scale(1); }
+}
+
+.dedication-card h2 {
+ margin: 0 0 16px;
+ font-size: 22px;
+ font-weight: 600;
+ color: var(--text-h);
+ font-family: var(--sans);
+}
+
+.dedication-card p {
+ margin: 0 0 12px;
+ font-size: 14px;
+ line-height: 1.6;
+ color: var(--text);
+ font-family: var(--sans);
+}
+
+.dedication-tagline {
+ font-style: italic;
+ opacity: 0.7;
+ font-size: 13px !important;
+}
+
+.dedication-close {
+ margin-top: 20px;
+ padding: 8px 24px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: transparent;
+ color: var(--text);
+ font-size: 13px;
+ font-family: var(--sans);
+ cursor: pointer;
+ transition: background 0.15s, color 0.15s;
+}
+
+.dedication-close:hover {
+ background: var(--accent-bg);
+ color: var(--accent);
+ border-color: var(--accent-border);
+}
+
+@media (max-width: 768px) {
+ .status-bar-left span:not(:first-child) {
+ display: none;
+ }
+}
diff --git a/calcpad-web/src/styles/tab-bar.css b/calcpad-web/src/styles/tab-bar.css
new file mode 100644
index 0000000..20342ea
--- /dev/null
+++ b/calcpad-web/src/styles/tab-bar.css
@@ -0,0 +1,205 @@
+/* ---------- Tab Bar ---------- */
+
+.tab-bar {
+ display: flex;
+ align-items: stretch;
+ height: 36px;
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+}
+
+.tab-bar-scroll {
+ display: flex;
+ flex: 1;
+ overflow-x: auto;
+ overflow-y: hidden;
+ scrollbar-width: none; /* Firefox */
+}
+
+.tab-bar-scroll::-webkit-scrollbar {
+ display: none; /* Chrome, Safari */
+}
+
+/* Fade indicators for scroll overflow */
+.tab-bar {
+ position: relative;
+}
+
+.tab-bar::before,
+.tab-bar::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 16px;
+ pointer-events: none;
+ z-index: 1;
+ opacity: 0;
+ transition: opacity 0.15s;
+}
+
+.tab-bar::before {
+ left: 0;
+ background: linear-gradient(to right, var(--bg-secondary), transparent);
+}
+
+.tab-bar::after {
+ right: 36px; /* before new tab button */
+ background: linear-gradient(to left, var(--bg-secondary), transparent);
+}
+
+/* ---------- Tab Item ---------- */
+
+.tab-item {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 0 8px;
+ min-width: 100px;
+ max-width: 200px;
+ height: 36px;
+ border-right: 1px solid var(--border);
+ border-bottom: 1px solid var(--border);
+ cursor: pointer;
+ user-select: none;
+ transition: background 0.1s;
+ flex-shrink: 0;
+}
+
+.tab-item:hover {
+ background: var(--bg);
+}
+
+.tab-item.active {
+ background: var(--bg);
+ border-bottom-color: transparent;
+ border-top: 2px solid var(--accent);
+}
+
+.tab-item:not(.active) {
+ border-top: 2px solid transparent;
+}
+
+/* ---------- Tab Label ---------- */
+
+.tab-label {
+ flex: 1;
+ font-size: 12px;
+ color: var(--text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.tab-item.active .tab-label {
+ font-weight: 500;
+ color: var(--text-h);
+}
+
+/* ---------- Modified Dot ---------- */
+
+.tab-modified-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--text);
+ opacity: 0.6;
+ flex-shrink: 0;
+ animation: tab-dot-in 0.2s ease-out;
+}
+
+@keyframes tab-dot-in {
+ from { opacity: 0; transform: scale(0); }
+ to { opacity: 0.6; transform: scale(1); }
+}
+
+/* ---------- Mobile ---------- */
+
+@media (max-width: 767px) {
+ .tab-bar {
+ height: 40px;
+ }
+
+ .tab-item {
+ height: 40px;
+ min-width: 80px;
+ }
+
+ .tab-close {
+ display: none;
+ }
+
+ .tab-new {
+ height: 40px;
+ width: 40px;
+ }
+}
+
+/* ---------- Close Button ---------- */
+
+.tab-close {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 16px;
+ border: none;
+ border-radius: 3px;
+ background: transparent;
+ color: var(--text);
+ opacity: 0;
+ font-size: 14px;
+ line-height: 1;
+ cursor: pointer;
+ flex-shrink: 0;
+ transition: opacity 0.1s, background 0.1s;
+}
+
+.tab-item:hover .tab-close,
+.tab-item.active .tab-close {
+ opacity: 0.5;
+}
+
+.tab-close:hover {
+ opacity: 1 !important;
+ background: var(--accent-bg);
+}
+
+/* ---------- Rename Input ---------- */
+
+.tab-rename-input {
+ flex: 1;
+ min-width: 0;
+ border: 1px solid var(--accent-border);
+ border-radius: 2px;
+ background: var(--bg);
+ color: var(--text-h);
+ font-size: 12px;
+ padding: 1px 4px;
+ outline: none;
+ font-family: inherit;
+}
+
+/* ---------- New Tab Button ---------- */
+
+.tab-new {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ border: none;
+ border-bottom: 1px solid var(--border);
+ background: transparent;
+ color: var(--text);
+ font-size: 18px;
+ font-weight: 300;
+ cursor: pointer;
+ flex-shrink: 0;
+ transition: color 0.1s;
+}
+
+.tab-new:hover {
+ color: var(--accent);
+}
diff --git a/calcpad-web/src/styles/theme-picker.css b/calcpad-web/src/styles/theme-picker.css
new file mode 100644
index 0000000..57db8c1
--- /dev/null
+++ b/calcpad-web/src/styles/theme-picker.css
@@ -0,0 +1,130 @@
+/* ---------- Theme Picker ---------- */
+
+.theme-picker-container {
+ position: relative;
+}
+
+.theme-picker-trigger {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border: none;
+ border-radius: 4px;
+ background: transparent;
+ cursor: pointer;
+ font-size: 16px;
+ line-height: 1;
+ transition: background 0.1s;
+}
+
+.theme-picker-trigger:hover {
+ background: var(--accent-bg);
+}
+
+.theme-picker-dropdown {
+ position: absolute;
+ top: calc(100% + 4px);
+ right: 0;
+ width: 220px;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
+ padding: 6px;
+ z-index: 200;
+ animation: theme-picker-in 0.15s ease-out;
+}
+
+@keyframes theme-picker-in {
+ from {
+ opacity: 0;
+ transform: scale(0.95) translateY(-4px);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1) translateY(0);
+ }
+}
+
+.theme-picker-section-label {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text);
+ opacity: 0.6;
+ padding: 6px 8px 4px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.theme-picker-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ padding: 6px 8px;
+ border: none;
+ border-radius: 4px;
+ background: transparent;
+ cursor: pointer;
+ font-size: 13px;
+ color: var(--text);
+ text-align: left;
+ transition: background 0.1s;
+}
+
+.theme-picker-item:hover {
+ background: var(--accent-bg);
+}
+
+.theme-picker-item.active {
+ font-weight: 500;
+ color: var(--text-h);
+}
+
+.theme-picker-item-icon {
+ font-size: 14px;
+ width: 20px;
+ text-align: center;
+}
+
+.theme-picker-item-label {
+ flex: 1;
+}
+
+.theme-picker-check {
+ font-size: 12px;
+ color: var(--accent);
+ font-weight: 600;
+}
+
+.theme-picker-separator {
+ height: 1px;
+ background: var(--border);
+ margin: 6px 0;
+}
+
+.theme-picker-accents {
+ display: flex;
+ gap: 6px;
+ padding: 4px 8px 6px;
+}
+
+.theme-picker-swatch {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ border: 2px solid transparent;
+ cursor: pointer;
+ transition: transform 0.1s, border-color 0.1s;
+ padding: 0;
+}
+
+.theme-picker-swatch:hover {
+ transform: scale(1.15);
+}
+
+.theme-picker-swatch.active {
+ border-color: var(--text-h);
+}
diff --git a/calcpad-web/src/vite-env.d.ts b/calcpad-web/src/vite-env.d.ts
index 20343fd..dab1d2b 100644
--- a/calcpad-web/src/vite-env.d.ts
+++ b/calcpad-web/src/vite-env.d.ts
@@ -1,5 +1,10 @@
///
+declare module '/wasm/calcpad_wasm.js' {
+ export default function init(): Promise
+ export function evalSheet(lines: string[]): import('./engine/types.ts').EngineLineResult[]
+}
+
declare module 'virtual:pwa-register' {
export interface RegisterSWOptions {
immediate?: boolean
diff --git a/calcpad-web/tsconfig.node.json b/calcpad-web/tsconfig.node.json
index a96b3e5..957f38f 100644
--- a/calcpad-web/tsconfig.node.json
+++ b/calcpad-web/tsconfig.node.json
@@ -4,7 +4,7 @@
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
- "types": ["node"],
+ "types": [],
"skipLibCheck": true,
"moduleResolution": "bundler",
diff --git a/calcpad-web/vite.config.ts b/calcpad-web/vite.config.ts
index 7086c5e..d6fcf81 100644
--- a/calcpad-web/vite.config.ts
+++ b/calcpad-web/vite.config.ts
@@ -6,7 +6,7 @@ export default defineConfig({
plugins: [
react(),
VitePWA({
- registerType: 'prompt',
+ registerType: 'autoUpdate',
includeAssets: ['favicon.svg', 'icons/*.svg'],
manifest: {
name: 'CalcPad',
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..8e46ffc
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,5 @@
+services:
+ web:
+ build: .
+ ports:
+ - "8080:8080"
diff --git a/nginx.conf b/nginx.conf
new file mode 100644
index 0000000..697886b
--- /dev/null
+++ b/nginx.conf
@@ -0,0 +1,32 @@
+server {
+ listen 8080;
+ server_name localhost;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # SPA fallback — serve index.html for all non-file routes
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ # Cache static assets aggressively (hashed filenames)
+ location /assets/ {
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ }
+
+ # WASM files — correct MIME type + cache
+ location /wasm/ {
+ types { application/wasm wasm; }
+ default_type application/javascript;
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ add_header Cross-Origin-Embedder-Policy "require-corp";
+ add_header Cross-Origin-Opener-Policy "same-origin";
+ }
+
+ # Gzip
+ gzip on;
+ gzip_types text/plain text/css application/javascript application/json application/wasm image/svg+xml;
+ gzip_min_length 256;
+}