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,163 @@
1
+ import { ex, typeStyle } from "./_shared.mjs";
2
+ // the reference checkbox: 16px box with radius 4 (sm) or 20px with radius 6 (md), 1px inset gray-300
3
+ // ring on white, brand-600 fill with a white 12 / 14px check (or a dash when indeterminate), 8 / 12px
4
+ // to a 14 / 16px medium label with a muted supporting line; box nudged 2px down to sit on the first
5
+ // line. Focus = 4px brand ring on the box; disabled = box at 50% (gray-100 when unchecked).
6
+ const TRANSITION_FAST = {
7
+ 'transition-property': 'background-color, box-shadow, color, opacity',
8
+ 'transition-duration': '{motion.duration.fast}',
9
+ 'transition-timing-function': '{motion.easing.linear}',
10
+ };
11
+ const ring = (color) => `inset 0 0 0 1px ${color}`;
12
+ /** the reference check: 14-unit viewBox, 2px stroke, round caps. */
13
+ const check = '<svg class="cn-checkbox__indicator" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11.6666 3.5L5.24992 9.91667L2.33325 7"/></svg>';
14
+ /** label wrapper → hidden input → box → text (+ optional description) */
15
+ const cb = (root, input, label, description = '', variant = 'default') => `<label class="cn-checkbox" data-variant="${variant}" ${root}><input class="cn-checkbox__input" type="checkbox" ${input}><span class="cn-checkbox__control" aria-hidden="true">${check}</span><span class="cn-checkbox__label">${label}</span>${description ? `<span class="cn-checkbox__description">${description}</span>` : ''}</label>`;
16
+ const stack = (...items) => `<div style="display:flex;flex-direction:column;gap:var(--cn-space-3)">${items.join('')}</div>`;
17
+ export const checkbox = {
18
+ name: 'Checkbox',
19
+ slug: 'checkbox',
20
+ category: 'forms',
21
+ description: 'Yes/no or multi-select choice: a 16px (sm) or 20px (md) box with a gray-300 ring that fills with the brand color and shows a white check, next to a medium label and a muted supporting line. The native input stays in the DOM (invisible but focusable) so every state is CSS-only via :has().',
22
+ usage: 'Independent options that take effect on save (permissions, filters, consent) and multi-select lists. For one-of-many use Radio; for a setting that applies immediately use Switch. Always with a visible label; never a lone box. The card variant turns each option into a selectable bordered card.',
23
+ anatomy: [
24
+ { part: 'root', element: 'label', description: 'The <label> wrapping everything: it is the hit target and gives the input its name. inline-grid: box in column 1, text in column 2, 8px (sm) or 12px (md) apart.' },
25
+ { part: 'input', element: 'input', description: 'Native <input type="checkbox">, visually hidden (opacity 0, positioned over the box) but focusable. Carries checked, disabled, aria-invalid.' },
26
+ { part: 'control', element: 'span', description: 'The visible box: white with a 1px gray-300 inset ring, radius 4 (sm) or 6 (md), nudged 2px down to align with the first text line. aria-hidden; purely decorative.' },
27
+ { part: 'indicator', element: 'svg', description: 'The white check (12px on sm, 14px on md) inside the box. Hidden until checked; replaced by a dash when indeterminate.' },
28
+ { part: 'label', element: 'span', description: '14px (sm) or 16px (md) medium text in the default ink. Sentence case, no period, states the positive outcome.' },
29
+ { part: 'description', element: 'span', description: 'Optional supporting line under the label, same size, regular weight, muted: consequence or scope of the option.', optional: true },
30
+ ],
31
+ props: {
32
+ size: { values: ['sm', 'md'], default: 'sm', description: 'sm = 16px box (radius 4) with a 14px label: forms, settings, tables (the reference default); md = 20px box (radius 6) with a 16px label: touch screens, onboarding and prominent options.' },
33
+ variant: { values: ['default', 'card'], default: 'default', description: 'default = bare box and text; card = the whole option is a selectable card (white, 1px gray-200 ring, radius 12, 16px padding) whose ring turns into a 2px brand ring when checked. Use cards for plans, roles and add-ons where the description matters.' },
34
+ },
35
+ states: {
36
+ checked: { selector: ':has(:checked)', description: 'Box fills with the brand color and shows the white check. A card gets a 2px brand ring.', markup: 'checked attribute on the input' },
37
+ indeterminate: { selector: '[data-indeterminate], &:has(:indeterminate)', description: 'Partial selection (a "Select all" over a mixed group): brand fill with a dash instead of the check.', markup: 'data-indeterminate on the root, plus input.indeterminate = true in JS' },
38
+ invalid: { selector: '[data-invalid], &:has([aria-invalid="true"])', description: 'Red-300 ring on the box, for a required acceptance that was skipped. Pair with a visible error message next to the group.', markup: 'aria-invalid="true" on the input, or data-invalid on the root' },
39
+ focus: { selector: ':has(:focus-visible)', description: 'Keyboard focus on the hidden input shows the 4px brand ring on the box (on the whole card in the card variant).', markup: 'native :focus-visible on the input' },
40
+ disabled: { selector: '[data-disabled], &:has(:disabled)', description: 'Box at 50% opacity (gray-100 fill when unchecked), cursor not-allowed on the whole row; the text keeps its color. A checked disabled box keeps its check.', markup: 'disabled on the input' },
41
+ },
42
+ base: {
43
+ root: {
44
+ position: 'relative',
45
+ display: 'inline-grid',
46
+ 'grid-template-columns': 'auto minmax(0, 1fr)',
47
+ 'column-gap': '{space.2}',
48
+ 'row-gap': '0',
49
+ 'align-items': 'start',
50
+ 'max-width': '100%',
51
+ color: '{color.fg-default}',
52
+ cursor: 'pointer',
53
+ '-webkit-tap-highlight-color': 'transparent',
54
+ },
55
+ input: { position: 'absolute', top: '{space.0.5}', left: '0', margin: '0', padding: '0', opacity: '0', cursor: 'pointer' },
56
+ control: {
57
+ position: 'relative',
58
+ display: 'inline-flex',
59
+ 'align-items': 'center',
60
+ 'justify-content': 'center',
61
+ 'flex-shrink': '0',
62
+ 'grid-column': '1',
63
+ 'grid-row': '1',
64
+ 'margin-top': '{space.0.5}',
65
+ 'background-color': '{color.bg-surface}',
66
+ 'box-shadow': ring('{color.border-control}'),
67
+ 'border-radius': '{radius.sm}',
68
+ color: '{color.fg-on-action}',
69
+ ...TRANSITION_FAST,
70
+ },
71
+ indicator: { display: 'none', 'flex-shrink': '0', 'pointer-events': 'none' },
72
+ label: { 'grid-column': '2', ...typeStyle('label-sm'), color: '{color.fg-default}', 'user-select': 'none' },
73
+ description: { 'grid-column': '2', ...typeStyle('body-md'), color: '{color.fg-muted}' },
74
+ '@states': {
75
+ checked: { control: { 'background-color': '{color.bg-action}', 'box-shadow': ring('{color.bg-action}') }, indicator: { display: 'block' } },
76
+ indeterminate: { control: { 'background-color': '{color.bg-action}', 'box-shadow': ring('{color.bg-action}') }, indicator: { display: 'none' } },
77
+ invalid: { control: { 'box-shadow': ring('{color.border-danger}') } },
78
+ focus: { control: { 'box-shadow': `${ring('{color.border-action}')}, {shadow.focus}` } },
79
+ disabled: {
80
+ root: { cursor: 'not-allowed' },
81
+ input: { cursor: 'not-allowed' },
82
+ control: { 'background-color': '{color.bg-muted}', opacity: '{opacity.disabled}' },
83
+ },
84
+ },
85
+ },
86
+ variants: {
87
+ size: {
88
+ sm: {
89
+ input: { width: '{size.icon.sm}', height: '{size.icon.sm}' },
90
+ control: { width: '{size.icon.sm}', height: '{size.icon.sm}', 'border-radius': '{radius.sm}' },
91
+ indicator: { width: '{size.icon.xs}', height: '{size.icon.xs}' },
92
+ },
93
+ md: {
94
+ root: { 'column-gap': '{space.3}', 'row-gap': '{space.0.5}' },
95
+ input: { width: '{size.icon.md}', height: '{size.icon.md}' },
96
+ control: { width: '{size.icon.md}', height: '{size.icon.md}', 'border-radius': '{radius.md}' },
97
+ indicator: { width: '{space.3.5}', height: '{space.3.5}' },
98
+ label: { ...typeStyle('label-lg'), 'font-weight': '{font.weight.medium}' },
99
+ description: { ...typeStyle('body-lg') },
100
+ },
101
+ },
102
+ variant: {
103
+ default: { root: {} },
104
+ card: {
105
+ root: {
106
+ display: 'grid',
107
+ width: '100%',
108
+ padding: '{space.4}',
109
+ 'background-color': '{color.bg-surface}',
110
+ 'border-radius': '{radius.card}',
111
+ 'box-shadow': ring('{color.border-default}'),
112
+ ...TRANSITION_FAST,
113
+ },
114
+ input: { top: '{space.4}', left: '{space.4}' },
115
+ '@states': {
116
+ checked: { root: { 'box-shadow': 'inset 0 0 0 2px {color.bg-action}' } },
117
+ focus: { root: { 'box-shadow': `${ring('{color.border-default}')}, {shadow.focus}` }, control: { 'box-shadow': ring('{color.border-control}') } },
118
+ invalid: { root: { 'box-shadow': ring('{color.border-danger}') } },
119
+ disabled: { root: { 'background-color': '{color.bg-subtle}' } },
120
+ },
121
+ },
122
+ },
123
+ },
124
+ extraCss: `
125
+ .cn-checkbox:has(:checked):has(:focus-visible) .cn-checkbox__control, .cn-checkbox[data-indeterminate]:has(:focus-visible) .cn-checkbox__control { box-shadow: inset 0 0 0 1px {color.bg-action}, {shadow.focus}; }
126
+ .cn-checkbox[data-indeterminate] .cn-checkbox__control::after, .cn-checkbox:has(:indeterminate) .cn-checkbox__control::after { content: ''; display: block; width: {space.1.5}; height: 2px; border-radius: {radius.full}; background-color: currentColor; }
127
+ .cn-checkbox[data-size="md"][data-indeterminate] .cn-checkbox__control::after, .cn-checkbox[data-size="md"]:has(:indeterminate) .cn-checkbox__control::after { width: {space.2}; }
128
+ .cn-checkbox[data-invalid]:has(:focus-visible) .cn-checkbox__control, .cn-checkbox:has([aria-invalid="true"]):has(:focus-visible) .cn-checkbox__control { box-shadow: inset 0 0 0 1px {color.border-danger}, {shadow.focus-danger}; }
129
+ .cn-checkbox[data-variant="card"]:has(:checked):has(:focus-visible) { box-shadow: inset 0 0 0 2px {color.bg-action}, {shadow.focus}; }
130
+ .cn-checkbox[data-variant="card"]:has(:checked):has(:focus-visible) .cn-checkbox__control { box-shadow: inset 0 0 0 1px {color.bg-action}; }`,
131
+ examples: [
132
+ ex('Default', cb('data-size="sm"', '', 'Send me a weekly digest'), '16px box, radius 4, gray-300 ring; 14px medium label 8px away.'),
133
+ ex('Checked', cb('data-size="sm"', 'checked', 'Notify me when Daniel Costa comments'), 'Brand-600 fill with a 12px white check.'),
134
+ ex('With supporting text', cb('data-size="sm"', 'checked', 'Require two-factor authentication', 'Members must verify with an authenticator app every 30 days.'), 'The supporting line is the same size, regular weight, muted.'),
135
+ ex('Medium', cb('data-size="md"', 'checked', 'Share usage analytics with Lumen', 'Helps us prioritise what to build next. Never includes your content.'), '20px box, radius 6, 14px check, 16px text 12px away with a 2px gap between lines.'),
136
+ ex('Indeterminate (select all)', cb('data-size="sm" data-indeterminate', 'aria-label="Select all members"', 'Select all', '3 of 8 members selected'), 'Set input.indeterminate = true in JS and mirror it with data-indeterminate for CSS.'),
137
+ ex('Invalid', cb('data-size="sm" data-invalid', 'aria-invalid="true" required', 'I agree to the Terms of Service and the Data Processing Agreement'), 'Red-300 ring. Show the error ("Accept the terms to continue") under the group, not inside the label.'),
138
+ ex('Disabled', stack(cb('data-size="sm"', 'checked disabled', 'Unlimited seats', 'Included in the Scale plan.'), cb('data-size="sm"', 'disabled', 'Dedicated IP address')), 'The box fades to 50% (gray-100 when unchecked); the text keeps its color.'),
139
+ ex('Card', stack(cb('data-size="sm"', 'checked', 'Research', 'Interviews, notes and evidence for Sofia Almeida’s team.', 'card'), cb('data-size="sm"', '', 'Design', 'Files, prototypes and the review queue.', 'card')), 'A selectable card: 1px gray-200 ring, radius 12, 16px padding; a 2px brand ring when checked.'),
140
+ ],
141
+ recipes: [
142
+ ex('Permission group', `<div role="group" aria-labelledby="perm-label" style="display:flex;flex-direction:column;gap:var(--cn-space-3);max-width:420px"><div class="cn-text-label-sm" id="perm-label">Member permissions</div>${cb('data-size="sm"', 'checked', 'View projects and conversations')}${cb('data-size="sm"', 'checked', 'Publish updates', 'Posts go out under the member’s own name.')}${cb('data-size="sm"', '', 'Approve invoices above $10,000')}${cb('data-size="sm"', 'disabled', 'Manage billing', 'Owners only.')}</div>`, 'A label names the group; options stack 12px apart; the locked one stays visible but disabled.'),
143
+ ex('Add-ons as cards', `<div role="group" aria-labelledby="addons-label" style="display:flex;flex-direction:column;gap:var(--cn-space-3);max-width:420px"><div class="cn-text-label-sm" id="addons-label">Add-ons</div>${cb('data-size="sm"', 'checked', 'Priority support · $49/mo', 'A named contact and a 4-hour response time.', 'card')}${cb('data-size="sm"', '', 'Audit log export · $19/mo', 'Stream every event to your SIEM.', 'card')}${cb('data-size="sm"', 'disabled', 'SSO enforcement', 'Included in the Enterprise plan.', 'card')}</div>`, 'Cards stack 12px apart; the whole card is the hit target and shows the focus ring.'),
144
+ ],
145
+ rules: [
146
+ 'Use a checkbox for options that take effect on save. For an immediate effect use Switch; for one-of-many use Radio.',
147
+ 'The label states the positive outcome ("Send me a weekly digest"), never a negation ("Do not send…") and never an "Enable/Disable" prefix.',
148
+ 'Labels are sentence case without a period; the supporting line is one full sentence with a period.',
149
+ 'Stack checkboxes vertically 12px apart (16px between cards). Never place more than two side by side.',
150
+ 'Indeterminate is only for a parent that summarizes children ("Select all"). Set input.indeterminate in JS and mirror it with data-indeterminate; clicking it checks everything.',
151
+ 'Invalid is reserved for a required acceptance (terms, consent). Put the error message under the group, in fg-danger, and clear it as soon as the box is checked.',
152
+ 'sm (16px) in forms, settings and tables; md (20px) on touch screens, onboarding and prominent options. Cards for plans, roles and add-ons where the description matters.',
153
+ 'Keep the native input in the DOM. The box is decorative (aria-hidden); never rebuild the checkbox with a div and onClick.',
154
+ ],
155
+ a11y: [
156
+ 'The root is a <label> wrapping the input, so the text is the accessible name and the whole row (or card) is the hit target.',
157
+ 'The visible box is aria-hidden; checked, focus and disabled come from the native input via :has(), so assistive tech sees a normal checkbox.',
158
+ 'A native checkbox announces "mixed" on its own once input.indeterminate is true; do not add aria-checked to it.',
159
+ 'Groups get role="group" (or a <fieldset>) with aria-labelledby pointing at the group label.',
160
+ 'Keep descriptions inside the label; if they contain links or run long, move them out and connect them with aria-describedby.',
161
+ ],
162
+ related: ['radio', 'switch', 'field'],
163
+ };
@@ -0,0 +1,49 @@
1
+ import { FOCUS_RING, TRANSITION_COLORS, RESET_BUTTON, STATE, ex, ICON } from "./_shared.mjs";
2
+ export const closeButton = {
3
+ name: 'CloseButton',
4
+ slug: 'close-button',
5
+ category: 'actions',
6
+ description: 'The × that dismisses dialogs, drawers, alerts, toasts and chips. A square utility button with a grey icon and a subtle hover fill.',
7
+ usage: 'Use only to dismiss the surface it sits in (top-right of dialogs/drawers/toasts, end of alerts/banners). It is never a "cancel" action in a footer; use a ghost Button there.',
8
+ anatomy: [
9
+ { part: 'root', element: 'button', description: 'Square hit area. Requires aria-label="Close".' },
10
+ { part: 'icon', element: 'svg', description: 'The × icon, 20px (sm) or 24px (md/lg).' },
11
+ ],
12
+ props: {
13
+ size: { values: ['xs', 'sm', 'md', 'lg'], default: 'sm', description: 'xs 24px (chips, badges) · sm 36px (alerts, toasts, cards) · md 44px (dialogs, drawers) · lg 48px.' },
14
+ theme: { values: ['light', 'dark'], default: 'light', description: 'light = grey icon on light surfaces; dark = translucent white icon for dark or brand surfaces.' },
15
+ },
16
+ states: { hover: STATE.hover(), active: STATE.active(), focus: STATE.focus(), disabled: STATE.disabled() },
17
+ base: {
18
+ root: { ...RESET_BUTTON, display: 'inline-flex', 'align-items': 'center', 'justify-content': 'center', 'flex-shrink': '0', 'border-radius': '{radius.lg}', color: '{color.fg-subtle}', ...TRANSITION_COLORS },
19
+ icon: { display: 'block', 'stroke-width': '2' },
20
+ '@states': {
21
+ focus: { root: FOCUS_RING },
22
+ disabled: { root: { opacity: '{opacity.disabled}', cursor: 'not-allowed' } },
23
+ },
24
+ },
25
+ variants: {
26
+ size: {
27
+ xs: { root: { width: '24px', height: '24px', 'border-radius': '{radius.sm}' }, icon: { width: '{size.icon.xs}', height: '{size.icon.xs}' } },
28
+ sm: { root: { width: '{size.control.sm}', height: '{size.control.sm}' }, icon: { width: '{size.icon.md}', height: '{size.icon.md}' } },
29
+ md: { root: { width: '{size.control.lg}', height: '{size.control.lg}' }, icon: { width: '{size.icon.lg}', height: '{size.icon.lg}' } },
30
+ lg: { root: { width: '{size.control.xl}', height: '{size.control.xl}' }, icon: { width: '{size.icon.lg}', height: '{size.icon.lg}' } },
31
+ },
32
+ theme: {
33
+ light: { root: {}, '@states': { hover: { root: { 'background-color': '{color.bg-subtle}', color: '{color.fg-muted}' } }, active: { root: { 'background-color': '{color.bg-muted}' } } } },
34
+ dark: { root: { color: 'rgba(255, 255, 255, 0.7)' }, '@states': { hover: { root: { 'background-color': 'rgba(255, 255, 255, 0.1)', color: '{white}' } }, active: { root: { 'background-color': 'rgba(255, 255, 255, 0.16)' } } } },
35
+ },
36
+ },
37
+ examples: [
38
+ ex('Sizes', `<button type="button" class="cn-close-button" data-size="xs" data-theme="light" aria-label="Close">${ICON.x.replace('cn-icon', 'cn-close-button__icon')}</button> <button type="button" class="cn-close-button" data-size="sm" data-theme="light" aria-label="Close">${ICON.x.replace('cn-icon', 'cn-close-button__icon')}</button> <button type="button" class="cn-close-button" data-size="md" data-theme="light" aria-label="Close">${ICON.x.replace('cn-icon', 'cn-close-button__icon')}</button> <button type="button" class="cn-close-button" data-size="lg" data-theme="light" aria-label="Close">${ICON.x.replace('cn-icon', 'cn-close-button__icon')}</button>`),
39
+ ex('Dark theme on an inverse surface', `<div style="background:var(--cn-color-bg-inverse);padding:var(--cn-space-3);border-radius:var(--cn-radius-lg)"><button type="button" class="cn-close-button" data-size="sm" data-theme="dark" aria-label="Close">${ICON.x.replace('cn-icon', 'cn-close-button__icon')}</button></div>`),
40
+ ],
41
+ rules: [
42
+ 'Always aria-label="Close" (or "Dismiss notification"), never a visible label.',
43
+ 'Position: top-right, aligned with the title baseline, with the same inset as the surface padding minus 8px so the icon aligns with the content edge.',
44
+ 'sm inside cards, alerts, toasts and chips; md/lg only in dialogs and drawers.',
45
+ 'Escape must trigger the same close action wherever a close button exists.',
46
+ ],
47
+ a11y: ['It is a real <button type="button"> with an accessible name.', 'Focus returns to the trigger that opened the surface after closing.'],
48
+ related: ['dialog', 'drawer', 'toast', 'alert', 'icon-button'],
49
+ };
@@ -0,0 +1,123 @@
1
+ import { RESET_BUTTON, ex, ICON, typeStyle } from "./_shared.mjs";
2
+ // Code has no direct equivalent in the reference; it borrows the reference's conventions: inline
3
+ // chips share the Kbd look (12px mono on gray-50, 1px gray-200 ring, radius 4), blocks are the
4
+ // table-card surface (white, 1px gray-200 ring + shadow-xs, radius 12) with a gray-50 header in the
5
+ // table-head voice (12px semibold gray-500) and a 28px utility copy button.
6
+ const TRANSITION_FAST = {
7
+ 'transition-property': 'background-color, color, box-shadow',
8
+ 'transition-duration': '{motion.duration.fast}',
9
+ 'transition-timing-function': '{motion.easing.linear}',
10
+ };
11
+ const ring = (color) => `inset 0 0 0 1px ${color}`;
12
+ const inline = (text, size = 'md') => `<code class="cn-code" data-variant="inline" data-size="${size}">${text}</code>`;
13
+ const copyButton = `<button type="button" class="cn-code__copy" aria-label="Copy code">${ICON.copy}</button>`;
14
+ const block = (attrs, body, header) => `<figure class="cn-code" data-variant="block" ${attrs}>${header !== undefined ? `<div class="cn-code__header"><span>${header}</span>${copyButton}</div>` : ''}<pre class="cn-code__pre"><code>${body}</code></pre></figure>`;
15
+ export const code = {
16
+ name: 'Code',
17
+ slug: 'code',
18
+ category: 'typography',
19
+ description: 'Monospace code, inline or as a block. Inline is a quiet chip in running text (gray-50, 1px gray-200 ring, radius 4); block is a card surface (white, hairline ring, shadow-xs, radius 12) with an optional file-name header and a copy button.',
20
+ usage: 'Inline for literals inside a sentence: commands, file names, attribute values, token names. Block for multi-line snippets, config files and terminal output. Keyboard shortcuts are Kbd, not Code; numbers in tables use numeric-md, not Code.',
21
+ anatomy: [
22
+ { part: 'root', element: 'code', description: 'Inline: a <code> span. Block: a <figure> wrapping the header and the pre.' },
23
+ { part: 'header', element: 'div', description: 'Optional bar above the code: file path or language in the table-head voice (12px semibold gray-500) keeping its case, since file paths are case-sensitive. Copy button on the right, bottom hairline, gray-50 fill, 44px tall.', optional: true },
24
+ { part: 'pre', element: 'pre', description: 'The code in <pre><code>. 14px mono (12px on sm), 16px padding, scrolls horizontally, never wraps.' },
25
+ { part: 'copy', element: 'button', description: '28px utility icon button (radius 6, 16px icon) in the header that copies the raw text. aria-label="Copy code".', optional: true },
26
+ ],
27
+ props: {
28
+ variant: { values: ['inline', 'block'], default: 'inline', description: 'inline = chip inside text for a literal; block = full-width card surface with radius 12 for multi-line code, with optional header and copy button.' },
29
+ size: { values: ['sm', 'md'], default: 'md', description: 'md = 12px inline and 14px block for docs and settings; sm = 11px inline and 12px block for tables, tooltips and popovers.' },
30
+ },
31
+ states: {},
32
+ base: {
33
+ root: { color: '{color.fg-default}' },
34
+ header: {
35
+ display: 'flex',
36
+ 'align-items': 'center',
37
+ 'justify-content': 'space-between',
38
+ gap: '{space.2}',
39
+ 'min-height': '{size.control.lg}',
40
+ padding: '{space.2} {space.2} {space.2} {space.4}',
41
+ 'background-color': '{color.bg-subtle}',
42
+ 'box-shadow': '0 1px 0 0 {color.border-default}',
43
+ ...typeStyle('label-xs'),
44
+ 'font-weight': '{font.weight.semibold}',
45
+ color: '{color.fg-subtle}',
46
+ },
47
+ pre: { margin: '0', overflow: 'auto', ...typeStyle('code-md'), color: '{color.fg-default}', 'tab-size': '2', 'white-space': 'pre' },
48
+ copy: {
49
+ ...RESET_BUTTON,
50
+ display: 'inline-flex',
51
+ 'align-items': 'center',
52
+ 'justify-content': 'center',
53
+ 'flex-shrink': '0',
54
+ width: 'calc({size.control.xs} - {space.1})',
55
+ height: 'calc({size.control.xs} - {space.1})',
56
+ 'border-radius': '{radius.md}',
57
+ color: '{color.fg-subtle}',
58
+ 'font-size': '{size.icon.sm}',
59
+ ...TRANSITION_FAST,
60
+ },
61
+ },
62
+ variants: {
63
+ variant: {
64
+ inline: {
65
+ root: {
66
+ display: 'inline',
67
+ ...typeStyle('code-sm'),
68
+ 'background-color': '{color.bg-subtle}',
69
+ 'box-shadow': ring('{color.border-default}'),
70
+ 'border-radius': '{radius.sm}',
71
+ padding: '{space.px} {space.1}',
72
+ 'overflow-wrap': 'anywhere',
73
+ 'vertical-align': 'baseline',
74
+ },
75
+ },
76
+ block: {
77
+ root: {
78
+ display: 'flex',
79
+ 'flex-direction': 'column',
80
+ width: '100%',
81
+ 'min-width': '0',
82
+ 'background-color': '{color.bg-surface}',
83
+ 'box-shadow': `${ring('{color.border-default}')}, {shadow.xs}`,
84
+ 'border-radius': '{radius.card}',
85
+ overflow: 'hidden',
86
+ },
87
+ },
88
+ },
89
+ size: {
90
+ sm: { root: { 'font-size': '{font.size.2xs}' }, pre: { 'font-size': '{font.size.xs}', padding: '{space.3}' } },
91
+ md: { root: { 'font-size': '{font.size.xs}' }, pre: { 'font-size': '{font.size.sm}', padding: '{space.4}' } },
92
+ },
93
+ },
94
+ extraCss: `
95
+ .cn-code .cn-code__pre code { font: inherit; color: inherit; background: none; padding: 0; border: 0; box-shadow: none; }
96
+ .cn-code .cn-code__copy .cn-icon { width: {size.icon.sm}; height: {size.icon.sm}; }
97
+ .cn-code .cn-code__copy:hover { background-color: {color.bg-muted}; color: {color.fg-muted}; }
98
+ .cn-code .cn-code__copy:focus-visible { outline: none; box-shadow: {shadow.focus}; color: {color.fg-muted}; }`,
99
+ examples: [
100
+ ex('Inline', inline('npm install -g @lumen/canon'), '12px mono on gray-50 with a gray-200 ring, radius 4.'),
101
+ ex('Inline in a sentence', `<p class="cn-text-body-md" style="max-width:560px">Run ${inline('canon build')} after editing ${inline('design/tokens.json')}; the gallery at ${inline('dist/preview.html')} reloads on its own.</p>`),
102
+ ex('Block with file name and copy', block('data-size="md" style="max-width:560px"', `{\n "name": "Lumen",\n "prefix": "ui",\n "brand": "#B4309F",\n "radiusScale": 1,\n "controlHeight": 40\n}`, 'design/system.json'), 'A card surface with a gray-50 header (12px semibold gray-500) and a 28px copy button.'),
103
+ ex('Block, terminal (no header)', block('data-size="md" style="max-width:560px"', `$ canon init "Lumen" --prefix ui\n✓ built 86 files → design/dist\n$ canon install\n✓ DESIGN.md, CLAUDE.md block and lint hook installed`)),
104
+ ex('Block, small', block('data-size="sm" style="max-width:480px"', `.ui-button[data-variant="primary"] {\n background-color: var(--ui-color-bg-action);\n color: var(--ui-color-fg-on-action);\n}`, 'components.css'), '12px mono with 12px padding for tables and popovers.'),
105
+ ex('Inline, small', `<span class="cn-text-body-sm" style="color:var(--cn-color-fg-muted)">Token ${inline('{color.bg-action}', 'sm')} uses the action color defined for the current theme.</span>`),
106
+ ],
107
+ rules: [
108
+ 'Inline code is for literals the reader might type or see verbatim: commands, file names, attribute values, token names. Never for emphasis.',
109
+ 'Block code never wraps; it scrolls horizontally. Keep lines under 80 characters in docs and trim leading indentation.',
110
+ 'The header shows the file path or the language in its original case (design/system.json, not DESIGN/SYSTEM.JSON). Omit it for terminal commands and one-liners.',
111
+ 'One copy button per block, in the header, 28px utility. It copies the raw text (not the rendered HTML) and confirms with a short "Copied" tooltip.',
112
+ 'Escape HTML inside <code> (&lt; &gt; &amp;); never inject raw user content.',
113
+ 'md in docs, settings and dialogs; sm inside tables, tooltips and popovers. Code does not scale with the surrounding heading.',
114
+ 'Syntax highlighting, if any, uses fg-default, fg-muted, fg-accent and fg-info only. No rainbow palettes, no background per token.',
115
+ ],
116
+ a11y: [
117
+ 'Use the semantic elements: <code> inline and <pre><code> for blocks, so screen readers announce code.',
118
+ 'The copy button has aria-label="Copy code" and a visible focus ring; announce "Copied" in a live region after the click.',
119
+ 'When a block overflows horizontally, give the <pre> tabindex="0" so keyboard users can scroll it.',
120
+ 'Never convey meaning by color alone in highlighted code; keep textual markers (+ / − in diffs).',
121
+ ],
122
+ related: ['kbd', 'prose', 'tooltip'],
123
+ };
@@ -0,0 +1,122 @@
1
+ import { FOCUS_RING, RESET_BUTTON, TRANSITION_COLORS, ex } from "./_shared.mjs";
2
+ // A small panel: an 8-column grid of round swatches and a hex field with a preview swatch and an
3
+ // eyedropper button. Selection is a ring drawn with box-shadow so the swatch never changes size.
4
+ const EYEDROPPER = '<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="M10.75 2.25l3 3-1.5 1.5-.75-.75L6 11.5 3.5 12.5 4.5 10l5.5-5.5-.75-.75z"/><path d="M9.25 5.25l1.5 1.5"/></svg>';
5
+ const swatch = (name, cssVar, selected = false) => `<button type="button" class="cn-color-picker__swatch" role="option" aria-selected="${selected}" aria-label="${name}" style="background:var(--cn-${cssVar})"></button>`;
6
+ const GRID = [
7
+ ['Ink', 'neutral-950'], ['Slate', 'neutral-700'], ['Stone', 'neutral-400'], ['Mist', 'neutral-200'],
8
+ ['Brand 800', 'brand-800'], ['Brand 600', 'brand-600'], ['Brand 400', 'brand-400'], ['Brand 200', 'brand-200'],
9
+ ['Red 600', 'red-600'], ['Red 400', 'red-400'], ['Amber 500', 'amber-500'], ['Amber 300', 'amber-300'],
10
+ ['Green 600', 'green-600'], ['Green 400', 'green-400'], ['Blue 600', 'blue-600'], ['Blue 400', 'blue-400'],
11
+ ];
12
+ const grid = (selected) => `<div class="cn-color-picker__swatches" role="listbox" aria-label="Preset colors">${GRID.map(([n, v]) => swatch(n, v, n === selected)).join('')}</div>`;
13
+ const field = (hex, previewVar, attrs = '') => `<div class="cn-color-picker__field"><div class="cn-input" data-variant="default" data-size="sm"><span class="cn-color-picker__swatch" data-preview aria-hidden="true" style="background:var(--cn-${previewVar})"></span><input class="cn-input__field" type="text" value="${hex}" aria-label="Hex color" spellcheck="false" autocapitalize="characters" maxlength="7"${attrs}></div><button type="button" class="cn-color-picker__eyedropper" aria-label="Pick a color from the screen">${EYEDROPPER}</button></div>`;
14
+ const picker = (size, inner) => `<div class="cn-color-picker" data-size="${size}" role="group" aria-label="Color">${inner}</div>`;
15
+ export const colorPicker = {
16
+ name: 'ColorPicker',
17
+ slug: 'color-picker',
18
+ category: 'forms',
19
+ description: 'A compact panel for choosing a color: an 8-column grid of round swatches, and a hex Input with a preview swatch and an eyedropper button. The selected swatch shows an action-colored ring.',
20
+ usage: 'Use for brand colors, labels, tags and chart series where a curated palette is enough. Offer the hex field for exact values and the eyedropper when the browser supports it. Not for full-spectrum editing (a design tool); for a fixed set of 3–6 colors use a Radio group of swatches inline.',
21
+ anatomy: [
22
+ { part: 'root', element: 'div', description: 'The panel (flex column, gap space.3): 280px wide (240 on sm), padding space.4, surface-raised, hairline, radius panel, shadow-lg. role="group" with an aria-label. Carries data-size.' },
23
+ { part: 'swatches', element: 'div', description: '8-column grid, gap space.2. role="listbox" aria-label "Preset colors".' },
24
+ { part: 'swatch', element: 'button', description: 'One 24px round swatch (20px on sm) with a hairline; its color is an inline background. role="option" aria-selected, aria-label naming the color. With data-preview it is the 16px preview inside the hex field.' },
25
+ { part: 'field', element: 'div', description: 'Row (flex, gap space.2) with the hex Input (size sm, leading preview swatch) and the eyedropper button.' },
26
+ { part: 'eyedropper', element: 'button', description: 'Square outline icon button (control height sm) with a pipette icon. aria-label "Pick a color from the screen". Hide it when the EyeDropper API is unavailable.' },
27
+ ],
28
+ props: {
29
+ size: { values: ['sm', 'md'], default: 'md', description: 'md = 24px swatches in a 280px panel (default); sm = 20px swatches in a 240px panel for popovers inside dense settings.' },
30
+ },
31
+ states: {
32
+ selected: { selector: ' .cn-color-picker__swatch[aria-selected="true"]', description: 'On the __swatch: a 2px action-colored ring (box-shadow, offset by the panel surface) marks the current color. Styled in extraCss because it lives on a child.', markup: 'aria-selected="true" on the swatch' },
33
+ swatchHover: { selector: ' .cn-color-picker__swatch:hover', description: 'On the __swatch: the hairline strengthens. Styled in extraCss.', markup: 'native :hover on a swatch' },
34
+ swatchFocus: { selector: ' .cn-color-picker__swatch:focus-visible', description: 'On the __swatch: the focus ring. Styled in extraCss.', markup: 'native :focus-visible on a swatch' },
35
+ eyedropperHover: { selector: ' .cn-color-picker__eyedropper:hover', description: 'On the __eyedropper: subtle fill and default ink. Styled in extraCss.', markup: 'native :hover on the eyedropper' },
36
+ },
37
+ base: {
38
+ root: {
39
+ display: 'flex',
40
+ 'flex-direction': 'column',
41
+ gap: '{space.3}',
42
+ width: 'calc({space.64} + {space.6})',
43
+ 'max-width': '100%',
44
+ padding: '{space.4}',
45
+ 'background-color': '{color.bg-surface-raised}',
46
+ border: '{border.width.thin} solid {color.border-default}',
47
+ 'border-radius': '{radius.panel}',
48
+ 'box-shadow': '{shadow.lg}',
49
+ color: '{color.fg-default}',
50
+ 'z-index': '{z.popover}',
51
+ },
52
+ swatches: { display: 'grid', 'grid-template-columns': 'repeat(8, 1fr)', 'justify-items': 'center', gap: '{space.2}' },
53
+ swatch: {
54
+ ...RESET_BUTTON,
55
+ display: 'block',
56
+ 'flex-shrink': '0',
57
+ width: '{space.6}',
58
+ height: '{space.6}',
59
+ 'border-radius': '{radius.full}',
60
+ 'box-shadow': 'inset 0 0 0 1px {color.border-default}',
61
+ 'background-color': '{color.bg-muted}',
62
+ ...TRANSITION_COLORS,
63
+ },
64
+ field: { display: 'flex', 'align-items': 'center', gap: '{space.2}' },
65
+ eyedropper: {
66
+ ...RESET_BUTTON,
67
+ display: 'inline-flex',
68
+ 'align-items': 'center',
69
+ 'justify-content': 'center',
70
+ 'flex-shrink': '0',
71
+ width: '{size.control.sm}',
72
+ height: '{size.control.sm}',
73
+ 'font-size': '{size.icon.md}',
74
+ 'background-color': '{color.bg-surface}',
75
+ border: '{border.width.thin} solid {color.border-control}',
76
+ 'border-radius': '{radius.control}',
77
+ 'box-shadow': '{shadow.xs}',
78
+ color: '{color.fg-subtle}',
79
+ ...TRANSITION_COLORS,
80
+ },
81
+ },
82
+ variants: {
83
+ size: {
84
+ sm: { root: { width: 'calc({space.56} + {space.4})', padding: '{space.3}', gap: '{space.2.5}' }, swatches: { gap: '{space.1.5}' }, swatch: { width: '{space.5}', height: '{space.5}' } },
85
+ md: { swatch: { width: '{space.6}', height: '{space.6}' } },
86
+ },
87
+ },
88
+ extraCss: `
89
+ .cn-color-picker .cn-color-picker__swatch:hover { box-shadow: inset 0 0 0 1px {color.border-strong}; }
90
+ .cn-color-picker .cn-color-picker__swatch[aria-selected="true"] { box-shadow: 0 0 0 2px {color.bg-surface-raised}, 0 0 0 4px {color.border-action}; }
91
+ .cn-color-picker .cn-color-picker__swatch:focus-visible { ${Object.entries(FOCUS_RING).map(([k, v]) => `${k}: ${v}`).join('; ')}; }
92
+ .cn-color-picker .cn-color-picker__swatch[aria-selected="true"]:focus-visible { box-shadow: 0 0 0 2px {color.bg-surface-raised}, 0 0 0 4px {color.border-action}, {shadow.focus}; }
93
+ .cn-color-picker .cn-input .cn-color-picker__swatch[data-preview] { width: {space.4}; height: {space.4}; margin-inline-start: {space.3}; cursor: default; pointer-events: none; }
94
+ .cn-color-picker .cn-input .cn-color-picker__swatch[data-preview] + .cn-input__field { padding-inline-start: {space.2}; font-family: {font.family.mono}; text-transform: uppercase; }
95
+ .cn-color-picker .cn-input { flex: 1 1 auto; min-width: 0; }
96
+ .cn-color-picker .cn-color-picker__eyedropper:hover { background-color: {color.bg-subtle}; color: {color.fg-default}; }
97
+ .cn-color-picker .cn-color-picker__eyedropper:focus-visible { outline: none; box-shadow: {shadow.focus}; border-color: {color.border-action}; }`,
98
+ examples: [
99
+ ex('Swatch grid with hex field', picker('md', grid('Brand 600') + field('#B4309F', 'brand-600')), 'Sixteen preset swatches in two rows; Brand 600 is selected and previewed in the hex field. Swatch colors are inline backgrounds using the primitive scale variables.'),
100
+ ex('Custom value (no swatch match)', picker('md', grid() + field('#1E9E6A', 'green-600')), 'A typed hex that matches no preset: no swatch is selected, the preview shows the typed color.'),
101
+ ex('Small', picker('sm', grid('Blue 600') + field('#2563EB', 'blue-600')), '20px swatches in a 240px panel.'),
102
+ ex('Invalid hex', picker('md', grid() + field('#12G', 'neutral-200', ' aria-invalid="true" aria-describedby="hex-error"')).replace('</div></div>', `</div><p class="cn-text-body-sm" id="hex-error" style="margin:0;color:var(--cn-color-fg-danger)">Enter 6 hex digits, like #B4309F.</p></div>`), 'The hex Input takes the invalid state; the message sits under the row.'),
103
+ ],
104
+ rules: [
105
+ 'Exactly 8 swatches per row and 1–3 rows (8–24 colors). Order them by hue family, darkest to lightest, neutrals first.',
106
+ 'The swatch color is the only inline style allowed (style="background:…"), preferably a primitive scale variable so the palette follows the theme.',
107
+ 'Selection is a ring, not a checkmark or a size change: aria-selected="true" draws the 2px action ring offset by the panel surface.',
108
+ 'The hex field always mirrors the selection: clicking a swatch writes its hex; typing a valid hex selects a matching swatch or clears the selection.',
109
+ 'Accept #RGB and #RRGGBB, normalize to uppercase #RRGGBB on blur, and mark the field invalid (with a message) otherwise.',
110
+ 'Show the eyedropper only when window.EyeDropper exists; never render a disabled eyedropper.',
111
+ 'The panel is statically positioned by the component; the app anchors it under its trigger (a swatch button or an Input) with an 8px offset and closes it on Escape.',
112
+ 'One ColorPicker per popover; never inline in a form, where a Radio group of swatches is lighter.',
113
+ ],
114
+ a11y: [
115
+ 'The swatch grid is role="listbox" with role="option" swatches; each has an aria-label naming the color ("Brand 600") and aria-selected. Arrow keys move between swatches, Enter or Space select.',
116
+ 'The hex input has an accessible name ("Hex color") and aria-invalid plus aria-describedby when the value is not a valid hex.',
117
+ 'The preview swatch inside the field is aria-hidden; the hex text is the accessible value.',
118
+ 'The eyedropper is a real <button> with aria-label; it announces the picked color by updating the hex input.',
119
+ 'Color is never the only cue for selection: the ring and the hex value both change.',
120
+ ],
121
+ related: ['input', 'popover', 'icon-button', 'radio'],
122
+ };