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 (
<>
-
-
+
+
Name
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-chips.tsx b/components/board/category-chips.tsx
new file mode 100644
index 0000000..58aac9b
--- /dev/null
+++ b/components/board/category-chips.tsx
@@ -0,0 +1,81 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+
+import { cn } from "@/lib/utils";
+import type { CategoryDTO } from "@/types/board";
+
+/**
+ * Mobile (< md) category rail: one chip per lane, shown under the top bar.
+ * Doubles as the board's position indicator while swiping between lanes
+ * (the active chip is highlighted, driven by the board's
+ * IntersectionObserver) and as quick navigation -- tapping a chip glides
+ * the lane pager to that lane.
+ */
+export function CategoryChips({
+ categories,
+ activeCategoryId,
+ onSelect,
+}: {
+ categories: CategoryDTO[];
+ activeCategoryId: string | null;
+ onSelect: (id: string) => void;
+}) {
+ const chipRefs = useRef(new Map());
+
+ // Keep the active chip in view if the user swipes several lanes at once
+ // (or a chip gets added/removed out of the strip's view).
+ useEffect(() => {
+ if (!activeCategoryId) return;
+ chipRefs.current
+ .get(activeCategoryId)
+ ?.scrollIntoView({ behavior: "smooth", inline: "center", block: "nearest" });
+ }, [activeCategoryId]);
+
+ return (
+
+ {categories.map((category) => {
+ const active = category.id === activeCategoryId;
+ const openTodos = category.groups.reduce(
+ (n, g) => n + g.todos.filter((t) => !t.completed).length,
+ 0
+ );
+
+ return (
+ {
+ if (el) chipRefs.current.set(category.id, el);
+ else chipRefs.current.delete(category.id);
+ }}
+ type="button"
+ onClick={() => onSelect(category.id)}
+ aria-pressed={active}
+ className={cn(
+ "flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-[13px] font-medium transition-colors",
+ active
+ ? "border-transparent bg-primary text-primary-foreground shadow-sm"
+ : "border-border bg-lane text-muted-foreground active:bg-accent active:text-accent-foreground"
+ )}
+ >
+ {category.name}
+
+ {openTodos}
+
+
+ );
+ })}
+
+ );
+}
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 }) {
+
- Summary
+ Summary
}
/>
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() {
+
- {current.label}
+ {current.label}
}
/>
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 (
+
+ setMobileOpen(true)} aria-label="Open menu">
+
+
+
+
+
+
+
+ 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 (
-
+ <>
@@ -46,7 +59,7 @@ export function SideNav({ userEmail, role }: { userEmail: string; role: Role })
-
+
{collapsed ? (
-
-
-
- {collapsed ? : }
-
-
-
+ >
+ );
+}
+
+/**
+ * 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 (
+ <>
+
+
+
+
+
+ {collapsed ? : }
+
+
+
+
+
+ >
);
}
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() {
<>
@@ -228,6 +232,77 @@ 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. */}
+
+ setSheetOpen(true)}
+ className="flex h-14 w-full items-center justify-center gap-2 transition-colors active:bg-accent/60"
+ >
+
+ Scheduled
+ {overdueCount > 0 && (
+
+
+
+ {overdueCount > 9 ? "9+" : overdueCount}
+
+
+ )}
+ {todayCount > 0 && (
+
+ {todayCount > 9 ? "9+" : todayCount}
+
+ )}
+
+ {/* Room for the iPhone home indicator so the bar never tucks under 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) => (