comments-ux: figma bubble pins, floating panel, seen/reactions/mentions UI, theme follow (0001 A–E + 0002)

- GAL pin = one closed polygon: round body, squared-off bottom-left corner
  ON the anchor; PIN gains unread (accent ring); tuner knobs; shipped
  defaults r9/ring4/alpha.9. DOM hit/highlight sized+offset from a LIVE
  pin-geometry radius store the tuner feeds.
- Floating comments panel: draggable (shared useDraggablePanel with
  always-onscreen restore; overlay FAB retrofitted), collapsible to header,
  header carries add/show-hide/mark-all; unread badges (rose on mention).
- Reactions (emoji-mart lazy, quick-row) + @-mention autocomplete
  (MentionInput; backend roster with presence/author fallback).
- Theme: ?theme= > storage > OS, no-flash boot, toggles (HomePage + overlay
  View row), boot-seeded pcbjam-dark schematic colors + kicadSetColorTheme /
  kicadSetDarkChrome bridges (canvas + wx chrome live flip), light/dark
  variants across all overlay surfaces.
- e2e: panel/seen/reactions/mentions/theme specs + resize-spec geometry;
  bumps pcbjam-shared (flat-key seen/reactions + listCollaborators) and
  wxwidgets (dark chrome) pointers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HLwn1toiNKi1MgxGKnZTes
This commit is contained in:
Gergő Törcsvári 2026-07-24 13:21:22 +02:00
commit 21a96b0440
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
39 changed files with 2436 additions and 232 deletions

View file

@ -7,6 +7,25 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>PCBJam - KiCad demo</title>
<!-- No-flash theme boot (comments-ux 0002): ?theme= (platform hand-off,
persisted) > localStorage > OS preference, applied before first paint.
Keep the key + precedence in sync with src/lib/theme.ts. -->
<script>
(function () {
try {
var p = new URLSearchParams(location.search).get("theme");
var t = p === "light" || p === "dark" ? p : null;
if (t) localStorage.setItem("pcbjam-theme", t);
else t = localStorage.getItem("pcbjam-theme");
if (t !== "light" && t !== "dark") {
t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
document.documentElement.classList.toggle("dark", t === "dark");
} catch (e) {
/* storage/matchMedia unavailable — stay light */
}
})();
</script>
<meta
name="description"
content="Early-access alpha of PCBJam — KiCad running in your browser. Open the example boards or your own KiCad project. No install, nothing uploaded."

View file

@ -15,6 +15,8 @@
"test:watch": "vitest"
},
"dependencies": {
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@hocuspocus/provider": "^4.1.1",
"@pcbjam/shared": "workspace:*",
"@pcbjam/sync-client": "workspace:*",
@ -25,6 +27,7 @@
"@ts-rest/core": "^3.52.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",

View file

@ -1,8 +1,24 @@
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, X } from "lucide-react";
import {
threadMentionsUnread,
threadUnreadCount,
type Collaborator,
type CommentAnchor,
} from "@pcbjam/shared";
import {
CheckCheck,
ChevronDown,
ChevronRight,
Eye,
EyeOff,
List,
MessageSquarePlus,
Plus,
SmilePlus,
X,
} from "lucide-react";
import {
screenToWorld,
worldToScreen,
@ -10,6 +26,16 @@ import {
type ResolvedThread,
type ViewportState,
} from "@/wasm/collab/comments";
import {
bubbleCenterOffsetPx,
pinRadiusPx,
subscribePinRadius,
} from "@/wasm/collab/pin-geometry";
import { useDraggablePanel } from "@/components/useDraggablePanel";
import { EmojiPickerPopover } from "@/components/EmojiPicker";
import { MentionInput } from "@/components/MentionInput";
import { noteEmojiUsed, quickEmojis } from "@/lib/emoji-quick";
import { cachedCollaborators, collaborators, mergeCandidates } from "@/lib/mentions";
/**
* DOM half of the hybrid comment pins (collab-presence 0005): the GAL overlay
@ -74,11 +100,19 @@ function timeAgo(ms: number): string {
const DRAG_THRESHOLD_PX = 4;
const DRAG_SYNC_MS = 60;
const PANEL_OPEN_KEY = "pcbjam:comments-panel-open";
const PANEL_COLLAPSED_KEY = "pcbjam:comments-panel-collapsed";
const PANEL_POS_KEY = "pcbjam:comments-panel-pos";
const PANEL_W = 288; // w-72
const PANEL_HEADER_H = 36;
export function CommentLayer({
controller,
viewport,
currentUser,
menuSlot,
onUnreadChange,
mentionPeers,
}: {
controller: CommentsController;
viewport: ViewportState | null;
@ -87,11 +121,35 @@ export function CommentLayer({
* into it while the menu is open; null (menu closed) renders neither.
* Pins, popovers, composer and the click catcher stay canvas-anchored. */
menuSlot: HTMLElement | null;
/** Unread rollup for the overlay FAB badge (0001 C): threads with unread
* messages + whether any of them mentions the current user. */
onUnreadChange?: (unreadThreads: number, mentioned: boolean) => void;
/** Live presence peers as mention candidates (0001 E fallback roster). */
mentionPeers?: Collaborator[];
}) {
const [threads, setThreads] = React.useState<ResolvedThread[]>(controller.threads());
const [mode, setMode] = React.useState(false);
const [openId, setOpenId] = React.useState<string | null>(null);
const [panel, setPanel] = React.useState(false);
// The floating panel's open state survives reloads (position does too, via
// useDraggablePanel inside CommentsPanel).
const [panel, setPanelState] = React.useState<boolean>(() => {
try {
return localStorage.getItem(PANEL_OPEN_KEY) === "1";
} catch {
return false;
}
});
const setPanel = (next: boolean | ((p: boolean) => boolean)) => {
setPanelState((prev) => {
const v = typeof next === "function" ? next(prev) : next;
try {
localStorage.setItem(PANEL_OPEN_KEY, v ? "1" : "0");
} catch {
/* private mode */
}
return v;
});
};
const [showResolved, setShowResolved] = React.useState(false);
const [hidden, setHidden] = React.useState(!controller.pinsVisible());
const [draft, setDraft] = React.useState<{ anchor: CommentAnchor; css: { x: number; y: number } } | null>(null);
@ -118,6 +176,35 @@ export function CommentLayer({
return controller.subscribe(setThreads);
}, [controller]);
// Viewing an open thread marks it seen — including replies that arrive
// WHILE it is open (threads dep). markSeen is forward-only, so the
// write-observe-rerun cycle settles instead of looping.
React.useEffect(() => {
if (openId) controller.markSeen(openId);
}, [openId, threads, controller]);
const unreadThreads = threads.filter((t) => threadUnreadCount(t, currentUser) > 0).length;
const mentioned = threads.some((t) => threadMentionsUnread(t, currentUser));
// Mention candidates (0001 E): backend roster (lazy, session-cached) ∪
// presence peers ∪ authors already in the doc; never yourself.
const threadsRef = React.useRef(threads);
threadsRef.current = threads;
const mentionPeersRef = React.useRef(mentionPeers);
mentionPeersRef.current = mentionPeers;
const getMentionCandidates = React.useCallback(async (): Promise<Collaborator[]> => {
const server = (await collaborators()) ?? [];
const peers = mentionPeersRef.current ?? [];
const authors: Collaborator[] = threadsRef.current.flatMap((t) =>
t.messages.map((m) => ({ slug: m.author, name: m.authorName || m.author })),
);
return mergeCandidates(server, peers, authors).filter((c) => c.slug !== currentUser);
}, [currentUser]);
React.useEffect(() => {
onUnreadChange?.(unreadThreads, mentioned);
}, [unreadThreads, mentioned, onUnreadChange]);
React.useEffect(() => {
const measure = () => setGlRect(glCanvasRect());
measure();
@ -219,11 +306,23 @@ export function CommentLayer({
}
};
// The GAL bubble's body floats up-right of the anchored point (sharp
// corner) — DOM (hit target, popover) must sit on the body, not the tip.
// The radius is LIVE (PresenceTuner re-styles the GAL pins at runtime), so
// subscribe rather than reading a constant.
const pinR = React.useSyncExternalStore(subscribePinRadius, pinRadiusPx);
const bubbleOff = bubbleCenterOffsetPx();
const toBodyCss = (css: { x: number; y: number }) => ({
x: css.x + bubbleOff.dx * cssRatio,
y: css.y + bubbleOff.dy * cssRatio,
});
const open = openId ? threads.find((t) => t.id === openId) : undefined;
// A thread opened from the panel may sit off-screen (jump-to clamps at the
// view bounds) — fall back to a centered popover rather than rendering none.
const openAnchorCss = open ? toCss(open.world) : null;
const openCss = open
? (toCss(open.world) ?? { x: window.innerWidth / 2 - 150, y: 120 })
? (openAnchorCss ? toBodyCss(openAnchorCss) : { x: window.innerWidth / 2 - 150, y: 120 })
: null;
const visibleThreads = threads.filter((t) => showResolved || !t.resolved);
const pinThreads = hidden ? [] : visibleThreads;
@ -247,11 +346,11 @@ export function CommentLayer({
setMode((m) => !m);
setDraft(null);
}}
className={`${overlayRowClass} ${mode ? "bg-amber-500/20 text-amber-200" : ""}`}
className={`${overlayRowClass} ${mode ? "bg-amber-500/20 text-amber-600 dark:text-amber-200" : ""}`}
>
<MessageSquarePlus size={14} className="shrink-0 text-white/50" />
<MessageSquarePlus size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
<span>{mode ? "Placing comment…" : "Add comment"}</span>
<span className="ml-auto text-[10px] text-white/40">
<span className="ml-auto text-[10px] text-neutral-400 dark:text-white/40">
{mode ? "Esc" : ""}
</span>
</button>
@ -261,11 +360,22 @@ export function CommentLayer({
aria-pressed={panel}
title="Show every comment in this file"
onClick={() => setPanel((p) => !p)}
className={`${overlayRowClass} ${panel ? "bg-white/10" : ""}`}
className={`${overlayRowClass} ${panel ? "bg-black/10 dark: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">
<List size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
<span>{panel ? "Close comments panel" : "Open comments panel"}</span>
<span className="ml-auto flex items-center gap-1 text-[10px] text-neutral-400 dark:text-white/40">
{unreadThreads > 0 && (
<span
data-testid="comment-unread-badge"
title={mentioned ? "Unread comments — you were mentioned" : "Unread comments"}
className={`flex h-4 min-w-4 items-center justify-center rounded-full px-1 font-semibold text-white ${
mentioned ? "bg-rose-500" : "bg-amber-500"
}`}
>
{unreadThreads}
</span>
)}
{threads.length}
</span>
</button>
@ -278,62 +388,14 @@ export function CommentLayer({
className={overlayRowClass}
>
{hidden ? (
<EyeOff size={14} className="shrink-0 text-white/50" />
<EyeOff size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
) : (
<Eye size={14} className="shrink-0 text-white/50" />
<Eye size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
)}
<span>{hidden ? "Show pins" : "Hide pins"}</span>
</button>
</div>
{/* Threads panel (filter + jump-to). */}
{panel && (
<div className="flex max-h-[50vh] w-full flex-col overflow-hidden rounded-lg bg-black/60 text-white ring-1 ring-inset ring-white/15">
<div className="flex items-center justify-between px-3 py-2 text-xs font-semibold">
<span>Comments ({visibleThreads.length})</span>
<label className="flex items-center gap-1 font-normal text-white/70">
<input
data-testid="comment-show-resolved"
type="checkbox"
checked={showResolved}
onChange={(e) => setShowResolved(e.target.checked)}
/>
resolved
</label>
</div>
<div className="overflow-y-auto">
{visibleThreads.length === 0 && (
<p className="px-3 pb-3 text-xs text-white/50">No comments yet.</p>
)}
{visibleThreads.map((t) => (
<button
key={t.id}
data-testid="comment-panel-item"
onClick={() => {
if (hidden) toggleHidden();
controller.jumpTo(t.id);
setOpenId(t.id);
}}
className="block w-full border-t border-white/10 px-3 py-2 text-left text-xs hover:bg-white/10"
>
<span
className="font-semibold"
style={{ color: controller.colorFor(t.createdBy) }}
title={authorLabel(t).title}
>
{authorLabel(t).text}
</span>{" "}
<span className="text-white/50">
{timeAgo(t.createdAt)} ago{t.resolved ? " · resolved" : ""}
</span>
<span className="mt-0.5 block truncate text-white/90">
{t.messages[0]?.body ?? ""}
</span>
</button>
))}
</div>
</div>
)}
</>,
menuSlot,
)
@ -343,6 +405,33 @@ export function CommentLayer({
<>
{menuUi}
{/* Floating comments panel (comments-ux 0001 B): draggable, scrollable,
independent of the overlay menu's open state. */}
{panel && (
<CommentsPanel
threads={visibleThreads}
total={threads.length}
currentUser={currentUser}
showResolved={showResolved}
onShowResolved={setShowResolved}
controller={controller}
mode={mode}
onToggleMode={() => {
if (hidden) toggleHidden();
setMode((m) => !m);
setDraft(null);
}}
pinsHidden={hidden}
onTogglePins={toggleHidden}
onJump={(t) => {
if (hidden) toggleHidden();
controller.jumpTo(t.id);
setOpenId(t.id);
}}
onClose={() => setPanel(false)}
/>
)}
{/* Comment-mode click catcher over the drawing area only. */}
{mode && glRect && (
<div
@ -355,8 +444,16 @@ export function CommentLayer({
{/* Pin hit/drag targets (the visual dot is GAL — these are the DOM halves). */}
{pinThreads.map((t) => {
const css = drag?.id === t.id ? drag.css : toCss(t.world);
if (!css) return null;
const anchorCss = drag?.id === t.id ? drag.css : toCss(t.world);
if (!anchorCss) return null;
const css = toBodyCss(anchorCss);
// The GAL bubble's CSS size (device px × ratio) — the visible ring
// must hug the drawn body, while the transparent button keeps a
// finger-friendly hit area regardless of DPI. +4 ≈ the default ring
// width (collab_presence_style.h pinRingPx) so the highlight clears
// the stroke's outer edge.
const bubbleD = 2 * pinR * cssRatio + 4;
const hitD = Math.max(26, bubbleD + 8);
return (
<button
key={t.id}
@ -366,11 +463,36 @@ export function CommentLayer({
onPointerDown={onPinPointerDown(t)}
onPointerMove={onPinPointerMove}
onPointerUp={onPinPointerUp(t)}
className={`absolute z-30 -translate-x-1/2 -translate-y-1/2 rounded-full ${
drag?.id === t.id ? "cursor-grabbing ring-2 ring-white" : "cursor-grab hover:ring-2 hover:ring-white/70"
className={`group absolute z-30 -translate-x-1/2 -translate-y-1/2 ${
drag?.id === t.id ? "cursor-grabbing" : "cursor-grab"
}`}
style={{ left: css.x, top: css.y, width: 22, height: 22, background: "transparent", touchAction: "none" }}
/>
style={{
left: css.x,
top: css.y,
width: hitD,
height: hitD,
background: "transparent",
touchAction: "none",
}}
>
{/* Highlight sized + shaped like the GAL bubble (round, sharp
bottom-left corner), always slightly padded from the hit
area: a hugging soft wash + outline reads as "this pin",
not a detached circle. */}
<span
className={`pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transition-opacity ${
drag?.id === t.id
? "bg-sky-400/25 ring-2 ring-sky-500 dark:ring-sky-300"
: "opacity-0 group-hover:opacity-100 bg-sky-400/15 ring-2 ring-sky-500/70 dark:ring-sky-300/70"
}`}
style={{
width: bubbleD + 4,
height: bubbleD + 4,
borderRadius: "9999px",
borderBottomLeftRadius: 0,
}}
/>
</button>
);
})}
@ -378,9 +500,10 @@ export function CommentLayer({
{draft && (
<Composer
css={draft.css}
getCandidates={getMentionCandidates}
onCancel={() => setDraft(null)}
onSubmit={(body) => {
const id = controller.create(draft.anchor, body);
onSubmit={(body, mentions) => {
const id = controller.create(draft.anchor, body, mentions);
setDraft(null);
setOpenId(id);
}}
@ -394,6 +517,7 @@ export function CommentLayer({
css={openCss}
currentUser={currentUser}
controller={controller}
getCandidates={getMentionCandidates}
onClose={() => setOpenId(null)}
/>
)}
@ -402,41 +526,257 @@ export function CommentLayer({
);
}
/**
* Floating comments panel (comments-ux 0001 B): a draggable window listing
* every thread, newest activity first, with its own scrollbar. The header
* carries the primary comment actions too (add pin, show/hide pins) and the
* panel COLLAPSES to just that header. Position, open and collapsed state
* persist per browser; useDraggablePanel guarantees the header can never be
* restored or stranded offscreen.
*/
function CommentsPanel({
threads,
total,
currentUser,
showResolved,
onShowResolved,
controller,
mode,
onToggleMode,
pinsHidden,
onTogglePins,
onJump,
onClose,
}: {
threads: ResolvedThread[];
total: number;
currentUser: string;
showResolved: boolean;
onShowResolved: (v: boolean) => void;
controller: CommentsController;
mode: boolean;
onToggleMode: () => void;
pinsHidden: boolean;
onTogglePins: () => void;
onJump: (t: ResolvedThread) => void;
onClose: () => void;
}) {
const rootRef = React.useRef<HTMLDivElement | null>(null);
const drag = useDraggablePanel({
storageKey: PANEL_POS_KEY,
handleWidth: PANEL_W,
handleHeight: PANEL_HEADER_H,
});
const [collapsed, setCollapsedState] = React.useState<boolean>(() => {
try {
return localStorage.getItem(PANEL_COLLAPSED_KEY) === "1";
} catch {
return false;
}
});
const setCollapsed = (v: boolean) => {
setCollapsedState(v);
try {
localStorage.setItem(PANEL_COLLAPSED_KEY, v ? "1" : "0");
} catch {
/* private mode */
}
};
// Default anchor: top-right area but CLEAR of the overlay menu's panel
// (which opens at the FAB, right-anchored, z-50 above us) — the panel is
// usually opened FROM that menu, so spawning underneath it would hide it
// and swallow its header drags.
const style: React.CSSProperties = drag.pos
? { left: drag.pos.x, top: drag.pos.y }
: { right: 308, top: 12 };
const lastActivity = (t: ResolvedThread) =>
t.messages[t.messages.length - 1]?.createdAt ?? t.createdAt;
const sorted = [...threads].sort((a, b) => lastActivity(b) - lastActivity(a));
return (
<div
ref={rootRef}
data-testid="comments-panel"
className="absolute z-40 flex w-72 flex-col overflow-hidden rounded-xl bg-white/95 text-neutral-900 shadow-2xl ring-1 ring-inset ring-black/10 dark:bg-neutral-950/90 dark:text-white dark:ring-white/15 backdrop-blur-sm"
style={style}
>
{/* Header = drag handle. Interactive children stop pointerdown so they
don't start a drag. */}
<div
data-testid="comments-panel-header"
className="flex cursor-grab select-none items-center gap-2 px-3 py-2 text-xs font-semibold active:cursor-grabbing"
style={{ touchAction: "none" }}
title="Comments — drag to move"
onPointerDown={(e) => drag.onPointerDown(e, rootRef.current!.getBoundingClientRect())}
onPointerMove={(e) => void drag.onPointerMove(e)}
onPointerUp={() => void drag.onPointerUp()}
>
<button
data-testid="comments-panel-collapse"
aria-expanded={!collapsed}
title={collapsed ? "Expand" : "Collapse to header"}
onPointerDown={(e) => e.stopPropagation()}
onClick={() => setCollapsed(!collapsed)}
className="rounded p-0.5 text-neutral-500 hover:bg-black/5 hover:text-neutral-900 dark:text-white/60 dark:hover:bg-white/10 dark:hover:text-white"
>
{collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
</button>
<span>Comments ({threads.length})</span>
<span
className="ml-auto flex items-center gap-0.5"
onPointerDown={(e) => e.stopPropagation()}
>
<button
data-testid="comments-panel-add"
aria-pressed={mode}
title={mode ? "Cancel placing (Esc)" : "Add comment — click the canvas to place a pin"}
onClick={onToggleMode}
className={`rounded p-0.5 hover:bg-black/5 hover:text-neutral-900 dark:hover:bg-white/10 dark:hover:text-white ${
mode ? "bg-amber-500/20 text-amber-600 dark:text-amber-200" : "text-neutral-500 dark:text-white/60"
}`}
>
<MessageSquarePlus size={14} />
</button>
<button
data-testid="comments-panel-pins"
aria-pressed={!pinsHidden}
title={pinsHidden ? "Show the pins on the canvas" : "Hide the pins on the canvas"}
onClick={onTogglePins}
className="rounded p-0.5 text-neutral-500 hover:bg-black/5 hover:text-neutral-900 dark:text-white/60 dark:hover:bg-white/10 dark:hover:text-white"
>
{pinsHidden ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
{threads.some((t) => threadUnreadCount(t, currentUser) > 0) && (
<button
data-testid="comments-mark-all-seen"
title="Mark all as seen"
onClick={() => {
for (const t of threads) {
if (threadUnreadCount(t, currentUser) > 0) controller.markSeen(t.id);
}
}}
className="rounded p-0.5 text-neutral-500 hover:bg-black/5 hover:text-neutral-900 dark:text-white/60 dark:hover:bg-white/10 dark:hover:text-white"
>
<CheckCheck size={14} />
</button>
)}
<button
data-testid="comments-panel-close"
title="Close"
onClick={onClose}
className="rounded p-0.5 text-neutral-500 hover:bg-black/5 hover:text-neutral-900 dark:text-white/60 dark:hover:bg-white/10 dark:hover:text-white"
>
<X size={14} />
</button>
</span>
</div>
{!collapsed && (
<label
className="flex items-center gap-1.5 border-t border-black/10 px-3 py-1.5 text-[11px] font-normal text-neutral-600 dark:border-white/10 dark:text-white/70"
onPointerDown={(e) => e.stopPropagation()}
>
<input
data-testid="comment-show-resolved"
type="checkbox"
checked={showResolved}
onChange={(e) => onShowResolved(e.target.checked)}
/>
show resolved
</label>
)}
{!collapsed && (
<div data-testid="comments-panel-list" className="max-h-[60vh] overflow-y-auto">
{sorted.length === 0 && (
<p className="px-3 pb-3 text-xs text-neutral-500 dark:text-white/50">
{total === 0 ? "No comments yet." : "Nothing to show — check the resolved filter."}
</p>
)}
{sorted.map((t) => (
<button
key={t.id}
data-testid="comment-panel-item"
onClick={() => onJump(t)}
className="block w-full border-t border-black/10 px-3 py-2 text-left text-xs hover:bg-black/5 dark:border-white/10 dark:hover:bg-white/10"
>
<span className="flex items-center gap-1">
<span
className="font-semibold"
style={{ color: controller.colorFor(t.createdBy) }}
title={authorLabel(t).title}
>
{authorLabel(t).text}
</span>{" "}
<span className="text-neutral-500 dark:text-white/50">
{timeAgo(lastActivity(t))} ago
{t.resolved ? " · resolved" : ""}
{t.messages.length > 1 ? ` · ${t.messages.length - 1} repl${t.messages.length === 2 ? "y" : "ies"}` : ""}
</span>
{threadUnreadCount(t, currentUser) > 0 && (
<span
data-testid="comment-unread-dot"
title={threadMentionsUnread(t, currentUser) ? "Unread — you were mentioned" : "Unread"}
className={`ml-auto h-2 w-2 shrink-0 rounded-full ${
threadMentionsUnread(t, currentUser) ? "bg-rose-400" : "bg-amber-400"
}`}
/>
)}
</span>
<span className="mt-0.5 block truncate text-neutral-800 dark:text-white/90">
{t.messages[0]?.body ?? ""}
</span>
</button>
))}
</div>
)}
</div>
);
}
/** Slugs actually still `@`-present in the sent body (a mention accepted then
* deleted from the text doesn't count). */
function presentMentions(body: string, accepted: Set<string>): string[] {
return [...accepted].filter((slug) => body.includes(`@${slug}`));
}
function Composer({
css,
getCandidates,
onSubmit,
onCancel,
}: {
css: { x: number; y: number };
onSubmit: (body: string) => void;
getCandidates: () => Promise<Collaborator[]>;
onSubmit: (body: string, mentions: string[]) => void;
onCancel: () => void;
}) {
const [body, setBody] = React.useState("");
const acceptedRef = React.useRef<Set<string>>(new Set());
const submit = () => {
if (body.trim()) onSubmit(body.trim());
if (body.trim()) onSubmit(body.trim(), presentMentions(body, acceptedRef.current));
else onCancel();
};
return (
<div
data-testid="comment-composer"
className="absolute z-40 w-64 rounded-lg bg-black/90 p-2 shadow-lg ring-1 ring-inset ring-white/20"
className="absolute z-40 w-64 rounded-lg bg-white/95 p-2 shadow-lg ring-1 ring-inset ring-black/15 dark:bg-black/90 dark:ring-white/20"
style={{ left: Math.min(css.x + 12, window.innerWidth - 280), top: Math.min(css.y, window.innerHeight - 120) }}
>
<textarea
<MentionInput
multiline
autoFocus
value={body}
onChange={(e) => setBody(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
placeholder="Add a comment…"
className="h-16 w-full resize-none rounded bg-white/10 p-2 text-xs text-white placeholder-white/40 outline-none"
onChange={setBody}
onMention={(slug) => acceptedRef.current.add(slug)}
onSubmit={submit}
getCandidates={getCandidates}
placeholder="Add a comment… (@ to mention)"
className="h-16 w-full resize-none rounded bg-black/5 p-2 text-xs text-neutral-900 placeholder-neutral-400 outline-none dark:bg-white/10 dark:text-white dark:placeholder-white/40"
/>
<div className="mt-1 flex justify-end gap-2">
<button onClick={onCancel} className="rounded px-2 py-1 text-xs text-white/70 hover:bg-white/10">
<button onClick={onCancel} className="rounded px-2 py-1 text-xs text-neutral-600 hover:bg-black/5 dark:text-white/70 dark:hover:bg-white/10">
Cancel
</button>
<button
@ -451,26 +791,206 @@ function Composer({
);
}
const MENTION_RE = /@([A-Za-z0-9][\w.-]*)/g;
/**
* Message body with `@slug` tokens rendered as highlight chips (comments-ux
* 0001 E). A token highlights when its slug is in the message's `mentions`
* (composer-accepted) or matches a known collaborator from the session-cached
* roster (covers hand-typed mentions). Your own slug gets the amber accent.
*/
function MentionBody({
body,
mentions,
currentUser,
}: {
body: string;
mentions?: string[];
currentUser: string;
}) {
const known = new Set([
...(mentions ?? []),
...(cachedCollaborators() ?? []).map((c) => c.slug),
]);
const parts: React.ReactNode[] = [];
let last = 0;
for (const match of body.matchAll(MENTION_RE)) {
const slug = match[1];
if (!slug || !known.has(slug) || match.index === undefined) continue;
parts.push(body.slice(last, match.index));
parts.push(
<span
key={match.index}
data-testid="comment-mention"
data-slug={slug}
title={`@${slug}`}
className={`rounded px-0.5 font-medium ${
slug === currentUser ? "bg-amber-500/25 text-amber-700 dark:text-amber-300" : "bg-sky-500/15 text-sky-700 dark:bg-sky-500/20 dark:text-sky-300"
}`}
>
{match[0]}
</span>,
);
last = match.index + match[0].length;
}
parts.push(body.slice(last));
return <p className="mt-0.5 whitespace-pre-wrap text-neutral-800 dark:text-white/90">{parts}</p>;
}
/**
* Reaction chips + pickers for one message (comments-ux 0001 D). Chips are
* plain text glyphs (no emoji-mart involved); the hover "add" button opens a
* quick row of most-used emoji, whose "+" opens the lazy full picker. Both
* popovers are body-portals at fixed coords — the popover's scroll container
* would clip in-place absolute children.
*/
function MessageReactions({
reactions,
currentUser,
onToggle,
}: {
reactions?: Record<string, string[]>;
currentUser: string;
onToggle: (emoji: string) => void;
}) {
const [picker, setPicker] = React.useState<{ kind: "quick" | "full"; x: number; y: number } | null>(null);
const quickRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => {
if (picker?.kind !== "quick") return;
const onDown = (e: PointerEvent) => {
if (!quickRef.current?.contains(e.target as Node)) setPicker(null);
};
// Capture + stopPropagation: Esc closes the quick row, not the thread
// popover underneath (CommentLayer's own window Esc handler).
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
setPicker(null);
}
};
window.addEventListener("pointerdown", onDown, true);
window.addEventListener("keydown", onKey, true);
return () => {
window.removeEventListener("pointerdown", onDown, true);
window.removeEventListener("keydown", onKey, true);
};
}, [picker?.kind]);
const entries = Object.entries(reactions ?? {}).sort(
(a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]),
);
return (
<div className="mt-1 flex flex-wrap items-center gap-1">
{entries.map(([emoji, slugs]) => (
<button
key={emoji}
data-testid="comment-reaction-chip"
data-emoji={emoji}
title={slugs.join(", ")}
onClick={() => onToggle(emoji)}
className={`flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[11px] ring-1 ring-inset ${
slugs.includes(currentUser)
? "bg-sky-500/20 ring-sky-500/50 dark:bg-sky-500/25 dark:ring-sky-300/50"
: "bg-black/5 ring-black/15 hover:bg-black/10 dark:bg-white/5 dark:ring-white/15 dark:hover:bg-white/10"
}`}
>
<span>{emoji}</span>
<span className="text-neutral-600 dark:text-white/70">{slugs.length}</span>
</button>
))}
<button
data-testid="comment-react"
title="Add reaction"
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
setPicker((p) => (p ? null : { kind: "quick", x: r.x, y: r.bottom }));
}}
className={`rounded-full p-1 text-neutral-400 ring-1 ring-inset ring-black/15 hover:bg-black/5 hover:text-neutral-900 dark:text-white/50 dark:ring-white/15 dark:hover:bg-white/10 dark:hover:text-white ${
entries.length ? "" : "opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
}`}
>
<SmilePlus size={12} />
</button>
{picker?.kind === "quick" &&
createPortal(
<div
ref={quickRef}
data-testid="comment-quick-react"
className="fixed z-[70] flex items-center gap-0.5 rounded-full bg-white px-1.5 py-1 shadow-xl ring-1 ring-black/10 dark:bg-neutral-900 dark:ring-white/15"
style={{
left: Math.max(8, Math.min(picker.x, window.innerWidth - 260)),
top: Math.min(picker.y + 4, window.innerHeight - 44),
}}
>
{quickEmojis().map((e) => (
<button
key={e}
data-emoji={e}
title={`React ${e}`}
onClick={() => {
onToggle(e);
setPicker(null);
}}
className="rounded px-1 text-sm hover:bg-black/5 dark:hover:bg-white/10"
>
{e}
</button>
))}
<button
data-testid="comment-react-more"
title="All emoji…"
onClick={() => setPicker((p) => (p ? { ...p, kind: "full" } : p))}
className="rounded px-1 text-neutral-500 hover:bg-black/5 hover:text-neutral-900 dark:text-white/60 dark:hover:bg-white/10 dark:hover:text-white"
>
<Plus size={14} />
</button>
</div>,
document.body,
)}
{picker?.kind === "full" && (
<EmojiPickerPopover
anchor={picker}
onPick={(e) => {
onToggle(e);
setPicker(null);
}}
onClose={() => setPicker(null)}
/>
)}
</div>
);
}
function ThreadPopover({
thread,
css,
currentUser,
controller,
getCandidates,
onClose,
}: {
thread: ResolvedThread;
css: { x: number; y: number };
currentUser: string;
controller: CommentsController;
getCandidates: () => Promise<Collaborator[]>;
onClose: () => void;
}) {
const [reply, setReply] = React.useState("");
const [editing, setEditing] = React.useState<{ id: string; body: string } | null>(null);
const replyMentionsRef = React.useRef<Set<string>>(new Set());
const sendReply = () => {
if (reply.trim()) {
controller.reply(thread.id, reply.trim());
controller.reply(thread.id, reply.trim(), presentMentions(reply, replyMentionsRef.current));
setReply("");
replyMentionsRef.current = new Set();
}
};
@ -482,14 +1002,14 @@ function ThreadPopover({
return (
<div
data-testid="comment-popover"
className="absolute z-[60] 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-white/95 text-neutral-900 shadow-lg ring-1 ring-inset ring-black/15 dark:bg-black/90 dark:text-white dark:ring-white/20"
style={{
left: Math.min(css.x + 16, window.innerWidth - 300),
top: Math.min(css.y - 8, window.innerHeight - 260),
}}
>
<div className="flex items-center justify-between px-3 py-2">
<span className="text-xs text-white/60">
<span className="text-xs text-neutral-500 dark:text-white/60">
{thread.detached && "detached pin · "}
{thread.resolved ? "resolved" : "open"}
</span>
@ -497,7 +1017,7 @@ function ThreadPopover({
<button
data-testid="comment-resolve"
onClick={() => controller.setResolved(thread.id, !thread.resolved)}
className="rounded px-1.5 py-0.5 text-[11px] text-white/80 ring-1 ring-inset ring-white/25 hover:bg-white/10"
className="rounded px-1.5 py-0.5 text-[11px] text-neutral-700 ring-1 ring-inset ring-black/20 hover:bg-black/5 dark:text-white/80 dark:ring-white/25 dark:hover:bg-white/10"
>
{thread.resolved ? "Reopen" : "Resolve"}
</button>
@ -514,13 +1034,13 @@ function ThreadPopover({
Delete
</button>
)}
<button onClick={onClose} title="Close" className="text-white/60 hover:text-white">
<button onClick={onClose} title="Close" className="text-neutral-500 hover:text-neutral-900 dark:text-white/60 dark:hover:text-white">
<X size={14} />
</button>
</div>
</div>
<div className="max-h-56 overflow-y-auto border-t border-white/10">
<div className="max-h-56 overflow-y-auto border-t border-black/10 dark:border-white/10">
{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">
@ -531,7 +1051,7 @@ function ThreadPopover({
>
{authorLabel(m).text}
</span>
<span className="text-[10px] text-white/40">
<span className="text-[10px] text-neutral-400 dark:text-white/40">
{timeAgo(m.createdAt)} ago{m.editedAt ? " · edited" : ""}
</span>
{m.author === currentUser && (
@ -539,7 +1059,7 @@ function ThreadPopover({
<button
data-testid="comment-edit"
onClick={() => setEditing({ id: m.id, body: m.body })}
className="text-[10px] text-white/60 hover:text-white"
className="text-[10px] text-neutral-500 hover:text-neutral-900 dark:text-white/60 dark:hover:text-white"
>
edit
</button>
@ -568,26 +1088,34 @@ function ThreadPopover({
setEditing(null);
}
}}
className="h-12 w-full resize-none rounded bg-white/10 p-1.5 text-xs text-white outline-none"
className="h-12 w-full resize-none rounded bg-black/5 p-1.5 text-xs text-neutral-900 outline-none dark:bg-white/10 dark:text-white"
/>
</div>
) : (
<p className="mt-0.5 whitespace-pre-wrap text-white/90">{m.body}</p>
<MentionBody body={m.body} mentions={m.mentions} currentUser={currentUser} />
)}
<MessageReactions
reactions={thread.reactions?.[m.id]}
currentUser={currentUser}
onToggle={(emoji) => {
controller.toggleReaction(thread.id, m.id, emoji);
noteEmojiUsed(emoji);
}}
/>
</div>
))}
</div>
<div className="border-t border-white/10 p-2">
<input
data-testid="comment-reply"
<div className="border-t border-black/10 p-2 dark:border-white/10">
<MentionInput
testId="comment-reply"
value={reply}
onChange={(e) => setReply(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") sendReply();
}}
placeholder="Reply…"
className="w-full rounded bg-white/10 px-2 py-1.5 text-xs text-white placeholder-white/40 outline-none"
onChange={setReply}
onMention={(slug) => replyMentionsRef.current.add(slug)}
onSubmit={sendReply}
getCandidates={getCandidates}
placeholder="Reply… (@ to mention)"
className="w-full rounded bg-black/5 px-2 py-1.5 text-xs text-neutral-900 placeholder-neutral-400 outline-none dark:bg-white/10 dark:text-white dark:placeholder-white/40"
/>
</div>
</div>

View file

@ -0,0 +1,108 @@
import * as React from "react";
import { createPortal } from "react-dom";
import { useThemeValue } from "@/lib/theme";
/**
* Full emoji picker for comment reactions (comments-ux 0001 D): emoji-mart —
* categories, search, skin tones, frequently-used — LAZY-LOADED so the picker
* component and its ~0.5 MB data JSON become their own chunk, fetched on the
* first "+" click and never in the boot path. The data is bundled locally
* (no CDN fetch): the standalone must work in offline/@local mode.
*
* Reaction CHIPS need none of this — they render native glyphs as plain text.
*/
interface EmojiSelection {
native?: string;
}
const LazyPicker = React.lazy(async () => {
const [{ default: data }, { default: Picker }] = await Promise.all([
import("@emoji-mart/data"),
import("@emoji-mart/react"),
]);
function PickerWithData(props: { onPick: (emoji: string) => void; theme: "light" | "dark" }) {
return (
<Picker
data={data}
theme={props.theme}
previewPosition="none"
autoFocus
onEmojiSelect={(e: EmojiSelection) => {
if (e.native) props.onPick(e.native);
}}
/>
);
}
return { default: PickerWithData };
});
// emoji-mart's rendered size — used only to clamp the popover into view.
const PICKER_W = 352;
const PICKER_H = 435;
/**
* Click-away-dismissed full picker, PORTALED to the body at a fixed position
* (anchor = the trigger's rect): the comment popover's scroll container would
* clip an absolutely-positioned child, and the picker is bigger than the
* popover anyway. z-[70]: above the thread popover's deliberate z-[60].
*/
export function EmojiPickerPopover({
anchor,
onPick,
onClose,
}: {
anchor: { x: number; y: number };
onPick: (emoji: string) => void;
onClose: () => void;
}) {
const ref = React.useRef<HTMLDivElement | null>(null);
const theme = useThemeValue();
React.useEffect(() => {
const onDown = (e: PointerEvent) => {
if (!ref.current?.contains(e.target as Node)) onClose();
};
// Capture phase: emoji-mart's search input consumes Escape (clears the
// query, stops propagation) before a bubble listener would see it.
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
// The picker is the focused surface: Esc closes IT, not the thread
// popover underneath (CommentLayer's own window Esc handler).
e.stopPropagation();
onClose();
}
};
window.addEventListener("pointerdown", onDown, true);
window.addEventListener("keydown", onKey, true);
return () => {
window.removeEventListener("pointerdown", onDown, true);
window.removeEventListener("keydown", onKey, true);
};
}, [onClose]);
return createPortal(
<div
ref={ref}
data-testid="emoji-picker"
className="fixed z-[70]"
style={{
left: Math.max(8, Math.min(anchor.x, window.innerWidth - PICKER_W - 8)),
top: Math.max(8, Math.min(anchor.y + 4, window.innerHeight - PICKER_H - 8)),
}}
>
<React.Suspense
fallback={
<div className="rounded-lg bg-white px-3 py-2 text-xs text-neutral-500 shadow-xl ring-1 ring-black/10 dark:bg-neutral-900 dark:text-white/60 dark:ring-white/15">
Loading emoji…
</div>
}
>
<LazyPicker onPick={onPick} theme={theme} />
</React.Suspense>
</div>,
document.body,
);
}

View file

@ -0,0 +1,206 @@
import * as React from "react";
import type { Collaborator } from "@pcbjam/shared";
import { filterCandidates } from "@/lib/mentions";
/**
* Text input/textarea with `@`-mention autocomplete (comments-ux 0001 E).
* Typing `@` at a word start opens a combobox under the field (candidates are
* fetched lazily on that first keystroke); ↑/↓ navigate, Enter/Tab accept,
* Esc dismisses (without closing the surrounding popover). Accepting inserts
* `@slug ` into the plain-text body and reports the slug via `onMention` —
* no contentEditable, no rich text. Hand-typed `@slug` text is legal too; it
* just isn't recorded in the message's `mentions` array.
*/
interface ActiveToken {
start: number; // index of the "@"
query: string;
}
function activeToken(value: string, caret: number): ActiveToken | null {
const at = value.lastIndexOf("@", caret - 1);
if (at < 0) return null;
// The @ must start a word (start-of-text or after whitespace).
if (at > 0 && !/\s/.test(value.charAt(at - 1))) return null;
const between = value.slice(at + 1, caret);
if (/[\s@]/.test(between)) return null;
return { start: at, query: between };
}
const MAX_ITEMS = 6;
export function MentionInput({
value,
onChange,
onMention,
onSubmit,
getCandidates,
multiline = false,
placeholder,
autoFocus = false,
className,
testId,
}: {
value: string;
onChange: (value: string) => void;
/** An autocomplete completion was accepted. */
onMention: (slug: string) => void;
/** Enter (without Shift, combobox closed). */
onSubmit?: () => void;
/** Lazy candidate source — first `@` keystroke triggers it, then cached. */
getCandidates: () => Promise<Collaborator[]>;
multiline?: boolean;
placeholder?: string;
autoFocus?: boolean;
className?: string;
testId?: string;
}) {
const [open, setOpen] = React.useState<{
token: ActiveToken;
items: Collaborator[];
sel: number;
} | null>(null);
const inputRef = React.useRef<HTMLTextAreaElement | HTMLInputElement | null>(null);
const candidatesRef = React.useRef<Collaborator[] | null>(null);
const refresh = (val: string, caret: number) => {
const token = activeToken(val, caret);
if (!token) {
setOpen(null);
return;
}
const apply = (cands: Collaborator[]) => {
const items = filterCandidates(cands, token.query).slice(0, MAX_ITEMS);
setOpen(items.length ? { token, items, sel: 0 } : null);
};
if (candidatesRef.current) {
apply(candidatesRef.current);
} else {
void getCandidates().then((c) => {
candidatesRef.current = c;
// Re-derive against the CURRENT field state — the user kept typing
// while the roster loaded.
const el = inputRef.current;
if (!el) return;
const now = activeToken(el.value, el.selectionStart ?? el.value.length);
if (now) {
const items = filterCandidates(c, now.query).slice(0, MAX_ITEMS);
setOpen(items.length ? { token: now, items, sel: 0 } : null);
}
});
}
};
const accept = (c: Collaborator) => {
if (!open) return;
const el = inputRef.current;
const caret = el?.selectionStart ?? value.length;
const inserted = `@${c.slug} `;
const next = value.slice(0, open.token.start) + inserted + value.slice(caret);
onMention(c.slug);
onChange(next);
setOpen(null);
const pos = open.token.start + inserted.length;
requestAnimationFrame(() => {
el?.focus();
el?.setSelectionRange(pos, pos);
});
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (open) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const d = e.key === "ArrowDown" ? 1 : -1;
setOpen({ ...open, sel: (open.sel + d + open.items.length) % open.items.length });
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
const item = open.items[open.sel];
if (item) accept(item);
return;
}
if (e.key === "Escape") {
// Only dismiss the combobox — not the popover/mode Esc handlers.
e.preventDefault();
e.stopPropagation();
setOpen(null);
return;
}
}
if (e.key === "Enter" && !e.shiftKey && onSubmit) {
e.preventDefault();
onSubmit();
}
};
const onInput = (e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
onChange(e.target.value);
refresh(e.target.value, e.target.selectionStart ?? e.target.value.length);
};
const shared = {
value,
onChange: onInput,
onKeyDown,
placeholder,
autoFocus,
className,
"data-testid": testId,
};
return (
<div className="relative w-full">
{multiline ? (
<textarea
{...shared}
ref={(el) => {
inputRef.current = el;
}}
/>
) : (
<input
{...shared}
ref={(el) => {
inputRef.current = el;
}}
/>
)}
{open && (
<div
data-testid="mention-combobox"
className="absolute left-0 right-0 top-full z-[80] mt-1 overflow-hidden rounded-md bg-white shadow-xl ring-1 ring-black/10 dark:bg-neutral-900 dark:ring-white/15"
>
{open.items.map((c, i) => (
<button
key={c.slug}
data-testid="mention-option"
data-slug={c.slug}
// pointerdown, not click: the field keeps focus (no blur race).
onPointerDown={(e) => {
e.preventDefault();
accept(c);
}}
onPointerEnter={() => setOpen((o) => (o ? { ...o, sel: i } : o))}
className={`flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs text-neutral-900 dark:text-white ${
i === open.sel ? "bg-sky-500/20 dark:bg-sky-600/40" : "hover:bg-black/5 dark:hover:bg-white/10"
}`}
>
{c.image ? (
<img src={c.image} alt="" className="h-4 w-4 shrink-0 rounded-full" />
) : (
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-black/10 text-[9px] uppercase dark:bg-white/15">
{(c.name || c.slug).slice(0, 1)}
</span>
)}
<span className="truncate">{c.name}</span>
<span className="ml-auto truncate text-[10px] text-neutral-400 dark:text-white/40">@{c.slug}</span>
</button>
))}
</div>
)}
</div>
);
}

View file

@ -1,5 +1,6 @@
import * as React from "react";
import { Users } from "lucide-react";
import { useDraggablePanel } from "@/components/useDraggablePanel";
/**
* Unified overlay menu (collab-presence 0010): the single circular icon that
@ -32,8 +33,9 @@ import { Users } from "lucide-react";
* 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";
"text-neutral-800 transition-colors hover:bg-black/5 " +
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-black/30 " +
"dark:text-white/90 dark:hover:bg-white/10 dark:focus-visible:ring-white/40";
/** A labelled group. `label` is omitted for the first/unnamed group. */
export function OverlayMenuSection({
@ -44,9 +46,9 @@ export function OverlayMenuSection({
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">
<div className="flex w-full flex-col gap-1 border-t border-black/10 pt-2 first:border-t-0 first:pt-0 dark:border-white/10">
{label && (
<div className="px-2 text-[10px] font-semibold uppercase tracking-wide text-white/40">
<div className="px-2 text-[10px] font-semibold uppercase tracking-wide text-neutral-400 dark:text-white/40">
{label}
</div>
)}
@ -57,50 +59,32 @@ export function OverlayMenuSection({
const POS_KEY = "pcbjam:overlay-menu-pos";
const FAB_SIZE = 36;
const DRAG_THRESHOLD_PX = 4;
type Pos = { x: number; y: number };
function loadPos(): Pos | null {
try {
const raw = localStorage.getItem(POS_KEY);
if (!raw) return null;
const p = JSON.parse(raw) as Pos;
return typeof p.x === "number" && typeof p.y === "number" ? p : null;
} catch {
return null;
}
}
function clamp(p: Pos): Pos {
return {
x: Math.min(Math.max(p.x, 4), window.innerWidth - FAB_SIZE - 4),
y: Math.min(Math.max(p.y, 4), window.innerHeight - FAB_SIZE - 4),
};
}
export function OverlayMenu({
badge,
unread = 0,
unreadMention = false,
children,
}: {
/** Peer count shown on the FAB (0 hides the badge). */
badge: number;
/** Unread comment threads (comments-ux 0001 C) — amber FAB badge, bottom
* corner; rose when one of them mentions the current user. 0 hides it. */
unread?: number;
unreadMention?: boolean;
/** Panel sections, rendered top-to-bottom. Falsy children collapse. */
children: React.ReactNode;
}) {
const [open, setOpen] = React.useState(false);
const [pos, setPos] = React.useState<Pos | null>(() =>
typeof window === "undefined" ? null : loadPos(),
);
const rootRef = React.useRef<HTMLDivElement | null>(null);
const dragRef = React.useRef<{
pointerId: number;
startX: number;
startY: number;
fabX: number;
fabY: number;
moved: boolean;
} | null>(null);
// Drag/clamp/persist behavior shared with the comments panel (0001 B) —
// including the always-onscreen restore guarantee.
const drag = useDraggablePanel({
storageKey: POS_KEY,
handleWidth: FAB_SIZE,
handleHeight: FAB_SIZE,
});
const pos = drag.pos;
// Esc closes (bubble phase, same etiquette as the comment layer — wx also
// sees the key, matching how every other overlay treats Escape).
@ -125,57 +109,16 @@ export function OverlayMenu({
}, [open]);
const onFabPointerDown = (e: React.PointerEvent) => {
(e.target as HTMLElement).setPointerCapture(e.pointerId);
const rect = rootRef.current!.getBoundingClientRect();
dragRef.current = {
pointerId: e.pointerId,
startX: e.clientX,
startY: e.clientY,
fabX: rect.x,
fabY: rect.y,
moved: false,
};
drag.onPointerDown(e, rootRef.current!.getBoundingClientRect());
};
const onFabPointerMove = (e: React.PointerEvent) => {
const d = dragRef.current;
if (!d) return;
if (!d.moved) {
if (
Math.hypot(e.clientX - d.startX, e.clientY - d.startY) <
DRAG_THRESHOLD_PX
) {
return;
}
d.moved = true;
setOpen(false); // dragging repositions; the click that follows reopens
}
setPos(
clamp({
x: d.fabX + (e.clientX - d.startX),
y: d.fabY + (e.clientY - d.startY),
}),
);
// Dragging repositions; the click that follows reopens.
if (drag.onPointerMove(e)) setOpen(false);
};
const onFabPointerUp = () => {
const d = dragRef.current;
dragRef.current = null;
if (!d) return;
if (d.moved) {
setPos((p) => {
if (p) {
try {
localStorage.setItem(POS_KEY, JSON.stringify(p));
} catch {
/* private mode — position just doesn't persist */
}
}
return p;
});
} else {
setOpen((o) => !o);
}
if (!drag.onPointerUp()) setOpen((o) => !o);
};
// Default anchor: top-right (the old row's home). After a drag, explicit px.
@ -196,10 +139,11 @@ export function OverlayMenu({
onPointerDown={onFabPointerDown}
onPointerMove={onFabPointerMove}
onPointerUp={onFabPointerUp}
className={`relative flex h-9 w-9 items-center justify-center rounded-full text-white shadow-lg ring-1 ring-inset transition-colors ${
className={`relative flex h-9 w-9 items-center justify-center rounded-full 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"
? "bg-sky-600 text-white ring-sky-300/40"
: "bg-white/90 text-neutral-700 ring-black/15 backdrop-blur-sm hover:bg-white " +
"dark:bg-neutral-950/80 dark:text-white dark:ring-white/15 dark:hover:bg-neutral-900/90"
}`}
style={{ touchAction: "none" }}
>
@ -212,21 +156,32 @@ export function OverlayMenu({
{badge}
</span>
)}
{unread > 0 && (
<span
data-testid="overlay-menu-unread-badge"
title={unreadMention ? "Unread comments — you were mentioned" : "Unread comments"}
className={`absolute -bottom-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-semibold text-white ${
unreadMention ? "bg-rose-500" : "bg-amber-500"
}`}
>
{unread}
</span>
)}
</button>
{open && (
<div
data-testid="overlay-menu-panel"
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 ${
className={`absolute flex w-72 flex-col gap-2 rounded-xl bg-white/95 p-2 shadow-2xl ring-1 ring-inset ring-black/10 backdrop-blur-sm dark:bg-neutral-950/90 dark:ring-white/15 ${
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">
<span className="text-[11px] font-semibold tracking-wide text-neutral-600 dark:text-white/70">
Session
</span>
{badge > 0 && (
<span className="text-[10px] text-white/40">
<span className="text-[10px] text-neutral-400 dark:text-white/40">
{badge} {badge === 1 ? "other" : "others"} here
</span>
)}

View file

@ -91,7 +91,7 @@ export function PresenceRoster({
: `${p.user.name} — on ${elsewhere}`
}
className={`${overlayRowClass} ${
followed ? "bg-white/10" : ""
followed ? "bg-black/10 dark:bg-white/10" : ""
} ${here ? "" : "cursor-default opacity-50 hover:bg-transparent"}`}
>
<span
@ -101,17 +101,17 @@ export function PresenceRoster({
/>
<span className="truncate">{p.user.name}</span>
{!here && (
<span className="ml-auto shrink-0 truncate text-[10px] text-white/40">
<span className="ml-auto shrink-0 truncate text-[10px] text-neutral-400 dark: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">
<span className="ml-auto flex shrink-0 items-center gap-1 text-[10px] font-medium text-neutral-600 dark:text-white/70">
<Eye size={12} /> Stop
</span>
)}
{here && !followed && followable(p) && (
<span className="ml-auto shrink-0 text-[10px] text-white/35">
<span className="ml-auto shrink-0 text-[10px] text-neutral-400 dark:text-white/35">
Follow
</span>
)}
@ -119,7 +119,7 @@ export function PresenceRoster({
);
})}
{peers.length > MAX_ROWS && (
<span className="px-2 py-1 text-[10px] text-white/40">
<span className="px-2 py-1 text-[10px] text-neutral-400 dark:text-white/40">
+{peers.length - MAX_ROWS} more
</span>
)}

View file

@ -1,4 +1,5 @@
import * as React from "react";
import { setPinRadiusPx } from "@/wasm/collab/pin-geometry";
import { PRESENCE_COLORS } from "@pcbjam/shared";
import { Palette, X } from "lucide-react";
@ -57,11 +58,13 @@ const DEFAULT_STYLE = {
cursorLabelChip: true,
fixedColor: "",
palette: [] as string[],
pinShape: 1,
pinRadiusPx: 9,
pinRingPx: 3,
pinRingAlpha: 1,
pinFillAlpha: 1,
pinRingPx: 4,
pinRingAlpha: 0.9,
pinFillAlpha: 0.9,
pinResolvedAlpha: 0.3,
pinUnreadRingColor: "#ffb020",
};
type Style = typeof DEFAULT_STYLE;
@ -98,6 +101,7 @@ const SEL_SHAPES = [
"exact outline (pcb)",
];
const CURSOR_SHAPES = ["cross", "pointer", "circle + dot"];
const PIN_SHAPES = ["circle dot", "bubble (sharp corner)"];
const VPOS = ["top", "bottom"];
const HPOS = ["start", "end", "center"];
@ -118,6 +122,10 @@ export function PresenceTuner({ mod, tool }: { mod: TunerModule; tool: string })
// Push on mount (restores a stored style after reload) + on every change.
React.useEffect(() => {
mod.kicadCollabSetStyle(JSON.stringify(style));
// The DOM half of the comment pins (hit target / highlight / popover
// offset in CommentLayer) mirrors the GAL radius — keep it in step with
// the live re-style, or the highlight drifts off the drawn bubble.
setPinRadiusPx(style.pinRadiusPx);
try {
localStorage.setItem(storeKey(tool), JSON.stringify(style));
} catch {
@ -298,9 +306,13 @@ export function PresenceTuner({ mod, tool }: { mod: TunerModule; tool: string })
<ColorsSection style={style} set={set} />
<Section title="Comment pins">
<Select label="shape" value={style.pinShape} options={PIN_SHAPES} onChange={(v) => set("pinShape", v)} />
<Range label="radius px" v={style.pinRadiusPx} min={3} max={16} step={0.5} onChange={(v) => set("pinRadiusPx", v)} />
<Range label="ring px" v={style.pinRingPx} min={0} max={5} step={0.25} onChange={(v) => set("pinRingPx", v)} />
<Range label="ring α" v={style.pinRingAlpha} min={0} max={1} step={0.05} onChange={(v) => set("pinRingAlpha", v)} />
{/* Bubble radius mirrors into the DOM hit target via pin-geometry.ts
— tuner drift is dev-only, ship values must land in BOTH
collab_presence_style.h and pin-geometry.ts. */}
<Range label="fill α" v={style.pinFillAlpha} min={0.2} max={1} step={0.05} onChange={(v) => set("pinFillAlpha", v)} />
</Section>
</div>

View file

@ -50,7 +50,7 @@ export function SourceChip({
return (
<span
title={descriptor.description}
className={`inline-flex items-center gap-2 text-xs font-medium text-white/90 ${className}`}
className={`inline-flex items-center gap-2 text-xs font-medium text-neutral-800 dark:text-white/90 ${className}`}
>
<Icon size={14} className={`shrink-0 ${MUTED_TONES[descriptor.kind]}`} />
{descriptor.label}

View file

@ -0,0 +1,23 @@
import { Moon, Sun } from "lucide-react";
import { setTheme, useThemeValue } from "@/lib/theme";
import { Button } from "@/components/ui/button";
/** Sun/moon theme toggle (comments-ux 0002): flips `<html>.dark` + storage;
* theme.ts subscribers (incl. the F4 canvas bridge) follow. */
export function ThemeToggle({ className }: { className?: string }) {
const theme = useThemeValue();
const next = theme === "dark" ? "light" : "dark";
return (
<Button
variant="ghost"
size="icon"
data-testid="theme-toggle"
title={`Switch to ${next} mode`}
aria-label={`Switch to ${next} mode`}
className={className}
onClick={() => setTheme(next)}
>
{theme === "dark" ? <Sun size={16} /> : <Moon size={16} />}
</Button>
);
}

View file

@ -15,7 +15,7 @@ import {
type KicadDoc,
type Tool,
} from "@pcbjam/shared";
import { ChevronDown, ChevronUp, Eye, EyeOff, Loader2, PanelsTopLeft } from "lucide-react";
import { ChevronDown, ChevronUp, Eye, EyeOff, Loader2, Moon, PanelsTopLeft, Sun } from "lucide-react";
import {
API_BASE_URL,
APP_URL,
@ -31,6 +31,7 @@ import {
import { defaultFileName, newFileTemplate, withExtension } from "@/lib/new-file";
import { redirectTargetFor } from "@/lib/redirect";
import { loadSessionIdentity } from "@/lib/session-identity";
import { setTheme, useThemeValue } from "@/lib/theme";
import { bootKicadTool } from "@/wasm/boot";
import { resolveWasmBase } from "@/wasm/wasm-assets";
import {
@ -905,6 +906,21 @@ export function WasmTool({
const [commentsSlot, setCommentsSlot] = React.useState<HTMLDivElement | null>(null);
const [viewportState, setViewportState] = React.useState<ViewportState | null>(null);
const commentsRef = React.useRef<CommentsController | null>(null);
// Unread-comments rollup for the FAB badge (comments-ux 0001 C).
const [commentsUnread, setCommentsUnread] = React.useState({ threads: 0, mentioned: false });
const onCommentsUnread = React.useCallback(
(threads: number, mentioned: boolean) => setCommentsUnread({ threads, mentioned }),
[],
);
// Live canvas theme (comments-ux 0002 F4): shell toggles drive the GAL color
// theme through the bridge when the loaded wasm exposes it; older builds
// just keep their boot-seeded theme.
const theme = useThemeValue();
React.useEffect(() => {
if (!ready) return;
const mod = (window as { Module?: { kicadSetColorTheme?: (name: string) => void } }).Module;
mod?.kicadSetColorTheme?.(theme === "dark" ? "pcbjam-dark" : "_builtin_default");
}, [theme, ready]);
// Dev-time presence style tuner (VITE_PRESENCE_TUNER=1) — set once the wasm
// exposes the style bridge, mounts the floating panel.
const [tunerMod, setTunerMod] = React.useState<TunerModule | null>(null);
@ -1688,7 +1704,11 @@ export function WasmTool({
comments (portal slot filled by CommentLayer), chrome toggle. It is
the one control that stays up in canvas-only (chrome-hidden) mode. */}
{ready && (
<OverlayMenu badge={peers.length}>
<OverlayMenu
badge={peers.length}
unread={commentsUnread.threads}
unreadMention={commentsUnread.mentioned}
>
{/* 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. */}
@ -1722,9 +1742,9 @@ export function WasmTool({
data-testid="view-only-pill"
className={`${overlayRowClass} cursor-default`}
>
<EyeOff size={14} className="shrink-0 text-white/50" />
<EyeOff size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
<span>View only</span>
<span className="ml-auto text-[10px] text-white/40">
<span className="ml-auto text-[10px] text-neutral-400 dark:text-white/40">
read-only
</span>
</div>
@ -1742,8 +1762,8 @@ export function WasmTool({
</OverlayMenuSection>
)}
{setChromeFn !== null && !readOnly && (
<OverlayMenuSection label="View">
<OverlayMenuSection label="View">
{setChromeFn !== null && !readOnly && (
<button
data-testid="chrome-toggle"
aria-pressed={chromeHidden}
@ -1752,17 +1772,34 @@ export function WasmTool({
onClick={() => toggleChromeHidden()}
>
{chromeHidden ? (
<PanelsTopLeft size={14} className="shrink-0 text-white/50" />
<PanelsTopLeft size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
) : (
<EyeOff size={14} className="shrink-0 text-white/50" />
<EyeOff size={14} className="shrink-0 text-neutral-400 dark: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">
<kbd className="ml-auto rounded bg-black/10 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500 dark:bg-white/10 dark:text-white/50">
{CHROME_HOTKEY_LABEL}
</kbd>
</button>
</OverlayMenuSection>
)}
)}
{/* Light/dark toggle (comments-ux 0002): flips the shell theme;
the F4 effect above re-themes the GAL canvas through the
bridge. Available to viewers too — theming isn't editing. */}
<button
data-testid="overlay-theme-toggle"
aria-pressed={theme === "dark"}
className={overlayRowClass}
title={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
>
{theme === "dark" ? (
<Sun size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
) : (
<Moon size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
)}
<span>{theme === "dark" ? "Light mode" : "Dark mode"}</span>
</button>
</OverlayMenuSection>
</OverlayMenu>
)}
@ -1775,6 +1812,11 @@ export function WasmTool({
viewport={viewportState}
currentUser={presenceUser().id}
menuSlot={commentsSlot}
onUnreadChange={onCommentsUnread}
mentionPeers={peers.map((p) => ({
slug: p.user.id,
name: p.user.name || p.user.id,
}))}
/>
)}

View file

@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { restorePosition } from "./useDraggablePanel";
const VP = { w: 1200, h: 800 };
describe("restorePosition (comments-ux 0001 B always-onscreen guarantee)", () => {
it("restores fraction entries scaled to the current viewport", () => {
const p = restorePosition(JSON.stringify({ fx: 0.5, fy: 0.5 }), VP, 36, 36, 4);
expect(p).toEqual({ x: 4 + 0.5 * (1200 - 36 - 8), y: 4 + 0.5 * (800 - 36 - 8) });
});
it("clamps out-of-range fractions onscreen instead of discarding", () => {
const p = restorePosition(JSON.stringify({ fx: 7, fy: -3 }), VP, 36, 36, 4);
expect(p).toEqual({ x: 4 + (1200 - 36 - 8), y: 4 });
});
it("keeps a legacy px entry that is still fully visible", () => {
expect(restorePosition(JSON.stringify({ x: 100, y: 100 }), VP, 36, 36)).toEqual({
x: 100,
y: 100,
});
});
it("discards a legacy px entry outside the current viewport (secondary display gone)", () => {
expect(restorePosition(JSON.stringify({ x: 2500, y: 100 }), VP, 36, 36)).toBeNull();
expect(restorePosition(JSON.stringify({ x: 100, y: -50 }), VP, 36, 36)).toBeNull();
// Partially visible but the handle would stick out — also reset.
expect(restorePosition(JSON.stringify({ x: 1190, y: 100 }), VP, 36, 36)).toBeNull();
});
it("ignores garbage", () => {
expect(restorePosition(null, VP, 36, 36)).toBeNull();
expect(restorePosition("not json", VP, 36, 36)).toBeNull();
expect(restorePosition(JSON.stringify({ nope: 1 }), VP, 36, 36)).toBeNull();
});
});

View file

@ -0,0 +1,172 @@
import * as React from "react";
/**
* Shared draggable-overlay behavior (comments-ux 0001 B), extracted from the
* 0010 OverlayMenu FAB and reused by the floating comments panel: pointer-
* capture dragging with a click-vs-drag threshold, viewport clamping, and
* per-panel position persistence with an ALWAYS-ONSCREEN restore guarantee.
*
* Persistence stores FRACTIONS of the viewport's free space, not absolute px:
* an ordinary window resize keeps the element roughly where the user left it,
* and a restore can never land offscreen (fractions clamp to [0,1]). Legacy
* absolute-px entries (pre-fraction format, possibly from a bigger or
* secondary display) are honored only if the drag handle would still be fully
* visible — otherwise they are discarded and the caller's default CSS anchor
* applies. Live window resizes re-clamp mid-session, so the handle can never
* be stranded outside the viewport.
*/
type Pos = { x: number; y: number };
const DRAG_THRESHOLD_PX = 4;
function freeSpace(handleW: number, handleH: number, margin: number) {
return {
w: Math.max(0, window.innerWidth - handleW - 2 * margin),
h: Math.max(0, window.innerHeight - handleH - 2 * margin),
};
}
/**
* Resolve a stored position against the CURRENT viewport (pure — exported for
* tests). Fraction entries always restore onscreen (clamped to [0,1] of the
* free space); legacy absolute-px entries are honored only if the handle
* would still be fully visible, else discarded (null = default anchor).
*/
export function restorePosition(
raw: string | null,
viewport: { w: number; h: number },
handleW: number,
handleH: number,
margin = 4,
): Pos | null {
if (!raw) return null;
let v: Partial<{ fx: number; fy: number; x: number; y: number }>;
try {
v = JSON.parse(raw) as typeof v;
} catch {
return null;
}
if (typeof v.fx === "number" && typeof v.fy === "number") {
const w = Math.max(0, viewport.w - handleW - 2 * margin);
const h = Math.max(0, viewport.h - handleH - 2 * margin);
return {
x: margin + Math.min(Math.max(v.fx, 0), 1) * w,
y: margin + Math.min(Math.max(v.fy, 0), 1) * h,
};
}
if (typeof v.x === "number" && typeof v.y === "number") {
const onscreen =
v.x >= 0 && v.y >= 0 && v.x + handleW <= viewport.w && v.y + handleH <= viewport.h;
return onscreen ? { x: v.x, y: v.y } : null;
}
return null;
}
export function useDraggablePanel(opts: {
storageKey: string;
/** Clamp box of the DRAG HANDLE (FAB / header bar) — what stays onscreen. */
handleWidth: number;
handleHeight: number;
margin?: number;
}) {
const { storageKey, handleWidth, handleHeight, margin = 4 } = opts;
const clamp = React.useCallback(
(p: Pos): Pos => {
const f = freeSpace(handleWidth, handleHeight, margin);
return {
x: Math.min(Math.max(p.x, margin), margin + f.w),
y: Math.min(Math.max(p.y, margin), margin + f.h),
};
},
[handleWidth, handleHeight, margin],
);
const [pos, setPos] = React.useState<Pos | null>(() => {
if (typeof window === "undefined") return null;
try {
return restorePosition(
localStorage.getItem(storageKey),
{ w: window.innerWidth, h: window.innerHeight },
handleWidth,
handleHeight,
margin,
);
} catch {
return null;
}
});
const persist = React.useCallback(
(p: Pos) => {
try {
const f = freeSpace(handleWidth, handleHeight, margin);
localStorage.setItem(
storageKey,
JSON.stringify({
fx: f.w > 0 ? (p.x - margin) / f.w : 0,
fy: f.h > 0 ? (p.y - margin) / f.h : 0,
}),
);
} catch {
/* private mode — position just doesn't persist */
}
},
[storageKey, handleWidth, handleHeight, margin],
);
// A live resize must not strand the handle outside the shrunken viewport.
React.useEffect(() => {
const onResize = () => setPos((p) => (p ? clamp(p) : p));
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [clamp]);
const dragRef = React.useRef<{
startX: number;
startY: number;
baseX: number;
baseY: number;
moved: boolean;
} | null>(null);
/** `handleRect` = the dragged element's current rect (measured by caller —
* the handle may not be the element being repositioned). */
const onPointerDown = (e: React.PointerEvent, handleRect: { x: number; y: number }) => {
(e.target as HTMLElement).setPointerCapture(e.pointerId);
dragRef.current = {
startX: e.clientX,
startY: e.clientY,
baseX: handleRect.x,
baseY: handleRect.y,
moved: false,
};
};
/** Returns true the moment the gesture crosses the drag threshold (callers
* close popups / mark "this is a drag, not a click" on that edge). */
const onPointerMove = (e: React.PointerEvent): boolean => {
const d = dragRef.current;
if (!d) return false;
let crossed = false;
if (!d.moved) {
if (Math.hypot(e.clientX - d.startX, e.clientY - d.startY) < DRAG_THRESHOLD_PX) return false;
d.moved = true;
crossed = true;
}
setPos(clamp({ x: d.baseX + (e.clientX - d.startX), y: d.baseY + (e.clientY - d.startY) }));
return crossed;
};
/** Ends the gesture; returns whether it was a drag (persisted) or a click. */
const onPointerUp = (): boolean => {
const d = dragRef.current;
dragRef.current = null;
if (!d) return false;
if (d.moved) setPos((p) => (p ? (persist(p), p) : p));
return d.moved;
};
return { pos, onPointerDown, onPointerMove, onPointerUp };
}

View file

@ -0,0 +1,48 @@
/**
* Quick-react row (comments-ux 0001 D): the user's most-used reaction emoji,
* seeded with a sensible default set. Usage counts persist per browser; the
* fixed seed is a starting point, not a limit — anything picked from the full
* emoji-mart picker joins the rotation.
*/
const KEY = "pcbjam:comment-quick-emoji";
const SEED = ["👍", "❤️", "😄", "🎉", "👀", "✅"];
export const QUICK_ROW_SIZE = 6;
function counts(): Record<string, number> {
try {
const raw = localStorage.getItem(KEY);
const v = raw ? (JSON.parse(raw) as unknown) : null;
if (v && typeof v === "object" && !Array.isArray(v)) {
return Object.fromEntries(
Object.entries(v as Record<string, unknown>).filter(([, n]) => typeof n === "number"),
) as Record<string, number>;
}
} catch {
/* private mode / corrupt entry — fall through to seed */
}
return {};
}
/** Top emoji by use, seed-filled up to QUICK_ROW_SIZE. */
export function quickEmojis(): string[] {
const used = Object.entries(counts())
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([e]) => e);
const out = [...used];
for (const s of SEED) {
if (out.length >= QUICK_ROW_SIZE) break;
if (!out.includes(s)) out.push(s);
}
return out.slice(0, QUICK_ROW_SIZE);
}
export function noteEmojiUsed(emoji: string): void {
try {
const c = counts();
c[emoji] = (c[emoji] ?? 0) + 1;
localStorage.setItem(KEY, JSON.stringify(c));
} catch {
/* private mode — quick row just stays the seed */
}
}

View file

@ -0,0 +1,62 @@
import type { Collaborator } from "@pcbjam/shared";
import { client } from "./contract-client";
import { currentScope } from "./config";
/**
* Mention-autocomplete roster (comments-ux 0001 E): the scope's collaborators
* from the backend's display-only `listCollaborators` route — fetched lazily
* on the first `@` keystroke, cached for the session. Backends without a
* members model (example/demo/static/@local) 404 or reject → null, and the
* caller falls back to presence peers ∪ existing comment authors.
*/
let inflight: Promise<Collaborator[] | null> | null = null;
let resolved: Collaborator[] | null = null;
export function collaborators(): Promise<Collaborator[] | null> {
inflight ??= client
.listCollaborators({ params: { scope: currentScope() } })
.then((r) => {
resolved = r.status === 200 ? r.body : null;
return resolved;
})
.catch(() => null);
return inflight;
}
/** The already-fetched roster, for synchronous render paths (mention
* highlighting of hand-typed `@slug`s); null before the first fetch lands. */
export function cachedCollaborators(): Collaborator[] | null {
return resolved;
}
/** Test hook: forget the session cache. */
export function resetCollaboratorsCache(): void {
inflight = null;
resolved = null;
}
/** Dedupe/merge candidate lists (first occurrence of a slug wins). */
export function mergeCandidates(...lists: Collaborator[][]): Collaborator[] {
const seen = new Set<string>();
const out: Collaborator[] = [];
for (const list of lists) {
for (const c of list) {
if (!c.slug || seen.has(c.slug)) continue;
seen.add(c.slug);
out.push(c);
}
}
return out;
}
/** Filter + rank candidates for a typed `@` query (prefix > substring). */
export function filterCandidates(candidates: Collaborator[], query: string): Collaborator[] {
const q = query.toLowerCase();
if (!q) return candidates;
const starts = (c: Collaborator) =>
c.slug.toLowerCase().startsWith(q) || c.name.toLowerCase().startsWith(q);
const contains = (c: Collaborator) =>
c.slug.toLowerCase().includes(q) || c.name.toLowerCase().includes(q);
return [...candidates.filter(starts), ...candidates.filter((c) => !starts(c) && contains(c))];
}

View file

@ -0,0 +1,68 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { currentTheme, initTheme, setTheme } from "./theme";
/** Stub the browser surface theme.ts touches (node test environment). */
function setup(opts: { search?: string; stored?: string; osDark?: boolean }) {
const store = new Map<string, string>();
if (opts.stored) store.set("pcbjam-theme", opts.stored);
const classes = new Set<string>();
vi.stubGlobal("localStorage", {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
});
vi.stubGlobal("window", {
location: { search: opts.search ?? "" },
matchMedia: () => ({ matches: opts.osDark ?? false }),
});
vi.stubGlobal("document", {
documentElement: {
classList: {
toggle: (name: string, on: boolean) => {
if (on) classes.add(name);
else classes.delete(name);
},
contains: (name: string) => classes.has(name),
},
},
});
return { store, classes };
}
afterEach(() => vi.unstubAllGlobals());
describe("theme resolution (comments-ux 0002): param > storage > OS", () => {
it("uses the OS preference when nothing else is set", () => {
setup({ osDark: true });
expect(currentTheme()).toBe("dark");
setup({ osDark: false });
expect(currentTheme()).toBe("light");
});
it("stored choice beats the OS preference", () => {
setup({ stored: "light", osDark: true });
expect(currentTheme()).toBe("light");
});
it("?theme= beats storage and persists on init (platform hand-off)", () => {
const { store, classes } = setup({ search: "?theme=dark", stored: "light" });
expect(currentTheme()).toBe("dark");
initTheme();
expect(store.get("pcbjam-theme")).toBe("dark");
expect(classes.has("dark")).toBe(true);
});
it("ignores an invalid ?theme= value", () => {
setup({ search: "?theme=blue", stored: "dark" });
expect(currentTheme()).toBe("dark");
});
it("setTheme persists and applies <html>.dark", () => {
const { store, classes } = setup({});
setTheme("dark");
expect(store.get("pcbjam-theme")).toBe("dark");
expect(classes.has("dark")).toBe(true);
setTheme("light");
expect(classes.has("dark")).toBe(false);
});
});

View file

@ -0,0 +1,81 @@
import * as React from "react";
/**
* Standalone light/dark theme (comments-ux 0002 F1), mirroring the platform's
* module — same storage key, same `<html>.dark` single source of truth — plus
* the `?theme=` hand-off: the platform appends the param to editor links (the
* only cross-origin channel; no iframe/postMessage exists), the param wins and
* re-persists on every navigation, then storage, then the OS preference.
* index.html applies the same rule inline before first paint — keep the key
* and precedence in sync with it.
*
* The KiCad canvas follows separately: boot seeding picks the color theme
* (wasm/boot.ts), and the F4 bridge (`kicadSetColorTheme`) switches it live
* when the loaded wasm exposes it (theme.ts notifies subscribers).
*/
const STORAGE_KEY = "pcbjam-theme";
export type Theme = "light" | "dark";
function themeParam(): Theme | null {
try {
const v = new URLSearchParams(window.location.search).get("theme");
return v === "light" || v === "dark" ? v : null;
} catch {
return null;
}
}
/** The theme in effect: `?theme=` > stored choice > OS preference. */
export function currentTheme(): Theme {
const param = themeParam();
if (param) return param;
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "light" || stored === "dark") return stored;
} catch {
/* storage disabled — fall through to the OS preference */
}
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
/** Reactive theme: tracks <html>.dark itself (the single source of truth all
* appliers — toggles, the no-flash boot script — write to). */
export function useThemeValue(): Theme {
const subscribe = React.useCallback((onChange: () => void) => {
const observer = new MutationObserver(onChange);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
return React.useSyncExternalStore(subscribe, () =>
document.documentElement.classList.contains("dark") ? "dark" : "light",
);
}
/** Persist and apply (tailwind `darkMode: ["class"]` keys off <html>.dark). */
export function setTheme(theme: Theme): void {
try {
localStorage.setItem(STORAGE_KEY, theme);
} catch {
/* storage disabled — still apply for this page view */
}
document.documentElement.classList.toggle("dark", theme === "dark");
}
/** Apply the resolved theme at app start and persist a `?theme=` hand-off so
* it survives in-app navigation (the param stays in the URL regardless). */
export function initTheme(): void {
const t = currentTheme();
if (themeParam()) {
try {
localStorage.setItem(STORAGE_KEY, t);
} catch {
/* private mode */
}
}
document.documentElement.classList.toggle("dark", t === "dark");
}

View file

@ -3,11 +3,17 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import { initAnalytics } from "./lib/analytics";
import { initTheme } from "./lib/theme";
import "./index.css";
// Privacy-friendly analytics (Plausible), only when VITE_PLAUSIBLE_SRC is set.
initAnalytics();
// Re-assert the resolved theme (index.html applied it pre-paint; this keeps
// SPA state consistent if that inline script is ever bypassed, e.g. tests
// mounting the app directly).
initTheme();
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});

View file

@ -2,6 +2,7 @@ import * as React from "react";
import { useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { type Tool, libPath, projectPath } from "@pcbjam/shared";
import { ThemeToggle } from "@/components/ThemeToggle";
import { FolderOpen, Library, Loader2, Package } from "lucide-react";
import { useLibs } from "@/lib/api";
import {
@ -230,6 +231,7 @@ export function HomePage() {
<span className="rounded-full border border-amber-500/50 bg-amber-500/10 px-2.5 py-0.5 text-xs font-medium text-amber-600 dark:text-amber-400">
Early-access alpha
</span>
<ThemeToggle className="ml-auto" />
</h1>
<p className="mb-8 text-sm text-muted-foreground">
{staticMode

View file

@ -23,6 +23,8 @@ import {
} from "./libs/source";
import { libUri, PCBJAM_LIB_MOUNT } from "./libs/uri";
import { installTouchGestures } from "./touch-gestures";
import { currentTheme } from "@/lib/theme";
import pcbjamDarkTheme from "./themes/pcbjam-dark.json";
/** The default user lib boot ensures exists, so there's a writable save target. */
const DEFAULT_USER_LIB_NAME = "My Symbols";
@ -431,6 +433,27 @@ async function doBoot(opts: BootOptions): Promise<void> {
`${KICAD_CONFIG_DIR}/design-block-lib-table`,
"(design_block_lib_table\n (version 7)\n)\n",
);
// Theme (comments-ux 0002 F2): the dark color theme is seeded ALWAYS (so
// the F4 live switch is a settings-only operation); the per-app settings
// pick the boot theme. Only the schematic set is overridden — KiCad's
// board canvas is dark by default, and `_builtin_default` keeps it.
FS.mkdirTree(`${KICAD_CONFIG_DIR}/colors`);
writeIfAbsent(
`${KICAD_CONFIG_DIR}/colors/pcbjam-dark.json`,
JSON.stringify(pcbjamDarkTheme, null, 2),
);
if (currentTheme() === "dark") {
const appearance = JSON.stringify(
{ appearance: { color_theme: "pcbjam-dark" } },
null,
2,
);
// writeIfAbsent by design: a same-session relaunch after the user
// switched themes in-app (F4 persists into these files) must win.
writeIfAbsent(`${KICAD_CONFIG_DIR}/eeschema.json`, appearance);
writeIfAbsent(`${KICAD_CONFIG_DIR}/pcbnew.json`, appearance);
}
};
const preRun = [createCanvas, writeResources];
@ -438,7 +461,16 @@ async function doBoot(opts: BootOptions): Promise<void> {
w.Module = {
thisProgram: TOOL_ARGV0[tool], // argv[0] for KiCad's DEBUG check
...(traceMask ? { ENV: { KICAD_TRACE: traceMask } } : {}),
// PCBJAM_DARK_CHROME is a getenv fallback for the wx chrome appearance
// (wxwidgets src/wasm/settings.cpp) — the authoritative seed is the
// kicadSetDarkChrome call in onRuntimeInitialized below, which runs on
// the browser main thread BEFORE main() spawns on the KiCad pthread
// (pthreads build environ from their own worker's ENV, so this ENV may
// never reach them).
ENV: {
...(traceMask ? { KICAD_TRACE: traceMask } : {}),
...(currentTheme() === "dark" ? { PCBJAM_DARK_CHROME: "1" } : {}),
},
// Runtime frame selection: emscripten feeds these to main() as argv[1..], which
// single_top.cpp parses ("--frame=<token>") to open the requested editor frame
// from a shared bundle. Set in the Module literal so it's present before the
@ -471,6 +503,12 @@ async function doBoot(opts: BootOptions): Promise<void> {
monitorRunDependencies: () => {},
onRuntimeInitialized: () => {
log("[boot] runtime initialized");
// Seed the wx chrome appearance BEFORE main() spawns on the KiCad
// pthread (comments-ux 0002 F4) — this runs on the browser main
// thread, and the flag lives in shared wasm memory. Older bundles
// without the binding just skip it.
const mod = w.Module as { kicadSetDarkChrome?: (dark: boolean) => void };
mod.kicadSetDarkChrome?.(currentTheme() === "dark");
const canvas = (w.Module as { canvas?: HTMLCanvasElement }).canvas;
if (canvas) canvas.style.display = "block";
onStatus("");

View file

@ -10,11 +10,14 @@ import {
field,
kicadItemsMap,
listThreads,
markThreadSeen,
observeComments,
removeMessage,
resolveAnchor,
setThreadAnchor,
setThreadResolved,
threadUnreadCount,
toggleReaction,
yToItemUnchecked,
type CommentAnchor,
type CommentThread,
@ -91,8 +94,13 @@ export interface CommentsController {
/** Build an anchor for a world-pos click: nearest positioned item within
* `maxDistIu` becomes the tracked anchor (+offset), else pos-only. */
anchorAt(world: { x: number; y: number }, maxDistIu: number): CommentAnchor;
create(anchor: CommentAnchor, body: string): string;
reply(threadId: string, body: string): void;
create(anchor: CommentAnchor, body: string, mentions?: string[]): string;
reply(threadId: string, body: string, mentions?: string[]): void;
/** Advance the bound user's seen watermark on a thread (0001 C) — pins and
* badges refresh through the normal observe → subscribe cycle. */
markSeen(threadId: string): void;
/** Toggle the bound user's emoji reaction on a message (0001 D). */
toggleReaction(threadId: string, messageId: string, emoji: string): void;
edit(threadId: string, messageId: string, body: string): boolean;
remove(threadId: string, messageId: string): "removed" | "thread-deleted" | false;
setResolved(threadId: string, resolved: boolean): void;
@ -165,6 +173,7 @@ export function createComments(opts: {
y: t.world.y,
color: colorFor(t.createdBy),
resolved: t.resolved,
unread: threadUnreadCount(t, user.id) > 0,
})),
}),
);
@ -226,23 +235,31 @@ export function createComments(opts: {
return { pos: { x: world.x, y: world.y } };
},
create(anchor, body) {
create(anchor, body, mentions) {
return createThread(doc, {
anchor,
author: user.id,
authorName: user.name,
authorEmail: user.email,
body,
mentions,
});
},
reply(threadId, body) {
reply(threadId, body, mentions) {
addMessage(doc, threadId, {
author: user.id,
authorName: user.name,
authorEmail: user.email,
body,
mentions,
});
},
markSeen(threadId) {
markThreadSeen(doc, threadId, user.id);
},
toggleReaction(threadId, messageId, emoji) {
toggleReaction(doc, threadId, messageId, user.id, emoji);
},
edit(threadId, messageId, body) {
return editMessage(doc, threadId, messageId, body);
},

View file

@ -0,0 +1,48 @@
/**
* Bubble-pin geometry (comments-ux 0001 A) — the TS mirror of the STYLE
* values in pcbjam/wasm/bindings/collab_presence_style.h. GAL draws the
* bubble; the DOM hit target, highlight and popover must sit on the drawn
* body, so both sides need the same numbers.
*
* The radius is LIVE, not a constant: the PresenceTuner can re-style the GAL
* pins at runtime (`kicadCollabSetStyle`), and the DOM must follow — it
* pushes the current `pinRadiusPx` here, and CommentLayer subscribes. The
* default matches the shipped C++ STYLE default; if the tuner-picked ship
* value changes, change BOTH defaults together.
*
* Shape (figma-style): a round body whose bottom-left corner is squared off;
* the SHARP CORNER is the anchored world point, so the body center sits at
* anchor + (r, -r) in screen coords (y down).
*/
export const DEFAULT_PIN_RADIUS_PX = 9;
let radiusPx = DEFAULT_PIN_RADIUS_PX;
const listeners = new Set<() => void>();
/** Current bubble radius in canvas device px (GAL screen px). */
export function pinRadiusPx(): number {
return radiusPx;
}
/** Follow a live GAL re-style (PresenceTuner). No-ops on bogus/same values. */
export function setPinRadiusPx(r: number): void {
if (!Number.isFinite(r) || r <= 0 || r === radiusPx) return;
radiusPx = r;
for (const l of listeners) l();
}
/** Subscribe to radius changes (React: useSyncExternalStore-compatible). */
export function subscribePinRadius(cb: () => void): () => void {
listeners.add(cb);
return () => listeners.delete(cb);
}
/**
* Offset from the anchor (sharp corner) to the bubble body center, in canvas
* device px with CSS axis orientation (y grows downward) — multiply by the
* canvas CSS ratio before positioning DOM.
*/
export function bubbleCenterOffsetPx(): { dx: number; dy: number } {
return { dx: radiusPx, dy: -radiusPx };
}

View file

@ -0,0 +1,55 @@
{
"meta": {
"name": "PCBJam Dark",
"version": 5
},
"schematic": {
"anchor": "rgb(77, 121, 255)",
"aux_items": "rgb(220, 220, 230)",
"background": "rgb(26, 26, 46)",
"brightened": "rgb(255, 0, 255)",
"bus": "rgb(98, 139, 255)",
"bus_junction": "rgb(98, 139, 255)",
"component_body": "rgb(42, 42, 58)",
"component_outline": "rgb(224, 138, 106)",
"cursor": "rgb(230, 230, 240)",
"dnp_marker": "rgba(220, 9, 9, 0.7)",
"erc_error": "rgb(255, 85, 85)",
"erc_exclusion": "rgba(94, 194, 194, 0.8)",
"erc_warning": "rgb(255, 184, 108)",
"excluded_from_sim": "rgb(154, 154, 174)",
"fields": "rgb(139, 233, 253)",
"grid": "rgb(58, 58, 78)",
"grid_axes": "rgb(84, 84, 114)",
"hidden": "rgb(94, 100, 120)",
"hovered": "rgb(77, 121, 255)",
"junction": "rgb(80, 200, 90)",
"label_global": "rgb(255, 184, 108)",
"label_hier": "rgb(189, 147, 249)",
"label_local": "rgb(230, 230, 240)",
"netclass_flag": "rgb(114, 174, 224)",
"no_connect": "rgb(98, 108, 128)",
"note": "rgb(184, 192, 224)",
"note_background": "rgba(0, 0, 0, 0)",
"op_currents": "rgb(255, 122, 122)",
"op_voltages": "rgb(122, 179, 255)",
"override_item_colors": false,
"page_limits": "rgb(84, 84, 104)",
"pin": "rgb(224, 138, 106)",
"pin_name": "rgb(94, 194, 194)",
"pin_number": "rgb(94, 194, 194)",
"private_note": "rgb(114, 142, 255)",
"reference": "rgb(139, 233, 253)",
"rule_area": "rgb(255, 121, 198)",
"shadow": "rgba(120, 170, 255, 0.55)",
"sheet": "rgb(189, 147, 249)",
"sheet_background": "rgba(0, 0, 0, 0)",
"sheet_fields": "rgb(139, 233, 253)",
"sheet_filename": "rgb(184, 192, 224)",
"sheet_label": "rgb(255, 184, 108)",
"sheet_name": "rgb(94, 194, 194)",
"value": "rgb(139, 233, 253)",
"wire": "rgb(80, 200, 90)",
"worksheet": "rgb(153, 85, 85)"
}
}