@spectral_labs/ui 1.5.3 → 1.6.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.
@@ -0,0 +1,134 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { describe, expect, it } from 'vitest';
5
+ /**
6
+ * spectral-ui#206 — the six covering surfaces paint the OPAQUE scrim token and
7
+ * own the veil's alpha through `opacity` (0.32, via the component's
8
+ * `--sp-comp-*-overlay-opacity` token where one exists). Exactly one owner:
9
+ * the colour is never translucent, the opacity is never missing or wrong.
10
+ *
11
+ * The two values are matched against an ALLOW-LIST, not a deny-list: any other
12
+ * spelling fails, so `#0000004d`, `oklch(0 0 0 / .3)`, `rgb(0 0 0 / 30%)` or a
13
+ * `color-mix()` cannot slip through a list of forbidden prefixes. Declarations
14
+ * are parsed (property, value) instead of substring-matched, because
15
+ * `--sp-comp-dialog-overlay-opacity: 0.32;` literally ends with
16
+ * `opacity: 0.32;` and would otherwise satisfy a naive regex while the real
17
+ * `opacity` declaration says something else.
18
+ *
19
+ * EVERY rule in the file that targets the class is inspected, not just the
20
+ * first one: in a Svelte component a later rule (a duplicate, a `@media`
21
+ * block) lives in the same scoped `<style>` and wins the cascade.
22
+ *
23
+ * Three of these surfaces (SideSheet, NavDrawer, BottomSheet) have no cell in
24
+ * `tests/e2e/scrim-alpha.spec.ts`, so this file is their only control — it has
25
+ * to be exact. The list is literal on purpose: a seventh surface that forgets
26
+ * the idiom has to be added here.
27
+ */
28
+ const here = dirname(fileURLToPath(import.meta.url));
29
+ const SURFACES = [
30
+ { file: 'dialog/components/dialog-overlay.svelte', selector: '.sp-dialog-overlay' },
31
+ {
32
+ file: 'alert-dialog/components/alert-dialog-overlay.svelte',
33
+ selector: '.sp-alert-dialog-overlay',
34
+ },
35
+ { file: 'sheet/components/sheet-overlay.svelte', selector: '.sp-sheet-overlay' },
36
+ { file: 'side-sheet/side-sheet.svelte', selector: '.sp-side-sheet-overlay' },
37
+ { file: 'nav-drawer/components/nav-drawer-root.svelte', selector: '.sp-nav-drawer-overlay' },
38
+ {
39
+ file: 'bottom-sheet/components/bottom-sheet-root.svelte',
40
+ selector: '.sp-bottom-sheet-overlay',
41
+ },
42
+ ];
43
+ /** `background-color` values that keep the colour opaque and token-driven. */
44
+ const ALLOWED_BACKGROUND = [
45
+ /^var\(--sp-sys-color-scrim\)$/,
46
+ /^var\(--sp-comp-[a-z-]+-overlay-color,\s*var\(--sp-sys-color-scrim\)\)$/,
47
+ ];
48
+ /** `opacity` values that make the component the single owner of the alpha. */
49
+ const ALLOWED_OPACITY = [/^0\.32$/, /^var\(--sp-comp-[a-z-]+-overlay-opacity,\s*0\.32\)$/];
50
+ /** The component's scoped stylesheet, so template braces are never parsed. */
51
+ function styleBlock(componentSource, file) {
52
+ const match = componentSource.match(/<style>(.*)<\/style>/s);
53
+ if (!match)
54
+ throw new Error(`${file}: no <style> block`);
55
+ return match[1];
56
+ }
57
+ /** Every rule in `source` whose selector list mentions `selector`, in order. */
58
+ function rulesTargeting(source, selector) {
59
+ const rules = [];
60
+ for (let open = 0; open < source.length; open++) {
61
+ if (source[open] !== '{')
62
+ continue;
63
+ let start = open - 1;
64
+ while (start >= 0 && !'{};'.includes(source[start]))
65
+ start--;
66
+ const selectorList = source.slice(start + 1, open).trim();
67
+ if (!selectorList.includes(selector))
68
+ continue;
69
+ let depth = 1;
70
+ let close = open + 1;
71
+ for (; close < source.length && depth > 0; close++) {
72
+ if (source[close] === '{')
73
+ depth++;
74
+ else if (source[close] === '}')
75
+ depth--;
76
+ }
77
+ const declarations = source
78
+ .slice(open + 1, close - 1)
79
+ .split(';')
80
+ .map((chunk) => chunk.trim())
81
+ .filter((chunk) => chunk.includes(':') && !chunk.includes('{'))
82
+ .map((chunk) => ({
83
+ property: chunk.slice(0, chunk.indexOf(':')).trim(),
84
+ value: chunk.slice(chunk.indexOf(':') + 1).trim(),
85
+ }));
86
+ rules.push({ selectorList, declarations });
87
+ }
88
+ return rules;
89
+ }
90
+ const valueOf = (rule, property) => rule.declarations.findLast((d) => d.property === property)?.value;
91
+ describe('overlay surfaces own the scrim alpha through opacity (#206)', () => {
92
+ for (const { file, selector } of SURFACES) {
93
+ const source = readFileSync(resolve(here, '..', file), 'utf8');
94
+ const rules = rulesTargeting(styleBlock(source, file), selector);
95
+ // The veil rule is the one whose selector is the bare class; anything else
96
+ // (`[data-state='closed']`, a media-query variant) is a variant rule.
97
+ const veilRules = rules.filter((r) => r.selectorList === selector);
98
+ const variantRules = rules.filter((r) => r.selectorList !== selector);
99
+ it(`${selector} is painted by exactly one veil rule`, () => {
100
+ expect(veilRules).toHaveLength(1);
101
+ });
102
+ it(`${selector} paints the opaque scrim token`, () => {
103
+ const background = valueOf(veilRules[0], 'background-color');
104
+ expect(background, `${file}: background-color`).toBeDefined();
105
+ expect(ALLOWED_BACKGROUND.some((allowed) => allowed.test(background)), `${file}: background-color "${background}" is not an opaque token reference`).toBe(true);
106
+ });
107
+ it(`${selector} carries the veil's alpha as opacity 0.32`, () => {
108
+ const opacity = valueOf(veilRules[0], 'opacity');
109
+ expect(opacity, `${file}: opacity`).toBeDefined();
110
+ expect(ALLOWED_OPACITY.some((allowed) => allowed.test(opacity)), `${file}: opacity "${opacity}" is not the 0.32 the component owns`).toBe(true);
111
+ });
112
+ it(`${selector} declares no local overlay token that would change the veil`, () => {
113
+ for (const rule of rules) {
114
+ for (const { property, value } of rule.declarations) {
115
+ if (/^--sp-comp-[a-z-]+-overlay-opacity$/.test(property)) {
116
+ expect(value, `${file}: ${property}`).toBe('0.32');
117
+ }
118
+ if (/^--sp-comp-[a-z-]+-overlay-color$/.test(property)) {
119
+ expect(value, `${file}: ${property}`).toBe('var(--sp-sys-color-scrim)');
120
+ }
121
+ }
122
+ }
123
+ });
124
+ it(`${selector} variants only fade the veil out, never repaint it`, () => {
125
+ for (const rule of variantRules) {
126
+ expect(valueOf(rule, 'background-color'), `${file}: "${rule.selectorList}" repaints the veil`).toBeUndefined();
127
+ const opacity = valueOf(rule, 'opacity');
128
+ if (opacity !== undefined) {
129
+ expect(opacity, `${file}: "${rule.selectorList}" opacity`).toBe('0');
130
+ }
131
+ }
132
+ });
133
+ }
134
+ });
@@ -16,6 +16,10 @@
16
16
  closeOnOutsideClick = true,
17
17
  onOpenChange = undefined,
18
18
  onClose = undefined,
19
+ itemAttrs = undefined,
20
+ disabledItemAttrs = undefined,
21
+ onItemSelect = undefined,
22
+ onDisabledItemSelect = undefined,
19
23
  }: {
20
24
  value?: string
21
25
  shouldFilter?: boolean
@@ -25,6 +29,14 @@
25
29
  closeOnOutsideClick?: boolean
26
30
  onOpenChange?: (open: boolean) => void
27
31
  onClose?: () => void
32
+ /** Arbitrary HTML attributes / native handlers spread onto the `copy` item. */
33
+ itemAttrs?: Record<string, unknown>
34
+ /** Same, spread onto the `settings` item (the disabled one). */
35
+ disabledItemAttrs?: Record<string, unknown>
36
+ /** Spectral `onSelect` of the `copy` item. */
37
+ onItemSelect?: (value: string) => void
38
+ /** Spectral `onSelect` of the `settings` item. */
39
+ onDisabledItemSelect?: (value: string) => void
28
40
  } = $props()
29
41
  </script>
30
42
 
@@ -42,14 +54,24 @@
42
54
  <CommandList>
43
55
  <CommandEmpty>No results found.</CommandEmpty>
44
56
  <CommandGroup heading="Actions">
45
- <CommandItem value="copy" keywords={['duplicate']}>Copy</CommandItem>
57
+ <CommandItem
58
+ value="copy"
59
+ keywords={['duplicate']}
60
+ onSelect={onItemSelect}
61
+ {...itemAttrs ?? {}}>Copy</CommandItem
62
+ >
46
63
  <CommandItem value="paste">Paste</CommandItem>
47
64
  <CommandItem value="cut">Cut</CommandItem>
48
65
  </CommandGroup>
49
66
  <CommandSeparator />
50
67
  <CommandGroup heading="Navigation">
51
68
  <CommandItem value="home">Home</CommandItem>
52
- <CommandItem value="settings" disabled>Settings</CommandItem>
69
+ <CommandItem
70
+ value="settings"
71
+ disabled
72
+ onSelect={onDisabledItemSelect}
73
+ {...disabledItemAttrs ?? {}}>Settings</CommandItem
74
+ >
53
75
  </CommandGroup>
54
76
  </CommandList>
55
77
  </CommandRoot>
@@ -0,0 +1,284 @@
1
+ import { flushSync, mount, unmount } from 'svelte';
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import CommandTest from './CommandTest.svelte';
4
+ // jsdom does not define PointerEvent — polyfill it for tests
5
+ if (typeof globalThis.PointerEvent === 'undefined') {
6
+ // @ts-expect-error — minimal polyfill for testing
7
+ globalThis.PointerEvent = class PointerEvent extends MouseEvent {
8
+ constructor(type, init) {
9
+ super(type, init);
10
+ }
11
+ };
12
+ }
13
+ /**
14
+ * spectral-ui#205 — `Command.Item` forwards native HTML attributes and handlers
15
+ * to its real `role="option"` node.
16
+ *
17
+ * Contract pinned here:
18
+ * - consumer attributes (`title`, `aria-*`, `data-testid`, `style`, …) reach
19
+ * the option, and a value reassigned by the parent AFTER mount is reflected;
20
+ * - the option's IDENTITY stays under the component's control: `role`, `id`,
21
+ * `tabindex`, `aria-selected`, `aria-disabled` and the internal `data-*`
22
+ * (`data-command-item`, `data-value`, `data-selected`, `data-disabled`)
23
+ * cannot be overridden — the spread comes FIRST, the identity comes after;
24
+ * - native handlers COMPOSE with the built-in behaviour: the consumer's runs
25
+ * first, `preventDefault()` cancels the Spectral selection, `onSelect` only
26
+ * fires after a real selection;
27
+ * - activation of a disabled row is inert: consumer `onclick` / `onkeydown` do
28
+ * not run either, while `onpointermove` stays observable (a dimmed row can
29
+ * still be hovered) without ever selecting the row.
30
+ *
31
+ * Tests titled `guard —` were already green on the pre-#205 tree (nothing was
32
+ * forwarded, so nothing could be overridden, leaked or mis-composed); their
33
+ * discriminating power comes from the mutant campaign below, each mutant
34
+ * applied alone on the final tree and killed by the named test(s):
35
+ * - spread placed LAST → identity guard, Enter-from-input guard
36
+ * - rest not spread → forwarding, post-mount reactivity, identity
37
+ * - `keywords` left in rest → no-leak guard
38
+ * - consumer handler run BEFORE the disabled guard → disabled-inert guard
39
+ * - onclick / onkeydown / onpointermove ignore `defaultPrevented` → cancel pins
40
+ * - onkeydown not composed → onkeydown compose + cancel
41
+ * - rest captured statically at init → post-mount reactivity
42
+ * - `data-command-item` rendered boolean → identity
43
+ * - onpointermove guarded on disabled → disabled-observable pin
44
+ * - hover routed through `select()` → hover-does-not-fire-onSelect guard
45
+ * - Space branch dropped → Space activation guard
46
+ * - disabled keydown returns before `preventDefault()` → disabled-Space pin
47
+ * - `'id'` dropped from the type's `Omit` → `src/__typecheck__` pin
48
+ *
49
+ * Keyboard note: Enter pressed in the INPUT activates the selected option
50
+ * through a synthetic `click()` (command-input.svelte), so a consumer observes
51
+ * that path through `onclick`, not `onkeydown`.
52
+ */
53
+ describe('command item — native attribute passthrough (#205)', () => {
54
+ let target;
55
+ let instance;
56
+ function setup(props = {}) {
57
+ target = document.createElement('div');
58
+ document.body.appendChild(target);
59
+ instance = mount(CommandTest, { target, props: { portalTo: target, ...props } });
60
+ flushSync();
61
+ flushSync(); // $effect item registration
62
+ return target;
63
+ }
64
+ const option = (value) => target.querySelector(`[role="option"][data-value="${value}"]`);
65
+ const input = () => target.querySelector('input[role="combobox"]');
66
+ afterEach(() => {
67
+ if (instance)
68
+ unmount(instance);
69
+ target?.remove();
70
+ });
71
+ // ── Forwarding ────────────────────────────────────────────────────────
72
+ it('forwards consumer HTML attributes to the option element', () => {
73
+ setup({
74
+ itemAttrs: {
75
+ title: 'Copy the selection',
76
+ 'aria-label': 'Copy',
77
+ 'aria-describedby': 'copy-hint',
78
+ 'data-testid': 'opt-copy',
79
+ style: 'color: red',
80
+ },
81
+ });
82
+ const el = option('copy');
83
+ expect(el.getAttribute('title')).toBe('Copy the selection');
84
+ expect(el.getAttribute('aria-label')).toBe('Copy');
85
+ expect(el.getAttribute('aria-describedby')).toBe('copy-hint');
86
+ expect(el.getAttribute('data-testid')).toBe('opt-copy');
87
+ expect(el.style.color).toBe('red');
88
+ });
89
+ it('reflects a consumer attribute reassigned by the parent after mount', () => {
90
+ target = document.createElement('div');
91
+ document.body.appendChild(target);
92
+ const props = $state({
93
+ portalTo: target,
94
+ itemAttrs: { title: 'Before', 'aria-describedby': 'hint-before' },
95
+ });
96
+ instance = mount(CommandTest, { target, props });
97
+ flushSync();
98
+ expect(option('copy').getAttribute('title')).toBe('Before');
99
+ props.itemAttrs = { title: 'After', 'aria-describedby': 'hint-after' };
100
+ flushSync();
101
+ expect(option('copy').getAttribute('title')).toBe('After');
102
+ expect(option('copy').getAttribute('aria-describedby')).toBe('hint-after');
103
+ });
104
+ it('guard — keeps the DS class alongside a consumer class', () => {
105
+ setup({ itemAttrs: { class: 'mine' } });
106
+ const el = option('copy');
107
+ expect(el.classList.contains('sp-command-item')).toBe(true);
108
+ expect(el.classList.contains('mine')).toBe(true);
109
+ });
110
+ // ── Identity stays ours ───────────────────────────────────────────────
111
+ it('keeps its identity attributes when a consumer tries to override them', () => {
112
+ setup({
113
+ itemAttrs: {
114
+ role: 'menuitem',
115
+ id: 'consumer-id',
116
+ tabindex: 0,
117
+ 'aria-selected': 'false',
118
+ 'aria-disabled': 'true',
119
+ 'data-selected': 'false',
120
+ 'data-value': 'leak',
121
+ 'data-disabled': '',
122
+ 'data-command-item': 'nope',
123
+ 'data-testid': 'still-forwarded',
124
+ },
125
+ });
126
+ // `copy` is the first enabled item → auto-selected.
127
+ const el = target.querySelector('[data-testid="still-forwarded"]');
128
+ expect(el).toBeTruthy();
129
+ expect(el.getAttribute('role')).toBe('option');
130
+ expect(el.id).toMatch(/^sp-command-item-\d+$/);
131
+ expect(el.getAttribute('tabindex')).toBe('-1');
132
+ expect(el.getAttribute('aria-selected')).toBe('true');
133
+ expect(el.hasAttribute('aria-disabled')).toBe(false);
134
+ expect(el.getAttribute('data-selected')).toBe('true');
135
+ expect(el.getAttribute('data-value')).toBe('copy');
136
+ expect(el.hasAttribute('data-disabled')).toBe(false);
137
+ // `data-command-item` keeps its shipped form: present, empty-valued.
138
+ expect(el.hasAttribute('data-command-item')).toBe(true);
139
+ expect(el.getAttribute('data-command-item')).toBe('');
140
+ });
141
+ it('guard — Enter in the input activates the item Spectral selected, whatever the consumer claims', () => {
142
+ const selected = [];
143
+ setup({
144
+ itemAttrs: { 'data-selected': 'false' },
145
+ onItemSelect: (v) => selected.push(v),
146
+ });
147
+ input().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
148
+ flushSync();
149
+ expect(selected).toEqual(['copy']);
150
+ });
151
+ it('guard — does not leak component props to the DOM', () => {
152
+ setup({ onItemSelect: () => { } });
153
+ const copy = option('copy');
154
+ // (`on*` keys never surface as attributes in Svelte, so only these can fail.)
155
+ for (const attr of ['value', 'keywords']) {
156
+ expect(copy.hasAttribute(attr)).toBe(false);
157
+ }
158
+ expect(option('settings').hasAttribute('disabled')).toBe(false);
159
+ });
160
+ // ── Handler composition ───────────────────────────────────────────────
161
+ it('composes a consumer onclick with the built-in selection (consumer first)', () => {
162
+ const seen = [];
163
+ setup({
164
+ itemAttrs: { onclick: () => seen.push('consumer') },
165
+ onItemSelect: (v) => seen.push(`selected:${v}`),
166
+ });
167
+ option('copy').click();
168
+ flushSync();
169
+ expect(seen).toEqual(['consumer', 'selected:copy']);
170
+ });
171
+ it('lets a consumer onclick cancel the selection with preventDefault', () => {
172
+ const selected = [];
173
+ setup({
174
+ itemAttrs: { onclick: (e) => e.preventDefault() },
175
+ onItemSelect: (v) => selected.push(v),
176
+ });
177
+ // Move the selection away first, so a (wrongly) successful click is visible.
178
+ input().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
179
+ flushSync();
180
+ expect(option('paste').getAttribute('data-selected')).toBe('true');
181
+ option('copy').click();
182
+ flushSync();
183
+ expect(selected).toEqual([]);
184
+ expect(option('copy').getAttribute('data-selected')).toBe('false');
185
+ expect(option('paste').getAttribute('data-selected')).toBe('true');
186
+ });
187
+ it('composes a consumer onkeydown with Enter on the option (consumer first)', () => {
188
+ const seen = [];
189
+ setup({
190
+ itemAttrs: { onkeydown: () => seen.push('consumer') },
191
+ onItemSelect: (v) => seen.push(`selected:${v}`),
192
+ });
193
+ option('copy').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
194
+ flushSync();
195
+ expect(seen).toEqual(['consumer', 'selected:copy']);
196
+ });
197
+ it('guard — composes a consumer onkeydown with Space on the option (consumer first)', () => {
198
+ const seen = [];
199
+ setup({
200
+ itemAttrs: { onkeydown: () => seen.push('consumer') },
201
+ onItemSelect: (v) => seen.push(`selected:${v}`),
202
+ });
203
+ option('copy').dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }));
204
+ flushSync();
205
+ expect(seen).toEqual(['consumer', 'selected:copy']);
206
+ });
207
+ it('lets a consumer onkeydown cancel the Enter/Space activation with preventDefault', () => {
208
+ const selected = [];
209
+ setup({
210
+ itemAttrs: { onkeydown: (e) => e.preventDefault() },
211
+ onItemSelect: (v) => selected.push(v),
212
+ });
213
+ option('copy').dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }));
214
+ flushSync();
215
+ expect(selected).toEqual([]);
216
+ });
217
+ it('composes a consumer onpointermove with the hover-selection — which never fires onSelect', () => {
218
+ const seen = [];
219
+ setup({
220
+ itemAttrs: { onpointermove: () => seen.push('consumer') },
221
+ onItemSelect: (v) => seen.push(`selected:${v}`),
222
+ });
223
+ // Start from a different selection so the hover-select is observable.
224
+ input().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
225
+ flushSync();
226
+ expect(option('paste').getAttribute('data-selected')).toBe('true');
227
+ option('copy').dispatchEvent(new PointerEvent('pointermove', { bubbles: true, cancelable: true }));
228
+ flushSync();
229
+ expect(seen).toEqual(['consumer']);
230
+ expect(option('copy').getAttribute('data-selected')).toBe('true');
231
+ });
232
+ it('lets a consumer onpointermove cancel the hover-selection with preventDefault', () => {
233
+ setup({ itemAttrs: { onpointermove: (e) => e.preventDefault() } });
234
+ input().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
235
+ flushSync();
236
+ option('copy').dispatchEvent(new PointerEvent('pointermove', { bubbles: true, cancelable: true }));
237
+ flushSync();
238
+ expect(option('copy').getAttribute('data-selected')).toBe('false');
239
+ expect(option('paste').getAttribute('data-selected')).toBe('true');
240
+ });
241
+ // ── Disabled: activation inert, hover observable ──────────────────────
242
+ it('guard — activation of a disabled item is inert: consumer onclick/onkeydown do not run', () => {
243
+ const seen = [];
244
+ setup({
245
+ disabledItemAttrs: {
246
+ onclick: () => seen.push('click'),
247
+ onkeydown: () => seen.push('keydown'),
248
+ },
249
+ onDisabledItemSelect: (v) => seen.push(`selected:${v}`),
250
+ });
251
+ const settings = option('settings');
252
+ settings.click();
253
+ settings.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
254
+ flushSync();
255
+ expect(seen).toEqual([]);
256
+ expect(option('copy').getAttribute('data-selected')).toBe('true');
257
+ expect(settings.getAttribute('data-selected')).toBe('false');
258
+ });
259
+ it('still prevents the Space default on a focused disabled item, without running the consumer onkeydown', () => {
260
+ const seen = [];
261
+ setup({ disabledItemAttrs: { onkeydown: () => seen.push('keydown') } });
262
+ const event = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true });
263
+ option('settings').dispatchEvent(event);
264
+ flushSync();
265
+ // Pre-#205 the item prevented Enter/Space before bailing on `disabled`,
266
+ // so Space on a dimmed, focused row never scrolled the list.
267
+ expect(event.defaultPrevented).toBe(true);
268
+ expect(seen).toEqual([]);
269
+ expect(option('copy').getAttribute('data-selected')).toBe('true');
270
+ });
271
+ it('keeps a consumer onpointermove observable on a disabled item without selecting it', () => {
272
+ const seen = [];
273
+ setup({
274
+ disabledItemAttrs: { onpointermove: () => seen.push('move') },
275
+ onDisabledItemSelect: (v) => seen.push(`selected:${v}`),
276
+ });
277
+ const settings = option('settings');
278
+ settings.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, cancelable: true }));
279
+ flushSync();
280
+ expect(seen).toEqual(['move']);
281
+ expect(settings.getAttribute('data-selected')).toBe('false');
282
+ expect(option('copy').getAttribute('data-selected')).toBe('true');
283
+ });
284
+ });
@@ -10,6 +10,12 @@
10
10
  onSelect,
11
11
  class: className,
12
12
  children,
13
+ // Pulled out of `rest` so the built-in behaviour can COMPOSE with them
14
+ // instead of being clobbered by the spread.
15
+ onclick,
16
+ onkeydown,
17
+ onpointermove,
18
+ ...rest
13
19
  }: CommandItemProps = $props()
14
20
  const ctx = getCommandContext()
15
21
  const id = useId('sp-command-item')
@@ -23,37 +29,68 @@
23
29
  return () => ctx.unregisterItem(id)
24
30
  })
25
31
 
26
- function onclick() {
27
- if (disabled) return
32
+ /** Svelte's DOM attribute handlers narrow `currentTarget` to the host
33
+ * element; a bare `MouseEvent` is not assignable to that. */
34
+ type RowEvent<E extends Event> = E & { currentTarget: EventTarget & HTMLDivElement }
35
+
36
+ // `onSelect` only fires after a real Spectral selection.
37
+ function select() {
28
38
  ctx.select(value)
29
39
  onSelect?.(value)
30
40
  }
31
41
 
32
- function onpointermove() {
42
+ // Activation of a disabled item is INERT, which includes the consumer's
43
+ // handler: a dimmed, unselectable row must not fire an application effect.
44
+ function handleClick(event: RowEvent<MouseEvent>) {
45
+ if (disabled) return
46
+ onclick?.(event)
47
+ if (event.defaultPrevented) return
48
+ select()
49
+ }
50
+
51
+ function handleKeydown(event: RowEvent<KeyboardEvent>) {
52
+ if (disabled) {
53
+ // Activation stays inert, but Enter/Space keep their default prevented so
54
+ // a focused, dimmed row never scrolls the list.
55
+ if (event.key === 'Enter' || event.key === ' ') event.preventDefault()
56
+ return
57
+ }
58
+ onkeydown?.(event)
59
+ if (event.defaultPrevented) return
60
+ if (event.key === 'Enter' || event.key === ' ') {
61
+ event.preventDefault()
62
+ select()
63
+ }
64
+ }
65
+
66
+ // Deliberately NOT guarded on `disabled`: a consumer may observe the pointer
67
+ // over a dimmed row; only the internal hover-selection is forbidden.
68
+ function handlePointerMove(event: RowEvent<PointerEvent>) {
69
+ onpointermove?.(event)
70
+ if (event.defaultPrevented) return
33
71
  if (!disabled && ctx.selectedValue !== value) ctx.select(value)
34
72
  }
35
73
  </script>
36
74
 
37
75
  {#if isVisible}
76
+ <!-- `{...rest}` FIRST: a consumer may add attributes, never override the
77
+ option's identity (role, id, selection state, internal data-*) — those
78
+ come after and win. `data-command-item=""` keeps its shipped form. -->
38
79
  <div
80
+ {...rest}
39
81
  class="sp-command-item {className ?? ''}"
40
82
  {id}
41
83
  role="option"
42
84
  aria-selected={isSelected}
43
85
  aria-disabled={disabled || undefined}
44
- data-command-item
86
+ data-command-item=""
45
87
  data-value={value}
46
88
  data-selected={isSelected}
47
89
  data-disabled={disabled || undefined}
48
90
  tabindex={-1}
49
- {onclick}
50
- onkeydown={(e) => {
51
- if (e.key === 'Enter' || e.key === ' ') {
52
- e.preventDefault()
53
- onclick()
54
- }
55
- }}
56
- {onpointermove}
91
+ onclick={handleClick}
92
+ onkeydown={handleKeydown}
93
+ onpointermove={handlePointerMove}
57
94
  >
58
95
  {#if children}{@render children()}{/if}
59
96
  </div>
@@ -1 +1 @@
1
- {"version":3,"file":"command-item.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/command/components/command-item.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA;AAuDnD,QAAA,MAAM,WAAW,sDAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
1
+ {"version":3,"file":"command-item.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/command/components/command-item.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA;AAyFnD,QAAA,MAAM,WAAW,sDAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
@@ -1,4 +1,5 @@
1
1
  import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
2
3
  export interface CommandProps {
3
4
  /** Current search query (bindable). */
4
5
  value?: string;
@@ -53,14 +54,38 @@ export interface CommandGroupProps {
53
54
  class?: string;
54
55
  children?: Snippet;
55
56
  }
56
- export interface CommandItemProps {
57
+ /**
58
+ * Extends the native `div` attributes (spectral-ui#205): a consumer's `title`,
59
+ * `aria-*` (including `aria-label`, which replaces the name derived from the
60
+ * content — standard ARIA precedence; `value`/`keywords` stay the filter's
61
+ * authority), `data-testid`, `style` and native event handlers reach the real
62
+ * `role="option"` node.
63
+ *
64
+ * The option's identity stays with the component: `role`, `id`, `tabindex`,
65
+ * `aria-selected` and `aria-disabled` are rejected by the type, and the
66
+ * internal `data-*` (`data-command-item`, `data-value`, `data-selected`,
67
+ * `data-disabled`) cannot be overridden — the type cannot exclude `data-*`
68
+ * keys, the markup order does.
69
+ *
70
+ * Consumer handlers COMPOSE with the built-in ones: yours runs first, and
71
+ * calling `preventDefault()` cancels the Spectral selection. Activation of a
72
+ * disabled item is inert (`onclick` / `onkeydown` do not run); `onpointermove`
73
+ * stays observable on a disabled item, which is never selected.
74
+ */
75
+ export interface CommandItemProps extends Omit<HTMLAttributes<HTMLDivElement>, 'class' | 'children' | 'role' | 'id' | 'tabindex' | 'aria-selected' | 'aria-disabled'> {
57
76
  /** Unique value for this item. */
58
77
  value: string;
59
78
  /** Extra keywords used for search matching (not displayed). */
60
79
  keywords?: string[];
61
80
  /** Whether the item is disabled. */
62
81
  disabled?: boolean;
63
- /** Called when the item is selected (Enter or click). */
82
+ /**
83
+ * Called after a real Spectral selection (click, Enter/Space on the option,
84
+ * or Enter in the input — which reaches the option as a synthetic `click`).
85
+ * Hover-selection never fires it. `onSelect` is the Spectral callback; the
86
+ * native lowercase `onselect` DOM handler, if provided, stays an ordinary DOM
87
+ * event — Command's selection mechanisms never invoke it.
88
+ */
64
89
  onSelect?: (value: string) => void;
65
90
  class?: string;
66
91
  children?: Snippet;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/lib/components/command/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAA;AAErC,MAAM,WAAW,YAAY;IAC3B,uCAAuC;IACvC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,gGAAgG;IAChG,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,EAAE,KAAK,MAAM,CAAA;IACvE,0EAA0E;IAC1E,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,gEAAgE;IAChE,IAAI,CAAC,EAAE,OAAO,CAAA;IACd;;OAEG;IACH,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,wDAAwD;IACxD,QAAQ,CAAC,EAAE,WAAW,GAAG,MAAM,CAAA;IAC/B,oDAAoD;IACpD,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,8CAA8C;IAC9C,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,mDAAmD;IACnD,mBAAmB,CAAC,EAAE,OAAO,CAAA;IAC7B,mDAAmD;IACnD,eAAe,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAA;IAChD,qDAAqD;IACrD,iBAAiB,CAAC,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,CAAA;IACjD,uFAAuF;IACvF,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACtC;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IACpB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,iBAAiB;IAChC,6CAA6C;IAC7C,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,gBAAgB;IAC/B,wCAAwC;IACxC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,iBAAiB;IAChC,qCAAqC;IACrC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,wEAAwE;IACxE,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,gBAAgB;IAC/B,kCAAkC;IAClC,KAAK,EAAE,MAAM,CAAA;IACb,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;IACnB,oCAAoC;IACpC,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,yDAAyD;IACzD,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IAClC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,qBAAqB;IACpC,KAAK,CAAC,EAAE,MAAM,CAAA;CACf"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/lib/components/command/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAA;AACrC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AAErD,MAAM,WAAW,YAAY;IAC3B,uCAAuC;IACvC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,gGAAgG;IAChG,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,EAAE,KAAK,MAAM,CAAA;IACvE,0EAA0E;IAC1E,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,gEAAgE;IAChE,IAAI,CAAC,EAAE,OAAO,CAAA;IACd;;OAEG;IACH,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,wDAAwD;IACxD,QAAQ,CAAC,EAAE,WAAW,GAAG,MAAM,CAAA;IAC/B,oDAAoD;IACpD,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,8CAA8C;IAC9C,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,mDAAmD;IACnD,mBAAmB,CAAC,EAAE,OAAO,CAAA;IAC7B,mDAAmD;IACnD,eAAe,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAA;IAChD,qDAAqD;IACrD,iBAAiB,CAAC,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,CAAA;IACjD,uFAAuF;IACvF,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACtC;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IACpB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,iBAAiB;IAChC,6CAA6C;IAC7C,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,gBAAgB;IAC/B,wCAAwC;IACxC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,iBAAiB;IAChC,qCAAqC;IACrC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,wEAAwE;IACxE,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,WAAW,gBAAiB,SAAQ,IAAI,CAC5C,cAAc,CAAC,cAAc,CAAC,EAC9B,OAAO,GAAG,UAAU,GAAG,MAAM,GAAG,IAAI,GAAG,UAAU,GAAG,eAAe,GAAG,eAAe,CACtF;IACC,kCAAkC;IAClC,KAAK,EAAE,MAAM,CAAA;IACb,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;IACnB,oCAAoC;IACpC,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IAClC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,qBAAqB;IACpC,KAAK,CAAC,EAAE,MAAM,CAAA;CACf"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spectral_labs/ui",
3
3
  "type": "module",
4
- "version": "1.5.3",
4
+ "version": "1.6.0",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",
7
7
  "access": "public"
@@ -0,0 +1,53 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import { dirname, resolve } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { describe, expect, it } from 'vitest'
6
+ import { generateThemeCSS, generateTokensCSS, themes } from '../../../../../scripts/generate-themes'
7
+
8
+ /**
9
+ * Durable control between `scripts/generate-themes.ts` and the six committed
10
+ * theme files (+ `tokens.css`): the committed file must be exactly what the
11
+ * generator emits once formatted by the repo's Prettier config. A hand edit
12
+ * of a generated file, or a generator change without regeneration, turns this
13
+ * RED — the two can no longer drift apart silently (spectral-ui#206).
14
+ *
15
+ * Importing the generator must not write anything: `main()` only runs when
16
+ * the script is executed directly.
17
+ */
18
+
19
+ const here = dirname(fileURLToPath(import.meta.url))
20
+ const themesDir = resolve(here, '..')
21
+
22
+ // Vitest resolves `prettier` with the `browser` condition (standalone build,
23
+ // no `resolveConfig`); the Node build is what the repo's `prettier --write`
24
+ // runs, so load exactly that one.
25
+ const require = createRequire(import.meta.url)
26
+ const prettier = require('prettier') as typeof import('prettier')
27
+
28
+ async function formatted(css: string, filepath: string): Promise<string> {
29
+ const config = (await prettier.resolveConfig(filepath)) ?? {}
30
+ return prettier.format(css, { ...config, filepath })
31
+ }
32
+
33
+ describe('generated theme CSS matches the generator (#206)', () => {
34
+ it('exposes the six themes', () => {
35
+ expect(themes.map((t) => t.name).sort()).toEqual(
36
+ ['carbon', 'day', 'docs', 'iris-ocr', 'midnight', 'spectral'].sort(),
37
+ )
38
+ })
39
+
40
+ for (const theme of themes) {
41
+ it(`${theme.name}.css is prettier(generateThemeCSS)`, async () => {
42
+ const filepath = resolve(themesDir, `${theme.name}.css`)
43
+ const committed = readFileSync(filepath, 'utf8')
44
+ expect(committed).toBe(await formatted(generateThemeCSS(theme), filepath))
45
+ })
46
+ }
47
+
48
+ it('tokens.css is prettier(generateTokensCSS)', async () => {
49
+ const filepath = resolve(themesDir, '..', 'tokens.css')
50
+ const committed = readFileSync(filepath, 'utf8')
51
+ expect(committed).toBe(await formatted(generateTokensCSS(), filepath))
52
+ })
53
+ })
@@ -0,0 +1,52 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { dirname, resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ /**
7
+ * spectral-ui#206 — `--sp-sys-color-scrim` is an OPAQUE colour in every theme
8
+ * and every mode. The veil's intensity belongs to the component's opacity
9
+ * token (`--sp-comp-*-overlay-opacity`, default 0.32) — never to the token,
10
+ * and never to both at once.
11
+ *
12
+ * This is a SOURCE pin: every `--sp-sys-color-scrim` declaration in the six
13
+ * generated theme files must be the opaque reference tone, and the generator
14
+ * must carry no hand-tuned scrim override that could re-introduce an alpha.
15
+ * The cascade itself (which declaration wins in `.dark`) is proven in a real
16
+ * Chromium by `tests/e2e/scrim-alpha.spec.ts`; jsdom cannot be that oracle.
17
+ */
18
+
19
+ const here = dirname(fileURLToPath(import.meta.url))
20
+ const THEMES = ['spectral', 'day', 'midnight', 'carbon', 'iris-ocr', 'docs'] as const
21
+ const OPAQUE = 'var(--sp-ref-palette-neutral-0)'
22
+
23
+ describe('--sp-sys-color-scrim is opaque in every theme and mode (#206)', () => {
24
+ for (const theme of THEMES) {
25
+ it(`${theme}.css declares the scrim as the opaque reference tone only`, () => {
26
+ const css = readFileSync(resolve(here, `../${theme}.css`), 'utf8')
27
+ const declarations = [...css.matchAll(/--sp-sys-color-scrim:([^;]+);/g)].map((m) =>
28
+ m[1].trim(),
29
+ )
30
+ // One in the generated light block, one in the generated dark block —
31
+ // nothing else, so no hand-tuned block can shadow them with an alpha.
32
+ // This single equality subsumes "never translucent": ANY other value, in
33
+ // any spelling (`rgba()`, `color-mix()`, `#RRGGBBAA`, `oklch(… / a)`),
34
+ // and any extra or missing declaration, fails here.
35
+ expect(declarations).toEqual([OPAQUE, OPAQUE])
36
+ })
37
+ }
38
+
39
+ it('the generator carries no hand-tuned color-scrim override', () => {
40
+ const generator = readFileSync(
41
+ resolve(here, '../../../../../scripts/generate-themes.ts'),
42
+ 'utf8',
43
+ )
44
+ // A hand-tuned override is a `'color-scrim': '<value>'` entry (the
45
+ // `SystemColorTokens` interface field `'color-scrim': string` is not one).
46
+ const overrides = generator.match(/['"]color-scrim['"]\s*:\s*['"]/g) ?? []
47
+ expect(overrides).toEqual([])
48
+ // The two generated declarations (light + dark) still exist and are opaque.
49
+ const emitted = [...generator.matchAll(/--sp-sys-color-scrim:\s*\$\{ref\('neutral', 0\)\};/g)]
50
+ expect(emitted).toHaveLength(2)
51
+ })
52
+ })
@@ -3,7 +3,7 @@
3
3
  * Grey charcoal theme (#5c7cfa) for Refero/AutomaticBook.
4
4
  *
5
5
  * Generated by scripts/generate-themes.ts
6
- * Do not edit manually — regenerate with: npx tsx scripts/generate-themes.ts
6
+ * Do not edit manually — regenerate with: pnpm generate:themes
7
7
  */
8
8
 
9
9
  /* ═══ REFERENCE TOKENS ═══ */
@@ -265,7 +265,6 @@
265
265
  --sp-sys-color-success: #34c759;
266
266
  --sp-sys-color-warning: #ff9f0a;
267
267
  --sp-sys-color-error: #ff3b30;
268
- --sp-sys-color-scrim: rgba(0, 0, 0, 0.3);
269
268
  --sp-sys-color-inverse-surface: #2c2c2e;
270
269
  --sp-sys-color-inverse-on-surface: #f5f5f7;
271
270
  }
@@ -3,7 +3,7 @@
3
3
  * Light macOS-like theme (#f5f5f7), transversal for all apps.
4
4
  *
5
5
  * Generated by scripts/generate-themes.ts
6
- * Do not edit manually — regenerate with: npx tsx scripts/generate-themes.ts
6
+ * Do not edit manually — regenerate with: pnpm generate:themes
7
7
  */
8
8
 
9
9
  /* ═══ REFERENCE TOKENS ═══ */
@@ -265,7 +265,6 @@
265
265
  --sp-sys-color-success: #34c759;
266
266
  --sp-sys-color-warning: #ff9f0a;
267
267
  --sp-sys-color-error: #ff3b30;
268
- --sp-sys-color-scrim: rgba(0, 0, 0, 0.3);
269
268
  --sp-sys-color-inverse-surface: #2c2c2e;
270
269
  --sp-sys-color-inverse-on-surface: #f5f5f7;
271
270
  }
@@ -3,7 +3,7 @@
3
3
  * Grey + warm orange theme (#ce9178) for SpectralDocs/Status.
4
4
  *
5
5
  * Generated by scripts/generate-themes.ts
6
- * Do not edit manually — regenerate with: npx tsx scripts/generate-themes.ts
6
+ * Do not edit manually — regenerate with: pnpm generate:themes
7
7
  */
8
8
 
9
9
  /* ═══ REFERENCE TOKENS ═══ */
@@ -3,7 +3,7 @@
3
3
  * Hybrid theme for Iris-OCR: day light mode + neon dark mode.
4
4
  *
5
5
  * Generated by scripts/generate-themes.ts
6
- * Do not edit manually — regenerate with: npx tsx scripts/generate-themes.ts
6
+ * Do not edit manually — regenerate with: pnpm generate:themes
7
7
  */
8
8
 
9
9
  /* ═══ REFERENCE TOKENS ═══ */
@@ -265,7 +265,6 @@
265
265
  --sp-sys-color-success: #34c759;
266
266
  --sp-sys-color-warning: #ff9f0a;
267
267
  --sp-sys-color-error: #ff3b30;
268
- --sp-sys-color-scrim: rgba(0, 0, 0, 0.3);
269
268
  --sp-sys-color-inverse-surface: #2c2c2e;
270
269
  --sp-sys-color-inverse-on-surface: #f5f5f7;
271
270
 
@@ -3,7 +3,7 @@
3
3
  * Blue-black Catppuccin-inspired theme (#89b4fa) for Translator/Preparo.
4
4
  *
5
5
  * Generated by scripts/generate-themes.ts
6
- * Do not edit manually — regenerate with: npx tsx scripts/generate-themes.ts
6
+ * Do not edit manually — regenerate with: pnpm generate:themes
7
7
  */
8
8
 
9
9
  /* ═══ REFERENCE TOKENS ═══ */
@@ -265,7 +265,6 @@
265
265
  --sp-sys-color-success: #34c759;
266
266
  --sp-sys-color-warning: #ff9f0a;
267
267
  --sp-sys-color-error: #ff3b30;
268
- --sp-sys-color-scrim: rgba(0, 0, 0, 0.3);
269
268
  --sp-sys-color-inverse-surface: #2c2c2e;
270
269
  --sp-sys-color-inverse-on-surface: #f5f5f7;
271
270
  }
@@ -3,7 +3,7 @@
3
3
  * Spectral hybrid theme: day light mode + iris-ocr dark mode.
4
4
  *
5
5
  * Generated by scripts/generate-themes.ts
6
- * Do not edit manually — regenerate with: npx tsx scripts/generate-themes.ts
6
+ * Do not edit manually — regenerate with: pnpm generate:themes
7
7
  */
8
8
 
9
9
  /* ═══ REFERENCE TOKENS ═══ */
@@ -265,7 +265,6 @@
265
265
  --sp-sys-color-success: #34c759;
266
266
  --sp-sys-color-warning: #ff9f0a;
267
267
  --sp-sys-color-error: #ff3b30;
268
- --sp-sys-color-scrim: rgba(0, 0, 0, 0.3);
269
268
  --sp-sys-color-inverse-surface: #2c2c2e;
270
269
  --sp-sys-color-inverse-on-surface: #f5f5f7;
271
270
 
@@ -307,7 +306,6 @@
307
306
  --sp-sys-color-success: #69f0ae;
308
307
  --sp-sys-color-warning: #ffab40;
309
308
  --sp-sys-color-error: #ff5252;
310
- --sp-sys-color-scrim: rgba(0, 0, 0, 0.6);
311
309
 
312
310
  /* ═══ EXTENSION TOKENS (--sp-ext-*) — Internal, theme-specific ═══ */
313
311
  --sp-ext-glow-color: #00ffe0;
package/src/tokens.css CHANGED
@@ -4,7 +4,7 @@
4
4
  * Shared across all themes — included in base.css.
5
5
  *
6
6
  * Generated by scripts/generate-themes.ts
7
- * Do not edit manually — regenerate with: npx tsx scripts/generate-themes.ts
7
+ * Do not edit manually — regenerate with: pnpm generate:themes
8
8
  */
9
9
 
10
10
  :root {