@webority/theme 0.0.10 → 0.2.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.
@@ -1,9 +1,9 @@
1
1
  // ============================================================================
2
2
  // _wui-components.scss
3
3
  // ----------------------------------------------------------------------------
4
- // Extracted portal component layer — verbatim CSS pulled from the Lumex
5
- // customer portal's `design-system.css`, `components.css`, and
6
- // `portal-helpers.css` (Lumex.Web.Customer/src/assets/styles/).
4
+ // Extracted portal component layer — the shared, reusable UI-component
5
+ // CSS pulled from the customer portal's `design-system.css`,
6
+ // `components.css`, and `portal-helpers.css`.
7
7
  //
8
8
  // Only rules whose subject is a genuinely shared, reusable UI-component class
9
9
  // were kept (fields, badges, modals, tables, drawers, phone/date/search
@@ -16,7 +16,7 @@
16
16
  // ============================================================================
17
17
 
18
18
  // ── Checkbox & Radio ──
19
- // Lumex selection controls: a 20px glyph centred in a 36px circular hit area
19
+ // Selection controls: a 20px glyph centred in a 36px circular hit area
20
20
  // that shows a soft primary-tinted ripple on hover. Unchecked = muted
21
21
  // grey-blue outline; checked = solid primary. Checkbox = squircle box + white
22
22
  // tick; radio = ring + filled centre dot. Pure CSS, no JS — drives the native
@@ -69,7 +69,7 @@
69
69
  background-color 0.15s ease;
70
70
  }
71
71
  .lx-check {
72
- border-radius: 6px;
72
+ border-radius: var(--radius-sm);
73
73
  corner-shape: squircle;
74
74
  }
75
75
  .lx-radio {
@@ -190,9 +190,9 @@
190
190
  .textarea {
191
191
  box-sizing: border-box;
192
192
  width: 100%;
193
- height: 45px;
193
+ height: var(--wui-control-height);
194
194
  padding: 12px;
195
- border-radius: 8px;
195
+ border-radius: var(--radius);
196
196
  border: 1px solid var(--color-border);
197
197
  background: var(--color-surface);
198
198
  font-size: 14px;
@@ -222,7 +222,7 @@
222
222
  outline: none;
223
223
  background-color: var(--color-bg-muted);
224
224
  color: var(--color-text);
225
- border-color: rgba(47, 80, 228, 0.3);
225
+ border-color: color-mix(in srgb, var(--color-primary) 30%, transparent);
226
226
  box-shadow: none;
227
227
  }
228
228
  // Textarea renders as .form-control.textarea (react-bootstrap). Match the
@@ -233,7 +233,7 @@ textarea.textarea.form-control:hover:not(:focus):not(:disabled):not([readonly])
233
233
  }
234
234
  textarea.textarea:focus {
235
235
  outline: none;
236
- border-color: rgba(47, 80, 228, 0.3);
236
+ border-color: color-mix(in srgb, var(--color-primary) 30%, transparent);
237
237
  box-shadow: none;
238
238
  }
239
239
  .input::placeholder,
@@ -269,17 +269,27 @@ textarea.textarea:focus {
269
269
 
270
270
  // Invalid field state (required-field validation) — soft danger-tinted
271
271
  // border on the surface.
272
+ // `[aria-invalid="true"]` is styled alongside `.is-invalid`: React sets the ARIA
273
+ // attribute (it is the accessible source of truth) and does not always add the
274
+ // Bootstrap class, so keying on the class alone left React error states unstyled.
272
275
  .input.is-invalid,
273
276
  .select.is-invalid,
274
277
  .textarea.is-invalid,
275
278
  .input.is-invalid:focus,
276
279
  .select.is-invalid:focus,
277
- .textarea.is-invalid:focus {
280
+ .textarea.is-invalid:focus,
281
+ .input[aria-invalid="true"],
282
+ .select[aria-invalid="true"],
283
+ .textarea[aria-invalid="true"],
284
+ .input[aria-invalid="true"]:focus,
285
+ .select[aria-invalid="true"]:focus,
286
+ .textarea[aria-invalid="true"]:focus {
278
287
  background: var(--color-surface);
279
288
  border-color: color-mix(in srgb, var(--color-danger) 45%, var(--color-surface));
280
289
  box-shadow: none;
281
290
  }
282
- .input.is-invalid {
291
+ .input.is-invalid,
292
+ .input[aria-invalid="true"] {
283
293
  padding-right: 41px;
284
294
  }
285
295
  .field-error {
@@ -472,7 +482,7 @@ textarea.textarea:focus {
472
482
  border: none;
473
483
  background: transparent;
474
484
  color: var(--color-text-subtle);
475
- border-radius: 8px;
485
+ border-radius: var(--radius);
476
486
  cursor: pointer;
477
487
  transition:
478
488
  color 0.12s ease,
@@ -556,7 +566,7 @@ textarea.textarea:focus {
556
566
  // red border around a non-input group (e.g. a structure-form card row)
557
567
  .group-invalid {
558
568
  border: 1px solid var(--color-danger);
559
- border-radius: 10px;
569
+ border-radius: var(--radius);
560
570
  padding: 8px;
561
571
  }
562
572
 
@@ -566,7 +576,7 @@ textarea.textarea:focus {
566
576
  display: flex;
567
577
  align-items: stretch;
568
578
  border: 1px solid var(--color-border);
569
- border-radius: 16px;
579
+ border-radius: var(--radius-lg);
570
580
  corner-shape: squircle;
571
581
  background: var(--color-surface);
572
582
  transition:
@@ -580,7 +590,7 @@ textarea.textarea:focus {
580
590
  }
581
591
  .input-with-unit:focus-within {
582
592
  background: var(--color-bg-muted);
583
- border-color: rgba(47, 80, 228, 0.3);
593
+ border-color: color-mix(in srgb, var(--color-primary) 30%, transparent);
584
594
  }
585
595
  .input-with-unit:has(.num-input.error) {
586
596
  border-color: var(--color-danger);
@@ -657,7 +667,7 @@ textarea.textarea:focus {
657
667
  background: currentColor;
658
668
  }
659
669
 
660
- // Keystone "soft" status badge — pill, 12/600, muted tone fill.
670
+ // "Soft" status badge — pill, 12/600, muted tone fill.
661
671
  .status-label {
662
672
  display: inline-flex;
663
673
  align-items: center;
@@ -670,24 +680,40 @@ textarea.textarea:focus {
670
680
  line-height: 1;
671
681
  white-space: nowrap;
672
682
  }
683
+ // Tones are SEMANTIC (success/warning/danger/info/neutral) — the same
684
+ // vocabulary .chip and .badge use — and derive from the same semantic ramp.
685
+ // The colour-named `.tone-*` classes are kept as deprecated aliases so older
686
+ // Razor call sites keep working; author new markup with the semantic names.
687
+ .status-label.success,
673
688
  .status-label.tone-green {
674
- background: rgba(34, 197, 94, 0.16);
689
+ background: color-mix(in srgb, var(--color-success) 16%, transparent);
675
690
  color: var(--color-success-strong);
676
691
  }
692
+ .status-label.warning,
677
693
  .status-label.tone-amber {
678
- background: rgba(255, 171, 0, 0.16);
694
+ background: color-mix(in srgb, var(--color-warning) 16%, transparent);
679
695
  color: var(--color-warning-strong);
680
696
  }
697
+ .status-label.danger,
681
698
  .status-label.tone-red {
682
- background: rgba(255, 86, 48, 0.16);
699
+ background: color-mix(in srgb, var(--color-danger) 16%, transparent);
683
700
  color: var(--color-danger-strong);
684
701
  }
702
+ .status-label.info,
685
703
  .status-label.tone-blue {
686
- background: rgba(0, 184, 217, 0.16);
704
+ background: color-mix(in srgb, var(--color-info) 16%, transparent);
687
705
  color: var(--color-info-strong);
688
706
  }
707
+ .status-label.primary {
708
+ background: var(--color-primary-soft);
709
+ color: var(--color-primary);
710
+ }
711
+ // Default/neutral — also the bare `.status-label` look, so an unknown or
712
+ // omitted tone still renders as a proper pill instead of transparent text.
713
+ .status-label,
714
+ .status-label.neutral,
689
715
  .status-label.tone-grey {
690
- background: rgba(145, 158, 171, 0.16);
716
+ background: color-mix(in srgb, var(--color-text-subtle) 16%, transparent);
691
717
  color: var(--color-text-subtle);
692
718
  }
693
719
 
@@ -735,7 +761,7 @@ textarea.textarea:focus {
735
761
  gap: 11px;
736
762
  padding: 20px;
737
763
  border: 1.5px solid var(--color-border);
738
- border-radius: 10px;
764
+ border-radius: var(--radius);
739
765
  background: var(--color-surface);
740
766
  cursor: pointer;
741
767
  transition:
@@ -743,10 +769,10 @@ textarea.textarea:focus {
743
769
  background 0.12s ease;
744
770
  }
745
771
  .struct-card:hover {
746
- border-color: rgba(47, 80, 228, 0.3);
772
+ border-color: color-mix(in srgb, var(--color-primary) 30%, transparent);
747
773
  }
748
774
  .struct-card.checked {
749
- border-color: rgba(47, 80, 228, 0.3);
775
+ border-color: color-mix(in srgb, var(--color-primary) 30%, transparent);
750
776
  background: color-mix(in srgb, var(--color-primary) 5%, transparent);
751
777
  }
752
778
  .struct-card.checked .struct-card-title {
@@ -893,7 +919,7 @@ textarea.textarea:focus {
893
919
  }
894
920
  .search-select-control.open {
895
921
  background: var(--color-bg-muted);
896
- border-color: rgba(47, 80, 228, 0.3);
922
+ border-color: color-mix(in srgb, var(--color-primary) 30%, transparent);
897
923
  }
898
924
  .search-select-caret {
899
925
  flex-shrink: 0;
@@ -919,9 +945,9 @@ textarea.textarea:focus {
919
945
  right: 0;
920
946
  background: var(--color-surface);
921
947
  border: 1px solid var(--color-border);
922
- border-radius: 16px;
948
+ border-radius: var(--radius-lg);
923
949
  corner-shape: squircle;
924
- box-shadow: 0 12px 32px rgba(7, 20, 55, 0.12);
950
+ box-shadow: var(--shadow-overlay);
925
951
  padding: 4px;
926
952
  overflow: hidden;
927
953
  }
@@ -945,7 +971,7 @@ textarea.textarea:focus {
945
971
  padding: 0 12px;
946
972
  height: 40px;
947
973
  border: 1px solid var(--color-border);
948
- border-radius: 14px;
974
+ border-radius: var(--radius);
949
975
  corner-shape: squircle;
950
976
  background: var(--color-surface);
951
977
  color: var(--color-text-subtle);
@@ -1001,7 +1027,7 @@ textarea.textarea:focus {
1001
1027
  padding: 9px 14px;
1002
1028
  line-height: 1.4;
1003
1029
  border: none;
1004
- border-radius: 12px;
1030
+ border-radius: var(--radius);
1005
1031
  corner-shape: squircle;
1006
1032
  background: transparent;
1007
1033
  font-size: 14px;
@@ -1043,7 +1069,7 @@ textarea.textarea:focus {
1043
1069
  }
1044
1070
  .datepicker.open .datepicker-trigger {
1045
1071
  background-color: var(--color-bg-muted);
1046
- border-color: rgba(47, 80, 228, 0.3);
1072
+ border-color: color-mix(in srgb, var(--color-primary) 30%, transparent);
1047
1073
  box-shadow: none;
1048
1074
  }
1049
1075
  // Portalled to <body> and fixed-positioned from JS so a modal's
@@ -1057,8 +1083,9 @@ textarea.textarea:focus {
1057
1083
  padding: 18px;
1058
1084
  background: var(--color-surface);
1059
1085
  border: 1px solid var(--color-border);
1060
- border-radius: 18px;
1086
+ border-radius: var(--radius-xl);
1061
1087
  corner-shape: squircle;
1088
+ box-shadow: var(--shadow-overlay);
1062
1089
  animation: datepicker-in 0.14s ease;
1063
1090
  }
1064
1091
  .datepicker-pop.drop-up {
@@ -1198,7 +1225,7 @@ textarea.textarea:focus {
1198
1225
  border: none;
1199
1226
  background: transparent;
1200
1227
  padding: 6px 12px;
1201
- border-radius: 8px;
1228
+ border-radius: var(--radius);
1202
1229
  font-size: 13px;
1203
1230
  font-weight: 600;
1204
1231
  color: var(--color-primary);
@@ -1217,24 +1244,65 @@ textarea.textarea:focus {
1217
1244
  .app-phone-number-input {
1218
1245
  width: 100%;
1219
1246
  }
1247
+ // One field, one border: the WRAPPER owns the chrome (same pattern as
1248
+ // .input-with-unit) so the dial-code picker and the number light up together.
1249
+ // The adornment used to be absolutely positioned over the input, which let the
1250
+ // placeholder run underneath it — masked only by the UA button's grey fill.
1220
1251
  .app-phone-number-input .phone-input-wrap {
1221
- position: relative;
1252
+ display: flex;
1253
+ align-items: center;
1222
1254
  width: 100%;
1255
+ height: var(--wui-control-height);
1256
+ border: 1px solid var(--color-border);
1257
+ border-radius: var(--radius);
1258
+ corner-shape: squircle;
1259
+ background: var(--color-surface);
1260
+ transition:
1261
+ border-color 0.12s,
1262
+ background-color 0.12s;
1223
1263
  }
1224
- .app-phone-number-input .phone-input-wrap > .input {
1225
- width: 100%;
1264
+ .app-phone-number-input .phone-input-wrap:hover:not(:focus-within) {
1265
+ background: var(--color-bg-muted);
1266
+ border-color: var(--color-border-strong);
1226
1267
  }
1227
- .app-phone-number-input .phone-country-adornment {
1228
- position: absolute;
1229
- left: 0;
1230
- top: 0;
1268
+ .app-phone-number-input .phone-input-wrap:focus-within {
1269
+ background: var(--color-bg-muted);
1270
+ border-color: var(--color-primary);
1271
+ }
1272
+ .app-phone-number-input .phone-input-wrap:has(> .input:disabled) {
1273
+ background: var(--color-bg-muted);
1274
+ cursor: not-allowed;
1275
+ }
1276
+ .app-phone-number-input .phone-input-wrap:has(.input.error),
1277
+ .app-phone-number-input .phone-input-wrap:has(.input.is-invalid) {
1278
+ border-color: color-mix(in srgb, var(--color-danger) 45%, var(--color-surface));
1279
+ }
1280
+ // The inner control drops its own chrome — the shell carries it.
1281
+ .app-phone-number-input .phone-input-wrap > .input,
1282
+ .app-phone-number-input .phone-input-wrap > .input:hover,
1283
+ .app-phone-number-input .phone-input-wrap > .input:focus {
1284
+ flex: 1 1 auto;
1285
+ min-width: 0;
1286
+ width: auto;
1231
1287
  height: 100%;
1288
+ border: 0;
1289
+ background: transparent;
1290
+ box-shadow: none;
1291
+ }
1292
+ // `relative` so the menu anchors here (it is NOT positioned by JS — it used to
1293
+ // rely on the adornment being absolute, which is why it appeared to float).
1294
+ .app-phone-number-input .phone-country-adornment {
1295
+ position: relative;
1296
+ flex: none;
1232
1297
  display: flex;
1233
1298
  align-items: center;
1234
1299
  padding-left: 12px;
1235
1300
  z-index: 5;
1236
1301
  }
1237
- .country-dropdown .country-toggle {
1302
+ // NOT scoped under `.country-dropdown` — <wui-phone> renders the toggle inside
1303
+ // `.phone-country-adornment`, with no such wrapper. Scoping it there left the
1304
+ // button on raw UA styling (2px outset border + grey fill) inside the field.
1305
+ .country-toggle {
1238
1306
  display: flex;
1239
1307
  align-items: center;
1240
1308
  gap: 6px;
@@ -1243,29 +1311,43 @@ textarea.textarea:focus {
1243
1311
  background: transparent;
1244
1312
  box-shadow: none;
1245
1313
  color: var(--color-text);
1314
+ cursor: pointer;
1315
+ font: inherit;
1316
+ line-height: 1;
1246
1317
  }
1247
- .country-dropdown .country-toggle::after {
1318
+ .country-toggle::after {
1248
1319
  display: none;
1249
1320
  }
1250
- .country-dropdown .country-toggle:hover,
1251
- .country-dropdown .country-toggle:focus,
1252
- .country-dropdown .country-toggle:active {
1253
- background: transparent !important;
1254
- box-shadow: none !important;
1255
- color: var(--color-text) !important;
1321
+ .country-toggle:hover,
1322
+ .country-toggle:focus,
1323
+ .country-toggle:active {
1324
+ background: transparent;
1325
+ box-shadow: none;
1326
+ color: var(--color-text);
1256
1327
  }
1257
- .app-phone-number-input .phone-flag {
1258
- width: 22px;
1259
- height: 15px;
1260
- border-radius: 2px;
1261
- object-fit: cover;
1262
- box-shadow: 0 1px 2px rgba(16, 24, 40, 0.06);
1328
+ .country-toggle:hover .phone-chevron {
1329
+ color: var(--color-text);
1330
+ }
1331
+ .country-toggle:focus {
1332
+ outline: none;
1263
1333
  }
1334
+ .country-toggle:focus-visible {
1335
+ outline: 2px solid var(--color-primary);
1336
+ outline-offset: 2px;
1337
+ border-radius: var(--radius-sm);
1338
+ }
1339
+ .country-toggle:disabled {
1340
+ cursor: not-allowed;
1341
+ opacity: 0.6;
1342
+ }
1343
+ // Flag glyphs come from the generated .wui-flag-* classes (_flags.scss).
1264
1344
  .app-phone-number-input .phone-chevron {
1265
1345
  color: var(--color-text-muted);
1266
1346
  transition: transform 0.2s ease;
1267
1347
  }
1268
- .country-dropdown.show .phone-chevron {
1348
+ // Keyed off the element's own ARIA state — <wui-phone> toggles aria-expanded,
1349
+ // it never adds react-bootstrap's `.show`.
1350
+ .country-toggle[aria-expanded="true"] .phone-chevron {
1269
1351
  transform: rotate(180deg);
1270
1352
  }
1271
1353
  .app-phone-number-input .phone-dial {
@@ -1274,34 +1356,48 @@ textarea.textarea:focus {
1274
1356
  color: var(--color-text);
1275
1357
  }
1276
1358
 
1359
+ // Menu, search field and rows all share the .search-select language so the
1360
+ // country picker reads as the same component family as every other picker.
1361
+ // Fixed, with top/left set from the trigger rect by <wui-phone>, so an
1362
+ // overflow-hidden card/modal/table wrapper can't clip the list. Sits above the
1363
+ // modal layer, matching .search-select-menu-fixed and .datepicker-pop.
1277
1364
  .country-dropdown-menu {
1365
+ position: fixed;
1366
+ z-index: 1600;
1278
1367
  width: 320px;
1279
- padding: 8px 0;
1368
+ max-width: calc(100vw - 16px);
1369
+ padding: 4px;
1280
1370
  border: 1px solid var(--color-border);
1281
- border-radius: 12px;
1371
+ border-radius: var(--radius-lg);
1372
+ corner-shape: squircle;
1282
1373
  background: var(--color-surface);
1283
- box-shadow: 0 10px 24px -4px rgba(15, 42, 81, 0.14);
1374
+ box-shadow: var(--shadow-overlay);
1284
1375
  overflow: hidden;
1285
1376
  }
1286
1377
  .dropdown-search-wrapper {
1287
1378
  position: relative;
1288
- padding: 4px 12px 8px;
1379
+ margin: 4px;
1289
1380
  }
1290
1381
  .dropdown-search-icon {
1291
1382
  position: absolute;
1292
- left: 24px;
1293
- top: calc(50% - 3px);
1383
+ left: 12px;
1384
+ top: 50%;
1294
1385
  transform: translateY(-50%);
1295
1386
  display: flex;
1296
- color: var(--color-text-muted);
1387
+ color: var(--color-text-subtle);
1297
1388
  pointer-events: none;
1389
+ transition: color 0.12s ease;
1390
+ }
1391
+ .dropdown-search-wrapper:focus-within .dropdown-search-icon {
1392
+ color: var(--color-primary);
1298
1393
  }
1299
1394
  .dropdown-search-input {
1300
1395
  width: 100%;
1301
- height: 38px;
1396
+ height: 40px;
1302
1397
  padding: 0 12px 0 36px;
1303
1398
  border: 1px solid var(--color-border);
1304
- border-radius: 999px;
1399
+ border-radius: var(--radius);
1400
+ corner-shape: squircle;
1305
1401
  font-size: 14px;
1306
1402
  color: var(--color-text);
1307
1403
  background: var(--color-surface);
@@ -1311,47 +1407,58 @@ textarea.textarea:focus {
1311
1407
  background: var(--color-bg-muted);
1312
1408
  border-color: var(--color-border-strong);
1313
1409
  }
1410
+ // Border is the focus cue everywhere else in the system — no glow ring here.
1314
1411
  .dropdown-search-input:focus {
1315
1412
  background: var(--color-bg-muted);
1316
1413
  border-color: var(--color-primary);
1317
- box-shadow: 0 0 0 3px var(--color-primary-soft);
1318
1414
  }
1319
1415
  .country-list-scroll {
1320
1416
  max-height: 250px;
1321
1417
  overflow-y: auto;
1322
1418
  overflow-x: hidden;
1419
+ padding: 4px;
1420
+ display: flex;
1421
+ flex-direction: column;
1422
+ gap: 4px;
1323
1423
  scrollbar-width: thin;
1424
+ scrollbar-gutter: stable;
1324
1425
  }
1325
1426
  .country-list-scroll::-webkit-scrollbar {
1326
1427
  width: 6px;
1327
1428
  }
1328
1429
  .country-list-scroll::-webkit-scrollbar-thumb {
1329
1430
  background: var(--color-border);
1330
- border-radius: 3px;
1431
+ border-radius: 999px;
1331
1432
  }
1433
+ // Rounded, inset rows with a primary-tinted hover — identical to
1434
+ // .search-select-option. (!important is required: react-bootstrap's
1435
+ // .dropdown-item ships its own colour/padding at equal specificity.)
1332
1436
  .country-dropdown-item {
1333
1437
  display: flex !important;
1334
1438
  align-items: center;
1335
1439
  gap: 10px;
1336
- padding: 9px 16px !important;
1440
+ padding: 9px 14px !important;
1441
+ border-radius: var(--radius) !important;
1442
+ corner-shape: squircle;
1337
1443
  cursor: pointer;
1338
1444
  color: var(--color-text) !important;
1339
1445
  background: transparent !important;
1340
1446
  border: 0 !important;
1447
+ transition:
1448
+ background 0.12s ease,
1449
+ color 0.12s ease;
1341
1450
  }
1342
1451
  .country-dropdown-item:hover,
1343
1452
  .country-dropdown-item:focus {
1344
- background: var(--color-bg-subtle) !important;
1453
+ background: var(--color-primary-soft) !important;
1454
+ color: var(--color-primary) !important;
1345
1455
  }
1346
1456
  .country-dropdown-item.selected {
1347
1457
  background: var(--color-primary-soft) !important;
1458
+ color: var(--color-primary) !important;
1459
+ font-weight: 500;
1348
1460
  }
1349
- .country-dropdown-menu .phone-flag-lg {
1350
- width: 22px;
1351
- height: 15px;
1352
- border-radius: 2px;
1353
- object-fit: cover;
1354
- box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05);
1461
+ .country-dropdown-menu .wui-flag {
1355
1462
  flex-shrink: 0;
1356
1463
  }
1357
1464
  .country-name-text {
@@ -1364,6 +1471,15 @@ textarea.textarea:focus {
1364
1471
  font-weight: 400;
1365
1472
  color: var(--color-text-muted);
1366
1473
  }
1474
+ // On a tinted row the label must ride the row colour, not stay grey.
1475
+ .country-dropdown-item:hover .country-name-text,
1476
+ .country-dropdown-item:focus .country-name-text,
1477
+ .country-dropdown-item.selected .country-name-text,
1478
+ .country-dropdown-item:hover .country-code-text,
1479
+ .country-dropdown-item:focus .country-code-text,
1480
+ .country-dropdown-item.selected .country-code-text {
1481
+ color: currentColor;
1482
+ }
1367
1483
  .selected-check-icon {
1368
1484
  margin-left: auto;
1369
1485
  display: flex;
@@ -1423,7 +1539,7 @@ textarea.textarea:focus {
1423
1539
  gap: 12px;
1424
1540
  padding: 12px 14px;
1425
1541
  border: 1px dashed var(--color-border);
1426
- border-radius: 12px;
1542
+ border-radius: var(--radius-lg);
1427
1543
  background: var(--color-surface);
1428
1544
  cursor: pointer;
1429
1545
  transition:
@@ -1480,7 +1596,7 @@ textarea.textarea:focus {
1480
1596
  .attach-preview {
1481
1597
  width: 100%;
1482
1598
  border: 1px solid var(--color-border);
1483
- border-radius: 12px;
1599
+ border-radius: var(--radius-lg);
1484
1600
  background: var(--color-surface);
1485
1601
  overflow: hidden;
1486
1602
  }
@@ -1566,7 +1682,7 @@ textarea.textarea:focus {
1566
1682
  display: flex;
1567
1683
  align-items: stretch;
1568
1684
  width: 100%;
1569
- border-radius: 16px;
1685
+ border-radius: var(--radius-lg);
1570
1686
  corner-shape: squircle;
1571
1687
  border: 1px solid var(--color-border);
1572
1688
  background: var(--color-surface);
@@ -1679,7 +1795,7 @@ textarea.textarea:focus {
1679
1795
  display: block;
1680
1796
  width: 100%;
1681
1797
  height: 350px;
1682
- border-radius: 16px;
1798
+ border-radius: var(--radius-lg);
1683
1799
  corner-shape: squircle;
1684
1800
  border: 1px solid var(--color-border);
1685
1801
  overflow: hidden;
@@ -1746,7 +1862,7 @@ textarea.textarea:focus {
1746
1862
  font-size: 14px;
1747
1863
  font-weight: 600;
1748
1864
  border-radius: 999px;
1749
- box-shadow: 0 6px 18px rgba(16, 24, 40, 0.22);
1865
+ box-shadow: var(--shadow-overlay);
1750
1866
  }
1751
1867
  .file-preview-remove {
1752
1868
  position: absolute;
@@ -2018,7 +2134,7 @@ textarea.textarea:focus {
2018
2134
  place-items: center;
2019
2135
  width: 30px;
2020
2136
  height: 30px;
2021
- border-radius: 9px;
2137
+ border-radius: var(--radius);
2022
2138
  corner-shape: squircle;
2023
2139
  background: var(--color-primary-soft);
2024
2140
  color: var(--color-primary);
@@ -2035,16 +2151,196 @@ textarea.textarea:focus {
2035
2151
  padding: 14px 16px 20px;
2036
2152
  }
2037
2153
 
2154
+ // ── Tabs ──
2155
+ // The tab strip both surfaces render. React drives it as a controlled strip and
2156
+ // Razor additionally renders Bootstrap panels beneath it, but the strip markup —
2157
+ // and therefore the look — is identical.
2158
+ .app-tabs {
2159
+ display: flex;
2160
+ align-items: center;
2161
+ gap: 2px;
2162
+ border-bottom: 1px solid var(--color-border);
2163
+ overflow-x: auto;
2164
+ scrollbar-width: none;
2165
+ }
2166
+ .app-tabs::-webkit-scrollbar {
2167
+ display: none;
2168
+ }
2169
+ .app-tab {
2170
+ display: inline-flex;
2171
+ align-items: center;
2172
+ gap: 8px;
2173
+ padding: 10px 14px;
2174
+ border: 0;
2175
+ // Sits on the strip's own hairline rather than adding a second one.
2176
+ border-bottom: 2px solid transparent;
2177
+ margin-bottom: -1px;
2178
+ background: transparent;
2179
+ font-family: inherit;
2180
+ font-size: 14px;
2181
+ font-weight: 500;
2182
+ line-height: 1.4;
2183
+ color: var(--color-text-muted);
2184
+ white-space: nowrap;
2185
+ cursor: pointer;
2186
+ transition:
2187
+ color 0.12s ease,
2188
+ border-color 0.12s ease;
2189
+ }
2190
+ .app-tab:hover:not(:disabled):not(.active) {
2191
+ color: var(--color-text);
2192
+ }
2193
+ .app-tab.active {
2194
+ color: var(--color-primary);
2195
+ border-bottom-color: var(--color-primary);
2196
+ }
2197
+ .app-tab:disabled {
2198
+ color: var(--color-text-subtle);
2199
+ cursor: not-allowed;
2200
+ }
2201
+ .app-tab:focus-visible {
2202
+ outline: 2px solid var(--color-primary);
2203
+ outline-offset: -2px;
2204
+ border-radius: var(--radius-sm);
2205
+ }
2206
+ .app-tab.danger.active {
2207
+ color: var(--color-danger);
2208
+ border-bottom-color: var(--color-danger);
2209
+ }
2210
+ // Trailing count pill.
2211
+ .app-tab-count {
2212
+ display: inline-flex;
2213
+ align-items: center;
2214
+ justify-content: center;
2215
+ min-width: 20px;
2216
+ padding: 0 6px;
2217
+ height: 20px;
2218
+ border-radius: 999px;
2219
+ background: var(--color-bg-muted);
2220
+ color: var(--color-text-subtle);
2221
+ font-size: 12px;
2222
+ font-weight: 600;
2223
+ }
2224
+ .app-tab.active .app-tab-count {
2225
+ background: var(--color-primary-soft);
2226
+ color: var(--color-primary);
2227
+ }
2228
+
2229
+ // Wrapper for a self-contained tabs widget (strip + panels), as <app-tabs>
2230
+ // renders server-side. React composes the strip and its panels itself.
2231
+ .app-tabs-group > .tab-content {
2232
+ padding-top: 16px;
2233
+ }
2234
+
2235
+ // Pills variant — a segmented control rather than an underline.
2236
+ .app-tabs-pills {
2237
+ gap: 4px;
2238
+ border-bottom: 0;
2239
+ }
2240
+ .app-tabs-pills .app-tab {
2241
+ border-bottom: 0;
2242
+ margin-bottom: 0;
2243
+ border-radius: var(--radius);
2244
+ }
2245
+ .app-tabs-pills .app-tab.active {
2246
+ background: var(--color-primary-soft);
2247
+ }
2248
+
2249
+ // ── Search field ──
2250
+ // The search control both surfaces render: a rounded field with a leading
2251
+ // magnifier that takes the brand colour on focus, plus a clear button that
2252
+ // appears once there's a value.
2253
+ .search-wrap {
2254
+ position: relative;
2255
+ display: flex;
2256
+ align-items: center;
2257
+ width: 100%;
2258
+ }
2259
+ .search-wrap > .search-icon {
2260
+ position: absolute;
2261
+ left: 13px;
2262
+ top: 50%;
2263
+ transform: translateY(-50%);
2264
+ display: grid;
2265
+ place-items: center;
2266
+ color: var(--color-text-subtle);
2267
+ pointer-events: none;
2268
+ transition: color 0.12s ease;
2269
+ }
2270
+ .search-wrap:focus-within > .search-icon {
2271
+ color: var(--color-primary);
2272
+ }
2273
+ .search-field,
2274
+ .search-input {
2275
+ width: 100%;
2276
+ padding-left: 40px;
2277
+ padding-right: 36px;
2278
+ }
2279
+ .search-clear {
2280
+ position: absolute;
2281
+ right: 8px;
2282
+ top: 50%;
2283
+ transform: translateY(-50%);
2284
+ display: grid;
2285
+ place-items: center;
2286
+ width: 26px;
2287
+ height: 26px;
2288
+ padding: 0;
2289
+ border: 0;
2290
+ border-radius: 999px;
2291
+ background: transparent;
2292
+ color: var(--color-text-subtle);
2293
+ cursor: pointer;
2294
+ opacity: 0;
2295
+ pointer-events: none;
2296
+ transition:
2297
+ opacity 0.12s ease,
2298
+ color 0.12s ease;
2299
+ }
2300
+ .search-wrap.has-value .search-clear,
2301
+ .search-wrap:focus-within .search-clear {
2302
+ opacity: 1;
2303
+ pointer-events: auto;
2304
+ }
2305
+ .search-clear:hover {
2306
+ color: var(--color-text);
2307
+ }
2308
+
2038
2309
  // ── Empty state ──
2039
2310
  .empty-state {
2040
2311
  text-align: center;
2041
2312
  padding: 48px 24px;
2042
2313
  color: var(--color-text-subtle);
2043
2314
  }
2315
+ .empty-state-title {
2316
+ font-size: 16px;
2317
+ font-weight: 600;
2318
+ line-height: 1.35;
2319
+ color: var(--color-text);
2320
+ }
2321
+ .empty-state-message {
2322
+ margin: 6px 0 0;
2323
+ font-size: 14px;
2324
+ line-height: 1.5;
2325
+ color: var(--color-text-muted);
2326
+ }
2327
+ .empty-state-action {
2328
+ margin-top: 16px;
2329
+ }
2330
+ // Tighter scale for a widget/tile, as opposed to a full card or page.
2331
+ .empty-state-sm {
2332
+ padding: 28px 16px;
2333
+ }
2334
+ .empty-state-sm .empty-state-title {
2335
+ font-size: 14px;
2336
+ }
2337
+ .empty-state-sm .empty-state-message {
2338
+ font-size: 12px;
2339
+ }
2044
2340
  .empty-state-icon {
2045
2341
  width: 48px;
2046
2342
  height: 48px;
2047
- border-radius: 12px;
2343
+ border-radius: var(--radius-lg);
2048
2344
  background: var(--color-primary-soft);
2049
2345
  color: var(--color-primary);
2050
2346
  display: inline-flex;
@@ -2109,6 +2405,52 @@ textarea.textarea:focus {
2109
2405
  background: transparent;
2110
2406
  }
2111
2407
 
2408
+ // ── Table (base) ──
2409
+ // The shared data-table look, on the plain `.table` class Bootstrap already
2410
+ // gives every table. Without this a product's tables fall back to stock
2411
+ // Bootstrap and drift from the design system; `.table-ledger` below is the
2412
+ // denser, card-inset variant, not a replacement for having a base.
2413
+ .table {
2414
+ --bs-table-bg: transparent;
2415
+ --bs-table-color: var(--color-text);
2416
+ width: 100%;
2417
+ margin-bottom: 0;
2418
+ color: var(--color-text);
2419
+ vertical-align: middle;
2420
+ border-color: var(--color-border);
2421
+ }
2422
+ .table > thead > tr > th {
2423
+ height: 42px;
2424
+ padding: 0 12px;
2425
+ background: var(--color-bg-muted);
2426
+ border-bottom: 1px solid var(--color-border);
2427
+ font-size: 12px;
2428
+ font-weight: 500;
2429
+ letter-spacing: 0.04em;
2430
+ text-transform: uppercase;
2431
+ color: var(--color-text-subtle);
2432
+ white-space: nowrap;
2433
+ vertical-align: middle;
2434
+ }
2435
+ .table > tbody > tr > td {
2436
+ padding: 12px;
2437
+ border-bottom: 1px solid var(--color-border);
2438
+ font-size: 14px;
2439
+ color: var(--color-text-muted);
2440
+ vertical-align: middle;
2441
+ }
2442
+ .table > tbody > tr:last-child > td {
2443
+ border-bottom: 0;
2444
+ }
2445
+ .table > tbody > tr:hover > td {
2446
+ background: var(--color-bg-subtle);
2447
+ }
2448
+ // Numeric / action columns align to the trailing edge.
2449
+ .table .cell-end,
2450
+ .table thead th.cell-end {
2451
+ text-align: right;
2452
+ }
2453
+
2112
2454
  // ── Table + pagination ──
2113
2455
  // DataTable cell helpers
2114
2456
  .table-ledger .lx-cell-end,
@@ -2141,11 +2483,11 @@ textarea.textarea:focus {
2141
2483
  }
2142
2484
  .table-ledger thead th:first-child {
2143
2485
  padding-left: 20px;
2144
- border-radius: 6px 0 0 6px;
2486
+ border-radius: var(--radius-sm) 0 0 var(--radius-sm);
2145
2487
  }
2146
2488
  .table-ledger thead th:last-child {
2147
2489
  padding-right: 20px;
2148
- border-radius: 0 6px 6px 0;
2490
+ border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
2149
2491
  }
2150
2492
  .table-ledger tbody td {
2151
2493
  height: 54px;
@@ -2191,7 +2533,9 @@ textarea.textarea:focus {
2191
2533
  .table-ledger a.u-name:hover {
2192
2534
  text-decoration: none;
2193
2535
  }
2194
- // Identity-cell subline.
2536
+ // Identity-cell subline. `.u-email` is in the group because an identity cell
2537
+ // renders the secondary line as either a generic .lx-text-xs or a .u-email.
2538
+ .table-ledger .u-meta > .u-email,
2195
2539
  .table-ledger .u-meta > .lx-text-xs {
2196
2540
  font-size: 12px;
2197
2541
  font-weight: 400;
@@ -2247,7 +2591,7 @@ textarea.textarea:focus {
2247
2591
  padding: 0 28px 0 12px;
2248
2592
  font-size: 13px;
2249
2593
  font-weight: 600;
2250
- border-radius: 16px;
2594
+ border-radius: var(--radius);
2251
2595
  background-position: right 8px center;
2252
2596
  background-size: 14px;
2253
2597
  }
@@ -2384,27 +2728,27 @@ textarea.textarea:focus {
2384
2728
  }
2385
2729
  .u-action.u-action-preview {
2386
2730
  background: var(--color-primary-soft);
2387
- color: #2f50e4;
2731
+ color: var(--color-primary);
2388
2732
  }
2389
2733
  .u-action.u-action-download {
2390
2734
  background: var(--color-success-soft);
2391
- color: #04b440;
2735
+ color: var(--color-success-active);
2392
2736
  }
2393
2737
  .u-action.u-action-share {
2394
2738
  background: var(--color-warning-soft);
2395
- color: #dfa000;
2739
+ color: var(--color-warning-active);
2396
2740
  }
2397
2741
  .u-action.u-action-preview:hover:not(:disabled) {
2398
2742
  background: var(--color-primary-soft);
2399
- color: #2f50e4;
2743
+ color: var(--color-primary);
2400
2744
  }
2401
2745
  .u-action.u-action-download:hover:not(:disabled) {
2402
2746
  background: var(--color-success-soft);
2403
- color: #04b440;
2747
+ color: var(--color-success-active);
2404
2748
  }
2405
2749
  .u-action.u-action-share:hover:not(:disabled) {
2406
2750
  background: var(--color-warning-soft);
2407
- color: #dfa000;
2751
+ color: var(--color-warning-active);
2408
2752
  }
2409
2753
  .u-action.u-action-danger:hover {
2410
2754
  color: var(--color-danger);
@@ -2490,12 +2834,12 @@ textarea.textarea:focus {
2490
2834
  // deep drop shadow.
2491
2835
  box-shadow:
2492
2836
  0 0 0 1px color-mix(in srgb, var(--color-text) 10%, transparent),
2493
- 0 24px 70px -18px rgba(0, 0, 0, 0.55);
2837
+ var(--shadow-overlay-lg);
2494
2838
  background: var(--color-surface);
2495
2839
  }
2496
2840
  .lx-modal {
2497
2841
  background: var(--color-surface);
2498
- border-radius: 24px;
2842
+ border-radius: var(--radius-xl);
2499
2843
  corner-shape: squircle;
2500
2844
  border: none;
2501
2845
  width: 100%;
@@ -2614,7 +2958,7 @@ textarea.textarea:focus {
2614
2958
  justify-content: center;
2615
2959
  width: 34px;
2616
2960
  height: 34px;
2617
- border-radius: 10px;
2961
+ border-radius: var(--radius);
2618
2962
  corner-shape: squircle;
2619
2963
  flex: none;
2620
2964
  background: var(--color-primary-soft);
@@ -2760,9 +3104,9 @@ textarea.textarea:focus {
2760
3104
  }
2761
3105
  .lx-confirm-actions .btn {
2762
3106
  flex: 1;
2763
- min-height: 45px;
2764
- height: 45px;
2765
- border-radius: 16px;
3107
+ min-height: var(--wui-control-height);
3108
+ height: var(--wui-control-height);
3109
+ border-radius: var(--radius);
2766
3110
  corner-shape: squircle;
2767
3111
  padding-left: 16px;
2768
3112
  padding-right: 16px;
@@ -2859,8 +3203,8 @@ textarea.textarea:focus {
2859
3203
  padding: 12px 14px;
2860
3204
  background: var(--color-surface);
2861
3205
  border: none;
2862
- border-radius: 10px;
2863
- box-shadow: 0 8px 28px rgba(7, 20, 55, 0.14);
3206
+ border-radius: var(--radius-lg);
3207
+ box-shadow: var(--shadow-overlay);
2864
3208
  text-align: left;
2865
3209
  white-space: normal;
2866
3210
  cursor: default;
@@ -2926,6 +3270,31 @@ textarea.textarea:focus {
2926
3270
  align-items: center;
2927
3271
  }
2928
3272
 
3273
+ // Tablet and below: the masthead stops being a single row — the title block and
3274
+ // its actions stack, and the actions stretch to the full width rather than
3275
+ // crowding the title. Bootstrap's lg tier (991.98px), not an invented breakpoint.
3276
+ @media (max-width: 991.98px) {
3277
+ .page-header {
3278
+ flex-direction: column;
3279
+ align-items: stretch;
3280
+ gap: 12px;
3281
+ }
3282
+ .page-actions {
3283
+ flex-wrap: wrap;
3284
+ width: 100%;
3285
+ }
3286
+ // A two-up field row is too tight for a tablet column.
3287
+ .field-row,
3288
+ .field-row-3 {
3289
+ grid-template-columns: 1fr;
3290
+ }
3291
+ // A fixed 420px drawer leaves too little of the page visible on a tablet.
3292
+ .drawer {
3293
+ width: 100vw;
3294
+ max-width: 420px;
3295
+ }
3296
+ }
3297
+
2929
3298
  // ── Row-action / icon buttons ──
2930
3299
  .btn-icon {
2931
3300
  min-height: 0;
@@ -3002,7 +3371,7 @@ textarea.textarea:focus {
3002
3371
  overflow: hidden;
3003
3372
  display: block;
3004
3373
  background: var(--color-bg-muted);
3005
- border-radius: 6px;
3374
+ border-radius: var(--radius-sm);
3006
3375
  }
3007
3376
  .skeleton::after {
3008
3377
  content: "";