diff --git a/app/app.vue b/app/app.vue
index c695e88cb2..2e9d67ea24 100644
--- a/app/app.vue
+++ b/app/app.vue
@@ -6,8 +6,8 @@ const route = useRoute()
const router = useRouter()
const { locale, locales } = useI18n()
-// Initialize accent color before hydration to prevent flash
-initAccentOnPrehydrate()
+// Initialize user preferences (accent color, package manager) before hydration to prevent flash/CLS
+initPreferencesOnPrehydrate()
const isHomepage = computed(() => route.name === 'index')
diff --git a/app/components/ExecuteCommandTerminal.vue b/app/components/ExecuteCommandTerminal.vue
new file mode 100644
index 0000000000..e21b82f467
--- /dev/null
+++ b/app/components/ExecuteCommandTerminal.vue
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $
+ {{ i > 0 ? ' ' : '' }}{{ part }}
+
+
+
+
+
+
+
+
diff --git a/app/components/InstallCommandTerminal.vue b/app/components/InstallCommandTerminal.vue
new file mode 100644
index 0000000000..37bd3441f4
--- /dev/null
+++ b/app/components/InstallCommandTerminal.vue
@@ -0,0 +1,273 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $
+ {{ i > 0 ? ' ' : '' }}{{ part }}
+
+
+
+
+
+
+ $
+ {{ i > 0 ? ' ' : '' }}{{ part }}
+
+
+ View {{ typesPackageName }}
+
+
+
+
+
+
+
+
+ # {{ $t('package.run.locally') }}
+
+
+
+ $
+ {{ i > 0 ? ' ' : '' }}{{ part }}
+
+
+
+
+
+
+
+
+ # {{ $t('package.create.title') }}
+
+
+
+ $
+ {{ i > 0 ? ' ' : '' }}{{ part }}
+
+
+
+ View {{ createPackageInfo.packageName }}
+
+
+
+
+
+
+
+
+
diff --git a/app/components/PackageManagerTabs.vue b/app/components/PackageManagerTabs.vue
new file mode 100644
index 0000000000..5b66ab99f9
--- /dev/null
+++ b/app/components/PackageManagerTabs.vue
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/components/PackageSkeleton.vue b/app/components/PackageSkeleton.vue
index c21bf63c25..d297bb3f02 100644
--- a/app/components/PackageSkeleton.vue
+++ b/app/components/PackageSkeleton.vue
@@ -106,7 +106,7 @@
id="install-heading-skeleton"
class="text-xs text-fg-subtle uppercase tracking-wider mb-3"
>
- {{ $t('package.skeleton.install') }}
+ {{ $t('package.skeleton.get_started') }}
diff --git a/app/composables/useSelectedPackageManager.ts b/app/composables/useSelectedPackageManager.ts
index 0780ca31d4..63b2efd63c 100644
--- a/app/composables/useSelectedPackageManager.ts
+++ b/app/composables/useSelectedPackageManager.ts
@@ -1,4 +1,28 @@
-/** @public */
-export function useSelectedPackageManager() {
- return useLocalStorage
('npmx-pm', 'npm')
-}
+/**
+ * Composable for managing the selected package manager preference.
+ *
+ * This composable syncs the selected PM to both localStorage and the
+ * `data-pm` attribute on ``. The attribute enables CSS-based
+ * visibility of PM-specific content without JavaScript.
+ *
+ * @public
+ */
+export const useSelectedPackageManager = createSharedComposable(
+ function useSelectedPackageManager() {
+ const pm = useLocalStorage('npmx-pm', 'npm')
+
+ // Sync to data-pm attribute on the client
+ if (import.meta.client) {
+ // Watch for changes and update the attribute
+ watch(
+ pm,
+ newPM => {
+ document.documentElement.dataset.pm = newPM
+ },
+ { immediate: true },
+ )
+ }
+
+ return pm
+ },
+)
diff --git a/app/composables/useSettings.ts b/app/composables/useSettings.ts
index 8b5fdf177c..1bf4a98188 100644
--- a/app/composables/useSettings.ts
+++ b/app/composables/useSettings.ts
@@ -84,28 +84,3 @@ export function useAccentColor() {
setAccentColor,
}
}
-
-/**
- * Applies accent color before hydration to prevent flash of default color.
- * Call this from app.vue to ensure accent color is applied on every page.
- * @public
- */
-export function initAccentOnPrehydrate() {
- // Callback is stringified by Nuxt - external variables won't be available.
- // Colors must be hardcoded since ACCENT_COLORS can't be referenced.
- onPrehydrate(() => {
- const colors: Record = {
- rose: 'oklch(0.797 0.084 11.056)',
- amber: 'oklch(0.828 0.165 84.429)',
- emerald: 'oklch(0.792 0.153 166.95)',
- sky: 'oklch(0.787 0.128 230.318)',
- violet: 'oklch(0.714 0.148 286.067)',
- coral: 'oklch(0.704 0.177 14.75)',
- }
- const settings = JSON.parse(localStorage.getItem('npmx-settings') || '{}')
- const color = settings.accentColorId ? colors[settings.accentColorId as AccentColorId] : null
- if (color) {
- document.documentElement.style.setProperty('--accent-color', color)
- }
- })
-}
diff --git a/app/pages/[...package].vue b/app/pages/[...package].vue
index b32e1bbafe..3c5e5165bb 100644
--- a/app/pages/[...package].vue
+++ b/app/pages/[...package].vue
@@ -13,8 +13,6 @@ definePageMeta({
const router = useRouter()
-const isMounted = useMounted()
-
const { packageName, requestedVersion, orgName } = usePackageRoute()
if (import.meta.server) {
@@ -273,15 +271,6 @@ const typesPackageName = computed(() => {
return packageAnalysis.value.types.packageName
})
-const {
- selectedPM,
- installCommandParts,
- typesInstallCommandParts,
- showTypesInInstall,
- copied,
- copyInstallCommand,
-} = useInstallCommand(packageName, requestedVersion, jsrInfo, typesPackageName)
-
// Executable detection for run command
const executableInfo = computed(() => {
if (!displayVersion.value || !pkg.value) return null
@@ -306,46 +295,6 @@ const isCreatePkg = computed(() => {
return isCreatePackage(pkg.value.name)
})
-// Run command parts for a specific command (local execute after install)
-function getRunParts(command?: string) {
- if (!pkg.value) return []
- return getRunCommandParts({
- packageName: pkg.value.name,
- packageManager: selectedPM.value,
- jsrInfo: jsrInfo.value,
- command,
- isBinaryOnly: false, // Local execute
- })
-}
-
-// Execute command parts for binary-only packages (remote execute)
-const executeCommandParts = computed(() => {
- if (!pkg.value) return []
- return getExecuteCommandParts({
- packageName: pkg.value.name,
- packageManager: selectedPM.value,
- jsrInfo: jsrInfo.value,
- isBinaryOnly: true,
- isCreatePackage: isCreatePkg.value,
- })
-})
-
-// Full execute command string for copying
-const executeCommand = computed(() => {
- if (!pkg.value) return ''
- return getExecuteCommand({
- packageName: pkg.value.name,
- packageManager: selectedPM.value,
- jsrInfo: jsrInfo.value,
- isBinaryOnly: true,
- isCreatePackage: isCreatePkg.value,
- })
-})
-
-// Copy execute command (for binary-only packages)
-const { copied: executeCopied, copy: copyExecute } = useClipboard({ copiedDuring: 2000 })
-const copyExecuteCommand = () => copyExecute(executeCommand.value)
-
// Get associated create-* package info (e.g., vite -> create-vite)
const createPackageInfo = computed(() => {
if (!packageAnalysis.value?.createPackage) return null
@@ -354,60 +303,6 @@ const createPackageInfo = computed(() => {
return packageAnalysis.value.createPackage
})
-// Create command parts for associated create-* package
-const createCommandParts = computed(() => {
- if (!createPackageInfo.value) return []
- const pm = packageManagers.find(p => p.id === selectedPM.value)
- if (!pm) return []
-
- // Extract short name: create-vite -> vite
- const createPkgName = createPackageInfo.value.packageName
- let shortName: string
- if (createPkgName.startsWith('@')) {
- // @scope/create-foo -> foo
- const slashIndex = createPkgName.indexOf('/')
- const name = createPkgName.slice(slashIndex + 1)
- shortName = name.startsWith('create-') ? name.slice('create-'.length) : name
- } else {
- // create-vite -> vite
- shortName = createPkgName.startsWith('create-')
- ? createPkgName.slice('create-'.length)
- : createPkgName
- }
-
- return [...pm.create.split(' '), shortName]
-})
-
-// Full create command string for copying
-const createCommand = computed(() => {
- return createCommandParts.value.join(' ')
-})
-
-// Copy create command
-const { copied: createCopied, copy: copyCreate } = useClipboard({ copiedDuring: 2000 })
-const copyCreateCommand = () => copyCreate(createCommand.value)
-
-// Primary run command parts
-const runCommandParts = computed(() => {
- if (!executableInfo.value?.hasExecutable) return []
- return getRunParts(executableInfo.value.primaryCommand)
-})
-
-// Full run command string for copying
-function getFullRunCommand(command?: string) {
- if (!pkg.value) return ''
- return getRunCommand({
- packageName: pkg.value.name,
- packageManager: selectedPM.value,
- jsrInfo: jsrInfo.value,
- command,
- })
-}
-
-// Copy run command
-const { copied: runCopied, copy: copyRun } = useClipboard({ copiedDuring: 2000 })
-const copyRunCommand = (command?: string) => copyRun(getFullRunCommand(command))
-
// Expandable description
const descriptionExpanded = ref(false)
const descriptionRef = useTemplateRef('descriptionRef')
@@ -943,65 +838,13 @@ function handleClick(event: MouseEvent) {
{{ $t('package.run.title') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- $
- {{ i > 0 ? ' ' : '' }}{{ part }}npx {{ pkg.name }}
-
-
-
-
+
+
@@ -1023,178 +866,16 @@ function handleClick(event: MouseEvent) {
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- $
- {{ i > 0 ? ' ' : '' }}{{ part }}npm install {{ pkg.name }}
-
-
-
-
-
- $
- {{ i > 0 ? ' ' : '' }}{{ part }}
-
-
- View {{ typesPackageName }}
-
-
-
-
-
-
-
- # {{ $t('package.run.locally') }}
-
-
-
-
- $
- {{ i > 0 ? ' ' : '' }}{{ part }}npx{{ ' '
- }}{{
- executableInfo?.primaryCommand
- }}
-
-
-
-
-
-
-
-
- # {{ $t('package.create.title') }}
-
-
-
-
- $
- {{ i > 0 ? ' ' : '' }}{{ part }}npm
- create {{ createPackageInfo.packageName.replace('create-', '') }}
-
-
-
- View {{ createPackageInfo.packageName }}
-
-
-
-
-
+
+
diff --git a/app/utils/prehydrate.ts b/app/utils/prehydrate.ts
new file mode 100644
index 0000000000..c101b649f0
--- /dev/null
+++ b/app/utils/prehydrate.ts
@@ -0,0 +1,60 @@
+import type { ACCENT_COLORS } from '#shared/utils/constants'
+
+type AccentColorId = keyof typeof ACCENT_COLORS
+
+/**
+ * Initialize user preferences before hydration to prevent flash/layout shift.
+ * This sets CSS custom properties and data attributes that CSS can use
+ * to show the correct content before Vue hydration occurs.
+ *
+ * Call this in app.vue or any page that needs early access to user preferences.
+ * @public
+ */
+export function initPreferencesOnPrehydrate() {
+ // Callback is stringified by Nuxt - external variables won't be available.
+ // All constants must be hardcoded inside the callback.
+ onPrehydrate(() => {
+ // Accent colors - hardcoded since ACCENT_COLORS can't be referenced
+ const colors: Record
= {
+ rose: 'oklch(0.797 0.084 11.056)',
+ amber: 'oklch(0.828 0.165 84.429)',
+ emerald: 'oklch(0.792 0.153 166.95)',
+ sky: 'oklch(0.787 0.128 230.318)',
+ violet: 'oklch(0.714 0.148 286.067)',
+ coral: 'oklch(0.704 0.177 14.75)',
+ }
+
+ // Valid package manager IDs
+ const validPMs = new Set(['npm', 'pnpm', 'yarn', 'bun', 'deno', 'vlt'])
+
+ // Read settings from localStorage
+ const settings = JSON.parse(localStorage.getItem('npmx-settings') || '{}')
+
+ // Apply accent color
+ const color = settings.accentColorId ? colors[settings.accentColorId as AccentColorId] : null
+ if (color) {
+ document.documentElement.style.setProperty('--accent-color', color)
+ }
+
+ // Read and apply package manager preference
+ const storedPM = localStorage.getItem('npmx-pm')
+ // Parse the stored value (it's stored as a JSON string by useLocalStorage)
+ let pm = 'npm'
+ if (storedPM) {
+ try {
+ const parsed = JSON.parse(storedPM)
+ if (validPMs.has(parsed)) {
+ pm = parsed
+ }
+ } catch {
+ // If parsing fails, check if it's a plain string (legacy format)
+ if (validPMs.has(storedPM)) {
+ pm = storedPM
+ }
+ }
+ }
+
+ // Set data attribute for CSS-based visibility
+ document.documentElement.dataset.pm = pm
+ })
+}
diff --git a/test/nuxt/components/DateTime.spec.ts b/test/nuxt/components/DateTime.spec.ts
index 612bf5d483..dd5973662a 100644
--- a/test/nuxt/components/DateTime.spec.ts
+++ b/test/nuxt/components/DateTime.spec.ts
@@ -10,7 +10,6 @@ vi.mock('~/composables/useSettings', () => ({
settings: ref({ relativeDates: mockRelativeDates.value }),
}),
useAccentColor: () => ({}),
- initAccentOnPrehydrate: () => {},
}))
describe('DateTime', () => {
diff --git a/test/nuxt/composables/use-install-command.spec.ts b/test/nuxt/composables/use-install-command.spec.ts
index 0b2e81b58e..20a827a3fe 100644
--- a/test/nuxt/composables/use-install-command.spec.ts
+++ b/test/nuxt/composables/use-install-command.spec.ts
@@ -3,8 +3,11 @@ import type { JsrPackageInfo } from '#shared/types/jsr'
describe('useInstallCommand', () => {
beforeEach(() => {
- // Reset localStorage before each test
+ // Reset localStorage and package manager state before each test
localStorage.clear()
+ // Reset the shared composable state to default 'npm'
+ const pm = useSelectedPackageManager()
+ pm.value = 'npm'
})
afterEach(() => {
diff --git a/tests/create-command.spec.ts b/tests/create-command.spec.ts
index 001dfa03f1..bac1e54b77 100644
--- a/tests/create-command.spec.ts
+++ b/tests/create-command.spec.ts
@@ -7,12 +7,12 @@ test.describe('Create Command', () => {
// Create command section should be visible (SSR)
// Use specific container to avoid matching README code blocks
- const createCommandSection = page.locator('.group\\/createcmd')
+ const createCommandSection = page.locator('.group\\/createcmd').first()
await expect(createCommandSection).toBeVisible()
await expect(createCommandSection.locator('code')).toContainText(/create vite/i)
// Link to create-vite should be present (uses sr-only text, so check attachment not visibility)
- await expect(page.locator('a[href="/create-vite"]')).toBeAttached()
+ await expect(page.locator('a[href="/create-vite"]').first()).toBeAttached()
})
test('/next - should show create command (shared maintainer, same repo)', async ({
@@ -23,12 +23,12 @@ test.describe('Create Command', () => {
// Create command section should be visible (SSR)
// Use specific container to avoid matching README code blocks
- const createCommandSection = page.locator('.group\\/createcmd')
+ const createCommandSection = page.locator('.group\\/createcmd').first()
await expect(createCommandSection).toBeVisible()
await expect(createCommandSection.locator('code')).toContainText(/create next-app/i)
// Link to create-next-app should be present (uses sr-only text, so check attachment not visibility)
- await expect(page.locator('a[href="/create-next-app"]')).toBeAttached()
+ await expect(page.locator('a[href="/create-next-app"]').first()).toBeAttached()
})
test('/nuxt - should show create command (same maintainer, same org)', async ({
@@ -40,7 +40,7 @@ test.describe('Create Command', () => {
// Create command section should be visible (SSR)
// nuxt has create-nuxt package, so command is "npm create nuxt"
// Use specific container to avoid matching README code blocks
- const createCommandSection = page.locator('.group\\/createcmd')
+ const createCommandSection = page.locator('.group\\/createcmd').first()
await expect(createCommandSection).toBeVisible()
await expect(createCommandSection.locator('code')).toContainText(/create nuxt/i)
})
@@ -55,7 +55,8 @@ test.describe('Create Command', () => {
await expect(page.locator('h1').filter({ hasText: 'color' })).toBeVisible()
// Create command section should NOT be visible (different maintainers)
- const createCommandSection = page.locator('.group\\/createcmd')
+ // Use .first() for consistency, though none should exist
+ const createCommandSection = page.locator('.group\\/createcmd').first()
await expect(createCommandSection).not.toBeVisible()
})
@@ -69,7 +70,8 @@ test.describe('Create Command', () => {
await expect(page.locator('h1').filter({ hasText: 'lodash' })).toBeVisible()
// Create command section should NOT be visible (no create-lodash exists)
- const createCommandSection = page.locator('.group\\/createcmd')
+ // Use .first() for consistency, though none should exist
+ const createCommandSection = page.locator('.group\\/createcmd').first()
await expect(createCommandSection).not.toBeVisible()
})
})
@@ -83,7 +85,7 @@ test.describe('Create Command', () => {
await expect(page.locator('h1')).toContainText('vite')
// Find the create command container (wait longer for API response)
- const createCommandContainer = page.locator('.group\\/createcmd')
+ const createCommandContainer = page.locator('.group\\/createcmd').first()
await expect(createCommandContainer).toBeVisible({ timeout: 15000 })
// Copy button should initially be hidden (opacity-0)
@@ -108,7 +110,7 @@ test.describe('Create Command', () => {
await goto('/vite', { waitUntil: 'hydration' })
// Find and hover over the create command container
- const createCommandContainer = page.locator('.group\\/createcmd')
+ const createCommandContainer = page.locator('.group\\/createcmd').first()
await createCommandContainer.hover()
// Click the copy button
@@ -132,7 +134,7 @@ test.describe('Create Command', () => {
await goto('/lodash', { waitUntil: 'hydration' })
// Find the install command container
- const installCommandContainer = page.locator('.group\\/installcmd')
+ const installCommandContainer = page.locator('.group\\/installcmd').first()
await expect(installCommandContainer).toBeVisible()
// Copy button should initially be hidden
@@ -157,7 +159,7 @@ test.describe('Create Command', () => {
await goto('/lodash', { waitUntil: 'hydration' })
// Find and hover over the install command container
- const installCommandContainer = page.locator('.group\\/installcmd')
+ const installCommandContainer = page.locator('.group\\/installcmd').first()
await installCommandContainer.hover()
// Click the copy button