diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index 5df11f3146..fb7a5d0429 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -43,7 +43,7 @@ We agree to restrict the following behaviors in our community. Instances, threat
Tensions can occur between community members even when they are trying their best to collaborate. Not every conflict represents a code of conduct violation, and this Code of Conduct reinforces encouraged behaviors and norms that can help avoid conflicts and minimize harm.
-When an incident does occur, it is important to report it promptly. To report a possible violation, contact the project stewards (@danielroe and @patak.dev) by DM in our community chat. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project stewards are obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
+When an incident does occur, it is important to report it promptly. To report a possible violation, contact the project stewards (@danielroe and @patak.cat) by DM in our community chat. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project stewards are obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
Community Moderators take reports of violations seriously and will make every effort to respond in a timely manner. They will investigate all reports of code of conduct violations, reviewing messages, logs, and recordings, or interviewing witnesses and other participants. Community Moderators will keep investigation and enforcement actions as transparent as possible while prioritizing safety and confidentiality. In order to honor these values, enforcement actions are carried out in private with the involved parties, but communicating to the whole community may be part of a mutually agreed upon resolution.
diff --git a/GOVERNANCE.md b/GOVERNANCE.md
index 63e92d7306..1731cdd378 100644
--- a/GOVERNANCE.md
+++ b/GOVERNANCE.md
@@ -41,7 +41,7 @@ Not every contributor will reach this level, and that's okay! Maintainers still
The npmx project Stewards are currently:
- **Daniel Roe** ([website](https://roe.dev), [social](https://bsky.app/profile/danielroe.dev), [github](https://github.com/danielroe), [@danielroe](https://chat.npmx.dev))
-- **Matias Capeletto** ([website](https://patak.dev), [social](https://bsky.app/profile/patak.dev), [github](https://github.com/patak-dev), [@patak.dev](https://chat.npmx.dev))
+- **Matias Capeletto** ([website](https://patak.cat), [social](https://bsky.app/profile/patak.cat), [github](https://github.com/patak-cat), [@patak.cat](https://chat.npmx.dev))
---
diff --git a/app/assets/main.css b/app/assets/main.css
index 0de1fb5fc8..8897b7da18 100644
--- a/app/assets/main.css
+++ b/app/assets/main.css
@@ -114,7 +114,7 @@
--badge-blue: oklch(0.579 0.191 252);
--badge-yellow: oklch(0.588 0.183 91);
- --badge-green: oklch(0.566 0.202 165);
+ --badge-green: oklch(0.49 0.15 161.08);
--badge-indigo: oklch(0.457 0.24 277.023);
--badge-purple: oklch(0.495 0.172 295);
--badge-orange: oklch(0.67 0.185 55);
@@ -275,13 +275,16 @@ dd {
}
/* Shiki theme colors */
-html.light .shiki,
-html.light .shiki span {
+html.light .shiki {
color: var(--shiki-light) !important;
background-color: var(--shiki-light-bg) !important;
- font-style: var(--shiki-light-font-style) !important;
- font-weight: var(--shiki-light-font-weight) !important;
- text-decoration: var(--shiki-light-text-decoration) !important;
+
+ & span {
+ color: var(--shiki-light) !important;
+ font-style: var(--shiki-light-font-style) !important;
+ font-weight: var(--shiki-light-font-weight) !important;
+ text-decoration: var(--shiki-light-text-decoration) !important;
+ }
}
/* Inline code in package descriptions */
diff --git a/app/components/Alert.vue b/app/components/Alert.vue
new file mode 100644
index 0000000000..19467a3727
--- /dev/null
+++ b/app/components/Alert.vue
@@ -0,0 +1,41 @@
+
+
+
+
+
diff --git a/app/components/AppFooter.vue b/app/components/AppFooter.vue
index 818168f1ba..d0cb8c6337 100644
--- a/app/components/AppFooter.vue
+++ b/app/components/AppFooter.vue
@@ -88,13 +88,21 @@ const closeModal = () => modalRef.value?.close?.()
- .
- {{ $t('shortcuts.open_code_view') }}
+ m
+ {{ $t('shortcuts.open_main') }}
d
{{ $t('shortcuts.open_docs') }}
+
+ .
+ {{ $t('shortcuts.open_code_view') }}
+
+
+ f
+ {{ $t('shortcuts.open_diff') }}
+
c
{{ $t('shortcuts.compare_from_package') }}
diff --git a/app/components/Code/MobileTreeDrawer.vue b/app/components/Code/MobileTreeDrawer.vue
index 17f8b23b78..192178e9b6 100644
--- a/app/components/Code/MobileTreeDrawer.vue
+++ b/app/components/Code/MobileTreeDrawer.vue
@@ -29,7 +29,7 @@ watch(isOpen, open => (isLocked.value = open))
+import { ref, computed } from 'vue'
+import { VueUiHorizontalBar } from 'vue-data-ui/vue-ui-horizontal-bar'
+import type { VueUiHorizontalBarConfig, VueUiHorizontalBarDatasetItem } from 'vue-data-ui'
+import { getFrameworkColor, isListedFramework } from '~/utils/frameworks'
+import { drawSmallNpmxLogoAndTaglineWatermark } from '~/composables/useChartWatermark'
+import {
+ loadFile,
+ insertLineBreaks,
+ sanitise,
+ applyEllipsis,
+ copyAltTextForCompareFacetBarChart,
+} from '~/utils/charts'
+
+import('vue-data-ui/style.css')
+
+const props = defineProps<{
+ values: (FacetValue | null | undefined)[]
+ packages: string[]
+ label: string
+ description: string
+ facetLoading?: boolean
+}>()
+
+const colorMode = useColorMode()
+const resolvedMode = shallowRef<'light' | 'dark'>('light')
+const rootEl = shallowRef(null)
+const { width } = useElementSize(rootEl)
+const { copy, copied } = useClipboard()
+
+const mobileBreakpointWidth = 640
+const isMobile = computed(() => width.value > 0 && width.value < mobileBreakpointWidth)
+
+const chartKey = ref(0)
+
+const { colors } = useCssVariables(
+ [
+ '--bg',
+ '--fg',
+ '--bg-subtle',
+ '--bg-elevated',
+ '--fg-subtle',
+ '--fg-muted',
+ '--border',
+ '--border-subtle',
+ ],
+ {
+ element: rootEl,
+ watchHtmlAttributes: true,
+ watchResize: false,
+ },
+)
+
+const watermarkColors = computed(() => ({
+ fg: colors.value.fg ?? OKLCH_NEUTRAL_FALLBACK,
+ bg: colors.value.bg ?? OKLCH_NEUTRAL_FALLBACK,
+ fgSubtle: colors.value.fgSubtle ?? OKLCH_NEUTRAL_FALLBACK,
+}))
+
+onMounted(async () => {
+ rootEl.value = document.documentElement
+ resolvedMode.value = colorMode.value === 'dark' ? 'dark' : 'light'
+})
+
+watch(
+ () => colorMode.value,
+ value => {
+ resolvedMode.value = value === 'dark' ? 'dark' : 'light'
+ },
+ { flush: 'sync' },
+)
+
+watch(
+ () => props.packages,
+ (newP, oldP) => {
+ if (newP.length !== oldP.length) return
+ chartKey.value += 1
+ },
+)
+
+const isDarkMode = computed(() => resolvedMode.value === 'dark')
+
+const dataset = computed(() => {
+ if (props.facetLoading) return []
+ return props.packages.map((name, index) => {
+ const rawValue = props.values[index]?.raw
+ return {
+ name: insertLineBreaks(applyEllipsis(name)),
+ value: typeof rawValue === 'number' ? rawValue : 0,
+ color: isListedFramework(name) ? getFrameworkColor(name) : undefined,
+ formattedValue: props.values[index]?.display,
+ }
+ })
+})
+
+const skeletonDataset = computed(() =>
+ props.packages.map((_pkg, i) => ({
+ name: '_',
+ value: i + 1,
+ color: colors.value.border,
+ })),
+)
+
+function buildExportFilename(extension: string): string {
+ const sanitizedPackages = props.packages.map(p => sanitise(p).slice(0, 10)).join('_')
+ const comparisonLabel = sanitise($t('compare.packages.section_comparison'))
+ const facetLabel = sanitise(props.label)
+ return `${facetLabel}_${comparisonLabel}_${sanitizedPackages}.${extension}`
+}
+
+const config = computed(() => {
+ return {
+ theme: isDarkMode.value ? 'dark' : '',
+ userOptions: {
+ buttons: {
+ tooltip: false,
+ pdf: false,
+ fullscreen: false,
+ sort: false,
+ annotator: false,
+ table: false,
+ csv: false,
+ altCopy: true,
+ },
+ buttonTitle: {
+ img: $t('package.trends.download_file', { fileType: 'PNG' }),
+ svg: $t('package.trends.download_file', { fileType: 'SVG' }),
+ altCopy: $t('package.trends.copy_alt.button_label'),
+ },
+ callbacks: {
+ img: args => {
+ const imageUri = args?.imageUri
+ if (!imageUri) return
+ loadFile(imageUri, buildExportFilename('png'))
+ },
+ svg: args => {
+ const blob = args?.blob
+ if (!blob) return
+ const url = URL.createObjectURL(blob)
+ loadFile(url, buildExportFilename('svg'))
+ URL.revokeObjectURL(url)
+ },
+ altCopy: ({ dataset: dst, config: cfg }) => {
+ copyAltTextForCompareFacetBarChart({
+ dataset: dst,
+ config: {
+ ...cfg,
+ facet: props.label,
+ description: props.description,
+ copy,
+ $t,
+ },
+ })
+ },
+ },
+ },
+ skeletonDataset: skeletonDataset.value,
+ skeletonConfig: {
+ style: {
+ chart: {
+ backgroundColor: colors.value.bg,
+ },
+ },
+ },
+ style: {
+ chart: {
+ backgroundColor: colors.value.bg,
+ height: 60 * props.packages.length,
+ layout: {
+ bars: {
+ rowColor: isDarkMode.value ? colors.value.borderSubtle : colors.value.bgSubtle,
+ rowRadius: 4,
+ borderRadius: 4,
+ dataLabels: {
+ fontSize: isMobile.value ? 12 : 18,
+ percentage: { show: false },
+ offsetX: 12,
+ bold: false,
+ color: colors.value.fg,
+ value: {
+ formatter: ({ config }) => {
+ return config?.datapoint?.formattedValue ?? '0'
+ },
+ },
+ },
+ nameLabels: {
+ fontSize: isMobile.value ? 12 : 18,
+ color: colors.value.fgSubtle,
+ },
+ underlayerColor: colors.value.bg,
+ },
+ highlighter: {
+ opacity: isMobile.value ? 0 : 5,
+ },
+ },
+ legend: {
+ show: false,
+ },
+ title: {
+ fontSize: 16,
+ bold: false,
+ text: props.label,
+ color: colors.value.fg,
+ subtitle: {
+ text: props.description,
+ fontSize: 12,
+ color: colors.value.fgSubtle,
+ },
+ },
+ tooltip: {
+ show: !isMobile.value,
+ borderColor: 'transparent',
+ backdropFilter: false,
+ backgroundColor: 'transparent',
+ customFormat: ({ datapoint }) => {
+ const name = datapoint?.name?.replace(/\n/g, ' ')
+ return `
+
+
+
+
+
+
+
+
+ ${name}
+
+
+ ${datapoint?.formattedValue ?? 0}
+
+
+
+ `
+ },
+ },
+ },
+ },
+ }
+})
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ CSV
+
+
+ PNG
+
+
+ SVG
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/components/Compare/PackageSelector.vue b/app/components/Compare/PackageSelector.vue
index 5cde32b1c8..43e7ec81c7 100644
--- a/app/components/Compare/PackageSelector.vue
+++ b/app/components/Compare/PackageSelector.vue
@@ -272,7 +272,7 @@ onClickOutside(containerRef, () => {
{
v-for="(result, index) in filteredResults"
:key="result.name"
data-navigable
- class="block w-full text-start my-0.5"
+ class="block w-full text-start my-0.5 !border-transparent"
:class="highlightedIndex === index + resultIndexOffset ? '!bg-accent/15' : ''"
@mouseenter="highlightedIndex = index + resultIndexOffset"
@click="addPackage(result.name)"
diff --git a/app/components/Header/SearchBox.vue b/app/components/Header/SearchBox.vue
index 8aeaecd158..9a01ebf9fe 100644
--- a/app/components/Header/SearchBox.vue
+++ b/app/components/Header/SearchBox.vue
@@ -44,11 +44,12 @@ defineExpose({ focus })
-
/
-
+
(),
{
size: 'medium',
@@ -28,6 +30,8 @@ const emit = defineEmits<{
const el = useTemplateRef('el')
+const keyboardShortcutsEnabled = useKeyboardShortcuts()
+
defineExpose({
focus: () => el.value?.focus(),
blur: () => el.value?.blur(),
@@ -51,5 +55,6 @@ defineExpose({
/** Catching Vue render-bug of invalid `disabled=false` attribute in the final HTML */
disabled ? true : undefined
"
+ :aria-keyshortcuts="keyboardShortcutsEnabled ? ariaKeyshortcuts : undefined"
/>
diff --git a/app/components/Link/Base.vue b/app/components/Link/Base.vue
index 5bc5328364..ba62e47ca8 100644
--- a/app/components/Link/Base.vue
+++ b/app/components/Link/Base.vue
@@ -124,7 +124,7 @@ const keyboardShortcutsEnabled = useKeyboardShortcuts()
{{ ariaKeyshortcuts }}
diff --git a/app/components/OgImage/BlogPost.vue b/app/components/OgImage/BlogPost.vue
index 01965a1abf..293979b1f2 100644
--- a/app/components/OgImage/BlogPost.vue
+++ b/app/components/OgImage/BlogPost.vue
@@ -110,6 +110,8 @@ const formattedAuthorNames = computed(() => {
v-if="author.avatar"
:src="author.avatar"
:alt="author.name"
+ width="48"
+ height="48"
class="w-full h-full object-cover"
/>
diff --git a/app/components/Package/ClaimPackageModal.vue b/app/components/Package/ClaimPackageModal.vue
index 7d061331ba..d7d8b4fbed 100644
--- a/app/components/Package/ClaimPackageModal.vue
+++ b/app/components/Package/ClaimPackageModal.vue
@@ -199,28 +199,26 @@ const previewPackageJson = computed(() => {
-
-
{{ $t('claim.modal.invalid_name') }}
-
+
-
-
{{ $t('common.warnings') }}
-
+
@@ -305,39 +303,23 @@ const previewPackageJson = computed(() => {
-
- {{ mergedError }}
-
+
{{ mergedError }}
-
-
{{ $t('claim.modal.scope_warning_title') }}
-
- {{
- $t('claim.modal.scope_warning_text', {
- username: npmUser || 'username',
- name: packageName,
- })
- }}
-
-
+
+ {{
+ $t('claim.modal.scope_warning_text', {
+ username: npmUser || 'username',
+ name: packageName,
+ })
+ }}
+
-
-
{{ $t('claim.modal.connect_required') }}
-
+
{{ $t('claim.modal.connect_required') }}
{
-
- {{ mergedError }}
-
+
{{ mergedError }}
+import type { PackumentVersion, ProvenanceDetails, SlimVersion, SlimPackument } from '#shared/types'
+import type { RouteLocationRaw } from 'vue-router'
+import { SCROLL_TO_TOP_THRESHOLD } from '~/composables/useScrollToTop'
+import { useModal } from '~/composables/useModal'
+import { useAtproto } from '~/composables/atproto/useAtproto'
+import { togglePackageLike } from '~/utils/atproto/likes'
+import { isEditableElement } from '~/utils/input'
+
+const props = defineProps<{
+ pkg?: Pick | null
+ resolvedVersion?: string | null
+ displayVersion?: PackumentVersion | null
+ latestVersion?: SlimVersion | null
+ provenanceData?: ProvenanceDetails | null
+ provenanceStatus?: string | null
+ page: 'main' | 'docs' | 'code' | 'diff'
+ versionUrlPattern: string
+}>()
+
+const { requestedVersion, orgName } = usePackageRoute()
+const { scrollToTop, isTouchDeviceClient } = useScrollToTop()
+const packageHeaderHeight = usePackageHeaderHeight()
+
+const header = useTemplateRef('header')
+const isHeaderPinned = shallowRef(false)
+const { height: headerHeight } = useElementBounding(header)
+
+function isStickyPinned(el: HTMLElement | null): boolean {
+ if (!el) return false
+
+ const style = getComputedStyle(el)
+ const top = parseFloat(style.top) || 0
+ const rect = el.getBoundingClientRect()
+
+ return Math.abs(rect.top - top) < 1
+}
+
+function checkHeaderPosition() {
+ isHeaderPinned.value = isStickyPinned(header.value)
+}
+
+useEventListener('scroll', checkHeaderPosition, { passive: true })
+useEventListener('resize', checkHeaderPosition)
+
+onMounted(() => {
+ checkHeaderPosition()
+})
+
+watch(
+ headerHeight,
+ value => {
+ packageHeaderHeight.value = Math.max(0, value)
+ },
+ { immediate: true },
+)
+
+onBeforeUnmount(() => {
+ packageHeaderHeight.value = 0
+})
+
+const navExtraOffsetStyle = { '--package-nav-extra': '0px' }
+
+const { y: scrollY } = useScroll(window)
+const showScrollToTop = computed(
+ () => isTouchDeviceClient.value && scrollY.value > SCROLL_TO_TOP_THRESHOLD,
+)
+
+const packageName = computed(() => props.pkg?.name ?? '')
+const compactNumberFormatter = useCompactNumberFormatter()
+
+const { copied: copiedPkgName, copy: copyPkgName } = useClipboard({
+ source: packageName,
+ copiedDuring: 2000,
+})
+
+function hasProvenance(version: PackumentVersion | null): boolean {
+ if (!version?.dist) return false
+ return !!(version.dist as { attestations?: unknown }).attestations
+}
+
+const router = useRouter()
+// Docs URL: use our generated API docs
+const docsLink = computed(() => {
+ if (!props.resolvedVersion) return null
+
+ return {
+ name: 'docs' as const,
+ params: {
+ path: [props.pkg?.name ?? '', 'v', props.resolvedVersion] satisfies [string, string, string],
+ },
+ }
+})
+
+const codeLink = computed((): RouteLocationRaw | null => {
+ if (props.pkg == null || props.resolvedVersion == null) {
+ return null
+ }
+ const split = props.pkg.name.split('/')
+ return {
+ name: 'code',
+ params: {
+ org: split.length === 2 ? split[0] : undefined,
+ packageName: split.length === 2 ? split[1]! : split[0]!,
+ version: props.resolvedVersion,
+ filePath: '',
+ },
+ }
+})
+
+const mainLink = computed((): RouteLocationRaw | null => {
+ if (props.pkg == null || props.resolvedVersion == null) {
+ return null
+ }
+ return packageRoute(props.pkg.name, props.resolvedVersion)
+})
+
+const diffLink = computed((): RouteLocationRaw | null => {
+ if (
+ props.pkg == null ||
+ props.resolvedVersion == null ||
+ props.latestVersion == null ||
+ props.latestVersion.version === props.resolvedVersion
+ ) {
+ return null
+ }
+ return diffRoute(props.pkg.name, props.resolvedVersion, props.latestVersion.version)
+})
+
+const keyboardShortcuts = useKeyboardShortcuts()
+
+onKeyStroke(
+ e => keyboardShortcuts.value && isKeyWithoutModifiers(e, '.') && !isEditableElement(e.target),
+ e => {
+ if (codeLink.value === null) return
+ e.preventDefault()
+
+ navigateTo(codeLink.value)
+ },
+ { dedupe: true },
+)
+
+onKeyStroke(
+ e => keyboardShortcuts.value && isKeyWithoutModifiers(e, 'm') && !isEditableElement(e.target),
+ e => {
+ if (mainLink.value === null) return
+ e.preventDefault()
+
+ navigateTo(mainLink.value)
+ },
+ { dedupe: true },
+)
+
+onKeyStroke(
+ e => keyboardShortcuts.value && isKeyWithoutModifiers(e, 'd') && !isEditableElement(e.target),
+ e => {
+ if (!docsLink.value) return
+ e.preventDefault()
+ navigateTo(docsLink.value)
+ },
+ { dedupe: true },
+)
+
+onKeyStroke(
+ e => keyboardShortcuts.value && isKeyWithoutModifiers(e, 'c') && !isEditableElement(e.target),
+ e => {
+ if (!props.pkg) return
+ e.preventDefault()
+ router.push({ name: 'compare', query: { packages: props.pkg.name } })
+ },
+ { dedupe: true },
+)
+
+onKeyStroke(
+ e => keyboardShortcuts.value && isKeyWithoutModifiers(e, 'f') && !isEditableElement(e.target),
+ e => {
+ if (diffLink.value === null) return
+ e.preventDefault()
+ navigateTo(diffLink.value)
+ },
+ { dedupe: true },
+)
+
+//atproto
+// TODO: Maybe set this where it's not loaded here every load?
+const { user } = useAtproto()
+
+const authModal = useModal('auth-modal')
+
+const { data: likesData, status: likeStatus } = useFetch(
+ () => `/api/social/likes/${packageName.value}`,
+ {
+ default: () => ({ totalLikes: 0, userHasLiked: false }),
+ server: false,
+ },
+)
+
+const isLoadingLikeData = computed(
+ () => likeStatus.value === 'pending' || likeStatus.value === 'idle',
+)
+
+const isLikeActionPending = shallowRef(false)
+
+const likeAction = async () => {
+ if (user.value?.handle == null) {
+ authModal.open()
+ return
+ }
+
+ if (isLikeActionPending.value) return
+
+ const currentlyLiked = likesData.value?.userHasLiked ?? false
+ const currentLikes = likesData.value?.totalLikes ?? 0
+
+ // Optimistic update
+ likesData.value = {
+ totalLikes: currentlyLiked ? currentLikes - 1 : currentLikes + 1,
+ userHasLiked: !currentlyLiked,
+ }
+
+ isLikeActionPending.value = true
+
+ try {
+ const result = await togglePackageLike(packageName.value, currentlyLiked, user.value?.handle)
+
+ isLikeActionPending.value = false
+
+ if (result.success) {
+ // Update with server response
+ likesData.value = result.data
+ } else {
+ // Revert on error
+ likesData.value = {
+ totalLikes: currentLikes,
+ userHasLiked: currentlyLiked,
+ }
+ }
+ } catch {
+ // Revert on error
+ likesData.value = {
+ totalLikes: currentLikes,
+ userHasLiked: currentlyLiked,
+ }
+ isLikeActionPending.value = false
+ }
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('package.links.main') }}
+
+
+ {{ $t('package.links.docs') }}
+
+
+ {{ $t('package.links.code') }}
+
+
+ {{ $t('compare.compare_versions') }}
+
+
+
+
+
+
+
diff --git a/app/components/Package/ManagerSelect.vue b/app/components/Package/ManagerSelect.vue
index 910a25aade..a6b33c619e 100644
--- a/app/components/Package/ManagerSelect.vue
+++ b/app/components/Package/ManagerSelect.vue
@@ -144,6 +144,7 @@ function handleKeydown(event: KeyboardEvent) {
:id="listboxId"
ref="listRef"
role="listbox"
+ data-testid="package-manager-dropdown"
:aria-activedescendant="
highlightedIndex >= 0
? `${listboxId}-${packageManagers[highlightedIndex]?.id}`
diff --git a/app/components/Package/Sidebar.vue b/app/components/Package/Sidebar.vue
index 32c3f755ba..9733e6bd99 100644
--- a/app/components/Package/Sidebar.vue
+++ b/app/components/Package/Sidebar.vue
@@ -23,6 +23,10 @@ const offset = computed(() => {
? content.value.offsetTop
: container.value.offsetHeight - content.value.offsetTop - content.value.offsetHeight
})
+const packageHeaderHeight = usePackageHeaderHeight()
+const stickyStyle = computed(() =>
+ direction.value === 'up' ? { top: `${56 + packageHeaderHeight.value}px` } : { bottom: `32px` },
+)
const style = computed(() => {
return direction.value === 'down'
@@ -42,6 +46,7 @@ const style = computed(() => {
diff --git a/app/components/Package/Skeleton.vue b/app/components/Package/Skeleton.vue
index 061767735b..ca1e784fad 100644
--- a/app/components/Package/Skeleton.vue
+++ b/app/components/Package/Skeleton.vue
@@ -1,51 +1,45 @@
+
+
+
+
-
-
-
-