diff --git a/client/src/pages/DashboardPage.test.tsx b/client/src/pages/DashboardPage.test.tsx index 88f93cc49..cb838d62a 100644 --- a/client/src/pages/DashboardPage.test.tsx +++ b/client/src/pages/DashboardPage.test.tsx @@ -1000,4 +1000,59 @@ describe('DashboardPage', () => { expect(within(grid()).queryByText('Paris Adventure')).not.toBeInTheDocument(); }); }); + + // Two people reported in a row that they could not find how to unarchive or + // rename a trip. The controls sat at opacity 0 until the card was hovered, and + // the archive button carried the same icon whether it would archive or restore. + describe('FE-PAGE-DASH-036: the cover controls are findable', () => { + const activeOnly = (trips: unknown[]) => + server.use( + http.get('/api/trips', ({ request }) => { + const url = new URL(request.url); + if (url.searchParams.get('archived')) return HttpResponse.json({ trips: [] }); + return HttpResponse.json({ trips }); + }), + ); + + it('names every action on the card, so none of them is icon-only guesswork', async () => { + activeOnly([buildTrip({ title: 'Lisbon 2025', start_date: '2025-05-01', end_date: '2025-05-08' })]); + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(screen.getAllByText('Lisbon 2025').length).toBeGreaterThan(0)); + await user.click(screen.getByText('Completed')); + const card = (await screen.findAllByText('Lisbon 2025')) + .map(n => n.closest('.trip-card')) + .find(Boolean) as HTMLElement; + for (const label of ['Edit', 'Duplicate', 'Archive', 'Delete']) { + const button = card.querySelector(`[aria-label="${label}"]`) as HTMLElement; + expect(button).toBeTruthy(); + // A tooltip as well as the screen-reader label — the icons alone did not read. + expect(button.getAttribute('title')).toBe(label); + } + }); + + it('swaps the archive icon for a restore one once the trip is archived', async () => { + const archived = buildTrip({ title: 'Old Rome Trip', start_date: '2024-01-01', end_date: '2024-01-07', is_archived: 1 }); + server.use( + http.get('/api/trips', ({ request }) => { + const url = new URL(request.url); + return HttpResponse.json({ trips: url.searchParams.get('archived') ? [archived] : [] }); + }), + ); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Archived')); + await waitFor(() => expect(screen.getByText('Old Rome Trip')).toBeInTheDocument()); + + const card = screen.getByText('Old Rome Trip').closest('.trip-card') as HTMLElement; + const restore = card.querySelector('[aria-label="Restore"]') as HTMLElement; + expect(restore).toBeTruthy(); + // lucide stamps the icon name on the svg, which is how the archive and the + // restore variant tell themselves apart. + expect(restore.querySelector('svg')?.getAttribute('class')).toContain('archive-restore'); + expect(card.querySelector('[aria-label="Archive"]')).toBeNull(); + }); + }); }); diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index f1613eb9c..dad6a303c 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -15,7 +15,7 @@ import { MS_PER_DAY, daysUntil, getTripStatus, } from './dashboard/dashboardModel' import { - Plus, Edit2, Trash2, Archive, Copy, ArrowRight, MapPin, + Plus, Edit2, Trash2, Archive, ArchiveRestore, Copy, ArrowRight, MapPin, Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar, LayoutGrid, List, Ticket, X, CalendarPlus, ParkingSquare, } from 'lucide-react' @@ -304,6 +304,7 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch const heroPlugins = usePluginStore(s => s.plugins).filter(p => p.type === 'widget' && p.slot === 'hero') const stop = (e: React.MouseEvent, fn: () => void) => { e.stopPropagation(); fn() } const status = getTripStatus(trip) + const archiveLabel = trip.is_archived ? t('dashboard.restore') : t('dashboard.archive') const start = splitDate(trip.start_date, locale) const end = splitDate(trip.end_date, locale) @@ -404,10 +405,10 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch {badge}
- - - - + + + +
@@ -535,6 +536,7 @@ function TripCard({ trip, locale, badges, onOpen, onEdit, onCopy, onArchive, onD }): React.ReactElement { const { t } = useTranslation() const status = getTripStatus(trip) + const archiveLabel = trip.is_archived ? t('dashboard.restore') : t('dashboard.archive') const start = splitDate(trip.start_date, locale) const end = splitDate(trip.end_date, locale) const until = daysUntil(trip.start_date) @@ -557,10 +559,10 @@ function TripCard({ trip, locale, badges, onOpen, onEdit, onCopy, onArchive, onD :
}
{statusLabel}
- - - - + + + +

{trip.title}

diff --git a/client/src/styles/dashboard.css b/client/src/styles/dashboard.css index 9e509b44a..1e0dcea34 100644 --- a/client/src/styles/dashboard.css +++ b/client/src/styles/dashboard.css @@ -191,11 +191,12 @@ .trek-dash .hero-top { display: flex; justify-content: space-between; align-items: start; } .trek-dash .hero-badge { display: inline-flex; align-items: center; gap: 8px; - background: oklch(1 0 0 / .14); + background: oklch(0.22 0.01 260 / .5); backdrop-filter: blur(14px) saturate(1.4); -webkit-backdrop-filter: blur(14px) saturate(1.4); - border: 1px solid oklch(1 0 0 / .2); padding: 8px 14px 8px 12px; + border: 1px solid oklch(1 0 0 / .32); padding: 8px 14px 8px 12px; border-radius: 999px; font-size: 12px; font-weight: 500; letter-spacing: 0.06em; text-transform: uppercase; + box-shadow: 0 2px 8px oklch(0 0 0 / .26); } .trek-dash .hero-badge .pulse { width: 7px; height: 7px; border-radius: 50%; @@ -208,15 +209,18 @@ 100% { box-shadow: 0 0 0 0 oklch(0.84 0.18 85 / 0); } } .trek-dash .hero-tools { display: flex; gap: 8px; } +/* Same story as .trip-action-btn: a dark scrim instead of a white .14 fill, so + the tools read against a bright hero photo too. */ .trek-dash .hero-tool { - width: 38px; height: 38px; border-radius: 50%; - background: oklch(1 0 0 / .14); + width: 40px; height: 40px; border-radius: 50%; + background: oklch(0.22 0.01 260 / .5); backdrop-filter: blur(14px) saturate(1.4); -webkit-backdrop-filter: blur(14px) saturate(1.4); - border: 1px solid oklch(1 0 0 / .2); color: #fff; + border: 1px solid oklch(1 0 0 / .32); color: #fff; + box-shadow: 0 2px 8px oklch(0 0 0 / .26); display: grid; place-items: center; transition: background .15s, transform .12s; } -.trek-dash .hero-tool:hover { background: oklch(1 0 0 / .26); transform: scale(1.05); } -.trek-dash .hero-tool svg { width: 16px; height: 16px; } +.trek-dash .hero-tool:hover { background: oklch(0.22 0.01 260 / .78); transform: scale(1.05); } +.trek-dash .hero-tool svg { width: 17px; height: 17px; } .trek-dash .hero-title-block { align-self: end; padding-bottom: 24px; } .trek-dash .hero-eyebrow { display: inline-flex; align-items: center; gap: 10px; @@ -435,26 +439,36 @@ .trek-dash .trip-cover img { width: 100%; height: 100%; object-fit: cover; transition: transform .6s cubic-bezier(.2,.7,.2,1); } .trek-dash .trip-card:hover .trip-cover img { transform: scale(1.04); } .trek-dash .trip-cover::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, oklch(0 0 0 / 0) 40%, oklch(0 0 0 / .55) 100%); } +/* Shares the dark scrim of .trip-action-btn so both overlays on the cover read + as one set, and the label survives a bright photo behind it. */ .trek-dash .trip-status { position: absolute; top: 16px; left: 16px; z-index: 1; display: inline-flex; align-items: center; gap: 7px; padding: 6px 11px 6px 9px; border-radius: 999px; font-size: 11.5px; font-weight: 500; letter-spacing: 0.02em; - background: oklch(1 0 0 / .18); backdrop-filter: blur(14px) saturate(1.4); -webkit-backdrop-filter: blur(14px) saturate(1.4); - border: 1px solid oklch(1 0 0 / .22); color: #fff; text-transform: uppercase; + background: oklch(0.22 0.01 260 / .58); backdrop-filter: blur(14px) saturate(1.4); -webkit-backdrop-filter: blur(14px) saturate(1.4); + border: 1px solid oklch(1 0 0 / .34); color: #fff; text-transform: uppercase; + box-shadow: 0 2px 8px oklch(0 0 0 / .28); } .trek-dash .trip-status .indicator { width: 6px; height: 6px; border-radius: 50%; background: oklch(0.84 0.16 145); } .trek-dash .trip-status.completed .indicator { background: oklch(0.75 0.02 220); } .trek-dash .trip-status.upcoming .indicator { background: oklch(0.84 0.18 85); } .trek-dash .trip-status.idea .indicator { background: oklch(0.78 0.14 280); } -.trek-dash .trip-actions { position: absolute; top: 14px; right: 14px; z-index: 1; display: flex; gap: 6px; opacity: 0; transition: opacity .2s; } +/* The actions used to sit at opacity 0 until the card was hovered, over a white + .18 glass fill. Two people in a row reported they never found the archive and + rename buttons — invisible at rest, and on a light cover the white-on-white + fill hid them even while hovering. They stay on the card now, and the fill is + a dark scrim so the white icons carry against any photo. */ +.trek-dash .trip-actions { position: absolute; top: 14px; right: 14px; z-index: 1; display: flex; gap: 6px; opacity: .92; transition: opacity .2s; } .trek-dash .trip-card:hover .trip-actions { opacity: 1; } .trek-dash .trip-action-btn { - width: 36px; height: 36px; border-radius: 50%; - background: oklch(1 0 0 / .18); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); - border: 1px solid oklch(1 0 0 / .22); color: #fff; display: grid; place-items: center; transition: background .15s; + width: 38px; height: 38px; border-radius: 50%; + background: oklch(0.22 0.01 260 / .58); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); + border: 1px solid oklch(1 0 0 / .34); color: #fff; display: grid; place-items: center; + box-shadow: 0 2px 8px oklch(0 0 0 / .28); + transition: background .15s, transform .12s; } -.trek-dash .trip-action-btn:hover { background: oklch(1 0 0 / .3); } -.trek-dash .trip-action-btn svg { width: 16px; height: 16px; } +.trek-dash .trip-action-btn:hover { background: oklch(0.22 0.01 260 / .82); transform: scale(1.06); } +.trek-dash .trip-action-btn svg { width: 17px; height: 17px; } .trek-dash .trip-cover-content { position: absolute; left: 18px; right: 18px; bottom: 16px; z-index: 1; color: #fff; } .trek-dash .trip-name { font-size: 26px; font-weight: 600; letter-spacing: -0.025em; line-height: 1.05; margin: 0; text-shadow: 0 1px 7px oklch(0 0 0 / .3), 0 1px 2px oklch(0 0 0 / .38); } .trek-dash .trip-where { margin-top: 4px; font-size: 13px; opacity: .85; display: flex; align-items: center; gap: 6px; } @@ -686,8 +700,8 @@ .trek-dash .trips { grid-template-columns: 1fr; gap: 16px; margin-bottom: 28px; } .trek-dash .add-trip-card { min-height: 180px; } - /* Touch devices have no hover — keep the edit/copy/archive/delete actions - visible at all times instead of revealing them on hover. */ + /* Touch devices have no hover state to lift the actions to full strength, so + skip the resting dim entirely. */ .trek-dash .trip-actions { opacity: 1; } /* Compact list row on mobile — keeps the list view distinct from the grid. The