diff --git a/.specify/memory/research/modern-ui-effects.md b/.specify/memory/research/modern-ui-effects.md new file mode 100644 index 0000000..9867ec9 --- /dev/null +++ b/.specify/memory/research/modern-ui-effects.md @@ -0,0 +1,37 @@ +# Modern UI Effects Research (2025-2026) + +## Liquid Glass (Apple WWDC 2025) +Evolved glassmorphism with directional lighting. Three-layer approach: highlight, shadow, illumination. +- `backdrop-filter: blur(20px) saturate(1.5)` — higher saturation than basic glass +- `inset 0 1px 0 rgba(255,255,255,0.15)` — top highlight (light direction) +- `inset 0 -1px 0 rgba(0,0,0,0.1)` — bottom shadow +- Outer drop shadow for depth: `0 8px 32px rgba(0,0,0,0.3)` +- Advanced: SVG `feTurbulence` + `feSpecularLighting` for refraction (Chromium only) +- Browser support: `backdrop-filter` ~88%, Firefox since v103 + +## Aurora / Gradient Mesh Backgrounds +Stacked animated radial gradients simulating northern lights. Pairs well with glass cards on dark backgrounds. +- Multiple `radial-gradient(ellipse ...)` layers with partial opacity +- Animated via `background-position` shift (GPU-friendly) +- `@property` rule enables direct gradient color animation (broad support since 2024) +- Best for ambient background movement, not for content areas + +## Animated Glow Borders +Rotating `conic-gradient` borders with blur halo. Striking on dark backgrounds. +- Outer wrapper with `conic-gradient(from var(--angle), color1, color2, color3, color1)` +- `::before` pseudo with `filter: blur(12px)` and `opacity: 0.5` for glow halo +- `@property --angle` trick to animate custom property inside `conic-gradient` +- Use sparingly — best for single highlight elements (FAB, CTA), not all cards + +## Modern Neumorphism (2025-2026 revision) +Subtler than the original trend. Higher contrast, less extreme extrusion, combined with accent colors. +- Light and dark shadow pair: `6px 6px 12px rgba(0,0,0,0.5)` + `-6px -6px 12px rgba(60,50,80,0.15)` +- `border: 1px solid rgba(255,255,255,0.05)` for definition +- Works on dark backgrounds with slightly lighter "uplift" shadow direction +- Better suited for interactive elements (buttons, toggles) than content cards + +## Sources +- Apple Liquid Glass CSS: dev.to/gruszdev, dev.to/kevinbism, css-tricks.com, kube.io +- Aurora: dev.to/oobleck, daltonwalsh.com, github.com/mattnewdavid +- Glow borders: frontendmasters.com (Kevin Powell), docode.co.in +- Trends overview: medium.com/design-bootcamp, index.dev, bighuman.com diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index e054431..224fd1c 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -16,6 +16,26 @@ --color-text-on-gradient: #ffffff; --color-surface: #fff5f8; --color-card: #ffffff; + --color-dark-base: #1B1730; + + /* Glass system */ + --color-glass: rgba(255, 255, 255, 0.1); + --color-glass-strong: rgba(255, 255, 255, 0.15); + --color-glass-subtle: rgba(255, 255, 255, 0.05); + --color-glass-border: rgba(255, 255, 255, 0.18); + --color-glass-border-hover: rgba(255, 255, 255, 0.3); + --color-glass-hover: rgba(255, 255, 255, 0.18); + --color-glass-inner: rgba(27, 23, 48, 0.55); + --color-glass-overlay: rgba(27, 23, 48, 0.4); + + /* Text on gradient (opacity variants) */ + --color-text-muted: rgba(255, 255, 255, 0.5); + --color-text-secondary: rgba(255, 255, 255, 0.7); + --color-text-soft: rgba(255, 255, 255, 0.85); + --color-text-bright: rgba(255, 255, 255, 0.9); + + /* Glow border */ + --gradient-glow: conic-gradient(from 135deg, var(--color-gradient-start), var(--color-gradient-mid), var(--color-gradient-end), var(--color-gradient-start)); /* Gradient */ --gradient-primary: linear-gradient(135deg, #f06292 0%, #ab47bc 50%, #5c6bc0 100%); @@ -33,7 +53,7 @@ --radius-button: 14px; /* Shadows */ - --shadow-card: 0 2px 8px rgba(0, 0, 0, 0.1); + --shadow-card: 0 4px 24px rgba(0, 0, 0, 0.12); --shadow-button: 0 2px 8px rgba(0, 0, 0, 0.15); /* Layout */ @@ -60,7 +80,22 @@ html { body { min-height: 100vh; - background: var(--gradient-primary); + background-color: var(--color-dark-base); + position: relative; +} + +body::before { + content: ''; + position: fixed; + inset: 0; + background-color: var(--color-dark-base); + background-image: + radial-gradient(at 70% 20%, rgba(240, 98, 146, 0.55) 0px, transparent 50%), + radial-gradient(at 25% 50%, rgba(171, 71, 188, 0.5) 0px, transparent 55%), + radial-gradient(at 80% 70%, rgba(92, 107, 192, 0.55) 0px, transparent 50%), + radial-gradient(at 35% 85%, rgba(255, 112, 67, 0.3) 0px, transparent 40%); + filter: blur(80px); + z-index: -1; } #app { @@ -82,28 +117,35 @@ body { /* Card-style form fields */ .form-field { background: var(--color-card); - border: none; + border: 1px solid #e0e0e0; border-radius: var(--radius-card); padding: var(--spacing-md) var(--spacing-md); - box-shadow: var(--shadow-card); width: 100%; font-family: inherit; font-size: 0.95rem; font-weight: 400; color: var(--color-text); outline: none; - transition: box-shadow 0.2s ease; + transition: border-color 0.2s ease; +} + +.form-field.glass { + color: var(--color-text-on-gradient); } .form-field:focus { - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18); + border-color: var(--color-glass-border-hover); } .form-field::placeholder { - color: #999; + color: var(--color-text-muted); font-weight: 400; } +.form-field.glass::placeholder { + color: var(--color-text-muted); +} + textarea.form-field { resize: vertical; min-height: 5rem; @@ -128,22 +170,29 @@ textarea.form-field { display: block; width: 100%; padding: var(--spacing-md) var(--spacing-lg); - background: var(--color-accent); + background: var(--color-card); color: var(--color-text); - border: none; + border: 1px solid #e0e0e0; border-radius: var(--radius-button); font-family: inherit; font-size: 1rem; font-weight: 700; cursor: pointer; - box-shadow: var(--shadow-button); - transition: opacity 0.2s ease, transform 0.1s ease; + transition: border-color 0.2s ease, transform 0.1s ease; text-align: center; text-decoration: none; } +.btn-primary.glass { + color: var(--color-text-on-gradient); + border: 2px solid transparent; + background: + linear-gradient(var(--color-glass-inner), var(--color-glass-inner)) padding-box, + var(--gradient-glow) border-box; +} + .btn-primary:hover { - opacity: 0.92; + border-color: var(--color-glass-border-hover); } .btn-primary:active { @@ -176,6 +225,68 @@ textarea.form-field { 100% { background-position: -200% 0; } } +/* ── Glass System ── */ + +/* Glass surface: passive containers on gradient (cards, icon boxes) */ +.glass { + background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%); + border: 1px solid var(--color-glass-border); + box-shadow: var(--shadow-card); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); +} + +.glass:hover:not(input):not(textarea):not(.btn-primary) { + background: var(--color-glass-hover); + border-color: var(--color-glass-border-hover); +} + +/* Glass interactive inner: dark translucent fill for interactive elements (FAB, CTA) */ +.glass-inner { + background: var(--color-glass-inner); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); +} + +/* Glow border: conic gradient wrapper with halo (static) */ +.glow-border { + background: var(--gradient-glow); + padding: 2px; + position: relative; +} + +.glow-border::before { + content: ''; + position: absolute; + inset: -4px; + border-radius: inherit; + background: var(--gradient-glow); + filter: blur(8px); + opacity: 0.3; + z-index: -1; +} + +/* Glow border animated variant */ +@property --glow-angle { + syntax: ''; + initial-value: 0deg; + inherits: false; +} + +.glow-border--animated { + background: conic-gradient(from var(--glow-angle), var(--color-gradient-start), var(--color-gradient-mid), var(--color-gradient-end), var(--color-gradient-start)); + animation: glow-rotate 4s linear infinite; +} + +.glow-border--animated::before { + background: conic-gradient(from var(--glow-angle), var(--color-gradient-start), var(--color-gradient-mid), var(--color-gradient-end), var(--color-gradient-start)); + animation: glow-rotate 4s linear infinite; +} + +@keyframes glow-rotate { + to { --glow-angle: 360deg; } +} + /* Utility */ .text-center { text-align: center; @@ -197,7 +308,7 @@ textarea.form-field { .sheet-title { font-size: 1.2rem; font-weight: 700; - color: var(--color-text); + color: var(--color-text-on-gradient); } .rsvp-form { @@ -209,7 +320,7 @@ textarea.form-field { .rsvp-form__label { font-size: 0.85rem; font-weight: 700; - color: var(--color-text); + color: var(--color-text-on-gradient); padding-left: 0.25rem; } diff --git a/frontend/src/components/AttendeeList.vue b/frontend/src/components/AttendeeList.vue index d826a8a..d8b5630 100644 --- a/frontend/src/components/AttendeeList.vue +++ b/frontend/src/components/AttendeeList.vue @@ -28,7 +28,7 @@ defineProps<{ .attendee-list__heading { font-size: 0.75rem; font-weight: 700; - color: rgba(255, 255, 255, 0.5); + color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.08em; } @@ -44,7 +44,7 @@ defineProps<{ .attendee-list__item { font-size: 0.95rem; - color: rgba(255, 255, 255, 0.85); + color: var(--color-text-soft); line-height: 1.4; overflow: hidden; text-overflow: ellipsis; @@ -53,7 +53,7 @@ defineProps<{ .attendee-list__empty { font-size: 0.9rem; - color: rgba(255, 255, 255, 0.5); + color: var(--color-text-muted); font-style: italic; } diff --git a/frontend/src/components/BottomSheet.vue b/frontend/src/components/BottomSheet.vue index c3a0ba6..375621f 100644 --- a/frontend/src/components/BottomSheet.vue +++ b/frontend/src/components/BottomSheet.vue @@ -45,7 +45,7 @@ watch( .sheet-backdrop { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.4); + background: var(--color-glass-overlay); display: flex; align-items: flex-end; justify-content: center; @@ -53,7 +53,11 @@ watch( } .sheet { - background: var(--color-card); + background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%); + border: 1px solid var(--color-glass-border); + border-bottom: none; + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); border-radius: 20px 20px 0 0; padding: var(--spacing-lg) var(--spacing-xl) var(--spacing-2xl); width: 100%; @@ -67,7 +71,7 @@ watch( .sheet__handle { width: 36px; height: 4px; - background: #ccc; + background: var(--color-glass-border-hover); border-radius: 2px; align-self: center; flex-shrink: 0; diff --git a/frontend/src/components/ConfirmDialog.vue b/frontend/src/components/ConfirmDialog.vue index db0ba97..b72ac5b 100644 --- a/frontend/src/components/ConfirmDialog.vue +++ b/frontend/src/components/ConfirmDialog.vue @@ -75,7 +75,7 @@ watch( .confirm-dialog__overlay { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.4); + background: var(--color-glass-overlay); display: flex; align-items: center; justify-content: center; @@ -84,9 +84,12 @@ watch( } .confirm-dialog { - background: var(--color-card); + background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%); + border: 1px solid var(--color-glass-border); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); border-radius: var(--radius-card); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); padding: var(--spacing-xl); max-width: 320px; width: 100%; @@ -98,13 +101,13 @@ watch( .confirm-dialog__title { font-size: 1.05rem; font-weight: 700; - color: var(--color-text); + color: var(--color-text-on-gradient); } .confirm-dialog__message { font-size: 0.9rem; font-weight: 400; - color: #666; + color: var(--color-text-soft); } .confirm-dialog__actions { @@ -130,8 +133,9 @@ watch( } .confirm-dialog__btn--cancel { - background: #e8e8e8; - color: #555; + background: var(--color-glass); + border: 1px solid var(--color-glass-border); + color: var(--color-text-on-gradient); } .confirm-dialog__btn--confirm { diff --git a/frontend/src/components/CreateEventFab.vue b/frontend/src/components/CreateEventFab.vue index a8736c5..03bbc91 100644 --- a/frontend/src/components/CreateEventFab.vue +++ b/frontend/src/components/CreateEventFab.vue @@ -1,6 +1,8 @@ @@ -16,20 +18,26 @@ import { RouterLink } from 'vue-router' width: 56px; height: 56px; border-radius: 50%; - background: var(--color-accent); - color: #fff; + color: var(--color-text-on-gradient); display: flex; align-items: center; justify-content: center; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); text-decoration: none; z-index: 100; - transition: transform 0.15s ease, box-shadow 0.15s ease; + transition: transform 0.15s ease; +} + +.fab__inner { + width: 100%; + height: 100%; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; } .fab:hover { transform: scale(1.08); - box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3); } .fab:active { @@ -41,6 +49,7 @@ import { RouterLink } from 'vue-router' outline-offset: 3px; } + .fab__icon { font-size: 1.8rem; font-weight: 300; diff --git a/frontend/src/components/DateSubheader.vue b/frontend/src/components/DateSubheader.vue index b7b2a23..2be49ce 100644 --- a/frontend/src/components/DateSubheader.vue +++ b/frontend/src/components/DateSubheader.vue @@ -12,7 +12,7 @@ defineProps<{ .date-subheader { font-size: 0.85rem; font-weight: 500; - color: rgba(255, 255, 255, 0.85); + color: var(--color-text-soft); margin: 0; padding: var(--spacing-xs) 0; } diff --git a/frontend/src/components/EmptyState.vue b/frontend/src/components/EmptyState.vue index 9938e4b..e870945 100644 --- a/frontend/src/components/EmptyState.vue +++ b/frontend/src/components/EmptyState.vue @@ -1,7 +1,9 @@ @@ -27,5 +29,34 @@ import { RouterLink } from 'vue-router' .empty-state__cta { max-width: 280px; + width: 100%; + border-radius: var(--radius-button); + text-decoration: none; + transition: transform 0.1s ease; +} + +.empty-state__cta-inner { + display: block; + width: 100%; + padding: var(--spacing-md) var(--spacing-lg); + border-radius: calc(var(--radius-button) - 2px); + font-family: inherit; + font-size: 1rem; + font-weight: 700; + color: var(--color-text-on-gradient); + text-align: center; +} + +.empty-state__cta:hover { + transform: scale(1.02); +} + +.empty-state__cta:active { + transform: scale(0.98); +} + +.empty-state__cta:focus-visible { + outline: 2px solid #fff; + outline-offset: 3px; } diff --git a/frontend/src/components/EventCard.vue b/frontend/src/components/EventCard.vue index e788e32..868720b 100644 --- a/frontend/src/components/EventCard.vue +++ b/frontend/src/components/EventCard.vue @@ -1,6 +1,6 @@ @@ -45,6 +47,30 @@ defineEmits<{ .rsvp-bar__cta { width: 100%; + border-radius: var(--radius-button); + transition: transform 0.1s ease; +} + +.rsvp-bar__cta:hover { + transform: scale(1.02); +} + +.rsvp-bar__cta:active { + transform: scale(0.98); +} + +.rsvp-bar__cta-inner { + display: block; + width: 100%; + padding: var(--spacing-md) var(--spacing-lg); + border-radius: calc(var(--radius-button) - 2px); + font-family: inherit; + font-size: 1rem; + font-weight: 700; + color: var(--color-text-on-gradient); + text-align: center; + border: none; + cursor: pointer; } .rsvp-bar__status { @@ -52,13 +78,16 @@ defineEmits<{ align-items: center; justify-content: center; gap: var(--spacing-xs); - background: var(--color-card); + background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%); + border: 1px solid var(--color-glass-border); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); border-radius: var(--radius-card); padding: var(--spacing-md) var(--spacing-lg); box-shadow: var(--shadow-card); font-weight: 600; font-size: 0.95rem; - color: var(--color-text); + color: var(--color-text-on-gradient); } .rsvp-bar__check { diff --git a/frontend/src/components/SectionHeader.vue b/frontend/src/components/SectionHeader.vue index a507b3c..035361c 100644 --- a/frontend/src/components/SectionHeader.vue +++ b/frontend/src/components/SectionHeader.vue @@ -15,7 +15,7 @@ defineProps<{ .section-header { font-size: 1rem; font-weight: 700; - color: var(--color-text); + color: var(--color-text-on-gradient); margin: 0; padding: var(--spacing-sm) 0; } diff --git a/frontend/src/components/__tests__/RsvpBar.spec.ts b/frontend/src/components/__tests__/RsvpBar.spec.ts index b9aa1d9..4296748 100644 --- a/frontend/src/components/__tests__/RsvpBar.spec.ts +++ b/frontend/src/components/__tests__/RsvpBar.spec.ts @@ -6,7 +6,7 @@ describe('RsvpBar', () => { it('renders CTA button when hasRsvp is false', () => { const wrapper = mount(RsvpBar) expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(true) - expect(wrapper.find('.rsvp-bar__cta').text()).toBe("I'm attending") + expect(wrapper.find('.rsvp-bar__cta-inner').text()).toBe("I'm attending") expect(wrapper.find('.rsvp-bar__status').exists()).toBe(false) }) @@ -19,7 +19,7 @@ describe('RsvpBar', () => { it('emits open when CTA button is clicked', async () => { const wrapper = mount(RsvpBar) - await wrapper.find('.rsvp-bar__cta').trigger('click') + await wrapper.find('.rsvp-bar__cta-inner').trigger('click') expect(wrapper.emitted('open')).toHaveLength(1) }) diff --git a/frontend/src/views/EventCreateView.vue b/frontend/src/views/EventCreateView.vue index 7b06c63..5d7363e 100644 --- a/frontend/src/views/EventCreateView.vue +++ b/frontend/src/views/EventCreateView.vue @@ -12,7 +12,7 @@ id="title" v-model="form.title" type="text" - class="form-field" + class="form-field glass" required maxlength="200" placeholder="What's the event?" @@ -27,7 +27,7 @@