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
package/lib/color.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Color math: sRGB <-> OKLCH, scale generation, contrast. Plain JS so the same
|
|
2
|
+
// code runs in Node (imported by the TS generators) and inline in preview.html.
|
|
3
|
+
|
|
4
|
+
/** @param {string} hex */
|
|
5
|
+
export function hexToRgb(hex) {
|
|
6
|
+
let h = hex.replace('#', '').trim();
|
|
7
|
+
if (h.length === 3 || h.length === 4) h = h.split('').map((c) => c + c).join('');
|
|
8
|
+
const n = parseInt(h.slice(0, 6), 16);
|
|
9
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** @param {number[]} rgb */
|
|
13
|
+
export function rgbToHex([r, g, b]) {
|
|
14
|
+
const c = (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0');
|
|
15
|
+
return `#${c(r)}${c(g)}${c(b)}`.toUpperCase();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const srgbToLinear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
|
19
|
+
const linearToSrgb = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
|
|
20
|
+
|
|
21
|
+
/** @param {number[]} rgb 0..255 → oklab [L,a,b] */
|
|
22
|
+
export function rgbToOklab([r, g, b]) {
|
|
23
|
+
const R = srgbToLinear(r / 255), G = srgbToLinear(g / 255), B = srgbToLinear(b / 255);
|
|
24
|
+
const l = Math.cbrt(0.4122214708 * R + 0.5363325363 * G + 0.0514459929 * B);
|
|
25
|
+
const m = Math.cbrt(0.2119034982 * R + 0.6806995451 * G + 0.1073969566 * B);
|
|
26
|
+
const s = Math.cbrt(0.0883024619 * R + 0.2817188376 * G + 0.6299787005 * B);
|
|
27
|
+
return [
|
|
28
|
+
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
|
29
|
+
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
|
30
|
+
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** @param {number[]} lab → rgb 0..255 (unclamped floats) */
|
|
35
|
+
export function oklabToRgb([L, a, b]) {
|
|
36
|
+
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
37
|
+
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
38
|
+
const s_ = L - 0.0894841775 * a - 1.291485548 * b;
|
|
39
|
+
const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3;
|
|
40
|
+
const R = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
|
|
41
|
+
const G = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
|
|
42
|
+
const B = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
|
|
43
|
+
return [linearToSrgb(R) * 255, linearToSrgb(G) * 255, linearToSrgb(B) * 255];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function hexToOklch(hex) {
|
|
47
|
+
const [L, a, b] = rgbToOklab(hexToRgb(hex));
|
|
48
|
+
const C = Math.hypot(a, b);
|
|
49
|
+
let H = (Math.atan2(b, a) * 180) / Math.PI;
|
|
50
|
+
if (H < 0) H += 360;
|
|
51
|
+
return { L, C, H: C < 1e-4 ? 0 : H };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function inGamut(rgb) {
|
|
55
|
+
return rgb.every((v) => v >= -0.5 && v <= 255.5);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** OKLCH → hex, reducing chroma until the color fits in sRGB. */
|
|
59
|
+
export function oklchToHex({ L, C, H }) {
|
|
60
|
+
const rad = (H * Math.PI) / 180;
|
|
61
|
+
let c = C;
|
|
62
|
+
for (let i = 0; i < 24; i++) {
|
|
63
|
+
const rgb = oklabToRgb([L, c * Math.cos(rad), c * Math.sin(rad)]);
|
|
64
|
+
if (inGamut(rgb)) return rgbToHex(rgb);
|
|
65
|
+
c *= 0.9;
|
|
66
|
+
}
|
|
67
|
+
return rgbToHex(oklabToRgb([L, 0, 0]));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Lightness / chroma curve for an 11-step scale (50…950). Tuned so the 500/600
|
|
71
|
+
// steps carry the seed's saturation and the ends stay usable as surfaces/text.
|
|
72
|
+
export const SCALE_STEPS = ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900', '950'];
|
|
73
|
+
const SCALE_L = [0.985, 0.965, 0.925, 0.865, 0.78, 0.68, 0.59, 0.51, 0.44, 0.38, 0.28];
|
|
74
|
+
const SCALE_C = [0.04, 0.08, 0.17, 0.32, 0.6, 0.9, 1.0, 0.92, 0.78, 0.62, 0.45];
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build a full 50…950 scale from one seed color. The seed lands on its
|
|
78
|
+
* nearest step; the rest is derived along the curve with the seed's hue.
|
|
79
|
+
* @param {string} seedHex
|
|
80
|
+
* @param {{ maxChroma?: number, hueShift?: number }} [opts]
|
|
81
|
+
*/
|
|
82
|
+
export function buildScale(seedHex, opts = {}) {
|
|
83
|
+
const seed = hexToOklch(seedHex);
|
|
84
|
+
const peak = Math.min(opts.maxChroma ?? 0.4, Math.max(seed.C, 0.002));
|
|
85
|
+
// Which step is the seed closest to (by lightness)? Anchor there.
|
|
86
|
+
let anchor = 0, best = Infinity;
|
|
87
|
+
SCALE_L.forEach((L, i) => { const d = Math.abs(L - seed.L); if (d < best) { best = d; anchor = i; } });
|
|
88
|
+
const scale = {};
|
|
89
|
+
SCALE_STEPS.forEach((step, i) => {
|
|
90
|
+
if (i === anchor) { scale[step] = seedHex.toUpperCase(); return; }
|
|
91
|
+
const C = (peak / SCALE_C[anchor]) * SCALE_C[i];
|
|
92
|
+
const H = seed.H + (opts.hueShift ?? 0) * ((i - anchor) / 10);
|
|
93
|
+
scale[step] = oklchToHex({ L: SCALE_L[i], C: Math.max(C, 0), H });
|
|
94
|
+
});
|
|
95
|
+
return scale;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Neutral scale: near-grey, optionally tinted with a hue at very low chroma.
|
|
100
|
+
* @param {{ hue?: number, chroma?: number, light?: string, dark?: string }} [opts]
|
|
101
|
+
* light/dark override the 50 and 950 endpoints (e.g. an off-white canvas and a charcoal ink).
|
|
102
|
+
*/
|
|
103
|
+
export function buildNeutralScale(opts = {}) {
|
|
104
|
+
const hue = opts.hue ?? 0;
|
|
105
|
+
const chroma = opts.chroma ?? 0;
|
|
106
|
+
// Map the curve so it hits the requested endpoints exactly and stays monotonic.
|
|
107
|
+
const L0 = opts.light ? hexToOklch(opts.light).L : SCALE_L[0];
|
|
108
|
+
const L10 = opts.dark ? hexToOklch(opts.dark).L : SCALE_L[SCALE_L.length - 1];
|
|
109
|
+
const cMin = SCALE_L[SCALE_L.length - 1], cMax = SCALE_L[0];
|
|
110
|
+
const scale = {};
|
|
111
|
+
SCALE_STEPS.forEach((step, i) => {
|
|
112
|
+
const t = (SCALE_L[i] - cMin) / (cMax - cMin); // 1 at 50 … 0 at 950
|
|
113
|
+
const L = L10 + t * (L0 - L10);
|
|
114
|
+
scale[step] = oklchToHex({ L, C: chroma, H: hue });
|
|
115
|
+
});
|
|
116
|
+
if (opts.light) scale['50'] = opts.light.toUpperCase();
|
|
117
|
+
if (opts.dark) scale['950'] = opts.dark.toUpperCase();
|
|
118
|
+
return scale;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** WCAG relative luminance */
|
|
122
|
+
export function luminance(hex) {
|
|
123
|
+
const [r, g, b] = hexToRgb(hex).map((v) => srgbToLinear(v / 255));
|
|
124
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** WCAG contrast ratio between two hex colors */
|
|
128
|
+
export function contrast(a, b) {
|
|
129
|
+
const la = luminance(a), lb = luminance(b);
|
|
130
|
+
const [hi, lo] = la > lb ? [la, lb] : [lb, la];
|
|
131
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Perceptual distance in OKLab (0 = identical; < 0.02 is visually very close) */
|
|
135
|
+
export function distance(hexA, hexB) {
|
|
136
|
+
const a = rgbToOklab(hexToRgb(hexA)), b = rgbToOklab(hexToRgb(hexB));
|
|
137
|
+
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Parse any css color literal we care about into hex (or null). */
|
|
141
|
+
export function parseCssColor(str) {
|
|
142
|
+
const s = str.trim();
|
|
143
|
+
if (/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(s)) return rgbToHex(hexToRgb(s));
|
|
144
|
+
let m = s.match(/^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i);
|
|
145
|
+
if (m) return rgbToHex([+m[1], +m[2], +m[3]]);
|
|
146
|
+
m = s.match(/^hsla?\(\s*([\d.]+)[\s,]+([\d.]+)%[\s,]+([\d.]+)%/i);
|
|
147
|
+
if (m) {
|
|
148
|
+
const h = +m[1] / 360, sat = +m[2] / 100, l = +m[3] / 100;
|
|
149
|
+
const q = l < 0.5 ? l * (1 + sat) : l + sat - l * sat, p = 2 * l - q;
|
|
150
|
+
const t2c = (t) => { if (t < 0) t += 1; if (t > 1) t -= 1; if (t < 1 / 6) return p + (q - p) * 6 * t; if (t < 1 / 2) return q; if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; return p; };
|
|
151
|
+
return rgbToHex([t2c(h + 1 / 3) * 255, t2c(h) * 255, t2c(h - 1 / 3) * 255]);
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** "rgba(HEX, alpha)" helper used by tokens: returns rgba() string */
|
|
157
|
+
export function alpha(hex, a) {
|
|
158
|
+
const [r, g, b] = hexToRgb(hex);
|
|
159
|
+
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
|
160
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export const SIZES = ['xs', 'sm', 'md', 'lg', 'xl'];
|
|
2
|
+
/** Height / horizontal padding / font / icon / gap for a control of a size. */
|
|
3
|
+
export const CONTROL = {
|
|
4
|
+
xs: { height: '{size.control.xs}', px: '{size.controlPadding.xs}', font: '{font.control.xs}', icon: '{size.controlIcon.xs}', gap: '{space.1}', radius: '{radius.control}' },
|
|
5
|
+
sm: { height: '{size.control.sm}', px: '{size.controlPadding.sm}', font: '{font.control.sm}', icon: '{size.controlIcon.sm}', gap: '{space.1.5}', radius: '{radius.control}' },
|
|
6
|
+
md: { height: '{size.control.md}', px: '{size.controlPadding.md}', font: '{font.control.md}', icon: '{size.controlIcon.md}', gap: '{space.2}', radius: '{radius.control}' },
|
|
7
|
+
lg: { height: '{size.control.lg}', px: '{size.controlPadding.lg}', font: '{font.control.lg}', icon: '{size.controlIcon.lg}', gap: '{space.2}', radius: '{radius.control}' },
|
|
8
|
+
xl: { height: '{size.control.xl}', px: '{size.controlPadding.xl}', font: '{font.control.xl}', icon: '{size.controlIcon.xl}', gap: '{space.2.5}', radius: '{radius.control}' },
|
|
9
|
+
};
|
|
10
|
+
export const FOCUS_RING = {
|
|
11
|
+
outline: 'none',
|
|
12
|
+
'box-shadow': '{shadow.focus}',
|
|
13
|
+
};
|
|
14
|
+
export const FOCUS_RING_DANGER = {
|
|
15
|
+
outline: 'none',
|
|
16
|
+
'box-shadow': '{shadow.focus-danger}',
|
|
17
|
+
};
|
|
18
|
+
export const TRANSITION_COLORS = {
|
|
19
|
+
'transition-property': 'background-color, border-color, color, box-shadow, opacity',
|
|
20
|
+
'transition-duration': '{motion.duration.normal}',
|
|
21
|
+
'transition-timing-function': '{motion.easing.standard}',
|
|
22
|
+
};
|
|
23
|
+
export const TRANSITION_ALL = {
|
|
24
|
+
'transition-property': 'background-color, border-color, color, box-shadow, opacity, transform',
|
|
25
|
+
'transition-duration': '{motion.duration.normal}',
|
|
26
|
+
'transition-timing-function': '{motion.easing.standard}',
|
|
27
|
+
};
|
|
28
|
+
/** Typography declarations from a composite type style ref name (e.g. 'label-md'). */
|
|
29
|
+
export function typeStyle(name) {
|
|
30
|
+
return {
|
|
31
|
+
'font-family': `{type.${name}.family}`,
|
|
32
|
+
'font-size': `{type.${name}.size}`,
|
|
33
|
+
'font-weight': `{type.${name}.weight}`,
|
|
34
|
+
'line-height': `{type.${name}.lineHeight}`,
|
|
35
|
+
'letter-spacing': `{type.${name}.letterSpacing}`,
|
|
36
|
+
'text-transform': `{type.${name}.transform}`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export const RESET_BUTTON = {
|
|
40
|
+
appearance: 'none',
|
|
41
|
+
'-webkit-appearance': 'none',
|
|
42
|
+
border: '0',
|
|
43
|
+
margin: '0',
|
|
44
|
+
padding: '0',
|
|
45
|
+
background: 'none',
|
|
46
|
+
font: 'inherit',
|
|
47
|
+
color: 'inherit',
|
|
48
|
+
cursor: 'pointer',
|
|
49
|
+
'text-decoration': 'none',
|
|
50
|
+
'user-select': 'none',
|
|
51
|
+
'-webkit-tap-highlight-color': 'transparent',
|
|
52
|
+
};
|
|
53
|
+
export const STATE = {
|
|
54
|
+
hover: (extra = '') => ({ selector: `:hover:not(:disabled):not([aria-disabled="true"])${extra}`, description: 'Pointer over the element (mouse only; never the only cue).', markup: 'native :hover' }),
|
|
55
|
+
active: () => ({ selector: ':active:not(:disabled):not([aria-disabled="true"])', description: 'While pressed.', markup: 'native :active' }),
|
|
56
|
+
focus: () => ({ selector: ':focus-visible', description: 'Keyboard focus. Shows the 3px ring; never on mouse click.', markup: 'native :focus-visible' }),
|
|
57
|
+
disabled: () => ({ selector: ':disabled, &[aria-disabled="true"]', description: 'Not interactive. Reduced opacity, no hover, cursor not-allowed.', markup: 'disabled attribute (or aria-disabled="true")' }),
|
|
58
|
+
loading: () => ({ selector: '[data-loading]', description: 'Async action in flight. Shows the spinner, hides the label visually, keeps width.', markup: 'data-loading attribute' }),
|
|
59
|
+
invalid: () => ({ selector: '[aria-invalid="true"]', description: 'Validation failed. Danger border + danger focus ring; pair with a FieldError.', markup: 'aria-invalid="true"' }),
|
|
60
|
+
checked: () => ({ selector: ':checked', description: 'Selected.', markup: 'checked attribute' }),
|
|
61
|
+
selected: () => ({ selector: '[aria-selected="true"], &[data-selected]', description: 'Selected item.', markup: 'aria-selected="true" or data-selected' }),
|
|
62
|
+
current: () => ({ selector: '[aria-current="page"], &[data-active]', description: 'The current item (navigation).', markup: 'aria-current="page" or data-active' }),
|
|
63
|
+
open: () => ({ selector: '[data-state="open"]', description: 'Expanded / visible.', markup: 'data-state="open"' }),
|
|
64
|
+
};
|
|
65
|
+
export const SIZE_PROP = (values = SIZES, def = 'md') => ({
|
|
66
|
+
values: [...values],
|
|
67
|
+
default: def,
|
|
68
|
+
description: 'Control height and its matching padding, font and icon size.',
|
|
69
|
+
});
|
|
70
|
+
export const ex = (title, html, description) => ({ title, html: html.trim(), description });
|
|
71
|
+
/** Inline SVG icons used in examples so the gallery renders without a font. */
|
|
72
|
+
export const ICON = {
|
|
73
|
+
plus: '<svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M8 3v10M3 8h10"/></svg>',
|
|
74
|
+
arrow: '<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="M3 8h10M9 4l4 4-4 4"/></svg>',
|
|
75
|
+
check: '<svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 8.5l3 3 7-7"/></svg>',
|
|
76
|
+
x: '<svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>',
|
|
77
|
+
chevronDown: '<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="M4 6l4 4 4-4"/></svg>',
|
|
78
|
+
chevronRight: '<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>',
|
|
79
|
+
search: '<svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5L14 14"/></svg>',
|
|
80
|
+
info: '<svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M8 7v4M8 5v.5"/></svg>',
|
|
81
|
+
warning: '<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="M8 2.5l6 11H2l6-11zM8 7v3M8 11.5v.5"/></svg>',
|
|
82
|
+
dots: '<svg class="cn-icon" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><circle cx="3.5" cy="8" r="1.25"/><circle cx="8" cy="8" r="1.25"/><circle cx="12.5" cy="8" r="1.25"/></svg>',
|
|
83
|
+
spark: '<svg class="cn-icon" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 1.5l1.6 4.9 4.9 1.6-4.9 1.6L8 14.5l-1.6-4.9L1.5 8l4.9-1.6z"/></svg>',
|
|
84
|
+
menu: '<svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M3 5h10M3 8h10M3 11h10"/></svg>',
|
|
85
|
+
calendar: '<svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><rect x="2.5" y="3.5" width="11" height="10" rx="1.5"/><path d="M2.5 7h11M5.5 2v3M10.5 2v3"/></svg>',
|
|
86
|
+
home: '<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="M2.5 7.5L8 3l5.5 4.5V13a.5.5 0 01-.5.5H3a.5.5 0 01-.5-.5V7.5z"/></svg>',
|
|
87
|
+
user: '<svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><circle cx="8" cy="5.5" r="2.75"/><path d="M2.75 13.5c.6-2.6 2.6-4 5.25-4s4.65 1.4 5.25 4"/></svg>',
|
|
88
|
+
inbox: '<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="M2.5 9.5h3l1 2h3l1-2h3M2.5 9.5V4a1 1 0 011-1h9a1 1 0 011 1v5.5M2.5 9.5V12a1 1 0 001 1h9a1 1 0 001-1V9.5"/></svg>',
|
|
89
|
+
settings: '<svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><circle cx="8" cy="8" r="2"/><path d="M8 1.75v1.5M8 12.75v1.5M1.75 8h1.5M12.75 8h1.5M3.6 3.6l1.05 1.05M11.35 11.35l1.05 1.05M3.6 12.4l1.05-1.05M11.35 4.65l1.05-1.05"/></svg>',
|
|
90
|
+
external: '<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="M7 3.5H3.5v9h9V9M9.5 3h3.5v3.5M13 3L7.5 8.5"/></svg>',
|
|
91
|
+
trash: '<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="M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.6 8.5h5.8l.6-8.5"/></svg>',
|
|
92
|
+
copy: '<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"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5"/><path d="M10.5 5.5V3.5a1 1 0 00-1-1h-6a1 1 0 00-1 1v6a1 1 0 001 1h2"/></svg>',
|
|
93
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { RESET_BUTTON, ex, ICON, typeStyle } from "./_shared.mjs";
|
|
2
|
+
// the reference FAQ accordion: full-width trigger rows (16 / 18px semibold ink) with a 20 / 24px
|
|
3
|
+
// gray-400 chevron or plus on the right, gray-200 hairlines, 16px gray-600 body text, 16 / 24px of
|
|
4
|
+
// vertical padding; optionally each item its own card.
|
|
5
|
+
const HAIRLINE = '{border.width.thin} solid {color.border-default}';
|
|
6
|
+
const ring = (color) => `inset 0 0 0 1px ${color}`;
|
|
7
|
+
const PLUS = '<svg class="cn-accordion__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path class="cn-accordion__icon-v" d="M8 3v10"/><path d="M3 8h10"/></svg>';
|
|
8
|
+
const item = (id, state, title, body, extra = '', icon = ICON.chevronDown.replace('cn-icon', 'cn-accordion__icon')) => `<div class="cn-accordion__item" data-state="${state}"><h3 style="margin:0;font:inherit"><button type="button" class="cn-accordion__trigger" id="${id}-trigger" aria-expanded="${state === 'open'}" aria-controls="${id}-content"${extra}>${title}${icon}</button></h3><div class="cn-accordion__content" id="${id}-content" role="region" aria-labelledby="${id}-trigger">${body}</div></div>`;
|
|
9
|
+
const FAQ = (prefix, icon) => [
|
|
10
|
+
item(`${prefix}-1`, 'open', 'Is there a free trial available?', 'Yes, you can try Lumen for free for 30 days. If you want, we\'ll provide you with a free, personalized 30-minute onboarding call to get you up and running as soon as possible.', '', icon),
|
|
11
|
+
item(`${prefix}-2`, 'closed', 'Can I change my plan later?', 'Of course. Our pricing scales with your company. Chat to our friendly team to find a solution that works for you.', '', icon),
|
|
12
|
+
item(`${prefix}-3`, 'closed', 'What is your cancellation policy?', 'We understand that things change. You can cancel your plan at any time and we\'ll refund you the difference already paid.', '', icon),
|
|
13
|
+
].join('');
|
|
14
|
+
export const accordion = {
|
|
15
|
+
name: 'Accordion',
|
|
16
|
+
slug: 'accordion',
|
|
17
|
+
category: 'data-display',
|
|
18
|
+
description: 'Stacked disclosure sections in the reference FAQ style: a full-width semibold trigger row with a chevron (or plus) that flips on the right, and muted body text that shows under it when open. Gray-200 hairlines between items; optional separated or card framing.',
|
|
19
|
+
usage: 'Secondary content people open on demand: FAQ, advanced settings, details of a step, long explanations under a summary. Not for primary navigation (Tabs) and not for hiding required form fields.',
|
|
20
|
+
anatomy: [
|
|
21
|
+
{ part: 'root', element: 'div', description: 'Column of items.' },
|
|
22
|
+
{ part: 'item', element: 'div', description: 'One section. Carries data-state="open|closed", which drives the icon and hides the content. Hairline below.' },
|
|
23
|
+
{ part: 'trigger', element: 'button', description: 'Full-width <button aria-expanded aria-controls> inside a heading element. Semibold ink label left, icon right; padding 16 / 24px by size.' },
|
|
24
|
+
{ part: 'icon', element: 'svg', description: '20 / 24px chevron-down (rotates 180° when open) or plus (its vertical bar hides when open) at the end of the trigger, fg-subtle.' },
|
|
25
|
+
{ part: 'icon-v', element: 'path', description: 'The vertical bar of the plus icon; hidden when the item is open so the plus becomes a minus.', optional: true },
|
|
26
|
+
{ part: 'content', element: 'div', description: 'The revealed panel: role="region", fg-muted body text, prose width, padding below. Hidden when the item is closed.' },
|
|
27
|
+
],
|
|
28
|
+
props: {
|
|
29
|
+
variant: {
|
|
30
|
+
values: ['default', 'separated', 'card'],
|
|
31
|
+
default: 'default',
|
|
32
|
+
description: 'default = items divided by hairlines, no frame (inside a Card or a page section); separated = each item is its own card (radius 12, ring) with a 16px gap (settings groups, FAQ on the canvas); card = the whole list in one card frame with items divided.',
|
|
33
|
+
},
|
|
34
|
+
size: {
|
|
35
|
+
values: ['sm', 'md', 'lg'],
|
|
36
|
+
default: 'md',
|
|
37
|
+
description: 'sm = 14px semibold triggers with 12px padding and 14px body (dense settings); md = 16px semibold triggers, 16px padding, 16px body (the default in cards and drawers); lg = 18px semibold triggers, 24px padding, 16px body with a 24px icon (the reference FAQ section).',
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
states: {
|
|
41
|
+
open: { selector: ' > [data-state="open"]', description: 'On an item, not the root: the chevron is rotated (or the plus becomes a minus) and the content is visible.', markup: 'data-state="open" on the item and aria-expanded="true" on its trigger' },
|
|
42
|
+
closed: { selector: ' > [data-state="closed"]', description: 'On an item: the content is display none.', markup: 'data-state="closed" on the item and aria-expanded="false" on its trigger' },
|
|
43
|
+
hover: { selector: ' > * > * > button:hover', description: 'On the trigger: the icon darkens; no background change.', markup: 'native :hover on the trigger' },
|
|
44
|
+
focus: { selector: ' > * > * > button:focus-visible', description: 'On the trigger: the 4px brand ring.', markup: 'native :focus-visible on the trigger' },
|
|
45
|
+
disabled: { selector: ' > * > * > button:disabled', description: 'On the trigger: 50% opacity, cannot open.', markup: 'disabled on the trigger button' },
|
|
46
|
+
},
|
|
47
|
+
base: {
|
|
48
|
+
root: { display: 'flex', 'flex-direction': 'column', width: '100%', 'min-width': '0', color: '{color.fg-default}' },
|
|
49
|
+
item: { 'border-bottom': HAIRLINE, 'min-width': '0' },
|
|
50
|
+
trigger: {
|
|
51
|
+
...RESET_BUTTON,
|
|
52
|
+
display: 'flex',
|
|
53
|
+
'align-items': 'flex-start',
|
|
54
|
+
'justify-content': 'space-between',
|
|
55
|
+
gap: '{space.4}',
|
|
56
|
+
width: '100%',
|
|
57
|
+
padding: '{space.4} 0',
|
|
58
|
+
...typeStyle('heading-xs'),
|
|
59
|
+
'text-align': 'left',
|
|
60
|
+
color: '{color.fg-default}',
|
|
61
|
+
'border-radius': '{radius.sm}',
|
|
62
|
+
'transition-property': 'color, box-shadow',
|
|
63
|
+
'transition-duration': '{motion.duration.fast}',
|
|
64
|
+
'transition-timing-function': '{motion.easing.linear}',
|
|
65
|
+
},
|
|
66
|
+
icon: {
|
|
67
|
+
width: '{size.icon.md}',
|
|
68
|
+
height: '{size.icon.md}',
|
|
69
|
+
'flex-shrink': '0',
|
|
70
|
+
'margin-top': '{space.0.5}',
|
|
71
|
+
color: '{color.fg-subtle}',
|
|
72
|
+
'transition-property': 'transform, color',
|
|
73
|
+
'transition-duration': '{motion.duration.normal}',
|
|
74
|
+
'transition-timing-function': '{motion.easing.standard}',
|
|
75
|
+
},
|
|
76
|
+
'icon-v': { 'transition-property': 'opacity', 'transition-duration': '{motion.duration.fast}', 'transition-timing-function': '{motion.easing.linear}' },
|
|
77
|
+
content: {
|
|
78
|
+
padding: '0 {space.10} {space.4} 0',
|
|
79
|
+
...typeStyle('body-lg'),
|
|
80
|
+
color: '{color.fg-muted}',
|
|
81
|
+
'max-width': '{size.container.prose}',
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
variants: {
|
|
85
|
+
variant: {
|
|
86
|
+
default: { root: {} },
|
|
87
|
+
separated: {
|
|
88
|
+
root: { gap: '{space.4}' },
|
|
89
|
+
item: { 'border-bottom': '0', 'box-shadow': ring('{color.border-default}'), 'border-radius': '{radius.card}', 'background-color': '{color.bg-surface}', 'padding-inline': '{space.6}' },
|
|
90
|
+
},
|
|
91
|
+
card: {
|
|
92
|
+
root: { 'box-shadow': `${ring('{color.border-default}')}, {shadow.xs}`, 'border-radius': '{radius.card}', 'background-color': '{color.bg-surface}', overflow: 'hidden' },
|
|
93
|
+
item: { 'padding-inline': '{space.6}' },
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
size: {
|
|
97
|
+
sm: { trigger: { padding: '{space.3} 0', ...typeStyle('label-md') }, icon: { width: '{size.icon.sm}', height: '{size.icon.sm}', 'margin-top': '{space.0.5}' }, content: { padding: '0 {space.8} {space.3} 0', ...typeStyle('body-md') } },
|
|
98
|
+
md: { root: {} },
|
|
99
|
+
lg: { trigger: { padding: '{space.6} 0', ...typeStyle('heading-sm') }, icon: { width: '{size.icon.lg}', height: '{size.icon.lg}' }, content: { padding: '0 {space.12} {space.6} 0' } },
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
extraCss: `
|
|
103
|
+
.cn-accordion__item[data-state="open"] > * > .cn-accordion__trigger .cn-accordion__icon { transform: rotate(180deg); }
|
|
104
|
+
.cn-accordion__item[data-state="open"] > * > .cn-accordion__trigger .cn-accordion__icon:has(.cn-accordion__icon-v) { transform: none; }
|
|
105
|
+
.cn-accordion__item[data-state="open"] > * > .cn-accordion__trigger .cn-accordion__icon-v { opacity: 0; }
|
|
106
|
+
.cn-accordion__item[data-state="closed"] > .cn-accordion__content { display: none; }
|
|
107
|
+
.cn-accordion__trigger:hover .cn-accordion__icon { color: {color.fg-muted}; }
|
|
108
|
+
.cn-accordion__trigger:focus-visible { outline: none; box-shadow: {shadow.focus}; }
|
|
109
|
+
.cn-accordion__trigger:disabled { opacity: {opacity.disabled}; cursor: not-allowed; }
|
|
110
|
+
.cn-accordion[data-variant="default"] > .cn-accordion__item:last-child, .cn-accordion[data-variant="card"] > .cn-accordion__item:last-child { border-bottom: 0; }
|
|
111
|
+
.cn-accordion[data-variant="default"] > .cn-accordion__item:first-child > * > .cn-accordion__trigger { padding-top: 0; }`,
|
|
112
|
+
examples: [
|
|
113
|
+
ex('Default (md), first item open', `<div class="cn-accordion" data-variant="default" data-size="md" style="max-width:640px">${FAQ('faq-a')}</div>`, '16px semibold triggers on gray-200 hairlines, 20px chevron, 16px muted body.'),
|
|
114
|
+
ex('FAQ (lg) with plus / minus icons', `<div class="cn-accordion" data-variant="default" data-size="lg" style="max-width:768px">${FAQ('faq-b', PLUS)}</div>`, 'The reference FAQ section: 18px semibold, 24px padding, a 24px plus that becomes a minus when open.'),
|
|
115
|
+
ex('Separated', `<div class="cn-accordion" data-variant="separated" data-size="md" style="max-width:640px">${FAQ('faq-c')}</div>`, 'Each item is its own card (radius 12, gray-200 ring), 16px apart; use on the canvas.'),
|
|
116
|
+
ex('Card, small, with a disabled item', `<div class="cn-accordion" data-variant="card" data-size="sm" style="max-width:560px">${item('faq-d-1', 'open', 'Approval rules', 'Every outgoing message needs one approval. Replies to an approved thread can be auto-approved per conversation.')}${item('faq-d-2', 'closed', 'Sending window', 'Messages go out between 08:00 and 18:00 in the recipient\'s time zone.')}${item('faq-d-3', 'closed', 'Custom signatures (Business plan)', 'Upgrade to edit signatures per sender.', ' disabled')}</div>`, 'One card frame with 14px triggers; the disabled trigger stays readable.'),
|
|
117
|
+
],
|
|
118
|
+
rules: [
|
|
119
|
+
'Every item carries data-state="open|closed" and its trigger aria-expanded; never leave the state implicit.',
|
|
120
|
+
'Trigger labels are short noun phrases or questions in sentence case (≤ 10 words); no icons before the label, only the chevron or plus after it. One icon style per accordion.',
|
|
121
|
+
'Content is prose or a small form, capped at prose width with a 40px right inset so it never runs under the icon; it is not a place for tables or lists of records.',
|
|
122
|
+
'Default to all items closed except when there is one obvious first item; open at most one item by default.',
|
|
123
|
+
'Allow several items open at once unless the content is mutually exclusive; do not force single-open behavior for FAQs.',
|
|
124
|
+
'No hover background on the trigger; only the icon darkens. Focus ring on keyboard focus only.',
|
|
125
|
+
'default inside cards and sections; separated on the canvas for long lists; card for a compact block of 2–4 items; lg only for FAQ sections on marketing and help pages.',
|
|
126
|
+
'Do not nest accordions.',
|
|
127
|
+
],
|
|
128
|
+
a11y: [
|
|
129
|
+
'The trigger is a <button> inside a heading (h2–h4 at the right level) with aria-expanded and aria-controls pointing to the content id.',
|
|
130
|
+
'The content has role="region" and aria-labelledby pointing to the trigger id.',
|
|
131
|
+
'Keyboard: Enter/Space toggle; Tab moves between triggers. Arrow-key navigation is optional and must not trap focus.',
|
|
132
|
+
'Closed content is display none, so it is removed from the tab order and the accessibility tree; do not use visibility tricks that keep it focusable.',
|
|
133
|
+
'Disabled triggers stay visible and readable; if the reason matters, say it in the label ("(Business plan)").',
|
|
134
|
+
],
|
|
135
|
+
related: ['tabs', 'card', 'list', 'description-list', 'divider'],
|
|
136
|
+
};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { FOCUS_RING, RESET_BUTTON, TRANSITION_COLORS, ex, ICON, typeStyle } from "./_shared.mjs";
|
|
2
|
+
// Account card: the signed-in person as a row: 40px avatar with a presence dot,
|
|
3
|
+
// name and email, and a ⋯ button that opens the account Menu. Lives at the
|
|
4
|
+
// bottom of the Sidebar and the mobile drawer, and in account switchers.
|
|
5
|
+
const SELECTOR = '<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="M5 6.25l3-3 3 3M5 9.75l3 3 3-3"/></svg>';
|
|
6
|
+
const LOGOUT = '<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.5 13.5h-3a1 1 0 01-1-1v-9a1 1 0 011-1h3M10.5 11l3-3-3-3M13.5 8h-7"/></svg>';
|
|
7
|
+
const card = (o) => `<div class="cn-account-card" data-variant="${o.variant ?? 'card'}" data-size="${o.size ?? 'md'}"${o.attrs ?? ''}><span class="cn-account-card__avatar" role="img" aria-label="${o.name}${o.status ? `, ${o.status}` : ''}">${o.initials}${o.status ? `<span class="cn-account-card__status" data-status="${o.status}"></span>` : ''}</span><div class="cn-account-card__content"><span class="cn-account-card__name">${o.name}</span><span class="cn-account-card__email">${o.email}</span></div>${o.menu ?? `<button type="button" class="cn-account-card__menu" aria-label="Account menu" aria-haspopup="menu" aria-expanded="false">${SELECTOR}</button>`}</div>`;
|
|
8
|
+
const wrap = (inner, width = '296px') => `<div style="width:100%;max-width:${width}">${inner}</div>`;
|
|
9
|
+
const menuItem = (label, icon, attrs = '') => `<button type="button" role="menuitem" class="cn-menu__item"${attrs}>${icon.replace('cn-icon', 'cn-menu__icon')}<span class="cn-menu__text">${label}</span></button>`;
|
|
10
|
+
export const accountCard = {
|
|
11
|
+
name: 'AccountCard',
|
|
12
|
+
slug: 'account-card',
|
|
13
|
+
category: 'data-display',
|
|
14
|
+
description: 'The signed-in person as a row: a 40px avatar with a presence dot, name in label-md, email in body-sm muted, and a ⋯ button that opens the account Menu. A hairline card at the bottom of the Sidebar, a plain row inside menus, or a compact row in account switchers.',
|
|
15
|
+
usage: 'Use wherever the current account is shown with a way to act on it: the bottom of the Sidebar and the mobile drawer, the header of the account Menu, the rows of a workspace switcher. Not for other people in lists (use List with an Avatar) and not for a profile page header (PageHeader).',
|
|
16
|
+
anatomy: [
|
|
17
|
+
{ part: 'root', element: 'div', description: 'The row: flex, 12px gap and padding, bg-surface, hairline, radius card, shadow-xs. Relative so a Menu can anchor to it.' },
|
|
18
|
+
{ part: 'avatar', element: 'span', description: '40px circle with initials or an <img>; relative so the status dot sits on its edge. role="img" + aria-label including presence.' },
|
|
19
|
+
{ part: 'status', element: 'span', description: '10px presence dot at the bottom-right of the avatar with a 2px surface ring; data-status="online|away|busy|offline" colors it.', optional: true },
|
|
20
|
+
{ part: 'content', element: 'div', description: 'Name over email, fills the row, truncates.' },
|
|
21
|
+
{ part: 'name', element: 'span', description: 'label-md, one line, ellipsis.' },
|
|
22
|
+
{ part: 'email', element: 'span', description: 'body-sm muted, one line, ellipsis.' },
|
|
23
|
+
{ part: 'menu', element: 'button', description: '32px ghost button with a 16px up/down selector icon that opens the account Menu. aria-label "Account menu", aria-haspopup, aria-expanded.', optional: true },
|
|
24
|
+
],
|
|
25
|
+
props: {
|
|
26
|
+
variant: {
|
|
27
|
+
values: ['card', 'plain', 'compact'],
|
|
28
|
+
default: 'card',
|
|
29
|
+
description: 'card = hairline card with shadow-xs, the Sidebar account card. plain = no border or shadow, 8px padding, for the header of a Menu or a Popover. compact = 8px padding, 32px avatar and smaller text for rows in an account switcher.',
|
|
30
|
+
},
|
|
31
|
+
size: {
|
|
32
|
+
values: ['sm', 'md'],
|
|
33
|
+
default: 'md',
|
|
34
|
+
description: 'md = 40px avatar, label-md name, body-sm email (sidebars, drawers). sm = 32px avatar, label-sm name, body-xs email for dense panels and popovers.',
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
states: {
|
|
38
|
+
hover: { selector: '[data-interactive]:hover', description: 'Pointer over a clickable card (only when the whole row is the trigger, marked data-interactive): subtle fill.', markup: 'data-interactive on the root and native :hover' },
|
|
39
|
+
selected: { selector: '[aria-selected="true"], &[data-selected]', description: 'The current account in a switcher: action-colored border and a 1px ring.', markup: 'aria-selected="true" (inside a listbox) or data-selected on the root' },
|
|
40
|
+
menuHover: { selector: ' .cn-account-card__menu:hover', description: 'Pointer over the ⋯ button (extraCss): subtle fill, ink icon.', markup: 'native :hover on .cn-account-card__menu' },
|
|
41
|
+
menuOpen: { selector: ' .cn-account-card__menu[aria-expanded="true"]', description: 'The account Menu is open (extraCss): the button keeps the subtle fill.', markup: 'aria-expanded="true" on .cn-account-card__menu' },
|
|
42
|
+
menuFocus: { selector: ' .cn-account-card__menu:focus-visible', description: 'Keyboard focus on the ⋯ button shows the focus ring (extraCss).', markup: 'native :focus-visible on .cn-account-card__menu' },
|
|
43
|
+
},
|
|
44
|
+
base: {
|
|
45
|
+
root: {
|
|
46
|
+
position: 'relative',
|
|
47
|
+
display: 'flex',
|
|
48
|
+
'align-items': 'center',
|
|
49
|
+
gap: '{space.3}',
|
|
50
|
+
width: '100%',
|
|
51
|
+
'min-width': '0',
|
|
52
|
+
padding: '{space.3}',
|
|
53
|
+
'background-color': '{color.bg-surface}',
|
|
54
|
+
border: '{border.width.thin} solid {color.border-default}',
|
|
55
|
+
'border-radius': '{radius.card}',
|
|
56
|
+
'box-shadow': '{shadow.xs}',
|
|
57
|
+
color: '{color.fg-default}',
|
|
58
|
+
...TRANSITION_COLORS,
|
|
59
|
+
},
|
|
60
|
+
avatar: {
|
|
61
|
+
position: 'relative',
|
|
62
|
+
display: 'inline-flex',
|
|
63
|
+
'align-items': 'center',
|
|
64
|
+
'justify-content': 'center',
|
|
65
|
+
'flex-shrink': '0',
|
|
66
|
+
width: '{space.10}',
|
|
67
|
+
height: '{space.10}',
|
|
68
|
+
'border-radius': '{radius.full}',
|
|
69
|
+
'background-color': '{color.bg-subtle}',
|
|
70
|
+
color: '{color.fg-muted}',
|
|
71
|
+
...typeStyle('label-sm'),
|
|
72
|
+
'letter-spacing': '{font.letterSpacing.wide}',
|
|
73
|
+
'text-transform': 'uppercase',
|
|
74
|
+
'user-select': 'none',
|
|
75
|
+
},
|
|
76
|
+
status: {
|
|
77
|
+
position: 'absolute',
|
|
78
|
+
bottom: '0',
|
|
79
|
+
right: '0',
|
|
80
|
+
width: '{space.2.5}',
|
|
81
|
+
height: '{space.2.5}',
|
|
82
|
+
'border-radius': '{radius.full}',
|
|
83
|
+
'background-color': '{color.fg-subtle}',
|
|
84
|
+
'box-shadow': '0 0 0 {border.width.medium} {color.bg-surface}',
|
|
85
|
+
},
|
|
86
|
+
content: { flex: '1 1 auto', 'min-width': '0', display: 'flex', 'flex-direction': 'column', gap: '{space.0.5}' },
|
|
87
|
+
name: { ...typeStyle('label-md'), 'line-height': '{font.lineHeight.snug}', color: '{color.fg-default}', overflow: 'hidden', 'text-overflow': 'ellipsis', 'white-space': 'nowrap' },
|
|
88
|
+
email: { ...typeStyle('body-sm'), color: '{color.fg-muted}', overflow: 'hidden', 'text-overflow': 'ellipsis', 'white-space': 'nowrap' },
|
|
89
|
+
menu: {
|
|
90
|
+
...RESET_BUTTON,
|
|
91
|
+
display: 'inline-flex',
|
|
92
|
+
'align-items': 'center',
|
|
93
|
+
'justify-content': 'center',
|
|
94
|
+
'flex-shrink': '0',
|
|
95
|
+
width: '{space.8}',
|
|
96
|
+
height: '{space.8}',
|
|
97
|
+
'border-radius': '{radius.md}',
|
|
98
|
+
color: '{color.fg-subtle}',
|
|
99
|
+
...TRANSITION_COLORS,
|
|
100
|
+
},
|
|
101
|
+
'@states': {
|
|
102
|
+
hover: { root: { 'background-color': '{color.bg-subtle}', cursor: 'pointer' } },
|
|
103
|
+
selected: { root: { 'border-color': '{color.border-action}', 'box-shadow': '0 0 0 {border.width.thin} {color.border-action}' } },
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
variants: {
|
|
107
|
+
variant: {
|
|
108
|
+
card: { root: {} },
|
|
109
|
+
plain: { root: { border: '0', 'box-shadow': 'none', 'background-color': 'transparent', padding: '{space.2}' } },
|
|
110
|
+
compact: {
|
|
111
|
+
root: { padding: '{space.2}', gap: '{space.2.5}' },
|
|
112
|
+
avatar: { width: '{space.8}', height: '{space.8}', 'font-size': '{font.size.2xs}' },
|
|
113
|
+
status: { width: '{space.2}', height: '{space.2}' },
|
|
114
|
+
name: { ...typeStyle('label-sm'), 'line-height': '{font.lineHeight.snug}' },
|
|
115
|
+
email: { ...typeStyle('body-xs') },
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
size: {
|
|
119
|
+
md: { root: {} },
|
|
120
|
+
sm: {
|
|
121
|
+
avatar: { width: '{space.8}', height: '{space.8}', 'font-size': '{font.size.2xs}' },
|
|
122
|
+
status: { width: '{space.2}', height: '{space.2}' },
|
|
123
|
+
name: { ...typeStyle('label-sm'), 'line-height': '{font.lineHeight.snug}' },
|
|
124
|
+
email: { ...typeStyle('body-xs') },
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
extraCss: `
|
|
129
|
+
.cn-account-card__avatar > img { display: block; width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
|
|
130
|
+
.cn-account-card__status[data-status="online"] { background-color: {color.bg-success}; }
|
|
131
|
+
.cn-account-card__status[data-status="away"] { background-color: {color.bg-warning}; }
|
|
132
|
+
.cn-account-card__status[data-status="busy"] { background-color: {color.bg-danger}; }
|
|
133
|
+
.cn-account-card__status[data-status="offline"] { background-color: {color.fg-subtle}; }
|
|
134
|
+
.cn-account-card__menu .cn-icon { width: {size.icon.md}; height: {size.icon.md}; }
|
|
135
|
+
.cn-account-card__menu:hover { background-color: {color.bg-subtle}; color: {color.fg-default}; }
|
|
136
|
+
.cn-account-card__menu[aria-expanded="true"] { background-color: {color.bg-subtle}; color: {color.fg-default}; }
|
|
137
|
+
.cn-account-card__menu:focus-visible { outline: none; box-shadow: ${FOCUS_RING['box-shadow']}; }
|
|
138
|
+
.cn-account-card[data-variant="plain"][data-selected], .cn-account-card[data-variant="plain"][aria-selected="true"] { background-color: {color.bg-subtle}; border: 0; box-shadow: none; }`,
|
|
139
|
+
examples: [
|
|
140
|
+
ex('Card (sidebar)', wrap(card({ initials: 'MC', name: 'Maya Chen', email: 'maya@lumen.co', status: 'online' })), 'The bottom-of-sidebar card: 40px avatar with a presence dot, name, email, and the selector button that opens the account Menu.'),
|
|
141
|
+
ex('Plain (inside a menu or popover)', wrap(card({ variant: 'plain', initials: 'DC', name: 'Daniel Costa', email: 'daniel@lumen.co', status: 'away', menu: '' })), 'No border or shadow, 8px padding; the surrounding panel provides the frame.'),
|
|
142
|
+
ex('Compact rows in a switcher', wrap(`<div role="listbox" aria-label="Switch account" style="display:flex;flex-direction:column;gap:var(--cn-space-1)">${card({ variant: 'compact', initials: 'MC', name: 'Maya Chen', email: 'maya@lumen.co', status: 'online', attrs: ' role="option" aria-selected="true"', menu: '' })}${card({ variant: 'compact', initials: 'LD', name: 'Lumen Design', email: 'design@lumen.co', attrs: ' role="option" aria-selected="false" data-interactive', menu: '' })}${card({ variant: 'compact', initials: 'SA', name: 'Sofia Almeida', email: 'sofia@lumen.co', status: 'busy', attrs: ' role="option" aria-selected="false" data-interactive', menu: '' })}</div>`), 'aria-selected="true" draws the action-colored ring; the other rows are clickable (data-interactive).'),
|
|
143
|
+
ex('Small size and presence values', wrap(`<div style="display:flex;flex-direction:column;gap:var(--cn-space-3)">${card({ size: 'sm', initials: 'AK', name: 'Aisha Khan', email: 'aisha@lumen.co', status: 'online' })}${card({ size: 'sm', initials: 'NB', name: 'Noah Berg', email: 'noah@lumen.co', status: 'offline' })}</div>`), 'sm = 32px avatar, label-sm name, body-xs email. online · away · busy · offline.'),
|
|
144
|
+
],
|
|
145
|
+
recipes: [
|
|
146
|
+
ex('With the account menu open', `<div class="cn-menu-anchor" style="width:100%;max-width:296px;min-height:232px">${card({ initials: 'MC', name: 'Maya Chen', email: 'maya@lumen.co', status: 'online', menu: `<button type="button" class="cn-account-card__menu" id="acct-menu-trigger" aria-label="Account menu" aria-haspopup="menu" aria-expanded="true" aria-controls="acct-menu">${SELECTOR}</button>` })}<div class="cn-menu" data-size="md" role="menu" id="acct-menu" aria-labelledby="acct-menu-trigger" data-state="open" data-floating style="width:100%">${menuItem('View profile', ICON.user)}${menuItem('Settings', ICON.settings)}<div class="cn-menu__separator" role="separator"></div>${menuItem('Log out', LOGOUT)}</div></div>`, 'The Menu anchors to the card with .cn-menu-anchor + data-floating and spans its width. Log out is last, after a separator.'),
|
|
147
|
+
],
|
|
148
|
+
rules: [
|
|
149
|
+
'Shows the signed-in person only: their own name, their own email. Other people are List rows with an Avatar.',
|
|
150
|
+
'One action: the selector button opens the account Menu (profile, settings, switch account, log out). Never add a second button to the row.',
|
|
151
|
+
'Presence dots only when the product has real-time presence; otherwise omit the status part rather than showing everyone online.',
|
|
152
|
+
'card in the Sidebar and the mobile drawer; plain as the header of the account Menu or a Popover; compact for the rows of an account switcher.',
|
|
153
|
+
'Name and email truncate with an ellipsis; the full values are in the avatar\'s aria-label and the Menu header. Never wrap to two lines.',
|
|
154
|
+
'A clickable whole row (data-interactive) is for switchers only; in the Sidebar the row is static and the button is the trigger.',
|
|
155
|
+
'The selected account in a switcher gets the action-colored ring; hover uses the subtle fill. Never both on the same row.',
|
|
156
|
+
'Keep the avatar neutral (initials on bg-subtle or the photo); the accent tone is reserved for the AI agent.',
|
|
157
|
+
],
|
|
158
|
+
a11y: [
|
|
159
|
+
'The avatar carries role="img" and an aria-label with the full name and presence ("Maya Chen, online"); the dot alone is decorative.',
|
|
160
|
+
'The menu button has aria-label "Account menu", aria-haspopup="menu", aria-expanded and aria-controls pointing at the Menu; Escape closes the menu and returns focus to it.',
|
|
161
|
+
'In a switcher the rows are role="option" inside role="listbox" with aria-selected; Up/Down move, Enter selects.',
|
|
162
|
+
'Name and email are plain text so they are read in order; do not hide them with CSS in narrow layouts, truncate instead.',
|
|
163
|
+
'Contrast: name in fg-default, email in fg-muted (AA on bg-surface and bg-subtle).',
|
|
164
|
+
],
|
|
165
|
+
related: ['sidebar', 'mobile-header', 'menu', 'avatar', 'popover', 'list'],
|
|
166
|
+
};
|