@webority/theme 0.8.6 → 0.8.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webority/theme",
3
- "version": "0.8.6",
3
+ "version": "0.8.7",
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": {
@@ -151,8 +151,8 @@
151
151
  // its own colour. (Primary would otherwise fall back to a washed-out indigo, secondary to a
152
152
  // faint brand tint.) .btn-danger keeps its own faded-red disabled (defined above), since the
153
153
  // reference doesn't cover it and a greyed destructive button loses its warning read.
154
- .btn:disabled:not(.btn-danger),
155
- .btn.disabled:not(.btn-danger) {
154
+ .btn:disabled:not(.btn-danger):not(.is-loading),
155
+ .btn.disabled:not(.btn-danger):not(.is-loading) {
156
156
  background-color: var(--color-bg-muted);
157
157
  border-color: var(--color-border);
158
158
  color: var(--color-text-subtle);
@@ -160,6 +160,38 @@
160
160
  cursor: not-allowed;
161
161
  }
162
162
 
163
+ // Loading is BUSY, not disabled-by-choice: the button keeps its own identity as a LIGHT tint
164
+ // of its variant, never the neutral disabled grey above. It stays non-interactive (still
165
+ // `disabled` for forms/a11y) but reads as "working". Solid variants (primary/danger/warning)
166
+ // tint from their fill and take the fill colour as the label + spinner (spinner is
167
+ // currentColor). Higher specificity than the grey rule, and placed after it, so it wins.
168
+ .btn.is-loading:disabled,
169
+ .btn.is-loading.disabled {
170
+ background-color: color-mix(in srgb, var(--bs-btn-bg) 15%, var(--color-surface));
171
+ border-color: color-mix(in srgb, var(--bs-btn-bg) 15%, var(--color-surface));
172
+ color: var(--bs-btn-bg);
173
+ opacity: 1;
174
+ cursor: progress;
175
+ }
176
+ // Transparent-fill variants (secondary/ghost/subtle/outline-*) have no fill to tint from —
177
+ // key the wash and the label off the accent (text) colour so the hue still shows.
178
+ .btn-secondary.is-loading:disabled,
179
+ .btn-secondary.is-loading.disabled,
180
+ .btn-ghost.is-loading:disabled,
181
+ .btn-ghost.is-loading.disabled,
182
+ .btn-subtle.is-loading:disabled,
183
+ .btn-subtle.is-loading.disabled,
184
+ .btn-outline-primary.is-loading:disabled,
185
+ .btn-outline-primary.is-loading.disabled,
186
+ .btn-outline-danger.is-loading:disabled,
187
+ .btn-outline-danger.is-loading.disabled,
188
+ .btn-outline-light.is-loading:disabled,
189
+ .btn-outline-light.is-loading.disabled {
190
+ background-color: color-mix(in srgb, var(--bs-btn-color) 12%, var(--color-surface));
191
+ border-color: color-mix(in srgb, var(--bs-btn-color) 12%, var(--color-surface));
192
+ color: var(--bs-btn-color);
193
+ }
194
+
163
195
  // ---- Component radii from tokens ------------------------------------
164
196
  // .btn/.card/.input capture Bootstrap's compiled $*-border-radius (the library
165
197
  // ladder) before a product loads, so a product's --wui-radius* override never
package/scss/_tokens.scss CHANGED
@@ -37,6 +37,10 @@
37
37
  --wui-bg-light: #FFFFFF;
38
38
  --wui-bg-elevated: #F9FBFC;
39
39
  --wui-bg-muted: #F5F6FA;
40
+ // Resting fill for every input-like control (text field, select, textarea, and the
41
+ // combobox/phone/date/number wrappers) — a touch lighter than --wui-bg-muted so fields read
42
+ // as their own surface. Change here to restyle every field's background at once.
43
+ --wui-field-bg: #F9FBFC;
40
44
  --wui-border-light: #EBEDF3;
41
45
  --wui-border-soft: #F4F6F8;
42
46
 
@@ -175,7 +179,13 @@
175
179
  --wui-warning-10: #FFF8E6;
176
180
  --wui-warning-5: #FEFBF2;
177
181
 
178
- --wui-danger-text: #b61d42;
182
+ // Error TEXT red. Shares the brand hue (350°) with --wui-danger-active (#E82646) but is
183
+ // darkened until it clears the 4.5:1 AA floor on every surface it lands on — the tightest
184
+ // is the .alert-danger tint (--wui-danger-10 #FFEAEF), where this is 4.77:1 and the brand
185
+ // #E82646 is only 3.81:1. Do NOT swap this back to --wui-danger-active: that has been tried
186
+ // three times and each time the a11y gate rejected the release. The brand red stays in use
187
+ // for non-text danger (fills, borders, icons), which carry a 3:1 floor instead.
188
+ --wui-danger-text: #CF1735;
179
189
  // Deepened danger fill for surfaces that carry WHITE text (btn-danger). The brighter
180
190
  // --wui-danger/-active below are too light for white (3.86:1 / 4.38:1, under AA); these
181
191
  // clear it — #C81340 → 5.80:1, hover → higher. Keep white-on-danger off the other tokens.
@@ -195,13 +205,12 @@
195
205
  --wui-radius-lg: 14px;
196
206
  --wui-radius-xl: 16px;
197
207
 
198
- // Field (input/select/datepicker/…) radius — a rounder squircle than the
199
- // generic radius ladder, matched to the button radius language. md is 18px;
200
- // sm/lg step with the control height (36/44/52px → ~0.41 ratio) so every
201
- // field size keeps a proportional corner.
202
- --wui-field-radius: 18px;
203
- --wui-field-radius-sm: 15px;
204
- --wui-field-radius-lg: 21px;
208
+ // Field (input/select/datepicker/…) radius — a flat 8px corner across every
209
+ // size, for a crisp plain-rounded field (no squircle). Change here to restyle
210
+ // every field's corner at once.
211
+ --wui-field-radius: 8px;
212
+ --wui-field-radius-sm: 8px;
213
+ --wui-field-radius-lg: 8px;
205
214
 
206
215
  // ---- Portal dark palette ----------------------------------------
207
216
  // Marketing stays light. Portal flips [data-theme="dark"] and maps
@@ -11,6 +11,7 @@
11
11
  --color-bg: var(--wui-bg-page);
12
12
  --color-bg-subtle: var(--wui-bg-elevated);
13
13
  --color-bg-muted: var(--wui-bg-muted);
14
+ --color-field-bg: var(--wui-field-bg);
14
15
  --color-surface: var(--wui-bg-light);
15
16
 
16
17
  // Borders
@@ -128,6 +129,9 @@
128
129
  --color-bg: var(--wui-dark-bg-page);
129
130
  --color-bg-subtle: var(--wui-dark-bg-elevated);
130
131
  --color-bg-muted: var(--wui-dark-bg-muted);
132
+ // Dark keeps the field on the muted fill (a light #F9FBFC would blow out the dark UI); the
133
+ // light-mode field-bg override lives on --wui-field-bg.
134
+ --color-field-bg: var(--wui-dark-bg-muted);
131
135
  --color-surface: var(--wui-dark-bg-surface);
132
136
 
133
137
  --color-border: var(--wui-dark-border);
@@ -345,7 +345,6 @@
345
345
  height: 40px;
346
346
  border: 1px solid var(--color-border);
347
347
  border-radius: var(--radius);
348
- corner-shape: squircle;
349
348
  background: var(--color-surface);
350
349
  color: var(--color-text-subtle);
351
350
  }
@@ -353,7 +352,7 @@
353
352
  position: relative;
354
353
  }
355
354
  .search-select-search:focus-within {
356
- background: var(--color-bg-muted);
355
+ background: var(--color-field-bg);
357
356
  }
358
357
  .search-select-search:focus-within svg {
359
358
  color: var(--color-primary);
@@ -485,7 +484,7 @@
485
484
  --wui-datepicker-width: 100%;
486
485
  }
487
486
  .datepicker.open .datepicker-trigger {
488
- background-color: var(--color-bg-muted);
487
+ background-color: var(--color-field-bg);
489
488
  border-color: color-mix(in srgb, var(--color-primary) 30%, transparent);
490
489
  box-shadow: none;
491
490
  }
@@ -806,7 +805,7 @@ button.datepicker-month:hover {
806
805
  color: var(--color-text-subtle);
807
806
  }
808
807
  .daterange.open .daterange-trigger {
809
- background-color: var(--color-bg-muted);
808
+ background-color: var(--color-field-bg);
810
809
  border-color: color-mix(in srgb, var(--color-primary) 30%, transparent);
811
810
  box-shadow: none;
812
811
  }
@@ -895,10 +894,10 @@ button.datepicker-month:hover {
895
894
  width: 100%;
896
895
  height: var(--wui-control-height);
897
896
  border: 1px solid var(--color-border);
898
- border-radius: var(--radius);
899
- corner-shape: squircle;
897
+ // Match AppInput's field-radius ladder exactly (18px md), not the generic --radius (10px).
898
+ border-radius: var(--wui-field-radius);
900
899
  // Match AppInput exactly: muted resting fill that lifts to a clean surface on focus.
901
- background: var(--color-bg-muted);
900
+ background: var(--color-field-bg);
902
901
  // Clip the inner control to the shell's rounded shape — a full-height child was painting
903
902
  // over the squircle border at the bottom, which read as a cut-off bottom edge. The dial
904
903
  // dropdown is position:fixed, so it is unaffected by this.
@@ -908,7 +907,7 @@ button.datepicker-month:hover {
908
907
  background-color 0.12s;
909
908
  }
910
909
  .app-phone-number-input .phone-input-wrap:hover:not(:focus-within) {
911
- background: var(--color-bg-muted);
910
+ background: var(--color-field-bg);
912
911
  border-color: var(--color-border-strong);
913
912
  }
914
913
  // Match AppInput's focus: clean surface fill + a soft primary border.
@@ -922,7 +921,8 @@ button.datepicker-month:hover {
922
921
  }
923
922
  .app-phone-number-input .phone-input-wrap:has(.input.error),
924
923
  .app-phone-number-input .phone-input-wrap:has(.input.is-invalid) {
925
- border-color: color-mix(in srgb, var(--color-danger) 45%, var(--color-surface));
924
+ // Match AppInput's soft invalid border exactly (danger at 30%, mirroring primary-30 focus).
925
+ border-color: color-mix(in srgb, var(--color-danger) 30%, transparent);
926
926
  }
927
927
  // The inner control drops its own chrome — the shell carries it.
928
928
  .app-phone-number-input .phone-input-wrap > .input,
@@ -1017,6 +1017,38 @@ button.datepicker-month:hover {
1017
1017
  padding-left: 10px;
1018
1018
  }
1019
1019
 
1020
+ // ── Size variants ── mirror AppInput's sm/md/lg ladder (height + radius + font).
1021
+ // The size class rides on the .app-phone-number-input container (the App* wrapper
1022
+ // owns it, and it survives <wui-phone>'s innerHTML re-renders); it scales the
1023
+ // shell height/radius, the tel font, the dial-code text, and the adornment pad so
1024
+ // the whole field steps together — md is the base block above.
1025
+ .app-phone-number-input.phone-sm .phone-input-wrap {
1026
+ height: var(--wui-control-height-sm);
1027
+ border-radius: var(--wui-field-radius-sm);
1028
+ }
1029
+ .app-phone-number-input.phone-sm .input {
1030
+ font-size: 13px;
1031
+ }
1032
+ .app-phone-number-input.phone-sm .phone-dial {
1033
+ font-size: 12px;
1034
+ }
1035
+ .app-phone-number-input.phone-sm .phone-country-adornment {
1036
+ padding-left: 10px;
1037
+ }
1038
+ .app-phone-number-input.phone-lg .phone-input-wrap {
1039
+ height: var(--wui-control-height-lg);
1040
+ border-radius: var(--wui-field-radius-lg);
1041
+ }
1042
+ .app-phone-number-input.phone-lg .input {
1043
+ font-size: 16px;
1044
+ }
1045
+ .app-phone-number-input.phone-lg .phone-dial {
1046
+ font-size: 14px;
1047
+ }
1048
+ .app-phone-number-input.phone-lg .phone-country-adornment {
1049
+ padding-left: 14px;
1050
+ }
1051
+
1020
1052
  // Menu, search field and rows all share the .search-select language so the
1021
1053
  // country picker reads as the same component family as every other picker.
1022
1054
  // Fixed, with top/left set from the trigger rect by <wui-phone>, so an
@@ -1058,20 +1090,19 @@ button.datepicker-month:hover {
1058
1090
  padding: 0 12px 0 36px;
1059
1091
  border: 1px solid var(--color-border);
1060
1092
  border-radius: var(--radius);
1061
- corner-shape: squircle;
1062
1093
  font-size: 14px;
1063
1094
  color: var(--color-text);
1064
1095
  background: var(--color-surface);
1065
1096
  outline: none;
1066
1097
  }
1067
1098
  .dropdown-search-input:hover:not(:focus) {
1068
- background: var(--color-bg-muted);
1099
+ background: var(--color-field-bg);
1069
1100
  border-color: var(--color-border-strong);
1070
1101
  }
1071
1102
  // Border is the focus cue everywhere else in the system — no glow ring here.
1072
1103
  // Soft primary, matching AppInput / the phone field shell rather than a full-strength border.
1073
1104
  .dropdown-search-input:focus {
1074
- background: var(--color-bg-muted);
1105
+ background: var(--color-field-bg);
1075
1106
  border-color: color-mix(in srgb, var(--color-primary) 30%, transparent);
1076
1107
  }
1077
1108
  .country-list-scroll {
@@ -1209,7 +1240,7 @@ button.datepicker-month:hover {
1209
1240
  --wui-timepicker-width: 100%;
1210
1241
  }
1211
1242
  .timepicker.open .timepicker-trigger {
1212
- background-color: var(--color-bg-muted);
1243
+ background-color: var(--color-field-bg);
1213
1244
  border-color: color-mix(in srgb, var(--color-primary) 30%, transparent);
1214
1245
  box-shadow: none;
1215
1246
  }
@@ -256,9 +256,8 @@
256
256
  height: var(--wui-control-height);
257
257
  padding: 12px;
258
258
  border-radius: var(--wui-field-radius);
259
- corner-shape: squircle;
260
259
  border: 1px solid var(--color-border);
261
- background: var(--color-bg-muted);
260
+ background: var(--color-field-bg);
262
261
  font-size: 14px;
263
262
  line-height: 1.5;
264
263
  color: var(--color-text);
@@ -305,7 +304,7 @@ textarea.form-control.autosize {
305
304
  .input:hover:not(:focus):not(:disabled):not([readonly]):not([aria-invalid="true"]):not(.is-invalid):not(.error):not(.open),
306
305
  .select:hover:not(:focus):not(:disabled):not([aria-invalid="true"]):not(.is-invalid):not(.error),
307
306
  .textarea:hover:not(:focus):not(:disabled):not([readonly]):not([aria-invalid="true"]):not(.is-invalid):not(.error) {
308
- background-color: var(--color-bg-muted);
307
+ background-color: var(--color-field-bg);
309
308
  border-color: var(--color-border-strong);
310
309
  }
311
310
  .form-control:focus,
@@ -349,14 +348,41 @@ textarea.form-control.autosize {
349
348
  .input::-ms-clear {
350
349
  display: none;
351
350
  }
351
+ // Autofill — kill the browser's yellow/blue autofill background so a filled field keeps its
352
+ // own chrome (muted at rest, clean surface on focus), same as a hand-typed value. WebKit
353
+ // won't let `background` win here, so paint over it with a full-cover inset box-shadow; the
354
+ // 9999s background transition defers the UA's own repaint so there's no fill flash. Applies to
355
+ // the plain field AND the phone's inner tel input (both carry `.input`).
356
+ .form-control:-webkit-autofill,
357
+ .form-control:-webkit-autofill:hover,
358
+ .input:-webkit-autofill,
359
+ .input:-webkit-autofill:hover,
360
+ .textarea:-webkit-autofill,
361
+ .textarea:-webkit-autofill:hover {
362
+ -webkit-box-shadow: 0 0 0 1000px var(--color-field-bg) inset;
363
+ box-shadow: 0 0 0 1000px var(--color-field-bg) inset;
364
+ -webkit-text-fill-color: var(--color-text);
365
+ caret-color: var(--color-text);
366
+ transition: background-color 9999s ease-out 0s;
367
+ }
368
+ // Focus lifts the autofilled field onto the clean surface, matching the typed-value focus state.
369
+ .form-control:-webkit-autofill:focus,
370
+ .input:-webkit-autofill:focus,
371
+ .textarea:-webkit-autofill:focus {
372
+ -webkit-box-shadow: 0 0 0 1000px var(--color-surface) inset;
373
+ box-shadow: 0 0 0 1000px var(--color-surface) inset;
374
+ -webkit-text-fill-color: var(--color-text);
375
+ caret-color: var(--color-text);
376
+ transition: background-color 9999s ease-out 0s;
377
+ }
352
378
  .form-control:disabled,
353
379
  .form-select:disabled,
354
380
  .input:disabled,
355
381
  .select:disabled,
356
382
  .textarea:disabled {
357
383
  // A touch deeper than the resting fill so a disabled field stays distinct now that
358
- // the resting state is itself filled (per the reference's disabled field).
359
- background: color-mix(in srgb, var(--color-bg-muted) 72%, var(--color-border));
384
+ // the resting state is itself filled (--color-border = #EBEDF3, per the reference).
385
+ background: var(--color-border);
360
386
  border-color: transparent;
361
387
  color: var(--color-text-subtle);
362
388
  cursor: not-allowed;
@@ -365,6 +391,14 @@ textarea.form-control.autosize {
365
391
  .field:has(.input:disabled) .label {
366
392
  color: var(--color-text-subtle);
367
393
  }
394
+ // A disabled field's placeholder reads a touch darker than the resting subtle grey
395
+ // (--color-text-muted = #4B5675) so it stays legible against the deeper disabled fill.
396
+ .form-control:disabled::placeholder,
397
+ .form-select:disabled::placeholder,
398
+ .input:disabled::placeholder,
399
+ .textarea:disabled::placeholder {
400
+ color: var(--color-text-muted);
401
+ }
368
402
 
369
403
  // Read-only is NOT disabled — still submitted/selectable/copyable, so no
370
404
  // not-allowed cursor. Match `[readonly]`, never `:read-only` (that also hits
@@ -684,10 +718,9 @@ textarea.form-control.autosize {
684
718
  width: 100%;
685
719
  border: 1px solid var(--color-border);
686
720
  border-radius: var(--wui-field-radius);
687
- corner-shape: squircle;
688
721
  // Soft-filled at rest, white on focus — one shared fill for the whole control so the
689
722
  // prefix/suffix and the input body are always the SAME colour combination as the field.
690
- background: var(--color-bg-muted);
723
+ background: var(--color-field-bg);
691
724
  transition:
692
725
  border-color 0.12s ease,
693
726
  background-color 0.12s ease;
@@ -758,7 +791,7 @@ textarea.form-control.autosize {
758
791
  // text. It MUST match the field's own fill, or the mask reads as a separate patch. The
759
792
  // resting/filled fill is --color-bg-muted (a white notch on the grey fill was the tell); it
760
793
  // only lifts to --color-surface on focus/invalid, matched below.
761
- background: var(--color-bg-muted);
794
+ background: var(--color-field-bg);
762
795
  pointer-events: none;
763
796
  transition:
764
797
  top 0.15s var(--wui-ease-ui),
@@ -840,7 +873,10 @@ textarea.form-control.autosize {
840
873
  color: var(--color-text-subtle);
841
874
  }
842
875
  .required {
843
- // AA against the surface — see .field-error; --color-danger-active is 4.38:1.
876
+ // Same red as .field-error. The mark is aria-hidden so it could carry the brighter brand
877
+ // hue, but then the asterisk and the message it belongs to sat two lines apart in visibly
878
+ // different reds — which read as a bug and got "fixed" three times by repainting the
879
+ // message with the mark's sub-AA red. One token for both ends that.
844
880
  color: var(--color-danger-text);
845
881
  // The gap is CSS, not a significant space in the markup. RequiredMark used to render
846
882
  // `{' '}*` while AppDatePicker and every Razor helper rendered a bare `*`, so the same
@@ -954,10 +990,9 @@ wui-select[compact] {
954
990
  align-items: stretch;
955
991
  border: 1px solid var(--color-border);
956
992
  border-radius: var(--wui-field-radius);
957
- corner-shape: squircle;
958
993
  // Soft-filled at rest, white on focus — the numeric input and its unit pill share the
959
994
  // one wrapper fill, same colour combination as every other field.
960
- background: var(--color-bg-muted);
995
+ background: var(--color-field-bg);
961
996
  transition:
962
997
  border-color 0.12s,
963
998
  background-color 0.12s;
@@ -1008,17 +1043,17 @@ wui-select[compact] {
1008
1043
  display: flex;
1009
1044
  align-items: stretch;
1010
1045
  border: 1px solid var(--color-border);
1011
- border-radius: var(--radius-lg);
1012
- corner-shape: squircle;
1046
+ // Field radius ladder (matches AppInput): md 18px; sm/lg step below.
1047
+ border-radius: var(--wui-field-radius);
1013
1048
  // Match AppInput exactly: muted resting fill that lifts to a clean surface on focus.
1014
- background: var(--color-bg-muted);
1049
+ background: var(--color-field-bg);
1015
1050
  transition:
1016
1051
  border-color 0.12s,
1017
1052
  background-color 0.12s;
1018
1053
  overflow: hidden;
1019
1054
  }
1020
1055
  .num-wrap:hover:not(:focus-within) {
1021
- background: var(--color-bg-muted);
1056
+ background: var(--color-field-bg);
1022
1057
  border-color: var(--color-border-strong);
1023
1058
  }
1024
1059
  .num-wrap:focus-within {
@@ -1098,12 +1133,18 @@ wui-select[compact] {
1098
1133
  // ── Size variants ── the inner input carries .input-sm / .input-lg (height +
1099
1134
  // font), the wrapper carries .num-wrap-sm / .num-wrap-lg to scale the button
1100
1135
  // cells to match. Default (md) uses the base 44px cell above.
1136
+ .num-wrap-sm {
1137
+ border-radius: var(--wui-field-radius-sm);
1138
+ }
1101
1139
  .num-wrap-sm .num-btn {
1102
1140
  width: 36px;
1103
1141
  }
1104
1142
  .num-wrap-sm .num-input {
1105
1143
  font-size: 13px;
1106
1144
  }
1145
+ .num-wrap-lg {
1146
+ border-radius: var(--wui-field-radius-lg);
1147
+ }
1107
1148
  .num-wrap-lg .num-btn {
1108
1149
  width: 52px;
1109
1150
  }
@@ -1113,7 +1154,7 @@ wui-select[compact] {
1113
1154
 
1114
1155
  // Affix (unit) in the num-wrap — a trailing pill after the input.
1115
1156
  .num-wrap .affix {
1116
- background: var(--color-bg-muted);
1157
+ background: var(--color-field-bg);
1117
1158
  border: none;
1118
1159
  border-left: 1px solid var(--color-border);
1119
1160
  padding: 9px 14px;
@@ -1276,6 +1317,24 @@ wui-select[compact] {
1276
1317
  color: var(--color-primary);
1277
1318
  }
1278
1319
 
1320
+ // Size scale (`size`): padding, gap and font step together; md is the base above.
1321
+ .choice-chip-sm {
1322
+ gap: 6px;
1323
+ padding: 6px 11px;
1324
+ font-size: 13px;
1325
+ }
1326
+ .choice-chip-sm .count {
1327
+ font-size: 10px;
1328
+ }
1329
+ .choice-chip-lg {
1330
+ gap: 10px;
1331
+ padding: 11px 18px;
1332
+ font-size: 15px;
1333
+ }
1334
+ .choice-chip-lg .count {
1335
+ font-size: 12px;
1336
+ }
1337
+
1279
1338
  // Form section heading — a branded accent bar rather than a full-width hairline,
1280
1339
  // so a long form reads as designed sections instead of raw rules.
1281
1340
  //
@@ -1352,9 +1411,11 @@ wui-otp {
1352
1411
  justify-content: center;
1353
1412
  width: 44px;
1354
1413
  height: var(--wui-control-height);
1414
+ // Colour model matches AppInput exactly: muted resting fill + soft border, same
1415
+ // field radius. State rules below mirror AppInput's hover/focus/invalid/disabled.
1355
1416
  border: 1px solid var(--color-border);
1356
- border-radius: var(--radius);
1357
- background: var(--color-surface);
1417
+ border-radius: var(--wui-field-radius);
1418
+ background: var(--color-field-bg);
1358
1419
  font-size: 18px;
1359
1420
  font-weight: 600;
1360
1421
  font-variant-numeric: tabular-nums;
@@ -1363,20 +1424,32 @@ wui-otp {
1363
1424
  border-color 0.12s,
1364
1425
  box-shadow 0.12s;
1365
1426
  }
1427
+ // Hover — the whole field lifts its border to strong (AppInput hover), but not while a
1428
+ // slot is active (focus wins) or the field is disabled.
1429
+ .otp:hover:not(:focus-within):not(:has(.otp-field:disabled)) .otp-slot {
1430
+ border-color: var(--color-border-strong);
1431
+ }
1366
1432
  // The caret cannot be seen through a transparent input, so the ACTIVE box carries the
1367
- // affordance instead — derived from selectionStart, since only one element is ever focused.
1433
+ // affordance instead — derived from selectionStart. Matches AppInput's focus: clean
1434
+ // surface fill + soft brand border, no ring.
1368
1435
  .otp-slot.active {
1369
- border-color: color-mix(in srgb, var(--color-primary) 45%, transparent);
1370
- box-shadow: 0 0 0 3px var(--color-primary-soft);
1436
+ background: var(--color-surface);
1437
+ border-color: var(--wui-primary-30);
1438
+ box-shadow: none;
1371
1439
  }
1372
1440
  .otp-field:disabled ~ .otp-slots .otp-slot {
1373
- background: var(--color-bg-muted);
1374
- border-color: var(--color-border);
1441
+ // Same disabled fill as AppInput: a touch deeper than the resting muted fill.
1442
+ background: color-mix(in srgb, var(--color-bg-muted) 72%, var(--color-border));
1443
+ border-color: transparent;
1375
1444
  color: var(--color-text-subtle);
1376
1445
  }
1446
+ // Invalid — soft danger border on a clean surface, mirroring AppInput's .error (danger
1447
+ // at 30%, no ring), not a solid alarm box.
1377
1448
  .field-error ~ .otp .otp-slot,
1378
1449
  .otp.error .otp-slot {
1379
- border-color: var(--color-danger);
1450
+ background: var(--color-surface);
1451
+ border-color: color-mix(in srgb, var(--color-danger) 30%, transparent);
1452
+ box-shadow: none;
1380
1453
  }
1381
1454
  @media (max-width: 575.98px) {
1382
1455
  // Six 44px boxes plus gaps overflow a 360px screen; shrink the box, never the font.
@@ -64,8 +64,8 @@
64
64
  .kpi-value {
65
65
  margin-top: 6px;
66
66
  // The value ("main heading") size flows from one var, so a size tier (sm/md/lg,
67
- // spotlight) or a custom `valueSize` (px) can drive it. Default (md) is 28px.
68
- font-size: var(--wui-kpi-value-size, 28px);
67
+ // spotlight) or a custom `valueSize` (px) can drive it. Default (md) is 24px.
68
+ font-size: var(--wui-kpi-value-size, 24px);
69
69
  font-weight: 700;
70
70
  // Large numerals need negative tracking to stop reading as separate digits;
71
71
  // tabular figures stop a live-updating value from shifting width.
@@ -153,7 +153,7 @@
153
153
  // chrome; orthogonal to variant/inline/attention, so it composes with all of them.
154
154
  .kpi.kpi-lg {
155
155
  padding: 20px;
156
- --wui-kpi-value-size: 40px;
156
+ --wui-kpi-value-size: 28px;
157
157
  }
158
158
  .kpi.kpi-lg .kpi-label {
159
159
  font-size: 14px;
@@ -737,7 +737,8 @@ a.info-row:hover .info-value {
737
737
  // ----------------------------------------------------------------------------
738
738
  .timeline--progress {
739
739
  // Halo widths scale with the dot: the coloured ring and the thin blank ring.
740
- --wui-timeline-halo: calc(var(--wui-timeline-dot) / 4);
740
+ // The active/status halo is a quarter of the dot, trimmed 2px for a tighter glow.
741
+ --wui-timeline-halo: calc(var(--wui-timeline-dot) / 4 - 2px);
741
742
  --wui-timeline-halo-thin: calc(var(--wui-timeline-dot) / 12);
742
743
  // Room on the left so a status dot's soft halo ring isn't clipped.
743
744
  padding-left: calc(var(--wui-timeline-halo) + 1px);
@@ -766,17 +767,14 @@ a.info-row:hover .info-value {
766
767
  .timeline--progress .timeline-item.is-done:has(+ .timeline-item.is-done)::before {
767
768
  background: var(--color-success);
768
769
  }
769
- // Base / upcoming dot: a hollow ring for a not-yet-reached step. Its 2px grey
770
- // halo is OPAQUE (mixed toward the surface, not transparent) so it masks the
771
- // rail behind the dot — the connector only shows in the clear gap, never
772
- // bleeding through the ring's halo.
770
+ // Base / upcoming dot: a hollow ring for a not-yet-reached step. Just the 2px
771
+ // grey ring outline — no outer halo. The dot's opaque surface fill already masks
772
+ // the connector within its bounds, so the rail stays clean without a halo ring.
773
773
  .timeline--progress .timeline-dot,
774
774
  .timeline--progress .timeline-item.is-upcoming .timeline-dot {
775
775
  color: var(--color-text-subtle);
776
776
  background: var(--color-surface);
777
- box-shadow:
778
- inset 0 0 0 2px var(--color-border),
779
- 0 0 0 var(--wui-timeline-halo-thin) color-mix(in srgb, var(--color-text-subtle) 12%, var(--color-surface));
777
+ box-shadow: inset 0 0 0 2px var(--color-border);
780
778
  }
781
779
  // Solid status discs with a white glyph and a soft concentric halo ring in the
782
780
  // dot's own hue. The fill is softened a touch (mixed toward the surface) so the
@@ -834,6 +832,65 @@ a.info-row:hover .info-value {
834
832
  font-weight: 500;
835
833
  }
836
834
 
835
+ // ----------------------------------------------------------------------------
836
+ // Horizontal orientation — dots in a row with the body stacked beneath each,
837
+ // and the rail running left-to-right. Orthogonal to variant: works for both
838
+ // `feed` and `progress`. A per-item connector (like the progress variant's) is
839
+ // used so the rail is independent of the item count.
840
+ // ----------------------------------------------------------------------------
841
+ .timeline--horizontal {
842
+ display: flex;
843
+ flex-direction: row;
844
+ align-items: flex-start;
845
+ // Room above for a status dot's halo ring; no left inset — the dots are
846
+ // centred in their equal-width columns, so a halo never reaches the edge.
847
+ padding: calc(var(--wui-timeline-halo, 6px) + 1px) 0 0;
848
+ gap: 0;
849
+ }
850
+ // One equal-width column per step; the dot sits on top, the body stacks beneath.
851
+ .timeline--horizontal .timeline-item {
852
+ flex: 1 1 0;
853
+ min-width: 0;
854
+ flex-direction: column;
855
+ align-items: center;
856
+ text-align: center;
857
+ gap: 10px;
858
+ padding: 0 6px;
859
+ }
860
+ // Both variants drop their vertical rail; the connector below draws the horizontal one.
861
+ .timeline--horizontal::before {
862
+ display: none;
863
+ }
864
+ // The segment from this dot across to the next — a 2px line at the dot's vertical
865
+ // centre, spanning from this dot's centre to the next dot's centre. z-index 0 keeps
866
+ // it behind the dots, whose opaque fill masks it where they overlap. Overrides the
867
+ // progress variant's vertical connector (same specificity, later in source).
868
+ .timeline--horizontal .timeline-item:not(:last-child)::before {
869
+ content: "";
870
+ position: absolute;
871
+ top: calc(var(--wui-timeline-dot) / 2 - 1px);
872
+ bottom: auto;
873
+ left: 50%;
874
+ right: -50%;
875
+ width: auto;
876
+ height: 2px;
877
+ background: var(--color-border);
878
+ z-index: 0;
879
+ }
880
+ // Centre the body and its rows beneath the dot.
881
+ .timeline--horizontal .timeline-body {
882
+ width: 100%;
883
+ text-align: center;
884
+ }
885
+ .timeline--horizontal .timeline-head {
886
+ flex-direction: column;
887
+ align-items: center;
888
+ gap: 2px;
889
+ }
890
+ .timeline--horizontal .timeline-meta {
891
+ justify-content: center;
892
+ }
893
+
837
894
  // ============================================================================
838
895
  // Feature-comparison table — us vs them. The highlighted column is tinted so
839
896
  // the reader's eye lands on it without a legend.