9 Commits

Author SHA1 Message Date
51ab99fc61 Introduce --color-danger-solid-* CSS variables and replace hardcoded values
All checks were successful
CI / backend-test (push) Successful in 55s
CI / frontend-test (push) Successful in 27s
CI / frontend-e2e (push) Successful in 1m30s
CI / build-and-publish (push) Successful in 58s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:24:58 +01:00
d52f51d6e1 Match cancel-event confirm button color with ConfirmDialog style
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:22:06 +01:00
c1760ae376 Apply consistent label color to cancel-event bottom sheet
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:19:26 +01:00
6d51327e56 Add touch drag-to-dismiss gesture to BottomSheet
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:15:30 +01:00
96044ae1ed Change create-event page title to "Great, a Party!"
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:08:45 +01:00
f972a41e45 Extract BackLink component into App layout
Move back navigation (chevron + "fete" brand) from per-view
definitions into a shared BackLink component rendered in App.vue.
Shown on all pages except home. Hero overlay gets pointer-events:
none so the link stays clickable on the event detail page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:06:03 +01:00
13b01dfba8 Add exclamation mark to RSVP CTA button ("I'm attending!")
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:36:44 +01:00
fd8724db8f Rename role badges to present participle (Organizing, Attending)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:34:36 +01:00
8885dbd722 Soften RSVP cancellation dialog wording
Replace harsh "permanently cancelled" language with friendlier
"The organizer will no longer see you as attending" and rename
buttons from "Cancel attendance" to "Cancel RSVP".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:32:19 +01:00
16 changed files with 149 additions and 95 deletions

View File

@@ -43,7 +43,7 @@ test.describe('US1: Cancel RSVP from Event Detail View', () => {
await expect(statusBar).toBeVisible()
// Cancel button hidden initially
await expect(page.getByRole('button', { name: 'Cancel attendance' })).not.toBeVisible()
await expect(page.getByRole('button', { name: 'Cancel RSVP' })).not.toBeVisible()
})
test('tapping status bar reveals cancel button', async ({ page, network }) => {
@@ -57,7 +57,7 @@ test.describe('US1: Cancel RSVP from Event Detail View', () => {
await page.getByRole('button', { name: /You're attending/ }).click()
// Cancel button appears
await expect(page.getByRole('button', { name: 'Cancel attendance' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Cancel RSVP' })).toBeVisible()
})
test('confirm cancellation → localStorage cleared, count decremented, bar reset', async ({ page, network }) => {
@@ -70,13 +70,13 @@ test.describe('US1: Cancel RSVP from Event Detail View', () => {
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
// Expand → Cancel attendance → Confirm in dialog
// Expand → Cancel RSVP → Confirm in dialog
await page.getByRole('button', { name: /You're attending/ }).click()
await page.locator('.rsvp-bar__cancel').click()
// Confirm dialog
await expect(page.getByText('Your attendance will be permanently cancelled.')).toBeVisible()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel attendance' }).click()
await expect(page.getByText('The organizer will no longer see you as attending.')).toBeVisible()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel RSVP' }).click()
// Bar resets to CTA state
await expect(page.getByRole('button', { name: "I'm attending" })).toBeVisible()
@@ -108,7 +108,7 @@ test.describe('US1: Cancel RSVP from Event Detail View', () => {
// Expand → Cancel → Confirm in dialog
await page.getByRole('button', { name: /You're attending/ }).click()
await page.locator('.rsvp-bar__cancel').click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel attendance' }).click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel RSVP' }).click()
// Error message
await expect(page.getByText('Could not cancel RSVP. Please try again.')).toBeVisible()
@@ -136,7 +136,7 @@ test.describe('US1: Cancel RSVP from Event Detail View', () => {
// Cancel first
await page.getByRole('button', { name: /You're attending/ }).click()
await page.locator('.rsvp-bar__cancel').click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel attendance' }).click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel RSVP' }).click()
// CTA should be back
await expect(page.getByRole('button', { name: "I'm attending" })).toBeVisible()
@@ -244,7 +244,7 @@ test.describe('US3: Cancel RSVP with Stale/Invalid Token', () => {
// Cancel flow
await page.getByRole('button', { name: /You're attending/ }).click()
await page.locator('.rsvp-bar__cancel').click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel attendance' }).click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel RSVP' }).click()
// Treated as success — CTA returns
await expect(page.getByRole('button', { name: "I'm attending" })).toBeVisible()

View File

@@ -139,7 +139,7 @@ test.describe('US5: Visual Distinction for Event Roles', () => {
const card = page.locator('.event-card').filter({ hasText: 'Summer BBQ' })
const badge = card.locator('.event-card__badge')
await expect(badge).toBeVisible()
await expect(badge).toHaveText('Organizer')
await expect(badge).toHaveText('Organizing')
await expect(badge).toHaveClass(/event-card__badge--organizer/)
})
@@ -150,7 +150,7 @@ test.describe('US5: Visual Distinction for Event Roles', () => {
const card = page.locator('.event-card').filter({ hasText: 'Team Meeting' })
const badge = card.locator('.event-card__badge')
await expect(badge).toBeVisible()
await expect(badge).toHaveText('Attendee')
await expect(badge).toHaveText('Attending')
await expect(badge).toHaveClass(/event-card__badge--attendee/)
})

View File

@@ -65,7 +65,7 @@ test.describe('US1: Watch event from detail page', () => {
await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event')
// Navigate to event list via back link
await page.locator('.detail__back').click()
await page.getByLabel('Back to home').click()
// Event appears with "Watching" label
await expect(page.getByText('Summer BBQ')).toBeVisible()
@@ -89,7 +89,7 @@ test.describe('US2: Un-watch event from detail page', () => {
await expect(bookmark).toHaveAttribute('aria-label', 'Watch this event')
// Navigate to event list via back link (avoid page.goto re-running addInitScript)
await page.locator('.detail__back').click()
await page.getByLabel('Back to home').click()
// Event is gone
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
@@ -109,8 +109,8 @@ test.describe('US3: Bookmark reflects attending status', () => {
await expect(bookmark).not.toBeVisible()
// Navigate to list via back link
await page.locator('.detail__back').click()
await expect(page.getByText('Attendee')).toBeVisible()
await page.getByLabel('Back to home').click()
await expect(page.getByText('Attending')).toBeVisible()
await expect(page.getByText('Watching')).not.toBeVisible()
})
})
@@ -129,7 +129,7 @@ test.describe('US4: RSVP cancellation preserves watch status', () => {
// Cancel RSVP
await page.getByRole('button', { name: /You're attending/ }).click()
await page.locator('.rsvp-bar__cancel').click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel attendance' }).click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel RSVP' }).click()
// Bookmark reappears in CTA state, filled because event is still stored
const bookmark = page.locator('.rsvp-bar__bookmark-inner')
@@ -137,9 +137,9 @@ test.describe('US4: RSVP cancellation preserves watch status', () => {
await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event')
// Navigate to list via back link
await page.locator('.detail__back').click()
await page.getByLabel('Back to home').click()
await expect(page.getByText('Watching')).toBeVisible()
await expect(page.getByText('Attendee')).not.toBeVisible()
await expect(page.getByText('Attending')).not.toBeVisible()
})
})
@@ -211,8 +211,8 @@ test.describe('US7: Watcher upgrades to attendee', () => {
await expect(bookmark).not.toBeVisible()
// Navigate to list via back link
await page.locator('.detail__back').click()
await expect(page.getByText('Attendee')).toBeVisible()
await page.getByLabel('Back to home').click()
await expect(page.getByText('Attending')).toBeVisible()
await expect(page.getByText('Watching')).not.toBeVisible()
})
})

View File

@@ -1,9 +1,26 @@
<template>
<div class="app-container">
<header v-if="route.name !== 'home'" class="app-header">
<BackLink />
</header>
<RouterView />
</div>
</template>
<script setup lang="ts">
import { RouterView } from 'vue-router'
import { RouterView, useRoute } from 'vue-router'
import BackLink from '@/components/BackLink.vue'
const route = useRoute()
</script>
<style scoped>
.app-header {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
padding-top: var(--spacing-lg);
}
</style>

View File

@@ -25,6 +25,9 @@
--color-danger-bg-strong: rgba(220, 38, 38, 0.2);
--color-danger-border: rgba(220, 38, 38, 0.3);
--color-danger-border-strong: rgba(220, 38, 38, 0.4);
--color-danger-solid: #d32f2f;
--color-danger-solid-hover: #b71c1c;
--color-danger-solid-text: #fff;
/* Glass system */
--color-glass: rgba(255, 255, 255, 0.1);
@@ -214,7 +217,7 @@ textarea.form-field {
/* Error message */
.field-error {
color: #fff;
color: var(--color-danger-solid);
font-size: 0.875rem;
font-weight: 600;
padding-left: 0.25rem;
@@ -325,7 +328,8 @@ textarea.form-field {
gap: var(--spacing-md);
}
.rsvp-form__label {
.rsvp-form__label,
.cancel-form__label {
font-size: 0.85rem;
font-weight: 700;
color: var(--color-text-on-gradient);
@@ -333,7 +337,7 @@ textarea.form-field {
}
.rsvp-form__field-error {
color: #d32f2f;
color: var(--color-danger-solid);
font-size: 0.875rem;
font-weight: 600;
padding-left: 0.25rem;

View File

@@ -0,0 +1,28 @@
<template>
<RouterLink to="/" class="back-link" aria-label="Back to home">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
<span class="back-link__brand">fete</span>
</RouterLink>
</template>
<script setup lang="ts">
import { RouterLink } from 'vue-router'
</script>
<style scoped>
.back-link {
display: inline-flex;
align-items: center;
gap: 0.15rem;
color: var(--color-text-on-gradient);
text-decoration: none;
line-height: 1;
}
.back-link__brand {
font-size: 1.3rem;
font-weight: 700;
}
</style>

View File

@@ -2,7 +2,18 @@
<Teleport to="body">
<Transition name="sheet">
<div v-if="open" class="sheet-backdrop" @click.self="$emit('close')" @keydown.escape="$emit('close')">
<div class="sheet" role="dialog" aria-modal="true" :aria-label="label" ref="sheetEl" tabindex="-1">
<div
class="sheet"
role="dialog"
aria-modal="true"
:aria-label="label"
ref="sheetEl"
tabindex="-1"
:style="dragStyle"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
>
<div class="sheet__handle" aria-hidden="true" />
<slot />
</div>
@@ -12,14 +23,14 @@
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { ref, computed, watch, nextTick } from 'vue'
defineProps<{
open: boolean
label: string
}>()
defineEmits<{
const emit = defineEmits<{
close: []
}>()
@@ -39,6 +50,45 @@ watch(
}
},
)
/* ── Drag-to-dismiss ── */
const DISMISS_THRESHOLD = 100
const dragY = ref(0)
const dragging = ref(false)
let startY = 0
const dragStyle = computed(() => {
if (!dragging.value || dragY.value <= 0) return undefined
return {
transform: `translateY(${dragY.value}px)`,
transition: 'none',
}
})
function onTouchStart(e: TouchEvent) {
const touch = e.touches[0]
if (!touch) return
startY = touch.clientY
dragging.value = true
dragY.value = 0
}
function onTouchMove(e: TouchEvent) {
if (!dragging.value) return
const touch = e.touches[0]
if (!touch) return
const delta = touch.clientY - startY
if (delta > 0) e.preventDefault()
dragY.value = Math.max(0, delta)
}
function onTouchEnd() {
if (dragY.value >= DISMISS_THRESHOLD) {
emit('close')
}
dragging.value = false
dragY.value = 0
}
</script>
<style scoped>

View File

@@ -139,8 +139,8 @@ watch(
}
.confirm-dialog__btn--confirm {
background: #d32f2f;
color: #fff;
background: var(--color-danger-solid);
color: var(--color-danger-solid-text);
}
.confirm-dialog-enter-active,

View File

@@ -12,7 +12,7 @@
<span class="event-card__time">{{ displayTime }}</span>
</RouterLink>
<span v-if="eventRole" class="event-card__badge" :class="`event-card__badge--${eventRole}`">
{{ eventRole === 'organizer' ? 'Organizer' : eventRole === 'attendee' ? 'Attendee' : 'Watching' }}
{{ eventRole === 'organizer' ? 'Organizing' : eventRole === 'attendee' ? 'Attending' : 'Watching' }}
</span>
<button
class="event-card__delete"
@@ -175,7 +175,7 @@ function onTouchEnd() {
}
.event-card__delete:hover {
color: #d32f2f;
color: var(--color-danger-solid);
background: rgba(211, 47, 47, 0.08);
}

View File

@@ -25,7 +25,7 @@
type="button"
@click="$emit('cancel')"
>
Cancel attendance
Cancel RSVP
</button>
</Transition>
</div>
@@ -34,7 +34,7 @@
<div v-else class="rsvp-bar__row">
<div class="rsvp-bar__cta glow-border glow-border--animated">
<button class="rsvp-bar__cta-inner glass-inner" type="button" @click="$emit('open')">
I'm attending
I'm attending!
</button>
</div>
<div class="rsvp-bar__bookmark glow-border glow-border--animated">

View File

@@ -55,12 +55,12 @@ describe('EventCard', () => {
it('renders organizer badge when eventRole is organizer', () => {
const wrapper = mountCard({ eventRole: 'organizer' })
expect(wrapper.text()).toContain('Organizer')
expect(wrapper.text()).toContain('Organizing')
})
it('renders attendee badge when eventRole is attendee', () => {
const wrapper = mountCard({ eventRole: 'attendee' })
expect(wrapper.text()).toContain('Attendee')
expect(wrapper.text()).toContain('Attending')
})
it('renders watcher badge when eventRole is watcher', () => {

View File

@@ -160,13 +160,13 @@ describe('EventList', () => {
const wrapper = mountList()
const badge = wrapper.find('.event-card__badge--organizer')
expect(badge.exists()).toBe(true)
expect(badge.text()).toBe('Organizer')
expect(badge.text()).toBe('Organizing')
})
it('assigns attendee role when event has rsvpToken', () => {
const wrapper = mountList()
const badge = wrapper.find('.event-card__badge--attendee')
expect(badge.exists()).toBe(true)
expect(badge.text()).toBe('Attendee')
expect(badge.text()).toBe('Attending')
})
})

View File

@@ -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-inner').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)
})

View File

@@ -1,9 +1,6 @@
<template>
<main class="create">
<header class="create__header">
<RouterLink to="/" class="create__back" aria-label="Back to home">&larr;</RouterLink>
<h1 class="create__title">Create</h1>
</header>
<h1 class="create__title">Great, a Party!</h1>
<form class="create__form" novalidate @submit.prevent="handleSubmit">
<div class="form-group">
@@ -76,7 +73,7 @@
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { useRouter } from 'vue-router'
import { api } from '@/api/client'
import { useEventStorage } from '@/composables/useEventStorage'
@@ -194,20 +191,7 @@ async function handleSubmit() {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
padding-top: var(--spacing-lg);
}
.create__header {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.create__back {
color: var(--color-text-on-gradient);
font-size: 1.5rem;
text-decoration: none;
line-height: 1;
padding-top: calc(var(--spacing-lg) + 2.5rem);
}
.create__title {

View File

@@ -8,10 +8,6 @@
alt=""
/>
<div class="detail__hero-overlay" />
<header class="detail__header">
<RouterLink to="/" class="detail__back" aria-label="Back to home">&larr;</RouterLink>
<span class="detail__brand">fete</span>
</header>
</div>
<div class="detail__body">
@@ -129,9 +125,9 @@
<!-- Cancel confirmation dialog -->
<ConfirmDialog
:open="confirmCancelOpen"
title="Cancel attendance?"
message="Your attendance will be permanently cancelled."
confirm-label="Cancel attendance"
title="Cancel RSVP?"
message="The organizer will no longer see you as attending."
confirm-label="Cancel RSVP"
cancel-label="Keep"
@confirm="handleCancelRsvp"
@cancel="confirmCancelOpen = false"
@@ -168,7 +164,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { useRoute } from 'vue-router'
import { api } from '@/api/client'
import { useEventStorage } from '@/composables/useEventStorage'
import AttendeeList from '@/components/AttendeeList.vue'
@@ -437,32 +433,7 @@ onMounted(fetchEvent)
var(--color-glass-overlay) 0%,
transparent 50%
);
}
.detail__header {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-lg) var(--content-padding);
padding-top: env(safe-area-inset-top, var(--spacing-lg));
z-index: 1;
}
.detail__back {
color: var(--color-text-on-gradient);
font-size: 1.5rem;
text-decoration: none;
line-height: 1;
}
.detail__brand {
font-size: 1.3rem;
font-weight: 700;
color: var(--color-text-on-gradient);
pointer-events: none;
}
.detail__body {
@@ -706,15 +677,15 @@ onMounted(fetchEvent)
font-family: inherit;
font-size: 1rem;
font-weight: 700;
color: var(--color-danger);
background: var(--color-danger-bg-strong);
border: 1px solid var(--color-danger-border);
color: var(--color-danger-solid-text);
background: var(--color-danger-solid);
border: none;
cursor: pointer;
transition: background 0.15s ease;
}
.cancel-form__confirm:hover {
background: var(--color-danger-bg-hover);
background: var(--color-danger-solid-hover);
}
.cancel-form__confirm:disabled {

View File

@@ -198,7 +198,7 @@ describe('EventDetailView', () => {
await flushPromises()
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').text()).toBe("I'm attending!")
wrapper.unmount()
})