@adia-ai/web-components 0.4.4 → 0.4.5
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/components/calendar-picker/calendar-picker.js +1 -1
- package/components/check/check.js +1 -1
- package/components/input/input.js +9 -9
- package/components/option-card/option-card.js +1 -1
- package/components/otp-input/otp-input.js +3 -3
- package/components/radio/radio.js +1 -1
- package/components/range/range.js +3 -3
- package/components/rating/rating.js +1 -1
- package/components/search/search.js +2 -2
- package/components/select/select.js +2 -2
- package/components/slider/slider.js +4 -4
- package/components/slider/slider.test.js +105 -0
- package/components/switch/switch.js +1 -1
- package/components/textarea/textarea.js +2 -2
- package/components/upload/upload.js +1 -1
- package/package.json +2 -1
- package/styles/design-tokens-export.js +554 -0
|
@@ -234,7 +234,7 @@ class UICalendarPicker extends UIFormElement {
|
|
|
234
234
|
#selectDate(iso) {
|
|
235
235
|
this.value = iso;
|
|
236
236
|
this.syncValue(iso);
|
|
237
|
-
this.dispatchEvent(new
|
|
237
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
|
|
238
238
|
// Re-render grid to reflect new selection; keep popover open until user clicks outside.
|
|
239
239
|
if (this.open) this.#renderCalendar();
|
|
240
240
|
}
|
|
@@ -40,7 +40,7 @@ class UICheck extends UIFormElement {
|
|
|
40
40
|
if (this.disabled) return;
|
|
41
41
|
this.checked = !this.checked;
|
|
42
42
|
this.indeterminate = false;
|
|
43
|
-
this.dispatchEvent(new
|
|
43
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value, checked: this.checked } }));
|
|
44
44
|
};
|
|
45
45
|
|
|
46
46
|
#onKey = (e) => {
|
|
@@ -462,8 +462,8 @@ class UIInput extends UIFormElement {
|
|
|
462
462
|
if (next === this.valueAsNumber) return;
|
|
463
463
|
this.value = this.#format(next);
|
|
464
464
|
this.syncValue(this.value);
|
|
465
|
-
this.dispatchEvent(new
|
|
466
|
-
this.dispatchEvent(new
|
|
465
|
+
this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: this.value } }));
|
|
466
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
|
|
467
467
|
}
|
|
468
468
|
|
|
469
469
|
// ── Event handlers ──
|
|
@@ -488,7 +488,7 @@ class UIInput extends UIFormElement {
|
|
|
488
488
|
this.value = text;
|
|
489
489
|
if (!this.#isNativePassword) this.#textEl.toggleAttribute('data-empty', !text);
|
|
490
490
|
this.syncValue(text);
|
|
491
|
-
this.dispatchEvent(new
|
|
491
|
+
this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: this.value } }));
|
|
492
492
|
};
|
|
493
493
|
|
|
494
494
|
#onBeforeInput = (e) => {
|
|
@@ -600,7 +600,7 @@ class UIInput extends UIFormElement {
|
|
|
600
600
|
e.preventDefault();
|
|
601
601
|
// Commit normalized value before firing form events.
|
|
602
602
|
this.#commitOnBlur();
|
|
603
|
-
this.dispatchEvent(new
|
|
603
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
|
|
604
604
|
this.dispatchEvent(new Event('submit', { bubbles: true }));
|
|
605
605
|
return;
|
|
606
606
|
}
|
|
@@ -608,7 +608,7 @@ class UIInput extends UIFormElement {
|
|
|
608
608
|
}
|
|
609
609
|
if (e.key === 'Enter') {
|
|
610
610
|
e.preventDefault();
|
|
611
|
-
this.dispatchEvent(new
|
|
611
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
|
|
612
612
|
this.dispatchEvent(new Event('submit', { bubbles: true }));
|
|
613
613
|
}
|
|
614
614
|
};
|
|
@@ -631,7 +631,7 @@ class UIInput extends UIFormElement {
|
|
|
631
631
|
|
|
632
632
|
#onBlur = () => {
|
|
633
633
|
if (this.#isNumberMode) this.#commitOnBlur();
|
|
634
|
-
this.dispatchEvent(new
|
|
634
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
|
|
635
635
|
};
|
|
636
636
|
|
|
637
637
|
#commitOnBlur() {
|
|
@@ -650,7 +650,7 @@ class UIInput extends UIFormElement {
|
|
|
650
650
|
if (this.value !== stored) {
|
|
651
651
|
this.value = stored;
|
|
652
652
|
this.syncValue(stored);
|
|
653
|
-
this.dispatchEvent(new
|
|
653
|
+
this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: this.value } }));
|
|
654
654
|
}
|
|
655
655
|
if (this.#textEl.textContent !== displayed) {
|
|
656
656
|
this.#textEl.textContent = displayed;
|
|
@@ -665,8 +665,8 @@ class UIInput extends UIFormElement {
|
|
|
665
665
|
this.syncValue(this.value);
|
|
666
666
|
this.#textEl.textContent = this.value;
|
|
667
667
|
this.#textEl.toggleAttribute('data-empty', !this.value);
|
|
668
|
-
this.dispatchEvent(new
|
|
669
|
-
this.dispatchEvent(new
|
|
668
|
+
this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: this.value } }));
|
|
669
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
|
|
670
670
|
}
|
|
671
671
|
|
|
672
672
|
#onPaste = (e) => {
|
|
@@ -116,7 +116,7 @@ class UIOptionCard extends UIFormElement {
|
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
this.checked = true;
|
|
119
|
-
this.dispatchEvent(new
|
|
119
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value, checked: this.checked } }));
|
|
120
120
|
};
|
|
121
121
|
|
|
122
122
|
#onKey = (e) => {
|
|
@@ -93,7 +93,7 @@ class UIOtpInput extends UIFormElement {
|
|
|
93
93
|
input.value = input.value.replace(/\D/g, '').slice(0, 1);
|
|
94
94
|
|
|
95
95
|
this.#syncCombined();
|
|
96
|
-
this.dispatchEvent(new
|
|
96
|
+
this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: this.value } }));
|
|
97
97
|
|
|
98
98
|
if (input.value && index < this.#inputs.length - 1) {
|
|
99
99
|
this.#inputs[index + 1].focus();
|
|
@@ -107,7 +107,7 @@ class UIOtpInput extends UIFormElement {
|
|
|
107
107
|
this.#inputs[index - 1].focus();
|
|
108
108
|
this.#inputs[index - 1].value = '';
|
|
109
109
|
this.#syncCombined();
|
|
110
|
-
this.dispatchEvent(new
|
|
110
|
+
this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: this.value } }));
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
|
|
@@ -118,7 +118,7 @@ class UIOtpInput extends UIFormElement {
|
|
|
118
118
|
this.#inputs[i].value = text[i] || '';
|
|
119
119
|
}
|
|
120
120
|
this.#syncCombined();
|
|
121
|
-
this.dispatchEvent(new
|
|
121
|
+
this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: this.value } }));
|
|
122
122
|
|
|
123
123
|
// Focus last filled or first empty
|
|
124
124
|
const firstEmpty = this.#inputs.findIndex(inp => !inp.value);
|
|
@@ -38,7 +38,7 @@ class UIRadio extends UIFormElement {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
this.checked = true;
|
|
41
|
-
this.dispatchEvent(new
|
|
41
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value, checked: this.checked } }));
|
|
42
42
|
};
|
|
43
43
|
|
|
44
44
|
#onKey = (e) => {
|
|
@@ -103,7 +103,7 @@ class UIRange extends UIFormElement {
|
|
|
103
103
|
const snapped = this.#snap(v);
|
|
104
104
|
if (snapped === this.value) return;
|
|
105
105
|
this.value = snapped;
|
|
106
|
-
this.dispatchEvent(new
|
|
106
|
+
this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: this.value } }));
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
// ── Pointer drag ──
|
|
@@ -147,7 +147,7 @@ class UIRange extends UIFormElement {
|
|
|
147
147
|
this.#fieldEl.releasePointerCapture(e.pointerId);
|
|
148
148
|
this.#fieldEl.removeEventListener('pointermove', this.#onPointerMove);
|
|
149
149
|
this.#fieldEl.removeEventListener('pointerup', this.#onPointerUp);
|
|
150
|
-
this.dispatchEvent(new
|
|
150
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
|
|
151
151
|
};
|
|
152
152
|
|
|
153
153
|
// ── Keyboard ──
|
|
@@ -166,7 +166,7 @@ class UIRange extends UIFormElement {
|
|
|
166
166
|
}
|
|
167
167
|
e.preventDefault();
|
|
168
168
|
this.#setValue(v);
|
|
169
|
-
this.dispatchEvent(new
|
|
169
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
|
|
170
170
|
};
|
|
171
171
|
|
|
172
172
|
disconnected() {
|
|
@@ -121,7 +121,7 @@ class UIRating extends UIFormElement {
|
|
|
121
121
|
this.value = v;
|
|
122
122
|
this.#hoverValue = null;
|
|
123
123
|
this.syncValue(String(v));
|
|
124
|
-
this.dispatchEvent(new
|
|
124
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
|
|
125
125
|
this.render();
|
|
126
126
|
}
|
|
127
127
|
|
|
@@ -62,7 +62,7 @@ class UISearch extends UIFormElement {
|
|
|
62
62
|
#onInput = () => {
|
|
63
63
|
this.value = this.#inputEl.value;
|
|
64
64
|
this.syncValue(this.value);
|
|
65
|
-
this.dispatchEvent(new
|
|
65
|
+
this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: this.value } }));
|
|
66
66
|
|
|
67
67
|
clearTimeout(this.#timer);
|
|
68
68
|
this.#timer = setTimeout(() => {
|
|
@@ -92,7 +92,7 @@ class UISearch extends UIFormElement {
|
|
|
92
92
|
if (this.#inputEl) this.#inputEl.value = '';
|
|
93
93
|
this.syncValue('');
|
|
94
94
|
this.setAttribute('value', '');
|
|
95
|
-
this.dispatchEvent(new
|
|
95
|
+
this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: this.value } }));
|
|
96
96
|
this.dispatchEvent(new CustomEvent('search', {
|
|
97
97
|
bubbles: true,
|
|
98
98
|
detail: { value: '' },
|
|
@@ -46,7 +46,7 @@ class UISelect extends UIFormElement {
|
|
|
46
46
|
this.open = false;
|
|
47
47
|
this.#query = '';
|
|
48
48
|
this.syncValue(opt.value);
|
|
49
|
-
this.dispatchEvent(new
|
|
49
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
|
|
50
50
|
};
|
|
51
51
|
|
|
52
52
|
connected() {
|
|
@@ -293,7 +293,7 @@ class UISelect extends UIFormElement {
|
|
|
293
293
|
this.#query = '';
|
|
294
294
|
this.open = false;
|
|
295
295
|
this.syncValue?.(q);
|
|
296
|
-
this.dispatchEvent(new
|
|
296
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
|
|
297
297
|
} else {
|
|
298
298
|
this.open = false;
|
|
299
299
|
}
|
|
@@ -110,7 +110,7 @@ class UISlider extends UIFormElement {
|
|
|
110
110
|
#setValue(v) {
|
|
111
111
|
if (v === this.value) return;
|
|
112
112
|
this.value = v;
|
|
113
|
-
this.dispatchEvent(new
|
|
113
|
+
this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: this.value } }));
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
#onPointerDown = (e) => {
|
|
@@ -132,13 +132,13 @@ class UISlider extends UIFormElement {
|
|
|
132
132
|
this.#thumbEl.releasePointerCapture(e.pointerId);
|
|
133
133
|
this.#thumbEl.removeEventListener('pointermove', this.#onPointerMove);
|
|
134
134
|
this.#thumbEl.removeEventListener('pointerup', this.#onPointerUp);
|
|
135
|
-
this.dispatchEvent(new
|
|
135
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
|
|
136
136
|
};
|
|
137
137
|
|
|
138
138
|
#onTrackClick = (e) => {
|
|
139
139
|
if (this.disabled || e.target === this.#thumbEl) return;
|
|
140
140
|
this.#setValue(this.#valueFromX(e.clientX));
|
|
141
|
-
this.dispatchEvent(new
|
|
141
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
|
|
142
142
|
};
|
|
143
143
|
|
|
144
144
|
#onKey = (e) => {
|
|
@@ -155,7 +155,7 @@ class UISlider extends UIFormElement {
|
|
|
155
155
|
}
|
|
156
156
|
e.preventDefault();
|
|
157
157
|
this.#setValue(this.#snap(v));
|
|
158
|
-
this.dispatchEvent(new
|
|
158
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
|
|
159
159
|
};
|
|
160
160
|
|
|
161
161
|
disconnected() {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import '../../core/element.js';
|
|
3
|
+
import './slider.js';
|
|
4
|
+
|
|
5
|
+
const tick = () => new Promise((r) => queueMicrotask(r));
|
|
6
|
+
|
|
7
|
+
function mount(html) {
|
|
8
|
+
const wrap = document.createElement('div');
|
|
9
|
+
wrap.innerHTML = html;
|
|
10
|
+
document.body.appendChild(wrap);
|
|
11
|
+
return wrap.firstElementChild;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('slider-ui', () => {
|
|
15
|
+
beforeEach(() => { document.body.innerHTML = ''; });
|
|
16
|
+
|
|
17
|
+
it('renders thumb at correct % for initial value', async () => {
|
|
18
|
+
const s = mount('<slider-ui value="50" min="0" max="100"></slider-ui>');
|
|
19
|
+
await tick();
|
|
20
|
+
const thumb = s.querySelector('[slot="thumb"]');
|
|
21
|
+
expect(thumb.style.left).toBe('50%');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Property reactivity contract — locks in the behavior that consumer
|
|
25
|
+
// feedback (FEEDBACK-adia-packages.md §5, 2026-05-12) incorrectly
|
|
26
|
+
// claimed was broken. UIElement's signal-backed property setters
|
|
27
|
+
// (core/element.js:31-50) trigger the host's render effect on every
|
|
28
|
+
// property change; slider-ui's render() reads this.value and updates
|
|
29
|
+
// [slot="thumb"].style.left. This test catches any regression that
|
|
30
|
+
// would break undo/redo or any other programmatic-value flow.
|
|
31
|
+
it('moves thumb when .value is set programmatically', async () => {
|
|
32
|
+
const s = mount('<slider-ui value="50" min="0" max="100"></slider-ui>');
|
|
33
|
+
await tick();
|
|
34
|
+
const thumb = s.querySelector('[slot="thumb"]');
|
|
35
|
+
expect(thumb.style.left).toBe('50%');
|
|
36
|
+
|
|
37
|
+
s.value = 75;
|
|
38
|
+
await tick();
|
|
39
|
+
expect(thumb.style.left).toBe('75%');
|
|
40
|
+
|
|
41
|
+
s.value = 10;
|
|
42
|
+
await tick();
|
|
43
|
+
expect(thumb.style.left).toBe('10%');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('moves thumb when attribute is set imperatively', async () => {
|
|
47
|
+
const s = mount('<slider-ui value="50" min="0" max="100"></slider-ui>');
|
|
48
|
+
await tick();
|
|
49
|
+
const thumb = s.querySelector('[slot="thumb"]');
|
|
50
|
+
|
|
51
|
+
s.setAttribute('value', '25');
|
|
52
|
+
await tick();
|
|
53
|
+
expect(thumb.style.left).toBe('25%');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('updates [slot="value"] readout text when .value changes', async () => {
|
|
57
|
+
const s = mount('<slider-ui value="50" min="0" max="100"></slider-ui>');
|
|
58
|
+
await tick();
|
|
59
|
+
const readout = s.querySelector('[slot="value"]');
|
|
60
|
+
expect(readout.textContent).toBe('50');
|
|
61
|
+
|
|
62
|
+
s.value = 88;
|
|
63
|
+
await tick();
|
|
64
|
+
expect(readout.textContent).toBe('88');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('emits CustomEvent("change") with detail.value on keyboard step', async () => {
|
|
68
|
+
const s = mount('<slider-ui value="50" min="0" max="100" step="1"></slider-ui>');
|
|
69
|
+
await tick();
|
|
70
|
+
|
|
71
|
+
let captured = null;
|
|
72
|
+
s.addEventListener('change', (e) => { captured = e; });
|
|
73
|
+
|
|
74
|
+
s.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
|
75
|
+
await tick();
|
|
76
|
+
|
|
77
|
+
expect(captured).not.toBeNull();
|
|
78
|
+
expect(captured).toBeInstanceOf(CustomEvent);
|
|
79
|
+
expect(captured.detail).toEqual({ value: 51 });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('emits CustomEvent("input") with detail.value on internal value change', async () => {
|
|
83
|
+
const s = mount('<slider-ui value="50" min="0" max="100" step="1"></slider-ui>');
|
|
84
|
+
await tick();
|
|
85
|
+
|
|
86
|
+
let captured = null;
|
|
87
|
+
s.addEventListener('input', (e) => { captured = e; });
|
|
88
|
+
|
|
89
|
+
s.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
|
|
90
|
+
await tick();
|
|
91
|
+
|
|
92
|
+
expect(captured).not.toBeNull();
|
|
93
|
+
expect(captured.detail).toEqual({ value: 51 });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('reflects value to [aria-valuenow] for screen readers', async () => {
|
|
97
|
+
const s = mount('<slider-ui value="50" min="0" max="100"></slider-ui>');
|
|
98
|
+
await tick();
|
|
99
|
+
expect(s.getAttribute('aria-valuenow')).toBe('50');
|
|
100
|
+
|
|
101
|
+
s.value = 80;
|
|
102
|
+
await tick();
|
|
103
|
+
expect(s.getAttribute('aria-valuenow')).toBe('80');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -35,7 +35,7 @@ class UISwitch extends UIFormElement {
|
|
|
35
35
|
#toggle = () => {
|
|
36
36
|
if (this.disabled) return;
|
|
37
37
|
this.checked = !this.checked;
|
|
38
|
-
this.dispatchEvent(new
|
|
38
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value, checked: this.checked } }));
|
|
39
39
|
};
|
|
40
40
|
|
|
41
41
|
#onKey = (e) => {
|
|
@@ -76,7 +76,7 @@ class UITextarea extends UIFormElement {
|
|
|
76
76
|
this.value = text;
|
|
77
77
|
this.#textEl.toggleAttribute('data-empty', !text);
|
|
78
78
|
this.syncValue(text);
|
|
79
|
-
this.dispatchEvent(new
|
|
79
|
+
this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: this.value } }));
|
|
80
80
|
};
|
|
81
81
|
|
|
82
82
|
#onKeydown = (e) => {
|
|
@@ -88,7 +88,7 @@ class UITextarea extends UIFormElement {
|
|
|
88
88
|
};
|
|
89
89
|
|
|
90
90
|
#onBlur = () => {
|
|
91
|
-
this.dispatchEvent(new
|
|
91
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
|
|
92
92
|
};
|
|
93
93
|
|
|
94
94
|
#onPaste = (e) => {
|
|
@@ -168,7 +168,7 @@ class UIUpload extends UIFormElement {
|
|
|
168
168
|
}
|
|
169
169
|
this.internals.setFormValue(fd);
|
|
170
170
|
|
|
171
|
-
this.dispatchEvent(new
|
|
171
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value, files: this.files } }));
|
|
172
172
|
this.render();
|
|
173
173
|
}
|
|
174
174
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adia-ai/web-components",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
4
4
|
"description": "AdiaUI web components — vanilla custom elements. A2UI runtime (renderer, registry, streams, wiring) lives in @adia-ai/a2ui-runtime.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"./core/*": "./core/*.js",
|
|
11
11
|
"./components": "./components/index.js",
|
|
12
12
|
"./components/*": "./components/*/*.js",
|
|
13
|
+
"./components/*.css": "./components/*/*.css",
|
|
13
14
|
"./styles/*": "./styles/*",
|
|
14
15
|
"./traits": "./traits/index.js",
|
|
15
16
|
"./traits/*": "./traits/*.js",
|
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /tokens/colors — Figma Variables Pro export.
|
|
3
|
+
*
|
|
4
|
+
* Reads the live --a-* color token surface, walks var()/light-dark() chains
|
|
5
|
+
* symbolically to keep cssVar identity in references, converts every literal
|
|
6
|
+
* OKLCH value through OKLab → linear-sRGB → sRGB → HSL in JS floats (no
|
|
7
|
+
* canvas quantization), and assembles a Variables Pro JSON document.
|
|
8
|
+
*
|
|
9
|
+
* Public surface:
|
|
10
|
+
* buildFigmaJson({ format }) → JSON object ready for JSON.stringify
|
|
11
|
+
* getExportStats() → counts + reference-coverage stats
|
|
12
|
+
* downloadJson(filename, data) → trigger browser file save
|
|
13
|
+
* copyJson(data) → copy stringified JSON to clipboard
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/* ─── CSS Object Model scan ──────────────────────────────────────────────── */
|
|
17
|
+
function buildCssVarMap() {
|
|
18
|
+
const map = new Map();
|
|
19
|
+
for (const sheet of document.styleSheets) {
|
|
20
|
+
let rules;
|
|
21
|
+
try { rules = sheet.cssRules; } catch { continue; }
|
|
22
|
+
walkRules(rules, map);
|
|
23
|
+
}
|
|
24
|
+
return map;
|
|
25
|
+
}
|
|
26
|
+
// Only GLOBAL-scope token definitions are authoritative. Attribute-scoped
|
|
27
|
+
// selectors like `[color="info"]` conditionally override --a-fg etc. and
|
|
28
|
+
// must not leak into the default-state token map.
|
|
29
|
+
function isAuthoritativeSelector(sel) {
|
|
30
|
+
return /(^|,)\s*(:root|theme-ui|\[data-theme\])\b/i.test(sel);
|
|
31
|
+
}
|
|
32
|
+
function walkRules(rules, map) {
|
|
33
|
+
for (const rule of rules) {
|
|
34
|
+
if (rule instanceof CSSStyleRule) {
|
|
35
|
+
if (!isAuthoritativeSelector(rule.selectorText)) continue;
|
|
36
|
+
const decl = rule.style;
|
|
37
|
+
for (let i = 0; i < decl.length; i++) {
|
|
38
|
+
const name = decl.item(i);
|
|
39
|
+
if (!name.startsWith('--a-')) continue;
|
|
40
|
+
const raw = decl.getPropertyValue(name).trim();
|
|
41
|
+
if (raw) map.set(name, raw);
|
|
42
|
+
}
|
|
43
|
+
} else if (rule.cssRules) {
|
|
44
|
+
walkRules(rule.cssRules, map);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* ─── CSS expression parsers ─────────────────────────────────────────────── */
|
|
50
|
+
function splitArgs(s) {
|
|
51
|
+
const out = [];
|
|
52
|
+
let depth = 0, start = 0;
|
|
53
|
+
for (let i = 0; i < s.length; i++) {
|
|
54
|
+
const c = s[i];
|
|
55
|
+
if (c === '(') depth++;
|
|
56
|
+
else if (c === ')') depth--;
|
|
57
|
+
else if (c === ',' && depth === 0) { out.push(s.slice(start, i).trim()); start = i + 1; }
|
|
58
|
+
}
|
|
59
|
+
out.push(s.slice(start).trim());
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
function matchSingleVar(expr) {
|
|
63
|
+
const m = expr.match(/^var\(\s*(--[\w-]+)\s*(?:,\s*(.+))?\s*\)$/s);
|
|
64
|
+
if (!m) return null;
|
|
65
|
+
return { name: m[1], fallback: m[2]?.trim() ?? null };
|
|
66
|
+
}
|
|
67
|
+
function matchLightDark(expr) {
|
|
68
|
+
if (!expr.startsWith('light-dark(')) return null;
|
|
69
|
+
if (!expr.endsWith(')')) return null;
|
|
70
|
+
const inner = expr.slice('light-dark('.length, -1);
|
|
71
|
+
const args = splitArgs(inner);
|
|
72
|
+
if (args.length !== 2) return null;
|
|
73
|
+
return { light: args[0], dark: args[1] };
|
|
74
|
+
}
|
|
75
|
+
function isColorLiteral(expr) {
|
|
76
|
+
return /^(oklch|oklab|rgb|rgba|hsl|hsla|#|color\()/i.test(expr);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* ─── Symbolic chain walker ──────────────────────────────────────────────── */
|
|
80
|
+
function walkChain(startCssVar, mode, map) {
|
|
81
|
+
const hops = [];
|
|
82
|
+
const visited = new Set();
|
|
83
|
+
let current = startCssVar;
|
|
84
|
+
|
|
85
|
+
while (true) {
|
|
86
|
+
if (visited.has(current)) return { terminal: 'cycle', hops };
|
|
87
|
+
visited.add(current);
|
|
88
|
+
hops.push(current);
|
|
89
|
+
|
|
90
|
+
const rhs = map.get(current);
|
|
91
|
+
if (!rhs) return { terminal: 'unresolved', hops };
|
|
92
|
+
|
|
93
|
+
let expr = rhs;
|
|
94
|
+
let jumped = false;
|
|
95
|
+
for (let i = 0; i < 4; i++) {
|
|
96
|
+
const ld = matchLightDark(expr);
|
|
97
|
+
if (ld) { expr = (mode === 'light' ? ld.light : ld.dark).trim(); continue; }
|
|
98
|
+
const v = matchSingleVar(expr);
|
|
99
|
+
if (v) {
|
|
100
|
+
if (map.has(v.name)) { current = v.name; jumped = true; break; }
|
|
101
|
+
if (v.fallback) { expr = v.fallback; continue; }
|
|
102
|
+
return { terminal: 'unresolved', hops };
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
if (jumped) continue;
|
|
107
|
+
|
|
108
|
+
if (isColorLiteral(expr)) {
|
|
109
|
+
return { terminal: 'literal', leafCssVar: current, leafLiteral: expr, hops };
|
|
110
|
+
}
|
|
111
|
+
return { terminal: 'inline-literal', leafCssVar: current, leafLiteral: expr, hops };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* ─── Probe-based value resolver ─────────────────────────────────────────── */
|
|
116
|
+
function withProbes(work) {
|
|
117
|
+
const root = document.createElement('div');
|
|
118
|
+
root.style.cssText = 'position:fixed;left:-99999px;top:0;width:0;height:0;overflow:hidden;';
|
|
119
|
+
document.body.appendChild(root);
|
|
120
|
+
const make = scheme => {
|
|
121
|
+
const h = document.createElement('div');
|
|
122
|
+
h.style.colorScheme = `only ${scheme}`;
|
|
123
|
+
root.appendChild(h);
|
|
124
|
+
return h;
|
|
125
|
+
};
|
|
126
|
+
const lightHost = make('light');
|
|
127
|
+
const darkHost = make('dark');
|
|
128
|
+
try {
|
|
129
|
+
return work({ lightHost, darkHost });
|
|
130
|
+
} finally {
|
|
131
|
+
document.body.removeChild(root);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function readComputed(host, cssVar) {
|
|
135
|
+
const p = document.createElement('div');
|
|
136
|
+
p.style.color = `var(${cssVar})`;
|
|
137
|
+
host.appendChild(p);
|
|
138
|
+
const v = getComputedStyle(p).color;
|
|
139
|
+
host.removeChild(p);
|
|
140
|
+
return v;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* ─── Color-literal parser + math (OKLCH→OKLab→linear sRGB→sRGB→HSL) ───── */
|
|
144
|
+
function parseComputedColor(str) {
|
|
145
|
+
str = (str || '').trim();
|
|
146
|
+
let m;
|
|
147
|
+
if ((m = str.match(/^oklch\(\s*([\d.+-]+%?)\s+([\d.+-]+%?)\s+([\d.+-]+)(deg|rad|turn|grad)?\s*(?:\/\s*([\d.+-]+%?))?\s*\)$/i))) {
|
|
148
|
+
let L = parseFloat(m[1]); if (m[1].endsWith('%')) L /= 100;
|
|
149
|
+
let C = parseFloat(m[2]); if (m[2].endsWith('%')) C = C / 100 * 0.4;
|
|
150
|
+
let h = parseFloat(m[3]);
|
|
151
|
+
switch (m[4]) { case 'rad': h = h * 180 / Math.PI; break;
|
|
152
|
+
case 'turn': h *= 360; break;
|
|
153
|
+
case 'grad': h *= 0.9; break; }
|
|
154
|
+
let a = 1;
|
|
155
|
+
if (m[5]) { a = parseFloat(m[5]); if (m[5].endsWith('%')) a /= 100; }
|
|
156
|
+
return { space: 'oklch', L, C, h, alpha: a };
|
|
157
|
+
}
|
|
158
|
+
if ((m = str.match(/^oklab\(\s*([\d.+-]+%?)\s+([\d.+-]+%?)\s+([\d.+-]+%?)\s*(?:\/\s*([\d.+-]+%?))?\s*\)$/i))) {
|
|
159
|
+
let L = parseFloat(m[1]); if (m[1].endsWith('%')) L /= 100;
|
|
160
|
+
let a = parseFloat(m[2]); if (m[2].endsWith('%')) a = a / 100 * 0.4;
|
|
161
|
+
let b = parseFloat(m[3]); if (m[3].endsWith('%')) b = b / 100 * 0.4;
|
|
162
|
+
let A = 1;
|
|
163
|
+
if (m[4]) { A = parseFloat(m[4]); if (m[4].endsWith('%')) A /= 100; }
|
|
164
|
+
return { space: 'oklab', L, a, b, alpha: A };
|
|
165
|
+
}
|
|
166
|
+
if ((m = str.match(/^rgba?\(\s*([\d.+-]+)[,\s]+([\d.+-]+)[,\s]+([\d.+-]+)\s*(?:[,/]\s*([\d.+-]+%?))?\s*\)$/i))) {
|
|
167
|
+
const a = m[4] ? (m[4].endsWith('%') ? parseFloat(m[4]) / 100 : parseFloat(m[4])) : 1;
|
|
168
|
+
return { space: 'srgb', r: parseFloat(m[1]) / 255, g: parseFloat(m[2]) / 255, b: parseFloat(m[3]) / 255, alpha: a };
|
|
169
|
+
}
|
|
170
|
+
if ((m = str.match(/^color\(\s*srgb\s+([\d.+-]+)\s+([\d.+-]+)\s+([\d.+-]+)\s*(?:\/\s*([\d.+-]+%?))?\s*\)$/i))) {
|
|
171
|
+
const a = m[4] ? (m[4].endsWith('%') ? parseFloat(m[4]) / 100 : parseFloat(m[4])) : 1;
|
|
172
|
+
return { space: 'srgb', r: parseFloat(m[1]), g: parseFloat(m[2]), b: parseFloat(m[3]), alpha: a };
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
function oklchToLab({ L, C, h }) {
|
|
177
|
+
const rad = h * Math.PI / 180;
|
|
178
|
+
return { L, a: C * Math.cos(rad), b: C * Math.sin(rad) };
|
|
179
|
+
}
|
|
180
|
+
// Björn Ottosson's OKLab → linear-sRGB matrix.
|
|
181
|
+
function oklabToLinearSrgb({ L, a, b }) {
|
|
182
|
+
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
183
|
+
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
184
|
+
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
|
185
|
+
const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3;
|
|
186
|
+
return {
|
|
187
|
+
r: 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
|
188
|
+
g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
189
|
+
b: -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
function linearToSrgb(x) {
|
|
193
|
+
const sign = x < 0 ? -1 : 1;
|
|
194
|
+
const ax = Math.abs(x);
|
|
195
|
+
const v = ax <= 0.0031308 ? 12.92 * ax : 1.055 * Math.pow(ax, 1 / 2.4) - 0.055;
|
|
196
|
+
return sign * v;
|
|
197
|
+
}
|
|
198
|
+
const clamp01 = x => Math.min(1, Math.max(0, x));
|
|
199
|
+
function colorToFloatSrgb(parsed) {
|
|
200
|
+
if (!parsed) return null;
|
|
201
|
+
if (parsed.space === 'srgb') {
|
|
202
|
+
return { r: clamp01(parsed.r), g: clamp01(parsed.g), b: clamp01(parsed.b), alpha: parsed.alpha, gamut: 'in' };
|
|
203
|
+
}
|
|
204
|
+
const lab = parsed.space === 'oklab'
|
|
205
|
+
? { L: parsed.L, a: parsed.a, b: parsed.b }
|
|
206
|
+
: oklchToLab(parsed);
|
|
207
|
+
const lin = oklabToLinearSrgb(lab);
|
|
208
|
+
const E = 1e-3;
|
|
209
|
+
const gamutIn = lin.r >= -E && lin.r <= 1 + E && lin.g >= -E && lin.g <= 1 + E && lin.b >= -E && lin.b <= 1 + E;
|
|
210
|
+
return {
|
|
211
|
+
r: clamp01(linearToSrgb(lin.r)),
|
|
212
|
+
g: clamp01(linearToSrgb(lin.g)),
|
|
213
|
+
b: clamp01(linearToSrgb(lin.b)),
|
|
214
|
+
alpha: parsed.alpha,
|
|
215
|
+
gamut: gamutIn ? 'in' : 'clamped',
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
function srgbToHsl({ r, g, b }) {
|
|
219
|
+
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
|
220
|
+
const L = (max + min) / 2;
|
|
221
|
+
let H = 0, S = 0;
|
|
222
|
+
const d = max - min;
|
|
223
|
+
if (d > 1e-12) {
|
|
224
|
+
S = L > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
225
|
+
if (max === r) H = ((g - b) / d) + (g < b ? 6 : 0);
|
|
226
|
+
else if (max === g) H = ((b - r) / d) + 2;
|
|
227
|
+
else H = ((r - g) / d) + 4;
|
|
228
|
+
H *= 60;
|
|
229
|
+
}
|
|
230
|
+
return { h: H, s: S * 100, l: L * 100 };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/* ─── Output formatters ──────────────────────────────────────────────────── */
|
|
234
|
+
const dp = (n, d = 4) => Number.isFinite(n) ? Number(n.toFixed(d)) : 0;
|
|
235
|
+
function formatHsl(srgb) {
|
|
236
|
+
const { h, s, l } = srgbToHsl(srgb);
|
|
237
|
+
const a = srgb.alpha;
|
|
238
|
+
return a < 0.999
|
|
239
|
+
? `hsla(${dp(h, 2)}, ${dp(s, 4)}%, ${dp(l, 4)}%, ${dp(a, 4)})`
|
|
240
|
+
: `hsl(${dp(h, 2)}, ${dp(s, 4)}%, ${dp(l, 4)}%)`;
|
|
241
|
+
}
|
|
242
|
+
function formatHex(srgb) {
|
|
243
|
+
const to = x => Math.round(x * 255).toString(16).padStart(2, '0');
|
|
244
|
+
return srgb.alpha < 0.999
|
|
245
|
+
? `rgba(${Math.round(srgb.r * 255)}, ${Math.round(srgb.g * 255)}, ${Math.round(srgb.b * 255)}, ${dp(srgb.alpha, 4)})`
|
|
246
|
+
: '#' + to(srgb.r) + to(srgb.g) + to(srgb.b);
|
|
247
|
+
}
|
|
248
|
+
// Float-RGB literal matches Figma's parseColor floatRgbRegex —
|
|
249
|
+
// /^\{\s*r:\s*[\d\.]+,\s*g:\s*[\d\.]+,\s*b:\s*[\d\.]+(,\s*opacity:\s*[\d\.]+)?\s*\}$/
|
|
250
|
+
// Sub-byte precision (theoretically up to f64), but the canonical sample
|
|
251
|
+
// plugin's importer has a bug (it calls JSON.parse on unquoted keys);
|
|
252
|
+
// permissive parsers like Variables Pro may accept it.
|
|
253
|
+
function formatFloatRgb(srgb) {
|
|
254
|
+
const f = x => dp(x, 6);
|
|
255
|
+
return srgb.alpha < 0.999
|
|
256
|
+
? `{r: ${f(srgb.r)}, g: ${f(srgb.g)}, b: ${f(srgb.b)}, opacity: ${dp(srgb.alpha, 4)}}`
|
|
257
|
+
: `{r: ${f(srgb.r)}, g: ${f(srgb.g)}, b: ${f(srgb.b)}}`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/* ─── Classifier + Figma key derivation ──────────────────────────────────── */
|
|
261
|
+
function isColorCssVar(name, rhs, map) {
|
|
262
|
+
if (rhs.startsWith('#')) return true;
|
|
263
|
+
if (/^(oklch|oklab|rgb|rgba|hsl|hsla|color)\(/.test(rhs)) return true;
|
|
264
|
+
if (/^(var\(|light-dark\()/.test(rhs)) {
|
|
265
|
+
const chain = walkChain(name, 'light', map);
|
|
266
|
+
if (chain.terminal !== 'literal') return false;
|
|
267
|
+
return isColorLiteral(chain.leafLiteral);
|
|
268
|
+
}
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Typography color tokens (--a-display-color, --a-title-color, etc.) get
|
|
273
|
+
// folded into a single 'text' group rather than scattered as 1-leaf groups.
|
|
274
|
+
const TEXT_FAMILIES = new Set([
|
|
275
|
+
'display', 'title', 'heading', 'kicker', 'label', 'caption',
|
|
276
|
+
'deck', 'section', 'subsection', 'metric', 'body', 'code',
|
|
277
|
+
]);
|
|
278
|
+
|
|
279
|
+
// Identify a cssVar as a primitive (tonal-ramp source) and return its
|
|
280
|
+
// ADAPTIVE figma key — polarity-collapsed (no -tint/-shade suffix).
|
|
281
|
+
// Both `--a-neutral-50-tint` and `--a-neutral-50-shade` (and the adaptive
|
|
282
|
+
// `--a-neutral-50` wrapper if present) map to `{neutral.50}`.
|
|
283
|
+
// Returns null if cssVar isn't a primitive shape.
|
|
284
|
+
function primitiveFigmaKey(cssVar) {
|
|
285
|
+
const body = cssVar.replace(/^--a-/, '');
|
|
286
|
+
let m;
|
|
287
|
+
// Step+polarity+scrim (fine or coarse): --a-{fam}-{N}-{tint|shade}-scrim
|
|
288
|
+
if ((m = body.match(/^([a-z]+)-(\d{1,2})-(?:tint|shade)-scrim$/))) return { group: m[1], leaf: `scrim-${m[2]}` };
|
|
289
|
+
// Step+polarity (fine or coarse): --a-{fam}-{N}-{tint|shade}
|
|
290
|
+
if ((m = body.match(/^([a-z]+)-(\d{1,2})-(?:tint|shade)$/))) return { group: m[1], leaf: m[2] };
|
|
291
|
+
// Adaptive scrim wrapper: --a-{fam}-{N}-scrim
|
|
292
|
+
if ((m = body.match(/^([a-z]+)-(\d{1,2})-scrim$/))) return { group: m[1], leaf: `scrim-${m[2]}` };
|
|
293
|
+
// Adaptive step wrapper: --a-{fam}-{N}
|
|
294
|
+
if ((m = body.match(/^([a-z]+)-(\d{1,2})$/))) return { group: m[1], leaf: m[2] };
|
|
295
|
+
// Data slot: --a-data-{N}
|
|
296
|
+
if ((m = body.match(/^data-(\d{1,2})$/))) return { group: 'data', leaf: m[1] };
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// For an adaptive primitive name, return the raw cssVars to probe in each
|
|
301
|
+
// mode. Light reads from the -tint primitive, Dark from the -shade primitive.
|
|
302
|
+
// Data slots are mode-aware via @property so the same cssVar is probed twice.
|
|
303
|
+
function primitiveProbeCssVars({ group, leaf }) {
|
|
304
|
+
if (group === 'data') {
|
|
305
|
+
const v = `--a-data-${leaf}`;
|
|
306
|
+
return { light: v, dark: v };
|
|
307
|
+
}
|
|
308
|
+
if (leaf.startsWith('scrim-')) {
|
|
309
|
+
const step = leaf.slice('scrim-'.length);
|
|
310
|
+
return {
|
|
311
|
+
light: `--a-${group}-${step}-tint-scrim`,
|
|
312
|
+
dark: `--a-${group}-${step}-shade-scrim`,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
light: `--a-${group}-${leaf}-tint`,
|
|
317
|
+
dark: `--a-${group}-${leaf}-shade`,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Figma key for any cssVar — primitives use the adaptive (polarity-collapsed)
|
|
322
|
+
// key; consumables get a camelCased fallback. Typography color overrides
|
|
323
|
+
// (display/title/heading/...) consolidate under a 'text' group.
|
|
324
|
+
function figmaKey(cssVar) {
|
|
325
|
+
const prim = primitiveFigmaKey(cssVar);
|
|
326
|
+
if (prim) return prim;
|
|
327
|
+
|
|
328
|
+
const body = cssVar.replace(/^--a-/, '');
|
|
329
|
+
const parts = body.split('-');
|
|
330
|
+
|
|
331
|
+
// --a-{family}-color (e.g. --a-display-color → text.display)
|
|
332
|
+
if (parts.length === 2 && parts[1] === 'color' && TEXT_FAMILIES.has(parts[0])) {
|
|
333
|
+
return { group: 'text', leaf: parts[0] };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const leaf = parts.slice(1).map((p, i) => i === 0 ? p : p[0].toUpperCase() + p.slice(1)).join('');
|
|
337
|
+
return { group: parts[0], leaf: leaf || 'default' };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/* ─── Extraction pipeline ────────────────────────────────────────────────── */
|
|
341
|
+
function extract() {
|
|
342
|
+
const map = buildCssVarMap();
|
|
343
|
+
const colorEntries = [...map.entries()].filter(([k, v]) => isColorCssVar(k, v, map));
|
|
344
|
+
|
|
345
|
+
// A cssVar is a real adaptive primitive iff its name shape matches a
|
|
346
|
+
// tonal-primitive AND its chain terminates at a literal that maps to
|
|
347
|
+
// the SAME adaptive key. Aliases like --a-canvas-0 → --a-neutral-0 have
|
|
348
|
+
// a matching name shape but resolve to a different family, so they're
|
|
349
|
+
// consumables, not primitives.
|
|
350
|
+
const isAdaptivePrimitive = (cssVar) => {
|
|
351
|
+
const startKey = primitiveFigmaKey(cssVar);
|
|
352
|
+
if (!startKey) return false;
|
|
353
|
+
for (const mode of ['light', 'dark']) {
|
|
354
|
+
const chain = walkChain(cssVar, mode, map);
|
|
355
|
+
if (chain.terminal !== 'literal') continue;
|
|
356
|
+
const leafKey = primitiveFigmaKey(chain.leafCssVar);
|
|
357
|
+
if (leafKey && leafKey.group === startKey.group && leafKey.leaf === startKey.leaf) return true;
|
|
358
|
+
}
|
|
359
|
+
return false;
|
|
360
|
+
};
|
|
361
|
+
const consumables = colorEntries
|
|
362
|
+
.filter(([k]) => !isAdaptivePrimitive(k))
|
|
363
|
+
.map(([k]) => k);
|
|
364
|
+
|
|
365
|
+
return withProbes(({ lightHost, darkHost }) => {
|
|
366
|
+
// Walk every consumable's chain in BOTH modes. The chain bottoms out on
|
|
367
|
+
// a raw polarity-suffixed primitive (e.g. --a-neutral-50-tint in light,
|
|
368
|
+
// --a-neutral-60-shade in dark). We map each to its adaptive primitive
|
|
369
|
+
// key so both modes alias the same Figma variable name (e.g. {neutral.50}).
|
|
370
|
+
const consumableResolutions = consumables.map(cssVar => {
|
|
371
|
+
const lightChain = walkChain(cssVar, 'light', map);
|
|
372
|
+
const darkChain = walkChain(cssVar, 'dark', map);
|
|
373
|
+
const lightPrimKey = lightChain.leafCssVar ? primitiveFigmaKey(lightChain.leafCssVar) : null;
|
|
374
|
+
const darkPrimKey = darkChain.leafCssVar ? primitiveFigmaKey(darkChain.leafCssVar) : null;
|
|
375
|
+
// Inline-resolved values (used when the chain doesn't reach a primitive
|
|
376
|
+
// we'd emit — e.g. for tokens with composed/calc expressions).
|
|
377
|
+
const lightSrgb = colorToFloatSrgb(parseComputedColor(readComputed(lightHost, cssVar)));
|
|
378
|
+
const darkSrgb = colorToFloatSrgb(parseComputedColor(readComputed(darkHost, cssVar)));
|
|
379
|
+
return { cssVar, lightChain, darkChain, lightPrimKey, darkPrimKey, lightSrgb, darkSrgb };
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// Enumerate every real adaptive primitive (reusing the same classifier
|
|
383
|
+
// that decided which cssVars are NOT consumables).
|
|
384
|
+
const discovered = new Map(); // 'group.leaf' → {group, leaf}
|
|
385
|
+
const recordPrim = (k) => { if (k) discovered.set(`${k.group}.${k.leaf}`, k); };
|
|
386
|
+
for (const cssVar of map.keys()) {
|
|
387
|
+
if (isAdaptivePrimitive(cssVar)) recordPrim(primitiveFigmaKey(cssVar));
|
|
388
|
+
}
|
|
389
|
+
// Defensive: include anything a consumable chain reached that we missed.
|
|
390
|
+
for (const c of consumableResolutions) {
|
|
391
|
+
recordPrim(c.lightPrimKey);
|
|
392
|
+
recordPrim(c.darkPrimKey);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// For each adaptive primitive, probe the underlying -tint cssVar in
|
|
396
|
+
// light mode and the -shade cssVar in dark mode. (Data slots probe the
|
|
397
|
+
// same mode-aware cssVar in both modes.)
|
|
398
|
+
const primitiveResolutions = [...discovered.values()]
|
|
399
|
+
.sort((a, b) => `${a.group}.${a.leaf}`.localeCompare(`${b.group}.${b.leaf}`))
|
|
400
|
+
.map(key => {
|
|
401
|
+
const probes = primitiveProbeCssVars(key);
|
|
402
|
+
return {
|
|
403
|
+
key,
|
|
404
|
+
lightSrgb: colorToFloatSrgb(parseComputedColor(readComputed(lightHost, probes.light))),
|
|
405
|
+
darkSrgb: colorToFloatSrgb(parseComputedColor(readComputed(darkHost, probes.dark))),
|
|
406
|
+
};
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
return { primitiveResolutions, consumableResolutions };
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function formatValue(srgb, format) {
|
|
414
|
+
switch (format) {
|
|
415
|
+
case 'hsl': return formatHsl(srgb);
|
|
416
|
+
case 'float-rgb': return formatFloatRgb(srgb);
|
|
417
|
+
case 'hex': return formatHex(srgb);
|
|
418
|
+
case 'dtcg': {
|
|
419
|
+
const f = x => dp(x, 6);
|
|
420
|
+
const to = x => Math.round(x * 255).toString(16).padStart(2, '0');
|
|
421
|
+
return {
|
|
422
|
+
colorSpace: 'srgb',
|
|
423
|
+
components: [f(srgb.r), f(srgb.g), f(srgb.b)],
|
|
424
|
+
alpha: dp(srgb.alpha, 4),
|
|
425
|
+
hex: '#' + to(srgb.r) + to(srgb.g) + to(srgb.b),
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
default: return formatHex(srgb);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
function buildEntry({ refKey, srgb, format }) {
|
|
432
|
+
// DTCG mode: minimal W3C shape, no Variables-Pro extensions.
|
|
433
|
+
if (format === 'dtcg') {
|
|
434
|
+
const base = { $type: 'color' };
|
|
435
|
+
if (refKey) return { ...base, $value: `{${refKey.group}.${refKey.leaf}}` };
|
|
436
|
+
return { ...base, $value: srgb ? formatValue(srgb, format) : { colorSpace: 'srgb', components: [0, 0, 0], alpha: 1, hex: '#000000' } };
|
|
437
|
+
}
|
|
438
|
+
// Variables-Pro legacy shape — string $value + Figma plugin extension fields.
|
|
439
|
+
const base = { $scopes: ['ALL_SCOPES'], $hiddenFromPublishing: true, $type: 'color' };
|
|
440
|
+
if (refKey) {
|
|
441
|
+
return { ...base, $libraryName: '', $collectionName: 'Tokens', $value: `{${refKey.group}.${refKey.leaf}}` };
|
|
442
|
+
}
|
|
443
|
+
if (!srgb) return { ...base, $value: '#000000' };
|
|
444
|
+
return { ...base, $value: formatValue(srgb, format) };
|
|
445
|
+
}
|
|
446
|
+
function buildMode(modeKey, format, { primitiveResolutions, consumableResolutions }) {
|
|
447
|
+
const tree = {};
|
|
448
|
+
// Primitives — emit by adaptive identity, value differs per mode
|
|
449
|
+
for (const p of primitiveResolutions) {
|
|
450
|
+
const { group, leaf } = p.key;
|
|
451
|
+
const srgb = modeKey === 'light' ? p.lightSrgb : p.darkSrgb;
|
|
452
|
+
tree[group] ??= {};
|
|
453
|
+
tree[group][leaf] = buildEntry({ srgb, format });
|
|
454
|
+
}
|
|
455
|
+
// Consumables — alias the primitive the chain ends on. Light and Dark
|
|
456
|
+
// may alias different primitives (e.g. canvas.strong → neutral.50 in
|
|
457
|
+
// Light, neutral.60 in Dark — that's how the source CSS works).
|
|
458
|
+
for (const c of consumableResolutions) {
|
|
459
|
+
const { group, leaf } = figmaKey(c.cssVar);
|
|
460
|
+
const chain = modeKey === 'light' ? c.lightChain : c.darkChain;
|
|
461
|
+
const primKey = modeKey === 'light' ? c.lightPrimKey : c.darkPrimKey;
|
|
462
|
+
const srgb = modeKey === 'light' ? c.lightSrgb : c.darkSrgb;
|
|
463
|
+
const refKey = (chain.terminal === 'literal' && primKey)
|
|
464
|
+
? primKey
|
|
465
|
+
: null;
|
|
466
|
+
tree[group] ??= {};
|
|
467
|
+
tree[group][leaf] = buildEntry({ refKey, srgb, format });
|
|
468
|
+
}
|
|
469
|
+
return tree;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/* ─── Public API ─────────────────────────────────────────────────────────── */
|
|
473
|
+
// format: 'dtcg' — Figma's NATIVE direct-import format. W3C Design
|
|
474
|
+
// Tokens 1.0 spec. $value is a structured object
|
|
475
|
+
// {colorSpace, components, alpha, hex}. Emits ONE
|
|
476
|
+
// FILE PER MODE — call buildDtcgFiles() for both.
|
|
477
|
+
// 'hex' — Variables Pro / jake-figma legacy. $value is a
|
|
478
|
+
// '#hex' or 'rgba(int, int, int, float)' string.
|
|
479
|
+
// Modes wrapped under [{Tokens: {modes: {Light, Dark}}}].
|
|
480
|
+
// 'float-rgb' — Legacy float-RGB literal string (permissive parsers only).
|
|
481
|
+
// 'hsl' — Legacy decimal-HSL (non-standard).
|
|
482
|
+
export function buildFigmaJson({ format = 'dtcg' } = {}) {
|
|
483
|
+
const ex = extract();
|
|
484
|
+
if (format === 'dtcg') {
|
|
485
|
+
// For DTCG, default to a SINGLE FILE containing the Light mode (Figma's
|
|
486
|
+
// canonical shape). Use buildDtcgFiles() to get both modes as separate
|
|
487
|
+
// files.
|
|
488
|
+
return buildMode('light', 'dtcg', ex);
|
|
489
|
+
}
|
|
490
|
+
// Variables Pro legacy: nested modes wrapper, string $value.
|
|
491
|
+
return [{
|
|
492
|
+
Tokens: {
|
|
493
|
+
modes: {
|
|
494
|
+
Light: buildMode('light', format, ex),
|
|
495
|
+
Dark: buildMode('dark', format, ex),
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
}];
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// DTCG emits one file per mode. Returns a list of {filename, body} pairs
|
|
502
|
+
// suitable for sequential downloads. Filenames are simple mode names so
|
|
503
|
+
// that Figma's import (which derives the mode name from the filename
|
|
504
|
+
// basename minus `.tokens.json`) creates modes called "light" and "dark".
|
|
505
|
+
export function buildDtcgFiles() {
|
|
506
|
+
const ex = extract();
|
|
507
|
+
return [
|
|
508
|
+
{ filename: `light.tokens.json`, body: buildMode('light', 'dtcg', ex) },
|
|
509
|
+
{ filename: `dark.tokens.json`, body: buildMode('dark', 'dtcg', ex) },
|
|
510
|
+
];
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export function getExportStats() {
|
|
514
|
+
const ex = extract();
|
|
515
|
+
let refCovL = 0, refCovD = 0, clampedL = 0, clampedD = 0;
|
|
516
|
+
for (const c of ex.consumableResolutions) {
|
|
517
|
+
if (c.lightPrimKey) refCovL++;
|
|
518
|
+
if (c.darkPrimKey) refCovD++;
|
|
519
|
+
if (c.lightSrgb?.gamut === 'clamped') clampedL++;
|
|
520
|
+
if (c.darkSrgb?.gamut === 'clamped') clampedD++;
|
|
521
|
+
}
|
|
522
|
+
for (const p of ex.primitiveResolutions) {
|
|
523
|
+
if (p.lightSrgb?.gamut === 'clamped') clampedL++;
|
|
524
|
+
if (p.darkSrgb?.gamut === 'clamped') clampedD++;
|
|
525
|
+
}
|
|
526
|
+
return {
|
|
527
|
+
primitives: ex.primitiveResolutions.length,
|
|
528
|
+
consumables: ex.consumableResolutions.length,
|
|
529
|
+
referenceCoverageLight: refCovL,
|
|
530
|
+
referenceCoverageDark: refCovD,
|
|
531
|
+
gamutClampedLight: clampedL,
|
|
532
|
+
gamutClampedDark: clampedD,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export function downloadJson(filename, data) {
|
|
537
|
+
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
538
|
+
const url = URL.createObjectURL(blob);
|
|
539
|
+
const a = document.createElement('a');
|
|
540
|
+
a.href = url;
|
|
541
|
+
a.download = filename;
|
|
542
|
+
// The site's SPA router listens for document-level click events on
|
|
543
|
+
// anchors to trigger client-side navigation and would try to
|
|
544
|
+
// history.pushState() the blob URL (which throws cross-origin).
|
|
545
|
+
// Dispatch a non-bubbling synthetic click so the event never reaches
|
|
546
|
+
// document — the browser's default download action still fires because
|
|
547
|
+
// it's tied to the event target's anchor element, not propagation.
|
|
548
|
+
a.dispatchEvent(new MouseEvent('click', { bubbles: false, cancelable: true, view: window }));
|
|
549
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
export async function copyJson(data) {
|
|
553
|
+
await navigator.clipboard.writeText(JSON.stringify(data, null, 2));
|
|
554
|
+
}
|