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:
parent
d3200e464c
commit
21a96b0440
39 changed files with 2436 additions and 232 deletions
82
tests/web/comments-mentions.spec.ts
Normal file
82
tests/web/comments-mentions.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
129
tests/web/comments-panel.spec.ts
Normal file
129
tests/web/comments-panel.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
86
tests/web/comments-reactions.spec.ts
Normal file
86
tests/web/comments-reactions.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
93
tests/web/comments-seen.spec.ts
Normal file
93
tests/web/comments-seen.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
|
@ -67,8 +67,13 @@ async function pinDelta(page: Page): Promise<{ dx: number; dy: number }> {
|
||||||
const r = gl.getBoundingClientRect();
|
const r = gl.getBoundingClientRect();
|
||||||
const ratio = r.width / vp.w;
|
const ratio = r.width / vp.w;
|
||||||
const world = win.__pcbjamComments.threads()[0].world;
|
const world = win.__pcbjamComments.threads()[0].world;
|
||||||
const truthX = r.x + ((world.x - vp.cx) * vp.scale + vp.w / 2) * ratio;
|
// Bubble-pin geometry (comments-ux 0001 A): the DOM target centers on the
|
||||||
const truthY = r.y + ((world.y - vp.cy) * vp.scale + vp.h / 2) * ratio;
|
// 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 pin = document.querySelector('[data-testid="comment-pin"]') as HTMLElement;
|
||||||
const pr = pin.getBoundingClientRect();
|
const pr = pin.getBoundingClientRect();
|
||||||
return { dx: pr.x + pr.width / 2 - truthX, dy: pr.y + pr.height / 2 - truthY };
|
return { dx: pr.x + pr.width / 2 - truthX, dy: pr.y + pr.height / 2 - truthY };
|
||||||
|
|
|
||||||
36
tests/web/theme.spec.ts
Normal file
36
tests/web/theme.spec.ts
Normal 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/);
|
||||||
|
});
|
||||||
|
|
@ -66,6 +66,7 @@ struct PIN
|
||||||
VECTOR2D pos; // world coords (IU)
|
VECTOR2D pos; // world coords (IU)
|
||||||
KIGFX::COLOR4D color;
|
KIGFX::COLOR4D color;
|
||||||
bool resolved = false;
|
bool resolved = false;
|
||||||
|
bool unread = false; // comments-ux 0001 C: accent ring
|
||||||
};
|
};
|
||||||
|
|
||||||
inline long long nowMs()
|
inline long long nowMs()
|
||||||
|
|
@ -384,7 +385,7 @@ struct CORE
|
||||||
for( const PIN& pin : pins )
|
for( const PIN& pin : pins )
|
||||||
{
|
{
|
||||||
KIGFX::COLOR4D color = peerColor( style, pin.name, pin.color );
|
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() );
|
view->Update( overlay.get() );
|
||||||
|
|
@ -532,9 +533,9 @@ struct CORE
|
||||||
scheduleRedraw();
|
scheduleRedraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** kicadCollabSetPins (0005): comment pin dots — `{pins:[{id,name,x,y,
|
/** kicadCollabSetPins (0005): comment pins — `{pins:[{id,name,x,y,
|
||||||
* color,resolved}]}`, world IU coords resolved by the TS side from the
|
* color,resolved,unread}]}`, world IU coords resolved by the TS side
|
||||||
* ydoc anchors. Snapshot semantics like setRemote. */
|
* from the ydoc anchors. Snapshot semantics like setRemote. */
|
||||||
void setPins( const std::string& aJson )
|
void setPins( const std::string& aJson )
|
||||||
{
|
{
|
||||||
json j = json::parse( aJson, nullptr, /*allow_exceptions*/ false );
|
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.pos = VECTOR2D( p.value( "x", 0.0 ), p.value( "y", 0.0 ) );
|
||||||
pin.color = parsePeerColor( p.value( "color", "" ) );
|
pin.color = parsePeerColor( p.value( "color", "" ) );
|
||||||
pin.resolved = p.value( "resolved", false );
|
pin.resolved = p.value( "resolved", false );
|
||||||
|
pin.unread = p.value( "unread", false );
|
||||||
parsed.push_back( std::move( pin ) );
|
parsed.push_back( std::move( pin ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
#include <view/view_overlay.h>
|
#include <view/view_overlay.h>
|
||||||
#include <nlohmann/json.hpp>
|
#include <nlohmann/json.hpp>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
@ -75,12 +76,20 @@ struct STYLE
|
||||||
std::string fixedColor;
|
std::string fixedColor;
|
||||||
std::vector<std::string> palette;
|
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 pinRadiusPx = 9.0;
|
||||||
double pinRingPx = 3.0;
|
double pinRingPx = 4.0;
|
||||||
double pinRingAlpha = 1.0;
|
double pinRingAlpha = 0.9;
|
||||||
double pinFillAlpha = 1.0;
|
double pinFillAlpha = 0.9;
|
||||||
double pinResolvedAlpha = 0.3;
|
double pinResolvedAlpha = 0.3;
|
||||||
|
std::string pinUnreadRingColor = "#ffb020"; // unread accent ring ("" = plain white)
|
||||||
|
|
||||||
// ── cross-app "ghost" selection (0006) ────────────────────────────────
|
// ── cross-app "ghost" selection (0006) ────────────────────────────────
|
||||||
// A peer's selection in the OTHER editor (eeschema symbol ⇄ pcbnew
|
// 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.pinRadiusPx = j.value( "pinRadiusPx", aStyle.pinRadiusPx );
|
||||||
aStyle.pinRingPx = j.value( "pinRingPx", aStyle.pinRingPx );
|
aStyle.pinRingPx = j.value( "pinRingPx", aStyle.pinRingPx );
|
||||||
aStyle.pinRingAlpha = j.value( "pinRingAlpha", aStyle.pinRingAlpha );
|
aStyle.pinRingAlpha = j.value( "pinRingAlpha", aStyle.pinRingAlpha );
|
||||||
aStyle.pinFillAlpha = j.value( "pinFillAlpha", aStyle.pinFillAlpha );
|
aStyle.pinFillAlpha = j.value( "pinFillAlpha", aStyle.pinFillAlpha );
|
||||||
aStyle.pinResolvedAlpha = j.value( "pinResolvedAlpha", aStyle.pinResolvedAlpha );
|
aStyle.pinResolvedAlpha = j.value( "pinResolvedAlpha", aStyle.pinResolvedAlpha );
|
||||||
|
aStyle.pinUnreadRingColor = j.value( "pinUnreadRingColor", aStyle.pinUnreadRingColor );
|
||||||
|
|
||||||
aStyle.xselAlphaScale = j.value( "xselAlphaScale", aStyle.xselAlphaScale );
|
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
|
/** Comment pin. Draw onto the CHIPS overlay: at the shapes depth an
|
||||||
* earlier-painted selection fill would reject the dot's fragments (the 0005
|
* earlier-painted selection fill would reject the pin's fragments (the 0005
|
||||||
* "drawn last sits above" comment had it backwards — later fragments LOSE). */
|
* "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,
|
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 fillAlpha = aResolved ? aS.pinResolvedAlpha : aS.pinFillAlpha;
|
||||||
double ringAlpha = aResolved ? aS.pinRingAlpha * 0.4 : aS.pinRingAlpha;
|
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->SetIsStroke( true );
|
||||||
aOv->SetIsFill( true );
|
aOv->SetIsFill( true );
|
||||||
aOv->SetFillColor( aColor.WithAlpha( fillAlpha ) );
|
aOv->SetFillColor( aColor.WithAlpha( fillAlpha ) );
|
||||||
aOv->SetStrokeColor( KIGFX::COLOR4D( 1, 1, 1, ringAlpha ) );
|
aOv->SetStrokeColor( ring );
|
||||||
aOv->SetLineWidth( aS.pinRingPx * aPx );
|
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
|
} // namespace pcbjam_presence
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@
|
||||||
#include "collab_common.h"
|
#include "collab_common.h"
|
||||||
#include "collab_presence_core.h"
|
#include "collab_presence_core.h"
|
||||||
#include "collab_presence_style.h"
|
#include "collab_presence_style.h"
|
||||||
|
#include "pcbjam_theme.h"
|
||||||
#include "pcbjam_libs_reload.h"
|
#include "pcbjam_libs_reload.h"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
|
||||||
|
|
@ -1658,6 +1659,18 @@ void schCollabSetStyle( std::string aJson )
|
||||||
presenceCore().setStyle( 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 +
|
// Tuner helper: a VARIED demo-selection set for the current sheet — smallest +
|
||||||
// largest symbol and two bundles of wires (net-ish), mirroring pcbnew's
|
// largest symbol and two bundles of wires (net-ish), mirroring pcbnew's
|
||||||
// pcbCollabTestDemoSet so the style preview shows the range of shapes.
|
// pcbCollabTestDemoSet so the style preview shows the range of shapes.
|
||||||
|
|
@ -2027,6 +2040,9 @@ EMSCRIPTEN_BINDINGS(eeschema) {
|
||||||
// Follow-user (collab-presence 0008).
|
// Follow-user (collab-presence 0008).
|
||||||
function("kicadCollabFitViewport", &schCollabFitViewport);
|
function("kicadCollabFitViewport", &schCollabFitViewport);
|
||||||
function("kicadCollabSetStyle", &schCollabSetStyle);
|
function("kicadCollabSetStyle", &schCollabSetStyle);
|
||||||
|
// Live color-theme switch (comments-ux 0002 F4).
|
||||||
|
function("kicadSetColorTheme", &schSetColorTheme);
|
||||||
|
function("kicadSetDarkChrome", &schSetDarkChrome);
|
||||||
function("kicadCollabTestListItems", &schCollabTestListItems);
|
function("kicadCollabTestListItems", &schCollabTestListItems);
|
||||||
function("kicadCollabTestDemoSet", &schCollabTestDemoSet);
|
function("kicadCollabTestDemoSet", &schCollabTestDemoSet);
|
||||||
function("kicadCollabGetViewport", &schCollabGetViewport);
|
function("kicadCollabGetViewport", &schCollabGetViewport);
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,9 @@ void pcbCollabReleaseSelection( std::string aUuidsJson, std::string aHold
|
||||||
std::string pcbCollabTestGetLocked();
|
std::string pcbCollabTestGetLocked();
|
||||||
std::string pcbCollabTestSelectFirst();
|
std::string pcbCollabTestSelectFirst();
|
||||||
bool pcbCollabTestClearSelection();
|
bool pcbCollabTestClearSelection();
|
||||||
|
// Live color-theme switch (comments-ux 0002 F4).
|
||||||
|
void pcbSetColorTheme( std::string aTheme );
|
||||||
|
void pcbSetDarkChrome( bool aDark );
|
||||||
|
|
||||||
bool schEditorActive();
|
bool schEditorActive();
|
||||||
int schLibsSymbolUsage( std::string aLibNickname, std::string aSymbolName );
|
int schLibsSymbolUsage( std::string aLibNickname, std::string aSymbolName );
|
||||||
|
|
@ -101,6 +104,8 @@ void schCollabSetViewport( double aCx, double aCy );
|
||||||
// Follow-user (collab-presence 0008).
|
// Follow-user (collab-presence 0008).
|
||||||
void schCollabFitViewport( double aCx, double aCy, double aHalfW, double aHalfH );
|
void schCollabFitViewport( double aCx, double aCy, double aHalfW, double aHalfH );
|
||||||
void schCollabSetStyle( std::string aJson );
|
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 schCollabTestListItems( int aCount );
|
||||||
std::string schCollabTestDemoSet();
|
std::string schCollabTestDemoSet();
|
||||||
std::string schCollabGetViewport();
|
std::string schCollabGetViewport();
|
||||||
|
|
@ -388,6 +393,21 @@ static void collabSetStyle( std::string aJson )
|
||||||
pcbEditorActive() ? pcbCollabSetStyle( aJson ) : schCollabSetStyle( 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 )
|
static std::string collabTestListItems( int aCount )
|
||||||
{
|
{
|
||||||
return pcbEditorActive() ? pcbCollabTestListItems( aCount ) : schCollabTestListItems( aCount );
|
return pcbEditorActive() ? pcbCollabTestListItems( aCount ) : schCollabTestListItems( aCount );
|
||||||
|
|
@ -490,6 +510,9 @@ EMSCRIPTEN_BINDINGS(kicad_editor) {
|
||||||
function("kicadCollabSetRemote", &collabSetRemote);
|
function("kicadCollabSetRemote", &collabSetRemote);
|
||||||
function("kicadCollabSetPins", &collabSetPins);
|
function("kicadCollabSetPins", &collabSetPins);
|
||||||
function("kicadCollabSetViewport", &collabSetViewport);
|
function("kicadCollabSetViewport", &collabSetViewport);
|
||||||
|
// Live color-theme switch (comments-ux 0002 F4).
|
||||||
|
function("kicadSetColorTheme", &setColorTheme);
|
||||||
|
function("kicadSetDarkChrome", &setDarkChrome);
|
||||||
// Follow-user (collab-presence 0008).
|
// Follow-user (collab-presence 0008).
|
||||||
function("kicadCollabFitViewport", &collabFitViewport);
|
function("kicadCollabFitViewport", &collabFitViewport);
|
||||||
function("kicadCollabSetStyle", &collabSetStyle);
|
function("kicadCollabSetStyle", &collabSetStyle);
|
||||||
|
|
|
||||||
101
wasm/bindings/pcbjam_theme.h
Normal file
101
wasm/bindings/pcbjam_theme.h
Normal 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__
|
||||||
|
|
@ -53,6 +53,7 @@
|
||||||
#include "collab_common.h"
|
#include "collab_common.h"
|
||||||
#include "collab_presence_core.h"
|
#include "collab_presence_core.h"
|
||||||
#include "collab_presence_style.h"
|
#include "collab_presence_style.h"
|
||||||
|
#include "pcbjam_theme.h"
|
||||||
#include "pcbjam_libs_reload.h"
|
#include "pcbjam_libs_reload.h"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
|
|
@ -1603,6 +1604,18 @@ void pcbCollabSetStyle( std::string aJson )
|
||||||
presenceCore().setStyle( 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 +
|
// Tuner helper: a VARIED demo-selection set — labeled uuid groups (smallest +
|
||||||
// largest footprint, the two busiest nets' track segments) so the style
|
// largest footprint, the two busiest nets' track segments) so the style
|
||||||
// preview shows the real range of shapes instead of two overlapping items.
|
// preview shows the real range of shapes instead of two overlapping items.
|
||||||
|
|
@ -2351,6 +2364,9 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
|
||||||
// Follow-user (collab-presence 0008).
|
// Follow-user (collab-presence 0008).
|
||||||
function("kicadCollabFitViewport", &pcbCollabFitViewport);
|
function("kicadCollabFitViewport", &pcbCollabFitViewport);
|
||||||
function("kicadCollabSetStyle", &pcbCollabSetStyle);
|
function("kicadCollabSetStyle", &pcbCollabSetStyle);
|
||||||
|
// Live color-theme switch (comments-ux 0002 F4).
|
||||||
|
function("kicadSetColorTheme", &pcbSetColorTheme);
|
||||||
|
function("kicadSetDarkChrome", &pcbSetDarkChrome);
|
||||||
function("kicadCollabTestListItems", &pcbCollabTestListItems);
|
function("kicadCollabTestListItems", &pcbCollabTestListItems);
|
||||||
function("kicadCollabTestDemoSet", &pcbCollabTestDemoSet);
|
function("kicadCollabTestDemoSet", &pcbCollabTestDemoSet);
|
||||||
function("kicadCollabGetViewport", &pcbCollabGetViewport);
|
function("kicadCollabGetViewport", &pcbCollabGetViewport);
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,12 @@ export async function buildApp(): Promise<import("fastify").FastifyInstance> {
|
||||||
}
|
}
|
||||||
return { status: 200 as const, body: await walk(PROJECT_DIR) };
|
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 }) => {
|
listLibs: async ({ headers, query }) => {
|
||||||
const owner = userOf(headers);
|
const owner = userOf(headers);
|
||||||
// Origins filtered by item kind (?kind); user libs are kind-agnostic
|
// 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
30
web/pnpm-lock.yaml
generated
|
|
@ -77,6 +77,12 @@ importers:
|
||||||
|
|
||||||
standalone:
|
standalone:
|
||||||
dependencies:
|
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':
|
'@hocuspocus/provider':
|
||||||
specifier: ^4.1.1
|
specifier: ^4.1.1
|
||||||
version: 4.1.1(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31)
|
version: 4.1.1(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31)
|
||||||
|
|
@ -107,6 +113,9 @@ importers:
|
||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
emoji-mart:
|
||||||
|
specifier: ^5.6.0
|
||||||
|
version: 5.6.0
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.469.0
|
specifier: ^0.469.0
|
||||||
version: 0.469.0(react@18.3.1)
|
version: 0.469.0(react@18.3.1)
|
||||||
|
|
@ -258,6 +267,15 @@ packages:
|
||||||
'@cloudflare/workers-types@4.20260610.1':
|
'@cloudflare/workers-types@4.20260610.1':
|
||||||
resolution: {integrity: sha512-Mk/f3lUygeIHzQ4HnJjU/JvGg/kllgp9gISty9nylHE/2M2MFeKO+hgAKSgiPpmwUbuhewdYGgqFGgT/ADK0/g==}
|
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':
|
'@esbuild/aix-ppc64@0.25.12':
|
||||||
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
|
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -1254,6 +1272,9 @@ packages:
|
||||||
electron-to-chromium@1.5.364:
|
electron-to-chromium@1.5.364:
|
||||||
resolution: {integrity: sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==}
|
resolution: {integrity: sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==}
|
||||||
|
|
||||||
|
emoji-mart@5.6.0:
|
||||||
|
resolution: {integrity: sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==}
|
||||||
|
|
||||||
es-errors@1.3.0:
|
es-errors@1.3.0:
|
||||||
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
|
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -2090,6 +2111,13 @@ snapshots:
|
||||||
|
|
||||||
'@cloudflare/workers-types@4.20260610.1': {}
|
'@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':
|
'@esbuild/aix-ppc64@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -2819,6 +2847,8 @@ snapshots:
|
||||||
|
|
||||||
electron-to-chromium@1.5.364: {}
|
electron-to-chromium@1.5.364: {}
|
||||||
|
|
||||||
|
emoji-mart@5.6.0: {}
|
||||||
|
|
||||||
es-errors@1.3.0: {}
|
es-errors@1.3.0: {}
|
||||||
|
|
||||||
es-module-lexer@1.7.0: {}
|
es-module-lexer@1.7.0: {}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,25 @@
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
<title>PCBJam - KiCad demo</title>
|
<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
|
<meta
|
||||||
name="description"
|
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."
|
content="Early-access alpha of PCBJam — KiCad running in your browser. Open the example boards or your own KiCad project. No install, nothing uploaded."
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@emoji-mart/data": "^1.2.1",
|
||||||
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@hocuspocus/provider": "^4.1.1",
|
"@hocuspocus/provider": "^4.1.1",
|
||||||
"@pcbjam/shared": "workspace:*",
|
"@pcbjam/shared": "workspace:*",
|
||||||
"@pcbjam/sync-client": "workspace:*",
|
"@pcbjam/sync-client": "workspace:*",
|
||||||
|
|
@ -25,6 +27,7 @@
|
||||||
"@ts-rest/core": "^3.52.1",
|
"@ts-rest/core": "^3.52.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"emoji-mart": "^5.6.0",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,24 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { overlayRowClass } from "@/components/OverlayMenu";
|
import { overlayRowClass } from "@/components/OverlayMenu";
|
||||||
import type { CommentAnchor } from "@pcbjam/shared";
|
import {
|
||||||
import { Eye, EyeOff, List, MessageSquarePlus, X } from "lucide-react";
|
threadMentionsUnread,
|
||||||
|
threadUnreadCount,
|
||||||
|
type Collaborator,
|
||||||
|
type CommentAnchor,
|
||||||
|
} from "@pcbjam/shared";
|
||||||
|
import {
|
||||||
|
CheckCheck,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
List,
|
||||||
|
MessageSquarePlus,
|
||||||
|
Plus,
|
||||||
|
SmilePlus,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
screenToWorld,
|
screenToWorld,
|
||||||
worldToScreen,
|
worldToScreen,
|
||||||
|
|
@ -10,6 +26,16 @@ import {
|
||||||
type ResolvedThread,
|
type ResolvedThread,
|
||||||
type ViewportState,
|
type ViewportState,
|
||||||
} from "@/wasm/collab/comments";
|
} 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
|
* 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_THRESHOLD_PX = 4;
|
||||||
const DRAG_SYNC_MS = 60;
|
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({
|
export function CommentLayer({
|
||||||
controller,
|
controller,
|
||||||
viewport,
|
viewport,
|
||||||
currentUser,
|
currentUser,
|
||||||
menuSlot,
|
menuSlot,
|
||||||
|
onUnreadChange,
|
||||||
|
mentionPeers,
|
||||||
}: {
|
}: {
|
||||||
controller: CommentsController;
|
controller: CommentsController;
|
||||||
viewport: ViewportState | null;
|
viewport: ViewportState | null;
|
||||||
|
|
@ -87,11 +121,35 @@ export function CommentLayer({
|
||||||
* into it while the menu is open; null (menu closed) renders neither.
|
* into it while the menu is open; null (menu closed) renders neither.
|
||||||
* Pins, popovers, composer and the click catcher stay canvas-anchored. */
|
* Pins, popovers, composer and the click catcher stay canvas-anchored. */
|
||||||
menuSlot: HTMLElement | null;
|
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 [threads, setThreads] = React.useState<ResolvedThread[]>(controller.threads());
|
||||||
const [mode, setMode] = React.useState(false);
|
const [mode, setMode] = React.useState(false);
|
||||||
const [openId, setOpenId] = React.useState<string | null>(null);
|
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 [showResolved, setShowResolved] = React.useState(false);
|
||||||
const [hidden, setHidden] = React.useState(!controller.pinsVisible());
|
const [hidden, setHidden] = React.useState(!controller.pinsVisible());
|
||||||
const [draft, setDraft] = React.useState<{ anchor: CommentAnchor; css: { x: number; y: number } } | null>(null);
|
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);
|
return controller.subscribe(setThreads);
|
||||||
}, [controller]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
const measure = () => setGlRect(glCanvasRect());
|
const measure = () => setGlRect(glCanvasRect());
|
||||||
measure();
|
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;
|
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
|
// 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.
|
// view bounds) — fall back to a centered popover rather than rendering none.
|
||||||
|
const openAnchorCss = open ? toCss(open.world) : null;
|
||||||
const openCss = open
|
const openCss = open
|
||||||
? (toCss(open.world) ?? { x: window.innerWidth / 2 - 150, y: 120 })
|
? (openAnchorCss ? toBodyCss(openAnchorCss) : { x: window.innerWidth / 2 - 150, y: 120 })
|
||||||
: null;
|
: null;
|
||||||
const visibleThreads = threads.filter((t) => showResolved || !t.resolved);
|
const visibleThreads = threads.filter((t) => showResolved || !t.resolved);
|
||||||
const pinThreads = hidden ? [] : visibleThreads;
|
const pinThreads = hidden ? [] : visibleThreads;
|
||||||
|
|
@ -247,11 +346,11 @@ export function CommentLayer({
|
||||||
setMode((m) => !m);
|
setMode((m) => !m);
|
||||||
setDraft(null);
|
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>{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" : ""}
|
{mode ? "Esc" : ""}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -261,11 +360,22 @@ export function CommentLayer({
|
||||||
aria-pressed={panel}
|
aria-pressed={panel}
|
||||||
title="Show every comment in this file"
|
title="Show every comment in this file"
|
||||||
onClick={() => setPanel((p) => !p)}
|
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" />
|
<List size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
|
||||||
<span>{panel ? "Hide list" : "Show list"}</span>
|
<span>{panel ? "Close comments panel" : "Open comments panel"}</span>
|
||||||
<span className="ml-auto text-[10px] text-white/40">
|
<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}
|
{threads.length}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -278,62 +388,14 @@ export function CommentLayer({
|
||||||
className={overlayRowClass}
|
className={overlayRowClass}
|
||||||
>
|
>
|
||||||
{hidden ? (
|
{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>
|
<span>{hidden ? "Show pins" : "Hide pins"}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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,
|
menuSlot,
|
||||||
)
|
)
|
||||||
|
|
@ -343,6 +405,33 @@ export function CommentLayer({
|
||||||
<>
|
<>
|
||||||
{menuUi}
|
{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. */}
|
{/* Comment-mode click catcher over the drawing area only. */}
|
||||||
{mode && glRect && (
|
{mode && glRect && (
|
||||||
<div
|
<div
|
||||||
|
|
@ -355,8 +444,16 @@ export function CommentLayer({
|
||||||
|
|
||||||
{/* Pin hit/drag targets (the visual dot is GAL — these are the DOM halves). */}
|
{/* Pin hit/drag targets (the visual dot is GAL — these are the DOM halves). */}
|
||||||
{pinThreads.map((t) => {
|
{pinThreads.map((t) => {
|
||||||
const css = drag?.id === t.id ? drag.css : toCss(t.world);
|
const anchorCss = drag?.id === t.id ? drag.css : toCss(t.world);
|
||||||
if (!css) return null;
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
|
|
@ -366,11 +463,36 @@ export function CommentLayer({
|
||||||
onPointerDown={onPinPointerDown(t)}
|
onPointerDown={onPinPointerDown(t)}
|
||||||
onPointerMove={onPinPointerMove}
|
onPointerMove={onPinPointerMove}
|
||||||
onPointerUp={onPinPointerUp(t)}
|
onPointerUp={onPinPointerUp(t)}
|
||||||
className={`absolute z-30 -translate-x-1/2 -translate-y-1/2 rounded-full ${
|
className={`group absolute z-30 -translate-x-1/2 -translate-y-1/2 ${
|
||||||
drag?.id === t.id ? "cursor-grabbing ring-2 ring-white" : "cursor-grab hover:ring-2 hover:ring-white/70"
|
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 && (
|
{draft && (
|
||||||
<Composer
|
<Composer
|
||||||
css={draft.css}
|
css={draft.css}
|
||||||
|
getCandidates={getMentionCandidates}
|
||||||
onCancel={() => setDraft(null)}
|
onCancel={() => setDraft(null)}
|
||||||
onSubmit={(body) => {
|
onSubmit={(body, mentions) => {
|
||||||
const id = controller.create(draft.anchor, body);
|
const id = controller.create(draft.anchor, body, mentions);
|
||||||
setDraft(null);
|
setDraft(null);
|
||||||
setOpenId(id);
|
setOpenId(id);
|
||||||
}}
|
}}
|
||||||
|
|
@ -394,6 +517,7 @@ export function CommentLayer({
|
||||||
css={openCss}
|
css={openCss}
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
controller={controller}
|
controller={controller}
|
||||||
|
getCandidates={getMentionCandidates}
|
||||||
onClose={() => setOpenId(null)}
|
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({
|
function Composer({
|
||||||
css,
|
css,
|
||||||
|
getCandidates,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: {
|
}: {
|
||||||
css: { x: number; y: number };
|
css: { x: number; y: number };
|
||||||
onSubmit: (body: string) => void;
|
getCandidates: () => Promise<Collaborator[]>;
|
||||||
|
onSubmit: (body: string, mentions: string[]) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [body, setBody] = React.useState("");
|
const [body, setBody] = React.useState("");
|
||||||
|
const acceptedRef = React.useRef<Set<string>>(new Set());
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
if (body.trim()) onSubmit(body.trim());
|
if (body.trim()) onSubmit(body.trim(), presentMentions(body, acceptedRef.current));
|
||||||
else onCancel();
|
else onCancel();
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid="comment-composer"
|
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) }}
|
style={{ left: Math.min(css.x + 12, window.innerWidth - 280), top: Math.min(css.y, window.innerHeight - 120) }}
|
||||||
>
|
>
|
||||||
<textarea
|
<MentionInput
|
||||||
|
multiline
|
||||||
autoFocus
|
autoFocus
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(e) => setBody(e.target.value)}
|
onChange={setBody}
|
||||||
onKeyDown={(e) => {
|
onMention={(slug) => acceptedRef.current.add(slug)}
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
onSubmit={submit}
|
||||||
e.preventDefault();
|
getCandidates={getCandidates}
|
||||||
submit();
|
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"
|
||||||
}}
|
|
||||||
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"
|
|
||||||
/>
|
/>
|
||||||
<div className="mt-1 flex justify-end gap-2">
|
<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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<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({
|
function ThreadPopover({
|
||||||
thread,
|
thread,
|
||||||
css,
|
css,
|
||||||
currentUser,
|
currentUser,
|
||||||
controller,
|
controller,
|
||||||
|
getCandidates,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
thread: ResolvedThread;
|
thread: ResolvedThread;
|
||||||
css: { x: number; y: number };
|
css: { x: number; y: number };
|
||||||
currentUser: string;
|
currentUser: string;
|
||||||
controller: CommentsController;
|
controller: CommentsController;
|
||||||
|
getCandidates: () => Promise<Collaborator[]>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [reply, setReply] = React.useState("");
|
const [reply, setReply] = React.useState("");
|
||||||
const [editing, setEditing] = React.useState<{ id: string; body: string } | null>(null);
|
const [editing, setEditing] = React.useState<{ id: string; body: string } | null>(null);
|
||||||
|
const replyMentionsRef = React.useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
const sendReply = () => {
|
const sendReply = () => {
|
||||||
if (reply.trim()) {
|
if (reply.trim()) {
|
||||||
controller.reply(thread.id, reply.trim());
|
controller.reply(thread.id, reply.trim(), presentMentions(reply, replyMentionsRef.current));
|
||||||
setReply("");
|
setReply("");
|
||||||
|
replyMentionsRef.current = new Set();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -482,14 +1002,14 @@ function ThreadPopover({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid="comment-popover"
|
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={{
|
style={{
|
||||||
left: Math.min(css.x + 16, window.innerWidth - 300),
|
left: Math.min(css.x + 16, window.innerWidth - 300),
|
||||||
top: Math.min(css.y - 8, window.innerHeight - 260),
|
top: Math.min(css.y - 8, window.innerHeight - 260),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between px-3 py-2">
|
<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.detached && "detached pin · "}
|
||||||
{thread.resolved ? "resolved" : "open"}
|
{thread.resolved ? "resolved" : "open"}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -497,7 +1017,7 @@ function ThreadPopover({
|
||||||
<button
|
<button
|
||||||
data-testid="comment-resolve"
|
data-testid="comment-resolve"
|
||||||
onClick={() => controller.setResolved(thread.id, !thread.resolved)}
|
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"}
|
{thread.resolved ? "Reopen" : "Resolve"}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -514,13 +1034,13 @@ function ThreadPopover({
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</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} />
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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) => (
|
{thread.messages.map((m) => (
|
||||||
<div key={m.id} data-testid="comment-message" className="group px-3 py-2 text-xs">
|
<div key={m.id} data-testid="comment-message" className="group px-3 py-2 text-xs">
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
|
|
@ -531,7 +1051,7 @@ function ThreadPopover({
|
||||||
>
|
>
|
||||||
{authorLabel(m).text}
|
{authorLabel(m).text}
|
||||||
</span>
|
</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" : ""}
|
{timeAgo(m.createdAt)} ago{m.editedAt ? " · edited" : ""}
|
||||||
</span>
|
</span>
|
||||||
{m.author === currentUser && (
|
{m.author === currentUser && (
|
||||||
|
|
@ -539,7 +1059,7 @@ function ThreadPopover({
|
||||||
<button
|
<button
|
||||||
data-testid="comment-edit"
|
data-testid="comment-edit"
|
||||||
onClick={() => setEditing({ id: m.id, body: m.body })}
|
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
|
edit
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -568,26 +1088,34 @@ function ThreadPopover({
|
||||||
setEditing(null);
|
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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-white/10 p-2">
|
<div className="border-t border-black/10 p-2 dark:border-white/10">
|
||||||
<input
|
<MentionInput
|
||||||
data-testid="comment-reply"
|
testId="comment-reply"
|
||||||
value={reply}
|
value={reply}
|
||||||
onChange={(e) => setReply(e.target.value)}
|
onChange={setReply}
|
||||||
onKeyDown={(e) => {
|
onMention={(slug) => replyMentionsRef.current.add(slug)}
|
||||||
if (e.key === "Enter") sendReply();
|
onSubmit={sendReply}
|
||||||
}}
|
getCandidates={getCandidates}
|
||||||
placeholder="Reply…"
|
placeholder="Reply… (@ to mention)"
|
||||||
className="w-full rounded bg-white/10 px-2 py-1.5 text-xs text-white placeholder-white/40 outline-none"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
108
web/standalone/src/components/EmojiPicker.tsx
Normal file
108
web/standalone/src/components/EmojiPicker.tsx
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
206
web/standalone/src/components/MentionInput.tsx
Normal file
206
web/standalone/src/components/MentionInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Users } from "lucide-react";
|
import { Users } from "lucide-react";
|
||||||
|
import { useDraggablePanel } from "@/components/useDraggablePanel";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unified overlay menu (collab-presence 0010): the single circular icon that
|
* 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. */
|
* reads as a list; the hover/active states are the only affordance needed. */
|
||||||
export const overlayRowClass =
|
export const overlayRowClass =
|
||||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs " +
|
"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 " +
|
"text-neutral-800 transition-colors hover:bg-black/5 " +
|
||||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-white/40";
|
"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. */
|
/** A labelled group. `label` is omitted for the first/unnamed group. */
|
||||||
export function OverlayMenuSection({
|
export function OverlayMenuSection({
|
||||||
|
|
@ -44,9 +46,9 @@ export function OverlayMenuSection({
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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 && (
|
{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}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -57,50 +59,32 @@ export function OverlayMenuSection({
|
||||||
|
|
||||||
const POS_KEY = "pcbjam:overlay-menu-pos";
|
const POS_KEY = "pcbjam:overlay-menu-pos";
|
||||||
const FAB_SIZE = 36;
|
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({
|
export function OverlayMenu({
|
||||||
badge,
|
badge,
|
||||||
|
unread = 0,
|
||||||
|
unreadMention = false,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
/** Peer count shown on the FAB (0 hides the badge). */
|
/** Peer count shown on the FAB (0 hides the badge). */
|
||||||
badge: number;
|
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. */
|
/** Panel sections, rendered top-to-bottom. Falsy children collapse. */
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = React.useState(false);
|
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 rootRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const dragRef = React.useRef<{
|
// Drag/clamp/persist behavior shared with the comments panel (0001 B) —
|
||||||
pointerId: number;
|
// including the always-onscreen restore guarantee.
|
||||||
startX: number;
|
const drag = useDraggablePanel({
|
||||||
startY: number;
|
storageKey: POS_KEY,
|
||||||
fabX: number;
|
handleWidth: FAB_SIZE,
|
||||||
fabY: number;
|
handleHeight: FAB_SIZE,
|
||||||
moved: boolean;
|
});
|
||||||
} | null>(null);
|
const pos = drag.pos;
|
||||||
|
|
||||||
// Esc closes (bubble phase, same etiquette as the comment layer — wx also
|
// Esc closes (bubble phase, same etiquette as the comment layer — wx also
|
||||||
// sees the key, matching how every other overlay treats Escape).
|
// sees the key, matching how every other overlay treats Escape).
|
||||||
|
|
@ -125,57 +109,16 @@ export function OverlayMenu({
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const onFabPointerDown = (e: React.PointerEvent) => {
|
const onFabPointerDown = (e: React.PointerEvent) => {
|
||||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
drag.onPointerDown(e, rootRef.current!.getBoundingClientRect());
|
||||||
const rect = rootRef.current!.getBoundingClientRect();
|
|
||||||
dragRef.current = {
|
|
||||||
pointerId: e.pointerId,
|
|
||||||
startX: e.clientX,
|
|
||||||
startY: e.clientY,
|
|
||||||
fabX: rect.x,
|
|
||||||
fabY: rect.y,
|
|
||||||
moved: false,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFabPointerMove = (e: React.PointerEvent) => {
|
const onFabPointerMove = (e: React.PointerEvent) => {
|
||||||
const d = dragRef.current;
|
// Dragging repositions; the click that follows reopens.
|
||||||
if (!d) return;
|
if (drag.onPointerMove(e)) setOpen(false);
|
||||||
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),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFabPointerUp = () => {
|
const onFabPointerUp = () => {
|
||||||
const d = dragRef.current;
|
if (!drag.onPointerUp()) setOpen((o) => !o);
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Default anchor: top-right (the old row's home). After a drag, explicit px.
|
// Default anchor: top-right (the old row's home). After a drag, explicit px.
|
||||||
|
|
@ -196,10 +139,11 @@ export function OverlayMenu({
|
||||||
onPointerDown={onFabPointerDown}
|
onPointerDown={onFabPointerDown}
|
||||||
onPointerMove={onFabPointerMove}
|
onPointerMove={onFabPointerMove}
|
||||||
onPointerUp={onFabPointerUp}
|
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
|
open
|
||||||
? "bg-sky-600 ring-sky-300/40"
|
? "bg-sky-600 text-white ring-sky-300/40"
|
||||||
: "bg-neutral-950/80 ring-white/15 backdrop-blur-sm hover:bg-neutral-900/90"
|
: "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" }}
|
style={{ touchAction: "none" }}
|
||||||
>
|
>
|
||||||
|
|
@ -212,21 +156,32 @@ export function OverlayMenu({
|
||||||
{badge}
|
{badge}
|
||||||
</span>
|
</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>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
data-testid="overlay-menu-panel"
|
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"
|
onLeftHalf ? "left-0" : "right-0"
|
||||||
} ${onTopHalf ? "top-11" : "bottom-11"}`}
|
} ${onTopHalf ? "top-11" : "bottom-11"}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between px-2 pt-0.5">
|
<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
|
Session
|
||||||
</span>
|
</span>
|
||||||
{badge > 0 && (
|
{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
|
{badge} {badge === 1 ? "other" : "others"} here
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ export function PresenceRoster({
|
||||||
: `${p.user.name} — on ${elsewhere}`
|
: `${p.user.name} — on ${elsewhere}`
|
||||||
}
|
}
|
||||||
className={`${overlayRowClass} ${
|
className={`${overlayRowClass} ${
|
||||||
followed ? "bg-white/10" : ""
|
followed ? "bg-black/10 dark:bg-white/10" : ""
|
||||||
} ${here ? "" : "cursor-default opacity-50 hover:bg-transparent"}`}
|
} ${here ? "" : "cursor-default opacity-50 hover:bg-transparent"}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
|
|
@ -101,17 +101,17 @@ export function PresenceRoster({
|
||||||
/>
|
/>
|
||||||
<span className="truncate">{p.user.name}</span>
|
<span className="truncate">{p.user.name}</span>
|
||||||
{!here && (
|
{!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}
|
on {elsewhere}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{here && followed && (
|
{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
|
<Eye size={12} /> Stop
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{here && !followed && followable(p) && (
|
{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
|
Follow
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -119,7 +119,7 @@ export function PresenceRoster({
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{peers.length > MAX_ROWS && (
|
{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
|
+{peers.length - MAX_ROWS} more
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { setPinRadiusPx } from "@/wasm/collab/pin-geometry";
|
||||||
import { PRESENCE_COLORS } from "@pcbjam/shared";
|
import { PRESENCE_COLORS } from "@pcbjam/shared";
|
||||||
import { Palette, X } from "lucide-react";
|
import { Palette, X } from "lucide-react";
|
||||||
|
|
||||||
|
|
@ -57,11 +58,13 @@ const DEFAULT_STYLE = {
|
||||||
cursorLabelChip: true,
|
cursorLabelChip: true,
|
||||||
fixedColor: "",
|
fixedColor: "",
|
||||||
palette: [] as string[],
|
palette: [] as string[],
|
||||||
|
pinShape: 1,
|
||||||
pinRadiusPx: 9,
|
pinRadiusPx: 9,
|
||||||
pinRingPx: 3,
|
pinRingPx: 4,
|
||||||
pinRingAlpha: 1,
|
pinRingAlpha: 0.9,
|
||||||
pinFillAlpha: 1,
|
pinFillAlpha: 0.9,
|
||||||
pinResolvedAlpha: 0.3,
|
pinResolvedAlpha: 0.3,
|
||||||
|
pinUnreadRingColor: "#ffb020",
|
||||||
};
|
};
|
||||||
|
|
||||||
type Style = typeof DEFAULT_STYLE;
|
type Style = typeof DEFAULT_STYLE;
|
||||||
|
|
@ -98,6 +101,7 @@ const SEL_SHAPES = [
|
||||||
"exact outline (pcb)",
|
"exact outline (pcb)",
|
||||||
];
|
];
|
||||||
const CURSOR_SHAPES = ["cross", "pointer", "circle + dot"];
|
const CURSOR_SHAPES = ["cross", "pointer", "circle + dot"];
|
||||||
|
const PIN_SHAPES = ["circle dot", "bubble (sharp corner)"];
|
||||||
const VPOS = ["top", "bottom"];
|
const VPOS = ["top", "bottom"];
|
||||||
const HPOS = ["start", "end", "center"];
|
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.
|
// Push on mount (restores a stored style after reload) + on every change.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
mod.kicadCollabSetStyle(JSON.stringify(style));
|
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 {
|
try {
|
||||||
localStorage.setItem(storeKey(tool), JSON.stringify(style));
|
localStorage.setItem(storeKey(tool), JSON.stringify(style));
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -298,9 +306,13 @@ export function PresenceTuner({ mod, tool }: { mod: TunerModule; tool: string })
|
||||||
<ColorsSection style={style} set={set} />
|
<ColorsSection style={style} set={set} />
|
||||||
|
|
||||||
<Section title="Comment pins">
|
<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="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 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)} />
|
<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)} />
|
<Range label="fill α" v={style.pinFillAlpha} min={0.2} max={1} step={0.05} onChange={(v) => set("pinFillAlpha", v)} />
|
||||||
</Section>
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ export function SourceChip({
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
title={descriptor.description}
|
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]}`} />
|
<Icon size={14} className={`shrink-0 ${MUTED_TONES[descriptor.kind]}`} />
|
||||||
{descriptor.label}
|
{descriptor.label}
|
||||||
|
|
|
||||||
23
web/standalone/src/components/ThemeToggle.tsx
Normal file
23
web/standalone/src/components/ThemeToggle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,7 @@ import {
|
||||||
type KicadDoc,
|
type KicadDoc,
|
||||||
type Tool,
|
type Tool,
|
||||||
} from "@pcbjam/shared";
|
} 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 {
|
import {
|
||||||
API_BASE_URL,
|
API_BASE_URL,
|
||||||
APP_URL,
|
APP_URL,
|
||||||
|
|
@ -31,6 +31,7 @@ import {
|
||||||
import { defaultFileName, newFileTemplate, withExtension } from "@/lib/new-file";
|
import { defaultFileName, newFileTemplate, withExtension } from "@/lib/new-file";
|
||||||
import { redirectTargetFor } from "@/lib/redirect";
|
import { redirectTargetFor } from "@/lib/redirect";
|
||||||
import { loadSessionIdentity } from "@/lib/session-identity";
|
import { loadSessionIdentity } from "@/lib/session-identity";
|
||||||
|
import { setTheme, useThemeValue } from "@/lib/theme";
|
||||||
import { bootKicadTool } from "@/wasm/boot";
|
import { bootKicadTool } from "@/wasm/boot";
|
||||||
import { resolveWasmBase } from "@/wasm/wasm-assets";
|
import { resolveWasmBase } from "@/wasm/wasm-assets";
|
||||||
import {
|
import {
|
||||||
|
|
@ -905,6 +906,21 @@ export function WasmTool({
|
||||||
const [commentsSlot, setCommentsSlot] = React.useState<HTMLDivElement | null>(null);
|
const [commentsSlot, setCommentsSlot] = React.useState<HTMLDivElement | null>(null);
|
||||||
const [viewportState, setViewportState] = React.useState<ViewportState | null>(null);
|
const [viewportState, setViewportState] = React.useState<ViewportState | null>(null);
|
||||||
const commentsRef = React.useRef<CommentsController | 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
|
// Dev-time presence style tuner (VITE_PRESENCE_TUNER=1) — set once the wasm
|
||||||
// exposes the style bridge, mounts the floating panel.
|
// exposes the style bridge, mounts the floating panel.
|
||||||
const [tunerMod, setTunerMod] = React.useState<TunerModule | null>(null);
|
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
|
comments (portal slot filled by CommentLayer), chrome toggle. It is
|
||||||
the one control that stays up in canvas-only (chrome-hidden) mode. */}
|
the one control that stays up in canvas-only (chrome-hidden) mode. */}
|
||||||
{ready && (
|
{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
|
{/* PEOPLE — who else is here, and whose view you're locked to. The
|
||||||
follow state lives on each person's own row (PresenceRoster), so
|
follow state lives on each person's own row (PresenceRoster), so
|
||||||
there is no separate "Following…" banner to keep in sync. */}
|
there is no separate "Following…" banner to keep in sync. */}
|
||||||
|
|
@ -1722,9 +1742,9 @@ export function WasmTool({
|
||||||
data-testid="view-only-pill"
|
data-testid="view-only-pill"
|
||||||
className={`${overlayRowClass} cursor-default`}
|
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>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
|
read-only
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1742,8 +1762,8 @@ export function WasmTool({
|
||||||
</OverlayMenuSection>
|
</OverlayMenuSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{setChromeFn !== null && !readOnly && (
|
<OverlayMenuSection label="View">
|
||||||
<OverlayMenuSection label="View">
|
{setChromeFn !== null && !readOnly && (
|
||||||
<button
|
<button
|
||||||
data-testid="chrome-toggle"
|
data-testid="chrome-toggle"
|
||||||
aria-pressed={chromeHidden}
|
aria-pressed={chromeHidden}
|
||||||
|
|
@ -1752,17 +1772,34 @@ export function WasmTool({
|
||||||
onClick={() => toggleChromeHidden()}
|
onClick={() => toggleChromeHidden()}
|
||||||
>
|
>
|
||||||
{chromeHidden ? (
|
{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>
|
<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}
|
{CHROME_HOTKEY_LABEL}
|
||||||
</kbd>
|
</kbd>
|
||||||
</button>
|
</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>
|
</OverlayMenu>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -1775,6 +1812,11 @@ export function WasmTool({
|
||||||
viewport={viewportState}
|
viewport={viewportState}
|
||||||
currentUser={presenceUser().id}
|
currentUser={presenceUser().id}
|
||||||
menuSlot={commentsSlot}
|
menuSlot={commentsSlot}
|
||||||
|
onUnreadChange={onCommentsUnread}
|
||||||
|
mentionPeers={peers.map((p) => ({
|
||||||
|
slug: p.user.id,
|
||||||
|
name: p.user.name || p.user.id,
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
36
web/standalone/src/components/useDraggablePanel.test.ts
Normal file
36
web/standalone/src/components/useDraggablePanel.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
172
web/standalone/src/components/useDraggablePanel.ts
Normal file
172
web/standalone/src/components/useDraggablePanel.ts
Normal 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 };
|
||||||
|
}
|
||||||
48
web/standalone/src/lib/emoji-quick.ts
Normal file
48
web/standalone/src/lib/emoji-quick.ts
Normal 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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
62
web/standalone/src/lib/mentions.ts
Normal file
62
web/standalone/src/lib/mentions.ts
Normal 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))];
|
||||||
|
}
|
||||||
68
web/standalone/src/lib/theme.test.ts
Normal file
68
web/standalone/src/lib/theme.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
81
web/standalone/src/lib/theme.ts
Normal file
81
web/standalone/src/lib/theme.ts
Normal 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");
|
||||||
|
}
|
||||||
|
|
@ -3,11 +3,17 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { initAnalytics } from "./lib/analytics";
|
import { initAnalytics } from "./lib/analytics";
|
||||||
|
import { initTheme } from "./lib/theme";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
// Privacy-friendly analytics (Plausible), only when VITE_PLAUSIBLE_SRC is set.
|
// Privacy-friendly analytics (Plausible), only when VITE_PLAUSIBLE_SRC is set.
|
||||||
initAnalytics();
|
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({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: { queries: { retry: false } },
|
defaultOptions: { queries: { retry: false } },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import * as React from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { type Tool, libPath, projectPath } from "@pcbjam/shared";
|
import { type Tool, libPath, projectPath } from "@pcbjam/shared";
|
||||||
|
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||||
import { FolderOpen, Library, Loader2, Package } from "lucide-react";
|
import { FolderOpen, Library, Loader2, Package } from "lucide-react";
|
||||||
import { useLibs } from "@/lib/api";
|
import { useLibs } from "@/lib/api";
|
||||||
import {
|
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">
|
<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
|
Early-access alpha
|
||||||
</span>
|
</span>
|
||||||
|
<ThemeToggle className="ml-auto" />
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mb-8 text-sm text-muted-foreground">
|
<p className="mb-8 text-sm text-muted-foreground">
|
||||||
{staticMode
|
{staticMode
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ import {
|
||||||
} from "./libs/source";
|
} from "./libs/source";
|
||||||
import { libUri, PCBJAM_LIB_MOUNT } from "./libs/uri";
|
import { libUri, PCBJAM_LIB_MOUNT } from "./libs/uri";
|
||||||
import { installTouchGestures } from "./touch-gestures";
|
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. */
|
/** The default user lib boot ensures exists, so there's a writable save target. */
|
||||||
const DEFAULT_USER_LIB_NAME = "My Symbols";
|
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`,
|
`${KICAD_CONFIG_DIR}/design-block-lib-table`,
|
||||||
"(design_block_lib_table\n (version 7)\n)\n",
|
"(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];
|
const preRun = [createCanvas, writeResources];
|
||||||
|
|
@ -438,7 +461,16 @@ async function doBoot(opts: BootOptions): Promise<void> {
|
||||||
|
|
||||||
w.Module = {
|
w.Module = {
|
||||||
thisProgram: TOOL_ARGV0[tool], // argv[0] for KiCad's DEBUG check
|
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
|
// Runtime frame selection: emscripten feeds these to main() as argv[1..], which
|
||||||
// single_top.cpp parses ("--frame=<token>") to open the requested editor frame
|
// 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
|
// 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: () => {},
|
monitorRunDependencies: () => {},
|
||||||
onRuntimeInitialized: () => {
|
onRuntimeInitialized: () => {
|
||||||
log("[boot] runtime initialized");
|
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;
|
const canvas = (w.Module as { canvas?: HTMLCanvasElement }).canvas;
|
||||||
if (canvas) canvas.style.display = "block";
|
if (canvas) canvas.style.display = "block";
|
||||||
onStatus("");
|
onStatus("");
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,14 @@ import {
|
||||||
field,
|
field,
|
||||||
kicadItemsMap,
|
kicadItemsMap,
|
||||||
listThreads,
|
listThreads,
|
||||||
|
markThreadSeen,
|
||||||
observeComments,
|
observeComments,
|
||||||
removeMessage,
|
removeMessage,
|
||||||
resolveAnchor,
|
resolveAnchor,
|
||||||
setThreadAnchor,
|
setThreadAnchor,
|
||||||
setThreadResolved,
|
setThreadResolved,
|
||||||
|
threadUnreadCount,
|
||||||
|
toggleReaction,
|
||||||
yToItemUnchecked,
|
yToItemUnchecked,
|
||||||
type CommentAnchor,
|
type CommentAnchor,
|
||||||
type CommentThread,
|
type CommentThread,
|
||||||
|
|
@ -91,8 +94,13 @@ export interface CommentsController {
|
||||||
/** Build an anchor for a world-pos click: nearest positioned item within
|
/** Build an anchor for a world-pos click: nearest positioned item within
|
||||||
* `maxDistIu` becomes the tracked anchor (+offset), else pos-only. */
|
* `maxDistIu` becomes the tracked anchor (+offset), else pos-only. */
|
||||||
anchorAt(world: { x: number; y: number }, maxDistIu: number): CommentAnchor;
|
anchorAt(world: { x: number; y: number }, maxDistIu: number): CommentAnchor;
|
||||||
create(anchor: CommentAnchor, body: string): string;
|
create(anchor: CommentAnchor, body: string, mentions?: string[]): string;
|
||||||
reply(threadId: string, body: string): void;
|
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;
|
edit(threadId: string, messageId: string, body: string): boolean;
|
||||||
remove(threadId: string, messageId: string): "removed" | "thread-deleted" | false;
|
remove(threadId: string, messageId: string): "removed" | "thread-deleted" | false;
|
||||||
setResolved(threadId: string, resolved: boolean): void;
|
setResolved(threadId: string, resolved: boolean): void;
|
||||||
|
|
@ -165,6 +173,7 @@ export function createComments(opts: {
|
||||||
y: t.world.y,
|
y: t.world.y,
|
||||||
color: colorFor(t.createdBy),
|
color: colorFor(t.createdBy),
|
||||||
resolved: t.resolved,
|
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 } };
|
return { pos: { x: world.x, y: world.y } };
|
||||||
},
|
},
|
||||||
create(anchor, body) {
|
create(anchor, body, mentions) {
|
||||||
return createThread(doc, {
|
return createThread(doc, {
|
||||||
anchor,
|
anchor,
|
||||||
author: user.id,
|
author: user.id,
|
||||||
authorName: user.name,
|
authorName: user.name,
|
||||||
authorEmail: user.email,
|
authorEmail: user.email,
|
||||||
body,
|
body,
|
||||||
|
mentions,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
reply(threadId, body) {
|
reply(threadId, body, mentions) {
|
||||||
addMessage(doc, threadId, {
|
addMessage(doc, threadId, {
|
||||||
author: user.id,
|
author: user.id,
|
||||||
authorName: user.name,
|
authorName: user.name,
|
||||||
authorEmail: user.email,
|
authorEmail: user.email,
|
||||||
body,
|
body,
|
||||||
|
mentions,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
markSeen(threadId) {
|
||||||
|
markThreadSeen(doc, threadId, user.id);
|
||||||
|
},
|
||||||
|
toggleReaction(threadId, messageId, emoji) {
|
||||||
|
toggleReaction(doc, threadId, messageId, user.id, emoji);
|
||||||
|
},
|
||||||
edit(threadId, messageId, body) {
|
edit(threadId, messageId, body) {
|
||||||
return editMessage(doc, threadId, messageId, body);
|
return editMessage(doc, threadId, messageId, body);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
48
web/standalone/src/wasm/collab/pin-geometry.ts
Normal file
48
web/standalone/src/wasm/collab/pin-geometry.ts
Normal 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 };
|
||||||
|
}
|
||||||
55
web/standalone/src/wasm/themes/pcbjam-dark.json
Normal file
55
web/standalone/src/wasm/themes/pcbjam-dark.json
Normal 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
|
||||||
Loading…
Reference in a new issue