@heyramzi/tasks 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,74 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+ import { cn } from "../lib/cn.js";
4
+ import { TONE_TEXT } from "../tone.js";
5
+ import { budgetTone } from "./duration.js";
6
+
7
+ type Props = {
8
+ /** Minutes logged. */
9
+ value: number;
10
+ /** Minutes budgeted. The ring turns warning at 90% and destructive past 100%. */
11
+ max: number;
12
+ /** Pixels. */
13
+ size?: number;
14
+ /** Stroke width in a 36 unit box. */
15
+ stroke?: number;
16
+ label?: string;
17
+ /** Rendered in the middle, e.g. the percentage. */
18
+ children?: Snippet;
19
+ class?: string;
20
+ };
21
+
22
+ let { value, max, size = 56, stroke = 4, label, children, class: className }: Props = $props();
23
+
24
+ const R = 16;
25
+ let tone = $derived(budgetTone(value, max));
26
+ let fraction = $derived(max > 0 ? Math.min(1, Math.max(0, value / max)) : 0);
27
+ let colorClass = $derived(tone === "neutral" ? TONE_TEXT.accent : TONE_TEXT[tone]);
28
+ </script>
29
+
30
+ <span
31
+ class={cn("relative inline-grid shrink-0 place-items-center", className)}
32
+ style:width="{size}px"
33
+ style:height="{size}px"
34
+ role={label ? "img" : undefined}
35
+ aria-label={label}
36
+ aria-hidden={label ? undefined : "true"}
37
+ >
38
+ <svg viewBox="0 0 36 36" class="size-full -rotate-90" fill="none" aria-hidden="true">
39
+ <circle cx="18" cy="18" r={R} stroke="currentColor" stroke-width={stroke} class="text-foreground/10" />
40
+ <circle
41
+ cx="18"
42
+ cy="18"
43
+ r={R}
44
+ pathLength="100"
45
+ stroke="currentColor"
46
+ stroke-width={stroke}
47
+ stroke-linecap="round"
48
+ stroke-dasharray="{fraction * 100} 100"
49
+ class={cn("time-ring-arc", colorClass)}
50
+ class:is-empty={fraction === 0}
51
+ />
52
+ </svg>
53
+ {#if children}
54
+ <span class="absolute inset-0 grid place-items-center">{@render children()}</span>
55
+ {/if}
56
+ </span>
57
+
58
+ <style>
59
+ .time-ring-arc {
60
+ transition:
61
+ stroke-dasharray var(--duration-slow, 400ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)),
62
+ color var(--duration-fast, 250ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
63
+ }
64
+
65
+ .time-ring-arc.is-empty {
66
+ opacity: 0;
67
+ }
68
+
69
+ @media (prefers-reduced-motion: reduce) {
70
+ .time-ring-arc {
71
+ transition: none;
72
+ }
73
+ }
74
+ </style>
@@ -0,0 +1,567 @@
1
+ <script lang="ts" generics="T">
2
+ import type { Snippet } from "svelte";
3
+ import { cn } from "../lib/cn.js";
4
+ import { TONE_TEXT } from "../tone.js";
5
+ import {
6
+ budgetTone,
7
+ dayLabel,
8
+ formatClock,
9
+ formatMinutes,
10
+ localDay,
11
+ parseMinutes,
12
+ personColor,
13
+ } from "./duration.js";
14
+ import { DEFAULT_TIME_LABELS } from "./time-types.js";
15
+ import type { TimeLabels, TimeLogInput, TimeRunning, TimeStopInfo } from "./time-types.js";
16
+ import TimeDot from "./time-dot.svelte";
17
+ import TimeRing from "./time-ring.svelte";
18
+ import closeIcon from "../icons/close.svg?raw";
19
+ import playIcon from "../icons/play.svg?raw";
20
+
21
+ type Props<T> = {
22
+ /** Every entry logged on the task. Order within a day is kept. */
23
+ entries: T[];
24
+ getId: (entry: T) => string;
25
+ getMinutes: (entry: T) => number;
26
+ /** The day worked, `YYYY-MM-DD`. */
27
+ getDay: (entry: T) => string;
28
+ /** The name shown for the person who logged it. */
29
+ getPerson: (entry: T) => string;
30
+ /** Groups the split by id when two people share a name. Defaults to the name. */
31
+ getPersonId?: (entry: T) => string;
32
+ getNote?: (entry: T) => string | null | undefined;
33
+ /** The timer in progress, or null. The host stores it, so it survives a reload. */
34
+ running?: TimeRunning | null;
35
+ /** Minutes budgeted for the task. Draws the progress ring. */
36
+ estimate?: number;
37
+ onStart?: () => void | Promise<void>;
38
+ /** Called with the elapsed time. Log it, then clear `running`. */
39
+ onStop?: (info: TimeStopInfo) => void | Promise<void>;
40
+ /** A manual entry. Return false, or throw, to keep what was typed. */
41
+ onLog: (input: TimeLogInput) => void | boolean | Promise<void | boolean>;
42
+ /** Shows a delete button on rows where `canDelete` allows it. */
43
+ onDelete?: (entry: T) => void | Promise<void>;
44
+ canDelete?: (entry: T) => boolean;
45
+ /** Extra text after the note, e.g. the project. */
46
+ renderMeta?: Snippet<[T]>;
47
+ /** BCP 47 tag for day names. Defaults to the browser's. */
48
+ locale?: string;
49
+ labels?: Partial<TimeLabels>;
50
+ /** Drops the card: no border, radius, background, shadow or padding. For a host that already sits the tracker in a panel. */
51
+ flat?: boolean;
52
+ class?: string;
53
+ };
54
+
55
+ let {
56
+ entries,
57
+ getId,
58
+ getMinutes,
59
+ getDay,
60
+ getPerson,
61
+ getPersonId,
62
+ getNote,
63
+ running = null,
64
+ estimate,
65
+ onStart,
66
+ onStop,
67
+ onLog,
68
+ onDelete,
69
+ canDelete,
70
+ renderMeta,
71
+ locale,
72
+ labels: labelOverrides,
73
+ flat = false,
74
+ class: className,
75
+ }: Props<T> = $props();
76
+
77
+ let labels = $derived({ ...DEFAULT_TIME_LABELS, ...labelOverrides });
78
+
79
+ // ---- timer
80
+ let now = $state(Date.now());
81
+ let stoppedNote = $state("");
82
+ let startedMs = $derived(running ? new Date(running.startedAt).getTime() : null);
83
+ let elapsedMs = $derived(startedMs === null ? 0 : Math.max(0, now - startedMs));
84
+ let clock = $derived(formatClock(elapsedMs));
85
+ let elapsedMinutes = $derived(Math.floor(elapsedMs / 60_000));
86
+
87
+ // The tick runs only while a timer does, and re-reads the clock so a sleeping tab catches up.
88
+ $effect(() => {
89
+ if (startedMs === null) return;
90
+ now = Date.now();
91
+ const id = setInterval(() => (now = Date.now()), 1000);
92
+ return () => clearInterval(id);
93
+ });
94
+
95
+ // Screen readers hear the start, then one update a minute, then the stop.
96
+ let status = $derived(
97
+ running
98
+ ? elapsedMinutes === 0
99
+ ? labels.started
100
+ : labels.runningFor(formatMinutes(elapsedMinutes))
101
+ : stoppedNote,
102
+ );
103
+
104
+ function sinceLabel(): string {
105
+ if (startedMs === null) return "";
106
+ return labels.since(
107
+ new Date(startedMs).toLocaleTimeString(locale, { hour: "numeric", minute: "2-digit" }),
108
+ );
109
+ }
110
+
111
+ async function toggle(): Promise<void> {
112
+ if (!running || startedMs === null) {
113
+ stoppedNote = "";
114
+ await onStart?.();
115
+ return;
116
+ }
117
+ const stoppedAt = new Date();
118
+ const startedAt = new Date(startedMs);
119
+ const minutes = Math.max(1, Math.round((stoppedAt.getTime() - startedMs) / 60_000));
120
+ stoppedNote = labels.stopped(formatMinutes(minutes));
121
+ await onStop?.({ minutes, startedAt, stoppedAt, day: localDay(startedAt) });
122
+ }
123
+
124
+ function onRootKeyDown(event: KeyboardEvent): void {
125
+ if (event.altKey && event.code === "KeyS" && !event.repeat) {
126
+ event.preventDefault();
127
+ void toggle();
128
+ }
129
+ }
130
+
131
+ // ---- manual entry
132
+ let duration = $state("");
133
+ let day = $state(localDay());
134
+ let note = $state("");
135
+ let saving = $state(false);
136
+ let invalid = $state(false);
137
+ let durationInput = $state<HTMLInputElement>();
138
+ let parsed = $derived(parseMinutes(duration));
139
+ let preview = $derived(parsed !== null && parsed >= 1 && parsed <= 1440 ? formatMinutes(parsed) : "");
140
+
141
+ async function submit(event: SubmitEvent): Promise<void> {
142
+ event.preventDefault();
143
+ invalid = parsed === null || parsed < 1 || parsed > 1440;
144
+ if (invalid || parsed === null || saving) return;
145
+ saving = true;
146
+ try {
147
+ const result = await onLog({ minutes: parsed, day, note: note.trim() || undefined });
148
+ if (result !== false) {
149
+ duration = "";
150
+ note = "";
151
+ durationInput?.focus();
152
+ }
153
+ } catch {
154
+ // The host owns the error message. The fields stay as typed.
155
+ } finally {
156
+ saving = false;
157
+ }
158
+ }
159
+
160
+ // ---- summary
161
+ let total = $derived(entries.reduce((sum, entry) => sum + getMinutes(entry), 0));
162
+ let liveTotal = $derived(total + elapsedMinutes);
163
+ let tone = $derived(budgetTone(liveTotal, estimate));
164
+ let percent = $derived(estimate ? Math.round((liveTotal / estimate) * 100) : 0);
165
+
166
+ let groups = $derived.by(() => {
167
+ const byDay = new Map<string, { day: string; items: T[]; minutes: number }>();
168
+ for (const entry of entries) {
169
+ const key = getDay(entry);
170
+ const group = byDay.get(key) ?? { day: key, items: [], minutes: 0 };
171
+ group.items.push(entry);
172
+ group.minutes += getMinutes(entry);
173
+ byDay.set(key, group);
174
+ }
175
+ return [...byDay.values()].sort((a, b) => (a.day < b.day ? 1 : a.day > b.day ? -1 : 0));
176
+ });
177
+
178
+ let people = $derived.by(() => {
179
+ const byPerson = new Map<string, { key: string; name: string; minutes: number }>();
180
+ for (const entry of entries) {
181
+ const key = getPersonId?.(entry) ?? getPerson(entry);
182
+ const person = byPerson.get(key) ?? { key, name: getPerson(entry), minutes: 0 };
183
+ person.minutes += getMinutes(entry);
184
+ byPerson.set(key, person);
185
+ }
186
+ return [...byPerson.values()].sort((a, b) => b.minutes - a.minutes);
187
+ });
188
+
189
+ // Rows that arrive after the first paint ease in. The first paint shows them as they are.
190
+ // svelte-ignore state_referenced_locally
191
+ const firstIds = new Set(entries.map(getId));
192
+
193
+ let deleting = $state<string | null>(null);
194
+
195
+ async function remove(entry: T): Promise<void> {
196
+ deleting = getId(entry);
197
+ try {
198
+ await onDelete?.(entry);
199
+ } finally {
200
+ deleting = null;
201
+ }
202
+ }
203
+
204
+ function initial(name: string): string {
205
+ return name.trim().charAt(0).toLocaleUpperCase() || "?";
206
+ }
207
+ </script>
208
+
209
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
210
+ <section
211
+ class={cn("flex flex-col gap-5", className)}
212
+ aria-label={labels.title}
213
+ onkeydown={onRootKeyDown}
214
+ >
215
+ <div class="sr-only" role="status" aria-live="polite">{status}</div>
216
+
217
+ <!-- Timer and manual entry -->
218
+ <div
219
+ class={cn("time-panel", !flat && "rounded-2xl border border-border bg-card p-4 text-card-foreground shadow-sm")}
220
+ class:is-running={!!running && !flat}
221
+ >
222
+ <div class="flex items-center gap-4">
223
+ <div class="min-w-0 flex-1">
224
+ <p
225
+ class={cn(
226
+ "flex h-5 items-center gap-2 text-xs font-medium",
227
+ running ? TONE_TEXT.accent : "text-muted-foreground",
228
+ )}
229
+ >
230
+ <TimeDot running={!!running} />
231
+ <span>{running ? labels.running : labels.timer}</span>
232
+ {#if running}
233
+ <span class="font-normal text-muted-foreground">{sinceLabel()}</span>
234
+ {/if}
235
+ </p>
236
+ <p
237
+ role="timer"
238
+ aria-label={labels.elapsed}
239
+ class={cn("time-readout mt-2 flex items-baseline text-[2.5rem] leading-none font-medium tracking-tight tabular-nums", running ? "text-foreground" : "text-muted-foreground/50")}
240
+ >
241
+ <span>{clock.hours}</span><span class="time-colon">:</span><span>{clock.minutes}</span><span class="time-colon">:</span><span>{clock.seconds}</span>
242
+ </p>
243
+ </div>
244
+
245
+ <button
246
+ type="button"
247
+ onclick={toggle}
248
+ aria-label={running ? labels.stop : labels.start}
249
+ aria-keyshortcuts="Alt+S"
250
+ title="{running ? labels.stop : labels.start} (Alt+S)"
251
+ class={cn(
252
+ "time-toggle relative grid size-12 shrink-0 place-items-center rounded-full outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-card",
253
+ running ? "bg-foreground text-background" : "bg-primary text-primary-foreground",
254
+ )}
255
+ class:is-running={!!running}
256
+ >
257
+ {#if running}
258
+ <svg viewBox="0 0 24 24" class="size-4.5" fill="currentColor" aria-hidden="true">
259
+ <rect x="6" y="6" width="12" height="12" rx="2.5" />
260
+ </svg>
261
+ {:else}
262
+ <span class="size-5 translate-x-px [&>svg]:size-full" aria-hidden="true">{@html playIcon}</span>
263
+ {/if}
264
+ </button>
265
+ </div>
266
+
267
+ <form
268
+ onsubmit={submit}
269
+ class="mt-4 grid grid-cols-2 items-center gap-2 border-t border-border/60 pt-4 sm:grid-cols-[7rem_8.75rem_minmax(0,1fr)_auto]"
270
+ >
271
+ <div class="relative">
272
+ <input
273
+ bind:this={durationInput}
274
+ bind:value={duration}
275
+ oninput={() => (invalid = false)}
276
+ placeholder={labels.durationPlaceholder}
277
+ aria-label={labels.duration}
278
+ aria-invalid={invalid}
279
+ aria-describedby={invalid ? "time-invalid" : undefined}
280
+ autocomplete="off"
281
+ inputmode="text"
282
+ class={cn("time-field w-full tabular-nums", preview && "pr-12")}
283
+ />
284
+ {#if preview && preview !== duration.trim()}
285
+ <span class="time-preview pointer-events-none absolute inset-y-0 right-2.5 flex items-center text-xs text-muted-foreground tabular-nums" aria-hidden="true">
286
+ {preview}
287
+ </span>
288
+ {/if}
289
+ </div>
290
+ <input
291
+ type="date"
292
+ bind:value={day}
293
+ aria-label={labels.date}
294
+ class="time-field w-full tabular-nums"
295
+ />
296
+ <input
297
+ bind:value={note}
298
+ placeholder={labels.notePlaceholder}
299
+ aria-label={labels.note}
300
+ autocomplete="off"
301
+ class="time-field col-span-2 w-full sm:col-span-1"
302
+ />
303
+ <button
304
+ type="submit"
305
+ disabled={saving || !duration.trim()}
306
+ class="col-span-2 inline-flex h-9 items-center justify-center rounded-md sm:col-span-1 border border-border bg-background px-3.5 text-sm font-medium text-foreground transition-colors outline-none hover:bg-muted focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50"
307
+ >
308
+ {saving ? labels.saving : labels.log}
309
+ </button>
310
+ </form>
311
+ {#if invalid}
312
+ <p id="time-invalid" class="mt-2 text-xs text-destructive" role="alert">{labels.invalid}</p>
313
+ {/if}
314
+ </div>
315
+
316
+ <!-- Total, budget, split -->
317
+ {#if entries.length > 0 || estimate}
318
+ <div class="flex flex-col gap-4 px-1">
319
+ <div class="flex items-center gap-4">
320
+ {#if estimate}
321
+ <TimeRing value={liveTotal} max={estimate} size={60} stroke={4}>
322
+ <span class={cn("text-xs font-semibold tabular-nums", tone === "neutral" ? "text-foreground" : TONE_TEXT[tone])}>{percent}%</span>
323
+ </TimeRing>
324
+ {/if}
325
+ <div class="min-w-0">
326
+ <p class="text-xs font-medium text-muted-foreground">{labels.logged}</p>
327
+ {#key total}
328
+ <p class="time-pop mt-0.5 text-2xl leading-tight font-semibold tracking-tight tabular-nums">
329
+ {formatMinutes(total)}
330
+ </p>
331
+ {/key}
332
+ {#if estimate}
333
+ <p class={cn("mt-0.5 text-xs tabular-nums", tone === "neutral" ? "text-muted-foreground" : TONE_TEXT[tone])}>
334
+ {liveTotal > estimate
335
+ ? labels.over(formatMinutes(liveTotal - estimate), formatMinutes(estimate))
336
+ : labels.remaining(formatMinutes(estimate - liveTotal), formatMinutes(estimate))}
337
+ </p>
338
+ {/if}
339
+ </div>
340
+ </div>
341
+
342
+ {#if people.length > 1}
343
+ <div>
344
+ <div class="flex h-1.5 gap-0.5 overflow-hidden rounded-full" aria-hidden="true">
345
+ {#each people as person (person.key)}
346
+ <span
347
+ class="time-segment min-w-1 rounded-full"
348
+ style:flex-grow={person.minutes}
349
+ style:background={personColor(person.key)}
350
+ ></span>
351
+ {/each}
352
+ </div>
353
+ <ul class="mt-2.5 flex flex-wrap gap-x-4 gap-y-1">
354
+ {#each people as person (person.key)}
355
+ <li class="flex items-center gap-1.5 text-xs">
356
+ <span class="size-2 rounded-full" style:background={personColor(person.key)} aria-hidden="true"></span>
357
+ <span class="text-muted-foreground">{person.name}</span>
358
+ <span class="font-medium tabular-nums">{formatMinutes(person.minutes)}</span>
359
+ </li>
360
+ {/each}
361
+ </ul>
362
+ </div>
363
+ {/if}
364
+ </div>
365
+ {/if}
366
+
367
+ <!-- Entries by day -->
368
+ {#if groups.length > 0}
369
+ <div class="flex flex-col gap-4">
370
+ {#each groups as group (group.day)}
371
+ <div>
372
+ <div class="flex items-baseline justify-between border-b border-border/60 px-2 pb-1.5">
373
+ <h3 class="text-xs font-medium text-muted-foreground">{dayLabel(group.day, locale)}</h3>
374
+ <span class="text-xs font-medium text-muted-foreground tabular-nums">{formatMinutes(group.minutes)}</span>
375
+ </div>
376
+ <ul class="mt-1">
377
+ {#each group.items as entry (getId(entry))}
378
+ {@const name = getPerson(entry)}
379
+ {@const text = getNote?.(entry)}
380
+ <li
381
+ class="time-row group flex items-center gap-3 rounded-lg px-2 py-1.5 text-sm transition-colors hover:bg-muted/50 focus-within:bg-muted/50"
382
+ class:is-new={!firstIds.has(getId(entry))}
383
+ class:opacity-50={deleting === getId(entry)}
384
+ >
385
+ <span
386
+ class="grid size-5 shrink-0 place-items-center rounded-full text-[10px] font-semibold text-white"
387
+ style:background={personColor(getPersonId?.(entry) ?? name)}
388
+ title={name}
389
+ aria-hidden="true"
390
+ >
391
+ {initial(name)}
392
+ </span>
393
+ <span class="min-w-0 flex-1 truncate">
394
+ <span class="sr-only">{name},</span>
395
+ {#if text}{text}{:else}<span class="text-muted-foreground">{name}</span>{/if}
396
+ {#if renderMeta}<span class="ms-2 text-xs text-muted-foreground">{@render renderMeta(entry)}</span>{/if}
397
+ </span>
398
+ <span class="shrink-0 font-medium tabular-nums">{formatMinutes(getMinutes(entry))}</span>
399
+ {#if onDelete && (canDelete?.(entry) ?? true)}
400
+ <button
401
+ type="button"
402
+ onclick={() => remove(entry)}
403
+ disabled={deleting === getId(entry)}
404
+ aria-label={labels.deleteEntry(formatMinutes(getMinutes(entry)))}
405
+ class="time-delete -my-1 -me-1 grid size-8 shrink-0 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:ring-[3px] focus-visible:ring-ring/50"
406
+ >
407
+ <span class="size-3.5 [&>svg]:size-full" aria-hidden="true">{@html closeIcon}</span>
408
+ </button>
409
+ {/if}
410
+ </li>
411
+ {/each}
412
+ </ul>
413
+ </div>
414
+ {/each}
415
+ </div>
416
+ {:else}
417
+ <p class="px-1 py-6 text-center text-sm text-muted-foreground">{labels.empty}</p>
418
+ {/if}
419
+ </section>
420
+
421
+ <style>
422
+ .time-panel {
423
+ transition:
424
+ border-color var(--duration-medium, 350ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)),
425
+ background-color var(--duration-medium, 350ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
426
+ }
427
+
428
+ .time-panel.is-running {
429
+ border-color: color-mix(in oklab, var(--tone-accent, var(--color-primary)) 32%, var(--color-border));
430
+ background-color: color-mix(in oklab, var(--tone-accent, var(--color-primary)) 4%, var(--color-card));
431
+ }
432
+
433
+ .time-readout {
434
+ transition: color var(--duration-fast, 250ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
435
+ font-feature-settings: "tnum" 1;
436
+ }
437
+
438
+ .time-colon {
439
+ margin-inline: 0.04em;
440
+ opacity: 0.45;
441
+ }
442
+
443
+ .time-toggle {
444
+ transition:
445
+ transform var(--duration-quick, 150ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)),
446
+ background-color var(--duration-fast, 250ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)),
447
+ color var(--duration-fast, 250ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
448
+ }
449
+
450
+ .time-toggle:active {
451
+ transform: scale(0.94);
452
+ }
453
+
454
+ .time-toggle.is-running::after {
455
+ content: "";
456
+ position: absolute;
457
+ inset: 0;
458
+ border-radius: inherit;
459
+ border: 1.5px solid var(--tone-accent, var(--color-primary));
460
+ animation: time-halo var(--time-pulse-duration, 2000ms) var(--ease-out, ease-out) infinite;
461
+ }
462
+
463
+ .time-field {
464
+ height: 2.25rem;
465
+ border-radius: var(--radius-md, 0.5rem);
466
+ border: 1px solid var(--color-input, var(--color-border));
467
+ background: var(--color-background);
468
+ padding-inline: 0.625rem;
469
+ font-size: 0.875rem;
470
+ color: var(--color-foreground);
471
+ outline: none;
472
+ transition:
473
+ border-color var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out),
474
+ box-shadow var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out);
475
+ }
476
+
477
+ .time-field::placeholder {
478
+ color: var(--color-muted-foreground);
479
+ opacity: 0.7;
480
+ }
481
+
482
+ .time-field:focus-visible {
483
+ border-color: var(--color-ring);
484
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-ring) 40%, transparent);
485
+ }
486
+
487
+ .time-field[aria-invalid="true"] {
488
+ border-color: var(--color-destructive);
489
+ }
490
+
491
+ .time-segment {
492
+ transition: flex-grow var(--duration-slow, 400ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
493
+ }
494
+
495
+ .time-delete {
496
+ opacity: 0;
497
+ }
498
+
499
+ .time-row:hover .time-delete,
500
+ .time-row:focus-within .time-delete {
501
+ opacity: 1;
502
+ }
503
+
504
+ @media (hover: none) {
505
+ .time-delete {
506
+ opacity: 1;
507
+ }
508
+ }
509
+
510
+ .time-pop,
511
+ .time-preview {
512
+ animation: time-pop var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out) backwards;
513
+ }
514
+
515
+ .time-row.is-new {
516
+ animation: time-row-in var(--duration-fast, 250ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)) backwards;
517
+ }
518
+
519
+ @keyframes time-halo {
520
+ from {
521
+ transform: scale(1);
522
+ opacity: 0.55;
523
+ }
524
+ to {
525
+ transform: scale(1.5);
526
+ opacity: 0;
527
+ }
528
+ }
529
+
530
+ @keyframes time-pop {
531
+ from {
532
+ opacity: 0;
533
+ transform: translateY(4px);
534
+ filter: blur(var(--blur-small, 2px));
535
+ }
536
+ }
537
+
538
+ @keyframes time-row-in {
539
+ from {
540
+ opacity: 0;
541
+ transform: translateY(var(--distance-base, 8px));
542
+ filter: blur(var(--blur-small, 2px));
543
+ }
544
+ }
545
+
546
+ @media (prefers-reduced-motion: reduce) {
547
+ .time-panel,
548
+ .time-readout,
549
+ .time-toggle,
550
+ .time-field,
551
+ .time-segment {
552
+ transition: none;
553
+ }
554
+
555
+ .time-toggle.is-running::after {
556
+ animation: none;
557
+ transform: scale(1.18);
558
+ opacity: 0.4;
559
+ }
560
+
561
+ .time-pop,
562
+ .time-preview,
563
+ .time-row.is-new {
564
+ animation: none;
565
+ }
566
+ }
567
+ </style>