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

@@ -10,7 +10,18 @@
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="CalcPad" />
<link rel="apple-touch-icon" href="/icons/icon-192.svg" />
<title>CalcPad</title>
<title>CalcText</title>
<script>
// Apply theme before React mounts to prevent FOUC
(function() {
var t = 'system';
try { t = localStorage.getItem('calctext-theme') || 'system'; } catch(e) {}
if (t === 'system') {
t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-theme', t);
})();
</script>
</head>
<body>
<div id="root"></div>

View File

@@ -1,80 +1,338 @@
/**
* CalcPad main application component.
* CalcText main application component.
*
* Two-column layout:
* Left: CodeMirror 6 editor with CalcPad syntax highlighting
* Right: Answer gutter (integrated into CodeMirror) + optional standalone AnswerColumn
*
* The WASM engine runs in a Web Worker. On each document change (debounced),
* the editor sends lines to the worker, which evaluates them and posts back
* results. Results are fed into the CodeMirror answer gutter extension.
* Workspace layout: header → tab bar → editor + results panel.
* Multi-document support via document store with localStorage persistence.
*/
import { useCallback } from 'react'
import { useCallback, useState, useRef, useEffect } from 'react'
import type { EditorView } from '@codemirror/view'
import { CalcEditor } from './editor/CalcEditor.tsx'
import { useEngine } from './engine/useEngine.ts'
import { useOnlineStatus } from './hooks/useOnlineStatus.ts'
import { useInstallPrompt } from './hooks/useInstallPrompt.ts'
import { useTheme } from './hooks/useTheme.ts'
import { useDocumentStore, loadSidebarState, saveSidebarState } from './hooks/useDocumentStore.ts'
import { OfflineBanner } from './components/OfflineBanner.tsx'
import { InstallPrompt } from './components/InstallPrompt.tsx'
import { ResultsPanel } from './components/ResultsPanel.tsx'
import { ThemePicker } from './components/ThemePicker.tsx'
import { TabBar } from './components/TabBar.tsx'
import { Sidebar } from './components/Sidebar.tsx'
import { StatusBar } from './components/StatusBar.tsx'
import { AlignToolbar } from './components/AlignToolbar.tsx'
import type { Alignment } from './components/AlignToolbar.tsx'
import { FormatToolbar } from './components/FormatToolbar.tsx'
import { MobileResultsTray } from './components/MobileResultsTray.tsx'
import './styles/app.css'
const INITIAL_DOC = `# CalcPad
// Basic arithmetic
2 + 3
10 * 4.5
100 / 7
// Variables
price = 49.99
quantity = 3
subtotal = price * quantity
// Percentages
tax = subtotal * 8%
total = subtotal + tax
// Functions
sqrt(144)
2 ^ 10
`
function App() {
const engine = useEngine()
const isOnline = useOnlineStatus()
const installPrompt = useInstallPrompt()
const themeCtx = useTheme()
const store = useDocumentStore()
const [editorView, setEditorView] = useState<EditorView | null>(null)
const resultsPanelRef = useRef<HTMLDivElement>(null)
const [modifiedIds, setModifiedIds] = useState<Set<string>>(new Set())
const [editorAlign, setEditorAlign] = useState<Alignment>('left')
const [resultsAlign, setResultsAlign] = useState<Alignment>('right')
const [formatPreview, setFormatPreview] = useState(true)
// Sidebar state
const [sidebarState, setSidebarState] = useState(loadSidebarState)
const setSidebarVisible = useCallback((v: boolean) => {
const next = { ...sidebarState, visible: v }
setSidebarState(next)
saveSidebarState(next)
}, [sidebarState])
const setSidebarWidth = useCallback((w: number) => {
const next = { ...sidebarState, width: w }
setSidebarState(next)
saveSidebarState(next)
}, [sidebarState])
// Track a key to force CalcEditor remount on tab switch
const [editorKey, setEditorKey] = useState(store.activeTabId)
// Draggable divider state
const [dividerX, setDividerX] = useState<number | null>(null)
const isDragging = useRef(false)
const handleDocChange = useCallback(
(lines: string[]) => {
engine.evalSheet(lines)
// Persist content
const content = lines.join('\n')
if (store.activeDoc && content !== store.activeDoc.content) {
store.updateContent(store.activeTabId, content)
setModifiedIds(prev => {
const next = new Set(prev)
next.add(store.activeTabId)
return next
})
// Clear modified dot after save debounce
setTimeout(() => {
setModifiedIds(prev => {
const next = new Set(prev)
next.delete(store.activeTabId)
return next
})
}, 500)
}
},
[engine.evalSheet],
[engine.evalSheet, store.activeTabId, store.activeDoc, store.updateContent],
)
// Switch tabs
const handleTabClick = useCallback((id: string) => {
if (id === store.activeTabId) return
store.setActiveTab(id)
setEditorKey(id)
}, [store.activeTabId, store.setActiveTab])
// New document
const handleNewTab = useCallback(() => {
const doc = store.createDocument()
setEditorKey(doc.id)
}, [store.createDocument])
// Close tab
const handleTabClose = useCallback((id: string) => {
store.closeTab(id)
// If we closed the active tab, editorKey needs updating
if (id === store.activeTabId) {
// State will update, trigger effect below
}
}, [store.closeTab, store.activeTabId])
// Sync editorKey when activeTabId changes externally (e.g. after close)
useEffect(() => {
if (editorKey !== store.activeTabId) {
setEditorKey(store.activeTabId)
}
}, [store.activeTabId]) // eslint-disable-line react-hooks/exhaustive-deps
// Apply editor text alignment via CodeMirror
useEffect(() => {
if (!editorView) return
editorView.dom.style.setProperty('--cm-text-align', editorAlign)
}, [editorView, editorAlign])
// Scroll sync: mirror editor scroll position to results panel
useEffect(() => {
if (!editorView) return
const scroller = editorView.scrollDOM
const onScroll = () => {
if (resultsPanelRef.current) {
resultsPanelRef.current.scrollTop = scroller.scrollTop
}
}
scroller.addEventListener('scroll', onScroll, { passive: true })
return () => scroller.removeEventListener('scroll', onScroll)
}, [editorView])
// Draggable divider handlers
const onDividerMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault()
isDragging.current = true
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
}, [])
useEffect(() => {
function onMouseMove(e: MouseEvent) {
if (!isDragging.current) return
setDividerX(e.clientX)
}
function onMouseUp() {
if (!isDragging.current) return
isDragging.current = false
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
return () => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
}, [])
// Keyboard shortcuts
useEffect(() => {
function handleKey(e: KeyboardEvent) {
const mod = e.metaKey || e.ctrlKey
// Ctrl+B — toggle sidebar
if (mod && e.key === 'b') {
e.preventDefault()
setSidebarVisible(!sidebarState.visible)
return
}
// Ctrl+N — new document
if (mod && e.key === 'n') {
e.preventDefault()
handleNewTab()
return
}
// Ctrl+W — close tab
if (mod && e.key === 'w') {
e.preventDefault()
handleTabClose(store.activeTabId)
return
}
// Ctrl+Tab / Ctrl+Shift+Tab — cycle tabs
if (mod && e.key === 'Tab') {
e.preventDefault()
const idx = store.openTabIds.indexOf(store.activeTabId)
const len = store.openTabIds.length
const next = e.shiftKey
? store.openTabIds[(idx - 1 + len) % len]
: store.openTabIds[(idx + 1) % len]
handleTabClick(next)
return
}
// Ctrl+1-9 — jump to tab
if (mod && e.key >= '1' && e.key <= '9') {
e.preventDefault()
const tabIdx = parseInt(e.key) - 1
if (tabIdx < store.openTabIds.length) {
handleTabClick(store.openTabIds[tabIdx])
}
return
}
}
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [store.activeTabId, store.openTabIds, handleNewTab, handleTabClose, handleTabClick, sidebarState.visible, setSidebarVisible])
// Compute flex styles from divider position
const editorStyle: React.CSSProperties = dividerX !== null
? { width: dividerX, flex: 'none' }
: {}
const resultsStyle: React.CSSProperties = dividerX !== null
? { flex: 1 }
: {}
return (
<div className="calcpad-app">
<OfflineBanner isOnline={isOnline} />
<header className="calcpad-header">
<h1>CalcPad</h1>
<p className="subtitle">Notepad Calculator</p>
<div className="header-status">
<span className={`status-dot ${engine.ready ? '' : 'loading'}`} />
<span>{engine.ready ? 'Engine ready' : 'Loading engine...'}</span>
<button
className="header-sidebar-toggle"
onClick={() => setSidebarVisible(!sidebarState.visible)}
title="Toggle sidebar (Ctrl+B)"
aria-label="Toggle sidebar"
>
</button>
<h1>CalcText</h1>
<div className="header-spacer" />
<div className="header-actions">
<FormatToolbar
editorView={editorView}
previewMode={formatPreview}
onPreviewToggle={() => setFormatPreview(p => !p)}
/>
<div className="header-divider" />
<AlignToolbar
editorAlign={editorAlign}
resultsAlign={resultsAlign}
onEditorAlignChange={setEditorAlign}
onResultsAlignChange={setResultsAlign}
/>
<ThemePicker
theme={themeCtx.theme}
accentColor={themeCtx.accentColor}
onThemeChange={themeCtx.setTheme}
onAccentChange={themeCtx.setAccent}
/>
</div>
</header>
<main className="calcpad-editor">
<div className="editor-pane">
<CalcEditor
initialDoc={INITIAL_DOC}
onDocChange={handleDocChange}
results={engine.results}
debounceMs={50}
<TabBar
tabs={store.openDocs}
activeTabId={store.activeTabId}
onTabClick={handleTabClick}
onTabClose={handleTabClose}
onTabRename={store.renameDocument}
onNewTab={handleNewTab}
modifiedIds={modifiedIds}
/>
<div className="calcpad-workspace">
{/* Mobile sidebar backdrop */}
{sidebarState.visible && (
<div
className="sidebar-backdrop"
onClick={() => setSidebarVisible(false)}
/>
</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
promptEvent={installPrompt.promptEvent}

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

View File

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

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

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

View 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}`}>
&nbsp;
</div>
)
})}
</div>
)
},
)

View 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 &apos;{search}&apos;</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>
)}
</>
)
}

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

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

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

View 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
`,
},
]

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',

View File

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

View File

@@ -11,6 +11,8 @@ import {
GutterMarker,
gutter,
EditorView,
hoverTooltip,
type Tooltip,
} from '@codemirror/view'
import { StateField, StateEffect, type Extension, RangeSet } from '@codemirror/state'
@@ -98,6 +100,48 @@ export const errorLinesField = StateField.define<Set<number>>({
},
})
// --- Error Messages (for tooltips) ---
export const errorMessagesField = StateField.define<Map<number, string>>({
create() {
return new Map()
},
update(msgs, tr) {
for (const effect of tr.effects) {
if (effect.is(setErrorsEffect)) {
const newMsgs = new Map<number, string>()
for (const error of effect.value) {
const lineNumber = tr.state.doc.lineAt(error.from).number
newMsgs.set(lineNumber, error.message)
}
return newMsgs
}
}
return msgs
},
})
// --- Error Tooltip (hover) ---
const errorTooltip = hoverTooltip((view, pos) => {
const line = view.state.doc.lineAt(pos)
const errorMessages = view.state.field(errorMessagesField)
const msg = errorMessages.get(line.number)
if (!msg) return null
return {
pos: line.from,
end: line.to,
above: false,
create() {
const dom = document.createElement('div')
dom.className = 'cm-error-tooltip'
dom.textContent = msg
return { dom }
},
} satisfies Tooltip
})
// --- Error Gutter ---
export const errorGutter = gutter({
@@ -118,15 +162,32 @@ export const errorGutter = gutter({
export const errorBaseTheme = EditorView.baseTheme({
'.cm-error-underline': {
textDecoration: 'underline wavy red',
textDecoration: 'underline wavy var(--error, red)',
textDecorationThickness: '1.5px',
},
'.cm-error-marker': {
color: '#e53e3e',
color: 'var(--error, #e53e3e)',
fontSize: '14px',
cursor: 'pointer',
},
'.cm-error-gutter': {
width: '20px',
},
'.cm-error-tooltip': {
backgroundColor: 'var(--bg-secondary, #f8f9fa)',
color: 'var(--error, #e53e3e)',
border: '1px solid var(--border, #e5e4e7)',
borderRadius: '4px',
padding: '4px 8px',
fontSize: '12px',
fontFamily: 'var(--sans, system-ui)',
maxWidth: '300px',
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
},
'.cm-tooltip': {
border: 'none',
backgroundColor: 'transparent',
},
})
/**
@@ -136,7 +197,9 @@ export function errorDisplayExtension(): Extension {
return [
errorDecorationsField,
errorLinesField,
errorMessagesField,
errorGutter,
errorTooltip,
errorBaseTheme,
]
}

View 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,
)
}

View 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]
}

View File

@@ -78,7 +78,8 @@ function fallbackEvalSheet(lines: string[]): EngineLineResult[] {
async function initWasm(): Promise<boolean> {
try {
// Try to load the wasm-pack output
const wasmModule = await import(/* @vite-ignore */ '/wasm/calcpad_wasm.js') as {
const wasmPath = '/wasm/calcpad_wasm.js'
const wasmModule = await import(/* @vite-ignore */ wasmPath) as {
default: () => Promise<void>
evalSheet: (lines: string[]) => EngineLineResult[]
}

View 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,
}
}

View 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})`
}

View 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;
}
}

View File

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

View File

@@ -13,50 +13,67 @@
display: flex;
align-items: center;
gap: 12px;
padding: 12px 24px;
padding: 6px 12px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
height: 40px;
}
.calcpad-header h1 {
margin: 0;
font-size: 20px;
font-size: 14px;
font-weight: 600;
letter-spacing: -0.3px;
}
.calcpad-header .subtitle {
margin: 0;
font-size: 13px;
color: var(--text);
.header-spacer {
flex: 1;
}
.header-status {
.header-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
gap: 6px;
}
.header-divider {
width: 1px;
height: 16px;
background: var(--border);
}
/* ---------- Sidebar toggle ---------- */
.header-sidebar-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text);
font-size: 16px;
cursor: pointer;
flex-shrink: 0;
transition: background 0.1s;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--success);
.header-sidebar-toggle:hover {
background: var(--accent-bg);
}
.status-dot.loading {
background: var(--warning);
animation: pulse 1.5s ease-in-out infinite;
/* ---------- Workspace (sidebar + editor area) ---------- */
.calcpad-workspace {
flex: 1;
display: flex;
overflow: hidden;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* ---------- Editor area ---------- */
/* ---------- Editor area (two-column layout) ---------- */
.calcpad-editor {
flex: 1;
@@ -65,7 +82,7 @@
}
.editor-pane {
flex: 1;
flex: 3;
min-width: 0;
overflow: hidden;
}
@@ -79,14 +96,87 @@
height: 100%;
}
/* ---------- Responsive ---------- */
.pane-divider {
width: 5px;
background: var(--border);
flex-shrink: 0;
cursor: col-resize;
transition: background 0.15s;
position: relative;
}
@media (max-width: 640px) {
.calcpad-header {
padding: 10px 16px;
.pane-divider:hover {
background: var(--accent);
}
/* Wider invisible hit area */
.pane-divider::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: -4px;
right: -4px;
}
.results-panel {
flex: 1;
min-width: 120px;
}
/* ---------- Responsive: Mobile (< 768px) ---------- */
@media (max-width: 767px) {
.calcpad-app {
height: 100dvh;
}
.calcpad-header .subtitle {
.calcpad-header {
height: 44px;
padding: 6px 8px;
gap: 8px;
}
.header-sidebar-toggle {
width: 36px;
height: 36px;
font-size: 18px;
}
.header-divider {
display: none;
}
.pane-divider {
display: none;
}
/* Editor goes full width, results panel hidden (tray replaces it) */
.calcpad-editor {
flex-direction: column;
}
.editor-pane {
flex: 1 !important;
width: 100% !important;
}
.results-panel {
display: none;
}
}
/* Safe areas for notched devices */
@supports (padding: env(safe-area-inset-top)) {
.calcpad-app {
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
}
/* Prevent pull-to-refresh in PWA */
.calcpad-app {
overscroll-behavior: none;
}

View 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;
}
}

View File

@@ -1,23 +1,16 @@
/* ---------- Base & Font Setup ---------- */
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--bg-secondary: #f8f9fa;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #6366f1;
--accent-bg: rgba(99, 102, 241, 0.1);
--accent-border: rgba(99, 102, 241, 0.5);
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, 'Courier New', monospace;
--warning: #f59e0b;
--warning-bg: rgba(245, 158, 11, 0.1);
--success: #10b981;
--success-bg: rgba(16, 185, 129, 0.1);
--error: #e53e3e;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, 'Courier New', monospace;
font: 16px/1.5 var(--sans);
color-scheme: light dark;
color: var(--text);
@@ -28,18 +21,180 @@
-moz-osx-font-smoothing: grayscale;
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--bg-secondary: #1a1b23;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #818cf8;
--accent-bg: rgba(129, 140, 248, 0.15);
--accent-border: rgba(129, 140, 248, 0.5);
}
/* ---------- Theme: Light (default) ---------- */
:root,
[data-theme="light"] {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--bg-secondary: #f8f9fa;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #6366f1;
--accent-bg: rgba(99, 102, 241, 0.1);
--accent-border: rgba(99, 102, 241, 0.5);
--stripe: rgba(0, 0, 0, 0.02);
--syntax-variable: #4f46e5;
--syntax-number: #0d9488;
--syntax-operator: #6b6375;
--syntax-keyword: #7c3aed;
--syntax-function: #2563eb;
--syntax-currency: #d97706;
--syntax-comment: rgba(107, 99, 117, 0.5);
--syntax-heading: #08060d;
--result-number: #374151;
--result-unit: #0d9488;
--result-currency: #d97706;
--result-datetime: #7c3aed;
--result-boolean: #6366f1;
}
/* ---------- Theme: Dark ---------- */
[data-theme="dark"] {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--bg-secondary: #1a1b23;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #818cf8;
--accent-bg: rgba(129, 140, 248, 0.15);
--accent-border: rgba(129, 140, 248, 0.5);
--stripe: rgba(255, 255, 255, 0.025);
--syntax-variable: #a5b4fc;
--syntax-number: #5eead4;
--syntax-operator: #9ca3af;
--syntax-keyword: #c4b5fd;
--syntax-function: #93c5fd;
--syntax-currency: #fcd34d;
--syntax-comment: rgba(156, 163, 175, 0.5);
--syntax-heading: #f3f4f6;
--result-number: #d1d5db;
--result-unit: #5eead4;
--result-currency: #fcd34d;
--result-datetime: #c4b5fd;
--result-boolean: #818cf8;
}
/* ---------- Theme: Matrix ---------- */
[data-theme="matrix"] {
--text: #00ff41;
--text-h: #33ff66;
--bg: #0a0a0a;
--bg-secondary: #0f1a0f;
--border: #003300;
--code-bg: #0a0f0a;
--accent: #00ff41;
--accent-bg: rgba(0, 255, 65, 0.1);
--accent-border: rgba(0, 255, 65, 0.4);
--stripe: rgba(0, 255, 65, 0.03);
--syntax-variable: #00ff41;
--syntax-number: #00cc33;
--syntax-operator: #00ff41;
--syntax-keyword: #39ff14;
--syntax-function: #00ff41;
--syntax-currency: #ffff00;
--syntax-comment: rgba(0, 255, 65, 0.4);
--syntax-heading: #33ff66;
--result-number: #00ff41;
--result-unit: #00cc33;
--result-currency: #ffff00;
--result-datetime: #39ff14;
--result-boolean: #00ff41;
--mono: 'Courier New', 'Fira Code', monospace;
--success: #00ff41;
--error: #ff0000;
}
/* Matrix special effects */
[data-theme="matrix"] .calcpad-app::after {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 0, 0, 0.06) 2px,
rgba(0, 0, 0, 0.06) 4px
);
z-index: 9999;
}
[data-theme="matrix"] .cm-cursor {
border-color: #00ff41 !important;
box-shadow: 0 0 4px #00ff41, 0 0 8px rgba(0, 255, 65, 0.3);
}
/* ---------- Theme: Midnight ---------- */
[data-theme="midnight"] {
--text: #94a3b8;
--text-h: #e2e8f0;
--bg: #0f172a;
--bg-secondary: #1e293b;
--border: #334155;
--code-bg: #1e293b;
--accent: #38bdf8;
--accent-bg: rgba(56, 189, 248, 0.12);
--accent-border: rgba(56, 189, 248, 0.5);
--stripe: rgba(56, 189, 248, 0.03);
--syntax-variable: #7dd3fc;
--syntax-number: #5eead4;
--syntax-operator: #94a3b8;
--syntax-keyword: #c4b5fd;
--syntax-function: #7dd3fc;
--syntax-currency: #fcd34d;
--syntax-comment: rgba(148, 163, 184, 0.5);
--syntax-heading: #e2e8f0;
--result-number: #cbd5e1;
--result-unit: #5eead4;
--result-currency: #fcd34d;
--result-datetime: #c4b5fd;
--result-boolean: #38bdf8;
}
/* ---------- Theme: Warm ---------- */
[data-theme="warm"] {
--text: #78716c;
--text-h: #1c1917;
--bg: #fffbf5;
--bg-secondary: #fef3e2;
--border: #e7e5e4;
--code-bg: #fef3e2;
--accent: #f97316;
--accent-bg: rgba(249, 115, 22, 0.1);
--accent-border: rgba(249, 115, 22, 0.5);
--stripe: rgba(249, 115, 22, 0.03);
--syntax-variable: #c2410c;
--syntax-number: #0d9488;
--syntax-operator: #78716c;
--syntax-keyword: #7c3aed;
--syntax-function: #2563eb;
--syntax-currency: #d97706;
--syntax-comment: rgba(120, 113, 108, 0.5);
--syntax-heading: #1c1917;
--result-number: #44403c;
--result-unit: #0d9488;
--result-currency: #d97706;
--result-datetime: #7c3aed;
--result-boolean: #f97316;
}
*,

View 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;
}
}

View 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;
}
}

View 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; }
}
}

View 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;
}
}

View 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);
}

View 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);
}

View File

@@ -1,5 +1,10 @@
/// <reference types="vite/client" />
declare module '/wasm/calcpad_wasm.js' {
export default function init(): Promise<void>
export function evalSheet(lines: string[]): import('./engine/types.ts').EngineLineResult[]
}
declare module 'virtual:pwa-register' {
export interface RegisterSWOptions {
immediate?: boolean

View File

@@ -4,7 +4,7 @@
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"types": [],
"skipLibCheck": true,
"moduleResolution": "bundler",

View File

@@ -6,7 +6,7 @@ export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'prompt',
registerType: 'autoUpdate',
includeAssets: ['favicon.svg', 'icons/*.svg'],
manifest: {
name: 'CalcPad',