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 = [
|
dependencies = [
|
||||||
"calcpad-engine",
|
"calcpad-engine",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"getrandom 0.2.17",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"serde",
|
"serde",
|
||||||
"serde-wasm-bindgen",
|
"serde-wasm-bindgen",
|
||||||
@@ -1580,8 +1581,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[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 {
|
fn is_empty_error(result: &CalcResult) -> bool {
|
||||||
// The engine returns CalcResult::error("empty input", ..) or
|
matches!(
|
||||||
// CalcResult::error("no expression found", ..) for blank/comment lines.
|
result.metadata.result_type,
|
||||||
if let CalcValue::Error { ref message, .. } = result.value {
|
ResultType::Empty | ResultType::NonCalculable
|
||||||
message == "empty input" || message == "no expression found"
|
)
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn error_message(result: &CalcResult) -> Option<&str> {
|
fn error_message(result: &CalcResult) -> Option<&str> {
|
||||||
|
|||||||
@@ -6,13 +6,17 @@ edition = "2021"
|
|||||||
[lib]
|
[lib]
|
||||||
crate-type = ["cdylib", "staticlib", "rlib"]
|
crate-type = ["cdylib", "staticlib", "rlib"]
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["network"]
|
||||||
|
network = ["ureq"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
chrono-tz = "0.10"
|
chrono-tz = "0.10"
|
||||||
dashu = "0.4"
|
dashu = "0.4"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
ureq = { version = "2", features = ["json"] }
|
ureq = { version = "2", features = ["json"], optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ impl CryptoProvider {
|
|||||||
/// Refresh rates from the CoinGecko API.
|
/// Refresh rates from the CoinGecko API.
|
||||||
/// Returns Ok(()) on success, Err with message on failure.
|
/// Returns Ok(()) on success, Err with message on failure.
|
||||||
/// Falls back to cached rates if the API call fails.
|
/// Falls back to cached rates if the API call fails.
|
||||||
|
#[cfg(feature = "network")]
|
||||||
pub fn refresh(&mut self) -> Result<(), String> {
|
pub fn refresh(&mut self) -> Result<(), String> {
|
||||||
if !self.is_stale() {
|
if !self.is_stale() {
|
||||||
return Ok(());
|
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.
|
/// Fetch rates from CoinGecko API.
|
||||||
|
#[cfg(feature = "network")]
|
||||||
fn fetch_from_api(&self) -> Result<HashMap<String, CryptoRate>, String> {
|
fn fetch_from_api(&self) -> Result<HashMap<String, CryptoRate>, String> {
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"{}/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=100&page=1&sparkline=false",
|
"{}/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
|
// Provider implementations
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(feature = "network")]
|
||||||
/// Open Exchange Rates API provider (<https://openexchangerates.org>).
|
/// Open Exchange Rates API provider (<https://openexchangerates.org>).
|
||||||
///
|
///
|
||||||
/// Requires an API key. Free tier provides 1,000 requests/month with USD base.
|
/// Requires an API key. Free tier provides 1,000 requests/month with USD base.
|
||||||
@@ -24,6 +25,7 @@ pub struct OpenExchangeRatesProvider {
|
|||||||
api_key: String,
|
api_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "network")]
|
||||||
impl OpenExchangeRatesProvider {
|
impl OpenExchangeRatesProvider {
|
||||||
pub fn new(api_key: &str) -> Self {
|
pub fn new(api_key: &str) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -32,6 +34,7 @@ impl OpenExchangeRatesProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "network")]
|
||||||
impl CurrencyProvider for OpenExchangeRatesProvider {
|
impl CurrencyProvider for OpenExchangeRatesProvider {
|
||||||
fn fetch_rates(&self) -> Result<ExchangeRates, CurrencyError> {
|
fn fetch_rates(&self) -> Result<ExchangeRates, CurrencyError> {
|
||||||
let url = format!(
|
let url = format!(
|
||||||
@@ -73,6 +76,7 @@ impl CurrencyProvider for OpenExchangeRatesProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "network")]
|
||||||
/// exchangerate.host API provider (<https://exchangerate.host>).
|
/// exchangerate.host API provider (<https://exchangerate.host>).
|
||||||
///
|
///
|
||||||
/// Free tier available; no API key required for basic usage.
|
/// Free tier available; no API key required for basic usage.
|
||||||
@@ -80,6 +84,7 @@ pub struct ExchangeRateHostProvider {
|
|||||||
api_key: Option<String>,
|
api_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "network")]
|
||||||
impl ExchangeRateHostProvider {
|
impl ExchangeRateHostProvider {
|
||||||
pub fn new(api_key: Option<&str>) -> Self {
|
pub fn new(api_key: Option<&str>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -88,6 +93,7 @@ impl ExchangeRateHostProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "network")]
|
||||||
impl CurrencyProvider for ExchangeRateHostProvider {
|
impl CurrencyProvider for ExchangeRateHostProvider {
|
||||||
fn fetch_rates(&self) -> Result<ExchangeRates, CurrencyError> {
|
fn fetch_rates(&self) -> Result<ExchangeRates, CurrencyError> {
|
||||||
let mut url = "https://api.exchangerate.host/live?source=USD".to_string();
|
let mut url = "https://api.exchangerate.host/live?source=USD".to_string();
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ pub mod rates;
|
|||||||
pub mod symbols;
|
pub mod symbols;
|
||||||
|
|
||||||
pub use crypto::{CryptoProvider, CryptoProviderConfig, CryptoRate};
|
pub use crypto::{CryptoProvider, CryptoProviderConfig, CryptoRate};
|
||||||
pub use fiat::{
|
#[cfg(feature = "network")]
|
||||||
ExchangeRateHostProvider, FiatCurrencyProvider, OpenExchangeRatesProvider, fallback_rates,
|
pub use fiat::{ExchangeRateHostProvider, OpenExchangeRatesProvider};
|
||||||
};
|
pub use fiat::{FiatCurrencyProvider, fallback_rates};
|
||||||
pub use rates::{ExchangeRateCache, ExchangeRates, ProviderConfig, RateMetadata, RateSource};
|
pub use rates::{ExchangeRateCache, ExchangeRates, ProviderConfig, RateMetadata, RateSource};
|
||||||
pub use symbols::{is_currency_code, is_crypto_symbol, resolve_currency};
|
pub use symbols::{is_currency_code, is_crypto_symbol, resolve_currency};
|
||||||
|
|
||||||
|
|||||||
@@ -215,6 +215,14 @@ impl Parser {
|
|||||||
Ok(Spanned::new(ExprKind::PrevRef, span))
|
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(
|
_ => Err(ParseError::new(
|
||||||
format!("unexpected token: {:?}", tok.kind),
|
format!("unexpected token: {:?}", tok.kind),
|
||||||
tok.span,
|
tok.span,
|
||||||
|
|||||||
@@ -4,12 +4,19 @@ use crate::lexer::tokenize;
|
|||||||
use crate::parser::parse;
|
use crate::parser::parse;
|
||||||
use crate::span::Span;
|
use crate::span::Span;
|
||||||
use crate::types::CalcResult;
|
use crate::types::CalcResult;
|
||||||
|
use crate::variables::aggregators;
|
||||||
|
|
||||||
/// Evaluate a single line of input and return the result.
|
/// Evaluate a single line of input and return the result.
|
||||||
pub fn eval_line(input: &str, ctx: &mut EvalContext) -> CalcResult {
|
pub fn eval_line(input: &str, ctx: &mut EvalContext) -> CalcResult {
|
||||||
let trimmed = input.trim();
|
let trimmed = input.trim();
|
||||||
if trimmed.is_empty() {
|
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);
|
let tokens = tokenize(trimmed);
|
||||||
@@ -24,7 +31,7 @@ pub fn eval_line(input: &str, ctx: &mut EvalContext) -> CalcResult {
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
if !has_expr {
|
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) {
|
match parse(tokens) {
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ impl SheetContext {
|
|||||||
|
|
||||||
// Heading lines produce no result -- skip them
|
// Heading lines produce no result -- skip them
|
||||||
if entry_is_heading {
|
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_results.push(result.clone());
|
||||||
ordered_sources.push(entry_source);
|
ordered_sources.push(entry_source);
|
||||||
self.results.insert(idx, result);
|
self.results.insert(idx, result);
|
||||||
@@ -251,7 +251,7 @@ impl SheetContext {
|
|||||||
// Store as __line_N for line reference support
|
// Store as __line_N for line reference support
|
||||||
ctx.set_variable(&format!("__line_{}", idx + 1), result.clone());
|
ctx.set_variable(&format!("__line_{}", idx + 1), result.clone());
|
||||||
// Update __prev for prev/ans support
|
// Update __prev for prev/ans support
|
||||||
if result.result_type() != crate::types::ResultType::Error {
|
if result.is_calculable() {
|
||||||
ctx.set_variable("__prev", result.clone());
|
ctx.set_variable("__prev", result.clone());
|
||||||
}
|
}
|
||||||
ordered_results.push(result);
|
ordered_results.push(result);
|
||||||
@@ -261,7 +261,11 @@ impl SheetContext {
|
|||||||
|
|
||||||
// Text/empty lines produce no result -- skip them
|
// Text/empty lines produce no result -- skip them
|
||||||
if entry_is_text || entry_source.trim().is_empty() {
|
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_results.push(result.clone());
|
||||||
ordered_sources.push(entry_source);
|
ordered_sources.push(entry_source);
|
||||||
self.results.insert(idx, result);
|
self.results.insert(idx, result);
|
||||||
@@ -276,7 +280,7 @@ impl SheetContext {
|
|||||||
// Store as __line_N for line reference support (1-indexed)
|
// Store as __line_N for line reference support (1-indexed)
|
||||||
ctx.set_variable(&format!("__line_{}", idx + 1), result.clone());
|
ctx.set_variable(&format!("__line_{}", idx + 1), result.clone());
|
||||||
// Update __prev for prev/ans support (only for non-error results)
|
// 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());
|
ctx.set_variable("__prev", result.clone());
|
||||||
}
|
}
|
||||||
ordered_results.push(result);
|
ordered_results.push(result);
|
||||||
@@ -299,7 +303,7 @@ impl SheetContext {
|
|||||||
}
|
}
|
||||||
// Replay line ref and prev for cached results too
|
// Replay line ref and prev for cached results too
|
||||||
ctx.set_variable(&format!("__line_{}", idx + 1), cached.clone());
|
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());
|
ctx.set_variable("__prev", cached.clone());
|
||||||
}
|
}
|
||||||
ordered_results.push(cached);
|
ordered_results.push(cached);
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ pub enum ResultType {
|
|||||||
DateTime,
|
DateTime,
|
||||||
TimeDelta,
|
TimeDelta,
|
||||||
Boolean,
|
Boolean,
|
||||||
|
Empty,
|
||||||
|
NonCalculable,
|
||||||
Error,
|
Error,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,6 +26,8 @@ impl fmt::Display for ResultType {
|
|||||||
ResultType::DateTime => write!(f, "DateTime"),
|
ResultType::DateTime => write!(f, "DateTime"),
|
||||||
ResultType::TimeDelta => write!(f, "TimeDelta"),
|
ResultType::TimeDelta => write!(f, "TimeDelta"),
|
||||||
ResultType::Boolean => write!(f, "Boolean"),
|
ResultType::Boolean => write!(f, "Boolean"),
|
||||||
|
ResultType::Empty => write!(f, "Empty"),
|
||||||
|
ResultType::NonCalculable => write!(f, "NonCalculable"),
|
||||||
ResultType::Error => write!(f, "Error"),
|
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 {
|
pub fn error(message: &str, span: Span) -> Self {
|
||||||
CalcResult {
|
CalcResult {
|
||||||
value: CalcValue::Error {
|
value: CalcValue::Error {
|
||||||
@@ -186,13 +220,34 @@ impl CalcResult {
|
|||||||
pub fn result_type(&self) -> ResultType {
|
pub fn result_type(&self) -> ResultType {
|
||||||
self.metadata.result_type
|
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 {
|
fn format_number(val: f64) -> String {
|
||||||
if val == val.floor() && val.abs() < 1e15 {
|
if val == val.floor() && val.abs() < 1e15 {
|
||||||
format!("{}", val as i64)
|
format!("{}", val as i64)
|
||||||
} else {
|
} 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() {
|
fn pipeline_eval_empty_input() {
|
||||||
let mut ctx = EvalContext::new();
|
let mut ctx = EvalContext::new();
|
||||||
let result = eval_line("", &mut ctx);
|
let result = eval_line("", &mut ctx);
|
||||||
assert_eq!(result.result_type(), ResultType::Error);
|
assert_eq!(result.result_type(), ResultType::Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pipeline_eval_comment_only() {
|
fn pipeline_eval_comment_only() {
|
||||||
let mut ctx = EvalContext::new();
|
let mut ctx = EvalContext::new();
|
||||||
let result = eval_line("// this is a comment", &mut ctx);
|
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) =====
|
// ===== Variable assignment tests (eval_sheet) =====
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ fn sheet_with_comments() {
|
|||||||
],
|
],
|
||||||
&mut ctx,
|
&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");
|
assert_eq!(results[3].metadata.display, "2000");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ fn sheet_with_empty_lines() {
|
|||||||
let mut ctx = EvalContext::new();
|
let mut ctx = EvalContext::new();
|
||||||
let results = eval_sheet(&["x = 10", "", "y = x + 5"], &mut ctx);
|
let results = eval_sheet(&["x = 10", "", "y = x + 5"], &mut ctx);
|
||||||
assert_eq!(results[0].metadata.display, "10");
|
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");
|
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
|
import PackageDescription
|
||||||
|
|
||||||
// Path to the Rust static library built by `cargo build --release`
|
// Path to the Rust static library built by `cargo build --release`
|
||||||
let rustLibPath = "../calcpad-engine/target/release"
|
let rustLibPath = "../target/release"
|
||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "CalcPad",
|
name: "CalcPad",
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
|
import AppKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct CalcPadApp: App {
|
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 {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
|
.onAppear {
|
||||||
|
NSApplication.shared.activate(ignoringOtherApps: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.defaultSize(width: 800, height: 600)
|
.defaultSize(width: 800, height: 600)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import SwiftUI
|
|||||||
|
|
||||||
/// Displays calculation results in a vertical column, one result per line,
|
/// Displays calculation results in a vertical column, one result per line,
|
||||||
/// aligned to match the corresponding editor lines.
|
/// aligned to match the corresponding editor lines.
|
||||||
/// Uses NSViewRepresentable wrapping NSScrollView + NSTextView for pixel-perfect
|
/// Uses NSViewRepresentable wrapping NSScrollView + StripedTextView for
|
||||||
/// line height alignment with the editor.
|
/// pixel-perfect line height alignment with the editor, including zebra striping.
|
||||||
struct AnswerColumnView: NSViewRepresentable {
|
struct AnswerColumnView: NSViewRepresentable {
|
||||||
let results: [LineResult]
|
let results: [LineResult]
|
||||||
@Binding var scrollOffset: CGFloat
|
@Binding var scrollOffset: CGFloat
|
||||||
var font: NSFont
|
var font: NSFont
|
||||||
|
var alignment: NSTextAlignment = .right
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeCoordinator() -> Coordinator {
|
||||||
Coordinator()
|
Coordinator()
|
||||||
@@ -24,23 +25,23 @@ struct AnswerColumnView: NSViewRepresentable {
|
|||||||
scrollView.verticalScrollElasticity = .none
|
scrollView.verticalScrollElasticity = .none
|
||||||
scrollView.horizontalScrollElasticity = .none
|
scrollView.horizontalScrollElasticity = .none
|
||||||
|
|
||||||
let textView = NSTextView()
|
let textView = StripedTextView()
|
||||||
textView.isEditable = false
|
textView.isEditable = false
|
||||||
textView.isSelectable = true
|
textView.isSelectable = true
|
||||||
textView.isRichText = true
|
textView.isRichText = true
|
||||||
textView.usesFontPanel = false
|
textView.usesFontPanel = false
|
||||||
textView.drawsBackground = false
|
textView.drawsBackground = true
|
||||||
textView.backgroundColor = .clear
|
textView.backgroundColor = .clear
|
||||||
|
|
||||||
// Match editor text container settings for alignment
|
// Match editor text container settings for alignment
|
||||||
textView.textContainer?.lineFragmentPadding = 4
|
textView.textContainer?.lineFragmentPadding = 4
|
||||||
textView.textContainerInset = NSSize(width: 8, height: 8)
|
textView.textContainerInset = NSSize(width: 8, height: 8)
|
||||||
|
|
||||||
// Disable line wrapping to match editor behavior
|
// Let the text container track the scroll view width
|
||||||
textView.isHorizontallyResizable = true
|
textView.isHorizontallyResizable = false
|
||||||
textView.textContainer?.widthTracksTextView = false
|
textView.textContainer?.widthTracksTextView = true
|
||||||
textView.textContainer?.containerSize = NSSize(
|
textView.textContainer?.containerSize = NSSize(
|
||||||
width: CGFloat.greatestFiniteMagnitude,
|
width: 0,
|
||||||
height: CGFloat.greatestFiniteMagnitude
|
height: CGFloat.greatestFiniteMagnitude
|
||||||
)
|
)
|
||||||
textView.maxSize = NSSize(
|
textView.maxSize = NSSize(
|
||||||
@@ -73,14 +74,15 @@ struct AnswerColumnView: NSViewRepresentable {
|
|||||||
let attributedString = NSMutableAttributedString()
|
let attributedString = NSMutableAttributedString()
|
||||||
|
|
||||||
let paragraphStyle = NSMutableParagraphStyle()
|
let paragraphStyle = NSMutableParagraphStyle()
|
||||||
paragraphStyle.alignment = .right
|
paragraphStyle.alignment = alignment
|
||||||
|
|
||||||
let resultColor = NSColor.secondaryLabelColor
|
let resultColor = NSColor.secondaryLabelColor
|
||||||
let errorColor = NSColor.systemRed
|
let errorColor = NSColor.systemRed
|
||||||
|
|
||||||
for (index, lineResult) in results.enumerated() {
|
for (index, lineResult) in results.enumerated() {
|
||||||
let displayText = lineResult.result ?? ""
|
// Only show successful results — errors stay as underlines in the editor
|
||||||
let color = lineResult.isError ? errorColor : resultColor
|
let displayText = lineResult.isError ? "" : (lineResult.result ?? "")
|
||||||
|
let color = resultColor
|
||||||
|
|
||||||
let attributes: [NSAttributedString.Key: Any] = [
|
let attributes: [NSAttributedString.Key: Any] = [
|
||||||
.font: font,
|
.font: font,
|
||||||
|
|||||||
@@ -1,6 +1,52 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import SwiftUI
|
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.
|
/// A SwiftUI wrapper around NSTextView for the editor pane.
|
||||||
/// Uses NSViewRepresentable to bridge AppKit's NSTextView into SwiftUI,
|
/// Uses NSViewRepresentable to bridge AppKit's NSTextView into SwiftUI,
|
||||||
/// providing line-level control, scroll position access, and performance
|
/// providing line-level control, scroll position access, and performance
|
||||||
@@ -9,6 +55,7 @@ struct EditorTextView: NSViewRepresentable {
|
|||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
@Binding var scrollOffset: CGFloat
|
@Binding var scrollOffset: CGFloat
|
||||||
var font: NSFont
|
var font: NSFont
|
||||||
|
var alignment: NSTextAlignment = .left
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeCoordinator() -> Coordinator {
|
||||||
Coordinator(self)
|
Coordinator(self)
|
||||||
@@ -22,7 +69,7 @@ struct EditorTextView: NSViewRepresentable {
|
|||||||
scrollView.borderType = .noBorder
|
scrollView.borderType = .noBorder
|
||||||
scrollView.drawsBackground = false
|
scrollView.drawsBackground = false
|
||||||
|
|
||||||
let textView = NSTextView()
|
let textView = StripedTextView()
|
||||||
textView.isEditable = true
|
textView.isEditable = true
|
||||||
textView.isSelectable = true
|
textView.isSelectable = true
|
||||||
textView.allowsUndo = true
|
textView.allowsUndo = true
|
||||||
@@ -33,11 +80,11 @@ struct EditorTextView: NSViewRepresentable {
|
|||||||
textView.isAutomaticTextReplacementEnabled = false
|
textView.isAutomaticTextReplacementEnabled = false
|
||||||
textView.isAutomaticSpellingCorrectionEnabled = false
|
textView.isAutomaticSpellingCorrectionEnabled = false
|
||||||
|
|
||||||
// Disable line wrapping — horizontal scroll instead
|
// Enable line wrapping — tracks text view width
|
||||||
textView.isHorizontallyResizable = true
|
textView.isHorizontallyResizable = false
|
||||||
textView.textContainer?.widthTracksTextView = false
|
textView.textContainer?.widthTracksTextView = true
|
||||||
textView.textContainer?.containerSize = NSSize(
|
textView.textContainer?.containerSize = NSSize(
|
||||||
width: CGFloat.greatestFiniteMagnitude,
|
width: 0,
|
||||||
height: CGFloat.greatestFiniteMagnitude
|
height: CGFloat.greatestFiniteMagnitude
|
||||||
)
|
)
|
||||||
textView.maxSize = NSSize(
|
textView.maxSize = NSSize(
|
||||||
@@ -49,9 +96,12 @@ struct EditorTextView: NSViewRepresentable {
|
|||||||
textView.font = font
|
textView.font = font
|
||||||
textView.textColor = .textColor
|
textView.textColor = .textColor
|
||||||
textView.backgroundColor = .clear
|
textView.backgroundColor = .clear
|
||||||
textView.drawsBackground = false
|
textView.drawsBackground = true
|
||||||
textView.insertionPointColor = .textColor
|
textView.insertionPointColor = .textColor
|
||||||
|
|
||||||
|
// Set alignment
|
||||||
|
textView.alignment = alignment
|
||||||
|
|
||||||
// Set the text
|
// Set the text
|
||||||
textView.string = text
|
textView.string = text
|
||||||
|
|
||||||
@@ -84,11 +134,19 @@ struct EditorTextView: NSViewRepresentable {
|
|||||||
// Update font if changed
|
// Update font if changed
|
||||||
if textView.font != font {
|
if textView.font != font {
|
||||||
textView.font = font
|
textView.font = font
|
||||||
// Reapply font to entire text storage
|
|
||||||
let range = NSRange(location: 0, length: textView.textStorage?.length ?? 0)
|
let range = NSRange(location: 0, length: textView.textStorage?.length ?? 0)
|
||||||
textView.textStorage?.addAttribute(.font, value: font, range: range)
|
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)
|
// Update text only if it actually changed (avoid feedback loops)
|
||||||
if textView.string != text {
|
if textView.string != text {
|
||||||
let selectedRanges = textView.selectedRanges
|
let selectedRanges = textView.selectedRanges
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
|
import AppKit
|
||||||
import Combine
|
import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// The main two-column editor layout: text editor on the left, results on the right.
|
/// The main two-column editor layout: text editor on the left, results on the right.
|
||||||
/// Scrolling is synchronized between both columns.
|
/// Scrolling is synchronized between both columns.
|
||||||
|
/// Includes a toolbar for justification toggles and alternating row striping.
|
||||||
struct TwoColumnEditorView: View {
|
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 scrollOffset: CGFloat = 0
|
||||||
@State private var results: [LineResult] = []
|
@State private var results: [LineResult] = []
|
||||||
@State private var evaluationTask: Task<Void, Never>?
|
@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
|
/// Uses the Rust FFI engine. Falls back to StubCalculationEngine if the Rust
|
||||||
/// library is not linked (e.g., during UI-only development).
|
/// library is not linked (e.g., during UI-only development).
|
||||||
@@ -18,35 +22,91 @@ struct TwoColumnEditorView: View {
|
|||||||
|
|
||||||
/// Font that respects the user's accessibility / Dynamic Type settings.
|
/// Font that respects the user's accessibility / Dynamic Type settings.
|
||||||
private var editorFont: NSFont {
|
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
|
let baseSize = NSFont.systemFontSize
|
||||||
// Scale with accessibility settings via the body text style size
|
|
||||||
let preferredSize = NSFont.preferredFont(forTextStyle: .body, options: [:]).pointSize
|
let preferredSize = NSFont.preferredFont(forTextStyle: .body, options: [:]).pointSize
|
||||||
// Use the larger of system default or accessibility-preferred size
|
|
||||||
let size = max(baseSize, preferredSize)
|
let size = max(baseSize, preferredSize)
|
||||||
return NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
|
return NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HSplitView {
|
VStack(spacing: 0) {
|
||||||
// Left pane: Editor
|
// Justification toolbar
|
||||||
EditorTextView(
|
HStack(spacing: 0) {
|
||||||
text: $text,
|
// Editor alignment buttons
|
||||||
scrollOffset: $scrollOffset,
|
HStack(spacing: 4) {
|
||||||
font: editorFont
|
Button(action: { editorAlignment = .left }) {
|
||||||
)
|
Image(systemName: "text.alignleft")
|
||||||
.frame(minWidth: 200)
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.foregroundColor(editorAlignment == .left ? .accentColor : .secondary)
|
||||||
|
.help("Align editor text left")
|
||||||
|
|
||||||
// Divider is automatic with HSplitView
|
Button(action: { editorAlignment = .center }) {
|
||||||
|
Image(systemName: "text.aligncenter")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.foregroundColor(editorAlignment == .center ? .accentColor : .secondary)
|
||||||
|
.help("Align editor text center")
|
||||||
|
|
||||||
// Right pane: Answer column
|
Button(action: { editorAlignment = .right }) {
|
||||||
AnswerColumnView(
|
Image(systemName: "text.alignright")
|
||||||
results: results,
|
}
|
||||||
scrollOffset: $scrollOffset,
|
.buttonStyle(.borderless)
|
||||||
font: editorFont
|
.foregroundColor(editorAlignment == .right ? .accentColor : .secondary)
|
||||||
)
|
.help("Align editor text right")
|
||||||
.frame(minWidth: 120, idealWidth: 200)
|
}
|
||||||
|
.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,
|
||||||
|
alignment: editorAlignment
|
||||||
|
)
|
||||||
|
.frame(minWidth: 200)
|
||||||
|
|
||||||
|
// Right pane: Answer column
|
||||||
|
AnswerColumnView(
|
||||||
|
results: results,
|
||||||
|
scrollOffset: $scrollOffset,
|
||||||
|
font: editorFont,
|
||||||
|
alignment: resultsAlignment
|
||||||
|
)
|
||||||
|
.frame(minWidth: 120, idealWidth: 200)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: text) { _, newValue in
|
.onChange(of: text) { _, newValue in
|
||||||
scheduleEvaluation(newValue)
|
scheduleEvaluation(newValue)
|
||||||
@@ -56,7 +116,6 @@ struct TwoColumnEditorView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Debounce evaluation so rapid typing doesn't cause excessive recalculation.
|
|
||||||
private func scheduleEvaluation(_ newText: String) {
|
private func scheduleEvaluation(_ newText: String) {
|
||||||
evaluationTask?.cancel()
|
evaluationTask?.cancel()
|
||||||
evaluationTask = Task { @MainActor in
|
evaluationTask = Task { @MainActor in
|
||||||
|
|||||||
@@ -105,12 +105,12 @@ struct RustEngineTests {
|
|||||||
#expect(result.isError == false)
|
#expect(result.isError == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("# is not a comment in Rust engine — treated as identifier lookup")
|
@Test("# heading is treated as non-calculable, not an error")
|
||||||
func hashNotComment() {
|
func hashHeading() {
|
||||||
let result = engine.evaluateLine("# header")
|
let result = engine.evaluateLine("# header")
|
||||||
// The Rust engine does not treat # as a comment. The # is skipped and
|
// The Rust engine now detects headings (# followed by space) as non-calculable.
|
||||||
// "header" is parsed as an identifier, resulting in an undefined variable error.
|
#expect(result.isError == false)
|
||||||
#expect(result.isError == true)
|
#expect(result.result == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - AC: Memory safety — no leaks with repeated calls
|
// MARK: - AC: Memory safety — no leaks with repeated calls
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ description = "CalcPad calculation engine compiled to WebAssembly"
|
|||||||
crate-type = ["cdylib", "rlib"]
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
calcpad-engine = { path = "../calcpad-engine" }
|
calcpad-engine = { path = "../calcpad-engine", default-features = false }
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde-wasm-bindgen = "0.6"
|
serde-wasm-bindgen = "0.6"
|
||||||
js-sys = "0.3"
|
js-sys = "0.3"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
|
getrandom = { version = "0.2", features = ["js"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
wasm-bindgen-test = "0.3"
|
wasm-bindgen-test = "0.3"
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ impl From<&CalcResult> for JsResult {
|
|||||||
ResultType::DateTime => "dateTime",
|
ResultType::DateTime => "dateTime",
|
||||||
ResultType::TimeDelta => "timeDelta",
|
ResultType::TimeDelta => "timeDelta",
|
||||||
ResultType::Boolean => "boolean",
|
ResultType::Boolean => "boolean",
|
||||||
|
ResultType::Empty => "empty",
|
||||||
|
ResultType::NonCalculable => "nonCalculable",
|
||||||
ResultType::Error => "error",
|
ResultType::Error => "error",
|
||||||
}
|
}
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|||||||
@@ -10,7 +10,18 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
<meta name="apple-mobile-web-app-title" content="CalcPad" />
|
<meta name="apple-mobile-web-app-title" content="CalcPad" />
|
||||||
<link rel="apple-touch-icon" href="/icons/icon-192.svg" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,80 +1,338 @@
|
|||||||
/**
|
/**
|
||||||
* CalcPad main application component.
|
* CalcText main application component.
|
||||||
*
|
*
|
||||||
* Two-column layout:
|
* Workspace layout: header → tab bar → editor + results panel.
|
||||||
* Left: CodeMirror 6 editor with CalcPad syntax highlighting
|
* Multi-document support via document store with localStorage persistence.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback } from 'react'
|
import { useCallback, useState, useRef, useEffect } from 'react'
|
||||||
|
import type { EditorView } from '@codemirror/view'
|
||||||
import { CalcEditor } from './editor/CalcEditor.tsx'
|
import { CalcEditor } from './editor/CalcEditor.tsx'
|
||||||
import { useEngine } from './engine/useEngine.ts'
|
import { useEngine } from './engine/useEngine.ts'
|
||||||
import { useOnlineStatus } from './hooks/useOnlineStatus.ts'
|
import { useOnlineStatus } from './hooks/useOnlineStatus.ts'
|
||||||
import { useInstallPrompt } from './hooks/useInstallPrompt.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 { OfflineBanner } from './components/OfflineBanner.tsx'
|
||||||
import { InstallPrompt } from './components/InstallPrompt.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'
|
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() {
|
function App() {
|
||||||
const engine = useEngine()
|
const engine = useEngine()
|
||||||
const isOnline = useOnlineStatus()
|
const isOnline = useOnlineStatus()
|
||||||
const installPrompt = useInstallPrompt()
|
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(
|
const handleDocChange = useCallback(
|
||||||
(lines: string[]) => {
|
(lines: string[]) => {
|
||||||
engine.evalSheet(lines)
|
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 (
|
return (
|
||||||
<div className="calcpad-app">
|
<div className="calcpad-app">
|
||||||
<OfflineBanner isOnline={isOnline} />
|
<OfflineBanner isOnline={isOnline} />
|
||||||
|
|
||||||
<header className="calcpad-header">
|
<header className="calcpad-header">
|
||||||
<h1>CalcPad</h1>
|
<button
|
||||||
<p className="subtitle">Notepad Calculator</p>
|
className="header-sidebar-toggle"
|
||||||
<div className="header-status">
|
onClick={() => setSidebarVisible(!sidebarState.visible)}
|
||||||
<span className={`status-dot ${engine.ready ? '' : 'loading'}`} />
|
title="Toggle sidebar (Ctrl+B)"
|
||||||
<span>{engine.ready ? 'Engine ready' : 'Loading engine...'}</span>
|
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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="calcpad-editor">
|
<TabBar
|
||||||
<div className="editor-pane">
|
tabs={store.openDocs}
|
||||||
<CalcEditor
|
activeTabId={store.activeTabId}
|
||||||
initialDoc={INITIAL_DOC}
|
onTabClick={handleTabClick}
|
||||||
onDocChange={handleDocChange}
|
onTabClose={handleTabClose}
|
||||||
results={engine.results}
|
onTabRename={store.renameDocument}
|
||||||
debounceMs={50}
|
onNewTab={handleNewTab}
|
||||||
|
modifiedIds={modifiedIds}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="calcpad-workspace">
|
||||||
|
{/* Mobile sidebar backdrop */}
|
||||||
|
{sidebarState.visible && (
|
||||||
|
<div
|
||||||
|
className="sidebar-backdrop"
|
||||||
|
onClick={() => setSidebarVisible(false)}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
</main>
|
<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" style={editorStyle}>
|
||||||
|
<CalcEditor
|
||||||
|
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
|
<InstallPrompt
|
||||||
promptEvent={installPrompt.promptEvent}
|
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.
|
* 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.
|
* and debounced evaluation via the WASM engine Web Worker.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -15,15 +15,17 @@ import {
|
|||||||
keymap,
|
keymap,
|
||||||
} from '@codemirror/view'
|
} from '@codemirror/view'
|
||||||
import {
|
import {
|
||||||
defaultHighlightStyle,
|
|
||||||
syntaxHighlighting,
|
syntaxHighlighting,
|
||||||
bracketMatching,
|
bracketMatching,
|
||||||
indentOnInput,
|
indentOnInput,
|
||||||
|
HighlightStyle,
|
||||||
} from '@codemirror/language'
|
} from '@codemirror/language'
|
||||||
|
import { tags } from '@lezer/highlight'
|
||||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||||
import { calcpadLanguage } from './calcpad-language.ts'
|
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 { 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'
|
import type { EngineLineResult } from '../engine/types.ts'
|
||||||
|
|
||||||
export interface CalcEditorProps {
|
export interface CalcEditorProps {
|
||||||
@@ -31,22 +33,27 @@ export interface CalcEditorProps {
|
|||||||
initialDoc?: string
|
initialDoc?: string
|
||||||
/** Called when the document text changes (debounced internally) */
|
/** Called when the document text changes (debounced internally) */
|
||||||
onDocChange?: (lines: string[]) => void
|
onDocChange?: (lines: string[]) => void
|
||||||
/** Engine evaluation results to display in the answer gutter */
|
/** Engine evaluation results to display as errors */
|
||||||
results?: EngineLineResult[]
|
results?: EngineLineResult[]
|
||||||
/** Debounce delay in ms before triggering onDocChange */
|
/** Debounce delay in ms before triggering onDocChange */
|
||||||
debounceMs?: number
|
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.
|
* CalcPad editor component built on CodeMirror 6.
|
||||||
* Handles syntax highlighting, line numbers, answer gutter,
|
* Handles syntax highlighting, line numbers, and error underlines.
|
||||||
* and error underlines.
|
|
||||||
*/
|
*/
|
||||||
export function CalcEditor({
|
export function CalcEditor({
|
||||||
initialDoc = '',
|
initialDoc = '',
|
||||||
onDocChange,
|
onDocChange,
|
||||||
results,
|
results,
|
||||||
debounceMs = 50,
|
debounceMs = 50,
|
||||||
|
onViewReady,
|
||||||
|
formatPreview = true,
|
||||||
}: CalcEditorProps) {
|
}: CalcEditorProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const viewRef = useRef<EditorView | null>(null)
|
const viewRef = useRef<EditorView | null>(null)
|
||||||
@@ -86,10 +93,11 @@ export function CalcEditor({
|
|||||||
indentOnInput(),
|
indentOnInput(),
|
||||||
history(),
|
history(),
|
||||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||||
syntaxHighlighting(defaultHighlightStyle),
|
syntaxHighlighting(calcpadHighlight),
|
||||||
calcpadLanguage(),
|
calcpadLanguage(),
|
||||||
answerGutterExtension(),
|
|
||||||
errorDisplayExtension(),
|
errorDisplayExtension(),
|
||||||
|
stripedLinesExtension(),
|
||||||
|
formatPreviewExtension(formatPreview),
|
||||||
updateListener,
|
updateListener,
|
||||||
calcpadEditorTheme,
|
calcpadEditorTheme,
|
||||||
],
|
],
|
||||||
@@ -101,6 +109,7 @@ export function CalcEditor({
|
|||||||
})
|
})
|
||||||
|
|
||||||
viewRef.current = view
|
viewRef.current = view
|
||||||
|
onViewReady?.(view)
|
||||||
|
|
||||||
// Trigger initial evaluation
|
// Trigger initial evaluation
|
||||||
const doc = view.state.doc.toString()
|
const doc = view.state.doc.toString()
|
||||||
@@ -110,23 +119,33 @@ export function CalcEditor({
|
|||||||
if (timerRef.current) clearTimeout(timerRef.current)
|
if (timerRef.current) clearTimeout(timerRef.current)
|
||||||
view.destroy()
|
view.destroy()
|
||||||
viewRef.current = null
|
viewRef.current = null
|
||||||
|
onViewReady?.(null)
|
||||||
}
|
}
|
||||||
// initialDoc intentionally excluded — we only set it once on mount
|
// initialDoc intentionally excluded — we only set it once on mount
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [scheduleEval])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const view = viewRef.current
|
const view = viewRef.current
|
||||||
if (!view || !results) return
|
if (!view || !results) return
|
||||||
|
|
||||||
const answers: LineAnswer[] = []
|
|
||||||
const errors: LineError[] = []
|
const errors: LineError[] = []
|
||||||
|
|
||||||
for (let i = 0; i < results.length; i++) {
|
for (let i = 0; i < results.length; i++) {
|
||||||
const lineNum = i + 1
|
const lineNum = i + 1
|
||||||
const result = results[i]
|
const result = results[i]
|
||||||
answers.push({ line: lineNum, result })
|
|
||||||
|
|
||||||
if (result.type === 'error' && result.error) {
|
if (result.type === 'error' && result.error) {
|
||||||
// Map to document positions
|
// Map to document positions
|
||||||
@@ -143,7 +162,6 @@ export function CalcEditor({
|
|||||||
|
|
||||||
view.dispatch({
|
view.dispatch({
|
||||||
effects: [
|
effects: [
|
||||||
setAnswersEffect.of(answers),
|
|
||||||
setErrorsEffect.of(errors),
|
setErrorsEffect.of(errors),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -152,6 +170,24 @@ export function CalcEditor({
|
|||||||
return <div ref={containerRef} className="calc-editor" />
|
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.
|
* Base theme for the CalcPad editor.
|
||||||
*/
|
*/
|
||||||
@@ -159,64 +195,44 @@ const calcpadEditorTheme = EditorView.baseTheme({
|
|||||||
'&': {
|
'&': {
|
||||||
height: '100%',
|
height: '100%',
|
||||||
fontSize: '15px',
|
fontSize: '15px',
|
||||||
fontFamily: 'ui-monospace, Consolas, "Courier New", monospace',
|
fontFamily: 'var(--mono, ui-monospace, Consolas, "Courier New", monospace)',
|
||||||
},
|
},
|
||||||
'.cm-scroller': {
|
'.cm-scroller': {
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
},
|
},
|
||||||
'.cm-content': {
|
'.cm-content': {
|
||||||
padding: '12px 0',
|
padding: '8px 0',
|
||||||
minHeight: '100%',
|
minHeight: '100%',
|
||||||
},
|
},
|
||||||
'.cm-line': {
|
'.cm-line': {
|
||||||
padding: '0 16px',
|
padding: '0 12px',
|
||||||
lineHeight: '1.6',
|
lineHeight: '1.6',
|
||||||
|
position: 'relative',
|
||||||
|
textAlign: 'var(--cm-text-align, left)',
|
||||||
},
|
},
|
||||||
'.cm-gutters': {
|
'.cm-gutters': {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
borderRight: 'none',
|
borderRight: 'none',
|
||||||
},
|
},
|
||||||
'.cm-lineNumbers .cm-gutterElement': {
|
'.cm-lineNumbers .cm-gutterElement': {
|
||||||
padding: '0 8px 0 16px',
|
padding: '0 6px 0 12px',
|
||||||
color: '#9ca3af',
|
color: 'var(--text, #9ca3af)',
|
||||||
|
opacity: '0.4',
|
||||||
fontSize: '13px',
|
fontSize: '13px',
|
||||||
minWidth: '32px',
|
minWidth: '32px',
|
||||||
},
|
},
|
||||||
'.cm-answer-gutter': {
|
'.cm-activeLineGutter .cm-gutterElement': {
|
||||||
minWidth: '140px',
|
opacity: '1',
|
||||||
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',
|
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
'&dark .cm-answer-value': {
|
'.cm-stripe': {
|
||||||
color: '#818cf8',
|
backgroundColor: 'var(--stripe, rgba(0, 0, 0, 0.02))',
|
||||||
},
|
|
||||||
'.cm-answer-error': {
|
|
||||||
color: '#e53e3e',
|
|
||||||
fontStyle: 'italic',
|
|
||||||
},
|
|
||||||
'&dark .cm-answer-error': {
|
|
||||||
color: '#fc8181',
|
|
||||||
},
|
},
|
||||||
'.cm-activeLine': {
|
'.cm-activeLine': {
|
||||||
backgroundColor: 'rgba(99, 102, 241, 0.04)',
|
backgroundColor: 'var(--accent-bg, rgba(99, 102, 241, 0.04))',
|
||||||
},
|
|
||||||
'&dark .cm-activeLine': {
|
|
||||||
backgroundColor: 'rgba(129, 140, 248, 0.06)',
|
|
||||||
},
|
},
|
||||||
'.cm-selectionBackground': {
|
'.cm-selectionBackground': {
|
||||||
backgroundColor: 'rgba(99, 102, 241, 0.15) !important',
|
backgroundColor: 'var(--accent-bg, rgba(99, 102, 241, 0.15)) !important',
|
||||||
},
|
},
|
||||||
'.cm-focused': {
|
'.cm-focused': {
|
||||||
outline: 'none',
|
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,
|
GutterMarker,
|
||||||
gutter,
|
gutter,
|
||||||
EditorView,
|
EditorView,
|
||||||
|
hoverTooltip,
|
||||||
|
type Tooltip,
|
||||||
} from '@codemirror/view'
|
} from '@codemirror/view'
|
||||||
import { StateField, StateEffect, type Extension, RangeSet } from '@codemirror/state'
|
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 ---
|
// --- Error Gutter ---
|
||||||
|
|
||||||
export const errorGutter = gutter({
|
export const errorGutter = gutter({
|
||||||
@@ -118,15 +162,32 @@ export const errorGutter = gutter({
|
|||||||
|
|
||||||
export const errorBaseTheme = EditorView.baseTheme({
|
export const errorBaseTheme = EditorView.baseTheme({
|
||||||
'.cm-error-underline': {
|
'.cm-error-underline': {
|
||||||
textDecoration: 'underline wavy red',
|
textDecoration: 'underline wavy var(--error, red)',
|
||||||
|
textDecorationThickness: '1.5px',
|
||||||
},
|
},
|
||||||
'.cm-error-marker': {
|
'.cm-error-marker': {
|
||||||
color: '#e53e3e',
|
color: 'var(--error, #e53e3e)',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
|
cursor: 'pointer',
|
||||||
},
|
},
|
||||||
'.cm-error-gutter': {
|
'.cm-error-gutter': {
|
||||||
width: '20px',
|
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 [
|
return [
|
||||||
errorDecorationsField,
|
errorDecorationsField,
|
||||||
errorLinesField,
|
errorLinesField,
|
||||||
|
errorMessagesField,
|
||||||
errorGutter,
|
errorGutter,
|
||||||
|
errorTooltip,
|
||||||
errorBaseTheme,
|
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> {
|
async function initWasm(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
// Try to load the wasm-pack output
|
// 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>
|
default: () => Promise<void>
|
||||||
evalSheet: (lines: string[]) => EngineLineResult[]
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px 24px;
|
padding: 6px 12px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calcpad-header h1 {
|
.calcpad-header h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 20px;
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
letter-spacing: -0.3px;
|
letter-spacing: -0.3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calcpad-header .subtitle {
|
.header-spacer {
|
||||||
margin: 0;
|
flex: 1;
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-status {
|
.header-actions {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
font-size: 12px;
|
}
|
||||||
|
|
||||||
|
.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);
|
color: var(--text);
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot {
|
.header-sidebar-toggle:hover {
|
||||||
width: 6px;
|
background: var(--accent-bg);
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--success);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot.loading {
|
/* ---------- Workspace (sidebar + editor area) ---------- */
|
||||||
background: var(--warning);
|
|
||||||
animation: pulse 1.5s ease-in-out infinite;
|
.calcpad-workspace {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
/* ---------- Editor area (two-column layout) ---------- */
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.3; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- Editor area ---------- */
|
|
||||||
|
|
||||||
.calcpad-editor {
|
.calcpad-editor {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -65,7 +82,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.editor-pane {
|
.editor-pane {
|
||||||
flex: 1;
|
flex: 3;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -79,14 +96,87 @@
|
|||||||
height: 100%;
|
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) {
|
.pane-divider:hover {
|
||||||
.calcpad-header {
|
background: var(--accent);
|
||||||
padding: 10px 16px;
|
}
|
||||||
|
|
||||||
|
/* 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;
|
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 {
|
:root {
|
||||||
--text: #6b6375;
|
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||||
--text-h: #08060d;
|
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||||
--bg: #fff;
|
--mono: ui-monospace, Consolas, 'Courier New', monospace;
|
||||||
--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);
|
|
||||||
--warning: #f59e0b;
|
--warning: #f59e0b;
|
||||||
--warning-bg: rgba(245, 158, 11, 0.1);
|
--warning-bg: rgba(245, 158, 11, 0.1);
|
||||||
--success: #10b981;
|
--success: #10b981;
|
||||||
--success-bg: rgba(16, 185, 129, 0.1);
|
--success-bg: rgba(16, 185, 129, 0.1);
|
||||||
--error: #e53e3e;
|
--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);
|
font: 16px/1.5 var(--sans);
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -28,18 +21,180 @@
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
/* ---------- Theme: Light (default) ---------- */
|
||||||
:root {
|
|
||||||
--text: #9ca3af;
|
:root,
|
||||||
--text-h: #f3f4f6;
|
[data-theme="light"] {
|
||||||
--bg: #16171d;
|
--text: #6b6375;
|
||||||
--bg-secondary: #1a1b23;
|
--text-h: #08060d;
|
||||||
--border: #2e303a;
|
--bg: #fff;
|
||||||
--code-bg: #1f2028;
|
--bg-secondary: #f8f9fa;
|
||||||
--accent: #818cf8;
|
--border: #e5e4e7;
|
||||||
--accent-bg: rgba(129, 140, 248, 0.15);
|
--code-bg: #f4f3ec;
|
||||||
--accent-border: rgba(129, 140, 248, 0.5);
|
--accent: #6366f1;
|
||||||
}
|
--accent-bg: rgba(99, 102, 241, 0.1);
|
||||||
|
--accent-border: rgba(99, 102, 241, 0.5);
|
||||||
|
--stripe: rgba(0, 0, 0, 0.02);
|
||||||
|
|
||||||
|
--syntax-variable: #4f46e5;
|
||||||
|
--syntax-number: #0d9488;
|
||||||
|
--syntax-operator: #6b6375;
|
||||||
|
--syntax-keyword: #7c3aed;
|
||||||
|
--syntax-function: #2563eb;
|
||||||
|
--syntax-currency: #d97706;
|
||||||
|
--syntax-comment: rgba(107, 99, 117, 0.5);
|
||||||
|
--syntax-heading: #08060d;
|
||||||
|
|
||||||
|
--result-number: #374151;
|
||||||
|
--result-unit: #0d9488;
|
||||||
|
--result-currency: #d97706;
|
||||||
|
--result-datetime: #7c3aed;
|
||||||
|
--result-boolean: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Theme: Dark ---------- */
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--text: #9ca3af;
|
||||||
|
--text-h: #f3f4f6;
|
||||||
|
--bg: #16171d;
|
||||||
|
--bg-secondary: #1a1b23;
|
||||||
|
--border: #2e303a;
|
||||||
|
--code-bg: #1f2028;
|
||||||
|
--accent: #818cf8;
|
||||||
|
--accent-bg: rgba(129, 140, 248, 0.15);
|
||||||
|
--accent-border: rgba(129, 140, 248, 0.5);
|
||||||
|
--stripe: rgba(255, 255, 255, 0.025);
|
||||||
|
|
||||||
|
--syntax-variable: #a5b4fc;
|
||||||
|
--syntax-number: #5eead4;
|
||||||
|
--syntax-operator: #9ca3af;
|
||||||
|
--syntax-keyword: #c4b5fd;
|
||||||
|
--syntax-function: #93c5fd;
|
||||||
|
--syntax-currency: #fcd34d;
|
||||||
|
--syntax-comment: rgba(156, 163, 175, 0.5);
|
||||||
|
--syntax-heading: #f3f4f6;
|
||||||
|
|
||||||
|
--result-number: #d1d5db;
|
||||||
|
--result-unit: #5eead4;
|
||||||
|
--result-currency: #fcd34d;
|
||||||
|
--result-datetime: #c4b5fd;
|
||||||
|
--result-boolean: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Theme: Matrix ---------- */
|
||||||
|
|
||||||
|
[data-theme="matrix"] {
|
||||||
|
--text: #00ff41;
|
||||||
|
--text-h: #33ff66;
|
||||||
|
--bg: #0a0a0a;
|
||||||
|
--bg-secondary: #0f1a0f;
|
||||||
|
--border: #003300;
|
||||||
|
--code-bg: #0a0f0a;
|
||||||
|
--accent: #00ff41;
|
||||||
|
--accent-bg: rgba(0, 255, 65, 0.1);
|
||||||
|
--accent-border: rgba(0, 255, 65, 0.4);
|
||||||
|
--stripe: rgba(0, 255, 65, 0.03);
|
||||||
|
|
||||||
|
--syntax-variable: #00ff41;
|
||||||
|
--syntax-number: #00cc33;
|
||||||
|
--syntax-operator: #00ff41;
|
||||||
|
--syntax-keyword: #39ff14;
|
||||||
|
--syntax-function: #00ff41;
|
||||||
|
--syntax-currency: #ffff00;
|
||||||
|
--syntax-comment: rgba(0, 255, 65, 0.4);
|
||||||
|
--syntax-heading: #33ff66;
|
||||||
|
|
||||||
|
--result-number: #00ff41;
|
||||||
|
--result-unit: #00cc33;
|
||||||
|
--result-currency: #ffff00;
|
||||||
|
--result-datetime: #39ff14;
|
||||||
|
--result-boolean: #00ff41;
|
||||||
|
|
||||||
|
--mono: 'Courier New', 'Fira Code', monospace;
|
||||||
|
--success: #00ff41;
|
||||||
|
--error: #ff0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Matrix special effects */
|
||||||
|
[data-theme="matrix"] .calcpad-app::after {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
transparent,
|
||||||
|
transparent 2px,
|
||||||
|
rgba(0, 0, 0, 0.06) 2px,
|
||||||
|
rgba(0, 0, 0, 0.06) 4px
|
||||||
|
);
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="matrix"] .cm-cursor {
|
||||||
|
border-color: #00ff41 !important;
|
||||||
|
box-shadow: 0 0 4px #00ff41, 0 0 8px rgba(0, 255, 65, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Theme: Midnight ---------- */
|
||||||
|
|
||||||
|
[data-theme="midnight"] {
|
||||||
|
--text: #94a3b8;
|
||||||
|
--text-h: #e2e8f0;
|
||||||
|
--bg: #0f172a;
|
||||||
|
--bg-secondary: #1e293b;
|
||||||
|
--border: #334155;
|
||||||
|
--code-bg: #1e293b;
|
||||||
|
--accent: #38bdf8;
|
||||||
|
--accent-bg: rgba(56, 189, 248, 0.12);
|
||||||
|
--accent-border: rgba(56, 189, 248, 0.5);
|
||||||
|
--stripe: rgba(56, 189, 248, 0.03);
|
||||||
|
|
||||||
|
--syntax-variable: #7dd3fc;
|
||||||
|
--syntax-number: #5eead4;
|
||||||
|
--syntax-operator: #94a3b8;
|
||||||
|
--syntax-keyword: #c4b5fd;
|
||||||
|
--syntax-function: #7dd3fc;
|
||||||
|
--syntax-currency: #fcd34d;
|
||||||
|
--syntax-comment: rgba(148, 163, 184, 0.5);
|
||||||
|
--syntax-heading: #e2e8f0;
|
||||||
|
|
||||||
|
--result-number: #cbd5e1;
|
||||||
|
--result-unit: #5eead4;
|
||||||
|
--result-currency: #fcd34d;
|
||||||
|
--result-datetime: #c4b5fd;
|
||||||
|
--result-boolean: #38bdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Theme: Warm ---------- */
|
||||||
|
|
||||||
|
[data-theme="warm"] {
|
||||||
|
--text: #78716c;
|
||||||
|
--text-h: #1c1917;
|
||||||
|
--bg: #fffbf5;
|
||||||
|
--bg-secondary: #fef3e2;
|
||||||
|
--border: #e7e5e4;
|
||||||
|
--code-bg: #fef3e2;
|
||||||
|
--accent: #f97316;
|
||||||
|
--accent-bg: rgba(249, 115, 22, 0.1);
|
||||||
|
--accent-border: rgba(249, 115, 22, 0.5);
|
||||||
|
--stripe: rgba(249, 115, 22, 0.03);
|
||||||
|
|
||||||
|
--syntax-variable: #c2410c;
|
||||||
|
--syntax-number: #0d9488;
|
||||||
|
--syntax-operator: #78716c;
|
||||||
|
--syntax-keyword: #7c3aed;
|
||||||
|
--syntax-function: #2563eb;
|
||||||
|
--syntax-currency: #d97706;
|
||||||
|
--syntax-comment: rgba(120, 113, 108, 0.5);
|
||||||
|
--syntax-heading: #1c1917;
|
||||||
|
|
||||||
|
--result-number: #44403c;
|
||||||
|
--result-unit: #0d9488;
|
||||||
|
--result-currency: #d97706;
|
||||||
|
--result-datetime: #7c3aed;
|
||||||
|
--result-boolean: #f97316;
|
||||||
}
|
}
|
||||||
|
|
||||||
*,
|
*,
|
||||||
|
|||||||
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" />
|
/// <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' {
|
declare module 'virtual:pwa-register' {
|
||||||
export interface RegisterSWOptions {
|
export interface RegisterSWOptions {
|
||||||
immediate?: boolean
|
immediate?: boolean
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"target": "ES2023",
|
"target": "ES2023",
|
||||||
"lib": ["ES2023"],
|
"lib": ["ES2023"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"types": ["node"],
|
"types": [],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default defineConfig({
|
|||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: 'prompt',
|
registerType: 'autoUpdate',
|
||||||
includeAssets: ['favicon.svg', 'icons/*.svg'],
|
includeAssets: ['favicon.svg', 'icons/*.svg'],
|
||||||
manifest: {
|
manifest: {
|
||||||
name: 'CalcPad',
|
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