Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,128 @@ body {
--tx-backdrop-overlay: rgba(0, 0, 0, 0.6);
}

:root[data-board-theme='dark'] {
--background: oklch(0.169 0.018 264.2);
--foreground: oklch(0.975 0.004 247.9);
--card: oklch(0.205 0.018 264.4);
--card-foreground: oklch(0.975 0.004 247.9);
--popover: oklch(0.205 0.018 264.4);
--popover-foreground: oklch(0.975 0.004 247.9);
--primary: oklch(0.929 0.013 255.508);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.255 0.016 264.3);
--secondary-foreground: oklch(0.975 0.004 247.9);
--muted: oklch(0.24 0.013 264.2);
--muted-foreground: oklch(0.77 0.018 255.7);
--accent: oklch(0.275 0.02 264.7);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216);
--border: rgba(255, 255, 255, 0.12);
--input: rgba(255, 255, 255, 0.14);
--ring: oklch(0.68 0.03 255.8);
--sidebar: oklch(0.213 0.02 264.6);
--sidebar-foreground: oklch(0.975 0.004 247.9);
--sidebar-primary: oklch(0.929 0.013 255.508);
--sidebar-primary-foreground: oklch(0.208 0.042 265.755);
--sidebar-accent: oklch(0.27 0.018 264.4);
--sidebar-accent-foreground: oklch(0.975 0.004 247.9);
--sidebar-border: rgba(255, 255, 255, 0.12);
--sidebar-ring: oklch(0.68 0.03 255.8);
}

:root[data-board-theme='soft'] {
--background: #f5f2ea;
--foreground: #2f2a24;
--card: #fcfaf4;
--card-foreground: #2f2a24;
--popover: #fcfaf4;
--popover-foreground: #2f2a24;
--primary: #2f2a24;
--primary-foreground: #fdfaf4;
--secondary: #ebe4d8;
--secondary-foreground: #2f2a24;
--muted: #eee7dc;
--muted-foreground: #6c6256;
--accent: #e5ddd0;
--accent-foreground: #2f2a24;
--border: #d8cebf;
--input: #ddd4c7;
--ring: #b8aa97;
}

:root[data-board-theme='retro'] {
--background: #f7efd9;
--foreground: #463823;
--card: #fff7e7;
--card-foreground: #463823;
--popover: #fff7e7;
--popover-foreground: #463823;
--primary: #463823;
--primary-foreground: #fff7e7;
--secondary: #eadcbc;
--secondary-foreground: #463823;
--muted: #f0e5c9;
--muted-foreground: #81694a;
--accent: #e7d6b0;
--accent-foreground: #463823;
--border: #d8c49d;
--input: #dfccab;
--ring: #b9965b;
}

:root[data-board-theme='starry'] {
--background: #0f2130;
--foreground: #edf6ff;
--card: #13293d;
--card-foreground: #edf6ff;
--popover: #13293d;
--popover-foreground: #edf6ff;
--primary: #d9ecff;
--primary-foreground: #0f2130;
--secondary: #183248;
--secondary-foreground: #edf6ff;
--muted: #162d42;
--muted-foreground: #aac1d4;
--accent: #1b3853;
--accent-foreground: #f5f9ff;
--destructive: #ff7b72;
--border: rgba(189, 213, 233, 0.18);
--input: rgba(189, 213, 233, 0.16);
--ring: #7fb4d6;
--sidebar: #143046;
--sidebar-foreground: #edf6ff;
--sidebar-primary: #d9ecff;
--sidebar-primary-foreground: #102739;
--sidebar-accent: #183a54;
--sidebar-accent-foreground: #edf6ff;
--sidebar-border: rgba(189, 213, 233, 0.18);
--sidebar-ring: #7fb4d6;
--tx-shadow-toolbar: 0 10px 30px -18px rgba(3, 10, 20, 0.72), 0 4px 12px -8px rgba(3, 10, 20, 0.54);
--tx-shadow-dropdown: 0 18px 45px -20px rgba(3, 10, 20, 0.76), 0 8px 18px -12px rgba(3, 10, 20, 0.58);
--tx-shadow-dialog: 0 24px 60px -26px rgba(3, 10, 20, 0.8), 0 10px 24px -14px rgba(3, 10, 20, 0.62);
--tx-backdrop-overlay: rgba(4, 12, 22, 0.7);
}

:root[data-board-theme='colorful'] {
--background: #effaff;
--foreground: #163049;
--card: #ffffff;
--card-foreground: #163049;
--popover: #ffffff;
--popover-foreground: #163049;
--primary: #163049;
--primary-foreground: #f5fcff;
--secondary: #d7f2fb;
--secondary-foreground: #163049;
--muted: #def5fc;
--muted-foreground: #55758f;
--accent: #caeefc;
--accent-foreground: #163049;
--border: #b9e4f6;
--input: #c7ebf8;
--ring: #77b6d6;
}

@layer base {
* {
@apply border-border outline-ring/50;
Expand All @@ -195,6 +317,13 @@ body {
@apply bg-background text-foreground;
}
}

:root[data-board-theme='dark'] .plait-board-container .plait-text-container,
:root[data-board-theme='dark'] .plait-board-container .slate-editable-container,
:root[data-board-theme='starry'] .plait-board-container .plait-text-container,
:root[data-board-theme='starry'] .plait-board-container .slate-editable-container {
color: #f8fafc;
}

@keyframes shimmer {
0% {
Expand Down
39 changes: 35 additions & 4 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useState, useEffect, Suspense, useCallback, useMemo } from 'react';
import { useState, useEffect, useLayoutEffect, Suspense, useCallback, useMemo } from 'react';
import dynamic from 'next/dynamic';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
import { BoardProvider } from '@/features/board/hooks/use-board-state';
Expand All @@ -18,6 +18,7 @@ import { useCollaborationState, useCollaborationSession } from '@thinkix/collabo
import { BoardLayoutSlots } from '@/features/board';
import { Sparkles } from 'lucide-react';
import { Button } from '@thinkix/ui';
import { getBoardThemeMode, isDarkBoardTheme } from '@thinkix/shared';

const BoardCanvas = dynamic(
() => import('@/features/board').then((mod) => mod.BoardCanvas),
Expand Down Expand Up @@ -87,6 +88,30 @@ function BoardAppContent() {
deleteBoard,
renameBoard
} = useBoardStore();

const boardThemeMode = useMemo(
() => getBoardThemeMode(currentBoard?.theme),
[currentBoard?.theme],
);
const boardUsesDarkShell = isDarkBoardTheme(boardThemeMode);

useLayoutEffect(() => {
const root = document.documentElement;
const previousTheme = root.getAttribute('data-board-theme');
const previousDark = root.classList.contains('dark');

root.setAttribute('data-board-theme', boardThemeMode);
root.classList.toggle('dark', boardUsesDarkShell);

return () => {
if (previousTheme) {
root.setAttribute('data-board-theme', previousTheme);
} else {
root.removeAttribute('data-board-theme');
}
root.classList.toggle('dark', previousDark);
};
}, [boardThemeMode, boardUsesDarkShell]);
Comment on lines +92 to +114

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid forcing the default theme before board hydration.

On the first render currentBoard is still null, so this computes the default theme and immediately writes it to document.documentElement. Persisted dark/starry boards will flash the wrong shell colors until initialize() finishes. Gate the root mutation until the board store is hydrated.

💡 Minimal guard
   useLayoutEffect(() => {
+    if (isLoading || !currentBoard) return;
+
     const root = document.documentElement;
     const previousTheme = root.getAttribute('data-board-theme');
     const previousDark = root.classList.contains('dark');
@@
-  }, [boardThemeMode, boardUsesDarkShell]);
+  }, [boardThemeMode, boardUsesDarkShell, currentBoard, isLoading]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const boardThemeMode = useMemo(
() => getBoardThemeMode(currentBoard?.theme),
[currentBoard?.theme],
);
const boardUsesDarkShell = isDarkBoardTheme(boardThemeMode);
useLayoutEffect(() => {
const root = document.documentElement;
const previousTheme = root.getAttribute('data-board-theme');
const previousDark = root.classList.contains('dark');
root.setAttribute('data-board-theme', boardThemeMode);
root.classList.toggle('dark', boardUsesDarkShell);
return () => {
if (previousTheme) {
root.setAttribute('data-board-theme', previousTheme);
} else {
root.removeAttribute('data-board-theme');
}
root.classList.toggle('dark', previousDark);
};
}, [boardThemeMode, boardUsesDarkShell]);
const boardThemeMode = useMemo(
() => getBoardThemeMode(currentBoard?.theme),
[currentBoard?.theme],
);
const boardUsesDarkShell = isDarkBoardTheme(boardThemeMode);
useLayoutEffect(() => {
if (isLoading || !currentBoard) return;
const root = document.documentElement;
const previousTheme = root.getAttribute('data-board-theme');
const previousDark = root.classList.contains('dark');
root.setAttribute('data-board-theme', boardThemeMode);
root.classList.toggle('dark', boardUsesDarkShell);
return () => {
if (previousTheme) {
root.setAttribute('data-board-theme', previousTheme);
} else {
root.removeAttribute('data-board-theme');
}
root.classList.toggle('dark', previousDark);
};
}, [boardThemeMode, boardUsesDarkShell, currentBoard, isLoading]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/page.tsx` around lines 92 - 114, The useLayoutEffect that writes
boardThemeMode and boardUsesDarkShell to document.documentElement runs before
the board store is hydrated and forces the default theme; guard that DOM
mutation by checking the board hydration flag (e.g., an isHydrated or
initialized boolean from the board store/initialize() result) and do nothing
until hydration completes. In practice, read the store's hydration state
alongside currentBoard, add it to the effect dependencies, and return early from
the useLayoutEffect if not hydrated so the root attribute/class is only set
after the board store is initialized.


const activeRoomId = roomFromUrl || currentBoard?.id || null;
const { isEnabled, enableCollaboration, disableCollaboration } = useCollaborationState(activeRoomId ?? undefined);
Expand Down Expand Up @@ -246,10 +271,15 @@ function BoardAppContent() {
if (isEnabled && activeRoomId) {
return (
<>
<Room roomId={activeRoomId} initialElements={currentBoard?.elements}>
<Room
roomId={activeRoomId}
initialElements={currentBoard?.elements}
initialTheme={currentBoard?.theme}
>
<CollaborativeBoard>
<div
className="relative w-screen h-screen overflow-hidden bg-background transition-[padding] duration-200"
className={`relative w-screen h-screen overflow-hidden bg-background transition-[padding] duration-200 ${boardUsesDarkShell ? 'dark' : ''}`}
data-board-theme={boardThemeMode}
style={{ paddingRight: agentOpen ? `${agentWidth}px` : 0 }}
>
<BoardCanvas boardData={currentBoard}>
Expand Down Expand Up @@ -282,7 +312,8 @@ function BoardAppContent() {
return (
<>
<div
className="relative w-screen h-screen overflow-hidden bg-background transition-[padding] duration-200"
className={`relative w-screen h-screen overflow-hidden bg-background transition-[padding] duration-200 ${boardUsesDarkShell ? 'dark' : ''}`}
data-board-theme={boardThemeMode}
style={{ paddingRight: agentOpen ? `${agentWidth}px` : 0 }}
>
<BoardCanvas boardData={currentBoard}>
Expand Down
72 changes: 66 additions & 6 deletions app/test/collaboration/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,14 @@ import {
} from '@thinkix/collaboration';
import { MockYjsProvider } from '@thinkix/collaboration/test-utils';
import { BoardLayoutSlots } from '@/features/board';
import { refreshGrid } from '@/features/board/grid';
import { getBoardThemeMode, isDarkBoardTheme } from '@thinkix/shared';

const MockCollaborativeRoom = ({ children, roomId, initialElements }: {
const MockCollaborativeRoom = ({ children, roomId, initialElements, initialTheme }: {
children: React.ReactNode;
roomId?: string;
initialElements?: BoardElement[];
initialTheme?: import('@plait/core').PlaitTheme;
}) => {
const [user] = useState(() => getOrCreateUser());

Expand All @@ -37,6 +40,7 @@ const MockCollaborativeRoom = ({ children, roomId, initialElements }: {
user={user}
roomId={roomId}
initialElements={initialElements}
initialTheme={initialTheme}
>
{children}
</MockYjsProvider>
Expand All @@ -60,8 +64,11 @@ function hashElements(elements: BoardElement[]): string {

function MockCollaborationBridge() {
const { board } = useBoardState();
const { elements, isLocalChange, setElements, syncState } = useYjsCollaboration();
const { elements, theme, isLocalChange, setElements, syncState } = useYjsCollaboration();
const { syncBus } = useSyncBus();
const currentBoardId = useBoardStore((state) => state.currentBoard?.id);
const currentBoardTheme = useBoardStore((state) => state.currentBoard?.theme);
const updateBoardTheme = useBoardStore((state) => state.updateBoardTheme);
const lastElementsHashRef = useRef('');
const isSyncingRef = useRef(false);

Expand All @@ -77,6 +84,25 @@ function MockCollaborationBridge() {
syncBus.emitRemoteChange(elements);
}, [board, elements, isLocalChange, syncBus]);

useEffect(() => {
if (!board || !theme) return;

if (getBoardThemeMode(board.theme) !== getBoardThemeMode(theme)) {
// eslint-disable-next-line react-hooks/immutability -- Plait board model requires direct mutation
board.theme = theme;
refreshGrid(board);
}
}, [board, theme]);

useEffect(() => {
if (!currentBoardId || !theme) return;
if (currentBoardTheme && getBoardThemeMode(currentBoardTheme) === getBoardThemeMode(theme)) {
return;
}

void updateBoardTheme(currentBoardId, theme);
}, [currentBoardId, currentBoardTheme, theme, updateBoardTheme]);

useEffect(() => {
const unsubscribe = syncBus.subscribeToLocalChanges((localElements: BoardElement[]) => {
if (!syncState.isConnected) {
Expand Down Expand Up @@ -141,7 +167,7 @@ function TestBoardAppContent() {
const pathname = usePathname();
const roomFromUrl = searchParams.get('room');

const {
const {
initialize,
boards,
currentBoard,
Expand All @@ -152,6 +178,30 @@ function TestBoardAppContent() {
renameBoard
} = useBoardStore();

const boardThemeMode = useMemo(
() => getBoardThemeMode(currentBoard?.theme),
[currentBoard?.theme],
);
const boardUsesDarkShell = isDarkBoardTheme(boardThemeMode);

useEffect(() => {
const root = document.documentElement;
const previousTheme = root.getAttribute('data-board-theme');
const previousDark = root.classList.contains('dark');

root.setAttribute('data-board-theme', boardThemeMode);
root.classList.toggle('dark', boardUsesDarkShell);

return () => {
if (previousTheme) {
root.setAttribute('data-board-theme', previousTheme);
} else {
root.removeAttribute('data-board-theme');
}
root.classList.toggle('dark', previousDark);
};
}, [boardThemeMode, boardUsesDarkShell]);

const activeRoomId = roomFromUrl || currentBoard?.id || null;
const { isEnabled, enableCollaboration, disableCollaboration } = useCollaborationState(activeRoomId ?? undefined);

Expand Down Expand Up @@ -281,8 +331,15 @@ function TestBoardAppContent() {
if (isEnabled && activeRoomId) {
return (
<>
<MockCollaborativeRoom roomId={activeRoomId} initialElements={currentBoard?.elements}>
<main className="w-screen h-screen overflow-hidden bg-background">
<MockCollaborativeRoom
roomId={activeRoomId}
initialElements={currentBoard?.elements}
initialTheme={currentBoard?.theme}
>
<main
className={`w-screen h-screen overflow-hidden bg-background ${boardUsesDarkShell ? 'dark' : ''}`}
data-board-theme={boardThemeMode}
>
<BoardCanvas boardData={currentBoard}>
<MockCollaborationBridge />
<BoardToolbar />
Expand All @@ -306,7 +363,10 @@ function TestBoardAppContent() {

return (
<>
<main className="w-screen h-screen overflow-hidden bg-background">
<main
className={`w-screen h-screen overflow-hidden bg-background ${boardUsesDarkShell ? 'dark' : ''}`}
data-board-theme={boardThemeMode}
>
<BoardCanvas boardData={currentBoard}>
<BoardToolbar />
<BoardLayoutSlots
Expand Down
Loading
Loading