Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
606cb61
feat: enable PWA installability via @vite-pwa/nuxt
tomayac Jun 18, 2026
21ae868
feat: add Share button to package pages via Web Share API
tomayac Jun 18, 2026
3d0af90
feat: add window controls overlay and app shortcuts
tomayac Jun 18, 2026
b02e663
feat(ui): replace custom toggle with native input switch polyfill
tomayac Jun 18, 2026
56be1fd
feat: add App Badging API for new likes on user's packages
tomayac Jun 18, 2026
d38aa74
feat: generate PWA screenshots for richer install UI
tomayac Jun 19, 2026
f116059
feat: add PWA manifest id, generate screenshots for richer install UI
tomayac Jun 19, 2026
1615b3e
Merge remote-tracking branch 'upstream/main' into pwa-and-premium-app…
tomayac Jun 20, 2026
b6f93a1
feat(pwa): improve WCO, Share button shortcut, and polyfill live theming
tomayac Jun 20, 2026
7e682b9
fix(types): fix vue-tsc failures in input-switch-polyfill plugin
tomayac Jul 1, 2026
7325eb7
Merge remote-tracking branch 'upstream/main' into pwa-and-premium-app…
tomayac Jul 1, 2026
7c9faf3
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 1, 2026
2f32b65
fix(pwa): validate full share payload before attaching og:image file
tomayac Jul 1, 2026
b222d1f
fix(pwa): fail fast when --url flag is passed without a value
tomayac Jul 1, 2026
988ed44
Merge remote-tracking branch 'origin/pwa-and-premium-app-features' in…
tomayac Jul 1, 2026
7047054
fix(pwa): propagate preview-server errors and clean up on startup fai…
tomayac Jul 1, 2026
07ed5b4
fix: resolve CI failures — knip, i18n schema, duplicate vue resolution
tomayac Jul 1, 2026
184cb52
fix(pwa): fix sticky sub-header gap under window controls overlay
tomayac Jul 2, 2026
49a4d7b
fix(pwa): enable install prompt capture
tomayac Jul 2, 2026
71eedc5
refactor(pwa): use top-level await in screenshot generator
tomayac Jul 4, 2026
209bac6
chore(pwa): drop unnecessary SPDX header from screenshot generator
tomayac Jul 4, 2026
905712b
refactor: split Web Share button and input switch polyfill into separ…
tomayac Jul 4, 2026
b9ab636
Merge remote-tracking branch 'upstream/main' into pwa-and-premium-app…
tomayac Jul 4, 2026
9eed2ba
fix: move likes-badge init out of a global plugin into app.vue
tomayac Jul 4, 2026
d51e77e
feat: add setting to toggle the likes badge
tomayac Jul 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 97 additions & 3 deletions app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,53 @@
}[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 <meta> 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) => {

Check warning on line 49 in app/app.vue

View workflow job for this annotation

GitHub Actions / 🤖 Autofix code

eslint-plugin-unicorn(consistent-function-scoping)

Function `applyThemeColor` does not capture any variables from its parent scope

Check warning on line 49 in app/app.vue

View workflow job for this annotation

GitHub Actions / 🔠 Lint project

eslint-plugin-unicorn(consistent-function-scoping)

Function `applyThemeColor` does not capture any variables from its parent scope
const meta = document.querySelector<HTMLMetaElement>('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<HTMLMetaElement>('meta[name="theme-color"]')
if (meta) {
new MutationObserver(() => {
if (desiredThemeColor) applyThemeColor(desiredThemeColor)
}).observe(meta, { attributes: true, attributeFilter: ['content'] })
}
})
}

useHead({
htmlAttrs: {
'lang': () => locale.value,
Expand Down Expand Up @@ -154,13 +201,19 @@
{{ route.name === 'search' ? `${$t('search.title_packages')} - npmx` : message }}
</NuxtRouteAnnouncer>

<div id="main-content" class="flex-1 flex flex-col" tabindex="-1">
<NuxtPage />
<!-- In WCO mode this div becomes a fixed scroll container that starts just
below the header, so the scrollbar never intrudes into the title bar. -->
<div id="app-scroll" class="flex-1 flex flex-col min-h-0">
<div id="main-content" class="flex-1 flex flex-col" tabindex="-1">
<NuxtPage />
</div>

<AppFooter />
</div>

<CommandPalette />

<AppFooter />
<PwaPrompt />

<ScrollToTop />
</div>
Expand Down Expand Up @@ -199,4 +252,45 @@
html[data-kbd-hints='true'] kbd::before {
opacity: 1;
}

/*
* Window Controls Overlay — scroll container.
*
* In WCO mode the <header> 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 <header> 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;
}
}
</style>
50 changes: 50 additions & 0 deletions app/components/AppHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,56 @@ useShortcuts({
})
</script>

<style scoped>
/*
* Window Controls Overlay — moves the app header into the installed PWA title
* bar, making the chrome controls (close/min/max) sit flush with the header.
*
* env(titlebar-area-x) — horizontal start after macOS traffic lights (0 on Windows)
* env(titlebar-area-y) — vertical offset from viewport top (usually 0)
* env(titlebar-area-width) — usable width (viewport minus controls on both sides)
*
* header has `drag` so the full strip is a window-move target.
* Interactive descendants each declare `no-drag`; the gaps between them
* inherit `drag` from the header and act as the drag handle.
*/
@media (display-mode: window-controls-overlay) {
header {
position: fixed;
inset-inline: 0;
top: 0;
padding-top: env(titlebar-area-y, 0px);
-webkit-app-region: drag;
app-region: drag;
}

/* Solid, opaque background so the web content matches the OS title-bar color
and scrolling content cannot bleed through the translucent layer. */
header > div {
background-color: var(--bg);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}

nav {
/* Span the full header width so the inline padding can reference 100% = viewport */
max-width: 100%;
margin-inline: 0;
/* Two-value shorthand in one declaration beats the container class's padding-inline.
Left: macOS traffic-lights width (0 on Windows).
Right: Windows min/max/close width (0 on macOS). */
padding-inline: env(titlebar-area-x, 0px)
calc(100% - env(titlebar-area-x, 0px) - env(titlebar-area-width, 100%));
}

/* Every interactive descendant must cancel the inherited drag so it stays clickable */
header :deep(:is(a, button, input, select, [role='button'], [role='combobox'])) {
-webkit-app-region: no-drag;
app-region: no-drag;
}
}
</style>

<template>
<header class="sticky top-0 z-50 border-b border-border">
<div class="absolute inset-0 bg-bg/80 backdrop-blur-md" />
Expand Down
5 changes: 5 additions & 0 deletions app/components/Header/ConnectorModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ const executeNpmxConnectorCommand = computed(() => {
:label="$t('connector.modal.auto_open_url')"
v-model="settings.connector.autoOpenURL"
/>
<SettingsToggle
:label="$t('connector.modal.show_likes_badge')"
:description="$t('connector.modal.show_likes_badge_description')"
v-model="settings.connector.showLikesBadge"
/>
</div>

<div class="border-t border-border my-3" />
Expand Down
40 changes: 40 additions & 0 deletions app/components/PwaPrompt.client.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<script setup lang="ts">
const { $pwa } = useNuxtApp()
</script>

<template>
<Transition name="pwa-toast" appear>
<div
v-if="$pwa?.needRefresh"
role="alert"
aria-live="polite"
class="fixed bottom-4 inset-ie-4 z-50 flex items-start gap-3 px-4 py-3 bg-bg border border-border rounded-lg shadow-lg max-w-sm"
>
<span class="i-lucide:refresh-cw w-4 h-4 text-fg-muted shrink-0 mt-0.5" aria-hidden="true" />
<p class="text-sm text-fg flex-1">{{ $t('pwa.update_available') }}</p>
<div class="flex items-center gap-2 shrink-0">
<ButtonBase size="sm" @click="$pwa?.cancelPrompt()">
{{ $t('common.close') }}
</ButtonBase>
<ButtonBase size="sm" variant="primary" @click="$pwa?.updateServiceWorker()">
{{ $t('pwa.refresh') }}
</ButtonBase>
</div>
</div>
</Transition>
</template>

<style scoped>
.pwa-toast-enter-active,
.pwa-toast-leave-active {
transition:
opacity 0.25s ease,
transform 0.25s ease;
}

.pwa-toast-enter-from,
.pwa-toast-leave-to {
opacity: 0;
transform: translateY(8px);
}
</style>
15 changes: 15 additions & 0 deletions app/composables/useAppBadge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
const canBadge = import.meta.client && 'setAppBadge' in navigator

export function useAppBadge() {
function setBadge(count?: number) {
if (!canBadge) return
navigator.setAppBadge(count).catch(() => {})
}

function clearBadge() {
if (!canBadge) return
navigator.clearAppBadge().catch(() => {})
}

return { canBadge, setBadge, clearBadge }
}
100 changes: 100 additions & 0 deletions app/composables/useLikesBadge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
const STORAGE_KEY = 'npmx-likes-badge'
const POLL_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes

interface StoredLikes {
npmUser: string
counts: Record<string, number>
}

function loadStored(npmUser: string): StoredLikes | null {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null
const data = JSON.parse(raw) as StoredLikes
return data.npmUser === npmUser ? data : null
} catch {
return null
}
}

function saveStored(npmUser: string, counts: Record<string, number>) {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ npmUser, counts } satisfies StoredLikes))
}

export function useLikesBadge() {
const { isConnected, npmUser, listUserPackages } = useConnector()
const { setBadge, clearBadge, canBadge } = useAppBadge()
const { settings } = useSettings()
const enabled = computed(() => settings.value.connector.showLikesBadge)

// Cached package list — refreshed only when the npm user changes.
const userPackages = shallowRef<string[]>([])

watch(npmUser, async user => {
if (!user) {
userPackages.value = []
return
}
const pkgMap = await listUserPackages()
// Cap at 20 packages to keep the polling cost bounded.
userPackages.value = pkgMap ? Object.keys(pkgMap).sort().slice(0, 20) : []
})

async function checkLikes() {
if (!canBadge || !enabled.value || !npmUser.value || !userPackages.value.length) return

const results = await Promise.allSettled(
userPackages.value.map(pkg =>
$fetch<{ totalLikes: number }>(`/api/social/likes/${encodeURIComponent(pkg)}`),
),
)

const current: Record<string, number> = {}
for (let i = 0; i < userPackages.value.length; i++) {
const r = results[i]
if (r?.status === 'fulfilled') {
current[userPackages.value[i]!] = r.value.totalLikes
}
}

const stored = loadStored(npmUser.value)

let newLikes = 0
for (const [pkg, count] of Object.entries(current)) {
// On first check there is no baseline — store current and show nothing.
const prev = stored?.counts[pkg] ?? count
newLikes += Math.max(0, count - prev)
}

saveStored(npmUser.value, current)

if (newLikes > 0) {
setBadge(newLikes)
} else {
clearBadge()
}
}

let timer: ReturnType<typeof setInterval> | null = null

watch(
[isConnected, npmUser, enabled],
([connected, user, isEnabled]) => {
if (timer) {
clearInterval(timer)
timer = null
}
if (connected && user && isEnabled) {
checkLikes()
timer = setInterval(checkLikes, POLL_INTERVAL_MS)
} else {
clearBadge()
}
},
{ immediate: true },
)

onScopeDispose(() => {
if (timer) clearInterval(timer)
})
}
3 changes: 3 additions & 0 deletions app/composables/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export interface AppSettings {
connector: {
/** Automatically open the web auth page in the browser */
autoOpenURL: boolean
/** Show a badge on the installed app icon for new likes on your packages */
showLikesBadge: boolean
}
codeContainerFull: boolean
/** Enable/disable ligatures in code */
Expand Down Expand Up @@ -73,6 +75,7 @@ const DEFAULT_SETTINGS: AppSettings = {
changelogAutoScroll: true,
connector: {
autoOpenURL: false,
showLikesBadge: true,
},
codeContainerFull: false,
codeLigatures: true,
Expand Down
25 changes: 25 additions & 0 deletions app/pages/settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,31 @@ useSeoMeta({
/>
</div>
</section>

<!-- APP Section (install prompt — shown only when browser supports PWA install) -->
<ClientOnly>
<section v-if="$pwa?.showInstallPrompt && !$pwa?.isPWAInstalled">
<h2 class="text-xs text-fg-muted uppercase tracking-wider mb-4">
{{ $t('settings.sections.app') }}
</h2>
<div class="bg-bg-subtle border border-border rounded-lg p-4 sm:p-6">
<div class="flex items-start justify-between gap-4">
<div class="space-y-1">
<p class="text-sm text-fg font-medium">{{ $t('pwa.install_app') }}</p>
<p class="text-sm text-fg-muted">{{ $t('pwa.install_app_description') }}</p>
</div>
<ButtonBase
size="sm"
variant="primary"
classicon="i-lucide:download"
@click="$pwa?.install()"
>
{{ $t('pwa.install') }}
</ButtonBase>
</div>
</div>
</section>
</ClientOnly>
</div>
</article>
</main>
Expand Down
Loading
Loading