@kubex/zinc 1.1.68 → 1.1.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 (37) hide show
  1. package/dist/custom-elements.json +1719 -282
  2. package/dist/vscode.html-custom-data.json +160 -29
  3. package/dist/web-types.json +395 -70
  4. package/dist/zn.d.ts +395 -16
  5. package/dist/zn.min.js +580 -408
  6. package/docs/pages/components/collapsible.md +6 -2
  7. package/docs/pages/components/icon-picker.md +14 -0
  8. package/docs/pages/components/input.md +19 -1
  9. package/docs/pages/components/preview-frame-demo.njk +33 -4
  10. package/docs/pages/components/preview-frame.md +20 -0
  11. package/docs/pages/components/theme-editor.md +303 -0
  12. package/docs/superpowers/plans/2026-08-03-theme-editor.md +1536 -0
  13. package/docs/superpowers/specs/2026-08-03-theme-editor-design.md +327 -0
  14. package/package.json +1 -1
  15. package/src/components/collapsible/collapsible.component.ts +21 -23
  16. package/src/components/dropdown/dropdown.component.ts +9 -0
  17. package/src/components/editor/editor.component.ts +20 -21
  18. package/src/components/file/file.component.ts +15 -0
  19. package/src/components/icon-picker/icon-picker.component.ts +150 -16
  20. package/src/components/icon-picker/icon-picker.scss +32 -0
  21. package/src/components/input/input.component.ts +44 -21
  22. package/src/components/input/input.scss +134 -42
  23. package/src/components/input/input.test.ts +45 -0
  24. package/src/components/preview-frame/preview-frame.component.ts +126 -16
  25. package/src/components/preview-frame/preview-frame.scss +27 -0
  26. package/src/components/preview-frame/preview-frame.test.ts +253 -0
  27. package/src/components/theme-editor/index.ts +12 -0
  28. package/src/components/theme-editor/theme-editor.component.ts +918 -0
  29. package/src/components/theme-editor/theme-editor.scss +309 -0
  30. package/src/components/theme-editor/theme-editor.test.ts +1752 -0
  31. package/src/events/events.ts +2 -0
  32. package/src/events/zn-theme-change.ts +13 -0
  33. package/src/events/zn-theme-submit.ts +9 -0
  34. package/src/types/web-test-runner-commands.d.ts +6 -0
  35. package/src/zinc.ts +1 -0
  36. package/tsconfig.json +7 -1
  37. package/web-test-runner.config.js +3 -1
@@ -26,19 +26,27 @@ export default class ZnIconPicker extends ZincElement implements ZincFormControl
26
26
  };
27
27
 
28
28
  private readonly formControlController = new FormControlController(this, {
29
- assumeInteractionOn: ['zn-change']
29
+ assumeInteractionOn: ['zn-change'],
30
+ // With allow-upload, a chosen file is submitted under `name` in place of
31
+ // the icon string — the two are mutually exclusive.
32
+ value: (el: ZnIconPicker) => el._file ?? el.icon
30
33
  });
31
34
 
32
35
  @property() name = '';
33
36
  @property() icon = '';
34
37
  @property() label = '';
35
- @property() library: string = 'material';
38
+ // Matches zn-icon's defaultLibrary so the picked icon previews the same way
39
+ // it will render wherever the stored name is displayed.
40
+ @property() library: string = 'material-symbols-outlined';
36
41
  @property() color: string = '';
37
42
  @property({type: Boolean, attribute: 'no-color'}) noColor: boolean = false;
38
43
  @property({type: Boolean, attribute: 'no-library'}) noLibrary: boolean = false;
39
44
  @property({attribute: 'help-text'}) helpText: string = '';
40
45
  @property({type: Boolean, reflect: true}) disabled = false;
41
46
  @property({type: Boolean, reflect: true}) required = false;
47
+ @property({type: Boolean, attribute: 'trigger-submit'}) triggerSubmit = false;
48
+ @property({type: Boolean, attribute: 'allow-upload'}) allowUpload = false;
49
+ @property() accept = 'image/*';
42
50
  @property({reflect: true}) form: string;
43
51
 
44
52
  @defaultValue() defaultValue = '';
@@ -52,8 +60,16 @@ export default class ZnIconPicker extends ZincElement implements ZincFormControl
52
60
  @state() private _pendingIcon = '';
53
61
  @state() private _pendingLibrary = '';
54
62
  @state() private _pendingColor = '';
63
+ @state() private _pendingFile: File | null = null;
64
+ @state() private _pendingFileUrl: string | null = null;
65
+ @state() private _mode: 'icon' | 'upload' = 'icon';
66
+
67
+ // Committed upload (allow-upload mode)
68
+ @state() private _file: File | null = null;
69
+ @state() private _fileUrl: string | null = null;
55
70
 
56
71
  @query('zn-dialog') private _dialog: ZnDialog;
72
+ @query('.icon-picker__file-input') private _fileInput: HTMLInputElement;
57
73
 
58
74
  get value(): string {
59
75
  return this.icon;
@@ -63,8 +79,18 @@ export default class ZnIconPicker extends ZincElement implements ZincFormControl
63
79
  this.icon = val;
64
80
  }
65
81
 
82
+ /** The chosen file when the user uploaded an image instead of picking an icon. */
83
+ get file(): File | null {
84
+ return this._file;
85
+ }
86
+
87
+ /** True when the current value renders as an image (an uploaded file or a URL) rather than a library icon. */
88
+ get isImageValue(): boolean {
89
+ return !!this._file || this.icon.includes('/');
90
+ }
91
+
66
92
  get validity(): ValidityState {
67
- if (this.required && !this.icon) {
93
+ if (this.required && !this.icon && !this._file) {
68
94
  return {
69
95
  valid: false,
70
96
  valueMissing: true,
@@ -81,7 +107,7 @@ export default class ZnIconPicker extends ZincElement implements ZincFormControl
81
107
  }
82
108
 
83
109
  get validationMessage(): string {
84
- return this.required && !this.icon ? 'Please select an icon.' : '';
110
+ return this.required && !this.icon && !this._file ? 'Please select an icon.' : '';
85
111
  }
86
112
 
87
113
  checkValidity(): boolean { return this.validity.valid; }
@@ -129,10 +155,11 @@ export default class ZnIconPicker extends ZincElement implements ZincFormControl
129
155
  }
130
156
 
131
157
  private async openDialog() {
132
- this._pendingIcon = this.icon;
158
+ this._pendingIcon = this.isImageValue ? '' : this.icon;
133
159
  this._pendingLibrary = this.library;
134
160
  this._pendingColor = this.color;
135
161
  this._searchQuery = '';
162
+ this._mode = this.allowUpload && this.isImageValue ? 'upload' : 'icon';
136
163
  this._iconList = await this.getIconsForLibrary(this.library);
137
164
  this._filteredIcons = this._iconList.slice(0, 200);
138
165
  this._dialogOpen = true;
@@ -142,22 +169,74 @@ export default class ZnIconPicker extends ZincElement implements ZincFormControl
142
169
  }
143
170
 
144
171
  private closeDialog() {
172
+ this.discardPendingFile();
145
173
  this._dialog.hide();
146
174
  this._dialogOpen = false;
147
175
  }
148
176
 
149
177
  private handleConfirm() {
150
- this.icon = this._pendingIcon;
151
- this.library = this._pendingLibrary;
152
- this.color = this._pendingColor;
178
+ if (this._mode === 'upload') {
179
+ if (this._pendingFile) {
180
+ if (this._fileUrl) {
181
+ URL.revokeObjectURL(this._fileUrl);
182
+ }
183
+ this._file = this._pendingFile;
184
+ this._fileUrl = this._pendingFileUrl;
185
+ this._pendingFile = null;
186
+ this._pendingFileUrl = null;
187
+ this.icon = '';
188
+ }
189
+ } else {
190
+ this.icon = this._pendingIcon;
191
+ this.library = this._pendingLibrary;
192
+ this.color = this._pendingColor;
193
+ this.clearFile();
194
+ }
153
195
  this.closeDialog();
154
196
  this.emit('zn-change');
197
+ if (this.triggerSubmit) {
198
+ this.formControlController.submit();
199
+ }
155
200
  }
156
201
 
157
202
  private handleCancel() {
158
203
  this.closeDialog();
159
204
  }
160
205
 
206
+ private handleFileSelect(e: Event) {
207
+ const input = e.target as HTMLInputElement;
208
+ const file = input.files?.[0] ?? null;
209
+ if (!file) return;
210
+ this.discardPendingFile();
211
+ this._pendingFile = file;
212
+ this._pendingFileUrl = URL.createObjectURL(file);
213
+ input.value = '';
214
+ }
215
+
216
+ private discardPendingFile() {
217
+ if (this._pendingFileUrl) {
218
+ URL.revokeObjectURL(this._pendingFileUrl);
219
+ }
220
+ this._pendingFile = null;
221
+ this._pendingFileUrl = null;
222
+ }
223
+
224
+ private clearFile() {
225
+ if (this._fileUrl) {
226
+ URL.revokeObjectURL(this._fileUrl);
227
+ }
228
+ this._file = null;
229
+ this._fileUrl = null;
230
+ }
231
+
232
+ disconnectedCallback() {
233
+ super.disconnectedCallback();
234
+ this.discardPendingFile();
235
+ if (this._fileUrl) {
236
+ URL.revokeObjectURL(this._fileUrl);
237
+ }
238
+ }
239
+
161
240
  private handleSearchInput(e: Event) {
162
241
  const input = e.target as HTMLInputElement;
163
242
  this._searchQuery = input.value.toLowerCase();
@@ -209,7 +288,11 @@ export default class ZnIconPicker extends ZincElement implements ZincFormControl
209
288
  e.stopPropagation();
210
289
  this.icon = '';
211
290
  this.color = '';
291
+ this.clearFile();
212
292
  this.emit('zn-change');
293
+ if (this.triggerSubmit) {
294
+ this.formControlController.submit();
295
+ }
213
296
  }
214
297
 
215
298
  private _handleTriggerClick() {
@@ -220,7 +303,12 @@ export default class ZnIconPicker extends ZincElement implements ZincFormControl
220
303
  render() {
221
304
  const hasLabel = !!this.label;
222
305
  const hasHelpText = !!this.helpText;
223
- const hasIcon = !!this.icon;
306
+ const hasValue = !!this.icon || !!this._file;
307
+ // Image values (uploaded file or URL) omit the library so zn-icon's URL
308
+ // auto-detection renders an <img> instead of a font ligature.
309
+ const triggerIcon = this._fileUrl ?? this.icon;
310
+ const triggerLibrary = hasValue && !this.isImageValue ? this.library : nothing;
311
+ const pendingPreviewUrl = this._pendingFileUrl ?? (this.isImageValue && !this._file ? this.icon : this._fileUrl);
224
312
 
225
313
  return html`
226
314
  <div part="form-control"
@@ -236,15 +324,15 @@ export default class ZnIconPicker extends ZincElement implements ZincFormControl
236
324
  part="trigger"
237
325
  class="icon-picker__trigger"
238
326
  panel-bg
239
- icon=${hasIcon ? this.icon : nothing}
240
- icon-library=${hasIcon ? this.library : nothing}
241
- icon-color=${(hasIcon && this.color) || nothing}
327
+ icon=${hasValue ? triggerIcon : nothing}
328
+ icon-library=${triggerLibrary}
329
+ icon-color=${(hasValue && !this.isImageValue && this.color) || nothing}
242
330
  icon-size="24"
243
331
  ?disabled=${this.disabled}
244
332
  @click=${this._handleTriggerClick}>
245
- ${hasIcon ? 'Click to edit' : 'Set an icon'}
333
+ ${hasValue ? 'Click to edit' : 'Set an icon'}
246
334
  </zn-button>
247
- ${hasIcon ? html`
335
+ ${hasValue ? html`
248
336
  <zn-button
249
337
  class="icon-picker__clear"
250
338
  color="transparent"
@@ -272,6 +360,38 @@ export default class ZnIconPicker extends ZincElement implements ZincFormControl
272
360
  <zn-dialog size="custom" label="Select Icon" @zn-close=${this.handleCancel}>
273
361
  <div class="icon-picker__dialog-layout">
274
362
  <div class="icon-picker__dialog-main">
363
+ ${this.allowUpload ? html`
364
+ <div class="icon-picker__mode-switch">
365
+ <zn-button
366
+ size="small"
367
+ color=${this._mode === 'icon' ? 'secondary' : 'transparent'}
368
+ icon="apps"
369
+ @click=${() => this._mode = 'icon'}>
370
+ Icon Library
371
+ </zn-button>
372
+ <zn-button
373
+ size="small"
374
+ color=${this._mode === 'upload' ? 'secondary' : 'transparent'}
375
+ icon="upload"
376
+ @click=${() => this._mode = 'upload'}>
377
+ Upload Image
378
+ </zn-button>
379
+ </div>
380
+ ` : nothing}
381
+
382
+ ${this._mode === 'upload' ? html`
383
+ <div class="icon-picker__upload">
384
+ <input class="icon-picker__file-input" type="file" accept=${this.accept} hidden
385
+ @change=${this.handleFileSelect}>
386
+ <zn-icon src="image" size="48"></zn-icon>
387
+ ${this._pendingFile ? html`
388
+ <span class="icon-picker__upload-filename">${this._pendingFile.name}</span>
389
+ ` : nothing}
390
+ <zn-button icon="upload" color="secondary" @click=${() => this._fileInput.click()}>
391
+ ${pendingPreviewUrl ? 'Choose a different file' : 'Choose a file'}
392
+ </zn-button>
393
+ </div>
394
+ ` : html`
275
395
  <div class="icon-picker__dialog-controls">
276
396
  ${!this.noLibrary ? html`
277
397
  <zn-select
@@ -354,10 +474,21 @@ export default class ZnIconPicker extends ZincElement implements ZincFormControl
354
474
  `)}
355
475
  </div>
356
476
  `}
477
+ `}
357
478
  </div>
358
479
 
359
480
  <div class="icon-picker__dialog-sidebar">
360
- ${this._pendingIcon ? html`
481
+ ${this._mode === 'upload' ? html`
482
+ <div class="icon-picker__preview ${pendingPreviewUrl ? '' : 'icon-picker__preview--empty'}">
483
+ ${pendingPreviewUrl ? html`
484
+ <img class="icon-picker__upload-preview" src=${pendingPreviewUrl} alt="">
485
+ <span class="icon-picker__preview-name">${this._pendingFile?.name ?? ''}</span>
486
+ ` : html`
487
+ <zn-icon src="image" size="48"></zn-icon>
488
+ <span class="icon-picker__preview-hint">Choose an image</span>
489
+ `}
490
+ </div>
491
+ ` : this._pendingIcon ? html`
361
492
  <div class="icon-picker__preview">
362
493
  <zn-icon
363
494
  src=${this._pendingIcon}
@@ -379,7 +510,10 @@ export default class ZnIconPicker extends ZincElement implements ZincFormControl
379
510
 
380
511
  <div slot="footer">
381
512
  <zn-button color="default" @click=${this.handleCancel}>Cancel</zn-button>
382
- <zn-button color="primary" @click=${this.handleConfirm} ?disabled=${!this._pendingIcon}>Confirm</zn-button>
513
+ <zn-button color="primary" @click=${this.handleConfirm}
514
+ ?disabled=${this._mode === 'upload' ? !this._pendingFile && !this.isImageValue : !this._pendingIcon}>
515
+ Confirm
516
+ </zn-button>
383
517
  </div>
384
518
  </zn-dialog>
385
519
  ` : nothing}
@@ -60,6 +60,38 @@ zn-dialog {
60
60
  }
61
61
  }
62
62
 
63
+ .icon-picker__mode-switch {
64
+ display: flex;
65
+ gap: var(--zn-spacing-x-small);
66
+ margin-bottom: var(--zn-spacing-small);
67
+ }
68
+
69
+ .icon-picker__upload {
70
+ flex: 1;
71
+ display: flex;
72
+ flex-direction: column;
73
+ align-items: center;
74
+ justify-content: center;
75
+ gap: var(--zn-spacing-small);
76
+ border: dashed 1px rgb(var(--zn-border-color));
77
+ border-radius: var(--zn-border-radius);
78
+ padding: var(--zn-spacing-large);
79
+ color: rgb(var(--zn-text-muted));
80
+ }
81
+
82
+ .icon-picker__upload-preview {
83
+ max-width: 100%;
84
+ max-height: 160px;
85
+ object-fit: contain;
86
+ border-radius: var(--zn-border-radius);
87
+ }
88
+
89
+ .icon-picker__upload-filename {
90
+ font-size: var(--zn-font-size-small);
91
+ color: rgb(var(--zn-text-muted));
92
+ word-break: break-all;
93
+ }
94
+
63
95
  .icon-picker__dialog-controls {
64
96
  display: flex;
65
97
  gap: var(--zn-spacing-small);
@@ -33,7 +33,7 @@ import styles from './input.scss';
33
33
  *
34
34
  * @slot label - The input's label. Alternatively, you can use the `label` attribute.
35
35
  * @slot label-tooltip - Used to add text that is displayed in a tooltip next to the label. Alternatively, you can use the `label-tooltip` attribute.
36
- * @slot context-note - Used to add contextual text that is displayed above the input, on the right. Alternatively, you can use the `context-note` attribute.
36
+ * @slot context-note - Used to add contextual text that is displayed above the input, on the right. Alternatively, you can use the `context-note` attribute. Range inputs display their live value here unless this is set.
37
37
  * @slot prefix - Used to prepend a presentational icon or similar element to the input.
38
38
  * @slot suffix - Used to append a presentational icon or similar element to the input.
39
39
  * @slot clear-icon - An icon to use in lieu of the default clear icon.
@@ -51,6 +51,12 @@ import styles from './input.scss';
51
51
  * @csspart clear-button - The clear button.
52
52
  * @csspart password-toggle-button - The password toggle button.
53
53
  * @csspart suffix - The container that wraps the suffix.
54
+ *
55
+ * @cssproperty --zn-range-track-height - The height of a range input's track.
56
+ * @cssproperty --zn-range-track-color - The color of a range input's track.
57
+ * @cssproperty --zn-range-thumb-size - The diameter of a range input's thumb.
58
+ * @cssproperty --zn-range-thumb-color - The color of a range input's thumb.
59
+ * @cssproperty --zn-range-thumb-ring-width - The width of the ring drawn around a range input's thumb.
54
60
  */
55
61
  export default class ZnInput extends ZincElement implements ZincFormControl {
56
62
  static styles = unsafeCSS(styles);
@@ -80,7 +86,7 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
80
86
  * to `text`
81
87
  */
82
88
  @property({reflect: true}) type: 'color' | 'currency' | 'date' | 'datetime-local' | 'email' | 'number' | 'password' |
83
- 'search' | 'tel' | 'text' | 'time' | 'url' = 'text';
89
+ 'range' | 'search' | 'tel' | 'text' | 'time' | 'url' = 'text';
84
90
 
85
91
  /** The name of the input, submitted as a name/value pair with form data. */
86
92
  @property() name: string = "";
@@ -146,6 +152,12 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
146
152
  /** The color format to display for color inputs. Only applies when type is 'color'. **/
147
153
  @property({attribute: 'color-format'}) colorFormat: 'hex' | 'rgb' | 'hsl' | 'oklch' = 'hex';
148
154
 
155
+ /**
156
+ * Appended to the value that range inputs display above the track, on the right, e.g. `rem`. Only applies when type
157
+ * is 'range' and no `context-note` is set.
158
+ */
159
+ @property({attribute: 'value-suffix'}) valueSuffix: string = '';
160
+
149
161
  /**
150
162
  * By default, form-controls are associated with the nearest containing `<form>` element. This attribute allows you
151
163
  * to place the form control outside a form and associate it with the form that has this `id`. The form must be
@@ -615,6 +627,13 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
615
627
  }
616
628
  }
617
629
 
630
+ protected firstUpdated() {
631
+ // A range input with no value resolves to the midpoint of min/max natively, so adopt it as our value
632
+ if (this.type === 'range' && this.value === '') {
633
+ this.value = this.input.value;
634
+ }
635
+ }
636
+
618
637
  @watch('disabled', {waitUntilFirstUpdate: true})
619
638
  handleDisabledChange() {
620
639
  // Disabled form controls are always valid
@@ -763,9 +782,10 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
763
782
  const hasLabelTooltipSlot = this.hasSlotController.test('label-tooltip');
764
783
  const hasContextNoteSlot = this.hasSlotController.test('context-note');
765
784
  const hasHelpTextSlot = this.hasSlotController.test('help-text');
785
+ const isRange = this.type === 'range';
766
786
  const hasLabel = this.label ? true : hasLabelSlot;
767
787
  const hasLabelTooltip = this.labelTooltip ? true : hasLabelTooltipSlot;
768
- const hasContextNote = this.contextNote ? true : hasContextNoteSlot;
788
+ const hasContextNote = this.contextNote || isRange ? true : hasContextNoteSlot;
769
789
  const hasHelpText = this.helpText ? true : hasHelpTextSlot;
770
790
  const hasClearIcon = this.clearable && !this.disabled && !this.readonly;
771
791
  const hasOptionalIcon = this.optionalIcon;
@@ -803,7 +823,8 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
803
823
 
804
824
  ${hasContextNote
805
825
  ? html`
806
- <span class="form-control__label-context-note"><slot name="context-note">${this.contextNote}</slot></span>`
826
+ <span class="form-control__label-context-note"><slot name="context-note">${this.contextNote ||
827
+ (isRange ? `${this.value ?? ''}${this.valueSuffix}` : '')}</slot></span>`
807
828
  : ''}
808
829
 
809
830
  <div part="form-control-input"
@@ -822,27 +843,12 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
822
843
  'input--filled': this.filled,
823
844
  'input--focused': this.hasFocus,
824
845
  'input--empty': !this.value,
846
+ 'input--range': isRange,
825
847
  'input--no-spin-buttons': this.noSpinButtons && this.type !== 'currency',
826
848
  })}>
827
849
 
828
850
  <span part="prefix" class="input__prefix">
829
- ${this.type === 'color'
830
- ? html`
831
- <div
832
- class="input__color-swatch"
833
- @click=${this.handleColorSwatchClick}
834
- style="--color-value: ${this.convertToHex(this.value) || '#000000'}">
835
- <div class="input__color-swatch-inner"></div>
836
- </div>
837
- <input
838
- class="input__color-picker"
839
- type="color"
840
- .value=${this.convertToHex(this.value) || '#000000'}
841
- @change=${this.handleColorPickerChange}
842
- @input=${this.handleColorPickerChange}
843
- tabindex="-1"
844
- />`
845
- : this.type === 'currency'
851
+ ${this.type === 'currency'
846
852
  ? html`
847
853
  <zn-icon class="input__prefix-default" src="payments"></zn-icon>`
848
854
  : this.type === 'email' && hasOptionalIcon
@@ -933,6 +939,23 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
933
939
 
934
940
  <span part="suffix" class="input__suffix">
935
941
  <slot name="suffix"></slot>
942
+ ${this.type === 'color'
943
+ ? html`
944
+ <div
945
+ class="input__color-swatch"
946
+ @click=${this.handleColorSwatchClick}
947
+ style="--color-value: ${this.convertToHex(this.value) || '#000000'}">
948
+ <div class="input__color-swatch-inner"></div>
949
+ </div>
950
+ <input
951
+ class="input__color-picker"
952
+ type="color"
953
+ .value=${this.convertToHex(this.value) || '#000000'}
954
+ @change=${this.handleColorPickerChange}
955
+ @input=${this.handleColorPickerChange}
956
+ tabindex="-1"
957
+ />`
958
+ : ''}
936
959
  </span>
937
960
  </div>
938
961
  </div>
@@ -384,51 +384,163 @@ input[type='date']::-webkit-calendar-picker-indicator {
384
384
  background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" height="14" width="12.25" viewBox="0 0 448 512"><path fill="%236d7176" d="M128 0c13.3 0 24 10.7 24 24V64H296V24c0-13.3 10.7-24 24-24s24 10.7 24 24V64h40c35.3 0 64 28.7 64 64v16 48V448c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V192 144 128C0 92.7 28.7 64 64 64h40V24c0-13.3 10.7-24 24-24zM400 192H48V448c0 8.8 7.2 16 16 16H384c8.8 0 16-7.2 16-16V192zM112 256h96c8.8 0 16 7.2 16 16v96c0 8.8-7.2 16-16 16H112c-8.8 0-16-7.2-16-16V272c0-8.8 7.2-16 16-16z"/></svg>');
385
385
  }
386
386
 
387
- /* Color input styling */
387
+ /* Color input styling — the preview fills the trailing edge of the field */
388
388
  .input__color-swatch {
389
389
  display: inline-flex;
390
- align-items: center;
391
- justify-content: center;
392
- width: 1.75rem;
393
- height: 1.75rem;
394
- border-radius: var(--zn-border-radius);
395
- border: solid 1px rgba(0, 0, 0, 0.15);
390
+ align-self: stretch;
391
+ border: none;
392
+ border-inline-start: solid var(--zn-input-border-width) var(--zn-input-border-color);
393
+ border-radius: 0;
396
394
  background-color: var(--zn-color-neutral-0);
397
395
  cursor: pointer;
398
- transition: var(--zn-transition-fast) border-color, var(--zn-transition-fast) box-shadow;
399
396
  overflow: hidden;
400
397
  position: relative;
401
398
  flex-shrink: 0;
402
- margin-inline-start: var(--zn-input-spacing-small);
399
+ margin: 0;
403
400
 
404
- &:hover {
405
- border-color: rgba(0, 0, 0, 0.25);
406
- box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.05);
401
+ &:hover .input__color-swatch-inner {
402
+ filter: brightness(0.95);
407
403
  }
408
404
  }
409
405
 
410
- /* Size-specific spacing adjustments for color swatch */
406
+ .input__color-swatch-inner {
407
+ width: 100%;
408
+ height: 100%;
409
+ background-color: var(--color-value, #000000);
410
+ transition: var(--zn-transition-fast) filter;
411
+ }
412
+
413
+ /* Size adjustments for color inputs — .input clips the trailing corners */
411
414
  .input--x-small .input__color-swatch {
412
- margin-inline-start: var(--zn-input-spacing-x-small);
415
+ width: calc(var(--zn-input-height-x-small) * 1.2);
413
416
  }
414
417
 
415
418
  .input--small .input__color-swatch {
416
- margin-inline-start: var(--zn-spacing-x2-small);
419
+ width: calc(var(--zn-input-height-small) * 1.2);
417
420
  }
418
421
 
419
422
  .input--medium .input__color-swatch {
420
- margin-inline-start: var(--zn-spacing-x-small);
423
+ width: calc(var(--zn-input-height-medium) * 1.2);
421
424
  }
422
425
 
423
426
  .input--large .input__color-swatch {
424
- margin-inline-start: var(--zn-spacing-small);
427
+ width: calc(var(--zn-input-height-large) * 1.2);
425
428
  }
426
429
 
427
- .input__color-swatch-inner {
428
- width: 100%;
429
- height: 100%;
430
- border-radius: calc(var(--zn-border-radius) - 2px);
431
- background-color: var(--color-value, #000000);
430
+ /* Range inputs render as a bare track, with the value shown in the context note above */
431
+ /* The thumb already leaves clear space above the track, so drop the label/field gap */
432
+ :host([type='range']) .form-control--has-label .form-control__label,
433
+ :host([type='range']) .form-control__label-context-note {
434
+ margin-bottom: 0;
435
+ }
436
+
437
+ :host([type='range']) .form-control-input {
438
+ margin-top: 0;
439
+ }
440
+
441
+ .input--standard.input--range {
442
+ --zn-range-track-height: 6px;
443
+ --zn-range-track-color: var(--zn-color-neutral-200);
444
+ --zn-range-thumb-size: 18px;
445
+ --zn-range-thumb-color: var(--zn-color-primary-600);
446
+ --zn-range-thumb-ring-width: 4px;
447
+
448
+ align-items: center;
449
+ height: var(--zn-range-thumb-size);
450
+ padding: 0;
451
+ overflow: visible;
452
+ cursor: pointer;
453
+ }
454
+
455
+ /* Matches the specificity of the hover/focus/disabled chrome rules above so the track stays bare */
456
+ .input--standard.input--range,
457
+ .input--standard.input--range:hover,
458
+ .input--standard.input--range.input--focused,
459
+ .input--standard.input--range.input--disabled {
460
+ border: none;
461
+ background: none;
462
+ box-shadow: none;
463
+ }
464
+
465
+ .input--standard.input--range.input--x-small {
466
+ --zn-range-track-height: 4px;
467
+ --zn-range-thumb-size: 14px;
468
+ --zn-range-thumb-ring-width: 3px;
469
+ }
470
+
471
+ .input--standard.input--range.input--small {
472
+ --zn-range-track-height: 5px;
473
+ --zn-range-thumb-size: 16px;
474
+ --zn-range-thumb-ring-width: 3px;
475
+ }
476
+
477
+ .input--standard.input--range.input--large {
478
+ --zn-range-track-height: 7px;
479
+ --zn-range-thumb-size: 20px;
480
+ }
481
+
482
+ .input--range .input__control {
483
+ -webkit-appearance: none;
484
+ appearance: none;
485
+ height: var(--zn-range-thumb-size);
486
+ padding: 0;
487
+ margin: 0;
488
+ background: none;
489
+ cursor: inherit;
490
+ }
491
+
492
+ .input--range .input__control::-webkit-slider-runnable-track {
493
+ height: var(--zn-range-track-height);
494
+ border-radius: calc(var(--zn-range-track-height) / 2);
495
+ background-color: var(--zn-range-track-color);
496
+ }
497
+
498
+ .input--range .input__control::-moz-range-track {
499
+ height: var(--zn-range-track-height);
500
+ border-radius: calc(var(--zn-range-track-height) / 2);
501
+ background-color: var(--zn-range-track-color);
502
+ }
503
+
504
+ .input--range .input__control::-webkit-slider-thumb {
505
+ -webkit-appearance: none;
506
+ appearance: none;
507
+ width: var(--zn-range-thumb-size);
508
+ height: var(--zn-range-thumb-size);
509
+ margin-top: calc((var(--zn-range-track-height) - var(--zn-range-thumb-size)) / 2);
510
+ border: var(--zn-range-thumb-ring-width) solid var(--zn-color-neutral-0);
511
+ border-radius: 50%;
512
+ background-color: var(--zn-range-thumb-color);
513
+ box-shadow: 0 0 0 1px var(--zn-input-border-color);
514
+ transition: var(--zn-transition-fast) box-shadow, var(--zn-transition-fast) background-color;
515
+ }
516
+
517
+ .input--range .input__control::-moz-range-thumb {
518
+ width: var(--zn-range-thumb-size);
519
+ height: var(--zn-range-thumb-size);
520
+ box-sizing: border-box;
521
+ border: var(--zn-range-thumb-ring-width) solid var(--zn-color-neutral-0);
522
+ border-radius: 50%;
523
+ background-color: var(--zn-range-thumb-color);
524
+ box-shadow: 0 0 0 1px var(--zn-input-border-color);
525
+ transition: var(--zn-transition-fast) box-shadow, var(--zn-transition-fast) background-color;
526
+ }
527
+
528
+ .input--range:hover:not(.input--disabled) .input__control::-webkit-slider-thumb {
529
+ background-color: var(--zn-color-primary-500);
530
+ box-shadow: 0 0 0 1px var(--zn-input-border-color-hover);
531
+ }
532
+
533
+ .input--range:hover:not(.input--disabled) .input__control::-moz-range-thumb {
534
+ background-color: var(--zn-color-primary-500);
535
+ box-shadow: 0 0 0 1px var(--zn-input-border-color-hover);
536
+ }
537
+
538
+ .input--range .input__control:focus-visible::-webkit-slider-thumb {
539
+ box-shadow: 0 0 0 var(--zn-focus-ring-width) var(--zn-input-focus-ring-color);
540
+ }
541
+
542
+ .input--range .input__control:focus-visible::-moz-range-thumb {
543
+ box-shadow: 0 0 0 var(--zn-focus-ring-width) var(--zn-input-focus-ring-color);
432
544
  }
433
545
 
434
546
  /* Hide the native color input but keep it functional */
@@ -444,23 +556,3 @@ input[type='date']::-webkit-calendar-picker-indicator {
444
556
  visibility: hidden;
445
557
  }
446
558
 
447
- /* Size adjustments for color inputs */
448
- .input--x-small .input__color-swatch {
449
- width: 1.25rem;
450
- height: 1.25rem;
451
- }
452
-
453
- .input--small .input__color-swatch {
454
- width: 1.5rem;
455
- height: 1.5rem;
456
- }
457
-
458
- .input--medium .input__color-swatch {
459
- width: 1.75rem;
460
- height: 1.75rem;
461
- }
462
-
463
- .input--large .input__color-swatch {
464
- width: 2rem;
465
- height: 2rem;
466
- }