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.
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/THIRD_PARTY_NOTICES.md +55 -0
- package/bin/canon.js +6 -0
- package/lib/build-manifest.mjs +54 -0
- package/lib/build.mjs +69 -0
- package/lib/cli.mjs +273 -0
- package/lib/color.js +160 -0
- package/lib/components/_shared.mjs +93 -0
- package/lib/components/accordion.mjs +136 -0
- package/lib/components/account-card.mjs +166 -0
- package/lib/components/activity-feed.mjs +176 -0
- package/lib/components/activity-gauge.mjs +98 -0
- package/lib/components/agent-presence.mjs +156 -0
- package/lib/components/alert.mjs +154 -0
- package/lib/components/app-store-button.mjs +108 -0
- package/lib/components/avatar-group.mjs +128 -0
- package/lib/components/avatar.mjs +225 -0
- package/lib/components/badge-group.mjs +55 -0
- package/lib/components/badge.mjs +174 -0
- package/lib/components/banner.mjs +116 -0
- package/lib/components/breadcrumb.mjs +170 -0
- package/lib/components/button-group.mjs +114 -0
- package/lib/components/button.mjs +197 -0
- package/lib/components/card-header.mjs +115 -0
- package/lib/components/card.mjs +106 -0
- package/lib/components/carousel.mjs +182 -0
- package/lib/components/chart-frame.mjs +101 -0
- package/lib/components/checkbox.mjs +163 -0
- package/lib/components/close-button.mjs +49 -0
- package/lib/components/code.mjs +123 -0
- package/lib/components/color-picker.mjs +122 -0
- package/lib/components/combobox.mjs +210 -0
- package/lib/components/command-palette.mjs +226 -0
- package/lib/components/content-divider.mjs +104 -0
- package/lib/components/counter.mjs +86 -0
- package/lib/components/credit-card.mjs +126 -0
- package/lib/components/date-picker.mjs +202 -0
- package/lib/components/description-list.mjs +88 -0
- package/lib/components/dialog.mjs +140 -0
- package/lib/components/divider.mjs +115 -0
- package/lib/components/drawer.mjs +150 -0
- package/lib/components/empty-state.mjs +112 -0
- package/lib/components/featured-card.mjs +124 -0
- package/lib/components/featured-icon.mjs +67 -0
- package/lib/components/field.mjs +122 -0
- package/lib/components/file-dropzone.mjs +166 -0
- package/lib/components/filter-bar.mjs +179 -0
- package/lib/components/header-navigation.mjs +94 -0
- package/lib/components/icon-button.mjs +179 -0
- package/lib/components/index.mjs +52 -0
- package/lib/components/inline-cta.mjs +139 -0
- package/lib/components/input-group.mjs +164 -0
- package/lib/components/input.mjs +197 -0
- package/lib/components/kanban.mjs +179 -0
- package/lib/components/kbd.mjs +70 -0
- package/lib/components/kicker.mjs +79 -0
- package/lib/components/link.mjs +108 -0
- package/lib/components/list.mjs +117 -0
- package/lib/components/media-frame.mjs +119 -0
- package/lib/components/menu.mjs +190 -0
- package/lib/components/message.mjs +249 -0
- package/lib/components/metric-group.mjs +210 -0
- package/lib/components/mobile-header.mjs +173 -0
- package/lib/components/multi-select.mjs +218 -0
- package/lib/components/notification.mjs +175 -0
- package/lib/components/number-input.mjs +149 -0
- package/lib/components/page-header.mjs +68 -0
- package/lib/components/pagination.mjs +236 -0
- package/lib/components/pin-input.mjs +120 -0
- package/lib/components/popover.mjs +126 -0
- package/lib/components/progress-circle.mjs +115 -0
- package/lib/components/progress.mjs +93 -0
- package/lib/components/prose.mjs +96 -0
- package/lib/components/radio.mjs +160 -0
- package/lib/components/rating.mjs +93 -0
- package/lib/components/rich-text-editor.mjs +178 -0
- package/lib/components/section-header.mjs +62 -0
- package/lib/components/segmented-control.mjs +121 -0
- package/lib/components/select.mjs +163 -0
- package/lib/components/sidebar-nav.mjs +172 -0
- package/lib/components/sidebar.mjs +124 -0
- package/lib/components/skeleton.mjs +66 -0
- package/lib/components/slider.mjs +122 -0
- package/lib/components/social-button.mjs +119 -0
- package/lib/components/spinner.mjs +69 -0
- package/lib/components/stat.mjs +120 -0
- package/lib/components/stepper.mjs +228 -0
- package/lib/components/switch.mjs +159 -0
- package/lib/components/table.mjs +212 -0
- package/lib/components/tabs.mjs +207 -0
- package/lib/components/tag.mjs +196 -0
- package/lib/components/tags-input.mjs +146 -0
- package/lib/components/textarea.mjs +117 -0
- package/lib/components/timeline.mjs +135 -0
- package/lib/components/toast.mjs +115 -0
- package/lib/components/tooltip.mjs +86 -0
- package/lib/components/topbar.mjs +207 -0
- package/lib/components/tree-view.mjs +156 -0
- package/lib/components/video-player.mjs +189 -0
- package/lib/connect.mjs +110 -0
- package/lib/design-files.mjs +175 -0
- package/lib/distribution.mjs +109 -0
- package/lib/editor.js +940 -0
- package/lib/engine.js +339 -0
- package/lib/generators/agents.mjs +68 -0
- package/lib/generators/connection.md +58 -0
- package/lib/generators/css.mjs +2 -0
- package/lib/generators/designmd.mjs +324 -0
- package/lib/generators/documentation.css +82 -0
- package/lib/generators/documentation.html +81 -0
- package/lib/generators/documentation.js +96 -0
- package/lib/generators/documentation.mjs +35 -0
- package/lib/generators/dtcg.mjs +60 -0
- package/lib/generators/preview.css +279 -0
- package/lib/generators/preview.mjs +225 -0
- package/lib/generators/react.mjs +162 -0
- package/lib/generators/tailwind.mjs +114 -0
- package/lib/install.mjs +108 -0
- package/lib/lint.mjs +538 -0
- package/lib/mcp.mjs +268 -0
- package/lib/open.mjs +7 -0
- package/lib/patterns/_app.mjs +56 -0
- package/lib/patterns/app-shell.mjs +25 -0
- package/lib/patterns/audit-log.mjs +37 -0
- package/lib/patterns/auth-pages.mjs +44 -0
- package/lib/patterns/billing-page.mjs +39 -0
- package/lib/patterns/calendar-page.mjs +55 -0
- package/lib/patterns/dashboard-page.mjs +28 -0
- package/lib/patterns/files-page.mjs +30 -0
- package/lib/patterns/first-run.mjs +31 -0
- package/lib/patterns/form-layout.mjs +24 -0
- package/lib/patterns/inbox-page.mjs +58 -0
- package/lib/patterns/index.mjs +29 -0
- package/lib/patterns/list-detail-page.mjs +24 -0
- package/lib/patterns/mobile-app.mjs +46 -0
- package/lib/patterns/modal-flows.mjs +32 -0
- package/lib/patterns/onboarding-page.mjs +42 -0
- package/lib/patterns/permissions-page.mjs +38 -0
- package/lib/patterns/profile-page.mjs +32 -0
- package/lib/patterns/settings-page.mjs +24 -0
- package/lib/project.mjs +28 -0
- package/lib/serve.mjs +279 -0
- package/lib/system.mjs +210 -0
- package/lib/tokens/base.js +251 -0
- package/lib/tokens/canon-preset.mjs +67 -0
- package/lib/tokens/index.mjs +1 -0
- package/lib/tokens/legacy-canon.json +559 -0
- package/lib/tokens/presets.mjs +161 -0
- package/lib/tokens/resolve.mjs +1 -0
- package/lib/types.mjs +4 -0
- package/lib/version.mjs +2 -0
- package/package.json +54 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { ex, ICON, typeStyle } from "./_shared.mjs";
|
|
2
|
+
// the reference table-card header: 20 × 24px padding (16 × 20 sm), a 16px semibold title with an
|
|
3
|
+
// optional gray "modern" badge 8px after it, a 14px gray-600 description 2px below, an optional
|
|
4
|
+
// 40px avatar or modern featured icon before the text, actions 12px apart on the right, and a
|
|
5
|
+
// gray-200 hairline that spans the card.
|
|
6
|
+
const HAIRLINE = '{border.width.thin} solid {color.border-default}';
|
|
7
|
+
const SILHOUETTE = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' fill='%23CDCED1'/%3E%3Ccircle cx='32' cy='25' r='11' fill='%23FAFAFA'/%3E%3Cpath d='M10 64c2-13 11-21 22-21s20 8 22 21z' fill='%23FAFAFA'/%3E%3C/svg%3E";
|
|
8
|
+
const btn = (label, variant, icon, size = 'md') => `<button type="button" class="cn-button" data-variant="${variant}" data-size="${size}">${icon ? ICON[icon].replace('cn-icon', 'cn-button__icon') : ''}<span class="cn-button__label">${label}</span></button>`;
|
|
9
|
+
const iconBtn = (label, i) => `<button type="button" class="cn-icon-button" data-variant="ghost" data-size="sm" data-shape="square" aria-label="${label}">${ICON[i].replace('cn-icon', 'cn-icon-button__icon')}</button>`;
|
|
10
|
+
const content = (title, description, badge = '') => `<div class="cn-card-header__content"><h2 class="cn-card-header__title">${title}${badge}</h2><p class="cn-card-header__description">${description}</p></div>`;
|
|
11
|
+
const badge = (text, tone = 'neutral') => `<span class="cn-badge cn-card-header__badge" data-tone="${tone}" data-variant="modern" data-size="sm" data-shape="square">${text}</span>`;
|
|
12
|
+
const actions = (inner) => `<div class="cn-card-header__actions">${inner}</div>`;
|
|
13
|
+
const tabs = (labels, current = 0) => `<div class="cn-card-header__tabs"><div class="cn-tabs" data-variant="underline" data-size="sm" data-orientation="horizontal" data-width="hug">${labels.map((l, i) => `<a href="#" class="cn-tabs__tab"${i === current ? ' aria-current="page"' : ''}>${l}</a>`).join('')}</div></div>`;
|
|
14
|
+
const AVATAR = `<div class="cn-card-header__media"><span class="cn-avatar" data-size="md" data-shape="circle" data-tone="neutral" data-status="online" aria-hidden="true"><img class="cn-avatar__image" src="${SILHOUETTE}" alt=""><span class="cn-avatar__status"></span></span></div>`;
|
|
15
|
+
const FEATURED = (i) => `<div class="cn-card-header__media"><span class="cn-featured-icon" data-theme="modern" data-tone="gray" data-size="md" data-shape="square">${ICON[i].replace('cn-icon', 'cn-featured-icon__icon')}</span></div>`;
|
|
16
|
+
const card = (header, body) => `<section class="cn-card" data-variant="default" data-padding="none" style="width:100%;max-width:720px">${header}<div class="cn-card__body" style="padding:var(--cn-space-5) var(--cn-space-6)"><p class="cn-text-body-md">${body}</p></div></section>`;
|
|
17
|
+
export const cardHeader = {
|
|
18
|
+
name: 'CardHeader',
|
|
19
|
+
slug: 'card-header',
|
|
20
|
+
category: 'layout',
|
|
21
|
+
description: 'The header row of a Card (the reference table-card header): 20 × 24px padding, a 16px semibold title with an optional gray badge, a 14px gray-600 description, an optional 40px avatar or modern featured icon before the text and the card\'s actions on the right, closed by a gray-200 hairline that spans the card.',
|
|
22
|
+
usage: 'Use as the first child of a Card that holds a table, a list, a form section or a record. It names the card and carries its actions (Import, Add user, the "more" menu). For a page title use PageHeader; for a heading between two blocks of a page use SectionHeader. Put it in a Card with data-padding="none" so the hairline reaches the card edges.',
|
|
23
|
+
anatomy: [
|
|
24
|
+
{ part: 'root', element: 'header', description: 'Wrapping flex row inside the Card: media, text block, actions; 16px gaps, aligned to the top, hairline below. Padding from size.' },
|
|
25
|
+
{ part: 'media', element: 'div', description: 'Optional 40px block before the text: a modern gray FeaturedIcon (md) or an Avatar md.', optional: true },
|
|
26
|
+
{ part: 'content', element: 'div', description: 'Column with 2px gap: title (with optional badge) then description. Grows to push actions right.' },
|
|
27
|
+
{ part: 'title', element: 'h2', description: '16px semibold in ink (heading-xs); a flex row so a Badge can follow the text 8px after it.' },
|
|
28
|
+
{ part: 'badge', element: 'span', description: 'A cn-badge (modern, gray, sm, square) inside the title after the text (add this class to the badge): "100 users", "Beta".', optional: true },
|
|
29
|
+
{ part: 'description', element: 'p', description: '14px supporting text in fg-muted.', optional: true },
|
|
30
|
+
{ part: 'actions', element: 'div', description: 'Row of Buttons or ghost IconButtons 12px apart; the "more" menu trigger is last.', optional: true },
|
|
31
|
+
{ part: 'tabs', element: 'div', description: 'Full-width slot under the text for an underline Tabs sm whose bar sits on the header hairline.', optional: true },
|
|
32
|
+
],
|
|
33
|
+
props: {
|
|
34
|
+
variant: {
|
|
35
|
+
values: ['default', 'with-badge', 'with-media', 'with-tabs'],
|
|
36
|
+
default: 'default',
|
|
37
|
+
description: 'default = title, description, actions. with-badge = a Badge after the title (count or state of the card). with-media = a 40px avatar or featured icon before the text (a person, a company, a feature). with-tabs = underline Tabs under the text switch views of the card\'s content.',
|
|
38
|
+
},
|
|
39
|
+
size: {
|
|
40
|
+
values: ['sm', 'md'],
|
|
41
|
+
default: 'md',
|
|
42
|
+
description: 'md = 20px vertical, 24px horizontal padding (table cards, settings cards). sm = 16px × 20px for compact cards in a grid or a side column.',
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
states: {},
|
|
46
|
+
base: {
|
|
47
|
+
root: {
|
|
48
|
+
display: 'flex',
|
|
49
|
+
'flex-wrap': 'wrap',
|
|
50
|
+
'align-items': 'flex-start',
|
|
51
|
+
'justify-content': 'space-between',
|
|
52
|
+
'column-gap': '{space.4}',
|
|
53
|
+
'row-gap': '{space.4}',
|
|
54
|
+
width: '100%',
|
|
55
|
+
'min-width': '0',
|
|
56
|
+
'border-bottom': HAIRLINE,
|
|
57
|
+
'background-color': '{color.bg-surface}',
|
|
58
|
+
color: '{color.fg-default}',
|
|
59
|
+
},
|
|
60
|
+
media: {
|
|
61
|
+
display: 'flex',
|
|
62
|
+
'align-items': 'center',
|
|
63
|
+
'justify-content': 'center',
|
|
64
|
+
'flex-shrink': '0',
|
|
65
|
+
width: '{space.10}',
|
|
66
|
+
height: '{space.10}',
|
|
67
|
+
},
|
|
68
|
+
content: { display: 'flex', 'flex-direction': 'column', gap: '{space.0.5}', flex: '1 1 {space.48}', 'min-width': '0' },
|
|
69
|
+
title: { display: 'flex', 'align-items': 'center', 'flex-wrap': 'wrap', gap: '{space.2}', ...typeStyle('heading-xs'), color: '{color.fg-default}', 'overflow-wrap': 'anywhere' },
|
|
70
|
+
badge: { 'flex-shrink': '0' },
|
|
71
|
+
description: { ...typeStyle('body-md'), color: '{color.fg-muted}' },
|
|
72
|
+
actions: { display: 'flex', 'align-items': 'center', 'flex-wrap': 'wrap', gap: '{space.3}', 'flex-shrink': '0', 'max-width': '100%' },
|
|
73
|
+
tabs: { 'flex-basis': '100%', 'min-width': '0', 'overflow-x': 'auto' },
|
|
74
|
+
},
|
|
75
|
+
variants: {
|
|
76
|
+
variant: {
|
|
77
|
+
default: { root: {} },
|
|
78
|
+
'with-badge': { root: {} },
|
|
79
|
+
'with-media': { root: {} },
|
|
80
|
+
'with-tabs': { root: {} },
|
|
81
|
+
},
|
|
82
|
+
size: {
|
|
83
|
+
sm: { root: { padding: '{space.4} {space.5}' }, tabs: { 'margin-bottom': 'calc({space.4} * -1)' } },
|
|
84
|
+
md: { root: { padding: '{space.5} {space.6}' }, tabs: { 'margin-bottom': 'calc({space.5} * -1)' } },
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
extraCss: `
|
|
88
|
+
.cn-card-header__tabs .cn-tabs[data-variant="underline"] { border-bottom: 0; min-width: max-content; margin-bottom: 0; }
|
|
89
|
+
@media (max-width: {breakpoint.md}) { .cn-card-header[data-size="md"] { padding: {space.5} {space.4}; } .cn-card-header[data-size="sm"] { padding: {space.4}; } }`,
|
|
90
|
+
examples: [
|
|
91
|
+
ex('Default (table card)', card(`<header class="cn-card-header" data-variant="default" data-size="md">${content('Team members', 'Manage your team members and their account permissions here.')}${actions(btn('Import', 'outline', 'inbox') + btn('Add user', 'primary', 'plus'))}</header>`, '24 members · 3 pending invites · Last change 2 hours ago by Daniel Costa.'), 'Card with data-padding="none" so the header hairline spans the full width; 20 × 24px padding, 16px semibold title.'),
|
|
92
|
+
ex('With badge', card(`<header class="cn-card-header" data-variant="with-badge" data-size="md">${content('Team members', 'Manage your team members and their account permissions here.', badge('100 users'))}${actions(iconBtn('More actions', 'dots'))}</header>`, 'Maya Chen, Daniel Costa, Sofia Almeida and 97 others.'), 'A modern gray badge 8px after the title; a single ghost "more" IconButton on the right.'),
|
|
93
|
+
ex('With avatar', card(`<header class="cn-card-header" data-variant="with-media" data-size="md">${AVATAR}${content('Maya Chen', 'maya@lumen.co')}${actions(iconBtn('Copy email', 'copy') + iconBtn('More actions', 'dots'))}</header>`, 'Product Manager · Lisbon, Portugal · Joined March 2024.'), 'A 40px Avatar with status before the text.'),
|
|
94
|
+
ex('With tabs', card(`<header class="cn-card-header" data-variant="with-tabs" data-size="md">${content('Projects', 'Everything your team is working on right now.')}${actions(btn('New project', 'primary', 'plus', 'sm'))}${tabs(['All', 'Active', 'Archived'])}</header>`, '12 active projects, 4 archived this quarter.'), 'Underline Tabs sm under the text; their brand bar sits on the header hairline.'),
|
|
95
|
+
ex('Small with featured icon', card(`<header class="cn-card-header" data-variant="with-media" data-size="sm">${FEATURED('inbox')}${content('Notifications', 'Choose how you get notified.')}${actions(btn('Edit', 'outline', undefined, 'sm'))}</header>`, 'Email and push for mentions and approvals; in-app only for the rest.'), 'sm padding (16 × 20) with a 40px modern gray FeaturedIcon.'),
|
|
96
|
+
],
|
|
97
|
+
rules: [
|
|
98
|
+
'Use inside a Card with data-padding="none" and give the body its own padding, so the header hairline reaches the card edges.',
|
|
99
|
+
'Title is a noun phrase in sentence case, 1–3 words, 16px semibold; the description is one sentence in 14px muted. Never repeat the page title in a card title.',
|
|
100
|
+
'Actions: one primary Button at most, outline for the rest, ghost IconButtons for copy / more; the "more" menu is always last, 12px apart.',
|
|
101
|
+
'A Badge after the title is a modern gray sm badge with a count or a state of the card content, never decoration; one per title.',
|
|
102
|
+
'with-media: an Avatar md for a person or company, a modern gray FeaturedIcon md for a feature or setting. Never both.',
|
|
103
|
+
'with-tabs: Tabs are underline, sm, and switch views of the card\'s own content; their bar sits on the header hairline (the tabs lose their own line).',
|
|
104
|
+
'md padding (20 × 24) on table and settings cards; sm (16 × 20) on cards narrower than 400px. Under 768px the horizontal padding drops to 16.',
|
|
105
|
+
'Keep the header on one row when space allows. Let actions wrap below the content before the title or description becomes a narrow column.',
|
|
106
|
+
],
|
|
107
|
+
a11y: [
|
|
108
|
+
'Root is a <header> inside the Card <section>; the title heading level follows the page outline (h2 under a PageHeader h1).',
|
|
109
|
+
'A Badge inside the title becomes part of the heading name; keep it short and meaningful.',
|
|
110
|
+
'IconButtons in actions need aria-label; the "more" trigger exposes aria-haspopup="menu".',
|
|
111
|
+
'Tabs under the header are route links with aria-current="page" or a role="tablist" with aria-selected.',
|
|
112
|
+
'The featured icon is decorative (aria-hidden); an Avatar whose name is the title is aria-hidden too, so the name is not read twice.',
|
|
113
|
+
],
|
|
114
|
+
related: ['card', 'section-header', 'page-header', 'badge', 'avatar', 'featured-icon', 'icon-button', 'tabs', 'table'],
|
|
115
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { FOCUS_RING, STATE, ex, ICON, typeStyle } from "./_shared.mjs";
|
|
2
|
+
// Resting surfaces stay flat; a leading rule communicates selection.
|
|
3
|
+
const HAIRLINE = '{border.width.thin} solid {color.border-default}';
|
|
4
|
+
const ring = (color) => `inset 0 0 0 1px ${color}`;
|
|
5
|
+
const TRANSITION_FAST = {
|
|
6
|
+
'transition-property': 'background-color, box-shadow, transform',
|
|
7
|
+
'transition-duration': '{motion.duration.fast}',
|
|
8
|
+
'transition-timing-function': '{motion.easing.linear}',
|
|
9
|
+
};
|
|
10
|
+
const btn = (label, variant) => `<button type="button" class="cn-button" data-variant="${variant}" data-size="sm"><span class="cn-button__label">${label}</span></button>`;
|
|
11
|
+
export const card = {
|
|
12
|
+
name: 'Card',
|
|
13
|
+
slug: 'card',
|
|
14
|
+
category: 'layout',
|
|
15
|
+
description: 'A bounded surface for one thing: an entity, a form section, a stat. A neutral surface, a fine outline and compact corners, with a leading rule on selected cards.',
|
|
16
|
+
usage: 'Group related content that stands alone (a customer, a settings section, a KPI). Do not use cards as a layout grid for everything; lists and tables are better for collections of similar rows. Use variant="interactive" only when the whole card is one link/action. Put a CardHeader first when the card needs a title with actions.',
|
|
17
|
+
anatomy: [
|
|
18
|
+
{ part: 'root', element: 'section', description: 'The surface: surface fill, fine outline, token-defined corners; padding via the padding prop.' },
|
|
19
|
+
{ part: 'header', element: 'header', description: 'Title row with optional actions on the right, inside the padding (for a full-width hairline header use CardHeader with padding none).', optional: true },
|
|
20
|
+
{ part: 'title', element: 'h3', description: 'heading-sm (18/28 semibold). One per card.', optional: true },
|
|
21
|
+
{ part: 'description', element: 'p', description: '14px fg-muted, under the title.', optional: true },
|
|
22
|
+
{ part: 'actions', element: 'div', description: 'Right-aligned row of ghost / outline buttons in the header, 12px apart.', optional: true },
|
|
23
|
+
{ part: 'body', element: 'div', description: 'Main content.' },
|
|
24
|
+
{ part: 'footer', element: 'footer', description: 'Meta or actions on a gray-200 hairline, 16 × 24px padding (12 / 24 / 16 in a padding="none" card, pagination footer; add data-padding="none" on the footer itself to zero it); on a subtle surface.', optional: true },
|
|
25
|
+
],
|
|
26
|
+
props: {
|
|
27
|
+
variant: { values: ['default', 'outlined', 'elevated', 'interactive', 'ghost'], default: 'default', description: 'default = a flat surface with a fine outline; outlined = ring only, transparent background (on white canvases); elevated = shadow-md (only on a same-color canvas); interactive = whole card is one link or action: the edge strengthens and the surface changes on hover; ghost = no ring, gray-50 fill (a quiet group inside another card).' },
|
|
28
|
+
padding: { values: ['none', 'sm', 'md', 'lg'], default: 'md', description: 'Inner padding of header/body/footer: none for tables, lists and media that bleed to the edge; sm 16px (compact cards in a grid); md 24px (default); lg 32px (hero cards and settings sections).' },
|
|
29
|
+
},
|
|
30
|
+
states: {
|
|
31
|
+
hover: { selector: ':hover', description: 'Only interactive cards react: a stronger edge and subtle fill.', markup: 'native' },
|
|
32
|
+
focus: STATE.focus(),
|
|
33
|
+
selected: STATE.selected(),
|
|
34
|
+
},
|
|
35
|
+
base: {
|
|
36
|
+
root: {
|
|
37
|
+
display: 'flex',
|
|
38
|
+
position: 'relative',
|
|
39
|
+
'flex-direction': 'column',
|
|
40
|
+
'background-color': '{color.bg-surface}',
|
|
41
|
+
'box-shadow': ring('{color.border-default}'),
|
|
42
|
+
'border-radius': '{radius.card}',
|
|
43
|
+
color: '{color.fg-default}',
|
|
44
|
+
'min-width': '0',
|
|
45
|
+
overflow: 'hidden',
|
|
46
|
+
},
|
|
47
|
+
header: { display: 'flex', 'align-items': 'flex-start', 'justify-content': 'space-between', gap: '{space.4}' },
|
|
48
|
+
title: { ...typeStyle('heading-sm'), color: '{color.fg-default}' },
|
|
49
|
+
description: { ...typeStyle('body-md'), color: '{color.fg-muted}', 'margin-top': '{space.0.5}' },
|
|
50
|
+
actions: { display: 'flex', 'align-items': 'center', gap: '{space.3}', 'flex-shrink': '0' },
|
|
51
|
+
body: { flex: '1 1 auto', 'min-width': '0' },
|
|
52
|
+
footer: { 'background-color': '{color.bg-subtle}', display: 'flex', 'align-items': 'center', gap: '{space.3}', 'border-top': HAIRLINE, ...typeStyle('body-md'), color: '{color.fg-muted}' },
|
|
53
|
+
'@states': {
|
|
54
|
+
selected: { root: { 'box-shadow': ring('{color.border-action}'), 'background-color': '{color.bg-action-subtle}' } },
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
variants: {
|
|
58
|
+
variant: {
|
|
59
|
+
default: { root: {} },
|
|
60
|
+
outlined: { root: { 'background-color': 'transparent', 'box-shadow': ring('{color.border-default}') } },
|
|
61
|
+
elevated: { root: { 'box-shadow': `${ring('{color.border-subtle}')}, {shadow.md}` } },
|
|
62
|
+
interactive: {
|
|
63
|
+
root: { cursor: 'pointer', ...TRANSITION_FAST },
|
|
64
|
+
'@states': {
|
|
65
|
+
hover: { root: { 'box-shadow': ring('{color.border-control-hover}'), 'background-color': '{color.bg-subtle}' } },
|
|
66
|
+
focus: { root: { ...FOCUS_RING, 'box-shadow': `${ring('{color.border-control}')}, {shadow.focus}` } },
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
ghost: { root: { 'background-color': '{color.bg-subtle}', 'box-shadow': 'none' } },
|
|
70
|
+
},
|
|
71
|
+
padding: {
|
|
72
|
+
none: { header: { padding: '0' }, body: { padding: '0' }, footer: { padding: '{space.3} {space.6} {space.4}' } },
|
|
73
|
+
sm: { header: { padding: '{space.4} {space.4} 0' }, body: { padding: '{space.4}' }, footer: { padding: '{space.3} {space.4}' } },
|
|
74
|
+
md: { header: { padding: '{space.6} {space.6} 0' }, body: { padding: '{space.6}' }, footer: { padding: '{space.4} {space.6}' } },
|
|
75
|
+
lg: { header: { padding: '{space.8} {space.8} 0' }, body: { padding: '{space.8}' }, footer: { padding: '{space.5} {space.8}' } },
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
extraCss: `
|
|
79
|
+
.cn-card[data-selected]::before { content: ""; position: absolute; inset-block: {space.4}; inset-inline-start: 0; width: {border.width.thick}; background: {color.bg-action}; }
|
|
80
|
+
.cn-card[data-padding="md"] .cn-card__header + .cn-card__body { padding-top: {space.5}; }
|
|
81
|
+
.cn-card[data-padding="sm"] .cn-card__header + .cn-card__body { padding-top: {space.4}; }
|
|
82
|
+
.cn-card[data-padding="lg"] .cn-card__header + .cn-card__body { padding-top: {space.6}; }
|
|
83
|
+
.cn-card[data-padding="none"] > .cn-card__footer[data-padding="none"] { padding: 0; }`,
|
|
84
|
+
examples: [
|
|
85
|
+
ex('Default with header and footer', `<section class="cn-card" data-variant="default" data-padding="md" style="max-width:400px"><header class="cn-card__header"><div><h3 class="cn-card__title">Lumen website redesign</h3><p class="cn-card__description">Marketing site · Due Sep 19</p></div><div class="cn-card__actions"><button type="button" class="cn-icon-button" data-variant="ghost" data-size="sm" data-shape="square" aria-label="More actions">${ICON.dots.replace('cn-icon', 'cn-icon-button__icon')}</button></div></header><div class="cn-card__body"><p class="cn-text-body-md">12 tasks, 4 in review. Sofia Almeida owns the design track, Daniel Costa the build.</p></div><footer class="cn-card__footer">Updated 2h ago by Maya Chen</footer></section>`, 'Flat surface, fine outline, 24px padding; the footer sits on a hairline.'),
|
|
86
|
+
ex('With a CardHeader and a table-like body', `<section class="cn-card" data-variant="default" data-padding="none" style="max-width:480px"><header class="cn-card-header" data-variant="default" data-size="md"><div class="cn-card-header__content"><h2 class="cn-card-header__title">Team members</h2><p class="cn-card-header__description">Manage your team and their permissions.</p></div><div class="cn-card-header__actions">${btn('Add user', 'primary')}</div></header><div class="cn-card__body" style="padding:var(--cn-space-6)"><p class="cn-text-body-md">6 members · 1 invitation pending.</p></div><footer class="cn-card__footer">${btn('Previous', 'outline')}<span style="margin-inline:auto">Page 1 of 3</span>${btn('Next', 'outline')}</footer></section>`, 'padding="none" so the CardHeader hairline spans the card; the body sets its own 24px padding.'),
|
|
87
|
+
ex('Interactive and selected', `<div style="display:flex;gap:var(--cn-space-6);flex-wrap:wrap"><a href="#" class="cn-card" data-variant="interactive" data-padding="md" style="width:280px"><div class="cn-card__body"><h3 class="cn-card__title">Open the workspace</h3><p class="cn-card__description">12 projects · 3 waiting for you</p></div></a><a href="#" class="cn-card" data-variant="interactive" data-padding="md" data-selected style="width:280px"><div class="cn-card__body"><h3 class="cn-card__title">Business plan</h3><p class="cn-card__description">$2,900 / month · 15 seats</p></div></a></div>`, 'Hover strengthens the outline; selection adds an action-tinted surface and a leading rule.'),
|
|
88
|
+
ex('Sizes of padding', `<div style="display:flex;gap:var(--cn-space-6);flex-wrap:wrap;align-items:flex-start"><section class="cn-card" data-variant="default" data-padding="sm" style="width:200px"><div class="cn-card__body cn-text-body-md">sm · 16px</div></section><section class="cn-card" data-variant="default" data-padding="md" style="width:200px"><div class="cn-card__body cn-text-body-md">md · 24px</div></section><section class="cn-card" data-variant="default" data-padding="lg" style="width:200px"><div class="cn-card__body cn-text-body-md">lg · 32px</div></section></div>`),
|
|
89
|
+
ex('Outlined, elevated and ghost', `<div style="display:flex;gap:var(--cn-space-6);flex-wrap:wrap;align-items:flex-start"><section class="cn-card" data-variant="outlined" data-padding="sm" style="width:220px"><div class="cn-card__body cn-text-body-md">Outlined: ring only, no fill or shadow.</div></section><section class="cn-card" data-variant="elevated" data-padding="sm" style="width:220px"><div class="cn-card__body cn-text-body-md">Elevated: shadow-md on a same-color canvas.</div></section><section class="cn-card" data-variant="ghost" data-padding="sm" style="width:220px"><div class="cn-card__body cn-text-body-md">Ghost: a quiet gray-50 group inside another card.</div></section></div>`),
|
|
90
|
+
],
|
|
91
|
+
rules: [
|
|
92
|
+
'Resting cards have a fine outline. Only floating layers use elevation; interactive cards respond through their edge and surface.',
|
|
93
|
+
'One title per card. Use a CardHeader (padding none) when the title needs a hairline, a badge, media or tabs; the plain header part is for simple titled cards.',
|
|
94
|
+
'Do not nest default cards inside default cards. Inside a card use ghost cards, dividers or plain groups.',
|
|
95
|
+
'A collection of similar items is a list or a table inside one card, not a grid of cards. Cards for a grid only when each item is visual (media) or the number is ≤ 6.',
|
|
96
|
+
'Padding is a prop, never ad-hoc: md 24px by default, sm 16px in dense grids, lg 32px for hero and settings cards, none when a table, list or media fills the card.',
|
|
97
|
+
'Interactive cards contain no other interactive elements (no buttons inside a link) and show a stronger edge on hover; selected cards (radio cards) carry data-selected and a leading selection rule.',
|
|
98
|
+
'Footers hold meta or the pagination / actions row, on a hairline, on a subtle surface.',
|
|
99
|
+
],
|
|
100
|
+
a11y: [
|
|
101
|
+
'Interactive cards are an <a> (navigation) or a <button> (action), never a div with onClick; selectable cards are radios or checkboxes with the card as their label.',
|
|
102
|
+
'Use <section> with an aria-labelledby pointing to the title when the card is a landmark of the page.',
|
|
103
|
+
'The focus ring follows the active token on :focus-visible; the selected ring is separate and does not replace it.',
|
|
104
|
+
],
|
|
105
|
+
related: ['card-header', 'section-header', 'stat', 'list', 'table', 'divider'],
|
|
106
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { RESET_BUTTON, TRANSITION_COLORS, ex, typeStyle } from "./_shared.mjs";
|
|
2
|
+
// Carousel: a clipped viewport with a flex track of slides, 40px round prev/next
|
|
3
|
+
// controls (inside the viewport or in a row below it), dot or line indicators and
|
|
4
|
+
// an optional caption on a scrim. Static in the gallery; the app moves the track.
|
|
5
|
+
const CHEVRON_L = '<svg class="cn-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>';
|
|
6
|
+
const CHEVRON_R = '<svg class="cn-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="M6 4l4 4-4 4"/></svg>';
|
|
7
|
+
/** Muted 16:9 scenes so slides render without the network. */
|
|
8
|
+
const SCENES = [
|
|
9
|
+
`<circle cx="480" cy="104" r="40" style="fill:var(--cn-color-bg-surface)"/><path d="M0 300L150 168l110 92 100-130 280 170v60H0z" style="fill:var(--cn-color-border-strong)"/>`,
|
|
10
|
+
`<rect x="72" y="64" width="232" height="112" rx="12" style="fill:var(--cn-color-bg-surface)"/><rect x="336" y="64" width="232" height="112" rx="12" style="fill:var(--cn-color-bg-surface)"/><rect x="72" y="208" width="496" height="88" rx="12" style="fill:var(--cn-color-bg-surface)"/><path d="M96 272l60-28 70 18 66-40 80 22 96-44" fill="none" stroke-width="3" stroke-linecap="round" style="stroke:var(--cn-color-bg-action)"/>`,
|
|
11
|
+
`<rect x="200" y="72" width="240" height="216" rx="16" style="fill:var(--cn-color-bg-surface)"/><circle cx="320" cy="150" r="36" style="fill:var(--cn-color-border-strong)"/><rect x="248" y="212" width="144" height="12" rx="6" style="fill:var(--cn-color-border-default)"/><rect x="272" y="240" width="96" height="12" rx="6" style="fill:var(--cn-color-border-default)"/>`,
|
|
12
|
+
];
|
|
13
|
+
const scene = (i, label) => `<span class="cn-placeholder" data-radius="none"><svg viewBox="0 0 640 360" role="img" aria-label="${label}"><rect width="640" height="360" style="fill:var(--cn-color-bg-muted)"/>${SCENES[i % SCENES.length]}</svg></span>`;
|
|
14
|
+
const slide = (i, total, inner) => `<div class="cn-carousel__slide" role="group" aria-roledescription="slide" aria-label="${i} of ${total}">${inner}</div>`;
|
|
15
|
+
const controls = (disabledPrev = false) => `<div class="cn-carousel__controls"><button type="button" class="cn-carousel__control" aria-label="Previous slide"${disabledPrev ? ' disabled' : ''}>${CHEVRON_L}</button><button type="button" class="cn-carousel__control" aria-label="Next slide">${CHEVRON_R}</button></div>`;
|
|
16
|
+
const indicators = (n, current) => `<div class="cn-carousel__indicators" role="group" aria-label="Choose slide">${Array.from({ length: n }, (_, i) => `<button type="button" class="cn-carousel__indicator" aria-label="Go to slide ${i + 1}"${i + 1 === current ? ' aria-current="true"' : ''}></button>`).join('')}</div>`;
|
|
17
|
+
const root = (attrs, inner, width = '640px') => `<div class="cn-carousel" ${attrs} role="region" aria-roledescription="carousel" aria-label="Product tour" style="width:100%;max-width:${width}">${inner}</div>`;
|
|
18
|
+
const quote = (text, author, meta) => `<p class="cn-carousel__quote">${text}</p><span class="cn-carousel__author">${author}</span><span class="cn-carousel__meta">${meta}</span>`;
|
|
19
|
+
export const carousel = {
|
|
20
|
+
name: 'Carousel',
|
|
21
|
+
slug: 'carousel',
|
|
22
|
+
category: 'media',
|
|
23
|
+
description: 'A clipped viewport (radius card) over a flex track of slides, with 40px round prev/next controls that sit inside the picture or in a row under it, 8px dot or 24px line indicators, and an optional caption on a scrim. The component is static; the app moves the track.',
|
|
24
|
+
usage: 'Use for a small set of visual items where one at a time is enough: product screenshots, gallery photos, customer quotes, onboarding cards. Not for content people need to compare (use a grid), not for navigation, and never auto-playing text.',
|
|
25
|
+
anatomy: [
|
|
26
|
+
{ part: 'root', element: 'div', description: 'The region (role="region", aria-roledescription="carousel"). Relative; stacks the viewport and, for controls="outside", the control row.' },
|
|
27
|
+
{ part: 'viewport', element: 'div', description: 'Clips the track: overflow hidden, radius card, bg-subtle. Controls, indicators and caption overlay it.' },
|
|
28
|
+
{ part: 'track', element: 'div', description: 'Flex row of slides that the app translates (transform) to move between them.' },
|
|
29
|
+
{ part: 'slide', element: 'div', description: 'One item (role="group", aria-roledescription="slide", aria-label "2 of 5"). 100% wide in single, a third in multi, a centered quote card in testimonial.' },
|
|
30
|
+
{ part: 'controls', element: 'div', description: 'Holds the two control buttons: absolutely centered over the viewport edges (inside) or a static right-aligned row under it (outside).', optional: true },
|
|
31
|
+
{ part: 'control', element: 'button', description: '40px circle, bg-surface, hairline, shadow-md, 20px chevron. aria-label "Previous slide" / "Next slide"; disabled at the ends when not looping.', optional: true },
|
|
32
|
+
{ part: 'indicators', element: 'div', description: 'Centered row of indicator buttons 16px above the bottom edge (role="group").', optional: true },
|
|
33
|
+
{ part: 'indicator', element: 'button', description: '8px dot (or 24×4 line) in bg-muted; the current one is bg-action and carries aria-current="true".', optional: true },
|
|
34
|
+
{ part: 'caption', element: 'div', description: 'Bottom-left text over a bg-inverse scrim (color-mix 60%), body-sm in fg-inverse. Leaves room for the indicators.', optional: true },
|
|
35
|
+
{ part: 'quote', element: 'p', description: 'Testimonial text, heading-md, centered.', optional: true },
|
|
36
|
+
{ part: 'author', element: 'span', description: 'Who said it, label-md, under the quote.', optional: true },
|
|
37
|
+
{ part: 'meta', element: 'span', description: 'Role and company, body-sm muted.', optional: true },
|
|
38
|
+
],
|
|
39
|
+
props: {
|
|
40
|
+
variant: {
|
|
41
|
+
values: ['single', 'multi', 'testimonial'],
|
|
42
|
+
default: 'single',
|
|
43
|
+
description: 'single = one full-width slide at a time (screenshots, photos). multi = three cards visible with a 16px gap, the track scrolls by one card (feature cards, logos). testimonial = each slide is a centered quote card on bg-subtle with author and role.',
|
|
44
|
+
},
|
|
45
|
+
indicators: {
|
|
46
|
+
values: ['dots', 'lines', 'none'],
|
|
47
|
+
default: 'dots',
|
|
48
|
+
description: 'dots = 8px circles (up to about 8 slides). lines = 24×4 bars, for 2–5 slides on marketing pages. none = hide the indicators when the controls or the caption already tell the position.',
|
|
49
|
+
},
|
|
50
|
+
controls: {
|
|
51
|
+
values: ['inside', 'outside', 'none'],
|
|
52
|
+
default: 'inside',
|
|
53
|
+
description: 'inside = round buttons floating over the left and right edges of the picture (photos, screenshots). outside = a row of the same buttons under the viewport, right-aligned, so nothing covers the content (cards, quotes). none = swipe and indicators only (mobile, testimonials).',
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
states: {
|
|
57
|
+
current: { selector: ' .cn-carousel__indicator[aria-current="true"]', description: 'The indicator of the visible slide (on the indicator; extraCss): bg-action.', markup: 'aria-current="true" on .cn-carousel__indicator' },
|
|
58
|
+
controlHover: { selector: ' .cn-carousel__control:hover', description: 'Pointer over a control (extraCss): bg-subtle fill, stronger border.', markup: 'native :hover on .cn-carousel__control' },
|
|
59
|
+
controlFocus: { selector: ' .cn-carousel__control:focus-visible', description: 'Keyboard focus on a control adds the focus ring under the shadow (extraCss).', markup: 'native :focus-visible on .cn-carousel__control' },
|
|
60
|
+
controlDisabled: { selector: ' .cn-carousel__control:disabled', description: 'At the first or last slide of a non-looping carousel: 50% opacity, not-allowed (extraCss).', markup: 'disabled on .cn-carousel__control' },
|
|
61
|
+
},
|
|
62
|
+
base: {
|
|
63
|
+
root: { position: 'relative', display: 'flex', 'flex-direction': 'column', width: '100%', 'min-width': '0', color: '{color.fg-default}' },
|
|
64
|
+
viewport: { position: 'relative', overflow: 'hidden', width: '100%', 'border-radius': '{radius.card}', 'background-color': '{color.bg-subtle}' },
|
|
65
|
+
track: { display: 'flex', width: '100%', 'transition-property': 'transform', 'transition-duration': '{motion.duration.slow}', 'transition-timing-function': '{motion.easing.standard}' },
|
|
66
|
+
slide: { position: 'relative', flex: '0 0 100%', 'min-width': '0' },
|
|
67
|
+
controls: {
|
|
68
|
+
position: 'absolute',
|
|
69
|
+
'inset-inline': '{space.4}',
|
|
70
|
+
top: '50%',
|
|
71
|
+
transform: 'translateY(-50%)',
|
|
72
|
+
display: 'flex',
|
|
73
|
+
'justify-content': 'space-between',
|
|
74
|
+
'pointer-events': 'none',
|
|
75
|
+
'z-index': '{z.raised}',
|
|
76
|
+
},
|
|
77
|
+
control: {
|
|
78
|
+
...RESET_BUTTON,
|
|
79
|
+
'pointer-events': 'auto',
|
|
80
|
+
display: 'inline-flex',
|
|
81
|
+
'align-items': 'center',
|
|
82
|
+
'justify-content': 'center',
|
|
83
|
+
width: '{space.10}',
|
|
84
|
+
height: '{space.10}',
|
|
85
|
+
'border-radius': '{radius.full}',
|
|
86
|
+
'background-color': '{color.bg-surface}',
|
|
87
|
+
border: '{border.width.thin} solid {color.border-default}',
|
|
88
|
+
'box-shadow': '{shadow.md}',
|
|
89
|
+
color: '{color.fg-default}',
|
|
90
|
+
...TRANSITION_COLORS,
|
|
91
|
+
},
|
|
92
|
+
indicators: {
|
|
93
|
+
position: 'absolute',
|
|
94
|
+
'inset-inline': '0',
|
|
95
|
+
bottom: '{space.4}',
|
|
96
|
+
display: 'flex',
|
|
97
|
+
'justify-content': 'center',
|
|
98
|
+
'align-items': 'center',
|
|
99
|
+
gap: '{space.2}',
|
|
100
|
+
'pointer-events': 'none',
|
|
101
|
+
'z-index': '{z.raised}',
|
|
102
|
+
},
|
|
103
|
+
indicator: {
|
|
104
|
+
...RESET_BUTTON,
|
|
105
|
+
'pointer-events': 'auto',
|
|
106
|
+
width: '{space.2}',
|
|
107
|
+
height: '{space.2}',
|
|
108
|
+
'border-radius': '{radius.full}',
|
|
109
|
+
'background-color': '{color.bg-muted}',
|
|
110
|
+
...TRANSITION_COLORS,
|
|
111
|
+
},
|
|
112
|
+
caption: {
|
|
113
|
+
position: 'absolute',
|
|
114
|
+
'inset-inline': '0',
|
|
115
|
+
bottom: '0',
|
|
116
|
+
padding: '{space.4} {space.5}',
|
|
117
|
+
'background-color': 'color-mix(in srgb, {color.bg-inverse} 60%, transparent)',
|
|
118
|
+
color: '{color.fg-inverse}',
|
|
119
|
+
...typeStyle('body-sm'),
|
|
120
|
+
},
|
|
121
|
+
quote: { ...typeStyle('heading-md'), color: '{color.fg-default}', 'text-align': 'center', 'text-wrap': 'balance', 'max-width': '{size.container.sm}' },
|
|
122
|
+
author: { ...typeStyle('label-md'), color: '{color.fg-default}', 'margin-top': '{space.6}' },
|
|
123
|
+
meta: { ...typeStyle('body-sm'), color: '{color.fg-muted}', 'margin-top': '{space.1}' },
|
|
124
|
+
},
|
|
125
|
+
variants: {
|
|
126
|
+
variant: {
|
|
127
|
+
single: { slide: { flex: '0 0 100%' } },
|
|
128
|
+
multi: {
|
|
129
|
+
viewport: { 'border-radius': '{radius.none}', 'background-color': 'transparent' },
|
|
130
|
+
track: { gap: '{space.4}' },
|
|
131
|
+
slide: { flex: '0 0 calc((100% - 2 * {space.4}) / 3)', 'border-radius': '{radius.card}', overflow: 'hidden' },
|
|
132
|
+
},
|
|
133
|
+
testimonial: {
|
|
134
|
+
slide: { display: 'flex', 'flex-direction': 'column', 'align-items': 'center', 'justify-content': 'center', 'text-align': 'center', padding: '{space.10} {space.8} {space.12}', 'min-height': '280px' },
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
indicators: {
|
|
138
|
+
dots: { indicator: {} },
|
|
139
|
+
lines: { indicator: { width: '{space.6}', height: '{space.1}' } },
|
|
140
|
+
none: { indicators: { display: 'none' } },
|
|
141
|
+
},
|
|
142
|
+
controls: {
|
|
143
|
+
inside: { controls: {} },
|
|
144
|
+
outside: { controls: { position: 'static', transform: 'none', 'justify-content': 'flex-end', gap: '{space.3}', 'padding-top': '{space.4}', 'pointer-events': 'auto' } },
|
|
145
|
+
none: { controls: { display: 'none' } },
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
extraCss: `
|
|
149
|
+
.cn-carousel__control .cn-icon { width: {size.icon.lg}; height: {size.icon.lg}; }
|
|
150
|
+
.cn-carousel__control:hover:not(:disabled) { background-color: {color.bg-subtle}; border-color: {color.border-strong}; }
|
|
151
|
+
.cn-carousel__control:focus-visible { outline: none; box-shadow: {shadow.md}, {shadow.focus}; }
|
|
152
|
+
.cn-carousel__control:disabled { opacity: {opacity.disabled}; cursor: not-allowed; }
|
|
153
|
+
.cn-carousel__indicator[aria-current="true"] { background-color: {color.bg-action}; }
|
|
154
|
+
.cn-carousel__indicator:focus-visible { outline: none; box-shadow: {shadow.focus}; }
|
|
155
|
+
.cn-carousel:not([data-indicators="none"]) .cn-carousel__caption { padding-bottom: {space.9}; }
|
|
156
|
+
.cn-carousel__slide > .cn-placeholder { border-radius: 0; }`,
|
|
157
|
+
examples: [
|
|
158
|
+
ex('Single with caption', root('data-variant="single" data-indicators="dots" data-controls="inside"', `<div class="cn-carousel__viewport"><div class="cn-carousel__track">${slide(1, 3, scene(0, 'Lumen dashboard on a laptop'))}${slide(2, 3, scene(1, 'Reporting view'))}${slide(3, 3, scene(2, 'Mobile app'))}</div>${controls(true)}${indicators(3, 1)}<div class="cn-carousel__caption">Lumen Studio · the new reporting workspace, September 2026</div></div>`), 'Round controls float over the edges; the caption sits on a scrim and leaves room for the dots. Previous is disabled on the first slide.'),
|
|
159
|
+
ex('Multi (three cards) with outside controls', root('data-variant="multi" data-indicators="none" data-controls="outside"', `<div class="cn-carousel__viewport"><div class="cn-carousel__track">${slide(1, 5, scene(1, 'Dashboards'))}${slide(2, 5, scene(2, 'Approvals'))}${slide(3, 5, scene(0, 'Mobile'))}${slide(4, 5, scene(1, 'Integrations'))}${slide(5, 5, scene(2, 'Reports'))}</div></div>${controls()}`, '760px'), 'Three cards visible with a 16px gap; the controls sit under the row so nothing covers the cards.'),
|
|
160
|
+
ex('Testimonial', root('data-variant="testimonial" data-indicators="dots" data-controls="none"', `<div class="cn-carousel__viewport"><div class="cn-carousel__track">${slide(1, 3, quote('“We replaced four spreadsheets with one Lumen dashboard. The finance review went from a day to forty minutes.”', 'Elena Rossi', 'Head of Finance, Northwind Freight'))}${slide(2, 3, quote('“Approvals on the phone changed how our field team works.”', 'Tomás Silva', 'Operations Lead, Verde Farms'))}${slide(3, 3, quote('“Onboarding a new analyst takes an afternoon now.”', 'Aisha Khan', 'Data Lead, Orbit Health'))}</div>${indicators(3, 1)}</div>`), 'A centered quote card per slide; dots only, swipe to move.'),
|
|
161
|
+
ex('Lines, no controls', root('data-variant="single" data-indicators="lines" data-controls="none"', `<div class="cn-carousel__viewport"><div class="cn-carousel__track">${slide(1, 4, scene(2, 'Onboarding step 1'))}${slide(2, 4, scene(0, 'Onboarding step 2'))}${slide(3, 4, scene(1, 'Onboarding step 3'))}${slide(4, 4, scene(2, 'Onboarding step 4'))}</div>${indicators(4, 2)}</div>`, '480px'), '24×4 line indicators for a short onboarding sequence; the second slide is current.'),
|
|
162
|
+
],
|
|
163
|
+
rules: [
|
|
164
|
+
'Three to eight slides. With one there is nothing to move; with more than eight use a grid with pagination.',
|
|
165
|
+
'Never auto-play. If a marketing page insists, pause on hover and focus, stop after one loop, and offer a pause button.',
|
|
166
|
+
'Controls inside only over pictures with quiet edges; over text or cards use outside so nothing is covered.',
|
|
167
|
+
'Disable Previous on the first slide and Next on the last unless the carousel loops; show both buttons always.',
|
|
168
|
+
'Indicators are dots up to eight slides, lines for two to five on marketing pages; hide them when a caption or counter already shows the position.',
|
|
169
|
+
'Captions are one line of body-sm on the scrim, bottom-left; longer descriptions go under the carousel as text.',
|
|
170
|
+
'Slides keep one aspect ratio across the set (16:9 by default); never let the height jump between slides.',
|
|
171
|
+
'The viewport is the only element with overflow hidden; the track moves with transform, 260ms, and respects reduced motion.',
|
|
172
|
+
'On touch, swipe moves one slide; on keyboard, Left/Right move when the region has focus.',
|
|
173
|
+
],
|
|
174
|
+
a11y: [
|
|
175
|
+
'Root: role="region", aria-roledescription="carousel", aria-label naming the set. Slides: role="group", aria-roledescription="slide", aria-label "2 of 5".',
|
|
176
|
+
'Controls are real <button>s with aria-label "Previous slide" / "Next slide"; indicators are buttons with aria-label "Go to slide n" and aria-current="true" on the current one.',
|
|
177
|
+
'Hidden slides are aria-hidden="true" and their links get tabindex="-1" so keyboard users do not tab into off-screen content.',
|
|
178
|
+
'Announce slide changes with an aria-live="polite" region ("Slide 2 of 5") rather than moving focus.',
|
|
179
|
+
'Images need alt text or, for decorative art, alt=""; captions that describe the slide can serve as the accessible name via aria-labelledby.',
|
|
180
|
+
],
|
|
181
|
+
related: ['media-frame', 'video-player', 'card', 'icon-button', 'pagination'],
|
|
182
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { ex, ICON, typeStyle } from "./_shared.mjs";
|
|
2
|
+
// the reference chart card: white, gray-200 ring, shadow-xs, radius 12, 20 × 24px padding; an
|
|
3
|
+
// 18px semibold title with a 14px muted description, a legend of 8px round swatches with 14px
|
|
4
|
+
// gray-600 labels 12px apart, gray-100 gridlines and 12px gray-500 axis text.
|
|
5
|
+
const ring = (color) => `inset 0 0 0 1px ${color}`;
|
|
6
|
+
/** Grouped bars, 6 months × 2 series, drawn with token variables only. */
|
|
7
|
+
const MONTHS = ['Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep'];
|
|
8
|
+
const SIGNUPS = [42, 55, 38, 60, 47, 51];
|
|
9
|
+
const UPGRADES = [12, 19, 15, 24, 18, 21];
|
|
10
|
+
const BASE = 170, MAX = 60, H = 130;
|
|
11
|
+
const bars = MONTHS.map((m, i) => {
|
|
12
|
+
const cx = 32 + 73 * i + 36.5;
|
|
13
|
+
const hs = Math.round((SIGNUPS[i] / MAX) * H), hr = Math.round((UPGRADES[i] / MAX) * H);
|
|
14
|
+
return `<rect x="${cx - 22}" y="${BASE - hs}" width="20" height="${hs}" rx="2" fill="var(--cn-color-bg-action)"><title>${m} sign-ups: ${SIGNUPS[i]}</title></rect><rect x="${cx + 2}" y="${BASE - hr}" width="20" height="${hr}" rx="2" fill="var(--cn-brand-300)"><title>${m} upgrades: ${UPGRADES[i]}</title></rect><text x="${cx}" y="188" text-anchor="middle">${m}</text>`;
|
|
15
|
+
}).join('');
|
|
16
|
+
const grid = [0, 20, 40, 60].map((v) => { const y = Math.round(BASE - (v / MAX) * H); return `<line x1="32" x2="472" y1="${y}" y2="${y}" stroke="var(--cn-color-border-subtle)"/><text x="24" y="${y + 4}" text-anchor="end">${v}</text>`; }).join('');
|
|
17
|
+
const BAR_CHART = `<svg viewBox="0 0 480 200" width="100%" role="img" aria-label="Sign-ups and upgrades per month, last 6 months. Upgrades grew from 12 to 21." style="display:block;max-width:100%">${grid}${bars}</svg>`;
|
|
18
|
+
const SPARK = `<svg viewBox="0 0 320 80" width="100%" role="img" aria-label="Conversion rate, last 30 days: 3.2% to 4.2%" style="display:block;color:var(--cn-color-bg-action);max-width:100%"><defs><linearGradient id="cn-spark-fill" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="currentColor" stop-opacity="0.16"/><stop offset="1" stop-color="currentColor" stop-opacity="0"/></linearGradient></defs><path d="M0,62 L32,58 L64,60 L96,48 L128,52 L160,40 L192,44 L224,30 L256,34 L288,22 L320,18 V80 H0 Z" fill="url(#cn-spark-fill)"/><polyline points="0,62 32,58 64,60 96,48 128,52 160,40 192,44 224,30 256,34 288,22 320,18" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/><circle cx="320" cy="18" r="4" fill="var(--cn-color-bg-surface)" stroke="currentColor" stroke-width="2"/></svg>`;
|
|
19
|
+
const legend = (items) => `<div class="cn-chart-frame__legend">${items.map(([label, color]) => `<span class="cn-chart-frame__legend-item"><span class="cn-chart-frame__swatch" style="background:${color}"></span>${label}</span>`).join('')}</div>`;
|
|
20
|
+
export const chartFrame = {
|
|
21
|
+
name: 'ChartFrame',
|
|
22
|
+
slug: 'chart-frame',
|
|
23
|
+
category: 'data-display',
|
|
24
|
+
description: 'The card every chart lives in: an 18px semibold title and muted description on the left, a legend of round swatches on the right, a fixed-height body for the chart and a small footer line. White, gray-200 ring, shadow-xs, radius 12. The chart itself is your SVG or library output; the frame gives it the system\'s type, colors and spacing.',
|
|
25
|
+
usage: 'Wrap every chart (bars, lines, areas, donuts) in dashboards and reports. Not for single numbers (Stat) and not for tables of values (Table).',
|
|
26
|
+
anatomy: [
|
|
27
|
+
{ part: 'root', element: 'section', description: 'The card: white, gray-200 ring, shadow-xs, radius 12, 20 × 24px padding, column layout with 20px gaps.' },
|
|
28
|
+
{ part: 'header', element: 'header', description: 'Row: title + description on the left, legend (or a small control) on the right, 16px apart.' },
|
|
29
|
+
{ part: 'title', element: 'h3', description: 'What the chart shows, 18px semibold in ink, sentence case ("Sign-ups per month").' },
|
|
30
|
+
{ part: 'description', element: 'p', description: 'Optional one line: period, filter or definition, 14px fg-muted.', optional: true },
|
|
31
|
+
{ part: 'legend', element: 'div', description: 'Wrapping row of legend items, 12px apart, 14px fg-muted.', optional: true },
|
|
32
|
+
{ part: 'legend-item', element: 'span', description: 'Swatch + series name, 8px apart.', optional: true },
|
|
33
|
+
{ part: 'swatch', element: 'span', description: '8px round swatch with a 0.5px inner contrast ring, filled with the series color via an inline style using a token variable.', optional: true },
|
|
34
|
+
{ part: 'body', element: 'div', description: 'Where the chart goes. Position relative, min-height from size, the SVG fills the width; axis text is 12px fg-subtle. Also the place for an EmptyState or Skeleton.' },
|
|
35
|
+
{ part: 'footer', element: 'footer', description: 'Optional source or note, 12px fg-subtle ("Source: product analytics · Updated 2h ago").', optional: true },
|
|
36
|
+
],
|
|
37
|
+
props: {
|
|
38
|
+
size: {
|
|
39
|
+
values: ['sm', 'md', 'lg'],
|
|
40
|
+
default: 'md',
|
|
41
|
+
description: 'Minimum height of the body: sm 160px (sparklines and small multiples, 16 × 20px padding), md 240px (dashboard tiles), lg 320px (the main chart of a report page).',
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
states: {},
|
|
45
|
+
base: {
|
|
46
|
+
root: {
|
|
47
|
+
display: 'flex',
|
|
48
|
+
'flex-direction': 'column',
|
|
49
|
+
gap: '{space.5}',
|
|
50
|
+
'background-color': '{color.bg-surface}',
|
|
51
|
+
'box-shadow': `${ring('{color.border-default}')}, {shadow.xs}`,
|
|
52
|
+
'border-radius': '{radius.card}',
|
|
53
|
+
padding: '{space.5} {space.6}',
|
|
54
|
+
'min-width': '0',
|
|
55
|
+
color: '{color.fg-default}',
|
|
56
|
+
},
|
|
57
|
+
header: { display: 'flex', 'align-items': 'flex-start', 'justify-content': 'space-between', gap: '{space.4}', 'flex-wrap': 'wrap' },
|
|
58
|
+
title: { ...typeStyle('heading-sm'), color: '{color.fg-default}', margin: '0' },
|
|
59
|
+
description: { ...typeStyle('body-md'), color: '{color.fg-muted}', 'margin-top': '{space.0.5}' },
|
|
60
|
+
legend: { display: 'flex', 'flex-wrap': 'wrap', gap: '{space.1} {space.3}', ...typeStyle('body-md'), color: '{color.fg-muted}' },
|
|
61
|
+
'legend-item': { display: 'inline-flex', 'align-items': 'center', gap: '{space.2}', 'white-space': 'nowrap' },
|
|
62
|
+
swatch: { width: '{space.2}', height: '{space.2}', 'border-radius': '{radius.full}', 'flex-shrink': '0', 'background-color': '{color.bg-action}', 'box-shadow': 'inset 0 0 0 0.5px color-mix(in srgb, {black} 10%, transparent)' },
|
|
63
|
+
body: { position: 'relative', 'min-width': '0', 'min-height': 'calc({space.56} + {space.4})' },
|
|
64
|
+
footer: { ...typeStyle('body-sm'), color: '{color.fg-subtle}' },
|
|
65
|
+
},
|
|
66
|
+
variants: {
|
|
67
|
+
size: {
|
|
68
|
+
sm: { body: { 'min-height': '{space.40}' }, root: { padding: '{space.4} {space.5}', gap: '{space.4}' } },
|
|
69
|
+
md: { body: { 'min-height': 'calc({space.56} + {space.4})' } },
|
|
70
|
+
lg: { body: { 'min-height': 'calc({space.64} + {space.16})' } },
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
extraCss: `
|
|
74
|
+
.cn-chart-frame__body > svg { display: block; width: 100%; height: auto; }
|
|
75
|
+
.cn-chart-frame__body > svg text { font-family: {font.family.sans}; font-size: {font.size.xs}; fill: {color.fg-subtle}; }
|
|
76
|
+
.cn-chart-frame .cn-chart-frame__body[data-scroll] { overflow-x: auto; }
|
|
77
|
+
.cn-chart-frame__body[data-scroll] > svg { min-width: {size.container.xs}; }
|
|
78
|
+
.cn-chart-frame__header > .cn-stat { align-items: flex-end; }`,
|
|
79
|
+
examples: [
|
|
80
|
+
ex('Bars, two series', `<section class="cn-chart-frame" data-size="md" style="width:100%;max-width:560px"><header class="cn-chart-frame__header"><div><h3 class="cn-chart-frame__title">Sign-ups and upgrades</h3><p class="cn-chart-frame__description">Last 6 months · all plans</p></div>${legend([['Sign-ups', 'var(--cn-color-bg-action)'], ['Upgrades', 'var(--cn-brand-300)']])}</header><div class="cn-chart-frame__body" data-scroll role="region" tabindex="0" aria-label="Sign-ups and upgrades chart; scroll horizontally on small screens">${BAR_CHART}</div><footer class="cn-chart-frame__footer">Source: product analytics · Updated 2h ago</footer></section>`, 'Series 1 = brand-600, series 2 = brand-300. Gridlines gray-100, axis text 12px gray-500, round 8px swatches.'),
|
|
81
|
+
ex('Small sparkline with a Stat', `<section class="cn-chart-frame" data-size="sm" style="width:100%;max-width:320px"><header class="cn-chart-frame__header"><div><h3 class="cn-chart-frame__title">Conversion rate</h3><p class="cn-chart-frame__description">Last 30 days</p></div><div class="cn-stat" data-size="sm" data-variant="plain" data-trend="up"><div class="cn-stat__value">4.2%</div><div class="cn-stat__delta"><svg class="cn-stat__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 13V3M4 7l4-4 4 4"/></svg><span>1.0 pt</span></div></div></header><div class="cn-chart-frame__body">${SPARK}</div></section>`, 'A plain Stat in the header slot instead of a legend; the line is brand-600 with a faint fill and a hollow end dot.'),
|
|
82
|
+
ex('Large, no data', `<section class="cn-chart-frame" data-size="lg" style="width:100%;max-width:560px"><header class="cn-chart-frame__header"><div><h3 class="cn-chart-frame__title">Revenue by plan</h3><p class="cn-chart-frame__description">This quarter</p></div></header><div class="cn-chart-frame__body" style="display:flex;align-items:center;justify-content:center"><div class="cn-empty-state" data-size="sm" data-variant="default"><span class="cn-featured-icon cn-empty-state__icon" data-theme="modern" data-tone="gray" data-size="lg" data-shape="square">${ICON.inbox.replace('cn-icon', 'cn-featured-icon__icon')}</span><h3 class="cn-empty-state__title">No revenue this quarter yet</h3><p class="cn-empty-state__description">Revenue appears here once the first invoice is paid.</p></div></div></section>`, 'The body hosts an EmptyState (with a modern FeaturedIcon) when there is nothing to draw.'),
|
|
83
|
+
],
|
|
84
|
+
rules: [
|
|
85
|
+
'Series colors in order: 1 = brand-600 (bg-action), 2 = brand-300, 3 = gray-400. Semantic colors (success, warning, danger, info) only when the series means that state. Maximum 6 series.',
|
|
86
|
+
'Gridlines are gray-100 (border-subtle), axis text is 12px gray-500, no axis lines heavier than a hairline. No 3D, no gradients beyond a faint area fill, no shadows, bars with 2px radius.',
|
|
87
|
+
'The title lives in the frame header, never inside the SVG; the SVG carries only marks, axes and labels.',
|
|
88
|
+
'Every series in the legend (8px round swatches, 12px apart), every axis with units; never rely on color alone (order and labels carry the meaning).',
|
|
89
|
+
'The chart fills the body width. Reflow axes and marks for narrow screens; for a static SVG whose labels would become too small, use data-scroll on the body with a named, focusable scroll region. Keep the minimum height from size.',
|
|
90
|
+
'One chart per frame. Small multiples are several sm frames in a grid, not one frame with subplots.',
|
|
91
|
+
'No data or loading: put an EmptyState (sm) or a Skeleton in the body; keep the header so the tile does not jump.',
|
|
92
|
+
'Numbers on the chart use the same formatting as the rest of the page (locale, units).',
|
|
93
|
+
],
|
|
94
|
+
a11y: [
|
|
95
|
+
'The SVG has role="img" and an aria-label that states the takeaway ("Upgrades grew from 12 to 21"), not just the chart name.',
|
|
96
|
+
'Offer the data as a table (a "View as table" link Button in the footer or a visually hidden table) for screen readers and copy-paste.',
|
|
97
|
+
'Tooltips on marks are enhancements; <title> elements inside the SVG give hover text without JS.',
|
|
98
|
+
'Check series contrast against the actual surface in each supported theme. Use labels, patterns or outlines when a lighter series alone does not reach 3:1.',
|
|
99
|
+
],
|
|
100
|
+
related: ['stat', 'card', 'card-header', 'empty-state', 'skeleton', 'table'],
|
|
101
|
+
};
|