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,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
+ }