diff --git a/README.md b/README.md index 5c4da25..e7653e5 100644 --- a/README.md +++ b/README.md @@ -424,11 +424,21 @@ Two pieces, both of which have to survive a page that renders on the server: so the arrows have to be asked for, and the controls only appear at all once there are more items than fit — with two testimonials it degrades to a plain row, which is the right answer. -- **`ui/reveal.ts`** fades each section in as it arrives. The hidden state is - *not* in the server-rendered HTML: it only applies under `html.reveal-ready`, - a class the directive adds at runtime, so a visitor without JavaScript reads a - full page instead of a blank column. `prefers-reduced-motion` skips the - observer entirely. +- **`ui/reveal.ts`** assembles each section as it arrives: its heading first, + then what it holds, and a grid's cards one after another (up to eight, then + together). The hidden state is *not* in the server-rendered HTML: it only + applies under `html.reveal-ready`, a class the directive adds at runtime, so + a visitor without JavaScript reads a full page instead of a blank column. +- **The hero enters on load**, in CSS (`hero.scss`), so it plays with or + without JavaScript: eyebrow, title, lead, buttons, and the stats last, at + 0.8s. The stats count up from zero — but only if the page hydrates before + then (`COUNT_UP_BEFORE_MS` in `stat-grid.ts`). Later than that the number has + already been read, and dropping it to zero to count again would be a flicker. +- **Scroll-driven, no JavaScript**: the hero's lights drift and fade as it + scrolls away, and the header gains a shadow once the page moves + (`animation-timeline: scroll()`). Browsers without it simply do not animate. + +`prefers-reduced-motion` turns every one of these off. `PiThemeService` owns the shell class, pinned to `alternative` in `app.config.ts` — the library's marketing look: pill buttons with a gradient diff --git a/landing/src/app/ui/hero.scss b/landing/src/app/ui/hero.scss index 910c804..876e4c8 100644 --- a/landing/src/app/ui/hero.scss +++ b/landing/src/app/ui/hero.scss @@ -44,3 +44,88 @@ gap: 0.75rem; margin-top: 2.25rem; } + +// --------------------------------------------------------------------------- +// Entrance. +// +// The first thing on the page, so it plays on load rather than on scroll, and +// in CSS rather than from the directive: it runs whether or not JavaScript +// ever does, and `both` keeps each piece hidden until its turn and shown after. +// +// The stats come last, at 0.8s. `StatGrid` counts its numbers up from zero +// only if it hydrates before then — see `COUNT_UP_BEFORE_MS` there. +// --------------------------------------------------------------------------- + +@keyframes hero-in { + from { + opacity: 0; + transform: translateY(22px); + } +} + +@media (prefers-reduced-motion: no-preference) { + .landing-hero .landing-eyebrow, + .landing-hero__title, + .landing-hero__lead, + .landing-hero__actions, + app-stat-grid { + animation: hero-in 0.8s cubic-bezier(0.22, 1, 0.36, 1) both; + } + + .landing-hero__title { + animation-delay: 0.08s; + } + + .landing-hero__lead { + animation-delay: 0.2s; + } + + .landing-hero__actions { + animation-delay: 0.32s; + } + + app-stat-grid { + display: block; + animation-delay: 0.8s; + } +} + +// --------------------------------------------------------------------------- +// The lights drift and fade as the hero scrolls away, tied to the scroll +// itself rather than to a listener. Where the browser has no scroll-driven +// animations the lights simply stay put. +// --------------------------------------------------------------------------- + +@keyframes hero-light-a { + to { + opacity: 0.15; + transform: translateX(-58%) translateY(9rem) scale(1.15); + } +} + +@keyframes hero-light-b { + to { + opacity: 0.1; + transform: translate(-6rem, 5rem); + } +} + +@supports (animation-timeline: scroll()) { + @media (prefers-reduced-motion: no-preference) { + .landing-hero::before, + .landing-hero::after { + animation-timeline: scroll(root); + animation-range: 0 90vh; + animation-fill-mode: both; + animation-timing-function: linear; + } + + .landing-hero::before { + animation-name: hero-light-a; + } + + .landing-hero::after { + animation-name: hero-light-b; + } + } +} diff --git a/landing/src/app/ui/site-header.scss b/landing/src/app/ui/site-header.scss index d0e73f4..7821a82 100644 --- a/landing/src/app/ui/site-header.scss +++ b/landing/src/app/ui/site-header.scss @@ -6,7 +6,11 @@ position: sticky; top: 0; z-index: 50; - background-color: color-mix(in srgb, var(--pi-color-white, #fff) 88%, transparent); + background-color: color-mix( + in srgb, + var(--pi-color-white, #fff) 88%, + transparent + ); backdrop-filter: blur(12px); border-bottom: 1px solid var(--pi-color-grey-200, #e4e4ea); } @@ -160,3 +164,24 @@ color: var(--pi-color-grey-600, #5b5b6b); cursor: pointer; } + +// A shadow under the header once the page moves, so it reads as floating over +// the content rather than as part of it. Tied to the scroll in CSS; without +// scroll-driven animations the border alone does the job, as before. +@keyframes header-lift { + to { + border-bottom-color: transparent; + box-shadow: 0 10px 30px -18px + color-mix(in srgb, var(--pi-color-primary) 45%, transparent); + } +} + +@supports (animation-timeline: scroll()) { + @media (prefers-reduced-motion: no-preference) { + .landing-header { + animation: header-lift linear both; + animation-timeline: scroll(root); + animation-range: 0 5rem; + } + } +} diff --git a/landing/src/app/ui/stat-grid.ts b/landing/src/app/ui/stat-grid.ts index 33c4017..c27ae24 100644 --- a/landing/src/app/ui/stat-grid.ts +++ b/landing/src/app/ui/stat-grid.ts @@ -1,13 +1,38 @@ -import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; +import { + afterNextRender, + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + ElementRef, + inject, + input, + PLATFORM_ID, + signal, +} from '@angular/core'; import { PiStatCardComponent } from '@pi/ui'; import type { Copy } from '../core/copy'; import type { Locale, SitePayload } from '../core/api'; +/** + * The hero's stats appear 0.8s after load (`hero.scss`). Counting up is only + * worth it if it can start from zero before anyone has read the number: after + * that, dropping a figure already on screen to 0 is a flicker, not an effect. + */ +const COUNT_UP_BEFORE_MS = 800; + +const COUNT_UP_DURATION_MS = 1400; + /** * The headline numbers from `/site`, as `pi-stat-card`. Zeroes are dropped * rather than shown: "0 proyectos" says less than nothing, and a young site * has plenty of them. + * + * The numbers count up from zero as they come into view. The server renders + * the real figures, so without JavaScript — or with reduced motion — they are + * simply there. */ @Component({ selector: 'app-stat-grid', @@ -34,6 +59,20 @@ export class StatGrid { readonly copy = input.required(); readonly locale = input.required(); + private readonly host = inject(ElementRef) + .nativeElement as HTMLElement; + private readonly document = inject(DOCUMENT); + private readonly destroyRef = inject(DestroyRef); + + /** 0 → 1 while counting; 1 on the server and whenever there is no animation. */ + private readonly progress = signal(1); + + constructor() { + if (isPlatformBrowser(inject(PLATFORM_ID))) { + afterNextRender(() => this.countUpWhenSeen()); + } + } + protected readonly stats = computed(() => { const raw = this.site()?.stats ?? {}; const labels = this.copy().stats; @@ -60,7 +99,70 @@ export class StatGrid { icon: entry.icon, color: entry.color, label: labels[entry.key], - value: format.format(raw[entry.key] ?? 0), + value: format.format( + Math.round((raw[entry.key] ?? 0) * this.progress()), + ), })); }); + + private countUpWhenSeen(): void { + const view = this.document.defaultView; + + if ( + !view || + typeof IntersectionObserver === 'undefined' || + view.matchMedia('(prefers-reduced-motion: reduce)').matches + ) { + return; + } + + const onScreen = this.host.getBoundingClientRect().top < view.innerHeight; + + if (onScreen) { + // In the hero at the top of the page: start with its entrance, or not + // at all if the number has already been on screen. + const wait = COUNT_UP_BEFORE_MS - view.performance.now(); + + if (wait > 0) { + this.progress.set(0); + view.setTimeout(() => this.countUp(view), wait); + } + + return; + } + + // Further down — a reload that restored the scroll position: count when + // it arrives. + this.progress.set(0); + + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + observer.disconnect(); + this.countUp(view); + } + }, + { threshold: 0.4 }, + ); + + observer.observe(this.host); + this.destroyRef.onDestroy(() => observer.disconnect()); + } + + private countUp(view: Window): void { + const started = view.performance.now(); + + const tick = (now: number) => { + const elapsed = Math.min(1, (now - started) / COUNT_UP_DURATION_MS); + + // Ease out: fast at first, settling on the real figure. + this.progress.set(1 - Math.pow(1 - elapsed, 3)); + + if (elapsed < 1) { + view.requestAnimationFrame(tick); + } + }; + + view.requestAnimationFrame(tick); + } } diff --git a/landing/src/styles.scss b/landing/src/styles.scss index 0d709f4..db0bc12 100644 --- a/landing/src/styles.scss +++ b/landing/src/styles.scss @@ -472,25 +472,69 @@ html.dark-mode .pi-shell { // so a visitor without JavaScript — or one whose hydration failed — reads a // fully visible page instead of a blank column. The media query is a second // belt: the directive already skips the observer when motion is reduced. +// +// A section does not fade as one block: its heading arrives first, then what +// it holds, and the cards of a grid one after another — so reading down the +// page, each section assembles itself as you reach it. // --------------------------------------------------------------------------- -html.reveal-ready .reveal { - opacity: 0; - transform: translateY(18px); - transition: - opacity 0.55s ease-out, - transform 0.55s ease-out; -} +$reveal-ease: cubic-bezier(0.22, 1, 0.36, 1); -html.reveal-ready .reveal.is-revealed { - opacity: 1; - transform: none; +html.reveal-ready .reveal { + .landing-section__head, + .landing-section__head ~ *, + .landing-grid > *, + .landing-issues > li { + opacity: 0; + transform: translateY(24px); + transition: + opacity 0.6s ease-out, + transform 0.7s $reveal-ease; + } + + // Cards also grow into place, which reads as arriving rather than sliding. + .landing-grid > * { + transform: translateY(28px) scale(0.97); + } + + &.is-revealed { + .landing-section__head, + .landing-section__head ~ *, + .landing-grid > *, + .landing-issues > li { + opacity: 1; + transform: none; + } + + .landing-section__head ~ * { + transition-delay: 0.12s; + } + + // One after another; past the eighth they arrive together, so a long grid + // does not keep the reader waiting for its last row. + @for $i from 1 through 8 { + .landing-grid > :nth-child(#{$i}), + .landing-issues > li:nth-child(#{$i}) { + transition-delay: #{0.15 + ($i - 1) * 0.08}s; + } + } + + .landing-grid > :nth-child(n + 9), + .landing-issues > li:nth-child(n + 9) { + transition-delay: 0.8s; + } + } } @media (prefers-reduced-motion: reduce) { html.reveal-ready .reveal { - opacity: 1; - transform: none; - transition: none; + .landing-section__head, + .landing-section__head ~ *, + .landing-grid > *, + .landing-issues > li { + opacity: 1; + transform: none; + transition: none; + } } }