diff --git a/app/globals.css b/app/globals.css index 484649e..18259d4 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; @@ -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% { diff --git a/app/page.tsx b/app/page.tsx index cfed61f..f86481d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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'; @@ -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), @@ -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]); const activeRoomId = roomFromUrl || currentBoard?.id || null; const { isEnabled, enableCollaboration, disableCollaboration } = useCollaborationState(activeRoomId ?? undefined); @@ -246,10 +271,15 @@ function BoardAppContent() { if (isEnabled && activeRoomId) { return ( <> - +
@@ -282,7 +312,8 @@ function BoardAppContent() { return ( <>
diff --git a/app/test/collaboration/page.tsx b/app/test/collaboration/page.tsx index c340257..4381fec 100644 --- a/app/test/collaboration/page.tsx +++ b/app/test/collaboration/page.tsx @@ -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()); @@ -37,6 +40,7 @@ const MockCollaborativeRoom = ({ children, roomId, initialElements }: { user={user} roomId={roomId} initialElements={initialElements} + initialTheme={initialTheme} > {children} @@ -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); @@ -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) { @@ -141,7 +167,7 @@ function TestBoardAppContent() { const pathname = usePathname(); const roomFromUrl = searchParams.get('room'); - const { + const { initialize, boards, currentBoard, @@ -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); @@ -281,8 +331,15 @@ function TestBoardAppContent() { if (isEnabled && activeRoomId) { return ( <> - -
+ +
@@ -306,7 +363,10 @@ function TestBoardAppContent() { return ( <> -
+
-
+
@@ -478,13 +480,17 @@ export function AgentPane({ -
+
{messages.length === 0 ? (
{DEFAULT_SUGGESTIONS.map((suggestion) => ( - + = { + default: { + fill: '#f5f5f5', + text: LIGHT_BOARD_INK.text, + }, + dark: { + fill: '#1f2937', + text: DARK_BOARD_INK.text, + }, + soft: { + fill: '#ffffff', + text: LIGHT_BOARD_INK.text, + }, + retro: { + fill: '#153d5d', + text: '#ffffff', + }, + starry: { + fill: '#17344b', + text: DARK_BOARD_INK.text, + }, + colorful: { + fill: '#e0f7ff', + text: '#0f172a', + }, +}; + +const KNOWN_ROOT_FILLS = new Set( + Object.values(ROOT_THEME_OVERRIDES).map((theme) => theme.fill.toLowerCase()), +); + +export function getThinkixMindRootFill(theme: BoardThemeMode): string { + return ROOT_THEME_OVERRIDES[theme].fill; +} + +export function isThinkixMindRootFill(fill: string): boolean { + return KNOWN_ROOT_FILLS.has(fill.trim().toLowerCase()); +} + +export const THINKIX_MIND_THEME_COLORS: MindThemeColor[] = MindThemeColors.map((themeColor) => { + const mode = themeColor.mode as BoardThemeMode; + const override = ROOT_THEME_OVERRIDES[mode] ?? ROOT_THEME_OVERRIDES.default; + + return { + ...themeColor, + rootFill: override.fill, + rootTextColor: override.text, + }; +}); diff --git a/features/board/utils/theme-elements.ts b/features/board/utils/theme-elements.ts new file mode 100644 index 0000000..99e3a9f --- /dev/null +++ b/features/board/utils/theme-elements.ts @@ -0,0 +1,253 @@ +import type { PlaitElement } from '@plait/core'; +import { + getBoardInkColors, + getBoardFillColors, + LIGHT_BOARD_INK, + DARK_BOARD_INK, + LIGHT_BOARD_FILLS, + DARK_BOARD_FILLS, + STARRY_BOARD_FILLS, + type BoardThemeMode, +} from '@thinkix/shared'; +import { getThinkixMindRootFill, isThinkixMindRootFill } from './mind-theme'; + +type RichTextLeaf = { + text?: string; + color?: string; + [key: string]: unknown; +}; + +type SlateTextValue = { + children?: RichTextLeaf[]; + [key: string]: unknown; +}; + +type ArrowTextValue = { + text?: SlateTextValue; + [key: string]: unknown; +}; + +type ThemeSyncElement = PlaitElement & { + strokeColor?: string; + color?: string; + fill?: string; + shape?: string; + isRoot?: boolean; + text?: SlateTextValue; + texts?: ArrowTextValue[]; + children?: PlaitElement[]; + data?: { + topic?: SlateTextValue; + [key: string]: unknown; + }; +}; + +function normalizeColorToken(color: string): string { + const value = color.trim().toLowerCase(); + + if (value === '#000') { + return '#000000'; + } + + if (value === '#fff') { + return '#ffffff'; + } + + const rgbMatch = value.match( + /^rgba?\(\s*(\d{1,3})[\s,]+(\d{1,3})[\s,]+(\d{1,3})(?:[\s,\/]+([01]?(?:\.\d+)?))?\s*\)$/, + ); + if (rgbMatch) { + const [, r, g, b, alpha] = rgbMatch; + if (alpha && Number(alpha) === 0) { + return value; + } + + const toHex = (channel: string) => + Number(channel).toString(16).padStart(2, '0'); + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; + } + + return value; +} + +function remapDefaultInk(color: string, theme: BoardThemeMode, kind: 'stroke' | 'text') { + const normalized = normalizeColorToken(color); + const lightValue = kind === 'stroke' ? LIGHT_BOARD_INK.stroke : LIGHT_BOARD_INK.text; + const darkValue = kind === 'stroke' ? DARK_BOARD_INK.stroke : DARK_BOARD_INK.text; + const target = getBoardInkColors(theme)[kind]; + + if (normalized === lightValue || normalized === darkValue) { + return target; + } + + return color; +} + +function remapDefaultFill(fill: string, theme: BoardThemeMode) { + const normalized = normalizeColorToken(fill); + const target = getBoardFillColors(theme); + + switch (normalized) { + case LIGHT_BOARD_FILLS.surface: + case DARK_BOARD_FILLS.surface: + case STARRY_BOARD_FILLS.surface: + return target.surface; + case LIGHT_BOARD_FILLS.mutedSurface: + case DARK_BOARD_FILLS.mutedSurface: + case STARRY_BOARD_FILLS.mutedSurface: + return target.mutedSurface; + case LIGHT_BOARD_FILLS.accentSurface: + case DARK_BOARD_FILLS.accentSurface: + case STARRY_BOARD_FILLS.accentSurface: + return target.accentSurface; + case LIGHT_BOARD_FILLS.noteSurface: + case DARK_BOARD_FILLS.noteSurface: + case STARRY_BOARD_FILLS.noteSurface: + return target.noteSurface; + default: + return fill; + } +} + +function isRootMindElement(element: ThemeSyncElement): boolean { + return element.type === 'mindmap' && element.isRoot === true; +} + +function syncSlateTextValue(text: SlateTextValue | undefined, theme: BoardThemeMode) { + if (!text || !Array.isArray(text.children)) { + return text; + } + + let changed = false; + const targetInk = getBoardInkColors(theme); + const nextChildren = text.children.map((child) => { + if (!child || typeof child !== 'object' || typeof child.text !== 'string') { + return child; + } + + if (!child.color) { + if (targetInk.text === LIGHT_BOARD_INK.text) { + return child; + } + + changed = true; + return { ...child, color: targetInk.text }; + } + + const nextColor = remapDefaultInk(child.color, theme, 'text'); + if (nextColor === child.color) { + return child; + } + + changed = true; + return { ...child, color: nextColor }; + }); + + return changed ? { ...text, children: nextChildren } : text; +} + +function syncElementForBoardTheme( + element: ThemeSyncElement, + theme: BoardThemeMode, +): ThemeSyncElement { + let changed = false; + const next: ThemeSyncElement = { ...element }; + const targetInk = getBoardInkColors(theme); + const targetRootFill = isRootMindElement(element) ? getThinkixMindRootFill(theme) : null; + + if (typeof element.strokeColor === 'string') { + const strokeColor = remapDefaultInk(element.strokeColor, theme, 'stroke'); + if (strokeColor !== element.strokeColor) { + next.strokeColor = strokeColor; + changed = true; + } + } else if ( + (element.type === 'geometry' && element.shape !== 'text') || + element.type === 'arrow' || + element.type === 'line' + ) { + next.strokeColor = targetInk.stroke; + changed = true; + } + + if (targetRootFill && (typeof element.fill !== 'string' || isThinkixMindRootFill(element.fill))) { + if (element.fill !== targetRootFill) { + next.fill = targetRootFill; + changed = true; + } + } else if (typeof element.fill === 'string') { + const fill = remapDefaultFill(element.fill, theme); + if (fill !== element.fill) { + next.fill = fill; + changed = true; + } + } + + if (typeof element.color === 'string') { + const color = remapDefaultInk(element.color, theme, 'text'); + if (color !== element.color) { + next.color = color; + changed = true; + } + } else if (targetInk.text !== LIGHT_BOARD_INK.text && element.type === 'text') { + next.color = targetInk.text; + changed = true; + } + + const text = syncSlateTextValue(element.text, theme); + if (text !== element.text) { + next.text = text; + changed = true; + } + + const topic = syncSlateTextValue(element.data?.topic, theme); + if (element.data && topic !== element.data.topic) { + next.data = { ...element.data, topic }; + changed = true; + } + + if (Array.isArray(element.texts)) { + let textListChanged = false; + const texts = element.texts.map((arrowText) => { + const syncedText = syncSlateTextValue(arrowText.text, theme); + if (syncedText === arrowText.text) { + return arrowText; + } + + textListChanged = true; + return { ...arrowText, text: syncedText }; + }); + + if (textListChanged) { + next.texts = texts; + changed = true; + } + } + + if (Array.isArray(element.children) && element.children.length > 0) { + const children = syncElementsForBoardTheme(element.children, theme); + if (children !== element.children) { + next.children = children; + changed = true; + } + } + + return changed ? next : element; +} + +export function syncElementsForBoardTheme( + elements: PlaitElement[], + theme: BoardThemeMode, +): PlaitElement[] { + let changed = false; + + const nextElements = elements.map((element) => { + const synced = syncElementForBoardTheme(element as ThemeSyncElement, theme); + if (synced !== element) { + changed = true; + } + return synced; + }); + + return changed ? nextElements : elements; +} diff --git a/features/collaboration/components/collaborate-button.tsx b/features/collaboration/components/collaborate-button.tsx index 1a7e02c..4d2d71a 100644 --- a/features/collaboration/components/collaborate-button.tsx +++ b/features/collaboration/components/collaborate-button.tsx @@ -12,10 +12,14 @@ interface CollaborateButtonProps { export function CollaborateButton({ onClick }: CollaborateButtonProps) { return (
) : previewElements.length > 0 ? ( - + ) : (
Enter markdown to preview diff --git a/features/dialogs/components/MermaidToBoardDialog.tsx b/features/dialogs/components/MermaidToBoardDialog.tsx index 96ca7f5..6a730b4 100644 --- a/features/dialogs/components/MermaidToBoardDialog.tsx +++ b/features/dialogs/components/MermaidToBoardDialog.tsx @@ -21,12 +21,16 @@ import { SelectTrigger, SelectValue, } from '@thinkix/ui'; -import { focusAndRevealElements, insertElementsSafely } from '@/features/board/utils'; +import { + focusAndRevealElements, + insertElementsSafely, + syncElementsForBoardTheme, +} from '@/features/board/utils'; import { parseMermaidToBoard } from '@thinkix/mermaid-to-thinkix'; import posthog from 'posthog-js'; import { Board, Wrapper } from '@plait-board/react-board'; import { addTextRenderer } from '@/features/board/plugins/add-text-renderer'; -import { createLogger } from '@thinkix/shared'; +import { createLogger, getBoardThemeMode } from '@thinkix/shared'; const logger = createLogger('dialog:mermaid-to-board'); @@ -161,7 +165,12 @@ function MermaidToBoardDialog({ open, onOpenChange }: MermaidToBoardDialogProps) setIsLoading(true); try { const result = await parseMermaidToBoard(deferredText); - setElements(result.elements as PlaitElement[]); + setElements( + syncElementsForBoardTheme( + result.elements as PlaitElement[], + getBoardThemeMode(board.theme), + ), + ); setWarnings(result.warnings || []); setError(null); } catch (err) { @@ -191,7 +200,7 @@ function MermaidToBoardDialog({ open, onOpenChange }: MermaidToBoardDialogProps) }, 500); return () => clearTimeout(timeoutId); - }, [deferredText]); + }, [board.theme, deferredText]); const plugins: PlaitPlugin[] = [withDraw, withMind, withGroup, withText, addTextRenderer]; const boardOptions = { @@ -306,7 +315,7 @@ function MermaidToBoardDialog({ open, onOpenChange }: MermaidToBoardDialogProps)
) : isValid ? (
- +
diff --git a/features/storage/components/BoardSwitcher.tsx b/features/storage/components/BoardSwitcher.tsx index b7b945b..a5b0f99 100644 --- a/features/storage/components/BoardSwitcher.tsx +++ b/features/storage/components/BoardSwitcher.tsx @@ -106,8 +106,10 @@ export function BoardSwitcher({ <> - + ); + })} +
+
+ + + + +
-
+
{BASIC_TOOLS.map((tool) => ( diff --git a/features/toolbar/components/ZoomToolbar.tsx b/features/toolbar/components/ZoomToolbar.tsx index 49fc342..26091bc 100644 --- a/features/toolbar/components/ZoomToolbar.tsx +++ b/features/toolbar/components/ZoomToolbar.tsx @@ -67,10 +67,13 @@ export function ZoomToolbar() { }; return ( -
+