Improve mobile UX with larger touch targets (#223)

- Add useIsMobile hook for viewport detection
- TabBar: larger height (48px), disable reorder on mobile, hide workspace numbers
- Header buttons: larger touch targets for SpellbookDropdown, UserMenu, LayoutControls
- Window toolbar: larger buttons (40px) on mobile
- Mosaic dividers: wider (12px) on mobile for easier dragging
- CommandLauncher: larger items, footer text, hide kbd hints on mobile
- Editor suggestions: responsive widths, min-height 44px touch targets
- EventFooter: larger kind/relay buttons on mobile
- CodeCopyButton: larger padding and icon on mobile
- MembersDropdown: larger trigger and list items on mobile

All changes use mobile-first Tailwind (base styles for mobile, md: for desktop)
to meet Apple HIG 44px minimum touch target recommendation.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-01-28 12:19:31 +01:00
committed by GitHub
parent a13b990e31
commit 2ad3f90174
15 changed files with 147 additions and 54 deletions

View File

@@ -20,13 +20,13 @@ export function CodeCopyButton({
return (
<button
onClick={onCopy}
className={`absolute top-2 right-2 p-2 bg-background/90 hover:bg-muted border border-border rounded transition-colors ${className}`.trim()}
className={`absolute top-2 right-2 p-3 md:p-2 bg-background/90 hover:bg-muted border border-border rounded transition-colors ${className}`.trim()}
aria-label={label}
>
{copied ? (
<CopyCheck className="size-4 text-muted-foreground" />
<CopyCheck className="size-5 md:size-4 text-muted-foreground" />
) : (
<Copy className="size-4 text-muted-foreground" />
<Copy className="size-5 md:size-4 text-muted-foreground" />
)}
</button>
);

View File

@@ -241,7 +241,7 @@ export default function CommandLauncher({
</div>
)}
{cmd.spellCommand && (
<div className="text-[10px] opacity-50 font-mono truncate mt-0.5">
<div className="text-xs md:text-[10px] opacity-50 font-mono truncate mt-0.5">
{cmd.spellCommand}
</div>
)}
@@ -254,7 +254,7 @@ export default function CommandLauncher({
</Command.List>
<div className="command-footer">
<div>
<div className="hidden md:block">
<kbd></kbd> navigate
<kbd></kbd> execute
<kbd>esc</kbd> close

View File

@@ -41,15 +41,17 @@ export function EventFooter({ event }: EventFooterProps) {
{/* Left: Kind Badge */}
<button
onClick={handleKindClick}
className="group flex items-center gap-1.5 cursor-crosshair hover:text-foreground transition-colors"
className="group flex items-center gap-2 md:gap-1.5 min-h-[44px] md:min-h-0 px-1 -mx-1 cursor-crosshair hover:text-foreground transition-colors"
title={`View documentation for kind ${event.kind}`}
>
<KindBadge
kind={event.kind}
variant="compact"
iconClassname="text-muted-foreground group-hover:text-foreground transition-colors size-3"
iconClassname="text-muted-foreground group-hover:text-foreground transition-colors size-4 md:size-3"
/>
<span className="text-[10px] leading-[10px]">{kindName}</span>
<span className="text-xs md:text-[10px] md:leading-[10px]">
{kindName}
</span>
</button>
{/* Right: Relay Dropdown */}
@@ -57,11 +59,11 @@ export function EventFooter({ event }: EventFooterProps) {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex items-center gap-1 cursor-pointer hover:text-foreground transition-colors"
className="flex items-center gap-2 md:gap-1 min-h-[44px] md:min-h-0 px-1 -mx-1 cursor-pointer hover:text-foreground transition-colors"
title={`Seen on ${relays.length} relay${relays.length > 1 ? "s" : ""}`}
>
<Wifi className="size-3" />
<span className="text-[10px] leading-[10px]">
<Wifi className="size-4 md:size-3" />
<span className="text-xs md:text-[10px] md:leading-[10px]">
{relays.length}
</span>
</button>

View File

@@ -110,10 +110,10 @@ export function LayoutControls() {
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
className="h-10 w-10 md:h-6 md:w-6"
aria-label="Layout settings"
>
<SlidersHorizontal className="h-3 w-3 text-muted-foreground" />
<SlidersHorizontal className="h-5 w-5 md:h-3 md:w-3 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">

View File

@@ -285,7 +285,7 @@ export function SpellbookDropdown() {
variant="ghost"
size="sm"
className={cn(
"h-7 px-2 gap-1.5 text-muted-foreground hover:text-foreground",
"h-10 md:h-7 px-2 gap-1.5 text-muted-foreground hover:text-foreground",
activeSpellbook && "text-foreground font-medium",
isTemporary && "ring-1 ring-amber-500/50",
)}

View File

@@ -6,6 +6,7 @@ import { LayoutControls } from "./LayoutControls";
import { useEffect, useState } from "react";
import { Reorder, useDragControls } from "framer-motion";
import { Workspace } from "@/types/app";
import { useIsMobile } from "@/hooks/useIsMobile";
interface TabItemProps {
ws: Workspace;
@@ -17,6 +18,7 @@ interface TabItemProps {
saveLabel: () => void;
setActiveWorkspace: (id: string) => void;
startEditing: (id: string, label?: string) => void;
isMobile: boolean;
}
function TabItem({
@@ -29,6 +31,7 @@ function TabItem({
saveLabel,
setActiveWorkspace,
startEditing,
isMobile,
}: TabItemProps) {
const dragControls = useDragControls();
@@ -38,7 +41,7 @@ function TabItem({
value={ws}
dragListener={false}
dragControls={dragControls}
whileDrag={{ scale: 1.05 }}
whileDrag={isMobile ? undefined : { scale: 1.05 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className={cn(
"flex items-center justify-center cursor-default outline-none",
@@ -48,14 +51,14 @@ function TabItem({
// Render input field when editing
<div
className={cn(
"px-3 py-1 text-xs font-mono rounded flex items-center gap-2 flex-shrink-0",
"px-3 py-2 md:py-1 text-sm md:text-xs font-mono rounded flex items-center gap-2 flex-shrink-0",
isActive
? "bg-primary text-primary-foreground"
: "bg-muted text-foreground",
)}
onClick={(e) => e.stopPropagation()}
>
<span>{ws.number}</span>
<span className="hidden md:inline">{ws.number}</span>
<input
type="text"
value={editingLabel}
@@ -73,28 +76,35 @@ function TabItem({
// Render button when not editing
<div
className={cn(
"flex items-center gap-0 px-1 py-0.5 text-xs font-mono rounded transition-colors whitespace-nowrap flex-shrink-0 group",
"flex items-center gap-0 px-3 py-2 md:px-1 md:py-0.5 text-sm md:text-xs font-mono rounded transition-colors whitespace-nowrap flex-shrink-0 group",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
<div
onPointerDown={(e) => dragControls.start(e)}
className="cursor-grab active:cursor-grabbing p-1 hover:bg-black/10 rounded flex items-center justify-center"
>
<GripVertical className="h-3 w-3 opacity-50 group-hover:opacity-100 transition-opacity" />
</div>
{/* Hide drag handle on mobile - reordering disabled */}
{!isMobile && (
<div
onPointerDown={(e) => dragControls.start(e)}
className="cursor-grab active:cursor-grabbing p-1 hover:bg-black/10 rounded flex items-center justify-center"
>
<GripVertical className="h-3 w-3 opacity-50 group-hover:opacity-100 transition-opacity" />
</div>
)}
<button
onClick={() => setActiveWorkspace(ws.id)}
onDoubleClick={() => startEditing(ws.id, ws.label)}
className="flex items-center gap-2 px-1 py-0.5 cursor-pointer"
>
<span>{ws.number}</span>
{ws.label && ws.label.trim() && (
{/* Hide workspace number on mobile - only useful for keyboard shortcuts */}
<span className="hidden md:inline">{ws.number}</span>
{ws.label && ws.label.trim() ? (
<span style={{ width: `${ws.label.trim().length || 0}ch` }}>
{ws.label.trim()}
</span>
) : (
/* Show number as fallback on mobile when no label */
<span className="md:hidden">{ws.number}</span>
)}
</button>
</div>
@@ -113,6 +123,7 @@ export function TabBar() {
reorderWorkspaces,
} = useGrimoire();
const { workspaces, activeWorkspaceId } = state;
const isMobile = useIsMobile();
// State for inline label editing
const [editingId, setEditingId] = useState<string | null>(null);
@@ -195,12 +206,16 @@ export function TabBar() {
return (
<>
<div className="h-8 border-t border-border bg-background flex items-center px-2 gap-1 overflow-x-auto no-scrollbar">
<div className="h-12 md:h-8 border-t border-border bg-background flex items-center px-2 gap-1 overflow-x-auto no-scrollbar">
{/* Left side: Workspace tabs + new workspace button */}
<Reorder.Group
axis="x"
values={sortedWorkspaces}
onReorder={(newOrder) => reorderWorkspaces(newOrder.map((w) => w.id))}
onReorder={
isMobile
? () => {} // No-op on mobile - reordering disabled
: (newOrder) => reorderWorkspaces(newOrder.map((w) => w.id))
}
className="flex items-center gap-1 flex-nowrap list-none p-0 m-0"
>
{sortedWorkspaces.map((ws) => (
@@ -215,6 +230,7 @@ export function TabBar() {
saveLabel={saveLabel}
setActiveWorkspace={setActiveWorkspace}
startEditing={startEditing}
isMobile={isMobile}
/>
))}
</Reorder.Group>
@@ -222,11 +238,11 @@ export function TabBar() {
<Button
variant="ghost"
size="icon"
className="h-6 w-6 ml-1 flex-shrink-0"
className="h-10 w-10 md:h-6 md:w-6 ml-1 flex-shrink-0"
onClick={handleNewTab}
aria-label="Create new workspace"
>
<Plus className="h-3 w-3" />
<Plus className="h-5 w-5 md:h-3 md:w-3" />
</Button>
{/* Spacer to push right side controls to the end */}

View File

@@ -148,12 +148,12 @@ export function WindowToolbar({
<Button
variant="link"
size="icon"
className="text-muted-foreground"
className="h-10 w-10 md:h-9 md:w-9 text-muted-foreground"
onClick={handleEdit}
title="Edit command"
aria-label="Edit command"
>
<Pencil className="size-4" />
<Pencil className="size-5 md:size-4" />
</Button>
{/* Copy button for NIPs */}
@@ -161,13 +161,17 @@ export function WindowToolbar({
<Button
variant="link"
size="icon"
className="text-muted-foreground"
className="h-10 w-10 md:h-9 md:w-9 text-muted-foreground"
onClick={handleCopyNip}
title="Copy NIP markdown"
aria-label="Copy NIP markdown"
disabled={!nipContent}
>
{copied ? <CopyCheck /> : <Copy />}
{copied ? (
<CopyCheck className="size-5 md:size-4" />
) : (
<Copy className="size-5 md:size-4" />
)}
</Button>
)}
@@ -177,11 +181,11 @@ export function WindowToolbar({
<Button
variant="link"
size="icon"
className="text-muted-foreground"
className="h-10 w-10 md:h-9 md:w-9 text-muted-foreground"
title="More actions"
aria-label="More actions"
>
<MoreVertical className="size-4" />
<MoreVertical className="size-5 md:size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@@ -248,12 +252,12 @@ export function WindowToolbar({
<Button
variant="link"
size="icon"
className="text-muted-foreground"
className="h-10 w-10 md:h-9 md:w-9 text-muted-foreground"
onClick={onClose}
title="Close window"
aria-label="Close window"
>
<X className="size-4" />
<X className="size-5 md:size-4" />
</Button>
)}
</>

View File

@@ -21,9 +21,9 @@ export function MembersDropdown({ participants }: MembersDropdownProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors">
<button className="flex items-center gap-1 px-1 text-muted-foreground hover:text-foreground transition-colors">
<Users2 className="size-3" />
<span>{participants.length}</span>
<span className="text-xs">{participants.length}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">

View File

@@ -5,6 +5,14 @@
overflow: hidden;
}
@media (max-width: 767px) {
.grimoire-command-launcher {
/* Use width with calc to stay centered (dialog uses translate-x-50%) */
width: calc(100% - 32px);
max-width: calc(100% - 32px);
}
}
.grimoire-command-content {
width: 100%;
}
@@ -84,13 +92,19 @@
}
.command-item {
padding: 10px 12px;
padding: 14px 16px;
cursor: pointer;
transition: all 0.1s ease;
margin-bottom: 2px;
border: 1px solid transparent;
}
@media (min-width: 768px) {
.command-item {
padding: 10px 12px;
}
}
/* Inverted selection - terminal style */
.command-item[aria-selected="true"] {
background: hsl(var(--foreground));
@@ -163,25 +177,38 @@
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
font-size: 14px;
color: hsl(var(--muted-foreground));
font-family: monospace;
}
@media (min-width: 768px) {
.command-footer {
font-size: 12px;
}
}
.command-footer-status {
color: hsl(var(--accent));
font-weight: 600;
}
.command-footer kbd {
padding: 2px 6px;
padding: 4px 10px;
background: hsl(var(--muted));
border: 1px solid hsl(var(--border));
font-size: 11px;
font-size: 13px;
font-family: monospace;
margin: 0 4px;
}
@media (min-width: 768px) {
.command-footer kbd {
padding: 2px 6px;
font-size: 11px;
}
}
/* Scrollbar styling to match terminal aesthetic */
.command-list::-webkit-scrollbar {
width: 8px;

View File

@@ -103,9 +103,9 @@ export const EmojiSuggestionList = forwardRef<
<div
ref={listRef}
role="listbox"
className="max-h-[240px] w-[296px] overflow-y-auto border border-border/50 bg-popover p-2 text-popover-foreground shadow-md"
className="max-h-[280px] w-full max-w-[296px] overflow-y-auto border border-border/50 bg-popover p-2 text-popover-foreground shadow-md"
>
<div className="grid grid-cols-8 gap-0.5">
<div className="grid grid-cols-6 md:grid-cols-8 gap-1 md:gap-0.5">
{items.map((item, index) => (
<button
key={`${item.shortcode}-${item.source}`}
@@ -115,20 +115,22 @@ export const EmojiSuggestionList = forwardRef<
onClick={() => command(item)}
onMouseEnter={() => setSelectedIndex(index)}
className={cn(
"flex size-8 items-center justify-center rounded transition-colors",
"flex size-10 md:size-8 items-center justify-center rounded transition-colors",
index === selectedIndex ? "bg-muted" : "hover:bg-muted/60",
)}
title={`:${item.shortcode}:`}
>
{item.source === "unicode" ? (
// Unicode emoji - render as text
<span className="text-lg leading-none">{item.url}</span>
<span className="text-xl md:text-lg leading-none">
{item.url}
</span>
) : (
// Custom emoji - render as image
<img
src={item.url}
alt={`:${item.shortcode}:`}
className="size-6 object-contain"
className="size-7 md:size-6 object-contain"
loading="lazy"
onError={(e) => {
// Replace with fallback on error

View File

@@ -81,7 +81,7 @@ export const ProfileSuggestionList = forwardRef<
<div
ref={listRef}
role="listbox"
className="max-h-[300px] w-[320px] overflow-y-auto border border-border/50 bg-popover text-popover-foreground shadow-md"
className="max-h-[300px] w-full max-w-[320px] overflow-y-auto border border-border/50 bg-popover text-popover-foreground shadow-md"
>
{items.map((item, index) => (
<button
@@ -90,7 +90,7 @@ export const ProfileSuggestionList = forwardRef<
aria-selected={index === selectedIndex}
onClick={() => command(item)}
onMouseEnter={() => setSelectedIndex(index)}
className={`flex w-full items-center gap-3 px-3 py-2 text-left transition-colors ${
className={`flex w-full items-center gap-3 px-3 py-3 md:py-2 min-h-[44px] text-left transition-colors ${
index === selectedIndex ? "bg-muted/60" : "hover:bg-muted/60"
}`}
>

View File

@@ -81,7 +81,7 @@ export const SlashCommandSuggestionList = forwardRef<
<div
ref={listRef}
role="listbox"
className="max-h-[300px] w-[320px] overflow-y-auto border border-border/50 bg-popover text-popover-foreground shadow-md"
className="max-h-[300px] w-full max-w-[320px] overflow-y-auto border border-border/50 bg-popover text-popover-foreground shadow-md"
>
{items.map((item, index) => (
<button
@@ -90,11 +90,11 @@ export const SlashCommandSuggestionList = forwardRef<
aria-selected={index === selectedIndex}
onClick={() => command(item)}
onMouseEnter={() => setSelectedIndex(index)}
className={`flex w-full items-center gap-3 px-3 py-2 text-left transition-colors ${
className={`flex w-full items-center gap-3 px-3 py-3 md:py-2 min-h-[44px] text-left transition-colors ${
index === selectedIndex ? "bg-muted/60" : "hover:bg-muted/60"
}`}
>
<Terminal className="size-4 flex-shrink-0 text-popover-foreground/60" />
<Terminal className="size-5 md:size-4 flex-shrink-0 text-popover-foreground/60" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium font-mono">
/{item.name}

View File

@@ -356,6 +356,7 @@ export default function UserMenu() {
<Button
size="sm"
variant="link"
className="h-10 w-10 md:h-8 md:w-auto p-2 md:p-1"
aria-label={account ? "User menu" : "Log in"}
>
{account ? (

28
src/hooks/useIsMobile.ts Normal file
View File

@@ -0,0 +1,28 @@
import { useState, useEffect } from "react";
const MOBILE_BREAKPOINT = 768;
/**
* Hook to detect if the viewport is mobile-sized.
* Uses matchMedia for efficient updates on resize.
*
* @param breakpoint - Width threshold in pixels (default: 768)
* @returns true if viewport width is below breakpoint
*/
export function useIsMobile(breakpoint = MOBILE_BREAKPOINT): boolean {
const [isMobile, setIsMobile] = useState(() =>
typeof window !== "undefined"
? window.matchMedia(`(max-width: ${breakpoint - 1}px)`).matches
: false,
);
useEffect(() => {
const mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [breakpoint]);
return isMobile;
}

View File

@@ -457,6 +457,19 @@ body.animating-layout
margin: -2px 0;
}
/* Mobile: Wider split dividers for touch dragging */
@media (max-width: 767px) {
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme .mosaic-split.-row {
width: 12px;
margin: 0 -6px;
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme .mosaic-split.-column {
height: 12px;
margin: -6px 0;
}
}
/* ==========================================================================
Accessibility: Focus Indicators
========================================================================== */