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

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

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

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

View File

@@ -1,7 +1,7 @@
/**
* React wrapper around CodeMirror 6 for the CalcPad editor.
*
* Integrates the CalcPad language mode, answer gutter, error display,
* Integrates the CalcPad language mode, error display,
* and debounced evaluation via the WASM engine Web Worker.
*/
@@ -15,15 +15,17 @@ import {
keymap,
} from '@codemirror/view'
import {
defaultHighlightStyle,
syntaxHighlighting,
bracketMatching,
indentOnInput,
HighlightStyle,
} from '@codemirror/language'
import { tags } from '@lezer/highlight'
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
import { calcpadLanguage } from './calcpad-language.ts'
import { answerGutterExtension, setAnswersEffect, type LineAnswer } from './answer-gutter.ts'
import { errorDisplayExtension, setErrorsEffect, type LineError } from './error-display.ts'
import { stripedLinesExtension } from './inline-results.ts'
import { formatPreviewExtension, formatPreviewCompartment, formatPreviewEnabled } from './format-preview.ts'
import type { EngineLineResult } from '../engine/types.ts'
export interface CalcEditorProps {
@@ -31,22 +33,27 @@ export interface CalcEditorProps {
initialDoc?: string
/** Called when the document text changes (debounced internally) */
onDocChange?: (lines: string[]) => void
/** Engine evaluation results to display in the answer gutter */
/** Engine evaluation results to display as errors */
results?: EngineLineResult[]
/** Debounce delay in ms before triggering onDocChange */
debounceMs?: number
/** Called with the EditorView once created (null on cleanup) */
onViewReady?: (view: EditorView | null) => void
/** Enable live preview formatting */
formatPreview?: boolean
}
/**
* CalcPad editor component built on CodeMirror 6.
* Handles syntax highlighting, line numbers, answer gutter,
* and error underlines.
* Handles syntax highlighting, line numbers, and error underlines.
*/
export function CalcEditor({
initialDoc = '',
onDocChange,
results,
debounceMs = 50,
onViewReady,
formatPreview = true,
}: CalcEditorProps) {
const containerRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<EditorView | null>(null)
@@ -86,10 +93,11 @@ export function CalcEditor({
indentOnInput(),
history(),
keymap.of([...defaultKeymap, ...historyKeymap]),
syntaxHighlighting(defaultHighlightStyle),
syntaxHighlighting(calcpadHighlight),
calcpadLanguage(),
answerGutterExtension(),
errorDisplayExtension(),
stripedLinesExtension(),
formatPreviewExtension(formatPreview),
updateListener,
calcpadEditorTheme,
],
@@ -101,6 +109,7 @@ export function CalcEditor({
})
viewRef.current = view
onViewReady?.(view)
// Trigger initial evaluation
const doc = view.state.doc.toString()
@@ -110,23 +119,33 @@ export function CalcEditor({
if (timerRef.current) clearTimeout(timerRef.current)
view.destroy()
viewRef.current = null
onViewReady?.(null)
}
// initialDoc intentionally excluded — we only set it once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scheduleEval])
// Push engine results into the answer gutter + error display
// Toggle format preview mode
useEffect(() => {
const view = viewRef.current
if (!view) return
view.dispatch({
effects: formatPreviewCompartment.reconfigure(
formatPreview ? formatPreviewEnabled : [],
),
})
}, [formatPreview])
// Push engine results into the error display
useEffect(() => {
const view = viewRef.current
if (!view || !results) return
const answers: LineAnswer[] = []
const errors: LineError[] = []
for (let i = 0; i < results.length; i++) {
const lineNum = i + 1
const result = results[i]
answers.push({ line: lineNum, result })
if (result.type === 'error' && result.error) {
// Map to document positions
@@ -143,7 +162,6 @@ export function CalcEditor({
view.dispatch({
effects: [
setAnswersEffect.of(answers),
setErrorsEffect.of(errors),
],
})
@@ -152,6 +170,24 @@ export function CalcEditor({
return <div ref={containerRef} className="calc-editor" />
}
/**
* Syntax highlighting using CSS variables for theme integration.
*/
const calcpadHighlight = HighlightStyle.define([
{ tag: tags.number, color: 'var(--syntax-number)' },
{ tag: tags.operator, color: 'var(--syntax-operator)' },
{ tag: tags.variableName, color: 'var(--syntax-variable)' },
{ tag: tags.function(tags.variableName), color: 'var(--syntax-function)' },
{ tag: tags.keyword, color: 'var(--syntax-keyword)' },
{ tag: tags.lineComment, color: 'var(--syntax-comment)', fontStyle: 'italic' },
{ tag: tags.heading, color: 'var(--syntax-heading)', fontWeight: '700' },
{ tag: tags.definitionOperator, color: 'var(--syntax-operator)' },
{ tag: tags.special(tags.variableName), color: 'var(--syntax-function)' },
{ tag: tags.constant(tags.variableName), color: 'var(--syntax-number)', fontWeight: '600' },
{ tag: tags.paren, color: 'var(--syntax-operator)' },
{ tag: tags.punctuation, color: 'var(--syntax-operator)' },
])
/**
* Base theme for the CalcPad editor.
*/
@@ -159,64 +195,44 @@ const calcpadEditorTheme = EditorView.baseTheme({
'&': {
height: '100%',
fontSize: '15px',
fontFamily: 'ui-monospace, Consolas, "Courier New", monospace',
fontFamily: 'var(--mono, ui-monospace, Consolas, "Courier New", monospace)',
},
'.cm-scroller': {
overflow: 'auto',
},
'.cm-content': {
padding: '12px 0',
padding: '8px 0',
minHeight: '100%',
},
'.cm-line': {
padding: '0 16px',
padding: '0 12px',
lineHeight: '1.6',
position: 'relative',
textAlign: 'var(--cm-text-align, left)',
},
'.cm-gutters': {
backgroundColor: 'transparent',
borderRight: 'none',
},
'.cm-lineNumbers .cm-gutterElement': {
padding: '0 8px 0 16px',
color: '#9ca3af',
padding: '0 6px 0 12px',
color: 'var(--text, #9ca3af)',
opacity: '0.4',
fontSize: '13px',
minWidth: '32px',
},
'.cm-answer-gutter': {
minWidth: '140px',
textAlign: 'right',
paddingRight: '16px',
borderLeft: '1px solid #e5e4e7',
backgroundColor: '#f8f9fa',
fontFamily: 'ui-monospace, Consolas, monospace',
fontSize: '14px',
},
'&dark .cm-answer-gutter': {
borderLeft: '1px solid #2e303a',
backgroundColor: '#1a1b23',
},
'.cm-answer-value': {
color: '#6366f1',
'.cm-activeLineGutter .cm-gutterElement': {
opacity: '1',
fontWeight: '600',
},
'&dark .cm-answer-value': {
color: '#818cf8',
},
'.cm-answer-error': {
color: '#e53e3e',
fontStyle: 'italic',
},
'&dark .cm-answer-error': {
color: '#fc8181',
'.cm-stripe': {
backgroundColor: 'var(--stripe, rgba(0, 0, 0, 0.02))',
},
'.cm-activeLine': {
backgroundColor: 'rgba(99, 102, 241, 0.04)',
},
'&dark .cm-activeLine': {
backgroundColor: 'rgba(129, 140, 248, 0.06)',
backgroundColor: 'var(--accent-bg, rgba(99, 102, 241, 0.04))',
},
'.cm-selectionBackground': {
backgroundColor: 'rgba(99, 102, 241, 0.15) !important',
backgroundColor: 'var(--accent-bg, rgba(99, 102, 241, 0.15)) !important',
},
'.cm-focused': {
outline: 'none',