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,117 @@
1
+ import { CONTROL, SIZE_PROP, ex, typeStyle } from "./_shared.mjs";
2
+ // the reference textarea: padding 12px (sm) / 12×14px (md, lg), 14px/20px text (16px/24px on lg;
3
+ // the reference uses 16px on md too — we keep 14px as the default UI size), 1px inset gray-300
4
+ // ring under shadow-xs, radius 8, focus = brand ring, invalid = red-300 ring, disabled = 50%.
5
+ // Height comes from rows (rows="3" by default in our forms; the browser default is 2).
6
+ const TRANSITION_FAST = {
7
+ 'transition-property': 'box-shadow, background-color, color, opacity',
8
+ 'transition-duration': '{motion.duration.fast}',
9
+ 'transition-timing-function': '{motion.easing.linear}',
10
+ };
11
+ const ring = (color) => `inset 0 0 0 1px ${color}`;
12
+ const RING_REST = `${ring('{color.border-control}')}, {shadow.xs}`;
13
+ const RING_HOVER = `${ring('{color.border-control-hover}')}, {shadow.xs}`;
14
+ const RING_FOCUS = `${ring('{color.border-action}')}, {shadow.xs}, {shadow.focus}`;
15
+ const RING_INVALID = `${ring('{color.border-danger}')}, {shadow.xs}`;
16
+ const RING_INVALID_FOCUS = `${ring('{color.border-danger}')}, {shadow.xs}, {shadow.focus-danger}`;
17
+ const SIZE = {
18
+ sm: { px: '{space.3}', py: '{space.3}', lh: '{type.body-md.lineHeight}' },
19
+ md: { px: '{space.3.5}', py: '{space.3}', lh: '{type.body-md.lineHeight}' },
20
+ lg: { px: '{space.3.5}', py: '{space.3}', lh: '{type.body-lg.lineHeight}' },
21
+ };
22
+ const sizeBlock = (s) => ({
23
+ field: { 'padding-inline': SIZE[s].px, 'padding-block': SIZE[s].py, 'font-size': CONTROL[s].font, 'line-height': SIZE[s].lh },
24
+ });
25
+ const ta = (attrs, body = '', size = 'md', resize = 'vertical', style = 'max-width:480px') => `<div class="cn-textarea" data-size="${size}" data-resize="${resize}" style="${style}"><textarea class="cn-textarea__field" rows="3" ${attrs}>${body}</textarea></div>`;
26
+ export const textarea = {
27
+ name: 'Textarea',
28
+ slug: 'textarea',
29
+ category: 'forms',
30
+ description: 'Multi-line text field with the same ring, radius 8, shadow-xs and focus ring as Input. Padding 12px (sm) or 12×14px (md, lg); the height comes from rows. The wrapper carries the ring; the native textarea is bare.',
31
+ usage: 'Free-form text longer than one line: notes, messages, descriptions, addresses. For one line use Input; for rich formatting use an editor component. Pair with Field for label, hint and a character count.',
32
+ anatomy: [
33
+ { part: 'root', element: 'div', description: 'Wrapper that draws the ring, background, radius, shadow-xs and the focus ring (:focus-within).' },
34
+ { part: 'field', element: 'textarea', description: 'The native <textarea>. No border, no outline, fills the wrapper. Set rows to size it (3 in forms); the wrapper grows with it and with a vertical resize.' },
35
+ ],
36
+ props: {
37
+ size: SIZE_PROP(['sm', 'md', 'lg']),
38
+ resize: { values: ['none', 'vertical'], default: 'vertical', description: 'vertical = the user can drag the corner to make it taller (default for notes and messages); none = fixed height, for inputs with a strict length or inside dense layouts.' },
39
+ },
40
+ states: {
41
+ hover: { selector: ':hover:not([data-disabled])', description: 'Pointer over the field. The reference has no hover change; presets with a distinct border-control-hover strengthen the ring.', markup: 'native' },
42
+ focus: { selector: ':focus-within', description: 'The inset ring turns to the brand color and the 4px brand focus ring appears around the wrapper.', markup: 'native :focus-within on the wrapper' },
43
+ invalid: { selector: '[data-invalid], &:has([aria-invalid="true"])', description: 'Red-300 ring; on focus the focus ring turns red. Always accompanied by a Field error.', markup: 'aria-invalid="true" on the textarea, or data-invalid on the wrapper' },
44
+ disabled: { selector: '[data-disabled], &:has(:disabled)', description: 'Whole control at 50% opacity, cursor not-allowed, no resize handle.', markup: 'disabled on the textarea' },
45
+ },
46
+ base: {
47
+ root: {
48
+ display: 'flex',
49
+ width: '100%',
50
+ 'min-width': '0',
51
+ 'background-color': '{color.bg-surface}',
52
+ 'border-radius': '{radius.control}',
53
+ 'box-shadow': RING_REST,
54
+ color: '{color.fg-default}',
55
+ ...TRANSITION_FAST,
56
+ },
57
+ field: {
58
+ display: 'block',
59
+ flex: '1 1 auto',
60
+ width: '100%',
61
+ 'min-width': '0',
62
+ margin: '0',
63
+ border: '0',
64
+ outline: 'none',
65
+ background: 'transparent',
66
+ 'box-shadow': 'none',
67
+ 'border-radius': '{radius.control}',
68
+ ...typeStyle('body-md'),
69
+ color: 'inherit',
70
+ resize: 'vertical',
71
+ 'scroll-padding-block': '{space.3}',
72
+ },
73
+ '@states': {
74
+ hover: { root: { 'box-shadow': RING_HOVER } },
75
+ focus: { root: { 'box-shadow': RING_FOCUS } },
76
+ invalid: { root: { 'box-shadow': RING_INVALID } },
77
+ disabled: { root: { opacity: '{opacity.disabled}', cursor: 'not-allowed' }, field: { cursor: 'not-allowed', resize: 'none' } },
78
+ },
79
+ },
80
+ variants: {
81
+ size: { sm: sizeBlock('sm'), md: sizeBlock('md'), lg: sizeBlock('lg') },
82
+ resize: {
83
+ none: { field: { resize: 'none' } },
84
+ vertical: { field: { resize: 'vertical' } },
85
+ },
86
+ },
87
+ extraCss: `
88
+ .cn-textarea .cn-textarea__field::placeholder { color: {color.fg-placeholder}; opacity: 1; }
89
+ .cn-textarea .cn-textarea__field:-webkit-autofill { border-radius: {radius.control}; -webkit-text-fill-color: {color.fg-default}; }
90
+ .cn-textarea:has([aria-invalid="true"]):focus-within, .cn-textarea[data-invalid]:focus-within { box-shadow: ${RING_INVALID_FOCUS}; }`,
91
+ examples: [
92
+ ex('Default', ta('placeholder="Describe the change for the release notes…" aria-label="Description"'), 'Three rows, 12×14px padding, gray-300 ring and shadow-xs, radius 8.'),
93
+ ex('With content', ta('aria-label="Notes"', 'Daniel Costa asked for the onboarding checklist to be split by role. Maya Chen will draft the designer track before the Friday review.'), 'The wrapper grows with rows; drag the corner to make it taller.'),
94
+ ex('Sizes', `<div style="display:flex;flex-direction:column;gap:var(--cn-space-4)">${ta('placeholder="Small · 12px padding, 14px text" aria-label="Small"', '', 'sm', 'vertical', 'max-width:480px')}${ta('placeholder="Medium · 12×14px padding, 14px text" aria-label="Medium"', '', 'md')}${ta('placeholder="Large · 12×14px padding, 16px text" aria-label="Large"', '', 'lg')}</div>`, 'sm 12px all round; md and lg 12px vertical, 14px horizontal. Text is 16px on lg.'),
95
+ ex('Fixed height', ta('placeholder="Internal note (not visible to the client)" aria-label="Internal note"', '', 'sm', 'none', 'max-width:360px'), 'resize="none" hides the handle for strict layouts.'),
96
+ ex('Invalid', ta('aria-invalid="true" aria-label="Description" aria-describedby="ta-err"', 'ok'), 'Red-300 ring. Pair with a Field error such as "Write at least 40 characters".'),
97
+ ex('Disabled', ta('disabled aria-label="Terms"', 'Standard terms apply to this workspace and cannot be edited after the plan is signed.'), 'Same fill and ring at 50% opacity; no resize handle.'),
98
+ ],
99
+ recipes: [
100
+ ex('With character count', `<div class="cn-field" data-layout="vertical" data-size="md" style="max-width:480px"><label class="cn-field__label" for="ta-bio">Public description</label><div class="cn-field__control"><div class="cn-textarea" data-size="md" data-resize="vertical"><textarea class="cn-textarea__field" id="ta-bio" rows="3" maxlength="240" aria-describedby="ta-bio-helper">Lumen helps product teams turn research into decisions: interviews, notes and evidence in one workspace.</textarea></div></div><p class="cn-field__helper" id="ta-bio-helper" style="display:flex;justify-content:space-between;gap:var(--cn-space-3)"><span>Shown on your public profile.</span><span class="cn-tabular">109 / 240</span></p></div>`, 'The count lives in the Field hint, right-aligned, tabular numerals. Turn it to fg-danger only when the limit is exceeded.'),
101
+ ],
102
+ rules: [
103
+ 'Start at 3 rows (rows="3"); use 4–6 only for the primary text of a screen (a message composer). Never taller than the viewport.',
104
+ 'Vertical resize only. Horizontal resize breaks the layout; disable resize entirely (resize="none") when the height is a design constraint.',
105
+ 'Always inside a Field with a visible label. Use the hint for the limit and the tone expected ("Visible to the client").',
106
+ 'Width comes from the layout (100% of the Field). Set max-width on the Field, never a fixed width on the wrapper.',
107
+ 'Show a character count only when there is a hard limit, as "109 / 240" in the hint. Do not block typing at the limit; mark the field invalid.',
108
+ 'Sizes match the Inputs around it: md in forms, sm in side panels and comment threads, lg on marketing and auth screens.',
109
+ ],
110
+ a11y: [
111
+ 'Every textarea has an accessible name: a <label for> from the Field, or aria-label when standalone.',
112
+ 'aria-invalid="true" and aria-describedby pointing to the Field error id when invalid.',
113
+ 'Keep the native resize handle keyboard-independent: the height must never be required to read the content (the textarea scrolls).',
114
+ 'A character limit is announced through the hint text; do not rely on maxlength alone.',
115
+ ],
116
+ related: ['input', 'field', 'select'],
117
+ };
@@ -0,0 +1,135 @@
1
+ import { ex, typeStyle } from "./_shared.mjs";
2
+ // the reference activity feed: events down a 2px gray-200 line, an 8px dot (or a 32px avatar)
3
+ // per event, a 14px semibold ink title, a 12px gray-500 time and 14px gray-600 detail; the
4
+ // current event pulses in brand, completed events are green, failures red.
5
+ const RING = '0 0 0 1.5px {color.bg-surface}';
6
+ const SILHOUETTE = "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";
7
+ const event = (time, title, description, extra = '', avatar = '') => `<li class="cn-timeline__item"${extra}>${avatar ? `<span class="cn-timeline__avatar cn-avatar" data-size="sm" data-shape="circle" data-tone="neutral" data-status="none" aria-hidden="true"><img class="cn-avatar__image" src="${SILHOUETTE}" alt=""></span>` : '<span class="cn-timeline__marker" aria-hidden="true"></span>'}<span class="cn-timeline__line" aria-hidden="true"></span><time class="cn-timeline__time" datetime="2026-09-11">${time}</time><span class="cn-timeline__title">${title}</span>${description ? `<span class="cn-timeline__description">${description}</span>` : ''}</li>`;
8
+ const RELEASE = [
9
+ event('Sep 8 · 09:12', 'Release branch created', 'v2.4.0 cut from main by Daniel Costa.', ' data-state="complete"'),
10
+ event('Sep 8 · 09:40', '12 pull requests merged', '8 features, 4 fixes.', ' data-state="complete"'),
11
+ event('Sep 9 · 14:05', 'Staging deploy approved', 'Sofia Almeida signed off on the design review.', ' data-state="complete"'),
12
+ event('Sep 11 · 08:31', 'Production rollout in progress', '25% of workspaces migrated; no errors so far.', ' data-state="current"'),
13
+ ].join('');
14
+ export const timeline = {
15
+ name: 'Timeline',
16
+ slug: 'timeline',
17
+ category: 'data-display',
18
+ description: 'Events in order down a thin vertical line: an 8px dot (or a 32px avatar) per event, a 12px timestamp, a 14px semibold title and a muted note. The current event pulses in brand; completed events are green; failures are red.',
19
+ usage: 'Activity of one thing over time: a release, a conversation, an order, a support ticket. For a multi-step process the user walks through use Stepper; for a list of records use List.',
20
+ anatomy: [
21
+ { part: 'root', element: 'ol', description: 'Ordered list of events, oldest first (or newest first for activity feeds; be consistent per screen).' },
22
+ { part: 'item', element: 'li', description: 'One event. Relative, left padding for the marker. data-state="complete|current" and data-tone="danger" color the marker.' },
23
+ { part: 'marker', element: 'span', description: '8px dot on the line with a 1.5px surface ring. Gray-300 by default; green when complete, brand (pulsing) when current, red for failures.' },
24
+ { part: 'avatar', element: 'span', description: 'Optional 32px Avatar in place of the dot (an Avatar root that also carries this class) for activity by a person; the line runs behind it.', optional: true },
25
+ { part: 'line', element: 'span', description: '2px gray-200 rule from this marker to the next. Hidden on the last item.' },
26
+ { part: 'time', element: 'time', description: 'When it happened, 12px medium fg-subtle. Use a <time datetime>. Relative ("2h ago") or absolute, consistent per screen.' },
27
+ { part: 'title', element: 'span', description: 'What happened, 14px semibold in ink, past tense ("Staging deploy approved").' },
28
+ { part: 'description', element: 'span', description: 'Optional one or two lines of detail, 14px fg-muted.', optional: true },
29
+ ],
30
+ props: {
31
+ size: { values: ['sm', 'md'], default: 'md', description: 'md = 14px titles with 20px between events (detail pages, drawers); sm = 12px titles and time, tighter spacing (side panels, cards).' },
32
+ variant: {
33
+ values: ['default', 'compact'],
34
+ default: 'default',
35
+ description: 'default = time above the title, each event a small block; compact = time inline after the title on one line, description under it, for dense activity feeds.',
36
+ },
37
+ },
38
+ states: {
39
+ complete: { selector: ' > [data-state="complete"]', description: 'On an item, not the root: the marker is green.', markup: 'data-state="complete" on the item' },
40
+ current: { selector: ' > [data-state="current"]', description: 'On an item: brand marker with a soft pulsing halo; the title is semibold brand.', markup: 'data-state="current" on the item (at most one per timeline)' },
41
+ danger: { selector: ' > [data-tone="danger"]', description: 'On an item: red marker and title for a failure (bounce, rejection, error).', markup: 'data-tone="danger" on the item' },
42
+ },
43
+ base: {
44
+ root: { display: 'flex', 'flex-direction': 'column', position: 'relative', margin: '0', padding: '0', 'list-style': 'none', 'min-width': '0', color: '{color.fg-default}' },
45
+ item: {
46
+ position: 'relative',
47
+ display: 'flex',
48
+ 'flex-direction': 'column',
49
+ gap: '{space.0.5}',
50
+ 'padding-inline-start': '{space.6}',
51
+ 'padding-bottom': '{space.5}',
52
+ 'min-width': '0',
53
+ },
54
+ marker: {
55
+ position: 'absolute',
56
+ left: '0',
57
+ top: '{space.1.5}',
58
+ width: '{space.2}',
59
+ height: '{space.2}',
60
+ 'border-radius': '{radius.full}',
61
+ 'background-color': '{color.border-control}',
62
+ 'box-shadow': RING,
63
+ 'z-index': '{z.raised}',
64
+ },
65
+ avatar: { position: 'absolute', left: 'calc({space.4} * -1 + {space.1})', top: '0', 'z-index': '{z.raised}', 'box-shadow': RING },
66
+ line: {
67
+ position: 'absolute',
68
+ left: '3px',
69
+ top: '{space.4}',
70
+ bottom: 'calc({space.1} * -1)',
71
+ width: '{border.width.medium}',
72
+ 'border-radius': '{radius.full}',
73
+ 'background-color': '{color.border-default}',
74
+ },
75
+ time: { ...typeStyle('label-xs'), color: '{color.fg-subtle}', 'white-space': 'nowrap', 'font-variant-numeric': 'tabular-nums' },
76
+ title: { ...typeStyle('label-md'), color: '{color.fg-default}' },
77
+ description: { ...typeStyle('body-md'), color: '{color.fg-muted}' },
78
+ },
79
+ variants: {
80
+ size: {
81
+ sm: { item: { 'padding-bottom': '{space.4}', 'padding-inline-start': '{space.5}' }, title: { ...typeStyle('label-sm') }, description: { ...typeStyle('body-sm') }, marker: { top: '{space.1}' } },
82
+ md: { item: { 'padding-bottom': '{space.5}' } },
83
+ },
84
+ variant: {
85
+ default: { item: {} },
86
+ compact: {
87
+ item: { 'flex-direction': 'row', 'flex-wrap': 'wrap', 'align-items': 'baseline', 'column-gap': '{space.2}', 'row-gap': '{space.0.5}', 'padding-bottom': '{space.3}' },
88
+ time: { order: '1' },
89
+ description: { 'flex-basis': '100%', order: '2' },
90
+ },
91
+ },
92
+ },
93
+ compound: [
94
+ { when: { size: 'sm', variant: 'compact' }, block: { item: { 'padding-bottom': '{space.2}' } } },
95
+ ],
96
+ extraCss: `
97
+ .cn-timeline__item:last-child .cn-timeline__line { display: none; }
98
+ .cn-timeline__item:has(> .cn-timeline__avatar) { padding-inline-start: {space.11}; }
99
+ .cn-timeline__item:has(> .cn-timeline__avatar) > .cn-timeline__avatar { left: 0; }
100
+ .cn-timeline__item:has(> .cn-timeline__avatar) > .cn-timeline__line { left: calc({space.4} - {border.width.thin}); top: {space.10}; }
101
+ .cn-timeline__item[data-state="complete"] .cn-timeline__marker { background-color: {color.bg-success}; }
102
+ .cn-timeline__item[data-state="current"] .cn-timeline__marker { background-color: {color.bg-action}; animation: cn-timeline-pulse 2s {motion.easing.standard} infinite; }
103
+ .cn-timeline__item[data-state="current"] .cn-timeline__title { color: {color.fg-action}; }
104
+ .cn-timeline__item[data-tone="danger"] .cn-timeline__marker { background-color: {color.bg-danger}; animation: none; }
105
+ .cn-timeline__item[data-tone="danger"] .cn-timeline__title { color: {color.fg-danger}; }
106
+ @keyframes cn-timeline-pulse {
107
+ 0% { box-shadow: ${RING}, 0 0 0 {border.width.medium} {color.bg-action-subtle}; }
108
+ 70% { box-shadow: ${RING}, 0 0 0 {space.2} transparent; }
109
+ 100% { box-shadow: ${RING}, 0 0 0 {space.2} transparent; }
110
+ }`,
111
+ examples: [
112
+ ex('Release activity, current event last', `<ol class="cn-timeline" data-size="md" data-variant="default" style="max-width:480px">${RELEASE}</ol>`, 'Three completed events (green dots), one current (brand, pulsing). 12px time over a 14px semibold title.'),
113
+ ex('Compact feed with a failure', `<ol class="cn-timeline" data-size="md" data-variant="compact" style="max-width:480px">${event('08:31', 'Rollout resumed', '25% of workspaces migrated.', ' data-state="complete"')}${event('08:02', 'Rollout paused', 'Error rate above 1% in eu-west-1; Aisha Khan is investigating.', ' data-tone="danger"')}${event('Yesterday', 'Staging deploy approved by Sofia Almeida', '', ' data-state="complete"')}${event('Sep 9', '12 pull requests merged', '', ' data-state="complete"')}</ol>`, 'Time inline after the title; danger tone on the failure.'),
114
+ ex('With avatars (activity by people)', `<ol class="cn-timeline" data-size="md" data-variant="default" style="max-width:480px">${event('2h ago', 'Maya Chen changed the due date', 'From Sep 12 to Sep 19.', '', 'MC')}${event('Yesterday', 'Daniel Costa commented', '“Ready for review once the API is deployed.”', '', 'DC')}${event('Sep 8', 'Sofia Almeida attached 2 files', 'Brand guidelines.pdf, Q3 roadmap.xlsx', '', 'SA')}</ol>`, 'A 32px Avatar replaces the dot; the 2px line runs between avatars.'),
115
+ ex('Small, in a side panel', `<ol class="cn-timeline" data-size="sm" data-variant="default" style="max-width:320px">${event('Sep 11', 'Production rollout in progress', '25% of workspaces migrated.', ' data-state="current"')}${event('Sep 9', 'Staging deploy approved', '', ' data-state="complete"')}${event('Sep 8', 'Release branch created', '', ' data-state="complete"')}</ol>`, 'Newest first is fine for feeds; be consistent per screen.'),
116
+ ],
117
+ rules: [
118
+ 'Titles are short past-tense facts ("Staging deploy approved", "12 pull requests merged"), sentence case, no trailing period.',
119
+ 'Timestamps use a <time datetime> element in 12px medium; pick relative or absolute per screen and keep it consistent across all events.',
120
+ 'At most one data-state="current" per timeline; it is the only pulsing element on the page.',
121
+ 'Marker colors mean state: gray = happened, green = completed step, brand = happening now, red = failed. Never other colors.',
122
+ 'Use the avatar marker when the event is something a person did; the dot when it is something the system did. One marker style per timeline.',
123
+ 'Descriptions are one or two lines of detail; anything longer (a message body) links to the record.',
124
+ 'Keep one direction per screen: oldest first for a process, newest first for an activity feed.',
125
+ 'Do not put buttons or badges inside events; the timeline reports, the surface acts. A link inside the description is fine.',
126
+ 'Long timelines collapse older events behind a "Show 12 earlier events" link Button; do not render hundreds of events.',
127
+ ],
128
+ a11y: [
129
+ 'Use <ol> and <li> so the order and count are announced; the marker, avatar and line are aria-hidden decoration.',
130
+ 'State must be in the text, not only in the marker color: the title or description says "failed", "approved", "in progress".',
131
+ 'The pulse animation stops under prefers-reduced-motion (handled by the base stylesheet); the current item still stands out by color and weight.',
132
+ '<time datetime> carries the machine-readable date so relative labels ("2h ago") remain meaningful.',
133
+ ],
134
+ related: ['stepper', 'list', 'badge', 'avatar', 'card'],
135
+ };
@@ -0,0 +1,115 @@
1
+ import { FOCUS_RING, RESET_BUTTON, TRANSITION_COLORS, STATE, ex, ICON, typeStyle } from "./_shared.mjs";
2
+ const TONE_ICON = {
3
+ neutral: '{color.fg-muted}',
4
+ success: '{color.fg-success}',
5
+ warning: '{color.fg-warning}',
6
+ danger: '{color.fg-danger}',
7
+ info: '{color.fg-info}',
8
+ };
9
+ const icon = (name) => ICON[name].replace('cn-icon', 'cn-toast__icon');
10
+ const dismiss = `<button type="button" class="cn-toast__dismiss" aria-label="Dismiss">${ICON.x}</button>`;
11
+ export const toast = {
12
+ name: 'Toast',
13
+ slug: 'toast',
14
+ category: 'feedback',
15
+ description: 'Short-lived notification panel that slides in at the bottom-right corner: a raised white card with an icon, a title, one line of detail and at most one action. The component styles the panel; the fixed region that stacks toasts is a separate class.',
16
+ usage: 'Use to confirm something the user just did ("Draft saved", "Invite sent") or to report a background event that needs no decision. Not for errors the user must act on (use Alert in place, or Dialog) and not for information that must persist (use Banner).',
17
+ anatomy: [
18
+ { part: 'root', element: 'div', description: 'The panel. 360px wide, raised surface, shadow-lg. role="status"; data-state="open|closed".' },
19
+ { part: 'icon', element: 'svg', description: '16px icon in the tone color, top-aligned with the title. Decorative.', optional: true },
20
+ { part: 'content', element: 'div', description: 'Title + description + action. Flexes to fill.' },
21
+ { part: 'title', element: 'div', description: 'label-md, one line: what happened ("Invite sent").' },
22
+ { part: 'description', element: 'p', description: 'body-sm muted, one line of detail (who, when, how many).', optional: true },
23
+ { part: 'action', element: 'div', description: 'One link or ghost Button (size sm) under the description: "Undo", "View".', optional: true },
24
+ { part: 'dismiss', element: 'button', description: '24px ghost icon button with an X in the top-right corner. aria-label="Dismiss".', optional: true },
25
+ ],
26
+ props: {
27
+ tone: {
28
+ values: ['neutral', 'success', 'warning', 'danger', 'info'],
29
+ default: 'neutral',
30
+ description: 'Only the icon color changes; the panel stays white. neutral = plain confirmation; success = a completed action; warning = something to look at later; danger = a background failure that needs no immediate decision; info = a heads-up.',
31
+ },
32
+ },
33
+ states: {
34
+ open: STATE.open(),
35
+ closed: { selector: '[data-state="closed"]', description: 'Removed from view (display none) after the auto-dismiss timer or the dismiss button.', markup: 'data-state="closed"' },
36
+ dismissHover: { selector: ' .cn-toast__dismiss:hover', description: 'Pointer over the dismiss button: subtle fill. Styled in extraCss because it lives on a child.', markup: 'native :hover on .cn-toast__dismiss' },
37
+ dismissFocus: { selector: ' .cn-toast__dismiss:focus-visible', description: 'Keyboard focus on the dismiss button shows the focus ring. Styled in extraCss.', markup: 'native :focus-visible on .cn-toast__dismiss' },
38
+ },
39
+ base: {
40
+ root: {
41
+ display: 'flex',
42
+ 'align-items': 'flex-start',
43
+ gap: '{space.3}',
44
+ width: '360px',
45
+ 'max-width': '100%',
46
+ padding: '{space.3} {space.4}',
47
+ 'background-color': '{color.bg-surface-raised}',
48
+ border: '{border.width.thin} solid {color.border-default}',
49
+ 'border-radius': '{radius.lg}',
50
+ 'box-shadow': '{shadow.lg}',
51
+ color: '{color.fg-default}',
52
+ ...typeStyle('body-sm'),
53
+ },
54
+ icon: { width: '{size.icon.md}', height: '{size.icon.md}', 'flex-shrink': '0', 'margin-top': '1px', color: '{color.fg-muted}' },
55
+ content: { flex: '1 1 auto', 'min-width': '0' },
56
+ title: { ...typeStyle('label-md'), 'line-height': '{font.lineHeight.snug}' },
57
+ description: { ...typeStyle('body-sm'), color: '{color.fg-muted}', 'margin-top': '{space.1}' },
58
+ action: { display: 'flex', 'align-items': 'center', gap: '{space.2}', 'margin-top': '{space.2}' },
59
+ dismiss: {
60
+ ...RESET_BUTTON,
61
+ display: 'inline-flex',
62
+ 'align-items': 'center',
63
+ 'justify-content': 'center',
64
+ 'flex-shrink': '0',
65
+ width: '{size.icon.xl}',
66
+ height: '{size.icon.xl}',
67
+ 'margin-block': 'calc({space.1} * -1)',
68
+ 'margin-inline-end': 'calc({space.1} * -1)',
69
+ 'border-radius': '{radius.md}',
70
+ color: '{color.fg-subtle}',
71
+ ...TRANSITION_COLORS,
72
+ },
73
+ '@states': {
74
+ open: { root: { animation: 'cn-toast-enter {motion.duration.slow} {motion.easing.out} both' } },
75
+ closed: { root: { display: 'none' } },
76
+ },
77
+ },
78
+ variants: {
79
+ tone: Object.fromEntries(Object.entries(TONE_ICON).map(([t, color]) => [t, { icon: { color } }])),
80
+ },
81
+ extraCss: `
82
+ .cn-toast-region { position: fixed; bottom: {space.6}; right: {space.6}; display: flex; flex-direction: column; gap: {space.2}; z-index: {z.toast}; pointer-events: none; }
83
+ .cn-toast-region > .cn-toast { pointer-events: auto; }
84
+ @keyframes cn-toast-enter { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
85
+ .cn-toast .cn-toast__dismiss .cn-icon { width: {size.icon.sm}; height: {size.icon.sm}; }
86
+ .cn-toast .cn-toast__dismiss:hover { background-color: {color.bg-subtle}; color: {color.fg-default}; }
87
+ .cn-toast .cn-toast__dismiss:focus-visible { outline: none; box-shadow: ${FOCUS_RING['box-shadow']}; }`,
88
+ examples: [
89
+ ex('Neutral', `<div class="cn-toast" data-tone="neutral" data-state="open" role="status">${icon('info')}<div class="cn-toast__content"><div class="cn-toast__title">Draft saved</div><p class="cn-toast__description">Reply to Nakamura Trading Co. · 2 seconds ago</p></div>${dismiss}</div>`),
90
+ ex('Success with action', `<div class="cn-toast" data-tone="success" data-state="open" role="status">${icon('check')}<div class="cn-toast__content"><div class="cn-toast__title">Invite sent to Hoffmann GmbH</div><p class="cn-toast__description">They will receive it within a minute.</p><div class="cn-toast__action"><button type="button" class="cn-button" data-variant="link" data-size="sm"><span class="cn-button__label">Undo</span></button></div></div>${dismiss}</div>`),
91
+ ex('Warning', `<div class="cn-toast" data-tone="warning" data-state="open" role="status">${icon('warning')}<div class="cn-toast__content"><div class="cn-toast__title">4 prospects skipped</div><p class="cn-toast__description">No verified email found. They stay in the list as “unreachable”.</p></div>${dismiss}</div>`),
92
+ ex('Danger (background failure)', `<div class="cn-toast" data-tone="danger" data-state="open" role="status">${icon('warning')}<div class="cn-toast__content"><div class="cn-toast__title">Export failed</div><p class="cn-toast__description">The file was not generated. Try again from the Export menu.</p><div class="cn-toast__action"><button type="button" class="cn-button" data-variant="ghost" data-size="sm"><span class="cn-button__label">Retry</span></button></div></div>${dismiss}</div>`),
93
+ ex('Info', `<div class="cn-toast" data-tone="info" data-state="open" role="status">${icon('info')}<div class="cn-toast__content"><div class="cn-toast__title">Nightly refresh finished</div><p class="cn-toast__description">37 new companies in Japan and South Korea.</p><div class="cn-toast__action"><button type="button" class="cn-button" data-variant="link" data-size="sm"><span class="cn-button__label">View</span></button></div></div>${dismiss}</div>`),
94
+ ],
95
+ recipes: [
96
+ ex('Stack of two (static)', `<div style="display:flex;flex-direction:column;gap:var(--cn-space-2)"><div class="cn-toast" data-tone="success" data-state="open" role="status">${icon('check')}<div class="cn-toast__content"><div class="cn-toast__title">Shortlist exported</div><p class="cn-toast__description">prospects-japan.xlsx · 1.2 MB</p></div>${dismiss}</div><div class="cn-toast" data-tone="neutral" data-state="open" role="status">${icon('info')}<div class="cn-toast__content"><div class="cn-toast__title">Agent paused</div><p class="cn-toast__description">Outreach resumes at 08:00 in the buyer’s timezone.</p></div>${dismiss}</div></div>`, 'In the app the same markup lives inside .cn-toast-region (fixed, bottom-right). Newest at the bottom.'),
97
+ ],
98
+ rules: [
99
+ 'Toasts auto-dismiss after 5 seconds (8 seconds when they carry an action). Hovering pauses the timer.',
100
+ 'At most 3 toasts visible; the oldest leaves when a fourth arrives.',
101
+ 'Never use a toast for an error that needs a decision. If the user must act, show an Alert next to the thing or a Dialog.',
102
+ 'Title is 2–5 words in past tense ("Invite sent"); description is one line with the concrete object, count or time.',
103
+ 'At most one action, variant link or ghost, size sm. "Undo" is the most valuable action a toast can offer.',
104
+ 'Render toasts inside .cn-toast-region (fixed, bottom-right, z toast). The panel itself is never position: fixed.',
105
+ 'The panel stays white in every tone; only the icon changes color. No tinted toasts.',
106
+ 'Enter animation is translateY(8px) → 0 with opacity, 260ms; leaving is opacity only. Both disappear under prefers-reduced-motion.',
107
+ ],
108
+ a11y: [
109
+ 'role="status" on the panel (polite); use aria-live="assertive" on the region only for danger toasts.',
110
+ 'The region is aria-label="Notifications"; toasts never steal focus when they appear.',
111
+ 'The dismiss button has aria-label="Dismiss"; Escape dismisses the most recent toast when a toast has focus.',
112
+ 'Pause the auto-dismiss timer while the toast or its action has focus or the pointer.',
113
+ ],
114
+ related: ['alert', 'banner', 'dialog', 'button'],
115
+ };
@@ -0,0 +1,86 @@
1
+ import { STATE, ex, typeStyle } from "./_shared.mjs";
2
+ export const tooltip = {
3
+ name: 'Tooltip',
4
+ slug: 'tooltip',
5
+ category: 'overlays',
6
+ description: 'Small inverse-colored label that names or explains a control on hover and focus. One short sentence, a 6px arrow toward the trigger, nothing interactive inside.',
7
+ usage: 'Use to name icon-only buttons and to add a hint to truncated values or abbreviations. Not for content the user needs to act on or copy (use Popover), not for long explanations (use helper text), and not as the only place a label lives on touch devices.',
8
+ anatomy: [
9
+ { part: 'root', element: 'div', description: 'The panel. role="tooltip", id referenced by the trigger’s aria-describedby. Positioned by the app; the component styles it in place.' },
10
+ { part: 'arrow', element: 'span', description: '6px square rotated 45°, same background as the panel, placed on the edge facing the trigger according to data-side.', optional: true },
11
+ ],
12
+ props: {
13
+ side: {
14
+ values: ['top', 'right', 'bottom', 'left'],
15
+ default: 'top',
16
+ description: 'Which side of the trigger the tooltip sits on; only moves the arrow. top is the default; use bottom under a topbar, left/right next to a sidebar.',
17
+ },
18
+ },
19
+ states: {
20
+ open: STATE.open(),
21
+ closed: { selector: '[data-state="closed"]', description: 'Hidden. The panel is removed from view (display none).', markup: 'data-state="closed"' },
22
+ },
23
+ base: {
24
+ root: {
25
+ position: 'relative',
26
+ display: 'inline-block',
27
+ width: 'max-content',
28
+ 'max-width': '240px',
29
+ padding: '{space.1.5} {space.2}',
30
+ 'background-color': '{color.bg-inverse}',
31
+ color: '{color.fg-inverse}',
32
+ 'border-radius': '{radius.md}',
33
+ 'box-shadow': '{shadow.md}',
34
+ ...typeStyle('body-xs'),
35
+ 'z-index': '{z.tooltip}',
36
+ 'pointer-events': 'none',
37
+ },
38
+ arrow: {
39
+ position: 'absolute',
40
+ width: '6px',
41
+ height: '6px',
42
+ 'background-color': '{color.bg-inverse}',
43
+ transform: 'rotate(45deg)',
44
+ },
45
+ '@states': {
46
+ open: { root: { animation: 'cn-tooltip-enter {motion.duration.fast} {motion.easing.standard} both' } },
47
+ closed: { root: { display: 'none' } },
48
+ },
49
+ },
50
+ variants: {
51
+ side: {
52
+ top: { arrow: { bottom: '-3px', left: 'calc(50% - 3px)' } },
53
+ bottom: { arrow: { top: '-3px', left: 'calc(50% - 3px)' } },
54
+ left: { arrow: { right: '-3px', top: 'calc(50% - 3px)' } },
55
+ right: { arrow: { left: '-3px', top: 'calc(50% - 3px)' } },
56
+ },
57
+ },
58
+ extraCss: `
59
+ @keyframes cn-tooltip-enter { from { opacity: 0; } to { opacity: 1; } }`,
60
+ examples: [
61
+ ex('Top (default)', `<div class="cn-tooltip" data-side="top" data-state="open" role="tooltip" id="tip-archive">Archive this prospect<span class="cn-tooltip__arrow"></span></div>`),
62
+ ex('Bottom', `<div class="cn-tooltip" data-side="bottom" data-state="open" role="tooltip" id="tip-refresh">Refreshed nightly at 02:00 UTC<span class="cn-tooltip__arrow"></span></div>`),
63
+ ex('Left', `<div class="cn-tooltip" data-side="left" data-state="open" role="tooltip" id="tip-verified">Verified with 3 public sources<span class="cn-tooltip__arrow"></span></div>`),
64
+ ex('Right, wrapping at 240px', `<div class="cn-tooltip" data-side="right" data-state="open" role="tooltip" id="tip-score">Fit score combines trade volume, product match and reply history.<span class="cn-tooltip__arrow"></span></div>`),
65
+ ],
66
+ recipes: [
67
+ ex('Icon button with its tooltip (static)', `<div style="display:inline-flex;flex-direction:column;align-items:center;gap:var(--cn-space-1)"><div class="cn-tooltip" data-side="top" data-state="open" role="tooltip" id="tip-copy">Copy company ID<span class="cn-tooltip__arrow"></span></div><button type="button" class="cn-button" data-variant="outline" data-size="sm" aria-describedby="tip-copy"><span class="cn-button__label">Copy</span></button></div>`, 'The trigger points to the tooltip with aria-describedby; the tooltip never contains the trigger’s label alone if the button has visible text.'),
68
+ ],
69
+ rules: [
70
+ 'Text only, at most one sentence, no period at the end unless it is two sentences (then it is too long).',
71
+ 'Never put links, buttons or inputs inside a tooltip. If it must be clicked, it is a Popover.',
72
+ 'Show after a 300ms hover delay and immediately on keyboard focus; hide on pointer leave, blur or Escape.',
73
+ 'Tooltips are not the only label on touch: every icon-only control also has aria-label, and mobile-only elements do not rely on tooltips at all.',
74
+ 'Prefer side="top". Use bottom under a fixed topbar and left/right for controls at the screen edge. Let the positioning library flip when there is no room.',
75
+ 'Max width 240px; wrapping is fine, scrolling is not.',
76
+ 'The panel is inverse-colored in both themes (dark on light, light on dark); never tone it.',
77
+ 'Do not show a tooltip that repeats the visible label of a text button.',
78
+ ],
79
+ a11y: [
80
+ 'role="tooltip" on the panel and aria-describedby on the trigger pointing to its id.',
81
+ 'Opens on focus as well as hover; a control that can be hovered must be reachable by keyboard.',
82
+ 'Escape closes it and returns nothing else; focus never moves into the tooltip.',
83
+ 'Inverse colors keep AA contrast in both themes; body-xs is the minimum size, never smaller.',
84
+ ],
85
+ related: ['popover', 'icon-button', 'button'],
86
+ };