Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d5f2661
feat(hooks): add useIsMobile media-query hook
AlexandreCamillo May 23, 2026
277fab2
fix(hooks): address useIsMobile review feedback (lint + dead guard)
AlexandreCamillo May 23, 2026
d876bb8
feat(kbd): hide keycaps on mobile viewports
AlexandreCamillo May 23, 2026
2487258
fix(shell): collapse empty sidebar row + allow main scroll on mobile
AlexandreCamillo May 23, 2026
6d6f305
feat(sidebar): mobile drawer mode driven by useIsMobile
AlexandreCamillo May 23, 2026
dfa0bc5
fix(sidebar): close listener deps + clean dead handlers on mobile drawer
AlexandreCamillo May 23, 2026
8fca604
feat(sidebar): mobile drawer styles + always-pill on mobile
AlexandreCamillo May 23, 2026
c1c82fe
refactor(project-sidebar): drop inline mobile hamburger + drawer (mov…
AlexandreCamillo May 23, 2026
bbda7da
fix(test): satisfy SidebarProps.children type in Sidebar.test.tsx
AlexandreCamillo May 23, 2026
668be2c
feat(topbar): mobile icon-search + command-palette close button
AlexandreCamillo May 23, 2026
80e1039
chore(gitignore): exclude public/_qa-mobile.html QA helper
AlexandreCamillo May 23, 2026
719f967
docs(catalog): unify mobile drawer pattern + add kbd-suppression entry
AlexandreCamillo May 23, 2026
66d9b0c
docs(frontend): document mobile shell pattern + media-query convention
AlexandreCamillo May 23, 2026
bbc4307
fix(sidebar): close drawer on pathname change (tree rows aren't <a> l…
AlexandreCamillo May 24, 2026
e36eb93
fix(palette): hide redundant esc badge on mobile (keep ✕ button)
AlexandreCamillo May 24, 2026
cbda8cd
fix(topbar): apply sidebar-inset padding on mobile so breadcrumb clea…
AlexandreCamillo May 24, 2026
c943a56
feat(sidebar): mobile sidebar morphs same as desktop with scrim overlay
AlexandreCamillo May 24, 2026
6a6f605
fix(canvas-toolbar): raise mobile default position above browser chrome
AlexandreCamillo May 24, 2026
8f076ac
fix(canvas-toolbar): raise mobile bottom further (+80% toolbar height)
AlexandreCamillo May 24, 2026
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@ docs/superpowers/
.claude/
dev-data/
test-data/

# Local-only mobile QA wrapper (iframe + same-origin viewport=390 helper).
# Spec: docs/superpowers/specs/2026-05-23-mobile-shell-refinement-spec.md §7
public/_qa-mobile.html
20 changes: 15 additions & 5 deletions docs/feature-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Global 52 px top bar (`Topbar.tsx`). Present on all authenticated pages. Margin-
| ID | Surface / Interaction | States |
|---|---|---|
| `topbar-bar` | Fixed top bar with search pill (centered), breadcrumbs (left), avatar (right) | visible on all auth pages; margin-left: 80 px when sidebar collapsed, smooth transition via `--morph-dur` |
| `topbar-search-pill` | Search trigger pill centered in topbar (min 240 px, max 340 px) with VscSearch icon + "Search..." text + a `<Kbd keys={['mod','k']} />` chip (platform-aware keycap rendering). `aria-label` includes OS-aware plain-text shortcut hint via `formatShortcut`. | default, hover (border brightens to `--border-strong`, bg to `--surface-hover`), focus-visible, active; opens `command-palette` and closes avatar dropdown |
| `topbar-search-pill` | Search trigger pill centered in topbar on desktop (min 240 px, max 340 px) with `VscSearch` icon + "Search..." text + a `<Kbd keys={['mod','k']} />` chip (platform-aware keycap). At `< 768 px` the pill becomes a 32 × 32 icon button positioned inside `.topbarRight` immediately to the left of the avatar, with `aria-label='Search'` (no keycap text); the `<Kbd>` chip is hidden by the global `mobile-kbd-suppression` rule. | default, hover (border brightens to `--border-strong`, bg to `--surface-hover`), focus-visible, active; opens `command-palette` and closes avatar dropdown |
| `topbar-avatar-btn` | User avatar button (top-right), 32 px circle with initials | default, hover (border-color accent), focus-visible, active; opens `topbar-avatar-menu` via `usePopover` |
| `topbar-avatar-menu` | Native HTML popover (`popover="auto"`) anchored to `topbar-avatar-btn` via `usePopover('right')`. Layout for **admin** users: an `Admin` subheading (10 px mono uppercase, 0.12em tracking, `--text-muted`, 6 px / 14 px padding) followed by **Invites** (navigates to `/settings/invites`) and **Agent Tokens** (navigates to `/settings/agents`); a divider; then **Sign Out** (danger variant). Layout for **member** users hides the entire Admin group (subheading + both items) — they see only **Sign Out**. Settings and Notifications items are not shipped. Paints in the browser top-layer — light-dismiss + ESC handled natively. Closes automatically when `command-palette` opens. | closed, open; admin view (full menu), member view (only Sign Out) |
| `topbar-breadcrumbs` | Breadcrumb strip — starts at project name (no "Markup" / "Home" prefix). Logo serves as root navigation | see `breadcrumbs-*` entries |
Expand All @@ -56,8 +56,8 @@ Collapsible sidebar shell (`Sidebar.tsx`). Present on every authenticated worksp
| `sidebar-footer` | Footer with "+ New Mockup" pill (replaces the former "+ New Project" CTA — project creation moves to `sidebar-projects-header`'s `+`). Same phased fade as scroll area | visible (expanded), hidden (collapsed) |
| `sidebar-new-mockup-btn` | "+ New Mockup" pill in footer. Accent text on `--btn-bg`, `--radius-pill`. Tooltip `Upload mockup · Ctrl+U`. Opens the OS file picker; on selection routes to `new-mockup-dialog` | default, hover (`--btn-bg-hover` bg, `--accent-overlay-mid` border, `--accent-bright` text), focus-visible, active |
| `sidebar-pill-position` | Collapsed pill position: `--pill-top: 5px`, `--pill-left: 5px` | pill has border-radius pill, elevated shadow, translucent bg with backdrop-filter |
| `sidebar-mobile-drawer` | `<dialog>` drawer on viewports < 768 px | closed, open; focus-trapped, scrim backdrop |
| `sidebar-mobile-hamburger` | Hamburger button to open mobile drawer | default, hover, focus-visible, active |
| `sidebar-mobile-drawer` | Native `<dialog>` 280 × 100vh slide-from-left at viewports `< 768 px`. Scrim `oklch(0% 0 0 / 0.6)` paints on the dialog element. Closes via: tap on scrim, `Esc`, tap on any nav-link in the tree, or the `✕` button (28 × 28) at the panel's top-right. Reuses the same `treeContent` + `footerContent` as the desktop morph. Owned by `Sidebar.tsx` (not the per-route ProjectSidebar). | closed, opening, open, closing |
| `sidebar-mobile-trigger` | The collapsed pill (logo `M.` + collapse-button) is the universal drawer trigger on viewports `< 768 px`. Same visual recipe as the desktop collapsed pill — same component, same icons, no separate hamburger. Tapping the collapse button opens `sidebar-mobile-drawer` instead of morphing. | pill-visible (drawer-closed), pill-hidden (drawer-open) |

## sidebar-projects-header

Expand Down Expand Up @@ -168,7 +168,7 @@ Global command palette (`CommandPalette.tsx`). Opens via `Ctrl+K` / `⌘K` or to
|---|---|---|
| `command-palette-trigger` | `Ctrl+K` on Windows/Linux, `⌘K` on Apple platforms, or search pill click. The shortcut and search pill dispatch an `open-command-palette` custom event on `document`; `CommandPalette` listens for this event to open. The keydown listener is also installed inside every **same-origin** iframe document that lives under `<body>` (mockup-viewer iframe today, future embedded previews tomorrow), and re-installed on each iframe `load` event — without this, focus inside the mockup iframe swallows the shortcut and the palette never receives it. A `MutationObserver` rooted on `document.body` catches iframes added after mount. Other overlays (e.g. the Topbar avatar dropdown) also listen for `open-command-palette` and close themselves so no two overlays coexist. | opens overlay |
| `command-palette-scrim` | Backdrop scrim — light tint (`rgba(0,0,0,0.20)`) + the **standard glass blur** (`backdrop-filter: blur(16px) saturate(140%)`). Same recipe as every other modal scrim in the product. | visible when open; click dismisses |
| `command-palette-panel` | Glass panel using the standard tokens (`--surface-glass-bg`, blur 16 px / saturate 140%, `--surface-glass-border`, `--shadow-popover`). Positioned top-center, `min(640px, 92vw)`. | scale-in animation on open |
| `command-palette-panel` | Glass panel using the standard tokens (`--surface-glass-bg`, blur 16 px / saturate 140%, `--surface-glass-border`, `--shadow-popover`). Positioned top-center, `min(640px, 92vw)`. On `< 768 px`: the keycap footer row is hidden; a 28 × 28 `✕` close button renders at the popover's top-right and replaces the ESC visual affordance. | scale-in animation on open |
| `command-palette-input` | Search text input with VscSearch icon. **Auto-focuses every time the palette opens** — focus is wired via a `useEffect` that fires when `open` flips to `true`, so the keyboard shortcut (`Ctrl/⌘+K`) AND the search-pill click both land focus in the input without an extra Tab. Matching text highlighted with `<mark>` (accent-overlay-mid bg, accent-bright text). | idle, typing (filters results live, staggered 20 ms entry animation per result) |
| `command-palette-results` | Grouped result list: projects first, then folders, then mockups. Each with appropriate icon (project icon, VscFolder, VscFile) | populated, empty ("No results"), loading |
| `command-palette-result-item` | Individual result row with icon + name + path (mono) | default, hover (`--surface-hover`), focused (keyboard), selected |
Expand Down Expand Up @@ -294,7 +294,7 @@ Full-bleed overlay that surfaces while the user drags a file over the workspace

| ID | Surface / Interaction | States |
|---|---|---|
| `drop-overlay` | Container that mounts on `dragenter` of a file over the window, dismounts on `dragleave` or after the drop hands off to `new-mockup-dialog`. Z-index 90 (above tweaker 80, dialog scrim ~50) | hidden (idle), visible (dragging file), entering (220 ms fade), exiting (120 ms fade) |
| `drop-overlay` | Container that mounts on `dragenter` of a file over the window, dismounts on `dragleave` or after the drop hands off to `new-mockup-dialog`. Z-index 90 (above tweaker 80, dialog scrim ~50). Hidden on `< 768 px` (touch devices don't have HTML5 drag-and-drop). | hidden (idle), visible (dragging file), entering (220 ms fade), exiting (120 ms fade) |
| `drop-overlay-glass-full` | Default — full-bleed scrim with `--scrim-glass-bg` + `blur(16px) saturate(140%)`. Centred panel `--surface-glass-bg` glass with dashed `--accent-overlay-mid` border (2 px), cloud-up icon, title `Drop your HTML here`, sub-line `Will be added to <breadcrumb-path>` (mono, `--text-dim`) | visible variant |
| `drop-overlay-dashed-border` | No scrim; only a dashed `--accent` 2 px border over the section/iframe target plus a floating glass chip top-right (`Drop here · <path>` in `--accent`) | visible variant |
| `drop-overlay-scrim-leve` | Same panel as `glass-full` but the scrim uses `blur(4px)` and 40 % opacity — less imposing | visible variant |
Expand Down Expand Up @@ -759,6 +759,16 @@ Cross-cutting visual and interaction surfaces defined in `globals.css` and `toke

---

## mobile-kbd-suppression

Global rule in `Kbd.module.css` that hides every `<Kbd>` keycap in viewports `< 768 px` — touch devices don't have a physical keyboard, so visually advertising shortcuts adds noise without affordance.

| ID | Surface / Interaction | States |
|---|---|---|
| `mobile-kbd-suppression` | `@media (max-width: 767px) { .group, .key, .plus { display: none; } }` in `Kbd.module.css` suppresses every keycap site-wide. Affected surfaces: `topbar-search-pill`, `command-palette` input chip + footer, all tooltips with kbd hints, every dialog footer with kbd. The `aria-label` of the search pill is also adjusted programmatically in `Topbar.tsx` (`'Search'` instead of `'Search... (Ctrl+K)'`). | suppressed (< 768 px), visible (>= 768 px) |

---

## Animation inventory

All motion tokens and keyframe animations.
Expand Down
12 changes: 12 additions & 0 deletions docs/frontend/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,3 +355,15 @@ There is no form library. Inputs are uncontrolled or controlled with `useState`
- Transitions use `var(--motion-fast)` (160ms), `var(--motion-base)` (220ms), `var(--motion-slow)` (320ms) with `var(--ease-standard)` or `var(--ease-spring)`
- New `@keyframes` rules ship with a matching `@media (prefers-reduced-motion: reduce)` override that zeros the animation
- Tldraw owns its own animations; we don't try to override them

## Mobile shell pattern

Every authenticated route (home, projects, settings, annotations) uses the same mobile shell:

- The `<Sidebar>` component is the single owner of the mobile entry point. At viewport `< 768 px` it renders only the collapsed pill (logo `M.` + collapse button) anchored top-left.
- Tapping the pill opens `sidebar-mobile-drawer` — a native `<dialog>` 280 × 100vh that slides in from the left with a `oklch(0% 0 0 / 0.6)` scrim covering the rest of the viewport.
- The drawer reuses the same `treeContent` and `footerContent` props passed to the desktop `<Sidebar>` — no parallel mobile tree.
- Four ways to close the drawer: scrim tap, `Esc`, any nav-link tap inside the drawer, the `✕` button in the panel's top-right (28 × 28).
- `ProjectSidebar` (and any future per-route sidebar) does NOT implement its own hamburger. The `<Sidebar>` component handles it for everyone.
- The topbar's search-pill collapses to a 32 × 32 icon button between the breadcrumb and the avatar at `< 768 px`. Kbd chips disappear (global rule in `Kbd.module.css`).
- The command palette adds a `✕` button on mobile to replace the missing ESC affordance.
8 changes: 8 additions & 0 deletions docs/frontend/styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,14 @@ import styles from './CommandPalette.module.css';
</div>
```

## Mobile rules

Component mobile styles live in the component's own module CSS under `@media (max-width: 767px)`. The breakpoint is a single global value — `767 px` (phones-only; tablets in portrait stay on the desktop layout).

The one exception is `<Kbd>` suppression: the rule lives in `Kbd.module.css` because keycap-hiding is component-agnostic — it must apply to every consumer (topbar, command palette, tooltips, dialog footers) without each one having to opt in.

For live JS reactivity (e.g. selecting a different render path based on viewport), use the `useIsMobile()` hook from `src/hooks/useIsMobile.ts`. The hook subscribes to `matchMedia('(max-width: 767px)')` and updates on resize / rotation. SSR returns `false` so the first paint matches the SSR HTML; the hook upgrades on mount.

## Adding a new token

1. Pick a category (colour, motion, spacing, etc.) — if the value doesn't fit any, add a new category
Expand Down
6 changes: 0 additions & 6 deletions src/app/projects/ProjectSidebar.module.css
Original file line number Diff line number Diff line change
@@ -1,9 +1,3 @@
.mobileFooter {
padding: 8px;
border-top: 1px solid var(--border-subtle);
flex-shrink: 0;
}

/* ─── Footer "New mockup" button (DS 13 § action variant) ─
* Opens the file picker. Uses the DS "action" variant tokens
* (accent-soft surface, accent-overlay-mid border, accent text) so
Expand Down
153 changes: 2 additions & 151 deletions src/app/projects/ProjectSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
'use client';

import { usePathname, useRouter } from 'next/navigation';
import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { VscAdd, VscNewFile, VscThreeBars } from 'react-icons/vsc';
import { type ChangeEvent, useCallback, useMemo, useRef, useState } from 'react';
import { VscAdd, VscNewFile } from 'react-icons/vsc';
import { flattenProjectTree } from '@/components/CommandPalette/flatten';
import { useConfirm } from '@/components/ConfirmDialog';
import { NewProjectDialog } from '@/components/NewProjectDialog/NewProjectDialog';
Expand Down Expand Up @@ -56,11 +56,8 @@ export function ProjectSidebar({
onUploadFile,
loading = false,
}: ProjectSidebarProps) {
const [mobileOpen, setMobileOpen] = useState(false);
const [newProjectOpen, setNewProjectOpen] = useState(false);
const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
const dialogRef = useRef<HTMLDialogElement>(null);
const hamburgerRef = useRef<HTMLButtonElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const router = useRouter();
const pathname = usePathname();
Expand Down Expand Up @@ -236,25 +233,6 @@ export function ProjectSidebar({
[confirm, nodeNameById, pathname, refreshShell, router],
);

useEffect(() => {
if (mobileOpen && dialogRef.current && !dialogRef.current.open) {
dialogRef.current.showModal();
} else if (!mobileOpen && dialogRef.current?.open) {
dialogRef.current.close();
}
}, [mobileOpen]);

useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
const onClose = () => {
setMobileOpen(false);
hamburgerRef.current?.focus();
};
dialog.addEventListener('close', onClose);
return () => dialog.removeEventListener('close', onClose);
}, []);

// Files chosen via the hidden picker flow through `validateFile`
// before reaching the parent. Invalid files toast; valid ones invoke
// the upload callback (mounted by T18). The input is reset
Expand Down Expand Up @@ -330,10 +308,6 @@ export function ProjectSidebar({
</>
);

// The footer is rendered twice (desktop Sidebar + mobile drawer)
// so the hidden `<input type="file">` is hoisted to a single,
// top-level mount below to keep `fileInputRef` pointing at exactly
// one element across both view modes.
const footerContent = (
<button
type="button"
Expand Down Expand Up @@ -378,129 +352,6 @@ export function ProjectSidebar({
<Sidebar footer={footerContent} defaultCollapsed={defaultCollapsed}>
{treeContent}
</Sidebar>

{/* Mobile hamburger */}
<button
ref={hamburgerRef}
type="button"
aria-label="Open navigation menu"
onClick={() => setMobileOpen(true)}
className="project-sidebar-hamburger"
style={{
display: 'none',
position: 'fixed',
top: 'var(--space-sm)',
left: 'var(--space-sm)',
zIndex: 50,
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 'var(--radius-xs)',
background: 'var(--btn-bg)',
color: 'var(--text-dim)',
border: 'none',
cursor: 'pointer',
}}
>
<VscThreeBars size={18} aria-hidden="true" />
</button>

{/* Mobile drawer */}
<dialog
ref={dialogRef}
aria-modal="true"
aria-label="Navigation menu"
onClick={(e) => {
if (e.target === dialogRef.current) setMobileOpen(false);
}}
onKeyDown={(e) => {
if (e.key === 'Escape') setMobileOpen(false);
}}
className="project-sidebar-drawer"
style={{
position: 'fixed',
inset: 0,
zIndex: 100,
border: 'none',
background: 'transparent',
padding: 0,
margin: 0,
maxWidth: '100vw',
maxHeight: '100vh',
width: '100vw',
height: '100vh',
}}
>
<div
style={{
position: 'absolute',
inset: 0,
background: 'oklch(0% 0 0 / 0.6)',
}}
/>
<div
style={{
position: 'relative',
width: 'var(--sidebar-width)',
height: '100%',
background: 'var(--bg-elevated)',
display: 'flex',
flexDirection: 'column',
boxShadow: 'var(--shadow-md)',
}}
>
<button
type="button"
aria-label="Close menu"
onClick={() => setMobileOpen(false)}
style={{
position: 'absolute',
top: 'var(--space-xs)',
right: 'var(--space-xs)',
width: 28,
height: 28,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 'var(--radius-xs)',
background: 'none',
color: 'var(--text-muted)',
border: 'none',
cursor: 'pointer',
zIndex: 1,
}}
>
</button>
{/* The mobile drawer no longer hosts a fixed "Projects" header
— the label has moved inline into the scroll content
(`projectsInlineLabel`) so both desktop and mobile share
the same singular DS-01 recipe. */}
<div
style={{
flex: 1,
overflowY: 'auto',
scrollbarWidth: 'thin',
scrollbarColor: 'var(--border) transparent',
paddingTop: 'var(--space-md)',
}}
>
{treeContent}
</div>
<div className={sidebarStyles.mobileFooter}>{footerContent}</div>
</div>
</dialog>

<style>{`
@media (max-width: 767px) {
.project-sidebar-hamburger { display: flex !important; }
}
@media (min-width: 768px) {
.project-sidebar-drawer { display: none !important; }
}
.project-sidebar-drawer::backdrop { background: transparent; }
`}</style>
</div>
);
}
13 changes: 13 additions & 0 deletions src/app/projects/layout.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,18 @@
@media (max-width: 767px) {
.shell {
grid-template-columns: 1fr;
/* The sidebar wrapper collapses to zero in mobile (drawer +
* trigger are position:fixed siblings); rightCol takes the
* remaining 1fr. Without `auto`, the wrapper's explicit row
* would grab `1fr` and push topbar + main offscreen. */
grid-template-rows: auto 1fr;
}

.main {
/* Desktop: scroll lives in nested containers (.scroll, .viewer).
* Mobile: those containers measure too short because the wrapper
* row used to swallow the height — let the main scroll itself so
* long content (home dashboard, project workspace) is reachable. */
overflow-y: auto;
}
}
10 changes: 10 additions & 0 deletions src/components/Breadcrumbs/Breadcrumbs.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
flex-shrink: 1;
}

/* Mobile: breadcrumb is hidden — the topbar's left padding clears the
* floating pill instead. Keeps the topbar uncluttered at 390 px and
* avoids long route names being truncated with no space to recover.
* Spec: docs/superpowers/specs/2026-05-23-mobile-shell-refinement-spec.md */
@media (max-width: 767px) {
.nav {
display: none;
}
}

.list {
list-style: none;
display: flex;
Expand Down
Loading
Loading