feat: ship as a real iOS-installable PWA, restructure bottom nav, fix safe-area
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
- Add manifest.webmanifest with standalone display + warm-craft theme colors, apple-touch-icon, and 192/512/512-maskable icons (frame-with-sunset glyph). - Add PWA meta tags + viewport-fit=cover so add-to-home-screen produces a true standalone app on iOS instead of a Safari bookmark. - Drop the Shared bottom-nav tab; the in-page sub-tabs already cover that. Three nav tabs total (Home / Library / Settings); pending-share badge moves to the Library tab. Predicate-based isActive() now correctly disambiguates /library vs /library?tab=shared. - Safe-area handling: bottom nav, bottom sheet, upload overlay, and #app respect env(safe-area-inset-*); sticky Library tabs anchor below the iPhone status bar. Introduces --bottom-nav-height token consumed by Settings, Library, and the toast. - LibraryView reactively follows route.query.tab so deep-linking /library?tab=shared lands on the right sub-tab. - Theme-color meta syncs client-side via useTheme.applyTheme so the user's chosen theme follows them into Android Chrome's chrome bar. Test suite expanded to 278 tests / 100% line coverage (99.84% statements, 99.78% branches). Remaining gaps are unreachable defensive code. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { createRouter, createMemoryHistory, type Router } from 'vue-router'
|
||||
import BottomNav from '@/components/BottomNav.vue'
|
||||
import { useImagesStore } from '@/stores/images'
|
||||
|
||||
const Stub = { template: '<div />' }
|
||||
|
||||
function makeRouter(): Router {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: Stub },
|
||||
{ path: '/library', component: Stub },
|
||||
{ path: '/settings', component: Stub },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
async function mountAt(router: Router, path: string) {
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
return mount(BottomNav, { global: { plugins: [router] } })
|
||||
}
|
||||
|
||||
function activeTabName(wrapper: ReturnType<typeof mount>): string | undefined {
|
||||
const active = wrapper.find('.bottom-nav__tab--active')
|
||||
if (!active.exists()) return undefined
|
||||
return active.find('.bottom-nav__label').text()
|
||||
}
|
||||
|
||||
describe('BottomNav', () => {
|
||||
let router: Router
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
router = makeRouter()
|
||||
})
|
||||
|
||||
it('renders three tabs (Home, Library, Settings)', async () => {
|
||||
const wrapper = await mountAt(router, '/')
|
||||
const labels = wrapper.findAll('.bottom-nav__label').map(n => n.text())
|
||||
expect(labels).toEqual(['Home', 'Library', 'Settings'])
|
||||
})
|
||||
|
||||
it('marks Home active on /', async () => {
|
||||
const wrapper = await mountAt(router, '/')
|
||||
expect(activeTabName(wrapper)).toBe('Home')
|
||||
})
|
||||
|
||||
it('marks Library active on /library', async () => {
|
||||
const wrapper = await mountAt(router, '/library')
|
||||
expect(activeTabName(wrapper)).toBe('Library')
|
||||
})
|
||||
|
||||
it('marks Library active on /library?tab=shared', async () => {
|
||||
const wrapper = await mountAt(router, '/library?tab=shared')
|
||||
expect(activeTabName(wrapper)).toBe('Library')
|
||||
})
|
||||
|
||||
it('marks Settings active on /settings', async () => {
|
||||
const wrapper = await mountAt(router, '/settings')
|
||||
expect(activeTabName(wrapper)).toBe('Settings')
|
||||
})
|
||||
|
||||
it('does not mark Home active on /library (regression: startsWith bug)', async () => {
|
||||
const wrapper = await mountAt(router, '/library')
|
||||
const homeTab = wrapper.findAll('.bottom-nav__tab')
|
||||
.find(t => t.find('.bottom-nav__label').text() === 'Home')
|
||||
expect(homeTab?.classes()).not.toContain('bottom-nav__tab--active')
|
||||
})
|
||||
|
||||
it('updates active tab when route changes', async () => {
|
||||
const wrapper = await mountAt(router, '/')
|
||||
expect(activeTabName(wrapper)).toBe('Home')
|
||||
await router.push('/library')
|
||||
await flushPromises()
|
||||
expect(activeTabName(wrapper)).toBe('Library')
|
||||
})
|
||||
|
||||
it('shows the pending-share count as a badge on the Library tab', async () => {
|
||||
const images = useImagesStore()
|
||||
images.pendingCount = 3
|
||||
const wrapper = await mountAt(router, '/')
|
||||
const libraryTab = wrapper.findAll('.bottom-nav__tab')
|
||||
.find(t => t.find('.bottom-nav__label').text() === 'Library')!
|
||||
const badge = libraryTab.find('.bottom-nav__badge')
|
||||
expect(badge.exists()).toBe(true)
|
||||
expect(badge.text()).toBe('3')
|
||||
})
|
||||
|
||||
it('clamps the badge count to "9+" when 10 or more are pending', async () => {
|
||||
const images = useImagesStore()
|
||||
images.pendingCount = 12
|
||||
const wrapper = await mountAt(router, '/')
|
||||
expect(wrapper.find('.bottom-nav__badge').text()).toBe('9+')
|
||||
})
|
||||
|
||||
it('hides the badge when no shares are pending', async () => {
|
||||
const images = useImagesStore()
|
||||
images.pendingCount = 0
|
||||
const wrapper = await mountAt(router, '/')
|
||||
expect(wrapper.find('.bottom-nav__badge').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user