feat: ship as a real iOS-installable PWA, restructure bottom nav, fix safe-area
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:
2026-05-06 18:07:05 -04:00
parent e0bad975ec
commit 5fcfb806be
58 changed files with 2922 additions and 60 deletions
@@ -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()
})
})