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

@ -0,0 +1,82 @@
import { test, expect, type Page } from '@playwright/test';
import { openOverlayMenu } from './overlay-menu';
/**
* @-mention e2e (comments-ux 0001 E): with alice and bob live in the same
* room, alice types `@b` in the composer the combobox offers bob (the
* presence-roster fallback covers backends without a members model),
* keyboard-accepts `@bob ` lands in the body, the sent message renders a
* mention chip, and bob's unread badge takes the mention accent.
*/
const SCOPE = 'default';
const ROUTE = 'demo.kicad_sch';
const TITLE = /demo — Schematic Editor/i;
async function bootAs(page: Page, user: string): Promise<void> {
await page.goto(`/${SCOPE}/projects/demo/${ROUTE}?user=${user}`);
await expect(page.locator('#canvas')).toBeVisible({ timeout: 120000 });
await expect
.poll(() => page.title(), { timeout: 120000, intervals: [1000] })
.toMatch(TITLE);
await openOverlayMenu(page);
await expect(page.getByTestId('comment-mode-toggle')).toBeVisible({ timeout: 30000 });
}
async function deleteAllThreads(page: Page): Promise<void> {
await page.evaluate(() => {
const ctl = (window as unknown as {
__pcbjamComments?: {
threads(): Array<{ id: string }>;
deleteThread(id: string): void;
};
}).__pcbjamComments;
ctl?.threads().forEach((t) => ctl.deleteThread(t.id));
});
await expect(page.getByTestId('comment-pin')).toHaveCount(0);
}
test('mentions: autocomplete from the live roster, chip render, mention-accent badge', async ({
page,
context,
}) => {
test.setTimeout(360000); // two full tool boots
await bootAs(page, 'alice');
await deleteAllThreads(page);
const pageB = await context.newPage();
await bootAs(pageB, 'bob');
// Both present: alice's roster knows bob (fallback mention source).
await expect(page.getByTestId('overlay-menu-badge')).toHaveText('1', { timeout: 30000 });
await page.getByTestId('comment-mode-toggle').click();
await page.getByTestId('comment-click-catcher').click({ position: { x: 400, y: 250 } });
const textarea = page.getByTestId('comment-composer').locator('textarea');
await textarea.fill('please check this ');
await textarea.press('End');
await textarea.pressSequentially('@b');
const combobox = page.getByTestId('mention-combobox');
await expect(combobox).toBeVisible();
await expect(combobox.locator('[data-slug="bob"]')).toBeVisible();
await textarea.press('Enter'); // accept the selected completion
await expect(textarea).toHaveValue('please check this @bob ');
await expect(combobox).toHaveCount(0);
await page.getByTestId('comment-submit').click();
// The sent message renders the mention as a chip.
await expect(page.getByTestId('comment-popover')).toBeVisible();
await expect(page.getByTestId('comment-mention')).toHaveText('@bob');
// bob's unread badge wears the mention accent (rose, not amber).
const badgeB = pageB.getByTestId('overlay-menu-unread-badge');
await expect(badgeB).toHaveText('1', { timeout: 20000 });
await expect(badgeB).toHaveClass(/bg-rose-500/);
// …and on bob's side the message chip carries his slug + amber self accent.
await pageB.getByTestId('comment-pin').click();
await expect(pageB.getByTestId('comment-mention')).toHaveClass(/text-amber-300/);
await pageB.close();
});

View file

@ -0,0 +1,129 @@
import { test, expect, type Page } from '@playwright/test';
import { openOverlayMenu } from './overlay-menu';
/**
* Floating comments panel e2e (comments-ux 0001 B): the list is a draggable
* window with its own scrollbar, independent of the overlay menu, with an
* always-onscreen restore guarantee (a stored position that no longer fits
* the viewport e.g. a gone secondary display resets to the default
* anchor; live shrinks clamp it back in).
*/
const SCOPE = 'default';
const ROUTE = 'demo.kicad_sch';
const TITLE = /demo — Schematic Editor/i;
async function bootAs(page: Page, user: string): Promise<void> {
await page.goto(`/${SCOPE}/projects/demo/${ROUTE}?user=${user}`);
await expect(page.locator('#canvas')).toBeVisible({ timeout: 120000 });
await expect
.poll(() => page.title(), { timeout: 120000, intervals: [1000] })
.toMatch(TITLE);
await openOverlayMenu(page);
await expect(page.getByTestId('comment-mode-toggle')).toBeVisible({ timeout: 30000 });
}
async function resetComments(page: Page): Promise<void> {
await page.evaluate(() => {
const ctl = (window as unknown as {
__pcbjamComments?: {
threads(): Array<{ id: string }>;
deleteThread(id: string): void;
create(anchor: { pos: { x: number; y: number } }, body: string): string;
};
}).__pcbjamComments;
ctl?.threads().forEach((t) => ctl.deleteThread(t.id));
});
await expect(page.getByTestId('comment-pin')).toHaveCount(0);
}
function createThread(page: Page, body: string, x: number): Promise<void> {
return page.evaluate(
([bodyText, wx]) => {
const ctl = (window as unknown as {
__pcbjamComments: {
create(anchor: { pos: { x: number; y: number } }, body: string): string;
};
}).__pcbjamComments;
ctl.create({ pos: { x: Number(wx), y: 0 } }, String(bodyText));
},
[body, String(x)],
);
}
test('floating panel: open, scroll, drag, offscreen-reset, clamp', async ({ page }) => {
test.setTimeout(300000); // one full tool boot
await bootAs(page, 'alice');
await resetComments(page);
for (let i = 0; i < 12; i++) await createThread(page, `note ${i}`, i * 100000);
// A stored position from a "bigger display" must NOT be honored: the panel
// opens at its default anchor instead. (Legacy px entry deliberately.)
await page.evaluate(() =>
localStorage.setItem('pcbjam:comments-panel-pos', JSON.stringify({ x: 5000, y: 120 })),
);
await openOverlayMenu(page);
await page.getByTestId('comment-panel-toggle').click();
const panel = page.getByTestId('comments-panel');
await expect(panel).toBeVisible();
await expect(page.getByTestId('comment-panel-item')).toHaveCount(12);
const vp = page.viewportSize()!;
let box = (await panel.boundingBox())!;
expect(box.x + box.width).toBeLessThanOrEqual(vp.width + 1);
expect(box.x).toBeGreaterThanOrEqual(-1);
// The list scrolls inside the panel (12 rows > max height).
const scrollable = await page
.getByTestId('comments-panel-list')
.evaluate((el) => el.scrollHeight > el.clientHeight);
expect(scrollable).toBe(true);
// Header carries the primary comment actions: "+" arms comment mode
// (click catcher appears), the eye hides the pin hit targets.
await page.getByTestId('comments-panel-add').click();
await expect(page.getByTestId('comment-click-catcher')).toBeVisible();
await page.getByTestId('comments-panel-add').click(); // cancel again
await expect(page.getByTestId('comment-click-catcher')).toHaveCount(0);
await page.getByTestId('comments-panel-pins').click();
await expect(page.getByTestId('comment-pin')).toHaveCount(0);
await page.getByTestId('comments-panel-pins').click();
// ≥1, not 12: pins whose anchors fall outside the viewport render no DOM
// hit target (culling), and the fit depends on the demo sheet.
await expect(page.getByTestId('comment-pin').first()).toBeVisible();
// Collapse to header-only; the state survives close/reopen.
await page.getByTestId('comments-panel-collapse').click();
await expect(page.getByTestId('comments-panel-list')).toHaveCount(0);
await expect(page.getByTestId('comments-panel-header')).toBeVisible();
await page.getByTestId('comments-panel-collapse').click();
await expect(page.getByTestId('comments-panel-list')).toBeVisible();
// Drag by the header to a chosen spot.
const header = page.getByTestId('comments-panel-header');
const hb = (await header.boundingBox())!;
await page.mouse.move(hb.x + 40, hb.y + hb.height / 2);
await page.mouse.down();
await page.mouse.move(120, 300, { steps: 5 });
await page.mouse.up();
box = (await panel.boundingBox())!;
expect(box.y).toBeGreaterThan(200);
expect(box.x).toBeLessThan(200);
// Live viewport shrink clamps the handle back onscreen.
await page.setViewportSize({ width: 700, height: 400 });
await expect
.poll(async () => {
const b = (await panel.boundingBox())!;
return b.x >= 0 && b.y >= 0 && b.x + 288 <= 700 + 1 && b.y + 36 <= 400 + 1;
})
.toBe(true);
// Close persists; the open state itself is storage-backed.
await page.getByTestId('comments-panel-close').click();
await expect(panel).toHaveCount(0);
expect(await page.evaluate(() => localStorage.getItem('pcbjam:comments-panel-open'))).toBe('0');
});

View file

@ -0,0 +1,86 @@
import { test, expect, type Page } from '@playwright/test';
import { openOverlayMenu } from './overlay-menu';
/**
* Emoji reactions e2e (comments-ux 0001 D): quick-row toggle on/off, two
* users reacting with the same emoji both counted (flat own-key writes no
* LWW clobber), and the lazy full picker mounting on demand.
*/
const SCOPE = 'default';
const ROUTE = 'demo.kicad_sch';
const TITLE = /demo — Schematic Editor/i;
async function bootAs(page: Page, user: string): Promise<void> {
await page.goto(`/${SCOPE}/projects/demo/${ROUTE}?user=${user}`);
await expect(page.locator('#canvas')).toBeVisible({ timeout: 120000 });
await expect
.poll(() => page.title(), { timeout: 120000, intervals: [1000] })
.toMatch(TITLE);
await openOverlayMenu(page);
await expect(page.getByTestId('comment-mode-toggle')).toBeVisible({ timeout: 30000 });
}
async function deleteAllThreads(page: Page): Promise<void> {
await page.evaluate(() => {
const ctl = (window as unknown as {
__pcbjamComments?: {
threads(): Array<{ id: string }>;
deleteThread(id: string): void;
};
}).__pcbjamComments;
ctl?.threads().forEach((t) => ctl.deleteThread(t.id));
});
await expect(page.getByTestId('comment-pin')).toHaveCount(0);
}
test('reactions: quick-row toggle, concurrent same-emoji, lazy full picker', async ({
page,
context,
}) => {
test.setTimeout(360000); // two full tool boots
await bootAs(page, 'alice');
await deleteAllThreads(page);
const pageB = await context.newPage();
await bootAs(pageB, 'bob');
// alice creates and keeps the popover open.
await page.getByTestId('comment-mode-toggle').click();
await page.getByTestId('comment-click-catcher').click({ position: { x: 400, y: 250 } });
await page.getByTestId('comment-composer').locator('textarea').fill('react to me');
await page.getByTestId('comment-submit').click();
await expect(page.getByTestId('comment-popover')).toBeVisible();
// alice: 👍 via the quick row ("add reaction" shows on message hover).
await page.getByTestId('comment-message').hover();
await page.getByTestId('comment-react').click();
await page.getByTestId('comment-quick-react').locator('[data-emoji="👍"]').click();
const chip = page.getByTestId('comment-reaction-chip');
await expect(chip).toHaveCount(1);
await expect(chip).toContainText('1');
// bob opens the same thread and clicks the existing chip → count 2 in BOTH.
await expect(pageB.getByTestId('comment-pin')).toHaveCount(1, { timeout: 20000 });
await pageB.getByTestId('comment-pin').click();
const chipB = pageB.getByTestId('comment-reaction-chip');
await expect(chipB).toContainText('1', { timeout: 20000 });
await chipB.click();
await expect(chipB).toContainText('2', { timeout: 20000 });
await expect(chip).toContainText('2', { timeout: 20000 });
// Toggle off (bob) → back to 1 everywhere.
await chipB.click();
await expect(chip).toContainText('1', { timeout: 20000 });
// Full picker: quick row's "+" mounts the lazy emoji-mart chunk.
await page.getByTestId('comment-message').hover();
await page.getByTestId('comment-react').click();
await page.getByTestId('comment-react-more').click();
await expect(page.getByTestId('emoji-picker')).toBeVisible();
await expect(page.locator('em-emoji-picker')).toBeVisible({ timeout: 30000 });
await page.keyboard.press('Escape');
await expect(page.getByTestId('emoji-picker')).toHaveCount(0);
await pageB.close();
});

View file

@ -0,0 +1,93 @@
import { test, expect, type Page } from '@playwright/test';
import { openOverlayMenu } from './overlay-menu';
/**
* Seen/unread e2e (comments-ux 0001 C): alice comments bob's FAB grows an
* unread badge and the panel row an unread dot; opening the thread clears
* them (event-driven mark-seen popover open, own writes); a reply re-arms
* alice's badge, and "mark all as seen" clears it without opening.
*/
const SCOPE = 'default';
const ROUTE = 'demo.kicad_sch';
const TITLE = /demo — Schematic Editor/i;
async function bootAs(page: Page, user: string): Promise<void> {
await page.goto(`/${SCOPE}/projects/demo/${ROUTE}?user=${user}`);
await expect(page.locator('#canvas')).toBeVisible({ timeout: 120000 });
await expect
.poll(() => page.title(), { timeout: 120000, intervals: [1000] })
.toMatch(TITLE);
await openOverlayMenu(page);
await expect(page.getByTestId('comment-mode-toggle')).toBeVisible({ timeout: 30000 });
}
async function deleteAllThreads(page: Page): Promise<void> {
await page.evaluate(() => {
const ctl = (window as unknown as {
__pcbjamComments?: {
threads(): Array<{ id: string }>;
deleteThread(id: string): void;
};
}).__pcbjamComments;
ctl?.threads().forEach((t) => ctl.deleteThread(t.id));
});
await expect(page.getByTestId('comment-pin')).toHaveCount(0);
}
test('unread badges: create → badge for the peer only → open clears → reply re-arms → mark-all', async ({
page,
context,
}) => {
test.setTimeout(360000); // two full tool boots
await bootAs(page, 'alice');
await deleteAllThreads(page);
const pageB = await context.newPage();
await bootAs(pageB, 'bob');
// alice creates via comment mode (own write = auto-seen for alice).
await page.getByTestId('comment-mode-toggle').click();
await page.getByTestId('comment-click-catcher').click({ position: { x: 400, y: 250 } });
await page.getByTestId('comment-composer').locator('textarea').fill('unseen by bob');
await page.getByTestId('comment-submit').click();
await expect(page.getByTestId('comment-popover')).toBeVisible();
// bob: FAB unread badge + menu-row badge + panel unread dot. alice: none.
await expect(pageB.getByTestId('overlay-menu-unread-badge')).toHaveText('1', {
timeout: 20000,
});
await expect(page.getByTestId('overlay-menu-unread-badge')).toHaveCount(0);
await openOverlayMenu(pageB);
await expect(pageB.getByTestId('comment-unread-badge')).toHaveText('1');
await pageB.getByTestId('comment-panel-toggle').click();
await expect(pageB.getByTestId('comment-unread-dot')).toHaveCount(1);
// Opening the thread marks it seen — badges and dot clear, GAL pins get
// unread=false (asserted indirectly: the DOM state is the driver).
await pageB.getByTestId('comment-pin').click();
await expect(pageB.getByTestId('comment-popover')).toBeVisible();
await expect(pageB.getByTestId('overlay-menu-unread-badge')).toHaveCount(0, {
timeout: 20000,
});
await expect(pageB.getByTestId('comment-unread-dot')).toHaveCount(0);
// bob replies from his open popover → alice (popover closed first) unreads.
await page.getByTestId('comment-popover').getByTitle('Close').click();
await pageB.getByTestId('comment-reply').fill('now you have mail');
await pageB.getByTestId('comment-reply').press('Enter');
await expect(page.getByTestId('overlay-menu-unread-badge')).toHaveText('1', {
timeout: 20000,
});
// "Mark all as seen" from alice's panel header clears without opening.
await openOverlayMenu(page);
await page.getByTestId('comment-panel-toggle').click();
await page.getByTestId('comments-mark-all-seen').click();
await expect(page.getByTestId('overlay-menu-unread-badge')).toHaveCount(0, {
timeout: 20000,
});
await pageB.close();
});

View file

@ -67,8 +67,13 @@ async function pinDelta(page: Page): Promise<{ dx: number; dy: number }> {
const r = gl.getBoundingClientRect();
const ratio = r.width / vp.w;
const world = win.__pcbjamComments.threads()[0].world;
const truthX = r.x + ((world.x - vp.cx) * vp.scale + vp.w / 2) * ratio;
const truthY = r.y + ((world.y - vp.cy) * vp.scale + vp.h / 2) * ratio;
// Bubble-pin geometry (comments-ux 0001 A): the DOM target centers on the
// bubble BODY at anchor + (r, -r) — the anchored point is the bubble's
// sharp bottom-left corner. Keep in sync with standalone
// src/wasm/collab/pin-geometry.ts (PIN_RADIUS_PX).
const bubbleR = 9 * ratio;
const truthX = r.x + ((world.x - vp.cx) * vp.scale + vp.w / 2) * ratio + bubbleR;
const truthY = r.y + ((world.y - vp.cy) * vp.scale + vp.h / 2) * ratio - bubbleR;
const pin = document.querySelector('[data-testid="comment-pin"]') as HTMLElement;
const pr = pin.getBoundingClientRect();
return { dx: pr.x + pr.width / 2 - truthX, dy: pr.y + pr.height / 2 - truthY };

36
tests/web/theme.spec.ts Normal file
View file

@ -0,0 +1,36 @@
import { test, expect } from '@playwright/test';
/**
* Standalone theme follow e2e (comments-ux 0002): resolution precedence
* (?theme= param > storage > OS), no-flash application before first paint,
* persistence across a param-less reload, and the shell toggle. Kept on the
* HOME page no tool boot needed; the canvas half (seeded KiCad color theme
* + the kicadSetColorTheme bridge) is covered by unit seeding assertions and
* manual Firefox verification per the standalone UI workflow.
*/
test('?theme=dark applies before paint, persists, and the toggle flips it', async ({ page }) => {
await page.goto('/?theme=dark', { waitUntil: 'domcontentloaded' });
// The inline boot script ran before first paint — no React needed yet.
await expect(page.locator('html')).toHaveClass(/dark/);
// The param persisted; a param-less reload stays dark.
await page.goto('/');
await expect(page.locator('html')).toHaveClass(/dark/);
expect(await page.evaluate(() => localStorage.getItem('pcbjam-theme'))).toBe('dark');
// Shell toggle flips class + storage.
await page.getByTestId('theme-toggle').click();
await expect(page.locator('html')).not.toHaveClass(/dark/);
expect(await page.evaluate(() => localStorage.getItem('pcbjam-theme'))).toBe('light');
// A later ?theme= wins over the stored choice.
await page.goto('/?theme=dark', { waitUntil: 'domcontentloaded' });
await expect(page.locator('html')).toHaveClass(/dark/);
});
test('invalid ?theme= falls back to the stored/OS preference', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'light' });
await page.goto('/?theme=purple', { waitUntil: 'domcontentloaded' });
await expect(page.locator('html')).not.toHaveClass(/dark/);
});

View file

@ -66,6 +66,7 @@ struct PIN
VECTOR2D pos; // world coords (IU)
KIGFX::COLOR4D color;
bool resolved = false;
bool unread = false; // comments-ux 0001 C: accent ring
};
inline long long nowMs()
@ -384,7 +385,7 @@ struct CORE
for( const PIN& pin : pins )
{
KIGFX::COLOR4D color = peerColor( style, pin.name, pin.color );
drawPin( chipOverlay.get(), pin.pos, color, pin.resolved, px, style );
drawPin( chipOverlay.get(), pin.pos, color, pin.resolved, pin.unread, px, style );
}
view->Update( overlay.get() );
@ -532,9 +533,9 @@ struct CORE
scheduleRedraw();
}
/** kicadCollabSetPins (0005): comment pin dots — `{pins:[{id,name,x,y,
* color,resolved}]}`, world IU coords resolved by the TS side from the
* ydoc anchors. Snapshot semantics like setRemote. */
/** kicadCollabSetPins (0005): comment pins — `{pins:[{id,name,x,y,
* color,resolved,unread}]}`, world IU coords resolved by the TS side
* from the ydoc anchors. Snapshot semantics like setRemote. */
void setPins( const std::string& aJson )
{
json j = json::parse( aJson, nullptr, /*allow_exceptions*/ false );
@ -552,6 +553,7 @@ struct CORE
pin.pos = VECTOR2D( p.value( "x", 0.0 ), p.value( "y", 0.0 ) );
pin.color = parsePeerColor( p.value( "color", "" ) );
pin.resolved = p.value( "resolved", false );
pin.unread = p.value( "unread", false );
parsed.push_back( std::move( pin ) );
}

View file

@ -24,6 +24,7 @@
#include <view/view_overlay.h>
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cmath>
#include <memory>
#include <string>
#include <vector>
@ -75,12 +76,20 @@ struct STYLE
std::string fixedColor;
std::vector<std::string> palette;
// ── comment pin dots ──────────────────────────────────────────────────
// ── comment pins ──────────────────────────────────────────────────────
// 0 circle dot · 1 figma-style bubble: a round body whose bottom-left
// corner is squared off — the SHARP CORNER is the anchored point
// (comments-ux 0001 A, reshaped per user feedback 2026-07-24).
// Shipped look picked with the tuner 2026-07-24: r9 body, 4px ring,
// slightly translucent ring+fill. pinRadiusPx is MIRRORED in the
// standalone's pin-geometry.ts (DEFAULT_PIN_RADIUS_PX) — change together.
int pinShape = 1;
double pinRadiusPx = 9.0;
double pinRingPx = 3.0;
double pinRingAlpha = 1.0;
double pinFillAlpha = 1.0;
double pinRingPx = 4.0;
double pinRingAlpha = 0.9;
double pinFillAlpha = 0.9;
double pinResolvedAlpha = 0.3;
std::string pinUnreadRingColor = "#ffb020"; // unread accent ring ("" = plain white)
// ── cross-app "ghost" selection (0006) ────────────────────────────────
// A peer's selection in the OTHER editor (eeschema symbol ⇄ pcbnew
@ -159,11 +168,13 @@ inline void patchStyle( STYLE& aStyle, const json& j )
}
}
aStyle.pinShape = j.value( "pinShape", aStyle.pinShape );
aStyle.pinRadiusPx = j.value( "pinRadiusPx", aStyle.pinRadiusPx );
aStyle.pinRingPx = j.value( "pinRingPx", aStyle.pinRingPx );
aStyle.pinRingAlpha = j.value( "pinRingAlpha", aStyle.pinRingAlpha );
aStyle.pinFillAlpha = j.value( "pinFillAlpha", aStyle.pinFillAlpha );
aStyle.pinResolvedAlpha = j.value( "pinResolvedAlpha", aStyle.pinResolvedAlpha );
aStyle.pinUnreadRingColor = j.value( "pinUnreadRingColor", aStyle.pinUnreadRingColor );
aStyle.xselAlphaScale = j.value( "xselAlphaScale", aStyle.xselAlphaScale );
}
@ -494,21 +505,60 @@ inline void drawCursor( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aChipOv,
}
}
/** Comment pin dot. Draw onto the CHIPS overlay: at the shapes depth an
* earlier-painted selection fill would reject the dot's fragments (the 0005
* "drawn last sits above" comment had it backwards later fragments LOSE). */
/** Comment pin. Draw onto the CHIPS overlay: at the shapes depth an
* earlier-painted selection fill would reject the pin's fragments (the 0005
* "drawn last sits above" comment had it backwards later fragments LOSE).
* Bubble shape (comments-ux 0001 A, figma-style): a round body whose
* bottom-left corner is squared off; `aPos` is that SHARP CORNER = the
* anchored world point, the body center sits at aPos + (r, -r) (screen
* up-right; KiCad IU y grows downward). One closed polygon three sampled
* round corners + the sharp one so the stroke traces the outline exactly
* with no depth-order seams. */
inline void drawPin( KIGFX::VIEW_OVERLAY* aOv, const VECTOR2D& aPos, const KIGFX::COLOR4D& aColor,
bool aResolved, double aPx, const STYLE& aS )
bool aResolved, bool aUnread, double aPx, const STYLE& aS )
{
double fillAlpha = aResolved ? aS.pinResolvedAlpha : aS.pinFillAlpha;
double ringAlpha = aResolved ? aS.pinRingAlpha * 0.4 : aS.pinRingAlpha;
KIGFX::COLOR4D ring( 1, 1, 1, ringAlpha );
if( aUnread && !aResolved && !aS.pinUnreadRingColor.empty() )
ring = parseHexColor( aS.pinUnreadRingColor, ring ).WithAlpha( ringAlpha );
aOv->SetIsStroke( true );
aOv->SetIsFill( true );
aOv->SetFillColor( aColor.WithAlpha( fillAlpha ) );
aOv->SetStrokeColor( KIGFX::COLOR4D( 1, 1, 1, ringAlpha ) );
aOv->SetStrokeColor( ring );
aOv->SetLineWidth( aS.pinRingPx * aPx );
aOv->Circle( aPos, aS.pinRadiusPx * aPx );
if( aS.pinShape == 0 )
{
aOv->Circle( aPos, aS.pinRadiusPx * aPx );
return;
}
double r = aS.pinRadiusPx * aPx;
VECTOR2D c = aPos + VECTOR2D( r, -r );
constexpr int SEGS = 24; // sampling of the 270° round part
VECTOR2D pts[SEGS + 3];
int n = 0;
pts[n++] = aPos; // the sharp corner
for( int i = 0; i <= SEGS; i++ )
{
// South (90° in y-down coords) → east → north → west: the round part.
double th = ( 90.0 - 270.0 * i / SEGS ) * M_PI / 180.0;
pts[n++] = c + VECTOR2D( cos( th ) * r, sin( th ) * r );
}
// Close the outline explicitly: the overlay strokes the point list as a
// polyline, so without repeating the first point the west → sharp-corner
// edge would have fill but no ring.
pts[n++] = aPos;
aOv->Polygon( pts, n );
}
} // namespace pcbjam_presence

View file

@ -61,6 +61,7 @@
#include "collab_common.h"
#include "collab_presence_core.h"
#include "collab_presence_style.h"
#include "pcbjam_theme.h"
#include "pcbjam_libs_reload.h"
#include <algorithm>
@ -1658,6 +1659,18 @@ void schCollabSetStyle( std::string aJson )
presenceCore().setStyle( aJson );
}
// JS → C++ (comments-ux 0002 F4): live color-theme switch (see pcbjam_theme.h).
void schSetColorTheme( std::string aTheme )
{
pcbjam_theme::setColorTheme( schFrame(), aTheme );
}
// Pre-main chrome appearance seed (called at onRuntimeInitialized).
void schSetDarkChrome( bool aDark )
{
pcbjam_theme::setDarkChromeFlag( aDark );
}
// Tuner helper: a VARIED demo-selection set for the current sheet — smallest +
// largest symbol and two bundles of wires (net-ish), mirroring pcbnew's
// pcbCollabTestDemoSet so the style preview shows the range of shapes.
@ -2027,6 +2040,9 @@ EMSCRIPTEN_BINDINGS(eeschema) {
// Follow-user (collab-presence 0008).
function("kicadCollabFitViewport", &schCollabFitViewport);
function("kicadCollabSetStyle", &schCollabSetStyle);
// Live color-theme switch (comments-ux 0002 F4).
function("kicadSetColorTheme", &schSetColorTheme);
function("kicadSetDarkChrome", &schSetDarkChrome);
function("kicadCollabTestListItems", &schCollabTestListItems);
function("kicadCollabTestDemoSet", &schCollabTestDemoSet);
function("kicadCollabGetViewport", &schCollabGetViewport);

View file

@ -79,6 +79,9 @@ void pcbCollabReleaseSelection( std::string aUuidsJson, std::string aHold
std::string pcbCollabTestGetLocked();
std::string pcbCollabTestSelectFirst();
bool pcbCollabTestClearSelection();
// Live color-theme switch (comments-ux 0002 F4).
void pcbSetColorTheme( std::string aTheme );
void pcbSetDarkChrome( bool aDark );
bool schEditorActive();
int schLibsSymbolUsage( std::string aLibNickname, std::string aSymbolName );
@ -101,6 +104,8 @@ void schCollabSetViewport( double aCx, double aCy );
// Follow-user (collab-presence 0008).
void schCollabFitViewport( double aCx, double aCy, double aHalfW, double aHalfH );
void schCollabSetStyle( std::string aJson );
// Live color-theme switch (comments-ux 0002 F4).
void schSetColorTheme( std::string aTheme );
std::string schCollabTestListItems( int aCount );
std::string schCollabTestDemoSet();
std::string schCollabGetViewport();
@ -388,6 +393,21 @@ static void collabSetStyle( std::string aJson )
pcbEditorActive() ? pcbCollabSetStyle( aJson ) : schCollabSetStyle( aJson );
}
// Theme switch (comments-ux 0002 F4): BOTH editors, not just the active one —
// a later frame switch (eeschema-switch-nav) must come up already themed.
// Each side no-ops on a null frame.
static void setColorTheme( std::string aTheme )
{
pcbSetColorTheme( aTheme );
schSetColorTheme( aTheme );
}
// The chrome flag is process-global — one call suffices.
static void setDarkChrome( bool aDark )
{
pcbSetDarkChrome( aDark );
}
static std::string collabTestListItems( int aCount )
{
return pcbEditorActive() ? pcbCollabTestListItems( aCount ) : schCollabTestListItems( aCount );
@ -490,6 +510,9 @@ EMSCRIPTEN_BINDINGS(kicad_editor) {
function("kicadCollabSetRemote", &collabSetRemote);
function("kicadCollabSetPins", &collabSetPins);
function("kicadCollabSetViewport", &collabSetViewport);
// Live color-theme switch (comments-ux 0002 F4).
function("kicadSetColorTheme", &setColorTheme);
function("kicadSetDarkChrome", &setDarkChrome);
// Follow-user (collab-presence 0008).
function("kicadCollabFitViewport", &collabFitViewport);
function("kicadCollabSetStyle", &collabSetStyle);

View file

@ -0,0 +1,101 @@
/*
* Live color-theme switch (comments-ux 0002 F4): kicadSetColorTheme(name)
* re-points the frame's app settings `color_theme` and drives the frame's own
* CommonSettingsChanged the exact path the desktop Preferences dialog uses
* to hot-apply a theme (SCH_BASE_FRAME / PCB_BASE_FRAME reload COLOR_SETTINGS
* into the painter, recache the view, refresh). SETTINGS_MANAGER resolves
* colors/<name>.json from the MEMFS config dir on demand, so the JSON only
* has to exist (standalone boot seeding writes it web/standalone
* src/wasm/boot.ts). The changed app settings are saved back to MEMFS so a
* same-session relaunch (warm pool) comes up already themed.
*/
#pragma once
#ifdef __EMSCRIPTEN__
#include <eda_draw_frame.h>
#include <pgm_base.h>
#include <settings/app_settings.h>
#include <settings/settings_manager.h>
#include <string>
#include <wx/event.h>
#include <wx/window.h>
#include "collab_common.h"
// wx wasm port chrome appearance (wxwidgets src/wasm/settings.cpp): the
// system-colour table every widget paints from.
extern "C" void wxWasmSetDarkAppearance( bool dark );
extern "C" bool wxWasmGetDarkAppearance();
namespace pcbjam_theme {
/** Set the chrome appearance FLAG only — no widget traffic, no fiber. Safe
* from the browser main thread at any point (it writes one bool in shared
* wasm memory); the embedder calls it at onRuntimeInitialized, BEFORE main()
* spawns on the KiCad pthread, so the first widget paint is already themed.
* (Module.ENV proved unreliable for this: the pthread builds its environ
* from its own worker's ENV, not the main runtime's.) */
inline void setDarkChromeFlag( bool aDark )
{
wxWasmSetDarkAppearance( aDark );
}
/** Flip the wx CHROME (panels/toolbars/dialogs — the system-colour table) and
* broadcast the change so every live window repaints:
* wxWindowBase::OnSysColourChanged recurses to children, and
* EDA_BASE_FRAME's handler additionally re-themes icons and rebuilds
* toolbars/menubar. No-op when the appearance didn't change (the merged
* image calls the theme entry once per editor). No DOM probing here this
* runs on the KiCad pthread, which has no `document`. */
inline void syncChromeAppearance( bool aDark )
{
if( aDark == wxWasmGetDarkAppearance() )
return;
wxWasmSetDarkAppearance( aDark );
for( wxWindowList::const_iterator it = wxTopLevelWindows.begin();
it != wxTopLevelWindows.end(); ++it )
{
wxWindow* tlw = *it;
wxSysColourChangedEvent evt;
evt.SetEventObject( tlw );
tlw->GetEventHandler()->ProcessEvent( evt );
tlw->Refresh();
}
}
/** Apply `aTheme` ("pcbjam-dark", "_builtin_default", …) to one frame. Runs
* on the frame's fiber: CommonSettingsChanged reaches tool/view internals
* that must not run from a bare JS callback. Null frame no-ops (the merged
* dispatcher calls every editor, open or not). */
inline void setColorTheme( EDA_DRAW_FRAME* aFrame, const std::string& aTheme )
{
if( !aFrame )
return;
pcbjam_collab::runOnFiber( aFrame, [aFrame, aTheme]() {
if( APP_SETTINGS_BASE* cfg = aFrame->config() )
{
cfg->m_ColorTheme = wxString::FromUTF8( aTheme.c_str() );
// Persist now — wasm sessions never exit cleanly, so the normal
// save-on-close path would lose the choice.
Pgm().GetSettingsManager().Save( cfg );
}
// wx chrome first (panels/toolbars), then the GAL canvas colors. The
// shell only ever sends our dark theme name or the builtin default,
// so the chrome appearance rides on that distinction.
syncChromeAppearance( aTheme != "_builtin_default" );
// 0 flags: no env/text vars changed; the frame's override chain still
// unconditionally reloads colors and recaches the view.
aFrame->CommonSettingsChanged( 0 );
} );
}
} // namespace pcbjam_theme
#endif // __EMSCRIPTEN__

View file

@ -53,6 +53,7 @@
#include "collab_common.h"
#include "collab_presence_core.h"
#include "collab_presence_style.h"
#include "pcbjam_theme.h"
#include "pcbjam_libs_reload.h"
#include <algorithm>
#include <chrono>
@ -1603,6 +1604,18 @@ void pcbCollabSetStyle( std::string aJson )
presenceCore().setStyle( aJson );
}
// JS → C++ (comments-ux 0002 F4): live color-theme switch (see pcbjam_theme.h).
void pcbSetColorTheme( std::string aTheme )
{
pcbjam_theme::setColorTheme( pcbFrame(), aTheme );
}
// Pre-main chrome appearance seed (called at onRuntimeInitialized).
void pcbSetDarkChrome( bool aDark )
{
pcbjam_theme::setDarkChromeFlag( aDark );
}
// Tuner helper: a VARIED demo-selection set — labeled uuid groups (smallest +
// largest footprint, the two busiest nets' track segments) so the style
// preview shows the real range of shapes instead of two overlapping items.
@ -2351,6 +2364,9 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
// Follow-user (collab-presence 0008).
function("kicadCollabFitViewport", &pcbCollabFitViewport);
function("kicadCollabSetStyle", &pcbCollabSetStyle);
// Live color-theme switch (comments-ux 0002 F4).
function("kicadSetColorTheme", &pcbSetColorTheme);
function("kicadSetDarkChrome", &pcbSetDarkChrome);
function("kicadCollabTestListItems", &pcbCollabTestListItems);
function("kicadCollabTestDemoSet", &pcbCollabTestDemoSet);
function("kicadCollabGetViewport", &pcbCollabGetViewport);

View file

@ -210,6 +210,12 @@ export async function buildApp(): Promise<import("fastify").FastifyInstance> {
}
return { status: 200 as const, body: await walk(PROJECT_DIR) };
},
// No members model on the example backend — mention autocomplete falls
// back to the presence roster + comment authors (contract-sanctioned 404).
listCollaborators: async () => ({
status: 404 as const,
body: { message: "no members model on this backend" },
}),
listLibs: async ({ headers, query }) => {
const owner = userOf(headers);
// Origins filtered by item kind (?kind); user libs are kind-agnostic

@ -1 +1 @@
Subproject commit b17af7c971f702d5e1ce2a753843160160797dc7
Subproject commit 5d35bcd2adc5f0c54d3b655912b680dfc6b0c5ce

30
web/pnpm-lock.yaml generated
View file

@ -77,6 +77,12 @@ importers:
standalone:
dependencies:
'@emoji-mart/data':
specifier: ^1.2.1
version: 1.2.1
'@emoji-mart/react':
specifier: ^1.1.1
version: 1.1.1(emoji-mart@5.6.0)(react@18.3.1)
'@hocuspocus/provider':
specifier: ^4.1.1
version: 4.1.1(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31)
@ -107,6 +113,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
emoji-mart:
specifier: ^5.6.0
version: 5.6.0
lucide-react:
specifier: ^0.469.0
version: 0.469.0(react@18.3.1)
@ -258,6 +267,15 @@ packages:
'@cloudflare/workers-types@4.20260610.1':
resolution: {integrity: sha512-Mk/f3lUygeIHzQ4HnJjU/JvGg/kllgp9gISty9nylHE/2M2MFeKO+hgAKSgiPpmwUbuhewdYGgqFGgT/ADK0/g==}
'@emoji-mart/data@1.2.1':
resolution: {integrity: sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==}
'@emoji-mart/react@1.1.1':
resolution: {integrity: sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==}
peerDependencies:
emoji-mart: ^5.2
react: ^16.8 || ^17 || ^18
'@esbuild/aix-ppc64@0.25.12':
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
engines: {node: '>=18'}
@ -1254,6 +1272,9 @@ packages:
electron-to-chromium@1.5.364:
resolution: {integrity: sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==}
emoji-mart@5.6.0:
resolution: {integrity: sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==}
es-errors@1.3.0:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
@ -2090,6 +2111,13 @@ snapshots:
'@cloudflare/workers-types@4.20260610.1': {}
'@emoji-mart/data@1.2.1': {}
'@emoji-mart/react@1.1.1(emoji-mart@5.6.0)(react@18.3.1)':
dependencies:
emoji-mart: 5.6.0
react: 18.3.1
'@esbuild/aix-ppc64@0.25.12':
optional: true
@ -2819,6 +2847,8 @@ snapshots:
electron-to-chromium@1.5.364: {}
emoji-mart@5.6.0: {}
es-errors@1.3.0: {}
es-module-lexer@1.7.0: {}

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)"
}
}

@ -1 +1 @@
Subproject commit a61bcf4aa8684bf2d3afa9d30b3e0edfb7debec1
Subproject commit 87336aa51d19942d5ac7fe1f18a326f3c3d1a1fb