Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
96 changes: 93 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

**Top-level rules — these override defaults:**

- **Zero comments** unless explaining a non-obvious *why*. See [Code conventions](#code-conventions).
- **Simple, readable code over clever code.** Prefer 3 obvious lines over 1 dense line.
- **Atomic commits** after each logical unit of work. See [Commits](#commits--atomic-always).
- **Before creating** a new hook, util, or component — grep for an existing one. Follow existing patterns.

## Project Overview

Thinkix is an AI-native infinite canvas whiteboard built with Next.js 16, React 19, and the Plait board library. The AI agent can read the current board, create new structures, and edit existing elements via a Unix-shell-style command set and a custom DSL. The project uses Bun workspaces for shared code and Liveblocks + Yjs for live collaboration.
Expand Down Expand Up @@ -195,8 +202,91 @@ NEXT_PUBLIC_POSTHOG_HOST=...

## Code conventions

- Self-documenting code; no ceremonial comments. Only comment non-obvious *why*.
### Comments — default is zero

Write code that does not need comments. Before adding any comment, apply this test: **does it explain WHY something is done this way, in a way the code itself cannot?** If it describes WHAT the code does, delete it and rename variables/functions until the code speaks for itself.

**Never write comments like these:**

```ts
// Loop through elements and filter selected ones
const selected = elements.filter(isSelected);

// Set the active tool to pen
setActiveTool('pen');

// Check if the board exists
if (!board) return null;

/** Handles the click event */
function handleClick() { ... }

// Initialize state
const [count, setCount] = useState(0);
```

**These are acceptable** (non-obvious why, workaround context, gotcha warnings):

```ts
// Plait re-orders jsonb keys on write; serialize canonically or prompt
// caching breaks downstream. See #142.
const payload = canonicalize(element);

// updatePointerType has no native hook — patched in with-tool-sync.ts
BoardTransforms.updatePointerType = patchedUpdatePointerType;

// Liveblocks presence uses screen coords; we convert to canvas/world
// coords here so cursors render correctly across different viewports.
const worldPos = screenToCanvas(screenPos, viewport);
```

JSDoc is allowed only on exported package APIs (`packages/*`), never on internal functions whose name and signature already tell the story.

### Simplicity over cleverness

Optimize for the next reader, not for elegance or impressiveness.

**Do:**
```ts
const activeUsers = users.filter(user => user.isActive);
const names = activeUsers.map(user => user.name);
```

**Don't:**
```ts
const names = users.reduce((acc, u) => (u.isActive ? [...acc, u.name] : acc), [] as string[]);
```

Rules:
- Prefer a plain `for`/`filter`/`map` chain over a dense one-liner with nested ternaries, `reduce`-to-build-objects, or point-free composition.
- Prefer 3 obvious lines over 1 dense line. Always.
- Prefer duplication over the wrong abstraction — do not invent a helper or generic until the third usage.
- Early returns over nested conditionals: `if (!board) return null` at the top, not an `else` branch at the bottom.
- No nested ternaries. Extract to a variable or use `if`/`else`.
- If you feel the urge to add a comment explaining clever logic — that is the signal to rewrite the logic, not add the comment.

### Naming

- Functions: verbs (`createBoard`, `handleClick`, `resolveProvider`).
- Variables/constants: nouns (`activeUsers`, `boardId`, `GRID_SIZE`).
- Booleans: adjectives/state (`isActive`, `hasPermission`, `shouldRender`).
- Names should be specific enough to be grep-able. Avoid single letters except in tight lambda args (`items.map(x => x.id)` is fine; `function f(x, y, z)` is not).

### Commits — atomic, always

Commit after each logical unit of work. Do not batch unrelated changes.

- One logical change per commit. Refactors, behavior changes, and test additions are separate commits even within the same task.
- If a fix requires a preparatory refactor, commit the refactor first (with no behavior change), then commit the fix.
- Conventional commit format: `fix(collab): …`, `feat(agent): …`, `refactor(board): …`, `test: …`, `chore: …`.
- Never mix generated files (`parser.js`) with hand-written changes unless the generation is caused by that change — and say so in the commit body.
- Commit messages: subject says WHAT changed, body (when needed) says WHY.

### General

- Feature modules expose a small `index.ts` barrel. Follow existing patterns instead of introducing new ones.
- Before creating a new hook, util, or component, grep the codebase for an existing one that does the same thing.
- Types flow from `@thinkix/shared` (no JSX) and `shared/constants` (JSX allowed).
- Prefer early returns (`if (!board) return null`).
- Keep PRs focused; if you change the DSL grammar, regenerate the parser before opening the PR (see `CONTRIBUTING.md`).
- `const`-first. Only use `let` when reassignment is genuinely needed.
- Single-responsibility functions. If a function does two things, split it.
- Keep PRs focused; if you change the DSL grammar, regenerate the parser before opening the PR (see `CONTRIBUTING.md`).
24 changes: 11 additions & 13 deletions features/board/components/BoardCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,11 @@ function RemoteSyncHandler({

useEffect(() => {
if (!syncBusContext) return;

const unsubscribe = syncBusContext.syncBus.subscribeToRemoteChanges((elements: BoardElement[]) => {
const normalized = normalizeElements(elements);
onElementsChange(normalized);
board.children = normalized;

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether react-hooks/immutability rule is enabled and how the sibling file guards the same mutation
fd -e cjs -e js -e json -e mjs -e ts eslint.config -H | xargs -r rg -n "immutability|react-hooks" 
rg -n "board.children\s*=" -C2

Repository: Kripu77/thinkix

Length of output: 153


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== ESLint config files ==\n'
fd -H -a 'eslint.config.*' . || true
fd -H -a '.eslintrc*' . || true

printf '\n== Search for react-hooks/immutability ==\n'
rg -n --hidden -S "react-hooks/immutability|immutability" . || true

printf '\n== Relevant files ==\n'
fd -H -a 'BoardCanvas.tsx' features || true
fd -H -a 'collaborative-board.tsx' features || true

printf '\n== BoardCanvas mutation context ==\n'
rg -n -C 4 "board\.children\s*=" features/board/components/BoardCanvas.tsx || true

printf '\n== Collaborative board mutation context ==\n'
rg -n -C 6 "board\.children\s*=" features/collaboration/components/collaborative-board.tsx || true

Repository: Kripu77/thinkix

Length of output: 2306


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== eslint.config.mjs relevant rules ==\n'
rg -n -C 3 "react-hooks/immutability|immutability" eslint.config.mjs || true

printf '\n== BoardCanvas surrounding context ==\n'
sed -n '1,140p' features/board/components/BoardCanvas.tsx

printf '\n== Collaborative board surrounding context ==\n'
sed -n '80,120p' features/collaboration/components/collaborative-board.tsx

Repository: Kripu77/thinkix

Length of output: 6205


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== eslint.config.mjs ==\n'
sed -n '1,260p' eslint.config.mjs

printf '\n== package.json eslint-related deps ==\n'
rg -n '"eslint|react-hooks|typescript-eslint|prettier"' package.json

printf '\n== any repo-wide react-hooks config imports ==\n'
rg -n "react-hooks|eslint-plugin-react-hooks|reactHooks" . --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**'

Repository: Kripu77/thinkix

Length of output: 4263


Add the immutability suppression in features/board/components/BoardCanvas.tsx:100. board.children = normalized uses the same direct Plait mutation already guarded in features/collaboration/components/collaborative-board.tsx; without the react-hooks/immutability disable, this file will fail the lint rule.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@features/board/components/BoardCanvas.tsx` at line 100, The direct assignment
to board.children in BoardCanvas should be treated as an intentional Plait
mutation and suppressed for the react-hooks/immutability lint rule, matching the
pattern already used in collaborative-board. Add the immutability disable
comment at the board.children = normalized update site in BoardCanvas so the
lint check does not fail on this deliberate mutation.

listRender.update(normalized, {
board: board,
parent: board,
Expand All @@ -110,7 +111,11 @@ function RemoteSyncHandler({
return null;
}

export function BoardCanvas({
export function BoardCanvas(props: BoardCanvasProps) {
return <BoardCanvasInner key={props.boardData?.id ?? 'default'} {...props} />;
}

function BoardCanvasInner({
initialValue,
className,
children,
Expand All @@ -131,25 +136,19 @@ export function BoardCanvas({
[boardThemeMode],
);

const initialElements = useMemo(() => {
return syncElementsForBoardTheme(
const [value, setValue] = useState<PlaitElement[]>(() =>
syncElementsForBoardTheme(
boardData?.elements ?? resolvedInitialValue,
getBoardThemeMode(boardData?.theme ?? DEFAULT_THEME),
);
}, [boardData?.elements, boardData?.theme, resolvedInitialValue]);

const [value, setValue] = useState<PlaitElement[]>(initialElements);
),
);

useEffect(() => {
if (boardData) {
setCurrentBoardId(boardData.id);
}
}, [boardData, setCurrentBoardId]);

useEffect(() => {
setValue(initialElements);
}, [initialElements]);

useAutoSave({
board: board,
enabled: !!boardData,
Expand Down Expand Up @@ -195,7 +194,6 @@ export function BoardCanvas({
return (
<div className={`relative w-full h-full board-wrapper ${className || ''} ${boardCursorClass}`}>
<Wrapper
key={boardData?.id ?? 'default'}
value={value}
options={DEFAULT_BOARD_OPTIONS}
plugins={plugins}
Expand Down
14 changes: 7 additions & 7 deletions packages/collaboration/src/components/cursor-overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { PlaitBoard } from '@plait/core';
import type { CursorState } from '../cursor-manager';
import { getVisibleCursors, getActiveCursors } from '../cursor-manager';
import { useViewport } from '../hooks/use-viewport';
import { documentToScreen, getViewportContainerElement } from '../utils';

interface CursorOverlayProps {
cursors: Map<string, CursorState>;
Expand Down Expand Up @@ -123,14 +124,12 @@ export function CursorOverlay({
const viewport = useViewport(board);
const [containerRect, setContainerRect] = useState<DOMRect | null>(null);

useEffect(() => {
useEffect(() => {
const getContainer = (): Element | null => {
if (containerRef?.current) {
return containerRef.current;
}
// Fallback to DOM query for backward compatibility
const container = document.querySelector('.plait-board-container') as HTMLElement | null;
return container?.querySelector('svg') || container;
return getViewportContainerElement(board);
};

const update = () => {
Expand All @@ -153,7 +152,7 @@ export function CursorOverlay({
window.removeEventListener('resize', update);
observer.disconnect();
};
}, [containerRef]);
}, [containerRef, board]);

const renderableCursors = useMemo((): RenderableCursor[] => {
if (!containerRect) return [];
Expand All @@ -172,10 +171,11 @@ export function CursorOverlay({
visible.forEach((cursor, id) => {
if (count >= maxCursors) return;

const { x, y } = documentToScreen(cursor.documentX, cursor.documentY, viewport);
result.push({
id,
screenX: cursor.documentX * viewport.zoom + viewport.offsetX + containerRect.left,
screenY: cursor.documentY * viewport.zoom + viewport.offsetY + containerRect.top,
screenX: x + containerRect.left,
screenY: y + containerRect.top,
userName: cursor.userName,
userColor: cursor.userColor,
userAvatar: cursor.userAvatar,
Expand Down
15 changes: 6 additions & 9 deletions packages/collaboration/src/cursor-manager.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Cursor, CollaborationUser } from './types';
import type { Viewport } from './utils/viewport';
import { documentToScreen, screenToDocument, type Viewport } from './utils/viewport';

export interface CursorState {
userId: string;
Expand Down Expand Up @@ -57,9 +57,8 @@ export class CursorManager {
viewport: Viewport,
pointerType: 'mouse' | 'pen' | 'touch' = 'mouse'
): void {
const documentX = (clientX - containerRect.left - viewport.offsetX) / viewport.zoom;
const documentY = (clientY - containerRect.top - viewport.offsetY) / viewport.zoom;

const { x: documentX, y: documentY } = screenToDocument(clientX, clientY, containerRect, viewport);

this.pendingUpdate = { x: documentX, y: documentY };
this.pendingPointer = pointerType;

Expand Down Expand Up @@ -150,8 +149,7 @@ export class CursorManager {
}

getCursorScreenState(cursor: CursorState, viewport: Viewport): CursorState & { screenX: number; screenY: number } {
const screenX = cursor.documentX * viewport.zoom + viewport.offsetX;
const screenY = cursor.documentY * viewport.zoom + viewport.offsetY;
const { x: screenX, y: screenY } = documentToScreen(cursor.documentX, cursor.documentY, viewport);
return { ...cursor, screenX, screenY };
}

Expand Down Expand Up @@ -242,9 +240,8 @@ export function getVisibleCursors(
const margin = 100;

for (const [id, cursor] of cursors) {
const screenX = cursor.documentX * viewport.zoom + viewport.offsetX;
const screenY = cursor.documentY * viewport.zoom + viewport.offsetY;

const { x: screenX, y: screenY } = documentToScreen(cursor.documentX, cursor.documentY, viewport);

if (screenX < -margin || screenX > screenWidth + margin) continue;
if (screenY < -margin || screenY > screenHeight + margin) continue;

Expand Down
24 changes: 14 additions & 10 deletions packages/collaboration/src/hooks/use-cursor-tracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ import {
createCursorManager,
} from '../cursor-manager';
import type { Cursor, CollaborationUser } from '../types';
import { getViewport, type Viewport } from '../utils';
import {
getViewport,
getViewportContainerElement,
documentToScreen,
type Viewport,
} from '../utils';

export interface UseCursorTrackingOptions {
board: PlaitBoard | null;
Expand Down Expand Up @@ -72,11 +77,13 @@ export function useCursorTracking({
const handlePointerMove = (e: PointerEvent) => {
const target = e.target;
if (!(target instanceof Element)) return;

const svg = target.closest('svg');
if (!svg || !boardRef.current) return;

const rect = svg.getBoundingClientRect();
if (!target.closest('.plait-board-container') || !boardRef.current) return;

const container = getViewportContainerElement(boardRef.current);
if (!container) return;

const rect = container.getBoundingClientRect();
const viewport = getViewport(boardRef.current);

if (rafId) {
Expand Down Expand Up @@ -156,9 +163,6 @@ export function useCursorScreenState(
cursor: CursorState,
viewport: Viewport
): CursorState & { screenX: number; screenY: number } {
return {
...cursor,
screenX: cursor.documentX * viewport.zoom + viewport.offsetX,
screenY: cursor.documentY * viewport.zoom + viewport.offsetY,
};
const { x: screenX, y: screenY } = documentToScreen(cursor.documentX, cursor.documentY, viewport);
return { ...cursor, screenX, screenY };
}
16 changes: 11 additions & 5 deletions packages/collaboration/src/hooks/use-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useEffect, useRef, useCallback } from 'react';
import { useStorage, useMutation, useSelf } from '@liveblocks/react/suspense';
import type { PlaitElement, PlaitBoard } from '@plait/core';
import { usePresence } from '../providers/liveblocks/hooks';
import { getViewport, getViewportContainerElement, screenToDocument } from '../utils';

interface UseBoardSyncOptions {
board: PlaitBoard | null;
Expand Down Expand Up @@ -70,12 +71,17 @@ export function useBoardCursorTracking(board: PlaitBoard | null, enabled: boolea
const handlePointerMove = (e: Event) => {
const pointerEvent = e as PointerEvent;
const target = pointerEvent.target as SVGElement | HTMLElement;
const svg = target.closest('svg');
if (!svg) return;
if (!target.closest('.plait-board-container')) return;

const rect = svg.getBoundingClientRect();
const x = (pointerEvent.clientX - rect.left - board.viewport.offsetX) / board.viewport.zoom;
const y = (pointerEvent.clientY - rect.top - board.viewport.offsetY) / board.viewport.zoom;
const container = getViewportContainerElement(board);
if (!container) return;

const { x, y } = screenToDocument(
pointerEvent.clientX,
pointerEvent.clientY,
container.getBoundingClientRect(),
getViewport(board)
);

updateCursor({ x, y, pointer: pointerEvent.pointerType as 'mouse' | 'pen' | 'touch' });
};
Expand Down
8 changes: 6 additions & 2 deletions packages/collaboration/src/hooks/use-viewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@ import { useEffect, useState, useRef } from 'react';
import type { PlaitBoard } from '@plait/core';
import { getViewport, type Viewport } from '../utils';

const DEFAULT_VIEWPORT: Viewport = { zoom: 1, offsetX: 0, offsetY: 0 };
const DEFAULT_VIEWPORT: Viewport = { zoom: 1, originationX: 0, originationY: 0 };

function viewportsEqual(a: Viewport, b: Viewport): boolean {
return a.zoom === b.zoom && a.offsetX === b.offsetX && a.offsetY === b.offsetY;
return (
a.zoom === b.zoom &&
a.originationX === b.originationX &&
a.originationY === b.originationY
);
}

export function useViewport(board: PlaitBoard | null): Viewport {
Expand Down
8 changes: 7 additions & 1 deletion packages/collaboration/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
export { getViewport, screenToDocument, documentToScreen, type Viewport } from './viewport';
export {
getViewport,
screenToDocument,
documentToScreen,
getViewportContainerElement,
type Viewport,
} from './viewport';
export { debounce, throttle } from './timing';
Loading
Loading