@webority/theme 0.9.1 → 0.9.3

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.1",
3
+ "version": "0.9.3",
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": {
@@ -109,6 +109,25 @@
109
109
  --bs-btn-disabled-border-color: var(--wui-danger-strong);
110
110
  }
111
111
 
112
+ // Secondary-danger — the destructive counterpart of .btn-secondary: a soft red wash
113
+ // instead of a solid red fill, for a quieter destructive action (e.g. a "Remove" that
114
+ // shouldn't shout like a solid .btn-danger). Mirrors .btn-secondary exactly — low-alpha
115
+ // tint deepening on hover/press, no border — but keyed off the danger tokens. The label
116
+ // uses --wui-danger-text (the AA-safe error red designed to read on a light red tint),
117
+ // not the brighter --color-danger. Disabled falls through to the shared neutral-grey
118
+ // state below (it is not .btn-danger), like .btn-secondary.
119
+ .btn-secondary-danger {
120
+ --bs-btn-color: var(--wui-danger-text);
121
+ --bs-btn-bg: color-mix(in srgb, var(--color-danger) 12%, transparent);
122
+ --bs-btn-border-color: transparent;
123
+ --bs-btn-hover-color: var(--wui-danger-text);
124
+ --bs-btn-hover-bg: color-mix(in srgb, var(--color-danger) 18%, transparent);
125
+ --bs-btn-hover-border-color: transparent;
126
+ --bs-btn-active-color: var(--wui-danger-text);
127
+ --bs-btn-active-bg: color-mix(in srgb, var(--color-danger) 26%, transparent);
128
+ --bs-btn-active-border-color: transparent;
129
+ }
130
+
112
131
  // Soft neutral tinted button — a quieter alternative to .btn-light for a secondary
113
132
  // action that shouldn't compete with a primary/danger CTA in the same row.
114
133
  .btn-subtle {
@@ -177,6 +196,8 @@
177
196
  // key the wash and the label off the accent (text) colour so the hue still shows.
178
197
  .btn-secondary.is-loading:disabled,
179
198
  .btn-secondary.is-loading.disabled,
199
+ .btn-secondary-danger.is-loading:disabled,
200
+ .btn-secondary-danger.is-loading.disabled,
180
201
  .btn-ghost.is-loading:disabled,
181
202
  .btn-ghost.is-loading.disabled,
182
203
  .btn-subtle.is-loading:disabled,
@@ -107,6 +107,12 @@
107
107
  opacity: 1;
108
108
  transform: scale(1);
109
109
  }
110
+ // The tick reads a touch bolder than the icon's default 1.5 hairline, so the confirmation
111
+ // stays crisp at the small top-corner size (CSS beats the glyph's stroke-width attribute;
112
+ // applies on both surfaces since both render the same stroke-based check).
113
+ .struct-card-check svg path {
114
+ stroke-width: 2.25;
115
+ }
110
116
 
111
117
  // Tile variant: big centered illustration + label. Opt in with
112
118
  // className="struct-card-tile".
@@ -171,8 +177,10 @@
171
177
  cursor: not-allowed;
172
178
  opacity: 0.6;
173
179
  }
174
- .search-select-control:hover:not(:disabled):not(.open) {
180
+ .search-select-control:hover:not(:disabled):not(.open):not([aria-invalid="true"]) {
175
181
  // Not while open — an open control keeps its active (primary) border, not the grey hover one.
182
+ // Not while invalid — an errored control (aria-invalid mirrored from the host) holds its red
183
+ // border on hover rather than reverting to the grey hover one.
176
184
  border-color: var(--color-border-strong);
177
185
  }
178
186
  .search-select-control.open {
@@ -484,7 +492,12 @@
484
492
  --wui-datepicker-width: 100%;
485
493
  }
486
494
  .datepicker.open .datepicker-trigger {
487
- background-color: var(--color-field-bg);
495
+ // While the calendar is open the trigger reads as a focused field — clean white surface, not
496
+ // the muted resting fill. Opening moves focus INTO the popup, so :focus drops off the trigger;
497
+ // this .open rule keeps it white (matching .search-select-control.open) instead of snapping
498
+ // back to grey the moment the dropdown appears. It stays white on close too (_close refocuses
499
+ // the trigger → :focus), greying only on an outside click that blurs it.
500
+ background-color: var(--color-surface);
488
501
  border-color: color-mix(in srgb, var(--color-primary) 30%, transparent);
489
502
  box-shadow: none;
490
503
  }
@@ -805,7 +818,8 @@ button.datepicker-month:hover {
805
818
  color: var(--color-text-subtle);
806
819
  }
807
820
  .daterange.open .daterange-trigger {
808
- background-color: var(--color-field-bg);
821
+ // Stay white while the two-month popover is open — see the note on .datepicker.open above.
822
+ background-color: var(--color-surface);
809
823
  border-color: color-mix(in srgb, var(--color-primary) 30%, transparent);
810
824
  box-shadow: none;
811
825
  }
@@ -906,7 +920,9 @@ button.datepicker-month:hover {
906
920
  border-color 0.12s,
907
921
  background-color 0.12s;
908
922
  }
909
- .app-phone-number-input .phone-input-wrap:hover:not(:focus-within) {
923
+ // Not while invalid — an errored control holds its red border/fill on hover (the host carries
924
+ // aria-invalid, so scope the hover treatment to a non-invalid host).
925
+ .app-phone-number-input wui-phone:not([aria-invalid="true"]) .phone-input-wrap:hover:not(:focus-within) {
910
926
  background: var(--color-field-bg);
911
927
  border-color: var(--color-border-strong);
912
928
  }
@@ -1256,7 +1272,8 @@ wui-timepicker[aria-invalid="true"] .timepicker.open .timepicker-trigger {
1256
1272
  --wui-timepicker-width: 100%;
1257
1273
  }
1258
1274
  .timepicker.open .timepicker-trigger {
1259
- background-color: var(--color-field-bg);
1275
+ // Stay white while the time list is open — see the note on .datepicker.open above.
1276
+ background-color: var(--color-surface);
1260
1277
  border-color: color-mix(in srgb, var(--color-primary) 30%, transparent);
1261
1278
  box-shadow: none;
1262
1279
  }
@@ -52,6 +52,23 @@
52
52
  user-select: none;
53
53
  }
54
54
 
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
+ .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
+ }
65
+ .form-check > .field-hint,
66
+ .form-check > .field-error {
67
+ flex-basis: 100%;
68
+ margin-top: 2px;
69
+ padding-left: var(--wui-check-hint-indent);
70
+ }
71
+
55
72
  // Glyph — shared by BS form-check-input and the wui-check alias.
56
73
  .form-check-input,
57
74
  .wui-check,
@@ -299,9 +316,13 @@ textarea.form-control.autosize {
299
316
  overflow: hidden;
300
317
  }
301
318
  // Hover/error guards: without them the hover fill repaints over an invalid border.
319
+ // `:not([aria-expanded="true"])` on the `.input` line: a combobox/dialog trigger whose popup is
320
+ // OPEN (datepicker/date-range/time-picker triggers set aria-expanded on the trigger, but carry
321
+ // their `.open` class on the WRAPPER, not the trigger) must keep its open white fill — without
322
+ // this the resting-hover grey repaints over it the moment the cursor sits on the open trigger.
302
323
  .form-control:hover:not(:focus):not(:disabled):not([readonly]):not([aria-invalid="true"]):not(.is-invalid):not(.error),
303
324
  .form-select:hover:not(:focus):not(:disabled):not([aria-invalid="true"]):not(.is-invalid):not(.error),
304
- .input:hover:not(:focus):not(:disabled):not([readonly]):not([aria-invalid="true"]):not(.is-invalid):not(.error):not(.open),
325
+ .input:hover:not(:focus):not(:disabled):not([readonly]):not([aria-invalid="true"]):not(.is-invalid):not(.error):not(.open):not([aria-expanded="true"]),
305
326
  .select:hover:not(:focus):not(:disabled):not([aria-invalid="true"]):not(.is-invalid):not(.error),
306
327
  .textarea:hover:not(:focus):not(:disabled):not([readonly]):not([aria-invalid="true"]):not(.is-invalid):not(.error) {
307
328
  background-color: var(--color-field-bg);
@@ -1052,7 +1073,9 @@ wui-select[compact] {
1052
1073
  background-color 0.12s;
1053
1074
  overflow: hidden;
1054
1075
  }
1055
- .num-wrap:hover:not(:focus-within) {
1076
+ // Not while invalid — an errored control holds its red border/fill on hover rather than
1077
+ // reverting to the grey hover one (matches AppInput, whose hover rule excludes the error state).
1078
+ .num-wrap:hover:not(:focus-within):not(:has(.num-input.error)):not(:has(.num-input.is-invalid)):not(:has(.num-input[aria-invalid="true"])) {
1056
1079
  background: var(--color-field-bg);
1057
1080
  border-color: var(--color-border-strong);
1058
1081
  }
@@ -1260,7 +1283,7 @@ wui-select[compact] {
1260
1283
  align-items: center;
1261
1284
  gap: 8px;
1262
1285
  padding: 9px 14px;
1263
- border: 1.5px solid var(--color-border);
1286
+ border: 1px solid var(--color-border);
1264
1287
  border-radius: 999px;
1265
1288
  font-size: 14px;
1266
1289
  font-weight: 500;
@@ -1286,7 +1309,9 @@ wui-select[compact] {
1286
1309
  pointer-events: none;
1287
1310
  }
1288
1311
  .choice-chip.checked {
1289
- border-color: var(--color-primary);
1312
+ // Match AppChoiceCard's checked border: a soft primary-30 edge, not the
1313
+ // full-strength primary line (they read as the same selection language).
1314
+ border-color: color-mix(in srgb, var(--color-primary) 30%, transparent);
1290
1315
  background: var(--color-primary-soft);
1291
1316
  color: var(--color-primary);
1292
1317
  }
@@ -58,6 +58,19 @@
58
58
  align-items: center;
59
59
  text-align: center;
60
60
  padding: 40px 24px;
61
+ // An empty ledger still renders its header row, whose nowrap cells can push the
62
+ // table wider than the viewport, so `.table-wrap` scrolls horizontally with no
63
+ // data to justify it. This state row spans the full scroll width, so without a
64
+ // pin the message centres over that width and slides off screen on mobile.
65
+ // Stick it to the scrollport's left edge and size it to the visible wrap width
66
+ // (`100cqw`, from the container on `.table-wrap`), so the message stays centred
67
+ // and fully in view no matter how far the header scrolls. `left: 0` is relative
68
+ // to the scroll container; on a wide screen `100cqw` equals the content width, so
69
+ // this is a no-op there.
70
+ position: sticky;
71
+ left: 0;
72
+ width: 100cqw;
73
+ box-sizing: border-box;
61
74
  }
62
75
  .tbl-empty-icon {
63
76
  display: inline-grid;
@@ -96,10 +109,23 @@
96
109
  }
97
110
  // The empty state owns its own padding and is not a row you can act on — no
98
111
  // cell padding, no border, and no hover fill from .table / .table-ledger.
99
- .table td.wui-table-empty:has(> .tbl-empty) {
112
+ .table td.wui-table-empty:has(> .tbl-empty),
113
+ .table td.wui-table-empty:has(> .tbl-empty-plain) {
100
114
  padding: 0;
101
115
  border-bottom: 0;
102
116
  }
117
+ // A plain-string empty (no `.tbl-empty` wrapper) — carries the padding the cell
118
+ // used to own, plus the same viewport pin as `.tbl-empty` so it stays on screen
119
+ // when a narrow ledger scrolls horizontally (see the pin note on `.tbl-empty`).
120
+ .tbl-empty-plain {
121
+ padding: 36px 24px;
122
+ text-align: center;
123
+ color: var(--color-text-muted);
124
+ position: sticky;
125
+ left: 0;
126
+ width: 100cqw;
127
+ box-sizing: border-box;
128
+ }
103
129
  .table tbody tr:hover td.wui-table-empty:has(> .tbl-empty),
104
130
  .table-ledger tbody tr:hover td.wui-table-empty:has(> .tbl-empty) {
105
131
  background: transparent;
@@ -253,6 +279,11 @@
253
279
  // class (set --wui-table-pad) without overriding this selector. Bottom padding is now
254
280
  // included (was 0) so the last row isn't flush against the card edge.
255
281
  padding: var(--wui-table-pad, 20px);
282
+ // Query container for the empty-state pin below. The header row can force the
283
+ // ledger wider than the viewport, so on a narrow screen `.table-wrap` scrolls
284
+ // horizontally; `100cqw` here is the *visible* wrap width regardless of that
285
+ // scroll, which is what keeps the empty message on screen (see `.tbl-empty`).
286
+ container-type: inline-size;
256
287
  }
257
288
  .table-ledger {
258
289
  border-spacing: 0;
@@ -893,6 +893,10 @@
893
893
  border: 1px dashed color-mix(in srgb, var(--color-border-strong) 75%, transparent);
894
894
  cursor: pointer;
895
895
  transition: border-color 0.18s;
896
+ // Query container so the overlay can drop its label on a small ring (see the @container
897
+ // rule after .set-uploader-overlay). container-type also makes the ring the containing
898
+ // block for the absolute overlay — same role its position:relative already plays.
899
+ container-type: inline-size;
896
900
  }
897
901
  .set-uploader-ring:hover,
898
902
  .set-uploader:hover .set-uploader-ring,
@@ -925,10 +929,16 @@
925
929
  flex-direction: column;
926
930
  align-items: center;
927
931
  justify-content: center;
928
- gap: 4px;
932
+ // Label + gap scale with the ring so a small (sm, 72px) ring's overlay stays legible and
933
+ // uncramped instead of wrapping a fixed 11px "Update photo" into a tight disc where it
934
+ // overlaps the initials. Clamped so md/lg land on the same 11px / 4px gap as before.
935
+ gap: clamp(2px, calc(var(--wui-logo-size) * 0.035), 4px);
936
+ padding: 0 6%;
929
937
  background: var(--color-scrim-strong);
930
938
  color: #fff;
931
- font-size: 11px;
939
+ font-size: clamp(9px, calc(var(--wui-logo-size) * 0.1), 11px);
940
+ line-height: 1.15;
941
+ text-align: center;
932
942
  font-weight: 600;
933
943
  opacity: 0;
934
944
  transition: opacity 0.18s;
@@ -938,6 +948,62 @@
938
948
  .set-uploader:focus-visible .set-uploader-overlay {
939
949
  opacity: 1;
940
950
  }
951
+ // A ring too small to fit "Update photo" without cramping it over the initials (sm = 72px,
952
+ // and any custom size below ~95px) drops the label and shows the icon alone — a compact edit
953
+ // affordance. The lone icon grows a little so it reads as the whole overlay's cue. Driven by a
954
+ // container query on the ring, so it works for every size, preset or custom px.
955
+ @container (max-width: 95px) {
956
+ .set-uploader-overlay > span {
957
+ display: none;
958
+ }
959
+ .set-uploader-overlay > .wui-icon {
960
+ width: calc(var(--wui-logo-size) * 0.34);
961
+ height: calc(var(--wui-logo-size) * 0.34);
962
+ }
963
+ // The label has no room in the ring, so surface it as a hover/focus tooltip below the ring
964
+ // (reusing the AppTooltip surface look). Text comes from data-overlay-label on the ring — a
965
+ // distinct attribute from data-wui-tip so the global tooltip rule never fires this on a large
966
+ // ring. Only rendered inside this container query, so md/lg (label visible) never get it.
967
+ .set-uploader-ring[data-overlay-label]::after {
968
+ content: attr(data-overlay-label);
969
+ position: absolute;
970
+ top: calc(100% + 8px);
971
+ left: 50%;
972
+ transform: translateX(-50%);
973
+ z-index: var(--wui-z-menu, 1080);
974
+ width: max-content;
975
+ max-width: 160px;
976
+ padding: 6px 10px;
977
+ border-radius: var(--radius, 10px);
978
+ corner-shape: squircle;
979
+ background: var(--color-surface);
980
+ color: var(--color-text);
981
+ border: 1px solid var(--color-border-light);
982
+ box-shadow: var(--wui-shadow-overlay);
983
+ font-size: 0.75rem;
984
+ font-weight: 500;
985
+ line-height: 1.3;
986
+ white-space: nowrap;
987
+ pointer-events: none;
988
+ opacity: 0;
989
+ visibility: hidden;
990
+ transition:
991
+ opacity 0.12s ease,
992
+ visibility 0.12s;
993
+ }
994
+ // :not(:disabled) — a disabled/loading picker can't be updated, so it shows no tooltip
995
+ // (mirrors the overlay, which isn't rendered in those states).
996
+ .set-uploader:not(:disabled):hover .set-uploader-ring[data-overlay-label]::after,
997
+ .set-uploader:not(:disabled):focus-visible .set-uploader-ring[data-overlay-label]::after {
998
+ opacity: 1;
999
+ visibility: visible;
1000
+ }
1001
+ @media (prefers-reduced-motion: reduce) {
1002
+ .set-uploader-ring[data-overlay-label]::after {
1003
+ transition: none;
1004
+ }
1005
+ }
1006
+ }
941
1007
  // While uploading: keep the overlay (spinner) visible and signal it's not
942
1008
  // clickable.
943
1009
  .set-uploader-overlay.is-loading {
@@ -213,14 +213,12 @@
213
213
  font-weight: 600;
214
214
  }
215
215
  // Static fallback underline (no-JS / first paint); the sliding ink bar takes over once
216
- // wired. Inset 10px (tab padding is 14px) so the bar runs the label width + 4px each side
217
- // — reads as belonging to the label yet never touches a rounded container edge. Matches
218
- // the wired ink bar's inset below.
216
+ // wired. Spans the tab's full width (inset defaults to 0), matching the wired ink bar below.
219
217
  .app-tab.active::after {
220
218
  content: "";
221
219
  position: absolute;
222
- left: var(--wui-tab-underline-inset, 10px);
223
- right: var(--wui-tab-underline-inset, 10px);
220
+ left: var(--wui-tab-underline-inset, 0px);
221
+ right: var(--wui-tab-underline-inset, 0px);
224
222
  bottom: -1px;
225
223
  height: 2px;
226
224
  border-radius: 2px;
@@ -268,18 +266,22 @@
268
266
  left: 0;
269
267
  bottom: -1px;
270
268
  height: 2px;
271
- // Inset 10px each side (tab padding is 14px) so the bar runs the label width + 4px per
272
- // side — clear of a rounded container edge; falls to 0 rather than going negative on a
273
- // tab narrower than the inset. Pills reset the inset to 0 (the pill fills the tab).
274
- width: max(0px, calc(var(--wui-tab-w, 0) - var(--wui-tab-underline-inset, 10px) * 2));
269
+ // Spans the tab's FULL width (the inset defaults to 0). A consumer can inset it via
270
+ // `--wui-tab-underline-inset`; falls to 0 rather than going negative on a tab narrower than
271
+ // any inset it sets. Pills keep the inset at 0 too (the pill fills the tab).
272
+ width: max(0px, calc(var(--wui-tab-w, 0) - var(--wui-tab-underline-inset, 0px) * 2));
275
273
  background: var(--color-primary);
276
274
  border-radius: 2px;
277
- transform: translateX(calc(var(--wui-tab-x, 0) + var(--wui-tab-underline-inset, 10px)));
275
+ transform: translateX(calc(var(--wui-tab-x, 0) + var(--wui-tab-underline-inset, 0px)));
278
276
  opacity: 0;
279
277
  pointer-events: none;
278
+ // Smooth ease-OUT (no overshoot), not the elastic bounce: a full-width bar sits flush to the
279
+ // edge tabs, so an elastic overshoot would push it past the strip and the `overflow-x: auto`
280
+ // clip would shear off its rounded corner. A decelerating slide reaches the tab exactly and
281
+ // never crosses the edge, so the radius is always intact.
280
282
  transition:
281
- transform 0.42s var(--wui-ease-elastic),
282
- width 0.42s var(--wui-ease-elastic),
283
+ transform 0.42s var(--wui-ease-out-quart),
284
+ width 0.42s var(--wui-ease-out-quart),
283
285
  opacity 0.15s ease;
284
286
  }
285
287
  .app-tabs[data-wui-tabs] .app-tab-indicator {
@@ -309,12 +309,6 @@
309
309
  line-height: 0;
310
310
  margin-bottom: 22px;
311
311
  }
312
- // Lift the badge off the modal surface with a soft outer shadow. `drop-shadow` traces
313
- // the SVG's circular alpha (a `box-shadow` would cast a square), and takes no spread —
314
- // so this is authored spread-free rather than reusing the `--wui-shadow-overlay` token.
315
- .wui-confirm-icon svg {
316
- filter: drop-shadow(0 10px 18px rgba(7, 20, 55, 0.18));
317
- }
318
312
  // Icon size tracks the copy: a title+message dialog keeps the full 80px badge (set on
319
313
  // the element), but a lone title (no message) is one line and gets a smaller badge.
320
314
  // `.wui-confirm-msg` is a real element rendered only when a message exists, so `:has()`
@@ -345,17 +339,18 @@
345
339
  .wui-modal-footer:has(.wui-confirm-actions) {
346
340
  border-top: none;
347
341
  padding-top: 0;
348
- }
342
+ // The full-width CTA row makes the footer's insets read as a frame, so the gap below the
343
+ // buttons matches the 22px on their left and right (the base footer's 16px bottom looked
344
+ // cramped against the equal side gaps).
345
+ padding-bottom: 22px;
346
+ }
347
+ // Layout ONLY — the two CTAs share the row equally. Their chrome (radius, height, type
348
+ // scale, weight, padding) comes entirely from AppButton, so a confirm button reads identically
349
+ // to an AppButton anywhere else in the app. No separate button styling lives here: the dialog
350
+ // used to re-skin its buttons (10px radius, 14px/500 type, forced height), which made the confirm
351
+ // CTAs look like a different button than every other AppButton.
349
352
  .wui-confirm-actions .btn {
350
353
  flex: 1;
351
- min-height: var(--wui-control-height);
352
- height: var(--wui-control-height);
353
- border-radius: var(--radius);
354
- corner-shape: squircle;
355
- padding-left: 16px;
356
- padding-right: 16px;
357
- font-size: 14px;
358
- font-weight: 500;
359
354
  }
360
355
 
361
356
  // ── Tooltip ──
@@ -600,13 +600,17 @@
600
600
 
601
601
  display: flex;
602
602
  align-items: center;
603
+ // On phones the trailing action drops onto its own row BELOW the content (the flex `gap`
604
+ // supplies the vertical space) so a button never crushes the message into a sliver; from md
605
+ // up it sits inline at the right — a VIEWPORT rule, not the callout's own width. See the
606
+ // `.info-callout-action` breakpoint below.
607
+ flex-wrap: wrap;
603
608
  gap: 12px;
604
609
  padding: 14px 18px;
605
610
  border: 1px solid var(--callout-border);
606
611
  // A thick coloured left edge — the callout's signature aside marker.
607
612
  border-left: 4px solid var(--callout-accent);
608
- border-radius: 20px;
609
- corner-shape: squircle;
613
+ border-radius: 8px;
610
614
  background: var(--callout-bg);
611
615
  font-size: 14px;
612
616
  // Sub-content (the body/subtitle) reads at #252F4A / 400; the heading and the
@@ -690,14 +694,30 @@
690
694
  min-width: 0;
691
695
  font-size: 12px;
692
696
  }
693
- // Trailing action slot — a button (or any node) pinned to the right, vertically centred.
697
+ // Trailing action slot — a button (or any node). Mobile-first: on phones it takes its own full
698
+ // row below the content (left-aligned there); from md up it collapses to its natural width and
699
+ // sits inline at the right edge of the content row.
694
700
  .info-callout-action {
695
- flex: none;
701
+ flex: 1 1 100%;
696
702
  align-self: center;
697
- display: inline-flex;
703
+ display: flex;
704
+ justify-content: flex-start;
698
705
  align-items: center;
699
706
  gap: 8px;
700
707
  }
708
+ // From md up the row never wraps — the content shrinks to seat the action inline at the right,
709
+ // so a button stays beside the text on any large screen, even when the callout sits in a narrow
710
+ // column (the wrap-below is a phones-only treatment).
711
+ @include media-breakpoint-up(md) {
712
+ .info-callout {
713
+ flex-wrap: nowrap;
714
+ }
715
+ .info-callout-action {
716
+ flex: none;
717
+ justify-content: initial;
718
+ margin-left: auto;
719
+ }
720
+ }
701
721
  .info-callout-heading {
702
722
  font-size: 14px;
703
723
  font-weight: 600;
@@ -751,9 +771,8 @@
751
771
  // the heading to the variant. Only the heading takes the variant colour — the body
752
772
  // stays dark ink so the message reads calmly.
753
773
  .alert {
754
- --bs-alert-border-radius: 20px;
755
- border-radius: 20px;
756
- corner-shape: squircle;
774
+ --bs-alert-border-radius: 8px;
775
+ border-radius: 8px;
757
776
  padding: 16px 18px;
758
777
  border-width: 1px;
759
778
  }