diff --git a/docs/interaction-motion.md b/docs/interaction-motion.md index 522d6dd..2ea7b0a 100644 --- a/docs/interaction-motion.md +++ b/docs/interaction-motion.md @@ -6,15 +6,19 @@ This site uses motion to explain navigation, reading progress, and available act ### Cross-document navigation -Same-origin page changes opt into the CSS View Transition API with `@view-transition { navigation: auto; }`. Supporting browsers use a short opacity and vertical-position transition. Other browsers perform normal Astro multi-page navigation. +Same-origin page changes opt into the CSS View Transition API with `@view-transition { navigation: auto; }`. Supporting browsers use a perceptible 480-millisecond opacity, position, and scale transition. Other browsers perform normal Astro multi-page navigation. The site mark has a stable transition identity so the persistent brand does not appear to jump between pages. Locale switches opt out before navigation. This avoids carrying a document snapshot across different `lang` trees and keeps reload, focus, and mobile-menu behavior deterministic. +### Page and section entry + +Page headers draw their existing identity rail without moving the heading, while the Home hero uses one-time staggered position animations that work without client-side JavaScript. Text remains at full contrast throughout. Long editorial sections use CSS view timelines where supported, moving from a lower position to their final position as they enter the viewport. Unsupported browsers render the final state immediately. + ### Long-page progress -Research, Projects, and project detail pages show a two-pixel reading-progress line at the bottom of the sticky header. It uses a CSS scroll progress timeline, so progress follows the visitor's scroll without a JavaScript scroll listener. +Research, Projects, and project detail pages show a four-pixel, two-color reading-progress line at the bottom of the sticky header. It uses a CSS scroll progress timeline, so progress follows the visitor's scroll without a JavaScript scroll listener. ### Section indexes @@ -28,7 +32,7 @@ Research, Projects, and the long `gnaroshi.dev` case study use compact sticky in ### State transitions -Buttons, linked evidence media, theme state, the mobile menu, and media dialogs use short transitions. These transitions identify an available action or a state change; they do not run on a loop. +Buttons and navigable surfaces lift by three pixels. Linked evidence media scales by 5.5 percent and exposes an inset focus frame on hover or keyboard focus. Theme state, the mobile menu, and media dialogs use visible state transitions. These transitions identify an available action or a state change; they do not run on a loop. ## Constraints @@ -59,3 +63,4 @@ References: - Does the interaction preserve focus and sticky-header offset? - Does reduced-motion remove the effect without removing feedback? - Is mobile horizontal navigation discoverable and is the active item fully visible? +- Can a reviewer perceive each intended effect at normal speed without being told where to look? diff --git a/src/styles/motion.css b/src/styles/motion.css index 0e14d6a..06fe7ec 100644 --- a/src/styles/motion.css +++ b/src/styles/motion.css @@ -4,7 +4,7 @@ */ ::view-transition-old(page-content) { - animation: site-page-out var(--duration-state) var(--ease-standard) both; + animation: site-page-out 320ms var(--ease-standard) both; } ::view-transition-new(page-content) { @@ -17,15 +17,96 @@ @keyframes site-page-out { to { - opacity: 0; - transform: translateY(-4px); + opacity: .35; + transform: translateY(-12px) scale(.995); } } @keyframes site-page-in { from { - opacity: 0; - transform: translateY(6px); + opacity: .25; + transform: translateY(18px) scale(.99); + } +} + +@keyframes site-content-enter { + from { + transform: translateY(24px); + } +} + +@keyframes site-hero-image-enter { + from { + opacity: .55; + transform: scale(1.075); + } +} + +@keyframes site-hero-copy-enter { + from { + transform: translateX(-28px); + } +} + +@keyframes site-section-reveal { + from { + transform: translateY(42px); + } +} + +@keyframes site-page-rail-enter { + from { + background-size: 4px 0; + } +} + +.page-header { + border-inline-start-color: transparent; + background: + linear-gradient(var(--color-identity-orange), var(--color-identity-orange)) + no-repeat + left top / 4px 100%; + animation: site-page-rail-enter 700ms var(--ease-emphasized) both; +} + +.page-shell > nav[data-in-page-navigation] { + animation: site-content-enter 620ms 300ms var(--ease-emphasized) both; +} + +.identity-hero__media img { + animation: site-hero-image-enter 1000ms var(--ease-emphasized) both; +} + +.identity-hero__copy > * { + animation: site-hero-copy-enter 640ms var(--ease-emphasized) both; +} + +.identity-hero__copy > :nth-child(1) { animation-delay: 100ms; } +.identity-hero__copy > :nth-child(2) { animation-delay: 180ms; } +.identity-hero__copy > :nth-child(3) { animation-delay: 260ms; } +.identity-hero__copy > :nth-child(4) { animation-delay: 340ms; } +.identity-hero__copy > :nth-child(n + 5) { animation-delay: 420ms; } + +@supports (animation-timeline: view()) { + :where( + .content-section, + .research-theme, + .project-section, + .project-detail .evidence-section, + .project-detail .at-a-glance, + .project-detail .prose-section, + .project-detail .scenario, + .project-detail .split-section, + .project-detail .tech-section, + .project-detail .technical-facts, + .project-detail .project-links + ) { + animation-name: site-section-reveal; + animation-duration: 1ms; + animation-timing-function: linear; + animation-fill-mode: both; + animation-timeline: view(block); + animation-range: entry 0% cover 28%; } } @@ -35,11 +116,15 @@ bottom: calc(var(--pixel-border) * -1); left: 0; display: none; - height: var(--pixel-border); + height: 4px; transform: scaleX(0); transform-origin: left center; - background: var(--color-identity-teal); - box-shadow: 3px 0 0 var(--color-identity-orange); + background: linear-gradient( + to right, + var(--color-identity-teal) 0 82%, + var(--color-identity-orange) 82% 100% + ); + box-shadow: 0 1px 0 color-mix(in srgb, var(--color-bg) 55%, transparent); pointer-events: none; } @@ -72,7 +157,8 @@ body[data-page-progress="true"] .site-header__progress { } .button:hover { - transform: translate(-1px, -1px); + transform: translate(-3px, -3px); + box-shadow: 4px 4px 0 color-mix(in srgb, var(--color-text) 24%, transparent); } .button:active { @@ -81,15 +167,60 @@ body[data-page-progress="true"] .site-header__progress { } [data-motion-media] { + position: relative; isolation: isolate; } +[data-motion-media]::after { + position: absolute; + inset: var(--space-3); + border: 2px solid color-mix(in srgb, var(--color-identity-teal) 82%, white); + opacity: 0; + transform: scale(.96); + box-shadow: 4px 4px 0 color-mix(in srgb, var(--color-identity-orange) 78%, transparent); + content: ""; + pointer-events: none; + transition: + opacity var(--duration-state) var(--ease-standard), + transform var(--duration-page) var(--ease-emphasized); +} + [data-motion-media] img { transform: scale(1); } -[data-motion-media]:where(:hover, :focus-visible) img { - transform: scale(1.018); +@media (hover: hover) { + [data-motion-media]:hover img { + transform: scale(1.055); + } + + [data-motion-media]:hover::after { + opacity: 1; + transform: scale(1); + } + + [data-motion-surface]:not([aria-current]):hover { + transform: translateY(-3px); + box-shadow: var(--shadow-md); + } +} + +[data-motion-media]:focus-visible img { + transform: scale(1.055); +} + +[data-motion-media]:focus-visible::after { + opacity: 1; + transform: scale(1); +} + +[data-motion-surface]:not([aria-current]):focus-visible { + transform: translateY(-3px); + box-shadow: var(--shadow-md); +} + +[data-motion-surface]:active { + transform: translateY(0) scale(.985); } .mobile-nav__panel:not([hidden]) { @@ -137,8 +268,24 @@ body[data-page-progress="true"] .site-header__progress { } .site-header__progress, + .page-header, + .page-shell > nav[data-in-page-navigation], + .identity-hero__media img, + .identity-hero__copy > *, + .content-section, + .research-theme, + .project-section, + .project-detail .evidence-section, + .project-detail .at-a-glance, + .project-detail .prose-section, + .project-detail .scenario, + .project-detail .split-section, + .project-detail .tech-section, + .project-detail .technical-facts, + .project-detail .project-links, .button, [data-motion-media] img, + [data-motion-media]::after, [data-motion-surface], .mobile-nav__panel, .media-zoom__dialog, @@ -148,6 +295,16 @@ body[data-page-progress="true"] .site-header__progress { } [data-motion-media]:where(:hover, :focus-visible) img { - transform: none; + transform: none !important; + } + + [data-motion-media]:where(:hover, :focus-visible)::after { + opacity: 0 !important; + transform: none !important; + } + + [data-motion-surface]:where(:hover, :focus-visible, :active), + .button:where(:hover, :active) { + transform: none !important; } } diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 51fa9da..f418ed9 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -97,9 +97,9 @@ --pixel-border: 2px; --shadow-sm: 2px 2px 0 rgb(24 32 28 / 12%); --shadow-md: 4px 4px 0 rgb(24 32 28 / 18%); - --duration-fast: 140ms; - --duration-state: 180ms; - --duration-page: 240ms; + --duration-fast: 160ms; + --duration-state: 240ms; + --duration-page: 480ms; --ease-standard: cubic-bezier(.2, .75, .25, 1); --ease-emphasized: cubic-bezier(.2, .9, .2, 1); --project-research-accent: #486f96; diff --git a/tests/e2e/interaction-audit.spec.ts b/tests/e2e/interaction-audit.spec.ts index 5f902a3..7c9e999 100644 --- a/tests/e2e/interaction-audit.spec.ts +++ b/tests/e2e/interaction-audit.spec.ts @@ -316,6 +316,60 @@ test("long editorial pages expose native scroll progress and stop motion on requ await expect(page.locator("body")).not.toHaveAttribute("data-page-progress", "true"); }); +test("signature motion is perceptible and remains optional", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.goto("/"); + + const heroTitle = page.locator(".identity-hero h1"); + await expect(heroTitle).toHaveCSS("animation-name", "site-hero-copy-enter"); + const heroDuration = await heroTitle.evaluate((element) => { + const value = getComputedStyle(element).animationDuration.split(",")[0]!.trim(); + return value.endsWith("ms") ? Number.parseFloat(value) : Number.parseFloat(value) * 1000; + }); + expect(heroDuration).toBeGreaterThanOrEqual(600); + + await page.goto("/about/"); + const pageHeader = page.locator(".page-header"); + await expect(pageHeader).toHaveCSS("animation-name", "site-page-rail-enter"); + const railSizes = await pageHeader.evaluate((element) => { + const [animation] = element.getAnimations(); + if (!animation) return null; + animation.pause(); + animation.currentTime = 0; + const start = getComputedStyle(element).backgroundSize; + animation.currentTime = animation.effect?.getTiming().duration as number; + const end = getComputedStyle(element).backgroundSize; + return { start, end }; + }); + expect(railSizes).not.toBeNull(); + expect(railSizes!.start).not.toBe(railSizes!.end); + + await page.goto("/"); + const evidence = page.locator("[data-motion-media]").first(); + await evidence.scrollIntoViewIfNeeded(); + await evidence.hover(); + await expect.poll(() => evidence.locator("img").evaluate((image) => new DOMMatrix(getComputedStyle(image).transform).a)).toBeGreaterThan(1.04); + await expect.poll(() => evidence.evaluate((element) => Number.parseFloat(getComputedStyle(element, "::after").opacity))).toBeGreaterThan(.8); + + await page.goto("/research/"); + await expect(page.locator(".site-header__progress")).toHaveCSS("height", "4px"); + const sectionAnimation = await page.locator(".research-theme").first().evaluate((element) => ({ + name: getComputedStyle(element).animationName, + timeline: getComputedStyle(element).animationTimeline + })); + if (sectionAnimation.timeline !== "auto") expect(sectionAnimation.name).toContain("site-section-reveal"); + + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto("/"); + await expect(page.locator(".identity-hero h1")).toHaveCSS("animation-name", "none"); + const reducedEvidence = page.locator("[data-motion-media]").first(); + await reducedEvidence.scrollIntoViewIfNeeded(); + await reducedEvidence.hover(); + await expect(reducedEvidence.locator("img")).toHaveCSS("transform", "none"); + await expect.poll(() => reducedEvidence.evaluate((element) => Number.parseFloat(getComputedStyle(element, "::after").opacity))).toBe(0); +}); + test("Research navigation restores direct and history-managed locations", async ({ page }) => { for (const route of ["/research/", "/ko/research/"]) { await page.goto(route);