@nightkatana/kronosys-app 1.0.0-beta.13 → 1.0.0-beta.14

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 (55) hide show
  1. package/app/globals.css +98 -0
  2. package/app/guide/page.tsx +59 -15
  3. package/app/implementation/page.tsx +317 -0
  4. package/app/layout.tsx +11 -3
  5. package/app/licenses/page.tsx +97 -37
  6. package/app/page.tsx +578 -192
  7. package/app/reporting/page.tsx +1073 -203
  8. package/app/settings/page.tsx +101 -16
  9. package/components/KronosysPayloadProvider.tsx +2 -0
  10. package/components/RouteTransition.tsx +18 -0
  11. package/components/dashboard/AppShellCommandCenterPlaceholder.tsx +17 -0
  12. package/components/dashboard/AppShellHeaderSessionMeta.tsx +210 -0
  13. package/components/dashboard/AppShellRouteNav.tsx +237 -48
  14. package/components/dashboard/DashboardSimpleModal.tsx +168 -25
  15. package/components/dashboard/GlobalPauseConfirmModal.tsx +183 -0
  16. package/components/dashboard/KronosysDatetimePopoverField.tsx +128 -119
  17. package/components/dashboard/NewSessionScopeModal.tsx +13 -0
  18. package/components/dashboard/SelectedSessionSidebarBlock.tsx +333 -116
  19. package/components/dashboard/SessionListPanel.tsx +279 -43
  20. package/components/dashboard/SettingsTagsProjectsSection.tsx +655 -239
  21. package/components/dashboard/SettingsTaskTemplatesSection.tsx +314 -0
  22. package/components/dashboard/TaskFocusPanel.tsx +602 -373
  23. package/components/dashboard/TaskSessionLiveCard.tsx +341 -115
  24. package/components/dashboard/TaskTimelineGanttModal.tsx +557 -0
  25. package/components/dashboard/taskFieldStyles.ts +3 -2
  26. package/lib/appShellHeaderClasses.ts +13 -0
  27. package/lib/dashboardCopy.ts +326 -81
  28. package/lib/dashboardQuickSearch.ts +54 -2
  29. package/lib/formatSessionNameTemplate.test.ts +53 -0
  30. package/lib/formatSessionNameTemplate.ts +141 -0
  31. package/lib/generatedUserChangelog.ts +9 -0
  32. package/lib/globalPausePreview.test.ts +170 -0
  33. package/lib/globalPausePreview.ts +292 -0
  34. package/lib/implementationNotes.ts +647 -0
  35. package/lib/reportingAggregate.test.ts +31 -4
  36. package/lib/reportingAggregate.ts +72 -0
  37. package/lib/reportingMetricHelp.ts +8 -0
  38. package/lib/reportingStrings.ts +28 -3
  39. package/lib/reportingTagWeekBreakdown.test.ts +4 -3
  40. package/lib/sessionTaskSidebarStats.test.ts +27 -1
  41. package/lib/sessionTaskSidebarStats.ts +124 -9
  42. package/lib/settingsCopy.ts +105 -0
  43. package/lib/taskTemplateDraft.test.ts +52 -0
  44. package/lib/taskTemplateDraft.ts +121 -0
  45. package/lib/taskTimelineGantt.test.ts +50 -0
  46. package/lib/taskTimelineGantt.ts +165 -0
  47. package/lib/userGuideCopy.ts +44 -28
  48. package/next-env.d.ts +1 -1
  49. package/package.json +2 -2
  50. package/server/actionDispatch.test.ts +184 -0
  51. package/server/actionDispatch.ts +453 -6
  52. package/server/actionTaskSession.ts +58 -11
  53. package/server/defaultCfg.ts +5 -0
  54. package/server/sessionWallHydrate.ts +13 -0
  55. package/components/dashboard/IssuePickerModal.tsx +0 -168
package/app/globals.css CHANGED
@@ -48,6 +48,22 @@ body {
48
48
  color: var(--foreground);
49
49
  }
50
50
 
51
+ @keyframes kronosys-route-transition-in-kf {
52
+ from {
53
+ opacity: 0;
54
+ transform: translateY(6px);
55
+ }
56
+ to {
57
+ opacity: 1;
58
+ transform: translateY(0);
59
+ }
60
+ }
61
+
62
+ .kronosys-route-transition-in {
63
+ animation: kronosys-route-transition-in-kf 170ms ease-out both;
64
+ will-change: opacity, transform;
65
+ }
66
+
51
67
  /* Minuteur focus : clignotement sous 5 min (travail / longue pause), accent violet sous 30 s */
52
68
  @keyframes kronosys-krono-focus-blink {
53
69
  0%,
@@ -187,11 +203,77 @@ html.dark .kronosys-datetime-popover select option {
187
203
  transform-origin: center center;
188
204
  }
189
205
 
206
+ /* Tâche en pause : halo ambre léger (minuteur arrêté) */
207
+ @keyframes kronosys-task-paused-ring-kf {
208
+ 0%,
209
+ 100% {
210
+ box-shadow:
211
+ 0 0 0 1px rgb(234 179 8 / 0.28),
212
+ 0 0 12px -4px rgb(234 179 8 / 0.12);
213
+ }
214
+ 50% {
215
+ box-shadow:
216
+ 0 0 0 3px rgb(234 179 8 / 0.42),
217
+ 0 0 18px -2px rgb(234 179 8 / 0.22);
218
+ }
219
+ }
220
+
221
+ .kronosys-task-paused-blink {
222
+ animation: kronosys-task-paused-ring-kf 1.75s ease-in-out infinite;
223
+ }
224
+
225
+ /* Tâche lancée : pulse léger tant que le minuteur roule */
226
+ @keyframes kronosys-task-running-pulse-kf {
227
+ 0%,
228
+ 100% {
229
+ box-shadow:
230
+ 0 0 0 1px rgb(16 185 129 / 0.22),
231
+ 0 0 10px -5px rgb(16 185 129 / 0.1);
232
+ }
233
+ 50% {
234
+ box-shadow:
235
+ 0 0 0 2px rgb(16 185 129 / 0.36),
236
+ 0 0 18px -4px rgb(16 185 129 / 0.18);
237
+ }
238
+ }
239
+
240
+ .kronosys-task-running-pulse {
241
+ animation: kronosys-task-running-pulse-kf 2.2s ease-in-out infinite;
242
+ }
243
+
244
+ /* Tâche terminée : pulse unique vert (succès) */
245
+ @keyframes kronosys-task-finish-celebrate-kf {
246
+ 0% {
247
+ box-shadow: 0 0 0 0 rgb(16 185 129 / 0);
248
+ transform: scale(1);
249
+ }
250
+ 45% {
251
+ box-shadow:
252
+ 0 0 0 4px rgb(16 185 129 / 0.35),
253
+ 0 0 20px -4px rgb(16 185 129 / 0.28);
254
+ transform: scale(1.012);
255
+ }
256
+ 100% {
257
+ box-shadow: 0 0 0 0 rgb(16 185 129 / 0);
258
+ transform: scale(1);
259
+ }
260
+ }
261
+
262
+ .kronosys-task-finish-celebrate {
263
+ animation: kronosys-task-finish-celebrate-kf 0.7s ease-out 1 both;
264
+ transform-origin: center center;
265
+ }
266
+
190
267
  @media (prefers-reduced-motion: reduce) {
191
268
  html {
192
269
  scroll-behavior: auto !important;
193
270
  }
194
271
 
272
+ .kronosys-route-transition-in {
273
+ animation: none;
274
+ transform: none;
275
+ }
276
+
195
277
  .kronosys-krono-focus-time-blink {
196
278
  animation: none;
197
279
  opacity: 1;
@@ -207,4 +289,20 @@ html.dark .kronosys-datetime-popover select option {
207
289
  animation: none;
208
290
  opacity: 1;
209
291
  }
292
+
293
+ .kronosys-task-paused-blink {
294
+ animation: none;
295
+ box-shadow: none;
296
+ }
297
+
298
+ .kronosys-task-running-pulse {
299
+ animation: none;
300
+ box-shadow: none;
301
+ }
302
+
303
+ .kronosys-task-finish-celebrate {
304
+ animation: none;
305
+ transform: none;
306
+ box-shadow: none;
307
+ }
210
308
  }
@@ -7,7 +7,13 @@ import { BookOpen, Search, X } from "lucide-react";
7
7
  import { useKronosysPayload } from "@/components/KronosysPayloadProvider";
8
8
  import { postKronosysAction } from "@/lib/kronosysApi";
9
9
  import { AppVersionStamp } from "@/components/dashboard/AppVersionStamp";
10
- import { appShellHeaderClassName, appShellHeaderToolRowClassName } from "@/lib/appShellHeaderClasses";
10
+ import {
11
+ appShellHeaderClassName,
12
+ appShellHeaderTitleMetaRowClassName,
13
+ appShellHeaderToolbarClassName,
14
+ } from "@/lib/appShellHeaderClasses";
15
+ import { AppShellCommandCenterPlaceholder } from "@/components/dashboard/AppShellCommandCenterPlaceholder";
16
+ import { AppShellHeaderSessionMeta } from "@/components/dashboard/AppShellHeaderSessionMeta";
11
17
  import { AppShellRouteNav } from "@/components/dashboard/AppShellRouteNav";
12
18
  import { UserGuideBodyText } from "@/components/dashboard/UserGuideBodyText";
13
19
  import { ThemeToggle } from "@/components/dashboard/ThemeToggle";
@@ -21,7 +27,10 @@ import { sectionSearchHaystack, userGuideBundle } from "@/lib/userGuideCopy";
21
27
 
22
28
  type LiveShape = { language?: string };
23
29
 
24
- function filterSections(query: string, sections: ReturnType<typeof userGuideBundle>["sections"]): number[] {
30
+ function filterSections(
31
+ query: string,
32
+ sections: ReturnType<typeof userGuideBundle>["sections"],
33
+ ): number[] {
25
34
  const q = query.trim().toLowerCase();
26
35
  if (q.length === 0) {
27
36
  return sections.map((_, i) => i);
@@ -44,14 +53,21 @@ function GuideContent() {
44
53
  const nav = useMemo(() => reportingNav(lang), [lang]);
45
54
  const dt = dashboardStrings(lang);
46
55
  const bundle = useMemo(() => userGuideBundle(lang), [lang]);
47
- const visible = useMemo(() => filterSections(q, bundle.sections), [q, bundle.sections]);
56
+ const visible = useMemo(
57
+ () => filterSections(q, bundle.sections),
58
+ [q, bundle.sections],
59
+ );
48
60
 
49
61
  const postLang = async (next: Lang) => {
50
62
  await postKronosysAction({ type: "setLanguage", lang: next });
51
63
  await refresh();
52
64
  };
53
65
 
54
- const bulletList = (lines: string[], idPrefix: string, listType: "ul" | "ol" = "ul"): ReactNode => {
66
+ const bulletList = (
67
+ lines: string[],
68
+ idPrefix: string,
69
+ listType: "ul" | "ol" = "ul",
70
+ ): ReactNode => {
55
71
  const listClass =
56
72
  listType === "ol"
57
73
  ? "mt-2 list-inside list-decimal space-y-1.5 pl-0.5 text-sm leading-relaxed text-zinc-600 dark:text-zinc-300"
@@ -71,7 +87,7 @@ function GuideContent() {
71
87
  return (
72
88
  <div className="min-h-screen bg-zinc-100 text-zinc-900 dark:bg-zinc-900 dark:text-zinc-100">
73
89
  <header className={appShellHeaderClassName}>
74
- <div className={appShellHeaderToolRowClassName}>
90
+ <div className={appShellHeaderTitleMetaRowClassName}>
75
91
  <div className="flex min-w-0 flex-col gap-1">
76
92
  <div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
77
93
  <Link
@@ -83,8 +99,13 @@ function GuideContent() {
83
99
  <span className="text-zinc-400 dark:text-zinc-600" aria-hidden>
84
100
  /
85
101
  </span>
86
- <span className="text-lg font-medium text-zinc-700 dark:text-zinc-300">{bundle.pageTitle}</span>
87
- <span className="inline-flex items-center text-violet-500 dark:text-violet-400" aria-hidden>
102
+ <span className="text-lg font-medium text-zinc-700 dark:text-zinc-300">
103
+ {bundle.pageTitle}
104
+ </span>
105
+ <span
106
+ className="inline-flex items-center text-violet-500 dark:text-violet-400"
107
+ aria-hidden
108
+ >
88
109
  <BookOpen className="size-5" strokeWidth={2} />
89
110
  </span>
90
111
  </div>
@@ -96,12 +117,17 @@ function GuideContent() {
96
117
  <AppVersionStamp ariaLabelTemplate={dt.appVersionAriaLabel} />
97
118
  </p>
98
119
  </div>
99
- <div className="flex flex-wrap items-center gap-1.5">
120
+ <AppShellHeaderSessionMeta payload={payload} dt={dt} />
121
+ </div>
122
+ <div className="flex w-full justify-end">
123
+ <div className={appShellHeaderToolbarClassName}>
124
+ <AppShellCommandCenterPlaceholder />
100
125
  <AppShellRouteNav
101
126
  current="guide"
102
127
  labels={nav}
103
128
  navAriaLabel={dt.appShellRouteNavAria}
104
129
  dashboardSessionId={dashboardSessionNavId}
130
+ reserveGlobalPauseSlot
105
131
  />
106
132
  <ThemeToggle lang={lang} />
107
133
  <PageRefreshButton
@@ -121,7 +147,9 @@ function GuideContent() {
121
147
  labelEn="English"
122
148
  labelFr="Français"
123
149
  menuHeading={lang === "fr" ? "Langue" : "Language"}
124
- triggerAriaLabel={lang === "fr" ? "Langue de l’interface" : "Interface language"}
150
+ triggerAriaLabel={
151
+ lang === "fr" ? "Langue de l’interface" : "Interface language"
152
+ }
125
153
  onSelect={(next) => void postLang(next)}
126
154
  />
127
155
  </div>
@@ -133,9 +161,14 @@ function GuideContent() {
133
161
  data-user-guide-scrolled=""
134
162
  >
135
163
  <p className="max-w-3xl text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
136
- <UserGuideBodyText line={bundle.pageSubtitle} sessionId={dashboardSessionNavId} />
164
+ <UserGuideBodyText
165
+ line={bundle.pageSubtitle}
166
+ sessionId={dashboardSessionNavId}
167
+ />
168
+ </p>
169
+ <p className="mt-2 text-xs text-zinc-500 dark:text-zinc-500">
170
+ {bundle.lastUpdated}
137
171
  </p>
138
- <p className="mt-2 text-xs text-zinc-500 dark:text-zinc-500">{bundle.lastUpdated}</p>
139
172
 
140
173
  <form
141
174
  onSubmit={(e) => e.preventDefault()}
@@ -179,7 +212,10 @@ function GuideContent() {
179
212
  </form>
180
213
 
181
214
  {visible.length === 0 && q.trim() ? (
182
- <output className="mt-8 block text-sm text-zinc-500 dark:text-zinc-400" aria-live="polite">
215
+ <output
216
+ className="mt-8 block text-sm text-zinc-500 dark:text-zinc-400"
217
+ aria-live="polite"
218
+ >
183
219
  {bundle.searchNoResults}
184
220
  </output>
185
221
  ) : null}
@@ -230,8 +266,14 @@ function GuideContent() {
230
266
  </h2>
231
267
  <div className="mt-3 space-y-3 text-sm leading-relaxed text-zinc-600 dark:text-zinc-300">
232
268
  {s.paragraphs.map((p, pIdx) => (
233
- <p key={`${s.id}-p${pIdx}`} className="[&:not(:first-of-type)]:pt-0.5">
234
- <UserGuideBodyText line={p} sessionId={dashboardSessionNavId} />
269
+ <p
270
+ key={`${s.id}-p${pIdx}`}
271
+ className="[&:not(:first-of-type)]:pt-0.5"
272
+ >
273
+ <UserGuideBodyText
274
+ line={p}
275
+ sessionId={dashboardSessionNavId}
276
+ />
235
277
  </p>
236
278
  ))}
237
279
  {s.steps && s.steps.length > 0 ? (
@@ -251,7 +293,9 @@ function GuideContent() {
251
293
  </div>
252
294
  ) : null}
253
295
  </div>
254
- {s.bullets && s.bullets.length > 0 ? bulletList(s.bullets, s.id) : null}
296
+ {s.bullets && s.bullets.length > 0
297
+ ? bulletList(s.bullets, s.id)
298
+ : null}
255
299
  </article>
256
300
  );
257
301
  })}
@@ -0,0 +1,317 @@
1
+ "use client";
2
+
3
+ import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
4
+ import Link from "next/link";
5
+ import { useSearchParams } from "next/navigation";
6
+ import { Check, FileCode2, X } from "lucide-react";
7
+ import { AppVersionStamp } from "@/components/dashboard/AppVersionStamp";
8
+ import { ThemeToggle } from "@/components/dashboard/ThemeToggle";
9
+ import { PageRefreshButton } from "@/components/dashboard/PageRefreshButton";
10
+ import { ScrollToTopFab } from "@/components/dashboard/ScrollToTopFab";
11
+ import { AppShellRouteNav } from "@/components/dashboard/AppShellRouteNav";
12
+ import { useKronosysPayload } from "@/components/KronosysPayloadProvider";
13
+ import {
14
+ appShellHeaderClassName,
15
+ appShellHeaderTitleMetaRowClassName,
16
+ appShellHeaderToolbarClassName,
17
+ } from "@/lib/appShellHeaderClasses";
18
+ import { AppShellCommandCenterPlaceholder } from "@/components/dashboard/AppShellCommandCenterPlaceholder";
19
+ import { AppShellHeaderSessionMeta } from "@/components/dashboard/AppShellHeaderSessionMeta";
20
+ import { dashboardStrings, type Lang } from "@/lib/dashboardCopy";
21
+ import { withDashboardSessionParam } from "@/lib/dashboardSessionNav";
22
+ import { implementationNotesBundle } from "@/lib/implementationNotes";
23
+ import { reportingNav } from "@/lib/reportingStrings";
24
+ import { postKronosysAction } from "@/lib/kronosysApi";
25
+ import { LanguageMenu } from "@/components/dashboard/LanguageMenu";
26
+
27
+ type LiveShape = { language?: string };
28
+
29
+ const IMPLEMENTATION_REVIEW_STORAGE_KEY = "kronosys.implementation.review.v2";
30
+
31
+ function userStoryScreenReaderLabel(lang: Lang, implemented: boolean): string {
32
+ if (lang === "fr") {
33
+ return implemented ? "Capacité livrée." : "Absent ou hors périmètre.";
34
+ }
35
+ return implemented ? "Capability shipped." : "Missing or out of scope.";
36
+ }
37
+
38
+ function useImplementationReviewState() {
39
+ const [reviewed, setReviewed] = useState<Record<string, boolean>>({});
40
+
41
+ useEffect(() => {
42
+ try {
43
+ const raw = localStorage.getItem(IMPLEMENTATION_REVIEW_STORAGE_KEY);
44
+ if (raw) {
45
+ const parsed = JSON.parse(raw) as Record<string, boolean>;
46
+ if (parsed && typeof parsed === "object") {
47
+ setReviewed(parsed);
48
+ }
49
+ }
50
+ } catch {
51
+ /* ignore */
52
+ }
53
+ }, []);
54
+
55
+ const setReviewedKey = useCallback((key: string, value: boolean) => {
56
+ setReviewed((prev) => {
57
+ const next = { ...prev, [key]: value };
58
+ try {
59
+ localStorage.setItem(
60
+ IMPLEMENTATION_REVIEW_STORAGE_KEY,
61
+ JSON.stringify(next),
62
+ );
63
+ } catch {
64
+ /* ignore */
65
+ }
66
+ return next;
67
+ });
68
+ }, []);
69
+
70
+ const resetReviewed = useCallback(() => {
71
+ setReviewed({});
72
+ try {
73
+ localStorage.removeItem(IMPLEMENTATION_REVIEW_STORAGE_KEY);
74
+ } catch {
75
+ /* ignore */
76
+ }
77
+ }, []);
78
+
79
+ return { reviewed, setReviewedKey, resetReviewed };
80
+ }
81
+
82
+ function ImplementationGlyph(props: Readonly<{ implemented: boolean }>) {
83
+ const { implemented } = props;
84
+ if (implemented) {
85
+ return (
86
+ <span
87
+ className="inline-flex text-emerald-600 dark:text-emerald-500"
88
+ aria-hidden
89
+ >
90
+ <Check className="size-4 shrink-0" strokeWidth={2.5} />
91
+ </span>
92
+ );
93
+ }
94
+ return (
95
+ <span className="inline-flex text-red-600 dark:text-red-500" aria-hidden>
96
+ <X className="size-4 shrink-0" strokeWidth={2.5} />
97
+ </span>
98
+ );
99
+ }
100
+
101
+ function ImplementationBody() {
102
+ const searchParams = useSearchParams();
103
+ const dashboardSessionNavId = searchParams.get("session");
104
+ const { payload, refresh } = useKronosysPayload();
105
+ const live = payload?.current as LiveShape | undefined;
106
+ const lang: Lang = live?.language === "fr" ? "fr" : "en";
107
+ const dt = dashboardStrings(lang);
108
+ const nav = useMemo(() => reportingNav(lang), [lang]);
109
+ const notes = useMemo(() => implementationNotesBundle(lang), [lang]);
110
+ const { reviewed, setReviewedKey, resetReviewed } =
111
+ useImplementationReviewState();
112
+
113
+ const postLang = async (next: Lang) => {
114
+ await postKronosysAction({ type: "setLanguage", lang: next });
115
+ await refresh();
116
+ };
117
+
118
+ return (
119
+ <div className="min-h-screen bg-zinc-100 text-zinc-900 dark:bg-zinc-900 dark:text-zinc-100">
120
+ <header className={appShellHeaderClassName}>
121
+ <div className={appShellHeaderTitleMetaRowClassName}>
122
+ <div className="flex min-w-0 flex-col gap-1">
123
+ <div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
124
+ <Link
125
+ href={withDashboardSessionParam("/", dashboardSessionNavId)}
126
+ className="text-xl font-semibold tracking-tight text-zinc-900 hover:text-violet-700 dark:text-zinc-100 dark:hover:text-violet-300"
127
+ >
128
+ Kronosys
129
+ </Link>
130
+ <span className="text-zinc-400 dark:text-zinc-600" aria-hidden>
131
+ /
132
+ </span>
133
+ <span className="text-lg font-medium text-zinc-700 dark:text-zinc-300">
134
+ {notes.pageTitle}
135
+ </span>
136
+ <span
137
+ className="inline-flex items-center text-violet-500 dark:text-violet-400"
138
+ aria-hidden
139
+ >
140
+ <FileCode2 className="size-5" strokeWidth={2} />
141
+ </span>
142
+ </div>
143
+ <p className="flex flex-wrap items-center gap-x-2 text-xs font-medium leading-snug text-zinc-500 dark:text-zinc-400">
144
+ <span>{dt.brandTagline}</span>
145
+ <span className="text-zinc-400/70 dark:text-zinc-600" aria-hidden>
146
+ ·
147
+ </span>
148
+ <AppVersionStamp ariaLabelTemplate={dt.appVersionAriaLabel} />
149
+ </p>
150
+ </div>
151
+ <AppShellHeaderSessionMeta payload={payload} dt={dt} />
152
+ </div>
153
+ <div className="flex w-full justify-end">
154
+ <div className={appShellHeaderToolbarClassName}>
155
+ <AppShellCommandCenterPlaceholder />
156
+ <AppShellRouteNav
157
+ current="implementation"
158
+ labels={nav}
159
+ navAriaLabel={dt.appShellRouteNavAria}
160
+ dashboardSessionId={dashboardSessionNavId}
161
+ reserveGlobalPauseSlot
162
+ />
163
+ <ThemeToggle lang={lang} />
164
+ <PageRefreshButton
165
+ title={dt.pageRefreshTitle}
166
+ ariaLabel={dt.pageRefreshAriaLabel}
167
+ inlineMessages={{
168
+ loading: dt.pageRefreshProgressLabel,
169
+ success: dt.pageRefreshDoneToast,
170
+ error: dt.pageRefreshFailedToast,
171
+ }}
172
+ onRefresh={async () => {
173
+ return await refresh({ routerInvalidate: true });
174
+ }}
175
+ />
176
+ <LanguageMenu
177
+ lang={lang}
178
+ labelEn="English"
179
+ labelFr="Français"
180
+ menuHeading={lang === "fr" ? "Langue" : "Language"}
181
+ triggerAriaLabel={
182
+ lang === "fr" ? "Langue de l’interface" : "Interface language"
183
+ }
184
+ onSelect={(next) => void postLang(next)}
185
+ />
186
+ </div>
187
+ </div>
188
+ </header>
189
+
190
+ <main className="mx-auto w-full max-w-4xl px-5 pb-16 pt-6 sm:px-8 lg:px-10">
191
+ <p className="text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
192
+ {notes.pageSubtitle}
193
+ </p>
194
+ <p className="mt-2 text-xs font-medium text-zinc-700 dark:text-zinc-300">
195
+ {notes.statusKeyLine}
196
+ </p>
197
+ <p className="mt-2 text-xs text-zinc-500 dark:text-zinc-500">
198
+ {notes.lastUpdated}
199
+ </p>
200
+ <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1">
201
+ <p className="text-xs text-zinc-500 dark:text-zinc-500">
202
+ {notes.repositoryDocLabel}
203
+ </p>
204
+ <button
205
+ type="button"
206
+ className="rounded-md border border-zinc-300 bg-white px-2 py-1 text-xs font-medium text-zinc-700 shadow-sm hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700/80"
207
+ onClick={resetReviewed}
208
+ aria-label={notes.checklistResetAria}
209
+ >
210
+ {notes.checklistResetLabel}
211
+ </button>
212
+ </div>
213
+
214
+ <section
215
+ className="mt-8 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-800/60"
216
+ aria-labelledby="implementation-user-stories-heading"
217
+ >
218
+ <h2
219
+ id="implementation-user-stories-heading"
220
+ className="text-base font-semibold text-zinc-900 dark:text-zinc-100"
221
+ >
222
+ {notes.storyGroupsHeading}
223
+ </h2>
224
+ <div className="mt-6 space-y-8">
225
+ {notes.storyGroups.map((group) => (
226
+ <section
227
+ key={group.id}
228
+ id={group.id}
229
+ className="rounded-lg border border-zinc-100 bg-zinc-50/80 p-3 dark:border-zinc-700/80 dark:bg-zinc-900/40"
230
+ aria-labelledby={`${group.id}-heading`}
231
+ >
232
+ <h3
233
+ id={`${group.id}-heading`}
234
+ className="text-sm font-semibold text-zinc-900 dark:text-zinc-100"
235
+ >
236
+ {group.title}
237
+ </h3>
238
+ {group.lead ? (
239
+ <p className="mt-1.5 text-xs leading-snug text-zinc-600 dark:text-zinc-400">
240
+ {group.lead}
241
+ </p>
242
+ ) : null}
243
+ <div className="mt-3 overflow-x-auto">
244
+ <table className="w-full border-collapse text-left text-sm text-zinc-700 dark:text-zinc-300">
245
+ <caption className="sr-only">{group.title}</caption>
246
+ <tbody>
247
+ {group.stories.map((story, idx) => {
248
+ const rowKey = `sg:${group.id}:s${idx}`;
249
+ const isReviewed = !!reviewed[rowKey];
250
+ const srMark = userStoryScreenReaderLabel(
251
+ lang,
252
+ story.implemented,
253
+ );
254
+ return (
255
+ <tr
256
+ key={`${group.id}-${idx}`}
257
+ className={
258
+ idx === 0
259
+ ? ""
260
+ : "border-t border-zinc-200 dark:border-zinc-700/80"
261
+ }
262
+ >
263
+ <td className="w-10 align-top px-2 py-2.5">
264
+ <span className="sr-only">{srMark} </span>
265
+ <ImplementationGlyph
266
+ implemented={story.implemented}
267
+ />
268
+ </td>
269
+ <td className="py-2.5 pr-2 leading-snug">
270
+ {story.text}
271
+ </td>
272
+ <td className="w-12 shrink-0 align-top px-1 py-2.5 text-center">
273
+ <input
274
+ type="checkbox"
275
+ className="size-4 rounded border-zinc-300 text-violet-600 focus:ring-violet-500 dark:border-zinc-600 dark:bg-zinc-900 dark:text-violet-500 dark:focus:ring-violet-400"
276
+ checked={isReviewed}
277
+ onChange={(e) =>
278
+ setReviewedKey(rowKey, e.target.checked)
279
+ }
280
+ aria-label={
281
+ isReviewed
282
+ ? notes.checklistUnmarkAria
283
+ : notes.checklistMarkAria
284
+ }
285
+ />
286
+ </td>
287
+ </tr>
288
+ );
289
+ })}
290
+ </tbody>
291
+ </table>
292
+ </div>
293
+ </section>
294
+ ))}
295
+ </div>
296
+ </section>
297
+ </main>
298
+ <ScrollToTopFab
299
+ ariaLabel={lang === "fr" ? "Retour en haut de la page" : "Back to top"}
300
+ />
301
+ </div>
302
+ );
303
+ }
304
+
305
+ export default function ImplementationPage() {
306
+ return (
307
+ <Suspense
308
+ fallback={
309
+ <div className="min-h-screen bg-zinc-100 px-6 py-10 text-sm text-zinc-500 dark:bg-zinc-900">
310
+ Kronosys…
311
+ </div>
312
+ }
313
+ >
314
+ <ImplementationBody />
315
+ </Suspense>
316
+ );
317
+ }
package/app/layout.tsx CHANGED
@@ -7,6 +7,7 @@ import { KronosysPackageVersionProvider } from "@/components/KronosysPackageVers
7
7
  import { DashboardToastProvider } from "@/components/dashboard/DashboardToastProvider";
8
8
  import { KronosysPayloadProvider } from "@/components/KronosysPayloadProvider";
9
9
  import { AppShellLiveSessionDrawer } from "@/components/dashboard/AppShellLiveSessionDrawer";
10
+ import { RouteTransition } from "@/components/RouteTransition";
10
11
  import { readKronosysPackageVersion } from "@/lib/readKronosysPackageVersion";
11
12
  import { getThemeBootstrapScript } from "@/lib/theme";
12
13
  import "./globals.css";
@@ -27,7 +28,10 @@ export const metadata: Metadata = {
27
28
  "Kronosys : sessions, tâches, KronoFocus — application web locale (Next.js + SQLite).",
28
29
  applicationName: "Kronosys Dashboard",
29
30
  icons: {
30
- icon: [{ url: "/icon-192.png", sizes: "192x192", type: "image/png" }, { url: "/icon-512.png", sizes: "512x512", type: "image/png" }],
31
+ icon: [
32
+ { url: "/icon-192.png", sizes: "192x192", type: "image/png" },
33
+ { url: "/icon-512.png", sizes: "512x512", type: "image/png" },
34
+ ],
31
35
  apple: [{ url: "/apple-icon.png", sizes: "180x180", type: "image/png" }],
32
36
  },
33
37
  appleWebApp: {
@@ -58,13 +62,17 @@ export default function RootLayout({
58
62
  suppressHydrationWarning
59
63
  >
60
64
  <body className="flex min-h-full flex-col font-sans">
61
- <script dangerouslySetInnerHTML={{ __html: getThemeBootstrapScript() }} />
65
+ <script
66
+ dangerouslySetInnerHTML={{ __html: getThemeBootstrapScript() }}
67
+ />
62
68
  <ThemeProvider>
63
69
  <KronosysPackageVersionProvider version={kronosysPackageVersion}>
64
70
  <DashboardToastProvider>
65
71
  <KronosysPayloadProvider>
66
72
  <PwaRegister />
67
- <div className="flex min-h-full flex-1 flex-col">{children}</div>
73
+ <div className="flex min-h-full flex-1 flex-col">
74
+ <RouteTransition>{children}</RouteTransition>
75
+ </div>
68
76
  <AppShellLiveSessionDrawer />
69
77
  <SiteLegalFooter />
70
78
  </KronosysPayloadProvider>