diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..18df005 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +target/ +node_modules/ +.git/ +.gitignore +*.md +.vscode/ +.idea/ +.claude/ +_scripts/ +calcpad-web/dist/ +calcpad-wasm/pkg/ diff --git a/Cargo.lock b/Cargo.lock index 36b8039..10f6d08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -505,6 +505,7 @@ version = "0.1.0" dependencies = [ "calcpad-engine", "chrono", + "getrandom 0.2.17", "js-sys", "serde", "serde-wasm-bindgen", @@ -1580,8 +1581,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4440b7c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,55 @@ +# Stage 1 — Build WASM from Rust +FROM rust:1.85-bookworm AS wasm-builder + +# Install wasm-pack via pre-built binary (much faster than cargo install) +RUN curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh + +WORKDIR /app + +# Copy workspace Cargo.toml +COPY Cargo.toml ./ + +# Copy the crates we actually need to compile +COPY calcpad-engine/ calcpad-engine/ +COPY calcpad-wasm/ calcpad-wasm/ + +# Create stub Cargo.toml + src for workspace members we don't need to build. +# The workspace requires all members to exist, but we never compile these. +RUN mkdir -p calcpad-cli/src && \ + printf '[package]\nname = "calcpad-cli"\nversion = "0.1.0"\nedition = "2021"\n\n[[bin]]\nname = "calcpad"\npath = "src/main.rs"\n\n[dependencies]\ncalcpad-engine = { path = "../calcpad-engine" }\n' > calcpad-cli/Cargo.toml && \ + echo 'fn main() {}' > calcpad-cli/src/main.rs && \ + mkdir -p calcpad-windows/src && \ + printf '[package]\nname = "calcpad-windows"\nversion = "0.1.0"\nedition = "2021"\n\n[[bin]]\nname = "calcpad-win"\npath = "src/main.rs"\n' > calcpad-windows/Cargo.toml && \ + echo 'fn main() {}' > calcpad-windows/src/main.rs + +# Build WASM +RUN wasm-pack build calcpad-wasm --target web --release + +# Stage 2 — Build frontend with Vite +FROM node:22-slim AS web-builder + +WORKDIR /app/calcpad-web + +# Install dependencies first (layer caching) +COPY calcpad-web/package.json calcpad-web/package-lock.json* ./ +RUN npm install + +# Copy web source +COPY calcpad-web/ ./ + +# Copy WASM output into public/wasm/ so Vite includes it in the build +COPY --from=wasm-builder /app/calcpad-wasm/pkg/calcpad_wasm.js public/wasm/calcpad_wasm.js +COPY --from=wasm-builder /app/calcpad-wasm/pkg/calcpad_wasm_bg.wasm public/wasm/calcpad_wasm_bg.wasm + +# Build (skip tsc type-check — that belongs in CI, not Docker) +RUN npx vite build + +# Stage 3 — Serve with nginx +FROM nginx:1.27-alpine + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=web-builder /app/calcpad-web/dist /usr/share/nginx/html + +EXPOSE 8080 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/_bmad-output/A-Product-Brief/00-product-brief.md b/_bmad-output/A-Product-Brief/00-product-brief.md new file mode 100644 index 0000000..d2a7013 --- /dev/null +++ b/_bmad-output/A-Product-Brief/00-product-brief.md @@ -0,0 +1,44 @@ +# Product Brief: CalcText + +> The strategic foundation — why this product exists, who it serves, and what success looks like. + +**Created:** 2026-03-17 +**Phase:** 1 — Product Brief +**Agent:** Saga (Analyst) + +--- + +## What Belongs Here + +The Product Brief answers five strategic questions: + +1. **Why** does this product exist? (Vision & business goals) +2. **Who** is it for? (Target users and their context) +3. **What** does it need to do? (Core capabilities) +4. **How** will we know it works? (Success metrics) +5. **What** are the constraints? (Platform requirements, tech stack) + +Everything downstream — trigger maps, scenarios, page specs, design system — traces back to decisions made here. This is the North Star. + +--- + +## For Agents + +**Workflow:** `_bmad/wds/workflows/1-project-brief/workflow.md` +**Agent trigger:** `PB` (Saga) +**Templates:** `_bmad/wds/workflows/1-project-brief/templates/` + +--- + +## Documents + +_This section will be updated as documents are created during Phase 1._ + +| # | Document | Status | +|---|----------|--------| +| 01 | Project Brief (Simplified) | Complete | +| 02 | Brownfield Analysis | Complete | + +--- + +_Created using Whiteport Design Studio (WDS) methodology_ diff --git a/_bmad-output/A-Product-Brief/01-brownfield-analysis.md b/_bmad-output/A-Product-Brief/01-brownfield-analysis.md new file mode 100644 index 0000000..5c80ec4 --- /dev/null +++ b/_bmad-output/A-Product-Brief/01-brownfield-analysis.md @@ -0,0 +1,205 @@ +# CalcText Web — Brownfield Analysis + +**Date:** 2026-03-17 +**Agent:** Freya (WDS Designer) +**Scope:** calcpad-web current state + cross-platform patterns from macOS + +--- + +## 1. Current Architecture + +| Layer | Tech | Status | +|-------|------|--------| +| Framework | React 19 + TypeScript | Solid | +| Editor | CodeMirror 6 (custom language, extensions) | Solid | +| Engine | Rust WASM in Web Worker (JS fallback for dev) | Solid | +| Build | Vite 7, ES2022 target, code splitting | Solid | +| PWA | vite-plugin-pwa, service worker, manifest | Solid | +| Styling | CSS custom properties, no framework | Flexible — ready for theme system | + +--- + +## 2. Current Layout + +``` +┌─────────────────────────────────────────┐ +│ Header (logo + subtitle + status dot) │ 52px +├─────────────────────────────────────────┤ +│ Align Toolbar (editor + results) │ 28px +├───────────────────────┬─┬───────────────┤ +│ │ │ │ +│ Editor (flex: 3) │D│ Results │ +│ CodeMirror 6 │i│ Panel │ +│ 15px mono, 24px line │v│ (flex: 1) │ +│ │ │ │ +└───────────────────────┴─┴───────────────┘ +``` + +**Mobile (< 640px):** Results panel, toolbar, divider, subtitle — all hidden. Editor only. + +--- + +## 3. CSS Design Tokens (Current) + +### Colors + +| Token | Light | Dark | +|-------|-------|------| +| --text | #6b6375 | #9ca3af | +| --text-h | #08060d | #f3f4f6 | +| --bg | #fff | #16171d | +| --bg-secondary | #f8f9fa | #1a1b23 | +| --border | #e5e4e7 | #2e303a | +| --code-bg | #f4f3ec | #1f2028 | +| --accent | #6366f1 | #818cf8 | +| --accent-bg | rgba(99,102,241,0.1) | rgba(129,140,248,0.15) | +| --accent-border | rgba(99,102,241,0.5) | rgba(129,140,248,0.5) | +| --warning | #f59e0b | (same) | +| --success | #10b981 | (same) | +| --error | #e53e3e | (same) | + +### Typography + +| Context | Font | Size | Weight | Line Height | +|---------|------|------|--------|-------------| +| Body | system-ui | 16px | 400 | 1.5 | +| Editor/Results | ui-monospace | 15px | 400/600 | 1.6 (24px) | +| Header title | system-ui | 20px | — | — | +| Header subtitle | system-ui | 13px | — | — | +| Status | system-ui | 12px | — | — | +| Toolbar labels | system-ui | 11px | — | — | +| Buttons | system-ui | 14px | 500 | — | + +### Spacing + +| Context | Value | +|---------|-------| +| Header padding | 12px 24px | +| Editor line padding | 0 16px | +| Editor content padding | 12px 0 | +| Results line padding | 0 16px | +| Results content padding | 12px 0 | +| Toolbar height | 28px | +| Toolbar padding | 0 12px | +| Button gaps | 4px | +| Divider width | 5px (9px hit area) | + +### Breakpoints + +| Width | Behavior | +|-------|----------| +| > 640px | Full two-column layout | +| <= 640px | Single column, results/toolbar/divider hidden | +| <= 480px | Install prompt stacks vertically | + +### Z-Index + +| Element | Value | +|---------|-------| +| Install prompt | 100 | +| (everything else) | Default stacking | + +### Animations + +| Element | Type | Duration | +|---------|------|----------| +| Divider hover | Transition | 0.15s | +| Toolbar buttons | Transition | 0.1s | +| Install button | Transition | 0.2s | +| Status dot (loading) | Pulse keyframe | 1.5s | +| Offline icon | Pulse keyframe | 2s | + +--- + +## 4. Component Inventory + +| Component | Location | Props | Purpose | +|-----------|----------|-------|---------| +| App | App.tsx | — | Shell, state, layout | +| CalcEditor | editor/CalcEditor.tsx | initialDoc, onDocChange, results, debounceMs, onViewReady | CodeMirror wrapper | +| ResultsPanel | components/ResultsPanel.tsx | results, align, style | Right-side results display | +| AlignToolbar | components/AlignToolbar.tsx | editorAlign, resultsAlign, onChange handlers | Text alignment controls | +| OfflineBanner | components/OfflineBanner.tsx | isOnline | Offline status | +| InstallPrompt | components/InstallPrompt.tsx | promptEvent, isInstalled, onInstall, onDismiss | PWA install | + +### Editor Extensions + +| Extension | File | Purpose | +|-----------|------|---------| +| calcpadLanguage | calcpad-language.ts | Syntax highlighting (keywords, functions, operators, currencies) | +| errorDisplayExtension | error-display.ts | Wavy underlines + gutter markers for errors | +| stripedLinesExtension | inline-results.ts | Even-line zebra striping | +| calcpadEditorTheme | CalcEditor.tsx | Base theme (font, padding, colors) | + +### Hooks + +| Hook | File | State | +|------|------|-------| +| useEngine | engine/useEngine.ts | ready, results, error, evalSheet() | +| useOnlineStatus | hooks/useOnlineStatus.ts | isOnline | +| useInstallPrompt | hooks/useInstallPrompt.ts | promptEvent, isInstalled, handlers | + +--- + +## 5. Accessibility Audit + +| Feature | Status | Notes | +|---------|--------|-------| +| Semantic HTML | Partial | Header, main present. Missing nav, regions | +| ARIA roles | Partial | OfflineBanner (status), InstallPrompt (complementary) | +| Focus indicators | Missing | No custom :focus-visible styles | +| Keyboard nav | Default | CodeMirror defaults only, toolbar not keyboard-navigable | +| Screen reader | Partial | Results panel has no aria-live for updates | +| Color contrast | OK | Meets AA for most combinations | +| Font scaling | Missing | Fixed 15px, ignores system preferences | +| Reduced motion | Missing | No prefers-reduced-motion media query | + +--- + +## 6. Cross-Platform Comparison (macOS vs Web) + +| Aspect | macOS | Web | Recommendation | +|--------|-------|-----|----------------| +| Spacing | Compact (8px) | Spacious (12-16px) | Tighten to match native feel | +| Results weight | Regular, secondary color | Bold 600, accent color | Reduce to secondary — results are too loud | +| Font scaling | System Dynamic Type | Fixed 15px | Respect system preferences | +| Min dimensions | 800x600 window | No minimum | Set min-width guidance | +| Text substitutions | All disabled | Browser default | Disable smart quotes/dashes | +| Stripe pattern | Synced, 4% opacity | Synced, 2.5-3.5% opacity | Already close | +| Error display | Editor only | Editor only | Already matched | +| Scroll sync | Binding-driven | Event-driven (passive) | Both work well | + +--- + +## 7. What's Missing for Workspace Vision + +| Feature | Exists? | Effort | Priority | +|---------|---------|--------|----------| +| Theme engine (multi-theme) | No (only system dark/light) | Medium | P0 | +| Theme switcher UI | No | Low | P0 | +| Document model (multi-doc) | No (single string in state) | Medium | P0 | +| Tab bar | No | Medium | P0 | +| File sidebar | No | Medium | P1 | +| localStorage persistence | No | Low | P0 | +| Folder organization | No | Medium | P1 | +| Templates library | No | Low | P2 | +| Dark mode toggle (manual) | No (system preference only) | Low | P0 | +| Mobile results view | No (hidden on mobile) | Medium | P1 | +| Keyboard shortcuts panel | No | Low | P2 | +| Status bar | No | Low | P1 | + +--- + +## 8. Strengths to Preserve + +1. **Engine isolation** — Web Worker keeps UI responsive +2. **PWA foundation** — Offline-ready, installable +3. **CSS custom properties** — Perfect base for theme system +4. **CodeMirror extensibility** — Custom language, errors, striping +5. **Scroll sync** — Reliable cross-pane synchronization +6. **Light/dark adaptation** — prefers-color-scheme already works +7. **Code splitting** — React and CodeMirror in separate chunks + +--- + +_Created using Whiteport Design Studio (WDS) methodology_ diff --git a/_bmad-output/A-Product-Brief/project-brief.md b/_bmad-output/A-Product-Brief/project-brief.md new file mode 100644 index 0000000..7f08f76 --- /dev/null +++ b/_bmad-output/A-Product-Brief/project-brief.md @@ -0,0 +1,94 @@ +# CalcText — Simplified Product Brief + +**Date:** 2026-03-17 +**Type:** Brownfield — Workspace Evolution +**Brief Level:** Simplified + +--- + +## Project Scope + +CalcText is a cross-platform notepad calculator (Web, macOS, Windows) powered by a shared Rust calculation engine with 200+ unit conversions, 180+ fiat currencies, 50+ crypto, date/time math, and 50+ built-in functions. + +The web app is evolving from a single-document, two-column calculator into a **full workspace application** with: + +- **Multi-theme system** — Light, Dark, Matrix, and custom user-defined themes +- **Tabbed documents** — Multiple calctext files open simultaneously +- **File sidebar** — Left panel with folder organization, recent files, favorites, templates +- **localStorage persistence** — Documents survive page reloads without backend +- **Elevated results panel** — Richer, subtler result display + +**Platform strategy:** Web-first design. Once established, replicate the UX patterns to macOS (SwiftUI) and Windows (iced/Rust). The design must be portable — avoid web-only paradigms that don't translate to native. + +--- + +## Challenge & Opportunity + +CalcText has a powerful engine trapped in a basic single-document UI. The current web app is functional but doesn't reflect the depth of what the engine can do, and doesn't invite users to stay. + +**The opportunity:** No competitor (Soulver, Numi, Numbr, PCalc) offers a true multi-document workspace with theming and file organization for calculations. CalcText can own this gap — becoming the app users open daily and live in, not just visit for a quick calculation. + +**The gap:** Current UI is a text editor with results. The vision is a professional workspace where calculations are organized, styled, and persistent. + +--- + +## Design Goals + +### Functional +- Multi-theme engine with preset themes and custom theme support +- Tab bar for multi-document management +- Collapsible file sidebar with tree view, folders, and templates +- Document model with create, rename, delete, organize +- localStorage persistence for all documents and preferences + +### Experience +- Professional workspace feel — "VS Code/Notion for numbers" +- Complete experience shipped as one coherent product, not incremental phases +- Matrix theme as a brand differentiator and personality statement +- Intuitive for first-time users, powerful for daily users + +### Business +- Web-first design that establishes reusable UX patterns +- Cross-platform portable design language +- Foundation for future premium features (cloud sync, accounts, collaboration) + +--- + +## Constraints + +### Technical (locked in) +- React 19 + CodeMirror 6 + Vite 7 +- Rust WASM engine running in Web Worker +- Custom CSS with CSS custom properties (no Tailwind, no shadcn) +- PWA architecture (service worker, manifest, offline support) + +### Deferred (out of scope this phase) +- User accounts and authentication +- Cloud save and sync (Supabase planned) +- Collaborative real-time editing (CRDT) +- Shareable URL links +- Embeddable widget + +### Cross-platform +- Design patterns must translate to SwiftUI (macOS) and iced (Windows) +- Avoid web-only interactions that don't port to native +- Shared engine means consistent calculation behavior across platforms + +### Timeline & Budget +- Flexible — no hard deadline +- Stakes: enterprise/high — quality over speed + +--- + +## Current State Reference + +See `01-brownfield-analysis.md` for complete codebase scan including: +- All CSS custom properties and values +- Component inventory and props +- Responsive breakpoints and behavior +- Accessibility audit +- Cross-platform comparison (macOS vs Web) + +--- + +_Created using Whiteport Design Studio (WDS) methodology_ diff --git a/_bmad-output/C-UX-Scenarios/00-ux-scenarios.md b/_bmad-output/C-UX-Scenarios/00-ux-scenarios.md new file mode 100644 index 0000000..0a5c966 --- /dev/null +++ b/_bmad-output/C-UX-Scenarios/00-ux-scenarios.md @@ -0,0 +1,42 @@ +# UX Scenarios: CalcText + +> Design experiences, not screens — every page serves a user with a goal and an emotion. + +**Created:** 2026-03-17 +**Phase:** 3 (Scenario Outline) + Phase 4 (UX Design) +**Agents:** Freya (Page Specifications) + +--- + +## Scenarios + +| # | Scenario | Pages | Description | +|---|----------|-------|-------------| +| 01 | Workspace Shell | App Shell, Status Bar | The overall 3-panel layout that contains everything | +| 02 | Calculation Experience | Editor, Results Panel | The core — typing calculations, seeing results | +| 03 | Document Management | Tab Bar, New Document, Rename/Delete | Multi-document workflow | +| 04 | File Organization | Sidebar, Folder Tree, Templates, Recent/Favorites | Organizing calctext files | +| 05 | Theming | Theme Picker, Preset Themes, Custom Theme, Accent Color | Personalizing the workspace | +| 06 | Mobile Experience | Mobile Shell, Mobile Results, Mobile Sidebar | Responsive/touch adaptation | + +--- + +## Page Index + +_Updated as page specifications are created during Phase 4._ + +| Scenario | Page | Status | File | +|----------|------|--------|------| +| 01 | App Shell | Specified | 01-workspace-shell/1.1-app-shell/1.1-app-shell.md | +| 01 | Status Bar | Specified | 01-workspace-shell/1.2-status-bar/1.2-status-bar.md | +| 02 | Editor | Specified | 02-calculation-experience/2.1-editor/2.1-editor.md | +| 02 | Results Panel | Specified | 02-calculation-experience/2.2-results-panel/2.2-results-panel.md | +| 03 | Tab Bar & Document Lifecycle | Specified | 03-document-management/3.1-tab-bar/3.1-tab-bar.md | +| 04 | Sidebar & File Organization | Specified | 04-file-organization/4.1-sidebar/4.1-sidebar.md | +| 04 | Templates | Specified | 04-file-organization/4.2-templates/4.2-templates.md | +| 05 | Theme System | Specified | 05-theming/5.1-theme-system/5.1-theme-system.md | +| 06 | Mobile Experience | Specified | 06-mobile-experience/6.1-mobile-shell/6.1-mobile-shell.md | + +--- + +_Created using Whiteport Design Studio (WDS) methodology_ diff --git a/_bmad-output/C-UX-Scenarios/01-workspace-shell/1.1-app-shell/1.1-app-shell.md b/_bmad-output/C-UX-Scenarios/01-workspace-shell/1.1-app-shell/1.1-app-shell.md new file mode 100644 index 0000000..dcb375b --- /dev/null +++ b/_bmad-output/C-UX-Scenarios/01-workspace-shell/1.1-app-shell/1.1-app-shell.md @@ -0,0 +1,558 @@ +# 1.1 — App Shell + +**Next Step:** → [Status Bar](../1.2-status-bar/1.2-status-bar.md) + +--- + +## Page Metadata + +| Property | Value | +|----------|-------| +| **Scenario** | 01 — Workspace Shell | +| **Page Number** | 1.1 | +| **Platform** | Web (PWA), portable to macOS/Windows | +| **Page Type** | Full Application Shell | +| **Viewport** | Desktop-first, responsive to mobile | +| **Interaction** | Mouse + keyboard (primary), touch (secondary) | +| **Visibility** | Public (no auth required) | + +--- + +## Overview + +**Page Purpose:** The app shell is the top-level container that defines the spatial structure of the entire CalcText workspace. It arranges all panels (sidebar, editor, results), the tab bar, header, and status bar into a cohesive layout that persists across all user interactions. + +**User Situation:** User opens CalcText in a browser. They expect a professional workspace where they can write calculations, manage files, and personalize their environment. First-time users should immediately understand the layout. Returning users should find their documents and preferences exactly as they left them. + +**Success Criteria:** +- User understands the 3-panel layout within 3 seconds +- All panels are resizable and collapsible +- Layout state persists across sessions (sidebar width, panel visibility) +- Feels native — not like a web page + +**Entry Points:** +- Direct URL (calctext.app or localhost) +- PWA launch from desktop/dock +- Returning session (restores last state from localStorage) + +**Exit Points:** +- Close browser/PWA (state auto-saves) +- All navigation is within the shell (no page transitions) + +--- + +## Reference Materials + +**Strategic Foundation:** +- [Product Brief](../../../A-Product-Brief/project-brief.md) — Workspace evolution, web-first +- [Brownfield Analysis](../../../A-Product-Brief/01-brownfield-analysis.md) — Current CSS tokens, components, gaps + +**Related Pages:** +- [Status Bar](../1.2-status-bar/1.2-status-bar.md) — Bottom status strip +- Tab Bar (Scenario 03) — Document tabs above editor +- Sidebar (Scenario 04) — File tree panel +- Editor (Scenario 02) — Calculation editor +- Results Panel (Scenario 02) — Results display + +--- + +## Layout Structure + +The app shell uses a 4-region layout: Header, Sidebar, Main Area (tabs + editor + results), and Status Bar. + +### Desktop (>= 768px) + +``` +┌─────────────────────────────────────────────────────────┐ +│ Header [Logo] [Theme] [Settings] [⌘] │ 40px +├────────┬────────────────────────────────────────────────┤ +│ │ Tab Bar [Budget ×] [Invoice ×] [+] │ 36px +│ ├─────────────────────────┬──┬──────────────────┤ +│ Side │ │ │ │ +│ bar │ Editor │ D│ Results │ +│ │ (CodeMirror 6) │ iv│ Panel │ +│ 240px │ │ │ │ +│ (coll │ │ │ │ +│ apsi │ │ │ │ +│ ble) │ │ │ │ +│ │ │ │ │ +├────────┴─────────────────────────┴──┴──────────────────┤ +│ Status Bar [Ln 12, Col 8] [Engine ●] [Dark 🎨] │ 24px +└─────────────────────────────────────────────────────────┘ +``` + +### Tablet (768px — 1024px) + +``` +┌─────────────────────────────────────────┐ +│ Header [Logo] [Theme] [≡] │ 40px +├─────────────────────────────────────────┤ +│ Tab Bar [Budget ×] [Invoice ×] [+] │ 36px +├─────────────────────────┬──┬────────────┤ +│ │ │ │ +│ Editor │ D│ Results │ +│ (CodeMirror 6) │ iv│ Panel │ +│ │ │ │ +├─────────────────────────┴──┴────────────┤ +│ Status Bar │ 24px +└─────────────────────────────────────────┘ +Sidebar: overlay drawer via hamburger [≡] +``` + +### Mobile (< 768px) + +``` +┌─────────────────────────┐ +│ Header [Logo] [≡] │ 44px (touch) +├─────────────────────────┤ +│ Tab Bar (scrollable) │ 36px +├─────────────────────────┤ +│ │ +│ Editor │ +│ (full width) │ +│ │ +│ │ +├─────────────────────────┤ +│ Results Tray (toggle) │ 48px collapsed / 40vh expanded +├─────────────────────────┤ +│ Status Bar │ 24px +└─────────────────────────┘ +Sidebar: full-screen drawer +Results: bottom tray with drag handle +``` + +--- + +## Spacing + +**Scale:** [Spacing Scale](../../../D-Design-System/00-design-system.md#spacing-scale) + +| Property | Token | Pixels (proposed) | +|----------|-------|--------------------| +| Page padding (horizontal) | space-zero | 0px (panels fill edge-to-edge) | +| Header padding (horizontal) | space-md | 12px | +| Header padding (vertical) | space-xs | 6px | +| Sidebar width (default) | — | 240px | +| Sidebar min width | — | 180px | +| Sidebar max width | — | 400px | +| Divider width (sidebar ↔ editor) | — | 1px visible, 8px hit area | +| Divider width (editor ↔ results) | — | 1px visible, 8px hit area | +| Tab bar height | — | 36px | +| Tab padding (horizontal) | space-sm | 8px | +| Status bar height | — | 24px | +| Status bar padding (horizontal) | space-md | 12px | + +--- + +## Typography + +**Scale:** [Type Scale](../../../D-Design-System/00-design-system.md#type-scale) + +| Element | Semantic | Size | Weight | Typeface | +|---------|----------|------|--------|----------| +| App title (header) | — | text-sm | 600 | system sans | +| Tab label | — | text-xs | 400 (normal), 500 (active) | system sans | +| Status bar text | — | text-3xs | 400 | system mono | +| Sidebar section title | H3 | text-2xs | 600 | system sans | +| Sidebar file name | — | text-xs | 400 | system sans | +| Editor content | — | text-md | 400 | system mono | +| Results value | — | text-md | 400 | system mono | + +--- + +## Page Sections + +### Section: Header + +**OBJECT ID:** `shell-header` + +| Property | Value | +|----------|-------| +| Purpose | App identity, global actions, theme quick-switch | +| Height | 40px (desktop/tablet), 44px (mobile — touch target) | +| Background | var(--bg) | +| Border | bottom 1px solid var(--border) | +| Layout | Horizontal: logo-left, actions-right | + +#### Logo Group + +**OBJECT ID:** `shell-header-logo` + +| Property | Value | +|----------|-------| +| Content | CalcText icon + wordmark | +| Icon size | 20px × 20px | +| Wordmark | "CalcText" in text-sm, weight 600 | +| Gap | space-xs (6px) between icon and wordmark | +| Mobile | Wordmark hidden < 480px, icon only | + +#### Header Actions + +**OBJECT ID:** `shell-header-actions` + +| Property | Value | +|----------|-------| +| Layout | Horizontal, space-xs gap | +| Items | Theme toggle, Settings button, Keyboard shortcuts (⌘) | +| Button size | 28px × 28px (icon buttons) | +| Icon size | 16px | +| Style | Ghost buttons — transparent bg, var(--text) icon, hover → var(--accent-bg) | + +#### Theme Toggle (Header) + +**OBJECT ID:** `shell-header-theme-toggle` + +| Property | Value | +|----------|-------| +| Component | Icon button with dropdown | +| Icon | Sun (light), Moon (dark), Terminal (matrix), Palette (custom) | +| Click | Opens theme picker dropdown | +| Tooltip | "Switch theme (Ctrl+Shift+T)" | + +#### ↕ `shell-header-v-space-zero` — Header sits flush against tab bar below + +--- + +### Section: Sidebar + +**OBJECT ID:** `shell-sidebar` + +| Property | Value | +|----------|-------| +| Purpose | File navigation, document organization, templates | +| Width | 240px default, resizable (180–400px), collapsible to 0 | +| Background | var(--bg-secondary) | +| Border | right 1px solid var(--border) | +| Toggle | Cmd/Ctrl+B to show/hide | +| Resize | Drag right edge, cursor col-resize | +| Persistence | Width and visibility stored in localStorage | + +#### Sidebar Sections + +**OBJECT ID:** `shell-sidebar-sections` + +| Section | Icon | Content | +|---------|------|---------| +| **Recent** | 🕐 | Last 5 opened documents, sorted by time | +| **Favorites** | ⭐ | User-pinned documents | +| **Files** | 📁 | Full folder tree with nested structure | +| **Templates** | 📋 | Pre-built starting points (Budget, Invoice, Unit Converter, Trip Planner, Blank) | + +Each section is collapsible with a chevron toggle. + +#### File Tree Item + +**OBJECT ID:** `shell-sidebar-file-item` + +| Property | Value | +|----------|-------| +| Height | 28px | +| Padding left | 12px + (depth × 16px) for nesting | +| Icon | 📄 file / 📁 folder (closed) / 📂 folder (open) | +| Label | File/folder name, text-xs, ellipsis on overflow | +| Hover | var(--accent-bg) background | +| Active | var(--accent-bg) + left 2px accent border | +| Right-click | Context menu: Rename, Delete, Duplicate, Move to folder, Add to favorites | +| Double-click | Opens document in new tab | +| Drag | Reorder within folder, drag between folders | + +#### Sidebar Footer + +**OBJECT ID:** `shell-sidebar-footer` + +| Property | Value | +|----------|-------| +| Content | [+ New Document] button, [+ New Folder] button | +| Layout | Horizontal, full width, space-xs gap | +| Button style | Ghost, text-2xs, var(--text), hover → var(--accent) | +| Position | Sticky bottom of sidebar | +| Padding | space-sm | +| Border | top 1px solid var(--border) | + +--- + +### Section: Tab Bar + +**OBJECT ID:** `shell-tabbar` + +| Property | Value | +|----------|-------| +| Purpose | Multi-document navigation, quick tab management | +| Height | 36px | +| Background | var(--bg-secondary) | +| Border | bottom 1px solid var(--border) | +| Layout | Horizontal scroll when tabs overflow | +| Position | Between header and editor area | + +#### Tab Item + +**OBJECT ID:** `shell-tabbar-tab` + +| Property | Value | +|----------|-------| +| Min width | 100px | +| Max width | 200px | +| Padding | 0 space-sm (0 8px) | +| Height | 36px (fills bar) | +| Label | Document title, text-xs, ellipsis on overflow | +| Modified indicator | Dot (6px) before title when unsaved changes | +| Close button | × icon, 14px, visible on hover or active tab | +| Active state | var(--bg) background, no bottom border (connected to editor) | +| Inactive state | var(--bg-secondary), bottom border 1px solid var(--border) | +| Hover (inactive) | var(--bg-secondary) lightened slightly | +| Drag | Reorder tabs via drag-and-drop | +| Middle-click | Close tab | +| Double-click | Rename document inline | + +#### New Tab Button + +**OBJECT ID:** `shell-tabbar-new` + +| Property | Value | +|----------|-------| +| Icon | + (16px) | +| Size | 36px × 36px (square, fills bar height) | +| Style | Ghost, var(--text), hover → var(--accent) | +| Click | Creates "Untitled" document and switches to it | +| Position | After last tab, before overflow | + +#### Tab Overflow + +**OBJECT ID:** `shell-tabbar-overflow` + +| Property | Value | +|----------|-------| +| Trigger | When tabs exceed container width | +| Behavior | Horizontal scroll with mouse wheel or trackpad | +| Indicators | Fade gradient on edges when scrollable | +| Keyboard | Ctrl+Tab / Ctrl+Shift+Tab to cycle tabs | + +--- + +### Section: Main Area + +**OBJECT ID:** `shell-main` + +| Property | Value | +|----------|-------| +| Purpose | Contains the active document's editor and results panel | +| Layout | Horizontal flex: editor (flex: 3) + divider + results (flex: 1) | +| Background | var(--bg) | +| Content | Delegates to Scenario 02 (Editor + Results Panel) | + +#### Editor Pane + +**OBJECT ID:** `shell-main-editor` + +| Property | Value | +|----------|-------| +| Flex | 3 (default ~75%) | +| Min width | 300px | +| Content | CodeMirror 6 instance (see Scenario 02) | +| Overflow | Vertical scroll (editor handles internally) | + +#### Pane Divider (Editor ↔ Results) + +**OBJECT ID:** `shell-main-divider` + +| Property | Value | +|----------|-------| +| Width | 1px visible line | +| Hit area | 8px (invisible padding for easy grabbing) | +| Cursor | col-resize | +| Color | var(--border), hover → var(--accent) | +| Transition | background 0.15s | +| Double-click | Reset to default 75/25 split | +| Persistence | Position stored in localStorage | + +#### Results Pane + +**OBJECT ID:** `shell-main-results` + +| Property | Value | +|----------|-------| +| Flex | 1 (default ~25%) | +| Min width | 120px | +| Content | Results panel (see Scenario 02) | +| Scroll sync | Mirrors editor scroll position | + +--- + +### Section: Status Bar + +**OBJECT ID:** `shell-statusbar` + +| Property | Value | +|----------|-------| +| Purpose | Contextual info: cursor position, engine status, theme indicator | +| Height | 24px | +| Background | var(--bg-secondary) | +| Border | top 1px solid var(--border) | +| Font | text-3xs, monospace | +| Color | var(--text) | +| Layout | Horizontal: left-info + right-info | + +Full specification in [1.2 — Status Bar](../1.2-status-bar/1.2-status-bar.md). + +--- + +## Page States + +| State | When | Appearance | Actions | +|-------|------|------------|---------| +| **First Launch** | No localStorage data | Sidebar open with Templates section expanded. Single tab "Welcome" with demo calctext. Theme follows OS preference. | User can start typing, explore templates, or create new doc | +| **Returning User** | localStorage has documents | Restores: last open tabs, active tab, sidebar state, theme, divider positions | Resume exactly where they left off | +| **Empty Workspace** | All documents deleted | Editor shows subtle placeholder: "Create a new document or choose a template to get started" | + New Document button prominent, Templates section highlighted | +| **Engine Loading** | WASM initializing | Status bar shows "Engine loading..." with pulse animation. Editor is editable. Results show "—" | Editor works, results appear once engine is ready | +| **Offline** | No network | Subtle indicator in status bar. All features work (localStorage). Currency rates may be stale. | Full functionality, offline banner only if currency conversion attempted | + +--- + +## Interactions & Keyboard Shortcuts + +| Shortcut | Action | +|----------|--------| +| Cmd/Ctrl + B | Toggle sidebar | +| Cmd/Ctrl + N | New document | +| Cmd/Ctrl + W | Close active tab | +| Cmd/Ctrl + Tab | Next tab | +| Cmd/Ctrl + Shift + Tab | Previous tab | +| Cmd/Ctrl + 1–9 | Switch to tab N | +| Cmd/Ctrl + Shift + T | Open theme picker | +| Cmd/Ctrl + , | Open settings | +| Cmd/Ctrl + S | Force save (visual confirmation — auto-save is default) | + +--- + +## Responsive Behavior + +| Breakpoint | Layout Changes | +|------------|----------------| +| **>= 1024px** | Full 3-panel: sidebar + editor + results | +| **768–1023px** | Sidebar becomes overlay drawer (hamburger toggle). Editor + results remain side-by-side | +| **< 768px** | Single column. Sidebar = full-screen drawer. Results = bottom tray (collapsible). Tabs = horizontal scroll. Header = 44px (touch). | + +### Mobile Results Tray + +| Property | Value | +|----------|-------| +| Collapsed height | 48px (shows last result + drag handle) | +| Expanded height | 40vh | +| Drag handle | 32px × 4px pill, centered, var(--border) | +| Swipe up | Expand tray | +| Swipe down | Collapse tray | +| Tap collapsed | Expand tray | + +### Mobile Sidebar Drawer + +| Property | Value | +|----------|-------| +| Width | 85vw (max 320px) | +| Background | var(--bg) | +| Overlay | 50% black backdrop | +| Animation | Slide from left, 200ms ease-out | +| Close | Tap backdrop, swipe left, or X button | + +--- + +## Theme Integration + +The app shell's colors are entirely driven by CSS custom properties. Switching themes means swapping the property values on `:root`. No component changes needed. + +| Theme | --bg | --bg-secondary | --text | --accent | Special | +|-------|------|----------------|--------|----------|---------| +| **Light** | #fff | #f8f9fa | #6b6375 | #6366f1 | — | +| **Dark** | #16171d | #1a1b23 | #9ca3af | #818cf8 | — | +| **Matrix** | #0a0a0a | #0f0f0f | #00ff41 | #00ff41 | Monospace everywhere. Subtle scanline overlay. Cursor blink green. | +| **Custom** | User-defined | User-defined | User-defined | User-defined | Accent color picker + base tone (warm/cool/neutral) | + +Theme selection persisted in localStorage as `calctext-theme`. + +--- + +## localStorage Schema + +```typescript +interface CalcTextStorage { + // Documents + documents: Document[] + folders: Folder[] + activeTabId: string + openTabIds: string[] + + // Layout + sidebarWidth: number + sidebarVisible: boolean + dividerPosition: number // percentage + + // Preferences + theme: 'light' | 'dark' | 'matrix' | string // string for custom + customTheme?: ThemeTokens + accentColor?: string + + // State + lastOpenedAt: string // ISO timestamp +} + +interface Document { + id: string + title: string + content: string + folderId: string | null + isFavorite: boolean + createdAt: string + updatedAt: string +} + +interface Folder { + id: string + name: string + parentId: string | null + order: number +} +``` + +--- + +## Technical Notes + +- **Cross-platform portability:** All layout patterns map to native equivalents. Sidebar → NavigationView (SwiftUI) / side_panel (iced). Tab bar → TabView. Status bar → standard OS status bar pattern. +- **Performance:** Only the active tab's CodeMirror instance should be in DOM. Inactive tabs store content in memory, restore on switch. +- **Auto-save:** Documents save to localStorage on every change (debounced 500ms). No explicit "save" needed, but Cmd+S provides visual confirmation. +- **Sidebar resize:** Use ResizeObserver + mouse events. Store width in localStorage. Minimum 180px, maximum 400px. +- **Tab management:** Maximum suggested tabs: 20. Beyond that, show "too many tabs" hint. No hard limit. + +--- + +## Open Questions + +| # | Question | Context | Status | +|---|----------|---------|--------| +| 1 | Should Matrix theme have a subtle CRT scanline effect? | Could be fun but might impact readability | 🔴 Open | +| 2 | Should we support tab groups / workspaces? | Multiple sets of tabs for different projects | 🔴 Open — defer to v2 | +| 3 | Max localStorage size for documents? | ~5MB browser limit. Need strategy for large collections. | 🟡 In Discussion — may need IndexedDB | + +--- + +## Checklist + +- [x] Page purpose clear +- [x] All Object IDs assigned +- [x] Layout structure defined (desktop, tablet, mobile) +- [x] Spacing tokens documented +- [x] Typography scale applied +- [x] States documented (first launch, returning, empty, loading, offline) +- [x] Keyboard shortcuts defined +- [x] Responsive breakpoints specified +- [x] Theme integration documented +- [x] localStorage schema defined +- [x] Cross-platform portability noted +- [x] Open questions captured + +--- + +**Next Step:** → [Status Bar](../1.2-status-bar/1.2-status-bar.md) + +--- + +_Created using Whiteport Design Studio (WDS) methodology_ diff --git a/_bmad-output/C-UX-Scenarios/01-workspace-shell/1.2-status-bar/1.2-status-bar.md b/_bmad-output/C-UX-Scenarios/01-workspace-shell/1.2-status-bar/1.2-status-bar.md new file mode 100644 index 0000000..2436410 --- /dev/null +++ b/_bmad-output/C-UX-Scenarios/01-workspace-shell/1.2-status-bar/1.2-status-bar.md @@ -0,0 +1,124 @@ +# 1.2 — Status Bar + +**Previous Step:** ← [App Shell](../1.1-app-shell/1.1-app-shell.md) + +--- + +## Page Metadata + +| Property | Value | +|----------|-------| +| **Scenario** | 01 — Workspace Shell | +| **Page Number** | 1.2 | +| **Platform** | Web (PWA), portable to macOS/Windows | +| **Page Type** | Persistent UI Strip (embedded in App Shell) | +| **Viewport** | All breakpoints | + +--- + +## Overview + +**Page Purpose:** Provide at-a-glance contextual information about the current document, engine status, and workspace state. Acts as a persistent information bar at the bottom of the workspace. + +**User Situation:** User is working in the editor and glances down for context — current line/column, whether the engine is ready, what theme is active, and document stats. + +**Success Criteria:** +- Information readable at a glance without interrupting flow +- No interactive elements that accidentally trigger (info-only, with 2 clickable shortcuts) +- Consistent across all themes + +--- + +## Layout Structure + +``` +┌──────────────────────────────────────────────────────────────┐ +│ [Ln 12, Col 8] [42 lines] │ [Engine ● Ready] [Dark 🎨] │ +│ ← left-aligned info │ right-aligned info → │ +└──────────────────────────────────────────────────────────────┘ +``` + +Height: 24px. Full width. Background: var(--bg-secondary). Border-top: 1px solid var(--border). + +--- + +## Spacing + +| Property | Token | Pixels | +|----------|-------|--------| +| Height | — | 24px | +| Padding horizontal | space-md | 12px | +| Item gap | space-md | 12px | +| Dot size (engine status) | — | 6px | +| Dot margin-right | space-2xs | 4px | + +--- + +## Typography + +| Element | Size | Weight | Typeface | Color | +|---------|------|--------|----------|-------| +| All status text | text-3xs | 400 | system mono | var(--text) | +| Engine status (ready) | text-3xs | 400 | system mono | var(--success) | +| Engine status (loading) | text-3xs | 400 | system mono | var(--warning) | +| Theme name | text-3xs | 500 | system mono | var(--text) | + +--- + +## Status Items + +### Left Group + +**OBJECT ID:** `statusbar-left` + +| Item | Content | Update Trigger | +|------|---------|----------------| +| Cursor position | `Ln {line}, Col {col}` | Cursor movement | +| Line count | `{n} lines` | Document change | +| Selection (conditional) | `{n} selected` | When text selected | + +### Right Group + +**OBJECT ID:** `statusbar-right` + +| Item | Content | Behavior | +|------|---------|----------| +| Engine status | `● Ready` (green dot) or `◌ Loading...` (amber, pulse) | Auto-updates on engine state change | +| Theme indicator | `{Theme Name} 🎨` | Click → opens theme picker (same as header button) | +| Offline (conditional) | `📡 Offline` | Only visible when offline | + +--- + +## States + +| State | Engine Indicator | Additional | +|-------|-----------------|------------| +| Ready | ● green dot, "Ready" | — | +| Loading | ◌ amber dot, pulse animation, "Loading..." | — | +| Error | ● red dot, "Engine error" | Tooltip with error message | +| Offline | ● green dot, "Ready" | + "📡 Offline" appended | + +--- + +## Responsive + +| Breakpoint | Behavior | +|------------|----------| +| >= 768px | Full status bar, all items visible | +| < 768px | Simplified: cursor position + engine dot only. Theme and line count hidden. | + +--- + +## Technical Notes + +- Status bar reads from editor state (cursor, selection, line count) and engine hook (ready, error) +- Theme indicator is the only clickable element — keeps status bar non-disruptive +- On macOS/Windows: maps to native status bar or window title bar info + +--- + +**Previous Step:** ← [App Shell](../1.1-app-shell/1.1-app-shell.md) + +--- + +_Created using Whiteport Design Studio (WDS) methodology_ diff --git a/_bmad-output/C-UX-Scenarios/02-calculation-experience/2.1-editor/2.1-editor.md b/_bmad-output/C-UX-Scenarios/02-calculation-experience/2.1-editor/2.1-editor.md new file mode 100644 index 0000000..1d6e791 --- /dev/null +++ b/_bmad-output/C-UX-Scenarios/02-calculation-experience/2.1-editor/2.1-editor.md @@ -0,0 +1,302 @@ +# 2.1 — Editor + +**Next Step:** → [Results Panel](../2.2-results-panel/2.2-results-panel.md) + +--- + +## Page Metadata + +| Property | Value | +|----------|-------| +| **Scenario** | 02 — Calculation Experience | +| **Page Number** | 2.1 | +| **Platform** | Web (PWA), portable to macOS/Windows | +| **Page Type** | Embedded Panel (within App Shell main area) | +| **Viewport** | All breakpoints | +| **Interaction** | Keyboard-primary, mouse secondary | + +--- + +## Overview + +**Page Purpose:** The editor is where calculations happen. Users type natural-language math expressions, define variables, add comments, and organize their thinking. It must feel like a fast, responsive text editor — not a calculator widget. + +**User Situation:** User is actively thinking through numbers. They're writing a budget, converting units, figuring out a mortgage, or doing quick math. The editor must never get in their way — every keystroke should feel instant. + +**Success Criteria:** +- Typing latency < 16ms (60fps) +- Results update within 50ms of pause (debounce) +- Syntax highlighting aids comprehension without distraction +- Errors are visible but non-intrusive +- Visual hierarchy makes 50-line documents scannable + +**Entry Points:** +- Opening a document (tab click, sidebar double-click, new document) +- Returning to active document + +**Exit Points:** +- Switching tabs (editor content swaps) +- Closing document + +--- + +## Reference Materials + +**Existing Implementation:** +- `calcpad-web/src/editor/CalcEditor.tsx` — Current CodeMirror wrapper +- `calcpad-web/src/editor/calcpad-language.ts` — Syntax highlighting +- `calcpad-web/src/editor/error-display.ts` — Error underlines + gutter +- `calcpad-web/src/editor/inline-results.ts` — Zebra striping + +**Design System:** +- [Spacing Scale](../../../D-Design-System/00-design-system.md#spacing-scale) +- [Type Scale](../../../D-Design-System/00-design-system.md#type-scale) + +--- + +## Layout Structure + +``` +┌──────────────────────────────────────────┐ +│ [Gutter] [Line Content] │ +│ │ +│ 1 │ # Monthly Budget │ ← heading (bold, larger) +│ 2 │ │ ← empty line +│ 3 │ // Income │ ← comment (muted) +│ 4 │ salary = 5000 │ ← variable assignment +│ 5 │ freelance = 1200 │ ← variable assignment +│ 6 │ total_income = salary + freelance │ ← expression +│ 7 │ │ +│ 8 │ // Expenses │ ← comment +│ 9 │ rent = 1500 │ +│ 10 │ groceries = 400 │ +│ 11 │ utilities = rent * 5% ̰ ̰ ̰ ̰ │ ← error underline +│ 12 │ │ +│ 13 │ total_expenses = sum │ ← aggregator +│ 14 │ savings = total_income - total_exp │ +│ │ │ +│ │ │ +└──────────────────────────────────────────┘ + Zebra striping on alternating lines +``` + +--- + +## Spacing + +| Property | Token | Pixels | +|----------|-------|--------| +| Content padding top/bottom | space-sm | 8px | +| Line padding horizontal | space-md | 12px | +| Gutter width | — | 40px (auto-expands for > 999 lines) | +| Gutter padding right | space-xs | 6px | +| Line height | — | 24px (15px font × 1.6) | +| Error gutter width | — | 20px | + +**Change from current:** Reduced line padding from 16px → 12px to match macOS's tighter feel. + +--- + +## Typography + +| Element | Size | Weight | Typeface | Color | +|---------|------|--------|----------|-------| +| Line content | text-md (15px) | 400 | system mono | var(--text) | +| Headings (# lines) | text-md (15px) | 700 | system mono | var(--text-h) | +| Comments (// lines) | text-md (15px) | 400 italic | system mono | var(--text) at 50% opacity | +| Variable names | text-md (15px) | 400 | system mono | var(--syntax-variable) | +| Numbers | text-md (15px) | 400 | system mono | var(--syntax-number) | +| Operators | text-md (15px) | 400 | system mono | var(--syntax-operator) | +| Keywords | text-md (15px) | 500 | system mono | var(--syntax-keyword) | +| Functions | text-md (15px) | 400 | system mono | var(--syntax-function) | +| Currency symbols | text-md (15px) | 400 | system mono | var(--syntax-currency) | +| Line numbers (gutter) | text-xs (13px) | 400 | system mono | var(--text) at 40% opacity | +| Active line number | text-xs (13px) | 600 | system mono | var(--text) | + +--- + +## Syntax Highlighting Tokens + +New CSS custom properties for syntax colors, per-theme: + +| Token | Light | Dark | Matrix | +|-------|-------|------|--------| +| --syntax-variable | #4f46e5 (indigo-600) | #a5b4fc (indigo-300) | #00ff41 | +| --syntax-number | #0d9488 (teal-600) | #5eead4 (teal-300) | #00cc33 | +| --syntax-operator | #6b6375 (text) | #9ca3af (text) | #00ff41 | +| --syntax-keyword | #7c3aed (violet-600) | #c4b5fd (violet-300) | #39ff14 | +| --syntax-function | #2563eb (blue-600) | #93c5fd (blue-300) | #00ff41 | +| --syntax-currency | #d97706 (amber-600) | #fcd34d (amber-300) | #ffff00 | +| --syntax-comment | rgba(text, 0.5) | rgba(text, 0.5) | rgba(#00ff41, 0.4) | +| --syntax-heading | var(--text-h) | var(--text-h) | #00ff41 | +| --syntax-error | #e53e3e | #fc8181 | #ff0000 | + +--- + +## Visual Hierarchy Improvements + +### Headings (`# lines`) + +| Property | Value | +|----------|-------| +| Font weight | 700 (bold) | +| Color | var(--text-h) — strongest text color | +| Top margin | 8px extra (visual section break) — only if preceded by non-empty line | +| Bottom margin | 0 (heading belongs to content below) | +| Background | None (clean) | + +### Comments (`// lines`) + +| Property | Value | +|----------|-------| +| Font style | Italic | +| Opacity | 50% of text color | +| No background stripe | Comments skip zebra striping (visually distinct already) | + +### Empty Lines + +| Property | Value | +|----------|-------| +| Height | 24px (same as content lines) | +| Background | Normal zebra stripe pattern | +| Purpose | Visual breathing room, section separators | + +### Active Line + +| Property | Value | +|----------|-------| +| Background | var(--accent-bg) — subtle accent tint | +| Gutter | Line number bold + full opacity | +| Transition | background 0.1s | + +--- + +## Error Display + +### Error Underline + +**OBJECT ID:** `editor-error-underline` + +| Property | Value | +|----------|-------| +| Style | wavy underline | +| Color | var(--syntax-error) | +| Thickness | 1.5px | +| Scope | Underlines the specific token/expression that errored | + +### Error Gutter Marker + +**OBJECT ID:** `editor-error-gutter` + +| Property | Value | +|----------|-------| +| Icon | ⚠ (warning triangle) | +| Size | 14px | +| Color | var(--syntax-error) | +| Position | Error gutter column (20px wide, left of line numbers) | +| Hover | Tooltip with error message text | + +### Error Tooltip + +**OBJECT ID:** `editor-error-tooltip` + +| Property | Value | +|----------|-------| +| Trigger | Hover over error gutter marker OR underlined text | +| Background | var(--bg-secondary) | +| Border | 1px solid var(--border) | +| Border-radius | 4px | +| Padding | 4px 8px | +| Font | text-xs, system sans, var(--syntax-error) | +| Shadow | 0 2px 8px rgba(0,0,0,0.15) | +| Max width | 300px | +| Position | Below the error line, left-aligned with gutter | + +--- + +## Zebra Striping + +| Property | Value | +|----------|-------| +| Pattern | Even-numbered lines (matching current implementation) | +| Light mode | rgba(0, 0, 0, 0.02) — reduced from 0.025 | +| Dark mode | rgba(255, 255, 255, 0.025) — reduced from 0.035 | +| Matrix mode | rgba(0, 255, 65, 0.03) — green tint | +| Skip on | Comment lines (already visually distinct) | + +--- + +## Autocomplete + +**OBJECT ID:** `editor-autocomplete` + +| Property | Value | +|----------|-------| +| Trigger | Typing 2+ characters that match a variable, function, or keyword | +| Panel | Dropdown below cursor, var(--bg) bg, 1px border, 4px radius | +| Max items | 8 visible, scroll for more | +| Item height | 28px | +| Active item | var(--accent-bg) highlight | +| Categories | Variables (with last value), Functions (with signature), Keywords, Units, Currencies | +| Keyboard | ↑↓ to navigate, Tab/Enter to accept, Esc to dismiss | +| Auto-dismiss | On cursor movement away | + +--- + +## Page States + +| State | When | Behavior | +|-------|------|----------| +| **Active editing** | User is typing | Debounced eval (50ms). Results update. Auto-save (500ms). | +| **Idle** | User paused | All results current. Document saved. | +| **Read-only** | Template preview (future) | No cursor, no editing. Gray overlay on gutter. | +| **Engine loading** | WASM initializing | Editor is fully editable. Results show "—" until engine ready. | +| **Large document** | > 500 lines | Viewport rendering only (CodeMirror handles this). Performance warning in status bar if > 1000 lines. | + +--- + +## Interactions + +| Action | Behavior | +|--------|----------| +| Type expression | Debounced eval → results update in 50ms | +| Define variable (`x = 5`) | Variable registered, available for autocomplete on subsequent lines | +| Reference variable | Autocomplete suggests matching variables with their current values | +| Use aggregator (`sum`, `total`) | Aggregates all numeric results above (up to previous heading or empty line block) | +| Line reference (`#3`) | References result of line 3 | +| Comment (`// text`) | Line excluded from evaluation, styled as comment | +| Heading (`# text`) | Section header, not evaluated, used for visual grouping and aggregator scoping | +| Select text | Selection count shown in status bar | +| Cmd/Ctrl+Z | Undo (per-document history preserved while tab is open) | +| Cmd/Ctrl+Shift+Z | Redo | +| Cmd/Ctrl+D | Duplicate current line | +| Cmd/Ctrl+/ | Toggle comment on current line | +| Alt+↑/↓ | Move line up/down | + +--- + +## Technical Notes + +- **CodeMirror instance management:** One instance per active tab. Inactive tabs store EditorState (preserves undo history). On tab switch, create new EditorView with stored state. +- **Eval debounce:** 50ms (current). Consider making configurable in settings (0–200ms range). +- **Syntax highlighting performance:** StreamLanguage parser runs synchronously — fine for < 1000 lines. For very large documents, consider switching to Lezer grammar. +- **Font scaling:** Expose font size setting (12–24px range) in settings. Current 15px is good default. Store in localStorage. +- **Cross-platform:** CodeMirror handles keyboard differences (Cmd vs Ctrl). Same extension stack works everywhere via WASM. + +--- + +## Open Questions + +| # | Question | Context | Status | +|---|----------|---------|--------| +| 1 | Should headings have extra top margin? | Creates visual sections but breaks 1:1 line alignment with results panel | 🟡 In Discussion — likely yes, results panel adjusts | +| 2 | Should comments skip zebra striping? | Makes them more visually distinct but breaks the pattern | 🔴 Open | +| 3 | Font size as user preference? | 12–24px range slider in settings | 🟢 Resolved: Yes, default 15px | + +--- + +**Next Step:** → [Results Panel](../2.2-results-panel/2.2-results-panel.md) + +--- + +_Created using Whiteport Design Studio (WDS) methodology_ diff --git a/_bmad-output/C-UX-Scenarios/02-calculation-experience/2.2-results-panel/2.2-results-panel.md b/_bmad-output/C-UX-Scenarios/02-calculation-experience/2.2-results-panel/2.2-results-panel.md new file mode 100644 index 0000000..c5bde37 --- /dev/null +++ b/_bmad-output/C-UX-Scenarios/02-calculation-experience/2.2-results-panel/2.2-results-panel.md @@ -0,0 +1,302 @@ +# 2.2 — Results Panel + +**Previous Step:** ← [Editor](../2.1-editor/2.1-editor.md) + +--- + +## Page Metadata + +| Property | Value | +|----------|-------| +| **Scenario** | 02 — Calculation Experience | +| **Page Number** | 2.2 | +| **Platform** | Web (PWA), portable to macOS/Windows | +| **Page Type** | Embedded Panel (within App Shell main area) | +| **Viewport** | Desktop + tablet (side panel), mobile (bottom tray) | + +--- + +## Overview + +**Page Purpose:** Display calculation results aligned line-by-line with the editor. Results are the reason the product exists — they must be clear, informative, and subtly styled so they complement the editor without competing for attention. + +**User Situation:** User is typing in the editor and their eyes flick right to see results. This happens dozens of times per session. The results must be instantly scannable — the eye should find the answer in milliseconds. + +**Success Criteria:** +- Every result line aligns pixel-perfectly with its editor line +- Results are readable but visually secondary to the editor +- Different result types are distinguishable at a glance +- Scroll synchronization is seamless + +**Entry Points:** +- Always visible on desktop/tablet (side panel) +- Toggle on mobile (bottom tray) + +--- + +## Layout Structure + +### Desktop/Tablet (side panel) + +``` +┌──────────────────────────┐ +│ │ +│ ──── │ ← heading (no result) +│ │ ← empty +│ ──── │ ← comment (no result) +│ 5,000 │ ← number +│ 1,200 │ ← number +│ 6,200 │ ← expression result +│ │ ← empty +│ ──── │ ← comment +│ 1,500 │ ← number +│ 400 │ ← number +│ ⚠ error │ ← error (muted, not red) +│ │ ← empty +│ 1,900 │ ← aggregator result +│ 4,300 │ ← expression result +│ │ +└──────────────────────────┘ + Right-aligned, zebra striping matches editor +``` + +### Mobile (bottom tray) + +``` +┌─────────────────────────────┐ +│ ═══ (drag handle) │ 48px collapsed +│ Last result: 4,300 │ +├─────────────────────────────┤ +│ Ln 5: salary = 5000 │ ← expanded: shows +│ Ln 6: freelance = 1200 │ line + result pairs +│ Ln 7: total_income = 6200 │ scrollable +│ ... │ +└─────────────────────────────┘ +``` + +--- + +## Spacing + +| Property | Token | Pixels | +|----------|-------|--------| +| Content padding top/bottom | space-sm | 8px (matches editor) | +| Result line padding horizontal | space-md | 12px (matches editor) | +| Result line height | — | 24px (matches editor exactly) | +| Mobile tray collapsed height | — | 48px | +| Mobile tray expanded height | — | 40vh | +| Mobile drag handle | — | 32px × 4px pill | + +--- + +## Typography + +| Element | Size | Weight | Typeface | Color | +|---------|------|--------|----------|-------| +| Numeric result | text-md (15px) | 400 | system mono | var(--result-number) | +| Unit value | text-md (15px) | 400 | system mono | var(--result-unit) | +| Currency value | text-md (15px) | 400 | system mono | var(--result-currency) | +| DateTime result | text-md (15px) | 400 | system mono | var(--result-datetime) | +| Boolean result | text-md (15px) | 400 | system mono | var(--result-boolean) | +| Error hint | text-xs (13px) | 400 | system sans | var(--text) at 30% opacity | +| Non-result lines | — | — | — | Empty (no text rendered) | + +**Design change from current:** Weight reduced from 600 → 400. Color changed from var(--accent) to type-specific semantic colors. This makes results secondary to the editor content, matching macOS behavior. + +--- + +## Result Type Colors + +New CSS custom properties per theme: + +| Token | Light | Dark | Matrix | Purpose | +|-------|-------|------|--------|---------| +| --result-number | #374151 (gray-700) | #d1d5db (gray-300) | #00ff41 | Plain numbers | +| --result-unit | #0d9488 (teal-600) | #5eead4 (teal-300) | #00cc33 | Values with units (5 kg, 3.2 m) | +| --result-currency | #d97706 (amber-600) | #fcd34d (amber-300) | #ffff00 | Currency values ($50, €42) | +| --result-datetime | #7c3aed (violet-600) | #c4b5fd (violet-300) | #39ff14 | Dates and times | +| --result-boolean | #6366f1 (indigo-500) | #818cf8 (indigo-400) | #00ff41 | true/false | +| --result-error | var(--text) at 30% | var(--text) at 30% | rgba(#ff0000, 0.3) | Error hint text | + +**Why type-specific colors:** Users scanning results can instantly distinguish "that's a currency" from "that's a unit" from "that's a date" without reading the value. The colors are muted (not saturated) so they don't compete with the editor. + +--- + +## Result Display Format + +| Result Type | Display Format | Example | +|-------------|----------------|---------| +| Number | Formatted with thousand separators | `6,200` | +| Unit value | Value + unit abbreviation | `2.2 kg` · `156.2 mi` | +| Currency | Symbol + value | `$4,300` · `€3,857.20` | +| DateTime | Locale-formatted | `Mar 25, 2026` · `14:30` | +| TimeDelta | Human-readable | `3 days, 4 hours` | +| Boolean | Lowercase | `true` · `false` | +| Comment | Empty line (dash marker) | `────` (subtle horizontal line) | +| Heading | Empty line (dash marker) | `────` | +| Empty | Empty line | (blank) | +| Error | Muted hint | `· error` (tiny, de-emphasized) | +| Variable assignment | Show assigned value | `5,000` | + +**Change from current:** Removed the `= ` prefix before results. Just show the value. Cleaner. + +### Comment/Heading Marker + +| Property | Value | +|----------|-------| +| Content | `────` (4 em dashes, or CSS border-bottom) | +| Color | var(--border) at 50% opacity | +| Purpose | Visual separator showing this line has no numeric result | +| Width | 60% of panel width, right-aligned | + +### Error Hint + +| Property | Value | +|----------|-------| +| Content | `· error` or `· invalid` | +| Color | var(--text) at 30% opacity | +| Purpose | Subtle indicator that something went wrong — details are in the editor gutter | +| Font | text-xs (13px), system sans | + +--- + +## Zebra Striping + +| Property | Value | +|----------|-------| +| Pattern | Matches editor exactly (same even-line pattern) | +| Colors | Same as editor per theme | +| Sync | Uses line index, not DOM position, for consistency | + +--- + +## Scroll Synchronization + +| Property | Value | +|----------|-------| +| Direction | Editor drives, results follows | +| Method | `scrollTop` mirroring via passive scroll listener | +| Latency | < 1 frame (requestAnimationFrame if needed) | +| Results panel | `overflow-y: hidden` — no independent scrolling | +| Edge case | If editor has heading margins (extra space), results panel inserts matching spacers | + +--- + +## Result Hover Interaction + +**OBJECT ID:** `results-hover` + +| Property | Value | +|----------|-------| +| Trigger | Mouse hover over a result line | +| Behavior | Show full precision + unit details in tooltip | +| Tooltip content | Raw value (full precision), type label, conversion hint | +| Example | Hover `$4,300` → "4300.00 USD (United States Dollar)" | +| Example | Hover `2.2 kg` → "2.20462 kg (kilogram) · 4.85 lb" | +| Style | Same as error tooltip (bg-secondary, border, 4px radius) | +| Delay | 500ms hover delay (don't trigger on casual mouse movement) | + +--- + +## Result Click Interaction + +**OBJECT ID:** `results-click` + +| Property | Value | +|----------|-------| +| Trigger | Single click on a result value | +| Behavior | Copy raw value to clipboard | +| Feedback | Brief flash (0.3s) — result text turns var(--success) then fades back | +| Tooltip | "Copied!" appears for 1.5s | +| Accessibility | aria-label="Copy result: {value}" | + +--- + +## Mobile Bottom Tray + +**OBJECT ID:** `results-mobile-tray` + +### Collapsed State (48px) + +| Property | Value | +|----------|-------| +| Content | Drag handle + last non-empty result value | +| Drag handle | 32px × 4px pill, var(--border), centered | +| Result text | "Last: {value}" or "No results" if empty | +| Font | text-xs, var(--text) | +| Background | var(--bg-secondary) | +| Border | top 1px solid var(--border) | +| Interaction | Tap or swipe up → expand | + +### Expanded State (40vh) + +| Property | Value | +|----------|-------| +| Content | Scrollable list of all line results paired with their expressions | +| Item format | `Ln {n}: {expression} → {result}` | +| Item height | 36px (larger for touch) | +| Active line | Highlighted with var(--accent-bg) | +| Tap result | Copy to clipboard (same as desktop click) | +| Interaction | Swipe down → collapse. Tap backdrop → collapse. | +| Scroll | Independent scroll (not synced with editor in mobile) | + +### Transitions + +| Property | Value | +|----------|-------| +| Expand/collapse | 200ms ease-out | +| Spring | Optional subtle overshoot on expand | + +--- + +## Page States + +| State | When | Results Display | +|-------|------|-----------------| +| **Normal** | Engine ready, results computed | Type-colored values per line | +| **Engine loading** | WASM initializing | All result lines show `—` in var(--text) at 20% | +| **Empty document** | No lines in editor | Panel is blank | +| **All errors** | Every line has errors | All lines show muted `· error` hints | +| **Stale results** | Document changed, eval pending | Previous results stay visible (no flash/flicker) | + +--- + +## Accessibility + +| Feature | Implementation | +|---------|---------------| +| ARIA | `role="complementary"`, `aria-label="Calculation results"` | +| Live updates | `aria-live="polite"` on result container — announces new results | +| Screen reader | Each result: `aria-label="Line {n}: {expression} equals {result}"` | +| Color-blind | Result types distinguishable by position + format, not just color | +| Click feedback | `aria-label="Copied"` announced on clipboard copy | +| Reduced motion | No flash animation; instant color change for copy feedback | + +--- + +## Technical Notes + +- **Result alignment:** Line height must match editor exactly (24px). If editor adds heading margins, results panel must add matching spacer divs. +- **Rendering:** ResultsPanel receives `EngineLineResult[]` from useEngine hook. Re-renders only changed lines (React key by line index). +- **Copy to clipboard:** Use `navigator.clipboard.writeText()`. Fall back to textarea trick for older browsers. +- **Hover tooltip positioning:** Position below the result line, right-aligned with panel. Flip above if near viewport bottom. +- **Mobile tray:** Use CSS `transform: translateY()` for smooth expand/collapse. Touch events for swipe gesture. +- **Cross-platform:** Side panel → native split view (macOS/Windows). Mobile tray → not applicable on desktop native. + +--- + +## Open Questions + +| # | Question | Context | Status | +|---|----------|---------|--------| +| 1 | Should heading lines in results show `────` markers? | Helps visual alignment but adds visual noise | 🔴 Open | +| 2 | Should copy-on-click copy formatted or raw value? | `$4,300` vs `4300` | 🟡 — Likely raw (more useful for pasting) | +| 3 | Result hover tooltip — show conversion alternatives? | "2.2 kg · 4.85 lb" on hover | 🟢 Resolved: Yes, useful for unit/currency results | + +--- + +**Previous Step:** ← [Editor](../2.1-editor/2.1-editor.md) + +--- + +_Created using Whiteport Design Studio (WDS) methodology_ diff --git a/_bmad-output/C-UX-Scenarios/03-document-management/3.1-tab-bar/3.1-tab-bar.md b/_bmad-output/C-UX-Scenarios/03-document-management/3.1-tab-bar/3.1-tab-bar.md new file mode 100644 index 0000000..3576946 --- /dev/null +++ b/_bmad-output/C-UX-Scenarios/03-document-management/3.1-tab-bar/3.1-tab-bar.md @@ -0,0 +1,257 @@ +# 3.1 — Tab Bar & Document Lifecycle + +**Next Step:** → [Sidebar](../../04-file-organization/4.1-sidebar/4.1-sidebar.md) + +--- + +## Page Metadata + +| Property | Value | +|----------|-------| +| **Scenario** | 03 — Document Management | +| **Page Number** | 3.1 | +| **Platform** | Web (PWA), portable to macOS/Windows | +| **Page Type** | Embedded Strip (within App Shell, between header and editor) | + +--- + +## Overview + +**Page Purpose:** Enable multi-document workflow. Users can open, create, close, reorder, and rename documents via tabs. The tab bar is the primary navigation between open documents. + +**Success Criteria:** +- Switching tabs feels instant (< 50ms) +- Users never lose work (auto-save before switch) +- Tab state survives page reload +- 15+ tabs remain usable (horizontal scroll) + +--- + +## Layout Structure + +``` +┌──────────────────────────────────────────────────────────┐ +│ [● Budget ×] [ Invoice ×] [ Unit Conv ×] [+] ··· │ 36px +└──────────────────────────────────────────────────────────┘ + ● = unsaved changes dot + Active tab: connected to editor (no bottom border) + [+] = new document button + ··· = overflow gradient when scrollable +``` + +--- + +## Spacing + +| Property | Token | Pixels | +|----------|-------|--------| +| Tab bar height | — | 36px | +| Tab padding horizontal | space-sm | 8px | +| Tab gap | — | 0px (tabs are flush, separated by 1px border) | +| Tab min width | — | 100px | +| Tab max width | — | 200px | +| Close button size | — | 16px × 16px | +| Close button margin-left | space-xs | 6px | +| Modified dot size | — | 6px | +| Modified dot margin-right | space-2xs | 4px | +| New tab button width | — | 36px (square) | + +--- + +## Typography + +| Element | Size | Weight | Color | +|---------|------|--------|-------| +| Tab label (active) | text-xs | 500 | var(--text-h) | +| Tab label (inactive) | text-xs | 400 | var(--text) | +| Close × | text-xs | 400 | var(--text) at 50%, hover → var(--text) | +| New + icon | text-sm | 300 | var(--text), hover → var(--accent) | + +--- + +## Tab States + +### Active Tab + +| Property | Value | +|----------|-------| +| Background | var(--bg) — same as editor, creates visual connection | +| Border bottom | None — tab "opens into" the editor | +| Border left/right | 1px solid var(--border) | +| Border top | 2px solid var(--accent) — active indicator | +| Label | Weight 500, var(--text-h) | +| Close button | Always visible | + +### Inactive Tab + +| Property | Value | +|----------|-------| +| Background | var(--bg-secondary) | +| Border bottom | 1px solid var(--border) — closed off from editor | +| Border left/right | 1px solid var(--border) | +| Border top | 2px solid transparent | +| Label | Weight 400, var(--text) | +| Close button | Visible on hover only | + +### Hover (Inactive) + +| Property | Value | +|----------|-------| +| Background | Blend between bg-secondary and bg (subtle lighten) | +| Transition | background 0.1s | + +### Dragging + +| Property | Value | +|----------|-------| +| Appearance | Tab lifts with subtle shadow (0 2px 8px rgba(0,0,0,0.15)) | +| Opacity | 90% | +| Placeholder | 2px var(--accent) vertical line at insertion point | +| Cursor | grabbing | + +--- + +## Interactions + +| Action | Behavior | +|--------|----------| +| **Click tab** | Switch to document. Auto-save current. Restore editor state (content, cursor, scroll, undo). | +| **Click +** | Create "Untitled" doc, open in new tab, focus editor. | +| **Click ×** | Close tab. If unsaved and modified, no prompt (auto-saved). Remove from openTabIds. | +| **Middle-click tab** | Close tab (same as ×). | +| **Double-click tab** | Inline rename — label becomes input field, Enter confirms, Esc cancels. | +| **Drag tab** | Reorder. Drop position shown by accent line indicator. | +| **Ctrl/Cmd+Tab** | Next tab (wraps). | +| **Ctrl/Cmd+Shift+Tab** | Previous tab (wraps). | +| **Ctrl/Cmd+W** | Close active tab. If last tab, create new "Untitled". | +| **Ctrl/Cmd+N** | New document + tab. | +| **Ctrl/Cmd+1–9** | Jump to tab by position. | +| **Mouse wheel on tab bar** | Horizontal scroll when tabs overflow. | + +--- + +## Tab Overflow + +| Property | Value | +|----------|-------| +| Trigger | Total tab width > container width | +| Scroll | Horizontal, smooth, via mouse wheel or trackpad | +| Indicators | 16px fade gradient on left/right edges when scrollable | +| Active tab | Auto-scrolls into view when selected via keyboard | +| New tab button | Sticky right — always visible outside scroll area | + +--- + +## Document Lifecycle + +### Create + +| Trigger | Behavior | +|---------|----------| +| Click [+] | New doc: `{ id: uuid(), title: "Untitled", content: "", folderId: null }` | +| Sidebar template click | New doc with template content and suggested title | +| Ctrl/Cmd+N | Same as [+] | + +Title auto-increments: "Untitled", "Untitled 2", "Untitled 3"... + +### Rename + +| Trigger | Behavior | +|---------|----------| +| Double-click tab | Label becomes ``, pre-selected text, 200px max width | +| Sidebar right-click → Rename | Same inline editing in sidebar | +| Enter | Confirm rename, update document and sidebar | +| Esc | Cancel, revert to previous name | +| Blur | Confirm (same as Enter) | +| Empty name | Revert to "Untitled" | + +### Delete + +| Trigger | Behavior | +|---------|----------| +| Sidebar right-click → Delete | Confirmation: "Delete '{title}'? This cannot be undone." | +| Confirm | Remove from documents[], close tab if open, remove from folder | +| Cancel | No action | +| Undo | 5-second toast: "Document deleted. [Undo]" — restores document if clicked | + +### Duplicate + +| Trigger | Behavior | +|---------|----------| +| Sidebar right-click → Duplicate | New doc: same content, title = "{original} (copy)", same folder | +| Opens in new tab automatically | + +### Auto-Save + +| Property | Value | +|----------|-------| +| Trigger | Document content change | +| Debounce | 500ms after last keystroke | +| Storage | localStorage (calctext-documents) | +| Indicator | Modified dot (●) appears immediately on change, disappears on save | +| Manual save | Ctrl/Cmd+S shows brief checkmark animation in tab (visual confirmation) | + +--- + +## Modified Indicator + +| Property | Value | +|----------|-------| +| Shape | Filled circle, 6px | +| Color | var(--text) at 60% | +| Position | Before tab label, 4px gap | +| Appears | On first character change | +| Disappears | On auto-save completion (500ms debounce) | +| Animation | Fade in 0.2s | + +--- + +## Context Menu (Right-Click Tab) + +| Item | Action | +|------|--------| +| Close | Close this tab | +| Close Others | Close all tabs except this one | +| Close to the Right | Close all tabs to the right | +| — | (separator) | +| Rename | Inline rename | +| Duplicate | Duplicate document | +| — | (separator) | +| Reveal in Sidebar | Scroll sidebar to show this file | + +--- + +## Mobile Adaptations + +| Property | Value | +|----------|-------| +| Tab bar | Horizontal scroll, touch-friendly | +| Tab height | 40px (larger touch target) | +| Close button | Hidden — swipe left on tab to reveal close | +| New tab | [+] button at far right | +| Rename | Long-press → context menu → Rename | +| Reorder | Long-press + drag | + +--- + +## Page States + +| State | When | Behavior | +|-------|------|----------| +| **Single tab** | Only one document open | Tab still shown (establishes pattern). Close creates new "Untitled". | +| **Many tabs (>10)** | Heavy usage | Horizontal scroll. Active tab auto-scrolls into view. | +| **All tabs closed** | User closed everything | Auto-create "Untitled" tab (workspace never empty). | +| **First launch** | No localStorage | Single "Welcome" tab with demo content. | + +--- + +## Technical Notes + +- **Tab switching performance:** Store `EditorState` per tab in memory. On switch: destroy current EditorView, create new with stored state. Content swap < 20ms. +- **Tab order persistence:** `openTabIds: string[]` in localStorage maintains order. +- **Cross-platform:** Maps to native tab bars. macOS: NSTabView or custom tab strip. Windows: iced tabs widget. +- **Max tabs:** Soft limit at 20 with performance hint in status bar. No hard limit. + +--- + +_Created using Whiteport Design Studio (WDS) methodology_ diff --git a/_bmad-output/C-UX-Scenarios/04-file-organization/4.1-sidebar/4.1-sidebar.md b/_bmad-output/C-UX-Scenarios/04-file-organization/4.1-sidebar/4.1-sidebar.md new file mode 100644 index 0000000..047af92 --- /dev/null +++ b/_bmad-output/C-UX-Scenarios/04-file-organization/4.1-sidebar/4.1-sidebar.md @@ -0,0 +1,328 @@ +# 4.1 — Sidebar & File Organization + +**Previous Step:** ← [Tab Bar](../../03-document-management/3.1-tab-bar/3.1-tab-bar.md) +**Next Step:** → [Templates](../4.2-templates/4.2-templates.md) + +--- + +## Page Metadata + +| Property | Value | +|----------|-------| +| **Scenario** | 04 — File Organization | +| **Page Number** | 4.1 | +| **Platform** | Web (PWA), portable to macOS/Windows | +| **Page Type** | Collapsible Side Panel (within App Shell) | + +--- + +## Overview + +**Page Purpose:** Organize and navigate calctext documents. The sidebar provides a persistent file tree with folders, recent files, favorites, and templates — transforming CalcText from a single-use calculator into a workspace where calculations are organized and retrievable. + +**Success Criteria:** +- Users find any document in < 3 seconds +- Folder hierarchy is intuitive (create, nest, rename, delete) +- Recent and Favorites provide quick access without browsing +- Sidebar never feels cluttered even with 50+ documents + +--- + +## Layout Structure + +``` +┌──────────────────────┐ +│ 🔍 Search... │ 32px — search bar +├──────────────────────┤ +│ │ +│ ▸ Recent │ section header (collapsible) +│ 📄 Budget │ +│ 📄 Quick Math │ +│ 📄 Invoice #42 │ +│ │ +│ ▸ Favorites │ section header +│ ⭐ Monthly Budget │ +│ ⭐ Tax Calculator │ +│ │ +│ ▾ Files │ section header (expanded) +│ 📁 Work │ folder +│ │ 📄 Budget │ file in folder +│ │ 📄 Invoice │ +│ 📁 Personal │ folder +│ │ 📁 Travel │ nested folder +│ │ │ 📄 Trip Cost │ +│ 📄 Scratch │ root-level file +│ │ +│ ▸ Templates │ section header +│ │ +├──────────────────────┤ +│ [+ Doc] [+ Folder] │ sticky footer +└──────────────────────┘ +``` + +--- + +## Spacing + +| Property | Token | Pixels | +|----------|-------|--------| +| Sidebar padding top | space-xs | 6px | +| Search bar height | — | 32px | +| Search bar margin | space-xs | 6px all sides | +| Section header height | — | 28px | +| Section header padding left | space-sm | 8px | +| File item height | — | 28px | +| File item padding left (root) | space-md | 12px | +| File item indent per depth | — | 16px | +| File icon size | — | 16px | +| File icon-to-label gap | space-xs | 6px | +| Section gap | space-xs | 6px | +| Footer height | — | 40px | +| Footer padding | space-xs | 6px | + +--- + +## Typography + +| Element | Size | Weight | Color | +|---------|------|--------|-------| +| Search placeholder | text-xs | 400 | var(--text) at 50% | +| Section header | text-2xs | 600 | var(--text) at 70% | +| File name | text-xs | 400 | var(--text) | +| File name (active) | text-xs | 500 | var(--text-h) | +| Folder name | text-xs | 500 | var(--text) | +| Footer buttons | text-2xs | 400 | var(--text), hover → var(--accent) | +| File count badge | text-3xs | 400 | var(--text) at 40% | + +--- + +## Search Bar + +**OBJECT ID:** `sidebar-search` + +| Property | Value | +|----------|-------| +| Placeholder | "Search documents..." | +| Background | var(--bg) | +| Border | 1px solid var(--border), focus → var(--accent-border) | +| Border radius | 4px | +| Icon | 🔍 magnifier, 14px, var(--text) at 40% | +| Padding | 4px 8px 4px 28px (icon offset) | +| Behavior | Filters file tree in real-time as user types | +| Clear | × button appears when text entered | +| Keyboard | Ctrl/Cmd+P opens/focuses search (like VS Code quick open) | +| Results | Flat list of matching files, ranked by recency. Highlights matching text. | +| Empty state | "No documents match '{query}'" | + +--- + +## Section: Recent + +**OBJECT ID:** `sidebar-recent` + +| Property | Value | +|----------|-------| +| Content | Last 5 opened documents, sorted by lastOpened timestamp | +| Collapsible | Yes, chevron toggle | +| Default state | Expanded on first launch, remembers toggle | +| Item display | File icon + name only (no folder path) | +| Empty state | "No recent documents" in text-3xs, muted | +| Update trigger | Opening any document pushes it to top, bumps oldest | + +--- + +## Section: Favorites + +**OBJECT ID:** `sidebar-favorites` + +| Property | Value | +|----------|-------| +| Content | User-pinned documents, ordered manually (drag) | +| Collapsible | Yes | +| Default state | Collapsed if empty, expanded if has items | +| Item display | ⭐ icon + name | +| Add to favorites | Right-click file → "Add to Favorites", or drag file into section | +| Remove | Right-click → "Remove from Favorites" | +| Empty state | "Drag files here or right-click → Add to Favorites" | + +--- + +## Section: Files (Tree) + +**OBJECT ID:** `sidebar-files` + +| Property | Value | +|----------|-------| +| Content | Complete folder tree with all documents | +| Collapsible | Yes (section level) | +| Default state | Expanded | +| Sort | Folders first, then files. Alphabetical within each group | +| Max depth | 3 levels (root → folder → subfolder → files). Prevents over-nesting | + +### Folder Item + +**OBJECT ID:** `sidebar-folder` + +| Property | Value | +|----------|-------| +| Icon | 📁 (closed) / 📂 (open) — or chevron ▸/▾ | +| Click | Toggle expand/collapse | +| Double-click | Rename inline | +| Right-click | Context menu | +| Drag | Reorder within parent. Drop files into folder. | +| Drop target | Highlight with var(--accent-bg) + 2px dashed var(--accent) border | +| Badge | File count in parentheses: `Work (3)` — text-3xs, muted | + +### File Item + +**OBJECT ID:** `sidebar-file` + +| Property | Value | +|----------|-------| +| Icon | 📄 (default) — could be themed per type later | +| Click | Open in tab (or switch to existing tab if already open) | +| Double-click | Open + rename inline | +| Hover | var(--accent-bg) background | +| Active | var(--accent-bg) + left 2px solid var(--accent) border | +| Active = | Currently open in active tab | +| Open indicator | Subtle dot or underline if open in any tab (even if not active) | +| Drag | Move between folders. Drag to tab bar to open. | +| Right-click | Context menu | + +### File Context Menu + +| Item | Action | +|------|--------| +| Open | Open in new tab | +| Open in New Tab | Open without closing current | +| — | separator | +| Rename | Inline rename | +| Duplicate | Copy with "(copy)" suffix | +| Add to Favorites | Toggle ⭐ | +| — | separator | +| Move to... | Submenu with folder list | +| — | separator | +| Delete | Confirm dialog → 5-second undo toast | + +### Folder Context Menu + +| Item | Action | +|------|--------| +| New Document Here | Create file inside this folder | +| New Subfolder | Create nested folder (max depth 3) | +| — | separator | +| Rename | Inline rename | +| — | separator | +| Delete Folder | Must be empty. If not: "Move contents to root first." | + +--- + +## Section: Templates + +**OBJECT ID:** `sidebar-templates` + +| Property | Value | +|----------|-------| +| Collapsible | Yes | +| Default state | Expanded on first launch | +| Content | Pre-built starting documents | + +Full specification in [4.2 — Templates](../4.2-templates/4.2-templates.md). + +--- + +## Sidebar Footer + +**OBJECT ID:** `sidebar-footer` + +| Property | Value | +|----------|-------| +| Position | Sticky bottom | +| Background | var(--bg-secondary) | +| Border top | 1px solid var(--border) | +| Layout | Two buttons side-by-side | +| Buttons | `[+ Document]` `[+ Folder]` — ghost style | +| New Document | Creates at root level, opens in tab | +| New Folder | Creates at root level, inline rename active | + +--- + +## Drag and Drop + +| Drag Source | Drop Target | Behavior | +|-------------|-------------|----------| +| File | Folder | Move file into folder | +| File | Between files | Reorder within same folder | +| File | Tab bar | Open file in new tab | +| File | Favorites section | Add to favorites | +| Folder | Between folders | Reorder at same depth | +| Tab | Sidebar folder | Move document to folder | + +### Drop Visual Feedback + +| State | Appearance | +|-------|------------| +| Valid target hover | var(--accent-bg) background, 2px dashed var(--accent) border | +| Invalid target | No visual change (drop not accepted) | +| Insertion line | 2px solid var(--accent) horizontal line at insertion point | +| Dragging item | 60% opacity, subtle shadow | + +--- + +## Resize Handle + +| Property | Value | +|----------|-------| +| Position | Right edge of sidebar | +| Width | 1px visible, 8px hit area | +| Cursor | col-resize | +| Color | var(--border), hover/drag → var(--accent) | +| Constraints | Min 180px, max 400px | +| Double-click | Reset to default 240px | +| Persistence | Width stored in localStorage | + +--- + +## Responsive Behavior + +| Breakpoint | Behavior | +|------------|----------| +| >= 1024px | Persistent side panel, resizable | +| 768–1023px | Overlay drawer, hamburger toggle in header | +| < 768px | Full-screen drawer (85vw, max 320px) | + +### Mobile Drawer + +| Property | Value | +|----------|-------| +| Trigger | Hamburger menu (≡) in header | +| Width | 85vw, max 320px | +| Overlay | 50% black backdrop | +| Animation | Slide from left, 200ms ease-out | +| Close | Tap backdrop, swipe left, or × button top-right | +| File tap | Opens document, auto-closes drawer | + +--- + +## Page States + +| State | When | Behavior | +|-------|------|----------| +| **Empty** | No documents or folders | Show: "Welcome! Create your first document or pick a template." + prominent buttons | +| **Few files** (<5) | Early usage | All sections visible, Templates expanded to encourage exploration | +| **Many files** (>20) | Power user | Search becomes critical. Sections collapsed by default except Files | +| **Search active** | User typed in search | Tree replaced by flat filtered list. Sections hidden. | +| **Dragging** | File/folder being moved | Drop targets highlighted. Invalid areas dimmed. | + +--- + +## Technical Notes + +- **Virtual scrolling:** Not needed until 500+ items. Standard DOM rendering is fine for typical usage. +- **Folder persistence:** `folders: Folder[]` in localStorage with `parentId` for tree structure. +- **Sort stability:** Alphabetical sort is stable — user manual ordering within a folder stored as `order` field. +- **Cross-platform:** Maps to NSOutlineView (macOS), Tree widget (iced/Windows). Same data model. + +--- + +_Created using Whiteport Design Studio (WDS) methodology_ diff --git a/_bmad-output/C-UX-Scenarios/04-file-organization/4.2-templates/4.2-templates.md b/_bmad-output/C-UX-Scenarios/04-file-organization/4.2-templates/4.2-templates.md new file mode 100644 index 0000000..44be56c --- /dev/null +++ b/_bmad-output/C-UX-Scenarios/04-file-organization/4.2-templates/4.2-templates.md @@ -0,0 +1,219 @@ +# 4.2 — Templates + +**Previous Step:** ← [Sidebar](../4.1-sidebar/4.1-sidebar.md) +**Next Step:** → [Theme System](../../05-theming/5.1-theme-system/5.1-theme-system.md) + +--- + +## Page Metadata + +| Property | Value | +|----------|-------| +| **Scenario** | 04 — File Organization | +| **Page Number** | 4.2 | +| **Platform** | Web (PWA), portable to macOS/Windows | +| **Page Type** | Sidebar Section + Modal (template preview) | + +--- + +## Overview + +**Page Purpose:** Provide ready-made starting documents that showcase CalcText's capabilities and help users get productive immediately. Templates are the product's best onboarding tool — they show by example. + +**Success Criteria:** +- First-time user finds a relevant template within 10 seconds +- Templates demonstrate the product's unique features (variables, units, currencies, aggregators) +- Using a template creates a new document (never modifies the template) + +--- + +## Template Library + +| Template | Description | Showcases | +|----------|-------------|-----------| +| **Budget** | Monthly income/expenses with categories | Variables, aggregators (sum, total), percentages | +| **Invoice** | Service invoice with line items and tax | Variables, multiplication, percentages, currency | +| **Unit Converter** | Common conversions with examples | Unit expressions (kg to lb, km to mi, °C to °F) | +| **Trip Planner** | Travel budget with currency conversion | Currency conversion, date math, variables | +| **Loan Calculator** | Mortgage/loan with monthly payments | Financial functions, percentages, variables | +| **Blank** | Empty document | — (clean start) | + +--- + +## Sidebar Templates Section + +**OBJECT ID:** `sidebar-templates` + +| Property | Value | +|----------|-------| +| Section header | "Templates" with 📋 icon | +| Collapsible | Yes | +| Default | Expanded on first launch, collapsed after first document created | +| Item height | 32px (slightly taller than files — more padding) | +| Item icon | Colored dot per template (visual distinction) | +| Item label | Template name, text-xs | +| Item sublabel | Brief description, text-3xs, muted, truncated | + +### Template Item Interaction + +| Action | Behavior | +|--------|----------| +| Click | Create new document from template. Title = template name. Opens in new tab. | +| Hover | Show full description in tooltip | +| Right-click | "Preview" option | + +--- + +## Template Colors (Icon Dots) + +| Template | Dot Color | +|----------|-----------| +| Budget | #10b981 (emerald) | +| Invoice | #6366f1 (indigo) | +| Unit Converter | #0d9488 (teal) | +| Trip Planner | #f59e0b (amber) | +| Loan Calculator | #7c3aed (violet) | +| Blank | var(--border) (gray) | + +--- + +## Template Content + +### Budget Template +``` +# Monthly Budget + +// Income +salary = 5000 +freelance = 1200 +total_income = salary + freelance + +// Housing +rent = 1500 +utilities = 150 +insurance = 80 + +// Living +groceries = 400 +transport = 120 +subscriptions = 45 + +// Summary +total_expenses = sum +savings = total_income - total_expenses +savings_rate = savings / total_income +``` + +### Invoice Template +``` +# Invoice #001 + +// Client: [Client Name] +// Date: [Date] + +// Services +web_design = 2500 +development = 4000 +consulting = 150 * 8 + +// Expenses +hosting = 29.99 +domain = 12.00 + +subtotal = sum + +// Tax +tax_rate = 10% +tax = subtotal * tax_rate +total = subtotal + tax +``` + +### Unit Converter Template +``` +# Unit Converter + +// Weight +75 kg in lb +2.5 lb in kg +100 g in oz + +// Distance +10 km in mi +26.2 mi in km +5280 ft in m + +// Temperature +100 °C in °F +72 °F in °C +0 °C in K + +// Data +1 GB in MB +500 MB in GB +1 TB in GB +``` + +### Trip Planner Template +``` +# Trip Planner + +// Budget +budget = $3000 + +// Flights +flight_out = $450 +flight_back = $380 + +// Hotel +nights = 7 +rate_per_night = $120 +hotel_total = nights * rate_per_night + +// Daily expenses +daily_food = $50 +daily_transport = $20 +daily_activities = $35 +daily_total = daily_food + daily_transport + daily_activities +trip_expenses = daily_total * nights + +// Summary +total_cost = flight_out + flight_back + hotel_total + trip_expenses +remaining = budget - total_cost +``` + +### Loan Calculator Template +``` +# Loan Calculator + +// Loan Details +principal = 250000 +annual_rate = 6.5% +years = 30 + +// Monthly Calculation +monthly_rate = annual_rate / 12 +num_payments = years * 12 + +// Monthly Payment +monthly_payment = principal * (monthly_rate * (1 + monthly_rate) ^ num_payments) / ((1 + monthly_rate) ^ num_payments - 1) + +// Total Cost +total_paid = monthly_payment * num_payments +total_interest = total_paid - principal + +// Summary +interest_ratio = total_interest / principal +``` + +--- + +## Technical Notes + +- Templates are hardcoded in the app (not fetched from server). Stored as string constants. +- Creating from template: deep copy content, assign new id/title, save to documents array. +- Future: user-created templates (save document as template). Defer to v2. +- Cross-platform: same template content across all platforms. + +--- + +_Created using Whiteport Design Studio (WDS) methodology_ diff --git a/_bmad-output/C-UX-Scenarios/05-theming/5.1-theme-system/5.1-theme-system.md b/_bmad-output/C-UX-Scenarios/05-theming/5.1-theme-system/5.1-theme-system.md new file mode 100644 index 0000000..5f2111d --- /dev/null +++ b/_bmad-output/C-UX-Scenarios/05-theming/5.1-theme-system/5.1-theme-system.md @@ -0,0 +1,371 @@ +# 5.1 — Theme System + +**Previous Step:** ← [Templates](../../04-file-organization/4.2-templates/4.2-templates.md) +**Next Step:** → [Mobile Experience](../../06-mobile-experience/6.1-mobile-shell/6.1-mobile-shell.md) + +--- + +## Page Metadata + +| Property | Value | +|----------|-------| +| **Scenario** | 05 — Theming | +| **Page Number** | 5.1 | +| **Platform** | Web (PWA), portable to macOS/Windows | +| **Page Type** | Dropdown Panel + Settings Section | + +--- + +## Overview + +**Page Purpose:** Enable users to personalize their workspace with preset themes and custom accent colors. Theming is a personality differentiator — especially the Matrix theme, which gives CalcText a unique identity in the calculator app market. + +**Success Criteria:** +- Theme switches instantly (< 16ms, no flash of unstyled content) +- Matrix theme makes users want to screenshot and share +- Custom accent color gives ownership feeling +- Theme persists across sessions + +--- + +## Theme Architecture + +All theming works through CSS custom properties on `:root`. Switching themes = swapping property values. No component changes, no re-renders beyond what CSS handles natively. + +```typescript +interface ThemeDefinition { + id: string + name: string + icon: string // emoji or svg + tokens: { + // Backgrounds + bg: string + bgSecondary: string + codeBg: string + + // Text + text: string + textH: string + + // Borders + border: string + + // Accent + accent: string + accentBg: string + accentBorder: string + + // Semantic + success: string + warning: string + error: string + + // Syntax highlighting + syntaxVariable: string + syntaxNumber: string + syntaxOperator: string + syntaxKeyword: string + syntaxFunction: string + syntaxCurrency: string + syntaxComment: string + syntaxHeading: string + + // Result colors + resultNumber: string + resultUnit: string + resultCurrency: string + resultDatetime: string + resultBoolean: string + + // Stripes + stripe: string + + // Special + fontOverride?: string // for Matrix: force monospace everywhere + specialEffect?: string // CSS class for effects like scanlines + } +} +``` + +--- + +## Preset Themes + +### Light + +| Token | Value | +|-------|-------| +| --bg | #ffffff | +| --bg-secondary | #f8f9fa | +| --text | #6b6375 | +| --text-h | #08060d | +| --border | #e5e4e7 | +| --accent | #6366f1 | +| --stripe | rgba(0, 0, 0, 0.02) | +| Feel | Clean, airy, professional | + +### Dark + +| Token | Value | +|-------|-------| +| --bg | #16171d | +| --bg-secondary | #1a1b23 | +| --text | #9ca3af | +| --text-h | #f3f4f6 | +| --border | #2e303a | +| --accent | #818cf8 | +| --stripe | rgba(255, 255, 255, 0.025) | +| Feel | Calm, focused, modern | + +### Matrix + +| Token | Value | +|-------|-------| +| --bg | #0a0a0a | +| --bg-secondary | #0f1a0f | +| --text | #00ff41 | +| --text-h | #33ff66 | +| --border | #003300 | +| --accent | #00ff41 | +| --stripe | rgba(0, 255, 65, 0.03) | +| --syntax-* | Green spectrum (#00cc33 to #39ff14) | +| --result-currency | #ffff00 (yellow — stands out in green) | +| fontOverride | `'Courier New', 'Fira Code', monospace` | +| specialEffect | `matrix-scanlines` | +| Feel | Iconic, hackery, fun | + +#### Matrix Special Effects + +**Scanlines (optional, subtle):** +```css +.matrix-scanlines::after { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(0, 0, 0, 0.08) 2px, + rgba(0, 0, 0, 0.08) 4px + ); + z-index: 9999; +} +``` + +**Cursor glow:** +```css +.matrix-theme .cm-cursor { + border-color: #00ff41; + box-shadow: 0 0 4px #00ff41, 0 0 8px rgba(0, 255, 65, 0.3); +} +``` + +**Text glow (subtle):** +```css +.matrix-theme .result-value { + text-shadow: 0 0 2px rgba(0, 255, 65, 0.3); +} +``` + +### Midnight + +| Token | Value | +|-------|-------| +| --bg | #0f172a | +| --bg-secondary | #1e293b | +| --text | #94a3b8 | +| --text-h | #e2e8f0 | +| --border | #334155 | +| --accent | #38bdf8 (sky-400) | +| --stripe | rgba(56, 189, 248, 0.03) | +| Feel | Deep blue, serene, late-night coding | + +### Warm + +| Token | Value | +|-------|-------| +| --bg | #fffbf5 | +| --bg-secondary | #fef3e2 | +| --text | #78716c | +| --text-h | #1c1917 | +| --border | #e7e5e4 | +| --accent | #f97316 (orange-500) | +| --stripe | rgba(249, 115, 22, 0.03) | +| Feel | Paper-like, warm, comfortable for long sessions | + +--- + +## Theme Picker Dropdown + +**OBJECT ID:** `theme-picker` + +| Property | Value | +|----------|-------| +| Trigger | Click theme icon in header or status bar, or Ctrl+Shift+T | +| Position | Dropdown below header button, right-aligned | +| Width | 240px | +| Background | var(--bg) | +| Border | 1px solid var(--border) | +| Border radius | 8px | +| Shadow | 0 4px 16px rgba(0, 0, 0, 0.15) | +| Padding | space-xs (6px) | +| Animation | Scale from 95% + fade, 150ms ease-out | +| Dismiss | Click outside, Esc, or select theme | + +### Theme Picker Layout + +``` +┌──────────────────────────┐ +│ Themes │ section header +│ │ +│ ☀️ Light ✓ │ active indicator +│ 🌙 Dark │ +│ 💻 Matrix │ +│ 🌊 Midnight │ +│ 📜 Warm │ +│ │ +│ ────────────────────── │ separator +│ │ +│ Accent Color │ section header +│ [●][●][●][●][●][●][●] │ color swatches +│ │ +│ ────────────────────── │ +│ ⚙️ System (auto) │ follows OS preference +└──────────────────────────┘ +``` + +### Theme Item + +| Property | Value | +|----------|-------| +| Height | 36px | +| Padding | 8px 12px | +| Layout | Icon + name + optional checkmark | +| Hover | var(--accent-bg) background, border-radius 4px | +| Active | Checkmark ✓ on right side, weight 500 | +| Click | Apply theme instantly, close picker | +| Preview | On hover, show a 4-color mini-swatch (bg, text, accent, secondary) | + +### Color Swatch Preview (on hover) + +| Property | Value | +|----------|-------| +| Size | 4 circles, 12px each, 4px gap | +| Colors | bg, bg-secondary, accent, text — of the hovered theme | +| Position | Inline after theme name | + +--- + +## Accent Color Picker + +**OBJECT ID:** `theme-accent-picker` + +| Property | Value | +|----------|-------| +| Location | Inside theme picker dropdown, below themes | +| Presets | 7 color swatches in a row | +| Swatch size | 20px circles, 6px gap | +| Active swatch | 2px ring in var(--text-h) | +| Custom | Last swatch is rainbow gradient → opens native color picker | +| Behavior | Overrides --accent, --accent-bg, --accent-border for current theme | +| Persistence | Stored as `accentColor` in localStorage | + +### Preset Accent Colors + +| Name | Value | Hex | +|------|-------|-----| +| Indigo (default) | Indigo 500/400 | #6366f1 / #818cf8 | +| Teal | Teal 500/400 | #14b8a6 / #2dd4bf | +| Rose | Rose 500/400 | #f43f5e / #fb7185 | +| Amber | Amber 500/400 | #f59e0b / #fbbf24 | +| Emerald | Emerald 500/400 | #10b981 / #34d399 | +| Sky | Sky 500/400 | #0ea5e9 / #38bdf8 | +| Custom | User pick | Via `` | + +Each preset has light/dark variants. The correct variant is selected based on current base theme. + +--- + +## System Theme Option + +**OBJECT ID:** `theme-system` + +| Property | Value | +|----------|-------| +| Behavior | Follows OS `prefers-color-scheme` | +| Matches | Light ↔ Light theme, Dark ↔ Dark theme | +| Override | Selecting any specific theme disables system following | +| Re-enable | Select "System (auto)" in picker | +| Label | "⚙️ System (auto)" with current resolved theme name | + +--- + +## Theme Application Flow + +``` +1. User clicks theme → store in localStorage +2. Apply CSS class to : `data-theme="matrix"` +3. CSS variables resolve from theme class +4. All components instantly update (CSS-only, no React re-render) +5. CodeMirror theme needs manual reconfiguration (dispatch theme compartment) +6. PWA theme-color meta tag updates for status bar color +``` + +### CodeMirror Theme Sync + +| Step | Action | +|------|--------| +| 1 | Theme changes → dispatch new baseTheme to EditorView | +| 2 | Syntax highlighting colors update via CSS variables (no extension swap) | +| 3 | Stripe colors update via CSS | +| 4 | Active line highlight updates via CSS | +| 5 | Only the base theme extension needs reconfiguration | + +--- + +## Responsive + +| Breakpoint | Theme Picker | +|------------|-------------| +| >= 768px | Dropdown panel below header button | +| < 768px | Bottom sheet (slides up from bottom, 60vh max height) | + +### Mobile Bottom Sheet + +| Property | Value | +|----------|-------| +| Width | 100vw | +| Max height | 60vh | +| Border radius | 12px 12px 0 0 (top corners) | +| Drag handle | 32px × 4px pill, centered | +| Backdrop | 50% black overlay | +| Animation | Slide up 200ms ease-out | +| Close | Swipe down, tap backdrop | + +--- + +## Page States + +| State | When | Behavior | +|-------|------|----------| +| **First launch** | No theme preference | Follow OS (system). If OS is dark, use Dark. | +| **Theme selected** | User picked a theme | Applied instantly, persisted. System mode disabled. | +| **System mode** | User selected "System (auto)" | Listens to OS changes in real-time. | +| **Custom accent** | User picked accent color | Overrides accent tokens. Works with any base theme. | +| **Matrix active** | Matrix theme selected | Font override applied. Scanline effect enabled. Green cursor glow. | + +--- + +## Technical Notes + +- **No Flash of Unstyled Content (FOUC):** Load theme from localStorage in `
diff --git a/calcpad-web/src/App.tsx b/calcpad-web/src/App.tsx index 716208d..9c29130 100644 --- a/calcpad-web/src/App.tsx +++ b/calcpad-web/src/App.tsx @@ -1,80 +1,338 @@ /** - * CalcPad main application component. + * CalcText main application component. * - * Two-column layout: - * Left: CodeMirror 6 editor with CalcPad syntax highlighting - * Right: Answer gutter (integrated into CodeMirror) + optional standalone AnswerColumn - * - * The WASM engine runs in a Web Worker. On each document change (debounced), - * the editor sends lines to the worker, which evaluates them and posts back - * results. Results are fed into the CodeMirror answer gutter extension. + * Workspace layout: header → tab bar → editor + results panel. + * Multi-document support via document store with localStorage persistence. */ -import { useCallback } from 'react' +import { useCallback, useState, useRef, useEffect } from 'react' +import type { EditorView } from '@codemirror/view' import { CalcEditor } from './editor/CalcEditor.tsx' import { useEngine } from './engine/useEngine.ts' import { useOnlineStatus } from './hooks/useOnlineStatus.ts' import { useInstallPrompt } from './hooks/useInstallPrompt.ts' +import { useTheme } from './hooks/useTheme.ts' +import { useDocumentStore, loadSidebarState, saveSidebarState } from './hooks/useDocumentStore.ts' import { OfflineBanner } from './components/OfflineBanner.tsx' import { InstallPrompt } from './components/InstallPrompt.tsx' +import { ResultsPanel } from './components/ResultsPanel.tsx' +import { ThemePicker } from './components/ThemePicker.tsx' +import { TabBar } from './components/TabBar.tsx' +import { Sidebar } from './components/Sidebar.tsx' +import { StatusBar } from './components/StatusBar.tsx' +import { AlignToolbar } from './components/AlignToolbar.tsx' +import type { Alignment } from './components/AlignToolbar.tsx' +import { FormatToolbar } from './components/FormatToolbar.tsx' +import { MobileResultsTray } from './components/MobileResultsTray.tsx' import './styles/app.css' -const INITIAL_DOC = `# CalcPad - -// Basic arithmetic -2 + 3 -10 * 4.5 -100 / 7 - -// Variables -price = 49.99 -quantity = 3 -subtotal = price * quantity - -// Percentages -tax = subtotal * 8% -total = subtotal + tax - -// Functions -sqrt(144) -2 ^ 10 -` - function App() { const engine = useEngine() const isOnline = useOnlineStatus() const installPrompt = useInstallPrompt() + const themeCtx = useTheme() + const store = useDocumentStore() + + const [editorView, setEditorView] = useState(null) + const resultsPanelRef = useRef(null) + const [modifiedIds, setModifiedIds] = useState>(new Set()) + const [editorAlign, setEditorAlign] = useState('left') + const [resultsAlign, setResultsAlign] = useState('right') + const [formatPreview, setFormatPreview] = useState(true) + + // Sidebar state + const [sidebarState, setSidebarState] = useState(loadSidebarState) + const setSidebarVisible = useCallback((v: boolean) => { + const next = { ...sidebarState, visible: v } + setSidebarState(next) + saveSidebarState(next) + }, [sidebarState]) + const setSidebarWidth = useCallback((w: number) => { + const next = { ...sidebarState, width: w } + setSidebarState(next) + saveSidebarState(next) + }, [sidebarState]) + + // Track a key to force CalcEditor remount on tab switch + const [editorKey, setEditorKey] = useState(store.activeTabId) + + // Draggable divider state + const [dividerX, setDividerX] = useState(null) + const isDragging = useRef(false) const handleDocChange = useCallback( (lines: string[]) => { engine.evalSheet(lines) + // Persist content + const content = lines.join('\n') + if (store.activeDoc && content !== store.activeDoc.content) { + store.updateContent(store.activeTabId, content) + setModifiedIds(prev => { + const next = new Set(prev) + next.add(store.activeTabId) + return next + }) + // Clear modified dot after save debounce + setTimeout(() => { + setModifiedIds(prev => { + const next = new Set(prev) + next.delete(store.activeTabId) + return next + }) + }, 500) + } }, - [engine.evalSheet], + [engine.evalSheet, store.activeTabId, store.activeDoc, store.updateContent], ) + // Switch tabs + const handleTabClick = useCallback((id: string) => { + if (id === store.activeTabId) return + store.setActiveTab(id) + setEditorKey(id) + }, [store.activeTabId, store.setActiveTab]) + + // New document + const handleNewTab = useCallback(() => { + const doc = store.createDocument() + setEditorKey(doc.id) + }, [store.createDocument]) + + // Close tab + const handleTabClose = useCallback((id: string) => { + store.closeTab(id) + // If we closed the active tab, editorKey needs updating + if (id === store.activeTabId) { + // State will update, trigger effect below + } + }, [store.closeTab, store.activeTabId]) + + // Sync editorKey when activeTabId changes externally (e.g. after close) + useEffect(() => { + if (editorKey !== store.activeTabId) { + setEditorKey(store.activeTabId) + } + }, [store.activeTabId]) // eslint-disable-line react-hooks/exhaustive-deps + + // Apply editor text alignment via CodeMirror + useEffect(() => { + if (!editorView) return + editorView.dom.style.setProperty('--cm-text-align', editorAlign) + }, [editorView, editorAlign]) + + // Scroll sync: mirror editor scroll position to results panel + useEffect(() => { + if (!editorView) return + const scroller = editorView.scrollDOM + const onScroll = () => { + if (resultsPanelRef.current) { + resultsPanelRef.current.scrollTop = scroller.scrollTop + } + } + scroller.addEventListener('scroll', onScroll, { passive: true }) + return () => scroller.removeEventListener('scroll', onScroll) + }, [editorView]) + + // Draggable divider handlers + const onDividerMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault() + isDragging.current = true + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + }, []) + + useEffect(() => { + function onMouseMove(e: MouseEvent) { + if (!isDragging.current) return + setDividerX(e.clientX) + } + function onMouseUp() { + if (!isDragging.current) return + isDragging.current = false + document.body.style.cursor = '' + document.body.style.userSelect = '' + } + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) + return () => { + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + } + }, []) + + // Keyboard shortcuts + useEffect(() => { + function handleKey(e: KeyboardEvent) { + const mod = e.metaKey || e.ctrlKey + + // Ctrl+B — toggle sidebar + if (mod && e.key === 'b') { + e.preventDefault() + setSidebarVisible(!sidebarState.visible) + return + } + + // Ctrl+N — new document + if (mod && e.key === 'n') { + e.preventDefault() + handleNewTab() + return + } + + // Ctrl+W — close tab + if (mod && e.key === 'w') { + e.preventDefault() + handleTabClose(store.activeTabId) + return + } + + // Ctrl+Tab / Ctrl+Shift+Tab — cycle tabs + if (mod && e.key === 'Tab') { + e.preventDefault() + const idx = store.openTabIds.indexOf(store.activeTabId) + const len = store.openTabIds.length + const next = e.shiftKey + ? store.openTabIds[(idx - 1 + len) % len] + : store.openTabIds[(idx + 1) % len] + handleTabClick(next) + return + } + + // Ctrl+1-9 — jump to tab + if (mod && e.key >= '1' && e.key <= '9') { + e.preventDefault() + const tabIdx = parseInt(e.key) - 1 + if (tabIdx < store.openTabIds.length) { + handleTabClick(store.openTabIds[tabIdx]) + } + return + } + } + + document.addEventListener('keydown', handleKey) + return () => document.removeEventListener('keydown', handleKey) + }, [store.activeTabId, store.openTabIds, handleNewTab, handleTabClose, handleTabClick, sidebarState.visible, setSidebarVisible]) + + // Compute flex styles from divider position + const editorStyle: React.CSSProperties = dividerX !== null + ? { width: dividerX, flex: 'none' } + : {} + const resultsStyle: React.CSSProperties = dividerX !== null + ? { flex: 1 } + : {} + return (
-

CalcPad

-

Notepad Calculator

-
- - {engine.ready ? 'Engine ready' : 'Loading engine...'} + +

CalcText

+
+
+ setFormatPreview(p => !p)} + /> +
+ +
-
-
- + +
+ {/* Mobile sidebar backdrop */} + {sidebarState.visible && ( +
setSidebarVisible(false)} /> -
-
+ )} + { store.openDocument(id); setEditorKey(id) }} + onNewDocument={(title, content) => { + const doc = store.createDocument(title, content) + setEditorKey(doc.id) + }} + onNewFolder={() => store.createFolder()} + onRenameDocument={store.renameDocument} + onDeleteDocument={store.deleteDocument} + onToggleFavorite={store.toggleFavorite} + onMoveToFolder={store.moveToFolder} + onRenameFolder={store.renameFolder} + onDeleteFolder={store.deleteFolder} + onWidthChange={setSidebarWidth} + /> + +
+
+ +
+
+ +
+
+ + + + void + onResultsAlignChange: (align: Alignment) => void +} + +function AlignIcon({ type }: { type: 'left' | 'center' | 'right' }) { + if (type === 'left') { + return ( + + ) + } + if (type === 'center') { + return ( + + ) + } + return ( + + ) +} + +const alignments: Alignment[] = ['left', 'center', 'right'] + +export function AlignToolbar({ + editorAlign, + resultsAlign, + onEditorAlignChange, + onResultsAlignChange, +}: AlignToolbarProps) { + return ( +
+
+ Editor + {alignments.map((a) => ( + + ))} +
+ +
+ Results + {alignments.map((a) => ( + + ))} +
+
+ ) +} diff --git a/calcpad-web/src/components/AnswerColumn.tsx b/calcpad-web/src/components/AnswerColumn.tsx deleted file mode 100644 index bece72a..0000000 --- a/calcpad-web/src/components/AnswerColumn.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Right-side answer column that displays evaluation results - * as a standalone panel (alternative to the gutter-based display). - * - * This component renders results in a scrollable column synced - * to the editor's line height. Each line shows the display value - * from the engine, color-coded by result type. - */ - -import type { EngineLineResult } from '../engine/types.ts' -import '../styles/answer-column.css' - -export interface AnswerColumnProps { - results: EngineLineResult[] -} - -function resultClassName(type: string): string { - switch (type) { - case 'number': - case 'unitValue': - case 'currencyValue': - case 'dateTime': - case 'timeDelta': - case 'boolean': - return 'answer-value' - case 'error': - return 'answer-error' - default: - return 'answer-empty' - } -} - -export function AnswerColumn({ results }: AnswerColumnProps) { - return ( -
- {results.map((result, i) => ( -
- {result.type === 'error' - ? 'Error' - : result.display || '\u00A0'} -
- ))} -
- ) -} diff --git a/calcpad-web/src/components/FormatToolbar.tsx b/calcpad-web/src/components/FormatToolbar.tsx new file mode 100644 index 0000000..3b53d62 --- /dev/null +++ b/calcpad-web/src/components/FormatToolbar.tsx @@ -0,0 +1,148 @@ +/** + * Formatting toolbar for the editor. + * Inserts/toggles markdown-like syntax in the CodeMirror editor. + */ + +import type { EditorView } from '@codemirror/view' +import '../styles/format-toolbar.css' + +interface FormatToolbarProps { + editorView: EditorView | null + previewMode: boolean + onPreviewToggle: () => void +} + +function insertPrefix(view: EditorView, prefix: string) { + const { state } = view + const line = state.doc.lineAt(state.selection.main.head) + const lineText = line.text + + if (lineText.startsWith(prefix)) { + // Remove prefix + view.dispatch({ + changes: { from: line.from, to: line.from + prefix.length, insert: '' }, + }) + } else { + // Add prefix + view.dispatch({ + changes: { from: line.from, insert: prefix }, + }) + } + view.focus() +} + +function wrapSelection(view: EditorView, wrapper: string) { + const { state } = view + const sel = state.selection.main + const selected = state.sliceDoc(sel.from, sel.to) + + if (selected.length === 0) { + // No selection — insert wrapper pair and place cursor inside + const text = `${wrapper}text${wrapper}` + view.dispatch({ + changes: { from: sel.from, insert: text }, + selection: { anchor: sel.from + wrapper.length, head: sel.from + wrapper.length + 4 }, + }) + } else if (selected.startsWith(wrapper) && selected.endsWith(wrapper)) { + // Already wrapped — unwrap + const inner = selected.slice(wrapper.length, -wrapper.length) + view.dispatch({ + changes: { from: sel.from, to: sel.to, insert: inner }, + selection: { anchor: sel.from, head: sel.from + inner.length }, + }) + } else { + // Wrap selection + const text = `${wrapper}${selected}${wrapper}` + view.dispatch({ + changes: { from: sel.from, to: sel.to, insert: text }, + selection: { anchor: sel.from, head: sel.from + text.length }, + }) + } + view.focus() +} + +function insertColor(view: EditorView, color: string) { + const { state } = view + const sel = state.selection.main + const selected = state.sliceDoc(sel.from, sel.to) + const label = selected.length > 0 ? selected : 'label' + const text = `[${color}:${label}]` + + view.dispatch({ + changes: { from: sel.from, to: sel.to, insert: text }, + selection: { anchor: sel.from, head: sel.from + text.length }, + }) + view.focus() +} + +const COLORS = [ + { name: 'Red', value: '#ef4444' }, + { name: 'Orange', value: '#f97316' }, + { name: 'Yellow', value: '#eab308' }, + { name: 'Green', value: '#22c55e' }, + { name: 'Blue', value: '#3b82f6' }, + { name: 'Purple', value: '#a855f7' }, +] + +export function FormatToolbar({ editorView, previewMode, onPreviewToggle }: FormatToolbarProps) { + return ( +
+
+ +
+ +
+ +
+ + + + +
+ +
+ +
+ {COLORS.map(c => ( +
+
+ ) +} diff --git a/calcpad-web/src/components/MobileResultsTray.tsx b/calcpad-web/src/components/MobileResultsTray.tsx new file mode 100644 index 0000000..b6a4f14 --- /dev/null +++ b/calcpad-web/src/components/MobileResultsTray.tsx @@ -0,0 +1,105 @@ +/** + * Mobile results tray — replaces the side panel on small screens. + * Collapsed: shows last result + drag handle (48px). + * Expanded: scrollable list of all results (40vh). + */ + +import { useState, useCallback, useRef } from 'react' +import type { EngineLineResult } from '../engine/types.ts' +import '../styles/mobile-results-tray.css' + +const DISPLAYABLE_TYPES = new Set([ + 'number', 'unitValue', 'currencyValue', 'dateTime', 'timeDelta', 'boolean', +]) + +interface MobileResultsTrayProps { + results: EngineLineResult[] + docLines: string[] +} + +export function MobileResultsTray({ results, docLines }: MobileResultsTrayProps) { + const [expanded, setExpanded] = useState(false) + const [copiedIdx, setCopiedIdx] = useState(null) + const startY = useRef(null) + + // Find last displayable result + let lastResult = '' + for (let i = results.length - 1; i >= 0; i--) { + if (DISPLAYABLE_TYPES.has(results[i].type) && results[i].display) { + lastResult = results[i].display + break + } + } + + const handleCopy = useCallback((idx: number, rawValue: number | null, display: string) => { + const text = rawValue != null ? String(rawValue) : display + navigator.clipboard.writeText(text).then(() => { + setCopiedIdx(idx) + setTimeout(() => setCopiedIdx(null), 1200) + }).catch(() => {}) + }, []) + + // Touch swipe handling + const handleTouchStart = useCallback((e: React.TouchEvent) => { + startY.current = e.touches[0].clientY + }, []) + + const handleTouchEnd = useCallback((e: React.TouchEvent) => { + if (startY.current === null) return + const deltaY = startY.current - e.changedTouches[0].clientY + startY.current = null + if (deltaY > 40) setExpanded(true) // swipe up + if (deltaY < -40) setExpanded(false) // swipe down + }, []) + + // Build result items for expanded view + const resultItems = results.map((r, i) => { + if (!DISPLAYABLE_TYPES.has(r.type) || !r.display) return null + const expr = docLines[i]?.trim() ?? '' + const isCopied = copiedIdx === i + return ( +
handleCopy(i, r.rawValue, r.display)} + > + Ln {i + 1} + {expr} + {isCopied ? 'Copied!' : r.display} +
+ ) + }).filter(Boolean) + + return ( +
+ {/* Drag handle + collapsed view */} +
setExpanded(prev => !prev)} + > +
+ {!expanded && ( + + {lastResult ? `Last: ${lastResult}` : 'No results'} + + )} + {expanded && ( + {resultItems.length} results + )} +
+ + {/* Expanded content */} + {expanded && ( +
+ {resultItems.length > 0 ? resultItems : ( +
No results yet
+ )} +
+ )} +
+ ) +} diff --git a/calcpad-web/src/components/ResultsPanel.tsx b/calcpad-web/src/components/ResultsPanel.tsx new file mode 100644 index 0000000..f69ab74 --- /dev/null +++ b/calcpad-web/src/components/ResultsPanel.tsx @@ -0,0 +1,109 @@ +/** + * Right-side results panel that displays one result per editor line. + * + * Each result line matches CodeMirror's 24px line height (15px * 1.6) + * so that results visually align with their corresponding expressions. + * Scroll position is driven externally via a forwarded ref. + */ + +import { forwardRef, useState, useCallback } from 'react' +import type { EngineLineResult } from '../engine/types.ts' +import '../styles/results-panel.css' + +const DISPLAYABLE_TYPES = new Set([ + 'number', + 'unitValue', + 'currencyValue', + 'dateTime', + 'timeDelta', + 'boolean', +]) + +const NON_RESULT_TYPES = new Set(['comment', 'text', 'empty']) + +/** Map result type to CSS class for type-specific coloring */ +function resultColorClass(type: string): string { + switch (type) { + case 'currencyValue': return 'result-currency' + case 'unitValue': return 'result-unit' + case 'dateTime': + case 'timeDelta': return 'result-datetime' + case 'boolean': return 'result-boolean' + default: return 'result-number' + } +} + +export interface ResultsPanelProps { + results: EngineLineResult[] + align: 'left' | 'center' | 'right' + style?: React.CSSProperties +} + +export const ResultsPanel = forwardRef( + function ResultsPanel({ results, align, style }, ref) { + const [copiedIdx, setCopiedIdx] = useState(null) + + const handleClick = useCallback((idx: number, rawValue: number | null, display: string) => { + const text = rawValue != null ? String(rawValue) : display + navigator.clipboard.writeText(text).then(() => { + setCopiedIdx(idx) + setTimeout(() => setCopiedIdx(null), 1200) + }).catch(() => { /* clipboard unavailable */ }) + }, []) + + return ( +
+ {results.map((result, i) => { + const isEven = (i + 1) % 2 === 0 + const stripe = isEven ? ' result-stripe' : '' + + if (DISPLAYABLE_TYPES.has(result.type) && result.display) { + const colorClass = resultColorClass(result.type) + const isCopied = copiedIdx === i + + return ( +
handleClick(i, result.rawValue, result.display)} + title="Click to copy" + > + {isCopied ? 'Copied!' : result.display} +
+ ) + } + + // Error hint + if (result.type === 'error' && result.error) { + return ( +
+ · error +
+ ) + } + + // Comment/heading marker + if (result.type === 'comment' || result.type === 'text') { + return ( +
+ ──── +
+ ) + } + + return ( +
+   +
+ ) + })} +
+ ) + }, +) diff --git a/calcpad-web/src/components/Sidebar.tsx b/calcpad-web/src/components/Sidebar.tsx new file mode 100644 index 0000000..58da600 --- /dev/null +++ b/calcpad-web/src/components/Sidebar.tsx @@ -0,0 +1,519 @@ +import { useState, useRef, useEffect, useCallback, type DragEvent } from 'react' +import type { CalcDocument, CalcFolder } from '../hooks/useDocumentStore.ts' +import { TEMPLATES } from '../data/templates.ts' +import '../styles/sidebar.css' + +interface SidebarProps { + visible: boolean + width: number + documents: CalcDocument[] + folders: CalcFolder[] + activeTabId: string + openTabIds: string[] + onFileClick: (id: string) => void + onNewDocument: (title?: string, content?: string) => void + onNewFolder: () => void + onRenameDocument: (id: string, title: string) => void + onDeleteDocument: (id: string) => void + onToggleFavorite: (id: string) => void + onMoveToFolder: (docId: string, folderId: string | null) => void + onRenameFolder: (id: string, name: string) => void + onDeleteFolder: (id: string) => void + onWidthChange: (width: number) => void +} + +export function Sidebar({ + visible, + width, + documents, + folders, + activeTabId, + openTabIds, + onFileClick, + onNewDocument, + onNewFolder, + onRenameDocument, + onDeleteDocument, + onToggleFavorite, + onMoveToFolder, + onRenameFolder, + onDeleteFolder, + onWidthChange, +}: SidebarProps) { + const [search, setSearch] = useState('') + const [expandedSections, setExpandedSections] = useState>( + () => new Set(['files', 'recent']) + ) + const [expandedFolders, setExpandedFolders] = useState>(() => new Set()) + const [editingId, setEditingId] = useState(null) + const [editValue, setEditValue] = useState('') + const [editType, setEditType] = useState<'file' | 'folder'>('file') + const [contextMenu, setContextMenu] = useState<{ + x: number; y: number; type: 'file' | 'folder'; id: string + } | null>(null) + const inputRef = useRef(null) + const resizeRef = useRef<{ startX: number; startWidth: number } | null>(null) + + // Focus rename input + useEffect(() => { + if (editingId && inputRef.current) { + inputRef.current.focus() + inputRef.current.select() + } + }, [editingId]) + + // Close context menu on click outside + useEffect(() => { + if (!contextMenu) return + const handler = () => setContextMenu(null) + document.addEventListener('click', handler) + return () => document.removeEventListener('click', handler) + }, [contextMenu]) + + // Resize handle + useEffect(() => { + function onMouseMove(e: MouseEvent) { + if (!resizeRef.current) return + const newWidth = Math.min(400, Math.max(180, resizeRef.current.startWidth + e.clientX - resizeRef.current.startX)) + onWidthChange(newWidth) + } + function onMouseUp() { + if (!resizeRef.current) return + resizeRef.current = null + document.body.style.cursor = '' + document.body.style.userSelect = '' + } + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) + return () => { + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + } + }, [onWidthChange]) + + const toggleSection = useCallback((section: string) => { + setExpandedSections(prev => { + const next = new Set(prev) + if (next.has(section)) next.delete(section) + else next.add(section) + return next + }) + }, []) + + const toggleFolder = useCallback((folderId: string) => { + setExpandedFolders(prev => { + const next = new Set(prev) + if (next.has(folderId)) next.delete(folderId) + else next.add(folderId) + return next + }) + }, []) + + // Rename — use refs to avoid stale closures in blur/keydown handlers + const editRef = useRef<{ id: string; value: string; type: 'file' | 'folder' } | null>(null) + + const startRename = useCallback((id: string, currentName: string, type: 'file' | 'folder') => { + editRef.current = { id, value: currentName, type } + setEditingId(id) + setEditValue(currentName) + setEditType(type) + setContextMenu(null) + }, []) + + const commitRename = useCallback(() => { + const edit = editRef.current + if (!edit) return + if (edit.type === 'file') onRenameDocument(edit.id, edit.value) + else onRenameFolder(edit.id, edit.value) + editRef.current = null + setEditingId(null) + }, [onRenameDocument, onRenameFolder]) + + // Keep ref in sync when editValue changes + const handleEditChange = useCallback((val: string) => { + setEditValue(val) + if (editRef.current) editRef.current.value = val + }, []) + + // Derived data + const recentDocs = [...documents] + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + .slice(0, 5) + + const favoriteDocs = documents.filter(d => d.isFavorite) + + const rootDocs = documents.filter(d => !d.folderId) + .sort((a, b) => a.title.localeCompare(b.title)) + + const rootFolders = folders.filter(f => !f.parentId) + .sort((a, b) => a.name.localeCompare(b.name)) + + const getDocsInFolder = (folderId: string) => + documents.filter(d => d.folderId === folderId).sort((a, b) => a.title.localeCompare(b.title)) + + // Drag and drop — use ref to avoid stale closure + const [dragId, setDragId] = useState(null) + const dragIdRef = useRef(null) + const [dropTarget, setDropTarget] = useState(null) + + const handleDragStart = useCallback((e: DragEvent, docId: string) => { + dragIdRef.current = docId + setDragId(docId) + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', docId) + }, []) + + const handleDragOver = useCallback((e: DragEvent, targetId: string) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + setDropTarget(targetId) + }, []) + + const handleDragLeave = useCallback(() => { + setDropTarget(null) + }, []) + + const handleDrop = useCallback((e: DragEvent, folderId: string | null) => { + e.preventDefault() + e.stopPropagation() + const docId = dragIdRef.current ?? e.dataTransfer.getData('text/plain') + if (docId) { + onMoveToFolder(docId, folderId) + if (folderId) { + setExpandedFolders(prev => { + const next = new Set(prev) + next.add(folderId) + return next + }) + } + } + dragIdRef.current = null + setDragId(null) + setDropTarget(null) + }, [onMoveToFolder]) + + const handleDragEnd = useCallback(() => { + dragIdRef.current = null + setDragId(null) + setDropTarget(null) + }, []) + + // Search filter + const searchResults = search.trim() + ? documents.filter(d => d.title.toLowerCase().includes(search.toLowerCase())) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + : null + + if (!visible) return null + + const renderFileItem = (doc: CalcDocument, depth = 0) => { + const isActive = doc.id === activeTabId + const isOpen = openTabIds.includes(doc.id) + const isDragged = dragId === doc.id + + return ( +
handleDragStart(e, doc.id)} + onDragEnd={handleDragEnd} + onClick={() => onFileClick(doc.id)} + onContextMenu={e => { + e.preventDefault() + setContextMenu({ x: e.clientX, y: e.clientY, type: 'file', id: doc.id }) + }} + > + 📄 + {editingId === doc.id ? ( + handleEditChange(e.target.value)} + onBlur={commitRename} + onKeyDown={e => { + if (e.key === 'Enter') commitRename() + if (e.key === 'Escape') setEditingId(null) + }} + onClick={e => e.stopPropagation()} + /> + ) : ( + {doc.title} + )} + {isOpen && !isActive && } +
+ ) + } + + const renderFolderItem = (folder: CalcFolder, depth = 0) => { + const isExpanded = expandedFolders.has(folder.id) + const docsInFolder = getDocsInFolder(folder.id) + const subFolders = folders.filter(f => f.parentId === folder.id) + const isDropTarget = dropTarget === folder.id + + return ( +
+
{ if (!editingId) toggleFolder(folder.id) }} + onDragOver={e => handleDragOver(e, folder.id)} + onDragLeave={handleDragLeave} + onDrop={e => handleDrop(e, folder.id)} + onContextMenu={e => { + e.preventDefault() + setContextMenu({ x: e.clientX, y: e.clientY, type: 'folder', id: folder.id }) + }} + > + {isExpanded ? '▾' : '▸'} + {editingId === folder.id ? ( + handleEditChange(e.target.value)} + onBlur={commitRename} + onKeyDown={e => { + if (e.key === 'Enter') commitRename() + if (e.key === 'Escape') setEditingId(null) + }} + onClick={e => e.stopPropagation()} + /> + ) : ( + <> + {isExpanded ? '📂' : '📁'} + {folder.name} + ({docsInFolder.length}) + + )} +
+ {isExpanded && ( + <> + {subFolders.map(sf => renderFolderItem(sf, depth + 1))} + {docsInFolder.map(d => renderFileItem(d, depth + 1))} + {docsInFolder.length === 0 && subFolders.length === 0 && ( +
+ Empty folder +
+ )} + + )} +
+ ) + } + + return ( + <> +
+ {/* Search */} +
+ 🔍 + setSearch(e.target.value)} + className="sidebar-search-input" + /> + {search && ( + + )} +
+ +
+ {searchResults ? ( + /* Search results */ +
+
+ Results ({searchResults.length}) +
+ {searchResults.map(d => renderFileItem(d))} + {searchResults.length === 0 && ( +
No documents match '{search}'
+ )} +
+ ) : ( + <> + {/* Recent */} +
+
toggleSection('recent')} + > + + {expandedSections.has('recent') ? '▾' : '▸'} + + 🕐 Recent +
+ {expandedSections.has('recent') && ( + recentDocs.length > 0 + ? recentDocs.map(d => renderFileItem(d)) + :
No recent documents
+ )} +
+ + {/* Favorites */} + {favoriteDocs.length > 0 && ( +
+
toggleSection('favorites')} + > + + {expandedSections.has('favorites') ? '▾' : '▸'} + + ⭐ Favorites +
+ {expandedSections.has('favorites') && favoriteDocs.map(d => renderFileItem(d))} +
+ )} + + {/* Templates */} +
+
toggleSection('templates')} + > + + {expandedSections.has('templates') ? '▾' : '▸'} + + 📋 Templates +
+ {expandedSections.has('templates') && ( +
+ {TEMPLATES.map(t => ( +
onNewDocument(t.name, t.content)} + title={t.description} + > + +
+ {t.name} + {t.description} +
+
+ ))} +
+ )} +
+ + {/* Files */} +
+
toggleSection('files')} + onDragOver={e => handleDragOver(e, 'root')} + onDragLeave={handleDragLeave} + onDrop={e => handleDrop(e, null)} + > + + {expandedSections.has('files') ? '▾' : '▸'} + + 📁 Files +
+ {expandedSections.has('files') && ( +
{ e.preventDefault(); e.dataTransfer.dropEffect = 'move' }} + onDrop={e => handleDrop(e, null)} + > + {rootFolders.map(f => renderFolderItem(f))} + {rootDocs.map(d => renderFileItem(d))} +
+ )} +
+ + )} +
+ + {/* Footer */} +
+ + +
+ + {/* Resize handle */} +
{ + e.preventDefault() + resizeRef.current = { startX: e.clientX, startWidth: width } + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + }} + onDoubleClick={() => onWidthChange(240)} + /> +
+ + {/* Context Menu */} + {contextMenu && ( +
+ {contextMenu.type === 'file' && ( + <> + + + + {folders.length > 0 && ( + <> +
+
Move to...
+ + {folders.map(f => ( + + ))} + + )} +
+ + + )} + {contextMenu.type === 'folder' && ( + <> + +
+ + + )} +
+ )} + + ) +} diff --git a/calcpad-web/src/components/StatusBar.tsx b/calcpad-web/src/components/StatusBar.tsx new file mode 100644 index 0000000..aad84ea --- /dev/null +++ b/calcpad-web/src/components/StatusBar.tsx @@ -0,0 +1,89 @@ +import type { EditorView } from '@codemirror/view' +import { useState, useEffect, useCallback } from 'react' +import '../styles/status-bar.css' + +interface StatusBarProps { + editorView: EditorView | null + engineReady: boolean + lineCount: number +} + +export function StatusBar({ editorView, engineReady, lineCount }: StatusBarProps) { + const [cursor, setCursor] = useState({ line: 1, col: 1 }) + const [selection, setSelection] = useState(0) + const [showDedication, setShowDedication] = useState(false) + + const toggleDedication = useCallback(() => { + setShowDedication(prev => !prev) + }, []) + + useEffect(() => { + if (!editorView) return + + const update = () => { + const state = editorView.state + const pos = state.selection.main.head + const line = state.doc.lineAt(pos) + setCursor({ line: line.number, col: pos - line.from + 1 }) + + const sel = state.selection.main + setSelection(Math.abs(sel.to - sel.from)) + } + + // Initial + update() + + const handler = () => update() + editorView.dom.addEventListener('keyup', handler) + editorView.dom.addEventListener('mouseup', handler) + + return () => { + editorView.dom.removeEventListener('keyup', handler) + editorView.dom.removeEventListener('mouseup', handler) + } + }, [editorView]) + + return ( +
+
+ Ln {cursor.line}, Col {cursor.col} + {lineCount} lines + {selection > 0 && {selection} selected} +
+
+ + + {engineReady ? 'Ready' : 'Loading...'} + + + Made with for Igor Cassel + +
+ + {showDedication && ( +
+
e.stopPropagation()}> +
+

For Igor Cassel

+

+ CalcText was created in honor of my cousin Igor, + who has always had a deep love for text editors + and the craft of building beautiful, functional tools. +

+

+ Every keystroke in this editor carries that inspiration. +

+ +
+
+ )} +
+ ) +} diff --git a/calcpad-web/src/components/TabBar.tsx b/calcpad-web/src/components/TabBar.tsx new file mode 100644 index 0000000..6e8e55a --- /dev/null +++ b/calcpad-web/src/components/TabBar.tsx @@ -0,0 +1,130 @@ +import { useState, useRef, useCallback, useEffect } from 'react' +import type { CalcDocument } from '../hooks/useDocumentStore.ts' +import '../styles/tab-bar.css' + +interface TabBarProps { + tabs: CalcDocument[] + activeTabId: string + onTabClick: (id: string) => void + onTabClose: (id: string) => void + onTabRename: (id: string, title: string) => void + onNewTab: () => void + modifiedIds?: Set +} + +export function TabBar({ + tabs, + activeTabId, + onTabClick, + onTabClose, + onTabRename, + onNewTab, + modifiedIds, +}: TabBarProps) { + const scrollRef = useRef(null) + const [editingId, setEditingId] = useState(null) + const [editValue, setEditValue] = useState('') + const inputRef = useRef(null) + + // Focus input when editing starts + useEffect(() => { + if (editingId && inputRef.current) { + inputRef.current.focus() + inputRef.current.select() + } + }, [editingId]) + + // Scroll active tab into view + useEffect(() => { + if (!scrollRef.current) return + const activeEl = scrollRef.current.querySelector('.tab-item.active') + activeEl?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + }, [activeTabId]) + + const startRename = useCallback((id: string, currentTitle: string) => { + setEditingId(id) + setEditValue(currentTitle) + }, []) + + const commitRename = useCallback(() => { + if (editingId) { + onTabRename(editingId, editValue) + setEditingId(null) + } + }, [editingId, editValue, onTabRename]) + + const cancelRename = useCallback(() => { + setEditingId(null) + }, []) + + const handleMiddleClick = useCallback((e: React.MouseEvent, id: string) => { + if (e.button === 1) { + e.preventDefault() + onTabClose(id) + } + }, [onTabClose]) + + // Horizontal scroll with mouse wheel + const handleWheel = useCallback((e: React.WheelEvent) => { + if (scrollRef.current && e.deltaY !== 0) { + scrollRef.current.scrollLeft += e.deltaY + } + }, []) + + return ( +
+
+ {tabs.map(tab => ( +
onTabClick(tab.id)} + onMouseDown={(e) => handleMiddleClick(e, tab.id)} + onDoubleClick={() => startRename(tab.id, tab.title)} + title={tab.title} + > + {modifiedIds?.has(tab.id) && ( + + )} + + {editingId === tab.id ? ( + setEditValue(e.target.value)} + onBlur={commitRename} + onKeyDown={e => { + if (e.key === 'Enter') commitRename() + if (e.key === 'Escape') cancelRename() + }} + onClick={e => e.stopPropagation()} + /> + ) : ( + {tab.title} + )} + + +
+ ))} +
+ +
+ ) +} diff --git a/calcpad-web/src/components/ThemePicker.tsx b/calcpad-web/src/components/ThemePicker.tsx new file mode 100644 index 0000000..8153046 --- /dev/null +++ b/calcpad-web/src/components/ThemePicker.tsx @@ -0,0 +1,116 @@ +import { useState, useRef, useEffect } from 'react' +import type { ThemeId } from '../hooks/useTheme.ts' +import { THEMES, ACCENT_COLORS } from '../hooks/useTheme.ts' +import '../styles/theme-picker.css' + +interface ThemePickerProps { + theme: ThemeId + accentColor: string | null + onThemeChange: (id: ThemeId) => void + onAccentChange: (color: string | null) => void +} + +export function ThemePicker({ theme, accentColor, onThemeChange, onAccentChange }: ThemePickerProps) { + const [open, setOpen] = useState(false) + const ref = useRef(null) + + // Close on click outside + useEffect(() => { + if (!open) return + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false) + } + } + function handleKey(e: KeyboardEvent) { + if (e.key === 'Escape') setOpen(false) + } + document.addEventListener('mousedown', handleClick) + document.addEventListener('keydown', handleKey) + return () => { + document.removeEventListener('mousedown', handleClick) + document.removeEventListener('keydown', handleKey) + } + }, [open]) + + // Keyboard shortcut: Ctrl+Shift+T + useEffect(() => { + function handleKey(e: KeyboardEvent) { + if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'T') { + e.preventDefault() + setOpen(prev => !prev) + } + } + document.addEventListener('keydown', handleKey) + return () => document.removeEventListener('keydown', handleKey) + }, []) + + const currentTheme = THEMES.find(t => t.id === theme) + const resolvedTheme = theme === 'system' ? undefined : theme + const icon = currentTheme?.icon ?? '⚙️' + + return ( +
+ + + {open && ( +
+
Themes
+ + {THEMES.map(t => ( + + ))} + +
+ +
Accent Color
+
+ {ACCENT_COLORS.map(c => ( +
+ +
+ + +
+ )} +
+ ) +} diff --git a/calcpad-web/src/data/templates.ts b/calcpad-web/src/data/templates.ts new file mode 100644 index 0000000..e696e25 --- /dev/null +++ b/calcpad-web/src/data/templates.ts @@ -0,0 +1,151 @@ +export interface Template { + id: string + name: string + description: string + color: string + content: string +} + +export const TEMPLATES: Template[] = [ + { + id: 'budget', + name: 'Budget', + description: 'Monthly income and expenses', + color: '#10b981', + content: `# Monthly Budget + +// Income +salary = 5000 +freelance = 1200 +total_income = salary + freelance + +// Housing +rent = 1500 +utilities = 150 +insurance = 80 + +// Living +groceries = 400 +transport = 120 +subscriptions = 45 + +// Summary +total_expenses = sum +savings = total_income - total_expenses +savings_rate = savings / total_income +`, + }, + { + id: 'invoice', + name: 'Invoice', + description: 'Service invoice with tax', + color: '#6366f1', + content: `# Invoice #001 + +// Client: [Client Name] +// Date: [Date] + +// Services +web_design = 2500 +development = 4000 +consulting = 150 * 8 + +// Expenses +hosting = 29.99 +domain = 12.00 + +subtotal = sum + +// Tax +tax_rate = 10% +tax = subtotal * tax_rate +total = subtotal + tax +`, + }, + { + id: 'units', + name: 'Unit Converter', + description: 'Common unit conversions', + color: '#0d9488', + content: `# Unit Converter + +// Weight +75 kg in lb +2.5 lb in kg +100 g in oz + +// Distance +10 km in mi +26.2 mi in km +5280 ft in m + +// Temperature +100 °C in °F +72 °F in °C +0 °C in K + +// Data +1 GB in MB +500 MB in GB +1 TB in GB +`, + }, + { + id: 'trip', + name: 'Trip Planner', + description: 'Travel budget with currencies', + color: '#f59e0b', + content: `# Trip Planner + +// Budget +budget = 3000 + +// Flights +flight_out = 450 +flight_back = 380 + +// Hotel +nights = 7 +rate_per_night = 120 +hotel_total = nights * rate_per_night + +// Daily expenses +daily_food = 50 +daily_transport = 20 +daily_activities = 35 +daily_total = daily_food + daily_transport + daily_activities +trip_expenses = daily_total * nights + +// Summary +total_cost = flight_out + flight_back + hotel_total + trip_expenses +remaining = budget - total_cost +`, + }, + { + id: 'loan', + name: 'Loan Calculator', + description: 'Mortgage and loan payments', + color: '#7c3aed', + content: `# Loan Calculator + +// Loan Details +principal = 250000 +annual_rate = 6.5% +years = 30 + +// Monthly Calculation +monthly_rate = annual_rate / 12 +num_payments = years * 12 + +// Monthly Payment +monthly_payment = principal * (monthly_rate * (1 + monthly_rate) ^ num_payments) / ((1 + monthly_rate) ^ num_payments - 1) + +// Total Cost +total_paid = monthly_payment * num_payments +total_interest = total_paid - principal + +// Summary +interest_ratio = total_interest / principal +`, + }, +] diff --git a/calcpad-web/src/editor/CalcEditor.tsx b/calcpad-web/src/editor/CalcEditor.tsx index 81837ef..1fb54bb 100644 --- a/calcpad-web/src/editor/CalcEditor.tsx +++ b/calcpad-web/src/editor/CalcEditor.tsx @@ -1,7 +1,7 @@ /** * React wrapper around CodeMirror 6 for the CalcPad editor. * - * Integrates the CalcPad language mode, answer gutter, error display, + * Integrates the CalcPad language mode, error display, * and debounced evaluation via the WASM engine Web Worker. */ @@ -15,15 +15,17 @@ import { keymap, } from '@codemirror/view' import { - defaultHighlightStyle, syntaxHighlighting, bracketMatching, indentOnInput, + HighlightStyle, } from '@codemirror/language' +import { tags } from '@lezer/highlight' import { defaultKeymap, history, historyKeymap } from '@codemirror/commands' import { calcpadLanguage } from './calcpad-language.ts' -import { answerGutterExtension, setAnswersEffect, type LineAnswer } from './answer-gutter.ts' import { errorDisplayExtension, setErrorsEffect, type LineError } from './error-display.ts' +import { stripedLinesExtension } from './inline-results.ts' +import { formatPreviewExtension, formatPreviewCompartment, formatPreviewEnabled } from './format-preview.ts' import type { EngineLineResult } from '../engine/types.ts' export interface CalcEditorProps { @@ -31,22 +33,27 @@ export interface CalcEditorProps { initialDoc?: string /** Called when the document text changes (debounced internally) */ onDocChange?: (lines: string[]) => void - /** Engine evaluation results to display in the answer gutter */ + /** Engine evaluation results to display as errors */ results?: EngineLineResult[] /** Debounce delay in ms before triggering onDocChange */ debounceMs?: number + /** Called with the EditorView once created (null on cleanup) */ + onViewReady?: (view: EditorView | null) => void + /** Enable live preview formatting */ + formatPreview?: boolean } /** * CalcPad editor component built on CodeMirror 6. - * Handles syntax highlighting, line numbers, answer gutter, - * and error underlines. + * Handles syntax highlighting, line numbers, and error underlines. */ export function CalcEditor({ initialDoc = '', onDocChange, results, debounceMs = 50, + onViewReady, + formatPreview = true, }: CalcEditorProps) { const containerRef = useRef(null) const viewRef = useRef(null) @@ -86,10 +93,11 @@ export function CalcEditor({ indentOnInput(), history(), keymap.of([...defaultKeymap, ...historyKeymap]), - syntaxHighlighting(defaultHighlightStyle), + syntaxHighlighting(calcpadHighlight), calcpadLanguage(), - answerGutterExtension(), errorDisplayExtension(), + stripedLinesExtension(), + formatPreviewExtension(formatPreview), updateListener, calcpadEditorTheme, ], @@ -101,6 +109,7 @@ export function CalcEditor({ }) viewRef.current = view + onViewReady?.(view) // Trigger initial evaluation const doc = view.state.doc.toString() @@ -110,23 +119,33 @@ export function CalcEditor({ if (timerRef.current) clearTimeout(timerRef.current) view.destroy() viewRef.current = null + onViewReady?.(null) } // initialDoc intentionally excluded — we only set it once on mount // eslint-disable-next-line react-hooks/exhaustive-deps }, [scheduleEval]) - // Push engine results into the answer gutter + error display + // Toggle format preview mode + useEffect(() => { + const view = viewRef.current + if (!view) return + view.dispatch({ + effects: formatPreviewCompartment.reconfigure( + formatPreview ? formatPreviewEnabled : [], + ), + }) + }, [formatPreview]) + + // Push engine results into the error display useEffect(() => { const view = viewRef.current if (!view || !results) return - const answers: LineAnswer[] = [] const errors: LineError[] = [] for (let i = 0; i < results.length; i++) { const lineNum = i + 1 const result = results[i] - answers.push({ line: lineNum, result }) if (result.type === 'error' && result.error) { // Map to document positions @@ -143,7 +162,6 @@ export function CalcEditor({ view.dispatch({ effects: [ - setAnswersEffect.of(answers), setErrorsEffect.of(errors), ], }) @@ -152,6 +170,24 @@ export function CalcEditor({ return
} +/** + * Syntax highlighting using CSS variables for theme integration. + */ +const calcpadHighlight = HighlightStyle.define([ + { tag: tags.number, color: 'var(--syntax-number)' }, + { tag: tags.operator, color: 'var(--syntax-operator)' }, + { tag: tags.variableName, color: 'var(--syntax-variable)' }, + { tag: tags.function(tags.variableName), color: 'var(--syntax-function)' }, + { tag: tags.keyword, color: 'var(--syntax-keyword)' }, + { tag: tags.lineComment, color: 'var(--syntax-comment)', fontStyle: 'italic' }, + { tag: tags.heading, color: 'var(--syntax-heading)', fontWeight: '700' }, + { tag: tags.definitionOperator, color: 'var(--syntax-operator)' }, + { tag: tags.special(tags.variableName), color: 'var(--syntax-function)' }, + { tag: tags.constant(tags.variableName), color: 'var(--syntax-number)', fontWeight: '600' }, + { tag: tags.paren, color: 'var(--syntax-operator)' }, + { tag: tags.punctuation, color: 'var(--syntax-operator)' }, +]) + /** * Base theme for the CalcPad editor. */ @@ -159,64 +195,44 @@ const calcpadEditorTheme = EditorView.baseTheme({ '&': { height: '100%', fontSize: '15px', - fontFamily: 'ui-monospace, Consolas, "Courier New", monospace', + fontFamily: 'var(--mono, ui-monospace, Consolas, "Courier New", monospace)', }, '.cm-scroller': { overflow: 'auto', }, '.cm-content': { - padding: '12px 0', + padding: '8px 0', minHeight: '100%', }, '.cm-line': { - padding: '0 16px', + padding: '0 12px', lineHeight: '1.6', + position: 'relative', + textAlign: 'var(--cm-text-align, left)', }, '.cm-gutters': { backgroundColor: 'transparent', borderRight: 'none', }, '.cm-lineNumbers .cm-gutterElement': { - padding: '0 8px 0 16px', - color: '#9ca3af', + padding: '0 6px 0 12px', + color: 'var(--text, #9ca3af)', + opacity: '0.4', fontSize: '13px', minWidth: '32px', }, - '.cm-answer-gutter': { - minWidth: '140px', - textAlign: 'right', - paddingRight: '16px', - borderLeft: '1px solid #e5e4e7', - backgroundColor: '#f8f9fa', - fontFamily: 'ui-monospace, Consolas, monospace', - fontSize: '14px', - }, - '&dark .cm-answer-gutter': { - borderLeft: '1px solid #2e303a', - backgroundColor: '#1a1b23', - }, - '.cm-answer-value': { - color: '#6366f1', + '.cm-activeLineGutter .cm-gutterElement': { + opacity: '1', fontWeight: '600', }, - '&dark .cm-answer-value': { - color: '#818cf8', - }, - '.cm-answer-error': { - color: '#e53e3e', - fontStyle: 'italic', - }, - '&dark .cm-answer-error': { - color: '#fc8181', + '.cm-stripe': { + backgroundColor: 'var(--stripe, rgba(0, 0, 0, 0.02))', }, '.cm-activeLine': { - backgroundColor: 'rgba(99, 102, 241, 0.04)', - }, - '&dark .cm-activeLine': { - backgroundColor: 'rgba(129, 140, 248, 0.06)', + backgroundColor: 'var(--accent-bg, rgba(99, 102, 241, 0.04))', }, '.cm-selectionBackground': { - backgroundColor: 'rgba(99, 102, 241, 0.15) !important', + backgroundColor: 'var(--accent-bg, rgba(99, 102, 241, 0.15)) !important', }, '.cm-focused': { outline: 'none', diff --git a/calcpad-web/src/editor/answer-gutter.ts b/calcpad-web/src/editor/answer-gutter.ts deleted file mode 100644 index 2f7a61d..0000000 --- a/calcpad-web/src/editor/answer-gutter.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Custom gutter for displaying computed results alongside each line. - * Adapted from epic/9-2-codemirror-6-editor. - * - * The answer column is right-aligned and visually distinct from the input. - */ - -import { GutterMarker, gutter } from '@codemirror/view' -import { StateField, StateEffect, type Extension } from '@codemirror/state' -import type { EngineLineResult } from '../engine/types.ts' - -// --- State Effects --- - -export interface LineAnswer { - line: number // 1-indexed line number - result: EngineLineResult | null -} - -export const setAnswersEffect = StateEffect.define() - -// --- Gutter Markers --- - -class AnswerMarker extends GutterMarker { - constructor( - readonly text: string, - readonly isError: boolean, - ) { - super() - } - - override toDOM(): HTMLElement { - const span = document.createElement('span') - span.className = this.isError ? 'cm-answer-error' : 'cm-answer-value' - span.textContent = this.text - return span - } - - override eq(other: GutterMarker): boolean { - return ( - other instanceof AnswerMarker && - other.text === this.text && - other.isError === this.isError - ) - } -} - -// --- State Field --- - -export const answersField = StateField.define>({ - create() { - return new Map() - }, - - update(answers, tr) { - for (const effect of tr.effects) { - if (effect.is(setAnswersEffect)) { - const newAnswers = new Map() - for (const { line, result } of effect.value) { - newAnswers.set(line, result) - } - return newAnswers - } - } - return answers - }, -}) - -// --- Gutter Extension --- - -const DISPLAYABLE_TYPES = new Set(['number', 'unitValue', 'currencyValue', 'dateTime', 'timeDelta', 'boolean']) - -export const answerGutter = gutter({ - class: 'cm-answer-gutter', - lineMarker(view, line) { - const doc = view.state.doc - const lineNumber = doc.lineAt(line.from).number - const answers = view.state.field(answersField) - const result = answers.get(lineNumber) - - if (!result) return null - - if (result.type === 'error') { - return new AnswerMarker('Error', true) - } - - if (DISPLAYABLE_TYPES.has(result.type) && result.display) { - return new AnswerMarker(result.display, false) - } - - return null - }, - lineMarkerChange(update) { - return update.transactions.some((tr) => - tr.effects.some((e) => e.is(setAnswersEffect)), - ) - }, -}) - -/** - * Creates the answer gutter extension bundle. - */ -export function answerGutterExtension(): Extension { - return [answersField, answerGutter] -} diff --git a/calcpad-web/src/editor/error-display.ts b/calcpad-web/src/editor/error-display.ts index b04755c..0cea049 100644 --- a/calcpad-web/src/editor/error-display.ts +++ b/calcpad-web/src/editor/error-display.ts @@ -11,6 +11,8 @@ import { GutterMarker, gutter, EditorView, + hoverTooltip, + type Tooltip, } from '@codemirror/view' import { StateField, StateEffect, type Extension, RangeSet } from '@codemirror/state' @@ -98,6 +100,48 @@ export const errorLinesField = StateField.define>({ }, }) +// --- Error Messages (for tooltips) --- + +export const errorMessagesField = StateField.define>({ + create() { + return new Map() + }, + update(msgs, tr) { + for (const effect of tr.effects) { + if (effect.is(setErrorsEffect)) { + const newMsgs = new Map() + for (const error of effect.value) { + const lineNumber = tr.state.doc.lineAt(error.from).number + newMsgs.set(lineNumber, error.message) + } + return newMsgs + } + } + return msgs + }, +}) + +// --- Error Tooltip (hover) --- + +const errorTooltip = hoverTooltip((view, pos) => { + const line = view.state.doc.lineAt(pos) + const errorMessages = view.state.field(errorMessagesField) + const msg = errorMessages.get(line.number) + if (!msg) return null + + return { + pos: line.from, + end: line.to, + above: false, + create() { + const dom = document.createElement('div') + dom.className = 'cm-error-tooltip' + dom.textContent = msg + return { dom } + }, + } satisfies Tooltip +}) + // --- Error Gutter --- export const errorGutter = gutter({ @@ -118,15 +162,32 @@ export const errorGutter = gutter({ export const errorBaseTheme = EditorView.baseTheme({ '.cm-error-underline': { - textDecoration: 'underline wavy red', + textDecoration: 'underline wavy var(--error, red)', + textDecorationThickness: '1.5px', }, '.cm-error-marker': { - color: '#e53e3e', + color: 'var(--error, #e53e3e)', fontSize: '14px', + cursor: 'pointer', }, '.cm-error-gutter': { width: '20px', }, + '.cm-error-tooltip': { + backgroundColor: 'var(--bg-secondary, #f8f9fa)', + color: 'var(--error, #e53e3e)', + border: '1px solid var(--border, #e5e4e7)', + borderRadius: '4px', + padding: '4px 8px', + fontSize: '12px', + fontFamily: 'var(--sans, system-ui)', + maxWidth: '300px', + boxShadow: '0 2px 8px rgba(0,0,0,0.15)', + }, + '.cm-tooltip': { + border: 'none', + backgroundColor: 'transparent', + }, }) /** @@ -136,7 +197,9 @@ export function errorDisplayExtension(): Extension { return [ errorDecorationsField, errorLinesField, + errorMessagesField, errorGutter, + errorTooltip, errorBaseTheme, ] } diff --git a/calcpad-web/src/editor/format-preview.ts b/calcpad-web/src/editor/format-preview.ts new file mode 100644 index 0000000..eb9799e --- /dev/null +++ b/calcpad-web/src/editor/format-preview.ts @@ -0,0 +1,202 @@ +/** + * Live Preview extension for CodeMirror. + * + * When enabled, hides markdown syntax markers and applies visual formatting: + * - `# text` → heading (bold, larger) + * - `**text**` → bold + * - `*text*` → italic + * - `// text` → comment (dimmed, italic) + * + * The active line always shows raw markdown for editing. + * Toggle with the compartment to switch between raw and preview modes. + */ + +import { + EditorView, + Decoration, + ViewPlugin, + type DecorationSet, + type ViewUpdate, + WidgetType, +} from '@codemirror/view' +import { Compartment, type Extension } from '@codemirror/state' + +// Invisible widget to replace hidden syntax markers +class HiddenMarker extends WidgetType { + toDOM() { + const span = document.createElement('span') + span.style.display = 'none' + return span + } +} + +const hiddenWidget = Decoration.replace({ widget: new HiddenMarker() }) + +const headingMark = Decoration.mark({ class: 'cm-fmt-heading' }) +const boldMark = Decoration.mark({ class: 'cm-fmt-bold' }) +const italicMark = Decoration.mark({ class: 'cm-fmt-italic' }) +const commentMark = Decoration.mark({ class: 'cm-fmt-comment' }) + +function buildDecorations(view: EditorView): DecorationSet { + const decorations: { from: number; to: number; dec: Decoration }[] = [] + const doc = view.state.doc + const activeLine = doc.lineAt(view.state.selection.main.head).number + + for (let i = 1; i <= doc.lines; i++) { + const line = doc.line(i) + const text = line.text + const isActive = i === activeLine + + // Headings: # text + const headingMatch = text.match(/^(#{1,6})\s/) + if (headingMatch) { + if (!isActive) { + // Hide the # prefix + decorations.push({ + from: line.from, + to: line.from + headingMatch[0].length, + dec: hiddenWidget, + }) + } + // Style the rest as heading + decorations.push({ + from: line.from + headingMatch[0].length, + to: line.to, + dec: headingMark, + }) + continue + } + + // Comments: // text + if (text.trimStart().startsWith('//')) { + const offset = text.indexOf('//') + if (!isActive) { + // Hide the // prefix + decorations.push({ + from: line.from + offset, + to: line.from + offset + 2 + (text[offset + 2] === ' ' ? 1 : 0), + dec: hiddenWidget, + }) + } + decorations.push({ + from: line.from, + to: line.to, + dec: commentMark, + }) + continue + } + + // Bold: **text** (only on non-active lines) + if (!isActive) { + const boldRegex = /\*\*(.+?)\*\*/g + let match + while ((match = boldRegex.exec(text)) !== null) { + const start = line.from + match.index + // Hide opening ** + decorations.push({ from: start, to: start + 2, dec: hiddenWidget }) + // Bold the content + decorations.push({ from: start + 2, to: start + 2 + match[1].length, dec: boldMark }) + // Hide closing ** + decorations.push({ from: start + 2 + match[1].length, to: start + match[0].length, dec: hiddenWidget }) + } + } + + // Italic: *text* (only on non-active lines, avoid **bold**) + if (!isActive) { + const italicRegex = /(? a.from - b.from || a.to - b.to) + + return Decoration.set(decorations.map(d => d.dec.range(d.from, d.to))) +} + +const formatPreviewPlugin = ViewPlugin.fromClass( + class { + decorations: DecorationSet + constructor(view: EditorView) { + this.decorations = buildDecorations(view) + } + update(update: ViewUpdate) { + if (update.docChanged || update.selectionSet || update.viewportChanged) { + this.decorations = buildDecorations(update.view) + } + } + }, + { decorations: (v) => v.decorations }, +) + +const formatPreviewTheme = EditorView.baseTheme({ + '.cm-fmt-heading': { + fontWeight: '700', + fontSize: '1.15em', + color: 'var(--text-h)', + }, + '.cm-fmt-bold': { + fontWeight: '700', + }, + '.cm-fmt-italic': { + fontStyle: 'italic', + }, + '.cm-fmt-comment': { + fontStyle: 'italic', + opacity: '0.5', + }, + '.cm-fmt-color-red': { color: '#ef4444' }, + '.cm-fmt-color-orange': { color: '#f97316' }, + '.cm-fmt-color-yellow': { color: '#eab308' }, + '.cm-fmt-color-green': { color: '#22c55e' }, + '.cm-fmt-color-blue': { color: '#3b82f6' }, + '.cm-fmt-color-purple': { color: '#a855f7' }, +}) + +// Empty extension for "raw" mode +const noopExtension: Extension = [] + +export const formatPreviewCompartment = new Compartment() + +/** The extensions to use when preview is enabled */ +export const formatPreviewEnabled = [formatPreviewPlugin, formatPreviewTheme] + +export function formatPreviewExtension(enabled: boolean): Extension { + return formatPreviewCompartment.of( + enabled ? formatPreviewEnabled : noopExtension, + ) +} diff --git a/calcpad-web/src/editor/inline-results.ts b/calcpad-web/src/editor/inline-results.ts new file mode 100644 index 0000000..79f3686 --- /dev/null +++ b/calcpad-web/src/editor/inline-results.ts @@ -0,0 +1,54 @@ +/** + * CodeMirror 6 extension for zebra-striped editor lines. + * + * Previously also contained inline result widgets, but results + * are now rendered in a separate ResultsPanel component. + */ + +import { Decoration, EditorView, ViewPlugin } from '@codemirror/view' +import type { DecorationSet, ViewUpdate } from '@codemirror/view' +import type { Extension } from '@codemirror/state' + +/** + * ViewPlugin that applies alternating background colors (zebra striping) + * to even-numbered editor lines, helping users visually connect + * expressions on the left to their inline results on the right. + */ +export const stripedLines = ViewPlugin.fromClass( + class { + decorations: DecorationSet + + constructor(view: EditorView) { + this.decorations = buildStripeDecorations(view) + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged) { + this.decorations = buildStripeDecorations(update.view) + } + } + }, + { + decorations: (v) => v.decorations, + }, +) + +const stripeDeco = Decoration.line({ class: 'cm-stripe' }) + +function buildStripeDecorations(view: EditorView): DecorationSet { + const decorations: Array> = [] + for (let i = 1; i <= view.state.doc.lines; i++) { + if (i % 2 === 0) { + const line = view.state.doc.line(i) + decorations.push(stripeDeco.range(line.from)) + } + } + return Decoration.set(decorations) +} + +/** + * Creates the striped-lines extension for the editor. + */ +export function stripedLinesExtension(): Extension { + return [stripedLines] +} diff --git a/calcpad-web/src/engine/worker.ts b/calcpad-web/src/engine/worker.ts index 427c7fe..94bfe12 100644 --- a/calcpad-web/src/engine/worker.ts +++ b/calcpad-web/src/engine/worker.ts @@ -78,7 +78,8 @@ function fallbackEvalSheet(lines: string[]): EngineLineResult[] { async function initWasm(): Promise { try { // Try to load the wasm-pack output - const wasmModule = await import(/* @vite-ignore */ '/wasm/calcpad_wasm.js') as { + const wasmPath = '/wasm/calcpad_wasm.js' + const wasmModule = await import(/* @vite-ignore */ wasmPath) as { default: () => Promise evalSheet: (lines: string[]) => EngineLineResult[] } diff --git a/calcpad-web/src/hooks/useDocumentStore.ts b/calcpad-web/src/hooks/useDocumentStore.ts new file mode 100644 index 0000000..3907fbd --- /dev/null +++ b/calcpad-web/src/hooks/useDocumentStore.ts @@ -0,0 +1,357 @@ +import { useState, useCallback, useRef } from 'react' + +const STORAGE_KEY = 'calctext-documents' +const FOLDERS_KEY = 'calctext-folders' +const TABS_KEY = 'calctext-tabs' +const ACTIVE_KEY = 'calctext-active-tab' +const SIDEBAR_KEY = 'calctext-sidebar' + +export interface CalcDocument { + id: string + title: string + content: string + folderId: string | null + isFavorite: boolean + createdAt: string + updatedAt: string +} + +export interface CalcFolder { + id: string + name: string + parentId: string | null + order: number +} + +interface StoreState { + documents: CalcDocument[] + folders: CalcFolder[] + openTabIds: string[] + activeTabId: string +} + +const WELCOME_DOC: CalcDocument = { + id: 'welcome', + title: 'Welcome', + content: `# CalcText + +// Basic arithmetic +2 + 3 +10 * 4.5 +100 / 7 + +// Variables +price = 49.99 +quantity = 3 +subtotal = price * quantity + +// Percentages +tax = subtotal * 8% +total = subtotal + tax + +// Functions +sqrt(144) +2 ^ 10 +`, + folderId: null, + isFavorite: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +} + +function generateId(): string { + return Date.now().toString(36) + Math.random().toString(36).slice(2, 8) +} + +function loadState(): StoreState { + try { + const docsJson = localStorage.getItem(STORAGE_KEY) + const foldersJson = localStorage.getItem(FOLDERS_KEY) + const tabsJson = localStorage.getItem(TABS_KEY) + const activeId = localStorage.getItem(ACTIVE_KEY) + + const documents: CalcDocument[] = docsJson ? JSON.parse(docsJson) : [WELCOME_DOC] + const folders: CalcFolder[] = foldersJson ? JSON.parse(foldersJson) : [] + const openTabIds: string[] = tabsJson ? JSON.parse(tabsJson) : [documents[0]?.id ?? 'welcome'] + const activeTabId = activeId && openTabIds.includes(activeId) ? activeId : openTabIds[0] ?? '' + + if (documents.length === 0) documents.push(WELCOME_DOC) + if (openTabIds.length === 0) openTabIds.push(documents[0].id) + + return { documents, folders, openTabIds, activeTabId } + } catch { + return { documents: [WELCOME_DOC], folders: [], openTabIds: ['welcome'], activeTabId: 'welcome' } + } +} + +function saveState(state: StoreState) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state.documents)) + localStorage.setItem(FOLDERS_KEY, JSON.stringify(state.folders)) + localStorage.setItem(TABS_KEY, JSON.stringify(state.openTabIds)) + localStorage.setItem(ACTIVE_KEY, state.activeTabId) + } catch { /* */ } +} + +export function loadSidebarState(): { visible: boolean; width: number } { + try { + const json = localStorage.getItem(SIDEBAR_KEY) + if (json) return JSON.parse(json) + } catch { /* */ } + return { visible: true, width: 240 } +} + +export function saveSidebarState(s: { visible: boolean; width: number }) { + try { localStorage.setItem(SIDEBAR_KEY, JSON.stringify(s)) } catch { /* */ } +} + +export function useDocumentStore() { + const [state, setState] = useState(loadState) + const saveTimerRef = useRef | null>(null) + // Use a ref to always have latest state for return values + const stateRef = useRef(state) + stateRef.current = state + + const persist = useCallback((next: StoreState) => { + setState(next) + saveState(next) + }, []) + + const persistDebounced = useCallback((next: StoreState) => { + setState(next) + if (saveTimerRef.current) clearTimeout(saveTimerRef.current) + saveTimerRef.current = setTimeout(() => saveState(next), 300) + }, []) + + // Use functional updates to avoid stale closures + + const setActiveTab = useCallback((id: string) => { + setState(prev => { + const next = { ...prev, activeTabId: id } + saveState(next) + return next + }) + }, []) + + const createDocument = useCallback((title?: string, content?: string): CalcDocument => { + const id = generateId() + const now = new Date().toISOString() + // Read current state to count untitled + const cur = stateRef.current + const existingUntitled = cur.documents.filter(d => d.title.startsWith('Untitled')).length + const newDoc: CalcDocument = { + id, + title: title ?? (existingUntitled === 0 ? 'Untitled' : `Untitled ${existingUntitled + 1}`), + content: content ?? '', + folderId: null, + isFavorite: false, + createdAt: now, + updatedAt: now, + } + setState(prev => { + const next: StoreState = { + ...prev, + documents: [...prev.documents, newDoc], + openTabIds: [...prev.openTabIds, id], + activeTabId: id, + } + saveState(next) + return next + }) + return newDoc + }, []) + + const updateContent = useCallback((id: string, content: string) => { + setState(prev => { + const next: StoreState = { + ...prev, + documents: prev.documents.map(d => + d.id === id ? { ...d, content, updatedAt: new Date().toISOString() } : d + ), + } + // debounced save + if (saveTimerRef.current) clearTimeout(saveTimerRef.current) + saveTimerRef.current = setTimeout(() => saveState(next), 300) + return next + }) + }, []) + + const renameDocument = useCallback((id: string, title: string) => { + setState(prev => { + const next: StoreState = { + ...prev, + documents: prev.documents.map(d => + d.id === id ? { ...d, title: title.trim() || 'Untitled', updatedAt: new Date().toISOString() } : d + ), + } + saveState(next) + return next + }) + }, []) + + const closeTab = useCallback((id: string) => { + setState(prev => { + let nextTabs = prev.openTabIds.filter(tid => tid !== id) + let nextActive = prev.activeTabId + + if (nextTabs.length === 0) { + const newId = generateId() + const newDoc: CalcDocument = { + id: newId, title: 'Untitled', content: '', + folderId: null, isFavorite: false, + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + } + const next: StoreState = { + ...prev, + documents: [...prev.documents, newDoc], + openTabIds: [newId], + activeTabId: newId, + } + saveState(next) + return next + } + + if (nextActive === id) { + const closedIdx = prev.openTabIds.indexOf(id) + nextActive = nextTabs[Math.min(closedIdx, nextTabs.length - 1)] + } + + const next = { ...prev, openTabIds: nextTabs, activeTabId: nextActive } + saveState(next) + return next + }) + }, []) + + const deleteDocument = useCallback((id: string) => { + setState(prev => { + const nextDocs = prev.documents.filter(d => d.id !== id) + let nextTabs = prev.openTabIds.filter(tid => tid !== id) + let nextActive = prev.activeTabId + + if (nextDocs.length === 0) { + const wd = { ...WELCOME_DOC, id: generateId() } + nextDocs.push(wd) + } + if (nextTabs.length === 0) nextTabs = [nextDocs[0].id] + if (!nextTabs.includes(nextActive)) nextActive = nextTabs[0] + + const next: StoreState = { ...prev, documents: nextDocs, openTabIds: nextTabs, activeTabId: nextActive } + saveState(next) + return next + }) + }, []) + + const reorderTabs = useCallback((fromIndex: number, toIndex: number) => { + setState(prev => { + const tabs = [...prev.openTabIds] + const [moved] = tabs.splice(fromIndex, 1) + tabs.splice(toIndex, 0, moved) + const next = { ...prev, openTabIds: tabs } + saveState(next) + return next + }) + }, []) + + const openDocument = useCallback((id: string) => { + setState(prev => { + const next = prev.openTabIds.includes(id) + ? { ...prev, activeTabId: id } + : { ...prev, openTabIds: [...prev.openTabIds, id], activeTabId: id } + saveState(next) + return next + }) + }, []) + + const toggleFavorite = useCallback((id: string) => { + setState(prev => { + const next: StoreState = { + ...prev, + documents: prev.documents.map(d => + d.id === id ? { ...d, isFavorite: !d.isFavorite } : d + ), + } + saveState(next) + return next + }) + }, []) + + const moveToFolder = useCallback((docId: string, folderId: string | null) => { + setState(prev => { + const next: StoreState = { + ...prev, + documents: prev.documents.map(d => + d.id === docId ? { ...d, folderId } : d + ), + } + saveState(next) + return next + }) + }, []) + + const createFolder = useCallback((name?: string, parentId?: string | null): CalcFolder => { + const folder: CalcFolder = { + id: generateId(), + name: name ?? 'New Folder', + parentId: parentId ?? null, + order: stateRef.current.folders.length, + } + setState(prev => { + const next = { ...prev, folders: [...prev.folders, folder] } + saveState(next) + return next + }) + return folder + }, []) + + const renameFolder = useCallback((id: string, name: string) => { + setState(prev => { + const next: StoreState = { + ...prev, + folders: prev.folders.map(f => + f.id === id ? { ...f, name: name.trim() || 'Folder' } : f + ), + } + saveState(next) + return next + }) + }, []) + + const deleteFolder = useCallback((id: string) => { + setState(prev => { + const next: StoreState = { + ...prev, + documents: prev.documents.map(d => d.folderId === id ? { ...d, folderId: null } : d), + folders: prev.folders.filter(f => f.id !== id && f.parentId !== id), + } + saveState(next) + return next + }) + }, []) + + const activeDoc = state.documents.find(d => d.id === state.activeTabId) ?? state.documents[0] + const openDocs = state.openTabIds + .map(id => state.documents.find(d => d.id === id)) + .filter((d): d is CalcDocument => d != null) + + return { + documents: state.documents, + folders: state.folders, + openDocs, + openTabIds: state.openTabIds, + activeTabId: state.activeTabId, + activeDoc, + setActiveTab, + createDocument, + updateContent, + renameDocument, + closeTab, + deleteDocument, + reorderTabs, + openDocument, + toggleFavorite, + moveToFolder, + createFolder, + renameFolder, + deleteFolder, + } +} diff --git a/calcpad-web/src/hooks/useTheme.ts b/calcpad-web/src/hooks/useTheme.ts new file mode 100644 index 0000000..3c05cba --- /dev/null +++ b/calcpad-web/src/hooks/useTheme.ts @@ -0,0 +1,135 @@ +const STORAGE_KEY = 'calctext-theme' +const ACCENT_KEY = 'calctext-accent' + +export type ThemeId = 'system' | 'light' | 'dark' | 'matrix' | 'midnight' | 'warm' + +export const THEMES: { id: ThemeId; name: string; icon: string }[] = [ + { id: 'light', name: 'Light', icon: '☀️' }, + { id: 'dark', name: 'Dark', icon: '🌙' }, + { id: 'matrix', name: 'Matrix', icon: '💻' }, + { id: 'midnight', name: 'Midnight', icon: '🌊' }, + { id: 'warm', name: 'Warm', icon: '📜' }, +] + +export const ACCENT_COLORS = [ + { name: 'Indigo', light: '#6366f1', dark: '#818cf8' }, + { name: 'Teal', light: '#14b8a6', dark: '#2dd4bf' }, + { name: 'Rose', light: '#f43f5e', dark: '#fb7185' }, + { name: 'Amber', light: '#f59e0b', dark: '#fbbf24' }, + { name: 'Emerald', light: '#10b981', dark: '#34d399' }, + { name: 'Sky', light: '#0ea5e9', dark: '#38bdf8' }, +] + +function getStoredTheme(): ThemeId { + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored && ['system', 'light', 'dark', 'matrix', 'midnight', 'warm'].includes(stored)) { + return stored as ThemeId + } + } catch { /* localStorage unavailable */ } + return 'system' +} + +function getStoredAccent(): string | null { + try { + return localStorage.getItem(ACCENT_KEY) + } catch { return null } +} + +function resolveSystemTheme(): 'light' | 'dark' { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' +} + +function isDarkTheme(theme: ThemeId): boolean { + if (theme === 'system') return resolveSystemTheme() === 'dark' + return theme === 'dark' || theme === 'matrix' || theme === 'midnight' +} + +import { useState, useEffect, useCallback } from 'react' + +export function useTheme() { + const [theme, setThemeState] = useState(getStoredTheme) + const [accentColor, setAccentState] = useState(getStoredAccent) + + const resolvedTheme = theme === 'system' ? resolveSystemTheme() : theme + + const setTheme = useCallback((id: ThemeId) => { + setThemeState(id) + try { localStorage.setItem(STORAGE_KEY, id) } catch { /* */ } + applyTheme(id, accentColor) + }, [accentColor]) + + const setAccent = useCallback((color: string | null) => { + setAccentState(color) + try { + if (color) localStorage.setItem(ACCENT_KEY, color) + else localStorage.removeItem(ACCENT_KEY) + } catch { /* */ } + applyTheme(theme, color) + }, [theme]) + + // Listen for system theme changes + useEffect(() => { + if (theme !== 'system') return + const mq = window.matchMedia('(prefers-color-scheme: dark)') + const handler = () => applyTheme('system', accentColor) + mq.addEventListener('change', handler) + return () => mq.removeEventListener('change', handler) + }, [theme, accentColor]) + + // Apply on mount + useEffect(() => { + applyTheme(theme, accentColor) + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + return { + theme, + resolvedTheme, + setTheme, + accentColor, + setAccent, + isDark: isDarkTheme(theme), + themes: THEMES, + accentColors: ACCENT_COLORS, + } +} + +function applyTheme(theme: ThemeId, accent: string | null) { + const resolved = theme === 'system' + ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') + : theme + + document.documentElement.setAttribute('data-theme', resolved) + + // Apply custom accent if set + if (accent) { + const dark = isDarkTheme(theme) + document.documentElement.style.setProperty('--accent', accent) + document.documentElement.style.setProperty('--accent-bg', hexToRgba(accent, dark ? 0.15 : 0.1)) + document.documentElement.style.setProperty('--accent-border', hexToRgba(accent, 0.5)) + } else { + document.documentElement.style.removeProperty('--accent') + document.documentElement.style.removeProperty('--accent-bg') + document.documentElement.style.removeProperty('--accent-border') + } + + // Update PWA theme-color + const meta = document.querySelector('meta[name="theme-color"]') + if (meta) { + const bgColors: Record = { + light: '#ffffff', + dark: '#16171d', + matrix: '#0a0a0a', + midnight: '#0f172a', + warm: '#fffbf5', + } + meta.setAttribute('content', bgColors[resolved] ?? '#ffffff') + } +} + +function hexToRgba(hex: string, alpha: number): string { + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + return `rgba(${r}, ${g}, ${b}, ${alpha})` +} diff --git a/calcpad-web/src/styles/align-toolbar.css b/calcpad-web/src/styles/align-toolbar.css new file mode 100644 index 0000000..1200a37 --- /dev/null +++ b/calcpad-web/src/styles/align-toolbar.css @@ -0,0 +1,53 @@ +/* ---------- Align Toolbar (inline in header) ---------- */ + +.align-toolbar { + display: flex; + align-items: center; + gap: 8px; +} + +.align-group { + display: flex; + align-items: center; + gap: 2px; +} + +.align-label { + font-size: 10px; + color: var(--text); + opacity: 0.5; + margin-right: 2px; + user-select: none; +} + +.align-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 20px; + padding: 0; + border: 1px solid transparent; + border-radius: 3px; + background: transparent; + color: var(--text); + cursor: pointer; + transition: background 0.1s, border-color 0.1s, color 0.1s; +} + +.align-btn:hover { + background: var(--accent-bg); + border-color: var(--border); +} + +.align-btn.active { + background: var(--accent-bg); + border-color: var(--accent-border); + color: var(--accent); +} + +@media (max-width: 768px) { + .align-toolbar { + display: none; + } +} diff --git a/calcpad-web/src/styles/answer-column.css b/calcpad-web/src/styles/answer-column.css deleted file mode 100644 index fe49613..0000000 --- a/calcpad-web/src/styles/answer-column.css +++ /dev/null @@ -1,47 +0,0 @@ -/* ---------- Answer column (standalone panel mode) ---------- */ - -.answer-column { - width: 220px; - padding: 12px 0; - border-left: 1px solid var(--border); - background: var(--bg-secondary); - overflow-y: auto; - flex-shrink: 0; -} - -.answer-line { - padding: 0 16px; - font-family: var(--mono); - font-size: 14px; - line-height: 1.6; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - /* Match CodeMirror's line height for alignment */ - height: 24px; -} - -.answer-value { - color: var(--accent); - font-weight: 600; - text-align: right; -} - -.answer-error { - color: var(--error); - font-style: italic; - text-align: right; -} - -.answer-empty { - color: transparent; -} - -@media (max-width: 640px) { - .answer-column { - width: 100%; - max-height: 120px; - border-left: none; - border-top: 1px solid var(--border); - } -} diff --git a/calcpad-web/src/styles/app.css b/calcpad-web/src/styles/app.css index e8a0490..fa046b7 100644 --- a/calcpad-web/src/styles/app.css +++ b/calcpad-web/src/styles/app.css @@ -13,50 +13,67 @@ display: flex; align-items: center; gap: 12px; - padding: 12px 24px; + padding: 6px 12px; border-bottom: 1px solid var(--border); flex-shrink: 0; + height: 40px; } .calcpad-header h1 { margin: 0; - font-size: 20px; + font-size: 14px; + font-weight: 600; letter-spacing: -0.3px; } -.calcpad-header .subtitle { - margin: 0; - font-size: 13px; - color: var(--text); +.header-spacer { + flex: 1; } -.header-status { +.header-actions { margin-left: auto; display: flex; align-items: center; - gap: 8px; - font-size: 12px; + gap: 6px; +} + +.header-divider { + width: 1px; + height: 16px; + background: var(--border); +} + +/* ---------- Sidebar toggle ---------- */ + +.header-sidebar-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 4px; + background: transparent; color: var(--text); + font-size: 16px; + cursor: pointer; + flex-shrink: 0; + transition: background 0.1s; } -.status-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--success); +.header-sidebar-toggle:hover { + background: var(--accent-bg); } -.status-dot.loading { - background: var(--warning); - animation: pulse 1.5s ease-in-out infinite; +/* ---------- Workspace (sidebar + editor area) ---------- */ + +.calcpad-workspace { + flex: 1; + display: flex; + overflow: hidden; } -@keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.3; } -} - -/* ---------- Editor area ---------- */ +/* ---------- Editor area (two-column layout) ---------- */ .calcpad-editor { flex: 1; @@ -65,7 +82,7 @@ } .editor-pane { - flex: 1; + flex: 3; min-width: 0; overflow: hidden; } @@ -79,14 +96,87 @@ height: 100%; } -/* ---------- Responsive ---------- */ +.pane-divider { + width: 5px; + background: var(--border); + flex-shrink: 0; + cursor: col-resize; + transition: background 0.15s; + position: relative; +} -@media (max-width: 640px) { - .calcpad-header { - padding: 10px 16px; +.pane-divider:hover { + background: var(--accent); +} + +/* Wider invisible hit area */ +.pane-divider::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: -4px; + right: -4px; +} + +.results-panel { + flex: 1; + min-width: 120px; +} + +/* ---------- Responsive: Mobile (< 768px) ---------- */ + +@media (max-width: 767px) { + .calcpad-app { + height: 100dvh; } - .calcpad-header .subtitle { + .calcpad-header { + height: 44px; + padding: 6px 8px; + gap: 8px; + } + + .header-sidebar-toggle { + width: 36px; + height: 36px; + font-size: 18px; + } + + .header-divider { + display: none; + } + + .pane-divider { + display: none; + } + + /* Editor goes full width, results panel hidden (tray replaces it) */ + .calcpad-editor { + flex-direction: column; + } + + .editor-pane { + flex: 1 !important; + width: 100% !important; + } + + .results-panel { display: none; } } + +/* Safe areas for notched devices */ +@supports (padding: env(safe-area-inset-top)) { + .calcpad-app { + padding-top: env(safe-area-inset-top); + padding-bottom: env(safe-area-inset-bottom); + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); + } +} + +/* Prevent pull-to-refresh in PWA */ +.calcpad-app { + overscroll-behavior: none; +} diff --git a/calcpad-web/src/styles/format-toolbar.css b/calcpad-web/src/styles/format-toolbar.css new file mode 100644 index 0000000..3d73535 --- /dev/null +++ b/calcpad-web/src/styles/format-toolbar.css @@ -0,0 +1,86 @@ +/* ---------- Format Toolbar ---------- */ + +.format-toolbar { + display: flex; + align-items: center; + gap: 6px; +} + +.format-group { + display: flex; + align-items: center; + gap: 2px; +} + +.format-separator { + width: 1px; + height: 16px; + background: var(--border); +} + +.format-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 22px; + padding: 0; + border: 1px solid transparent; + border-radius: 3px; + background: transparent; + color: var(--text); + font-size: 12px; + font-family: var(--sans); + cursor: pointer; + transition: background 0.1s, border-color 0.1s, color 0.1s; +} + +.format-btn:hover { + background: var(--accent-bg); + border-color: var(--border); + color: var(--text-h); +} + +.format-btn:active { + background: var(--accent-bg); + border-color: var(--accent-border); + color: var(--accent); +} + +.format-italic { + font-style: italic; + font-family: Georgia, serif; +} + +.format-preview-toggle.active { + background: var(--accent-bg); + border-color: var(--accent-border); + color: var(--accent); +} + +/* ---------- Color Buttons ---------- */ + +.format-colors { + gap: 3px; +} + +.format-color-btn { + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid transparent; + padding: 0; + cursor: pointer; + transition: transform 0.1s, border-color 0.1s; +} + +.format-color-btn:hover { + transform: scale(1.25); + border-color: var(--text-h); +} + +@media (max-width: 768px) { + .format-toolbar { + display: none; + } +} diff --git a/calcpad-web/src/styles/index.css b/calcpad-web/src/styles/index.css index 3c71baf..fbf8c4b 100644 --- a/calcpad-web/src/styles/index.css +++ b/calcpad-web/src/styles/index.css @@ -1,23 +1,16 @@ +/* ---------- Base & Font Setup ---------- */ + :root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --bg-secondary: #f8f9fa; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #6366f1; - --accent-bg: rgba(99, 102, 241, 0.1); - --accent-border: rgba(99, 102, 241, 0.5); + --sans: system-ui, 'Segoe UI', Roboto, sans-serif; + --heading: system-ui, 'Segoe UI', Roboto, sans-serif; + --mono: ui-monospace, Consolas, 'Courier New', monospace; + --warning: #f59e0b; --warning-bg: rgba(245, 158, 11, 0.1); --success: #10b981; --success-bg: rgba(16, 185, 129, 0.1); --error: #e53e3e; - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, 'Courier New', monospace; - font: 16px/1.5 var(--sans); color-scheme: light dark; color: var(--text); @@ -28,18 +21,180 @@ -moz-osx-font-smoothing: grayscale; } -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --bg-secondary: #1a1b23; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #818cf8; - --accent-bg: rgba(129, 140, 248, 0.15); - --accent-border: rgba(129, 140, 248, 0.5); - } +/* ---------- Theme: Light (default) ---------- */ + +:root, +[data-theme="light"] { + --text: #6b6375; + --text-h: #08060d; + --bg: #fff; + --bg-secondary: #f8f9fa; + --border: #e5e4e7; + --code-bg: #f4f3ec; + --accent: #6366f1; + --accent-bg: rgba(99, 102, 241, 0.1); + --accent-border: rgba(99, 102, 241, 0.5); + --stripe: rgba(0, 0, 0, 0.02); + + --syntax-variable: #4f46e5; + --syntax-number: #0d9488; + --syntax-operator: #6b6375; + --syntax-keyword: #7c3aed; + --syntax-function: #2563eb; + --syntax-currency: #d97706; + --syntax-comment: rgba(107, 99, 117, 0.5); + --syntax-heading: #08060d; + + --result-number: #374151; + --result-unit: #0d9488; + --result-currency: #d97706; + --result-datetime: #7c3aed; + --result-boolean: #6366f1; +} + +/* ---------- Theme: Dark ---------- */ + +[data-theme="dark"] { + --text: #9ca3af; + --text-h: #f3f4f6; + --bg: #16171d; + --bg-secondary: #1a1b23; + --border: #2e303a; + --code-bg: #1f2028; + --accent: #818cf8; + --accent-bg: rgba(129, 140, 248, 0.15); + --accent-border: rgba(129, 140, 248, 0.5); + --stripe: rgba(255, 255, 255, 0.025); + + --syntax-variable: #a5b4fc; + --syntax-number: #5eead4; + --syntax-operator: #9ca3af; + --syntax-keyword: #c4b5fd; + --syntax-function: #93c5fd; + --syntax-currency: #fcd34d; + --syntax-comment: rgba(156, 163, 175, 0.5); + --syntax-heading: #f3f4f6; + + --result-number: #d1d5db; + --result-unit: #5eead4; + --result-currency: #fcd34d; + --result-datetime: #c4b5fd; + --result-boolean: #818cf8; +} + +/* ---------- Theme: Matrix ---------- */ + +[data-theme="matrix"] { + --text: #00ff41; + --text-h: #33ff66; + --bg: #0a0a0a; + --bg-secondary: #0f1a0f; + --border: #003300; + --code-bg: #0a0f0a; + --accent: #00ff41; + --accent-bg: rgba(0, 255, 65, 0.1); + --accent-border: rgba(0, 255, 65, 0.4); + --stripe: rgba(0, 255, 65, 0.03); + + --syntax-variable: #00ff41; + --syntax-number: #00cc33; + --syntax-operator: #00ff41; + --syntax-keyword: #39ff14; + --syntax-function: #00ff41; + --syntax-currency: #ffff00; + --syntax-comment: rgba(0, 255, 65, 0.4); + --syntax-heading: #33ff66; + + --result-number: #00ff41; + --result-unit: #00cc33; + --result-currency: #ffff00; + --result-datetime: #39ff14; + --result-boolean: #00ff41; + + --mono: 'Courier New', 'Fira Code', monospace; + --success: #00ff41; + --error: #ff0000; +} + +/* Matrix special effects */ +[data-theme="matrix"] .calcpad-app::after { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(0, 0, 0, 0.06) 2px, + rgba(0, 0, 0, 0.06) 4px + ); + z-index: 9999; +} + +[data-theme="matrix"] .cm-cursor { + border-color: #00ff41 !important; + box-shadow: 0 0 4px #00ff41, 0 0 8px rgba(0, 255, 65, 0.3); +} + +/* ---------- Theme: Midnight ---------- */ + +[data-theme="midnight"] { + --text: #94a3b8; + --text-h: #e2e8f0; + --bg: #0f172a; + --bg-secondary: #1e293b; + --border: #334155; + --code-bg: #1e293b; + --accent: #38bdf8; + --accent-bg: rgba(56, 189, 248, 0.12); + --accent-border: rgba(56, 189, 248, 0.5); + --stripe: rgba(56, 189, 248, 0.03); + + --syntax-variable: #7dd3fc; + --syntax-number: #5eead4; + --syntax-operator: #94a3b8; + --syntax-keyword: #c4b5fd; + --syntax-function: #7dd3fc; + --syntax-currency: #fcd34d; + --syntax-comment: rgba(148, 163, 184, 0.5); + --syntax-heading: #e2e8f0; + + --result-number: #cbd5e1; + --result-unit: #5eead4; + --result-currency: #fcd34d; + --result-datetime: #c4b5fd; + --result-boolean: #38bdf8; +} + +/* ---------- Theme: Warm ---------- */ + +[data-theme="warm"] { + --text: #78716c; + --text-h: #1c1917; + --bg: #fffbf5; + --bg-secondary: #fef3e2; + --border: #e7e5e4; + --code-bg: #fef3e2; + --accent: #f97316; + --accent-bg: rgba(249, 115, 22, 0.1); + --accent-border: rgba(249, 115, 22, 0.5); + --stripe: rgba(249, 115, 22, 0.03); + + --syntax-variable: #c2410c; + --syntax-number: #0d9488; + --syntax-operator: #78716c; + --syntax-keyword: #7c3aed; + --syntax-function: #2563eb; + --syntax-currency: #d97706; + --syntax-comment: rgba(120, 113, 108, 0.5); + --syntax-heading: #1c1917; + + --result-number: #44403c; + --result-unit: #0d9488; + --result-currency: #d97706; + --result-datetime: #7c3aed; + --result-boolean: #f97316; } *, diff --git a/calcpad-web/src/styles/mobile-results-tray.css b/calcpad-web/src/styles/mobile-results-tray.css new file mode 100644 index 0000000..990963d --- /dev/null +++ b/calcpad-web/src/styles/mobile-results-tray.css @@ -0,0 +1,117 @@ +/* ---------- Mobile Results Tray ---------- */ +/* Only visible on mobile (< 768px) */ + +.mobile-results-tray { + display: none; +} + +@media (max-width: 767px) { + .mobile-results-tray { + display: flex; + flex-direction: column; + background: var(--bg-secondary); + border-top: 1px solid var(--border); + flex-shrink: 0; + transition: max-height 0.2s ease-out; + max-height: 48px; + overflow: hidden; + } + + .mobile-results-tray.expanded { + max-height: 40vh; + } + + /* ---------- Header / Collapsed ---------- */ + + .tray-header { + display: flex; + flex-direction: column; + align-items: center; + padding: 8px 16px; + min-height: 48px; + cursor: pointer; + user-select: none; + flex-shrink: 0; + } + + .tray-drag-handle { + width: 32px; + height: 4px; + border-radius: 2px; + background: var(--border); + margin-bottom: 6px; + } + + .tray-last-result { + font-size: 13px; + font-family: var(--mono); + color: var(--text); + } + + /* ---------- Expanded Content ---------- */ + + .tray-content { + flex: 1; + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + } + + .tray-result-item { + display: flex; + align-items: center; + gap: 8px; + height: 44px; + padding: 0 16px; + border-bottom: 1px solid var(--border); + cursor: pointer; + transition: background 0.1s; + } + + .tray-result-item:active { + background: var(--accent-bg); + } + + .tray-result-item.copied { + background: var(--success-bg); + } + + .tray-result-line { + font-size: 11px; + font-family: var(--mono); + color: var(--text); + opacity: 0.4; + width: 40px; + flex-shrink: 0; + } + + .tray-result-expr { + flex: 1; + font-size: 13px; + font-family: var(--mono); + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .tray-result-value { + font-size: 13px; + font-family: var(--mono); + color: var(--result-number, var(--text-h)); + font-weight: 500; + flex-shrink: 0; + max-width: 120px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .tray-empty { + padding: 16px; + text-align: center; + font-size: 13px; + color: var(--text); + opacity: 0.4; + } +} diff --git a/calcpad-web/src/styles/results-panel.css b/calcpad-web/src/styles/results-panel.css new file mode 100644 index 0000000..a1250f4 --- /dev/null +++ b/calcpad-web/src/styles/results-panel.css @@ -0,0 +1,104 @@ +/* ---------- Results Panel ---------- */ + +.results-panel { + overflow: hidden; + background: var(--bg-secondary); + padding: 8px 0; +} + +.result-line { + padding: 0 12px; + font-family: var(--mono); + font-size: 15px; + line-height: 1.6; + height: 24px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ---------- Type-Specific Colors ---------- */ + +.result-value { + font-weight: 400; + cursor: pointer; + transition: color 0.15s; +} + +.result-value:hover { + filter: brightness(1.2); +} + +.result-number { + color: var(--result-number, var(--text)); +} + +.result-unit { + color: var(--result-unit, #0d9488); +} + +.result-currency { + color: var(--result-currency, #d97706); +} + +.result-datetime { + color: var(--result-datetime, #7c3aed); +} + +.result-boolean { + color: var(--result-boolean, var(--accent)); +} + +/* ---------- Copy Feedback ---------- */ + +.result-value.copied { + color: var(--success) !important; + font-weight: 500; +} + +/* ---------- Error Hint ---------- */ + +.result-error-hint { + color: var(--text); + opacity: 0.25; + font-size: 13px; + font-family: var(--sans); +} + +/* ---------- Comment/Heading Marker ---------- */ + +.result-marker { + display: flex; + align-items: center; + justify-content: flex-end; +} + +.result-dash { + display: inline-block; + width: 60%; + height: 1px; + background: var(--border); + opacity: 0.4; + font-size: 0; + overflow: hidden; +} + +/* ---------- Empty ---------- */ + +.result-empty { + /* intentionally blank */ +} + +/* ---------- Stripes ---------- */ + +.result-stripe { + background: var(--stripe, rgba(0, 0, 0, 0.02)); +} + +/* ---------- Responsive ---------- */ + +@media (max-width: 768px) { + .results-panel { + display: none; + } +} diff --git a/calcpad-web/src/styles/sidebar.css b/calcpad-web/src/styles/sidebar.css new file mode 100644 index 0000000..842b26a --- /dev/null +++ b/calcpad-web/src/styles/sidebar.css @@ -0,0 +1,460 @@ +/* ---------- Sidebar ---------- */ + +.sidebar { + display: flex; + flex-direction: column; + background: var(--bg-secondary); + border-right: 1px solid var(--border); + flex-shrink: 0; + position: relative; + overflow: hidden; +} + +/* ---------- Search ---------- */ + +.sidebar-search { + display: flex; + align-items: center; + padding: 6px; + position: relative; + flex-shrink: 0; +} + +.sidebar-search-icon { + position: absolute; + left: 14px; + font-size: 12px; + pointer-events: none; + opacity: 0.5; +} + +.sidebar-search-input { + width: 100%; + height: 28px; + padding: 4px 24px 4px 28px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg); + color: var(--text); + font-size: 12px; + font-family: inherit; + outline: none; + transition: border-color 0.15s; +} + +.sidebar-search-input:focus { + border-color: var(--accent-border); +} + +.sidebar-search-input::placeholder { + color: var(--text); + opacity: 0.4; +} + +.sidebar-search-clear { + position: absolute; + right: 10px; + border: none; + background: none; + color: var(--text); + cursor: pointer; + font-size: 14px; + opacity: 0.5; + padding: 0 4px; +} + +.sidebar-search-clear:hover { + opacity: 1; +} + +/* ---------- Content ---------- */ + +.sidebar-content { + flex: 1; + overflow-y: auto; + overflow-x: hidden; +} + +/* ---------- Sections ---------- */ + +.sidebar-section { + padding: 4px 0; +} + +.sidebar-section-header { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + font-size: 11px; + font-weight: 600; + color: var(--text); + opacity: 0.7; + cursor: pointer; + user-select: none; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.sidebar-section-header:hover { + opacity: 1; +} + +.sidebar-section-chevron { + font-size: 10px; + width: 12px; + text-align: center; +} + +/* ---------- File Item ---------- */ + +.sidebar-file { + display: flex; + align-items: center; + gap: 6px; + height: 28px; + padding-right: 8px; + cursor: pointer; + user-select: none; + transition: background 0.1s; + position: relative; +} + +.sidebar-file:hover { + background: var(--accent-bg); +} + +.sidebar-file.active { + background: var(--accent-bg); + border-left: 2px solid var(--accent); +} + +.sidebar-file-icon { + font-size: 14px; + flex-shrink: 0; +} + +.sidebar-file-label { + flex: 1; + font-size: 12px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sidebar-file.active .sidebar-file-label { + font-weight: 500; + color: var(--text-h); +} + +.sidebar-open-dot { + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--accent); + flex-shrink: 0; + opacity: 0.6; +} + +/* ---------- Folder Item ---------- */ + +.sidebar-folder { + display: flex; + align-items: center; + gap: 4px; + height: 28px; + padding-right: 8px; + cursor: pointer; + user-select: none; + transition: background 0.1s; +} + +.sidebar-folder:hover { + background: var(--accent-bg); +} + +.sidebar-folder-chevron { + font-size: 10px; + width: 12px; + text-align: center; + flex-shrink: 0; +} + +.sidebar-folder-icon { + font-size: 14px; + flex-shrink: 0; +} + +.sidebar-folder-label { + flex: 1; + font-size: 12px; + font-weight: 500; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sidebar-folder-count { + font-size: 10px; + color: var(--text); + opacity: 0.4; + flex-shrink: 0; +} + +/* ---------- Drag & Drop ---------- */ + +.sidebar-file.dragging { + opacity: 0.4; +} + +.sidebar-folder.drop-target { + background: var(--accent-bg); + outline: 2px dashed var(--accent); + outline-offset: -2px; + border-radius: 4px; +} + +.sidebar-section-header.drop-target { + background: var(--accent-bg); +} + +.sidebar-files-area { + min-height: 8px; +} + +/* ---------- Templates ---------- */ + +.sidebar-template { + display: flex; + align-items: center; + gap: 8px; + height: 32px; + padding: 0 12px; + cursor: pointer; + transition: background 0.1s; +} + +.sidebar-template:hover { + background: var(--accent-bg); +} + +.sidebar-template-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.sidebar-template-text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.sidebar-template-name { + font-size: 12px; + color: var(--text); + line-height: 1.2; +} + +.sidebar-template-desc { + font-size: 10px; + color: var(--text); + opacity: 0.5; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.2; +} + +/* ---------- Empty State ---------- */ + +.sidebar-empty { + padding: 6px 12px; + font-size: 11px; + color: var(--text); + opacity: 0.4; + font-style: italic; +} + +/* ---------- Rename Input ---------- */ + +.sidebar-rename-input { + flex: 1; + min-width: 0; + border: 1px solid var(--accent-border); + border-radius: 2px; + background: var(--bg); + color: var(--text-h); + font-size: 12px; + padding: 1px 4px; + outline: none; + font-family: inherit; +} + +/* ---------- Footer ---------- */ + +.sidebar-footer { + display: flex; + gap: 4px; + padding: 6px; + border-top: 1px solid var(--border); + flex-shrink: 0; +} + +.sidebar-footer-btn { + flex: 1; + height: 26px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--text); + font-size: 11px; + font-family: inherit; + cursor: pointer; + transition: color 0.1s, background 0.1s; +} + +.sidebar-footer-btn:hover { + color: var(--accent); + background: var(--accent-bg); +} + +/* ---------- Resize Handle ---------- */ + +.sidebar-resize { + position: absolute; + top: 0; + right: -4px; + width: 8px; + height: 100%; + cursor: col-resize; + z-index: 10; +} + +.sidebar-resize:hover::after { + content: ''; + position: absolute; + top: 0; + right: 3px; + width: 2px; + height: 100%; + background: var(--accent); + opacity: 0.5; +} + +/* ---------- Context Menu ---------- */ + +.sidebar-context-menu { + position: fixed; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + padding: 4px; + z-index: 300; + min-width: 160px; +} + +.sidebar-context-menu button { + display: block; + width: 100%; + padding: 5px 10px; + border: none; + border-radius: 3px; + background: transparent; + color: var(--text); + font-size: 12px; + font-family: inherit; + text-align: left; + cursor: pointer; +} + +.sidebar-context-menu button:hover { + background: var(--accent-bg); + color: var(--text-h); +} + +.sidebar-context-separator { + height: 1px; + background: var(--border); + margin: 4px 0; +} + +.sidebar-context-label { + padding: 3px 10px; + font-size: 10px; + font-weight: 600; + color: var(--text); + opacity: 0.5; + text-transform: uppercase; +} + +.sidebar-context-danger { + color: var(--error) !important; +} + +/* ---------- Responsive: Mobile Drawer ---------- */ + +@media (max-width: 767px) { + .sidebar { + position: fixed; + left: 0; + top: 0; + bottom: 0; + width: 85vw !important; + max-width: 320px; + z-index: 400; + box-shadow: 4px 0 20px rgba(0, 0, 0, 0.25); + animation: sidebar-slide-in 0.2s ease-out; + } + + @keyframes sidebar-slide-in { + from { transform: translateX(-100%); } + to { transform: translateX(0); } + } + + .sidebar-file { + height: 44px; + } + + .sidebar-folder { + height: 44px; + } + + .sidebar-template { + height: 44px; + } + + .sidebar-search-input { + height: 36px; + font-size: 14px; + } + + .sidebar-resize { + display: none; + } +} + +/* Mobile sidebar backdrop — rendered from App.tsx */ +.sidebar-backdrop { + display: none; +} + +@media (max-width: 767px) { + .sidebar-backdrop { + display: block; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 399; + animation: sidebar-backdrop-in 0.2s ease-out; + } + + @keyframes sidebar-backdrop-in { + from { opacity: 0; } + to { opacity: 1; } + } +} diff --git a/calcpad-web/src/styles/status-bar.css b/calcpad-web/src/styles/status-bar.css new file mode 100644 index 0000000..64b5312 --- /dev/null +++ b/calcpad-web/src/styles/status-bar.css @@ -0,0 +1,159 @@ +/* ---------- Status Bar ---------- */ + +.status-bar { + display: flex; + align-items: center; + justify-content: space-between; + height: 24px; + padding: 0 12px; + background: var(--bg-secondary); + border-top: 1px solid var(--border); + flex-shrink: 0; + font-family: var(--mono); + font-size: 11px; + color: var(--text); + opacity: 0.8; + user-select: none; +} + +.status-bar-left, +.status-bar-right { + display: flex; + align-items: center; + gap: 12px; +} + +.status-bar-engine { + display: flex; + align-items: center; + gap: 4px; +} + +.status-bar-dot { + width: 6px; + height: 6px; + border-radius: 50%; +} + +.status-bar-dot.ready { + background: var(--success); +} + +.status-bar-dot.loading { + background: var(--warning); + animation: pulse 1.5s ease-in-out infinite; +} + +.status-bar-dedication { + opacity: 0.6; + font-family: var(--sans); + font-size: 10px; + letter-spacing: 0.2px; + cursor: pointer; + transition: opacity 0.15s; +} + +.status-bar-dedication:hover { + opacity: 1; +} + +.status-bar-heart { + color: #e53e3e; + font-size: 11px; +} + +/* ---------- Dedication Overlay ---------- */ + +.dedication-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 500; + animation: dedication-fade-in 0.2s ease-out; +} + +@keyframes dedication-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.dedication-card { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 16px; + padding: 40px; + max-width: 420px; + text-align: center; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + animation: dedication-card-in 0.3s ease-out; +} + +@keyframes dedication-card-in { + from { opacity: 0; transform: scale(0.9) translateY(20px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.dedication-heart { + font-size: 48px; + color: #e53e3e; + margin-bottom: 16px; + animation: dedication-beat 1.2s ease-in-out infinite; +} + +@keyframes dedication-beat { + 0%, 100% { transform: scale(1); } + 14% { transform: scale(1.15); } + 28% { transform: scale(1); } + 42% { transform: scale(1.1); } + 56% { transform: scale(1); } +} + +.dedication-card h2 { + margin: 0 0 16px; + font-size: 22px; + font-weight: 600; + color: var(--text-h); + font-family: var(--sans); +} + +.dedication-card p { + margin: 0 0 12px; + font-size: 14px; + line-height: 1.6; + color: var(--text); + font-family: var(--sans); +} + +.dedication-tagline { + font-style: italic; + opacity: 0.7; + font-size: 13px !important; +} + +.dedication-close { + margin-top: 20px; + padding: 8px 24px; + border: 1px solid var(--border); + border-radius: 8px; + background: transparent; + color: var(--text); + font-size: 13px; + font-family: var(--sans); + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.dedication-close:hover { + background: var(--accent-bg); + color: var(--accent); + border-color: var(--accent-border); +} + +@media (max-width: 768px) { + .status-bar-left span:not(:first-child) { + display: none; + } +} diff --git a/calcpad-web/src/styles/tab-bar.css b/calcpad-web/src/styles/tab-bar.css new file mode 100644 index 0000000..20342ea --- /dev/null +++ b/calcpad-web/src/styles/tab-bar.css @@ -0,0 +1,205 @@ +/* ---------- Tab Bar ---------- */ + +.tab-bar { + display: flex; + align-items: stretch; + height: 36px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.tab-bar-scroll { + display: flex; + flex: 1; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; /* Firefox */ +} + +.tab-bar-scroll::-webkit-scrollbar { + display: none; /* Chrome, Safari */ +} + +/* Fade indicators for scroll overflow */ +.tab-bar { + position: relative; +} + +.tab-bar::before, +.tab-bar::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + width: 16px; + pointer-events: none; + z-index: 1; + opacity: 0; + transition: opacity 0.15s; +} + +.tab-bar::before { + left: 0; + background: linear-gradient(to right, var(--bg-secondary), transparent); +} + +.tab-bar::after { + right: 36px; /* before new tab button */ + background: linear-gradient(to left, var(--bg-secondary), transparent); +} + +/* ---------- Tab Item ---------- */ + +.tab-item { + display: flex; + align-items: center; + gap: 4px; + padding: 0 8px; + min-width: 100px; + max-width: 200px; + height: 36px; + border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); + cursor: pointer; + user-select: none; + transition: background 0.1s; + flex-shrink: 0; +} + +.tab-item:hover { + background: var(--bg); +} + +.tab-item.active { + background: var(--bg); + border-bottom-color: transparent; + border-top: 2px solid var(--accent); +} + +.tab-item:not(.active) { + border-top: 2px solid transparent; +} + +/* ---------- Tab Label ---------- */ + +.tab-label { + flex: 1; + font-size: 12px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tab-item.active .tab-label { + font-weight: 500; + color: var(--text-h); +} + +/* ---------- Modified Dot ---------- */ + +.tab-modified-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text); + opacity: 0.6; + flex-shrink: 0; + animation: tab-dot-in 0.2s ease-out; +} + +@keyframes tab-dot-in { + from { opacity: 0; transform: scale(0); } + to { opacity: 0.6; transform: scale(1); } +} + +/* ---------- Mobile ---------- */ + +@media (max-width: 767px) { + .tab-bar { + height: 40px; + } + + .tab-item { + height: 40px; + min-width: 80px; + } + + .tab-close { + display: none; + } + + .tab-new { + height: 40px; + width: 40px; + } +} + +/* ---------- Close Button ---------- */ + +.tab-close { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border: none; + border-radius: 3px; + background: transparent; + color: var(--text); + opacity: 0; + font-size: 14px; + line-height: 1; + cursor: pointer; + flex-shrink: 0; + transition: opacity 0.1s, background 0.1s; +} + +.tab-item:hover .tab-close, +.tab-item.active .tab-close { + opacity: 0.5; +} + +.tab-close:hover { + opacity: 1 !important; + background: var(--accent-bg); +} + +/* ---------- Rename Input ---------- */ + +.tab-rename-input { + flex: 1; + min-width: 0; + border: 1px solid var(--accent-border); + border-radius: 2px; + background: var(--bg); + color: var(--text-h); + font-size: 12px; + padding: 1px 4px; + outline: none; + font-family: inherit; +} + +/* ---------- New Tab Button ---------- */ + +.tab-new { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: none; + border-bottom: 1px solid var(--border); + background: transparent; + color: var(--text); + font-size: 18px; + font-weight: 300; + cursor: pointer; + flex-shrink: 0; + transition: color 0.1s; +} + +.tab-new:hover { + color: var(--accent); +} diff --git a/calcpad-web/src/styles/theme-picker.css b/calcpad-web/src/styles/theme-picker.css new file mode 100644 index 0000000..57db8c1 --- /dev/null +++ b/calcpad-web/src/styles/theme-picker.css @@ -0,0 +1,130 @@ +/* ---------- Theme Picker ---------- */ + +.theme-picker-container { + position: relative; +} + +.theme-picker-trigger { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 4px; + background: transparent; + cursor: pointer; + font-size: 16px; + line-height: 1; + transition: background 0.1s; +} + +.theme-picker-trigger:hover { + background: var(--accent-bg); +} + +.theme-picker-dropdown { + position: absolute; + top: calc(100% + 4px); + right: 0; + width: 220px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); + padding: 6px; + z-index: 200; + animation: theme-picker-in 0.15s ease-out; +} + +@keyframes theme-picker-in { + from { + opacity: 0; + transform: scale(0.95) translateY(-4px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.theme-picker-section-label { + font-size: 11px; + font-weight: 600; + color: var(--text); + opacity: 0.6; + padding: 6px 8px 4px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.theme-picker-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 8px; + border: none; + border-radius: 4px; + background: transparent; + cursor: pointer; + font-size: 13px; + color: var(--text); + text-align: left; + transition: background 0.1s; +} + +.theme-picker-item:hover { + background: var(--accent-bg); +} + +.theme-picker-item.active { + font-weight: 500; + color: var(--text-h); +} + +.theme-picker-item-icon { + font-size: 14px; + width: 20px; + text-align: center; +} + +.theme-picker-item-label { + flex: 1; +} + +.theme-picker-check { + font-size: 12px; + color: var(--accent); + font-weight: 600; +} + +.theme-picker-separator { + height: 1px; + background: var(--border); + margin: 6px 0; +} + +.theme-picker-accents { + display: flex; + gap: 6px; + padding: 4px 8px 6px; +} + +.theme-picker-swatch { + width: 20px; + height: 20px; + border-radius: 50%; + border: 2px solid transparent; + cursor: pointer; + transition: transform 0.1s, border-color 0.1s; + padding: 0; +} + +.theme-picker-swatch:hover { + transform: scale(1.15); +} + +.theme-picker-swatch.active { + border-color: var(--text-h); +} diff --git a/calcpad-web/src/vite-env.d.ts b/calcpad-web/src/vite-env.d.ts index 20343fd..dab1d2b 100644 --- a/calcpad-web/src/vite-env.d.ts +++ b/calcpad-web/src/vite-env.d.ts @@ -1,5 +1,10 @@ /// +declare module '/wasm/calcpad_wasm.js' { + export default function init(): Promise + export function evalSheet(lines: string[]): import('./engine/types.ts').EngineLineResult[] +} + declare module 'virtual:pwa-register' { export interface RegisterSWOptions { immediate?: boolean diff --git a/calcpad-web/tsconfig.node.json b/calcpad-web/tsconfig.node.json index a96b3e5..957f38f 100644 --- a/calcpad-web/tsconfig.node.json +++ b/calcpad-web/tsconfig.node.json @@ -4,7 +4,7 @@ "target": "ES2023", "lib": ["ES2023"], "module": "ESNext", - "types": ["node"], + "types": [], "skipLibCheck": true, "moduleResolution": "bundler", diff --git a/calcpad-web/vite.config.ts b/calcpad-web/vite.config.ts index 7086c5e..d6fcf81 100644 --- a/calcpad-web/vite.config.ts +++ b/calcpad-web/vite.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ plugins: [ react(), VitePWA({ - registerType: 'prompt', + registerType: 'autoUpdate', includeAssets: ['favicon.svg', 'icons/*.svg'], manifest: { name: 'CalcPad', diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8e46ffc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,5 @@ +services: + web: + build: . + ports: + - "8080:8080" diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..697886b --- /dev/null +++ b/nginx.conf @@ -0,0 +1,32 @@ +server { + listen 8080; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # SPA fallback — serve index.html for all non-file routes + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets aggressively (hashed filenames) + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # WASM files — correct MIME type + cache + location /wasm/ { + types { application/wasm wasm; } + default_type application/javascript; + expires 1y; + add_header Cache-Control "public, immutable"; + add_header Cross-Origin-Embedder-Policy "require-corp"; + add_header Cross-Origin-Opener-Policy "same-origin"; + } + + # Gzip + gzip on; + gzip_types text/plain text/css application/javascript application/json application/wasm image/svg+xml; + gzip_min_length 256; +}