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>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260515120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add user.design_version — opt-in flag for the v2 (dusks) redesign; null defaults to v1';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Nullable on purpose: null means the user has never touched the preference,
|
||||
// which the app treats as v1. New explicit values are 'v1' or 'v2'.
|
||||
$this->addSql('ALTER TABLE "user" ADD design_version VARCHAR(10) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE "user" DROP COLUMN design_version');
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 264 KiB |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -16,9 +16,9 @@
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="WeVisto" />
|
||||
<script type="module" crossorigin src="/build/assets/index-B3RcyMgN.js"></script>
|
||||
<script type="module" crossorigin src="/build/assets/index-C81kpUpa.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-BNDVmFr7.js">
|
||||
<link rel="stylesheet" crossorigin href="/build/assets/index-CraJX9-T.css">
|
||||
<link rel="stylesheet" crossorigin href="/build/assets/index-_dOZGklt.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Controller;
|
||||
use App\Entity\User;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
@@ -36,21 +37,28 @@ class SpaController extends AbstractController
|
||||
}
|
||||
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$theme = $user->getTheme() ?? 'warm-craft';
|
||||
$user = $this->getUser();
|
||||
$theme = $user->getTheme() ?? 'warm-craft';
|
||||
$designVersion = $user->getDesignVersion(); // 'v1' or 'v2'; defaults to 'v1'
|
||||
|
||||
$userData = json_encode([
|
||||
'id' => $user->getId(),
|
||||
'email' => $user->getEmail(),
|
||||
'roles' => $user->getRoles(),
|
||||
'theme' => $theme,
|
||||
'timezone' => $user->getTimezone(),
|
||||
'id' => $user->getId(),
|
||||
'email' => $user->getEmail(),
|
||||
'roles' => $user->getRoles(),
|
||||
'theme' => $theme,
|
||||
'designVersion' => $designVersion,
|
||||
'timezone' => $user->getTimezone(),
|
||||
], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT);
|
||||
|
||||
$html = (string) file_get_contents($indexFile);
|
||||
|
||||
// Inject theme on <html> so CSS applies before JS hydrates (no FOUC)
|
||||
$html = str_replace('<html lang="en">', '<html lang="en" data-theme="' . htmlspecialchars($theme, ENT_QUOTES) . '">', $html);
|
||||
// Inject theme + design version on <html> so CSS applies before JS hydrates (no FOUC)
|
||||
$html = str_replace(
|
||||
'<html lang="en">',
|
||||
'<html lang="en" data-theme="' . htmlspecialchars($theme, ENT_QUOTES)
|
||||
. '" data-design="' . htmlspecialchars($designVersion, ENT_QUOTES) . '">',
|
||||
$html,
|
||||
);
|
||||
|
||||
// Bootstrap current user into window so Pinia auth store needs no initial API call.
|
||||
// Also expose the Mercure public URL so the live-updates composable can subscribe
|
||||
@@ -62,6 +70,15 @@ class SpaController extends AbstractController
|
||||
$html,
|
||||
);
|
||||
|
||||
return new Response($html, headers: ['Content-Type' => 'text/html; charset=utf-8']);
|
||||
$response = new Response($html, headers: ['Content-Type' => 'text/html; charset=utf-8']);
|
||||
// Refresh the design cookie on every SPA load so Twig pages (login,
|
||||
// setup, help) reflect the current server-side preference even after
|
||||
// a login on a new browser.
|
||||
$response->headers->setCookie(Cookie::create('wevisto_design', $designVersion)
|
||||
->withPath('/')
|
||||
->withExpires(new \DateTimeImmutable('+1 year'))
|
||||
->withHttpOnly(false)
|
||||
->withSameSite('Lax'));
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Controller;
|
||||
use App\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -27,6 +28,9 @@ class UserApiController extends AbstractController
|
||||
'honey-slate',
|
||||
];
|
||||
|
||||
private const VALID_DESIGN_VERSIONS = ['v1', 'v2'];
|
||||
public const DESIGN_COOKIE = 'wevisto_design';
|
||||
|
||||
#[Route('/search', name: 'api_users_search', methods: ['GET'])]
|
||||
public function search(Request $request, EntityManagerInterface $em): JsonResponse
|
||||
{
|
||||
@@ -73,6 +77,32 @@ class UserApiController extends AbstractController
|
||||
return $this->json(['theme' => $theme]);
|
||||
}
|
||||
|
||||
#[Route('/design', name: 'api_user_design', methods: ['PATCH'])]
|
||||
public function updateDesignVersion(Request $request, EntityManagerInterface $em): JsonResponse
|
||||
{
|
||||
$body = json_decode($request->getContent(), true);
|
||||
$version = $body['designVersion'] ?? null;
|
||||
|
||||
if (!is_string($version) || !in_array($version, self::VALID_DESIGN_VERSIONS, true)) {
|
||||
return $this->json(['error' => 'Invalid designVersion'], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$user->setDesignVersion($version);
|
||||
$em->flush();
|
||||
|
||||
$response = $this->json(['designVersion' => $version]);
|
||||
// Cookie mirrors the server-side preference so Twig pages (login,
|
||||
// setup, help) can do correct first-paint without re-querying the DB.
|
||||
$response->headers->setCookie(Cookie::create(self::DESIGN_COOKIE, $version)
|
||||
->withPath('/')
|
||||
->withExpires(new \DateTimeImmutable('+1 year'))
|
||||
->withHttpOnly(false) // readable from JS so the SPA can swap design without reload
|
||||
->withSameSite('Lax'));
|
||||
return $response;
|
||||
}
|
||||
|
||||
#[Route('/password', name: 'api_user_password', methods: ['PATCH'])]
|
||||
public function updatePassword(
|
||||
Request $request,
|
||||
|
||||
@@ -36,6 +36,14 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
#[ORM\Column(length: 50, nullable: true)]
|
||||
private ?string $theme = null;
|
||||
|
||||
/**
|
||||
* Design version preference: 'v1' (cream, the original) or 'v2' (dusks,
|
||||
* the atmospheric redesign). Null means the user has never opted in,
|
||||
* which the app treats as v1. Cookie-mirrored on login.
|
||||
*/
|
||||
#[ORM\Column(length: 10, nullable: true)]
|
||||
private ?string $designVersion = null;
|
||||
|
||||
#[ORM\Column(length: 60, nullable: true)]
|
||||
private ?string $timezone = null;
|
||||
|
||||
@@ -105,6 +113,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** Returns 'v1' or 'v2'; defaults to 'v1' when unset. */
|
||||
public function getDesignVersion(): string
|
||||
{
|
||||
return $this->designVersion ?? 'v1';
|
||||
}
|
||||
|
||||
public function setDesignVersion(?string $version): static
|
||||
{
|
||||
$this->designVersion = $version;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTimezone(): string
|
||||
{
|
||||
return $this->timezone ?? 'UTC';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html data-design="{{ app.request.cookies.get('wevisto_design')|default('v1') }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{% block title %}Welcome!{% endblock %}</title>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-design="{{ app.request.cookies.get('wevisto_design')|default('v1') }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-design="{{ app.request.cookies.get('wevisto_design')|default('v1') }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-design="{{ app.request.cookies.get('wevisto_design')|default('v1') }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-design="{{ app.request.cookies.get('wevisto_design')|default('v1') }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-design="{{ app.request.cookies.get('wevisto_design')|default('v1') }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-design="{{ app.request.cookies.get('wevisto_design')|default('v1') }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-design="{{ app.request.cookies.get('wevisto_design')|default('v1') }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-design="{{ app.request.cookies.get('wevisto_design')|default('v1') }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-design="{{ app.request.cookies.get('wevisto_design')|default('v1') }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-design="{{ app.request.cookies.get('wevisto_design')|default('v1') }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
Reference in New Issue
Block a user