Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions apps/web/src/components/date-ticket.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { formatDay, formatMonth } from "@/lib/format";
import { colourwayOf } from "@/lib/poster";
import { cn } from "cn";

/**
* The date of an event drawn as a ticket: the day on one piece and the month on a short stub, with a half-circle notch at each end
* of the tear and a dashed line along it (the ticket `.ticket-v` draws). It sits on a cover, where the date is also written out
* beside it, so it is hidden from assistive technology. When the card it sits on (an ancestor `.group`) is hovered or focused the
* stub tears off along the dashed line and falls away, and it comes back when the pointer leaves: see `.date-ticket` in effects.css.
* `card`: the small one on a photograph. `poster`: sized in the poster's own units (cqmin), so it scales with the cover.
*/
export function DateTicket({ value, slug = "general", size = "card", className }: { value: string; slug?: string; size?: "card" | "poster"; className?: string }) {
const { ground, ink } = colourwayOf(slug);
// the ticket is the category's ink and its words are the category's ground, so it stands out on a poster of that ground and on a photograph alike
return (
<span
aria-hidden
data-slot="date-ticket"
data-size={size}
className={cn("date-ticket ticket-v", className)}
style={{ "--ticket-fill": ink, "--ticket-ink": ground } as React.CSSProperties}
>
<span className="date-ticket-day">{formatDay(value)}</span>
<span className="date-ticket-stub">{formatMonth(value)}</span>
</span>
);
}
13 changes: 7 additions & 6 deletions apps/web/src/components/event-card.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Link } from "@tanstack/react-router";
import { CalendarDays, MapPin } from "lucide-react";
import { DateBlock, Kind, LiveMark } from "@/components/event-parts";
import { DateTicket } from "@/components/date-ticket";
import { Kind, LiveMark } from "@/components/event-parts";
import { EventPoster } from "@/components/event-poster";
import { useT } from "@/i18n";
import { isLive, type EventSummary } from "@/lib/events";
Expand All @@ -9,17 +10,17 @@ import { formatDateRange } from "@/lib/format";
/**
* An event in a list: its cover over its words. A photograph gets the date as a block in the category's colourway over
* its corner; an event without one gets a poster, whose picture is the date, so it gets no block. Everything under the cover
* is the same for both. Cards rise into place as the page is scrolled to them (`rise-in`), and the pointer over a cover says "view".
* is the same for both. Cards rise into place as the page is scrolled to them (`rise-in`), the pointer over a cover says "view", and the card lifts a little (`card-lift`).
*/
export function EventCard({ event }: { event: EventSummary }) {
const t = useT();
return (
<Link
to="/events/$slug"
params={{ slug: event.slug }}
className="rise-in group flex flex-col gap-4 rounded-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/60 focus-visible:ring-offset-2 focus-visible:ring-offset-background"
className="rise-in card-lift group flex flex-col gap-4 rounded-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/60 focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<div data-cursor={t("common.cursor.view")} className="relative aspect-[16/10] w-full shrink-0 overflow-hidden rounded-sm bg-muted">
<div data-cursor={t("common.cursor.view")} className="card-lift-cover relative aspect-[16/10] w-full shrink-0 overflow-hidden rounded-sm bg-muted">
{event.thumbnailUrl ? (
<>
<img
Expand All @@ -28,10 +29,10 @@ export function EventCard({ event }: { event: EventSummary }) {
loading="lazy"
className="absolute inset-0 size-full object-cover transition-transform duration-700 ease-out-expo group-hover:scale-[1.03]"
/>
<DateBlock value={event.startsAt} slug={event.category.slug} compact className="absolute top-3 left-3 h-[4.5rem] w-16 shadow-md" />
<DateTicket value={event.startsAt} slug={event.category.slug} className="absolute top-3 left-3 z-10" />
</>
) : (
<EventPoster event={event} bare className="absolute inset-0 transition-transform duration-700 ease-out-expo group-hover:scale-[1.03]" />
<EventPoster event={event} bare ticket className="absolute inset-0 transition-transform duration-700 ease-out-expo group-hover:scale-[1.03]" />
)}
{isLive(event) && (
<div className="absolute right-3 bottom-3 z-10">
Expand Down
21 changes: 17 additions & 4 deletions apps/web/src/components/event-poster.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DateTicket } from "@/components/date-ticket";
import { useLocale } from "@/i18n";

import { categoryName } from "@/lib/category-name";
import { formatDay, formatMonth } from "@/lib/format";
import { colourwayOf, layoutOf } from "@/lib/poster";
Expand All @@ -23,14 +23,27 @@ export function Tide({ color, className }: { color: string; className?: string }
* the layout is the event's, so a list of them reads as one family. The words are drawn for looks only; the date
* and category are always written out beside it, which is why it is hidden from assistive technology.
* `layout` fixes the arrangement (the billboard needs the numeral at the top right); `bare` leaves out the category and
* month so the caller can set its own words on the poster.
* month so the caller can set its own words on the poster. `ticket` sets the date on a ticket (DateTicket) in place of the big numeral;
* the ticket tears when the card around the poster is hovered.
*/
export function EventPoster({ event, className, layout: fixed, bare, children }: { event: PosterEvent; className?: string; layout?: 0 | 1 | 2; bare?: boolean; children?: React.ReactNode }) {
export function EventPoster({ event, className, layout: fixed, bare, ticket, children }: { event: PosterEvent; className?: string; layout?: 0 | 1 | 2; bare?: boolean; ticket?: boolean; children?: React.ReactNode }) {
const locale = useLocale();
const colourway = colourwayOf(event.category.slug);
const layout = fixed ?? layoutOf(event.id);
const text = colourway.tone === "dark" ? "text-dusk-foreground" : "text-sun-foreground";
const day = (
const day = ticket ? (
<DateTicket
value={event.startsAt}
slug={event.category.slug}
size="poster"
className={cn(
"absolute z-10",
layout === 0 && "bottom-[8cqmin] left-[8cqmin]",
layout === 1 && "top-[8cqmin] right-[8cqmin]",
layout === 2 && "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2",
)}
/>
) : (
<span
aria-hidden
className={cn(
Expand Down
12 changes: 7 additions & 5 deletions apps/web/src/components/home-events.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { ArrowRight, ArrowUpRight, CalendarDays, MapPin } from "lucide-react";
import { DateBlock, HourRuler, Kind, LiveMark } from "@/components/event-parts";
import { CategoryChip, EventPoster } from "@/components/event-poster";
import { buttonVariants } from "@/components/ui/button";
import { useT } from "@/i18n";
import { useLocale, useT } from "@/i18n";
import { isLive, type Category, type EventSummary } from "@/lib/events";
import { categoryName } from "@/lib/category-name";
import { formatDateRange, formatTime } from "@/lib/format";
import { colourwayOf } from "@/lib/poster";
import { cn } from "cn";
Expand All @@ -31,12 +32,12 @@ export function Billboard({ event, level = 3 }: { event: EventSummary; level?: 2
</Heading>
);
return (
<Link to="/events/$slug" params={{ slug: event.slug }} className={cn("group grid gap-6 rounded-sm lg:grid-cols-[7fr_5fr] lg:gap-12", focus)}>
<div data-cursor={t("common.cursor.view")} className="relative aspect-[5/4] overflow-hidden rounded-sm bg-muted sm:aspect-[4/3] lg:aspect-[16/9]">
<Link to="/events/$slug" params={{ slug: event.slug }} className={cn("card-lift group grid gap-6 rounded-sm lg:grid-cols-[7fr_5fr] lg:gap-12", focus)}>
<div data-cursor={t("common.cursor.view")} className="card-lift-cover relative aspect-[5/4] overflow-hidden rounded-sm bg-muted sm:aspect-[4/3] lg:aspect-[16/9]">
{event.thumbnailUrl ? (
<img src={event.thumbnailUrl} alt="" loading="lazy" className="size-full object-cover transition-transform duration-700 ease-out-expo group-hover:scale-[1.03]" />
) : (
<EventPoster event={event} layout={1} bare className="size-full">
<EventPoster event={event} layout={1} bare ticket className="size-full">
<div className="absolute inset-x-0 bottom-0 z-10 p-5 pr-[34%] sm:p-7 sm:pr-[34%]">{title}</div>
</EventPoster>
)}
Expand Down Expand Up @@ -125,6 +126,7 @@ export function Ledger({ events }: { events: EventSummary[] }) {
* colour on hover and focus, the colour the category's posters wear everywhere else.
*/
export function KindIndex({ categories }: { categories: Category[] }) {
const locale = useLocale();
return (
<ul className="grid gap-x-12 lg:grid-cols-2">
{categories.map((c) => {
Expand All @@ -142,7 +144,7 @@ export function KindIndex({ categories }: { categories: Category[] }) {
)}
>
<CategoryChip slug={c.slug} className="h-10 w-14 sm:h-12 sm:w-[4.5rem]" />
<span className="line-clamp-2 min-w-0 flex-1 text-4xl leading-[1.2] font-bold tracking-tight sm:text-5xl sm:leading-[1.2] lg:text-6xl lg:leading-[1.2]">{c.name}</span>
<span className="line-clamp-2 min-w-0 flex-1 text-4xl leading-[1.2] font-bold tracking-tight sm:text-5xl sm:leading-[1.2] lg:text-6xl lg:leading-[1.2]">{categoryName(c, locale)}</span>
<ArrowUpRight className="size-7 shrink-0 transition-transform duration-300 ease-out-expo group-hover:translate-x-1 group-hover:-translate-y-1 group-focus-visible:translate-x-1 group-focus-visible:-translate-y-1 sm:size-9" />
</Link>
</li>
Expand Down
119 changes: 119 additions & 0 deletions apps/web/src/effects.css
Original file line number Diff line number Diff line change
Expand Up @@ -377,3 +377,122 @@ html:not(.dark) .on-sand {
.cursor-ring[data-labelled="true"] {
opacity: 0;
}

/*
* The date on a cover, as a ticket (DateTicket): two pieces in the category's ink with its words on them. It rests a little askew.
* When the card it sits on (`.group`) is hovered or focused, the stub is torn off along the dashed line: the two pieces part and tilt
* in opposite ways, and the torn edges turn jagged (the same number of clip points at rest and torn, so the clip animates). Only
* with a hovering pointer and when motion is welcome; otherwise the ticket just rests. `--shift` is the distance of the tear:
* a few pixels on a card, a few percent of the poster on a poster.
*/
.date-ticket {
--notch: 5px;
--shift: 4px;
--tooth: 3px;
--ease-tear: cubic-bezier(0.34, 1.56, 0.64, 1);
--ticket-outline-color: color-mix(in oklab, black 28%, transparent);
width: 4rem;
rotate: -3deg;
color: var(--ticket-ink);
font-weight: 700;
line-height: 1;
text-align: center;
font-variant-numeric: tabular-nums;
/* the outline of .ticket-v and a soft shadow, so the ticket lifts off whatever it lies on */
filter: var(--ticket-outline) drop-shadow(0 6px 8px color-mix(in oklab, black 28%, transparent));
}

.date-ticket > * {
display: flex;
align-items: center;
justify-content: center;
background: var(--ticket-fill);
transition:
transform 520ms var(--ease-tear),
clip-path 520ms var(--ease-tear);
/* the pieces' edges at rest: straight, but with the clip points the torn state moves */
clip-path: polygon(0 0, 100% 0, 100% 100%, 85% 100%, 70% 100%, 55% 100%, 40% 100%, 25% 100%, 10% 100%, 0 100%);
}

.date-ticket-day {
height: 2.9rem;
border-radius: 6px 6px 0 0;
font-size: 1.875rem;
}

.date-ticket-stub {
height: 1.45rem;
border-radius: 0 0 6px 6px;
font-size: 0.75rem;
/* the stub is a shade darker than the day, so the two read as two pieces even before the tear */
background: color-mix(in oklab, var(--ticket-fill) 88%, black);
clip-path: polygon(0 0, 15% 0, 30% 0, 45% 0, 60% 0, 75% 0, 90% 0, 100% 0, 100% 100%, 0 100%);
}

.date-ticket > :last-child::before {
border-top-color: color-mix(in oklab, var(--ticket-ink) 55%, transparent);
transition: opacity var(--dur-fast) ease-out;
}

/* on a poster the ticket is sized in the poster's own units, so it grows and shrinks with the cover */
.date-ticket[data-size="poster"] {
--notch: 2.3cqmin;
--shift: 2.2cqmin;
--tooth: 1.1cqmin;
width: 30cqmin;
}

.date-ticket[data-size="poster"] .date-ticket-day {
height: 27cqmin;
border-radius: 1.8cqmin 1.8cqmin 0 0;
font-size: 21cqmin;
}

.date-ticket[data-size="poster"] .date-ticket-stub {
height: 12cqmin;
border-radius: 0 0 1.8cqmin 1.8cqmin;
font-size: 6.2cqmin;
}

@media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference) {
/* the day lifts and tips back... */
.group:is(:hover, :focus-visible) .date-ticket > :first-child {
transform: translate(calc(var(--shift) * -0.35), calc(var(--shift) * -0.5)) rotate(-4deg);
clip-path: polygon(0 0, 100% 0, 100% 100%, 85% calc(100% - var(--tooth)), 70% 100%, 55% calc(100% - var(--tooth)), 40% 100%, 25% calc(100% - var(--tooth)), 10% 100%, 0 calc(100% - var(--tooth)));
}

/* ...and the stub tears off, drops and swings away from its top-left corner */
.group:is(:hover, :focus-visible) .date-ticket > :last-child {
transform: translate(calc(var(--shift) * 0.9), calc(var(--shift) * 1.7)) rotate(9deg);
transform-origin: 0 0;
clip-path: polygon(0 var(--tooth), 15% 0, 30% var(--tooth), 45% 0, 60% var(--tooth), 75% 0, 90% var(--tooth), 100% 0, 100% 100%, 0 100%);
}

/* the dashed line is where it tore: it is gone */
.group:is(:hover, :focus-visible) .date-ticket > :last-child::before {
opacity: 0;
}
}

/*
* A card in a list lifts a few pixels when the pointer is over it (or it has focus) and its cover throws a soft shadow, as a
* ticket picked up off a table. `transform`, not `translate`: `rise-in` already owns that property. Fine pointers without reduced
* motion only; everyone else keeps the zoom on the picture and the underlined title.
*/
@media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference) {
.card-lift {
transition: transform var(--dur-base) var(--ease-out-expo);
}

.card-lift .card-lift-cover {
transition: box-shadow var(--dur-base) var(--ease-out-expo);
}

.card-lift:is(:hover, :focus-visible) {
transform: translateY(-6px);
}

.card-lift:is(:hover, :focus-visible) .card-lift-cover {
box-shadow: 0 14px 24px -12px color-mix(in oklab, var(--foreground) 40%, transparent);
}
}
Loading