diff --git a/apps/web/src/routes/Board.tsx b/apps/web/src/routes/Board.tsx
index d7abd70..51464e4 100644
--- a/apps/web/src/routes/Board.tsx
+++ b/apps/web/src/routes/Board.tsx
@@ -4,16 +4,18 @@ import {
CanvasStage,
useAssetStore,
usePageStore,
+ useSettingsStore,
useShapeStore,
} from "@notux/canvas";
import { useTheme } from "@notux/ui";
import { AppMenu } from "../features/canvas/AppMenu";
+import { CollabBar } from "../features/canvas/CollabBar";
import { Dock } from "../features/canvas/Dock";
+import { FollowPill } from "../features/canvas/FollowPill";
import { SaveStatus } from "../features/canvas/SaveStatus";
import { SelectionInspector } from "../features/canvas/SelectionInspector";
import { useIdentity } from "../features/canvas/useIdentity";
import { ensureBoardOwnership } from "../features/board/boardOwnership";
-import { BoardAccessIndicator } from "../features/board/BoardAccessIndicator";
import { getSupabase } from "../lib/supabase";
export default function Board() {
@@ -43,6 +45,7 @@ export default function Board() {
.then(() => {
// Seed/migrate the page list against the IndexedDB-hydrated doc.
usePageStore.getState().initPages(boardId);
+ useSettingsStore.getState().initSettings(boardId);
setReady(true);
// Claim board ownership when signed in — gates named snapshots.
void ensureBoardOwnership(client, boardId).then((r) => {
@@ -82,12 +85,14 @@ export default function Board() {
-
+
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
index 035d6ae..664d629 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -178,43 +178,154 @@ a {
text-decoration: underline;
}
-.board-access {
+/* ----- Collaboration bar (top-right) ------------------------------------- */
+
+.collab-bar {
position: fixed;
top: 16px;
right: 16px;
+ z-index: 30;
display: flex;
align-items: center;
- gap: 8px;
- padding: 4px 6px 4px 12px;
- border-radius: 999px;
- background: var(--glass-tint, rgba(30, 30, 30, 0.75));
- border: 1px solid var(--glass-stroke, rgba(255, 255, 255, 0.12));
- backdrop-filter: blur(12px);
- font-size: 12px;
- z-index: 20;
+ gap: 4px;
+ padding: 4px;
+ border-radius: 12px;
+}
+
+.collab-bar__avatars {
+ display: flex;
+ align-items: center;
+ padding: 0 2px;
+}
+
+.collab-avatar {
+ appearance: none;
+ border: 2px solid var(--bg-1);
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border-radius: 50%;
+ display: grid;
+ place-items: center;
+ overflow: hidden;
+ color: #fff;
+ font-size: 11px;
+ font-weight: 700;
+ flex: none;
+ margin-left: -6px;
+ transition: transform 140ms ease;
}
-.board-access__label {
- color: var(--fg-1, rgba(255, 255, 255, 0.7));
+.collab-bar__avatars > :first-child {
+ margin-left: 0;
}
-.board-access__toggle {
- background: rgba(255, 255, 255, 0.12);
- border: 1px solid var(--glass-stroke, rgba(255, 255, 255, 0.12));
- color: var(--fg-0, #fff);
+.collab-avatar:is(button) {
+ cursor: pointer;
+}
+
+.collab-avatar:is(button):hover {
+ transform: translateY(-2px) scale(1.08);
+ z-index: 1;
+}
+
+.collab-avatar--spotlight {
+ box-shadow: 0 0 0 2px var(--accent);
+}
+
+.collab-avatar--overflow {
+ background: var(--segment-bg);
+ color: var(--fg-1);
+}
+
+.collab-avatar__img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
+.collab-bar__btn {
+ appearance: none;
+ border: 0;
+ background: transparent;
+ color: var(--fg-0);
+ width: 34px;
+ height: 34px;
+ border-radius: 8px;
+ display: grid;
+ place-items: center;
+ cursor: pointer;
+ transition: background 140ms ease, color 140ms ease;
+}
+
+.collab-bar__btn:hover {
+ background: var(--glass-hover);
+}
+
+.collab-bar__btn--active {
+ background: var(--accent);
+ color: #fff;
+}
+
+.collab-bar__divider {
+ width: 1px;
+ height: 20px;
+ margin: 0 2px;
+ background: var(--glass-stroke);
+}
+
+/* "Following X" pill, shown under the dock while follow mode is active. */
+.follow-pill {
+ position: fixed;
+ top: 76px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 30;
+ appearance: none;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 14px;
border-radius: 999px;
- padding: 4px 10px;
- font-size: 12px;
+ border: 1.5px solid var(--accent);
+ background: var(--popover-bg);
+ color: var(--fg-0);
+ font: inherit;
+ font-size: 13px;
+ font-weight: 600;
+ backdrop-filter: blur(18px) saturate(180%);
+ -webkit-backdrop-filter: blur(18px) saturate(180%);
+ box-shadow: var(--shadow-lg);
cursor: pointer;
}
-.board-access__toggle:hover {
- background: rgba(255, 255, 255, 0.2);
+.follow-pill:hover {
+ background: var(--glass-hover);
+}
+
+@keyframes pill-in {
+ from {
+ opacity: 0;
+ transform: translateX(-50%) translateY(-6px);
+ }
}
-.board-access__toggle:disabled {
- opacity: 0.5;
- cursor: default;
+.follow-pill {
+ animation: pill-in 200ms cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .follow-pill {
+ animation: none;
+ }
+}
+
+.follow-pill__dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex: none;
}
.board {
@@ -331,6 +442,20 @@ a {
background: var(--glass-hover);
}
+.selection-inspector__btn--active {
+ background: color-mix(in srgb, var(--accent) 24%, transparent);
+ border-color: var(--accent);
+}
+
+/* Inline segmented button group (e.g. text alignment). */
+.selection-inspector__seg {
+ display: grid;
+ grid-auto-flow: column;
+ grid-auto-columns: 1fr;
+ gap: 6px;
+ flex: 1;
+}
+
.selection-inspector__lock {
appearance: none;
border: 1px solid var(--glass-stroke);
diff --git a/packages/canvas/src/CanvasStage.tsx b/packages/canvas/src/CanvasStage.tsx
index 7727833..24b90a4 100644
--- a/packages/canvas/src/CanvasStage.tsx
+++ b/packages/canvas/src/CanvasStage.tsx
@@ -4,7 +4,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Stage } from "react-konva";
import { newAuthorId } from "./ids";
import { useAwareness } from "./hooks/useAwareness";
+import { useFollowViewport } from "./hooks/useFollowViewport";
import { useUndoManager } from "./hooks/useUndoManager";
+import { useFollowStore } from "./store/followStore";
import { BackgroundLayer } from "./layers/BackgroundLayer";
import { OverlayLayer } from "./layers/OverlayLayer";
import { PresenceLayer } from "./layers/PresenceLayer";
@@ -13,7 +15,14 @@ import { TransformLayer } from "./layers/TransformLayer";
import { useAssetStore } from "./store/assetStore";
import { useCommandStore } from "./store/commandStore";
import { useDraftStore } from "./store/draftStore";
+import { usePrefsStore } from "./store/prefsStore";
+import { useSettingsStore } from "./store/settingsStore";
import { DEFAULT_PAGE_ID } from "./store/pageStore";
+import { resolveInkColor } from "./theme/adaptiveInk";
+import {
+ effectiveBackground,
+ isDarkBackground,
+} from "./theme/backgroundPresets";
import { useShapeStore } from "./store/shapeStore";
import { useTextEditStore } from "./store/textEditStore";
import { useToolStore } from "./store/toolStore";
@@ -27,9 +36,9 @@ import { screenToWorld, zoomAt } from "./viewport/Viewport";
interface Props {
boardId: string;
pageId?: string;
- // Changing this (the app's active theme) re-renders the Konva layers so they
- // re-read CSS-variable colors via cssVar(). The value itself is unused.
- theme?: string;
+ // The app's active theme. Picks the background-preset variant and re-renders
+ // the Konva layers so they re-read CSS-variable colors via cssVar().
+ theme?: "light" | "dark";
}
const ZOOM_PER_WHEEL_PIXEL = 0.0015;
@@ -59,7 +68,7 @@ function isTypingTarget(t: EventTarget | null): boolean {
export function CanvasStage({
boardId: _boardId,
pageId = DEFAULT_PAGE_ID,
- theme: _theme,
+ theme = "light",
}: Props) {
const containerRef = useRef
(null);
const stageRef = useRef(null);
@@ -72,11 +81,17 @@ export function CanvasStage({
const { undo, redo, canUndo, canRedo } = useUndoManager();
const awareness = useAwareness();
+ const showRemoteCursors = usePrefsStore((s) => s.showRemoteCursors);
+ const background = useSettingsStore((s) => s.background);
+ // Smart ink keys off the paper the shapes actually sit on, not the UI theme.
+ const darkCanvas = isDarkBackground(effectiveBackground(background, theme));
// Register undo/redo + zoom so the app menu (outside the canvas) can drive
// them. Re-registers when handlers or canvas size change.
const sizeRef = useRef(size);
sizeRef.current = size;
+ const viewportRef = useRef(viewport);
+ viewportRef.current = viewport;
useEffect(() => {
const zoomBy = (factor: number) =>
setViewport((v) =>
@@ -93,6 +108,49 @@ export function CanvasStage({
});
}, [undo, redo, canUndo, canRedo]);
+ // ----- Spotlight / follow mode -------------------------------------------
+ const spotlighting = useFollowStore((s) => s.spotlighting);
+ const followingClientID = useFollowStore((s) => s.followingClientID);
+ useFollowViewport({ awareness, setViewport, sizeRef });
+
+ // Publish our spotlight flag so peers auto-follow while we present.
+ useEffect(() => {
+ if (!awareness) return;
+ awareness.setLocalStateField("spotlight", spotlighting ? true : null);
+ }, [awareness, spotlighting]);
+
+ // Publish our view (world-space screen centre + scale) at ~10 Hz so peers
+ // can follow us. Skipped while we ourselves follow someone — a follower's
+ // view is never consumed and would just flood the channel.
+ const viewTimerRef = useRef(null);
+ useEffect(() => {
+ if (!awareness || followingClientID !== null) return;
+ if (viewTimerRef.current !== null) return; // trailing-edge throttle
+ viewTimerRef.current = window.setTimeout(() => {
+ viewTimerRef.current = null;
+ const v = viewportRef.current;
+ const s = sizeRef.current;
+ awareness.setLocalStateField("view", {
+ cx: (s.w / 2 - v.x) / v.scale,
+ cy: (s.h / 2 - v.y) / v.scale,
+ scale: v.scale,
+ });
+ }, 100);
+ }, [awareness, viewport, size, followingClientID]);
+ useEffect(() => {
+ return () => {
+ if (viewTimerRef.current !== null) window.clearTimeout(viewTimerRef.current);
+ };
+ }, []);
+
+ // Any manual viewport gesture breaks follow mode (and suppresses auto
+ // re-follow until that presenter re-toggles their spotlight).
+ const breakFollow = useCallback(() => {
+ if (useFollowStore.getState().followingClientID !== null) {
+ useFollowStore.getState().stopFollowing(true);
+ }
+ }, []);
+
// Publish the local cursor (world coords) to awareness, throttled to one
// update per animation frame so rapid pointer moves don't flood the channel.
const cursorRafRef = useRef(null);
@@ -283,6 +341,10 @@ export function CanvasStage({
return () => {
if (cursorRafRef.current !== null) cancelAnimationFrame(cursorRafRef.current);
awareness?.setLocalStateField("cursor", null);
+ awareness?.setLocalStateField("view", null);
+ awareness?.setLocalStateField("spotlight", null);
+ useFollowStore.getState().stopFollowing();
+ useFollowStore.getState().setSpotlighting(false);
};
}, [awareness]);
@@ -308,6 +370,7 @@ export function CanvasStage({
const onPointerDown = useCallback(
(evt: React.PointerEvent) => {
const native = evt.nativeEvent;
+ breakFollow();
if (native.button === 1 || spaceHeld || tool === "hand") {
evt.preventDefault();
panRef.current = { active: true, lastX: native.clientX, lastY: native.clientY };
@@ -331,7 +394,7 @@ export function CanvasStage({
evt.currentTarget.setPointerCapture(native.pointerId);
toolRef.current.onPointerDown(pointerToToolPoint(native), buildToolContext());
},
- [spaceHeld, tool, viewport, buildToolContext],
+ [spaceHeld, tool, viewport, buildToolContext, breakFollow],
);
const onPointerMove = useCallback(
@@ -378,6 +441,7 @@ export function CanvasStage({
const onWheel = useCallback((evt: React.WheelEvent) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
+ breakFollow();
const sx = evt.clientX - rect.left;
const sy = evt.clientY - rect.top;
if (evt.ctrlKey || evt.metaKey) {
@@ -390,7 +454,7 @@ export function CanvasStage({
y: v.y - evt.deltaY * PAN_PER_WHEEL_PIXEL,
}));
}
- }, []);
+ }, [breakFollow]);
// File drag-and-drop import. dragover must preventDefault for drop to fire.
const onDragOver = useCallback((evt: React.DragEvent) => {
@@ -447,6 +511,7 @@ export function CanvasStage({
font: hit.font,
size: hit.size,
color: hit.color,
+ align: hit.align ?? "left",
});
} else if (hit && hit.kind === "sticky" && !hit.locked) {
const pad = 14;
@@ -459,6 +524,7 @@ export function CanvasStage({
font: "-apple-system, system-ui, sans-serif",
size: hit.fontSize ?? 18,
color: "#1c1c1e",
+ align: hit.align ?? "center",
});
}
},
@@ -519,10 +585,27 @@ export function CanvasStage({
scaleY={viewport.scale}
listening
>
-
-
-
-
+
+
+
+ {showRemoteCursors && (
+
+ )}
);
diff --git a/packages/canvas/src/TextEditorOverlay.tsx b/packages/canvas/src/TextEditorOverlay.tsx
index c0d9ef6..b3ff3a6 100644
--- a/packages/canvas/src/TextEditorOverlay.tsx
+++ b/packages/canvas/src/TextEditorOverlay.tsx
@@ -3,6 +3,7 @@ import type { YText } from "@notux/types";
import { newShapeId } from "./ids";
import { useShapeStore } from "./store/shapeStore";
import { useTextEditStore } from "./store/textEditStore";
+import { resolveInkColor } from "./theme/adaptiveInk";
import { useToolStore } from "./store/toolStore";
import type { ViewportState } from "./viewport/Viewport";
@@ -10,6 +11,8 @@ interface Props {
viewport: ViewportState;
pageId: string;
authorId: string;
+ // Matches the canvas's adaptive-ink rendering so editing is WYSIWYG.
+ darkCanvas?: boolean;
}
// HTML