CollaborationComments
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callComments
A design thread where you can @mention the agent and it replies. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add commentsReact
npx designme add comments --reactThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.
About
What it is for and what it does.
Comments is a thread pinned to one spot in a design, where people reply to each other and can bring the agent in by typing @agent. Each comment is an avatar, a name, a time and the text; the first one starts the thread and the replies sit indented under it on a thin line. A quiet Agree button shows beside the time on hover or keyboard focus. The agent's reply shows a thinking mark first, then its words fade in, and it can end with buttons such as Apply to all that turn into a short note once used. The whole thread can be resolved, which fades it back.
- When feedback lives on a design or document and the agent should act on it from the thread
- When people need to @mention teammates and the agent from the same reply field
- When an agent's suggestion needs a yes or a choice before it changes anything
- When resolved threads should stay visible but step back
- The first of
commentsstarts the thread, and every later one is a reply, indented under it on a thin line. The header counts the replies. - Typing @ and the start of a handle opens a menu of matching
people, and Enter or a click inserts the handle. - @mentions in plain text comments are highlighted in the accent's tint, limited to known handles when
peopleis given. - Each comment's Agree button shows on hover or keyboard focus, and always on a touch screen. It toggles yours in
agreed, and a chip under the text counts everyone who agreed (agreeson the comment, plus you); the chip toggles yours too. - A comment with
thinkingshows the thinking dots; anagentcomment's words fade in as itstextgrows. actionsadd buttons under a comment, and settingresolutionreplaces them with a confirmation line like "Applied to 3 frames".- Enter or the Send button calls
onSendwith the trimmed reply and clears the field; Send is disabled while the field is empty. - The reply field has a dimmed edge at rest and a step stronger one while it has the focus, with no ring.
- The Resolve button in the header toggles
resolved, fades the thread, turns the pin green and marks the thread Resolved.
- The thread is an
aria-live="polite"region, so new comments and replies are announced, and the replies are a group labelled Replies. - The reply field is a
comboboxwitharia-expandedreflecting the mention menu, which is alistboxofoptionrows. - In the mention menu ↑ and ↓ move the highlight and wrap, which
aria-activedescendantpoints to, and Enter picks the highlighted person. - Escape dismisses the mention menu until the text changes.
- Agree and its chip are toggle buttons with
aria-pressed; the chip's label says how many agreed. The Agree button stays in the tab order and shows as soon as it has the focus. - Resolve thread reports its state with
aria-pressed, and the icon-only Send button has anaria-label.
Usage
Import it and drive it from your own state.
Import
import { Comments } from '@/components/designme/comments/Comments';Example
The demo above as a React component, 62 lines you can copy.
'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useCallback, useRef, useState } from 'react';
import { Comments, type CommentsEntry, type CommentsPerson } from './Comments';
import { useStory, useWait } from '../lib/useScript';
/* ---------- Comments: Maya asks the agent in the thread; it thinks, writes its answer, then offers to apply it ---------- */
const AGENT: CommentsPerson = { name: 'Design agent', initials: 'DA', color: 'var(--dm-av-b)', handle: 'agent' };
const MAYA: CommentsPerson = { name: 'Maya Kim', initials: 'MK', color: 'var(--dm-av-l)', handle: 'maya' };
const JON: CommentsPerson = { name: 'Jon Ortiz', initials: 'JO', color: 'var(--dm-av-m)', handle: 'jon' };
const START: CommentsEntry[] = [
{ id: 'c1', author: MAYA, text: 'The secondary button feels too faint next to the CTA.', time: '2h', agrees: 1 },
{ id: 'c2', author: JON, text: 'Agree. Can we keep it outlined though?', time: '1h' }
];
const ASK = '@agent can you keep it outlined but darker?';
export default function CommentsDemo() {
const [comments, setComments] = useState(START);
const [draft, setDraft] = useState('');
const wait = useWait();
const next = useRef(3);
const reply = useCallback(async (q: string) => {
const id = 'c' + next.current++;
const set = (patch: Partial<CommentsEntry>) => setComments(cs => cs.map(c => (c.id === id ? { ...c, ...patch } : c)));
setComments(cs => [...cs, { id, author: AGENT, text: '', time: 'now', agent: true, thinking: true }]);
await wait(900);
set({ thinking: false });
const answer = /outline|border/i.test(q + ' outlined')
? 'Kept it outlined and raised the text to #5B6472, which passes AA at 5.9 : 1. Want me to apply it to the other 2 frames?'
: 'On it. I will post the change here when it is ready.';
let text = '';
for (const w of answer.split(' ')) { text += (text ? ' ' : '') + w; set({ text }); await wait(40); }
set({ actions: [
{ label: 'Apply to all', primary: true, onClick: () => set({ resolution: 'Applied to 3 frames' }) },
{ label: 'Just this one', onClick: () => set({ resolution: 'Applied to frame 2' }) }
] });
}, [wait]);
const send = useCallback((text: string) => {
setComments(cs => [...cs, { id: 'c' + next.current++, author: MAYA, text, time: 'now' }]);
if (/@agent/i.test(text)) reply(text);
}, [reply]);
// Maya types the question a letter at a time, then sends it. Clicking, focusing or typing in it takes over, and what she
// had typed goes
const ref = useRef<HTMLDivElement>(null);
useStory(ref, async ({ wait, taken }) => {
await wait(1000);
taken(() => setDraft(''));
for (let i = 1; i <= ASK.length; i++) { setDraft(ASK.slice(0, i)); await wait(28); }
await wait(300);
setDraft(''); send(ASK);
});
return (
<div ref={ref} style={{ width: '100%' }}>
<Comments title="Pricing, frame 2" comments={comments} people={[AGENT, MAYA, JON]} value={draft} onValueChange={setDraft} onSend={send} />
</div>
);
}
Props
Everything Comments takes.
| Prop | Type | What it does |
|---|---|---|
titlerequired | string | What the thread is about, like "Pricing, frame 2" |
pin | ReactNode | The number on the pin, like 1 |
commentsrequired | CommentsEntry[] | The thread: the first comment starts it, and every later one is a reply, indented under it |
people | CommentsPerson[] | Who can be @mentioned. Typing @ and the start of a handle opens a menu of them |
value | string | The reply being written. Leave it out to let the field keep it |
defaultValue | string | |
onValueChange | (value: string) => void | |
onSend | (text: string) => void | Called with the reply when it is sent. Add it to `comments` to show it |
placeholder | string | |
resolved | boolean | Whether the thread is resolved, which fades it. Leave it out to let the Resolve button toggle it |
defaultResolved | boolean | |
onResolvedChange | (resolved: boolean) => void | |
agreed | string[] | The ids of the comments you agreed with. Leave it out to let the Agree buttons keep it |
defaultAgreed | string[] | |
onAgreedChange | (ids: string[]) => void | |
className | string |
Code
The full source, as the CLI installs it.
Comments.tsxReact · 186 lines
'use client';
import { useId, useState, type KeyboardEvent, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Avatar, avatarInk, enter, Think, Tip } from '../lib/motion';
import '../designme.css';
import './comments.css';
export interface CommentsPerson {
name: string;
/** Two letters for the avatar, like "MK" */
initials: string;
/** The avatar colour, like 'var(--dm-av-l)' */
color: string;
/** What follows the @ to mention them, like "maya". People with a handle show up in the mention menu */
handle?: string;
}
export interface CommentsAction {
label: string;
/** Draws it as the accent button. The others are white with an outline */
primary?: boolean;
onClick: () => void;
}
export interface CommentsEntry {
/** Keeps the comment in place when the list changes */
id: string;
author: CommentsPerson;
/** The comment. A string gets its @mentions highlighted; an agent's reply fades in word by word as it grows */
text: ReactNode;
/** Like "2h" or "now" */
time: string;
/** Marks the agent's replies: their words fade in as `text` grows */
agent?: boolean;
/** Shows the thinking mark instead of the text while the agent works on a reply */
thinking?: boolean;
/** Buttons under the comment, like "Apply to all" */
actions?: CommentsAction[];
/** Replaces the buttons once one is used, like "Applied to 3 frames" */
resolution?: string;
/** How many other people agreed with it. Yours is added on top when you agree */
agrees?: number;
}
export interface CommentsProps {
/** What the thread is about, like "Pricing, frame 2" */
title: string;
/** The number on the pin, like 1 */
pin?: ReactNode;
/** The thread: the first comment starts it, and every later one is a reply, indented under it */
comments: CommentsEntry[];
/** Who can be @mentioned. Typing @ and the start of a handle opens a menu of them */
people?: CommentsPerson[];
/** The reply being written. Leave it out to let the field keep it */
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Called with the reply when it is sent. Add it to `comments` to show it */
onSend?: (text: string) => void;
placeholder?: string;
/** Whether the thread is resolved, which fades it. Leave it out to let the Resolve button toggle it */
resolved?: boolean;
defaultResolved?: boolean;
onResolvedChange?: (resolved: boolean) => void;
/** The ids of the comments you agreed with. Leave it out to let the Agree buttons keep it */
agreed?: string[];
defaultAgreed?: string[];
onAgreedChange?: (ids: string[]) => void;
className?: string;
}
/** Highlights @handles. With people given, only theirs; otherwise any @word */
function mentions(text: string, handles: string[] | null) {
return text.split(/(@\w+)/).map((part, i) => {
const hit = part.startsWith('@') && (!handles || handles.includes(part.slice(1).toLowerCase()));
return hit ? <span key={i} className="dm-m">{part}</span> : part;
});
}
function Comment({ c, handles, mine, onAgree }: { c: CommentsEntry; handles: string[] | null; mine: boolean; onAgree: () => void }) {
const e = enter();
let body: ReactNode = c.text;
if (c.thinking) body = <Think />;
else if (c.agent && typeof c.text === 'string') body = c.text.split(' ').map((w, i) => <span key={i} className="dm-w">{w} </span>);
else if (typeof c.text === 'string') body = mentions(c.text, handles);
const n = (c.agrees ?? 0) + (mine ? 1 : 0);
return (
<div className={cx('dm-cmx-c', c.agent && 'dm-ag', e.className)} style={e.style}>
<Avatar initials={c.author.initials} color={c.author.color} />
<div className="dm-b">
<div className="dm-n">
<b>{c.author.name}</b><span className="dm-t">{c.time}</span>
{/* the quiet Agree button shows on hover or keyboard focus, and always on a touch screen */}
<div className="dm-cmx-tools">
<Tip label="Agree"><button type="button" className="dm-ib dm-cmx-rx" aria-label="Agree" aria-pressed={mine} onClick={onAgree}><Icon name="up-thumb" size="xs" /></button></Tip>
</div>
</div>
<div className="dm-tx" style={c.thinking ? { color: 'var(--dm-accent-mark, var(--dm-accent))' } : undefined}>{body}</div>
{n > 0 && (
<div className="dm-cmx-rs">
<button type="button" className="dm-cmx-rc" aria-pressed={mine} aria-label={'Agree, ' + n + (n === 1 ? ' person' : ' people') + (mine ? ', including you' : '')} onClick={onAgree}>
<Icon name="up-thumb" size="xs" /><span className="dm-num">{n}</span>
</button>
</div>
)}
{(c.resolution || !!c.actions?.length) && (
<div className={cx('dm-act', e.className)}>
{c.resolution
? <span className="dm-cmx-rv dm-enter"><Icon name="ok" size="xs" />{c.resolution}</span>
: c.actions!.map((a, i) => <button type="button" key={i} className={cx('dm-pill dm-sm', a.primary ? 'dm-primary' : 'dm-line')} onClick={a.onClick}>{a.label}</button>)}
</div>
)}
</div>
</div>
);
}
/** A comment thread on a design, where you can @mention the agent and it replies. */
export function Comments({ title, pin = 1, comments, people, value, defaultValue = '', onValueChange, onSend, placeholder = 'Reply, or @agent to ask', resolved, defaultResolved = false, onResolvedChange, agreed, defaultAgreed = [], onAgreedChange, className }: CommentsProps) {
const [draft, setDraft] = useControllable(value, defaultValue, onValueChange);
const [done, setDone] = useControllable(resolved, defaultResolved, onResolvedChange);
const [yes, setYes] = useControllable(agreed, defaultAgreed, onAgreedChange);
const handles = people ? people.flatMap(p => (p.handle ? [p.handle.toLowerCase()] : [])) : null;
const agree = (id: string) => setYes(yes.includes(id) ? yes.filter(x => x !== id) : [...yes, id]);
// typing @ and the start of a handle offers the people it could be; Escape puts the menu away until the text changes
const [dismissed, setDismissed] = useState<string | null>(null);
const typed = draft.match(/@(\w*)$/);
const matches = typed ? (people ?? []).filter(p => p.handle?.toLowerCase().startsWith(typed[1].toLowerCase())) : [];
const menuOpen = matches.length > 0 && dismissed !== draft;
const pick = (p: CommentsPerson) => setDraft(draft.replace(/@\w*$/, '@' + p.handle + ' '));
// the highlighted person: the up and down arrows move it and wrap, and it starts on the first whenever the list changes
const [hl, setHl] = useState({ at: 0, for: '' });
const at = hl.for === draft ? Math.min(hl.at, matches.length - 1) : 0;
const id = useId();
const send = () => { const v = draft.trim(); if (!v) return; setDraft(''); onSend?.(v); };
const key = (e: KeyboardEvent<HTMLInputElement>) => {
if (menuOpen && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { e.preventDefault(); setHl({ at: (at + (e.key === 'ArrowDown' ? 1 : -1) + matches.length) % matches.length, for: draft }); return; }
if (e.key === 'Enter') { e.preventDefault(); if (menuOpen) pick(matches[at]); else send(); }
if (e.key === 'Escape') setDismissed(draft);
};
const [head, ...replies] = comments;
const one = (c: CommentsEntry) => <Comment key={c.id} c={c} handles={handles} mine={yes.includes(c.id)} onAgree={() => agree(c.id)} />;
const status = done ? 'Resolved' : replies.length ? replies.length + (replies.length === 1 ? ' reply' : ' replies') : '';
return (
<div data-dm="" className={cx('dm-cmx dm-panel', done && 'dm-done', className)}>
<div className="dm-cmx-h">
<span className="dm-cmx-pin" aria-hidden="true">{pin}</span>
<b>{title}</b>
<span className="dm-cmx-st dm-num">{status}</span>
<Tip label={done ? 'Reopen thread' : 'Resolve thread'}>
<button type="button" className="dm-ib dm-cmx-res" aria-label="Resolve thread" aria-pressed={done} onClick={() => setDone(!done)}>
<Icon name={done ? 'undo' : 'check'} size="xs" />
</button>
</Tip>
</div>
<div className="dm-cmx-th" aria-live="polite">
{head && one(head)}
{replies.length > 0 && <div className="dm-cmx-rp" role="group" aria-label="Replies">{replies.map(one)}</div>}
</div>
<div className="dm-cmx-in">
<div className="dm-cmx-box">
<input placeholder={placeholder} aria-label="Reply" spellCheck={false} autoComplete="off" value={draft} onChange={e => setDraft(e.target.value)} onKeyDown={key}
role="combobox" aria-expanded={menuOpen} aria-autocomplete="list" aria-controls={`${id}-men`} aria-activedescendant={menuOpen ? `${id}-${at}` : undefined} />
<button type="button" className="dm-cmx-go" aria-label="Send" disabled={!draft.trim()} onClick={send}><Icon name="up" size="xs" /></button>
</div>
<div className={cx('dm-pop dm-cmx-men', menuOpen && 'dm-open')} role="listbox" id={`${id}-men`} aria-label="Mention" inert={!menuOpen}>
{matches.map((p, i) => (
// mousedown, so the field keeps its focus
<button type="button" key={p.handle} id={`${id}-${i}`} tabIndex={-1} className={cx('dm-mi', i === at && 'dm-hl')} role="option" aria-selected={i === at}
onMouseEnter={() => setHl({ at: i, for: draft })} onMouseDown={e => { e.preventDefault(); pick(p); }}>
<span className="dm-av" style={{ background: p.color, color: avatarInk(p.color), width: 20, height: 20, fontSize: 9 }} aria-hidden="true">{p.initials}</span>
<span>{p.name}<span className="dm-d">@{p.handle}</span></span>
</button>
))}
</div>
</div>
</div>
);
}
comments.cssReact · 61 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Comments ---------- */
/* a thread on one spot of a design: the first comment, then its replies indented on a thin line. Each comment is an avatar,
a name, a time and the text; its Agree button shows on hover or keyboard focus (always, on a touch screen) */
.dm-cmx{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-4);min-height:340px}
/* on a card the thread keeps the room its last reply takes, so it stays centred while replies arrive */
.dm-stage-in>.dm-cmx{min-height:inherit}
.dm-cmx-h{display:flex;align-items:center;gap:var(--dm-s-2);font-size:13.5px;margin:calc(-1 * var(--dm-s-1)) calc(-1 * var(--dm-s-1)) 0 0}
.dm-cmx-h b{font-weight:500}
.dm-cmx-st{flex:1;font-size:12px;color:var(--dm-muted)}
.dm-cmx-pin{width:20px;height:20px;border-radius:50% 50% 50% var(--dm-r-xs);background:var(--dm-accent);color:var(--dm-on-accent);display:grid;place-items:center;font-size:11px;font-weight:500;flex:none;transition:background var(--dm-t)}
.dm-cmx.dm-done .dm-cmx-pin{background:var(--dm-ok);color:var(--dm-surface)}
.dm-cmx-res{width:28px;height:28px;color:var(--dm-muted)}
.dm-cmx-th{display:flex;flex-direction:column;gap:var(--dm-s-4);flex:1}
.dm-cmx-rp{display:flex;flex-direction:column;gap:var(--dm-s-4);margin-left:var(--dm-s-3);padding-left:calc(var(--dm-s-5) + 1px);position:relative}
/* the replies' line starts just below the first comment's avatar, centred under it, and runs down beside every reply */
.dm-cmx-th>.dm-cmx-c:first-child{position:relative}
.dm-cmx-th>.dm-cmx-c:first-child:has(+ .dm-cmx-rp)::before,.dm-cmx-rp::before{content:'';position:absolute;width:1px;background:var(--dm-rail);pointer-events:none}
.dm-cmx-th>.dm-cmx-c:first-child:has(+ .dm-cmx-rp)::before{left:calc(var(--dm-s-3) - .5px);top:calc(24px + var(--dm-s-1));bottom:calc(-1 * var(--dm-s-4))}
.dm-cmx-rp::before{left:-.5px;top:0;bottom:0}
.dm-cmx-c{display:flex;gap:var(--dm-s-3);min-width:0}
.dm-cmx-c .dm-av{width:24px;height:24px;font-size:10px}
.dm-cmx-c .dm-b{flex:1;display:flex;flex-direction:column;min-width:0}
.dm-cmx-c .dm-n{display:flex;align-items:center;gap:var(--dm-s-2);height:24px;font-size:13px}
.dm-cmx-c .dm-n b{font-weight:500;white-space:nowrap}
.dm-cmx-c .dm-n .dm-t{font-size:12px;color:var(--dm-muted)}
.dm-cmx-c .dm-tx{font-size:13.5px;line-height:1.5;color:var(--dm-fg);overflow-wrap:anywhere}
.dm-cmx-c .dm-m{color:var(--dm-accent-fg,var(--dm-accent));background:var(--dm-sel);border-radius:var(--dm-r-xs);padding:0 2px}
.dm-cmx-c.dm-ag .dm-tx .dm-w{animation:dm-cmw .4s var(--dm-ease) both}
@keyframes dm-cmw{from{opacity:0;filter:blur(3px)}to{opacity:1;filter:none}}
.dm-cmx-c .dm-act{display:flex;flex-wrap:wrap;gap:var(--dm-s-2);margin-top:var(--dm-s-2)}
/* the quiet Agree button beside the time */
.dm-cmx-tools{margin-left:auto;display:flex;gap:var(--dm-s-1);transition:opacity var(--dm-t-fast)}
.dm-cmx-tools .dm-ib{width:26px;height:26px;color:var(--dm-muted)}
.dm-cmx-tools .dm-ib[aria-pressed="true"]{color:var(--dm-fg)}
@media (hover:hover) and (pointer:fine){.dm-cmx-tools{opacity:0}}
.dm-cmx-c .dm-n:focus-within .dm-cmx-tools{opacity:1}
@media (hover:hover) and (pointer:fine){.dm-cmx-c:hover .dm-cmx-tools{opacity:1}}
/* how many agreed: a small chip under the text, a step stronger once you have */
.dm-cmx-rs{display:flex;margin-top:var(--dm-s-2)}
.dm-cmx-rs:has(.dm-cmx-rc[hidden]){display:none}
.dm-cmx-rc{display:inline-flex;align-items:center;gap:var(--dm-s-1);height:22px;padding:0 var(--dm-s-2);border-radius:var(--dm-r-pill);font-size:12px;color:var(--dm-fg2);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-line) 50%,transparent);transition:box-shadow var(--dm-t-fast),color var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.dm-cmx-rc:hover{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-rail) 50%,transparent)}}
.dm-cmx-rc[aria-pressed="true"]{color:var(--dm-fg);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-faint) 50%,transparent)}
/* the reply field: a dimmed edge at rest, one step stronger while it has the focus, and no ring */
.dm-cmx-in{position:relative}
.dm-cmx-box{display:flex;align-items:center;gap:6px;height:var(--dm-field-h);padding:0 5px 0 var(--dm-s-3);border-radius:var(--dm-r-field);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent);transition:box-shadow var(--dm-t-fast)}
@supports (corner-shape:squircle){.dm-cmx-box{corner-shape:squircle;border-radius:var(--dm-r-field-sq)}}
.dm-cmx-box:has(input:focus){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
.dm-cmx-box input{flex:1;min-width:0;border:0;outline:0;background:transparent;font:inherit;font-size:13.5px;color:var(--dm-fg)}
.dm-cmx-box input::placeholder{color:var(--dm-muted)}
.dm-cmx-go{width:30px;height:30px;border-radius:50%;background:var(--dm-accent);color:var(--dm-on-accent);display:grid;place-items:center;flex:none;transition:background var(--dm-t-fast),transform .15s}
@media (hover:hover) and (pointer:fine){.dm-cmx-go:hover{background:var(--dm-accent-2)}}
.dm-cmx-go:active{transform:scale(.94)}
.dm-cmx-go[disabled]{opacity:1;cursor:not-allowed;background:var(--dm-dis-hatch),var(--dm-dis-fill);color:var(--dm-dis);box-shadow:var(--dm-soft-ring);transform:none}
@media (hover:hover) and (pointer:fine){.dm-cmx-go[disabled]:hover{opacity:1;cursor:not-allowed;background:var(--dm-dis-hatch),var(--dm-dis-fill);color:var(--dm-dis);box-shadow:var(--dm-soft-ring);transform:none}}
.dm-cmx-men{bottom:46px;left:0;min-width:210px}
.dm-cmx.dm-done .dm-cmx-th,.dm-cmx.dm-done .dm-cmx-in{opacity:.45;pointer-events:none;transition:opacity .4s}
.dm-cmx-rv{font-size:12.5px;color:var(--dm-ok);display:flex;align-items:center;gap:6px}
:root:not([data-style="soft"]) .dm-cmx-c .dm-m:not(:where([data-style="soft"],[data-style="soft"] *)){box-shadow:inset 0 -1px 0 var(--dm-accent-mark)}
comments.htmlHTML · 10 lines
<div class="card" data-c="comments">
<div class="stage-in">
<div class="cmx panel">
<div class="cmx-h"><span class="cmx-pin" aria-hidden="true">1</span><b>Pricing, frame 2</b><span class="cmx-st num"></span><button class="ib cmx-res" aria-label="Resolve thread" aria-pressed="false" data-tip="Resolve thread"><svg class="i xs"><use href="#i-check"/></svg></button></div>
<div class="cmx-th" aria-live="polite"></div>
<div class="cmx-in"><div class="cmx-box"><input placeholder="Reply, or @agent to ask" aria-label="Reply" spellcheck="false" autocomplete="off"><button class="cmx-go" aria-label="Send" disabled><svg class="i xs"><use href="#i-up"/></svg></button></div><div class="pop cmx-men" role="listbox"></div></div>
</div>
</div>
</div>
comments.cssHTML · 60 lines
/* ---------- Comments ---------- */
/* a thread on one spot of a design: the first comment, then its replies indented on a thin line. Each comment is an avatar,
a name, a time and the text; its Agree button shows on hover or keyboard focus (always, on a touch screen) */
.cmx{padding:var(--s-4);display:flex;flex-direction:column;gap:var(--s-4);min-height:340px}
/* on a card the thread keeps the room its last reply takes, so it stays centred while replies arrive */
.stage-in>.cmx{min-height:inherit}
.cmx-h{display:flex;align-items:center;gap:var(--s-2);font-size:13.5px;margin:calc(-1 * var(--s-1)) calc(-1 * var(--s-1)) 0 0}
.cmx-h b{font-weight:500}
.cmx-st{flex:1;font-size:12px;color:var(--muted)}
.cmx-pin{width:20px;height:20px;border-radius:50% 50% 50% var(--r-xs);background:var(--accent);color:var(--on-accent);display:grid;place-items:center;font-size:11px;font-weight:500;flex:none;transition:background var(--t)}
.cmx.done .cmx-pin{background:var(--ok);color:var(--surface)}
.cmx-res{width:28px;height:28px;color:var(--muted)}
.cmx-th{display:flex;flex-direction:column;gap:var(--s-4);flex:1}
.cmx-rp{display:flex;flex-direction:column;gap:var(--s-4);margin-left:var(--s-3);padding-left:calc(var(--s-5) + 1px);position:relative}
/* the replies' line starts just below the first comment's avatar, centred under it, and runs down beside every reply */
.cmx-th>.cmx-c:first-child{position:relative}
.cmx-th>.cmx-c:first-child:has(+ .cmx-rp)::before,.cmx-rp::before{content:'';position:absolute;width:1px;background:var(--rail);pointer-events:none}
.cmx-th>.cmx-c:first-child:has(+ .cmx-rp)::before{left:calc(var(--s-3) - .5px);top:calc(24px + var(--s-1));bottom:calc(-1 * var(--s-4))}
.cmx-rp::before{left:-.5px;top:0;bottom:0}
.cmx-c{display:flex;gap:var(--s-3);min-width:0}
.cmx-c .av{width:24px;height:24px;font-size:10px}
.cmx-c .b{flex:1;display:flex;flex-direction:column;min-width:0}
.cmx-c .n{display:flex;align-items:center;gap:var(--s-2);height:24px;font-size:13px}
.cmx-c .n b{font-weight:500;white-space:nowrap}
.cmx-c .n .t{font-size:12px;color:var(--muted)}
.cmx-c .tx{font-size:13.5px;line-height:1.5;color:var(--fg);overflow-wrap:anywhere}
.cmx-c .m{color:var(--accent-fg,var(--accent));background:var(--sel);border-radius:var(--r-xs);padding:0 2px}
.cmx-c.ag .tx .w{animation:cmw .4s var(--ease) both}
@keyframes cmw{from{opacity:0;filter:blur(3px)}to{opacity:1;filter:none}}
.cmx-c .act{display:flex;flex-wrap:wrap;gap:var(--s-2);margin-top:var(--s-2)}
/* the quiet Agree button beside the time */
.cmx-tools{margin-left:auto;display:flex;gap:var(--s-1);transition:opacity var(--t-fast)}
.cmx-tools .ib{width:26px;height:26px;color:var(--muted)}
.cmx-tools .ib[aria-pressed="true"]{color:var(--fg)}
@media (hover:hover) and (pointer:fine){.cmx-tools{opacity:0}}
.cmx-c .n:focus-within .cmx-tools{opacity:1}
@media (hover:hover) and (pointer:fine){.cmx-c:hover .cmx-tools{opacity:1}}
/* how many agreed: a small chip under the text, a step stronger once you have */
.cmx-rs{display:flex;margin-top:var(--s-2)}
.cmx-rs:has(.cmx-rc[hidden]){display:none}
.cmx-rc{display:inline-flex;align-items:center;gap:var(--s-1);height:22px;padding:0 var(--s-2);border-radius:var(--r-pill);font-size:12px;color:var(--fg2);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--line) 50%,transparent);transition:box-shadow var(--t-fast),color var(--t-fast)}
@media (hover:hover) and (pointer:fine){.cmx-rc:hover{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--rail) 50%,transparent)}}
.cmx-rc[aria-pressed="true"]{color:var(--fg);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--faint) 50%,transparent)}
/* the reply field: a dimmed edge at rest, one step stronger while it has the focus, and no ring */
.cmx-in{position:relative}
.cmx-box{display:flex;align-items:center;gap:6px;height:var(--field-h);padding:0 5px 0 var(--s-3);border-radius:var(--r-field);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line) 50%,transparent);transition:box-shadow var(--t-fast)}
@supports (corner-shape:squircle){.cmx-box{corner-shape:squircle;border-radius:var(--r-field-sq)}}
.cmx-box:has(input:focus){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
.cmx-box input{flex:1;min-width:0;border:0;outline:0;background:transparent;font:inherit;font-size:13.5px;color:var(--fg)}
.cmx-box input::placeholder{color:var(--muted)}
.cmx-go{width:30px;height:30px;border-radius:50%;background:var(--accent);color:var(--on-accent);display:grid;place-items:center;flex:none;transition:background var(--t-fast),transform .15s}
@media (hover:hover) and (pointer:fine){.cmx-go:hover{background:var(--accent-2)}}
.cmx-go:active{transform:scale(.94)}
.cmx-go[disabled]{opacity:1;cursor:not-allowed;background:var(--dis-hatch),var(--dis-fill);color:var(--dis);box-shadow:var(--soft-ring);transform:none}
@media (hover:hover) and (pointer:fine){.cmx-go[disabled]:hover{opacity:1;cursor:not-allowed;background:var(--dis-hatch),var(--dis-fill);color:var(--dis);box-shadow:var(--soft-ring);transform:none}}
.cmx-men{bottom:46px;left:0;min-width:210px}
.cmx.done .cmx-th,.cmx.done .cmx-in{opacity:.45;pointer-events:none;transition:opacity .4s}
.cmx-rv{font-size:12.5px;color:var(--ok);display:flex;align-items:center;gap:6px}
:root:not([data-style="soft"]) .cmx-c .m:not(:where([data-style="soft"],[data-style="soft"] *)){box-shadow:inset 0 -1px 0 var(--accent-mark)}
comments.jsHTML · 62 lines
DM.def('comments', (r, c) => {
r.innerHTML = r.innerHTML.replace('{check}', I('check','xs')).replace('{up}', I('up','xs'));
const root = $('.cmx', r), th = $('.cmx-th', r), inp = $('.cmx-box input', r), go = $('.cmx-go', r), men = $('.cmx-men', r), st = $('.cmx-st', r), rs = $('.cmx-res', r);
const P = { MK:['Maya Kim','var(--av-l)'], JO:['Jon Ortiz','var(--av-m)'], DA:['Design agent','var(--av-b)'] };
const esc = t => t.replace(/[&<>"]/g, ch => ({ '&':'&', '<':'<', '>':'>', '"':'"' })[ch]);
const fmt = t => esc(t).replace(/@(agent|maya|jon)\b/gi, m => `<span class="m">${m}</span>`);
// the first comment starts the thread; every later one is a reply, indented on a thin line under it
let rp = null;
const count = () => { const n = rp ? rp.children.length : 0; st.textContent = root.classList.contains('done') ? 'Resolved' : n ? n + (n === 1 ? ' reply' : ' replies') : ''; };
function add(who, t, when, ag, agreed = 0){
const el = html(`<div class="cmx-c${ag ? ' ag' : ''}">${AV(who, P[who][1])}<div class="b"><div class="n"><b>${P[who][0]}</b><span class="t">${when}</span><div class="cmx-tools"><button class="ib cmx-rx" aria-label="Agree" aria-pressed="false" data-tip="Agree">${I('up-thumb','xs')}</button></div></div><div class="tx">${ag ? '' : fmt(t)}</div><div class="cmx-rs"><button class="cmx-rc" aria-pressed="false" hidden>${I('up-thumb','xs')}<span class="num"></span></button></div></div></div>`);
el._agree = { n:agreed, mine:false }; agree(el);
$('.cmx-rx', el).onclick = () => agree(el, true); $('.cmx-rc', el).onclick = () => agree(el, true);
if (!th.firstElementChild) th.appendChild(el);
else { if (!rp){ rp = html('<div class="cmx-rp" role="group" aria-label="Replies"></div>'); th.appendChild(rp); } rp.appendChild(el); }
enter(el); count(); return el;
}
// Agree: the button by the time and the chip under the text both toggle yours, and the chip counts everyone's
function agree(el, flip){
const a = el._agree; if (flip) a.mine = !a.mine;
const n = a.n + (a.mine ? 1 : 0), b = $('.cmx-rx', el), chip = $('.cmx-rc', el), had = document.activeElement === chip;
b.setAttribute('aria-pressed', a.mine); chip.setAttribute('aria-pressed', a.mine);
chip.setAttribute('aria-label', 'Agree, ' + n + (n === 1 ? ' person' : ' people') + (a.mine ? ', including you' : ''));
$('.num', chip).textContent = n; chip.hidden = !n;
if (had && !n) b.focus({ preventScroll:true });
}
add('MK', 'The secondary button feels too faint next to the CTA.', '2h', false, 1);
add('JO', 'Agree. Can we keep it outlined though?', '1h');
async function agent(q){
const el = add('DA', '', 'now', true), tx = $('.tx', el); tx.innerHTML = '<span class="think on" style="color:var(--accent-mark,var(--accent))"><i></i><i></i><i></i><i></i></span>'; await c.wait(900); tx.innerHTML = '';
const a = /outline|border/i.test(q + ' outlined') ? 'Kept it outlined and raised the text to #5B6472, which passes AA at 5.9 : 1. Want me to apply it to the other 2 frames?' : 'On it. I will post the change here when it is ready.';
for (const w of a.split(' ')){ tx.insertAdjacentHTML('beforeend', `<span class="w">${w} </span>`); await c.wait(40); }
const act = html(`<div class="act"><button class="pill primary sm">Apply to all</button><button class="pill line sm">Just this one</button></div>`); $('.b', el).appendChild(act); enter(act);
$$('button', act).forEach((b, i) => b.onclick = () => { act.innerHTML = `<span class="cmx-rv">${I('ok','xs')}${i ? 'Applied to frame 2' : 'Applied to 3 frames'}</span>`; enter(act.firstChild); });
}
function send(){ const v = inp.value.trim(); if (!v) return; add('MK', v, 'now'); inp.value = ''; go.disabled = true; shut(); if (/@agent/i.test(v)) c.run(() => agent(v)); }
const opts = [['DA','agent','Design agent'],['MK','maya','Maya Kim'],['JO','jon','Jon Ortiz']];
// the reply box drives the list of people to mention: it is a combobox, and the highlighted row is its active option
men.id = 'cmx-m-' + Math.random().toString(36).slice(2, 7); men.setAttribute('aria-label', 'Mention');
inp.setAttribute('role', 'combobox'); inp.setAttribute('aria-autocomplete', 'list'); inp.setAttribute('aria-expanded', 'false'); inp.setAttribute('aria-controls', men.id);
let hl = 0;
const mens = () => $$('.mi', men);
const light = k => { const all = mens(); if (!all.length){ inp.removeAttribute('aria-activedescendant'); return; } hl = (k + all.length) % all.length; all.forEach((b, j) => { b.classList.toggle('hl', j === hl); b.setAttribute('aria-selected', j === hl); }); inp.setAttribute('aria-activedescendant', all[hl].id); };
const shut = () => { men.classList.remove('open'); inp.setAttribute('aria-expanded', 'false'); inp.removeAttribute('aria-activedescendant'); };
inp.oninput = () => { go.disabled = !inp.value.trim(); const m = inp.value.match(/@(\w*)$/);
if (m){ const list = opts.filter(o => o[1].startsWith(m[1].toLowerCase())); men.innerHTML = list.map(o => `<button class="mi" role="option" tabindex="-1" id="${men.id}-${o[1]}" data-h="${o[1]}">${AV(o[0], P[o[0]][1]).replace('class="av" style="', 'class="av" style="width:20px;height:20px;font-size:9px;')}<span>${o[2]}<span class="d">@${o[1]}</span></span></button>`).join(''); men.classList.toggle('open', list.length > 0); inp.setAttribute('aria-expanded', list.length > 0); light(0);
$$('.mi', men).forEach(b => b.onmousedown = e => { e.preventDefault(); inp.value = inp.value.replace(/@\w*$/, '@' + b.dataset.h + ' '); shut(); go.disabled = false; inp.focus({ preventScroll:true }); }); }
else shut(); };
// with the mention list open, the up and down arrows move through it and wrap, Enter picks, and Escape closes it
inp.onkeydown = e => {
const open = men.classList.contains('open') && mens().length;
if (open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')){ e.preventDefault(); light(hl + (e.key === 'ArrowDown' ? 1 : -1)); return; }
if (e.key === 'Enter'){ const f = mens()[hl]; if (open && f){ f.onmousedown(e); } else send(); }
if (e.key === 'Escape') shut(); };
go.onclick = send;
// Resolve fades the thread back and turns the pin green; pressed again it reopens
rs.onclick = () => { const d = root.classList.toggle('done'); hideTip(); rs.innerHTML = I(d ? 'undo' : 'check', 'xs'); rs.setAttribute('aria-pressed', d); rs.dataset.tip = d ? 'Reopen thread' : 'Resolve thread'; count(); };
// the preview asks the agent in the thread on its own; clicking, focusing or typing in it takes over, and what it had
// typed goes
c.auto(1000, () => c.run(async () => { await c.type(inp, '@agent can you keep it outlined but darker?', { ms:28, each:() => inp.oninput(), keep:true }); shut(); await c.step(300); send(); }));
});
Questions
What people ask about it.
- How do I stream the agent's reply into the thread?
- Add a comment with
agent: trueandthinking: true, then setthinkingto false and keep updating itstextas tokens arrive. Each word fades in as it is added. - Does the component call my agent when someone types @agent?
- No.
onSendgives you the reply text, and you decide what happens. The example checks for @agent, adds the person's comment, then adds the agent's reply. - How do I let the person approve the agent's change?
- Give the agent's comment
actions, such as Apply to all and Just this one. In eachonClick, apply the change and setresolutionto a short line that replaces the buttons. - How do I save who agreed with a comment?
- Pass
agreedwith the ids of the comments the current person agreed with, and update it inonAgreedChange. Put everyone else's count in each comment'sagrees.
More in Collaboration
Threads, inboxes and boards for teams working alongside an agent.