feat(design): v2 opt-in (atmospheric dusks) — Settings toggle, cookie-mirrored
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:
2026-05-15 12:28:44 -04:00
parent 5bb8289a54
commit a302ac09b4
36 changed files with 367 additions and 58 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

+1
View File
@@ -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'
+117
View File
@@ -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).
}
+1 -1
View File
@@ -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')
+1
View File
@@ -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,
})
+25 -25
View File
@@ -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)
+2
View File
@@ -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
}
+93
View File
@@ -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 &amp; 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>
+28
View File
@@ -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
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
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
+2 -2
View File
@@ -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>
+27 -10
View File
@@ -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;
}
}
+30
View File
@@ -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,
+20
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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">