From 59468678e8bd982dc3ac214b2c0b53e4f10b2835 Mon Sep 17 00:00:00 2001 From: Kiron-Q-Summit Date: Sun, 8 Nov 2026 16:50:22 +0100 Subject: [PATCH 1/5] fix(web): ticket carousel count, card sharpness, compare pickers, glass Buy Now Carousel: arrows and dots set a target the count holds while the smooth scroll travels, a swipe only advances the count in its direction once the next card is clearly closer, and scrollend settles it on the centered card. Rapid taps queue (1 -> 4) instead of bouncing (3-4-3). Cards: off-screen cards drop their light animation (not just pause it), with a resting frame equal to the first keyframe, and the Save chip loses its live backdrop blur. About 60 fewer GPU layers and a third less layer area on phones, which fell back to low-resolution tiles while swiping. Compare: a button + listbox picker (WAI-ARIA select-only) replaces the native select: always opens downward and eases the page down to fit, closes on a second tap, outside tap or Escape keeping the pick, full keyboard support, softer open and close, reduced motion respected. The pinned names sit centered in their columns under the glass header. Home: Buy Now becomes clear liquid glass over the video, with a rim, halo, white focus ring and pressed state. Label and link unchanged. Co-Authored-By: Claude Opus 5.5 --- apps/web/DESIGN.md | 4 +- .../src/components/tickets/TicketCards.astro | 27 ++ .../components/tickets/TicketCompare.astro | 347 ++++++++------- .../src/components/tickets/ticket-theme.css | 15 + .../src/components/tickets/tickets.client.ts | 408 +++++++++++++++--- apps/web/src/styles/global.css | 106 +++-- apps/web/src/styles/tokens.css | 3 + cspell.config.yaml | 1 + scripts/check/design.mjs | 2 + 9 files changed, 671 insertions(+), 242 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 378f2f7..87abc9f 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -341,7 +341,7 @@ Radii are small and functional. Buttons are nearly square (5px and 7px), cards a Shared patterns live in `global.css` as flat classes, and the `ui/` Astro components are style-less typed wrappers over those classes (see `PRIM-*` in [`AGENTS.md`](AGENTS.md)). Not every pattern has earned a class: `.button` is real and shipped, while the card is small enough that each component declares it, so for cards the tokens are the whole contract. -**COMP-1 Buttons are `.button` plus its documented variants** (`.is-alternate`, `.is-secondary`, `.is-small`, `.is-glass`) from `global.css`. No hand-styled anchor or button. `.is-glass` is the light liquid glass call to action for dark grounds (COMP-6): ice glass, navy ink, a small uppercase label and a trailing arrow (`.button-arrow`), a cyan halo on hover and focus. +**COMP-1 Buttons are `.button` plus its documented variants** (`.is-alternate`, `.is-secondary`, `.is-small`, `.is-glass`) from `global.css`. No hand-styled anchor or button. `.is-glass` is the liquid glass call to action for dark grounds such as the home hero video (COMP-6): clear glass with no color fill, the ground showing through a light blur held slightly darker so the white label reads on any frame, a lit edge, a specular rim and a soft cyan halo, a small uppercase label and a trailing arrow (`.button-arrow`); hover and focus brighten the glass and lift the halo, focus adds a white ring, press scales it down a touch. **COMP-2 Cards use `--radius-card` and a `--color-neutral-lighter` border.** There is no shared `.card` class to inherit from, so this rule is the contract. @@ -436,7 +436,7 @@ The violet ramp is sampled from the Hack CI background artwork, which ships no w ## Tickets -`/ticket-categories/` is the 2027 ticket shop front, and it trades the conference minimalism for a calm, precise sales surface in the Q27 CI, built from liquid glass after the reference images the team picked. Liquid glass here means clear glass with a specular rim that catches light top left and bottom right, a caustic cyan glow along the lower inner edge, a little brightness and saturation lifted from what lies behind, and depth from light rather than from a flat fill (`.t-glass` in `tickets/ticket-theme.css`). A pale cool ground carries glowing light pools and Q bubbles; every ticket card is its own field of ice, cyan and Q27 blue light with Q bubbles inside, and the highlights sit on a glass panel that shows the light behind it blurred. A Q bubble is the original Q mark (`public/icon-512.png`) used as a mask and filled with flowing light; its rim is cut from the same mask, so the brand drawing is never redrawn, and it only floats, turns a few degrees and scales evenly, never stretched or skewed. Buttons are calm rounded rectangles (`--radius-ticket-button`) with a small uppercase label: buy buttons are navy glass with a lit rim, a Q27 blue inner glow and a trailing arrow; pressing one turns it light blue (`ticket-press`) with navy text, on touch, mouse and keyboard alike, and navigation is never delayed for the animation. Moving parts are glass too: a lens that glides to the picked tab and stretches as it moves (on phones the five tabs form a centered, symmetric three over two), glass carousel arrows and a small Q bubble marking the current ticket among the dots, the benefit meter as a glass tube whose glowing fill slides between tickets and carries a passing glint, and the compare pickers, whose open list (where the browser supports a styled select) is one flat frosted glass tone, dense enough that nothing behind it shows through, that settles in with a short stretch and marks options with raised glass chips, never with a color. Switching tickets under What's included plays one short entrance: the summary rises in, the benefit sheet slides up, and the rows follow in a quick cascade with their status icons popping in; statuses sit in one fixed-width column so icons align. The site header (COMP-6, first built for this page) is a floating bar of light frosted glass inset from the viewport edges: one flat tone with no gradient of its own, the page light showing through the blur, depth only from its lit edges, and small chip links; its open menu on phones is an opaque liquid surface in the Q27 blues, like the ticket cards, with light that turns slowly behind the links, so nothing of the page ever shows through the text. The page ends in a short glass fade into the footer (`.t-page-fade`), which replaces the footer's hairline on this page. The compare view is a product spec sheet in the CI: a glass sheet over the page light, navy ink and hairline rows, with the same buy buttons as the cards. The hero Q keeps its exact drawing: material comes only from light, a soft highlight and a shallow shadow, never from redrawing, tilting or distorting the mark. +`/ticket-categories/` is the 2027 ticket shop front, and it trades the conference minimalism for a calm, precise sales surface in the Q27 CI, built from liquid glass after the reference images the team picked. Liquid glass here means clear glass with a specular rim that catches light top left and bottom right, a caustic cyan glow along the lower inner edge, a little brightness and saturation lifted from what lies behind, and depth from light rather than from a flat fill (`.t-glass` in `tickets/ticket-theme.css`). A pale cool ground carries glowing light pools and Q bubbles; every ticket card is its own field of ice, cyan and Q27 blue light with Q bubbles inside, and the highlights sit on a glass panel that shows the light behind it blurred. A Q bubble is the original Q mark (`public/icon-512.png`) used as a mask and filled with flowing light; its rim is cut from the same mask, so the brand drawing is never redrawn, and it only floats, turns a few degrees and scales evenly, never stretched or skewed. Buttons are calm rounded rectangles (`--radius-ticket-button`) with a small uppercase label: buy buttons are navy glass with a lit rim, a Q27 blue inner glow and a trailing arrow; pressing one turns it light blue (`ticket-press`) with navy text, on touch, mouse and keyboard alike, and navigation is never delayed for the animation. Moving parts are glass too: a lens that glides to the picked tab and stretches as it moves (on phones the five tabs form a centered, symmetric three over two), glass carousel arrows and a small Q bubble marking the current ticket among the dots, the benefit meter as a glass tube whose glowing fill slides between tickets and carries a passing glint, and the compare pickers: a glass field that opens a listbox of its own (not the browser select, whose list flipped upward or could not be dismissed on some phones), always downward, easing the page down first when the list would not fit, closed again by a second tap, a tap outside or Escape without changing the pick; the list is one flat frosted glass tone, dense enough that nothing behind it shows through, unfolds softly from the field (`--duration-ticket-picker-open`), folds back a little faster and marks the picked ticket with a raised glass chip and a check, never with a color. Above the compare rows the two picked names stay pinned just under the site header, each centered over its own column. Switching tickets under What's included plays one short entrance: the summary rises in, the benefit sheet slides up, and the rows follow in a quick cascade with their status icons popping in; statuses sit in one fixed-width column so icons align. The site header (COMP-6, first built for this page) is a floating bar of light frosted glass inset from the viewport edges: one flat tone with no gradient of its own, the page light showing through the blur, depth only from its lit edges, and small chip links; its open menu on phones is an opaque liquid surface in the Q27 blues, like the ticket cards, with light that turns slowly behind the links, so nothing of the page ever shows through the text. The page ends in a short glass fade into the footer (`.t-page-fade`), which replaces the footer's hairline on this page. The compare view is a product spec sheet in the CI: a glass sheet over the page light, navy ink and hairline rows, with the same buy buttons as the cards. The hero Q keeps its exact drawing: material comes only from light, a soft highlight and a shallow shadow, never from redrawing, tilting or distorting the mark. On phones the cards become a scroll-snap carousel with exactly one card at rest, buttons, dots and a `1 / 5` count; it never autoplays and never captures vertical scrolling. Tablets and laptops show three cards over two, centered; wide screens show all five in one row. Without JavaScript the carousel still swipes, every panel of What's included is shown, and every buy link works. diff --git a/apps/web/src/components/tickets/TicketCards.astro b/apps/web/src/components/tickets/TicketCards.astro index fd33805..8dc6f09 100644 --- a/apps/web/src/components/tickets/TicketCards.astro +++ b/apps/web/src/components/tickets/TicketCards.astro @@ -253,6 +253,33 @@ const LIGHT = [ white-space: nowrap; } + /* + * GPU budget (the brief blur and pixelation while loading and swiping + * on phones). All five cards used to animate their light and re-blur it + * under two glass surfaces each, so a phone showing one card carried + * about 170 layers and 44 animations and fell back to low-resolution + * tiles. Now: + * - a card that is not the centered one (tickets.client.ts sets + * data-ticket-still at rest) drops its light animation entirely, not + * just pauses it, so its layers are freed; the resting frame equals + * the first keyframe (ticket-theme.css), so it restarts seamlessly; + * - the Save chip has no live backdrop blur: it sits on the card's own + * soft light, where the blur changed nothing you can see. + * The highlights panel keeps its glass blur, which is what shows the Q + * bubbles behind it frosted. + */ + .t-save { + -webkit-backdrop-filter: none; + backdrop-filter: none; + } + + .t-slide[data-ticket-still] .t-card::before, + .t-slide[data-ticket-still] .t-card::after, + .t-slide[data-ticket-still] :global(.t-qb), + .t-slide[data-ticket-still] :global(.t-qb-flow) { + animation: none; + } + .t-now { margin: 0; color: var(--color-navy); diff --git a/apps/web/src/components/tickets/TicketCompare.astro b/apps/web/src/components/tickets/TicketCompare.astro index ddb73af..659a92c 100644 --- a/apps/web/src/components/tickets/TicketCompare.astro +++ b/apps/web/src/components/tickets/TicketCompare.astro @@ -13,6 +13,7 @@ import "./ticket-theme.css"; import type { TicketCategoriesContent } from "../../lib/content"; import { EVENTS } from "../../lib/analytics/events"; import IconArrowRight from "../icons/IconArrowRight.astro"; +import IconCheck from "../icons/IconCheck.astro"; import IconChevron from "../icons/IconChevron.astro"; import TicketStatus from "./TicketStatus.astro"; import { @@ -73,29 +74,55 @@ const payload = JSON.stringify(clientData(page)).replace(/ (
- -
- - +

@@ -283,13 +310,26 @@ const payload = JSON.stringify(clientData(page)).replace(/ [data-picker-value] { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .t-select-field:disabled { cursor: default; - opacity: 1; } - .t-select select:active { + .t-select-field:active, + .t-select.is-open .t-select-field { box-shadow: inset 0 2px 5px color-mix(in srgb, var(--color-navy) 12%, transparent), - 0 1px 1px color-mix(in srgb, var(--color-navy) 8%, transparent); + 0 0 0 1px color-mix(in srgb, var(--color-navy) 14%, transparent); } .t-select-chevron { @@ -335,135 +380,131 @@ const payload = JSON.stringify(clientData(page)).replace(/: keyboard, screen readers and form behavior - * are native. Other browsers keep their own picker (the iOS wheel, for - * example), which still works the same way. + * The open list is glass and nothing else: one flat frosted tone, dense + * and strongly blurred so nothing behind it shows through, a lit rim and + * a faint lower edge. Always below the field. It unfolds from the field + * (fade, a short drop and a slight vertical stretch that settles) and + * folds back a little faster; data-state drives both directions so the + * close plays out before the list is hidden. */ - @supports (appearance: base-select) { - .t-select select, - .t-select select::picker(select) { - appearance: base-select; - } + .t-select-list { + position: absolute; + top: calc(100% + var(--space-2xs)); + right: 0; + left: 0; + z-index: 1; + margin: 0; + padding: 0.35rem; + border-radius: var(--radius-ticket-panel); + background: color-mix(in srgb, var(--color-white) 90%, transparent); + box-shadow: + inset 0 1px 1px var(--color-white), + inset 0 0 0 1px color-mix(in srgb, var(--color-white) 70%, transparent), + inset 0 -1px 1px color-mix(in srgb, var(--color-navy) 8%, transparent), + 0 0 0 1px color-mix(in srgb, var(--color-navy) 7%, transparent), + 0 26px 50px -22px color-mix(in srgb, var(--color-navy) 32%, transparent); + list-style: none; + opacity: 0; + transform: translateY(-6px) scaleY(0.96); + transform-origin: top center; + -webkit-backdrop-filter: blur(30px); + backdrop-filter: blur(30px); + transition: + opacity var(--duration-ticket-picker-close) var(--ease-snappy), + transform var(--duration-ticket-picker-close) var(--ease-snappy); + } - .t-select select::picker-icon { - display: none; - } + .t-select-list[data-state="open"] { + opacity: 1; + transform: none; + transition: + opacity var(--duration-ticket-picker-open) var(--ease-emphasized), + transform var(--duration-ticket-picker-open) var(--ease-emphasized); + } - .t-select:has(select:open) .t-select-chevron { - transform: translateY(-50%) rotate(180deg); - } + .t-select-list:focus { + outline: none; + } - /* - * The open list is glass and nothing else: one flat frosted tone, dense - * and strongly blurred so nothing behind it shows through, with a lit - * rim and a faint lower edge for depth. No gradient, no color. It opens - * with a short liquid settle (a slight stretch that comes to rest) and - * closes faster. - */ - .t-select select::picker(select) { - margin-block: var(--space-2xs); - padding: 0.35rem; - border: 0; - border-radius: var(--radius-ticket-panel); - background: color-mix(in srgb, var(--color-white) 88%, transparent); - box-shadow: - inset 0 1px 1px var(--color-white), - inset 0 0 0 1px color-mix(in srgb, var(--color-white) 70%, transparent), - inset 0 -1px 1px color-mix(in srgb, var(--color-navy) 8%, transparent), - 0 0 0 1px color-mix(in srgb, var(--color-navy) 7%, transparent), - 0 26px 50px -22px color-mix(in srgb, var(--color-navy) 32%, transparent); - opacity: 1; - transform: none; - transform-origin: top center; - -webkit-backdrop-filter: blur(30px); - backdrop-filter: blur(30px); - transition: - opacity var(--duration-close) var(--ease-snappy), - transform var(--duration-close) var(--ease-snappy), - display var(--duration-close) allow-discrete, - overlay var(--duration-close) allow-discrete; - } + .t-select-option { + display: flex; + align-items: center; + gap: var(--space-xs); + min-height: 2.75rem; + padding: var(--space-2xs) var(--space-xs); + border-radius: var(--radius-ticket-button); + color: var(--color-navy); + font-size: var(--text-size-regular); + font-weight: var(--font-weight-semibold); + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: background-color var(--duration-hover) var(--ease-snappy); + } - .t-select select:not(:open)::picker(select) { - opacity: 0; - transform: translateY(-4px) scale(0.97, 0.94); - } + .t-select-check { + display: inline-flex; + margin-inline-start: auto; + color: var(--color-navy); + opacity: 0; + } - .t-select select:open::picker(select) { - animation: t-picker-in var(--duration-open) var(--ease-emphasized); - } + /* The picked ticket: a raised glass chip and a check, never a color. */ + .t-select-option[aria-selected="true"] { + background-color: var(--color-white); + box-shadow: + inset 0 1px 1px var(--color-white), + 0 0 0 1px color-mix(in srgb, var(--color-navy) 8%, transparent), + 0 4px 10px -6px color-mix(in srgb, var(--color-navy) 30%, transparent); + font-weight: var(--font-weight-extrabold); + } - .t-select option { - display: flex; - align-items: center; - gap: var(--space-xs); - min-height: 2.75rem; - padding: var(--space-2xs) var(--space-xs); - border-radius: var(--radius-ticket-button); - color: var(--color-navy); - font-size: var(--text-size-regular); - font-weight: var(--font-weight-semibold); - cursor: pointer; - transition: - background-color var(--duration-hover) var(--ease-snappy), - box-shadow var(--duration-hover) var(--ease-snappy); - } + .t-select-option[aria-selected="true"] .t-select-check { + opacity: 1; + } + + /* The keyboard's current option (aria-activedescendant): a quieter chip + plus the focus ring while the list has keyboard focus. */ + .t-select-option.is-active:not([aria-selected="true"]) { + background-color: color-mix( + in srgb, + var(--color-neutral-lightest) 80%, + transparent + ); + box-shadow: inset 0 1px 1px var(--color-white); + } + + .t-select-list:focus-visible .t-select-option.is-active { + outline: 2px solid var(--color-focus); + outline-offset: -2px; + } - /* Hover and the picked ticket: a raised glass chip, never a color. */ - .t-select option:hover, - .t-select option:focus-visible { + @media (hover: hover) { + .t-select-option:not([aria-disabled="true"]):hover { background-color: color-mix( in srgb, - var(--color-neutral-lightest) 70%, + var(--color-neutral-lightest) 80%, transparent ); - box-shadow: inset 0 1px 1px var(--color-white); - outline: none; } - .t-select option:checked { + .t-select-option[aria-selected="true"]:hover { background-color: var(--color-white); - box-shadow: - inset 0 1px 1px var(--color-white), - 0 0 0 1px color-mix(in srgb, var(--color-navy) 8%, transparent), - 0 4px 10px -6px color-mix(in srgb, var(--color-navy) 30%, transparent); - font-weight: var(--font-weight-extrabold); - } - - .t-select option::checkmark { - order: 1; - margin-inline-start: auto; - color: var(--color-navy); - } - - /* The ticket already shown in the other column. */ - .t-select option:disabled { - color: color-mix(in srgb, var(--color-navy) 35%, transparent); - cursor: not-allowed; } } - @keyframes t-picker-in { - 0% { - opacity: 0; - transform: translateY(-6px) scale(0.94, 0.82); - } - - 60% { - opacity: 1; - transform: translateY(0) scale(1.01, 1.02); - } - - 100% { - opacity: 1; - transform: none; - } + /* The ticket already shown in the other column. */ + .t-select-option[aria-disabled="true"] { + color: color-mix(in srgb, var(--color-navy) 35%, transparent); + cursor: not-allowed; } .t-cmp-ticket { @@ -531,12 +572,16 @@ const payload = JSON.stringify(clientData(page)).replace(/ diff --git a/apps/web/src/components/tickets/ticket-theme.css b/apps/web/src/components/tickets/ticket-theme.css index 6aaee84..4140cd3 100644 --- a/apps/web/src/components/tickets/ticket-theme.css +++ b/apps/web/src/components/tickets/ticket-theme.css @@ -351,8 +351,14 @@ * other makes the pools flow into new shapes, like light moving through * liquid glass, with transform as the only animated property. */ +/* + * Resting frame = the first keyframe (2D, so a still layer is not promoted + * to its own GPU layer). A card whose animation is removed while it is off + * screen (TicketCards) therefore restarts without a jump. + */ .t-field::before { inset: -45%; + transform: translate(-5%, -3%) scale(1.08, 0.94); background: radial-gradient( 32% 22% at 34% 38%, @@ -373,6 +379,7 @@ .t-field::after { inset: -40%; + transform: translate(5%, 4%) scale(0.95, 1.08); background: radial-gradient( 30% 20% at 62% 70%, @@ -549,6 +556,13 @@ position: absolute; aspect-ratio: 1; pointer-events: none; + /* Resting frame = first keyframe of t-q-float (see .t-field::before). */ + transform: rotate(-5deg); +} + +.t-qb.is-card-sm, +.t-qb.is-ground-2 { + transform: rotate(6deg) scale(0.98); } .t-qb > span { @@ -597,6 +611,7 @@ .t-qb-flow { position: absolute; inset: -45%; + transform: translate(-5%, -3%) scale(1.08, 0.94); background: radial-gradient( 30% 20% at 35% 40%, diff --git a/apps/web/src/components/tickets/tickets.client.ts b/apps/web/src/components/tickets/tickets.client.ts index 397516a..8a7f3f3 100644 --- a/apps/web/src/components/tickets/tickets.client.ts +++ b/apps/web/src/components/tickets/tickets.client.ts @@ -50,6 +50,21 @@ function initPress(root: HTMLElement): void { /* Carousel: one card at rest on phones; grid (no carousel) when wide. */ /* ------------------------------------------------------------------ */ +/* + * The count, the dots and the card always agree: + * - Arrows, dots and keys set a target. The display jumps to it at once + * and holds it while the smooth scroll travels there, so a scroll frame + * that still has the old card nearest can never pull it back. + * - A swipe moves the display only forward in the direction the track is + * moving, and only once the next card is clearly closer (hysteresis), + * so a finger hovering near the midpoint or a quick reversal cannot + * make it flicker 3-4-3. + * - When the track comes to rest (scrollend, or a short quiet period + * where scrollend is missing) the display is set to the card that is + * actually centered, whatever happened on the way. + * Heavy work (inert, pausing the light of off-screen cards) runs only at + * rest, never per scroll frame. + */ function initCarousel(root: HTMLElement): void { const track = root.querySelector("[data-track]"); const controls = root.querySelector("[data-controls]"); @@ -61,33 +76,51 @@ function initCarousel(root: HTMLElement): void { const current = controls.querySelector("[data-current]"); controls.hidden = false; - let active = 0; + // Shown index (count + dots) and the index the track has settled on. + let shown = 0; + let settled = 0; + // Set while an arrow, dot or key scroll is on its way. + let target: number | null = null; + let lastLeft = track.scrollLeft; const isCarousel = () => track.scrollWidth > track.clientWidth + 1; - const render = () => { - const carousel = isCarousel(); - slides.forEach((slide, i) => { - // Hidden slides keep nothing focusable (buy links, All benefits). - slide.inert = carousel && i !== active; - }); - dots.forEach((dot, i) => - i === active + const show = (i: number) => { + if (i === shown && current?.textContent === String(i + 1)) return; + shown = i; + dots.forEach((dot, d) => + d === i ? dot.setAttribute("aria-current", "true") : dot.removeAttribute("aria-current"), ); - if (current) current.textContent = String(active + 1); - if (prev) prev.disabled = active === 0; - if (next) next.disabled = active === slides.length - 1; + if (current) current.textContent = String(i + 1); + if (prev) prev.disabled = i === 0; + if (next) next.disabled = i === slides.length - 1; + }; + + // At rest: only the centered card is reachable and lit. The others keep + // nothing focusable (buy links, All benefits) and hold their light still, + // which frees the GPU for the one card on screen. + const settle = (i: number) => { + settled = i; + const carousel = isCarousel(); + slides.forEach((slide, s) => { + const off = carousel && s !== i; + slide.inert = off; + slide.toggleAttribute("data-ticket-still", off); + }); + show(i); }; - // The slide whose center is closest to the track's center is active. + const centerOf = (i: number) => + slides[i].offsetLeft + slides[i].offsetWidth / 2; + const viewCenter = () => track.scrollLeft + track.clientWidth / 2; + const nearest = (): number => { - const center = track.scrollLeft + track.clientWidth / 2; + const center = viewCenter(); let best = 0; let bestDistance = Infinity; - slides.forEach((slide, i) => { - const mid = slide.offsetLeft + slide.offsetWidth / 2; - const distance = Math.abs(mid - center); + slides.forEach((_, i) => { + const distance = Math.abs(centerOf(i) - center); if (distance < bestDistance) { bestDistance = distance; best = i; @@ -97,17 +130,46 @@ function initCarousel(root: HTMLElement): void { }; let frame = 0; + let quiet = 0; + const onRest = () => { + clearTimeout(quiet); + const i = nearest(); + // A programmatic scroll is done once it reaches its card. + if (target !== null && i === target) target = null; + if (target === null) settle(i); + }; + track.addEventListener( "scroll", () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(() => { - const i = nearest(); - if (i !== active) { - active = i; - render(); + const left = track.scrollLeft; + const direction = Math.sign(left - lastLeft); + lastLeft = left; + if (target === null && direction !== 0) { + // The neighbor in the direction of travel takes over once its + // center is within 40% of a card width of the view's center. + const ahead = shown + direction; + if (ahead >= 0 && ahead < slides.length) { + const reach = slides[ahead].offsetWidth * 0.4; + if (Math.abs(centerOf(ahead) - viewCenter()) < reach) show(ahead); + } } }); + // Fallback for browsers without scrollend. + clearTimeout(quiet); + quiet = window.setTimeout(onRest, 140); + }, + { passive: true }, + ); + track.addEventListener("scrollend", onRest); + + // A new touch takes over from any arrow or dot scroll still running. + track.addEventListener( + "pointerdown", + () => { + target = null; }, { passive: true }, ); @@ -115,31 +177,44 @@ function initCarousel(root: HTMLElement): void { const go = (i: number) => { const index = Math.max(0, Math.min(slides.length - 1, i)); const slide = slides[index]; + const left = slide.offsetLeft - (track.clientWidth - slide.offsetWidth) / 2; + show(index); + if (Math.abs(track.scrollLeft - left) < 1) { + target = null; + settle(index); + return; + } + target = index; + // The card being scrolled to must not be inert when it arrives, and + // its light starts as it slides in. + slide.inert = false; + slide.removeAttribute("data-ticket-still"); track.scrollTo({ - left: slide.offsetLeft - (track.clientWidth - slide.offsetWidth) / 2, + left, behavior: reducedMotion() ? "auto" : "smooth", }); - active = index; - render(); }; - prev?.addEventListener("click", () => go(active - 1)); - next?.addEventListener("click", () => go(active + 1)); + // Arrows step from what is shown, so quick repeated taps queue up + // (2 -> 3 -> 4) instead of restarting from the card still sliding away. + prev?.addEventListener("click", () => go(shown - 1)); + next?.addEventListener("click", () => go(shown + 1)); dots.forEach((dot, i) => dot.addEventListener("click", () => go(i))); // Arrow keys move between tickets while focus is inside the carousel. root.addEventListener("keydown", (e) => { if (!isCarousel()) return; if ((e.target as Element).closest("select, input, textarea")) return; - if (e.key === "ArrowRight") go(active + 1); - else if (e.key === "ArrowLeft") go(active - 1); + if (e.key === "ArrowRight") go(shown + 1); + else if (e.key === "ArrowLeft") go(shown - 1); else return; e.preventDefault(); }); - // Crossing the 900px breakpoint switches carousel <-> grid. - new ResizeObserver(() => render()).observe(track); - render(); + // Crossing the 900px breakpoint switches carousel <-> grid; in the grid + // every card is reachable and lit. + new ResizeObserver(() => settle(target ?? settled)).observe(track); + settle(nearest()); } /* ------------------------------------------------------------------ */ @@ -258,15 +333,250 @@ function initTabs(section: HTMLElement): (key: string) => void { /* Compare: two pickers re-fill their column from the shared model. */ /* ------------------------------------------------------------------ */ +/* + * One compare picker: a button that opens a listbox (WAI-ARIA select-only + * pattern). It always opens downward; when the list would end below the + * visible area the page first eases down just enough (never above the + * fixed header). A second tap on the field, a tap outside, Escape or Tab + * close it and keep the previous pick. Keyboard: Enter, Space, Arrow keys + * open; Arrows, Home, End and a first letter move; Enter or Space pick. + */ +interface Picker { + root: HTMLElement; + value: () => string; + setValue: (key: string) => void; + setDisabled: (key: string) => void; + close: (focusField?: boolean) => void; + isOpen: () => boolean; +} + +const openPickers = new Set(); + +function createPicker(root: HTMLElement, onPick: () => void): Picker | null { + const field = root.querySelector("[data-picker-field]"); + const list = root.querySelector("[data-picker-list]"); + const label = root.querySelector("[data-picker-value]"); + if (!field || !list || !label) return null; + const options = [...list.querySelectorAll('[role="option"]')]; + let open = false; + let active = -1; + let hideTimer = 0; + let typed = ""; + let typedTimer = 0; + + const enabled = (o: HTMLElement) => + o.getAttribute("aria-disabled") !== "true"; + const value = () => root.dataset.value ?? ""; + + const setActive = (i: number) => { + active = i; + options.forEach((o, n) => o.classList.toggle("is-active", n === i)); + const o = options[i]; + if (o) { + list.setAttribute("aria-activedescendant", o.id); + o.scrollIntoView({ block: "nearest" }); + } else list.removeAttribute("aria-activedescendant"); + }; + + const step = (from: number, dir: 1 | -1) => { + for (let i = from + dir; i >= 0 && i < options.length; i += dir) { + if (enabled(options[i])) return i; + } + return from; + }; + + // Bring field and list into view below the fixed header, gently. + const reveal = () => { + const header = document.getElementById("site-header"); + const top = (header?.getBoundingClientRect().bottom ?? 0) + 12; + const view = window.visualViewport?.height ?? window.innerHeight; + const fieldBox = field.getBoundingClientRect(); + const fieldTop = fieldBox.top; + // Untransformed size: the list is still mid-unfold when this runs. + const listBottom = + fieldBox.bottom + list.offsetTop - field.offsetHeight + list.offsetHeight; + const need = listBottom + 16 - view; + if (need <= 0) return; + // Never push the field itself under the header. + const by = Math.min(need, Math.max(0, fieldTop - top)); + if (by > 0) { + window.scrollBy({ + top: by, + behavior: reducedMotion() ? "auto" : "smooth", + }); + } + }; + + const show = () => { + if (open || field.disabled) return; + openPickers.forEach((p) => p !== picker && p.close()); + open = true; + openPickers.add(picker); + clearTimeout(hideTimer); + list.hidden = false; + root.classList.add("is-open"); + field.setAttribute("aria-expanded", "true"); + // Next frame so the closed state is painted and the open transition runs. + void list.offsetHeight; + list.dataset.state = "open"; + const selected = options.findIndex((o) => o.dataset.value === value()); + setActive(selected >= 0 ? selected : step(-1, 1)); + list.focus({ preventScroll: true }); + reveal(); + }; + + const close = (focusField = false) => { + if (!open) return; + open = false; + openPickers.delete(picker); + root.classList.remove("is-open"); + field.setAttribute("aria-expanded", "false"); + list.dataset.state = "closed"; + list.removeAttribute("aria-activedescendant"); + const ms = reducedMotion() + ? 0 + : parseFloat( + getComputedStyle(list).getPropertyValue( + "--duration-ticket-picker-close", + ), + ) || 240; + hideTimer = window.setTimeout(() => { + if (!open) list.hidden = true; + }, ms); + if (focusField) field.focus({ preventScroll: true }); + }; + + const pick = (i: number) => { + const o = options[i]; + if (!o || !enabled(o)) return; + const changed = o.dataset.value !== value(); + picker.setValue(o.dataset.value ?? ""); + close(true); + if (changed) onPick(); + }; + + // Tap/click on the field toggles. pointerdown on the field must not count + // as "outside" for the document listener below. + field.addEventListener("click", () => (open ? close(true) : show())); + field.addEventListener("keydown", (e) => { + if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) { + e.preventDefault(); + show(); + if (e.key === "ArrowUp") setActive(step(options.length, -1)); + } + }); + + list.addEventListener("click", (e) => { + const o = (e.target as Element).closest('[role="option"]'); + if (o) pick(options.indexOf(o)); + }); + list.addEventListener("pointermove", (e) => { + const o = (e.target as Element).closest('[role="option"]'); + if (o && enabled(o)) setActive(options.indexOf(o)); + }); + list.addEventListener("keydown", (e) => { + switch (e.key) { + case "ArrowDown": + setActive(step(active, 1)); + break; + case "ArrowUp": + setActive(step(active, -1)); + break; + case "Home": + setActive(step(-1, 1)); + break; + case "End": + setActive(step(options.length, -1)); + break; + case "Enter": + case " ": + pick(active); + break; + case "Escape": + close(true); + break; + case "Tab": + close(); + return; // let focus move on + default: + if (e.key.length === 1 && /\S/.test(e.key)) { + clearTimeout(typedTimer); + typed += e.key.toLowerCase(); + typedTimer = window.setTimeout(() => (typed = ""), 600); + const hit = options.findIndex( + (o) => + enabled(o) && + (o.textContent ?? "").trim().toLowerCase().startsWith(typed), + ); + if (hit >= 0) setActive(hit); + break; + } + return; + } + e.preventDefault(); + }); + // Focus leaving the picker (e.g. a screen reader moving on) closes it. + root.addEventListener("focusout", (e) => { + if (open && !root.contains(e.relatedTarget as Node | null)) close(); + }); + + const picker: Picker = { + root, + value, + isOpen: () => open, + close, + setValue: (key) => { + root.dataset.value = key; + options.forEach((o) => + o.setAttribute( + "aria-selected", + o.dataset.value === key ? "true" : "false", + ), + ); + const o = options.find((x) => x.dataset.value === key); + label.textContent = o?.textContent?.trim() ?? key; + }, + setDisabled: (key) => + options.forEach((o) => + o.dataset.value === key + ? o.setAttribute("aria-disabled", "true") + : o.removeAttribute("aria-disabled"), + ), + }; + field.disabled = false; + return picker; +} + +// A tap or click anywhere outside an open picker closes it (no pick). +document.addEventListener( + "pointerdown", + (e) => { + openPickers.forEach((p) => { + if (!p.root.contains(e.target as Node)) p.close(); + }); + }, + { capture: true }, +); + function initCompare(section: HTMLElement): void { const raw = section.querySelector("[data-ticket-data]")?.textContent; if (!raw) return; const data = JSON.parse(raw) as TicketClientData; - const selects = [0, 1].map((col) => - section.querySelector(`[data-select="${col}"]`), + const roots = [0, 1].map((col) => + section.querySelector(`[data-picker="${col}"]`), + ); + if (!roots[0] || !roots[1]) return; + const made: (Picker | null)[] = []; + roots.forEach((root, col) => + made.push( + createPicker(root as HTMLElement, () => { + lockOptions(); + fill(col); + }), + ), ); - if (!selects[0] || !selects[1]) return; - const pickers = selects as [HTMLSelectElement, HTMLSelectElement]; + if (!made[0] || !made[1]) return; + const pickers = made as [Picker, Picker]; const replay = (el: Element | null) => { if (!el || reducedMotion()) return; @@ -276,7 +586,7 @@ function initCompare(section: HTMLElement): void { }; const fill = (col: number) => { - const ticket = data.tickets.find((t) => t.key === pickers[col].value); + const ticket = data.tickets.find((t) => t.key === pickers[col].value()); if (!ticket) return; const box = section.querySelector(`[data-swap="${col}"]`); @@ -321,29 +631,13 @@ function initCompare(section: HTMLElement): void { }; // A ticket already shown in one column cannot be picked in the other. - const lockOptions = () => { - pickers.forEach((picker, col) => { - const other = pickers[1 - col].value; - [...picker.options].forEach((option) => { - option.disabled = option.value === other; - }); - }); - }; + function lockOptions() { + pickers.forEach((picker, col) => + picker.setDisabled(pickers[1 - col].value()), + ); + } - pickers.forEach((picker, col) => { - picker.disabled = false; - picker.addEventListener("change", () => { - lockOptions(); - fill(col); - }); - }); lockOptions(); - // Restore what a back/forward navigation or a form autofill left behind. - fill(0); - fill(1); - section - .querySelectorAll(".is-swapping") - .forEach((el) => el.classList.remove("is-swapping")); } /* ------------------------------------------------------------------ */ diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css index b4d3ae2..5fd0ab3 100644 --- a/apps/web/src/styles/global.css +++ b/apps/web/src/styles/global.css @@ -356,14 +356,15 @@ a { } /* - Light liquid glass call to action (DESIGN.md COMP-1, COMP-6): the ticket - page's glass material (TICKETS-1) as a button for dark grounds, first the - home hero's Buy Now over the video. Ice glass with a lit rim and a Q27 - blue glow inside the lower edge, navy ink, small uppercase label and a - trailing arrow (.button-arrow). Hover lights a cyan halo (opacity only) - and nudges the arrow; press scales it down a touch. Focus keeps the site's - composite ring (blue outline on a white band), which reads on navy and - video alike. + Liquid glass call to action for dark grounds (DESIGN.md COMP-1, COMP-6), + first the home hero's Buy Now over the video. Clear glass, no color fill: + the video shows through, softened by a light blur and held a touch darker + (brightness) so the white label reads on any frame. Depth only from + light: a lit top edge, a caustic cyan glow inside the lower edge, a + specular rim (::after, bright top left and bottom right) and a soft halo + around it. Hover and keyboard focus brighten the glass and lift the halo + (::before, opacity only); press scales it down a touch and lights the + glass. Focus adds a white ring, which reads on navy and on video alike. */ .button.is-glass { position: relative; @@ -374,40 +375,71 @@ a { gap: 0.6rem; min-height: 3.25rem; padding: 0.85rem 1.6rem; - border: 1px solid color-mix(in srgb, var(--color-white) 75%, transparent); + border: 0; border-radius: var(--radius-ticket-button); background: linear-gradient( 180deg, - var(--color-white) 0%, - var(--color-ticket-ice) 100% + color-mix(in srgb, var(--color-white) 16%, transparent) 0%, + color-mix(in srgb, var(--color-white) 6%, transparent) 100% ); box-shadow: - inset 0 1px 1px var(--color-white), - inset 0 -14px 18px -14px - color-mix(in srgb, var(--color-ticket-sky) 70%, transparent), - 0 12px 30px -14px - color-mix(in srgb, var(--color-ticket-cyan) 75%, transparent); - color: var(--color-navy); + inset 0 1px 1px color-mix(in srgb, var(--color-white) 75%, transparent), + inset 0 -1px 1px color-mix(in srgb, var(--color-white) 20%, transparent), + inset 0 -14px 20px -16px var(--color-ticket-cyan), + 0 12px 32px -14px + color-mix(in srgb, var(--color-ticket-cyan) 55%, transparent), + 0 0 22px -8px color-mix(in srgb, var(--color-white) 45%, transparent); + color: var(--color-white); font-size: var(--text-size-small); font-weight: var(--font-weight-semibold); letter-spacing: 0.09em; line-height: 1.2; + text-shadow: 0 1px 2px color-mix(in srgb, var(--color-black) 45%, transparent); text-transform: uppercase; -webkit-tap-highlight-color: transparent; touch-action: manipulation; + -webkit-backdrop-filter: blur(10px) saturate(150%) brightness(0.8); + backdrop-filter: blur(10px) saturate(150%) brightness(0.8); transition: transform var(--duration-ticket-press) var(--ease-snappy); } -/* Hover halo: a cyan glow around the glass, faded in by opacity. */ +/* The specular rim: one pixel, bright where light meets the edge. */ +.button.is-glass::after { + content: ""; + position: absolute; + inset: 0; + padding: 1px; + border-radius: inherit; + background: linear-gradient( + 140deg, + color-mix(in srgb, var(--color-white) 95%, transparent) 0%, + color-mix(in srgb, var(--color-white) 35%, transparent) 30%, + color-mix(in srgb, var(--color-white) 12%, transparent) 55%, + color-mix(in srgb, var(--color-ticket-cyan) 75%, transparent) 85%, + color-mix(in srgb, var(--color-white) 90%, transparent) 100% + ); + pointer-events: none; + -webkit-mask: + linear-gradient(var(--color-black) 0 0) content-box, + linear-gradient(var(--color-black) 0 0); + -webkit-mask-composite: xor; + mask: + linear-gradient(var(--color-black) 0 0) content-box exclude, + linear-gradient(var(--color-black) 0 0); +} + +/* Hover, focus and press light: brighter glass and a wider halo. */ .button.is-glass::before { content: ""; position: absolute; - inset: -1px; + inset: 0; z-index: -1; border-radius: inherit; + background: color-mix(in srgb, var(--color-white) 12%, transparent); box-shadow: - 0 0 0 4px color-mix(in srgb, var(--color-ticket-cyan) 35%, transparent), - 0 16px 36px -10px var(--color-ticket-cyan); + 0 0 0 4px color-mix(in srgb, var(--color-ticket-cyan) 22%, transparent), + 0 14px 40px -8px + color-mix(in srgb, var(--color-ticket-cyan) 70%, transparent); opacity: 0; pointer-events: none; transition: opacity var(--duration-hover) var(--ease-snappy); @@ -423,10 +455,10 @@ a { .button.is-glass:hover { background: linear-gradient( 180deg, - var(--color-white) 0%, - var(--color-ticket-ice) 100% + color-mix(in srgb, var(--color-white) 16%, transparent) 0%, + color-mix(in srgb, var(--color-white) 6%, transparent) 100% ); - color: var(--color-navy); + color: var(--color-white); } .button.is-glass:hover::before { @@ -438,26 +470,40 @@ a { } } -.button.is-glass:focus-visible::before { - opacity: 1; +.button.is-glass:focus-visible { + outline: 2px solid var(--color-white); + outline-offset: 3px; } -.button.is-glass:focus-visible { - box-shadow: - inset 0 1px 1px var(--color-white), - 0 0 0 6px var(--color-white); +.button.is-glass:focus-visible::before, +.button.is-glass:active::before { + opacity: 1; } .button.is-glass:active { transform: scale(0.98); } +/* Without backdrop blur, a dark tint keeps the label on a calm surface. */ +@supports not ( + (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) +) { + .button.is-glass { + background: color-mix(in srgb, var(--color-navy) 55%, transparent); + } +} + @media (prefers-reduced-motion: reduce) { .button.is-glass, .button.is-glass::before, .button-arrow { transition: none; } + + .button.is-glass:active, + .button.is-glass:hover .button-arrow { + transform: none; + } } /* There is deliberately NO orange button variant (DESIGN.md COLOR-1): the live site has no diff --git a/apps/web/src/styles/tokens.css b/apps/web/src/styles/tokens.css index 440ce71..923d0fd 100644 --- a/apps/web/src/styles/tokens.css +++ b/apps/web/src/styles/tokens.css @@ -207,6 +207,9 @@ --duration-ticket-glint: 5.2s; --duration-ticket-enter: 460ms; --duration-ticket-stagger: 18ms; + /* The compare pickers' list: a soft, unhurried open, a quicker close. */ + --duration-ticket-picker-open: 340ms; + --duration-ticket-picker-close: 240ms; /* Hack campaign motion (DESIGN.md HACK-1). Ambient loops (falling fragments, the light seen through the white surface) run slowly and can be paused on the page; the reveal and the logo light pass are one diff --git a/cspell.config.yaml b/cspell.config.yaml index 52da802..672d227 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -52,6 +52,7 @@ words: - Ersti - ersti - wifo + - activedescendant - actuals - adrs - affordances diff --git a/scripts/check/design.mjs b/scripts/check/design.mjs index b0828eb..127211c 100644 --- a/scripts/check/design.mjs +++ b/scripts/check/design.mjs @@ -361,6 +361,8 @@ const UNMAPPED_TOKENS = new Map([ ["--duration-ticket-glint", "see --duration-ticket-press"], ["--duration-ticket-enter", "see --duration-ticket-press"], ["--duration-ticket-stagger", "see --duration-ticket-press"], + ["--duration-ticket-picker-open", "see --duration-ticket-press"], + ["--duration-ticket-picker-close", "see --duration-ticket-press"], [ "--duration-hack-fall", "see --ease-snappy; Q-Hack campaign only (DESIGN.md HACK-1)", From c21582fb1887576da2cbef505418849a1466a0e8 Mon Sep 17 00:00:00 2001 From: Kiron-Q-Summit Date: Sun, 8 Nov 2026 18:25:21 +0100 Subject: [PATCH 2/5] feat(web): shared liquid glass motion (press, unfold, dynamic type) MOTION-6: one press, unfold/fold and word-by-word type pattern for the glass parts, timed from the team's reference videos. - Home Buy Now: same clear glass; a press compresses it on the first frame, runs a light pulse from the touch point and springs it back. - Header: the phone menu unfolds out of the menu button with a light sweep and staggered links, folds back into it, reversible mid-way. - Compare: clearer sheet and picker fields in the Buy Now material; the list unfolds out of its field and folds back; names left-aligned on a clear glass bar that blurs the rows (the sheet's blur moved to a pseudo layer so it is no backdrop root). - What's included: the summary grows out of the picked tab, the sheet unrolls in place, contents and description follow (Web Animations, interruptible). - lib/motion/glass-press.ts and typewriter.ts, glass and type tokens. Co-Authored-By: Claude Opus 5.5 --- apps/web/DESIGN.md | 19 +- apps/web/src/components/home/Hero.astro | 2 + apps/web/src/components/layout/Nav.astro | 135 +++++++++-- .../src/components/layout/nav-menu.client.ts | 38 ++- .../components/tickets/TicketCompare.astro | 220 ++++++++++++++---- .../components/tickets/TicketIncluded.astro | 48 ++-- .../src/components/tickets/tickets.client.ts | 132 ++++++++++- apps/web/src/layouts/Base.astro | 6 + apps/web/src/lib/motion/glass-press.ts | 83 +++++++ apps/web/src/lib/motion/typewriter.ts | 61 +++++ apps/web/src/styles/global.css | 105 ++++++++- apps/web/src/styles/tokens.css | 22 +- scripts/check/design.mjs | 14 +- 13 files changed, 779 insertions(+), 106 deletions(-) create mode 100644 apps/web/src/lib/motion/glass-press.ts create mode 100644 apps/web/src/lib/motion/typewriter.ts diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 87abc9f..8a6d66c 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -248,6 +248,7 @@ Every rule, with the token or class it is about. Cite these IDs verbatim in code | MOTION-3 | Animate transform and opacity only | `transform`, `opacity` | | MOTION-4 | No client-side animation runtime | `package.json` | | MOTION-5 | Page transitions and reveals have one implementation each | `@view-transition`, `ui/Timeline` | +| MOTION-6 | Liquid glass parts share one press, unfold and type motion | `--duration-glass-*`, `lib/motion/` | | KICKOFF-1 | Join Q is its own visual system | `components/kickoff/` | | HACK-1 | Q-Hack x Hacktoberfest is its own campaign system | `components/hackathon/` | | TICKETS-1 | The 2027 ticket page is its own sales system | `components/tickets/` | @@ -341,7 +342,7 @@ Radii are small and functional. Buttons are nearly square (5px and 7px), cards a Shared patterns live in `global.css` as flat classes, and the `ui/` Astro components are style-less typed wrappers over those classes (see `PRIM-*` in [`AGENTS.md`](AGENTS.md)). Not every pattern has earned a class: `.button` is real and shipped, while the card is small enough that each component declares it, so for cards the tokens are the whole contract. -**COMP-1 Buttons are `.button` plus its documented variants** (`.is-alternate`, `.is-secondary`, `.is-small`, `.is-glass`) from `global.css`. No hand-styled anchor or button. `.is-glass` is the liquid glass call to action for dark grounds such as the home hero video (COMP-6): clear glass with no color fill, the ground showing through a light blur held slightly darker so the white label reads on any frame, a lit edge, a specular rim and a soft cyan halo, a small uppercase label and a trailing arrow (`.button-arrow`); hover and focus brighten the glass and lift the halo, focus adds a white ring, press scales it down a touch. +**COMP-1 Buttons are `.button` plus its documented variants** (`.is-alternate`, `.is-secondary`, `.is-small`, `.is-glass`) from `global.css`. No hand-styled anchor or button. `.is-glass` is the liquid glass call to action for dark grounds such as the home hero video (COMP-6): clear glass with no color fill, the ground showing through a light blur held slightly darker so the white label reads on any frame, a lit edge, a specular rim and a soft cyan halo, a small uppercase label and a trailing arrow (`.button-arrow`); hover and focus brighten the glass and lift the halo, focus adds a white ring, and a press compresses it a little on the first frame, runs a light pulse from the touch point and springs it back on release (MOTION-6). **COMP-2 Cards use `--radius-card` and a `--color-neutral-lighter` border.** There is no shared `.card` class to inherit from, so this rule is the contract. @@ -364,7 +365,7 @@ The footer's column labels are deliberately not `.u-eyebrow`: they label a navig **COMP-4 Accordions are native `

`/``.** The shared open and close animation lives in `global.css`, once, for every accordion on the site, and so does the summary row itself (`.u-accordion-summary`) and the chevron (`src/components/icons/IconChevron.astro`). No JS widget replaces the native element. What stays with each accordion is what genuinely differs: its hairline, its label type, and the `[open]` rotation, which targets that accordion's own chevron box. -**COMP-6 The site header is the floating liquid glass bar on every page.** `layout/Nav.astro` is the one header, rendered once by `Base.astro`; there is no plain-bar variant and no page-local copy. It is the bar first built for the ticket page (TICKETS-1): inset from the viewport edges, light frosted glass (white at about half strength) with a strong blur and a still specular rim, dense enough that navy or purple shapes passing under it never cut the links' contrast, small uppercase chip links with a raised white chip for the current page, and on phones an opaque Q27 liquid sheet as the open menu. Over a dark ground (`.section.is-dark`, including the home video hero, or anything marked `data-nav-tone="dark"`) the bar frosts to a denser white fill so the navy logo and links keep their contrast; `nav-tone.client.ts` switches it, and CSS sets the frosted state for a page that opens on a dark section so the first paint is right. The header and `.button.is-glass` may use the `ticket-*` tokens they are built from (radius, Q27 blues, ticket durations); nothing else on conference pages may. Its geometry (`--site-header-height` and friends) lives on `:root` so a section that runs under the bar, like the home hero video, can pull itself up by exactly that height. +**COMP-6 The site header is the floating liquid glass bar on every page.** `layout/Nav.astro` is the one header, rendered once by `Base.astro`; there is no plain-bar variant and no page-local copy. It is the bar first built for the ticket page (TICKETS-1): inset from the viewport edges, light frosted glass (white at about half strength) with a strong blur and a still specular rim, dense enough that navy or purple shapes passing under it never cut the links' contrast, small uppercase chip links with a raised white chip for the current page, and on phones an opaque Q27 liquid sheet as the open menu, which unfolds out of the menu button, a light running across it and the links following a few frames apart, and folds back into the button (MOTION-6). Over a dark ground (`.section.is-dark`, including the home video hero, or anything marked `data-nav-tone="dark"`) the bar frosts to a denser white fill so the navy logo and links keep their contrast; `nav-tone.client.ts` switches it, and CSS sets the frosted state for a page that opens on a dark section so the first paint is right. The header and `.button.is-glass` may use the `ticket-*` tokens they are built from (radius, Q27 blues, ticket durations); nothing else on conference pages may. Its geometry (`--site-header-height` and friends) lives on `:root` so a section that runs under the bar, like the home hero video, can pull itself up by exactly that height. PRIM-3's note that the home and program FAQs are "intentionally separate" is about their section layouts, which really are two designs (a two-column copy-plus-list band, and a single left-aligned column). It is not a license to re-copy the summary row inside them. @@ -382,6 +383,10 @@ Motion is small, fast, purposeful, and interruptible. The timing model follows t | Cross-document page transition | `--ease-snappy` | `--duration-page` 220ms | | Sliding in from an edge (sheet, drawer) | `--ease-buttery` | context | | Continuous loop (the partner marquee) | `linear` | the loop's own period | +| Glass press (MOTION-6) | `--ease-snappy` in, `--ease-glass-spring` back | `--duration-glass-press` 90ms, `--duration-glass-release` 420ms | +| Glass surface unfolding out of its trigger (MOTION-6) | clip `--ease-emphasized`, size `--ease-glass-spring` | `--duration-glass-unfold` 380ms, stagger `--duration-glass-stagger` 28ms | +| The same surface folding back | `--ease-emphasized` | `--duration-glass-fold` 220ms | +| Dynamic type, per word (MOTION-6) | `--ease-snappy` | `--duration-type-word` 150ms apart, each fading in `--duration-type-fade` 120ms | Exit is faster than enter, hover stays under 150ms, and nothing crosses 500ms. `linear` is for loops and for scroll-driven timelines whose progress is the scroll position rather than a clock: the partner-logo marquee, and the timeline's rail and reveal keyframes in `ui/Timeline.astro`. It is never the curve for a click-triggered transition, where it reads as robotic. @@ -416,6 +421,14 @@ Everything below is CSS. None of it ships an animation runtime, so none of it co A second component wanting a reveal extracts the shared utility at that point rather than copying Timeline's keyframes. `global.css` briefly carried a `.u-reveal` utility with no consumers while Timeline implemented its own; an extracted utility nothing extracts to is worse than no utility, so it was removed. +**MOTION-6 Liquid glass parts share one press, unfold and type motion.** The timings come from the team's reference videos (Apple's Liquid Glass demos), measured frame by frame, and live in the `--duration-glass-*`, `--duration-type-*` and `--ease-glass-spring` tokens. Every glass part uses the same three patterns, so new ones are composed rather than reinvented: + +- **Press.** Mark the element `data-glass-press` and give it a `.glass-light` child (it needs `position: relative` and a radius). `lib/motion/glass-press.ts`, booted once in `Base.astro`, sets `.is-pressed` for touch, mouse and Enter/Space alike and replays a light pulse from the touch point (`--press-x` / `--press-y`). The element gives a little on the first frame (`--duration-glass-press`) and springs back past its size on release (`--duration-glass-release`, `--ease-glass-spring`). Nothing waits for it: links navigate and buttons fire on the native event. Users: the home Buy Now (`.button.is-glass`), the header's menu button, the compare picker fields. +- **Unfold and fold.** A surface grows out of the control that opened it: it starts clipped to and squeezed into the trigger's box, the clip opens on `--ease-emphasized` while the size springs (`--ease-glass-spring`, a small overshoot that settles), a light runs across it, and its contents follow `--duration-glass-stagger` apart, invisible while squeezed so text is never seen distorted. Folding plays back into the trigger faster (`--duration-glass-fold`) and fades only at the end; the element is hidden only after the fold. Toggles are CSS transitions on a state attribute, so a quick second tap reverses mid-way; panels that move between places use the Web Animations API and cancel what is still running. Users: the header's phone menu (`data-sheet`), the compare pickers' list (`data-state`), What's included (`tickets.client.ts`). +- **Dynamic type.** `lib/motion/typewriter.ts` writes a short line word by word inside a surface that has just unfolded: each word fades up in `--duration-type-fade`, one every `--duration-type-word`, capped at about 0.7s per line; no cursor, no loop. Every word is laid out from the first frame, so nothing reflows; screen readers get the sentence once from a visually hidden copy; calling it again replaces a line still typing; reduced motion and no-JS show the plain text. Only for supporting text that appears in an interaction (the ticket description in What's included and in each compare column); never for prices, buttons, navigation, options or compare data, and never on scroll. + +Scale and clip stay on the glass surface itself, never on content that has to be compared or read in place. Each pattern has its reduced-motion escape (MOTION-2): no transition, no pulse, no typing. + Progressive enhancement is the rule, not an aspiration: the fallback for every one of these is the un-animated state, never hidden content. Never gate visibility on an animation that may not run. ## Kickoff @@ -436,7 +449,7 @@ The violet ramp is sampled from the Hack CI background artwork, which ships no w ## Tickets -`/ticket-categories/` is the 2027 ticket shop front, and it trades the conference minimalism for a calm, precise sales surface in the Q27 CI, built from liquid glass after the reference images the team picked. Liquid glass here means clear glass with a specular rim that catches light top left and bottom right, a caustic cyan glow along the lower inner edge, a little brightness and saturation lifted from what lies behind, and depth from light rather than from a flat fill (`.t-glass` in `tickets/ticket-theme.css`). A pale cool ground carries glowing light pools and Q bubbles; every ticket card is its own field of ice, cyan and Q27 blue light with Q bubbles inside, and the highlights sit on a glass panel that shows the light behind it blurred. A Q bubble is the original Q mark (`public/icon-512.png`) used as a mask and filled with flowing light; its rim is cut from the same mask, so the brand drawing is never redrawn, and it only floats, turns a few degrees and scales evenly, never stretched or skewed. Buttons are calm rounded rectangles (`--radius-ticket-button`) with a small uppercase label: buy buttons are navy glass with a lit rim, a Q27 blue inner glow and a trailing arrow; pressing one turns it light blue (`ticket-press`) with navy text, on touch, mouse and keyboard alike, and navigation is never delayed for the animation. Moving parts are glass too: a lens that glides to the picked tab and stretches as it moves (on phones the five tabs form a centered, symmetric three over two), glass carousel arrows and a small Q bubble marking the current ticket among the dots, the benefit meter as a glass tube whose glowing fill slides between tickets and carries a passing glint, and the compare pickers: a glass field that opens a listbox of its own (not the browser select, whose list flipped upward or could not be dismissed on some phones), always downward, easing the page down first when the list would not fit, closed again by a second tap, a tap outside or Escape without changing the pick; the list is one flat frosted glass tone, dense enough that nothing behind it shows through, unfolds softly from the field (`--duration-ticket-picker-open`), folds back a little faster and marks the picked ticket with a raised glass chip and a check, never with a color. Above the compare rows the two picked names stay pinned just under the site header, each centered over its own column. Switching tickets under What's included plays one short entrance: the summary rises in, the benefit sheet slides up, and the rows follow in a quick cascade with their status icons popping in; statuses sit in one fixed-width column so icons align. The site header (COMP-6, first built for this page) is a floating bar of light frosted glass inset from the viewport edges: one flat tone with no gradient of its own, the page light showing through the blur, depth only from its lit edges, and small chip links; its open menu on phones is an opaque liquid surface in the Q27 blues, like the ticket cards, with light that turns slowly behind the links, so nothing of the page ever shows through the text. The page ends in a short glass fade into the footer (`.t-page-fade`), which replaces the footer's hairline on this page. The compare view is a product spec sheet in the CI: a glass sheet over the page light, navy ink and hairline rows, with the same buy buttons as the cards. The hero Q keeps its exact drawing: material comes only from light, a soft highlight and a shallow shadow, never from redrawing, tilting or distorting the mark. +`/ticket-categories/` is the 2027 ticket shop front, and it trades the conference minimalism for a calm, precise sales surface in the Q27 CI, built from liquid glass after the reference images the team picked. Liquid glass here means clear glass with a specular rim that catches light top left and bottom right, a caustic cyan glow along the lower inner edge, a little brightness and saturation lifted from what lies behind, and depth from light rather than from a flat fill (`.t-glass` in `tickets/ticket-theme.css`). A pale cool ground carries glowing light pools and Q bubbles; every ticket card is its own field of ice, cyan and Q27 blue light with Q bubbles inside, and the highlights sit on a glass panel that shows the light behind it blurred. A Q bubble is the original Q mark (`public/icon-512.png`) used as a mask and filled with flowing light; its rim is cut from the same mask, so the brand drawing is never redrawn, and it only floats, turns a few degrees and scales evenly, never stretched or skewed. Buttons are calm rounded rectangles (`--radius-ticket-button`) with a small uppercase label: buy buttons are navy glass with a lit rim, a Q27 blue inner glow and a trailing arrow; pressing one turns it light blue (`ticket-press`) with navy text, on touch, mouse and keyboard alike, and navigation is never delayed for the animation. Moving parts are glass too: a lens that glides to the picked tab and stretches as it moves (on phones the five tabs form a centered, symmetric three over two), glass carousel arrows and a small Q bubble marking the current ticket among the dots, the benefit meter as a glass tube whose glowing fill slides between tickets and carries a passing glint, and the compare pickers: a clear glass field in the home Buy Now's material that opens a listbox of its own (not the browser select, whose list flipped upward or could not be dismissed on some phones), always downward, easing the page down first when the list would not fit, closed again by a second tap, a tap outside or Escape without changing the pick; the list is one flat frosted glass tone, dense enough that nothing behind it shows through, unfolds out of the field and folds back into it (MOTION-6), and marks the picked ticket with a raised glass chip and a check, never with a color. Above the compare rows the two picked names stay pinned just under the site header, each at the left of its own column, on a clear glass bar that blurs the rows passing under it; the compare sheet keeps its fill and blur on a pseudo layer so it is no backdrop root and that blur reaches the rows. Switching tickets under What's included grows the summary card out of the tab just picked, runs a light across it and writes its description in, then unrolls the benefit sheet in place, and the rows follow in a quick cascade with their status icons popping in (MOTION-6); statuses sit in one fixed-width column so icons align. The site header (COMP-6, first built for this page) is a floating bar of light frosted glass inset from the viewport edges: one flat tone with no gradient of its own, the page light showing through the blur, depth only from its lit edges, and small chip links; its open menu on phones is an opaque liquid surface in the Q27 blues, like the ticket cards, with light that turns slowly behind the links, so nothing of the page ever shows through the text. The page ends in a short glass fade into the footer (`.t-page-fade`), which replaces the footer's hairline on this page. The compare view is a product spec sheet in the CI: a glass sheet over the page light, navy ink and hairline rows, with the same buy buttons as the cards. The hero Q keeps its exact drawing: material comes only from light, a soft highlight and a shallow shadow, never from redrawing, tilting or distorting the mark. On phones the cards become a scroll-snap carousel with exactly one card at rest, buttons, dots and a `1 / 5` count; it never autoplays and never captures vertical scrolling. Tablets and laptops show three cards over two, centered; wide screens show all five in one row. Without JavaScript the carousel still swipes, every panel of What's included is shown, and every buy link works. diff --git a/apps/web/src/components/home/Hero.astro b/apps/web/src/components/home/Hero.astro index c23de0f..4075122 100644 --- a/apps/web/src/components/home/Hero.astro +++ b/apps/web/src/components/home/Hero.astro @@ -204,7 +204,9 @@ const { headline, tagline, announcement, cta } = Astro.props; data-ph-event={EVENTS.hero_cta_clicked} data-ph-prop-cta-label={cta.label} data-ph-prop-cta-href={cta.href} + data-glass-press > +