@webority/theme 0.9.13 → 0.9.15

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.9.13",
3
+ "version": "0.9.15",
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": {
@@ -692,35 +692,6 @@ button.wui-input-leading:focus-visible {
692
692
  transform: translateY(-1px);
693
693
  }
694
694
 
695
- // ---- Loading shimmer skeleton ----------------------------------------
696
- .shimmer {
697
- position: relative;
698
- overflow: hidden;
699
- background: var(--wui-bg-muted);
700
- }
701
- .shimmer::after {
702
- content: "";
703
- position: absolute;
704
- inset: 0;
705
- background: linear-gradient(90deg,
706
- transparent,
707
- rgba(255, 255, 255, 0.5),
708
- transparent);
709
- transform: translateX(-100%);
710
- animation: shimmer 1.4s infinite;
711
- }
712
- // Dark mode shimmer — lightens the gradient for visibility on darker surfaces.
713
- :root[data-theme="dark"] .shimmer::after {
714
- background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.08), transparent);
715
- }
716
-
717
- // ---- Keyframes -------------------------------------------------------
718
-
719
- @keyframes shimmer {
720
- 0% { transform: translateX(-100%); }
721
- 100% { transform: translateX(100%); }
722
- }
723
-
724
695
  // ---- Native scrollbars (document + opt-in) --------------------------
725
696
  // Scoped to html/body and `.wui-scroll` — never `*`. Global
726
697
  // `*` restyled every nested scroller (modals, tiny panels) and fought
@@ -805,9 +776,6 @@ html.is-scrolling body::-webkit-scrollbar-thumb {
805
776
  .press-feedback {
806
777
  transition: none;
807
778
  }
808
- .shimmer::after {
809
- animation: none;
810
- }
811
779
  // Hover animation options: hide the moving layer and drop lift/glow so no
812
780
  // option animates, regardless of which .btn-anim-* is applied.
813
781
  .btn:not(.btn-icon):not(.u-action)::before {
@@ -45,6 +45,9 @@
45
45
  }
46
46
  .form-check-label,
47
47
  .wui-control-label {
48
+ // `min-width: 0` lets the label wrap inside its column instead of forcing
49
+ // the track to overflow.
50
+ min-width: 0;
48
51
  cursor: pointer;
49
52
  font-size: 14px;
50
53
  line-height: 1.4;
@@ -52,21 +55,23 @@
52
55
  user-select: none;
53
56
  }
54
57
 
55
- // Checkbox / radio / switch hint & error sit BELOW the label on their own line, indented to
56
- // align under the label text (past the glyph + gap) — not inline beside it. `.form-check` is a
57
- // flex row for glyph+label; a full-width flex basis wraps the message to a second line.
58
+ // The block variant is a 2-column grid — glyph in column 1, label in column 2 — so a long
59
+ // label WRAPS inside its own column and stays beside the glyph, never dropping onto a second
60
+ // line below it (a flex-wrap row breaks lines by each item's max-content size, so any label
61
+ // longer than the remaining width is bumped below the glyph regardless of flex sizing). The
62
+ // hint/error occupy column 2 too, landing on the next row aligned under the label text.
58
63
  .form-check {
59
- flex-wrap: wrap;
60
- --wui-check-hint-indent: 28px; // 20px glyph + 8px gap
61
- }
62
- .form-switch {
63
- --wui-check-hint-indent: 48px; // 40px track + 8px gap
64
+ display: grid;
65
+ grid-template-columns: auto 1fr;
66
+ // Vertically centre the glyph against the whole label block, so it sits in
67
+ // the middle when the label wraps to multiple lines.
68
+ align-items: center;
69
+ column-gap: 12px;
70
+ row-gap: 2px;
64
71
  }
65
72
  .form-check > .field-hint,
66
73
  .form-check > .field-error {
67
- flex-basis: 100%;
68
- margin-top: 2px;
69
- padding-left: var(--wui-check-hint-indent);
74
+ grid-column: 2;
70
75
  }
71
76
 
72
77
  // Glyph — shared by BS form-check-input and the wui-check alias.
@@ -711,6 +716,15 @@ textarea.form-control.autosize {
711
716
  outline: 2px solid var(--color-primary);
712
717
  outline-offset: 2px;
713
718
  }
719
+ .pw-toggle .pw-eye-off {
720
+ display: none;
721
+ }
722
+ .pw-toggle.pw-revealed .pw-eye {
723
+ display: none;
724
+ }
725
+ .pw-toggle.pw-revealed .pw-eye-off {
726
+ display: block;
727
+ }
714
728
 
715
729
  // Clearable ✕ — a trailing reset button, placed inside .input-wrap / .input-icon. It only
716
730
  // renders while the field has a value; .has-clear reserves the room so text never sits under it.
@@ -645,11 +645,15 @@
645
645
  white-space: nowrap;
646
646
  }
647
647
  .u-action {
648
+ // The disc size is a custom property so the wrapper can scale it with the glyph `size`
649
+ // (React AppActionIcon / Razor <app-action-icon> set it to `size + 16px` — a constant 8px
650
+ // ring). Default 36px keeps the historical disc for the default `size=20`.
651
+ --wui-action-size: 36px;
648
652
  display: inline-grid;
649
653
  place-items: center;
650
654
  flex: none;
651
- width: 36px;
652
- height: 36px;
655
+ width: var(--wui-action-size);
656
+ height: var(--wui-action-size);
653
657
  padding: 0;
654
658
  border: none;
655
659
  background: var(--color-bg-subtle);
@@ -662,9 +666,12 @@
662
666
  position: relative;
663
667
  }
664
668
  // Touch pointers need a 44px minimum target. Grow the hit area, not the
665
- // ink, so row density and alignment are untouched.
669
+ // ink, so row density and alignment are untouched. This lives on ::before, NOT
670
+ // ::after: an AppActionIcon carries `data-wui-tip` for its tooltip, whose bubble
671
+ // owns `[data-wui-tip]::after` (imported later, same specificity) — sharing ::after
672
+ // would let the tooltip overwrite the hit-area box and silently drop the 44px target.
666
673
  @media (pointer: coarse) {
667
- .u-action::after {
674
+ .u-action::before {
668
675
  content: "";
669
676
  position: absolute;
670
677
  top: 50%;
@@ -91,17 +91,21 @@
91
91
  .faq-a-inner {
92
92
  min-height: 0;
93
93
  overflow: hidden;
94
+ // Horizontal padding is CONSTANT in both states. Toggling it with `.open` made the content
95
+ // jump 18px sideways the instant a pane collapsed (padding isn't transitioned, unlike the
96
+ // grid-row height) — the "slide left" bug. Side padding adds no height, so keeping it on the
97
+ // closed pane is safe; only the BOTTOM padding stays conditional (below), because a constant
98
+ // one leaks height on the collapsed 0fr row.
99
+ padding: 0 18px;
94
100
  font-size: 14px;
95
101
  line-height: 1.6;
96
102
  color: var(--color-text-muted);
97
103
  }
98
- // Padding lives on a child of the clipped element so it does not add height while
99
- // the row is collapsed to 0fr.
100
104
  .faq-a-inner > :first-child {
101
105
  margin-top: 0;
102
106
  }
103
107
  .faq-item.open .faq-a-inner {
104
- padding: 0 18px 16px;
108
+ padding-bottom: 16px;
105
109
  }
106
110
 
107
111
  @media (prefers-reduced-motion: reduce) {
@@ -1121,4 +1121,307 @@ a.info-row:hover .info-value {
1121
1121
  }
1122
1122
  .result-muted .result-code {
1123
1123
  color: var(--color-text-muted);
1124
- }
1124
+ }
1125
+
1126
+ // ─────────────────────────────────────────────────────────────────────────────
1127
+ // AppLink / <app-link> — a text link with a choice of hover animation.
1128
+ // Base is a flat accent-coloured anchor; `.wui-link--<animation>` layers the
1129
+ // hover treatment. Every animation is disabled under prefers-reduced-motion,
1130
+ // where the link falls back to a static underline. React AppLink + Razor
1131
+ // <app-link> emit identical class names, so the two surfaces share this block.
1132
+ // ─────────────────────────────────────────────────────────────────────────────
1133
+ // The line each animation draws is our own thin ::after (or, for `none`, nothing) —
1134
+ // never the browser's text-decoration. Bootstrap's global `a:hover` re-adds
1135
+ // `text-decoration: underline` to every anchor, and `.wui-link` is an <a>, so without
1136
+ // pinning it off here that global underline bleeds onto the link on hover, sitting under
1137
+ // our animated line as a second, thicker underline. Force it off in every state.
1138
+ .wui-link,
1139
+ .wui-link:hover,
1140
+ .wui-link:focus,
1141
+ .wui-link:focus-visible,
1142
+ .wui-link:active {
1143
+ text-decoration: none;
1144
+ }
1145
+ .wui-link {
1146
+ display: inline-flex;
1147
+ align-items: center;
1148
+ gap: 0.35em;
1149
+ position: relative;
1150
+ color: var(--color-primary);
1151
+ font-weight: 500;
1152
+ cursor: pointer;
1153
+ border-radius: 4px;
1154
+ transition: color 0.25s var(--wui-ease-out-quart);
1155
+ }
1156
+ .wui-link:hover {
1157
+ color: var(--color-primary);
1158
+ }
1159
+ .wui-link:focus-visible {
1160
+ outline: 2px solid var(--color-primary);
1161
+ outline-offset: 3px;
1162
+ }
1163
+ .wui-link.is-disabled {
1164
+ color: var(--color-text-muted);
1165
+ cursor: not-allowed;
1166
+ opacity: 0.65;
1167
+ pointer-events: none;
1168
+ }
1169
+ .wui-link__label {
1170
+ position: relative;
1171
+ z-index: 0;
1172
+ white-space: nowrap;
1173
+ }
1174
+ .wui-link__ic {
1175
+ display: inline-flex;
1176
+ flex: none;
1177
+ }
1178
+ .wui-link__ic--left,
1179
+ .wui-link__ic--right {
1180
+ line-height: 0;
1181
+ }
1182
+ .wui-link--sm {
1183
+ font-size: 0.8125rem;
1184
+ }
1185
+ .wui-link--lg {
1186
+ font-size: 1.125rem;
1187
+ }
1188
+
1189
+ // underline family — one thin single line, drawn with the same 1px ::after on every
1190
+ // variant so `static`, `underline`, and `underline-center` never differ in weight.
1191
+ // `underline` slides it in from the left, `underline-center` grows it from the middle,
1192
+ // `static` shows it always (see below).
1193
+ .wui-link--underline .wui-link__label::after,
1194
+ .wui-link--underline-center .wui-link__label::after,
1195
+ .wui-link--static .wui-link__label::after {
1196
+ content: "";
1197
+ position: absolute;
1198
+ left: 0;
1199
+ right: 0;
1200
+ bottom: -2px;
1201
+ height: 1px;
1202
+ background: currentColor;
1203
+ transform: scaleX(0);
1204
+ transform-origin: left center;
1205
+ transition: transform 0.28s var(--wui-ease-out-quart);
1206
+ }
1207
+ .wui-link--underline-center .wui-link__label::after {
1208
+ transform-origin: center;
1209
+ }
1210
+ .wui-link--underline:hover .wui-link__label::after,
1211
+ .wui-link--underline:focus-visible .wui-link__label::after,
1212
+ .wui-link--underline-center:hover .wui-link__label::after,
1213
+ .wui-link--underline-center:focus-visible .wui-link__label::after {
1214
+ transform: scaleX(1);
1215
+ }
1216
+
1217
+ // slide — the whole link (label + icons) nudges to the right.
1218
+ .wui-link--slide {
1219
+ transition: color 0.25s var(--wui-ease-out-quart), transform 0.25s var(--wui-ease-out-quart);
1220
+ }
1221
+ .wui-link--slide:hover,
1222
+ .wui-link--slide:focus-visible {
1223
+ transform: translateX(3px);
1224
+ }
1225
+
1226
+ // icon — a trailing icon slides and fades in on hover.
1227
+ .wui-link--icon .wui-link__ic--right {
1228
+ margin-inline-start: -0.2em;
1229
+ opacity: 0;
1230
+ transform: translateX(-4px);
1231
+ transition: opacity 0.25s var(--wui-ease-out-quart), transform 0.25s var(--wui-ease-out-quart), margin 0.25s var(--wui-ease-out-quart);
1232
+ }
1233
+ .wui-link--icon:hover .wui-link__ic--right,
1234
+ .wui-link--icon:focus-visible .wui-link__ic--right {
1235
+ margin-inline-start: 0;
1236
+ opacity: 1;
1237
+ transform: translateX(2px);
1238
+ }
1239
+
1240
+ // highlight — a tinted background wipes across behind the label.
1241
+ .wui-link--highlight .wui-link__label::before {
1242
+ content: "";
1243
+ position: absolute;
1244
+ inset: -0.12em -0.3em;
1245
+ z-index: -1;
1246
+ border-radius: 4px;
1247
+ background: var(--color-primary-soft);
1248
+ transform: scaleX(0);
1249
+ transform-origin: left center;
1250
+ transition: transform 0.3s var(--wui-ease-out-quart);
1251
+ }
1252
+ .wui-link--highlight:hover .wui-link__label::before,
1253
+ .wui-link--highlight:focus-visible .wui-link__label::before {
1254
+ transform: scaleX(1);
1255
+ }
1256
+
1257
+ // bracket — [ ] brackets slide inward around the label on hover.
1258
+ .wui-link--bracket .wui-link__label {
1259
+ padding-inline: 0.45em;
1260
+ }
1261
+ .wui-link--bracket .wui-link__label::before,
1262
+ .wui-link--bracket .wui-link__label::after {
1263
+ position: absolute;
1264
+ top: 0;
1265
+ opacity: 0;
1266
+ transition: opacity 0.25s var(--wui-ease-out-quart), transform 0.25s var(--wui-ease-out-quart);
1267
+ }
1268
+ .wui-link--bracket .wui-link__label::before {
1269
+ content: "[";
1270
+ left: 0;
1271
+ transform: translateX(0.35em);
1272
+ }
1273
+ .wui-link--bracket .wui-link__label::after {
1274
+ content: "]";
1275
+ right: 0;
1276
+ transform: translateX(-0.35em);
1277
+ }
1278
+ .wui-link--bracket:hover .wui-link__label::before,
1279
+ .wui-link--bracket:focus-visible .wui-link__label::before,
1280
+ .wui-link--bracket:hover .wui-link__label::after,
1281
+ .wui-link--bracket:focus-visible .wui-link__label::after {
1282
+ opacity: 1;
1283
+ transform: translateX(0);
1284
+ }
1285
+
1286
+ // fade — starts in body text colour, resolves to the accent on hover.
1287
+ .wui-link--fade {
1288
+ color: var(--color-text);
1289
+ }
1290
+ .wui-link--fade:hover,
1291
+ .wui-link--fade:focus-visible {
1292
+ color: var(--color-primary);
1293
+ }
1294
+
1295
+ // static — the same thin single line, always visible (no text-decoration, so it matches
1296
+ // the animated underline's weight exactly). At rest the line is fully drawn; on hover it
1297
+ // re-draws with a left→right slide sweep, so the single line still animates.
1298
+ .wui-link--static .wui-link__label::after {
1299
+ transform: scaleX(1);
1300
+ transform-origin: left center;
1301
+ transition: none;
1302
+ }
1303
+ .wui-link--static:hover .wui-link__label::after,
1304
+ .wui-link--static:focus-visible .wui-link__label::after {
1305
+ animation: wui-link-underline-slide 0.32s var(--wui-ease-out-quart);
1306
+ }
1307
+ @keyframes wui-link-underline-slide {
1308
+ from {
1309
+ transform: scaleX(0);
1310
+ }
1311
+ to {
1312
+ transform: scaleX(1);
1313
+ }
1314
+ }
1315
+
1316
+ // grow — the label's letters open up on hover.
1317
+ .wui-link--grow {
1318
+ transition: color 0.25s var(--wui-ease-out-quart), letter-spacing 0.25s var(--wui-ease-out-quart);
1319
+ }
1320
+ .wui-link--grow:hover,
1321
+ .wui-link--grow:focus-visible {
1322
+ letter-spacing: 0.035em;
1323
+ }
1324
+
1325
+ // glow — a soft accent glow blooms behind the text on hover.
1326
+ .wui-link--glow {
1327
+ transition: color 0.25s var(--wui-ease-out-quart), text-shadow 0.3s var(--wui-ease-out-quart);
1328
+ }
1329
+ .wui-link--glow:hover,
1330
+ .wui-link--glow:focus-visible {
1331
+ text-shadow: 0 0 10px color-mix(in srgb, var(--color-primary) 55%, transparent);
1332
+ }
1333
+
1334
+ // shine — a light shimmer sweeps across the label on hover. The text is painted with a
1335
+ // gradient (mostly the accent, with a lighter band) clipped to the glyphs; hovering
1336
+ // animates that band from right to left across the word.
1337
+ .wui-link--shine .wui-link__label {
1338
+ background-image: linear-gradient(
1339
+ 110deg,
1340
+ var(--color-primary) 0%,
1341
+ var(--color-primary) 42%,
1342
+ color-mix(in srgb, var(--color-primary) 25%, white) 50%,
1343
+ var(--color-primary) 58%,
1344
+ var(--color-primary) 100%
1345
+ );
1346
+ background-size: 220% 100%;
1347
+ background-position: 100% 0;
1348
+ background-repeat: no-repeat;
1349
+ -webkit-background-clip: text;
1350
+ background-clip: text;
1351
+ -webkit-text-fill-color: transparent;
1352
+ color: transparent;
1353
+ }
1354
+ .wui-link--shine:hover .wui-link__label,
1355
+ .wui-link--shine:focus-visible .wui-link__label {
1356
+ animation: wui-link-shine 0.9s var(--wui-ease-out-quart);
1357
+ }
1358
+ @keyframes wui-link-shine {
1359
+ from {
1360
+ background-position: 100% 0;
1361
+ }
1362
+ to {
1363
+ background-position: 0 0;
1364
+ }
1365
+ }
1366
+
1367
+ // none — a plain coloured link, no underline, no motion.
1368
+ .wui-link--none .wui-link__label {
1369
+ text-decoration: none;
1370
+ }
1371
+
1372
+ @media (prefers-reduced-motion: reduce) {
1373
+ .wui-link,
1374
+ .wui-link--slide,
1375
+ .wui-link__label::after,
1376
+ .wui-link__label::before,
1377
+ .wui-link--icon .wui-link__ic--right {
1378
+ transition: none;
1379
+ }
1380
+ // Animated underlines settle to a visible static line rather than a hidden one.
1381
+ .wui-link--underline .wui-link__label::after,
1382
+ .wui-link--underline-center .wui-link__label::after {
1383
+ transform: scaleX(1);
1384
+ }
1385
+ // The static line drops its hover re-draw sweep.
1386
+ .wui-link--static:hover .wui-link__label::after,
1387
+ .wui-link--static:focus-visible .wui-link__label::after {
1388
+ animation: none;
1389
+ }
1390
+ .wui-link--slide:hover,
1391
+ .wui-link--slide:focus-visible {
1392
+ transform: none;
1393
+ }
1394
+ .wui-link--icon .wui-link__ic--right {
1395
+ opacity: 1;
1396
+ transform: none;
1397
+ margin-inline-start: 0;
1398
+ }
1399
+ .wui-link--highlight:hover .wui-link__label::before,
1400
+ .wui-link--highlight:focus-visible .wui-link__label::before {
1401
+ transform: none;
1402
+ }
1403
+ .wui-link--bracket .wui-link__label::before,
1404
+ .wui-link--bracket .wui-link__label::after {
1405
+ opacity: 1;
1406
+ transform: none;
1407
+ }
1408
+ // grow / glow settle to their rest state — no letter-spacing shift, no glow bloom.
1409
+ .wui-link--grow:hover,
1410
+ .wui-link--grow:focus-visible {
1411
+ letter-spacing: normal;
1412
+ }
1413
+ .wui-link--glow:hover,
1414
+ .wui-link--glow:focus-visible {
1415
+ text-shadow: none;
1416
+ }
1417
+ // shine drops the shimmer and paints the label a solid accent colour.
1418
+ .wui-link--shine:hover .wui-link__label,
1419
+ .wui-link--shine:focus-visible .wui-link__label {
1420
+ animation: none;
1421
+ }
1422
+ .wui-link--shine .wui-link__label {
1423
+ background-image: none;
1424
+ -webkit-text-fill-color: var(--color-primary);
1425
+ color: var(--color-primary);
1426
+ }
1427
+ }
@@ -676,15 +676,36 @@ button.step:focus-visible .step-circle {
676
676
  gap: 12px;
677
677
  }
678
678
  .repeater-row {
679
+ position: relative;
679
680
  display: flex;
681
+ // When present, the row-label line is the full-width FIRST line (a header band) and the
682
+ // fields wrap to the line beneath it. The markup renders that band only when the row is
683
+ // labelled or removable (see .repeater-row-label) — a removable row's empty band reserves
684
+ // the gap that keeps the fields clear of the absolute remove control. A row that is neither
685
+ // has no band and stays compact; there is no overlap to avoid, since the control is absent too.
686
+ flex-wrap: wrap;
680
687
  align-items: flex-start;
681
688
  gap: 12px;
682
- padding: 14px 16px;
689
+ padding: 14px 16px 16px;
683
690
  border: 1px solid var(--color-border);
684
691
  border-radius: var(--radius-lg);
685
692
  corner-shape: squircle;
686
693
  background: var(--color-surface);
687
694
  }
695
+ // The row's header band. Rendered only when the row is labelled (shows the label) or removable
696
+ // (an empty band reserving the gap that keeps the fields off the remove control); a row that is
697
+ // neither omits it and stays compact. Full-width so the fields wrap below it; min-height + the row
698
+ // gap clear the 36px remove disc; the right padding keeps any label text clear of the top-right
699
+ // control. Set the text via React rowLabel / Razor <app-repeater-row label="…">.
700
+ .repeater-row-label {
701
+ flex: 0 0 100%;
702
+ min-height: 24px;
703
+ padding-right: 44px;
704
+ font-size: 12px;
705
+ font-weight: 600;
706
+ line-height: 1.4;
707
+ color: var(--color-text-muted);
708
+ }
688
709
  .repeater-index {
689
710
  flex: none;
690
711
  width: 22px;
@@ -705,8 +726,13 @@ button.step:focus-visible .step-circle {
705
726
  min-width: 0;
706
727
  }
707
728
  .repeater-remove {
708
- flex: none;
709
- margin-top: 4px;
729
+ // Pulled out of the row's flex flow and parked in the top-right corner of the header
730
+ // band, so it never steals horizontal room from the fields — the last field can run to
731
+ // the row's right edge instead of stopping short. Sits a little below the top edge so it
732
+ // reads as part of the band rather than pinned to the corner.
733
+ position: absolute;
734
+ top: 12px;
735
+ right: 12px;
710
736
  }
711
737
  .repeater-add {
712
738
  align-self: flex-start;
@@ -863,10 +863,8 @@
863
863
  // Round dropzone uploader (workspace/brand logo) and the settings-page photo
864
864
  // ring share this same interactive-uploader pattern.
865
865
  .set-uploader {
866
- // One knob for the whole control — the ring diameter. Presets and the numeric `size`
867
- // prop set it (inline); the ring, initials font and icons all derive from it, so any
868
- // px value scales the picker proportionally. md (120px) is the default.
869
- --wui-logo-size: 120px;
866
+ // The ring diameter (--wui-logo-size) is set on the shell, not here, so the sibling remove
867
+ // control inherits it too; the ring, initials font and icons all derive from it.
870
868
  display: inline-flex;
871
869
  padding: 0;
872
870
  border: none;
@@ -1026,8 +1024,11 @@
1026
1024
  width: max-content;
1027
1025
  max-width: 100%;
1028
1026
  }
1029
- // Positioning context for the remove control, which is a sibling of the button.
1027
+ // Positioning context for the remove control, which is a sibling of the button. It also owns the
1028
+ // one sizing knob — the ring diameter — so BOTH children (the button and the remove control)
1029
+ // inherit it. Presets and the numeric `size` prop set it inline; md (120px) is the default.
1030
1030
  .set-uploader-shell {
1031
+ --wui-logo-size: 120px;
1031
1032
  position: relative;
1032
1033
  display: inline-block;
1033
1034
  }
@@ -1105,8 +1106,11 @@
1105
1106
  display: inline-flex;
1106
1107
  align-items: center;
1107
1108
  justify-content: center;
1108
- width: 28px;
1109
- height: 28px;
1109
+ // Scales with the ring (~23% of the diameter reads well) so it stays proportional at every
1110
+ // size instead of dwarfing a small (sm) ring and vanishing on a large (lg) one. Clamped so it
1111
+ // never shrinks below a comfortable tap target or grows past the badge look; md lands on 28px.
1112
+ width: clamp(20px, calc(var(--wui-logo-size) * 0.23), 34px);
1113
+ height: clamp(20px, calc(var(--wui-logo-size) * 0.23), 34px);
1110
1114
  padding: 0;
1111
1115
  border: 2px solid var(--color-surface);
1112
1116
  border-radius: 50%;
@@ -1115,6 +1119,12 @@
1115
1119
  cursor: pointer;
1116
1120
  transition: transform 0.15s;
1117
1121
  }
1122
+ // The × glyph scales with the button (its width/height attributes are set in JS/Razor as a
1123
+ // baseline; this overrides them, the way the overlay icon is scaled under its container query).
1124
+ .set-uploader-remove > .wui-icon {
1125
+ width: clamp(11px, calc(var(--wui-logo-size) * 0.115), 17px);
1126
+ height: clamp(11px, calc(var(--wui-logo-size) * 0.115), 17px);
1127
+ }
1118
1128
  .set-uploader-remove:hover {
1119
1129
  transform: scale(1.08);
1120
1130
  }
@@ -193,6 +193,9 @@
193
193
  color: var(--color-text-muted);
194
194
  white-space: nowrap;
195
195
  cursor: pointer;
196
+ // A tab can be a real <a href> (link tab) — strip the browser's link underline so a link
197
+ // tab is visually identical to a button tab.
198
+ text-decoration: none;
196
199
  transition:
197
200
  color 0.12s ease,
198
201
  border-color 0.12s ease;
@@ -210,9 +213,18 @@
210
213
  object-fit: cover;
211
214
  border-radius: var(--radius-sm);
212
215
  }
213
- .app-tab:hover:not(:disabled):not(.active) {
216
+ .app-tab:hover:not(:disabled):not(.disabled):not(.active) {
214
217
  color: var(--color-text);
215
218
  }
219
+ // A link tab is a real <a>, and Bootstrap's `a:hover` (plus the theme's link hover) re-add an
220
+ // underline that the base `text-decoration: none` above (lower specificity) can't hold off. Scope
221
+ // under `.app-tabs` so these win in every state — a tab must read as a tab, never a link.
222
+ .app-tabs .app-tab,
223
+ .app-tabs .app-tab:hover,
224
+ .app-tabs .app-tab:focus,
225
+ .app-tabs .app-tab:focus-visible {
226
+ text-decoration: none;
227
+ }
216
228
  .app-tab.active {
217
229
  color: var(--color-primary);
218
230
  // The active tab carries weight as well as colour — colour alone is a weak
@@ -238,10 +250,20 @@
238
250
  .app-tabs-vertical .app-tab.active::after {
239
251
  display: none;
240
252
  }
241
- .app-tab:disabled {
253
+ // `:disabled` matches a disabled <button> tab; `.disabled`/[aria-disabled] cover a disabled
254
+ // link tab (an <a> can't be truly :disabled). Same inert, muted treatment for both.
255
+ .app-tab:disabled,
256
+ .app-tab.disabled,
257
+ .app-tab[aria-disabled="true"] {
242
258
  color: var(--color-text-subtle);
243
259
  cursor: not-allowed;
244
260
  }
261
+ // A disabled link tab already has no href; also stop it swallowing clicks (a <button:disabled>
262
+ // gets this from the UA — an <a> does not).
263
+ .app-tab.disabled,
264
+ .app-tab[aria-disabled="true"] {
265
+ pointer-events: none;
266
+ }
245
267
  .app-tab:focus-visible {
246
268
  outline: 2px solid var(--color-primary);
247
269
  outline-offset: -2px;
@@ -251,7 +273,7 @@
251
273
  .app-tab.danger {
252
274
  color: var(--color-danger-text);
253
275
  }
254
- .app-tab.danger:hover:not(:disabled):not(.active) {
276
+ .app-tab.danger:hover:not(:disabled):not(.disabled):not(.active) {
255
277
  color: var(--color-danger-text);
256
278
  }
257
279
  .app-tab.danger.active {