22 KiB
UI Polish & UX Upgrade Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Transform ZeroClock from a grey monochrome app into a polished, amber-accented desktop tool with proper UX primitives (button hierarchy, toast notifications, rich empty states, UI zoom, portable storage, window persistence).
Architecture: CSS token overhaul in main.css provides the foundation. Toast system (component + Pinia store) replaces all alert() calls. Each of the 7 views gets updated templates with new button hierarchy, amber accents, and rich empty states. Rust backend gets portable storage and window-state plugin. UI zoom applies CSS zoom on #app root, persisted via settings.
Tech Stack: Vue 3 Composition API, Tailwind CSS v4 with @theme tokens, Pinia stores, Lucide Vue Next icons, Chart.js, Tauri v2 with rusqlite, tauri-plugin-window-state
Task 1: Design System — CSS Tokens & Utilities
Files:
- Modify:
src/styles/main.css
What to do:
Replace the entire @theme block with the new charcoal + amber palette. Remove all legacy aliases. Add amber focus utility and toast animation keyframes.
New @theme block:
@theme {
/* Background layers (charcoal, lifted from near-black) */
--color-bg-base: #1A1A18;
--color-bg-surface: #222220;
--color-bg-elevated: #2C2C28;
--color-bg-inset: #141413;
/* Text hierarchy (warm whites) */
--color-text-primary: #F5F5F0;
--color-text-secondary: #8A8A82;
--color-text-tertiary: #5A5A54;
/* Borders (bumped for charcoal) */
--color-border-subtle: #2E2E2A;
--color-border-visible: #3D3D38;
/* Accent (amber) */
--color-accent: #D97706;
--color-accent-hover: #B45309;
--color-accent-muted: rgba(217, 119, 6, 0.12);
--color-accent-text: #FBBF24;
/* Status (semantic only) */
--color-status-running: #34D399;
--color-status-warning: #EAB308;
--color-status-error: #EF4444;
--color-status-info: #3B82F6;
/* Fonts */
--font-sans: 'Inter', system-ui, sans-serif;
--font-mono: 'IBM Plex Mono', monospace;
}
Remove: All legacy aliases (lines 29-38 in current file: --color-background, --color-surface, --color-surface-elevated, --color-border, --color-error, --color-amber, --color-amber-hover, --color-success, --color-warning).
Add new keyframes after existing animations:
/* Toast enter animation */
@keyframes toast-enter {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-toast-enter {
animation: toast-enter 200ms ease-out;
}
/* Toast exit animation */
@keyframes toast-exit {
from { opacity: 1; }
to { opacity: 0; }
}
.animate-toast-exit {
animation: toast-exit 150ms ease-in forwards;
}
/* Timer colon amber pulse */
@keyframes pulse-colon {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
.animate-pulse-colon {
animation: pulse-colon 1s ease-in-out infinite;
}
Add global focus utility inside the @layer base block:
/* Amber focus ring for all interactive elements */
input:focus, select:focus, textarea:focus {
border-color: var(--color-accent) !important;
outline: none;
box-shadow: 0 0 0 2px var(--color-accent-muted);
}
Update scrollbar thumb to use new border token: var(--color-border-subtle) for thumb, var(--color-text-tertiary) for hover.
Verify: Run npx vite build — should succeed with no errors.
Commit: feat: overhaul design tokens — charcoal palette + amber accent
Task 2: Toast Notification System
Files:
- Create:
src/stores/toast.ts - Create:
src/components/ToastNotification.vue - Modify:
src/App.vue(add toast container)
Toast store (src/stores/toast.ts):
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface Toast {
id: number
message: string
type: 'success' | 'error' | 'info'
exiting?: boolean
}
export const useToastStore = defineStore('toast', () => {
const toasts = ref<Toast[]>([])
let nextId = 0
function addToast(message: string, type: Toast['type'] = 'info') {
const id = nextId++
toasts.value.push({ id, message, type })
// Max 3 visible
if (toasts.value.length > 3) {
toasts.value.shift()
}
// Auto-dismiss after 3s
setTimeout(() => removeToast(id), 3000)
}
function removeToast(id: number) {
const toast = toasts.value.find(t => t.id === id)
if (toast) {
toast.exiting = true
setTimeout(() => {
toasts.value = toasts.value.filter(t => t.id !== id)
}, 150)
}
}
function success(message: string) { addToast(message, 'success') }
function error(message: string) { addToast(message, 'error') }
function info(message: string) { addToast(message, 'info') }
return { toasts, addToast, removeToast, success, error, info }
})
Toast component (src/components/ToastNotification.vue):
Template: Fixed top-center container. Iterates toastStore.toasts. Each toast is a flex row with left colored border (3px), Lucide icon (Check/AlertCircle/Info, 16px), and message text. Click to dismiss. Uses animate-toast-enter / animate-toast-exit classes.
- Success:
border-l-status-running, green Check icon - Error:
border-l-status-error, red AlertCircle icon - Info:
border-l-accent, amber Info icon - Body:
bg-bg-surface border border-border-subtle rounded shadow-lg - Text:
text-sm text-text-primary - Width:
w-80(320px) - Gap between stacked toasts:
gap-2
Import Lucide icons: Check, AlertCircle, Info from lucide-vue-next.
App.vue modification:
Add <ToastNotification /> as a sibling AFTER the main shell div (so it overlays everything). Import the component.
Verify: Build succeeds. Toast component renders (can test by temporarily calling useToastStore().success('test') in App.vue onMounted).
Commit: feat: add toast notification system
Task 3: Shell — TitleBar & NavRail
Files:
- Modify:
src/components/TitleBar.vue - Modify:
src/components/NavRail.vue
TitleBar changes:
- Wordmark "ZeroClock" → change class from
text-text-secondarytotext-accent-text - No other changes — running timer section and window controls are correct
NavRail changes:
- Active indicator: change
bg-text-primarytobg-accent - Add a tooltip caret: small CSS triangle (border trick) pointing left, positioned at the left edge of the tooltip div. 4px wide, same bg as tooltip (
bg-bg-elevated).
Tooltip caret implementation — add a ::before pseudo-element or a small inline div:
<!-- Inside the tooltip div, add as first child: -->
<div class="absolute -left-1 top-1/2 -translate-y-1/2 w-0 h-0 border-y-4 border-y-transparent border-r-4 border-r-bg-elevated"></div>
Note: The border-r color needs to match the tooltip background. Since Tailwind v4 may not support border-r-bg-elevated directly, use an inline style: style="border-right-color: var(--color-bg-elevated)".
Verify: Build succeeds.
Commit: feat: amber wordmark and NavRail active indicator
Task 4: Dashboard — Full Redesign
Files:
- Modify:
src/views/Dashboard.vue
Template changes:
-
Add greeting header at top (before stats):
- Greeting computed from current hour: <6 "Good morning", <12 "Good morning", <18 "Good afternoon", else "Good evening"
- Date formatted: "Monday, February 17, 2026" using
toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }) - Greeting:
text-lg text-text-secondary - Date:
text-xs text-text-tertiary mt-1 - Wrapper:
mb-8
-
Stats row — change from 3 to 4 columns (
grid-cols-4):- Add "Today" stat (invoke
get_reportsfor today only) - Values: change from
text-text-primarytotext-accent-text - Keep labels as
text-text-tertiary uppercase
- Add "Today" stat (invoke
-
Chart bars:
- Change
backgroundColor: '#4A4A45'to'#D97706' - Grid color:
'#2E2E2A' - Tick color:
'#5A5A54'
- Change
-
Recent entries:
- Add "View all" link at bottom:
<router-link to="/entries" class="text-xs text-text-tertiary hover:text-text-secondary">View all</router-link>
- Add "View all" link at bottom:
-
Empty state — wrap current empty
<p>in a richer block:- Show empty state when BOTH recentEntries is empty AND weekStats.totalSeconds === 0
- Centered:
flex flex-col items-center justify-center py-16 - Lucide
Clockicon (import it), 48px,text-text-tertiary - "Start tracking your time" in
text-sm text-text-secondary mt-4 - Description in
text-xs text-text-tertiary mt-2 max-w-xs text-center <router-link to="/timer">styled as amber primary button:mt-4 px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded hover:bg-accent-hover transition-colors
Script changes:
- Add
todayStatsref, fetch in onMounted withgetToday()for both start and end - Add greeting computed
- Add date computed
- Import
Clockfrom lucide-vue-next - Import
RouterLinkfrom vue-router (or just use<router-link>which is globally available)
Verify: Build succeeds.
Commit: feat: redesign Dashboard — greeting, amber stats, rich empty state
Task 5: Timer — Full Redesign
Files:
- Modify:
src/views/Timer.vue
Template changes:
-
Timer display — split into digits and colons for amber pulse:
- Instead of
{{ timerStore.formattedTime }}as one string, split into parts - Create a computed that returns
{ hours, min, sec }or render each part separately - Colons get class
text-accent-text animate-pulse-colonwhen running,text-text-primarywhen stopped - Digits stay
text-text-primary
- Instead of
-
Start/Stop button:
- Start:
bg-accent text-bg-base px-10 py-3 text-sm font-medium rounded hover:bg-accent-hover transition-colors duration-150 - Stop:
bg-status-error text-white px-10 py-3 text-sm font-medium rounded hover:bg-status-error/80 transition-colors duration-150 - Remove the old outlined border classes
- Start:
-
Replace alert() for "Please select a project":
- Import
useToastStore - Replace
alert('Please select a project before starting the timer')withtoastStore.info('Please select a project before starting the timer')
- Import
-
Empty state for recent entries:
- Import
Timericon from lucide-vue-next (use alias likeTimerIconto avoid name conflict) - Replace plain
<p>with centered block: icon (40px) + "No entries today" + description + no CTA (user is already on the right page)
- Import
Script changes:
- Add
const toastStore = useToastStore() - Add computed for split timer parts (hours, minutes, seconds as separate strings)
- Import
useToastStore
Verify: Build succeeds.
Commit: feat: redesign Timer — amber Start, colon pulse, toast
Task 6: Projects — Full Redesign
Files:
- Modify:
src/views/Projects.vue
Template changes:
-
"+ Add" button — replace ghost text link with small amber button:
<button @click="openCreateDialog" class="px-3 py-1.5 bg-accent text-bg-base text-xs font-medium rounded hover:bg-accent-hover transition-colors duration-150"> + Add </button> -
Card hover — add
hover:bg-bg-elevatedto the card div. Addtransition-all duration-150and change left border fromborder-l-2toborder-l-[2px] hover:border-l-[3px]. -
Card content — combine client name and hourly rate on one line:
- Replace separate client
<p>and rate<p>with single line: {{ getClientName(project.client_id) }} · ${{ project.hourly_rate.toFixed(2) }}/hr- Class:
text-xs text-text-secondary mt-0.5
- Replace separate client
-
Color picker presets in create/edit dialog — add a row of 8 color swatches above the color input:
<div class="flex gap-2 mb-2"> <button v-for="c in colorPresets" :key="c" @click="formData.color = c" class="w-6 h-6 rounded-full border-2 transition-colors" :class="formData.color === c ? 'border-text-primary' : 'border-transparent'" :style="{ backgroundColor: c }" /> </div>Add to script:
const colorPresets = ['#D97706', '#3B82F6', '#8B5CF6', '#EC4899', '#10B981', '#EF4444', '#06B6D4', '#6B7280'] -
Dialog buttons — Submit becomes amber primary:
bg-accent text-bg-base font-medium rounded hover:bg-accent-hover. Cancel stays secondary. -
Empty state:
- Import
FolderKanbanfrom lucide-vue-next - Replace current simple empty with: icon (48px) + "No projects yet" + description + amber "Create Project" button (calls
openCreateDialog)
- Import
Verify: Build succeeds.
Commit: feat: redesign Projects — amber button, color presets, rich empty state
Task 7: Entries — Full Redesign
Files:
- Modify:
src/views/Entries.vue
Template changes:
-
Filter bar — wrap in a container:
<div class="bg-bg-surface rounded p-4 mb-6"> <!-- existing filter content --> </div> -
Apply button → amber primary:
bg-accent text-bg-base text-xs font-medium rounded hover:bg-accent-hover -
Clear button → ghost:
text-text-secondary text-xs hover:text-text-primary transition-colors. Remove border classes. -
Table header row — add
bg-bg-surfaceto the<thead>or<tr>:<tr class="border-b border-border-subtle bg-bg-surface"> -
Duration column — change from
text-text-primarytotext-accent-text:<td class="px-4 py-3 text-right text-xs font-mono text-accent-text"> -
Edit dialog — Save button becomes amber primary. Cancel stays secondary.
-
Empty state:
- Import
Listfrom lucide-vue-next - Below the filter bar, show centered empty: icon (48px) + "No entries found" + description text + amber "Go to Timer" button as router-link
- Import
Verify: Build succeeds.
Commit: feat: redesign Entries — filter container, amber actions, rich empty state
Task 8: Reports — Full Redesign
Files:
- Modify:
src/views/Reports.vue
Template changes:
-
Filter bar — wrap in
bg-bg-surface rounded p-4 mb-6container -
Generate button → amber primary. Export CSV → secondary outlined.
-
Stats values — change from
text-text-primarytotext-accent-text:<p class="text-[1.25rem] font-mono text-accent-text font-medium"> -
Chart colors:
- Grid:
'#2E2E2A' - Ticks:
'#5A5A54' - (Bar colors already use project colors, which is correct)
- Grid:
-
Breakdown hours value — change to
text-accent-text font-mono -
Replace alert() calls:
- Import
useToastStore alert('Please select a date range')→toastStore.info('Please select a date range')alert('No data to export')→toastStore.info('No data to export')alert('Failed to generate report')→toastStore.error('Failed to generate report')
- Import
-
Empty states:
- Import
BarChart3from lucide-vue-next - Chart area empty: icon (48px) + "Generate a report to see your data"
- Breakdown empty: same or slightly different text
- Import
Verify: Build succeeds.
Commit: feat: redesign Reports — amber actions and stats, toast notifications
Task 9: Invoices — Full Redesign
Files:
- Modify:
src/views/Invoices.vue
Template changes:
-
Active tab underline — change from
border-text-primarytoborder-accent:'text-text-primary border-b-2 border-accent' -
Table header row — add
bg-bg-surface -
Amount column — change to
text-accent-text font-mono -
Create form submit → amber primary button
-
Create form total line — the dollar amount in the totals box:
text-accent-text font-mono -
Invoice detail dialog — Export PDF button → amber primary. Total amount →
text-accent-text. -
Replace alert():
- Import
useToastStore alert('Please select a client')→toastStore.info('Please select a client')
- Import
-
Empty state (list view):
- Import
FileTextfrom lucide-vue-next - Icon (48px) + "No invoices yet" + description + amber "Create Invoice" button (sets
view = 'create')
- Import
Verify: Build succeeds.
Commit: feat: redesign Invoices — amber tabs and totals, rich empty state
Task 10: Settings — Full Redesign + UI Zoom
Files:
- Modify:
src/views/Settings.vue
Template changes:
-
Save Settings button → amber primary:
bg-accent text-bg-base text-xs font-medium rounded hover:bg-accent-hover -
Export button stays secondary. Clear Data stays danger.
-
New "Appearance" section — add between Timer and Data sections:
<div> <h2 class="text-base font-medium text-text-primary mb-4">Appearance</h2> <div class="pb-6 border-b border-border-subtle"> <div class="flex items-center justify-between"> <div> <p class="text-[0.8125rem] text-text-primary">UI Scale</p> <p class="text-xs text-text-secondary mt-0.5">Adjust the interface zoom level</p> </div> <div class="flex items-center gap-2"> <button @click="decreaseZoom" class="w-8 h-8 flex items-center justify-center border border-border-visible rounded text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors" :disabled="zoomLevel <= 80"> <Minus class="w-3.5 h-3.5" /> </button> <span class="w-12 text-center text-sm font-mono text-text-primary">{{ zoomLevel }}%</span> <button @click="increaseZoom" class="w-8 h-8 flex items-center justify-center border border-border-visible rounded text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors" :disabled="zoomLevel >= 150"> <Plus class="w-3.5 h-3.5" /> </button> </div> </div> </div> </div> -
Replace all alert() calls (5 total) with toast calls:
alert('Settings saved successfully')→toastStore.success('Settings saved')alert('Failed to save settings')→toastStore.error('Failed to save settings')alert('Failed to export data')→toastStore.error('Failed to export data')alert('Failed to clear data')→toastStore.error('Failed to clear data')alert('All data has been cleared')→toastStore.success('All data has been cleared')
Script changes:
- Import
useToastStore,Plus,Minusfrom lucide-vue-next - Add
zoomLevelref, initialized from settings store - Add
increaseZoom()/decreaseZoom()functions:- Steps: 80, 90, 100, 110, 120, 130, 150
- Updates the CSS zoom on
document.getElementById('app') - Calls
settingsStore.updateSetting('ui_zoom', zoomLevel.value.toString())
- Load zoom from settings on mount:
zoomLevel.value = parseInt(settingsStore.settings.ui_zoom) || 100
Verify: Build succeeds.
Commit: feat: redesign Settings — amber save, UI zoom, toasts
Task 11: App.vue — Zoom Initialization
Files:
- Modify:
src/App.vue
Add zoom initialization logic. On app mount, read the ui_zoom setting and apply CSS zoom to the #app element.
<script setup lang="ts">
import { onMounted } from 'vue'
import TitleBar from './components/TitleBar.vue'
import NavRail from './components/NavRail.vue'
import ToastNotification from './components/ToastNotification.vue'
import { useSettingsStore } from './stores/settings'
const settingsStore = useSettingsStore()
onMounted(async () => {
await settingsStore.fetchSettings()
const zoom = parseInt(settingsStore.settings.ui_zoom) || 100
const app = document.getElementById('app')
if (app) {
app.style.zoom = `${zoom}%`
}
})
</script>
<template>
<div class="h-full w-full flex flex-col bg-bg-base">
<TitleBar />
<div class="flex-1 flex overflow-hidden">
<NavRail />
<main class="flex-1 overflow-auto">
<router-view />
</main>
</div>
</div>
<ToastNotification />
</template>
Verify: Build succeeds.
Commit: feat: zoom initialization and toast container in App.vue
Task 12: Rust Backend — Portable Storage
Files:
- Modify:
src-tauri/src/lib.rs - Modify:
src-tauri/Cargo.toml
lib.rs changes:
Replace get_data_dir() to always use exe-relative path:
fn get_data_dir() -> PathBuf {
let exe_path = std::env::current_exe().unwrap();
let data_dir = exe_path.parent().unwrap().join("data");
std::fs::create_dir_all(&data_dir).ok();
data_dir
}
Remove use directories if present (it was only used as fallback, now we use exe-relative exclusively).
Cargo.toml changes:
Remove the directories dependency line:
directories = "5"
Verify: cd src-tauri && cargo check succeeds.
Commit: feat: portable storage — data directory next to exe
Task 13: Rust Backend — Window State Persistence
Files:
- Modify:
src-tauri/Cargo.toml - Modify:
src-tauri/src/lib.rs - Modify:
src-tauri/tauri.conf.json
Cargo.toml: Add dependency:
tauri-plugin-window-state = "2"
tauri.conf.json changes:
- Remove
"center": truefrom the window config (so saved position is respected) - Add window-state to plugins section — note: the plugin may need allowlist config. Check the plugin docs, but typically just registering in Rust is enough.
lib.rs changes:
Add the plugin registration in the builder chain, BEFORE .manage():
.plugin(tauri_plugin_window_state::Builder::new().build())
The plugin automatically saves/restores window position, size, and maximized state. By default it uses the app's data directory, which is now exe-relative thanks to Task 12.
Note: For the window-state plugin to use our portable data dir, we may need to check if it respects the Tauri path resolver or if it needs explicit configuration. If it saves to AppData by default, we may need to configure its storage path. Check the plugin API.
Verify: cd src-tauri && cargo check succeeds (this will also download the new dependency).
Commit: feat: persist window position and size between runs
Task 14: Build Verification & Cleanup
Files:
- All modified files
Steps:
- Run
npx vue-tsc --noEmit— verify no TypeScript errors - Run
npx vite build— verify frontend builds - Run
cd src-tauri && cargo check— verify Rust compiles - Check for any remaining
alert(calls in src/ — should be zero - Check for any remaining references to old color tokens (--color-background, --color-surface, --color-amber, etc.) — should be zero
- Verify no imports of removed dependencies
Commit: chore: cleanup — verify build, remove stale references