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,186 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Breadcrumb.tsx -- Breadcrumb Navigation
|
|
3
|
+
* ============================================================
|
|
4
|
+
* WHY THIS FILE EXISTS
|
|
5
|
+
* --------------------
|
|
6
|
+
* The breadcrumb provides orientation within the application
|
|
7
|
+
* hierarchy. It answers the question "where am I?" for both
|
|
8
|
+
* sighted users and screen reader users, and provides one-click
|
|
9
|
+
* navigation to parent routes.
|
|
10
|
+
*
|
|
11
|
+
* DERIVATION FROM PATHNAME
|
|
12
|
+
* ------------------------
|
|
13
|
+
* Crumbs are derived from Next.js usePathname() by splitting
|
|
14
|
+
* the path segments and capitalizing them. This is a reasonable
|
|
15
|
+
* default for a preset; real applications would provide a route
|
|
16
|
+
* label map (e.g., '/customers/acme-corp' -> 'Acme Corp') for
|
|
17
|
+
* human-readable crumb labels.
|
|
18
|
+
*
|
|
19
|
+
* A static routeLabels map is provided for the preset's known
|
|
20
|
+
* routes. Unmapped segments fall back to title-cased segment.
|
|
21
|
+
*
|
|
22
|
+
* TRUNCATION
|
|
23
|
+
* ----------
|
|
24
|
+
* Long paths are truncated in the middle on narrow viewports.
|
|
25
|
+
* Only the first and last crumbs are always shown; middle crumbs
|
|
26
|
+
* are replaced by an ellipsis on viewports < 480px. This is done
|
|
27
|
+
* via CSS (the middle <li> elements receive a responsive display
|
|
28
|
+
* class) so no JavaScript is involved in the truncation logic.
|
|
29
|
+
*
|
|
30
|
+
* ACCESSIBILITY
|
|
31
|
+
* -------------
|
|
32
|
+
* - Wrapped in <nav aria-label="Breadcrumb"> (navigation landmark)
|
|
33
|
+
* - Ordered list <ol> communicates the hierarchy to SR
|
|
34
|
+
* - Separators are aria-hidden spans (not bullet characters)
|
|
35
|
+
* - Current page item has aria-current="page" and no link
|
|
36
|
+
* - The home crumb has a distinct aria-label ("Home")
|
|
37
|
+
*
|
|
38
|
+
* SEPARATOR CHOICE
|
|
39
|
+
* ----------------
|
|
40
|
+
* We use a forward slash (/) as the separator, styled as a
|
|
41
|
+
* muted text-muted span. This is preferable to CSS-generated
|
|
42
|
+
* content (::before) because it is predictably positioned
|
|
43
|
+
* within the flex row without z-index or overflow concerns.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
'use client'
|
|
47
|
+
|
|
48
|
+
import Link from 'next/link'
|
|
49
|
+
import { usePathname } from 'next/navigation'
|
|
50
|
+
import { useMemo } from 'react'
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Route label map
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Maps path segments to human-readable labels for the preset's known routes.
|
|
56
|
+
// Add entries here for any route whose segment slug is not human-readable.
|
|
57
|
+
const ROUTE_LABELS: Record<string, string> = {
|
|
58
|
+
dashboard: 'Dashboard',
|
|
59
|
+
analytics: 'Analytics',
|
|
60
|
+
customers: 'Customers',
|
|
61
|
+
documents: 'Documents',
|
|
62
|
+
billing: 'Billing',
|
|
63
|
+
notifications: 'Notifications',
|
|
64
|
+
settings: 'Settings',
|
|
65
|
+
help: 'Help & Docs',
|
|
66
|
+
profile: 'Profile',
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Types
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
interface CrumbItem {
|
|
74
|
+
label: string
|
|
75
|
+
href: string
|
|
76
|
+
isCurrent: boolean
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Helpers
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
function segmentToLabel(segment: string): string {
|
|
84
|
+
if (ROUTE_LABELS[segment]) return ROUTE_LABELS[segment]
|
|
85
|
+
// Title-case the segment, replacing hyphens with spaces.
|
|
86
|
+
return segment
|
|
87
|
+
.replace(/-/g, ' ')
|
|
88
|
+
.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function buildCrumbs(pathname: string): CrumbItem[] {
|
|
92
|
+
const segments = pathname.split('/').filter(Boolean)
|
|
93
|
+
|
|
94
|
+
if (segments.length === 0) {
|
|
95
|
+
return [{ label: 'Home', href: '/', isCurrent: true }]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const crumbs: CrumbItem[] = []
|
|
99
|
+
let cumulativePath = ''
|
|
100
|
+
|
|
101
|
+
segments.forEach((segment, index) => {
|
|
102
|
+
cumulativePath += '/' + segment
|
|
103
|
+
crumbs.push({
|
|
104
|
+
label: segmentToLabel(segment),
|
|
105
|
+
href: cumulativePath,
|
|
106
|
+
isCurrent: index === segments.length - 1,
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
return crumbs
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Component
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
export function Breadcrumb() {
|
|
118
|
+
const pathname = usePathname()
|
|
119
|
+
|
|
120
|
+
const crumbs = useMemo(() => buildCrumbs(pathname), [pathname])
|
|
121
|
+
|
|
122
|
+
// Single crumb (root): show nothing or just the page title.
|
|
123
|
+
// Breadcrumbs only provide value when there is more than one level.
|
|
124
|
+
if (crumbs.length <= 1) {
|
|
125
|
+
return (
|
|
126
|
+
<nav aria-label="Breadcrumb" className="breadcrumb">
|
|
127
|
+
<ol role="list" className="breadcrumb-list">
|
|
128
|
+
<li className="breadcrumb-item breadcrumb-item--current">
|
|
129
|
+
<span aria-current="page" className="breadcrumb-current">
|
|
130
|
+
{crumbs[0]?.label ?? 'Dashboard'}
|
|
131
|
+
</span>
|
|
132
|
+
</li>
|
|
133
|
+
</ol>
|
|
134
|
+
</nav>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<nav aria-label="Breadcrumb" className="breadcrumb">
|
|
140
|
+
<ol role="list" className="breadcrumb-list">
|
|
141
|
+
{crumbs.map((crumb, index) => {
|
|
142
|
+
const isFirst = index === 0
|
|
143
|
+
const isLast = crumb.isCurrent
|
|
144
|
+
// Middle crumbs (not first, not last) are hidden on very narrow viewports.
|
|
145
|
+
const isMiddle = !isFirst && !isLast
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<li
|
|
149
|
+
key={crumb.href}
|
|
150
|
+
className={[
|
|
151
|
+
'breadcrumb-item',
|
|
152
|
+
isMiddle ? 'breadcrumb-item--middle' : '',
|
|
153
|
+
isLast ? 'breadcrumb-item--current' : '',
|
|
154
|
+
]
|
|
155
|
+
.filter(Boolean)
|
|
156
|
+
.join(' ')}
|
|
157
|
+
>
|
|
158
|
+
{/* Separator before every item except the first */}
|
|
159
|
+
{!isFirst && (
|
|
160
|
+
<span
|
|
161
|
+
className="breadcrumb-separator"
|
|
162
|
+
aria-hidden="true"
|
|
163
|
+
>
|
|
164
|
+
/
|
|
165
|
+
</span>
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
{isLast ? (
|
|
169
|
+
<span
|
|
170
|
+
aria-current="page"
|
|
171
|
+
className="breadcrumb-current"
|
|
172
|
+
>
|
|
173
|
+
{crumb.label}
|
|
174
|
+
</span>
|
|
175
|
+
) : (
|
|
176
|
+
<Link href={crumb.href} className="breadcrumb-link">
|
|
177
|
+
{crumb.label}
|
|
178
|
+
</Link>
|
|
179
|
+
)}
|
|
180
|
+
</li>
|
|
181
|
+
)
|
|
182
|
+
})}
|
|
183
|
+
</ol>
|
|
184
|
+
</nav>
|
|
185
|
+
)
|
|
186
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Container.tsx -- Page Content Width Authority
|
|
3
|
+
* ============================================================
|
|
4
|
+
* WHY THIS FILE EXISTS
|
|
5
|
+
* --------------------
|
|
6
|
+
* The Container component is the SOLE authority for two
|
|
7
|
+
* layout concerns that must be consistent across every page:
|
|
8
|
+
*
|
|
9
|
+
* 1. Max-width: 1280px. This keeps content readable on wide
|
|
10
|
+
* monitors without requiring the user to pan left-right.
|
|
11
|
+
* 1280px is chosen because, at the typical dashboard
|
|
12
|
+
* sidebar-expanded width (240px), the content area is
|
|
13
|
+
* 1040px -- a comfortable width for a two-column data
|
|
14
|
+
* layout without feeling too spacious.
|
|
15
|
+
*
|
|
16
|
+
* 2. Horizontal padding: --spacing-page-edge
|
|
17
|
+
* (clamp(1.5rem, 4vw, 2.5rem) = 24px to 40px).
|
|
18
|
+
* Fluid padding scales with viewport so narrow viewports
|
|
19
|
+
* get a compact 24px margin and wide viewports get a
|
|
20
|
+
* comfortable 40px. The asymmetric widening at large sizes
|
|
21
|
+
* gives the content breathing room without a hard jump.
|
|
22
|
+
*
|
|
23
|
+
* WHAT CONTAINER DOES NOT DO
|
|
24
|
+
* --------------------------
|
|
25
|
+
* - It does not set vertical spacing (callers use className).
|
|
26
|
+
* - It does not impose a grid or flex layout on its children.
|
|
27
|
+
* - It does not know about the sidebar or topbar.
|
|
28
|
+
*
|
|
29
|
+
* USAGE PATTERN
|
|
30
|
+
* -------------
|
|
31
|
+
* Every page should wrap its primary content in a Container.
|
|
32
|
+
* The AppShell's <main> element intentionally has no padding
|
|
33
|
+
* of its own: Container applies it. This separation means
|
|
34
|
+
* full-bleed sections (hero images, full-width tables) can be
|
|
35
|
+
* placed outside Container and still sit within <main>.
|
|
36
|
+
*
|
|
37
|
+
* Example:
|
|
38
|
+
* <main className="shell-content">
|
|
39
|
+
* <Container className="py-8">
|
|
40
|
+
* <StatCardGrid>...</StatCardGrid>
|
|
41
|
+
* <AreaChart ... />
|
|
42
|
+
* </Container>
|
|
43
|
+
* </main>
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import React, { type HTMLAttributes, forwardRef } from 'react'
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Types
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
export interface ContainerProps extends HTMLAttributes<HTMLDivElement> {
|
|
53
|
+
/**
|
|
54
|
+
* Additional class names for vertical spacing and other
|
|
55
|
+
* layout overrides. Horizontal padding and max-width are
|
|
56
|
+
* managed internally and cannot be overridden via className.
|
|
57
|
+
*/
|
|
58
|
+
className?: string
|
|
59
|
+
children: React.ReactNode
|
|
60
|
+
/**
|
|
61
|
+
* Render as a different HTML element. Useful for <section>
|
|
62
|
+
* or <article> when semantic correctness is needed.
|
|
63
|
+
* Defaults to 'div'.
|
|
64
|
+
*/
|
|
65
|
+
as?: 'div' | 'section' | 'article'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Component
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
export const Container = forwardRef<HTMLDivElement, ContainerProps>(
|
|
73
|
+
function Container({ className, children, as: Tag = 'div', ...rest }, ref) {
|
|
74
|
+
return (
|
|
75
|
+
<Tag
|
|
76
|
+
ref={ref as React.Ref<HTMLDivElement>}
|
|
77
|
+
className={['container', className].filter(Boolean).join(' ')}
|
|
78
|
+
{...rest}
|
|
79
|
+
>
|
|
80
|
+
{children}
|
|
81
|
+
</Tag>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
Container.displayName = 'Container'
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* NavItem.tsx -- Sidebar Navigation Item
|
|
3
|
+
* ============================================================
|
|
4
|
+
* WHY THIS FILE EXISTS
|
|
5
|
+
* --------------------
|
|
6
|
+
* The sidebar NavItem is the atomic interactive unit within
|
|
7
|
+
* the navigation. It is extracted from Sidebar.tsx into its
|
|
8
|
+
* own file for two reasons:
|
|
9
|
+
*
|
|
10
|
+
* 1. Testability: isolated props make the component trivially
|
|
11
|
+
* unit-testable without mounting the entire Sidebar.
|
|
12
|
+
*
|
|
13
|
+
* 2. Reuse: the same item pattern (icon + label + badge +
|
|
14
|
+
* active state) could appear in a mobile bottom nav or a
|
|
15
|
+
* settings sidebar, both of which would import NavItem.
|
|
16
|
+
*
|
|
17
|
+
* ACTIVE STATE
|
|
18
|
+
* ------------
|
|
19
|
+
* The active state is communicated in three ways:
|
|
20
|
+
* - Visually: bg-highlight background + text-accent color +
|
|
21
|
+
* 3px accent-colored left border.
|
|
22
|
+
* - Semantically: aria-current="page" on the link element.
|
|
23
|
+
* - Structurally: the CSS class sidebar-nav-item--active.
|
|
24
|
+
*
|
|
25
|
+
* COLLAPSE BEHAVIOR
|
|
26
|
+
* -----------------
|
|
27
|
+
* When isCollapsed is true:
|
|
28
|
+
* - The label text span opacity transitions to 0 and
|
|
29
|
+
* max-width transitions to 0 (see note in Sidebar.tsx about
|
|
30
|
+
* this max-width exception).
|
|
31
|
+
* - A browser-native tooltip (title attribute) provides the
|
|
32
|
+
* label on hover for mouse users.
|
|
33
|
+
* - A sr-only span is present for keyboard/SR users.
|
|
34
|
+
* - Badges collapse to a dot indicator (no number shown).
|
|
35
|
+
*
|
|
36
|
+
* MOTION
|
|
37
|
+
* ------
|
|
38
|
+
* We use framer-motion's `layout` prop on the container to
|
|
39
|
+
* animate the width change smoothly when the sidebar
|
|
40
|
+
* transitions between collapsed and expanded. The label uses
|
|
41
|
+
* an AnimatePresence for opacity-based exit/enter.
|
|
42
|
+
*
|
|
43
|
+
* FOCUS RING
|
|
44
|
+
* ----------
|
|
45
|
+
* The global :focus-visible rule in globals.css provides the
|
|
46
|
+
* focus ring. NavItem does not add its own outline; it relies
|
|
47
|
+
* on the global rule so focus rings are consistent across all
|
|
48
|
+
* interactive elements.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import Link from 'next/link'
|
|
52
|
+
import { type LucideIcon } from 'lucide-react'
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Types
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
export interface NavItemProps {
|
|
59
|
+
/** Navigation target */
|
|
60
|
+
href: string
|
|
61
|
+
/** Human-readable label; shown when expanded, sr-only when collapsed */
|
|
62
|
+
label: string
|
|
63
|
+
/** Lucide icon component */
|
|
64
|
+
icon: LucideIcon
|
|
65
|
+
/** Unread count badge; shows as dot when sidebar is collapsed */
|
|
66
|
+
badge?: number
|
|
67
|
+
/** Whether this item matches the current route */
|
|
68
|
+
isActive?: boolean
|
|
69
|
+
/** Whether the sidebar is in icon-only mode */
|
|
70
|
+
isCollapsed?: boolean
|
|
71
|
+
/** Called when the item is clicked (for closing mobile drawer) */
|
|
72
|
+
onClick?: () => void
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Component
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
export function NavItem({
|
|
80
|
+
href,
|
|
81
|
+
label,
|
|
82
|
+
icon: Icon,
|
|
83
|
+
badge,
|
|
84
|
+
isActive = false,
|
|
85
|
+
isCollapsed = false,
|
|
86
|
+
onClick,
|
|
87
|
+
}: NavItemProps) {
|
|
88
|
+
return (
|
|
89
|
+
<Link
|
|
90
|
+
href={href}
|
|
91
|
+
onClick={onClick}
|
|
92
|
+
aria-current={isActive ? 'page' : undefined}
|
|
93
|
+
title={isCollapsed ? label : undefined}
|
|
94
|
+
className={[
|
|
95
|
+
'sidebar-nav-item',
|
|
96
|
+
isActive ? 'sidebar-nav-item--active' : '',
|
|
97
|
+
]
|
|
98
|
+
.filter(Boolean)
|
|
99
|
+
.join(' ')}
|
|
100
|
+
>
|
|
101
|
+
{/* Icon: always visible, always decorative (label provides the name) */}
|
|
102
|
+
<span className="sidebar-nav-icon" aria-hidden="true">
|
|
103
|
+
<Icon size={18} strokeWidth={1.75} />
|
|
104
|
+
</span>
|
|
105
|
+
|
|
106
|
+
{/*
|
|
107
|
+
* Label: visible text when expanded.
|
|
108
|
+
* aria-hidden when collapsed because the title attribute
|
|
109
|
+
* and the sr-only span provide the accessible name.
|
|
110
|
+
*/}
|
|
111
|
+
<span
|
|
112
|
+
className={[
|
|
113
|
+
'sidebar-nav-label',
|
|
114
|
+
isCollapsed ? 'sidebar-nav-label--hidden' : '',
|
|
115
|
+
]
|
|
116
|
+
.filter(Boolean)
|
|
117
|
+
.join(' ')}
|
|
118
|
+
aria-hidden={isCollapsed}
|
|
119
|
+
>
|
|
120
|
+
{label}
|
|
121
|
+
</span>
|
|
122
|
+
|
|
123
|
+
{/* Always-readable SR label (collapsed state only) */}
|
|
124
|
+
{isCollapsed && <span className="sr-only">{label}</span>}
|
|
125
|
+
|
|
126
|
+
{/* Badge: numeric count when expanded, dot when collapsed */}
|
|
127
|
+
{badge !== undefined && (
|
|
128
|
+
<>
|
|
129
|
+
{!isCollapsed && (
|
|
130
|
+
<span
|
|
131
|
+
className="sidebar-nav-badge"
|
|
132
|
+
aria-label={`${badge} unread`}
|
|
133
|
+
>
|
|
134
|
+
{badge > 99 ? '99+' : badge}
|
|
135
|
+
</span>
|
|
136
|
+
)}
|
|
137
|
+
{isCollapsed && (
|
|
138
|
+
<span
|
|
139
|
+
className="sidebar-nav-badge-dot"
|
|
140
|
+
aria-hidden="true"
|
|
141
|
+
/>
|
|
142
|
+
)}
|
|
143
|
+
</>
|
|
144
|
+
)}
|
|
145
|
+
</Link>
|
|
146
|
+
)
|
|
147
|
+
}
|