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>