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

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:

  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:

<!-- 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:

    <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:

    <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:

    <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>:

    <tr class="border-b border-border-subtle bg-bg-surface">
    
  5. Duration column — change from text-text-primary to text-accent-text:

    <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:

    <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:

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

    <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.

<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:

  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():

.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