feat(focus-timer): Phase 1 — visual countdown ring + just-start timer

Closes the Core MVP gap in docs/brief/feature-set.md ("visual time
representation") and wires the dangling kon:start-timer emit that
MicroSteps.svelte has been firing into the void since the stub was
written. Implements phase 1 of the 2026-04-23 feature-complete
roadmap.

New:
- src/lib/stores/focusTimer.svelte.ts — singleton timer store with
  localStorage persistence so a timer started in Dictation survives
  page nav, window close, and reopen. Gentle WebAudio chime at
  completion (no bundled asset). 250 ms tick. Completion flash for
  3 s before auto-clear.
- src/lib/components/FocusTimer.svelte — floating top-right overlay
  with SVG progress ring (shrinking colour: accent -> warning in
  the final 15% -> success on completion). Cancel + "+1 min" on
  hover. Renders nothing when idle.

Wired in +layout.svelte next to ToastViewport.

Two triggers now in the app:
- MicroSteps row 2-min button (pre-existing emit, previously no
  listener)
- WipTaskList row 5-min button (new; the brief's "just-start"
  from the Now column)

Respects prefers-reduced-motion via the existing
[data-reduce-motion] attribute.

Out of scope, carried to later phases: rhythmic voice anchoring
(Phase 6), custom-duration picker, multi-timer UI, native OS
notification (deferred to Phase 6 with the full nudge pipeline).
This commit is contained in:
2026-04-24 11:50:45 +01:00
parent df6b19834d
commit 0c34a29367
4 changed files with 525 additions and 1 deletions

View File

@@ -1,7 +1,13 @@
<script lang="ts">
import { tasks, addTask, completeTask, uncompleteTask, deleteTask } from '$lib/stores/page.svelte.js';
import MicroSteps from '$lib/components/MicroSteps.svelte';
import { ChevronDown, ChevronRight } from 'lucide-svelte';
import { ChevronDown, ChevronRight, Timer } from 'lucide-svelte';
function startFocusTimer(task: { id: string; text: string }) {
window.dispatchEvent(new CustomEvent('kon:start-timer', {
detail: { taskId: task.id, seconds: 300, label: task.text }
}));
}
let { wipLimit = 3 } = $props();
@@ -68,6 +74,16 @@
aria-label="Complete task"
></button>
<span class="text-[13px] text-text flex-1 min-w-0 truncate">{task.text}</span>
<!-- 5-min focus timer — the "just-start" button from the brief -->
<button
class="opacity-0 group-hover:opacity-100 text-text-tertiary hover:text-accent"
onclick={() => startFocusTimer(task)}
aria-label="Start 5-minute focus timer for this task"
title="Start 5-minute focus timer"
style="transition: opacity var(--duration-ui)"
>
<Timer size={12} aria-hidden="true" />
</button>
<!-- Expand/collapse micro-steps toggle -->
<button
class="opacity-0 group-hover:opacity-100 text-text-tertiary hover:text-accent"