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:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user