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,139 @@
1
+ import { FOCUS_RING, RESET_BUTTON, TRANSITION_COLORS, ex, ICON, typeStyle } from "./_shared.mjs";
2
+ // Inline CTA: a bordered row inside content that asks for one thing: a 40px
3
+ // featured icon or avatar, a label-md title, a body-sm description, a ghost
4
+ // secondary + a link or primary action, an optional progress bar and a × to
5
+ // dismiss. The banner variant lines the actions up on the right.
6
+ const HAIRLINE = '{border.width.thin} solid {color.border-default}';
7
+ const SVG = {
8
+ spark: ICON.spark.replace('cn-icon', 'cn-featured-icon__icon'),
9
+ drive: '<svg class="cn-featured-icon__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><ellipse cx="8" cy="4" rx="5" ry="2"/><path d="M3 4v8c0 1.1 2.25 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.25 2 5 2s5-.9 5-2"/></svg>',
10
+ mail: '<svg class="cn-featured-icon__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3.5" width="12" height="9" rx="1.5"/><path d="M2.5 4.5L8 8.5l5.5-4"/></svg>',
11
+ };
12
+ const featured = (svg, tone) => `<span class="cn-inline-cta__media"><span class="cn-featured-icon" data-theme="light" data-tone="${tone}" data-size="md" data-shape="square">${svg}</span></span>`;
13
+ const avatar = (initials, name) => `<span class="cn-inline-cta__media"><span class="cn-avatar" data-size="lg" data-shape="circle" data-tone="neutral" data-status="none" role="img" aria-label="${name}"><span class="cn-avatar__fallback" aria-hidden="true">${initials}</span></span></span>`;
14
+ const DISMISS = `<button type="button" class="cn-inline-cta__dismiss" aria-label="Dismiss">${ICON.x}</button>`;
15
+ const btn = (label, variant) => `<button type="button" class="cn-button" data-variant="${variant}" data-size="sm"><span class="cn-button__label">${label}</span></button>`;
16
+ const actions = (...buttons) => `<div class="cn-inline-cta__actions">${buttons.join('')}</div>`;
17
+ const progress = (pct, label) => `<div class="cn-inline-cta__progress" role="progressbar" aria-label="${label}" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct}"><div class="cn-inline-cta__progress-fill" style="width:${pct}%"></div></div>`;
18
+ const content = (title, description, extra = '') => `<div class="cn-inline-cta__content"><div class="cn-inline-cta__title">${title}</div><p class="cn-inline-cta__description">${description}</p>${extra}</div>`;
19
+ const cta = (variant, tone, inner) => `<div class="cn-inline-cta" data-variant="${variant}" data-tone="${tone}" role="region" aria-label="Notice" style="width:100%;max-width:640px">${inner}</div>`;
20
+ export const inlineCta = {
21
+ name: 'InlineCta',
22
+ slug: 'inline-cta',
23
+ category: 'feedback',
24
+ description: 'A bordered row inside content that asks for one thing: a 40px featured icon or avatar, a label-md title, a body-sm description, a ghost secondary and a link or primary action, an optional progress bar and a × to dismiss. Tones tint the surface for brand, warning and success.',
25
+ usage: 'Use between content blocks when the next step lives right there: upgrade to unlock a feature, verify an email before sending, free up storage, act on an invitation. For sidebar nudges use FeaturedCard; for page-wide messages use Banner; for form errors use Alert.',
26
+ anatomy: [
27
+ { part: 'root', element: 'div', description: 'The row: bg-surface, hairline, radius card, 16px padding, shadow-xs, flex with 16px gaps, relative for the ×.' },
28
+ { part: 'media', element: 'span', description: 'Wrapper for a 40px FeaturedIcon (system prompts) or a lg Avatar (a person\'s action).', optional: true },
29
+ { part: 'content', element: 'div', description: 'Title, description and, in the stacked variants, the actions and progress.' },
30
+ { part: 'title', element: 'div', description: 'label-md title, one line: what is offered or needed ("Verify your email address").' },
31
+ { part: 'description', element: 'p', description: 'body-sm muted, one or two lines: why, and the concrete number when there is one.' },
32
+ { part: 'progress', element: 'div', description: '8px track (bg-muted, radius full) under the description for used-of-limit prompts. role="progressbar".', optional: true },
33
+ { part: 'progress-fill', element: 'div', description: 'The action-colored fill; width is the inline percentage.', optional: true },
34
+ { part: 'actions', element: 'div', description: 'Row of sm Buttons: ghost secondary first, then one link-color or primary action. Inside the content, or beside it in the banner variant.' },
35
+ { part: 'dismiss', element: 'button', description: '28px button with a 20px × in the top-right corner (or at the end of the banner row). aria-label "Dismiss".', optional: true },
36
+ ],
37
+ props: {
38
+ variant: {
39
+ values: ['default', 'with-progress', 'with-avatar', 'banner'],
40
+ default: 'default',
41
+ description: 'default = featured icon, text, actions under the text. with-progress = adds an 8px progress bar between the description and the actions (storage, seats, quota). with-avatar = a person\'s avatar instead of the icon, for invitations and requests from someone. banner = one line: text on the left, actions on the right, × at the end; for wide content areas.',
42
+ },
43
+ tone: {
44
+ values: ['neutral', 'brand', 'warning', 'success'],
45
+ default: 'neutral',
46
+ description: 'neutral = white surface with a hairline, for housekeeping (storage, invites). brand = action-subtle surface for upgrades and new features. warning = warning-subtle surface for something that blocks a next step (verify, expiring). success = success-subtle surface for a completed action that has a follow-up (accepted, connected).',
47
+ },
48
+ },
49
+ states: {
50
+ dismissHover: { selector: ' .cn-inline-cta__dismiss:hover', description: 'Pointer over the × (extraCss): subtle fill, ink icon.', markup: 'native :hover on .cn-inline-cta__dismiss' },
51
+ dismissFocus: { selector: ' .cn-inline-cta__dismiss:focus-visible', description: 'Keyboard focus on the × shows the focus ring (extraCss).', markup: 'native :focus-visible on .cn-inline-cta__dismiss' },
52
+ },
53
+ base: {
54
+ root: {
55
+ position: 'relative',
56
+ display: 'flex',
57
+ 'align-items': 'flex-start',
58
+ gap: '{space.4}',
59
+ width: '100%',
60
+ 'min-width': '0',
61
+ padding: '{space.4}',
62
+ 'background-color': '{color.bg-surface}',
63
+ border: HAIRLINE,
64
+ 'border-radius': '{radius.card}',
65
+ 'box-shadow': '{shadow.xs}',
66
+ color: '{color.fg-default}',
67
+ },
68
+ media: { display: 'inline-flex', 'flex-shrink': '0' },
69
+ content: { flex: '1 1 auto', 'min-width': '0', display: 'flex', 'flex-direction': 'column', gap: '{space.1}', 'padding-inline-end': '{space.8}' },
70
+ title: { ...typeStyle('label-md'), 'line-height': '{font.lineHeight.normal}', color: '{color.fg-default}', 'overflow-wrap': 'anywhere' },
71
+ description: { ...typeStyle('body-sm'), color: '{color.fg-muted}', 'overflow-wrap': 'anywhere' },
72
+ progress: { width: '100%', height: '{space.2}', 'margin-top': '{space.2}', 'border-radius': '{radius.full}', 'background-color': '{color.bg-muted}', overflow: 'hidden' },
73
+ 'progress-fill': { height: '100%', width: '0', 'border-radius': '{radius.full}', 'background-color': '{color.bg-action}', 'transition-property': 'width', 'transition-duration': '{motion.duration.slow}', 'transition-timing-function': '{motion.easing.standard}' },
74
+ actions: { display: 'flex', 'flex-wrap': 'wrap', 'align-items': 'center', gap: '{space.3}', 'margin-top': '{space.2}' },
75
+ dismiss: {
76
+ ...RESET_BUTTON,
77
+ position: 'absolute',
78
+ top: '{space.2}',
79
+ 'inset-inline-end': '{space.2}',
80
+ display: 'inline-flex',
81
+ 'align-items': 'center',
82
+ 'justify-content': 'center',
83
+ width: '{space.7}',
84
+ height: '{space.7}',
85
+ 'border-radius': '{radius.md}',
86
+ color: '{color.fg-subtle}',
87
+ ...TRANSITION_COLORS,
88
+ },
89
+ },
90
+ variants: {
91
+ variant: {
92
+ default: { root: {} },
93
+ 'with-progress': { root: {} },
94
+ 'with-avatar': { root: {} },
95
+ banner: {
96
+ root: { 'align-items': 'center', 'flex-wrap': 'wrap' },
97
+ content: { flex: '1 1 12rem', 'padding-inline-end': '0' },
98
+ actions: { 'margin-top': '0', 'margin-inline-start': 'auto', 'flex-shrink': '1', 'flex-wrap': 'wrap' },
99
+ dismiss: { position: 'static', 'flex-shrink': '0' },
100
+ },
101
+ },
102
+ tone: {
103
+ neutral: { root: {} },
104
+ brand: { root: { 'background-color': '{color.bg-action-subtle}', 'border-color': 'transparent' } },
105
+ warning: { root: { 'background-color': '{color.bg-warning-subtle}', 'border-color': '{color.border-warning}' } },
106
+ success: { root: { 'background-color': '{color.bg-success-subtle}', 'border-color': '{color.border-success}' } },
107
+ },
108
+ },
109
+ extraCss: `
110
+ .cn-inline-cta__dismiss .cn-icon { width: {size.icon.lg}; height: {size.icon.lg}; }
111
+ .cn-inline-cta__dismiss:hover { background-color: color-mix(in srgb, {color.fg-default} 8%, transparent); color: {color.fg-default}; }
112
+ .cn-inline-cta__dismiss:focus-visible { outline: none; box-shadow: ${FOCUS_RING['box-shadow']}; }
113
+ .cn-inline-cta__actions > .cn-button[data-variant="ghost"]:first-child { margin-inline-start: calc(-1 * {size.controlPadding.sm}); }
114
+ .cn-inline-cta[data-variant="banner"] .cn-inline-cta__actions > .cn-button[data-variant="ghost"]:first-child { margin-inline-start: 0; }`,
115
+ examples: [
116
+ ex('Upgrade to Pro (brand)', cta('default', 'brand', `${featured(SVG.spark, 'brand')}${content('Upgrade to Pro', 'Unlimited dashboards, 30-day history and priority support for your whole team.', actions(btn('Dismiss', 'ghost'), btn('Upgrade plan', 'primary')))}${DISMISS}`), 'A featured icon, two lines and a primary action on the action-subtle surface.'),
117
+ ex('Storage with progress', cta('with-progress', 'neutral', `${featured(SVG.drive, 'gray')}${content('Storage almost full', '128 GB of 150 GB used. Uploads pause at 100%.', progress(85, 'Storage used') + actions(btn('Dismiss', 'ghost'), btn('Manage storage', 'link-color')))}${DISMISS}`), 'The number is in the text; the bar shows it. Neutral surface for housekeeping.'),
118
+ ex('Verify your email (warning banner)', cta('banner', 'warning', `${featured(SVG.mail, 'warning')}${content('Verify your email address', 'We sent a link to maya@lumen.co. It expires in 24 hours.')}${actions(btn('Resend link', 'link'), btn('Open inbox', 'outline'))}${DISMISS}`), 'One line on wide content: text left, actions right, × at the end.'),
119
+ ex('With avatar (success)', cta('with-avatar', 'success', `${avatar('DC', 'Daniel Costa')}${content('Daniel Costa accepted your invite', 'He now has access to the Q3 launch plan workspace.', actions(btn('Dismiss', 'ghost'), btn('View profile', 'link-color')))}${DISMISS}`), 'A person\'s action shows their avatar instead of an icon.'),
120
+ ],
121
+ rules: [
122
+ 'One ask per row: the title names it, the description gives the reason and the number, the last action does it.',
123
+ 'At most two actions: a ghost sm secondary ("Dismiss", "Later") first and one link-color or primary sm action last. Primary only for the one conversion of the page.',
124
+ 'Featured icon for system prompts, avatar for a person\'s action; never both, never an image.',
125
+ 'Tone follows meaning: brand for upgrades and launches, warning for something that blocks the next step, success for a completed action with a follow-up, neutral for housekeeping.',
126
+ 'Progress appears only with a real limit and the used-of-total in the description; the fill stays action-colored, the text carries the warning.',
127
+ 'Use banner only when the row is wider than 640px; below that the actions wrap under the text (default).',
128
+ 'Every InlineCta is dismissible unless the ask blocks the page (then it is a Banner). Remember the dismissal per user.',
129
+ 'Place it next to what it talks about (above the upload list, under the email field), never at the top of every page.',
130
+ ],
131
+ a11y: [
132
+ 'The row is a labelled region (role="region" + aria-label) or a plain <section>; use role="status" only when it appears in response to an action.',
133
+ 'Featured icons are aria-hidden; avatars with initials carry role="img" + aria-label with the person\'s name.',
134
+ 'The progress bar has role="progressbar" with aria-valuenow/min/max and an aria-label; the same number is in the description.',
135
+ 'The × has aria-label "Dismiss" and is the last Tab stop in the row; actions are real <button>s or links.',
136
+ 'Tone is never the only signal: the title says "Verify", "Upgrade" or "Accepted" in words.',
137
+ ],
138
+ related: ['featured-card', 'banner', 'alert', 'featured-icon', 'avatar', 'button'],
139
+ };
@@ -0,0 +1,164 @@
1
+ import { CONTROL, FOCUS_RING, TRANSITION_COLORS, SIZE_PROP, ex, ICON, typeStyle } from "./_shared.mjs";
2
+ // Input with addons on either side: a text prefix/suffix, a bare <select>, a leading icon or a
3
+ // trailing Button. The wrapper draws the one border, radius, shadow and focus ring; every addon
4
+ // is separated from the field by a hairline and inherits the wrapper's height.
5
+ const sizeBlock = (s) => ({
6
+ root: { height: CONTROL[s].height, 'border-radius': CONTROL[s].radius },
7
+ field: { 'padding-inline': CONTROL[s].px, 'font-size': CONTROL[s].font },
8
+ addon: { 'font-size': CONTROL[s].font },
9
+ select: { 'font-size': CONTROL[s].font },
10
+ icon: { width: CONTROL[s].icon, height: CONTROL[s].icon, 'margin-inline-start': CONTROL[s].px },
11
+ });
12
+ const chevron = ICON.chevronDown.replace('cn-icon', 'cn-input-group__icon');
13
+ const search = ICON.search.replace('cn-icon', 'cn-input-group__icon');
14
+ const copyIcon = ICON.copy.replace('cn-icon', 'cn-button__icon');
15
+ const group = (inner, attrs = 'data-size="md"') => `<div class="cn-input-group" ${attrs} style="max-width:360px">${inner}</div>`;
16
+ const field = (attrs) => `<input class="cn-input-group__field" type="text" ${attrs}>`;
17
+ const addon = (text) => `<span class="cn-input-group__addon">${text}</span>`;
18
+ const select = (label, options) => `<select class="cn-input-group__select" aria-label="${label}">${options.map((o, i) => `<option${i === 0 ? ' selected' : ''}>${o}</option>`).join('')}</select>${chevron}`;
19
+ const copyButton = `<button type="button" class="cn-button cn-input-group__button" data-variant="ghost" data-size="sm">${copyIcon}<span class="cn-button__label">Copy</span></button>`;
20
+ export const inputGroup = {
21
+ name: 'InputGroup',
22
+ slug: 'input-group',
23
+ category: 'forms',
24
+ description: 'An Input with addons attached to one or both ends: a muted text prefix or suffix ("https://", "USD"), a bare select, a leading icon or a trailing Button. One hairline box, one shadow, one focus ring around everything.',
25
+ usage: 'Use when a fixed part of the value belongs to the field: a protocol, a domain, a unit, a currency or a region selector, or when a "Copy" action must sit inside the field. For a lone prefix text the Input affix is enough; for a numeric stepper use NumberInput; for tokens use TagsInput. Pair with Field for the label.',
26
+ anatomy: [
27
+ { part: 'root', element: 'div', description: 'The bordered wrapper (flex, stretch). Draws background, hairline, radius, shadow-xs and the focus ring on :focus-within. Carries data-size, data-invalid, data-disabled.' },
28
+ { part: 'icon', element: 'svg', description: 'Decorative 20px icon: a leading icon before the field, or the chevron that follows a select addon. aria-hidden, pointer-events none.', optional: true },
29
+ { part: 'addon', element: 'span', description: 'Static text addon such as "https://" or "USD": fg-muted, padding-inline space.3, separated from the field by a hairline. Leading when placed before the field, trailing when after.', optional: true },
30
+ { part: 'select', element: 'select', description: 'A bare native <select> used as an addon (currency, region, protocol), followed by an icon element that draws the chevron. Leading or trailing.', optional: true },
31
+ { part: 'field', element: 'input', description: 'The native <input>: no border, no outline, fills the remaining width.' },
32
+ { part: 'button', element: 'button', description: 'A trailing Button (class cn-button, variant ghost, size sm) that also carries this part so it loses its radius and stretches to the group height. One per group, always last.', optional: true },
33
+ ],
34
+ props: {
35
+ size: SIZE_PROP(['sm', 'md', 'lg']),
36
+ },
37
+ states: {
38
+ hover: { selector: ':hover:not([data-disabled])', description: 'Border strengthens on the whole group.', markup: 'native :hover' },
39
+ focus: { selector: ':focus-within', description: 'Border becomes the action color and the ring appears around the whole group, whichever child has focus.', markup: 'native :focus-within on the wrapper' },
40
+ invalid: { selector: '[data-invalid], &:has([aria-invalid="true"])', description: 'Danger border, danger ring on focus. Always accompanied by a Field error.', markup: 'aria-invalid="true" on the input, or data-invalid on the wrapper' },
41
+ disabled: { selector: '[data-disabled], &:has(input:disabled)', description: 'Grey fill, disabled text on every addon, not interactive. The trailing Button keeps full opacity but takes the disabled color.', markup: 'disabled on the input (and on the button)' },
42
+ },
43
+ base: {
44
+ root: {
45
+ display: 'flex',
46
+ 'align-items': 'stretch',
47
+ width: '100%',
48
+ 'min-width': '0',
49
+ 'background-color': '{color.bg-surface}',
50
+ border: '{border.width.thin} solid {color.border-control}',
51
+ color: '{color.fg-default}',
52
+ 'box-shadow': '{shadow.xs}',
53
+ overflow: 'hidden',
54
+ ...TRANSITION_COLORS,
55
+ },
56
+ field: {
57
+ flex: '1 1 auto',
58
+ 'min-width': '0',
59
+ height: '100%',
60
+ margin: '0',
61
+ border: '0',
62
+ outline: 'none',
63
+ background: 'transparent',
64
+ ...typeStyle('body-md'),
65
+ 'line-height': '{font.lineHeight.none}',
66
+ color: 'inherit',
67
+ },
68
+ addon: {
69
+ display: 'flex',
70
+ 'align-items': 'center',
71
+ 'flex-shrink': '0',
72
+ 'padding-inline': '{space.3}',
73
+ color: '{color.fg-muted}',
74
+ 'background-color': '{color.bg-surface}',
75
+ 'border-inline-end': '{border.width.thin} solid {color.border-control}',
76
+ ...typeStyle('body-md'),
77
+ 'line-height': '{font.lineHeight.none}',
78
+ 'white-space': 'nowrap',
79
+ 'user-select': 'none',
80
+ },
81
+ select: {
82
+ appearance: 'none',
83
+ '-webkit-appearance': 'none',
84
+ 'flex-shrink': '0',
85
+ 'align-self': 'stretch',
86
+ margin: '0',
87
+ border: '0',
88
+ outline: 'none',
89
+ background: 'transparent',
90
+ 'padding-inline-start': '{space.3}',
91
+ 'padding-inline-end': 'calc({space.2.5} + {size.controlIcon.md} + {space.2.5})',
92
+ ...typeStyle('body-md'),
93
+ 'line-height': '{font.lineHeight.none}',
94
+ color: '{color.fg-muted}',
95
+ cursor: 'pointer',
96
+ 'border-inline-end': '{border.width.thin} solid {color.border-control}',
97
+ ...TRANSITION_COLORS,
98
+ },
99
+ button: {
100
+ 'align-self': 'stretch',
101
+ height: 'auto',
102
+ 'flex-shrink': '0',
103
+ 'border-radius': '0',
104
+ 'padding-inline': '{space.3}',
105
+ 'border-inline-start': '{border.width.thin} solid {color.border-control}',
106
+ },
107
+ icon: { 'flex-shrink': '0', 'align-self': 'center', color: '{color.fg-subtle}', 'pointer-events': 'none' },
108
+ '@states': {
109
+ hover: { root: { 'border-color': '{color.border-control-hover}' } },
110
+ focus: { root: { ...FOCUS_RING, 'border-color': '{color.border-action}' } },
111
+ invalid: { root: { 'border-color': '{color.border-danger}' } },
112
+ disabled: {
113
+ root: { 'background-color': '{color.bg-disabled}', color: '{color.fg-disabled}', 'border-color': '{color.border-disabled}', 'box-shadow': 'none', cursor: 'not-allowed' },
114
+ field: { cursor: 'not-allowed' },
115
+ addon: { color: '{color.fg-disabled}', 'background-color': 'transparent', 'border-color': '{color.border-disabled}' },
116
+ select: { color: '{color.fg-disabled}', cursor: 'not-allowed', 'border-color': '{color.border-disabled}' },
117
+ button: { 'border-color': '{color.border-disabled}' },
118
+ icon: { color: '{color.fg-disabled}' },
119
+ },
120
+ },
121
+ },
122
+ variants: {
123
+ size: { sm: sizeBlock('sm'), md: sizeBlock('md'), lg: sizeBlock('lg') },
124
+ },
125
+ extraCss: `
126
+ .cn-input-group .cn-input-group__field::placeholder { color: {color.fg-placeholder}; opacity: 1; }
127
+ .cn-input-group:has([aria-invalid="true"]):focus-within, .cn-input-group[data-invalid]:focus-within { box-shadow: {shadow.focus-danger}; border-color: {color.border-danger}; }
128
+ .cn-input-group .cn-input-group__icon:first-child + .cn-input-group__field { padding-inline-start: {space.2}; }
129
+ .cn-input-group .cn-input-group__field ~ .cn-input-group__addon, .cn-input-group .cn-input-group__field ~ .cn-input-group__select { border-inline-end: 0; border-inline-start: {border.width.thin} solid {color.border-control}; }
130
+ .cn-input-group .cn-input-group__select + .cn-input-group__icon { margin-inline-start: calc(({size.controlIcon.md} + {space.2.5}) * -1); margin-inline-end: 0; }
131
+ .cn-input-group .cn-input-group__select:hover:not(:disabled) { color: {color.fg-default}; }
132
+ .cn-input-group .cn-input-group__select:focus-visible { background-color: {color.bg-subtle}; color: {color.fg-default}; }
133
+ .cn-input-group .cn-input-group__button { box-shadow: none; }
134
+ .cn-input-group .cn-input-group__button:focus-visible { box-shadow: inset 0 0 0 2px {color.border-action}; }
135
+ .cn-input-group .cn-input-group__button:disabled { opacity: 1; color: {color.fg-disabled}; }`,
136
+ examples: [
137
+ ex('Leading text addon', group(`${addon('https://')}${field('placeholder="lumen.co" aria-label="Website"')}`), '"https://" sits in the muted addon; the user types only the host.'),
138
+ ex('Trailing text addon', group(`${field('value="maya.chen" aria-label="Workspace handle"')}${addon('@lumen.co')}`), 'The domain is fixed, so it lives in the field instead of the helper text.'),
139
+ ex('Leading select (currency)', group(`${select('Currency', ['USD', 'EUR', 'BRL', 'JPY'])}${field('inputmode="decimal" value="1,200.00" aria-label="Amount"')}`), 'A bare native select as an addon; the chevron is an icon element placed right after it.'),
140
+ ex('Trailing button (copy)', group(`${field('value="https://app.lumen.co/invite/8f3k2p" readonly aria-label="Invite link"')}${copyButton}`), 'A ghost Button (size sm) that carries the __button part: no radius, stretched to the group, separated by a hairline.'),
141
+ ex('Icon and trailing dropdown', group(`${search}${field('type="search" placeholder="Search prospects…" aria-label="Search prospects"')}${select('Region', ['All regions', 'Europe', 'Latin America', 'Asia-Pacific'])}`), 'Leading icon, then the field, then a select addon at the end.'),
142
+ ex('Sizes', `<div style="display:flex;flex-direction:column;gap:var(--cn-space-3);width:100%;min-width:0;max-width:360px">${group(`${addon('https://')}${field('placeholder="lumen.co" aria-label="Website (small)"')}`, 'data-size="sm"')}${group(`${addon('https://')}${field('placeholder="lumen.co" aria-label="Website (medium)"')}`, 'data-size="md"')}${group(`${addon('https://')}${field('placeholder="lumen.co" aria-label="Website (large)"')}`, 'data-size="lg"')}</div>`, 'sm / md / lg follow the control heights; addons stretch with the group.'),
143
+ ex('Invalid', group(`${addon('https://')}${field('value="lumen" aria-invalid="true" aria-label="Website"')}`), 'Pair with a Field error such as "Enter a full domain, like lumen.co".'),
144
+ ex('Disabled', group(`${select('Currency', ['USD'])}${field('value="1,200.00" disabled aria-label="Amount"')}`)),
145
+ ],
146
+ rules: [
147
+ 'One border for the whole group: the wrapper draws it; addons, the select and the button never draw their own outer border, only the hairline that separates them from the field.',
148
+ 'Text addons hold what the user must not type: protocol, domain, unit, currency code. If the addon changes the meaning of the value (currency, region), make it a select addon instead.',
149
+ 'At most one addon per side, and at most one Button per group, always trailing. A group with more than three children is a toolbar, not a field.',
150
+ 'The trailing Button is a ghost cn-button, size sm, whatever the group size; it also carries the __button part so it stretches and loses its radius.',
151
+ 'Addon labels are 1–2 words or a code ("USD", "@lumen.co", "https://"); never a full sentence.',
152
+ 'Sizes: md in forms; sm inside toolbars and tables; lg on marketing and auth screens.',
153
+ 'Width comes from the layout. The field takes the remaining width; addons keep their intrinsic width.',
154
+ 'Error state must show a Field error under the group; color alone is not enough.',
155
+ ],
156
+ a11y: [
157
+ 'The input has an accessible name (label for / aria-label). A text addon is visual: repeat its meaning in the label when it matters ("Website (https://)").',
158
+ 'A select addon needs its own aria-label ("Currency"); the chevron svg is aria-hidden.',
159
+ 'The trailing button is a real <button type="button"> with a verb label; it is a separate Tab stop after the input.',
160
+ 'aria-invalid="true" and aria-describedby pointing at the Field error id when invalid.',
161
+ 'Focus is shown on the whole group (:focus-within); the button shows an inset ring so it stays visible inside the clipped wrapper.',
162
+ ],
163
+ related: ['input', 'select', 'button', 'field', 'number-input', 'tags-input'],
164
+ };
@@ -0,0 +1,197 @@
1
+ import { CONTROL, SIZE_PROP, ex, ICON, typeStyle } from "./_shared.mjs";
2
+ // the reference text field, measure for measure: 36 / 40 / 44px tall, padding 8×12 (sm, md) and
3
+ // 10×14 (lg), 14px text (16px on lg; the reference also uses 16px on md — we keep 14px as the
4
+ // default UI size), a 1px inset gray-300 ring under shadow-xs, radius 8, 100ms linear transitions.
5
+ // Focus = brand ring, invalid = red-300 ring (red focus ring), disabled = whole control at 50%.
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
+ /** the reference draws the border as a 1px inset ring so it never changes layout and stacks with shadow-xs. */
12
+ const ring = (color) => `inset 0 0 0 1px ${color}`;
13
+ const RING_REST = `${ring('{color.border-control}')}, {shadow.xs}`;
14
+ const RING_HOVER = `${ring('{color.border-control-hover}')}, {shadow.xs}`;
15
+ const RING_FOCUS = `${ring('{color.border-action}')}, {shadow.xs}, {shadow.focus}`;
16
+ const RING_INVALID = `${ring('{color.border-danger}')}, {shadow.xs}`;
17
+ const RING_INVALID_FOCUS = `${ring('{color.border-danger}')}, {shadow.xs}, {shadow.focus-danger}`;
18
+ /** px = horizontal padding, icon = leading icon size, pr = right inset of the shortcut chip. */
19
+ const SIZE = {
20
+ sm: { px: '{space.3}', icon: '{size.icon.sm}', pr: '{space.1.5}', lh: '{type.body-md.lineHeight}' },
21
+ md: { px: '{space.3}', icon: '{size.icon.md}', pr: '{space.2}', lh: '{type.body-md.lineHeight}' },
22
+ lg: { px: '{space.3.5}', icon: '{size.icon.md}', pr: '{space.2.5}', lh: '{type.body-lg.lineHeight}' },
23
+ };
24
+ const sizeBlock = (s) => ({
25
+ root: { height: CONTROL[s].height },
26
+ field: { 'padding-inline': SIZE[s].px, 'font-size': CONTROL[s].font, 'line-height': SIZE[s].lh },
27
+ affix: { 'font-size': CONTROL[s].font, 'padding-inline-start': SIZE[s].px, 'padding-inline-end': s === 'sm' ? '{space.1.5}' : '{space.2}' },
28
+ icon: { width: SIZE[s].icon, height: SIZE[s].icon, 'margin-inline-start': SIZE[s].px },
29
+ tooltip: { 'margin-inline-end': SIZE[s].px },
30
+ shortcut: { 'margin-inline-end': SIZE[s].pr },
31
+ });
32
+ const icon = (name) => ICON[name].replace('cn-icon', 'cn-input__icon');
33
+ const help = `<button type="button" class="cn-input__tooltip" aria-label="More information">${ICON.info}</button>`;
34
+ const shortcut = (keys = '⌘K') => `<span class="cn-input__shortcut" aria-hidden="true">${keys}</span>`;
35
+ const inp = (attrs, size = 'md', variant = 'default', before = '', after = '', style = 'max-width:320px') => `<div class="cn-input" data-variant="${variant}" data-size="${size}" style="${style}">${before}<input class="cn-input__field" ${attrs}>${after}</div>`;
36
+ const row = (...items) => `<div style="display:flex;flex-direction:column;gap:var(--cn-space-4)">${items.join('')}</div>`;
37
+ export const input = {
38
+ name: 'Input',
39
+ slug: 'input',
40
+ category: 'forms',
41
+ description: 'Single-line text field: a white wrapper with a 1px gray-300 inset ring and shadow-xs, radius 8, 36 / 40 / 44px tall. Optional leading icon, inline prefix or suffix, a trailing help icon (tooltip) and a keyboard-shortcut chip. The wrapper carries the ring; the native input is bare.',
42
+ usage: 'Free text, numbers, email, search, URLs. Pair with Field for label + hint + error. For choices use Select or Combobox; for multi-line text use Textarea.',
43
+ anatomy: [
44
+ { part: 'root', element: 'div', description: 'Wrapper that draws the ring, background, radius, shadow-xs and the focus ring (:focus-within). Flex row, height from size.' },
45
+ { part: 'icon', element: 'svg', description: 'Optional leading icon (search, mail): 16px on sm, 20px on md and lg, gray-500, inset by the horizontal padding; the text starts 8px after it (36 / 40 / 42px from the edge). Decorative.', optional: true },
46
+ { part: 'affix', element: 'span', description: 'Optional inline prefix or suffix text ("https://", "USD", "kg") in the muted color, no fill and no rule: padded 12px on the outside (14px on lg) and 6–8px next to the text.', optional: true },
47
+ { part: 'field', element: 'input', description: 'The native <input>. No border, no outline, fills the wrapper; padding 12px (14px on lg) on each side.' },
48
+ { part: 'tooltip', element: 'button', description: 'Optional trailing 16px help icon that opens a Tooltip on hover/focus (aria-label = the tooltip text). Gray-500, darker on hover. When the field is invalid the same slot shows the alert icon in the danger color.', optional: true },
49
+ { part: 'shortcut', element: 'span', description: 'Optional keyboard-shortcut chip at the trailing edge ("⌘K"): 12px medium gray-500 on white, 1px gray-200 ring, radius 4, padding 1px 4px, 6 / 8 / 10px from the edge. aria-hidden; hidden under 768px.', optional: true },
50
+ ],
51
+ props: {
52
+ size: SIZE_PROP(['sm', 'md', 'lg']),
53
+ variant: { values: ['default', 'filled'], default: 'default', description: 'default = white with the gray-300 ring and shadow-xs (the reference input; forms, dialogs, settings); filled = gray-50 fill without ring or shadow until focus, for dense toolbars and table filters (no reference equivalent).' },
54
+ },
55
+ states: {
56
+ 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' },
57
+ focus: { selector: ':focus-within', description: 'The inset ring turns to the brand color and the 4px brand focus ring appears around the wrapper (never on the input itself).', markup: 'native :focus-within on the wrapper' },
58
+ invalid: { selector: '[data-invalid], &:has([aria-invalid="true"])', description: 'Red-300 ring; on focus the ring stays red and the focus ring turns red. The help slot shows the alert icon in the danger color. Always accompanied by a Field error.', markup: 'aria-invalid="true" on the input, or data-invalid on the wrapper' },
59
+ disabled: { selector: '[data-disabled], &:has(:disabled)', description: 'Whole control at 50% opacity, cursor not-allowed, same fill and ring.', markup: 'disabled on the input' },
60
+ readonly: { selector: ':has([readonly])', description: 'Looks like default; text stays selectable and the focus ring still shows.', markup: 'readonly on the input' },
61
+ },
62
+ base: {
63
+ root: {
64
+ position: 'relative',
65
+ display: 'flex',
66
+ 'align-items': 'center',
67
+ width: '100%',
68
+ 'min-width': '0',
69
+ 'background-color': '{color.bg-surface}',
70
+ 'border-radius': '{radius.control}',
71
+ 'box-shadow': RING_REST,
72
+ color: '{color.fg-default}',
73
+ ...TRANSITION_FAST,
74
+ },
75
+ field: {
76
+ flex: '1 1 auto',
77
+ 'min-width': '0',
78
+ height: '100%',
79
+ margin: '0',
80
+ 'padding-block': '0',
81
+ border: '0',
82
+ outline: 'none',
83
+ background: 'transparent',
84
+ 'box-shadow': 'none',
85
+ ...typeStyle('body-md'),
86
+ color: 'inherit',
87
+ },
88
+ icon: { 'flex-shrink': '0', color: '{color.fg-subtle}', 'pointer-events': 'none' },
89
+ affix: {
90
+ display: 'flex',
91
+ 'align-items': 'center',
92
+ 'align-self': 'stretch',
93
+ 'flex-shrink': '0',
94
+ ...typeStyle('body-md'),
95
+ color: '{color.fg-muted}',
96
+ 'white-space': 'nowrap',
97
+ 'user-select': 'none',
98
+ },
99
+ tooltip: {
100
+ display: 'inline-flex',
101
+ 'align-items': 'center',
102
+ 'justify-content': 'center',
103
+ 'flex-shrink': '0',
104
+ width: '{size.icon.sm}',
105
+ height: '{size.icon.sm}',
106
+ margin: '0',
107
+ padding: '0',
108
+ border: '0',
109
+ background: 'none',
110
+ 'border-radius': '{radius.sm}',
111
+ color: '{color.fg-subtle}',
112
+ cursor: 'pointer',
113
+ ...TRANSITION_FAST,
114
+ },
115
+ shortcut: {
116
+ display: 'inline-flex',
117
+ 'align-items': 'center',
118
+ 'flex-shrink': '0',
119
+ padding: '{space.px} {space.1}',
120
+ 'border-radius': '{radius.sm}',
121
+ ...typeStyle('label-xs'),
122
+ color: '{color.fg-subtle}',
123
+ 'background-color': '{color.bg-surface}',
124
+ 'box-shadow': ring('{color.border-default}'),
125
+ 'white-space': 'nowrap',
126
+ 'user-select': 'none',
127
+ 'pointer-events': 'none',
128
+ },
129
+ '@states': {
130
+ hover: { root: { 'box-shadow': RING_HOVER } },
131
+ focus: { root: { 'box-shadow': RING_FOCUS } },
132
+ invalid: { root: { 'box-shadow': RING_INVALID }, tooltip: { color: '{color.fg-danger}' } },
133
+ disabled: { root: { opacity: '{opacity.disabled}', cursor: 'not-allowed' }, field: { cursor: 'not-allowed' }, tooltip: { cursor: 'not-allowed' } },
134
+ },
135
+ },
136
+ variants: {
137
+ variant: {
138
+ default: { root: {} },
139
+ filled: {
140
+ root: { 'background-color': '{color.bg-subtle}', 'box-shadow': 'none' },
141
+ '@states': {
142
+ hover: { root: { 'background-color': '{color.bg-muted}', 'box-shadow': 'none' } },
143
+ focus: { root: { 'background-color': '{color.bg-surface}', 'box-shadow': RING_FOCUS } },
144
+ invalid: { root: { 'box-shadow': RING_INVALID } },
145
+ },
146
+ },
147
+ },
148
+ size: { sm: sizeBlock('sm'), md: sizeBlock('md'), lg: sizeBlock('lg') },
149
+ },
150
+ extraCss: `
151
+ .cn-input .cn-input__field::placeholder { color: {color.fg-placeholder}; opacity: 1; }
152
+ .cn-input .cn-input__field:-webkit-autofill { border-radius: {radius.control}; -webkit-text-fill-color: {color.fg-default}; }
153
+ .cn-input:has([aria-invalid="true"]):focus-within, .cn-input[data-invalid]:focus-within { box-shadow: ${RING_INVALID_FOCUS}; }
154
+ .cn-input .cn-input__icon + .cn-input__field { padding-inline-start: {space.2}; }
155
+ .cn-input .cn-input__affix + .cn-input__field { padding-inline-start: 0; }
156
+ .cn-input .cn-input__field + .cn-input__affix { padding-inline-start: {space.2}; }
157
+ .cn-input[data-size="sm"] .cn-input__field + .cn-input__affix { padding-inline-start: {space.1.5}; }
158
+ .cn-input[data-size="lg"] .cn-input__field + .cn-input__affix { padding-inline-end: {space.3.5}; }
159
+ .cn-input:has(> .cn-input__tooltip) .cn-input__field, .cn-input:has(> .cn-input__shortcut) .cn-input__field { padding-inline-end: {space.2}; }
160
+ .cn-input .cn-input__tooltip .cn-icon { width: {size.icon.sm}; height: {size.icon.sm}; stroke-width: 2.25; }
161
+ .cn-input .cn-input__tooltip:hover, .cn-input .cn-input__tooltip:focus-visible { color: {color.fg-muted}; }
162
+ .cn-input .cn-input__tooltip:focus-visible { outline: none; box-shadow: {shadow.focus}; }
163
+ .cn-input[data-invalid] .cn-input__tooltip:hover, .cn-input:has([aria-invalid="true"]) .cn-input__tooltip:hover { color: {color.fg-danger}; }
164
+ .cn-input .cn-input__field[type="search"]::-webkit-search-decoration, .cn-input .cn-input__field[type="search"]::-webkit-search-cancel-button { -webkit-appearance: none; }
165
+ @media (max-width: {breakpoint.md}) { .cn-input .cn-input__shortcut { display: none; } }`,
166
+ examples: [
167
+ ex('Default', inp('type="email" placeholder="maya@lumen.app" aria-label="Work email"'), '40px tall, 12px padding, gray-300 ring and shadow-xs, radius 8.'),
168
+ ex('Sizes', row(inp('type="text" placeholder="Small · 36px" aria-label="Small"', 'sm'), inp('type="text" placeholder="Medium · 40px" aria-label="Medium"', 'md'), inp('type="text" placeholder="Large · 44px" aria-label="Large"', 'lg')), '36 / 40 / 44px. Text is 14px on sm and md, 16px on lg; padding grows to 14px on lg.'),
169
+ ex('With leading icon', row(inp('type="search" placeholder="Search workspaces…" aria-label="Search"', 'md', 'default', icon('search')), inp('type="search" placeholder="Filter members…" aria-label="Filter members"', 'sm', 'default', icon('search'))), 'Icon 20px (16px on sm), gray-500, inset by the padding; the text starts 8px after it.'),
170
+ ex('With prefix and suffix', row(inp('type="url" placeholder="lumen.app" aria-label="Website"', 'md', 'default', '<span class="cn-input__affix">https://</span>'), inp('type="number" value="2500" aria-label="Monthly budget"', 'md', 'default', '<span class="cn-input__affix">USD</span>', '<span class="cn-input__affix">/ month</span>')), 'Inline affixes in the muted color, no fill and no rule; the text follows 8px after a prefix.'),
171
+ ex('With help icon and shortcut', row(inp('type="text" placeholder="Workspace name" aria-label="Workspace name" aria-describedby="in-help"', 'md', 'default', '', help), inp('type="search" placeholder="Search…" aria-label="Search"', 'md', 'default', icon('search'), shortcut())), 'The help icon (16px, gray-500) sits 12px from the edge and opens a Tooltip; the ⌘K chip sits 8px from the edge and hides under 768px.'),
172
+ ex('Filled (toolbar)', inp('type="text" placeholder="Filter…" aria-label="Filter"', 'sm', 'filled', '', '', 'max-width:240px'), 'Gray-50 fill without a ring; the ring appears on focus.'),
173
+ ex('Invalid', inp('type="email" value="maya@lumen" aria-invalid="true" aria-label="Work email" aria-describedby="in-err"', 'md', 'default', '', help), 'Red-300 ring; the help slot turns into the danger alert icon. Pair with a Field error such as "Enter a complete address".'),
174
+ ex('Disabled', inp('type="text" value="Lumen Studio" disabled aria-label="Workspace"'), 'Same fill and ring at 50% opacity, cursor not-allowed.'),
175
+ ],
176
+ recipes: [
177
+ ex('Search with shortcut in a topbar', `<div style="display:flex;align-items:center;gap:var(--cn-space-3);max-width:560px">${inp('type="search" placeholder="Search projects, people, files…" aria-label="Search"', 'md', 'default', icon('search'), shortcut(), 'flex:1 1 auto')}<button type="button" class="cn-button" data-variant="outline" data-size="md"><span class="cn-button__label">Filters</span></button></div>`, 'A search field with a leading icon and the ⌘K chip next to an outline button of the same height.'),
178
+ ],
179
+ rules: [
180
+ 'Always inside a Field with a visible label, except search inputs with a leading icon and a placeholder that says what is searched.',
181
+ 'Placeholder is an example, not a label ("maya@lumen.app", not "Email").',
182
+ 'Width comes from the layout (100% of the Field). Never set a fixed pixel width; use max-width on the Field for short values.',
183
+ 'Sizes: md (40px) in forms and dialogs; sm (36px) inside toolbars, tables and filters; lg (44px) on marketing and auth screens where 16px text is wanted.',
184
+ 'Never change the height by adding padding. Height comes only from size: 36 / 40 / 44px with 12 / 12 / 14px horizontal padding.',
185
+ 'The help icon opens a Tooltip and never replaces the hint; the shortcut chip is decorative and only appears on fields that really have a global shortcut.',
186
+ 'Error state must show a Field error under the input; the red ring and icon alone are not enough.',
187
+ 'On mobile the font-size must be ≥ 16px to prevent Safari zoom: use size lg or the pattern in DESIGN.md.',
188
+ ],
189
+ a11y: [
190
+ 'Every input has an accessible name: a <label for> from the Field, or aria-label for standalone search.',
191
+ 'aria-invalid="true" and aria-describedby pointing to the Field error id when invalid.',
192
+ 'The help icon is a real <button> with aria-label equal to the tooltip text; the shortcut chip is aria-hidden (announce the shortcut in the tooltip or hint).',
193
+ 'Keep native type attributes (email, url, number, search) for keyboards and validation.',
194
+ 'The focus ring shows on the wrapper through :focus-within; never remove it on the input without the wrapper replacement.',
195
+ ],
196
+ related: ['field', 'textarea', 'select', 'combobox', 'kbd', 'tooltip'],
197
+ };