Skip to content

Commit 5803006

Browse files
committed
fix: prevent flicker of package manager tabs on hydration
1 parent f94b7de commit 5803006

8 files changed

Lines changed: 551 additions & 365 deletions

File tree

app/app.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ const route = useRoute()
66
const router = useRouter()
77
const { locale, locales } = useI18n()
88
9-
// Initialize accent color before hydration to prevent flash
10-
initAccentOnPrehydrate()
9+
// Initialize user preferences (accent color, package manager) before hydration to prevent flash/CLS
10+
initPreferencesOnPrehydrate()
1111
1212
const isHomepage = computed(() => route.name === 'index')
1313
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<script setup lang="ts">
2+
import type { JsrPackageInfo } from '#shared/types/jsr'
3+
import type { PackageManagerId } from '~/utils/install-command'
4+
5+
/**
6+
* A terminal-style execute command display for binary-only packages.
7+
* Renders all package manager variants with CSS-based visibility.
8+
*/
9+
10+
const props = defineProps<{
11+
packageName: string
12+
jsrInfo?: JsrPackageInfo | null
13+
isCreatePackage?: boolean
14+
}>()
15+
16+
const selectedPM = useSelectedPackageManager()
17+
18+
// Generate execute command parts for a specific package manager
19+
function getExecutePartsForPM(pmId: PackageManagerId) {
20+
return getExecuteCommandParts({
21+
packageName: props.packageName,
22+
packageManager: pmId,
23+
jsrInfo: props.jsrInfo,
24+
isBinaryOnly: true,
25+
isCreatePackage: props.isCreatePackage,
26+
})
27+
}
28+
29+
// Full execute command for copying (uses current selected PM)
30+
function getFullExecuteCommand() {
31+
return getExecuteCommand({
32+
packageName: props.packageName,
33+
packageManager: selectedPM.value,
34+
jsrInfo: props.jsrInfo,
35+
isBinaryOnly: true,
36+
isCreatePackage: props.isCreatePackage,
37+
})
38+
}
39+
40+
// Copy handler
41+
const { copied: executeCopied, copy: copyExecute } = useClipboard({ copiedDuring: 2000 })
42+
const copyExecuteCommand = () => copyExecute(getFullExecuteCommand())
43+
</script>
44+
45+
<template>
46+
<div class="relative group">
47+
<!-- Terminal-style execute command -->
48+
<div class="bg-bg-subtle border border-border rounded-lg overflow-hidden">
49+
<div class="flex gap-1.5 px-3 pt-2 sm:px-4 sm:pt-3">
50+
<span class="w-2.5 h-2.5 rounded-full bg-fg-subtle" />
51+
<span class="w-2.5 h-2.5 rounded-full bg-fg-subtle" />
52+
<span class="w-2.5 h-2.5 rounded-full bg-fg-subtle" />
53+
</div>
54+
<div class="px-3 pt-2 pb-3 sm:px-4 sm:pt-3 sm:pb-4 space-y-1">
55+
<!-- Execute command - render all PM variants, CSS controls visibility -->
56+
<div
57+
v-for="pm in packageManagers"
58+
:key="`execute-${pm.id}`"
59+
:data-pm-cmd="pm.id"
60+
class="flex items-center gap-2 group/executecmd"
61+
>
62+
<span class="text-fg-subtle font-mono text-sm select-none">$</span>
63+
<code class="font-mono text-sm"
64+
><span
65+
v-for="(part, i) in getExecutePartsForPM(pm.id)"
66+
:key="i"
67+
:class="i === 0 ? 'text-fg' : 'text-fg-muted'"
68+
>{{ i > 0 ? ' ' : '' }}{{ part }}</span
69+
></code
70+
>
71+
<button
72+
type="button"
73+
class="px-2 py-0.5 font-mono text-xs text-fg-muted bg-bg-subtle/80 border border-border rounded transition-colors duration-200 opacity-0 group-hover/executecmd:opacity-100 hover:(text-fg border-border-hover) active:scale-95 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
74+
:aria-label="$t('package.get_started.copy_command')"
75+
@click.stop="copyExecuteCommand"
76+
>
77+
{{ executeCopied ? $t('common.copied') : $t('common.copy') }}
78+
</button>
79+
</div>
80+
</div>
81+
</div>
82+
</div>
83+
</template>
84+
85+
<style>
86+
/* Hide all variants by default when preference is set */
87+
:root[data-pm] [data-pm-cmd] {
88+
display: none;
89+
}
90+
91+
/* Show only the matching package manager command */
92+
:root[data-pm='npm'] [data-pm-cmd='npm'],
93+
:root[data-pm='pnpm'] [data-pm-cmd='pnpm'],
94+
:root[data-pm='yarn'] [data-pm-cmd='yarn'],
95+
:root[data-pm='bun'] [data-pm-cmd='bun'],
96+
:root[data-pm='deno'] [data-pm-cmd='deno'],
97+
:root[data-pm='vlt'] [data-pm-cmd='vlt'] {
98+
display: flex;
99+
}
100+
101+
/* Fallback: when no data-pm is set (SSR initial), show npm as default */
102+
:root:not([data-pm]) [data-pm-cmd]:not([data-pm-cmd='npm']) {
103+
display: none;
104+
}
105+
</style>
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
<script setup lang="ts">
2+
import type { JsrPackageInfo } from '#shared/types/jsr'
3+
import type { PackageManagerId } from '~/utils/install-command'
4+
5+
const props = defineProps<{
6+
packageName: string
7+
requestedVersion?: string | null
8+
jsrInfo?: JsrPackageInfo | null
9+
typesPackageName?: string | null
10+
executableInfo?: { hasExecutable: boolean; primaryCommand?: string } | null
11+
createPackageInfo?: { packageName: string } | null
12+
}>()
13+
14+
const { selectedPM, showTypesInInstall, copied, copyInstallCommand } = useInstallCommand(
15+
() => props.packageName,
16+
() => props.requestedVersion ?? null,
17+
() => props.jsrInfo ?? null,
18+
() => props.typesPackageName ?? null,
19+
)
20+
21+
// Generate install command parts for a specific package manager
22+
function getInstallPartsForPM(pmId: PackageManagerId) {
23+
return getInstallCommandParts({
24+
packageName: props.packageName,
25+
packageManager: pmId,
26+
version: props.requestedVersion,
27+
jsrInfo: props.jsrInfo,
28+
})
29+
}
30+
31+
// Generate run command parts for a specific package manager
32+
function getRunPartsForPM(pmId: PackageManagerId, command?: string) {
33+
return getRunCommandParts({
34+
packageName: props.packageName,
35+
packageManager: pmId,
36+
jsrInfo: props.jsrInfo,
37+
command,
38+
isBinaryOnly: false,
39+
})
40+
}
41+
42+
// Generate create command parts for a specific package manager
43+
function getCreatePartsForPM(pmId: PackageManagerId) {
44+
if (!props.createPackageInfo) return []
45+
const pm = packageManagers.find(p => p.id === pmId)
46+
if (!pm) return []
47+
48+
const createPkgName = props.createPackageInfo.packageName
49+
let shortName: string
50+
if (createPkgName.startsWith('@')) {
51+
const slashIndex = createPkgName.indexOf('/')
52+
const name = createPkgName.slice(slashIndex + 1)
53+
shortName = name.startsWith('create-') ? name.slice('create-'.length) : name
54+
} else {
55+
shortName = createPkgName.startsWith('create-')
56+
? createPkgName.slice('create-'.length)
57+
: createPkgName
58+
}
59+
60+
return [...pm.create.split(' '), shortName]
61+
}
62+
63+
// Generate @types install command parts for a specific package manager
64+
function getTypesInstallPartsForPM(pmId: PackageManagerId) {
65+
if (!props.typesPackageName) return []
66+
const pm = packageManagers.find(p => p.id === pmId)
67+
if (!pm) return []
68+
69+
const devFlag = pmId === 'bun' ? '-d' : '-D'
70+
const pkgSpec = pmId === 'deno' ? `npm:${props.typesPackageName}` : props.typesPackageName
71+
72+
return [pm.label, pm.action, devFlag, pkgSpec]
73+
}
74+
75+
// Full run command for copying (uses current selected PM)
76+
function getFullRunCommand(command?: string) {
77+
return getRunCommand({
78+
packageName: props.packageName,
79+
packageManager: selectedPM.value,
80+
jsrInfo: props.jsrInfo,
81+
command,
82+
})
83+
}
84+
85+
// Full create command for copying (uses current selected PM)
86+
function getFullCreateCommand() {
87+
return getCreatePartsForPM(selectedPM.value).join(' ')
88+
}
89+
90+
// Copy handlers
91+
const { copied: runCopied, copy: copyRun } = useClipboard({ copiedDuring: 2000 })
92+
const copyRunCommand = (command?: string) => copyRun(getFullRunCommand(command))
93+
94+
const { copied: createCopied, copy: copyCreate } = useClipboard({ copiedDuring: 2000 })
95+
const copyCreateCommand = () => copyCreate(getFullCreateCommand())
96+
</script>
97+
98+
<template>
99+
<div class="relative group">
100+
<!-- Terminal-style install command -->
101+
<div class="bg-bg-subtle border border-border rounded-lg overflow-hidden">
102+
<div class="flex gap-1.5 px-3 pt-2 sm:px-4 sm:pt-3">
103+
<span class="w-2.5 h-2.5 rounded-full bg-fg-subtle" />
104+
<span class="w-2.5 h-2.5 rounded-full bg-fg-subtle" />
105+
<span class="w-2.5 h-2.5 rounded-full bg-fg-subtle" />
106+
</div>
107+
<div class="px-3 pt-2 pb-3 sm:px-4 sm:pt-3 sm:pb-4 space-y-1 overflow-x-auto">
108+
<!-- Install command - render all PM variants, CSS controls visibility -->
109+
<div
110+
v-for="pm in packageManagers"
111+
:key="`install-${pm.id}`"
112+
:data-pm-cmd="pm.id"
113+
class="flex items-center gap-2 group/installcmd min-w-0"
114+
>
115+
<span class="text-fg-subtle font-mono text-sm select-none shrink-0">$</span>
116+
<code class="font-mono text-sm min-w-0"
117+
><span
118+
v-for="(part, i) in getInstallPartsForPM(pm.id)"
119+
:key="i"
120+
:class="i === 0 ? 'text-fg' : 'text-fg-muted'"
121+
>{{ i > 0 ? ' ' : '' }}{{ part }}</span
122+
></code
123+
>
124+
<button
125+
type="button"
126+
class="px-2 py-0.5 font-mono text-xs text-fg-muted bg-bg-subtle/80 border border-border rounded transition-colors duration-200 opacity-0 group-hover/installcmd:opacity-100 hover:(text-fg border-border-hover) active:scale-95 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
127+
:aria-label="$t('package.get_started.copy_command')"
128+
@click.stop="copyInstallCommand"
129+
>
130+
<span aria-live="polite">{{ copied ? $t('common.copied') : $t('common.copy') }}</span>
131+
</button>
132+
</div>
133+
134+
<!-- @types package install - render all PM variants when types package exists -->
135+
<template v-if="typesPackageName && showTypesInInstall">
136+
<div
137+
v-for="pm in packageManagers"
138+
:key="`types-${pm.id}`"
139+
:data-pm-cmd="pm.id"
140+
class="flex items-center gap-2 min-w-0"
141+
>
142+
<span class="text-fg-subtle font-mono text-sm select-none shrink-0">$</span>
143+
<code class="font-mono text-sm min-w-0"
144+
><span
145+
v-for="(part, i) in getTypesInstallPartsForPM(pm.id)"
146+
:key="i"
147+
:class="i === 0 ? 'text-fg' : 'text-fg-muted'"
148+
>{{ i > 0 ? ' ' : '' }}{{ part }}</span
149+
></code
150+
>
151+
<NuxtLink
152+
:to="`/${typesPackageName}`"
153+
class="text-fg-subtle hover:text-fg-muted text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 rounded"
154+
:title="$t('package.get_started.view_types', { package: typesPackageName })"
155+
>
156+
<span
157+
class="i-carbon:arrow-right rtl-flip w-3 h-3 inline-block align-middle"
158+
aria-hidden="true"
159+
/>
160+
<span class="sr-only">View {{ typesPackageName }}</span>
161+
</NuxtLink>
162+
</div>
163+
</template>
164+
165+
<!-- Run command (only if package has executables) - render all PM variants -->
166+
<template v-if="executableInfo?.hasExecutable">
167+
<!-- Comment line -->
168+
<div class="flex items-center gap-2 pt-1">
169+
<span class="text-fg-subtle font-mono text-sm select-none"
170+
># {{ $t('package.run.locally') }}</span
171+
>
172+
</div>
173+
174+
<div
175+
v-for="pm in packageManagers"
176+
:key="`run-${pm.id}`"
177+
:data-pm-cmd="pm.id"
178+
class="flex items-center gap-2 group/runcmd"
179+
>
180+
<span class="text-fg-subtle font-mono text-sm select-none">$</span>
181+
<code class="font-mono text-sm"
182+
><span
183+
v-for="(part, i) in getRunPartsForPM(pm.id, executableInfo?.primaryCommand)"
184+
:key="i"
185+
:class="i === 0 ? 'text-fg' : 'text-fg-muted'"
186+
>{{ i > 0 ? ' ' : '' }}{{ part }}</span
187+
></code
188+
>
189+
<button
190+
type="button"
191+
class="px-2 py-0.5 font-mono text-xs text-fg-muted bg-bg-subtle/80 border border-border rounded transition-colors duration-200 opacity-0 group-hover/runcmd:opacity-100 hover:(text-fg border-border-hover) active:scale-95 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
192+
@click.stop="copyRunCommand(executableInfo?.primaryCommand)"
193+
>
194+
{{ runCopied ? $t('common.copied') : $t('common.copy') }}
195+
</button>
196+
</div>
197+
</template>
198+
199+
<!-- Create command (for packages with associated create-* package) - render all PM variants -->
200+
<template v-if="createPackageInfo">
201+
<!-- Comment line -->
202+
<div class="flex items-center gap-2 pt-1">
203+
<span class="text-fg-subtle font-mono text-sm select-none"
204+
># {{ $t('package.create.title') }}</span
205+
>
206+
</div>
207+
208+
<div
209+
v-for="pm in packageManagers"
210+
:key="`create-${pm.id}`"
211+
:data-pm-cmd="pm.id"
212+
class="flex items-center gap-2 group/createcmd"
213+
>
214+
<span class="text-fg-subtle font-mono text-sm select-none">$</span>
215+
<code class="font-mono text-sm"
216+
><span
217+
v-for="(part, i) in getCreatePartsForPM(pm.id)"
218+
:key="i"
219+
:class="i === 0 ? 'text-fg' : 'text-fg-muted'"
220+
>{{ i > 0 ? ' ' : '' }}{{ part }}</span
221+
></code
222+
>
223+
<button
224+
type="button"
225+
class="px-2 py-0.5 font-mono text-xs text-fg-muted bg-bg-subtle/80 border border-border rounded transition-colors duration-200 opacity-0 group-hover/createcmd:opacity-100 hover:(text-fg border-border-hover) active:scale-95 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
226+
:aria-label="$t('package.create.copy_command')"
227+
@click.stop="copyCreateCommand"
228+
>
229+
<span aria-live="polite">{{
230+
createCopied ? $t('common.copied') : $t('common.copy')
231+
}}</span>
232+
</button>
233+
<NuxtLink
234+
:to="`/${createPackageInfo.packageName}`"
235+
class="text-fg-subtle hover:text-fg-muted text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 rounded"
236+
:title="`View ${createPackageInfo.packageName}`"
237+
>
238+
<span class="i-carbon:arrow-right rtl-flip w-3 h-3" aria-hidden="true" />
239+
<span class="sr-only">View {{ createPackageInfo.packageName }}</span>
240+
</NuxtLink>
241+
</div>
242+
</template>
243+
</div>
244+
</div>
245+
</div>
246+
</template>
247+
248+
<style>
249+
/*
250+
* Package manager command visibility based on data-pm attribute on <html>.
251+
* All variants are rendered; CSS shows only the selected one.
252+
*/
253+
254+
/* Hide all variants by default when preference is set */
255+
:root[data-pm] [data-pm-cmd] {
256+
display: none;
257+
}
258+
259+
/* Show only the matching package manager command */
260+
:root[data-pm='npm'] [data-pm-cmd='npm'],
261+
:root[data-pm='pnpm'] [data-pm-cmd='pnpm'],
262+
:root[data-pm='yarn'] [data-pm-cmd='yarn'],
263+
:root[data-pm='bun'] [data-pm-cmd='bun'],
264+
:root[data-pm='deno'] [data-pm-cmd='deno'],
265+
:root[data-pm='vlt'] [data-pm-cmd='vlt'] {
266+
display: flex;
267+
}
268+
269+
/* Fallback: when no data-pm is set (SSR initial), show npm as default */
270+
:root:not([data-pm]) [data-pm-cmd]:not([data-pm-cmd='npm']) {
271+
display: none;
272+
}
273+
</style>

0 commit comments

Comments
 (0)