@happyvertical/smrt-ui 0.40.68 → 0.40.70

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.
Files changed (41) hide show
  1. package/dist/components/data/DataTable.svelte +1 -1
  2. package/dist/components/feedback/LoadingOverlay.svelte +1 -1
  3. package/dist/components/forms/Combobox.svelte +1 -1
  4. package/dist/components/forms/FilePicker.svelte +1 -1
  5. package/dist/components/forms/Input.svelte +1 -1
  6. package/dist/components/forms/InputGroup.svelte +1 -1
  7. package/dist/components/forms/Listbox.svelte +1 -1
  8. package/dist/components/forms/MultiSelect.svelte +1 -1
  9. package/dist/components/forms/RangeSlider.svelte +1 -1
  10. package/dist/components/forms/Select.svelte +1 -1
  11. package/dist/components/forms/Slider.svelte +1 -1
  12. package/dist/components/forms/TagsInput.svelte +1 -1
  13. package/dist/components/forms/Textarea.svelte +1 -1
  14. package/dist/components/roles/RoleSelector.svelte +10 -3
  15. package/dist/components/ui/Dropdown.svelte +11 -3
  16. package/dist/components/ui/Popover.svelte +1 -1
  17. package/dist/themes/ThemeProvider.svelte +40 -42
  18. package/dist/themes/ThemeProvider.svelte.d.ts +10 -0
  19. package/dist/themes/ThemeProvider.svelte.d.ts.map +1 -1
  20. package/dist/themes/__tests__/control-boundary-contrast.test.js +112 -0
  21. package/dist/themes/__tests__/css-generator.test.js +42 -0
  22. package/dist/themes/__tests__/styles-lockstep.test.js +32 -0
  23. package/dist/themes/__tests__/theme-provider.test.js +46 -8
  24. package/dist/themes/__tests__/theme-script.test.js +16 -11
  25. package/dist/themes/css-generator.d.ts +5 -0
  26. package/dist/themes/css-generator.d.ts.map +1 -1
  27. package/dist/themes/css-generator.js +5 -0
  28. package/dist/themes/glass/index.d.ts.map +1 -1
  29. package/dist/themes/glass/index.js +12 -4
  30. package/dist/themes/smrt/index.d.ts.map +1 -1
  31. package/dist/themes/smrt/index.js +8 -2
  32. package/dist/themes/studio/index.d.ts.map +1 -1
  33. package/dist/themes/studio/index.js +6 -2
  34. package/dist/themes/styles/glass.css +316 -84
  35. package/dist/themes/styles/material.css +355 -53
  36. package/dist/themes/styles/smrt.css +4 -4
  37. package/dist/themes/styles/studio.css +308 -72
  38. package/dist/themes/theme-script.d.ts +12 -4
  39. package/dist/themes/theme-script.d.ts.map +1 -1
  40. package/dist/themes/theme-script.js +14 -19
  41. package/package.json +3 -2
@@ -529,7 +529,7 @@ const sizeClasses = {
529
529
  }
530
530
 
531
531
  .data-table__cell--expand { width: 2.75rem; text-align: center; }
532
- .data-table__expand-button { width: 1.75rem; height: 1.75rem; border: 1px solid var(--smrt-color-outline-variant); border-radius: var(--smrt-radius-full); background: transparent; color: var(--smrt-color-on-surface); cursor: pointer; }
532
+ .data-table__expand-button { width: 1.75rem; height: 1.75rem; border: 1px solid var(--smrt-color-outline); border-radius: var(--smrt-radius-full); background: transparent; color: var(--smrt-color-on-surface); cursor: pointer; }
533
533
  .data-table__expand-button:focus-visible { outline: 2px solid var(--smrt-color-primary); outline-offset: 2px; }
534
534
  .data-table__cell--expanded { padding: var(--smrt-spacing-4); background: var(--smrt-color-surface-container-low); }
535
535
  .data-table__footer { border-top: 2px solid var(--smrt-color-outline-variant); font-weight: var(--smrt-typography-weight-medium); }
@@ -259,7 +259,7 @@ function handleKeydown(e: KeyboardEvent) {
259
259
  font-size: var(--smrt-typography-label-large-size, 0.875rem);
260
260
  color: var(--smrt-color-on-surface-variant, #6b7280);
261
261
  background: transparent;
262
- border: 1px solid var(--smrt-color-outline-variant, #d1d5db);
262
+ border: 1px solid var(--smrt-color-outline, #9ca3af);
263
263
  border-radius: var(--smrt-radius-md, 8px);
264
264
  cursor: pointer;
265
265
  transition: all var(--smrt-duration-short2, 150ms) var(--smrt-easing-standard, ease);
@@ -165,7 +165,7 @@ useControlRegistration(() => {
165
165
  </div>
166
166
  <style>
167
167
  .combobox { position: relative; display: grid; gap: var(--smrt-spacing-1); color: var(--smrt-color-on-surface); }
168
- label { font: var(--smrt-typography-label-large-font); } input { width: 100%; padding: var(--smrt-spacing-2) var(--smrt-spacing-3); border: 1px solid var(--smrt-color-outline-variant); border-radius: var(--smrt-radius-small); background: var(--smrt-color-surface); color: inherit; }
168
+ label { font: var(--smrt-typography-label-large-font); } input { width: 100%; padding: var(--smrt-spacing-2) var(--smrt-spacing-3); border: 1px solid var(--smrt-color-outline); border-radius: var(--smrt-radius-small); background: var(--smrt-color-surface); color: inherit; }
169
169
  input:focus { outline: 2px solid var(--smrt-color-primary); outline-offset: 1px; } .options { position: absolute; z-index: var(--smrt-z-index-dropdown); top: 100%; left: 0; right: 0; display: grid; padding: var(--smrt-spacing-1); border: 1px solid var(--smrt-color-outline-variant); border-radius: var(--smrt-radius-small); background: var(--smrt-color-surface-container); box-shadow: var(--smrt-elevation-2); }
170
170
  .options button { padding: var(--smrt-spacing-2) var(--smrt-spacing-3); border: 0; border-radius: var(--smrt-radius-extra-small); background: transparent; color: var(--smrt-color-on-surface); text-align: left; } .options button.active { background: var(--smrt-color-secondary-container); }
171
171
  :global(.combobox[data-smrt-highlighted='true']) { outline: 3px solid var(--smrt-color-tertiary); outline-offset: 4px; }
@@ -106,7 +106,7 @@ useControlRegistration(() => {
106
106
  <span class="label">{label}</span><span class="description">{files.length ? `${files.length} file${files.length === 1 ? '' : 's'} selected` : description}</span>
107
107
  </label>
108
108
  <style>
109
- .file-picker { display: inline-flex; flex-direction: column; gap: var(--smrt-spacing-1); padding: var(--smrt-spacing-3) var(--smrt-spacing-4); border: 1px solid var(--smrt-color-outline-variant); border-radius: var(--smrt-radius-small); background: var(--smrt-color-surface); color: var(--smrt-color-on-surface); cursor: pointer; }
109
+ .file-picker { display: inline-flex; flex-direction: column; gap: var(--smrt-spacing-1); padding: var(--smrt-spacing-3) var(--smrt-spacing-4); border: 1px solid var(--smrt-color-outline); border-radius: var(--smrt-radius-small); background: var(--smrt-color-surface); color: var(--smrt-color-on-surface); cursor: pointer; }
110
110
  .file-picker.dropzone { width: 100%; min-height: 8rem; align-items: center; justify-content: center; border-style: dashed; text-align: center; }
111
111
  .file-picker.dragging { border-color: var(--smrt-color-primary); background: var(--smrt-color-primary-container); }
112
112
  .file-picker.disabled { opacity: .5; cursor: not-allowed; } input { position: absolute; width: 1px; height: 1px; opacity: 0; }
@@ -179,7 +179,7 @@ export function getElement(): HTMLInputElement | null {
179
179
  line-height: var(--smrt-typography-body-medium-line-height, 1.5);
180
180
  color: var(--smrt-color-on-surface, #1f2937);
181
181
  background-color: var(--smrt-color-surface, #fff);
182
- border: 1px solid var(--smrt-color-outline-variant, #d1d5db);
182
+ border: 1px solid var(--smrt-color-outline, #6b7280);
183
183
  border-radius: var(--smrt-radius-small, 0.375rem);
184
184
  transition:
185
185
  border-color var(--smrt-duration-short2, 150ms) var(--smrt-easing-standard, ease-in-out),
@@ -10,7 +10,7 @@ let { prefix, suffix, children, class: className = '' }: Props = $props();
10
10
  </script>
11
11
  <div class="input-group {className}">{#if prefix}<span class="adornment">{@render prefix()}</span>{/if}<div class="control">{@render children()}</div>{#if suffix}<span class="adornment">{@render suffix()}</span>{/if}</div>
12
12
  <style>
13
- .input-group { display: flex; align-items: stretch; width: 100%; border: 1px solid var(--smrt-color-outline-variant); border-radius: var(--smrt-radius-small); background: var(--smrt-color-surface); overflow: hidden; }
13
+ .input-group { display: flex; align-items: stretch; width: 100%; border: 1px solid var(--smrt-color-outline); border-radius: var(--smrt-radius-small); background: var(--smrt-color-surface); overflow: hidden; }
14
14
  .adornment { display: inline-flex; align-items: center; padding: 0 var(--smrt-spacing-3); background: var(--smrt-color-surface-container-low); color: var(--smrt-color-on-surface-variant); }
15
15
  .control { flex: 1; min-width: 0; } .control :global(input), .control :global(select), .control :global(textarea) { border: 0 !important; border-radius: 0 !important; box-shadow: none !important; }
16
16
  .input-group:focus-within { outline: 2px solid var(--smrt-color-primary); outline-offset: 1px; }
@@ -90,7 +90,7 @@ useControlRegistration(() => {
90
90
  onkeydown={(event) => move(event, index)} onclick={() => select(option.value)}>{option.label}</button>{/each}
91
91
  </div>
92
92
  <style>
93
- .listbox { display: grid; padding: var(--smrt-spacing-1); border: 1px solid var(--smrt-color-outline-variant); border-radius: var(--smrt-radius-small); background: var(--smrt-color-surface); }
93
+ .listbox { display: grid; padding: var(--smrt-spacing-1); border: 1px solid var(--smrt-color-outline); border-radius: var(--smrt-radius-small); background: var(--smrt-color-surface); }
94
94
  button { padding: var(--smrt-spacing-2) var(--smrt-spacing-3); border: 0; border-radius: var(--smrt-radius-extra-small); background: transparent; color: var(--smrt-color-on-surface); text-align: left; cursor: pointer; }
95
95
  button[aria-selected='true'] { background: var(--smrt-color-secondary-container); color: var(--smrt-color-on-secondary-container); } button:focus-visible { outline: 2px solid var(--smrt-color-primary); }
96
96
  button:disabled { opacity: .5; cursor: not-allowed; } :global(.listbox[data-smrt-highlighted='true']) { outline: 3px solid var(--smrt-color-tertiary); outline-offset: 4px; }
@@ -152,7 +152,7 @@ useControlRegistration(() => {
152
152
  </div>
153
153
  <style>
154
154
  .multi-select { position: relative; display: grid; gap: var(--smrt-spacing-1); color: var(--smrt-color-on-surface); } .label { font: var(--smrt-typography-label-large-font); }
155
- .trigger { width: 100%; min-height: 2.5rem; padding: var(--smrt-spacing-2) var(--smrt-spacing-3); border: 1px solid var(--smrt-color-outline-variant); border-radius: var(--smrt-radius-small); background: var(--smrt-color-surface); color: inherit; text-align: left; }
155
+ .trigger { width: 100%; min-height: 2.5rem; padding: var(--smrt-spacing-2) var(--smrt-spacing-3); border: 1px solid var(--smrt-color-outline); border-radius: var(--smrt-radius-small); background: var(--smrt-color-surface); color: inherit; text-align: left; }
156
156
  .options { position: absolute; z-index: var(--smrt-z-index-dropdown); top: 100%; left: 0; right: 0; display: grid; padding: var(--smrt-spacing-1); border: 1px solid var(--smrt-color-outline-variant); background: var(--smrt-color-surface-container); box-shadow: var(--smrt-elevation-2); }
157
157
  .options button { display: grid; grid-template-columns: 1.25rem 1fr; gap: var(--smrt-spacing-2); padding: var(--smrt-spacing-2); border: 0; background: transparent; color: inherit; text-align: left; } .options button[aria-selected='true'] { background: var(--smrt-color-secondary-container); }
158
158
  :global(.multi-select[data-smrt-highlighted='true']) { outline: 3px solid var(--smrt-color-tertiary); outline-offset: 4px; }
@@ -131,7 +131,7 @@ useControlRegistration(() => {
131
131
  .range-slider__track input::-moz-range-thumb { pointer-events: auto; }
132
132
  .range-slider__inputs { display: flex; gap: var(--smrt-spacing-3); }
133
133
  .range-slider__inputs label { display: grid; gap: var(--smrt-spacing-1); flex: 1; font: var(--smrt-typography-label-small-font); color: var(--smrt-color-on-surface-variant); }
134
- .range-slider__inputs input { width: 100%; padding: var(--smrt-spacing-2); border: 1px solid var(--smrt-color-outline-variant); border-radius: var(--smrt-radius-small); background: var(--smrt-color-surface); color: var(--smrt-color-on-surface); }
134
+ .range-slider__inputs input { width: 100%; padding: var(--smrt-spacing-2); border: 1px solid var(--smrt-color-outline); border-radius: var(--smrt-radius-small); background: var(--smrt-color-surface); color: var(--smrt-color-on-surface); }
135
135
  input:focus-visible { outline: 2px solid var(--smrt-color-primary); outline-offset: 3px; }
136
136
  :global(.range-slider[data-smrt-highlighted='true']) { outline: 3px solid var(--smrt-color-tertiary); outline-offset: 4px; border-radius: var(--smrt-radius-small); }
137
137
  </style>
@@ -136,7 +136,7 @@ export function getElement(): HTMLSelectElement | null {
136
136
  background-position: right 0.5rem center;
137
137
  background-repeat: no-repeat;
138
138
  background-size: 1.5em 1.5em;
139
- border: 1px solid var(--smrt-color-outline-variant, #d1d5db);
139
+ border: 1px solid var(--smrt-color-outline, #6b7280);
140
140
  border-radius: 0.375rem;
141
141
  appearance: none;
142
142
  cursor: pointer;
@@ -125,7 +125,7 @@ useControlRegistration(() => {
125
125
  .slider__header { justify-content: space-between; font: var(--smrt-typography-label-large-font); }
126
126
  output { color: var(--smrt-color-primary); font-variant-numeric: tabular-nums; }
127
127
  input[type='range'] { flex: 1; min-width: 8rem; accent-color: var(--smrt-color-primary); cursor: pointer; }
128
- .slider__number { width: 5.5rem; padding: var(--smrt-spacing-2); border: 1px solid var(--smrt-color-outline-variant); border-radius: var(--smrt-radius-small); background: var(--smrt-color-surface); color: var(--smrt-color-on-surface); }
128
+ .slider__number { width: 5.5rem; padding: var(--smrt-spacing-2); border: 1px solid var(--smrt-color-outline); border-radius: var(--smrt-radius-small); background: var(--smrt-color-surface); color: var(--smrt-color-on-surface); }
129
129
  input:focus-visible { outline: 2px solid var(--smrt-color-primary); outline-offset: 3px; }
130
130
  .slider[data-smrt-highlighted='true'] { outline: 3px solid var(--smrt-color-tertiary); outline-offset: 4px; border-radius: var(--smrt-radius-small); }
131
131
  </style>
@@ -99,7 +99,7 @@ useControlRegistration(() => {
99
99
  </div></div>
100
100
  <style>
101
101
  .tags-field { display: grid; gap: var(--smrt-spacing-1); color: var(--smrt-color-on-surface); } label { font: var(--smrt-typography-label-large-font); }
102
- .tags { display: flex; flex-wrap: wrap; gap: var(--smrt-spacing-1); min-height: 2.5rem; padding: var(--smrt-spacing-1); border: 1px solid var(--smrt-color-outline-variant); border-radius: var(--smrt-radius-small); background: var(--smrt-color-surface); }
102
+ .tags { display: flex; flex-wrap: wrap; gap: var(--smrt-spacing-1); min-height: 2.5rem; padding: var(--smrt-spacing-1); border: 1px solid var(--smrt-color-outline); border-radius: var(--smrt-radius-small); background: var(--smrt-color-surface); }
103
103
  .tag { display: inline-flex; align-items: center; gap: var(--smrt-spacing-1); padding: var(--smrt-spacing-1) var(--smrt-spacing-2); border-radius: var(--smrt-radius-full); background: var(--smrt-color-secondary-container); color: var(--smrt-color-on-secondary-container); }
104
104
  .tag button { border: 0; background: transparent; color: inherit; cursor: pointer; } .tags > input { flex: 1; min-width: 8rem; border: 0; outline: 0; background: transparent; color: inherit; }
105
105
  :global(.tags[data-smrt-highlighted='true']) { outline: 3px solid var(--smrt-color-tertiary); outline-offset: 4px; }
@@ -134,7 +134,7 @@ export function getElement(): HTMLTextAreaElement | null {
134
134
  line-height: var(--smrt-typography-body-medium-line-height, 1.5);
135
135
  color: var(--smrt-color-on-surface, #1f2937);
136
136
  background-color: var(--smrt-color-surface, #fff);
137
- border: 1px solid var(--smrt-color-outline-variant, #d1d5db);
137
+ border: 1px solid var(--smrt-color-outline, #6b7280);
138
138
  border-radius: 0.375rem;
139
139
  resize: vertical;
140
140
  min-height: 80px;
@@ -199,7 +199,7 @@ $effect(() => {
199
199
  width: 100%;
200
200
  padding: 0.5rem 0.75rem;
201
201
  background: var(--smrt-color-surface, white);
202
- border: 1px solid var(--smrt-color-outline-variant, #d1d5db);
202
+ border: 1px solid var(--smrt-color-outline, #6b7280);
203
203
  border-radius: var(--smrt-radius-small, 0.375rem);
204
204
  font-size: var(--smrt-typography-body-medium-size, 0.875rem);
205
205
  cursor: pointer;
@@ -270,10 +270,17 @@ $effect(() => {
270
270
  text-align: left;
271
271
  }
272
272
 
273
- .option:hover,
273
+ .option:hover {
274
+ background: var(--smrt-color-surface-container-high, #f3f4f6);
275
+ }
276
+ /* Keyboard focus needs its own indicator: the hover background alone is a
277
+ ~1.1:1 change against the list surface in every preset, which is invisible
278
+ and fails WCAG 2.4.11. Inset so it is not clipped by the list's overflow
279
+ (#2322). */
274
280
  .option:focus-visible {
275
281
  background: var(--smrt-color-surface-container-high, #f3f4f6);
276
- outline: none;
282
+ outline: 2px solid var(--smrt-color-primary, #005ac1);
283
+ outline-offset: -2px;
277
284
  }
278
285
 
279
286
  .option.selected {
@@ -179,7 +179,7 @@ $effect(() => {
179
179
  cursor: pointer;
180
180
  padding: var(--smrt-spacing-2, 8px) var(--smrt-spacing-3, 12px);
181
181
  border-radius: var(--smrt-radius-medium, 8px);
182
- border: 1px solid var(--smrt-color-outline-variant, #cac4d0);
182
+ border: 1px solid var(--smrt-color-outline, #79747e);
183
183
  background: var(--smrt-color-surface);
184
184
  color: var(--smrt-color-on-surface);
185
185
  }
@@ -236,10 +236,18 @@ $effect(() => {
236
236
  color: var(--smrt-color-on-surface);
237
237
  white-space: nowrap;
238
238
  }
239
- .dropdown__item:hover:not(:disabled),
239
+ .dropdown__item:hover:not(:disabled) {
240
+ background: var(--smrt-color-surface-container-high);
241
+ }
242
+ /* Keyboard focus needs its own indicator: the hover background alone is a
243
+ ~1.1:1 change against the menu surface in every preset (1.00:1 in glass
244
+ light), which is invisible and fails WCAG 2.4.11. The ring is inset so it
245
+ sits inside the item's padding box rather than overlapping the neighbouring
246
+ item or the menu's edge (#2322). */
240
247
  .dropdown__item:focus-visible {
241
248
  background: var(--smrt-color-surface-container-high);
242
- outline: none;
249
+ outline: 2px solid var(--smrt-color-primary);
250
+ outline-offset: -2px;
243
251
  }
244
252
  .dropdown__item:disabled {
245
253
  opacity: 0.5;
@@ -91,7 +91,7 @@ $effect(() => {
91
91
 
92
92
  <style>
93
93
  .popover { position: relative; display: inline-flex; }
94
- .popover__trigger { appearance: none; padding: var(--smrt-spacing-2) var(--smrt-spacing-3); border: 1px solid var(--smrt-color-outline-variant); border-radius: var(--smrt-radius-medium); background: var(--smrt-color-surface); color: var(--smrt-color-on-surface); font: inherit; cursor: pointer; }
94
+ .popover__trigger { appearance: none; padding: var(--smrt-spacing-2) var(--smrt-spacing-3); border: 1px solid var(--smrt-color-outline); border-radius: var(--smrt-radius-medium); background: var(--smrt-color-surface); color: var(--smrt-color-on-surface); font: inherit; cursor: pointer; }
95
95
  .popover__trigger:focus-visible, .popover__panel:focus-visible { outline: 2px solid var(--smrt-color-primary); outline-offset: 2px; }
96
96
  .popover__trigger:disabled { opacity: .5; cursor: not-allowed; }
97
97
  .popover__panel { position: absolute; z-index: var(--smrt-z-index-popover, 1000); min-width: 16rem; padding: var(--smrt-spacing-4); border: 1px solid var(--smrt-color-outline-variant); border-radius: var(--smrt-radius-large); background: var(--smrt-color-surface-container); color: var(--smrt-color-on-surface); box-shadow: var(--smrt-elevation-3); }
@@ -3,7 +3,7 @@ import type { Snippet } from 'svelte';
3
3
  import { onMount, untrack } from 'svelte';
4
4
  import { setThemeContext, type ThemeContext } from './context.svelte.js';
5
5
  import { generateThemeVariables } from './css-generator.js';
6
- import { getTheme, isValidPreset } from './registry.js';
6
+ import { availablePresets, getTheme, isValidPreset } from './registry.js';
7
7
  import type {
8
8
  ColorScheme,
9
9
  Theme,
@@ -34,6 +34,16 @@ interface Props {
34
34
  persist?: boolean;
35
35
  /** Storage key for persistence */
36
36
  storageKey?: string;
37
+ /**
38
+ * Force runtime generation of the full CSS variable set onto the wrapper's
39
+ * inline style (the legacy delivery path). Defaults to `true` for custom
40
+ * registered themes — they have no static stylesheet — and `false` for
41
+ * built-in presets, whose variables ship as static CSS
42
+ * (`themes/styles/<preset>.css`) selected by the `data-theme` /
43
+ * `data-color-scheme` attributes. Leave unset unless you cannot import the
44
+ * static stylesheet for a built-in preset.
45
+ */
46
+ inlineVariables?: boolean;
37
47
  /** Content */
38
48
  children: Snippet;
39
49
  }
@@ -47,6 +57,7 @@ let {
47
57
  paintSurface = true,
48
58
  persist = defaultThemeConfig.persist,
49
59
  storageKey = defaultThemeConfig.storageKey,
60
+ inlineVariables,
50
61
  children,
51
62
  }: Props = $props();
52
63
 
@@ -85,39 +96,41 @@ const resolvedScheme = $derived<'light' | 'dark'>(
85
96
 
86
97
  const isDark = $derived(resolvedScheme === 'dark');
87
98
 
88
- // Generate CSS variables - using $derived with immediate value
89
- const cssVariables = $derived.by(() => {
90
- const vars = generateThemeVariables(currentTheme, isDark);
99
+ // Static-first variable delivery: built-in presets ship their full variable
100
+ // set as static stylesheets (`themes/styles/<preset>.css`) selected by the
101
+ // data-theme/data-color-scheme attributes, so the wrapper carries no ~200-var
102
+ // inline style. Custom registered themes have no static file, so they keep the
103
+ // legacy runtime generation (opt built-ins back in with `inlineVariables`).
104
+ const isBuiltInPreset = $derived(
105
+ (availablePresets as string[]).includes(config.preset),
106
+ );
107
+ const useInlineVariables = $derived(inlineVariables ?? !isBuiltInPreset);
108
+
109
+ // Convert to style string — full variable set only on the runtime path;
110
+ // otherwise just the explicit accent/overrides so they layer over static CSS.
111
+ const styleString = $derived.by(() => {
112
+ const declarations: string[] = [];
113
+
114
+ if (useInlineVariables) {
115
+ for (const [key, value] of Object.entries(
116
+ generateThemeVariables(currentTheme, isDark),
117
+ )) {
118
+ declarations.push(`${key}: ${value}`);
119
+ }
120
+ }
91
121
 
92
122
  // Apply primary color override
93
123
  if (config.primaryColor) {
94
- vars['--smrt-color-primary'] = config.primaryColor;
124
+ declarations.push(`--smrt-color-primary: ${config.primaryColor}`);
95
125
  // Recalculate dependent colors would go here in a full implementation
96
126
  }
97
127
 
98
- // Apply border radius override
99
- if (config.borderRadius && config.borderRadius !== 'md') {
100
- // The border radius values are already in CSS vars, but we could
101
- // add logic here to override specific radius values
102
- }
103
-
104
128
  // Apply custom overrides
105
- return { ...vars, ...config.overrides };
106
- });
129
+ for (const [key, value] of Object.entries(config.overrides ?? {})) {
130
+ declarations.push(`${key}: ${value}`);
131
+ }
107
132
 
108
- // Convert to style string
109
- const styleString = $derived.by(() => {
110
- return Object.entries(cssVariables)
111
- .map(([key, value]) => {
112
- const isExplicitOverride =
113
- Object.hasOwn(config.overrides ?? {}, key) ||
114
- (key === '--smrt-color-primary' && config.primaryColor);
115
- const bootstrapKey = key.replace(/^--smrt-/, '--smrt-bootstrap-');
116
- return `${key}: ${
117
- isExplicitOverride ? value : `var(${bootstrapKey}, ${value})`
118
- }`;
119
- })
120
- .join('; ');
133
+ return declarations.join('; ');
121
134
  });
122
135
 
123
136
  // Theme state for context
@@ -258,21 +271,6 @@ $effect(() => {
258
271
  }
259
272
  });
260
273
 
261
- // The pre-paint aliases only bridge SSR to the hydrated provider. Remove them
262
- // after the provider has applied the persisted config so later runtime theme
263
- // changes use the component's reactive variables normally.
264
- $effect(() => {
265
- if (typeof document !== 'undefined' && mounted) {
266
- const html = document.documentElement;
267
- for (const property of Array.from(html.style)) {
268
- if (property.startsWith('--smrt-bootstrap-')) {
269
- html.style.removeProperty(property);
270
- }
271
- }
272
- html.removeAttribute('data-smrt-theme-bootstrap');
273
- }
274
- });
275
-
276
274
  // Sync props to state — but only on actual prop changes. config is seeded
277
275
  // from the initial props above and onMount applies persisted preferences, so
278
276
  // re-asserting the initial prop values here would clobber the persisted
@@ -339,7 +337,7 @@ $effect(() => {
339
337
  class:dark={isDark}
340
338
  class:no-paint={!paintSurface}
341
339
  class:smrt-theme-glass={config.preset === 'glass'}
342
- style={styleString}
340
+ style={styleString || undefined}
343
341
  data-theme={config.preset}
344
342
  data-color-scheme={resolvedScheme}
345
343
  >
@@ -21,6 +21,16 @@ interface Props {
21
21
  persist?: boolean;
22
22
  /** Storage key for persistence */
23
23
  storageKey?: string;
24
+ /**
25
+ * Force runtime generation of the full CSS variable set onto the wrapper's
26
+ * inline style (the legacy delivery path). Defaults to `true` for custom
27
+ * registered themes — they have no static stylesheet — and `false` for
28
+ * built-in presets, whose variables ship as static CSS
29
+ * (`themes/styles/<preset>.css`) selected by the `data-theme` /
30
+ * `data-color-scheme` attributes. Leave unset unless you cannot import the
31
+ * static stylesheet for a built-in preset.
32
+ */
33
+ inlineVariables?: boolean;
24
34
  /** Content */
25
35
  children: Snippet;
26
36
  }
@@ -1 +1 @@
1
- {"version":3,"file":"ThemeProvider.svelte.d.ts","sourceRoot":"","sources":["../../src/themes/ThemeProvider.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAKtC,OAAO,KAAK,EACV,WAAW,EAEX,WAAW,EACX,WAAW,EAEZ,MAAM,YAAY,CAAC;AAIpB,UAAU,KAAK;IACb,0BAA0B;IAC1B,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,8BAA8B;IAC9B,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,oCAAoC;IACpC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,+BAA+B;IAC/B,YAAY,CAAC,EAAE,WAAW,CAAC,cAAc,CAAC,CAAC;IAC3C,oCAAoC;IACpC,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,0CAA0C;IAC1C,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,kCAAkC;IAClC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc;IACd,QAAQ,EAAE,OAAO,CAAC;CACnB;AA2TD,QAAA,MAAM,aAAa,2CAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"ThemeProvider.svelte.d.ts","sourceRoot":"","sources":["../../src/themes/ThemeProvider.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAKtC,OAAO,KAAK,EACV,WAAW,EAEX,WAAW,EACX,WAAW,EAEZ,MAAM,YAAY,CAAC;AAIpB,UAAU,KAAK;IACb,0BAA0B;IAC1B,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,8BAA8B;IAC9B,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,oCAAoC;IACpC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,+BAA+B;IAC/B,YAAY,CAAC,EAAE,WAAW,CAAC,cAAc,CAAC,CAAC;IAC3C,oCAAoC;IACpC,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,0CAA0C;IAC1C,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,kCAAkC;IAClC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;;;;OAQG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,cAAc;IACd,QAAQ,EAAE,OAAO,CAAC;CACnB;AA+SD,QAAA,MAAM,aAAa,2CAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Control-boundary contrast floor, every preset (#2322).
3
+ *
4
+ * WCAG 2.2 SC 1.4.11 requires the visual boundary that identifies a UI
5
+ * component to clear 3:1 against its adjacent background. The form primitives
6
+ * used to draw their resting border with `outline-variant` — the divider role,
7
+ * which measures 1.2–1.7:1 in every preset — so an input's edge was effectively
8
+ * invisible. They now consume `outline`, and this test pins the other half of
9
+ * that contract: `outline` must actually be a boundary-strength value in every
10
+ * preset and scheme, on every surface a control can sit on.
11
+ *
12
+ * `outlineVariant` is deliberately NOT held to this floor. It is the quiet
13
+ * hairline for dividers, panel edges, and readout rows; raising it would erase
14
+ * the distinction the two tokens exist to draw.
15
+ */
16
+ import { describe, expect, it } from 'vitest';
17
+ import { themes } from '../registry.js';
18
+ /** Every surface role a control can be placed on. */
19
+ const SURFACES = [
20
+ 'surface',
21
+ 'surfaceVariant',
22
+ 'surfaceContainer',
23
+ 'surfaceContainerLow',
24
+ 'surfaceContainerHigh',
25
+ 'surfaceContainerHighest',
26
+ 'surfaceContainerLowest',
27
+ 'surfaceDim',
28
+ 'surfaceBright',
29
+ 'background',
30
+ ];
31
+ /**
32
+ * Parse `#rrggbb` or `rgba(r, g, b, a)` into a straight-alpha colour.
33
+ *
34
+ * The glass preset expresses its surfaces and outlines as `rgba(...)`, so a
35
+ * hex-only parser silently skips exactly the preset whose translucency makes
36
+ * contrast hardest to reason about — which is how glass sat at 1.42:1 unnoticed.
37
+ */
38
+ function parseColor(value) {
39
+ const hex = value.trim().match(/^#([0-9a-f]{6})$/i);
40
+ if (hex) {
41
+ const n = Number.parseInt(hex[1], 16);
42
+ return { rgb: [(n >> 16) & 255, (n >> 8) & 255, n & 255], alpha: 1 };
43
+ }
44
+ const rgba = value
45
+ .trim()
46
+ .match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:[\s,/]+([\d.]+))?\s*\)$/i);
47
+ if (rgba) {
48
+ return {
49
+ rgb: [Number(rgba[1]), Number(rgba[2]), Number(rgba[3])],
50
+ alpha: rgba[4] === undefined ? 1 : Number(rgba[4]),
51
+ };
52
+ }
53
+ return null;
54
+ }
55
+ /** Composite `value` over an opaque backdrop, resolving any alpha. */
56
+ function flatten(value, backdrop) {
57
+ const parsed = parseColor(value);
58
+ if (!parsed)
59
+ return null;
60
+ const { rgb, alpha } = parsed;
61
+ return rgb.map((c, i) => Math.round(c * alpha + backdrop[i] * (1 - alpha)));
62
+ }
63
+ function relativeLuminance([r, g, b]) {
64
+ const [rr, gg, bb] = [r, g, b].map((n) => {
65
+ const s = n / 255;
66
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
67
+ });
68
+ return 0.2126 * rr + 0.7152 * gg + 0.0722 * bb;
69
+ }
70
+ function contrast(a, b) {
71
+ const [hi, lo] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x);
72
+ return (hi + 0.05) / (lo + 0.05);
73
+ }
74
+ describe('control boundaries clear the 3:1 non-text floor (#2322)', () => {
75
+ for (const theme of Object.values(themes)) {
76
+ for (const scheme of ['light', 'dark']) {
77
+ const palette = theme[scheme];
78
+ it(`${theme.id} ${scheme}: outline is boundary-strength on every surface`, () => {
79
+ // Translucent surfaces resolve against the scheme's own background,
80
+ // which is the only backdrop the theme can promise.
81
+ const page = flatten(palette.background, [255, 255, 255]);
82
+ expect(page, `${theme.id} ${scheme} background must be parseable`).not.toBeNull();
83
+ const failures = [];
84
+ for (const surfaceName of SURFACES) {
85
+ // Assert each parse BEFORE it is used as the next one's backdrop.
86
+ // Folding these together would let an unparseable surface reach
87
+ // `flatten` as a null backdrop, and the test would die on a
88
+ // TypeError instead of naming the token that could not be read.
89
+ const surface = flatten(palette[surfaceName], page);
90
+ expect(surface, `${theme.id} ${scheme} ${surfaceName} must be parseable`).not.toBeNull();
91
+ const outline = flatten(palette.outline, surface);
92
+ expect(outline, `${theme.id} ${scheme} outline must be parseable`).not.toBeNull();
93
+ const ratio = contrast(outline, surface);
94
+ if (ratio < 3)
95
+ failures.push(`${surfaceName} ${ratio.toFixed(2)}:1`);
96
+ }
97
+ expect(failures, `${theme.id} ${scheme} outline below 3:1`).toEqual([]);
98
+ });
99
+ }
100
+ }
101
+ it('keeps outline and outlineVariant as distinct roles', () => {
102
+ // If a preset ever "fixes" its bezel by setting it equal to the boundary,
103
+ // dividers become as loud as control edges and the two-token system
104
+ // collapses. The hairline is allowed to be quiet — it just may not BE the
105
+ // boundary token.
106
+ for (const theme of Object.values(themes)) {
107
+ for (const scheme of ['light', 'dark']) {
108
+ expect(theme[scheme].outlineVariant, `${theme.id} ${scheme} outlineVariant must stay distinct from outline`).not.toBe(theme[scheme].outline);
109
+ }
110
+ }
111
+ });
112
+ });
@@ -71,6 +71,48 @@ describe('theme CSS generator', () => {
71
71
  // Deeper black shadows above level 1.
72
72
  expect(dark['--smrt-elevation-5']).toContain('rgba(0, 0, 0, 0.25)');
73
73
  });
74
+ it('matches every static preset CSS on colour values (drift guard)', () => {
75
+ // The elevation guard below caught shadows drifting; colours drifted too
76
+ // and nothing noticed — smrt.css carried `--smrt-theme-name: SMRT` long
77
+ // after the theme was renamed `s-m-r-t` (#2322). The alias test only
78
+ // checks that token NAMES appear in each stylesheet, so a stale VALUE
79
+ // sails through it. Compare the values themselves, for every preset.
80
+ const files = {
81
+ material: 'material.css',
82
+ glass: 'glass.css',
83
+ studio: 'studio.css',
84
+ smrt: 'smrt.css',
85
+ happyvertical: 'happyvertical.css',
86
+ };
87
+ const drift = [];
88
+ for (const [id, file] of Object.entries(files)) {
89
+ const css = readFileSync(join(process.cwd(), 'src/themes/styles', file), 'utf8');
90
+ for (const scheme of ['light', 'dark']) {
91
+ const selector = `[data-theme="${id}"][data-color-scheme="${scheme}"]`;
92
+ const start = css.indexOf(selector);
93
+ expect(start, `${file} must define ${selector}`).toBeGreaterThan(-1);
94
+ const open = css.indexOf('{', start);
95
+ const body = css.slice(open + 1, css.indexOf('\n}', open));
96
+ const defined = {};
97
+ for (const match of body.matchAll(/(--smrt-[a-z0-9-]+):\s*([^;]+);/gi)) {
98
+ defined[match[1]] = match[2].trim();
99
+ }
100
+ const generated = generateThemeVariables(getTheme(id), scheme === 'dark');
101
+ for (const [token, value] of Object.entries(generated)) {
102
+ if (!token.startsWith('--smrt-color-') &&
103
+ token !== '--smrt-theme-name')
104
+ continue;
105
+ // Only compare tokens the stylesheet actually declares; absence is
106
+ // the alias test's job, not this one's.
107
+ if (defined[token] !== undefined &&
108
+ defined[token] !== String(value)) {
109
+ drift.push(`${file} ${scheme} ${token}: css=${defined[token]} generator=${value}`);
110
+ }
111
+ }
112
+ }
113
+ }
114
+ expect(drift).toEqual([]);
115
+ });
74
116
  it('matches the static studio.css for both schemes (drift guard)', () => {
75
117
  // The static-CSS delivery path and the JS generator must agree on VALUES,
76
118
  // not just token names — this is what let the studio dark shadows diverge.
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Static stylesheet lockstep (#1586, all presets).
3
+ *
4
+ * Every built-in preset ships a static stylesheet whose two
5
+ * `[data-theme][data-color-scheme]` token blocks must be the verbatim output
6
+ * of the theme generator — ThemeProvider defaults built-in presets to this
7
+ * static delivery path, so drift between the theme definitions and the
8
+ * shipped CSS would silently change rendered values. Run
9
+ * `pnpm --filter @happyvertical/smrt-ui generate:themes` to regenerate after
10
+ * editing a preset definition.
11
+ */
12
+ import { readFileSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { describe, expect, it } from 'vitest';
15
+ import { generateThemeCSS } from '../css-generator.js';
16
+ import { availablePresets, getTheme } from '../registry.js';
17
+ // Resolved from process.cwd() (NOT import.meta.url): the smrt-vitest plugin
18
+ // runs each package's suite with cwd === the package root.
19
+ describe('static stylesheets match the generator verbatim (#1586)', () => {
20
+ for (const preset of availablePresets) {
21
+ const theme = getTheme(preset);
22
+ const staticCss = readFileSync(join(process.cwd(), `src/themes/styles/${preset}.css`), 'utf8');
23
+ for (const [scheme, isDark] of [
24
+ ['light', false],
25
+ ['dark', true],
26
+ ]) {
27
+ it(`${preset} ${scheme} block matches generateThemeCSS output`, () => {
28
+ expect(staticCss).toContain(generateThemeCSS(theme, isDark));
29
+ });
30
+ }
31
+ }
32
+ });
@@ -92,18 +92,56 @@ describe('ThemeProvider persistence', () => {
92
92
  expect(document.documentElement.getAttribute('data-color-scheme')).toBe('light');
93
93
  });
94
94
  });
95
- it('emits bootstrap-aware SSR variable fallbacks', () => {
95
+ it('renders no inline variable payload for built-in presets (static-first)', () => {
96
96
  mockSystemDark(false);
97
97
  const { container } = render(ThemeProvider, {
98
98
  props: { children: child() },
99
99
  });
100
+ const root = container.querySelector('.smrt-theme-root');
101
+ const style = root?.getAttribute('style') ?? '';
102
+ // Built-in presets ship their full variable set as static stylesheets
103
+ // selected by the data attributes — the wrapper carries no ~200-var style.
104
+ expect(style).not.toContain('--smrt-color-background');
105
+ expect(root).toHaveAttribute('data-theme');
106
+ expect(root).toHaveAttribute('data-color-scheme');
107
+ });
108
+ it('applies only the explicit accent/overrides inline for built-in presets', () => {
109
+ mockSystemDark(false);
110
+ const { container } = render(ThemeProvider, {
111
+ props: {
112
+ primaryColor: '#123456',
113
+ overrides: { '--smrt-color-tertiary': '#654321' },
114
+ children: child(),
115
+ },
116
+ });
117
+ const style = container.querySelector('.smrt-theme-root')?.getAttribute('style') ?? '';
118
+ expect(style).toContain('--smrt-color-primary: #123456');
119
+ expect(style).toContain('--smrt-color-tertiary: #654321');
120
+ expect(style).not.toContain('--smrt-color-background');
121
+ });
122
+ it('keeps runtime variable generation for custom registered themes', async () => {
123
+ const { createTheme, registerTheme } = await import('../create-theme.js');
124
+ registerTheme(createTheme({
125
+ id: 'provider-test-brand',
126
+ name: 'Provider Test Brand',
127
+ light: { primary: '#336699', background: '#fefefe' },
128
+ dark: { primary: '#99ccff', background: '#101820' },
129
+ }));
130
+ mockSystemDark(false);
131
+ const { container } = render(ThemeProvider, {
132
+ props: { preset: 'provider-test-brand', children: child() },
133
+ });
134
+ const style = container.querySelector('.smrt-theme-root')?.getAttribute('style') ?? '';
135
+ // No static stylesheet can exist for a registered custom theme, so the
136
+ // provider falls back to the legacy runtime-generated variable set.
137
+ expect(style).toContain('--smrt-color-background: #fefefe');
138
+ });
139
+ it('opts a built-in preset back into runtime generation via inlineVariables', () => {
140
+ mockSystemDark(false);
141
+ const { container } = render(ThemeProvider, {
142
+ props: { preset: 'material', inlineVariables: true, children: child() },
143
+ });
100
144
  const style = container.querySelector('.smrt-theme-root')?.getAttribute('style') ?? '';
101
- const bootstrapBackground = [
102
- '--smrt',
103
- 'bootstrap',
104
- 'color',
105
- 'background',
106
- ].join('-');
107
- expect(style).toContain(`--smrt-color-background: var(${bootstrapBackground},`);
145
+ expect(style).toContain('--smrt-color-background');
108
146
  });
109
147
  });