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,140 @@
1
+ import { FOCUS_RING, RESET_BUTTON, TRANSITION_COLORS, STATE, ex, ICON, typeStyle } from "./_shared.mjs";
2
+ const close = `<button type="button" class="cn-dialog__close" aria-label="Close">${ICON.x}</button>`;
3
+ const icon = (name) => `<span class="cn-dialog__icon">${ICON[name]}</span>`;
4
+ export const dialog = {
5
+ name: 'Dialog',
6
+ slug: 'dialog',
7
+ category: 'overlays',
8
+ description: 'Modal panel centered over a scrim for one decision or one short task. Raised white surface, radius-2xl, shadow-lg; header with a required title, a scrolling body, and a subtle footer where the single primary action sits last.',
9
+ usage: 'Use when the user must decide or complete something before continuing: confirm a deletion, name a new search, pick between two plans. Not for details of a list item (use Drawer, which keeps the list visible), not for hints (Tooltip/Popover), and never as a container for a whole page of settings.',
10
+ anatomy: [
11
+ { part: 'root', element: 'div', description: 'The panel. role="dialog" aria-modal="true" aria-labelledby → title. data-state="open|closed". Rendered inside .cn-dialog-backdrop in the app.' },
12
+ { part: 'header', element: 'header', description: 'Icon (optional) + title/description on the left, close button top-right. No bottom border.' },
13
+ { part: 'icon', element: 'span', description: '32px circle before the title: subtle fill and muted icon by default; danger fill and icon in variant="destructive".', optional: true },
14
+ { part: 'title', element: 'h2', description: 'heading-md. Required. A question or a short verb phrase: "Delete this search?", "Name your shortlist".' },
15
+ { part: 'description', element: 'p', description: 'body-sm muted, one or two sentences with the consequence.', optional: true },
16
+ { part: 'close', element: 'button', description: '32px ghost icon button with an X in the top-right. aria-label="Close".', optional: true },
17
+ { part: 'body', element: 'div', description: 'body-md content: a field, a short form, a summary. Scrolls when the panel hits max-height.' },
18
+ { part: 'footer', element: 'footer', description: 'Subtle background, top hairline, right-aligned: ghost cancel first, then one primary (or danger) Button.' },
19
+ ],
20
+ props: {
21
+ size: {
22
+ values: ['sm', 'md', 'lg', 'xl'],
23
+ default: 'md',
24
+ description: 'Max width of the panel. sm = 400px for a yes/no confirmation; md = 520px for one field or a short form (default); lg = 640px for a form with several fields; xl = 800px for a two-column layout or a preview. Pick two sizes per app and stick to them.',
25
+ },
26
+ variant: {
27
+ values: ['default', 'destructive'],
28
+ default: 'default',
29
+ description: 'default = neutral header icon (or none); destructive = the header icon turns danger (red circle) and the footer’s primary action is a danger Button. Use only for irreversible actions.',
30
+ },
31
+ },
32
+ states: {
33
+ open: STATE.open(),
34
+ closed: { selector: '[data-state="closed"]', description: 'Hidden (display none). The backdrop is removed at the same time.', markup: 'data-state="closed"' },
35
+ closeHover: { selector: ' .cn-dialog__close:hover', description: 'Pointer over the close button: subtle fill. Styled in extraCss because it lives on a child.', markup: 'native :hover on .cn-dialog__close' },
36
+ closeFocus: { selector: ' .cn-dialog__close:focus-visible', description: 'Keyboard focus on the close button shows the focus ring. Styled in extraCss.', markup: 'native :focus-visible on .cn-dialog__close' },
37
+ },
38
+ base: {
39
+ root: {
40
+ display: 'flex',
41
+ 'flex-direction': 'column',
42
+ width: '100%',
43
+ 'max-width': '520px',
44
+ 'max-height': 'calc(100vh - {space.16})',
45
+ 'background-color': '{color.bg-surface-raised}',
46
+ border: '{border.width.thin} solid {color.border-default}',
47
+ 'border-radius': '{radius.2xl}',
48
+ 'box-shadow': '{shadow.lg}',
49
+ color: '{color.fg-default}',
50
+ overflow: 'hidden',
51
+ ...typeStyle('body-md'),
52
+ },
53
+ header: { display: 'flex', 'align-items': 'flex-start', 'justify-content': 'space-between', gap: '{space.4}', padding: '{space.6} {space.6} 0' },
54
+ icon: {
55
+ display: 'inline-flex',
56
+ 'align-items': 'center',
57
+ 'justify-content': 'center',
58
+ 'flex-shrink': '0',
59
+ width: '{size.icon.2xl}',
60
+ height: '{size.icon.2xl}',
61
+ 'border-radius': '{radius.full}',
62
+ 'background-color': '{color.bg-subtle}',
63
+ color: '{color.fg-muted}',
64
+ },
65
+ title: { ...typeStyle('heading-md'), color: '{color.fg-default}' },
66
+ description: { ...typeStyle('body-sm'), color: '{color.fg-muted}', 'margin-top': '{space.1}' },
67
+ close: {
68
+ ...RESET_BUTTON,
69
+ display: 'inline-flex',
70
+ 'align-items': 'center',
71
+ 'justify-content': 'center',
72
+ 'flex-shrink': '0',
73
+ width: '{size.icon.2xl}',
74
+ height: '{size.icon.2xl}',
75
+ 'margin-block': 'calc({space.1} * -1)',
76
+ 'margin-inline-end': 'calc({space.2} * -1)',
77
+ 'border-radius': '{radius.md}',
78
+ color: '{color.fg-subtle}',
79
+ ...TRANSITION_COLORS,
80
+ },
81
+ body: { flex: '1 1 auto', 'min-height': '0', overflow: 'auto', padding: '{space.6}', ...typeStyle('body-md') },
82
+ footer: {
83
+ display: 'flex',
84
+ 'align-items': 'center',
85
+ 'justify-content': 'flex-end',
86
+ gap: '{space.2}',
87
+ padding: '{space.4} {space.6}',
88
+ 'border-top': '{border.width.thin} solid {color.border-subtle}',
89
+ 'background-color': '{color.bg-subtle}',
90
+ },
91
+ '@states': {
92
+ open: { root: { animation: 'cn-dialog-enter {motion.duration.slow} {motion.easing.out} both' } },
93
+ closed: { root: { display: 'none' } },
94
+ },
95
+ },
96
+ variants: {
97
+ size: {
98
+ sm: { root: { 'max-width': '400px' } },
99
+ md: { root: { 'max-width': '520px' } },
100
+ lg: { root: { 'max-width': '640px' } },
101
+ xl: { root: { 'max-width': '800px' } },
102
+ },
103
+ variant: {
104
+ default: { root: {} },
105
+ destructive: { icon: { 'background-color': '{color.bg-danger-subtle}', color: '{color.fg-danger}' } },
106
+ },
107
+ },
108
+ extraCss: `
109
+ .cn-dialog-backdrop { position: fixed; inset: 0; background: {color.bg-overlay}; display: grid; place-items: center; padding: {space.6}; z-index: {z.modal}; }
110
+ .cn-dialog[data-state="closed"] { display: none; }
111
+ @keyframes cn-dialog-enter { from { opacity: 0; transform: scale(0.98); } to { opacity: 1; transform: scale(1); } }
112
+ .cn-dialog .cn-dialog__icon .cn-icon { width: {size.icon.md}; height: {size.icon.md}; }
113
+ .cn-dialog .cn-dialog__close .cn-icon { width: {size.icon.md}; height: {size.icon.md}; }
114
+ .cn-dialog .cn-dialog__close:hover { background-color: {color.bg-subtle}; color: {color.fg-default}; }
115
+ .cn-dialog .cn-dialog__close:focus-visible { outline: none; box-shadow: ${FOCUS_RING['box-shadow']}; }`,
116
+ examples: [
117
+ ex('Default (md)', `<div class="cn-dialog" data-size="md" data-variant="default" data-state="open" role="dialog" aria-modal="true" aria-labelledby="dlg-name-title"><header class="cn-dialog__header"><div><h2 class="cn-dialog__title" id="dlg-name-title">Name your shortlist</h2><p class="cn-dialog__description">You can rename it later from the list header.</p></div>${close}</header><div class="cn-dialog__body"><div class="cn-input" data-variant="default" data-size="md"><input class="cn-input__field" type="text" value="Japan · frozen seafood · Q4" aria-label="Shortlist name"></div></div><footer class="cn-dialog__footer"><button type="button" class="cn-button" data-variant="ghost" data-size="md"><span class="cn-button__label">Cancel</span></button><button type="button" class="cn-button" data-variant="primary" data-size="md"><span class="cn-button__label">Create shortlist</span></button></footer></div>`),
118
+ ex('Destructive (sm)', `<div class="cn-dialog" data-size="sm" data-variant="destructive" data-state="open" role="dialog" aria-modal="true" aria-labelledby="dlg-delete-title"><header class="cn-dialog__header"><div style="display:flex;gap:var(--cn-space-3);align-items:flex-start">${icon('trash')}<div><h2 class="cn-dialog__title" id="dlg-delete-title">Delete this search?</h2><p class="cn-dialog__description">“Germany · industrial pumps” and its 38 prospects will be removed. This cannot be undone.</p></div></div>${close}</header><footer class="cn-dialog__footer"><button type="button" class="cn-button" data-variant="ghost" data-size="md"><span class="cn-button__label">Cancel</span></button><button type="button" class="cn-button" data-variant="danger" data-size="md"><span class="cn-button__label">Delete search</span></button></footer></div>`, 'The only dialog that ends with a danger Button. Cancel stays first and ghost.'),
119
+ ex('With header icon (md)', `<div class="cn-dialog" data-size="md" data-variant="default" data-state="open" role="dialog" aria-modal="true" aria-labelledby="dlg-pause-title"><header class="cn-dialog__header"><div style="display:flex;gap:var(--cn-space-3);align-items:flex-start">${icon('calendar')}<div><h2 class="cn-dialog__title" id="dlg-pause-title">Pause outreach until Monday?</h2><p class="cn-dialog__description">Drafts stay ready; nothing is sent until 08:00 on 15 Sep in each buyer’s timezone.</p></div></div>${close}</header><footer class="cn-dialog__footer"><button type="button" class="cn-button" data-variant="ghost" data-size="md"><span class="cn-button__label">Keep sending</span></button><button type="button" class="cn-button" data-variant="primary" data-size="md"><span class="cn-button__label">Pause until Monday</span></button></footer></div>`),
120
+ ex('Large with scrolling body (lg)', `<div class="cn-dialog" data-size="lg" data-variant="default" data-state="open" role="dialog" aria-modal="true" aria-labelledby="dlg-plan-title" style="max-height:360px"><header class="cn-dialog__header"><div><h2 class="cn-dialog__title" id="dlg-plan-title">Review the outreach plan</h2><p class="cn-dialog__description">12 companies · 3 waves · first wave tomorrow 09:00 JST</p></div>${close}</header><div class="cn-dialog__body"><p>Wave 1 (4 companies): Nakamura Trading Co., Hoffmann GmbH, Lagos Fresh Ltd., Marisco del Sur.</p><p style="margin-top:var(--cn-space-3)">Wave 2 (4 companies): Busan Cold Chain, Norsk Havfisk AS, Valencia Congelados, Pacific Reef Foods.</p><p style="margin-top:var(--cn-space-3)">Wave 3 (4 companies): Tokyo Maruha, Baltic Seafood OÜ, Dalian Ocean Trade, Cape Fisheries.</p><p style="margin-top:var(--cn-space-3)">Each company receives one email and one follow-up after 4 business days. Replies are drafted by the agent and wait for your approval.</p></div><footer class="cn-dialog__footer"><button type="button" class="cn-button" data-variant="ghost" data-size="md"><span class="cn-button__label">Back</span></button><button type="button" class="cn-button" data-variant="primary" data-size="md"><span class="cn-button__label">Start outreach</span></button></footer></div>`, 'max-height is inline here only to show the body scrolling; in the app it comes from calc(100vh - 64px).'),
121
+ ],
122
+ rules: [
123
+ 'A title is required and is the accessible name. Write it as the question being answered or the task being done.',
124
+ 'Exactly one primary action, last in the footer. Cancel (or the escape hatch) comes first as a ghost Button, labelled with what it does ("Keep sending"), not "Cancel" when a better verb exists.',
125
+ 'variant="destructive": header icon in danger colors and the primary becomes a danger Button. Never two filled buttons.',
126
+ 'Render the panel inside .cn-dialog-backdrop (fixed, scrim, centered, z modal). The panel itself is never position: fixed.',
127
+ 'Escape and the backdrop click close the dialog unless the body has unsaved input; then ask.',
128
+ 'Pick at most two sizes per app (typically sm for confirmations and md for forms). xl is for previews only.',
129
+ 'The body scrolls; header and footer stay put. Never let the whole panel exceed the viewport.',
130
+ 'Do not stack dialogs. A dialog that needs another step becomes a stepper inside the same dialog or a full page.',
131
+ 'Enter: scale 0.98 → 1 with fade, 260ms; the scrim fades in at the same time.',
132
+ ],
133
+ a11y: [
134
+ 'role="dialog" aria-modal="true" aria-labelledby pointing to the title (and aria-describedby to the description when present).',
135
+ 'Focus is trapped inside while open; on open it goes to the first control (or the cancel button for destructive dialogs) and returns to the trigger on close.',
136
+ 'Escape closes. The close button has aria-label="Close" and is the last element in the header.',
137
+ 'Everything behind the dialog gets inert (or aria-hidden) while it is open; body scroll is locked.',
138
+ ],
139
+ related: ['drawer', 'popover', 'alert', 'button', 'input'],
140
+ };
@@ -0,0 +1,115 @@
1
+ import { ex, typeStyle } from "./_shared.mjs";
2
+ // the reference content dividers: a 1px gray-200 rule, optionally carrying text (14px medium
3
+ // gray-600), a heading, a button or a button group in the middle, at the start or at the end;
4
+ // single line, dual line (rules above and below the content) or a gray-50 fill band.
5
+ const HAIRLINE = '{border.width.thin} solid {color.border-default}';
6
+ const SPACING = { none: '0', sm: '{space.2}', md: '{space.4}', lg: '{space.8}' };
7
+ const btn = (label, variant = 'outline', icon = '') => `<button type="button" class="cn-button" data-variant="${variant}" data-size="sm">${icon}<span class="cn-button__label">${label}</span></button>`;
8
+ const PLUS = '<svg class="cn-button__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M8 3v10M3 8h10"/></svg>';
9
+ const para = (text) => `<p class="cn-text-body-md" style="color:var(--cn-color-fg-muted);margin:0">${text}</p>`;
10
+ const sep = (o) => `<div class="cn-divider" data-orientation="${o.orientation ?? 'horizontal'}" data-variant="${o.variant ?? 'single'}" data-spacing="${o.spacing ?? 'md'}" data-label="${o.label ?? 'center'}" role="separator"><span class="cn-divider__content">${o.inner}</span></div>`;
11
+ export const divider = {
12
+ name: 'Divider',
13
+ slug: 'divider',
14
+ category: 'layout',
15
+ description: 'A 1px gray-200 hairline that separates. Horizontal between blocks, vertical between inline controls, or the reference content divider carrying text, a heading, a button or a button group on the line, between two lines, or on a gray-50 band.',
16
+ usage: 'Between sections of a card or form, between groups in a toolbar or menu, to label a break ("Earlier", "Or continue with") or to place an action between two blocks ("Add step"). Not between every list row or table row (those components draw their own rules), and never as decoration.',
17
+ anatomy: [
18
+ { part: 'root', element: 'hr', description: 'An <hr> for a plain horizontal rule. A <div role="separator"> when vertical or when it carries content (add data-label="center|start|end").' },
19
+ { part: 'content', element: 'span', description: 'Optional slot on the line: text, a heading, a Button or a ButtonGroup. Centered, or at the start / end with data-label.', optional: true },
20
+ { part: 'label', element: 'span', description: 'Plain text inside the content slot: 14px medium fg-muted ("Earlier", "Or"). A heading uses a real h-tag with the heading-xs class instead.', optional: true },
21
+ ],
22
+ props: {
23
+ orientation: {
24
+ values: ['horizontal', 'vertical'],
25
+ default: 'horizontal',
26
+ description: 'horizontal = full-width rule between stacked blocks; vertical = 1px column that stretches to the height of a flex row (toolbars, inline meta).',
27
+ },
28
+ variant: {
29
+ values: ['single', 'dual', 'fill'],
30
+ default: 'single',
31
+ description: 'single = one hairline, with the content sitting on it (the default). dual = a hairline above and below the content row (44px with text, 56 with buttons), for section breaks that carry an action. fill = a gray-50 band (36 / 52px) holding the content, for sticky sub-headers and grouped feeds.',
32
+ },
33
+ spacing: {
34
+ values: ['none', 'sm', 'md', 'lg'],
35
+ default: 'md',
36
+ description: 'Margin around the rule: none 0 (inside components that manage their own spacing), sm 8px (toolbars, dense cards), md 16px (default inside cards and forms), lg 32px (between page sections). Applies as margin-block when horizontal and margin-inline when vertical.',
37
+ },
38
+ },
39
+ states: {},
40
+ base: {
41
+ root: {
42
+ border: '0',
43
+ 'border-top': HAIRLINE,
44
+ width: '100%',
45
+ height: '0',
46
+ margin: '0',
47
+ 'flex-shrink': '0',
48
+ color: '{color.fg-muted}',
49
+ },
50
+ content: {
51
+ display: 'inline-flex',
52
+ 'align-items': 'center',
53
+ gap: '{space.2}',
54
+ 'padding-inline': '{space.3}',
55
+ 'white-space': 'nowrap',
56
+ },
57
+ label: {
58
+ ...typeStyle('label-sm'),
59
+ color: '{color.fg-muted}',
60
+ 'white-space': 'nowrap',
61
+ },
62
+ },
63
+ variants: {
64
+ orientation: {
65
+ horizontal: { root: { width: '100%', height: '0', 'border-top': HAIRLINE, 'border-inline-start': '0' } },
66
+ vertical: { root: { width: '0', height: 'auto', 'min-height': '{space.4}', 'align-self': 'stretch', 'border-top': '0', 'border-inline-start': HAIRLINE } },
67
+ },
68
+ variant: {
69
+ single: { root: {} },
70
+ dual: { root: {} },
71
+ fill: { root: {} },
72
+ },
73
+ spacing: Object.fromEntries(Object.entries(SPACING).map(([k, v]) => [k, { root: { 'margin-block': v } }])),
74
+ },
75
+ compound: Object.entries(SPACING).map(([k, v]) => ({ when: { orientation: 'vertical', spacing: k }, block: { root: { 'margin-block': '0', 'margin-inline': v } } })),
76
+ extraCss: `
77
+ .cn-divider[data-label] { display: flex; align-items: center; width: 100%; border: 0; height: auto; }
78
+ .cn-divider[data-label]::before, .cn-divider[data-label]::after { content: ""; flex: 1 1 auto; height: 0; border-top: ${HAIRLINE}; }
79
+ .cn-divider[data-label="start"]::before { flex: 0 0 {space.4}; }
80
+ .cn-divider[data-label="end"]::after { flex: 0 0 {space.4}; }
81
+ .cn-divider[data-label][data-variant="dual"] { border-top: ${HAIRLINE}; border-bottom: ${HAIRLINE}; padding-block: {space.3}; }
82
+ .cn-divider[data-label][data-variant="dual"]::before, .cn-divider[data-label][data-variant="dual"]::after { border-top: 0; }
83
+ .cn-divider[data-label][data-variant="fill"] { background-color: {color.bg-subtle}; padding: {space.2} {space.1}; border-radius: {radius.md}; }
84
+ .cn-divider[data-label][data-variant="fill"]::before, .cn-divider[data-label][data-variant="fill"]::after { border-top: 0; }
85
+ .cn-divider[data-label="start"][data-variant="dual"]::before, .cn-divider[data-label="start"][data-variant="fill"]::before { flex: 0 0 0; }
86
+ .cn-divider[data-label="end"][data-variant="dual"]::after, .cn-divider[data-label="end"][data-variant="fill"]::after { flex: 0 0 0; }
87
+ .cn-divider__content > .cn-text-heading-xs { color: {color.fg-default}; }`,
88
+ examples: [
89
+ ex('Horizontal (default)', `<div style="width:100%;max-width:480px">${para('Lumen keeps every project, task and file in one workspace your team can search.')}<hr class="cn-divider" data-orientation="horizontal" data-variant="single" data-spacing="md">${para('Invite teammates, set roles and connect the tools you already use.')}</div>`, 'A plain <hr>: 1px gray-200 with 16px above and below.'),
90
+ ex('With text', `<div style="width:100%;max-width:480px">${para('Sofia Almeida commented · 08:31')}${sep({ inner: '<span class="cn-divider__label">Earlier</span>' })}${para('Daniel Costa opened the pull request · Sep 9')}</div>`, '14px medium gray-600 text on the line, 12px of air each side. Use for time breaks and "or" separators.'),
91
+ ex('Heading at the start, button at the end', `<div style="width:100%;max-width:560px">${sep({ label: 'start', spacing: 'sm', inner: '<h3 class="cn-text-heading-xs">Shipping details</h3>' })}${para('FOB Santos · 24 t · 1 × 40\' reefer')}${sep({ label: 'end', spacing: 'md', inner: btn('Add step', 'outline', PLUS) })}</div>`, 'data-label="start" / "end" keep a 16px stub of line before or after the content.'),
92
+ ex('Button and button group in the middle', `<div style="width:100%;max-width:560px;display:flex;flex-direction:column;gap:var(--cn-space-4)">${sep({ spacing: 'none', inner: btn('Add step', 'outline', PLUS) })}${sep({ spacing: 'none', inner: `<div class="cn-button-group" data-variant="attached" data-orientation="horizontal" role="group" aria-label="Insert">${btn('Text', 'outline')}${btn('Image', 'outline')}${btn('Embed', 'outline')}</div>` })}</div>`, 'An action between two blocks: one outline Button, or a ButtonGroup.'),
93
+ ex('Dual line and fill', `<div style="width:100%;max-width:560px;display:flex;flex-direction:column;gap:var(--cn-space-6)">${sep({ variant: 'dual', spacing: 'none', inner: '<span class="cn-divider__label">Today</span>' })}${sep({ variant: 'dual', spacing: 'none', label: 'end', inner: btn('Show 12 earlier events', 'ghost') })}${sep({ variant: 'fill', spacing: 'none', label: 'start', inner: '<span class="cn-divider__label">Yesterday</span>' })}</div>`, 'dual = rules above and below the content row; fill = a gray-50 band (radius 6) holding it.'),
94
+ ex('Vertical in a toolbar', `<div style="display:flex;flex-wrap:wrap;align-items:center;gap:var(--cn-space-1)">${btn('Approve', 'primary')}${btn('Edit', 'ghost')}<div class="cn-divider" data-orientation="vertical" data-variant="single" data-spacing="sm" role="separator" aria-orientation="vertical"></div>${btn('Snooze', 'ghost')}${btn('Discard', 'ghost')}</div>`, 'Stretches to the row height; margin-inline from spacing.'),
95
+ ex('Large spacing between sections', `<div style="width:100%;max-width:480px"><h3 class="cn-text-heading-sm">Company</h3>${para('Lumen · Lisbon, Portugal')}<hr class="cn-divider" data-orientation="horizontal" data-variant="single" data-spacing="lg"><h3 class="cn-text-heading-sm">Contacts</h3>${para('Maya Chen · Product Manager')}</div>`),
96
+ ],
97
+ rules: [
98
+ 'A divider separates two things that are both present; never end a block with one and never stack two.',
99
+ 'Do not add dividers between rows of List, Table, DescriptionList or Accordion; they draw their own rules.',
100
+ 'Spacing is a prop, never an ad-hoc margin: sm in toolbars and dense cards, md inside cards and forms, lg between page sections, none inside components that already space their children.',
101
+ 'Prefer whitespace over a divider when the two blocks already differ in type (a heading followed by a paragraph needs no rule).',
102
+ 'Text on the line is 14px medium muted, 1–3 words ("Earlier", "Or", "Today"); a heading on the line is a real h-tag in heading-xs; a labeled divider is not a section title on its own.',
103
+ 'One action on a divider at most: an outline or ghost sm Button, or one ButtonGroup; never a primary button.',
104
+ 'single by default; dual only when the divider carries an action between two long blocks; fill for sticky date bands in feeds.',
105
+ 'Vertical dividers only between inline controls or meta of the same height; never taller than the row.',
106
+ 'Always gray-200; never a stronger color, never thicker than 1px, never dashed (dashed is reserved for drop zones).',
107
+ ],
108
+ a11y: [
109
+ 'A plain horizontal rule is an <hr> (implicit role separator). A labeled or vertical one is a <div role="separator">; add aria-orientation="vertical" when vertical.',
110
+ 'The label is read as text; keep it meaningful on its own ("Earlier" is fine, "———" is not).',
111
+ 'A button or button group on the divider is focusable in the normal order; the separator role does not hide it.',
112
+ 'Decorative dividers inside a component that already conveys grouping may use aria-hidden="true" to reduce noise.',
113
+ ],
114
+ related: ['card', 'button', 'button-group', 'list', 'accordion', 'timeline'],
115
+ };
@@ -0,0 +1,150 @@
1
+ import { FOCUS_RING, RESET_BUTTON, TRANSITION_COLORS, STATE, ex, ICON, typeStyle } from "./_shared.mjs";
2
+ const close = `<button type="button" class="cn-drawer__close" aria-label="Close">${ICON.x}</button>`;
3
+ export const drawer = {
4
+ name: 'Drawer',
5
+ slug: 'drawer',
6
+ category: 'overlays',
7
+ description: 'Side panel that slides in over the page for the detail of one item while the list stays visible behind the scrim. Raised surface, a hairline on the inner edge, shadow-lg; sticky header and footer with a scrolling body.',
8
+ usage: 'Use for the detail of a list or table row (a prospect, a conversation, an agent run), for editing one record, and for filters on mobile. Not for confirmations (use Dialog) and never as a replacement for a page: if the content has its own navigation, it is a page.',
9
+ anatomy: [
10
+ { part: 'root', element: 'aside', description: 'The panel. role="dialog" aria-modal="true" aria-labelledby → title. data-state="open|closed", data-side, data-size. Rendered inside .cn-drawer-backdrop in the app; in the gallery it is shown bounded with an inline height.' },
11
+ { part: 'header', element: 'header', description: 'Title/description on the left, close button on the right. Bottom hairline. Does not scroll.' },
12
+ { part: 'title', element: 'h2', description: 'heading-md. Names the item ("Nakamura Trading Co.") or the task ("Edit agent").' },
13
+ { part: 'description', element: 'p', description: 'body-sm muted, one line of context under the title.', optional: true },
14
+ { part: 'close', element: 'button', description: '32px ghost icon button with an X in the header. aria-label="Close".' },
15
+ { part: 'body', element: 'div', description: 'The scrolling area. Padding space-6; content is sections, description lists, a timeline, a form.' },
16
+ { part: 'footer', element: 'footer', description: 'Subtle background, top hairline, right-aligned actions: ghost first, one primary last. Only when the drawer edits something.', optional: true },
17
+ ],
18
+ props: {
19
+ side: {
20
+ values: ['right', 'left', 'bottom'],
21
+ default: 'right',
22
+ description: 'Edge the drawer slides in from. right = detail of a list item (default, the Vera pattern); left = navigation or filters; bottom = a sheet on mobile: full width, 80vh tall, rounded top corners.',
23
+ },
24
+ size: {
25
+ values: ['sm', 'md', 'lg'],
26
+ default: 'md',
27
+ description: 'Width for right/left drawers. sm = 360px for a short detail or filters; md = 480px for the detail of a record (default); lg = 640px for a conversation or an editor. Ignored for side="bottom".',
28
+ },
29
+ },
30
+ states: {
31
+ open: STATE.open(),
32
+ closed: { selector: '[data-state="closed"]', description: 'Hidden (display none); the backdrop is removed at the same time.', markup: 'data-state="closed"' },
33
+ closeHover: { selector: ' .cn-drawer__close:hover', description: 'Pointer over the close button: subtle fill. Styled in extraCss because it lives on a child.', markup: 'native :hover on .cn-drawer__close' },
34
+ closeFocus: { selector: ' .cn-drawer__close:focus-visible', description: 'Keyboard focus on the close button shows the focus ring. Styled in extraCss.', markup: 'native :focus-visible on .cn-drawer__close' },
35
+ },
36
+ base: {
37
+ root: {
38
+ display: 'flex',
39
+ 'flex-direction': 'column',
40
+ width: '480px',
41
+ 'max-width': '100vw',
42
+ height: '100%',
43
+ 'background-color': '{color.bg-surface-raised}',
44
+ 'border-inline-start': '{border.width.thin} solid {color.border-default}',
45
+ 'box-shadow': '{shadow.lg}',
46
+ color: '{color.fg-default}',
47
+ overflow: 'hidden',
48
+ ...typeStyle('body-md'),
49
+ },
50
+ header: {
51
+ display: 'flex',
52
+ 'align-items': 'flex-start',
53
+ 'justify-content': 'space-between',
54
+ gap: '{space.4}',
55
+ padding: '{space.5} {space.6}',
56
+ 'border-bottom': '{border.width.thin} solid {color.border-subtle}',
57
+ 'flex-shrink': '0',
58
+ },
59
+ title: { ...typeStyle('heading-md'), color: '{color.fg-default}' },
60
+ description: { ...typeStyle('body-sm'), color: '{color.fg-muted}', 'margin-top': '{space.1}' },
61
+ close: {
62
+ ...RESET_BUTTON,
63
+ display: 'inline-flex',
64
+ 'align-items': 'center',
65
+ 'justify-content': 'center',
66
+ 'flex-shrink': '0',
67
+ width: '{size.icon.2xl}',
68
+ height: '{size.icon.2xl}',
69
+ 'margin-block': 'calc({space.1} * -1)',
70
+ 'margin-inline-end': 'calc({space.2} * -1)',
71
+ 'border-radius': '{radius.md}',
72
+ color: '{color.fg-subtle}',
73
+ ...TRANSITION_COLORS,
74
+ },
75
+ body: { flex: '1 1 auto', 'min-height': '0', overflow: 'auto', padding: '{space.6}' },
76
+ footer: {
77
+ display: 'flex',
78
+ 'flex-wrap': 'wrap',
79
+ 'align-items': 'center',
80
+ 'justify-content': 'flex-end',
81
+ gap: '{space.2}',
82
+ padding: '{space.4} {space.6}',
83
+ 'border-top': '{border.width.thin} solid {color.border-subtle}',
84
+ 'background-color': '{color.bg-subtle}',
85
+ 'flex-shrink': '0',
86
+ },
87
+ '@states': {
88
+ closed: { root: { display: 'none' } },
89
+ },
90
+ },
91
+ variants: {
92
+ side: {
93
+ right: {
94
+ root: {},
95
+ '@states': { open: { root: { animation: 'cn-drawer-in-right {motion.duration.slow} {motion.easing.out} both' } } },
96
+ },
97
+ left: {
98
+ root: { 'border-inline-start': '0', 'border-inline-end': '{border.width.thin} solid {color.border-default}' },
99
+ '@states': { open: { root: { animation: 'cn-drawer-in-left {motion.duration.slow} {motion.easing.out} both' } } },
100
+ },
101
+ bottom: {
102
+ root: { width: '100%', 'max-width': '100vw', height: '80vh', 'border-inline-start': '0', 'border-top': '{border.width.thin} solid {color.border-default}', 'border-radius': '{radius.2xl} {radius.2xl} 0 0' },
103
+ '@states': { open: { root: { animation: 'cn-drawer-in-bottom {motion.duration.slow} {motion.easing.out} both' } } },
104
+ },
105
+ },
106
+ size: {
107
+ sm: { root: { width: '360px' } },
108
+ md: { root: { width: '480px' } },
109
+ lg: { root: { width: '640px' } },
110
+ },
111
+ },
112
+ compound: [
113
+ { when: { side: 'bottom', size: 'sm' }, block: { root: { width: '100%' } } },
114
+ { when: { side: 'bottom', size: 'md' }, block: { root: { width: '100%' } } },
115
+ { when: { side: 'bottom', size: 'lg' }, block: { root: { width: '100%' } } },
116
+ ],
117
+ extraCss: `
118
+ .cn-drawer-backdrop { position: fixed; inset: 0; background: {color.bg-overlay}; z-index: {z.modal}; display: flex; justify-content: flex-end; align-items: stretch; }
119
+ .cn-drawer-backdrop[data-side="left"] { justify-content: flex-start; }
120
+ .cn-drawer-backdrop[data-side="bottom"] { flex-direction: column; justify-content: flex-end; }
121
+ @keyframes cn-drawer-in-right { from { opacity: 0; transform: translateX(16px); } to { opacity: 1; transform: translateX(0); } }
122
+ @keyframes cn-drawer-in-left { from { opacity: 0; transform: translateX(-16px); } to { opacity: 1; transform: translateX(0); } }
123
+ @keyframes cn-drawer-in-bottom { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } }
124
+ .cn-drawer .cn-drawer__close .cn-icon { width: {size.icon.md}; height: {size.icon.md}; }
125
+ .cn-drawer .cn-drawer__close:hover { background-color: {color.bg-subtle}; color: {color.fg-default}; }
126
+ .cn-drawer .cn-drawer__close:focus-visible { outline: none; box-shadow: ${FOCUS_RING['box-shadow']}; }`,
127
+ examples: [
128
+ ex('Right, md (item detail)', `<aside class="cn-drawer" data-side="right" data-size="md" data-state="open" role="dialog" aria-modal="true" aria-labelledby="drw-nakamura" style="height:420px"><header class="cn-drawer__header"><div><h2 class="cn-drawer__title" id="drw-nakamura">Nakamura Trading Co.</h2><p class="cn-drawer__description">Osaka, Japan · Importer of frozen seafood · <span class="cn-badge" data-tone="success" data-variant="soft" data-size="sm">Verified</span></p></div>${close}</header><div class="cn-drawer__body"><p><strong>Why it fits</strong><br>Imports 1,200 t/year of frozen shrimp from Ecuador and Vietnam; two decision makers found; replied to a similar offer in 2025.</p><p style="margin-top:var(--cn-space-4)"><strong>Last activity</strong><br>Email opened 3 days ago · No reply yet · Follow-up scheduled for Tuesday 09:00 JST.</p><p style="margin-top:var(--cn-space-4)"><strong>Contacts</strong><br>Kenji Nakamura, Purchasing Director · Aiko Sato, Import Coordinator.</p></div><footer class="cn-drawer__footer"><button type="button" class="cn-button" data-variant="ghost" data-size="md"><span class="cn-button__label">Archive</span></button><button type="button" class="cn-button" data-variant="primary" data-size="md"><span class="cn-button__label">Send follow-up now</span></button></footer></aside>`, 'height:420px is inline only so the gallery shows the bounded panel; in the app it fills the backdrop.'),
129
+ ex('Left, sm (filters)', `<aside class="cn-drawer" data-side="left" data-size="sm" data-state="open" role="dialog" aria-modal="true" aria-labelledby="drw-filters" style="height:360px"><header class="cn-drawer__header"><div><h2 class="cn-drawer__title" id="drw-filters">Filters</h2><p class="cn-drawer__description">38 of 214 prospects match</p></div>${close}</header><div class="cn-drawer__body"><div class="cn-input" data-variant="default" data-size="sm">${ICON.search.replace('cn-icon', 'cn-input__icon')}<input class="cn-input__field" type="search" placeholder="Country or product…" aria-label="Filter prospects"></div><p style="margin-top:var(--cn-space-4);color:var(--cn-color-fg-muted)">Japan · South Korea · Frozen seafood · Replied</p></div><footer class="cn-drawer__footer"><button type="button" class="cn-button" data-variant="ghost" data-size="md"><span class="cn-button__label">Clear all</span></button><button type="button" class="cn-button" data-variant="primary" data-size="md"><span class="cn-button__label">Show 38</span></button></footer></aside>`),
130
+ ex('Bottom sheet (mobile)', `<div style="width:100%;max-width:420px"><aside class="cn-drawer" data-side="bottom" data-size="md" data-state="open" role="dialog" aria-modal="true" aria-labelledby="drw-sheet" style="height:300px"><header class="cn-drawer__header"><div><h2 class="cn-drawer__title" id="drw-sheet">Approve this reply?</h2><p class="cn-drawer__description">Drafted by the agent 2 minutes ago</p></div>${close}</header><div class="cn-drawer__body">“Thank you for your interest, Nakamura-san. We can supply 40 t of frozen shrimp per month from October, CIF Osaka. Would a call on Thursday suit you?”</div><footer class="cn-drawer__footer"><button type="button" class="cn-button" data-variant="ghost" data-size="md"><span class="cn-button__label">Edit</span></button><button type="button" class="cn-button" data-variant="primary" data-size="md"><span class="cn-button__label">Approve and send</span></button></footer></aside></div>`, 'Bottom drawers are full width, 80vh tall, with rounded top corners. The wrapper is inline here only to bound the example.'),
131
+ ex('Large, read-only (no footer)', `<aside class="cn-drawer" data-side="right" data-size="lg" data-state="open" role="dialog" aria-modal="true" aria-labelledby="drw-run" style="height:360px"><header class="cn-drawer__header"><div><h2 class="cn-drawer__title" id="drw-run">Agent run · 11 Sep, 09:14</h2><p class="cn-drawer__description">Prospecting · Japan · frozen seafood · 4 min 12 s</p></div>${close}</header><div class="cn-drawer__body"><p>Read 3 replies from Nakamura, Hoffmann and Lagos Fresh. Drafted 3 answers. Flagged Hoffmann as “asks for a price list” — waiting for your file.</p><p style="margin-top:var(--cn-space-4)">Searched 212 companies, verified 38, discarded 174 (no trade data or wrong product).</p></div></aside>`),
132
+ ],
133
+ rules: [
134
+ 'The detail of a list item opens in a drawer, never in a new page. The list stays visible behind the scrim so the user keeps their place.',
135
+ 'One level deep only: a drawer never opens another drawer. A link inside a drawer that needs more space closes it and navigates.',
136
+ 'Render the panel inside .cn-drawer-backdrop with the same data-side; the panel itself is never position: fixed.',
137
+ 'Header is sticky and carries the item name; the body scrolls; the footer appears only when the drawer edits something (ghost first, one primary last).',
138
+ 'Width comes from size; never set it inline. Below 640px every drawer becomes full width (right/left) or a bottom sheet.',
139
+ 'Close on Escape, on the close button and on scrim click, unless the body has unsaved edits; then ask.',
140
+ 'Deep-link the open drawer (?prospect=123) so refresh and share keep it open.',
141
+ 'Enter: 16px slide from its side with fade, 260ms; leave is the reverse at 180ms.',
142
+ ],
143
+ a11y: [
144
+ 'role="dialog" aria-modal="true" with aria-labelledby on the title.',
145
+ 'Focus moves to the close button (read-only drawers) or the first field (edit drawers) on open, and returns to the row that opened it on close.',
146
+ 'Focus is trapped inside while open; the page behind is inert.',
147
+ 'The close button has aria-label="Close"; Escape closes.',
148
+ ],
149
+ related: ['dialog', 'popover', 'card', 'button', 'badge'],
150
+ };
@@ -0,0 +1,112 @@
1
+ import { ex, ICON, typeStyle } from "./_shared.mjs";
2
+ // the reference empty state: a 512px centered column; a 48px modern gray featured icon (56 at lg)
3
+ // or an illustration in the header (16 / 20 / 20px below); content max 352px with a 16 / 18 / 20px
4
+ // semibold title and 14 / 14 / 16px gray-600 text (4 / 8 / 8px apart, 24 / 32 / 32px above the
5
+ // footer); actions 12px apart; optional search field and background pattern.
6
+ const HAIRLINE = '{border.width.thin} solid {color.border-default}';
7
+ const ring = (color) => `inset 0 0 0 1px ${color}`;
8
+ const button = (label, variant, size = 'md', icon = '') => `<button type="button" class="cn-button" data-variant="${variant}" data-size="${size}">${icon ? icon.replace('cn-icon', 'cn-button__icon') : ''}<span class="cn-button__label">${label}</span></button>`;
9
+ const featured = (i, size = 'lg') => `<span class="cn-featured-icon cn-empty-state__icon" data-theme="modern" data-tone="gray" data-size="${size}" data-shape="square">${ICON[i].replace('cn-icon', 'cn-featured-icon__icon')}</span>`;
10
+ /** A quiet inline illustration (a stack of documents) drawn with token variables. */
11
+ const ILLUSTRATION = `<svg class="cn-empty-state__illustration" viewBox="0 0 152 120" aria-hidden="true"><ellipse cx="76" cy="108" rx="60" ry="6" fill="var(--cn-color-bg-muted)"/><rect x="38" y="30" width="76" height="74" rx="8" fill="var(--cn-color-bg-surface)" stroke="var(--cn-color-border-control)" stroke-width="1.5"/><rect x="46" y="22" width="76" height="74" rx="8" fill="var(--cn-color-bg-surface)" stroke="var(--cn-color-border-control)" stroke-width="1.5"/><rect x="54" y="14" width="76" height="74" rx="8" fill="var(--cn-color-bg-surface)" stroke="var(--cn-color-border-control)" stroke-width="1.5"/><rect x="66" y="30" width="36" height="6" rx="3" fill="var(--cn-color-bg-muted)"/><rect x="66" y="44" width="52" height="6" rx="3" fill="var(--cn-color-bg-subtle)"/><rect x="66" y="58" width="44" height="6" rx="3" fill="var(--cn-color-bg-subtle)"/><circle cx="110" cy="80" r="14" fill="var(--cn-color-bg-action-subtle)" stroke="var(--cn-color-bg-action)" stroke-width="1.5"/><path d="M104.5 80.5l3.5 3.5 7-7.5" fill="none" stroke="var(--cn-color-bg-action)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
12
+ const SEARCH = `<div class="cn-empty-state__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" value="Osaka"></div>`;
13
+ const state = (o) => `<div class="cn-empty-state" data-size="${o.size ?? 'md'}" data-variant="${o.variant ?? 'default'}"${o.style ? ` style="${o.style}"` : ''}>${o.header ? `<div class="cn-empty-state__header">${o.header}</div>` : ''}<h3 class="cn-empty-state__title">${o.title}</h3>${o.description ? `<p class="cn-empty-state__description">${o.description}</p>` : ''}${o.search ?? ''}${o.actions ? `<div class="cn-empty-state__actions">${o.actions}</div>` : ''}</div>`;
14
+ export const emptyState = {
15
+ name: 'EmptyState',
16
+ slug: 'empty-state',
17
+ category: 'feedback',
18
+ description: 'What a region shows when it has nothing to show, in the reference layout: a modern gray featured icon (or a quiet illustration) above a short semibold title, one sentence of help capped at 352px and the actions that fill it. Centered, narrow, calm.',
19
+ usage: 'Empty collections (no projects yet), empty filters and searches (no results), empty slots (no file uploaded) and drop zones. Not for errors (Alert) and not for loading (Skeleton).',
20
+ anatomy: [
21
+ { part: 'root', element: 'div', description: 'Centered column, max width 512px, padding from size. Plain, card or dashed frame.' },
22
+ { part: 'header', element: 'div', description: 'Optional wrapper for the featured icon or illustration, 16 / 20 / 20px above the title (24 with an illustration).', optional: true },
23
+ { part: 'icon', element: 'span', description: 'A FeaturedIcon (theme modern, tone gray, 48px; 56px at lg) that also carries this class; or a bare 24px line icon in fg-subtle for the smallest inline states. Decorative.', optional: true },
24
+ { part: 'illustration', element: 'svg', description: 'An inline SVG illustration (≈152 × 120) drawn with token variables: documents, a cloud, a box, a credit card.', optional: true },
25
+ { part: 'title', element: 'h3', description: '16 / 18 / 20px semibold in ink: what is empty, in plain words ("No projects yet").' },
26
+ { part: 'description', element: 'p', description: 'One sentence, 14px (16px at lg) fg-muted, max 352px: why it is empty or what will fill it.', optional: true },
27
+ { part: 'search', element: 'div', description: 'Optional search Input (max 352px) between the text and the actions for "no results" states, so the user can refine the query in place.', optional: true },
28
+ { part: 'actions', element: 'div', description: 'Row of one primary Button and optionally one secondary (outline) Button, 12px apart, 24 / 32 / 32px below the text.', optional: true },
29
+ ],
30
+ props: {
31
+ size: {
32
+ values: ['sm', 'md', 'lg'],
33
+ default: 'md',
34
+ description: 'sm = inline in a card, table body or chart (16px title, 14px text, 48px icon, 4px text gap); md = a page section (18px title, 14px text, 48px icon, 8px gap); lg = a whole empty page (20px title, 16px text, 56px icon).',
35
+ },
36
+ variant: {
37
+ values: ['default', 'card', 'dashed', 'illustration', 'with-search'],
38
+ default: 'default',
39
+ description: 'default = no frame, a featured icon in the header (inside a Card, table or panel); card = white with a gray-200 ring, shadow-xs and radius 12 (standalone on the canvas); dashed = dashed gray-300 border, transparent, for drop zones and "create your first" slots; illustration = an inline SVG illustration in the header instead of the icon (24px below it); with-search = a search field under the text for "no results" states.',
40
+ },
41
+ },
42
+ states: {},
43
+ base: {
44
+ root: {
45
+ display: 'flex',
46
+ 'flex-direction': 'column',
47
+ 'align-items': 'center',
48
+ 'text-align': 'center',
49
+ gap: '{space.2}',
50
+ padding: '{space.12} {space.6}',
51
+ width: '100%',
52
+ 'max-width': 'calc({space.64} * 2)',
53
+ 'margin-inline': 'auto',
54
+ color: '{color.fg-default}',
55
+ },
56
+ header: { position: 'relative', display: 'flex', 'align-items': 'center', 'justify-content': 'center', 'margin-bottom': '{space.3}' },
57
+ icon: { 'flex-shrink': '0', 'margin-bottom': '{space.3}' },
58
+ illustration: { display: 'block', width: '152px', height: 'auto', 'max-width': '100%' },
59
+ title: { ...typeStyle('heading-sm'), color: '{color.fg-default}', margin: '0', 'max-width': 'calc({space.64} + {space.24})' },
60
+ description: { ...typeStyle('body-md'), color: '{color.fg-muted}', 'max-width': 'calc({space.64} + {space.24})', margin: '0' },
61
+ search: { width: '100%', 'max-width': 'calc({space.64} + {space.24})', 'margin-top': '{space.4}' },
62
+ actions: { display: 'flex', 'flex-wrap': 'wrap', 'justify-content': 'center', 'align-items': 'center', gap: '{space.3}', 'margin-top': '{space.6}' },
63
+ },
64
+ variants: {
65
+ size: {
66
+ sm: { root: { padding: '{space.8} {space.4}', gap: '{space.1}' }, header: { 'margin-bottom': '{space.3}' }, icon: { 'margin-bottom': '{space.3}' }, title: { ...typeStyle('heading-xs') }, actions: { 'margin-top': '{space.5}' } },
67
+ md: { root: { padding: '{space.12} {space.6}' } },
68
+ lg: { root: { padding: '{space.16} {space.8}' }, title: { 'font-size': '{font.size.xl}', 'line-height': '{font.lineHeight.normal}' }, description: { ...typeStyle('body-lg') } },
69
+ },
70
+ variant: {
71
+ default: { root: {} },
72
+ card: { root: { 'background-color': '{color.bg-surface}', 'box-shadow': `${ring('{color.border-default}')}, {shadow.xs}`, 'border-radius': '{radius.card}' } },
73
+ dashed: { root: { 'background-color': 'transparent', border: '{border.width.thin} dashed {color.border-control}', 'border-radius': '{radius.card}' } },
74
+ illustration: { header: { 'margin-bottom': '{space.4}' } },
75
+ 'with-search': { root: {} },
76
+ },
77
+ },
78
+ extraCss: `
79
+ .cn-empty-state__header > .cn-empty-state__icon { margin-bottom: 0; }
80
+ svg.cn-empty-state__icon { width: {size.icon.lg}; height: {size.icon.lg}; color: {color.fg-subtle}; }
81
+ .cn-empty-state[data-size="lg"] .cn-featured-icon.cn-empty-state__icon { width: {space.14}; height: {space.14}; border-radius: {radius.xl}; }
82
+ .cn-empty-state[data-size="lg"] .cn-featured-icon.cn-empty-state__icon > .cn-featured-icon__icon { width: {size.icon.xl}; height: {size.icon.xl}; }
83
+ .cn-empty-state__title + .cn-empty-state__search, .cn-empty-state__description + .cn-empty-state__search { margin-top: {space.4}; }
84
+ .cn-empty-state__search + .cn-empty-state__actions { margin-top: {space.4}; }
85
+ .cn-empty-state[data-variant="dashed"] .cn-empty-state__icon { margin-bottom: {space.3}; }`,
86
+ examples: [
87
+ ex('No projects yet (default md)', state({ header: featured('copy'), title: 'No projects yet', description: 'Projects keep your team\'s work in one place. Create the first one or import from another tool.', actions: button('Import', 'outline') + button('New project', 'primary', 'md', ICON.plus) }), 'A 48px modern gray featured icon 20px above an 18px semibold title, 14px gray-600 text capped at 352px, two buttons 12px apart 32px below.'),
88
+ ex('Illustration', state({ variant: 'illustration', header: ILLUSTRATION, title: 'You\'re all caught up', description: 'Every task assigned to you is done. New work will show up here as soon as a teammate assigns it.', actions: button('View all tasks', 'link-color', 'md') }), 'An inline SVG illustration (documents) in the header, 24px above the title.'),
89
+ ex('No results, with search (sm)', state({ size: 'sm', variant: 'with-search', header: featured('search'), title: 'No customers found', description: 'Your search “Osaka” did not match any customers. Try a different name or clear the filters.', search: SEARCH, actions: button('Clear search', 'outline', 'sm') + button('New customer', 'primary', 'sm', ICON.plus) }), 'The search field sits between the text and the actions so the query can be refined in place; 16px title.'),
90
+ ex('Drop zone (dashed)', state({ variant: 'dashed', header: featured('inbox', 'md'), title: 'Drop a CSV here', description: 'One customer per row, with at least a name and an email address.', actions: button('Choose file', 'outline'), style: 'max-width:512px' }), 'Dashed gray-300 frame with a 40px modern icon; the button is the keyboard path.'),
91
+ ex('Whole page (lg, card)', state({ size: 'lg', variant: 'card', header: featured('inbox', 'xl'), title: 'Nothing waiting for you', description: 'Your team is working on 12 tasks. You will see reviews and approvals here as soon as they need a decision.', actions: button('See what the team is doing', 'outline') }), '56px icon, 20px title, 16px text; the white card frame when the empty state is the whole canvas region.'),
92
+ ex('Inline in a chart or table body', `<div class="cn-empty-state" data-size="sm" data-variant="default">${ICON.search.replace('cn-icon', 'cn-empty-state__icon')}<h3 class="cn-empty-state__title">No results for “Osaka”</h3><p class="cn-empty-state__description">Try a wider region or clear the score filter.</p><div class="cn-empty-state__actions">${button('Clear filters', 'link-color', 'sm')}</div></div>`, 'The bare 24px line icon for the smallest inline states, no header wrapper.'),
93
+ ],
94
+ rules: [
95
+ 'Title says what is empty in the user\'s words ("No projects yet", "Nothing waiting for you"), never "No data", "Empty" or "404".',
96
+ 'Description is one sentence, 14px, capped at 352px, that says why or what will fill it; skip it when the title is enough.',
97
+ 'One primary action at most, and only if the user can fill the region from here; a second action is outline or link-color, 12px before it.',
98
+ 'The header holds one thing: a modern gray FeaturedIcon (48px, 56 at lg), an illustration, or a file icon. Never a colored disc, never an emoji.',
99
+ 'Filter-empty ("No results for X") is size sm with the with-search variant, keeps the filters visible and offers "Clear search"; it never uses a card or dashed frame.',
100
+ 'Use dashed only for drop zones and "create the first one" slots; card only when the empty state is the whole canvas region; illustration for onboarding moments, not for every empty table.',
101
+ 'Loading is not empty: show a Skeleton until the data arrives, then the EmptyState if it is truly empty.',
102
+ 'Do not center the empty state vertically in a tall page; keep it at the top with its padding so it reads like content.',
103
+ ],
104
+ a11y: [
105
+ 'The title is a real heading at the level of the region it replaces (h2 for a page, h3 for a card); do not skip levels.',
106
+ 'The featured icon and illustration are aria-hidden; nothing in the empty state depends on them.',
107
+ 'When the empty state appears after a filter or search, announce it with aria-live="polite" on the region so the change is heard.',
108
+ 'Drop zones also work by keyboard: the "Choose file" Button opens the file picker; the dashed area is not the only way.',
109
+ 'A search field inside the empty state keeps the current query as its value and has an aria-label.',
110
+ ],
111
+ related: ['featured-icon', 'skeleton', 'alert', 'button', 'input', 'card', 'table', 'list', 'file-dropzone'],
112
+ };