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 { ex, typeStyle } from "./_shared.mjs";
2
+ /** Inline SVG placeholder: neutral fill with one diagonal line, 16:9. */
3
+ const PLACEHOLDER = `<svg class="cn-media-frame__media" viewBox="0 0 160 90" preserveAspectRatio="xMidYMid slice" role="img" aria-label="Placeholder image"><rect width="160" height="90" fill="var(--cn-color-bg-muted)"/><path d="M0 90L160 0" stroke="var(--cn-color-border-strong)" stroke-width="1"/></svg>`;
4
+ export const mediaFrame = {
5
+ name: 'MediaFrame',
6
+ slug: 'media-frame',
7
+ category: 'media',
8
+ description: 'A figure that holds an image or video at a fixed aspect ratio: a clipping frame with a subtle fill, an optional hairline, an optional flat text overlay, and a caption below. The frame clips; the figure does not, so captions never get cut.',
9
+ usage: 'Use for every image or video in the product: company logos in cards, screenshots in help articles, thumbnails in lists, a video in an empty state. Never place a raw <img> in a layout; the frame guarantees the ratio, the loading placeholder and the radius.',
10
+ anatomy: [
11
+ { part: 'root', element: 'figure', description: 'The figure. Sets width; overflow visible so the caption sits below. data-ratio / data-radius / data-fit, optional data-bordered, data-state="loading".' },
12
+ { part: 'frame', element: 'div', description: 'The clipping box: aspect-ratio, radius, subtle fill, overflow hidden. Holds media and overlay.' },
13
+ { part: 'media', element: 'img', description: 'The <img>, <video> or inline <svg>. Fills the frame; object-fit from data-fit. Always has alt (or aria-label for svg).' },
14
+ { part: 'overlay', element: 'div', description: 'Optional flat scrim over the media with inverse text at the bottom-left: a title, a duration, a label. No gradient.', optional: true },
15
+ { part: 'caption', element: 'figcaption', description: 'body-xs muted line under the frame: source, date, what the image shows.', optional: true },
16
+ ],
17
+ props: {
18
+ ratio: {
19
+ values: ['auto', '1:1', '4:3', '3:2', '16:9'],
20
+ default: '16:9',
21
+ description: 'Aspect ratio of the frame. auto = the media’s own height (logos, inline illustrations); 1:1 = avatars, product shots; 4:3 = documents and screenshots; 3:2 = photos; 16:9 = video and hero thumbnails (default).',
22
+ },
23
+ radius: {
24
+ values: ['none', 'md', 'xl', '2xl'],
25
+ default: 'xl',
26
+ description: 'Corner radius of the frame. none = full-bleed inside a card with padding none; md = small thumbnails in rows; xl = default for cards and articles; 2xl = hero media on marketing pages.',
27
+ },
28
+ fit: {
29
+ values: ['cover', 'contain'],
30
+ default: 'cover',
31
+ description: 'cover = fills the frame and crops (photos, screenshots); contain = shows the whole media on the subtle fill (logos, diagrams).',
32
+ },
33
+ },
34
+ states: {
35
+ loading: { selector: '[data-state="loading"]', description: 'Media not yet available: the frame shows the skeleton shimmer and the media is hidden.', markup: 'data-state="loading" on the figure' },
36
+ bordered: { selector: '[data-bordered]', description: 'Adds the default hairline around the frame, for light media on white surfaces (logos, screenshots).', markup: 'data-bordered attribute on the figure' },
37
+ },
38
+ base: {
39
+ root: { display: 'block', width: '100%', 'min-width': '0', margin: '0', overflow: 'visible' },
40
+ frame: {
41
+ position: 'relative',
42
+ width: '100%',
43
+ 'aspect-ratio': '16 / 9',
44
+ 'background-color': '{color.bg-subtle}',
45
+ 'border-radius': '{radius.xl}',
46
+ border: '{border.width.thin} solid transparent',
47
+ overflow: 'hidden',
48
+ },
49
+ media: { display: 'block', width: '100%', height: '100%', 'object-fit': 'cover' },
50
+ overlay: {
51
+ position: 'absolute',
52
+ inset: '0',
53
+ display: 'flex',
54
+ 'flex-direction': 'column',
55
+ 'justify-content': 'flex-end',
56
+ padding: '{space.4}',
57
+ 'background-color': '{color.bg-overlay}',
58
+ color: '{color.fg-inverse}',
59
+ ...typeStyle('label-md'),
60
+ },
61
+ caption: { ...typeStyle('body-xs'), color: '{color.fg-muted}', padding: '{space.2} 0 0' },
62
+ '@states': {
63
+ loading: {
64
+ frame: {
65
+ 'background-image': 'linear-gradient(90deg, {color.bg-muted} 0%, {color.bg-subtle} 50%, {color.bg-muted} 100%)',
66
+ 'background-size': '200% 100%',
67
+ animation: 'cn-media-frame-shimmer 1.4s ease-in-out infinite',
68
+ },
69
+ media: { visibility: 'hidden' },
70
+ },
71
+ bordered: { frame: { 'border-color': '{color.border-default}' } },
72
+ },
73
+ },
74
+ variants: {
75
+ ratio: {
76
+ auto: { frame: { 'aspect-ratio': 'auto' }, media: { height: 'auto' } },
77
+ '1:1': { frame: { 'aspect-ratio': '1 / 1' } },
78
+ '4:3': { frame: { 'aspect-ratio': '4 / 3' } },
79
+ '3:2': { frame: { 'aspect-ratio': '3 / 2' } },
80
+ '16:9': { frame: { 'aspect-ratio': '16 / 9' } },
81
+ },
82
+ radius: {
83
+ none: { frame: { 'border-radius': '{radius.none}' } },
84
+ md: { frame: { 'border-radius': '{radius.md}' } },
85
+ xl: { frame: { 'border-radius': '{radius.xl}' } },
86
+ '2xl': { frame: { 'border-radius': '{radius.2xl}' } },
87
+ },
88
+ fit: {
89
+ cover: { media: { 'object-fit': 'cover' } },
90
+ contain: { media: { 'object-fit': 'contain', padding: '{space.4}' } },
91
+ },
92
+ },
93
+ extraCss: `
94
+ @keyframes cn-media-frame-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }`,
95
+ examples: [
96
+ ex('Default (16:9, radius xl, cover)', `<figure class="cn-media-frame" data-ratio="16:9" data-radius="xl" data-fit="cover" style="width:320px"><div class="cn-media-frame__frame">${PLACEHOLDER}</div><figcaption class="cn-media-frame__caption">Osaka port, container terminal · Photo: Nakamura Trading Co.</figcaption></figure>`),
97
+ ex('Square thumbnail, bordered, radius md', `<figure class="cn-media-frame" data-ratio="1:1" data-radius="md" data-fit="contain" data-bordered style="width:96px"><div class="cn-media-frame__frame">${PLACEHOLDER.replace('preserveAspectRatio="xMidYMid slice"', 'preserveAspectRatio="xMidYMid meet"')}</div></figure>`, 'contain keeps logos whole on the subtle fill; the hairline separates light logos from a white card.'),
98
+ ex('4:3 with overlay', `<figure class="cn-media-frame" data-ratio="4:3" data-radius="xl" data-fit="cover" style="width:280px"><div class="cn-media-frame__frame">${PLACEHOLDER}<div class="cn-media-frame__overlay">How agents verify a company · 3:40</div></div></figure>`, 'The overlay is a flat scrim with inverse text; no gradient.'),
99
+ ex('Loading', `<figure class="cn-media-frame" data-ratio="3:2" data-radius="xl" data-fit="cover" data-state="loading" style="width:240px" aria-busy="true"><div class="cn-media-frame__frame">${PLACEHOLDER}</div><figcaption class="cn-media-frame__caption">Loading preview…</figcaption></figure>`),
100
+ ex('Auto ratio, no radius (full bleed)', `<figure class="cn-media-frame" data-ratio="auto" data-radius="none" data-fit="cover" style="width:320px"><div class="cn-media-frame__frame"><svg class="cn-media-frame__media" viewBox="0 0 160 40" role="img" aria-label="Placeholder banner"><rect width="160" height="40" fill="var(--cn-color-bg-muted)"/><path d="M0 40L160 0" stroke="var(--cn-color-border-strong)" stroke-width="1"/></svg></div></figure>`, 'auto takes the media’s own height. Use inside a Card with padding none.'),
101
+ ],
102
+ rules: [
103
+ 'Every image and video in the product lives in a MediaFrame; a raw <img> in a layout is a lint error.',
104
+ 'Pick the ratio from the content, not the space: 1:1 logos and avatars, 4:3 screenshots, 3:2 photos, 16:9 video. Do not stretch a screenshot into 16:9.',
105
+ 'Width comes from the layout (100% of its column); set it inline only in isolated thumbnails.',
106
+ 'Logos and diagrams use fit="contain" and data-bordered; photos use cover without a border.',
107
+ 'The overlay is flat (bg-overlay) with one line of label-md inverse text at the bottom-left. No gradients, no blur, no second line.',
108
+ 'Captions are body-xs muted, one line, below the frame: source, date, or what the picture shows. They are not titles.',
109
+ 'Radius matches the container: md in rows, xl in cards, 2xl on marketing heroes, none when the media bleeds to the card edge.',
110
+ 'Show data-state="loading" only for network images; inline SVGs render immediately.',
111
+ ],
112
+ a11y: [
113
+ 'Every <img> has alt text that says what it shows; decorative images use alt="" and the figure gets no caption.',
114
+ 'Inline SVG media uses role="img" with aria-label; videos have captions or a transcript.',
115
+ 'The figcaption is the accessible description; do not repeat the alt text in it.',
116
+ 'Overlay text keeps AA contrast on the scrim (inverse text on bg-overlay); never place text over unscrimmed media.',
117
+ ],
118
+ related: ['card', 'skeleton', 'avatar'],
119
+ };
@@ -0,0 +1,190 @@
1
+ import { STATE, ex, ICON, typeStyle } from "./_shared.mjs";
2
+ // the reference dropdown: a 248px white panel, radius 8, 1px ring + shadow-lg, 4px vertical
3
+ // padding; items 36px tall (padding 8 × 10 inside a 6px gutter, radius 6, 14px medium gray-700),
4
+ // 16px gray-400 icons 8px before the label, 12px gray-500 shortcut on the right, gray-200
5
+ // separators 4px apart, gray-50 when highlighted, red for destructive, 50% when disabled.
6
+ const TRANSITION_FAST = {
7
+ 'transition-property': 'background-color, color, box-shadow',
8
+ 'transition-duration': '{motion.duration.fast}',
9
+ 'transition-timing-function': '{motion.easing.linear}',
10
+ };
11
+ const HAIRLINE = '{border.width.thin} solid {color.border-default}';
12
+ const icon = (name) => ICON[name].replace('cn-icon', 'cn-menu__icon');
13
+ const CHECK = ICON.check.replace('cn-icon', 'cn-menu__check');
14
+ const item = (label, i = null, shortcut = '', attrs = '') => `<button type="button" role="menuitem" class="cn-menu__item"${attrs}>${i ? icon(i) : ''}<span class="cn-menu__text">${label}</span>${shortcut ? `<kbd class="cn-menu__shortcut">${shortcut}</kbd>` : ''}</button>`;
15
+ const check = (role, label, checked, attrs = '') => `<div role="${role}" tabindex="-1" aria-checked="${checked}" class="cn-menu__item"${attrs}>${CHECK}<span class="cn-menu__text">${label}</span></div>`;
16
+ const SEP = '<div class="cn-menu__separator" role="separator"></div>';
17
+ const label = (id, text) => `<div class="cn-menu__label" id="${id}">${text}</div>`;
18
+ const AVATAR_SRC = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' fill='%23CDCED1'/%3E%3Ccircle cx='32' cy='25' r='11' fill='%23FAFAFA'/%3E%3Cpath d='M10 64c2-13 11-21 22-21s20 8 22 21z' fill='%23FAFAFA'/%3E%3C/svg%3E";
19
+ const person = (name, checked = false) => `<div role="menuitemradio" tabindex="-1" aria-checked="${checked}" class="cn-menu__item"><img class="cn-menu__avatar" src="${AVATAR_SRC}" alt=""><span class="cn-menu__text">${name}</span>${CHECK}</div>`;
20
+ const HEADER = `<div class="cn-menu__header cn-avatar-label-group" data-size="md"><span class="cn-avatar" data-size="md" data-shape="circle" data-tone="neutral" data-status="online" role="img" aria-label="Maya Chen, online"><span class="cn-avatar__fallback" aria-hidden="true">MC</span><span class="cn-avatar__status"></span></span><div class="cn-avatar-label-group__text"><span class="cn-avatar-label-group__name">Maya Chen</span><span class="cn-avatar-label-group__email">maya@lumen.co</span></div></div>`;
21
+ export const menu = {
22
+ name: 'Menu',
23
+ slug: 'menu',
24
+ category: 'overlays',
25
+ description: 'The dropdown panel of the reference: a 248px raised white surface (radius 8, hairline ring, shadow-lg) of 36px items with 16px icons, right-aligned shortcuts, gray-200 separators, section labels, an optional account-card header and a destructive last item. Styles the panel only; positioning is a separate anchor layer.',
26
+ usage: 'Use for a short list of actions on one object (row actions, "More" in a card header, the account menu) and for grouped option toggles (sort, view, switch account). Not for navigation (SidebarNav), not for picking a form value (Select / Combobox), not for anything that needs a search field (CommandPalette).',
27
+ anatomy: [
28
+ { part: 'root', element: 'div', description: 'The panel: role="menu", 248px wide, bg-surface-raised, gray-200 ring, radius 8, shadow-lg, 4px vertical and 6px horizontal padding. data-state="open|closed".' },
29
+ { part: 'header', element: 'div', description: 'Optional account card at the top: an Avatar md with the avatar label group (name + email), 12px padding, hairline below; bleeds to the panel edges.', optional: true },
30
+ { part: 'group', element: 'div', description: 'role="group" wrapping related items, optionally labelled.', optional: true },
31
+ { part: 'label', element: 'div', description: 'Section heading in 12px semibold fg-subtle, padding 6 × 10 (4 below): "Sort by", "Switch account". Referenced by the group\'s aria-labelledby.', optional: true },
32
+ { part: 'item', element: 'button', description: 'One action or option: role="menuitem" | "menuitemcheckbox" | "menuitemradio". 36px tall, padding 8 × 10, radius 6, 14px medium gray-700. Holds icon / avatar / check + text + shortcut or chevron.' },
33
+ { part: 'icon', element: 'svg', description: '16px leading icon, fg-subtle. All items in a group have one, or none do.', optional: true },
34
+ { part: 'avatar', element: 'img', description: '20px round avatar in the leading slot (account switchers, assignees).', optional: true },
35
+ { part: 'check', element: 'svg', description: '16px brand check mark of checkbox / radio items, in the leading slot (or trailing after an avatar); invisible until aria-checked="true" so text stays aligned.', optional: true },
36
+ { part: 'text', element: 'span', description: 'The item label; grows and truncates.', optional: true },
37
+ { part: 'shortcut', element: 'kbd', description: 'Keyboard shortcut in 12px medium fg-subtle, pushed to the end. Only for shortcuts that really work.', optional: true },
38
+ { part: 'chevron', element: 'svg', description: '16px chevron-right at the end of an item that opens a submenu.', optional: true },
39
+ { part: 'separator', element: 'div', description: 'A 1px gray-200 rule bleeding to the panel edges, 4px above and below. role="separator".', optional: true },
40
+ ],
41
+ props: {
42
+ size: {
43
+ values: ['sm', 'md'],
44
+ default: 'md',
45
+ description: 'md = 36px items, 14px medium; the reference. sm = 32px items (padding 6 × 8) for dense table row menus and toolbars.',
46
+ },
47
+ },
48
+ states: {
49
+ open: STATE.open(),
50
+ closed: { selector: '[data-state="closed"]', description: 'Hidden. The panel stays in the DOM for focus management but is display: none.', markup: 'data-state="closed"' },
51
+ highlighted: { selector: ' .cn-menu__item[data-highlighted], & .cn-menu__item:hover', description: 'The item under the pointer or reached by arrow keys (on the ITEM): gray-50 fill, text one step darker.', markup: 'data-highlighted on the item (keyboard) or native :hover' },
52
+ checked: { selector: ' .cn-menu__item[aria-checked="true"]', description: 'A checkbox / radio item that is on: the brand check mark becomes visible.', markup: 'aria-checked="true" on a role="menuitemcheckbox|menuitemradio" item' },
53
+ danger: { selector: ' .cn-menu__item[data-danger]', description: 'Destructive action: fg-danger text and icon, danger-subtle fill when highlighted. Always last, after a separator.', markup: 'data-danger on the item' },
54
+ disabled: { selector: ' .cn-menu__item[aria-disabled="true"]', description: 'Not available now: 50% opacity, cursor not-allowed, no hover. Stays visible and focusable so the user learns it exists.', markup: 'aria-disabled="true" on the item' },
55
+ },
56
+ base: {
57
+ root: {
58
+ display: 'flex',
59
+ 'flex-direction': 'column',
60
+ gap: '{space.0.5}',
61
+ width: 'calc({space.56} + {space.6})',
62
+ 'max-width': '100%',
63
+ padding: '{space.1} {space.1.5}',
64
+ 'background-color': '{color.bg-surface-raised}',
65
+ border: HAIRLINE,
66
+ 'border-radius': '{radius.panel}',
67
+ 'box-shadow': '{shadow.lg}',
68
+ color: '{color.fg-default}',
69
+ outline: 'none',
70
+ },
71
+ header: {
72
+ display: 'flex',
73
+ 'align-items': 'center',
74
+ gap: '{space.3}',
75
+ padding: '{space.3} {space.4}',
76
+ margin: 'calc({space.1} * -1) calc({space.1.5} * -1) {space.1}',
77
+ 'border-bottom': HAIRLINE,
78
+ 'min-width': '0',
79
+ },
80
+ group: { display: 'flex', 'flex-direction': 'column', gap: '{space.0.5}' },
81
+ label: {
82
+ ...typeStyle('label-xs'),
83
+ 'font-weight': '{font.weight.semibold}',
84
+ color: '{color.fg-subtle}',
85
+ padding: '{space.1.5} {space.2.5} {space.1}',
86
+ 'user-select': 'none',
87
+ },
88
+ item: {
89
+ appearance: 'none',
90
+ '-webkit-appearance': 'none',
91
+ border: '0',
92
+ margin: '0',
93
+ background: 'none',
94
+ font: 'inherit',
95
+ 'text-align': 'start',
96
+ width: '100%',
97
+ display: 'flex',
98
+ 'align-items': 'center',
99
+ gap: '{space.2}',
100
+ 'flex-shrink': '0',
101
+ height: '{space.9}',
102
+ padding: '{space.2} {space.2.5}',
103
+ 'border-radius': '{radius.md}',
104
+ ...typeStyle('label-sm'),
105
+ color: '{color.fg-muted}',
106
+ 'text-decoration': 'none',
107
+ cursor: 'pointer',
108
+ 'user-select': 'none',
109
+ 'white-space': 'nowrap',
110
+ outline: 'none',
111
+ ...TRANSITION_FAST,
112
+ },
113
+ icon: { width: '{size.icon.sm}', height: '{size.icon.sm}', 'flex-shrink': '0', color: '{color.fg-subtle}', transition: 'inherit' },
114
+ avatar: { width: '{size.icon.md}', height: '{size.icon.md}', 'border-radius': '{radius.full}', 'object-fit': 'cover', 'flex-shrink': '0', 'background-color': '{color.bg-muted}', 'max-width': 'none' },
115
+ check: { width: '{size.icon.sm}', height: '{size.icon.sm}', 'flex-shrink': '0', visibility: 'hidden', color: '{brand.600}' },
116
+ text: { flex: '1 1 auto', 'min-width': '0', overflow: 'hidden', 'text-overflow': 'ellipsis' },
117
+ shortcut: {
118
+ ...typeStyle('label-xs'),
119
+ color: '{color.fg-subtle}',
120
+ 'margin-inline-start': 'auto',
121
+ 'padding-inline': '{space.1} {space.1}',
122
+ 'flex-shrink': '0',
123
+ },
124
+ chevron: { width: '{size.icon.sm}', height: '{size.icon.sm}', 'flex-shrink': '0', 'margin-inline-start': 'auto', color: '{color.fg-subtle}' },
125
+ separator: {
126
+ height: '0',
127
+ 'border-top': HAIRLINE,
128
+ margin: '{space.1} calc({space.1.5} * -1)',
129
+ },
130
+ '@states': {
131
+ open: { root: { display: 'flex' } },
132
+ closed: { root: { display: 'none' } },
133
+ },
134
+ },
135
+ variants: {
136
+ size: {
137
+ sm: {
138
+ item: { height: '{space.8}', padding: '{space.1.5} {space.2}' },
139
+ label: { padding: '{space.1} {space.2} {space.1}' },
140
+ },
141
+ md: { root: {} },
142
+ },
143
+ },
144
+ extraCss: `
145
+ .cn-menu__item[data-highlighted], .cn-menu__item:hover:not([aria-disabled="true"]), .cn-menu__item:focus-visible { background-color: {color.bg-subtle}; color: {color.fg-default}; }
146
+ .cn-menu__item[data-highlighted] .cn-menu__icon, .cn-menu__item:hover:not([aria-disabled="true"]) .cn-menu__icon { color: {color.fg-muted}; }
147
+ .cn-menu__item:focus-visible { box-shadow: inset 0 0 0 2px {brand.500}; }
148
+ .cn-menu__item[data-danger] { color: {color.fg-danger}; }
149
+ .cn-menu__item[data-danger] .cn-menu__icon { color: {red.500}; }
150
+ .cn-menu__item[data-danger][data-highlighted], .cn-menu__item[data-danger]:hover:not([aria-disabled="true"]), .cn-menu__item[data-danger]:focus-visible { background-color: {color.bg-danger-subtle}; color: {color.fg-danger}; }
151
+ .cn-menu__item[aria-disabled="true"] { opacity: {opacity.disabled}; cursor: not-allowed; }
152
+ .cn-menu__item[aria-checked="true"] .cn-menu__check { visibility: visible; }
153
+ .cn-menu__item > .cn-menu__avatar + .cn-menu__text + .cn-menu__check { margin-inline-start: auto; }
154
+ .cn-menu-anchor { position: relative; display: inline-block; }
155
+ .cn-menu[data-floating] { position: absolute; top: calc(100% + {space.1}); inset-inline-start: 0; z-index: {z.dropdown}; }
156
+ .cn-menu[data-floating][data-align="end"] { inset-inline-start: auto; inset-inline-end: 0; }
157
+ .cn-menu[data-floating][data-side="top"] { top: auto; bottom: calc(100% + {space.1}); }`,
158
+ examples: [
159
+ ex('Actions on one object', `<div class="cn-menu" data-size="md" role="menu" aria-label="Project actions" data-state="open">${item('View project', 'external', '↵')}${item('Edit', 'settings', '⌘E')}${item('Duplicate', 'copy', '⌘D')}${item('Move to…', 'arrow')}${SEP}${item('Delete', 'trash', '⌫', ' data-danger')}</div>`, '248px panel; verbs in sentence case, "…" when the item opens a dialog. Delete is last, after a separator, in red.'),
160
+ ex('Account menu with header', `<div class="cn-menu" data-size="md" role="menu" aria-label="Account" data-state="open">${HEADER}${item('View profile', 'user', '⌘K→P')}${item('Settings', 'settings', '⌘S')}${item('Keyboard shortcuts', 'info', '?')}${SEP}<div class="cn-menu__group" role="group" aria-labelledby="menu-switch">${label('menu-switch', 'Switch account')}${person('Maya Chen', true)}${person('Daniel Costa')}</div>${SEP}${item('Log out', 'external', '⌥⇧Q')}</div>`, 'The header is the account card (Avatar md + name + email); the "Switch account" section uses 20px avatars with the check on the right.'),
161
+ ex('Grouped options with checks', `<div class="cn-menu" data-size="md" role="menu" aria-label="View options" data-state="open"><div class="cn-menu__group" role="group" aria-labelledby="menu-sort">${label('menu-sort', 'Sort by')}${check('menuitemradio', 'Newest first', true)}${check('menuitemradio', 'Oldest first', false)}${check('menuitemradio', 'Name', false)}</div>${SEP}<div class="cn-menu__group" role="group" aria-labelledby="menu-show">${label('menu-show', 'Show')}${check('menuitemcheckbox', 'Archived projects', true)}${check('menuitemcheckbox', 'Only mine', false)}</div></div>`, 'The brand check occupies the leading slot even when hidden, so labels align.'),
162
+ ex('Submenu, highlighted and disabled items', `<div class="cn-menu" data-size="md" role="menu" aria-label="Row actions" data-state="open">${item('Assign to…', 'user', '', ' data-highlighted')}<button type="button" role="menuitem" class="cn-menu__item" aria-haspopup="menu" aria-expanded="false">${icon('inbox')}<span class="cn-menu__text">Share</span>${ICON.chevronRight.replace('cn-icon', 'cn-menu__chevron')}</button>${item('Snooze', 'calendar', 'S')}${item('Mark as done', 'check', 'E', ' aria-disabled="true"')}</div>`, 'data-highlighted is what keyboard navigation sets; a submenu item ends in a chevron; disabled items stay visible at 50%.'),
163
+ ex('Small', `<div class="cn-menu" data-size="sm" role="menu" aria-label="Cell actions" data-state="open">${item('Copy', 'copy', '⌘C')}${item('Paste', 'inbox', '⌘V')}${SEP}${item('Clear', 'trash', '', ' data-danger')}</div>`, '32px items for dense tables.'),
164
+ ex('Closed (in the DOM, hidden)', `<div class="cn-menu" data-size="md" role="menu" aria-label="Hidden menu" data-state="closed">${item('You should not see this', 'info')}</div><span class="cn-text-body-sm">data-state="closed" renders nothing.</span>`),
165
+ ],
166
+ recipes: [
167
+ ex('Anchored to a trigger', `<div class="cn-menu-anchor" style="min-height:220px"><button type="button" class="cn-icon-button" data-variant="outline" data-size="md" data-shape="square" aria-label="More actions" aria-haspopup="menu" aria-expanded="true" aria-controls="menu-anchored" id="menu-anchored-trigger">${ICON.dots.replace('cn-icon', 'cn-icon-button__icon')}</button><div class="cn-menu" data-size="md" role="menu" id="menu-anchored" aria-labelledby="menu-anchored-trigger" data-state="open" data-floating>${item('Rename', 'settings')}${item('Duplicate', 'copy', '⌘D')}${SEP}${item('Delete', 'trash', '', ' data-danger')}</div></div>`, 'Wrap trigger + panel in .cn-menu-anchor; data-floating positions the panel 4px under the trigger. Add data-align="end" to right-align, data-side="top" to flip.'),
168
+ ],
169
+ rules: [
170
+ 'Max 7 items per group and about 12 in total. Beyond that, the object has too many actions; move rare ones into a dialog or a settings page.',
171
+ 'Items are verbs in sentence case ("Duplicate", "Move to…"); "…" when the item opens a dialog. Options (sort, show) are nouns with checks.',
172
+ 'Items are 36px (sm 32) with 14px medium text; icons 16px fg-subtle, all-or-none within a group; shortcuts 12px on the right only when the key really works.',
173
+ 'Destructive actions are last, after a separator, marked data-danger, and always confirm.',
174
+ 'The panel is 248px wide (or wider to fit the trigger when the trigger is a full-width control); never narrower.',
175
+ 'Position with .cn-menu-anchor + data-floating (4px below the trigger, aligned to its start; data-align="end" to align to its end, data-side="top" to flip). One open menu at a time.',
176
+ 'The account header is the only place for an avatar label group inside a menu; items use 20px avatars.',
177
+ 'No submenus deeper than one level; the chevron item opens a second panel to the side.',
178
+ 'Disabled items stay visible with aria-disabled and explain themselves in a Tooltip; hiding them makes the menu feel unstable.',
179
+ 'The panel styles only; put backdrop, focus trap and scroll lock in the app layer, never in the component.',
180
+ ],
181
+ a11y: [
182
+ 'Root has role="menu" and aria-labelledby pointing at the trigger (or aria-label). The trigger has aria-haspopup="menu", aria-expanded and aria-controls.',
183
+ 'Items are role="menuitem", "menuitemcheckbox" or "menuitemradio" with aria-checked; groups are role="group" with aria-labelledby; separators are role="separator".',
184
+ 'Keyboard: Down/Up move highlight (wrapping), Home/End jump, Enter/Space activate, Right opens a submenu, Esc closes and returns focus to the trigger, typing jumps to a matching item.',
185
+ 'Only one item is tabbable (roving tabindex); highlight is set with data-highlighted and reflected by aria-activedescendant or focus.',
186
+ 'Disabled items use aria-disabled="true" and remain focusable so they can be announced; never the disabled attribute.',
187
+ 'Avatars inside items are decorative (alt=""); the text names the person.',
188
+ ],
189
+ related: ['icon-button', 'button-group', 'command-palette', 'popover', 'select', 'avatar'],
190
+ };
@@ -0,0 +1,249 @@
1
+ import { FOCUS_RING, TRANSITION_COLORS, ex, typeStyle } from "./_shared.mjs";
2
+ // Chat messaging: a thread of bubbles (incoming on the left in bg-subtle, outgoing
3
+ // on the right in the action color), each with a name + time line, optional file or
4
+ // image attachment inside the bubble, a three-dot typing indicator and a composer
5
+ // with a toolbar and a primary Send button.
6
+ const HAIRLINE = '{border.width.thin} solid {color.border-control}';
7
+ const SVG = {
8
+ file: '<svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4.5 2.5h4.5l3 3v8a.5.5 0 01-.5.5h-7a.5.5 0 01-.5-.5v-11z"/><path d="M9 2.5v3h3"/></svg>',
9
+ clip: '<svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.5 5.5l-4.25 4.25a1.5 1.5 0 002.12 2.12L13 7.25a2.83 2.83 0 00-4-4L4.25 8a4.24 4.24 0 006 6l3.25-3.25"/></svg>',
10
+ smile: '<svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><circle cx="8" cy="8" r="6"/><path d="M5.5 9.5s.9 1.5 2.5 1.5 2.5-1.5 2.5-1.5M6 6.25v.5M10 6.25v.5"/></svg>',
11
+ mic: '<svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><rect x="6" y="2.5" width="4" height="7" rx="2"/><path d="M4 8a4 4 0 008 0M8 12v1.5"/></svg>',
12
+ send: '<svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 8l11-5.5-3 11-2.5-4.5L2.5 8z"/><path d="M8 9L13.5 2.5"/></svg>',
13
+ };
14
+ /** Muted 4:3 picture so the image attachment renders without the network. */
15
+ const PICTURE = `<svg viewBox="0 0 320 240" role="img" aria-label="Photo of the new office lobby"><rect width="320" height="240" style="fill:var(--cn-color-bg-muted)"/><circle cx="236" cy="70" r="28" style="fill:var(--cn-color-bg-surface)"/><path d="M0 200L84 116l64 56 52-70 120 98v40H0z" style="fill:var(--cn-color-border-strong)"/></svg>`;
16
+ const iconBtn = (svg, label) => `<button type="button" class="cn-icon-button" data-variant="ghost" data-size="sm" data-shape="square" aria-label="${label}">${svg.replace('cn-icon', 'cn-icon-button__icon')}</button>`;
17
+ const avatar = (initials, name) => `<span class="cn-message__avatar" role="img" aria-label="${name}">${initials}</span>`;
18
+ const msg = (o) => `<div class="cn-message__item" data-direction="${o.dir}">${o.dir === 'in' && o.initials ? avatar(o.initials, o.name) : ''}<div class="cn-message__content"><div class="cn-message__meta"><span class="cn-message__name">${o.name}</span><time class="cn-message__time" datetime="${o.datetime}">${o.time}</time></div><div class="cn-message__bubble">${o.body}</div></div></div>`;
19
+ const fileCard = (name, meta) => `<a href="#" class="cn-message__attachment"><span class="cn-message__attachment-icon" aria-hidden="true">${SVG.file}</span><span><span class="cn-message__attachment-name">${name}</span><span class="cn-message__attachment-meta">${meta}</span></span></a>`;
20
+ const typing = (initials, name) => `<div class="cn-message__item" data-direction="in">${avatar(initials, name)}<div class="cn-message__content"><div class="cn-message__typing" role="status" aria-label="${name} is typing"><span class="cn-message__dot"></span><span class="cn-message__dot"></span><span class="cn-message__dot"></span></div></div></div>`;
21
+ const divider = (text) => `<div class="cn-message__date-divider" role="separator">${text}</div>`;
22
+ const COMPOSER = `<div class="cn-message__composer"><textarea class="cn-message__composer-input" rows="2" placeholder="Message Maya Chen…" aria-label="Message"></textarea><div class="cn-message__composer-toolbar"><div class="cn-message__composer-tools">${iconBtn(SVG.clip, 'Attach a file')}${iconBtn(SVG.smile, 'Add emoji')}${iconBtn(SVG.mic, 'Record a voice note')}</div><button type="button" class="cn-button" data-variant="primary" data-size="sm"><span class="cn-button__label">Send</span>${SVG.send.replace('cn-icon', 'cn-button__icon')}</button></div></div>`;
23
+ const thread = (variant, density, inner, width = '560px') => `<div class="cn-message" data-variant="${variant}" data-density="${density}" role="log" aria-label="Conversation with Maya Chen" style="width:100%;max-width:${width}">${inner}</div>`;
24
+ export const message = {
25
+ name: 'Message',
26
+ slug: 'message',
27
+ category: 'data-display',
28
+ description: 'Chat messages in a thread: incoming bubbles on the left in bg-subtle, outgoing on the right in the action color, each with a name and time line, a 32px avatar, optional file or image attachments, a three-dot typing indicator, centered date chips and a composer with a toolbar and a primary Send.',
29
+ usage: 'Use for two-way conversations in real time: support chat, a thread with a teammate, the transcript of an agent conversation. For a comment stream on a document use ActivityFeed; for a timeline of events use Timeline; for one-off notifications use Notification.',
30
+ anatomy: [
31
+ { part: 'root', element: 'div', description: 'The thread (role="log"): a flex column of items and date dividers with a 16px gap. Width comes from the layout.' },
32
+ { part: 'date-divider', element: 'div', description: 'Centered chip that separates days ("Today", "Sep 9"). role="separator".', optional: true },
33
+ { part: 'item', element: 'div', description: 'One message: avatar + content in a row. data-direction="in" (received, left) or "out" (sent, right, mirrored).' },
34
+ { part: 'avatar', element: 'span', description: '32px circle with initials (24px in compact), aligned to the bottom of the bubble. Only on incoming messages; role="img" + aria-label.', optional: true },
35
+ { part: 'content', element: 'div', description: 'Column with the meta line and the bubble, aligned to the start (in) or the end (out).' },
36
+ { part: 'meta', element: 'div', description: 'Name and time on one line above the bubble.' },
37
+ { part: 'name', element: 'span', description: 'Sender name, label-sm ("Maya Chen", "You").' },
38
+ { part: 'time', element: 'time', description: 'Sent time, body-xs fg-subtle, a <time datetime>.' },
39
+ { part: 'bubble', element: 'div', description: 'The message body, max 70% of the thread width, 10/14px padding, 16/24 text, radius 8 with the corner nearest the sender squared. Holds text and optional attachments.' },
40
+ { part: 'attachment', element: 'a', description: 'A file card (icon + name + meta) or, with data-kind="image", a picture, inside the bubble. Links to the file.', optional: true },
41
+ { part: 'attachment-icon', element: 'span', description: '32px square with a 16px file icon.', optional: true },
42
+ { part: 'attachment-name', element: 'span', description: 'File name, label-sm, truncated.', optional: true },
43
+ { part: 'attachment-meta', element: 'span', description: 'Size or type under the name, body-xs, muted.', optional: true },
44
+ { part: 'typing', element: 'div', description: 'Three animated dots in an incoming-style bubble. role="status" with "{Name} is typing".', optional: true },
45
+ { part: 'dot', element: 'span', description: 'One 6px dot of the typing indicator; the three dots bounce in sequence.', optional: true },
46
+ { part: 'composer', element: 'div', description: 'The reply box: hairline border, radius card, focus ring on :focus-within. Holds the textarea and the toolbar.', optional: true },
47
+ { part: 'composer-input', element: 'textarea', description: 'Bare <textarea>, two rows, no border; grows with content in the app.', optional: true },
48
+ { part: 'composer-toolbar', element: 'div', description: 'Bottom row of the composer: tool icon buttons on the left, the primary sm Send on the right.', optional: true },
49
+ { part: 'composer-tools', element: 'div', description: 'The group of ghost IconButtons (attach, emoji, voice) that fills the toolbar\'s left side.', optional: true },
50
+ ],
51
+ props: {
52
+ variant: {
53
+ values: ['thread', 'composer', 'bubble'],
54
+ default: 'thread',
55
+ description: 'thread = the full conversation column with dividers, messages, typing and optionally a composer at the end. composer = the root holds only the reply box (a chat that lives elsewhere, a "quick reply" footer). bubble = the root holds a single message, for embedding one quoted message in another surface.',
56
+ },
57
+ density: {
58
+ values: ['default', 'compact'],
59
+ default: 'default',
60
+ description: 'default = 32px avatars, 16/24 text, 16px between messages (full-page chat). compact = 24px avatars, 14px text, 8px between messages, for side panels and support widgets.',
61
+ },
62
+ },
63
+ states: {
64
+ incoming: { selector: ' .cn-message__item[data-direction="in"]', description: 'A received message (on the item): avatar on the left, bg-subtle bubble with the top-left corner squared. Styled in extraCss.', markup: 'data-direction="in" on .cn-message__item' },
65
+ outgoing: { selector: ' .cn-message__item[data-direction="out"]', description: 'A sent message (on the item): mirrored to the right, action-colored bubble with the top-right corner squared, no avatar. Styled in extraCss.', markup: 'data-direction="out" on .cn-message__item' },
66
+ composerFocus: { selector: ' .cn-message__composer:focus-within', description: 'Typing in the composer: the border turns action-colored and the 3px ring appears (extraCss).', markup: 'native :focus-within on .cn-message__composer' },
67
+ composerDisabled: { selector: ' .cn-message__composer[data-disabled]', description: 'The conversation is closed or read-only: grey fill, muted text, textarea disabled (extraCss).', markup: 'data-disabled on .cn-message__composer plus disabled on the textarea' },
68
+ },
69
+ base: {
70
+ root: { display: 'flex', 'flex-direction': 'column', gap: '{space.4}', width: '100%', 'min-width': '0', color: '{color.fg-default}' },
71
+ 'date-divider': {
72
+ 'align-self': 'center',
73
+ display: 'inline-flex',
74
+ 'align-items': 'center',
75
+ height: '{space.6}',
76
+ 'padding-inline': '{space.2.5}',
77
+ 'border-radius': '{radius.full}',
78
+ 'background-color': '{color.bg-subtle}',
79
+ ...typeStyle('label-xs'),
80
+ color: '{color.fg-subtle}',
81
+ 'user-select': 'none',
82
+ },
83
+ item: { display: 'flex', 'align-items': 'flex-end', gap: '{space.2}', 'max-width': '100%', 'min-width': '0' },
84
+ avatar: {
85
+ display: 'inline-flex',
86
+ 'align-items': 'center',
87
+ 'justify-content': 'center',
88
+ 'flex-shrink': '0',
89
+ width: '{space.8}',
90
+ height: '{space.8}',
91
+ 'border-radius': '{radius.full}',
92
+ 'background-color': '{color.bg-subtle}',
93
+ color: '{color.fg-muted}',
94
+ ...typeStyle('label-xs'),
95
+ 'letter-spacing': '{font.letterSpacing.wide}',
96
+ 'text-transform': 'uppercase',
97
+ 'user-select': 'none',
98
+ overflow: 'hidden',
99
+ },
100
+ content: { flex: '1 1 auto', 'min-width': '0', display: 'flex', 'flex-direction': 'column', 'align-items': 'flex-start', gap: '{space.1.5}' },
101
+ meta: { display: 'flex', 'align-items': 'baseline', gap: '{space.2}', 'padding-inline': '{space.1}' },
102
+ name: { ...typeStyle('label-sm'), 'line-height': '{font.lineHeight.normal}', color: '{color.fg-default}' },
103
+ time: { ...typeStyle('body-xs'), color: '{color.fg-subtle}', 'white-space': 'nowrap' },
104
+ bubble: {
105
+ display: 'flex',
106
+ 'flex-direction': 'column',
107
+ gap: '{space.2}',
108
+ 'max-width': '70%',
109
+ padding: '{space.2.5} {space.3.5}',
110
+ ...typeStyle('body-lg'),
111
+ 'line-height': '{font.lineHeight.normal}',
112
+ 'border-radius': '{radius.lg}',
113
+ 'background-color': '{color.bg-subtle}',
114
+ color: '{color.fg-default}',
115
+ 'overflow-wrap': 'anywhere',
116
+ },
117
+ attachment: {
118
+ display: 'flex',
119
+ 'align-items': 'center',
120
+ gap: '{space.3}',
121
+ 'min-width': '0',
122
+ padding: '{space.2.5} {space.3}',
123
+ 'border-radius': '{radius.md}',
124
+ 'background-color': '{color.bg-surface}',
125
+ border: '{border.width.thin} solid {color.border-default}',
126
+ color: 'inherit',
127
+ 'text-decoration': 'none',
128
+ ...TRANSITION_COLORS,
129
+ },
130
+ 'attachment-icon': { display: 'inline-flex', 'align-items': 'center', 'justify-content': 'center', 'flex-shrink': '0', width: '{space.8}', height: '{space.8}', 'border-radius': '{radius.md}', 'background-color': '{color.bg-subtle}', color: '{color.fg-muted}' },
131
+ 'attachment-name': { display: 'block', ...typeStyle('label-sm'), 'line-height': '{font.lineHeight.normal}', 'min-width': '0', overflow: 'hidden', 'text-overflow': 'ellipsis', 'white-space': 'nowrap' },
132
+ 'attachment-meta': { display: 'block', ...typeStyle('body-xs'), opacity: '{opacity.muted}' },
133
+ typing: {
134
+ display: 'inline-flex',
135
+ 'align-items': 'center',
136
+ gap: '{space.1}',
137
+ padding: '{space.3.5} {space.3.5}',
138
+ 'border-radius': '{radius.lg}',
139
+ 'border-start-start-radius': '{radius.none}',
140
+ 'background-color': '{color.bg-subtle}',
141
+ 'align-self': 'flex-start',
142
+ },
143
+ dot: { width: '6px', height: '6px', 'border-radius': '{radius.full}', 'background-color': '{color.fg-subtle}', animation: 'cn-message-typing 1.2s {motion.easing.standard} infinite' },
144
+ composer: {
145
+ display: 'flex',
146
+ 'flex-direction': 'column',
147
+ width: '100%',
148
+ 'min-width': '0',
149
+ 'background-color': '{color.bg-surface}',
150
+ border: HAIRLINE,
151
+ 'border-radius': '{radius.card}',
152
+ 'box-shadow': '{shadow.xs}',
153
+ overflow: 'hidden',
154
+ ...TRANSITION_COLORS,
155
+ },
156
+ 'composer-input': {
157
+ display: 'block',
158
+ width: '100%',
159
+ 'min-height': 'calc(2 * {font.size.md} * {font.lineHeight.normal} + 2 * {space.3})',
160
+ margin: '0',
161
+ padding: '{space.3} {space.3.5}',
162
+ border: '0',
163
+ outline: 'none',
164
+ background: 'transparent',
165
+ ...typeStyle('body-md'),
166
+ color: 'inherit',
167
+ resize: 'none',
168
+ },
169
+ 'composer-toolbar': { display: 'flex', 'align-items': 'center', gap: '{space.2}', padding: '0 {space.2} {space.2} {space.2}' },
170
+ 'composer-tools': { display: 'flex', 'align-items': 'center', gap: '{space.0.5}', flex: '1 1 auto', 'min-width': '0' },
171
+ },
172
+ variants: {
173
+ variant: {
174
+ thread: { root: {} },
175
+ composer: { root: { gap: '0' } },
176
+ bubble: { root: { gap: '0' } },
177
+ },
178
+ density: {
179
+ default: { root: {} },
180
+ compact: {
181
+ root: { gap: '{space.2}' },
182
+ item: { gap: '{space.1.5}' },
183
+ avatar: { width: '{space.6}', height: '{space.6}', 'font-size': '{font.size.2xs}' },
184
+ bubble: { padding: '{space.2} {space.3}', ...typeStyle('body-md') },
185
+ meta: { gap: '{space.1.5}' },
186
+ typing: { padding: '{space.2.5} {space.3}' },
187
+ 'composer-input': { 'min-height': 'calc(1 * {font.size.md} * {font.lineHeight.normal} + 2 * {space.2.5})', padding: '{space.2.5} {space.3}' },
188
+ },
189
+ },
190
+ },
191
+ extraCss: `
192
+ .cn-message__item[data-direction="in"] .cn-message__bubble { border-start-start-radius: {radius.none}; }
193
+ .cn-message__item[data-direction="out"] { flex-direction: row-reverse; }
194
+ .cn-message__item[data-direction="out"] .cn-message__content { align-items: flex-end; }
195
+ .cn-message__item[data-direction="out"] .cn-message__meta { flex-direction: row-reverse; }
196
+ .cn-message__item[data-direction="out"] .cn-message__bubble { background-color: {color.bg-action}; color: {color.fg-on-action}; border-start-end-radius: {radius.none}; }
197
+ .cn-message__item[data-direction="out"] .cn-message__attachment { background-color: color-mix(in srgb, {color.fg-on-action} 12%, transparent); border-color: color-mix(in srgb, {color.fg-on-action} 24%, transparent); }
198
+ .cn-message__item[data-direction="out"] .cn-message__attachment-icon { background-color: color-mix(in srgb, {color.fg-on-action} 16%, transparent); color: inherit; }
199
+ .cn-message__attachment:hover { border-color: {color.border-control-hover}; }
200
+ .cn-message__attachment > span:last-child { min-width: 0; flex: 1 1 auto; }
201
+ .cn-message__attachment:focus-visible { outline: none; box-shadow: ${FOCUS_RING['box-shadow']}; }
202
+ .cn-message__attachment-icon .cn-icon { width: {size.icon.md}; height: {size.icon.md}; }
203
+ .cn-message__attachment[data-kind="image"] { display: block; padding: 0; border: 0; overflow: hidden; border-radius: {radius.md}; max-width: 320px; }
204
+ .cn-message__attachment[data-kind="image"] > svg, .cn-message__attachment[data-kind="image"] > img { display: block; width: 100%; height: auto; }
205
+ .cn-message__dot:nth-child(2) { animation-delay: 0.2s; }
206
+ .cn-message__dot:nth-child(3) { animation-delay: 0.4s; }
207
+ @keyframes cn-message-typing { 0%, 60%, 100% { transform: translateY(0); opacity: {opacity.disabled}; } 30% { transform: translateY(-3px); opacity: 1; } }
208
+ .cn-message__composer:focus-within { border-color: {color.border-action}; box-shadow: ${FOCUS_RING['box-shadow']}; }
209
+ .cn-message__composer-input::placeholder { color: {color.fg-placeholder}; opacity: 1; }
210
+ .cn-message__composer[data-disabled] { background-color: {color.bg-disabled}; color: {color.fg-disabled}; border-color: {color.border-disabled}; box-shadow: none; }
211
+ .cn-message__composer[data-disabled] .cn-message__composer-input { cursor: not-allowed; }`,
212
+ examples: [
213
+ ex('Thread with an attachment and typing', thread('thread', 'default', [
214
+ divider('Today'),
215
+ msg({ dir: 'in', name: 'Maya Chen', initials: 'MC', time: '09:12', datetime: '2026-09-11T09:12', body: 'Morning! Did the Q3 launch plan land in your inbox?' }),
216
+ msg({ dir: 'out', name: 'You', time: '09:14', datetime: '2026-09-11T09:14', body: 'It did — reading it now. Two questions about the pricing page.' }),
217
+ msg({ dir: 'in', name: 'Maya Chen', initials: 'MC', time: '09:16', datetime: '2026-09-11T09:16', body: `Here is the latest deck, the pricing slides start on page 12.${fileCard('q3-launch-plan-v4.pdf', 'PDF · 2.4 MB')}` }),
218
+ msg({ dir: 'out', name: 'You', time: '09:20', datetime: '2026-09-11T09:20', body: 'Perfect, thanks. I will leave comments before lunch.' }),
219
+ typing('MC', 'Maya Chen'),
220
+ ].join('')), 'Received on the left with an avatar, sent on the right in the action color. The typing indicator is an incoming bubble with three bouncing dots.'),
221
+ ex('Composer', thread('composer', 'default', COMPOSER), 'Attach, emoji and voice as ghost IconButtons; Send is the only primary button. Enter sends, Shift+Enter adds a line.'),
222
+ ex('Single bubble', thread('bubble', 'default', msg({ dir: 'in', name: 'Daniel Costa', initials: 'DC', time: 'Yesterday · 17:40', datetime: '2026-09-10T17:40', body: 'Reminder: the brand review moved to Thursday at 10:00.' }), '480px'), 'One message embedded in another surface (a notification, a quoted reply).'),
223
+ ex('Image attachment, compact density', thread('thread', 'compact', [
224
+ msg({ dir: 'out', name: 'You', time: '12:02', datetime: '2026-09-11T12:02', body: `The lobby signage went up this morning.<a href="#" class="cn-message__attachment" data-kind="image" aria-label="Open photo">${PICTURE}</a>` }),
225
+ msg({ dir: 'in', name: 'Sofia Almeida', initials: 'SA', time: '12:05', datetime: '2026-09-11T12:05', body: 'Looks great in daylight. Can you send the night shot too?' }),
226
+ msg({ dir: 'out', name: 'You', time: '12:06', datetime: '2026-09-11T12:06', body: 'On it.' }),
227
+ ].join(''), '420px'), 'compact = 24px avatars, 14px text, 8px between messages, for side panels and widgets.'),
228
+ ex('Disabled composer', thread('composer', 'default', COMPOSER.replace('class="cn-message__composer"', 'class="cn-message__composer" data-disabled').replace('rows="2" placeholder="Message Maya Chen…"', 'rows="2" disabled placeholder="This conversation is closed"')), 'A closed conversation keeps the composer visible but disabled, with the reason as placeholder.'),
229
+ ],
230
+ rules: [
231
+ 'Incoming on the left with an avatar, outgoing on the right without one. Never color-code people; direction is the only distinction.',
232
+ 'Bubbles are at most 70% of the thread width and wrap text; never a fixed width, never a scrollbar inside a bubble.',
233
+ 'Every message shows the sender and a time. Group consecutive messages from the same sender within 5 minutes under one meta line.',
234
+ 'Date dividers between days only ("Today", "Yesterday", "Sep 9"); never a divider between every message.',
235
+ 'Attachments live inside the bubble: a file card for documents, a picture (max 320px wide, radius md) for images. One attachment per bubble; more than one becomes a list.',
236
+ 'The typing indicator shows for one person at a time and disappears after 5 seconds of silence; it is never shown for the current user.',
237
+ 'The composer is the only bordered surface in the thread; tools are ghost IconButtons and Send is a primary sm Button. Enter sends, Shift+Enter breaks the line.',
238
+ 'Compact density only in side panels and widgets narrower than 480px.',
239
+ 'Long threads load older messages above with a "Load earlier messages" link Button; keep the newest message and the composer in view.',
240
+ ],
241
+ a11y: [
242
+ 'Root is role="log" with an aria-label naming the conversation; new messages are appended so screen readers announce them politely.',
243
+ 'Each message exposes sender and time as text in DOM order before the body; the direction mirroring is CSS-only.',
244
+ 'Avatars with initials use role="img" + aria-label; sent messages have no avatar and say "You" in the name.',
245
+ 'The typing indicator is role="status" with an aria-label such as "Maya Chen is typing"; the dots are decorative.',
246
+ 'The composer textarea has an accessible name, tool buttons have aria-labels, and the Send button is a real <button>; Escape clears a draft only after a confirm.',
247
+ ],
248
+ related: ['activity-feed', 'avatar', 'textarea', 'icon-button', 'button', 'notification'],
249
+ };