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
11 changes: 8 additions & 3 deletions docs/interaction-motion.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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?
181 changes: 169 additions & 12 deletions src/styles/motion.css
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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%;
}
}

Expand All @@ -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;
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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]) {
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}
}
6 changes: 3 additions & 3 deletions src/styles/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
54 changes: 54 additions & 0 deletions tests/e2e/interaction-audit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down