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 Infinite Workspace
Section titled “The Infinite Workspace”The canvas has no boundaries. You can pan in any direction and zoom in or out. There’s always more space.
Navigation
Section titled “Navigation”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.
Coordinate System
Section titled “Coordinate System”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.
Tiles: The Building Blocks
Section titled “Tiles: The Building Blocks”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.
Tile Types
Section titled “Tile Types”Flowstate supports 10 tile types:
| Type | Description | Icon/Visual |
|---|---|---|
office | Team workspace owned by you | Office icon + name |
invited_office | Team workspace you’re a member of | Office icon + name |
room | Discussion space with sparks | Room color + title |
spark | Individual idea or request | Spark icon + text |
shelf | Collection of links | Shelf icon |
passport | Your professional profile | Passport icon |
motd | Message of the day (platform announcement) | MOTD icon + message |
widget | Installed widget tool | Widget UI preview |
widget_store | Widget marketplace | Store icon |
ai_presence | AI assistant presence indicator | AI avatar |
Each tile type has its own visual design, but they all share the same interaction model.
Tile Anatomy
Section titled “Tile Anatomy”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.
Tile Dimensions
Section titled “Tile Dimensions”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.
Tile Interactions
Section titled “Tile Interactions”Flowstate uses a click-to-select interaction model. This is a hard rule for all tiles.
Selection
Section titled “Selection”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.
Movement
Section titled “Movement”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.
Actions
Section titled “Actions”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.
The Click-to-Select Rule
Section titled “The Click-to-Select Rule”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 Grid System
Section titled “The Grid System”The canvas uses a grid to keep tiles aligned. The grid is invisible, but you feel it when you move tiles.
Grid Size
Section titled “Grid Size”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).
Why a Grid?
Section titled “Why a Grid?”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.
Snap Behavior
Section titled “Snap Behavior”When you release a tile, the system:
- Calculates the nearest grid position
- Checks if that position collides with another tile
- If it collides, finds the nearest non-colliding position
- Moves the tile to the final position
- Publishes a position update event to Redis
This all happens in a few milliseconds. The tile appears to snap into place smoothly.
Collision Detection
Section titled “Collision Detection”Tiles cannot overlap. The system prevents it.
How It Works
Section titled “How It Works”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:
- Get the bounding box of the tile at the new position
- Get the bounding boxes of all other tiles
- Check for overlaps using rectangle intersection
- If an overlap is found, try nearby positions (up, down, left, right)
- Return the first non-overlapping position
Collision Avoidance
Section titled “Collision Avoidance”The system tries to place the tile as close to your intended position as possible. It checks positions in this order:
- Exact position (if no collision)
- One grid cell up
- One grid cell down
- One grid cell left
- One grid cell right
- Two grid cells up
- Two grid cells down
- (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
Section titled “Pinned Tiles”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.
Multi-Select
Section titled “Multi-Select”You can select multiple tiles at once to perform bulk operations.
Selecting Multiple Tiles
Section titled “Selecting Multiple Tiles”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.
Multi-Select Toolbar
Section titled “Multi-Select Toolbar”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)
Individual Tile Actions Hidden
Section titled “Individual Tile Actions Hidden”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.
Position Persistence
Section titled “Position Persistence”Every tile’s position is stored in the database. When you move a tile, the new position is saved immediately.
The CanvasPosition Model
Section titled “The CanvasPosition Model”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.
Unique Constraint
Section titled “Unique Constraint”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.
Default Positions
Section titled “Default Positions”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:
- Start at the origin (0, 0)
- Check if that position is occupied
- If occupied, try positions in a spiral pattern (up, down, left, right, etc.)
- Return the first unoccupied position
This ensures new tiles appear in a sensible location, not on top of existing tiles.
Real-Time Sync
Section titled “Real-Time Sync”When you move a tile, the change syncs to all your connected devices in real-time.
How Sync Works
Section titled “How Sync Works”- You drag a tile to a new position
- The frontend sends a
POST /api/canvas/positions/:idrequest to the backend - The backend updates the position in the database
- The backend publishes a
position:updateevent to Redis - All backend instances subscribed to your user channel receive the event
- Each backend instance sends the event to your connected devices via SSE
- 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.
Event Format
Section titled “Event Format”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.
Conflict Resolution
Section titled “Conflict Resolution”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.
The TileContainer Component
Section titled “The TileContainer Component”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.
TileContainer Props
Section titled “TileContainer Props”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}Using TileContainer
Section titled “Using TileContainer”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.
Toolbar Visibility
Section titled “Toolbar Visibility”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.
Action Buttons
Section titled “Action Buttons”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
Section titled “Widget Tiles”Widget tiles have special rules because they contain interactive content.
Read-Only Preview
Section titled “Read-Only Preview”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.
Expanded Modal
Section titled “Expanded Modal”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.
Canvas State Management
Section titled “Canvas State Management”The canvas state is managed by Zustand stores in the frontend.
Canvas Store
Section titled “Canvas Store”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;}SSE Store
Section titled “SSE Store”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.
Canvas Scopes
Section titled “Canvas Scopes”Flowstate supports multiple canvas scopes. Each scope is a separate workspace with its own set of positions.
Default Scope
Section titled “Default Scope”The default scope is "main". This is the primary canvas where most work happens.
Custom Scopes
Section titled “Custom Scopes”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.
Performance Considerations
Section titled “Performance Considerations”The canvas is designed to handle hundreds of tiles without performance degradation.
Virtualization
Section titled “Virtualization”Only visible tiles are rendered. Off-screen tiles are virtualized (not in the DOM). This keeps the DOM size small and rendering fast.
Debounced Updates
Section titled “Debounced Updates”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.
Optimistic Updates
Section titled “Optimistic Updates”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.
Lazy Loading
Section titled “Lazy Loading”Tile content is lazy-loaded. The TileContainer renders immediately, but the content inside loads asynchronously. This keeps the initial render fast.
Accessibility
Section titled “Accessibility”The canvas is designed to be accessible to keyboard and screen reader users.
Keyboard Navigation
Section titled “Keyboard Navigation”- 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
Screen Reader Support
Section titled “Screen Reader Support”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”).
Future Enhancements
Section titled “Future Enhancements”Collaborative Cursors
Section titled “Collaborative Cursors”Show other users’ cursors on the canvas in real-time. This helps teams see what others are working on.
Tile Grouping
Section titled “Tile Grouping”Allow users to group tiles into folders or collections. Groups can be collapsed to save space.
Custom Layouts
Section titled “Custom Layouts”Let users save and restore canvas layouts. Switch between different layouts for different contexts (e.g., “Work”, “Personal”, “Archive”).
Tile Filtering
Section titled “Tile Filtering”Add a search/filter bar to show only tiles matching certain criteria (e.g., “show only Rooms”, “show only pinned tiles”).
Tile Sorting
Section titled “Tile Sorting”Provide automatic sorting options (e.g., “sort by creation date”, “sort by last modified”, “sort alphabetically”).
Summary
Section titled “Summary”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.