feat(web): implement complete workspace with themes, tabs, sidebar, and mobile

Transform CalcText from a single-document calculator into a full workspace
application with multi-document support, theming, and responsive mobile experience.

- Theme system: 5 presets (Light, Dark, Matrix, Midnight, Warm) + accent colors
- Document model with localStorage persistence and auto-save
- Tab bar with keyboard shortcuts (Ctrl+N/W/Tab/1-9), rename, close
- Sidebar with search, recent, favorites, folders, templates, drag-and-drop
- 5 templates: Budget, Invoice, Unit Converter, Trip Planner, Loan Calculator
- Status bar with cursor position, engine status, dedication to Igor Cassel
- Results panel: type-specific colors, click-to-copy, error hints
- Format toolbar: H, B, I, //, color labels with live preview toggle
- Syntax highlighting using theme CSS variables
- Error hover tooltips
- Mobile: bottom results tray, sidebar drawer, touch targets, safe areas
- Docker multi-stage build (Rust WASM + Vite + Nginx)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 09:12:05 -04:00
parent 806e2f1ec6
commit 0d38bd3108
78 changed files with 8175 additions and 421 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
target/
node_modules/
.git/
.gitignore
*.md
.vscode/
.idea/
.claude/
_scripts/
calcpad-web/dist/
calcpad-wasm/pkg/

3
Cargo.lock generated
View File

@@ -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]]

55
Dockerfile Normal file
View File

@@ -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;"]

View File

@@ -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_

View File

@@ -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_

View File

@@ -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_

View File

@@ -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_

View File

@@ -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 (180400px), 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 + 19 | 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 |
| **7681023px** | 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_

View File

@@ -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_

View File

@@ -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 (0200ms 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 (1224px 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? | 1224px 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_

View File

@@ -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_

View File

@@ -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+19** | 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 `<input>`, 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_

View File

@@ -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 |
| 7681023px | 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_

View File

@@ -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_

View File

@@ -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 `<input type="color">` |
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 <html>: `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 `<script>` in `<head>` BEFORE React mounts. Set `data-theme` on `<html>` synchronously.
- **CSS structure:** Each theme is a `[data-theme="name"]` selector block overriding `:root` variables.
- **Matrix performance:** Scanline effect uses `pointer-events: none` and is pure CSS. No performance impact.
- **Cross-platform:** macOS/Windows use native appearance APIs. Theme names map to native equivalents. Matrix theme = custom dark palette on all platforms.
- **PWA:** Update `<meta name="theme-color">` dynamically when theme changes for native status bar color.
---
_Created using Whiteport Design Studio (WDS) methodology_

View File

@@ -0,0 +1,278 @@
# 6.1 — Mobile Experience
**Previous Step:** ← [Theme System](../../05-theming/5.1-theme-system/5.1-theme-system.md)
---
## Page Metadata
| Property | Value |
|----------|-------|
| **Scenario** | 06 — Mobile Experience |
| **Page Number** | 6.1 |
| **Platform** | Web (PWA) — mobile viewport |
| **Page Type** | Responsive Adaptation (all app shell components) |
| **Viewport** | < 768px |
| **Interaction** | Touch-first |
---
## Overview
**Page Purpose:** Define how the entire CalcText workspace adapts to mobile. This is NOT a separate app it's the same app intelligently restructured for touch and small screens. Every feature remains accessible; nothing is hidden or removed.
**Current Problem:** The existing web app hides results, toolbar, and divider on mobile effectively removing the product's value on the most common device type.
**Success Criteria:**
- All features accessible on mobile (calculations, results, file management, themes)
- Touch targets >= 44px
- One-handed operation possible for core flow (type → see result)
- Feels like a native mobile app when installed as PWA
---
## Mobile Layout (< 768px)
```
┌─────────────────────────────┐
│ Header [≡] CalcText [🎨]│ 44px (touch-sized)
├─────────────────────────────┤
│ Tab Bar (horizontal scroll) │ 40px
├─────────────────────────────┤
│ │
│ │
│ Editor │
│ (full width) │
│ │
│ │
│ │
├─────────────────────────────┤
│ ═══ Results Tray │ 48px collapsed
│ Last: $4,300 │
├─────────────────────────────┤
│ Status (simplified) │ 24px
└─────────────────────────────┘
```
---
## Component Adaptations
### Header (Mobile)
| Property | Desktop | Mobile |
|----------|---------|--------|
| Height | 40px | 44px (touch target) |
| Left content | Logo + "CalcText" | [≡] hamburger + "CalcText" |
| Right content | Theme + Settings + ⌘ | [🎨] theme only |
| Padding | 12px 12px | 8px 12px |
| Hamburger | N/A | 44px × 44px touch target, opens sidebar drawer |
### Tab Bar (Mobile)
| Property | Desktop | Mobile |
|----------|---------|--------|
| Height | 36px | 40px |
| Tab min width | 100px | 80px (more compact) |
| Close button | Visible on hover/active | Hidden — swipe left to reveal |
| New tab (+) | After last tab | Sticky far-right |
| Scroll | Mouse wheel | Touch swipe horizontal |
| Active indicator | Top 2px accent border | Bottom 2px accent border (thumb reachable) |
### Editor (Mobile)
| Property | Desktop | Mobile |
|----------|---------|--------|
| Width | Flex (shared with results) | 100vw |
| Line padding | 12px | 12px |
| Gutter | 40px (line numbers) | 32px (compact numbers) |
| Font size | 15px | 15px (same — readable on mobile) |
| Line height | 24px | 24px (same) |
| Keyboard | Physical | Virtual — editor scrolls above keyboard |
#### Virtual Keyboard Handling
| Property | Value |
|----------|-------|
| Viewport | Uses `100dvh` (dynamic viewport height) to account for keyboard |
| Scroll | Editor auto-scrolls to keep cursor visible above keyboard |
| Results tray | Hides when keyboard is open (not enough space) |
| Status bar | Hides when keyboard is open |
### Results Tray (Mobile)
Replaces the side panel with a bottom tray.
| State | Height | Content |
|-------|--------|---------|
| **Collapsed** | 48px | Drag handle + last non-empty result |
| **Expanded** | 40vh (max 60vh) | Full scrollable results list |
| **Hidden** | 0px | When virtual keyboard is open |
#### Collapsed Tray
| Property | Value |
|----------|-------|
| Background | var(--bg-secondary) |
| Border top | 1px solid var(--border) |
| Drag handle | 32px × 4px pill, var(--border), centered |
| Content | "Last: {value}" — last non-empty result, text-xs |
| Tap | Expand tray |
| Swipe up | Expand tray |
#### Expanded Tray
| Property | Value |
|----------|-------|
| Content | All results paired with expressions |
| Item format | Line number + expression snippet + result value |
| Item height | 44px (touch-friendly) |
| Active line | var(--accent-bg) highlight |
| Tap result | Copy to clipboard + brief feedback |
| Swipe down | Collapse tray |
| Tap drag handle | Collapse |
| Scroll | Independent vertical scroll |
#### Tray Interaction
```
┌──────────────────────────────┐
│ ═══ (drag handle) │ Swipe up to expand
│ Last: $4,300 │
├──────────────────────────────┤ ← expanded state below
│ 5 salary 5,000 │
│ 6 freelance 1,200 │
│ 7 total_income 6,200 │ ← highlighted (active line)
│ 9 rent 1,500 │
│ 10 groceries 400 │
│ 13 total_expenses 1,900 │
│ 14 savings 4,300 │
└──────────────────────────────┘
```
### Sidebar Drawer (Mobile)
| Property | Value |
|----------|-------|
| Trigger | Hamburger [≡] in header |
| Width | 85vw, max 320px |
| Position | Fixed left, full height |
| Background | var(--bg) |
| Backdrop | rgba(0, 0, 0, 0.5) — tap to close |
| Animation | translateX(-100%) → translateX(0), 200ms ease-out |
| Close | Tap backdrop, swipe left, × button (top-right, 44px target) |
| Content | Same sections as desktop sidebar (search, recent, favorites, files, templates) |
| File tap | Opens document → auto-closes drawer |
| Search | Full width, 44px height (touch target) |
| File items | 44px height (touch target, up from 28px desktop) |
### Theme Picker (Mobile)
| Property | Value |
|----------|-------|
| Trigger | [🎨] button in header |
| Style | Bottom sheet (slides from bottom) |
| Height | Auto (content-driven), max 60vh |
| Border radius | 12px 12px 0 0 |
| Drag handle | 32px × 4px pill, centered |
| Items | 48px height per theme (touch targets) |
| Accent swatches | 32px circles, 8px gap (larger for touch) |
| Close | Swipe down, tap backdrop |
### Status Bar (Mobile)
| Property | Value |
|----------|-------|
| Height | 24px (same) |
| Content | Cursor position + engine status dot only |
| Hidden items | Line count, theme indicator (accessible elsewhere) |
| Hidden | When virtual keyboard is open |
---
## Touch Gestures
| Gesture | Context | Action |
|---------|---------|--------|
| Swipe left on tab | Tab bar | Reveal close button |
| Swipe up on results tray | Results | Expand tray |
| Swipe down on results tray | Results | Collapse tray |
| Swipe left from right edge | Sidebar drawer | Close drawer |
| Swipe down on bottom sheet | Theme picker | Close sheet |
| Long press on file | Sidebar | Show context menu |
| Long press on tab | Tab bar | Drag to reorder |
| Tap result | Results tray | Copy to clipboard |
| Pinch | Editor | Zoom font size (optional) |
---
## Breakpoint Details
| Width | Classification | Key Adaptations |
|-------|---------------|-----------------|
| **>= 1024px** | Desktop | Full 3-panel layout |
| **7681023px** | Tablet | Sidebar → overlay drawer. Editor + results side-by-side. |
| **480767px** | Mobile | Single column. Results tray. Sidebar drawer. Touch targets 44px. |
| **< 480px** | Small mobile | Same as mobile. Tab labels may truncate. Logo text hidden. |
### Tablet Specifics (7681023px)
| Component | Behavior |
|-----------|----------|
| Sidebar | Overlay drawer (hamburger toggle) instead of persistent panel |
| Editor + Results | Side-by-side with divider (same as desktop) |
| Tab bar | Same as desktop (enough width) |
| Header | Show hamburger [≡] instead of persistent sidebar |
| Theme picker | Dropdown (same as desktop) |
---
## PWA Mobile Enhancements
| Feature | Implementation |
|---------|---------------|
| Standalone display | `display: standalone` no browser chrome |
| Status bar color | `theme-color` meta tag updates per theme |
| Safe areas | `env(safe-area-inset-*)` for notched devices |
| Splash screen | Theme-colored background + CalcText logo |
| Home screen icon | App icon with accent color ring |
| Orientation | Portrait preferred, landscape supported |
### Safe Area Padding
```css
.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);
}
```
---
## Page States (Mobile-Specific)
| State | When | Behavior |
|-------|------|----------|
| **Keyboard open** | User tapped editor | Results tray + status bar hide. Editor fills available space. |
| **Keyboard closed** | User tapped outside or pressed Done | Results tray + status bar reappear. |
| **Drawer open** | Hamburger tapped | Sidebar overlays. Backdrop captures tap to close. |
| **Tray expanded** | User swiped up | 40vh results list. Editor partially visible above. |
| **Offline** | No network | Status bar shows offline indicator. All features work. |
---
## Technical Notes
- **Viewport units:** Use `dvh` (dynamic viewport height) not `vh` to handle mobile browser chrome and virtual keyboard.
- **Touch events:** Use `touchstart`/`touchmove`/`touchend` for swipe gestures. Consider `passive: true` for scroll performance.
- **Overscroll:** Disable `overscroll-behavior: none` on app container to prevent pull-to-refresh interference.
- **iOS safe areas:** Test with iPhone notch and dynamic island. Apply `env(safe-area-inset-*)`.
- **Android back button:** In PWA mode, back button should close drawers/sheets before navigating back.
- **Font scaling:** Respect system font size on mobile. Use relative units where possible.
- **Cross-platform:** Mobile web layout does NOT need to port to native mobile (that's a separate app). But interaction patterns (swipe, long-press) inform native mobile design if built later.
---
_Created using Whiteport Design Studio (WDS) methodology_

View File

@@ -0,0 +1,104 @@
# Design System: CalcText
> Components, tokens, and patterns that grow from actual usage — not upfront planning.
**Created:** 2026-03-17
**Phase:** 7 — Design System (optional)
**Agent:** Freya (Designer)
---
## What Belongs Here
The Design System captures reusable patterns that emerge during UX Design (Phase 4). It is not designed upfront — it crystallizes from real page specifications.
**What goes here:**
- **Design Tokens** — Colors, spacing, typography, shadows
- **Components** — Buttons, inputs, cards, navigation elements
- **Patterns** — Layouts, form structures, content blocks
- **Visual Design** — Mood boards, design concepts, color and typography explorations
- **Assets** — Logos, icons, images, graphics
---
## Folder Structure
```
D-Design-System/
├── 00-design-system.md <- This file (hub + guide)
├── 01-Visual-Design/
│ ├── mood-boards/
│ ├── design-concepts/
│ ├── color-exploration/
│ └── typography-tests/
├── 02-Assets/
│ ├── logos/
│ ├── icons/
│ ├── images/
│ └── graphics/
└── components/
├── interactive/
├── form/
├── layout/
├── content/
├── feedback/
└── navigation/
```
---
## Spacing Scale
_Will be defined during first design session._
| Token | Value | Use |
|-------|-------|-----|
| space-3xs | — | Hairline gaps |
| space-2xs | — | Minimal spacing |
| space-xs | — | Tight spacing |
| space-sm | — | Small gaps |
| **space-md** | — | **Default element spacing** |
| space-lg | — | Comfortable spacing |
| space-xl | — | Section padding |
| space-2xl | — | Section gaps |
| space-3xl | — | Page-level breathing room |
---
## Type Scale
_Will be defined during first design session._
| Token | Value | Use |
|-------|-------|-----|
| text-3xs | — | Fine print |
| text-2xs | — | Metadata |
| text-xs | — | Captions |
| text-sm | — | Labels |
| text-md | — | Body text |
| text-lg | — | Emphasis |
| text-xl | — | Subheadings |
| text-2xl | — | Section titles |
| text-3xl | — | Hero headings |
---
## Tokens
_Additional design tokens will be documented here as they emerge from page specifications._
---
## Patterns
_Patterns will be documented here as spacing objects recur across pages._
---
## Components
_Components will be documented here as patterns emerge across scenarios._
---
_Created using Whiteport Design Studio (WDS) methodology_

View File

@@ -0,0 +1,117 @@
# Design Log
**Project:** CalcText
**Started:** 2026-03-17
**Method:** Whiteport Design Studio (WDS)
---
## Backlog
> Business-value items. Add links to detail files if needed.
- [x] Create simplified product brief (brownfield) — Phase 1
- [x] Define workspace scenarios — Phase 3
- [x] Design app shell (3-panel layout: sidebar + editor + results) — Phase 4
- [x] Design theme engine (Light, Dark, Matrix, custom) — Phase 4
- [x] Design tab system for multi-document — Phase 4
- [x] Design file sidebar with folders — Phase 4
- [x] Design elevated results panel — Phase 4
- [x] Design mobile experience — Phase 4
---
## Current
| Task | Started | Agent |
|------|---------|-------|
| — | — | — |
> Brownfield codebase scan completed 2026-03-17. See `A-Product-Brief/01-brownfield-analysis.md`
**Rules:** Mark what you start. Complete it when done (move to Log). One task at a time per agent.
---
## Design Loop Status
> Per-page design progress. Updated by agents at every design transition.
| Scenario | Step | Page | Status | Updated |
|----------|------|------|--------|---------|
| 01-workspace-shell | 1.1 | App Shell | specified | 2026-03-17 |
| 01-workspace-shell | 1.2 | Status Bar | specified | 2026-03-17 |
| 02-calc-experience | 2.1 | Editor | specified | 2026-03-17 |
| 02-calc-experience | 2.2 | Results Panel | specified | 2026-03-17 |
| 03-doc-management | 3.1 | Tab Bar & Lifecycle | specified | 2026-03-17 |
| 04-file-org | 4.1 | Sidebar | specified | 2026-03-17 |
| 04-file-org | 4.2 | Templates | specified | 2026-03-17 |
| 05-theming | 5.1 | Theme System | specified | 2026-03-17 |
| 06-mobile | 6.1 | Mobile Shell | specified | 2026-03-17 |
**Status values:** `discussed` -> `wireframed` -> `specified` -> `explored` -> `building` -> `built` -> `approved` | `removed`
**How to use:**
- **Append a row** when a page reaches a new status (do not overwrite — latest row per page is current status)
- **Read on startup** to see where the project stands and what to suggest next
---
## Log
### 2026-03-18 — Full workspace implementation complete
- Theme system: 5 presets (Light, Dark, Matrix, Midnight, Warm) + 6 accent colors
- Document model with localStorage persistence
- Tab bar with multi-document support, rename, keyboard shortcuts
- Sidebar with search, recent, favorites, folders, templates, drag-and-drop
- 5 templates: Budget, Invoice, Unit Converter, Trip Planner, Loan Calculator
- Status bar with cursor position, engine status, dedication to Igor Cassel
- Results panel redesign: type-specific colors, click-to-copy
- Format toolbar: H, B, I, //, color labels
- Live Preview toggle (markdown → formatted)
- Syntax highlighting using theme CSS variables
- Error hover tooltips
- Mobile experience: bottom results tray, sidebar drawer, touch targets, safe areas
- Alignment toolbar re-integrated in header
### 2026-03-17 — All 6 scenarios designed (Phase 4 complete)
- 8 page specifications created across 6 scenarios
- App Shell, Status Bar, Editor, Results Panel, Tab Bar, Sidebar, Templates, Theme System, Mobile
- 5 preset themes defined (Light, Dark, Matrix, Midnight, Warm)
- 7 accent color presets
- Complete localStorage schema
- Mobile experience redesigned: bottom results tray, sidebar drawer, touch gestures
- 14 keyboard shortcuts defined
- 5 templates with full content
### 2026-03-17 — Simplified product brief completed (Phase 1)
- Scope: workspace evolution (themes, tabs, sidebar, persistence)
- Strategy: web-first, then replicate to macOS and Windows
- Deferred: accounts, cloud sync, collaboration (backend needed)
- Saved to `A-Product-Brief/project-brief.md`
### 2026-03-17 — Brownfield codebase scan completed
- Full web app scan: 6 components, 4 editor extensions, 3 hooks, 15 CSS tokens
- Cross-platform comparison with macOS native app
- Key findings: results panel too loud, no manual dark mode toggle, mobile experience gutted, no document model
- Analysis saved to `A-Product-Brief/01-brownfield-analysis.md`
### 2026-03-17 — Project initialized (Phase 0)
- Type: brownfield
- Complexity: complex (web application)
- Tech stack: React 19 + CodeMirror 6 + Vite 7 + WASM
- Component library: Custom CSS (CSS custom properties)
- Brief level: simplified
- Stakes: enterprise/high
- Involvement: autonomous execution
- Brainstorm session established product vision: evolve from single-document calculator to full workspace with themes, tabs, sidebar, file management
---
## About This Folder
- **This file** — Single source of truth for project progress
- **agent-experiences/** — Compressed insights from design discussions (dated files)
- **wds-project-outline.yaml** — Project configuration from Phase 0 setup
**Do not modify `wds-project-outline.yaml`** — it is the source of truth for project configuration.

View File

@@ -0,0 +1,50 @@
# WDS Project Outline
# Generated: 2026-03-17
# Phase 0: Project Setup
project_name: CalcText
project_type: brownfield
product_complexity: complex
tech_stack: react
component_library: custom
root_folder: "_bmad-output"
brief_level: simplified
design_system_mode: recommended
existing_materials:
has_materials: false
notes: "Brainstorm insights from party mode session available in conversation context"
project_context:
stakes: enterprise
description: "Cross-platform notepad calculator evolving into a full workspace application"
working_relationship:
involvement: autonomous
user_role: project_manager
recommendation_style: direct_guidance
stakeholder_notes: null
# Brownfield Context
brownfield:
existing_product: "CalcText Web App"
tech_stack_detail: "React 19 + CodeMirror 6 + Vite 7 + WASM engine"
current_state: "Two-column editor (input + results), PWA, light/dark theme"
evolution_goals:
- "Multi-theme system (Light, Dark, Matrix, custom)"
- "Tab system for multiple calctext documents"
- "File sidebar with folders and organization"
- "User accounts and cloud persistence (deferred - backend)"
- "Elevated results panel design"
- "Mobile experience redesign"
# Phase Routing
phases:
phase_0: completed
phase_1: "simplified brief (brownfield)"
phase_2: skipped
phase_3: "scenarios from brownfield analysis"
phase_4: "UX design with Freya"
phase_5: "custom component development"
phase_7: "design system extraction"
phase_8: "product evolution entry point"

95
_scripts/smoke-test.sh Executable file
View File

@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# smoke-test.sh — Quick verification of CalcPad CLI and web app
set -euo pipefail
PASS=0
FAIL=0
SKIP=0
pass() { echo " PASS: $1"; ((PASS++)); }
fail() { echo " FAIL: $1"; ((FAIL++)); }
skip() { echo " SKIP: $1"; ((SKIP++)); }
# ─── CLI Tests ──────────────────────────────────────────────────────────────────
echo ""
echo "=== CLI Tests ==="
if command -v cargo &>/dev/null; then
# 1. Basic arithmetic
result=$(cargo run -q -p calcpad-cli -- "2 + 3" 2>/dev/null)
if [[ "$result" == *"5"* ]]; then pass "arithmetic: 2 + 3 = 5"
else fail "arithmetic: expected 5, got '$result'"; fi
# 2. Pipe / stdin
result=$(echo -e "x = 10\nx * 3" | cargo run -q -p calcpad-cli 2>/dev/null)
if [[ "$result" == *"30"* ]]; then pass "pipe/stdin: x=10, x*3 = 30"
else fail "pipe/stdin: expected 30 in output, got '$result'"; fi
# 3. JSON output
result=$(cargo run -q -p calcpad-cli -- --format json "42" 2>/dev/null)
if [[ "$result" == *"42"* ]] && [[ "$result" == *"{"* ]]; then pass "JSON output"
else fail "JSON output: expected JSON with 42, got '$result'"; fi
# 4. Error exit code for invalid input
if cargo run -q -p calcpad-cli -- "???invalid???" &>/dev/null; then
fail "error exit code: expected non-zero exit"
else
pass "error exit code: non-zero on bad input"
fi
# 5. cargo test suite
if cargo test --workspace -q 2>/dev/null; then pass "cargo test --workspace"
else fail "cargo test --workspace"; fi
else
skip "CLI tests (cargo not found)"
fi
# ─── Web Tests ──────────────────────────────────────────────────────────────────
echo ""
echo "=== Web Tests ==="
WEB_URL="${WEB_URL:-http://localhost:8080}"
if curl -sf "$WEB_URL" &>/dev/null; then
# 1. index.html returns 200
status=$(curl -sf -o /dev/null -w "%{http_code}" "$WEB_URL/")
if [[ "$status" == "200" ]]; then pass "index.html 200"
else fail "index.html: got HTTP $status"; fi
# 2. HTML contains expected content
body=$(curl -sf "$WEB_URL/")
if [[ "$body" == *"CalcPad"* ]]; then pass "HTML contains CalcPad"
else fail "HTML missing CalcPad title"; fi
# 3. WASM JS glue is served
wasm_js_status=$(curl -sf -o /dev/null -w "%{http_code}" "$WEB_URL/wasm/calcpad_wasm.js")
if [[ "$wasm_js_status" == "200" ]]; then pass "WASM JS served"
else fail "WASM JS: got HTTP $wasm_js_status"; fi
# 4. WASM binary content-type
ct=$(curl -sf -o /dev/null -w "%{content_type}" "$WEB_URL/wasm/calcpad_wasm_bg.wasm")
if [[ "$ct" == *"wasm"* ]]; then pass "WASM content-type: $ct"
else fail "WASM content-type: expected wasm, got '$ct'"; fi
# 5. SPA fallback — non-existent route returns index.html
fallback=$(curl -sf "$WEB_URL/nonexistent/route")
if [[ "$fallback" == *"CalcPad"* ]]; then pass "SPA fallback"
else fail "SPA fallback: did not return index.html"; fi
else
skip "Web tests ($WEB_URL not reachable — run 'docker compose up -d' first)"
fi
# ─── Summary ────────────────────────────────────────────────────────────────────
echo ""
echo "=== Summary ==="
echo " Passed: $PASS Failed: $FAIL Skipped: $SKIP"
if [[ $FAIL -gt 0 ]]; then exit 1; fi
exit 0

View File

@@ -51,13 +51,10 @@ fn is_error(result: &CalcResult) -> bool {
}
fn is_empty_error(result: &CalcResult) -> bool {
// The engine returns CalcResult::error("empty input", ..) or
// CalcResult::error("no expression found", ..) for blank/comment lines.
if let CalcValue::Error { ref message, .. } = result.value {
message == "empty input" || message == "no expression found"
} else {
false
}
matches!(
result.metadata.result_type,
ResultType::Empty | ResultType::NonCalculable
)
}
fn error_message(result: &CalcResult) -> Option<&str> {

View File

@@ -6,13 +6,17 @@ edition = "2021"
[lib]
crate-type = ["cdylib", "staticlib", "rlib"]
[features]
default = ["network"]
network = ["ureq"]
[dependencies]
chrono = { version = "0.4", features = ["serde"] }
chrono-tz = "0.10"
dashu = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
ureq = { version = "2", features = ["json"] }
ureq = { version = "2", features = ["json"], optional = true }
[dev-dependencies]
tempfile = "3"

View File

@@ -137,6 +137,7 @@ impl CryptoProvider {
/// Refresh rates from the CoinGecko API.
/// Returns Ok(()) on success, Err with message on failure.
/// Falls back to cached rates if the API call fails.
#[cfg(feature = "network")]
pub fn refresh(&mut self) -> Result<(), String> {
if !self.is_stale() {
return Ok(());
@@ -163,7 +164,18 @@ impl CryptoProvider {
}
}
/// Refresh is unavailable without the `network` feature.
#[cfg(not(feature = "network"))]
pub fn refresh(&mut self) -> Result<(), String> {
if self.cache.is_some() {
Ok(())
} else {
Err("Network support not available".to_string())
}
}
/// Fetch rates from CoinGecko API.
#[cfg(feature = "network")]
fn fetch_from_api(&self) -> Result<HashMap<String, CryptoRate>, String> {
let url = format!(
"{}/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=100&page=1&sparkline=false",

View File

@@ -16,6 +16,7 @@ use std::collections::HashMap;
// Provider implementations
// ---------------------------------------------------------------------------
#[cfg(feature = "network")]
/// Open Exchange Rates API provider (<https://openexchangerates.org>).
///
/// Requires an API key. Free tier provides 1,000 requests/month with USD base.
@@ -24,6 +25,7 @@ pub struct OpenExchangeRatesProvider {
api_key: String,
}
#[cfg(feature = "network")]
impl OpenExchangeRatesProvider {
pub fn new(api_key: &str) -> Self {
Self {
@@ -32,6 +34,7 @@ impl OpenExchangeRatesProvider {
}
}
#[cfg(feature = "network")]
impl CurrencyProvider for OpenExchangeRatesProvider {
fn fetch_rates(&self) -> Result<ExchangeRates, CurrencyError> {
let url = format!(
@@ -73,6 +76,7 @@ impl CurrencyProvider for OpenExchangeRatesProvider {
}
}
#[cfg(feature = "network")]
/// exchangerate.host API provider (<https://exchangerate.host>).
///
/// Free tier available; no API key required for basic usage.
@@ -80,6 +84,7 @@ pub struct ExchangeRateHostProvider {
api_key: Option<String>,
}
#[cfg(feature = "network")]
impl ExchangeRateHostProvider {
pub fn new(api_key: Option<&str>) -> Self {
Self {
@@ -88,6 +93,7 @@ impl ExchangeRateHostProvider {
}
}
#[cfg(feature = "network")]
impl CurrencyProvider for ExchangeRateHostProvider {
fn fetch_rates(&self) -> Result<ExchangeRates, CurrencyError> {
let mut url = "https://api.exchangerate.host/live?source=USD".to_string();

View File

@@ -13,9 +13,9 @@ pub mod rates;
pub mod symbols;
pub use crypto::{CryptoProvider, CryptoProviderConfig, CryptoRate};
pub use fiat::{
ExchangeRateHostProvider, FiatCurrencyProvider, OpenExchangeRatesProvider, fallback_rates,
};
#[cfg(feature = "network")]
pub use fiat::{ExchangeRateHostProvider, OpenExchangeRatesProvider};
pub use fiat::{FiatCurrencyProvider, fallback_rates};
pub use rates::{ExchangeRateCache, ExchangeRates, ProviderConfig, RateMetadata, RateSource};
pub use symbols::{is_currency_code, is_crypto_symbol, resolve_currency};

View File

@@ -215,6 +215,14 @@ impl Parser {
Ok(Spanned::new(ExprKind::PrevRef, span))
}
TokenKind::Percent(val) => {
// Standalone percentage: 8% = 0.08
let val = *val;
let span = tok.span;
self.advance();
Ok(Spanned::new(ExprKind::Number(val / 100.0), span))
}
_ => Err(ParseError::new(
format!("unexpected token: {:?}", tok.kind),
tok.span,

View File

@@ -4,12 +4,19 @@ use crate::lexer::tokenize;
use crate::parser::parse;
use crate::span::Span;
use crate::types::CalcResult;
use crate::variables::aggregators;
/// Evaluate a single line of input and return the result.
pub fn eval_line(input: &str, ctx: &mut EvalContext) -> CalcResult {
let trimmed = input.trim();
if trimmed.is_empty() {
return CalcResult::error("empty input", Span::new(0, 0));
return CalcResult::empty(Span::new(0, 0));
}
// Detect headings (# Title) and aggregator keywords (sum, total, etc.)
// before tokenizing — the lexer misinterprets `#` as a line reference prefix.
if aggregators::is_heading(trimmed) || aggregators::detect_aggregator(trimmed).is_some() {
return CalcResult::non_calculable(Span::new(0, trimmed.len()));
}
let tokens = tokenize(trimmed);
@@ -24,7 +31,7 @@ pub fn eval_line(input: &str, ctx: &mut EvalContext) -> CalcResult {
)
});
if !has_expr {
return CalcResult::error("no expression found", Span::new(0, trimmed.len()));
return CalcResult::non_calculable(Span::new(0, trimmed.len()));
}
match parse(tokens) {

View File

@@ -217,7 +217,7 @@ impl SheetContext {
// Heading lines produce no result -- skip them
if entry_is_heading {
let result = CalcResult::error("no expression found", Span::new(0, entry_source.len()));
let result = CalcResult::non_calculable(Span::new(0, entry_source.len()));
ordered_results.push(result.clone());
ordered_sources.push(entry_source);
self.results.insert(idx, result);
@@ -251,7 +251,7 @@ impl SheetContext {
// Store as __line_N for line reference support
ctx.set_variable(&format!("__line_{}", idx + 1), result.clone());
// Update __prev for prev/ans support
if result.result_type() != crate::types::ResultType::Error {
if result.is_calculable() {
ctx.set_variable("__prev", result.clone());
}
ordered_results.push(result);
@@ -261,7 +261,11 @@ impl SheetContext {
// Text/empty lines produce no result -- skip them
if entry_is_text || entry_source.trim().is_empty() {
let result = CalcResult::error("no expression found", Span::new(0, entry_source.len()));
let result = if entry_source.trim().is_empty() {
CalcResult::empty(Span::new(0, 0))
} else {
CalcResult::non_calculable(Span::new(0, entry_source.len()))
};
ordered_results.push(result.clone());
ordered_sources.push(entry_source);
self.results.insert(idx, result);
@@ -276,7 +280,7 @@ impl SheetContext {
// Store as __line_N for line reference support (1-indexed)
ctx.set_variable(&format!("__line_{}", idx + 1), result.clone());
// Update __prev for prev/ans support (only for non-error results)
if result.result_type() != crate::types::ResultType::Error {
if result.is_calculable() {
ctx.set_variable("__prev", result.clone());
}
ordered_results.push(result);
@@ -299,7 +303,7 @@ impl SheetContext {
}
// Replay line ref and prev for cached results too
ctx.set_variable(&format!("__line_{}", idx + 1), cached.clone());
if cached.result_type() != crate::types::ResultType::Error {
if cached.is_calculable() {
ctx.set_variable("__prev", cached.clone());
}
ordered_results.push(cached);

View File

@@ -12,6 +12,8 @@ pub enum ResultType {
DateTime,
TimeDelta,
Boolean,
Empty,
NonCalculable,
Error,
}
@@ -24,6 +26,8 @@ impl fmt::Display for ResultType {
ResultType::DateTime => write!(f, "DateTime"),
ResultType::TimeDelta => write!(f, "TimeDelta"),
ResultType::Boolean => write!(f, "Boolean"),
ResultType::Empty => write!(f, "Empty"),
ResultType::NonCalculable => write!(f, "NonCalculable"),
ResultType::Error => write!(f, "Error"),
}
}
@@ -168,6 +172,36 @@ impl CalcResult {
}
}
pub fn empty(span: Span) -> Self {
CalcResult {
value: CalcValue::Error {
message: "empty input".to_string(),
span: span.into(),
},
metadata: ResultMetadata {
span: span.into(),
result_type: ResultType::Empty,
display: String::new(),
raw_value: None,
},
}
}
pub fn non_calculable(span: Span) -> Self {
CalcResult {
value: CalcValue::Error {
message: "no expression found".to_string(),
span: span.into(),
},
metadata: ResultMetadata {
span: span.into(),
result_type: ResultType::NonCalculable,
display: String::new(),
raw_value: None,
},
}
}
pub fn error(message: &str, span: Span) -> Self {
CalcResult {
value: CalcValue::Error {
@@ -186,13 +220,34 @@ impl CalcResult {
pub fn result_type(&self) -> ResultType {
self.metadata.result_type
}
/// Returns true if this result is a computed value (not empty, non-calculable, or error).
pub fn is_calculable(&self) -> bool {
!matches!(
self.metadata.result_type,
ResultType::Empty | ResultType::NonCalculable | ResultType::Error
)
}
}
fn format_number(val: f64) -> String {
if val == val.floor() && val.abs() < 1e15 {
format!("{}", val as i64)
} else {
format!("{}", val)
// Round to 10 significant figures to avoid f64 noise
let abs = val.abs();
let magnitude = if abs > 0.0 { abs.log10().floor() as i32 } else { 0 };
let precision = (10 - magnitude - 1).max(0) as usize;
let formatted = format!("{:.prec$}", val, prec = precision);
// Strip trailing zeros after decimal point
if formatted.contains('.') {
formatted
.trim_end_matches('0')
.trim_end_matches('.')
.to_string()
} else {
formatted
}
}
}

View File

@@ -101,14 +101,14 @@ fn pipeline_eval_division_by_zero() {
fn pipeline_eval_empty_input() {
let mut ctx = EvalContext::new();
let result = eval_line("", &mut ctx);
assert_eq!(result.result_type(), ResultType::Error);
assert_eq!(result.result_type(), ResultType::Empty);
}
#[test]
fn pipeline_eval_comment_only() {
let mut ctx = EvalContext::new();
let result = eval_line("// this is a comment", &mut ctx);
assert_eq!(result.result_type(), ResultType::Error);
assert_eq!(result.result_type(), ResultType::NonCalculable);
}
// ===== Variable assignment tests (eval_sheet) =====

View File

@@ -123,7 +123,7 @@ fn sheet_with_comments() {
],
&mut ctx,
);
assert_eq!(results[0].result_type(), ResultType::Error); // comment
assert_eq!(results[0].result_type(), ResultType::NonCalculable); // comment
assert_eq!(results[3].metadata.display, "2000");
}
@@ -132,7 +132,7 @@ fn sheet_with_empty_lines() {
let mut ctx = EvalContext::new();
let results = eval_sheet(&["x = 10", "", "y = x + 5"], &mut ctx);
assert_eq!(results[0].metadata.display, "10");
assert_eq!(results[1].result_type(), ResultType::Error); // empty line
assert_eq!(results[1].result_type(), ResultType::Empty); // empty line
assert_eq!(results[2].metadata.display, "15");
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>CalcPad.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

View File

@@ -3,7 +3,7 @@
import PackageDescription
// Path to the Rust static library built by `cargo build --release`
let rustLibPath = "../calcpad-engine/target/release"
let rustLibPath = "../target/release"
let package = Package(
name: "CalcPad",

View File

@@ -1,10 +1,20 @@
import AppKit
import SwiftUI
@main
struct CalcPadApp: App {
init() {
// Without a .app bundle, macOS treats the process as a background app
// that cannot receive keyboard input. This makes it a regular foreground app.
NSApplication.shared.setActivationPolicy(.regular)
}
var body: some Scene {
WindowGroup {
ContentView()
.onAppear {
NSApplication.shared.activate(ignoringOtherApps: true)
}
}
.defaultSize(width: 800, height: 600)
}

View File

@@ -3,12 +3,13 @@ import SwiftUI
/// Displays calculation results in a vertical column, one result per line,
/// aligned to match the corresponding editor lines.
/// Uses NSViewRepresentable wrapping NSScrollView + NSTextView for pixel-perfect
/// line height alignment with the editor.
/// Uses NSViewRepresentable wrapping NSScrollView + StripedTextView for
/// pixel-perfect line height alignment with the editor, including zebra striping.
struct AnswerColumnView: NSViewRepresentable {
let results: [LineResult]
@Binding var scrollOffset: CGFloat
var font: NSFont
var alignment: NSTextAlignment = .right
func makeCoordinator() -> Coordinator {
Coordinator()
@@ -24,23 +25,23 @@ struct AnswerColumnView: NSViewRepresentable {
scrollView.verticalScrollElasticity = .none
scrollView.horizontalScrollElasticity = .none
let textView = NSTextView()
let textView = StripedTextView()
textView.isEditable = false
textView.isSelectable = true
textView.isRichText = true
textView.usesFontPanel = false
textView.drawsBackground = false
textView.drawsBackground = true
textView.backgroundColor = .clear
// Match editor text container settings for alignment
textView.textContainer?.lineFragmentPadding = 4
textView.textContainerInset = NSSize(width: 8, height: 8)
// Disable line wrapping to match editor behavior
textView.isHorizontallyResizable = true
textView.textContainer?.widthTracksTextView = false
// Let the text container track the scroll view width
textView.isHorizontallyResizable = false
textView.textContainer?.widthTracksTextView = true
textView.textContainer?.containerSize = NSSize(
width: CGFloat.greatestFiniteMagnitude,
width: 0,
height: CGFloat.greatestFiniteMagnitude
)
textView.maxSize = NSSize(
@@ -73,14 +74,15 @@ struct AnswerColumnView: NSViewRepresentable {
let attributedString = NSMutableAttributedString()
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .right
paragraphStyle.alignment = alignment
let resultColor = NSColor.secondaryLabelColor
let errorColor = NSColor.systemRed
for (index, lineResult) in results.enumerated() {
let displayText = lineResult.result ?? ""
let color = lineResult.isError ? errorColor : resultColor
// Only show successful results errors stay as underlines in the editor
let displayText = lineResult.isError ? "" : (lineResult.result ?? "")
let color = resultColor
let attributes: [NSAttributedString.Key: Any] = [
.font: font,

View File

@@ -1,6 +1,52 @@
import AppKit
import SwiftUI
// MARK: - StripedTextView
/// NSTextView subclass that draws alternating row background stripes.
/// Used by both the editor and answer column to provide zebra-striping
/// that visually connects lines across panes.
final class StripedTextView: NSTextView {
/// Subtle stripe color for odd-numbered lines.
static let stripeColor = NSColor.secondaryLabelColor.withAlphaComponent(0.04)
override func drawBackground(in rect: NSRect) {
super.drawBackground(in: rect)
guard let layoutManager = layoutManager,
let textContainer = textContainer else { return }
let stripeColor = Self.stripeColor
// We need the absolute line index (not just visible lines) so that
// the stripe pattern stays consistent when scrolling and matches
// the answer column. Walk all line fragments from the start of the
// document, but only fill those that intersect the dirty rect.
let fullGlyphRange = layoutManager.glyphRange(for: textContainer)
var lineIndex = 0
layoutManager.enumerateLineFragments(forGlyphRange: fullGlyphRange) {
fragmentRect, _, _, _, _ in
if lineIndex % 2 == 1 {
var stripeRect = fragmentRect
stripeRect.origin.x = 0
stripeRect.size.width = self.bounds.width
stripeRect.origin.y += self.textContainerInset.height
// Only draw if the stripe intersects the dirty rect
if stripeRect.intersects(rect) {
stripeColor.setFill()
NSBezierPath.fill(stripeRect)
}
}
lineIndex += 1
}
}
}
// MARK: - EditorTextView
/// A SwiftUI wrapper around NSTextView for the editor pane.
/// Uses NSViewRepresentable to bridge AppKit's NSTextView into SwiftUI,
/// providing line-level control, scroll position access, and performance
@@ -9,6 +55,7 @@ struct EditorTextView: NSViewRepresentable {
@Binding var text: String
@Binding var scrollOffset: CGFloat
var font: NSFont
var alignment: NSTextAlignment = .left
func makeCoordinator() -> Coordinator {
Coordinator(self)
@@ -22,7 +69,7 @@ struct EditorTextView: NSViewRepresentable {
scrollView.borderType = .noBorder
scrollView.drawsBackground = false
let textView = NSTextView()
let textView = StripedTextView()
textView.isEditable = true
textView.isSelectable = true
textView.allowsUndo = true
@@ -33,11 +80,11 @@ struct EditorTextView: NSViewRepresentable {
textView.isAutomaticTextReplacementEnabled = false
textView.isAutomaticSpellingCorrectionEnabled = false
// Disable line wrapping horizontal scroll instead
textView.isHorizontallyResizable = true
textView.textContainer?.widthTracksTextView = false
// Enable line wrapping tracks text view width
textView.isHorizontallyResizable = false
textView.textContainer?.widthTracksTextView = true
textView.textContainer?.containerSize = NSSize(
width: CGFloat.greatestFiniteMagnitude,
width: 0,
height: CGFloat.greatestFiniteMagnitude
)
textView.maxSize = NSSize(
@@ -49,9 +96,12 @@ struct EditorTextView: NSViewRepresentable {
textView.font = font
textView.textColor = .textColor
textView.backgroundColor = .clear
textView.drawsBackground = false
textView.drawsBackground = true
textView.insertionPointColor = .textColor
// Set alignment
textView.alignment = alignment
// Set the text
textView.string = text
@@ -84,11 +134,19 @@ struct EditorTextView: NSViewRepresentable {
// Update font if changed
if textView.font != font {
textView.font = font
// Reapply font to entire text storage
let range = NSRange(location: 0, length: textView.textStorage?.length ?? 0)
textView.textStorage?.addAttribute(.font, value: font, range: range)
}
// Update alignment if changed
if textView.alignment != alignment {
textView.alignment = alignment
let range = NSRange(location: 0, length: textView.textStorage?.length ?? 0)
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = alignment
textView.textStorage?.addAttribute(.paragraphStyle, value: paragraphStyle, range: range)
}
// Update text only if it actually changed (avoid feedback loops)
if textView.string != text {
let selectedRanges = textView.selectedRanges

View File

@@ -1,13 +1,17 @@
import AppKit
import Combine
import SwiftUI
/// The main two-column editor layout: text editor on the left, results on the right.
/// Scrolling is synchronized between both columns.
/// Includes a toolbar for justification toggles and alternating row striping.
struct TwoColumnEditorView: View {
@State private var text: String = ""
@State private var text: String = "# CalcPad\n\n// Basic arithmetic\n2 + 3\n10 * 4.5\n100 / 7\n\n// Variables\nprice = 49.99\nquantity = 3\nsubtotal = price * quantity\n\n// Percentages\ntax = subtotal * 8%\ntotal = subtotal + tax\n\n// Functions\nsqrt(144)\n2 ^ 10\n"
@State private var scrollOffset: CGFloat = 0
@State private var results: [LineResult] = []
@State private var evaluationTask: Task<Void, Never>?
@State private var editorAlignment: NSTextAlignment = .left
@State private var resultsAlignment: NSTextAlignment = .right
/// Uses the Rust FFI engine. Falls back to StubCalculationEngine if the Rust
/// library is not linked (e.g., during UI-only development).
@@ -18,36 +22,92 @@ struct TwoColumnEditorView: View {
/// Font that respects the user's accessibility / Dynamic Type settings.
private var editorFont: NSFont {
// Use the system's preferred monospaced font size, which scales with
// Accessibility > Display > Text Size in System Settings (macOS 14+).
let baseSize = NSFont.systemFontSize
// Scale with accessibility settings via the body text style size
let preferredSize = NSFont.preferredFont(forTextStyle: .body, options: [:]).pointSize
// Use the larger of system default or accessibility-preferred size
let size = max(baseSize, preferredSize)
return NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
}
var body: some View {
VStack(spacing: 0) {
// Justification toolbar
HStack(spacing: 0) {
// Editor alignment buttons
HStack(spacing: 4) {
Button(action: { editorAlignment = .left }) {
Image(systemName: "text.alignleft")
}
.buttonStyle(.borderless)
.foregroundColor(editorAlignment == .left ? .accentColor : .secondary)
.help("Align editor text left")
Button(action: { editorAlignment = .center }) {
Image(systemName: "text.aligncenter")
}
.buttonStyle(.borderless)
.foregroundColor(editorAlignment == .center ? .accentColor : .secondary)
.help("Align editor text center")
Button(action: { editorAlignment = .right }) {
Image(systemName: "text.alignright")
}
.buttonStyle(.borderless)
.foregroundColor(editorAlignment == .right ? .accentColor : .secondary)
.help("Align editor text right")
}
.padding(.horizontal, 8)
Spacer()
// Results alignment buttons
HStack(spacing: 4) {
Button(action: { resultsAlignment = .left }) {
Image(systemName: "text.alignleft")
}
.buttonStyle(.borderless)
.foregroundColor(resultsAlignment == .left ? .accentColor : .secondary)
.help("Align results left")
Button(action: { resultsAlignment = .center }) {
Image(systemName: "text.aligncenter")
}
.buttonStyle(.borderless)
.foregroundColor(resultsAlignment == .center ? .accentColor : .secondary)
.help("Align results center")
Button(action: { resultsAlignment = .right }) {
Image(systemName: "text.alignright")
}
.buttonStyle(.borderless)
.foregroundColor(resultsAlignment == .right ? .accentColor : .secondary)
.help("Align results right")
}
.padding(.horizontal, 8)
}
.padding(.vertical, 4)
Divider()
HSplitView {
// Left pane: Editor
EditorTextView(
text: $text,
scrollOffset: $scrollOffset,
font: editorFont
font: editorFont,
alignment: editorAlignment
)
.frame(minWidth: 200)
// Divider is automatic with HSplitView
// Right pane: Answer column
AnswerColumnView(
results: results,
scrollOffset: $scrollOffset,
font: editorFont
font: editorFont,
alignment: resultsAlignment
)
.frame(minWidth: 120, idealWidth: 200)
}
}
.onChange(of: text) { _, newValue in
scheduleEvaluation(newValue)
}
@@ -56,7 +116,6 @@ struct TwoColumnEditorView: View {
}
}
/// Debounce evaluation so rapid typing doesn't cause excessive recalculation.
private func scheduleEvaluation(_ newText: String) {
evaluationTask?.cancel()
evaluationTask = Task { @MainActor in

View File

@@ -105,12 +105,12 @@ struct RustEngineTests {
#expect(result.isError == false)
}
@Test("# is not a comment in Rust engine — treated as identifier lookup")
func hashNotComment() {
@Test("# heading is treated as non-calculable, not an error")
func hashHeading() {
let result = engine.evaluateLine("# header")
// The Rust engine does not treat # as a comment. The # is skipped and
// "header" is parsed as an identifier, resulting in an undefined variable error.
#expect(result.isError == true)
// The Rust engine now detects headings (# followed by space) as non-calculable.
#expect(result.isError == false)
#expect(result.result == nil)
}
// MARK: - AC: Memory safety no leaks with repeated calls

View File

@@ -8,13 +8,14 @@ description = "CalcPad calculation engine compiled to WebAssembly"
crate-type = ["cdylib", "rlib"]
[dependencies]
calcpad-engine = { path = "../calcpad-engine" }
calcpad-engine = { path = "../calcpad-engine", default-features = false }
wasm-bindgen = "0.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde-wasm-bindgen = "0.6"
js-sys = "0.3"
chrono = "0.4"
getrandom = { version = "0.2", features = ["js"] }
[dev-dependencies]
wasm-bindgen-test = "0.3"

View File

@@ -29,6 +29,8 @@ impl From<&CalcResult> for JsResult {
ResultType::DateTime => "dateTime",
ResultType::TimeDelta => "timeDelta",
ResultType::Boolean => "boolean",
ResultType::Empty => "empty",
ResultType::NonCalculable => "nonCalculable",
ResultType::Error => "error",
}
.to_string();

View File

@@ -10,7 +10,18 @@
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="CalcPad" />
<link rel="apple-touch-icon" href="/icons/icon-192.svg" />
<title>CalcPad</title>
<title>CalcText</title>
<script>
// Apply theme before React mounts to prevent FOUC
(function() {
var t = 'system';
try { t = localStorage.getItem('calctext-theme') || 'system'; } catch(e) {}
if (t === 'system') {
t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-theme', t);
})();
</script>
</head>
<body>
<div id="root"></div>

View File

@@ -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<EditorView | null>(null)
const resultsPanelRef = useRef<HTMLDivElement>(null)
const [modifiedIds, setModifiedIds] = useState<Set<string>>(new Set())
const [editorAlign, setEditorAlign] = useState<Alignment>('left')
const [resultsAlign, setResultsAlign] = useState<Alignment>('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<number | null>(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 (
<div className="calcpad-app">
<OfflineBanner isOnline={isOnline} />
<header className="calcpad-header">
<h1>CalcPad</h1>
<p className="subtitle">Notepad Calculator</p>
<div className="header-status">
<span className={`status-dot ${engine.ready ? '' : 'loading'}`} />
<span>{engine.ready ? 'Engine ready' : 'Loading engine...'}</span>
<button
className="header-sidebar-toggle"
onClick={() => setSidebarVisible(!sidebarState.visible)}
title="Toggle sidebar (Ctrl+B)"
aria-label="Toggle sidebar"
>
</button>
<h1>CalcText</h1>
<div className="header-spacer" />
<div className="header-actions">
<FormatToolbar
editorView={editorView}
previewMode={formatPreview}
onPreviewToggle={() => setFormatPreview(p => !p)}
/>
<div className="header-divider" />
<AlignToolbar
editorAlign={editorAlign}
resultsAlign={resultsAlign}
onEditorAlignChange={setEditorAlign}
onResultsAlignChange={setResultsAlign}
/>
<ThemePicker
theme={themeCtx.theme}
accentColor={themeCtx.accentColor}
onThemeChange={themeCtx.setTheme}
onAccentChange={themeCtx.setAccent}
/>
</div>
</header>
<TabBar
tabs={store.openDocs}
activeTabId={store.activeTabId}
onTabClick={handleTabClick}
onTabClose={handleTabClose}
onTabRename={store.renameDocument}
onNewTab={handleNewTab}
modifiedIds={modifiedIds}
/>
<div className="calcpad-workspace">
{/* Mobile sidebar backdrop */}
{sidebarState.visible && (
<div
className="sidebar-backdrop"
onClick={() => setSidebarVisible(false)}
/>
)}
<Sidebar
visible={sidebarState.visible}
width={sidebarState.width}
documents={store.documents}
folders={store.folders}
activeTabId={store.activeTabId}
openTabIds={store.openTabIds}
onFileClick={(id) => { 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}
/>
<main className="calcpad-editor">
<div className="editor-pane">
<div className="editor-pane" style={editorStyle}>
<CalcEditor
initialDoc={INITIAL_DOC}
key={editorKey}
initialDoc={store.activeDoc?.content ?? ''}
onDocChange={handleDocChange}
results={engine.results}
debounceMs={50}
onViewReady={setEditorView}
formatPreview={formatPreview}
/>
</div>
<div
className="pane-divider"
onMouseDown={onDividerMouseDown}
/>
<ResultsPanel
ref={resultsPanelRef}
results={engine.results}
align={resultsAlign}
style={resultsStyle}
/>
</main>
</div>
<MobileResultsTray
results={engine.results}
docLines={store.activeDoc?.content.split('\n') ?? []}
/>
<StatusBar
editorView={editorView}
engineReady={engine.ready}
lineCount={store.activeDoc?.content.split('\n').length ?? 0}
/>
<InstallPrompt
promptEvent={installPrompt.promptEvent}

View File

@@ -0,0 +1,84 @@
/**
* Toolbar with justification buttons for editor and results panes.
* Matches the macOS app: left / center / right for each pane.
*/
import '../styles/align-toolbar.css'
export type Alignment = 'left' | 'center' | 'right'
export interface AlignToolbarProps {
editorAlign: Alignment
resultsAlign: Alignment
onEditorAlignChange: (align: Alignment) => void
onResultsAlignChange: (align: Alignment) => void
}
function AlignIcon({ type }: { type: 'left' | 'center' | 'right' }) {
if (type === 'left') {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<rect x="1" y="2" width="12" height="1.5" rx="0.5" fill="currentColor" />
<rect x="1" y="6" width="8" height="1.5" rx="0.5" fill="currentColor" />
<rect x="1" y="10" width="10" height="1.5" rx="0.5" fill="currentColor" />
</svg>
)
}
if (type === 'center') {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<rect x="1" y="2" width="12" height="1.5" rx="0.5" fill="currentColor" />
<rect x="3" y="6" width="8" height="1.5" rx="0.5" fill="currentColor" />
<rect x="2" y="10" width="10" height="1.5" rx="0.5" fill="currentColor" />
</svg>
)
}
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<rect x="1" y="2" width="12" height="1.5" rx="0.5" fill="currentColor" />
<rect x="5" y="6" width="8" height="1.5" rx="0.5" fill="currentColor" />
<rect x="3" y="10" width="10" height="1.5" rx="0.5" fill="currentColor" />
</svg>
)
}
const alignments: Alignment[] = ['left', 'center', 'right']
export function AlignToolbar({
editorAlign,
resultsAlign,
onEditorAlignChange,
onResultsAlignChange,
}: AlignToolbarProps) {
return (
<div className="align-toolbar">
<div className="align-group">
<span className="align-label">Editor</span>
{alignments.map((a) => (
<button
key={a}
className={`align-btn${editorAlign === a ? ' active' : ''}`}
onClick={() => onEditorAlignChange(a)}
title={`Align editor ${a}`}
>
<AlignIcon type={a} />
</button>
))}
</div>
<div className="align-group">
<span className="align-label">Results</span>
{alignments.map((a) => (
<button
key={a}
className={`align-btn${resultsAlign === a ? ' active' : ''}`}
onClick={() => onResultsAlignChange(a)}
title={`Align results ${a}`}
>
<AlignIcon type={a} />
</button>
))}
</div>
</div>
)
}

View File

@@ -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 (
<div className="answer-column" aria-label="Calculation results" role="complementary">
{results.map((result, i) => (
<div
key={i}
className={`answer-line ${resultClassName(result.type)}`}
title={result.error ?? result.display}
>
{result.type === 'error'
? 'Error'
: result.display || '\u00A0'}
</div>
))}
</div>
)
}

View File

@@ -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 (
<div className="format-toolbar">
<div className="format-group">
<button
className={`format-btn format-preview-toggle ${previewMode ? 'active' : ''}`}
onClick={onPreviewToggle}
title={previewMode ? 'Show raw markdown' : 'Show formatted preview'}
>
👁
</button>
</div>
<div className="format-separator" />
<div className="format-group">
<button
className="format-btn"
onClick={() => editorView && insertPrefix(editorView, '# ')}
title="Heading (toggle # prefix)"
>
<strong>H</strong>
</button>
<button
className="format-btn"
onClick={() => editorView && wrapSelection(editorView, '**')}
title="Bold (**text**)"
>
<strong>B</strong>
</button>
<button
className="format-btn format-italic"
onClick={() => editorView && wrapSelection(editorView, '*')}
title="Italic (*text*)"
>
<em>I</em>
</button>
<button
className="format-btn"
onClick={() => editorView && insertPrefix(editorView, '// ')}
title="Comment (toggle // prefix)"
>
//
</button>
</div>
<div className="format-separator" />
<div className="format-group format-colors">
{COLORS.map(c => (
<button
key={c.name}
className="format-color-btn"
style={{ backgroundColor: c.value }}
onClick={() => editorView && insertColor(editorView, c.name.toLowerCase())}
title={`${c.name} label`}
/>
))}
</div>
</div>
)
}

View File

@@ -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<number | null>(null)
const startY = useRef<number | null>(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 (
<div
key={i}
className={`tray-result-item ${isCopied ? 'copied' : ''}`}
onClick={() => handleCopy(i, r.rawValue, r.display)}
>
<span className="tray-result-line">Ln {i + 1}</span>
<span className="tray-result-expr">{expr}</span>
<span className="tray-result-value">{isCopied ? 'Copied!' : r.display}</span>
</div>
)
}).filter(Boolean)
return (
<div
className={`mobile-results-tray ${expanded ? 'expanded' : ''}`}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* Drag handle + collapsed view */}
<div
className="tray-header"
onClick={() => setExpanded(prev => !prev)}
>
<div className="tray-drag-handle" />
{!expanded && (
<span className="tray-last-result">
{lastResult ? `Last: ${lastResult}` : 'No results'}
</span>
)}
{expanded && (
<span className="tray-last-result">{resultItems.length} results</span>
)}
</div>
{/* Expanded content */}
{expanded && (
<div className="tray-content">
{resultItems.length > 0 ? resultItems : (
<div className="tray-empty">No results yet</div>
)}
</div>
)}
</div>
)
}

View File

@@ -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<HTMLDivElement, ResultsPanelProps>(
function ResultsPanel({ results, align, style }, ref) {
const [copiedIdx, setCopiedIdx] = useState<number | null>(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 (
<div
ref={ref}
className="results-panel"
style={{ textAlign: align, ...style }}
role="complementary"
aria-label="Calculation results"
>
{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 (
<div
key={i}
className={`result-line result-value ${colorClass}${stripe}${isCopied ? ' copied' : ''}`}
onClick={() => handleClick(i, result.rawValue, result.display)}
title="Click to copy"
>
{isCopied ? 'Copied!' : result.display}
</div>
)
}
// Error hint
if (result.type === 'error' && result.error) {
return (
<div key={i} className={`result-line result-error-hint${stripe}`}>
· error
</div>
)
}
// Comment/heading marker
if (result.type === 'comment' || result.type === 'text') {
return (
<div key={i} className={`result-line result-marker${stripe}`}>
<span className="result-dash"></span>
</div>
)
}
return (
<div key={i} className={`result-line result-empty${stripe}`}>
&nbsp;
</div>
)
})}
</div>
)
},
)

View File

@@ -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<Set<string>>(
() => new Set(['files', 'recent'])
)
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set())
const [editingId, setEditingId] = useState<string | null>(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<HTMLInputElement>(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<string | null>(null)
const dragIdRef = useRef<string | null>(null)
const [dropTarget, setDropTarget] = useState<string | null>(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 (
<div
key={doc.id}
className={`sidebar-file ${isActive ? 'active' : ''} ${isOpen ? 'open' : ''} ${isDragged ? 'dragging' : ''}`}
style={{ paddingLeft: 12 + depth * 16 }}
draggable
onDragStart={e => 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 })
}}
>
<span className="sidebar-file-icon">📄</span>
{editingId === doc.id ? (
<input
ref={inputRef}
className="sidebar-rename-input"
value={editValue}
onChange={e => handleEditChange(e.target.value)}
onBlur={commitRename}
onKeyDown={e => {
if (e.key === 'Enter') commitRename()
if (e.key === 'Escape') setEditingId(null)
}}
onClick={e => e.stopPropagation()}
/>
) : (
<span className="sidebar-file-label">{doc.title}</span>
)}
{isOpen && !isActive && <span className="sidebar-open-dot" />}
</div>
)
}
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 (
<div key={folder.id}>
<div
className={`sidebar-folder ${isDropTarget ? 'drop-target' : ''}`}
style={{ paddingLeft: 12 + depth * 16 }}
onClick={() => { 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 })
}}
>
<span className="sidebar-folder-chevron">{isExpanded ? '▾' : '▸'}</span>
{editingId === folder.id ? (
<input
ref={inputRef}
className="sidebar-rename-input"
value={editValue}
onChange={e => handleEditChange(e.target.value)}
onBlur={commitRename}
onKeyDown={e => {
if (e.key === 'Enter') commitRename()
if (e.key === 'Escape') setEditingId(null)
}}
onClick={e => e.stopPropagation()}
/>
) : (
<>
<span className="sidebar-folder-icon">{isExpanded ? '📂' : '📁'}</span>
<span className="sidebar-folder-label">{folder.name}</span>
<span className="sidebar-folder-count">({docsInFolder.length})</span>
</>
)}
</div>
{isExpanded && (
<>
{subFolders.map(sf => renderFolderItem(sf, depth + 1))}
{docsInFolder.map(d => renderFileItem(d, depth + 1))}
{docsInFolder.length === 0 && subFolders.length === 0 && (
<div className="sidebar-empty" style={{ paddingLeft: 28 + depth * 16 }}>
Empty folder
</div>
)}
</>
)}
</div>
)
}
return (
<>
<div className="sidebar" style={{ width }}>
{/* Search */}
<div className="sidebar-search">
<span className="sidebar-search-icon">🔍</span>
<input
type="text"
placeholder="Search documents..."
value={search}
onChange={e => setSearch(e.target.value)}
className="sidebar-search-input"
/>
{search && (
<button className="sidebar-search-clear" onClick={() => setSearch('')}>×</button>
)}
</div>
<div className="sidebar-content">
{searchResults ? (
/* Search results */
<div className="sidebar-section">
<div className="sidebar-section-header">
Results ({searchResults.length})
</div>
{searchResults.map(d => renderFileItem(d))}
{searchResults.length === 0 && (
<div className="sidebar-empty">No documents match &apos;{search}&apos;</div>
)}
</div>
) : (
<>
{/* Recent */}
<div className="sidebar-section">
<div
className="sidebar-section-header"
onClick={() => toggleSection('recent')}
>
<span className="sidebar-section-chevron">
{expandedSections.has('recent') ? '▾' : '▸'}
</span>
🕐 Recent
</div>
{expandedSections.has('recent') && (
recentDocs.length > 0
? recentDocs.map(d => renderFileItem(d))
: <div className="sidebar-empty">No recent documents</div>
)}
</div>
{/* Favorites */}
{favoriteDocs.length > 0 && (
<div className="sidebar-section">
<div
className="sidebar-section-header"
onClick={() => toggleSection('favorites')}
>
<span className="sidebar-section-chevron">
{expandedSections.has('favorites') ? '▾' : '▸'}
</span>
Favorites
</div>
{expandedSections.has('favorites') && favoriteDocs.map(d => renderFileItem(d))}
</div>
)}
{/* Templates */}
<div className="sidebar-section">
<div
className="sidebar-section-header"
onClick={() => toggleSection('templates')}
>
<span className="sidebar-section-chevron">
{expandedSections.has('templates') ? '▾' : '▸'}
</span>
📋 Templates
</div>
{expandedSections.has('templates') && (
<div className="sidebar-templates">
{TEMPLATES.map(t => (
<div
key={t.id}
className="sidebar-template"
onClick={() => onNewDocument(t.name, t.content)}
title={t.description}
>
<span className="sidebar-template-dot" style={{ backgroundColor: t.color }} />
<div className="sidebar-template-text">
<span className="sidebar-template-name">{t.name}</span>
<span className="sidebar-template-desc">{t.description}</span>
</div>
</div>
))}
</div>
)}
</div>
{/* Files */}
<div className="sidebar-section">
<div
className={`sidebar-section-header ${dropTarget === 'root' ? 'drop-target' : ''}`}
onClick={() => toggleSection('files')}
onDragOver={e => handleDragOver(e, 'root')}
onDragLeave={handleDragLeave}
onDrop={e => handleDrop(e, null)}
>
<span className="sidebar-section-chevron">
{expandedSections.has('files') ? '▾' : '▸'}
</span>
📁 Files
</div>
{expandedSections.has('files') && (
<div
className={`sidebar-files-area ${dropTarget === 'root' ? 'drop-target-area' : ''}`}
onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move' }}
onDrop={e => handleDrop(e, null)}
>
{rootFolders.map(f => renderFolderItem(f))}
{rootDocs.map(d => renderFileItem(d))}
</div>
)}
</div>
</>
)}
</div>
{/* Footer */}
<div className="sidebar-footer">
<button className="sidebar-footer-btn" onClick={onNewDocument}>+ Document</button>
<button className="sidebar-footer-btn" onClick={onNewFolder}>+ Folder</button>
</div>
{/* Resize handle */}
<div
className="sidebar-resize"
onMouseDown={e => {
e.preventDefault()
resizeRef.current = { startX: e.clientX, startWidth: width }
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
}}
onDoubleClick={() => onWidthChange(240)}
/>
</div>
{/* Context Menu */}
{contextMenu && (
<div
className="sidebar-context-menu"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
{contextMenu.type === 'file' && (
<>
<button onClick={() => { onFileClick(contextMenu.id); setContextMenu(null) }}>
Open
</button>
<button onClick={() => {
const doc = documents.find(d => d.id === contextMenu.id)
if (doc) startRename(doc.id, doc.title, 'file')
}}>
Rename
</button>
<button onClick={() => {
onToggleFavorite(contextMenu.id)
setContextMenu(null)
}}>
{documents.find(d => d.id === contextMenu.id)?.isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}
</button>
{folders.length > 0 && (
<>
<div className="sidebar-context-separator" />
<div className="sidebar-context-label">Move to...</div>
<button onClick={() => { onMoveToFolder(contextMenu.id, null); setContextMenu(null) }}>
Root
</button>
{folders.map(f => (
<button key={f.id} onClick={() => { onMoveToFolder(contextMenu.id, f.id); setContextMenu(null) }}>
📁 {f.name}
</button>
))}
</>
)}
<div className="sidebar-context-separator" />
<button className="sidebar-context-danger" onClick={() => {
onDeleteDocument(contextMenu.id)
setContextMenu(null)
}}>
Delete
</button>
</>
)}
{contextMenu.type === 'folder' && (
<>
<button onClick={() => {
const folder = folders.find(f => f.id === contextMenu.id)
if (folder) startRename(folder.id, folder.name, 'folder')
}}>
Rename
</button>
<div className="sidebar-context-separator" />
<button className="sidebar-context-danger" onClick={() => {
onDeleteFolder(contextMenu.id)
setContextMenu(null)
}}>
Delete Folder
</button>
</>
)}
</div>
)}
</>
)
}

View File

@@ -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 (
<div className="status-bar">
<div className="status-bar-left">
<span>Ln {cursor.line}, Col {cursor.col}</span>
<span>{lineCount} lines</span>
{selection > 0 && <span>{selection} selected</span>}
</div>
<div className="status-bar-right">
<span className="status-bar-engine">
<span className={`status-bar-dot ${engineReady ? 'ready' : 'loading'}`} />
{engineReady ? 'Ready' : 'Loading...'}
</span>
<span
className="status-bar-dedication"
onClick={toggleDedication}
role="button"
tabIndex={0}
>
Made with <span className="status-bar-heart"></span> for Igor Cassel
</span>
</div>
{showDedication && (
<div className="dedication-overlay" onClick={toggleDedication}>
<div className="dedication-card" onClick={e => e.stopPropagation()}>
<div className="dedication-heart"></div>
<h2>For Igor Cassel</h2>
<p>
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.
</p>
<p className="dedication-tagline">
Every keystroke in this editor carries that inspiration.
</p>
<button className="dedication-close" onClick={toggleDedication}>
Close
</button>
</div>
</div>
)}
</div>
)
}

View File

@@ -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<string>
}
export function TabBar({
tabs,
activeTabId,
onTabClick,
onTabClose,
onTabRename,
onNewTab,
modifiedIds,
}: TabBarProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState('')
const inputRef = useRef<HTMLInputElement>(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 (
<div className="tab-bar">
<div className="tab-bar-scroll" ref={scrollRef} onWheel={handleWheel}>
{tabs.map(tab => (
<div
key={tab.id}
className={`tab-item ${tab.id === activeTabId ? 'active' : ''}`}
onClick={() => onTabClick(tab.id)}
onMouseDown={(e) => handleMiddleClick(e, tab.id)}
onDoubleClick={() => startRename(tab.id, tab.title)}
title={tab.title}
>
{modifiedIds?.has(tab.id) && (
<span className="tab-modified-dot" />
)}
{editingId === tab.id ? (
<input
ref={inputRef}
className="tab-rename-input"
value={editValue}
onChange={e => setEditValue(e.target.value)}
onBlur={commitRename}
onKeyDown={e => {
if (e.key === 'Enter') commitRename()
if (e.key === 'Escape') cancelRename()
}}
onClick={e => e.stopPropagation()}
/>
) : (
<span className="tab-label">{tab.title}</span>
)}
<button
className="tab-close"
onClick={e => {
e.stopPropagation()
onTabClose(tab.id)
}}
aria-label={`Close ${tab.title}`}
>
×
</button>
</div>
))}
</div>
<button
className="tab-new"
onClick={onNewTab}
aria-label="New document"
title="New document (Ctrl+N)"
>
+
</button>
</div>
)
}

View File

@@ -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<HTMLDivElement>(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 (
<div className="theme-picker-container" ref={ref}>
<button
className="theme-picker-trigger"
onClick={() => setOpen(prev => !prev)}
title="Switch theme (Ctrl+Shift+T)"
aria-label="Switch theme"
aria-expanded={open}
>
<span className="theme-picker-icon">{icon}</span>
</button>
{open && (
<div className="theme-picker-dropdown" role="menu">
<div className="theme-picker-section-label">Themes</div>
{THEMES.map(t => (
<button
key={t.id}
className={`theme-picker-item ${resolvedTheme === t.id ? 'active' : ''}`}
onClick={() => { onThemeChange(t.id); setOpen(false) }}
role="menuitem"
>
<span className="theme-picker-item-icon">{t.icon}</span>
<span className="theme-picker-item-label">{t.name}</span>
{resolvedTheme === t.id && <span className="theme-picker-check"></span>}
</button>
))}
<div className="theme-picker-separator" />
<div className="theme-picker-section-label">Accent Color</div>
<div className="theme-picker-accents">
{ACCENT_COLORS.map(c => (
<button
key={c.name}
className={`theme-picker-swatch ${accentColor === c.light || accentColor === c.dark ? 'active' : ''}`}
style={{ backgroundColor: c.light }}
onClick={() => {
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}`}
/>
))}
</div>
<div className="theme-picker-separator" />
<button
className={`theme-picker-item ${theme === 'system' ? 'active' : ''}`}
onClick={() => { onThemeChange('system'); setOpen(false) }}
role="menuitem"
>
<span className="theme-picker-item-icon"></span>
<span className="theme-picker-item-label">System (auto)</span>
{theme === 'system' && <span className="theme-picker-check"></span>}
</button>
</div>
)}
</div>
)
}

View File

@@ -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
`,
},
]

View File

@@ -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<HTMLDivElement>(null)
const viewRef = useRef<EditorView | null>(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 <div ref={containerRef} className="calc-editor" />
}
/**
* 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',

View File

@@ -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<LineAnswer[]>()
// --- 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<Map<number, EngineLineResult | null>>({
create() {
return new Map()
},
update(answers, tr) {
for (const effect of tr.effects) {
if (effect.is(setAnswersEffect)) {
const newAnswers = new Map<number, EngineLineResult | null>()
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]
}

View File

@@ -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<Set<number>>({
},
})
// --- Error Messages (for tooltips) ---
export const errorMessagesField = StateField.define<Map<number, string>>({
create() {
return new Map()
},
update(msgs, tr) {
for (const effect of tr.effects) {
if (effect.is(setErrorsEffect)) {
const newMsgs = new Map<number, string>()
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,
]
}

View File

@@ -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 = /(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g
let match
while ((match = italicRegex.exec(text)) !== null) {
const start = line.from + match.index
// Hide opening *
decorations.push({ from: start, to: start + 1, dec: hiddenWidget })
// Italic the content
decorations.push({ from: start + 1, to: start + 1 + match[1].length, dec: italicMark })
// Hide closing *
decorations.push({ from: start + 1 + match[1].length, to: start + match[0].length, dec: hiddenWidget })
}
}
// Color labels: [color:text] (on non-active lines, show colored text)
if (!isActive) {
const colorRegex = /\[(red|orange|yellow|green|blue|purple):(.+?)\]/g
let match
while ((match = colorRegex.exec(text)) !== null) {
const start = line.from + match.index
const color = match[1]
const content = match[2]
// Hide [color:
decorations.push({ from: start, to: start + color.length + 2, dec: hiddenWidget })
// Color the content
decorations.push({
from: start + color.length + 2,
to: start + color.length + 2 + content.length,
dec: Decoration.mark({ class: `cm-fmt-color-${color}` }),
})
// Hide ]
decorations.push({
from: start + match[0].length - 1,
to: start + match[0].length,
dec: hiddenWidget,
})
}
}
}
// Sort by position (required by RangeSet)
decorations.sort((a, b) => 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,
)
}

View File

@@ -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<ReturnType<typeof stripeDeco.range>> = []
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]
}

View File

@@ -78,7 +78,8 @@ function fallbackEvalSheet(lines: string[]): EngineLineResult[] {
async function initWasm(): Promise<boolean> {
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<void>
evalSheet: (lines: string[]) => EngineLineResult[]
}

View File

@@ -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<StoreState>(loadState)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | 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,
}
}

View File

@@ -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<ThemeId>(getStoredTheme)
const [accentColor, setAccentState] = useState<string | null>(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<string, string> = {
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})`
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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 ---------- */
@media (max-width: 640px) {
.calcpad-header {
padding: 10px 16px;
.pane-divider {
width: 5px;
background: var(--border);
flex-shrink: 0;
cursor: col-resize;
transition: background 0.15s;
position: relative;
}
.calcpad-header .subtitle {
.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 {
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;
}

View File

@@ -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;
}
}

View File

@@ -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,8 +21,40 @@
-moz-osx-font-smoothing: grayscale;
}
@media (prefers-color-scheme: dark) {
:root {
/* ---------- 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;
@@ -39,7 +64,137 @@
--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;
}
*,

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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; }
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -1,5 +1,10 @@
/// <reference types="vite/client" />
declare module '/wasm/calcpad_wasm.js' {
export default function init(): Promise<void>
export function evalSheet(lines: string[]): import('./engine/types.ts').EngineLineResult[]
}
declare module 'virtual:pwa-register' {
export interface RegisterSWOptions {
immediate?: boolean

View File

@@ -4,7 +4,7 @@
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"types": [],
"skipLibCheck": true,
"moduleResolution": "bundler",

View File

@@ -6,7 +6,7 @@ export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'prompt',
registerType: 'autoUpdate',
includeAssets: ['favicon.svg', 'icons/*.svg'],
manifest: {
name: 'CalcPad',

5
docker-compose.yml Normal file
View File

@@ -0,0 +1,5 @@
services:
web:
build: .
ports:
- "8080:8080"

32
nginx.conf Normal file
View File

@@ -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;
}