diff --git a/src/app/globals.css b/src/app/globals.css index f501c83..bdeaba3 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -17,9 +17,24 @@ --surface-2: #1f1f1f; --nav-bg: #121212; --text: #ffffff; - --text-secondary: rgba(255, 255, 255, 0.80); /* 5.7:1 — AA body text, subtitles */ - --text-muted: rgba(255, 255, 255, 0.56); /* 3.8:1 — AA large text only (18px+) */ - --text-dim: rgba(255, 255, 255, 0.34); /* 2.3:1 — decorative only, never body */ + --text-secondary: rgba( + 255, + 255, + 255, + 0.8 + ); /* 5.7:1 — AA body text, subtitles */ + --text-muted: rgba( + 255, + 255, + 255, + 0.56 + ); /* 3.8:1 — AA large text only (18px+) */ + --text-dim: rgba( + 255, + 255, + 255, + 0.34 + ); /* 2.3:1 — decorative only, never body */ --border: rgba(220, 219, 214, 0.12); --border-strong: rgba(220, 219, 214, 0.18); /** Brand dark teal — icons on white chips, light-mode accent. */ @@ -49,9 +64,14 @@ --surface-2: #f6f6f4; --nav-bg: #ffffff; --text: #0a0a0a; - --text-secondary: rgba(10, 10, 10, 0.72); /* 5.0:1 — AA body text, subtitles */ - --text-muted: rgba(10, 10, 10, 0.56); /* 3.8:1 — AA large text only (18px+) */ - --text-dim: rgba(10, 10, 10, 0.36); /* 2.4:1 — decorative only, never body */ + --text-secondary: rgba( + 10, + 10, + 10, + 0.72 + ); /* 5.0:1 — AA body text, subtitles */ + --text-muted: rgba(10, 10, 10, 0.56); /* 3.8:1 — AA large text only (18px+) */ + --text-dim: rgba(10, 10, 10, 0.36); /* 2.4:1 — decorative only, never body */ --border: rgba(45, 45, 42, 0.12); --border-strong: rgba(45, 45, 42, 0.17); --accent-solid: #006c66; @@ -98,10 +118,12 @@ --shadow-editor: var(--editor-shadow); - --font-sans: var(--font-geist-sans), "Geist", "Geist Fallback", -apple-system, - system-ui, sans-serif; - --font-mono: var(--font-geist-mono), "Geist Mono", "Geist Mono Fallback", - ui-monospace, monospace; + --font-sans: + var(--font-geist-sans), "Geist", "Geist Fallback", -apple-system, system-ui, + sans-serif; + --font-mono: + var(--font-geist-mono), "Geist Mono", "Geist Mono Fallback", ui-monospace, + monospace; } @layer base { @@ -117,12 +139,17 @@ -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; overflow-x: hidden; - transition: background 0.5s ease, color 0.5s ease; + transition: + background 0.5s ease, + color 0.5s ease; } /* The app only ever renders the editor shell, which owns the viewport. */ + html:has(.editor-shell), body:has(.editor-shell) { + height: 100%; overflow: hidden; + overscroll-behavior: none; } /* The footer is rendered by the root layout on every route, but the app diff --git a/src/components/layout/EditorShell.tsx b/src/components/layout/EditorShell.tsx index 608080f..6892ba7 100644 --- a/src/components/layout/EditorShell.tsx +++ b/src/components/layout/EditorShell.tsx @@ -1,25 +1,127 @@ -import type { ReactNode } from "react"; +"use client"; + +import { useRef, useState, type PointerEvent, type ReactNode } from "react"; + +/** Id of the mobile controls drawer, for aria-controls on its triggers. */ +export const EDITOR_CONTROLS_ID = "editor-controls"; + +/** Drag distance (px) or flick speed (px/ms) that dismisses the sheet. */ +const DISMISS_DISTANCE = 80; +const DISMISS_VELOCITY = 0.5; type EditorShellProps = { header: ReactNode; sidebar: ReactNode; preview: ReactNode; + /** Whether the mobile controls drawer is open (<=900px only). */ + controlsOpen: boolean; + onCloseControls: () => void; }; -export function EditorShell({ header, sidebar, preview }: EditorShellProps) { +export function EditorShell({ + header, + sidebar, + preview, + controlsOpen, + onCloseControls, +}: EditorShellProps) { + const drag = useRef<{ + startY: number; + lastY: number; + lastT: number; + v: number; + } | null>(null); + const moved = useRef(false); + const [dragY, setDragY] = useState(null); + + const onHandleDown = (event: PointerEvent) => { + event.currentTarget.setPointerCapture(event.pointerId); + drag.current = { + startY: event.clientY, + lastY: event.clientY, + lastT: event.timeStamp, + v: 0, + }; + moved.current = false; + setDragY(0); + }; + + const onHandleMove = (event: PointerEvent) => { + const state = drag.current; + if (!state) return; + const dy = event.clientY - state.startY; + if (Math.abs(dy) > 4) moved.current = true; + const dt = event.timeStamp - state.lastT; + if (dt > 0) state.v = (event.clientY - state.lastY) / dt; + state.lastY = event.clientY; + state.lastT = event.timeStamp; + // There is nothing above the open position to reveal, so up only rubber-bands. + setDragY(dy > 0 ? dy : Math.max(-24, dy / 3)); + }; + + const onHandleUp = (event: PointerEvent) => { + const state = drag.current; + drag.current = null; + setDragY(null); + if (!state) return; + const dy = event.clientY - state.startY; + if (dy > DISMISS_DISTANCE || state.v > DISMISS_VELOCITY) onCloseControls(); + }; + + const onHandleClick = () => { + if (moved.current) return; + onCloseControls(); + }; + return ( -
+
{header}
-
+
+ {/* Backdrop behind the mobile drawer. */} + {sidebar} -
+
{preview} diff --git a/src/components/layout/Nav.tsx b/src/components/layout/Nav.tsx index fa1f637..ab54012 100644 --- a/src/components/layout/Nav.tsx +++ b/src/components/layout/Nav.tsx @@ -27,38 +27,44 @@ export function Nav({ }: NavProps) { return (