canon-ds 0.1.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 (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +153 -0
  3. package/THIRD_PARTY_NOTICES.md +55 -0
  4. package/bin/canon.js +6 -0
  5. package/lib/build-manifest.mjs +54 -0
  6. package/lib/build.mjs +69 -0
  7. package/lib/cli.mjs +273 -0
  8. package/lib/color.js +160 -0
  9. package/lib/components/_shared.mjs +93 -0
  10. package/lib/components/accordion.mjs +136 -0
  11. package/lib/components/account-card.mjs +166 -0
  12. package/lib/components/activity-feed.mjs +176 -0
  13. package/lib/components/activity-gauge.mjs +98 -0
  14. package/lib/components/agent-presence.mjs +156 -0
  15. package/lib/components/alert.mjs +154 -0
  16. package/lib/components/app-store-button.mjs +108 -0
  17. package/lib/components/avatar-group.mjs +128 -0
  18. package/lib/components/avatar.mjs +225 -0
  19. package/lib/components/badge-group.mjs +55 -0
  20. package/lib/components/badge.mjs +174 -0
  21. package/lib/components/banner.mjs +116 -0
  22. package/lib/components/breadcrumb.mjs +170 -0
  23. package/lib/components/button-group.mjs +114 -0
  24. package/lib/components/button.mjs +197 -0
  25. package/lib/components/card-header.mjs +115 -0
  26. package/lib/components/card.mjs +106 -0
  27. package/lib/components/carousel.mjs +182 -0
  28. package/lib/components/chart-frame.mjs +101 -0
  29. package/lib/components/checkbox.mjs +163 -0
  30. package/lib/components/close-button.mjs +49 -0
  31. package/lib/components/code.mjs +123 -0
  32. package/lib/components/color-picker.mjs +122 -0
  33. package/lib/components/combobox.mjs +210 -0
  34. package/lib/components/command-palette.mjs +226 -0
  35. package/lib/components/content-divider.mjs +104 -0
  36. package/lib/components/counter.mjs +86 -0
  37. package/lib/components/credit-card.mjs +126 -0
  38. package/lib/components/date-picker.mjs +202 -0
  39. package/lib/components/description-list.mjs +88 -0
  40. package/lib/components/dialog.mjs +140 -0
  41. package/lib/components/divider.mjs +115 -0
  42. package/lib/components/drawer.mjs +150 -0
  43. package/lib/components/empty-state.mjs +112 -0
  44. package/lib/components/featured-card.mjs +124 -0
  45. package/lib/components/featured-icon.mjs +67 -0
  46. package/lib/components/field.mjs +122 -0
  47. package/lib/components/file-dropzone.mjs +166 -0
  48. package/lib/components/filter-bar.mjs +179 -0
  49. package/lib/components/header-navigation.mjs +94 -0
  50. package/lib/components/icon-button.mjs +179 -0
  51. package/lib/components/index.mjs +52 -0
  52. package/lib/components/inline-cta.mjs +139 -0
  53. package/lib/components/input-group.mjs +164 -0
  54. package/lib/components/input.mjs +197 -0
  55. package/lib/components/kanban.mjs +179 -0
  56. package/lib/components/kbd.mjs +70 -0
  57. package/lib/components/kicker.mjs +79 -0
  58. package/lib/components/link.mjs +108 -0
  59. package/lib/components/list.mjs +117 -0
  60. package/lib/components/media-frame.mjs +119 -0
  61. package/lib/components/menu.mjs +190 -0
  62. package/lib/components/message.mjs +249 -0
  63. package/lib/components/metric-group.mjs +210 -0
  64. package/lib/components/mobile-header.mjs +173 -0
  65. package/lib/components/multi-select.mjs +218 -0
  66. package/lib/components/notification.mjs +175 -0
  67. package/lib/components/number-input.mjs +149 -0
  68. package/lib/components/page-header.mjs +68 -0
  69. package/lib/components/pagination.mjs +236 -0
  70. package/lib/components/pin-input.mjs +120 -0
  71. package/lib/components/popover.mjs +126 -0
  72. package/lib/components/progress-circle.mjs +115 -0
  73. package/lib/components/progress.mjs +93 -0
  74. package/lib/components/prose.mjs +96 -0
  75. package/lib/components/radio.mjs +160 -0
  76. package/lib/components/rating.mjs +93 -0
  77. package/lib/components/rich-text-editor.mjs +178 -0
  78. package/lib/components/section-header.mjs +62 -0
  79. package/lib/components/segmented-control.mjs +121 -0
  80. package/lib/components/select.mjs +163 -0
  81. package/lib/components/sidebar-nav.mjs +172 -0
  82. package/lib/components/sidebar.mjs +124 -0
  83. package/lib/components/skeleton.mjs +66 -0
  84. package/lib/components/slider.mjs +122 -0
  85. package/lib/components/social-button.mjs +119 -0
  86. package/lib/components/spinner.mjs +69 -0
  87. package/lib/components/stat.mjs +120 -0
  88. package/lib/components/stepper.mjs +228 -0
  89. package/lib/components/switch.mjs +159 -0
  90. package/lib/components/table.mjs +212 -0
  91. package/lib/components/tabs.mjs +207 -0
  92. package/lib/components/tag.mjs +196 -0
  93. package/lib/components/tags-input.mjs +146 -0
  94. package/lib/components/textarea.mjs +117 -0
  95. package/lib/components/timeline.mjs +135 -0
  96. package/lib/components/toast.mjs +115 -0
  97. package/lib/components/tooltip.mjs +86 -0
  98. package/lib/components/topbar.mjs +207 -0
  99. package/lib/components/tree-view.mjs +156 -0
  100. package/lib/components/video-player.mjs +189 -0
  101. package/lib/connect.mjs +110 -0
  102. package/lib/design-files.mjs +175 -0
  103. package/lib/distribution.mjs +109 -0
  104. package/lib/editor.js +940 -0
  105. package/lib/engine.js +339 -0
  106. package/lib/generators/agents.mjs +68 -0
  107. package/lib/generators/connection.md +58 -0
  108. package/lib/generators/css.mjs +2 -0
  109. package/lib/generators/designmd.mjs +324 -0
  110. package/lib/generators/documentation.css +82 -0
  111. package/lib/generators/documentation.html +81 -0
  112. package/lib/generators/documentation.js +96 -0
  113. package/lib/generators/documentation.mjs +35 -0
  114. package/lib/generators/dtcg.mjs +60 -0
  115. package/lib/generators/preview.css +279 -0
  116. package/lib/generators/preview.mjs +225 -0
  117. package/lib/generators/react.mjs +162 -0
  118. package/lib/generators/tailwind.mjs +114 -0
  119. package/lib/install.mjs +108 -0
  120. package/lib/lint.mjs +538 -0
  121. package/lib/mcp.mjs +268 -0
  122. package/lib/open.mjs +7 -0
  123. package/lib/patterns/_app.mjs +56 -0
  124. package/lib/patterns/app-shell.mjs +25 -0
  125. package/lib/patterns/audit-log.mjs +37 -0
  126. package/lib/patterns/auth-pages.mjs +44 -0
  127. package/lib/patterns/billing-page.mjs +39 -0
  128. package/lib/patterns/calendar-page.mjs +55 -0
  129. package/lib/patterns/dashboard-page.mjs +28 -0
  130. package/lib/patterns/files-page.mjs +30 -0
  131. package/lib/patterns/first-run.mjs +31 -0
  132. package/lib/patterns/form-layout.mjs +24 -0
  133. package/lib/patterns/inbox-page.mjs +58 -0
  134. package/lib/patterns/index.mjs +29 -0
  135. package/lib/patterns/list-detail-page.mjs +24 -0
  136. package/lib/patterns/mobile-app.mjs +46 -0
  137. package/lib/patterns/modal-flows.mjs +32 -0
  138. package/lib/patterns/onboarding-page.mjs +42 -0
  139. package/lib/patterns/permissions-page.mjs +38 -0
  140. package/lib/patterns/profile-page.mjs +32 -0
  141. package/lib/patterns/settings-page.mjs +24 -0
  142. package/lib/project.mjs +28 -0
  143. package/lib/serve.mjs +279 -0
  144. package/lib/system.mjs +210 -0
  145. package/lib/tokens/base.js +251 -0
  146. package/lib/tokens/canon-preset.mjs +67 -0
  147. package/lib/tokens/index.mjs +1 -0
  148. package/lib/tokens/legacy-canon.json +559 -0
  149. package/lib/tokens/presets.mjs +161 -0
  150. package/lib/tokens/resolve.mjs +1 -0
  151. package/lib/types.mjs +4 -0
  152. package/lib/version.mjs +2 -0
  153. package/package.json +54 -0
@@ -0,0 +1,210 @@
1
+ import { ex, ICON, typeStyle } from "./_shared.mjs";
2
+ // Metric group: the metric-card family as one self-contained component. A grid
3
+ // of cards (label, big value, soft trend pill with a 12px arrow, "vs last month"
4
+ // note) that can carry a featured icon, a 64px sparkline, a ⋯ menu or a
5
+ // "View report" link under a divider, a progress bar, or collapse into a list.
6
+ const HAIRLINE = '{border.width.thin} solid {color.border-default}';
7
+ const ARROW = {
8
+ up: '<svg class="cn-metric-group__trend-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 13V3M4 7l4-4 4 4"/></svg>',
9
+ down: '<svg class="cn-metric-group__trend-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3v10M4 9l4 4 4-4"/></svg>',
10
+ flat: '<svg class="cn-metric-group__trend-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" aria-hidden="true"><path d="M3 8h10"/></svg>',
11
+ };
12
+ const SVG = {
13
+ users: '<svg class="cn-featured-icon__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><circle cx="6" cy="5.5" r="2.5"/><path d="M1.75 13c.5-2.4 2.2-3.75 4.25-3.75S9.75 10.6 10.25 13M10.5 3.25a2.5 2.5 0 010 4.5M11.5 9.5c1.6.4 2.5 1.6 2.75 3.5"/></svg>',
14
+ wallet: '<svg class="cn-featured-icon__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.5" y="4.5" width="11" height="8" rx="1.5"/><path d="M2.5 7.5h11M5 10.5h2"/></svg>',
15
+ clock: '<svg class="cn-featured-icon__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6"/><path d="M8 5v3l2 1.5"/></svg>',
16
+ };
17
+ /** 128×64 sparkline. `d` is the line; the area closes it to the baseline. */
18
+ const spark = (d) => `<div class="cn-metric-group__chart" aria-hidden="true"><svg viewBox="0 0 128 64" preserveAspectRatio="none"><path data-area d="${d} V64 H0 Z"/><path d="${d}"/></svg></div>`;
19
+ const LINES = [
20
+ 'M0 44 L16 40 L32 46 L48 34 L64 36 L80 24 L96 28 L112 18 L128 12',
21
+ 'M0 38 L16 42 L32 36 L48 40 L64 30 L80 32 L96 22 L112 26 L128 16',
22
+ 'M0 16 L16 22 L32 18 L48 30 L64 28 L80 40 L96 36 L112 46 L128 50',
23
+ ];
24
+ const featured = (svg, tone = 'brand') => `<span class="cn-metric-group__icon"><span class="cn-featured-icon" data-theme="light" data-tone="${tone}" data-size="md" data-shape="square">${svg}</span></span>`;
25
+ const trend = (dir, text, label = 'vs last month', sentiment) => `<div class="cn-metric-group__delta"><span class="cn-metric-group__trend" data-trend="${dir}"${sentiment ? ` data-sentiment="${sentiment}"` : ''}>${ARROW[dir]}${text}</span><span class="cn-metric-group__trend-label">${label}</span></div>`;
26
+ const MORE = `<div class="cn-metric-group__actions"><button type="button" class="cn-icon-button" data-variant="ghost" data-size="sm" data-shape="square" aria-label="More actions" aria-haspopup="menu">${ICON.dots.replace('cn-icon', 'cn-icon-button__icon')}</button></div>`;
27
+ const REPORT = `<div class="cn-metric-group__divider" role="separator"></div><div class="cn-metric-group__actions"><button type="button" class="cn-button" data-variant="link-color" data-size="sm"><span class="cn-button__label">View report</span></button></div>`;
28
+ const progress = (id, label, value, pct, max, tone = 'action') => `<div class="cn-metric-group__progress"><div class="cn-progress" data-size="md" data-tone="${tone}" role="progressbar" aria-labelledby="${id}" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="${max}"><div class="cn-progress__header"><span class="cn-progress__label" id="${id}">${label}</span><span class="cn-progress__value">${value}</span></div><div class="cn-progress__track"><div class="cn-progress__indicator" style="width:${Math.round((pct / max) * 100)}%"></div></div></div></div>`;
29
+ const item = (o) => `<div class="cn-metric-group__item"><div class="cn-metric-group__header">${o.icon ?? ''}<span class="cn-metric-group__label">${o.label}</span>${o.actions ?? ''}</div><div class="cn-metric-group__body"><span class="cn-metric-group__value">${o.value}</span>${o.chart ?? ''}</div>${o.trend ?? ''}${o.extra ?? ''}</div>`;
30
+ const grid = (variant, columns, inner) => `<div class="cn-metric-group" data-variant="${variant}" data-columns="${columns}" role="group" aria-label="Key metrics">${inner}</div>`;
31
+ export const metricGroup = {
32
+ name: 'MetricGroup',
33
+ slug: 'metric-group',
34
+ category: 'data-display',
35
+ description: 'The metric-card family as one component: a responsive grid of surface cards with a label, a heading-lg tabular value, a soft success/danger trend pill with a 12px arrow and a "vs last month" note. Cards can add a featured icon, a 64px sparkline, a ⋯ menu or a "View report" link under a divider, or a progress bar; the list variant stacks the same items as rows in one card.',
36
+ usage: 'Use at the top of a dashboard or report page to summarize 2–4 numbers with their change. For a single KPI use Stat; for a chart with axes use ChartFrame; for counts in navigation use Counter.',
37
+ anatomy: [
38
+ { part: 'root', element: 'div', description: 'The grid (role="group", aria-label): 24px gaps, 2–4 equal columns that collapse to two below the lg breakpoint and one below md.' },
39
+ { part: 'item', element: 'div', description: 'One metric card: bg-surface, hairline, radius card, 20px padding, shadow-xs; a flex column with 16px gaps.' },
40
+ { part: 'header', element: 'div', description: 'Top row: optional featured icon, the label, and the optional ⋯ actions pushed right.' },
41
+ { part: 'icon', element: 'span', description: 'Wrapper for a 40px FeaturedIcon (light theme) before the label.', optional: true },
42
+ { part: 'label', element: 'span', description: 'What is measured, label-sm in fg-default ("Monthly recurring revenue").' },
43
+ { part: 'actions', element: 'div', description: 'Right-aligned actions: a ghost sm IconButton (⋯) in the header, or a link-color sm "View report" Button under the divider.', optional: true },
44
+ { part: 'body', element: 'div', description: 'The value on the left and, when present, the sparkline on the right, bottom-aligned.' },
45
+ { part: 'value', element: 'span', description: 'The number in heading-lg semibold with tabular figures; the unit is part of the string ("$48,210", "1.9%").' },
46
+ { part: 'delta', element: 'div', description: 'Row under the value: the trend pill and its label.', optional: true },
47
+ { part: 'trend', element: 'span', description: 'Soft pill: success for up, danger for down, neutral for flat (data-trend); add data-sentiment="good|bad" when the direction and the meaning disagree.', optional: true },
48
+ { part: 'trend-icon', element: 'svg', description: '12px arrow inside the pill matching data-trend.', optional: true },
49
+ { part: 'trend-label', element: 'span', description: 'Period note in body-sm muted ("vs last month").', optional: true },
50
+ { part: 'chart', element: 'div', description: '128×64 sparkline slot; the svg strokes currentColor (bg-action) with a 10% area under the line. Decorative.', optional: true },
51
+ { part: 'progress', element: 'div', description: 'Wrapper for a Progress bar (label + value + track) for quota-style metrics.', optional: true },
52
+ { part: 'divider', element: 'div', description: 'Hairline that bleeds to the card edges above a footer action.', optional: true },
53
+ ],
54
+ props: {
55
+ variant: {
56
+ values: ['simple', 'with-trend', 'with-icon', 'with-chart', 'with-actions', 'with-progress', 'list'],
57
+ default: 'simple',
58
+ description: 'simple = label and value only. with-trend = adds the trend pill and period note (the default dashboard card). with-icon = a 40px FeaturedIcon before the label for a small set of very different metrics. with-chart = a sparkline next to the value for metrics with a shape over time. with-actions = a ⋯ menu in the header or a "View report" link under a divider. with-progress = a Progress bar for used-of-limit metrics (seats, storage). list = the same items as rows inside one card, for side panels and narrow columns.',
59
+ },
60
+ columns: {
61
+ values: ['2', '3', '4'],
62
+ default: '3',
63
+ description: 'Number of equal columns on wide screens: 2 for two hero numbers or cards with charts, 3 by default, 4 for a dense KPI row. Below the lg breakpoint 3 and 4 become 2; below md everything is one column. Ignored by the list variant.',
64
+ },
65
+ },
66
+ states: {
67
+ up: { selector: ' .cn-metric-group__trend[data-trend="up"]', description: 'The value increased (on the trend pill; extraCss): success tint, up arrow.', markup: 'data-trend="up" on .cn-metric-group__trend' },
68
+ down: { selector: ' .cn-metric-group__trend[data-trend="down"]', description: 'The value decreased (extraCss): danger tint, down arrow.', markup: 'data-trend="down" on .cn-metric-group__trend' },
69
+ flat: { selector: ' .cn-metric-group__trend[data-trend="flat"]', description: 'No meaningful change (extraCss): neutral tint, a dash.', markup: 'data-trend="flat" on .cn-metric-group__trend' },
70
+ sentiment: { selector: ' .cn-metric-group__trend[data-sentiment]', description: 'When direction and meaning disagree (churn going down is good): data-sentiment="good|bad" overrides the tint while the arrow keeps the direction (extraCss).', markup: 'data-sentiment="good" or "bad" on .cn-metric-group__trend' },
71
+ },
72
+ base: {
73
+ root: { display: 'grid', 'grid-template-columns': 'repeat(3, minmax(0, 1fr))', gap: '{space.6}', width: '100%', 'min-width': '0', color: '{color.fg-default}' },
74
+ item: {
75
+ position: 'relative',
76
+ display: 'flex',
77
+ 'flex-direction': 'column',
78
+ gap: '{space.4}',
79
+ 'min-width': '0',
80
+ padding: '{space.5}',
81
+ 'background-color': '{color.bg-surface}',
82
+ border: HAIRLINE,
83
+ 'border-radius': '{radius.card}',
84
+ 'box-shadow': '{shadow.xs}',
85
+ },
86
+ header: { display: 'flex', 'align-items': 'center', gap: '{space.3}', 'min-width': '0' },
87
+ icon: { display: 'inline-flex', 'flex-shrink': '0' },
88
+ label: { flex: '1 1 auto', 'min-width': '0', ...typeStyle('label-sm'), 'line-height': '{font.lineHeight.normal}', color: '{color.fg-default}', 'overflow-wrap': 'anywhere' },
89
+ actions: { display: 'flex', 'align-items': 'center', gap: '{space.2}', 'margin-inline-start': 'auto', 'flex-shrink': '0' },
90
+ body: { display: 'flex', 'align-items': 'flex-end', 'justify-content': 'space-between', gap: '{space.4}', 'min-width': '0' },
91
+ value: { ...typeStyle('heading-lg'), 'letter-spacing': '{font.letterSpacing.tight}', 'font-variant-numeric': 'tabular-nums', color: '{color.fg-default}', 'min-width': '0', 'overflow-wrap': 'anywhere' },
92
+ delta: { display: 'flex', 'flex-wrap': 'wrap', 'align-items': 'center', gap: '{space.2}', 'min-width': '0' },
93
+ trend: {
94
+ display: 'inline-flex',
95
+ 'align-items': 'center',
96
+ gap: '{space.1}',
97
+ height: '{space.6}',
98
+ 'padding-inline': '{space.2}',
99
+ 'border-radius': '{radius.full}',
100
+ border: HAIRLINE,
101
+ 'background-color': '{color.bg-subtle}',
102
+ ...typeStyle('label-sm'),
103
+ color: '{color.fg-muted}',
104
+ 'font-variant-numeric': 'tabular-nums',
105
+ 'white-space': 'nowrap',
106
+ },
107
+ 'trend-icon': { width: '{size.icon.xs}', height: '{size.icon.xs}', 'flex-shrink': '0' },
108
+ 'trend-label': { ...typeStyle('body-sm'), color: '{color.fg-muted}', 'white-space': 'nowrap' },
109
+ chart: { flex: '0 0 auto', width: '128px', height: '{space.16}', color: '{color.bg-action}' },
110
+ progress: { width: '100%' },
111
+ divider: { height: '0', 'border-top': HAIRLINE, 'margin-inline': 'calc(-1 * {space.5})' },
112
+ },
113
+ variants: {
114
+ variant: {
115
+ simple: { root: {} },
116
+ 'with-trend': { root: {} },
117
+ 'with-icon': { root: {} },
118
+ 'with-chart': { root: {} },
119
+ 'with-actions': { root: {} },
120
+ 'with-progress': { root: {} },
121
+ list: {
122
+ root: { display: 'flex', 'flex-direction': 'column', gap: '0', 'background-color': '{color.bg-surface}', border: HAIRLINE, 'border-radius': '{radius.card}', 'box-shadow': '{shadow.xs}', overflow: 'hidden' },
123
+ item: { 'flex-direction': 'row', 'align-items': 'center', gap: '{space.4}', padding: '{space.4} {space.5}', border: '0', 'border-radius': '0', 'box-shadow': 'none' },
124
+ header: { flex: '1 1 auto' },
125
+ body: { flex: '0 0 auto', 'align-items': 'center' },
126
+ value: { ...typeStyle('heading-md'), 'letter-spacing': '{font.letterSpacing.tight}' },
127
+ delta: { flex: '0 0 auto', 'flex-wrap': 'nowrap' },
128
+ chart: { width: '96px', height: '{space.10}' },
129
+ },
130
+ },
131
+ columns: {
132
+ '2': { root: { 'grid-template-columns': 'repeat(2, minmax(0, 1fr))' } },
133
+ '3': { root: { 'grid-template-columns': 'repeat(3, minmax(0, 1fr))' } },
134
+ '4': { root: { 'grid-template-columns': 'repeat(4, minmax(0, 1fr))' } },
135
+ },
136
+ },
137
+ extraCss: `
138
+ .cn-metric-group__trend[data-trend="up"] { background-color: {color.bg-success-subtle}; color: {color.fg-success}; border-color: {color.border-success}; }
139
+ .cn-metric-group__trend[data-trend="down"] { background-color: {color.bg-danger-subtle}; color: {color.fg-danger}; border-color: {color.border-danger}; }
140
+ .cn-metric-group__trend[data-trend="flat"] { background-color: {color.bg-subtle}; color: {color.fg-muted}; border-color: {color.border-default}; }
141
+ .cn-metric-group__trend[data-sentiment="good"] { background-color: {color.bg-success-subtle}; color: {color.fg-success}; border-color: {color.border-success}; }
142
+ .cn-metric-group__trend[data-sentiment="bad"] { background-color: {color.bg-danger-subtle}; color: {color.fg-danger}; border-color: {color.border-danger}; }
143
+ .cn-metric-group__chart svg { display: block; width: 100%; height: 100%; overflow: visible; }
144
+ .cn-metric-group__chart path { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
145
+ .cn-metric-group__chart path[data-area] { fill: color-mix(in srgb, currentColor 10%, transparent); stroke: none; }
146
+ .cn-metric-group[data-variant="list"] .cn-metric-group__item + .cn-metric-group__item { border-top: ${HAIRLINE}; }
147
+ @media (max-width: {breakpoint.lg}) { .cn-metric-group[data-columns="3"], .cn-metric-group[data-columns="4"] { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
148
+ @media (max-width: {breakpoint.md}) { .cn-metric-group[data-columns="2"], .cn-metric-group[data-columns="3"], .cn-metric-group[data-columns="4"] { grid-template-columns: minmax(0, 1fr); } }`,
149
+ examples: [
150
+ ex('Simple, three columns', grid('simple', '3', [
151
+ item({ label: 'Monthly recurring revenue', value: '$48,210' }),
152
+ item({ label: 'Active customers', value: '2,318' }),
153
+ item({ label: 'Churn rate', value: '1.9%' }),
154
+ ].join('')), 'Label and value only. The unit is part of the value string.'),
155
+ ex('With trend, four columns', grid('with-trend', '4', [
156
+ item({ label: 'Monthly recurring revenue', value: '$48,210', trend: trend('up', '12%') }),
157
+ item({ label: 'Active customers', value: '2,318', trend: trend('up', '4.3%') }),
158
+ item({ label: 'Churn rate', value: '1.9%', trend: trend('down', '0.4 pts', 'vs last month', 'good') }),
159
+ item({ label: 'Avg. session', value: '6m 12s', trend: trend('flat', '0%') }),
160
+ ].join('')), 'Churn went down, which is good: data-sentiment="good" keeps the down arrow but tints the pill success.'),
161
+ ex('With icon', grid('with-icon', '3', [
162
+ item({ label: 'Open invoices', value: '43', icon: featured(SVG.wallet), trend: trend('up', '6', 'since Monday') }),
163
+ item({ label: 'Paid this month', value: '$128,400', icon: featured(SVG.wallet, 'success'), trend: trend('up', '18%') }),
164
+ item({ label: 'Overdue', value: '7', icon: featured(SVG.clock, 'warning'), trend: trend('down', '3', 'vs last week', 'good') }),
165
+ ].join('')), 'A 40px FeaturedIcon before the label; tone follows the metric, not decoration.'),
166
+ ex('With chart', grid('with-chart', '3', [
167
+ item({ label: 'Sign-ups', value: '1,204', chart: spark(LINES[0]), trend: trend('up', '18%') }),
168
+ item({ label: 'Trial to paid', value: '24.6%', chart: spark(LINES[1]), trend: trend('up', '2.1 pts') }),
169
+ item({ label: 'Support tickets', value: '312', chart: spark(LINES[2]), trend: trend('down', '9%', 'vs last month', 'good') }),
170
+ ].join('')), 'The sparkline sits next to the value, strokes the action color and is decorative.'),
171
+ ex('With actions (menu)', grid('with-actions', '2', [
172
+ item({ label: 'Net revenue retention', value: '112%', actions: MORE, trend: trend('up', '3 pts', 'vs last quarter') }),
173
+ item({ label: 'Payback period', value: '9.4 months', actions: MORE, trend: trend('down', '1.1 months', 'vs last quarter', 'good') }),
174
+ ].join('')), 'A ghost ⋯ IconButton in the header opens a Menu (export, compare, hide).'),
175
+ ex('With actions (View report)', grid('with-actions', '3', [
176
+ item({ label: 'Website visitors', value: '84,120', trend: trend('up', '22%'), extra: REPORT }),
177
+ item({ label: 'Demo requests', value: '318', trend: trend('up', '11%'), extra: REPORT }),
178
+ item({ label: 'Cost per lead', value: '$41.30', trend: trend('up', '5%', 'vs last month', 'bad'), extra: REPORT }),
179
+ ].join('')), 'A hairline bleeds to the card edges above a link-color "View report".'),
180
+ ex('With progress', grid('with-progress', '2', [
181
+ item({ label: 'Seats used', value: '34 / 40', extra: progress('mg-seats', 'Team plan', '85%', 34, 40, 'warning') }),
182
+ item({ label: 'Storage', value: '128 GB', extra: progress('mg-storage', 'of 250 GB', '51%', 128, 250) }),
183
+ ].join('')), 'Used-of-limit metrics carry a Progress bar; warning tone from 80%.'),
184
+ ex('List', `<div style="width:100%;max-width:420px">${grid('list', '3', [
185
+ item({ label: 'Monthly recurring revenue', value: '$48,210', trend: trend('up', '12%', '') }),
186
+ item({ label: 'Active customers', value: '2,318', trend: trend('up', '4.3%', '') }),
187
+ item({ label: 'Churn rate', value: '1.9%', trend: trend('down', '0.4 pts', '', 'good') }),
188
+ item({ label: 'Avg. session', value: '6m 12s', trend: trend('flat', '0%', '') }),
189
+ ].join(''))}</div>`, 'The same items as rows in one card, for side panels and narrow columns.'),
190
+ ],
191
+ rules: [
192
+ 'Two to four metrics per group. A fifth number belongs in a table or a second group with its own heading.',
193
+ 'Labels name the metric in sentence case ("Monthly recurring revenue"), never the value type; values carry their unit in the string and use tabular figures.',
194
+ 'Format numbers for the locale with at most four significant characters plus unit ("$48.2k" beats "$48,210.37" in a card).',
195
+ 'data-trend is the direction of change; when up is bad or down is good (churn, cost, response time), add data-sentiment so the tint tells the truth while the arrow keeps the direction.',
196
+ 'The period note is short and always present with a trend ("vs last month"); a trend without a period is meaningless.',
197
+ 'One variant per group: every card has the same parts, aligned on the same rows. Never mix a sparkline card with a plain one.',
198
+ 'Sparklines are decorative and unlabeled; if the shape matters enough to read, use ChartFrame.',
199
+ 'Actions are one ⋯ menu in the header or one "View report" link under the divider, never both.',
200
+ 'Use list in columns narrower than 420px or inside drawers; on a page use the grid.',
201
+ ],
202
+ a11y: [
203
+ 'The root is role="group" with an aria-label ("Key metrics"); each card reads in order: label, value, trend, note.',
204
+ 'Trend arrows are aria-hidden; the trend text includes the sign or the word ("+12%", "0.4 pts") and the label gives the period, so color is never the only signal.',
205
+ 'Sparklines are aria-hidden; when a chart carries information, give it a text alternative or use ChartFrame.',
206
+ 'The ⋯ button has aria-label "More actions" and aria-haspopup="menu"; "View report" is a real link or button with the metric name available via aria-label when several cards share the text.',
207
+ 'Progress bars use role="progressbar" with aria-valuenow/min/max and a visible value.',
208
+ ],
209
+ related: ['stat', 'featured-icon', 'progress', 'chart-frame', 'icon-button', 'card'],
210
+ };
@@ -0,0 +1,173 @@
1
+ import { FOCUS_RING, RESET_BUTTON, TRANSITION_COLORS, ex, ICON, typeStyle } from "./_shared.mjs";
2
+ // Mobile header: the 64px bar that replaces the Sidebar below the lg breakpoint
3
+ // (logo left, 40px hamburger right, bottom hairline) and the slide-in drawer it
4
+ // opens: logo row, 48px nav items, support/settings and the account card at the
5
+ // bottom. The drawer is rendered statically here (absolute, never fixed).
6
+ const MENU = '<svg class="cn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>';
7
+ const X = '<svg class="cn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>';
8
+ const BELL = '<svg class="cn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 16V11a6 6 0 1112 0v5l1.5 2h-15zM10 20a2 2 0 004 0"/></svg>';
9
+ const LOGOUT = '<svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 13.5h-3a1 1 0 01-1-1v-9a1 1 0 011-1h3M10.5 11l3-3-3-3M13.5 8h-7"/></svg>';
10
+ const LOGO = `<a href="#" class="cn-mobile-header__logo" aria-label="Lumen home"><span class="cn-mobile-header__mark" aria-hidden="true"></span><span class="cn-mobile-header__brand">Lumen</span></a>`;
11
+ const toggle = (open) => `<button type="button" class="cn-mobile-header__toggle" aria-label="${open ? 'Close menu' : 'Open menu'}" aria-expanded="${open}" aria-controls="mh-drawer">${open ? X : MENU}</button>`;
12
+ const navItem = (label, icon, current = false, badge = '') => `<a href="#" class="cn-mobile-header__item"${current ? ' aria-current="page"' : ''}>${icon.replace('cn-icon', 'cn-mobile-header__icon')}<span class="cn-mobile-header__label">${label}</span>${badge ? `<span class="cn-mobile-header__badge">${badge}</span>` : ''}</a>`;
13
+ const ACCOUNT = `<div class="cn-mobile-header__account"><span class="cn-mobile-header__avatar" role="img" aria-label="Maya Chen">MC</span><div class="cn-mobile-header__account-text"><span class="cn-mobile-header__name">Maya Chen</span><span class="cn-mobile-header__email">maya@lumen.co</span></div><button type="button" class="cn-mobile-header__account-action" aria-label="Log out">${LOGOUT}</button></div>`;
14
+ const DRAWER = `<div class="cn-mobile-header__scrim" aria-hidden="true"></div><nav class="cn-mobile-header__drawer" id="mh-drawer" aria-label="Main"><div class="cn-mobile-header__drawer-header">${LOGO}<button type="button" class="cn-close-button" data-size="sm" data-theme="light" aria-label="Close menu">${ICON.x.replace('cn-icon', 'cn-close-button__icon')}</button></div><div class="cn-mobile-header__nav">${navItem('Home', ICON.home)}${navItem('Dashboard', ICON.calendar, true)}${navItem('Projects', ICON.copy)}${navItem('Tasks', ICON.check, false, '10')}${navItem('Reporting', ICON.inbox)}${navItem('Users', ICON.user)}</div><div class="cn-mobile-header__footer">${navItem('Support', ICON.info)}${navItem('Settings', ICON.settings)}${ACCOUNT}</div></nav>`;
15
+ const phone = (inner, tall = false) => `<div style="width:100%;max-width:375px${tall ? ';min-height:640px' : ''}">${inner}</div>`;
16
+ const header = (variant, inner) => `<header class="cn-mobile-header" data-variant="${variant}">${inner}</header>`;
17
+ export const mobileHeader = {
18
+ name: 'MobileHeader',
19
+ slug: 'mobile-header',
20
+ category: 'navigation',
21
+ description: 'The 64px top bar that replaces the Sidebar on small screens: logo on the left, a 40px hamburger on the right, a bottom hairline; plus the 296px drawer it opens with a logo row, 48px navigation items, the support and settings group and the account card at the bottom. The drawer is positioned under the bar (absolute), never fixed.',
22
+ usage: 'Use as the app header below the lg breakpoint, paired with the Sidebar above it. Not for marketing sites (HeaderNavigation has its own mobile menu) and not as a page header inside content (PageHeader).',
23
+ anatomy: [
24
+ { part: 'root', element: 'header', description: 'The 64px bar: flex, space-between, 16px horizontal padding, bg-surface, bottom hairline; relative so the drawer anchors to it.' },
25
+ { part: 'logo', element: 'a', description: 'Link to home holding the mark and the wordmark. aria-label "Lumen home".' },
26
+ { part: 'mark', element: 'span', description: '28px action-colored square mark (replace with the real logo).' },
27
+ { part: 'brand', element: 'span', description: 'The product name, label-md semibold.' },
28
+ { part: 'actions', element: 'div', description: 'Optional cluster before the toggle (search, notifications) as ghost IconButtons.', optional: true },
29
+ { part: 'toggle', element: 'button', description: '40px hamburger button with a 24px icon; aria-expanded and aria-controls point at the drawer; shows an × when open.' },
30
+ { part: 'scrim', element: 'div', description: 'The overlay under the bar that dims the page while the drawer is open (bg-overlay). Hidden when closed.', optional: true },
31
+ { part: 'drawer', element: 'nav', description: 'The 296px panel under the bar: bg-surface, right hairline, shadow-xl, 16px padding, flex column. aria-label "Main". Hidden when closed.', optional: true },
32
+ { part: 'drawer-header', element: 'div', description: 'First row of the drawer: the logo again and a CloseButton.', optional: true },
33
+ { part: 'nav', element: 'div', description: 'Main list of nav items, 4px apart, fills the drawer.', optional: true },
34
+ { part: 'item', element: 'a', description: '48px navigation link: 24px icon, 16px semibold label, optional count. aria-current="page" on the current one.', optional: true },
35
+ { part: 'icon', element: 'svg', description: '24px icon in fg-subtle before the label.', optional: true },
36
+ { part: 'label', element: 'span', description: 'The item text, truncated.', optional: true },
37
+ { part: 'badge', element: 'span', description: 'Trailing count pill (22px, bg-subtle, body-xs).', optional: true },
38
+ { part: 'footer', element: 'div', description: 'Bottom group: Support and Settings items above the account card; top hairline.', optional: true },
39
+ { part: 'account', element: 'div', description: 'Account card: 40px avatar, name + email, log-out button, in a hairline card.', optional: true },
40
+ { part: 'avatar', element: 'span', description: '40px circle with initials; role="img" + aria-label.', optional: true },
41
+ { part: 'account-text', element: 'div', description: 'Name and email column.', optional: true },
42
+ { part: 'name', element: 'span', description: 'label-sm, truncated.', optional: true },
43
+ { part: 'email', element: 'span', description: 'body-sm muted, truncated.', optional: true },
44
+ { part: 'account-action', element: 'button', description: '36px utility button (log out). aria-label required.', optional: true },
45
+ ],
46
+ props: {
47
+ variant: {
48
+ values: ['simple', 'with-drawer-open'],
49
+ default: 'simple',
50
+ description: 'simple = the bar only; the drawer and scrim are hidden (the resting state). with-drawer-open = the drawer and scrim are shown under the bar and the toggle shows ×; the app switches to this state when the toggle is pressed.',
51
+ },
52
+ },
53
+ states: {
54
+ expanded: { selector: ' .cn-mobile-header__toggle[aria-expanded="true"]', description: 'The toggle while the drawer is open (on the toggle; extraCss): subtle fill and ink icon.', markup: 'aria-expanded="true" on .cn-mobile-header__toggle' },
55
+ current: { selector: ' .cn-mobile-header__item[aria-current="page"]', description: 'The current destination (on the item; extraCss): subtle fill, ink text.', markup: 'aria-current="page" on .cn-mobile-header__item' },
56
+ hover: { selector: ' .cn-mobile-header__item:hover, & .cn-mobile-header__toggle:hover', description: 'Pointer over an item or the toggle (extraCss): subtle fill.', markup: 'native :hover' },
57
+ focus: { selector: ' .cn-mobile-header__item:focus-visible, & .cn-mobile-header__toggle:focus-visible, & .cn-mobile-header__logo:focus-visible', description: 'Keyboard focus shows the ring on links and buttons (extraCss).', markup: 'native :focus-visible' },
58
+ },
59
+ base: {
60
+ root: {
61
+ position: 'relative',
62
+ display: 'flex',
63
+ 'align-items': 'center',
64
+ 'justify-content': 'space-between',
65
+ gap: '{space.3}',
66
+ height: '{space.16}',
67
+ width: '100%',
68
+ 'padding-inline': '{space.4}',
69
+ 'background-color': '{color.bg-surface}',
70
+ 'border-bottom': '{border.width.thin} solid {color.border-default}',
71
+ color: '{color.fg-default}',
72
+ },
73
+ logo: { display: 'inline-flex', 'align-items': 'center', gap: '{space.2}', color: '{color.fg-default}', 'text-decoration': 'none', 'border-radius': '{radius.sm}' },
74
+ mark: { width: '{space.7}', height: '{space.7}', 'border-radius': '{radius.md}', 'background-color': '{color.bg-action}', 'flex-shrink': '0' },
75
+ brand: { ...typeStyle('label-md'), 'font-weight': '{font.weight.semibold}' },
76
+ actions: { display: 'flex', 'align-items': 'center', gap: '{space.1}', 'margin-inline-start': 'auto' },
77
+ toggle: {
78
+ ...RESET_BUTTON,
79
+ display: 'inline-flex',
80
+ 'align-items': 'center',
81
+ 'justify-content': 'center',
82
+ 'flex-shrink': '0',
83
+ width: '{space.10}',
84
+ height: '{space.10}',
85
+ 'margin-inline-end': 'calc(-1 * {space.2})',
86
+ 'border-radius': '{radius.control}',
87
+ color: '{color.fg-muted}',
88
+ ...TRANSITION_COLORS,
89
+ },
90
+ scrim: { display: 'none', position: 'absolute', top: '100%', 'inset-inline': '0', height: '560px', 'background-color': '{color.bg-overlay}', 'z-index': '{z.overlay}' },
91
+ drawer: {
92
+ display: 'none',
93
+ position: 'absolute',
94
+ top: '100%',
95
+ 'inset-inline-start': '0',
96
+ 'z-index': '{z.modal}',
97
+ width: '100%',
98
+ 'max-width': '296px',
99
+ height: '560px',
100
+ 'flex-direction': 'column',
101
+ gap: '{space.4}',
102
+ padding: '{space.4}',
103
+ 'background-color': '{color.bg-surface}',
104
+ 'border-inline-end': '{border.width.thin} solid {color.border-default}',
105
+ 'box-shadow': '{shadow.xl}',
106
+ overflow: 'auto',
107
+ },
108
+ 'drawer-header': { display: 'flex', 'align-items': 'center', 'justify-content': 'space-between', gap: '{space.3}', 'min-height': '{space.9}' },
109
+ nav: { display: 'flex', 'flex-direction': 'column', gap: '{space.1}', flex: '1 1 auto', 'min-height': '0' },
110
+ item: {
111
+ display: 'flex',
112
+ 'align-items': 'center',
113
+ gap: '{space.3}',
114
+ height: '{space.12}',
115
+ 'padding-inline': '{space.3}',
116
+ 'border-radius': '{radius.md}',
117
+ ...typeStyle('body-lg'),
118
+ 'font-weight': '{font.weight.semibold}',
119
+ color: '{color.fg-muted}',
120
+ 'text-decoration': 'none',
121
+ ...TRANSITION_COLORS,
122
+ },
123
+ icon: { width: '{size.icon.xl}', height: '{size.icon.xl}', 'flex-shrink': '0', color: '{color.fg-subtle}' },
124
+ label: { flex: '1 1 auto', 'min-width': '0', overflow: 'hidden', 'text-overflow': 'ellipsis', 'white-space': 'nowrap' },
125
+ badge: { display: 'inline-flex', 'align-items': 'center', height: '22px', 'padding-inline': '{space.2}', 'border-radius': '{radius.full}', 'background-color': '{color.bg-subtle}', border: '{border.width.thin} solid {color.border-default}', ...typeStyle('body-xs'), 'font-weight': '{font.weight.medium}', color: '{color.fg-muted}' },
126
+ footer: { display: 'flex', 'flex-direction': 'column', gap: '{space.1}', 'margin-top': 'auto', 'padding-top': '{space.4}', 'border-top': '{border.width.thin} solid {color.border-default}' },
127
+ account: { display: 'flex', 'align-items': 'center', gap: '{space.3}', padding: '{space.3}', 'margin-top': '{space.3}', 'border-radius': '{radius.card}', border: '{border.width.thin} solid {color.border-default}', 'background-color': '{color.bg-surface}', 'box-shadow': '{shadow.xs}' },
128
+ avatar: { display: 'inline-flex', 'align-items': 'center', 'justify-content': 'center', 'flex-shrink': '0', width: '{space.10}', height: '{space.10}', 'border-radius': '{radius.full}', 'background-color': '{color.bg-subtle}', color: '{color.fg-muted}', ...typeStyle('label-sm'), 'letter-spacing': '{font.letterSpacing.wide}', 'user-select': 'none' },
129
+ 'account-text': { display: 'flex', 'flex-direction': 'column', 'min-width': '0', flex: '1 1 auto' },
130
+ name: { ...typeStyle('label-sm'), 'line-height': '{font.lineHeight.snug}', color: '{color.fg-default}', overflow: 'hidden', 'text-overflow': 'ellipsis', 'white-space': 'nowrap' },
131
+ email: { ...typeStyle('body-sm'), color: '{color.fg-muted}', overflow: 'hidden', 'text-overflow': 'ellipsis', 'white-space': 'nowrap' },
132
+ 'account-action': { ...RESET_BUTTON, display: 'inline-flex', 'align-items': 'center', 'justify-content': 'center', 'flex-shrink': '0', width: '{space.9}', height: '{space.9}', 'border-radius': '{radius.md}', color: '{color.fg-subtle}', ...TRANSITION_COLORS },
133
+ },
134
+ variants: {
135
+ variant: {
136
+ simple: { drawer: { display: 'none' }, scrim: { display: 'none' } },
137
+ 'with-drawer-open': { drawer: { display: 'flex' }, scrim: { display: 'block' } },
138
+ },
139
+ },
140
+ extraCss: `
141
+ .cn-mobile-header__toggle .cn-icon { width: {size.icon.xl}; height: {size.icon.xl}; }
142
+ .cn-mobile-header__toggle:hover { background-color: {color.bg-subtle}; color: {color.fg-default}; }
143
+ .cn-mobile-header__toggle[aria-expanded="true"] { background-color: {color.bg-subtle}; color: {color.fg-default}; }
144
+ .cn-mobile-header__toggle:focus-visible, .cn-mobile-header__logo:focus-visible, .cn-mobile-header__item:focus-visible, .cn-mobile-header__account-action:focus-visible { outline: none; box-shadow: ${FOCUS_RING['box-shadow']}; }
145
+ .cn-mobile-header__item:hover { background-color: {color.bg-subtle}; color: {color.fg-default}; }
146
+ .cn-mobile-header__item[aria-current="page"] { background-color: {color.bg-subtle}; color: {color.fg-default}; }
147
+ .cn-mobile-header__item[aria-current="page"] .cn-mobile-header__icon { color: {color.fg-muted}; }
148
+ .cn-mobile-header__account-action .cn-icon { width: {size.icon.lg}; height: {size.icon.lg}; }
149
+ .cn-mobile-header__account-action:hover { background-color: {color.bg-subtle}; color: {color.fg-muted}; }`,
150
+ examples: [
151
+ ex('Simple', phone(header('simple', `${LOGO}${toggle(false)}`)), 'The resting bar: logo left, hamburger right, hairline below. The drawer is in the DOM but hidden.'),
152
+ ex('With the drawer open', phone(header('with-drawer-open', `${LOGO}${toggle(true)}${DRAWER}`), true), 'The toggle becomes ×, the scrim dims the page and the 296px drawer slides in under the bar: logo row, 48px items, Support/Settings and the account card.'),
153
+ ex('With actions', phone(header('simple', `${LOGO}<div class="cn-mobile-header__actions"><button type="button" class="cn-icon-button" data-variant="ghost" data-size="md" data-shape="square" aria-label="Search">${ICON.search.replace('cn-icon', 'cn-icon-button__icon')}</button><button type="button" class="cn-icon-button" data-variant="ghost" data-size="md" data-shape="square" aria-label="Notifications, 3 unread">${BELL.replace('cn-icon', 'cn-icon-button__icon')}</button></div>${toggle(false)}`)), 'Up to two ghost IconButtons before the toggle; more goes into the drawer.'),
154
+ ],
155
+ rules: [
156
+ 'Show the MobileHeader below the lg breakpoint and the Sidebar above it; never both at once.',
157
+ 'The bar is 64px with the logo on the left and the hamburger on the right; at most two icon actions between them.',
158
+ 'The drawer mirrors the Sidebar exactly: same items, same order, same account card. Do not add mobile-only destinations.',
159
+ 'Items are 48px so they are comfortable to tap; the current one has the subtle fill and aria-current="page".',
160
+ 'Opening the drawer swaps the hamburger for an ×, adds the scrim and locks page scroll; closing restores focus to the toggle.',
161
+ 'Counts use the badge part; never red dots for ordinary counts.',
162
+ 'In the app, position the drawer as a fixed layer in the app shell; the component itself only styles the panel under the bar.',
163
+ 'Escape and a tap on the scrim close the drawer; swiping left may close it but is never the only way.',
164
+ ],
165
+ a11y: [
166
+ 'The toggle is a <button> with aria-label ("Open menu" / "Close menu"), aria-expanded and aria-controls pointing at the drawer id.',
167
+ 'The drawer is a <nav aria-label="Main">; the current item carries aria-current="page". While open, focus moves into the drawer and stays there until it closes.',
168
+ 'The scrim is aria-hidden; the CloseButton inside the drawer duplicates the toggle for screen-reader users who are inside the panel.',
169
+ 'Initials avatars carry role="img" with the full name; the log-out button has an aria-label.',
170
+ 'Touch targets are at least 40px (toggle) and 48px (items); the logo link has an aria-label because the mark alone has no name.',
171
+ ],
172
+ related: ['sidebar', 'sidebar-nav', 'account-card', 'close-button', 'icon-button', 'drawer'],
173
+ };