feat(design): v2 opt-in (atmospheric dusks) — Settings toggle, cookie-mirrored
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
Lets users opt into the new atmospheric design without affecting users on v1.
Adds a beta-flag toggle in Settings → Design. Server-side preference persists
across devices; a cookie mirrors it so unauthenticated Twig pages do correct
first-paint without an extra DB roundtrip.
Backend:
- User.designVersion column (nullable VARCHAR(10); null defaults to 'v1')
- Migration Version20260515120000
- PATCH /api/user/design endpoint accepting 'v1'|'v2', sets wevisto_design cookie
- SpaController injects data-design on <html> + refreshes the cookie on every
SPA load (keeps cross-device pref in sync)
- Twig templates (base, login, register, help, setup, token-*) read the
cookie via {{ app.request.cookies.get('wevisto_design')|default('v1') }}
so login/setup pages also respect the user's design choice
Frontend:
- design-v2.scss — opt-in overlay scoped under [data-design="v2"]. Overrides
--color-* tokens to dusk variants per theme (warm-craft → amber, ocean-dusk
stays, etc.), adds harbor photo backdrop via body::before with theme tint
via body::after. Glass-card blur on existing surfaces. v1 untouched.
- harbor.jpg shipped as a public asset (270KB, single-fetch, cached)
- User type gains designVersion ('v1' | 'v2')
- SettingsView toggle (Original / Atmospheric) calls the API, updates the
data-design attribute optimistically, reverts on failure
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 264 KiB |
@@ -2,6 +2,7 @@ import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import VueKonva from 'vue-konva'
|
||||
import '@/styles/global.scss'
|
||||
import '@/styles/design-v2.scss'
|
||||
import App from './App.vue'
|
||||
import router from '@/router'
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// ─── Design v2 — atmospheric dusks ──────────────────────────────────────
|
||||
// Opt-in overlay activated via [data-design="v2"] on <html>. The user's
|
||||
// theme preference (warm-craft, ocean-dusk, etc.) chooses *which* dusk;
|
||||
// all six get a tinted-photo backdrop and a darker, glass-friendly token set.
|
||||
//
|
||||
// The yellow V (brand) stays #f0d000 across every dusk.
|
||||
|
||||
[data-design="v2"] {
|
||||
// Brand constants — survive every theme/dusk
|
||||
--brand-yellow: #f0d000;
|
||||
|
||||
// Default dusk = warm-craft → "amber dusk"
|
||||
--color-bg: #1a0d05;
|
||||
--color-surface: rgba(50, 22, 8, 0.55);
|
||||
--color-surface-2: rgba(80, 38, 14, 0.55);
|
||||
--color-border: rgba(230, 180, 130, 0.20);
|
||||
--color-text: #faecd0;
|
||||
--color-text-muted: #c8a880;
|
||||
--color-primary: #e89048;
|
||||
--color-primary-fg: #1a0d05;
|
||||
--color-secondary: rgba(80, 38, 14, 0.55);
|
||||
--color-secondary-fg:#faecd0;
|
||||
--color-destructive: #e08070;
|
||||
--color-destructive-fg: #ffffff;
|
||||
--color-focus-ring: var(--brand-yellow);
|
||||
|
||||
// Per-theme dusk overrides — only the bg-tint + accent change between dusks
|
||||
&[data-theme="ocean-dusk"] {
|
||||
--color-bg: #06121f;
|
||||
--color-surface: rgba(10, 28, 48, 0.55);
|
||||
--color-surface-2: rgba(18, 42, 70, 0.55);
|
||||
--color-border: rgba(180, 210, 235, 0.18);
|
||||
--color-text: #f4eed8;
|
||||
--color-text-muted: #b0c4d8;
|
||||
--color-primary: #4e9fc8;
|
||||
--color-primary-fg: #06121f;
|
||||
}
|
||||
&[data-theme="sage-cream"] {
|
||||
--color-bg: #081208;
|
||||
--color-surface: rgba(18, 38, 22, 0.55);
|
||||
--color-surface-2: rgba(28, 60, 32, 0.55);
|
||||
--color-border: rgba(180, 220, 180, 0.18);
|
||||
--color-text: #ecf3e0;
|
||||
--color-text-muted: #a8c0a0;
|
||||
--color-primary: #88c068;
|
||||
--color-primary-fg: #081208;
|
||||
}
|
||||
&[data-theme="playful-pop"] {
|
||||
--color-bg: #1a060f;
|
||||
--color-surface: rgba(48, 14, 36, 0.55);
|
||||
--color-surface-2: rgba(72, 22, 54, 0.55);
|
||||
--color-border: rgba(230, 180, 200, 0.20);
|
||||
--color-text: #f8e8ec;
|
||||
--color-text-muted: #d0a0b8;
|
||||
--color-primary: #d878a0;
|
||||
--color-primary-fg: #1a060f;
|
||||
}
|
||||
&[data-theme="dusty-mauve"] {
|
||||
--color-bg: #100618;
|
||||
--color-surface: rgba(36, 14, 50, 0.55);
|
||||
--color-surface-2: rgba(54, 22, 74, 0.55);
|
||||
--color-border: rgba(210, 190, 230, 0.18);
|
||||
--color-text: #f0e8f8;
|
||||
--color-text-muted: #c0b0d0;
|
||||
--color-primary: #b890d8;
|
||||
--color-primary-fg: #100618;
|
||||
}
|
||||
&[data-theme="honey-slate"] {
|
||||
--color-bg: #18120a;
|
||||
--color-surface: rgba(42, 32, 12, 0.55);
|
||||
--color-surface-2: rgba(62, 50, 22, 0.55);
|
||||
--color-border: rgba(232, 200, 130, 0.22);
|
||||
--color-text: #faf0d8;
|
||||
--color-text-muted: #d0b888;
|
||||
--color-primary: #e8c050;
|
||||
--color-primary-fg: #18120a;
|
||||
}
|
||||
|
||||
// Harbor photo backdrop — fixed under everything, theme-tinted
|
||||
body {
|
||||
position: relative;
|
||||
}
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: url('/build/assets/harbor.jpg') center/cover no-repeat;
|
||||
filter: brightness(0.55) saturate(0.9);
|
||||
z-index: -3;
|
||||
}
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--color-bg);
|
||||
opacity: 0.55;
|
||||
mix-blend-mode: multiply;
|
||||
z-index: -2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Frosted-glass surfaces — anywhere v1 used --color-surface as a solid bg
|
||||
// becomes a backdrop-blurred semi-translucent panel.
|
||||
// (The v1 components don't change their structure; they just inherit
|
||||
// the new token values + glass effect via this overlay.)
|
||||
.frame-card,
|
||||
.library__tile,
|
||||
.settings__section-card,
|
||||
.home-view__empty-card {
|
||||
backdrop-filter: saturate(160%) blur(20px);
|
||||
-webkit-backdrop-filter: saturate(160%) blur(20px);
|
||||
}
|
||||
|
||||
// The yellow V in the wordmark needs to survive even when the V is
|
||||
// technically part of the brand glyph (no Vue change required — handled
|
||||
// by tokens).
|
||||
}
|
||||
@@ -50,7 +50,7 @@ describe('App', () => {
|
||||
|
||||
it('falls back to auth.user.theme when no stamped theme is present', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setUser({ id: 1, email: 'a@b', roles: [], theme: 'ocean-dusk', timezone: 'UTC' })
|
||||
auth.setUser({ id: 1, email: 'a@b', roles: [], theme: 'ocean-dusk', designVersion: 'v1', timezone: 'UTC' })
|
||||
await mountApp()
|
||||
expect(document.documentElement.dataset.theme).toBe('ocean-dusk')
|
||||
const meta = document.querySelector('meta[name="theme-color"]')
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('useTheme', () => {
|
||||
|
||||
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' })
|
||||
auth.setUser({ id: 1, email: 'a@b', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
|
||||
const { applyTheme } = useTheme()
|
||||
applyTheme('sage-cream')
|
||||
expect(auth.user?.theme).toBe('sage-cream')
|
||||
|
||||
@@ -7,6 +7,7 @@ const makeUser = (overrides: Partial<User> = {}): User => ({
|
||||
email: 'test@example.com',
|
||||
roles: ['ROLE_USER'],
|
||||
theme: null,
|
||||
designVersion: 'v1',
|
||||
timezone: 'America/Chicago',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('SettingsView', () => {
|
||||
|
||||
it('renders one swatch per theme and the user email', () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setUser({ id: 1, email: 'matt@example.com', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||
auth.setUser({ id: 1, email: 'matt@example.com', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
|
||||
const wrapper = mount(SettingsView)
|
||||
expect(wrapper.findAll('.theme-swatch')).toHaveLength(THEMES.length)
|
||||
expect(wrapper.text()).toContain('matt@example.com')
|
||||
@@ -48,7 +48,7 @@ describe('SettingsView', () => {
|
||||
|
||||
it('marks the user current theme as the active swatch', () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'ocean-dusk', timezone: 'UTC' })
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'ocean-dusk', designVersion: 'v1', timezone: 'UTC' })
|
||||
const wrapper = mount(SettingsView)
|
||||
const active = wrapper.find('.theme-swatch--active')
|
||||
expect(active.attributes('aria-label')).toBe('Ocean Dusk')
|
||||
@@ -57,14 +57,14 @@ describe('SettingsView', () => {
|
||||
|
||||
it('falls back to warm-craft as active when user has no theme set', () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: null, timezone: 'UTC' })
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: null, designVersion: 'v1', timezone: 'UTC' })
|
||||
const wrapper = mount(SettingsView)
|
||||
expect(wrapper.find('.theme-swatch--active').attributes('aria-label')).toBe('Warm Craft')
|
||||
})
|
||||
|
||||
it('clicking a swatch saves the theme via PATCH /api/user/theme', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
@@ -84,7 +84,7 @@ describe('SettingsView', () => {
|
||||
|
||||
it('renders a Sign out link to /logout', () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
|
||||
const wrapper = mount(SettingsView)
|
||||
const logout = wrapper.find('a.settings__logout')
|
||||
expect(logout.text()).toBe('Sign out')
|
||||
@@ -93,7 +93,7 @@ describe('SettingsView', () => {
|
||||
|
||||
it('shows the install button by default (no native prompt yet, not standalone)', () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
|
||||
const wrapper = mount(SettingsView)
|
||||
const btn = wrapper.find('.settings__install')
|
||||
expect(btn.exists()).toBe(true)
|
||||
@@ -102,7 +102,7 @@ describe('SettingsView', () => {
|
||||
|
||||
it('opens the iOS-style instructions modal when install button is clicked without a native prompt', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
|
||||
const wrapper = mount(SettingsView)
|
||||
expect(wrapper.find('.install-modal').exists()).toBe(false)
|
||||
await wrapper.find('.settings__install').trigger('click')
|
||||
@@ -112,7 +112,7 @@ describe('SettingsView', () => {
|
||||
|
||||
it('closes the modal via the close button', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
|
||||
const wrapper = mount(SettingsView)
|
||||
await wrapper.find('.settings__install').trigger('click')
|
||||
await wrapper.find('.install-modal__close').trigger('click')
|
||||
@@ -121,7 +121,7 @@ describe('SettingsView', () => {
|
||||
|
||||
it('clicking the iOS-instructions backdrop closes the modal', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
|
||||
const wrapper = mount(SettingsView)
|
||||
await wrapper.find('.settings__install').trigger('click')
|
||||
expect(wrapper.find('.install-modal').exists()).toBe(true)
|
||||
@@ -139,7 +139,7 @@ describe('SettingsView', () => {
|
||||
|
||||
it('shows the native Install button when a beforeinstallprompt event is cached', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
|
||||
fireBeforeInstallPrompt()
|
||||
const wrapper = mount(SettingsView)
|
||||
await flushPromises()
|
||||
@@ -148,7 +148,7 @@ describe('SettingsView', () => {
|
||||
|
||||
it('clicking the native Install button invokes prompt() and resolves accepted', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
|
||||
const ev = fireBeforeInstallPrompt({ accepted: true })
|
||||
const wrapper = mount(SettingsView)
|
||||
await flushPromises()
|
||||
@@ -165,7 +165,7 @@ describe('SettingsView', () => {
|
||||
// This covers lines 187-192: install() returns false (user dismissed) AND
|
||||
// canPromptInstall has flipped false (event consumed) → showIosInstructions.
|
||||
const auth = useAuthStore()
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
|
||||
fireBeforeInstallPrompt({ accepted: false })
|
||||
const wrapper = mount(SettingsView)
|
||||
await flushPromises()
|
||||
@@ -181,7 +181,7 @@ describe('SettingsView', () => {
|
||||
|
||||
it('opens the change-password modal when the Change password action is clicked', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
|
||||
const wrapper = mount(SettingsView)
|
||||
|
||||
expect(wrapper.find('.pw-form').exists()).toBe(false)
|
||||
@@ -192,7 +192,7 @@ describe('SettingsView', () => {
|
||||
|
||||
it('shows a mismatch error when confirm differs from new password', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
|
||||
const wrapper = mount(SettingsView)
|
||||
await wrapper.find('.settings__action-link').trigger('click')
|
||||
|
||||
@@ -212,7 +212,7 @@ describe('SettingsView', () => {
|
||||
|
||||
it('does not submit when fields are empty (button disabled)', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
|
||||
const wrapper = mount(SettingsView)
|
||||
await wrapper.find('.settings__action-link').trigger('click')
|
||||
|
||||
@@ -225,7 +225,7 @@ describe('SettingsView', () => {
|
||||
// submitPasswordChange. Even if the form gets force-submitted with a
|
||||
// mismatch, no fetch fires.
|
||||
const auth = useAuthStore()
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
@@ -246,7 +246,7 @@ describe('SettingsView', () => {
|
||||
it('submits the new password, shows success, clears fields, and auto-closes', async () => {
|
||||
vi.useFakeTimers()
|
||||
const auth = useAuthStore()
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
|
||||
const fetchMock = vi.fn().mockResolvedValue({ status: 204, json: () => Promise.resolve({}) })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
@@ -283,7 +283,7 @@ describe('SettingsView', () => {
|
||||
|
||||
it('renders the server-provided error message on a 4xx response', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
status: 400,
|
||||
json: () => Promise.resolve({ error: 'Current password is wrong.' }),
|
||||
@@ -307,7 +307,7 @@ describe('SettingsView', () => {
|
||||
|
||||
it('uses a generic error when the server returns no error message', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
status: 500,
|
||||
json: () => Promise.resolve({}),
|
||||
@@ -330,7 +330,7 @@ describe('SettingsView', () => {
|
||||
it('falls back to the generic error when the response body is unparseable JSON', async () => {
|
||||
// res.json() rejecting exercises the `.catch(() => ({}))` branch.
|
||||
const auth = useAuthStore()
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
status: 400,
|
||||
json: () => Promise.reject(new Error('not json')),
|
||||
@@ -352,7 +352,7 @@ describe('SettingsView', () => {
|
||||
|
||||
it('renders a network-error message when fetch rejects', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
|
||||
const fetchMock = vi.fn().mockRejectedValue(new TypeError('network down'))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
@@ -371,7 +371,7 @@ describe('SettingsView', () => {
|
||||
|
||||
it('toggles the submit button into a saving state during the request', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
|
||||
// Pending fetch keeps pwSubmitting=true so the saving label is visible.
|
||||
let resolveFetch!: (v: { status: number; json: () => Promise<unknown> }) => void
|
||||
const fetchMock = vi.fn(() => new Promise(res => { resolveFetch = res }))
|
||||
@@ -398,7 +398,7 @@ describe('SettingsView', () => {
|
||||
|
||||
it('closes the modal via the × button and clears entered fields (resetPasswordForm)', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
|
||||
const wrapper = mount(SettingsView)
|
||||
await wrapper.find('.settings__action-link').trigger('click')
|
||||
|
||||
@@ -425,7 +425,7 @@ describe('SettingsView', () => {
|
||||
|
||||
it('renders the iPhone-specific copy in the install modal when the UA is iOS', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
|
||||
|
||||
// usePwaInstall calls detectIOS() per-component-instance from
|
||||
// navigator.userAgent, so spoofing the UA before mount picks the iOS branch.
|
||||
@@ -453,7 +453,7 @@ describe('SettingsView', () => {
|
||||
|
||||
it('closes the password modal when the backdrop is clicked', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', designVersion: 'v1', timezone: 'UTC' })
|
||||
const wrapper = mount(SettingsView)
|
||||
await wrapper.find('.settings__action-link').trigger('click')
|
||||
expect(wrapper.find('.pw-form').exists()).toBe(true)
|
||||
|
||||
@@ -3,6 +3,8 @@ export interface User {
|
||||
email: string
|
||||
roles: string[]
|
||||
theme: string | null
|
||||
/** Opt-in design version — 'v1' (cream, original) or 'v2' (atmospheric dusks). */
|
||||
designVersion: 'v1' | 'v2'
|
||||
timezone: string
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,36 @@
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="settings__section">
|
||||
<h2 class="settings__section-title">Design (beta)</h2>
|
||||
<p class="settings__hint">
|
||||
Try the new atmospheric look. Your theme picks below still apply —
|
||||
v2 just renders them as dusks over a Camogli harbor backdrop.
|
||||
</p>
|
||||
<div class="design-toggle" role="radiogroup" aria-label="Design version">
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
:aria-checked="currentDesign === 'v1'"
|
||||
:class="['design-toggle__opt', { 'design-toggle__opt--active': currentDesign === 'v1' }]"
|
||||
@click="selectDesign('v1')"
|
||||
>
|
||||
<span class="design-toggle__label">Original</span>
|
||||
<span class="design-toggle__sub">cream & terracotta</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
:aria-checked="currentDesign === 'v2'"
|
||||
:class="['design-toggle__opt', { 'design-toggle__opt--active': currentDesign === 'v2' }]"
|
||||
@click="selectDesign('v2')"
|
||||
>
|
||||
<span class="design-toggle__label">Atmospheric</span>
|
||||
<span class="design-toggle__sub">harbor dusks · beta</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings__section">
|
||||
<h2 class="settings__section-title">Theme</h2>
|
||||
<div class="theme-grid" role="radiogroup" aria-label="Choose theme">
|
||||
@@ -177,12 +207,34 @@ const { saveTheme } = useTheme()
|
||||
const { isStandalone, isIOS, canPromptInstall, install } = usePwaInstall()
|
||||
|
||||
const currentTheme = computed(() => auth.user?.theme ?? 'warm-craft')
|
||||
const currentDesign = computed(() => auth.user?.designVersion ?? 'v1')
|
||||
const showIosInstructions = ref(false)
|
||||
|
||||
function select(themeId: string) {
|
||||
saveTheme(themeId)
|
||||
}
|
||||
|
||||
async function selectDesign(version: 'v1' | 'v2') {
|
||||
if (currentDesign.value === version) return
|
||||
const prev = currentDesign.value
|
||||
// Optimistic local update so the SPA flips immediately.
|
||||
if (auth.user) auth.user.designVersion = version
|
||||
document.documentElement.setAttribute('data-design', version)
|
||||
try {
|
||||
const res = await fetch('/api/user/design', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ designVersion: version }),
|
||||
})
|
||||
if (!res.ok) throw new Error('save failed')
|
||||
// Cookie is set in the response — read-once nothing else to do here.
|
||||
} catch {
|
||||
// Revert on failure.
|
||||
if (auth.user) auth.user.designVersion = prev
|
||||
document.documentElement.setAttribute('data-design', prev)
|
||||
}
|
||||
}
|
||||
|
||||
async function onNativeInstall() {
|
||||
const accepted = await install()
|
||||
// If the native prompt failed for any reason (rare — e.g. the event
|
||||
@@ -535,4 +587,45 @@ async function submitPasswordChange() {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Design version toggle (v1 vs v2) ─────────────────────────────────────
|
||||
.design-toggle {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-2);
|
||||
|
||||
&__opt {
|
||||
background: var(--color-surface);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
color: var(--color-text);
|
||||
transition: border-color var(--duration-fast), background var(--duration-fast);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&--active {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: block;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 800;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
display: block;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user