diff --git a/.cdp/mobile-shots.cjs b/.cdp/mobile-shots.cjs new file mode 100644 index 0000000..b8ab0ff --- /dev/null +++ b/.cdp/mobile-shots.cjs @@ -0,0 +1,136 @@ +/* Mobile responsive verification: login, then screenshot the app at a + phone viewport (390x844) in the key mobile states, plus a desktop + sanity shot. Uses the Playwright-cached chromium headless shell. */ +const { spawn } = require("node:child_process"); +const fs = require("node:fs"); +const http = require("node:http"); +const path = require("node:path"); + +const CHROME = "/home/brianfertig/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome"; +const PORT = 9351; +const BASE = "http://localhost:3000"; +const OUT = path.join(__dirname, "..", "shots"); + +function httpJson(p) { + return new Promise((resolve, reject) => { + const req = http.request({ host: "127.0.0.1", port: PORT, path: p }, (res) => { + let d = ""; res.on("data", (c) => (d += c)); + res.on("end", () => { try { resolve(JSON.parse(d)); } catch { resolve(d); } }); + }); + req.on("error", reject); req.end(); + }); +} + +(async () => { + const profile = fs.mkdtempSync("/tmp/chrome-prof-"); + const proc = spawn(CHROME, ["--headless=new", "--no-sandbox", `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check", "--disable-gpu", "about:blank"], { stdio: "ignore" }); + let targets = null; + for (let i = 0; i < 40; i++) { await new Promise((r) => setTimeout(r, 500)); try { targets = await httpJson("/json/list"); break; } catch {} } + if (!targets) { console.error("no targets"); proc.kill(); process.exit(1); } + const page = targets.find((t) => t.type === "page"); + const ws = new WebSocket(page.webSocketDebuggerUrl); + await new Promise((r) => (ws.onopen = r)); + let id = 0; + const send = (method, params = {}) => new Promise((resolve) => { + const myId = ++id; ws.send(JSON.stringify({ id: myId, method, params })); + const t = setInterval(() => { const m = ws._buf.find((b) => b.id === myId); if (m) { clearInterval(t); resolve(m.result ?? m.error); } }, 10); + }); + ws._buf = []; + ws.onmessage = (m) => { const msg = JSON.parse(m.data); if (msg.id) { ws._buf.push(msg); return; } + if (msg.method === "Runtime.consoleAPICalled" && msg.params.type === "error") { + const txt = msg.params.args.map((a) => a.value ?? a.description ?? a.type).join(" "); + if (!/hydrated|React DevTools|HMR|404/.test(txt)) console.log("[err]", txt.slice(0, 160)); + } }; + + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + async function shot(name, w, h, mobile) { + await send("Emulation.setDeviceMetricsOverride", { width: w, height: h, deviceScaleFactor: 1, mobile, hasTouch: mobile }); + const s = await send("Page.captureScreenshot", { format: "png" }); + fs.writeFileSync(path.join(OUT, `${name}.png`), Buffer.from(s.data, "base64")); + console.log(`saved ${name} (${w}x${h})`); + } + async function click(css) { + const res = await send("Runtime.evaluate", { expression: `(() => { const el = document.querySelector(${JSON.stringify(css)}); if (!el) return "missing"; el.click(); return "clicked"; })()`, returnByValue: true }); + console.log(`click(${css}) ->`, res?.result?.value ?? res); + } + async function evalJs(expr) { + return (await send("Runtime.evaluate", { expression: expr, returnByValue: true }))?.result?.value; + } + + await send("Runtime.enable"); await send("Page.enable"); + await send("Emulation.setDeviceMetricsOverride", { width: 390, height: 844, deviceScaleFactor: 1, mobile: true, hasTouch: true }); + + // ---- login (phone viewport) ---- + await send("Page.navigate", { url: `${BASE}/login` }); + await wait(3000); + const loginRes = await evalJs(`(() => { + const setVal = (el, v) => { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value").set; + setter.call(el, v); + el.dispatchEvent(new Event("input", { bubbles: true })); + }; + const email = document.querySelector("#email"); + const password = document.querySelector("#password"); + if (!email || !password) return "inputs missing"; + setVal(email, "dev@example.com"); + setVal(password, "password123"); + email.form.requestSubmit(); + return "submitted"; + })()`); + console.log("login ->", loginRes); + await wait(4000); + console.log("now at", await evalJs("location.href")); + + // ---- 1. home board, mobile ---- + await send("Page.navigate", { url: `${BASE}/` }); + await wait(3500); + await shot("m01-home-board", 390, 844, true); + + // ---- 2. nav drawer open ---- + await click('button[aria-label="Open menu"]'); + await wait(900); + await shot("m02-home-drawer", 390, 844, true); + // close via backdrop: click the backdrop element (base-ui renders it as a button/div behind) + await evalJs(`(() => { const b = document.querySelector('[data-slot="dialog-overlay"], .fixed.inset-0.z-40'); if (b) b.click(); const pop = document.querySelector('[data-slot="dialog-content"]'); return "done"; })()`); + await wait(500); + // ensure closed: dispatch Escape + await send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }); + await wait(600); + + // ---- 3. second lane via chip ---- + const chips = await evalJs(`Array.from(document.querySelectorAll("button[aria-pressed]")).map(b => b.textContent).join(" | ")`); + console.log("chips:", chips); + await click('button[aria-pressed="false"]'); + await wait(1200); + await shot("m03-home-lane2", 390, 844, true); + + // ---- 4. scheduled sheet ---- + const dock = await evalJs(`(() => { const b = Array.from(document.querySelectorAll("nav[aria-label='Scheduled to-dos'] button")).pop(); if (!b) return "missing"; b.click(); return "clicked"; })()`); + console.log("dock ->", dock); + await wait(1000); + await shot("m04-scheduled-sheet", 390, 844, true); + await send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }); + await wait(600); + + // ---- 5. projects page ---- + await send("Page.navigate", { url: `${BASE}/projects` }); + await wait(2500); + await shot("m05-projects", 390, 844, true); + + // ---- 6. chat page ---- + await send("Page.navigate", { url: `${BASE}/chat` }); + await wait(2500); + await shot("m06-chat", 390, 844, true); + + // ---- 7. tablet width (768, desktop shell boundary) ---- + await send("Page.navigate", { url: `${BASE}/` }); + await wait(2500); + await shot("m07-tablet-768", 768, 1024, false); + + // ---- 8. desktop sanity ---- + await send("Page.navigate", { url: `${BASE}/` }); + await wait(2500); + await shot("m08-desktop-1440", 1440, 900, false); + + proc.kill(); process.exit(0); +})().catch((e) => { console.error(e); process.exit(1); }); diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 4723097..b17523d 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -6,6 +6,7 @@ import type { ThemeName } from "@/lib/themes"; import type { ProjectDTO } from "@/types/project"; import { SideNavProvider } from "@/components/nav/side-nav-provider"; import { SideNav } from "@/components/nav/side-nav"; +import { MobileTopBar } from "@/components/nav/mobile-top-bar"; import { ProjectsProvider } from "@/components/projects/projects-context"; import { ScheduledPanelProvider } from "@/components/scheduled/scheduled-panel-provider"; import { ScheduledPanel } from "@/components/scheduled/scheduled-panel"; @@ -40,15 +41,20 @@ export default async function AppLayout({ children }: { children: React.ReactNod - {/* On small screens the three-column shell would crush the - board; give it a sensible minimum width and let the page - scroll horizontally instead, and drop the Scheduled panel - (auxiliary on a phone) until the viewport can hold it. */} -
- -
{children}
- -
+ {/* Responsive shell: + - md and up: the original three-column desktop layout, with + the same min-width + horizontal-scroll fallback as before. + - below md (phones): a true stacked app layout -- top bar, + full-height board, and the Scheduled dock (rendered inside + ScheduledPanel) take the full column with no horizontal + scrolling; h-dvh tracks the mobile browser's dynamic + toolbar instead of the stale 100vh. */} +
+ + +
{children}
+ +
diff --git a/components/admin/admin-user-table.tsx b/components/admin/admin-user-table.tsx index 8e8a8d5..97d20c1 100644 --- a/components/admin/admin-user-table.tsx +++ b/components/admin/admin-user-table.tsx @@ -49,8 +49,8 @@ export function AdminUserTable({ return ( <> -
- +
+
diff --git a/components/board/add-category-lane.tsx b/components/board/add-category-lane.tsx index 8ebeef8..7cb7ccc 100644 --- a/components/board/add-category-lane.tsx +++ b/components/board/add-category-lane.tsx @@ -39,7 +39,10 @@ export function AddCategoryLane() { render={ + ); + })} + + ); +} diff --git a/components/board/category-lane.tsx b/components/board/category-lane.tsx index f8fd4c5..c69c3c5 100644 --- a/components/board/category-lane.tsx +++ b/components/board/category-lane.tsx @@ -5,7 +5,7 @@ import { useSortable } from "@dnd-kit/sortable"; import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { useDndContext, useDroppable } from "@dnd-kit/core"; import { CSS } from "@dnd-kit/utilities"; -import { GripVertical, MoreVertical, Plus, Trash2 } from "lucide-react"; +import { GripVertical, MoreVertical, Trash2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { @@ -98,9 +98,13 @@ export function CategoryLane({ category }: { category: CategoryDTO }) {
diff --git a/components/board/kanban-board.tsx b/components/board/kanban-board.tsx index e773585..151c4a7 100644 --- a/components/board/kanban-board.tsx +++ b/components/board/kanban-board.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { DndContext, DragOverlay, @@ -17,6 +17,7 @@ import { LayoutDashboard, Plus } from "lucide-react"; import { BoardProvider, useBoard } from "@/components/board/board-context"; import { CategoryLane } from "@/components/board/category-lane"; +import { CategoryChips } from "@/components/board/category-chips"; import { AddCategoryLane } from "@/components/board/add-category-lane"; import { GroupCardOverlay } from "@/components/board/group-card-overlay"; import { EmptyState } from "@/components/board/empty-state"; @@ -40,6 +41,11 @@ function Board({ const { categories, reorderLanes, reorderGroups, moveGroup } = useBoard(); const [draggedGroup, setDraggedGroup] = useState(null); const [quickAddOpen, setQuickAddOpen] = useState(false); + const pagerRef = useRef(null); + // Which lane is front-and-center in the mobile pager (see the observer + // below) -- null until the first intersection report lands, after which + // CategoryChips falls back to the first lane for the brief gap. + const [activeCategoryId, setActiveCategoryId] = useState(null); // The "+ to-do" quick action targets the first group it can find -- the // goal is one click from a keyboard or pointer to start typing a to-do @@ -129,6 +135,51 @@ function Board({ const categoryIds = categories.map((c) => c.id); + // Mobile pager awareness: watch the lanes inside the scroll pager and + // keep the chip rail in sync with whichever lane is most visible. Only + // runs when lanes actually exist; re-runs when the lane set changes so + // added/removed lanes get observed. (No-op on desktop -- the lanes are + // there, but the chips are hidden, so the state is simply unused.) + useEffect(() => { + const pager = pagerRef.current; + if (!pager) return; + const lanes = Array.from(pager.querySelectorAll("[data-category-id]")); + if (lanes.length === 0) return; + const ratios = new Map(); + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const el = entry.target as HTMLElement; + if (entry.isIntersecting) ratios.set(el, entry.intersectionRatio); + else ratios.delete(el); + } + let best: HTMLElement | null = null; + let bestRatio = 0; + for (const lane of lanes) { + const ratio = ratios.get(lane) ?? 0; + if (ratio > bestRatio) { + bestRatio = ratio; + best = lane; + } + } + if (best?.dataset.categoryId) setActiveCategoryId(best.dataset.categoryId); + }, + { root: pager, threshold: [0.25, 0.5, 0.75, 1] } + ); + lanes.forEach((lane) => observer.observe(lane)); + return () => observer.disconnect(); + }, [categories]); + + // Chip tap: glide the pager to that lane (snap then settles it exactly + // onto the lane's start edge). + function handleSelectChip(id: string) { + const pager = pagerRef.current; + const lane = pager?.querySelector(`[data-category-id="${id}"]`); + if (!pager || !lane) return; + const left = lane.getBoundingClientRect().left - pager.getBoundingClientRect().left + pager.scrollLeft; + pager.scrollTo({ left, behavior: "smooth" }); + } + return ( -
+
- +
-

{title}

+

{title}

{/* Per-project theme picker, to the right of the project's name -- only projects have their own theme assignment. */} {projectId && } @@ -179,9 +230,10 @@ function Board({ className="gap-1.5" disabled={!quickTargetGroup} onClick={() => setQuickAddOpen(true)} + aria-label="Add to-do" > - To-do + To-do } /> @@ -194,19 +246,37 @@ function Board({
{categories.length === 0 ? ( -
+
) : ( -
- - {categories.map((category) => ( - - ))} - - -
+ <> + {/* Mobile only: the category rail. On desktop the lanes are + already labeled and side-by-side, so this stays hidden. */} +
+ +
+ + {/* Mobile: one lane per screen, snap-paged (swipe = next lane), + while each lane still scrolls its cards vertically. Desktop + keeps the plain side-by-side scroll (snap disabled at md). */} +
+ + {categories.map((category) => ( + + ))} + + +
+ )}
diff --git a/components/board/summary-button.tsx b/components/board/summary-button.tsx index f2e30d2..8f6f5ff 100644 --- a/components/board/summary-button.tsx +++ b/components/board/summary-button.tsx @@ -151,9 +151,9 @@ export function SummaryButton({ projectId }: { projectId?: string }) { + } /> diff --git a/components/board/view-switcher.tsx b/components/board/view-switcher.tsx index 1b1829e..24a2ee8 100644 --- a/components/board/view-switcher.tsx +++ b/components/board/view-switcher.tsx @@ -28,9 +28,9 @@ export function ViewSwitcher() { + } /> diff --git a/components/nav/mobile-top-bar.tsx b/components/nav/mobile-top-bar.tsx new file mode 100644 index 0000000..72d7f25 --- /dev/null +++ b/components/nav/mobile-top-bar.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { Menu, NotebookPen } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { ThemeToggle } from "@/components/theme/theme-toggle"; +import { useSideNav } from "@/components/nav/side-nav-provider"; + +/** + * Mobile (< md) app top bar: hamburger (opens the nav drawer) + brand, + * with the theme picker on the right so it's reachable without opening + * the drawer. The page's own header (board title, counts, actions) sits + * in the content area just below this bar. + */ +export function MobileTopBar() { + const { setMobileOpen } = useSideNav(); + + return ( +
+ + +
+ + + + Organize +
+ +
+ +
+
+ ); +} diff --git a/components/nav/side-nav-provider.tsx b/components/nav/side-nav-provider.tsx index 3e4f63a..cd2a046 100644 --- a/components/nav/side-nav-provider.tsx +++ b/components/nav/side-nav-provider.tsx @@ -7,6 +7,10 @@ const STORAGE_KEY = "organize:sidenav-collapsed"; interface SideNavContextValue { collapsed: boolean; toggle: () => void; + // The mobile (< md) hamburger drawer. Deliberately NOT persisted: a drawer + // should never be left open across a reload or navigation. + mobileOpen: boolean; + setMobileOpen: (open: boolean) => void; } const SideNavContext = createContext(null); @@ -16,6 +20,7 @@ export function SideNavProvider({ children }: { children: React.ReactNode }) { // hydration mismatch; the real persisted value is applied right after // mount, trading a one-frame flash for zero hydration warnings. const [collapsed, setCollapsed] = useState(false); + const [mobileOpen, setMobileOpen] = useState(false); useEffect(() => { const stored = localStorage.getItem(STORAGE_KEY); @@ -31,7 +36,7 @@ export function SideNavProvider({ children }: { children: React.ReactNode }) { }; return ( - + {children} ); diff --git a/components/nav/side-nav.tsx b/components/nav/side-nav.tsx index 96c8ac1..65f71ed 100644 --- a/components/nav/side-nav.tsx +++ b/components/nav/side-nav.tsx @@ -1,6 +1,7 @@ "use client"; import { ChevronLeft, ChevronRight, LogOut, NotebookPen } from "lucide-react"; +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; @@ -13,17 +14,29 @@ import { ThemeToggle } from "@/components/theme/theme-toggle"; import { logout } from "@/lib/actions/auth"; import { Role } from "@/lib/generated/prisma/enums"; -export function SideNav({ userEmail, role }: { userEmail: string; role: Role }) { - const { collapsed, toggle } = useSideNav(); +/** + * The actual nav content, shared by the desktop aside and the mobile + * hamburger drawer so the two never drift apart. `collapsed` is always + * false in the drawer (a 288px-wide sheet has no room for icon-only mode). + */ +function SideNavContent({ + collapsed, + userEmail, + role, + themeMenu, +}: { + collapsed: boolean; + userEmail: string; + role: Role; + // Where the theme picker menu opens from its trigger -- right of the + // trigger in the desktop sidebar, below it in the mobile drawer (a + // right-opening menu would run off the phone's right edge there). + themeMenu: { side: "right" | "bottom"; align: "start" | "end" }; +}) { const adminItem = role === Role.ADMIN ? [ADMIN_NAV_ITEM] : []; return ( - + + ); +} + +/** + * Mobile (< md) hamburger drawer: the full side nav in a left sheet. + * Tapping any link closes it; the backdrop and Escape close it too. + */ +function MobileNavDrawer({ userEmail, role }: { userEmail: string; role: Role }) { + const { mobileOpen, setMobileOpen } = useSideNav(); + + return ( + + + + { + // Close on navigation -- links (nav items, projects) are the only + // things that should dismiss it, not e.g. a theme switch. + if ((e.target as HTMLElement).closest("a")) setMobileOpen(false); + }} + className={cn( + "fixed inset-y-0 left-0 z-50 flex w-72 max-w-[85vw] flex-col overflow-y-auto bg-sidebar text-sidebar-foreground shadow-xl duration-200 md:hidden", + "data-open:animate-in data-open:fade-in-0 data-open:slide-in-from-left", + "data-closed:animate-out data-closed:fade-out-0 data-closed:slide-out-to-left" + )} + > + Menu + + + + + ); +} + +export function SideNav({ userEmail, role }: { userEmail: string; role: Role }) { + const { collapsed, toggle } = useSideNav(); + + return ( + <> + + + + ); } diff --git a/components/scheduled/scheduled-panel.tsx b/components/scheduled/scheduled-panel.tsx index 6b73216..797bcfa 100644 --- a/components/scheduled/scheduled-panel.tsx +++ b/components/scheduled/scheduled-panel.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { usePathname } from "next/navigation"; import { toast } from "sonner"; import { AlertTriangle, Bell, BellOff, CalendarClock, ChevronLeft, ChevronRight, Plus } from "lucide-react"; +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; import { cn } from "@/lib/utils"; import { formatWeekdayDate } from "@/lib/format"; @@ -102,6 +103,9 @@ export function ScheduledPanel() { const [board, setBoard] = useState(null); const [dialogOpen, setDialogOpen] = useState(false); const [editingId, setEditingId] = useState(null); + // Mobile bottom sheet (the dock bar below opens it). Kept separate from + // `dialogOpen` -- that one is the add/edit form dialog. + const [sheetOpen, setSheetOpen] = useState(false); const { holds, beginHold, cancelHold } = useHoldOnComplete(); const refresh = useCallback(() => { @@ -203,7 +207,7 @@ export function ScheduledPanel() { <> + {/* Mobile (< md) entry point: a dock bar that sits at the bottom of + the app column (in-flow, so nothing overlaps it) carrying the + same overdue/today urgency signals as the desktop collapsed + rail. It opens the full panel as a bottom sheet below rather + than dedicating permanent screen width to it. */} + + + + + + + Scheduled to-dos + {/* Grabber -- visual affordance that this is a sheet, not the whole screen. */} +
+
+ setSheetOpen(false)} + notificationsSupported={notifications.supported} + notificationsEnabled={notifications.enabled} + onToggleNotifications={handleToggleNotifications} + /> +
+ + + + -
+
{!board ? (

Loading…

) : ( diff --git a/components/theme/theme-toggle.tsx b/components/theme/theme-toggle.tsx index 11ca08a..0ff6004 100644 --- a/components/theme/theme-toggle.tsx +++ b/components/theme/theme-toggle.tsx @@ -100,7 +100,18 @@ export function OptionRow({ ); } -export function ThemeToggle({ collapsed }: { collapsed?: boolean }) { +export function ThemeToggle({ + collapsed, + menuSide = "right", + menuAlign = "start", +}: { + collapsed?: boolean; + // Which side of the trigger the picker menu opens on: "right" for the + // sidebar, "bottom" when the trigger sits in the mobile top bar (where a + // right-opening menu would run off the screen). + menuSide?: "right" | "bottom"; + menuAlign?: "start" | "end"; +}) { const { theme, setTheme } = useTheme(); // The picker shows all the real themes in Light/Dark sections; "system" is // handled by a dedicated row since it resolves to one of them per device @@ -124,7 +135,7 @@ export function ThemeToggle({ collapsed }: { collapsed?: boolean }) { } /> - + Light {LIGHT_OPTIONS.map((option) => (
Name