fix(editor): real comment authors + overlay menu redesign

Comments showed the author's SLUG — which doubles as their personal scope
— instead of their name, because WasmTool passed presenceUser().id where
.name already existed.

- denormalize authorName/authorEmail onto each comment message + thread at
  write time, so a comment still reads correctly when its author is
  offline, renamed, or gone. `author` stays the SLUG: colorFor() and the
  "is this mine?" ownership checks compare it. Legacy comments fall back.
- session-identity keeps the email it was already fetching and discarding.

Overlay menu: restyle + reorganise into labelled sections (People /
Document / Comments / View) over one shared row shape, so sections can't
drift apart again.

- comments: four bare icons -> labelled rows (Add comment / Show list with
  count / Hide pins). The nested expand toggle is gone: the section IS the
  group, so it was a collapsible inside a collapsible.
- presence: facepile of initials -> one row per person with an explicit
  Follow/Stop. The separate "Following X" banner is deleted; that state now
  lives on the person's own row, so there is nothing to keep in sync.
- SourceChip gains a `muted` tone for known-dark surfaces; its solid
  variant is untouched for the light project pages that share it.

Fixes a regression the redesign introduced: the taller panel (z-50) covered
the comment popover (z-40), and since a popover can be opened FROM the
menu's thread list, the menu swallowed clicks on the popover it had just
spawned — delete was unreachable. Popover now z-[60].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016p9kjdGBdcpwUSjJ3q5xg2
This commit is contained in:
Gergő Törcsvári 2026-07-20 20:59:24 +02:00
commit fc2efeff2c
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
11 changed files with 387 additions and 178 deletions

View file

@ -29,10 +29,11 @@ async function bootAs(page: Page, user: string): Promise<void> {
intervals: [1000],
})
.toMatch(TITLE);
await openOverlayMenu(page); // the comment bar lives in the overlay menu (0010)
await expect(page.getByTestId('comment-bar-toggle')).toBeVisible({ timeout: 30000 });
await page.getByTestId('comment-bar-toggle').click();
await expect(page.getByTestId('comment-mode-toggle')).toBeVisible();
// The comment controls live in the overlay menu's "Comments" section and are
// visible as soon as it opens — the nested expand toggle (comment-bar-toggle)
// is gone, since the section itself is the group.
await openOverlayMenu(page); // the comment controls live in the overlay menu (0010)
await expect(page.getByTestId('comment-mode-toggle')).toBeVisible({ timeout: 30000 });
}
/** Comments persist in the room's ydoc — start each run from a clean slate. */

View file

@ -27,12 +27,12 @@ async function bootAs(page: Page, user: string): Promise<void> {
intervals: [1000],
})
.toMatch(TITLE);
// The comment controls mount once the collab session + bridge are up; the
// action buttons live inside the expandable bar — open it for the test.
await openOverlayMenu(page); // the comment bar lives in the overlay menu (0010)
await expect(page.getByTestId('comment-bar-toggle')).toBeVisible({ timeout: 30000 });
await page.getByTestId('comment-bar-toggle').click();
await expect(page.getByTestId('comment-mode-toggle')).toBeVisible();
// The comment controls mount once the collab session + bridge are up. They
// live in the overlay menu's "Comments" section and are visible as soon as it
// opens — the old nested expand toggle (comment-bar-toggle) is gone, since the
// section itself is the group.
await openOverlayMenu(page); // the comment controls live in the overlay menu (0010)
await expect(page.getByTestId('comment-mode-toggle')).toBeVisible({ timeout: 30000 });
}
/** Delete every leftover thread from previous runs comments PERSIST in the

@ -1 +1 @@
Subproject commit e044c86ea538a1ec6dd2120d1f85fcf9da6b8aa2
Subproject commit 10ef7c1a0278970277cfb67c7b0360e3f1d03ce9

View file

@ -1,7 +1,8 @@
import * as React from "react";
import { createPortal } from "react-dom";
import { overlayRowClass } from "@/components/OverlayMenu";
import type { CommentAnchor } from "@pcbjam/shared";
import { Eye, EyeOff, List, MessageSquarePlus, MessageSquareText, X } from "lucide-react";
import { Eye, EyeOff, List, MessageSquarePlus, X } from "lucide-react";
import {
screenToWorld,
worldToScreen,
@ -29,6 +30,28 @@ interface CssRect {
height: number;
}
/**
* What to show for a comment author, and what to reveal on hover.
*
* `author`/`createdBy` is the SLUG the identity key used for colors and
* ownership. It is what used to be rendered, which is why comments showed a
* scope-looking string instead of a person. Prefer the denormalized display
* name; legacy messages (written before authorName existed) have none and fall
* back to the slug, so old threads keep working.
*/
function authorLabel(a: { author?: string; createdBy?: string; authorName?: string; authorEmail?: string }): {
text: string;
title: string;
} {
const slug = a.author ?? a.createdBy ?? "";
const text = a.authorName || slug;
// Tooltip: email when we captured one, otherwise the slug — always something
// more identifying than the label itself.
const title = a.authorEmail ? `${text} <${a.authorEmail}>` : slug;
return { text, title };
}
function glCanvasRect(): CssRect | null {
const el = Array.from(document.querySelectorAll('[id^="glcanvas-"]')).find((c) => {
const r = (c as HTMLElement).getBoundingClientRect();
@ -66,7 +89,6 @@ export function CommentLayer({
menuSlot: HTMLElement | null;
}) {
const [threads, setThreads] = React.useState<ResolvedThread[]>(controller.threads());
const [barOpen, setBarOpen] = React.useState(false);
const [mode, setMode] = React.useState(false);
const [openId, setOpenId] = React.useState<string | null>(null);
const [panel, setPanel] = React.useState(false);
@ -211,54 +233,57 @@ export function CommentLayer({
const menuUi = menuSlot
? createPortal(
<>
<div className="flex items-center gap-2">
{/* The overlay menu's "Comments" section IS the group, so there is no
nested open/close toggle here any more that was a collapsible
inside a collapsible. And every action is a LABELLED row: the old
bar was four bare icons whose meanings you had to hover to learn. */}
<div className="flex w-full flex-col">
<button
data-testid="comment-bar-toggle"
title="Comments"
onClick={() => setBarOpen((o) => !o)}
className={`flex h-8 min-w-8 items-center justify-center gap-1 rounded-full px-2 text-xs shadow-sm ring-1 ring-inset ring-white/20 ${
barOpen ? "bg-sky-600 text-white" : "bg-black/70 text-white hover:bg-black/85"
}`}
data-testid="comment-mode-toggle"
aria-pressed={mode}
title={mode ? "Cancel (Esc)" : "Click the canvas to place a pin"}
onClick={() => {
if (hidden) toggleHidden();
setMode((m) => !m);
setDraft(null);
}}
className={`${overlayRowClass} ${mode ? "bg-amber-500/20 text-amber-200" : ""}`}
>
<MessageSquareText size={15} />
{threads.length > 0 && <span>{threads.length}</span>}
<MessageSquarePlus size={14} className="shrink-0 text-white/50" />
<span>{mode ? "Placing comment…" : "Add comment"}</span>
<span className="ml-auto text-[10px] text-white/40">
{mode ? "Esc" : ""}
</span>
</button>
<button
data-testid="comment-panel-toggle"
aria-pressed={panel}
title="Show every comment in this file"
onClick={() => setPanel((p) => !p)}
className={`${overlayRowClass} ${panel ? "bg-white/10" : ""}`}
>
<List size={14} className="shrink-0 text-white/50" />
<span>{panel ? "Hide list" : "Show list"}</span>
<span className="ml-auto text-[10px] text-white/40">
{threads.length}
</span>
</button>
<button
data-testid="comment-visibility-toggle"
aria-pressed={!hidden}
title={hidden ? "Show the pins on the canvas" : "Hide the pins on the canvas"}
onClick={toggleHidden}
className={overlayRowClass}
>
{hidden ? (
<EyeOff size={14} className="shrink-0 text-white/50" />
) : (
<Eye size={14} className="shrink-0 text-white/50" />
)}
<span>{hidden ? "Show pins" : "Hide pins"}</span>
</button>
{barOpen && (
<div className="flex items-center gap-1 rounded-full bg-black/70 p-1 shadow-sm ring-1 ring-inset ring-white/20">
<button
data-testid="comment-mode-toggle"
title={mode ? "Cancel comment (Esc)" : "New comment"}
onClick={() => {
if (hidden) toggleHidden();
setMode((m) => !m);
setDraft(null);
}}
className={`flex h-6 w-6 items-center justify-center rounded-full ${
mode ? "bg-amber-500 text-black" : "text-white hover:bg-white/15"
}`}
>
<MessageSquarePlus size={14} />
</button>
<button
data-testid="comment-panel-toggle"
title="Comment list"
onClick={() => setPanel((p) => !p)}
className={`flex h-6 w-6 items-center justify-center rounded-full ${
panel ? "bg-white/25 text-white" : "text-white hover:bg-white/15"
}`}
>
<List size={14} />
</button>
<button
data-testid="comment-visibility-toggle"
title={hidden ? "Show comments" : "Hide comments"}
onClick={toggleHidden}
className="flex h-6 w-6 items-center justify-center rounded-full text-white hover:bg-white/15"
>
{hidden ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
)}
</div>
{/* Threads panel (filter + jump-to). */}
@ -294,8 +319,9 @@ export function CommentLayer({
<span
className="font-semibold"
style={{ color: controller.colorFor(t.createdBy) }}
title={authorLabel(t).title}
>
{t.createdBy}
{authorLabel(t).text}
</span>{" "}
<span className="text-white/50">
{timeAgo(t.createdAt)} ago{t.resolved ? " · resolved" : ""}
@ -336,7 +362,7 @@ export function CommentLayer({
key={t.id}
data-testid="comment-pin"
data-thread-id={t.id}
title={`${t.createdBy}: ${t.messages[0]?.body ?? ""}${t.detached ? " (detached)" : ""} — drag to move`}
title={`${authorLabel(t).text}: ${t.messages[0]?.body ?? ""}${t.detached ? " (detached)" : ""} — drag to move`}
onPointerDown={onPinPointerDown(t)}
onPointerMove={onPinPointerMove}
onPointerUp={onPinPointerUp(t)}
@ -448,10 +474,15 @@ function ThreadPopover({
}
};
// z-[60] beats the overlay menu's z-50 ON PURPOSE: the menu panel is tall
// enough to cover a pin popover, and a popover can be opened FROM the menu
// (the thread list's jump-to), so the menu would otherwise swallow clicks on
// the popover it just spawned. The focused surface wins; the menu stays one
// click-away from dismissal.
return (
<div
data-testid="comment-popover"
className="absolute z-40 w-72 rounded-lg bg-black/90 text-white shadow-lg ring-1 ring-inset ring-white/20"
className="absolute z-[60] w-72 rounded-lg bg-black/90 text-white shadow-lg ring-1 ring-inset ring-white/20"
style={{
left: Math.min(css.x + 16, window.innerWidth - 300),
top: Math.min(css.y - 8, window.innerHeight - 260),
@ -493,8 +524,12 @@ function ThreadPopover({
{thread.messages.map((m) => (
<div key={m.id} data-testid="comment-message" className="group px-3 py-2 text-xs">
<div className="flex items-baseline gap-2">
<span className="font-semibold" style={{ color: controller.colorFor(m.author) }}>
{m.author}
<span
className="font-semibold"
style={{ color: controller.colorFor(m.author) }}
title={authorLabel(m).title}
>
{authorLabel(m).text}
</span>
<span className="text-[10px] text-white/40">
{timeAgo(m.createdAt)} ago{m.editedAt ? " · edited" : ""}

View file

@ -13,8 +13,48 @@ import { Users } from "lucide-react";
* toasts) by decision: it is trivially dismissed (click-away, Esc, the FAB)
* and can be dragged out of the way. Stays up in chrome-hidden mode it is
* the canvas-only survivor the chrome toggle used to be.
*
* VISUAL SYSTEM. The panel previously stacked whatever its children happened to
* look like free-floating pills of different heights, radii and surfaces, all
* left-aligned with a gap. It read as debris rather than a menu. The primitives
* below are the fix and the contract:
*
* OverlayMenuSection a labelled group, separated from its neighbours.
* overlayRowClass the shared row shape for anything interactive.
*
* Children compose these instead of inventing their own chrome. Two deliberate
* exceptions stay self-styled because they are shared with light-background
* pages (SourceChip) or own a nontrivial internal layout (PresenceRoster)
* those are wrapped in a section rather than restyled.
*/
/** Row shape for any interactive item in the panel. Full-width so the panel
* reads as a list; the hover/active states are the only affordance needed. */
export const overlayRowClass =
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs " +
"text-white/90 transition-colors hover:bg-white/10 " +
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-white/40";
/** A labelled group. `label` is omitted for the first/unnamed group. */
export function OverlayMenuSection({
label,
children,
}: {
label?: string;
children: React.ReactNode;
}) {
return (
<div className="flex w-full flex-col gap-1 border-t border-white/10 pt-2 first:border-t-0 first:pt-0">
{label && (
<div className="px-2 text-[10px] font-semibold uppercase tracking-wide text-white/40">
{label}
</div>
)}
{children}
</div>
);
}
const POS_KEY = "pcbjam:overlay-menu-pos";
const FAB_SIZE = 36;
const DRAG_THRESHOLD_PX = 4;
@ -156,8 +196,10 @@ export function OverlayMenu({
onPointerDown={onFabPointerDown}
onPointerMove={onFabPointerMove}
onPointerUp={onFabPointerUp}
className={`relative flex h-9 w-9 items-center justify-center rounded-full shadow-md ring-1 ring-inset ring-white/25 ${
open ? "bg-sky-600 text-white" : "bg-black/75 text-white hover:bg-black/90"
className={`relative flex h-9 w-9 items-center justify-center rounded-full text-white shadow-lg ring-1 ring-inset transition-colors ${
open
? "bg-sky-600 ring-sky-300/40"
: "bg-neutral-950/80 ring-white/15 backdrop-blur-sm hover:bg-neutral-900/90"
}`}
style={{ touchAction: "none" }}
>
@ -175,10 +217,20 @@ export function OverlayMenu({
{open && (
<div
data-testid="overlay-menu-panel"
className={`absolute flex w-72 flex-col items-start gap-2 rounded-lg bg-black/85 p-2 shadow-xl ring-1 ring-inset ring-white/20 ${
className={`absolute flex w-72 flex-col gap-2 rounded-xl bg-neutral-950/90 p-2 shadow-2xl ring-1 ring-inset ring-white/15 backdrop-blur-sm ${
onLeftHalf ? "left-0" : "right-0"
} ${onTopHalf ? "top-11" : "bottom-11"}`}
>
<div className="flex items-center justify-between px-2 pt-0.5">
<span className="text-[11px] font-semibold tracking-wide text-white/70">
Session
</span>
{badge > 0 && (
<span className="text-[10px] text-white/40">
{badge} {badge === 1 ? "other" : "others"} here
</span>
)}
</div>
{children}
</div>
)}

View file

@ -1,21 +1,28 @@
import { Eye } from "lucide-react";
import type { PresencePeer } from "@/wasm/collab/presence";
import type { FollowTarget } from "@/wasm/collab/follow-user";
import { overlayRowClass } from "@/components/OverlayMenu";
/**
* "Who else is in this file" (collab-presence 0001/0003): a compact facepile of
* the room's OTHER users, one colored initial-avatar per person, fed by the
* collab session's awareness. For eeschema (per-sheet rooms + warm-pool
* skeleton states) peers on a DIFFERENT sheet render dimmed, with the sheet
* they're on in the tooltip. Rendered in the editor's top-right overlay stack
* next to SourceChip; the parent hides it when there are no peers. Chip styling
* mirrors SourceChip (solid fill + inset ring) so it is legible on any backdrop.
* "Who else is in this file" (collab-presence 0001/0003), as a readable LIST.
*
* Follow-user (0008): when `onFollow` is provided, clicking a same-sheet
* peer's avatar follows their viewport (click again to stop); the followed
* peer gets a ring in their color. The parent renders the "Following…"
* banner the roster only toggles.
* This used to be a facepile overlapping 20px circles showing one initial
* each. That is a fine density trade in a cramped toolbar, but it lives in the
* overlay menu now (0010), which is 288px wide: there is room for names, and a
* row of colored initials told you neither who was here nor that clicking one
* followed them. One row per person instead: color dot, name, where they are,
* and an explicit follow affordance.
*
* For eeschema (per-sheet rooms) peers on a DIFFERENT sheet are dimmed, say
* which sheet they're on, and are not followable following someone you cannot
* see would just teleport you.
*
* Follow-user (0008): clicking a same-sheet peer follows their viewport; the
* followed peer's row stays highlighted with a "Stop" action, so the state and
* its exit live on the same row (the parent no longer renders a separate
* "Following…" banner).
*/
const MAX_AVATARS = 5;
const MAX_ROWS = 6;
function sheetLabel(sheetPath?: string): string {
if (!sheetPath) return "";
@ -42,13 +49,10 @@ export function PresenceRoster({
const sameSheet = (p: PresencePeer) =>
(p.sheetPath ?? undefined) === (activeSheetPath ?? undefined);
// Same-sheet peers first, then elsewhere (dimmed) — stable within each group.
const ordered = [...peers].sort((a, b) => Number(sameSheet(b)) - Number(sameSheet(a)));
const names = ordered
.map((p) =>
sameSheet(p) ? p.user.name : `${p.user.name} (on ${sheetLabel(p.sheetPath) || "another sheet"})`,
)
.join(", ");
const shown = ordered.slice(0, MAX_AVATARS);
const ordered = [...peers].sort(
(a, b) => Number(sameSheet(b)) - Number(sameSheet(a)),
);
const shown = ordered.slice(0, MAX_ROWS);
const followable = (p: PresencePeer) => !!onFollow && sameSheet(p);
const isFollowed = (p: PresencePeer) => following?.clientId === p.clientId;
@ -56,46 +60,69 @@ export function PresenceRoster({
const toggleFollow = (p: PresencePeer) => {
if (!onFollow) return;
onFollow(
isFollowed(p) ? null : { clientId: p.clientId, userId: p.user.id, name: p.user.name },
isFollowed(p)
? null
: { clientId: p.clientId, userId: p.user.id, name: p.user.name },
);
};
return (
<span
data-testid="presence-roster"
title={`Also here: ${names}`}
className="inline-flex items-center rounded-full bg-black/70 py-0.5 pl-1 pr-1.5 shadow-sm ring-1 ring-inset ring-white/20"
>
{shown.map((p) => (
<button
key={p.user.id}
type="button"
data-presence-user={p.user.id}
data-presence-elsewhere={sameSheet(p) ? undefined : "1"}
data-presence-following={isFollowed(p) ? "1" : undefined}
disabled={!followable(p)}
onClick={() => toggleFollow(p)}
title={
sameSheet(p)
? `${p.user.name}${onFollow ? (isFollowed(p) ? " — click to stop following" : " — click to follow") : ""}`
: `${p.user.name} — on ${sheetLabel(p.sheetPath) || "another sheet"}`
}
style={{
backgroundColor: p.user.color,
...(isFollowed(p) ? { boxShadow: `0 0 0 2px ${p.user.color}` } : {}),
}}
className={`-ml-1.5 flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold text-white ring-2 ring-black/50 first:ml-0 ${
sameSheet(p) ? "cursor-pointer" : "opacity-40"
}`}
>
{p.user.name.charAt(0).toUpperCase()}
</button>
))}
{peers.length > MAX_AVATARS && (
<span className="ml-1 text-[10px] font-medium text-white/80">
+{peers.length - MAX_AVATARS}
<div data-testid="presence-roster" className="flex w-full flex-col">
{shown.map((p) => {
const here = sameSheet(p);
const followed = isFollowed(p);
const elsewhere = sheetLabel(p.sheetPath) || "another sheet";
return (
<button
key={p.user.id}
type="button"
data-presence-user={p.user.id}
data-presence-elsewhere={here ? undefined : "1"}
data-presence-following={followed ? "1" : undefined}
disabled={!followable(p)}
onClick={() => toggleFollow(p)}
title={
here
? onFollow
? followed
? `${p.user.name} — click to stop following`
: `${p.user.name} — click to follow their view`
: p.user.name
: `${p.user.name} — on ${elsewhere}`
}
className={`${overlayRowClass} ${
followed ? "bg-white/10" : ""
} ${here ? "" : "cursor-default opacity-50 hover:bg-transparent"}`}
>
<span
aria-hidden
className="h-2.5 w-2.5 shrink-0 rounded-full ring-1 ring-inset ring-black/40"
style={{ backgroundColor: p.user.color }}
/>
<span className="truncate">{p.user.name}</span>
{!here && (
<span className="ml-auto shrink-0 truncate text-[10px] text-white/40">
on {elsewhere}
</span>
)}
{here && followed && (
<span className="ml-auto flex shrink-0 items-center gap-1 text-[10px] font-medium text-white/70">
<Eye size={12} /> Stop
</span>
)}
{here && !followed && followable(p) && (
<span className="ml-auto shrink-0 text-[10px] text-white/35">
Follow
</span>
)}
</button>
);
})}
{peers.length > MAX_ROWS && (
<span className="px-2 py-1 text-[10px] text-white/40">
+{peers.length - MAX_ROWS} more
</span>
)}
</span>
</div>
);
}

View file

@ -23,14 +23,41 @@ const TONES: Record<SourceKind, string> = {
"remote-rw": "bg-sky-600 text-white ring-sky-300/30",
};
// MUTED variant, for use on a known-dark surface (the editor's overlay menu).
// The saturated fills above exist because the chip must survive an unknown
// backdrop; inside the menu the backdrop IS known, and a solid sky/emerald pill
// just shouts. Here the colour drops to a small leading dot and the chip itself
// becomes another neutral row.
const MUTED_TONES: Record<SourceKind, string> = {
local: "text-emerald-300/90",
"remote-ro": "text-amber-300/90",
"remote-rw": "text-sky-300/90",
};
export function SourceChip({
descriptor,
className = "",
tone = "solid",
}: {
descriptor: SourceDescriptor;
className?: string;
/** `muted` for known-dark surfaces (see MUTED_TONES). */
tone?: "solid" | "muted";
}) {
const Icon = ICONS[descriptor.kind];
if (tone === "muted") {
return (
<span
title={descriptor.description}
className={`inline-flex items-center gap-2 text-xs font-medium text-white/90 ${className}`}
>
<Icon size={14} className={`shrink-0 ${MUTED_TONES[descriptor.kind]}`} />
{descriptor.label}
</span>
);
}
return (
<span
title={descriptor.description}

View file

@ -15,10 +15,11 @@ import {
type KicadDoc,
type Tool,
} from "@pcbjam/shared";
import { ChevronDown, ChevronUp, EyeOff, Loader2, PanelsTopLeft } from "lucide-react";
import { ChevronDown, ChevronUp, Eye, EyeOff, Loader2, PanelsTopLeft } from "lucide-react";
import {
API_BASE_URL,
APP_URL,
commentAuthor,
currentScope,
libsSourceConfig,
modelsSourceConfig,
@ -86,7 +87,11 @@ import {
} from "@/wasm/collab/comments";
import { PresenceRoster } from "@/components/PresenceRoster";
import { CommentLayer } from "@/components/CommentLayer";
import { OverlayMenu } from "@/components/OverlayMenu";
import {
OverlayMenu,
OverlayMenuSection,
overlayRowClass,
} from "@/components/OverlayMenu";
import { hasTunerBridge, PresenceTuner, type TunerModule } from "@/components/PresenceTuner";
import {
createSheetCollabManager,
@ -1164,7 +1169,7 @@ export function WasmTool({
const ctl = createComments({
doc,
mod: win.Module,
user: presenceUser().id,
user: commentAuthor(),
tool,
// Author colors follow the live nth-in-room assignment when the
// author is present; offline authors fall back to the name hash.
@ -1684,58 +1689,79 @@ export function WasmTool({
the one control that stays up in canvas-only (chrome-hidden) mode. */}
{ready && (
<OverlayMenu badge={peers.length}>
{/* PEOPLE who else is here, and whose view you're locked to. The
follow state lives on each person's own row (PresenceRoster), so
there is no separate "Following…" banner to keep in sync. */}
{peers.length > 0 && (
<PresenceRoster
peers={peers}
activeSheetPath={activeSheetPath}
following={followingTarget}
onFollow={(t) => {
if (t) followRef.current?.follow(t);
else followRef.current?.unfollow();
}}
/>
<OverlayMenuSection label="People">
<PresenceRoster
peers={peers}
activeSheetPath={activeSheetPath}
following={followingTarget}
onFollow={(t) => {
if (t) followRef.current?.follow(t);
else followRef.current?.unfollow();
}}
/>
</OverlayMenuSection>
)}
{sourceDescriptor && <SourceChip descriptor={sourceDescriptor} />}
{readOnly && (
<span
data-testid="view-only-pill"
className="flex h-8 items-center rounded-full bg-black/70 px-3 text-xs font-medium text-white shadow-sm ring-1 ring-inset ring-white/20"
>
View only
</span>
)}
{followingTarget && (
<div className="flex items-center gap-2 text-xs text-white">
<span>
Following <span className="font-semibold">{followingTarget.name}</span>
</span>
<button
type="button"
className="rounded-full bg-white/15 px-2 py-0.5 font-medium hover:bg-white/25"
onClick={() => followRef.current?.unfollow()}
>
Stop
</button>
</div>
{/* DOCUMENT where this file came from and whether you may edit it.
SourceChip is shared with the light project pages, so instead of
restyling it we ask for its `muted` tone: colour drops to a dot,
and the chip sits in a normal row like everything else. */}
{(sourceDescriptor || readOnly) && (
<OverlayMenuSection label="Document">
{sourceDescriptor && (
<div className={`${overlayRowClass} cursor-default`}>
<SourceChip descriptor={sourceDescriptor} tone="muted" />
</div>
)}
{readOnly && (
<div
data-testid="view-only-pill"
className={`${overlayRowClass} cursor-default`}
>
<EyeOff size={14} className="shrink-0 text-white/50" />
<span>View only</span>
<span className="ml-auto text-[10px] text-white/40">
read-only
</span>
</div>
)}
</OverlayMenuSection>
)}
{commentsCtl && (
<div
data-testid="overlay-menu-comments"
ref={setCommentsSlot}
className="flex w-full flex-col items-start gap-2"
/>
<OverlayMenuSection label="Comments">
<div
data-testid="overlay-menu-comments"
ref={setCommentsSlot}
className="flex w-full flex-col items-start gap-2"
/>
</OverlayMenuSection>
)}
{setChromeFn !== null && !readOnly && (
<button
data-testid="chrome-toggle"
aria-pressed={chromeHidden}
className="flex h-8 items-center gap-2 rounded-full bg-black/70 px-3 text-xs text-white shadow-sm ring-1 ring-inset ring-white/20 hover:bg-black/85"
title={`${chromeHidden ? "Show" : "Hide"} UI (${CHROME_HOTKEY_LABEL})`}
onClick={() => toggleChromeHidden()}
>
{chromeHidden ? <PanelsTopLeft size={15} /> : <EyeOff size={15} />}
{chromeHidden ? "Show UI" : "Hide UI"}
</button>
<OverlayMenuSection label="View">
<button
data-testid="chrome-toggle"
aria-pressed={chromeHidden}
className={overlayRowClass}
title={`${chromeHidden ? "Show" : "Hide"} UI (${CHROME_HOTKEY_LABEL})`}
onClick={() => toggleChromeHidden()}
>
{chromeHidden ? (
<PanelsTopLeft size={14} className="shrink-0 text-white/50" />
) : (
<EyeOff size={14} className="shrink-0 text-white/50" />
)}
<span>{chromeHidden ? "Show UI" : "Hide UI"}</span>
<kbd className="ml-auto rounded bg-white/10 px-1.5 py-0.5 text-[10px] font-medium text-white/50">
{CHROME_HOTKEY_LABEL}
</kbd>
</button>
</OverlayMenuSection>
)}
</OverlayMenu>
)}

View file

@ -214,6 +214,28 @@ export function presenceUser(): PresenceUser {
return { id: slug, name, color: colorForUser(slug) };
}
/** Author identity stamped onto comments. */
export interface CommentAuthor {
/** Slug — the IDENTITY key (colors, "is this mine?"). Never a display name. */
id: string;
/** Display name; falls back to the slug when no session identity is loaded. */
name: string;
/** Only present for a real authenticated session. */
email?: string;
}
/**
* Who to attribute a new comment to. Same identity as `presenceUser()`, plus the
* email, and kept separate because comments DENORMALIZE these at write time
* (comments-wire.ts) whereas presence re-broadcasts them live.
*/
export function commentAuthor(): CommentAuthor {
const slug = userSlug();
const session = sessionIdentity();
const mine = session && session.slug === slug ? session : null;
return { id: slug, name: mine?.name ?? slug, email: mine?.email };
}
/**
* DEV-TIME presence style tuner (collab-presence): VITE_PRESENCE_TUNER=1 mounts
* a floating panel that live-patches the wasm overlay style

View file

@ -7,7 +7,12 @@
* without the endpoint (example backend, demo/static) simply yield null and
* the pre-auth slug fallback in config.ts stays in effect.
*/
export type SessionIdentity = { slug: string; name: string };
export type SessionIdentity = {
slug: string;
name: string;
/** Undefined for backends that don't return one (example/demo/static). */
email?: string;
};
let identity: SessionIdentity | null = null;
let pending: Promise<SessionIdentity | null> | null = null;
@ -35,12 +40,11 @@ export function loadSessionIdentity(
} | null
)?.user;
if (u && typeof u.slug === "string" && u.slug) {
const email = typeof u.email === "string" && u.email ? u.email : undefined;
identity = {
slug: u.slug,
name:
(typeof u.name === "string" && u.name) ||
(typeof u.email === "string" && u.email) ||
u.slug,
name: (typeof u.name === "string" && u.name) || email || u.slug,
email,
};
}
return identity;

View file

@ -115,8 +115,12 @@ const PUSH_THROTTLE_MS = 30;
export function createComments(opts: {
doc: Y.Doc;
mod: CommentPinsModule;
/** Author slug for new messages (presence identity). */
user: string;
/**
* Author for new messages. `id` is the slug (identity key); `name`/`email`
* are denormalized onto each message at write time so a comment still shows
* a real author when they are offline, renamed, or gone (comments-wire.ts).
*/
user: { id: string; name?: string; email?: string };
tool: string;
/** Presence color resolver (nth-in-room); undefined falls back to the hash. */
colorFor?: (userId: string) => string | undefined;
@ -223,10 +227,21 @@ export function createComments(opts: {
return { pos: { x: world.x, y: world.y } };
},
create(anchor, body) {
return createThread(doc, { anchor, author: user, body });
return createThread(doc, {
anchor,
author: user.id,
authorName: user.name,
authorEmail: user.email,
body,
});
},
reply(threadId, body) {
addMessage(doc, threadId, { author: user, body });
addMessage(doc, threadId, {
author: user.id,
authorName: user.name,
authorEmail: user.email,
body,
});
},
edit(threadId, messageId, body) {
return editMessage(doc, threadId, messageId, body);