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,170 @@
1
+ import { RESET_BUTTON, ex, ICON, typeStyle } from "./_shared.mjs";
2
+ // the reference breadcrumbs: 14px medium gray-600 links, the current page one step darker,
3
+ // 16px gray-300 chevrons (or a "/" divider), an optional 20px home icon; three trail types:
4
+ // text (20px), text with a hairline under it (36px) and button chips (28px, gray-50 when current).
5
+ const TRANSITION_FAST = {
6
+ 'transition-property': 'background-color, color, box-shadow',
7
+ 'transition-duration': '{motion.duration.fast}',
8
+ 'transition-timing-function': '{motion.easing.linear}',
9
+ };
10
+ const HAIRLINE = '{border.width.thin} solid {color.border-default}';
11
+ const chevron = ICON.chevronRight.replace('cn-icon', 'cn-breadcrumb__separator');
12
+ const SLASH = '<span class="cn-breadcrumb__separator" aria-hidden="true">/</span>';
13
+ const HOME = ICON.home.replace('cn-icon', 'cn-breadcrumb__icon');
14
+ const li = (inner) => `<li class="cn-breadcrumb__item">${inner}</li>`;
15
+ const lnk = (label, icon = '') => `<a href="#" class="cn-breadcrumb__link"${icon ? ` aria-label="${label}"` : ''}>${icon || label}</a>`;
16
+ const cur = (label) => `<span class="cn-breadcrumb__current" aria-current="page">${label}</span>`;
17
+ const crumbs = (variant, divider, size, parts, extra = '') => `<nav class="cn-breadcrumb" data-variant="${variant}" data-divider="${divider}" data-size="${size}" aria-label="Breadcrumb"${extra}><ol class="cn-breadcrumb__list">${parts.map((p, i) => li((i ? (divider === 'slash' ? SLASH : chevron) : '') + p)).join('')}</ol></nav>`;
18
+ export const breadcrumb = {
19
+ name: 'Breadcrumb',
20
+ slug: 'breadcrumb',
21
+ category: 'navigation',
22
+ description: 'The path to the current page in the reference\'s three trail types: 14px medium links separated by 16px gray-300 chevrons (or slashes), ending in the current page one step darker; plain text (20px), text on a hairline (36px) or 28px button chips. Sits above the page title.',
23
+ usage: 'Use on pages three or more levels deep (Settings → Team → Roles) so the user can go up one level with one click. Not for top-level pages, not for wizard progress (Stepper), never inside the Topbar.',
24
+ anatomy: [
25
+ { part: 'root', element: 'nav', description: 'The landmark: <nav aria-label="Breadcrumb">, 14px medium, fg-muted.' },
26
+ { part: 'list', element: 'ol', description: 'Ordered list of levels, root first. Flex row, 8px gaps, wraps on narrow screens.' },
27
+ { part: 'item', element: 'li', description: 'One level: an optional separator followed by a link, the ellipsis button, or the current page.' },
28
+ { part: 'link', element: 'a', description: 'An ancestor page, fg-muted; ink on hover (gray-50 chip in the button type). Truncates at 24 characters.' },
29
+ { part: 'icon', element: 'svg', description: 'Optional 20px home icon as the first link (with aria-label="Home"), fg-subtle.', optional: true },
30
+ { part: 'separator', element: 'svg', description: '16px gray-300 chevron (or a "/" span) before every item except the first. aria-hidden.' },
31
+ { part: 'ellipsis', element: 'button', description: 'The "…" that stands in for collapsed middle levels; opens a Menu listing them.', optional: true },
32
+ { part: 'current', element: 'span', description: 'The page the user is on: one step darker than the links, medium, aria-current="page". Not a link.' },
33
+ ],
34
+ props: {
35
+ variant: {
36
+ values: ['text', 'line', 'button'],
37
+ default: 'text',
38
+ description: 'text = plain 20px trail (page headers, the default). line = the trail sits on a full-width gray-200 hairline, 36px tall (above a page title without its own divider). button = each level is a 28px chip (radius 6, padding 4 × 8); the current one is filled gray-50 (record headers, drawers).',
39
+ },
40
+ divider: {
41
+ values: ['chevron', 'slash'],
42
+ default: 'chevron',
43
+ description: 'chevron = 16px gray-300 chevron-right (default). slash = a "/" in gray-300 (denser, developer-facing paths).',
44
+ },
45
+ size: {
46
+ values: ['sm', 'md'],
47
+ default: 'md',
48
+ description: 'md = 14px medium (text-sm); the page-header default. sm = 12px medium for drawers, dialogs and card headers.',
49
+ },
50
+ },
51
+ states: {
52
+ hover: { selector: ' .cn-breadcrumb__link:hover', description: 'Pointer over an ancestor link (on the LINK, not the root): ink text; in the button type also a gray-50 chip.', markup: 'native :hover on the link' },
53
+ focus: { selector: ' .cn-breadcrumb__link:focus-visible, & .cn-breadcrumb__ellipsis:focus-visible', description: 'Keyboard focus on a link or the ellipsis: the 4px brand ring.', markup: 'native :focus-visible' },
54
+ current: { selector: ' [aria-current="page"]', description: 'The last item: one step darker, medium, no hover, not a link (button type: gray-50 chip).', markup: 'aria-current="page" on the current span' },
55
+ },
56
+ base: {
57
+ root: {
58
+ display: 'flex',
59
+ 'align-items': 'center',
60
+ 'min-width': '0',
61
+ ...typeStyle('label-sm'),
62
+ color: '{color.fg-muted}',
63
+ },
64
+ list: {
65
+ display: 'flex',
66
+ 'align-items': 'center',
67
+ 'flex-wrap': 'wrap',
68
+ gap: '{space.2}',
69
+ 'min-width': '0',
70
+ margin: '0',
71
+ padding: '0',
72
+ 'list-style': 'none',
73
+ },
74
+ item: { display: 'inline-flex', 'align-items': 'center', gap: '{space.2}', 'min-width': '0' },
75
+ link: {
76
+ display: 'inline-flex',
77
+ 'align-items': 'center',
78
+ color: '{color.fg-muted}',
79
+ 'text-decoration': 'none',
80
+ 'border-radius': '{radius.sm}',
81
+ 'white-space': 'nowrap',
82
+ 'max-width': '24ch',
83
+ overflow: 'hidden',
84
+ 'text-overflow': 'ellipsis',
85
+ ...TRANSITION_FAST,
86
+ },
87
+ icon: { width: '{size.icon.md}', height: '{size.icon.md}', 'flex-shrink': '0', color: '{color.fg-subtle}', transition: 'inherit' },
88
+ separator: { display: 'inline-flex', 'align-items': 'center', 'justify-content': 'center', width: '{size.icon.sm}', height: '{size.icon.sm}', 'flex-shrink': '0', color: '{color.border-control}', 'user-select': 'none' },
89
+ ellipsis: {
90
+ ...RESET_BUTTON,
91
+ display: 'inline-flex',
92
+ 'align-items': 'center',
93
+ 'justify-content': 'center',
94
+ height: '{space.5}',
95
+ 'padding-inline': '{space.1}',
96
+ 'border-radius': '{radius.sm}',
97
+ color: '{color.fg-subtle}',
98
+ 'letter-spacing': '{font.letterSpacing.wide}',
99
+ ...TRANSITION_FAST,
100
+ },
101
+ current: {
102
+ display: 'inline-flex',
103
+ 'align-items': 'center',
104
+ color: '{color.fg-default}',
105
+ 'white-space': 'nowrap',
106
+ 'min-width': '0',
107
+ 'max-width': '32ch',
108
+ overflow: 'hidden',
109
+ 'text-overflow': 'ellipsis',
110
+ },
111
+ },
112
+ variants: {
113
+ variant: {
114
+ text: { root: {} },
115
+ line: { root: { width: '100%', 'padding-block': '{space.2} calc({space.2} - {border.width.thin})', 'border-bottom': HAIRLINE } },
116
+ button: {
117
+ list: { gap: '{space.1}' },
118
+ item: { gap: '{space.1}' },
119
+ link: { height: '{space.7}', padding: '{space.1} {space.2}', 'border-radius': '{radius.md}' },
120
+ current: { height: '{space.7}', padding: '{space.1} {space.2}', 'border-radius': '{radius.md}', 'background-color': '{color.bg-subtle}' },
121
+ ellipsis: { height: '{space.7}', padding: '{space.1} {space.2}', 'border-radius': '{radius.md}' },
122
+ },
123
+ },
124
+ divider: { chevron: { root: {} }, slash: { separator: { ...typeStyle('label-sm'), width: 'auto', 'padding-inline': '{space.0.5}' } } },
125
+ size: {
126
+ sm: {
127
+ root: { 'font-size': '{font.size.xs}' },
128
+ list: { gap: '{space.1.5}' },
129
+ item: { gap: '{space.1.5}' },
130
+ icon: { width: '{size.icon.sm}', height: '{size.icon.sm}' },
131
+ },
132
+ md: { root: {} },
133
+ },
134
+ },
135
+ extraCss: `
136
+ .cn-breadcrumb__link:hover { color: {color.fg-default}; }
137
+ .cn-breadcrumb__link:hover .cn-breadcrumb__icon { color: {color.fg-muted}; }
138
+ .cn-breadcrumb[data-variant="button"] .cn-breadcrumb__link:hover, .cn-breadcrumb__ellipsis:hover { color: {color.fg-default}; background-color: {color.bg-subtle}; }
139
+ .cn-breadcrumb__link:focus-visible, .cn-breadcrumb__ellipsis:focus-visible { outline: none; box-shadow: {shadow.focus}; }`,
140
+ examples: [
141
+ ex('Text (default)', crumbs('text', 'chevron', 'md', [lnk('Settings'), lnk('Team'), cur('Roles and permissions')]), 'Ancestors gray-600, the current page one step darker; 16px gray-300 chevrons.'),
142
+ ex('With home icon', crumbs('text', 'chevron', 'md', [lnk('Home', HOME), lnk('Projects'), lnk('Lumen website'), cur('Design')]), 'A 20px home icon replaces the first label (aria-label="Home").'),
143
+ ex('Slash divider', crumbs('text', 'slash', 'md', [lnk('Home', HOME), lnk('Projects'), cur('Lumen website')]), 'data-divider="slash" for denser, path-like trails.'),
144
+ ex('Text with line', `<div style="width:100%;max-width:560px">${crumbs('line', 'chevron', 'md', [lnk('Home', HOME), lnk('Customers'), cur('Sofia Almeida')])}</div>`, '36px tall on a full-width hairline; use above a title that has no divider of its own.'),
145
+ ex('Button chips', crumbs('button', 'chevron', 'md', [lnk('Home', HOME), lnk('Invoices'), cur('INV-0042')]), '28px chips, radius 6; the current page is a gray-50 chip and hovered ancestors light up.'),
146
+ ex('Collapsed middle', crumbs('text', 'chevron', 'md', [lnk('Home', HOME), `<button type="button" class="cn-breadcrumb__ellipsis" aria-label="Show 2 hidden levels" aria-haspopup="menu" aria-expanded="false">…</button>`, lnk('Q3 launch'), cur('Design review')]), 'Deeper than four levels: keep root, the parent and the current page; the ellipsis opens a Menu with the rest.'),
147
+ ex('Small, inside a drawer header', crumbs('text', 'chevron', 'sm', [lnk('Settings'), lnk('Team'), cur('Roles and permissions')])),
148
+ ex('Long names truncate', crumbs('text', 'chevron', 'md', [lnk('Home', HOME), lnk('Enterprise procurement pipeline for the northern region'), cur('Annual supply agreement 2026 with Daniel Costa\'s team')]), 'Links cut at 24ch, the current page at 32ch; the full text goes in a title attribute.'),
149
+ ],
150
+ rules: [
151
+ 'Max 4 visible levels. Deeper paths collapse the middle into an ellipsis that opens a Menu of the hidden levels.',
152
+ 'Labels are the exact page titles of the ancestors (same text as their h1), not shortened synonyms.',
153
+ 'The last item is the current page: darker, not a link, aria-current="page". Never end with a separator.',
154
+ 'Never show a one-level breadcrumb ("Home" alone). Below three levels, skip the breadcrumb entirely.',
155
+ 'Place it in the page header directly above the title, 8px apart. Never in the Topbar and never at the bottom of the page.',
156
+ 'Separators are the 16px gray-300 chevron, or the slash for path-like trails; never arrows, dots or both in one app.',
157
+ 'text in page headers; line when the header has no divider of its own; button in record headers and drawers where the trail is also a filter.',
158
+ 'sm only inside drawers, dialogs and card headers; md everywhere else.',
159
+ 'Truncate long names with an ellipsis and provide the full name in a title; never wrap a single label across lines.',
160
+ ],
161
+ a11y: [
162
+ 'Root is <nav aria-label="Breadcrumb"> containing an <ol>; order conveys hierarchy.',
163
+ 'The current page has aria-current="page" and is not a link (or is a link only if it reloads the same page).',
164
+ 'Separators are aria-hidden="true"; the chevron or slash is decoration, the list order is the semantics.',
165
+ 'A home icon link carries aria-label="Home"; the icon itself is aria-hidden.',
166
+ 'The ellipsis is a <button aria-label="Show N hidden levels" aria-haspopup="menu" aria-expanded>, not plain text.',
167
+ 'Truncated labels keep their full text in a title attribute and in the accessible name.',
168
+ ],
169
+ related: ['link', 'menu', 'page-header', 'topbar', 'sidebar-nav'],
170
+ };
@@ -0,0 +1,114 @@
1
+ import { CONTROL, ex, ICON } from "./_shared.mjs";
2
+ // the system ButtonGroup: attached secondary-gray buttons (36/40/44px) whose 1px
3
+ // rings overlap by 1px, outer corners 8px, inner corners square, shadow-xs on the
4
+ // container, selected item (aria-pressed) in the gray-50 hover fill.
5
+ const outline = (label, attrs = '', leading = '', size = 'md') => `<button type="button" class="cn-button" data-variant="outline" data-size="${size}"${attrs}>${leading}<span class="cn-button__label">${label}</span></button>`;
6
+ const iconBtn = (label, i, variant = 'outline', attrs = '') => `<button type="button" class="cn-icon-button" data-variant="${variant}" data-size="md" data-shape="square" aria-label="${label}"${attrs}>${ICON[i].replace('cn-icon', 'cn-icon-button__icon')}</button>`;
7
+ const group = (attrs, children) => `<div class="cn-button-group" data-variant="attached" data-orientation="horizontal" ${attrs}>${children}</div>`;
8
+ export const buttonGroup = {
9
+ name: 'ButtonGroup',
10
+ slug: 'button-group',
11
+ category: 'actions',
12
+ description: 'A row (or column) of related Buttons. Attached groups fuse into one control by squaring the inner corners and overlapping the 1px rings (the reference button group); spaced groups just keep a consistent gap.',
13
+ usage: 'Use attached for actions that belong to one object (Assign · Snooze · Archive), for split buttons (action + chevron menu) and for a toggle group where one option is on (aria-pressed on the selected child). Use spaced for a row of independent toolbar actions. For a rail-style single choice use SegmentedControl.',
14
+ anatomy: [
15
+ { part: 'root', element: 'div', description: 'Inline-flex container with role="group" and an aria-label. Children are outline Buttons or IconButtons of the same size; attached groups carry the shadow-xs and the 8px outer radius.' },
16
+ ],
17
+ props: {
18
+ variant: {
19
+ values: ['attached', 'spaced'],
20
+ default: 'attached',
21
+ description: 'attached = no gap, inner corners squared, rings overlap by 1px so the group reads as one control (the reference ButtonGroup); spaced = 12px gap, every button keeps its own radius and shadow.',
22
+ },
23
+ orientation: {
24
+ values: ['horizontal', 'vertical'],
25
+ default: 'horizontal',
26
+ description: 'horizontal = a row (toolbars, headers, split buttons); vertical = a stacked column (zoom controls, ordering, side rails). Vertical attached groups stretch children to the same width.',
27
+ },
28
+ },
29
+ states: {},
30
+ base: {
31
+ root: {
32
+ position: 'relative',
33
+ 'z-index': '{z.base}',
34
+ display: 'inline-flex',
35
+ 'align-items': 'stretch',
36
+ 'flex-wrap': 'nowrap',
37
+ 'vertical-align': 'middle',
38
+ 'max-width': '100%',
39
+ },
40
+ },
41
+ variants: {
42
+ variant: {
43
+ attached: { root: { gap: '0', 'border-radius': '{radius.control}', 'box-shadow': '{shadow.xs}' } },
44
+ spaced: { root: { gap: '{space.3}', 'flex-wrap': 'wrap' } },
45
+ },
46
+ orientation: {
47
+ horizontal: { root: { 'flex-direction': 'row' } },
48
+ vertical: { root: { 'flex-direction': 'column', 'align-items': 'stretch' } },
49
+ },
50
+ },
51
+ extraCss: `
52
+ .cn-button-group > .cn-button { flex-shrink: 1; min-width: 0; height: auto; min-height: {size.control.md}; white-space: normal; }
53
+ .cn-button-group > .cn-button > .cn-button__label { min-width: 0; overflow-wrap: anywhere; }
54
+ ${Object.entries(CONTROL).map(([size, control]) => `.cn-button-group > .cn-button[data-size="${size}"] { min-height: ${control.height}; }`).join('\n')}
55
+ .cn-button-group[data-variant="attached"][data-orientation="horizontal"] > .cn-button:not(:first-child),
56
+ .cn-button-group[data-variant="attached"][data-orientation="horizontal"] > .cn-icon-button:not(:first-child) { border-start-start-radius: 0; border-end-start-radius: 0; margin-inline-start: -1px; }
57
+ .cn-button-group[data-variant="attached"][data-orientation="horizontal"] > .cn-button:not(:last-child),
58
+ .cn-button-group[data-variant="attached"][data-orientation="horizontal"] > .cn-icon-button:not(:last-child) { border-start-end-radius: 0; border-end-end-radius: 0; }
59
+ .cn-button-group[data-variant="attached"][data-orientation="vertical"] > .cn-button:not(:first-child),
60
+ .cn-button-group[data-variant="attached"][data-orientation="vertical"] > .cn-icon-button:not(:first-child) { border-start-start-radius: 0; border-start-end-radius: 0; margin-block-start: -1px; }
61
+ .cn-button-group[data-variant="attached"][data-orientation="vertical"] > .cn-button:not(:last-child),
62
+ .cn-button-group[data-variant="attached"][data-orientation="vertical"] > .cn-icon-button:not(:last-child) { border-end-start-radius: 0; border-end-end-radius: 0; }
63
+ .cn-button-group[data-variant="attached"] > .cn-button::before,
64
+ .cn-button-group[data-variant="attached"] > .cn-icon-button::before { border-radius: inherit; }
65
+ .cn-button-group[data-variant="attached"] > .cn-button:hover,
66
+ .cn-button-group[data-variant="attached"] > .cn-button:focus-visible,
67
+ .cn-button-group[data-variant="attached"] > .cn-button[aria-pressed="true"],
68
+ .cn-button-group[data-variant="attached"] > .cn-icon-button:hover,
69
+ .cn-button-group[data-variant="attached"] > .cn-icon-button:focus-visible,
70
+ .cn-button-group[data-variant="attached"] > .cn-icon-button[aria-pressed="true"] { z-index: {z.raised}; }
71
+ /* the reference: items carry the ring + skeuomorphic pair only; the container carries the single shadow-xs. */
72
+ .cn-button-group[data-variant="attached"] > .cn-button[data-variant="outline"],
73
+ .cn-button-group[data-variant="attached"] > .cn-icon-button[data-variant="outline"] { box-shadow: inset 0 0 0 1px {color.border-control}, {shadow.control}; }
74
+ .cn-button-group[data-variant="attached"] > .cn-button[data-variant="outline"]:focus-visible,
75
+ .cn-button-group[data-variant="attached"] > .cn-icon-button[data-variant="outline"]:focus-visible { box-shadow: inset 0 0 0 1px {color.border-control}, {shadow.control}, {shadow.focus}; }
76
+ .cn-button-group[data-variant="attached"] > .cn-button[data-variant="primary"],
77
+ .cn-button-group[data-variant="attached"] > .cn-icon-button[data-variant="primary"] { box-shadow: {shadow.control}; }
78
+ .cn-button-group[data-variant="attached"] > .cn-button[data-variant="primary"]:focus-visible,
79
+ .cn-button-group[data-variant="attached"] > .cn-icon-button[data-variant="primary"]:focus-visible { box-shadow: {shadow.control}, {shadow.focus}; }
80
+ .cn-button-group[data-variant="attached"] > .cn-button[aria-pressed="true"],
81
+ .cn-button-group[data-variant="attached"] > .cn-icon-button[aria-pressed="true"] { background-color: {color.bg-subtle}; color: {color.fg-default}; }
82
+ .cn-button-group[data-variant="attached"] > .cn-button[aria-pressed="true"] .cn-button__icon,
83
+ .cn-button-group[data-variant="attached"] > .cn-icon-button[aria-pressed="true"] .cn-icon-button__icon { color: {color.fg-muted}; }
84
+ .cn-button-group[data-variant="attached"] > .cn-button:disabled { opacity: 1; color: color-mix(in srgb, {color.fg-muted} 50%, transparent); }
85
+ .cn-button-group[data-variant="attached"] > .cn-button:disabled > * { opacity: {opacity.disabled}; }
86
+ .cn-button-group[data-orientation="vertical"] > .cn-button { justify-content: flex-start; }`,
87
+ examples: [
88
+ ex('Attached (actions on one object)', group('role="group" aria-label="Conversation actions"', outline('Assign') + outline('Snooze') + outline('Archive')), 'Outline buttons fuse into one control: inner corners squared, rings overlap by 1px, shadow-xs on the group.'),
89
+ ex('Toggle group (one option on)', group('role="group" aria-label="Range"', outline('12 months', ' aria-pressed="true"') + outline('30 days', ' aria-pressed="false"') + outline('7 days', ' aria-pressed="false"') + outline('24 hours', ' aria-pressed="false"')), 'The reference ButtonGroup: the selected child (aria-pressed="true") sits in the gray-50 fill with default ink.'),
90
+ ex('With leading icons', group('role="group" aria-label="View"', outline('Text', '', ICON.menu.replace('cn-icon', 'cn-button__icon')) + outline('Grid', ' aria-pressed="true"', ICON.home.replace('cn-icon', 'cn-button__icon')) + outline('Calendar', '', ICON.calendar.replace('cn-icon', 'cn-button__icon'))), 'Icons are gray-400 at rest and gray-500 on the selected item.'),
91
+ ex('Split button', group('role="group" aria-label="Send invite"', `<button type="button" class="cn-button" data-variant="primary" data-size="md"><span class="cn-button__label">Send invite</span></button>` + iconBtn('More send options', 'chevronDown', 'primary', ' aria-haspopup="menu" aria-expanded="false"')), 'The only case where an attached group is primary: the main action plus a chevron that opens a Menu.'),
92
+ ex('Attached icon buttons', group('role="group" aria-label="Row actions"', iconBtn('Copy ID', 'copy') + iconBtn('Open in new tab', 'external') + iconBtn('Delete', 'trash'))),
93
+ ex('Sizes', `<div style="display:flex;flex-wrap:wrap;align-items:center;gap:var(--cn-space-4)">${group('role="group" aria-label="Range small"', outline('Day', ' aria-pressed="true"', '', 'sm') + outline('Week', '', '', 'sm') + outline('Month', '', '', 'sm'))}${group('role="group" aria-label="Range medium"', outline('Day', ' aria-pressed="true"') + outline('Week') + outline('Month'))}${group('role="group" aria-label="Range large"', outline('Day', ' aria-pressed="true"', '', 'lg') + outline('Week', '', '', 'lg') + outline('Month', '', '', 'lg'))}</div>`, '36 / 40 / 44px, from the children\'s size.'),
94
+ ex('Spaced', `<div class="cn-button-group" data-variant="spaced" data-orientation="horizontal" role="group" aria-label="List tools">${outline('Filter', '', ICON.menu.replace('cn-icon', 'cn-button__icon'))}${outline('Sort')}${outline('Export', '', ICON.external.replace('cn-icon', 'cn-button__icon'))}</div>`, 'Independent tools keep their own radius; 12px apart.'),
95
+ ex('Vertical attached with a disabled child', `<div class="cn-button-group" data-variant="attached" data-orientation="vertical" role="group" aria-label="Reorder">${outline('Move up')}${outline('Move down')}${outline('Remove', ' disabled')}</div>`, 'Children stretch to the widest label; text aligns to the start. A disabled child fades its content, not its ring.'),
96
+ ],
97
+ rules: [
98
+ 'Group only actions that act on the same object or belong to the same tool. Cancel and Save are not a group; they are an action row.',
99
+ 'All children share one size and the outline variant. The single exception is the split button: primary action + primary chevron.',
100
+ 'Attached groups hold 2–5 children. Beyond that, use a toolbar of spaced groups or move the rest into a Menu.',
101
+ 'Attached = one control (the corners say so). Spaced = several controls. Do not attach unrelated tools just to save space.',
102
+ 'A toggle group marks exactly one child with aria-pressed="true" (a range, a view). For a rail with a lifted white item use SegmentedControl instead.',
103
+ 'Vertical groups are rare: zoom controls, reorder handles, a rail of icon actions. Never a vertical group of text actions in a form.',
104
+ 'Do not mix Button and IconButton sizes inside one group; the heights must match exactly for the rings to overlap cleanly.',
105
+ 'Disabled children stay in the group (the layout must not jump); the reason shows in a Tooltip.',
106
+ ],
107
+ a11y: [
108
+ 'The root has role="group" and an aria-label naming what the actions act on ("Conversation actions").',
109
+ 'Every child is a real <button>; IconButtons keep their aria-label. The group adds no keyboard behavior (Tab moves between children).',
110
+ 'Toggle children expose aria-pressed="true|false"; a split button chevron exposes aria-haspopup="menu" and aria-expanded with its own aria-label.',
111
+ 'Focus rings are raised above neighbours (z-index) so the ring is never clipped by the next button.',
112
+ ],
113
+ related: ['button', 'icon-button', 'segmented-control', 'menu'],
114
+ };
@@ -0,0 +1,197 @@
1
+ import { CONTROL, RESET_BUTTON, STATE, SIZE_PROP, ex, ICON, typeStyle } from "./_shared.mjs";
2
+ // Flat controls with an explicit hierarchy; all dimensions follow the active tokens.
3
+ const TRANSITION_FAST = {
4
+ 'transition-property': 'background-color, border-color, color, box-shadow, opacity, text-decoration-color',
5
+ 'transition-duration': '{motion.duration.fast}',
6
+ 'transition-timing-function': '{motion.easing.linear}',
7
+ };
8
+ /** Inset borders preserve dimensions across states. */
9
+ const ring = (color) => `inset 0 0 0 1px ${color}`;
10
+ /** Optional token-defined depth; the Canon preset keeps both layers flat. */
11
+ const SHADOW_SOLID = '{shadow.control}, {shadow.xs}';
12
+ /** Bordered controls share the same optional depth tokens. */
13
+ const shadowRinged = (color) => `${ring(color)}, ${SHADOW_SOLID}`;
14
+ /** Icons keep the label contrast on both light and dark action fills. */
15
+ const ICON_ON_SOLID = 'currentColor';
16
+ const ICON_ON_SOLID_HOVER = 'currentColor';
17
+ /** Filled button (primary / primary-destructive). Loading = hover fill, for continuity. */
18
+ const solid = (bg, hoverBg, fg, focus) => ({
19
+ root: { 'background-color': bg, color: fg, 'box-shadow': SHADOW_SOLID },
20
+ icon: { color: ICON_ON_SOLID },
21
+ '@states': {
22
+ hover: { root: { 'background-color': hoverBg }, icon: { color: ICON_ON_SOLID_HOVER } },
23
+ loading: { root: { 'background-color': hoverBg } },
24
+ focus: { root: { 'box-shadow': `${SHADOW_SOLID}, ${focus}` } },
25
+ },
26
+ });
27
+ /** Ringed button (secondary gray / secondary color / secondary destructive). */
28
+ const bordered = (o) => ({
29
+ root: { 'background-color': o.bg, color: o.fg, 'box-shadow': shadowRinged(o.ringColor) },
30
+ icon: { color: o.icon },
31
+ '@states': {
32
+ hover: { root: { 'background-color': o.hoverBg, color: o.hoverFg }, icon: { color: o.hoverIcon } },
33
+ loading: { root: { 'background-color': o.hoverBg } },
34
+ focus: { root: { 'box-shadow': `${shadowRinged(o.ringColor)}, ${o.focus}` } },
35
+ },
36
+ });
37
+ /** Transparent button (tertiary gray / tertiary destructive). */
38
+ const plain = (o) => ({
39
+ root: { 'background-color': 'transparent', color: o.fg, 'box-shadow': 'none' },
40
+ icon: { color: o.icon },
41
+ '@states': {
42
+ hover: { root: { 'background-color': o.hoverBg, color: o.hoverFg }, icon: { color: o.hoverIcon } },
43
+ loading: { root: { 'background-color': o.hoverBg } },
44
+ focus: { root: { 'box-shadow': o.focus } },
45
+ },
46
+ });
47
+ /** Link-styled button (link-color / link-gray / link-destructive): no padding, height = line-height, radius 4, underline only on hover. */
48
+ const linkLike = (o) => ({
49
+ root: { 'background-color': 'transparent', color: o.fg, 'box-shadow': 'none', height: 'auto', 'padding-inline': '0', 'justify-content': 'normal', 'border-radius': '{radius.sm}' },
50
+ label: { 'padding-inline': '0', 'text-decoration-line': 'underline', 'text-decoration-color': 'transparent', 'text-underline-offset': '3px' },
51
+ icon: { color: o.icon },
52
+ '@states': {
53
+ hover: { root: { color: o.hoverFg }, label: { 'text-decoration-color': o.decoration }, icon: { color: o.hoverIcon } },
54
+ focus: { root: { 'box-shadow': o.focus } },
55
+ },
56
+ });
57
+ const LG = new Set(['lg', 'xl']);
58
+ const sizeBlock = (s) => ({
59
+ root: {
60
+ height: CONTROL[s].height,
61
+ 'padding-inline': CONTROL[s].px,
62
+ 'font-size': CONTROL[s].font,
63
+ gap: '{space.2}',
64
+ ...(LG.has(s) ? { 'line-height': '{font.lineHeight.normal}' } : {}),
65
+ },
66
+ label: LG.has(s) ? { 'text-underline-offset': '4px' } : {},
67
+ icon: { width: CONTROL[s].icon, height: CONTROL[s].icon },
68
+ spinner: { width: CONTROL[s].icon, height: CONTROL[s].icon },
69
+ });
70
+ const btn = (variant, label, attrs = '', leading = '', trailing = '', size = 'md') => `<button type="button" class="cn-button" data-variant="${variant}" data-size="${size}"${attrs}>${leading}<span class="cn-button__label">${label}</span>${trailing}</button>`;
71
+ const icon = (name) => ICON[name].replace('cn-icon', 'cn-button__icon');
72
+ const row = (...items) => `<div style="display:flex;flex-wrap:wrap;align-items:center;gap:var(--cn-space-3)">${items.join('')}</div>`;
73
+ export const button = {
74
+ name: 'Button',
75
+ slug: 'button',
76
+ category: 'actions',
77
+ description: 'Triggers an action. Eleven intents share one shape (the system button): flat filled primary with a clear pressed state, ringed secondaries, transparent tertiaries and link styles, each in gray, brand and destructive voices. The intent is a data attribute, never a different component.',
78
+ usage: 'Use for actions (save, send, open a dialog). Use Link for navigation inside text. One primary button per view region; put it last (right) in a row of actions. Destructive actions use the danger* variants and always confirm.',
79
+ anatomy: [
80
+ { part: 'root', element: 'button', description: 'The interactive element. Always a <button type="button|submit"> or an <a role="button"> when it navigates. Add data-icon-only when it holds a single icon and a visually hidden label.' },
81
+ { part: 'icon', element: 'svg', description: 'Optional leading or trailing icon, 20px (16px on xs). Decorative: aria-hidden. Matches the label on filled buttons; secondary icons use semantic foreground tokens.', optional: true },
82
+ { part: 'label', element: 'span', description: 'The action label. Verb first ("Save changes"), sentence case, no trailing period.' },
83
+ { part: 'spinner', element: 'span', description: 'Shown only while data-loading; replaces the icon slot, the label becomes invisible but keeps its width.', optional: true },
84
+ ],
85
+ props: {
86
+ variant: {
87
+ values: ['primary', 'secondary', 'outline', 'ghost', 'danger', 'link', 'secondary-color', 'link-color', 'danger-outline', 'danger-ghost', 'danger-link'],
88
+ default: 'primary',
89
+ description: 'primary = the main filled action; outline = a single neutral edge; secondary = a quiet filled alternative; secondary-color = an action-colored outline; ghost = a transparent toolbar action. link and link-color align with text. The danger variants apply the same hierarchy to destructive actions.',
90
+ },
91
+ size: SIZE_PROP(),
92
+ icon: { values: ['default', 'only'], default: 'default', description: 'only = square icon-only button (padding equals the vertical padding); requires aria-label.' },
93
+ },
94
+ states: {
95
+ hover: STATE.hover(),
96
+ active: { selector: ':active:not(:disabled):not([aria-disabled="true"])', description: 'While pressed: the primary action deepens to bg-action-active.', markup: 'native :active' },
97
+ focus: { selector: ':focus-visible', description: 'Keyboard focus. The token-defined focus ring (red for danger*) is added under the existing ring and shadow; never on mouse click.', markup: 'native :focus-visible' },
98
+ 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")' },
99
+ loading: STATE.loading(),
100
+ },
101
+ base: {
102
+ root: {
103
+ ...RESET_BUTTON,
104
+ position: 'relative',
105
+ display: 'inline-flex',
106
+ 'align-items': 'center',
107
+ 'justify-content': 'center',
108
+ 'flex-shrink': '0',
109
+ 'white-space': 'nowrap',
110
+ ...typeStyle('label-md'),
111
+ 'font-size': CONTROL.md.font,
112
+ 'border-radius': '{radius.control}',
113
+ ...TRANSITION_FAST,
114
+ },
115
+ icon: { 'flex-shrink': '0', display: 'block', 'pointer-events': 'none', transition: 'inherit' },
116
+ label: { display: 'inline-block', transition: 'inherit' },
117
+ spinner: {
118
+ display: 'none',
119
+ position: 'absolute',
120
+ inset: '0',
121
+ margin: 'auto',
122
+ 'border-radius': '{radius.full}',
123
+ border: '2px solid currentColor',
124
+ 'border-right-color': 'transparent',
125
+ animation: 'cn-spin 0.7s linear infinite',
126
+ },
127
+ '@states': {
128
+ focus: { root: { outline: 'none', 'box-shadow': '{shadow.focus}' } },
129
+ disabled: { root: { opacity: '{opacity.disabled}', cursor: 'not-allowed' } },
130
+ loading: {
131
+ root: { cursor: 'progress', 'pointer-events': 'none' },
132
+ label: { visibility: 'hidden' },
133
+ icon: { visibility: 'hidden' },
134
+ spinner: { display: 'block' },
135
+ },
136
+ },
137
+ },
138
+ // `size` is emitted before `variant` on purpose: link variants override height/padding
139
+ // of the size rules without needing a compound per size.
140
+ variants: {
141
+ size: {
142
+ xs: sizeBlock('xs'),
143
+ sm: sizeBlock('sm'),
144
+ md: sizeBlock('md'),
145
+ lg: sizeBlock('lg'),
146
+ xl: sizeBlock('xl'),
147
+ },
148
+ variant: {
149
+ primary: solid('{color.bg-action}', '{color.bg-action-hover}', '{color.fg-on-action}', '{shadow.focus}'),
150
+ secondary: bordered({ bg: '{color.bg-subtle}', ringColor: '{color.border-control}', fg: '{color.fg-muted}', hoverBg: '{color.bg-muted}', hoverFg: '{color.fg-default}', icon: '{color.fg-subtle}', hoverIcon: '{color.fg-muted}', focus: '{shadow.focus}' }),
151
+ outline: bordered({ bg: '{color.bg-surface}', ringColor: '{color.border-control}', fg: '{color.fg-muted}', hoverBg: '{color.bg-subtle}', hoverFg: '{color.fg-default}', icon: '{color.fg-subtle}', hoverIcon: '{color.fg-muted}', focus: '{shadow.focus}' }),
152
+ 'secondary-color': bordered({ bg: '{color.bg-surface}', ringColor: '{color.border-action}', fg: '{color.fg-action}', hoverBg: '{color.bg-action-subtle}', hoverFg: '{color.fg-link-hover}', icon: '{brand.500}', hoverIcon: '{brand.600}', focus: '{shadow.focus}' }),
153
+ ghost: plain({ fg: '{color.fg-muted}', hoverBg: '{color.bg-subtle}', hoverFg: '{color.fg-default}', icon: '{color.fg-subtle}', hoverIcon: '{color.fg-muted}', focus: '{shadow.focus}' }),
154
+ link: linkLike({ fg: '{color.fg-muted}', hoverFg: '{color.fg-default}', decoration: '{color.fg-subtle}', icon: '{color.fg-subtle}', hoverIcon: '{color.fg-muted}', focus: '{shadow.focus}' }),
155
+ 'link-color': linkLike({ fg: '{color.fg-action}', hoverFg: '{color.fg-link-hover}', decoration: '{brand.500}', icon: '{brand.500}', hoverIcon: '{brand.600}', focus: '{shadow.focus}' }),
156
+ danger: solid('{color.bg-danger}', '{color.bg-danger-hover}', '{white}', '{shadow.focus-danger}'),
157
+ 'danger-outline': bordered({ bg: '{color.bg-surface}', ringColor: '{color.border-danger}', fg: '{color.fg-danger}', hoverBg: '{color.bg-danger-subtle}', hoverFg: '{color.fg-danger}', icon: '{red.500}', hoverIcon: '{red.600}', focus: '{shadow.focus-danger}' }),
158
+ 'danger-ghost': plain({ fg: '{color.fg-danger}', hoverBg: '{color.bg-danger-subtle}', hoverFg: '{color.fg-danger}', icon: '{red.500}', hoverIcon: '{red.600}', focus: '{shadow.focus-danger}' }),
159
+ 'danger-link': linkLike({ fg: '{color.fg-danger}', hoverFg: '{color.fg-danger}', decoration: 'currentColor', icon: '{red.500}', hoverIcon: '{red.600}', focus: '{shadow.focus-danger}' }),
160
+ },
161
+ },
162
+ extraCss: `
163
+ .cn-button[data-variant="primary"]:active:not(:disabled):not([aria-disabled="true"]) { background-color: {color.bg-action-active}; }
164
+ @keyframes cn-spin { to { transform: rotate(360deg); } }
165
+ .cn-button[data-icon-only] { padding-inline: 0; aspect-ratio: 1 / 1; }
166
+ .cn-button[data-icon-only] .cn-button__label:not(.cn-sr-only) { display: none; }`,
167
+ examples: [
168
+ ex('Primary', btn('primary', 'Save changes'), 'Flat action fill with a contrasting label. One primary action per view region.'),
169
+ ex('Gray and brand hierarchy', row(btn('outline', 'Save changes'), btn('secondary-color', 'Save changes'), btn('ghost', 'Save changes'), btn('link', 'Save changes'), btn('link-color', 'Save changes')), 'outline = Secondary gray, secondary-color = Secondary color, ghost = Tertiary, link = Link gray, link-color = Link color.'),
170
+ ex('Secondary (subtle fill)', btn('secondary', 'Save changes'), 'The secondary hover fill at rest. For dense toolbars where a white button would disappear.'),
171
+ ex('Destructive', row(btn('danger', 'Delete'), btn('danger-outline', 'Delete'), btn('danger-ghost', 'Delete'), btn('danger-link', 'Delete')), 'Primary, secondary, tertiary and link destructive. Every one of them confirms before acting.'),
172
+ ex('Sizes', row(btn('outline', 'Save changes', '', icon('plus'), '', 'xs'), btn('outline', 'Save changes', '', icon('plus'), '', 'sm'), btn('outline', 'Save changes', '', icon('plus'), '', 'md'), btn('outline', 'Save changes', '', icon('plus'), '', 'lg'), btn('outline', 'Save changes', '', icon('plus'), '', 'xl')), '32 / 36 / 40 / 44 / 48px. Text stays 14px up to md and becomes 16px on lg and xl; icons are 20px (16px on xs).'),
173
+ ex('With icons and icon-only', row(btn('primary', 'Save changes', '', icon('plus')), btn('outline', 'Save changes', '', '', icon('arrow')), btn('outline', '<span class="cn-sr-only">Copy link</span>', ' data-icon-only aria-label="Copy link"', icon('copy')), btn('ghost', '<span class="cn-sr-only">More</span>', ' data-icon-only aria-label="More"', icon('dots'))), 'Leading icon = what the action is; trailing = where it goes. data-icon-only squares the padding (40×40 on md).'),
174
+ ex('Loading', row(`<button type="button" class="cn-button" data-variant="primary" data-size="md" data-loading aria-busy="true"><span class="cn-button__spinner" aria-hidden="true"></span><span class="cn-button__label">Saving…</span></button>`, `<button type="button" class="cn-button" data-variant="outline" data-size="md" data-loading aria-busy="true"><span class="cn-button__spinner" aria-hidden="true"></span><span class="cn-button__label">Exporting…</span></button>`), 'The fill switches to the hover color, the label hides but keeps its width.'),
175
+ ex('Disabled', row(btn('primary', 'Save changes', ' disabled'), btn('outline', 'Save changes', ' disabled'), btn('ghost', 'Save changes', ' disabled'), btn('danger', 'Delete', ' disabled')), 'Whole button at 50% opacity, cursor not-allowed.'),
176
+ ],
177
+ recipes: [
178
+ ex('Action row (dialog footer)', `<div style="display:flex;gap:var(--cn-space-3);justify-content:flex-end">${btn('outline', 'Cancel')}${btn('primary', 'Confirm')}</div>`, 'Secondary gray first, primary last, 12px apart. Never two filled buttons.'),
179
+ ],
180
+ rules: [
181
+ 'Exactly one primary button per view region (a dialog, a page header, a form). If two actions feel primary, one of them is not.',
182
+ 'Labels are verbs in sentence case: "Save changes", "Send invite", "Delete". Never "OK", "Submit", "Click here".',
183
+ 'Icons are optional and decorative. Leading icon = what the action is (plus, upload); trailing icon = where it goes (arrow, external). Icon-only buttons set data-icon-only plus aria-label (or use IconButton).',
184
+ 'Never change the height by adding padding. Height comes only from size: 32 / 36 / 40 / 44 / 48px, padding 10 / 12 / 14 / 16 / 18px.',
185
+ 'Link variants (link, link-color, danger-link) have no padding and no height: they align with text. Use them for a low-emphasis action in a row of controls, never inside running text (that is Link).',
186
+ 'Loading: set data-loading and aria-busy="true"; keep the label in the DOM so width does not jump. The fill becomes the hover fill.',
187
+ 'Do not put two filled (primary + danger) buttons side by side. Danger actions live behind a confirm dialog.',
188
+ 'Full-width buttons only on mobile sheets and auth forms.',
189
+ ],
190
+ a11y: [
191
+ 'Use <button>. Only use <a> when the action navigates, and then it is a Link or a button-styled anchor with a real href.',
192
+ 'Icon-only buttons need an accessible name: aria-label on the button (or a cn-sr-only label inside), plus data-icon-only for the square padding.',
193
+ 'disabled removes it from the tab order; if the user needs to know why, keep it enabled and show the reason on click.',
194
+ 'Focus ring is shown on :focus-visible only, as a 4px box-shadow ring; never outline: none without replacement.',
195
+ ],
196
+ related: ['icon-button', 'button-group', 'link'],
197
+ };