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,172 @@
1
+ import { ex, ICON, typeStyle } from "./_shared.mjs";
2
+ // A compact reading rail with persistent leading markers on current destinations.
3
+ const TRANSITION_FAST = {
4
+ 'transition-property': 'background-color, color, box-shadow',
5
+ 'transition-duration': '{motion.duration.fast}',
6
+ 'transition-timing-function': '{motion.easing.linear}',
7
+ };
8
+ const HAIRLINE = '{border.width.thin} solid {color.border-default}';
9
+ const ring = (color) => `inset 0 0 0 1px ${color}`;
10
+ const icon = (name) => ICON[name].replace('cn-icon', 'cn-sidebar-nav__icon');
11
+ const item = (label, i, attrs = '', count = '') => `<a href="#" class="cn-sidebar-nav__item"${attrs}>${i ? icon(i) : ''}<span class="cn-sidebar-nav__label">${label}</span>${count ? `<span class="cn-sidebar-nav__count">${count}</span>` : ''}</a>`;
12
+ const parent = (label, i, open, children) => `<details class="cn-sidebar-nav__group"${open ? ' open' : ''}><summary class="cn-sidebar-nav__item">${icon(i)}<span class="cn-sidebar-nav__label">${label}</span>${ICON.chevronDown.replace('cn-icon', 'cn-sidebar-nav__chevron')}</summary>${children}</details>`;
13
+ const group = (id, label, items) => `<div class="cn-sidebar-nav__group" role="group"${label ? ` aria-labelledby="${id}"` : ''}>${label ? `<div class="cn-sidebar-nav__group-label" id="${id}">${label}</div>` : ''}${items}</div>`;
14
+ const MAIN = (current = 'Dashboard') => [['Home', 'home'], ['Dashboard', 'calendar'], ['Projects', 'copy'], ['Tasks', 'check', '10'], ['Reporting', 'inbox'], ['Users', 'user']].map(([l, i, c]) => item(l, i, l === current ? ' aria-current="page"' : '', c ?? '')).join('');
15
+ export const sidebarNav = {
16
+ name: 'SidebarNav',
17
+ slug: 'sidebar-nav',
18
+ category: 'navigation',
19
+ description: 'A grouped navigation rail: a 280px column of 36px link rows (14px semibold, 20px icon, radius 6) grouped with subheadings or dividers, a quiet hover fill and a leading mark on the current destination, with count badges and collapsible children.',
20
+ usage: 'Use as the nav list of a Sidebar or on its own for the second level of navigation inside a product area (settings sections, a workspace\'s views). Items are answers to what the user wants to do, grouped by job, never a mirror of the data model. For 3–6 top-level destinations use the Topbar instead.',
21
+ anatomy: [
22
+ { part: 'root', element: 'nav', description: 'The column: 280px wide, 20 × 16px padding, groups stacked. Has aria-label.' },
23
+ { part: 'group', element: 'div', description: 'A set of related items with role="group" (or a <details> for a collapsible parent); rows 2px apart, 16px above each group after the first.' },
24
+ { part: 'group-label', element: 'div', description: 'Subheading naming the group in 12px semibold fg-subtle, 8px inset: "General", "Workspace". Referenced by the group\'s aria-labelledby.', optional: true },
25
+ { part: 'item', element: 'a', description: 'One destination: a 36px link row (or <summary> for a collapsible parent), padding 8, radius 6, 14px semibold gray-700; holds icon + label + optional count or chevron. data-level="2" indents a child row to 40px.' },
26
+ { part: 'icon', element: 'svg', description: '20px leading icon, fg-subtle at rest, one step darker when hovered or current. All items in a group have one, or none do.', optional: true },
27
+ { part: 'label', element: 'span', description: 'The destination name, 1–2 words. Fills the row and truncates with an ellipsis.' },
28
+ { part: 'count', element: 'span', description: 'Trailing count in a 22px gray badge (12px medium, gray-50, gray-200 ring), 12px after the label. Only for numbers that need action, never totals.', optional: true },
29
+ { part: 'chevron', element: 'svg', description: '16px chevron-down at the end of a collapsible parent; flips when the <details> is open.', optional: true },
30
+ { part: 'divider', element: 'hr', description: '1px gray-200 rule between groups (the "dividers" layout), 8px above and below.', optional: true },
31
+ ],
32
+ props: {
33
+ size: {
34
+ values: ['sm', 'md'],
35
+ default: 'md',
36
+ description: 'md = 36px rows, 14px semibold, 20px icons; the default. sm = 32px rows (padding 6 × 8), 14px semibold, 16px icons for dense admin rails and secondary panels.',
37
+ },
38
+ variant: {
39
+ values: ['default', 'inset'],
40
+ default: 'default',
41
+ description: 'default = transparent on the surface; hovered and current rows get the gray-50 fill (the default). inset = the whole nav is a gray-50 rail with a hairline on its trailing edge, and the current item uses a surface fill and a leading action-colored rule.',
42
+ },
43
+ },
44
+ states: {
45
+ hover: { selector: ' .cn-sidebar-nav__item:hover', description: 'Pointer over an item (lives on the ITEM, not the root): gray-50 fill, gray-800 label, gray-500 icon.', markup: 'native :hover on the item' },
46
+ current: { selector: ' .cn-sidebar-nav__item[aria-current="page"]', description: 'The destination the user is on: gray-50 fill (gray-100 when hovered), ink label, darker icon (inset: white pill with ring and shadow-xs). Exactly one per nav.', markup: 'aria-current="page" on the item' },
47
+ focus: { selector: ' .cn-sidebar-nav__item:focus-visible', description: 'Keyboard focus on an item: the token-defined focus ring around the row.', markup: 'native :focus-visible on the item' },
48
+ disabled: { selector: ' .cn-sidebar-nav__item[aria-disabled="true"]', description: 'Destination not available on this plan or not yet set up. 50% opacity, no hover; keep the label so the user knows it exists.', markup: 'aria-disabled="true" on the item (omit href)' },
49
+ },
50
+ base: {
51
+ root: {
52
+ display: 'flex',
53
+ 'flex-direction': 'column',
54
+ gap: '{space.4}',
55
+ width: 'calc({space.64} + {space.6})',
56
+ 'flex-shrink': '0',
57
+ padding: '{space.5} {space.4}',
58
+ color: '{color.fg-default}',
59
+ },
60
+ group: { display: 'flex', 'flex-direction': 'column', gap: '{space.0.5}' },
61
+ 'group-label': {
62
+ display: 'block',
63
+ ...typeStyle('label-xs'),
64
+ 'font-family': '{font.family.mono}',
65
+ 'letter-spacing': '{font.letterSpacing.wide}',
66
+ 'text-transform': 'uppercase',
67
+ color: '{color.fg-subtle}',
68
+ padding: '0 {space.2} {space.1}',
69
+ 'user-select': 'none',
70
+ },
71
+ item: {
72
+ position: 'relative',
73
+ display: 'flex',
74
+ 'align-items': 'center',
75
+ gap: '{space.2}',
76
+ width: '100%',
77
+ 'max-height': '{space.9}',
78
+ padding: '{space.2}',
79
+ 'border-radius': '{radius.md}',
80
+ ...typeStyle('label-md'),
81
+ color: '{color.fg-muted}',
82
+ 'background-color': 'transparent',
83
+ 'text-decoration': 'none',
84
+ cursor: 'pointer',
85
+ 'white-space': 'nowrap',
86
+ 'min-width': '0',
87
+ 'user-select': 'none',
88
+ 'list-style': 'none',
89
+ ...TRANSITION_FAST,
90
+ },
91
+ icon: { width: '{size.icon.md}', height: '{size.icon.md}', 'flex-shrink': '0', color: '{color.fg-subtle}', transition: 'inherit' },
92
+ label: { flex: '1 1 auto', 'min-width': '0', overflow: 'hidden', 'text-overflow': 'ellipsis', 'white-space': 'nowrap' },
93
+ count: {
94
+ display: 'inline-flex',
95
+ 'align-items': 'center',
96
+ 'justify-content': 'center',
97
+ height: '22px',
98
+ 'margin-inline-start': '{space.3}',
99
+ 'margin-block': '-1px',
100
+ 'padding-inline': '{space.2}',
101
+ 'border-radius': '{radius.sm}',
102
+ 'background-color': '{color.bg-subtle}',
103
+ 'box-shadow': ring('{color.border-default}'),
104
+ ...typeStyle('label-xs'),
105
+ color: '{color.fg-muted}',
106
+ 'font-variant-numeric': 'tabular-nums',
107
+ 'flex-shrink': '0',
108
+ },
109
+ chevron: { width: '{size.icon.sm}', height: '{size.icon.sm}', 'margin-inline-start': '{space.3}', 'flex-shrink': '0', color: '{color.fg-subtle}', 'stroke-width': '2.5', 'transition-property': 'transform', 'transition-duration': '{motion.duration.fast}', 'transition-timing-function': '{motion.easing.linear}' },
110
+ divider: { border: '0', 'border-top': HAIRLINE, width: '100%', margin: '{space.2} {space.0.5}' },
111
+ },
112
+ variants: {
113
+ size: {
114
+ sm: {
115
+ item: { 'max-height': '{space.8}', padding: '{space.1.5} {space.2}' },
116
+ icon: { width: '{size.icon.sm}', height: '{size.icon.sm}' },
117
+ },
118
+ md: { root: {} },
119
+ },
120
+ variant: {
121
+ default: { root: {} },
122
+ inset: {
123
+ root: { 'background-color': '{color.bg-subtle}', 'border-inline-end': HAIRLINE },
124
+ },
125
+ },
126
+ },
127
+ extraCss: `
128
+ .cn-sidebar-nav__item:hover:not([aria-disabled="true"]) { background-color: {color.bg-subtle}; color: {color.fg-default}; }
129
+ .cn-sidebar-nav__item:hover:not([aria-disabled="true"]) .cn-sidebar-nav__icon { color: {color.fg-muted}; }
130
+ .cn-sidebar-nav__item[aria-current="page"] { background-color: {color.bg-action-subtle}; color: {color.fg-action}; box-shadow: inset 2px 0 0 {color.bg-action}; }
131
+ [dir="rtl"] .cn-sidebar-nav__item[aria-current="page"] { box-shadow: inset -2px 0 0 {color.bg-action}; }
132
+ .cn-sidebar-nav__item[aria-current="page"]:hover { background-color: {color.bg-muted}; }
133
+ .cn-sidebar-nav__item[aria-current="page"] .cn-sidebar-nav__icon { color: inherit; }
134
+ .cn-sidebar-nav[data-variant="inset"] .cn-sidebar-nav__item:hover:not([aria-disabled="true"]) { background-color: {color.bg-muted}; }
135
+ .cn-sidebar-nav[data-variant="inset"] .cn-sidebar-nav__item[aria-current="page"] { background-color: {color.bg-surface}; box-shadow: inset 2px 0 0 {color.bg-action}; }
136
+ .cn-sidebar-nav__item:focus-visible { outline: none; box-shadow: {shadow.focus}; z-index: {z.raised}; }
137
+ .cn-sidebar-nav__item[aria-disabled="true"] { opacity: {opacity.disabled}; cursor: not-allowed; pointer-events: none; }
138
+ .cn-sidebar-nav__item[data-level="2"] { padding-inline-start: {space.10}; padding-inline-end: {space.3}; }
139
+ .cn-sidebar-nav[data-size="sm"] .cn-sidebar-nav__item[data-level="2"] { padding-inline-start: {space.8}; }
140
+ details.cn-sidebar-nav__group > summary::-webkit-details-marker { display: none; }
141
+ details.cn-sidebar-nav__group > summary::marker { content: ""; }
142
+ details.cn-sidebar-nav__group[open] > summary .cn-sidebar-nav__chevron { transform: scaleY(-1); }
143
+ details.cn-sidebar-nav__group > .cn-sidebar-nav__item + .cn-sidebar-nav__item { margin-top: {space.0.5}; }`,
144
+ examples: [
145
+ ex('Simple list', `<nav class="cn-sidebar-nav" data-variant="default" data-size="md" aria-label="Main">${group('sn-main', null, MAIN())}</nav>`, '36px rows, 14px semibold, 20px icons; Dashboard is current (gray-50), Tasks carries a 22px count badge.'),
146
+ ex('Sections with subheadings', `<nav class="cn-sidebar-nav" data-variant="default" data-size="md" aria-label="Main">${group('sn-general', 'General', item('Home', 'home') + item('Dashboard', 'calendar', ' aria-current="page"') + item('Tasks', 'check', '', '10'))}${group('sn-ws', 'Workspace', item('Projects', 'copy') + item('Reporting', 'inbox') + item('Users', 'user'))}${group('sn-acct', 'Account', item('Support', 'info') + item('Settings', 'settings'))}</nav>`, 'Groups 16px apart, each with a 12px semibold subheading.'),
147
+ ex('Dividers and a collapsible parent', `<nav class="cn-sidebar-nav" data-variant="default" data-size="md" aria-label="Main">${group('sn-d1', null, item('Home', 'home') + item('Dashboard', 'calendar'))}<hr class="cn-sidebar-nav__divider">${group('sn-d2', null, parent('Projects', 'copy', true, item('All projects', null, ' data-level="2" aria-current="page"') + item('Shared with me', null, ' data-level="2"') + item('Archived', null, ' data-level="2"')) + item('Reporting', 'inbox') + item('Users', 'user'))}<hr class="cn-sidebar-nav__divider">${group('sn-d3', null, item('Support', 'info') + item('Settings', 'settings'))}</nav>`, 'A <details> group with a <summary> row: the chevron flips when open; child rows are indented to 40px.'),
148
+ ex('Inset rail, small', `<nav class="cn-sidebar-nav" data-variant="inset" data-size="sm" aria-label="Settings">${group('sn-i1', 'Workspace', item('General', 'settings') + item('Members', 'user', ' aria-current="page"') + item('Billing', 'calendar'))}${group('sn-i2', 'Data', item('Imports', 'inbox') + item('Integrations', 'external') + item('API keys', 'copy'))}</nav>`, 'The rail is gray-50 with a trailing hairline; the current item uses a leading action-colored mark. 32px rows.'),
149
+ ex('Disabled item and truncation', `<nav class="cn-sidebar-nav" data-variant="default" data-size="md" aria-label="Reports">${group('sn-r', 'Reports', item('Pipeline', 'arrow', ' aria-current="page"') + item('Enterprise procurement pipeline, Q3 review', 'calendar') + `<a class="cn-sidebar-nav__item" aria-disabled="true" title="Available on the Business plan">${icon('spark')}<span class="cn-sidebar-nav__label">Attribution</span></a>`)}</nav>`, 'Long labels truncate; the disabled item keeps its name and explains itself in a title or Tooltip.'),
150
+ ],
151
+ rules: [
152
+ 'Max 7 items per group and max 4 groups visible. If the nav needs more, the information architecture is wrong, not the component.',
153
+ 'Never one item per database table. Items are jobs and destinations ("Dashboard", "Reporting", "Billing"), grouped by who uses them and when.',
154
+ 'Labels are 1–2 words, sentence case, nouns. No verbs ("Manage members" → "Members"), no product jargon.',
155
+ 'Rows are 36px (sm 32px) with 8px padding and 2px between them; never change the height with padding.',
156
+ 'Icons are all-or-none within a group, 20px (16px at sm), fg-subtle; never emoji, never colored.',
157
+ 'Counts use the 22px badge and only for what needs action (unread, pending approvals, failed runs), never totals; hide the count at 0.',
158
+ 'Exactly one item carries aria-current="page" at any time, the deepest matching route; a collapsible parent stays open while a child is current.',
159
+ 'Width is fixed at 280px; the content never widens it. Long labels truncate with an ellipsis and get a title attribute.',
160
+ 'Two levels at most: a collapsible parent and its children. Deeper sections are Tabs on the page.',
161
+ 'The first group may go unlabeled (the home set); later groups get a subheading or a divider, not both.',
162
+ ],
163
+ a11y: [
164
+ 'Root is <nav aria-label="…"> naming the area ("Main", "Settings"); each group is role="group" with aria-labelledby pointing at its subheading.',
165
+ 'Items are real <a href> links; aria-current="page" marks the current destination and is the only selection state.',
166
+ 'Collapsible parents are <details>/<summary> so they work without JS; the chevron is decorative.',
167
+ 'Disabled items use aria-disabled="true", drop the href, and explain why in a title or Tooltip; they stay in the DOM so the structure is stable.',
168
+ 'Counts are read as part of the link text; add sr-only context when the number is ambiguous ("10 open tasks").',
169
+ 'Keyboard: Tab moves through items in order; no roving tabindex or arrow keys (this is a list of links, not a menu).',
170
+ ],
171
+ related: ['sidebar', 'topbar', 'tabs', 'breadcrumb', 'badge'],
172
+ };
@@ -0,0 +1,124 @@
1
+ import { ex, ICON, typeStyle } from "./_shared.mjs";
2
+ const item = (label, icon, current = false, badge = '') => `<a href="#" class="cn-sidebar__item"${current ? ' aria-current="page"' : ''}>${icon.replace('cn-icon', 'cn-sidebar__icon')}<span class="cn-sidebar__label">${label}</span>${badge ? `<span class="cn-sidebar__badge">${badge}</span>` : ''}</a>`;
3
+ const NAV = `${item('Home', ICON.home)}${item('Dashboard', ICON.calendar, true)}${item('Projects', ICON.copy)}${item('Tasks', ICON.check, false, '10')}${item('Reporting', ICON.inbox)}${item('Users', ICON.user)}`;
4
+ const FOOT = `${item('Support', ICON.info)}${item('Settings', ICON.settings)}`;
5
+ const LOGO = `<div class="cn-sidebar__brand"><span class="cn-sidebar__logo" aria-hidden="true"></span><span class="cn-sidebar__brand-name">Lumen</span></div>`;
6
+ const ACCOUNT = `<div class="cn-sidebar__account"><span class="cn-sidebar__avatar" aria-hidden="true">MC</span><div class="cn-sidebar__account-text"><span class="cn-sidebar__name">Maya Chen</span><span class="cn-sidebar__email">maya@lumen.co</span></div><button type="button" class="cn-sidebar__account-action" aria-label="Log out">${ICON.external}</button></div>`;
7
+ const SEARCH = `<div class="cn-sidebar__search cn-input" data-variant="default" data-size="md">${ICON.search.replace('cn-icon', 'cn-input__icon')}<input class="cn-input__field" type="search" placeholder="Search" aria-label="Search"></div>`;
8
+ export const sidebar = {
9
+ name: 'Sidebar',
10
+ slug: 'sidebar',
11
+ category: 'navigation',
12
+ description: 'The application sidebar family of the system: a 280px column with logo, search, navigation items matching SidebarNav (36px, label-md, 20px icons), a bottom group and an account card; slim 68px rail; dual-tier rail + panel; sections with subheadings; dark theme.',
13
+ usage: 'Primary navigation of an application with more than 5 destinations. For 3–5 destinations prefer the Topbar with tabs. Groups: main destinations at top, support/settings at the bottom, account card last. Never one item per database table; items are user tasks.',
14
+ anatomy: [
15
+ { part: 'root', element: 'nav', description: 'The column: flex column, full height, right hairline.' },
16
+ { part: 'brand', element: 'div', description: 'Logo row at the top (32px logo + name).' },
17
+ { part: 'logo', element: 'span', description: '32px brand mark.' },
18
+ { part: 'brand-name', element: 'span', description: 'Product name, label-lg semibold (hidden in slim).' },
19
+ { part: 'search', element: 'div', description: 'Optional search Input under the logo.', optional: true },
20
+ { part: 'nav', element: 'div', description: 'Main list of items (flex column, gap 2px).' },
21
+ { part: 'section', element: 'div', description: 'A group with a subheading (sections variant).', optional: true },
22
+ { part: 'heading', element: 'div', description: 'Group subheading: label-xs in fg-subtle, 8px horizontal padding.', optional: true },
23
+ { part: 'item', element: 'a', description: 'Nav item: 36px, 8px horizontal padding and gap, radius 6, label-md (14px medium by default), fg-muted; icon + label + optional badge.' },
24
+ { part: 'icon', element: 'svg', description: '20px icon in fg-subtle (fg-muted when current).' },
25
+ { part: 'label', element: 'span', description: 'The item text, truncated.' },
26
+ { part: 'badge', element: 'span', description: 'Trailing count pill (22px, bg-subtle, body-xs).', optional: true },
27
+ { part: 'divider', element: 'hr', description: 'Hairline between groups (dividers variant).', optional: true },
28
+ { part: 'footer', element: 'div', description: 'Bottom group: support/settings items then the account card.' },
29
+ { part: 'account', element: 'div', description: 'Account card: avatar 40, name + email, trailing log-out icon; bordered card in the default theme.' },
30
+ { part: 'avatar', element: 'span', description: '40px circle with initials or image.' },
31
+ { part: 'account-text', element: 'div', description: 'Name and email column.' },
32
+ { part: 'name', element: 'span', description: 'label-sm semibold.' },
33
+ { part: 'email', element: 'span', description: 'body-sm fg-muted, truncated.' },
34
+ { part: 'account-action', element: 'button', description: '36px utility button (log out / ⋯).' },
35
+ { part: 'rail', element: 'div', description: 'The 68px icon-only column of the dual-tier variant.', optional: true },
36
+ { part: 'panel', element: 'div', description: 'The 256px second-tier panel of the dual-tier variant.', optional: true },
37
+ { part: 'rail-item', element: 'a', description: '48px square icon button with a 20px icon in a rail.', optional: true },
38
+ ],
39
+ props: {
40
+ variant: { values: ['simple', 'slim', 'dual-tier', 'sections', 'dividers'], default: 'simple', description: 'simple = one flat list (280px); slim = 68px icon rail with tooltips; dual-tier = 68px rail + 256px panel for the active area; sections = groups with subheadings; dividers = groups separated by hairlines.' },
41
+ theme: { values: ['light', 'dark', 'brand'], default: 'light', description: 'light = white; dark = gray-950 surface with gray-800 borders; brand = brand-700 surface (marketing-style app).' },
42
+ },
43
+ states: {
44
+ current: { selector: ' .cn-sidebar__item[aria-current="page"]', description: 'The active destination: subtle fill, fg-default, icon fg-muted.', markup: 'aria-current="page" on the item' },
45
+ hover: { selector: ' .cn-sidebar__item:hover', description: 'Subtle fill.', markup: 'native' },
46
+ focus: { selector: ' .cn-sidebar__item:focus-visible', description: 'Focus ring.', markup: 'native' },
47
+ },
48
+ base: {
49
+ root: { display: 'flex', 'flex-direction': 'column', width: 'calc({space.64} + {space.6})', 'min-height': '640px', 'background-color': '{color.bg-surface}', 'border-inline-end': '{border.width.thin} solid {color.border-default}', padding: '{space.5} {space.4} {space.6}', gap: '{space.5}', 'flex-shrink': '0' },
50
+ brand: { display: 'flex', 'align-items': 'center', gap: '{space.2.5}', 'padding-inline': '{space.2}', height: '32px' },
51
+ logo: { width: '32px', height: '32px', 'border-radius': '{radius.lg}', 'background-color': '{color.bg-action}', 'flex-shrink': '0' },
52
+ 'brand-name': { ...typeStyle('label-lg'), color: '{color.fg-default}' },
53
+ search: { width: '100%' },
54
+ nav: { display: 'flex', 'flex-direction': 'column', gap: '{space.0.5}', flex: '1 1 auto', 'min-height': '0' },
55
+ section: { display: 'flex', 'flex-direction': 'column', gap: '{space.0.5}', 'margin-top': '{space.4}' },
56
+ heading: { ...typeStyle('label-xs'), color: '{color.fg-subtle}', padding: '0 {space.2} {space.1}' },
57
+ item: { display: 'flex', 'align-items': 'center', gap: '{space.2}', height: '{space.9}', 'padding-inline': '{space.2}', 'border-radius': '{radius.md}', ...typeStyle('label-md'), color: '{color.fg-muted}', 'text-decoration': 'none', 'transition-property': 'background-color, color', 'transition-duration': '{motion.duration.fast}', 'transition-timing-function': '{motion.easing.standard}' },
58
+ icon: { width: '{size.icon.md}', height: '{size.icon.md}', 'flex-shrink': '0', color: '{color.fg-subtle}' },
59
+ label: { flex: '1 1 auto', 'min-width': '0', overflow: 'hidden', 'text-overflow': 'ellipsis', 'white-space': 'nowrap' },
60
+ badge: { display: 'inline-flex', 'align-items': 'center', height: '22px', 'padding-inline': '{space.2}', 'border-radius': '{radius.full}', 'background-color': '{color.bg-subtle}', border: '{border.width.thin} solid {color.border-default}', ...typeStyle('body-xs'), 'font-weight': '{font.weight.medium}', color: '{color.fg-muted}' },
61
+ divider: { border: '0', 'border-top': '{border.width.thin} solid {color.border-default}', margin: '{space.3} 0' },
62
+ footer: { display: 'flex', 'flex-direction': 'column', gap: '{space.0.5}', 'padding-top': '{space.4}' },
63
+ account: { display: 'flex', 'align-items': 'center', gap: '{space.3}', padding: '{space.3}', 'margin-top': '{space.4}', 'border-radius': '{radius.card}', border: '{border.width.thin} solid {color.border-default}', 'background-color': '{color.bg-surface}', 'box-shadow': '{shadow.xs}' },
64
+ avatar: { display: 'inline-flex', 'align-items': 'center', 'justify-content': 'center', width: '40px', height: '40px', 'border-radius': '{radius.full}', 'background-color': '{color.bg-accent-subtle}', color: '{color.fg-accent}', ...typeStyle('label-sm'), 'flex-shrink': '0' },
65
+ 'account-text': { display: 'flex', 'flex-direction': 'column', 'min-width': '0', flex: '1 1 auto' },
66
+ name: { ...typeStyle('label-sm'), color: '{color.fg-default}', overflow: 'hidden', 'text-overflow': 'ellipsis', 'white-space': 'nowrap' },
67
+ email: { ...typeStyle('body-sm'), color: '{color.fg-muted}', overflow: 'hidden', 'text-overflow': 'ellipsis', 'white-space': 'nowrap' },
68
+ 'account-action': { appearance: 'none', border: '0', padding: '0', margin: '0', background: 'none', cursor: 'pointer', display: 'inline-flex', 'align-items': 'center', 'justify-content': 'center', width: '36px', height: '36px', 'border-radius': '{radius.md}', color: '{color.fg-subtle}', 'flex-shrink': '0' },
69
+ rail: { display: 'flex', 'flex-direction': 'column', 'align-items': 'center', gap: '{space.1}', width: '68px', padding: '{space.5} {space.2.5}', 'border-inline-end': '{border.width.thin} solid {color.border-default}' },
70
+ panel: { display: 'flex', 'flex-direction': 'column', gap: '{space.0.5}', width: '256px', padding: '{space.5} {space.4}' },
71
+ 'rail-item': { display: 'inline-flex', 'align-items': 'center', 'justify-content': 'center', width: '48px', height: '48px', 'border-radius': '{radius.md}', color: '{color.fg-subtle}' },
72
+ '@states': {
73
+ current: { item: { 'background-color': '{color.bg-action-subtle}', color: '{color.fg-action}', 'box-shadow': 'inset 2px 0 0 currentColor' } },
74
+ hover: { item: { 'background-color': '{color.bg-subtle}', color: '{color.fg-default}' } },
75
+ focus: { item: { outline: 'none', 'box-shadow': '{shadow.focus}' } },
76
+ },
77
+ },
78
+ variants: {
79
+ variant: {
80
+ simple: { root: {} },
81
+ slim: { root: { width: '68px', 'align-items': 'center', padding: '{space.5} {space.2.5} {space.6}' }, item: { width: '48px', height: '48px', 'justify-content': 'center', 'padding-inline': '0' }, account: { padding: '0', border: '0', 'box-shadow': 'none', 'margin-top': '{space.4}' }, search: { display: 'none' } },
82
+ 'dual-tier': { root: { 'flex-direction': 'row', width: 'auto', padding: '0', gap: '0' } },
83
+ sections: { root: {} },
84
+ dividers: { root: {} },
85
+ },
86
+ theme: {
87
+ light: { root: {} },
88
+ dark: { root: { 'background-color': '{neutral.950}', 'border-color': '{neutral.800}', color: '{neutral.100}' }, 'brand-name': { color: '{white}' }, item: { color: '{neutral.100}' }, icon: { color: '{neutral.400}' }, heading: { color: '{neutral.500}' }, badge: { 'background-color': '{neutral.800}', 'border-color': '{neutral.700}', color: '{neutral.200}' }, divider: { 'border-color': '{neutral.800}' }, account: { 'border-color': '{neutral.800}', 'background-color': '{neutral.950}' }, name: { color: '{white}' }, email: { color: '{neutral.400}' }, rail: { 'border-color': '{neutral.800}' }, 'account-action': { color: '{neutral.400}' }, '@states': { current: { item: { 'background-color': '{neutral.800}', color: '{white}' } }, hover: { item: { 'background-color': '{neutral.800}', color: '{white}' } } } },
89
+ brand: { root: { 'background-color': '{brand.700}', 'border-color': '{brand.600}', color: '{white}' }, 'brand-name': { color: '{white}' }, logo: { 'background-color': '{white}' }, item: { color: '{brand.100}' }, icon: { color: '{brand.300}' }, heading: { color: '{brand.300}' }, badge: { 'background-color': '{brand.600}', 'border-color': '{brand.500}', color: '{white}' }, divider: { 'border-color': '{brand.600}' }, account: { 'border-color': '{brand.600}', 'background-color': '{brand.700}' }, avatar: { 'background-color': '{brand.500}', color: '{white}' }, name: { color: '{white}' }, email: { color: '{brand.200}' }, 'account-action': { color: '{brand.200}' }, '@states': { current: { item: { 'background-color': '{brand.600}', color: '{white}' } }, hover: { item: { 'background-color': '{brand.600}', color: '{white}' } } } },
90
+ },
91
+ },
92
+ extraCss: `
93
+ .cn-sidebar__logo { clip-path: polygon(0 0, 100% 0, 100% 22%, 27% 22%, 27% 78%, 100% 78%, 100% 100%, 0 100%); border-radius: 0; }
94
+ [dir="rtl"] .cn-sidebar__item[aria-current="page"] { box-shadow: inset -2px 0 0 currentColor; }
95
+ .cn-sidebar[data-variant="slim"] .cn-sidebar__label, .cn-sidebar[data-variant="slim"] .cn-sidebar__badge, .cn-sidebar[data-variant="slim"] .cn-sidebar__brand-name, .cn-sidebar[data-variant="slim"] .cn-sidebar__account-text, .cn-sidebar[data-variant="slim"] .cn-sidebar__account-action { display: none; }
96
+ .cn-sidebar[data-variant="slim"] .cn-sidebar__brand { padding-inline: 0; }
97
+ .cn-sidebar[data-variant="slim"] .cn-sidebar__footer { align-items: center; }
98
+ .cn-sidebar__rail-item > svg { width: {size.icon.md}; height: {size.icon.md}; }
99
+ .cn-sidebar[data-variant="dual-tier"] .cn-sidebar__rail-item[aria-current="page"], .cn-sidebar__rail-item:hover { background-color: {color.bg-subtle}; color: {color.fg-default}; }
100
+ .cn-sidebar[data-variant="dual-tier"] .cn-sidebar__panel .cn-sidebar__heading { padding-inline: {space.2}; margin-bottom: {space.2}; font-size: {font.size.md}; font-weight: {font.weight.semibold}; color: {color.fg-default}; }
101
+ .cn-sidebar__item[aria-current="page"] .cn-sidebar__icon { color: inherit; }
102
+ .cn-sidebar[data-theme="dark"] .cn-sidebar__item[aria-current="page"] .cn-sidebar__icon, .cn-sidebar[data-theme="brand"] .cn-sidebar__item[aria-current="page"] .cn-sidebar__icon { color: currentColor; }
103
+ .cn-sidebar__account-action:hover { background-color: {color.bg-subtle}; color: {color.fg-muted}; }
104
+ .cn-sidebar[data-theme="dark"] .cn-sidebar__search .cn-input { background-color: {neutral.900}; border-color: {neutral.700}; color: {white}; }
105
+ .cn-sidebar[data-theme="brand"] .cn-sidebar__search .cn-input { background-color: {brand.600}; border-color: {brand.500}; color: {white}; }`,
106
+ examples: [
107
+ ex('Simple', `<nav class="cn-sidebar" data-variant="simple" data-theme="light" aria-label="Main">${LOGO}${SEARCH}<div class="cn-sidebar__nav">${NAV}</div><div class="cn-sidebar__footer">${FOOT}${ACCOUNT}</div></nav>`),
108
+ ex('Sections with subheadings', `<nav class="cn-sidebar" data-variant="sections" data-theme="light" aria-label="Main">${LOGO}<div class="cn-sidebar__nav"><div class="cn-sidebar__section"><div class="cn-sidebar__heading">General</div>${item('Home', ICON.home)}${item('Dashboard', ICON.calendar, true)}${item('Tasks', ICON.check, false, '10')}</div><div class="cn-sidebar__section"><div class="cn-sidebar__heading">Workspace</div>${item('Projects', ICON.copy)}${item('Reporting', ICON.inbox)}${item('Users', ICON.user)}</div></div><div class="cn-sidebar__footer">${FOOT}${ACCOUNT}</div></nav>`),
109
+ ex('Dividers', `<nav class="cn-sidebar" data-variant="dividers" data-theme="light" aria-label="Main">${LOGO}<div class="cn-sidebar__nav">${item('Home', ICON.home)}${item('Dashboard', ICON.calendar, true)}<hr class="cn-sidebar__divider">${item('Projects', ICON.copy)}${item('Tasks', ICON.check, false, '10')}${item('Reporting', ICON.inbox)}<hr class="cn-sidebar__divider">${item('Users', ICON.user)}</div><div class="cn-sidebar__footer">${FOOT}${ACCOUNT}</div></nav>`),
110
+ ex('Slim rail', `<nav class="cn-sidebar" data-variant="slim" data-theme="light" aria-label="Main">${LOGO}<div class="cn-sidebar__nav">${item('Home', ICON.home)}${item('Dashboard', ICON.calendar, true)}${item('Projects', ICON.copy)}${item('Tasks', ICON.check)}${item('Reporting', ICON.inbox)}${item('Users', ICON.user)}</div><div class="cn-sidebar__footer">${FOOT}${ACCOUNT}</div></nav>`),
111
+ ex('Dual tier', `<nav class="cn-sidebar" data-variant="dual-tier" data-theme="light" aria-label="Main"><div class="cn-sidebar__rail"><span class="cn-sidebar__logo" aria-hidden="true"></span><a href="#" class="cn-sidebar__rail-item" aria-label="Home">${ICON.home}</a><a href="#" class="cn-sidebar__rail-item" aria-current="page" aria-label="Projects">${ICON.copy}</a><a href="#" class="cn-sidebar__rail-item" aria-label="Reporting">${ICON.inbox}</a><a href="#" class="cn-sidebar__rail-item" aria-label="Settings">${ICON.settings}</a></div><div class="cn-sidebar__panel"><div class="cn-sidebar__heading">Projects</div>${item('All projects', ICON.copy, true, '24')}${item('Archived', ICON.inbox)}${item('Shared with me', ICON.user)}${item('Templates', ICON.calendar)}</div></nav>`),
112
+ ex('Dark theme', `<nav class="cn-sidebar" data-variant="simple" data-theme="dark" aria-label="Main">${LOGO}${SEARCH}<div class="cn-sidebar__nav">${NAV}</div><div class="cn-sidebar__footer">${FOOT}${ACCOUNT}</div></nav>`),
113
+ ex('Brand theme', `<nav class="cn-sidebar" data-variant="simple" data-theme="brand" aria-label="Main">${LOGO}<div class="cn-sidebar__nav">${NAV}</div><div class="cn-sidebar__footer">${FOOT}${ACCOUNT}</div></nav>`),
114
+ ],
115
+ rules: [
116
+ 'Width 280px, matching SidebarNav. Slim rail 68px. Dual tier 68 + 256. The sidebar never scrolls the page; its own nav scrolls.',
117
+ 'Items match SidebarNav: 36px tall with a 20px icon, 8px gap and horizontal padding, label-md typography, and 2px between rows. The current item carries a leading rule and action-tinted surface; hover uses a quiet neutral fill.',
118
+ 'Order: logo, optional search, main items (≤ 7), sections, then the footer with Support/Settings and the account card.',
119
+ 'Counts use the badge part; never red dots for ordinary counts.',
120
+ 'On tablets collapse to the slim rail; on mobile replace with the MobileHeader drawer.',
121
+ ],
122
+ a11y: ['<nav aria-label="Main">; the current item carries aria-current="page".', 'Slim rail items are icon-only and need aria-label (and a Tooltip on hover).', 'The account log-out control is a labelled button.'],
123
+ related: ['sidebar-nav', 'topbar', 'account-card', 'mobile-header', 'input'],
124
+ };
@@ -0,0 +1,66 @@
1
+ import { ex } from "./_shared.mjs";
2
+ export const skeleton = {
3
+ name: 'Skeleton',
4
+ slug: 'skeleton',
5
+ category: 'feedback',
6
+ description: 'Grey placeholder shapes that hold the layout while content loads: text lines, a circle, a rectangle or a card, with a slow shimmer sweeping across. Same size as the content they stand in for, so nothing jumps when it arrives.',
7
+ usage: 'Use when the shape of the incoming content is known (a list, a card, a profile header) and the wait is over ~300ms. For a wait with no known layout use Spinner; for a measurable task use Progress. Never leave skeletons on screen after an error; replace them with an empty state or an Alert.',
8
+ anatomy: [
9
+ { part: 'root', element: 'div', description: 'One placeholder block. aria-hidden="true"; the region around it carries aria-busy. Size comes from the variant plus inline width/height.' },
10
+ ],
11
+ props: {
12
+ variant: {
13
+ values: ['text', 'circle', 'rect', 'card'],
14
+ default: 'text',
15
+ description: 'text = one line, 1em tall, radius sm, full width (set width inline for the last line); circle = 40px avatar placeholder, radius full; rect = a box with radius md for images, inputs and buttons (size inline); card = radius xl, min-height 120px, for a whole card.',
16
+ },
17
+ },
18
+ states: {},
19
+ base: {
20
+ root: {
21
+ display: 'block',
22
+ 'flex-shrink': '0',
23
+ 'background-color': '{color.bg-muted}',
24
+ 'background-image': 'linear-gradient(90deg, {color.bg-muted} 0%, {color.bg-subtle} 50%, {color.bg-muted} 100%)',
25
+ 'background-size': '200% 100%',
26
+ 'border-radius': '{radius.md}',
27
+ animation: 'cn-skeleton-shimmer 1.4s ease-in-out infinite',
28
+ },
29
+ },
30
+ variants: {
31
+ variant: {
32
+ text: { root: { height: '1em', width: '100%', 'border-radius': '{radius.sm}' } },
33
+ circle: { root: { width: '{size.icon.2xl}', height: '{size.icon.2xl}', 'border-radius': '{radius.full}' } },
34
+ rect: { root: { 'border-radius': '{radius.md}' } },
35
+ card: { root: { 'border-radius': '{radius.xl}', 'min-height': '120px', width: '100%' } },
36
+ },
37
+ },
38
+ extraCss: `
39
+ @keyframes cn-skeleton-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
40
+ .cn-skeleton + .cn-skeleton[data-variant="text"] { margin-top: {space.2}; }`,
41
+ examples: [
42
+ ex('Text lines', `<div style="width:320px" aria-busy="true"><div class="cn-skeleton" data-variant="text" aria-hidden="true"></div><div class="cn-skeleton" data-variant="text" aria-hidden="true"></div><div class="cn-skeleton" data-variant="text" aria-hidden="true" style="width:60%"></div></div>`, 'Consecutive text skeletons space themselves 8px apart. The last line is shorter.'),
43
+ ex('Circle', `<div class="cn-skeleton" data-variant="circle" aria-hidden="true"></div>`),
44
+ ex('Rect (image or control)', `<div class="cn-skeleton" data-variant="rect" aria-hidden="true" style="width:240px;height:36px"></div>`),
45
+ ex('Card', `<div class="cn-skeleton" data-variant="card" aria-hidden="true" style="width:320px;height:140px"></div>`),
46
+ ],
47
+ recipes: [
48
+ ex('List row (circle + two lines)', `<div style="display:flex;align-items:center;gap:var(--cn-space-3);width:360px" aria-busy="true"><div class="cn-skeleton" data-variant="circle" aria-hidden="true"></div><div style="flex:1;min-width:0"><div class="cn-skeleton" data-variant="text" aria-hidden="true" style="width:45%"></div><div class="cn-skeleton" data-variant="text" aria-hidden="true" style="width:70%;height:0.85em"></div></div></div>`, 'Mirrors a prospect row: avatar, name, one line of meta. Repeat it 3–5 times, never more.'),
49
+ ex('Card with header', `<section class="cn-card" data-variant="default" data-padding="md" style="max-width:360px" aria-busy="true"><div class="cn-card__body"><div class="cn-skeleton" data-variant="text" aria-hidden="true" style="width:40%;height:1.25em"></div><div class="cn-skeleton" data-variant="text" aria-hidden="true" style="width:80%"></div><div class="cn-skeleton" data-variant="text" aria-hidden="true" style="width:65%"></div><div class="cn-skeleton" data-variant="rect" aria-hidden="true" style="width:120px;height:32px;margin-top:var(--cn-space-4)"></div></div></section>`),
50
+ ],
51
+ rules: [
52
+ 'Match the real content: same widths, same line count, same avatar size. A skeleton that does not match the layout causes a jump and is worse than a spinner.',
53
+ 'Show 3–5 rows for a list, never the whole page. Below the fold the real content can arrive silently.',
54
+ 'Do not show skeletons for waits under 300ms, and keep them at least 500ms once shown to avoid flicker.',
55
+ 'One shimmer rhythm for the whole page (1.4s); all skeletons animate in sync because they share one keyframe.',
56
+ 'Vary text widths (100%, 60%, 80%); identical lines look like a table, not text.',
57
+ 'Never put skeletons inside a Button, a Badge or a Toast.',
58
+ 'Errors replace skeletons with an Alert or an empty state; they never stay on screen.',
59
+ ],
60
+ a11y: [
61
+ 'aria-hidden="true" on every skeleton; put aria-busy="true" on the region that is loading and remove it when content arrives.',
62
+ 'Announce the loaded state through a live region only when the wait was long (more than a few seconds).',
63
+ 'The shimmer stops under prefers-reduced-motion (base.css); the grey shapes remain.',
64
+ ],
65
+ related: ['spinner', 'progress', 'card'],
66
+ };
@@ -0,0 +1,122 @@
1
+ import { ex, typeStyle } from "./_shared.mjs";
2
+ // the reference slider: 8px gray-200 rail with a brand-600 fill, a 24px white thumb with a 2px brand
3
+ // ring and shadow-md (grab / grabbing cursor), and an optional value label — bottom (16px medium
4
+ // under the thumb) or top (a floating white chip with shadow-lg and a hairline ring above it).
5
+ // Focus = 4px brand ring on the thumb; disabled = whole control at 50%.
6
+ const THUMB = `box-sizing: border-box; width: {space.6}; height: {space.6}; border-radius: {radius.full}; background-color: {white}; border: 0; box-shadow: inset 0 0 0 2px {color.bg-action}, {shadow.md}; cursor: grab; transition: box-shadow {motion.duration.fast} {motion.easing.linear};`;
7
+ const TRACK = `height: {space.2}; border-radius: {radius.full}; background: linear-gradient(to right, {color.bg-action} var(--cn-slider-fill), {color.border-default} var(--cn-slider-fill));`;
8
+ const THUMB_FOCUS = `box-shadow: inset 0 0 0 2px {color.bg-action}, {shadow.md}, {shadow.focus};`;
9
+ /** Where the floating labels sit: under the thumb centre, clamped so they never leave the track. */
10
+ const LABEL_LEFT = 'clamp({space.3}, var(--cn-slider-fill), calc(100% - {space.3}))';
11
+ const slider_ = (attrs, fill, value = '', label = 'right', style = 'max-width:360px', root = '') => `<div class="cn-slider" data-label="${label}" style="--cn-slider-fill: ${fill};${style}" ${root}><input class="cn-slider__field" type="range" ${attrs}>${value ? `<output class="cn-slider__value" for="${attrs.match(/id="([^"]+)"/)?.[1] ?? ''}">${value}</output>` : ''}</div>`;
12
+ export const slider = {
13
+ name: 'Slider',
14
+ slug: 'slider',
15
+ category: 'forms',
16
+ description: 'Native range input with an 8px gray-200 rail, a brand fill up to the current value and a 24px white thumb with a 2px brand ring and shadow-md. The value can sit at the right, under the thumb or in a floating chip above it.',
17
+ usage: 'Pick a value from a continuous or coarse range where the feel matters more than the exact figure (a threshold, a budget band, opacity, volume). For exact numbers use Input type="number"; for a few discrete options use SegmentedControl or Radio. Pair with Field for the label.',
18
+ anatomy: [
19
+ { part: 'root', element: 'div', description: 'Flex row holding the range input and, optionally, the readout. Carries --cn-slider-fill (the value as a percentage), data-label and data-disabled.' },
20
+ { part: 'field', element: 'input', description: 'The native <input type="range">, 24px tall. Rail and thumb are styled through pseudo-elements; the fill comes from the --cn-slider-fill custom property.' },
21
+ { part: 'value', element: 'output', description: 'Optional readout (<output for="<input id>">): right = 14px medium tabular text beside the track; bottom = 16px medium text centred under the thumb; top = a floating 12px semibold chip above the thumb (white, radius 8, shadow-lg, hairline ring).', optional: true },
22
+ ],
23
+ props: {
24
+ label: { values: ['right', 'bottom', 'top', 'none'], default: 'right', description: 'Where the value readout sits. right = beside the track (default, tabular so it never jumps); bottom = centred under the thumb (the reference "bottom"); top = a floating chip above the thumb (the reference "top-floating"); none = hidden (put the value in the Field label instead).' },
25
+ },
26
+ states: {
27
+ focus: { selector: ':has(:focus-visible)', description: 'Keyboard focus: the 4px brand ring around the thumb; the readout turns to the default ink.', markup: 'native :focus-visible on the input' },
28
+ active: { selector: ':has(:active)', description: 'While dragging: the thumb shows the grabbing cursor.', markup: 'native :active on the input' },
29
+ disabled: { selector: '[data-disabled], &:has(:disabled)', description: 'Whole control at 50% opacity, cursor not-allowed.', markup: 'disabled on the input' },
30
+ },
31
+ base: {
32
+ root: { position: 'relative', display: 'flex', 'align-items': 'center', gap: '{space.3}', width: '100%', 'min-width': '0', color: '{color.fg-default}' },
33
+ field: {
34
+ appearance: 'none',
35
+ '-webkit-appearance': 'none',
36
+ flex: '1 1 auto',
37
+ width: '100%',
38
+ 'min-width': '0',
39
+ height: '{space.6}',
40
+ margin: '0',
41
+ padding: '0',
42
+ background: 'transparent',
43
+ outline: 'none',
44
+ cursor: 'pointer',
45
+ },
46
+ value: { ...typeStyle('label-sm'), color: '{color.fg-muted}', 'min-width': '{space.10}', 'text-align': 'end', 'flex-shrink': '0', 'white-space': 'nowrap', 'font-variant-numeric': 'tabular-nums' },
47
+ '@states': {
48
+ focus: { value: { color: '{color.fg-default}' } },
49
+ active: { field: { cursor: 'grabbing' } },
50
+ disabled: { root: { opacity: '{opacity.disabled}', cursor: 'not-allowed' }, field: { cursor: 'not-allowed' } },
51
+ },
52
+ },
53
+ variants: {
54
+ label: {
55
+ right: { value: {} },
56
+ bottom: {
57
+ root: { 'margin-block-end': '{space.8}' },
58
+ value: { position: 'absolute', top: 'calc(100% + {space.2})', left: LABEL_LEFT, transform: 'translateX(-50%)', 'min-width': '0', ...typeStyle('body-lg'), 'font-weight': '{font.weight.medium}', color: '{color.fg-default}', 'text-align': 'center' },
59
+ },
60
+ top: {
61
+ root: { 'margin-block-start': '{space.10}' },
62
+ value: {
63
+ position: 'absolute',
64
+ bottom: 'calc(100% + {space.2})',
65
+ left: LABEL_LEFT,
66
+ transform: 'translateX(-50%)',
67
+ 'min-width': '0',
68
+ padding: '{space.1.5} {space.2}',
69
+ 'border-radius': '{radius.panel}',
70
+ 'background-color': '{color.bg-surface}',
71
+ ...typeStyle('label-xs'),
72
+ 'font-weight': '{font.weight.semibold}',
73
+ color: '{color.fg-muted}',
74
+ 'text-align': 'center',
75
+ 'box-shadow': '{shadow.lg}, inset 0 0 0 1px {color.border-default}',
76
+ },
77
+ },
78
+ none: { value: { display: 'none' } },
79
+ },
80
+ },
81
+ extraCss: `
82
+ .cn-slider { --cn-slider-fill: 0%; }
83
+ .cn-slider .cn-slider__field::-webkit-slider-runnable-track { ${TRACK} }
84
+ .cn-slider .cn-slider__field::-moz-range-track { ${TRACK} }
85
+ .cn-slider .cn-slider__field::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; margin-top: calc(({space.2} - {space.6}) / 2); ${THUMB} }
86
+ .cn-slider .cn-slider__field::-moz-range-thumb { ${THUMB} }
87
+ .cn-slider .cn-slider__field:active:not(:disabled)::-webkit-slider-thumb { cursor: grabbing; }
88
+ .cn-slider .cn-slider__field:active:not(:disabled)::-moz-range-thumb { cursor: grabbing; }
89
+ .cn-slider .cn-slider__field:focus-visible::-webkit-slider-thumb { ${THUMB_FOCUS} }
90
+ .cn-slider .cn-slider__field:focus-visible::-moz-range-thumb { ${THUMB_FOCUS} }
91
+ .cn-slider .cn-slider__field:disabled::-webkit-slider-thumb { cursor: not-allowed; }
92
+ .cn-slider .cn-slider__field:disabled::-moz-range-thumb { cursor: not-allowed; }`,
93
+ examples: [
94
+ ex('With value at the right', slider_('id="sl-score" min="0" max="100" step="1" value="40" aria-label="Minimum confidence"', '40%', '40%'), '8px gray-200 rail, brand fill, 24px white thumb with a 2px brand ring and shadow-md. Set --cn-slider-fill on the root to (value − min) ÷ (max − min).'),
95
+ ex('Label under the thumb', slider_('id="sl-bottom" min="0" max="100" step="5" value="65" aria-label="Storage quota"', '65%', '65%', 'bottom'), 'The reference "bottom" label: 16px medium, centred under the thumb, 8px below the track.'),
96
+ ex('Floating label above', slider_('id="sl-top" min="0" max="100" step="1" value="25" aria-label="Opacity"', '25%', '25%', 'top'), 'The reference "top-floating" label: a white chip with radius 8, shadow-lg and a hairline ring, 12px semibold.'),
97
+ ex('Without readout', slider_('min="0" max="10" step="1" value="7" aria-label="Notification intensity" aria-valuetext="7 of 10"', '70%', '', 'none'), 'Put the value in the Field label or the captions instead.'),
98
+ ex('Stepped (budget band)', slider_('id="sl-budget" min="5000" max="50000" step="5000" value="20000" aria-label="Monthly budget" aria-valuetext="$20,000"', '33.3%', '$20k'), 'Use step for coarse ranges; format the readout with its unit.'),
99
+ ex('Disabled', slider_('id="sl-locked" min="0" max="100" value="60" disabled aria-label="Sending rate"', '60%', '60%', 'right', 'max-width:360px', 'data-disabled'), 'Whole control at 50% opacity.'),
100
+ ],
101
+ recipes: [
102
+ ex('In a Field with range captions', `<div class="cn-field" data-layout="vertical" data-size="md" style="max-width:360px"><label class="cn-field__label" for="sl-threshold">Auto-publish confidence</label><div class="cn-field__control"><div class="cn-slider" data-label="right" style="--cn-slider-fill: 70%"><input class="cn-slider__field" id="sl-threshold" type="range" min="50" max="100" step="5" value="85" aria-describedby="sl-threshold-helper"><output class="cn-slider__value" for="sl-threshold">85%</output></div><div class="cn-text-body-sm" style="display:flex;justify-content:space-between;color:var(--cn-color-fg-subtle);margin-top:var(--cn-space-1)"><span>50% · more drafts</span><span>100% · safer</span></div></div><p class="cn-field__helper" id="sl-threshold-helper">Drafts below this confidence wait for Sofia Almeida’s approval.</p></div>`, 'Label from the Field, captions under the track in 12px, hint explains the consequence.'),
103
+ ],
104
+ rules: [
105
+ 'Set --cn-slider-fill on the root (or on the input for label="right") to (value − min) ÷ (max − min) × 100% and update it on every input event. Without it the rail shows no fill and the floating labels sit at the start.',
106
+ 'Always show the value somewhere: the readout (right, bottom or top), the Field label ("Threshold: 40") or captions. A slider without a number is a toy.',
107
+ 'Use step for coarse ranges (budget bands, percentages in fives). Never a slider for exact figures (prices, dates, quantities under 20): use Input type="number".',
108
+ 'The right readout is 14px medium, tabular, at least 40px wide so it does not jump between 9 and 10. Format with the unit ($20k, 85%).',
109
+ 'Bottom and top labels follow the thumb; leave 32px (bottom) or 40px (top) of room, which the variant reserves with a margin.',
110
+ 'Width comes from the Field: minimum 160px, ideal 240–360px, never full width on desktop.',
111
+ 'One slider per row. A two-thumb range is a separate component (RangeSlider), not two sliders side by side.',
112
+ 'Motion: none on the thumb position (it follows the pointer); only the focus ring transitions (100ms).',
113
+ ],
114
+ a11y: [
115
+ 'Keep the native <input type="range">: it exposes role slider with min, max and the current value for free.',
116
+ 'Give it an accessible name (Field label with for, or aria-label) and aria-valuetext when the number needs a unit ("$20,000", "7 of 10").',
117
+ 'Keyboard: arrows move one step, Page Up/Down ten steps, Home/End jump to the ends. Do not override these.',
118
+ 'The <output for> ties the readout to the input; update its text whenever the value changes.',
119
+ 'The 4px focus ring on the thumb must stay visible in both themes; never set outline: none without the ring.',
120
+ ],
121
+ related: ['input', 'field', 'segmented-control'],
122
+ };