chati-dev 4.3.0 → 4.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.
Files changed (156) hide show
  1. package/README.md +18 -13
  2. package/bin/chati.js +32 -4
  3. package/framework/agents/build/dev.md +9 -5
  4. package/framework/agents/discover/brief.md +2 -0
  5. package/framework/agents/discover/brownfield-wu.md +2 -0
  6. package/framework/agents/discover/greenfield-wu.md +18 -1
  7. package/framework/agents/plan/detail.md +2 -0
  8. package/framework/agents/plan/tasks.md +31 -0
  9. package/framework/agents/plan/ux-brand-architect.md +21 -2
  10. package/framework/agents/plan/ux-component-engineer.md +10 -0
  11. package/framework/agents/quality/qa-implementation.md +1 -1
  12. package/framework/agents/quality/qa-planning.md +1 -1
  13. package/framework/agents/quality/qa-visual.md +33 -21
  14. package/framework/config.yaml +5 -4
  15. package/framework/constitution.md +58 -11
  16. package/framework/context/governance.md +12 -10
  17. package/framework/context/root.md +2 -2
  18. package/framework/data/entity-registry.yaml +12 -4
  19. package/framework/data/qa-rubrics.yaml +335 -0
  20. package/framework/domains/constitution.yaml +3 -3
  21. package/framework/executors/json-validate.js +80 -0
  22. package/framework/executors/npm-script.js +72 -0
  23. package/framework/executors/path-check.js +67 -0
  24. package/framework/executors/registry.yaml +36 -0
  25. package/framework/hooks/advance-trigger.js +47 -3
  26. package/framework/hooks/constitution-guard.js +9 -4
  27. package/framework/hooks/git-push-authority.js +113 -0
  28. package/framework/hooks/license-guard.js +59 -9
  29. package/framework/hooks/model-governance.js +40 -17
  30. package/framework/hooks/prism-engine.js +78 -11
  31. package/framework/hooks/session-digest.js +12 -3
  32. package/framework/hooks/settings.json +7 -3
  33. package/framework/hooks/style-guard.js +52 -3
  34. package/framework/hooks/team-quality-gate.js +43 -5
  35. package/framework/i18n/en.yaml +3 -3
  36. package/framework/i18n/es.yaml +3 -3
  37. package/framework/i18n/fr.yaml +3 -3
  38. package/framework/i18n/pt.yaml +3 -3
  39. package/framework/intelligence/confidence.yaml +85 -6
  40. package/framework/intelligence/context-engine.md +9 -5
  41. package/framework/intelligence/decision-engine.md +1 -1
  42. package/framework/orchestrator/chati-router.js +59 -5
  43. package/framework/orchestrator/chati-update.md +19 -3
  44. package/framework/orchestrator/chati.md +20 -1
  45. package/framework/quality-gates/planning-gate.md +2 -0
  46. package/framework/rules/human-writing-style.md +47 -0
  47. package/framework/scaffold/motion-premium/README.md +12 -2
  48. package/framework/scaffold/motion-premium/lib/animations/tokens.ts.template +1 -1
  49. package/framework/scaffold/motion-premium/scaffold.yaml +6 -1
  50. package/framework/scaffold/motion-premium-3d/README.md +10 -0
  51. package/framework/scaffold/motion-premium-3d/components/webgl/WebGLContext.tsx.template +1 -1
  52. package/framework/scaffold/motion-premium-3d/scaffold.yaml +3 -0
  53. package/framework/scaffold/saas-dashboard/README.md +58 -0
  54. package/framework/scaffold/saas-dashboard/app/dashboard/page.tsx.template +546 -0
  55. package/framework/scaffold/saas-dashboard/app/globals.css.template +746 -0
  56. package/framework/scaffold/saas-dashboard/app/layout.tsx.template +192 -0
  57. package/framework/scaffold/saas-dashboard/app/shell.css.template +1070 -0
  58. package/framework/scaffold/saas-dashboard/components/data/Chart.tsx.template +725 -0
  59. package/framework/scaffold/saas-dashboard/components/data/DataTable.tsx.template +623 -0
  60. package/framework/scaffold/saas-dashboard/components/data/DataTable.types.ts.template +104 -0
  61. package/framework/scaffold/saas-dashboard/components/data/DataTablePagination.tsx.template +234 -0
  62. package/framework/scaffold/saas-dashboard/components/data/SkeletonBlock.tsx.template +111 -0
  63. package/framework/scaffold/saas-dashboard/components/data/StatCard.tsx.template +393 -0
  64. package/framework/scaffold/saas-dashboard/components/data/StatCardGrid.tsx.template +121 -0
  65. package/framework/scaffold/saas-dashboard/components/feedback/EmptyState.tsx.template +281 -0
  66. package/framework/scaffold/saas-dashboard/components/interaction/CommandPalette.tsx.template +767 -0
  67. package/framework/scaffold/saas-dashboard/components/interaction/Field.tsx.template +351 -0
  68. package/framework/scaffold/saas-dashboard/components/interaction/Form.tsx.template +424 -0
  69. package/framework/scaffold/saas-dashboard/components/primitives/Badge.tsx.template +193 -0
  70. package/framework/scaffold/saas-dashboard/components/primitives/Button.tsx.template +261 -0
  71. package/framework/scaffold/saas-dashboard/components/primitives/Card.tsx.template +287 -0
  72. package/framework/scaffold/saas-dashboard/components/primitives/Input.tsx.template +310 -0
  73. package/framework/scaffold/saas-dashboard/components/primitives/Spinner.tsx.template +135 -0
  74. package/framework/scaffold/saas-dashboard/components/primitives/index.ts.template +60 -0
  75. package/framework/scaffold/saas-dashboard/components/shell/AppShell.tsx.template +174 -0
  76. package/framework/scaffold/saas-dashboard/components/shell/Breadcrumb.tsx.template +186 -0
  77. package/framework/scaffold/saas-dashboard/components/shell/Container.tsx.template +86 -0
  78. package/framework/scaffold/saas-dashboard/components/shell/NavItem.tsx.template +147 -0
  79. package/framework/scaffold/saas-dashboard/components/shell/Sidebar.tsx.template +378 -0
  80. package/framework/scaffold/saas-dashboard/components/shell/SidebarContext.tsx.template +169 -0
  81. package/framework/scaffold/saas-dashboard/components/shell/ThemeToggle.tsx.template +128 -0
  82. package/framework/scaffold/saas-dashboard/components/shell/Topbar.tsx.template +301 -0
  83. package/framework/scaffold/saas-dashboard/lib/brand.ts.template +324 -0
  84. package/framework/scaffold/saas-dashboard/scaffold.yaml +225 -0
  85. package/framework/schemas/session.schema.json +5 -0
  86. package/framework/schemas/task.schema.json +9 -0
  87. package/framework/scripts/visual-qa.js +101 -3
  88. package/framework/tasks/orchestrator-health.md +4 -4
  89. package/framework/tasks/qa-impl-verdict.md +11 -5
  90. package/framework/templates/brandbook-html-tmpl.md +1 -1
  91. package/framework/templates/qa-gate-tmpl.yaml +24 -11
  92. package/package.json +4 -3
  93. package/src/config/context-file-generator.js +0 -6
  94. package/src/dashboard/renderer.js +0 -36
  95. package/src/executors/runner.js +204 -0
  96. package/src/installer/core.js +13 -3
  97. package/src/installer/templates.js +5 -6
  98. package/src/intelligence/registry-manager.js +1 -1
  99. package/src/license/client.js +9 -3
  100. package/src/license/commands.js +12 -2
  101. package/src/license/machine-id.js +42 -1
  102. package/src/memory/gotchas.js +58 -40
  103. package/src/memory/magic-docs.js +1 -1
  104. package/src/memory/session-digest.js +9 -4
  105. package/src/orchestrator/cli.js +186 -15
  106. package/src/orchestrator/doctor.js +98 -16
  107. package/src/orchestrator/pipeline-manager.js +44 -22
  108. package/src/orchestrator/session-manager.js +64 -8
  109. package/src/telemetry/sender.js +7 -19
  110. package/src/terminal/run-team.js +3 -3
  111. package/src/upgrade/tracked-files-detector.js +34 -16
  112. package/src/utils/feature-flags.js +1 -1
  113. package/src/utils/flatten-entities.js +4 -40
  114. package/src/utils/schema-validator.js +0 -14
  115. package/src/wizard/i18n.js +3 -3
  116. package/src/api/index.js +0 -120
  117. package/src/autonomy/autonomous-gate.js +0 -294
  118. package/src/autonomy/build-loop.js +0 -281
  119. package/src/autonomy/build-state.js +0 -286
  120. package/src/autonomy/cause-analyzer.js +0 -177
  121. package/src/autonomy/escalation.js +0 -214
  122. package/src/autonomy/index.js +0 -51
  123. package/src/autonomy/mode-manager.js +0 -225
  124. package/src/autonomy/mode-suggester.js +0 -283
  125. package/src/autonomy/progress-reporter.js +0 -275
  126. package/src/autonomy/safety-net.js +0 -370
  127. package/src/config/agent-customizer.js +0 -231
  128. package/src/decision/analyzer.js +0 -291
  129. package/src/decision/engine.js +0 -250
  130. package/src/decision/index.js +0 -38
  131. package/src/decision/registry-healer.js +0 -468
  132. package/src/decision/registry-updater.js +0 -339
  133. package/src/extensions/loader.js +0 -145
  134. package/src/extensions/registry.js +0 -134
  135. package/src/gates/circuit-breaker.js +0 -151
  136. package/src/gates/g1-planning-complete.js +0 -154
  137. package/src/gates/g2-qa-planning.js +0 -156
  138. package/src/gates/g3-implementation.js +0 -215
  139. package/src/gates/g4-qa-implementation.js +0 -240
  140. package/src/gates/g5-deploy-ready.js +0 -181
  141. package/src/gates/gate-base.js +0 -185
  142. package/src/gates/index.js +0 -46
  143. package/src/health/auto-fix.js +0 -216
  144. package/src/health/engine.js +0 -246
  145. package/src/merger/semantic-merger.js +0 -292
  146. package/src/preview/detector.js +0 -238
  147. package/src/preview/index.js +0 -20
  148. package/src/preview/launcher.js +0 -235
  149. package/src/preview/log-buffer.js +0 -103
  150. package/src/quality/metrics-collector.js +0 -281
  151. package/src/quality/test-runner.js +0 -366
  152. package/src/tasks/executor.js +0 -195
  153. package/src/tasks/index.js +0 -4
  154. package/src/tasks/loader.js +0 -210
  155. package/src/tasks/router.js +0 -182
  156. package/src/utils/event-bus.js +0 -126
@@ -0,0 +1,378 @@
1
+ /*
2
+ * Sidebar.tsx -- Application Sidebar Navigation
3
+ * ============================================================
4
+ * WHY THIS FILE EXISTS
5
+ * --------------------
6
+ * The sidebar is the primary navigation surface for the
7
+ * dashboard. It must satisfy four competing demands:
8
+ *
9
+ * 1. Legibility: wide enough to show labels, icons, and
10
+ * section groupings without crowding.
11
+ *
12
+ * 2. Density: collapsible to a 64px icon-only rail so the
13
+ * content area can expand for data-heavy views.
14
+ *
15
+ * 3. Accessibility: keyboard navigation, focus management,
16
+ * tooltip labels when collapsed, aria landmarks.
17
+ *
18
+ * 4. Responsiveness: on mobile the sidebar becomes a fixed
19
+ * overlay drawer triggered by the Topbar hamburger.
20
+ *
21
+ * COLLAPSE ANIMATION APPROACH
22
+ * ---------------------------
23
+ * The sidebar does NOT animate its own width. The AppShell CSS
24
+ * Grid column handles width via the --shell-sidebar-w custom
25
+ * property. What the sidebar DOES animate:
26
+ *
27
+ * - Label opacity: 0 when collapsed, 1 when expanded.
28
+ * (opacity + visibility to keep labels out of tab order
29
+ * when hidden.)
30
+ * - Label max-width: 0 to 200px, controlling the visual
31
+ * collapse without animating width (which is expensive).
32
+ * We animate max-width only here because it is on a small
33
+ * inline element (label text span) not the outer container,
34
+ * so the layout shift is contained and acceptable.
35
+ * NOTE: max-width IS animated here as an exception because
36
+ * the element is a text-only span inside a flex row and
37
+ * cannot be handled by opacity alone (hidden text still
38
+ * occupies space). transform: scaleX(0) was evaluated but
39
+ * causes subpixel blurring on retina. This is the pragmatic
40
+ * trade-off.
41
+ *
42
+ * NAV STRUCTURE
43
+ * -------------
44
+ * The nav tree is defined as a static array of NavSection[].
45
+ * In a real product this would be constructed from the user's
46
+ * permissions and the application's route config. The static
47
+ * definition is intentional for the preset: it shows the
48
+ * structure without introducing a dependency on any specific
49
+ * auth or routing abstraction.
50
+ *
51
+ * ACTIVE STATE DETECTION
52
+ * ----------------------
53
+ * We use Next.js usePathname() to detect the active route and
54
+ * compare it against each item's href. Exact match is used for
55
+ * root routes; prefix match (startsWith) is used for section
56
+ * roots so that nested routes highlight the parent item.
57
+ *
58
+ * ACCESSIBILITY
59
+ * -------------
60
+ * - The sidebar is a <nav> element with aria-label="Main"
61
+ * - When collapsed, text labels are aria-hidden and visually
62
+ * hidden; tooltips (title attr) provide the label
63
+ * - When a nav item is active, aria-current="page" is set
64
+ * - Keyboard: Tab moves between focusable items; no roving
65
+ * tabindex (the nav is not a composite widget)
66
+ * - Section headings use <p role="heading" aria-level="2">
67
+ * (not <h2>) to avoid polluting the document heading hierarchy
68
+ */
69
+
70
+ 'use client'
71
+
72
+ import React from 'react';
73
+ import { usePathname } from 'next/navigation'
74
+ import Link from 'next/link'
75
+ import {
76
+ LayoutDashboard,
77
+ Users,
78
+ BarChart3,
79
+ FileText,
80
+ Settings,
81
+ CreditCard,
82
+ Bell,
83
+ HelpCircle,
84
+ ChevronLeft,
85
+ type LucideIcon,
86
+ } from 'lucide-react'
87
+ import { useSidebar } from './SidebarContext'
88
+ import { ThemeToggle } from './ThemeToggle'
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Nav data model
92
+ // ---------------------------------------------------------------------------
93
+
94
+ interface NavItem {
95
+ label: string
96
+ href: string
97
+ icon: LucideIcon
98
+ badge?: number
99
+ /** Exact match required for active detection (e.g. for root '/') */
100
+ exact?: boolean
101
+ }
102
+
103
+ interface NavSection {
104
+ id: string
105
+ heading?: string
106
+ items: NavItem[]
107
+ }
108
+
109
+ // Static nav structure. In production this would be derived from
110
+ // permissions and the application route manifest.
111
+ const NAV_SECTIONS: NavSection[] = [
112
+ {
113
+ id: 'main',
114
+ items: [
115
+ { label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, exact: true },
116
+ { label: 'Analytics', href: '/analytics', icon: BarChart3 },
117
+ { label: 'Customers', href: '/customers', icon: Users, badge: 4 },
118
+ { label: 'Documents', href: '/documents', icon: FileText },
119
+ ],
120
+ },
121
+ {
122
+ id: 'account',
123
+ heading: 'Account',
124
+ items: [
125
+ { label: 'Billing', href: '/billing', icon: CreditCard },
126
+ { label: 'Notifications', href: '/notifications', icon: Bell, badge: 12 },
127
+ { label: 'Settings', href: '/settings', icon: Settings },
128
+ ],
129
+ },
130
+ {
131
+ id: 'support',
132
+ heading: 'Support',
133
+ items: [
134
+ { label: 'Help & Docs', href: '/help', icon: HelpCircle },
135
+ ],
136
+ },
137
+ ]
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Helper: is this nav item active?
141
+ // ---------------------------------------------------------------------------
142
+
143
+ function isActive(href: string, pathname: string, exact?: boolean): boolean {
144
+ if (exact) return pathname === href
145
+ return pathname === href || pathname.startsWith(href + '/')
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // NavItemRow sub-component
150
+ // ---------------------------------------------------------------------------
151
+ // Extracted to keep the main component readable. This is NOT exported
152
+ // as the public NavItem component (that lives in NavItem.tsx); this is
153
+ // an internal render helper specific to Sidebar's layout.
154
+
155
+ interface NavItemRowProps {
156
+ item: NavItem
157
+ isCollapsed: boolean
158
+ active: boolean
159
+ }
160
+
161
+ function NavItemRow({ item, isCollapsed, active }: NavItemRowProps) {
162
+ const Icon = item.icon
163
+
164
+ return (
165
+ <Link
166
+ href={item.href}
167
+ aria-current={active ? 'page' : undefined}
168
+ // When collapsed, the visible label is hidden; provide a title
169
+ // for the browser's native tooltip as a fallback. The accessible
170
+ // label is still provided by the sr-only span below.
171
+ title={isCollapsed ? item.label : undefined}
172
+ className={[
173
+ 'sidebar-nav-item',
174
+ active ? 'sidebar-nav-item--active' : '',
175
+ ]
176
+ .filter(Boolean)
177
+ .join(' ')}
178
+ >
179
+ {/*
180
+ * Icon: always visible. aria-hidden because the text label
181
+ * (or sr-only label when collapsed) provides the accessible name.
182
+ */}
183
+ <span className="sidebar-nav-icon" aria-hidden="true">
184
+ <Icon size={18} strokeWidth={1.75} />
185
+ </span>
186
+
187
+ {/*
188
+ * Label: visible when expanded, clipped when collapsed.
189
+ * aria-hidden when collapsed because the title attribute
190
+ * provides the tooltip. A sr-only span is always present
191
+ * so the accessible name is available regardless of state.
192
+ */}
193
+ <span
194
+ className="sidebar-nav-label"
195
+ aria-hidden={isCollapsed}
196
+ >
197
+ {item.label}
198
+ </span>
199
+
200
+ {/* Always-available SR label (visible or hidden, SR always reads it) */}
201
+ {isCollapsed && (
202
+ <span className="sr-only">{item.label}</span>
203
+ )}
204
+
205
+ {/*
206
+ * Badge: unread count. Hidden when collapsed (tooltip cannot
207
+ * show it; that is acceptable for counts -- the content itself
208
+ * will surface the unread state on click).
209
+ */}
210
+ {item.badge !== undefined && !isCollapsed && (
211
+ <span className="sidebar-nav-badge" aria-label={`${item.badge} unread`}>
212
+ {item.badge > 99 ? '99+' : item.badge}
213
+ </span>
214
+ )}
215
+ {item.badge !== undefined && isCollapsed && (
216
+ <span className="sidebar-nav-badge-dot" aria-hidden="true" />
217
+ )}
218
+ </Link>
219
+ )
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Main Sidebar component
224
+ // ---------------------------------------------------------------------------
225
+
226
+ export function Sidebar() {
227
+ const { isCollapsed, isMobileOpen, toggle, closeMobile } = useSidebar()
228
+ const pathname = usePathname()
229
+
230
+ return (
231
+ /*
232
+ * Overlay: on mobile, a separate backdrop is rendered by AppShell.
233
+ * The sidebar itself uses position:fixed on mobile and translateX
234
+ * to slide in/out. On desktop it is in the normal grid flow.
235
+ *
236
+ * class "sidebar" handles desktop grid-column layout.
237
+ * class "sidebar--collapsed" shrinks labels.
238
+ * class "sidebar--mobile-open" triggers mobile slide-in.
239
+ */
240
+ <>
241
+ <aside
242
+ className={[
243
+ 'sidebar',
244
+ isCollapsed ? 'sidebar--collapsed' : '',
245
+ isMobileOpen ? 'sidebar--mobile-open' : '',
246
+ ]
247
+ .filter(Boolean)
248
+ .join(' ')}
249
+ aria-label="Main"
250
+ >
251
+ {/* ----------------------------------------------------------------
252
+ * Header: logo / wordmark + collapse toggle
253
+ * ---------------------------------------------------------------- */}
254
+ <div className="sidebar-header">
255
+ {/* Logo mark: always visible */}
256
+ <div className="sidebar-logo" aria-hidden="true">
257
+ <span className="sidebar-logo-mark" />
258
+ </div>
259
+
260
+ {/* Wordmark: visible only when expanded */}
261
+ <span className="sidebar-wordmark" aria-hidden={isCollapsed}>
262
+ Acme
263
+ </span>
264
+
265
+ {/*
266
+ * Collapse toggle: desktop only (hidden on mobile via CSS).
267
+ * aria-expanded reflects the current state.
268
+ * aria-controls references the nav element below.
269
+ */}
270
+ <button
271
+ type="button"
272
+ onClick={toggle}
273
+ aria-expanded={!isCollapsed}
274
+ aria-controls="sidebar-nav"
275
+ aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
276
+ className="sidebar-collapse-btn"
277
+ >
278
+ <span
279
+ className={[
280
+ 'sidebar-collapse-icon',
281
+ isCollapsed ? 'sidebar-collapse-icon--flipped' : '',
282
+ ]
283
+ .filter(Boolean)
284
+ .join(' ')}
285
+ aria-hidden="true"
286
+ >
287
+ <ChevronLeft size={16} strokeWidth={2} />
288
+ </span>
289
+ </button>
290
+
291
+ {/* Mobile close button: visible only on mobile when drawer is open */}
292
+ <button
293
+ type="button"
294
+ onClick={closeMobile}
295
+ aria-label="Close navigation"
296
+ className="sidebar-mobile-close-btn"
297
+ >
298
+ <span aria-hidden="true">&times;</span>
299
+ </button>
300
+ </div>
301
+
302
+ {/* ----------------------------------------------------------------
303
+ * Nav sections
304
+ * ---------------------------------------------------------------- */}
305
+ <nav
306
+ id="sidebar-nav"
307
+ className="sidebar-nav"
308
+ aria-label="Main navigation"
309
+ >
310
+ {NAV_SECTIONS.map((section, sectionIndex) => (
311
+ <div
312
+ key={section.id}
313
+ className="sidebar-section"
314
+ // Stagger animation delay: each section appears 40ms after
315
+ // the previous. Implemented via CSS nth-child + animation-delay.
316
+ // The CSS variable is read by the sidebar-section animation rule.
317
+ style={{ '--section-index': sectionIndex } as React.CSSProperties}
318
+ >
319
+ {/* Section heading: hidden when sidebar is collapsed */}
320
+ {section.heading && (
321
+ <p
322
+ role="heading"
323
+ aria-level={2}
324
+ className="sidebar-section-heading"
325
+ aria-hidden={isCollapsed}
326
+ >
327
+ {section.heading}
328
+ </p>
329
+ )}
330
+
331
+ <ul role="list" className="sidebar-section-list">
332
+ {section.items.map((item) => (
333
+ <li key={item.href}>
334
+ <NavItemRow
335
+ item={item}
336
+ isCollapsed={isCollapsed}
337
+ active={isActive(item.href, pathname, item.exact)}
338
+ />
339
+ </li>
340
+ ))}
341
+ </ul>
342
+ </div>
343
+ ))}
344
+ </nav>
345
+
346
+ {/* ----------------------------------------------------------------
347
+ * Sidebar footer: user identity + theme toggle + settings
348
+ * ---------------------------------------------------------------- */}
349
+ <div className="sidebar-footer">
350
+ {!isCollapsed && (
351
+ <div className="sidebar-user">
352
+ {/* Avatar */}
353
+ <div className="sidebar-user-avatar" aria-hidden="true">
354
+ <span className="sidebar-user-initials">GA</span>
355
+ </div>
356
+ <div className="sidebar-user-info">
357
+ <p className="sidebar-user-name">Gabriel A.</p>
358
+ <p className="sidebar-user-email">gabriel@example.com</p>
359
+ </div>
360
+ </div>
361
+ )}
362
+
363
+ {isCollapsed && (
364
+ <div
365
+ className="sidebar-user-avatar sidebar-user-avatar--collapsed"
366
+ title="Gabriel A."
367
+ aria-label="User: Gabriel A."
368
+ >
369
+ <span className="sidebar-user-initials" aria-hidden="true">GA</span>
370
+ </div>
371
+ )}
372
+
373
+ <ThemeToggle />
374
+ </div>
375
+ </aside>
376
+ </>
377
+ )
378
+ }
@@ -0,0 +1,169 @@
1
+ /*
2
+ * SidebarContext.tsx -- Sidebar Collapse State Context
3
+ * ============================================================
4
+ * WHY THIS FILE EXISTS
5
+ * --------------------
6
+ * Multiple components in the shell need to read and react to the
7
+ * sidebar's collapsed state:
8
+ *
9
+ * - AppShell: adjusts the CSS grid column width
10
+ * - Sidebar: controls its own visual width and label visibility
11
+ * - NavItem: hides text labels, shows tooltips when collapsed
12
+ * - Topbar: adjusts the hamburger icon aria-expanded state
13
+ *
14
+ * Rather than threading a `collapsed` prop through three levels of
15
+ * components (which would create brittle coupling), we use a
16
+ * React context with a provider at the AppShell boundary. Any
17
+ * consumer can read or mutate the state without knowing about the
18
+ * others.
19
+ *
20
+ * PERSISTENCE STRATEGY
21
+ * --------------------
22
+ * We persist the collapsed preference to localStorage under the
23
+ * key 'sidebar-collapsed'. This means the sidebar renders in the
24
+ * user's last-chosen state on the next session. The initial state
25
+ * is read synchronously from localStorage during the provider's
26
+ * useState initializer (a function passed to useState runs once,
27
+ * on mount, in the browser). On the server there is no
28
+ * localStorage, so we default to expanded (false).
29
+ *
30
+ * MOBILE BEHAVIOR
31
+ * ---------------
32
+ * On mobile viewports the sidebar is hidden off-screen regardless
33
+ * of the collapsed preference. A separate `isMobileOpen` boolean
34
+ * controls whether the mobile overlay drawer is visible. These
35
+ * two states are intentionally separate:
36
+ * - isCollapsed: user's icon-only preference on desktop
37
+ * - isMobileOpen: whether the drawer is open on mobile
38
+ *
39
+ * TYPESCRIPT
40
+ * ----------
41
+ * The context value is fully typed with a default of null so
42
+ * consumers that call useContext outside the provider boundary
43
+ * receive a helpful error via the useSidebar() hook guard.
44
+ */
45
+
46
+ 'use client'
47
+
48
+ import {
49
+ createContext,
50
+ useContext,
51
+ useState,
52
+ useCallback,
53
+ useEffect,
54
+ type ReactNode,
55
+ } from 'react'
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Types
59
+ // ---------------------------------------------------------------------------
60
+
61
+ export interface SidebarContextValue {
62
+ /** Whether the desktop sidebar is in icon-only collapsed mode */
63
+ isCollapsed: boolean
64
+ /** Whether the mobile overlay drawer is open */
65
+ isMobileOpen: boolean
66
+ /** Toggle collapsed state (desktop) */
67
+ toggle: () => void
68
+ /** Force collapsed state to true */
69
+ collapse: () => void
70
+ /** Force collapsed state to false */
71
+ expand: () => void
72
+ /** Open the mobile overlay drawer */
73
+ openMobile: () => void
74
+ /** Close the mobile overlay drawer */
75
+ closeMobile: () => void
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Context
80
+ // ---------------------------------------------------------------------------
81
+
82
+ const SidebarContext = createContext<SidebarContextValue | null>(null)
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Hook
86
+ // ---------------------------------------------------------------------------
87
+
88
+ /**
89
+ * useSidebar -- access sidebar context from any child component.
90
+ *
91
+ * Throws a descriptive error if called outside a SidebarProvider,
92
+ * which makes debugging layout issues much easier during development.
93
+ */
94
+ export function useSidebar(): SidebarContextValue {
95
+ const ctx = useContext(SidebarContext)
96
+ if (ctx === null) {
97
+ throw new Error(
98
+ 'useSidebar must be called inside a <SidebarProvider>. ' +
99
+ 'Wrap your AppShell (or root layout) with <SidebarProvider>.'
100
+ )
101
+ }
102
+ return ctx
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Provider
107
+ // ---------------------------------------------------------------------------
108
+
109
+ const STORAGE_KEY = 'sidebar-collapsed'
110
+
111
+ export function SidebarProvider({ children }: { children: ReactNode }) {
112
+ // Read the persisted preference synchronously. The initializer function
113
+ // runs only once (on mount) so this does not cause repeated localStorage
114
+ // reads on re-renders.
115
+ const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
116
+ if (typeof window === 'undefined') return false
117
+ try {
118
+ const stored = window.localStorage.getItem(STORAGE_KEY)
119
+ return stored === 'true'
120
+ } catch {
121
+ return false
122
+ }
123
+ })
124
+
125
+ const [isMobileOpen, setIsMobileOpen] = useState(false)
126
+
127
+ // Persist every change to localStorage. useEffect is the right place
128
+ // because localStorage is a side effect and not part of render.
129
+ useEffect(() => {
130
+ try {
131
+ window.localStorage.setItem(STORAGE_KEY, String(isCollapsed))
132
+ } catch {
133
+ // localStorage may be unavailable in private browsing; fail silently.
134
+ }
135
+ }, [isCollapsed])
136
+
137
+ // Close mobile drawer on resize above the mobile breakpoint so the
138
+ // drawer does not linger open when the viewport expands.
139
+ useEffect(() => {
140
+ const mq = window.matchMedia('(min-width: 768px)')
141
+ const handler = (e: MediaQueryListEvent) => {
142
+ if (e.matches) setIsMobileOpen(false)
143
+ }
144
+ mq.addEventListener('change', handler)
145
+ return () => mq.removeEventListener('change', handler)
146
+ }, [])
147
+
148
+ const toggle = useCallback(() => setIsCollapsed((v) => !v), [])
149
+ const collapse = useCallback(() => setIsCollapsed(true), [])
150
+ const expand = useCallback(() => setIsCollapsed(false), [])
151
+ const openMobile = useCallback(() => setIsMobileOpen(true), [])
152
+ const closeMobile = useCallback(() => setIsMobileOpen(false), [])
153
+
154
+ return (
155
+ <SidebarContext.Provider
156
+ value={{
157
+ isCollapsed,
158
+ isMobileOpen,
159
+ toggle,
160
+ collapse,
161
+ expand,
162
+ openMobile,
163
+ closeMobile,
164
+ }}
165
+ >
166
+ {children}
167
+ </SidebarContext.Provider>
168
+ )
169
+ }
@@ -0,0 +1,128 @@
1
+ /*
2
+ * ThemeToggle.tsx -- Dark/Light Theme Toggle
3
+ * ============================================================
4
+ * WHY THIS FILE EXISTS
5
+ * --------------------
6
+ * The application ships with a designed dark palette as the
7
+ * default. Some users prefer a light theme. ThemeToggle allows
8
+ * in-app switching without reloading the page.
9
+ *
10
+ * MECHANISM
11
+ * ---------
12
+ * Theme switching is implemented by toggling data-theme='light'
13
+ * on the <html> element. The root layout.tsx sets data-theme='dark'
14
+ * by default. globals.css declares [data-theme='light'] { ... }
15
+ * overrides for all semantic tokens. This approach:
16
+ *
17
+ * 1. Requires no CSS framework changes (Tailwind dark: prefix
18
+ * would require knowing the toggle state at build time).
19
+ * 2. Is instantaneous (no class re-computation, just a DOM
20
+ * attribute change that triggers a CSS cascade update).
21
+ * 3. Works with server-rendered initial HTML (no FOUC for dark).
22
+ *
23
+ * PERSISTENCE
24
+ * -----------
25
+ * The preference is stored in localStorage under 'color-theme'.
26
+ * On mount (useEffect), the saved preference is read and applied.
27
+ * Because layout.tsx sets data-theme='dark' and suppresses the
28
+ * hydration warning, there is a brief flash only on light-theme
29
+ * users' first paint. This is the standard trade-off for
30
+ * server-rendered apps with client-side theme preferences.
31
+ *
32
+ * ANIMATION
33
+ * ---------
34
+ * The Sun and Moon icons cross-fade via opacity transition at
35
+ * --duration-normal (200ms) with --ease-in-out. Both icons are
36
+ * rendered at all times; the inactive one has opacity:0. This
37
+ * avoids layout shift from conditional rendering and produces a
38
+ * smooth cross-dissolve effect.
39
+ *
40
+ * ACCESSIBILITY
41
+ * -------------
42
+ * - The button has an sr-only label that reflects the current
43
+ * action: 'Switch to light theme' or 'Switch to dark theme'.
44
+ * - The icons are aria-hidden (decorative).
45
+ * - The button uses the topbar-icon-btn class which includes the
46
+ * global focus ring from globals.css.
47
+ */
48
+
49
+ 'use client'
50
+
51
+ import { useState, useEffect } from 'react'
52
+ import { Sun, Moon } from 'lucide-react'
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Types + constants
56
+ // ---------------------------------------------------------------------------
57
+
58
+ type Theme = 'dark' | 'light'
59
+ const STORAGE_KEY = 'color-theme'
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Component
63
+ // ---------------------------------------------------------------------------
64
+
65
+ export function ThemeToggle() {
66
+ const [theme, setTheme] = useState<Theme>('dark')
67
+
68
+ // Read the persisted preference on mount and apply it.
69
+ useEffect(() => {
70
+ try {
71
+ const stored = window.localStorage.getItem(STORAGE_KEY) as Theme | null
72
+ if (stored === 'light' || stored === 'dark') {
73
+ setTheme(stored)
74
+ document.documentElement.setAttribute('data-theme', stored)
75
+ }
76
+ } catch {
77
+ // Fail silently if localStorage is unavailable.
78
+ }
79
+ }, [])
80
+
81
+ function handleToggle() {
82
+ const next: Theme = theme === 'dark' ? 'light' : 'dark'
83
+ setTheme(next)
84
+ document.documentElement.setAttribute('data-theme', next)
85
+ try {
86
+ window.localStorage.setItem(STORAGE_KEY, next)
87
+ } catch {
88
+ // Fail silently.
89
+ }
90
+ }
91
+
92
+ const isDark = theme === 'dark'
93
+
94
+ return (
95
+ <button
96
+ type="button"
97
+ onClick={handleToggle}
98
+ // aria-pressed communicates the current state (dark = pressed).
99
+ // Users can infer: pressed = dark mode is ON.
100
+ aria-pressed={isDark}
101
+ className="theme-toggle topbar-icon-btn"
102
+ >
103
+ {/*
104
+ * Both icons are always in the DOM. Transitions are on opacity
105
+ * only, so no layout shift occurs when switching themes.
106
+ */}
107
+ <span
108
+ className="theme-toggle-icon"
109
+ style={{ opacity: isDark ? 0 : 1 }}
110
+ aria-hidden="true"
111
+ >
112
+ <Sun size={18} strokeWidth={1.75} />
113
+ </span>
114
+ <span
115
+ className="theme-toggle-icon theme-toggle-icon--overlay"
116
+ style={{ opacity: isDark ? 1 : 0 }}
117
+ aria-hidden="true"
118
+ >
119
+ <Moon size={18} strokeWidth={1.75} />
120
+ </span>
121
+
122
+ {/* SR label: states the action (what will happen next). */}
123
+ <span className="sr-only">
124
+ {isDark ? 'Switch to light theme' : 'Switch to dark theme'}
125
+ </span>
126
+ </button>
127
+ )
128
+ }