@vc-shell/vc-app-skill 2.7.0-pr398.d8bb4ce → 2.8.0-pr399.6ff7b99

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": "@vc-shell/vc-app-skill",
3
- "version": "2.7.0-pr398.d8bb4ce",
3
+ "version": "2.8.0-pr399.6ff7b99",
4
4
  "description": "AI coding skill for scaffolding and generating VirtoCommerce Shell applications. Works with Claude Code, OpenCode, Gemini, Codex, Cursor.",
5
5
  "bin": "./bin/install.cjs",
6
6
  "files": [
package/runtime/VERSION CHANGED
@@ -1 +1 @@
1
- 2.7.0
1
+ 2.8.0
@@ -1 +1 @@
1
- Synced from framework at commit 0c25dd4eb on 2026-10-26T12:24:22.743Z
1
+ Synced from framework at commit b57bc2f71 on 2026-10-30T15:31:36.432Z
@@ -82,17 +82,89 @@ Inline `useBladeWidgets([...])` is fine for one-off widgets, but extracting beco
82
82
 
83
83
  ### HeadlessWidgetDeclaration
84
84
 
85
- | Field | Type | Required | Description |
86
- | ----------- | -------------------------------------------------------- | -------- | ----------------------------------------- |
87
- | `id` | `string` | Yes | Unique widget identifier |
88
- | `icon` | `string` | Yes | Icon name (e.g., `"lucide-tag"`) |
89
- | `title` | `string` | Yes | i18n key or display title |
90
- | `badge` | `Ref<number \| string> \| ComputedRef<number \| string>` | No | Badge counter value |
91
- | `loading` | `Ref<boolean> \| ComputedRef<boolean>` | No | Show loading indicator |
92
- | `disabled` | `Ref<boolean> \| boolean` | No | Disable the widget |
93
- | `isVisible` | `ComputedRef<boolean> \| Ref<boolean> \| boolean` | No | Toggle visibility |
94
- | `onClick` | `() => void` | No | Action when widget is clicked |
95
- | `onRefresh` | `() => void \| Promise<void>` | No | Called by `refresh(id)` or `refreshAll()` |
85
+ | Field | Type | Required | Description |
86
+ | ----------------- | -------------------------------------------------------- | -------- | ----------------------------------------- |
87
+ | `id` | `string` | Yes | Unique widget identifier |
88
+ | `icon` | `string` | Yes | Icon name (e.g., `"lucide-tag"`) |
89
+ | `title` | `string` | Yes | i18n key or display title |
90
+ | `badge` | `Ref<number \| string> \| ComputedRef<number \| string>` | No | Badge counter value |
91
+ | `loading` | `Ref<boolean> \| ComputedRef<boolean>` | No | Show loading indicator |
92
+ | `disabled` | `Ref<boolean> \| boolean` | No | Disable the widget |
93
+ | `isVisible` | `ComputedRef<boolean> \| Ref<boolean> \| boolean` | No | Toggle visibility |
94
+ | `onClick` | `() => void` | No | Action when widget is clicked |
95
+ | `onRefresh` | `() => void \| Promise<void>` | No | Called by `refresh(id)` or `refreshAll()` |
96
+ | `alerts` | `Ref<IWidgetAlert[]> \| ComputedRef<IWidgetAlert[]>` | No | Live alerts shown in the widget popup |
97
+ | `alertsComponent` | `Component` | No | Popup content instead of the default rows |
98
+
99
+ ### Alerts
100
+
101
+ A widget with a non-empty `alerts` list shows a dot on its icon, colored by the highest severity. Hovering the widget (desktop) opens a popup with the alerts; clicking the widget still opens the blade. On touch screens the first tap on a widget with alerts opens the popup, which then has an Open button; a second tap on the widget also opens the blade. Widgets without alerts show no popup.
102
+
103
+ ```typescript
104
+ const alerts = computed<IWidgetAlert[]>(() => (outOfStock.value ? [{ id: "stock", severity: "warning", text: t("OFFERS.OUT_OF_STOCK") }] : []));
105
+
106
+ useBladeWidgets([{ id: "OffersWidget", icon: "lucide-tag", title: "OFFERS.TITLE", alerts }]);
107
+ ```
108
+
109
+ | `IWidgetAlert` field | Type | Required | Description |
110
+ | -------------------- | ---------------------------------------------- | -------- | --------------------------------- |
111
+ | `id` | `string` | Yes | Unique within the widget |
112
+ | `severity` | `"info" \| "warning" \| "error" \| "critical"` | Yes | Drives the dot color and row icon |
113
+ | `text` | `string` | Yes | Already translated |
114
+ | `meta` | `string` | No | Secondary line (time, reason) |
115
+ | `onOpen` | `() => void` | No | Called on row click |
116
+ | `action` | `{ label: string; run: () => void }` | No | Inline action button |
117
+
118
+ Alerts are data: compute them from what the widget has loaded, and they stay exactly as long as the problem does. When a push means the data changed, refetch it; the alerts follow, together with the badge. A component widget passes the same `alerts` through `useWidgetTrigger`.
119
+
120
+ ```typescript
121
+ const unread = ref<Message[]>([]);
122
+ const load = async () => {
123
+ unread.value = await api.searchUnread({ entityId: entityId.value });
124
+ };
125
+
126
+ // A push for this entity: refetch, and the badge and alerts update together
127
+ useBladeNotifications<MessagePush>({
128
+ types: ["MessagePushNotification"],
129
+ filter: (m) => m.entityId === entityId.value,
130
+ onMessage: load,
131
+ });
132
+
133
+ const alerts = computed<IWidgetAlert[]>(() => unread.value.map((m) => ({ id: m.id!, severity: "info", text: m.content ?? "", onOpen: openMessenger })));
134
+ useWidgetTrigger({ onRefresh: load, alerts });
135
+ ```
136
+
137
+ Widget alerts are independent from the notification bell: the bell lists notifications, the widget lists what is unresolved in its data.
138
+
139
+ ### Custom popup content
140
+
141
+ The popup shows one row per alert by default. To draw it yourself (message previews, an offer card), pass `alertsComponent`. The alerts still decide the dot and whether the popup opens; the component decides what it shows. It receives `IWidgetAlertsComponentProps`: the `alerts` and a `close()` to call after it opens a blade.
142
+
143
+ ```vue
144
+ <!-- MessagesPreview.vue -->
145
+ <script setup lang="ts">
146
+ import type { IWidgetAlertsComponentProps } from "@vc-shell/framework";
147
+ defineProps<IWidgetAlertsComponentProps>();
148
+ </script>
149
+
150
+ <template>
151
+ <div class="tw-p-3">
152
+ <p
153
+ v-for="a in alerts"
154
+ :key="a.id"
155
+ >
156
+ {{ a.meta }}: {{ a.text }}
157
+ </p>
158
+ <button @click="close()">Open conversation</button>
159
+ </div>
160
+ </template>
161
+ ```
162
+
163
+ ```typescript
164
+ useWidgetTrigger({ onRefresh: load, alerts, alertsComponent: MessagesPreview });
165
+ ```
166
+
167
+ Keyboard opening moves focus to the first button, link or input in the component, so put the main action first or make it a real `<button>`.
96
168
 
97
169
  ### Returns
98
170
 
@@ -116,14 +188,16 @@ useWidgetTrigger({ onRefresh: loadData });
116
188
 
117
189
  ### IWidgetTrigger
118
190
 
119
- | Field | Type | Required | Description |
120
- | ----------- | ----------------------------- | -------- | ------------------------------------------------- |
121
- | `icon` | `string` | No | Lucide icon name for dropdown rendering |
122
- | `title` | `string` | No | Display title (fallback: widget's title) |
123
- | `badge` | `Ref<number \| string>` | No | Reactive badge value |
124
- | `onClick` | `() => void` | No | Handler called when widget is clicked in dropdown |
125
- | `onRefresh` | `() => void \| Promise<void>` | No | Handler called to refresh widget data |
126
- | `disabled` | `Ref<boolean> \| boolean` | No | Disabled state |
191
+ | Field | Type | Required | Description |
192
+ | ----------------- | ----------------------------- | -------- | ----------------------------------------------------- |
193
+ | `icon` | `string` | No | Lucide icon name for dropdown rendering |
194
+ | `title` | `string` | No | Display title (fallback: widget's title) |
195
+ | `badge` | `Ref<number \| string>` | No | Reactive badge value |
196
+ | `onClick` | `() => void` | No | Handler called when widget is clicked in dropdown |
197
+ | `onRefresh` | `() => void \| Promise<void>` | No | Handler called to refresh widget data |
198
+ | `disabled` | `Ref<boolean> \| boolean` | No | Disabled state |
199
+ | `alerts` | `Ref<IWidgetAlert[]>` | No | Live alerts shown in the widget popup (see above) |
200
+ | `alertsComponent` | `Component` | No | Popup content instead of the default rows (see above) |
127
201
 
128
202
  ### How It Works
129
203
 
@@ -163,18 +163,40 @@ Avoid wrapping `registerToolbarItem` in an `if (hasAccess(...))` check unless yo
163
163
 
164
164
  ## Cross-blade toolbar management
165
165
 
166
- All methods accept an optional `targetBladeId` second argument. Pass it to register, update, or clear toolbar items on a blade other than the current one:
166
+ All methods accept an optional `targetBladeId` second argument. Pass it to register, update, or clear toolbar items on a blade other than the current one. The id is the blade instance id (`useBlade().id`), generated when the blade opens:
167
167
 
168
168
  ```typescript
169
- // Register a button on a specific child blade
170
- registerToolbarItem({ id: "child-action", title: "Action", clickHandler: () => {} }, "ChildBlade");
169
+ // Register a button on a specific open blade instance
170
+ registerToolbarItem({ id: "child-action", title: "Action", clickHandler: () => {} }, childBladeId);
171
171
 
172
172
  // Clear another blade's toolbar
173
- clearBladeToolbarItems("ChildBlade");
173
+ clearBladeToolbarItems(childBladeId);
174
174
  ```
175
175
 
176
176
  Pass `"*"` as the bladeId to register a global item that appears on every blade.
177
177
 
178
+ ## Adding a button to another module's blade
179
+
180
+ A module can add a button to a blade it does not own with `registerExternalToolbarItem()`, the toolbar counterpart of `registerExternalWidget()`. `targetBlades` lists blade names (the `name` from `defineBlade`). The button shows on every open instance of those blades and survives the blades closing:
181
+
182
+ ```typescript
183
+ import { registerExternalToolbarItem } from "@vc-shell/framework";
184
+
185
+ export default defineAppModule({
186
+ install() {
187
+ registerExternalToolbarItem({
188
+ id: "quality-check",
189
+ title: "Quality check",
190
+ icon: "lucide-sparkles",
191
+ targetBlades: ["Products"],
192
+ clickHandler: () => runCheck(),
193
+ });
194
+ },
195
+ });
196
+ ```
197
+
198
+ The name match is case-insensitive. If the blade registers its own item with the same `id`, the blade's item wins. `registerToolbarItem(item, "Products")` does the same for a single blade.
199
+
178
200
  ## Auto-cleanup control
179
201
 
180
202
  By default, items registered through `useToolbar()` inside a component setup are cleared when that component unmounts (`autoCleanup: true`). Disable this for items that should persist beyond the registering component:
@@ -51,11 +51,11 @@ None.
51
51
 
52
52
  ### Additional Exports
53
53
 
54
- | Export | Description |
55
- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
56
- | `provideWidgetService()` | Creates and provides the widget service via Vue injection. Idempotent -- returns existing service if already provided. Cleans up via `onScopeDispose`. |
57
- | `registerWidget(widget, bladeId)` | Global pre-registration. Widgets registered this way are picked up when `provideWidgetService()` creates the service. Use in module `install()`. |
58
- | `registerExternalWidget(registration)` | Global pre-registration for cross-module widgets. These widgets are rendered in blades owned by other modules. |
54
+ | Export | Description |
55
+ | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
56
+ | `provideWidgetService()` | Creates and provides the widget service via Vue injection. Idempotent -- returns existing service if already provided. Cleans up via `onScopeDispose`. |
57
+ | `registerWidget(widget, bladeId)` | Global pre-registration. Widgets registered this way are picked up when `provideWidgetService()` creates the service. Use in module `install()`. Pass a blade name to show the widget on every instance of that blade. |
58
+ | `registerExternalWidget(registration)` | Global pre-registration for cross-module widgets. These widgets are rendered in blades owned by other modules. |
59
59
 
60
60
  <!-- internal:start -->
61
61
 
@@ -90,7 +90,7 @@ export default {
90
90
  component: markRaw(OrderStatusWidget),
91
91
  icon: "fas fa-clipboard-check",
92
92
  },
93
- "order-details", // blade ID where this widget appears
93
+ "OrderDetails", // blade name (from defineBlade); the widget appears on every open instance of it
94
94
  );
95
95
  },
96
96
  };
@@ -110,7 +110,7 @@ export default {
110
110
  install() {
111
111
  registerExternalWidget({
112
112
  id: "shipping-tracker",
113
- targetBlades: ["order-details"], // target blades from other modules
113
+ targetBlades: ["OrderDetails"], // blade names from other modules
114
114
  component: markRaw(ShippingTracker),
115
115
  title: "Shipping",
116
116
  });
@@ -137,12 +137,18 @@ This means module load order does not matter. Remote modules loaded via Module F
137
137
  Components are sorted by `priority` (ascending). Lower numbers render first:
138
138
 
139
139
  ```typescript
140
- add({ id: "a", component: A, priority: 20 }); // Renders second
141
- add({ id: "b", component: B, priority: 10 }); // Renders first
142
- add({ id: "c", component: C }); // priority=0, renders first (tie)
140
+ add({ id: "a", component: A, priority: 20 }); // Renders third
141
+ add({ id: "b", component: B, priority: 10 }); // Renders second
142
+ add({ id: "c", component: C }); // priority=0, renders first
143
143
  ```
144
144
 
145
- Default priority is `0`. Components with the same priority render in registration order.
145
+ Default priority is `0`. Components with the same priority render in registration order. Inside one module that is the order of the `add()` calls. Across modules it is the order the modules install in, which can change between page loads when remote modules load asynchronously. When several modules contribute to one extension point, give each component an explicit `priority`, spaced out (10, 20, 30) so a new component can go in between.
146
+
147
+ To move a component that another module registered, call `add()` with the same `id` and a new `priority`; the entry is replaced and re-sorted.
148
+
149
+ !!! note "Toolbar sorts the other way"
150
+
151
+ Toolbar items put a higher `priority` first. See [useToolbar](../../composables/useToolbar/).
146
152
 
147
153
  ### Typed Metadata
148
154
 
@@ -32,16 +32,16 @@ Every service follows the same architecture:
32
32
 
33
33
  ## Services
34
34
 
35
- | Service | Factory | Module-level API | Key Type |
36
- | ------------------------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
37
- | **MenuService** | `createMenuService()` | `addMenuItem()`, `removeRegisteredMenuItem()`, `setMenuBadge()`, `removeMenuBadge()`, `getMenuBadge()`, `getMenuBadges()` | `MenuItem` |
38
- | **WidgetService** | `createWidgetService()` | `registerWidget()`, `registerExternalWidget()` | `IWidget` / `IExternalWidgetRegistration` |
39
- | **ToolbarService** | `createToolbarService()` | `registerToolbarItem()` | `IToolbarItem` |
40
- | **DashboardService** | `createDashboardService({ hasAccess? })` | `registerDashboardWidget()` | `DashboardWidget` |
41
- | **SettingsMenuService** | `createSettingsMenuService()` | `addSettingsMenuItem()` | `ISettingsMenuItem` |
42
- | **AppBarWidgetService** | `createAppBarWidgetService()` | `addAppBarWidget()` | `AppBarWidget` |
43
- | **AppBarMobileButtonsService** | `createAppBarMobileButtonsService()` | (direct API) | `AppBarButtonContent` |
44
- | **LanguageService** | `createLanguageService()` | (direct API) | locale strings |
35
+ | Service | Factory | Module-level API | Key Type |
36
+ | ------------------------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
37
+ | **MenuService** | `createMenuService()` | `addMenuItem()`, `removeRegisteredMenuItem()`, `setMenuBadge()`, `removeMenuBadge()`, `getMenuBadge()`, `getMenuBadges()` | `MenuItem` |
38
+ | **WidgetService** | `createWidgetService()` | `registerWidget()`, `registerExternalWidget()` | `IWidget` / `IExternalWidgetRegistration` |
39
+ | **ToolbarService** | `createToolbarService()` | `registerToolbarItem()`, `registerExternalToolbarItem()` | `IToolbarItem` / `IExternalToolbarItemRegistration` |
40
+ | **DashboardService** | `createDashboardService({ hasAccess? })` | `registerDashboardWidget()` | `DashboardWidget` |
41
+ | **SettingsMenuService** | `createSettingsMenuService()` | `addSettingsMenuItem()` | `ISettingsMenuItem` |
42
+ | **AppBarWidgetService** | `createAppBarWidgetService()` | `addAppBarWidget()` | `AppBarWidget` |
43
+ | **AppBarMobileButtonsService** | `createAppBarMobileButtonsService()` | (direct API) | `AppBarButtonContent` |
44
+ | **LanguageService** | `createLanguageService()` | (direct API) | locale strings |
45
45
 
46
46
  ## Usage
47
47
 
@@ -44,7 +44,7 @@ The `ChangePassword` component renders a `VcPopup` with three validated input fi
44
44
 
45
45
  ### Validation
46
46
 
47
- - Field-level: `required` and `min:6` rules via vee-validate's `Field` component.
47
+ - Field-level: `required` on every field; `min:6` on the new password and its confirmation only. The current password can be any password the account has (one set before the policy changed, or by an administrator), so it isn't checked against the policy for new passwords; the platform verifies it on save.
48
48
  - API-level: `validatePassword()` checks the platform's password policy on each input change.
49
49
  - Custom checks: confirms that new password matches confirmation and that the new password differs from the current one.
50
50
  - Errors are displayed as `IdentityError` codes mapped to i18n keys: `COMPONENTS.CHANGE_PASSWORD.ERRORS.{code}`.
@@ -64,7 +64,7 @@ The `ChangePassword` component renders a `VcPopup` with three validated input fi
64
64
 
65
65
  | Field | Validation | Description |
66
66
  | ---------------- | ------------------------------------ | ---------------------------------------------- |
67
- | Current Password | `required`, `min:6` | Verifies the user knows their current password |
67
+ | Current Password | `required` | Verifies the user knows their current password |
68
68
  | New Password | `required`, `min:6`, platform policy | The desired new password |
69
69
  | Confirm Password | `required`, `min:6`, must match new | Confirmation of the new password |
70
70
 
@@ -111,6 +111,14 @@ Badge values above 99 are automatically displayed as "99+".
111
111
  | `--widget-border-radius` | `8px` | Corner radius |
112
112
  | `--widget-focus-ring-color` | `var(--primary-300)` | Focus ring color |
113
113
 
114
+ ## Alert dot and badge highlight
115
+
116
+ Inside a blade widget strip, `VcWidget` reads per-widget render state provided by the blade (it renders exactly as before elsewhere):
117
+
118
+ - **Alert dot**: a small dot on the icon (`.vc-widget__alert-dot--danger|--warning|--info`), coloured from the highest alert severity. The dot itself is `aria-hidden`; the widget gets `aria-haspopup="dialog"`, `aria-expanded` for the alert popup, and an `aria-describedby` text such as "2 need attention".
119
+ - **Badge change highlight**: when `value` changes, the badge pulses for about 1.5s (`.vc-widget__badge--changed`). A first change within 2s of mount counts as the initial load and does not pulse. Only inside a blade; a standalone `VcWidget` never pulses. The pulse is disabled under `prefers-reduced-motion`.
120
+ - **Edit mode**: while the widget layout is being edited, the badge and dot are hidden and clicks are blocked.
121
+
114
122
  ## Accessibility
115
123
 
116
124
  - Renders with `role="button"` and `tabindex="0"` for keyboard interaction
@@ -188,6 +188,21 @@ By default, `padded` is `true`, which adds compact padding and rounded item back
188
188
  </VcDropdown>
189
189
  ```
190
190
 
191
+ ### Keeping Content Mounted
192
+
193
+ By default the panel is unmounted while closed. Set `forceMount` to keep it in the DOM, hidden with `display: none` and `inert`, so content inside it (data loads, subscriptions) stays alive. Outside-click and Escape handling only run while the dropdown is open, and a floating panel is repositioned each time it opens.
194
+
195
+ ```vue
196
+ <VcDropdown v-model="isOpen" force-mount>
197
+ <template #trigger="{ toggle }">
198
+ <VcButton @click="toggle">Menu</VcButton>
199
+ </template>
200
+ <template #items-container>
201
+ <LiveStatusList />
202
+ </template>
203
+ </VcDropdown>
204
+ ```
205
+
191
206
  ## Recipes
192
207
 
193
208
  ### Confirmation Dropdown
@@ -331,6 +346,7 @@ By default, `padded` is `true`, which adds compact padding and rounded item back
331
346
  | `autoFocusPanel` | `boolean` | `true` | Focus the panel element when opened |
332
347
  | `padded` | `boolean` | `true` | Apply compact padding and rounded item backgrounds |
333
348
  | `zIndex` | `number` | `9300` | Z-index for the floating panel |
349
+ | `forceMount` | `boolean` | `false` | Keep the panel content mounted (hidden, inert) while closed |
334
350
 
335
351
  ## Events
336
352
 
@@ -393,8 +409,8 @@ By default, `padded` is `true`, which adds compact padding and rounded item back
393
409
  - Floating positioning uses `useFloatingPosition` (a thin wrapper around `@floating-ui/vue`) with `autoUpdate: true` when `floating` is enabled — the panel recalculates its position on scroll/resize.
394
410
  - Teleport target is determined by `useTeleportTarget`. When `floating` is `true`, the panel is teleported to avoid z-index stacking context issues.
395
411
  - Keyboard navigation (`ArrowDown`, `ArrowUp`, `Home`, `End`, `Enter`/`Space`, `Escape`) is handled in `onPanelKeydown`. Escape also attaches a document-level listener when the dropdown is open to catch events that bubble past the panel.
396
- - `syncFocusedIndex` is called on open to pre-focus the active item (via `isItemActive`) or the first item.
397
- - The `VcDropdownItem` internal sub-component (in `_internal/VcDropdownItem.vue`) is used by `VcBreadcrumbs` for its overflow menu items.
412
+ - `syncFocusedIndex` is called on open to pre-focus the active item (via `isItemActive`) or the first item. That cursor is painted only while the panel is `:focus-visible`, so a mouse-opened menu shows no pre-highlighted item.
413
+ - The `VcDropdownItem` internal sub-component (in `_internal/VcDropdownItem.vue`) is used by `VcBreadcrumbs` for its overflow menu items. Its icon placeholder keeps titles aligned only when some item in the list has an icon or check; a list with none renders no icon gutter.
398
414
  - Default `zIndex` is `9300` (matches the CSS custom property `--z-critical-floating-panel`).
399
415
 
400
416
  <!-- internal:end -->
@@ -449,6 +449,24 @@ Four variants are available: `danger`, `warning`, `info`, `success`. System bann
449
449
 
450
450
  On mobile, width is forced to `100%`. When `expanded` is `true`, the blade fills all available space.
451
451
 
452
+ ## Widgets
453
+
454
+ Registered widgets (see [`useBladeWidgets`](../../../../core/composables/useBladeWidgets/index.docs.md)) are shown in a strip in the blade header on desktop and in a bottom bar on mobile.
455
+
456
+ - Clicking a widget always opens its blade.
457
+ - A widget with alerts shows a dot on its icon. Hovering it (desktop), tapping it (touch; the next tap or the popup's Open button opens the blade) or pressing ArrowDown on it shows the alerts popup. Widgets without alerts show no popup. Screen readers hear the alert count on the widget and on the chevron when hidden widgets have alerts.
458
+ - The strip holds up to 5 widgets. The rest go to the dropdown behind the chevron (a sheet on mobile). The chevron is visible whenever it has something to show: hidden widgets or Customize. On an unnamed blade whose widgets all fit the strip there is no chevron.
459
+ - On a narrow blade the strip shows only the widgets that fit, and the rest move to the top of the dropdown until there is room again. The saved layout does not change. The toolbar keeps room for at least its "More" button (`--blade-toolbar-buttons-min-width`, 80px by default). While customizing, the strip shows all its widgets and scrolls.
460
+ - Widgets hidden in the dropdown or sheet stay mounted, so their badges, alerts and refresh keep working.
461
+
462
+ ### Customizing the layout
463
+
464
+ Choose Customize in the dropdown (sheet on mobile) to edit which widgets are on the blade and in what order: the minus and plus buttons move a widget off or onto the strip (plus is disabled while the strip is full), drag changes the order, and arrow keys reorder the focused widget. Reset restores the default layout, Done leaves edit mode. On desktop, closing the dropdown with Escape or the chevron discards the edit. On mobile, swiping the sheet down saves it like Done.
465
+
466
+ A widget you remove stays in the dropdown. A strip widget that is not shown right now (hidden on this record, or its module has not loaded yet) keeps its slot, and no other widget takes it. An external widget whose module loads after the blade opened appears as soon as the module registers it. New widgets fill free strip slots until the user customizes the blade; after that they appear in the dropdown.
467
+
468
+ The layout is stored per blade in `localStorage` under `VC_BLADE_WIDGETS_${bladeName.toUpperCase()}`. Blades without a `name` cannot be customized.
469
+
452
470
  ## Recipes
453
471
 
454
472
  ### Master-Detail (List + Details)
@@ -77,6 +77,7 @@ const open = ref(false);
77
77
  | `closeThreshold` | `number` | `0.3` | Swipe fraction of panel height needed to dismiss (bottom). |
78
78
  | `draggable` | `boolean` | `false` | Enable swipe-to-dismiss (bottom only). |
79
79
  | `dragHandle` | `boolean` | `false` | Show iOS-style drag handle bar. |
80
+ | `forceMount` | `boolean` | `false` | Keep the panel content mounted (hidden, inert) while closed. |
80
81
 
81
82
  ## Events
82
83
 
@@ -117,6 +118,10 @@ When `trapFocus` is true (default), Tab key cycles through focusable elements in
117
118
 
118
119
  When `position="bottom"` and `draggable` is true, users can swipe the panel down to dismiss it. The `dragHandle` prop adds a visible handle bar at the top of the panel for better affordance.
119
120
 
121
+ ### Keeping Content Mounted
122
+
123
+ By default the panel is unmounted while closed. Set `forceMount` to keep it in the DOM, hidden with `display: none` and `inert`, so content inside it (data loads, subscriptions) stays alive. The overlay, focus trap, scroll lock and Escape handling are active only while `modelValue` is true.
124
+
120
125
  ### Size Presets
121
126
 
122
127
  | Size | Width (left/right) | Height (bottom) |