feat(web): implement complete workspace with themes, tabs, sidebar, and mobile
Transform CalcText from a single-document calculator into a full workspace application with multi-document support, theming, and responsive mobile experience. - Theme system: 5 presets (Light, Dark, Matrix, Midnight, Warm) + accent colors - Document model with localStorage persistence and auto-save - Tab bar with keyboard shortcuts (Ctrl+N/W/Tab/1-9), rename, close - Sidebar with search, recent, favorites, folders, templates, drag-and-drop - 5 templates: Budget, Invoice, Unit Converter, Trip Planner, Loan Calculator - Status bar with cursor position, engine status, dedication to Igor Cassel - Results panel: type-specific colors, click-to-copy, error hints - Format toolbar: H, B, I, //, color labels with live preview toggle - Syntax highlighting using theme CSS variables - Error hover tooltips - Mobile: bottom results tray, sidebar drawer, touch targets, safe areas - Docker multi-stage build (Rust WASM + Vite + Nginx) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
target/
|
||||
node_modules/
|
||||
.git/
|
||||
.gitignore
|
||||
*.md
|
||||
.vscode/
|
||||
.idea/
|
||||
.claude/
|
||||
_scripts/
|
||||
calcpad-web/dist/
|
||||
calcpad-wasm/pkg/
|
||||
3
Cargo.lock
generated
3
Cargo.lock
generated
@@ -505,6 +505,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"calcpad-engine",
|
||||
"chrono",
|
||||
"getrandom 0.2.17",
|
||||
"js-sys",
|
||||
"serde",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -1580,8 +1581,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
55
Dockerfile
Normal file
55
Dockerfile
Normal file
@@ -0,0 +1,55 @@
|
||||
# Stage 1 — Build WASM from Rust
|
||||
FROM rust:1.85-bookworm AS wasm-builder
|
||||
|
||||
# Install wasm-pack via pre-built binary (much faster than cargo install)
|
||||
RUN curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy workspace Cargo.toml
|
||||
COPY Cargo.toml ./
|
||||
|
||||
# Copy the crates we actually need to compile
|
||||
COPY calcpad-engine/ calcpad-engine/
|
||||
COPY calcpad-wasm/ calcpad-wasm/
|
||||
|
||||
# Create stub Cargo.toml + src for workspace members we don't need to build.
|
||||
# The workspace requires all members to exist, but we never compile these.
|
||||
RUN mkdir -p calcpad-cli/src && \
|
||||
printf '[package]\nname = "calcpad-cli"\nversion = "0.1.0"\nedition = "2021"\n\n[[bin]]\nname = "calcpad"\npath = "src/main.rs"\n\n[dependencies]\ncalcpad-engine = { path = "../calcpad-engine" }\n' > calcpad-cli/Cargo.toml && \
|
||||
echo 'fn main() {}' > calcpad-cli/src/main.rs && \
|
||||
mkdir -p calcpad-windows/src && \
|
||||
printf '[package]\nname = "calcpad-windows"\nversion = "0.1.0"\nedition = "2021"\n\n[[bin]]\nname = "calcpad-win"\npath = "src/main.rs"\n' > calcpad-windows/Cargo.toml && \
|
||||
echo 'fn main() {}' > calcpad-windows/src/main.rs
|
||||
|
||||
# Build WASM
|
||||
RUN wasm-pack build calcpad-wasm --target web --release
|
||||
|
||||
# Stage 2 — Build frontend with Vite
|
||||
FROM node:22-slim AS web-builder
|
||||
|
||||
WORKDIR /app/calcpad-web
|
||||
|
||||
# Install dependencies first (layer caching)
|
||||
COPY calcpad-web/package.json calcpad-web/package-lock.json* ./
|
||||
RUN npm install
|
||||
|
||||
# Copy web source
|
||||
COPY calcpad-web/ ./
|
||||
|
||||
# Copy WASM output into public/wasm/ so Vite includes it in the build
|
||||
COPY --from=wasm-builder /app/calcpad-wasm/pkg/calcpad_wasm.js public/wasm/calcpad_wasm.js
|
||||
COPY --from=wasm-builder /app/calcpad-wasm/pkg/calcpad_wasm_bg.wasm public/wasm/calcpad_wasm_bg.wasm
|
||||
|
||||
# Build (skip tsc type-check — that belongs in CI, not Docker)
|
||||
RUN npx vite build
|
||||
|
||||
# Stage 3 — Serve with nginx
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=web-builder /app/calcpad-web/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
44
_bmad-output/A-Product-Brief/00-product-brief.md
Normal file
44
_bmad-output/A-Product-Brief/00-product-brief.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Product Brief: CalcText
|
||||
|
||||
> The strategic foundation — why this product exists, who it serves, and what success looks like.
|
||||
|
||||
**Created:** 2026-03-17
|
||||
**Phase:** 1 — Product Brief
|
||||
**Agent:** Saga (Analyst)
|
||||
|
||||
---
|
||||
|
||||
## What Belongs Here
|
||||
|
||||
The Product Brief answers five strategic questions:
|
||||
|
||||
1. **Why** does this product exist? (Vision & business goals)
|
||||
2. **Who** is it for? (Target users and their context)
|
||||
3. **What** does it need to do? (Core capabilities)
|
||||
4. **How** will we know it works? (Success metrics)
|
||||
5. **What** are the constraints? (Platform requirements, tech stack)
|
||||
|
||||
Everything downstream — trigger maps, scenarios, page specs, design system — traces back to decisions made here. This is the North Star.
|
||||
|
||||
---
|
||||
|
||||
## For Agents
|
||||
|
||||
**Workflow:** `_bmad/wds/workflows/1-project-brief/workflow.md`
|
||||
**Agent trigger:** `PB` (Saga)
|
||||
**Templates:** `_bmad/wds/workflows/1-project-brief/templates/`
|
||||
|
||||
---
|
||||
|
||||
## Documents
|
||||
|
||||
_This section will be updated as documents are created during Phase 1._
|
||||
|
||||
| # | Document | Status |
|
||||
|---|----------|--------|
|
||||
| 01 | Project Brief (Simplified) | Complete |
|
||||
| 02 | Brownfield Analysis | Complete |
|
||||
|
||||
---
|
||||
|
||||
_Created using Whiteport Design Studio (WDS) methodology_
|
||||
205
_bmad-output/A-Product-Brief/01-brownfield-analysis.md
Normal file
205
_bmad-output/A-Product-Brief/01-brownfield-analysis.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# CalcText Web — Brownfield Analysis
|
||||
|
||||
**Date:** 2026-03-17
|
||||
**Agent:** Freya (WDS Designer)
|
||||
**Scope:** calcpad-web current state + cross-platform patterns from macOS
|
||||
|
||||
---
|
||||
|
||||
## 1. Current Architecture
|
||||
|
||||
| Layer | Tech | Status |
|
||||
|-------|------|--------|
|
||||
| Framework | React 19 + TypeScript | Solid |
|
||||
| Editor | CodeMirror 6 (custom language, extensions) | Solid |
|
||||
| Engine | Rust WASM in Web Worker (JS fallback for dev) | Solid |
|
||||
| Build | Vite 7, ES2022 target, code splitting | Solid |
|
||||
| PWA | vite-plugin-pwa, service worker, manifest | Solid |
|
||||
| Styling | CSS custom properties, no framework | Flexible — ready for theme system |
|
||||
|
||||
---
|
||||
|
||||
## 2. Current Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Header (logo + subtitle + status dot) │ 52px
|
||||
├─────────────────────────────────────────┤
|
||||
│ Align Toolbar (editor + results) │ 28px
|
||||
├───────────────────────┬─┬───────────────┤
|
||||
│ │ │ │
|
||||
│ Editor (flex: 3) │D│ Results │
|
||||
│ CodeMirror 6 │i│ Panel │
|
||||
│ 15px mono, 24px line │v│ (flex: 1) │
|
||||
│ │ │ │
|
||||
└───────────────────────┴─┴───────────────┘
|
||||
```
|
||||
|
||||
**Mobile (< 640px):** Results panel, toolbar, divider, subtitle — all hidden. Editor only.
|
||||
|
||||
---
|
||||
|
||||
## 3. CSS Design Tokens (Current)
|
||||
|
||||
### Colors
|
||||
|
||||
| Token | Light | Dark |
|
||||
|-------|-------|------|
|
||||
| --text | #6b6375 | #9ca3af |
|
||||
| --text-h | #08060d | #f3f4f6 |
|
||||
| --bg | #fff | #16171d |
|
||||
| --bg-secondary | #f8f9fa | #1a1b23 |
|
||||
| --border | #e5e4e7 | #2e303a |
|
||||
| --code-bg | #f4f3ec | #1f2028 |
|
||||
| --accent | #6366f1 | #818cf8 |
|
||||
| --accent-bg | rgba(99,102,241,0.1) | rgba(129,140,248,0.15) |
|
||||
| --accent-border | rgba(99,102,241,0.5) | rgba(129,140,248,0.5) |
|
||||
| --warning | #f59e0b | (same) |
|
||||
| --success | #10b981 | (same) |
|
||||
| --error | #e53e3e | (same) |
|
||||
|
||||
### Typography
|
||||
|
||||
| Context | Font | Size | Weight | Line Height |
|
||||
|---------|------|------|--------|-------------|
|
||||
| Body | system-ui | 16px | 400 | 1.5 |
|
||||
| Editor/Results | ui-monospace | 15px | 400/600 | 1.6 (24px) |
|
||||
| Header title | system-ui | 20px | — | — |
|
||||
| Header subtitle | system-ui | 13px | — | — |
|
||||
| Status | system-ui | 12px | — | — |
|
||||
| Toolbar labels | system-ui | 11px | — | — |
|
||||
| Buttons | system-ui | 14px | 500 | — |
|
||||
|
||||
### Spacing
|
||||
|
||||
| Context | Value |
|
||||
|---------|-------|
|
||||
| Header padding | 12px 24px |
|
||||
| Editor line padding | 0 16px |
|
||||
| Editor content padding | 12px 0 |
|
||||
| Results line padding | 0 16px |
|
||||
| Results content padding | 12px 0 |
|
||||
| Toolbar height | 28px |
|
||||
| Toolbar padding | 0 12px |
|
||||
| Button gaps | 4px |
|
||||
| Divider width | 5px (9px hit area) |
|
||||
|
||||
### Breakpoints
|
||||
|
||||
| Width | Behavior |
|
||||
|-------|----------|
|
||||
| > 640px | Full two-column layout |
|
||||
| <= 640px | Single column, results/toolbar/divider hidden |
|
||||
| <= 480px | Install prompt stacks vertically |
|
||||
|
||||
### Z-Index
|
||||
|
||||
| Element | Value |
|
||||
|---------|-------|
|
||||
| Install prompt | 100 |
|
||||
| (everything else) | Default stacking |
|
||||
|
||||
### Animations
|
||||
|
||||
| Element | Type | Duration |
|
||||
|---------|------|----------|
|
||||
| Divider hover | Transition | 0.15s |
|
||||
| Toolbar buttons | Transition | 0.1s |
|
||||
| Install button | Transition | 0.2s |
|
||||
| Status dot (loading) | Pulse keyframe | 1.5s |
|
||||
| Offline icon | Pulse keyframe | 2s |
|
||||
|
||||
---
|
||||
|
||||
## 4. Component Inventory
|
||||
|
||||
| Component | Location | Props | Purpose |
|
||||
|-----------|----------|-------|---------|
|
||||
| App | App.tsx | — | Shell, state, layout |
|
||||
| CalcEditor | editor/CalcEditor.tsx | initialDoc, onDocChange, results, debounceMs, onViewReady | CodeMirror wrapper |
|
||||
| ResultsPanel | components/ResultsPanel.tsx | results, align, style | Right-side results display |
|
||||
| AlignToolbar | components/AlignToolbar.tsx | editorAlign, resultsAlign, onChange handlers | Text alignment controls |
|
||||
| OfflineBanner | components/OfflineBanner.tsx | isOnline | Offline status |
|
||||
| InstallPrompt | components/InstallPrompt.tsx | promptEvent, isInstalled, onInstall, onDismiss | PWA install |
|
||||
|
||||
### Editor Extensions
|
||||
|
||||
| Extension | File | Purpose |
|
||||
|-----------|------|---------|
|
||||
| calcpadLanguage | calcpad-language.ts | Syntax highlighting (keywords, functions, operators, currencies) |
|
||||
| errorDisplayExtension | error-display.ts | Wavy underlines + gutter markers for errors |
|
||||
| stripedLinesExtension | inline-results.ts | Even-line zebra striping |
|
||||
| calcpadEditorTheme | CalcEditor.tsx | Base theme (font, padding, colors) |
|
||||
|
||||
### Hooks
|
||||
|
||||
| Hook | File | State |
|
||||
|------|------|-------|
|
||||
| useEngine | engine/useEngine.ts | ready, results, error, evalSheet() |
|
||||
| useOnlineStatus | hooks/useOnlineStatus.ts | isOnline |
|
||||
| useInstallPrompt | hooks/useInstallPrompt.ts | promptEvent, isInstalled, handlers |
|
||||
|
||||
---
|
||||
|
||||
## 5. Accessibility Audit
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| Semantic HTML | Partial | Header, main present. Missing nav, regions |
|
||||
| ARIA roles | Partial | OfflineBanner (status), InstallPrompt (complementary) |
|
||||
| Focus indicators | Missing | No custom :focus-visible styles |
|
||||
| Keyboard nav | Default | CodeMirror defaults only, toolbar not keyboard-navigable |
|
||||
| Screen reader | Partial | Results panel has no aria-live for updates |
|
||||
| Color contrast | OK | Meets AA for most combinations |
|
||||
| Font scaling | Missing | Fixed 15px, ignores system preferences |
|
||||
| Reduced motion | Missing | No prefers-reduced-motion media query |
|
||||
|
||||
---
|
||||
|
||||
## 6. Cross-Platform Comparison (macOS vs Web)
|
||||
|
||||
| Aspect | macOS | Web | Recommendation |
|
||||
|--------|-------|-----|----------------|
|
||||
| Spacing | Compact (8px) | Spacious (12-16px) | Tighten to match native feel |
|
||||
| Results weight | Regular, secondary color | Bold 600, accent color | Reduce to secondary — results are too loud |
|
||||
| Font scaling | System Dynamic Type | Fixed 15px | Respect system preferences |
|
||||
| Min dimensions | 800x600 window | No minimum | Set min-width guidance |
|
||||
| Text substitutions | All disabled | Browser default | Disable smart quotes/dashes |
|
||||
| Stripe pattern | Synced, 4% opacity | Synced, 2.5-3.5% opacity | Already close |
|
||||
| Error display | Editor only | Editor only | Already matched |
|
||||
| Scroll sync | Binding-driven | Event-driven (passive) | Both work well |
|
||||
|
||||
---
|
||||
|
||||
## 7. What's Missing for Workspace Vision
|
||||
|
||||
| Feature | Exists? | Effort | Priority |
|
||||
|---------|---------|--------|----------|
|
||||
| Theme engine (multi-theme) | No (only system dark/light) | Medium | P0 |
|
||||
| Theme switcher UI | No | Low | P0 |
|
||||
| Document model (multi-doc) | No (single string in state) | Medium | P0 |
|
||||
| Tab bar | No | Medium | P0 |
|
||||
| File sidebar | No | Medium | P1 |
|
||||
| localStorage persistence | No | Low | P0 |
|
||||
| Folder organization | No | Medium | P1 |
|
||||
| Templates library | No | Low | P2 |
|
||||
| Dark mode toggle (manual) | No (system preference only) | Low | P0 |
|
||||
| Mobile results view | No (hidden on mobile) | Medium | P1 |
|
||||
| Keyboard shortcuts panel | No | Low | P2 |
|
||||
| Status bar | No | Low | P1 |
|
||||
|
||||
---
|
||||
|
||||
## 8. Strengths to Preserve
|
||||
|
||||
1. **Engine isolation** — Web Worker keeps UI responsive
|
||||
2. **PWA foundation** — Offline-ready, installable
|
||||
3. **CSS custom properties** — Perfect base for theme system
|
||||
4. **CodeMirror extensibility** — Custom language, errors, striping
|
||||
5. **Scroll sync** — Reliable cross-pane synchronization
|
||||
6. **Light/dark adaptation** — prefers-color-scheme already works
|
||||
7. **Code splitting** — React and CodeMirror in separate chunks
|
||||
|
||||
---
|
||||
|
||||
_Created using Whiteport Design Studio (WDS) methodology_
|
||||
94
_bmad-output/A-Product-Brief/project-brief.md
Normal file
94
_bmad-output/A-Product-Brief/project-brief.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# CalcText — Simplified Product Brief
|
||||
|
||||
**Date:** 2026-03-17
|
||||
**Type:** Brownfield — Workspace Evolution
|
||||
**Brief Level:** Simplified
|
||||
|
||||
---
|
||||
|
||||
## Project Scope
|
||||
|
||||
CalcText is a cross-platform notepad calculator (Web, macOS, Windows) powered by a shared Rust calculation engine with 200+ unit conversions, 180+ fiat currencies, 50+ crypto, date/time math, and 50+ built-in functions.
|
||||
|
||||
The web app is evolving from a single-document, two-column calculator into a **full workspace application** with:
|
||||
|
||||
- **Multi-theme system** — Light, Dark, Matrix, and custom user-defined themes
|
||||
- **Tabbed documents** — Multiple calctext files open simultaneously
|
||||
- **File sidebar** — Left panel with folder organization, recent files, favorites, templates
|
||||
- **localStorage persistence** — Documents survive page reloads without backend
|
||||
- **Elevated results panel** — Richer, subtler result display
|
||||
|
||||
**Platform strategy:** Web-first design. Once established, replicate the UX patterns to macOS (SwiftUI) and Windows (iced/Rust). The design must be portable — avoid web-only paradigms that don't translate to native.
|
||||
|
||||
---
|
||||
|
||||
## Challenge & Opportunity
|
||||
|
||||
CalcText has a powerful engine trapped in a basic single-document UI. The current web app is functional but doesn't reflect the depth of what the engine can do, and doesn't invite users to stay.
|
||||
|
||||
**The opportunity:** No competitor (Soulver, Numi, Numbr, PCalc) offers a true multi-document workspace with theming and file organization for calculations. CalcText can own this gap — becoming the app users open daily and live in, not just visit for a quick calculation.
|
||||
|
||||
**The gap:** Current UI is a text editor with results. The vision is a professional workspace where calculations are organized, styled, and persistent.
|
||||
|
||||
---
|
||||
|
||||
## Design Goals
|
||||
|
||||
### Functional
|
||||
- Multi-theme engine with preset themes and custom theme support
|
||||
- Tab bar for multi-document management
|
||||
- Collapsible file sidebar with tree view, folders, and templates
|
||||
- Document model with create, rename, delete, organize
|
||||
- localStorage persistence for all documents and preferences
|
||||
|
||||
### Experience
|
||||
- Professional workspace feel — "VS Code/Notion for numbers"
|
||||
- Complete experience shipped as one coherent product, not incremental phases
|
||||
- Matrix theme as a brand differentiator and personality statement
|
||||
- Intuitive for first-time users, powerful for daily users
|
||||
|
||||
### Business
|
||||
- Web-first design that establishes reusable UX patterns
|
||||
- Cross-platform portable design language
|
||||
- Foundation for future premium features (cloud sync, accounts, collaboration)
|
||||
|
||||
---
|
||||
|
||||
## Constraints
|
||||
|
||||
### Technical (locked in)
|
||||
- React 19 + CodeMirror 6 + Vite 7
|
||||
- Rust WASM engine running in Web Worker
|
||||
- Custom CSS with CSS custom properties (no Tailwind, no shadcn)
|
||||
- PWA architecture (service worker, manifest, offline support)
|
||||
|
||||
### Deferred (out of scope this phase)
|
||||
- User accounts and authentication
|
||||
- Cloud save and sync (Supabase planned)
|
||||
- Collaborative real-time editing (CRDT)
|
||||
- Shareable URL links
|
||||
- Embeddable widget
|
||||
|
||||
### Cross-platform
|
||||
- Design patterns must translate to SwiftUI (macOS) and iced (Windows)
|
||||
- Avoid web-only interactions that don't port to native
|
||||
- Shared engine means consistent calculation behavior across platforms
|
||||
|
||||
### Timeline & Budget
|
||||
- Flexible — no hard deadline
|
||||
- Stakes: enterprise/high — quality over speed
|
||||
|
||||
---
|
||||
|
||||
## Current State Reference
|
||||
|
||||
See `01-brownfield-analysis.md` for complete codebase scan including:
|
||||
- All CSS custom properties and values
|
||||
- Component inventory and props
|
||||
- Responsive breakpoints and behavior
|
||||
- Accessibility audit
|
||||
- Cross-platform comparison (macOS vs Web)
|
||||
|
||||
---
|
||||
|
||||
_Created using Whiteport Design Studio (WDS) methodology_
|
||||
42
_bmad-output/C-UX-Scenarios/00-ux-scenarios.md
Normal file
42
_bmad-output/C-UX-Scenarios/00-ux-scenarios.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# UX Scenarios: CalcText
|
||||
|
||||
> Design experiences, not screens — every page serves a user with a goal and an emotion.
|
||||
|
||||
**Created:** 2026-03-17
|
||||
**Phase:** 3 (Scenario Outline) + Phase 4 (UX Design)
|
||||
**Agents:** Freya (Page Specifications)
|
||||
|
||||
---
|
||||
|
||||
## Scenarios
|
||||
|
||||
| # | Scenario | Pages | Description |
|
||||
|---|----------|-------|-------------|
|
||||
| 01 | Workspace Shell | App Shell, Status Bar | The overall 3-panel layout that contains everything |
|
||||
| 02 | Calculation Experience | Editor, Results Panel | The core — typing calculations, seeing results |
|
||||
| 03 | Document Management | Tab Bar, New Document, Rename/Delete | Multi-document workflow |
|
||||
| 04 | File Organization | Sidebar, Folder Tree, Templates, Recent/Favorites | Organizing calctext files |
|
||||
| 05 | Theming | Theme Picker, Preset Themes, Custom Theme, Accent Color | Personalizing the workspace |
|
||||
| 06 | Mobile Experience | Mobile Shell, Mobile Results, Mobile Sidebar | Responsive/touch adaptation |
|
||||
|
||||
---
|
||||
|
||||
## Page Index
|
||||
|
||||
_Updated as page specifications are created during Phase 4._
|
||||
|
||||
| Scenario | Page | Status | File |
|
||||
|----------|------|--------|------|
|
||||
| 01 | App Shell | Specified | 01-workspace-shell/1.1-app-shell/1.1-app-shell.md |
|
||||
| 01 | Status Bar | Specified | 01-workspace-shell/1.2-status-bar/1.2-status-bar.md |
|
||||
| 02 | Editor | Specified | 02-calculation-experience/2.1-editor/2.1-editor.md |
|
||||
| 02 | Results Panel | Specified | 02-calculation-experience/2.2-results-panel/2.2-results-panel.md |
|
||||
| 03 | Tab Bar & Document Lifecycle | Specified | 03-document-management/3.1-tab-bar/3.1-tab-bar.md |
|
||||
| 04 | Sidebar & File Organization | Specified | 04-file-organization/4.1-sidebar/4.1-sidebar.md |
|
||||
| 04 | Templates | Specified | 04-file-organization/4.2-templates/4.2-templates.md |
|
||||
| 05 | Theme System | Specified | 05-theming/5.1-theme-system/5.1-theme-system.md |
|
||||
| 06 | Mobile Experience | Specified | 06-mobile-experience/6.1-mobile-shell/6.1-mobile-shell.md |
|
||||
|
||||
---
|
||||
|
||||
_Created using Whiteport Design Studio (WDS) methodology_
|
||||
@@ -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_
|
||||
@@ -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_
|
||||
@@ -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_
|
||||
@@ -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_
|
||||
@@ -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 `<input>`, pre-selected text, 200px max width |
|
||||
| Sidebar right-click → Rename | Same inline editing in sidebar |
|
||||
| Enter | Confirm rename, update document and sidebar |
|
||||
| Esc | Cancel, revert to previous name |
|
||||
| Blur | Confirm (same as Enter) |
|
||||
| Empty name | Revert to "Untitled" |
|
||||
|
||||
### Delete
|
||||
|
||||
| Trigger | Behavior |
|
||||
|---------|----------|
|
||||
| Sidebar right-click → Delete | Confirmation: "Delete '{title}'? This cannot be undone." |
|
||||
| Confirm | Remove from documents[], close tab if open, remove from folder |
|
||||
| Cancel | No action |
|
||||
| Undo | 5-second toast: "Document deleted. [Undo]" — restores document if clicked |
|
||||
|
||||
### Duplicate
|
||||
|
||||
| Trigger | Behavior |
|
||||
|---------|----------|
|
||||
| Sidebar right-click → Duplicate | New doc: same content, title = "{original} (copy)", same folder |
|
||||
| Opens in new tab automatically |
|
||||
|
||||
### Auto-Save
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Trigger | Document content change |
|
||||
| Debounce | 500ms after last keystroke |
|
||||
| Storage | localStorage (calctext-documents) |
|
||||
| Indicator | Modified dot (●) appears immediately on change, disappears on save |
|
||||
| Manual save | Ctrl/Cmd+S shows brief checkmark animation in tab (visual confirmation) |
|
||||
|
||||
---
|
||||
|
||||
## Modified Indicator
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Shape | Filled circle, 6px |
|
||||
| Color | var(--text) at 60% |
|
||||
| Position | Before tab label, 4px gap |
|
||||
| Appears | On first character change |
|
||||
| Disappears | On auto-save completion (500ms debounce) |
|
||||
| Animation | Fade in 0.2s |
|
||||
|
||||
---
|
||||
|
||||
## Context Menu (Right-Click Tab)
|
||||
|
||||
| Item | Action |
|
||||
|------|--------|
|
||||
| Close | Close this tab |
|
||||
| Close Others | Close all tabs except this one |
|
||||
| Close to the Right | Close all tabs to the right |
|
||||
| — | (separator) |
|
||||
| Rename | Inline rename |
|
||||
| Duplicate | Duplicate document |
|
||||
| — | (separator) |
|
||||
| Reveal in Sidebar | Scroll sidebar to show this file |
|
||||
|
||||
---
|
||||
|
||||
## Mobile Adaptations
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Tab bar | Horizontal scroll, touch-friendly |
|
||||
| Tab height | 40px (larger touch target) |
|
||||
| Close button | Hidden — swipe left on tab to reveal close |
|
||||
| New tab | [+] button at far right |
|
||||
| Rename | Long-press → context menu → Rename |
|
||||
| Reorder | Long-press + drag |
|
||||
|
||||
---
|
||||
|
||||
## Page States
|
||||
|
||||
| State | When | Behavior |
|
||||
|-------|------|----------|
|
||||
| **Single tab** | Only one document open | Tab still shown (establishes pattern). Close creates new "Untitled". |
|
||||
| **Many tabs (>10)** | Heavy usage | Horizontal scroll. Active tab auto-scrolls into view. |
|
||||
| **All tabs closed** | User closed everything | Auto-create "Untitled" tab (workspace never empty). |
|
||||
| **First launch** | No localStorage | Single "Welcome" tab with demo content. |
|
||||
|
||||
---
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- **Tab switching performance:** Store `EditorState` per tab in memory. On switch: destroy current EditorView, create new with stored state. Content swap < 20ms.
|
||||
- **Tab order persistence:** `openTabIds: string[]` in localStorage maintains order.
|
||||
- **Cross-platform:** Maps to native tab bars. macOS: NSTabView or custom tab strip. Windows: iced tabs widget.
|
||||
- **Max tabs:** Soft limit at 20 with performance hint in status bar. No hard limit.
|
||||
|
||||
---
|
||||
|
||||
_Created using Whiteport Design Studio (WDS) methodology_
|
||||
@@ -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_
|
||||
@@ -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_
|
||||
@@ -0,0 +1,371 @@
|
||||
# 5.1 — Theme System
|
||||
|
||||
**Previous Step:** ← [Templates](../../04-file-organization/4.2-templates/4.2-templates.md)
|
||||
**Next Step:** → [Mobile Experience](../../06-mobile-experience/6.1-mobile-shell/6.1-mobile-shell.md)
|
||||
|
||||
---
|
||||
|
||||
## Page Metadata
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Scenario** | 05 — Theming |
|
||||
| **Page Number** | 5.1 |
|
||||
| **Platform** | Web (PWA), portable to macOS/Windows |
|
||||
| **Page Type** | Dropdown Panel + Settings Section |
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
**Page Purpose:** Enable users to personalize their workspace with preset themes and custom accent colors. Theming is a personality differentiator — especially the Matrix theme, which gives CalcText a unique identity in the calculator app market.
|
||||
|
||||
**Success Criteria:**
|
||||
- Theme switches instantly (< 16ms, no flash of unstyled content)
|
||||
- Matrix theme makes users want to screenshot and share
|
||||
- Custom accent color gives ownership feeling
|
||||
- Theme persists across sessions
|
||||
|
||||
---
|
||||
|
||||
## Theme Architecture
|
||||
|
||||
All theming works through CSS custom properties on `:root`. Switching themes = swapping property values. No component changes, no re-renders beyond what CSS handles natively.
|
||||
|
||||
```typescript
|
||||
interface ThemeDefinition {
|
||||
id: string
|
||||
name: string
|
||||
icon: string // emoji or svg
|
||||
tokens: {
|
||||
// Backgrounds
|
||||
bg: string
|
||||
bgSecondary: string
|
||||
codeBg: string
|
||||
|
||||
// Text
|
||||
text: string
|
||||
textH: string
|
||||
|
||||
// Borders
|
||||
border: string
|
||||
|
||||
// Accent
|
||||
accent: string
|
||||
accentBg: string
|
||||
accentBorder: string
|
||||
|
||||
// Semantic
|
||||
success: string
|
||||
warning: string
|
||||
error: string
|
||||
|
||||
// Syntax highlighting
|
||||
syntaxVariable: string
|
||||
syntaxNumber: string
|
||||
syntaxOperator: string
|
||||
syntaxKeyword: string
|
||||
syntaxFunction: string
|
||||
syntaxCurrency: string
|
||||
syntaxComment: string
|
||||
syntaxHeading: string
|
||||
|
||||
// Result colors
|
||||
resultNumber: string
|
||||
resultUnit: string
|
||||
resultCurrency: string
|
||||
resultDatetime: string
|
||||
resultBoolean: string
|
||||
|
||||
// Stripes
|
||||
stripe: string
|
||||
|
||||
// Special
|
||||
fontOverride?: string // for Matrix: force monospace everywhere
|
||||
specialEffect?: string // CSS class for effects like scanlines
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Preset Themes
|
||||
|
||||
### Light
|
||||
|
||||
| Token | Value |
|
||||
|-------|-------|
|
||||
| --bg | #ffffff |
|
||||
| --bg-secondary | #f8f9fa |
|
||||
| --text | #6b6375 |
|
||||
| --text-h | #08060d |
|
||||
| --border | #e5e4e7 |
|
||||
| --accent | #6366f1 |
|
||||
| --stripe | rgba(0, 0, 0, 0.02) |
|
||||
| Feel | Clean, airy, professional |
|
||||
|
||||
### Dark
|
||||
|
||||
| Token | Value |
|
||||
|-------|-------|
|
||||
| --bg | #16171d |
|
||||
| --bg-secondary | #1a1b23 |
|
||||
| --text | #9ca3af |
|
||||
| --text-h | #f3f4f6 |
|
||||
| --border | #2e303a |
|
||||
| --accent | #818cf8 |
|
||||
| --stripe | rgba(255, 255, 255, 0.025) |
|
||||
| Feel | Calm, focused, modern |
|
||||
|
||||
### Matrix
|
||||
|
||||
| Token | Value |
|
||||
|-------|-------|
|
||||
| --bg | #0a0a0a |
|
||||
| --bg-secondary | #0f1a0f |
|
||||
| --text | #00ff41 |
|
||||
| --text-h | #33ff66 |
|
||||
| --border | #003300 |
|
||||
| --accent | #00ff41 |
|
||||
| --stripe | rgba(0, 255, 65, 0.03) |
|
||||
| --syntax-* | Green spectrum (#00cc33 to #39ff14) |
|
||||
| --result-currency | #ffff00 (yellow — stands out in green) |
|
||||
| fontOverride | `'Courier New', 'Fira Code', monospace` |
|
||||
| specialEffect | `matrix-scanlines` |
|
||||
| Feel | Iconic, hackery, fun |
|
||||
|
||||
#### Matrix Special Effects
|
||||
|
||||
**Scanlines (optional, subtle):**
|
||||
```css
|
||||
.matrix-scanlines::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 0, 0, 0.08) 2px,
|
||||
rgba(0, 0, 0, 0.08) 4px
|
||||
);
|
||||
z-index: 9999;
|
||||
}
|
||||
```
|
||||
|
||||
**Cursor glow:**
|
||||
```css
|
||||
.matrix-theme .cm-cursor {
|
||||
border-color: #00ff41;
|
||||
box-shadow: 0 0 4px #00ff41, 0 0 8px rgba(0, 255, 65, 0.3);
|
||||
}
|
||||
```
|
||||
|
||||
**Text glow (subtle):**
|
||||
```css
|
||||
.matrix-theme .result-value {
|
||||
text-shadow: 0 0 2px rgba(0, 255, 65, 0.3);
|
||||
}
|
||||
```
|
||||
|
||||
### Midnight
|
||||
|
||||
| Token | Value |
|
||||
|-------|-------|
|
||||
| --bg | #0f172a |
|
||||
| --bg-secondary | #1e293b |
|
||||
| --text | #94a3b8 |
|
||||
| --text-h | #e2e8f0 |
|
||||
| --border | #334155 |
|
||||
| --accent | #38bdf8 (sky-400) |
|
||||
| --stripe | rgba(56, 189, 248, 0.03) |
|
||||
| Feel | Deep blue, serene, late-night coding |
|
||||
|
||||
### Warm
|
||||
|
||||
| Token | Value |
|
||||
|-------|-------|
|
||||
| --bg | #fffbf5 |
|
||||
| --bg-secondary | #fef3e2 |
|
||||
| --text | #78716c |
|
||||
| --text-h | #1c1917 |
|
||||
| --border | #e7e5e4 |
|
||||
| --accent | #f97316 (orange-500) |
|
||||
| --stripe | rgba(249, 115, 22, 0.03) |
|
||||
| Feel | Paper-like, warm, comfortable for long sessions |
|
||||
|
||||
---
|
||||
|
||||
## Theme Picker Dropdown
|
||||
|
||||
**OBJECT ID:** `theme-picker`
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Trigger | Click theme icon in header or status bar, or Ctrl+Shift+T |
|
||||
| Position | Dropdown below header button, right-aligned |
|
||||
| Width | 240px |
|
||||
| Background | var(--bg) |
|
||||
| Border | 1px solid var(--border) |
|
||||
| Border radius | 8px |
|
||||
| Shadow | 0 4px 16px rgba(0, 0, 0, 0.15) |
|
||||
| Padding | space-xs (6px) |
|
||||
| Animation | Scale from 95% + fade, 150ms ease-out |
|
||||
| Dismiss | Click outside, Esc, or select theme |
|
||||
|
||||
### Theme Picker Layout
|
||||
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ Themes │ section header
|
||||
│ │
|
||||
│ ☀️ Light ✓ │ active indicator
|
||||
│ 🌙 Dark │
|
||||
│ 💻 Matrix │
|
||||
│ 🌊 Midnight │
|
||||
│ 📜 Warm │
|
||||
│ │
|
||||
│ ────────────────────── │ separator
|
||||
│ │
|
||||
│ Accent Color │ section header
|
||||
│ [●][●][●][●][●][●][●] │ color swatches
|
||||
│ │
|
||||
│ ────────────────────── │
|
||||
│ ⚙️ System (auto) │ follows OS preference
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
### Theme Item
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Height | 36px |
|
||||
| Padding | 8px 12px |
|
||||
| Layout | Icon + name + optional checkmark |
|
||||
| Hover | var(--accent-bg) background, border-radius 4px |
|
||||
| Active | Checkmark ✓ on right side, weight 500 |
|
||||
| Click | Apply theme instantly, close picker |
|
||||
| Preview | On hover, show a 4-color mini-swatch (bg, text, accent, secondary) |
|
||||
|
||||
### Color Swatch Preview (on hover)
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Size | 4 circles, 12px each, 4px gap |
|
||||
| Colors | bg, bg-secondary, accent, text — of the hovered theme |
|
||||
| Position | Inline after theme name |
|
||||
|
||||
---
|
||||
|
||||
## Accent Color Picker
|
||||
|
||||
**OBJECT ID:** `theme-accent-picker`
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Location | Inside theme picker dropdown, below themes |
|
||||
| Presets | 7 color swatches in a row |
|
||||
| Swatch size | 20px circles, 6px gap |
|
||||
| Active swatch | 2px ring in var(--text-h) |
|
||||
| Custom | Last swatch is rainbow gradient → opens native color picker |
|
||||
| Behavior | Overrides --accent, --accent-bg, --accent-border for current theme |
|
||||
| Persistence | Stored as `accentColor` in localStorage |
|
||||
|
||||
### Preset Accent Colors
|
||||
|
||||
| Name | Value | Hex |
|
||||
|------|-------|-----|
|
||||
| Indigo (default) | Indigo 500/400 | #6366f1 / #818cf8 |
|
||||
| Teal | Teal 500/400 | #14b8a6 / #2dd4bf |
|
||||
| Rose | Rose 500/400 | #f43f5e / #fb7185 |
|
||||
| Amber | Amber 500/400 | #f59e0b / #fbbf24 |
|
||||
| Emerald | Emerald 500/400 | #10b981 / #34d399 |
|
||||
| Sky | Sky 500/400 | #0ea5e9 / #38bdf8 |
|
||||
| Custom | User pick | Via `<input type="color">` |
|
||||
|
||||
Each preset has light/dark variants. The correct variant is selected based on current base theme.
|
||||
|
||||
---
|
||||
|
||||
## System Theme Option
|
||||
|
||||
**OBJECT ID:** `theme-system`
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Behavior | Follows OS `prefers-color-scheme` |
|
||||
| Matches | Light ↔ Light theme, Dark ↔ Dark theme |
|
||||
| Override | Selecting any specific theme disables system following |
|
||||
| Re-enable | Select "System (auto)" in picker |
|
||||
| Label | "⚙️ System (auto)" with current resolved theme name |
|
||||
|
||||
---
|
||||
|
||||
## Theme Application Flow
|
||||
|
||||
```
|
||||
1. User clicks theme → store in localStorage
|
||||
2. Apply CSS class to <html>: `data-theme="matrix"`
|
||||
3. CSS variables resolve from theme class
|
||||
4. All components instantly update (CSS-only, no React re-render)
|
||||
5. CodeMirror theme needs manual reconfiguration (dispatch theme compartment)
|
||||
6. PWA theme-color meta tag updates for status bar color
|
||||
```
|
||||
|
||||
### CodeMirror Theme Sync
|
||||
|
||||
| Step | Action |
|
||||
|------|--------|
|
||||
| 1 | Theme changes → dispatch new baseTheme to EditorView |
|
||||
| 2 | Syntax highlighting colors update via CSS variables (no extension swap) |
|
||||
| 3 | Stripe colors update via CSS |
|
||||
| 4 | Active line highlight updates via CSS |
|
||||
| 5 | Only the base theme extension needs reconfiguration |
|
||||
|
||||
---
|
||||
|
||||
## Responsive
|
||||
|
||||
| Breakpoint | Theme Picker |
|
||||
|------------|-------------|
|
||||
| >= 768px | Dropdown panel below header button |
|
||||
| < 768px | Bottom sheet (slides up from bottom, 60vh max height) |
|
||||
|
||||
### Mobile Bottom Sheet
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Width | 100vw |
|
||||
| Max height | 60vh |
|
||||
| Border radius | 12px 12px 0 0 (top corners) |
|
||||
| Drag handle | 32px × 4px pill, centered |
|
||||
| Backdrop | 50% black overlay |
|
||||
| Animation | Slide up 200ms ease-out |
|
||||
| Close | Swipe down, tap backdrop |
|
||||
|
||||
---
|
||||
|
||||
## Page States
|
||||
|
||||
| State | When | Behavior |
|
||||
|-------|------|----------|
|
||||
| **First launch** | No theme preference | Follow OS (system). If OS is dark, use Dark. |
|
||||
| **Theme selected** | User picked a theme | Applied instantly, persisted. System mode disabled. |
|
||||
| **System mode** | User selected "System (auto)" | Listens to OS changes in real-time. |
|
||||
| **Custom accent** | User picked accent color | Overrides accent tokens. Works with any base theme. |
|
||||
| **Matrix active** | Matrix theme selected | Font override applied. Scanline effect enabled. Green cursor glow. |
|
||||
|
||||
---
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- **No Flash of Unstyled Content (FOUC):** Load theme from localStorage in `<script>` in `<head>` BEFORE React mounts. Set `data-theme` on `<html>` synchronously.
|
||||
- **CSS structure:** Each theme is a `[data-theme="name"]` selector block overriding `:root` variables.
|
||||
- **Matrix performance:** Scanline effect uses `pointer-events: none` and is pure CSS. No performance impact.
|
||||
- **Cross-platform:** macOS/Windows use native appearance APIs. Theme names map to native equivalents. Matrix theme = custom dark palette on all platforms.
|
||||
- **PWA:** Update `<meta name="theme-color">` dynamically when theme changes for native status bar color.
|
||||
|
||||
---
|
||||
|
||||
_Created using Whiteport Design Studio (WDS) methodology_
|
||||
@@ -0,0 +1,278 @@
|
||||
# 6.1 — Mobile Experience
|
||||
|
||||
**Previous Step:** ← [Theme System](../../05-theming/5.1-theme-system/5.1-theme-system.md)
|
||||
|
||||
---
|
||||
|
||||
## Page Metadata
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Scenario** | 06 — Mobile Experience |
|
||||
| **Page Number** | 6.1 |
|
||||
| **Platform** | Web (PWA) — mobile viewport |
|
||||
| **Page Type** | Responsive Adaptation (all app shell components) |
|
||||
| **Viewport** | < 768px |
|
||||
| **Interaction** | Touch-first |
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
**Page Purpose:** Define how the entire CalcText workspace adapts to mobile. This is NOT a separate app — it's the same app intelligently restructured for touch and small screens. Every feature remains accessible; nothing is hidden or removed.
|
||||
|
||||
**Current Problem:** The existing web app hides results, toolbar, and divider on mobile — effectively removing the product's value on the most common device type.
|
||||
|
||||
**Success Criteria:**
|
||||
- All features accessible on mobile (calculations, results, file management, themes)
|
||||
- Touch targets >= 44px
|
||||
- One-handed operation possible for core flow (type → see result)
|
||||
- Feels like a native mobile app when installed as PWA
|
||||
|
||||
---
|
||||
|
||||
## Mobile Layout (< 768px)
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ Header [≡] CalcText [🎨]│ 44px (touch-sized)
|
||||
├─────────────────────────────┤
|
||||
│ Tab Bar (horizontal scroll) │ 40px
|
||||
├─────────────────────────────┤
|
||||
│ │
|
||||
│ │
|
||||
│ Editor │
|
||||
│ (full width) │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
├─────────────────────────────┤
|
||||
│ ═══ Results Tray │ 48px collapsed
|
||||
│ Last: $4,300 │
|
||||
├─────────────────────────────┤
|
||||
│ Status (simplified) │ 24px
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Adaptations
|
||||
|
||||
### Header (Mobile)
|
||||
|
||||
| Property | Desktop | Mobile |
|
||||
|----------|---------|--------|
|
||||
| Height | 40px | 44px (touch target) |
|
||||
| Left content | Logo + "CalcText" | [≡] hamburger + "CalcText" |
|
||||
| Right content | Theme + Settings + ⌘ | [🎨] theme only |
|
||||
| Padding | 12px 12px | 8px 12px |
|
||||
| Hamburger | N/A | 44px × 44px touch target, opens sidebar drawer |
|
||||
|
||||
### Tab Bar (Mobile)
|
||||
|
||||
| Property | Desktop | Mobile |
|
||||
|----------|---------|--------|
|
||||
| Height | 36px | 40px |
|
||||
| Tab min width | 100px | 80px (more compact) |
|
||||
| Close button | Visible on hover/active | Hidden — swipe left to reveal |
|
||||
| New tab (+) | After last tab | Sticky far-right |
|
||||
| Scroll | Mouse wheel | Touch swipe horizontal |
|
||||
| Active indicator | Top 2px accent border | Bottom 2px accent border (thumb reachable) |
|
||||
|
||||
### Editor (Mobile)
|
||||
|
||||
| Property | Desktop | Mobile |
|
||||
|----------|---------|--------|
|
||||
| Width | Flex (shared with results) | 100vw |
|
||||
| Line padding | 12px | 12px |
|
||||
| Gutter | 40px (line numbers) | 32px (compact numbers) |
|
||||
| Font size | 15px | 15px (same — readable on mobile) |
|
||||
| Line height | 24px | 24px (same) |
|
||||
| Keyboard | Physical | Virtual — editor scrolls above keyboard |
|
||||
|
||||
#### Virtual Keyboard Handling
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Viewport | Uses `100dvh` (dynamic viewport height) to account for keyboard |
|
||||
| Scroll | Editor auto-scrolls to keep cursor visible above keyboard |
|
||||
| Results tray | Hides when keyboard is open (not enough space) |
|
||||
| Status bar | Hides when keyboard is open |
|
||||
|
||||
### Results Tray (Mobile)
|
||||
|
||||
Replaces the side panel with a bottom tray.
|
||||
|
||||
| State | Height | Content |
|
||||
|-------|--------|---------|
|
||||
| **Collapsed** | 48px | Drag handle + last non-empty result |
|
||||
| **Expanded** | 40vh (max 60vh) | Full scrollable results list |
|
||||
| **Hidden** | 0px | When virtual keyboard is open |
|
||||
|
||||
#### Collapsed Tray
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Background | var(--bg-secondary) |
|
||||
| Border top | 1px solid var(--border) |
|
||||
| Drag handle | 32px × 4px pill, var(--border), centered |
|
||||
| Content | "Last: {value}" — last non-empty result, text-xs |
|
||||
| Tap | Expand tray |
|
||||
| Swipe up | Expand tray |
|
||||
|
||||
#### Expanded Tray
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Content | All results paired with expressions |
|
||||
| Item format | Line number + expression snippet + result value |
|
||||
| Item height | 44px (touch-friendly) |
|
||||
| Active line | var(--accent-bg) highlight |
|
||||
| Tap result | Copy to clipboard + brief feedback |
|
||||
| Swipe down | Collapse tray |
|
||||
| Tap drag handle | Collapse |
|
||||
| Scroll | Independent vertical scroll |
|
||||
|
||||
#### Tray Interaction
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ ═══ (drag handle) │ Swipe up to expand
|
||||
│ Last: $4,300 │
|
||||
├──────────────────────────────┤ ← expanded state below
|
||||
│ 5 salary 5,000 │
|
||||
│ 6 freelance 1,200 │
|
||||
│ 7 total_income 6,200 │ ← highlighted (active line)
|
||||
│ 9 rent 1,500 │
|
||||
│ 10 groceries 400 │
|
||||
│ 13 total_expenses 1,900 │
|
||||
│ 14 savings 4,300 │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
### Sidebar Drawer (Mobile)
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Trigger | Hamburger [≡] in header |
|
||||
| Width | 85vw, max 320px |
|
||||
| Position | Fixed left, full height |
|
||||
| Background | var(--bg) |
|
||||
| Backdrop | rgba(0, 0, 0, 0.5) — tap to close |
|
||||
| Animation | translateX(-100%) → translateX(0), 200ms ease-out |
|
||||
| Close | Tap backdrop, swipe left, × button (top-right, 44px target) |
|
||||
| Content | Same sections as desktop sidebar (search, recent, favorites, files, templates) |
|
||||
| File tap | Opens document → auto-closes drawer |
|
||||
| Search | Full width, 44px height (touch target) |
|
||||
| File items | 44px height (touch target, up from 28px desktop) |
|
||||
|
||||
### Theme Picker (Mobile)
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Trigger | [🎨] button in header |
|
||||
| Style | Bottom sheet (slides from bottom) |
|
||||
| Height | Auto (content-driven), max 60vh |
|
||||
| Border radius | 12px 12px 0 0 |
|
||||
| Drag handle | 32px × 4px pill, centered |
|
||||
| Items | 48px height per theme (touch targets) |
|
||||
| Accent swatches | 32px circles, 8px gap (larger for touch) |
|
||||
| Close | Swipe down, tap backdrop |
|
||||
|
||||
### Status Bar (Mobile)
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Height | 24px (same) |
|
||||
| Content | Cursor position + engine status dot only |
|
||||
| Hidden items | Line count, theme indicator (accessible elsewhere) |
|
||||
| Hidden | When virtual keyboard is open |
|
||||
|
||||
---
|
||||
|
||||
## Touch Gestures
|
||||
|
||||
| Gesture | Context | Action |
|
||||
|---------|---------|--------|
|
||||
| Swipe left on tab | Tab bar | Reveal close button |
|
||||
| Swipe up on results tray | Results | Expand tray |
|
||||
| Swipe down on results tray | Results | Collapse tray |
|
||||
| Swipe left from right edge | Sidebar drawer | Close drawer |
|
||||
| Swipe down on bottom sheet | Theme picker | Close sheet |
|
||||
| Long press on file | Sidebar | Show context menu |
|
||||
| Long press on tab | Tab bar | Drag to reorder |
|
||||
| Tap result | Results tray | Copy to clipboard |
|
||||
| Pinch | Editor | Zoom font size (optional) |
|
||||
|
||||
---
|
||||
|
||||
## Breakpoint Details
|
||||
|
||||
| Width | Classification | Key Adaptations |
|
||||
|-------|---------------|-----------------|
|
||||
| **>= 1024px** | Desktop | Full 3-panel layout |
|
||||
| **768–1023px** | Tablet | Sidebar → overlay drawer. Editor + results side-by-side. |
|
||||
| **480–767px** | Mobile | Single column. Results tray. Sidebar drawer. Touch targets 44px. |
|
||||
| **< 480px** | Small mobile | Same as mobile. Tab labels may truncate. Logo text hidden. |
|
||||
|
||||
### Tablet Specifics (768–1023px)
|
||||
|
||||
| Component | Behavior |
|
||||
|-----------|----------|
|
||||
| Sidebar | Overlay drawer (hamburger toggle) instead of persistent panel |
|
||||
| Editor + Results | Side-by-side with divider (same as desktop) |
|
||||
| Tab bar | Same as desktop (enough width) |
|
||||
| Header | Show hamburger [≡] instead of persistent sidebar |
|
||||
| Theme picker | Dropdown (same as desktop) |
|
||||
|
||||
---
|
||||
|
||||
## PWA Mobile Enhancements
|
||||
|
||||
| Feature | Implementation |
|
||||
|---------|---------------|
|
||||
| Standalone display | `display: standalone` — no browser chrome |
|
||||
| Status bar color | `theme-color` meta tag updates per theme |
|
||||
| Safe areas | `env(safe-area-inset-*)` for notched devices |
|
||||
| Splash screen | Theme-colored background + CalcText logo |
|
||||
| Home screen icon | App icon with accent color ring |
|
||||
| Orientation | Portrait preferred, landscape supported |
|
||||
|
||||
### Safe Area Padding
|
||||
|
||||
```css
|
||||
.calcpad-app {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Page States (Mobile-Specific)
|
||||
|
||||
| State | When | Behavior |
|
||||
|-------|------|----------|
|
||||
| **Keyboard open** | User tapped editor | Results tray + status bar hide. Editor fills available space. |
|
||||
| **Keyboard closed** | User tapped outside or pressed Done | Results tray + status bar reappear. |
|
||||
| **Drawer open** | Hamburger tapped | Sidebar overlays. Backdrop captures tap to close. |
|
||||
| **Tray expanded** | User swiped up | 40vh results list. Editor partially visible above. |
|
||||
| **Offline** | No network | Status bar shows offline indicator. All features work. |
|
||||
|
||||
---
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- **Viewport units:** Use `dvh` (dynamic viewport height) not `vh` to handle mobile browser chrome and virtual keyboard.
|
||||
- **Touch events:** Use `touchstart`/`touchmove`/`touchend` for swipe gestures. Consider `passive: true` for scroll performance.
|
||||
- **Overscroll:** Disable `overscroll-behavior: none` on app container to prevent pull-to-refresh interference.
|
||||
- **iOS safe areas:** Test with iPhone notch and dynamic island. Apply `env(safe-area-inset-*)`.
|
||||
- **Android back button:** In PWA mode, back button should close drawers/sheets before navigating back.
|
||||
- **Font scaling:** Respect system font size on mobile. Use relative units where possible.
|
||||
- **Cross-platform:** Mobile web layout does NOT need to port to native mobile (that's a separate app). But interaction patterns (swipe, long-press) inform native mobile design if built later.
|
||||
|
||||
---
|
||||
|
||||
_Created using Whiteport Design Studio (WDS) methodology_
|
||||
104
_bmad-output/D-Design-System/00-design-system.md
Normal file
104
_bmad-output/D-Design-System/00-design-system.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Design System: CalcText
|
||||
|
||||
> Components, tokens, and patterns that grow from actual usage — not upfront planning.
|
||||
|
||||
**Created:** 2026-03-17
|
||||
**Phase:** 7 — Design System (optional)
|
||||
**Agent:** Freya (Designer)
|
||||
|
||||
---
|
||||
|
||||
## What Belongs Here
|
||||
|
||||
The Design System captures reusable patterns that emerge during UX Design (Phase 4). It is not designed upfront — it crystallizes from real page specifications.
|
||||
|
||||
**What goes here:**
|
||||
- **Design Tokens** — Colors, spacing, typography, shadows
|
||||
- **Components** — Buttons, inputs, cards, navigation elements
|
||||
- **Patterns** — Layouts, form structures, content blocks
|
||||
- **Visual Design** — Mood boards, design concepts, color and typography explorations
|
||||
- **Assets** — Logos, icons, images, graphics
|
||||
|
||||
---
|
||||
|
||||
## Folder Structure
|
||||
|
||||
```
|
||||
D-Design-System/
|
||||
├── 00-design-system.md <- This file (hub + guide)
|
||||
├── 01-Visual-Design/
|
||||
│ ├── mood-boards/
|
||||
│ ├── design-concepts/
|
||||
│ ├── color-exploration/
|
||||
│ └── typography-tests/
|
||||
├── 02-Assets/
|
||||
│ ├── logos/
|
||||
│ ├── icons/
|
||||
│ ├── images/
|
||||
│ └── graphics/
|
||||
└── components/
|
||||
├── interactive/
|
||||
├── form/
|
||||
├── layout/
|
||||
├── content/
|
||||
├── feedback/
|
||||
└── navigation/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spacing Scale
|
||||
|
||||
_Will be defined during first design session._
|
||||
|
||||
| Token | Value | Use |
|
||||
|-------|-------|-----|
|
||||
| space-3xs | — | Hairline gaps |
|
||||
| space-2xs | — | Minimal spacing |
|
||||
| space-xs | — | Tight spacing |
|
||||
| space-sm | — | Small gaps |
|
||||
| **space-md** | — | **Default element spacing** |
|
||||
| space-lg | — | Comfortable spacing |
|
||||
| space-xl | — | Section padding |
|
||||
| space-2xl | — | Section gaps |
|
||||
| space-3xl | — | Page-level breathing room |
|
||||
|
||||
---
|
||||
|
||||
## Type Scale
|
||||
|
||||
_Will be defined during first design session._
|
||||
|
||||
| Token | Value | Use |
|
||||
|-------|-------|-----|
|
||||
| text-3xs | — | Fine print |
|
||||
| text-2xs | — | Metadata |
|
||||
| text-xs | — | Captions |
|
||||
| text-sm | — | Labels |
|
||||
| text-md | — | Body text |
|
||||
| text-lg | — | Emphasis |
|
||||
| text-xl | — | Subheadings |
|
||||
| text-2xl | — | Section titles |
|
||||
| text-3xl | — | Hero headings |
|
||||
|
||||
---
|
||||
|
||||
## Tokens
|
||||
|
||||
_Additional design tokens will be documented here as they emerge from page specifications._
|
||||
|
||||
---
|
||||
|
||||
## Patterns
|
||||
|
||||
_Patterns will be documented here as spacing objects recur across pages._
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
_Components will be documented here as patterns emerge across scenarios._
|
||||
|
||||
---
|
||||
|
||||
_Created using Whiteport Design Studio (WDS) methodology_
|
||||
117
_bmad-output/_progress/00-design-log.md
Normal file
117
_bmad-output/_progress/00-design-log.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# Design Log
|
||||
|
||||
**Project:** CalcText
|
||||
**Started:** 2026-03-17
|
||||
**Method:** Whiteport Design Studio (WDS)
|
||||
|
||||
---
|
||||
|
||||
## Backlog
|
||||
|
||||
> Business-value items. Add links to detail files if needed.
|
||||
|
||||
- [x] Create simplified product brief (brownfield) — Phase 1
|
||||
- [x] Define workspace scenarios — Phase 3
|
||||
- [x] Design app shell (3-panel layout: sidebar + editor + results) — Phase 4
|
||||
- [x] Design theme engine (Light, Dark, Matrix, custom) — Phase 4
|
||||
- [x] Design tab system for multi-document — Phase 4
|
||||
- [x] Design file sidebar with folders — Phase 4
|
||||
- [x] Design elevated results panel — Phase 4
|
||||
- [x] Design mobile experience — Phase 4
|
||||
|
||||
---
|
||||
|
||||
## Current
|
||||
|
||||
| Task | Started | Agent |
|
||||
|------|---------|-------|
|
||||
| — | — | — |
|
||||
|
||||
> Brownfield codebase scan completed 2026-03-17. See `A-Product-Brief/01-brownfield-analysis.md`
|
||||
|
||||
**Rules:** Mark what you start. Complete it when done (move to Log). One task at a time per agent.
|
||||
|
||||
---
|
||||
|
||||
## Design Loop Status
|
||||
|
||||
> Per-page design progress. Updated by agents at every design transition.
|
||||
|
||||
| Scenario | Step | Page | Status | Updated |
|
||||
|----------|------|------|--------|---------|
|
||||
| 01-workspace-shell | 1.1 | App Shell | specified | 2026-03-17 |
|
||||
| 01-workspace-shell | 1.2 | Status Bar | specified | 2026-03-17 |
|
||||
| 02-calc-experience | 2.1 | Editor | specified | 2026-03-17 |
|
||||
| 02-calc-experience | 2.2 | Results Panel | specified | 2026-03-17 |
|
||||
| 03-doc-management | 3.1 | Tab Bar & Lifecycle | specified | 2026-03-17 |
|
||||
| 04-file-org | 4.1 | Sidebar | specified | 2026-03-17 |
|
||||
| 04-file-org | 4.2 | Templates | specified | 2026-03-17 |
|
||||
| 05-theming | 5.1 | Theme System | specified | 2026-03-17 |
|
||||
| 06-mobile | 6.1 | Mobile Shell | specified | 2026-03-17 |
|
||||
|
||||
**Status values:** `discussed` -> `wireframed` -> `specified` -> `explored` -> `building` -> `built` -> `approved` | `removed`
|
||||
|
||||
**How to use:**
|
||||
- **Append a row** when a page reaches a new status (do not overwrite — latest row per page is current status)
|
||||
- **Read on startup** to see where the project stands and what to suggest next
|
||||
|
||||
---
|
||||
|
||||
## Log
|
||||
|
||||
### 2026-03-18 — Full workspace implementation complete
|
||||
- Theme system: 5 presets (Light, Dark, Matrix, Midnight, Warm) + 6 accent colors
|
||||
- Document model with localStorage persistence
|
||||
- Tab bar with multi-document support, rename, keyboard shortcuts
|
||||
- Sidebar with search, recent, favorites, folders, templates, drag-and-drop
|
||||
- 5 templates: Budget, Invoice, Unit Converter, Trip Planner, Loan Calculator
|
||||
- Status bar with cursor position, engine status, dedication to Igor Cassel
|
||||
- Results panel redesign: type-specific colors, click-to-copy
|
||||
- Format toolbar: H, B, I, //, color labels
|
||||
- Live Preview toggle (markdown → formatted)
|
||||
- Syntax highlighting using theme CSS variables
|
||||
- Error hover tooltips
|
||||
- Mobile experience: bottom results tray, sidebar drawer, touch targets, safe areas
|
||||
- Alignment toolbar re-integrated in header
|
||||
|
||||
### 2026-03-17 — All 6 scenarios designed (Phase 4 complete)
|
||||
- 8 page specifications created across 6 scenarios
|
||||
- App Shell, Status Bar, Editor, Results Panel, Tab Bar, Sidebar, Templates, Theme System, Mobile
|
||||
- 5 preset themes defined (Light, Dark, Matrix, Midnight, Warm)
|
||||
- 7 accent color presets
|
||||
- Complete localStorage schema
|
||||
- Mobile experience redesigned: bottom results tray, sidebar drawer, touch gestures
|
||||
- 14 keyboard shortcuts defined
|
||||
- 5 templates with full content
|
||||
|
||||
### 2026-03-17 — Simplified product brief completed (Phase 1)
|
||||
- Scope: workspace evolution (themes, tabs, sidebar, persistence)
|
||||
- Strategy: web-first, then replicate to macOS and Windows
|
||||
- Deferred: accounts, cloud sync, collaboration (backend needed)
|
||||
- Saved to `A-Product-Brief/project-brief.md`
|
||||
|
||||
### 2026-03-17 — Brownfield codebase scan completed
|
||||
- Full web app scan: 6 components, 4 editor extensions, 3 hooks, 15 CSS tokens
|
||||
- Cross-platform comparison with macOS native app
|
||||
- Key findings: results panel too loud, no manual dark mode toggle, mobile experience gutted, no document model
|
||||
- Analysis saved to `A-Product-Brief/01-brownfield-analysis.md`
|
||||
|
||||
### 2026-03-17 — Project initialized (Phase 0)
|
||||
- Type: brownfield
|
||||
- Complexity: complex (web application)
|
||||
- Tech stack: React 19 + CodeMirror 6 + Vite 7 + WASM
|
||||
- Component library: Custom CSS (CSS custom properties)
|
||||
- Brief level: simplified
|
||||
- Stakes: enterprise/high
|
||||
- Involvement: autonomous execution
|
||||
- Brainstorm session established product vision: evolve from single-document calculator to full workspace with themes, tabs, sidebar, file management
|
||||
|
||||
---
|
||||
|
||||
## About This Folder
|
||||
|
||||
- **This file** — Single source of truth for project progress
|
||||
- **agent-experiences/** — Compressed insights from design discussions (dated files)
|
||||
- **wds-project-outline.yaml** — Project configuration from Phase 0 setup
|
||||
|
||||
**Do not modify `wds-project-outline.yaml`** — it is the source of truth for project configuration.
|
||||
50
_bmad-output/_progress/wds-project-outline.yaml
Normal file
50
_bmad-output/_progress/wds-project-outline.yaml
Normal file
@@ -0,0 +1,50 @@
|
||||
# WDS Project Outline
|
||||
# Generated: 2026-03-17
|
||||
# Phase 0: Project Setup
|
||||
|
||||
project_name: CalcText
|
||||
project_type: brownfield
|
||||
product_complexity: complex
|
||||
tech_stack: react
|
||||
component_library: custom
|
||||
root_folder: "_bmad-output"
|
||||
brief_level: simplified
|
||||
design_system_mode: recommended
|
||||
|
||||
existing_materials:
|
||||
has_materials: false
|
||||
notes: "Brainstorm insights from party mode session available in conversation context"
|
||||
|
||||
project_context:
|
||||
stakes: enterprise
|
||||
description: "Cross-platform notepad calculator evolving into a full workspace application"
|
||||
|
||||
working_relationship:
|
||||
involvement: autonomous
|
||||
user_role: project_manager
|
||||
recommendation_style: direct_guidance
|
||||
stakeholder_notes: null
|
||||
|
||||
# Brownfield Context
|
||||
brownfield:
|
||||
existing_product: "CalcText Web App"
|
||||
tech_stack_detail: "React 19 + CodeMirror 6 + Vite 7 + WASM engine"
|
||||
current_state: "Two-column editor (input + results), PWA, light/dark theme"
|
||||
evolution_goals:
|
||||
- "Multi-theme system (Light, Dark, Matrix, custom)"
|
||||
- "Tab system for multiple calctext documents"
|
||||
- "File sidebar with folders and organization"
|
||||
- "User accounts and cloud persistence (deferred - backend)"
|
||||
- "Elevated results panel design"
|
||||
- "Mobile experience redesign"
|
||||
|
||||
# Phase Routing
|
||||
phases:
|
||||
phase_0: completed
|
||||
phase_1: "simplified brief (brownfield)"
|
||||
phase_2: skipped
|
||||
phase_3: "scenarios from brownfield analysis"
|
||||
phase_4: "UX design with Freya"
|
||||
phase_5: "custom component development"
|
||||
phase_7: "design system extraction"
|
||||
phase_8: "product evolution entry point"
|
||||
95
_scripts/smoke-test.sh
Executable file
95
_scripts/smoke-test.sh
Executable file
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# smoke-test.sh — Quick verification of CalcPad CLI and web app
|
||||
set -euo pipefail
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
SKIP=0
|
||||
|
||||
pass() { echo " PASS: $1"; ((PASS++)); }
|
||||
fail() { echo " FAIL: $1"; ((FAIL++)); }
|
||||
skip() { echo " SKIP: $1"; ((SKIP++)); }
|
||||
|
||||
# ─── CLI Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo "=== CLI Tests ==="
|
||||
|
||||
if command -v cargo &>/dev/null; then
|
||||
|
||||
# 1. Basic arithmetic
|
||||
result=$(cargo run -q -p calcpad-cli -- "2 + 3" 2>/dev/null)
|
||||
if [[ "$result" == *"5"* ]]; then pass "arithmetic: 2 + 3 = 5"
|
||||
else fail "arithmetic: expected 5, got '$result'"; fi
|
||||
|
||||
# 2. Pipe / stdin
|
||||
result=$(echo -e "x = 10\nx * 3" | cargo run -q -p calcpad-cli 2>/dev/null)
|
||||
if [[ "$result" == *"30"* ]]; then pass "pipe/stdin: x=10, x*3 = 30"
|
||||
else fail "pipe/stdin: expected 30 in output, got '$result'"; fi
|
||||
|
||||
# 3. JSON output
|
||||
result=$(cargo run -q -p calcpad-cli -- --format json "42" 2>/dev/null)
|
||||
if [[ "$result" == *"42"* ]] && [[ "$result" == *"{"* ]]; then pass "JSON output"
|
||||
else fail "JSON output: expected JSON with 42, got '$result'"; fi
|
||||
|
||||
# 4. Error exit code for invalid input
|
||||
if cargo run -q -p calcpad-cli -- "???invalid???" &>/dev/null; then
|
||||
fail "error exit code: expected non-zero exit"
|
||||
else
|
||||
pass "error exit code: non-zero on bad input"
|
||||
fi
|
||||
|
||||
# 5. cargo test suite
|
||||
if cargo test --workspace -q 2>/dev/null; then pass "cargo test --workspace"
|
||||
else fail "cargo test --workspace"; fi
|
||||
|
||||
else
|
||||
skip "CLI tests (cargo not found)"
|
||||
fi
|
||||
|
||||
# ─── Web Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo "=== Web Tests ==="
|
||||
|
||||
WEB_URL="${WEB_URL:-http://localhost:8080}"
|
||||
|
||||
if curl -sf "$WEB_URL" &>/dev/null; then
|
||||
|
||||
# 1. index.html returns 200
|
||||
status=$(curl -sf -o /dev/null -w "%{http_code}" "$WEB_URL/")
|
||||
if [[ "$status" == "200" ]]; then pass "index.html 200"
|
||||
else fail "index.html: got HTTP $status"; fi
|
||||
|
||||
# 2. HTML contains expected content
|
||||
body=$(curl -sf "$WEB_URL/")
|
||||
if [[ "$body" == *"CalcPad"* ]]; then pass "HTML contains CalcPad"
|
||||
else fail "HTML missing CalcPad title"; fi
|
||||
|
||||
# 3. WASM JS glue is served
|
||||
wasm_js_status=$(curl -sf -o /dev/null -w "%{http_code}" "$WEB_URL/wasm/calcpad_wasm.js")
|
||||
if [[ "$wasm_js_status" == "200" ]]; then pass "WASM JS served"
|
||||
else fail "WASM JS: got HTTP $wasm_js_status"; fi
|
||||
|
||||
# 4. WASM binary content-type
|
||||
ct=$(curl -sf -o /dev/null -w "%{content_type}" "$WEB_URL/wasm/calcpad_wasm_bg.wasm")
|
||||
if [[ "$ct" == *"wasm"* ]]; then pass "WASM content-type: $ct"
|
||||
else fail "WASM content-type: expected wasm, got '$ct'"; fi
|
||||
|
||||
# 5. SPA fallback — non-existent route returns index.html
|
||||
fallback=$(curl -sf "$WEB_URL/nonexistent/route")
|
||||
if [[ "$fallback" == *"CalcPad"* ]]; then pass "SPA fallback"
|
||||
else fail "SPA fallback: did not return index.html"; fi
|
||||
|
||||
else
|
||||
skip "Web tests ($WEB_URL not reachable — run 'docker compose up -d' first)"
|
||||
fi
|
||||
|
||||
# ─── Summary ────────────────────────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo "=== Summary ==="
|
||||
echo " Passed: $PASS Failed: $FAIL Skipped: $SKIP"
|
||||
|
||||
if [[ $FAIL -gt 0 ]]; then exit 1; fi
|
||||
exit 0
|
||||
@@ -51,13 +51,10 @@ fn is_error(result: &CalcResult) -> bool {
|
||||
}
|
||||
|
||||
fn is_empty_error(result: &CalcResult) -> bool {
|
||||
// The engine returns CalcResult::error("empty input", ..) or
|
||||
// CalcResult::error("no expression found", ..) for blank/comment lines.
|
||||
if let CalcValue::Error { ref message, .. } = result.value {
|
||||
message == "empty input" || message == "no expression found"
|
||||
} else {
|
||||
false
|
||||
}
|
||||
matches!(
|
||||
result.metadata.result_type,
|
||||
ResultType::Empty | ResultType::NonCalculable
|
||||
)
|
||||
}
|
||||
|
||||
fn error_message(result: &CalcResult) -> Option<&str> {
|
||||
|
||||
@@ -6,13 +6,17 @@ edition = "2021"
|
||||
[lib]
|
||||
crate-type = ["cdylib", "staticlib", "rlib"]
|
||||
|
||||
[features]
|
||||
default = ["network"]
|
||||
network = ["ureq"]
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
chrono-tz = "0.10"
|
||||
dashu = "0.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
ureq = { version = "2", features = ["json"] }
|
||||
ureq = { version = "2", features = ["json"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -137,6 +137,7 @@ impl CryptoProvider {
|
||||
/// Refresh rates from the CoinGecko API.
|
||||
/// Returns Ok(()) on success, Err with message on failure.
|
||||
/// Falls back to cached rates if the API call fails.
|
||||
#[cfg(feature = "network")]
|
||||
pub fn refresh(&mut self) -> Result<(), String> {
|
||||
if !self.is_stale() {
|
||||
return Ok(());
|
||||
@@ -163,7 +164,18 @@ impl CryptoProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh is unavailable without the `network` feature.
|
||||
#[cfg(not(feature = "network"))]
|
||||
pub fn refresh(&mut self) -> Result<(), String> {
|
||||
if self.cache.is_some() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Network support not available".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch rates from CoinGecko API.
|
||||
#[cfg(feature = "network")]
|
||||
fn fetch_from_api(&self) -> Result<HashMap<String, CryptoRate>, String> {
|
||||
let url = format!(
|
||||
"{}/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=100&page=1&sparkline=false",
|
||||
|
||||
@@ -16,6 +16,7 @@ use std::collections::HashMap;
|
||||
// Provider implementations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(feature = "network")]
|
||||
/// Open Exchange Rates API provider (<https://openexchangerates.org>).
|
||||
///
|
||||
/// Requires an API key. Free tier provides 1,000 requests/month with USD base.
|
||||
@@ -24,6 +25,7 @@ pub struct OpenExchangeRatesProvider {
|
||||
api_key: String,
|
||||
}
|
||||
|
||||
#[cfg(feature = "network")]
|
||||
impl OpenExchangeRatesProvider {
|
||||
pub fn new(api_key: &str) -> Self {
|
||||
Self {
|
||||
@@ -32,6 +34,7 @@ impl OpenExchangeRatesProvider {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "network")]
|
||||
impl CurrencyProvider for OpenExchangeRatesProvider {
|
||||
fn fetch_rates(&self) -> Result<ExchangeRates, CurrencyError> {
|
||||
let url = format!(
|
||||
@@ -73,6 +76,7 @@ impl CurrencyProvider for OpenExchangeRatesProvider {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "network")]
|
||||
/// exchangerate.host API provider (<https://exchangerate.host>).
|
||||
///
|
||||
/// Free tier available; no API key required for basic usage.
|
||||
@@ -80,6 +84,7 @@ pub struct ExchangeRateHostProvider {
|
||||
api_key: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "network")]
|
||||
impl ExchangeRateHostProvider {
|
||||
pub fn new(api_key: Option<&str>) -> Self {
|
||||
Self {
|
||||
@@ -88,6 +93,7 @@ impl ExchangeRateHostProvider {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "network")]
|
||||
impl CurrencyProvider for ExchangeRateHostProvider {
|
||||
fn fetch_rates(&self) -> Result<ExchangeRates, CurrencyError> {
|
||||
let mut url = "https://api.exchangerate.host/live?source=USD".to_string();
|
||||
|
||||
@@ -13,9 +13,9 @@ pub mod rates;
|
||||
pub mod symbols;
|
||||
|
||||
pub use crypto::{CryptoProvider, CryptoProviderConfig, CryptoRate};
|
||||
pub use fiat::{
|
||||
ExchangeRateHostProvider, FiatCurrencyProvider, OpenExchangeRatesProvider, fallback_rates,
|
||||
};
|
||||
#[cfg(feature = "network")]
|
||||
pub use fiat::{ExchangeRateHostProvider, OpenExchangeRatesProvider};
|
||||
pub use fiat::{FiatCurrencyProvider, fallback_rates};
|
||||
pub use rates::{ExchangeRateCache, ExchangeRates, ProviderConfig, RateMetadata, RateSource};
|
||||
pub use symbols::{is_currency_code, is_crypto_symbol, resolve_currency};
|
||||
|
||||
|
||||
@@ -215,6 +215,14 @@ impl Parser {
|
||||
Ok(Spanned::new(ExprKind::PrevRef, span))
|
||||
}
|
||||
|
||||
TokenKind::Percent(val) => {
|
||||
// Standalone percentage: 8% = 0.08
|
||||
let val = *val;
|
||||
let span = tok.span;
|
||||
self.advance();
|
||||
Ok(Spanned::new(ExprKind::Number(val / 100.0), span))
|
||||
}
|
||||
|
||||
_ => Err(ParseError::new(
|
||||
format!("unexpected token: {:?}", tok.kind),
|
||||
tok.span,
|
||||
|
||||
@@ -4,12 +4,19 @@ use crate::lexer::tokenize;
|
||||
use crate::parser::parse;
|
||||
use crate::span::Span;
|
||||
use crate::types::CalcResult;
|
||||
use crate::variables::aggregators;
|
||||
|
||||
/// Evaluate a single line of input and return the result.
|
||||
pub fn eval_line(input: &str, ctx: &mut EvalContext) -> CalcResult {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return CalcResult::error("empty input", Span::new(0, 0));
|
||||
return CalcResult::empty(Span::new(0, 0));
|
||||
}
|
||||
|
||||
// Detect headings (# Title) and aggregator keywords (sum, total, etc.)
|
||||
// before tokenizing — the lexer misinterprets `#` as a line reference prefix.
|
||||
if aggregators::is_heading(trimmed) || aggregators::detect_aggregator(trimmed).is_some() {
|
||||
return CalcResult::non_calculable(Span::new(0, trimmed.len()));
|
||||
}
|
||||
|
||||
let tokens = tokenize(trimmed);
|
||||
@@ -24,7 +31,7 @@ pub fn eval_line(input: &str, ctx: &mut EvalContext) -> CalcResult {
|
||||
)
|
||||
});
|
||||
if !has_expr {
|
||||
return CalcResult::error("no expression found", Span::new(0, trimmed.len()));
|
||||
return CalcResult::non_calculable(Span::new(0, trimmed.len()));
|
||||
}
|
||||
|
||||
match parse(tokens) {
|
||||
|
||||
@@ -217,7 +217,7 @@ impl SheetContext {
|
||||
|
||||
// Heading lines produce no result -- skip them
|
||||
if entry_is_heading {
|
||||
let result = CalcResult::error("no expression found", Span::new(0, entry_source.len()));
|
||||
let result = CalcResult::non_calculable(Span::new(0, entry_source.len()));
|
||||
ordered_results.push(result.clone());
|
||||
ordered_sources.push(entry_source);
|
||||
self.results.insert(idx, result);
|
||||
@@ -251,7 +251,7 @@ impl SheetContext {
|
||||
// Store as __line_N for line reference support
|
||||
ctx.set_variable(&format!("__line_{}", idx + 1), result.clone());
|
||||
// Update __prev for prev/ans support
|
||||
if result.result_type() != crate::types::ResultType::Error {
|
||||
if result.is_calculable() {
|
||||
ctx.set_variable("__prev", result.clone());
|
||||
}
|
||||
ordered_results.push(result);
|
||||
@@ -261,7 +261,11 @@ impl SheetContext {
|
||||
|
||||
// Text/empty lines produce no result -- skip them
|
||||
if entry_is_text || entry_source.trim().is_empty() {
|
||||
let result = CalcResult::error("no expression found", Span::new(0, entry_source.len()));
|
||||
let result = if entry_source.trim().is_empty() {
|
||||
CalcResult::empty(Span::new(0, 0))
|
||||
} else {
|
||||
CalcResult::non_calculable(Span::new(0, entry_source.len()))
|
||||
};
|
||||
ordered_results.push(result.clone());
|
||||
ordered_sources.push(entry_source);
|
||||
self.results.insert(idx, result);
|
||||
@@ -276,7 +280,7 @@ impl SheetContext {
|
||||
// Store as __line_N for line reference support (1-indexed)
|
||||
ctx.set_variable(&format!("__line_{}", idx + 1), result.clone());
|
||||
// Update __prev for prev/ans support (only for non-error results)
|
||||
if result.result_type() != crate::types::ResultType::Error {
|
||||
if result.is_calculable() {
|
||||
ctx.set_variable("__prev", result.clone());
|
||||
}
|
||||
ordered_results.push(result);
|
||||
@@ -299,7 +303,7 @@ impl SheetContext {
|
||||
}
|
||||
// Replay line ref and prev for cached results too
|
||||
ctx.set_variable(&format!("__line_{}", idx + 1), cached.clone());
|
||||
if cached.result_type() != crate::types::ResultType::Error {
|
||||
if cached.is_calculable() {
|
||||
ctx.set_variable("__prev", cached.clone());
|
||||
}
|
||||
ordered_results.push(cached);
|
||||
|
||||
@@ -12,6 +12,8 @@ pub enum ResultType {
|
||||
DateTime,
|
||||
TimeDelta,
|
||||
Boolean,
|
||||
Empty,
|
||||
NonCalculable,
|
||||
Error,
|
||||
}
|
||||
|
||||
@@ -24,6 +26,8 @@ impl fmt::Display for ResultType {
|
||||
ResultType::DateTime => write!(f, "DateTime"),
|
||||
ResultType::TimeDelta => write!(f, "TimeDelta"),
|
||||
ResultType::Boolean => write!(f, "Boolean"),
|
||||
ResultType::Empty => write!(f, "Empty"),
|
||||
ResultType::NonCalculable => write!(f, "NonCalculable"),
|
||||
ResultType::Error => write!(f, "Error"),
|
||||
}
|
||||
}
|
||||
@@ -168,6 +172,36 @@ impl CalcResult {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn empty(span: Span) -> Self {
|
||||
CalcResult {
|
||||
value: CalcValue::Error {
|
||||
message: "empty input".to_string(),
|
||||
span: span.into(),
|
||||
},
|
||||
metadata: ResultMetadata {
|
||||
span: span.into(),
|
||||
result_type: ResultType::Empty,
|
||||
display: String::new(),
|
||||
raw_value: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn non_calculable(span: Span) -> Self {
|
||||
CalcResult {
|
||||
value: CalcValue::Error {
|
||||
message: "no expression found".to_string(),
|
||||
span: span.into(),
|
||||
},
|
||||
metadata: ResultMetadata {
|
||||
span: span.into(),
|
||||
result_type: ResultType::NonCalculable,
|
||||
display: String::new(),
|
||||
raw_value: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(message: &str, span: Span) -> Self {
|
||||
CalcResult {
|
||||
value: CalcValue::Error {
|
||||
@@ -186,13 +220,34 @@ impl CalcResult {
|
||||
pub fn result_type(&self) -> ResultType {
|
||||
self.metadata.result_type
|
||||
}
|
||||
|
||||
/// Returns true if this result is a computed value (not empty, non-calculable, or error).
|
||||
pub fn is_calculable(&self) -> bool {
|
||||
!matches!(
|
||||
self.metadata.result_type,
|
||||
ResultType::Empty | ResultType::NonCalculable | ResultType::Error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_number(val: f64) -> String {
|
||||
if val == val.floor() && val.abs() < 1e15 {
|
||||
format!("{}", val as i64)
|
||||
} else {
|
||||
format!("{}", val)
|
||||
// Round to 10 significant figures to avoid f64 noise
|
||||
let abs = val.abs();
|
||||
let magnitude = if abs > 0.0 { abs.log10().floor() as i32 } else { 0 };
|
||||
let precision = (10 - magnitude - 1).max(0) as usize;
|
||||
let formatted = format!("{:.prec$}", val, prec = precision);
|
||||
// Strip trailing zeros after decimal point
|
||||
if formatted.contains('.') {
|
||||
formatted
|
||||
.trim_end_matches('0')
|
||||
.trim_end_matches('.')
|
||||
.to_string()
|
||||
} else {
|
||||
formatted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,14 +101,14 @@ fn pipeline_eval_division_by_zero() {
|
||||
fn pipeline_eval_empty_input() {
|
||||
let mut ctx = EvalContext::new();
|
||||
let result = eval_line("", &mut ctx);
|
||||
assert_eq!(result.result_type(), ResultType::Error);
|
||||
assert_eq!(result.result_type(), ResultType::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_eval_comment_only() {
|
||||
let mut ctx = EvalContext::new();
|
||||
let result = eval_line("// this is a comment", &mut ctx);
|
||||
assert_eq!(result.result_type(), ResultType::Error);
|
||||
assert_eq!(result.result_type(), ResultType::NonCalculable);
|
||||
}
|
||||
|
||||
// ===== Variable assignment tests (eval_sheet) =====
|
||||
|
||||
@@ -123,7 +123,7 @@ fn sheet_with_comments() {
|
||||
],
|
||||
&mut ctx,
|
||||
);
|
||||
assert_eq!(results[0].result_type(), ResultType::Error); // comment
|
||||
assert_eq!(results[0].result_type(), ResultType::NonCalculable); // comment
|
||||
assert_eq!(results[3].metadata.display, "2000");
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ fn sheet_with_empty_lines() {
|
||||
let mut ctx = EvalContext::new();
|
||||
let results = eval_sheet(&["x = 10", "", "y = x + 5"], &mut ctx);
|
||||
assert_eq!(results[0].metadata.display, "10");
|
||||
assert_eq!(results[1].result_type(), ResultType::Error); // empty line
|
||||
assert_eq!(results[1].result_type(), ResultType::Empty); // empty line
|
||||
assert_eq!(results[2].metadata.display, "15");
|
||||
}
|
||||
|
||||
|
||||
7
calcpad-macos/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
calcpad-macos/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
Binary file not shown.
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>CalcPad.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -3,7 +3,7 @@
|
||||
import PackageDescription
|
||||
|
||||
// Path to the Rust static library built by `cargo build --release`
|
||||
let rustLibPath = "../calcpad-engine/target/release"
|
||||
let rustLibPath = "../target/release"
|
||||
|
||||
let package = Package(
|
||||
name: "CalcPad",
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct CalcPadApp: App {
|
||||
init() {
|
||||
// Without a .app bundle, macOS treats the process as a background app
|
||||
// that cannot receive keyboard input. This makes it a regular foreground app.
|
||||
NSApplication.shared.setActivationPolicy(.regular)
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.onAppear {
|
||||
NSApplication.shared.activate(ignoringOtherApps: true)
|
||||
}
|
||||
}
|
||||
.defaultSize(width: 800, height: 600)
|
||||
}
|
||||
|
||||
@@ -3,12 +3,13 @@ import SwiftUI
|
||||
|
||||
/// Displays calculation results in a vertical column, one result per line,
|
||||
/// aligned to match the corresponding editor lines.
|
||||
/// Uses NSViewRepresentable wrapping NSScrollView + NSTextView for pixel-perfect
|
||||
/// line height alignment with the editor.
|
||||
/// Uses NSViewRepresentable wrapping NSScrollView + StripedTextView for
|
||||
/// pixel-perfect line height alignment with the editor, including zebra striping.
|
||||
struct AnswerColumnView: NSViewRepresentable {
|
||||
let results: [LineResult]
|
||||
@Binding var scrollOffset: CGFloat
|
||||
var font: NSFont
|
||||
var alignment: NSTextAlignment = .right
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
@@ -24,23 +25,23 @@ struct AnswerColumnView: NSViewRepresentable {
|
||||
scrollView.verticalScrollElasticity = .none
|
||||
scrollView.horizontalScrollElasticity = .none
|
||||
|
||||
let textView = NSTextView()
|
||||
let textView = StripedTextView()
|
||||
textView.isEditable = false
|
||||
textView.isSelectable = true
|
||||
textView.isRichText = true
|
||||
textView.usesFontPanel = false
|
||||
textView.drawsBackground = false
|
||||
textView.drawsBackground = true
|
||||
textView.backgroundColor = .clear
|
||||
|
||||
// Match editor text container settings for alignment
|
||||
textView.textContainer?.lineFragmentPadding = 4
|
||||
textView.textContainerInset = NSSize(width: 8, height: 8)
|
||||
|
||||
// Disable line wrapping to match editor behavior
|
||||
textView.isHorizontallyResizable = true
|
||||
textView.textContainer?.widthTracksTextView = false
|
||||
// Let the text container track the scroll view width
|
||||
textView.isHorizontallyResizable = false
|
||||
textView.textContainer?.widthTracksTextView = true
|
||||
textView.textContainer?.containerSize = NSSize(
|
||||
width: CGFloat.greatestFiniteMagnitude,
|
||||
width: 0,
|
||||
height: CGFloat.greatestFiniteMagnitude
|
||||
)
|
||||
textView.maxSize = NSSize(
|
||||
@@ -73,14 +74,15 @@ struct AnswerColumnView: NSViewRepresentable {
|
||||
let attributedString = NSMutableAttributedString()
|
||||
|
||||
let paragraphStyle = NSMutableParagraphStyle()
|
||||
paragraphStyle.alignment = .right
|
||||
paragraphStyle.alignment = alignment
|
||||
|
||||
let resultColor = NSColor.secondaryLabelColor
|
||||
let errorColor = NSColor.systemRed
|
||||
|
||||
for (index, lineResult) in results.enumerated() {
|
||||
let displayText = lineResult.result ?? ""
|
||||
let color = lineResult.isError ? errorColor : resultColor
|
||||
// Only show successful results — errors stay as underlines in the editor
|
||||
let displayText = lineResult.isError ? "" : (lineResult.result ?? "")
|
||||
let color = resultColor
|
||||
|
||||
let attributes: [NSAttributedString.Key: Any] = [
|
||||
.font: font,
|
||||
|
||||
@@ -1,6 +1,52 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - StripedTextView
|
||||
|
||||
/// NSTextView subclass that draws alternating row background stripes.
|
||||
/// Used by both the editor and answer column to provide zebra-striping
|
||||
/// that visually connects lines across panes.
|
||||
final class StripedTextView: NSTextView {
|
||||
/// Subtle stripe color for odd-numbered lines.
|
||||
static let stripeColor = NSColor.secondaryLabelColor.withAlphaComponent(0.04)
|
||||
|
||||
override func drawBackground(in rect: NSRect) {
|
||||
super.drawBackground(in: rect)
|
||||
|
||||
guard let layoutManager = layoutManager,
|
||||
let textContainer = textContainer else { return }
|
||||
|
||||
let stripeColor = Self.stripeColor
|
||||
|
||||
// We need the absolute line index (not just visible lines) so that
|
||||
// the stripe pattern stays consistent when scrolling and matches
|
||||
// the answer column. Walk all line fragments from the start of the
|
||||
// document, but only fill those that intersect the dirty rect.
|
||||
let fullGlyphRange = layoutManager.glyphRange(for: textContainer)
|
||||
var lineIndex = 0
|
||||
|
||||
layoutManager.enumerateLineFragments(forGlyphRange: fullGlyphRange) {
|
||||
fragmentRect, _, _, _, _ in
|
||||
|
||||
if lineIndex % 2 == 1 {
|
||||
var stripeRect = fragmentRect
|
||||
stripeRect.origin.x = 0
|
||||
stripeRect.size.width = self.bounds.width
|
||||
stripeRect.origin.y += self.textContainerInset.height
|
||||
|
||||
// Only draw if the stripe intersects the dirty rect
|
||||
if stripeRect.intersects(rect) {
|
||||
stripeColor.setFill()
|
||||
NSBezierPath.fill(stripeRect)
|
||||
}
|
||||
}
|
||||
lineIndex += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - EditorTextView
|
||||
|
||||
/// A SwiftUI wrapper around NSTextView for the editor pane.
|
||||
/// Uses NSViewRepresentable to bridge AppKit's NSTextView into SwiftUI,
|
||||
/// providing line-level control, scroll position access, and performance
|
||||
@@ -9,6 +55,7 @@ struct EditorTextView: NSViewRepresentable {
|
||||
@Binding var text: String
|
||||
@Binding var scrollOffset: CGFloat
|
||||
var font: NSFont
|
||||
var alignment: NSTextAlignment = .left
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
@@ -22,7 +69,7 @@ struct EditorTextView: NSViewRepresentable {
|
||||
scrollView.borderType = .noBorder
|
||||
scrollView.drawsBackground = false
|
||||
|
||||
let textView = NSTextView()
|
||||
let textView = StripedTextView()
|
||||
textView.isEditable = true
|
||||
textView.isSelectable = true
|
||||
textView.allowsUndo = true
|
||||
@@ -33,11 +80,11 @@ struct EditorTextView: NSViewRepresentable {
|
||||
textView.isAutomaticTextReplacementEnabled = false
|
||||
textView.isAutomaticSpellingCorrectionEnabled = false
|
||||
|
||||
// Disable line wrapping — horizontal scroll instead
|
||||
textView.isHorizontallyResizable = true
|
||||
textView.textContainer?.widthTracksTextView = false
|
||||
// Enable line wrapping — tracks text view width
|
||||
textView.isHorizontallyResizable = false
|
||||
textView.textContainer?.widthTracksTextView = true
|
||||
textView.textContainer?.containerSize = NSSize(
|
||||
width: CGFloat.greatestFiniteMagnitude,
|
||||
width: 0,
|
||||
height: CGFloat.greatestFiniteMagnitude
|
||||
)
|
||||
textView.maxSize = NSSize(
|
||||
@@ -49,9 +96,12 @@ struct EditorTextView: NSViewRepresentable {
|
||||
textView.font = font
|
||||
textView.textColor = .textColor
|
||||
textView.backgroundColor = .clear
|
||||
textView.drawsBackground = false
|
||||
textView.drawsBackground = true
|
||||
textView.insertionPointColor = .textColor
|
||||
|
||||
// Set alignment
|
||||
textView.alignment = alignment
|
||||
|
||||
// Set the text
|
||||
textView.string = text
|
||||
|
||||
@@ -84,11 +134,19 @@ struct EditorTextView: NSViewRepresentable {
|
||||
// Update font if changed
|
||||
if textView.font != font {
|
||||
textView.font = font
|
||||
// Reapply font to entire text storage
|
||||
let range = NSRange(location: 0, length: textView.textStorage?.length ?? 0)
|
||||
textView.textStorage?.addAttribute(.font, value: font, range: range)
|
||||
}
|
||||
|
||||
// Update alignment if changed
|
||||
if textView.alignment != alignment {
|
||||
textView.alignment = alignment
|
||||
let range = NSRange(location: 0, length: textView.textStorage?.length ?? 0)
|
||||
let paragraphStyle = NSMutableParagraphStyle()
|
||||
paragraphStyle.alignment = alignment
|
||||
textView.textStorage?.addAttribute(.paragraphStyle, value: paragraphStyle, range: range)
|
||||
}
|
||||
|
||||
// Update text only if it actually changed (avoid feedback loops)
|
||||
if textView.string != text {
|
||||
let selectedRanges = textView.selectedRanges
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import AppKit
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
/// The main two-column editor layout: text editor on the left, results on the right.
|
||||
/// Scrolling is synchronized between both columns.
|
||||
/// Includes a toolbar for justification toggles and alternating row striping.
|
||||
struct TwoColumnEditorView: View {
|
||||
@State private var text: String = ""
|
||||
@State private var text: String = "# CalcPad\n\n// Basic arithmetic\n2 + 3\n10 * 4.5\n100 / 7\n\n// Variables\nprice = 49.99\nquantity = 3\nsubtotal = price * quantity\n\n// Percentages\ntax = subtotal * 8%\ntotal = subtotal + tax\n\n// Functions\nsqrt(144)\n2 ^ 10\n"
|
||||
@State private var scrollOffset: CGFloat = 0
|
||||
@State private var results: [LineResult] = []
|
||||
@State private var evaluationTask: Task<Void, Never>?
|
||||
@State private var editorAlignment: NSTextAlignment = .left
|
||||
@State private var resultsAlignment: NSTextAlignment = .right
|
||||
|
||||
/// Uses the Rust FFI engine. Falls back to StubCalculationEngine if the Rust
|
||||
/// library is not linked (e.g., during UI-only development).
|
||||
@@ -18,36 +22,92 @@ struct TwoColumnEditorView: View {
|
||||
|
||||
/// Font that respects the user's accessibility / Dynamic Type settings.
|
||||
private var editorFont: NSFont {
|
||||
// Use the system's preferred monospaced font size, which scales with
|
||||
// Accessibility > Display > Text Size in System Settings (macOS 14+).
|
||||
let baseSize = NSFont.systemFontSize
|
||||
// Scale with accessibility settings via the body text style size
|
||||
let preferredSize = NSFont.preferredFont(forTextStyle: .body, options: [:]).pointSize
|
||||
// Use the larger of system default or accessibility-preferred size
|
||||
let size = max(baseSize, preferredSize)
|
||||
return NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Justification toolbar
|
||||
HStack(spacing: 0) {
|
||||
// Editor alignment buttons
|
||||
HStack(spacing: 4) {
|
||||
Button(action: { editorAlignment = .left }) {
|
||||
Image(systemName: "text.alignleft")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.foregroundColor(editorAlignment == .left ? .accentColor : .secondary)
|
||||
.help("Align editor text left")
|
||||
|
||||
Button(action: { editorAlignment = .center }) {
|
||||
Image(systemName: "text.aligncenter")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.foregroundColor(editorAlignment == .center ? .accentColor : .secondary)
|
||||
.help("Align editor text center")
|
||||
|
||||
Button(action: { editorAlignment = .right }) {
|
||||
Image(systemName: "text.alignright")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.foregroundColor(editorAlignment == .right ? .accentColor : .secondary)
|
||||
.help("Align editor text right")
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Results alignment buttons
|
||||
HStack(spacing: 4) {
|
||||
Button(action: { resultsAlignment = .left }) {
|
||||
Image(systemName: "text.alignleft")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.foregroundColor(resultsAlignment == .left ? .accentColor : .secondary)
|
||||
.help("Align results left")
|
||||
|
||||
Button(action: { resultsAlignment = .center }) {
|
||||
Image(systemName: "text.aligncenter")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.foregroundColor(resultsAlignment == .center ? .accentColor : .secondary)
|
||||
.help("Align results center")
|
||||
|
||||
Button(action: { resultsAlignment = .right }) {
|
||||
Image(systemName: "text.alignright")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.foregroundColor(resultsAlignment == .right ? .accentColor : .secondary)
|
||||
.help("Align results right")
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
|
||||
Divider()
|
||||
|
||||
HSplitView {
|
||||
// Left pane: Editor
|
||||
EditorTextView(
|
||||
text: $text,
|
||||
scrollOffset: $scrollOffset,
|
||||
font: editorFont
|
||||
font: editorFont,
|
||||
alignment: editorAlignment
|
||||
)
|
||||
.frame(minWidth: 200)
|
||||
|
||||
// Divider is automatic with HSplitView
|
||||
|
||||
// Right pane: Answer column
|
||||
AnswerColumnView(
|
||||
results: results,
|
||||
scrollOffset: $scrollOffset,
|
||||
font: editorFont
|
||||
font: editorFont,
|
||||
alignment: resultsAlignment
|
||||
)
|
||||
.frame(minWidth: 120, idealWidth: 200)
|
||||
}
|
||||
}
|
||||
.onChange(of: text) { _, newValue in
|
||||
scheduleEvaluation(newValue)
|
||||
}
|
||||
@@ -56,7 +116,6 @@ struct TwoColumnEditorView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Debounce evaluation so rapid typing doesn't cause excessive recalculation.
|
||||
private func scheduleEvaluation(_ newText: String) {
|
||||
evaluationTask?.cancel()
|
||||
evaluationTask = Task { @MainActor in
|
||||
|
||||
@@ -105,12 +105,12 @@ struct RustEngineTests {
|
||||
#expect(result.isError == false)
|
||||
}
|
||||
|
||||
@Test("# is not a comment in Rust engine — treated as identifier lookup")
|
||||
func hashNotComment() {
|
||||
@Test("# heading is treated as non-calculable, not an error")
|
||||
func hashHeading() {
|
||||
let result = engine.evaluateLine("# header")
|
||||
// The Rust engine does not treat # as a comment. The # is skipped and
|
||||
// "header" is parsed as an identifier, resulting in an undefined variable error.
|
||||
#expect(result.isError == true)
|
||||
// The Rust engine now detects headings (# followed by space) as non-calculable.
|
||||
#expect(result.isError == false)
|
||||
#expect(result.result == nil)
|
||||
}
|
||||
|
||||
// MARK: - AC: Memory safety — no leaks with repeated calls
|
||||
|
||||
@@ -8,13 +8,14 @@ description = "CalcPad calculation engine compiled to WebAssembly"
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
calcpad-engine = { path = "../calcpad-engine" }
|
||||
calcpad-engine = { path = "../calcpad-engine", default-features = false }
|
||||
wasm-bindgen = "0.2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde-wasm-bindgen = "0.6"
|
||||
js-sys = "0.3"
|
||||
chrono = "0.4"
|
||||
getrandom = { version = "0.2", features = ["js"] }
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
|
||||
@@ -29,6 +29,8 @@ impl From<&CalcResult> for JsResult {
|
||||
ResultType::DateTime => "dateTime",
|
||||
ResultType::TimeDelta => "timeDelta",
|
||||
ResultType::Boolean => "boolean",
|
||||
ResultType::Empty => "empty",
|
||||
ResultType::NonCalculable => "nonCalculable",
|
||||
ResultType::Error => "error",
|
||||
}
|
||||
.to_string();
|
||||
|
||||
@@ -10,7 +10,18 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="CalcPad" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.svg" />
|
||||
<title>CalcPad</title>
|
||||
<title>CalcText</title>
|
||||
<script>
|
||||
// Apply theme before React mounts to prevent FOUC
|
||||
(function() {
|
||||
var t = 'system';
|
||||
try { t = localStorage.getItem('calctext-theme') || 'system'; } catch(e) {}
|
||||
if (t === 'system') {
|
||||
t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,80 +1,338 @@
|
||||
/**
|
||||
* CalcPad main application component.
|
||||
* CalcText main application component.
|
||||
*
|
||||
* Two-column layout:
|
||||
* Left: CodeMirror 6 editor with CalcPad syntax highlighting
|
||||
* Right: Answer gutter (integrated into CodeMirror) + optional standalone AnswerColumn
|
||||
*
|
||||
* The WASM engine runs in a Web Worker. On each document change (debounced),
|
||||
* the editor sends lines to the worker, which evaluates them and posts back
|
||||
* results. Results are fed into the CodeMirror answer gutter extension.
|
||||
* Workspace layout: header → tab bar → editor + results panel.
|
||||
* Multi-document support via document store with localStorage persistence.
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useState, useRef, useEffect } from 'react'
|
||||
import type { EditorView } from '@codemirror/view'
|
||||
import { CalcEditor } from './editor/CalcEditor.tsx'
|
||||
import { useEngine } from './engine/useEngine.ts'
|
||||
import { useOnlineStatus } from './hooks/useOnlineStatus.ts'
|
||||
import { useInstallPrompt } from './hooks/useInstallPrompt.ts'
|
||||
import { useTheme } from './hooks/useTheme.ts'
|
||||
import { useDocumentStore, loadSidebarState, saveSidebarState } from './hooks/useDocumentStore.ts'
|
||||
import { OfflineBanner } from './components/OfflineBanner.tsx'
|
||||
import { InstallPrompt } from './components/InstallPrompt.tsx'
|
||||
import { ResultsPanel } from './components/ResultsPanel.tsx'
|
||||
import { ThemePicker } from './components/ThemePicker.tsx'
|
||||
import { TabBar } from './components/TabBar.tsx'
|
||||
import { Sidebar } from './components/Sidebar.tsx'
|
||||
import { StatusBar } from './components/StatusBar.tsx'
|
||||
import { AlignToolbar } from './components/AlignToolbar.tsx'
|
||||
import type { Alignment } from './components/AlignToolbar.tsx'
|
||||
import { FormatToolbar } from './components/FormatToolbar.tsx'
|
||||
import { MobileResultsTray } from './components/MobileResultsTray.tsx'
|
||||
import './styles/app.css'
|
||||
|
||||
const INITIAL_DOC = `# CalcPad
|
||||
|
||||
// Basic arithmetic
|
||||
2 + 3
|
||||
10 * 4.5
|
||||
100 / 7
|
||||
|
||||
// Variables
|
||||
price = 49.99
|
||||
quantity = 3
|
||||
subtotal = price * quantity
|
||||
|
||||
// Percentages
|
||||
tax = subtotal * 8%
|
||||
total = subtotal + tax
|
||||
|
||||
// Functions
|
||||
sqrt(144)
|
||||
2 ^ 10
|
||||
`
|
||||
|
||||
function App() {
|
||||
const engine = useEngine()
|
||||
const isOnline = useOnlineStatus()
|
||||
const installPrompt = useInstallPrompt()
|
||||
const themeCtx = useTheme()
|
||||
const store = useDocumentStore()
|
||||
|
||||
const [editorView, setEditorView] = useState<EditorView | null>(null)
|
||||
const resultsPanelRef = useRef<HTMLDivElement>(null)
|
||||
const [modifiedIds, setModifiedIds] = useState<Set<string>>(new Set())
|
||||
const [editorAlign, setEditorAlign] = useState<Alignment>('left')
|
||||
const [resultsAlign, setResultsAlign] = useState<Alignment>('right')
|
||||
const [formatPreview, setFormatPreview] = useState(true)
|
||||
|
||||
// Sidebar state
|
||||
const [sidebarState, setSidebarState] = useState(loadSidebarState)
|
||||
const setSidebarVisible = useCallback((v: boolean) => {
|
||||
const next = { ...sidebarState, visible: v }
|
||||
setSidebarState(next)
|
||||
saveSidebarState(next)
|
||||
}, [sidebarState])
|
||||
const setSidebarWidth = useCallback((w: number) => {
|
||||
const next = { ...sidebarState, width: w }
|
||||
setSidebarState(next)
|
||||
saveSidebarState(next)
|
||||
}, [sidebarState])
|
||||
|
||||
// Track a key to force CalcEditor remount on tab switch
|
||||
const [editorKey, setEditorKey] = useState(store.activeTabId)
|
||||
|
||||
// Draggable divider state
|
||||
const [dividerX, setDividerX] = useState<number | null>(null)
|
||||
const isDragging = useRef(false)
|
||||
|
||||
const handleDocChange = useCallback(
|
||||
(lines: string[]) => {
|
||||
engine.evalSheet(lines)
|
||||
// Persist content
|
||||
const content = lines.join('\n')
|
||||
if (store.activeDoc && content !== store.activeDoc.content) {
|
||||
store.updateContent(store.activeTabId, content)
|
||||
setModifiedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
next.add(store.activeTabId)
|
||||
return next
|
||||
})
|
||||
// Clear modified dot after save debounce
|
||||
setTimeout(() => {
|
||||
setModifiedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(store.activeTabId)
|
||||
return next
|
||||
})
|
||||
}, 500)
|
||||
}
|
||||
},
|
||||
[engine.evalSheet],
|
||||
[engine.evalSheet, store.activeTabId, store.activeDoc, store.updateContent],
|
||||
)
|
||||
|
||||
// Switch tabs
|
||||
const handleTabClick = useCallback((id: string) => {
|
||||
if (id === store.activeTabId) return
|
||||
store.setActiveTab(id)
|
||||
setEditorKey(id)
|
||||
}, [store.activeTabId, store.setActiveTab])
|
||||
|
||||
// New document
|
||||
const handleNewTab = useCallback(() => {
|
||||
const doc = store.createDocument()
|
||||
setEditorKey(doc.id)
|
||||
}, [store.createDocument])
|
||||
|
||||
// Close tab
|
||||
const handleTabClose = useCallback((id: string) => {
|
||||
store.closeTab(id)
|
||||
// If we closed the active tab, editorKey needs updating
|
||||
if (id === store.activeTabId) {
|
||||
// State will update, trigger effect below
|
||||
}
|
||||
}, [store.closeTab, store.activeTabId])
|
||||
|
||||
// Sync editorKey when activeTabId changes externally (e.g. after close)
|
||||
useEffect(() => {
|
||||
if (editorKey !== store.activeTabId) {
|
||||
setEditorKey(store.activeTabId)
|
||||
}
|
||||
}, [store.activeTabId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Apply editor text alignment via CodeMirror
|
||||
useEffect(() => {
|
||||
if (!editorView) return
|
||||
editorView.dom.style.setProperty('--cm-text-align', editorAlign)
|
||||
}, [editorView, editorAlign])
|
||||
|
||||
// Scroll sync: mirror editor scroll position to results panel
|
||||
useEffect(() => {
|
||||
if (!editorView) return
|
||||
const scroller = editorView.scrollDOM
|
||||
const onScroll = () => {
|
||||
if (resultsPanelRef.current) {
|
||||
resultsPanelRef.current.scrollTop = scroller.scrollTop
|
||||
}
|
||||
}
|
||||
scroller.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => scroller.removeEventListener('scroll', onScroll)
|
||||
}, [editorView])
|
||||
|
||||
// Draggable divider handlers
|
||||
const onDividerMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
isDragging.current = true
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (!isDragging.current) return
|
||||
setDividerX(e.clientX)
|
||||
}
|
||||
function onMouseUp() {
|
||||
if (!isDragging.current) return
|
||||
isDragging.current = false
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
const mod = e.metaKey || e.ctrlKey
|
||||
|
||||
// Ctrl+B — toggle sidebar
|
||||
if (mod && e.key === 'b') {
|
||||
e.preventDefault()
|
||||
setSidebarVisible(!sidebarState.visible)
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl+N — new document
|
||||
if (mod && e.key === 'n') {
|
||||
e.preventDefault()
|
||||
handleNewTab()
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl+W — close tab
|
||||
if (mod && e.key === 'w') {
|
||||
e.preventDefault()
|
||||
handleTabClose(store.activeTabId)
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl+Tab / Ctrl+Shift+Tab — cycle tabs
|
||||
if (mod && e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
const idx = store.openTabIds.indexOf(store.activeTabId)
|
||||
const len = store.openTabIds.length
|
||||
const next = e.shiftKey
|
||||
? store.openTabIds[(idx - 1 + len) % len]
|
||||
: store.openTabIds[(idx + 1) % len]
|
||||
handleTabClick(next)
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl+1-9 — jump to tab
|
||||
if (mod && e.key >= '1' && e.key <= '9') {
|
||||
e.preventDefault()
|
||||
const tabIdx = parseInt(e.key) - 1
|
||||
if (tabIdx < store.openTabIds.length) {
|
||||
handleTabClick(store.openTabIds[tabIdx])
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKey)
|
||||
return () => document.removeEventListener('keydown', handleKey)
|
||||
}, [store.activeTabId, store.openTabIds, handleNewTab, handleTabClose, handleTabClick, sidebarState.visible, setSidebarVisible])
|
||||
|
||||
// Compute flex styles from divider position
|
||||
const editorStyle: React.CSSProperties = dividerX !== null
|
||||
? { width: dividerX, flex: 'none' }
|
||||
: {}
|
||||
const resultsStyle: React.CSSProperties = dividerX !== null
|
||||
? { flex: 1 }
|
||||
: {}
|
||||
|
||||
return (
|
||||
<div className="calcpad-app">
|
||||
<OfflineBanner isOnline={isOnline} />
|
||||
|
||||
<header className="calcpad-header">
|
||||
<h1>CalcPad</h1>
|
||||
<p className="subtitle">Notepad Calculator</p>
|
||||
<div className="header-status">
|
||||
<span className={`status-dot ${engine.ready ? '' : 'loading'}`} />
|
||||
<span>{engine.ready ? 'Engine ready' : 'Loading engine...'}</span>
|
||||
<button
|
||||
className="header-sidebar-toggle"
|
||||
onClick={() => setSidebarVisible(!sidebarState.visible)}
|
||||
title="Toggle sidebar (Ctrl+B)"
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<h1>CalcText</h1>
|
||||
<div className="header-spacer" />
|
||||
<div className="header-actions">
|
||||
<FormatToolbar
|
||||
editorView={editorView}
|
||||
previewMode={formatPreview}
|
||||
onPreviewToggle={() => setFormatPreview(p => !p)}
|
||||
/>
|
||||
<div className="header-divider" />
|
||||
<AlignToolbar
|
||||
editorAlign={editorAlign}
|
||||
resultsAlign={resultsAlign}
|
||||
onEditorAlignChange={setEditorAlign}
|
||||
onResultsAlignChange={setResultsAlign}
|
||||
/>
|
||||
<ThemePicker
|
||||
theme={themeCtx.theme}
|
||||
accentColor={themeCtx.accentColor}
|
||||
onThemeChange={themeCtx.setTheme}
|
||||
onAccentChange={themeCtx.setAccent}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<TabBar
|
||||
tabs={store.openDocs}
|
||||
activeTabId={store.activeTabId}
|
||||
onTabClick={handleTabClick}
|
||||
onTabClose={handleTabClose}
|
||||
onTabRename={store.renameDocument}
|
||||
onNewTab={handleNewTab}
|
||||
modifiedIds={modifiedIds}
|
||||
/>
|
||||
|
||||
<div className="calcpad-workspace">
|
||||
{/* Mobile sidebar backdrop */}
|
||||
{sidebarState.visible && (
|
||||
<div
|
||||
className="sidebar-backdrop"
|
||||
onClick={() => setSidebarVisible(false)}
|
||||
/>
|
||||
)}
|
||||
<Sidebar
|
||||
visible={sidebarState.visible}
|
||||
width={sidebarState.width}
|
||||
documents={store.documents}
|
||||
folders={store.folders}
|
||||
activeTabId={store.activeTabId}
|
||||
openTabIds={store.openTabIds}
|
||||
onFileClick={(id) => { store.openDocument(id); setEditorKey(id) }}
|
||||
onNewDocument={(title, content) => {
|
||||
const doc = store.createDocument(title, content)
|
||||
setEditorKey(doc.id)
|
||||
}}
|
||||
onNewFolder={() => store.createFolder()}
|
||||
onRenameDocument={store.renameDocument}
|
||||
onDeleteDocument={store.deleteDocument}
|
||||
onToggleFavorite={store.toggleFavorite}
|
||||
onMoveToFolder={store.moveToFolder}
|
||||
onRenameFolder={store.renameFolder}
|
||||
onDeleteFolder={store.deleteFolder}
|
||||
onWidthChange={setSidebarWidth}
|
||||
/>
|
||||
|
||||
<main className="calcpad-editor">
|
||||
<div className="editor-pane">
|
||||
<div className="editor-pane" style={editorStyle}>
|
||||
<CalcEditor
|
||||
initialDoc={INITIAL_DOC}
|
||||
key={editorKey}
|
||||
initialDoc={store.activeDoc?.content ?? ''}
|
||||
onDocChange={handleDocChange}
|
||||
results={engine.results}
|
||||
debounceMs={50}
|
||||
onViewReady={setEditorView}
|
||||
formatPreview={formatPreview}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="pane-divider"
|
||||
onMouseDown={onDividerMouseDown}
|
||||
/>
|
||||
<ResultsPanel
|
||||
ref={resultsPanelRef}
|
||||
results={engine.results}
|
||||
align={resultsAlign}
|
||||
style={resultsStyle}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<MobileResultsTray
|
||||
results={engine.results}
|
||||
docLines={store.activeDoc?.content.split('\n') ?? []}
|
||||
/>
|
||||
|
||||
<StatusBar
|
||||
editorView={editorView}
|
||||
engineReady={engine.ready}
|
||||
lineCount={store.activeDoc?.content.split('\n').length ?? 0}
|
||||
/>
|
||||
|
||||
<InstallPrompt
|
||||
promptEvent={installPrompt.promptEvent}
|
||||
|
||||
84
calcpad-web/src/components/AlignToolbar.tsx
Normal file
84
calcpad-web/src/components/AlignToolbar.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Toolbar with justification buttons for editor and results panes.
|
||||
* Matches the macOS app: left / center / right for each pane.
|
||||
*/
|
||||
|
||||
import '../styles/align-toolbar.css'
|
||||
|
||||
export type Alignment = 'left' | 'center' | 'right'
|
||||
|
||||
export interface AlignToolbarProps {
|
||||
editorAlign: Alignment
|
||||
resultsAlign: Alignment
|
||||
onEditorAlignChange: (align: Alignment) => void
|
||||
onResultsAlignChange: (align: Alignment) => void
|
||||
}
|
||||
|
||||
function AlignIcon({ type }: { type: 'left' | 'center' | 'right' }) {
|
||||
if (type === 'left') {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||
<rect x="1" y="2" width="12" height="1.5" rx="0.5" fill="currentColor" />
|
||||
<rect x="1" y="6" width="8" height="1.5" rx="0.5" fill="currentColor" />
|
||||
<rect x="1" y="10" width="10" height="1.5" rx="0.5" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
if (type === 'center') {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||
<rect x="1" y="2" width="12" height="1.5" rx="0.5" fill="currentColor" />
|
||||
<rect x="3" y="6" width="8" height="1.5" rx="0.5" fill="currentColor" />
|
||||
<rect x="2" y="10" width="10" height="1.5" rx="0.5" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||
<rect x="1" y="2" width="12" height="1.5" rx="0.5" fill="currentColor" />
|
||||
<rect x="5" y="6" width="8" height="1.5" rx="0.5" fill="currentColor" />
|
||||
<rect x="3" y="10" width="10" height="1.5" rx="0.5" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const alignments: Alignment[] = ['left', 'center', 'right']
|
||||
|
||||
export function AlignToolbar({
|
||||
editorAlign,
|
||||
resultsAlign,
|
||||
onEditorAlignChange,
|
||||
onResultsAlignChange,
|
||||
}: AlignToolbarProps) {
|
||||
return (
|
||||
<div className="align-toolbar">
|
||||
<div className="align-group">
|
||||
<span className="align-label">Editor</span>
|
||||
{alignments.map((a) => (
|
||||
<button
|
||||
key={a}
|
||||
className={`align-btn${editorAlign === a ? ' active' : ''}`}
|
||||
onClick={() => onEditorAlignChange(a)}
|
||||
title={`Align editor ${a}`}
|
||||
>
|
||||
<AlignIcon type={a} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="align-group">
|
||||
<span className="align-label">Results</span>
|
||||
{alignments.map((a) => (
|
||||
<button
|
||||
key={a}
|
||||
className={`align-btn${resultsAlign === a ? ' active' : ''}`}
|
||||
onClick={() => onResultsAlignChange(a)}
|
||||
title={`Align results ${a}`}
|
||||
>
|
||||
<AlignIcon type={a} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* Right-side answer column that displays evaluation results
|
||||
* as a standalone panel (alternative to the gutter-based display).
|
||||
*
|
||||
* This component renders results in a scrollable column synced
|
||||
* to the editor's line height. Each line shows the display value
|
||||
* from the engine, color-coded by result type.
|
||||
*/
|
||||
|
||||
import type { EngineLineResult } from '../engine/types.ts'
|
||||
import '../styles/answer-column.css'
|
||||
|
||||
export interface AnswerColumnProps {
|
||||
results: EngineLineResult[]
|
||||
}
|
||||
|
||||
function resultClassName(type: string): string {
|
||||
switch (type) {
|
||||
case 'number':
|
||||
case 'unitValue':
|
||||
case 'currencyValue':
|
||||
case 'dateTime':
|
||||
case 'timeDelta':
|
||||
case 'boolean':
|
||||
return 'answer-value'
|
||||
case 'error':
|
||||
return 'answer-error'
|
||||
default:
|
||||
return 'answer-empty'
|
||||
}
|
||||
}
|
||||
|
||||
export function AnswerColumn({ results }: AnswerColumnProps) {
|
||||
return (
|
||||
<div className="answer-column" aria-label="Calculation results" role="complementary">
|
||||
{results.map((result, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`answer-line ${resultClassName(result.type)}`}
|
||||
title={result.error ?? result.display}
|
||||
>
|
||||
{result.type === 'error'
|
||||
? 'Error'
|
||||
: result.display || '\u00A0'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
148
calcpad-web/src/components/FormatToolbar.tsx
Normal file
148
calcpad-web/src/components/FormatToolbar.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Formatting toolbar for the editor.
|
||||
* Inserts/toggles markdown-like syntax in the CodeMirror editor.
|
||||
*/
|
||||
|
||||
import type { EditorView } from '@codemirror/view'
|
||||
import '../styles/format-toolbar.css'
|
||||
|
||||
interface FormatToolbarProps {
|
||||
editorView: EditorView | null
|
||||
previewMode: boolean
|
||||
onPreviewToggle: () => void
|
||||
}
|
||||
|
||||
function insertPrefix(view: EditorView, prefix: string) {
|
||||
const { state } = view
|
||||
const line = state.doc.lineAt(state.selection.main.head)
|
||||
const lineText = line.text
|
||||
|
||||
if (lineText.startsWith(prefix)) {
|
||||
// Remove prefix
|
||||
view.dispatch({
|
||||
changes: { from: line.from, to: line.from + prefix.length, insert: '' },
|
||||
})
|
||||
} else {
|
||||
// Add prefix
|
||||
view.dispatch({
|
||||
changes: { from: line.from, insert: prefix },
|
||||
})
|
||||
}
|
||||
view.focus()
|
||||
}
|
||||
|
||||
function wrapSelection(view: EditorView, wrapper: string) {
|
||||
const { state } = view
|
||||
const sel = state.selection.main
|
||||
const selected = state.sliceDoc(sel.from, sel.to)
|
||||
|
||||
if (selected.length === 0) {
|
||||
// No selection — insert wrapper pair and place cursor inside
|
||||
const text = `${wrapper}text${wrapper}`
|
||||
view.dispatch({
|
||||
changes: { from: sel.from, insert: text },
|
||||
selection: { anchor: sel.from + wrapper.length, head: sel.from + wrapper.length + 4 },
|
||||
})
|
||||
} else if (selected.startsWith(wrapper) && selected.endsWith(wrapper)) {
|
||||
// Already wrapped — unwrap
|
||||
const inner = selected.slice(wrapper.length, -wrapper.length)
|
||||
view.dispatch({
|
||||
changes: { from: sel.from, to: sel.to, insert: inner },
|
||||
selection: { anchor: sel.from, head: sel.from + inner.length },
|
||||
})
|
||||
} else {
|
||||
// Wrap selection
|
||||
const text = `${wrapper}${selected}${wrapper}`
|
||||
view.dispatch({
|
||||
changes: { from: sel.from, to: sel.to, insert: text },
|
||||
selection: { anchor: sel.from, head: sel.from + text.length },
|
||||
})
|
||||
}
|
||||
view.focus()
|
||||
}
|
||||
|
||||
function insertColor(view: EditorView, color: string) {
|
||||
const { state } = view
|
||||
const sel = state.selection.main
|
||||
const selected = state.sliceDoc(sel.from, sel.to)
|
||||
const label = selected.length > 0 ? selected : 'label'
|
||||
const text = `[${color}:${label}]`
|
||||
|
||||
view.dispatch({
|
||||
changes: { from: sel.from, to: sel.to, insert: text },
|
||||
selection: { anchor: sel.from, head: sel.from + text.length },
|
||||
})
|
||||
view.focus()
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
{ name: 'Red', value: '#ef4444' },
|
||||
{ name: 'Orange', value: '#f97316' },
|
||||
{ name: 'Yellow', value: '#eab308' },
|
||||
{ name: 'Green', value: '#22c55e' },
|
||||
{ name: 'Blue', value: '#3b82f6' },
|
||||
{ name: 'Purple', value: '#a855f7' },
|
||||
]
|
||||
|
||||
export function FormatToolbar({ editorView, previewMode, onPreviewToggle }: FormatToolbarProps) {
|
||||
return (
|
||||
<div className="format-toolbar">
|
||||
<div className="format-group">
|
||||
<button
|
||||
className={`format-btn format-preview-toggle ${previewMode ? 'active' : ''}`}
|
||||
onClick={onPreviewToggle}
|
||||
title={previewMode ? 'Show raw markdown' : 'Show formatted preview'}
|
||||
>
|
||||
👁
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="format-separator" />
|
||||
|
||||
<div className="format-group">
|
||||
<button
|
||||
className="format-btn"
|
||||
onClick={() => editorView && insertPrefix(editorView, '# ')}
|
||||
title="Heading (toggle # prefix)"
|
||||
>
|
||||
<strong>H</strong>
|
||||
</button>
|
||||
<button
|
||||
className="format-btn"
|
||||
onClick={() => editorView && wrapSelection(editorView, '**')}
|
||||
title="Bold (**text**)"
|
||||
>
|
||||
<strong>B</strong>
|
||||
</button>
|
||||
<button
|
||||
className="format-btn format-italic"
|
||||
onClick={() => editorView && wrapSelection(editorView, '*')}
|
||||
title="Italic (*text*)"
|
||||
>
|
||||
<em>I</em>
|
||||
</button>
|
||||
<button
|
||||
className="format-btn"
|
||||
onClick={() => editorView && insertPrefix(editorView, '// ')}
|
||||
title="Comment (toggle // prefix)"
|
||||
>
|
||||
//
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="format-separator" />
|
||||
|
||||
<div className="format-group format-colors">
|
||||
{COLORS.map(c => (
|
||||
<button
|
||||
key={c.name}
|
||||
className="format-color-btn"
|
||||
style={{ backgroundColor: c.value }}
|
||||
onClick={() => editorView && insertColor(editorView, c.name.toLowerCase())}
|
||||
title={`${c.name} label`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
105
calcpad-web/src/components/MobileResultsTray.tsx
Normal file
105
calcpad-web/src/components/MobileResultsTray.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Mobile results tray — replaces the side panel on small screens.
|
||||
* Collapsed: shows last result + drag handle (48px).
|
||||
* Expanded: scrollable list of all results (40vh).
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import type { EngineLineResult } from '../engine/types.ts'
|
||||
import '../styles/mobile-results-tray.css'
|
||||
|
||||
const DISPLAYABLE_TYPES = new Set([
|
||||
'number', 'unitValue', 'currencyValue', 'dateTime', 'timeDelta', 'boolean',
|
||||
])
|
||||
|
||||
interface MobileResultsTrayProps {
|
||||
results: EngineLineResult[]
|
||||
docLines: string[]
|
||||
}
|
||||
|
||||
export function MobileResultsTray({ results, docLines }: MobileResultsTrayProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [copiedIdx, setCopiedIdx] = useState<number | null>(null)
|
||||
const startY = useRef<number | null>(null)
|
||||
|
||||
// Find last displayable result
|
||||
let lastResult = ''
|
||||
for (let i = results.length - 1; i >= 0; i--) {
|
||||
if (DISPLAYABLE_TYPES.has(results[i].type) && results[i].display) {
|
||||
lastResult = results[i].display
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = useCallback((idx: number, rawValue: number | null, display: string) => {
|
||||
const text = rawValue != null ? String(rawValue) : display
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopiedIdx(idx)
|
||||
setTimeout(() => setCopiedIdx(null), 1200)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Touch swipe handling
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
startY.current = e.touches[0].clientY
|
||||
}, [])
|
||||
|
||||
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
|
||||
if (startY.current === null) return
|
||||
const deltaY = startY.current - e.changedTouches[0].clientY
|
||||
startY.current = null
|
||||
if (deltaY > 40) setExpanded(true) // swipe up
|
||||
if (deltaY < -40) setExpanded(false) // swipe down
|
||||
}, [])
|
||||
|
||||
// Build result items for expanded view
|
||||
const resultItems = results.map((r, i) => {
|
||||
if (!DISPLAYABLE_TYPES.has(r.type) || !r.display) return null
|
||||
const expr = docLines[i]?.trim() ?? ''
|
||||
const isCopied = copiedIdx === i
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`tray-result-item ${isCopied ? 'copied' : ''}`}
|
||||
onClick={() => handleCopy(i, r.rawValue, r.display)}
|
||||
>
|
||||
<span className="tray-result-line">Ln {i + 1}</span>
|
||||
<span className="tray-result-expr">{expr}</span>
|
||||
<span className="tray-result-value">{isCopied ? 'Copied!' : r.display}</span>
|
||||
</div>
|
||||
)
|
||||
}).filter(Boolean)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mobile-results-tray ${expanded ? 'expanded' : ''}`}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* Drag handle + collapsed view */}
|
||||
<div
|
||||
className="tray-header"
|
||||
onClick={() => setExpanded(prev => !prev)}
|
||||
>
|
||||
<div className="tray-drag-handle" />
|
||||
{!expanded && (
|
||||
<span className="tray-last-result">
|
||||
{lastResult ? `Last: ${lastResult}` : 'No results'}
|
||||
</span>
|
||||
)}
|
||||
{expanded && (
|
||||
<span className="tray-last-result">{resultItems.length} results</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded content */}
|
||||
{expanded && (
|
||||
<div className="tray-content">
|
||||
{resultItems.length > 0 ? resultItems : (
|
||||
<div className="tray-empty">No results yet</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
109
calcpad-web/src/components/ResultsPanel.tsx
Normal file
109
calcpad-web/src/components/ResultsPanel.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Right-side results panel that displays one result per editor line.
|
||||
*
|
||||
* Each result line matches CodeMirror's 24px line height (15px * 1.6)
|
||||
* so that results visually align with their corresponding expressions.
|
||||
* Scroll position is driven externally via a forwarded ref.
|
||||
*/
|
||||
|
||||
import { forwardRef, useState, useCallback } from 'react'
|
||||
import type { EngineLineResult } from '../engine/types.ts'
|
||||
import '../styles/results-panel.css'
|
||||
|
||||
const DISPLAYABLE_TYPES = new Set([
|
||||
'number',
|
||||
'unitValue',
|
||||
'currencyValue',
|
||||
'dateTime',
|
||||
'timeDelta',
|
||||
'boolean',
|
||||
])
|
||||
|
||||
const NON_RESULT_TYPES = new Set(['comment', 'text', 'empty'])
|
||||
|
||||
/** Map result type to CSS class for type-specific coloring */
|
||||
function resultColorClass(type: string): string {
|
||||
switch (type) {
|
||||
case 'currencyValue': return 'result-currency'
|
||||
case 'unitValue': return 'result-unit'
|
||||
case 'dateTime':
|
||||
case 'timeDelta': return 'result-datetime'
|
||||
case 'boolean': return 'result-boolean'
|
||||
default: return 'result-number'
|
||||
}
|
||||
}
|
||||
|
||||
export interface ResultsPanelProps {
|
||||
results: EngineLineResult[]
|
||||
align: 'left' | 'center' | 'right'
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export const ResultsPanel = forwardRef<HTMLDivElement, ResultsPanelProps>(
|
||||
function ResultsPanel({ results, align, style }, ref) {
|
||||
const [copiedIdx, setCopiedIdx] = useState<number | null>(null)
|
||||
|
||||
const handleClick = useCallback((idx: number, rawValue: number | null, display: string) => {
|
||||
const text = rawValue != null ? String(rawValue) : display
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopiedIdx(idx)
|
||||
setTimeout(() => setCopiedIdx(null), 1200)
|
||||
}).catch(() => { /* clipboard unavailable */ })
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="results-panel"
|
||||
style={{ textAlign: align, ...style }}
|
||||
role="complementary"
|
||||
aria-label="Calculation results"
|
||||
>
|
||||
{results.map((result, i) => {
|
||||
const isEven = (i + 1) % 2 === 0
|
||||
const stripe = isEven ? ' result-stripe' : ''
|
||||
|
||||
if (DISPLAYABLE_TYPES.has(result.type) && result.display) {
|
||||
const colorClass = resultColorClass(result.type)
|
||||
const isCopied = copiedIdx === i
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`result-line result-value ${colorClass}${stripe}${isCopied ? ' copied' : ''}`}
|
||||
onClick={() => handleClick(i, result.rawValue, result.display)}
|
||||
title="Click to copy"
|
||||
>
|
||||
{isCopied ? 'Copied!' : result.display}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error hint
|
||||
if (result.type === 'error' && result.error) {
|
||||
return (
|
||||
<div key={i} className={`result-line result-error-hint${stripe}`}>
|
||||
· error
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Comment/heading marker
|
||||
if (result.type === 'comment' || result.type === 'text') {
|
||||
return (
|
||||
<div key={i} className={`result-line result-marker${stripe}`}>
|
||||
<span className="result-dash">────</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={i} className={`result-line result-empty${stripe}`}>
|
||||
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
519
calcpad-web/src/components/Sidebar.tsx
Normal file
519
calcpad-web/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,519 @@
|
||||
import { useState, useRef, useEffect, useCallback, type DragEvent } from 'react'
|
||||
import type { CalcDocument, CalcFolder } from '../hooks/useDocumentStore.ts'
|
||||
import { TEMPLATES } from '../data/templates.ts'
|
||||
import '../styles/sidebar.css'
|
||||
|
||||
interface SidebarProps {
|
||||
visible: boolean
|
||||
width: number
|
||||
documents: CalcDocument[]
|
||||
folders: CalcFolder[]
|
||||
activeTabId: string
|
||||
openTabIds: string[]
|
||||
onFileClick: (id: string) => void
|
||||
onNewDocument: (title?: string, content?: string) => void
|
||||
onNewFolder: () => void
|
||||
onRenameDocument: (id: string, title: string) => void
|
||||
onDeleteDocument: (id: string) => void
|
||||
onToggleFavorite: (id: string) => void
|
||||
onMoveToFolder: (docId: string, folderId: string | null) => void
|
||||
onRenameFolder: (id: string, name: string) => void
|
||||
onDeleteFolder: (id: string) => void
|
||||
onWidthChange: (width: number) => void
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
visible,
|
||||
width,
|
||||
documents,
|
||||
folders,
|
||||
activeTabId,
|
||||
openTabIds,
|
||||
onFileClick,
|
||||
onNewDocument,
|
||||
onNewFolder,
|
||||
onRenameDocument,
|
||||
onDeleteDocument,
|
||||
onToggleFavorite,
|
||||
onMoveToFolder,
|
||||
onRenameFolder,
|
||||
onDeleteFolder,
|
||||
onWidthChange,
|
||||
}: SidebarProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
||||
() => new Set(['files', 'recent'])
|
||||
)
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set())
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editValue, setEditValue] = useState('')
|
||||
const [editType, setEditType] = useState<'file' | 'folder'>('file')
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
x: number; y: number; type: 'file' | 'folder'; id: string
|
||||
} | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const resizeRef = useRef<{ startX: number; startWidth: number } | null>(null)
|
||||
|
||||
// Focus rename input
|
||||
useEffect(() => {
|
||||
if (editingId && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
inputRef.current.select()
|
||||
}
|
||||
}, [editingId])
|
||||
|
||||
// Close context menu on click outside
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return
|
||||
const handler = () => setContextMenu(null)
|
||||
document.addEventListener('click', handler)
|
||||
return () => document.removeEventListener('click', handler)
|
||||
}, [contextMenu])
|
||||
|
||||
// Resize handle
|
||||
useEffect(() => {
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (!resizeRef.current) return
|
||||
const newWidth = Math.min(400, Math.max(180, resizeRef.current.startWidth + e.clientX - resizeRef.current.startX))
|
||||
onWidthChange(newWidth)
|
||||
}
|
||||
function onMouseUp() {
|
||||
if (!resizeRef.current) return
|
||||
resizeRef.current = null
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
}, [onWidthChange])
|
||||
|
||||
const toggleSection = useCallback((section: string) => {
|
||||
setExpandedSections(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(section)) next.delete(section)
|
||||
else next.add(section)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleFolder = useCallback((folderId: string) => {
|
||||
setExpandedFolders(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(folderId)) next.delete(folderId)
|
||||
else next.add(folderId)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Rename — use refs to avoid stale closures in blur/keydown handlers
|
||||
const editRef = useRef<{ id: string; value: string; type: 'file' | 'folder' } | null>(null)
|
||||
|
||||
const startRename = useCallback((id: string, currentName: string, type: 'file' | 'folder') => {
|
||||
editRef.current = { id, value: currentName, type }
|
||||
setEditingId(id)
|
||||
setEditValue(currentName)
|
||||
setEditType(type)
|
||||
setContextMenu(null)
|
||||
}, [])
|
||||
|
||||
const commitRename = useCallback(() => {
|
||||
const edit = editRef.current
|
||||
if (!edit) return
|
||||
if (edit.type === 'file') onRenameDocument(edit.id, edit.value)
|
||||
else onRenameFolder(edit.id, edit.value)
|
||||
editRef.current = null
|
||||
setEditingId(null)
|
||||
}, [onRenameDocument, onRenameFolder])
|
||||
|
||||
// Keep ref in sync when editValue changes
|
||||
const handleEditChange = useCallback((val: string) => {
|
||||
setEditValue(val)
|
||||
if (editRef.current) editRef.current.value = val
|
||||
}, [])
|
||||
|
||||
// Derived data
|
||||
const recentDocs = [...documents]
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||
.slice(0, 5)
|
||||
|
||||
const favoriteDocs = documents.filter(d => d.isFavorite)
|
||||
|
||||
const rootDocs = documents.filter(d => !d.folderId)
|
||||
.sort((a, b) => a.title.localeCompare(b.title))
|
||||
|
||||
const rootFolders = folders.filter(f => !f.parentId)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
const getDocsInFolder = (folderId: string) =>
|
||||
documents.filter(d => d.folderId === folderId).sort((a, b) => a.title.localeCompare(b.title))
|
||||
|
||||
// Drag and drop — use ref to avoid stale closure
|
||||
const [dragId, setDragId] = useState<string | null>(null)
|
||||
const dragIdRef = useRef<string | null>(null)
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null)
|
||||
|
||||
const handleDragStart = useCallback((e: DragEvent, docId: string) => {
|
||||
dragIdRef.current = docId
|
||||
setDragId(docId)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', docId)
|
||||
}, [])
|
||||
|
||||
const handleDragOver = useCallback((e: DragEvent, targetId: string) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
setDropTarget(targetId)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setDropTarget(null)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback((e: DragEvent, folderId: string | null) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const docId = dragIdRef.current ?? e.dataTransfer.getData('text/plain')
|
||||
if (docId) {
|
||||
onMoveToFolder(docId, folderId)
|
||||
if (folderId) {
|
||||
setExpandedFolders(prev => {
|
||||
const next = new Set(prev)
|
||||
next.add(folderId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
dragIdRef.current = null
|
||||
setDragId(null)
|
||||
setDropTarget(null)
|
||||
}, [onMoveToFolder])
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
dragIdRef.current = null
|
||||
setDragId(null)
|
||||
setDropTarget(null)
|
||||
}, [])
|
||||
|
||||
// Search filter
|
||||
const searchResults = search.trim()
|
||||
? documents.filter(d => d.title.toLowerCase().includes(search.toLowerCase()))
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||
: null
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
const renderFileItem = (doc: CalcDocument, depth = 0) => {
|
||||
const isActive = doc.id === activeTabId
|
||||
const isOpen = openTabIds.includes(doc.id)
|
||||
const isDragged = dragId === doc.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={doc.id}
|
||||
className={`sidebar-file ${isActive ? 'active' : ''} ${isOpen ? 'open' : ''} ${isDragged ? 'dragging' : ''}`}
|
||||
style={{ paddingLeft: 12 + depth * 16 }}
|
||||
draggable
|
||||
onDragStart={e => handleDragStart(e, doc.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={() => onFileClick(doc.id)}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault()
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, type: 'file', id: doc.id })
|
||||
}}
|
||||
>
|
||||
<span className="sidebar-file-icon">📄</span>
|
||||
{editingId === doc.id ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="sidebar-rename-input"
|
||||
value={editValue}
|
||||
onChange={e => handleEditChange(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') commitRename()
|
||||
if (e.key === 'Escape') setEditingId(null)
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span className="sidebar-file-label">{doc.title}</span>
|
||||
)}
|
||||
{isOpen && !isActive && <span className="sidebar-open-dot" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderFolderItem = (folder: CalcFolder, depth = 0) => {
|
||||
const isExpanded = expandedFolders.has(folder.id)
|
||||
const docsInFolder = getDocsInFolder(folder.id)
|
||||
const subFolders = folders.filter(f => f.parentId === folder.id)
|
||||
const isDropTarget = dropTarget === folder.id
|
||||
|
||||
return (
|
||||
<div key={folder.id}>
|
||||
<div
|
||||
className={`sidebar-folder ${isDropTarget ? 'drop-target' : ''}`}
|
||||
style={{ paddingLeft: 12 + depth * 16 }}
|
||||
onClick={() => { if (!editingId) toggleFolder(folder.id) }}
|
||||
onDragOver={e => handleDragOver(e, folder.id)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={e => handleDrop(e, folder.id)}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault()
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, type: 'folder', id: folder.id })
|
||||
}}
|
||||
>
|
||||
<span className="sidebar-folder-chevron">{isExpanded ? '▾' : '▸'}</span>
|
||||
{editingId === folder.id ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="sidebar-rename-input"
|
||||
value={editValue}
|
||||
onChange={e => handleEditChange(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') commitRename()
|
||||
if (e.key === 'Escape') setEditingId(null)
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="sidebar-folder-icon">{isExpanded ? '📂' : '📁'}</span>
|
||||
<span className="sidebar-folder-label">{folder.name}</span>
|
||||
<span className="sidebar-folder-count">({docsInFolder.length})</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<>
|
||||
{subFolders.map(sf => renderFolderItem(sf, depth + 1))}
|
||||
{docsInFolder.map(d => renderFileItem(d, depth + 1))}
|
||||
{docsInFolder.length === 0 && subFolders.length === 0 && (
|
||||
<div className="sidebar-empty" style={{ paddingLeft: 28 + depth * 16 }}>
|
||||
Empty folder
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sidebar" style={{ width }}>
|
||||
{/* Search */}
|
||||
<div className="sidebar-search">
|
||||
<span className="sidebar-search-icon">🔍</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search documents..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="sidebar-search-input"
|
||||
/>
|
||||
{search && (
|
||||
<button className="sidebar-search-clear" onClick={() => setSearch('')}>×</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sidebar-content">
|
||||
{searchResults ? (
|
||||
/* Search results */
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section-header">
|
||||
Results ({searchResults.length})
|
||||
</div>
|
||||
{searchResults.map(d => renderFileItem(d))}
|
||||
{searchResults.length === 0 && (
|
||||
<div className="sidebar-empty">No documents match '{search}'</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Recent */}
|
||||
<div className="sidebar-section">
|
||||
<div
|
||||
className="sidebar-section-header"
|
||||
onClick={() => toggleSection('recent')}
|
||||
>
|
||||
<span className="sidebar-section-chevron">
|
||||
{expandedSections.has('recent') ? '▾' : '▸'}
|
||||
</span>
|
||||
🕐 Recent
|
||||
</div>
|
||||
{expandedSections.has('recent') && (
|
||||
recentDocs.length > 0
|
||||
? recentDocs.map(d => renderFileItem(d))
|
||||
: <div className="sidebar-empty">No recent documents</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Favorites */}
|
||||
{favoriteDocs.length > 0 && (
|
||||
<div className="sidebar-section">
|
||||
<div
|
||||
className="sidebar-section-header"
|
||||
onClick={() => toggleSection('favorites')}
|
||||
>
|
||||
<span className="sidebar-section-chevron">
|
||||
{expandedSections.has('favorites') ? '▾' : '▸'}
|
||||
</span>
|
||||
⭐ Favorites
|
||||
</div>
|
||||
{expandedSections.has('favorites') && favoriteDocs.map(d => renderFileItem(d))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Templates */}
|
||||
<div className="sidebar-section">
|
||||
<div
|
||||
className="sidebar-section-header"
|
||||
onClick={() => toggleSection('templates')}
|
||||
>
|
||||
<span className="sidebar-section-chevron">
|
||||
{expandedSections.has('templates') ? '▾' : '▸'}
|
||||
</span>
|
||||
📋 Templates
|
||||
</div>
|
||||
{expandedSections.has('templates') && (
|
||||
<div className="sidebar-templates">
|
||||
{TEMPLATES.map(t => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="sidebar-template"
|
||||
onClick={() => onNewDocument(t.name, t.content)}
|
||||
title={t.description}
|
||||
>
|
||||
<span className="sidebar-template-dot" style={{ backgroundColor: t.color }} />
|
||||
<div className="sidebar-template-text">
|
||||
<span className="sidebar-template-name">{t.name}</span>
|
||||
<span className="sidebar-template-desc">{t.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Files */}
|
||||
<div className="sidebar-section">
|
||||
<div
|
||||
className={`sidebar-section-header ${dropTarget === 'root' ? 'drop-target' : ''}`}
|
||||
onClick={() => toggleSection('files')}
|
||||
onDragOver={e => handleDragOver(e, 'root')}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={e => handleDrop(e, null)}
|
||||
>
|
||||
<span className="sidebar-section-chevron">
|
||||
{expandedSections.has('files') ? '▾' : '▸'}
|
||||
</span>
|
||||
📁 Files
|
||||
</div>
|
||||
{expandedSections.has('files') && (
|
||||
<div
|
||||
className={`sidebar-files-area ${dropTarget === 'root' ? 'drop-target-area' : ''}`}
|
||||
onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move' }}
|
||||
onDrop={e => handleDrop(e, null)}
|
||||
>
|
||||
{rootFolders.map(f => renderFolderItem(f))}
|
||||
{rootDocs.map(d => renderFileItem(d))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="sidebar-footer">
|
||||
<button className="sidebar-footer-btn" onClick={onNewDocument}>+ Document</button>
|
||||
<button className="sidebar-footer-btn" onClick={onNewFolder}>+ Folder</button>
|
||||
</div>
|
||||
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
className="sidebar-resize"
|
||||
onMouseDown={e => {
|
||||
e.preventDefault()
|
||||
resizeRef.current = { startX: e.clientX, startWidth: width }
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
}}
|
||||
onDoubleClick={() => onWidthChange(240)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
className="sidebar-context-menu"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
>
|
||||
{contextMenu.type === 'file' && (
|
||||
<>
|
||||
<button onClick={() => { onFileClick(contextMenu.id); setContextMenu(null) }}>
|
||||
Open
|
||||
</button>
|
||||
<button onClick={() => {
|
||||
const doc = documents.find(d => d.id === contextMenu.id)
|
||||
if (doc) startRename(doc.id, doc.title, 'file')
|
||||
}}>
|
||||
Rename
|
||||
</button>
|
||||
<button onClick={() => {
|
||||
onToggleFavorite(contextMenu.id)
|
||||
setContextMenu(null)
|
||||
}}>
|
||||
{documents.find(d => d.id === contextMenu.id)?.isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}
|
||||
</button>
|
||||
{folders.length > 0 && (
|
||||
<>
|
||||
<div className="sidebar-context-separator" />
|
||||
<div className="sidebar-context-label">Move to...</div>
|
||||
<button onClick={() => { onMoveToFolder(contextMenu.id, null); setContextMenu(null) }}>
|
||||
Root
|
||||
</button>
|
||||
{folders.map(f => (
|
||||
<button key={f.id} onClick={() => { onMoveToFolder(contextMenu.id, f.id); setContextMenu(null) }}>
|
||||
📁 {f.name}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<div className="sidebar-context-separator" />
|
||||
<button className="sidebar-context-danger" onClick={() => {
|
||||
onDeleteDocument(contextMenu.id)
|
||||
setContextMenu(null)
|
||||
}}>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{contextMenu.type === 'folder' && (
|
||||
<>
|
||||
<button onClick={() => {
|
||||
const folder = folders.find(f => f.id === contextMenu.id)
|
||||
if (folder) startRename(folder.id, folder.name, 'folder')
|
||||
}}>
|
||||
Rename
|
||||
</button>
|
||||
<div className="sidebar-context-separator" />
|
||||
<button className="sidebar-context-danger" onClick={() => {
|
||||
onDeleteFolder(contextMenu.id)
|
||||
setContextMenu(null)
|
||||
}}>
|
||||
Delete Folder
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
89
calcpad-web/src/components/StatusBar.tsx
Normal file
89
calcpad-web/src/components/StatusBar.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { EditorView } from '@codemirror/view'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import '../styles/status-bar.css'
|
||||
|
||||
interface StatusBarProps {
|
||||
editorView: EditorView | null
|
||||
engineReady: boolean
|
||||
lineCount: number
|
||||
}
|
||||
|
||||
export function StatusBar({ editorView, engineReady, lineCount }: StatusBarProps) {
|
||||
const [cursor, setCursor] = useState({ line: 1, col: 1 })
|
||||
const [selection, setSelection] = useState(0)
|
||||
const [showDedication, setShowDedication] = useState(false)
|
||||
|
||||
const toggleDedication = useCallback(() => {
|
||||
setShowDedication(prev => !prev)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorView) return
|
||||
|
||||
const update = () => {
|
||||
const state = editorView.state
|
||||
const pos = state.selection.main.head
|
||||
const line = state.doc.lineAt(pos)
|
||||
setCursor({ line: line.number, col: pos - line.from + 1 })
|
||||
|
||||
const sel = state.selection.main
|
||||
setSelection(Math.abs(sel.to - sel.from))
|
||||
}
|
||||
|
||||
// Initial
|
||||
update()
|
||||
|
||||
const handler = () => update()
|
||||
editorView.dom.addEventListener('keyup', handler)
|
||||
editorView.dom.addEventListener('mouseup', handler)
|
||||
|
||||
return () => {
|
||||
editorView.dom.removeEventListener('keyup', handler)
|
||||
editorView.dom.removeEventListener('mouseup', handler)
|
||||
}
|
||||
}, [editorView])
|
||||
|
||||
return (
|
||||
<div className="status-bar">
|
||||
<div className="status-bar-left">
|
||||
<span>Ln {cursor.line}, Col {cursor.col}</span>
|
||||
<span>{lineCount} lines</span>
|
||||
{selection > 0 && <span>{selection} selected</span>}
|
||||
</div>
|
||||
<div className="status-bar-right">
|
||||
<span className="status-bar-engine">
|
||||
<span className={`status-bar-dot ${engineReady ? 'ready' : 'loading'}`} />
|
||||
{engineReady ? 'Ready' : 'Loading...'}
|
||||
</span>
|
||||
<span
|
||||
className="status-bar-dedication"
|
||||
onClick={toggleDedication}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
Made with <span className="status-bar-heart">♥</span> for Igor Cassel
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showDedication && (
|
||||
<div className="dedication-overlay" onClick={toggleDedication}>
|
||||
<div className="dedication-card" onClick={e => e.stopPropagation()}>
|
||||
<div className="dedication-heart">♥</div>
|
||||
<h2>For Igor Cassel</h2>
|
||||
<p>
|
||||
CalcText was created in honor of my cousin Igor,
|
||||
who has always had a deep love for text editors
|
||||
and the craft of building beautiful, functional tools.
|
||||
</p>
|
||||
<p className="dedication-tagline">
|
||||
Every keystroke in this editor carries that inspiration.
|
||||
</p>
|
||||
<button className="dedication-close" onClick={toggleDedication}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
130
calcpad-web/src/components/TabBar.tsx
Normal file
130
calcpad-web/src/components/TabBar.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import type { CalcDocument } from '../hooks/useDocumentStore.ts'
|
||||
import '../styles/tab-bar.css'
|
||||
|
||||
interface TabBarProps {
|
||||
tabs: CalcDocument[]
|
||||
activeTabId: string
|
||||
onTabClick: (id: string) => void
|
||||
onTabClose: (id: string) => void
|
||||
onTabRename: (id: string, title: string) => void
|
||||
onNewTab: () => void
|
||||
modifiedIds?: Set<string>
|
||||
}
|
||||
|
||||
export function TabBar({
|
||||
tabs,
|
||||
activeTabId,
|
||||
onTabClick,
|
||||
onTabClose,
|
||||
onTabRename,
|
||||
onNewTab,
|
||||
modifiedIds,
|
||||
}: TabBarProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editValue, setEditValue] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Focus input when editing starts
|
||||
useEffect(() => {
|
||||
if (editingId && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
inputRef.current.select()
|
||||
}
|
||||
}, [editingId])
|
||||
|
||||
// Scroll active tab into view
|
||||
useEffect(() => {
|
||||
if (!scrollRef.current) return
|
||||
const activeEl = scrollRef.current.querySelector('.tab-item.active')
|
||||
activeEl?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||
}, [activeTabId])
|
||||
|
||||
const startRename = useCallback((id: string, currentTitle: string) => {
|
||||
setEditingId(id)
|
||||
setEditValue(currentTitle)
|
||||
}, [])
|
||||
|
||||
const commitRename = useCallback(() => {
|
||||
if (editingId) {
|
||||
onTabRename(editingId, editValue)
|
||||
setEditingId(null)
|
||||
}
|
||||
}, [editingId, editValue, onTabRename])
|
||||
|
||||
const cancelRename = useCallback(() => {
|
||||
setEditingId(null)
|
||||
}, [])
|
||||
|
||||
const handleMiddleClick = useCallback((e: React.MouseEvent, id: string) => {
|
||||
if (e.button === 1) {
|
||||
e.preventDefault()
|
||||
onTabClose(id)
|
||||
}
|
||||
}, [onTabClose])
|
||||
|
||||
// Horizontal scroll with mouse wheel
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
if (scrollRef.current && e.deltaY !== 0) {
|
||||
scrollRef.current.scrollLeft += e.deltaY
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="tab-bar">
|
||||
<div className="tab-bar-scroll" ref={scrollRef} onWheel={handleWheel}>
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`tab-item ${tab.id === activeTabId ? 'active' : ''}`}
|
||||
onClick={() => onTabClick(tab.id)}
|
||||
onMouseDown={(e) => handleMiddleClick(e, tab.id)}
|
||||
onDoubleClick={() => startRename(tab.id, tab.title)}
|
||||
title={tab.title}
|
||||
>
|
||||
{modifiedIds?.has(tab.id) && (
|
||||
<span className="tab-modified-dot" />
|
||||
)}
|
||||
|
||||
{editingId === tab.id ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="tab-rename-input"
|
||||
value={editValue}
|
||||
onChange={e => setEditValue(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') commitRename()
|
||||
if (e.key === 'Escape') cancelRename()
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span className="tab-label">{tab.title}</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="tab-close"
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onTabClose(tab.id)
|
||||
}}
|
||||
aria-label={`Close ${tab.title}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="tab-new"
|
||||
onClick={onNewTab}
|
||||
aria-label="New document"
|
||||
title="New document (Ctrl+N)"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
116
calcpad-web/src/components/ThemePicker.tsx
Normal file
116
calcpad-web/src/components/ThemePicker.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import type { ThemeId } from '../hooks/useTheme.ts'
|
||||
import { THEMES, ACCENT_COLORS } from '../hooks/useTheme.ts'
|
||||
import '../styles/theme-picker.css'
|
||||
|
||||
interface ThemePickerProps {
|
||||
theme: ThemeId
|
||||
accentColor: string | null
|
||||
onThemeChange: (id: ThemeId) => void
|
||||
onAccentChange: (color: string | null) => void
|
||||
}
|
||||
|
||||
export function ThemePicker({ theme, accentColor, onThemeChange, onAccentChange }: ThemePickerProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
document.addEventListener('keydown', handleKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick)
|
||||
document.removeEventListener('keydown', handleKey)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Keyboard shortcut: Ctrl+Shift+T
|
||||
useEffect(() => {
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'T') {
|
||||
e.preventDefault()
|
||||
setOpen(prev => !prev)
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKey)
|
||||
return () => document.removeEventListener('keydown', handleKey)
|
||||
}, [])
|
||||
|
||||
const currentTheme = THEMES.find(t => t.id === theme)
|
||||
const resolvedTheme = theme === 'system' ? undefined : theme
|
||||
const icon = currentTheme?.icon ?? '⚙️'
|
||||
|
||||
return (
|
||||
<div className="theme-picker-container" ref={ref}>
|
||||
<button
|
||||
className="theme-picker-trigger"
|
||||
onClick={() => setOpen(prev => !prev)}
|
||||
title="Switch theme (Ctrl+Shift+T)"
|
||||
aria-label="Switch theme"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="theme-picker-icon">{icon}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="theme-picker-dropdown" role="menu">
|
||||
<div className="theme-picker-section-label">Themes</div>
|
||||
|
||||
{THEMES.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={`theme-picker-item ${resolvedTheme === t.id ? 'active' : ''}`}
|
||||
onClick={() => { onThemeChange(t.id); setOpen(false) }}
|
||||
role="menuitem"
|
||||
>
|
||||
<span className="theme-picker-item-icon">{t.icon}</span>
|
||||
<span className="theme-picker-item-label">{t.name}</span>
|
||||
{resolvedTheme === t.id && <span className="theme-picker-check">✓</span>}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="theme-picker-separator" />
|
||||
|
||||
<div className="theme-picker-section-label">Accent Color</div>
|
||||
<div className="theme-picker-accents">
|
||||
{ACCENT_COLORS.map(c => (
|
||||
<button
|
||||
key={c.name}
|
||||
className={`theme-picker-swatch ${accentColor === c.light || accentColor === c.dark ? 'active' : ''}`}
|
||||
style={{ backgroundColor: c.light }}
|
||||
onClick={() => {
|
||||
const isDark = ['dark', 'matrix', 'midnight'].includes(resolvedTheme ?? '')
|
||||
const color = isDark ? c.dark : c.light
|
||||
onAccentChange(accentColor === color ? null : color)
|
||||
}}
|
||||
title={c.name}
|
||||
aria-label={`Accent color: ${c.name}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="theme-picker-separator" />
|
||||
|
||||
<button
|
||||
className={`theme-picker-item ${theme === 'system' ? 'active' : ''}`}
|
||||
onClick={() => { onThemeChange('system'); setOpen(false) }}
|
||||
role="menuitem"
|
||||
>
|
||||
<span className="theme-picker-item-icon">⚙️</span>
|
||||
<span className="theme-picker-item-label">System (auto)</span>
|
||||
{theme === 'system' && <span className="theme-picker-check">✓</span>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
151
calcpad-web/src/data/templates.ts
Normal file
151
calcpad-web/src/data/templates.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
export interface Template {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
color: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export const TEMPLATES: Template[] = [
|
||||
{
|
||||
id: 'budget',
|
||||
name: 'Budget',
|
||||
description: 'Monthly income and expenses',
|
||||
color: '#10b981',
|
||||
content: `# Monthly Budget
|
||||
|
||||
// Income
|
||||
salary = 5000
|
||||
freelance = 1200
|
||||
total_income = salary + freelance
|
||||
|
||||
// Housing
|
||||
rent = 1500
|
||||
utilities = 150
|
||||
insurance = 80
|
||||
|
||||
// Living
|
||||
groceries = 400
|
||||
transport = 120
|
||||
subscriptions = 45
|
||||
|
||||
// Summary
|
||||
total_expenses = sum
|
||||
savings = total_income - total_expenses
|
||||
savings_rate = savings / total_income
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'invoice',
|
||||
name: 'Invoice',
|
||||
description: 'Service invoice with tax',
|
||||
color: '#6366f1',
|
||||
content: `# Invoice #001
|
||||
|
||||
// Client: [Client Name]
|
||||
// Date: [Date]
|
||||
|
||||
// Services
|
||||
web_design = 2500
|
||||
development = 4000
|
||||
consulting = 150 * 8
|
||||
|
||||
// Expenses
|
||||
hosting = 29.99
|
||||
domain = 12.00
|
||||
|
||||
subtotal = sum
|
||||
|
||||
// Tax
|
||||
tax_rate = 10%
|
||||
tax = subtotal * tax_rate
|
||||
total = subtotal + tax
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'units',
|
||||
name: 'Unit Converter',
|
||||
description: 'Common unit conversions',
|
||||
color: '#0d9488',
|
||||
content: `# Unit Converter
|
||||
|
||||
// Weight
|
||||
75 kg in lb
|
||||
2.5 lb in kg
|
||||
100 g in oz
|
||||
|
||||
// Distance
|
||||
10 km in mi
|
||||
26.2 mi in km
|
||||
5280 ft in m
|
||||
|
||||
// Temperature
|
||||
100 °C in °F
|
||||
72 °F in °C
|
||||
0 °C in K
|
||||
|
||||
// Data
|
||||
1 GB in MB
|
||||
500 MB in GB
|
||||
1 TB in GB
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'trip',
|
||||
name: 'Trip Planner',
|
||||
description: 'Travel budget with currencies',
|
||||
color: '#f59e0b',
|
||||
content: `# Trip Planner
|
||||
|
||||
// Budget
|
||||
budget = 3000
|
||||
|
||||
// Flights
|
||||
flight_out = 450
|
||||
flight_back = 380
|
||||
|
||||
// Hotel
|
||||
nights = 7
|
||||
rate_per_night = 120
|
||||
hotel_total = nights * rate_per_night
|
||||
|
||||
// Daily expenses
|
||||
daily_food = 50
|
||||
daily_transport = 20
|
||||
daily_activities = 35
|
||||
daily_total = daily_food + daily_transport + daily_activities
|
||||
trip_expenses = daily_total * nights
|
||||
|
||||
// Summary
|
||||
total_cost = flight_out + flight_back + hotel_total + trip_expenses
|
||||
remaining = budget - total_cost
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'loan',
|
||||
name: 'Loan Calculator',
|
||||
description: 'Mortgage and loan payments',
|
||||
color: '#7c3aed',
|
||||
content: `# Loan Calculator
|
||||
|
||||
// Loan Details
|
||||
principal = 250000
|
||||
annual_rate = 6.5%
|
||||
years = 30
|
||||
|
||||
// Monthly Calculation
|
||||
monthly_rate = annual_rate / 12
|
||||
num_payments = years * 12
|
||||
|
||||
// Monthly Payment
|
||||
monthly_payment = principal * (monthly_rate * (1 + monthly_rate) ^ num_payments) / ((1 + monthly_rate) ^ num_payments - 1)
|
||||
|
||||
// Total Cost
|
||||
total_paid = monthly_payment * num_payments
|
||||
total_interest = total_paid - principal
|
||||
|
||||
// Summary
|
||||
interest_ratio = total_interest / principal
|
||||
`,
|
||||
},
|
||||
]
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* React wrapper around CodeMirror 6 for the CalcPad editor.
|
||||
*
|
||||
* Integrates the CalcPad language mode, answer gutter, error display,
|
||||
* Integrates the CalcPad language mode, error display,
|
||||
* and debounced evaluation via the WASM engine Web Worker.
|
||||
*/
|
||||
|
||||
@@ -15,15 +15,17 @@ import {
|
||||
keymap,
|
||||
} from '@codemirror/view'
|
||||
import {
|
||||
defaultHighlightStyle,
|
||||
syntaxHighlighting,
|
||||
bracketMatching,
|
||||
indentOnInput,
|
||||
HighlightStyle,
|
||||
} from '@codemirror/language'
|
||||
import { tags } from '@lezer/highlight'
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
import { calcpadLanguage } from './calcpad-language.ts'
|
||||
import { answerGutterExtension, setAnswersEffect, type LineAnswer } from './answer-gutter.ts'
|
||||
import { errorDisplayExtension, setErrorsEffect, type LineError } from './error-display.ts'
|
||||
import { stripedLinesExtension } from './inline-results.ts'
|
||||
import { formatPreviewExtension, formatPreviewCompartment, formatPreviewEnabled } from './format-preview.ts'
|
||||
import type { EngineLineResult } from '../engine/types.ts'
|
||||
|
||||
export interface CalcEditorProps {
|
||||
@@ -31,22 +33,27 @@ export interface CalcEditorProps {
|
||||
initialDoc?: string
|
||||
/** Called when the document text changes (debounced internally) */
|
||||
onDocChange?: (lines: string[]) => void
|
||||
/** Engine evaluation results to display in the answer gutter */
|
||||
/** Engine evaluation results to display as errors */
|
||||
results?: EngineLineResult[]
|
||||
/** Debounce delay in ms before triggering onDocChange */
|
||||
debounceMs?: number
|
||||
/** Called with the EditorView once created (null on cleanup) */
|
||||
onViewReady?: (view: EditorView | null) => void
|
||||
/** Enable live preview formatting */
|
||||
formatPreview?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* CalcPad editor component built on CodeMirror 6.
|
||||
* Handles syntax highlighting, line numbers, answer gutter,
|
||||
* and error underlines.
|
||||
* Handles syntax highlighting, line numbers, and error underlines.
|
||||
*/
|
||||
export function CalcEditor({
|
||||
initialDoc = '',
|
||||
onDocChange,
|
||||
results,
|
||||
debounceMs = 50,
|
||||
onViewReady,
|
||||
formatPreview = true,
|
||||
}: CalcEditorProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const viewRef = useRef<EditorView | null>(null)
|
||||
@@ -86,10 +93,11 @@ export function CalcEditor({
|
||||
indentOnInput(),
|
||||
history(),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||
syntaxHighlighting(defaultHighlightStyle),
|
||||
syntaxHighlighting(calcpadHighlight),
|
||||
calcpadLanguage(),
|
||||
answerGutterExtension(),
|
||||
errorDisplayExtension(),
|
||||
stripedLinesExtension(),
|
||||
formatPreviewExtension(formatPreview),
|
||||
updateListener,
|
||||
calcpadEditorTheme,
|
||||
],
|
||||
@@ -101,6 +109,7 @@ export function CalcEditor({
|
||||
})
|
||||
|
||||
viewRef.current = view
|
||||
onViewReady?.(view)
|
||||
|
||||
// Trigger initial evaluation
|
||||
const doc = view.state.doc.toString()
|
||||
@@ -110,23 +119,33 @@ export function CalcEditor({
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
view.destroy()
|
||||
viewRef.current = null
|
||||
onViewReady?.(null)
|
||||
}
|
||||
// initialDoc intentionally excluded — we only set it once on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scheduleEval])
|
||||
|
||||
// Push engine results into the answer gutter + error display
|
||||
// Toggle format preview mode
|
||||
useEffect(() => {
|
||||
const view = viewRef.current
|
||||
if (!view) return
|
||||
view.dispatch({
|
||||
effects: formatPreviewCompartment.reconfigure(
|
||||
formatPreview ? formatPreviewEnabled : [],
|
||||
),
|
||||
})
|
||||
}, [formatPreview])
|
||||
|
||||
// Push engine results into the error display
|
||||
useEffect(() => {
|
||||
const view = viewRef.current
|
||||
if (!view || !results) return
|
||||
|
||||
const answers: LineAnswer[] = []
|
||||
const errors: LineError[] = []
|
||||
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const lineNum = i + 1
|
||||
const result = results[i]
|
||||
answers.push({ line: lineNum, result })
|
||||
|
||||
if (result.type === 'error' && result.error) {
|
||||
// Map to document positions
|
||||
@@ -143,7 +162,6 @@ export function CalcEditor({
|
||||
|
||||
view.dispatch({
|
||||
effects: [
|
||||
setAnswersEffect.of(answers),
|
||||
setErrorsEffect.of(errors),
|
||||
],
|
||||
})
|
||||
@@ -152,6 +170,24 @@ export function CalcEditor({
|
||||
return <div ref={containerRef} className="calc-editor" />
|
||||
}
|
||||
|
||||
/**
|
||||
* Syntax highlighting using CSS variables for theme integration.
|
||||
*/
|
||||
const calcpadHighlight = HighlightStyle.define([
|
||||
{ tag: tags.number, color: 'var(--syntax-number)' },
|
||||
{ tag: tags.operator, color: 'var(--syntax-operator)' },
|
||||
{ tag: tags.variableName, color: 'var(--syntax-variable)' },
|
||||
{ tag: tags.function(tags.variableName), color: 'var(--syntax-function)' },
|
||||
{ tag: tags.keyword, color: 'var(--syntax-keyword)' },
|
||||
{ tag: tags.lineComment, color: 'var(--syntax-comment)', fontStyle: 'italic' },
|
||||
{ tag: tags.heading, color: 'var(--syntax-heading)', fontWeight: '700' },
|
||||
{ tag: tags.definitionOperator, color: 'var(--syntax-operator)' },
|
||||
{ tag: tags.special(tags.variableName), color: 'var(--syntax-function)' },
|
||||
{ tag: tags.constant(tags.variableName), color: 'var(--syntax-number)', fontWeight: '600' },
|
||||
{ tag: tags.paren, color: 'var(--syntax-operator)' },
|
||||
{ tag: tags.punctuation, color: 'var(--syntax-operator)' },
|
||||
])
|
||||
|
||||
/**
|
||||
* Base theme for the CalcPad editor.
|
||||
*/
|
||||
@@ -159,64 +195,44 @@ const calcpadEditorTheme = EditorView.baseTheme({
|
||||
'&': {
|
||||
height: '100%',
|
||||
fontSize: '15px',
|
||||
fontFamily: 'ui-monospace, Consolas, "Courier New", monospace',
|
||||
fontFamily: 'var(--mono, ui-monospace, Consolas, "Courier New", monospace)',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
},
|
||||
'.cm-content': {
|
||||
padding: '12px 0',
|
||||
padding: '8px 0',
|
||||
minHeight: '100%',
|
||||
},
|
||||
'.cm-line': {
|
||||
padding: '0 16px',
|
||||
padding: '0 12px',
|
||||
lineHeight: '1.6',
|
||||
position: 'relative',
|
||||
textAlign: 'var(--cm-text-align, left)',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'transparent',
|
||||
borderRight: 'none',
|
||||
},
|
||||
'.cm-lineNumbers .cm-gutterElement': {
|
||||
padding: '0 8px 0 16px',
|
||||
color: '#9ca3af',
|
||||
padding: '0 6px 0 12px',
|
||||
color: 'var(--text, #9ca3af)',
|
||||
opacity: '0.4',
|
||||
fontSize: '13px',
|
||||
minWidth: '32px',
|
||||
},
|
||||
'.cm-answer-gutter': {
|
||||
minWidth: '140px',
|
||||
textAlign: 'right',
|
||||
paddingRight: '16px',
|
||||
borderLeft: '1px solid #e5e4e7',
|
||||
backgroundColor: '#f8f9fa',
|
||||
fontFamily: 'ui-monospace, Consolas, monospace',
|
||||
fontSize: '14px',
|
||||
},
|
||||
'&dark .cm-answer-gutter': {
|
||||
borderLeft: '1px solid #2e303a',
|
||||
backgroundColor: '#1a1b23',
|
||||
},
|
||||
'.cm-answer-value': {
|
||||
color: '#6366f1',
|
||||
'.cm-activeLineGutter .cm-gutterElement': {
|
||||
opacity: '1',
|
||||
fontWeight: '600',
|
||||
},
|
||||
'&dark .cm-answer-value': {
|
||||
color: '#818cf8',
|
||||
},
|
||||
'.cm-answer-error': {
|
||||
color: '#e53e3e',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'&dark .cm-answer-error': {
|
||||
color: '#fc8181',
|
||||
'.cm-stripe': {
|
||||
backgroundColor: 'var(--stripe, rgba(0, 0, 0, 0.02))',
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.04)',
|
||||
},
|
||||
'&dark .cm-activeLine': {
|
||||
backgroundColor: 'rgba(129, 140, 248, 0.06)',
|
||||
backgroundColor: 'var(--accent-bg, rgba(99, 102, 241, 0.04))',
|
||||
},
|
||||
'.cm-selectionBackground': {
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.15) !important',
|
||||
backgroundColor: 'var(--accent-bg, rgba(99, 102, 241, 0.15)) !important',
|
||||
},
|
||||
'.cm-focused': {
|
||||
outline: 'none',
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
/**
|
||||
* Custom gutter for displaying computed results alongside each line.
|
||||
* Adapted from epic/9-2-codemirror-6-editor.
|
||||
*
|
||||
* The answer column is right-aligned and visually distinct from the input.
|
||||
*/
|
||||
|
||||
import { GutterMarker, gutter } from '@codemirror/view'
|
||||
import { StateField, StateEffect, type Extension } from '@codemirror/state'
|
||||
import type { EngineLineResult } from '../engine/types.ts'
|
||||
|
||||
// --- State Effects ---
|
||||
|
||||
export interface LineAnswer {
|
||||
line: number // 1-indexed line number
|
||||
result: EngineLineResult | null
|
||||
}
|
||||
|
||||
export const setAnswersEffect = StateEffect.define<LineAnswer[]>()
|
||||
|
||||
// --- Gutter Markers ---
|
||||
|
||||
class AnswerMarker extends GutterMarker {
|
||||
constructor(
|
||||
readonly text: string,
|
||||
readonly isError: boolean,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
override toDOM(): HTMLElement {
|
||||
const span = document.createElement('span')
|
||||
span.className = this.isError ? 'cm-answer-error' : 'cm-answer-value'
|
||||
span.textContent = this.text
|
||||
return span
|
||||
}
|
||||
|
||||
override eq(other: GutterMarker): boolean {
|
||||
return (
|
||||
other instanceof AnswerMarker &&
|
||||
other.text === this.text &&
|
||||
other.isError === this.isError
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- State Field ---
|
||||
|
||||
export const answersField = StateField.define<Map<number, EngineLineResult | null>>({
|
||||
create() {
|
||||
return new Map()
|
||||
},
|
||||
|
||||
update(answers, tr) {
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(setAnswersEffect)) {
|
||||
const newAnswers = new Map<number, EngineLineResult | null>()
|
||||
for (const { line, result } of effect.value) {
|
||||
newAnswers.set(line, result)
|
||||
}
|
||||
return newAnswers
|
||||
}
|
||||
}
|
||||
return answers
|
||||
},
|
||||
})
|
||||
|
||||
// --- Gutter Extension ---
|
||||
|
||||
const DISPLAYABLE_TYPES = new Set(['number', 'unitValue', 'currencyValue', 'dateTime', 'timeDelta', 'boolean'])
|
||||
|
||||
export const answerGutter = gutter({
|
||||
class: 'cm-answer-gutter',
|
||||
lineMarker(view, line) {
|
||||
const doc = view.state.doc
|
||||
const lineNumber = doc.lineAt(line.from).number
|
||||
const answers = view.state.field(answersField)
|
||||
const result = answers.get(lineNumber)
|
||||
|
||||
if (!result) return null
|
||||
|
||||
if (result.type === 'error') {
|
||||
return new AnswerMarker('Error', true)
|
||||
}
|
||||
|
||||
if (DISPLAYABLE_TYPES.has(result.type) && result.display) {
|
||||
return new AnswerMarker(result.display, false)
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
lineMarkerChange(update) {
|
||||
return update.transactions.some((tr) =>
|
||||
tr.effects.some((e) => e.is(setAnswersEffect)),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Creates the answer gutter extension bundle.
|
||||
*/
|
||||
export function answerGutterExtension(): Extension {
|
||||
return [answersField, answerGutter]
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
GutterMarker,
|
||||
gutter,
|
||||
EditorView,
|
||||
hoverTooltip,
|
||||
type Tooltip,
|
||||
} from '@codemirror/view'
|
||||
import { StateField, StateEffect, type Extension, RangeSet } from '@codemirror/state'
|
||||
|
||||
@@ -98,6 +100,48 @@ export const errorLinesField = StateField.define<Set<number>>({
|
||||
},
|
||||
})
|
||||
|
||||
// --- Error Messages (for tooltips) ---
|
||||
|
||||
export const errorMessagesField = StateField.define<Map<number, string>>({
|
||||
create() {
|
||||
return new Map()
|
||||
},
|
||||
update(msgs, tr) {
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(setErrorsEffect)) {
|
||||
const newMsgs = new Map<number, string>()
|
||||
for (const error of effect.value) {
|
||||
const lineNumber = tr.state.doc.lineAt(error.from).number
|
||||
newMsgs.set(lineNumber, error.message)
|
||||
}
|
||||
return newMsgs
|
||||
}
|
||||
}
|
||||
return msgs
|
||||
},
|
||||
})
|
||||
|
||||
// --- Error Tooltip (hover) ---
|
||||
|
||||
const errorTooltip = hoverTooltip((view, pos) => {
|
||||
const line = view.state.doc.lineAt(pos)
|
||||
const errorMessages = view.state.field(errorMessagesField)
|
||||
const msg = errorMessages.get(line.number)
|
||||
if (!msg) return null
|
||||
|
||||
return {
|
||||
pos: line.from,
|
||||
end: line.to,
|
||||
above: false,
|
||||
create() {
|
||||
const dom = document.createElement('div')
|
||||
dom.className = 'cm-error-tooltip'
|
||||
dom.textContent = msg
|
||||
return { dom }
|
||||
},
|
||||
} satisfies Tooltip
|
||||
})
|
||||
|
||||
// --- Error Gutter ---
|
||||
|
||||
export const errorGutter = gutter({
|
||||
@@ -118,15 +162,32 @@ export const errorGutter = gutter({
|
||||
|
||||
export const errorBaseTheme = EditorView.baseTheme({
|
||||
'.cm-error-underline': {
|
||||
textDecoration: 'underline wavy red',
|
||||
textDecoration: 'underline wavy var(--error, red)',
|
||||
textDecorationThickness: '1.5px',
|
||||
},
|
||||
'.cm-error-marker': {
|
||||
color: '#e53e3e',
|
||||
color: 'var(--error, #e53e3e)',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
'.cm-error-gutter': {
|
||||
width: '20px',
|
||||
},
|
||||
'.cm-error-tooltip': {
|
||||
backgroundColor: 'var(--bg-secondary, #f8f9fa)',
|
||||
color: 'var(--error, #e53e3e)',
|
||||
border: '1px solid var(--border, #e5e4e7)',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'var(--sans, system-ui)',
|
||||
maxWidth: '300px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
},
|
||||
'.cm-tooltip': {
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -136,7 +197,9 @@ export function errorDisplayExtension(): Extension {
|
||||
return [
|
||||
errorDecorationsField,
|
||||
errorLinesField,
|
||||
errorMessagesField,
|
||||
errorGutter,
|
||||
errorTooltip,
|
||||
errorBaseTheme,
|
||||
]
|
||||
}
|
||||
|
||||
202
calcpad-web/src/editor/format-preview.ts
Normal file
202
calcpad-web/src/editor/format-preview.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Live Preview extension for CodeMirror.
|
||||
*
|
||||
* When enabled, hides markdown syntax markers and applies visual formatting:
|
||||
* - `# text` → heading (bold, larger)
|
||||
* - `**text**` → bold
|
||||
* - `*text*` → italic
|
||||
* - `// text` → comment (dimmed, italic)
|
||||
*
|
||||
* The active line always shows raw markdown for editing.
|
||||
* Toggle with the compartment to switch between raw and preview modes.
|
||||
*/
|
||||
|
||||
import {
|
||||
EditorView,
|
||||
Decoration,
|
||||
ViewPlugin,
|
||||
type DecorationSet,
|
||||
type ViewUpdate,
|
||||
WidgetType,
|
||||
} from '@codemirror/view'
|
||||
import { Compartment, type Extension } from '@codemirror/state'
|
||||
|
||||
// Invisible widget to replace hidden syntax markers
|
||||
class HiddenMarker extends WidgetType {
|
||||
toDOM() {
|
||||
const span = document.createElement('span')
|
||||
span.style.display = 'none'
|
||||
return span
|
||||
}
|
||||
}
|
||||
|
||||
const hiddenWidget = Decoration.replace({ widget: new HiddenMarker() })
|
||||
|
||||
const headingMark = Decoration.mark({ class: 'cm-fmt-heading' })
|
||||
const boldMark = Decoration.mark({ class: 'cm-fmt-bold' })
|
||||
const italicMark = Decoration.mark({ class: 'cm-fmt-italic' })
|
||||
const commentMark = Decoration.mark({ class: 'cm-fmt-comment' })
|
||||
|
||||
function buildDecorations(view: EditorView): DecorationSet {
|
||||
const decorations: { from: number; to: number; dec: Decoration }[] = []
|
||||
const doc = view.state.doc
|
||||
const activeLine = doc.lineAt(view.state.selection.main.head).number
|
||||
|
||||
for (let i = 1; i <= doc.lines; i++) {
|
||||
const line = doc.line(i)
|
||||
const text = line.text
|
||||
const isActive = i === activeLine
|
||||
|
||||
// Headings: # text
|
||||
const headingMatch = text.match(/^(#{1,6})\s/)
|
||||
if (headingMatch) {
|
||||
if (!isActive) {
|
||||
// Hide the # prefix
|
||||
decorations.push({
|
||||
from: line.from,
|
||||
to: line.from + headingMatch[0].length,
|
||||
dec: hiddenWidget,
|
||||
})
|
||||
}
|
||||
// Style the rest as heading
|
||||
decorations.push({
|
||||
from: line.from + headingMatch[0].length,
|
||||
to: line.to,
|
||||
dec: headingMark,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Comments: // text
|
||||
if (text.trimStart().startsWith('//')) {
|
||||
const offset = text.indexOf('//')
|
||||
if (!isActive) {
|
||||
// Hide the // prefix
|
||||
decorations.push({
|
||||
from: line.from + offset,
|
||||
to: line.from + offset + 2 + (text[offset + 2] === ' ' ? 1 : 0),
|
||||
dec: hiddenWidget,
|
||||
})
|
||||
}
|
||||
decorations.push({
|
||||
from: line.from,
|
||||
to: line.to,
|
||||
dec: commentMark,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Bold: **text** (only on non-active lines)
|
||||
if (!isActive) {
|
||||
const boldRegex = /\*\*(.+?)\*\*/g
|
||||
let match
|
||||
while ((match = boldRegex.exec(text)) !== null) {
|
||||
const start = line.from + match.index
|
||||
// Hide opening **
|
||||
decorations.push({ from: start, to: start + 2, dec: hiddenWidget })
|
||||
// Bold the content
|
||||
decorations.push({ from: start + 2, to: start + 2 + match[1].length, dec: boldMark })
|
||||
// Hide closing **
|
||||
decorations.push({ from: start + 2 + match[1].length, to: start + match[0].length, dec: hiddenWidget })
|
||||
}
|
||||
}
|
||||
|
||||
// Italic: *text* (only on non-active lines, avoid **bold**)
|
||||
if (!isActive) {
|
||||
const italicRegex = /(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g
|
||||
let match
|
||||
while ((match = italicRegex.exec(text)) !== null) {
|
||||
const start = line.from + match.index
|
||||
// Hide opening *
|
||||
decorations.push({ from: start, to: start + 1, dec: hiddenWidget })
|
||||
// Italic the content
|
||||
decorations.push({ from: start + 1, to: start + 1 + match[1].length, dec: italicMark })
|
||||
// Hide closing *
|
||||
decorations.push({ from: start + 1 + match[1].length, to: start + match[0].length, dec: hiddenWidget })
|
||||
}
|
||||
}
|
||||
|
||||
// Color labels: [color:text] (on non-active lines, show colored text)
|
||||
if (!isActive) {
|
||||
const colorRegex = /\[(red|orange|yellow|green|blue|purple):(.+?)\]/g
|
||||
let match
|
||||
while ((match = colorRegex.exec(text)) !== null) {
|
||||
const start = line.from + match.index
|
||||
const color = match[1]
|
||||
const content = match[2]
|
||||
// Hide [color:
|
||||
decorations.push({ from: start, to: start + color.length + 2, dec: hiddenWidget })
|
||||
// Color the content
|
||||
decorations.push({
|
||||
from: start + color.length + 2,
|
||||
to: start + color.length + 2 + content.length,
|
||||
dec: Decoration.mark({ class: `cm-fmt-color-${color}` }),
|
||||
})
|
||||
// Hide ]
|
||||
decorations.push({
|
||||
from: start + match[0].length - 1,
|
||||
to: start + match[0].length,
|
||||
dec: hiddenWidget,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by position (required by RangeSet)
|
||||
decorations.sort((a, b) => a.from - b.from || a.to - b.to)
|
||||
|
||||
return Decoration.set(decorations.map(d => d.dec.range(d.from, d.to)))
|
||||
}
|
||||
|
||||
const formatPreviewPlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildDecorations(view)
|
||||
}
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.selectionSet || update.viewportChanged) {
|
||||
this.decorations = buildDecorations(update.view)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ decorations: (v) => v.decorations },
|
||||
)
|
||||
|
||||
const formatPreviewTheme = EditorView.baseTheme({
|
||||
'.cm-fmt-heading': {
|
||||
fontWeight: '700',
|
||||
fontSize: '1.15em',
|
||||
color: 'var(--text-h)',
|
||||
},
|
||||
'.cm-fmt-bold': {
|
||||
fontWeight: '700',
|
||||
},
|
||||
'.cm-fmt-italic': {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'.cm-fmt-comment': {
|
||||
fontStyle: 'italic',
|
||||
opacity: '0.5',
|
||||
},
|
||||
'.cm-fmt-color-red': { color: '#ef4444' },
|
||||
'.cm-fmt-color-orange': { color: '#f97316' },
|
||||
'.cm-fmt-color-yellow': { color: '#eab308' },
|
||||
'.cm-fmt-color-green': { color: '#22c55e' },
|
||||
'.cm-fmt-color-blue': { color: '#3b82f6' },
|
||||
'.cm-fmt-color-purple': { color: '#a855f7' },
|
||||
})
|
||||
|
||||
// Empty extension for "raw" mode
|
||||
const noopExtension: Extension = []
|
||||
|
||||
export const formatPreviewCompartment = new Compartment()
|
||||
|
||||
/** The extensions to use when preview is enabled */
|
||||
export const formatPreviewEnabled = [formatPreviewPlugin, formatPreviewTheme]
|
||||
|
||||
export function formatPreviewExtension(enabled: boolean): Extension {
|
||||
return formatPreviewCompartment.of(
|
||||
enabled ? formatPreviewEnabled : noopExtension,
|
||||
)
|
||||
}
|
||||
54
calcpad-web/src/editor/inline-results.ts
Normal file
54
calcpad-web/src/editor/inline-results.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* CodeMirror 6 extension for zebra-striped editor lines.
|
||||
*
|
||||
* Previously also contained inline result widgets, but results
|
||||
* are now rendered in a separate ResultsPanel component.
|
||||
*/
|
||||
|
||||
import { Decoration, EditorView, ViewPlugin } from '@codemirror/view'
|
||||
import type { DecorationSet, ViewUpdate } from '@codemirror/view'
|
||||
import type { Extension } from '@codemirror/state'
|
||||
|
||||
/**
|
||||
* ViewPlugin that applies alternating background colors (zebra striping)
|
||||
* to even-numbered editor lines, helping users visually connect
|
||||
* expressions on the left to their inline results on the right.
|
||||
*/
|
||||
export const stripedLines = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildStripeDecorations(view)
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildStripeDecorations(update.view)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations: (v) => v.decorations,
|
||||
},
|
||||
)
|
||||
|
||||
const stripeDeco = Decoration.line({ class: 'cm-stripe' })
|
||||
|
||||
function buildStripeDecorations(view: EditorView): DecorationSet {
|
||||
const decorations: Array<ReturnType<typeof stripeDeco.range>> = []
|
||||
for (let i = 1; i <= view.state.doc.lines; i++) {
|
||||
if (i % 2 === 0) {
|
||||
const line = view.state.doc.line(i)
|
||||
decorations.push(stripeDeco.range(line.from))
|
||||
}
|
||||
}
|
||||
return Decoration.set(decorations)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the striped-lines extension for the editor.
|
||||
*/
|
||||
export function stripedLinesExtension(): Extension {
|
||||
return [stripedLines]
|
||||
}
|
||||
@@ -78,7 +78,8 @@ function fallbackEvalSheet(lines: string[]): EngineLineResult[] {
|
||||
async function initWasm(): Promise<boolean> {
|
||||
try {
|
||||
// Try to load the wasm-pack output
|
||||
const wasmModule = await import(/* @vite-ignore */ '/wasm/calcpad_wasm.js') as {
|
||||
const wasmPath = '/wasm/calcpad_wasm.js'
|
||||
const wasmModule = await import(/* @vite-ignore */ wasmPath) as {
|
||||
default: () => Promise<void>
|
||||
evalSheet: (lines: string[]) => EngineLineResult[]
|
||||
}
|
||||
|
||||
357
calcpad-web/src/hooks/useDocumentStore.ts
Normal file
357
calcpad-web/src/hooks/useDocumentStore.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
|
||||
const STORAGE_KEY = 'calctext-documents'
|
||||
const FOLDERS_KEY = 'calctext-folders'
|
||||
const TABS_KEY = 'calctext-tabs'
|
||||
const ACTIVE_KEY = 'calctext-active-tab'
|
||||
const SIDEBAR_KEY = 'calctext-sidebar'
|
||||
|
||||
export interface CalcDocument {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
folderId: string | null
|
||||
isFavorite: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CalcFolder {
|
||||
id: string
|
||||
name: string
|
||||
parentId: string | null
|
||||
order: number
|
||||
}
|
||||
|
||||
interface StoreState {
|
||||
documents: CalcDocument[]
|
||||
folders: CalcFolder[]
|
||||
openTabIds: string[]
|
||||
activeTabId: string
|
||||
}
|
||||
|
||||
const WELCOME_DOC: CalcDocument = {
|
||||
id: 'welcome',
|
||||
title: 'Welcome',
|
||||
content: `# CalcText
|
||||
|
||||
// Basic arithmetic
|
||||
2 + 3
|
||||
10 * 4.5
|
||||
100 / 7
|
||||
|
||||
// Variables
|
||||
price = 49.99
|
||||
quantity = 3
|
||||
subtotal = price * quantity
|
||||
|
||||
// Percentages
|
||||
tax = subtotal * 8%
|
||||
total = subtotal + tax
|
||||
|
||||
// Functions
|
||||
sqrt(144)
|
||||
2 ^ 10
|
||||
`,
|
||||
folderId: null,
|
||||
isFavorite: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
|
||||
}
|
||||
|
||||
function loadState(): StoreState {
|
||||
try {
|
||||
const docsJson = localStorage.getItem(STORAGE_KEY)
|
||||
const foldersJson = localStorage.getItem(FOLDERS_KEY)
|
||||
const tabsJson = localStorage.getItem(TABS_KEY)
|
||||
const activeId = localStorage.getItem(ACTIVE_KEY)
|
||||
|
||||
const documents: CalcDocument[] = docsJson ? JSON.parse(docsJson) : [WELCOME_DOC]
|
||||
const folders: CalcFolder[] = foldersJson ? JSON.parse(foldersJson) : []
|
||||
const openTabIds: string[] = tabsJson ? JSON.parse(tabsJson) : [documents[0]?.id ?? 'welcome']
|
||||
const activeTabId = activeId && openTabIds.includes(activeId) ? activeId : openTabIds[0] ?? ''
|
||||
|
||||
if (documents.length === 0) documents.push(WELCOME_DOC)
|
||||
if (openTabIds.length === 0) openTabIds.push(documents[0].id)
|
||||
|
||||
return { documents, folders, openTabIds, activeTabId }
|
||||
} catch {
|
||||
return { documents: [WELCOME_DOC], folders: [], openTabIds: ['welcome'], activeTabId: 'welcome' }
|
||||
}
|
||||
}
|
||||
|
||||
function saveState(state: StoreState) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.documents))
|
||||
localStorage.setItem(FOLDERS_KEY, JSON.stringify(state.folders))
|
||||
localStorage.setItem(TABS_KEY, JSON.stringify(state.openTabIds))
|
||||
localStorage.setItem(ACTIVE_KEY, state.activeTabId)
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
export function loadSidebarState(): { visible: boolean; width: number } {
|
||||
try {
|
||||
const json = localStorage.getItem(SIDEBAR_KEY)
|
||||
if (json) return JSON.parse(json)
|
||||
} catch { /* */ }
|
||||
return { visible: true, width: 240 }
|
||||
}
|
||||
|
||||
export function saveSidebarState(s: { visible: boolean; width: number }) {
|
||||
try { localStorage.setItem(SIDEBAR_KEY, JSON.stringify(s)) } catch { /* */ }
|
||||
}
|
||||
|
||||
export function useDocumentStore() {
|
||||
const [state, setState] = useState<StoreState>(loadState)
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
// Use a ref to always have latest state for return values
|
||||
const stateRef = useRef(state)
|
||||
stateRef.current = state
|
||||
|
||||
const persist = useCallback((next: StoreState) => {
|
||||
setState(next)
|
||||
saveState(next)
|
||||
}, [])
|
||||
|
||||
const persistDebounced = useCallback((next: StoreState) => {
|
||||
setState(next)
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
||||
saveTimerRef.current = setTimeout(() => saveState(next), 300)
|
||||
}, [])
|
||||
|
||||
// Use functional updates to avoid stale closures
|
||||
|
||||
const setActiveTab = useCallback((id: string) => {
|
||||
setState(prev => {
|
||||
const next = { ...prev, activeTabId: id }
|
||||
saveState(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const createDocument = useCallback((title?: string, content?: string): CalcDocument => {
|
||||
const id = generateId()
|
||||
const now = new Date().toISOString()
|
||||
// Read current state to count untitled
|
||||
const cur = stateRef.current
|
||||
const existingUntitled = cur.documents.filter(d => d.title.startsWith('Untitled')).length
|
||||
const newDoc: CalcDocument = {
|
||||
id,
|
||||
title: title ?? (existingUntitled === 0 ? 'Untitled' : `Untitled ${existingUntitled + 1}`),
|
||||
content: content ?? '',
|
||||
folderId: null,
|
||||
isFavorite: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
setState(prev => {
|
||||
const next: StoreState = {
|
||||
...prev,
|
||||
documents: [...prev.documents, newDoc],
|
||||
openTabIds: [...prev.openTabIds, id],
|
||||
activeTabId: id,
|
||||
}
|
||||
saveState(next)
|
||||
return next
|
||||
})
|
||||
return newDoc
|
||||
}, [])
|
||||
|
||||
const updateContent = useCallback((id: string, content: string) => {
|
||||
setState(prev => {
|
||||
const next: StoreState = {
|
||||
...prev,
|
||||
documents: prev.documents.map(d =>
|
||||
d.id === id ? { ...d, content, updatedAt: new Date().toISOString() } : d
|
||||
),
|
||||
}
|
||||
// debounced save
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
||||
saveTimerRef.current = setTimeout(() => saveState(next), 300)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const renameDocument = useCallback((id: string, title: string) => {
|
||||
setState(prev => {
|
||||
const next: StoreState = {
|
||||
...prev,
|
||||
documents: prev.documents.map(d =>
|
||||
d.id === id ? { ...d, title: title.trim() || 'Untitled', updatedAt: new Date().toISOString() } : d
|
||||
),
|
||||
}
|
||||
saveState(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const closeTab = useCallback((id: string) => {
|
||||
setState(prev => {
|
||||
let nextTabs = prev.openTabIds.filter(tid => tid !== id)
|
||||
let nextActive = prev.activeTabId
|
||||
|
||||
if (nextTabs.length === 0) {
|
||||
const newId = generateId()
|
||||
const newDoc: CalcDocument = {
|
||||
id: newId, title: 'Untitled', content: '',
|
||||
folderId: null, isFavorite: false,
|
||||
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
||||
}
|
||||
const next: StoreState = {
|
||||
...prev,
|
||||
documents: [...prev.documents, newDoc],
|
||||
openTabIds: [newId],
|
||||
activeTabId: newId,
|
||||
}
|
||||
saveState(next)
|
||||
return next
|
||||
}
|
||||
|
||||
if (nextActive === id) {
|
||||
const closedIdx = prev.openTabIds.indexOf(id)
|
||||
nextActive = nextTabs[Math.min(closedIdx, nextTabs.length - 1)]
|
||||
}
|
||||
|
||||
const next = { ...prev, openTabIds: nextTabs, activeTabId: nextActive }
|
||||
saveState(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const deleteDocument = useCallback((id: string) => {
|
||||
setState(prev => {
|
||||
const nextDocs = prev.documents.filter(d => d.id !== id)
|
||||
let nextTabs = prev.openTabIds.filter(tid => tid !== id)
|
||||
let nextActive = prev.activeTabId
|
||||
|
||||
if (nextDocs.length === 0) {
|
||||
const wd = { ...WELCOME_DOC, id: generateId() }
|
||||
nextDocs.push(wd)
|
||||
}
|
||||
if (nextTabs.length === 0) nextTabs = [nextDocs[0].id]
|
||||
if (!nextTabs.includes(nextActive)) nextActive = nextTabs[0]
|
||||
|
||||
const next: StoreState = { ...prev, documents: nextDocs, openTabIds: nextTabs, activeTabId: nextActive }
|
||||
saveState(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const reorderTabs = useCallback((fromIndex: number, toIndex: number) => {
|
||||
setState(prev => {
|
||||
const tabs = [...prev.openTabIds]
|
||||
const [moved] = tabs.splice(fromIndex, 1)
|
||||
tabs.splice(toIndex, 0, moved)
|
||||
const next = { ...prev, openTabIds: tabs }
|
||||
saveState(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const openDocument = useCallback((id: string) => {
|
||||
setState(prev => {
|
||||
const next = prev.openTabIds.includes(id)
|
||||
? { ...prev, activeTabId: id }
|
||||
: { ...prev, openTabIds: [...prev.openTabIds, id], activeTabId: id }
|
||||
saveState(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleFavorite = useCallback((id: string) => {
|
||||
setState(prev => {
|
||||
const next: StoreState = {
|
||||
...prev,
|
||||
documents: prev.documents.map(d =>
|
||||
d.id === id ? { ...d, isFavorite: !d.isFavorite } : d
|
||||
),
|
||||
}
|
||||
saveState(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const moveToFolder = useCallback((docId: string, folderId: string | null) => {
|
||||
setState(prev => {
|
||||
const next: StoreState = {
|
||||
...prev,
|
||||
documents: prev.documents.map(d =>
|
||||
d.id === docId ? { ...d, folderId } : d
|
||||
),
|
||||
}
|
||||
saveState(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const createFolder = useCallback((name?: string, parentId?: string | null): CalcFolder => {
|
||||
const folder: CalcFolder = {
|
||||
id: generateId(),
|
||||
name: name ?? 'New Folder',
|
||||
parentId: parentId ?? null,
|
||||
order: stateRef.current.folders.length,
|
||||
}
|
||||
setState(prev => {
|
||||
const next = { ...prev, folders: [...prev.folders, folder] }
|
||||
saveState(next)
|
||||
return next
|
||||
})
|
||||
return folder
|
||||
}, [])
|
||||
|
||||
const renameFolder = useCallback((id: string, name: string) => {
|
||||
setState(prev => {
|
||||
const next: StoreState = {
|
||||
...prev,
|
||||
folders: prev.folders.map(f =>
|
||||
f.id === id ? { ...f, name: name.trim() || 'Folder' } : f
|
||||
),
|
||||
}
|
||||
saveState(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const deleteFolder = useCallback((id: string) => {
|
||||
setState(prev => {
|
||||
const next: StoreState = {
|
||||
...prev,
|
||||
documents: prev.documents.map(d => d.folderId === id ? { ...d, folderId: null } : d),
|
||||
folders: prev.folders.filter(f => f.id !== id && f.parentId !== id),
|
||||
}
|
||||
saveState(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const activeDoc = state.documents.find(d => d.id === state.activeTabId) ?? state.documents[0]
|
||||
const openDocs = state.openTabIds
|
||||
.map(id => state.documents.find(d => d.id === id))
|
||||
.filter((d): d is CalcDocument => d != null)
|
||||
|
||||
return {
|
||||
documents: state.documents,
|
||||
folders: state.folders,
|
||||
openDocs,
|
||||
openTabIds: state.openTabIds,
|
||||
activeTabId: state.activeTabId,
|
||||
activeDoc,
|
||||
setActiveTab,
|
||||
createDocument,
|
||||
updateContent,
|
||||
renameDocument,
|
||||
closeTab,
|
||||
deleteDocument,
|
||||
reorderTabs,
|
||||
openDocument,
|
||||
toggleFavorite,
|
||||
moveToFolder,
|
||||
createFolder,
|
||||
renameFolder,
|
||||
deleteFolder,
|
||||
}
|
||||
}
|
||||
135
calcpad-web/src/hooks/useTheme.ts
Normal file
135
calcpad-web/src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
const STORAGE_KEY = 'calctext-theme'
|
||||
const ACCENT_KEY = 'calctext-accent'
|
||||
|
||||
export type ThemeId = 'system' | 'light' | 'dark' | 'matrix' | 'midnight' | 'warm'
|
||||
|
||||
export const THEMES: { id: ThemeId; name: string; icon: string }[] = [
|
||||
{ id: 'light', name: 'Light', icon: '☀️' },
|
||||
{ id: 'dark', name: 'Dark', icon: '🌙' },
|
||||
{ id: 'matrix', name: 'Matrix', icon: '💻' },
|
||||
{ id: 'midnight', name: 'Midnight', icon: '🌊' },
|
||||
{ id: 'warm', name: 'Warm', icon: '📜' },
|
||||
]
|
||||
|
||||
export const ACCENT_COLORS = [
|
||||
{ name: 'Indigo', light: '#6366f1', dark: '#818cf8' },
|
||||
{ name: 'Teal', light: '#14b8a6', dark: '#2dd4bf' },
|
||||
{ name: 'Rose', light: '#f43f5e', dark: '#fb7185' },
|
||||
{ name: 'Amber', light: '#f59e0b', dark: '#fbbf24' },
|
||||
{ name: 'Emerald', light: '#10b981', dark: '#34d399' },
|
||||
{ name: 'Sky', light: '#0ea5e9', dark: '#38bdf8' },
|
||||
]
|
||||
|
||||
function getStoredTheme(): ThemeId {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored && ['system', 'light', 'dark', 'matrix', 'midnight', 'warm'].includes(stored)) {
|
||||
return stored as ThemeId
|
||||
}
|
||||
} catch { /* localStorage unavailable */ }
|
||||
return 'system'
|
||||
}
|
||||
|
||||
function getStoredAccent(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(ACCENT_KEY)
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
function resolveSystemTheme(): 'light' | 'dark' {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
function isDarkTheme(theme: ThemeId): boolean {
|
||||
if (theme === 'system') return resolveSystemTheme() === 'dark'
|
||||
return theme === 'dark' || theme === 'matrix' || theme === 'midnight'
|
||||
}
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setThemeState] = useState<ThemeId>(getStoredTheme)
|
||||
const [accentColor, setAccentState] = useState<string | null>(getStoredAccent)
|
||||
|
||||
const resolvedTheme = theme === 'system' ? resolveSystemTheme() : theme
|
||||
|
||||
const setTheme = useCallback((id: ThemeId) => {
|
||||
setThemeState(id)
|
||||
try { localStorage.setItem(STORAGE_KEY, id) } catch { /* */ }
|
||||
applyTheme(id, accentColor)
|
||||
}, [accentColor])
|
||||
|
||||
const setAccent = useCallback((color: string | null) => {
|
||||
setAccentState(color)
|
||||
try {
|
||||
if (color) localStorage.setItem(ACCENT_KEY, color)
|
||||
else localStorage.removeItem(ACCENT_KEY)
|
||||
} catch { /* */ }
|
||||
applyTheme(theme, color)
|
||||
}, [theme])
|
||||
|
||||
// Listen for system theme changes
|
||||
useEffect(() => {
|
||||
if (theme !== 'system') return
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handler = () => applyTheme('system', accentColor)
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [theme, accentColor])
|
||||
|
||||
// Apply on mount
|
||||
useEffect(() => {
|
||||
applyTheme(theme, accentColor)
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return {
|
||||
theme,
|
||||
resolvedTheme,
|
||||
setTheme,
|
||||
accentColor,
|
||||
setAccent,
|
||||
isDark: isDarkTheme(theme),
|
||||
themes: THEMES,
|
||||
accentColors: ACCENT_COLORS,
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme(theme: ThemeId, accent: string | null) {
|
||||
const resolved = theme === 'system'
|
||||
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
||||
: theme
|
||||
|
||||
document.documentElement.setAttribute('data-theme', resolved)
|
||||
|
||||
// Apply custom accent if set
|
||||
if (accent) {
|
||||
const dark = isDarkTheme(theme)
|
||||
document.documentElement.style.setProperty('--accent', accent)
|
||||
document.documentElement.style.setProperty('--accent-bg', hexToRgba(accent, dark ? 0.15 : 0.1))
|
||||
document.documentElement.style.setProperty('--accent-border', hexToRgba(accent, 0.5))
|
||||
} else {
|
||||
document.documentElement.style.removeProperty('--accent')
|
||||
document.documentElement.style.removeProperty('--accent-bg')
|
||||
document.documentElement.style.removeProperty('--accent-border')
|
||||
}
|
||||
|
||||
// Update PWA theme-color
|
||||
const meta = document.querySelector('meta[name="theme-color"]')
|
||||
if (meta) {
|
||||
const bgColors: Record<string, string> = {
|
||||
light: '#ffffff',
|
||||
dark: '#16171d',
|
||||
matrix: '#0a0a0a',
|
||||
midnight: '#0f172a',
|
||||
warm: '#fffbf5',
|
||||
}
|
||||
meta.setAttribute('content', bgColors[resolved] ?? '#ffffff')
|
||||
}
|
||||
}
|
||||
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||
}
|
||||
53
calcpad-web/src/styles/align-toolbar.css
Normal file
53
calcpad-web/src/styles/align-toolbar.css
Normal file
@@ -0,0 +1,53 @@
|
||||
/* ---------- Align Toolbar (inline in header) ---------- */
|
||||
|
||||
.align-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.align-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.align-label {
|
||||
font-size: 10px;
|
||||
color: var(--text);
|
||||
opacity: 0.5;
|
||||
margin-right: 2px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.align-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, border-color 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.align-btn:hover {
|
||||
background: var(--accent-bg);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.align-btn.active {
|
||||
background: var(--accent-bg);
|
||||
border-color: var(--accent-border);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.align-toolbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
86
calcpad-web/src/styles/format-toolbar.css
Normal file
86
calcpad-web/src/styles/format-toolbar.css
Normal file
@@ -0,0 +1,86 @@
|
||||
/* ---------- Format Toolbar ---------- */
|
||||
|
||||
.format-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.format-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.format-separator {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.format-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-family: var(--sans);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, border-color 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.format-btn:hover {
|
||||
background: var(--accent-bg);
|
||||
border-color: var(--border);
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.format-btn:active {
|
||||
background: var(--accent-bg);
|
||||
border-color: var(--accent-border);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.format-italic {
|
||||
font-style: italic;
|
||||
font-family: Georgia, serif;
|
||||
}
|
||||
|
||||
.format-preview-toggle.active {
|
||||
background: var(--accent-bg);
|
||||
border-color: var(--accent-border);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---------- Color Buttons ---------- */
|
||||
|
||||
.format-colors {
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.format-color-btn {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s, border-color 0.1s;
|
||||
}
|
||||
|
||||
.format-color-btn:hover {
|
||||
transform: scale(1.25);
|
||||
border-color: var(--text-h);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.format-toolbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,16 @@
|
||||
/* ---------- Base & Font Setup ---------- */
|
||||
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--bg-secondary: #f8f9fa;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #6366f1;
|
||||
--accent-bg: rgba(99, 102, 241, 0.1);
|
||||
--accent-border: rgba(99, 102, 241, 0.5);
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, 'Courier New', monospace;
|
||||
|
||||
--warning: #f59e0b;
|
||||
--warning-bg: rgba(245, 158, 11, 0.1);
|
||||
--success: #10b981;
|
||||
--success-bg: rgba(16, 185, 129, 0.1);
|
||||
--error: #e53e3e;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, 'Courier New', monospace;
|
||||
|
||||
font: 16px/1.5 var(--sans);
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
@@ -28,8 +21,40 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
/* ---------- Theme: Light (default) ---------- */
|
||||
|
||||
:root,
|
||||
[data-theme="light"] {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--bg-secondary: #f8f9fa;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #6366f1;
|
||||
--accent-bg: rgba(99, 102, 241, 0.1);
|
||||
--accent-border: rgba(99, 102, 241, 0.5);
|
||||
--stripe: rgba(0, 0, 0, 0.02);
|
||||
|
||||
--syntax-variable: #4f46e5;
|
||||
--syntax-number: #0d9488;
|
||||
--syntax-operator: #6b6375;
|
||||
--syntax-keyword: #7c3aed;
|
||||
--syntax-function: #2563eb;
|
||||
--syntax-currency: #d97706;
|
||||
--syntax-comment: rgba(107, 99, 117, 0.5);
|
||||
--syntax-heading: #08060d;
|
||||
|
||||
--result-number: #374151;
|
||||
--result-unit: #0d9488;
|
||||
--result-currency: #d97706;
|
||||
--result-datetime: #7c3aed;
|
||||
--result-boolean: #6366f1;
|
||||
}
|
||||
|
||||
/* ---------- Theme: Dark ---------- */
|
||||
|
||||
[data-theme="dark"] {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
@@ -39,7 +64,137 @@
|
||||
--accent: #818cf8;
|
||||
--accent-bg: rgba(129, 140, 248, 0.15);
|
||||
--accent-border: rgba(129, 140, 248, 0.5);
|
||||
}
|
||||
--stripe: rgba(255, 255, 255, 0.025);
|
||||
|
||||
--syntax-variable: #a5b4fc;
|
||||
--syntax-number: #5eead4;
|
||||
--syntax-operator: #9ca3af;
|
||||
--syntax-keyword: #c4b5fd;
|
||||
--syntax-function: #93c5fd;
|
||||
--syntax-currency: #fcd34d;
|
||||
--syntax-comment: rgba(156, 163, 175, 0.5);
|
||||
--syntax-heading: #f3f4f6;
|
||||
|
||||
--result-number: #d1d5db;
|
||||
--result-unit: #5eead4;
|
||||
--result-currency: #fcd34d;
|
||||
--result-datetime: #c4b5fd;
|
||||
--result-boolean: #818cf8;
|
||||
}
|
||||
|
||||
/* ---------- Theme: Matrix ---------- */
|
||||
|
||||
[data-theme="matrix"] {
|
||||
--text: #00ff41;
|
||||
--text-h: #33ff66;
|
||||
--bg: #0a0a0a;
|
||||
--bg-secondary: #0f1a0f;
|
||||
--border: #003300;
|
||||
--code-bg: #0a0f0a;
|
||||
--accent: #00ff41;
|
||||
--accent-bg: rgba(0, 255, 65, 0.1);
|
||||
--accent-border: rgba(0, 255, 65, 0.4);
|
||||
--stripe: rgba(0, 255, 65, 0.03);
|
||||
|
||||
--syntax-variable: #00ff41;
|
||||
--syntax-number: #00cc33;
|
||||
--syntax-operator: #00ff41;
|
||||
--syntax-keyword: #39ff14;
|
||||
--syntax-function: #00ff41;
|
||||
--syntax-currency: #ffff00;
|
||||
--syntax-comment: rgba(0, 255, 65, 0.4);
|
||||
--syntax-heading: #33ff66;
|
||||
|
||||
--result-number: #00ff41;
|
||||
--result-unit: #00cc33;
|
||||
--result-currency: #ffff00;
|
||||
--result-datetime: #39ff14;
|
||||
--result-boolean: #00ff41;
|
||||
|
||||
--mono: 'Courier New', 'Fira Code', monospace;
|
||||
--success: #00ff41;
|
||||
--error: #ff0000;
|
||||
}
|
||||
|
||||
/* Matrix special effects */
|
||||
[data-theme="matrix"] .calcpad-app::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 0, 0, 0.06) 2px,
|
||||
rgba(0, 0, 0, 0.06) 4px
|
||||
);
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
[data-theme="matrix"] .cm-cursor {
|
||||
border-color: #00ff41 !important;
|
||||
box-shadow: 0 0 4px #00ff41, 0 0 8px rgba(0, 255, 65, 0.3);
|
||||
}
|
||||
|
||||
/* ---------- Theme: Midnight ---------- */
|
||||
|
||||
[data-theme="midnight"] {
|
||||
--text: #94a3b8;
|
||||
--text-h: #e2e8f0;
|
||||
--bg: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--border: #334155;
|
||||
--code-bg: #1e293b;
|
||||
--accent: #38bdf8;
|
||||
--accent-bg: rgba(56, 189, 248, 0.12);
|
||||
--accent-border: rgba(56, 189, 248, 0.5);
|
||||
--stripe: rgba(56, 189, 248, 0.03);
|
||||
|
||||
--syntax-variable: #7dd3fc;
|
||||
--syntax-number: #5eead4;
|
||||
--syntax-operator: #94a3b8;
|
||||
--syntax-keyword: #c4b5fd;
|
||||
--syntax-function: #7dd3fc;
|
||||
--syntax-currency: #fcd34d;
|
||||
--syntax-comment: rgba(148, 163, 184, 0.5);
|
||||
--syntax-heading: #e2e8f0;
|
||||
|
||||
--result-number: #cbd5e1;
|
||||
--result-unit: #5eead4;
|
||||
--result-currency: #fcd34d;
|
||||
--result-datetime: #c4b5fd;
|
||||
--result-boolean: #38bdf8;
|
||||
}
|
||||
|
||||
/* ---------- Theme: Warm ---------- */
|
||||
|
||||
[data-theme="warm"] {
|
||||
--text: #78716c;
|
||||
--text-h: #1c1917;
|
||||
--bg: #fffbf5;
|
||||
--bg-secondary: #fef3e2;
|
||||
--border: #e7e5e4;
|
||||
--code-bg: #fef3e2;
|
||||
--accent: #f97316;
|
||||
--accent-bg: rgba(249, 115, 22, 0.1);
|
||||
--accent-border: rgba(249, 115, 22, 0.5);
|
||||
--stripe: rgba(249, 115, 22, 0.03);
|
||||
|
||||
--syntax-variable: #c2410c;
|
||||
--syntax-number: #0d9488;
|
||||
--syntax-operator: #78716c;
|
||||
--syntax-keyword: #7c3aed;
|
||||
--syntax-function: #2563eb;
|
||||
--syntax-currency: #d97706;
|
||||
--syntax-comment: rgba(120, 113, 108, 0.5);
|
||||
--syntax-heading: #1c1917;
|
||||
|
||||
--result-number: #44403c;
|
||||
--result-unit: #0d9488;
|
||||
--result-currency: #d97706;
|
||||
--result-datetime: #7c3aed;
|
||||
--result-boolean: #f97316;
|
||||
}
|
||||
|
||||
*,
|
||||
|
||||
117
calcpad-web/src/styles/mobile-results-tray.css
Normal file
117
calcpad-web/src/styles/mobile-results-tray.css
Normal file
@@ -0,0 +1,117 @@
|
||||
/* ---------- Mobile Results Tray ---------- */
|
||||
/* Only visible on mobile (< 768px) */
|
||||
|
||||
.mobile-results-tray {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.mobile-results-tray {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
transition: max-height 0.2s ease-out;
|
||||
max-height: 48px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mobile-results-tray.expanded {
|
||||
max-height: 40vh;
|
||||
}
|
||||
|
||||
/* ---------- Header / Collapsed ---------- */
|
||||
|
||||
.tray-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
min-height: 48px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tray-drag-handle {
|
||||
width: 32px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--border);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tray-last-result {
|
||||
font-size: 13px;
|
||||
font-family: var(--mono);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ---------- Expanded Content ---------- */
|
||||
|
||||
.tray-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.tray-result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.tray-result-item:active {
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
|
||||
.tray-result-item.copied {
|
||||
background: var(--success-bg);
|
||||
}
|
||||
|
||||
.tray-result-line {
|
||||
font-size: 11px;
|
||||
font-family: var(--mono);
|
||||
color: var(--text);
|
||||
opacity: 0.4;
|
||||
width: 40px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tray-result-expr {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-family: var(--mono);
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tray-result-value {
|
||||
font-size: 13px;
|
||||
font-family: var(--mono);
|
||||
color: var(--result-number, var(--text-h));
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
max-width: 120px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tray-empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
104
calcpad-web/src/styles/results-panel.css
Normal file
104
calcpad-web/src/styles/results-panel.css
Normal file
@@ -0,0 +1,104 @@
|
||||
/* ---------- Results Panel ---------- */
|
||||
|
||||
.results-panel {
|
||||
overflow: hidden;
|
||||
background: var(--bg-secondary);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.result-line {
|
||||
padding: 0 12px;
|
||||
font-family: var(--mono);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
height: 24px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ---------- Type-Specific Colors ---------- */
|
||||
|
||||
.result-value {
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.result-value:hover {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
.result-number {
|
||||
color: var(--result-number, var(--text));
|
||||
}
|
||||
|
||||
.result-unit {
|
||||
color: var(--result-unit, #0d9488);
|
||||
}
|
||||
|
||||
.result-currency {
|
||||
color: var(--result-currency, #d97706);
|
||||
}
|
||||
|
||||
.result-datetime {
|
||||
color: var(--result-datetime, #7c3aed);
|
||||
}
|
||||
|
||||
.result-boolean {
|
||||
color: var(--result-boolean, var(--accent));
|
||||
}
|
||||
|
||||
/* ---------- Copy Feedback ---------- */
|
||||
|
||||
.result-value.copied {
|
||||
color: var(--success) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ---------- Error Hint ---------- */
|
||||
|
||||
.result-error-hint {
|
||||
color: var(--text);
|
||||
opacity: 0.25;
|
||||
font-size: 13px;
|
||||
font-family: var(--sans);
|
||||
}
|
||||
|
||||
/* ---------- Comment/Heading Marker ---------- */
|
||||
|
||||
.result-marker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.result-dash {
|
||||
display: inline-block;
|
||||
width: 60%;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
opacity: 0.4;
|
||||
font-size: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---------- Empty ---------- */
|
||||
|
||||
.result-empty {
|
||||
/* intentionally blank */
|
||||
}
|
||||
|
||||
/* ---------- Stripes ---------- */
|
||||
|
||||
.result-stripe {
|
||||
background: var(--stripe, rgba(0, 0, 0, 0.02));
|
||||
}
|
||||
|
||||
/* ---------- Responsive ---------- */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.results-panel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
460
calcpad-web/src/styles/sidebar.css
Normal file
460
calcpad-web/src/styles/sidebar.css
Normal file
@@ -0,0 +1,460 @@
|
||||
/* ---------- Sidebar ---------- */
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---------- Search ---------- */
|
||||
|
||||
.sidebar-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-search-icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.sidebar-search-input {
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
padding: 4px 24px 4px 28px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.sidebar-search-input:focus {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
|
||||
.sidebar-search-input::placeholder {
|
||||
color: var(--text);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.sidebar-search-clear {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
opacity: 0.5;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.sidebar-search-clear:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ---------- Content ---------- */
|
||||
|
||||
.sidebar-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ---------- Sections ---------- */
|
||||
|
||||
.sidebar-section {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.sidebar-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.sidebar-section-header:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-section-chevron {
|
||||
font-size: 10px;
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ---------- File Item ---------- */
|
||||
|
||||
.sidebar-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 28px;
|
||||
padding-right: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.1s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-file:hover {
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
|
||||
.sidebar-file.active {
|
||||
background: var(--accent-bg);
|
||||
border-left: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
.sidebar-file-icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-file-label {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar-file.active .sidebar-file-label {
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.sidebar-open-dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ---------- Folder Item ---------- */
|
||||
|
||||
.sidebar-folder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 28px;
|
||||
padding-right: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.sidebar-folder:hover {
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
|
||||
.sidebar-folder-chevron {
|
||||
font-size: 10px;
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-folder-icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-folder-label {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar-folder-count {
|
||||
font-size: 10px;
|
||||
color: var(--text);
|
||||
opacity: 0.4;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---------- Drag & Drop ---------- */
|
||||
|
||||
.sidebar-file.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.sidebar-folder.drop-target {
|
||||
background: var(--accent-bg);
|
||||
outline: 2px dashed var(--accent);
|
||||
outline-offset: -2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sidebar-section-header.drop-target {
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
|
||||
.sidebar-files-area {
|
||||
min-height: 8px;
|
||||
}
|
||||
|
||||
/* ---------- Templates ---------- */
|
||||
|
||||
.sidebar-template {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.sidebar-template:hover {
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
|
||||
.sidebar-template-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-template-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-template-name {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sidebar-template-desc {
|
||||
font-size: 10px;
|
||||
color: var(--text);
|
||||
opacity: 0.5;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* ---------- Empty State ---------- */
|
||||
|
||||
.sidebar-empty {
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
opacity: 0.4;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ---------- Rename Input ---------- */
|
||||
|
||||
.sidebar-rename-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 2px;
|
||||
background: var(--bg);
|
||||
color: var(--text-h);
|
||||
font-size: 12px;
|
||||
padding: 1px 4px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* ---------- Footer ---------- */
|
||||
|
||||
.sidebar-footer {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-footer-btn {
|
||||
flex: 1;
|
||||
height: 26px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: color 0.1s, background 0.1s;
|
||||
}
|
||||
|
||||
.sidebar-footer-btn:hover {
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
|
||||
/* ---------- Resize Handle ---------- */
|
||||
|
||||
.sidebar-resize {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -4px;
|
||||
width: 8px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.sidebar-resize:hover::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 3px;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ---------- Context Menu ---------- */
|
||||
|
||||
.sidebar-context-menu {
|
||||
position: fixed;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
padding: 4px;
|
||||
z-index: 300;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.sidebar-context-menu button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 5px 10px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar-context-menu button:hover {
|
||||
background: var(--accent-bg);
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.sidebar-context-separator {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.sidebar-context-label {
|
||||
padding: 3px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
opacity: 0.5;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sidebar-context-danger {
|
||||
color: var(--error) !important;
|
||||
}
|
||||
|
||||
/* ---------- Responsive: Mobile Drawer ---------- */
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 85vw !important;
|
||||
max-width: 320px;
|
||||
z-index: 400;
|
||||
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.25);
|
||||
animation: sidebar-slide-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes sidebar-slide-in {
|
||||
from { transform: translateX(-100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
.sidebar-file {
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.sidebar-folder {
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.sidebar-template {
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.sidebar-search-input {
|
||||
height: 36px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sidebar-resize {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile sidebar backdrop — rendered from App.tsx */
|
||||
.sidebar-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.sidebar-backdrop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 399;
|
||||
animation: sidebar-backdrop-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes sidebar-backdrop-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
}
|
||||
159
calcpad-web/src/styles/status-bar.css
Normal file
159
calcpad-web/src/styles/status-bar.css
Normal file
@@ -0,0 +1,159 @@
|
||||
/* ---------- Status Bar ---------- */
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 24px;
|
||||
padding: 0 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
opacity: 0.8;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.status-bar-left,
|
||||
.status-bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status-bar-engine {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-bar-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-bar-dot.ready {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.status-bar-dot.loading {
|
||||
background: var(--warning);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-bar-dedication {
|
||||
opacity: 0.6;
|
||||
font-family: var(--sans);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.2px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.status-bar-dedication:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.status-bar-heart {
|
||||
color: #e53e3e;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ---------- Dedication Overlay ---------- */
|
||||
|
||||
.dedication-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 500;
|
||||
animation: dedication-fade-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes dedication-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.dedication-card {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 40px;
|
||||
max-width: 420px;
|
||||
text-align: center;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
animation: dedication-card-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes dedication-card-in {
|
||||
from { opacity: 0; transform: scale(0.9) translateY(20px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
.dedication-heart {
|
||||
font-size: 48px;
|
||||
color: #e53e3e;
|
||||
margin-bottom: 16px;
|
||||
animation: dedication-beat 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes dedication-beat {
|
||||
0%, 100% { transform: scale(1); }
|
||||
14% { transform: scale(1.15); }
|
||||
28% { transform: scale(1); }
|
||||
42% { transform: scale(1.1); }
|
||||
56% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.dedication-card h2 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--text-h);
|
||||
font-family: var(--sans);
|
||||
}
|
||||
|
||||
.dedication-card p {
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
}
|
||||
|
||||
.dedication-tagline {
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
.dedication-close {
|
||||
margin-top: 20px;
|
||||
padding: 8px 24px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: var(--sans);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.dedication-close:hover {
|
||||
background: var(--accent-bg);
|
||||
color: var(--accent);
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.status-bar-left span:not(:first-child) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
205
calcpad-web/src/styles/tab-bar.css
Normal file
205
calcpad-web/src/styles/tab-bar.css
Normal file
@@ -0,0 +1,205 @@
|
||||
/* ---------- Tab Bar ---------- */
|
||||
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
height: 36px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-bar-scroll {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
.tab-bar-scroll::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari */
|
||||
}
|
||||
|
||||
/* Fade indicators for scroll overflow */
|
||||
.tab-bar {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-bar::before,
|
||||
.tab-bar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 16px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.tab-bar::before {
|
||||
left: 0;
|
||||
background: linear-gradient(to right, var(--bg-secondary), transparent);
|
||||
}
|
||||
|
||||
.tab-bar::after {
|
||||
right: 36px; /* before new tab button */
|
||||
background: linear-gradient(to left, var(--bg-secondary), transparent);
|
||||
}
|
||||
|
||||
/* ---------- Tab Item ---------- */
|
||||
|
||||
.tab-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 8px;
|
||||
min-width: 100px;
|
||||
max-width: 200px;
|
||||
height: 36px;
|
||||
border-right: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.1s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-item:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
background: var(--bg);
|
||||
border-bottom-color: transparent;
|
||||
border-top: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
.tab-item:not(.active) {
|
||||
border-top: 2px solid transparent;
|
||||
}
|
||||
|
||||
/* ---------- Tab Label ---------- */
|
||||
|
||||
.tab-label {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tab-item.active .tab-label {
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
/* ---------- Modified Dot ---------- */
|
||||
|
||||
.tab-modified-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text);
|
||||
opacity: 0.6;
|
||||
flex-shrink: 0;
|
||||
animation: tab-dot-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes tab-dot-in {
|
||||
from { opacity: 0; transform: scale(0); }
|
||||
to { opacity: 0.6; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* ---------- Mobile ---------- */
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.tab-bar {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
height: 40px;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-new {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Close Button ---------- */
|
||||
|
||||
.tab-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
opacity: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.1s, background 0.1s;
|
||||
}
|
||||
|
||||
.tab-item:hover .tab-close,
|
||||
.tab-item.active .tab-close {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
opacity: 1 !important;
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
|
||||
/* ---------- Rename Input ---------- */
|
||||
|
||||
.tab-rename-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 2px;
|
||||
background: var(--bg);
|
||||
color: var(--text-h);
|
||||
font-size: 12px;
|
||||
padding: 1px 4px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* ---------- New Tab Button ---------- */
|
||||
|
||||
.tab-new {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 18px;
|
||||
font-weight: 300;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.1s;
|
||||
}
|
||||
|
||||
.tab-new:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
130
calcpad-web/src/styles/theme-picker.css
Normal file
130
calcpad-web/src/styles/theme-picker.css
Normal file
@@ -0,0 +1,130 @@
|
||||
/* ---------- Theme Picker ---------- */
|
||||
|
||||
.theme-picker-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-picker-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.theme-picker-trigger:hover {
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
|
||||
.theme-picker-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
width: 220px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
padding: 6px;
|
||||
z-index: 200;
|
||||
animation: theme-picker-in 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes theme-picker-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-picker-section-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
opacity: 0.6;
|
||||
padding: 6px 8px 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.theme-picker-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.theme-picker-item:hover {
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
|
||||
.theme-picker-item.active {
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.theme-picker-item-icon {
|
||||
font-size: 14px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.theme-picker-item-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.theme-picker-check {
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.theme-picker-separator {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.theme-picker-accents {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 4px 8px 6px;
|
||||
}
|
||||
|
||||
.theme-picker-swatch {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s, border-color 0.1s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.theme-picker-swatch:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
.theme-picker-swatch.active {
|
||||
border-color: var(--text-h);
|
||||
}
|
||||
5
calcpad-web/src/vite-env.d.ts
vendored
5
calcpad-web/src/vite-env.d.ts
vendored
@@ -1,5 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '/wasm/calcpad_wasm.js' {
|
||||
export default function init(): Promise<void>
|
||||
export function evalSheet(lines: string[]): import('./engine/types.ts').EngineLineResult[]
|
||||
}
|
||||
|
||||
declare module 'virtual:pwa-register' {
|
||||
export interface RegisterSWOptions {
|
||||
immediate?: boolean
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"types": [],
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
@@ -6,7 +6,7 @@ export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'prompt',
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.svg', 'icons/*.svg'],
|
||||
manifest: {
|
||||
name: 'CalcPad',
|
||||
|
||||
5
docker-compose.yml
Normal file
5
docker-compose.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:8080"
|
||||
32
nginx.conf
Normal file
32
nginx.conf
Normal file
@@ -0,0 +1,32 @@
|
||||
server {
|
||||
listen 8080;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA fallback — serve index.html for all non-file routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets aggressively (hashed filenames)
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# WASM files — correct MIME type + cache
|
||||
location /wasm/ {
|
||||
types { application/wasm wasm; }
|
||||
default_type application/javascript;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header Cross-Origin-Embedder-Policy "require-corp";
|
||||
add_header Cross-Origin-Opener-Policy "same-origin";
|
||||
}
|
||||
|
||||
# Gzip
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/javascript application/json application/wasm image/svg+xml;
|
||||
gzip_min_length 256;
|
||||
}
|
||||
Reference in New Issue
Block a user