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

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