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,124 @@
1
+ import { FOCUS_RING, RESET_BUTTON, TRANSITION_COLORS, ex, ICON, typeStyle } from "./_shared.mjs";
2
+ // Featured card: the small promo / usage card at the bottom of a sidebar: a
3
+ // bg-subtle card with a label-sm title, a body-sm description, an optional 8px
4
+ // progress bar or 16:9 image, a ghost "Dismiss" + link "Upgrade plan" row and a
5
+ // 20px × in the corner.
6
+ const IMAGE = `<svg viewBox="0 0 320 180" role="img" aria-label="Lumen on a phone"><rect width="320" height="180" style="fill:var(--cn-color-bg-muted)"/><rect x="118" y="20" width="84" height="176" rx="14" style="fill:var(--cn-color-bg-surface)"/><rect x="128" y="36" width="64" height="8" rx="4" style="fill:var(--cn-color-border-strong)"/><rect x="128" y="52" width="40" height="6" rx="3" style="fill:var(--cn-color-border-default)"/><rect x="128" y="72" width="64" height="44" rx="6" style="fill:var(--cn-color-bg-subtle)"/><path d="M134 106l14-12 12 8 12-16 20 12" fill="none" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="stroke:var(--cn-color-bg-action)"/><rect x="128" y="126" width="64" height="10" rx="5" style="fill:var(--cn-color-bg-action)"/></svg>`;
7
+ const DISMISS = `<button type="button" class="cn-featured-card__dismiss" aria-label="Dismiss">${ICON.x}</button>`;
8
+ const actions = (secondary, primary) => `<div class="cn-featured-card__actions"><button type="button" class="cn-button" data-variant="ghost" data-size="sm"><span class="cn-button__label">${secondary}</span></button><button type="button" class="cn-button" data-variant="link-color" data-size="sm"><span class="cn-button__label">${primary}</span></button></div>`;
9
+ const progress = (pct, label) => `<div class="cn-featured-card__progress" role="progressbar" aria-label="${label}" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct}"><div class="cn-featured-card__progress-fill" style="width:${pct}%"></div></div>`;
10
+ const card = (variant, tone, inner) => `<div class="cn-featured-card" data-variant="${variant}" data-tone="${tone}" role="complementary" aria-label="Sidebar notice" style="width:100%;max-width:264px">${inner}</div>`;
11
+ export const featuredCard = {
12
+ name: 'FeaturedCard',
13
+ slug: 'featured-card',
14
+ category: 'feedback',
15
+ description: 'The small promo or usage card at the bottom of a sidebar: a bg-subtle card with a label-sm title, body-sm muted description, an optional 8px progress bar or 16:9 image, a ghost "Dismiss" and a link-color action, and a 20px × in the corner.',
16
+ usage: 'Use for one gentle nudge that lives in navigation: plan usage, a trial countdown, a new app or feature, an invite prompt. One per sidebar, always dismissible. For page-level messages use Banner or Alert; for a row inside content use InlineCta.',
17
+ anatomy: [
18
+ { part: 'root', element: 'div', description: 'The card: bg-subtle, radius card, 16px padding, flex column with 12px gaps, relative for the × button. 264px wide in a sidebar.' },
19
+ { part: 'title', element: 'div', description: 'label-sm title, one line, with room on the right for the ×.' },
20
+ { part: 'description', element: 'p', description: 'body-sm muted, one to three lines. Includes the number when there is one ("80% of 10 GB used").' },
21
+ { part: 'progress', element: 'div', description: '8px track (bg-muted, radius full) for usage and countdowns. role="progressbar".', optional: true },
22
+ { part: 'progress-fill', element: 'div', description: 'The action-colored fill; width is the inline percentage.', optional: true },
23
+ { part: 'media', element: 'div', description: '16:9 image slot with radius md above the actions (announcements).', optional: true },
24
+ { part: 'actions', element: 'div', description: 'Row of two sm Buttons: ghost "Dismiss" first, link-color action last.', optional: true },
25
+ { part: 'dismiss', element: 'button', description: '24px hit area with a 20px × in the top-right corner. aria-label "Dismiss".', optional: true },
26
+ ],
27
+ props: {
28
+ variant: {
29
+ values: ['progress', 'image', 'text'],
30
+ default: 'progress',
31
+ description: 'progress = title, description and an 8px bar (storage used, seats, trial days left). image = title, description and a 16:9 picture (a new app, a feature launch). text = title and description only (invite your team, complete your profile).',
32
+ },
33
+ tone: {
34
+ values: ['neutral', 'brand'],
35
+ default: 'neutral',
36
+ description: 'neutral = bg-subtle card, the everyday usage card. brand = bg-action-subtle with an action-colored title for the one promotional card (upgrade, trial). Never two brand cards in a sidebar.',
37
+ },
38
+ },
39
+ states: {
40
+ dismissHover: { selector: ' .cn-featured-card__dismiss:hover', description: 'Pointer over the × (extraCss): muted fill, ink icon.', markup: 'native :hover on .cn-featured-card__dismiss' },
41
+ dismissFocus: { selector: ' .cn-featured-card__dismiss:focus-visible', description: 'Keyboard focus on the × shows the focus ring (extraCss).', markup: 'native :focus-visible on .cn-featured-card__dismiss' },
42
+ },
43
+ base: {
44
+ root: {
45
+ position: 'relative',
46
+ display: 'flex',
47
+ 'flex-direction': 'column',
48
+ gap: '{space.3}',
49
+ width: '100%',
50
+ 'min-width': '0',
51
+ padding: '{space.4}',
52
+ 'background-color': '{color.bg-subtle}',
53
+ 'border-radius': '{radius.card}',
54
+ color: '{color.fg-default}',
55
+ },
56
+ title: { ...typeStyle('label-sm'), 'line-height': '{font.lineHeight.normal}', color: '{color.fg-default}', 'padding-inline-end': '{space.6}' },
57
+ description: { ...typeStyle('body-sm'), color: '{color.fg-muted}', 'margin-top': 'calc(-1 * {space.2})' },
58
+ progress: { width: '100%', height: '{space.2}', 'border-radius': '{radius.full}', 'background-color': '{color.bg-muted}', overflow: 'hidden' },
59
+ '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}' },
60
+ media: { display: 'block', width: '100%', 'aspect-ratio': '16 / 9', 'border-radius': '{radius.md}', overflow: 'hidden', 'background-color': '{color.bg-muted}' },
61
+ actions: { display: 'flex', 'align-items': 'center', gap: '{space.3}' },
62
+ dismiss: {
63
+ ...RESET_BUTTON,
64
+ position: 'absolute',
65
+ top: '{space.3}',
66
+ 'inset-inline-end': '{space.3}',
67
+ display: 'inline-flex',
68
+ 'align-items': 'center',
69
+ 'justify-content': 'center',
70
+ width: '{space.6}',
71
+ height: '{space.6}',
72
+ 'border-radius': '{radius.sm}',
73
+ color: '{color.fg-subtle}',
74
+ ...TRANSITION_COLORS,
75
+ },
76
+ },
77
+ variants: {
78
+ variant: {
79
+ progress: { root: {} },
80
+ image: { root: {} },
81
+ text: { root: {} },
82
+ },
83
+ tone: {
84
+ neutral: { root: { 'background-color': '{color.bg-subtle}' } },
85
+ brand: {
86
+ root: { 'background-color': '{color.bg-action-subtle}' },
87
+ title: { color: '{color.fg-action}' },
88
+ progress: { 'background-color': 'color-mix(in srgb, {color.bg-action} 16%, transparent)' },
89
+ dismiss: { color: '{color.fg-action}' },
90
+ },
91
+ },
92
+ },
93
+ extraCss: `
94
+ .cn-featured-card__dismiss .cn-icon { width: {size.icon.lg}; height: {size.icon.lg}; }
95
+ .cn-featured-card__dismiss:hover { background-color: {color.bg-muted}; color: {color.fg-default}; }
96
+ .cn-featured-card__dismiss:focus-visible { outline: none; box-shadow: ${FOCUS_RING['box-shadow']}; }
97
+ .cn-featured-card[data-tone="brand"] .cn-featured-card__dismiss:hover { background-color: color-mix(in srgb, {color.bg-action} 16%, transparent); }
98
+ .cn-featured-card__media > svg, .cn-featured-card__media > img { display: block; width: 100%; height: 100%; object-fit: cover; }
99
+ .cn-featured-card__actions > .cn-button[data-variant="ghost"]:first-child { margin-inline-start: calc(-1 * {size.controlPadding.sm}); }`,
100
+ examples: [
101
+ ex('Usage with progress', card('progress', 'neutral', `<div class="cn-featured-card__title">Used space</div><p class="cn-featured-card__description">Your team has used 80% of the available space. Need more?</p>${progress(80, 'Storage used')}${actions('Dismiss', 'Upgrade plan')}${DISMISS}`), 'The bottom-of-sidebar usage card: number in the text, bar below, two quiet actions.'),
102
+ ex('Announcement with image, brand tone', card('image', 'brand', `<div class="cn-featured-card__title">Lumen for iOS is here</div><p class="cn-featured-card__description">Approve requests and reply to comments from your phone.</p><div class="cn-featured-card__media">${IMAGE}</div>${actions('Dismiss', 'Get the app')}${DISMISS}`), 'One brand-toned card per sidebar; the image is 16:9 with radius md.'),
103
+ ex('Text only', card('text', 'neutral', `<div class="cn-featured-card__title">Invite your team</div><p class="cn-featured-card__description">Lumen works better together. Add teammates to share dashboards and approvals.</p>${actions('Dismiss', 'Send invites')}${DISMISS}`), 'A nudge with no bar or picture.'),
104
+ ex('Trial countdown, brand tone', card('progress', 'brand', `<div class="cn-featured-card__title">Trial ends in 4 days</div><p class="cn-featured-card__description">10 of 14 days used. Keep your dashboards by choosing a plan.</p>${progress(71, 'Trial days used')}${actions('Dismiss', 'Choose a plan')}${DISMISS}`), 'Countdowns use the progress variant with the days in the description.'),
105
+ ],
106
+ rules: [
107
+ 'One featured card per sidebar, always at the bottom above the account card, always dismissible.',
108
+ 'Title in label-sm, one line, sentence case; description at most three lines and includes the number ("80% of 10 GB used").',
109
+ 'Exactly two actions: a ghost sm "Dismiss" first and one link-color sm action last. Never a primary button in the sidebar.',
110
+ 'The × dismisses for this session; the "Dismiss" button dismisses for good. Do not show the card again within 30 days.',
111
+ 'Progress fills with the action color and warns in the text, not by turning red; the sidebar is not the place for alarms.',
112
+ 'brand tone only for the one promotional card (upgrade, trial); usage and housekeeping cards are neutral.',
113
+ 'Images are 16:9, radius md, illustrative; never a screenshot with text small enough to need reading.',
114
+ 'Do not stack a FeaturedCard with a Banner about the same thing; pick the surface closest to where the action happens.',
115
+ ],
116
+ a11y: [
117
+ 'The card is a complementary region (role="complementary" or an <aside>) with an aria-label so it can be skipped.',
118
+ 'The progress bar carries role="progressbar" with aria-valuenow/min/max and an aria-label; the percentage also appears in the description text.',
119
+ 'The × has aria-label "Dismiss" and comes last in the tab order inside the card; Escape does nothing here (the card is not modal).',
120
+ 'Images have alt text or aria-label describing what is promoted; decorative art uses alt="".',
121
+ 'Both actions are real <button>s or links; the link-color action opens the page it names.',
122
+ ],
123
+ related: ['sidebar', 'inline-cta', 'banner', 'progress', 'button', 'close-button'],
124
+ };
@@ -0,0 +1,67 @@
1
+ import { ex, ICON } from "./_shared.mjs";
2
+ const sizes = { sm: { box: '32px', icon: '{size.icon.sm}', radius: '{radius.md}' }, md: { box: '40px', icon: '{size.icon.md}', radius: '{radius.lg}' }, lg: { box: '48px', icon: '{size.icon.lg}', radius: '{radius.lg}' }, xl: { box: '56px', icon: '{size.icon.xl}', radius: '{radius.xl}' } };
3
+ const tone = (fill, fg, solid, ringA, ringB) => ({ fill, fg, solid, ringA, ringB });
4
+ const TONES = {
5
+ brand: tone('{color.bg-accent-subtle}', '{color.fg-accent}', '{color.bg-action}', '{brand.50}', '{brand.100}'),
6
+ gray: tone('{color.bg-muted}', '{color.fg-muted}', '{color.bg-inverse}', '{neutral.50}', '{neutral.100}'),
7
+ success: tone('{color.bg-success-subtle}', '{color.fg-success}', '{color.bg-success}', '{green.50}', '{green.100}'),
8
+ warning: tone('{color.bg-warning-subtle}', '{color.fg-warning}', '{color.bg-warning}', '{amber.50}', '{amber.100}'),
9
+ error: tone('{color.bg-danger-subtle}', '{color.fg-danger}', '{color.bg-danger}', '{red.50}', '{red.100}'),
10
+ };
11
+ const compound = Object.entries(TONES).flatMap(([t, v]) => [
12
+ { when: { tone: t, theme: 'light' }, block: { root: { 'background-color': v.fill, color: v.fg, 'border-inline-start': '2px solid currentColor' } } },
13
+ { when: { tone: t, theme: 'dark' }, block: { root: { 'background-color': v.solid, color: t === 'brand' ? '{color.fg-on-action}' : t === 'gray' ? '{color.fg-inverse}' : '{white}' } } },
14
+ { when: { tone: t, theme: 'outline' }, block: { root: { 'background-color': '{color.bg-surface}', color: v.fg, border: '1px solid currentColor', 'border-inline-start-width': '2px' } } },
15
+ ]);
16
+ export const featuredIcon = {
17
+ name: 'FeaturedIcon',
18
+ slug: 'featured-icon',
19
+ category: 'media',
20
+ description: 'A compact icon frame with a leading edge and a clipped corner. Introduces a dialog, an empty state or a meaningful section.',
21
+ usage: 'Use at the top of a dialog, an empty state, a feature card or a step. One per block. Never as a decorative bullet in lists and never next to a title inside a table row (use a plain 20px icon there).',
22
+ anatomy: [
23
+ { part: 'root', element: 'span', description: 'The container. Size, radius and theme live here.' },
24
+ { part: 'icon', element: 'svg', description: 'The icon, sized by the container size (16/20/24/28). Stroke 1.5, currentColor.' },
25
+ ],
26
+ props: {
27
+ theme: { values: ['light', 'dark', 'modern', 'outline'], default: 'outline', description: 'outline = a fine frame with a stronger leading edge (default); light = a quiet tinted surface; dark = solid fill with contrasting icon; modern = a neutral surface with a fine border.' },
28
+ tone: { values: ['brand', 'gray', 'success', 'warning', 'error'], default: 'brand', description: 'Color family. gray for neutral/system, success/warning/error for status dialogs.' },
29
+ size: { values: ['sm', 'md', 'lg', 'xl'], default: 'md', description: 'Container 32 / 40 / 48 / 56px with icon 16 / 20 / 24 / 28.' },
30
+ shape: { values: ['square', 'circle'], default: 'square', description: 'square = compact corners with a clipped upper corner; circle = an optional round silhouette. Use square throughout a view.' },
31
+ },
32
+ states: {},
33
+ base: {
34
+ root: { position: 'relative', display: 'inline-flex', 'align-items': 'center', 'justify-content': 'center', 'flex-shrink': '0', 'vertical-align': 'middle' },
35
+ icon: { display: 'block', 'stroke-width': '1.5' },
36
+ },
37
+ variants: {
38
+ theme: {
39
+ light: { root: {} },
40
+ dark: { root: {} },
41
+ modern: { root: { 'background-color': '{color.bg-surface}', color: '{color.fg-muted}', border: '{border.width.thin} solid {color.border-default}', 'box-shadow': 'none' } },
42
+ outline: { root: {} },
43
+ },
44
+ tone: Object.fromEntries(Object.keys(TONES).map((t) => [t, { root: {} }])),
45
+ size: Object.fromEntries(Object.entries(sizes).map(([k, v]) => [k, { root: { width: v.box, height: v.box, 'border-radius': v.radius }, icon: { width: v.icon, height: v.icon } }])),
46
+ shape: { square: { root: {} }, circle: { root: { 'border-radius': '{radius.full}' } } },
47
+ },
48
+ compound: [...compound, { when: { theme: 'modern', tone: 'gray' }, block: { root: { color: '{color.fg-muted}' } } }],
49
+ extraCss: `
50
+ .cn-featured-icon:not([data-shape="circle"]) { clip-path: polygon(0 0, calc(100% - 7px) 0, 100% 7px, 100% 100%, 0 100%); }
51
+ .cn-featured-icon[data-theme="outline"]:not([data-shape="circle"])::after { content: ""; position: absolute; width: 10px; height: 1px; top: 2px; right: -2px; transform: rotate(45deg); background: currentColor; }`,
52
+ examples: [
53
+ ex('Framed icons (default)', `<span class="cn-featured-icon" data-theme="outline" data-tone="brand" data-size="md" data-shape="square">${ICON.spark.replace('cn-icon', 'cn-featured-icon__icon')}</span> <span class="cn-featured-icon" data-theme="outline" data-tone="gray" data-size="md" data-shape="square">${ICON.settings.replace('cn-icon', 'cn-featured-icon__icon')}</span> <span class="cn-featured-icon" data-theme="outline" data-tone="success" data-size="md" data-shape="square">${ICON.check.replace('cn-icon', 'cn-featured-icon__icon')}</span> <span class="cn-featured-icon" data-theme="outline" data-tone="warning" data-size="md" data-shape="square">${ICON.warning.replace('cn-icon', 'cn-featured-icon__icon')}</span> <span class="cn-featured-icon" data-theme="outline" data-tone="error" data-size="md" data-shape="square">${ICON.trash.replace('cn-icon', 'cn-featured-icon__icon')}</span>`),
54
+ ex('Dark and modern', `<span class="cn-featured-icon" data-theme="dark" data-tone="brand" data-size="md" data-shape="square">${ICON.spark.replace('cn-icon', 'cn-featured-icon__icon')}</span> <span class="cn-featured-icon" data-theme="dark" data-tone="error" data-size="md" data-shape="square">${ICON.trash.replace('cn-icon', 'cn-featured-icon__icon')}</span> <span class="cn-featured-icon" data-theme="modern" data-tone="gray" data-size="md" data-shape="square">${ICON.inbox.replace('cn-icon', 'cn-featured-icon__icon')}</span> <span class="cn-featured-icon" data-theme="modern" data-tone="gray" data-size="lg" data-shape="square">${ICON.search.replace('cn-icon', 'cn-featured-icon__icon')}</span>`),
55
+ ex('Outline frames (dialogs and alerts)', `<span class="cn-featured-icon" data-theme="outline" data-tone="brand" data-size="md" data-shape="square">${ICON.check.replace('cn-icon', 'cn-featured-icon__icon')}</span> <span class="cn-featured-icon" data-theme="outline" data-tone="error" data-size="md" data-shape="square">${ICON.warning.replace('cn-icon', 'cn-featured-icon__icon')}</span> <span class="cn-featured-icon" data-theme="outline" data-tone="success" data-size="md" data-shape="square">${ICON.check.replace('cn-icon', 'cn-featured-icon__icon')}</span>`),
56
+ ex('Sizes', `<span class="cn-featured-icon" data-theme="light" data-tone="brand" data-size="sm" data-shape="square">${ICON.spark.replace('cn-icon', 'cn-featured-icon__icon')}</span> <span class="cn-featured-icon" data-theme="light" data-tone="brand" data-size="md" data-shape="square">${ICON.spark.replace('cn-icon', 'cn-featured-icon__icon')}</span> <span class="cn-featured-icon" data-theme="light" data-tone="brand" data-size="lg" data-shape="square">${ICON.spark.replace('cn-icon', 'cn-featured-icon__icon')}</span> <span class="cn-featured-icon" data-theme="light" data-tone="brand" data-size="xl" data-shape="square">${ICON.spark.replace('cn-icon', 'cn-featured-icon__icon')}</span>`),
57
+ ],
58
+ rules: [
59
+ 'One featured icon per block, always above or left of a title. It introduces; it never decorates.',
60
+ 'Dialogs: lg (48px) light or outline; empty states: lg/xl; feature cards on marketing pages: md/lg light or dark; alerts: sm outline frame.',
61
+ 'Tone follows meaning: brand for neutral product actions, error for destructive confirmations, success for completed, warning for caution, gray for system/info.',
62
+ 'The icon is always an outline icon from the set with stroke 1.5 and currentColor. Never emoji, never a filled icon.',
63
+ 'Do not put featured icons in table rows, list items or badges.',
64
+ ],
65
+ a11y: ['Decorative: the icon carries aria-hidden and the title next to it names the content.', 'If it must convey a status alone (rare), add role="img" and aria-label to the root.'],
66
+ related: ['empty-state', 'dialog', 'alert', 'card'],
67
+ };
@@ -0,0 +1,122 @@
1
+ import { ex, ICON, typeStyle } from "./_shared.mjs";
2
+ // the reference text field wrapper: label → control → hint stacked 6px apart. Label 14px medium in
3
+ // the default ink with an optional brand asterisk (2px away) and a 16px help icon; hint 14px muted
4
+ // (12px on sm) that turns to the danger color when invalid. Nothing else: no colon, no bold.
5
+ const inp = (id, attrs, size = 'md', affix = '') => `<div class="cn-input" data-variant="default" data-size="${size}">${affix}<input class="cn-input__field" id="${id}" ${attrs}></div>`;
6
+ const REQUIRED = '<span class="cn-field__required" aria-hidden="true">*</span>';
7
+ const help = (text) => `<button type="button" class="cn-field__tooltip" aria-label="${text}">${ICON.info}</button>`;
8
+ const fld = (id, label, control, o = {}) => `<div class="cn-field" data-layout="${o.layout ?? 'vertical'}" data-size="${o.size ?? 'md'}" ${o.root ?? ''} style="${o.style ?? 'max-width:360px'}"><label class="cn-field__label" for="${id}">${label}${o.labelExtra ?? ''}</label><div class="cn-field__control">${control}</div>${o.hint ? `<p class="cn-field__helper" id="${id}-hint">${o.hint}</p>` : ''}${o.error ? `<p class="cn-field__error" id="${id}-error">${o.error}</p>` : ''}</div>`;
9
+ export const field = {
10
+ name: 'Field',
11
+ slug: 'field',
12
+ category: 'forms',
13
+ description: 'Label, control and one line of hint or error text stacked 6px apart (the reference text-field rhythm). The label is 14px medium with an optional brand asterisk and help icon; the hint is 14px muted and turns red when the field is invalid. It owns the words around a control so every form reads the same way.',
14
+ usage: 'Wrap every Input, Textarea, Select, Combobox, Slider and FileDropzone in a Field so it gets a visible label and a place for hint and error text. Do not wrap Checkbox, Radio or Switch (they carry their own label; group them with a radio-group or a fieldset). Use layout="horizontal" only on settings pages where many short fields stack under one another.',
15
+ anatomy: [
16
+ { part: 'root', element: 'div', description: 'The wrapper: a 6px vertical stack by default; a label-column grid in horizontal layout. Carries data-invalid and data-disabled.' },
17
+ { part: 'label', element: 'label', description: 'Visible label with for="<control id>": 14px medium in the default ink, a flex row with 2px gaps for the asterisk and help icon. Sentence case, no trailing colon, 1–4 words ("Company name").' },
18
+ { part: 'required', element: 'span', description: 'Optional brand asterisk right after the label text (2px away) marking a required field; turns red when invalid. aria-hidden — the control carries required.', optional: true },
19
+ { part: 'tooltip', element: 'button', description: 'Optional 16px help icon after the label that opens a Tooltip on hover/focus (aria-label = the tooltip text). Gray-500, darker on hover.', optional: true },
20
+ { part: 'optional', element: 'span', description: 'Muted "(optional)" marker inside the label, regular weight, for forms that mark optional fields instead of required ones.', optional: true },
21
+ { part: 'control', element: 'div', description: 'Slot holding exactly one control (an Input, Select, Textarea, Combobox…). No styling besides min-width: 0.' },
22
+ { part: 'helper', element: 'p', description: 'The hint: one short line under the control, 14px (12px on sm) muted: format, example or limit ("Shown on invoices"). Hidden while the field is invalid.', optional: true },
23
+ { part: 'error', element: 'p', description: 'Validation message in the same size as the hint, in the danger color (an optional 16px icon may lead it). Hidden until data-invalid is on the root; give it an id and reference it from the control with aria-describedby.', optional: true },
24
+ ],
25
+ props: {
26
+ layout: { values: ['vertical', 'horizontal'], default: 'vertical', description: 'vertical = label above the control (forms, dialogs, onboarding); horizontal = label in a fixed 192px left column with the control on the right (settings pages with many short fields).' },
27
+ size: { values: ['sm', 'md'], default: 'md', description: 'md = 14px label and 14px hint, pairs with md/lg controls; sm = 14px label and 12px hint, pairs with sm controls in dense panels and filters (the reference size="sm" hint).' },
28
+ },
29
+ states: {
30
+ invalid: { selector: '[data-invalid]', description: 'Shows the error in the danger color, hides the hint and turns the asterisk red. Set together with aria-invalid="true" on the control.', markup: 'data-invalid on the root' },
31
+ disabled: { selector: '[data-disabled]', description: 'The control fades on its own (50% opacity); the label and hint stay readable and the label shows the not-allowed cursor. Set together with disabled on the control.', markup: 'data-disabled on the root' },
32
+ },
33
+ base: {
34
+ root: { display: 'flex', 'flex-direction': 'column', 'align-items': 'stretch', gap: '{space.1.5}', width: '100%', 'min-width': '0' },
35
+ label: { display: 'flex', 'align-items': 'center', gap: '{space.0.5}', ...typeStyle('label-sm'), color: '{color.fg-default}', cursor: 'default' },
36
+ required: { color: '{brand.600}', 'user-select': 'none' },
37
+ tooltip: {
38
+ display: 'inline-flex',
39
+ 'align-items': 'center',
40
+ 'justify-content': 'center',
41
+ 'flex-shrink': '0',
42
+ width: '{size.icon.sm}',
43
+ height: '{size.icon.sm}',
44
+ margin: '0',
45
+ padding: '0',
46
+ border: '0',
47
+ background: 'none',
48
+ 'border-radius': '{radius.sm}',
49
+ color: '{color.fg-subtle}',
50
+ cursor: 'pointer',
51
+ 'transition-property': 'color, box-shadow',
52
+ 'transition-duration': '{motion.duration.slow}',
53
+ 'transition-timing-function': '{motion.easing.linear}',
54
+ },
55
+ optional: { 'font-weight': '{font.weight.regular}', color: '{color.fg-subtle}', 'margin-inline-start': '{space.0.5}' },
56
+ control: { 'min-width': '0' },
57
+ helper: { ...typeStyle('body-md'), color: '{color.fg-muted}' },
58
+ error: { display: 'none', 'align-items': 'flex-start', gap: '{space.1.5}', ...typeStyle('body-md'), color: '{color.fg-danger}' },
59
+ '@states': {
60
+ invalid: { helper: { display: 'none' }, error: { display: 'flex' }, required: { color: '{color.fg-danger}' } },
61
+ disabled: { label: { cursor: 'not-allowed' } },
62
+ },
63
+ },
64
+ variants: {
65
+ layout: {
66
+ vertical: { root: {} },
67
+ horizontal: {
68
+ root: { display: 'grid', 'grid-template-columns': '{space.48} minmax(0, 1fr)', 'column-gap': '{space.6}', 'row-gap': '{space.1.5}', 'align-items': 'start' },
69
+ label: { 'grid-column': '1', 'grid-row': '1', 'padding-block-start': '{space.2.5}' },
70
+ control: { 'grid-column': '2', 'grid-row': '1' },
71
+ helper: { 'grid-column': '2' },
72
+ error: { 'grid-column': '2' },
73
+ },
74
+ },
75
+ size: {
76
+ sm: { helper: { ...typeStyle('body-sm') }, error: { ...typeStyle('body-sm') } },
77
+ md: { helper: {}, error: {} },
78
+ },
79
+ },
80
+ extraCss: `
81
+ .cn-field .cn-field__tooltip .cn-icon { width: {size.icon.sm}; height: {size.icon.sm}; stroke-width: 2.25; }
82
+ .cn-field .cn-field__tooltip:hover, .cn-field .cn-field__tooltip:focus-visible { color: {color.fg-muted}; }
83
+ .cn-field .cn-field__tooltip:focus-visible { outline: none; box-shadow: {shadow.focus}; }
84
+ .cn-field .cn-field__error .cn-icon { width: {size.icon.sm}; height: {size.icon.sm}; flex-shrink: 0; margin-top: {space.0.5}; }
85
+ .cn-field[data-layout="horizontal"][data-size="sm"] .cn-field__label { padding-block-start: {space.2}; }
86
+ @media (max-width: {breakpoint.sm}) {
87
+ .cn-field[data-layout="horizontal"] { grid-template-columns: minmax(0, 1fr); gap: {space.1.5}; }
88
+ .cn-field[data-layout="horizontal"] .cn-field__label, .cn-field[data-layout="horizontal"] .cn-field__control, .cn-field[data-layout="horizontal"] .cn-field__helper, .cn-field[data-layout="horizontal"] .cn-field__error { grid-column: 1; grid-row: auto; }
89
+ .cn-field[data-layout="horizontal"] .cn-field__label, .cn-field[data-layout="horizontal"][data-size="sm"] .cn-field__label { padding-block-start: 0; }
90
+ }`,
91
+ examples: [
92
+ ex('Vertical (default)', fld('field-company', 'Company name', inp('field-company', 'type="text" placeholder="Lumen Studio" aria-describedby="field-company-hint"'), { hint: 'The legal name that appears on invoices.' }), 'Label 14px medium, 6px, the control, 6px, a 14px muted hint.'),
93
+ ex('Required with help icon', fld('field-email', 'Work email', inp('field-email', 'type="email" placeholder="maya@lumen.app" required aria-describedby="field-email-hint"'), { hint: 'We only send billing receipts here.', labelExtra: REQUIRED + help('Used to sign in and to receive receipts') }), 'The brand asterisk sits 2px after the label; the 16px help icon opens a Tooltip.'),
94
+ ex('Optional marker', fld('field-site', 'Website', inp('field-site', 'type="url" placeholder="lumen.app"', 'md', '<span class="cn-input__affix">https://</span>'), { labelExtra: ' <span class="cn-field__optional">(optional)</span>' }), 'For forms that mark optional fields instead of required ones.'),
95
+ ex('Invalid', fld('field-email2', 'Work email', inp('field-email2', 'type="email" value="maya@lumen" aria-invalid="true" required aria-describedby="field-email2-error"'), { root: 'data-invalid', hint: 'We only send billing receipts here.', error: 'Enter a complete address, like maya@lumen.app.', labelExtra: REQUIRED }), 'The error replaces the hint in the danger color and the asterisk turns red; the text says how to fix it.'),
96
+ ex('Horizontal (settings page)', fld('field-display', 'Display name', inp('field-display', 'type="text" value="Maya Chen"'), { layout: 'horizontal', hint: 'Shown to teammates in comments and activity.', style: 'max-width:640px' }), 'A 192px label column; the label aligns with the 40px control.'),
97
+ ex('Disabled', fld('field-plan', 'Plan', inp('field-plan', 'type="text" value="Team (annual)" disabled'), { root: 'data-disabled', hint: 'Managed by Aisha Khan, your billing admin.' }), 'The control fades; the words stay readable.'),
98
+ ex('Small', fld('field-min', 'Minimum order value', inp('field-min', 'type="number" value="5000"', 'sm', '<span class="cn-input__affix">USD</span>'), { size: 'sm', hint: 'Orders below this value are hidden.', style: 'max-width:280px' }), 'A 14px label with a 12px hint, for sm controls.'),
99
+ ],
100
+ recipes: [
101
+ ex('Settings section', `<section class="cn-card" data-variant="default" data-padding="md" style="max-width:640px;width:100%"><header class="cn-card__header"><div><h3 class="cn-card__title">Workspace</h3><p class="cn-card__description">Name and region are visible to every member.</p></div></header><div class="cn-card__body" style="display:flex;flex-direction:column;gap:var(--cn-space-5)">${fld('ws-name', 'Workspace name', inp('ws-name', 'type="text" value="Lumen Studio"'), { layout: 'horizontal', style: '' })}${fld('ws-slug', 'URL', inp('ws-slug', 'type="text" value="lumen-studio"', 'md', '<span class="cn-input__affix">app.lumen.app/</span>'), { layout: 'horizontal', hint: 'Lowercase letters, numbers and dashes.', style: '' })}${fld('ws-region', 'Data region', inp('ws-region', 'type="text" value="Europe (Frankfurt)" disabled'), { layout: 'horizontal', root: 'data-disabled', hint: 'Contact support to migrate regions.', style: '' })}</div><footer class="cn-card__footer" style="justify-content:flex-end"><button type="button" class="cn-button" data-variant="primary" data-size="sm"><span class="cn-button__label">Save changes</span></button></footer></section>`, 'Horizontal fields inside a Card, stacked 20px apart. One primary action in the footer.'),
102
+ ],
103
+ rules: [
104
+ 'Every control gets a visible label. Placeholder text is an example value, never the label.',
105
+ 'Labels are 1–4 words in sentence case with no colon. Name what goes in the field ("Work email"), not the action ("Enter your email").',
106
+ 'Pick one convention per form: mark required fields with the brand asterisk, or mark optional ones with "(optional)". Never both.',
107
+ 'Hint text is one sentence in present tense that answers "what format" or "why do you need it". If it needs two lines, the label or the flow is wrong.',
108
+ 'Invalid: set data-invalid on the Field AND aria-invalid="true" on the control. The error replaces the hint and says how to fix it ("Enter a complete address"), not just "Invalid".',
109
+ 'The help icon carries context that does not fit the hint (why, where it is used); it never replaces the hint or the error.',
110
+ 'Use one layout per form. Horizontal settings fields have a 192px label column and stack vertically below 640px of available width.',
111
+ 'Fields stack 20px apart; two related short fields (city + postal code) may share a row 16px apart.',
112
+ 'Constrain short values (postal code, quantity, currency) with max-width on the Field, not on the control.',
113
+ ],
114
+ a11y: [
115
+ 'The <label for> references the control id, so clicking the label focuses the control and screen readers announce it.',
116
+ 'The asterisk is aria-hidden; put required on the control (or aria-required) so the requirement is announced.',
117
+ 'The help icon is a real <button> with aria-label equal to the tooltip text; the tooltip is referenced with aria-describedby.',
118
+ 'When invalid, the control has aria-invalid="true" and aria-describedby pointing at the error id; when valid, aria-describedby points at the hint id.',
119
+ 'The error text is static in the flow. Announce submit results with a separate live region, not by making every error aria-live.',
120
+ ],
121
+ related: ['input', 'textarea', 'select', 'combobox', 'checkbox', 'radio', 'tooltip'],
122
+ };
@@ -0,0 +1,166 @@
1
+ import { ex, ICON, typeStyle } from "./_shared.mjs";
2
+ // the reference file upload: a white dropzone with a 1px gray-200 ring, radius 12, 16×24px padding,
3
+ // a centered 40px modern featured icon, "Click to upload" (brand link) + "or drag and drop" (14px
4
+ // muted) and a 12px hint; drag-over = 2px brand ring; disabled = gray-50. Files list as bordered
5
+ // cards (radius 12, 16px padding, 12px gap): 40px file icon, medium name, size · status row, an 8px
6
+ // progress bar, a utility delete button top-right; failed rows get a 2px red ring and "Try again".
7
+ const TRANSITION_FAST = {
8
+ 'transition-property': 'box-shadow, background-color, color, opacity',
9
+ 'transition-duration': '{motion.duration.fast}',
10
+ 'transition-timing-function': '{motion.easing.linear}',
11
+ };
12
+ const ring = (width, color) => `inset 0 0 0 ${width} ${color}`;
13
+ const UPLOAD_PATH = '<path d="M8 10.5V3M4.5 6.5L8 3l3.5 3.5M2.5 11v1.5a1 1 0 001 1h9a1 1 0 001-1V11"/>';
14
+ const svg16 = (cls, body, sw = '1.5') => `<svg class="${cls}" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
15
+ const featured = `<span class="cn-featured-icon" data-theme="modern" data-tone="gray" data-size="md" data-shape="square">${svg16('cn-featured-icon__icon', UPLOAD_PATH)}</span>`;
16
+ const FILE_ICON = '<svg class="cn-file-dropzone__file-icon" viewBox="0 0 40 40" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 5h13l9 9v19a2 2 0 01-2 2H11a2 2 0 01-2-2V7a2 2 0 012-2z"/><path d="M24 5v9h9"/></svg>';
17
+ const STATUS_ICON = {
18
+ complete: svg16('cn-icon', '<circle cx="8" cy="8" r="6.25"/><path d="M5.5 8.25l1.75 1.75 3.5-3.5"/>', '1.75'),
19
+ uploading: svg16('cn-icon', UPLOAD_PATH, '1.75'),
20
+ failed: svg16('cn-icon', '<circle cx="8" cy="8" r="6.25"/><path d="M6 6l4 4M10 6l-4 4"/>', '1.75'),
21
+ };
22
+ const STATUS_TEXT = { complete: 'Complete', uploading: 'Uploading…', failed: 'Failed' };
23
+ const deleteButton = (name) => `<button type="button" class="cn-icon-button" data-variant="utility" data-size="xs" data-shape="square" aria-label="Delete ${name}">${ICON.trash.replace('cn-icon', 'cn-icon-button__icon')}</button>`;
24
+ const progress = (name, pct) => `<div class="cn-progress" data-size="lg" data-tone="action" role="progressbar" aria-label="Uploading ${name}" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100"><div class="cn-progress__track"><div class="cn-progress__indicator" style="width:${pct}%"></div></div></div>`;
25
+ const fileRow = (name, size, state, pct) => `<li class="cn-file-dropzone__file" data-state="${state}">${FILE_ICON}<div class="cn-file-dropzone__file-body"><span class="cn-file-dropzone__file-name">${name}</span><div class="cn-file-dropzone__file-meta"><span class="cn-file-dropzone__file-size">${size}</span><span class="cn-file-dropzone__file-divider" aria-hidden="true"></span><span class="cn-file-dropzone__file-status">${STATUS_ICON[state]}${STATUS_TEXT[state]}</span></div>${state === 'failed' ? `<div class="cn-file-dropzone__file-retry"><button type="button" class="cn-button" data-variant="danger-link" data-size="sm"><span class="cn-button__label">Try again</span></button></div>` : `<div class="cn-file-dropzone__file-progress">${progress(name, pct)}<span class="cn-file-dropzone__file-percent">${pct}%</span></div>`}</div><div class="cn-file-dropzone__file-actions">${deleteButton(name)}</div></li>`;
26
+ const TITLE = '<span class="cn-file-dropzone__action">Click to upload</span> or drag and drop';
27
+ const zone = (root, input, hint, files = '', title = TITLE) => `<div class="cn-file-dropzone" ${root}><input class="cn-file-dropzone__input" type="file" ${input}>${featured}<div class="cn-file-dropzone__text"><span class="cn-file-dropzone__title">${title}</span><span class="cn-file-dropzone__hint">${hint}</span></div>${files ? `<ul class="cn-file-dropzone__files">${files}</ul>` : ''}</div>`;
28
+ export const fileDropzone = {
29
+ name: 'FileDropzone',
30
+ slug: 'file-dropzone',
31
+ category: 'forms',
32
+ description: 'Upload target: a white card with a 1px gray-200 ring and radius 12, a centered 40px featured icon, "Click to upload or drag and drop" and a hint of accepted types. The native file input covers the whole zone, so click and drop work without extra JS. Selected files list under the text as bordered cards with progress and a delete button.',
33
+ usage: 'Any file upload in a form or dialog (documents, spreadsheets, a logo). For a single small image next to a preview use size="sm". Not for pasting text or URLs, and not as a full-page drop overlay (that is a separate layer).',
34
+ anatomy: [
35
+ { part: 'root', element: 'div', description: 'The zone: white, 1px gray-200 inset ring, radius 12, padding 16×24 (md) or 16 (sm), centered column with a 12px gap. Carries data-state="idle|dragover", data-invalid, data-disabled.' },
36
+ { part: 'input', element: 'input', description: 'Native <input type="file"> stretched over the whole zone at opacity 0. Carries accept, multiple, disabled, aria-invalid.' },
37
+ { part: 'icon', element: 'svg', description: 'The upload-cloud icon. Put it inside a FeaturedIcon (theme modern, tone gray, size md = 40px, radius 8); on its own it renders as a 20px muted icon.', optional: true },
38
+ { part: 'text', element: 'div', description: 'Centered text block: the title line and the hint, 4px apart.' },
39
+ { part: 'title', element: 'span', description: 'The first line, 14px muted: the action ("Click to upload") followed by "or drag and drop" (or "Release to upload" while dragging).' },
40
+ { part: 'action', element: 'span', description: 'The brand link-colored words inside the title ("Click to upload"): 14px semibold, underlined when the zone is hovered. Not a button — the input above it takes the click.' },
41
+ { part: 'hint', element: 'span', description: '12px muted line: accepted types and size limit ("SVG, PNG, JPG or GIF (max. 800×400px)"). Turns to the danger color and carries the rejection reason when invalid.' },
42
+ { part: 'files', element: 'ul', description: 'List of selected files under the text, full width, 12px between rows, raised above the input so its buttons are clickable.', optional: true },
43
+ { part: 'file', element: 'li', description: 'One file card: white, 1px gray-200 ring, radius 12, 16px padding, 12px gap between icon, body and actions. data-state="uploading|complete|failed"; failed rows get a 2px red ring.', optional: true },
44
+ { part: 'file-icon', element: 'svg', description: '40×40 file-type icon, muted, does not shrink.', optional: true },
45
+ { part: 'file-body', element: 'div', description: 'Name, meta row, progress or retry stacked; takes the remaining width.', optional: true },
46
+ { part: 'file-name', element: 'span', description: 'The file name, 14px medium, truncated with an ellipsis.', optional: true },
47
+ { part: 'file-meta', element: 'div', description: 'Row 2px under the name: size, a 1×12px divider and the status, 8px apart, 14px muted.', optional: true },
48
+ { part: 'file-size', element: 'span', description: 'Formatted size ("1.2 MB"), 14px muted, tabular.', optional: true },
49
+ { part: 'file-divider', element: 'span', description: 'A 1×12px gray-300 rule between size and status.', optional: true },
50
+ { part: 'file-status', element: 'span', description: '16px icon + word, 14px medium, 4px apart: "Uploading…" muted, "Complete" green, "Failed" red (colors follow data-state on the row).', optional: true },
51
+ { part: 'file-progress', element: 'div', description: 'Row 4px under the meta: a Progress bar (size lg = 8px track) with the percentage at the right, 12px apart.', optional: true },
52
+ { part: 'file-percent', element: 'span', description: 'The percentage next to the bar, 14px medium, tabular.', optional: true },
53
+ { part: 'file-retry', element: 'div', description: 'Row 6px under the meta on failed rows holding a danger-link sm "Try again" button.', optional: true },
54
+ { part: 'file-actions', element: 'div', description: 'Top-right slot for the utility xs delete button, pulled 8px into the padding so the icon aligns with the content edge.', optional: true },
55
+ ],
56
+ props: {
57
+ size: { values: ['sm', 'md'], default: 'md', description: 'md = padding 16×24 with the 40px featured icon (documents in forms and dialogs, the reference dropzone); sm = padding 16 all round for a single avatar, logo or attachment next to a preview.' },
58
+ },
59
+ states: {
60
+ dragover: { selector: '[data-state="dragover"]', description: 'A file is being dragged over the zone: the ring becomes 2px in the brand color.', markup: 'data-state="dragover" on the root (set on dragenter, cleared on dragleave/drop)' },
61
+ hover: { selector: ':hover:not([data-disabled])', description: 'Pointer over the zone: the action words underline, like a link. Nothing else changes.', markup: 'native :hover' },
62
+ focus: { selector: ':has(> input:focus-visible)', description: 'Keyboard focus on the file input (not on a row button) shows the 4px brand ring around the zone.', markup: 'native :focus-visible on the input' },
63
+ invalid: { selector: '[data-invalid], &:has([aria-invalid="true"])', description: 'A rejected file (type or size): the hint turns to the danger color and carries the reason.', markup: 'aria-invalid="true" on the input, or data-invalid on the root' },
64
+ disabled: { selector: '[data-disabled], &:has(> input:disabled)', description: 'Gray-50 fill, cursor not-allowed, the featured icon at 50%, no drop. Scoped to the file input so a disabled row button does not trigger it.', markup: 'disabled on the input' },
65
+ 'file-failed': { selector: '[data-state="failed"]', description: 'On a __file row: 2px red ring, red status, "Try again" instead of the progress bar.', markup: 'data-state="failed" on the row' },
66
+ },
67
+ base: {
68
+ root: {
69
+ position: 'relative',
70
+ display: 'flex',
71
+ 'flex-direction': 'column',
72
+ 'align-items': 'center',
73
+ gap: '{space.3}',
74
+ width: '100%',
75
+ 'min-width': '0',
76
+ 'background-color': '{color.bg-surface}',
77
+ 'border-radius': '{radius.card}',
78
+ 'box-shadow': ring('1px', '{color.border-default}'),
79
+ color: '{color.fg-muted}',
80
+ cursor: 'pointer',
81
+ ...TRANSITION_FAST,
82
+ },
83
+ input: { position: 'absolute', inset: '0', width: '100%', height: '100%', margin: '0', padding: '0', opacity: '0', cursor: 'pointer' },
84
+ icon: { display: 'block', width: '{size.icon.md}', height: '{size.icon.md}', 'flex-shrink': '0', color: '{color.fg-muted}' },
85
+ text: { display: 'flex', 'flex-direction': 'column', gap: '{space.1}', 'text-align': 'center', 'min-width': '0' },
86
+ title: { display: 'flex', 'justify-content': 'center', 'flex-wrap': 'wrap', gap: '{space.1}', ...typeStyle('body-md'), color: '{color.fg-muted}' },
87
+ action: { ...typeStyle('label-md'), color: '{color.fg-action}', 'text-decoration-line': 'underline', 'text-decoration-color': 'transparent', 'text-underline-offset': '3px', ...TRANSITION_FAST },
88
+ hint: { ...typeStyle('body-sm'), color: '{color.fg-muted}', ...TRANSITION_FAST },
89
+ files: { position: 'relative', 'z-index': '{z.raised}', display: 'flex', 'flex-direction': 'column', gap: '{space.3}', width: '100%', 'margin-top': '{space.1}', 'text-align': 'start', cursor: 'default' },
90
+ file: {
91
+ position: 'relative',
92
+ display: 'flex',
93
+ 'align-items': 'flex-start',
94
+ gap: '{space.3}',
95
+ 'min-width': '0',
96
+ padding: '{space.4}',
97
+ 'background-color': '{color.bg-surface}',
98
+ 'border-radius': '{radius.card}',
99
+ 'box-shadow': ring('1px', '{color.border-default}'),
100
+ color: '{color.fg-default}',
101
+ 'transition-property': 'box-shadow',
102
+ 'transition-duration': '{motion.duration.fast}',
103
+ 'transition-timing-function': '{motion.easing.linear}',
104
+ },
105
+ 'file-icon': { width: '{space.10}', height: '{space.10}', 'flex-shrink': '0', color: '{color.fg-subtle}' },
106
+ 'file-body': { flex: '1 1 auto', 'min-width': '0', display: 'flex', 'flex-direction': 'column' },
107
+ 'file-name': { display: 'block', ...typeStyle('label-sm'), color: '{color.fg-default}', overflow: 'hidden', 'text-overflow': 'ellipsis', 'white-space': 'nowrap' },
108
+ 'file-meta': { display: 'flex', 'align-items': 'center', gap: '{space.2}', 'margin-top': '{space.0.5}', ...typeStyle('body-md'), color: '{color.fg-muted}' },
109
+ 'file-size': { 'flex-shrink': '0', 'font-variant-numeric': 'tabular-nums' },
110
+ 'file-divider': { width: '{space.px}', height: '{space.3}', 'border-radius': '{radius.full}', 'background-color': '{color.border-control}', 'flex-shrink': '0' },
111
+ 'file-status': { display: 'inline-flex', 'align-items': 'center', gap: '{space.1}', ...typeStyle('label-sm'), color: '{color.fg-subtle}' },
112
+ 'file-progress': { display: 'flex', 'align-items': 'center', gap: '{space.3}', 'margin-top': '{space.1}', width: '100%' },
113
+ 'file-percent': { 'flex-shrink': '0', ...typeStyle('label-sm'), color: '{color.fg-muted}', 'font-variant-numeric': 'tabular-nums' },
114
+ 'file-retry': { 'margin-top': '{space.1.5}' },
115
+ 'file-actions': { 'align-self': 'flex-start', 'flex-shrink': '0', margin: 'calc(-1 * {space.2}) calc(-1 * {space.2}) 0 0' },
116
+ '@states': {
117
+ dragover: { root: { 'box-shadow': ring('2px', '{color.border-action}') } },
118
+ hover: { action: { 'text-decoration-color': 'currentColor' } },
119
+ focus: { root: { 'box-shadow': `${ring('1px', '{color.border-default}')}, {shadow.focus}` } },
120
+ invalid: { hint: { color: '{color.fg-danger}' } },
121
+ disabled: { root: { 'background-color': '{color.bg-subtle}', cursor: 'not-allowed', 'pointer-events': 'none' }, input: { cursor: 'not-allowed' }, icon: { opacity: '{opacity.disabled}' } },
122
+ 'file-failed': { file: { 'box-shadow': ring('2px', '{color.border-danger}') }, 'file-status': { color: '{color.fg-danger}' } },
123
+ },
124
+ },
125
+ variants: {
126
+ size: {
127
+ sm: { root: { padding: '{space.4}', gap: '{space.2}' } },
128
+ md: { root: { padding: '{space.4} {space.6}' } },
129
+ },
130
+ },
131
+ extraCss: `
132
+ .cn-file-dropzone[data-disabled] .cn-featured-icon, .cn-file-dropzone:has(> input:disabled) .cn-featured-icon { opacity: {opacity.disabled}; }
133
+ .cn-file-dropzone .cn-file-dropzone__file[data-state="complete"] .cn-file-dropzone__file-status { color: {color.fg-success}; }
134
+ .cn-file-dropzone .cn-file-dropzone__file[data-state="uploading"] .cn-file-dropzone__file-status { color: {color.fg-subtle}; }
135
+ .cn-file-dropzone .cn-file-dropzone__file[data-state="complete"] .cn-file-dropzone__file-percent { color: {color.fg-muted}; }
136
+ .cn-file-dropzone .cn-file-dropzone__file-status .cn-icon { width: {size.icon.sm}; height: {size.icon.sm}; stroke-width: 2.5; }
137
+ .cn-file-dropzone .cn-file-dropzone__file-progress .cn-progress { flex: 1 1 auto; min-width: 0; }`,
138
+ examples: [
139
+ ex('Default', zone('data-size="md" data-state="idle" style="max-width:512px"', 'multiple accept=".pdf,.docx,.fig" aria-label="Upload files"', 'PDF, DOCX or FIG (max. 25 MB)'), 'White, 1px gray-200 ring, radius 12, 16×24 padding; 40px modern featured icon; "Click to upload" in the brand link color.'),
140
+ ex('Drag over', zone('data-size="md" data-state="dragover" style="max-width:512px"', 'multiple accept=".pdf,.docx,.fig" aria-label="Upload files"', 'PDF, DOCX or FIG (max. 25 MB)', '', 'Release to upload'), 'The ring becomes 2px in the brand color; nothing else moves.'),
141
+ ex('With files (complete, uploading, failed)', zone('data-size="md" data-state="idle" style="max-width:512px"', 'multiple accept=".pdf,.docx,.fig" aria-label="Upload files"', 'PDF, DOCX or FIG (max. 25 MB)', fileRow('brand-guidelines-v3.pdf', '1.2 MB', 'complete', 100) + fileRow('research-synthesis.fig', '348 KB', 'uploading', 60) + fileRow('interview-notes-daniel-costa.docx', '2.1 MB', 'failed', 0)), 'Rows are bordered cards with a 40px file icon, medium name, size · status, an 8px progress bar and a utility delete button; a failed row gets a 2px red ring and "Try again".'),
142
+ ex('Small (single image)', zone('data-size="sm" data-state="idle" style="max-width:280px"', 'accept="image/png,image/jpeg" aria-label="Upload logo"', 'PNG or JPG, square, up to 2 MB'), '16px padding all round, for one asset next to its preview.'),
143
+ ex('Invalid', zone('data-size="md" data-state="idle" data-invalid style="max-width:512px"', 'multiple accept=".pdf,.docx,.fig" aria-invalid="true" aria-label="Upload files"', 'archive.zip is not accepted. Upload PDF, DOCX or FIG (max. 25 MB).'), 'The hint turns to the danger color and starts with the rejected file.'),
144
+ ex('Disabled', zone('data-size="md" data-state="idle" style="max-width:512px"', 'disabled aria-label="Upload files"', 'Uploads are paused while the project is archived.'), 'Gray-50 fill, cursor not-allowed, the featured icon at 50%.'),
145
+ ],
146
+ recipes: [
147
+ ex('In a Field', `<div class="cn-field" data-layout="vertical" data-size="md" style="max-width:512px"><label class="cn-field__label" for="dz-docs">Supporting documents <span class="cn-field__optional">(optional)</span></label><div class="cn-field__control">${zone('data-size="md" data-state="idle"', 'id="dz-docs" multiple accept=".pdf,.docx,.fig" aria-describedby="dz-docs-hint"', 'PDF, DOCX or FIG (max. 25 MB)', fileRow('usability-report-q3.pdf', '212 KB', 'complete', 100))}</div><p class="cn-field__helper" id="dz-docs-hint">Visible to everyone in the Lumen workspace.</p></div>`, 'The Field label names the input through for/id; the hint says where the files go.'),
148
+ ],
149
+ rules: [
150
+ 'The whole area is the target: the file input covers the root, so a click anywhere opens the picker and a drop anywhere works. Do not add a separate "Browse" button.',
151
+ 'Title copy is "Click to upload" (brand link) + "or drag and drop"; while dragging over it may change to "Release to upload". Under 768px the second half may be dropped ("Click to upload and attach files").',
152
+ 'The hint always states accepted types and the size limit ("PDF, DOCX or FIG (max. 25 MB)"); when invalid it starts with the rejected file and keeps the same constraints in one sentence.',
153
+ 'Set data-state="dragover" on dragenter and clear it on dragleave and drop. The ring changes in 100ms; nothing pulses.',
154
+ 'Selected files list as cards 12px apart with name, size · status, an 8px progress bar with the percentage and a utility xs delete button. Failed rows set data-state="failed", get a 2px red ring and a danger-link "Try again".',
155
+ 'md in forms and dialogs, max-width 512px; sm for one small asset (avatar, logo) placed next to its preview.',
156
+ 'Never a full-page dropzone inside the app shell; use a page-level drop overlay for that and keep this component in the form.',
157
+ ],
158
+ a11y: [
159
+ 'The native <input type="file"> stays focusable: Tab reaches it, Enter or Space open the picker, and the ring shows on the zone through :has(:focus-visible).',
160
+ 'Give the input an accessible name that says what to upload ("Upload files"): a Field label with for/id, or aria-label.',
161
+ 'Use accept and multiple on the input so the OS picker and assistive tech know the constraints; repeat them in the hint.',
162
+ 'Delete buttons carry aria-label "Delete <file name>"; progress bars carry aria-label and aria-valuenow; announce completion or failure in a live region.',
163
+ 'Drag and drop is an enhancement: the picker must always work, including on touch devices.',
164
+ ],
165
+ related: ['field', 'featured-icon', 'progress', 'icon-button', 'button'],
166
+ };