@kenlo/sds-design-system 1.0.2 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -160,6 +160,21 @@ toolchain que já tem.
160
160
 
161
161
  ---
162
162
 
163
+ ## Versão
164
+
165
+ Regra combinada com o Rafael Leobas em 05/11/2026, igual no SDS e no KDLS.
166
+
167
+ - **Na npm** vale o padrão dela: `MAIOR.MENOR.CORREÇÃO`, sem zero à esquerda (`1.1.1`, nunca `1.01.01`).
168
+ - **Correção sobe só o último número** (+0.0.1). É assim que todo site recebe a versão nova sozinho.
169
+ - **O número do meio e o último vão de 0 a 99.** Depois do 99, vira o próximo: `1.19.99` → `1.20.0`;
170
+ `1.99.99` → `2.0.0`. Um teste (`tests/display-version.test.js`) reprova o MR se passar de 99.
171
+ - **A virada para um novo número maior é combinada antes com os times**: quem instalou com `^1.x` não
172
+ recebe `2.0.0` sozinho.
173
+ - **No portal** a versão aparece sempre com largura fixa, `vX.XX.XX` (`1.1.1` aparece como `v1.01.01`).
174
+ - **Sem subir a versão, nada chega aos sites**: a esteira só publica na npm uma versão nova.
175
+
176
+ ---
177
+
163
178
  ## Documentação
164
179
 
165
180
  O portal tem todos os componentes com controles ao vivo, a tabela de props de cada um e o
@@ -178,3 +193,27 @@ npm run dev
178
193
  O pacote é público na npm porque os projetos que o consomem precisam instalá-lo sem
179
194
  credencial, igual ao KDLS. **Público não quer dizer aberto:** o código é proprietário da
180
195
  Kenlo (`UNLICENSED`) e as contribuições passam pelo repositório no GitLab.
196
+
197
+ ## Portal no pages.kenlo.io
198
+
199
+ Decisão do Rafael Leobas, 03/11/2026: portais de design system moram no pages.kenlo.io, com o login
200
+ Google da Kenlo, para a empresa toda navegar e testar; demonstrações de produto (VIRA, CMS 2.0, Site 2.0)
201
+ ficam no claude.ai.
202
+
203
+ **Regra:** toda mudança que entra na `main` atualiza o portal no pages.kenlo.io.
204
+
205
+ **Cabe, mas ainda falta o caminho de publicação.** Medido em 03/11/2026 (main de9f1d9): montado com
206
+ `vite build --base ./` e as fontes embutidas, o portal tem 1,76 MB, abaixo do limite de 5 MB por página,
207
+ e passou nas checagens do próprio `kenlo-pages` (`scripts/catalogo.py` e `scripts/conferir_tela.py`, sem
208
+ erro). Uma sessão não consegue entregá-lo: a porta de MR do Kenlo Brain recebe o conteúdo dentro do
209
+ pedido e os ramos pessoais são bloqueados. Falta quem administra o pages.kenlo.io decidir o caminho
210
+ automático: a esteira do `kenlo-pages` montar o portal a partir deste repositório, ou a esteira daqui
211
+ publicar depois de cada merge.
212
+
213
+ **Enquanto isso:** a esteira daqui já tem o passo `deploy-portal`, que publica o portal num bucket a cada
214
+ merge na `main`, parado à espera de duas variáveis (`SDS_DOCS_BUCKET` e `SDS_GCP_SA_KEY`). Com
215
+ `base: './'` em `portal/vite.config.js`, o portal montado funciona em qualquer pasta (bucket ou
216
+ pages.kenlo.io).
217
+
218
+ ## Quem aprova mudanças aqui
219
+ Mudança normal: quem pediu aprova sozinho. Mudança de alto risco (dados de cliente, login, cobrança, segredos, infraestrutura, esteira automática): precisa também de um dos cinco aprovadores. Regra completa: https://pages.kenlo.io/p/kenlo-brain/quem-aprova-o-que/ (Constituição G11 c, e, j).
@@ -13,7 +13,7 @@
13
13
  theme and would light the screen up instead of dimming it, which is why they were
14
14
  literals at all; --color-scrim is that value as a token. *//* ============================================================
15
15
  SDS Drawer
16
- Width: 600px (default) | full height | slides from right/left
16
+ Width: 600px (default), resizable by dragging the inner edge | full height | slides from right/left
17
17
  Header: Bold 24px, border-bottom neutral-30
18
18
  Footer: py-16, border-top neutral-30
19
19
  ============================================================ */
@@ -51,6 +51,43 @@
51
51
  box-shadow: 4px 0px 24px var(--color-opacity-20);
52
52
  animation: sds-drawer-in-left 250ms var(--ease-standard);
53
53
  }
54
+ /* ── Resize handle: 8px hit area on the inner edge, 2px line on hover/drag ── */
55
+ .sds-drawer__resize-handle {
56
+ position: absolute;
57
+ top: 0;
58
+ bottom: 0;
59
+ left: calc(var(--spacing-1) * -1);
60
+ width: var(--spacing-2);
61
+ cursor: col-resize;
62
+ touch-action: none;
63
+ z-index: 1;
64
+ outline: none;
65
+ }
66
+ .sds-drawer--left .sds-drawer__resize-handle {
67
+ left: auto;
68
+ right: calc(var(--spacing-1) * -1);
69
+ }
70
+ .sds-drawer__resize-handle::after {
71
+ content: '';
72
+ position: absolute;
73
+ top: 0;
74
+ bottom: 0;
75
+ left: 50%;
76
+ width: 2px;
77
+ transform: translateX(-50%);
78
+ background: transparent;
79
+ transition: background 120ms ease;
80
+ }
81
+ .sds-drawer__resize-handle:hover::after,
82
+ .sds-drawer--resizing .sds-drawer__resize-handle::after {
83
+ background: var(--color-primary-50);
84
+ }
85
+ .sds-drawer__resize-handle:focus-visible {
86
+ box-shadow: var(--focus-ring);
87
+ }
88
+ .sds-drawer--resizing {
89
+ user-select: none;
90
+ }
54
91
  @keyframes sds-drawer-in {
55
92
  from { transform: translateX(100%); }
56
93
  to { transform: translateX(0); }
@@ -146,6 +183,9 @@
146
183
  .sds-drawer {
147
184
  width: 100%;
148
185
  }
186
+ .sds-drawer__resize-handle {
187
+ display: none;
188
+ }
149
189
  .sds-drawer__footer {
150
190
  flex-direction: column-reverse;
151
191
  align-items: stretch;
@@ -32,9 +32,23 @@ import { lockScroll, unlockScroll, trapFocus } from "../../foundations/overlay.j
32
32
  import dismissIcon from "../../icons/generated/fluent-dismiss-regular.marko";
33
33
  import backIcon from "../../icons/generated/fluent-chevron-left-regular.marko";
34
34
 
35
+ static const DEFAULT_WIDTH = 600;
36
+ static const MIN_WIDTH = 400;
37
+ static const MAX_WIDTH_RATIO = 0.9;
38
+ static const KEYBOARD_STEP = 16;
39
+
40
+ static function maxWidth() {
41
+ return Math.max(MIN_WIDTH, Math.round(window.innerWidth * MAX_WIDTH_RATIO));
42
+ }
43
+
44
+ static function clampWidth(value) {
45
+ return Math.min(maxWidth(), Math.max(MIN_WIDTH, Math.round(value)));
46
+ }
47
+
35
48
  class {
36
49
  onCreate() {
37
- this.state = { confirming: false };
50
+ // width stays null until the visitor drags: the CSS default (600px) holds until then.
51
+ this.state = { confirming: false, width: null, resizing: false };
38
52
  this.release = null;
39
53
  }
40
54
 
@@ -80,8 +94,10 @@ class {
80
94
  }
81
95
 
82
96
  shut() {
97
+ this.stopResize?.();
83
98
  if (!this.locked) return;
84
99
  this.locked = false;
100
+ this.state.width = null;
85
101
  unlockScroll();
86
102
  this.release?.();
87
103
  this.release = null;
@@ -121,6 +137,52 @@ class {
121
137
  this.retrap(this.getEl("panel"));
122
138
  }
123
139
 
140
+ currentWidth() {
141
+ return this.state.width ?? (this.getEl("panel")?.getBoundingClientRect().width || DEFAULT_WIDTH);
142
+ }
143
+
144
+ growDirection() {
145
+ return (this.input.placement ?? "right") === "right" ? -1 : 1;
146
+ }
147
+
148
+ startResize(event) {
149
+ if (event.button !== 0) return;
150
+ event.preventDefault();
151
+ const startX = event.clientX;
152
+ const startWidth = this.currentWidth();
153
+ const direction = this.growDirection();
154
+ this.state.resizing = true;
155
+
156
+ const onMove = (moveEvent) => {
157
+ this.state.width = clampWidth(startWidth + (moveEvent.clientX - startX) * direction);
158
+ };
159
+ const onUp = () => {
160
+ this.stopResize = null;
161
+ this.state.resizing = false;
162
+ document.removeEventListener("pointermove", onMove);
163
+ document.removeEventListener("pointerup", onUp);
164
+ document.removeEventListener("pointercancel", onUp);
165
+ };
166
+ this.stopResize = onUp;
167
+ document.addEventListener("pointermove", onMove);
168
+ document.addEventListener("pointerup", onUp);
169
+ document.addEventListener("pointercancel", onUp);
170
+ }
171
+
172
+ resizeByKey(event) {
173
+ const direction = this.growDirection();
174
+ const keys = {
175
+ ArrowLeft: () => this.currentWidth() - KEYBOARD_STEP * direction,
176
+ ArrowRight: () => this.currentWidth() + KEYBOARD_STEP * direction,
177
+ Home: () => MIN_WIDTH,
178
+ End: () => maxWidth(),
179
+ };
180
+ const next = keys[event.key];
181
+ if (!next) return;
182
+ event.preventDefault();
183
+ this.state.width = clampWidth(next());
184
+ }
185
+
124
186
  confirmAndClose() {
125
187
  this.state.confirming = false;
126
188
  this.wantsConfirmFocus = false;
@@ -167,8 +229,10 @@ $ {
167
229
  const panelClass = [
168
230
  "sds-drawer",
169
231
  placement !== "right" ? `sds-drawer--${placement}` : "",
232
+ state.resizing ? "sds-drawer--resizing" : "",
170
233
  extraClass,
171
234
  ].filter(Boolean).join(" ");
235
+ const panelStyle = state.width !== null ? `--drawer-width:${state.width}px` : null;
172
236
  }
173
237
 
174
238
  <if(isOpen)>
@@ -181,7 +245,7 @@ $ {
181
245
  aria-label=title
182
246
  on-click("handleOverlayClick")
183
247
  >
184
- <div key="panel" class=panelClass>
248
+ <div key="panel" class=panelClass style=panelStyle>
185
249
  <div class="sds-drawer__header">
186
250
  <if(onBack)>
187
251
  <sds-icon-button variant="neutral" aria-label=backLabel onClick=onBack>
@@ -236,6 +300,19 @@ $ {
236
300
  </else>
237
301
  </div>
238
302
  </if>
303
+
304
+ <div
305
+ class="sds-drawer__resize-handle"
306
+ role="separator"
307
+ aria-orientation="vertical"
308
+ aria-label="Redimensionar drawer"
309
+ aria-valuemin=MIN_WIDTH
310
+ aria-valuemax=(typeof window !== "undefined" ? maxWidth() : null)
311
+ aria-valuenow=(state.width ?? DEFAULT_WIDTH)
312
+ tabindex="0"
313
+ on-pointerdown("startResize")
314
+ on-keydown("resizeByKey")
315
+ />
239
316
  </div>
240
317
 
241
318
  <if(state.confirming)>
@@ -7,7 +7,7 @@
7
7
  *//* ============================================================
8
8
  SDS IconButton
9
9
  Sizes: 32 | 40 | 48px
10
- Variants: primary (colored, default) | neutral (gray)
10
+ Variants: primary (colored, default) | neutral (gray) | inverse (white, on photo/dark)
11
11
  ============================================================ */
12
12
 
13
13
  .sds-icon-button {
@@ -74,11 +74,27 @@
74
74
  .sds-icon-button--neutral .sds-icon-button__icon {
75
75
  color: var(--color-neutral-70);
76
76
  }
77
+ /* ── Inverse: white icon over a photo or a dark surface ──
78
+ Ported from kdls-design-system 2.10.0 (2f15769). That surface never follows the theme,
79
+ so neither do the overlays: a fixed white tint, built from --color-white the way
80
+ --color-scrim is built from --color-black. Not --color-opacity-*, which follows the theme,
81
+ nor --color-opacity-inverse-*, which runs against it — in the dark theme that one turns
82
+ dark, and a dark tint on a dark surface is a hover nobody sees. KDLS writes the same
83
+ 10% / 20% as rgba literals. */
84
+ .sds-icon-button--inverse .sds-icon-button__icon {
85
+ color: var(--color-white);
86
+ }
87
+ .sds-icon-button--inverse:hover:not(:disabled):not(.sds-icon-button--loading) {
88
+ background-color: color-mix(in srgb, var(--color-white) 10%, transparent);
89
+ }
90
+ .sds-icon-button--inverse:active:not(:disabled):not(.sds-icon-button--loading) {
91
+ background-color: color-mix(in srgb, var(--color-white) 20%, transparent);
92
+ }
77
93
  /* ── Focus ring ── */
78
94
  .sds-icon-button:focus-visible {
79
95
  box-shadow: var(--focus-ring);
80
96
  }
81
- /* ── Disabled — always last, overrides primary/neutral icon color via cascade order ── */
97
+ /* ── Disabled — always last, overrides primary/neutral/inverse icon color via cascade order ── */
82
98
  .sds-icon-button:disabled .sds-icon-button__icon,
83
99
  .sds-icon-button[aria-disabled="true"] .sds-icon-button__icon {
84
100
  color: var(--color-neutral-40);
@@ -3,8 +3,11 @@
3
3
  *
4
4
  * Ported from kdls-design-system v2.5.1 (0f3edf8) components/IconButton/IconButton.tsx.
5
5
  * Divergence: none. `primary` was already the slot, not a Kenlo value.
6
+ *
7
+ * `inverse` came later, from kdls-design-system 2.10.0 (2f15769): a white glyph for a button
8
+ * over a photo or a dark surface — the Uploader's strip over a preview is the first.
6
9
  */
7
- export const ICON_BUTTON_VARIANTS = ['primary', 'neutral'];
10
+ export const ICON_BUTTON_VARIANTS = ['primary', 'neutral', 'inverse'];
8
11
  export const ICON_BUTTON_SIZES = ['sm', 'md', 'lg'];
9
12
 
10
13
  function assertOneOf(label, value, allowed) {
@@ -391,19 +391,50 @@
391
391
  .sds-input__hint--disabled { color: var(--color-neutral-40); }
392
392
  /* ── Textbox (textarea) variant ── */
393
393
  .sds-input--textbox {
394
- height: auto;
394
+ height: var(--sds-textarea-height, auto);
395
395
  min-height: var(--sds-textarea-min-height, 114px);
396
396
  align-items: flex-start;
397
- resize: vertical;
398
397
  overflow: hidden;
399
398
  }
400
- /* Field: fixed padding-top — guarantees constant height regardless of state */
399
+ /* Field: fixed padding-top — guarantees constant height regardless of state.
400
+ The textarea runs down to the bottom border, as in KDLS: past the last row the start of
401
+ the next line shows cut at the edge, so a longer text reads as one that goes on. */
401
402
  .sds-input--textbox .sds-input__field {
402
403
  min-height: 0;
403
404
  padding-top: var(--spacing-7);
404
- padding-bottom: var(--spacing-3);
405
+ padding-bottom: 0;
405
406
  align-self: stretch;
406
407
  }
408
+ /* The target grows with the corner radius so the rounded corner never eats into its 24px,
409
+ and the grip moves in with it so it stays inside the curve. */
410
+ .sds-input__resize-handle {
411
+ position: absolute;
412
+ right: 0;
413
+ bottom: 0;
414
+ width: max(var(--spacing-6), calc(var(--radius-md) * 0.5 + var(--spacing-4)));
415
+ height: max(var(--spacing-6), calc(var(--radius-md) * 0.5 + var(--spacing-4)));
416
+ display: flex;
417
+ align-items: flex-end;
418
+ justify-content: flex-end;
419
+ padding: 0 max(var(--spacing-1), calc(var(--radius-md) * 0.3)) max(var(--spacing-1), calc(var(--radius-md) * 0.3)) 0;
420
+ box-sizing: border-box;
421
+ color: var(--color-neutral-50);
422
+ cursor: ns-resize;
423
+ touch-action: none;
424
+ z-index: 1;
425
+ outline: none;
426
+ transition: color 120ms ease;
427
+ }
428
+ .sds-input__resize-handle:hover,
429
+ .sds-input--resizing .sds-input__resize-handle {
430
+ color: var(--color-primary-50-solid);
431
+ }
432
+ .sds-input__resize-handle:focus-visible {
433
+ box-shadow: var(--focus-ring-inset);
434
+ }
435
+ .sds-input--resizing {
436
+ user-select: none;
437
+ }
407
438
  .sds-input--textbox .sds-input__label {
408
439
  top: 26px;
409
440
  transform: translateY(-50%);
@@ -413,17 +444,29 @@
413
444
  .sds-input--filled.sds-input--textbox .sds-input__label,
414
445
  .sds-input--textbox:focus-within .sds-input__label {
415
446
  top: var(--spacing-2);
447
+ /* Reset the unfloated rule's translateY(-50%) above — same specificity, later in the
448
+ file, so it otherwise wins the tie and cancels `top`, pinning the label to the
449
+ field's edge instead of 8px below it. */
450
+ transform: none;
416
451
  font-size: var(--font-size-label-sm);
417
452
  line-height: var(--lh-label-sm);
418
453
  }
454
+ /* The textarea also runs over half of the field's right padding, so its scrollbar sits 8px
455
+ from the right border instead of 16px inside the text's room, as in KDLS. The gutter is
456
+ kept even without a bar, so the text ends at the same place with or without one. Its own
457
+ 8px at the end is the bottom room, reached when it scrolls to the end. */
419
458
  .sds-input__native--textarea {
459
+ box-sizing: border-box;
420
460
  padding-top: 0;
421
- padding-bottom: 0;
461
+ padding-right: 0;
462
+ padding-bottom: var(--spacing-2);
422
463
  resize: none;
423
464
  flex: 1;
424
465
  line-height: var(--lh-body-md);
425
466
  overflow-y: auto;
426
- width: 100%;
467
+ scrollbar-gutter: stable;
468
+ width: calc(100% + var(--spacing-2));
469
+ margin-right: calc(-1 * var(--spacing-2));
427
470
  }
428
471
  /* ── Dark mode ── */
429
472
  [data-theme="dark"] .sds-input__country {
@@ -47,6 +47,9 @@ import { assertInputType, assertFeedback, assertWidth, formatValue, nativeTypeFo
47
47
  import { validateOnFor, SUBMIT_EVENT } from "../Form/form-context.js";
48
48
  import { rehyphenate } from "../../foundations/attrs.js";
49
49
 
50
+ static const TEXTBOX_LINE = 24;
51
+ static const TEXTBOX_CHROME = 42;
52
+
50
53
  class {
51
54
  onCreate(input) {
52
55
  this.state = {
@@ -56,6 +59,8 @@ class {
56
59
  showPassword: false,
57
60
  error: null,
58
61
  touched: false,
62
+ textboxHeight: null,
63
+ resizing: false,
59
64
  };
60
65
  }
61
66
 
@@ -137,9 +142,58 @@ class {
137
142
  }
138
143
 
139
144
  onDestroy() {
145
+ this.stopResize?.();
140
146
  this.getEl("field-root")?.removeEventListener(SUBMIT_EVENT, this.onFormSubmit);
141
147
  }
142
148
 
149
+ get textboxMinHeight() {
150
+ return (this.input.rows ?? 3) * TEXTBOX_LINE + TEXTBOX_CHROME;
151
+ }
152
+
153
+ clampTextboxHeight(value) {
154
+ return Math.max(this.textboxMinHeight, Math.round(value));
155
+ }
156
+
157
+ currentTextboxHeight() {
158
+ return this.state.textboxHeight
159
+ ?? (this.getEl("box")?.getBoundingClientRect().height || this.textboxMinHeight);
160
+ }
161
+
162
+ startResize(event) {
163
+ if (event.button !== 0) return;
164
+ event.preventDefault();
165
+ const startY = event.clientY;
166
+ const startHeight = this.currentTextboxHeight();
167
+ this.state.resizing = true;
168
+
169
+ const onMove = (moveEvent) => {
170
+ this.state.textboxHeight = this.clampTextboxHeight(startHeight + moveEvent.clientY - startY);
171
+ };
172
+ const onUp = () => {
173
+ this.stopResize = null;
174
+ this.state.resizing = false;
175
+ document.removeEventListener("pointermove", onMove);
176
+ document.removeEventListener("pointerup", onUp);
177
+ document.removeEventListener("pointercancel", onUp);
178
+ };
179
+ this.stopResize = onUp;
180
+ document.addEventListener("pointermove", onMove);
181
+ document.addEventListener("pointerup", onUp);
182
+ document.addEventListener("pointercancel", onUp);
183
+ }
184
+
185
+ resizeByKey(event) {
186
+ const keys = {
187
+ ArrowDown: () => this.currentTextboxHeight() + TEXTBOX_LINE,
188
+ ArrowUp: () => this.currentTextboxHeight() - TEXTBOX_LINE,
189
+ Home: () => this.textboxMinHeight,
190
+ };
191
+ const next = keys[event.key];
192
+ if (!next) return;
193
+ event.preventDefault();
194
+ this.state.textboxHeight = this.clampTextboxHeight(next());
195
+ }
196
+
143
197
  get currentValue() {
144
198
  return this.input.value !== undefined ? this.input.value : this.state.value;
145
199
  }
@@ -306,6 +360,7 @@ $ {
306
360
  // KDLS marks only the textbox. A modifier per type produced ten classes no rule
307
361
  // matched — dead weight in every field's class attribute.
308
362
  type === "textbox" ? "sds-input--textbox" : "",
363
+ state.resizing ? "sds-input--resizing" : "",
309
364
  effectiveFeedback !== "none" ? `sds-input--feedback-${effectiveFeedback}` : "",
310
365
  disabled ? "sds-input--disabled" : "",
311
366
  loading ? "sds-input--loading" : "",
@@ -348,9 +403,13 @@ $ {
348
403
  // that reads it and lost the line that writes it. 24px a line plus the 42px the label
349
404
  // and padding take, which is KDLS's arithmetic.
350
405
  <div
406
+ key="box"
351
407
  class=fieldBoxClass
352
408
  aria-busy=(loading ? "true" : null)
353
- style=(isTextarea ? `--sds-textarea-min-height: ${rows * 24 + 42}px` : null)
409
+ style=(isTextarea
410
+ ? `--sds-textarea-min-height: ${component.textboxMinHeight}px`
411
+ + (state.textboxHeight !== null ? `; --sds-textarea-height: ${state.textboxHeight}px` : "")
412
+ : null)
354
413
  >
355
414
  <if(showsCountry)>
356
415
  <!-- The flag opens the list and the dial code sits beside it, which is the whole
@@ -465,6 +524,24 @@ $ {
465
524
  </button>
466
525
  </if>
467
526
 
527
+ <if(isTextarea && !disabled)>
528
+ <div
529
+ class="sds-input__resize-handle"
530
+ role="separator"
531
+ aria-orientation="horizontal"
532
+ aria-label="Redimensionar campo"
533
+ aria-valuemin=component.textboxMinHeight
534
+ aria-valuenow=(state.textboxHeight ?? component.textboxMinHeight)
535
+ tabindex="0"
536
+ on-pointerdown("startResize")
537
+ on-keydown("resizeByKey")
538
+ >
539
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
540
+ <path d="M9 1L1 9M9 5L5 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
541
+ </svg>
542
+ </div>
543
+ </if>
544
+
468
545
  <if(showsIncrementer)>
469
546
  <div class="sds-input__spinner">
470
547
  <button type="button" class="sds-input__spinner-btn" tabindex="-1"
@@ -80,7 +80,9 @@
80
80
  .sds-modal__body {
81
81
  flex: 1;
82
82
  overflow-y: auto;
83
- padding: 0 var(--spacing-6);
83
+ /* Vertical padding keeps a focused control's ring from clipping against the
84
+ scroll container's edge — the container has no padding to draw into. */
85
+ padding: var(--spacing-1) var(--spacing-6);
84
86
  }
85
87
  /* ── Footer: pt-[24px] pb-[24px] px-[24px] ── */
86
88
  .sds-modal__footer {
@@ -46,14 +46,78 @@
46
46
  bottom: var(--spacing-6);
47
47
  left: var(--spacing-6);
48
48
  z-index: var(--z-notice);
49
- display: flex;
50
- flex-direction: column;
51
- gap: var(--spacing-3);
52
49
  pointer-events: none;
53
50
  }
54
51
  .sds-toaster > * {
55
52
  pointer-events: auto;
56
53
  }
54
+ /* ── Pile: the front toast plus the edge of the next two (KDLS MR !67) ──
55
+ Each edge is 10px higher and 5% smaller; 10px sits between --spacing-2 and --spacing-3
56
+ and there is no token for a scale. The edges take the front toast's height from
57
+ inset: 0, so nothing is measured in script. */
58
+ .sds-toaster__close-all {
59
+ display: flex;
60
+ justify-content: center;
61
+ margin-bottom: var(--spacing-3);
62
+ }
63
+ .sds-toaster__stack--peek-1 .sds-toaster__close-all {
64
+ margin-bottom: calc(var(--spacing-3) + 10px);
65
+ }
66
+ .sds-toaster__stack--peek-2 .sds-toaster__close-all {
67
+ margin-bottom: calc(var(--spacing-3) + 20px);
68
+ }
69
+ .sds-toaster__list {
70
+ position: relative;
71
+ display: flex;
72
+ flex-direction: column;
73
+ gap: var(--spacing-3);
74
+ border-radius: var(--radius-md);
75
+ outline: none;
76
+ }
77
+ .sds-toaster__list--collapsed {
78
+ cursor: pointer;
79
+ }
80
+ .sds-toaster__list:focus-visible {
81
+ box-shadow: var(--focus-ring);
82
+ }
83
+ .sds-toaster__item {
84
+ position: relative;
85
+ z-index: 3;
86
+ transform-origin: 50% 0;
87
+ transition: transform 200ms ease;
88
+ }
89
+ .sds-toaster__item--depth-1,
90
+ .sds-toaster__item--depth-2,
91
+ .sds-toaster__item--depth-3 {
92
+ position: absolute;
93
+ inset: 0;
94
+ }
95
+ .sds-toaster__item--depth-1 {
96
+ z-index: 2;
97
+ transform: translateY(-10px) scale(0.95);
98
+ }
99
+ .sds-toaster__item--depth-2,
100
+ .sds-toaster__item--depth-3 {
101
+ z-index: 1;
102
+ transform: translateY(-20px) scale(0.9);
103
+ }
104
+ .sds-toaster__item--depth-3 {
105
+ z-index: 0;
106
+ opacity: 0;
107
+ }
108
+ .sds-toaster__item--depth-1 .sds-toast,
109
+ .sds-toaster__item--depth-2 .sds-toast,
110
+ .sds-toaster__item--depth-3 .sds-toast {
111
+ height: 100%;
112
+ }
113
+ .sds-toaster__item--depth-1 .sds-toast > *,
114
+ .sds-toaster__item--depth-2 .sds-toast > *,
115
+ .sds-toaster__item--depth-3 .sds-toast > * {
116
+ opacity: 0;
117
+ }
118
+ @media (prefers-reduced-motion: reduce) {
119
+ .sds-toaster__item { transition: none; }
120
+ }
57
121
  .sds-toast__icon {
58
122
  display: inline-flex;
59
123
  align-items: center;
@@ -65,6 +129,25 @@
65
129
  .sds-toast__message {
66
130
  flex: 1;
67
131
  }
132
+ /* ── Action: a line of its own below the message; the ✕ stays on the first line ── */
133
+ .sds-toast--with-action {
134
+ flex-wrap: wrap;
135
+ row-gap: 0;
136
+ }
137
+ .sds-toast--with-action .sds-toast__message {
138
+ min-width: 0;
139
+ overflow-wrap: break-word;
140
+ }
141
+ .sds-toast__action-row {
142
+ order: 1;
143
+ flex-basis: 100%;
144
+ box-sizing: border-box;
145
+ /* Icon (24px) + gap: the button starts where the text starts */
146
+ padding-left: calc(24px + var(--spacing-2));
147
+ }
148
+ .sds-toast--neutral .sds-toast__action-row {
149
+ padding-left: 0;
150
+ }
68
151
  .sds-toast__dismiss {
69
152
  flex-shrink: 0;
70
153
  color: inherit; /* <button> resets color; force inheritance from .sds-toast */
@@ -1,13 +1,18 @@
1
1
  /**
2
- * The list of live toasts, their timers, and who to tell when it changes.
2
+ * The list of live toasts, the timer of the one in front, and who to tell when it changes.
3
3
  *
4
- * Ported from kdls-design-system v2.5.1 (0f3edf8) components/Toast/toast-store.ts, which is
5
- * already framework-free — a module-level list and a set of listeners. That is what lets
4
+ * Ported from kdls-design-system components/Toast/toast-store.ts — first from v2.5.1
5
+ * (0f3edf8), then the pile from MR !67 (ai-fix/kdls-toast-pilha, 8df83d3). It is already
6
+ * framework-free — a module-level list and a set of listeners. That is what lets
6
7
  * `toast(...)` be called from anywhere, including from a catch block that is nowhere near a
7
8
  * component, and it survives the move from React to Marko untouched in behaviour.
8
9
  *
9
- * The only change is the class name in the comment below, because the exit animation it
10
- * points at is now sds-toast--leaving.
10
+ * Only the front toast — the oldest one still on screen — counts down. The next one starts
11
+ * its own time when the front one leaves: several toasts fired at once pile up, and if they
12
+ * all ran together they would all vanish together, before anyone read the ones behind.
13
+ *
14
+ * The only other change is the class name in the comment below, because the exit animation
15
+ * it points at is now sds-toast--leaving.
11
16
  */
12
17
 
13
18
  /** Fixed by the design system: 4s, or 8s when the visitor needs time to read a failure. */
@@ -20,7 +25,9 @@ export const TOAST_EXIT_MS = 220;
20
25
  let toasts = [];
21
26
  let seq = 0;
22
27
  const listeners = new Set();
23
- const running = new Map();
28
+ /** The front toast's time: { id, startedAt, remaining, timeout }. `timeout` is unset while paused. */
29
+ let countdown = null;
30
+ let paused = false;
24
31
 
25
32
  function emit() {
26
33
  const snapshot = [...toasts];
@@ -37,38 +44,53 @@ export function subscribe(listener) {
37
44
  return () => { listeners.delete(listener); };
38
45
  }
39
46
 
40
- function arm(id, ms) {
41
- const timeout = setTimeout(() => startLeaving(id), ms);
42
- running.set(id, { timeout, startedAt: Date.now(), remaining: ms });
47
+ function run() {
48
+ if (!countdown || paused) return;
49
+ const { id } = countdown;
50
+ countdown.startedAt = Date.now();
51
+ countdown.timeout = setTimeout(() => startLeaving(id), countdown.remaining);
52
+ }
53
+
54
+ /** Starts the time of the front toast, when it is not counting yet. */
55
+ function armFront() {
56
+ if (countdown) return;
57
+ const front = toasts.find((t) => !t.leaving);
58
+ if (!front) return;
59
+ countdown = {
60
+ id: front.id,
61
+ startedAt: Date.now(),
62
+ remaining: front.feedback === 'error' ? DURATION_ERROR : DURATION_DEFAULT,
63
+ };
64
+ run();
43
65
  }
44
66
 
45
67
  function startLeaving(id) {
46
68
  const item = toasts.find((t) => t.id === id);
47
69
  if (!item || item.leaving) return;
48
- const timer = running.get(id);
49
- if (timer) {
50
- clearTimeout(timer.timeout);
51
- running.delete(id);
70
+ if (countdown?.id === id) {
71
+ clearTimeout(countdown.timeout);
72
+ countdown = null;
52
73
  }
53
74
  toasts = toasts.map((t) => (t.id === id ? { ...t, leaving: true } : t));
75
+ armFront();
54
76
  emit();
55
77
  setTimeout(() => remove(id), TOAST_EXIT_MS);
56
78
  }
57
79
 
58
80
  function remove(id) {
59
81
  toasts = toasts.filter((t) => t.id !== id);
82
+ // The pile left from under the pointer, so no mouseleave will come to resume.
83
+ if (toasts.length === 0) paused = false;
60
84
  emit();
61
85
  }
62
86
 
63
- /** Adds a toast and schedules its own dismissal. Returns the id. */
87
+ /** Adds a toast at the end of the line. Returns the id. */
64
88
  export function addToast(message, opts = {}) {
65
89
  const id = ++seq;
66
- const feedback = opts.feedback ?? 'neutral';
67
- const duration = feedback === 'error' ? DURATION_ERROR : DURATION_DEFAULT;
68
90
  const item = {
69
91
  id,
70
92
  message,
71
- feedback,
93
+ feedback: opts.feedback ?? 'neutral',
72
94
  actionLabel: opts.actionLabel,
73
95
  onAction: opts.onAction,
74
96
  actionDisabled: opts.actionDisabled,
@@ -78,8 +100,8 @@ export function addToast(message, opts = {}) {
78
100
  leaving: false,
79
101
  };
80
102
  toasts = [...toasts, item];
103
+ armFront();
81
104
  emit();
82
- arm(id, duration);
83
105
  return id;
84
106
  }
85
107
 
@@ -88,30 +110,37 @@ export function dismissToast(id) {
88
110
  startLeaving(id);
89
111
  }
90
112
 
113
+ /** Closes every toast on screen at once — the pile's "Fechar todos". */
114
+ export function dismissAll() {
115
+ toasts.filter((t) => !t.leaving).forEach((t) => startLeaving(t.id));
116
+ }
117
+
91
118
  /**
92
- * Holds every timer where it is. The container calls this on hover: a toast that vanishes
93
- * while someone is reaching for its action is a toast that wasted their time.
119
+ * Holds the time where it is, including the turn of whichever toast comes forward next.
120
+ * The container calls this on hover: a toast that vanishes while someone is reaching for
121
+ * its action is a toast that wasted their time.
94
122
  */
95
123
  export function pauseAll() {
96
- running.forEach((timer) => {
97
- clearTimeout(timer.timeout);
98
- const elapsed = Date.now() - timer.startedAt;
99
- timer.remaining = Math.max(0, timer.remaining - elapsed);
100
- });
124
+ if (paused) return;
125
+ paused = true;
126
+ if (!countdown?.timeout) return;
127
+ clearTimeout(countdown.timeout);
128
+ countdown.timeout = undefined;
129
+ countdown.remaining = Math.max(0, countdown.remaining - (Date.now() - countdown.startedAt));
101
130
  }
102
131
 
103
- /** Starts them again with whatever time they had left. */
132
+ /** Starts it again with whatever time was left. */
104
133
  export function resumeAll() {
105
- running.forEach((timer, id) => {
106
- timer.timeout = setTimeout(() => startLeaving(id), timer.remaining);
107
- timer.startedAt = Date.now();
108
- });
134
+ if (!paused) return;
135
+ paused = false;
136
+ run();
109
137
  }
110
138
 
111
139
  /** Tests only: the list is module state and outlives them. */
112
140
  export function resetToasts() {
113
- running.forEach((timer) => clearTimeout(timer.timeout));
114
- running.clear();
141
+ if (countdown) clearTimeout(countdown.timeout);
142
+ countdown = null;
143
+ paused = false;
115
144
  toasts = [];
116
145
  seq = 0;
117
146
  emit();
@@ -9,6 +9,11 @@
9
9
  surface because KDLS put it there, marked internal for the same reason: nothing else
10
10
  should set it.
11
11
 
12
+ The action button sits on a line of its own, below the message and aligned with it,
13
+ as KDLS has it since MR !67 (ai-fix/kdls-toast-pilha, 8df83d3): beside the message it
14
+ left the text 136px and a short sentence ran to four or six lines; below it the text
15
+ has the full 256px, and the ✕ stays on the first line, on the right.
16
+
12
17
  Divergence: none in behaviour. The four hover and active overlays were rgba literals
13
18
  in KDLS's stylesheet, which is what --color-opacity-inverse-* now holds — the regular
14
19
  opacity family follows the theme and is backwards on a surface that runs against it. -->
@@ -49,6 +54,7 @@ $ {
49
54
  const classes = [
50
55
  "sds-toast",
51
56
  `sds-toast--${feedback}`,
57
+ actionLabel ? "sds-toast--with-action" : "",
52
58
  leaving ? "sds-toast--leaving" : "",
53
59
  extraClass,
54
60
  ].filter(Boolean).join(" ");
@@ -68,15 +74,17 @@ $ {
68
74
  <span class="sds-toast__message">${message}</span>
69
75
 
70
76
  <if(actionLabel)>
71
- <sds-button
72
- variant="link"
73
- label=actionLabel
74
- onClick=onAction
75
- disabled=actionDisabled
76
- disabledTooltip=actionDisabledTooltip
77
- disabledTooltipPosition=actionDisabledTooltipPosition
78
- class="sds-toast__action"
79
- />
77
+ <div class="sds-toast__action-row">
78
+ <sds-button
79
+ variant="link"
80
+ label=actionLabel
81
+ onClick=onAction
82
+ disabled=actionDisabled
83
+ disabledTooltip=actionDisabledTooltip
84
+ disabledTooltipPosition=actionDisabledTooltipPosition
85
+ class="sds-toast__action"
86
+ />
87
+ </div>
80
88
  </if>
81
89
 
82
90
  <if(showDismiss)>
@@ -1,11 +1,25 @@
1
- <!-- sds-toaster — ported from kdls-design-system v2.5.1 (0f3edf8) components/Toast/Toaster.tsx.
1
+ <!-- sds-toaster — ported from kdls-design-system v2.5.1 (0f3edf8) components/Toast/Toaster.tsx,
2
+ and the pile from MR !67 (ai-fix/kdls-toast-pilha, 8df83d3).
2
3
 
3
4
  Mount once at the root of the page. It subscribes to the store and renders whatever
4
- toast(...) has fired, stacked in the corner. No attributes: the position is fixed and
5
- the timing belongs to the store.
5
+ toast(...) has fired, in the corner. No attributes: the position is fixed and the
6
+ timing belongs to the store.
6
7
 
7
- Hovering the stack pauses every timer. A toast that vanishes while someone is reaching
8
- for its action has wasted their time, and KDLS gets this right — it is carried as is.
8
+ With more than one toast they pile up: only the first to arrive is shown whole, with
9
+ the edge of the next two above it, each 10px higher and 5% smaller; from the fourth on
10
+ nothing shows. A click on the pile (or Enter, with the focus on it) opens it into a
11
+ column, 12px apart, the newest at the bottom; a click outside or Esc closes it. A click
12
+ on a toast's ✕ or action does not open it. With the pointer over it, the keyboard focus
13
+ on it, or the pile open, a "Fechar todos" button sits centred above it. One toast per
14
+ problem is the rule this exists for: seven refused photos in a column took 85% of a
15
+ laptop screen, and all seven left at once.
16
+
17
+ Hovering pauses the time, including the turn of whichever toast comes forward next. A
18
+ toast that vanishes while someone is reaching for its action has wasted their time, and
19
+ KDLS gets this right — it is carried as is.
20
+
21
+ Keyboard focus that enters a toast behind the front one opens the pile, so the caret
22
+ never lands on a button nobody can see.
9
23
 
10
24
  Divergences:
11
25
 
@@ -17,54 +31,168 @@
17
31
 
18
32
  It renders nothing at all until the store has something, rather than an empty
19
33
  container: an empty <div role="region" aria-label="Notificações"> is a landmark a
20
- screen reader offers to navigate to and finds nothing inside. -->
34
+ screen reader offers to navigate to and finds nothing inside.
35
+
36
+ The listeners for a click outside and for Esc live on the document for as long as the
37
+ toaster is mounted and do nothing while the pile is closed. KDLS adds them only while
38
+ it is open; here that would mean re-binding on a state change, and the cost of an idle
39
+ check per key press is nothing. -->
40
+
41
+ import { subscribe, dismissToast, dismissAll, pauseAll, resumeAll } from "./toast-store.js";
21
42
 
22
- import { subscribe, dismissToast, pauseAll, resumeAll } from "./toast-store.js";
43
+ static const HIDDEN_DEPTH = 3;
44
+
45
+ static function itemClass(index, collapsed) {
46
+ const depth = collapsed ? Math.min(index, HIDDEN_DEPTH) : 0;
47
+ return ["sds-toaster__item", depth ? `sds-toaster__item--depth-${depth}` : ""]
48
+ .filter(Boolean).join(" ");
49
+ }
50
+
51
+ static function isFocusVisible(el) {
52
+ try {
53
+ return el.matches(":focus-visible");
54
+ } catch {
55
+ return true;
56
+ }
57
+ }
23
58
 
24
59
  class {
25
60
  onCreate() {
26
- this.state = { items: [] };
61
+ this.state = { items: [], expanded: false, hovered: false, keyboardFocus: false };
27
62
  }
28
63
 
29
64
  onMount() {
30
65
  // The store calls back immediately with what it already holds, so a toaster mounted
31
66
  // after a toast was fired still shows it.
32
- this.unsubscribe = subscribe((items) => { this.state.items = items; });
67
+ this.unsubscribe = subscribe((items) => {
68
+ this.state.items = items;
69
+ if (items.length < 2) this.state.expanded = false;
70
+ if (!items.length) {
71
+ this.state.hovered = false;
72
+ this.state.keyboardFocus = false;
73
+ }
74
+ });
75
+
76
+ this.onDocumentMouseDown = (event) => {
77
+ if (!this.state.expanded) return;
78
+ // Marko replaces nodes on re-render: look the pile up now, never keep a reference.
79
+ const stack = this.getEl("stack");
80
+ if (!stack || !stack.contains(event.target)) this.state.expanded = false;
81
+ };
82
+ // Capture: the open pile is the top layer, so its Esc runs before a Drawer's or Modal's.
83
+ this.onDocumentKeyDown = (event) => {
84
+ if (!this.state.expanded || event.key !== "Escape") return;
85
+ event.preventDefault();
86
+ this.state.expanded = false;
87
+ };
88
+ document.addEventListener("mousedown", this.onDocumentMouseDown);
89
+ document.addEventListener("keydown", this.onDocumentKeyDown, true);
33
90
  }
34
91
 
35
92
  onDestroy() {
36
93
  this.unsubscribe?.();
94
+ document.removeEventListener("mousedown", this.onDocumentMouseDown);
95
+ document.removeEventListener("keydown", this.onDocumentKeyDown, true);
96
+ }
97
+
98
+ get collapsed() {
99
+ return this.state.items.length > 1 && !this.state.expanded;
100
+ }
101
+
102
+ enter() {
103
+ this.state.hovered = true;
104
+ pauseAll();
105
+ }
106
+
107
+ leave() {
108
+ this.state.hovered = false;
109
+ resumeAll();
110
+ }
111
+
112
+ focusIn(event) {
113
+ const target = event.target;
114
+ this.state.keyboardFocus = target === this.getEl("list") || isFocusVisible(target);
115
+ if (this.collapsed && target.closest('[class*="sds-toaster__item--depth-"]')) {
116
+ this.state.expanded = true;
117
+ }
118
+ }
119
+
120
+ focusOut(event) {
121
+ const stack = this.getEl("stack");
122
+ if (!stack || !stack.contains(event.relatedTarget)) this.state.keyboardFocus = false;
37
123
  }
38
124
 
39
- pause() { pauseAll(); }
40
- resume() { resumeAll(); }
125
+ open(event) {
126
+ if (this.collapsed && !event.target.closest("button")) this.state.expanded = true;
127
+ }
128
+
129
+ openByKey(event) {
130
+ if (this.collapsed && event.key === "Enter" && event.target === this.getEl("list")) {
131
+ this.state.expanded = true;
132
+ }
133
+ }
134
+
135
+ closeAll() { dismissAll(); }
41
136
  dismiss(id) { dismissToast(id); }
42
137
  }
43
138
 
44
- <if(state.items.length)>
45
- <div
46
- class="sds-toaster"
47
- role="region"
48
- aria-label="Notificações"
49
- on-mouseenter("pause")
50
- on-mouseleave("resume")
51
- >
52
- <!-- `key` on each toast, so a dismissal removes the right node rather than shifting
53
- the list up underneath the animation. -->
54
- <for|item| of=state.items>
55
- <sds-toast
56
- key=item.id
57
- message=item.message
58
- feedback=item.feedback
59
- actionLabel=item.actionLabel
60
- onAction=item.onAction
61
- actionDisabled=item.actionDisabled
62
- actionDisabledTooltip=item.actionDisabledTooltip
63
- actionDisabledTooltipPosition=item.actionDisabledTooltipPosition
64
- showDismiss=item.showDismiss
65
- leaving=item.leaving
66
- onDismiss=() => component.dismiss(item.id)
67
- />
68
- </for>
139
+ $ {
140
+ const { items, expanded, hovered, keyboardFocus } = state;
141
+ const stacked = items.length > 1;
142
+ const collapsed = stacked && !expanded;
143
+ const peek = collapsed ? Math.min(items.length - 1, 2) : 0;
144
+ const showCloseAll = stacked && (hovered || keyboardFocus || expanded);
145
+ const stackClass = ["sds-toaster__stack", peek ? `sds-toaster__stack--peek-${peek}` : ""]
146
+ .filter(Boolean).join(" ");
147
+ const listClass = ["sds-toaster__list", collapsed ? "sds-toaster__list--collapsed" : ""]
148
+ .filter(Boolean).join(" ");
149
+ }
150
+
151
+ <if(items.length)>
152
+ <div class="sds-toaster" role="region" aria-label="Notificações">
153
+ <div
154
+ key="stack"
155
+ class=stackClass
156
+ on-mouseenter("enter")
157
+ on-mouseleave("leave")
158
+ on-focusin("focusIn")
159
+ on-focusout("focusOut")
160
+ >
161
+ <if(showCloseAll)>
162
+ <div class="sds-toaster__close-all">
163
+ <sds-button variant="pill" label="Fechar todos" onClick=() => component.closeAll()/>
164
+ </div>
165
+ </if>
166
+ <div
167
+ key="list"
168
+ class=listClass
169
+ role=(stacked ? "group" : null)
170
+ aria-label=(stacked ? `${items.length} notificações` : null)
171
+ aria-expanded=(stacked ? String(expanded) : null)
172
+ tabindex=(stacked ? "0" : null)
173
+ on-click("open")
174
+ on-keydown("openByKey")
175
+ >
176
+ <!-- `key` on each toast, so a dismissal removes the right node rather than shifting
177
+ the list up underneath the animation. -->
178
+ <for|item, index| of=items>
179
+ <div key=`item-${item.id}` class=itemClass(index, collapsed)>
180
+ <sds-toast
181
+ key=item.id
182
+ message=item.message
183
+ feedback=item.feedback
184
+ actionLabel=item.actionLabel
185
+ onAction=item.onAction
186
+ actionDisabled=item.actionDisabled
187
+ actionDisabledTooltip=item.actionDisabledTooltip
188
+ actionDisabledTooltipPosition=item.actionDisabledTooltipPosition
189
+ showDismiss=item.showDismiss
190
+ leaving=item.leaving
191
+ onDismiss=() => component.dismiss(item.id)
192
+ />
193
+ </div>
194
+ </for>
195
+ </div>
196
+ </div>
69
197
  </div>
70
198
  </if>
@@ -6,11 +6,15 @@
6
6
  [data-color-scheme="lya"] .sds-uploader--dragging
7
7
  */
8
8
 
9
- /* Three literals snapped to the system. The overlay that dims a preview is --color-scrim,
10
- black in both themes for the same reason the modal's veil is. The two hover states on it
11
- sit on that dark overlay rather than on the page, so they read --color-opacity-inverse-*,
12
- the family that runs against the theme — the ordinary one would draw a dark veil on an
13
- already dark surface. The alphas move from .15/.25 to the .10/.20 the scale offers. *//* ============================================================
9
+ /* The strip that dims a preview is black at 60%, in both themes, built from --color-black
10
+ the way --color-scrim is. Not the scrim itself: that is 70%, a modal's veil over a whole
11
+ page, and every overlay that covers the page reads it — Rafael set the strip lighter, the
12
+ same value KDLS uses. The crop editor's mask below is a veil, and keeps the scrim.
13
+
14
+ Its three buttons are sds-icon-button variant="inverse": the white glyph and the hover
15
+ and press tints come from the button itself, never from rules here. The port carried
16
+ three such rules aimed at --neutral and --24, classes these buttons never had, so none
17
+ applied and the glyphs came out in the primary slot. *//* ============================================================
14
18
  SDS Uploader
15
19
  ============================================================ */
16
20
 
@@ -92,16 +96,7 @@
92
96
  justify-content: flex-end;
93
97
  gap: var(--spacing-1);
94
98
  padding: var(--spacing-2) var(--spacing-3);
95
- background: var(--color-scrim);
96
- }
97
- .sds-uploader__overlay .sds-icon-button--neutral .sds-icon-button__icon {
98
- color: var(--color-white);
99
- }
100
- .sds-uploader__overlay .sds-icon-button--24:hover:not(:disabled)::before {
101
- background: var(--color-opacity-inverse-10);
102
- }
103
- .sds-uploader__overlay .sds-icon-button--24:active:not(:disabled)::before {
104
- background: var(--color-opacity-inverse-20);
99
+ background: color-mix(in srgb, var(--color-black) 60%, transparent);
105
100
  }
106
101
  /* ── Edit modal body ── */
107
102
  .sds-uploader__edit-body {
@@ -167,15 +167,15 @@ $ {
167
167
  <div class=`sds-uploader sds-uploader--filled ${extraClass}`.trim()>
168
168
  <img src=value alt=previewAlt class="sds-uploader__preview"/>
169
169
  <div class="sds-uploader__overlay">
170
- <sds-icon-button aria-label=removeLabel onClick=() => component.remove()>
170
+ <sds-icon-button variant="inverse" aria-label=removeLabel onClick=() => component.remove()>
171
171
  <@icon><${deleteIcon}/></@icon>
172
172
  </sds-icon-button>
173
173
  <if(onDownload)>
174
- <sds-icon-button aria-label=downloadLabel onClick=onDownload>
174
+ <sds-icon-button variant="inverse" aria-label=downloadLabel onClick=onDownload>
175
175
  <@icon><${downloadIcon}/></@icon>
176
176
  </sds-icon-button>
177
177
  </if>
178
- <sds-icon-button aria-label=editLabel onClick=() => component.openEditor()>
178
+ <sds-icon-button variant="inverse" aria-label=editLabel onClick=() => component.openEditor()>
179
179
  <@icon><${cropIcon}/></@icon>
180
180
  </sds-icon-button>
181
181
  </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kenlo/sds-design-system",
3
- "version": "1.0.2",
3
+ "version": "1.1.2",
4
4
  "description": "SDS, the Site Design System \u2014 Marko 5 components for Kenlo's public products (Site, Landing Page, Blog)",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {