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.
- package/README.md +18 -13
- package/bin/chati.js +32 -4
- package/framework/agents/build/dev.md +9 -5
- package/framework/agents/discover/brief.md +2 -0
- package/framework/agents/discover/brownfield-wu.md +2 -0
- package/framework/agents/discover/greenfield-wu.md +18 -1
- package/framework/agents/plan/detail.md +2 -0
- package/framework/agents/plan/tasks.md +31 -0
- package/framework/agents/plan/ux-brand-architect.md +21 -2
- package/framework/agents/plan/ux-component-engineer.md +10 -0
- package/framework/agents/quality/qa-implementation.md +1 -1
- package/framework/agents/quality/qa-planning.md +1 -1
- package/framework/agents/quality/qa-visual.md +33 -21
- package/framework/config.yaml +5 -4
- package/framework/constitution.md +58 -11
- package/framework/context/governance.md +12 -10
- package/framework/context/root.md +2 -2
- package/framework/data/entity-registry.yaml +12 -4
- package/framework/data/qa-rubrics.yaml +335 -0
- package/framework/domains/constitution.yaml +3 -3
- package/framework/executors/json-validate.js +80 -0
- package/framework/executors/npm-script.js +72 -0
- package/framework/executors/path-check.js +67 -0
- package/framework/executors/registry.yaml +36 -0
- package/framework/hooks/advance-trigger.js +47 -3
- package/framework/hooks/constitution-guard.js +9 -4
- package/framework/hooks/git-push-authority.js +113 -0
- package/framework/hooks/license-guard.js +59 -9
- package/framework/hooks/model-governance.js +40 -17
- package/framework/hooks/prism-engine.js +78 -11
- package/framework/hooks/session-digest.js +12 -3
- package/framework/hooks/settings.json +7 -3
- package/framework/hooks/style-guard.js +52 -3
- package/framework/hooks/team-quality-gate.js +43 -5
- package/framework/i18n/en.yaml +3 -3
- package/framework/i18n/es.yaml +3 -3
- package/framework/i18n/fr.yaml +3 -3
- package/framework/i18n/pt.yaml +3 -3
- package/framework/intelligence/confidence.yaml +85 -6
- package/framework/intelligence/context-engine.md +9 -5
- package/framework/intelligence/decision-engine.md +1 -1
- package/framework/orchestrator/chati-router.js +59 -5
- package/framework/orchestrator/chati-update.md +19 -3
- package/framework/orchestrator/chati.md +20 -1
- package/framework/quality-gates/planning-gate.md +2 -0
- package/framework/rules/human-writing-style.md +47 -0
- package/framework/scaffold/motion-premium/README.md +12 -2
- package/framework/scaffold/motion-premium/lib/animations/tokens.ts.template +1 -1
- package/framework/scaffold/motion-premium/scaffold.yaml +6 -1
- package/framework/scaffold/motion-premium-3d/README.md +10 -0
- package/framework/scaffold/motion-premium-3d/components/webgl/WebGLContext.tsx.template +1 -1
- package/framework/scaffold/motion-premium-3d/scaffold.yaml +3 -0
- package/framework/scaffold/saas-dashboard/README.md +58 -0
- package/framework/scaffold/saas-dashboard/app/dashboard/page.tsx.template +546 -0
- package/framework/scaffold/saas-dashboard/app/globals.css.template +746 -0
- package/framework/scaffold/saas-dashboard/app/layout.tsx.template +192 -0
- package/framework/scaffold/saas-dashboard/app/shell.css.template +1070 -0
- package/framework/scaffold/saas-dashboard/components/data/Chart.tsx.template +725 -0
- package/framework/scaffold/saas-dashboard/components/data/DataTable.tsx.template +623 -0
- package/framework/scaffold/saas-dashboard/components/data/DataTable.types.ts.template +104 -0
- package/framework/scaffold/saas-dashboard/components/data/DataTablePagination.tsx.template +234 -0
- package/framework/scaffold/saas-dashboard/components/data/SkeletonBlock.tsx.template +111 -0
- package/framework/scaffold/saas-dashboard/components/data/StatCard.tsx.template +393 -0
- package/framework/scaffold/saas-dashboard/components/data/StatCardGrid.tsx.template +121 -0
- package/framework/scaffold/saas-dashboard/components/feedback/EmptyState.tsx.template +281 -0
- package/framework/scaffold/saas-dashboard/components/interaction/CommandPalette.tsx.template +767 -0
- package/framework/scaffold/saas-dashboard/components/interaction/Field.tsx.template +351 -0
- package/framework/scaffold/saas-dashboard/components/interaction/Form.tsx.template +424 -0
- package/framework/scaffold/saas-dashboard/components/primitives/Badge.tsx.template +193 -0
- package/framework/scaffold/saas-dashboard/components/primitives/Button.tsx.template +261 -0
- package/framework/scaffold/saas-dashboard/components/primitives/Card.tsx.template +287 -0
- package/framework/scaffold/saas-dashboard/components/primitives/Input.tsx.template +310 -0
- package/framework/scaffold/saas-dashboard/components/primitives/Spinner.tsx.template +135 -0
- package/framework/scaffold/saas-dashboard/components/primitives/index.ts.template +60 -0
- package/framework/scaffold/saas-dashboard/components/shell/AppShell.tsx.template +174 -0
- package/framework/scaffold/saas-dashboard/components/shell/Breadcrumb.tsx.template +186 -0
- package/framework/scaffold/saas-dashboard/components/shell/Container.tsx.template +86 -0
- package/framework/scaffold/saas-dashboard/components/shell/NavItem.tsx.template +147 -0
- package/framework/scaffold/saas-dashboard/components/shell/Sidebar.tsx.template +378 -0
- package/framework/scaffold/saas-dashboard/components/shell/SidebarContext.tsx.template +169 -0
- package/framework/scaffold/saas-dashboard/components/shell/ThemeToggle.tsx.template +128 -0
- package/framework/scaffold/saas-dashboard/components/shell/Topbar.tsx.template +301 -0
- package/framework/scaffold/saas-dashboard/lib/brand.ts.template +324 -0
- package/framework/scaffold/saas-dashboard/scaffold.yaml +225 -0
- package/framework/schemas/session.schema.json +5 -0
- package/framework/schemas/task.schema.json +9 -0
- package/framework/scripts/visual-qa.js +101 -3
- package/framework/tasks/orchestrator-health.md +4 -4
- package/framework/tasks/qa-impl-verdict.md +11 -5
- package/framework/templates/brandbook-html-tmpl.md +1 -1
- package/framework/templates/qa-gate-tmpl.yaml +24 -11
- package/package.json +4 -3
- package/src/config/context-file-generator.js +0 -6
- package/src/dashboard/renderer.js +0 -36
- package/src/executors/runner.js +204 -0
- package/src/installer/core.js +13 -3
- package/src/installer/templates.js +5 -6
- package/src/intelligence/registry-manager.js +1 -1
- package/src/license/client.js +9 -3
- package/src/license/commands.js +12 -2
- package/src/license/machine-id.js +42 -1
- package/src/memory/gotchas.js +58 -40
- package/src/memory/magic-docs.js +1 -1
- package/src/memory/session-digest.js +9 -4
- package/src/orchestrator/cli.js +186 -15
- package/src/orchestrator/doctor.js +98 -16
- package/src/orchestrator/pipeline-manager.js +44 -22
- package/src/orchestrator/session-manager.js +64 -8
- package/src/telemetry/sender.js +7 -19
- package/src/terminal/run-team.js +3 -3
- package/src/upgrade/tracked-files-detector.js +34 -16
- package/src/utils/feature-flags.js +1 -1
- package/src/utils/flatten-entities.js +4 -40
- package/src/utils/schema-validator.js +0 -14
- package/src/wizard/i18n.js +3 -3
- package/src/api/index.js +0 -120
- package/src/autonomy/autonomous-gate.js +0 -294
- package/src/autonomy/build-loop.js +0 -281
- package/src/autonomy/build-state.js +0 -286
- package/src/autonomy/cause-analyzer.js +0 -177
- package/src/autonomy/escalation.js +0 -214
- package/src/autonomy/index.js +0 -51
- package/src/autonomy/mode-manager.js +0 -225
- package/src/autonomy/mode-suggester.js +0 -283
- package/src/autonomy/progress-reporter.js +0 -275
- package/src/autonomy/safety-net.js +0 -370
- package/src/config/agent-customizer.js +0 -231
- package/src/decision/analyzer.js +0 -291
- package/src/decision/engine.js +0 -250
- package/src/decision/index.js +0 -38
- package/src/decision/registry-healer.js +0 -468
- package/src/decision/registry-updater.js +0 -339
- package/src/extensions/loader.js +0 -145
- package/src/extensions/registry.js +0 -134
- package/src/gates/circuit-breaker.js +0 -151
- package/src/gates/g1-planning-complete.js +0 -154
- package/src/gates/g2-qa-planning.js +0 -156
- package/src/gates/g3-implementation.js +0 -215
- package/src/gates/g4-qa-implementation.js +0 -240
- package/src/gates/g5-deploy-ready.js +0 -181
- package/src/gates/gate-base.js +0 -185
- package/src/gates/index.js +0 -46
- package/src/health/auto-fix.js +0 -216
- package/src/health/engine.js +0 -246
- package/src/merger/semantic-merger.js +0 -292
- package/src/preview/detector.js +0 -238
- package/src/preview/index.js +0 -20
- package/src/preview/launcher.js +0 -235
- package/src/preview/log-buffer.js +0 -103
- package/src/quality/metrics-collector.js +0 -281
- package/src/quality/test-runner.js +0 -366
- package/src/tasks/executor.js +0 -195
- package/src/tasks/index.js +0 -4
- package/src/tasks/loader.js +0 -210
- package/src/tasks/router.js +0 -182
- 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">×</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
|
+
}
|