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,115 @@
1
+ import { ex, typeStyle } from "./_shared.mjs";
2
+ // SVG ring in a 100-unit viewBox: r = 42, circumference 2π·42 = 263.89. The indicator is a dashed
3
+ // circle whose stroke-dashoffset = 263.89 × (1 − value) (half gauge: 131.95 × (1 − value)).
4
+ // The label and caption are HTML centered over the ring; the svg sits behind them.
5
+ const C = 263.89;
6
+ const HALF = 131.95;
7
+ const off = (pct, half = false) => ((half ? HALF : C) * (1 - pct / 100)).toFixed(1);
8
+ const SIZES = {
9
+ xs: { box: '{space.16}', stroke: '10', label: 'label-xs' },
10
+ sm: { box: '{space.24}', stroke: '8', label: 'label-sm' },
11
+ md: { box: '{space.40}', stroke: '6', label: 'heading-sm' },
12
+ lg: { box: 'calc({space.40} + {space.10})', stroke: '5', label: 'heading-lg' },
13
+ };
14
+ const TONE = { action: '{color.bg-action}', success: '{color.bg-success}', warning: '{color.bg-warning}', danger: '{color.bg-danger}' };
15
+ const ring = (o) => {
16
+ const half = o.variant === 'half';
17
+ return `<div class="cn-progress-circle" data-size="${o.size ?? 'md'}" data-variant="${o.variant ?? 'circle'}" data-tone="${o.tone ?? 'action'}" role="progressbar" aria-valuenow="${o.value}" aria-valuemin="0" aria-valuemax="100" aria-label="${o.label}"><svg viewBox="0 0 100 100" aria-hidden="true"><circle class="cn-progress-circle__track" cx="50" cy="50" r="42"/><circle class="cn-progress-circle__indicator" cx="50" cy="50" r="42" style="stroke-dashoffset:${off(o.value, half)}"/></svg><div class="cn-progress-circle__label">${o.text ?? `${o.value}%`}</div>${o.caption ? `<div class="cn-progress-circle__caption">${o.caption}</div>` : ''}</div>`;
18
+ };
19
+ export const progressCircle = {
20
+ name: 'ProgressCircle',
21
+ slug: 'progress-circle',
22
+ category: 'feedback',
23
+ description: 'A ring that shows a share of a whole: a hairline track, a round-capped indicator in the action (or status) color, and the value centered inside with an optional caption. The half variant is a gauge that opens downward.',
24
+ usage: 'Use for one headline percentage that deserves space: storage used, goal completion, a KPI on a dashboard card, an upload in a dialog. For inline or row-level progress use Progress (the bar); for a wait without a value use Spinner; for several metrics at once use ActivityGauge.',
25
+ anatomy: [
26
+ { part: 'root', element: 'div', description: 'Square box (inline-flex column, centered) that positions the svg behind the label. role="progressbar" with aria-valuenow/min/max and an aria-label. Carries data-size, data-variant, data-tone.' },
27
+ { part: 'track', element: 'circle', description: 'The full ring in the hairline color (stroke border-default). r = 42 in a 0 0 100 100 viewBox, fill none.' },
28
+ { part: 'indicator', element: 'circle', description: 'The value ring: same geometry, stroke in the tone color, round caps, dasharray 263.89. Its stroke-dashoffset is an inline style computed from the value.' },
29
+ { part: 'label', element: 'div', description: 'The centered value ("72%"), tabular numerals. Type scales with size: label-xs / label-sm / heading-sm / heading-lg.' },
30
+ { part: 'caption', element: 'div', description: 'body-xs muted line under the value ("Active users"). Hidden on xs.', optional: true },
31
+ ],
32
+ props: {
33
+ size: { values: ['xs', 'sm', 'md', 'lg'], default: 'md', description: 'Outer box 64 / 96 / 160 / 200px. xs = inside table rows and list items (value only); sm = stat tiles; md = a dashboard card; lg = a page hero or an empty-state style focus.' },
34
+ variant: { values: ['circle', 'half'], default: 'circle', description: 'circle = full ring, value in the middle; half = the top half of the ring as a gauge, value sitting on the chord. Use half when the number reads as a level (capacity, health) rather than completion.' },
35
+ tone: { values: ['action', 'success', 'warning', 'danger'], default: 'action', description: 'Indicator color. action = default; success = completed or healthy; warning = near a limit (≥ 80%); danger = over the limit or failed.' },
36
+ },
37
+ states: {},
38
+ base: {
39
+ root: {
40
+ position: 'relative',
41
+ display: 'inline-flex',
42
+ 'flex-direction': 'column',
43
+ 'align-items': 'center',
44
+ 'justify-content': 'center',
45
+ 'flex-shrink': '0',
46
+ width: '{space.40}',
47
+ height: '{space.40}',
48
+ color: '{color.fg-default}',
49
+ 'vertical-align': 'middle',
50
+ },
51
+ track: { fill: 'none', stroke: '{color.border-default}', 'stroke-width': '6', transform: 'rotate(-90deg)', 'transform-origin': '50% 50%' },
52
+ indicator: {
53
+ fill: 'none',
54
+ stroke: '{color.bg-action}',
55
+ 'stroke-width': '6',
56
+ 'stroke-linecap': 'round',
57
+ 'stroke-dasharray': '263.89',
58
+ 'stroke-dashoffset': '263.89',
59
+ transform: 'rotate(-90deg)',
60
+ 'transform-origin': '50% 50%',
61
+ 'transition-property': 'stroke-dashoffset, stroke',
62
+ 'transition-duration': '{motion.duration.slow}',
63
+ 'transition-timing-function': '{motion.easing.standard}',
64
+ },
65
+ label: { position: 'relative', 'z-index': '{z.raised}', ...typeStyle('heading-sm'), 'font-variant-numeric': 'tabular-nums', color: '{color.fg-default}', 'text-align': 'center' },
66
+ caption: { position: 'relative', 'z-index': '{z.raised}', 'margin-top': '{space.0.5}', ...typeStyle('body-xs'), color: '{color.fg-muted}', 'text-align': 'center', 'max-width': '70%' },
67
+ },
68
+ variants: {
69
+ size: Object.fromEntries(Object.entries(SIZES).map(([k, v]) => [k, {
70
+ root: { width: v.box, height: v.box },
71
+ track: { 'stroke-width': v.stroke },
72
+ indicator: { 'stroke-width': v.stroke },
73
+ label: typeStyle(v.label),
74
+ ...(k === 'xs' ? { caption: { display: 'none' } } : {}),
75
+ }])),
76
+ variant: {
77
+ circle: { root: {} },
78
+ half: {
79
+ root: { 'justify-content': 'flex-end', 'padding-bottom': '{space.1}' },
80
+ track: { 'stroke-dasharray': '131.95 263.89', transform: 'rotate(180deg)' },
81
+ indicator: { 'stroke-dasharray': '131.95 263.89', 'stroke-dashoffset': '131.95', transform: 'rotate(180deg)' },
82
+ },
83
+ },
84
+ tone: Object.fromEntries(Object.entries(TONE).map(([t, color]) => [t, { indicator: { stroke: color } }])),
85
+ },
86
+ compound: Object.entries(SIZES).map(([k, v]) => ({ when: { variant: 'half', size: k }, block: { root: { height: `calc(${v.box} / 2)` } } })),
87
+ extraCss: `
88
+ .cn-progress-circle > svg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; overflow: visible; }
89
+ .cn-progress-circle[data-variant="half"] > svg { height: 200%; }
90
+ .cn-progress-circle[data-variant="half"] { overflow: hidden; }`,
91
+ examples: [
92
+ ex('Default (md, action)', ring({ value: 72, label: 'Active users: 72%', caption: 'Active users' }), 'stroke-dashoffset = 263.89 × (1 − 0.72) = 73.9.'),
93
+ ex('Sizes', `<div style="display:flex;flex-wrap:wrap;align-items:flex-end;gap:var(--cn-space-6)">${ring({ size: 'xs', value: 40, label: 'Storage used: 40%' })}${ring({ size: 'sm', value: 65, label: 'Onboarding: 65%', caption: 'Onboarding' })}${ring({ size: 'md', value: 72, label: 'Active users: 72%', caption: 'Active users' })}${ring({ size: 'lg', value: 88, label: 'Quarterly goal: 88%', caption: 'Quarterly goal' })}</div>`, '64 / 96 / 160 / 200px with label-xs / label-sm / heading-sm / heading-lg values.'),
94
+ ex('Tones', `<div style="display:flex;flex-wrap:wrap;gap:var(--cn-space-6)">${ring({ size: 'sm', tone: 'success', value: 100, label: 'Import complete', text: 'Done', caption: 'Import' })}${ring({ size: 'sm', tone: 'warning', value: 82, label: 'Seats used: 82%', caption: 'Seats used' })}${ring({ size: 'sm', tone: 'danger', value: 96, label: 'API quota: 96%', caption: 'API quota' })}</div>`, 'success at completion, warning from 80%, danger over the limit.'),
95
+ ex('Half gauge', `<div style="display:flex;flex-wrap:wrap;align-items:flex-end;gap:var(--cn-space-6)">${ring({ variant: 'half', size: 'md', value: 65, label: 'Deal health: 65%', caption: 'Deal health' })}${ring({ variant: 'half', size: 'sm', tone: 'warning', value: 84, label: 'Warehouse capacity: 84%', caption: 'Capacity' })}</div>`, 'Half the circumference: stroke-dashoffset = 131.95 × (1 − 0.65) = 46.2.'),
96
+ ex('Custom label', ring({ size: 'md', value: 56, label: '118 of 212 companies verified', text: '118', caption: 'of 212 verified' }), 'The label can be a count instead of a percentage; aria-valuenow still carries the percentage.'),
97
+ ],
98
+ rules: [
99
+ 'Geometry is fixed: viewBox 0 0 100 100, r = 42, circumference 263.89. Set the value only through the inline style on the indicator: stroke-dashoffset = 263.89 × (1 − value / 100); for the half variant 131.95 × (1 − value / 100).',
100
+ 'The label is HTML, never SVG text, so it uses the type scale and stays selectable; keep it to a number or a short word ("Done").',
101
+ 'Tone follows the value, not the brand: switch to warning at ≥ 80% of a limit and to danger when the limit is exceeded or the task failed; success only at 100% or "healthy".',
102
+ 'One ProgressCircle per card. Several percentages side by side are a table or an ActivityGauge.',
103
+ 'xs has no caption: put the meaning in the row label next to it.',
104
+ 'Never animate on first paint; transition only between value updates (260ms).',
105
+ 'Use half for levels (capacity, health, score) and circle for completion (progress, share of a goal).',
106
+ 'Stroke width scales with the size (10 / 8 / 6 / 5 viewBox units) so the ring stays around 6–10px at every size; do not override it.',
107
+ ],
108
+ a11y: [
109
+ 'role="progressbar" with aria-valuenow, aria-valuemin="0", aria-valuemax="100" and an aria-label naming what is measured ("Active users: 72%").',
110
+ 'Use aria-valuetext when the visible label is not a percentage ("118 of 212 verified").',
111
+ 'The svg is aria-hidden; the label text is the visible equivalent of the ring, so color is never the only signal.',
112
+ 'Respect prefers-reduced-motion: the dashoffset transition is the only motion and can be disabled globally.',
113
+ ],
114
+ related: ['progress', 'activity-gauge', 'stat', 'spinner'],
115
+ };
@@ -0,0 +1,93 @@
1
+ import { ex, typeStyle } from "./_shared.mjs";
2
+ const TONE_FILL = {
3
+ action: '{color.bg-action}',
4
+ accent: '{color.bg-accent}',
5
+ success: '{color.bg-success}',
6
+ warning: '{color.bg-warning}',
7
+ danger: '{color.bg-danger}',
8
+ };
9
+ export const progress = {
10
+ name: 'Progress',
11
+ slug: 'progress',
12
+ category: 'feedback',
13
+ description: 'Horizontal bar that shows how much of a known task is done: a muted track, a filled indicator whose width is the value, and an optional label/value line above. Indeterminate mode slides a short segment when the total is unknown.',
14
+ usage: 'Use for tasks with a measurable share: an import, seats used of a plan, steps of an onboarding. Use the indeterminate state only briefly, while the total is being computed. For a wait without a value use Spinner; for loading content use Skeleton.',
15
+ anatomy: [
16
+ { part: 'root', element: 'div', description: 'Wrapper: header + track stacked with a 6px gap. role="progressbar" with aria-valuenow/min/max, or aria-busy in indeterminate mode.' },
17
+ { part: 'header', element: 'div', description: 'Optional line above the track: label on the left, value on the right, body-xs muted.', optional: true },
18
+ { part: 'label', element: 'span', description: 'What is being measured ("Importing prospects", "Seats used").', optional: true },
19
+ { part: 'value', element: 'span', description: 'The number, tabular ("42%", "12 / 40").', optional: true },
20
+ { part: 'track', element: 'div', description: 'The full-width muted bar, radius full, clips the indicator.' },
21
+ { part: 'indicator', element: 'div', description: 'The filled part. Width comes from an inline style (style="width:42%") and transitions smoothly.' },
22
+ ],
23
+ props: {
24
+ size: {
25
+ values: ['sm', 'md', 'lg'],
26
+ default: 'md',
27
+ description: 'Track height. sm = 2px for inside table rows and cards; md = 4px default; lg = 8px for a page-level task (import, onboarding).',
28
+ },
29
+ tone: {
30
+ values: ['action', 'accent', 'success', 'warning', 'danger'],
31
+ default: 'action',
32
+ description: 'Fill color. action = default; accent = an agent is working on it; success = completed; warning = near a limit (≥ 80%); danger = over the limit or failed.',
33
+ },
34
+ },
35
+ states: {
36
+ indeterminate: { selector: '[data-state="indeterminate"]', description: 'Total unknown: a 40%-wide segment slides across the track in a loop. No aria-valuenow; aria-busy="true".', markup: 'data-state="indeterminate" on the root (omit the inline width)' },
37
+ },
38
+ base: {
39
+ root: { display: 'flex', 'flex-direction': 'column', gap: '{space.1.5}', width: '100%', 'min-width': '0' },
40
+ header: { display: 'flex', 'align-items': 'baseline', 'justify-content': 'space-between', gap: '{space.3}', ...typeStyle('body-xs'), color: '{color.fg-muted}' },
41
+ label: { 'min-width': '0', overflow: 'hidden', 'text-overflow': 'ellipsis', 'white-space': 'nowrap' },
42
+ value: { 'flex-shrink': '0', 'font-variant-numeric': 'tabular-nums', color: '{color.fg-default}' },
43
+ track: { position: 'relative', width: '100%', height: '4px', 'background-color': '{color.bg-muted}', 'border-radius': '{radius.full}', overflow: 'hidden' },
44
+ indicator: {
45
+ height: '100%',
46
+ width: '0',
47
+ 'background-color': '{color.bg-action}',
48
+ 'border-radius': '{radius.full}',
49
+ 'transition-property': 'width',
50
+ 'transition-duration': '{motion.duration.slow}',
51
+ 'transition-timing-function': '{motion.easing.standard}',
52
+ },
53
+ '@states': {
54
+ indeterminate: {
55
+ indicator: { width: '40%', animation: 'cn-progress-slide 1.2s {motion.easing.standard} infinite' },
56
+ },
57
+ },
58
+ },
59
+ variants: {
60
+ size: {
61
+ sm: { track: { height: '2px' } },
62
+ md: { track: { height: '4px' } },
63
+ lg: { track: { height: '8px' } },
64
+ },
65
+ tone: Object.fromEntries(Object.entries(TONE_FILL).map(([t, color]) => [t, { indicator: { 'background-color': color } }])),
66
+ },
67
+ extraCss: `
68
+ @keyframes cn-progress-slide { from { transform: translateX(-100%); } to { transform: translateX(250%); } }`,
69
+ examples: [
70
+ ex('Default with label and value', `<div class="cn-progress" data-size="md" data-tone="action" role="progressbar" aria-labelledby="prg-import" aria-valuenow="42" aria-valuemin="0" aria-valuemax="100" style="max-width:360px"><div class="cn-progress__header"><span class="cn-progress__label" id="prg-import">Importing prospects</span><span class="cn-progress__value">42%</span></div><div class="cn-progress__track"><div class="cn-progress__indicator" style="width:42%"></div></div></div>`),
71
+ ex('Large, accent (agent working)', `<div class="cn-progress" data-size="lg" data-tone="accent" role="progressbar" aria-labelledby="prg-verify" aria-valuenow="118" aria-valuemin="0" aria-valuemax="212" style="max-width:360px"><div class="cn-progress__header"><span class="cn-progress__label" id="prg-verify">Verifying companies</span><span class="cn-progress__value">118 / 212</span></div><div class="cn-progress__track"><div class="cn-progress__indicator" style="width:56%"></div></div></div>`),
72
+ ex('Warning near a limit', `<div class="cn-progress" data-size="md" data-tone="warning" role="progressbar" aria-labelledby="prg-seats" aria-valuenow="34" aria-valuemin="0" aria-valuemax="40" style="max-width:360px"><div class="cn-progress__header"><span class="cn-progress__label" id="prg-seats">Seats used</span><span class="cn-progress__value">34 / 40</span></div><div class="cn-progress__track"><div class="cn-progress__indicator" style="width:85%"></div></div></div>`),
73
+ ex('Success, complete', `<div class="cn-progress" data-size="md" data-tone="success" role="progressbar" aria-labelledby="prg-done" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100" style="max-width:360px"><div class="cn-progress__header"><span class="cn-progress__label" id="prg-done">Export</span><span class="cn-progress__value">Done</span></div><div class="cn-progress__track"><div class="cn-progress__indicator" style="width:100%"></div></div></div>`),
74
+ ex('Small, no header (inside a row)', `<div class="cn-progress" data-size="sm" data-tone="danger" role="progressbar" aria-label="Bounce rate" aria-valuenow="12" aria-valuemin="0" aria-valuemax="100" style="max-width:160px"><div class="cn-progress__track"><div class="cn-progress__indicator" style="width:12%"></div></div></div>`),
75
+ ex('Indeterminate', `<div class="cn-progress" data-size="md" data-tone="action" data-state="indeterminate" role="progressbar" aria-labelledby="prg-prep" aria-busy="true" style="max-width:360px"><div class="cn-progress__header"><span class="cn-progress__label" id="prg-prep">Preparing the search…</span></div><div class="cn-progress__track"><div class="cn-progress__indicator"></div></div></div>`),
76
+ ],
77
+ rules: [
78
+ 'Width comes from the layout; the indicator width is the only inline style allowed (style="width:42%").',
79
+ 'Always show the value when the user can act on it (seats, quota). Show "Done" instead of 100% when the task is finished.',
80
+ 'Tone follows the value, not the brand: switch to warning at ≥ 80% of a limit and to danger when the limit is exceeded or the task failed.',
81
+ 'accent means an agent is doing the work. Do not use it for user-driven uploads.',
82
+ 'Indeterminate for at most a few seconds; if the total stays unknown, replace it with a Spinner and a sentence.',
83
+ 'Sizes: sm inside rows and cards, md by default, lg for one page-level task at a time.',
84
+ 'Never animate the indicator on first paint; only transitions between updates (260ms).',
85
+ ],
86
+ a11y: [
87
+ 'role="progressbar" with aria-valuenow, aria-valuemin and aria-valuemax; use aria-valuetext when the number alone is unclear ("34 of 40 seats").',
88
+ 'Name it with aria-labelledby pointing to the label, or aria-label when there is no visible label.',
89
+ 'Indeterminate: omit aria-valuenow and set aria-busy="true" on the region being loaded.',
90
+ 'Color is never the only signal: the value text or the label states warning/danger conditions.',
91
+ ],
92
+ related: ['spinner', 'skeleton', 'agent-presence', 'badge'],
93
+ };
@@ -0,0 +1,96 @@
1
+ import { ex, typeStyle } from "./_shared.mjs";
2
+ /** Composite type style as a CSS declaration string for extraCss. */
3
+ const ts = (name) => Object.entries(typeStyle(name)).map(([p, v]) => `${p}: ${v};`).join(' ');
4
+ export const prose = {
5
+ name: 'Prose',
6
+ slug: 'prose',
7
+ category: 'typography',
8
+ description: 'Container for long-form text (help articles, agent reports, release notes, legal): sets the reading measure and styles every HTML element inside it — headings, paragraphs, lists, quotes, code, tables — from the type scale, so markdown output looks right without classes.',
9
+ usage: 'Wrap any block of rendered markdown or editorial HTML in it. Not for UI text (labels, descriptions, table cells), which uses the type utilities directly, and not for forms.',
10
+ anatomy: [
11
+ { part: 'root', element: 'article', description: 'The container. Max width 65ch, default text color, base font from size. Every element inside is styled by tag; the only class you may add inside is .cn-kicker above a heading.' },
12
+ ],
13
+ props: {
14
+ size: {
15
+ values: ['sm', 'md', 'lg'],
16
+ default: 'md',
17
+ description: 'Base text size. sm = body-sm with headings one step smaller (side panels, drawers, tooltips of an article); md = body-md, the app default (help center, reports); lg = body-lg for marketing and long reads.',
18
+ },
19
+ },
20
+ states: {},
21
+ base: {
22
+ root: {
23
+ 'max-width': '{size.container.prose}',
24
+ color: '{color.fg-default}',
25
+ ...typeStyle('body-md'),
26
+ 'overflow-wrap': 'break-word',
27
+ },
28
+ },
29
+ variants: {
30
+ size: {
31
+ sm: { root: { ...typeStyle('body-sm') } },
32
+ md: { root: {} },
33
+ lg: { root: { ...typeStyle('body-lg') } },
34
+ },
35
+ },
36
+ extraCss: `
37
+ .cn-prose > :first-child { margin-top: 0; }
38
+ .cn-prose > :last-child { margin-bottom: 0; }
39
+ .cn-prose h1 { ${ts('heading-xl')} color: {color.fg-default}; margin-top: {space.10}; margin-bottom: {space.4}; }
40
+ .cn-prose h2 { ${ts('heading-lg')} color: {color.fg-default}; margin-top: {space.8}; margin-bottom: {space.3}; }
41
+ .cn-prose h3 { ${ts('heading-md')} color: {color.fg-default}; margin-top: {space.6}; margin-bottom: {space.2}; }
42
+ .cn-prose h4 { ${ts('heading-sm')} color: {color.fg-default}; margin-top: {space.5}; margin-bottom: {space.2}; }
43
+ .cn-prose p { margin-bottom: {space.4}; }
44
+ .cn-prose ul, .cn-prose ol { padding-left: {space.5}; margin-bottom: {space.4}; }
45
+ .cn-prose ul { list-style: disc; }
46
+ .cn-prose ol { list-style: decimal; }
47
+ .cn-prose li { margin-bottom: {space.1}; }
48
+ .cn-prose li > ul, .cn-prose li > ol { margin-top: {space.1}; margin-bottom: 0; }
49
+ .cn-prose li::marker { color: {color.fg-subtle}; }
50
+ .cn-prose blockquote { border-left: {border.width.medium} solid {color.border-strong}; padding-left: {space.4}; margin-bottom: {space.4}; color: {color.fg-muted}; }
51
+ .cn-prose blockquote p:last-child { margin-bottom: 0; }
52
+ .cn-prose a { color: {color.fg-link}; text-decoration: underline; text-underline-offset: 3px; text-decoration-color: {color.border-strong}; }
53
+ .cn-prose a:hover { color: {color.fg-link-hover}; text-decoration-color: currentColor; }
54
+ .cn-prose strong { font-weight: {font.weight.semibold}; }
55
+ .cn-prose code { ${ts('code-sm')} background-color: {color.bg-subtle}; border-radius: {radius.sm}; padding: 1px 5px; }
56
+ .cn-prose pre { ${ts('code-md')} background-color: {color.bg-subtle}; border-radius: {radius.lg}; padding: {space.4}; margin-bottom: {space.4}; overflow: auto; }
57
+ .cn-prose pre code { background: none; border-radius: 0; padding: 0; font: inherit; }
58
+ .cn-prose hr { border: 0; border-top: {border.width.thin} solid {color.border-default}; margin: {space.8} 0; }
59
+ .cn-prose img, .cn-prose video { border-radius: {radius.lg}; margin-bottom: {space.4}; }
60
+ .cn-prose figure { margin-bottom: {space.4}; }
61
+ .cn-prose figcaption { ${ts('body-xs')} color: {color.fg-muted}; margin-top: {space.2}; }
62
+ .cn-prose table { ${ts('body-sm')} width: 100%; border-collapse: collapse; margin-bottom: {space.4}; }
63
+ .cn-prose th { ${ts('kicker')} text-transform: uppercase; color: {color.fg-subtle}; text-align: left; padding: {space.2} {space.3}; border-bottom: {border.width.thin} solid {color.border-default}; }
64
+ .cn-prose td { padding: {space.2} {space.3}; border-bottom: {border.width.thin} solid {color.border-subtle}; vertical-align: top; }
65
+ .cn-prose .cn-kicker { margin-bottom: {space.2}; }
66
+ .cn-prose[data-size="sm"] h1 { ${ts('heading-lg')} margin-top: {space.8}; margin-bottom: {space.3}; }
67
+ .cn-prose[data-size="sm"] h2 { ${ts('heading-md')} margin-top: {space.6}; margin-bottom: {space.2}; }
68
+ .cn-prose[data-size="sm"] h3 { ${ts('heading-sm')} margin-top: {space.5}; margin-bottom: {space.2}; }
69
+ .cn-prose[data-size="sm"] h4 { ${ts('heading-xs')} margin-top: {space.4}; margin-bottom: {space.1.5}; }
70
+ .cn-prose[data-size="sm"] p, .cn-prose[data-size="sm"] ul, .cn-prose[data-size="sm"] ol, .cn-prose[data-size="sm"] pre, .cn-prose[data-size="sm"] table, .cn-prose[data-size="sm"] blockquote { margin-bottom: {space.3}; }
71
+ .cn-prose[data-size="sm"] table { ${ts('body-xs')} }
72
+ .cn-prose[data-size="lg"] p, .cn-prose[data-size="lg"] ul, .cn-prose[data-size="lg"] ol { margin-bottom: {space.5}; }`,
73
+ examples: [
74
+ ex('Article (md)', `<article class="cn-prose" data-size="md"><h2>How agents verify a company</h2><p>Before a company reaches your shortlist, an agent checks it against three public sources: customs records, the national business registry and the company’s own site. Only companies with a match in at least two sources are marked <strong>verified</strong>.</p><ul><li>Customs records show real import volume and product codes (HS 0306 for frozen shrimp).</li><li>The registry confirms the legal entity, its age and its directors.</li><li>The website confirms the product line and the contact channel.</li></ul><blockquote><p>“Verified does not mean interested. It means the company exists, imports what you sell, and can be reached.”</p></blockquote><p>Each check leaves a trace. Open a prospect and look for the <code>verified_by</code> field; it lists the sources and the date of the last check.</p><table><thead><tr><th>Source</th><th>What it proves</th><th>Refreshed</th></tr></thead><tbody><tr><td>Customs records</td><td>Import volume, HS codes</td><td>Monthly</td></tr><tr><td>Business registry</td><td>Legal entity, directors</td><td>Quarterly</td></tr><tr><td>Company website</td><td>Product line, contacts</td><td>Weekly</td></tr></tbody></table><h3>When a check fails</h3><p>The prospect stays in the results as <em>unverified</em> and the agent retries after 7 days. You can force a re-check from the prospect drawer.</p><pre><code>GET /v1/prospects/nk-2041/verification
75
+ → { "verified": true, "sources": ["customs", "registry"], "checked_at": "2026-09-10" }</code></pre></article>`),
76
+ ex('Small (inside a drawer)', `<article class="cn-prose" data-size="sm" style="max-width:360px"><h3>Release notes · 11 Sep 2026</h3><p>Agents now read replies in Japanese and German and draft answers in the buyer’s language.</p><ol><li>Open a conversation with a reply in another language.</li><li>Check the draft; the original is shown below it.</li><li>Approve, edit, or ask the agent to rewrite.</li></ol><p>Questions? See <a href="#">Working with drafts</a>.</p></article>`),
77
+ ex('Large (marketing)', `<article class="cn-prose" data-size="lg"><h2>Find real buyers, not lists</h2><p>Vera reads trade data the way an analyst does: who imported what, from where, how often. Then it writes to the people who can say yes — and waits for your approval before anything is sent.</p><hr><p>Start with one product and one country. Most teams see the first verified reply within two weeks.</p></article>`),
78
+ ],
79
+ rules: [
80
+ 'Prose is for rendered documents; UI text never goes inside it. If an element needs a class, it does not belong in Prose (except .cn-kicker above a heading).',
81
+ 'One h1 per document; start articles inside the app at h2 because the page already has the h1.',
82
+ 'Keep the measure: never widen beyond 65ch. Wide layouts put Prose in one column and media or a table of contents in the other.',
83
+ 'Links are ink-colored and underlined, never blue. External links get the external icon after the text.',
84
+ 'Code inline for identifiers and values; pre for anything with more than one line. Never pre for a single command inside a sentence.',
85
+ 'Tables inside Prose are simple (≤ 4 columns); anything sortable or wider is a Table component outside the Prose.',
86
+ 'Images and video inside Prose get radius lg and a caption in the figcaption, not in the paragraph below.',
87
+ 'Size follows the container: sm in drawers and popovers, md in app pages, lg only on marketing and long-read pages.',
88
+ ],
89
+ a11y: [
90
+ 'Use semantic elements (h2–h4 in order, ul/ol, blockquote, table with th) so the structure is navigable by headings and landmarks.',
91
+ 'The root is an <article> (self-contained) or a <section> with aria-labelledby when it is part of a page.',
92
+ 'Link text says where it goes ("Working with drafts"), never "click here"; underline is kept so color is not the only cue.',
93
+ 'Code blocks scroll horizontally and are focusable (tabindex="0") when they overflow, so keyboard users can reach the content.',
94
+ ],
95
+ related: ['kicker', 'link', 'card', 'media-frame'],
96
+ };
@@ -0,0 +1,160 @@
1
+ import { ex, typeStyle } from "./_shared.mjs";
2
+ // the reference radio button: 16px circle (sm) or 20px (md) with a 1px inset gray-300 ring on white,
3
+ // brand-600 fill with a white 6 / 8px dot when selected, 8 / 12px to a 14 / 16px medium label with a
4
+ // muted supporting line; circle nudged 2px down. Focus = 4px brand ring; disabled = circle at 50%
5
+ // (gray-100 when unselected). Groups stack 16px apart.
6
+ const TRANSITION_FAST = {
7
+ 'transition-property': 'background-color, box-shadow, color, opacity',
8
+ 'transition-duration': '{motion.duration.fast}',
9
+ 'transition-timing-function': '{motion.easing.linear}',
10
+ };
11
+ const ring = (color) => `inset 0 0 0 1px ${color}`;
12
+ /** label wrapper → hidden input → circle → text (+ optional description) */
13
+ const rd = (root, input, label, description = '', variant = 'default') => `<label class="cn-radio" data-variant="${variant}" ${root}><input class="cn-radio__input" type="radio" ${input}><span class="cn-radio__control" aria-hidden="true"><span class="cn-radio__indicator"></span></span><span class="cn-radio__label">${label}</span>${description ? `<span class="cn-radio__description">${description}</span>` : ''}</label>`;
14
+ const stack = (...items) => `<div style="display:flex;flex-direction:column;gap:var(--cn-space-4)">${items.join('')}</div>`;
15
+ export const radio = {
16
+ name: 'Radio',
17
+ slug: 'radio',
18
+ category: 'forms',
19
+ description: 'One-of-many choice: a 16px (sm) or 20px (md) circle with a gray-300 ring that fills with the brand color and shows a white dot, next to a medium label and a muted supporting line. Same anatomy as Checkbox; always used in a named group of 2–5 options.',
20
+ usage: 'Pick exactly one option when all options should be visible and compared (billing cycle, priority, delivery method). For more than 5 options use Select; for an on/off setting use Switch; for a view toggle in a toolbar use SegmentedControl. The card variant turns each option into a selectable bordered card.',
21
+ anatomy: [
22
+ { part: 'root', element: 'label', description: 'The <label> wrapping everything: hit target and accessible name. inline-grid: circle in column 1, text in column 2, 8px (sm) or 12px (md) apart.' },
23
+ { part: 'input', element: 'input', description: 'Native <input type="radio" name="…">, visually hidden but focusable. Options of one question share the same name.' },
24
+ { part: 'control', element: 'span', description: 'The visible circle: white with a 1px gray-300 inset ring, radius full, nudged 2px down to align with the first text line. aria-hidden.' },
25
+ { part: 'indicator', element: 'span', description: 'The white inner dot (6px on sm, 8px on md). Hidden until checked.' },
26
+ { part: 'label', element: 'span', description: '14px (sm) or 16px (md) medium text in the default ink. A noun phrase in sentence case, no period.' },
27
+ { part: 'description', element: 'span', description: 'Optional supporting line under the label, same size, regular weight, muted: price, consequence or example.', optional: true },
28
+ ],
29
+ props: {
30
+ size: { values: ['sm', 'md'], default: 'sm', description: 'sm = 16px circle with a 14px label: forms, dialogs, tables (the reference default); md = 20px circle with a 16px label: touch screens, onboarding and prominent choices.' },
31
+ variant: { values: ['default', 'card'], default: 'default', description: 'default = bare circle and text; card = the whole option is a selectable card (white, 1px gray-200 ring, radius 12, 16px padding) whose ring turns into a 2px brand ring when selected. Use cards for plans and tiers where the description matters.' },
32
+ },
33
+ states: {
34
+ checked: { selector: ':has(:checked)', description: 'Circle fills with the brand color and shows the white dot. A card gets a 2px brand ring.', markup: 'checked attribute on the input' },
35
+ invalid: { selector: '[data-invalid], &:has([aria-invalid="true"])', description: 'Red-300 ring on every option of a required group with no selection. Pair with an error message under the group.', markup: 'aria-invalid="true" on the inputs, or data-invalid on each root' },
36
+ focus: { selector: ':has(:focus-visible)', description: 'Keyboard focus shows the 4px brand ring on the circle (on the whole card in the card variant).', markup: 'native :focus-visible on the input' },
37
+ disabled: { selector: '[data-disabled], &:has(:disabled)', description: 'Circle at 50% opacity (gray-100 fill when unselected), cursor not-allowed on the whole row; the text keeps its color. A checked disabled option keeps its dot.', markup: 'disabled on the input' },
38
+ },
39
+ base: {
40
+ root: {
41
+ position: 'relative',
42
+ display: 'inline-grid',
43
+ 'grid-template-columns': 'auto minmax(0, 1fr)',
44
+ 'column-gap': '{space.2}',
45
+ 'row-gap': '0',
46
+ 'align-items': 'start',
47
+ 'max-width': '100%',
48
+ color: '{color.fg-default}',
49
+ cursor: 'pointer',
50
+ '-webkit-tap-highlight-color': 'transparent',
51
+ },
52
+ input: { position: 'absolute', top: '{space.0.5}', left: '0', margin: '0', padding: '0', opacity: '0', cursor: 'pointer' },
53
+ control: {
54
+ position: 'relative',
55
+ display: 'inline-flex',
56
+ 'align-items': 'center',
57
+ 'justify-content': 'center',
58
+ 'flex-shrink': '0',
59
+ 'grid-column': '1',
60
+ 'grid-row': '1',
61
+ 'margin-top': '{space.0.5}',
62
+ 'background-color': '{color.bg-surface}',
63
+ 'box-shadow': ring('{color.border-control}'),
64
+ 'border-radius': '{radius.full}',
65
+ color: '{color.fg-on-action}',
66
+ ...TRANSITION_FAST,
67
+ },
68
+ indicator: { display: 'none', 'border-radius': '{radius.full}', 'background-color': 'currentColor', 'flex-shrink': '0', 'pointer-events': 'none' },
69
+ label: { 'grid-column': '2', ...typeStyle('label-sm'), color: '{color.fg-default}', 'user-select': 'none' },
70
+ description: { 'grid-column': '2', ...typeStyle('body-md'), color: '{color.fg-muted}' },
71
+ '@states': {
72
+ checked: { control: { 'background-color': '{color.bg-action}', 'box-shadow': ring('{color.bg-action}') }, indicator: { display: 'block' } },
73
+ invalid: { control: { 'box-shadow': ring('{color.border-danger}') } },
74
+ focus: { control: { 'box-shadow': `${ring('{color.border-action}')}, {shadow.focus}` } },
75
+ disabled: {
76
+ root: { cursor: 'not-allowed' },
77
+ input: { cursor: 'not-allowed' },
78
+ control: { 'background-color': '{color.bg-muted}', opacity: '{opacity.disabled}' },
79
+ },
80
+ },
81
+ },
82
+ variants: {
83
+ size: {
84
+ sm: {
85
+ input: { width: '{size.icon.sm}', height: '{size.icon.sm}' },
86
+ control: { width: '{size.icon.sm}', height: '{size.icon.sm}' },
87
+ indicator: { width: '{space.1.5}', height: '{space.1.5}' },
88
+ },
89
+ md: {
90
+ root: { 'column-gap': '{space.3}', 'row-gap': '{space.0.5}' },
91
+ input: { width: '{size.icon.md}', height: '{size.icon.md}' },
92
+ control: { width: '{size.icon.md}', height: '{size.icon.md}' },
93
+ indicator: { width: '{space.2}', height: '{space.2}' },
94
+ label: { ...typeStyle('label-lg'), 'font-weight': '{font.weight.medium}' },
95
+ description: { ...typeStyle('body-lg') },
96
+ },
97
+ },
98
+ variant: {
99
+ default: { root: {} },
100
+ card: {
101
+ root: {
102
+ display: 'grid',
103
+ width: '100%',
104
+ padding: '{space.4}',
105
+ 'background-color': '{color.bg-surface}',
106
+ 'border-radius': '{radius.card}',
107
+ 'box-shadow': ring('{color.border-default}'),
108
+ ...TRANSITION_FAST,
109
+ },
110
+ input: { top: '{space.4}', left: '{space.4}' },
111
+ '@states': {
112
+ checked: { root: { 'box-shadow': 'inset 0 0 0 2px {color.bg-action}' } },
113
+ focus: { root: { 'box-shadow': `${ring('{color.border-default}')}, {shadow.focus}` }, control: { 'box-shadow': ring('{color.border-control}') } },
114
+ invalid: { root: { 'box-shadow': ring('{color.border-danger}') } },
115
+ disabled: { root: { 'background-color': '{color.bg-subtle}' } },
116
+ },
117
+ },
118
+ },
119
+ },
120
+ extraCss: `
121
+ .cn-radio:has(:checked):has(:focus-visible) .cn-radio__control { box-shadow: inset 0 0 0 1px {color.bg-action}, {shadow.focus}; }
122
+ .cn-radio[data-invalid]:has(:focus-visible) .cn-radio__control, .cn-radio:has([aria-invalid="true"]):has(:focus-visible) .cn-radio__control { box-shadow: inset 0 0 0 1px {color.border-danger}, {shadow.focus-danger}; }
123
+ .cn-radio[data-variant="card"]:has(:checked):has(:focus-visible) { box-shadow: inset 0 0 0 2px {color.bg-action}, {shadow.focus}; }
124
+ .cn-radio[data-variant="card"]:has(:checked):has(:focus-visible) .cn-radio__control { box-shadow: inset 0 0 0 1px {color.bg-action}; }
125
+ .cn-radio-group { display: flex; flex-direction: column; gap: {space.4}; min-width: 0; margin: 0; padding: 0; border: 0; }
126
+ .cn-radio-group__label { font-family: {type.label-sm.family}; font-size: {type.label-sm.size}; font-weight: {type.label-sm.weight}; line-height: {type.label-sm.lineHeight}; letter-spacing: {type.label-sm.letterSpacing}; color: {color.fg-default}; margin-bottom: {space.0.5}; }
127
+ .cn-radio-group[data-orientation="horizontal"] { flex-direction: row; flex-wrap: wrap; row-gap: {space.3}; column-gap: {space.6}; }
128
+ .cn-radio-group[data-orientation="horizontal"] .cn-radio-group__label { flex-basis: 100%; margin-bottom: 0; }`,
129
+ examples: [
130
+ ex('Default (unchecked)', rd('data-size="sm"', 'name="ex-cycle" value="monthly"', 'Monthly'), '16px circle, gray-300 ring; 14px medium label 8px away.'),
131
+ ex('Checked', rd('data-size="sm"', 'name="ex-checked" value="annual" checked', 'Annual'), 'Brand-600 fill with a 6px white dot.'),
132
+ ex('With supporting text', rd('data-size="sm"', 'name="ex-desc" value="team" checked', 'Team', '$99 per seat, billed monthly.'), 'The supporting line is the same size, regular weight, muted.'),
133
+ ex('Medium', rd('data-size="md"', 'name="ex-md" value="scale" checked', 'Scale', 'Unlimited seats, SSO and audit log.'), '20px circle with an 8px dot; 16px text 12px away with a 2px gap between lines.'),
134
+ ex('Invalid', rd('data-size="sm" data-invalid', 'name="ex-invalid" value="wire" aria-invalid="true" required', 'Wire transfer'), 'Every option of the group gets data-invalid; the error message sits under the group.'),
135
+ ex('Disabled', stack(rd('data-size="sm"', 'name="ex-disabled" value="card" checked disabled', 'Company card', 'Locked by Aisha Khan, your finance admin.'), rd('data-size="sm"', 'name="ex-disabled" value="invoice" disabled', 'Invoice')), 'The circle fades to 50% (gray-100 when unselected); the text keeps its color.'),
136
+ ex('Card', stack(rd('data-size="sm"', 'name="ex-card" value="starter"', 'Starter — $29/mo', 'Up to 5 members and 3 projects.', 'card'), rd('data-size="sm"', 'name="ex-card" value="team" checked', 'Team — $99/mo', 'Unlimited members, 20 projects and shared libraries.', 'card')), 'A selectable card: 1px gray-200 ring, radius 12, 16px padding; a 2px brand ring when selected.'),
137
+ ],
138
+ recipes: [
139
+ ex('Radio group (stacked)', `<div class="cn-radio-group" role="radiogroup" aria-labelledby="rg-billing"><div class="cn-radio-group__label" id="rg-billing">Billing cycle</div>${rd('data-size="sm"', 'name="billing" value="monthly"', 'Monthly', '$99 per seat, cancel anytime.')}${rd('data-size="sm"', 'name="billing" value="annual" checked', 'Annual', '$79 per seat, billed once a year. Save 20%.')}</div>`, 'A label names the question; options stack 16px apart; one is always checked.'),
140
+ ex('Radio group (horizontal)', `<div class="cn-radio-group" role="radiogroup" aria-labelledby="rg-priority" data-orientation="horizontal"><div class="cn-radio-group__label" id="rg-priority">Priority</div>${rd('data-size="sm"', 'name="priority" value="low"', 'Low')}${rd('data-size="sm"', 'name="priority" value="normal" checked', 'Normal')}${rd('data-size="sm"', 'name="priority" value="high"', 'High')}</div>`, 'Horizontal only for short one-word options without descriptions.'),
141
+ ex('Plans as cards', `<div class="cn-radio-group" role="radiogroup" aria-labelledby="rg-plan" style="max-width:420px"><div class="cn-radio-group__label" id="rg-plan">Plan</div>${rd('data-size="sm"', 'name="plan" value="starter"', 'Starter — $29/mo', 'Up to 5 members and 3 projects.', 'card')}${rd('data-size="sm"', 'name="plan" value="team" checked', 'Team — $99/mo', 'Unlimited members, 20 projects and shared libraries.', 'card')}${rd('data-size="sm"', 'name="plan" value="enterprise"', 'Enterprise', 'SSO, audit log and a named contact. Talk to Noah Berg.', 'card')}</div>`, 'Cards stack 16px apart; the whole card is the hit target and shows the focus ring.'),
142
+ ],
143
+ rules: [
144
+ 'A radio group has 2–5 options and always one selected (pre-select the safest or most common). No selection is a validation error, not a state.',
145
+ 'Wrap options in .cn-radio-group with a label; never a loose row of radios without a visible question.',
146
+ 'Options are parallel noun phrases in sentence case ("Monthly", "Annual"), never questions or verbs. Put price and consequence in the supporting line.',
147
+ 'Stacked 16px apart by default; horizontal (data-orientation="horizontal", 24px column gap) only for one-word options without descriptions.',
148
+ 'If choosing an option reveals more fields, put them directly under that option indented by the label column (8px + circle width), not at the end of the group.',
149
+ 'Use SegmentedControl instead when the choice is a view/mode in a toolbar; use Select when there are more than 5 options.',
150
+ 'sm (16px) in forms and dialogs; md (20px) on touch screens and onboarding. Cards for plans and tiers where the description matters.',
151
+ ],
152
+ a11y: [
153
+ 'Each option is a <label> wrapping a native <input type="radio">; every input of the group shares the same name so arrow keys move between them.',
154
+ 'The group container has role="radiogroup" (or is a <fieldset> with <legend>) and aria-labelledby pointing at the group label.',
155
+ 'Keyboard: Tab enters the group on the checked option; Up/Down and Left/Right move and select; Space selects when nothing is checked.',
156
+ 'Invalid: aria-invalid="true" on every input plus aria-describedby pointing at the error under the group.',
157
+ 'The circle and dot are aria-hidden; state comes from the native input, so no aria-checked is needed.',
158
+ ],
159
+ related: ['checkbox', 'switch', 'segmented-control', 'select', 'field'],
160
+ };
@@ -0,0 +1,93 @@
1
+ import { ex, typeStyle } from "./_shared.mjs";
2
+ // Star rating for display: five stars painted amber when filled and border-strong when empty, a
3
+ // numeric value in label-sm and a muted count. The badge variant packs five 12px stars and the value
4
+ // into a hairline pill for testimonials and marketing cards.
5
+ const STAR = 'M10 1.5l2.6 5.4 5.9.8-4.3 4.1 1.1 5.9L10 14.9l-5.3 2.8 1.1-5.9L1.5 7.7l5.9-.8z';
6
+ const star = (filled) => filled === 'half'
7
+ ? `<svg class="cn-rating__star" data-filled="half" viewBox="0 0 20 20" aria-hidden="true"><path d="${STAR}"/><path d="${STAR}"/></svg>`
8
+ : `<svg class="cn-rating__star" data-filled="${filled}" viewBox="0 0 20 20" aria-hidden="true"><path d="${STAR}"/></svg>`;
9
+ /** Five stars for a value between 0 and 5; a fraction ≥ .25 and < .75 renders a half star. */
10
+ const stars = (value) => Array.from({ length: 5 }, (_, i) => star(value >= i + 0.75 ? 'true' : value >= i + 0.25 ? 'half' : 'false')).join('');
11
+ const rate = (o) => `<div class="cn-rating" data-variant="${o.variant ?? 'stars'}" data-size="${o.size ?? 'md'}" role="img" aria-label="${o.label}">${o.inner}</div>`;
12
+ export const rating = {
13
+ name: 'Rating',
14
+ slug: 'rating',
15
+ category: 'data-display',
16
+ description: 'Read-only star rating: five stars (filled amber, half, or empty in the hairline color), an optional numeric value and a muted count. The badge variant packs five small stars and the value into a hairline pill; inline shows one star with the number for dense rows.',
17
+ usage: 'Display reviews, satisfaction scores and quality grades next to a product, a vendor or a testimonial. It is not an input: for collecting a rating use a Radio group of stars. Do not use stars for anything that is not a rating (priority, importance).',
18
+ anatomy: [
19
+ { part: 'root', element: 'div', description: 'Inline row (flex, align center, gap space.1). role="img" with aria-label "4.5 out of 5 stars, 128 reviews". Carries data-variant and data-size.' },
20
+ { part: 'star', element: 'svg', description: 'One star. data-filled="true" paints it amber, "false" the hairline color, "half" splits it with a clip on the right half. 16px (sm) or 20px (md); 12px inside the badge.' },
21
+ { part: 'value', element: 'span', description: 'The number ("4.5"), label-sm, tabular. Follows the stars with a small gap.', optional: true },
22
+ { part: 'count', element: 'span', description: 'Muted body-sm context: "(128 reviews)", "from 1,240 reviews".', optional: true },
23
+ { part: 'badge', element: 'span', description: 'Hairline pill (surface fill, radius full, padding-inline space.2) holding five 12px stars and the value. Used by the badge variant.', optional: true },
24
+ ],
25
+ props: {
26
+ variant: {
27
+ values: ['stars', 'badge', 'inline'],
28
+ default: 'stars',
29
+ description: 'stars = five stars with an optional value and count, for product pages and vendor cards; badge = a pill with five small stars and the value, followed by muted text, for testimonials and marketing proof lines; inline = a single star plus the value (and count), for table cells, list rows and compact cards.',
30
+ },
31
+ size: { values: ['sm', 'md'], default: 'md', description: 'md = 20px stars with label-sm value (cards, headers); sm = 16px stars with label-xs value (rows, dense lists). The badge always uses 12px stars.' },
32
+ },
33
+ states: {},
34
+ base: {
35
+ root: { display: 'inline-flex', 'align-items': 'center', gap: '{space.1}', 'vertical-align': 'middle', color: '{color.fg-default}' },
36
+ star: { display: 'block', 'flex-shrink': '0', width: '{size.icon.lg}', height: '{size.icon.lg}', fill: '{color.border-strong}' },
37
+ value: { 'margin-inline-start': '{space.1}', ...typeStyle('label-sm'), 'font-variant-numeric': 'tabular-nums', color: '{color.fg-default}' },
38
+ count: { ...typeStyle('body-sm'), color: '{color.fg-muted}', 'white-space': 'nowrap' },
39
+ badge: {
40
+ display: 'inline-flex',
41
+ 'align-items': 'center',
42
+ gap: '{space.0.5}',
43
+ height: '{space.6}',
44
+ 'padding-inline': '{space.2}',
45
+ 'background-color': '{color.bg-surface}',
46
+ border: '{border.width.thin} solid {color.border-default}',
47
+ 'border-radius': '{radius.full}',
48
+ 'box-shadow': '{shadow.xs}',
49
+ 'white-space': 'nowrap',
50
+ },
51
+ },
52
+ variants: {
53
+ variant: {
54
+ stars: { root: {} },
55
+ badge: { root: { gap: '{space.2}' }, star: { width: '{size.icon.xs}', height: '{size.icon.xs}' }, value: { ...typeStyle('label-xs'), 'margin-inline-start': '{space.1}' } },
56
+ inline: { root: { gap: '{space.1.5}' }, value: { 'margin-inline-start': '0' } },
57
+ },
58
+ size: {
59
+ sm: { star: { width: '{size.icon.md}', height: '{size.icon.md}' }, value: { 'font-size': '{font.size.xs}' }, count: { 'font-size': '{font.size.xs}' } },
60
+ md: { star: { width: '{size.icon.lg}', height: '{size.icon.lg}' } },
61
+ },
62
+ },
63
+ extraCss: `
64
+ .cn-rating .cn-rating__star[data-filled="true"] { fill: {amber.400}; }
65
+ .cn-rating .cn-rating__star[data-filled="half"] path:last-child { fill: {amber.400}; clip-path: inset(0 50% 0 0); }
66
+ .cn-rating[data-variant="badge"] .cn-rating__star { width: {size.icon.xs}; height: {size.icon.xs}; }`,
67
+ examples: [
68
+ ex('Stars with value and count', rate({ value: 4.5, label: '4.5 out of 5 stars, 128 reviews', inner: `${stars(4.5)}<span class="cn-rating__value">4.5</span><span class="cn-rating__count">(128 reviews)</span>` }), 'Four filled stars, one half star, the number and the count.'),
69
+ ex('Stars only', rate({ value: 5, label: '5 out of 5 stars', inner: stars(5) }), 'When the number is shown elsewhere (a heading, a table column).'),
70
+ ex('Badge (proof line)', rate({ variant: 'badge', value: 4.9, label: '4.9 out of 5 stars from 1,240 reviews', inner: `<span class="cn-rating__badge">${stars(4.9)}<span class="cn-rating__value">4.9</span></span><span class="cn-rating__count">from 1,240 reviews</span>` }), 'Five 12px stars and the value inside a hairline pill, the count as muted text after it.'),
71
+ ex('Inline (rows and cells)', `<div style="display:flex;flex-direction:column;gap:var(--cn-space-2);align-items:flex-start">${rate({ variant: 'inline', value: 4.8, label: '4.8 out of 5 stars, 312 reviews', inner: `${star('true')}<span class="cn-rating__value">4.8</span><span class="cn-rating__count">(312)</span>` })}${rate({ variant: 'inline', size: 'sm', value: 3.6, label: '3.6 out of 5 stars, 41 reviews', inner: `${star('true')}<span class="cn-rating__value">3.6</span><span class="cn-rating__count">(41)</span>` })}</div>`, 'One star and the number; the count in parentheses.'),
72
+ ex('Sizes', `<div style="display:flex;flex-direction:column;gap:var(--cn-space-2);align-items:flex-start">${rate({ size: 'sm', value: 4, label: '4 out of 5 stars', inner: `${stars(4)}<span class="cn-rating__value">4.0</span>` })}${rate({ size: 'md', value: 4, label: '4 out of 5 stars', inner: `${stars(4)}<span class="cn-rating__value">4.0</span>` })}</div>`, '16px stars (sm) and 20px stars (md).'),
73
+ ex('Low and empty', `<div style="display:flex;flex-direction:column;gap:var(--cn-space-2);align-items:flex-start">${rate({ value: 2.3, label: '2.3 out of 5 stars, 9 reviews', inner: `${stars(2.3)}<span class="cn-rating__value">2.3</span><span class="cn-rating__count">(9 reviews)</span>` })}${rate({ value: 0, label: 'No ratings yet', inner: `${stars(0)}<span class="cn-rating__count">No ratings yet</span>` })}</div>`, 'Empty stars stay visible in the hairline color so the scale is always readable.'),
74
+ ],
75
+ recipes: [
76
+ ex('Testimonial header', `<div style="display:flex;flex-direction:column;gap:var(--cn-space-2);max-width:420px">${rate({ variant: 'badge', value: 5, label: '5 out of 5 stars', inner: `<span class="cn-rating__badge">${stars(5)}<span class="cn-rating__value">5.0</span></span>` })}<p class="cn-text-body-md" style="margin:0">“Lumen found the buyers we had been chasing for two years in one afternoon.”</p><p class="cn-text-body-sm" style="margin:0;color:var(--cn-color-fg-muted)">Elena Rossi, Head of Procurement</p></div>`, 'Badge above the quote, attribution in muted text below.'),
77
+ ],
78
+ rules: [
79
+ 'Always five stars. Fill from the left; a fraction from .25 to .75 is a half star, never a partial fill at other widths.',
80
+ 'Filled stars are amber, empty stars the hairline color. Never recolor stars by brand or by score; the number carries the nuance.',
81
+ 'Show the value with one decimal ("4.5", "4.0") and the count with a thousands separator ("1,240 reviews"). Round the value, never the count.',
82
+ 'stars on product and vendor pages; inline in tables, lists and compact cards; badge only on marketing and testimonial blocks.',
83
+ 'With fewer than 5 reviews show the count and no value; with 0 show empty stars and "No ratings yet".',
84
+ 'Rating is display only. Collecting a rating is a form control (Radio group of stars) with its own focus and keyboard behavior.',
85
+ 'Sizes match the surrounding text: sm next to body-sm, md next to body-md and headings.',
86
+ ],
87
+ a11y: [
88
+ 'The root is role="img" with aria-label that states the score and the count ("4.5 out of 5 stars, 128 reviews"); the stars are aria-hidden.',
89
+ 'The visible value and count repeat the aria-label content, so sighted and screen-reader users get the same information.',
90
+ 'Color is not the only cue: filled and empty stars differ in luminance and the number is always available nearby or in the label.',
91
+ ],
92
+ related: ['badge', 'stat', 'card', 'radio'],
93
+ };