@profpowell/vb-design-system 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 +122 -0
- package/dist/vb-design-system.css +993 -0
- package/dist/vb-design-system.js +4482 -0
- package/dist/vb-design-system.min.css +1 -0
- package/dist/vb-design-system.min.js +513 -0
- package/package.json +78 -0
- package/src/index.css +21 -0
- package/src/index.js +24 -0
- package/src/lib/bundle-registry.js +156 -0
- package/src/lib/vb-element.js +96 -0
- package/src/patterns/do-dont.css +86 -0
- package/src/patterns/token-table.css +67 -0
- package/src/utils/copy-init.js +287 -0
- package/src/web-components/accessibility-specimen/api.json +51 -0
- package/src/web-components/accessibility-specimen/logic.js +232 -0
- package/src/web-components/accessibility-specimen/static.html +41 -0
- package/src/web-components/accessibility-specimen/styles.css +156 -0
- package/src/web-components/brand-specimen/api.json +37 -0
- package/src/web-components/brand-specimen/logic.js +61 -0
- package/src/web-components/brand-specimen/static.html +77 -0
- package/src/web-components/brand-specimen/styles.css +10 -0
- package/src/web-components/breakpoint-specimen/api.json +44 -0
- package/src/web-components/breakpoint-specimen/logic.js +186 -0
- package/src/web-components/breakpoint-specimen/static.html +38 -0
- package/src/web-components/breakpoint-specimen/styles.css +180 -0
- package/src/web-components/color-palette/api.json +73 -0
- package/src/web-components/color-palette/logic.js +280 -0
- package/src/web-components/color-palette/static.html +54 -0
- package/src/web-components/color-palette/styles.css +113 -0
- package/src/web-components/color-picker/_color-utils.js +210 -0
- package/src/web-components/component-sampler/api.json +15 -0
- package/src/web-components/component-sampler/logic.js +120 -0
- package/src/web-components/component-sampler/static.html +47 -0
- package/src/web-components/component-sampler/styles.css +9 -0
- package/src/web-components/font-pairer/_font-data.js +95 -0
- package/src/web-components/font-pairer/api.json +56 -0
- package/src/web-components/font-pairer/logic.js +401 -0
- package/src/web-components/font-pairer/static.html +9 -0
- package/src/web-components/font-pairer/styles.css +10 -0
- package/src/web-components/gradient-builder/_gradient-utils.js +54 -0
- package/src/web-components/gradient-builder/api.json +63 -0
- package/src/web-components/gradient-builder/logic.js +261 -0
- package/src/web-components/gradient-builder/static.html +9 -0
- package/src/web-components/gradient-builder/styles.css +10 -0
- package/src/web-components/icon-set/api.json +39 -0
- package/src/web-components/icon-set/logic.js +133 -0
- package/src/web-components/icon-set/static.html +36 -0
- package/src/web-components/icon-set/styles.css +82 -0
- package/src/web-components/icon-specimen/api.json +46 -0
- package/src/web-components/icon-specimen/logic.js +67 -0
- package/src/web-components/icon-specimen/static.html +39 -0
- package/src/web-components/icon-specimen/styles.css +38 -0
- package/src/web-components/layout-specimen/api.json +12 -0
- package/src/web-components/layout-specimen/logic.js +219 -0
- package/src/web-components/layout-specimen/static.html +22 -0
- package/src/web-components/layout-specimen/styles.css +95 -0
- package/src/web-components/motion-specimen/api.json +54 -0
- package/src/web-components/motion-specimen/logic.js +146 -0
- package/src/web-components/motion-specimen/static.html +13 -0
- package/src/web-components/motion-specimen/styles.css +29 -0
- package/src/web-components/palette-generator/_palette-utils.js +133 -0
- package/src/web-components/palette-generator/api.json +79 -0
- package/src/web-components/palette-generator/logic.js +288 -0
- package/src/web-components/palette-generator/static.html +47 -0
- package/src/web-components/palette-generator/styles.css +36 -0
- package/src/web-components/semantic-palette/_color-math.js +149 -0
- package/src/web-components/semantic-palette/api.json +41 -0
- package/src/web-components/semantic-palette/logic.js +400 -0
- package/src/web-components/semantic-palette/static.html +42 -0
- package/src/web-components/semantic-palette/styles.css +20 -0
- package/src/web-components/spacing-specimen/api.json +63 -0
- package/src/web-components/spacing-specimen/logic.js +132 -0
- package/src/web-components/spacing-specimen/static.html +71 -0
- package/src/web-components/spacing-specimen/styles.css +58 -0
- package/src/web-components/theme-catalog/api.json +14 -0
- package/src/web-components/theme-catalog/logic.js +228 -0
- package/src/web-components/theme-catalog/static.html +15 -0
- package/src/web-components/theme-export/api.json +18 -0
- package/src/web-components/theme-export/dtcg-serialize.js +417 -0
- package/src/web-components/theme-export/logic.js +201 -0
- package/src/web-components/theme-export/static.html +12 -0
- package/src/web-components/theme-export/theme-css-parse.js +93 -0
- package/src/web-components/theme-import/api.json +14 -0
- package/src/web-components/theme-import/catalog/atlassian.tokens.json +303 -0
- package/src/web-components/theme-import/catalog/bootstrap.tokens.json +304 -0
- package/src/web-components/theme-import/catalog/carbon.tokens.json +301 -0
- package/src/web-components/theme-import/catalog/catppuccin-mocha.tokens.json +301 -0
- package/src/web-components/theme-import/catalog/govuk.tokens.json +301 -0
- package/src/web-components/theme-import/catalog/manifest.json +132 -0
- package/src/web-components/theme-import/catalog/material.tokens.json +289 -0
- package/src/web-components/theme-import/catalog/salesforce.tokens.json +301 -0
- package/src/web-components/theme-import/catalog/tailwind.tokens.json +302 -0
- package/src/web-components/theme-import/dtcg-deserialize.js +322 -0
- package/src/web-components/theme-import/logic.js +263 -0
- package/src/web-components/theme-import/static.html +15 -0
- package/src/web-components/token-specimen/api.json +24 -0
- package/src/web-components/token-specimen/logic.js +256 -0
- package/src/web-components/token-specimen/static.html +47 -0
- package/src/web-components/token-specimen/styles.css +10 -0
- package/src/web-components/type-specimen/api.json +91 -0
- package/src/web-components/type-specimen/logic.js +156 -0
- package/src/web-components/type-specimen/static.html +84 -0
- package/src/web-components/type-specimen/styles.css +131 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Palette Generator (Static Fallback)</title>
|
|
7
|
+
<style>
|
|
8
|
+
/*
|
|
9
|
+
Minimal baseline styling for the no-JS fallback.
|
|
10
|
+
A production page would load the project stylesheet instead;
|
|
11
|
+
these rules exist only so the file is self-contained.
|
|
12
|
+
*/
|
|
13
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
14
|
+
|
|
15
|
+
body {
|
|
16
|
+
font-family: system-ui, sans-serif;
|
|
17
|
+
line-height: 1.5;
|
|
18
|
+
max-inline-size: 40rem;
|
|
19
|
+
margin-inline: auto;
|
|
20
|
+
padding: 2rem 1rem;
|
|
21
|
+
}
|
|
22
|
+
</style>
|
|
23
|
+
</head>
|
|
24
|
+
<body>
|
|
25
|
+
|
|
26
|
+
<!--
|
|
27
|
+
Upgrade relationship
|
|
28
|
+
====================
|
|
29
|
+
This static HTML IS the baseline that <palette-generator> enhances.
|
|
30
|
+
|
|
31
|
+
When JavaScript loads and the <palette-generator> custom element is
|
|
32
|
+
defined, the markup is progressively enhanced with additional
|
|
33
|
+
interactivity, ARIA attributes, and keyboard navigation.
|
|
34
|
+
|
|
35
|
+
Without JS the core content remains accessible and functional.
|
|
36
|
+
-->
|
|
37
|
+
|
|
38
|
+
<h1>Palette Generator</h1>
|
|
39
|
+
|
|
40
|
+
<noscript>
|
|
41
|
+
<p>This component works best with JavaScript enabled.</p>
|
|
42
|
+
</noscript>
|
|
43
|
+
|
|
44
|
+
<!-- TODO: Add meaningful static fallback markup for <palette-generator> -->
|
|
45
|
+
|
|
46
|
+
</body>
|
|
47
|
+
</html>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* palette-generator styles
|
|
3
|
+
* Seed picker label/spacing and export toolbar hover states.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
palette-generator {
|
|
7
|
+
display: block;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
palette-generator .pg-seed-label {
|
|
11
|
+
display: block;
|
|
12
|
+
font-size: var(--font-size-sm, 0.875rem);
|
|
13
|
+
font-weight: 600;
|
|
14
|
+
color: var(--color-text-muted, #666);
|
|
15
|
+
margin-block-end: var(--size-2xs, 0.25rem);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
palette-generator > color-picker,
|
|
19
|
+
palette-generator > input[type="color"] {
|
|
20
|
+
margin-block-end: var(--size-s, 0.75rem);
|
|
21
|
+
padding-block-end: var(--size-s, 0.75rem);
|
|
22
|
+
border-block-end: 1px solid var(--color-border-subtle, #e5e5e5);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
palette-generator .pg-export {
|
|
26
|
+
margin-block-start: var(--size-s, 0.75rem);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
palette-generator .pg-export button:hover {
|
|
30
|
+
background: var(--surface-hover, #f5f5f5);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
palette-generator .pg-export button:focus-visible {
|
|
34
|
+
outline: var(--focus-ring-width, 2px) solid var(--focus-ring-color, currentColor);
|
|
35
|
+
outline-offset: 2px;
|
|
36
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Color math helpers for semantic-palette.
|
|
3
|
+
* - hexToOklch / oklchToHex: round-trip sRGB hex ↔ OKLCh. Reference: Björn Ottosson's OKLab paper.
|
|
4
|
+
* - resolveToHex: normalize any CSS color string to #rrggbb via a DOM probe.
|
|
5
|
+
* - relativeLuminance + contrastRatio: WCAG 2.1 contrast for text-vs-background.
|
|
6
|
+
* - pickContrastingHex: black-or-white pick by perceived luminance.
|
|
7
|
+
*/
|
|
8
|
+
import { hexToRgb, rgbToHex } from '../color-picker/_color-utils.js';
|
|
9
|
+
|
|
10
|
+
const srgbToLinear = (c) =>
|
|
11
|
+
c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
12
|
+
|
|
13
|
+
const linearToSrgb = (c) =>
|
|
14
|
+
c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
|
|
15
|
+
|
|
16
|
+
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Convert a #rrggbb hex string into OKLCh.
|
|
20
|
+
* @param {string} hex
|
|
21
|
+
* @returns {{ l: number, c: number, h: number }} l is 0–1, c ≈ 0–0.4, h in degrees 0–360
|
|
22
|
+
*/
|
|
23
|
+
export function hexToOklch(hex) {
|
|
24
|
+
const { r, g, b } = hexToRgb(hex);
|
|
25
|
+
const lr = srgbToLinear(r / 255);
|
|
26
|
+
const lg = srgbToLinear(g / 255);
|
|
27
|
+
const lb = srgbToLinear(b / 255);
|
|
28
|
+
|
|
29
|
+
const lcone = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb;
|
|
30
|
+
const mcone = 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb;
|
|
31
|
+
const scone = 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb;
|
|
32
|
+
|
|
33
|
+
const lp = Math.cbrt(lcone);
|
|
34
|
+
const mp = Math.cbrt(mcone);
|
|
35
|
+
const sp = Math.cbrt(scone);
|
|
36
|
+
|
|
37
|
+
const L = 0.2104542553 * lp + 0.7936177850 * mp - 0.0040720468 * sp;
|
|
38
|
+
const A = 1.9779984951 * lp - 2.4285922050 * mp + 0.4505937099 * sp;
|
|
39
|
+
const B = 0.0259040371 * lp + 0.7827717662 * mp - 0.8086757660 * sp;
|
|
40
|
+
|
|
41
|
+
const c = Math.sqrt(A * A + B * B);
|
|
42
|
+
let h = (Math.atan2(B, A) * 180) / Math.PI;
|
|
43
|
+
if (h < 0) h += 360;
|
|
44
|
+
|
|
45
|
+
return { l: L, c, h };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Convert OKLCh (l: 0–1, c: 0–~0.4, h: 0–360°) into a #rrggbb hex string.
|
|
50
|
+
* Gamut-clamps out-of-sRGB values to [0,1] before encoding.
|
|
51
|
+
*/
|
|
52
|
+
export function oklchToHex(l, c, h) {
|
|
53
|
+
const hr = (h * Math.PI) / 180;
|
|
54
|
+
const A = c * Math.cos(hr);
|
|
55
|
+
const B = c * Math.sin(hr);
|
|
56
|
+
|
|
57
|
+
const lp = l + 0.3963377774 * A + 0.2158037573 * B;
|
|
58
|
+
const mp = l - 0.1055613458 * A - 0.0638541728 * B;
|
|
59
|
+
const sp = l - 0.0894841775 * A - 1.2914855480 * B;
|
|
60
|
+
|
|
61
|
+
const lcone = lp * lp * lp;
|
|
62
|
+
const mcone = mp * mp * mp;
|
|
63
|
+
const scone = sp * sp * sp;
|
|
64
|
+
|
|
65
|
+
const lr = 4.0767416621 * lcone - 3.3077115913 * mcone + 0.2309699292 * scone;
|
|
66
|
+
const lg = -1.2684380046 * lcone + 2.6097574011 * mcone - 0.3413193965 * scone;
|
|
67
|
+
const lb = -0.0041960863 * lcone - 0.7034186147 * mcone + 1.7076147010 * scone;
|
|
68
|
+
|
|
69
|
+
const r = clamp01(linearToSrgb(lr));
|
|
70
|
+
const g = clamp01(linearToSrgb(lg));
|
|
71
|
+
const b = clamp01(linearToSrgb(lb));
|
|
72
|
+
|
|
73
|
+
return rgbToHex(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve any CSS color string (hex, oklch, rgb, named) to a #rrggbb hex.
|
|
78
|
+
* Uses a DOM probe element to let the browser do color-space math.
|
|
79
|
+
* Returns null when the probe is unavailable or the string is invalid.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} input
|
|
82
|
+
* @param {HTMLElement} [probeEl] optional reusable probe (span attached off-screen)
|
|
83
|
+
*/
|
|
84
|
+
export function resolveToHex(input, probeEl) {
|
|
85
|
+
if (!input) return null;
|
|
86
|
+
const s = String(input).trim();
|
|
87
|
+
if (/^#[0-9a-f]{6}$/i.test(s)) return s.toLowerCase();
|
|
88
|
+
if (/^#[0-9a-f]{3}$/i.test(s)) {
|
|
89
|
+
return ('#' + s.slice(1).split('').map((c) => c + c).join('')).toLowerCase();
|
|
90
|
+
}
|
|
91
|
+
if (typeof document === 'undefined') return null;
|
|
92
|
+
|
|
93
|
+
const probe = probeEl || document.createElement('span');
|
|
94
|
+
if (!probeEl) {
|
|
95
|
+
probe.style.display = 'none';
|
|
96
|
+
document.body.appendChild(probe);
|
|
97
|
+
}
|
|
98
|
+
probe.style.color = '';
|
|
99
|
+
probe.style.color = s;
|
|
100
|
+
const computed = getComputedStyle(probe).color;
|
|
101
|
+
if (!probeEl) probe.remove();
|
|
102
|
+
|
|
103
|
+
const m = computed.match(/rgba?\(\s*([0-9.]+)[\s,]+([0-9.]+)[\s,]+([0-9.]+)/);
|
|
104
|
+
if (!m) return null;
|
|
105
|
+
return rgbToHex(Math.round(+m[1]), Math.round(+m[2]), Math.round(+m[3]));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Format OKLCh values into a CSS oklch() string.
|
|
110
|
+
* @param {{l:number,c:number,h:number}} v
|
|
111
|
+
*/
|
|
112
|
+
export function formatOklch({ l, c, h }) {
|
|
113
|
+
return `oklch(${(l * 100).toFixed(1)}% ${c.toFixed(3)} ${h.toFixed(1)})`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Relative luminance per WCAG 2.1 */
|
|
117
|
+
export function relativeLuminance(hex) {
|
|
118
|
+
const { r, g, b } = hexToRgb(hex);
|
|
119
|
+
const channel = (v) => {
|
|
120
|
+
const s = v / 255;
|
|
121
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
122
|
+
};
|
|
123
|
+
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** WCAG contrast ratio between two hex colors (1–21). */
|
|
127
|
+
export function contrastRatio(hex1, hex2) {
|
|
128
|
+
const l1 = relativeLuminance(hex1);
|
|
129
|
+
const l2 = relativeLuminance(hex2);
|
|
130
|
+
const lighter = Math.max(l1, l2);
|
|
131
|
+
const darker = Math.min(l1, l2);
|
|
132
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* WCAG level for a ratio at normal text size.
|
|
137
|
+
* @param {number} ratio
|
|
138
|
+
* @returns {'AAA'|'AA'|'Fail'}
|
|
139
|
+
*/
|
|
140
|
+
export function wcagLevel(ratio) {
|
|
141
|
+
if (ratio >= 7) return 'AAA';
|
|
142
|
+
if (ratio >= 4.5) return 'AA';
|
|
143
|
+
return 'Fail';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Pick black or white for contrast against a given hex background. */
|
|
147
|
+
export function pickContrastingHex(hex) {
|
|
148
|
+
return relativeLuminance(hex) > 0.4 ? '#000000' : '#ffffff';
|
|
149
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../../schemas/api.schema.json",
|
|
3
|
+
"element": "semantic-palette",
|
|
4
|
+
"type": "web-component",
|
|
5
|
+
"description": "Preview a palette in use as VB semantic roles. Reads colors from a descendant <color-palette> or <palette-generator> and renders one preview card per role with per-pairing WCAG chips. Editing happens upstream in the palette; this component is a preview + theme CSS exporter.",
|
|
6
|
+
"attributes": [
|
|
7
|
+
{
|
|
8
|
+
"name": "colors",
|
|
9
|
+
"kind": "host-api",
|
|
10
|
+
"purpose": "config",
|
|
11
|
+
"type": "string",
|
|
12
|
+
"description": "Comma-separated hex palette (fallback when no <color-palette> or <palette-generator> child)."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "roles",
|
|
16
|
+
"kind": "host-api",
|
|
17
|
+
"purpose": "config",
|
|
18
|
+
"type": "string",
|
|
19
|
+
"description": "Comma-separated roles in assignment order. Valid: primary, secondary, accent, success, warning, error, info. Default: \"primary,secondary,accent\"."
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"name": "show-export",
|
|
23
|
+
"kind": "host-api",
|
|
24
|
+
"purpose": "config",
|
|
25
|
+
"type": "boolean",
|
|
26
|
+
"description": "Show Copy Theme CSS / Copy JSON toolbar."
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"name": "label",
|
|
30
|
+
"kind": "host-api",
|
|
31
|
+
"purpose": "config",
|
|
32
|
+
"type": "string",
|
|
33
|
+
"description": "Optional heading label above the previews."
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"events": [
|
|
37
|
+
{ "name": "semantic-palette:change" }
|
|
38
|
+
],
|
|
39
|
+
"childAttributes": [],
|
|
40
|
+
"structure": []
|
|
41
|
+
}
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* semantic-palette: Preview a palette in use as VB semantic roles.
|
|
3
|
+
*
|
|
4
|
+
* Reads colors from a descendant <color-palette> or <palette-generator>
|
|
5
|
+
* (or its own `colors` attribute) and renders one preview card per role —
|
|
6
|
+
* button/link/badge for brand roles, alert/badge/inline for status roles —
|
|
7
|
+
* each with a per-pairing WCAG chip. Editing happens upstream in the palette;
|
|
8
|
+
* this component is a live preview that also exports a theme CSS block.
|
|
9
|
+
*
|
|
10
|
+
* @attr {string} colors - Comma-separated hex palette (fallback when no child source).
|
|
11
|
+
* @attr {string} roles - Comma-separated roles in assignment order. Default: "primary,secondary,accent".
|
|
12
|
+
* Valid: primary, secondary, accent, success, warning, error, info.
|
|
13
|
+
* @attr {boolean} show-export - Show Copy Theme CSS / Copy JSON toolbar.
|
|
14
|
+
* @attr {string} label - Optional heading label above the previews.
|
|
15
|
+
*
|
|
16
|
+
* @fires semantic-palette:change - On initial render and every palette update.
|
|
17
|
+
* detail: { mapping: Record<role,hex>, tokens: string, source }
|
|
18
|
+
*/
|
|
19
|
+
import { registerComponent } from '../../lib/bundle-registry.js';
|
|
20
|
+
import { VBElement } from '../../lib/vb-element.js';
|
|
21
|
+
import { copyText } from '../../utils/copy-init.js';
|
|
22
|
+
import {
|
|
23
|
+
hexToOklch,
|
|
24
|
+
oklchToHex,
|
|
25
|
+
formatOklch,
|
|
26
|
+
contrastRatio,
|
|
27
|
+
wcagLevel,
|
|
28
|
+
pickContrastingHex,
|
|
29
|
+
resolveToHex,
|
|
30
|
+
} from './_color-math.js';
|
|
31
|
+
import { generatePalette } from '../palette-generator/_palette-utils.js';
|
|
32
|
+
|
|
33
|
+
const BRAND_ROLES = ['primary', 'secondary', 'accent'];
|
|
34
|
+
const STATUS_ROLES = ['success', 'warning', 'error', 'info'];
|
|
35
|
+
const ALL_ROLES = [...BRAND_ROLES, ...STATUS_ROLES];
|
|
36
|
+
|
|
37
|
+
const ROLE_LABELS = {
|
|
38
|
+
primary: 'Primary', secondary: 'Secondary', accent: 'Accent',
|
|
39
|
+
success: 'Success', warning: 'Warning', error: 'Error', info: 'Info',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const LEVEL_COLORS = {
|
|
43
|
+
aaa: { bg: 'oklch(92% 0.08 145)', fg: 'oklch(30% 0.12 145)' },
|
|
44
|
+
aa: { bg: 'oklch(94% 0.08 90)', fg: 'oklch(35% 0.12 70)' },
|
|
45
|
+
fail: { bg: 'oklch(92% 0.08 25)', fg: 'oklch(35% 0.15 25)' },
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const MONO = 'var(--font-mono, monospace)';
|
|
49
|
+
const MUTED = 'var(--color-text-muted, #666)';
|
|
50
|
+
const BORDER = 'var(--color-border, #ddd)';
|
|
51
|
+
const SURFACE = 'var(--color-surface, #fff)';
|
|
52
|
+
const TEXT = 'var(--color-text, #222)';
|
|
53
|
+
const RADIUS_S = 'var(--radius-s, 0.25rem)';
|
|
54
|
+
const RADIUS_M = 'var(--radius-m, 0.5rem)';
|
|
55
|
+
|
|
56
|
+
class SemanticPalette extends VBElement {
|
|
57
|
+
static observedAttributes = ['colors', 'roles', 'show-export', 'label'];
|
|
58
|
+
|
|
59
|
+
/** @type {string[]} */ #colors = [];
|
|
60
|
+
/** @type {string[]} */ #roles = [];
|
|
61
|
+
/** @type {HTMLElement|null} */ #source = null;
|
|
62
|
+
/** @type {HTMLElement|null} */ #previews = null;
|
|
63
|
+
/** @type {HTMLElement|null} */ #probe = null;
|
|
64
|
+
|
|
65
|
+
setup() {
|
|
66
|
+
this.#probe = document.createElement('span');
|
|
67
|
+
this.#probe.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none';
|
|
68
|
+
this.append(this.#probe);
|
|
69
|
+
|
|
70
|
+
this.#source = this.querySelector('color-palette, palette-generator');
|
|
71
|
+
this.#resolveRoles();
|
|
72
|
+
this.#readColors();
|
|
73
|
+
this.#renderShell();
|
|
74
|
+
this.#renderPreviews('init');
|
|
75
|
+
this.#wireSource();
|
|
76
|
+
this.#wireExport();
|
|
77
|
+
this.#emitChange('init');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
attributeChangedCallback(name) {
|
|
81
|
+
if (!this.isConnected || !this.hasAttribute('data-upgraded')) return;
|
|
82
|
+
if (name === 'roles') this.#resolveRoles();
|
|
83
|
+
if (name === 'colors') this.#readColors();
|
|
84
|
+
// Shell (label/export toolbar) can safely re-render; it doesn't contain any picker
|
|
85
|
+
this.#renderShell();
|
|
86
|
+
this.#renderPreviews('attribute');
|
|
87
|
+
this.#wireExport();
|
|
88
|
+
this.#emitChange('attribute');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
teardown() {
|
|
92
|
+
this.#probe?.remove();
|
|
93
|
+
this.#probe = null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Source + state ─────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
#readColors() {
|
|
99
|
+
// 1. color-palette child
|
|
100
|
+
const cp = this.querySelector('color-palette');
|
|
101
|
+
if (cp) {
|
|
102
|
+
if (typeof /** @type {any} */ (cp).colors !== 'undefined' && /** @type {any} */ (cp).colors.length) {
|
|
103
|
+
this.#colors = /** @type {any} */ (cp).colors.slice();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const raw = cp.getAttribute('colors') || '';
|
|
107
|
+
const parsed = raw.split(',').map((s) => s.trim()).filter(Boolean);
|
|
108
|
+
if (parsed.length) { this.#colors = parsed; return; }
|
|
109
|
+
}
|
|
110
|
+
// 2. palette-generator child (derive from seed + harmony)
|
|
111
|
+
const pg = this.querySelector('palette-generator');
|
|
112
|
+
if (pg) {
|
|
113
|
+
const seed = pg.getAttribute('seed')
|
|
114
|
+
|| /** @type {HTMLInputElement | null} */ (pg.querySelector('input[type="color"]'))?.value
|
|
115
|
+
|| '';
|
|
116
|
+
const harmony = pg.getAttribute('harmony') || 'complementary';
|
|
117
|
+
if (seed) {
|
|
118
|
+
try {
|
|
119
|
+
const { colors } = generatePalette(seed, harmony);
|
|
120
|
+
this.#colors = colors.slice();
|
|
121
|
+
return;
|
|
122
|
+
} catch { /* fall through */ }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// 3. colors attribute
|
|
126
|
+
const raw = this.getAttribute('colors') || '';
|
|
127
|
+
this.#colors = raw.split(',').map((s) => s.trim()).filter(Boolean);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#resolveRoles() {
|
|
131
|
+
const raw = this.getAttribute('roles') || BRAND_ROLES.join(',');
|
|
132
|
+
const roles = raw
|
|
133
|
+
.split(',')
|
|
134
|
+
.map((s) => s.trim().toLowerCase())
|
|
135
|
+
.filter((r) => ALL_ROLES.includes(r));
|
|
136
|
+
this.#roles = roles.length ? roles : [...BRAND_ROLES];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#mapping() {
|
|
140
|
+
const map = {};
|
|
141
|
+
for (let i = 0; i < this.#roles.length; i++) {
|
|
142
|
+
map[this.#roles[i]] = this.#colors[i] || this.#colors[this.#colors.length - 1] || '#888888';
|
|
143
|
+
}
|
|
144
|
+
return map;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Render ─────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
#renderShell() {
|
|
150
|
+
const existing = this.querySelector(':scope > .sp-shell');
|
|
151
|
+
if (existing) existing.remove();
|
|
152
|
+
|
|
153
|
+
const label = this.getAttribute('label');
|
|
154
|
+
const showExport = this.hasAttribute('show-export');
|
|
155
|
+
|
|
156
|
+
const shell = document.createElement('div');
|
|
157
|
+
shell.className = 'sp-shell';
|
|
158
|
+
shell.style.cssText = 'display:flex;flex-direction:column;gap:var(--size-m,1rem);margin-block-start:var(--size-s,0.75rem)';
|
|
159
|
+
|
|
160
|
+
if (label) {
|
|
161
|
+
const p = document.createElement('p');
|
|
162
|
+
p.style.cssText = 'font-weight:600;margin:0';
|
|
163
|
+
p.textContent = label;
|
|
164
|
+
shell.append(p);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const previews = document.createElement('section');
|
|
168
|
+
previews.className = 'sp-previews';
|
|
169
|
+
previews.setAttribute('aria-label', 'Role previews');
|
|
170
|
+
previews.style.cssText = `display:grid;grid-template-columns:repeat(auto-fill,minmax(min(22rem,100%),1fr));gap:var(--size-m,1rem)`;
|
|
171
|
+
shell.append(previews);
|
|
172
|
+
this.#previews = previews;
|
|
173
|
+
|
|
174
|
+
if (showExport) {
|
|
175
|
+
shell.insertAdjacentHTML('beforeend', this.#renderExport());
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
this.append(shell);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
#renderPreviews(_source) {
|
|
182
|
+
if (!this.#previews) return;
|
|
183
|
+
const map = this.#mapping();
|
|
184
|
+
this.#previews.innerHTML = this.#roles.map((role) => this.#renderCard(role, map[role])).join('');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
#renderCard(role, hex) {
|
|
188
|
+
const roleLabel = ROLE_LABELS[role] || role;
|
|
189
|
+
const isStatus = STATUS_ROLES.includes(role);
|
|
190
|
+
const samples = isStatus ? this.#renderStatusSamples(role, hex) : this.#renderBrandSamples(role, hex);
|
|
191
|
+
const cardStyle = `display:flex;flex-direction:column;gap:var(--size-s,0.75rem);padding:var(--size-m,1rem);border:1px solid ${BORDER};border-radius:${RADIUS_M};background:${SURFACE};min-inline-size:0`;
|
|
192
|
+
|
|
193
|
+
return `<article class="sp-card" data-role="${role}" aria-label="${roleLabel} role preview" style="${cardStyle}">
|
|
194
|
+
<header style="display:flex;align-items:center;justify-content:space-between;gap:var(--size-s,0.75rem)">
|
|
195
|
+
<div>
|
|
196
|
+
<code style="font-family:${MONO};font-size:var(--font-size-sm,0.875rem);font-weight:600">${role}</code>
|
|
197
|
+
<span style="display:block;font-size:var(--font-size-xs,0.75rem);color:${MUTED}">${roleLabel}</span>
|
|
198
|
+
</div>
|
|
199
|
+
<div style="display:flex;align-items:center;gap:0.5rem">
|
|
200
|
+
<span class="sp-swatch" style="display:inline-block;inline-size:1.75rem;block-size:1.75rem;border-radius:${RADIUS_S};border:1px solid oklch(0% 0 0 / 0.15);background:${hex}"></span>
|
|
201
|
+
<code style="font-family:${MONO};font-size:var(--font-size-xs,0.75rem);color:${MUTED}">${hex}</code>
|
|
202
|
+
</div>
|
|
203
|
+
</header>
|
|
204
|
+
<div class="sp-samples" style="display:flex;flex-wrap:wrap;gap:var(--size-s,0.75rem);align-items:flex-start">${samples}</div>
|
|
205
|
+
</article>`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
#renderBrandSamples(role, hex) {
|
|
209
|
+
const textOnRole = this.#resolveTextOnRole(role, hex);
|
|
210
|
+
const surface = this.#resolveSurface();
|
|
211
|
+
const subtleBg = this.#resolveSubtle(role, hex);
|
|
212
|
+
const subtleFg = this.#darken(hex);
|
|
213
|
+
|
|
214
|
+
return [
|
|
215
|
+
this.#sample({
|
|
216
|
+
render: `<button type="button" tabindex="-1" style="all:unset;display:inline-block;padding:0.375rem 0.875rem;border-radius:${RADIUS_S};background:${hex};color:${textOnRole};font-family:inherit;font-size:var(--font-size-sm,0.875rem);font-weight:600;cursor:default">Button</button>`,
|
|
217
|
+
fg: textOnRole, bg: hex, caption: 'On role',
|
|
218
|
+
}),
|
|
219
|
+
this.#sample({
|
|
220
|
+
render: `<span style="color:${hex};background:${surface};padding:0.25rem 0.5rem;border-radius:${RADIUS_S};font-size:var(--font-size-sm,0.875rem);font-weight:500;text-decoration:underline">Linked text</span>`,
|
|
221
|
+
fg: hex, bg: surface || '#ffffff', caption: 'On surface',
|
|
222
|
+
}),
|
|
223
|
+
this.#sample({
|
|
224
|
+
render: `<span style="display:inline-block;padding:0.125rem 0.5rem;border-radius:999px;background:${subtleBg};color:${subtleFg};font-size:var(--font-size-xs,0.75rem);font-weight:600;letter-spacing:0.04em;text-transform:uppercase">Badge</span>`,
|
|
225
|
+
fg: subtleFg, bg: subtleBg, caption: 'Subtle',
|
|
226
|
+
}),
|
|
227
|
+
].join('');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
#renderStatusSamples(role, hex) {
|
|
231
|
+
const surface = this.#resolveSurface();
|
|
232
|
+
const subtleBg = this.#resolveStatusSubtle(role, hex);
|
|
233
|
+
const statusText = this.#resolveStatusText(role, hex);
|
|
234
|
+
const onRole = pickContrastingHex(hex);
|
|
235
|
+
|
|
236
|
+
return [
|
|
237
|
+
this.#sample({
|
|
238
|
+
render: `<div style="display:flex;align-items:center;gap:0.5rem;padding:0.5rem 0.75rem;border-radius:${RADIUS_S};background:${subtleBg};color:${statusText};border:1px solid ${hex};font-size:var(--font-size-sm,0.875rem);max-inline-size:100%">
|
|
239
|
+
<span style="display:inline-block;inline-size:0.5rem;block-size:0.5rem;border-radius:50%;background:${hex};flex-shrink:0"></span>
|
|
240
|
+
<span><strong>${ROLE_LABELS[role] || role}</strong> — message text</span>
|
|
241
|
+
</div>`,
|
|
242
|
+
fg: statusText, bg: subtleBg, caption: 'Alert',
|
|
243
|
+
}),
|
|
244
|
+
this.#sample({
|
|
245
|
+
render: `<span style="display:inline-block;padding:0.125rem 0.5rem;border-radius:999px;background:${hex};color:${onRole};font-size:var(--font-size-xs,0.75rem);font-weight:700;letter-spacing:0.04em;text-transform:uppercase">${role}</span>`,
|
|
246
|
+
fg: onRole, bg: hex, caption: 'Badge',
|
|
247
|
+
}),
|
|
248
|
+
this.#sample({
|
|
249
|
+
render: `<span style="color:${hex};background:${surface};padding:0.25rem 0.5rem;border-radius:${RADIUS_S};font-size:var(--font-size-sm,0.875rem);font-weight:600">Inline status</span>`,
|
|
250
|
+
fg: hex, bg: surface || '#ffffff', caption: 'On surface',
|
|
251
|
+
}),
|
|
252
|
+
].join('');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
#sample({ render, fg, bg, caption }) {
|
|
256
|
+
const fgHex = resolveToHex(fg, this.#probe || undefined) || '#000000';
|
|
257
|
+
const bgHex = resolveToHex(bg, this.#probe || undefined) || '#ffffff';
|
|
258
|
+
const ratio = contrastRatio(fgHex, bgHex);
|
|
259
|
+
const level = wcagLevel(ratio).toLowerCase();
|
|
260
|
+
const lc = LEVEL_COLORS[level];
|
|
261
|
+
const chipStyle = `display:inline-flex;align-items:center;gap:0.25rem;padding:0.125rem 0.375rem;border-radius:${RADIUS_S};font-family:${MONO};font-size:var(--font-size-xs,0.75rem);background:${lc.bg};color:${lc.fg}`;
|
|
262
|
+
return `<div style="display:flex;flex-direction:column;gap:0.25rem;align-items:flex-start;min-inline-size:0;max-inline-size:100%">
|
|
263
|
+
<div style="max-inline-size:100%">${render}</div>
|
|
264
|
+
<div style="display:flex;align-items:center;gap:0.375rem;font-size:var(--font-size-xs,0.75rem);color:${MUTED}">
|
|
265
|
+
<span style="${chipStyle}" title="${caption}: ${ratio.toFixed(2)}:1">${ratio.toFixed(2)} <small>${level.toUpperCase()}</small></span>
|
|
266
|
+
<span>${caption}</span>
|
|
267
|
+
</div>
|
|
268
|
+
</div>`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
#renderExport() {
|
|
272
|
+
const toolbar = `display:flex;gap:var(--size-2xs,0.375rem);padding-block-start:var(--size-xs,0.5rem);border-block-start:1px solid ${BORDER}`;
|
|
273
|
+
const btn = `padding:0.375rem 0.875rem;border:1px solid ${BORDER};border-radius:${RADIUS_S};background:${SURFACE};color:${TEXT};cursor:pointer;font:inherit;font-size:var(--font-size-sm,0.875rem)`;
|
|
274
|
+
return `<div class="sp-export" role="toolbar" aria-label="Export semantic palette" style="${toolbar}">
|
|
275
|
+
<button type="button" class="sp-copy-css" style="${btn}">Copy Theme CSS</button>
|
|
276
|
+
<button type="button" class="sp-copy-json" style="${btn}">Copy JSON</button>
|
|
277
|
+
</div>`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── Wiring ─────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
#wireSource() {
|
|
283
|
+
// color-palette edit events
|
|
284
|
+
const cp = this.querySelector('color-palette');
|
|
285
|
+
if (cp) {
|
|
286
|
+
this.listen(cp, 'color-palette:change', () => {
|
|
287
|
+
this.#readColors();
|
|
288
|
+
this.#renderPreviews('palette');
|
|
289
|
+
this.#emitChange('palette');
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
// palette-generator (legacy path — still supported)
|
|
293
|
+
const pg = this.querySelector('palette-generator');
|
|
294
|
+
if (pg) {
|
|
295
|
+
this.listen(pg, 'palette-generator:generate', () => {
|
|
296
|
+
this.#readColors();
|
|
297
|
+
this.#renderPreviews('palette');
|
|
298
|
+
this.#emitChange('palette');
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
#wireExport() {
|
|
304
|
+
const cssBtn = this.querySelector('.sp-copy-css');
|
|
305
|
+
const jsonBtn = this.querySelector('.sp-copy-json');
|
|
306
|
+
if (cssBtn) {
|
|
307
|
+
this.listen(cssBtn, 'click', () => {
|
|
308
|
+
copyText(this.#buildCSS(), {
|
|
309
|
+
button: /** @type {HTMLElement} */ (cssBtn),
|
|
310
|
+
announceMessage: 'Theme CSS copied',
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
if (jsonBtn) {
|
|
315
|
+
this.listen(jsonBtn, 'click', () => {
|
|
316
|
+
copyText(JSON.stringify(this.#mapping(), null, 2), {
|
|
317
|
+
button: /** @type {HTMLElement} */ (jsonBtn),
|
|
318
|
+
announceMessage: 'JSON copied',
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── Token resolution ───────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
#cs() { return getComputedStyle(this); }
|
|
327
|
+
|
|
328
|
+
#resolveSurface() {
|
|
329
|
+
return this.#cs().getPropertyValue('--color-surface').trim() || '#ffffff';
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
#resolveTextOnRole(role, fallbackHex) {
|
|
333
|
+
const explicit = this.#cs().getPropertyValue(`--color-text-on-${role}`).trim();
|
|
334
|
+
return explicit || pickContrastingHex(fallbackHex);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
#resolveSubtle(role, fallbackHex) {
|
|
338
|
+
const explicit = this.#cs().getPropertyValue(`--color-${role}-subtle`).trim();
|
|
339
|
+
if (explicit) return explicit;
|
|
340
|
+
const { h } = hexToOklch(fallbackHex);
|
|
341
|
+
return oklchToHex(0.95, 0.03, h);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
#resolveStatusSubtle(role, fallbackHex) {
|
|
345
|
+
const explicit = this.#cs().getPropertyValue(`--color-${role}-subtle`).trim();
|
|
346
|
+
if (explicit) return explicit;
|
|
347
|
+
const { h } = hexToOklch(fallbackHex);
|
|
348
|
+
return oklchToHex(0.94, 0.05, h);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
#resolveStatusText(role, fallbackHex) {
|
|
352
|
+
const explicit = this.#cs().getPropertyValue(`--color-${role}-text`).trim();
|
|
353
|
+
if (explicit) return explicit;
|
|
354
|
+
const { h } = hexToOklch(fallbackHex);
|
|
355
|
+
return oklchToHex(0.3, 0.14, h);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
#darken(hex) {
|
|
359
|
+
const { l, c, h } = hexToOklch(hex);
|
|
360
|
+
return oklchToHex(Math.max(0.2, l - 0.25), c, h);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ── Export + emit ──────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
#buildCSS() {
|
|
366
|
+
const map = this.#mapping();
|
|
367
|
+
const brand = [];
|
|
368
|
+
const status = [];
|
|
369
|
+
for (const role of this.#roles) {
|
|
370
|
+
const hex = map[role];
|
|
371
|
+
if (!hex) continue;
|
|
372
|
+
const { l, c, h } = hexToOklch(hex);
|
|
373
|
+
if (BRAND_ROLES.includes(role)) {
|
|
374
|
+
brand.push(
|
|
375
|
+
` --hue-${role}: ${h.toFixed(1)};`,
|
|
376
|
+
` --lightness-${role}: ${(l * 100).toFixed(1)}%;`,
|
|
377
|
+
` --chroma-${role}: ${c.toFixed(3)};`,
|
|
378
|
+
);
|
|
379
|
+
} else {
|
|
380
|
+
status.push(` --color-${role}: ${formatOklch({ l, c, h })};`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
const parts = [];
|
|
384
|
+
if (brand.length) parts.push(' /* Brand seeds */', ...brand);
|
|
385
|
+
if (status.length) {
|
|
386
|
+
if (brand.length) parts.push('');
|
|
387
|
+
parts.push(' /* Status colors */', ...status);
|
|
388
|
+
}
|
|
389
|
+
return `:root {\n${parts.join('\n')}\n}\n`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
#emitChange(source) {
|
|
393
|
+
this.dispatchEvent(new CustomEvent('semantic-palette:change', {
|
|
394
|
+
bubbles: true,
|
|
395
|
+
detail: { mapping: this.#mapping(), tokens: this.#buildCSS(), source },
|
|
396
|
+
}));
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
registerComponent('semantic-palette', SemanticPalette);
|