From 05337c8a66c5798fff1a31913e328cfd6ccdc751 Mon Sep 17 00:00:00 2001 From: kripu77 Date: Fri, 10 Apr 2026 13:20:48 +1000 Subject: [PATCH 01/17] feat(shared): add board theme types and utilities Add BoardThemeMode type, theme color resolution helpers (grid, blueprint, ruled), ink/fill color mappings, and theme mode detection to @thinkix/shared. --- packages/shared/src/index.ts | 1 + packages/shared/src/theme.ts | 260 +++++++++++++++++++++++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 packages/shared/src/theme.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e6e89f9..a96d3be 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,4 @@ export * from './types'; export * from './constants'; +export * from './theme'; export { logger, createLogger } from './logger'; diff --git a/packages/shared/src/theme.ts b/packages/shared/src/theme.ts new file mode 100644 index 0000000..f14352e --- /dev/null +++ b/packages/shared/src/theme.ts @@ -0,0 +1,260 @@ +import { GRID_BACKGROUND_COLORS, type GridType } from './types'; + +export type BoardThemeMode = + | 'default' + | 'dark' + | 'soft' + | 'retro' + | 'starry' + | 'colorful'; + +export interface GridThemeColors { + primary: string; + secondary: string; + major: string; + background: string; +} + +export interface BoardThemeOption { + value: BoardThemeMode; + label: string; + swatchClassName: string; +} + +export interface BoardInkColors { + stroke: string; + text: string; +} + +export interface BoardFillColors { + surface: string; + mutedSurface: string; + accentSurface: string; + noteSurface: string; +} + +export const DEFAULT_BOARD_THEME_MODE: BoardThemeMode = 'default'; +export const LIGHT_BOARD_INK: BoardInkColors = { + stroke: '#000000', + text: '#000000', +}; +export const DARK_BOARD_INK: BoardInkColors = { + stroke: '#e5e7eb', + text: '#f8fafc', +}; +export const LIGHT_BOARD_FILLS: BoardFillColors = { + surface: '#ffffff', + mutedSurface: '#f0f0f0', + accentSurface: '#ececff', + noteSurface: '#ffffde', +}; +export const DARK_BOARD_FILLS: BoardFillColors = { + surface: '#1f2937', + mutedSurface: '#262b33', + accentSurface: '#1c2742', + noteSurface: '#3b3320', +}; +export const STARRY_BOARD_FILLS: BoardFillColors = { + surface: '#17344b', + mutedSurface: '#1b3a4f', + accentSurface: '#1f3f60', + noteSurface: '#3d3520', +}; + +export const BOARD_THEME_OPTIONS: BoardThemeOption[] = [ + { + value: 'default', + label: 'Light', + swatchClassName: 'bg-white border-zinc-300', + }, + { + value: 'dark', + label: 'Dark', + swatchClassName: 'bg-zinc-950 border-zinc-700', + }, + { + value: 'soft', + label: 'Soft', + swatchClassName: 'bg-stone-100 border-stone-300', + }, + { + value: 'retro', + label: 'Retro', + swatchClassName: 'bg-amber-50 border-amber-300', + }, + { + value: 'starry', + label: 'Starry', + swatchClassName: 'bg-slate-950 border-sky-700', + }, + { + value: 'colorful', + label: 'Colorful', + swatchClassName: 'bg-cyan-50 border-cyan-300', + }, +]; + +export function isBoardThemeMode(value: unknown): value is BoardThemeMode { + return BOARD_THEME_OPTIONS.some((theme) => theme.value === value); +} + +export function getBoardThemeMode( + theme?: { themeColorMode?: unknown } | null, +): BoardThemeMode { + const themeColorMode = theme?.themeColorMode; + return isBoardThemeMode(themeColorMode) ? themeColorMode : DEFAULT_BOARD_THEME_MODE; +} + +export function isDarkBoardTheme(theme: BoardThemeMode): boolean { + return theme === 'dark' || theme === 'starry'; +} + +export function getBoardInkColors(theme: BoardThemeMode): BoardInkColors { + return isDarkBoardTheme(theme) ? DARK_BOARD_INK : LIGHT_BOARD_INK; +} + +export function getBoardFillColors(theme: BoardThemeMode): BoardFillColors { + if (theme === 'starry') { + return STARRY_BOARD_FILLS; + } + + return isDarkBoardTheme(theme) ? DARK_BOARD_FILLS : LIGHT_BOARD_FILLS; +} + +export function getGridThemeColors(theme: BoardThemeMode): GridThemeColors { + switch (theme) { + case 'dark': + return { + primary: '#404040', + secondary: '#333333', + major: '#555555', + background: GRID_BACKGROUND_COLORS.dark, + }; + case 'soft': + return { + primary: '#c8c8c8', + secondary: '#d8d8d8', + major: '#a0a0a0', + background: '#f5f5f5', + }; + case 'retro': + return { + primary: '#c4b998', + secondary: '#d4ccb8', + major: '#a09070', + background: '#f9f8ed', + }; + case 'starry': + return { + primary: '#2a4a5d', + secondary: '#1a3a4d', + major: '#3a6a8d', + background: '#0d2537', + }; + case 'colorful': + return { + primary: '#a0d0e0', + secondary: '#c0e0f0', + major: '#70b0c8', + background: GRID_BACKGROUND_COLORS.light, + }; + case 'default': + default: + return { + primary: '#d0d0d0', + secondary: '#e8e8e8', + major: '#a0a0a0', + background: GRID_BACKGROUND_COLORS.light, + }; + } +} + +export function getBlueprintColors(theme: BoardThemeMode): GridThemeColors { + switch (theme) { + case 'dark': + case 'starry': + return { + primary: '#3a6080', + secondary: '#2a4a60', + major: '#4a80a0', + background: GRID_BACKGROUND_COLORS.blueprint.dark, + }; + default: + return { + primary: '#a8c8d8', + secondary: '#c8e0f0', + major: '#7aa8c0', + background: GRID_BACKGROUND_COLORS.blueprint.light, + }; + } +} + +export function getRuledColors(theme: BoardThemeMode): GridThemeColors { + switch (theme) { + case 'dark': + return { + primary: '#3b4048', + secondary: '#5b6575', + major: '#4b5563', + background: '#18181b', + }; + case 'starry': + return { + primary: '#34566e', + secondary: '#547993', + major: '#456983', + background: '#10293d', + }; + case 'soft': + return { + primary: '#cdc8bf', + secondary: '#d9d4cc', + major: '#bbb3a8', + background: '#f6f3ef', + }; + case 'retro': + return { + primary: '#cec39f', + secondary: '#d9cfb1', + major: '#b8a679', + background: '#f7f1de', + }; + case 'colorful': + return { + primary: '#c5d7ea', + secondary: '#d6e5f5', + major: '#9cb7cf', + background: '#f7fbff', + }; + case 'default': + default: + return { + primary: '#d4d1ca', + secondary: '#dfdcd6', + major: '#bcb6aa', + background: GRID_BACKGROUND_COLORS.ruled, + }; + } +} + +export function getBoardBackgroundColor( + gridType: GridType, + theme: BoardThemeMode, +): string { + if (gridType === 'blueprint') { + return getBlueprintColors(theme).background; + } + + if (gridType === 'ruled') { + return getRuledColors(theme).background; + } + + return getGridThemeColors(theme).background; +} + +export function rgba(hex: string, alpha: number): string { + const r = Number.parseInt(hex.slice(1, 3), 16); + const g = Number.parseInt(hex.slice(3, 5), 16); + const b = Number.parseInt(hex.slice(5, 7), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} From c05e91ecf43a50fe7140a80671b91c1a5336df37 Mon Sep 17 00:00:00 2001 From: kripu77 Date: Fri, 10 Apr 2026 13:23:41 +1000 Subject: [PATCH 02/17] feat(theme): add CSS variables for all board themes Add data-board-theme attribute selectors for dark, soft, retro, starry, and colorful modes with full oklch/hex color palettes. Override text color for dark themes on Plait text containers. --- app/globals.css | 129 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) 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% { From 0c7bccaf1b5c99c9eb05552238a54e7355aec1bd Mon Sep 17 00:00:00 2001 From: kripu77 Date: Fri, 10 Apr 2026 13:23:54 +1000 Subject: [PATCH 03/17] feat(theme): add surface tokens and update design system constants Add reusable surface classes (floating, panel, input, subtle) using backdrop-blur and semi-transparent backgrounds. Update toolbar, control, dropdown, and collaboration styles to use new surface tokens. --- shared/constants/theme.ts | 42 +++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/shared/constants/theme.ts b/shared/constants/theme.ts index cb15282..24648cf 100644 --- a/shared/constants/theme.ts +++ b/shared/constants/theme.ts @@ -5,6 +5,25 @@ function cn(...inputs: (string | undefined | boolean | null)[]) { return twMerge(clsx(inputs)); } +const FLOATING_SURFACE = cn( + 'border border-border/80 bg-card/92 text-foreground', + 'backdrop-blur-xl shadow-[var(--tx-shadow-toolbar)]' +); + +const PANEL_SURFACE = cn( + 'border border-border/80 bg-card/94 text-foreground', + 'backdrop-blur-xl shadow-[var(--tx-shadow-dialog)]' +); + +const INPUT_SURFACE = cn( + 'border border-border/80 bg-background/70 text-foreground', + 'backdrop-blur-sm' +); + +const SUBTLE_SURFACE = cn( + 'border border-border/70 bg-muted/35 text-foreground' +); + /** * Design system theme tokens for Thinkix UI components. * All class strings use CSS custom properties (--tx-*) for sizing and typography. @@ -20,6 +39,12 @@ function cn(...inputs: (string | undefined | boolean | null)[]) { * - tip: Information callouts */ export const THEME = { + surface: { + floating: cn('!border-border/80 !bg-card/92 !text-foreground', FLOATING_SURFACE), + panel: cn('!border-border/80 !bg-card/94 !text-foreground', PANEL_SURFACE), + input: cn('!border-border/80 !bg-background/70 !text-foreground', INPUT_SURFACE), + subtle: cn('!border-border/70 !bg-muted/35 !text-foreground', SUBTLE_SURFACE), + }, /** * Toolbar button styles (36px square, 18px icons) * Used for: BoardToolbar, AppMenu trigger @@ -27,9 +52,9 @@ export const THEME = { toolbar: { container: cn( 'inline-flex items-center gap-1.5', - 'rounded-lg border bg-background/95 backdrop-blur', + 'rounded-lg', + FLOATING_SURFACE, 'px-2 py-2', - 'shadow-[var(--tx-shadow-toolbar)]' ), button: cn( 'h-[var(--tx-toolbar-btn)] w-[var(--tx-toolbar-btn)]', @@ -41,7 +66,7 @@ export const THEME = { 'disabled:pointer-events-none disabled:opacity-30' ), buttonSelected: cn( - 'bg-accent text-accent-foreground' + 'border border-border/70 bg-accent/60 text-foreground shadow-sm' ), separator: 'mx-1.5 h-6 w-px bg-border', mobileSeparator: 'mx-1 h-5 w-px bg-border', @@ -58,9 +83,9 @@ export const THEME = { control: { container: cn( 'inline-flex items-center gap-0.5', - 'rounded-lg border bg-background/95 backdrop-blur', + 'rounded-lg', + FLOATING_SURFACE, 'px-1 py-1', - 'shadow-[var(--tx-shadow-toolbar)]' ), button: cn( 'h-[var(--tx-control-btn)] w-[var(--tx-control-btn)]', @@ -87,7 +112,7 @@ export const THEME = { content: cn( 'min-w-[180px] z-50', 'overflow-hidden rounded-lg border', - 'bg-popover text-popover-foreground', + 'border-border/80 bg-popover/98 text-popover-foreground backdrop-blur-xl', 'p-1', 'shadow-[var(--tx-shadow-dropdown)]' ), @@ -214,8 +239,9 @@ export const THEME = { collab: { container: cn( 'hidden lg:flex items-center gap-2', - 'rounded-lg border bg-background/95 backdrop-blur', - 'px-2 py-1.5 shadow-sm' + 'rounded-lg px-2 py-1.5', + FLOATING_SURFACE, + 'shadow-sm' ), statusDot: 'h-2 w-2 rounded-full', statusConnected: 'bg-green-500', From 7ab2b7c9c92f61a41f85abe020b017a8e8d4bd67 Mon Sep 17 00:00:00 2001 From: kripu77 Date: Fri, 10 Apr 2026 13:24:08 +1000 Subject: [PATCH 04/17] feat(storage): add theme field to board data model Add PlaitTheme to BoardDto, BoardData, and Board interfaces. Default to light theme on creation. Add updateBoardTheme action for live theme changes. Update board adapter mapping and test fixtures. --- packages/storage/lib/board-adapter.ts | 6 +++++- packages/storage/lib/db.ts | 2 ++ packages/storage/lib/use-board-store.test.ts | 3 +++ packages/storage/lib/use-board-store.ts | 22 +++++++++++++++++++- 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/storage/lib/board-adapter.ts b/packages/storage/lib/board-adapter.ts index 7aed176..0a7a185 100644 --- a/packages/storage/lib/board-adapter.ts +++ b/packages/storage/lib/board-adapter.ts @@ -1,5 +1,5 @@ import { db, type BoardDto } from './db'; -import type { PlaitElement } from '@plait/core'; +import { ThemeColorMode, type PlaitElement, type PlaitTheme } from '@plait/core'; export interface BoardInfo { id: string; @@ -14,6 +14,7 @@ export interface BoardData { name: string; elements: PlaitElement[]; viewport: { x: number; y: number; zoom: number }; + theme: PlaitTheme; } let isInitialized = false; @@ -47,6 +48,7 @@ export const boardAdapter = { name: board.name, elements: board.elements as PlaitElement[], viewport: board.viewport, + theme: board.theme ?? { themeColorMode: ThemeColorMode.default }, }; }, @@ -59,6 +61,7 @@ export const boardAdapter = { name: board.name, elements: board.elements as PlaitElement[], viewport: board.viewport, + theme: board.theme ?? { themeColorMode: ThemeColorMode.default }, }; }, @@ -71,6 +74,7 @@ export const boardAdapter = { name, elements: [], viewport: { x: 0, y: 0, zoom: 1 }, + theme: { themeColorMode: ThemeColorMode.default }, createdAt: now, updatedAt: now, }; diff --git a/packages/storage/lib/db.ts b/packages/storage/lib/db.ts index acf7f01..5585cd7 100644 --- a/packages/storage/lib/db.ts +++ b/packages/storage/lib/db.ts @@ -1,3 +1,4 @@ +import type { PlaitTheme } from '@plait/core'; import Dexie, { Table } from 'dexie'; export interface BoardDto { @@ -5,6 +6,7 @@ export interface BoardDto { name: string; elements: unknown[]; viewport: { x: number; y: number; zoom: number }; + theme?: PlaitTheme; createdAt: number; updatedAt: number; } diff --git a/packages/storage/lib/use-board-store.test.ts b/packages/storage/lib/use-board-store.test.ts index 755e43b..800102f 100644 --- a/packages/storage/lib/use-board-store.test.ts +++ b/packages/storage/lib/use-board-store.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ThemeColorMode } from '@plait/core'; import type { BoardDto } from './db'; const { mockDb } = vi.hoisted(() => ({ @@ -36,6 +37,7 @@ function createBoardDto(id: string, name: string): BoardDto { name, elements: [], viewport: { x: 0, y: 0, zoom: 1 }, + theme: { themeColorMode: ThemeColorMode.default }, createdAt: 1, updatedAt: 1, }; @@ -47,6 +49,7 @@ function createBoard(board: BoardDto): Board { name: board.name, elements: [], viewport: board.viewport, + theme: board.theme ?? { themeColorMode: ThemeColorMode.default }, createdAt: board.createdAt, updatedAt: board.updatedAt, }; diff --git a/packages/storage/lib/use-board-store.ts b/packages/storage/lib/use-board-store.ts index 9f0773c..b576195 100644 --- a/packages/storage/lib/use-board-store.ts +++ b/packages/storage/lib/use-board-store.ts @@ -3,7 +3,7 @@ import { create } from 'zustand'; import { subscribeWithSelector } from 'zustand/middleware'; import { db, type BoardDto } from './db'; -import type { PlaitElement } from '@plait/core'; +import { ThemeColorMode, type PlaitElement, type PlaitTheme } from '@plait/core'; export interface BoardMetadata { id: string; @@ -18,6 +18,7 @@ export interface Board { name: string; elements: PlaitElement[]; viewport: { x: number; y: number; zoom: number }; + theme: PlaitTheme; createdAt: number; updatedAt: number; } @@ -38,6 +39,7 @@ interface BoardActions { deleteBoard: (id: string) => Promise; renameBoard: (id: string, name: string) => Promise; saveBoard: (board: Board) => Promise; + updateBoardTheme: (id: string, theme: PlaitTheme) => Promise; setSaveStatus: (status: SaveStatus) => void; } @@ -49,6 +51,7 @@ function mapBoardDtoToBoard(boardDto: BoardDto): Board { name: boardDto.name, elements: boardDto.elements as PlaitElement[], viewport: boardDto.viewport, + theme: boardDto.theme ?? { themeColorMode: ThemeColorMode.default }, createdAt: boardDto.createdAt, updatedAt: boardDto.updatedAt, }; @@ -120,6 +123,7 @@ export const useBoardStore = create()( name: 'My Board', elements: [], viewport: { x: 0, y: 0, zoom: 1 }, + theme: { themeColorMode: ThemeColorMode.default }, createdAt: now, updatedAt: now, }; @@ -143,6 +147,7 @@ export const useBoardStore = create()( name, elements: [], viewport: { x: 0, y: 0, zoom: 1 }, + theme: { themeColorMode: ThemeColorMode.default }, createdAt: now, updatedAt: now, }; @@ -245,6 +250,21 @@ export const useBoardStore = create()( } }, + updateBoardTheme: async (id: string, theme: PlaitTheme) => { + const updatedAt = Date.now(); + await db.boards.update(id, { theme, updatedAt }); + + set((state) => ({ + boards: state.boards.map((board) => + board.id === id ? { ...board, updatedAt } : board, + ), + currentBoard: + state.currentBoard?.id === id + ? { ...state.currentBoard, theme, updatedAt } + : state.currentBoard, + })); + }, + setSaveStatus: (status: SaveStatus) => set({ saveStatus: status }), })) ); From d63924f4e53477684755c0a3a054fc97454520f4 Mon Sep 17 00:00:00 2001 From: kripu77 Date: Fri, 10 Apr 2026 13:24:18 +1000 Subject: [PATCH 05/17] feat(board): add element theme sync utilities and mind theme colors Add syncElementsForBoardTheme to remap ink, fill, and text colors when the board theme changes. Add THINKIX_MIND_THEME_COLORS with per-theme root node overrides. Export from board utils barrel. --- features/board/utils/index.ts | 2 + features/board/utils/mind-theme.ts | 59 ++++++ features/board/utils/theme-elements.ts | 253 +++++++++++++++++++++++++ 3 files changed, 314 insertions(+) create mode 100644 features/board/utils/mind-theme.ts create mode 100644 features/board/utils/theme-elements.ts diff --git a/features/board/utils/index.ts b/features/board/utils/index.ts index 7115aa0..64fcbf4 100644 --- a/features/board/utils/index.ts +++ b/features/board/utils/index.ts @@ -11,3 +11,5 @@ export { estimateStickySize, isStickyColorName, } from './sticky-note'; +export { syncElementsForBoardTheme } from './theme-elements'; +export { THINKIX_MIND_THEME_COLORS } from './mind-theme'; diff --git a/features/board/utils/mind-theme.ts b/features/board/utils/mind-theme.ts new file mode 100644 index 0000000..992c64e --- /dev/null +++ b/features/board/utils/mind-theme.ts @@ -0,0 +1,59 @@ +import { + MindThemeColors, + type MindThemeColor, +} from '@plait/mind'; +import { + LIGHT_BOARD_INK, + DARK_BOARD_INK, + type BoardThemeMode, +} from '@thinkix/shared'; + +const ROOT_THEME_OVERRIDES: Record = { + 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; +} From 2a5e2b8b5da2d1ab962cc0bdaa8fe98d01cbd2bd Mon Sep 17 00:00:00 2001 From: kripu77 Date: Fri, 10 Apr 2026 13:24:27 +1000 Subject: [PATCH 06/17] refactor(grid): use shared theme utilities for grid colors Replace inline theme color maps with re-exports from @thinkix/shared. Use getBoardThemeMode and getBoardBackgroundColor helpers in grid renderer and background style updates. --- features/board/grid/grid-plugin.ts | 28 +++---- features/board/grid/utils/theme-colors.ts | 98 ++--------------------- 2 files changed, 19 insertions(+), 107 deletions(-) diff --git a/features/board/grid/grid-plugin.ts b/features/board/grid/grid-plugin.ts index 5c7511f..31b4038 100644 --- a/features/board/grid/grid-plugin.ts +++ b/features/board/grid/grid-plugin.ts @@ -1,12 +1,16 @@ -import { PlaitBoard, ThemeColorMode } from '@plait/core'; +import { PlaitBoard } from '@plait/core'; import type { BoardBackground, GridType, ViewportBounds } from './types'; -import { DEFAULT_BOARD_BACKGROUND, GRID_BACKGROUND_COLORS, GRID_DENSITIES } from './types'; -import type { GridDensity } from '@thinkix/shared'; +import { DEFAULT_BOARD_BACKGROUND, GRID_DENSITIES } from './types'; +import { + getBoardBackgroundColor, + getBoardThemeMode, + type GridDensity, +} from '@thinkix/shared'; import { STORAGE_KEYS } from '@/shared/constants'; const VALID_GRID_TYPES: GridType[] = ['dot', 'square', 'blueprint', 'isometric', 'ruled', 'blank']; import { getViewportBounds } from './utils/world-to-screen'; -import { getGridThemeColors, getBlueprintColors } from './utils/theme-colors'; +import { getGridThemeColors, getBlueprintColors, getRuledColors } from './utils/theme-colors'; import type { GridRenderer, GridRenderContext } from './renderers'; import { BlankRenderer, @@ -115,11 +119,13 @@ function renderGrid(state: GridPluginState, board: PlaitBoard): void { try { const bounds: ViewportBounds = getViewportBounds(board) const zoom = board.viewport.zoom - const theme = board.theme?.themeColorMode ?? ThemeColorMode.default + const theme = getBoardThemeMode(board.theme) let colors if (state.config.type === 'blueprint') { colors = getBlueprintColors(theme) + } else if (state.config.type === 'ruled') { + colors = getRuledColors(theme) } else { colors = getGridThemeColors(theme) } @@ -148,16 +154,8 @@ function updateBackgroundStyle(state: GridPluginState, board: PlaitBoard): void const boardContainer = PlaitBoard.getBoardContainer(board) if (!boardContainer) return - const theme = board.theme?.themeColorMode ?? ThemeColorMode.default - let bgColor: string - - if (state.config.type === 'blueprint') { - bgColor = getBlueprintColors(theme).background - } else if (state.config.type === 'ruled') { - bgColor = GRID_BACKGROUND_COLORS.ruled - } else { - bgColor = getGridThemeColors(theme).background - } + const theme = getBoardThemeMode(board.theme) + const bgColor = getBoardBackgroundColor(state.config.type, theme) const svgHost = boardContainer.querySelector('.board-host-svg') as SVGSVGElement | null if (svgHost) { diff --git a/features/board/grid/utils/theme-colors.ts b/features/board/grid/utils/theme-colors.ts index cf76b2e..bad6dc8 100644 --- a/features/board/grid/utils/theme-colors.ts +++ b/features/board/grid/utils/theme-colors.ts @@ -1,92 +1,6 @@ -import { ThemeColorMode } from '@plait/core'; -import type { GridThemeColors } from '../types'; -import { GRID_BACKGROUND_COLORS } from '@thinkix/shared'; - -const LIGHT_GRID_COLORS: GridThemeColors = { - primary: '#d0d0d0', - secondary: '#e8e8e8', - major: '#a0a0a0', - background: GRID_BACKGROUND_COLORS.light, -}; - -const DARK_GRID_COLORS: GridThemeColors = { - primary: '#404040', - secondary: '#333333', - major: '#555555', - background: GRID_BACKGROUND_COLORS.dark, -}; - -const BLUEPRINT_COLORS: GridThemeColors = { - primary: '#a8c8d8', - secondary: '#c8e0f0', - major: '#7aa8c0', - background: GRID_BACKGROUND_COLORS.blueprint.light, -}; - -const SOFT_GRID_COLORS: GridThemeColors = { - primary: '#c8c8c8', - secondary: '#d8d8d8', - major: '#a0a0a0', - background: '#f5f5f5', -}; - -const RETRO_GRID_COLORS: GridThemeColors = { - primary: '#c4b998', - secondary: '#d4ccb8', - major: '#a09070', - background: '#f9f8ed', -}; - -const STARRY_GRID_COLORS: GridThemeColors = { - primary: '#2a4a5d', - secondary: '#1a3a4d', - major: '#3a6a8d', - background: '#0d2537', -}; - -const COLORFUL_GRID_COLORS: GridThemeColors = { - primary: '#a0d0e0', - secondary: '#c0e0f0', - major: '#70b0c8', - background: '#ffffff', -}; - -export function getGridThemeColors(theme: ThemeColorMode): GridThemeColors { - switch (theme) { - case ThemeColorMode.dark: - return DARK_GRID_COLORS; - case ThemeColorMode.soft: - return SOFT_GRID_COLORS; - case ThemeColorMode.retro: - return RETRO_GRID_COLORS; - case ThemeColorMode.starry: - return STARRY_GRID_COLORS; - case ThemeColorMode.colorful: - return COLORFUL_GRID_COLORS; - case ThemeColorMode.default: - default: - return LIGHT_GRID_COLORS; - } -} - -export function getBlueprintColors(theme: ThemeColorMode): GridThemeColors { - switch (theme) { - case ThemeColorMode.dark: - case ThemeColorMode.starry: - return { - primary: '#3a6080', - secondary: '#2a4a60', - major: '#4a80a0', - background: GRID_BACKGROUND_COLORS.blueprint.dark, - }; - default: - return BLUEPRINT_COLORS; - } -} - -export function rgba(hex: string, alpha: number): string { - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); - return `rgba(${r}, ${g}, ${b}, ${alpha})`; -} +export { + getGridThemeColors, + getBlueprintColors, + getRuledColors, + rgba, +} from '@thinkix/shared'; From bf0e7b91f2282f1ed45d46e9cc96797c51a52d2f Mon Sep 17 00:00:00 2001 From: kripu77 Date: Fri, 10 Apr 2026 13:25:01 +1000 Subject: [PATCH 07/17] feat(board): integrate theme into BoardCanvas and page shell Pass board theme to Plait Wrapper. Apply theme-synced elements on load and remote updates. Set data-board-theme attribute and dark class on the page shell via useLayoutEffect. --- app/page.tsx | 39 ++++++++++++++-- features/board/components/BoardCanvas.tsx | 54 ++++++++++++++++++----- 2 files changed, 77 insertions(+), 16 deletions(-) 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/features/board/components/BoardCanvas.tsx b/features/board/components/BoardCanvas.tsx index 5e427a5..6102a12 100644 --- a/features/board/components/BoardCanvas.tsx +++ b/features/board/components/BoardCanvas.tsx @@ -12,7 +12,7 @@ import { } from '@plait/core'; import { withGroup, withText } from '@plait/common'; import { withDraw } from '@plait/draw'; -import { withMind, MindThemeColors } from '@plait/mind'; +import { withMind } from '@plait/mind'; import { addPenMode } from '../plugins/add-pen-mode'; import { addImageRenderer } from '../plugins/add-image-renderer'; import { addEmojiRenderer } from '../plugins/add-emoji-renderer'; @@ -34,6 +34,8 @@ import { useAutoSave } from '@/features/storage'; import { PencilModeIndicator } from './PencilModeIndicator'; import type { Board as StorageBoard } from '@thinkix/storage'; import { useOptionalSyncBus, type BoardElement, validateBoardElements, logger } from '@thinkix/collaboration'; +import { getBoardThemeMode } from '@thinkix/shared'; +import { syncElementsForBoardTheme, THINKIX_MIND_THEME_COLORS } from '../utils'; import '@/app/styles/plait-react-board.css'; @@ -48,13 +50,15 @@ const DEFAULT_BOARD_OPTIONS: PlaitBoardOptions = { readonly: false, hideScrollbar: false, disabledScrollOnNonFocus: false, - themeColors: MindThemeColors, + themeColors: THINKIX_MIND_THEME_COLORS, }; const DEFAULT_THEME: PlaitTheme = { themeColorMode: ThemeColorMode.default, }; +const EMPTY_INITIAL_VALUE: PlaitElement[] = []; + const createPlugins = (onPencilModeChange?: (isPencilMode: boolean) => void): PlaitPlugin[] => [ withGrid, withDraw, @@ -76,7 +80,13 @@ const createPlugins = (onPencilModeChange?: (isPencilMode: boolean) => void): Pl withPinchZoom, ]; -function RemoteSyncHandler({ onElementsChange }: { onElementsChange: (elements: PlaitElement[]) => void }) { +function RemoteSyncHandler({ + onElementsChange, + normalizeElements, +}: { + onElementsChange: (elements: PlaitElement[]) => void; + normalizeElements: (elements: PlaitElement[]) => PlaitElement[]; +}) { const board = useBoard(); const listRender = useListRender(); const syncBusContext = useOptionalSyncBus(); @@ -85,8 +95,9 @@ function RemoteSyncHandler({ onElementsChange }: { onElementsChange: (elements: if (!syncBusContext) return; const unsubscribe = syncBusContext.syncBus.subscribeToRemoteChanges((elements: BoardElement[]) => { - onElementsChange(elements); - listRender.update(elements, { + const normalized = normalizeElements(elements); + onElementsChange(normalized); + listRender.update(normalized, { board: board, parent: board, parentG: PlaitBoard.getElementHost(board), @@ -94,23 +105,38 @@ function RemoteSyncHandler({ onElementsChange }: { onElementsChange: (elements: }); return unsubscribe; - }, [board, listRender, onElementsChange, syncBusContext]); + }, [board, listRender, normalizeElements, onElementsChange, syncBusContext]); return null; } export function BoardCanvas({ - initialValue = [], + initialValue, className, children, boardData, }: BoardCanvasProps) { const { board, setBoard, state, setCurrentBoardId, setPencilMode } = useBoardState(); const syncBusContext = useOptionalSyncBus(); - + const resolvedInitialValue = initialValue ?? EMPTY_INITIAL_VALUE; + + const boardTheme = useMemo(() => { + return boardData?.theme ?? DEFAULT_THEME; + }, [boardData?.theme]); + + const boardThemeMode = useMemo(() => getBoardThemeMode(boardTheme), [boardTheme]); + + const normalizeElementsForTheme = useCallback( + (elements: PlaitElement[]) => syncElementsForBoardTheme(elements, boardThemeMode), + [boardThemeMode], + ); + const initialElements = useMemo(() => { - return boardData?.elements ?? initialValue; - }, [boardData?.elements, initialValue]); + return syncElementsForBoardTheme( + boardData?.elements ?? resolvedInitialValue, + getBoardThemeMode(boardData?.theme ?? DEFAULT_THEME), + ); + }, [boardData?.elements, boardData?.theme, resolvedInitialValue]); const [value, setValue] = useState(initialElements); @@ -173,13 +199,14 @@ export function BoardCanvas({ value={value} options={DEFAULT_BOARD_OPTIONS} plugins={plugins} - theme={DEFAULT_THEME} + theme={boardTheme} onChange={handleChange} >
0 ? 'true' : 'false'} + data-board-theme={boardThemeMode} className="w-full h-full" >
- + From bf36617a38e58abff6db1e70775eaaa1a7b8ff9f Mon Sep 17 00:00:00 2001 From: kripu77 Date: Fri, 10 Apr 2026 13:25:19 +1000 Subject: [PATCH 08/17] feat(toolbar): add theme picker to app menu Add Palette submenu with visual theme previews (Light, Dark, Soft, Retro, Starry, Colorful). Persist theme on change, sync with collaboration, and refresh grid background. Persist loaded file data to storage. --- features/toolbar/components/AppMenu.tsx | 194 +++++++++++++++++++++++- 1 file changed, 191 insertions(+), 3 deletions(-) diff --git a/features/toolbar/components/AppMenu.tsx b/features/toolbar/components/AppMenu.tsx index be948e3..eca9430 100644 --- a/features/toolbar/components/AppMenu.tsx +++ b/features/toolbar/components/AppMenu.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState } from 'react'; -import { FolderOpen, Save, Trash2, FileImage, ChevronRight, Menu, Users, Link2, UserCircle2 } from 'lucide-react'; +import { FolderOpen, Save, Trash2, FileImage, ChevronRight, Menu, Users, Link2, UserCircle2, Palette, Check } from 'lucide-react'; import { MindMapIcon } from '@/shared/constants/icons'; import { useBoard, useListRender } from '@plait-board/react-board'; import { @@ -10,6 +10,7 @@ import { PlaitTheme, PlaitBoard, Viewport, + ThemeColorMode, } from '@plait/core'; import { Button, cn } from '@thinkix/ui'; import { @@ -39,9 +40,21 @@ import { exportAsPng, exportAsJpg, } from '@thinkix/file-utils'; +import { useBoardStore } from '@thinkix/storage'; import { MarkdownToMindmapDialog, MermaidToBoardDialog, MermaidIcon } from '@/features/dialogs'; -import { NicknameDialog, useOptionalSyncBus, type CollaborationUser, validateBoardElements, logger } from '@thinkix/collaboration'; +import { + NicknameDialog, + useOptionalSyncBus, + useOptionalYjsCollaboration, + type CollaborationUser, + type BoardElement, + validateBoardElements, + logger, +} from '@thinkix/collaboration'; import { THEME } from '@/shared/constants'; +import { BOARD_THEME_OPTIONS, getBoardThemeMode, type BoardThemeMode } from '@thinkix/shared'; +import { refreshGrid } from '@/features/board/grid'; +import { syncElementsForBoardTheme } from '@/features/board/utils'; import posthog from 'posthog-js'; export type { CollaborationUser }; @@ -60,10 +73,40 @@ interface AppMenuProps { }; } +const THEME_PREVIEW_STYLES: Record = { + default: { + surface: 'bg-white border-zinc-200', + accent: 'bg-zinc-100 border-zinc-300', + }, + dark: { + surface: 'bg-zinc-950 border-zinc-700', + accent: 'bg-zinc-800 border-zinc-600', + }, + soft: { + surface: 'bg-stone-100 border-stone-300', + accent: 'bg-stone-200 border-stone-400', + }, + retro: { + surface: 'bg-amber-50 border-amber-300', + accent: 'bg-amber-200 border-amber-500', + }, + starry: { + surface: 'bg-slate-950 border-sky-700', + accent: 'bg-slate-800 border-sky-500', + }, + colorful: { + surface: 'bg-cyan-50 border-cyan-300', + accent: 'bg-cyan-200 border-cyan-500', + }, +}; + export function AppMenu({ boardName, onEnableCollaboration, collaboration }: AppMenuProps) { const board = useBoard(); const listRender = useListRender(); const syncBusContext = useOptionalSyncBus(); + const collaborationContext = useOptionalYjsCollaboration(); + const currentBoard = useBoardStore((state) => state.currentBoard); + const saveBoard = useBoardStore((state) => state.saveBoard); const [isOpen, setIsOpen] = useState(false); const [isClearDialogOpen, setIsClearDialogOpen] = useState(false); const [isMarkdownDialogOpen, setIsMarkdownDialogOpen] = useState(false); @@ -88,9 +131,42 @@ export function AppMenu({ boardName, onEnableCollaboration, collaboration }: App parent: board, parentG: PlaitBoard.getElementHost(board), }); + refreshGrid(board); BoardTransforms.fitViewport(board); }; + const persistCurrentBoard = async ({ + elements = board.children, + viewport = { + x: board.viewport.x ?? 0, + y: board.viewport.y ?? 0, + zoom: board.viewport.zoom ?? 1, + }, + theme = board.theme, + }: { + elements?: PlaitElement[]; + viewport?: Viewport; + theme?: PlaitTheme; + } = {}) => { + if (!currentBoard) { + return; + } + + await saveBoard({ + id: currentBoard.id, + name: currentBoard.name, + elements, + viewport: { + x: viewport.x ?? 0, + y: viewport.y ?? 0, + zoom: viewport.zoom ?? 1, + }, + theme: theme ?? currentBoard.theme, + createdAt: currentBoard.createdAt, + updatedAt: Date.now(), + }); + }; + const handleOpenFile = async () => { setIsOpen(false); setIsLoading(true); @@ -98,6 +174,11 @@ export function AppMenu({ boardName, onEnableCollaboration, collaboration }: App const data = await loadBoardFromFile(); if (data) { clearAndLoad(data.elements, data.viewport, data.theme); + await persistCurrentBoard({ + elements: data.elements, + viewport: data.viewport, + theme: data.theme ?? board.theme, + }); if (syncBusContext) { const { valid, invalid } = validateBoardElements(data.elements); @@ -180,10 +261,42 @@ export function AppMenu({ boardName, onEnableCollaboration, collaboration }: App setIsClearDialogOpen(true); }; + const handleThemeChange = async (themeColorMode: BoardThemeMode) => { + setIsOpen(false); + + const theme: PlaitTheme = { themeColorMode: ThemeColorMode[themeColorMode] }; + const nextElements = syncElementsForBoardTheme(board.children, themeColorMode); + + board.children = nextElements; + board.theme = theme; + listRender.update(nextElements, { + board, + parent: board, + parentG: PlaitBoard.getElementHost(board), + }); + refreshGrid(board); + + if (collaborationContext) { + collaborationContext.setBoardState(nextElements as unknown as BoardElement[], theme); + } + + await persistCurrentBoard({ elements: nextElements, theme }); + + posthog.capture('board_theme_changed', { + board_name: boardName, + theme: themeColorMode, + }); + }; + const confirmClearBoard = () => { setIsClearDialogOpen(false); const elementCount = board.children.length; clearAndLoad([]); + void persistCurrentBoard({ + elements: [], + viewport: { x: board.viewport.x ?? 0, y: board.viewport.y ?? 0, zoom: board.viewport.zoom ?? 1 }, + theme: board.theme, + }); if (syncBusContext) { syncBusContext.emitLocalChange([]); @@ -194,15 +307,21 @@ export function AppMenu({ boardName, onEnableCollaboration, collaboration }: App posthog.capture('board_cleared', { board_name: boardName, element_count: elementCount }); }; + const currentThemeMode = getBoardThemeMode(board.theme); + const currentThemeLabel = + BOARD_THEME_OPTIONS.find((theme) => theme.value === currentThemeMode)?.label ?? 'Light'; + return ( <> + ); + })} +
+
+ + + + + Date: Fri, 10 Apr 2026 13:25:36 +1000 Subject: [PATCH 09/17] feat(ui): update toolbar and control styles for board theming Apply THEME.surface tokens to floating containers. Switch trigger buttons from variant outline to ghost with hover:bg-accent. Add data-testid attributes for toolbar and canvas-mode elements. Update snapshot. --- features/board/grid/components/GridToolbar.tsx | 9 ++++++--- .../collaboration/components/collaborate-button.tsx | 8 ++++++-- features/storage/components/BoardSwitcher.tsx | 4 +++- features/toolbar/components/BoardToolbar.tsx | 2 +- features/toolbar/components/ZoomToolbar.tsx | 11 +++++++---- .../__snapshots__/collaborate-button.test.tsx.snap | 4 ++-- 6 files changed, 25 insertions(+), 13 deletions(-) diff --git a/features/board/grid/components/GridToolbar.tsx b/features/board/grid/components/GridToolbar.tsx index 95bba5e..452eef2 100644 --- a/features/board/grid/components/GridToolbar.tsx +++ b/features/board/grid/components/GridToolbar.tsx @@ -7,6 +7,7 @@ import { ChevronUp } from 'lucide-react'; import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@thinkix/ui'; import { cn } from '@thinkix/ui'; import { useBoardState } from '@/features/board/hooks/use-board-state'; +import { THEME } from '@/shared/constants'; import { getGridConfig, setGridConfig } from '../grid-plugin'; import type { GridType, GridDensity, BoardBackground } from '../types'; import { DEFAULT_BOARD_BACKGROUND } from '../types'; @@ -140,8 +141,10 @@ export function GridToolbar() { return (
@@ -150,7 +153,7 @@ export function GridToolbar() { - + 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({ <> -