From 1f01907f875981a5931085a15e75d8ffad218a4a Mon Sep 17 00:00:00 2001 From: gnaroshi Date: Sun, 19 Jul 2026 13:21:22 +0900 Subject: [PATCH 1/4] feat: add purposeful native motion --- src/components/LanguageSwitcher.astro | 1 + src/components/SiteHeader.astro | 1 + src/layouts/BaseLayout.astro | 10 +- src/styles/motion.css | 153 ++++++++++++++++++++++++++ src/styles/navigation.css | 46 +++++++- src/styles/tokens.css | 7 +- 6 files changed, 210 insertions(+), 8 deletions(-) create mode 100644 src/styles/motion.css diff --git a/src/components/LanguageSwitcher.astro b/src/components/LanguageSwitcher.astro index eaa0f71..7ebd0db 100644 --- a/src/components/LanguageSwitcher.astro +++ b/src/components/LanguageSwitcher.astro @@ -49,6 +49,7 @@ const noticeId = `translation-status-${mobile ? "mobile" : "desktop"}`; window.addEventListener("popstate", updateTarget); link.addEventListener("click", () => { updateTarget(); + document.getElementById("cross-document-view-transition")?.remove(); try { window.localStorage.setItem("locale", link.lang.startsWith("ko") ? "ko" : "en"); } catch {} }); } diff --git a/src/components/SiteHeader.astro b/src/components/SiteHeader.astro index c9b9f0c..2c207af 100644 --- a/src/components/SiteHeader.astro +++ b/src/components/SiteHeader.astro @@ -26,4 +26,5 @@ const navigationSignature = getPrimaryNavigation(locale) + diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index dcbb4c3..c9f28e1 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -2,6 +2,7 @@ import "../styles/tokens.css"; import "../styles/global.css"; import "../styles/navigation.css"; +import "../styles/motion.css"; import SEO from "../components/SEO.astro"; import SiteFooter from "../components/SiteFooter.astro"; import SiteHeader from "../components/SiteHeader.astro"; @@ -39,6 +40,7 @@ interface Props { articleTags?: string[]; author?: string; minimalChrome?: boolean; + pageProgress?: boolean; } const { @@ -66,7 +68,8 @@ const { modifiedTime, articleTags, author, - minimalChrome = false + minimalChrome = false, + pageProgress = false } = Astro.props; const rssPath = getLocalePath(locale, "/rss.xml"); const contentFeedBuildInfo = getContentFeedBuildInfo(); @@ -110,6 +113,9 @@ const websiteBuildInfo = getWebsiteBuildInfo(); author={author} /> + - + {!minimalChrome && } {!minimalChrome && paperLab && } diff --git a/src/styles/motion.css b/src/styles/motion.css new file mode 100644 index 0000000..0e14d6a --- /dev/null +++ b/src/styles/motion.css @@ -0,0 +1,153 @@ +/* + * Motion explains navigation, progress, and available actions. Everything in + * this file is progressive enhancement; the site remains complete without it. + */ + +::view-transition-old(page-content) { + animation: site-page-out var(--duration-state) var(--ease-standard) both; +} + +::view-transition-new(page-content) { + animation: site-page-in var(--duration-page) var(--ease-emphasized) both; +} + +.site-main { + view-transition-name: page-content; +} + +@keyframes site-page-out { + to { + opacity: 0; + transform: translateY(-4px); + } +} + +@keyframes site-page-in { + from { + opacity: 0; + transform: translateY(6px); + } +} + +.site-header__progress { + position: absolute; + right: 0; + bottom: calc(var(--pixel-border) * -1); + left: 0; + display: none; + height: var(--pixel-border); + transform: scaleX(0); + transform-origin: left center; + background: var(--color-identity-teal); + box-shadow: 3px 0 0 var(--color-identity-orange); + pointer-events: none; +} + +body[data-page-progress="true"] .site-header__progress { + display: block; +} + +@supports (animation-timeline: scroll()) { + body[data-page-progress="true"] .site-header__progress { + animation: site-reading-progress 1ms linear both; + animation-timeline: scroll(root block); + } + + @keyframes site-reading-progress { + to { + transform: scaleX(1); + } + } +} + +.button, +[data-motion-media] img, +[data-motion-surface] { + transition: + border-color var(--duration-state) var(--ease-standard), + box-shadow var(--duration-state) var(--ease-standard), + color var(--duration-state) var(--ease-standard), + background-color var(--duration-state) var(--ease-standard), + transform var(--duration-state) var(--ease-emphasized); +} + +.button:hover { + transform: translate(-1px, -1px); +} + +.button:active { + transform: translate(0, 0); + box-shadow: none; +} + +[data-motion-media] { + isolation: isolate; +} + +[data-motion-media] img { + transform: scale(1); +} + +[data-motion-media]:where(:hover, :focus-visible) img { + transform: scale(1.018); +} + +.mobile-nav__panel:not([hidden]) { + opacity: 1; + transform: translateY(0); + transition: + opacity var(--duration-state) var(--ease-standard), + transform var(--duration-page) var(--ease-emphasized); +} + +@starting-style { + .mobile-nav__panel:not([hidden]) { + opacity: 0; + transform: translateY(-8px); + } + + .media-zoom__dialog[open] { + opacity: 0; + transform: translateY(8px) scale(.99); + } + + .media-zoom__dialog[open]::backdrop { + opacity: 0; + } +} + +.media-zoom__dialog[open] { + opacity: 1; + transform: translateY(0) scale(1); + transition: + opacity var(--duration-state) var(--ease-standard), + transform var(--duration-page) var(--ease-emphasized); +} + +.media-zoom__dialog[open]::backdrop { + opacity: 1; + transition: opacity var(--duration-state) var(--ease-standard); +} + +@media (prefers-reduced-motion: reduce) { + ::view-transition-group(*), + ::view-transition-old(*), + ::view-transition-new(*) { + animation-duration: .001ms; + } + + .site-header__progress, + .button, + [data-motion-media] img, + [data-motion-surface], + .mobile-nav__panel, + .media-zoom__dialog, + .media-zoom__dialog::backdrop { + animation: none; + transition: none; + } + + [data-motion-media]:where(:hover, :focus-visible) img { + transform: none; + } +} diff --git a/src/styles/navigation.css b/src/styles/navigation.css index a21fb31..1093c1e 100644 --- a/src/styles/navigation.css +++ b/src/styles/navigation.css @@ -25,6 +25,7 @@ color: var(--color-text); text-decoration: none; white-space: nowrap; + view-transition-name: site-brand; } .site-header__mark { @@ -64,6 +65,7 @@ .desktop-nav > span, .utility-nav > a, .utility-nav > span { + position: relative; display: inline-grid; min-height: 36px; align-items: center; @@ -74,6 +76,22 @@ white-space: nowrap; } +.desktop-nav a::after, +.desktop-nav > span::after, +.utility-nav > a::after, +.utility-nav > span::after { + position: absolute; + right: 0; + bottom: 2px; + left: 0; + height: 2px; + transform: scaleX(0); + transform-origin: center; + background: var(--color-accent); + content: ""; + transition: transform var(--duration-state) var(--ease-emphasized); +} + .desktop-nav a:hover, .desktop-nav > span[aria-current="page"], .utility-nav > a:hover, @@ -81,9 +99,14 @@ color: var(--color-accent); } -.desktop-nav a[aria-current="page"], -.utility-nav > a[aria-current="page"] { - box-shadow: inset 0 -2px 0 var(--color-accent); +.desktop-nav a:hover::after, +.utility-nav > a:hover::after { + transform: scaleX(.55); +} + +.desktop-nav > span[aria-current="page"]::after, +.utility-nav > span[aria-current="page"]::after { + transform: scaleX(1); } .language-switcher { @@ -226,13 +249,24 @@ display: flex; min-height: 52px; align-items: center; + padding-inline: var(--space-3); border-bottom: 1px solid var(--color-border); color: var(--color-text); font-weight: 700; text-decoration: none; } -.mobile-nav__panel nav > span[aria-current="page"] { color: var(--color-accent); } +.mobile-nav__panel a:hover, +.mobile-nav__panel a:focus-visible { + background: var(--color-surface-muted); + color: var(--color-accent); +} + +.mobile-nav__panel nav > span[aria-current="page"] { + background: var(--color-accent-soft); + color: var(--color-accent); + box-shadow: inset 3px 0 0 var(--color-identity-orange); +} body.mobile-nav-open { overflow: hidden; } .paper-lab-nav { @@ -336,4 +370,8 @@ body.mobile-nav-open { overflow: hidden; } @media (prefers-reduced-motion: reduce) { .theme-toggle__icon { transition: none; } + .desktop-nav a::after, + .desktop-nav > span::after, + .utility-nav > a::after, + .utility-nav > span::after { transition: none; } } diff --git a/src/styles/tokens.css b/src/styles/tokens.css index dd4d2ce..51fa9da 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -11,7 +11,7 @@ --color-surface-quiet: var(--color-surface-muted); --color-text: #18201c; --color-text-secondary: #3f4943; - --color-text-muted: #657169; + --color-text-muted: #5f6b63; --color-border: #d5dbd4; --color-border-strong: #aab5ac; --color-accent: #126954; @@ -99,8 +99,11 @@ --shadow-md: 4px 4px 0 rgb(24 32 28 / 18%); --duration-fast: 140ms; --duration-state: 180ms; + --duration-page: 240ms; + --ease-standard: cubic-bezier(.2, .75, .25, 1); + --ease-emphasized: cubic-bezier(.2, .9, .2, 1); --project-research-accent: #486f96; - --project-application-accent: #78679a; + --project-application-accent: #725f95; --project-infrastructure-accent: #7b6a2e; --gutter: 16px; From e084266e6a259de8da8ca61191a73da6664babd1 Mon Sep 17 00:00:00 2001 From: gnaroshi Date: Sun, 19 Jul 2026 13:21:44 +0900 Subject: [PATCH 2/4] refactor: clarify editorial navigation hierarchy --- src/components/InPageAnchorBehavior.astro | 136 +++++++++++++++--- .../projects/FeaturedApplicationCard.astro | 3 +- .../projects/SupportingApplicationCard.astro | 2 +- src/data/locales/en.ts | 3 + src/data/locales/ko.ts | 3 + src/data/locales/types.ts | 1 + src/styles/home.css | 22 ++- src/styles/projects.css | 95 ++++++++++-- src/views/BlogIndexView.astro | 5 +- src/views/HomeView.astro | 4 +- src/views/ProjectsView.astro | 28 ++-- src/views/ResearchView.astro | 71 ++++++--- 12 files changed, 297 insertions(+), 76 deletions(-) diff --git a/src/components/InPageAnchorBehavior.astro b/src/components/InPageAnchorBehavior.astro index e820e8f..866d504 100644 --- a/src/components/InPageAnchorBehavior.astro +++ b/src/components/InPageAnchorBehavior.astro @@ -14,6 +14,49 @@ } } + function revealCurrentInScroller(current: HTMLElement | null): void { + const scroller = current?.closest("[data-in-page-scroller]"); + const overflows = Boolean(scroller && scroller.scrollWidth > scroller.clientWidth + 1); + scroller?.toggleAttribute("data-overflow", overflows); + if (!current || !scroller || !overflows) return; + const links = [...scroller.querySelectorAll("a[href]")]; + const index = links.indexOf(current); + const currentRect = current.getBoundingClientRect(); + const scrollerRect = scroller.getBoundingClientRect(); + const currentLeft = scroller.scrollLeft + currentRect.left - scrollerRect.left; + const centeredLeft = currentLeft - (scroller.clientWidth - currentRect.width) / 2; + const maximumLeft = scroller.scrollWidth - scroller.clientWidth; + const isBoundaryItem = index <= 0 || index === links.length - 1; + const left = index <= 0 + ? 0 + : index === links.length - 1 + ? maximumLeft + : Math.min(maximumLeft, Math.max(0, centeredLeft)); + requestAnimationFrame(() => scroller.scrollTo({ + left, + behavior: reducedMotion.matches || isBoundaryItem ? "auto" : "smooth" + })); + } + + function setNavigationCurrent(navigation: HTMLElement, locationCurrent: HTMLElement | null): void { + for (const link of navigation.querySelectorAll("[data-in-page-link]")) { + if (link === locationCurrent) link.setAttribute("aria-current", "location"); + else if (link.getAttribute("aria-current") === "location") link.removeAttribute("aria-current"); + } + + const routeCurrent = navigation.querySelector("[data-paper-nav-route-current]"); + if (routeCurrent) { + if (locationCurrent) routeCurrent.removeAttribute("aria-current"); + else routeCurrent.setAttribute("aria-current", "page"); + } + + const current = locationCurrent ?? routeCurrent; + if (navigation.dataset.visibleCurrent !== current?.getAttribute("href")) { + navigation.dataset.visibleCurrent = current?.getAttribute("href") ?? ""; + revealCurrentInScroller(current); + } + } + function updateCurrentAnchor(): void { for (const navigation of document.querySelectorAll("[data-in-page-navigation]")) { let locationCurrent: HTMLElement | null = null; @@ -22,33 +65,62 @@ const isCurrent = normalizedPath(url.pathname) === normalizedPath(window.location.pathname) && url.hash === window.location.hash; if (isCurrent && url.hash) { - link.setAttribute("aria-current", "location"); locationCurrent = link; - } else if (link.getAttribute("aria-current") === "location") { - link.removeAttribute("aria-current"); } } + setNavigationCurrent(navigation, locationCurrent); + } + } - const routeCurrent = navigation.querySelector("[data-paper-nav-route-current]"); - if (routeCurrent) { - if (locationCurrent) routeCurrent.removeAttribute("aria-current"); - else routeCurrent.setAttribute("aria-current", "page"); + function updateScrollSpy(): void { + for (const navigation of document.querySelectorAll("[data-in-page-navigation][data-scroll-spy]")) { + const links = [...navigation.querySelectorAll("[data-in-page-link]")]; + const pairs = links.flatMap((link) => { + const url = new URL(link.href, window.location.href); + const target = normalizedPath(url.pathname) === normalizedPath(window.location.pathname) + ? targetForHash(url.hash) + : null; + return target ? [{ link, target }] : []; + }); + if (pairs.length === 0) continue; + + const focusedHashPair = pairs.find((pair) => ( + `#${encodeURIComponent(pair.target.id)}` === window.location.hash + || `#${pair.target.id}` === window.location.hash + ) && document.activeElement === pair.target); + if (focusedHashPair) { + setNavigationCurrent(navigation, focusedHashPair.link); + continue; } - const current = locationCurrent ?? routeCurrent; - const scroller = current?.closest("[data-in-page-scroller]"); - const overflows = Boolean(scroller && scroller.scrollWidth > scroller.clientWidth + 1); - scroller?.toggleAttribute("data-overflow", overflows); - if (current && scroller && overflows) { - const left = current.offsetLeft - (scroller.clientWidth - current.offsetWidth) / 2; - requestAnimationFrame(() => scroller.scrollTo({ - left: Math.max(0, left), - behavior: reducedMotion.matches ? "auto" : "smooth" - })); + const headerBottom = document.querySelector(".site-header")?.getBoundingClientRect().bottom ?? 0; + const navigationBottom = navigation.getBoundingClientRect().bottom; + const activationLine = Math.max(headerBottom, navigationBottom) + 12; + let current = pairs[0]!; + let currentTop = Number.NEGATIVE_INFINITY; + for (const pair of pairs) { + const top = pair.target.getBoundingClientRect().top; + if (top <= activationLine && top > currentTop + 1) { + current = pair; + currentTop = top; + } + else break; } + const atDocumentEnd = window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2; + if (atDocumentEnd) current = pairs.at(-1)!; + setNavigationCurrent(navigation, current.link); } } + let scrollFrame = 0; + function scheduleScrollSpy(): void { + if (scrollFrame) return; + scrollFrame = requestAnimationFrame(() => { + scrollFrame = 0; + updateScrollSpy(); + }); + } + function exposeTarget(hash: string, moveFocus = true): boolean { const target = targetForHash(hash); if (!target) return false; @@ -62,10 +134,20 @@ } document.addEventListener("click", (event) => { - const link = (event.target as Element | null)?.closest("a[href*='#']"); + const link = (event.target as Element | null)?.closest("a[href]"); if (!link || event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; const url = new URL(link.href, window.location.href); - if (url.origin !== window.location.origin || normalizedPath(url.pathname) !== normalizedPath(window.location.pathname) || !url.hash) return; + if (url.origin !== window.location.origin || normalizedPath(url.pathname) !== normalizedPath(window.location.pathname)) return; + if (!url.hash && window.location.hash && link.matches("[data-paper-nav-route-current]")) { + event.preventDefault(); + history.pushState(null, "", `${url.pathname}${url.search}`); + window.dispatchEvent(new Event("gnaroshi:locationchange")); + window.scrollTo({ top: 0, behavior: reducedMotion.matches ? "auto" : "smooth" }); + link.focus({ preventScroll: true }); + updateCurrentAnchor(); + return; + } + if (!url.hash) return; if (!targetForHash(url.hash)) return; event.preventDefault(); if (window.location.hash !== url.hash) { @@ -78,12 +160,22 @@ window.addEventListener("hashchange", () => exposeTarget(window.location.hash)); window.addEventListener("popstate", () => { if (window.location.hash) exposeTarget(window.location.hash); - else updateCurrentAnchor(); + else { + updateCurrentAnchor(); + scheduleScrollSpy(); + } + }); + window.addEventListener("scroll", scheduleScrollSpy, { passive: true }); + window.addEventListener("resize", () => { + updateCurrentAnchor(); + scheduleScrollSpy(); }); - window.addEventListener("resize", updateCurrentAnchor); requestAnimationFrame(() => { if (window.location.hash) exposeTarget(window.location.hash); - else updateCurrentAnchor(); + else { + updateCurrentAnchor(); + scheduleScrollSpy(); + } }); diff --git a/src/components/projects/FeaturedApplicationCard.astro b/src/components/projects/FeaturedApplicationCard.astro index c0c625b..8c0fa19 100644 --- a/src/components/projects/FeaturedApplicationCard.astro +++ b/src/components/projects/FeaturedApplicationCard.astro @@ -26,10 +26,11 @@ const { project, story, media, locale, href, stackLabel, lead = false } = Astro. class:list={["featured-app", lead && "featured-app--lead", !lead && "featured-app--paired"]} data-project-id={project.id} data-project-kind={project.kind} + data-motion-surface style={`--source-media-ratio:${media.width}/${media.height}`} >