@kenlo/sds-design-system 0.9.9 → 1.0.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.
Files changed (45) hide show
  1. package/README.md +58 -7
  2. package/components/Avatar/Avatar.css +22 -0
  3. package/components/Button/Button.css +95 -8
  4. package/components/Card/Card.css +16 -6
  5. package/components/Card/card-variants.js +18 -0
  6. package/components/Card/card.marko +12 -2
  7. package/components/Checkbox/Checkbox.css +56 -0
  8. package/components/ColorPicker/ColorPicker.css +25 -2
  9. package/components/ColorPicker/color-picker.marko +25 -4
  10. package/components/DatePicker/DatePicker.css +22 -0
  11. package/components/Floating/focus-when-ready.js +59 -0
  12. package/components/Incrementer/Incrementer.css +22 -0
  13. package/components/Input/Input.css +25 -2
  14. package/components/Menu/Menu.css +21 -11
  15. package/components/Menu/menu-variants.js +12 -0
  16. package/components/Menu/menu.marko +11 -7
  17. package/components/Pill/Pill.css +46 -0
  18. package/components/Radio/Radio.css +75 -0
  19. package/components/Select/Select.css +21 -1
  20. package/components/Select/select-options.js +10 -1
  21. package/components/Select/select.marko +112 -5
  22. package/components/Table/Table.css +73 -2
  23. package/components/Table/table-column-picker.marko +161 -0
  24. package/components/Table/table-logic.js +56 -0
  25. package/components/Table/table.marko +50 -12
  26. package/components/TextEditor/TextEditor.css +2 -2
  27. package/components/Toggle/Toggle.css +40 -0
  28. package/components/Tooltip/Tooltip.css +2 -2
  29. package/components/Uploader/Uploader.css +23 -0
  30. package/foundations/custom-scheme.js +128 -10
  31. package/foundations/fonts/OFL.txt +114 -0
  32. package/foundations/fonts/fonts.json +152 -0
  33. package/foundations/fonts/manrope-cyrillic-ext.woff2 +0 -0
  34. package/foundations/fonts/manrope-cyrillic.woff2 +0 -0
  35. package/foundations/fonts/manrope-greek.woff2 +0 -0
  36. package/foundations/fonts/manrope-latin-ext.woff2 +0 -0
  37. package/foundations/fonts/manrope-latin.woff2 +0 -0
  38. package/foundations/fonts/manrope-vietnamese.woff2 +0 -0
  39. package/foundations/tokens.css +1095 -15
  40. package/icons/generated/fluent-chevron-down-filled.marko +3 -0
  41. package/icons/generated/fluent-chevron-up-filled.marko +3 -0
  42. package/icons/generated/fluent-column-triple-regular.marko +3 -0
  43. package/icons/manifest.json +3 -0
  44. package/marko.json +3 -0
  45. package/package.json +8 -3
package/README.md CHANGED
@@ -37,6 +37,12 @@ import "@kenlo/sds-design-system/components/Button/Button.css";
37
37
  As tags não precisam de import. O `marko.json` na raiz do pacote é o catálogo: o compilador
38
38
  do Marko acha `<sds-button>` sozinho.
39
39
 
40
+ **A tipografia vem junto no passo 1, e não há passo para esquecer.** A Manrope — a fonte
41
+ padrão — viaja dentro do pacote, e as regras `@font-face` estão no próprio `tokens.css`,
42
+ que é o arquivo que declara qual fonte usar. Nada é pedido ao Google enquanto a página
43
+ carrega: o visitante do site é cliente da imobiliária, e o IP dele não sai daqui. As outras
44
+ cinco opções de `data-font` estão descritas em **Tema**, logo abaixo.
45
+
40
46
  ```marko
41
47
  <sds-button variant="primary" onClick=salvar>Salvar</sds-button>
42
48
 
@@ -61,6 +67,32 @@ Quatro atributos no `<html>` decidem a aparência inteira. Um atributo no valor
61
67
  | `data-radius` | `flat`, `soft`, `medium`, `rounded` | `soft` |
62
68
  | `data-font` | `manrope`, `inter`, `helvetica`, `source-sans`, `nunito`, `montserrat` | `manrope` |
63
69
 
70
+ **De onde cada fonte vem**, porque isso decide o que o visitante baixa:
71
+
72
+ | | onde mora | o que o visitante baixa |
73
+ |---|---|---|
74
+ | `manrope` (padrão) | dentro do pacote | ~24 KB — só o subset do idioma dele |
75
+ | `helvetica` | na máquina do visitante | nada |
76
+ | as outras quatro | bucket da Kenlo, publicado pelo KDLS | só o subset do idioma, e só se você escolher |
77
+
78
+ A Manrope é embarcada de propósito: a fonte padrão não pode depender de rede nenhuma além
79
+ da do próprio app — se o bucket cair, nenhum produto perde a tipografia junto. As opcionais
80
+ ficam fora do pacote pelo motivo oposto: os bytes delas não devem pesar em quem nunca
81
+ escolhe nenhuma.
82
+
83
+ > **As quatro opcionais carregam desde 21/09**, quando o CORS entrou no bucket. Conferido
84
+ > aqui com `npm run check:fonts`: 24 de 24 utilizáveis, e num navegador limpo as quatro
85
+ > famílias renderizam.
86
+ >
87
+ > **Com uma ressalva que vale saber.** Entre a publicação do bucket e o conserto do CORS,
88
+ > houve uma janela em que os arquivos respondiam sem o cabeçalho. Um navegador que passou
89
+ > por uma dessas URLs nessa janela **guardou a resposta ruim** — e elas são servidas
90
+ > `immutable`, com um ano de cache, então ele não revalida. Essa pessoa continua vendo o
91
+ > fallback, e consertar o servidor não a alcança: só um nome de arquivo novo alcança.
92
+ >
93
+ > A `manrope` e a `helvetica` nunca dependeram disso — e essa é exatamente a razão de a
94
+ > padrão vir embarcada.
95
+
64
96
  Há duas portas para escrevê-los, e a diferença importa:
65
97
 
66
98
  ```js
@@ -80,20 +112,39 @@ visitante. A metade servidor existe para evitar isso.
80
112
 
81
113
  ## Cor do cliente
82
114
 
83
- Cada site tem a sua cor, e ela entra em tempo de execução:
115
+ Os dez esquemas da tabela acima são os prontos. Um site com marca própria usa as **suas**
116
+ cores, e uma marca tem duas: um **destaque** e uma **complementar**. As duas entram em tempo
117
+ de execução, e há três jeitos de usá-las:
118
+
119
+ | | destaque | complementar |
120
+ |---|---|---|
121
+ | **1 sólida** | preenche o slot primário | não é aplicada |
122
+ | **2 sólidas** | preenche o slot primário | pinta o botão `ghost`, e nada mais |
123
+ | **gradiente** | primeiro stop | último stop |
84
124
 
85
125
  ```js
86
- import { applyCustomColor, applyCustomGradient, clearCustomScheme }
126
+ import { applyCustomColor, applyComplementColor, applyCustomGradient,
127
+ clearComplementColor, clearCustomScheme }
87
128
  from "@kenlo/sds-design-system/foundations/custom-scheme.js";
88
129
 
89
- applyCustomColor("#2E7D32"); // nove tons sólidos
90
- applyCustomGradient(["#77A6E0", "#BF71E5"]); // nove gradientes
91
- clearCustomScheme();
130
+ applyCustomColor("#2E7D32"); // nove tons sólidos no slot primário
131
+ applyComplementColor("#BF71E5"); // nove tons em --color-complement-10..90
132
+ applyCustomGradient(["#2E7D32", "#BF71E5"]); // nove gradientes
133
+ clearComplementColor(); // derruba só a complementar
134
+ clearCustomScheme(); // derruba as duas
92
135
  ```
93
136
 
94
- As duas preenchem `--color-primary-10..90`, os companheiros `-solid` e o
137
+ O destaque preenche `--color-primary-10..90`, os companheiros `-solid` e o
95
138
  `--color-on-primary` — este último escolhido por luminância relativa (WCAG), para o texto
96
- sobre a cor ficar legível seja ela qual for. Uma chamada substitui a outra; nunca empilham.
139
+ sobre a cor ficar legível seja ela qual for.
140
+
141
+ **A complementar pinta um controle só: o botão `ghost`**, que é o contornado. É decisão, não
142
+ limitação — uma segunda cor solta no sistema é como o sistema deixa de ter uma cor.
143
+
144
+ E ela é uma **camada**, não um terceiro modo: mora no seu próprio `<style>`, então trocar o
145
+ destaque não a derruba junto. Enquanto você não definir nenhuma, o ghost fica exatamente
146
+ como sempre foi — a leitura é `var(--color-complement-50, var(--color-primary-50-solid))`,
147
+ o fallback responde, e nada muda para quem nunca usar isso.
97
148
 
98
149
  ---
99
150
 
@@ -108,3 +108,25 @@
108
108
  object-fit: cover;
109
109
  border-radius: inherit;
110
110
  }
111
+
112
+ /* ── The site's gradient, on the stroke ────────────────────────────────────────
113
+
114
+ `border-color` rejects a gradient, so a stroke normally reads the `-solid` companion —
115
+ the gradient's first stop, flat. These rules paint it properly instead: the gradient on
116
+ `border-box`, the surface colour on `padding-box`, over a transparent border. Only the
117
+ border's own 2px show the gradient, and `border-radius` still works, which is what
118
+ `border-image` would cost.
119
+
120
+ `[data-primary-gradient]` is written by `applyCustomGradient` and removed by
121
+ `applyCustomColor` — a stylesheet cannot ask what a custom property contains, so the
122
+ code that fills the slot says so. KDLS wrote the same rules under
123
+ `[data-color-scheme="lya"]`, its own brand gradient; SDS deleted every `lya` block with
124
+ the brand and these went with them. Keyed on the marker they serve any site's gradient.
125
+ ─────────────────────────────────────────────────────────────────────────── */
126
+
127
+ [data-primary-gradient] .sds-avatar--clickable:hover {
128
+ background:
129
+ linear-gradient(var(--component-bg-color), var(--component-bg-color)) padding-box,
130
+ var(--color-primary-50) border-box;
131
+ border-color: transparent;
132
+ }
@@ -94,15 +94,32 @@
94
94
  VARIANT: primary (filled)
95
95
  Uses --color-primary-* → follows data-color-scheme
96
96
  ────────────────────────────────────────────── */
97
+ /* `background-origin` travels with every one of these, and it has to.
98
+
99
+ `.sds-button` carries `border: 2px solid transparent`, so a primary button comes out the
100
+ same size as a ghost one, which has a visible border. With a solid colour that border is
101
+ invisible: the colour paints the whole border box. With a GRADIENT — and the primary slot
102
+ is allowed to hold one — the background is sized to the box INSIDE the border, painted
103
+ over the border anyway, and the 2px strip is filled with the next tile: the left edge
104
+ shows the colour from the far right, all the way round. Rafael saw it as a strange stroke
105
+ on the picker's "Aplicar".
106
+
107
+ Declaring it once on `.sds-button` does not work, and that is the trap: the `background`
108
+ shorthand RESETS `background-origin` to `padding-box`, and the shorthand is what every
109
+ rule below uses — because the slot may hold a colour or a gradient and only the shorthand
110
+ takes both. So it is repeated wherever the shorthand appears. */
97
111
  .sds-button--primary {
98
112
  background: var(--color-primary-50);
113
+ background-origin: border-box;
99
114
  color: var(--color-on-primary);
100
115
  }
101
116
  .sds-button--primary:hover:not(:disabled):not(.sds-button--loading) {
102
117
  background: var(--color-primary-60);
118
+ background-origin: border-box;
103
119
  }
104
120
  .sds-button--primary:active:not(:disabled):not(.sds-button--loading) {
105
121
  background: var(--color-primary-70);
122
+ background-origin: border-box;
106
123
  }
107
124
  .sds-button--primary:focus-visible {
108
125
  box-shadow: var(--focus-ring);
@@ -110,20 +127,37 @@
110
127
  /* ──────────────────────────────────────────────
111
128
  VARIANT: ghost (outlined / ghost)
112
129
  ────────────────────────────────────────────── */
130
+ /* The outlined control is the one thing the site's COMPLEMENTARY colour paints.
131
+
132
+ A brand has a highlight and a complement. The highlight fills the primary slot and
133
+ paints everything; the complement paints this. The product that consumes SDS already
134
+ models both, and until now the design system could only receive the first.
135
+
136
+ Every declaration reads the complement THROUGH a fallback to the primary tone it used
137
+ to read, and to the same tone — never a neighbouring one. So a consumer that sets no
138
+ complement gets this button exactly as it has always been, and clearing the complement
139
+ reverts it with nothing that has to know how to undo anything.
140
+
141
+ The primary fallback keeps its `-solid` companion because the primary slot may hold a
142
+ gradient and `color` and `border-color` reject one. The complement never needs the
143
+ twin: that slot is solid by definition, and in gradient mode it is not used at all.
144
+ See foundations/custom-scheme.js, applyComplementColor. */
113
145
  .sds-button--ghost {
114
146
  background-color: transparent;
115
- border-color: var(--color-primary-50-solid);
116
- color: var(--color-primary-50-solid);
147
+ border-color: var(--color-complement-50, var(--color-primary-50-solid));
148
+ color: var(--color-complement-50, var(--color-primary-50-solid));
117
149
  }
118
150
  .sds-button--ghost:hover:not(:disabled):not(.sds-button--loading) {
119
- background: var(--color-primary-10);
120
- border-color: var(--color-primary-60-solid);
121
- color: var(--color-primary-60-solid);
151
+ background: var(--color-complement-10, var(--color-primary-10));
152
+ background-origin: border-box;
153
+ border-color: var(--color-complement-60, var(--color-primary-60-solid));
154
+ color: var(--color-complement-60, var(--color-primary-60-solid));
122
155
  }
123
156
  .sds-button--ghost:active:not(:disabled):not(.sds-button--loading) {
124
- background: var(--color-primary-20);
125
- border-color: var(--color-primary-70-solid);
126
- color: var(--color-primary-70-solid);
157
+ background: var(--color-complement-20, var(--color-primary-20));
158
+ background-origin: border-box;
159
+ border-color: var(--color-complement-70, var(--color-primary-70-solid));
160
+ color: var(--color-complement-70, var(--color-primary-70-solid));
127
161
  }
128
162
  .sds-button--ghost:focus-visible {
129
163
  box-shadow: var(--focus-ring);
@@ -164,14 +198,17 @@
164
198
  font-weight: var(--font-weight-medium);
165
199
  line-height: var(--lh-body-md);
166
200
  background: var(--color-primary-50);
201
+ background-origin: border-box;
167
202
  color: var(--color-on-primary);
168
203
  border: 1px solid var(--color-primary-50-solid);
169
204
  }
170
205
  .sds-button--pillFilled:hover:not(:disabled):not(.sds-button--loading) {
171
206
  background: var(--color-primary-60);
207
+ background-origin: border-box;
172
208
  }
173
209
  .sds-button--pillFilled:active:not(:disabled):not(.sds-button--loading) {
174
210
  background: var(--color-primary-70);
211
+ background-origin: border-box;
175
212
  }
176
213
  .sds-button--pillFilled:focus-visible {
177
214
  box-shadow: var(--focus-ring);
@@ -338,3 +375,53 @@
338
375
 
339
376
  [data-radius="rounded"] .sds-button--md { border-radius: var(--radius-full); }
340
377
  [data-radius="rounded"] .sds-button--lg { border-radius: var(--radius-full); }
378
+
379
+ /* ── The site's gradient, on the stroke ────────────────────────────────────────
380
+
381
+ `border-color` rejects a gradient, so a stroke normally reads the `-solid` companion —
382
+ the gradient's first stop, flat. These rules paint it properly instead: the gradient on
383
+ `border-box`, the surface colour on `padding-box`, over a transparent border. Only the
384
+ border's own 2px show the gradient, and `border-radius` still works, which is what
385
+ `border-image` would cost.
386
+
387
+ `[data-primary-gradient]` is written by `applyCustomGradient` and removed by
388
+ `applyCustomColor` — a stylesheet cannot ask what a custom property contains, so the
389
+ code that fills the slot says so. KDLS wrote the same rules under
390
+ `[data-color-scheme="lya"]`, its own brand gradient; SDS deleted every `lya` block with
391
+ the brand and these went with them. Keyed on the marker they serve any site's gradient.
392
+ ─────────────────────────────────────────────────────────────────────────── */
393
+
394
+ [data-primary-gradient] .sds-button--ghost {
395
+ background:
396
+ linear-gradient(var(--component-bg-color), var(--component-bg-color)) padding-box,
397
+ var(--color-primary-50) border-box;
398
+ border-color: transparent;
399
+ }
400
+
401
+ [data-primary-gradient] .sds-button--ghost:hover:not(:disabled):not(.sds-button--loading) {
402
+ background:
403
+ var(--color-primary-10) padding-box,
404
+ var(--color-primary-50) border-box;
405
+ border-color: transparent;
406
+ }
407
+
408
+ [data-primary-gradient] .sds-button--ghost:active:not(:disabled):not(.sds-button--loading) {
409
+ background:
410
+ var(--color-primary-20) padding-box,
411
+ var(--color-primary-50) border-box;
412
+ border-color: transparent;
413
+ }
414
+
415
+ [data-primary-gradient] .sds-button--ghost:focus-visible {
416
+ background:
417
+ linear-gradient(var(--component-bg-color), var(--component-bg-color)) padding-box,
418
+ var(--color-primary-50) border-box;
419
+ border-color: transparent;
420
+ }
421
+
422
+ [data-primary-gradient] .sds-button--pill:hover:not(:disabled):not(.sds-button--loading) {
423
+ background:
424
+ linear-gradient(var(--component-bg-color), var(--component-bg-color)) padding-box,
425
+ var(--color-primary-50) border-box;
426
+ border-color: transparent;
427
+ }
@@ -33,16 +33,26 @@
33
33
  .sds-card--hoverable:focus-visible {
34
34
  box-shadow: var(--focus-ring);
35
35
  }
36
- /* ── Cover — media/image slot, no padding, bleeds to the card's own radius.
37
- Default: top, full width, aspect-ratio fixed at 16/9 (overridden by coverHeight).
38
- horizontal: left column, fixed width, fills the row's height — aspect-ratio has
39
- no effect there since both dimensions are already explicit. ── */
36
+ /* Default: top, full width, no proportion — the cover is as tall as its picture unless
37
+ the caller says otherwise, with coverHeight (pixels) or coverRatio (a proportion).
38
+
39
+ This comment used to claim a 16/9 default. It was never true: the class below existed
40
+ and the template never emitted it. Making it the default now would resize every card
41
+ already published, so the ratio is opt-in and the comment is what changed.
42
+
43
+ horizontal: left column. A ratio there also releases the fixed width, otherwise both
44
+ dimensions are explicit and the proportion has nothing to do. ── */
40
45
  .sds-card__cover {
41
46
  position: relative;
42
47
  line-height: 0;
43
48
  }
44
- .sds-card__cover--16-9 {
45
- aspect-ratio: 16 / 9;
49
+ .sds-card__cover--16-9 { aspect-ratio: 16 / 9; }
50
+ .sds-card__cover--4-3 { aspect-ratio: 4 / 3; }
51
+ .sds-card__cover--3-2 { aspect-ratio: 3 / 2; }
52
+ .sds-card__cover--1-1 { aspect-ratio: 1 / 1; }
53
+
54
+ .sds-card--horizontal .sds-card__cover[class*="sds-card__cover--"] {
55
+ width: auto;
46
56
  }
47
57
  .sds-card__cover img {
48
58
  display: block;
@@ -22,3 +22,21 @@ export const assertCardSize = (v) => assertOneOf('size', v, CARD_SIZES);
22
22
  // `align` is optional in both places that take it, so undefined passes through.
23
23
  export const assertAlign = (v) => (v === undefined ? v : assertOneOf('align', v, ALIGNMENTS));
24
24
  export const assertColumns = (v) => assertOneOf('columns', v, INFO_ROW_COLUMNS);
25
+
26
+ /**
27
+ * The cover's proportion.
28
+ *
29
+ * Card.css has claimed since it was written that the cover defaults to 16/9, and the
30
+ * class that would do it — .sds-card__cover--16-9 — was never emitted by the template.
31
+ * This is that rule, made real and made opt-in: defaulting it now would change the shape
32
+ * of every card already using this component.
33
+ *
34
+ * Asked for by the Site layer, which needs 4/3 on a vertical property card and 3/2 on a
35
+ * horizontal one (Rafael, 2026-09-17).
36
+ */
37
+ export const COVER_RATIOS = ['16/9', '4/3', '3/2', '1/1'];
38
+
39
+ export const assertCoverRatio = (v) => (v === undefined ? v : assertOneOf('coverRatio', v, COVER_RATIOS));
40
+
41
+ // '4/3' -> '4-3', which is what the class name uses.
42
+ export const coverRatioSlug = (v) => v.replace('/', '-');
@@ -11,13 +11,14 @@ nothing. A property listing renders twenty of these.
11
11
  It is a <button> when it has a click handler, rather than a div with a handler and no
12
12
  keyboard path. -->
13
13
 
14
- import { assertCardSize } from "./card-variants.js";
14
+ import { assertCardSize, assertCoverRatio, coverRatioSlug } from "./card-variants.js";
15
15
 
16
16
  $ {
17
17
  const {
18
18
  cover,
19
19
  coverWidth,
20
20
  coverHeight,
21
+ coverRatio,
21
22
  coverBadges,
22
23
  horizontal = false,
23
24
  size = "md",
@@ -32,6 +33,7 @@ $ {
32
33
  } = input;
33
34
 
34
35
  assertCardSize(size);
36
+ assertCoverRatio(coverRatio);
35
37
 
36
38
  const renderable = (value) => (value && value.renderBody) || value;
37
39
  const interactive = Boolean(onClick);
@@ -62,6 +64,14 @@ $ {
62
64
  horizontal && coverWidth ? `width: ${coverWidth}px` : "",
63
65
  !horizontal && coverHeight ? `height: ${coverHeight}px` : "",
64
66
  ].filter(Boolean).join("; ") || null;
67
+
68
+ // An explicit pixel dimension is the caller being specific; the ratio is the shape's
69
+ // default. Whichever dimension this orientation can honour, if given, wins.
70
+ const pixelWins = horizontal ? Boolean(coverWidth) : Boolean(coverHeight);
71
+ const coverClass = [
72
+ "sds-card__cover",
73
+ coverRatio && !pixelWins ? `sds-card__cover--${coverRatioSlug(coverRatio)}` : "",
74
+ ].filter(Boolean).join(" ");
65
75
  }
66
76
 
67
77
  <macro|{ }| name="inner">
@@ -69,7 +79,7 @@ $ {
69
79
  // skeleton block, promising a picture that was never going to arrive — and once loaded
70
80
  // the card jumped as that block disappeared. KDLS gates on the cover alone.
71
81
  <if(cover)>
72
- <div class="sds-card__cover" style=coverStyle>
82
+ <div class=coverClass style=coverStyle>
73
83
  <if(loading)>
74
84
  <sds-skeleton-image fill/>
75
85
  </if>
@@ -247,3 +247,59 @@
247
247
  /* Indeterminate is drawn from a class rather than the :indeterminate pseudo-class.
248
248
  That property can only be set from JavaScript, and this component does not hydrate —
249
249
  with appearance:none the browser draws nothing here anyway, so the class is enough. */
250
+
251
+ /* ── The site's gradient, on the stroke ────────────────────────────────────────
252
+
253
+ `border-color` rejects a gradient, so a stroke normally reads the `-solid` companion —
254
+ the gradient's first stop, flat. These rules paint it properly instead: the gradient on
255
+ `border-box`, the surface colour on `padding-box`, over a transparent border. Only the
256
+ border's own 2px show the gradient, and `border-radius` still works, which is what
257
+ `border-image` would cost.
258
+
259
+ `[data-primary-gradient]` is written by `applyCustomGradient` and removed by
260
+ `applyCustomColor` — a stylesheet cannot ask what a custom property contains, so the
261
+ code that fills the slot says so. KDLS wrote the same rules under
262
+ `[data-color-scheme="lya"]`, its own brand gradient; SDS deleted every `lya` block with
263
+ the brand and these went with them. Keyed on the marker they serve any site's gradient.
264
+ ─────────────────────────────────────────────────────────────────────────── */
265
+
266
+ [data-primary-gradient] .sds-checkbox:hover:not(.sds-checkbox--disabled):not(.sds-checkbox--checked):not(.sds-checkbox--indeterminate) .sds-checkbox__box,
267
+ [data-primary-gradient] .sds-checkbox:active:not(.sds-checkbox--disabled):not(.sds-checkbox--checked):not(.sds-checkbox--indeterminate) .sds-checkbox__box {
268
+ background:
269
+ linear-gradient(var(--component-bg-color), var(--component-bg-color)) padding-box,
270
+ var(--color-primary-50) border-box;
271
+ border-color: transparent;
272
+ }
273
+
274
+ /* ── And when the gradient FILLS the box, it has no border to fight ────────────
275
+
276
+ A checked box paints the slot as its fill and the `-solid` companion as its border. With
277
+ a solid colour those are the same colour and the border cannot be seen. With a gradient
278
+ they are not: the fill runs from one stop to the other while the 2px ring stays flat at
279
+ the first, so a 20px box gets a hard edge of the wrong colour all the way round — and
280
+ the fill, sized to the box inside that ring and tiled into it, shows the far end there
281
+ as well.
282
+
283
+ `border: none` is the answer, and it is KDLS's: under a gradient the fill IS the whole
284
+ box, so there is nothing left to ring.
285
+
286
+ The touched state brightens rather than stepping to tone 60. Stepping a gradient's tone
287
+ moves every stop at once, which reads as a different colour rather than as the same
288
+ control being pressed. ─────────────────────────────────────────────────────── */
289
+ [data-primary-gradient] .sds-checkbox--checked .sds-checkbox__box,
290
+ [data-primary-gradient] .sds-checkbox--indeterminate .sds-checkbox__box,
291
+ [data-primary-gradient] .sds-checkbox__input:checked ~ .sds-checkbox__box {
292
+ background: var(--color-primary-50);
293
+ border: none;
294
+ }
295
+
296
+ [data-primary-gradient] .sds-checkbox--checked:hover:not(.sds-checkbox--disabled) .sds-checkbox__box,
297
+ [data-primary-gradient] .sds-checkbox--checked:active:not(.sds-checkbox--disabled) .sds-checkbox__box,
298
+ [data-primary-gradient] .sds-checkbox--indeterminate:hover:not(.sds-checkbox--disabled) .sds-checkbox__box,
299
+ [data-primary-gradient] .sds-checkbox--indeterminate:active:not(.sds-checkbox--disabled) .sds-checkbox__box,
300
+ [data-primary-gradient] .sds-checkbox:hover:not(.sds-checkbox--disabled) .sds-checkbox__input:checked ~ .sds-checkbox__box,
301
+ [data-primary-gradient] .sds-checkbox:active:not(.sds-checkbox--disabled) .sds-checkbox__input:checked ~ .sds-checkbox__box {
302
+ background: var(--color-primary-50);
303
+ border: none;
304
+ filter: brightness(1.1);
305
+ }
@@ -218,8 +218,8 @@ button.sds-colorpicker__trigger:disabled {
218
218
  /* The canvas and the sliders take focus now, so they have to show it. */
219
219
  .sds-colorpicker__canvas:focus-visible,
220
220
  .sds-colorpicker__slider:focus-visible {
221
- outline: var(--focus-ring-width) solid var(--focus-stroke-color);
222
- outline-offset: 2px;
221
+ outline: var(--focus-ring-outline);
222
+ outline-offset: var(--focus-ring-outline-offset);
223
223
  }
224
224
 
225
225
  /* ── Overlay ──
@@ -251,3 +251,26 @@ button.sds-colorpicker__trigger:disabled {
251
251
  .sds-colorpicker--headless {
252
252
  display: contents;
253
253
  }
254
+
255
+ /* ── The site's gradient, on the stroke ────────────────────────────────────────
256
+
257
+ `border-color` rejects a gradient, so a stroke normally reads the `-solid` companion —
258
+ the gradient's first stop, flat. These rules paint it properly instead: the gradient on
259
+ `border-box`, the surface colour on `padding-box`, over a transparent border. Only the
260
+ border's own 2px show the gradient, and `border-radius` still works, which is what
261
+ `border-image` would cost.
262
+
263
+ `[data-primary-gradient]` is written by `applyCustomGradient` and removed by
264
+ `applyCustomColor` — a stylesheet cannot ask what a custom property contains, so the
265
+ code that fills the slot says so. KDLS wrote the same rules under
266
+ `[data-color-scheme="lya"]`, its own brand gradient; SDS deleted every `lya` block with
267
+ the brand and these went with them. Keyed on the marker they serve any site's gradient.
268
+ ─────────────────────────────────────────────────────────────────────────── */
269
+
270
+ [data-primary-gradient] .sds-colorpicker__trigger:hover:not(.sds-colorpicker__trigger--disabled),
271
+ [data-primary-gradient] .sds-colorpicker__trigger--open {
272
+ background:
273
+ linear-gradient(var(--component-bg-color), var(--component-bg-color)) padding-box,
274
+ var(--color-primary-50) border-box;
275
+ border-color: transparent;
276
+ }
@@ -65,7 +65,11 @@ class {
65
65
  this.state.sat = s;
66
66
  this.state.bri = v;
67
67
  this.state.opacity = startingAlpha(input.value);
68
- this.syncHexInput();
68
+ // The argument, not `this.input`. Marko has not assigned it yet at this point — the
69
+ // same reason onCreate takes one, five lines above. Falling through to `this.input`
70
+ // read `disableAlpha` of undefined, and the throw escaped into the update: the page
71
+ // stopped hydrating, the colour stayed applied and the picker stayed open.
72
+ this.syncHexInput(input);
69
73
  }
70
74
 
71
75
  get currentHex() {
@@ -222,12 +226,29 @@ class {
222
226
  }
223
227
  }
224
228
 
229
+ /**
230
+ * Reports the colour and closes — and does everything it needs from `this.input` BEFORE
231
+ * either, because either can take this component away.
232
+ *
233
+ * A caller closes on the report, and closing this picker unmounts it. So the line after a
234
+ * call out can be running on a component that no longer exists, where `this.input` is
235
+ * undefined. It threw exactly there; the error escaped into Marko's update, and from that
236
+ * point nothing on the page hydrated again — the colour had been applied and the app was
237
+ * dead, with a theme pill still showing the old value beside a token that had changed.
238
+ *
239
+ * So: read first, then close, then report through the captured reference. Closing goes
240
+ * through `setOpen` like every other way out of this panel — writing `state.open`
241
+ * directly closed it only when nobody owned `open`, which left a controlled caller told
242
+ * about the colour and never told to close.
243
+ */
225
244
  apply() {
226
- const hex = this.input.disableAlpha
245
+ const { onChange, disableAlpha } = this.input;
246
+ const hex = disableAlpha
227
247
  ? this.currentHex
228
248
  : this.currentHex + alphaToHex(this.state.opacity);
229
- this.input.onChange?.(hex);
230
- this.state.open = false;
249
+
250
+ this.setOpen(false);
251
+ onChange?.(hex);
231
252
  }
232
253
  }
233
254
 
@@ -427,3 +427,25 @@
427
427
  [data-radius="rounded"] .sds-datepicker__day { border-radius: var(--radius-full); }
428
428
  [data-radius="rounded"] .sds-datepicker__month-item { border-radius: var(--radius-full); }
429
429
  [data-radius="rounded"] .sds-datepicker__year-item { border-radius: var(--radius-full); }
430
+
431
+ /* ── The site's gradient, on the stroke ────────────────────────────────────────
432
+
433
+ `border-color` rejects a gradient, so a stroke normally reads the `-solid` companion —
434
+ the gradient's first stop, flat. These rules paint it properly instead: the gradient on
435
+ `border-box`, the surface colour on `padding-box`, over a transparent border. Only the
436
+ border's own 2px show the gradient, and `border-radius` still works, which is what
437
+ `border-image` would cost.
438
+
439
+ `[data-primary-gradient]` is written by `applyCustomGradient` and removed by
440
+ `applyCustomColor` — a stylesheet cannot ask what a custom property contains, so the
441
+ code that fills the slot says so. KDLS wrote the same rules under
442
+ `[data-color-scheme="lya"]`, its own brand gradient; SDS deleted every `lya` block with
443
+ the brand and these went with them. Keyed on the marker they serve any site's gradient.
444
+ ─────────────────────────────────────────────────────────────────────────── */
445
+
446
+ [data-primary-gradient] .sds-datepicker__day--today {
447
+ background:
448
+ linear-gradient(var(--component-bg-color), var(--component-bg-color)) padding-box,
449
+ var(--color-primary-50) border-box;
450
+ border-color: transparent;
451
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Put the caret inside a floating panel, once the panel can actually take it.
3
+ *
4
+ * `sds-floating` renders its panel `visibility: hidden` until it has measured and placed
5
+ * it — see Floating.css: a panel nobody has measured is 0×0, 0×0 fits anywhere, and a flip
6
+ * decided on that basis puts the panel on the wrong side in front of the reader. The
7
+ * reveal happens one frame later, in `afterOpen`.
8
+ *
9
+ * A component that opens a panel and wants the caret inside it runs on the render where
10
+ * the panel exists and is still hidden. `focus()` there is a no-op, and a silent one:
11
+ * nothing throws, nothing returns false, `document.activeElement` simply does not move.
12
+ * Measured in Chromium on 2026-09-17, sampling every frame after a click on an
13
+ * sds-select's trigger:
14
+ *
15
+ * frame 1 — panel present, visibility hidden, not positioned ← the focus call
16
+ * frame 2 — visibility visible, positioned ← nothing asks again
17
+ *
18
+ * So: ask, check whether the caret arrived, and ask again on the next frame if it did not.
19
+ * Bounded, because a panel that never becomes visible is a panel that was closed, and a
20
+ * retry with no end is worse than a caret that did not move. In the measurement above it
21
+ * took three tries.
22
+ *
23
+ * The target arrives as a LOOKUP rather than an element. Marko replaces the node on
24
+ * re-render, and a reference held across one points at something detached — focus() on it
25
+ * throws nothing and moves nothing, which is the same silent failure by another route.
26
+ *
27
+ * jsdom has no layout and so honours focus() on a hidden element. This rule therefore
28
+ * cannot be caught by rendering a component in a test; it is tested directly, with the
29
+ * first attempt made to fail the way the browser fails it.
30
+ */
31
+
32
+ /** How many times to ask in total, including the first. Three sufficed when measured. */
33
+ const DEFAULT_ATTEMPTS = 5;
34
+
35
+ /**
36
+ * @param {() => (HTMLElement|null|undefined)} lookup finds the element to focus, now
37
+ * @param {object} [options]
38
+ * @param {number} [options.attempts] total tries, including the first
39
+ * @param {(cb: () => void) => void} [options.schedule] when to try again; a frame by
40
+ * default, and injectable so the rule can be stepped by hand in a test
41
+ */
42
+ export function focusWhenReady(lookup, { attempts = DEFAULT_ATTEMPTS, schedule } = {}) {
43
+ const later = schedule
44
+ ?? ((callback) => (typeof requestAnimationFrame === 'function'
45
+ ? requestAnimationFrame(() => callback())
46
+ : setTimeout(callback, 16)));
47
+
48
+ const tryOnce = (left) => {
49
+ const target = lookup();
50
+ if (!target) return;
51
+
52
+ target.focus({ preventScroll: true });
53
+ if (document.activeElement === target || left <= 1) return;
54
+
55
+ later(() => tryOnce(left - 1));
56
+ };
57
+
58
+ tryOnce(attempts);
59
+ }
@@ -92,3 +92,25 @@
92
92
  border-color: var(--border-interactive-color);
93
93
  color: var(--color-neutral-40);
94
94
  }
95
+
96
+ /* ── The site's gradient, on the stroke ────────────────────────────────────────
97
+
98
+ `border-color` rejects a gradient, so a stroke normally reads the `-solid` companion —
99
+ the gradient's first stop, flat. These rules paint it properly instead: the gradient on
100
+ `border-box`, the surface colour on `padding-box`, over a transparent border. Only the
101
+ border's own 2px show the gradient, and `border-radius` still works, which is what
102
+ `border-image` would cost.
103
+
104
+ `[data-primary-gradient]` is written by `applyCustomGradient` and removed by
105
+ `applyCustomColor` — a stylesheet cannot ask what a custom property contains, so the
106
+ code that fills the slot says so. KDLS wrote the same rules under
107
+ `[data-color-scheme="lya"]`, its own brand gradient; SDS deleted every `lya` block with
108
+ the brand and these went with them. Keyed on the marker they serve any site's gradient.
109
+ ─────────────────────────────────────────────────────────────────────────── */
110
+
111
+ [data-primary-gradient] .sds-incrementer:hover:not(.sds-incrementer--disabled) {
112
+ background:
113
+ linear-gradient(var(--component-bg-color), var(--component-bg-color)) padding-box,
114
+ var(--color-primary-50) border-box;
115
+ border-color: transparent;
116
+ }