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
+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';