bitboss-ui 3.0.0-beta.21 → 3.0.0-beta.22

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.
Files changed (183) hide show
  1. package/README.md +55 -3
  2. package/bin/bitboss-ui-mcp.mjs +419 -9
  3. package/bin/bitboss-ui.mjs +293 -22
  4. package/dist/ai/BbAccordion.md +21 -23
  5. package/dist/ai/BbAlert.md +6 -12
  6. package/dist/ai/BbAvatar.md +60 -24
  7. package/dist/ai/BbBadge.md +12 -20
  8. package/dist/ai/BbBaseButton.md +14 -16
  9. package/dist/ai/BbBaseCheckboxIcon.md +6 -10
  10. package/dist/ai/BbBaseRadioIcon.md +6 -10
  11. package/dist/ai/BbBaseSwitchIcon.md +5 -9
  12. package/dist/ai/BbBreadcrumbs.md +23 -34
  13. package/dist/ai/BbButton.md +35 -47
  14. package/dist/ai/BbCheckbox.md +21 -37
  15. package/dist/ai/BbCheckboxGroup.md +13 -13
  16. package/dist/ai/BbCollapsible.md +14 -26
  17. package/dist/ai/BbColorInput.md +13 -23
  18. package/dist/ai/BbColorPalette.md +23 -35
  19. package/dist/ai/BbConfirm.md +17 -32
  20. package/dist/ai/BbDatePickerInput.md +15 -21
  21. package/dist/ai/BbDialog.md +159 -62
  22. package/dist/ai/BbDropdown.md +21 -25
  23. package/dist/ai/BbDropdownButton.md +13 -30
  24. package/dist/ai/BbDropzone.md +9 -11
  25. package/dist/ai/BbIcon.md +44 -33
  26. package/dist/ai/BbIndicator.md +2 -2
  27. package/dist/ai/BbNumberInput.md +18 -20
  28. package/dist/ai/BbOffCanvas.md +42 -42
  29. package/dist/ai/BbPagination.md +21 -23
  30. package/dist/ai/BbPopover.md +15 -17
  31. package/dist/ai/BbProgress.md +40 -51
  32. package/dist/ai/BbRadio.md +9 -9
  33. package/dist/ai/BbRadioGroup.md +7 -11
  34. package/dist/ai/BbRating.md +8 -12
  35. package/dist/ai/BbSelect.md +32 -36
  36. package/dist/ai/BbSelectPopover.md +19 -31
  37. package/dist/ai/BbSlider.md +15 -19
  38. package/dist/ai/BbSmoothHeight.md +16 -31
  39. package/dist/ai/BbSpinner.md +118 -74
  40. package/dist/ai/BbSwitch.md +25 -18
  41. package/dist/ai/BbSwitchGroup.md +9 -14
  42. package/dist/ai/BbTable.md +102 -55
  43. package/dist/ai/BbTabs.md +59 -82
  44. package/dist/ai/BbTag.md +12 -23
  45. package/dist/ai/BbTextInput.md +20 -26
  46. package/dist/ai/BbTextarea.md +28 -34
  47. package/dist/ai/BbToast.md +14 -34
  48. package/dist/ai/BbTooltip.md +22 -20
  49. package/dist/ai/BbTree.md +46 -72
  50. package/dist/ai/changelog.json +169 -62
  51. package/dist/ai/components.json +1467 -9
  52. package/dist/ai/composables/useConfirm.md +5 -2
  53. package/dist/ai/composables/useToast.md +4 -2
  54. package/dist/ai/guides/agent-contract.md +7 -4
  55. package/dist/ai/guides/ai-router.md +37 -22
  56. package/dist/ai/guides/component-picker.md +23 -18
  57. package/dist/ai/guides/design-language.md +141 -13
  58. package/dist/ai/guides/design-tokens.md +29 -4
  59. package/dist/ai/guides/icons-policy.md +46 -21
  60. package/dist/ai/guides/installation-and-plugin-setup.md +253 -17
  61. package/dist/ai/guides/migration/components/bb-rating.md +26 -1
  62. package/dist/ai/guides/migration/components/bb-tooltip.md +18 -0
  63. package/dist/ai/guides/migration/v2-to-v3.md +50 -23
  64. package/dist/ai/index.md +1 -1
  65. package/dist/ai/recipes/inertia/approvals-inbox.md +3 -3
  66. package/dist/ai/recipes/inertia/inline-edit-workspace.md +2 -2
  67. package/dist/ai/recipes/inertia/onboarding.md +2 -2
  68. package/dist/ai/recipes/inertia/ownership-atlas.md +6 -6
  69. package/dist/ai/recipes/inertia/record-form.md +5 -5
  70. package/dist/ai/recipes/inertia/records-workspace.md +4 -4
  71. package/dist/ai/recipes/inertia/upload-center.md +6 -6
  72. package/dist/ai/recipes/inertia/wizard-form.md +5 -5
  73. package/dist/ai/recipes/nuxt/approvals-inbox.md +3 -3
  74. package/dist/ai/recipes/nuxt/inline-edit-workspace.md +2 -2
  75. package/dist/ai/recipes/nuxt/onboarding.md +2 -2
  76. package/dist/ai/recipes/nuxt/record-form.md +5 -5
  77. package/dist/ai/recipes/nuxt/records-workspace.md +4 -4
  78. package/dist/ai/recipes/nuxt/upload-center.md +6 -6
  79. package/dist/ai/recipes/nuxt/wizard-form.md +5 -5
  80. package/dist/ai/recipes/vue/approvals-inbox.md +3 -3
  81. package/dist/ai/recipes/vue/inline-edit-workspace.md +2 -2
  82. package/dist/ai/recipes/vue/onboarding.md +2 -2
  83. package/dist/ai/recipes/vue/record-form.md +10 -10
  84. package/dist/ai/recipes/vue/records-workspace.md +4 -4
  85. package/dist/ai/recipes/vue/upload-center.md +6 -6
  86. package/dist/ai/recipes/vue/wizard-form.md +5 -5
  87. package/dist/ai/source/BbBadge.md +1 -1
  88. package/dist/ai/source/BbBaseCheckbox.md +1 -1
  89. package/dist/ai/source/BbBaseDatePicker.md +5 -5
  90. package/dist/ai/source/BbBaseDatePickerInput.md +2 -2
  91. package/dist/ai/source/BbBaseRadio.md +1 -1
  92. package/dist/ai/source/BbBaseRating.md +5 -5
  93. package/dist/ai/source/BbBaseSelect.md +1 -1
  94. package/dist/ai/source/BbBaseSlider.md +5 -5
  95. package/dist/ai/source/BbBaseSwitch.md +1 -1
  96. package/dist/ai/source/BbBaseTimePickerInput.md +1 -1
  97. package/dist/ai/source/BbBreadcrumbs.md +20 -1
  98. package/dist/ai/source/BbButton.md +2 -2
  99. package/dist/ai/source/BbDialog.md +39 -8
  100. package/dist/ai/source/BbDropdown.md +1 -1
  101. package/dist/ai/source/BbDropzone.md +5 -5
  102. package/dist/ai/source/BbIcon.md +23 -10
  103. package/dist/ai/source/BbOffCanvas.md +50 -28
  104. package/dist/ai/source/BbPagination.md +2 -2
  105. package/dist/ai/source/BbPopover.md +12 -1
  106. package/dist/ai/source/BbSelectPopover.md +45 -7
  107. package/dist/ai/source/BbSpinner.md +2 -2
  108. package/dist/ai/source/BbTable.md +11 -11
  109. package/dist/ai/source/BbTooltip.md +57 -10
  110. package/dist/ai/source/ChipsBox.md +1 -1
  111. package/dist/ai/source/CommaBox.md +1 -1
  112. package/dist/ai/source/GuidesComponent.md +14 -7
  113. package/dist/ai/source/OptionsContainer.md +1 -1
  114. package/dist/components/BbBadge/BadgeBodyContent.vue_vue_type_script_setup_true_lang.js +1 -1
  115. package/dist/components/BbBadge/BbBadge.vue_vue_type_script_setup_true_lang.js +1 -1
  116. package/dist/components/BbBaseCheckbox/BbBaseCheckbox.vue_vue_type_script_setup_true_lang.js +1 -1
  117. package/dist/components/BbBaseDatePicker/BbBaseDatePicker.vue_vue_type_script_setup_true_lang.js +4 -4
  118. package/dist/components/BbBaseDatePicker/BbBaseDatePickerInputDaySelector.vue_vue_type_script_setup_true_lang.js +1 -1
  119. package/dist/components/BbBaseDatePickerInput/BbBaseDatePickerInput.vue_vue_type_script_setup_true_lang.js +1 -1
  120. package/dist/components/BbBaseInputContainer/BbBaseInputContainer.vue.d.ts +1 -1
  121. package/dist/components/BbBaseRadio/BbBaseRadio.vue_vue_type_script_setup_true_lang.js +1 -1
  122. package/dist/components/BbBaseRating/BbBaseRating.vue_vue_type_script_setup_true_lang.js +3 -3
  123. package/dist/components/BbBaseSelect/BbBaseSelect.vue_vue_type_script_setup_true_lang.js +1 -1
  124. package/dist/components/BbBaseSlider/BbBaseSlider.vue_vue_type_script_setup_true_lang.js +3 -3
  125. package/dist/components/BbBaseSwitch/BbBaseSwitch.vue_vue_type_script_setup_true_lang.js +1 -1
  126. package/dist/components/BbBaseTimePickerInput/BbBaseTimePickerInput.vue_vue_type_script_setup_true_lang.js +1 -1
  127. package/dist/components/BbBreadcrumbs/BbBreadcrumbs.vue_vue_type_script_setup_true_lang.js +71 -71
  128. package/dist/components/BbButton/BbButton.vue_vue_type_script_setup_true_lang.js +2 -2
  129. package/dist/components/BbDialog/BbDialog.vue_vue_type_script_setup_true_lang.js +102 -97
  130. package/dist/components/BbDropdown/AdaptiveDropdown.vue_vue_type_script_setup_true_lang.js +1 -1
  131. package/dist/components/BbDropdown/BbDropdown.vue_vue_type_script_setup_true_lang.js +58 -58
  132. package/dist/components/BbDropzone/BbDropzone.vue_vue_type_script_setup_true_lang.js +2 -2
  133. package/dist/components/BbIcon/BbIcon.vue_vue_type_script_setup_true_lang.js +33 -35
  134. package/dist/components/BbIcon/iconCache.d.ts +30 -0
  135. package/dist/components/BbIcon/iconCache.js +21 -3
  136. package/dist/components/BbOffCanvas/BbOffCanvas.vue_vue_type_script_setup_true_lang.js +57 -56
  137. package/dist/components/BbPagination/BbPagination.vue_vue_type_script_setup_true_lang.js +2 -2
  138. package/dist/components/BbPopover/BbPopover.vue_vue_type_script_setup_true_lang.js +138 -138
  139. package/dist/components/BbSelectPopover/BbSelectPopover.vue_vue_type_script_setup_true_lang.js +43 -43
  140. package/dist/components/BbSpinner/BbSpinner.vue_vue_type_script_setup_true_lang.js +1 -1
  141. package/dist/components/BbSpinner/types.d.ts +1 -1
  142. package/dist/components/BbTable/BbTable.vue_vue_type_script_setup_true_lang.js +8 -8
  143. package/dist/components/BbTable/BbTableDataRow.js +2 -2
  144. package/dist/components/BbToast/BbToastRegion.vue_vue_type_script_setup_true_lang.js +62 -62
  145. package/dist/components/BbTooltip/BbTooltip.vue_vue_type_script_setup_true_lang.js +81 -78
  146. package/dist/components/BbTooltip/types.d.ts +8 -1
  147. package/dist/components/ChipsBox/ChipsBox.vue_vue_type_script_setup_true_lang.js +1 -1
  148. package/dist/components/CommaBox/CommaBox.vue_vue_type_script_setup_true_lang.js +1 -1
  149. package/dist/components/OptionsContainer/OptionsContainer.vue_vue_type_script_setup_true_lang.js +1 -1
  150. package/dist/composables/useConfirm.d.ts +22 -0
  151. package/dist/composables/useConfirm.js +43 -40
  152. package/dist/composables/useSafeAreaInternal.js +1 -1
  153. package/dist/composables/useToast.d.ts +16 -0
  154. package/dist/composables/useToast.js +20 -16
  155. package/dist/composables/useViewportWidth.d.ts +20 -6
  156. package/dist/composables/useViewportWidth.js +8 -7
  157. package/dist/icon-scan.d.ts +276 -0
  158. package/dist/icon-scan.js +485 -0
  159. package/dist/icons-whitelist.d.ts +5 -4
  160. package/dist/llms-full.txt +1979 -1527
  161. package/dist/llms-medium.txt +461 -74
  162. package/dist/llms.txt +1 -1
  163. package/dist/nuxt.js +10 -7
  164. package/dist/plugin.js +1 -0
  165. package/dist/runtime/nuxt-plugin.js +2 -1
  166. package/dist/styles.css +1 -1
  167. package/dist/types/Config.d.ts +18 -0
  168. package/dist/utils/cssLayers.d.ts +91 -0
  169. package/dist/utils/cssLayers.js +43 -0
  170. package/dist/utils/injectLibraryStyles.d.ts +3 -1
  171. package/dist/utils/injectLibraryStyles.js +24 -23
  172. package/dist/vite-plugin.d.ts +74 -11
  173. package/dist/vite.js +292 -253
  174. package/llms.txt +1 -1
  175. package/package.json +7 -5
  176. package/scripts/lib/ensure-manifest.mjs +28 -5
  177. package/scripts/lib/eslint-plugin.d.ts +35 -0
  178. package/scripts/lib/eslint-plugin.mjs +299 -3
  179. package/scripts/lib/hand-roll-hints.mjs +50 -6
  180. package/scripts/lib/html-attributes.mjs +42 -0
  181. package/scripts/lib/mcp-config.mjs +38 -34
  182. package/scripts/lib/public-type-exports.mjs +246 -0
  183. package/scripts/lib/validate-bb-markup.mjs +205 -12
@@ -1074,7 +1074,7 @@ const formattedSize = computed(() => {
1074
1074
  class="flex-1"
1075
1075
  :model-value="upload.progress"
1076
1076
  />
1077
- <span class="text-xs tabular-nums text-[color:var(--bb-text-muted)]"
1077
+ <span class="text-xs text-[color:var(--bb-text-muted)] tabular-nums"
1078
1078
  >{{ upload.progress }}%</span
1079
1079
  >
1080
1080
  </div>
@@ -1508,7 +1508,7 @@ const clearSelection = () => {
1508
1508
  <section class="flex flex-col gap-1.5">
1509
1509
  <div class="flex items-center justify-between">
1510
1510
  <h3
1511
- class="text-xs font-semibold uppercase tracking-wide text-[color:var(--bb-text-muted)]"
1511
+ class="text-xs font-semibold tracking-wide text-[color:var(--bb-text-muted)] uppercase"
1512
1512
  >
1513
1513
  Extracted fields
1514
1514
  </h3>
@@ -1548,7 +1548,7 @@ const clearSelection = () => {
1548
1548
  one-click cell as in the table. -->
1549
1549
  <section class="flex flex-col gap-1.5">
1550
1550
  <h3
1551
- class="text-xs font-semibold uppercase tracking-wide text-[color:var(--bb-text-muted)]"
1551
+ class="text-xs font-semibold tracking-wide text-[color:var(--bb-text-muted)] uppercase"
1552
1552
  >
1553
1553
  Details
1554
1554
  </h3>
@@ -1565,7 +1565,7 @@ const clearSelection = () => {
1565
1565
  <!-- Activity feed. -->
1566
1566
  <section class="flex flex-col gap-1.5">
1567
1567
  <h3
1568
- class="text-xs font-semibold uppercase tracking-wide text-[color:var(--bb-text-muted)]"
1568
+ class="text-xs font-semibold tracking-wide text-[color:var(--bb-text-muted)] uppercase"
1569
1569
  >
1570
1570
  Activity
1571
1571
  </h3>
@@ -1712,7 +1712,7 @@ const longDate = (value: string) =>
1712
1712
  <!-- Recent receipts: names drill down again — the stack keeps growing. -->
1713
1713
  <section class="flex flex-col gap-1.5">
1714
1714
  <h3
1715
- class="text-xs font-semibold uppercase tracking-wide text-[color:var(--bb-text-muted)]"
1715
+ class="text-xs font-semibold tracking-wide text-[color:var(--bb-text-muted)] uppercase"
1716
1716
  >
1717
1717
  Recent receipts
1718
1718
  </h3>
@@ -1730,7 +1730,7 @@ const longDate = (value: string) =>
1730
1730
  {{ recent.filename }}
1731
1731
  </BbButton>
1732
1732
  <BbBadge variant="outline">{{ recent.status }}</BbBadge>
1733
- <span class="text-xs tabular-nums text-[color:var(--bb-text-muted)]">
1733
+ <span class="text-xs text-[color:var(--bb-text-muted)] tabular-nums">
1734
1734
  {{ currency.format(recent.amount) }}
1735
1735
  </span>
1736
1736
  </li>
@@ -1042,7 +1042,7 @@ const today = new Date().toISOString().slice(0, 10);
1042
1042
  >
1043
1043
  <div class="flex items-center justify-between gap-2">
1044
1044
  <h3
1045
- class="text-xs font-semibold uppercase tracking-wide text-[color:var(--bb-text-muted)]"
1045
+ class="text-xs font-semibold tracking-wide text-[color:var(--bb-text-muted)] uppercase"
1046
1046
  >
1047
1047
  Audience
1048
1048
  </h3>
@@ -1089,7 +1089,7 @@ const today = new Date().toISOString().slice(0, 10);
1089
1089
  >
1090
1090
  <div class="flex items-center justify-between gap-2">
1091
1091
  <h3
1092
- class="text-xs font-semibold uppercase tracking-wide text-[color:var(--bb-text-muted)]"
1092
+ class="text-xs font-semibold tracking-wide text-[color:var(--bb-text-muted)] uppercase"
1093
1093
  >
1094
1094
  Content
1095
1095
  </h3>
@@ -1120,7 +1120,7 @@ const today = new Date().toISOString().slice(0, 10);
1120
1120
  >
1121
1121
  <div class="flex items-center justify-between gap-2">
1122
1122
  <h3
1123
- class="text-xs font-semibold uppercase tracking-wide text-[color:var(--bb-text-muted)]"
1123
+ class="text-xs font-semibold tracking-wide text-[color:var(--bb-text-muted)] uppercase"
1124
1124
  >
1125
1125
  Schedule
1126
1126
  </h3>
@@ -1286,7 +1286,7 @@ const isReview = computed(() => ctx.step.value === 'review');
1286
1286
  <!-- The segment's definition, so "who is in here" is answerable in place. -->
1287
1287
  <section class="flex flex-col gap-1.5">
1288
1288
  <h3
1289
- class="text-xs font-semibold uppercase tracking-wide text-[color:var(--bb-text-muted)]"
1289
+ class="text-xs font-semibold tracking-wide text-[color:var(--bb-text-muted)] uppercase"
1290
1290
  >
1291
1291
  Conditions
1292
1292
  </h3>
@@ -1305,7 +1305,7 @@ const isReview = computed(() => ctx.step.value === 'review');
1305
1305
  <!-- Recent campaigns to this segment: fatigue check before sending. -->
1306
1306
  <section class="flex flex-col gap-1.5">
1307
1307
  <h3
1308
- class="text-xs font-semibold uppercase tracking-wide text-[color:var(--bb-text-muted)]"
1308
+ class="text-xs font-semibold tracking-wide text-[color:var(--bb-text-muted)] uppercase"
1309
1309
  >
1310
1310
  Recent campaigns
1311
1311
  </h3>
@@ -41,7 +41,7 @@
41
41
  <BbIcon :icon="clearIcon" />
42
42
  </button>
43
43
  </span>
44
- <span v-if="props.loading" class="sr-only">{{
44
+ <span v-if="props.loading" class="bb-sr-only">{{
45
45
  t('common.loadingText')
46
46
  }}</span>
47
47
  </span>
@@ -166,7 +166,7 @@ const inputAttributes = computed(() => {
166
166
  // readonly is announced only via aria-readonly.
167
167
  'aria-readonly': props.readonly ? true : undefined,
168
168
  checked: internalChecked.value,
169
- class: 'sr-only',
169
+ class: 'bb-sr-only',
170
170
  disabled: props.disabled,
171
171
  id: props.id,
172
172
  indeterminate: props.indeterminate,
@@ -13,8 +13,8 @@
13
13
  :class="isCoarse ? `bb-base-date-picker--unit-${homeMode}` : undefined"
14
14
  :style="{ '--weeks': weeksInMonth }"
15
15
  >
16
- <span aria-live="polite" class="sr-only">{{ formattedYear }}</span>
17
- <div aria-live="polite" class="sr-only">{{ cursorAnnouncement }}</div>
16
+ <span aria-live="polite" class="bb-sr-only">{{ formattedYear }}</span>
17
+ <div aria-live="polite" class="bb-sr-only">{{ cursorAnnouncement }}</div>
18
18
  <div class="bb-base-date-picker__body">
19
19
  <!-- No controls row under `type="year"`: the year list scrolls itself
20
20
  (and extends lazily at both ends), so there is nothing for the
@@ -42,7 +42,7 @@
42
42
  stroke-width="1.5"
43
43
  />
44
44
  </svg>
45
- <span class="sr-only">{{ previousPageLabel }}</span>
45
+ <span class="bb-sr-only">{{ previousPageLabel }}</span>
46
46
  </BbBaseButton>
47
47
 
48
48
  <div class="bb-base-date-picker__nav-center">
@@ -98,7 +98,7 @@
98
98
  stroke-width="1.5"
99
99
  />
100
100
  </svg>
101
- <span class="sr-only">{{ nextPageLabel }}</span>
101
+ <span class="bb-sr-only">{{ nextPageLabel }}</span>
102
102
  </BbBaseButton>
103
103
  </div>
104
104
 
@@ -141,7 +141,7 @@
141
141
  :id="`${globalId}-status`"
142
142
  aria-atomic="true"
143
143
  aria-live="polite"
144
- class="sr-only"
144
+ class="bb-sr-only"
145
145
  role="status"
146
146
  >
147
147
  {{ modeAnnouncement }}
@@ -165,7 +165,7 @@
165
165
 
166
166
  <span
167
167
  :id="descriptionId"
168
- class="bb-base-date-picker-input__accessible-description sr-only"
168
+ class="bb-base-date-picker-input__accessible-description bb-sr-only"
169
169
  >{{ t('baseDatePickerInput.dateFormat') }} {{ formatHint }}</span
170
170
  >
171
171
  <template v-if="$slots.suffix" #suffix><slot name="suffix" /></template>
@@ -1006,7 +1006,7 @@ const timeHintKey = computed(() =>
1006
1006
  : 'baseDatePickerInput.timeFormat'
1007
1007
  );
1008
1008
 
1009
- /** Localized format hint used by the placeholder and the sr-only description. */
1009
+ /** Localized format hint used by the placeholder and the bb-sr-only description. */
1010
1010
  const formatHint = computed(() => {
1011
1011
  // The hint has to describe the fields that are actually there — promising
1012
1012
  // DD/MM/YYYY to a two-field month input is worse than no hint at all.
@@ -20,7 +20,7 @@
20
20
  'bb-base-radio--readonly': readonly,
21
21
  }"
22
22
  >
23
- <input v-bind="inputAttributes" class="sr-only" />
23
+ <input v-bind="inputAttributes" class="bb-sr-only" />
24
24
  <slot name="icon" v-bind="slotAttributes">
25
25
  <BbBaseRadioIcon
26
26
  :checked="checked"
@@ -20,7 +20,7 @@
20
20
  :role="groupRole"
21
21
  >
22
22
  <slot name="prepend"></slot>
23
- <span v-if="showClearHint" :id="clearHintId" class="sr-only">{{
23
+ <span v-if="showClearHint" :id="clearHintId" class="bb-sr-only">{{
24
24
  clearHintText
25
25
  }}</span>
26
26
  <label
@@ -34,7 +34,7 @@
34
34
  'bb-base-rating__option--readonly': readonly,
35
35
  }"
36
36
  >
37
- <span class="bb-base-rating__label-text sr-only">{{
37
+ <span class="bb-base-rating__label-text bb-sr-only">{{
38
38
  option.value == 1
39
39
  ? t('rating.singleStar', option.value, props.stars).value
40
40
  : t('rating.multipleStars', option.value, props.stars).value
@@ -44,7 +44,7 @@
44
44
  :aria-describedby="computedAriaDescribedby"
45
45
  :autofocus="autofocus && !index"
46
46
  :checked="option.checked"
47
- class="sr-only"
47
+ class="bb-sr-only"
48
48
  :disabled="option.disabled"
49
49
  :name="option.name"
50
50
  :required="required"
@@ -508,7 +508,7 @@ export const sizeMap: Sizes = {
508
508
  --color: var(--bb-primary);
509
509
  --ring-color: var(--bb-ring);
510
510
  --size: 24px;
511
- --spacing: 0px;
511
+ --space: 0px;
512
512
 
513
513
  /* Highlights all previous and current stars when hovering the component so it previews stars */
514
514
  &:not(.bb-base-rating--disabled):not(.bb-base-rating--readonly):not(
@@ -558,7 +558,7 @@ export const sizeMap: Sizes = {
558
558
  }
559
559
 
560
560
  &__inner-container {
561
- column-gap: var(--spacing);
561
+ column-gap: var(--space);
562
562
  display: inline-flex;
563
563
  flex-wrap: wrap;
564
564
  }
@@ -190,7 +190,7 @@
190
190
  so a visually-hidden copy nested inside it would double every reading.
191
191
  The chips keep their ✕ buttons — those are real tab stops and must stay
192
192
  named; the activator's name just stops reading them. -->
193
- <span :id="`${id}__selection`" class="sr-only">{{
193
+ <span :id="`${id}__selection`" class="bb-sr-only">{{
194
194
  accessibleSelectionText(text)
195
195
  }}</span>
196
196
  <template #append-outer
@@ -30,7 +30,7 @@
30
30
  <input
31
31
  :id="id"
32
32
  ref="hiddenInput"
33
- class="sr-only"
33
+ class="bb-sr-only"
34
34
  :disabled="disabled"
35
35
  :name="name"
36
36
  :readonly="true"
@@ -132,10 +132,10 @@
132
132
  <!-- Localized qualifiers referenced by the range thumbs' aria-labelledby
133
133
  so the two identical role=slider spans are distinguishable to AT. -->
134
134
  <template v-if="props.range && props.id">
135
- <span :id="`${props.id}__thumb_label_lower`" class="sr-only">{{
135
+ <span :id="`${props.id}__thumb_label_lower`" class="bb-sr-only">{{
136
136
  t('slider.minimumThumb').value
137
137
  }}</span>
138
- <span :id="`${props.id}__thumb_label_higher`" class="sr-only">{{
138
+ <span :id="`${props.id}__thumb_label_higher`" class="bb-sr-only">{{
139
139
  t('slider.maximumThumb').value
140
140
  }}</span>
141
141
  </template>
@@ -181,11 +181,11 @@ const { t } = useLocale();
181
181
 
182
182
  /**
183
183
  * `role="slider"` is name-from-author: the container's visible `<label>`
184
- * targets the sr-only hidden input (`for`/`id`), which never names these
184
+ * targets the bb-sr-only hidden input (`for`/`id`), which never names these
185
185
  * sibling thumb spans — without an explicit reference the focused thumb
186
186
  * announces as an unnamed "slider, N". The wrapped layer (BbSlider →
187
187
  * BbBaseInputContainer) renders that label as `${id}__label`, so the thumbs
188
- * point at it; range mode appends a localized sr-only qualifier so the two
188
+ * point at it; range mode appends a localized bb-sr-only qualifier so the two
189
189
  * thumbs are distinguishable. Bare BbBaseSlider without a container keeps its
190
190
  * documented hole: the reference targets nothing and the name stays empty.
191
191
  */
@@ -158,7 +158,7 @@ const inputAttributes = computed(() => {
158
158
  // readonly is NOT disabled — native `disabled` conveys disabled.
159
159
  'aria-readonly': props.readonly ? true : undefined,
160
160
  checked: internalChecked.value,
161
- class: 'sr-only',
161
+ class: 'bb-sr-only',
162
162
  disabled: props.disabled,
163
163
  id: props.id,
164
164
  indeterminate: props.indeterminate,
@@ -129,7 +129,7 @@
129
129
 
130
130
  <span
131
131
  :id="descriptionId"
132
- class="bb-base-time-picker-input__accessible-description sr-only"
132
+ class="bb-base-time-picker-input__accessible-description bb-sr-only"
133
133
  >{{ formatHint }}</span
134
134
  >
135
135
  <template v-if="$slots.suffix" #suffix><slot name="suffix" /></template>
@@ -174,9 +174,28 @@ const props = withDefaults(defineProps<BbBreadcrumbsProps>(), {
174
174
  defineSlots<BbBreadcrumbsSlots>();
175
175
  const { t } = useLocale();
176
176
  const logger = useLogger();
177
- const FLEX_BASIS_OFFSET_WIDTH = 5;
178
177
  const SAFETY_MARGIN_WIDTH = 20;
179
178
  const COLLAPSE_BUFFER = 1;
179
+ /**
180
+ * Slack added to `--flex-basis` on top of the width the items actually need.
181
+ *
182
+ * It is DERIVED, not tuned: the basis is the width this component asks the flex
183
+ * row for, and `recompute()` must agree that everything fits at exactly that
184
+ * width. Substituting `availableWidth = estimatedRequiredWidth` into
185
+ * `effectiveAvailableWidth` cancels the offset and the safety margin and leaves
186
+ * `FLEX_BASIS_OFFSET_WIDTH - COLLAPSE_BUFFER`, which then has to clear
187
+ * `isNearThreshold`'s `SAFETY_MARGIN_WIDTH / 2` band.
188
+ *
189
+ * At the old hand-picked `5` it did not: the slack came to `5 - 1 = 4`, inside
190
+ * the 10px band, so at its own basis the component always declared one item
191
+ * overflowed. That flipped `--flex-grow` to `1`, the row grew it, everything
192
+ * then fitted, `--flex-grow` went back to `0`, it shrank to the basis, and the
193
+ * cycle repeated every frame — a self-inflicted resize→recompute loop that only
194
+ * showed up once an app stopped overriding `flex-basis` (Tailwind v4 puts
195
+ * utilities in `@layer utilities`, which lose to our unlayered rule).
196
+ */
197
+ const COLLAPSE_BUFFER_AND_BAND = COLLAPSE_BUFFER + SAFETY_MARGIN_WIDTH / 2;
198
+ const FLEX_BASIS_OFFSET_WIDTH = COLLAPSE_BUFFER_AND_BAND + 1;
180
199
  const FONT_WIDTH = 8.5;
181
200
 
182
201
  const slots = useSlots();
@@ -51,10 +51,10 @@
51
51
  class="bb-button__prepend-icon"
52
52
  :icon="props['prepend:icon']"
53
53
  />
54
- <span class="bb-button__content" :class="{ 'sr-only': !!icon }"
54
+ <span class="bb-button__content" :class="{ 'bb-sr-only': !!icon }"
55
55
  ><slot>{{ text }}</slot></span
56
56
  >
57
- <span v-if="loading" class="bb-button__loading-label sr-only">{{
57
+ <span v-if="loading" class="bb-button__loading-label bb-sr-only">{{
58
58
  t('common.loadingText')
59
59
  }}</span>
60
60
 
@@ -50,6 +50,7 @@
50
50
  'bb-dialog--mobile': isMobile,
51
51
  'bb-dialog--deny-close': denyClose,
52
52
  'bb-dialog--covered': isCovered,
53
+ 'bb-dialog--stacked': isStackedAbove,
53
54
  }"
54
55
  :inert="(!model && !isVisible) || isCovered"
55
56
  :style="{
@@ -430,6 +431,20 @@ const isCovered = computed(() => {
430
431
  return list[list.length - 1] !== stackId;
431
432
  });
432
433
 
434
+ /**
435
+ * True when this dialog sits ABOVE another member of its stack. A stack paints
436
+ * ONE backdrop, owned by its bottom member; everyone above rides on it with a
437
+ * transparent backdrop of their own (see `--stacked` in index.css). Two things
438
+ * fall out: swapping members never fades a backdrop, so the page behind never
439
+ * blinks mid-crossfade, and N stacked dialogs cost one blur pass instead of N.
440
+ */
441
+ const isStackedAbove = computed(() => {
442
+ if (!props.stack) return false;
443
+ const list = stacks.value[props.stack];
444
+ if (!list || !list.includes(stackId)) return false;
445
+ return list[0] !== stackId;
446
+ });
447
+
433
448
  let releaseTopLayer: (() => void) | null = null;
434
449
  const toggleDialog = async (value: boolean) => {
435
450
  if (value) {
@@ -460,13 +475,21 @@ const toggleDialog = async (value: boolean) => {
460
475
  emit('shown');
461
476
  } else {
462
477
  emit('hide');
478
+ // Leave the stack NOW, not after the fade. The member beneath uncovers
479
+ // at once and crossfades in under this one's fade-out, so it is fully
480
+ // visible one transition after the close — not two, with a dead window
481
+ // where nothing was on screen (measured 2026-09-10: 250ms of blank, the
482
+ // parent finishing at ~520ms). Safe to do early: while this dialog is
483
+ // still natively open, the HTML modal algorithm keeps everything outside
484
+ // it inert regardless of our classes, and Escape stays owned through the
485
+ // overlay stack until the listener is removed below.
486
+ partStack();
463
487
  const currentOpenedTimes = openedTimes.value;
464
488
  await wait(props.transitionDuration);
465
- // Reopened during the transition — keep it open, and keep its stack
466
- // membership + top-layer host intact (they were never released).
489
+ // Reopened during the transition — keep it open. The open path already
490
+ // re-joined the stack (on top); the top-layer host was never released.
467
491
  if (currentOpenedTimes !== openedTimes.value) return;
468
492
  removeEscapeListener();
469
- partStack();
470
493
  dialog.value?.close();
471
494
  // Release the top-layer host only after close(): releasing at close-start
472
495
  // would re-home body toasts under a still-modal dialog, freezing them for
@@ -842,14 +865,22 @@ export type { BbDialogSlots as BaseDialogSlots };
842
865
  animation: bb-dialog-deny-bounce 220ms cubic-bezier(0.22, 1, 0.36, 1);
843
866
  }
844
867
 
845
- /* A dialog covered by a later sibling of the same stack fades out entirely —
846
- only the top-most member of the stack stays visible. */
868
+ /* A dialog covered by a later sibling of the same stack fades its BOX out —
869
+ only the top-most member of the stack stays visible. Its backdrop stays:
870
+ the bottom member owns the stack's one backdrop (below). */
847
871
  &.bb-dialog--covered {
848
872
  opacity: 0;
873
+ }
849
874
 
850
- &::backdrop {
851
- opacity: 0;
852
- }
875
+ /* A member above the bottom of its stack rides on the bottom member's
876
+ backdrop and paints none of its own. Swapping members therefore never
877
+ fades a backdrop — no brightness blink mid-crossfade — and the stack costs
878
+ one blur pass however deep it goes. Must follow `--open`, which sets the
879
+ backdrop to 1 at equal specificity. */
880
+ &.bb-dialog--stacked::backdrop {
881
+ -webkit-backdrop-filter: none;
882
+ backdrop-filter: none;
883
+ opacity: 0;
853
884
  }
854
885
 
855
886
  &::backdrop {
@@ -94,7 +94,7 @@
94
94
  menu container: `role="menu"` admits only items and groups, and a
95
95
  reader in menu mode walks items — so the presentational row inside
96
96
  is skipped and an empty search would announce nothing. -->
97
- <span class="sr-only" role="status">{{ statusText }}</span>
97
+ <span class="bb-sr-only" role="status">{{ statusText }}</span>
98
98
  <div v-if="$slots.footer" class="bb-dropdown__footer">
99
99
  <slot
100
100
  :disabled="disabled"
@@ -41,7 +41,7 @@
41
41
  :aria-label="dropzoneAriaLabel"
42
42
  :aria-labelledby="ariaLabelledby"
43
43
  :autofocus="autofocus"
44
- class="sr-only"
44
+ class="bb-sr-only"
45
45
  :disabled="disabled"
46
46
  :multiple="multiple"
47
47
  type="file"
@@ -54,8 +54,8 @@
54
54
  half of that composed name ("<your copy> Select files"), so the
55
55
  name still says the control takes files. Without it the localized
56
56
  `aria-label` is the whole name and this span would be dead
57
- sr-only chatter on every dropzone. -->
58
- <span v-if="hasSlotLabel" :id="defaultLabelId" class="sr-only">{{
57
+ bb-sr-only chatter on every dropzone. -->
58
+ <span v-if="hasSlotLabel" :id="defaultLabelId" class="bb-sr-only">{{
59
59
  dropzoneAriaLabel
60
60
  }}</span>
61
61
  </label>
@@ -330,7 +330,7 @@ const errorContainerId = `${containerId}_errors`;
330
330
  const warningContainerId = `${containerId}_warnings`;
331
331
  /** Handed to the slot as `labelId`; the consumer puts it on their label node. */
332
332
  const slotLabelId = `${containerId}_label`;
333
- /** Our sr-only copy of the localized instruction, appended to that name. */
333
+ /** Our bb-sr-only copy of the localized instruction, appended to that name. */
334
334
  const defaultLabelId = `${containerId}_default_label`;
335
335
 
336
336
  const dragging = ref(false);
@@ -394,7 +394,7 @@ onUpdated(syncSlotLabel);
394
394
 
395
395
  /**
396
396
  * `aria-labelledby` wins over `aria-label`, so the localized instruction has to
397
- * be re-supplied as a referenced node — hence the sr-only span. Consumer copy
397
+ * be re-supplied as a referenced node — hence the bb-sr-only span. Consumer copy
398
398
  * first: WCAG 2.5.3 (Label in Name) wants the visible label at the START of the
399
399
  * accessible name so speech input can match what the user reads.
400
400
  */
@@ -18,7 +18,7 @@
18
18
  import {
19
19
  computed,
20
20
  inject,
21
- ref,
21
+ shallowRef,
22
22
  onServerPrefetch,
23
23
  onBeforeMount,
24
24
  watch,
@@ -36,8 +36,10 @@ import { useConfig } from '@/composables/useConfig';
36
36
  import {
37
37
  cacheKeyFor as cacheKey,
38
38
  EMPTY_ICON,
39
+ MISSING_ICON,
39
40
  parseIcon,
40
41
  parsedIcons,
42
+ resolveRegistryIcon,
41
43
  type ParsedIcon,
42
44
  } from './iconCache';
43
45
  import type { BbIconProps, BbIconSize } from './types';
@@ -52,7 +54,10 @@ if (process.env.NODE_ENV !== 'production')
52
54
  warnRemovedAttrs('BbIcon', removedPropsFor('BbIcon'));
53
55
 
54
56
  const logger = useLogger();
55
- const icons = ref<IconRegistry>();
57
+ // Shallow: the registry is a (possibly Proxy-wrapped) map of loaders — there is
58
+ // nothing to track inside it, and a deep reactive wrapper would proxy the
59
+ // plugin's own Proxy on every lookup.
60
+ const icons = shallowRef<IconRegistry>();
56
61
 
57
62
  const loadIcons = () => {
58
63
  // Prefer the typed Symbol key; fall back to the legacy string key for one
@@ -102,7 +107,10 @@ const cacheKeyFor = (value: string) => cacheKey(value, isSvgPayload(value));
102
107
  * Seeded from the module cache: an icon this page has already resolved renders
103
108
  * complete in the FIRST render pass, with no loader promise and no regex.
104
109
  */
105
- const parsed = ref<ParsedIcon | null>(
110
+ // Shallow on purpose: a parsed icon is immutable, and the placeholder is
111
+ // recognised by IDENTITY (`=== MISSING_ICON`) — a deep ref would hand back a
112
+ // reactive proxy of it and the comparison would never hold.
113
+ const parsed = shallowRef<ParsedIcon | null>(
106
114
  parsedIcons.get(cacheKeyFor(props.icon)) ?? null
107
115
  );
108
116
 
@@ -136,13 +144,13 @@ const getIcon = async () => {
136
144
  logger.throw('Icons have not been loaded');
137
145
  }
138
146
 
139
- const iconLoader = icons.value![key];
140
- if (!iconLoader) {
141
- logger.throw(`Icon "${key}" does not exist.`);
142
- }
143
-
144
- const source = await iconLoader();
145
- parsed.value = parsedIcons.set(key, parseIcon(source)).get(key)!;
147
+ // Never throws: a name that does not resolve yields the built-in
148
+ // placeholder (and one console.error per name per page), so the component
149
+ // tree around the icon stays mounted and the gap is visible where the icon
150
+ // should be — in dev exactly as in production.
151
+ const resolved = await resolveRegistryIcon(key, icons.value!);
152
+ // The prop may have moved on while the loader was in flight.
153
+ if (cacheKeyFor(props.icon) === key) parsed.value = resolved;
146
154
  };
147
155
 
148
156
  onServerPrefetch(async () => {
@@ -217,6 +225,11 @@ const iconAttributes = computed(() => {
217
225
  // Icons should never be focusable or keyboard navigable since they're not interactive
218
226
  base['focusable'] = 'false';
219
227
 
228
+ // The placeholder stands in for a name that did not resolve: sized and
229
+ // coloured exactly like the icon it replaces, and findable in the DOM (and
230
+ // in tests) by the name that was asked for.
231
+ if (parsed.value === MISSING_ICON) base['data-bb-icon-missing'] = props.icon;
232
+
220
233
  return base;
221
234
  });
222
235
  </script>