diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 7a0fe2d..2a87689 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -81,6 +81,7 @@ {"_type":"issue","id":"islandflow-zs0","title":"Migrate terminal UI to smart-money profiles","description":"Migrate apps/web terminal rendering to consume SmartMoneyEvent directly: primary profile, probability ladder, reason codes, and suppression/abstention state, while preserving legacy alert/classifier displays during the bridge.","status":"closed","priority":2,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-04T21:35:23Z","created_by":"dirtydishes","updated_at":"2026-05-05T05:39:58Z","closed_at":"2026-05-05T05:39:58Z","close_reason":"Completed terminal smart-money profile migration","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-igk","title":"Add plan mode","description":"Implement a user-facing plan mode in the application so users can switch into planning before taking action. Scope to be clarified from existing app patterns.","status":"closed","priority":2,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-04T04:22:37Z","created_by":"dirtydishes","updated_at":"2026-05-04T04:26:18Z","started_at":"2026-05-04T04:22:40Z","closed_at":"2026-05-04T04:26:18Z","close_reason":"Implemented as a global pi extension toggled with Shift+P","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-biq","title":"Finish raw live options delivery and filter/backpressure observability","description":"The smart-money signal path and Tape filters are in place, but the next firehose pass should finish server-side selective raw live delivery for options subscriptions and add explicit filtered-out/backpressure observability for API/web counters. This was discovered while landing islandflow-e4r.\n","status":"in_progress","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T20:28:58Z","created_by":"dirtydishes","updated_at":"2026-04-29T03:54:12Z","started_at":"2026-04-29T03:54:12Z","dependencies":[{"issue_id":"islandflow-biq","depends_on_id":"islandflow-e4r","type":"discovered-from","created_at":"2026-04-28T16:28:58Z","created_by":"auto-import","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"islandflow-cwr","title":"polish terminal navigation drawer motion","description":"The shared terminal navigation drawer opens and closes abruptly because it mounts only while open and unmounts immediately on dismiss. Add calm, reduced-motion-safe drawer and backdrop transitions so the mobile navigation feels intentional without slowing task flow. Include validation for open and dismiss behavior if the existing drawer interaction coverage is touched.","status":"closed","priority":3,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-23T23:58:06Z","created_by":"dirtydishes","updated_at":"2026-05-24T00:05:16Z","started_at":"2026-05-23T23:58:17Z","closed_at":"2026-05-24T00:05:16Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-3by","title":"add interaction coverage for terminal navigation drawer","description":"Add browser- or DOM-level coverage for the shared terminal header drawer so open/close behavior, Escape dismissal, backdrop dismissal, and route-change dismissal are exercised beyond pure route helper tests.","status":"open","priority":3,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-23T23:35:57Z","created_by":"dirtydishes","updated_at":"2026-05-23T23:35:57Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-gm0","title":"Default turn-doc diffs to @pierre/diffs","description":"Why this issue exists and what needs to be done\\n\\nUpdate AGENTS.md turn-documentation guidance to prefer @pierre/diffs output with an explicit fallback path when unavailable, and include the related package manifest/lock updates in the same change set.","status":"closed","priority":3,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-23T22:51:57Z","created_by":"dirtydishes","updated_at":"2026-05-23T22:52:23Z","started_at":"2026-05-23T22:52:00Z","closed_at":"2026-05-23T22:52:23Z","close_reason":"completed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-hpf","title":"add anatomy explainer for options print and smart money flow","description":"Create a standalone docs/anatomy.html reference page that explains the end-to-end lifecycle of an options print through enrichment, signal filtering, compute clustering, flow packet creation, smart-money evaluation, classifier hits, alerts, and API/live consumption. The page should be polished, user-readable, and visually strong enough to serve as a reusable reference artifact for both technical and non-technical readers.","notes":"Added docs/anatomy.html as a standalone reference page for the options-print to smart-money pipeline, styled in the repo product register and layered for executive, mixed technical, and operator-level readers. Regenerated docs/index.html so the page is discoverable from the docs surface.","status":"closed","priority":3,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-23T02:18:48Z","created_by":"dirtydishes","updated_at":"2026-05-23T02:24:58Z","started_at":"2026-05-23T02:18:53Z","closed_at":"2026-05-23T02:24:58Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 8c449c1..05bc716 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -19,6 +19,9 @@ --blue: oklch(0.72 0.13 247); --blue-soft: oklch(0.72 0.13 247 / 0.11); --drawer-width: min(320px, calc(100vw - 28px)); + --drawer-enter-duration: 220ms; + --drawer-exit-duration: 180ms; + --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); --topbar-height: 64px; } @@ -103,6 +106,24 @@ input { 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); + will-change: transform, opacity; +} + +.terminal-nav-drawer[data-state="open"] { + opacity: 1; + transform: translate3d(0, 0, 0); +} + +.terminal-nav-drawer[data-state="closing"] { + transform: translate3d(calc(-1 * min(16px, 4vw)), 0, 0); + transition-duration: var(--drawer-exit-duration), var(--drawer-exit-duration), var(--drawer-exit-duration); + pointer-events: none; } .terminal-drawer-backdrop { @@ -112,6 +133,17 @@ input { 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; +} + +.terminal-drawer-backdrop[data-state="closing"] { + transition-duration: var(--drawer-exit-duration); + pointer-events: none; } .terminal-drawer-head { @@ -1992,6 +2024,8 @@ h3 { @media (prefers-reduced-motion: reduce) { .skip-link, + .terminal-nav-drawer, + .terminal-drawer-backdrop, .terminal-nav-link, .terminal-filter-field::before, .terminal-filter-field::after, diff --git a/apps/web/app/terminal.tsx b/apps/web/app/terminal.tsx index 3444320..3896170 100644 --- a/apps/web/app/terminal.tsx +++ b/apps/web/app/terminal.tsx @@ -103,6 +103,7 @@ const LIVE_EQUITIES_SILENT_WARNING_MS = parseBoundedInt( 5 * 60 * 1000 ); const LIVE_FLOW_STALE_MS = 30_000; +const NAV_DRAWER_EXIT_MS = 180; const PINNED_EVIDENCE_TTL_MS = parseBoundedInt( process.env.NEXT_PUBLIC_PINNED_EVIDENCE_TTL_MS, 20 * 60 * 1000, @@ -118,6 +119,13 @@ const PINNED_EVIDENCE_MAX_ITEMS = parseBoundedInt( const NBBO_MAX_AGE_MS = Number(process.env.NEXT_PUBLIC_NBBO_MAX_AGE_MS); const NBBO_MAX_AGE_MS_SAFE = Number.isFinite(NBBO_MAX_AGE_MS) && NBBO_MAX_AGE_MS > 0 ? NBBO_MAX_AGE_MS : 1000; + +type NavDrawerPhase = "closed" | "opening" | "open" | "closing"; + +const prefersReducedMotion = () => + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; const FLOW_FILTER_PRESET = process.env.NEXT_PUBLIC_FLOW_FILTER_PRESET ?? "smart-money"; const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1"]); const CANDLE_INTERVALS = [ @@ -8827,23 +8835,101 @@ function SyntheticControlDock() { export function TerminalAppShell({ children }: { children: ReactNode }) { const state = useTerminalState(); const pathname = usePathname(); - const [drawerOpen, setDrawerOpen] = useState(false); + const [drawerPhase, setDrawerPhase] = useState("closed"); + const drawerFrameRef = useRef(null); + const drawerCloseTimerRef = useRef(null); const tickerFieldId = useId(); const tickerHintId = useId(); const activeNavHref = getTerminalNavCurrentHref(pathname); + const drawerExpanded = drawerPhase === "opening" || drawerPhase === "open"; useEffect(() => { - setDrawerOpen(false); + 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]); useEffect(() => { - if (!drawerOpen) { + return () => { + if (drawerFrameRef.current !== null) { + window.cancelAnimationFrame(drawerFrameRef.current); + } + if (drawerCloseTimerRef.current !== null) { + window.clearTimeout(drawerCloseTimerRef.current); + } + }; + }, []); + + const openNavDrawer = useCallback(() => { + if (drawerFrameRef.current !== null) { + window.cancelAnimationFrame(drawerFrameRef.current); + drawerFrameRef.current = null; + } + if (drawerCloseTimerRef.current !== null) { + window.clearTimeout(drawerCloseTimerRef.current); + drawerCloseTimerRef.current = null; + } + + if (prefersReducedMotion()) { + setDrawerPhase("open"); + return; + } + + setDrawerPhase("opening"); + drawerFrameRef.current = window.requestAnimationFrame(() => { + drawerFrameRef.current = null; + setDrawerPhase("open"); + }); + }, []); + + const closeNavDrawer = useCallback(() => { + if (drawerPhase === "closed" || drawerPhase === "closing") { + return; + } + if (drawerFrameRef.current !== null) { + window.cancelAnimationFrame(drawerFrameRef.current); + drawerFrameRef.current = null; + } + if (drawerCloseTimerRef.current !== null) { + window.clearTimeout(drawerCloseTimerRef.current); + drawerCloseTimerRef.current = null; + } + + if (prefersReducedMotion()) { + setDrawerPhase("closed"); + return; + } + + // Keep the drawer mounted long enough to animate out cleanly. + setDrawerPhase("closing"); + drawerCloseTimerRef.current = window.setTimeout(() => { + drawerCloseTimerRef.current = null; + setDrawerPhase("closed"); + }, NAV_DRAWER_EXIT_MS); + }, [drawerPhase]); + + const toggleNavDrawer = useCallback(() => { + if (drawerExpanded) { + closeNavDrawer(); + return; + } + openNavDrawer(); + }, [closeNavDrawer, drawerExpanded, openNavDrawer]); + + useEffect(() => { + if (!drawerExpanded) { return; } const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { - setDrawerOpen(false); + closeNavDrawer(); } }; @@ -8851,7 +8937,7 @@ export function TerminalAppShell({ children }: { children: ReactNode }) { return () => { document.removeEventListener("keydown", handleKeyDown); }; - }, [drawerOpen]); + }, [closeNavDrawer, drawerExpanded]); return ( @@ -8865,11 +8951,11 @@ export function TerminalAppShell({ children }: { children: ReactNode }) {
- {drawerOpen ? ( + {drawerPhase !== "closed" ? ( <> diff --git a/docs/turns/2026-05-23-smooth-terminal-nav-drawer-motion.html b/docs/turns/2026-05-23-smooth-terminal-nav-drawer-motion.html new file mode 100644 index 0000000..0a5f5e8 --- /dev/null +++ b/docs/turns/2026-05-23-smooth-terminal-nav-drawer-motion.html @@ -0,0 +1,508 @@ + + + + + + Turn Report: Smooth Terminal Navigation Drawer Motion + + + +
+
+

Smooth Terminal Navigation Drawer Motion

+

Created: 2026-05-23 20:02 EDT · Repo: islandflow · Branch: sidebar-redesign

+
+ Beads: islandflow-cwr + Follow-up: islandflow-3by + Scope: terminal shell motion polish +
+
+ +
+
+

Summary

+

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 product’s calm, task-first character and still respects reduced-motion preferences.

+
+ +
+

Changes Made

+
    +
  • Replaced the boolean-only drawer mount logic in apps/web/app/terminal.tsx with a small phase model: closed, opening, open, and closing.
  • +
  • Kept the drawer mounted briefly during close so the exit animation can complete before the DOM node is removed.
  • +
  • Added data-state-driven motion styles in apps/web/app/globals.css for the drawer surface and the backdrop.
  • +
  • Used a fast exponential ease with separate enter and exit timings to make the drawer feel decisive without being harsh.
  • +
  • Made the motion path reduced-motion-safe by disabling transitions in CSS and skipping delayed unmount logic in JavaScript when prefers-reduced-motion is enabled.
  • +
+
+ +
+

Context

+

Islandflow’s 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.

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

Important Implementation Details

+
    +
  • The trigger now derives its aria-expanded state from the drawer phase, so assistive state follows the visible interaction rather than a raw mount boolean.
  • +
  • Escape, backdrop click, and the close button all use the same close path, which means every dismissal route gets the same exit animation behavior.
  • +
  • The route-change effect still hard-resets the drawer to closed, which keeps navigation swaps predictable and avoids carrying a half-finished overlay across pages.
  • +
  • The close phase disables pointer interaction, preventing the backdrop or drawer from intercepting extra clicks while the exit motion finishes.
  • +
+
+ +
+

Relevant Diff Snippets

+
+
+

apps/web/app/terminal.tsx · phase-based drawer lifecycle

+
+
- const [drawerOpen, setDrawerOpen] = useState(false);
+- useEffect(() => {
+-   setDrawerOpen(false);
+- }, [pathname]);
++ type NavDrawerPhase = "closed" | "opening" | "open" | "closing";
++ const [drawerPhase, setDrawerPhase] = useState<NavDrawerPhase>("closed");
++ const drawerExpanded = drawerPhase === "opening" || drawerPhase === "open";
++ const openNavDrawer = useCallback(() => {
++   if (prefersReducedMotion()) {
++     setDrawerPhase("open");
++     return;
++   }
++   setDrawerPhase("opening");
++   drawerFrameRef.current = window.requestAnimationFrame(() => {
++     setDrawerPhase("open");
++   });
++ }, []);
++ const closeNavDrawer = useCallback(() => {
++   if (prefersReducedMotion()) {
++     setDrawerPhase("closed");
++     return;
++   }
++   setDrawerPhase("closing");
++   drawerCloseTimerRef.current = window.setTimeout(() => {
++     setDrawerPhase("closed");
++   }, NAV_DRAWER_EXIT_MS);
++ }, [drawerPhase]);
+
+ +
+

apps/web/app/globals.css · drawer and backdrop motion states

+
+
+ --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;
++   }
++ }
+
+
+

Snippets are rendered client-side with Diffs (diffs.com project) and include inline fallback text for offline viewing.

+
+ +
+

Expected Impact for End-Users

+

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.

+
+ +
+

Validation

+
    +
  • Passed: bun --cwd=apps/web run build
  • +
  • Verified in code: the drawer now has explicit opening and closing phases, shared dismissal handlers, and reduced-motion branching.
  • +
  • Limited: a live browser animation pass was attempted against http://localhost:3000, but the dedicated in-app browser tooling was not callable in this session and transient Playwright access was unavailable without extra setup.
  • +
+
+ +
+

Issues, Limitations, and Mitigations

+
    +
  • This change improves the shell animation itself, but it does not yet add DOM-level interaction tests for the drawer lifecycle.
  • +
  • The route-change dismissal still resets immediately instead of animating closed, which is intentional to avoid overlay state bleeding into the next screen.
  • +
  • The motion tuning is scoped to the navigation drawer only, so any future shell overlays may still need their own consistency pass.
  • +
+
+ +
+

Follow-up Work

+
    +
  • Complete islandflow-3by by adding interaction coverage for open, Escape dismiss, backdrop dismiss, and route-change dismiss behavior.
  • +
  • Consider harmonizing other shell overlays, such as administrative or evidence drawers, around the same motion timing tokens if they begin to feel inconsistent.
  • +
+
+
+
+ + + +