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,119 @@
1
+ import { CONTROL, FOCUS_RING, RESET_BUTTON, TRANSITION_COLORS, STATE, ex, typeStyle } from "./_shared.mjs";
2
+ const outlineTheme = (icon) => ({ bg: '{color.bg-surface}', hoverBg: '{color.bg-subtle}', fg: '{color.fg-default}', border: '{color.border-control}', icon });
3
+ const inverseTheme = { bg: '{color.bg-inverse}', hoverBg: 'color-mix(in srgb, {color.bg-inverse} 88%, {color.bg-surface})', fg: '{color.fg-inverse}', border: 'transparent', icon: 'currentColor' };
4
+ const blueTheme = (step, hover) => ({ bg: `{blue.${step}}`, hoverBg: `{blue.${hover}}`, fg: '{white}', border: 'transparent', icon: 'currentColor' });
5
+ const PAINT = {
6
+ google: { brand: blueTheme('600', '700'), gray: outlineTheme('{color.fg-default}'), color: outlineTheme('{blue.500}') },
7
+ apple: { brand: inverseTheme, gray: outlineTheme('{color.fg-default}'), color: outlineTheme('{color.fg-default}') },
8
+ github: { brand: inverseTheme, gray: outlineTheme('{color.fg-default}'), color: outlineTheme('{color.fg-default}') },
9
+ facebook: { brand: blueTheme('600', '700'), gray: outlineTheme('{color.fg-default}'), color: outlineTheme('{blue.600}') },
10
+ };
11
+ const compound = Object.entries(PAINT).flatMap(([social, themes]) => Object.entries(themes).map(([theme, t]) => ({
12
+ when: { social, theme },
13
+ block: {
14
+ root: { 'background-color': t.bg, color: t.fg, 'border-color': t.border },
15
+ icon: { color: t.icon },
16
+ '@states': { hover: { root: { 'background-color': t.hoverBg } }, active: { root: { 'background-color': t.hoverBg } } },
17
+ },
18
+ })));
19
+ const LG = new Set(['lg', 'xl']);
20
+ const sizeBlock = (s) => ({
21
+ root: { height: CONTROL[s].height, 'padding-inline': CONTROL[s].px, 'font-size': CONTROL[s].font, gap: LG.has(s) ? '{space.3}' : '{space.2.5}' },
22
+ });
23
+ /** Monochrome glyphs: a G-shaped arc, a fruit outline with a stem, a circle with code brackets, a circle with an f. */
24
+ const GLYPH = {
25
+ google: '<path d="M12 12h8.5a8.5 8.5 0 1 1-2.5-6"/>',
26
+ apple: '<circle cx="12" cy="13.5" r="6.5"/><path d="M12 7c0-2.2 1.3-3.5 3.5-3.5"/>',
27
+ github: '<circle cx="12" cy="12" r="9"/><path d="M9.5 9.5L7 12l2.5 2.5M14.5 9.5L17 12l-2.5 2.5"/>',
28
+ facebook: '<circle cx="12" cy="12" r="9"/><path d="M13.5 20v-6.5h2.2l.3-2.5h-2.5V9.6c0-.8.4-1.3 1.2-1.3h1.4V6.2h-2c-2 0-3.1 1.2-3.1 3.1V11H9v2.5h2V20"/>',
29
+ };
30
+ const NAME = { google: 'Google', apple: 'Apple', github: 'GitHub', facebook: 'Facebook' };
31
+ const icon = (social) => `<svg class="cn-social-button__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${GLYPH[social]}</svg>`;
32
+ const btn = (social, theme = 'gray', size = 'md', attrs = '', verb = 'Sign in with') => `<button type="button" class="cn-social-button" data-social="${social}" data-theme="${theme}" data-size="${size}"${attrs}>${icon(social)}<span class="cn-social-button__label">${verb} ${NAME[social]}</span></button>`;
33
+ const stack = (...items) => `<div style="display:flex;flex-direction:column;gap:var(--cn-space-3);max-width:360px">${items.join('')}</div>`;
34
+ export const socialButton = {
35
+ name: 'SocialButton',
36
+ slug: 'social-button',
37
+ category: 'actions',
38
+ description: 'A sign-in button for an identity provider: the Button box with a 24px logo slot and a "Sign in with …" label. Three themes: the provider\'s brand fill, a white gray button with a monochrome logo, or a white button with the colored logo.',
39
+ usage: 'Auth screens only (sign in, sign up, connect an account). Stack the providers you support in a fixed order under the email form, all in the same theme and size. Never use it for sharing links or as a generic icon button.',
40
+ anatomy: [
41
+ { part: 'root', element: 'button', description: 'The button (or an <a> when the flow is a redirect). Full width in auth forms. Carries data-social, data-theme, data-size.' },
42
+ { part: 'icon', element: 'svg', description: '24px logo slot. Monochrome (currentColor) in brand and gray themes, the provider color in the color theme. aria-hidden.' },
43
+ { part: 'label', element: 'span', description: '"Sign in with Google" / "Continue with Apple": verb + provider name, sentence case.' },
44
+ ],
45
+ props: {
46
+ social: { values: ['google', 'apple', 'github', 'facebook'], default: 'google', description: 'The identity provider. It selects the brand fill (brand theme) and the logo color (color theme); the logo itself comes from the markup.' },
47
+ theme: { values: ['brand', 'gray', 'color'], default: 'gray', description: 'gray = white, hairline, monochrome logo: the default that sits quietly under an email form; brand = the provider\'s fill with a white logo, for a single prominent provider; color = white with the provider-colored logo, for a row of several providers on marketing-style auth pages.' },
48
+ size: { values: ['md', 'lg', 'xl'], default: 'md', description: 'Button heights md / lg / xl (40 / 44 / 48px in the reference scale). Match the size of the email form\'s submit button.' },
49
+ },
50
+ states: {
51
+ hover: STATE.hover(),
52
+ active: STATE.active(),
53
+ focus: STATE.focus(),
54
+ disabled: STATE.disabled(),
55
+ },
56
+ base: {
57
+ root: {
58
+ ...RESET_BUTTON,
59
+ display: 'inline-flex',
60
+ 'align-items': 'center',
61
+ 'justify-content': 'center',
62
+ gap: '{space.2.5}',
63
+ 'flex-shrink': '0',
64
+ 'white-space': 'nowrap',
65
+ ...typeStyle('label-md'),
66
+ 'font-size': CONTROL.md.font,
67
+ height: CONTROL.md.height,
68
+ 'padding-inline': CONTROL.md.px,
69
+ 'border-radius': '{radius.control}',
70
+ border: '{border.width.thin} solid transparent',
71
+ 'box-shadow': '{shadow.xs}',
72
+ ...TRANSITION_COLORS,
73
+ },
74
+ icon: { display: 'block', 'flex-shrink': '0', width: '{size.icon.xl}', height: '{size.icon.xl}', 'pointer-events': 'none', transition: 'inherit' },
75
+ label: { display: 'inline-block', 'padding-inline': '{space.0.5}' },
76
+ '@states': {
77
+ focus: { root: { ...FOCUS_RING, 'box-shadow': '{shadow.xs}, {shadow.focus}' } },
78
+ disabled: { root: { opacity: '{opacity.disabled}', cursor: 'not-allowed' } },
79
+ },
80
+ },
81
+ variants: {
82
+ social: { google: { root: {} }, apple: { root: {} }, github: { root: {} }, facebook: { root: {} } },
83
+ theme: {
84
+ brand: { root: {} },
85
+ gray: { root: {} },
86
+ color: { root: {} },
87
+ },
88
+ size: { md: sizeBlock('md'), lg: sizeBlock('lg'), xl: sizeBlock('xl') },
89
+ },
90
+ compound,
91
+ extraCss: `
92
+ .cn-social-button[data-full] { width: 100%; }`,
93
+ examples: [
94
+ ex('Gray (default)', stack(btn('google'), btn('apple'), btn('github'), btn('facebook')), 'White with a hairline and a monochrome logo; the quiet default under an email form.'),
95
+ ex('Brand', stack(btn('google', 'brand'), btn('apple', 'brand'), btn('github', 'brand'), btn('facebook', 'brand')), 'The provider fill with a white logo, for one prominent provider.'),
96
+ ex('Color', stack(btn('google', 'color'), btn('apple', 'color'), btn('github', 'color'), btn('facebook', 'color')), 'White with the provider-colored logo, for a row of several providers.'),
97
+ ex('Sizes', stack(btn('google', 'gray', 'md', '', 'Continue with'), btn('google', 'gray', 'lg', '', 'Continue with'), btn('google', 'gray', 'xl', '', 'Continue with')), 'md / lg / xl follow the control heights; text grows to 16px on lg and xl.'),
98
+ ex('Full width and disabled', stack(btn('github', 'gray', 'md', ' data-full'), btn('google', 'gray', 'md', ' data-full disabled')), 'data-full stretches the button to the form width; disabled keeps the layout and dims it.'),
99
+ ],
100
+ recipes: [
101
+ ex('Under an email form', `<div style="display:flex;flex-direction:column;gap:var(--cn-space-3);max-width:360px"><button type="submit" class="cn-button" data-variant="primary" data-size="md" style="width:100%"><span class="cn-button__label">Sign in</span></button><div style="display:flex;align-items:center;gap:var(--cn-space-3);color:var(--cn-color-fg-subtle)" class="cn-text-body-xs"><span style="flex:1;height:1px;background:var(--cn-color-border-default)"></span>or<span style="flex:1;height:1px;background:var(--cn-color-border-default)"></span></div>${btn('google', 'gray', 'md', ' data-full')}${btn('apple', 'gray', 'md', ' data-full')}</div>`, 'Primary submit first, an "or" divider, then the providers in gray at the same size.'),
102
+ ],
103
+ rules: [
104
+ 'Label = verb + provider: "Sign in with Google" on sign-in screens, "Continue with Google" when the same button signs up. Never the provider name alone, never "Login".',
105
+ 'One theme and one size per screen. gray under an email form; color when providers stand alone in a row; brand only for a single provider that is the main way in.',
106
+ 'Order providers by usage in your audience and keep that order on every screen; put the email form above them, never below.',
107
+ 'Full width (data-full) inside auth forms; intrinsic width only in a horizontal row of icon-heavy color buttons.',
108
+ 'The logo slot is 24px; use the provider\'s official monochrome or color asset in production and respect its clear-space rules. The example glyphs are placeholders.',
109
+ 'Never add a trailing arrow or a second icon; the logo is the only icon.',
110
+ 'While the provider popup is open set aria-busy="true" and disable the other providers to avoid two flows at once.',
111
+ ],
112
+ a11y: [
113
+ 'It is a <button type="button"> (or an <a href> for redirect flows) with the visible label as its accessible name; the logo is aria-hidden.',
114
+ 'Focus ring on :focus-visible only, stacked over the resting shadow so the ring is visible on filled brand buttons too.',
115
+ 'Disabled providers stay visible and readable (opacity only); explain why when it matters ("Apple sign-in is not available in this region").',
116
+ 'Contrast: brand fills use the 600 step with white text; gray and color themes use default ink on the surface.',
117
+ ],
118
+ related: ['button', 'app-store-button', 'divider', 'input'],
119
+ };
@@ -0,0 +1,69 @@
1
+ import { ex } from "./_shared.mjs";
2
+ const SIZE_ICON = { xs: '{size.icon.xs}', sm: '{size.icon.sm}', md: '{size.icon.md}', lg: '{size.icon.lg}', xl: '{size.icon.xl}' };
3
+ const TONE_COLOR = { current: 'currentColor', muted: '{color.fg-subtle}', action: '{color.fg-action}', 'on-action': '{color.fg-on-action}' };
4
+ export const spinner = {
5
+ name: 'Spinner',
6
+ slug: 'spinner',
7
+ category: 'feedback',
8
+ description: 'Small rotating ring for a wait without a known duration. A 2px current-color circle with one transparent quarter, spinning at 0.7s; it inherits the text color so it fits inside buttons, rows and empty states.',
9
+ usage: 'Use next to a short sentence while something loads and no layout is known yet ("Searching 212 companies…"), or inside a control that is busy. For loading content whose shape is known use Skeleton; for a measurable task use Progress. Never use more than one spinner in view.',
10
+ anatomy: [
11
+ { part: 'root', element: 'span', description: 'The ring. role="status"; contains a visually hidden label (.cn-sr-only) so screen readers announce what is happening.' },
12
+ ],
13
+ props: {
14
+ size: {
15
+ values: ['xs', 'sm', 'md', 'lg', 'xl'],
16
+ default: 'md',
17
+ description: 'Diameter from the icon scale: xs 12px (inside badges), sm 14px (inside sm buttons and table rows), md 16px (default, inline with body text), lg 20px (empty states), xl 24px (page-level loading).',
18
+ },
19
+ tone: {
20
+ values: ['current', 'muted', 'action', 'on-action'],
21
+ default: 'current',
22
+ description: 'current = inherits the text color (default, inside buttons and rows); muted = subtle grey for quiet loading; action = the action color for a primary wait; on-action = the text color used on filled action surfaces.',
23
+ },
24
+ },
25
+ states: {},
26
+ base: {
27
+ root: {
28
+ display: 'inline-block',
29
+ 'flex-shrink': '0',
30
+ width: '{size.icon.md}',
31
+ height: '{size.icon.md}',
32
+ 'border-radius': '{radius.full}',
33
+ border: '{border.width.medium} solid currentColor',
34
+ 'border-right-color': 'transparent',
35
+ color: 'currentColor',
36
+ 'vertical-align': 'middle',
37
+ animation: 'cn-spinner-spin 0.7s linear infinite',
38
+ },
39
+ },
40
+ variants: {
41
+ size: Object.fromEntries(Object.entries(SIZE_ICON).map(([s, v]) => [s, { root: { width: v, height: v, ...(s === 'xs' || s === 'sm' ? { 'border-width': '1.5px' } : {}) } }])),
42
+ tone: Object.fromEntries(Object.entries(TONE_COLOR).map(([t, c]) => [t, { root: { color: c } }])),
43
+ },
44
+ extraCss: `
45
+ @keyframes cn-spinner-spin { to { transform: rotate(360deg); } }`,
46
+ examples: [
47
+ ex('Default (md, current color)', `<span class="cn-spinner" data-size="md" data-tone="current" role="status"><span class="cn-sr-only">Loading</span></span>`),
48
+ ex('Sizes', `<span class="cn-spinner" data-size="xs" data-tone="current" role="status"><span class="cn-sr-only">Loading</span></span> <span class="cn-spinner" data-size="sm" data-tone="current" role="status"><span class="cn-sr-only">Loading</span></span> <span class="cn-spinner" data-size="md" data-tone="current" role="status"><span class="cn-sr-only">Loading</span></span> <span class="cn-spinner" data-size="lg" data-tone="current" role="status"><span class="cn-sr-only">Loading</span></span> <span class="cn-spinner" data-size="xl" data-tone="current" role="status"><span class="cn-sr-only">Loading</span></span>`),
49
+ ex('Muted with a sentence', `<div style="display:inline-flex;align-items:center;gap:var(--cn-space-2);color:var(--cn-color-fg-muted)"><span class="cn-spinner" data-size="sm" data-tone="muted" role="status"><span class="cn-sr-only">Loading</span></span><span class="cn-text-body-sm">Searching 212 companies in Japan…</span></div>`),
50
+ ex('Action tone', `<span class="cn-spinner" data-size="lg" data-tone="action" role="status"><span class="cn-sr-only">Verifying</span></span>`),
51
+ ex('On a filled action surface', `<span style="display:inline-flex;align-items:center;gap:var(--cn-space-2);padding:var(--cn-space-2) var(--cn-space-3);border-radius:var(--cn-radius-md);background:var(--cn-color-bg-action);color:var(--cn-color-fg-on-action)"><span class="cn-spinner" data-size="sm" data-tone="on-action" role="status"><span class="cn-sr-only">Sending</span></span><span class="cn-text-label-md">Sending…</span></span>`, 'Inside a real Button use data-loading instead; the Button draws its own spinner.'),
52
+ ],
53
+ rules: [
54
+ 'One spinner in view at a time. Several loading areas share one spinner at the top of the region, or use Skeleton.',
55
+ 'Pair it with a sentence that names what is happening ("Searching 212 companies…"); a lone spinner is only acceptable inside a control.',
56
+ 'Do not show it for waits under 300ms; flashing spinners feel slower than nothing.',
57
+ 'Inside a Button use data-loading; the Button already has its own spinner sized to the control.',
58
+ 'Size follows the text it sits next to: sm with body-sm, md with body-md, lg/xl only in empty states and page-level loading.',
59
+ 'Tone current by default; muted for background loading; action only for a primary wait the user asked for.',
60
+ 'Never place a spinner inside a Toast or a Badge.',
61
+ ],
62
+ a11y: [
63
+ 'role="status" on the ring with a visually hidden text (.cn-sr-only) that says what is loading.',
64
+ 'When the wait ends, update the same live region with the result ("38 companies found") so the change is announced.',
65
+ 'The animation stops under prefers-reduced-motion (base.css); the static ring remains visible.',
66
+ 'Do not put the spinner inside aria-hidden containers when it is the only feedback.',
67
+ ],
68
+ related: ['progress', 'skeleton', 'button'],
69
+ };
@@ -0,0 +1,120 @@
1
+ import { ex, ICON, typeStyle } from "./_shared.mjs";
2
+ // A measured readout: a mono label, a clear value and a separate change indicator.
3
+ const HAIRLINE = '{border.width.thin} solid {color.border-default}';
4
+ const ring = (color) => `inset 0 0 0 1px ${color}`;
5
+ const ARROW = {
6
+ up: '<svg class="cn-stat__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 13V3M4 7l4-4 4 4"/></svg>',
7
+ down: '<svg class="cn-stat__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3v10M4 9l4 4 4-4"/></svg>',
8
+ flat: '<svg class="cn-stat__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M3 8h10"/></svg>',
9
+ };
10
+ const tile = (o) => {
11
+ const trend = o.trend ?? 'flat';
12
+ return `<div class="cn-stat" data-size="${o.size ?? 'md'}" data-variant="${o.variant ?? 'card'}" data-trend="${trend}"${o.sentiment ? ` data-sentiment="${o.sentiment}"` : ''}${o.style ? ` style="${o.style}"` : ''}>${o.media ? `<div class="cn-stat__media">${o.media}</div>` : ''}<div class="cn-stat__label">${o.label}</div><div class="cn-stat__value">${o.value}</div>${o.delta ? `<div class="cn-stat__delta"${o.badge ? ' data-badge' : ''}>${ARROW[trend]}<span>${o.delta}</span></div>` : ''}${o.description ? `<div class="cn-stat__description">${o.description}</div>` : ''}${o.actions ? `<div class="cn-stat__actions">${o.actions}</div>` : ''}</div>`;
13
+ };
14
+ const FEATURED = (i) => `<span class="cn-featured-icon" data-theme="modern" data-tone="gray" data-size="md" data-shape="square">${ICON[i].replace('cn-icon', 'cn-featured-icon__icon')}</span>`;
15
+ const VIEW = `<button type="button" class="cn-button" data-variant="link-color" data-size="sm"><span class="cn-button__label">View report</span>${ICON.arrow.replace('cn-icon', 'cn-button__icon')}</button>`;
16
+ export const stat = {
17
+ name: 'Stat',
18
+ slug: 'stat',
19
+ category: 'data-display',
20
+ description: 'A metric readout with a compact mono label and one semibold tabular number, a change indicator with an arrow (green up, red down) or as a pill, a "vs last month" note, optionally a featured icon above and a "View report" footer. Direction is a fact; whether it is good is a separate attribute.',
21
+ usage: 'Dashboards and page headers that summarize a workspace ("Active users 2,420", "Conversion 4.2%"). Rows of 3–4 tiles. Not for counts inside navigation (Counter) or for a value inside a table (plain tabular text).',
22
+ anatomy: [
23
+ { part: 'root', element: 'div', description: 'Column of media, label, value, delta + description, actions. Plain, or framed as the metric card (white, gray-200 ring, flat edge and token-defined corners, 20 × 24px padding).' },
24
+ { part: 'media', element: 'div', description: 'Optional 40px FeaturedIcon (modern, gray) above the label, 16px before it.', optional: true },
25
+ { part: 'label', element: 'div', description: 'What is measured, 14px medium fg-muted. 1–3 words ("Active users").' },
26
+ { part: 'value', element: 'div', description: 'The number, semibold, tabular figures: 24 / 30 / 36px by size. Include the unit in the string ("41%", "2.1h", "$412k").' },
27
+ { part: 'delta', element: 'div', description: 'Optional change vs the previous period: 16px arrow + 14px medium text ("12%"). Colored by trend and sentiment; data-badge renders it as a tinted pill.', optional: true },
28
+ { part: 'icon', element: 'svg', description: '16px arrow inside the delta: up, down or flat, matching data-trend.', optional: true },
29
+ { part: 'description', element: 'div', description: 'Optional 14px fg-muted note after the delta: period, source or definition ("vs last month").', optional: true },
30
+ { part: 'actions', element: 'div', description: 'Optional footer on a hairline (bleeds to the card edges, 16 × 24px padding) holding a link-color Button ("View report").', optional: true },
31
+ ],
32
+ props: {
33
+ size: {
34
+ values: ['sm', 'md', 'lg'],
35
+ default: 'md',
36
+ description: 'Size of the value: sm = 24/32 semibold (inside cards and drawers); md = 30/38 (the default readout, dashboard rows); lg = 36/44 (one hero number per page).',
37
+ },
38
+ variant: {
39
+ values: ['plain', 'card'],
40
+ default: 'card',
41
+ description: 'card = the metric card: white, gray-200 ring, flat edge and token-defined corners, 20 × 24px padding (standalone tiles on the canvas); plain = no frame, for stats inside a Card, a header or next to a chart.',
42
+ },
43
+ trend: {
44
+ values: ['up', 'down', 'flat'],
45
+ default: 'flat',
46
+ description: 'Direction of the delta: up = increased (green), down = decreased (red), flat = unchanged (muted). Direction only; add data-sentiment="good|bad" when up is bad or down is good.',
47
+ },
48
+ },
49
+ states: {},
50
+ base: {
51
+ // A wrapping row: media, label, value and actions span the full width; delta and description share one line.
52
+ root: { display: 'flex', 'flex-wrap': 'wrap', 'align-items': 'center', gap: '{space.2}', 'min-width': '0', color: '{color.fg-default}' },
53
+ media: { display: 'flex', width: '100%', 'margin-bottom': '{space.2}' },
54
+ label: { width: '100%', ...typeStyle('kicker'), color: '{color.fg-muted}' },
55
+ value: {
56
+ width: '100%',
57
+ ...typeStyle('heading-lg'),
58
+ 'font-variant-numeric': 'tabular-nums',
59
+ color: '{color.fg-default}',
60
+ 'overflow-wrap': 'anywhere',
61
+ },
62
+ delta: { display: 'inline-flex', 'align-items': 'center', gap: '{space.1}', ...typeStyle('label-sm'), color: '{color.fg-muted}', 'font-variant-numeric': 'tabular-nums' },
63
+ icon: { width: '{size.icon.sm}', height: '{size.icon.sm}', 'flex-shrink': '0' },
64
+ description: { ...typeStyle('body-md'), color: '{color.fg-muted}' },
65
+ actions: { display: 'flex', width: '100%', 'align-items': 'center', 'justify-content': 'flex-end', gap: '{space.3}', 'margin-top': '{space.2}', 'padding-top': '{space.4}', 'border-top': HAIRLINE },
66
+ },
67
+ variants: {
68
+ size: {
69
+ sm: { root: { gap: '{space.1.5}' }, value: { ...typeStyle('heading-md') }, delta: { ...typeStyle('label-xs') } },
70
+ md: { value: { ...typeStyle('heading-lg') } },
71
+ lg: { root: { gap: '{space.3}' }, value: { ...typeStyle('heading-xl') }, delta: { ...typeStyle('label-md') }, description: { ...typeStyle('body-lg') } },
72
+ },
73
+ variant: {
74
+ plain: { root: {} },
75
+ card: { root: { 'background-color': '{color.bg-surface}', 'box-shadow': `${ring('{color.border-default}')}, {shadow.xs}`, 'border-radius': '{radius.card}', padding: '{space.5} {space.6}' }, actions: { width: 'calc(100% + {space.6} * 2)', margin: '{space.4} calc({space.6} * -1) calc({space.5} * -1)', padding: '{space.4} {space.6}' } },
76
+ },
77
+ trend: {
78
+ up: { delta: { color: '{color.fg-success}' } },
79
+ down: { delta: { color: '{color.fg-danger}' } },
80
+ flat: { delta: { color: '{color.fg-muted}' } },
81
+ },
82
+ },
83
+ compound: [
84
+ { when: { variant: 'card', size: 'sm' }, block: { root: { padding: '{space.4} {space.5}' }, actions: { width: 'calc(100% + {space.5} * 2)', margin: '{space.3} calc({space.5} * -1) calc({space.4} * -1)', padding: '{space.3} {space.5}' } } },
85
+ { when: { variant: 'card', size: 'lg' }, block: { root: { padding: '{space.6}' }, actions: { margin: '{space.4} calc({space.6} * -1) calc({space.6} * -1)' } } },
86
+ ],
87
+ extraCss: `
88
+ .cn-stat__delta[data-badge] { height: {space.6}; padding: 0 {space.2.5} 0 {space.2}; border-radius: {radius.full}; background-color: {color.bg-subtle}; box-shadow: ${ring('{color.border-default}')}; }
89
+ .cn-stat[data-trend="up"] .cn-stat__delta[data-badge] { background-color: {green.50}; box-shadow: ${ring('{green.200}')}; color: {green.700}; }
90
+ .cn-stat[data-trend="down"] .cn-stat__delta[data-badge] { background-color: {red.50}; box-shadow: ${ring('{red.200}')}; color: {red.700}; }
91
+ .cn-stat[data-sentiment="good"] .cn-stat__delta { color: {color.fg-success}; }
92
+ .cn-stat[data-sentiment="bad"] .cn-stat__delta { color: {color.fg-danger}; }
93
+ .cn-stat[data-sentiment="good"] .cn-stat__delta[data-badge] { background-color: {green.50}; box-shadow: ${ring('{green.200}')}; color: {green.700}; }
94
+ .cn-stat[data-sentiment="bad"] .cn-stat__delta[data-badge] { background-color: {red.50}; box-shadow: ${ring('{red.200}')}; color: {red.700}; }
95
+ .cn-stat[data-sentiment="neutral"] .cn-stat__delta { color: {color.fg-muted}; }`,
96
+ examples: [
97
+ ex('Metric card (default)', tile({ label: 'Active users', value: '2,420', delta: '12%', trend: 'up', description: 'vs last month', style: 'width:280px' }), 'White card with a fine outline, 20 × 24px padding; 14px medium label, a token-sized semibold value, green arrow + "12%", muted "vs last month".'),
98
+ ex('Row of three', `<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:var(--cn-space-6);width:100%">${tile({ label: 'Total users', value: '2,420', delta: '40%', trend: 'up', description: 'vs last month' })}${tile({ label: 'Active sessions', value: '1,210', delta: '10%', trend: 'down', description: 'vs last month' })}${tile({ label: 'Conversion rate', value: '4.2%', delta: '0.8 pts', trend: 'up', description: 'vs last month' })}</div>`, 'One row, equal widths, same size. The labels line up.'),
99
+ ex('With icon and actions', `<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:var(--cn-space-6);width:100%;max-width:560px">${tile({ label: 'Views 24h', value: '18,204', delta: '6%', trend: 'up', description: 'vs yesterday', media: FEATURED('user'), actions: VIEW })}${tile({ label: 'Revenue', value: '$32,480', delta: '2%', trend: 'down', description: 'vs last month', media: FEATURED('inbox'), actions: VIEW, badge: true })}</div>`, 'A 40px modern FeaturedIcon above the label; the delta as a tinted pill (data-badge); "View report" in a footer on a hairline.'),
100
+ ex('Sizes (plain)', `<div style="display:flex;gap:var(--cn-space-10);align-items:flex-end;flex-wrap:wrap">${tile({ label: 'Conversion', value: '4.2%', delta: '0.8 pts', trend: 'up', size: 'sm', variant: 'plain' })}${tile({ label: 'Conversion', value: '4.2%', delta: '0.8 pts', trend: 'up', size: 'md', variant: 'plain' })}${tile({ label: 'Conversion', value: '4.2%', delta: '0.8 pts', trend: 'up', size: 'lg', variant: 'plain', description: 'vs last month' })}</div>`, 'sm 24px · md 30px · lg 36px values.'),
101
+ ex('Direction is not sentiment', `<div style="display:flex;gap:var(--cn-space-6);flex-wrap:wrap">${tile({ label: 'Avg. response time', value: '2.1h', delta: '18%', trend: 'down', sentiment: 'good', description: 'vs last month', style: 'width:260px' })}${tile({ label: 'Bounce rate', value: '4.2%', delta: '0.8 pts', trend: 'up', sentiment: 'bad', description: 'vs last month', style: 'width:260px' })}</div>`, 'The arrow says which way it moved; data-sentiment says whether that is good.'),
102
+ ],
103
+ rules: [
104
+ 'Label is 14px medium muted, 1–3 words, names the metric ("Active users"), never the value type ("Count").',
105
+ 'The value carries its unit in the string ("41%", "2.1h", "$412k") and uses tabular figures; format with the locale, max 5 significant characters plus unit.',
106
+ 'data-trend is the direction of change (the arrow), not a judgement. When up is bad (bounce rate, response time) or down is good, add data-sentiment="good|bad" so the color tells the truth.',
107
+ 'The delta is the change ("12%", "0.8 pts") and the description names the period ("vs last month"); no delta at all beats a delta without a period.',
108
+ 'Rows of stats: 3–4 tiles, equal width, same size and variant, 24px apart, labels aligned on one baseline. Never a grid of nine tiles.',
109
+ 'One lg (hero) stat per page at most; md in dashboard rows; sm inside cards, drawers and comparisons.',
110
+ 'Use variant="card" on the canvas; plain inside a Card, a page header or next to a chart (ChartFrame).',
111
+ 'The only action is the "View report" link in the footer; no buttons inside the tile. If the whole tile opens a report, wrap it in a Link.',
112
+ ],
113
+ a11y: [
114
+ 'Reading order is label, value, delta, description; the DOM must match so screen readers announce "Active users, 2,420, up 12% vs last month".',
115
+ 'The arrow is decorative (aria-hidden); the delta text must state the direction in words or sign ("+12%", "−18%"), never rely on the arrow or color.',
116
+ 'Use a real heading only when the stat is a section of its own; inside a row of tiles keep them as divs so the page outline stays clean.',
117
+ 'Ensure the value text is selectable and not an image; no SVG numbers.',
118
+ ],
119
+ related: ['counter', 'chart-frame', 'card', 'featured-icon', 'badge', 'button'],
120
+ };
@@ -0,0 +1,228 @@
1
+ import { ex, ICON, typeStyle } from "./_shared.mjs";
2
+ // the reference progress steps: 24px (sm) or 32px (md) step icons — a number in a ringed circle,
3
+ // a gray-50 disc with a gray-300 dot that turns brand-600 with a white dot (current, 4px brand-100
4
+ // halo) or a white check (complete) — 8 / 10px minimal dots, or a 4px bar over the text; 2px
5
+ // connectors, 14px semibold titles (brand-700 when current) with 14px supporting text.
6
+ const TRANSITION_FAST = {
7
+ 'transition-property': 'background-color, border-color, color, box-shadow',
8
+ 'transition-duration': '{motion.duration.fast}',
9
+ 'transition-timing-function': '{motion.easing.linear}',
10
+ };
11
+ const CHECK = ICON.check.replace('cn-icon', 'cn-stepper__check');
12
+ const step = (n, state, label, description = '', variant = 'number') => {
13
+ const inner = variant === 'number' ? `${n}` : variant === 'icon' ? '<span class="cn-stepper__dot"></span>' : '';
14
+ const indicator = state === 'complete' && variant !== 'line' && variant !== 'dot' ? `${CHECK}<span class="cn-sr-only">Completed:</span>` : state === 'error' && variant === 'number' ? `${n}<span class="cn-sr-only">, needs attention:</span>` : inner;
15
+ const attrs = state === 'current' ? ' data-state="current" aria-current="step"' : state === 'upcoming' ? '' : ` data-state="${state}"`;
16
+ return `<li class="cn-stepper__step"${attrs}><span class="cn-stepper__indicator" aria-hidden="${state === 'complete' || state === 'error' ? 'false' : 'true'}">${indicator}</span>${label ? `<span class="cn-stepper__content"><span class="cn-stepper__label">${label}</span>${description ? `<span class="cn-stepper__description">${description}</span>` : ''}</span>` : ''}<span class="cn-stepper__connector" aria-hidden="true"></span></li>`;
17
+ };
18
+ const build = (variant, orientation, size, label, steps) => `<ol class="cn-stepper" data-variant="${variant}" data-orientation="${orientation}" data-size="${size}" aria-label="${label}">${steps}</ol>`;
19
+ const SETUP = [['complete', 'Your details', 'Name and email'], ['current', 'Company details', 'Website and location'], ['upcoming', 'Invite your team', 'Start collaborating'], ['upcoming', 'Add your socials', 'Share posts to your accounts']];
20
+ const steps = (variant, withDesc = true, list = SETUP) => list.map(([s, l, d], i) => step(i + 1, s, l, withDesc ? d : '', variant)).join('');
21
+ export const stepper = {
22
+ name: 'Stepper',
23
+ slug: 'stepper',
24
+ category: 'navigation',
25
+ description: 'Progress through a sequence in the reference\'s four marker styles: numbered circles, step icons (disc with a dot, brand when current, check when complete), minimal dots, or a 4px bar over the text; 24 / 32px, joined by 2px connectors, horizontal or vertical, with a semibold title and supporting text per step.',
26
+ usage: 'Use above a multi-step form or wizard (onboarding, import, checkout) with 3–6 steps that happen in order, or as a vertical rail beside a long form. Not for switching between peer views (Tabs), not for percentage progress (Progress) and not for a timeline of past events (Timeline).',
27
+ anatomy: [
28
+ { part: 'root', element: 'ol', description: 'Ordered list of steps with aria-label. Flex row (horizontal) or column (vertical), 16px gaps.' },
29
+ { part: 'step', element: 'li', description: 'One step: indicator + content + connector. data-state="complete|current|error" (omit for upcoming); aria-current="step" on the current one.' },
30
+ { part: 'indicator', element: 'span', description: 'The marker: a 24 / 32px circle (number or icon), an 8 / 10px dot, or a 4px bar. Upcoming: gray-50 with a gray-300 ring or dot; current: brand-600 with a 4px brand-100 halo; complete: brand-600 with a white check; error: red.' },
31
+ { part: 'dot', element: 'span', description: 'The inner dot of the icon variant: 8 / 10px, gray-300 when upcoming, white when current.', optional: true },
32
+ { part: 'check', element: 'svg', description: '12 / 16px white check mark inside a complete indicator (number and icon variants).', optional: true },
33
+ { part: 'content', element: 'span', description: 'Column holding label and description, 2px apart, aligned to the indicator\'s center (under the bar in the line variant).' },
34
+ { part: 'label', element: 'span', description: 'Step title, 14px semibold: 1–3 words ("Company details"). Gray-700 when upcoming or complete, brand-700 when current.' },
35
+ { part: 'description', element: 'span', description: 'Optional 14px supporting text under the title, fg-muted: "Website and location". Carries the message in the error state.', optional: true },
36
+ { part: 'connector', element: 'span', description: '2px gray-200 line from this step to the next; fills the gap horizontally or runs down the indicator\'s axis vertically. Hidden on the last step and in the line variant; brand-600 after a complete step.' },
37
+ ],
38
+ props: {
39
+ variant: {
40
+ values: ['number', 'icon', 'dot', 'line'],
41
+ default: 'number',
42
+ description: 'number = the step number in a ringed circle (forms with many steps). icon = the reference step icon: a gray-50 disc with a gray-300 dot that turns brand with a white dot when current and a check when complete (onboarding, wizards). dot = minimal 8 / 10px dots joined by connectors, labels optional (compact progress under a dialog title). line = a 4px bar above each step\'s text, no connectors (the reference "text with line").',
43
+ },
44
+ orientation: {
45
+ values: ['horizontal', 'vertical'],
46
+ default: 'horizontal',
47
+ description: 'horizontal = a row across the top of a wizard, steps share the width and connectors fill the gaps; up to 5 steps. vertical = a column in a side rail or next to a long form (the reference 400px column); the connector runs down from each indicator, so descriptions can be longer.',
48
+ },
49
+ size: {
50
+ values: ['sm', 'md'],
51
+ default: 'md',
52
+ description: 'md = 32px circles (10px dots, 16px check), 14px titles; the default above a page-level form. sm = 24px circles (8px dots, 12px check), 14px titles; inside dialogs, drawers and cards.',
53
+ },
54
+ },
55
+ states: {
56
+ complete: { selector: ' .cn-stepper__step[data-state="complete"]', description: 'Done (on the STEP, not the root): indicator filled brand-600 with a white check (bar or dot turns brand); the connector after it turns brand-600; title stays gray-700.', markup: 'data-state="complete" on the li; put the check svg (and sr-only "Completed:") inside the indicator instead of the number' },
57
+ current: { selector: ' .cn-stepper__step[data-state="current"], & .cn-stepper__step[aria-current="step"]', description: 'The step the user is on: brand-600 indicator with a white dot (number variant: 2px brand ring and brand number) and a 4px brand-100 halo; title in brand-700. Exactly one.', markup: 'data-state="current" and aria-current="step" on the li' },
58
+ error: { selector: ' .cn-stepper__step[data-state="error"]', description: 'A visited step that failed validation: red-500 ring on a red-50 disc, red title; the description says what to fix.', markup: 'data-state="error" on the li, with the problem in the description' },
59
+ upcoming: { selector: ' .cn-stepper__step:not([data-state])', description: 'Not reached yet: gray-50 disc with a gray-300 ring or dot, gray-700 title. The default; no attribute needed.', markup: 'no data-state' },
60
+ },
61
+ base: {
62
+ root: {
63
+ display: 'flex',
64
+ 'align-items': 'flex-start',
65
+ gap: '{space.4}',
66
+ width: '100%',
67
+ margin: '0',
68
+ padding: '0',
69
+ 'list-style': 'none',
70
+ },
71
+ step: {
72
+ position: 'relative',
73
+ display: 'flex',
74
+ 'align-items': 'flex-start',
75
+ gap: '{space.3}',
76
+ flex: '1 1 0',
77
+ 'min-width': '0',
78
+ },
79
+ indicator: {
80
+ position: 'relative',
81
+ display: 'inline-flex',
82
+ 'align-items': 'center',
83
+ 'justify-content': 'center',
84
+ 'flex-shrink': '0',
85
+ width: '{space.8}',
86
+ height: '{space.8}',
87
+ 'border-radius': '{radius.full}',
88
+ 'background-color': '{color.bg-subtle}',
89
+ 'box-shadow': 'inset 0 0 0 1px {color.border-default}',
90
+ color: '{color.fg-muted}',
91
+ ...typeStyle('label-sm'),
92
+ 'font-variant-numeric': 'tabular-nums',
93
+ ...TRANSITION_FAST,
94
+ },
95
+ dot: { display: 'block', width: '{space.2.5}', height: '{space.2.5}', 'border-radius': '{radius.full}', 'background-color': '{color.border-control}', transition: 'inherit' },
96
+ check: { width: '{size.icon.sm}', height: '{size.icon.sm}', display: 'block', 'stroke-width': '2.5' },
97
+ content: {
98
+ display: 'flex',
99
+ 'flex-direction': 'column',
100
+ gap: '{space.0.5}',
101
+ 'min-width': '0',
102
+ 'padding-top': '{space.1.5}',
103
+ },
104
+ label: {
105
+ ...typeStyle('label-md'),
106
+ color: '{color.fg-muted}',
107
+ 'white-space': 'nowrap',
108
+ overflow: 'hidden',
109
+ 'text-overflow': 'ellipsis',
110
+ transition: 'inherit',
111
+ },
112
+ description: { ...typeStyle('body-md'), color: '{color.fg-muted}' },
113
+ connector: {
114
+ flex: '1 1 auto',
115
+ height: '{border.width.medium}',
116
+ 'min-width': '{space.4}',
117
+ 'margin-top': 'calc({space.8} / 2 - {border.width.thin})',
118
+ 'border-radius': '{radius.full}',
119
+ 'background-color': '{color.border-default}',
120
+ },
121
+ },
122
+ variants: {
123
+ variant: {
124
+ number: { root: {} },
125
+ icon: { root: {} },
126
+ dot: {
127
+ indicator: { width: '{space.2.5}', height: '{space.2.5}', 'background-color': '{color.border-default}', 'box-shadow': 'none', 'margin-top': 'calc({space.2.5} / 2)' },
128
+ content: { 'padding-top': '0' },
129
+ connector: { 'margin-top': 'calc({space.2.5} - {border.width.thin})' },
130
+ },
131
+ line: {
132
+ step: { 'flex-direction': 'column', gap: '{space.3}' },
133
+ indicator: { width: '100%', height: '{space.1}', 'border-radius': '{radius.full}', 'background-color': '{color.bg-muted}', 'box-shadow': 'none' },
134
+ content: { 'padding-top': '0' },
135
+ connector: { display: 'none' },
136
+ },
137
+ },
138
+ orientation: {
139
+ horizontal: { root: { 'flex-direction': 'row' } },
140
+ vertical: {
141
+ root: { 'flex-direction': 'column', gap: '0', width: 'auto', 'max-width': '{size.container.xs}' },
142
+ step: { flex: '0 0 auto', 'padding-bottom': '{space.6}' },
143
+ connector: {
144
+ position: 'absolute',
145
+ 'inset-inline-start': 'calc({space.8} / 2 - {border.width.thin})',
146
+ top: 'calc({space.8} + {space.1})',
147
+ bottom: '{space.1}',
148
+ width: '{border.width.medium}',
149
+ height: 'auto',
150
+ flex: 'none',
151
+ 'min-width': '0',
152
+ 'margin-top': '0',
153
+ },
154
+ label: { 'white-space': 'normal' },
155
+ },
156
+ },
157
+ size: {
158
+ sm: {
159
+ indicator: { width: '{space.6}', height: '{space.6}', 'font-size': '{font.size.xs}' },
160
+ dot: { width: '{space.2}', height: '{space.2}' },
161
+ check: { width: '{size.icon.xs}', height: '{size.icon.xs}' },
162
+ content: { 'padding-top': '{space.0.5}' },
163
+ connector: { 'margin-top': 'calc({space.6} / 2 - {border.width.thin})' },
164
+ },
165
+ md: { root: {} },
166
+ },
167
+ },
168
+ compound: [
169
+ { when: { orientation: 'vertical', size: 'sm' }, block: { connector: { 'inset-inline-start': 'calc({space.6} / 2 - {border.width.thin})', top: 'calc({space.6} + {space.1})', 'margin-top': '0' }, step: { 'padding-bottom': '{space.5}' } } },
170
+ { when: { variant: 'dot', size: 'sm' }, block: { indicator: { width: '{space.2}', height: '{space.2}', 'margin-top': '{space.1.5}' }, connector: { 'margin-top': 'calc({space.2.5} - {border.width.thin})' } } },
171
+ { when: { variant: 'dot', orientation: 'vertical' }, block: { connector: { 'inset-inline-start': 'calc({space.2.5} / 2 - {border.width.thin})', top: 'calc({space.2.5} * 2 + {space.1})', 'margin-top': '0' } } },
172
+ { when: { variant: 'line', orientation: 'vertical' }, block: { step: { 'flex-direction': 'row', 'padding-bottom': '0', 'margin-bottom': '{space.4}' }, indicator: { width: '{space.1}', height: 'auto', 'align-self': 'stretch', 'min-height': '{space.10}' } } },
173
+ ],
174
+ extraCss: `
175
+ .cn-stepper__step:last-child .cn-stepper__connector { display: none; }
176
+ .cn-stepper[data-orientation="horizontal"] .cn-stepper__step:last-child { flex: 0 0 auto; }
177
+ .cn-stepper[data-orientation="horizontal"][data-variant="line"] .cn-stepper__step:last-child { flex: 1 1 0; }
178
+ .cn-stepper[data-orientation="vertical"] .cn-stepper__step:last-child { padding-bottom: 0; margin-bottom: 0; }
179
+ .cn-stepper[data-variant="number"] .cn-stepper__indicator { background-color: {color.bg-surface}; box-shadow: inset 0 0 0 1px {color.border-control}; }
180
+ .cn-stepper__step[data-state="complete"] .cn-stepper__indicator { background-color: {color.bg-action}; box-shadow: none; color: {color.fg-on-action}; }
181
+ .cn-stepper__step[data-state="complete"] .cn-stepper__dot { display: none; }
182
+ .cn-stepper__step[data-state="complete"] .cn-stepper__connector { background-color: {color.bg-action}; }
183
+ .cn-stepper__step[data-state="current"] .cn-stepper__indicator, .cn-stepper__step[aria-current="step"] .cn-stepper__indicator { background-color: {color.bg-action}; box-shadow: 0 0 0 4px {color.bg-action-subtle}; color: {color.fg-on-action}; }
184
+ .cn-stepper[data-variant="number"] .cn-stepper__step[data-state="current"] .cn-stepper__indicator, .cn-stepper[data-variant="number"] .cn-stepper__step[aria-current="step"] .cn-stepper__indicator { background-color: {color.bg-surface}; box-shadow: inset 0 0 0 2px {color.bg-action}, 0 0 0 4px {color.bg-action-subtle}; color: {color.fg-action}; }
185
+ .cn-stepper__step[data-state="current"] .cn-stepper__dot, .cn-stepper__step[aria-current="step"] .cn-stepper__dot { background-color: {color.fg-on-action}; }
186
+ .cn-stepper__step[data-state="current"] .cn-stepper__label, .cn-stepper__step[aria-current="step"] .cn-stepper__label { color: {color.fg-action}; }
187
+ .cn-stepper[data-variant="line"] .cn-stepper__step[data-state="current"] .cn-stepper__indicator, .cn-stepper[data-variant="line"] .cn-stepper__step[data-state="complete"] .cn-stepper__indicator, .cn-stepper[data-variant="dot"] .cn-stepper__step[data-state="complete"] .cn-stepper__indicator { box-shadow: none; }
188
+ .cn-stepper[data-variant="dot"] .cn-stepper__step[data-state="current"] .cn-stepper__indicator { box-shadow: 0 0 0 4px {color.bg-action-subtle}; }
189
+ .cn-stepper__step[data-state="error"] .cn-stepper__indicator { background-color: {color.bg-danger-subtle}; box-shadow: inset 0 0 0 1px {red.500}; color: {color.fg-danger}; }
190
+ .cn-stepper__step[data-state="error"] .cn-stepper__dot { background-color: {red.500}; }
191
+ .cn-stepper__step[data-state="error"] .cn-stepper__label, .cn-stepper__step[data-state="error"] .cn-stepper__description { color: {color.fg-danger}; }
192
+ @media (max-width: {breakpoint.md}) {
193
+ .cn-stepper[data-orientation="horizontal"]:has(.cn-stepper__content) { flex-direction: column; gap: {space.5}; }
194
+ .cn-stepper[data-orientation="horizontal"]:has(.cn-stepper__content) .cn-stepper__step { flex: 0 0 auto; width: 100%; }
195
+ .cn-stepper[data-orientation="horizontal"]:has(.cn-stepper__content) .cn-stepper__content { flex: 1; }
196
+ .cn-stepper[data-orientation="horizontal"]:has(.cn-stepper__content) .cn-stepper__label { white-space: normal; overflow-wrap: anywhere; }
197
+ .cn-stepper[data-orientation="horizontal"]:has(.cn-stepper__content) .cn-stepper__connector { display: none; }
198
+ }`,
199
+ examples: [
200
+ ex('Icon with text (default md)', build('icon', 'horizontal', 'md', 'Account setup', steps('icon')), 'The reference step icon: gray-50 disc with a gray-300 dot; current is brand-600 with a white dot and a 4px halo, complete is brand with a white check. 2px connectors turn brand after a complete step.'),
201
+ ex('Icon with number', build('number', 'horizontal', 'md', 'Account setup', steps('number')), 'Numbers in ringed 32px circles; the current one has a 2px brand ring, the complete one is filled with a check.'),
202
+ ex('Minimal dots and connected dots', `<div style="display:flex;flex-direction:column;gap:var(--cn-space-8);width:100%">${build('dot', 'horizontal', 'md', 'Onboarding', steps('dot', false))}<div style="max-width:480px">${build('dot', 'horizontal', 'sm', 'Onboarding', steps('dot', false, [['complete', '', ''], ['complete', '', ''], ['current', '', ''], ['upcoming', '', ''], ['upcoming', '', '']]))}</div></div>`, '10px (md) or 8px (sm) dots; labels are optional. Complete dots are brand, the current one has the halo.'),
203
+ ex('Text with line', build('line', 'horizontal', 'md', 'Account setup', steps('line')), 'A 4px bar over each step: gray-100 upcoming, brand-600 current and complete; no connectors, steps share the width.'),
204
+ ex('Vertical with an error', build('icon', 'vertical', 'md', 'CSV import', step(1, 'complete', 'Upload file', 'customers-q3.csv · 1,204 rows', 'icon') + step(2, 'error', 'Map columns', '2 columns are unmapped: “Phone”, “Owner”', 'icon') + step(3, 'upcoming', 'Validate', 'Check duplicates and formats', 'icon') + step(4, 'upcoming', 'Import', 'Add 1,204 customers to Lumen', 'icon')), 'The vertical rail (max 480px): the connector runs down from each 32px indicator; the error step turns red and says what to fix.'),
205
+ ex('Small, vertical line', build('line', 'vertical', 'sm', 'Send campaign', steps('line', true, [['complete', 'Details', 'Name and audience'], ['complete', 'Recipients', '1,204 customers'], ['current', 'Schedule', 'Thursday, 09:00'], ['upcoming', 'Send', 'Review and confirm']])), 'In the vertical line variant the 4px bar runs beside each step.'),
206
+ ex('Small numbers, labels only', build('number', 'horizontal', 'sm', 'Send campaign', step(1, 'complete', 'Details') + step(2, 'complete', 'Recipients') + step(3, 'current', 'Schedule') + step(4, 'upcoming', 'Send')), 'Inside a dialog or drawer: 24px indicators, no descriptions.'),
207
+ ],
208
+ rules: [
209
+ '3–6 steps. Two steps are a single form with a confirm; more than six means the flow needs to be split or some steps merged.',
210
+ 'Titles are short and specific to the task. Labeled horizontal steps stack below 768px so titles and descriptions stay readable; unlabeled progress dots remain a row.',
211
+ 'Exactly one current step at all times; everything before it is complete (or error), everything after it upcoming.',
212
+ 'Complete steps may be clickable (wrap the label in a Link, or make the step a button) to go back; upcoming steps are never clickable.',
213
+ 'Descriptions are optional and short (≤ 5 words); in the error state the description is the message that says what to fix.',
214
+ 'Error only on a visited step and only with a message; never mark an upcoming step as error.',
215
+ 'icon or number for wizards above a form; dot for compact progress under a dialog title; line when the steps are also section titles of a long page.',
216
+ 'Horizontal for up to 5 steps above a wizard; vertical (max 480px wide) when there are 5–6 steps, when descriptions are needed, or in a side rail beside a long form.',
217
+ 'Indicators are 32px (sm 24) with 2px connectors; the check appears only on complete steps. No custom icons, letters or emoji in the circles.',
218
+ 'The stepper shows position, it does not save. Moving between steps is done by the form\'s Back / Continue buttons.',
219
+ ],
220
+ a11y: [
221
+ 'Root is an <ol aria-label="…"> so the order is announced ("step 2 of 4"); each step is an <li>.',
222
+ 'The current step carries aria-current="step" on the li in addition to data-state.',
223
+ 'Status is not color alone: complete indicators contain the check plus sr-only "Completed:", error indicators keep the number plus sr-only ", needs attention:".',
224
+ 'Connectors and decorative indicators are aria-hidden; the label text is the accessible name of the step. Dot steps without labels need an sr-only label each.',
225
+ 'If steps are clickable, they are links or buttons with the step name as text, and upcoming steps are not focusable.',
226
+ ],
227
+ related: ['tabs', 'progress', 'timeline', 'button', 'featured-icon'],
228
+ };