summaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
authorSolstice <solstice@local>2026-06-09 00:22:18 -0700
committerSolstice <solstice@local>2026-06-09 00:22:18 -0700
commit6cb006cc136f1fc5c83537cc30c64d223d1755e4 (patch)
treeb12dfb4a8345c980dc8747657ce381016cdf3b34 /src/components
parent25e1dcf205cd14feafdd9b4cf6b7a66f253ba6d2 (diff)
feat: frontend view, state management, and user interface
Diffstat (limited to 'src/components')
-rw-r--r--src/components/NotificationOverlay.tsx117
-rw-r--r--src/components/SettingsPanel.tsx240
-rw-r--r--src/components/TaskList.tsx283
-rw-r--r--src/components/TimerView.tsx236
4 files changed, 876 insertions, 0 deletions
diff --git a/src/components/NotificationOverlay.tsx b/src/components/NotificationOverlay.tsx
new file mode 100644
index 0000000..965f19d
--- /dev/null
+++ b/src/components/NotificationOverlay.tsx
@@ -0,0 +1,117 @@
+import { useEffect, useRef } from 'react';
+import { sendNotification } from '@tauri-apps/plugin-notification';
+import { useTaskStore } from '../store/taskStore';
+
+interface NotificationOverlayProps {
+ visible: boolean;
+ taskId: string | null;
+ onDismiss: () => void;
+}
+
+export function NotificationOverlay({ visible, taskId, onDismiss }: NotificationOverlayProps) {
+ const tasks = useTaskStore((s) => s.tasks);
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+ const task = tasks.find((t) => t.id === taskId) ?? null;
+
+ useEffect(() => {
+ if (!visible) return;
+
+ // OS notification
+ const body = task ? `"${task.name}" session complete.` : 'Session complete. Time for a break.';
+ try {
+ sendNotification({ title: 'Solstice', body });
+ } catch {
+ // Notifications may not be permitted in dev; ignore errors
+ }
+
+ // Auto-dismiss after 4 seconds
+ timerRef.current = setTimeout(() => {
+ onDismiss();
+ }, 4000);
+
+ return () => {
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ }
+ };
+ }, [visible, task, onDismiss]);
+
+ if (!visible) return null;
+
+ return (
+ <div
+ onClick={onDismiss}
+ style={{
+ position: 'fixed',
+ inset: 0,
+ display: 'flex',
+ alignItems: 'flex-start',
+ justifyContent: 'center',
+ paddingTop: '40px',
+ zIndex: 200,
+ pointerEvents: 'auto',
+ }}
+ >
+ <div
+ onClick={(e) => e.stopPropagation()}
+ style={{
+ background: 'var(--ink-2)',
+ border: '1px solid var(--line-2)',
+ borderRadius: 'var(--r-3)',
+ boxShadow: 'var(--shadow-3)',
+ padding: '24px 32px',
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ gap: '8px',
+ animation: 'slideDown 0.2s ease',
+ minWidth: '280px',
+ }}
+ >
+ <span
+ style={{
+ fontFamily: 'var(--font-display)',
+ fontStyle: 'italic',
+ fontSize: '28px',
+ fontWeight: 400,
+ color: 'var(--fg-1)',
+ lineHeight: 1.1,
+ }}
+ >
+ Session complete
+ </span>
+ {task && (
+ <span
+ style={{
+ fontFamily: 'var(--font-sans)',
+ fontSize: '13px',
+ color: 'var(--fg-3)',
+ }}
+ >
+ {task.name}
+ </span>
+ )}
+ <span
+ style={{
+ fontFamily: 'var(--font-sans)',
+ fontSize: '11px',
+ color: 'var(--fg-4)',
+ marginTop: '4px',
+ textTransform: 'uppercase',
+ letterSpacing: '0.1em',
+ }}
+ >
+ Click to dismiss
+ </span>
+ </div>
+
+ <style>{`
+ @keyframes slideDown {
+ from { opacity: 0; transform: translateY(-16px); }
+ to { opacity: 1; transform: translateY(0); }
+ }
+ `}</style>
+ </div>
+ );
+}
diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx
new file mode 100644
index 0000000..0d3cbf9
--- /dev/null
+++ b/src/components/SettingsPanel.tsx
@@ -0,0 +1,240 @@
+import { useState, useEffect } from 'react';
+import { useSettingsStore, Settings } from '../store/settingsStore';
+
+interface SettingsPanelProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+export function SettingsPanel({ open, onClose }: SettingsPanelProps) {
+ const { settings, fetchSettings, updateSettings } = useSettingsStore();
+
+ const [workMins, setWorkMins] = useState(25);
+ const [shortBreakMins, setShortBreakMins] = useState(5);
+ const [longBreakMins, setLongBreakMins] = useState(15);
+ const [sessionsBeforeLong, setSessionsBeforeLong] = useState(4);
+
+ useEffect(() => {
+ if (open && !settings) {
+ fetchSettings();
+ }
+ }, [open, settings, fetchSettings]);
+
+ useEffect(() => {
+ if (settings) {
+ setWorkMins(Math.round(settings.work_duration_secs / 60));
+ setShortBreakMins(Math.round(settings.short_break_secs / 60));
+ setLongBreakMins(Math.round(settings.long_break_secs / 60));
+ setSessionsBeforeLong(settings.sessions_before_long_break);
+ }
+ }, [settings]);
+
+ if (!open) return null;
+
+ const handleSave = async () => {
+ const s: Settings = {
+ work_duration_secs: workMins * 60,
+ short_break_secs: shortBreakMins * 60,
+ long_break_secs: longBreakMins * 60,
+ sessions_before_long_break: sessionsBeforeLong,
+ };
+ await updateSettings(s);
+ onClose();
+ };
+
+ return (
+ <div
+ onClick={onClose}
+ style={{
+ position: 'fixed',
+ inset: 0,
+ background: 'rgba(11, 14, 19, 0.70)',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ zIndex: 100,
+ }}
+ >
+ {/* Modal card */}
+ <div
+ onClick={(e) => e.stopPropagation()}
+ style={{
+ background: 'var(--ink-1)',
+ border: '1px solid var(--line-2)',
+ borderRadius: 'var(--r-3)',
+ boxShadow: 'var(--shadow-3)',
+ padding: '32px',
+ width: '360px',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '24px',
+ }}
+ >
+ <span className="eyebrow" style={{ letterSpacing: '0.18em' }}>
+ Settings
+ </span>
+
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
+ <NumberField
+ label="Work duration"
+ unit="min"
+ value={workMins}
+ min={1}
+ max={120}
+ onChange={setWorkMins}
+ />
+ <NumberField
+ label="Short break"
+ unit="min"
+ value={shortBreakMins}
+ min={1}
+ max={60}
+ onChange={setShortBreakMins}
+ />
+ <NumberField
+ label="Long break"
+ unit="min"
+ value={longBreakMins}
+ min={1}
+ max={60}
+ onChange={setLongBreakMins}
+ />
+ <NumberField
+ label="Sessions before long break"
+ unit="sessions"
+ value={sessionsBeforeLong}
+ min={1}
+ max={10}
+ onChange={setSessionsBeforeLong}
+ />
+ </div>
+
+ <div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
+ <button
+ onClick={onClose}
+ style={{
+ fontFamily: 'var(--font-sans)',
+ fontSize: '14px',
+ fontWeight: 500,
+ padding: '8px 16px',
+ borderRadius: 'var(--r-1)',
+ border: '1px solid var(--line-2)',
+ background: 'transparent',
+ color: 'var(--brass)',
+ cursor: 'pointer',
+ transition: 'border-color 0.15s ease',
+ }}
+ onMouseEnter={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.borderColor = 'var(--brass)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.borderColor = 'var(--line-2)';
+ }}
+ >
+ Cancel
+ </button>
+ <button
+ onClick={handleSave}
+ style={{
+ fontFamily: 'var(--font-sans)',
+ fontSize: '14px',
+ fontWeight: 600,
+ padding: '8px 20px',
+ borderRadius: 'var(--r-1)',
+ border: 'none',
+ background: 'var(--brass)',
+ color: 'var(--brass-ink)',
+ cursor: 'pointer',
+ transition: 'background 0.15s ease',
+ }}
+ onMouseEnter={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.background = 'var(--brass-bright)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.background = 'var(--brass)';
+ }}
+ onMouseDown={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.background = 'var(--brass-deep)';
+ }}
+ onMouseUp={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.background = 'var(--brass-bright)';
+ }}
+ >
+ Save
+ </button>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+interface NumberFieldProps {
+ label: string;
+ unit: string;
+ value: number;
+ min: number;
+ max: number;
+ onChange: (v: number) => void;
+}
+
+function NumberField({ label, unit, value, min, max, onChange }: NumberFieldProps) {
+ return (
+ <div
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: '16px',
+ }}
+ >
+ <label
+ style={{
+ fontFamily: 'var(--font-sans)',
+ fontSize: '14px',
+ color: 'var(--fg-2)',
+ flex: 1,
+ }}
+ >
+ {label}
+ </label>
+ <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
+ <input
+ type="number"
+ min={min}
+ max={max}
+ value={value}
+ onChange={(e) => onChange(Math.min(max, Math.max(min, parseInt(e.target.value) || min)))}
+ style={{
+ fontFamily: 'var(--font-mono)',
+ fontSize: '14px',
+ fontVariantNumeric: 'tabular-nums',
+ padding: '5px 8px',
+ borderRadius: 'var(--r-2)',
+ border: '1px solid var(--line-2)',
+ background: 'var(--ink-3)',
+ color: 'var(--fg-1)',
+ outline: 'none',
+ width: '56px',
+ textAlign: 'center',
+ }}
+ onFocus={(e) => {
+ (e.currentTarget as HTMLInputElement).style.boxShadow = 'var(--shadow-brass)';
+ }}
+ onBlur={(e) => {
+ (e.currentTarget as HTMLInputElement).style.boxShadow = 'none';
+ }}
+ />
+ <span
+ style={{
+ fontFamily: 'var(--font-sans)',
+ fontSize: '12px',
+ color: 'var(--fg-4)',
+ minWidth: '44px',
+ }}
+ >
+ {unit}
+ </span>
+ </div>
+ </div>
+ );
+}
diff --git a/src/components/TaskList.tsx b/src/components/TaskList.tsx
new file mode 100644
index 0000000..0f87e0d
--- /dev/null
+++ b/src/components/TaskList.tsx
@@ -0,0 +1,283 @@
+import { useState } from 'react';
+import { useTaskStore, Task } from '../store/taskStore';
+import { useTimerStore } from '../store/timerStore';
+
+export function TaskList() {
+ const { tasks, addTask, deleteTask, setCurrentTask } = useTaskStore();
+ const currentTaskId = useTimerStore((s) => s.currentTaskId);
+
+ const [showForm, setShowForm] = useState(false);
+ const [newName, setNewName] = useState('');
+ const [newSessions, setNewSessions] = useState(4);
+
+ const handleAdd = async () => {
+ const name = newName.trim();
+ if (!name) return;
+ await addTask(name, newSessions);
+ setNewName('');
+ setNewSessions(4);
+ setShowForm(false);
+ };
+
+ const handleSelect = async (task: Task) => {
+ if (task.id === currentTaskId) {
+ await setCurrentTask(null);
+ } else {
+ await setCurrentTask(task.id);
+ }
+ };
+
+ const handleDelete = async (e: React.MouseEvent, id: string) => {
+ e.stopPropagation();
+ await deleteTask(id);
+ };
+
+ return (
+ <aside
+ style={{
+ width: '280px',
+ flexShrink: 0,
+ background: 'var(--ink-1)',
+ borderRight: '1px solid var(--line-1)',
+ display: 'flex',
+ flexDirection: 'column',
+ overflow: 'hidden',
+ }}
+ >
+ {/* Header */}
+ <div
+ style={{
+ padding: '16px',
+ borderBottom: '1px solid var(--line-1)',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ }}
+ >
+ <span className="eyebrow">Tasks</span>
+ <button
+ onClick={() => setShowForm((v) => !v)}
+ style={{
+ fontFamily: 'var(--font-sans)',
+ fontSize: '12px',
+ fontWeight: 600,
+ padding: '4px 10px',
+ borderRadius: 'var(--r-1)',
+ border: '1px solid var(--line-2)',
+ background: 'transparent',
+ color: 'var(--brass)',
+ cursor: 'pointer',
+ transition: 'border-color 0.15s ease',
+ }}
+ onMouseEnter={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.borderColor = 'var(--brass)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.borderColor = 'var(--line-2)';
+ }}
+ >
+ + Add
+ </button>
+ </div>
+
+ {/* Add task form */}
+ {showForm && (
+ <div
+ style={{
+ padding: '12px 16px',
+ borderBottom: '1px solid var(--line-1)',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '8px',
+ }}
+ >
+ <input
+ type="text"
+ placeholder="Task name"
+ value={newName}
+ onChange={(e) => setNewName(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
+ autoFocus
+ style={{
+ fontFamily: 'var(--font-sans)',
+ fontSize: '14px',
+ padding: '6px 10px',
+ borderRadius: 'var(--r-2)',
+ border: '1px solid var(--line-2)',
+ background: 'var(--ink-3)',
+ color: 'var(--fg-1)',
+ outline: 'none',
+ width: '100%',
+ boxSizing: 'border-box',
+ }}
+ onFocus={(e) => {
+ (e.currentTarget as HTMLInputElement).style.boxShadow = 'var(--shadow-brass)';
+ }}
+ onBlur={(e) => {
+ (e.currentTarget as HTMLInputElement).style.boxShadow = 'none';
+ }}
+ />
+ <div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
+ <label
+ style={{
+ fontFamily: 'var(--font-sans)',
+ fontSize: '12px',
+ color: 'var(--fg-3)',
+ whiteSpace: 'nowrap',
+ }}
+ >
+ Sessions
+ </label>
+ <input
+ type="number"
+ min={1}
+ max={20}
+ value={newSessions}
+ onChange={(e) => setNewSessions(Math.max(1, parseInt(e.target.value) || 1))}
+ style={{
+ fontFamily: 'var(--font-mono)',
+ fontSize: '14px',
+ padding: '4px 8px',
+ borderRadius: 'var(--r-2)',
+ border: '1px solid var(--line-2)',
+ background: 'var(--ink-3)',
+ color: 'var(--fg-1)',
+ outline: 'none',
+ width: '56px',
+ textAlign: 'center',
+ }}
+ />
+ <button
+ onClick={handleAdd}
+ style={{
+ fontFamily: 'var(--font-sans)',
+ fontSize: '12px',
+ fontWeight: 600,
+ padding: '4px 12px',
+ borderRadius: 'var(--r-1)',
+ border: 'none',
+ background: 'var(--brass)',
+ color: 'var(--brass-ink)',
+ cursor: 'pointer',
+ marginLeft: 'auto',
+ transition: 'background 0.15s ease',
+ }}
+ onMouseEnter={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.background = 'var(--brass-bright)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.background = 'var(--brass)';
+ }}
+ >
+ Save
+ </button>
+ </div>
+ </div>
+ )}
+
+ {/* Task list */}
+ <div style={{ overflowY: 'auto', flex: 1 }}>
+ {tasks.length === 0 && (
+ <div
+ style={{
+ padding: '24px 16px',
+ textAlign: 'center',
+ fontFamily: 'var(--font-sans)',
+ fontSize: '13px',
+ color: 'var(--fg-4)',
+ }}
+ >
+ No tasks yet
+ </div>
+ )}
+ {tasks.map((task) => {
+ const isSelected = task.id === currentTaskId;
+ const isCompleted = task.completed;
+
+ return (
+ <div
+ key={task.id}
+ onClick={() => !isCompleted && handleSelect(task)}
+ style={{
+ padding: '10px 16px',
+ borderBottom: '1px solid var(--line-1)',
+ borderLeft: isSelected ? '2px solid var(--brass)' : '2px solid transparent',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '8px',
+ cursor: isCompleted ? 'default' : 'pointer',
+ background: isSelected ? 'var(--ink-2)' : 'transparent',
+ opacity: isCompleted ? 0.45 : 1,
+ transition: 'background 0.15s ease, border-color 0.15s ease',
+ }}
+ onMouseEnter={(e) => {
+ if (!isSelected && !isCompleted) {
+ (e.currentTarget as HTMLDivElement).style.background = 'var(--ink-2)';
+ }
+ }}
+ onMouseLeave={(e) => {
+ if (!isSelected) {
+ (e.currentTarget as HTMLDivElement).style.background = 'transparent';
+ }
+ }}
+ >
+ {/* Task name */}
+ <div style={{ flex: 1, minWidth: 0 }}>
+ <div
+ style={{
+ fontFamily: 'var(--font-sans)',
+ fontSize: '14px',
+ color: isCompleted ? 'var(--fg-4)' : 'var(--fg-2)',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ }}
+ >
+ {task.name}
+ </div>
+ {/* Session count */}
+ <div
+ style={{
+ fontFamily: 'var(--font-mono)',
+ fontSize: '11px',
+ color: 'var(--fg-4)',
+ fontVariantNumeric: 'tabular-nums',
+ marginTop: '2px',
+ }}
+ >
+ {task.remaining_sessions} / {task.total_sessions}
+ </div>
+ </div>
+
+ {/* Delete */}
+ <button
+ onClick={(e) => handleDelete(e, task.id)}
+ style={{
+ fontFamily: 'var(--font-sans)',
+ fontSize: '16px',
+ lineHeight: 1,
+ padding: '2px 4px',
+ border: 'none',
+ background: 'transparent',
+ color: 'var(--fg-4)',
+ cursor: 'pointer',
+ flexShrink: 0,
+ transition: 'color 0.15s ease',
+ }}
+ onMouseEnter={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.color = 'var(--negative)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.color = 'var(--fg-4)';
+ }}
+ title="Remove task"
+ >
+ x
+ </button>
+ </div>
+ );
+ })}
+ </div>
+ </aside>
+ );
+}
diff --git a/src/components/TimerView.tsx b/src/components/TimerView.tsx
new file mode 100644
index 0000000..b56e6a8
--- /dev/null
+++ b/src/components/TimerView.tsx
@@ -0,0 +1,236 @@
+import { invoke } from '@tauri-apps/api/core';
+import { useTimerStore, TimerPhase } from '../store/timerStore';
+import { useTaskStore } from '../store/taskStore';
+
+const RING_SIZE = 280;
+const STROKE = 8;
+const RADIUS = (RING_SIZE - STROKE) / 2;
+const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
+
+function phaseLabel(phase: TimerPhase): string {
+ switch (phase) {
+ case 'work': return 'Focus';
+ case 'short_break': return 'Short Break';
+ case 'long_break': return 'Long Break';
+ }
+}
+
+function phaseColor(phase: TimerPhase): string {
+ switch (phase) {
+ case 'work': return 'var(--brass)';
+ case 'short_break': return 'var(--positive)';
+ case 'long_break': return 'var(--info)';
+ }
+}
+
+function eyebrowText(phase: TimerPhase, sessionCount: number): string {
+ if (phase === 'work') {
+ return `WORK ยท SESSION ${sessionCount + 1}`;
+ }
+ if (phase === 'short_break') return 'SHORT BREAK';
+ return 'LONG BREAK';
+}
+
+function formatTime(secs: number): string {
+ const m = Math.floor(secs / 60);
+ const s = secs % 60;
+ return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
+}
+
+export function TimerView() {
+ const { phase, remainingSecs, totalSecs, running, sessionCount, currentTaskId } =
+ useTimerStore();
+ const tasks = useTaskStore((s) => s.tasks);
+
+ const currentTask = tasks.find((t) => t.id === currentTaskId) ?? null;
+
+ const progress = totalSecs > 0 ? remainingSecs / totalSecs : 1;
+ const dashOffset = CIRCUMFERENCE * (1 - progress);
+ const arcColor = phaseColor(phase);
+
+ const handleStart = () => invoke('start_timer');
+ const handlePause = () => invoke('pause_timer');
+ const handleSkip = () => invoke('skip_phase');
+ const handleReset = () => invoke('reset_timer');
+
+ return (
+ <div
+ style={{
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: '24px',
+ flex: 1,
+ padding: '32px',
+ }}
+ >
+ {/* Eyebrow */}
+ <span className="eyebrow" style={{ letterSpacing: '0.18em' }}>
+ {eyebrowText(phase, sessionCount)}
+ </span>
+
+ {/* Circular ring */}
+ <div style={{ position: 'relative', width: RING_SIZE, height: RING_SIZE }}>
+ <svg
+ width={RING_SIZE}
+ height={RING_SIZE}
+ style={{ transform: 'rotate(-90deg)' }}
+ >
+ {/* Background track */}
+ <circle
+ cx={RING_SIZE / 2}
+ cy={RING_SIZE / 2}
+ r={RADIUS}
+ fill="none"
+ stroke="var(--line-2)"
+ strokeWidth={STROKE}
+ />
+ {/* Progress arc */}
+ <circle
+ cx={RING_SIZE / 2}
+ cy={RING_SIZE / 2}
+ r={RADIUS}
+ fill="none"
+ stroke={arcColor}
+ strokeWidth={STROKE}
+ strokeLinecap="round"
+ strokeDasharray={CIRCUMFERENCE}
+ strokeDashoffset={dashOffset}
+ style={{ transition: 'stroke-dashoffset 0.8s ease, stroke 0.3s ease' }}
+ />
+ </svg>
+
+ {/* Center content */}
+ <div
+ style={{
+ position: 'absolute',
+ inset: 0,
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: '4px',
+ }}
+ >
+ <span
+ style={{
+ fontFamily: 'var(--font-display)',
+ fontStyle: 'italic',
+ fontSize: '56px',
+ fontWeight: 400,
+ lineHeight: 1,
+ color: 'var(--fg-1)',
+ letterSpacing: '-0.02em',
+ fontVariantNumeric: 'tabular-nums',
+ }}
+ >
+ {formatTime(remainingSecs)}
+ </span>
+ <span
+ style={{
+ fontFamily: 'var(--font-sans)',
+ fontSize: '12px',
+ fontWeight: 600,
+ textTransform: 'uppercase',
+ letterSpacing: '0.12em',
+ color: 'var(--fg-3)',
+ }}
+ >
+ {phaseLabel(phase)}
+ </span>
+ </div>
+ </div>
+
+ {/* Current task name */}
+ {currentTask && (
+ <span
+ style={{
+ fontFamily: 'var(--font-sans)',
+ fontSize: '14px',
+ color: 'var(--fg-3)',
+ maxWidth: '320px',
+ textAlign: 'center',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ }}
+ >
+ {currentTask.name}
+ </span>
+ )}
+
+ {/* Controls */}
+ <div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
+ <button
+ onClick={running ? handlePause : handleStart}
+ style={{
+ fontFamily: 'var(--font-sans)',
+ fontSize: '14px',
+ fontWeight: 600,
+ padding: '8px 24px',
+ borderRadius: 'var(--r-1)',
+ border: 'none',
+ background: 'var(--brass)',
+ color: 'var(--brass-ink)',
+ cursor: 'pointer',
+ transition: 'background 0.15s ease',
+ }}
+ onMouseEnter={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.background = 'var(--brass-bright)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.background = 'var(--brass)';
+ }}
+ onMouseDown={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.background = 'var(--brass-deep)';
+ }}
+ onMouseUp={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.background = 'var(--brass-bright)';
+ }}
+ >
+ {running ? 'Pause' : 'Start'}
+ </button>
+
+ <GhostButton onClick={handleSkip}>Skip</GhostButton>
+ <GhostButton onClick={handleReset}>Reset</GhostButton>
+ </div>
+ </div>
+ );
+}
+
+function GhostButton({
+ onClick,
+ children,
+}: {
+ onClick: () => void;
+ children: React.ReactNode;
+}) {
+ return (
+ <button
+ onClick={onClick}
+ style={{
+ fontFamily: 'var(--font-sans)',
+ fontSize: '14px',
+ fontWeight: 500,
+ padding: '8px 16px',
+ borderRadius: 'var(--r-1)',
+ border: '1px solid var(--line-2)',
+ background: 'transparent',
+ color: 'var(--brass)',
+ cursor: 'pointer',
+ transition: 'border-color 0.15s ease, color 0.15s ease',
+ }}
+ onMouseEnter={(e) => {
+ const el = e.currentTarget as HTMLButtonElement;
+ el.style.borderColor = 'var(--brass)';
+ }}
+ onMouseLeave={(e) => {
+ const el = e.currentTarget as HTMLButtonElement;
+ el.style.borderColor = 'var(--line-2)';
+ }}
+ >
+ {children}
+ </button>
+ );
+}