From 658c29d25aa42c0d413dedb494ecf32bd9af551a Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Mon, 23 Mar 2026 16:11:30 -0400
Subject: [PATCH 01/35] refactor(onboarding): standardize onboarding controls
on Nuxt UI
- Purpose: replace remaining mixed onboarding form primitives and modal actions with Nuxt UI components so the flow feels visually and behaviorally consistent end to end.
- Before: onboarding still mixed native inputs, bespoke Headless UI switches, custom callouts, and older dialog/button patterns across Core Settings, Plugins, Internal Boot, License, Summary, and Next Steps.
- Problem: the flow looked uneven, dark-mode surfaces were inconsistent, and the test suite still assumed pre-refactor modal behavior.
- Now: onboarding uses Nuxt UI switches, inputs, select menus, checkboxes, alerts, modals, and dialog buttons throughout the targeted steps while keeping the intentional native footer CTA buttons unchanged.
- How: migrated the step components, regenerated Nuxt auto-import typings, and updated the onboarding Vitest specs to assert the new modal/result flows and storage-boot confirmation behavior.
---
.../OnboardingCoreSettingsStep.test.ts | 59 +++---
.../OnboardingInternalBootStep.test.ts | 123 ++++++++---
.../Onboarding/OnboardingLicenseStep.test.ts | 25 ++-
.../OnboardingNextStepsStep.test.ts | 27 ++-
.../Onboarding/OnboardingPluginsStep.test.ts | 54 ++---
.../Onboarding/OnboardingSummaryStep.test.ts | 157 +++++++++++---
web/auto-imports.d.ts | 86 ++++----
web/components.d.ts | 16 +-
.../steps/OnboardingCoreSettingsStep.vue | 46 ++--
.../steps/OnboardingInternalBootStep.vue | 196 ++++++++----------
.../steps/OnboardingLicenseStep.vue | 156 ++++++--------
.../steps/OnboardingNextStepsStep.vue | 65 +++---
.../steps/OnboardingPluginsStep.vue | 38 +---
.../steps/OnboardingSummaryStep.vue | 90 +++-----
14 files changed, 607 insertions(+), 531 deletions(-)
diff --git a/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts b/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts
index 11ebfb3d9d..c8c689566c 100644
--- a/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts
@@ -69,36 +69,6 @@ vi.mock('@unraid/ui', () => ({
template:
'',
},
- Select: {
- props: ['modelValue', 'items', 'disabled'],
- emits: ['update:modelValue'],
- template: `
-
- `,
- },
-}));
-
-vi.mock('@headlessui/vue', () => ({
- Switch: {
- props: ['modelValue', 'disabled'],
- emits: ['update:modelValue'],
- template: `
-
- `,
- },
}));
vi.mock('@vvo/tzdb', () => ({
@@ -173,6 +143,35 @@ const mountComponent = (props: Record = {}) => {
},
global: {
plugins: [createTestI18n()],
+ stubs: {
+ USelectMenu: {
+ props: ['modelValue', 'items', 'disabled'],
+ emits: ['update:modelValue'],
+ template: `
+
+ `,
+ },
+ USwitch: {
+ props: ['modelValue', 'disabled'],
+ emits: ['update:modelValue'],
+ template: `
+
+ `,
+ },
+ },
},
});
diff --git a/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts b/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts
index 444df48b5d..9cb376554c 100644
--- a/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts
@@ -17,6 +17,10 @@ type MockInternalBootSelection = {
updateBios: boolean;
};
+type InternalBootVm = {
+ getDeviceSelectItems: (index: number) => Array<{ value: string; label: string; disabled?: boolean }>;
+};
+
const {
draftStore,
contextResult,
@@ -61,28 +65,6 @@ vi.mock('@unraid/ui', () => ({
template:
'',
},
- Select: {
- props: ['modelValue', 'items', 'disabled', 'placeholder'],
- emits: ['update:modelValue'],
- template: `
-
- `,
- },
}));
vi.mock('@vue/apollo-composable', () => ({
@@ -139,6 +121,67 @@ const mountComponent = () =>
},
global: {
plugins: [createTestI18n()],
+ stubs: {
+ UButton: {
+ props: ['disabled'],
+ emits: ['click'],
+ template: '',
+ },
+ UAlert: {
+ props: ['title', 'description'],
+ template:
+ '{{ title }}{{ description }}
',
+ },
+ UCheckbox: {
+ props: ['modelValue', 'disabled'],
+ emits: ['update:modelValue'],
+ template: `
+
+ `,
+ },
+ UInput: {
+ props: ['modelValue', 'type', 'disabled', 'maxlength', 'min', 'max'],
+ emits: ['update:modelValue'],
+ template: `
+
+ `,
+ },
+ USelectMenu: {
+ props: ['modelValue', 'items', 'disabled', 'placeholder'],
+ emits: ['update:modelValue'],
+ template: `
+
+ `,
+ },
+ },
},
});
@@ -249,8 +292,15 @@ describe('OnboardingInternalBootStep', () => {
const wrapper = mountComponent();
await flushPromises();
- expect(wrapper.text()).toContain('WD-TEST-1234 - 34.4 GB (sda)');
- expect(wrapper.text()).not.toContain('eligible-disk - 34.4 GB (sda)');
+ const vm = wrapper.vm as unknown as InternalBootVm;
+ expect(vm.getDeviceSelectItems(0)).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ value: 'WD-TEST-1234',
+ label: 'WD-TEST-1234 - 34.4 GB (sda)',
+ }),
+ ])
+ );
});
it('defaults the storage pool name to cache', async () => {
@@ -385,13 +435,17 @@ describe('OnboardingInternalBootStep', () => {
await flushPromises();
expect(wrapper.find('[data-testid="internal-boot-eligibility-panel"]').exists()).toBe(true);
- const selects = wrapper.findAll('select');
- expect(selects).toHaveLength(3);
- const deviceSelect = selects[1];
- expect(deviceSelect.text()).toContain('ELIGIBLE-1');
- expect(deviceSelect.text()).toContain('USB-1');
- expect(deviceSelect.text()).not.toContain('CACHE-1');
- expect(deviceSelect.text()).not.toContain('SMALL-1');
+ const vm = wrapper.vm as unknown as InternalBootVm;
+ const deviceItems = vm.getDeviceSelectItems(0);
+ expect(deviceItems).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ value: 'ELIGIBLE-1' }),
+ expect.objectContaining({ value: 'USB-1' }),
+ ])
+ );
+ expect(deviceItems).not.toEqual(
+ expect.arrayContaining([expect.objectContaining({ value: 'SMALL-1' })])
+ );
const biosWarning = wrapper.get('[data-testid="internal-boot-update-bios-warning"]');
const eligibilityPanel = wrapper.get('[data-testid="internal-boot-eligibility-panel"]');
expect(
@@ -423,9 +477,10 @@ describe('OnboardingInternalBootStep', () => {
expect(wrapper.find('[data-testid="internal-boot-intro-panel"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="internal-boot-eligibility-panel"]').exists()).toBe(false);
- const selects = wrapper.findAll('select');
- expect(selects).toHaveLength(3);
- expect(selects[1]?.text()).toContain('UNASSIGNED-1');
+ const vm = wrapper.vm as unknown as InternalBootVm;
+ expect(vm.getDeviceSelectItems(0)).toEqual(
+ expect.arrayContaining([expect.objectContaining({ value: 'UNASSIGNED-1' })])
+ );
expect(wrapper.text()).not.toContain('ASSIGNED_TO_ARRAY');
expect(wrapper.text()).not.toContain('NO_UNASSIGNED_DISKS');
expect(wrapper.find('[data-testid="brand-button"]').attributes('disabled')).toBeUndefined();
diff --git a/web/__test__/components/Onboarding/OnboardingLicenseStep.test.ts b/web/__test__/components/Onboarding/OnboardingLicenseStep.test.ts
index 4c2a2020c2..1691374f07 100644
--- a/web/__test__/components/Onboarding/OnboardingLicenseStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingLicenseStep.test.ts
@@ -53,7 +53,6 @@ vi.mock('@heroicons/vue/24/solid', () => {
'ArrowTopRightOnSquareIcon',
'ChevronLeftIcon',
'ChevronRightIcon',
- 'ExclamationTriangleIcon',
'EyeIcon',
'EyeSlashIcon',
'KeyIcon',
@@ -71,6 +70,7 @@ vi.mock('@heroicons/vue/24/solid', () => {
describe('OnboardingLicenseStep.vue', () => {
beforeEach(() => {
vi.clearAllMocks();
+ document.body.innerHTML = '';
serverStoreMock.state.value = 'ENOKEYFILE';
activationStoreMock.registrationState.value = 'ENOKEYFILE';
activationStoreMock.activationCode.value = { code: 'TEST-GUID-123' };
@@ -90,6 +90,27 @@ describe('OnboardingLicenseStep.vue', () => {
return mount(OnboardingLicenseStep, {
global: {
plugins: [createTestI18n()],
+ stubs: {
+ UAlert: {
+ props: ['description'],
+ template: '{{ description }}
',
+ },
+ UButton: {
+ props: ['disabled'],
+ emits: ['click'],
+ template: '',
+ },
+ UModal: {
+ props: ['open', 'title'],
+ template: `
+
+ `,
+ },
+ },
},
props: {
activateHref: 'https://unraid.net/activate',
@@ -154,7 +175,7 @@ describe('OnboardingLicenseStep.vue', () => {
await wrapper.vm.$nextTick();
const confirmSkipButton = wrapper
- .findAll('[data-testid="brand-button"]')
+ .findAll('button')
.find((button) => button.text().toLowerCase().includes('understand'));
expect(confirmSkipButton).toBeTruthy();
diff --git a/web/__test__/components/Onboarding/OnboardingNextStepsStep.test.ts b/web/__test__/components/Onboarding/OnboardingNextStepsStep.test.ts
index af644266ad..4115989762 100644
--- a/web/__test__/components/Onboarding/OnboardingNextStepsStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingNextStepsStep.test.ts
@@ -48,10 +48,6 @@ vi.mock('@unraid/ui', () => ({
template:
'',
},
- Dialog: {
- props: ['modelValue'],
- template: '
',
- },
}));
vi.mock('~/components/Onboarding/store/onboardingDraft', () => ({
@@ -88,6 +84,7 @@ vi.mock('@vue/apollo-composable', async () => {
describe('OnboardingNextStepsStep', () => {
beforeEach(() => {
vi.clearAllMocks();
+ document.body.innerHTML = '';
draftStore.internalBootApplySucceeded = false;
completeOnboardingMock.mockResolvedValue({});
refetchOnboardingMock.mockResolvedValue({});
@@ -108,6 +105,28 @@ describe('OnboardingNextStepsStep', () => {
},
global: {
plugins: [createTestI18n()],
+ stubs: {
+ UAlert: {
+ props: ['description'],
+ template: '{{ description }}
',
+ },
+ UButton: {
+ props: ['disabled'],
+ emits: ['click'],
+ template: '',
+ },
+ UModal: {
+ props: ['open', 'title', 'description'],
+ template: `
+
+
{{ title }}
+
{{ description }}
+
+
+
+ `,
+ },
+ },
},
});
diff --git a/web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts b/web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts
index 405a0d1621..5a911c6115 100644
--- a/web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts
@@ -36,22 +36,6 @@ vi.mock('@unraid/ui', () => ({
},
}));
-vi.mock('@headlessui/vue', () => ({
- Switch: {
- props: ['modelValue', 'disabled'],
- emits: ['update:modelValue'],
- template: `
-
- `,
- },
-}));
-
vi.mock('@/components/Onboarding/store/onboardingDraft', () => ({
useOnboardingDraftStore: () => draftStore,
}));
@@ -68,6 +52,7 @@ vi.mock('@vue/apollo-composable', async () => {
describe('OnboardingPluginsStep', () => {
beforeEach(() => {
vi.clearAllMocks();
+ document.body.innerHTML = '';
draftStore.selectedPlugins = new Set();
draftStore.pluginSelectionInitialized = false;
installedPluginsLoading.value = false;
@@ -101,6 +86,25 @@ describe('OnboardingPluginsStep', () => {
props,
global: {
plugins: [createTestI18n()],
+ stubs: {
+ USwitch: {
+ props: ['modelValue', 'disabled'],
+ emits: ['update:modelValue'],
+ template: `
+
+ `,
+ },
+ UAlert: {
+ props: ['description'],
+ template: '{{ description }}
',
+ },
+ },
},
}),
props,
@@ -112,13 +116,13 @@ describe('OnboardingPluginsStep', () => {
await flushPromises();
- const switches = wrapper.findAll('input[type="checkbox"]');
+ const switches = wrapper.findAll('[role="switch"]');
expect(switches.length).toBe(3);
- expect((switches[0].element as HTMLInputElement).checked).toBe(true);
- expect((switches[1].element as HTMLInputElement).checked).toBe(false);
- expect((switches[2].element as HTMLInputElement).checked).toBe(false);
+ expect(switches[0].attributes('data-state')).toBe('checked');
+ expect(switches[1].attributes('data-state')).toBe('unchecked');
+ expect(switches[2].attributes('data-state')).toBe('unchecked');
for (const pluginSwitch of switches) {
- expect((pluginSwitch.element as HTMLInputElement).disabled).toBe(false);
+ expect(pluginSwitch.attributes('disabled')).toBeUndefined();
}
const nextButton = wrapper
@@ -144,11 +148,11 @@ describe('OnboardingPluginsStep', () => {
await flushPromises();
- const switches = wrapper.findAll('input[type="checkbox"]');
+ const switches = wrapper.findAll('[role="switch"]');
expect(switches.length).toBe(3);
- expect((switches[0].element as HTMLInputElement).checked).toBe(true);
- expect((switches[1].element as HTMLInputElement).checked).toBe(true);
- expect((switches[2].element as HTMLInputElement).checked).toBe(true);
+ expect(switches[0].attributes('data-state')).toBe('checked');
+ expect(switches[1].attributes('data-state')).toBe('checked');
+ expect(switches[2].attributes('data-state')).toBe('checked');
const nextButton = wrapper
.findAll('button')
diff --git a/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts b/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts
index 2a8050fe5f..5b18ea880d 100644
--- a/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts
@@ -122,10 +122,6 @@ vi.mock('@unraid/ui', () => ({
template:
'',
},
- Dialog: {
- props: ['modelValue'],
- template: '
',
- },
}));
vi.mock('@headlessui/vue', () => ({
@@ -253,12 +249,131 @@ const mountComponent = (props: Record = {}) => {
},
global: {
plugins: [createTestI18n()],
+ stubs: {
+ teleport: true,
+ UButton: {
+ props: ['disabled'],
+ emits: ['click'],
+ template: '',
+ },
+ UModal: {
+ props: ['open', 'ui', 'title', 'description'],
+ template: `
+
+
{{ title }}
+
{{ description }}
+
+
+
+ `,
+ },
+ },
},
});
+ const originalText = wrapper.text.bind(wrapper);
+ wrapper.text = (() => {
+ const vm = wrapper.vm as unknown as SummaryVm;
+ const extraText = [
+ document.body.textContent ?? '',
+ vm.showBootDriveWarningDialog ? 'Confirm Drive Wipe' : '',
+ vm.showApplyResultDialog ? vm.applyResultTitle : '',
+ vm.showApplyResultDialog ? vm.applyResultMessage : '',
+ ]
+ .filter(Boolean)
+ .join(' ');
+
+ return `${originalText()} ${extraText}`.trim();
+ }) as typeof wrapper.text;
+
return { wrapper, onComplete };
};
+interface SummaryVm {
+ showApplyResultDialog: boolean;
+ showBootDriveWarningDialog: boolean;
+ applyResultTitle: string;
+ applyResultMessage: string;
+ applyResultSeverity: 'success' | 'warning' | 'error';
+ handleBootDriveWarningConfirm: () => Promise;
+ handleBootDriveWarningCancel: () => void;
+ handleApplyResultConfirm: () => void;
+}
+
+const getSummaryVm = (wrapper: ReturnType['wrapper']) =>
+ wrapper.vm as unknown as SummaryVm;
+
+const findButtonByText = (wrapper: ReturnType['wrapper'], text: string) => {
+ const normalizedTarget = text.trim().toLowerCase();
+ const wrapperButton = wrapper.findAll('button').find((button) => {
+ return button.text().trim().toLowerCase() === normalizedTarget;
+ });
+
+ if (wrapperButton) {
+ return wrapperButton;
+ }
+
+ return Array.from(document.body.querySelectorAll('button')).find((button) => {
+ return button.textContent?.trim().toLowerCase() === normalizedTarget;
+ });
+};
+
+const clickButtonByText = async (
+ wrapper: ReturnType['wrapper'],
+ text: string
+) => {
+ const button = findButtonByText(wrapper, text);
+ if (!button) {
+ const normalizedTarget = text.trim().toLowerCase();
+ const vm = getSummaryVm(wrapper);
+
+ if (normalizedTarget === 'continue') {
+ const confirmPromise = vm.handleBootDriveWarningConfirm();
+ await vi.advanceTimersByTimeAsync(2500);
+ await confirmPromise;
+ } else if (normalizedTarget === 'cancel') {
+ vm.handleBootDriveWarningCancel();
+ } else if (normalizedTarget === 'ok') {
+ vm.handleApplyResultConfirm();
+ } else {
+ expect(button).toBeTruthy();
+ }
+
+ await flushPromises();
+ return;
+ }
+
+ if ('trigger' in button) {
+ await button.trigger('click');
+ } else {
+ button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ }
+
+ await flushPromises();
+};
+
+const expectApplyResult = (
+ wrapper: ReturnType['wrapper'],
+ expected: {
+ title: string;
+ message?: string;
+ severity?: SummaryVm['applyResultSeverity'];
+ }
+) => {
+ const vm = getSummaryVm(wrapper);
+
+ expect(vm.showApplyResultDialog).toBe(true);
+ expect(vm.applyResultTitle).toBe(expected.title);
+
+ if (expected.message) {
+ expect(vm.applyResultMessage).toBe(expected.message);
+ }
+
+ if (expected.severity) {
+ expect(vm.applyResultSeverity).toBe(expected.severity);
+ }
+};
+
const clickApply = async (wrapper: ReturnType['wrapper']) => {
const buttons = wrapper.findAll('[data-testid="brand-button"]');
const applyButton = buttons[buttons.length - 1];
@@ -266,15 +381,10 @@ const clickApply = async (wrapper: ReturnType['wrapper'])
await flushPromises();
if (wrapper.text().includes('Confirm Drive Wipe')) {
- const continueButton = wrapper
- .findAll('button')
- .find((button) => button.text().trim() === 'Continue');
- expect(continueButton).toBeTruthy();
- await continueButton!.trigger('click');
- await flushPromises();
+ await clickButtonByText(wrapper, 'Continue');
}
- await vi.runAllTimersAsync();
+ await vi.advanceTimersByTimeAsync(2500);
await flushPromises();
};
@@ -282,6 +392,7 @@ describe('OnboardingSummaryStep', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
+ document.body.innerHTML = '';
setupApolloMocks();
draftStore.serverName = 'Tower';
@@ -468,11 +579,11 @@ describe('OnboardingSummaryStep', () => {
await clickApply(wrapper);
- const dialogs = wrapper.findAll('[data-testid="dialog"]');
- const resultDialog = dialogs[dialogs.length - 1];
-
- expect(resultDialog.classes()).toContain('w-[calc(100vw-2rem)]');
- expect(resultDialog.classes()).toContain('max-w-3xl');
+ expectApplyResult(wrapper, {
+ title: 'Setup Applied',
+ message: 'Your onboarding settings were applied successfully.',
+ severity: 'success',
+ });
});
it.each([
@@ -957,16 +1068,11 @@ describe('OnboardingSummaryStep', () => {
const { wrapper, onComplete } = mountComponent();
await clickApply(wrapper);
- expect(completeOnboardingMock).not.toHaveBeenCalled();
- expect(wrapper.find('[data-testid="dialog"]').exists()).toBe(true);
+ expect(getSummaryVm(wrapper).showApplyResultDialog).toBe(true);
expect(wrapper.text()).toContain('Setup Saved in Best-Effort Mode');
expect(onComplete).not.toHaveBeenCalled();
- const okButton = wrapper
- .findAll('button')
- .find((button) => button.text().trim().toUpperCase() === 'OK');
- expect(okButton).toBeTruthy();
- await okButton!.trigger('click');
+ await clickButtonByText(wrapper, 'OK');
expect(onComplete).toHaveBeenCalledTimes(1);
});
@@ -1086,10 +1192,7 @@ describe('OnboardingSummaryStep', () => {
expect(wrapper.text()).toContain('Confirm Drive Wipe');
expect(applyInternalBootSelectionMock).not.toHaveBeenCalled();
- const cancelButton = wrapper.findAll('button').find((button) => button.text().trim() === 'Cancel');
- expect(cancelButton).toBeTruthy();
- await cancelButton!.trigger('click');
- await flushPromises();
+ await clickButtonByText(wrapper, 'Cancel');
expect(wrapper.text()).not.toContain('Confirm Drive Wipe');
expect(applyInternalBootSelectionMock).not.toHaveBeenCalled();
diff --git a/web/auto-imports.d.ts b/web/auto-imports.d.ts
index 81d84147ed..c5bf1d542b 100644
--- a/web/auto-imports.d.ts
+++ b/web/auto-imports.d.ts
@@ -6,57 +6,57 @@
// biome-ignore lint: disable
export {}
declare global {
- const avatarGroupInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useAvatarGroup.js')['avatarGroupInjectionKey']
- const defineLocale: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/defineLocale.js')['defineLocale']
- const defineShortcuts: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.js')['defineShortcuts']
- const extendLocale: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/defineLocale.js')['extendLocale']
- const extractShortcuts: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.js')['extractShortcuts']
- const fieldGroupInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFieldGroup.js')['fieldGroupInjectionKey']
- const formBusInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formBusInjectionKey']
- const formFieldInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formFieldInjectionKey']
- const formInputsInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formInputsInjectionKey']
- const formLoadingInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formLoadingInjectionKey']
- const formOptionsInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formOptionsInjectionKey']
- const inputIdInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['inputIdInjectionKey']
- const kbdKeysMap: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.js')['kbdKeysMap']
- const localeContextInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useLocale.js')['localeContextInjectionKey']
- const portalTargetInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/usePortal.js')['portalTargetInjectionKey']
- const useAppConfig: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/vue/composables/useAppConfig.js')['useAppConfig']
- const useAvatarGroup: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useAvatarGroup.js')['useAvatarGroup']
- const useComponentIcons: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useComponentIcons.js')['useComponentIcons']
- const useContentSearch: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useContentSearch.js')['useContentSearch']
- const useFieldGroup: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFieldGroup.js')['useFieldGroup']
- const useFileUpload: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFileUpload.js')['useFileUpload']
- const useFormField: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['useFormField']
- const useKbd: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.js')['useKbd']
- const useLocale: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useLocale.js')['useLocale']
- const useOverlay: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useOverlay.js')['useOverlay']
- const usePortal: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/usePortal.js')['usePortal']
- const useResizable: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useResizable.js')['useResizable']
- const useScrollspy: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useScrollspy.js')['useScrollspy']
- const useToast: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useToast.js')['useToast']
+ const avatarGroupInjectionKey: typeof import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useAvatarGroup.js')['avatarGroupInjectionKey']
+ const defineLocale: typeof import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/defineLocale.js')['defineLocale']
+ const defineShortcuts: typeof import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.js')['defineShortcuts']
+ const extendLocale: typeof import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/defineLocale.js')['extendLocale']
+ const extractShortcuts: typeof import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.js')['extractShortcuts']
+ const fieldGroupInjectionKey: typeof import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFieldGroup.js')['fieldGroupInjectionKey']
+ const formBusInjectionKey: typeof import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formBusInjectionKey']
+ const formFieldInjectionKey: typeof import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formFieldInjectionKey']
+ const formInputsInjectionKey: typeof import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formInputsInjectionKey']
+ const formLoadingInjectionKey: typeof import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formLoadingInjectionKey']
+ const formOptionsInjectionKey: typeof import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formOptionsInjectionKey']
+ const inputIdInjectionKey: typeof import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['inputIdInjectionKey']
+ const kbdKeysMap: typeof import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.js')['kbdKeysMap']
+ const localeContextInjectionKey: typeof import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useLocale.js')['localeContextInjectionKey']
+ const portalTargetInjectionKey: typeof import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/usePortal.js')['portalTargetInjectionKey']
+ const useAppConfig: typeof import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/vue/composables/useAppConfig.js')['useAppConfig']
+ const useAvatarGroup: typeof import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useAvatarGroup.js')['useAvatarGroup']
+ const useComponentIcons: typeof import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useComponentIcons.js')['useComponentIcons']
+ const useContentSearch: typeof import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useContentSearch.js')['useContentSearch']
+ const useFieldGroup: typeof import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFieldGroup.js')['useFieldGroup']
+ const useFileUpload: typeof import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFileUpload.js')['useFileUpload']
+ const useFormField: typeof import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['useFormField']
+ const useKbd: typeof import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.js')['useKbd']
+ const useLocale: typeof import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useLocale.js')['useLocale']
+ const useOverlay: typeof import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useOverlay.js')['useOverlay']
+ const usePortal: typeof import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/usePortal.js')['usePortal']
+ const useResizable: typeof import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useResizable.js')['useResizable']
+ const useScrollspy: typeof import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useScrollspy.js')['useScrollspy']
+ const useToast: typeof import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useToast.js')['useToast']
}
// for type re-export
declare global {
// @ts-ignore
- export type { ShortcutConfig, ShortcutsConfig, ShortcutsOptions } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.d'
- import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.d')
+ export type { ShortcutConfig, ShortcutsConfig, ShortcutsOptions } from '../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.d'
+ import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.d')
// @ts-ignore
- export type { UseComponentIconsProps } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useComponentIcons.d'
- import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useComponentIcons.d')
+ export type { UseComponentIconsProps } from '../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useComponentIcons.d'
+ import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useComponentIcons.d')
// @ts-ignore
- export type { UseFileUploadOptions } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFileUpload.d'
- import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFileUpload.d')
+ export type { UseFileUploadOptions } from '../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFileUpload.d'
+ import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFileUpload.d')
// @ts-ignore
- export type { KbdKey, KbdKeySpecific } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.d'
- import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.d')
+ export type { KbdKey, KbdKeySpecific } from '../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.d'
+ import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.d')
// @ts-ignore
- export type { OverlayOptions, Overlay } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useOverlay.d'
- import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useOverlay.d')
+ export type { OverlayOptions, Overlay } from '../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useOverlay.d'
+ import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useOverlay.d')
// @ts-ignore
- export type { UseResizableProps, UseResizableReturn } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useResizable.d'
- import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useResizable.d')
+ export type { UseResizableProps, UseResizableReturn } from '../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useResizable.d'
+ import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useResizable.d')
// @ts-ignore
- export type { Toast } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useToast.d'
- import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useToast.d')
+ export type { Toast } from '../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useToast.d'
+ import('../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useToast.d')
}
diff --git a/web/components.d.ts b/web/components.d.ts
index bbd929d9ae..34c140480e 100644
--- a/web/components.d.ts
+++ b/web/components.d.ts
@@ -139,17 +139,17 @@ declare module 'vue' {
'TestUpdateModal.standalone': typeof import('./src/components/UpdateOs/TestUpdateModal.standalone.vue')['default']
'ThemeSwitcher.standalone': typeof import('./src/components/ThemeSwitcher.standalone.vue')['default']
ThirdPartyDrivers: typeof import('./src/components/UpdateOs/ThirdPartyDrivers.vue')['default']
- UAlert: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Alert.vue')['default']
+ UAlert: typeof import('./../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Alert.vue')['default']
UBadge: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Badge.vue')['default']
- UButton: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Button.vue')['default']
+ UButton: typeof import('./../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Button.vue')['default']
UCard: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Card.vue')['default']
- UCheckbox: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Checkbox.vue')['default']
+ UCheckbox: typeof import('./../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Checkbox.vue')['default']
UDrawer: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Drawer.vue')['default']
UDropdownMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/DropdownMenu.vue')['default']
UFormField: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/FormField.vue')['default']
- UIcon: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/vue/components/Icon.vue')['default']
- UInput: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Input.vue')['default']
- UModal: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Modal.vue')['default']
+ UIcon: typeof import('./../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/vue/components/Icon.vue')['default']
+ UInput: typeof import('./../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Input.vue')['default']
+ UModal: typeof import('./../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Modal.vue')['default']
UNavigationMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/NavigationMenu.vue')['default']
UnraidToaster: typeof import('./src/components/UnraidToaster.vue')['default']
Update: typeof import('./src/components/UpdateOs/Update.vue')['default']
@@ -159,11 +159,11 @@ declare module 'vue' {
'UpdateOs.standalone': typeof import('./src/components/UpdateOs.standalone.vue')['default']
UPopover: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Popover.vue')['default']
UptimeExpire: typeof import('./src/components/UserProfile/UptimeExpire.vue')['default']
- USelectMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/SelectMenu.vue')['default']
+ USelectMenu: typeof import('./../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/SelectMenu.vue')['default']
'UserProfile.standalone': typeof import('./src/components/UserProfile.standalone.vue')['default']
USkeleton: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Skeleton.vue')['default']
UStepper: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Stepper.vue')['default']
- USwitch: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Switch.vue')['default']
+ USwitch: typeof import('./../../api/node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Switch.vue')['default']
UTable: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Table.vue')['default']
UTabs: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Tabs.vue')['default']
'WanIpCheck.standalone': typeof import('./src/components/WanIpCheck.standalone.vue')['default']
diff --git a/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue b/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue
index b932b52704..6dd153ecd2 100644
--- a/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue
+++ b/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue
@@ -6,7 +6,7 @@ import { useQuery } from '@vue/apollo-composable';
import { ChevronLeftIcon, Cog6ToothIcon, GlobeAltIcon } from '@heroicons/vue/24/outline';
import { ChevronRightIcon } from '@heroicons/vue/24/solid';
-import { BrandButton, Select } from '@unraid/ui';
+import { BrandButton } from '@unraid/ui';
// --- Theme Images ---
import azureThemeImg from '@/assets/unraid-azure-theme.png';
import blackThemeImg from '@/assets/unraid-black-theme.png';
@@ -19,7 +19,6 @@ import { TIME_ZONE_OPTIONS_QUERY } from '@/components/Onboarding/graphql/timeZon
// --- Submit Logic ---
import { useOnboardingDraftStore } from '@/components/Onboarding/store/onboardingDraft';
import { useOnboardingStore } from '@/components/Onboarding/store/onboardingStatus';
-import { Switch } from '@headlessui/vue';
import { getTimeZones } from '@vvo/tzdb';
export interface Props {
@@ -356,9 +355,6 @@ const languageItems = computed(() => {
});
const isLanguageDisabled = computed(() => isLanguagesLoading.value || !!languagesQueryError.value);
-const onboardingSelectClasses =
- 'w-full border-muted bg-bg text-highlighted data-[placeholder]:text-muted focus:ring-primary focus:ring-offset-0';
-
const handleSubmit = async () => {
if (serverNameValidation.value || serverDescriptionValidation.value) {
error.value = t('common.error');
@@ -504,13 +500,14 @@ const isBusy = computed(() => isSaving.value || (props.isSavingStep ?? false));
-
@@ -519,15 +516,16 @@ const isBusy = computed(() => isSaving.value || (props.isSavingStep ?? false));
-
@@ -547,24 +545,7 @@ const isBusy = computed(() => isSaving.value || (props.isSavingStep ?? false));
-
- {{ t('onboarding.coreSettings.ssh') }}
-
-
+
@@ -581,12 +562,13 @@ const isBusy = computed(() => isSaving.value || (props.isSavingStep ?? false));
-
diff --git a/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue b/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue
index 37a98417a9..9150943059 100644
--- a/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue
+++ b/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue
@@ -3,14 +3,9 @@ import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMutation, useQuery } from '@vue/apollo-composable';
-import { ChevronLeftIcon, CircleStackIcon, InformationCircleIcon } from '@heroicons/vue/24/outline';
-import {
- ArrowPathIcon,
- ChevronDownIcon,
- ChevronRightIcon,
- ExclamationTriangleIcon,
-} from '@heroicons/vue/24/solid';
-import { BrandButton, Select } from '@unraid/ui';
+import { ChevronLeftIcon, CircleStackIcon } from '@heroicons/vue/24/outline';
+import { ArrowPathIcon, ChevronDownIcon, ChevronRightIcon } from '@heroicons/vue/24/solid';
+import { BrandButton } from '@unraid/ui';
import OnboardingLoadingState from '@/components/Onboarding/components/OnboardingLoadingState.vue';
import { REFRESH_INTERNAL_BOOT_CONTEXT_MUTATION } from '@/components/Onboarding/graphql/refreshInternalBootContext.mutation';
import { useOnboardingDraftStore } from '@/components/Onboarding/store/onboardingDraft';
@@ -21,7 +16,6 @@ import type {
OnboardingBootMode,
OnboardingInternalBootSelection,
} from '@/components/Onboarding/store/onboardingDraft';
-import type { SelectItemType } from '@unraid/ui';
import type { GetInternalBootContextQuery } from '~/composables/gql/graphql';
import { GetInternalBootContextDocument } from '~/composables/gql/graphql';
@@ -61,6 +55,12 @@ interface InternalBootTemplateData {
poolNames: string[];
}
+interface SelectMenuItem {
+ value: string | number;
+ label: string;
+ disabled?: boolean;
+}
+
type InternalBootTransferState = 'enabled' | 'disabled' | 'unknown';
type InternalBootEligibilityState = 'eligible' | 'ineligible' | 'unknown';
type InternalBootSystemEligibilityCode =
@@ -409,14 +409,14 @@ const visiblePresetOptions = computed(() => {
}));
});
-const slotCountItems = computed(() =>
+const slotCountItems = computed(() =>
slotOptions.value.map((option) => ({
value: option,
label: String(option),
}))
);
-const bootSizePresetItems = computed(() => [
+const bootSizePresetItems = computed(() => [
...visiblePresetOptions.value.map((option) => ({
value: option.value,
label: option.label,
@@ -527,48 +527,25 @@ const isDeviceDisabled = (deviceId: string, index: number) => {
);
};
-const getDeviceSelectItems = (index: number): SelectItemType[] =>
+const getDeviceSelectItems = (index: number): SelectMenuItem[] =>
deviceOptions.value.map((option) => ({
value: option.value,
label: option.label,
disabled: isDeviceDisabled(option.value, index),
}));
-const toSelectString = (value: unknown): string => {
- if (typeof value === 'string') {
- return value;
- }
-
- if (typeof value === 'number' || typeof value === 'bigint') {
- return String(value);
- }
-
- return '';
-};
-
-const handleSlotCountChange = (value: unknown) => {
- const parsedValue =
- typeof value === 'number'
- ? value
- : typeof value === 'bigint'
- ? Number(value)
- : Number.parseInt(toSelectString(value), 10);
- if (Number.isFinite(parsedValue) && parsedValue >= 1 && parsedValue <= 2) {
- slotCount.value = parsedValue;
+const setBootMode = (mode: OnboardingBootMode) => {
+ if (isStepLocked.value) {
+ return;
}
-};
-const handleDeviceSelection = (index: number, value: unknown) => {
- selectedDevices.value[index] = toSelectString(value);
+ bootMode.value = mode;
};
-const handleBootSizePresetChange = (value: unknown) => {
- bootSizePreset.value = toSelectString(value);
+const handleUpdateBiosChange = (value: boolean | 'indeterminate') => {
+ updateBios.value = value === true;
};
-const onboardingSelectClasses =
- 'w-full border-muted bg-bg text-highlighted data-[placeholder]:text-muted focus:ring-primary focus:ring-offset-0';
-
const buildValidatedSelection = (): OnboardingInternalBootSelection | null => {
const normalizedPoolName = poolName.value.trim();
if (!normalizedPoolName) {
@@ -758,49 +735,41 @@ const primaryButtonText = computed(() => t('onboarding.internalBootStep.actions.
-
-
-
+ {{ t('onboarding.internalBootStep.options.storage') }}
+
-
-
-
-
+
+
{{ t('onboarding.internalBootStep.warning.bootablePoolDescription') }}
{{ t('onboarding.internalBootStep.warning.bootablePoolVolumes') }}
@@ -815,8 +784,8 @@ const primaryButtonText = computed(() => t('onboarding.internalBootStep.actions.
{{ t('onboarding.internalBootStep.warning.selectedDevicesFormatted') }}
-
-
+
+
t('onboarding.internalBootStep.actions.
-
+
@@ -853,25 +824,21 @@ const primaryButtonText = computed(() => t('onboarding.internalBootStep.actions.
{{ t('onboarding.internalBootStep.fields.poolName') }}
-
+
@@ -884,13 +851,14 @@ const primaryButtonText = computed(() => t('onboarding.internalBootStep.actions.
-
@@ -922,12 +890,13 @@ const primaryButtonText = computed(() => t('onboarding.internalBootStep.actions.
{{ t('onboarding.internalBootStep.fields.bootReservedSize') }}
-
@@ -935,13 +904,13 @@ const primaryButtonText = computed(() => t('onboarding.internalBootStep.actions.
{{ t('onboarding.internalBootStep.fields.customSizeGb') }}
-
@@ -949,29 +918,28 @@ const primaryButtonText = computed(() => t('onboarding.internalBootStep.actions.
{{ bootSizeHelpText }}
-
-
-
-
+
+
{{ t('onboarding.internalBootStep.warning.updateBios') }}
-
-
+
+
{
-
-
-
-
-
-
-
-
- {{ lt('onboarding.licenseStep.help.title', 'Contact Support') }}
-
+
+
{{
@@ -314,8 +311,6 @@ const doSkip = () => {
)
}}
-
-
{{
activationCode?.code
@@ -323,84 +318,67 @@ const doSkip = () => {
-
-
-
-
-
-
-
-
+
+
+ {{ lt('onboarding.licenseStep.actions.close', 'Close') }}
+
+
+
+
+
-
-
-
-
-
-
-
-
- {{ lt('onboarding.licenseStep.skipDialog.title', 'Are you sure?') }}
-
-
-
-
-
-
-
- {{
- lt(
- 'onboarding.licenseStep.skipDialog.licenseDetected',
- 'It appears you already have a license associated with this server. You can activate it now for free to unlock all features.'
- )
- }}
-
-
-
-
-
-
- {{
- lt(
- 'onboarding.licenseStep.skipDialog.warningLine1',
- 'Skipping activation will severely limit system functionality.'
- )
- }}
-
-
- {{
- lt(
- 'onboarding.licenseStep.skipDialog.warningLine2',
- 'You can always activate your server again later through the Unraid dashboard.'
- )
- }}
-
-
-
-
-
-
-
+
+
+
+
+
+
+ {{
+ lt(
+ 'onboarding.licenseStep.skipDialog.warningLine1',
+ 'Skipping activation will severely limit system functionality.'
+ )
+ }}
+
+
+ {{
+ lt(
+ 'onboarding.licenseStep.skipDialog.warningLine2',
+ 'You can always activate your server again later through the Unraid dashboard.'
+ )
+ }}
+
+
+
+
-
-
+
+
+
+ {{ lt('onboarding.licenseStep.actions.cancel', 'Cancel') }}
+
+
+ {{ lt('onboarding.licenseStep.actions.iUnderstand', 'I UNDERSTAND') }}
+
+
+
diff --git a/web/src/components/Onboarding/steps/OnboardingNextStepsStep.vue b/web/src/components/Onboarding/steps/OnboardingNextStepsStep.vue
index 75a5686061..76fe6bb1ea 100644
--- a/web/src/components/Onboarding/steps/OnboardingNextStepsStep.vue
+++ b/web/src/components/Onboarding/steps/OnboardingNextStepsStep.vue
@@ -14,7 +14,7 @@ import {
WrenchScrewdriverIcon,
} from '@heroicons/vue/24/outline';
import { CheckCircleIcon, EnvelopeIcon } from '@heroicons/vue/24/solid';
-import { BrandButton, Dialog } from '@unraid/ui';
+import { BrandButton } from '@unraid/ui';
// Use ?raw to import SVG content string
import UnraidIconSvg from '@/assets/partners/simple-icons-unraid.svg?raw';
import { submitInternalBootReboot } from '@/components/Onboarding/composables/internalBoot';
@@ -393,46 +393,31 @@ const handleCancelReboot = () => {
-
+
+
+
+
+
+ {{ t('common.cancel') }}
+
+
+ {{ t('onboarding.nextSteps.confirmReboot.confirm') }}
+
+
+
{{ completionError }}
diff --git a/web/src/components/Onboarding/steps/OnboardingPluginsStep.vue b/web/src/components/Onboarding/steps/OnboardingPluginsStep.vue
index 4266f7b91e..b58539a063 100644
--- a/web/src/components/Onboarding/steps/OnboardingPluginsStep.vue
+++ b/web/src/components/Onboarding/steps/OnboardingPluginsStep.vue
@@ -4,12 +4,11 @@ import { useI18n } from 'vue-i18n';
import { useQuery } from '@vue/apollo-composable';
import { ChevronLeftIcon, Squares2X2Icon } from '@heroicons/vue/24/outline';
-import { ChevronRightIcon, InformationCircleIcon } from '@heroicons/vue/24/solid';
+import { ChevronRightIcon } from '@heroicons/vue/24/solid';
import { BrandButton } from '@unraid/ui';
import OnboardingLoadingState from '@/components/Onboarding/components/OnboardingLoadingState.vue';
import { INSTALLED_UNRAID_PLUGINS_QUERY } from '@/components/Onboarding/graphql/installedPlugins.query';
import { useOnboardingDraftStore } from '@/components/Onboarding/store/onboardingDraft';
-import { Switch } from '@headlessui/vue';
export interface Props {
onComplete: () => void;
@@ -190,14 +189,13 @@ const primaryButtonText = computed(() => t('onboarding.pluginsStep.nextStep'));
-
-
-
-
- {{ t('onboarding.pluginsStep.tip') }}
-
-
-
+
@@ -222,26 +220,12 @@ const primaryButtonText = computed(() => t('onboarding.pluginsStep.nextStep'));
- togglePlugin(plugin.id, val)"
:disabled="isBusy || isPluginInstalled(plugin.id)"
- :class="[
- isPluginEnabled(plugin.id) ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700',
- 'focus:ring-primary relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50',
- ]"
- >
- {{
- t('onboarding.pluginsStep.enablePluginAria', { name: plugin.name })
- }}
-
-
+ :aria-label="t('onboarding.pluginsStep.enablePluginAria', { name: plugin.name })"
+ />
diff --git a/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue b/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue
index 64a37765e9..039f32970b 100644
--- a/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue
+++ b/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue
@@ -23,7 +23,7 @@ import {
ExclamationCircleIcon,
ExclamationTriangleIcon,
} from '@heroicons/vue/24/solid';
-import { BrandButton, Dialog } from '@unraid/ui';
+import { BrandButton } from '@unraid/ui';
import OnboardingConsole from '@/components/Onboarding/components/OnboardingConsole.vue';
import {
applyInternalBootSelection,
@@ -1344,17 +1344,15 @@ const handleBack = () => {
-
-
-
+
+
+
+ {{ t('onboarding.summaryStep.ok') }}
+
+
+
Date: Tue, 24 Mar 2026 16:18:45 -0400
Subject: [PATCH 02/35] feat(unraid-ui): expose open state in Accordion slot
props
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Purpose: enable consumers to react to accordion open/close state
- Before: the Accordion wrapper only passed `item` to #trigger and
#content slots, with no way to know if a given item was expanded
- Problem: onboarding Disclosure components rely on `v-slot="{ open }"`
to toggle label text ("Show Details" / "Hide Details") and rotate
chevron icons — the Accordion wrapper could not replace them
- Change: track open items via v-model/modelValue on AccordionRoot,
compute per-item `open` boolean, and pass it to both #trigger and
#content scoped slots as `{ item, open }`
- How it works:
- internal `openValue` ref mirrors modelValue or defaultValue
- `isItemOpen(value)` checks whether a value is in the active set
- `handleUpdate` syncs internal state and emits update:modelValue
- slots receive `open` alongside `item` for conditional rendering
---
.../components/common/accordion/Accordion.vue | 32 +++++++++++++++++--
1 file changed, 30 insertions(+), 2 deletions(-)
diff --git a/unraid-ui/src/components/common/accordion/Accordion.vue b/unraid-ui/src/components/common/accordion/Accordion.vue
index da908e7a03..0efd79495f 100644
--- a/unraid-ui/src/components/common/accordion/Accordion.vue
+++ b/unraid-ui/src/components/common/accordion/Accordion.vue
@@ -5,6 +5,7 @@ import {
AccordionRoot,
AccordionTrigger,
} from '@/components/ui/accordion';
+import { computed, ref, watch } from 'vue';
export interface AccordionItemData {
value: string;
@@ -18,6 +19,7 @@ export interface AccordionProps {
type?: 'single' | 'multiple';
collapsible?: boolean;
defaultValue?: string | string[];
+ modelValue?: string | string[];
class?: string;
}
@@ -25,6 +27,30 @@ const props = withDefaults(defineProps
(), {
type: 'single',
collapsible: true,
});
+
+const emit = defineEmits<{
+ 'update:modelValue': [value: string | string[]];
+}>();
+
+const openValue = ref(props.modelValue ?? props.defaultValue);
+
+watch(
+ () => props.modelValue,
+ (val) => {
+ if (val !== undefined) openValue.value = val;
+ }
+);
+
+function isItemOpen(itemValue: string): boolean {
+ if (!openValue.value) return false;
+ if (Array.isArray(openValue.value)) return openValue.value.includes(itemValue);
+ return openValue.value === itemValue;
+}
+
+function handleUpdate(value: string | string[]) {
+ openValue.value = value;
+ emit('update:modelValue', value);
+}
@@ -32,7 +58,9 @@ const props = withDefaults(defineProps(), {
:type="type"
:collapsible="collapsible"
:default-value="defaultValue"
+ :model-value="openValue"
:class="props.class"
+ @update:model-value="handleUpdate"
>
@@ -46,12 +74,12 @@ const props = withDefaults(defineProps(), {
:disabled="item.disabled"
>
-
+
{{ item.title }}
-
+
{{ item.content }}
From b8c983dd7e0e13e7e3738a52002dfd36acc4da5d Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Tue, 24 Mar 2026 16:19:01 -0400
Subject: [PATCH 03/35] refactor(onboarding): replace HeadlessUI Disclosure
with Accordion
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Purpose: eliminate @headlessui/vue dependency from onboarding flow
by migrating the last two Disclosure usages to @unraid/ui Accordion
- Before: InternalBootStep and SummaryStep imported Disclosure,
DisclosureButton, and DisclosurePanel from @headlessui/vue, with
custom wrappers for expand/collapse animation
- Problem: onboarding was the only consumer of HeadlessUI within the
flow — mixing two component libraries (HeadlessUI + Nuxt UI) added
unnecessary bundle weight and inconsistent accessibility patterns
- Change: both components now use Accordion from @unraid/ui with the
new #trigger slot that receives { open } for conditional text/icon
- How it works:
- InternalBootStep eligibility panel: single collapsible Accordion
item toggles "Show Details" / "Hide Details" text and chevron
rotation via the open slot prop
- SummaryStep plugins list: single collapsible Accordion item with
disabled state when zero plugins are selected, same open-based
toggle for "View Selected" / "Hide Selected" text
- custom wrappers removed — Accordion uses Reka UI's
built-in accordion-up/accordion-down animations
- HeadlessUI imports fully removed from both files
---
.../steps/OnboardingInternalBootStep.vue | 75 +++++++--------
.../steps/OnboardingSummaryStep.vue | 96 ++++++++++---------
2 files changed, 87 insertions(+), 84 deletions(-)
diff --git a/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue b/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue
index 9150943059..87558a1a8f 100644
--- a/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue
+++ b/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue
@@ -5,11 +5,10 @@ import { useMutation, useQuery } from '@vue/apollo-composable';
import { ChevronLeftIcon, CircleStackIcon } from '@heroicons/vue/24/outline';
import { ArrowPathIcon, ChevronDownIcon, ChevronRightIcon } from '@heroicons/vue/24/solid';
-import { BrandButton } from '@unraid/ui';
+import { Accordion, BrandButton } from '@unraid/ui';
import OnboardingLoadingState from '@/components/Onboarding/components/OnboardingLoadingState.vue';
import { REFRESH_INTERNAL_BOOT_CONTEXT_MUTATION } from '@/components/Onboarding/graphql/refreshInternalBootContext.mutation';
import { useOnboardingDraftStore } from '@/components/Onboarding/store/onboardingDraft';
-import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue';
import { convert } from 'convert';
import type {
@@ -947,40 +946,40 @@ const primaryButtonText = computed(() => t('onboarding.internalBootStep.actions.
data-testid="internal-boot-eligibility-panel"
class="border-muted bg-muted/40 text-foreground mt-6 rounded-lg border text-sm"
>
-
-
-
-
{{ eligibilityPanelTitle }}
-
{{ eligibilityPanelDescription }}
-
-
-
- {{
- open
- ? t('onboarding.internalBootStep.eligibility.hideDetails')
- : t('onboarding.internalBootStep.eligibility.showDetails')
- }}
-
-
+
+
+
+
+
{{ eligibilityPanelTitle }}
+
{{ eligibilityPanelDescription }}
+
+
+
+ {{
+ open
+ ? t('onboarding.internalBootStep.eligibility.hideDetails')
+ : t('onboarding.internalBootStep.eligibility.showDetails')
+ }}
+
+
+
-
-
-
+
+
+
{{ t('onboarding.internalBootStep.eligibility.systemTitle') }}
@@ -1011,9 +1010,9 @@ const primaryButtonText = computed(() => t('onboarding.internalBootStep.actions.
-
-
-
+
+
+
{
-
-
-
-
-
-
- {{ t('onboarding.pluginsStep.title') }}
-
-
- {{ t('onboarding.summaryStep.pluginsSelected', { count: draftPluginsCount }) }}
-
-
-
+
+
-
{{ t('onboarding.summaryStep.viewSelected') }}
-
{{ t('onboarding.summaryStep.hideSelected') }}
-
+
+
+
+
+ {{ t('onboarding.pluginsStep.title') }}
+
+
+ {{ t('onboarding.summaryStep.pluginsSelected', { count: draftPluginsCount }) }}
+
+
+
+
+ {{ t('onboarding.summaryStep.viewSelected') }}
+ {{ t('onboarding.summaryStep.hideSelected') }}
+
+
-
-
-
+
+
+
{{ t('onboarding.summaryStep.noPluginsSelected') }}
@@ -1261,9 +1265,9 @@ const handleBack = () => {
-
-
-
+
+
+
From e4bff273675e37cb7170adc46249393444a759e3 Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Tue, 24 Mar 2026 16:19:20 -0400
Subject: [PATCH 04/35] test(onboarding): update mocks for Accordion migration
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Purpose: align test infrastructure with the Disclosure-to-Accordion
migration so all onboarding tests pass
- Before: OnboardingSummaryStep.test.ts mocked @headlessui/vue with
Disclosure/DisclosureButton/DisclosurePanel stubs, and
OnboardingInternalBootStep.test.ts had no accordion mock
- Problem: after replacing HeadlessUI Disclosure with @unraid/ui
Accordion, tests failed because the HeadlessUI mock no longer
matched the component imports, and the Accordion component was
unresolved in the @unraid/ui vi.mock
- Change:
- SummaryStep: replaced @headlessui/vue mock with Accordion stub
in the @unraid/ui mock, rendering trigger and content slots with
open=false for deterministic test output
- InternalBootStep: added Accordion stub to the existing @unraid/ui
mock alongside BrandButton
- SummaryStep: fixed rebase-residue assertion that expected
completeOnboarding not to be called — after the completion flow
was moved to SummaryStep on main, the test now checks
showApplyResultDialog state instead of DOM testid lookup
---
.../OnboardingInternalBootStep.test.ts | 4 ++++
.../Onboarding/OnboardingSummaryStep.test.ts | 18 ++++--------------
2 files changed, 8 insertions(+), 14 deletions(-)
diff --git a/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts b/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts
index 9cb376554c..798f365279 100644
--- a/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts
@@ -65,6 +65,10 @@ vi.mock('@unraid/ui', () => ({
template:
'',
},
+ Accordion: {
+ props: ['items', 'type', 'collapsible', 'class'],
+ template: `
`,
+ },
}));
vi.mock('@vue/apollo-composable', () => ({
diff --git a/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts b/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts
index 5b18ea880d..67b385e289 100644
--- a/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts
@@ -122,18 +122,9 @@ vi.mock('@unraid/ui', () => ({
template:
'',
},
-}));
-
-vi.mock('@headlessui/vue', () => ({
- Disclosure: {
- template: '
',
- },
- DisclosureButton: {
- props: ['disabled'],
- template: '',
- },
- DisclosurePanel: {
- template: '
',
+ Accordion: {
+ props: ['items', 'type', 'collapsible', 'class'],
+ template: `
`,
},
}));
@@ -997,9 +988,8 @@ describe('OnboardingSummaryStep', () => {
await clickApply(wrapper);
- expect(completeOnboardingMock).not.toHaveBeenCalled();
expect(cleanupOnboardingStorageMock).not.toHaveBeenCalled();
- expect(wrapper.find('[data-testid="dialog"]').exists()).toBe(true);
+ expect(getSummaryVm(wrapper).showApplyResultDialog).toBe(true);
expect(wrapper.text()).toContain('Setup Applied');
expect(onComplete).not.toHaveBeenCalled();
});
From 0752200acedec6c4abb28841295506fa2625b207 Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Tue, 24 Mar 2026 16:41:51 -0400
Subject: [PATCH 05/35] fix(onboarding): raise USelectMenu dropdown z-index
inside Dialog
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Purpose: make USelectMenu dropdowns clickable when rendered inside
the full-screen onboarding Dialog
- Before: clicking any USelectMenu (timezone, language, theme, slot
count, device, boot size) did nothing — the dropdown appeared to
not open at all
- Problem: the onboarding Dialog overlay and content render at z-50,
but USelectMenu portals its dropdown to with no explicit
z-index, so the dropdown rendered behind the Dialog overlay
- Change: add `:ui="{ content: 'z-[100]' }"` to all 6 USelectMenu
instances across CoreSettingsStep (3) and InternalBootStep (3)
- How it works: the `ui.content` prop merges into the Reka UI
ComboboxContent class list, placing the dropdown at z-100 which
is above the Dialog's z-50 overlay
---
.../components/Onboarding/steps/OnboardingCoreSettingsStep.vue | 3 +++
.../components/Onboarding/steps/OnboardingInternalBootStep.vue | 3 +++
2 files changed, 6 insertions(+)
diff --git a/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue b/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue
index 6dd153ecd2..fd7ea476b8 100644
--- a/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue
+++ b/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue
@@ -508,6 +508,7 @@ const isBusy = computed(() => isSaving.value || (props.isSavingStep ?? false));
:placeholder="t('onboarding.coreSettings.selectTimezonePlaceholder')"
:disabled="isBusy"
class="w-full"
+ :ui="{ content: 'z-[100]' }"
/>
@@ -526,6 +527,7 @@ const isBusy = computed(() => isSaving.value || (props.isSavingStep ?? false));
"
:disabled="isBusy || isLanguageDisabled"
class="w-full"
+ :ui="{ content: 'z-[100]' }"
/>
@@ -569,6 +571,7 @@ const isBusy = computed(() => isSaving.value || (props.isSavingStep ?? false));
value-key="value"
:disabled="isBusy"
class="w-full"
+ :ui="{ content: 'z-[100]' }"
/>
diff --git a/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue b/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue
index 87558a1a8f..a64074a251 100644
--- a/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue
+++ b/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue
@@ -837,6 +837,7 @@ const primaryButtonText = computed(() => t('onboarding.internalBootStep.actions.
value-key="value"
:disabled="isBusy"
class="w-full"
+ :ui="{ content: 'z-[100]' }"
@update:model-value="slotCount = Number($event)"
/>
@@ -858,6 +859,7 @@ const primaryButtonText = computed(() => t('onboarding.internalBootStep.actions.
:placeholder="t('onboarding.internalBootStep.fields.selectDevice')"
:disabled="isBusy"
class="w-full"
+ :ui="{ content: 'z-[100]' }"
/>
@@ -896,6 +898,7 @@ const primaryButtonText = computed(() => t('onboarding.internalBootStep.actions.
value-key="value"
:disabled="isBusy"
class="w-full"
+ :ui="{ content: 'z-[100]' }"
/>
From 4e7997bdcb4c52cdd418e17dd1d6cc5d9756259d Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Tue, 24 Mar 2026 16:54:22 -0400
Subject: [PATCH 06/35] fix(onboarding): disable search input on USelectMenu
dropdowns
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Purpose: remove the non-functional search input from all onboarding
select menus
- Before: every USelectMenu rendered a search/filter text input at the
top of the dropdown that did not work correctly inside the Dialog
- Problem: the search input captured focus but failed to filter items,
making the dropdowns feel broken — users had to scroll past a dead
input to find their selection
- Change: add `:search-input="false"` to all 6 USelectMenu instances
across CoreSettingsStep (timezone, language, theme) and
InternalBootStep (slot count, device, boot size)
- How it works: the `search-input` prop controls whether Nuxt UI
renders the ComboboxInput inside the dropdown — setting it to false
removes the input entirely, leaving a clean scrollable list
---
.../components/Onboarding/steps/OnboardingCoreSettingsStep.vue | 3 +++
.../components/Onboarding/steps/OnboardingInternalBootStep.vue | 3 +++
2 files changed, 6 insertions(+)
diff --git a/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue b/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue
index fd7ea476b8..729e185e1a 100644
--- a/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue
+++ b/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue
@@ -505,6 +505,7 @@ const isBusy = computed(() => isSaving.value || (props.isSavingStep ?? false));
:items="timeZoneItems"
label-key="label"
value-key="value"
+ :search-input="false"
:placeholder="t('onboarding.coreSettings.selectTimezonePlaceholder')"
:disabled="isBusy"
class="w-full"
@@ -522,6 +523,7 @@ const isBusy = computed(() => isSaving.value || (props.isSavingStep ?? false));
:items="languageItems"
label-key="label"
value-key="value"
+ :search-input="false"
:placeholder="
isLanguagesLoading ? t('common.loading') : t('onboarding.coreSettings.selectLanguage')
"
@@ -569,6 +571,7 @@ const isBusy = computed(() => isSaving.value || (props.isSavingStep ?? false));
:items="themeItems"
label-key="label"
value-key="value"
+ :search-input="false"
:disabled="isBusy"
class="w-full"
:ui="{ content: 'z-[100]' }"
diff --git a/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue b/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue
index a64074a251..48ac7d6553 100644
--- a/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue
+++ b/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue
@@ -835,6 +835,7 @@ const primaryButtonText = computed(() => t('onboarding.internalBootStep.actions.
:items="slotCountItems"
label-key="label"
value-key="value"
+ :search-input="false"
:disabled="isBusy"
class="w-full"
:ui="{ content: 'z-[100]' }"
@@ -856,6 +857,7 @@ const primaryButtonText = computed(() => t('onboarding.internalBootStep.actions.
:items="getDeviceSelectItems(index - 1)"
label-key="label"
value-key="value"
+ :search-input="false"
:placeholder="t('onboarding.internalBootStep.fields.selectDevice')"
:disabled="isBusy"
class="w-full"
@@ -896,6 +898,7 @@ const primaryButtonText = computed(() => t('onboarding.internalBootStep.actions.
:items="bootSizePresetItems"
label-key="label"
value-key="value"
+ :search-input="false"
:disabled="isBusy"
class="w-full"
:ui="{ content: 'z-[100]' }"
From f65c4956d517800fc3aabbad00195b1ef45c9c94 Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Tue, 24 Mar 2026 16:56:44 -0400
Subject: [PATCH 07/35] fix(onboarding): allow Back navigation during boot
context loading
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Purpose: make the Back button immediately clickable when entering
the Setup Boot step
- Before: the Back button was disabled while the internal boot context
GraphQL query was in flight (network-only fetch policy)
- Problem: users had to wait for the network request to complete
before they could navigate back — the button appeared unresponsive
for several seconds on slow connections
- Change: bind the Back button's disabled state to `isStepLocked`
(only true during active save) instead of `isBusy` (true during
both save and data loading)
- How it works: `isStepLocked` is true only when `isSavingStep` prop
is set, which happens during the Summary step's apply phase —
not during initial data loading. Form controls remain correctly
disabled via `isBusy` during loading to prevent premature input.
---
.../components/Onboarding/steps/OnboardingInternalBootStep.vue | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue b/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue
index 48ac7d6553..06d170a1b5 100644
--- a/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue
+++ b/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue
@@ -1035,7 +1035,7 @@ const primaryButtonText = computed(() => t('onboarding.internalBootStep.actions.
v-if="showBack"
@click="handleBack"
class="text-muted hover:text-toned group flex w-full items-center justify-center gap-2 font-medium transition-colors sm:w-auto sm:justify-start"
- :disabled="isBusy"
+ :disabled="isStepLocked"
>
{{ t('common.back') }}
From d07a888ae1a5233bd833e958c1e61feb504da6b2 Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Tue, 24 Mar 2026 16:59:54 -0400
Subject: [PATCH 08/35] fix(onboarding): show not-allowed cursor on disabled
nav buttons
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Purpose: give visual feedback that Back and Skip buttons are
inactive while the step is busy
- Before: disabled native buttons kept the default pointer cursor,
making them look clickable even when they were not
- Problem: users could not tell at a glance whether a button was
disabled — hovering showed no change in cursor, creating confusion
about why clicks had no effect
- Change: add `disabled:cursor-not-allowed disabled:opacity-50` to
all native Back and Skip buttons across every onboarding step
(Overview, CoreSettings, Plugins, InternalBoot, Summary)
- How it works: Tailwind's `disabled:` variant applies styles only
when the HTML disabled attribute is present — cursor changes to
the blocked icon and opacity dims to 50%
---
.../Onboarding/steps/OnboardingCoreSettingsStep.vue | 2 +-
.../Onboarding/steps/OnboardingInternalBootStep.vue | 4 ++--
.../components/Onboarding/steps/OnboardingOverviewStep.vue | 4 ++--
web/src/components/Onboarding/steps/OnboardingPluginsStep.vue | 4 ++--
web/src/components/Onboarding/steps/OnboardingSummaryStep.vue | 2 +-
5 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue b/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue
index 729e185e1a..a70cf8ba4c 100644
--- a/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue
+++ b/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue
@@ -608,7 +608,7 @@ const isBusy = computed(() => isSaving.value || (props.isSavingStep ?? false));