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,179 @@
1
+ import { RESET_BUTTON, TRANSITION_COLORS, ex, ICON, typeStyle } from "./_shared.mjs";
2
+ // Filter bar: the row above a table or list that holds search, dropdown filter
3
+ // buttons, a date range, sort, a list/grid view toggle, the result count and the
4
+ // active-filter chips. Everything inside is an existing control; this component
5
+ // only lays them out and styles the chips and the view toggle.
6
+ const SVG = {
7
+ grid: '<svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true"><rect x="2.5" y="2.5" width="4.5" height="4.5" rx="1"/><rect x="9" y="2.5" width="4.5" height="4.5" rx="1"/><rect x="2.5" y="9" width="4.5" height="4.5" rx="1"/><rect x="9" y="9" width="4.5" height="4.5" rx="1"/></svg>',
8
+ filter: '<svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M2.5 4.5h11M4.5 8h7M6.5 11.5h3"/></svg>',
9
+ sort: '<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="M5 3v10M5 13l-2-2M5 13l2-2M11 13V3M11 3L9 5M11 3l2 2"/></svg>',
10
+ };
11
+ const bi = (name) => (name in ICON ? ICON[name] : SVG[name]).replace('cn-icon', 'cn-button__icon');
12
+ const btn = (label, variant = 'outline', leading = '', trailing = '', size = 'sm') => `<button type="button" class="cn-button" data-variant="${variant}" data-size="${size}">${leading}<span class="cn-button__label">${label}</span>${trailing}</button>`;
13
+ const dropdown = (label) => btn(label, 'outline', '', bi('chevronDown')).replace('class="cn-button"', 'class="cn-button" aria-haspopup="menu" aria-expanded="false"');
14
+ const SEARCH = `<div class="cn-filter-bar__search"><div class="cn-input" data-variant="default" data-size="sm">${ICON.search.replace('cn-icon', 'cn-input__icon')}<input class="cn-input__field" type="search" placeholder="Search customers…" aria-label="Search customers"></div></div>`;
15
+ const chip = (key, value) => `<span class="cn-filter-bar__chip"><span class="cn-filter-bar__chip-key">${key}:</span>${value}<button type="button" class="cn-filter-bar__chip-remove" aria-label="Remove filter ${key}: ${value}">${ICON.x}</button></span>`;
16
+ const VIEW = `<div class="cn-filter-bar__view" role="group" aria-label="Layout"><button type="button" class="cn-filter-bar__view-item" aria-pressed="true" aria-label="List view">${ICON.menu}</button><button type="button" class="cn-filter-bar__view-item" aria-pressed="false" aria-label="Grid view">${SVG.grid}</button></div>`;
17
+ const count = (n) => `<span class="cn-filter-bar__count" aria-live="polite">${n}</span>`;
18
+ const CLEAR = `<div class="cn-filter-bar__clear">${btn('Clear filters', 'link')}</div>`;
19
+ const DATE = `<div class="cn-filter-bar__date">${btn('Sep 1 – Sep 11, 2026', 'outline', bi('calendar'), bi('chevronDown')).replace('class="cn-button"', 'class="cn-button" aria-haspopup="dialog" aria-expanded="false"')}</div>`;
20
+ const SORT = `<div class="cn-filter-bar__sort">${btn('Sort: Newest', 'outline', bi('sort'), bi('chevronDown')).replace('class="cn-button"', 'class="cn-button" aria-haspopup="menu" aria-expanded="false"')}</div>`;
21
+ const tab = (label, selected, id, extra = '') => `<button type="button" role="tab" class="cn-tabs__tab" id="fb-tab-${id}" aria-selected="${selected}" tabindex="${selected ? '0' : '-1'}">${label}${extra}</button>`;
22
+ const bar = (variant, align, inner) => `<div class="cn-filter-bar" data-variant="${variant}" data-align="${align}" role="search" aria-label="Filter customers" style="width:100%">${inner}</div>`;
23
+ export const filterBar = {
24
+ name: 'FilterBar',
25
+ slug: 'filter-bar',
26
+ category: 'forms',
27
+ description: 'The toolbar above a table or list: a 320px search, dropdown filter buttons with chevrons, a date-range button, sort, a list/grid toggle, the result count, and a wrapping row of active-filter chips with an × each. Composes Input, Button and Tabs; adds only the chips and the view toggle.',
28
+ usage: 'Use above any collection view (customers, invoices, files) that can be searched, filtered or re-sorted. Not for form fields (Field), not for page-level tabs (Tabs on their own), and not inside a Card header (put a single search Input there).',
29
+ anatomy: [
30
+ { part: 'root', element: 'div', description: 'The bar: flex row that wraps, 12px gaps, role="search" or a labelled toolbar. Width comes from the layout.' },
31
+ { part: 'search', element: 'div', description: 'Wrapper for a sm Input with a leading search icon; 320px wide, shrinks to 200px.', optional: true },
32
+ { part: 'group', element: 'div', description: 'A cluster of outline sm Buttons with trailing chevrons that open filter menus (Status, Plan, Owner).', optional: true },
33
+ { part: 'chips', element: 'div', description: 'The wrapping row of active filters. Takes the full width so it sits under the controls.', optional: true },
34
+ { part: 'chip', element: 'span', description: 'One active filter: 28px pill with a hairline, "Key: value" and a 16px × remove button.', optional: true },
35
+ { part: 'chip-key', element: 'span', description: 'The muted "Status:" prefix inside a chip.', optional: true },
36
+ { part: 'chip-remove', element: 'button', description: 'The 16px × that removes this filter. aria-label "Remove filter Status: Active".', optional: true },
37
+ { part: 'date', element: 'div', description: 'Wrapper for the date-range button: outline sm Button with a leading calendar icon and a trailing chevron that opens a DatePicker.', optional: true },
38
+ { part: 'sort', element: 'div', description: 'Wrapper for the sort control: an outline sm Button ("Sort: Newest") that opens a Menu of radio items.', optional: true },
39
+ { part: 'view', element: 'div', description: 'The list/grid toggle: a hairline box of 32px icon buttons, role="group".', optional: true },
40
+ { part: 'view-item', element: 'button', description: 'One icon button inside the view toggle; aria-pressed="true" on the active layout, aria-label naming it.', optional: true },
41
+ { part: 'count', element: 'span', description: 'The result count in body-sm muted ("128 results"), aria-live so filtering announces the new number.', optional: true },
42
+ { part: 'clear', element: 'div', description: 'Wrapper for the link sm "Clear filters" Button. Only rendered when at least one filter is active.', optional: true },
43
+ { part: 'tabs', element: 'div', description: 'Wrapper for a Tabs component (underline for segments of the collection, pill for quick date ranges).', optional: true },
44
+ { part: 'end', element: 'div', description: 'The trailing cluster (count, sort, view, clear). With align="between" it is pushed to the right edge.', optional: true },
45
+ ],
46
+ props: {
47
+ variant: {
48
+ values: ['simple', 'with-chips', 'with-date', 'with-tabs', 'dense'],
49
+ default: 'simple',
50
+ description: 'simple = search + filter buttons + count (most tables). with-chips = the same plus a second line of active-filter chips with × and a Clear link (when filters persist). with-date = quick-range pill Tabs + a date-range button for time-based data (invoices, analytics). with-tabs = underline Tabs that segment the collection ("All / Active / Trial") with search on the right. dense = 8px gaps and 24px chips for toolbars inside drawers and cards.',
51
+ },
52
+ align: {
53
+ values: ['start', 'between'],
54
+ default: 'start',
55
+ description: 'start = everything flows from the left in one run. between = the end cluster (count, sort, view) is pushed to the right edge, leaving the middle empty. Use between on full-width pages, start inside narrower panels.',
56
+ },
57
+ },
58
+ states: {
59
+ chipRemoveHover: { selector: ' .cn-filter-bar__chip-remove:hover', description: 'Pointer over a chip\'s × (on the button; extraCss): muted fill, ink icon.', markup: 'native :hover on .cn-filter-bar__chip-remove' },
60
+ chipRemoveFocus: { selector: ' .cn-filter-bar__chip-remove:focus-visible', description: 'Keyboard focus on a chip\'s × shows the focus ring (extraCss).', markup: 'native :focus-visible on .cn-filter-bar__chip-remove' },
61
+ viewPressed: { selector: ' .cn-filter-bar__view-item[aria-pressed="true"]', description: 'The active layout in the view toggle (on the item; extraCss): subtle fill, ink icon.', markup: 'aria-pressed="true" on .cn-filter-bar__view-item' },
62
+ viewHover: { selector: ' .cn-filter-bar__view-item:hover', description: 'Pointer over a layout button: subtle fill (extraCss).', markup: 'native :hover on .cn-filter-bar__view-item' },
63
+ viewFocus: { selector: ' .cn-filter-bar__view-item:focus-visible', description: 'Keyboard focus on a layout button: an inset 2px ring (the toggle clips overflow) (extraCss).', markup: 'native :focus-visible on .cn-filter-bar__view-item' },
64
+ },
65
+ base: {
66
+ root: { display: 'flex', 'flex-wrap': 'wrap', 'align-items': 'center', gap: '{space.3}', width: '100%', 'min-width': '0', color: '{color.fg-default}' },
67
+ search: { flex: '0 1 320px', 'min-width': '200px', 'max-width': '100%' },
68
+ group: { display: 'flex', 'flex-wrap': 'wrap', 'align-items': 'center', gap: '{space.2}', 'min-width': '0' },
69
+ chips: { display: 'flex', 'flex-wrap': 'wrap', 'align-items': 'center', gap: '{space.2}', 'flex-basis': '100%', 'min-width': '0' },
70
+ chip: {
71
+ display: 'inline-flex',
72
+ 'align-items': 'center',
73
+ gap: '{space.1}',
74
+ height: '{space.7}',
75
+ padding: '0 {space.1} 0 {space.2.5}',
76
+ 'background-color': '{color.bg-surface}',
77
+ border: '{border.width.thin} solid {color.border-control}',
78
+ 'border-radius': '{radius.full}',
79
+ 'box-shadow': '{shadow.xs}',
80
+ ...typeStyle('label-sm'),
81
+ color: '{color.fg-default}',
82
+ 'white-space': 'nowrap',
83
+ },
84
+ 'chip-key': { color: '{color.fg-muted}', 'font-weight': '{font.weight.regular}' },
85
+ 'chip-remove': {
86
+ ...RESET_BUTTON,
87
+ display: 'inline-flex',
88
+ 'align-items': 'center',
89
+ 'justify-content': 'center',
90
+ width: '{space.4}',
91
+ height: '{space.4}',
92
+ 'margin-inline-start': '{space.0.5}',
93
+ 'border-radius': '{radius.full}',
94
+ color: '{color.fg-subtle}',
95
+ ...TRANSITION_COLORS,
96
+ },
97
+ date: { display: 'inline-flex', 'flex-shrink': '0' },
98
+ sort: { display: 'inline-flex', 'flex-shrink': '0' },
99
+ view: {
100
+ display: 'inline-flex',
101
+ 'align-items': 'stretch',
102
+ 'flex-shrink': '0',
103
+ height: '{size.control.sm}',
104
+ 'background-color': '{color.bg-surface}',
105
+ border: '{border.width.thin} solid {color.border-control}',
106
+ 'border-radius': '{radius.control}',
107
+ 'box-shadow': '{shadow.xs}',
108
+ overflow: 'hidden',
109
+ },
110
+ 'view-item': {
111
+ ...RESET_BUTTON,
112
+ display: 'inline-flex',
113
+ 'align-items': 'center',
114
+ 'justify-content': 'center',
115
+ width: '{size.control.sm}',
116
+ color: '{color.fg-subtle}',
117
+ ...TRANSITION_COLORS,
118
+ },
119
+ count: { ...typeStyle('body-sm'), color: '{color.fg-muted}', 'white-space': 'nowrap', 'flex-shrink': '0', 'font-variant-numeric': 'tabular-nums' },
120
+ clear: { display: 'inline-flex', 'flex-shrink': '0' },
121
+ tabs: { display: 'flex', 'min-width': '0', flex: '0 1 auto' },
122
+ end: { display: 'flex', 'flex-wrap': 'wrap', 'align-items': 'center', gap: '{space.3}', 'min-width': '0' },
123
+ },
124
+ variants: {
125
+ variant: {
126
+ simple: { root: {} },
127
+ 'with-chips': { root: {} },
128
+ 'with-date': { root: {} },
129
+ 'with-tabs': { root: {} },
130
+ dense: {
131
+ root: { gap: '{space.2}' },
132
+ search: { flex: '0 1 240px', 'min-width': '160px' },
133
+ chips: { gap: '{space.1.5}' },
134
+ chip: { height: '{space.6}', padding: '0 {space.0.5} 0 {space.2}', ...typeStyle('label-xs') },
135
+ end: { gap: '{space.2}' },
136
+ },
137
+ },
138
+ align: {
139
+ start: { end: { 'margin-inline-start': '0' } },
140
+ between: { end: { 'margin-inline-start': 'auto' } },
141
+ },
142
+ },
143
+ extraCss: `
144
+ .cn-filter-bar__chip-remove .cn-icon { width: {size.icon.xs}; height: {size.icon.xs}; stroke-width: 2; }
145
+ .cn-filter-bar__chip-remove:hover { background-color: {color.bg-muted}; color: {color.fg-default}; }
146
+ .cn-filter-bar__chip-remove:focus-visible { outline: none; box-shadow: {shadow.focus}; }
147
+ .cn-filter-bar__view-item + .cn-filter-bar__view-item { border-inline-start: {border.width.thin} solid {color.border-control}; }
148
+ .cn-filter-bar__view-item .cn-icon { width: {size.icon.md}; height: {size.icon.md}; }
149
+ .cn-filter-bar__view-item:hover { background-color: {color.bg-subtle}; color: {color.fg-muted}; }
150
+ .cn-filter-bar__view-item[aria-pressed="true"] { background-color: {color.bg-subtle}; color: {color.fg-default}; }
151
+ .cn-filter-bar__view-item:focus-visible { outline: none; box-shadow: inset 0 0 0 2px {color.ring-focus}; }
152
+ .cn-filter-bar__tabs .cn-tabs { max-width: 100%; }`,
153
+ examples: [
154
+ ex('Simple', bar('simple', 'start', `${SEARCH}<div class="cn-filter-bar__group">${dropdown('Status')}${dropdown('Plan')}${dropdown('Owner')}</div><div class="cn-filter-bar__end">${count('128 results')}</div>`), 'Search, three filter menus and the count in one run.'),
155
+ ex('With chips', bar('with-chips', 'between', `${SEARCH}<div class="cn-filter-bar__group">${btn('Filters', 'outline', bi('filter'))}</div><div class="cn-filter-bar__end">${count('37 results')}${SORT}${VIEW}</div><div class="cn-filter-bar__chips">${chip('Status', 'Active')}${chip('Plan', 'Team')}${chip('Owner', 'Maya Chen')}${CLEAR}</div>`), 'Active filters wrap onto a second line as removable chips; Clear filters is a link Button at the end.'),
156
+ ex('With date range', bar('with-date', 'between', `<div class="cn-filter-bar__tabs"><div class="cn-tabs" data-variant="pill" data-size="sm" role="tablist" aria-label="Quick ranges">${tab('12 months', false, '12m')}${tab('30 days', true, '30d')}${tab('7 days', false, '7d')}${tab('24 hours', false, '24h')}</div></div>${DATE}<div class="cn-filter-bar__end">${btn('Filters', 'outline', bi('filter'))}${SORT}</div>`), 'Quick ranges as pill Tabs, a custom range from the calendar button.'),
157
+ ex('With tabs', bar('with-tabs', 'between', `<div class="cn-filter-bar__tabs"><div class="cn-tabs" data-variant="underline" data-size="md" role="tablist" aria-label="Customer segments">${tab('All', true, 'all', '<span class="cn-tabs__count">128</span>')}${tab('Active', false, 'active')}${tab('Trial', false, 'trial', '<span class="cn-tabs__count">9</span>')}${tab('Churned', false, 'churned')}</div></div><div class="cn-filter-bar__end">${SEARCH}${VIEW}</div>`), 'Tabs segment the collection; search and the view toggle sit on the right.'),
158
+ ex('Dense', bar('dense', 'start', `${SEARCH}<div class="cn-filter-bar__group">${dropdown('Type')}${dropdown('Modified')}</div>${count('12 files')}<div class="cn-filter-bar__chips">${chip('Type', 'PDF')}${chip('Modified', 'Last 7 days')}${CLEAR}</div>`), '8px gaps and 24px chips for toolbars inside drawers and cards.'),
159
+ ],
160
+ rules: [
161
+ 'Order from left to right: search, filter menus, date range, then the end cluster: count, sort, view toggle. Never put the count first.',
162
+ 'Filter buttons are outline sm Buttons with a trailing chevron and open a Menu; the button label names the field ("Status"), never the value.',
163
+ 'Chips show "Key: value" and only appear for active filters; the Clear link appears with the first chip and disappears with the last.',
164
+ 'Search is 320px on pages, shrinks to 200px, and is the only Input in the bar. Put more fields in a filter Drawer.',
165
+ 'The count uses the exact number and the noun ("128 results", "12 files") and updates live; no "Showing" prefix.',
166
+ 'The view toggle has two or three layouts at most, icon-only, with one aria-pressed="true".',
167
+ 'Sort is a button labelled "Sort: {option}" that opens a Menu of radio items; never a bare Select in the bar.',
168
+ 'align="between" on full-width pages; align="start" inside panels narrower than 720px, where the bar wraps anyway.',
169
+ 'dense only inside drawers, cards and split views; on a page the default heights keep 36px targets.',
170
+ ],
171
+ a11y: [
172
+ 'Root is role="search" when it contains the search field, otherwise role="toolbar"; give it an aria-label naming the collection.',
173
+ 'Filter buttons expose aria-haspopup="menu" and aria-expanded; the date button aria-haspopup="dialog".',
174
+ 'Chip × buttons have aria-label "Remove filter {Key}: {value}"; removing one moves focus to the next chip or to the search field.',
175
+ 'The view toggle is role="group" with aria-label; each item has aria-pressed and an aria-label naming the layout.',
176
+ 'The count carries aria-live="polite" so screen readers hear the new number after filtering; keep the text short.',
177
+ ],
178
+ related: ['input', 'button', 'tabs', 'menu', 'tag', 'table', 'segmented-control'],
179
+ };
@@ -0,0 +1,94 @@
1
+ import { ex, ICON, typeStyle } from "./_shared.mjs";
2
+ const navItem = (label, current = false, dropdown = false) => `<a href="#" class="cn-header-navigation__item"${current ? ' aria-current="page"' : ''}>${label}${dropdown ? ICON.chevronDown.replace('cn-icon', 'cn-header-navigation__chevron') : ''}</a>`;
3
+ const NAV = `${navItem('Home', true)}${navItem('Products', false, true)}${navItem('Resources', false, true)}${navItem('Pricing')}`;
4
+ const LOGO = `<a href="#" class="cn-header-navigation__logo"><span class="cn-header-navigation__mark" aria-hidden="true"></span>Lumen</a>`;
5
+ const ACTIONS = `<div class="cn-header-navigation__actions"><button type="button" class="cn-button" data-variant="ghost" data-size="lg"><span class="cn-button__label">Log in</span></button><button type="button" class="cn-button" data-variant="primary" data-size="lg"><span class="cn-button__label">Sign up</span></button></div>`;
6
+ const menuItem = (title, desc, icon) => `<a href="#" class="cn-header-navigation__menu-item">${icon.replace('cn-icon', 'cn-header-navigation__menu-icon')}<span class="cn-header-navigation__menu-text"><span class="cn-header-navigation__menu-title">${title}</span><span class="cn-header-navigation__menu-desc">${desc}</span></span></a>`;
7
+ export const headerNavigation = {
8
+ name: 'HeaderNavigation',
9
+ slug: 'header-navigation',
10
+ category: 'navigation',
11
+ description: 'Marketing website header: 80px bar with logo, text links (with optional mega menu), and Log in / Sign up actions; collapses to a hamburger on mobile. "header navigation".',
12
+ usage: 'Top of every marketing page (landing, pricing, about, blog). Inside the product use Topbar or Sidebar instead.',
13
+ anatomy: [
14
+ { part: 'root', element: 'header', description: 'Full-width bar, 80px, bottom hairline (transparent when over a hero).' },
15
+ { part: 'container', element: 'div', description: 'Centered 1280px row with 32px side padding.' },
16
+ { part: 'logo', element: 'a', description: 'Logo mark + wordmark, links home.' },
17
+ { part: 'mark', element: 'span', description: '32px mark.' },
18
+ { part: 'nav', element: 'nav', description: 'The text links, 8px apart.' },
19
+ { part: 'item', element: 'a', description: '16px semibold link, 40px tall, fg-muted → fg-default; current is fg-default.' },
20
+ { part: 'chevron', element: 'svg', description: 'Dropdown indicator, 20px.' },
21
+ { part: 'actions', element: 'div', description: 'Right side: Log in (ghost) and Sign up (primary), lg size.' },
22
+ { part: 'toggle', element: 'button', description: 'Hamburger IconButton shown under 1024px.' },
23
+ { part: 'menu', element: 'div', description: 'Mega menu panel: white, radius 12, shadow-lg, 2–3 columns of menu items.', optional: true },
24
+ { part: 'menu-item', element: 'a', description: 'Icon + title + description row, 12px radius, hover bg-subtle.' },
25
+ { part: 'menu-icon', element: 'svg', description: '24px icon in brand color.' },
26
+ { part: 'menu-text', element: 'span', description: 'Title and description column.' },
27
+ { part: 'menu-title', element: 'span', description: 'label-md semibold.' },
28
+ { part: 'menu-desc', element: 'span', description: 'body-md fg-muted.' },
29
+ ],
30
+ props: {
31
+ variant: { values: ['default', 'transparent', 'with-menu'], default: 'default', description: 'default = white with bottom hairline; transparent = no background/border (over a hero); with-menu = shows the mega menu panel open under the bar (static preview).' },
32
+ theme: { values: ['light', 'dark'], default: 'light', description: 'dark = gray-950 bar with light text (dark landing pages).' },
33
+ },
34
+ states: {
35
+ hover: { selector: ' .cn-header-navigation__item:hover', description: 'Link darkens.', markup: 'native' },
36
+ current: { selector: ' .cn-header-navigation__item[aria-current="page"]', description: 'Current section is fg-default.', markup: 'aria-current="page"' },
37
+ open: { selector: '[data-state="open"]', description: 'Mega menu visible.', markup: 'data-state="open" on the root' },
38
+ },
39
+ base: {
40
+ root: { position: 'relative', 'background-color': '{color.bg-surface}', 'border-bottom': '{border.width.thin} solid {color.border-default}' },
41
+ container: { display: 'flex', 'align-items': 'center', gap: '{space.10}', height: '80px', 'max-width': '{size.container.xl}', 'margin-inline': 'auto', 'padding-inline': '{space.8}' },
42
+ logo: { display: 'inline-flex', 'align-items': 'center', gap: '{space.2.5}', ...typeStyle('label-lg'), 'font-size': '{font.size.lg}', color: '{color.fg-default}', 'text-decoration': 'none', 'flex-shrink': '0' },
43
+ mark: { width: '32px', height: '32px', 'border-radius': '{radius.lg}', 'background-color': '{color.bg-action}' },
44
+ nav: { display: 'flex', 'align-items': 'center', gap: '{space.2}', flex: '1 1 auto' },
45
+ item: { display: 'inline-flex', 'align-items': 'center', gap: '{space.1}', height: '40px', 'padding-inline': '{space.3}', 'border-radius': '{radius.md}', ...typeStyle('label-lg'), color: '{color.fg-muted}', 'text-decoration': 'none', 'transition-property': 'color, background-color', 'transition-duration': '{motion.duration.fast}', 'transition-timing-function': '{motion.easing.standard}' },
46
+ chevron: { width: '{size.icon.md}', height: '{size.icon.md}', color: '{color.fg-subtle}' },
47
+ actions: { display: 'flex', 'align-items': 'center', gap: '{space.3}', 'flex-shrink': '0' },
48
+ toggle: { display: 'none', appearance: 'none', border: '0', background: 'none', padding: '0', width: '40px', height: '40px', 'border-radius': '{radius.md}', color: '{color.fg-muted}', cursor: 'pointer', 'align-items': 'center', 'justify-content': 'center', 'margin-inline-start': 'auto' },
49
+ menu: { display: 'none', position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)', width: '{size.container.md}', 'max-width': 'calc(100% - {space.8})', 'margin-top': '{space.2}', padding: '{space.5}', 'background-color': '{color.bg-surface-raised}', border: '{border.width.thin} solid {color.border-default}', 'border-radius': '{radius.card}', 'box-shadow': '{shadow.lg}', 'grid-template-columns': 'repeat(2, minmax(0, 1fr))', gap: '{space.2}', 'z-index': '{z.dropdown}' },
50
+ 'menu-item': { display: 'flex', gap: '{space.4}', 'align-items': 'flex-start', padding: '{space.3}', 'border-radius': '{radius.lg}', 'text-decoration': 'none', color: 'inherit', 'transition-property': 'background-color', 'transition-duration': '{motion.duration.fast}', 'transition-timing-function': '{motion.easing.standard}' },
51
+ 'menu-icon': { width: '{size.icon.lg}', height: '{size.icon.lg}', color: '{brand.600}', 'flex-shrink': '0', 'margin-top': '2px' },
52
+ 'menu-text': { display: 'flex', 'flex-direction': 'column', gap: '{space.1}', 'min-width': '0' },
53
+ 'menu-title': { ...typeStyle('label-md'), 'font-size': '{font.size.md}', color: '{color.fg-default}' },
54
+ 'menu-desc': { ...typeStyle('body-md'), color: '{color.fg-muted}' },
55
+ '@states': {
56
+ hover: { item: { color: '{color.fg-default}' } },
57
+ current: { item: { color: '{color.fg-default}' } },
58
+ open: { menu: { display: 'grid' } },
59
+ },
60
+ },
61
+ variants: {
62
+ variant: {
63
+ default: { root: {} },
64
+ transparent: { root: { 'background-color': 'transparent', 'border-bottom-color': 'transparent' } },
65
+ 'with-menu': { root: {}, menu: { display: 'grid', position: 'relative', top: 'auto', left: 'auto', transform: 'none', 'margin-inline': 'auto', 'margin-bottom': '{space.6}' } },
66
+ },
67
+ theme: {
68
+ light: { root: {} },
69
+ dark: { root: { 'background-color': '{neutral.950}', 'border-bottom-color': '{neutral.800}' }, logo: { color: '{white}' }, item: { color: '{neutral.300}' }, chevron: { color: '{neutral.500}' }, toggle: { color: '{neutral.300}' }, '@states': { hover: { item: { color: '{white}' } }, current: { item: { color: '{white}' } } } },
70
+ },
71
+ },
72
+ extraCss: `
73
+ .cn-header-navigation__mark { clip-path: polygon(0 0, 100% 0, 100% 22%, 27% 22%, 27% 78%, 100% 78%, 100% 100%, 0 100%); border-radius: 0; }
74
+ .cn-header-navigation__item:hover .cn-header-navigation__chevron { color: currentColor; }
75
+ .cn-header-navigation__menu-item:hover { background-color: {color.bg-subtle}; }
76
+ .cn-header-navigation[data-theme="dark"] .cn-button[data-variant="ghost"] { color: {neutral.300}; }
77
+ .cn-header-navigation[data-theme="dark"] .cn-button[data-variant="ghost"]:hover { background-color: {neutral.800}; color: {white}; }
78
+ @media (max-width: {breakpoint.lg}) { .cn-header-navigation .cn-header-navigation__nav, .cn-header-navigation .cn-header-navigation__actions { display: none; } .cn-header-navigation .cn-header-navigation__toggle { display: inline-flex; } .cn-header-navigation .cn-header-navigation__container { height: 64px; padding-inline: {space.4}; } }`,
79
+ examples: [
80
+ ex('Default', `<header class="cn-header-navigation" data-variant="default" data-theme="light"><div class="cn-header-navigation__container">${LOGO}<nav class="cn-header-navigation__nav" aria-label="Main">${NAV}</nav>${ACTIONS}<button type="button" class="cn-header-navigation__toggle" aria-label="Open menu">${ICON.menu}</button></div></header>`),
81
+ ex('With mega menu open', `<header class="cn-header-navigation" data-variant="with-menu" data-theme="light" data-state="open"><div class="cn-header-navigation__container">${LOGO}<nav class="cn-header-navigation__nav" aria-label="Main">${navItem('Home')}${navItem('Products', true, true)}${navItem('Resources', false, true)}${navItem('Pricing')}</nav>${ACTIONS}<button type="button" class="cn-header-navigation__toggle" aria-label="Open menu">${ICON.menu}</button></div><div class="cn-header-navigation__menu">${menuItem('Analytics', 'Get a better understanding of your traffic.', ICON.calendar)}${menuItem('Engagement', 'Speak directly to your customers.', ICON.inbox)}${menuItem('Security', 'Your customers’ data will be safe and secure.', ICON.check)}${menuItem('Integrations', 'Connect with third-party tools.', ICON.copy)}${menuItem('Automations', 'Build strategic funnels that will convert.', ICON.spark)}${menuItem('Reports', 'Get detailed reports with the click of a button.', ICON.home)}</div></header>`),
82
+ ex('Transparent over a hero surface', `<div style="background:var(--cn-color-bg-subtle)"><header class="cn-header-navigation" data-variant="transparent" data-theme="light"><div class="cn-header-navigation__container">${LOGO}<nav class="cn-header-navigation__nav" aria-label="Main">${NAV}</nav>${ACTIONS}<button type="button" class="cn-header-navigation__toggle" aria-label="Open menu">${ICON.menu}</button></div></header></div>`),
83
+ ex('Dark', `<header class="cn-header-navigation" data-variant="default" data-theme="dark"><div class="cn-header-navigation__container">${LOGO}<nav class="cn-header-navigation__nav" aria-label="Main">${NAV}</nav>${ACTIONS}<button type="button" class="cn-header-navigation__toggle" aria-label="Open menu">${ICON.menu}</button></div></header>`),
84
+ ],
85
+ rules: [
86
+ '80px tall on desktop, 64 on mobile; 1280px container with 32px padding; logo left, links after a 40px gap, actions right.',
87
+ 'Links: 16px semibold gray; ≤ 5 items; dropdown items show a chevron and open a mega menu with icon + title + one-line description rows.',
88
+ 'Actions: exactly "Log in" (tertiary/ghost, lg) and one primary CTA (lg). Never two filled buttons.',
89
+ 'Below 1024px, links and actions hide behind the hamburger; the mobile menu lists the same links stacked, then the two actions full width.',
90
+ 'Transparent variant only over a hero whose background guarantees contrast.',
91
+ ],
92
+ a11y: ['<header> + <nav aria-label="Main">; dropdown triggers use aria-expanded; the mega menu is reachable by keyboard and closes with Escape.', 'The hamburger is a labelled button.'],
93
+ related: ['topbar', 'button', 'mobile-header', 'menu'],
94
+ };
@@ -0,0 +1,179 @@
1
+ import { CONTROL, RESET_BUTTON, STATE, SIZE_PROP, ex, ICON } from "./_shared.mjs";
2
+ // the system icon-only button (Button with data-icon-only) plus the ButtonUtility
3
+ // used for close (×) and table-row actions: same box as Button (32–48px), 20px icon
4
+ // (16px on xs), a flat fill or a single inset border on filled/ringed intents.
5
+ const TRANSITION_FAST = {
6
+ 'transition-property': 'background-color, border-color, color, box-shadow, opacity',
7
+ 'transition-duration': '{motion.duration.fast}',
8
+ 'transition-timing-function': '{motion.easing.linear}',
9
+ };
10
+ const ring = (color) => `inset 0 0 0 1px ${color}`;
11
+ const SHADOW_SOLID = '{shadow.control}, {shadow.xs}';
12
+ const shadowRinged = (color) => `${ring(color)}, ${SHADOW_SOLID}`;
13
+ const ICON_ON_SOLID = 'color-mix(in srgb, currentColor 60%, transparent)';
14
+ const ICON_ON_SOLID_HOVER = 'color-mix(in srgb, currentColor 70%, transparent)';
15
+ const solid = (bg, hoverBg, fg, focus) => ({
16
+ root: { 'background-color': bg, color: fg, 'box-shadow': SHADOW_SOLID },
17
+ icon: { color: ICON_ON_SOLID },
18
+ '@states': {
19
+ hover: { root: { 'background-color': hoverBg }, icon: { color: ICON_ON_SOLID_HOVER } },
20
+ loading: { root: { 'background-color': hoverBg } },
21
+ focus: { root: { 'box-shadow': `${SHADOW_SOLID}, ${focus}` } },
22
+ pressed: { root: { 'background-color': hoverBg } },
23
+ },
24
+ });
25
+ const bordered = (bg, ringColor, hoverBg, pressedBg) => ({
26
+ root: { 'background-color': bg, color: '{color.fg-subtle}', 'box-shadow': shadowRinged(ringColor) },
27
+ '@states': {
28
+ hover: { root: { 'background-color': hoverBg, color: '{color.fg-muted}' } },
29
+ loading: { root: { 'background-color': hoverBg } },
30
+ focus: { root: { 'box-shadow': `${shadowRinged(ringColor)}, {shadow.focus}` } },
31
+ pressed: { root: { 'background-color': pressedBg, color: '{color.fg-default}', 'box-shadow': shadowRinged(ringColor) } },
32
+ },
33
+ });
34
+ const plain = () => ({
35
+ root: { 'background-color': 'transparent', color: '{color.fg-subtle}', 'box-shadow': 'none' },
36
+ '@states': {
37
+ hover: { root: { 'background-color': '{color.bg-subtle}', color: '{color.fg-muted}' } },
38
+ loading: { root: { 'background-color': '{color.bg-subtle}' } },
39
+ focus: { root: { 'box-shadow': '{shadow.focus}' } },
40
+ pressed: { root: { 'background-color': '{color.bg-subtle}', color: '{color.fg-default}' } },
41
+ },
42
+ });
43
+ const sizeBlock = (s) => ({
44
+ root: { width: CONTROL[s].height, height: CONTROL[s].height },
45
+ icon: { width: CONTROL[s].icon, height: CONTROL[s].icon },
46
+ spinner: { width: CONTROL[s].icon, height: CONTROL[s].icon },
47
+ });
48
+ const icon = (name) => ICON[name].replace('cn-icon', 'cn-icon-button__icon');
49
+ const btn = (variant, size, shape, label, i, attrs = '') => `<button type="button" class="cn-icon-button" data-variant="${variant}" data-size="${size}" data-shape="${shape}" aria-label="${label}"${attrs}>${icon(i)}</button>`;
50
+ const row = (...items) => `<div style="display:flex;flex-wrap:wrap;align-items:center;gap:var(--cn-space-3)">${items.join('')}</div>`;
51
+ export const iconButton = {
52
+ name: 'IconButton',
53
+ slug: 'icon-button',
54
+ category: 'actions',
55
+ description: 'A square (or round) button holding exactly one icon: icon-only button and its utility button. Same intents and sizes as Button, width equals height, the icon sits in a muted gray until hover, and the accessible name lives in aria-label.',
56
+ usage: 'Use for a frequent, unambiguous action next to content: close, more actions, copy, delete a row, toggle a panel. If the icon needs a caption to be understood, use a Button with a label instead. Toggles (pin, bookmark, show calendar) use aria-pressed. Always pair with a Tooltip that repeats the aria-label.',
57
+ anatomy: [
58
+ { part: 'root', element: 'button', description: 'The interactive element. <button type="button"> with a mandatory aria-label; <a> only when it navigates.' },
59
+ { part: 'icon', element: 'svg', description: 'The single icon, 20px (16px on xs). Decorative: aria-hidden="true". No text inside the button.' },
60
+ { part: 'spinner', element: 'span', description: 'Shown only while data-loading; the icon becomes invisible, the button keeps its size.', optional: true },
61
+ ],
62
+ props: {
63
+ variant: {
64
+ values: ['primary', 'secondary', 'outline', 'ghost', 'danger', 'utility'],
65
+ default: 'ghost',
66
+ description: 'ghost = default for toolbars, table rows and card headers: transparent, gray-400 icon, gray-50 fill on hover (Tertiary icon-only). utility = the same colors in a smaller 6px-radius box for close (×) buttons, table-row and file-list actions (ButtonUtility tertiary: 28px on xs, 32px on sm). outline = white with a gray-300 ring when it stands alone on the canvas (Secondary icon-only). secondary = gray-50 fill with a ring next to a filled action. primary = the one filled action (rare: a "+" that is the main action on a small screen). danger = destructive, always behind a confirm.',
67
+ },
68
+ size: SIZE_PROP(),
69
+ shape: {
70
+ values: ['square', 'circle'],
71
+ default: 'square',
72
+ description: 'square = 8px radius (6px for utility), sits in toolbars and rows next to Buttons; circle = fully round, for avatars and floating actions. Do not mix shapes in one row.',
73
+ },
74
+ },
75
+ states: {
76
+ hover: STATE.hover(),
77
+ active: { selector: ':active:not(:disabled):not([aria-disabled="true"])', description: 'While pressed. has no distinct pressed style: it keeps the hover colors.', markup: 'native :active' },
78
+ focus: { selector: ':focus-visible', description: 'Keyboard focus. The 4px brand ring (red for danger) is added under the existing ring and shadow.', markup: 'native :focus-visible' },
79
+ disabled: { selector: ':disabled, &[aria-disabled="true"]', description: 'Not interactive. Whole button at 50% opacity, cursor not-allowed, no hover.', markup: 'disabled attribute (or aria-disabled="true")' },
80
+ loading: STATE.loading(),
81
+ pressed: { selector: '[aria-pressed="true"]', description: 'Toggle is on. Gray-50 fill + default ink so the state is visible at rest, not only on hover. Use with ghost, utility or outline.', markup: 'aria-pressed="true" (toggle buttons only)' },
82
+ },
83
+ base: {
84
+ root: {
85
+ ...RESET_BUTTON,
86
+ position: 'relative',
87
+ display: 'inline-flex',
88
+ 'align-items': 'center',
89
+ 'justify-content': 'center',
90
+ 'flex-shrink': '0',
91
+ padding: '0',
92
+ color: '{color.fg-subtle}',
93
+ 'border-radius': '{radius.control}',
94
+ ...TRANSITION_FAST,
95
+ },
96
+ icon: { 'flex-shrink': '0', display: 'block', 'pointer-events': 'none', transition: 'inherit' },
97
+ spinner: {
98
+ display: 'none',
99
+ position: 'absolute',
100
+ inset: '0',
101
+ margin: 'auto',
102
+ 'border-radius': '{radius.full}',
103
+ border: '2px solid currentColor',
104
+ 'border-right-color': 'transparent',
105
+ animation: 'icon-button-spin 0.7s linear infinite',
106
+ },
107
+ '@states': {
108
+ focus: { root: { outline: 'none', 'box-shadow': '{shadow.focus}' } },
109
+ disabled: { root: { opacity: '{opacity.disabled}', cursor: 'not-allowed' } },
110
+ loading: {
111
+ root: { cursor: 'progress', 'pointer-events': 'none' },
112
+ icon: { visibility: 'hidden' },
113
+ spinner: { display: 'block' },
114
+ },
115
+ pressed: { root: { 'background-color': '{color.bg-subtle}', color: '{color.fg-default}' } },
116
+ },
117
+ },
118
+ variants: {
119
+ variant: {
120
+ primary: solid('{color.bg-action}', '{color.bg-action-hover}', '{color.fg-on-action}', '{shadow.focus}'),
121
+ secondary: bordered('{color.bg-subtle}', '{color.border-control}', '{color.bg-muted}', '{color.bg-muted}'),
122
+ outline: bordered('{color.bg-surface}', '{color.border-control}', '{color.bg-subtle}', '{color.bg-subtle}'),
123
+ ghost: plain(),
124
+ danger: solid('{color.bg-danger}', '{color.bg-danger-hover}', '{white}', '{shadow.focus-danger}'),
125
+ utility: { ...plain(), root: { ...plain().root, 'border-radius': '{radius.md}' } },
126
+ },
127
+ size: {
128
+ xs: sizeBlock('xs'),
129
+ sm: sizeBlock('sm'),
130
+ md: sizeBlock('md'),
131
+ lg: sizeBlock('lg'),
132
+ xl: sizeBlock('xl'),
133
+ },
134
+ shape: {
135
+ square: { root: {} },
136
+ circle: { root: { 'border-radius': '{radius.full}' } },
137
+ },
138
+ },
139
+ // ButtonUtility: xs 28×28 with a 16px icon, sm 32×32 with a 20px icon.
140
+ compound: [
141
+ { when: { variant: 'utility', size: 'xs' }, block: { root: { width: 'calc({size.control.xs} - {space.1})', height: 'calc({size.control.xs} - {space.1})' } } },
142
+ { when: { variant: 'utility', size: 'sm' }, block: { root: { width: '{size.control.xs}', height: '{size.control.xs}' } } },
143
+ ],
144
+ extraCss: `
145
+ @keyframes icon-button-spin { to { transform: rotate(360deg); } }
146
+ .cn-icon-button[data-shape="circle"]::before { border-radius: {radius.full}; }`,
147
+ examples: [
148
+ ex('Ghost (toolbar default)', btn('ghost', 'md', 'square', 'More actions', 'dots'), 'Transparent, gray-400 icon; gray-50 fill and a darker icon on hover.'),
149
+ ex('Intents', row(btn('primary', 'md', 'square', 'Add member', 'plus'), btn('secondary', 'md', 'square', 'Settings', 'settings'), btn('outline', 'md', 'square', 'Copy link', 'copy'), btn('ghost', 'md', 'square', 'Search', 'search'), btn('danger', 'md', 'square', 'Delete row', 'trash')), 'Same colors as Button. Ghost is the default because most icon buttons sit inside toolbars and rows.'),
150
+ ex('Utility (close and row actions)', row(btn('utility', 'xs', 'square', 'Delete file', 'trash'), btn('utility', 'sm', 'square', 'Close', 'x'), btn('utility', 'md', 'square', 'Close panel', 'x')), 'ButtonUtility: 28px (xs) and 32px (sm) boxes with a 6px radius, for file rows, tags and dialog headers.'),
151
+ ex('Circle', row(btn('ghost', 'md', 'circle', 'Close', 'x'), btn('outline', 'md', 'circle', 'Open profile', 'user'), btn('primary', 'lg', 'circle', 'New message', 'plus')), 'Round for avatars and floating actions.'),
152
+ ex('Pressed (toggle)', row(btn('outline', 'md', 'square', 'Show calendar', 'calendar', ' aria-pressed="true"'), btn('outline', 'md', 'square', 'Show inbox', 'inbox', ' aria-pressed="false"'), btn('ghost', 'md', 'square', 'Pin to sidebar', 'spark', ' aria-pressed="true"')), 'aria-pressed="true" keeps the gray-50 fill at rest so the on state is visible without hovering.'),
153
+ ex('Sizes', row(btn('outline', 'xs', 'square', 'Search', 'search'), btn('outline', 'sm', 'square', 'Search', 'search'), btn('outline', 'md', 'square', 'Search', 'search'), btn('outline', 'lg', 'square', 'Search', 'search'), btn('outline', 'xl', 'square', 'Search', 'search')), '32 / 36 / 40 / 44 / 48px; the icon is 20px except on xs (16px).'),
154
+ ex('Loading', `<button type="button" class="cn-icon-button" data-variant="primary" data-size="md" data-shape="square" aria-label="Sending" data-loading aria-busy="true"><span class="cn-icon-button__spinner" aria-hidden="true"></span>${icon('arrow')}</button>`),
155
+ ex('Disabled', row(btn('outline', 'md', 'square', 'Delete row', 'trash', ' disabled'), btn('ghost', 'md', 'square', 'More actions', 'dots', ' disabled'))),
156
+ ],
157
+ recipes: [
158
+ ex('Row actions in a card header', `<div style="display:flex;gap:var(--cn-space-1)">${btn('ghost', 'sm', 'square', 'Copy ID', 'copy')}${btn('ghost', 'sm', 'square', 'Open in new tab', 'external')}${btn('ghost', 'sm', 'square', 'More actions', 'dots')}</div>`, 'Ghost, size sm, 4px apart. The "more" menu is always last.'),
159
+ ],
160
+ rules: [
161
+ 'aria-label is mandatory and names the action, not the icon: "Delete row", not "Trash".',
162
+ 'Always pair with a Tooltip that shows the same text as aria-label; hover reveals what keyboard users already hear.',
163
+ 'Ghost inside toolbars, table rows and card headers; utility for close buttons and file-list actions; outline when the button stands alone on the canvas; primary only when the icon action is the main action of the region.',
164
+ 'No text inside an IconButton. The moment it needs a caption, it is a Button with a leading icon.',
165
+ 'Toggles set aria-pressed and use ghost, utility or outline; the icon keeps its meaning in both states (a pin stays a pin, it does not become a cross).',
166
+ 'Icon size comes from size; never scale the svg by hand or pad the button to change its footprint.',
167
+ 'Touch targets: md (40px) or larger on mobile and in touch-first screens; xs/sm are for dense desktop rows only.',
168
+ 'One shape per row. Square next to Buttons; circle for avatars and floating actions.',
169
+ 'Destructive icon actions (danger, or a ghost trash icon) always confirm before acting.',
170
+ ],
171
+ a11y: [
172
+ 'Use <button type="button"> with aria-label (or aria-labelledby). An icon alone has no accessible name.',
173
+ 'The svg is aria-hidden="true" and focusable="false"; the name comes from the button.',
174
+ 'Toggle buttons expose aria-pressed="true|false" and keep the same aria-label in both states.',
175
+ 'Focus ring on :focus-visible only, as a 4px box-shadow ring; never outline: none without a replacement.',
176
+ 'While loading set aria-busy="true" and keep the aria-label; disabled removes it from the tab order.',
177
+ ],
178
+ related: ['button', 'button-group', 'menu', 'tooltip'],
179
+ };
@@ -0,0 +1,52 @@
1
+ import { readdirSync } from 'node:fs';
2
+ import { dirname, extname, join } from 'node:path';
3
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4
+ export const CATEGORY_ORDER = ['typography', 'actions', 'forms', 'navigation', 'data-display', 'feedback', 'overlays', 'layout', 'media'];
5
+ export const CATEGORY_LABEL = {
6
+ typography: 'Typography', actions: 'Actions', forms: 'Forms', navigation: 'Navigation', 'data-display': 'Data display', feedback: 'Feedback', overlays: 'Overlays', layout: 'Layout', media: 'Media',
7
+ };
8
+ /** Preferred documentation order inside each category; unknown slugs go after, alphabetically. */
9
+ export const DOC_ORDER = [
10
+ 'kicker', 'prose', 'link', 'code', 'kbd',
11
+ 'button', 'icon-button', 'button-group', 'segmented-control',
12
+ 'field', 'input', 'textarea', 'select', 'combobox', 'checkbox', 'radio', 'switch', 'slider', 'file-dropzone',
13
+ 'topbar', 'tabs', 'sidebar-nav', 'breadcrumb', 'pagination', 'menu', 'command-palette', 'stepper',
14
+ 'badge', 'tag', 'counter', 'avatar', 'avatar-group', 'agent-presence', 'stat', 'table', 'list', 'description-list', 'accordion', 'timeline', 'kanban', 'chart-frame',
15
+ 'alert', 'banner', 'toast', 'progress', 'spinner', 'skeleton', 'empty-state',
16
+ 'tooltip', 'popover', 'dialog', 'drawer',
17
+ 'card', 'divider',
18
+ 'media-frame',
19
+ ];
20
+ let cache = null;
21
+ /** Discover source or compiled modules exporting a ComponentSpec. */
22
+ export async function loadCatalog() {
23
+ if (cache)
24
+ return cache;
25
+ const dir = dirname(fileURLToPath(import.meta.url));
26
+ const extension = extname(fileURLToPath(import.meta.url));
27
+ const files = readdirSync(dir).filter((f) => f.endsWith(extension) && !f.startsWith('_') && f !== `index${extension}`).sort();
28
+ const specs = [];
29
+ for (const f of files) {
30
+ const mod = await import(pathToFileURL(join(dir, f)).href);
31
+ for (const v of Object.values(mod)) {
32
+ if (v && typeof v === 'object' && 'slug' in v && 'anatomy' in v)
33
+ specs.push(v);
34
+ }
35
+ }
36
+ specs.sort(compareSpecs);
37
+ cache = specs;
38
+ return specs;
39
+ }
40
+ export function compareSpecs(a, b) {
41
+ const ca = CATEGORY_ORDER.indexOf(a.category), cb = CATEGORY_ORDER.indexOf(b.category);
42
+ if (ca !== cb)
43
+ return ca - cb;
44
+ const oa = DOC_ORDER.indexOf(a.slug), ob = DOC_ORDER.indexOf(b.slug);
45
+ if (oa !== -1 && ob !== -1)
46
+ return oa - ob;
47
+ if (oa !== -1)
48
+ return -1;
49
+ if (ob !== -1)
50
+ return 1;
51
+ return a.slug.localeCompare(b.slug);
52
+ }