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,113 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useTheme, THEMES } from '@/composables/useTheme'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
describe('useTheme', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
document.head.querySelectorAll('meta[name="theme-color"]').forEach(n => n.remove())
|
||||
const meta = document.createElement('meta')
|
||||
meta.setAttribute('name', 'theme-color')
|
||||
meta.setAttribute('content', '#000000')
|
||||
document.head.appendChild(meta)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.head.querySelectorAll('meta[name="theme-color"]').forEach(n => n.remove())
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
})
|
||||
|
||||
it('exports six themes with id, label, primary, bg, text', () => {
|
||||
expect(THEMES).toHaveLength(6)
|
||||
for (const t of THEMES) {
|
||||
expect(t.id).toBeTypeOf('string')
|
||||
expect(t.label).toBeTypeOf('string')
|
||||
expect(t.primary).toMatch(/^#[0-9a-f]{6}$/i)
|
||||
expect(t.bg).toMatch(/^#[0-9a-f]{6}$/i)
|
||||
expect(t.text).toMatch(/^#[0-9a-f]{6}$/i)
|
||||
}
|
||||
})
|
||||
|
||||
it('applyTheme writes data-theme on <html>', () => {
|
||||
const { applyTheme } = useTheme()
|
||||
applyTheme('ocean-dusk')
|
||||
expect(document.documentElement.dataset.theme).toBe('ocean-dusk')
|
||||
})
|
||||
|
||||
it('applyTheme syncs auth.user.theme when a user is signed in', () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setUser({ id: 1, email: 'a@b', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||
const { applyTheme } = useTheme()
|
||||
applyTheme('sage-cream')
|
||||
expect(auth.user?.theme).toBe('sage-cream')
|
||||
})
|
||||
|
||||
it('applyTheme updates the theme-color meta tag with the theme bg', () => {
|
||||
const { applyTheme } = useTheme()
|
||||
applyTheme('playful-pop')
|
||||
const meta = document.querySelector('meta[name="theme-color"]')
|
||||
const bg = THEMES.find(t => t.id === 'playful-pop')!.bg
|
||||
expect(meta?.getAttribute('content')).toBe(bg)
|
||||
})
|
||||
|
||||
it('applyTheme is a no-op for unknown theme ids on the meta tag', () => {
|
||||
const { applyTheme } = useTheme()
|
||||
applyTheme('does-not-exist')
|
||||
// data-theme still updates (we mirror the input as-is)
|
||||
expect(document.documentElement.dataset.theme).toBe('does-not-exist')
|
||||
// meta tag is left at its previous value
|
||||
const meta = document.querySelector('meta[name="theme-color"]')
|
||||
expect(meta?.getAttribute('content')).toBe('#000000')
|
||||
})
|
||||
|
||||
it('applyTheme silently skips meta sync when no theme-color meta exists', () => {
|
||||
document.head.querySelectorAll('meta[name="theme-color"]').forEach(n => n.remove())
|
||||
const { applyTheme } = useTheme()
|
||||
expect(() => applyTheme('warm-craft')).not.toThrow()
|
||||
expect(document.documentElement.dataset.theme).toBe('warm-craft')
|
||||
})
|
||||
|
||||
it('saveTheme applies and PATCHes /api/user/theme on success', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const { saveTheme } = useTheme()
|
||||
await saveTheme('honey-slate')
|
||||
|
||||
expect(document.documentElement.dataset.theme).toBe('honey-slate')
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/user/theme',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ theme: 'honey-slate' }),
|
||||
}),
|
||||
)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('saveTheme shows a toast on non-ok response', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
|
||||
const { saveTheme } = useTheme()
|
||||
// Spy on the toast store via the module
|
||||
const { useToastStore } = await import('@/stores/toast')
|
||||
const toast = useToastStore()
|
||||
const showSpy = vi.spyOn(toast, 'show')
|
||||
await saveTheme('dusty-mauve')
|
||||
expect(showSpy).toHaveBeenCalledWith(expect.stringContaining('Could not save'), 'error')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('saveTheme shows a toast when fetch rejects', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')))
|
||||
const { saveTheme } = useTheme()
|
||||
const { useToastStore } = await import('@/stores/toast')
|
||||
const toast = useToastStore()
|
||||
const showSpy = vi.spyOn(toast, 'show')
|
||||
await saveTheme('warm-craft')
|
||||
expect(showSpy).toHaveBeenCalled()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user