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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Before structural changes, read:
- `docs/projects-content-model.md`
- `docs/projects-editorial-rules.md`
- `docs/projects-tech-stack.md`
- `docs/interaction-motion.md`

## Stack

Expand Down Expand Up @@ -148,6 +149,7 @@ Use shared locale-aware views. Never add `/en/` or `/kr/` routes. Do not render
- Keep application mode limited to public paper workflows and Activity views.
- Use semantic landmarks and heading order.
- Preserve visible focus states, keyboard navigation, contrast, and mobile overflow protection.
- Keep motion tied to navigation, progress, or state; prefer progressive native CSS and preserve the complete reduced-motion experience.
- Keep graph list fallback and heatmap labels accessible.
- Do not expose developer or authoring instructions on public pages.
- Apply the two-second semantic test to every large image: its broad subject must be clear before adjacent copy is read.
Expand Down
13 changes: 8 additions & 5 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,13 +198,16 @@ Minimum requirements:

## Animation

Use little to no animation. Acceptable uses:
Motion must explain destination, progress, or state. The site uses progressive native CSS rather than a general animation framework:

- Small focus or hover transitions.
- Theme transition if subtle.
- Heatmap hover states.
- short same-origin page transitions;
- a scroll-linked reading-progress marker on long editorial pages;
- current-section movement in compact local navigation;
- small focus, hover, pressed, theme, menu, and dialog transitions.

Avoid page-load animation, scroll animation, parallax, and motion-heavy effects.
Do not add automatic loops, custom cursors, decorative parallax, motion-only feedback, or effects that hide content before JavaScript runs. Motion must not change layout dimensions. Unsupported browsers keep a complete static interface, and `prefers-reduced-motion` removes every nonessential transition.

See `docs/interaction-motion.md`.

## Design Acceptance Criteria

Expand Down
61 changes: 61 additions & 0 deletions docs/interaction-motion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Interaction And Motion

This site uses motion to explain navigation, reading progress, and available actions. Motion is progressive enhancement: content, routes, and controls remain complete when a browser does not support a feature or when the visitor requests reduced motion.

## Current Approach

### 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.

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.

### 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.

### Section indexes

Research, Projects, and the long `gnaroshi.dev` case study use compact sticky indexes. The index:

- names real destinations instead of generic sequence labels;
- keeps exactly one current section;
- follows hash navigation, direct loads, history, keyboard activation, and scrolling;
- moves the active item into view on narrow screens;
- leaves the full section content in the document.

### 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.

## Constraints

- No custom cursor.
- No automatic looping animation.
- No parallax or decorative scroll choreography.
- No animation that hides required content before JavaScript runs.
- No transform that changes layout dimensions.
- No motion-only status communication.
- No animation framework for effects supported by native CSS.
- `prefers-reduced-motion: reduce` disables page, control, dialog, menu, and media motion.

## Browser Strategy

View Transitions, scroll-driven animations, and `@starting-style` are used only as progressive enhancements. Unsupported browsers keep normal navigation, a static header, and immediately visible menus and dialogs.

References:

- [MDN: Using the View Transition API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API/Using)
- [MDN: Scroll-driven animation timelines](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Scroll-driven_animations/Timelines)

## Review Checklist

- Does the motion explain destination, progress, or state?
- Does the interface remain clear in a still screenshot?
- Does keyboard activation produce the same result as pointer activation?
- Is exactly one item current within each navigation scope?
- 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?
136 changes: 114 additions & 22 deletions src/components/InPageAnchorBehavior.astro
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,49 @@
}
}

function revealCurrentInScroller(current: HTMLElement | null): void {
const scroller = current?.closest<HTMLElement>("[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<HTMLElement>("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<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>("[data-in-page-navigation]")) {
let locationCurrent: HTMLElement | null = null;
Expand All @@ -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<HTMLElement>("[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<HTMLElement>("[data-in-page-navigation][data-scroll-spy]")) {
const links = [...navigation.querySelectorAll<HTMLAnchorElement>("[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<HTMLElement>("[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<HTMLElement>(".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;
Expand All @@ -62,10 +134,20 @@
}

document.addEventListener("click", (event) => {
const link = (event.target as Element | null)?.closest<HTMLAnchorElement>("a[href*='#']");
const link = (event.target as Element | null)?.closest<HTMLAnchorElement>("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) {
Expand All @@ -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();
}
});
</script>
1 change: 1 addition & 0 deletions src/components/LanguageSwitcher.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
});
}
Expand Down
1 change: 1 addition & 0 deletions src/components/SiteHeader.astro
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ const navigationSignature = getPrimaryNavigation(locale)
<UtilityNav currentPath={currentPath} locale={locale} alternateUrls={alternateUrls} />
<MobileNav currentPath={currentPath} locale={locale} alternateUrls={alternateUrls} />
</div>
<span class="site-header__progress" aria-hidden="true"></span>
</header>
3 changes: 2 additions & 1 deletion src/components/projects/FeaturedApplicationCard.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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}`}
>
<figure class="featured-app__figure">
<a class="featured-app__media" data-card-part="media" href={href}>
<a class="featured-app__media" data-card-part="media" data-motion-media href={href}>
<ResponsiveImage asset={media} locale={locale} sizes={lead ? "(min-width: 1100px) 42vw, 100vw" : "(min-width: 1100px) 38vw, 100vw"} />
</a>
<figcaption><strong>{media.caption[locale]}</strong><span>{media.demoDisclosure[locale]}</span></figcaption>
Expand Down
8 changes: 5 additions & 3 deletions src/components/projects/ProjectScenario.astro
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@ const mediaByStep = new Map(media.map((asset) => [asset.stepId, asset]));
{!withMedia && <p class="scenario__disclosure">{scenario.demoDisclosure}</p>}
</section>
<style>
.scenario{display:grid;gap:var(--space-6);padding-block:var(--space-8);border-block:1px solid var(--color-border)}
.scenario{display:grid;gap:var(--space-6);padding:var(--space-8) var(--space-5);background:var(--color-surface-muted)}
header{display:grid;gap:var(--space-3);max-width:72ch} header>p:not(.eyebrow){color:var(--color-text-secondary);line-height:1.7}
ol{display:grid;gap:var(--space-8);margin:0;padding:0;list-style:none} li{display:grid;gap:var(--space-4)}
ol{display:grid;gap:var(--space-8);margin:0;padding:0;list-style:none} li{position:relative;display:grid;gap:var(--space-4)}
li:not(:last-child)::after{position:absolute;z-index:0;top:2.25rem;bottom:calc(var(--space-8) * -1);left:1.08rem;width:2px;background:var(--color-border-strong);content:""}
.scenario__step{display:grid;grid-template-columns:2.25rem minmax(0,1fr);gap:var(--space-3);align-items:start;max-width:72ch}
.scenario__step>span{display:grid;place-items:center;width:2.25rem;height:2.25rem;border-radius:50%;color:var(--color-surface);background:var(--color-text);font-variant-numeric:tabular-nums}
.scenario__step>span{z-index:1;display:grid;place-items:center;width:2.25rem;height:2.25rem;color:var(--color-surface);background:var(--color-text);font-family:var(--font-mono);font-variant-numeric:tabular-nums;line-height:1;box-shadow:2px 2px 0 var(--color-identity-orange)}
h3{padding-top:.25rem;font-size:var(--step-1)} .scenario__step p,.scenario__disclosure{margin-top:var(--space-2);color:var(--color-text-secondary);line-height:1.7}
figure{display:grid;grid-template-rows:auto auto;margin:0;overflow:hidden;border:1px solid var(--color-border);background:var(--color-surface)} .scenario__media{min-width:0;overflow:hidden}.scenario__media :global(.responsive-media){width:100%;height:100%}.scenario__media :global(img){object-fit:contain}
figcaption{padding:var(--space-3);color:var(--color-text-secondary);font-size:var(--step--1);line-height:1.55;border-top:1px solid var(--color-border)} figcaption span{display:block;margin-top:.2rem}
@media(max-width:479px){.scenario{margin-inline:calc(var(--space-3) * -1);padding-inline:var(--space-3)}}
</style>
23 changes: 15 additions & 8 deletions src/components/projects/ProjectSectionNavigation.astro
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,22 @@ const entries = locale === "ko"
];
---

<nav class="project-section-nav" aria-label={locale === "ko" ? "이 프로젝트의 목차" : "On this project page"} data-in-page-navigation>
{entries.map(([id, label]) => (
<a href={getLocalePath(locale, `/projects/gnaroshi-dev/#${id}`)} data-in-page-link>{label}</a>
))}
<nav class="project-section-nav" aria-label={locale === "ko" ? "이 프로젝트의 목차" : "On this project page"} data-in-page-navigation data-scroll-spy>
<ol data-in-page-scroller>
{entries.map(([id, label], index) => (
<li><a href={getLocalePath(locale, `/projects/gnaroshi-dev/#${id}`)} data-in-page-link data-motion-surface><span aria-hidden="true">{String(index + 1).padStart(2, "0")}</span>{label}</a></li>
))}
</ol>
</nav>

<style>
.project-section-nav { display:flex; min-width:0; gap:var(--space-2); overflow-x:auto; padding-block:var(--space-3); border-block:1px solid var(--color-border); scrollbar-width:thin; }
.project-section-nav a { display:inline-flex; min-height:44px; flex:0 0 auto; align-items:center; padding-inline:var(--space-2); border-bottom:2px solid transparent; color:var(--color-text-secondary); font-size:var(--text-sm); font-weight:700; text-decoration:none; white-space:nowrap; }
.project-section-nav a:hover { color:var(--color-text); }
.project-section-nav a[aria-current="location"] { border-bottom-color:var(--color-accent); color:var(--color-accent); }
.project-section-nav { position:sticky; z-index:12; top:var(--header-height); min-width:0; margin-inline:calc(var(--space-2) * -1); padding:var(--space-2); background:color-mix(in srgb,var(--color-bg) 94%,transparent); box-shadow:0 10px 24px rgb(24 32 28 / 8%); backdrop-filter:blur(12px); }
.project-section-nav ol { display:flex; min-width:0; gap:var(--space-1); padding:0; margin:0; overflow-x:auto; list-style:none; scrollbar-color:var(--color-border-strong) transparent; scrollbar-width:thin; }
.project-section-nav li { display:flex; flex:0 0 auto; }
.project-section-nav a { display:grid; min-height:46px; grid-template-columns:auto auto; align-items:center; gap:var(--space-2); padding-inline:var(--space-3); background:var(--color-surface-muted); color:var(--color-text-secondary); font-size:var(--text-sm); font-weight:700; text-decoration:none; white-space:nowrap; }
.project-section-nav a span { color:var(--color-text-muted); font-family:var(--font-mono); font-size:var(--text-xs); }
.project-section-nav a:hover { background:var(--color-surface-raised); color:var(--color-text); }
.project-section-nav a[aria-current="location"] { background:var(--color-accent-soft); color:var(--color-accent); box-shadow:inset 3px 0 0 var(--color-identity-orange); }
.project-section-nav a[aria-current="location"] span { color:var(--color-accent); }
@media (prefers-reduced-motion:reduce) { .project-section-nav { backdrop-filter:none; } }
</style>
Loading