mobile-first UI redesign — touch targets, responsive layout, safe areas

- MessageItem: 44px touch targets, relative time on mobile, tap-expand metadata
- ComposeBox: safe-area-inset-bottom, compact type labels (T/C/R/E/P)
- ChannelSidebar: wider on mobile, 44px channel buttons
- All components: mobile-first with md: breakpoint for desktop
- viewport: cover, no-scale, apple-mobile-web-app-capable
- Pure Tailwind, no custom CSS

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 19:35:44 +02:00
parent 98086b7ce7
commit 69e8384598
4 changed files with 124 additions and 108 deletions

View File

@@ -3,7 +3,10 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="theme-color" content="#1a1a2e" />
<title>colony</title> <title>colony</title>
</head> </head>
<body> <body>

View File

@@ -31,20 +31,20 @@ export function ChannelSidebar({
} }
return ( return (
<div className="flex flex-col h-full w-52 border-r border-sidebar-border bg-sidebar text-sidebar-foreground"> <div className="flex flex-col h-full w-64 md:w-52 border-r border-white/[0.06] bg-[oklch(0.1_0.005_260)] text-muted-foreground">
{/* Header */} {/* Header */}
<div className="px-3 py-3 border-b border-sidebar-border"> <div className="px-4 py-3 md:px-3 border-b border-white/[0.06]">
<div className="text-[15px] font-bold tracking-tight text-foreground"> <div className="text-[16px] md:text-[15px] font-bold tracking-tight text-foreground">
COLONY COLONY
</div> </div>
<div className="text-[10px] text-muted-foreground mt-0.5"> <div className="text-[11px] md:text-[10px] text-muted-foreground/60 mt-0.5">
apes.unslope.com apes.unslope.com
</div> </div>
</div> </div>
{/* Channel list */} {/* Channel list */}
<div className="flex-1 overflow-y-auto py-2"> <div className="flex-1 overflow-y-auto py-2">
<div className="px-2 mb-1 text-[10px] font-bold text-muted-foreground tracking-widest"> <div className="px-3 md:px-2 mb-1 text-[10px] font-bold text-muted-foreground/50 tracking-widest">
CHANNELS CHANNELS
</div> </div>
{channels.map((ch) => ( {channels.map((ch) => (
@@ -52,26 +52,26 @@ export function ChannelSidebar({
type="button" type="button"
key={ch.id} key={ch.id}
onClick={() => onSelect(ch.id)} onClick={() => onSelect(ch.id)}
className={`w-full text-left px-3 py-1 text-[12px] transition-colors ${ className={`w-full text-left px-4 md:px-3 py-2 md:py-1 text-[13px] md:text-[12px] transition-colors min-h-[44px] md:min-h-0 flex items-center ${
ch.id === activeId ch.id === activeId
? "bg-sidebar-accent text-sidebar-accent-foreground font-medium" ? "bg-white/[0.06] text-foreground font-medium"
: "text-sidebar-foreground hover:bg-sidebar-accent/50" : "hover:bg-white/[0.03]"
}`} }`}
> >
<span className="text-muted-foreground mr-1">#</span> <span className="text-muted-foreground/50 mr-1.5">#</span>
{ch.name} {ch.name}
</button> </button>
))} ))}
</div> </div>
{/* Current user */} {/* Current user */}
<div className="px-3 py-2 border-t border-sidebar-border text-[11px]"> <div className="px-4 md:px-3 py-2 border-t border-white/[0.06] text-[11px]">
<span className="text-muted-foreground">logged in as </span> <span className="text-muted-foreground/50">as </span>
<span className="font-bold text-foreground">{getCurrentUsername()}</span> <span className="font-bold text-foreground">{getCurrentUsername()}</span>
</div> </div>
{/* New channel input */} {/* New channel */}
<div className="p-2 border-t border-sidebar-border"> <div className="p-2 border-t border-white/[0.06]">
<input <input
type="text" type="text"
placeholder="+ new channel" placeholder="+ new channel"
@@ -79,7 +79,7 @@ export function ChannelSidebar({
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreate()} onKeyDown={(e) => e.key === "Enter" && handleCreate()}
disabled={creating} disabled={creating}
className="w-full bg-sidebar-accent text-[11px] text-sidebar-foreground placeholder:text-muted-foreground px-2 py-1 rounded-sm border border-sidebar-border focus:outline-none focus:border-[var(--color-agent-glow)]" className="w-full bg-white/[0.04] text-[12px] md:text-[11px] text-foreground placeholder:text-muted-foreground/40 px-3 py-2 md:py-1 rounded-sm border border-white/[0.06] focus:outline-none focus:border-blue-500/30 min-h-[40px] md:min-h-0"
/> />
</div> </div>
</div> </div>

View File

@@ -9,12 +9,12 @@ interface Props {
onMessageSent: () => void; onMessageSent: () => void;
} }
const TYPES: { value: MessageType; label: string; key: string }[] = [ const TYPES: { value: MessageType; label: string; shortLabel: string }[] = [
{ value: "text", label: "TXT", key: "1" }, { value: "text", label: "TXT", shortLabel: "T" },
{ value: "code", label: "CODE", key: "2" }, { value: "code", label: "CODE", shortLabel: "C" },
{ value: "result", label: "RES", key: "3" }, { value: "result", label: "RES", shortLabel: "R" },
{ value: "error", label: "ERR", key: "4" }, { value: "error", label: "ERR", shortLabel: "E" },
{ value: "plan", label: "PLAN", key: "5" }, { value: "plan", label: "PLAN", shortLabel: "P" },
]; ];
export function ComposeBox({ export function ComposeBox({
@@ -46,40 +46,42 @@ export function ComposeBox({
} }
return ( return (
<div className="border-t border-border bg-card px-4 py-3"> <div className="border-t border-border bg-card px-3 py-2 md:px-4 md:py-3 pb-[env(safe-area-inset-bottom,8px)]">
{replyTo && ( {replyTo && (
<div className="flex items-center gap-2 mb-2 text-[11px] text-muted-foreground"> <div className="flex items-center gap-2 mb-1.5 text-[10px] md:text-[11px] text-muted-foreground">
<span>replying to #{replyTo.slice(0, 8)}</span> <span>^ #{replyTo.slice(0, 8)}</span>
<button <button
type="button" type="button"
onClick={onClearReply} onClick={onClearReply}
className="text-[10px] hover:text-foreground" className="hover:text-foreground min-w-[44px] min-h-[32px] flex items-center justify-center md:min-w-0 md:min-h-0"
> >
[x] [x]
</button> </button>
</div> </div>
)} )}
<div className="flex items-center gap-2">
{/* Type selector */} <div className="flex items-center gap-1.5 md:gap-2">
{/* Type selector — compact on mobile */}
<div className="flex gap-0.5"> <div className="flex gap-0.5">
{TYPES.map((t) => ( {TYPES.map((t) => (
<button <button
type="button" type="button"
key={t.value} key={t.value}
onClick={() => setMsgType(t.value)} onClick={() => setMsgType(t.value)}
className={`px-1.5 py-0.5 text-[10px] font-bold rounded-sm transition-colors ${ className={`px-1 md:px-1.5 py-1 md:py-0.5 text-[9px] md:text-[10px] font-bold rounded-sm transition-colors min-w-[28px] md:min-w-0 min-h-[36px] md:min-h-0 flex items-center justify-center ${
msgType === t.value msgType === t.value
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground" : "text-muted-foreground hover:text-foreground"
}`} }`}
title={`${t.label} (Alt+${t.key})`}
> >
{t.label} {/* Short label on mobile, full on desktop */}
<span className="md:hidden">{t.shortLabel}</span>
<span className="hidden md:inline">{t.label}</span>
</button> </button>
))} ))}
</div> </div>
{/* Input */} {/* Input — larger touch target on mobile */}
<input <input
type="text" type="text"
value={content} value={content}
@@ -89,23 +91,22 @@ export function ComposeBox({
e.preventDefault(); e.preventDefault();
handleSend(); handleSend();
} }
// Alt+1-5 for type switching
if (e.altKey && e.key >= "1" && e.key <= "5") { if (e.altKey && e.key >= "1" && e.key <= "5") {
setMsgType(TYPES[parseInt(e.key) - 1].value); setMsgType(TYPES[parseInt(e.key) - 1].value);
} }
}} }}
placeholder={`message #${channelId.slice(0, 8)}...`} placeholder="message..."
disabled={sending} disabled={sending}
className="flex-1 bg-input text-[13px] text-foreground placeholder:text-muted-foreground px-3 py-1.5 rounded-sm border border-border focus:outline-none focus:border-[var(--color-agent-glow)]" className="flex-1 bg-input text-[13px] md:text-[13px] text-foreground placeholder:text-muted-foreground px-3 py-2 md:py-1.5 rounded-sm border border-border focus:outline-none focus:border-blue-500/30"
/> />
<button <button
type="button" type="button"
onClick={handleSend} onClick={handleSend}
disabled={sending || !content.trim()} disabled={sending || !content.trim()}
className="px-3 py-1.5 text-[11px] font-bold bg-primary text-primary-foreground rounded-sm hover:opacity-80 disabled:opacity-30 transition-opacity" className="px-3 py-2 md:py-1.5 text-[11px] font-bold bg-primary text-primary-foreground rounded-sm hover:opacity-80 disabled:opacity-30 transition-opacity min-w-[44px] min-h-[36px] md:min-h-0 flex items-center justify-center"
> >
SEND {sending ? "..." : "SEND"}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,3 +1,4 @@
import { useState } from "react";
import type { Message } from "@/types/Message"; import type { Message } from "@/types/Message";
interface Props { interface Props {
@@ -6,67 +7,65 @@ interface Props {
onReply: (id: string) => void; onReply: (id: string) => void;
} }
const TYPE_CONFIG: Record< const TYPE_BORDER: Record<string, string> = {
string, text: "",
{ border: string; label: string; labelColor: string } code: "border-l-2 border-amber-500/70",
> = { result: "border-l-2 border-emerald-500/70",
text: { border: "", label: "", labelColor: "" }, error: "border-l-2 border-red-500/70",
code: { plan: "border-l-2 border-blue-500/70",
border: "border-l-2 border-[var(--color-msg-code)]",
label: "CODE",
labelColor: "text-[var(--color-msg-code)]",
},
result: {
border: "border-l-2 border-[var(--color-msg-result)]",
label: "RESULT",
labelColor: "text-[var(--color-msg-result)]",
},
error: {
border: "border-l-2 border-[var(--color-msg-error)]",
label: "ERROR",
labelColor: "text-[var(--color-msg-error)]",
},
plan: {
border: "border-l-2 border-[var(--color-msg-plan)]",
label: "PLAN",
labelColor: "text-[var(--color-msg-plan)]",
},
}; };
const TYPE_LABEL: Record<string, { text: string; color: string }> = {
code: { text: "CODE", color: "text-amber-500" },
result: { text: "RES", color: "text-emerald-500" },
error: { text: "ERR", color: "text-red-500" },
plan: { text: "PLAN", color: "text-blue-500" },
};
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "now";
if (mins < 60) return `${mins}m`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h`;
return `${Math.floor(hrs / 24)}d`;
}
export function MessageItem({ message, replyTarget, onReply }: Props) { export function MessageItem({ message, replyTarget, onReply }: Props) {
const [metaOpen, setMetaOpen] = useState(false);
const isAgent = message.user.role === "agent"; const isAgent = message.user.role === "agent";
const isDeleted = !!message.deleted_at; const isDeleted = !!message.deleted_at;
const cfg = TYPE_CONFIG[message.type] || TYPE_CONFIG.text; const border = TYPE_BORDER[message.type] || "";
const label = TYPE_LABEL[message.type];
const meta = message.metadata as Record<string, string> | null; const meta = message.metadata as Record<string, string> | null;
return ( return (
<div <div
className={`group relative px-4 py-2 transition-colors hover:bg-[oklch(0.15_0.005_260)] ${ className={`group relative px-3 py-2 md:px-4 transition-colors hover:bg-white/[0.03] ${
isAgent ? "bg-[oklch(0.13_0.008_260)]" : "" isAgent ? "bg-white/[0.02]" : ""
}`} }`}
> >
{/* Agent glow line */} {/* Agent glow — left edge */}
{isAgent && ( {isAgent && (
<div className="absolute left-0 top-0 bottom-0 w-[2px] bg-[var(--color-agent-glow)]" /> <div className="absolute left-0 top-0 bottom-0 w-0.5 bg-blue-500/30" />
)} )}
{/* Reply context */} {/* Reply context */}
{replyTarget && ( {replyTarget && (
<div className="ml-6 mb-1 text-[11px] text-muted-foreground flex items-center gap-1 opacity-70"> <div className="mb-1 text-[10px] md:text-[11px] text-muted-foreground flex items-center gap-1 opacity-60 pl-7 md:pl-9">
<span className="select-none">^</span> <span>^</span>
<span className="font-medium">{replyTarget.user.display_name}</span> <span className="font-medium">{replyTarget.user.display_name}</span>
<span className="truncate max-w-80 opacity-60"> <span className="truncate max-w-40 md:max-w-80">{replyTarget.content}</span>
{replyTarget.content}
</span>
</div> </div>
)} )}
<div className={`flex items-start gap-3 ${cfg.border} ${cfg.border ? "pl-3" : ""}`}> <div className={`flex items-start gap-2 md:gap-3 ${border} ${border ? "pl-2 md:pl-3" : ""}`}>
{/* Avatar */} {/* Avatar — small on mobile */}
<div <div
className={`w-6 h-6 flex-shrink-0 flex items-center justify-center text-[10px] font-bold rounded-sm ${ className={`w-5 h-5 md:w-6 md:h-6 flex-shrink-0 flex items-center justify-center text-[9px] md:text-[10px] font-bold rounded-sm ${
isAgent isAgent
? "bg-[var(--color-agent-glow)] text-foreground" ? "bg-blue-500/20 text-blue-400"
: "bg-secondary text-muted-foreground" : "bg-secondary text-muted-foreground"
}`} }`}
> >
@@ -74,68 +73,81 @@ export function MessageItem({ message, replyTarget, onReply }: Props) {
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{/* Header line */} {/* Header */}
<div className="flex items-center gap-2 text-[11px]"> <div className="flex items-center gap-1.5 md:gap-2 text-[10px] md:text-[11px] flex-wrap">
<span className={`font-bold ${isAgent ? "text-[oklch(0.75_0.12_250)]" : "text-foreground"}`}> <span className={`font-bold ${isAgent ? "text-blue-400" : "text-foreground"}`}>
{message.user.display_name} {message.user.display_name}
</span> </span>
{isAgent && ( {isAgent && (
<span className="text-[10px] px-1 py-0 bg-[var(--color-agent-glow)] rounded-sm text-[oklch(0.8_0.1_250)]"> <span className="text-[9px] px-1 bg-blue-500/20 rounded-sm text-blue-400">
AGENT AGT
</span> </span>
)} )}
{cfg.label && ( {label && (
<span className={`text-[10px] font-bold ${cfg.labelColor}`}> <span className={`text-[9px] md:text-[10px] font-bold ${label.color}`}>
{cfg.label} {label.text}
</span> </span>
)} )}
<span className="text-muted-foreground tabular-nums"> {/* Mobile: relative time. Desktop: full time */}
<span className="text-muted-foreground tabular-nums md:hidden">
{timeAgo(message.created_at)}
</span>
<span className="text-muted-foreground tabular-nums hidden md:inline">
{new Date(message.created_at).toLocaleTimeString("en-US", { {new Date(message.created_at).toLocaleTimeString("en-US", {
hour12: false, hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})} })}
</span> </span>
<span className="text-muted-foreground opacity-40 tabular-nums"> <span className="text-muted-foreground/40 tabular-nums hidden md:inline">
#{Number(message.seq)} #{Number(message.seq)}
</span> </span>
{/* Reply — always visible on mobile (no hover), hover on desktop */}
<button <button
type="button" type="button"
onClick={() => onReply(message.id)} onClick={() => onReply(message.id)}
className="text-[10px] text-muted-foreground opacity-0 group-hover:opacity-60 hover:!opacity-100 transition-opacity ml-auto" className="text-[9px] md:text-[10px] text-muted-foreground md:opacity-0 md:group-hover:opacity-60 hover:!opacity-100 transition-opacity ml-auto min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-end"
> >
REPLY REPLY
</button> </button>
</div> </div>
{/* Content */} {/* Content */}
<div className={`mt-0.5 text-[13px] leading-relaxed ${ <div className={`mt-0.5 text-[12px] md:text-[13px] leading-relaxed break-words ${
message.type === "code" ? "font-mono bg-[oklch(0.1_0.005_260)] px-2 py-1 rounded-sm whitespace-pre-wrap" : "" message.type === "code" ? "bg-black/20 px-2 py-1 rounded-sm whitespace-pre-wrap overflow-x-auto" : ""
} ${ } ${message.type === "error" ? "text-red-400" : ""}`}>
message.type === "error" ? "text-[var(--color-msg-error)]" : ""
}`}>
{isDeleted ? ( {isDeleted ? (
<span className="italic text-muted-foreground opacity-40"> <span className="italic text-muted-foreground/40">[deleted]</span>
[deleted]
</span>
) : ( ) : (
message.content message.content
)} )}
</div> </div>
{/* Agent metadata strip */} {/* Agent metadata — tap to expand on mobile, always compact on desktop */}
{meta && isAgent && ( {meta && isAgent && (
<div className="mt-1 flex gap-3 text-[10px] text-muted-foreground opacity-50"> <>
{/* Desktop: inline strip */}
<div className="hidden md:flex mt-1 gap-3 text-[10px] text-muted-foreground/50">
{meta.model && <span>{meta.model}</span>} {meta.model && <span>{meta.model}</span>}
{meta.hostname && <span>{meta.hostname}</span>} {meta.hostname && <span>{meta.hostname}</span>}
{meta.cwd && <span>{meta.cwd}</span>} {meta.cwd && <span>{meta.cwd}</span>}
{meta.skill && ( {meta.skill && <span className="text-blue-400/50">{meta.skill}</span>}
<span className="text-[var(--color-msg-plan)]">
{meta.skill}
</span>
)}
</div> </div>
{/* Mobile: tap to expand */}
<button
type="button"
onClick={() => setMetaOpen(!metaOpen)}
className="md:hidden mt-1 text-[9px] text-muted-foreground/40 min-h-[32px] flex items-center"
>
{metaOpen ? "hide meta" : `${meta.model || "agent"} ...`}
</button>
{metaOpen && (
<div className="md:hidden mt-0.5 text-[10px] text-muted-foreground/50 space-y-0.5">
{meta.model && <div>model: {meta.model}</div>}
{meta.hostname && <div>host: {meta.hostname}</div>}
{meta.cwd && <div>cwd: {meta.cwd}</div>}
{meta.skill && <div className="text-blue-400/50">skill: {meta.skill}</div>}
</div>
)}
</>
)} )}
</div> </div>
</div> </div>