islandflow/docs/turns/2026-05-23-smooth-terminal-nav-drawer-motion.html

508 lines
17 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Turn Report: Smooth Terminal Navigation Drawer Motion</title>
<style>
:root {
color-scheme: dark;
--bg: #06080b;
--panel: #111820;
--panel-2: #0d141b;
--text: #e6edf4;
--muted: #90a0b2;
--faint: #6e7b8c;
--accent: #f5a623;
--accent-soft: rgba(245, 166, 35, 0.12);
--border: rgba(255, 255, 255, 0.08);
--border-strong: rgba(255, 177, 48, 0.3);
--ok: #25c17a;
--warn: #ffd599;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(245, 166, 35, 0.08), transparent 30%),
linear-gradient(180deg, #0b1016 0%, #06080b 100%);
line-height: 1.6;
}
main {
max-width: 1120px;
margin: 0 auto;
padding: 28px 18px 42px;
}
header {
padding: 20px;
border: 1px solid var(--border);
border-radius: 18px;
background: linear-gradient(180deg, rgba(17, 24, 32, 0.96), rgba(13, 20, 27, 0.96));
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.3);
}
h1, h2 {
margin: 0;
font-family: "Quantico", "IBM Plex Sans", sans-serif;
text-transform: uppercase;
letter-spacing: 0.06em;
}
h1 {
font-size: clamp(1.4rem, 2.5vw, 2.15rem);
}
h2 {
margin-bottom: 0.8rem;
font-size: 1rem;
color: #f2f6fb;
}
.meta {
margin-top: 0.55rem;
color: var(--muted);
font-size: 0.95rem;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
margin-top: 0.85rem;
}
.chip {
padding: 0.28rem 0.68rem;
border-radius: 999px;
border: 1px solid var(--border-strong);
background: var(--accent-soft);
color: var(--text);
font-size: 0.82rem;
font-family: "IBM Plex Mono", ui-monospace, monospace;
}
.grid {
display: grid;
gap: 1rem;
margin-top: 1rem;
}
section {
padding: 1rem 1.05rem;
border: 1px solid var(--border);
border-radius: 16px;
background: color-mix(in oklab, var(--panel) 90%, black);
}
p {
margin: 0.45rem 0;
}
ul {
margin: 0.45rem 0 0;
padding-left: 1.2rem;
}
li + li {
margin-top: 0.35rem;
}
code {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.92em;
padding: 0.08rem 0.32rem;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.05);
color: #f2f6fb;
}
.callout {
margin-top: 0.8rem;
padding: 0.8rem 0.9rem;
border-radius: 12px;
border: 1px solid rgba(255, 177, 48, 0.28);
background: rgba(245, 166, 35, 0.08);
color: #f5e6c8;
}
.good {
color: var(--ok);
}
.warn {
color: var(--warn);
}
.diff-grid {
display: grid;
gap: 0.85rem;
}
.diff-shell {
border: 1px solid rgba(255, 177, 48, 0.22);
border-radius: 12px;
overflow: hidden;
background: #0b1118;
}
.diff-title {
margin: 0;
padding: 0.65rem 0.8rem;
border-bottom: 1px solid rgba(255, 177, 48, 0.18);
background: rgba(245, 166, 35, 0.08);
color: #f2f6fb;
font-size: 0.88rem;
font-family: "IBM Plex Mono", ui-monospace, monospace;
}
.diff-view {
padding: 0.35rem;
}
.diff-fallback {
margin: 0;
padding: 0.8rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
white-space: pre-wrap;
overflow-x: auto;
font-size: 0.84rem;
font-family: "IBM Plex Mono", ui-monospace, monospace;
color: #dbe6f6;
background: #0b1118;
}
.diff-shell.rendered .diff-fallback {
display: none;
}
.note {
margin-top: 0.7rem;
color: var(--muted);
font-size: 0.92rem;
}
</style>
</head>
<body>
<main>
<header>
<h1>Smooth Terminal Navigation Drawer Motion</h1>
<p class="meta">Created: 2026-05-23 20:02 EDT · Repo: <code>islandflow</code> · Branch: <code>sidebar-redesign</code></p>
<div class="chips">
<span class="chip">Beads: islandflow-cwr</span>
<span class="chip">Follow-up: islandflow-3by</span>
<span class="chip">Scope: terminal shell motion polish</span>
</div>
</header>
<div class="grid">
<section>
<h2>Summary</h2>
<p>The terminal navigation drawer now enters and exits with a short, controlled slide-and-fade instead of appearing and disappearing abruptly. The interaction stays aligned with the products calm, task-first character and still respects reduced-motion preferences.</p>
</section>
<section>
<h2>Changes Made</h2>
<ul>
<li>Replaced the boolean-only drawer mount logic in <code>apps/web/app/terminal.tsx</code> with a small phase model: <code>closed</code>, <code>opening</code>, <code>open</code>, and <code>closing</code>.</li>
<li>Kept the drawer mounted briefly during close so the exit animation can complete before the DOM node is removed.</li>
<li>Added <code>data-state</code>-driven motion styles in <code>apps/web/app/globals.css</code> for the drawer surface and the backdrop.</li>
<li>Used a fast exponential ease with separate enter and exit timings to make the drawer feel decisive without being harsh.</li>
<li>Made the motion path reduced-motion-safe by disabling transitions in CSS and skipping delayed unmount logic in JavaScript when <code>prefers-reduced-motion</code> is enabled.</li>
</ul>
</section>
<section>
<h2>Context</h2>
<p>Islandflows product context calls for composure under volatility, precise feedback, and minimal ornamental motion. The previous drawer behavior broke that by popping in and out instantly, which felt more like a state glitch than an intentional navigation reveal.</p>
<div class="callout">The goal here was not to make the drawer dramatic. It was to make it legible: a short spatial transition that confirms where the panel comes from and where it goes.</div>
</section>
<section>
<h2>Important Implementation Details</h2>
<ul>
<li>The trigger now derives its <code>aria-expanded</code> state from the drawer phase, so assistive state follows the visible interaction rather than a raw mount boolean.</li>
<li>Escape, backdrop click, and the close button all use the same close path, which means every dismissal route gets the same exit animation behavior.</li>
<li>The route-change effect still hard-resets the drawer to <code>closed</code>, which keeps navigation swaps predictable and avoids carrying a half-finished overlay across pages.</li>
<li>The close phase disables pointer interaction, preventing the backdrop or drawer from intercepting extra clicks while the exit motion finishes.</li>
</ul>
</section>
<section>
<h2>Relevant Diff Snippets</h2>
<div class="diff-grid">
<div class="diff-shell" id="diff-shell-1">
<p class="diff-title">apps/web/app/terminal.tsx · phase-based drawer lifecycle</p>
<div class="diff-view" id="diff-1"></div>
<pre class="diff-fallback"><code>- const [drawerOpen, setDrawerOpen] = useState(false);
- useEffect(() =&gt; {
- setDrawerOpen(false);
- }, [pathname]);
+ type NavDrawerPhase = "closed" | "opening" | "open" | "closing";
+ const [drawerPhase, setDrawerPhase] = useState&lt;NavDrawerPhase&gt;("closed");
+ const drawerExpanded = drawerPhase === "opening" || drawerPhase === "open";
+ const openNavDrawer = useCallback(() =&gt; {
+ if (prefersReducedMotion()) {
+ setDrawerPhase("open");
+ return;
+ }
+ setDrawerPhase("opening");
+ drawerFrameRef.current = window.requestAnimationFrame(() =&gt; {
+ setDrawerPhase("open");
+ });
+ }, []);
+ const closeNavDrawer = useCallback(() =&gt; {
+ if (prefersReducedMotion()) {
+ setDrawerPhase("closed");
+ return;
+ }
+ setDrawerPhase("closing");
+ drawerCloseTimerRef.current = window.setTimeout(() =&gt; {
+ setDrawerPhase("closed");
+ }, NAV_DRAWER_EXIT_MS);
+ }, [drawerPhase]);</code></pre>
</div>
<div class="diff-shell" id="diff-shell-2">
<p class="diff-title">apps/web/app/globals.css · drawer and backdrop motion states</p>
<div class="diff-view" id="diff-2"></div>
<pre class="diff-fallback"><code>+ --drawer-enter-duration: 220ms;
+ --drawer-exit-duration: 180ms;
+ --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
+ .terminal-nav-drawer {
+ opacity: 0;
+ transform: translate3d(calc(-1 * min(22px, 6vw)), 0, 0);
+ transition:
+ transform var(--drawer-enter-duration) var(--ease-out-expo),
+ opacity var(--drawer-enter-duration) var(--ease-out-expo);
+ }
+ .terminal-nav-drawer[data-state="open"] {
+ opacity: 1;
+ transform: translate3d(0, 0, 0);
+ }
+ .terminal-nav-drawer[data-state="closing"] {
+ transition-duration: var(--drawer-exit-duration), var(--drawer-exit-duration), var(--drawer-exit-duration);
+ pointer-events: none;
+ }
+ .terminal-drawer-backdrop[data-state="open"] {
+ opacity: 1;
+ }
+ @media (prefers-reduced-motion: reduce) {
+ .terminal-nav-drawer,
+ .terminal-drawer-backdrop {
+ transition: none;
+ }
+ }</code></pre>
</div>
</div>
<p class="note">Snippets are rendered client-side with Diffs (diffs.com project) and include inline fallback text for offline viewing.</p>
</section>
<section>
<h2>Expected Impact for End-Users</h2>
<p>Users opening the navigation drawer should now see a calmer reveal that feels attached to the shell instead of pasted over it. Closing the drawer should feel cleaner as well, especially on mobile-width layouts where the abrupt unmount was most noticeable.</p>
</section>
<section>
<h2>Validation</h2>
<ul>
<li><span class="good">Passed:</span> <code>bun --cwd=apps/web run build</code></li>
<li><span class="good">Verified in code:</span> the drawer now has explicit <code>opening</code> and <code>closing</code> phases, shared dismissal handlers, and reduced-motion branching.</li>
<li><span class="warn">Limited:</span> a live browser animation pass was attempted against <code>http://localhost:3000</code>, but the dedicated in-app browser tooling was not callable in this session and transient Playwright access was unavailable without extra setup.</li>
</ul>
</section>
<section>
<h2>Issues, Limitations, and Mitigations</h2>
<ul>
<li>This change improves the shell animation itself, but it does not yet add DOM-level interaction tests for the drawer lifecycle.</li>
<li>The route-change dismissal still resets immediately instead of animating closed, which is intentional to avoid overlay state bleeding into the next screen.</li>
<li>The motion tuning is scoped to the navigation drawer only, so any future shell overlays may still need their own consistency pass.</li>
</ul>
</section>
<section>
<h2>Follow-up Work</h2>
<ul>
<li>Complete <code>islandflow-3by</code> by adding interaction coverage for open, Escape dismiss, backdrop dismiss, and route-change dismiss behavior.</li>
<li>Consider harmonizing other shell overlays, such as administrative or evidence drawers, around the same motion timing tokens if they begin to feel inconsistent.</li>
</ul>
</section>
</div>
</main>
<script type="module">
const snippets = [
{
shellId: "diff-shell-1",
containerId: "diff-1",
name: "apps/web/app/terminal.tsx",
oldContents: `const pathname = usePathname();
const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => {
setDrawerOpen(false);
}, [pathname]);
useEffect(() => {
if (!drawerOpen) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setDrawerOpen(false);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [drawerOpen]);`,
newContents: `const pathname = usePathname();
const [drawerPhase, setDrawerPhase] = useState<NavDrawerPhase>("closed");
const drawerFrameRef = useRef<number | null>(null);
const drawerCloseTimerRef = useRef<number | null>(null);
const drawerExpanded = drawerPhase === "opening" || drawerPhase === "open";
useEffect(() => {
if (drawerFrameRef.current !== null) {
window.cancelAnimationFrame(drawerFrameRef.current);
drawerFrameRef.current = null;
}
if (drawerCloseTimerRef.current !== null) {
window.clearTimeout(drawerCloseTimerRef.current);
drawerCloseTimerRef.current = null;
}
setDrawerPhase("closed");
}, [pathname]);
const openNavDrawer = useCallback(() => {
if (prefersReducedMotion()) {
setDrawerPhase("open");
return;
}
setDrawerPhase("opening");
drawerFrameRef.current = window.requestAnimationFrame(() => {
drawerFrameRef.current = null;
setDrawerPhase("open");
});
}, []);
const closeNavDrawer = useCallback(() => {
if (prefersReducedMotion()) {
setDrawerPhase("closed");
return;
}
setDrawerPhase("closing");
drawerCloseTimerRef.current = window.setTimeout(() => {
drawerCloseTimerRef.current = null;
setDrawerPhase("closed");
}, NAV_DRAWER_EXIT_MS);
}, [drawerPhase]);`
},
{
shellId: "diff-shell-2",
containerId: "diff-2",
name: "apps/web/app/globals.css",
oldContents: `.terminal-nav-drawer {
position: fixed;
inset: 0 auto 0 0;
z-index: 45;
width: var(--drawer-width);
padding: 20px 18px 18px;
display: flex;
flex-direction: column;
gap: 20px;
background: linear-gradient(180deg, oklch(0.16 0.012 250 / 0.98), oklch(0.13 0.011 250 / 0.98));
border-right: 1px solid var(--border);
box-shadow: 0 28px 72px rgba(0, 0, 0, 0.48);
}
.terminal-drawer-backdrop {
position: fixed;
inset: 0;
z-index: 40;
border: 0;
background: rgba(3, 5, 8, 0.62);
cursor: pointer;
}`,
newContents: `:root {
--drawer-enter-duration: 220ms;
--drawer-exit-duration: 180ms;
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
}
.terminal-nav-drawer {
position: fixed;
inset: 0 auto 0 0;
z-index: 45;
width: var(--drawer-width);
padding: 20px 18px 18px;
display: flex;
flex-direction: column;
gap: 20px;
background: linear-gradient(180deg, oklch(0.16 0.012 250 / 0.98), oklch(0.13 0.011 250 / 0.98));
border-right: 1px solid var(--border);
box-shadow: 0 28px 72px rgba(0, 0, 0, 0.48);
opacity: 0;
transform: translate3d(calc(-1 * min(22px, 6vw)), 0, 0);
transition:
transform var(--drawer-enter-duration) var(--ease-out-expo),
opacity var(--drawer-enter-duration) var(--ease-out-expo),
box-shadow var(--drawer-enter-duration) var(--ease-out-expo);
}
.terminal-nav-drawer[data-state="open"] {
opacity: 1;
transform: translate3d(0, 0, 0);
}
.terminal-nav-drawer[data-state="closing"] {
transition-duration: var(--drawer-exit-duration), var(--drawer-exit-duration), var(--drawer-exit-duration);
pointer-events: none;
}
.terminal-drawer-backdrop {
position: fixed;
inset: 0;
z-index: 40;
border: 0;
background: rgba(3, 5, 8, 0.62);
cursor: pointer;
opacity: 0;
transition: opacity var(--drawer-enter-duration) var(--ease-out-expo);
}
.terminal-drawer-backdrop[data-state="open"] {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.terminal-nav-drawer,
.terminal-drawer-backdrop {
transition: none;
}
}`
}
];
try {
const { FileDiff } = await import("https://esm.sh/@pierre/diffs");
for (const snippet of snippets) {
const container = document.getElementById(snippet.containerId);
const shell = document.getElementById(snippet.shellId);
if (!container || !shell) {
continue;
}
const instance = new FileDiff({
theme: { dark: "pierre-dark", light: "pierre-light" },
diffStyle: "split"
});
instance.render({
oldFile: {
name: snippet.name,
contents: snippet.oldContents
},
newFile: {
name: snippet.name,
contents: snippet.newContents
},
containerWrapper: container
});
shell.classList.add("rendered");
}
} catch (error) {
console.warn("Failed to render diff snippets with Diffs.", error);
}
</script>
</body>
</html>