Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 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
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
93 changes: 90 additions & 3 deletions app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,46 @@
}[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 <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 42 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

Check warning on line 42 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
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 +194,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 +245,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
1 change: 1 addition & 0 deletions app/components/Button/Base.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const keyboardShortcutsEnabled = useKeyboardShortcuts()

defineExpose({
focus: () => el.value?.focus(),
click: () => el.value?.click(),
getBoundingClientRect: () => el.value?.getBoundingClientRect(),
})
</script>
Expand Down
28 changes: 28 additions & 0 deletions app/components/Package/Header.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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',
Expand Down Expand Up @@ -238,6 +261,11 @@ useShortcuts({
</LinkBase>
<PackageLikes :packageName />

<PackageShareButton
:package-name="packageName"
:description="displayVersion?.description"
/>

<LinkBase
variant="button-secondary"
v-if="fundingUrl"
Expand Down
73 changes: 73 additions & 0 deletions app/components/Package/ShareButton.client.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<script setup lang="ts">
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<File | null> {
// 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<HTMLMetaElement>('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(() => {})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
</script>

<template>
<ButtonBase
v-if="canShare"
ref="buttonRef"
variant="secondary"
classicon="i-lucide:share-2"
:aria-label="$t('package.share_aria_label', { package: packageName })"
:ariaKeyshortcuts="'v'"
@click="share"
>
<span class="max-sm:sr-only">{{ $t('package.links.share') }}</span>
</ButtonBase>
</template>
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>
42 changes: 3 additions & 39 deletions app/components/Settings/Toggle.client.vue
Original file line number Diff line number Diff line change
Expand Up @@ -54,51 +54,15 @@ const id = useId()
{{ label }}
</span>
<input
role="switch"
switch
type="checkbox"
:id="id"
v-model="checked"
class="toggle appearance-none h-6 w-11 rounded-full border border-fg relative shrink-0 bg-fg/50 transition-colors duration-200 ease-in-out checked:bg-fg [@media(hover:hover)]:hover:bg-fg/60 [@media(hover:hover)]:checked:hover:(bg-fg/80 after:opacity-50) focus-visible:(outline-2 outline-accent outline-offset-2) before:(content-[''] absolute h-5 w-5 top-px start-px rounded-full bg-bg transition-transform duration-200 ease-in-out) checked:before:translate-x-[20px] rtl:checked:before:-translate-x-[20px] after:(content-[''] absolute h-3.5 w-3.5 top-[4px] start-[4px] i-lucide:check bg-fg opacity-0 transition-all duration-200 ease-in-out) checked:after:opacity-100 checked:after:translate-x-[20px] rtl:checked:after:-translate-x-[20px]"
style="grid-area: toggle; justify-self: end"
class="shrink-0 focus-visible:(outline-2 outline-accent outline-offset-2)"
style="grid-area: toggle; justify-self: end; accent-color: var(--accent)"
/>
</label>
<p v-if="description" class="text-sm text-fg-muted mt-2">
{{ description }}
</p>
</template>

<style scoped>
/* Support forced colors */
@media (forced-colors: active) {
.toggle {
background: Canvas;
border-color: CanvasText;
}

.toggle:checked {
background: Highlight;
border-color: CanvasText;
}

.toggle::before {
background-color: CanvasText;
}

.toggle:checked::before {
background-color: Canvas;
}

.toggle::after {
background-color: Highlight;
}
}

@media (prefers-reduced-motion: reduce) {
.toggle,
.toggle::before,
.toggle::after {
transition: none !important;
animation: none !important;
}
}
</style>
Loading
Loading