Files
zeroclock/docs/plans/2026-02-17-ui-polish-implementation.md
2026-02-17 21:21:16 +02:00

653 lines
22 KiB
Markdown

# 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:**
```css
@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:
```css
/* 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:
```css
/* 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`):**
```typescript
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:**
1. Wordmark "ZeroClock" → change class from `text-text-secondary` to `text-accent-text`
2. No other changes — running timer section and window controls are correct
**NavRail changes:**
1. Active indicator: change `bg-text-primary` to `bg-accent`
2. 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:
```html
<!-- 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:**
1. **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`
2. **Stats row** — change from 3 to 4 columns (`grid-cols-4`):
- Add "Today" stat (invoke `get_reports` for today only)
- Values: change from `text-text-primary` to `text-accent-text`
- Keep labels as `text-text-tertiary uppercase`
3. **Chart bars:**
- Change `backgroundColor: '#4A4A45'` to `'#D97706'`
- Grid color: `'#2E2E2A'`
- Tick color: `'#5A5A54'`
4. **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>`
5. **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 `Clock` icon (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 `todayStats` ref, fetch in onMounted with `getToday()` for both start and end
- Add greeting computed
- Add date computed
- Import `Clock` from lucide-vue-next
- Import `RouterLink` from 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:**
1. **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-colon` when running, `text-text-primary` when stopped
- Digits stay `text-text-primary`
2. **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
3. **Replace alert()** for "Please select a project":
- Import `useToastStore`
- Replace `alert('Please select a project before starting the timer')` with `toastStore.info('Please select a project before starting the timer')`
4. **Empty state** for recent entries:
- Import `Timer` icon from lucide-vue-next (use alias like `TimerIcon` to avoid name conflict)
- Replace plain `<p>` with centered block: icon (40px) + "No entries today" + description + no CTA (user is already on the right page)
**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:**
1. **"+ Add" button** — replace ghost text link with small amber button:
```html
<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>
```
2. **Card hover** — add `hover:bg-bg-elevated` to the card div. Add `transition-all duration-150` and change left border from `border-l-2` to `border-l-[2px] hover:border-l-[3px]`.
3. **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`
4. **Color picker presets** in create/edit dialog — add a row of 8 color swatches above the color input:
```html
<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']`
5. **Dialog buttons** — Submit becomes amber primary: `bg-accent text-bg-base font-medium rounded hover:bg-accent-hover`. Cancel stays secondary.
6. **Empty state:**
- Import `FolderKanban` from lucide-vue-next
- Replace current simple empty with: icon (48px) + "No projects yet" + description + amber "Create Project" button (calls `openCreateDialog`)
**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:**
1. **Filter bar** — wrap in a container:
```html
<div class="bg-bg-surface rounded p-4 mb-6">
<!-- existing filter content -->
</div>
```
2. **Apply button** → amber primary: `bg-accent text-bg-base text-xs font-medium rounded hover:bg-accent-hover`
3. **Clear button** → ghost: `text-text-secondary text-xs hover:text-text-primary transition-colors`. Remove border classes.
4. **Table header row** — add `bg-bg-surface` to the `<thead>` or `<tr>`:
```html
<tr class="border-b border-border-subtle bg-bg-surface">
```
5. **Duration column** — change from `text-text-primary` to `text-accent-text`:
```html
<td class="px-4 py-3 text-right text-xs font-mono text-accent-text">
```
6. **Edit dialog** — Save button becomes amber primary. Cancel stays secondary.
7. **Empty state:**
- Import `List` from 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
**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:**
1. **Filter bar** — wrap in `bg-bg-surface rounded p-4 mb-6` container
2. **Generate button** → amber primary. Export CSV → secondary outlined.
3. **Stats values** — change from `text-text-primary` to `text-accent-text`:
```html
<p class="text-[1.25rem] font-mono text-accent-text font-medium">
```
4. **Chart colors:**
- Grid: `'#2E2E2A'`
- Ticks: `'#5A5A54'`
- (Bar colors already use project colors, which is correct)
5. **Breakdown hours value** — change to `text-accent-text font-mono`
6. **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')`
7. **Empty states:**
- Import `BarChart3` from lucide-vue-next
- Chart area empty: icon (48px) + "Generate a report to see your data"
- Breakdown empty: same or slightly different text
**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:**
1. **Active tab underline** — change from `border-text-primary` to `border-accent`:
```html
'text-text-primary border-b-2 border-accent'
```
2. **Table header row** — add `bg-bg-surface`
3. **Amount column** — change to `text-accent-text font-mono`
4. **Create form submit** → amber primary button
5. **Create form total line** — the dollar amount in the totals box: `text-accent-text font-mono`
6. **Invoice detail dialog** — Export PDF button → amber primary. Total amount → `text-accent-text`.
7. **Replace alert():**
- Import `useToastStore`
- `alert('Please select a client')` → `toastStore.info('Please select a client')`
8. **Empty state (list view):**
- Import `FileText` from lucide-vue-next
- Icon (48px) + "No invoices yet" + description + amber "Create Invoice" button (sets `view = 'create'`)
**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:**
1. **Save Settings button** → amber primary: `bg-accent text-bg-base text-xs font-medium rounded hover:bg-accent-hover`
2. **Export button** stays secondary. Clear Data stays danger.
3. **New "Appearance" section** — add between Timer and Data sections:
```html
<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>
```
4. **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`, `Minus` from lucide-vue-next
- Add `zoomLevel` ref, 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.
```vue
<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:
```rust
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:
```toml
tauri-plugin-window-state = "2"
```
**tauri.conf.json changes:**
1. Remove `"center": true` from the window config (so saved position is respected)
2. 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()`:
```rust
.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:**
1. Run `npx vue-tsc --noEmit` — verify no TypeScript errors
2. Run `npx vite build` — verify frontend builds
3. Run `cd src-tauri && cargo check` — verify Rust compiles
4. Check for any remaining `alert(` calls in src/ — should be zero
5. Check for any remaining references to old color tokens (--color-background, --color-surface, --color-amber, etc.) — should be zero
6. Verify no imports of removed dependencies
**Commit:** `chore: cleanup — verify build, remove stale references`