diff --git a/app/app.vue b/app/app.vue
index 03fc939fb5..21f32d8994 100644
--- a/app/app.vue
+++ b/app/app.vue
@@ -31,6 +31,46 @@ const colorScheme = computed(() => {
}[colorMode.preference]
})
+// Keep theme-color in sync with --bg so the WCO title-bar strip (where the
+// OS traffic-lights / min-max-close buttons are drawn) matches the header.
+// We write directly to the DOM node rather than going through useHead
+// because NuxtPwaAssets also calls useHead for theme-color, and as a child
+// component it would always win the deduplication race.
+if (import.meta.client) {
+ let desiredThemeColor = ''
+
+ const applyThemeColor = (color: string) => {
+ const meta = document.querySelector('meta[name="theme-color"]')
+ if (meta && meta.content !== color) meta.content = color
+ }
+ const readBg = () => {
+ const raw = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim()
+ if (!raw) return
+ desiredThemeColor = raw
+ applyThemeColor(raw)
+ }
+
+ onMounted(() => {
+ readBg()
+
+ // Re-apply whenever the color mode or accent changes
+ new MutationObserver(readBg).observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ['style', 'class'],
+ })
+
+ // @unhead flushes after onMounted and re-writes the meta node with the
+ // PWA module's static '#0a0a0a'. Guard against that by watching the node
+ // and immediately re-asserting our CSS-variable value when it changes.
+ const meta = document.querySelector('meta[name="theme-color"]')
+ if (meta) {
+ new MutationObserver(() => {
+ if (desiredThemeColor) applyThemeColor(desiredThemeColor)
+ }).observe(meta, { attributes: true, attributeFilter: ['content'] })
+ }
+ })
+}
+
useHead({
htmlAttrs: {
'lang': () => locale.value,
@@ -154,13 +194,19 @@ if (!isBlogPostRoute.value) {
{{ route.name === 'search' ? `${$t('search.title_packages')} - npmx` : message }}
-
-
+
+
+
+
+
+
+
-
+
@@ -199,4 +245,45 @@ kbd::before {
html[data-kbd-hints='true'] kbd::before {
opacity: 1;
}
+
+/*
+ * Window Controls Overlay — scroll container.
+ *
+ * In WCO mode the is position:fixed, so the viewport would
+ * otherwise scroll from y=0 (through the title bar). Instead we disable
+ * viewport scrolling entirely and make #app-scroll a fixed element that
+ * starts exactly at the header's bottom border, so the scrollbar track
+ * appears only in the content area and never in the title bar.
+ *
+ * Header height = env(titlebar-area-y, 0px) ← usually 0
+ * + 3.5rem (min-h-14, the nav row)
+ * + 1px (border-bottom of the header)
+ */
+@media (display-mode: window-controls-overlay) {
+ html,
+ body {
+ overflow: hidden;
+ height: 100%;
+ /* scrollbar-gutter: stable reserves 15 px on the right even when the
+ scrollbar is gone. That gap shows up in the header border and the
+ fixed #app-scroll element. Remove the reservation in WCO mode. */
+ scrollbar-gutter: auto;
+ }
+
+ #app-scroll {
+ position: fixed;
+ top: calc(env(titlebar-area-y, 0px) + 3.5rem + 1px);
+ inset-inline: 0;
+ bottom: 0;
+ overflow-y: auto;
+ }
+
+ /* Page-level sticky sub-headers (e.g. PackageHeader) use top-14 to clear
+ the fixed when the viewport itself is the scroll container.
+ #app-scroll already starts below the header here, so that offset would
+ otherwise leave a redundant 3.5rem gap above them. */
+ #app-scroll .sticky[class~='top-14'] {
+ top: 0;
+ }
+}
diff --git a/app/components/AppHeader.vue b/app/components/AppHeader.vue
index 46a8d4cf9e..bcf4f02c2c 100644
--- a/app/components/AppHeader.vue
+++ b/app/components/AppHeader.vue
@@ -214,6 +214,56 @@ useShortcuts({
})
+
+
diff --git a/app/components/Button/Base.vue b/app/components/Button/Base.vue
index 1a321e888d..182dc3db28 100644
--- a/app/components/Button/Base.vue
+++ b/app/components/Button/Base.vue
@@ -41,6 +41,7 @@ const keyboardShortcutsEnabled = useKeyboardShortcuts()
defineExpose({
focus: () => el.value?.focus(),
+ click: () => el.value?.click(),
getBoundingClientRect: () => el.value?.getBoundingClientRect(),
})
diff --git a/app/components/Package/Header.vue b/app/components/Package/Header.vue
index d0306d3706..a087268529 100644
--- a/app/components/Package/Header.vue
+++ b/app/components/Package/Header.vue
@@ -75,6 +75,18 @@ const { copied: copiedPkgName, copy: copyPkgName } = useClipboard({
copiedDuring: 2000,
})
+const canShare = import.meta.client && 'share' in navigator
+
+function sharePackage() {
+ navigator
+ .share({
+ title: packageName.value,
+ text: props.displayVersion?.description ?? packageName.value,
+ url: window.location.href,
+ })
+ .catch(() => {})
+}
+
function hasProvenance(version: PackumentVersion | null): boolean {
if (!version?.dist) return false
return !!(version.dist as { attestations?: unknown }).attestations
@@ -100,6 +112,17 @@ useCommandPaletteContextCommands(
},
]
+ if (canShare) {
+ commands.push({
+ id: 'package-share',
+ group: 'package',
+ label: $t('package.links.share'),
+ keywords: [packageName.value, 'share'],
+ iconClass: 'i-lucide:share-2',
+ action: sharePackage,
+ })
+ }
+
if (fundingUrl.value) {
commands.push({
id: 'package-link-funding',
@@ -238,6 +261,11 @@ useShortcuts({
+
+
+const props = defineProps<{
+ packageName: string
+ description?: string | null
+}>()
+
+const canShare = 'share' in navigator
+const buttonRef = useTemplateRef<{ click: () => void }>('buttonRef')
+
+if (canShare) {
+ onKeyStroke(
+ e => isKeyWithoutModifiers(e, 'v') && !isEditableElement(e.target),
+ e => {
+ e.preventDefault()
+ // Click the button element so the browser anchors the share sheet at
+ // the button's position rather than the current mouse cursor position.
+ buttonRef.value?.click()
+ },
+ )
+}
+
+async function getOgImageFile(): Promise {
+ // Files sharing is not universally supported; bail out early if not available.
+ if (!navigator.canShare?.({ files: [new File([], 'x.png', { type: 'image/png' })] })) {
+ return null
+ }
+ try {
+ const ogMeta = document.querySelector('meta[property="og:image"]')
+ if (!ogMeta?.content) return null
+ const blob = await fetch(ogMeta.content).then(r => r.blob())
+ if (!blob.type.startsWith('image/')) return null
+ return new File([blob], `${props.packageName}.png`, { type: blob.type })
+ } catch {
+ return null
+ }
+}
+
+async function share() {
+ const shareData: ShareData = {
+ title: props.packageName,
+ text: props.description ?? props.packageName,
+ url: window.location.href,
+ }
+
+ const imageFile = await getOgImageFile()
+ if (imageFile) {
+ const shareDataWithFile: ShareData = { ...shareData, files: [imageFile] }
+ // Some implementations support sharing files or url/text, but not the
+ // combination of both. Only attach the file once the full payload
+ // (title + text + url + files) is confirmed shareable, so we keep the
+ // url/text fallback otherwise.
+ if (navigator.canShare?.(shareDataWithFile)) {
+ shareData.files = shareDataWithFile.files
+ }
+ }
+
+ await navigator.share(shareData).catch(() => {})
+}
+
+
+
+
+ {{ $t('package.links.share') }}
+
+
diff --git a/app/components/PwaPrompt.client.vue b/app/components/PwaPrompt.client.vue
new file mode 100644
index 0000000000..f0e95457c5
--- /dev/null
+++ b/app/components/PwaPrompt.client.vue
@@ -0,0 +1,40 @@
+
+
+
+
+