diff --git a/app/app.vue b/app/app.vue
index 03fc939fb5..fc98b8bd81 100644
--- a/app/app.vue
+++ b/app/app.vue
@@ -31,6 +31,53 @@ const colorScheme = computed(() => {
}[colorMode.preference]
})
+// Runs only while this root component is mounted (unlike a global plugin),
+// so it doesn't interfere with component tests that mountSuspended a
+// standalone component and mock useConnector for that component alone.
+if (import.meta.client) {
+ useLikesBadge()
+}
+
+// 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 +201,19 @@ if (!isBlogPostRoute.value) {
{{ route.name === 'search' ? `${$t('search.title_packages')} - npmx` : message }}
-
-
+
+
+
+
+
+
+
-
+
@@ -199,4 +252,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/Header/ConnectorModal.vue b/app/components/Header/ConnectorModal.vue
index d157ea31cc..188a43e9af 100644
--- a/app/components/Header/ConnectorModal.vue
+++ b/app/components/Header/ConnectorModal.vue
@@ -70,6 +70,11 @@ const executeNpmxConnectorCommand = computed(() => {
:label="$t('connector.modal.auto_open_url')"
v-model="settings.connector.autoOpenURL"
/>
+
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 @@
+
+
+
+
+