@nightkatana/kronosys-app 1.0.0-beta.21 → 1.0.0-beta.22

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.
Files changed (46) hide show
  1. package/README.md +1 -1
  2. package/app/changelog/page.tsx +87 -19
  3. package/app/globals.css +10 -8
  4. package/app/guide/page.tsx +71 -34
  5. package/app/implementation/page.tsx +70 -60
  6. package/app/licenses/page.tsx +79 -47
  7. package/app/logs/page.tsx +103 -47
  8. package/app/page.tsx +104 -169
  9. package/app/reporting/page.tsx +1918 -1436
  10. package/app/settings/page.tsx +66 -44
  11. package/components/KronosysPayloadProvider.tsx +19 -5
  12. package/components/dashboard/AppShellHeaderKronoFocus.tsx +78 -0
  13. package/components/dashboard/AppShellHeaderToolbarLayout.tsx +36 -0
  14. package/components/dashboard/AppShellHeaderUtilityRibbon.tsx +19 -0
  15. package/components/dashboard/AppShellHeaderWallClock.tsx +23 -17
  16. package/components/dashboard/AppShellRouteNav.tsx +336 -209
  17. package/components/dashboard/AppShellToolbarCommandCenter.tsx +225 -0
  18. package/components/dashboard/AppShellToolbarRouteNav.tsx +204 -0
  19. package/components/dashboard/DashboardCommandCenter.tsx +119 -30
  20. package/components/dashboard/KronoFocusPanel.tsx +287 -260
  21. package/components/dashboard/LanguageMenu.tsx +23 -7
  22. package/components/dashboard/PageRefreshButton.tsx +42 -16
  23. package/components/dashboard/ReportingTour.tsx +20 -2
  24. package/components/dashboard/SessionListPanel.tsx +4 -4
  25. package/components/dashboard/ThemeToggle.tsx +4 -3
  26. package/components/dashboard/useAnchoredFloatingPortalStyle.ts +9 -2
  27. package/components/dashboard/useKronoFocusLiveSeconds.ts +4 -2
  28. package/lib/appShellHeaderClasses.ts +22 -3
  29. package/lib/appShellToolbarChrome.ts +112 -0
  30. package/lib/appShellToolbarDeferredIntents.ts +112 -0
  31. package/lib/appShellToolbarSessionSlices.ts +67 -0
  32. package/lib/dashboardCopy.ts +78 -29
  33. package/lib/dashboardQuickSearch.ts +37 -6
  34. package/lib/dashboardUrlSession.ts +36 -0
  35. package/lib/generatedUserChangelog.ts +14 -0
  36. package/lib/implementationNotes.ts +18 -14
  37. package/lib/reportingAggregate.ts +68 -9
  38. package/lib/reportingMetricHelp.ts +8 -8
  39. package/lib/reportingStrings.ts +118 -9
  40. package/lib/reportingTagWeekBreakdown.ts +55 -13
  41. package/lib/settingsCopy.ts +6 -7
  42. package/lib/userGuideCopy.ts +29 -26
  43. package/package.json +7 -5
  44. package/server/db.ts +6 -4
  45. package/server/dbSchema.ts +2 -2
  46. package/components/dashboard/AppShellCommandCenterPlaceholder.tsx +0 -17
@@ -1,7 +1,9 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useId, useRef, useState } from "react";
4
+ import { createPortal } from "react-dom";
4
5
  import { Play, Pause, RotateCcw, Check, X, CircleHelp } from "lucide-react";
6
+ import { useAnchoredFloatingPortalStyle } from "@/components/dashboard/useAnchoredFloatingPortalStyle";
5
7
  import type { DashboardStrings } from "@/lib/dashboardCopy";
6
8
  import {
7
9
  clearKronoFocusDurationHistory,
@@ -15,6 +17,13 @@ import {
15
17
  KRONO_FOCUS_RHYTHM_PRESETS,
16
18
  } from "@/lib/kronoFocusRhythm";
17
19
  import { getKronoFocusTimerUrgency } from "@/lib/kronoFocusTimerUrgency";
20
+ import {
21
+ appShellToolbarIconActiveClass,
22
+ appShellToolbarIconLinkClass,
23
+ appShellToolbarInsetCellH10ButtonClass,
24
+ appShellToolbarInsetCellH10Class,
25
+ appShellToolbarRibbonTrayWideClass,
26
+ } from "@/lib/appShellToolbarChrome";
18
27
 
19
28
  import { useKronoFocusLiveSeconds } from "./useKronoFocusLiveSeconds";
20
29
 
@@ -41,19 +50,10 @@ const MAX_WORK_SEC = 8 * 3600;
41
50
  /** Durée de travail KronoFocus par défaut (25 min). */
42
51
  const DEFAULT_KRONO_FOCUS_WORK_SEC = 25 * 60;
43
52
 
44
- /**
45
- * Bandeau entête : repères proches des boutons du header, avec variantes clair / sombre.
46
- */
47
- /** Même gabarit que `AppShellRouteNav` (`size-10`) pour rester sur une ligne avec la nav. */
48
- const kronoFocusControlBtnBaseHeader =
49
- "box-border inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-lg border border-zinc-300 bg-white p-0 leading-none outline-none transition hover:border-zinc-400 [&_svg]:pointer-events-none [&_svg]:block [&_svg]:!h-5 [&_svg]:!w-5 [&_svg]:max-h-5 [&_svg]:max-w-5 [&_svg]:shrink-0 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-violet-500/55 dark:border-zinc-700 dark:bg-zinc-900 dark:hover:border-zinc-500";
50
- const kronoFocusControlNeutralHeader = `${kronoFocusControlBtnBaseHeader} text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800/80`;
51
- const kronoFocusControlAccentHeader = `${kronoFocusControlBtnBaseHeader} text-violet-800 hover:bg-violet-50 dark:text-violet-200 dark:hover:bg-violet-950/55`;
52
-
53
53
  const kronoFocusControlBtnBaseCard =
54
54
  "box-border inline-flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-md border border-zinc-300 bg-white p-0 leading-none outline-none transition hover:border-zinc-400 [&_svg]:pointer-events-none [&_svg]:block [&_svg]:!h-[18px] [&_svg]:!w-[18px] [&_svg]:max-h-[18px] [&_svg]:max-w-[18px] [&_svg]:shrink-0 sm:h-10 sm:w-10 sm:[&_svg]:!h-5 sm:[&_svg]:!w-5 sm:[&_svg]:max-h-5 sm:[&_svg]:max-w-5 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-violet-500/55 dark:border-zinc-700 dark:bg-zinc-900 dark:hover:border-zinc-500";
55
- const kronoFocusControlNeutralCard = `${kronoFocusControlBtnBaseCard} text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800/80`;
56
- const kronoFocusControlAccentCard = `${kronoFocusControlBtnBaseCard} text-violet-800 hover:bg-violet-50 dark:text-violet-200 dark:hover:bg-violet-950/55`;
55
+ const kronoFocusControlNeutralCard = `${kronoFocusControlBtnBaseCard} text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800/80 disabled:pointer-events-none disabled:opacity-45 disabled:cursor-not-allowed disabled:hover:bg-white dark:disabled:hover:bg-zinc-900`;
56
+ const kronoFocusControlAccentCard = `${kronoFocusControlBtnBaseCard} text-violet-800 hover:bg-violet-50 dark:text-violet-200 dark:hover:bg-violet-950/55 disabled:pointer-events-none disabled:opacity-45 disabled:cursor-not-allowed disabled:hover:bg-white dark:disabled:hover:bg-zinc-900`;
57
57
 
58
58
  /** Affichage minuteur et champ de durée : toujours `HH:MM:SS` (temps écoulé, pas une heure du jour). */
59
59
  function formatSecondsAsHMS(totalSec: number): string {
@@ -61,7 +61,9 @@ function formatSecondsAsHMS(totalSec: number): string {
61
61
  const h = Math.floor(s / 3600);
62
62
  const m = Math.floor((s % 3600) / 60);
63
63
  const sec = s % 60;
64
- return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
64
+ return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(
65
+ sec,
66
+ ).padStart(2, "0")}`;
65
67
  }
66
68
 
67
69
  /**
@@ -73,7 +75,8 @@ function parseTimeInputToSeconds(value: string): number | null {
73
75
  if (parts.length === 2) {
74
76
  const h = Number.parseInt(parts[0], 10);
75
77
  const m = Number.parseInt(parts[1], 10);
76
- if (Number.isNaN(h) || Number.isNaN(m) || m < 0 || m > 59 || h < 0) return null;
78
+ if (Number.isNaN(h) || Number.isNaN(m) || m < 0 || m > 59 || h < 0)
79
+ return null;
77
80
  return h * 3600 + m * 60;
78
81
  }
79
82
  if (parts.length === 3) {
@@ -215,7 +218,9 @@ function KronoFocusDurationPopoverFields({
215
218
  />
216
219
  </div>
217
220
  </div>
218
- <p className="mt-1.5 text-[0.6rem] leading-snug text-zinc-500 dark:text-zinc-500">{t.kronoFocusRhythmBreaksMinutesHint}</p>
221
+ <p className="mt-1.5 text-[0.6rem] leading-snug text-zinc-500 dark:text-zinc-500">
222
+ {t.kronoFocusRhythmBreaksMinutesHint}
223
+ </p>
219
224
  <button
220
225
  type="button"
221
226
  className="mt-2 w-full rounded-md border border-zinc-300 px-2 py-1.5 text-left text-[0.7rem] font-medium text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 dark:border-zinc-600 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
@@ -250,7 +255,10 @@ function KronoFocusDurationPopoverFields({
250
255
  type="button"
251
256
  className={kronoFocusDurationHistoryChipClass}
252
257
  title={label}
253
- aria-label={t.kronoFocusDurationHistoryPickAria.replace("{time}", label)}
258
+ aria-label={t.kronoFocusDurationHistoryPickAria.replace(
259
+ "{time}",
260
+ label,
261
+ )}
254
262
  onClick={() => setDraftTime(label)}
255
263
  >
256
264
  {label}
@@ -284,67 +292,6 @@ function KronoFocusDurationPopoverFields({
284
292
  );
285
293
  }
286
294
 
287
- function KronoFocusPanelHelpTrigger({ t }: { t: DashboardStrings }) {
288
- const subtitle = (t.kronoFocusStandaloneSubtitle ?? "").trim();
289
- const note = (t.kronoFocusAutoRefreshNote ?? "").trim();
290
- const hasBody = subtitle.length > 0 || note.length > 0;
291
-
292
- const [open, setOpen] = useState(false);
293
- const rootRef = useRef<HTMLDivElement>(null);
294
- const id = useId();
295
-
296
- useEffect(() => {
297
- if (!open) {
298
- return;
299
- }
300
- const onDoc = (e: MouseEvent) => {
301
- if (!rootRef.current?.contains(e.target as Node)) {
302
- setOpen(false);
303
- }
304
- };
305
- document.addEventListener("mousedown", onDoc);
306
- return () => document.removeEventListener("mousedown", onDoc);
307
- }, [open]);
308
-
309
- if (!hasBody) {
310
- return null;
311
- }
312
-
313
- return (
314
- <div className="relative inline-flex shrink-0" ref={rootRef}>
315
- <button
316
- type="button"
317
- className="rounded p-0.5 text-zinc-500 hover:bg-zinc-200/90 hover:text-zinc-800 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
318
- aria-label={t.kronoFocusPanelHelpAriaLabel}
319
- aria-expanded={open ? "true" : "false"}
320
- aria-controls={`${id}-kronoFocus-panel-help`}
321
- onClick={() => setOpen((o) => !o)}
322
- >
323
- <CircleHelp size={15} strokeWidth={1.75} aria-hidden />
324
- </button>
325
- {open ? (
326
- <div
327
- id={`${id}-kronoFocus-panel-help`}
328
- className="absolute left-0 top-full z-[60] mt-1 w-[min(calc(100vw-2rem),18rem)] rounded-lg border border-zinc-200 bg-white p-2.5 text-left shadow-lg dark:border-zinc-600 dark:bg-zinc-900"
329
- role="region"
330
- aria-label={t.kronoFocusPanelHelpAriaLabel}
331
- >
332
- {subtitle ? (
333
- <p className="text-[0.7rem] leading-snug text-zinc-700 dark:text-zinc-300">{subtitle}</p>
334
- ) : null}
335
- {note ? (
336
- <p
337
- className={`text-[0.7rem] leading-snug text-zinc-600 dark:text-zinc-400 ${subtitle ? "mt-2" : ""}`}
338
- >
339
- {note}
340
- </p>
341
- ) : null}
342
- </div>
343
- ) : null}
344
- </div>
345
- );
346
- }
347
-
348
295
  function KronoFocusDurationHelpTrigger({ t }: { t: DashboardStrings }) {
349
296
  const [open, setOpen] = useState(false);
350
297
  const rootRef = useRef<HTMLDivElement>(null);
@@ -382,7 +329,9 @@ function KronoFocusDurationHelpTrigger({ t }: { t: DashboardStrings }) {
382
329
  role="region"
383
330
  aria-label={t.kronoFocusDurationHelpAriaLabel}
384
331
  >
385
- <p className="text-[0.7rem] leading-snug text-zinc-700 dark:text-zinc-300">{t.kronoFocusDurationHelpBody}</p>
332
+ <p className="text-[0.7rem] leading-snug text-zinc-700 dark:text-zinc-300">
333
+ {t.kronoFocusDurationHelpBody}
334
+ </p>
386
335
  </div>
387
336
  ) : null}
388
337
  </div>
@@ -407,25 +356,44 @@ export function KronoFocusPanel({
407
356
  /** `headerBar` : bandeau compact pour l’entête (grands écrans) ; `default` : carte complète */
408
357
  variant?: "default" | "headerBar";
409
358
  }) {
410
- const serverSecs = kronoFocus?.timeLeftSeconds ?? DEFAULT_KRONO_FOCUS_WORK_SEC;
359
+ const serverSecs =
360
+ kronoFocus?.timeLeftSeconds ?? DEFAULT_KRONO_FOCUS_WORK_SEC;
411
361
  const mode = kronoFocus?.mode ?? "work";
412
362
  const status = kronoFocus?.status ?? "idle";
413
- const displaySecs = useKronoFocusLiveSeconds(serverSecs, status, kronoFocus?.kronoFocusDeadlineAtMs);
363
+ const displaySecs = useKronoFocusLiveSeconds(
364
+ serverSecs,
365
+ status,
366
+ kronoFocus?.kronoFocusDeadlineAtMs,
367
+ );
414
368
  const clockDisplay = formatSecondsAsHMS(displaySecs);
415
369
  const canEditWorkDuration =
416
370
  !viewingArchive && status === "idle" && mode === "work";
417
371
 
418
372
  const [durationPopoverOpen, setDurationPopoverOpen] = useState(false);
419
- const [draftTime, setDraftTime] = useState(() => formatSecondsAsHMS(serverSecs));
373
+ const [draftTime, setDraftTime] = useState(() =>
374
+ formatSecondsAsHMS(serverSecs),
375
+ );
420
376
  const [draftShortBreakMin, setDraftShortBreakMin] = useState("5");
421
377
  const [draftLongBreakMin, setDraftLongBreakMin] = useState("15");
422
- const [customDurationHistory, setCustomDurationHistory] = useState<number[]>([]);
423
- const popoverRef = useRef<HTMLDivElement>(null);
378
+ const [customDurationHistory, setCustomDurationHistory] = useState<number[]>(
379
+ [],
380
+ );
381
+ const timeButtonRef = useRef<HTMLButtonElement>(null);
382
+ const durationPopoverPanelRef = useRef<HTMLDivElement>(null);
424
383
  const prevKronoFocusStatusRef = useRef(status);
425
384
  const [showStartPulse, setShowStartPulse] = useState(false);
426
385
 
386
+ const durationPopoverStyle = useAnchoredFloatingPortalStyle(
387
+ durationPopoverOpen && canEditWorkDuration,
388
+ timeButtonRef,
389
+ durationPopoverPanelRef,
390
+ { align: "center", maxWidthRem: 20 },
391
+ );
392
+
427
393
  useEffect(() => {
428
- setCustomDurationHistory(loadKronoFocusDurationHistory(DEFAULT_KRONO_FOCUS_WORK_SEC));
394
+ setCustomDurationHistory(
395
+ loadKronoFocusDurationHistory(DEFAULT_KRONO_FOCUS_WORK_SEC),
396
+ );
429
397
  }, []);
430
398
 
431
399
  useEffect(() => {
@@ -439,7 +407,8 @@ export function KronoFocusPanel({
439
407
  return;
440
408
  }
441
409
  const workSec =
442
- typeof kronoFocus?.workDurationSeconds === "number" && Number.isFinite(kronoFocus.workDurationSeconds)
410
+ typeof kronoFocus?.workDurationSeconds === "number" &&
411
+ Number.isFinite(kronoFocus.workDurationSeconds)
443
412
  ? kronoFocus.workDurationSeconds
444
413
  : serverSecs;
445
414
  setDraftTime(formatSecondsAsHMS(workSec));
@@ -474,8 +443,12 @@ export function KronoFocusPanel({
474
443
  useEffect(() => {
475
444
  if (!durationPopoverOpen) return;
476
445
  const onDocMouseDown = (e: MouseEvent) => {
477
- const el = popoverRef.current;
478
- if (el && !el.contains(e.target as Node)) {
446
+ const target = e.target as Node;
447
+ const trigger = timeButtonRef.current;
448
+ const panel = durationPopoverPanelRef.current;
449
+ const insideTrigger = trigger ? trigger.contains(target) : false;
450
+ const insidePanel = panel ? panel.contains(target) : false;
451
+ if (!insideTrigger && !insidePanel) {
479
452
  setDurationPopoverOpen(false);
480
453
  }
481
454
  };
@@ -490,7 +463,9 @@ export function KronoFocusPanel({
490
463
  };
491
464
  }, [durationPopoverOpen]);
492
465
 
493
- const applyRhythmPreset = (preset: (typeof KRONO_FOCUS_RHYTHM_PRESETS)[number]) => {
466
+ const applyRhythmPreset = (
467
+ preset: (typeof KRONO_FOCUS_RHYTHM_PRESETS)[number],
468
+ ) => {
494
469
  void post({
495
470
  type: "setKronoFocusDurations",
496
471
  workSeconds: preset.workSeconds,
@@ -498,7 +473,11 @@ export function KronoFocusPanel({
498
473
  longBreakSeconds: preset.longBreakSeconds,
499
474
  });
500
475
  setCustomDurationHistory((prev) => {
501
- const next = pushKronoFocusDurationHistory(prev, preset.workSeconds, DEFAULT_KRONO_FOCUS_WORK_SEC);
476
+ const next = pushKronoFocusDurationHistory(
477
+ prev,
478
+ preset.workSeconds,
479
+ DEFAULT_KRONO_FOCUS_WORK_SEC,
480
+ );
502
481
  persistKronoFocusDurationHistory(next);
503
482
  return next;
504
483
  });
@@ -511,17 +490,22 @@ export function KronoFocusPanel({
511
490
  parsed !== null
512
491
  ? clampWorkDurationSeconds(parsed)
513
492
  : clampWorkDurationSeconds(
514
- typeof kronoFocus?.workDurationSeconds === "number" && Number.isFinite(kronoFocus.workDurationSeconds)
493
+ typeof kronoFocus?.workDurationSeconds === "number" &&
494
+ Number.isFinite(kronoFocus.workDurationSeconds)
515
495
  ? kronoFocus.workDurationSeconds
516
496
  : serverSecs,
517
497
  );
518
498
  const sm = Number.parseInt(draftShortBreakMin.trim(), 10);
519
499
  const lm = Number.parseInt(draftLongBreakMin.trim(), 10);
520
500
  const shortBreakSeconds = clampBreakDurationSeconds(
521
- Number.isFinite(sm) && sm >= 1 ? sm * 60 : (kronoFocus?.shortBreakDurationSeconds ?? 5 * 60),
501
+ Number.isFinite(sm) && sm >= 1
502
+ ? sm * 60
503
+ : kronoFocus?.shortBreakDurationSeconds ?? 5 * 60,
522
504
  );
523
505
  const longBreakSeconds = clampBreakDurationSeconds(
524
- Number.isFinite(lm) && lm >= 1 ? lm * 60 : (kronoFocus?.longBreakDurationSeconds ?? 15 * 60),
506
+ Number.isFinite(lm) && lm >= 1
507
+ ? lm * 60
508
+ : kronoFocus?.longBreakDurationSeconds ?? 15 * 60,
525
509
  );
526
510
  void post({
527
511
  type: "setKronoFocusDurations",
@@ -530,7 +514,11 @@ export function KronoFocusPanel({
530
514
  longBreakSeconds,
531
515
  });
532
516
  setCustomDurationHistory((prev) => {
533
- const next = pushKronoFocusDurationHistory(prev, workSeconds, DEFAULT_KRONO_FOCUS_WORK_SEC);
517
+ const next = pushKronoFocusDurationHistory(
518
+ prev,
519
+ workSeconds,
520
+ DEFAULT_KRONO_FOCUS_WORK_SEC,
521
+ );
534
522
  persistKronoFocusDurationHistory(next);
535
523
  return next;
536
524
  });
@@ -543,7 +531,11 @@ export function KronoFocusPanel({
543
531
  };
544
532
 
545
533
  const modeLabel =
546
- mode === "work" ? t.workMode : mode === "break" ? t.breakMode : t.longBreakMode;
534
+ mode === "work"
535
+ ? t.workMode
536
+ : mode === "break"
537
+ ? t.breakMode
538
+ : t.longBreakMode;
547
539
 
548
540
  const linkedId = kronoFocus?.linkedTaskId;
549
541
  const linkedName = kronoFocus?.linkedTaskName?.trim();
@@ -555,20 +547,23 @@ export function KronoFocusPanel({
555
547
  ? `#kronosys-active-task-${linkedId}`
556
548
  : "#kronosys-task-focus";
557
549
 
558
- const { blink: timerBlink, urgentHighlight: timerUrgentHighlight } = getKronoFocusTimerUrgency({
559
- timeLeftSeconds: displaySecs,
560
- mode,
561
- status,
562
- });
550
+ const { blink: timerBlink, urgentHighlight: timerUrgentHighlight } =
551
+ getKronoFocusTimerUrgency({
552
+ timeLeftSeconds: displaySecs,
553
+ mode,
554
+ status,
555
+ });
563
556
 
564
557
  const clearStartPulse = () => setShowStartPulse(false);
565
558
 
566
559
  const timeSize =
567
- variant === "headerBar" ? "text-lg xl:text-xl" : "text-3xl sm:text-4xl";
568
- /** Libellé de phase : même échelle que le minuteur ; bandeau entête = une ligne, troncature serrée. */
560
+ variant === "headerBar"
561
+ ? "text-sm font-bold sm:text-base"
562
+ : "text-3xl sm:text-4xl";
563
+ /** Libellé de phase : bandeau entête = cellule h10 ; carte = grande échelle. */
569
564
  const phaseLabelClassName =
570
565
  variant === "headerBar"
571
- ? `${timeSize} min-w-0 max-w-[8.5rem] shrink truncate text-center font-bold leading-none tracking-tight text-zinc-800 dark:text-zinc-100 sm:max-w-[11rem] xl:max-w-[13rem]`
566
+ ? "block min-w-0 truncate text-center text-xs font-bold leading-none tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-sm"
572
567
  : `${timeSize} max-w-[min(100%,24rem)] text-center font-bold leading-none tracking-tight text-zinc-800 dark:text-zinc-100 sm:max-w-[30rem]`;
573
568
  /** Largeur stable pour `HH:MM:SS` jusqu’à 8 h (08:00:00). */
574
569
  const timeSlotClass =
@@ -583,39 +578,72 @@ export function KronoFocusPanel({
583
578
  showStartPulse
584
579
  ? "kronosys-krono-focus-start-pulse"
585
580
  : timerBlink
586
- ? "kronosys-krono-focus-time-blink"
587
- : ""
581
+ ? "kronosys-krono-focus-time-blink"
582
+ : ""
588
583
  }`;
589
584
 
590
- const durationPopoverAlign =
591
- "left-1/2 top-full z-50 mt-2 w-[min(100vw-2rem,20rem)] -translate-x-1/2";
592
-
593
585
  if (variant === "headerBar") {
586
+ const headerBarCtrlDisabled =
587
+ "disabled:pointer-events-none disabled:opacity-45 disabled:cursor-not-allowed";
588
+ const headerBarCtrlNeutral = `${appShellToolbarIconLinkClass} ${headerBarCtrlDisabled} [&_svg]:pointer-events-none [&_svg]:block [&_svg]:shrink-0`;
589
+ const headerBarCtrlAccent = `${appShellToolbarIconActiveClass} ${headerBarCtrlDisabled} [&_svg]:pointer-events-none [&_svg]:block [&_svg]:shrink-0`;
590
+
591
+ const ctrlsReadOnly = viewingArchive;
592
+ const ctrlRoTitle = ctrlsReadOnly
593
+ ? t.kronoFocusControlsReadOnlyTooltip
594
+ : undefined;
595
+
594
596
  return (
595
597
  <section
596
- className="flex w-full min-w-0 items-center justify-center rounded-lg border border-violet-200/90 bg-gradient-to-r from-violet-50/95 via-white to-violet-50/80 px-2 py-1 shadow-sm shadow-violet-900/10 sm:px-3 xl:h-10 xl:min-h-0 xl:py-0.5 dark:border-violet-900/40 dark:from-violet-950/35 dark:via-zinc-900/50 dark:to-violet-950/30 dark:shadow-black/15"
598
+ className={`relative ${appShellToolbarRibbonTrayWideClass}`}
597
599
  aria-label={t.kronoFocusTitle}
598
600
  >
599
- <div
600
- ref={popoverRef}
601
- className="relative flex w-full min-w-0 max-w-6xl flex-wrap items-center justify-center gap-x-1.5 gap-y-1 sm:gap-x-2 xl:flex-nowrap xl:gap-y-0"
602
- >
603
- {viewingArchive ? (
604
- <span
605
- className="max-w-[5rem] shrink-0 truncate text-[0.55rem] font-medium leading-none text-violet-900/90 sm:max-w-[7rem] dark:text-violet-200/85"
606
- title={t.kronoFocusLiveWhileViewingArchive}
601
+ <div className="relative flex min-h-0 min-w-0 max-w-full flex-nowrap items-center gap-x-2">
602
+ <div
603
+ className={`${appShellToolbarInsetCellH10Class} max-w-[8.5rem] min-w-0 shrink px-1.5 sm:max-w-[11rem]`}
604
+ >
605
+ <span className={phaseLabelClassName}>{modeLabel}</span>
606
+ </div>
607
+ <div className="flex shrink-0 items-center gap-x-2">
608
+ {status === "running" ? (
609
+ <button
610
+ type="button"
611
+ className={headerBarCtrlNeutral}
612
+ disabled={ctrlsReadOnly}
613
+ title={ctrlsReadOnly ? ctrlRoTitle : t.kronoFocusPause}
614
+ aria-label={t.kronoFocusPause}
615
+ onClick={() => void post({ type: "pauseKronoFocus" })}
616
+ >
617
+ <Pause strokeWidth={2} aria-hidden />
618
+ </button>
619
+ ) : (
620
+ <button
621
+ type="button"
622
+ className={headerBarCtrlAccent}
623
+ disabled={ctrlsReadOnly}
624
+ title={ctrlsReadOnly ? ctrlRoTitle : t.kronoFocusStart}
625
+ aria-label={t.kronoFocusStart}
626
+ onClick={() => void post({ type: "startKronoFocus" })}
627
+ >
628
+ <Play strokeWidth={2} aria-hidden />
629
+ </button>
630
+ )}
631
+ <button
632
+ type="button"
633
+ className={headerBarCtrlNeutral}
634
+ disabled={ctrlsReadOnly}
635
+ title={ctrlsReadOnly ? ctrlRoTitle : t.kronoFocusReset}
636
+ aria-label={t.kronoFocusReset}
637
+ onClick={() => void post({ type: "resetKronoFocus" })}
607
638
  >
608
- {t.kronoFocusLiveWhileViewingArchive}
609
- </span>
610
- ) : null}
611
- <div className="shrink-0">
612
- <KronoFocusPanelHelpTrigger t={t} />
639
+ <RotateCcw strokeWidth={2} aria-hidden />
640
+ </button>
613
641
  </div>
614
- <div className={phaseLabelClassName}>{modeLabel}</div>
615
642
  {canEditWorkDuration ? (
616
643
  <button
644
+ ref={timeButtonRef}
617
645
  type="button"
618
- className={`${timeSlotClass} rounded-md px-0.5 py-0 leading-none ${timeClassName} cursor-pointer outline-none transition-colors hover:bg-zinc-200/60 dark:hover:bg-zinc-800/50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-violet-500/55`}
646
+ className={`${timeSlotClass} ${appShellToolbarInsetCellH10ButtonClass} min-w-[9ch] px-1 font-mono tabular-nums ${timeClassName}`}
619
647
  title={t.kronoFocusEditDurationTitle}
620
648
  aria-label={t.kronoFocusEditDurationTitle}
621
649
  aria-expanded={durationPopoverOpen ? "true" : "false"}
@@ -625,51 +653,22 @@ export function KronoFocusPanel({
625
653
  {clockDisplay}
626
654
  </button>
627
655
  ) : (
628
- <div className={`${timeSlotClass} shrink-0 leading-none ${timeClassName}`} onAnimationEnd={clearStartPulse}>
656
+ <div
657
+ className={`${timeSlotClass} ${appShellToolbarInsetCellH10Class} min-w-[9ch] px-1 font-mono tabular-nums leading-none ${timeClassName}`}
658
+ onAnimationEnd={clearStartPulse}
659
+ >
629
660
  {clockDisplay}
630
661
  </div>
631
662
  )}
632
- {!viewingArchive ? (
633
- <div className="flex shrink-0 items-center gap-1.5">
634
- {status === "running" ? (
635
- <button
636
- type="button"
637
- className={kronoFocusControlNeutralHeader}
638
- title={t.kronoFocusPause}
639
- aria-label={t.kronoFocusPause}
640
- onClick={() => void post({ type: "pauseKronoFocus" })}
641
- >
642
- <Pause strokeWidth={2} aria-hidden />
643
- </button>
644
- ) : (
645
- <button
646
- type="button"
647
- className={kronoFocusControlAccentHeader}
648
- title={t.kronoFocusStart}
649
- aria-label={t.kronoFocusStart}
650
- onClick={() => void post({ type: "startKronoFocus" })}
651
- >
652
- <Play strokeWidth={2} aria-hidden />
653
- </button>
654
- )}
655
- <button
656
- type="button"
657
- className={kronoFocusControlNeutralHeader}
658
- title={t.kronoFocusReset}
659
- aria-label={t.kronoFocusReset}
660
- onClick={() => void post({ type: "resetKronoFocus" })}
661
- >
662
- <RotateCcw strokeWidth={2} aria-hidden />
663
- </button>
664
- </div>
665
- ) : null}
666
663
  {showTaskLink ? (
667
- <div className="hidden min-w-0 max-w-[min(38vw,12rem)] shrink border-l border-violet-200/70 pl-2 sm:block dark:border-violet-800/50">
664
+ <div className="hidden h-10 min-w-0 max-w-[min(38vw,12rem)] shrink items-center border-l border-zinc-300/70 pl-2 sm:flex dark:border-zinc-600/70">
668
665
  <p
669
666
  className="min-w-0 truncate text-left text-[0.65rem] leading-none text-zinc-600 dark:text-zinc-400"
670
667
  title={`${t.kronoFocusLinkedTaskIntro} ${linkedName || "—"}`}
671
668
  >
672
- <span className="text-zinc-500">{t.kronoFocusLinkedTaskIntro} </span>
669
+ <span className="text-zinc-500">
670
+ {t.kronoFocusLinkedTaskIntro}{" "}
671
+ </span>
673
672
  <a
674
673
  href={taskHref}
675
674
  className="font-medium text-violet-800 underline decoration-violet-500/45 underline-offset-2 hover:text-violet-700 dark:text-violet-400/95 dark:decoration-violet-500/50 dark:hover:text-violet-300"
@@ -680,34 +679,42 @@ export function KronoFocusPanel({
680
679
  </div>
681
680
  ) : null}
682
681
 
683
- {durationPopoverOpen && canEditWorkDuration && (
684
- <div
685
- className={`absolute ${durationPopoverAlign} rounded-lg border border-zinc-200 bg-white p-3 text-left shadow-xl dark:border-zinc-600 dark:bg-zinc-900`}
686
- role="dialog"
687
- aria-label={t.kronoFocusDurationPickerLabel}
688
- >
689
- <KronoFocusDurationPopoverFields
690
- inputId="kronosys-krono-focus-duration-hb"
691
- t={t}
692
- draftTime={draftTime}
693
- setDraftTime={setDraftTime}
694
- draftShortBreakMin={draftShortBreakMin}
695
- setDraftShortBreakMin={setDraftShortBreakMin}
696
- draftLongBreakMin={draftLongBreakMin}
697
- setDraftLongBreakMin={setDraftLongBreakMin}
698
- onPickPreset={applyRhythmPreset}
699
- customHistory={customDurationHistory}
700
- onPickDefault={() => {
701
- setDraftTime(formatSecondsAsHMS(DEFAULT_KRONO_FOCUS_WORK_SEC));
702
- setDraftShortBreakMin("5");
703
- setDraftLongBreakMin("15");
704
- }}
705
- onApply={applyDraftDuration}
706
- onCancel={() => setDurationPopoverOpen(false)}
707
- onClearHistory={clearDurationHistory}
708
- />
709
- </div>
710
- )}
682
+ {durationPopoverOpen &&
683
+ canEditWorkDuration &&
684
+ typeof document !== "undefined" &&
685
+ createPortal(
686
+ <div
687
+ ref={durationPopoverPanelRef}
688
+ style={durationPopoverStyle}
689
+ className="rounded-lg border border-zinc-200 bg-white p-3 text-left shadow-xl dark:border-zinc-600 dark:bg-zinc-900"
690
+ role="dialog"
691
+ aria-label={t.kronoFocusDurationPickerLabel}
692
+ >
693
+ <KronoFocusDurationPopoverFields
694
+ inputId="kronosys-krono-focus-duration-hb"
695
+ t={t}
696
+ draftTime={draftTime}
697
+ setDraftTime={setDraftTime}
698
+ draftShortBreakMin={draftShortBreakMin}
699
+ setDraftShortBreakMin={setDraftShortBreakMin}
700
+ draftLongBreakMin={draftLongBreakMin}
701
+ setDraftLongBreakMin={setDraftLongBreakMin}
702
+ onPickPreset={applyRhythmPreset}
703
+ customHistory={customDurationHistory}
704
+ onPickDefault={() => {
705
+ setDraftTime(
706
+ formatSecondsAsHMS(DEFAULT_KRONO_FOCUS_WORK_SEC),
707
+ );
708
+ setDraftShortBreakMin("5");
709
+ setDraftLongBreakMin("15");
710
+ }}
711
+ onApply={applyDraftDuration}
712
+ onCancel={() => setDurationPopoverOpen(false)}
713
+ onClearHistory={clearDurationHistory}
714
+ />
715
+ </div>,
716
+ document.body,
717
+ )}
711
718
  </div>
712
719
  </section>
713
720
  );
@@ -719,58 +726,65 @@ export function KronoFocusPanel({
719
726
  aria-label={t.kronoFocusTitle}
720
727
  >
721
728
  <div className="mx-auto flex w-full max-w-5xl flex-col items-center gap-4">
722
- <div className="flex w-full flex-wrap items-center justify-center gap-3 sm:justify-between">
723
- <div className="flex min-w-0 flex-wrap items-center justify-center gap-2">
724
- {viewingArchive ? (
725
- <p className="max-w-[min(100%,22rem)] text-center text-[0.7rem] leading-snug text-violet-900/85 dark:text-violet-200/75">
726
- {t.kronoFocusLiveWhileViewingArchive}
727
- </p>
728
- ) : null}
729
- <KronoFocusPanelHelpTrigger t={t} />
730
- </div>
731
- {!viewingArchive ? (
732
- <div className="flex shrink-0 items-center gap-2">
733
- {status === "running" ? (
734
- <button
735
- type="button"
736
- className={kronoFocusControlNeutralCard}
737
- title={t.kronoFocusPause}
738
- aria-label={t.kronoFocusPause}
739
- onClick={() => void post({ type: "pauseKronoFocus" })}
740
- >
741
- <Pause strokeWidth={2} aria-hidden />
742
- </button>
743
- ) : (
744
- <button
745
- type="button"
746
- className={kronoFocusControlAccentCard}
747
- title={t.kronoFocusStart}
748
- aria-label={t.kronoFocusStart}
749
- onClick={() => void post({ type: "startKronoFocus" })}
750
- >
751
- <Play strokeWidth={2} aria-hidden />
752
- </button>
753
- )}
729
+ <div className="flex w-full flex-wrap items-center justify-center gap-3 sm:justify-end">
730
+ <div className="flex shrink-0 items-center gap-2">
731
+ {status === "running" ? (
754
732
  <button
755
733
  type="button"
756
734
  className={kronoFocusControlNeutralCard}
757
- title={t.kronoFocusReset}
758
- aria-label={t.kronoFocusReset}
759
- onClick={() => void post({ type: "resetKronoFocus" })}
735
+ disabled={viewingArchive}
736
+ title={
737
+ viewingArchive
738
+ ? t.kronoFocusControlsReadOnlyTooltip
739
+ : t.kronoFocusPause
740
+ }
741
+ aria-label={t.kronoFocusPause}
742
+ onClick={() => void post({ type: "pauseKronoFocus" })}
760
743
  >
761
- <RotateCcw strokeWidth={2} aria-hidden />
744
+ <Pause strokeWidth={2} aria-hidden />
762
745
  </button>
763
- </div>
764
- ) : null}
746
+ ) : (
747
+ <button
748
+ type="button"
749
+ className={kronoFocusControlAccentCard}
750
+ disabled={viewingArchive}
751
+ title={
752
+ viewingArchive
753
+ ? t.kronoFocusControlsReadOnlyTooltip
754
+ : t.kronoFocusStart
755
+ }
756
+ aria-label={t.kronoFocusStart}
757
+ onClick={() => void post({ type: "startKronoFocus" })}
758
+ >
759
+ <Play strokeWidth={2} aria-hidden />
760
+ </button>
761
+ )}
762
+ <button
763
+ type="button"
764
+ className={kronoFocusControlNeutralCard}
765
+ disabled={viewingArchive}
766
+ title={
767
+ viewingArchive
768
+ ? t.kronoFocusControlsReadOnlyTooltip
769
+ : t.kronoFocusReset
770
+ }
771
+ aria-label={t.kronoFocusReset}
772
+ onClick={() => void post({ type: "resetKronoFocus" })}
773
+ >
774
+ <RotateCcw strokeWidth={2} aria-hidden />
775
+ </button>
776
+ </div>
765
777
  </div>
766
778
 
767
- <div
768
- className="relative flex w-full min-w-0 flex-wrap items-center justify-center gap-x-5 gap-y-3"
769
- ref={popoverRef}
770
- >
771
- <div className={`${phaseLabelClassName} text-violet-950 dark:text-violet-100/95`}>{modeLabel}</div>
779
+ <div className="relative flex w-full min-w-0 flex-wrap items-center justify-center gap-x-5 gap-y-3">
780
+ <div
781
+ className={`${phaseLabelClassName} text-violet-950 dark:text-violet-100/95`}
782
+ >
783
+ {modeLabel}
784
+ </div>
772
785
  {canEditWorkDuration ? (
773
786
  <button
787
+ ref={timeButtonRef}
774
788
  type="button"
775
789
  className={`${timeSlotClass} rounded-md px-1 py-0.5 ${timeClassName} cursor-pointer outline-none transition-colors hover:bg-zinc-200/60 dark:hover:bg-zinc-800/50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-violet-500/55`}
776
790
  title={t.kronoFocusEditDurationTitle}
@@ -782,44 +796,57 @@ export function KronoFocusPanel({
782
796
  {clockDisplay}
783
797
  </button>
784
798
  ) : (
785
- <div className={`${timeSlotClass} ${timeClassName}`} onAnimationEnd={clearStartPulse}>
786
- {clockDisplay}
787
- </div>
788
- )}
789
-
790
- {durationPopoverOpen && canEditWorkDuration && (
791
799
  <div
792
- className={`absolute ${durationPopoverAlign} rounded-lg border border-zinc-200 bg-white p-3 text-left shadow-xl dark:border-zinc-600 dark:bg-zinc-900`}
793
- role="dialog"
794
- aria-label={t.kronoFocusDurationPickerLabel}
800
+ className={`${timeSlotClass} ${timeClassName}`}
801
+ onAnimationEnd={clearStartPulse}
795
802
  >
796
- <KronoFocusDurationPopoverFields
797
- inputId="kronosys-krono-focus-duration"
798
- t={t}
799
- draftTime={draftTime}
800
- setDraftTime={setDraftTime}
801
- draftShortBreakMin={draftShortBreakMin}
802
- setDraftShortBreakMin={setDraftShortBreakMin}
803
- draftLongBreakMin={draftLongBreakMin}
804
- setDraftLongBreakMin={setDraftLongBreakMin}
805
- onPickPreset={applyRhythmPreset}
806
- customHistory={customDurationHistory}
807
- onPickDefault={() => {
808
- setDraftTime(formatSecondsAsHMS(DEFAULT_KRONO_FOCUS_WORK_SEC));
809
- setDraftShortBreakMin("5");
810
- setDraftLongBreakMin("15");
811
- }}
812
- onApply={applyDraftDuration}
813
- onCancel={() => setDurationPopoverOpen(false)}
814
- onClearHistory={clearDurationHistory}
815
- />
803
+ {clockDisplay}
816
804
  </div>
817
805
  )}
806
+
807
+ {durationPopoverOpen &&
808
+ canEditWorkDuration &&
809
+ typeof document !== "undefined" &&
810
+ createPortal(
811
+ <div
812
+ ref={durationPopoverPanelRef}
813
+ style={durationPopoverStyle}
814
+ className="rounded-lg border border-zinc-200 bg-white p-3 text-left shadow-xl dark:border-zinc-600 dark:bg-zinc-900"
815
+ role="dialog"
816
+ aria-label={t.kronoFocusDurationPickerLabel}
817
+ >
818
+ <KronoFocusDurationPopoverFields
819
+ inputId="kronosys-krono-focus-duration"
820
+ t={t}
821
+ draftTime={draftTime}
822
+ setDraftTime={setDraftTime}
823
+ draftShortBreakMin={draftShortBreakMin}
824
+ setDraftShortBreakMin={setDraftShortBreakMin}
825
+ draftLongBreakMin={draftLongBreakMin}
826
+ setDraftLongBreakMin={setDraftLongBreakMin}
827
+ onPickPreset={applyRhythmPreset}
828
+ customHistory={customDurationHistory}
829
+ onPickDefault={() => {
830
+ setDraftTime(
831
+ formatSecondsAsHMS(DEFAULT_KRONO_FOCUS_WORK_SEC),
832
+ );
833
+ setDraftShortBreakMin("5");
834
+ setDraftLongBreakMin("15");
835
+ }}
836
+ onApply={applyDraftDuration}
837
+ onCancel={() => setDurationPopoverOpen(false)}
838
+ onClearHistory={clearDurationHistory}
839
+ />
840
+ </div>,
841
+ document.body,
842
+ )}
818
843
  </div>
819
844
 
820
845
  {showTaskLink ? (
821
846
  <p className="w-full max-w-3xl text-center text-[0.8rem] leading-snug text-zinc-600 dark:text-zinc-400">
822
- <span className="text-zinc-500">{t.kronoFocusLinkedTaskIntro} </span>
847
+ <span className="text-zinc-500">
848
+ {t.kronoFocusLinkedTaskIntro}{" "}
849
+ </span>
823
850
  <a
824
851
  href={taskHref}
825
852
  className="font-medium text-violet-800 underline decoration-violet-500/45 underline-offset-2 hover:text-violet-700 dark:text-violet-400/95 dark:decoration-violet-500/50 dark:hover:text-violet-300"