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,225 @@
|
|
|
1
|
+
import { ex, ICON, typeStyle } from "./_shared.mjs";
|
|
2
|
+
// the reference avatar: xxs 16 · xs 24 · sm 32 · md 40 · lg 48 · xl 56 · 2xl 64, semibold initials
|
|
3
|
+
// (12 → 24px) on gray-100, a 0.5px inner contrast ring, a status dot (6 → 16px) with a 1.5px
|
|
4
|
+
// surface ring, a blue verified tick (10 → 20px), a company-logo badge, a square (radius 6)
|
|
5
|
+
// shape for companies, and the avatar label group (name + email) next to it.
|
|
6
|
+
const SIZE = {
|
|
7
|
+
xxs: { box: '{space.4}', font: '{font.size.2xs}', icon: '{size.icon.xs}', dot: '{space.1}', tick: '{space.2}', badge: '{space.2}' },
|
|
8
|
+
xs: { box: '{space.6}', font: '{font.size.xs}', icon: '{size.icon.sm}', dot: '{space.1.5}', tick: '{space.2.5}', badge: '{space.2}' },
|
|
9
|
+
sm: { box: '{space.8}', font: '{font.size.sm}', icon: '{size.icon.md}', dot: '{space.2}', tick: '{space.3}', badge: '{space.3}' },
|
|
10
|
+
md: { box: '{space.10}', font: '{font.size.md}', icon: '{size.icon.lg}', dot: '{space.2.5}', tick: '{space.3.5}', badge: '{space.3.5}' },
|
|
11
|
+
lg: { box: '{space.12}', font: '{font.size.lg}', icon: '{size.icon.xl}', dot: '{space.3}', tick: '{space.4}', badge: '{space.4}' },
|
|
12
|
+
xl: { box: '{space.14}', font: '{font.size.xl}', icon: '{size.icon.2xl}', dot: '{space.3.5}', tick: '18px', badge: '18px' },
|
|
13
|
+
'2xl': { box: '{space.16}', font: '{font.size.2xl}', icon: '{size.icon.2xl}', dot: '{space.4}', tick: '{space.5}', badge: '{space.5}' },
|
|
14
|
+
};
|
|
15
|
+
const TONE = {
|
|
16
|
+
neutral: { bg: '{color.bg-muted}', fg: '{color.fg-subtle}' },
|
|
17
|
+
accent: { bg: '{color.bg-accent-subtle}', fg: '{color.fg-accent}' },
|
|
18
|
+
info: { bg: '{color.bg-info-subtle}', fg: '{color.fg-info}' },
|
|
19
|
+
success: { bg: '{color.bg-success-subtle}', fg: '{color.fg-success}' },
|
|
20
|
+
warning: { bg: '{color.bg-warning-subtle}', fg: '{color.fg-warning}' },
|
|
21
|
+
};
|
|
22
|
+
const STATUS = {
|
|
23
|
+
online: '{green.500}',
|
|
24
|
+
away: '{amber.500}',
|
|
25
|
+
busy: '{red.500}',
|
|
26
|
+
offline: '{color.border-control}',
|
|
27
|
+
};
|
|
28
|
+
/** 0.5px inner contrast ring and the 1.5px surface ring around dots and badges. */
|
|
29
|
+
const CONTRAST = 'inset 0 0 0 0.5px color-mix(in srgb, {black} 16%, transparent)';
|
|
30
|
+
const SURFACE_RING = '0 0 0 1.5px {color.bg-surface}';
|
|
31
|
+
/** Neutral silhouette so the gallery renders an image without the network. */
|
|
32
|
+
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";
|
|
33
|
+
const LOGO = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='16' fill='%23B4309F'/%3E%3Cpath d='M9 22V10h3.4l3.6 7.2L19.6 10H23v12h-3v-7l-3 6h-1l-3-6v7z' fill='%23fff'/%3E%3C/svg%3E";
|
|
34
|
+
const VERIFIED = `<svg class="cn-avatar__verified" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M8 0l1.9 1.6 2.4-.5.9 2.3 2.3.9-.5 2.4L16 8l-1.6 1.9.5 2.4-2.3.9-.9 2.3-2.4-.5L8 16l-1.9-1.6-2.4.5-.9-2.3-2.3-.9.5-2.4L0 8l1.6-1.9-.5-2.4 2.3-.9.9-2.3 2.4.5z"/><path d="M4.8 8.3l2.1 2.1 4.3-4.4" fill="none" stroke="var(--cn-color-fg-on-action)" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
35
|
+
const BUILDING = '<svg class="cn-avatar__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="M3 13.5V3.5a1 1 0 011-1h5a1 1 0 011 1v10M10 6.5h2a1 1 0 011 1v6M1.5 13.5h13M5.5 5h2M5.5 7.5h2M5.5 10h2"/></svg>';
|
|
36
|
+
const av = (size, inner, name, shape = 'circle', tone = 'neutral', status = 'none', extra = '') => `<span class="cn-avatar" data-size="${size}" data-shape="${shape}" data-tone="${tone}" data-status="${status}" role="img" aria-label="${name}"${extra}>${inner}${status !== 'none' ? '<span class="cn-avatar__status"></span>' : ''}</span>`;
|
|
37
|
+
const initials = (size, text, name, extra = '') => av(size, `<span class="cn-avatar__fallback" aria-hidden="true">${text}</span>`, name, 'circle', 'neutral', 'none', extra);
|
|
38
|
+
const photo = (size, name, status = 'none', extra = '') => `<span class="cn-avatar" data-size="${size}" data-shape="circle" data-tone="neutral" data-status="${status}"${extra}><img class="cn-avatar__image" src="${SILHOUETTE}" alt="${name}${status !== 'none' ? `, ${status}` : ''}">${status !== 'none' ? '<span class="cn-avatar__status"></span>' : ''}</span>`;
|
|
39
|
+
const labelGroup = (size, avatar, name, email) => `<div class="cn-avatar-label-group" data-size="${size}">${avatar}<div class="cn-avatar-label-group__text"><span class="cn-avatar-label-group__name">${name}</span><span class="cn-avatar-label-group__email">${email}</span></div></div>`;
|
|
40
|
+
export const avatar = {
|
|
41
|
+
name: 'Avatar',
|
|
42
|
+
slug: 'avatar',
|
|
43
|
+
category: 'data-display',
|
|
44
|
+
description: 'A person or company as a small picture in the reference\'s seven sizes (16 → 64px): photo when there is one, semibold initials on gray-100 or a placeholder icon when there is not, a 0.5px contrast ring, and optional status dot, verified tick or company badge on the corner. Circles are people; squares are companies.',
|
|
45
|
+
usage: 'Next to a name in tables, lists, comments, assignee fields, menus and headers; with the avatar label group when the name and email sit beside it. Use AvatarGroup for several people in one slot. Not a button: wrap it in a Button or Link when it opens something.',
|
|
46
|
+
anatomy: [
|
|
47
|
+
{ part: 'root', element: 'span', description: 'Sized box, position relative, overflow visible so the badges can sit on the edge; draws the 0.5px inner contrast ring. Carries role="img" + aria-label when showing initials. Add data-border for the 1px outer ring, tabindex or make it a button when focusable.' },
|
|
48
|
+
{ part: 'image', element: 'img', description: 'The photo, cropped with object-fit cover, clipped by the root radius, with the reference\'s faint 1px light rim at the top and bottom edge. Needs alt.', optional: true },
|
|
49
|
+
{ part: 'fallback', element: 'span', description: 'Initials (1–2 letters, semibold, 12 → 24px) centered on the gray-100 fill. Shown when there is no image.', optional: true },
|
|
50
|
+
{ part: 'icon', element: 'svg', description: 'Placeholder icon (a person, or a building for companies) inside the fallback: 12 → 32px, fg-subtle.', optional: true },
|
|
51
|
+
{ part: 'status', element: 'span', description: 'Presence dot at the bottom-right (6 → 16px) with a 1.5px surface ring so it reads on any photo. Hidden unless data-status is a value other than none.', optional: true },
|
|
52
|
+
{ part: 'verified', element: 'svg', description: 'The blue verified tick at the bottom-right (10 → 20px): a blue-500 seal with a white check.', optional: true },
|
|
53
|
+
{ part: 'badge', element: 'span', description: 'Company-logo badge at the bottom-right (8 → 20px): a round brand-50 tile with a 1.5px surface ring holding a small logo image.', optional: true },
|
|
54
|
+
],
|
|
55
|
+
props: {
|
|
56
|
+
size: {
|
|
57
|
+
values: ['xxs', 'xs', 'sm', 'md', 'lg', 'xl', '2xl'],
|
|
58
|
+
default: 'md',
|
|
59
|
+
description: 'Outer size: xxs 16px (inline in text, tags), xs 24px (dense tables, menus, kanban cards), sm 32px (lists, table rows), md 40px (default: account cards, comments, table rows with two lines), lg 48px (headers, drawers), xl 56px and 2xl 64px (profile and settings pages).',
|
|
60
|
+
},
|
|
61
|
+
shape: {
|
|
62
|
+
values: ['circle', 'square'],
|
|
63
|
+
default: 'circle',
|
|
64
|
+
description: 'circle = a person; square (radius 6) = a company, workspace or integration. Never mix shapes in one list.',
|
|
65
|
+
},
|
|
66
|
+
tone: {
|
|
67
|
+
values: ['neutral', 'accent', 'info', 'success', 'warning'],
|
|
68
|
+
default: 'neutral',
|
|
69
|
+
description: 'Tint of the initials fallback. neutral (gray-100 with gray-500 initials, the reference) for people; accent only for the product\'s own assistant; info/success/warning only when the entity itself has that state. Never assign colors per person.',
|
|
70
|
+
},
|
|
71
|
+
status: {
|
|
72
|
+
values: ['none', 'online', 'away', 'busy', 'offline'],
|
|
73
|
+
default: 'none',
|
|
74
|
+
description: 'Presence dot: none hides it (default); online = green-500, away = amber, busy = red, offline = gray-300. Only for real-time presence.',
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
states: {
|
|
78
|
+
focus: { selector: ':focus-visible', description: 'Keyboard focus when the avatar is a button or link (account menu trigger): the 4px brand ring.', markup: 'make the avatar a <button> or <a>, or add tabindex="0"' },
|
|
79
|
+
},
|
|
80
|
+
base: {
|
|
81
|
+
root: {
|
|
82
|
+
display: 'inline-flex',
|
|
83
|
+
position: 'relative',
|
|
84
|
+
'flex-shrink': '0',
|
|
85
|
+
'vertical-align': 'middle',
|
|
86
|
+
overflow: 'visible',
|
|
87
|
+
'border-radius': '{radius.full}',
|
|
88
|
+
'background-color': '{color.bg-muted}',
|
|
89
|
+
},
|
|
90
|
+
image: {
|
|
91
|
+
display: 'block',
|
|
92
|
+
width: '100%',
|
|
93
|
+
height: '100%',
|
|
94
|
+
'object-fit': 'cover',
|
|
95
|
+
'border-radius': 'inherit',
|
|
96
|
+
overflow: 'hidden',
|
|
97
|
+
},
|
|
98
|
+
fallback: {
|
|
99
|
+
display: 'flex',
|
|
100
|
+
'align-items': 'center',
|
|
101
|
+
'justify-content': 'center',
|
|
102
|
+
width: '100%',
|
|
103
|
+
height: '100%',
|
|
104
|
+
'border-radius': 'inherit',
|
|
105
|
+
overflow: 'hidden',
|
|
106
|
+
'background-color': '{color.bg-muted}',
|
|
107
|
+
color: '{color.fg-subtle}',
|
|
108
|
+
...typeStyle('label-md'),
|
|
109
|
+
'line-height': '1',
|
|
110
|
+
'user-select': 'none',
|
|
111
|
+
},
|
|
112
|
+
icon: { display: 'block', color: '{color.fg-subtle}', 'stroke-width': '2' },
|
|
113
|
+
status: {
|
|
114
|
+
display: 'none',
|
|
115
|
+
position: 'absolute',
|
|
116
|
+
bottom: '0',
|
|
117
|
+
right: '0',
|
|
118
|
+
'border-radius': '{radius.full}',
|
|
119
|
+
'box-shadow': SURFACE_RING,
|
|
120
|
+
'background-color': '{color.border-control}',
|
|
121
|
+
'z-index': '{z.raised}',
|
|
122
|
+
},
|
|
123
|
+
verified: {
|
|
124
|
+
position: 'absolute',
|
|
125
|
+
bottom: '0',
|
|
126
|
+
right: '0',
|
|
127
|
+
display: 'block',
|
|
128
|
+
color: '{blue.500}',
|
|
129
|
+
'z-index': '{z.raised}',
|
|
130
|
+
},
|
|
131
|
+
badge: {
|
|
132
|
+
position: 'absolute',
|
|
133
|
+
bottom: '-2px',
|
|
134
|
+
right: '-2px',
|
|
135
|
+
display: 'flex',
|
|
136
|
+
'align-items': 'center',
|
|
137
|
+
'justify-content': 'center',
|
|
138
|
+
overflow: 'hidden',
|
|
139
|
+
'border-radius': '{radius.full}',
|
|
140
|
+
'background-color': '{brand.50}',
|
|
141
|
+
'box-shadow': SURFACE_RING,
|
|
142
|
+
'z-index': '{z.raised}',
|
|
143
|
+
},
|
|
144
|
+
'@states': {
|
|
145
|
+
focus: { root: { outline: 'none', 'box-shadow': '{shadow.focus}' } },
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
variants: {
|
|
149
|
+
size: Object.fromEntries(Object.entries(SIZE).map(([s, v]) => [s, {
|
|
150
|
+
root: { width: v.box, height: v.box },
|
|
151
|
+
fallback: { 'font-size': v.font },
|
|
152
|
+
icon: { width: v.icon, height: v.icon },
|
|
153
|
+
status: { width: v.dot, height: v.dot },
|
|
154
|
+
verified: { width: v.tick, height: v.tick },
|
|
155
|
+
badge: { width: v.badge, height: v.badge },
|
|
156
|
+
}])),
|
|
157
|
+
shape: {
|
|
158
|
+
circle: { root: { 'border-radius': '{radius.full}' } },
|
|
159
|
+
square: { root: { 'border-radius': '{radius.md}' }, status: { transform: 'translate(25%, 25%)' } },
|
|
160
|
+
},
|
|
161
|
+
tone: Object.fromEntries(Object.entries(TONE).map(([t, v]) => [t, { root: { 'background-color': v.bg }, fallback: { 'background-color': v.bg, color: v.fg } }])),
|
|
162
|
+
status: {
|
|
163
|
+
none: { status: { display: 'none' } },
|
|
164
|
+
...Object.fromEntries(Object.entries(STATUS).map(([s, c]) => [s, { status: { display: 'block', 'background-color': c } }])),
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
compound: [
|
|
168
|
+
{ when: { size: 'xxs', shape: 'square' }, block: { root: { 'border-radius': '{radius.sm}' } } },
|
|
169
|
+
{ when: { size: 'xs', shape: 'square' }, block: { root: { 'border-radius': '{radius.sm}' } } },
|
|
170
|
+
],
|
|
171
|
+
extraCss: `
|
|
172
|
+
.cn-avatar::after { content: ""; position: absolute; inset: 0; border-radius: inherit; box-shadow: ${CONTRAST}; pointer-events: none; }
|
|
173
|
+
.cn-avatar:has(> .cn-avatar__image)::before { content: ""; position: absolute; inset: 0; border-radius: inherit; border: 1px solid color-mix(in srgb, {white} 32%, transparent); -webkit-mask-image: linear-gradient(to bottom, black 0%, transparent 25%, transparent 75%, black 100%); mask-image: linear-gradient(to bottom, black 0%, transparent 25%, transparent 75%, black 100%); pointer-events: none; }
|
|
174
|
+
.cn-avatar[data-size="xxs"]::before, .cn-avatar[data-size="xs"]::before { display: none; }
|
|
175
|
+
.cn-avatar[data-size="xs"] .cn-avatar__verified, .cn-avatar[data-size="xxs"] .cn-avatar__verified { right: -1px; bottom: -1px; }
|
|
176
|
+
.cn-avatar[data-size="xxs"] .cn-avatar__fallback { font-size: 8px; }
|
|
177
|
+
.cn-avatar[data-border] { outline: 1px solid color-mix(in srgb, {black} 10%, transparent); outline-offset: 1px; }
|
|
178
|
+
.cn-avatar[data-size="lg"][data-border], .cn-avatar[data-size="xl"][data-border], .cn-avatar[data-size="2xl"][data-border] { outline-offset: 2px; }
|
|
179
|
+
.cn-avatar__badge > img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
|
|
180
|
+
button.cn-avatar, a.cn-avatar { appearance: none; border: 0; padding: 0; margin: 0; cursor: pointer; }
|
|
181
|
+
.cn-avatar-label-group { display: inline-flex; align-items: center; gap: {space.3}; min-width: 0; vertical-align: middle; }
|
|
182
|
+
.cn-avatar-label-group__text { display: flex; flex-direction: column; min-width: 0; }
|
|
183
|
+
.cn-avatar-label-group__name { font-size: {font.size.sm}; font-weight: {font.weight.medium}; line-height: {font.lineHeight.normal}; color: {color.fg-default}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
184
|
+
.cn-avatar-label-group__email { font-size: {font.size.xs}; line-height: {font.lineHeight.normal}; color: {color.fg-muted}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
185
|
+
.cn-avatar-label-group[data-size="sm"] { gap: {space.2.5}; }
|
|
186
|
+
.cn-avatar-label-group[data-size="md"] .cn-avatar-label-group__email { font-size: {font.size.sm}; }
|
|
187
|
+
.cn-avatar-label-group[data-size="lg"] { gap: {space.4}; }
|
|
188
|
+
.cn-avatar-label-group[data-size="lg"] .cn-avatar-label-group__name { font-size: {font.size.md}; font-weight: {font.weight.semibold}; }
|
|
189
|
+
.cn-avatar-label-group[data-size="lg"] .cn-avatar-label-group__email { font-size: {font.size.sm}; }
|
|
190
|
+
.cn-avatar-add { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; appearance: none; margin: 0; padding: 0; width: {space.10}; height: {space.10}; border-radius: {radius.full}; border: 1px dashed {color.border-control}; background-color: {color.bg-surface}; color: {color.fg-subtle}; cursor: pointer; transition: background-color {motion.duration.fast} {motion.easing.linear}, color {motion.duration.fast} {motion.easing.linear}; }
|
|
191
|
+
.cn-avatar-add > .cn-icon { width: {size.icon.md}; height: {size.icon.md}; }
|
|
192
|
+
.cn-avatar-add:hover { background-color: {color.bg-subtle}; color: {color.fg-muted}; }
|
|
193
|
+
.cn-avatar-add:focus-visible { outline: none; box-shadow: {shadow.focus}; }
|
|
194
|
+
.cn-avatar-add:disabled { opacity: {opacity.disabled}; cursor: not-allowed; }
|
|
195
|
+
.cn-avatar-add[data-size="xs"] { width: {space.6}; height: {space.6}; } .cn-avatar-add[data-size="xs"] > .cn-icon { width: {size.icon.sm}; height: {size.icon.sm}; }
|
|
196
|
+
.cn-avatar-add[data-size="sm"] { width: {space.8}; height: {space.8}; } .cn-avatar-add[data-size="sm"] > .cn-icon { width: {size.icon.sm}; height: {size.icon.sm}; }`,
|
|
197
|
+
examples: [
|
|
198
|
+
ex('Sizes', `<div style="display:flex;flex-wrap:wrap;align-items:flex-end;gap:var(--cn-space-4)">${initials('xxs', 'M', 'Maya Chen')}${initials('xs', 'MC', 'Maya Chen')}${initials('sm', 'MC', 'Maya Chen')}${initials('md', 'MC', 'Maya Chen')}${initials('lg', 'MC', 'Maya Chen')}${initials('xl', 'MC', 'Maya Chen')}${initials('2xl', 'MC', 'Maya Chen')}</div>`, 'xxs 16 · xs 24 · sm 32 · md 40 · lg 48 · xl 56 · 2xl 64. Semibold initials on gray-100 with the 0.5px contrast ring.'),
|
|
199
|
+
ex('Photo, border and status', `<div style="display:flex;flex-wrap:wrap;align-items:center;gap:var(--cn-space-4)">${photo('md', 'Daniel Costa')}${photo('md', 'Daniel Costa', 'none', ' data-border')}${photo('md', 'Sofia Almeida', 'online')}${photo('md', 'Lucas Ferreira', 'offline')}${photo('md', 'Aisha Khan', 'away')}${photo('md', 'Noah Berg', 'busy')}</div>`, 'The photo gets a faint light rim; data-border adds the 1px outer ring. Dots: online green, offline gray-300, away amber, busy red, each with a 1.5px surface ring.'),
|
|
200
|
+
ex('Status dot per size', `<div style="display:flex;flex-wrap:wrap;align-items:flex-end;gap:var(--cn-space-4)">${photo('xs', 'Maya Chen', 'online')}${photo('sm', 'Maya Chen', 'online')}${photo('md', 'Maya Chen', 'online')}${photo('lg', 'Maya Chen', 'online')}${photo('xl', 'Maya Chen', 'online')}${photo('2xl', 'Maya Chen', 'online')}</div>`, '6 · 8 · 10 · 12 · 14 · 16px dots.'),
|
|
201
|
+
ex('Verified tick', `<div style="display:flex;flex-wrap:wrap;align-items:flex-end;gap:var(--cn-space-4)">${av('xs', `<img class="cn-avatar__image" src="${SILHOUETTE}" alt="">${VERIFIED}`, 'Elena Rossi, verified')}${av('sm', `<img class="cn-avatar__image" src="${SILHOUETTE}" alt="">${VERIFIED}`, 'Elena Rossi, verified')}${av('md', `<img class="cn-avatar__image" src="${SILHOUETTE}" alt="">${VERIFIED}`, 'Elena Rossi, verified')}${av('lg', `<img class="cn-avatar__image" src="${SILHOUETTE}" alt="">${VERIFIED}`, 'Elena Rossi, verified')}${av('xl', `<img class="cn-avatar__image" src="${SILHOUETTE}" alt="">${VERIFIED}`, 'Elena Rossi, verified')}${av('2xl', `<img class="cn-avatar__image" src="${SILHOUETTE}" alt="">${VERIFIED}`, 'Elena Rossi, verified')}</div>`, 'The blue-500 seal with a white check, 10 → 20px, bottom-right.'),
|
|
202
|
+
ex('Company badge and placeholders', `<div style="display:flex;flex-wrap:wrap;align-items:center;gap:var(--cn-space-4)">${av('md', `<img class="cn-avatar__image" src="${SILHOUETTE}" alt=""><span class="cn-avatar__badge"><img src="${LOGO}" alt=""></span>`, 'Tomás Silva at Lumen')}${av('md', `<span class="cn-avatar__fallback" aria-hidden="true">${ICON.user.replace('cn-icon', 'cn-avatar__icon')}</span>`, 'Unknown person')}${av('md', `<span class="cn-avatar__fallback" aria-hidden="true">${BUILDING}</span>`, 'Lumen', 'square')}${av('md', `<span class="cn-avatar__fallback" aria-hidden="true">LU</span>`, 'Lumen', 'square')}${av('lg', `<img class="cn-avatar__image" src="${LOGO}" alt="Lumen">`, 'Lumen', 'square')}</div>`, 'A 14px company badge on a person; the person placeholder icon; the square company placeholder (building icon or initials) and a square logo.'),
|
|
203
|
+
ex('Label group', `<div style="display:flex;flex-direction:column;align-items:flex-start;gap:var(--cn-space-5)">${labelGroup('sm', photo('sm', 'Maya Chen', 'online'), 'Maya Chen', 'maya@lumen.co')}${labelGroup('md', photo('md', 'Daniel Costa', 'online'), 'Daniel Costa', 'daniel@lumen.co')}${labelGroup('lg', initials('lg', 'SA', 'Sofia Almeida'), 'Sofia Almeida', 'sofia@lumen.co')}</div>`, 'The avatar label group recipe: avatar + name (14px medium) + email (fg-muted); data-size sm / md / lg sets the gap and text sizes.'),
|
|
204
|
+
ex('Tones and the focusable trigger', `<div style="display:flex;flex-wrap:wrap;align-items:center;gap:var(--cn-space-4)">${initials('md', 'MC', 'Maya Chen')}${av('md', '<span class="cn-avatar__fallback" aria-hidden="true">AI</span>', 'Lumen assistant', 'circle', 'accent')}${av('md', '<span class="cn-avatar__fallback" aria-hidden="true">NC</span>', 'New contact', 'circle', 'info')}${av('md', '<span class="cn-avatar__fallback" aria-hidden="true">VB</span>', 'Verified buyer', 'circle', 'success')}${av('md', '<span class="cn-avatar__fallback" aria-hidden="true">PR</span>', 'Pending review', 'circle', 'warning')}<button type="button" class="cn-avatar" data-size="md" data-shape="circle" data-tone="neutral" data-status="none" aria-label="Open account menu" aria-haspopup="menu"><span class="cn-avatar__fallback" aria-hidden="true">MC</span></button><button type="button" class="cn-avatar-add" aria-label="Add teammate">${ICON.plus}</button></div>`, 'neutral for people; the other tones only when the entity has that state. A button avatar gets the focus ring; .cn-avatar-add is the dashed add button.'),
|
|
205
|
+
],
|
|
206
|
+
rules: [
|
|
207
|
+
'Circle = a person. Square (radius 6) = a company, a workspace or an integration. One shape per list.',
|
|
208
|
+
'Initials: first + last name (2 letters) from xs up; a single letter at xxs. Semibold, gray-500 on gray-100.',
|
|
209
|
+
'Fallback tone is neutral for people. accent is reserved for the product\'s assistant. Never give people random colors.',
|
|
210
|
+
'Show the status dot only for real-time presence that matters; never as decoration; data-status="none" otherwise. The verified tick and the company badge are mutually exclusive with the dot.',
|
|
211
|
+
'Photos are cropped with cover, never stretched; the only frame is data-border (1px outer ring) on profile pages.',
|
|
212
|
+
'Sizes follow the slot: xxs inline in text and tags, xs in dense tables and menus, sm in list rows, md in account cards and 72px table rows, lg in headers and drawers, xl / 2xl on profile pages.',
|
|
213
|
+
'Next to a name and email use the avatar label group (.cn-avatar-label-group) so the text sizes and gaps match the avatar size.',
|
|
214
|
+
'The avatar is not a control unless it is the account menu trigger: then it is a <button aria-haspopup="menu"> and gets the focus ring; do not add hover styles.',
|
|
215
|
+
'Always pair with the name in text nearby except in tight groups; the picture is never the only identification.',
|
|
216
|
+
],
|
|
217
|
+
a11y: [
|
|
218
|
+
'Photo: alt is the person\'s name when the name is not adjacent text; alt="" when it is, to avoid reading the name twice.',
|
|
219
|
+
'Initials: put role="img" and aria-label="Full name" on the root and aria-hidden="true" on the initials span.',
|
|
220
|
+
'Presence and verification must be available as text: include them in the alt or aria-label ("Maya Chen, online", "Elena Rossi, verified"). The dot and tick alone are not accessible.',
|
|
221
|
+
'When clickable, the avatar is a <button> or <a> with an aria-label and shows the focus ring; a wrapping Link works too.',
|
|
222
|
+
'The add button (.cn-avatar-add) needs aria-label ("Add teammate").',
|
|
223
|
+
],
|
|
224
|
+
related: ['avatar-group', 'badge', 'list', 'table', 'menu', 'kanban'],
|
|
225
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { ex, ICON, typeStyle } from "./_shared.mjs";
|
|
2
|
+
const TONES = { brand: ['{color.bg-accent-subtle}', '{color.fg-accent}', '{brand.200}'], gray: ['{color.bg-subtle}', '{color.fg-muted}', '{color.border-default}'], success: ['{color.bg-success-subtle}', '{color.fg-success}', '{green.200}'], warning: ['{color.bg-warning-subtle}', '{color.fg-warning}', '{amber.200}'], error: ['{color.bg-danger-subtle}', '{color.fg-danger}', '{red.200}'] };
|
|
3
|
+
export const badgeGroup = {
|
|
4
|
+
name: 'BadgeGroup',
|
|
5
|
+
slug: 'badge-group',
|
|
6
|
+
category: 'data-display',
|
|
7
|
+
description: 'A pill that holds a small inner badge plus a message and an arrow: the "badge group" used for announcements above hero titles ("New feature · Check out the team dashboard →").',
|
|
8
|
+
usage: 'Announcements, release notes and "what is new" links above a page or hero title. One per page. Not for status in tables (use Badge).',
|
|
9
|
+
anatomy: [
|
|
10
|
+
{ part: 'root', element: 'a', description: 'The outer pill. A link when it navigates.' },
|
|
11
|
+
{ part: 'badge', element: 'span', description: 'The inner pill (e.g. "New feature").' },
|
|
12
|
+
{ part: 'label', element: 'span', description: 'The message.' },
|
|
13
|
+
{ part: 'icon', element: 'svg', description: 'Trailing arrow, 16px.' },
|
|
14
|
+
],
|
|
15
|
+
props: {
|
|
16
|
+
tone: { values: ['brand', 'gray', 'success', 'warning', 'error'], default: 'brand', description: 'Color family of the group.' },
|
|
17
|
+
theme: { values: ['light', 'modern'], default: 'light', description: 'light = tinted outer pill with white inner badge; modern = white outer pill with grey ring and a tinted inner badge.' },
|
|
18
|
+
size: { values: ['sm', 'md', 'lg'], default: 'md', description: 'Outer pill 24 / 28 / 32px; text 12 / 14 / 14.' },
|
|
19
|
+
align: { values: ['leading', 'trailing'], default: 'leading', description: 'leading = inner badge first; trailing = inner badge after the message.' },
|
|
20
|
+
},
|
|
21
|
+
states: { hover: { selector: ':hover', description: 'Slightly stronger fill; arrow moves 2px.', markup: 'native' }, focus: { selector: ':focus-visible', description: 'Focus ring.', markup: 'native' } },
|
|
22
|
+
base: {
|
|
23
|
+
root: { display: 'inline-flex', 'align-items': 'center', gap: '{space.2}', 'border-radius': '{radius.full}', border: '{border.width.thin} solid transparent', 'padding-inline': '{space.1} {space.2.5}', ...typeStyle('label-sm'), 'text-decoration': 'none', 'min-width': '0', 'max-width': '100%', 'padding-block': '{space.0.5}', cursor: 'pointer', 'transition-property': 'background-color, border-color', 'transition-duration': '{motion.duration.fast}', 'transition-timing-function': '{motion.easing.standard}' },
|
|
24
|
+
badge: { 'flex-shrink': '0', 'white-space': 'nowrap', display: 'inline-flex', 'align-items': 'center', height: '22px', 'padding-inline': '{space.2}', 'border-radius': '{radius.full}', border: '{border.width.thin} solid transparent', 'font-size': '{font.size.xs}', 'font-weight': '{font.weight.medium}', 'line-height': '1' },
|
|
25
|
+
label: { display: 'inline-block', 'min-width': '0', 'overflow-wrap': 'anywhere' },
|
|
26
|
+
icon: { width: '{size.icon.sm}', height: '{size.icon.sm}', 'flex-shrink': '0', transition: 'transform {motion.duration.fast} {motion.easing.standard}' },
|
|
27
|
+
'@states': { focus: { root: { outline: 'none', 'box-shadow': '{shadow.focus}' } }, hover: { icon: { transform: 'translateX(2px)' } } },
|
|
28
|
+
},
|
|
29
|
+
variants: {
|
|
30
|
+
tone: Object.fromEntries(Object.keys(TONES).map((t) => [t, { root: {} }])),
|
|
31
|
+
theme: { light: { root: {} }, modern: { root: { 'background-color': '{color.bg-surface}', 'border-color': '{color.border-control}', color: '{color.fg-muted}', 'box-shadow': '{shadow.xs}' } } },
|
|
32
|
+
size: {
|
|
33
|
+
sm: { root: { 'min-height': '24px', 'font-size': '{font.size.xs}' }, badge: { height: '18px', 'padding-inline': '{space.1.5}' } },
|
|
34
|
+
md: { root: { 'min-height': '28px' }, badge: { height: '22px' } },
|
|
35
|
+
lg: { root: { 'min-height': '32px', 'padding-inline': '{space.1} {space.3}' }, badge: { height: '24px', 'font-size': '{font.size.sm}' } },
|
|
36
|
+
},
|
|
37
|
+
align: { leading: { root: {} }, trailing: { root: { 'flex-direction': 'row-reverse', 'padding-inline': '{space.2.5} {space.1}' } } },
|
|
38
|
+
},
|
|
39
|
+
compound: Object.entries(TONES).flatMap(([t, [bg, fg, border]]) => [
|
|
40
|
+
{ when: { tone: t, theme: 'light' }, block: { root: { 'background-color': bg, color: fg, 'border-color': border }, badge: { 'background-color': '{color.bg-surface}', color: fg, 'border-color': border }, '@states': { hover: { root: { 'border-color': fg } } } } },
|
|
41
|
+
{ when: { tone: t, theme: 'modern' }, block: { badge: { 'background-color': bg, color: fg, 'border-color': border } } },
|
|
42
|
+
]),
|
|
43
|
+
extraCss: `
|
|
44
|
+
.cn-badge-group[data-align="trailing"] .cn-badge-group__icon { order: -1; transform: none; }
|
|
45
|
+
.cn-badge-group[data-align="trailing"]:hover .cn-badge-group__icon { transform: translateX(-2px); }`,
|
|
46
|
+
examples: [
|
|
47
|
+
ex('Light, leading (default)', `<a href="#" class="cn-badge-group" data-tone="brand" data-theme="light" data-size="md" data-align="leading"><span class="cn-badge-group__badge">New feature</span><span class="cn-badge-group__label">Check out the team dashboard</span>${ICON.arrow.replace('cn-icon', 'cn-badge-group__icon')}</a>`),
|
|
48
|
+
ex('Modern', `<a href="#" class="cn-badge-group" data-tone="brand" data-theme="modern" data-size="md" data-align="leading"><span class="cn-badge-group__badge">We're hiring!</span><span class="cn-badge-group__label">Join our remote team</span>${ICON.arrow.replace('cn-icon', 'cn-badge-group__icon')}</a>`),
|
|
49
|
+
ex('Tones', `<a href="#" class="cn-badge-group" data-tone="gray" data-theme="light" data-size="md" data-align="leading"><span class="cn-badge-group__badge">Update</span><span class="cn-badge-group__label">Version 4.2 is live</span>${ICON.arrow.replace('cn-icon', 'cn-badge-group__icon')}</a> <a href="#" class="cn-badge-group" data-tone="success" data-theme="light" data-size="md" data-align="leading"><span class="cn-badge-group__badge">Resolved</span><span class="cn-badge-group__label">All systems normal</span>${ICON.arrow.replace('cn-icon', 'cn-badge-group__icon')}</a> <a href="#" class="cn-badge-group" data-tone="error" data-theme="light" data-size="md" data-align="leading"><span class="cn-badge-group__badge">Incident</span><span class="cn-badge-group__label">API latency degraded</span>${ICON.arrow.replace('cn-icon', 'cn-badge-group__icon')}</a>`),
|
|
50
|
+
ex('Trailing and sizes', `<a href="#" class="cn-badge-group" data-tone="brand" data-theme="light" data-size="sm" data-align="trailing"><span class="cn-badge-group__badge">New</span><span class="cn-badge-group__label">Read the changelog</span>${ICON.arrow.replace('cn-icon', 'cn-badge-group__icon')}</a> <a href="#" class="cn-badge-group" data-tone="brand" data-theme="light" data-size="lg" data-align="leading"><span class="cn-badge-group__badge">New feature</span><span class="cn-badge-group__label">Check out the team dashboard</span>${ICON.arrow.replace('cn-icon', 'cn-badge-group__icon')}</a>`),
|
|
51
|
+
],
|
|
52
|
+
rules: ['One badge group per page, placed above the hero or page title with 16–24px below it.', 'Inner badge: 1–2 words; label: one short sentence; always ends with the arrow when it links.', 'the system equivalent: badge group (light / modern; leading / trailing; sm–lg).'],
|
|
53
|
+
a11y: ['When it navigates it is an <a href>; otherwise a <span> without hover affordance.', 'The inner badge text is part of the link name; do not hide it.'],
|
|
54
|
+
related: ['badge', 'kicker', 'banner'],
|
|
55
|
+
};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { RESET_BUTTON, TRANSITION_COLORS, ex, ICON, typeStyle } from "./_shared.mjs";
|
|
2
|
+
const mix = (a, b, pct) => `color-mix(in srgb, ${a} ${pct}%, ${b})`;
|
|
3
|
+
const scale = (s) => ({ bg: `{${s}.50}`, fg: `{${s}.700}`, ring: `{${s}.200}`, addon: `{${s}.500}`, solidBg: `{${s}.600}`, solidFg: '{white}' });
|
|
4
|
+
/** Hues our primitives do not carry (gray-blue, indigo, pink, orange) are mixed from two neighbouring scales. */
|
|
5
|
+
const mixed = (a, b, pct) => ({
|
|
6
|
+
bg: mix(`{${a}.50}`, `{${b}.50}`, pct),
|
|
7
|
+
fg: mix(`{${a}.700}`, `{${b}.700}`, pct),
|
|
8
|
+
ring: mix(`{${a}.200}`, `{${b}.200}`, pct),
|
|
9
|
+
addon: mix(`{${a}.500}`, `{${b}.500}`, pct),
|
|
10
|
+
solidBg: mix(`{${a}.600}`, `{${b}.600}`, pct),
|
|
11
|
+
solidFg: '{white}',
|
|
12
|
+
});
|
|
13
|
+
const TONES = {
|
|
14
|
+
neutral: { bg: '{color.bg-subtle}', fg: '{color.fg-muted}', ring: '{color.border-default}', addon: '{color.fg-subtle}', solidBg: '{color.bg-inverse}', solidFg: '{color.fg-inverse}' },
|
|
15
|
+
accent: scale('brand'),
|
|
16
|
+
success: scale('green'),
|
|
17
|
+
warning: scale('amber'),
|
|
18
|
+
danger: scale('red'),
|
|
19
|
+
info: scale('blue'),
|
|
20
|
+
'gray-blue': mixed('neutral', 'blue', 72),
|
|
21
|
+
'blue-light': { bg: '{blue.50}', fg: '{blue.600}', ring: '{blue.100}', addon: '{blue.400}', solidBg: '{blue.500}', solidFg: '{white}' },
|
|
22
|
+
blue: scale('blue'),
|
|
23
|
+
indigo: mixed('brand', 'blue', 55),
|
|
24
|
+
purple: scale('brand'),
|
|
25
|
+
pink: mixed('red', 'brand', 58),
|
|
26
|
+
orange: mixed('red', 'amber', 50),
|
|
27
|
+
};
|
|
28
|
+
const compound = Object.entries(TONES).flatMap(([tone, t]) => [
|
|
29
|
+
{ when: { tone, variant: 'soft' }, block: { root: { 'background-color': t.bg, color: t.fg, 'border-color': t.ring } } },
|
|
30
|
+
{ when: { tone, variant: 'outline' }, block: { root: { 'background-color': 'transparent', color: t.fg, 'border-color': t.ring } } },
|
|
31
|
+
{ when: { tone, variant: 'solid' }, block: { root: { 'background-color': t.solidBg, color: t.solidFg, 'border-color': t.solidBg } } },
|
|
32
|
+
]);
|
|
33
|
+
const b = (text, tone = 'neutral', variant = 'soft', size = 'md', shape = 'square', inner = '') => `<span class="cn-badge" data-tone="${tone}" data-variant="${variant}" data-size="${size}" data-shape="${shape}">${inner}${text}</span>`;
|
|
34
|
+
const DOT = '<span class="cn-badge__dot"></span>';
|
|
35
|
+
const icon = (name) => ICON[name].replace('cn-icon', 'cn-badge__icon');
|
|
36
|
+
const AVATAR = "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";
|
|
37
|
+
const avatar = (name) => `<img class="cn-badge__avatar" src="${AVATAR}" alt="" data-name="${name}">`;
|
|
38
|
+
const x = (label) => `<button type="button" class="cn-badge__x" aria-label="Remove ${label}">${ICON.x}</button>`;
|
|
39
|
+
export const badge = {
|
|
40
|
+
name: 'Badge',
|
|
41
|
+
slug: 'badge',
|
|
42
|
+
category: 'data-display',
|
|
43
|
+
description: 'Small status label with compact corners (or an optional pill): 12/14px medium text, a 1px ring one step darker than the fill, optional dot, icon, avatar or close button. The tone carries meaning, the variant carries emphasis.',
|
|
44
|
+
usage: 'Statuses, categories and counts next to a title, in a table cell, inside a tab or a nav item. Not for actions (use Button) and not for selectable or removable filters (use Tag) — the close button here only dismisses a label.',
|
|
45
|
+
anatomy: [
|
|
46
|
+
{ part: 'root', element: 'span', description: 'The pill (or square). Sizes sm 22 / md 24 / lg 28px tall; text is medium weight, never uppercase.' },
|
|
47
|
+
{ part: 'dot', element: 'span', description: 'Optional 6px status dot before the text in the tone\'s addon color. Use for live states (online, running).', optional: true },
|
|
48
|
+
{ part: 'icon', element: 'svg', description: 'Optional 12px icon before or after the text, in the tone\'s addon color. Decorative.', optional: true },
|
|
49
|
+
{ part: 'avatar', element: 'img', description: 'Optional 16px round image (a person, a flag, a logo) before the text.', optional: true },
|
|
50
|
+
{ part: 'x', element: 'button', description: 'Optional 16px close button after the text (12px × icon). Needs aria-label "Remove {text}".', optional: true },
|
|
51
|
+
],
|
|
52
|
+
props: {
|
|
53
|
+
tone: {
|
|
54
|
+
values: ['neutral', 'accent', 'success', 'warning', 'danger', 'info', 'gray-blue', 'blue-light', 'blue', 'indigo', 'purple', 'pink', 'orange'],
|
|
55
|
+
default: 'neutral',
|
|
56
|
+
description: 'Meaning. neutral (gray) for categories; accent (brand) for brand/AI; success/warning/danger (error) for state; info = blue. The extra hues (gray-blue, blue-light, blue, indigo, purple, pink, orange) are the system\'s decorative palette for labels and tags: use them only for user-chosen categories, never for state.',
|
|
57
|
+
},
|
|
58
|
+
variant: {
|
|
59
|
+
values: ['soft', 'outline', 'solid', 'modern'],
|
|
60
|
+
default: 'soft',
|
|
61
|
+
description: 'soft = tinted fill with a ring one step darker (the system pill-color / color); modern = white fill, gray ring and shadow-xs, only the dot/icon takes the tone color; outline = ring only, transparent fill; solid = filled, for one emphasized status per row at most.',
|
|
62
|
+
},
|
|
63
|
+
shape: {
|
|
64
|
+
values: ['pill', 'square'],
|
|
65
|
+
default: 'square',
|
|
66
|
+
description: 'square = compact token-defined corners (default); pill = fully rounded, for an explicitly round visual family.',
|
|
67
|
+
},
|
|
68
|
+
size: { values: ['sm', 'md', 'lg'], default: 'md', description: 'sm 22px tall with 12px text (tables, tabs, nav items); md 24px with 14px text (default); lg 28px with 14px text (page headers, cards).' },
|
|
69
|
+
},
|
|
70
|
+
states: {},
|
|
71
|
+
base: {
|
|
72
|
+
root: {
|
|
73
|
+
display: 'inline-flex',
|
|
74
|
+
'align-items': 'center',
|
|
75
|
+
gap: '{space.1.5}',
|
|
76
|
+
'flex-shrink': '0',
|
|
77
|
+
'box-sizing': 'border-box',
|
|
78
|
+
height: '{space.6}',
|
|
79
|
+
'padding-inline': '{space.2.5}',
|
|
80
|
+
...typeStyle('label-sm'),
|
|
81
|
+
'border-radius': '{radius.sm}',
|
|
82
|
+
border: '{border.width.thin} solid transparent',
|
|
83
|
+
'white-space': 'nowrap',
|
|
84
|
+
'vertical-align': 'middle',
|
|
85
|
+
},
|
|
86
|
+
dot: { width: '6px', height: '6px', 'border-radius': '{radius.full}', 'flex-shrink': '0', 'background-color': '{color.fg-subtle}' },
|
|
87
|
+
icon: { width: '{size.icon.xs}', height: '{size.icon.xs}', 'flex-shrink': '0', 'stroke-width': '2', color: '{color.fg-subtle}' },
|
|
88
|
+
avatar: { width: '{space.4}', height: '{space.4}', 'border-radius': '{radius.full}', 'object-fit': 'cover', 'flex-shrink': '0', 'max-width': 'none', 'background-color': '{color.bg-muted}' },
|
|
89
|
+
x: {
|
|
90
|
+
...RESET_BUTTON,
|
|
91
|
+
display: 'inline-flex',
|
|
92
|
+
'align-items': 'center',
|
|
93
|
+
'justify-content': 'center',
|
|
94
|
+
width: '{space.4}',
|
|
95
|
+
height: '{space.4}',
|
|
96
|
+
'border-radius': '{radius.full}',
|
|
97
|
+
'flex-shrink': '0',
|
|
98
|
+
opacity: '{opacity.muted}',
|
|
99
|
+
...TRANSITION_COLORS,
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
variants: {
|
|
103
|
+
tone: Object.fromEntries(Object.entries(TONES).map(([t, v]) => [t, { dot: { 'background-color': v.addon }, icon: { color: v.addon } }])),
|
|
104
|
+
variant: {
|
|
105
|
+
soft: { root: {} },
|
|
106
|
+
outline: { root: {} },
|
|
107
|
+
solid: { root: {}, dot: { 'background-color': 'currentColor' }, icon: { color: 'currentColor' } },
|
|
108
|
+
modern: { root: { 'background-color': '{color.bg-surface}', color: '{color.fg-muted}', 'border-color': '{color.border-control}', 'box-shadow': '{shadow.xs}' } },
|
|
109
|
+
},
|
|
110
|
+
shape: {
|
|
111
|
+
pill: { root: { 'border-radius': '{radius.full}' }, x: { 'border-radius': '{radius.full}' } },
|
|
112
|
+
square: { root: { 'border-radius': '{radius.md}' }, x: { 'border-radius': '3px' } },
|
|
113
|
+
},
|
|
114
|
+
size: {
|
|
115
|
+
sm: { root: { height: '22px', 'padding-inline': '{space.2}', gap: '{space.1}', ...typeStyle('label-xs') } },
|
|
116
|
+
md: { root: { height: '{space.6}', 'padding-inline': '{space.2.5}' } },
|
|
117
|
+
lg: { root: { height: '{space.7}', 'padding-inline': '{space.3}' } },
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
compound: [
|
|
121
|
+
...compound,
|
|
122
|
+
{ when: { shape: 'square', size: 'sm' }, block: { root: { 'padding-inline': '{space.1.5}' } } },
|
|
123
|
+
{ when: { shape: 'square', size: 'md' }, block: { root: { 'padding-inline': '{space.2}' } } },
|
|
124
|
+
{ when: { shape: 'square', size: 'lg' }, block: { root: { 'padding-inline': '{space.2.5}', 'border-radius': '{radius.lg}' } } },
|
|
125
|
+
],
|
|
126
|
+
extraCss: `
|
|
127
|
+
.cn-badge__x { color: currentColor; }
|
|
128
|
+
.cn-badge__x:hover { opacity: 1; background-color: color-mix(in srgb, currentColor 12%, transparent); }
|
|
129
|
+
.cn-badge__x:focus-visible { outline: none; opacity: 1; box-shadow: {shadow.focus}; }
|
|
130
|
+
.cn-badge__x .cn-icon { width: {size.icon.xs}; height: {size.icon.xs}; stroke-width: 2; }
|
|
131
|
+
/* the system trims the padding on the side that carries a dot, icon, avatar or close button. */
|
|
132
|
+
.cn-badge:has(> .cn-badge__dot:first-child), .cn-badge:has(> .cn-badge__icon:first-child) { padding-inline-start: {space.2}; }
|
|
133
|
+
.cn-badge[data-size="sm"]:has(> .cn-badge__dot:first-child), .cn-badge[data-size="sm"]:has(> .cn-badge__icon:first-child) { padding-inline-start: {space.1.5}; }
|
|
134
|
+
.cn-badge[data-size="lg"]:has(> .cn-badge__dot:first-child), .cn-badge[data-size="lg"]:has(> .cn-badge__icon:first-child) { padding-inline-start: {space.2.5}; }
|
|
135
|
+
.cn-badge:has(> .cn-badge__icon:last-child) { padding-inline-end: {space.2}; }
|
|
136
|
+
.cn-badge[data-size="sm"]:has(> .cn-badge__icon:last-child) { padding-inline-end: {space.1.5}; }
|
|
137
|
+
.cn-badge[data-size="lg"]:has(> .cn-badge__icon:last-child) { padding-inline-end: {space.2.5}; }
|
|
138
|
+
.cn-badge:has(> .cn-badge__icon) { gap: {space.1}; }
|
|
139
|
+
.cn-badge[data-size="sm"]:has(> .cn-badge__icon) { gap: {space.0.5}; }
|
|
140
|
+
.cn-badge:has(> .cn-badge__avatar:first-child) { padding-inline-start: {space.1}; }
|
|
141
|
+
.cn-badge[data-size="sm"]:has(> .cn-badge__avatar:first-child) { padding-inline-start: 3px; }
|
|
142
|
+
.cn-badge[data-size="lg"]:has(> .cn-badge__avatar:first-child) { padding-inline-start: {space.1.5}; }
|
|
143
|
+
.cn-badge[data-shape="square"]:has(> .cn-badge__avatar:first-child) { padding-inline-start: {space.1.5}; }
|
|
144
|
+
.cn-badge[data-shape="square"][data-size="sm"]:has(> .cn-badge__avatar:first-child) { padding-inline-start: {space.1}; }
|
|
145
|
+
.cn-badge[data-shape="square"][data-size="lg"]:has(> .cn-badge__avatar:first-child) { padding-inline-start: {space.2}; }
|
|
146
|
+
.cn-badge:has(> .cn-badge__x:last-child) { padding-inline-end: {space.1}; gap: {space.0.5}; }
|
|
147
|
+
.cn-badge[data-size="sm"]:has(> .cn-badge__x:last-child) { padding-inline-end: 3px; }
|
|
148
|
+
.cn-badge[data-size="lg"]:has(> .cn-badge__x:last-child) { padding-inline-end: {space.1.5}; }`,
|
|
149
|
+
examples: [
|
|
150
|
+
ex('Colors (pill, soft)', ['neutral', 'accent', 'danger', 'warning', 'success', 'info', 'gray-blue', 'blue-light', 'blue', 'indigo', 'purple', 'pink', 'orange'].map((t) => b('Label', t)).join(' '), 'Order: gray, brand, error, warning, success, then the decorative hues.'),
|
|
151
|
+
ex('Sizes', `${b('Label', 'accent', 'soft', 'sm')} ${b('Label', 'accent', 'soft', 'md')} ${b('Label', 'accent', 'soft', 'lg')}`, 'sm 22px · md 24px · lg 28px.'),
|
|
152
|
+
ex('Types: pill-color, badge-color, badge-modern', `${b('Label', 'success', 'soft', 'md', 'pill')} ${b('Label', 'success', 'soft', 'md', 'square')} ${b('Label', 'success', 'modern', 'md', 'square')} ${b('Label', 'success', 'modern', 'lg', 'square')}`, 'square = 6px radius (8px at lg); modern = white with a gray ring and shadow-xs.'),
|
|
153
|
+
ex('With dot', `${b('Active', 'success', 'soft', 'md', 'pill', DOT)} ${b('Processing', 'accent', 'soft', 'md', 'square', DOT)} ${b('Online', 'success', 'modern', 'md', 'square', DOT)} ${b('Offline', 'neutral', 'modern', 'sm', 'square', DOT)}`, 'In the modern type only the dot takes the color.'),
|
|
154
|
+
ex('With icon', `${b('Verified', 'success', 'soft', 'md', 'pill', icon('check'))} ${b('Needs attention', 'warning', 'soft', 'md', 'square', icon('warning'))} <span class="cn-badge" data-tone="accent" data-variant="modern" data-size="md" data-shape="square">Open${icon('arrow')}</span>`, 'Leading or trailing, 12px, addon color.'),
|
|
155
|
+
ex('With avatar', `${b('Maya Chen', 'neutral', 'soft', 'md', 'pill', avatar('Maya Chen'))} ${b('Daniel Costa', 'neutral', 'modern', 'md', 'square', avatar('Daniel Costa'))} ${b('Sofia Almeida', 'accent', 'soft', 'lg', 'pill', avatar('Sofia Almeida'))}`),
|
|
156
|
+
ex('With x', `<span class="cn-badge" data-tone="neutral" data-variant="soft" data-size="md" data-shape="pill">Design${x('Design')}</span> <span class="cn-badge" data-tone="accent" data-variant="soft" data-size="md" data-shape="square">Reporting${x('Reporting')}</span> <span class="cn-badge" data-tone="neutral" data-variant="modern" data-size="lg" data-shape="square">the system${x('the system')}</span>`),
|
|
157
|
+
ex('Outline and solid', `${b('Draft', 'neutral', 'outline')} ${b('Overdue', 'danger', 'solid')} ${b('Live', 'success', 'solid', 'sm', 'pill', DOT)} ${b('New', 'accent', 'outline', 'md', 'square')}`),
|
|
158
|
+
],
|
|
159
|
+
rules: [
|
|
160
|
+
'Text is 1–2 words in sentence case, medium weight, never uppercase.',
|
|
161
|
+
'One solid badge per row at most. Everything else soft, modern or outline.',
|
|
162
|
+
'Tone is meaning, not decoration: success = done/healthy, warning = needs attention, danger = failed/blocked, info = new/informational, accent = brand or AI-generated, neutral = category. The decorative hues are for user-defined labels only.',
|
|
163
|
+
'Modern badges keep gray text whatever the tone; only the dot or icon takes the color. Use them on tinted backgrounds where a colored fill would clash.',
|
|
164
|
+
'sm inside tables, tabs and nav items; md next to titles; lg in page headers and cards. Match the size of the text next to it.',
|
|
165
|
+
'Never make the badge itself clickable. If it filters, it is a Tag; if it acts, it is a Button. The × only dismisses a label.',
|
|
166
|
+
],
|
|
167
|
+
a11y: [
|
|
168
|
+
'The text must carry the meaning; the dot, icon and tone are reinforcement. "Working" not just a green dot.',
|
|
169
|
+
'The close button needs aria-label "Remove {text}" and is the only focusable thing in a badge.',
|
|
170
|
+
'Live states with a dot may also use aria-live on the containing region, not on the badge.',
|
|
171
|
+
'Avatar images inside badges are decorative (alt="") because the text names the person.',
|
|
172
|
+
],
|
|
173
|
+
related: ['tag', 'kicker', 'counter'],
|
|
174
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { FOCUS_RING, RESET_BUTTON, TRANSITION_COLORS, ex, ICON, typeStyle } from "./_shared.mjs";
|
|
2
|
+
// tone → soft (bg, border, fg) and solid (bg, fg)
|
|
3
|
+
const TONES = {
|
|
4
|
+
neutral: { bg: '{color.bg-subtle}', border: '{color.border-default}', fg: '{color.fg-default}', solidBg: '{color.bg-inverse}', solidFg: '{color.fg-inverse}' },
|
|
5
|
+
info: { bg: '{color.bg-info-subtle}', border: '{color.border-info}', fg: '{color.fg-info}', solidBg: '{color.bg-info}', solidFg: '{white}' },
|
|
6
|
+
warning: { bg: '{color.bg-warning-subtle}', border: '{color.border-warning}', fg: '{color.fg-warning}', solidBg: '{color.bg-warning}', solidFg: '{neutral.950}' },
|
|
7
|
+
danger: { bg: '{color.bg-danger-subtle}', border: '{color.border-danger}', fg: '{color.fg-danger}', solidBg: '{color.bg-danger}', solidFg: '{white}' },
|
|
8
|
+
accent: { bg: '{color.bg-accent-subtle}', border: '{color.border-default}', fg: '{color.fg-accent}', solidBg: '{color.bg-accent}', solidFg: '{color.fg-on-accent}' },
|
|
9
|
+
};
|
|
10
|
+
const compound = Object.entries(TONES).flatMap(([t, v]) => [
|
|
11
|
+
{ when: { tone: t, variant: 'soft' }, block: { root: { 'background-color': v.bg, 'border-bottom-color': v.border, color: v.fg } } },
|
|
12
|
+
{ when: { tone: t, variant: 'solid' }, block: { root: { 'background-color': v.solidBg, 'border-bottom-color': 'transparent', color: v.solidFg } } },
|
|
13
|
+
]);
|
|
14
|
+
const icon = (name) => ICON[name].replace('cn-icon', 'cn-banner__icon');
|
|
15
|
+
const dismiss = `<button type="button" class="cn-banner__dismiss" aria-label="Dismiss">${ICON.x}</button>`;
|
|
16
|
+
export const banner = {
|
|
17
|
+
name: 'Banner',
|
|
18
|
+
slug: 'banner',
|
|
19
|
+
category: 'feedback',
|
|
20
|
+
description: 'Full-width strip at the top of the page or a section: one line of text, an optional icon, one action and a dismiss. Square corners, no shadow, a hairline at the bottom; it reads as part of the chrome, not as a card.',
|
|
21
|
+
usage: 'Use for one notice that concerns the whole page or account: maintenance tonight, trial ending, a product announcement (tone accent), an outage (tone danger). Not for feedback on a specific element (use Alert) and not for transient confirmations (use Toast).',
|
|
22
|
+
anatomy: [
|
|
23
|
+
{ part: 'root', element: 'div', description: 'The strip. Spans the full width of its container; role="status" (or "alert" for danger).' },
|
|
24
|
+
{ part: 'icon', element: 'svg', description: '16px icon in the current text color. Decorative.', optional: true },
|
|
25
|
+
{ part: 'content', element: 'div', description: 'The message, one sentence, body-sm. Flexes to fill; may contain a <strong> lead.' },
|
|
26
|
+
{ part: 'actions', element: 'div', description: 'One link or outline Button (size sm) on the right.', optional: true },
|
|
27
|
+
{ part: 'dismiss', element: 'button', description: '24px ghost icon button with an X at the far end. aria-label="Dismiss".', optional: true },
|
|
28
|
+
],
|
|
29
|
+
props: {
|
|
30
|
+
tone: {
|
|
31
|
+
values: ['neutral', 'info', 'warning', 'danger', 'accent'],
|
|
32
|
+
default: 'neutral',
|
|
33
|
+
description: 'neutral = housekeeping notice; info = something to know; warning = act before a deadline; danger = something is broken now; accent = brand announcement (new feature, launch) in the accent wash — never for status.',
|
|
34
|
+
},
|
|
35
|
+
variant: {
|
|
36
|
+
values: ['soft', 'solid'],
|
|
37
|
+
default: 'soft',
|
|
38
|
+
description: 'soft = tinted background with dark tone text (default); solid = filled tone background with white text, reserved for danger (outage) and info (maintenance) when the notice must not be missed.',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
states: {
|
|
42
|
+
dismissHover: { selector: ' .cn-banner__dismiss:hover', description: 'Pointer over the dismiss button: faint current-color wash. Styled in extraCss because it lives on a child.', markup: 'native :hover on .cn-banner__dismiss' },
|
|
43
|
+
dismissFocus: { selector: ' .cn-banner__dismiss:focus-visible', description: 'Keyboard focus on the dismiss button shows the focus ring. Styled in extraCss.', markup: 'native :focus-visible on .cn-banner__dismiss' },
|
|
44
|
+
sticky: { selector: '[data-sticky]', description: 'Sticks under the topbar while the page scrolls. Position only; styled in extraCss so examples stay static.', markup: 'data-sticky attribute on the root' },
|
|
45
|
+
},
|
|
46
|
+
base: {
|
|
47
|
+
root: {
|
|
48
|
+
display: 'flex',
|
|
49
|
+
'align-items': 'center',
|
|
50
|
+
gap: '{space.3}',
|
|
51
|
+
width: '100%',
|
|
52
|
+
'min-height': '{size.control.lg}',
|
|
53
|
+
padding: '{space.2} {space.4}',
|
|
54
|
+
'border-radius': '{radius.none}',
|
|
55
|
+
'border-bottom': '{border.width.thin} solid {color.border-default}',
|
|
56
|
+
'background-color': '{color.bg-subtle}',
|
|
57
|
+
color: '{color.fg-default}',
|
|
58
|
+
...typeStyle('body-sm'),
|
|
59
|
+
'box-shadow': 'none',
|
|
60
|
+
},
|
|
61
|
+
icon: { width: '{size.icon.md}', height: '{size.icon.md}', 'flex-shrink': '0' },
|
|
62
|
+
content: { flex: '1 1 auto', 'min-width': '0' },
|
|
63
|
+
actions: { display: 'flex', 'align-items': 'center', gap: '{space.2}', 'flex-shrink': '0' },
|
|
64
|
+
dismiss: {
|
|
65
|
+
...RESET_BUTTON,
|
|
66
|
+
display: 'inline-flex',
|
|
67
|
+
'align-items': 'center',
|
|
68
|
+
'justify-content': 'center',
|
|
69
|
+
'flex-shrink': '0',
|
|
70
|
+
width: '{size.icon.xl}',
|
|
71
|
+
height: '{size.icon.xl}',
|
|
72
|
+
'margin-inline-end': 'calc({space.1} * -1)',
|
|
73
|
+
'border-radius': '{radius.md}',
|
|
74
|
+
color: 'inherit',
|
|
75
|
+
opacity: '{opacity.muted}',
|
|
76
|
+
...TRANSITION_COLORS,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
variants: {
|
|
80
|
+
tone: Object.fromEntries(Object.keys(TONES).map((t) => [t, { root: {} }])),
|
|
81
|
+
variant: { soft: { root: {} }, solid: { root: {} } },
|
|
82
|
+
},
|
|
83
|
+
compound,
|
|
84
|
+
extraCss: `
|
|
85
|
+
.cn-banner .cn-banner__dismiss .cn-icon { width: {size.icon.sm}; height: {size.icon.sm}; }
|
|
86
|
+
.cn-banner .cn-banner__dismiss:hover { opacity: 1; background-color: color-mix(in srgb, currentColor 12%, transparent); }
|
|
87
|
+
.cn-banner .cn-banner__dismiss:focus-visible { opacity: 1; outline: none; box-shadow: ${FOCUS_RING['box-shadow']}; }
|
|
88
|
+
.cn-banner .cn-banner__actions .cn-button { color: inherit; }
|
|
89
|
+
.cn-banner[data-variant="solid"] .cn-banner__actions .cn-button[data-variant="outline"] { background-color: transparent; border-color: color-mix(in srgb, currentColor 40%, transparent); box-shadow: none; }
|
|
90
|
+
.cn-banner[data-variant="solid"] .cn-banner__actions .cn-button[data-variant="link"] { text-decoration-color: currentColor; }
|
|
91
|
+
.cn-banner[data-sticky] { position: sticky; top: 0; z-index: {z.sticky}; }`,
|
|
92
|
+
examples: [
|
|
93
|
+
ex('Neutral (soft)', `<div class="cn-banner" data-tone="neutral" data-variant="soft" role="status">${icon('info')}<div class="cn-banner__content"><strong>Scheduled maintenance</strong> on Saturday 14 Sep, 02:00–03:00 UTC. Outreach pauses during that hour.</div><div class="cn-banner__actions"><button type="button" class="cn-button" data-variant="link" data-size="sm"><span class="cn-button__label">Details</span></button></div>${dismiss}</div>`),
|
|
94
|
+
ex('Info', `<div class="cn-banner" data-tone="info" data-variant="soft" role="status">${icon('info')}<div class="cn-banner__content">Your trial ends in 6 days. Add a payment method to keep your prospects and drafts.</div><div class="cn-banner__actions"><button type="button" class="cn-button" data-variant="outline" data-size="sm"><span class="cn-button__label">Add payment method</span></button></div>${dismiss}</div>`),
|
|
95
|
+
ex('Warning', `<div class="cn-banner" data-tone="warning" data-variant="soft" role="status">${icon('warning')}<div class="cn-banner__content">The Gmail connection for <strong>sales@verolabs.com</strong> expires tomorrow. Reconnect to avoid a gap in replies.</div><div class="cn-banner__actions"><button type="button" class="cn-button" data-variant="outline" data-size="sm"><span class="cn-button__label">Reconnect</span></button></div></div>`),
|
|
96
|
+
ex('Accent (announcement)', `<div class="cn-banner" data-tone="accent" data-variant="soft" role="status">${icon('spark')}<div class="cn-banner__content"><strong>New:</strong> agents now read replies in Japanese and German and draft answers in the buyer’s language.</div><div class="cn-banner__actions"><button type="button" class="cn-button" data-variant="link" data-size="sm"><span class="cn-button__label">See what changed</span></button></div>${dismiss}</div>`),
|
|
97
|
+
ex('Danger (solid)', `<div class="cn-banner" data-tone="danger" data-variant="solid" role="alert">${icon('warning')}<div class="cn-banner__content">Email delivery is degraded since 09:12 UTC. Sent messages may arrive late; nothing is lost.</div><div class="cn-banner__actions"><button type="button" class="cn-button" data-variant="outline" data-size="sm"><span class="cn-button__label">Status page</span></button></div></div>`, 'solid is reserved for danger and info notices that must not be missed. No dismiss while the incident is open.'),
|
|
98
|
+
],
|
|
99
|
+
rules: [
|
|
100
|
+
'One banner per page at most. If a second notice appears, the more urgent one wins and the other waits.',
|
|
101
|
+
'One sentence, ideally under 90 characters, with an optional <strong> lead. No title, no second line.',
|
|
102
|
+
'One action at most (link or outline, size sm) plus the dismiss. Never a primary Button in a banner.',
|
|
103
|
+
'No radius, no shadow, no margin: the banner touches the edges of its container and sits directly under the topbar.',
|
|
104
|
+
'Use data-sticky only when the notice must stay visible while scrolling (an open incident). Sticky banners are never accent.',
|
|
105
|
+
'variant="solid" only for tone danger or info. Never solid accent, warning or neutral.',
|
|
106
|
+
'Danger banners for open incidents are not dismissible; they leave when the incident closes.',
|
|
107
|
+
'Accent tone is for announcements only. It never signals a status or an error.',
|
|
108
|
+
],
|
|
109
|
+
a11y: [
|
|
110
|
+
'role="status" by default; role="alert" for danger banners inserted after page load.',
|
|
111
|
+
'The banner is inside the header landmark or the first child of <main>, so screen readers meet it before the page content.',
|
|
112
|
+
'The dismiss button needs aria-label="Dismiss"; the action must be a real <button> or <a href>.',
|
|
113
|
+
'Solid variants keep AA contrast: white text on info/danger, ink text on warning.',
|
|
114
|
+
],
|
|
115
|
+
related: ['alert', 'toast', 'topbar', 'button'],
|
|
116
|
+
};
|