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,68 @@
1
+ import { ex, ICON, typeStyle } from "./_shared.mjs";
2
+ const btn = (v, label, icon = '') => `<button type="button" class="cn-button" data-variant="${v}" data-size="md">${icon}<span class="cn-button__label">${label}</span></button>`;
3
+ export const pageHeader = {
4
+ name: 'PageHeader',
5
+ slug: 'page-header',
6
+ category: 'layout',
7
+ description: 'Top of an application page: title, supporting text and the actions for the page, optionally with breadcrumbs above, a search field, an avatar, or tabs below. The "page header" pattern.',
8
+ usage: 'Exactly one per page, directly under the top navigation. Put the page-level primary action here (at most one filled button). Section titles inside the page use SectionHeader.',
9
+ anatomy: [
10
+ { part: 'root', element: 'header', description: 'Flex row: content left, actions right; wraps on mobile.' },
11
+ { part: 'crumbs', element: 'nav', description: 'Optional breadcrumb row above the title (uses Breadcrumb markup).', optional: true },
12
+ { part: 'media', element: 'span', description: 'Optional 56px avatar or featured icon before the content.', optional: true },
13
+ { part: 'content', element: 'div', description: 'Title + description column.' },
14
+ { part: 'title', element: 'h1', description: 'heading-lg (30/38 semibold). One h1 per page.' },
15
+ { part: 'description', element: 'p', description: 'body-lg (16/24) in fg-muted. One sentence.' },
16
+ { part: 'actions', element: 'div', description: 'Right-aligned row of buttons; on mobile it stacks under the content.' },
17
+ { part: 'search', element: 'div', description: 'Optional search Input in the actions row (width 320).', optional: true },
18
+ { part: 'tabs', element: 'div', description: 'Optional Tabs (underline) below, bleeding to the header edges.', optional: true },
19
+ ],
20
+ props: {
21
+ variant: { values: ['simple', 'with-search', 'with-tabs', 'with-avatar', 'centered'], default: 'simple', description: 'simple = title/description/actions; with-search = a search field before the actions; with-tabs = tabs under the header; with-avatar = 56px media block before the title (profile pages); centered = title and description centered, no actions (informational pages).' },
22
+ divider: { values: ['yes', 'no'], default: 'yes', description: 'yes = 1px hairline under the header (or under the tabs).' },
23
+ },
24
+ states: {},
25
+ base: {
26
+ root: { display: 'flex', 'flex-wrap': 'wrap', 'align-items': 'flex-start', 'justify-content': 'space-between', gap: '{space.4} {space.6}', 'padding-bottom': '{space.5}' },
27
+ crumbs: { 'flex-basis': '100%', 'margin-bottom': '{space.2}' },
28
+ media: { 'flex-shrink': '0' },
29
+ content: { display: 'flex', 'flex-direction': 'column', gap: '{space.1}', 'min-width': '0', flex: '1 1 320px' },
30
+ title: { ...typeStyle('heading-lg'), color: '{color.fg-default}' },
31
+ description: { ...typeStyle('body-lg'), color: '{color.fg-muted}' },
32
+ actions: { display: 'flex', 'align-items': 'center', gap: '{space.3}', 'flex-shrink': '0', 'flex-wrap': 'wrap' },
33
+ search: { width: '320px', 'max-width': '100%' },
34
+ tabs: { 'flex-basis': '100%', 'min-width': '0', 'max-width': '100%', 'margin-top': '{space.2}' },
35
+ },
36
+ variants: {
37
+ variant: {
38
+ simple: { root: {} },
39
+ 'with-search': { root: {} },
40
+ 'with-tabs': { root: { 'padding-bottom': '0' } },
41
+ 'with-avatar': { root: {} },
42
+ centered: { root: { 'flex-direction': 'column', 'align-items': 'center', 'text-align': 'center' }, content: { 'align-items': 'center', flex: '0 1 auto', 'max-width': '640px' } },
43
+ },
44
+ divider: { yes: { root: { 'border-bottom': '{border.width.thin} solid {color.border-default}' } }, no: { root: {} } },
45
+ },
46
+ extraCss: `
47
+ .cn-page-header[data-variant="with-avatar"] { align-items: center; }
48
+ .cn-page-header[data-variant="with-avatar"] .cn-page-header__content { flex-direction: row; align-items: center; gap: {space.4}; }
49
+ .cn-page-header[data-variant="with-avatar"] .cn-page-header__title + .cn-page-header__description { margin-top: 0; }
50
+ .cn-page-header[data-variant="with-tabs"] .cn-page-header__tabs .cn-tabs { margin-bottom: -1px; }
51
+ @media (max-width: {breakpoint.md}) { .cn-page-header { padding-bottom: {space.4}; } .cn-page-header .cn-page-header__actions { width: 100%; } .cn-page-header .cn-page-header__actions .cn-button { flex: 1 1 auto; } .cn-page-header .cn-page-header__search { width: 100%; } }`,
52
+ examples: [
53
+ ex('Simple', `<header class="cn-page-header" data-variant="simple" data-divider="yes"><div class="cn-page-header__content"><h1 class="cn-page-header__title">Team members</h1><p class="cn-page-header__description">Manage your team members and their account permissions here.</p></div><div class="cn-page-header__actions">${btn('outline', 'Secondary')}${btn('primary', 'Add member', ICON.plus.replace('cn-icon', 'cn-button__icon'))}</div></header>`),
54
+ ex('With breadcrumbs and search', `<header class="cn-page-header" data-variant="with-search" data-divider="yes"><nav class="cn-page-header__crumbs cn-breadcrumb" data-size="sm" aria-label="Breadcrumb"><ol class="cn-breadcrumb__list"><li class="cn-breadcrumb__item"><a class="cn-breadcrumb__link" href="#">Home</a></li><li class="cn-breadcrumb__separator" aria-hidden="true">${ICON.chevronRight}</li><li class="cn-breadcrumb__item"><a class="cn-breadcrumb__link" href="#">Settings</a></li><li class="cn-breadcrumb__separator" aria-hidden="true">${ICON.chevronRight}</li><li class="cn-breadcrumb__item"><span class="cn-breadcrumb__current" aria-current="page">Team</span></li></ol></nav><div class="cn-page-header__content"><h1 class="cn-page-header__title">Team</h1><p class="cn-page-header__description">Invite and manage the people who work with you.</p></div><div class="cn-page-header__actions"><div class="cn-page-header__search cn-input" data-variant="default" data-size="md">${ICON.search.replace('cn-icon', 'cn-input__icon')}<input class="cn-input__field" type="search" placeholder="Search" aria-label="Search team"></div>${btn('primary', 'Invite')}</div></header>`),
55
+ ex('With tabs', `<header class="cn-page-header" data-variant="with-tabs" data-divider="yes"><div class="cn-page-header__content"><h1 class="cn-page-header__title">Settings</h1><p class="cn-page-header__description">Manage your account settings and preferences.</p></div><div class="cn-page-header__actions">${btn('outline', 'Discard')}${btn('primary', 'Save changes')}</div><div class="cn-page-header__tabs"><div class="cn-tabs" data-variant="underline" data-size="md" role="tablist"><button type="button" class="cn-tabs__tab" role="tab" aria-selected="true">My details</button><button type="button" class="cn-tabs__tab" role="tab" aria-selected="false">Profile</button><button type="button" class="cn-tabs__tab" role="tab" aria-selected="false">Password</button><button type="button" class="cn-tabs__tab" role="tab" aria-selected="false">Team</button><button type="button" class="cn-tabs__tab" role="tab" aria-selected="false">Plan</button><button type="button" class="cn-tabs__tab" role="tab" aria-selected="false">Billing</button></div></div></header>`),
56
+ ex('With avatar', `<header class="cn-page-header" data-variant="with-avatar" data-divider="yes"><div class="cn-page-header__content"><span class="cn-page-header__media cn-avatar" data-size="xl" data-shape="circle" data-tone="accent" data-status="online"><span class="cn-avatar__fallback">MC</span><span class="cn-avatar__status"></span></span><div><h1 class="cn-page-header__title">Maya Chen</h1><p class="cn-page-header__description">maya@lumen.co</p></div></div><div class="cn-page-header__actions">${btn('outline', 'Share')}${btn('outline', 'View profile')}${btn('primary', 'Edit')}</div></header>`),
57
+ ex('Centered (informational)', `<header class="cn-page-header" data-variant="centered" data-divider="no"><div class="cn-page-header__content"><h1 class="cn-page-header__title">Release notes</h1><p class="cn-page-header__description">All the latest the system updates, improvements, and fixes.</p></div></header>`),
58
+ ],
59
+ rules: [
60
+ 'The title is the h1 and matches the navigation item that brought the user here.',
61
+ 'Description is optional and never longer than one sentence.',
62
+ 'At most one primary button; secondary actions are outline; overflow goes into a ⋯ IconButton with a Menu.',
63
+ '20px below the header (the padding) then 32px to the first section when there is a divider; with tabs, the tab list sits on the divider line.',
64
+ 'On mobile (< 768) actions move under the content and stretch full width.',
65
+ ],
66
+ a11y: ['One h1 per page; the breadcrumb nav has aria-label="Breadcrumb".', 'Search fields have an aria-label when the placeholder is the only label.'],
67
+ related: ['section-header', 'breadcrumb', 'tabs', 'button', 'input', 'avatar'],
68
+ };
@@ -0,0 +1,236 @@
1
+ import { RESET_BUTTON, ex, ICON, typeStyle } from "./_shared.mjs";
2
+ // the reference pagination: 36px page items (14px medium gray-500, radius 8 or full) that turn
3
+ // gray-50 with gray-700 text when hovered or current, 2px apart; secondary-style Previous / Next
4
+ // buttons at the ends; a hairline above with 20px of padding; "Page 1 of 10" for the minimal
5
+ // center type; a 36px button group; 8px dots and 40 × 6 lines for carousels and steps.
6
+ const TRANSITION_FAST = {
7
+ 'transition-property': 'background-color, border-color, color, box-shadow',
8
+ 'transition-duration': '{motion.duration.fast}',
9
+ 'transition-timing-function': '{motion.easing.linear}',
10
+ };
11
+ const HAIRLINE = '{border.width.thin} solid {color.border-default}';
12
+ const ring = (color) => `inset 0 0 0 1px ${color}`;
13
+ /** Previous / Next look like the reference secondary button: white, gray-300 ring, skeuomorphic edge, shadow-xs. */
14
+ const SECONDARY_SHADOW = `${ring('{color.border-control}')}, {shadow.control}, {shadow.xs}`;
15
+ const ARROW_LEFT = '<svg class="cn-pagination__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13 8H3M7 4L3 8l4 4"/></svg>';
16
+ const ARROW_RIGHT = ICON.arrow.replace('cn-icon', 'cn-pagination__icon');
17
+ const CHEVRON_LEFT = '<svg class="cn-pagination__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 4l-4 4 4 4"/></svg>';
18
+ const CHEVRON_RIGHT = ICON.chevronRight.replace('cn-icon', 'cn-pagination__icon');
19
+ const page = (n, current = false) => `<button type="button" class="cn-pagination__item"${current ? ' aria-current="page"' : ''}>${n}</button>`;
20
+ const ell = '<span class="cn-pagination__ellipsis" aria-hidden="true">…</span>';
21
+ const prev = (label = 'Previous', disabled = false, icon = ARROW_LEFT) => `<button type="button" class="cn-pagination__prev" aria-label="Previous page"${disabled ? ' disabled' : ''}${label ? '' : ' data-icon-only'}>${icon}${label ? `<span>${label}</span>` : ''}</button>`;
22
+ const next = (label = 'Next', disabled = false, icon = ARROW_RIGHT) => `<button type="button" class="cn-pagination__next" aria-label="Next page"${disabled ? ' disabled' : ''}${label ? '' : ' data-icon-only'}>${label ? `<span>${label}</span>` : ''}${icon}</button>`;
23
+ const summary = (text) => `<span class="cn-pagination__summary">${text}</span>`;
24
+ const pages = (inner) => `<div class="cn-pagination__pages">${inner}</div>`;
25
+ const nav = (variant, inner, extra = '', size = 'md', shape = 'square', align = 'center') => `<nav class="cn-pagination" data-variant="${variant}" data-size="${size}" data-shape="${shape}" data-align="${align}" aria-label="Pagination"${extra}>${inner}</nav>`;
26
+ const TEN = pages(`${page(1, true)}${page(2)}${page(3)}${ell}${page(8)}${page(9)}${page(10)}`);
27
+ const control = {
28
+ ...RESET_BUTTON,
29
+ display: 'inline-flex',
30
+ 'align-items': 'center',
31
+ 'justify-content': 'center',
32
+ 'flex-shrink': '0',
33
+ width: '{space.9}',
34
+ height: '{space.9}',
35
+ 'border-radius': '{radius.control}',
36
+ ...typeStyle('label-sm'),
37
+ color: '{color.fg-subtle}',
38
+ 'font-variant-numeric': 'tabular-nums',
39
+ ...TRANSITION_FAST,
40
+ };
41
+ const button = {
42
+ ...RESET_BUTTON,
43
+ display: 'inline-flex',
44
+ 'align-items': 'center',
45
+ 'justify-content': 'center',
46
+ gap: '{space.1}',
47
+ 'flex-shrink': '0',
48
+ height: '{space.9}',
49
+ 'min-width': '{space.9}',
50
+ 'padding-inline': '{space.3}',
51
+ 'border-radius': '{radius.control}',
52
+ ...typeStyle('label-md'),
53
+ 'background-color': '{color.bg-surface}',
54
+ color: '{color.fg-muted}',
55
+ 'box-shadow': SECONDARY_SHADOW,
56
+ ...TRANSITION_FAST,
57
+ };
58
+ export const pagination = {
59
+ name: 'Pagination',
60
+ slug: 'pagination',
61
+ category: 'navigation',
62
+ description: 'Page controls of the reference: 36px page numbers (14px medium, gray-50 when current) with ellipses, secondary-style Previous / Next buttons at the ends on a hairline; a minimal "Page 1 of 10" center; a segmented button group; dots and lines for carousels.',
63
+ usage: 'Use under a table or list whose total is known and larger than one page (page-default under page-level tables, minimal or button-group in a table card footer). Use simple or minimal when the total is unknown or the space is narrow. dots and line are for carousels and onboarding slides, never for data.',
64
+ anatomy: [
65
+ { part: 'root', element: 'nav', description: '<nav aria-label="Pagination">: a flex row, space-between, 12px gap; page-default and minimal add the hairline above with 20px of padding.' },
66
+ { part: 'summary', element: 'span', description: 'The position in words, 14px medium gray-700 with tabular numbers: "Page 1 of 10" (minimal) or "1–10 of 240" (default).', optional: true },
67
+ { part: 'pages', element: 'div', description: 'The centered row of page items, 2px apart (0 in button-group, 8 for dots and lines).', optional: true },
68
+ { part: 'prev', element: 'button', description: 'Previous: a 36px secondary-style button (gray-300 ring, shadow-xs) with a 20px arrow and the word "Previous"; data-icon-only makes it a 36px square. Disabled on the first page, never hidden.' },
69
+ { part: 'item', element: 'button', description: 'One page number, 36 × 36, 14px medium gray-500, radius 8 (or full); gray-50 with gray-700 text when hovered or current (aria-current="page").' },
70
+ { part: 'ellipsis', element: 'span', description: 'The "…" standing in for skipped pages, 36 × 36, gray-600, aria-hidden.', optional: true },
71
+ { part: 'next', element: 'button', description: 'Next: the mirror of prev. Disabled on the last page.' },
72
+ { part: 'icon', element: 'svg', description: '20px arrow or chevron inside prev / next. Decorative.' },
73
+ { part: 'dot', element: 'button', description: 'An 8px gray-200 dot (dots variant); brand-600 when current. aria-label "Slide N".', optional: true },
74
+ { part: 'line', element: 'button', description: 'A 40 × 6 gray-200 bar (line variant); brand-600 when current.', optional: true },
75
+ ],
76
+ props: {
77
+ variant: {
78
+ values: ['default', 'simple', 'page-default', 'minimal', 'button-group', 'dots', 'line'],
79
+ default: 'default',
80
+ description: 'default = summary, prev, page items and next in one row (inline under a table). simple = summary + prev / next only, for unknown totals and narrow layouts. page-default = the reference page pagination: Previous at the left, numbers centered, Next at the right, on a hairline with 20px padding. minimal = Previous, "Page 1 of 10" and Next, same hairline (table card footers). button-group = the page items as one 36px segmented control with shared rings. dots = 8px dots for carousels. line = 40 × 6 bars for onboarding steps.',
81
+ },
82
+ size: {
83
+ values: ['sm', 'md'],
84
+ default: 'md',
85
+ description: 'md = 36px items and buttons (the reference). sm = 32px items and buttons for dense card footers and drawers.',
86
+ },
87
+ shape: {
88
+ values: ['square', 'round'],
89
+ default: 'square',
90
+ description: 'square = radius 8 on page items (default). round = fully rounded page items (the reference rounded={true}).',
91
+ },
92
+ align: {
93
+ values: ['left', 'center', 'right'],
94
+ default: 'center',
95
+ description: 'Where the pages or the summary sit in minimal, button-group, dots and line: left, center (default) or right. page-default is always Previous | pages | Next.',
96
+ },
97
+ },
98
+ states: {
99
+ current: { selector: ' .cn-pagination__item[aria-current="page"], & .cn-pagination__dot[aria-current="true"], & .cn-pagination__line[aria-current="true"]', description: 'The page being shown (on the ITEM, not the root): gray-50 fill with gray-700 text; dots and lines turn brand-600.', markup: 'aria-current="page" on the page item, aria-current="true" on a dot or line' },
100
+ hover: { selector: ' .cn-pagination__item:hover, & .cn-pagination__prev:hover, & .cn-pagination__next:hover', description: 'Pointer over a page or a button: gray-50 fill and gray-700 text (the same look as current).', markup: 'native :hover' },
101
+ focus: { selector: ' .cn-pagination__item:focus-visible, & .cn-pagination__prev:focus-visible, & .cn-pagination__next:focus-visible', description: 'Keyboard focus: the 4px brand ring around that control.', markup: 'native :focus-visible' },
102
+ disabled: { selector: ' .cn-pagination__prev:disabled, & .cn-pagination__next:disabled', description: 'A button at a bound (first or last page): 50% opacity, no hover; stays visible so the layout never jumps.', markup: 'disabled (or aria-disabled="true" on links)' },
103
+ },
104
+ base: {
105
+ root: {
106
+ display: 'flex',
107
+ 'align-items': 'center',
108
+ 'justify-content': 'space-between',
109
+ gap: '{space.3}',
110
+ width: '100%',
111
+ ...typeStyle('body-md'),
112
+ color: '{color.fg-muted}',
113
+ },
114
+ summary: {
115
+ ...typeStyle('label-sm'),
116
+ color: '{color.fg-muted}',
117
+ 'font-variant-numeric': 'tabular-nums',
118
+ 'white-space': 'nowrap',
119
+ },
120
+ pages: { display: 'flex', 'align-items': 'center', gap: '{space.0.5}', 'min-width': '0', 'flex-wrap': 'wrap' },
121
+ item: control,
122
+ prev: button,
123
+ next: button,
124
+ ellipsis: {
125
+ display: 'inline-flex',
126
+ 'align-items': 'center',
127
+ 'justify-content': 'center',
128
+ width: '{space.9}',
129
+ height: '{space.9}',
130
+ color: '{color.fg-muted}',
131
+ 'user-select': 'none',
132
+ 'flex-shrink': '0',
133
+ },
134
+ icon: { width: '{size.icon.md}', height: '{size.icon.md}', 'flex-shrink': '0', display: 'block', color: '{color.fg-subtle}', transition: 'inherit' },
135
+ dot: { ...RESET_BUTTON, display: 'block', width: '{space.2}', height: '{space.2}', 'border-radius': '{radius.full}', 'background-color': '{color.border-default}', 'flex-shrink': '0', ...TRANSITION_FAST },
136
+ line: { ...RESET_BUTTON, display: 'block', width: '{space.10}', height: '{space.1.5}', 'border-radius': '{radius.full}', 'background-color': '{color.border-default}', 'flex-shrink': '0', ...TRANSITION_FAST },
137
+ },
138
+ variants: {
139
+ variant: {
140
+ default: { root: { 'justify-content': 'flex-start', width: 'auto' } },
141
+ simple: { root: { 'justify-content': 'flex-start', width: 'auto' }, item: { display: 'none' }, ellipsis: { display: 'none' }, pages: { display: 'none' } },
142
+ 'page-default': { root: { 'border-top': HAIRLINE, 'padding-top': '{space.5}' } },
143
+ minimal: { root: { 'border-top': HAIRLINE, 'padding-top': '{space.5}' } },
144
+ 'button-group': { pages: { gap: '0', 'flex-wrap': 'nowrap', 'border-radius': '{radius.control}', 'box-shadow': '{shadow.xs}' }, item: { 'border-radius': '0', 'background-color': '{color.bg-surface}', color: '{color.fg-muted}', 'box-shadow': `${ring('{color.border-control}')}, {shadow.control}` }, ellipsis: { 'background-color': '{color.bg-surface}', 'box-shadow': `${ring('{color.border-control}')}, {shadow.control}` } },
145
+ dots: { pages: { gap: '{space.2}' } },
146
+ line: { pages: { gap: '{space.2}' } },
147
+ },
148
+ size: {
149
+ sm: {
150
+ item: { width: '{space.8}', height: '{space.8}' },
151
+ prev: { height: '{space.8}', 'min-width': '{space.8}', 'padding-inline': '{space.2.5}' },
152
+ next: { height: '{space.8}', 'min-width': '{space.8}', 'padding-inline': '{space.2.5}' },
153
+ ellipsis: { width: '{space.8}', height: '{space.8}' },
154
+ icon: { width: '{size.icon.sm}', height: '{size.icon.sm}' },
155
+ },
156
+ md: { root: {} },
157
+ },
158
+ shape: {
159
+ square: { root: {} },
160
+ round: { item: { 'border-radius': '{radius.full}' } },
161
+ },
162
+ align: {
163
+ left: { root: {} },
164
+ center: { root: {} },
165
+ right: { root: {} },
166
+ },
167
+ },
168
+ compound: [
169
+ { when: { variant: 'minimal', align: 'left' }, block: { root: { 'justify-content': 'flex-start' }, summary: { order: '-1', 'margin-inline-end': 'auto' } } },
170
+ { when: { variant: 'minimal', align: 'right' }, block: { root: { 'justify-content': 'flex-end' }, summary: { 'margin-inline-end': 'auto' } } },
171
+ { when: { variant: 'button-group', align: 'left' }, block: { root: { 'justify-content': 'flex-start' } } },
172
+ { when: { variant: 'button-group', align: 'right' }, block: { root: { 'justify-content': 'flex-end' } } },
173
+ { when: { variant: 'dots', align: 'left' }, block: { root: { 'justify-content': 'flex-start' } } },
174
+ { when: { variant: 'dots', align: 'right' }, block: { root: { 'justify-content': 'flex-end' } } },
175
+ { when: { variant: 'line', align: 'left' }, block: { root: { 'justify-content': 'flex-start' } } },
176
+ { when: { variant: 'line', align: 'right' }, block: { root: { 'justify-content': 'flex-end' } } },
177
+ { when: { variant: 'dots', size: 'md' }, block: { dot: { width: '{space.2.5}', height: '{space.2.5}' } } },
178
+ { when: { variant: 'line', size: 'md' }, block: { line: { height: '{space.2}' } } },
179
+ ],
180
+ extraCss: `
181
+ .cn-pagination__item:hover:not(:disabled):not([aria-disabled="true"]), .cn-pagination__item[aria-current="page"] { background-color: {color.bg-subtle}; color: {color.fg-muted}; }
182
+ .cn-pagination__item[aria-current="page"] { cursor: default; }
183
+ .cn-pagination__prev:hover:not(:disabled):not([aria-disabled="true"]), .cn-pagination__next:hover:not(:disabled):not([aria-disabled="true"]) { background-color: {color.bg-subtle}; color: {color.fg-default}; }
184
+ .cn-pagination__prev:hover:not(:disabled) .cn-pagination__icon, .cn-pagination__next:hover:not(:disabled) .cn-pagination__icon { color: {color.fg-muted}; }
185
+ .cn-pagination__prev[data-icon-only], .cn-pagination__next[data-icon-only] { padding-inline: 0; width: {space.9}; }
186
+ .cn-pagination[data-size="sm"] .cn-pagination__prev[data-icon-only], .cn-pagination[data-size="sm"] .cn-pagination__next[data-icon-only] { width: {space.8}; }
187
+ .cn-pagination__prev > span, .cn-pagination__next > span { padding-inline: {space.0.5}; }
188
+ .cn-pagination__item:focus-visible, .cn-pagination__prev:focus-visible, .cn-pagination__next:focus-visible, .cn-pagination__dot:focus-visible, .cn-pagination__line:focus-visible { outline: none; box-shadow: {shadow.focus}; z-index: {z.raised}; }
189
+ .cn-pagination__prev:focus-visible, .cn-pagination__next:focus-visible { box-shadow: ${SECONDARY_SHADOW}, {shadow.focus}; }
190
+ .cn-pagination__item:disabled, .cn-pagination__prev:disabled, .cn-pagination__next:disabled,
191
+ .cn-pagination__item[aria-disabled="true"], .cn-pagination__prev[aria-disabled="true"], .cn-pagination__next[aria-disabled="true"] { opacity: {opacity.disabled}; cursor: not-allowed; pointer-events: none; }
192
+ .cn-pagination[data-variant="button-group"] .cn-pagination__pages > * + * { margin-inline-start: -1px; }
193
+ .cn-pagination[data-variant="button-group"] .cn-pagination__pages > :first-child { border-start-start-radius: {radius.control}; border-end-start-radius: {radius.control}; }
194
+ .cn-pagination[data-variant="button-group"] .cn-pagination__pages > :last-child { border-start-end-radius: {radius.control}; border-end-end-radius: {radius.control}; }
195
+ .cn-pagination[data-variant="button-group"] .cn-pagination__item:hover:not(:disabled), .cn-pagination[data-variant="button-group"] .cn-pagination__item[aria-current="page"] { background-color: {color.bg-subtle}; color: {color.fg-default}; box-shadow: ${ring('{color.border-control}')}, {shadow.control}; }
196
+ .cn-pagination[data-variant="button-group"] .cn-pagination__item:focus-visible { box-shadow: ${ring('{color.border-control}')}, {shadow.control}, {shadow.focus}; }
197
+ .cn-pagination__dot[aria-current="true"], .cn-pagination__line[aria-current="true"] { background-color: {color.bg-action}; }
198
+ .cn-pagination__dot:hover:not([aria-current="true"]), .cn-pagination__line:hover:not([aria-current="true"]) { background-color: {color.border-control}; }
199
+ .cn-pagination[data-framed] .cn-pagination__pages { padding: {space.2}; border-radius: {radius.full}; background-color: {color.bg-surface}; box-shadow: ${ring('{color.border-default}')}, {shadow.xs}; }
200
+ @media (max-width: {breakpoint.md}) {
201
+ .cn-pagination[data-variant="page-default"] .cn-pagination__pages { display: none; }
202
+ .cn-pagination[data-variant="page-default"] .cn-pagination__prev > span, .cn-pagination[data-variant="page-default"] .cn-pagination__next > span { display: none; }
203
+ .cn-pagination[data-variant="page-default"] .cn-pagination__prev, .cn-pagination[data-variant="page-default"] .cn-pagination__next { padding-inline: 0; width: {space.9}; }
204
+ }`,
205
+ examples: [
206
+ ex('Page default', `<div style="width:100%;max-width:720px">${nav('page-default', `${prev('Previous', true)}${TEN}${next()}`)}</div>`, 'Previous and Next at the ends (secondary-style, 36px), numbers centered 2px apart on a hairline with 20px of padding. Page 1 is current in gray-50.'),
207
+ ex('Page default, rounded, middle page', `<div style="width:100%;max-width:720px">${nav('page-default', `${prev()}${pages(`${page(1)}${ell}${page(5)}${page(6, true)}${page(7)}${ell}${page(10)}`)}${next()}`, '', 'md', 'round')}</div>`, 'data-shape="round": fully rounded items. Seven slots: first, ellipsis, current ±1, ellipsis, last.'),
208
+ ex('Minimal center', `<div style="width:100%;max-width:720px">${nav('minimal', `${prev('Previous', true)}${summary('Page 1 of 10')}${next()}`)}</div>`, 'Previous, "Page 1 of 10" and Next; the reference minimal center type for table card footers.'),
209
+ ex('Minimal, right aligned, small', `<div style="width:100%;max-width:720px">${nav('minimal', `${summary('Page 3 of 10')}${prev('', false, CHEVRON_LEFT)}${next('', false, CHEVRON_RIGHT)}`, '', 'sm', 'square', 'right')}</div>`, 'The summary on the left, two 32px icon-only chevron buttons on the right (data-icon-only).'),
210
+ ex('Button group', `<div style="width:100%;max-width:720px">${nav('button-group', `${pages(`${page(1, true)}${page(2)}${page(3)}${ell}${page(8)}${page(9)}${page(10)}`)}`, '', 'md', 'square', 'right')}</div>`, 'One 36px segmented control with shared gray-300 rings and shadow-xs; the current page is gray-50.'),
211
+ ex('Default (inline) and simple', `<div style="display:flex;flex-direction:column;gap:var(--cn-space-4)">${nav('default', `${summary('1–10 of 240')}${prev('', true, CHEVRON_LEFT)}${pages(`${page(1, true)}${page(2)}${page(3)}${ell}${page(24)}`)}${next('', false, CHEVRON_RIGHT)}`)}${nav('simple', `${summary('Showing 20 results')}${prev('', false, CHEVRON_LEFT)}${next('', false, CHEVRON_RIGHT)}`)}</div>`, 'default keeps everything in one inline row; simple hides the page items for cursor APIs and unknown totals.'),
212
+ ex('Dots and lines (carousels)', `<div style="display:flex;flex-direction:column;gap:var(--cn-space-6);align-items:flex-start">${nav('dots', pages(['1', '2', '3', '4', '5'].map((n, i) => `<button type="button" class="cn-pagination__dot" aria-label="Slide ${n}"${i === 1 ? ' aria-current="true"' : ''}></button>`).join('')), '', 'md', 'square', 'left')}${nav('line', pages(['1', '2', '3', '4'].map((n, i) => `<button type="button" class="cn-pagination__line" aria-label="Step ${n}"${i === 2 ? ' aria-current="true"' : ''}></button>`).join('')), '', 'md', 'square', 'left')}${nav('dots', pages(['1', '2', '3', '4'].map((n, i) => `<button type="button" class="cn-pagination__dot" aria-label="Slide ${n}"${i === 0 ? ' aria-current="true"' : ''}></button>`).join('')), ' data-framed', 'sm', 'square', 'left')}</div>`, '10px dots (8px at sm) and 40 × 8 lines, gray-200 at rest and brand-600 when current; data-framed puts them on a white ringed pill.'),
213
+ ex('Pages as links', `<div style="width:100%;max-width:720px"><nav class="cn-pagination" data-variant="page-default" data-size="md" data-shape="square" data-align="center" aria-label="Pagination"><a href="?page=1" class="cn-pagination__prev" aria-label="Previous page">${ARROW_LEFT}<span>Previous</span></a><div class="cn-pagination__pages"><a href="?page=1" class="cn-pagination__item">1</a><a href="?page=2" class="cn-pagination__item" aria-current="page">2</a><a href="?page=3" class="cn-pagination__item">3</a><a href="?page=4" class="cn-pagination__item">4</a><a href="?page=5" class="cn-pagination__item">5</a></div><a href="?page=3" class="cn-pagination__next" aria-label="Next page"><span>Next</span>${ARROW_RIGHT}</a></nav></div>`, 'When the page is in the URL, every control is an <a href> so pages are shareable and crawlable.'),
214
+ ],
215
+ rules: [
216
+ 'At most seven page slots: first, last, the current page with one neighbour on each side, and ellipses for the gaps.',
217
+ 'Page items are 36 × 36 (sm 32), 14px medium, 2px apart; the current page is gray-50 with darker text, the same look as hover. Never an inverse fill.',
218
+ 'Previous and Next are always present and always the secondary-button look; at a bound they are disabled, never removed, so the layout does not shift. Under 768px they become 36px icon-only buttons.',
219
+ 'page-default under page-level tables and lists; minimal or button-group in a table card footer (12 × 24px padding); simple for cursor APIs, unknown totals and drawers.',
220
+ 'The minimal summary reads "Page N of M" in 14px medium; the default summary reads "1–10 of 240". Never both.',
221
+ 'dots and line are for carousels and onboarding slides only (aria-current="true" on the current one); never for tables.',
222
+ 'The page-size selector ("10 per page") belongs to the table toolbar or the advanced card footer, not next to the page items.',
223
+ 'sm only inside cards and drawers; md under page-level tables.',
224
+ 'When the page number is part of the URL, render every control as a link; otherwise as buttons. Never mix.',
225
+ ],
226
+ a11y: [
227
+ 'Root is <nav aria-label="Pagination">; when there are several on a page, name them ("Customers pagination").',
228
+ 'The current page carries aria-current="page" and is announced as such; the gray fill is the visual cue, the attribute is the semantic one.',
229
+ 'Prev / Next have aria-label="Previous page" / "Next page" even when the word is visible; the arrows are aria-hidden.',
230
+ 'Ellipses are aria-hidden; screen readers hear the page numbers, not "dot dot dot".',
231
+ 'Disabled bounds use the disabled attribute on buttons or aria-disabled="true" on links (keep the href out of the tab order).',
232
+ 'Announce page changes: put the summary in an aria-live="polite" region when the table updates in place.',
233
+ 'Dots and lines are buttons with aria-label "Slide N" / "Step N" and aria-current="true" on the active one.',
234
+ ],
235
+ related: ['table', 'list', 'button-group', 'button', 'select'],
236
+ };
@@ -0,0 +1,120 @@
1
+ import { TRANSITION_COLORS, SIZE_PROP, ex, typeStyle } from "./_shared.mjs";
2
+ // One-time-code entry: a row of square cells, one character each, the digit painted in the action
3
+ // color at display size. Cells are square: 48 / 64 / 80px; type is heading-xl / display-md / display-lg
4
+ // (the closest composite styles to the reference's display sizes).
5
+ const SIZES = {
6
+ sm: { box: '{space.12}', type: 'heading-xl', separator: 'heading-md' },
7
+ md: { box: '{space.16}', type: 'display-md', separator: 'heading-lg' },
8
+ lg: { box: '{space.20}', type: 'display-lg', separator: 'heading-xl' },
9
+ };
10
+ const cell = (i, total, value = '', attrs = '') => `<input class="cn-pin-input__cell" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="1" placeholder="0"${i === 1 ? ' autocomplete="one-time-code"' : ''} aria-label="Digit ${i} of ${total}"${value ? ` value="${value}" data-filled` : ''}${attrs}>`;
11
+ const SEP = '<span class="cn-pin-input__separator" aria-hidden="true">-</span>';
12
+ const pin = (attrs, digits, separatorAfter = 0, cellAttrs = '') => `<div class="cn-pin-input" ${attrs} role="group" aria-label="Verification code">${digits.map((d, i) => cell(i + 1, digits.length, d, cellAttrs) + (separatorAfter && i + 1 === separatorAfter ? SEP : '')).join('')}</div>`;
13
+ export const pinInput = {
14
+ name: 'PinInput',
15
+ slug: 'pin-input',
16
+ category: 'forms',
17
+ description: 'A row of square one-character cells for a verification code. Each cell is a native input with a "0" placeholder; the typed digit renders large, centered, in the action color. Focus moves cell to cell.',
18
+ usage: 'Use for one-time passcodes, 2FA codes and short confirmation codes (4 or 6 digits) sent by email or SMS. Not for phone numbers, card numbers or anything the user copies from a document: use Input with inputmode there.',
19
+ anatomy: [
20
+ { part: 'root', element: 'div', description: 'The row (flex, gap space.2). role="group" with an aria-label; carries data-size, data-length, data-invalid, data-disabled.' },
21
+ { part: 'cell', element: 'input', description: 'One <input maxlength="1" inputmode="numeric">: square, centered display text in fg-action, hairline, radius lg, shadow-xs. Set data-filled once it holds a character.' },
22
+ { part: 'separator', element: 'span', description: 'A muted "-" between the two halves of a 6-digit code. aria-hidden.', optional: true },
23
+ ],
24
+ props: {
25
+ size: SIZE_PROP(['sm', 'md', 'lg']),
26
+ length: { values: ['4', '6'], default: '6', description: '4 = short codes (app confirmations, kiosk PINs), rendered as one group with a wider gap; 6 = the common SMS / authenticator code, rendered as two groups of three with a separator between them.' },
27
+ },
28
+ states: {
29
+ focus: { selector: ' .cn-pin-input__cell:focus', description: 'On the __cell (not the root): the focused cell shows the action border and the ring. Styled in extraCss because it lives on a child.', markup: 'native :focus on a cell' },
30
+ filled: { selector: ' .cn-pin-input__cell[data-filled]', description: 'On the __cell: the cell holds a character; the border strengthens so the progress is visible at a glance. Styled in extraCss.', markup: 'data-filled on the cell (set it whenever value is not empty)' },
31
+ invalid: { selector: '[data-invalid], &:has([aria-invalid="true"])', description: 'Wrong or expired code: every cell takes the danger border, the focused cell the danger ring. Pair with a message under the row.', markup: 'aria-invalid="true" on the cells, or data-invalid on the root' },
32
+ disabled: { selector: '[data-disabled], &:has(input:disabled)', description: 'While the code is being verified or after too many attempts: grey cells, disabled text, cursor not-allowed.', markup: 'disabled on the cells' },
33
+ },
34
+ base: {
35
+ root: { display: 'inline-flex', 'align-items': 'center', gap: '{space.2}', 'max-width': '100%' },
36
+ cell: {
37
+ 'box-sizing': 'border-box',
38
+ 'flex-shrink': '1',
39
+ 'min-width': '0',
40
+ 'aspect-ratio': '1',
41
+ margin: '0',
42
+ padding: '0',
43
+ 'text-align': 'center',
44
+ ...typeStyle('display-md'),
45
+ 'line-height': '{font.lineHeight.none}',
46
+ color: '{color.fg-action}',
47
+ 'caret-color': '{color.fg-action}',
48
+ 'background-color': '{color.bg-surface}',
49
+ border: '{border.width.thin} solid {color.border-control}',
50
+ 'border-radius': '{radius.lg}',
51
+ 'box-shadow': '{shadow.xs}',
52
+ outline: 'none',
53
+ ...TRANSITION_COLORS,
54
+ },
55
+ separator: {
56
+ 'flex-shrink': '0',
57
+ 'padding-inline': '{space.1}',
58
+ ...typeStyle('heading-lg'),
59
+ 'line-height': '{font.lineHeight.none}',
60
+ color: '{color.fg-placeholder}',
61
+ 'user-select': 'none',
62
+ },
63
+ '@states': {
64
+ invalid: { cell: { 'border-color': '{color.border-danger}' } },
65
+ disabled: {
66
+ root: { cursor: 'not-allowed' },
67
+ cell: { 'background-color': '{color.bg-disabled}', color: '{color.fg-disabled}', 'border-color': '{color.border-disabled}', 'box-shadow': 'none', cursor: 'not-allowed' },
68
+ separator: { color: '{color.fg-disabled}' },
69
+ },
70
+ },
71
+ },
72
+ variants: {
73
+ size: Object.fromEntries(Object.entries(SIZES).map(([k, v]) => [k, {
74
+ cell: { width: v.box, height: 'auto', ...typeStyle(v.type), 'line-height': '{font.lineHeight.none}' },
75
+ separator: { ...typeStyle(v.separator), 'line-height': '{font.lineHeight.none}' },
76
+ }])),
77
+ length: {
78
+ '4': { root: { gap: '{space.3}' } },
79
+ '6': { root: { gap: '{space.2}' } },
80
+ },
81
+ },
82
+ extraCss: `
83
+ .cn-pin-input .cn-pin-input__cell::placeholder { color: {color.fg-placeholder}; opacity: 1; }
84
+ .cn-pin-input .cn-pin-input__cell::-webkit-outer-spin-button, .cn-pin-input .cn-pin-input__cell::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
85
+ .cn-pin-input .cn-pin-input__cell[data-filled] { border-color: {color.border-strong}; }
86
+ .cn-pin-input .cn-pin-input__cell:focus { border-color: {color.border-action}; box-shadow: {shadow.focus}; outline: none; }
87
+ .cn-pin-input[data-invalid] .cn-pin-input__cell:focus, .cn-pin-input:has([aria-invalid="true"]) .cn-pin-input__cell:focus { border-color: {color.border-danger}; box-shadow: {shadow.focus-danger}; }
88
+ .cn-pin-input[data-invalid] .cn-pin-input__cell[data-filled], .cn-pin-input:has([aria-invalid="true"]) .cn-pin-input__cell[data-filled] { border-color: {color.border-danger}; }
89
+ @media (max-width: {breakpoint.sm}) {
90
+ .cn-pin-input[data-length="6"] { gap: {space.1}; }
91
+ .cn-pin-input .cn-pin-input__separator { padding-inline: 0; }
92
+ .cn-pin-input[data-size] .cn-pin-input__cell { font-size: {font.size.2xl}; }
93
+ }`,
94
+ examples: [
95
+ ex('Six digits, half entered', pin('data-size="md" data-length="6"', ['4', '8', '2', '', '', ''], 3), 'Two groups of three with a separator. Filled cells carry data-filled; the empty ones show the "0" placeholder.'),
96
+ ex('Four digits', pin('data-size="md" data-length="4"', ['', '', '', '']), 'A single group with a wider gap and no separator.'),
97
+ ex('Complete', pin('data-size="md" data-length="6"', ['7', '3', '1', '9', '0', '5'], 3), 'All six cells filled: the form can submit automatically.'),
98
+ ex('Sizes', `<div style="display:flex;flex-direction:column;gap:var(--cn-space-4);align-items:flex-start">${pin('data-size="sm" data-length="4"', ['2', '6', '', ''])}${pin('data-size="md" data-length="4"', ['2', '6', '', ''])}${pin('data-size="lg" data-length="4"', ['2', '6', '', ''])}</div>`, '48 / 64 / 80px cells with heading-xl / display-md / display-lg digits.'),
99
+ ex('Invalid', `<div style="display:flex;flex-direction:column;gap:var(--cn-space-2);align-items:flex-start">${pin('data-size="md" data-length="6" data-invalid', ['4', '8', '2', '1', '1', '9'], 3, ' aria-invalid="true" aria-describedby="pin-error"')}<p class="cn-text-body-sm" id="pin-error" style="color:var(--cn-color-fg-danger);margin:0">That code has expired. We sent a new one to maya.chen@lumen.co.</p></div>`, 'Every cell takes the danger border; the message under the row says what to do next.'),
100
+ ex('Disabled (verifying)', pin('data-size="md" data-length="6"', ['7', '3', '1', '9', '0', '5'], 3, ' disabled'), 'Locked while the code is checked; the digits stay visible.'),
101
+ ],
102
+ rules: [
103
+ 'One character per cell, numeric keyboard on mobile (inputmode="numeric", pattern="[0-9]*"), and autocomplete="one-time-code" on the first cell so the OS can offer the SMS code.',
104
+ 'Typing a digit moves focus to the next cell; Backspace on an empty cell moves back and clears the previous one; pasting a full code fills every cell at once.',
105
+ 'Submit automatically when the last cell is filled, and show the disabled state while verifying; never make the user press a button after typing six digits.',
106
+ 'Six digits are displayed as 3 + 3 with a separator; four digits as one group. Do not use other lengths or mix sizes in one form.',
107
+ 'Mark filled cells with data-filled so the border tells the progress without reading the digits.',
108
+ 'On error, keep the digits, set the invalid state and put a plain sentence under the row ("That code has expired. We sent a new one."). Clear the error on the next keystroke.',
109
+ 'Sizes: md on auth screens; sm inside dialogs and settings panels; lg only on a dedicated verification page.',
110
+ 'The digit is painted in the action color; the placeholder "0" in the placeholder color. Never dim the typed digit.',
111
+ ],
112
+ a11y: [
113
+ 'The root is role="group" with aria-label ("Verification code"); each cell has aria-label "Digit n of 6" so the position is announced.',
114
+ 'Cells are native inputs: focus, disabled and invalid come from the platform. Do not rebuild them with contenteditable divs.',
115
+ 'When invalid, set aria-invalid="true" on the cells and aria-describedby on the group (or the cells) pointing at the error message id.',
116
+ 'The separator is aria-hidden; it has no meaning beyond grouping.',
117
+ 'Focus ring appears on the focused cell only; moving focus programmatically must keep the ring visible.',
118
+ ],
119
+ related: ['input', 'field', 'input-group', 'number-input'],
120
+ };
@@ -0,0 +1,126 @@
1
+ import { FOCUS_RING, RESET_BUTTON, TRANSITION_COLORS, STATE, ex, ICON, typeStyle } from "./_shared.mjs";
2
+ const close = `<button type="button" class="cn-popover__close" aria-label="Close">${ICON.x}</button>`;
3
+ export const popover = {
4
+ name: 'Popover',
5
+ slug: 'popover',
6
+ category: 'overlays',
7
+ description: 'Small anchored panel with real content: a title, a short body, and at most two actions. Raised white surface, hairline, shadow-md and an optional arrow toward its trigger. The component styles the panel; the app positions it.',
8
+ usage: 'Use for lightweight tasks next to their trigger: a quick filter, a date picker, a confirmation with one field, a preview card. Not for one-line hints (use Tooltip), not for flows that need the whole screen (use Dialog) and not for lists of commands (use Menu).',
9
+ anatomy: [
10
+ { part: 'root', element: 'div', description: 'The panel. role="dialog" (or "region" when it is informational), data-state="open|closed", data-side for the arrow.' },
11
+ { part: 'header', element: 'header', description: 'Title + description on the left, close button on the right. Optional when the body speaks for itself.', optional: true },
12
+ { part: 'title', element: 'h3', description: 'heading-xs. Names the task ("Filter by country").', optional: true },
13
+ { part: 'description', element: 'p', description: 'body-sm muted, one line under the title.', optional: true },
14
+ { part: 'body', element: 'div', description: 'body-sm content: a short form, a list, a preview.' },
15
+ { part: 'footer', element: 'footer', description: 'Right-aligned row: ghost cancel then one primary Button, size sm.', optional: true },
16
+ { part: 'close', element: 'button', description: '24px ghost icon button with an X at the top-right. aria-label="Close".', optional: true },
17
+ { part: 'arrow', element: 'span', description: '8px rotated square with the same surface and hairline, on the edge facing the trigger.', optional: true },
18
+ ],
19
+ props: {
20
+ size: {
21
+ values: ['sm', 'md', 'lg'],
22
+ default: 'md',
23
+ description: 'Panel width. sm = 240px for a single control or a short list; md = 320px for a small form (default); lg = 400px for a preview or a two-column form.',
24
+ },
25
+ side: {
26
+ values: ['top', 'right', 'bottom', 'left'],
27
+ default: 'bottom',
28
+ description: 'Which side of the trigger the panel opens on; moves the arrow and its border. bottom is the default for buttons in a toolbar.',
29
+ },
30
+ },
31
+ states: {
32
+ open: STATE.open(),
33
+ closed: { selector: '[data-state="closed"]', description: 'Hidden (display none).', markup: 'data-state="closed"' },
34
+ closeHover: { selector: ' .cn-popover__close:hover', description: 'Pointer over the close button: subtle fill. Styled in extraCss because it lives on a child.', markup: 'native :hover on .cn-popover__close' },
35
+ closeFocus: { selector: ' .cn-popover__close:focus-visible', description: 'Keyboard focus on the close button shows the focus ring. Styled in extraCss.', markup: 'native :focus-visible on .cn-popover__close' },
36
+ },
37
+ base: {
38
+ root: {
39
+ position: 'relative',
40
+ display: 'flex',
41
+ 'flex-direction': 'column',
42
+ width: '320px',
43
+ 'max-width': '100%',
44
+ padding: '{space.4}',
45
+ 'background-color': '{color.bg-surface-raised}',
46
+ border: '{border.width.thin} solid {color.border-default}',
47
+ 'border-radius': '{radius.lg}',
48
+ 'box-shadow': '{shadow.md}',
49
+ color: '{color.fg-default}',
50
+ ...typeStyle('body-sm'),
51
+ 'z-index': '{z.popover}',
52
+ },
53
+ header: { display: 'flex', 'align-items': 'flex-start', 'justify-content': 'space-between', gap: '{space.3}', 'margin-bottom': '{space.2}' },
54
+ title: { ...typeStyle('heading-xs'), color: '{color.fg-default}' },
55
+ description: { ...typeStyle('body-sm'), color: '{color.fg-muted}', 'margin-top': '{space.0.5}' },
56
+ body: { ...typeStyle('body-sm'), 'min-width': '0' },
57
+ footer: { display: 'flex', 'align-items': 'center', 'justify-content': 'flex-end', gap: '{space.2}', 'margin-top': '{space.3}' },
58
+ close: {
59
+ ...RESET_BUTTON,
60
+ display: 'inline-flex',
61
+ 'align-items': 'center',
62
+ 'justify-content': 'center',
63
+ 'flex-shrink': '0',
64
+ width: '{size.icon.xl}',
65
+ height: '{size.icon.xl}',
66
+ 'margin-block': 'calc({space.1} * -1)',
67
+ 'margin-inline-end': 'calc({space.1} * -1)',
68
+ 'border-radius': '{radius.md}',
69
+ color: '{color.fg-subtle}',
70
+ ...TRANSITION_COLORS,
71
+ },
72
+ arrow: {
73
+ position: 'absolute',
74
+ width: '8px',
75
+ height: '8px',
76
+ 'background-color': '{color.bg-surface-raised}',
77
+ border: '{border.width.thin} solid {color.border-default}',
78
+ transform: 'rotate(45deg)',
79
+ },
80
+ '@states': {
81
+ open: { root: { animation: 'cn-popover-enter {motion.duration.normal} {motion.easing.out} both' } },
82
+ closed: { root: { display: 'none' } },
83
+ },
84
+ },
85
+ variants: {
86
+ size: {
87
+ sm: { root: { width: '240px' } },
88
+ md: { root: { width: '320px' } },
89
+ lg: { root: { width: '400px' } },
90
+ },
91
+ side: {
92
+ top: { arrow: { bottom: '-5px', left: 'calc(50% - 4px)', 'border-top-color': 'transparent', 'border-left-color': 'transparent' } },
93
+ bottom: { arrow: { top: '-5px', left: 'calc(50% - 4px)', 'border-bottom-color': 'transparent', 'border-right-color': 'transparent' } },
94
+ left: { arrow: { right: '-5px', top: 'calc(50% - 4px)', 'border-bottom-color': 'transparent', 'border-left-color': 'transparent' } },
95
+ right: { arrow: { left: '-5px', top: 'calc(50% - 4px)', 'border-top-color': 'transparent', 'border-right-color': 'transparent' } },
96
+ },
97
+ },
98
+ extraCss: `
99
+ @keyframes cn-popover-enter { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
100
+ .cn-popover .cn-popover__close .cn-icon { width: {size.icon.sm}; height: {size.icon.sm}; }
101
+ .cn-popover .cn-popover__close:hover { background-color: {color.bg-subtle}; color: {color.fg-default}; }
102
+ .cn-popover .cn-popover__close:focus-visible { outline: none; box-shadow: ${FOCUS_RING['box-shadow']}; }`,
103
+ examples: [
104
+ ex('Default (md, bottom)', `<div class="cn-popover" data-size="md" data-side="bottom" data-state="open" role="dialog" aria-labelledby="pop-filter-title"><span class="cn-popover__arrow"></span><header class="cn-popover__header"><div><h3 class="cn-popover__title" id="pop-filter-title">Filter by country</h3><p class="cn-popover__description">Prospects from these markets only.</p></div>${close}</header><div class="cn-popover__body"><div class="cn-input" data-variant="default" data-size="sm">${ICON.search.replace('cn-icon', 'cn-input__icon')}<input class="cn-input__field" type="search" placeholder="Search countries…" aria-label="Search countries"></div><p style="margin-top:var(--cn-space-2);color:var(--cn-color-fg-muted)">Japan, South Korea, Germany selected.</p></div><footer class="cn-popover__footer"><button type="button" class="cn-button" data-variant="ghost" data-size="sm"><span class="cn-button__label">Clear</span></button><button type="button" class="cn-button" data-variant="primary" data-size="sm"><span class="cn-button__label">Apply</span></button></footer></div>`),
105
+ ex('Small, top, no header', `<div class="cn-popover" data-size="sm" data-side="top" data-state="open" role="region" aria-label="Fit score"><span class="cn-popover__arrow"></span><div class="cn-popover__body"><strong>Fit score 82</strong><br>Trade volume ↑ · Product match ↑ · Replied twice in 2025.</div></div>`),
106
+ ex('Large preview, right', `<div class="cn-popover" data-size="lg" data-side="right" data-state="open" role="dialog" aria-labelledby="pop-company-title"><span class="cn-popover__arrow"></span><header class="cn-popover__header"><div><h3 class="cn-popover__title" id="pop-company-title">Nakamura Trading Co.</h3><p class="cn-popover__description">Osaka, Japan · Importer of frozen seafood</p></div>${close}</header><div class="cn-popover__body">14 years active · 2 decision makers found · last outreach 3 days ago. <span class="cn-badge" data-tone="success" data-variant="soft" data-size="sm">Verified</span></div><footer class="cn-popover__footer"><button type="button" class="cn-button" data-variant="outline" data-size="sm"><span class="cn-button__label">Open profile</span></button></footer></div>`),
107
+ ex('Left, confirmation', `<div class="cn-popover" data-size="md" data-side="left" data-state="open" role="dialog" aria-labelledby="pop-remove-title"><span class="cn-popover__arrow"></span><header class="cn-popover__header"><div><h3 class="cn-popover__title" id="pop-remove-title">Remove from shortlist?</h3><p class="cn-popover__description">You can add it back from the search results.</p></div></header><footer class="cn-popover__footer"><button type="button" class="cn-button" data-variant="ghost" data-size="sm"><span class="cn-button__label">Keep</span></button><button type="button" class="cn-button" data-variant="danger" data-size="sm"><span class="cn-button__label">Remove</span></button></footer></div>`),
108
+ ],
109
+ rules: [
110
+ 'One task per popover. If it needs more than one field group or a scrolling body, it is a Dialog or a Drawer.',
111
+ 'Width comes from size only; never set width inline. Height is content, max 60vh with an internal scroll on the body.',
112
+ 'Footer: ghost cancel first, then one primary (or danger) Button, both size sm. No footer for informational popovers.',
113
+ 'Opens on click (never on hover), closes on Escape, outside click, or the close button. The trigger gets aria-expanded.',
114
+ 'Position with a floating library; flip side when there is no room and keep the arrow centered on the trigger.',
115
+ 'Only one popover open at a time; opening another closes the first.',
116
+ 'Use the arrow when the popover is detached from its trigger by more than 4px; omit it when it hugs a toolbar.',
117
+ 'Enter animation: 4px slide + fade, 180ms; leave is fade only.',
118
+ ],
119
+ a11y: [
120
+ 'role="dialog" with aria-labelledby on the title when the popover contains controls; role="region" with aria-label when it is read-only.',
121
+ 'Focus moves to the first control inside on open and returns to the trigger on close.',
122
+ 'The trigger carries aria-expanded and aria-controls pointing to the panel id.',
123
+ 'Escape closes; Tab cycles inside a dialog popover; the close button has aria-label="Close".',
124
+ ],
125
+ related: ['tooltip', 'dialog', 'drawer', 'button', 'input'],
126
+ };