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
47 changes: 26 additions & 21 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Use this file as the source of truth for agent work in this repo. Keep it factua
- Do not overwrite unrelated user changes in a dirty worktree.
- Every code change must include tests in the same session. Update existing tests when behavior changes.
- Before adding new selectors in tests, look for an existing `data-testid`. If one is missing, add one instead of relying on text or CSS classes.
- Keep component files grouped by component directory, for example `src/components/Canvas/TopBar/TopBar.tsx` with `TopBar.module.css`, instead of mixing multiple components in one folder.

## Stack

Expand Down Expand Up @@ -53,30 +54,31 @@ npm run spell:check # cspell for src TS/TSX
src/
App.tsx App shell; creates default plan on first load
types/index.ts Point, Wall, Box, Element, ToolType, FloorPlan
utils/geometry.ts Coordinate conversion, snapping, dimensions, nudging
utils/storage.ts localStorage read/write helpers
utils/
geometry/geometry.ts Coordinate conversion, snapping, dimensions, nudging
storage/storage.ts localStorage read/write helpers
store/
useFloorplanStore.ts Plans, elements, persistence, undo/redo history
useToolStore.ts Active tool, chain state, selection, measure, zoom, pan
useFloorplanStore/ Plans, elements, persistence, undo/redo history
useToolStore/ Active tool, chain state, selection, measure, zoom, pan
hooks/
useSnap.ts Endpoint/segment/axis/grid snapping
useFocusTrap.ts Overlay/dialog focus handling
useSnap/ Endpoint/segment/axis/grid snapping
useFocusTrap/ Overlay/dialog focus handling
components/
Canvas/
DrawingCanvas.tsx Main stage, pointer/keyboard/wheel handling
Grid.tsx Adaptive grid
WallElement.tsx Wall rendering and editing affordances
BoxElement.tsx Box rendering and dragging
MeasureOverlay.tsx Measurement overlay for measure tool
MultiSelectBar.tsx Actions for multi-selection
ScaleBar.tsx Scale indicator
TopBar.tsx Plan name and plan manager entry point
DrawingCanvas/ Main stage, pointer/keyboard/wheel handling
Grid/ Adaptive grid
WallElement/ Wall rendering and editing affordances
BoxElement/ Box rendering and dragging
MeasureOverlay/ Measurement overlay for measure tool
MultiSelectBar/ Actions for multi-selection
ScaleBar/ Scale indicator
TopBar/ Plan name and plan manager entry point
layout.ts Overlay/mobile layout constants
Toolbar/ Tool switcher, undo/redo, help
PropertiesPanel/ Single-selection editing
PropertiesPanel/FtInInput/ Feet-and-inches field
FloorplanManager/ Create, rename, delete, switch plans
HelpOverlay/ Help modal shown on first visit
src/tests/unit/ Unit tests
e2e/ Playwright scenarios
```

Expand Down Expand Up @@ -105,7 +107,7 @@ type Box = {
type ToolType = 'select' | 'wall' | 'box' | 'measure';
```

`FloorPlan` stores `id`, `name`, timestamps, and `elements`.
`FloorPlan` stores `id`, `version`, `name`, timestamps, and `elements`.

## Core Invariants

Expand All @@ -127,7 +129,7 @@ Snap priority is:
3. Axis lock from the active wall chain origin
4. Grid

This logic lives in `src/hooks/useSnap.ts`. Preserve that ordering unless the task explicitly changes snapping behavior and tests are updated with it.
This logic lives in `src/hooks/useSnap/useSnap.ts`. Preserve that ordering unless the task explicitly changes snapping behavior and tests are updated with it.

### Stage rendering

Expand All @@ -142,10 +144,13 @@ This logic lives in `src/hooks/useSnap.ts`. Preserve that ordering unless the ta

### Persistence

- Storage helpers are in `src/utils/storage.ts`.
- Storage helpers are in `src/utils/storage/storage.ts`.
- Each persisted plan carries a schema `version`.
- Floor plans persist on mutating store actions.
- Undo/redo history is session-only and not persisted.
- Corrupt or missing storage data should degrade safely to empty/default state.
- If a change would break compatibility with existing persisted plan data, bump the plan version constant and add a migration path for older plans in `loadFloorPlans()`.
- Do not ship a breaking persisted-data change without tests that cover both the legacy load path and the migrated current shape.

## Current Tooling and Behaviors

Expand Down Expand Up @@ -174,8 +179,8 @@ Tests are mandatory for every code change.

| Change type | Test location |
|---|---|
| Geometry, parsing, conversion, snapping helpers | `src/tests/unit/geometry.test.ts` or `src/tests/unit/useSnap.test.ts` |
| Store mutations, persistence, undo/redo | `src/tests/unit/useFloorplanStore.test.ts` or `src/tests/unit/useToolStore.test.ts` |
| Geometry, parsing, conversion, snapping helpers | Co-located spec next to the source file, for example `src/utils/geometry/geometry.test.ts` or `src/hooks/useSnap/useSnap.test.ts` |
| Store mutations, persistence, undo/redo | Co-located spec next to the store file, for example `src/store/useFloorplanStore/useFloorplanStore.test.ts` or `src/store/useToolStore/useToolStore.test.ts` |
| Drawing interactions, keyboard handling, cancel flows, selection, mobile UI behavior | `e2e/floorplan.spec.ts` or a new `e2e/*.spec.ts` |

### Unit test expectations
Expand Down Expand Up @@ -211,7 +216,7 @@ Keep these covered:

Typical verification choices:

- Geometry/store-only change: `npm run test -- src/tests/unit/...`
- Geometry/store-only change: `npm run test -- src/path/to/file.test.ts`
- UI interaction change: `npm run test:e2e -- e2e/...`
- Cross-cutting change: `npm run test`, then `npm run build`

Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ SnapDraft stores plan data in world coordinates measured in feet. Rendering is h

The app starts with a default plan on first load. If a saved plan already has content, the canvas fits that content on open.

Persisted floor plans also carry a schema `version`. When a future change would break compatibility with existing saved plan data, the plan version must be bumped and `src/utils/storage/storage.ts` must include a migration path so older plans still load into the current shape.

## Tools and Shortcuts

| Tool | Shortcut | Use |
Expand Down Expand Up @@ -91,7 +93,9 @@ Dimension fields accept common architectural shorthand:

## Testing

Unit tests live under `src/tests/unit/`. End-to-end tests live under `e2e/`.
Unit tests are co-located with the files they cover under `src/`. End-to-end tests live under `e2e/`.

Component source is also grouped by component directory, for example `src/components/Canvas/TopBar/TopBar.tsx` or `src/components/Toolbar/Toolbar.tsx` with colocated CSS and tests, instead of mixing many components in one folder.

Typical workflows:

Expand Down Expand Up @@ -129,10 +133,9 @@ GitHub Actions runs:

```text
src/
components/ UI and canvas components
components/ UI and canvas components, grouped into per-component folders
hooks/ Shared hooks such as snapping and focus management
store/ Zustand stores for floor plan data and tool state
tests/unit/ Unit tests
types/ Shared TypeScript models
utils/ Geometry and storage helpers
e2e/ Playwright tests
Expand All @@ -141,5 +144,7 @@ e2e/ Playwright tests
## Contributing Notes

- Keep persisted geometry in feet, not pixels.
- Keep persisted plan schema changes backward-compatible when possible.
- If you make a breaking change to persisted plan data, bump the plan version and add a tested migration path in `src/utils/storage/storage.ts`.
- Use `stage.getRelativePointerPosition()` for world-coordinate math.
- Prefer `data-testid` selectors for UI tests.
10 changes: 5 additions & 5 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { useEffect, useState } from 'react';
import { DrawingCanvas } from './components/Canvas/DrawingCanvas';
import { DrawingCanvas } from './components/Canvas/DrawingCanvas/DrawingCanvas';
import { Toolbar } from './components/Toolbar/Toolbar';
import { TopBar } from './components/Canvas/TopBar';
import { TopBar } from './components/Canvas/TopBar/TopBar';
import { PropertiesPanel } from './components/PropertiesPanel/PropertiesPanel';
import { HelpOverlay } from './components/HelpOverlay/HelpOverlay';
import { ScaleBar } from './components/Canvas/ScaleBar';
import { MultiSelectBar } from './components/Canvas/MultiSelectBar';
import { useFloorplanStore } from './store/useFloorplanStore';
import { ScaleBar } from './components/Canvas/ScaleBar/ScaleBar';
import { MultiSelectBar } from './components/Canvas/MultiSelectBar/MultiSelectBar';
import { useFloorplanStore } from './store/useFloorplanStore/useFloorplanStore';
import styles from './App.module.css';

export default function App() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { useRef } from 'react';
import { Group, Rect, Text, Circle, Line } from 'react-konva';
import type Konva from 'konva';
import { useToolStore } from '../../store/useToolStore';
import { useFloorplanStore } from '../../store/useFloorplanStore';
import type { Box } from '../../types';
import { ftToPx, pxToFt, formatFeet } from '../../utils/geometry';
import { useToolStore } from '../../../store/useToolStore/useToolStore';
import { useFloorplanStore } from '../../../store/useFloorplanStore/useFloorplanStore';
import type { Box } from '../../../types';
import { ftToPx, pxToFt, formatFeet } from '../../../utils/geometry/geometry';

const HANDLE_OFFSET_PX = 22;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import { useRef, useState, useEffect, useCallback } from 'react';
import { Stage, Layer, Line, Rect, Circle, Text, Group } from 'react-konva';
import type Konva from 'konva';
import { nanoid } from 'nanoid';
import { useFloorplanStore } from '../../store/useFloorplanStore';
import { useToolStore } from '../../store/useToolStore';
import { useSnap } from '../../hooks/useSnap';
import { Grid } from './Grid';
import { WallElement } from './WallElement';
import { BoxElement } from './BoxElement';
import { MeasureOverlay } from './MeasureOverlay';
import { useFloorplanStore } from '../../../store/useFloorplanStore/useFloorplanStore';
import { useToolStore } from '../../../store/useToolStore/useToolStore';
import { useSnap } from '../../../hooks/useSnap/useSnap';
import { Grid } from '../Grid/Grid';
import { WallElement } from '../WallElement/WallElement';
import { BoxElement } from '../BoxElement/BoxElement';
import { MeasureOverlay } from '../MeasureOverlay/MeasureOverlay';
import styles from './DrawingCanvas.module.css';
import {
ftToPx,
Expand All @@ -20,14 +20,14 @@ import {
NUDGE_FT,
FINE_NUDGE_FT,
getWallSnapIncrement,
} from '../../utils/geometry';
import type { Point, Element } from '../../types';
} from '../../../utils/geometry/geometry';
import type { Point, Element } from '../../../types';
import {
FIT_CONTENT_PADDING_PX,
MOBILE_OVERLAY_CLEARANCE_PX,
MOBILE_TOOLBAR_INSET_PX,
shouldUseMobileOverlayLayout,
} from './layout';
} from '../layout';

const DRAG_THRESHOLD_FT = 0.3;
const CLOSE_CHAIN_RADIUS_FT = 0.5;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Line, Rect } from 'react-konva';
import { useMemo } from 'react';
import { PIXELS_PER_FOOT } from '../../utils/geometry';
import { PIXELS_PER_FOOT } from '../../../utils/geometry/geometry';

type Props = {
width: number;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Group, Line, Rect, Text } from 'react-konva';
import { distance, formatFeet } from '../../utils/geometry';
import type { Point } from '../../types';
import { distance, formatFeet } from '../../../utils/geometry/geometry';
import type { Point } from '../../../types';

type Props = {
start: Point;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useToolStore } from '../../store/useToolStore';
import { useFloorplanStore } from '../../store/useFloorplanStore';
import { useToolStore } from '../../../store/useToolStore/useToolStore';
import { useFloorplanStore } from '../../../store/useFloorplanStore/useFloorplanStore';
import styles from './MultiSelectBar.module.css';

export function MultiSelectBar() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useRef, useState } from 'react';
import { ChevronDown } from 'lucide-react';
import { useFloorplanStore } from '../../store/useFloorplanStore';
import { FloorplanManager } from '../FloorplanManager/FloorplanManager';
import { useFloorplanStore } from '../../../store/useFloorplanStore/useFloorplanStore';
import { FloorplanManager } from '../../FloorplanManager/FloorplanManager';
import styles from './TopBar.module.css';

export function TopBar() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useRef, useState } from 'react';
import { Line, Circle } from 'react-konva';
import type Konva from 'konva';
import { useToolStore } from '../../store/useToolStore';
import { useFloorplanStore } from '../../store/useFloorplanStore';
import type { Wall, Point } from '../../types';
import { useToolStore } from '../../../store/useToolStore/useToolStore';
import { useFloorplanStore } from '../../../store/useFloorplanStore/useFloorplanStore';
import type { Wall, Point } from '../../../types';
import {
ftToPx,
pxToFt,
Expand All @@ -12,7 +12,7 @@ import {
distance,
SNAP_RADIUS_FT,
getWallSnapIncrement,
} from '../../utils/geometry';
} from '../../../utils/geometry/geometry';

function moveOtherSelected(
node: Konva.Node,
Expand Down
4 changes: 2 additions & 2 deletions src/components/FloorplanManager/FloorplanManager.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useRef, useState } from 'react';
import { X, Pencil, Plus } from 'lucide-react';
import { useFloorplanStore } from '../../store/useFloorplanStore';
import { useFocusTrap } from '../../hooks/useFocusTrap';
import { useFloorplanStore } from '../../store/useFloorplanStore/useFloorplanStore';
import { useFocusTrap } from '../../hooks/useFocusTrap/useFocusTrap';
import styles from './FloorplanManager.module.css';

type Props = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { HelpOverlay } from '../../components/HelpOverlay/HelpOverlay';
import styles from '../../components/HelpOverlay/HelpOverlay.module.css';
import { HelpOverlay } from './HelpOverlay';
import styles from './HelpOverlay.module.css';

describe('HelpOverlay', () => {
let container: HTMLDivElement;
Expand Down
2 changes: 1 addition & 1 deletion src/components/HelpOverlay/HelpOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useRef } from 'react';
import { X } from 'lucide-react';
import { useFocusTrap } from '../../hooks/useFocusTrap';
import { useFocusTrap } from '../../hooks/useFocusTrap/useFocusTrap';
import styles from './HelpOverlay.module.css';

type Props = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState, useId } from 'react';
import { formatFeet, parseFtIn } from '../../utils/geometry';
import { formatFeet, parseFtIn } from '../../../utils/geometry/geometry';
import styles from './FtInInput.module.css';

type Props = {
Expand Down
8 changes: 4 additions & 4 deletions src/components/PropertiesPanel/PropertiesPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useState } from 'react';
import { useFloorplanStore } from '../../store/useFloorplanStore';
import { useToolStore } from '../../store/useToolStore';
import { useFloorplanStore } from '../../store/useFloorplanStore/useFloorplanStore';
import { useToolStore } from '../../store/useToolStore/useToolStore';
import type { Box, Element, Wall } from '../../types';
import { collectConnectedWallIds, formatFeet } from '../../utils/geometry';
import { FtInInput } from './FtInInput';
import { collectConnectedWallIds, formatFeet } from '../../utils/geometry/geometry';
import { FtInInput } from './FtInInput/FtInInput';
import styles from './PropertiesPanel.module.css';

function WallProperties({ wall, onDelete }: { wall: Wall; onDelete: () => void }) {
Expand Down
4 changes: 2 additions & 2 deletions src/components/Toolbar/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import {
Redo2,
HelpCircle,
} from 'lucide-react';
import { useToolStore } from '../../store/useToolStore';
import { useFloorplanStore } from '../../store/useFloorplanStore';
import { useToolStore } from '../../store/useToolStore/useToolStore';
import { useFloorplanStore } from '../../store/useFloorplanStore/useFloorplanStore';
import type { ToolType } from '../../types';
import styles from './Toolbar.module.css';

Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, it, expect } from 'vitest';
import { collectEndpoints, findNearestOnSegments, axisSnap } from '../../hooks/useSnap';
import { findNearestEndpoint, WALL_SNAP_FT, FINE_WALL_SNAP_FT } from '../../utils/geometry';
import { collectEndpoints, findNearestOnSegments, axisSnap } from './useSnap';
import {
findNearestEndpoint,
WALL_SNAP_FT,
FINE_WALL_SNAP_FT,
} from '../../utils/geometry/geometry';
import type { Element } from '../../types';

const wall = (points: { x: number; y: number }[]): Element => ({
Expand Down
4 changes: 2 additions & 2 deletions src/hooks/useSnap.ts → src/hooks/useSnap/useSnap.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback } from 'react';
import type { Point, Element } from '../types';
import type { Point, Element } from '../../types';
import {
snapPointToGrid,
snapToGrid,
Expand All @@ -8,7 +8,7 @@ import {
distance,
SNAP_RADIUS_FT,
GRID_SNAP_FT,
} from '../utils/geometry';
} from '../../utils/geometry/geometry';

function collectEndpoints(elements: Element[]): Point[] {
const pts: Point[] = [];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useFloorplanStore } from '../../store/useFloorplanStore';
import { useFloorplanStore } from './useFloorplanStore';
import type { Element } from '../../types';

const wall = (id: string): Element => ({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useFloorplanStore } from '../../store/useFloorplanStore';
import { useFloorplanStore } from './useFloorplanStore';
import { FLOORPLAN_VERSION } from '../../utils/storage/storage';
import type { Element } from '../../types';

const wall = (id = 'w1'): Element => ({
Expand Down Expand Up @@ -37,6 +38,7 @@ describe('createPlan', () => {
const id = useFloorplanStore.getState().createPlan();
const plan = useFloorplanStore.getState().plans.find((p) => p.id === id);
expect(plan).toBeDefined();
expect(plan!.version).toBe(FLOORPLAN_VERSION);
expect(plan!.name).toBe('Untitled Plan');
expect(plan!.elements).toHaveLength(0);
});
Expand Down Expand Up @@ -223,6 +225,7 @@ describe('persistence to localStorage', () => {
const stored = localStorage.getItem('snapdraft_floorplans');
expect(stored).not.toBeNull();
const parsed = JSON.parse(stored!);
expect(parsed.find((p: { id: string }) => p.id === id)?.version).toBe(FLOORPLAN_VERSION);
expect(parsed.find((p: { id: string }) => p.id === id)?.name).toBe('Persisted');
});

Expand Down
Loading
Loading