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({ }) + +