@webority/theme 0.13.4 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.scss CHANGED
@@ -4,9 +4,9 @@
4
4
  //
5
5
  // Load order is LOAD-BEARING — never reorder:
6
6
  // variables (before bootstrap) -> bootstrap -> tokens -> aliases -> components -> wui-components
7
- // Flags are NOT imported here: _wui-components already imports them (the phone control needs
8
- // them), and legacy @import does not dedupe, so a second import emitted all ~245 flag classes
9
- // twice (≈490 selectors) and bloated the bundle.
7
+ // Country flags are NOT imported here, and neither does _wui-components: they are an opt-in
8
+ // layer (scss/_flags.scss, compiled on its own to dist/webority-flags.css) for sites that render
9
+ // the phone input or country picker.
10
10
  @import "scss/variables";
11
11
  @import "bootstrap/scss/bootstrap";
12
12
  @import "scss/tokens";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webority/theme",
3
- "version": "0.13.4",
3
+ "version": "0.14.0",
4
4
  "description": "Webority shared design system — Bootstrap 5.3 SCSS overrides, --wui-* tokens, and shared component CSS. The single source of brand chrome for React portals and Razor sites.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "publishConfig": {
@@ -26,6 +26,8 @@
26
26
  "./scss/wui/*": "./scss/wui/_*.scss",
27
27
  "./scss/*": "./scss/_*.scss",
28
28
  "./css": "./dist/webority-theme.css",
29
+ "./css/flags": "./dist/webority-flags.css",
30
+ "./purge-safelist.json": "./dist/purge-safelist.json",
29
31
  "./package.json": "./package.json"
30
32
  },
31
33
  "sass": "index.scss",
@@ -39,6 +41,6 @@
39
41
  "sass-embedded": "^1.97.0"
40
42
  },
41
43
  "scripts": {
42
- "build": "sass index.scss dist/webority-theme.css --load-path=node_modules --style=compressed --no-source-map --quiet-deps --silence-deprecation=if-function && node ../../scripts/sync-rcl-assets.mjs theme"
44
+ "build": "sass index.scss dist/webority-theme.css --load-path=node_modules --style=compressed --no-source-map --quiet-deps --silence-deprecation=if-function && sass scss/_flags.scss dist/webority-flags.css --style=compressed --no-source-map --quiet && node ../../scripts/gen-purge-safelist.mjs && node ../../scripts/sync-rcl-assets.mjs theme"
43
45
  }
44
46
  }
@@ -245,32 +245,32 @@
245
245
  // sizes step font-size, horizontal padding and radius together (vertical size
246
246
  // comes from --wui-control-height, so a button lines up with an input). Weight
247
247
  // is 600 on every rung — buttons read as a deliberate action, not body text.
248
- // Medium (default): 15px / 600, 24px side padding, 18px squircle radius, 45px tall.
248
+ // Medium (default): 14px / 600, 24px side padding, 18px squircle radius, 45px tall.
249
249
  .btn {
250
250
  --bs-btn-border-radius: 18px;
251
- --bs-btn-font-size: 0.9375rem;
251
+ --bs-btn-font-size: 0.875rem;
252
252
  --bs-btn-font-weight: 600;
253
253
  --bs-btn-padding-x: 1.5rem;
254
254
  // Progressive enhancement only (Chrome/Safari recent). Unsupported engines
255
255
  // keep a plain border-radius arc — not a cross-browser design contract.
256
256
  corner-shape: squircle;
257
257
  }
258
- // Small: 14px, 18px side padding, 14px squircle radius (proportional to md), 35px tall.
258
+ // Small: 12px, 18px side padding, 14px squircle radius (proportional to md), 35px tall.
259
259
  .btn-sm:not(.btn-icon),
260
260
  .btn-group-sm > .btn:not(.btn-icon) {
261
261
  --bs-btn-border-radius: 14px;
262
- --bs-btn-font-size: 0.875rem;
262
+ --bs-btn-font-size: 0.75rem;
263
263
  --bs-btn-padding-x: 1.125rem;
264
264
  display: inline-flex;
265
265
  align-items: center;
266
266
  justify-content: center;
267
267
  min-height: 35px;
268
268
  }
269
- // Large: 17px, 32px side padding, 22px squircle radius (proportional to md), 55px tall — a spacious CTA.
269
+ // Large: 16px, 32px side padding, 22px squircle radius (proportional to md), 55px tall — a spacious CTA.
270
270
  .btn-lg:not(.btn-icon),
271
271
  .btn-group-lg > .btn:not(.btn-icon) {
272
272
  --bs-btn-border-radius: 22px;
273
- --bs-btn-font-size: 1.0625rem;
273
+ --bs-btn-font-size: 1rem;
274
274
  --bs-btn-padding-x: 2rem;
275
275
  display: inline-flex;
276
276
  align-items: center;
package/scss/_tokens.scss CHANGED
@@ -43,6 +43,10 @@
43
43
  --wui-field-bg: #F9FBFC;
44
44
  --wui-border-light: #EBEDF3;
45
45
  --wui-border-soft: #F4F6F8;
46
+ // The unfilled part of a progress bar / meter. Darker than every surface it can sit on
47
+ // (card, page, muted fill) so the empty track reads as a track, yet light enough that the
48
+ // mid-yellow warning fill still separates from it.
49
+ --wui-progress-track: #E1E5EE;
46
50
 
47
51
  // Gradients (hero, CTA)
48
52
  --wui-hero-gradient:
@@ -217,6 +221,8 @@
217
221
  --wui-dark-bg-surface: #181b23;
218
222
  --wui-dark-border: #2a2f3a;
219
223
  --wui-dark-border-strong:#3a414f;
224
+ // Lighter than the dark page, surface and muted fill, so the empty track shows on all three.
225
+ --wui-dark-progress-track: #323845;
220
226
  --wui-dark-text: #e6e8ee;
221
227
  --wui-dark-text-muted: #b4b9c6;
222
228
  --wui-dark-text-subtle: #8a90a0;
@@ -18,9 +18,18 @@
18
18
  --color-border: var(--wui-border-light);
19
19
  --color-border-light: var(--wui-border-soft);
20
20
  --color-border-strong: var(--wui-text-5);
21
+ --color-progress-track: var(--wui-progress-track);
22
+ // Label text inside a progress bar fill.
23
+ --color-progress-label: var(--wui-bg-light);
21
24
 
22
25
  // Text ramp
23
26
  --color-text: var(--wui-text-heading);
27
+ // Body copy on a tinted surface (alert bodies): a step softer than the heading ink.
28
+ --color-text-body: var(--wui-text-1);
29
+ // A dark-ink glyph such as Bootstrap's .btn-close needs no filter on a light surface.
30
+ --color-close-filter: none;
31
+ // The border of a focused / open / active control (input, select, OTP slot, dropzone).
32
+ --color-focus-border: var(--wui-primary-30);
24
33
  --color-text-muted: var(--wui-text-2);
25
34
  --color-text-subtle: var(--wui-text-3);
26
35
 
@@ -54,6 +63,7 @@
54
63
  --color-info-soft: var(--color-info-10);
55
64
  // Use this — never --color-info — when the colour IS the text.
56
65
  --color-info-text: var(--wui-info-text);
66
+ --color-info-text-on-soft: var(--wui-info-text);
57
67
 
58
68
  --color-success-active: var(--wui-success-active);
59
69
  --color-success: var(--wui-success);
@@ -66,6 +76,7 @@
66
76
  --color-success-soft: var(--color-success-10);
67
77
  // Use this — never --color-success — when the colour IS the text.
68
78
  --color-success-text: var(--wui-success-text);
79
+ --color-success-text-on-soft: var(--wui-success-text);
69
80
 
70
81
  --color-warning-active: var(--wui-warning-active);
71
82
  --color-warning: var(--wui-warning);
@@ -79,6 +90,7 @@
79
90
  --color-warning-soft: var(--color-warning-10);
80
91
  // Use this — never --color-warning — when the colour IS the text.
81
92
  --color-warning-text: var(--wui-warning-text);
93
+ --color-warning-text-on-soft: var(--wui-warning-text);
82
94
 
83
95
  --color-danger-active: var(--wui-danger-active);
84
96
  --color-danger: var(--wui-danger);
@@ -91,6 +103,7 @@
91
103
  --color-danger-soft: var(--color-danger-10);
92
104
  // Use this — never --color-danger — when the colour IS the text.
93
105
  --color-danger-text: var(--wui-danger-text);
106
+ --color-danger-text-on-soft: var(--wui-danger-text);
94
107
 
95
108
  // Accent → warning ramp
96
109
  --color-accent: var(--color-warning);
@@ -137,10 +150,19 @@
137
150
  --color-border: var(--wui-dark-border);
138
151
  --color-border-light: color-mix(in srgb, var(--wui-dark-border) 50%, transparent);
139
152
  --color-border-strong: var(--wui-dark-border-strong);
153
+ --color-progress-track: var(--wui-dark-progress-track);
154
+ // Same near-black as --color-primary-text below: 4.97:1 or better on every bright fill.
155
+ --color-progress-label: #0F0F0F;
140
156
 
141
157
  --color-text: var(--wui-dark-text);
142
158
  --color-text-muted: var(--wui-dark-text-muted);
143
159
  --color-text-subtle: var(--wui-dark-text-subtle);
160
+ --color-text-body: var(--wui-dark-text);
161
+ // Bootstrap's .btn-close glyph is ink-dark; on a dark surface it needs the white filter.
162
+ --color-close-filter: invert(1) grayscale(100%) brightness(200%);
163
+ // The light pastel focus border glows against a dark field; dark mode uses the dark theme's
164
+ // own primary (the button fill), which still clears 3:1 against every dark surface.
165
+ --color-focus-border: var(--wui-dark-primary);
144
166
 
145
167
  // Re-bridge the Bootstrap colour vars onto the (now dark) app tokens so the
146
168
  // named-var text/body/border utilities — .text-body-secondary, .text-muted,
@@ -183,6 +205,14 @@
183
205
  --color-warning-text: var(--wui-warning);
184
206
  --color-info-text: var(--wui-info);
185
207
 
208
+ // Text ON a soft tint (an alert heading). The bright tone alone falls under WCAG AA on its own
209
+ // 18% tint over the dark page (info and danger reach only ~3.8:1), so it is lifted with 30%
210
+ // white: every tone then clears 4.5:1 on the dark page, surface and muted fill.
211
+ --color-success-text-on-soft: color-mix(in srgb, var(--wui-success) 70%, white);
212
+ --color-danger-text-on-soft: color-mix(in srgb, var(--wui-danger) 70%, white);
213
+ --color-warning-text-on-soft: color-mix(in srgb, var(--wui-warning) 70%, white);
214
+ --color-info-text-on-soft: color-mix(in srgb, var(--wui-info) 70%, white);
215
+
186
216
  // Light mode maps -strong to deep AA fills; on dark surfaces those sink into the page.
187
217
  // Remap to the bright fills so timeline dots / strong accents stay visible (same as -text).
188
218
  --color-success-strong: var(--wui-success);
@@ -194,6 +224,12 @@
194
224
  --color-warning-soft: color-mix(in srgb, var(--wui-warning) 18%, transparent);
195
225
  --color-danger-soft: color-mix(in srgb, var(--wui-danger) 18%, transparent);
196
226
  --color-info-soft: color-mix(in srgb, var(--wui-info) 18%, transparent);
227
+ // The -50 border tone: the light hex would glow on a dark page, so it becomes a translucent
228
+ // mix of the vivid tone that reads as a border on the dark tint.
229
+ --color-success-50: color-mix(in srgb, var(--wui-success) 45%, transparent);
230
+ --color-warning-50: color-mix(in srgb, var(--wui-warning) 45%, transparent);
231
+ --color-danger-50: color-mix(in srgb, var(--wui-danger) 45%, transparent);
232
+ --color-info-50: color-mix(in srgb, var(--wui-info) 45%, transparent);
197
233
  --color-accent-soft: var(--color-warning-soft);
198
234
 
199
235
  --color-scrim: color-mix(in srgb, black 45%, transparent);
@@ -43,20 +43,10 @@
43
43
  // set, so it has to win on equal specificity.
44
44
  @import "wui/workspace";
45
45
 
46
- // Flags belong here because <wui-phone> cannot render without them.
47
- //
48
- // The 245 `.wui-flag-*` rules live in _flags.scss, and this entry did not pull it in — so a
49
- // product following the documented chain (variables -> bootstrap -> tokens -> components ->
50
- // wui-components) got AppPhoneNumberInput with an empty grey box where every flag should be,
51
- // and nothing failing anywhere to point at the cause. The component shipped; its styling did
52
- // not. That is the same escape as th.sortable and .u-name, and the fix is the same principle:
53
- // a component and the CSS it requires travel together.
54
-
55
- // It is not free — roughly 183KB raw, 45KB gzipped, mostly inlined SVG. A product that never
56
- // renders a phone field can skip it by importing the `wui/*` partials above individually
57
- // instead of this aggregate; that is the deliberate opt-out, and it is a choice rather than
58
- // an accident.
59
- @import "flags";
46
+ // Country flags are NOT part of this aggregate. The 245 `.wui-flag-*` rules are about 187KB raw
47
+ // (38KB Brotli) of inlined SVG, and most sites never render a phone field or country picker.
48
+ // A site that does imports `@webority/theme/scss/flags` once, after this file; without it
49
+ // <wui-phone> and <wui-country-select> show an empty grey box where the flag should be.
60
50
  @import "wui/shell";
61
51
  @import "wui/dock";
62
52
  @import "wui/consent-banner";
@@ -58,18 +58,14 @@
58
58
  background: var(--color-surface);
59
59
  color: var(--color-primary);
60
60
  }
61
- // Icon illustrations are coloured PNGs: greyscale them until the card is
62
- // selected, then show the full-colour (blue) art.
63
- .struct-card-ic img {
64
- filter: grayscale(1);
65
- opacity: 0.6;
66
- transition:
67
- filter 0.12s,
68
- opacity 0.12s;
69
- }
70
- .struct-card.checked .struct-card-ic img {
71
- filter: none;
72
- opacity: 1;
61
+ // An image in the round icon disc (React iconNode={<img …/>}) keeps its own colours whether or not
62
+ // the card is selected, fills the whole disc and is cropped to its circle, whatever the image's own
63
+ // size or aspect. The tile variant drops the disc and sizes its illustration itself, so it is left out.
64
+ .struct-card:not(.struct-card-tile) .struct-card-ic img {
65
+ width: 100%;
66
+ height: 100%;
67
+ object-fit: cover;
68
+ border-radius: inherit;
73
69
  }
74
70
  .struct-card-body {
75
71
  display: flex;
@@ -113,6 +109,77 @@
113
109
  .struct-card-check svg path {
114
110
  stroke-width: 2.25;
115
111
  }
112
+ // No tick (hideCheck / hide-check): the plain card no longer needs the top/bottom room the
113
+ // corner tick sat in, so it tightens. The tile and image cards keep their own padding.
114
+ .wui-struct-card-no-check:not(.struct-card-tile):not(.struct-card-img-card) {
115
+ padding-top: 14px;
116
+ padding-bottom: 14px;
117
+ }
118
+
119
+ // Sizes (size="sm" | "lg"; md is the base card above). Padding, gap, icon disc, type and the tick
120
+ // offset step together so a card reads as one unit at every size. The tile variant owns its layout
121
+ // and type (its later rules win), and the image card steps only its body padding and type.
122
+ .wui-struct-card-sm {
123
+ gap: 9px;
124
+ }
125
+ .wui-struct-card-sm:not(.struct-card-tile):not(.struct-card-img-card) {
126
+ padding: 14px;
127
+ }
128
+ .wui-struct-card-sm.wui-struct-card-no-check:not(.struct-card-tile):not(.struct-card-img-card) {
129
+ padding-top: 10px;
130
+ padding-bottom: 10px;
131
+ }
132
+ .wui-struct-card-sm .struct-card-ic {
133
+ width: 36px;
134
+ height: 36px;
135
+ }
136
+ .wui-struct-card-sm .struct-card-title {
137
+ font-size: 14px;
138
+ }
139
+ .wui-struct-card-sm .struct-card-desc {
140
+ font-size: 13px;
141
+ }
142
+ .wui-struct-card-sm .struct-card-check {
143
+ top: 7px;
144
+ right: 7px;
145
+ }
146
+ .wui-struct-card-sm.struct-card-img-card .struct-card-body {
147
+ padding: 9px 11px;
148
+ }
149
+ .wui-struct-card-lg {
150
+ gap: 14px;
151
+ }
152
+ .wui-struct-card-lg:not(.struct-card-tile):not(.struct-card-img-card) {
153
+ padding: 26px;
154
+ }
155
+ .wui-struct-card-lg.wui-struct-card-no-check:not(.struct-card-tile):not(.struct-card-img-card) {
156
+ padding-top: 18px;
157
+ padding-bottom: 18px;
158
+ }
159
+ .wui-struct-card-lg .struct-card-ic {
160
+ width: 56px;
161
+ height: 56px;
162
+ }
163
+ .wui-struct-card-lg .struct-card-title {
164
+ font-size: 18px;
165
+ }
166
+ .wui-struct-card-lg .struct-card-desc {
167
+ font-size: 15px;
168
+ }
169
+ .wui-struct-card-lg .struct-card-check {
170
+ top: 13px;
171
+ right: 13px;
172
+ }
173
+ .wui-struct-card-lg.struct-card-img-card .struct-card-body {
174
+ padding: 15px 18px;
175
+ }
176
+
177
+ // Stacked layout (layout="stacked"): the icon disc sits on its own line and the title and
178
+ // description start on the line below, left-aligned. The gap between them follows the size step.
179
+ .wui-struct-card-stacked {
180
+ flex-direction: column;
181
+ align-items: flex-start;
182
+ }
116
183
 
117
184
  // Tile variant: big centered illustration + label. Opt in with
118
185
  // className="struct-card-tile".
@@ -131,13 +198,9 @@
131
198
  border-radius: 0;
132
199
  background: transparent;
133
200
  }
134
- // Full-colour at all times (override the greyscale-until-selected treatment).
135
- .struct-card-tile .struct-card-ic img,
136
- .struct-card-tile.checked .struct-card-ic img {
201
+ .struct-card-tile .struct-card-ic img {
137
202
  width: 100px;
138
203
  height: 100px;
139
- filter: none;
140
- opacity: 1;
141
204
  }
142
205
  .struct-card-tile .struct-card-body {
143
206
  align-items: center;
@@ -185,10 +248,10 @@
185
248
  }
186
249
  .search-select-control.open {
187
250
  // White fill + the active/focus border, matching a focused text input (which lifts off the
188
- // muted resting fill onto white with a --wui-primary-30 border). Keeps the control seamless
251
+ // muted resting fill onto white with a --color-focus-border border). Keeps the control seamless
189
252
  // with the open menu and the floated label's white mask.
190
253
  background: var(--color-surface);
191
- border-color: var(--wui-primary-30);
254
+ border-color: var(--color-focus-border);
192
255
  }
193
256
  .search-select-caret {
194
257
  flex-shrink: 0;
@@ -367,7 +430,7 @@
367
430
  }
368
431
  .search-select-search:focus-within {
369
432
  background: var(--color-surface);
370
- border-color: var(--wui-primary-30);
433
+ border-color: var(--color-focus-border);
371
434
  }
372
435
  .search-select-search:focus-within svg {
373
436
  color: var(--color-primary);
@@ -506,12 +569,21 @@
506
569
  // While the calendar is open the trigger reads as a focused field — clean white surface, not
507
570
  // the muted resting fill. Opening moves focus INTO the popup, so :focus drops off the trigger;
508
571
  // this .open rule keeps it white (matching .search-select-control.open) instead of snapping
509
- // back to grey the moment the dropdown appears. It stays white on close too (_close refocuses
510
- // the trigger → :focus), greying only on an outside click that blurs it.
572
+ // back to grey the moment the dropdown appears.
511
573
  background-color: var(--color-surface);
512
- border-color: color-mix(in srgb, var(--color-primary) 30%, transparent);
574
+ border-color: var(--color-focus-border);
513
575
  box-shadow: none;
514
576
  }
577
+ // Closing hands focus back to the trigger (so the keyboard user lands where they started), which
578
+ // would leave a mouse user with an "active" field after the calendar is gone. Once the popup is
579
+ // shut, the trigger keeps the focus look only for keyboard focus (:focus-visible); a pick or
580
+ // close by pointer returns it to rest. An errored field (aria-invalid on the host) keeps its red.
581
+ wui-datepicker:not([aria-invalid="true"]) .datepicker:not(.open) .datepicker-trigger:focus:not(:focus-visible),
582
+ wui-date-range:not([aria-invalid="true"]) .daterange:not(.open) .daterange-trigger:focus:not(:focus-visible),
583
+ wui-timepicker:not([aria-invalid="true"]) .timepicker:not(.open) .timepicker-trigger:focus:not(:focus-visible) {
584
+ background-color: var(--color-field-bg);
585
+ border-color: var(--color-border);
586
+ }
515
587
  // Portalled to <body> and fixed-positioned from JS so a modal's
516
588
  // overflow:hidden can't clip it; top/left come from inline styles. z-index
517
589
  // sits above the modal layer.
@@ -677,6 +749,13 @@ button.datepicker-month:hover {
677
749
  color: var(--color-border-strong);
678
750
  cursor: not-allowed;
679
751
  }
752
+ // Today outside the allowed range keeps its ring, so the user can still find it, but in the
753
+ // disabled ink: a primary ring would read as a date they can pick.
754
+ .datepicker-day.is-today:disabled,
755
+ .datepicker-cell.is-today:disabled {
756
+ box-shadow: inset 0 0 0 1.5px var(--color-border-strong);
757
+ color: var(--color-border-strong);
758
+ }
680
759
  .datepicker-foot {
681
760
  display: flex;
682
761
  align-items: center;
@@ -835,7 +914,7 @@ button.datepicker-month:hover {
835
914
  .daterange.open .daterange-trigger {
836
915
  // Stay white while the two-month popover is open — see the note on .datepicker.open above.
837
916
  background-color: var(--color-surface);
838
- border-color: color-mix(in srgb, var(--color-primary) 30%, transparent);
917
+ border-color: var(--color-focus-border);
839
918
  box-shadow: none;
840
919
  }
841
920
  .daterange-pop {
@@ -951,7 +1030,7 @@ button.datepicker-month:hover {
951
1030
  // Match AppInput's focus: clean surface fill + a soft primary border.
952
1031
  .app-phone-number-input .phone-input-wrap:focus-within {
953
1032
  background: var(--color-surface);
954
- border-color: var(--wui-primary-30);
1033
+ border-color: var(--color-focus-border);
955
1034
  }
956
1035
  .app-phone-number-input .phone-input-wrap:has(> .input:disabled) {
957
1036
  background: var(--color-bg-muted);
@@ -1176,7 +1255,7 @@ wui-timepicker[aria-invalid="true"] .timepicker.open .timepicker-trigger {
1176
1255
  // AppInput's exact focus: clean surface fill + its soft primary border token.
1177
1256
  .dropdown-search-input:focus {
1178
1257
  background: var(--color-surface);
1179
- border-color: var(--wui-primary-30);
1258
+ border-color: var(--color-focus-border);
1180
1259
  }
1181
1260
  // AppInput's placeholder colour, not the browser default grey.
1182
1261
  .dropdown-search-input::placeholder {
@@ -1319,7 +1398,7 @@ wui-timepicker[aria-invalid="true"] .timepicker.open .timepicker-trigger {
1319
1398
  .timepicker.open .timepicker-trigger {
1320
1399
  // Stay white while the time list is open — see the note on .datepicker.open above.
1321
1400
  background-color: var(--color-surface);
1322
- border-color: color-mix(in srgb, var(--color-primary) 30%, transparent);
1401
+ border-color: var(--color-focus-border);
1323
1402
  box-shadow: none;
1324
1403
  }
1325
1404
  .timepicker-pop {
@@ -348,7 +348,7 @@ textarea.form-control.autosize {
348
348
  background-color: var(--color-surface);
349
349
  color: var(--color-text);
350
350
  // Soft brand border is the focus cue (per the reference) — no ring/shadow.
351
- border-color: var(--wui-primary-30);
351
+ border-color: var(--color-focus-border);
352
352
  box-shadow: none;
353
353
  }
354
354
  .form-control::placeholder,
@@ -625,6 +625,14 @@ $wui-field-error-glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/
625
625
  color: var(--color-text);
626
626
  -webkit-text-fill-color: var(--color-text);
627
627
  }
628
+ // ...but -webkit-text-fill-color is inherited by ::placeholder and beats its `color`, so the rule
629
+ // above painted the placeholder as bright as a typed value. Hand the placeholder its own subtle
630
+ // ink back (the same --color-text-subtle every other placeholder uses).
631
+ :root[data-theme="dark"] .form-control.error::placeholder,
632
+ :root[data-theme="dark"] .input.error::placeholder {
633
+ color: var(--color-text-subtle);
634
+ -webkit-text-fill-color: var(--color-text-subtle);
635
+ }
628
636
 
629
637
  // Field-relative wrap for trailing affixes (error icon, password toggle).
630
638
  .input-wrap {
@@ -793,9 +801,10 @@ $wui-field-error-glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/
793
801
  }
794
802
  .input-affixed:focus-within {
795
803
  background: var(--color-surface);
796
- border-color: var(--wui-primary-30);
804
+ border-color: var(--color-focus-border);
797
805
  }
798
- .input-affixed:has(.input.error) {
806
+ .input-affixed:has(.input.error),
807
+ .input-affixed:has(.input[aria-invalid="true"]) {
799
808
  background: var(--color-surface);
800
809
  border-color: color-mix(in srgb, var(--color-danger) 30%, transparent);
801
810
  }
@@ -806,9 +815,18 @@ $wui-field-error-glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/
806
815
  border-radius: 0;
807
816
  background: transparent;
808
817
  }
818
+ // A select in the affixed shell (AppSelect prefix/suffix): the custom element is the flex
819
+ // item, and its inner .input control already loses its own border through the rule above.
820
+ .input-affixed > wui-select {
821
+ flex: 1;
822
+ min-width: 0;
823
+ }
809
824
  .input-affixed .affix {
810
825
  display: flex;
811
826
  align-items: center;
827
+ // A one-character affix (₹, $, #) still reads as a box, centred in it, not a sliver.
828
+ justify-content: center;
829
+ min-width: 44px;
812
830
  padding: 0 12px;
813
831
  // Transparent so the affix inherits the wrapper fill — the addon and the input read as
814
832
  // one continuous surface (only the separator border divides them).
@@ -993,6 +1011,7 @@ $wui-field-error-glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/
993
1011
  // into a plain div, a grid cell, a table cell — it would still collapse. Being a flex
994
1012
  // item blockifies it; nothing else does.
995
1013
  wui-select,
1014
+ wui-country-select,
996
1015
  wui-autocomplete,
997
1016
  wui-multiselect,
998
1017
  wui-taginput,
@@ -1010,6 +1029,7 @@ wui-select[compact] {
1010
1029
  width: auto;
1011
1030
  }
1012
1031
  .field > wui-select,
1032
+ .field > wui-country-select,
1013
1033
  .field > wui-autocomplete,
1014
1034
  .field > wui-multiselect,
1015
1035
  .field > wui-taginput,
@@ -1069,7 +1089,7 @@ wui-select[compact] {
1069
1089
  }
1070
1090
  .input-with-unit:focus-within {
1071
1091
  background: var(--color-surface);
1072
- border-color: var(--wui-primary-30);
1092
+ border-color: var(--color-focus-border);
1073
1093
  }
1074
1094
  .input-with-unit:has(.num-input.error) {
1075
1095
  background: var(--color-surface);
@@ -1142,7 +1162,7 @@ wui-select[compact] {
1142
1162
  }
1143
1163
  .num-wrap:focus-within {
1144
1164
  background: var(--color-surface);
1145
- border-color: var(--wui-primary-30);
1165
+ border-color: var(--color-focus-border);
1146
1166
  }
1147
1167
  .num-wrap:has(.num-input.error),
1148
1168
  .num-wrap:has(.num-input.is-invalid),
@@ -1512,7 +1532,7 @@ wui-otp {
1512
1532
  // surface fill + soft brand border, no ring.
1513
1533
  .otp-slot.active {
1514
1534
  background: var(--color-surface);
1515
- border-color: var(--wui-primary-30);
1535
+ border-color: var(--color-focus-border);
1516
1536
  box-shadow: none;
1517
1537
  }
1518
1538
  .otp-field:disabled ~ .otp-slots .otp-slot {
@@ -407,15 +407,20 @@ a.info-row:hover .info-value {
407
407
  // ============================================================================
408
408
  // Segmented control — 2–4 mutually-exclusive options shown side by side.
409
409
  // The theme toggle (light/dark/system) is the canonical use.
410
+ //
411
+ // The selected segment is an OUTLINED pill: a primary ring over a faint primary tint, with the
412
+ // label in primary. The tint is 8% so the label clears AA on it in both themes (light 5.6:1,
413
+ // dark 4.5:1); a stronger tint drops the dark label under 4.5:1.
410
414
  // ============================================================================
411
415
  .segmented {
416
+ --segmented-selected-bg: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
412
417
  position: relative;
413
418
  display: inline-flex;
414
419
  gap: 2px;
415
420
  padding: 3px;
416
421
  border: 1px solid var(--color-border);
417
422
  border-radius: 999px;
418
- background: var(--color-bg-muted);
423
+ background: var(--color-surface);
419
424
  }
420
425
  .segmented-option {
421
426
  position: relative;
@@ -432,7 +437,7 @@ a.info-row:hover .info-value {
432
437
  font-weight: 600;
433
438
  line-height: 1;
434
439
  white-space: nowrap;
435
- color: var(--color-text-muted);
440
+ color: var(--color-text);
436
441
  cursor: pointer;
437
442
  transition:
438
443
  background 0.14s ease,
@@ -457,7 +462,7 @@ a.info-row:hover .info-value {
457
462
  white-space: nowrap;
458
463
  }
459
464
  .segmented-option:hover {
460
- color: var(--color-text);
465
+ color: var(--color-primary);
461
466
  }
462
467
  // A per-option sub-label under the main text — optional, so a segment without one keeps the
463
468
  // plain single-line layout above untouched.
@@ -475,18 +480,18 @@ a.info-row:hover .info-value {
475
480
  .segmented-option-note {
476
481
  font-size: 10px;
477
482
  font-weight: 400;
478
- // Inherits the option's current text colour (muted, hover, or white-on-checked) rather
483
+ // Inherits the option's current text colour (plain, hover, or primary-on-checked) rather
479
484
  // than a fixed token, so it always matches without a separate state per variant. No opacity
480
485
  // muting — that dropped the sub-label below AA on an unchecked option; the 10px size already
481
486
  // distinguishes it from the label.
482
487
  color: currentColor;
483
488
  }
489
+ // Without JS this per-option pill stands in for the sliding thumb below, so it carries the
490
+ // same ring and tint.
484
491
  .segmented-option:has(.segmented-input:checked) {
485
- background: var(--color-primary);
486
- // Token, not fixed white: the pill is always the brand fill, so its label must
487
- // contrast with THAT fill, not the page — a light-primary brand overrides
488
- // --wui-primary-text to a dark value and this follows automatically.
489
- color: var(--color-primary-text);
492
+ background: var(--segmented-selected-bg);
493
+ box-shadow: inset 0 0 0 2px var(--color-primary);
494
+ color: var(--color-primary);
490
495
  }
491
496
  // The ring is drawn on the label because the input it belongs to has no box.
492
497
  .segmented-option:has(.segmented-input:focus-visible) {
@@ -504,21 +509,25 @@ a.info-row:hover .info-value {
504
509
  width: 0;
505
510
  z-index: 0;
506
511
  border-radius: 999px;
507
- background: var(--color-primary);
508
- box-shadow: 0 2px 8px color-mix(in srgb, var(--color-primary) 32%, transparent);
512
+ background: var(--segmented-selected-bg);
513
+ // An inset ring, not a border: a border would add to the width JS measures and sets.
514
+ box-shadow: inset 0 0 0 2px var(--color-primary);
509
515
  opacity: 0;
510
516
  pointer-events: none;
511
517
  }
512
518
  .segmented.is-animated .segmented-thumb {
513
519
  opacity: 1;
514
- // A spring easing that overshoots a touch and settles — livelier than a flat slide.
520
+ // A fast ease-out that never overshoots. A spring easing slid the thumb past its target, so
521
+ // at the first or last segment the ring crossed the track's edge mid-slide. Clipping the
522
+ // track instead is not an option: it would also clip the options' focus outline.
515
523
  transition:
516
- transform 0.36s cubic-bezier(0.34, 1.42, 0.5, 1),
517
- width 0.36s cubic-bezier(0.34, 1.42, 0.5, 1);
524
+ transform 0.32s cubic-bezier(0.22, 1, 0.36, 1),
525
+ width 0.32s cubic-bezier(0.22, 1, 0.36, 1);
518
526
  }
519
- // While the thumb draws the fill, the checked option drops its own background (keeps white text).
527
+ // While the thumb draws the pill, the checked option drops its own (keeps the primary label).
520
528
  .segmented.is-animated .segmented-option:has(.segmented-input:checked) {
521
529
  background: transparent;
530
+ box-shadow: none;
522
531
  }
523
532
  // Icon pop — the selected segment's glyph springs up a touch as it becomes active.
524
533
  .segmented-option:has(.segmented-input:checked) .wui-icon {
@@ -260,11 +260,18 @@ button.step:focus-visible .step-circle {
260
260
  overflow: hidden;
261
261
  text-overflow: ellipsis;
262
262
  }
263
+ // Where connectors are drawn, a step takes only its content's width (and still shrinks to
264
+ // ellipsize its text) and the connector takes the rest, so the rail joins each step to the
265
+ // next. A journey bar without connectors keeps its evenly spread steps.
266
+ .stepper-journey:has(> .step-line) > .step {
267
+ flex: 0 1 auto;
268
+ }
263
269
  .stepper-journey .step-line {
264
- flex: none;
265
- width: 32px;
270
+ flex: 1 1 32px;
271
+ min-width: 24px;
266
272
  height: 2px;
267
- margin: 0 6px;
273
+ margin: 0 16px;
274
+ border-radius: 999px;
268
275
  background: var(--color-border-strong);
269
276
  }
270
277
  .stepper-journey .step-line.done {