Skip to content

The Canvas

The canvas is the heart of Flowstate. It’s an infinite 2D workspace where all your work lives. Instead of organizing things in folders or lists, you organize them in space. Related items sit close together. Important items sit in prominent positions. Archived work moves to the edges.

This document explains how the canvas works, how tiles behave, and how the system keeps everything in sync.

The canvas has no boundaries. You can pan in any direction and zoom in or out. There’s always more space.

Panning: Click and drag the background to move around the canvas. On trackpads, use two-finger swipe. The canvas follows your movement smoothly.

Zooming: Use your mouse wheel or trackpad pinch gesture to zoom. Zoom in to focus on details. Zoom out to see the big picture.

Centering: Double-click the background to reset the view to the center of your canvas.

The canvas uses a standard 2D coordinate system:

  • Origin (0, 0): The center of the canvas
  • X-axis: Positive values go right, negative values go left
  • Y-axis: Positive values go down, negative values go up

Tile positions are stored as (x, y) coordinates. When you move a tile, you’re changing its coordinates.

Everything on the canvas appears as a tile. A tile is a visual block that represents an entity: a Room, an Office, a Spark, a Widget, or another object.

Flowstate supports 10 tile types:

TypeDescriptionIcon/Visual
officeTeam workspace owned by youOffice icon + name
invited_officeTeam workspace you’re a member ofOffice icon + name
roomDiscussion space with sparksRoom color + title
sparkIndividual idea or requestSpark icon + text
shelfCollection of linksShelf icon
passportYour professional profilePassport icon
motdMessage of the day (platform announcement)MOTD icon + message
widgetInstalled widget toolWidget UI preview
widget_storeWidget marketplaceStore icon
ai_presenceAI assistant presence indicatorAI avatar

Each tile type has its own visual design, but they all share the same interaction model.

Every tile has these components:

Header: Shows the tile’s name, icon, or color. Identifies what the tile represents.

Body: The main content area. For Rooms, this might show a preview of Sparks. For Widgets, this shows the widget UI.

Toolbar: Appears when the tile is selected. Contains drag grip, pin button, and context menu button.

Selection Highlight: A purple border that appears when the tile is selected.

Tiles have fixed dimensions based on their type:

  • Standard tiles (Room, Office, Shelf, Passport): 280px wide, 200px tall
  • Compact tiles (Spark, MOTD): 240px wide, 120px tall
  • Widget tiles: Variable size based on widget settings (default 320px × 240px)

These dimensions are defined in the codebase and enforced by the TileContainer component.

Flowstate uses a click-to-select interaction model. This is a hard rule for all tiles.

Click to select: Click anywhere on a tile’s surface to select it. The tile gets a purple highlight border, and the toolbar appears.

Escape to deselect: Press Escape to clear the selection.

Click background to deselect: Click the canvas background to deselect all tiles.

Multi-select: Hold Shift and click tiles to select multiple. Or click and drag to draw a selection box around tiles.

Drag to move: Click and drag a selected tile to move it. The tile follows your cursor.

Snap to grid: When you release the tile, it snaps to the nearest grid position. This keeps the canvas clean and aligned.

Collision detection: If the tile would overlap another tile, the system pushes it to the nearest non-overlapping position.

Pinning: Click the pin button in the toolbar to lock the tile’s position. Pinned tiles can’t be moved until you unpin them.

Action buttons: Some tiles have action buttons (Enter, Join, Review, etc.). These are separate from the tile surface. Clicking an action button performs the action without selecting the tile.

Context menu: Right-click a tile (or click the three-dot button in the toolbar) to open a context menu with additional actions.

Expand: Some tiles (Widgets, Rooms) can be expanded into a full-screen modal. Click the expand button or double-click the tile.

This is critical: clicking a tile’s surface always selects it, never navigates.

If a tile needs a navigation action (like entering a Room or joining an Office), it must have a dedicated action button. The action button calls e.stopPropagation() to prevent the click from bubbling up to the tile surface.

This rule ensures consistent behavior across all tiles. Users know that clicking a tile selects it, and clicking a button performs an action.

The canvas uses a grid to keep tiles aligned. The grid is invisible, but you feel it when you move tiles.

The grid size is 20 pixels. All tile positions snap to multiples of 20.

If you drop a tile at (123, 456), it snaps to (120, 460).

Visual Alignment: The grid keeps tiles aligned with each other. This makes the canvas look clean and organized.

Predictable Movement: Tiles move in discrete steps, not pixel-by-pixel. This makes movement feel intentional, not jittery.

Collision Detection: The grid simplifies collision detection. Tiles occupy grid cells, and the system checks for overlaps at the cell level.

When you release a tile, the system:

  1. Calculates the nearest grid position
  2. Checks if that position collides with another tile
  3. If it collides, finds the nearest non-colliding position
  4. Moves the tile to the final position
  5. Publishes a position update event to Redis

This all happens in a few milliseconds. The tile appears to snap into place smoothly.

Tiles cannot overlap. The system prevents it.

When you move a tile, the system checks if the new position would overlap another tile. If it would, the system finds the nearest position that doesn’t overlap.

The collision detection algorithm:

  1. Get the bounding box of the tile at the new position
  2. Get the bounding boxes of all other tiles
  3. Check for overlaps using rectangle intersection
  4. If an overlap is found, try nearby positions (up, down, left, right)
  5. Return the first non-overlapping position

The system tries to place the tile as close to your intended position as possible. It checks positions in this order:

  1. Exact position (if no collision)
  2. One grid cell up
  3. One grid cell down
  4. One grid cell left
  5. One grid cell right
  6. Two grid cells up
  7. Two grid cells down
  8. (and so on, spiraling outward)

This ensures the tile ends up near where you wanted it, even if the exact position is blocked.

Pinned tiles are excluded from collision detection. They’re treated as immovable obstacles. If you try to move a tile to a position occupied by a pinned tile, the system finds an alternative position.

You can select multiple tiles at once to perform bulk operations.

Shift-click: Hold Shift and click tiles to add them to the selection.

Selection box: Click and drag on the background to draw a selection box. All tiles inside the box are selected.

Select all: Press Cmd+A (macOS) or Ctrl+A (Windows/Linux) to select all tiles.

When 2 or more tiles are selected, a MultiSelectToolbar appears on the left side of the canvas. It provides bulk operations:

Alignment:

  • Align left: Align all tiles to the leftmost tile’s left edge
  • Align right: Align all tiles to the rightmost tile’s right edge
  • Align top: Align all tiles to the topmost tile’s top edge
  • Align bottom: Align all tiles to the bottommost tile’s bottom edge
  • Center horizontally: Align all tiles to the horizontal center
  • Center vertically: Align all tiles to the vertical center

Distribution:

  • Distribute horizontally: Space tiles evenly along the X-axis
  • Distribute vertically: Space tiles evenly along the Y-axis

Bulk Actions:

  • Pin all: Pin all selected tiles
  • Unpin all: Unpin all selected tiles
  • Delete all: Delete all selected tiles (with confirmation)

When multiple tiles are selected, individual tile action buttons (Enter, Join, etc.) are hidden. This prevents accidental actions when you’re trying to organize tiles.

The context menu button remains visible, but it shows a multi-select context menu with bulk actions instead of individual tile actions.

Every tile’s position is stored in the database. When you move a tile, the new position is saved immediately.

Positions are stored in the CanvasPosition collection:

{
id: string; // Unique position ID
userId: string; // Owner of this position
itemType: string; // Type of tile (room, office, widget, etc.)
itemId: string; // ID of the entity
canvasScope: string; // Which canvas (default: "main")
x: number; // X coordinate
y: number; // Y coordinate
isPinned: boolean; // Whether the tile is pinned
createdAt: Date;
updatedAt: Date;
}

Each user has their own set of positions. If you move a Room tile, it moves on your canvas, but not on anyone else’s canvas.

The database enforces a unique constraint on (userId, itemType, itemId, canvasScope). This ensures each tile has exactly one position per user per canvas.

If you try to create a duplicate position, the database rejects it.

When a new entity is created (a Room, an Office, etc.), the backend generates a default position for it. The default position is calculated to avoid collisions with existing tiles.

The algorithm:

  1. Start at the origin (0, 0)
  2. Check if that position is occupied
  3. If occupied, try positions in a spiral pattern (up, down, left, right, etc.)
  4. Return the first unoccupied position

This ensures new tiles appear in a sensible location, not on top of existing tiles.

When you move a tile, the change syncs to all your connected devices in real-time.

  1. You drag a tile to a new position
  2. The frontend sends a POST /api/canvas/positions/:id request to the backend
  3. The backend updates the position in the database
  4. The backend publishes a position:update event to Redis
  5. All backend instances subscribed to your user channel receive the event
  6. Each backend instance sends the event to your connected devices via SSE
  7. Your other devices receive the event and update the tile’s position

This all happens in under 100 milliseconds. The tile appears to move simultaneously on all your devices.

Position update events look like this:

{
"type": "position:update",
"data": {
"id": "pos_abc123",
"itemType": "room",
"itemId": "room_xyz789",
"x": 240,
"y": 180,
"isPinned": false
}
}

The frontend receives this event and updates the corresponding tile’s position in the canvas store.

If you move a tile on two devices at the same time, the last write wins. The backend processes requests in the order they arrive. The second request overwrites the first.

This is acceptable because position conflicts are rare and low-stakes. If a tile ends up in the wrong position, you can just move it again.

Every tile on the canvas is wrapped in a TileContainer component. This component provides all the standard tile behavior: selection, dragging, pinning, context menu, etc.

interface TileContainerProps {
id: string; // Tile ID
type: string; // Tile type (room, office, etc.)
x: number; // X position
y: number; // Y position
width: number; // Tile width
height: number; // Tile height
isPinned?: boolean; // Whether the tile is pinned
isSelected?: boolean; // Whether the tile is selected
onClick?: () => void; // Click handler (selection)
onMove?: (x, y) => void; // Move handler
onPin?: () => void; // Pin handler
onUnpin?: () => void; // Unpin handler
onContextMenu?: () => void; // Context menu handler
children: ReactNode; // Tile content
}

To create a new tile type, wrap your content in TileContainer:

<TileContainer
id={room.id}
type="room"
x={position.x}
y={position.y}
width={280}
height={200}
isPinned={position.isPinned}
isSelected={selectedBlockIds.includes(room.id)}
onClick={() => selectBlock(room.id)}
onMove={(x, y) => updatePosition(room.id, x, y)}
onPin={() => pinPosition(room.id)}
onUnpin={() => unpinPosition(room.id)}
onContextMenu={() => openContextMenu(room.id)}
>
<RoomTileContent room={room} />
</TileContainer>

The TileContainer handles all the interaction logic. Your content component just renders the tile’s appearance.

The toolbar (drag grip, pin button, context menu button) appears when the tile is selected. It’s always visible when selected, no hover required.

This ensures the toolbar is accessible on touch devices and makes the interaction model consistent.

If your tile has action buttons (Enter, Join, etc.), they must call e.stopPropagation() to prevent the click from selecting the tile:

<button
onClick={(e) => {
e.stopPropagation();
enterRoom(room.id);
}}
>
Enter
</button>

This ensures clicking the button performs the action without selecting the tile.

Widget tiles have special rules because they contain interactive content.

The tile view is a read-only preview. All interactive functionality belongs in the expanded modal view.

No text inputs in tile view. They’re too small to be usable.

No destructive actions (delete buttons) in tile view. Too easy to trigger accidentally.

No add/create forms in tile view. They clutter the compact preview.

No reorder controls in tile view. Arrow buttons and move controls belong in the modal.

No drag-to-reorder inside widgets. It conflicts with the canvas drag system.

Widgets receive an isExpanded prop that indicates whether they’re in tile or modal view:

interface InternalWidgetProps {
isExpanded?: boolean;
}

Use this prop to toggle between preview and interactive mode:

function MyWidget({ isExpanded }: InternalWidgetProps) {
if (isExpanded) {
return <FullInteractiveView />;
}
return <ReadOnlyPreview />;
}

The expanded modal provides a full-screen workspace where users can interact with the widget’s full functionality.

The canvas state is managed by Zustand stores in the frontend.

The useCanvasStore manages canvas-level state:

interface CanvasStore {
positions: CanvasPosition[]; // All tile positions
selectedBlockIds: string[]; // Selected tile IDs
viewportX: number; // Pan X offset
viewportY: number; // Pan Y offset
zoom: number; // Zoom level
selectBlock: (id) => void;
deselectBlock: (id) => void;
clearSelection: () => void;
updatePosition: (id, x, y) => void;
pinPosition: (id) => void;
unpinPosition: (id) => void;
setViewport: (x, y) => void;
setZoom: (zoom) => void;
}

The useSSEStore manages the real-time sync connection:

interface SSEStore {
connected: boolean; // Whether SSE is connected
lastEventId: string; // Last received event ID
connect: () => void;
disconnect: () => void;
handleEvent: (event) => void;
}

When an SSE event arrives, the handleEvent function updates the canvas store with the new data.

Flowstate supports multiple canvas scopes. Each scope is a separate workspace with its own set of positions.

The default scope is "main". This is the primary canvas where most work happens.

In the future, Flowstate may support custom scopes for different contexts:

  • "project:{projectId}": A canvas scoped to a specific project
  • "office:{officeId}": A canvas scoped to an office workspace
  • "room:{roomId}": A canvas scoped to a room

Each scope would have its own set of positions, allowing users to organize work in different contexts.

The canvas is designed to handle hundreds of tiles without performance degradation.

Only visible tiles are rendered. Off-screen tiles are virtualized (not in the DOM). This keeps the DOM size small and rendering fast.

Position updates are debounced to avoid flooding the backend with requests. When you drag a tile, the frontend waits until you stop moving before sending the update.

The frontend updates the tile’s position immediately, before the backend responds. This makes the interaction feel instant. If the backend rejects the update, the frontend reverts to the previous position.

Tile content is lazy-loaded. The TileContainer renders immediately, but the content inside loads asynchronously. This keeps the initial render fast.

The canvas is designed to be accessible to keyboard and screen reader users.

  • Tab: Move focus between tiles
  • Arrow keys: Move the focused tile (when selected)
  • Enter: Activate the focused tile’s primary action
  • Escape: Deselect all tiles
  • Cmd/Ctrl+A: Select all tiles

All tiles have ARIA labels that describe their type and content. Screen readers announce the tile’s name and type when focused.

Action buttons have descriptive labels (e.g., “Enter Room: Design Discussion” instead of just “Enter”).

Show other users’ cursors on the canvas in real-time. This helps teams see what others are working on.

Allow users to group tiles into folders or collections. Groups can be collapsed to save space.

Let users save and restore canvas layouts. Switch between different layouts for different contexts (e.g., “Work”, “Personal”, “Archive”).

Add a search/filter bar to show only tiles matching certain criteria (e.g., “show only Rooms”, “show only pinned tiles”).

Provide automatic sorting options (e.g., “sort by creation date”, “sort by last modified”, “sort alphabetically”).

The canvas is an infinite 2D workspace where all your work lives. Tiles represent entities and can be moved, selected, pinned, and organized. The grid system keeps tiles aligned. Collision detection prevents overlaps. Real-time sync ensures changes propagate instantly. The TileContainer component provides consistent interaction behavior across all tile types.

For more details on the data model behind the canvas, see the Data Model documentation.