Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1214,3 +1214,37 @@ filter now offers a **"none"** option alongside its values.
byte-identical copies; this extracted them into `src/client/FilterSelect.tsx`
with a `nullable` flag that adds the "none" option, so both surfaces stay in
step.

### PROG-79 — mobile top nav becomes a bottom tab bar

The header packed the "Progress" logo + six text nav links + the New menu + the
avatar into one row; on a phone that overflowed and scrolled sideways (the issue
is filed under the **Mobile functionality** arc). Fixed by swapping the *pattern*
on small screens, not by trimming destinations.

- **Bottom tab bar on mobile, inline nav on desktop.** Below Tailwind's `sm`
breakpoint the header's inline `<nav>` is hidden and a fixed bottom tab bar
(`MobileTabBar.tsx`, `sm:hidden`) takes over; at `sm`+ the bar is hidden and the
original inline nav returns unchanged. The mobile header is now just logo +
New + avatar, so it can't overflow.
- **The iOS-standard 5-slot split.** Four primary surfaces get their own tab
(Board · Outline · Agenda · Search); the rest (Structure · Archive) live behind
a **More** tab that opens a small sheet above the bar. Chosen over cramming all
six text tabs across a 375 px phone. Archive was already deemed a rare
destination (D49) and Structure is curation, so they're the natural pair to
demote.
- **Active state is always visible** (the issue's explicit ask). The current
tab's icon + label are lit in the adobe accent; the **More** tab lights when
its sheet holds the current page (e.g. on `/structure`), so you can always see
where you are without opening anything. Verified with a real phone-viewport
browser pass (header 390 px = viewport 390 px, no horizontal scroll).
- **One source of destinations.** The list moved to a shared `nav.tsx` (href,
label, `match`, an `icon` used only by the bar, and a `primary` flag) imported
by both the desktop header and the tab bar, so the two can't drift — same
reasoning as the shared `FilterSelect` (PROG-76).
- **Bottom-anchored chrome lifts above the bar on mobile.** `main` gains
`pb-24` (cleared at `sm`), and the toast stack + PWA install card float above
the bar on phones; all use the existing `pwa-safe-bottom` inset so they clear
the iOS home indicator. *Rejected:* a hamburger menu (hides the active state
the issue wants kept visible) and a 6-tab bar (too cramped with text labels on
a narrow phone).
8 changes: 7 additions & 1 deletion docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,13 @@ canonical key — entirely client-side from the loaded workspace (D22).
signed-in identity avatar. The always-available structure-creation entry point
(SPEC v2 §4). The avatar dropdown holds the profile + **Sign out**, plus an
**Admin** link for super-admins (D44) — Admin lives here, not in the top nav,
as a rare destination.
as a rare destination. The inline nav is **desktop-only**: below `sm` it is
hidden and a fixed **bottom tab bar** (`MobileTabBar.tsx`) takes over — Board ·
Outline · Agenda · Search as tabs and a **More** tab (sheet) for Structure ·
Archive, the active tab lit in the adobe accent (More included when its sheet's
page is current), clear of the iOS home indicator. This stops the header from
overflowing and scrolling sideways on a phone (PROG-79). Both surfaces read
their destinations from one shared `nav.tsx` list so they can't drift.
- **Agenda (`/agenda`)** — the time-driven cut: every issue with a due date
that isn't done/canceled, sorted by due date ascending and grouped **Overdue ·
Today · This week · Later** (computed from the owner's local day; "this week"
Expand Down
7 changes: 5 additions & 2 deletions src/client/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Link, Route, Switch } from "wouter";
import CommandLayer from "./commands/CommandLayer";
import Header from "./Header";
import MobileTabBar from "./MobileTabBar";
import InstallPrompt from "./pwa/InstallPrompt";
import SignIn from "./SignIn";
import { UnauthenticatedError, useWorkspace } from "./store";
Expand Down Expand Up @@ -36,8 +37,9 @@ export default function App() {
{/* Wide shell for the board; narrow pages re-constrain themselves.
Tighter padding on phones — the board needs the width. Top gap is
kept small so content sits just under the sticky header, with roomier
bottom padding for scroll breathing room (PROG-69). */}
<main className="mx-auto max-w-screen-2xl px-3 pb-5 pt-3 sm:px-6 sm:pb-10 sm:pt-4">
bottom padding for scroll breathing room (PROG-69). The extra mobile
bottom padding (pb-24) clears the fixed bottom tab bar (PROG-79). */}
<main className="mx-auto max-w-screen-2xl px-3 pb-24 pt-3 sm:px-6 sm:pb-10 sm:pt-4">
{/* Initial app load: the only permitted loading state (SPEC §8.2). */}
{isPending && <p className="text-ink-faint">Loading workspace…</p>}
{error && <p className="text-danger">{String(error)}</p>}
Expand Down Expand Up @@ -87,6 +89,7 @@ export default function App() {
</Switch>
)}
</main>
{workspace && <MobileTabBar />}
<Toasts />
<InstallPrompt />
</div>
Expand Down
17 changes: 5 additions & 12 deletions src/client/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { useState } from "react";
import { Link, useLocation } from "wouter";
import { openCreateContainer, openCreateIssue, type ContainerDialogRequest } from "./commands/controller";
import { NAV } from "./nav";
import { useWorkspaceSlice } from "./store";

// End the session, then reload — an unauthenticated load bounces to sign-in.
Expand All @@ -18,17 +19,6 @@ async function signOut() {
}
}

type NavItem = { href: string; label: string; match: (path: string) => boolean };

const NAV: NavItem[] = [
{ href: "/", label: "Board", match: (p) => p === "/" },
{ href: "/outline", label: "Outline", match: (p) => p.startsWith("/outline") },
{ href: "/agenda", label: "Agenda", match: (p) => p.startsWith("/agenda") },
{ href: "/search", label: "Search", match: (p) => p.startsWith("/search") },
{ href: "/structure", label: "Structure", match: (p) => p.startsWith("/structure") },
{ href: "/archive", label: "Archive", match: (p) => p.startsWith("/archive") },
];

export default function Header() {
const [path] = useLocation();
const [menuOpen, setMenuOpen] = useState(false);
Expand All @@ -53,7 +43,10 @@ export default function Header() {
<Link href="/" className="mr-2 font-semibold tracking-tight text-ink">
Progress
</Link>
<nav className="flex items-center gap-1 text-sm">
{/* Inline nav is desktop-only; on phones the bottom tab bar
(MobileTabBar) carries navigation so the header can't overflow and
scroll sideways (PROG-79). */}
<nav className="hidden items-center gap-1 text-sm sm:flex">
{NAV.map((item) => (
<Link
key={item.href}
Expand Down
86 changes: 86 additions & 0 deletions src/client/MobileTabBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Bottom tab bar — the mobile-only primary navigation (PROG-79). On phones the
// header's inline nav is hidden (it overflowed and scrolled sideways), and this
// fixed bar takes over: the four primary surfaces get a tab each, the rest sit
// behind a "More" sheet (the iOS-standard 5-slot pattern). The active tab is
// always lit (icon + label in the adobe accent), so you can see where you are at
// a glance without opening anything. Hidden at `sm` and up, where the inline nav
// returns. Destinations come from the shared NAV list so the two can't drift.

import { useState } from "react";
import { Link, useLocation } from "wouter";
import { MoreIcon, NAV, type NavItem } from "./nav";

export default function MobileTabBar() {
const [path] = useLocation();
const [moreOpen, setMoreOpen] = useState(false);
const primary = NAV.filter((i) => i.primary);
const secondary = NAV.filter((i) => !i.primary);
// "More" reads as active whenever a destination it holds is the current page,
// so the bar still shows where you are even when the surface isn't a top tab.
const moreActive = secondary.some((i) => i.match(path));

return (
// pwa-safe-bottom/x: clear the iOS home indicator and rounded corners; inert
// in a desktop browser. backdrop-blur + translucent paper matches the header.
<nav className="pwa-safe-bottom pwa-safe-x fixed inset-x-0 bottom-0 z-40 border-t border-line bg-paper/95 backdrop-blur sm:hidden">
{moreOpen && (
<>
{/* Tap anywhere outside to dismiss the sheet. */}
<div className="fixed inset-0 z-40" onClick={() => setMoreOpen(false)} />
<div className="absolute inset-x-0 bottom-full z-50 border-t border-line bg-card pb-1 shadow-xl">
{secondary.map((item) => {
const active = item.match(path);
return (
<Link
key={item.href}
href={item.href}
onClick={() => setMoreOpen(false)}
aria-current={active ? "page" : undefined}
className={`flex items-center gap-3 px-5 py-3 text-sm ${
active ? "bg-adobe-wash/40 text-adobe-deep" : "text-ink-soft hover:bg-line"
}`}
>
<span className="text-ink-faint">{item.icon}</span>
{item.label}
</Link>
);
})}
</div>
</>
)}

<div className="mx-auto flex max-w-screen-2xl items-stretch">
{primary.map((item) => (
<Tab key={item.href} item={item} active={item.match(path)} />
))}
<button
type="button"
onClick={() => setMoreOpen((o) => !o)}
aria-expanded={moreOpen}
aria-current={moreActive ? "page" : undefined}
className={`flex flex-1 flex-col items-center justify-center gap-0.5 py-1.5 text-[10px] font-medium ${
moreActive || moreOpen ? "text-adobe-deep" : "text-ink-faint"
}`}
>
{MoreIcon}
More
</button>
</div>
</nav>
);
}

function Tab({ item, active }: { item: NavItem; active: boolean }) {
return (
<Link
href={item.href}
aria-current={active ? "page" : undefined}
className={`flex flex-1 flex-col items-center justify-center gap-0.5 py-1.5 text-[10px] font-medium ${
active ? "text-adobe-deep" : "text-ink-faint"
}`}
>
{item.icon}
{item.label}
</Link>
);
}
95 changes: 95 additions & 0 deletions src/client/nav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// The top-level navigation destinations, shared by the desktop header's inline
// nav (Header.tsx) and the mobile bottom tab bar (MobileTabBar.tsx) so the two
// can't drift (PROG-79). Each item carries an `icon` (used only by the tab bar;
// the desktop nav is text-only) and a `primary` flag: the four primary surfaces
// get their own bottom tab, the rest live behind a "More" tab — the standard
// iOS 5-slot pattern. Desktop shows all six inline.

import type { ReactNode } from "react";

export type NavItem = {
href: string;
label: string;
match: (path: string) => boolean;
icon: ReactNode;
// True ⇒ gets its own bottom tab on mobile; false ⇒ lives in the "More" sheet.
primary: boolean;
};

// Shared SVG props: line icons on `currentColor`, sized for the tab bar.
const ICON = {
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.7,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
className: "h-6 w-6",
"aria-hidden": true,
};

const BoardIcon = (
<svg {...ICON}>
<rect x="3" y="4" width="5" height="16" rx="1" />
<rect x="9.5" y="4" width="5" height="11" rx="1" />
<rect x="16" y="4" width="5" height="7" rx="1" />
</svg>
);

const OutlineIcon = (
<svg {...ICON}>
<circle cx="5" cy="6" r="1.3" fill="currentColor" stroke="none" />
<circle cx="5" cy="12" r="1.3" fill="currentColor" stroke="none" />
<circle cx="5" cy="18" r="1.3" fill="currentColor" stroke="none" />
<path d="M9 6h11M9 12h11M9 18h11" />
</svg>
);

const AgendaIcon = (
<svg {...ICON}>
<rect x="3.5" y="5" width="17" height="15" rx="2" />
<path d="M3.5 9.5h17M8 3.5v3M16 3.5v3" />
</svg>
);

const SearchIcon = (
<svg {...ICON}>
<circle cx="11" cy="11" r="6" />
<line x1="15.5" y1="15.5" x2="20" y2="20" />
</svg>
);

const StructureIcon = (
<svg {...ICON}>
<rect x="9" y="3.5" width="6" height="4" rx="1" />
<rect x="3.5" y="16.5" width="6" height="4" rx="1" />
<rect x="14.5" y="16.5" width="6" height="4" rx="1" />
<path d="M12 7.5V12M6.5 16.5V13h11v3.5" />
</svg>
);

const ArchiveIcon = (
<svg {...ICON}>
<rect x="3.5" y="4.5" width="17" height="4" rx="1" />
<path d="M5 8.5V19a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8.5" />
<line x1="10" y1="12.5" x2="14" y2="12.5" />
</svg>
);

// Three filled dots — the "More" tab glyph.
export const MoreIcon = (
<svg {...ICON}>
<circle cx="5" cy="12" r="1.6" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none" />
<circle cx="19" cy="12" r="1.6" fill="currentColor" stroke="none" />
</svg>
);

export const NAV: NavItem[] = [
{ href: "/", label: "Board", match: (p) => p === "/", icon: BoardIcon, primary: true },
{ href: "/outline", label: "Outline", match: (p) => p.startsWith("/outline"), icon: OutlineIcon, primary: true },
{ href: "/agenda", label: "Agenda", match: (p) => p.startsWith("/agenda"), icon: AgendaIcon, primary: true },
{ href: "/search", label: "Search", match: (p) => p.startsWith("/search"), icon: SearchIcon, primary: true },
{ href: "/structure", label: "Structure", match: (p) => p.startsWith("/structure"), icon: StructureIcon, primary: false },
{ href: "/archive", label: "Archive", match: (p) => p.startsWith("/archive"), icon: ArchiveIcon, primary: false },
];
2 changes: 1 addition & 1 deletion src/client/pwa/InstallPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ function PlusSquareIcon({ className }: { className?: string }) {
// on phones without spanning a desktop window.
function Card({ children, onDismiss }: { children: React.ReactNode; onDismiss: () => void }) {
return (
<div className="pwa-safe-bottom pwa-safe-x fixed inset-x-0 bottom-0 z-50 flex justify-center px-3 pb-3">
<div className="pwa-safe-bottom pwa-safe-x fixed inset-x-0 bottom-16 z-50 flex justify-center px-3 pb-3 sm:bottom-0">
<div className="relative w-full max-w-md rounded-lg border border-line bg-paper p-4 shadow-xl">
<button
onClick={onDismiss}
Expand Down
4 changes: 3 additions & 1 deletion src/client/toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,10 @@ function subscribe(cb: () => void) {
export function Toasts() {
const current = useSyncExternalStore(subscribe, () => toasts);
if (current.length === 0) return null;
// bottom-24 on mobile floats toasts above the fixed bottom tab bar (PROG-79);
// back to bottom-4 once the bar is gone at sm+.
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
<div className="fixed bottom-24 right-4 z-50 flex flex-col gap-2 sm:bottom-4">
{current.map((t) => (
<div
key={t.id}
Expand Down