feat(desktop): prototype composable messages

Signed-off-by: cynfria <yescynthia@gmail.com>
This commit is contained in:
cynfria
2026-08-27 16:38:21 -07:00
parent e4b5d5bb4b
commit 0f8bde696d
2 changed files with 319 additions and 4 deletions

View File

@@ -2,7 +2,23 @@
import { useEffect, useId, useRef, useState } from "react";
import type { CSSProperties, MutableRefObject, PointerEvent as ReactPointerEvent } from "react";
import { ChevronDown, Plus, Search, SquareDashed, X } from "lucide-react";
import {
ArrowUp,
AtSign,
ChevronDown,
Hash,
Headphones,
LockKeyhole,
MoreVertical,
Paperclip,
PanelRightOpen,
Plus,
Search,
SmilePlus,
SquareDashed,
Users,
X,
} from "lucide-react";
import { ProjectArtifactRenderer } from "./berd-project-cube/ProjectArtifactRenderer";
import type {
ProjectArtifactProjection,
@@ -958,8 +974,168 @@ function TaskDetailPanel({
);
}
type MessagePanelId = "sidebar" | "chat" | "thread";
type MessagePanelLayout = { x: number; y: number; detached: boolean };
const INITIAL_MESSAGE_LAYOUT: Record<MessagePanelId, MessagePanelLayout> = {
sidebar: { x: 0, y: 0, detached: false },
chat: { x: 0, y: 0, detached: false },
thread: { x: 0, y: 0, detached: false },
};
function MessageComposer({ compact = false }: { compact?: boolean }) {
return (
<div className={`message-composer${compact ? " is-compact" : ""}`}>
<span>Message #buzz-design</span>
<div className="message-composer-actions">
<AtSign aria-hidden="true" size={18} strokeWidth={1.7} />
<Paperclip aria-hidden="true" size={18} strokeWidth={1.7} />
<SmilePlus aria-hidden="true" size={18} strokeWidth={1.7} />
<button type="button" aria-label="Send message"><ArrowUp aria-hidden="true" size={18} strokeWidth={1.8} /></button>
</div>
</div>
);
}
function MessagesView() {
const [layouts, setLayouts] = useState(INITIAL_MESSAGE_LAYOUT);
const dragRef = useRef<{
id: MessagePanelId;
pointerId: number;
startX: number;
startY: number;
originX: number;
originY: number;
cell: HTMLElement;
x: number;
y: number;
} | null>(null);
const beginPanelDrag = (
event: ReactPointerEvent<HTMLElement>,
id: MessagePanelId,
) => {
if ((event.target as Element).closest("button, input")) return;
const cell = event.currentTarget.closest(".message-panel-cell") as HTMLElement | null;
if (!cell) return;
event.currentTarget.setPointerCapture(event.pointerId);
const current = layouts[id];
dragRef.current = {
id,
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
originX: current.x,
originY: current.y,
cell,
x: current.x,
y: current.y,
};
cell.classList.add("is-pulling", "is-active-drag");
};
const updatePanelDrag = (event: ReactPointerEvent<HTMLElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
if (event.buttons === 0) {
endPanelDrag(event);
return;
}
drag.x = Math.max(-340, Math.min(340, drag.originX + event.clientX - drag.startX));
drag.y = Math.max(-190, Math.min(190, drag.originY + event.clientY - drag.startY));
const distance = Math.hypot(drag.x, drag.y);
drag.cell.style.setProperty("--panel-x", `${drag.x}px`);
drag.cell.style.setProperty("--panel-y", `${drag.y}px`);
drag.cell.style.setProperty("--tether-distance", `${Math.min(distance, 126)}px`);
drag.cell.style.setProperty("--tether-angle", `${Math.atan2(drag.y, drag.x)}rad`);
drag.cell.style.setProperty("--tether-squeeze", String(Math.max(0.28, 1 - distance / 170)));
drag.cell.classList.toggle("is-breaking", distance > 94);
};
const endPanelDrag = (event: ReactPointerEvent<HTMLElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
const distance = Math.hypot(drag.x, drag.y);
const shouldAttach = distance < (layouts[drag.id].detached ? 54 : 72);
const next = shouldAttach
? { x: 0, y: 0, detached: false }
: { x: drag.x, y: drag.y, detached: true };
drag.cell.style.setProperty("--panel-x", `${next.x}px`);
drag.cell.style.setProperty("--panel-y", `${next.y}px`);
drag.cell.classList.remove("is-pulling", "is-breaking", "is-active-drag");
setLayouts((current) => ({ ...current, [drag.id]: next }));
dragRef.current = null;
};
const panelStyle = (id: MessagePanelId) => ({
"--panel-x": `${layouts[id].x}px`,
"--panel-y": `${layouts[id].y}px`,
}) as CSSProperties;
const dragHandlers = (id: MessagePanelId) => ({
onPointerDown: (event: ReactPointerEvent<HTMLElement>) => beginPanelDrag(event, id),
onPointerMove: updatePanelDrag,
onPointerUp: endPanelDrag,
onPointerCancel: endPanelDrag,
});
return (
<section className="messages-stage" aria-label="Composable messages workspace">
<div className={`messages-board${Object.values(layouts).some((panel) => panel.detached) ? " has-detached-panel" : ""}`}>
<div className={`message-panel-cell sidebar-cell${layouts.sidebar.detached ? " is-detached" : ""}`} style={panelStyle("sidebar")}>
<div className="liquid-tether" aria-hidden="true" />
<aside className="message-panel channel-panel">
<div className="panel-drag-handle channel-search-wrap" {...dragHandlers("sidebar")}>
<Search aria-hidden="true" size={18} strokeWidth={1.7} />
<input aria-label="Search channels" placeholder="Search" />
</div>
<nav className="channel-list" aria-label="Channels">
<p>Buzz</p>
{['design-system', 'buzz-interface', 'launch-planning', 'agent-tools'].map((name, index) => <button type="button" className={index === 0 ? "is-current" : ""} key={name}><Hash aria-hidden="true" size={18} strokeWidth={1.7} />{name}</button>)}
<p>Berd</p>
{['project-cubes', 'design-reviews', 'research'].map((name) => <button type="button" key={name}><Hash aria-hidden="true" size={18} strokeWidth={1.7} />{name}</button>)}
<p>Messages</p>
{['morganmartin', 'tho', 'tulsi', 'laurenkenny'].map((name) => <button type="button" key={name}><SquareDashed aria-hidden="true" size={17} strokeWidth={1.5} />{name}</button>)}
</nav>
</aside>
</div>
<div className={`message-panel-cell chat-cell${layouts.chat.detached ? " is-detached" : ""}`} style={panelStyle("chat")}>
<div className="liquid-tether" aria-hidden="true" />
<article className="message-panel chat-panel">
<header className="panel-drag-handle chat-header" {...dragHandlers("chat")}>
<div><LockKeyhole aria-hidden="true" size={18} strokeWidth={1.7} /><strong>buzz-interface-squad</strong></div>
<div className="chat-header-actions"><button type="button" aria-label="Channel privacy"><LockKeyhole aria-hidden="true" size={18} strokeWidth={1.7} /></button><button type="button" aria-label="Members"><Users aria-hidden="true" size={18} strokeWidth={1.7} /><span>13</span></button><button type="button" aria-label="Huddle"><Headphones aria-hidden="true" size={18} strokeWidth={1.7} /></button><button type="button" aria-label="More"><MoreVertical aria-hidden="true" size={18} strokeWidth={1.7} /></button></div>
</header>
<div className="chat-feed">
<div className="day-divider"><span>Yesterday</span></div>
<div className="chat-message"><img src="/berd-agent-avatars/pushback-gloopies-5.png" alt="" /><div><p><strong>Caroline McKenzie</strong><span>Yesterday at 7:05PM</span></p><div>Brought copy link out of the context menu and into the main message rail. Testing whether people miss quick reactions before we add more controls back.</div></div></div>
<div className="chat-message"><img src="/berd-agent-avatars/builderbot-gloopies-20.png" alt="" /><div><p><strong>Jude</strong><span>5:03AM</span></p><div>Outside of the code review process, do you have any feedback or concerns about Buzz development that you can share with engineers?</div><button type="button" className="reply-link"><span className="mini-avatars"><img src="/berd-agent-avatars/berdy-gloopies-22.png" alt="" /><img src="/berd-agent-avatars/pushback-gloopies-5.png" alt="" /></span><strong>4 replies</strong><span>last reply 5 hours ago</span></button></div></div>
</div>
<MessageComposer />
</article>
</div>
<div className={`message-panel-cell thread-cell${layouts.thread.detached ? " is-detached" : ""}`} style={panelStyle("thread")}>
<div className="liquid-tether" aria-hidden="true" />
<aside className="message-panel thread-panel">
<header className="panel-drag-handle thread-header" {...dragHandlers("thread")}><div><PanelRightOpen aria-hidden="true" size={18} strokeWidth={1.7} /><strong>Thread</strong></div><button type="button" aria-label="Close thread"><X aria-hidden="true" size={18} strokeWidth={1.7} /></button></header>
<div className="thread-content">
<div className="thread-message"><img src="/berd-agent-avatars/pushback-gloopies-5.png" alt="" /><div><p><strong>Caroline McKenzie</strong><span>Yesterday at 7:05PM</span></p><div>Brought copy link out of the context menu and into the main message rail. Also tried simplifying by removing quick reactions but can put them back later if people really miss them.</div></div></div>
</div>
<MessageComposer compact />
</aside>
</div>
</div>
</section>
);
}
export function BuzzProjectPrototype() {
const [activeTab, setActiveTab] = useState<"me" | "projects">("me");
const [activeTab, setActiveTab] = useState<"me" | "messages" | "projects">("me");
const [selectedTask, setSelectedTask] = useState<SelectedTask | null>(null);
const appShellRef = useRef<HTMLElement>(null);
const projectWorldRef = useRef<HTMLDivElement>(null);
@@ -973,7 +1149,7 @@ export function BuzzProjectPrototype() {
y: 0,
});
const showTab = (tab: "me" | "projects") => {
const showTab = (tab: "me" | "messages" | "projects") => {
setSelectedTask(null);
setActiveTab(tab);
};
@@ -1044,13 +1220,15 @@ export function BuzzProjectPrototype() {
<header className="topbar">
<div className="header-spacer" aria-hidden="true" />
<nav className="segmented" aria-label="Primary navigation">
<button type="button" className={activeTab === "me" ? "selected" : ""} onClick={() => showTab("me")}>Me</button><button type="button">Messages</button><button type="button" className={activeTab === "projects" ? "selected" : ""} onClick={() => showTab("projects")}>Projects</button><button type="button" className="add-button" aria-label="Create new"><Plus aria-hidden="true" size={18} strokeWidth={1.75} /></button>
<button type="button" className={activeTab === "me" ? "selected" : ""} onClick={() => showTab("me")}>Me</button><button type="button" className={activeTab === "messages" ? "selected" : ""} onClick={() => showTab("messages")}>Messages</button><button type="button" className={activeTab === "projects" ? "selected" : ""} onClick={() => showTab("projects")}>Projects</button><button type="button" className="add-button" aria-label="Create new"><Plus aria-hidden="true" size={18} strokeWidth={1.75} /></button>
</nav>
<div className="utilities"><button type="button" className="round-button" aria-label="Search"><Search className="search-icon" aria-hidden="true" size={18} strokeWidth={1.75} /></button><div className="avatar" aria-label="Cynthia"><span>C</span></div></div>
</header>
{activeTab === "projects" ? (
<ProjectsView />
) : activeTab === "messages" ? (
<MessagesView />
) : (
<section
className="project-stage"

View File

@@ -138,6 +138,142 @@ body { background: #e6f2f5; color: var(--ink); font-family: Inter, ui-sans-serif
.label-ribbon.is-selected .ribbon-stroke { stroke: var(--task-accent); filter: drop-shadow(0 12px 25px color-mix(in srgb, var(--task-accent) 42%, transparent)); }
.label-ribbon.is-selected .ribbon-text { fill: #fff; }
.messages-stage {
position: relative;
height: 100svh;
overflow: hidden;
padding: 74px 20px 20px;
background:
radial-gradient(circle, rgba(92,116,126,.12) 1px, transparent 1.2px),
radial-gradient(circle at 82% 12%, rgba(182,222,239,.72), transparent 36%),
radial-gradient(circle at 10% 90%, rgba(255,255,255,.9), transparent 34%);
background-size: 42px 42px, auto, auto;
}
.messages-board {
position: relative;
display: grid;
grid-template-columns: minmax(220px, 255px) minmax(440px, 1fr) minmax(300px, 340px);
width: 100%;
height: 100%;
isolation: isolate;
filter: drop-shadow(0 14px 30px rgba(57,78,87,.12));
}
.message-panel-cell {
--panel-x: 0px;
--panel-y: 0px;
--tether-distance: 0px;
--tether-angle: 0rad;
--tether-squeeze: 1;
position: relative;
min-width: 0;
min-height: 0;
}
.message-panel-cell.is-active-drag { z-index: 20; }
.message-panel {
position: relative;
z-index: 2;
display: flex;
width: 100%;
height: 100%;
min-width: 0;
flex-direction: column;
overflow: hidden;
background: rgba(255,255,255,.94);
border: 1px solid rgba(255,255,255,.96);
transform: translate3d(var(--panel-x), var(--panel-y), 0);
transition:
transform 480ms cubic-bezier(.22,1,.36,1),
border-radius 260ms ease-out,
box-shadow 260ms ease-out;
will-change: transform;
}
.sidebar-cell .message-panel { border-radius: 28px 0 0 28px; }
.chat-cell .message-panel { border-radius: 0; border-left-color: rgba(38,38,38,.09); }
.thread-cell .message-panel { border-radius: 0 28px 28px 0; border-left-color: rgba(38,38,38,.09); }
.message-panel-cell.is-detached .message-panel,
.message-panel-cell.is-breaking .message-panel {
border-radius: 28px;
box-shadow: 0 18px 44px rgba(53,76,86,.16), inset 0 1px 0 rgba(255,255,255,.9);
}
.message-panel-cell.is-pulling .message-panel { transition: border-radius 180ms ease-out, box-shadow 180ms ease-out; }
.panel-drag-handle { cursor: grab; touch-action: none; user-select: none; }
.message-panel-cell.is-pulling .panel-drag-handle { cursor: grabbing; }
.liquid-tether {
position: absolute;
z-index: 1;
left: 50%;
top: 50%;
width: calc(var(--tether-distance) + 88px);
height: 112px;
border-radius: 999px;
background: rgba(255,255,255,.92);
opacity: 0;
transform-origin: 0 50%;
transform: translateY(-50%) rotate(var(--tether-angle)) scaleY(var(--tether-squeeze));
transition: opacity 90ms ease, transform 120ms ease-out;
pointer-events: none;
filter: blur(1px);
}
.message-panel-cell.is-pulling .liquid-tether { opacity: 1; transition: none; }
.message-panel-cell.is-pulling.is-breaking .liquid-tether { opacity: 0; transform: translateY(-50%) rotate(var(--tether-angle)) scaleY(.08); transition: opacity 100ms ease-out, transform 160ms ease-out; }
.channel-panel { padding: 10px; }
.channel-search-wrap { display: flex; height: 42px; align-items: center; gap: 10px; padding: 0 12px; border-radius: 13px; background: rgba(243,243,243,.8); }
.channel-search-wrap svg { flex: 0 0 auto; color: rgba(38,38,38,.5); }
.channel-search-wrap input { width: 100%; min-width: 0; border: 0; outline: 0; color: #262626; background: transparent; font-size: 14px; }
.channel-search-wrap input::placeholder { color: rgba(38,38,38,.42); }
.channel-list { overflow: auto; padding: 8px 0 20px; }
.channel-list p { margin: 20px 10px 8px; color: rgba(38,38,38,.72); font-size: 14px; }
.channel-list button { display: flex; width: 100%; height: 36px; align-items: center; gap: 10px; padding: 0 10px; border: 0; border-radius: 11px; background: transparent; cursor: pointer; text-align: left; font-size: 14px; }
.channel-list button:hover,
.channel-list button.is-current { background: rgba(38,38,38,.055); }
.channel-list button svg { flex: 0 0 auto; }
.chat-header,
.thread-header { display: flex; min-height: 64px; align-items: center; justify-content: space-between; gap: 16px; padding: 0 20px; border-bottom: 1px solid rgba(38,38,38,.09); }
.chat-header > div:first-child,
.thread-header > div:first-child { display: flex; align-items: center; gap: 10px; min-width: 0; }
.chat-header strong,
.thread-header strong { overflow: hidden; font-size: 14px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.chat-header-actions { display: flex; align-items: center; gap: 8px; }
.chat-header-actions button,
.thread-header button { display: inline-flex; height: 36px; min-width: 36px; align-items: center; justify-content: center; gap: 7px; padding: 0 9px; border: 1px solid rgba(38,38,38,.12); border-radius: 9px; background: rgba(255,255,255,.72); cursor: pointer; }
.thread-header button { border: 0; background: transparent; }
.chat-header-actions button:hover,
.thread-header button:hover { background: rgba(38,38,38,.05); }
.chat-feed { flex: 1; overflow: auto; padding: 48px 20px 120px; }
.day-divider { display: flex; align-items: center; gap: 14px; margin-bottom: 46px; color: rgba(38,38,38,.68); font-size: 11px; }
.day-divider::before,
.day-divider::after { content: ""; height: 1px; flex: 1; background: rgba(38,38,38,.1); }
.day-divider span { padding: 8px 13px; border: 1px solid rgba(38,38,38,.12); border-radius: 999px; }
.chat-message,
.thread-message { display: grid; grid-template-columns: 42px 1fr; gap: 12px; margin-bottom: 30px; font-size: 14px; line-height: 1.42; }
.chat-message > img,
.thread-message > img { width: 42px; height: 42px; object-fit: contain; border-radius: 50%; }
.chat-message p,
.thread-message p { margin: 0 0 6px; }
.chat-message p strong,
.thread-message p strong { margin-right: 10px; font-weight: 650; }
.chat-message p span,
.thread-message p span { color: rgba(38,38,38,.65); }
.reply-link { display: flex; align-items: center; gap: 8px; margin-top: 12px; padding: 0; border: 0; color: #262626; background: transparent; cursor: pointer; font-size: 13px; }
.reply-link > span:last-child { color: rgba(38,38,38,.42); }
.mini-avatars { display: flex; padding-left: 10px; }
.mini-avatars img { width: 26px; height: 26px; margin-left: -10px; object-fit: contain; border: 2px solid #fff; border-radius: 50%; }
.message-composer { position: absolute; z-index: 4; left: 20px; right: 20px; bottom: 18px; min-height: 112px; padding: 18px; border: 1px solid rgba(38,38,38,.13); border-radius: 18px; color: rgba(38,38,38,.38); background: rgba(255,255,255,.84); box-shadow: 0 8px 28px rgba(57,78,87,.04); backdrop-filter: blur(18px); font-size: 14px; }
.message-composer-actions { position: absolute; left: 18px; right: 18px; bottom: 14px; display: flex; align-items: center; gap: 14px; color: #262626; }
.message-composer-actions button { width: 34px; height: 34px; margin-left: auto; display: grid; place-items: center; border: 0; border-radius: 50%; color: #262626; background: #f3e7ff; cursor: pointer; }
.thread-content { flex: 1; overflow: auto; padding: 28px 20px 130px; }
.thread-message { grid-template-columns: 40px 1fr; }
.thread-message > img { width: 40px; height: 40px; }
.thread-panel .message-composer { left: 18px; right: 18px; }
@media (max-width: 1050px) {
.messages-board { grid-template-columns: 215px minmax(370px, 1fr) 290px; }
.chat-header-actions button:nth-child(2), .chat-header-actions button:nth-child(3) { display: none; }
}
.projects-stage {
position: relative;
height: 100svh;
@@ -317,6 +453,7 @@ body { background: #e6f2f5; color: var(--ink); font-family: Inter, ui-sans-serif
@media (prefers-reduced-motion: reduce) {
.wrapped-labels { will-change: auto; }
.orbit-system, .task-detail-panel { transition-duration: 1ms; animation-duration: 1ms; }
.message-panel, .liquid-tether { transition: none; }
.response-composer { transition: none; }
.response-composer textarea { animation: none; }
.project-name-pill { transition: none; }