diff --git a/frontend/e2e/atlas-shape-tool.spec.ts b/frontend/e2e/atlas-shape-tool.spec.ts
new file mode 100644
index 00000000..bbd6a011
--- /dev/null
+++ b/frontend/e2e/atlas-shape-tool.spec.ts
@@ -0,0 +1,150 @@
+import { test, expect } from './fixtures/server'
+import { dragBetween } from './fixtures/atlasBoard'
+import { contextMenu } from './fixtures/contextMenu'
+import { ATLAS_KIND_TOPIC, selectKind } from './fixtures/kindPicker'
+
+// The shape tool (goal 0169 slice 5): drag-to-draw lands a rectangle/
+// ellipse/arrow as a board-local BoardObject -- NEVER a card, matching
+// image/ink's own goal 0179 correction. One tray tool covers all three
+// types, picked via the style picker while armed; drawing several never
+// interrupts drawing to commit. Shared pool: every entity created here
+// is deleted here.
+//
+// Real pointer-capture drag, not React Flow's own internal drag
+// machinery (the class QUARANTINE.md's box-select/NodeResizer entries
+// document as unreliable to synthesize) -- same wiring
+// atlas-pencil-tool.spec.ts's own header comment documents.
+async function boardPoint(board: import('@playwright/test').Locator, fx: number, fy: number): Promise<{ x: number; y: number }> {
+ const box = await board.boundingBox()
+ if (!box) throw new Error('board has no bounding box')
+ return { x: box.x + box.width * fx, y: box.y + box.height * fy }
+}
+
+function shapeObjects(page: import('@playwright/test').Page) {
+ return page.locator('[data-testid="atlas-board-object"][data-object-kind="shape"]')
+}
+
+test('dragging the shape tool lands a rectangle, never a card, and the tool stays armed for the next shape', async ({ page }) => {
+ await page.goto('/')
+ await page.getByRole('link', { name: 'Atlas' }).click()
+ const board = page.getByTestId('atlas-board')
+ await expect(board).toBeVisible()
+
+ const shapeTool = page.getByTestId('atlas-tray-shape')
+ await shapeTool.click()
+ await expect(shapeTool).toHaveAttribute('data-armed', 'true')
+ const picker = page.getByTestId('atlas-shape-style-picker')
+ await expect(picker).toBeVisible()
+ // Rectangle is the default type -- confirmed selected before any drag.
+ await expect(picker.getByTestId('atlas-shape-type-rectangle')).toHaveAttribute('data-selected', 'true')
+
+ await dragBetween(page, await boardPoint(board, 0.05, 0.1), await boardPoint(board, 0.2, 0.25))
+
+ const shapes = shapeObjects(page)
+ await expect(shapes).toHaveCount(1)
+ await expect(shapes.first()).toHaveAttribute('data-shape-type', 'rectangle')
+ // The rule, absolute: drawing never creates a card the user didn't
+ // explicitly ask for.
+ await expect(page.getByTestId('atlas-note-card').filter({ hasText: 'Rectangle' })).toHaveCount(0)
+
+ // Drag-to-draw is a sticky tool: completing a shape never disarms it.
+ await expect(shapeTool).toHaveAttribute('data-armed', 'true')
+ await dragBetween(page, await boardPoint(board, 0.3, 0.1), await boardPoint(board, 0.45, 0.25))
+ await expect(shapes).toHaveCount(2)
+
+ for (let i = 0; i < 2; i++) {
+ await shapes.first().click({ button: 'right' })
+ const menu = contextMenu(page)
+ await expect(menu).toBeVisible()
+ await menu.getByText('Delete', { exact: true }).click()
+ }
+ await expect(shapes).toHaveCount(0)
+})
+
+test('picking ellipse then arrow draws each type, and an arrow carries no Size (dx/dy only)', async ({ page }) => {
+ await page.goto('/')
+ await page.getByRole('link', { name: 'Atlas' }).click()
+ const board = page.getByTestId('atlas-board')
+ await expect(board).toBeVisible()
+
+ await page.getByTestId('atlas-tray-shape').click()
+ const picker = page.getByTestId('atlas-shape-style-picker')
+ await expect(picker).toBeVisible()
+
+ await picker.getByTestId('atlas-shape-type-ellipse').click()
+ await expect(picker.getByTestId('atlas-shape-type-ellipse')).toHaveAttribute('data-selected', 'true')
+ // Both drags stay in the board's own TOP band, clear of the style
+ // picker's own popover (anchored 'outside-top' of the bottom-center
+ // tray, so it occupies the lower-middle of the viewport for as long
+ // as shape stays armed) -- a drag start point landing ON that popover
+ // would be swallowed by it rather than reaching the board's own
+ // pointer-capture handler, never producing a shape (goal 0184's
+ // class: verify the drag start point is reachable, don't assume it).
+ await dragBetween(page, await boardPoint(board, 0.05, 0.1), await boardPoint(board, 0.2, 0.25))
+ const shapes = shapeObjects(page)
+ await expect(shapes).toHaveCount(1)
+ await expect(shapes.first()).toHaveAttribute('data-shape-type', 'ellipse')
+
+ await picker.getByTestId('atlas-shape-type-arrow').click()
+ await dragBetween(page, await boardPoint(board, 0.3, 0.1), await boardPoint(board, 0.45, 0.25))
+ await expect(shapes).toHaveCount(2)
+ await expect(page.locator('[data-testid="atlas-board-object"][data-shape-type="arrow"]')).toHaveCount(1)
+
+ for (let i = 0; i < 2; i++) {
+ await shapes.first().click({ button: 'right' })
+ const menu = contextMenu(page)
+ await expect(menu).toBeVisible()
+ await menu.getByText('Delete', { exact: true }).click()
+ }
+ await expect(shapes).toHaveCount(0)
+})
+
+test('the shape style choice survives a disarm/re-arm cycle, and Promote to card works the same way ink\'s does', async ({ page }) => {
+ await page.goto('/')
+ await page.getByRole('link', { name: 'Atlas' }).click()
+ const board = page.getByTestId('atlas-board')
+ await expect(board).toBeVisible()
+
+ const shapeTool = page.getByTestId('atlas-tray-shape')
+ await shapeTool.click()
+ const picker = page.getByTestId('atlas-shape-style-picker')
+ await expect(picker).toBeVisible()
+
+ const chosenSwatch = picker.getByTestId('atlas-shape-stroke-da3633')
+ await expect(chosenSwatch).toHaveAttribute('data-selected', 'false')
+ await chosenSwatch.click()
+ await expect(chosenSwatch).toHaveAttribute('data-selected', 'true')
+
+ // Disarm, then re-arm: the style picker REMOUNTS (AnchoredOverlay
+ // unmounts its children while closed) -- the swatch selection
+ // surviving proves it lives in the ephemeral store, not per-mount
+ // component state.
+ await shapeTool.click()
+ await expect(picker).not.toBeVisible()
+ await shapeTool.click()
+ await expect(picker).toBeVisible()
+ await expect(picker.getByTestId('atlas-shape-stroke-da3633')).toHaveAttribute('data-selected', 'true')
+
+ const shapes = shapeObjects(page)
+ await dragBetween(page, await boardPoint(board, 0.55, 0.1), await boardPoint(board, 0.7, 0.25))
+ await expect(shapes).toHaveCount(1)
+
+ await shapes.first().click({ button: 'right' })
+ const menu = contextMenu(page)
+ await expect(menu).toBeVisible()
+ await menu.getByText('Promote to card…', { exact: true }).click()
+ const popover = page.getByTestId('atlas-placement-popover')
+ await expect(popover).toBeVisible()
+ await expect(popover.getByTestId('atlas-placement-title')).toHaveValue('Rectangle')
+ await selectKind(popover, ATLAS_KIND_TOPIC)
+ await popover.getByTestId('atlas-placement-submit').click()
+ await expect(popover).not.toBeVisible()
+ await expect(shapes).toHaveCount(0)
+ const card = page.getByTestId('atlas-note-card').filter({ hasText: 'Rectangle' })
+ await expect(card).toBeVisible()
+
+ await card.click({ button: 'right' })
+ await expect(menu).toBeVisible()
+ await menu.getByText('Delete', { exact: true }).click()
+ await expect(card).toHaveCount(0)
+})
diff --git a/frontend/src/atlas/AtlasBoard.tsx b/frontend/src/atlas/AtlasBoard.tsx
index 4ffdf6e0..65040a6f 100644
--- a/frontend/src/atlas/AtlasBoard.tsx
+++ b/frontend/src/atlas/AtlasBoard.tsx
@@ -22,6 +22,7 @@ import { useAtlasPencilStyle } from './atlasPencilStyleStore'
import { AtlasPencilLivePreview } from './AtlasPencilLivePreview'
import { AtlasEraserLiveTrail } from './AtlasEraserLiveTrail'
import { AtlasLaserTrail } from './AtlasLaserTrail'
+import { AtlasShapeLivePreview } from './AtlasShapeLivePreview'
import { useAtlasDragTools } from './useAtlasDragTools'
import { useAtlasDragFiling, type FrameBox } from './useAtlasDragFiling'
import { AtlasDragHighlightContext } from './atlasDragHighlightContext'
@@ -314,11 +315,14 @@ function AtlasBoardInner({ boardFilter, onBoardFilterChange, filterMatchCount, f
const r = areaDraw.dragLocalRect, marqueeStyle = r ? { left: r.x, top: r.y, width: r.width, height: r.height } : null
- // Eraser + Laser's own arming and gesture hooks, plus the four-way
- // activeDrag resolution across all of Area/Pencil/Eraser/Laser (goal
- // 0169 slice 4) -- split into its own file at the 500-line seam.
- const { eraserDraw, laserDraw, activeDrag, anyDragToolArmed } = useAtlasDragTools({
- isFree, readOnly, armedTool: creation.armedTool, screenToFlowPosition, topLevelBoxes, noteBoxes, wrapperRef,
+ // Eraser + Laser's own arming and gesture hooks, plus Shape's full
+ // wiring (armed flag, style store, placement door, drag hook -- goal
+ // 0169 slice 5, instantiated INSIDE this hook the same way Eraser/
+ // Laser already are, unlike Area/Pencil which predate this split),
+ // plus the five-way activeDrag resolution across all of them -- split
+ // into its own file at the 500-line seam.
+ const { eraserDraw, laserDraw, shapeStyle, shapeDraw, activeDrag, anyDragToolArmed } = useAtlasDragTools({
+ isFree, readOnly, armedTool: creation.armedTool, screenToFlowPosition, topLevelBoxes, noteBoxes, wrapperRef, parentID,
onDeleteSelection, areaArmed, areaDraw, pencilArmed, pencilDraw,
})
@@ -454,6 +458,9 @@ function AtlasBoardInner({ boardFilter, onBoardFilterChange, filterMatchCount, f
{marqueeStyle &&
}
{pencilDraw.localPoints && }
+ {shapeDraw.localStart && shapeDraw.localCurrent && (
+
+ )}
{eraserDraw.localPoints && }
{laserDraw.points.length > 0 && }
{slotDrag.dragLine && }
diff --git a/frontend/src/atlas/AtlasBoardObjectNode.tsx b/frontend/src/atlas/AtlasBoardObjectNode.tsx
index a3e6b8e1..9c241314 100644
--- a/frontend/src/atlas/AtlasBoardObjectNode.tsx
+++ b/frontend/src/atlas/AtlasBoardObjectNode.tsx
@@ -4,6 +4,7 @@ import type { NodeProps, Node as RFNode } from '@xyflow/react'
import { ImageIcon, PencilIcon } from '@primer/octicons-react'
import type { BoardObject } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models'
import { AtlasService } from '../shared/bindings'
+import { AtlasShapeContent } from './AtlasShapeContent'
import styles from './AtlasBoardObjectNode.module.css'
export interface AtlasBoardObjectData extends Record {
@@ -23,13 +24,20 @@ export type AtlasBoardObjectRFNode = RFNode
// at its own natural/intrinsic size (clamped by this module's own CSS
// max-width/height so a full-resolution screenshot never dwarfs the
// board) until a future resize persists BoardObject.Size.
-export const AtlasBoardObjectNode = memo(function AtlasBoardObjectNode({ data }: NodeProps) {
+// A shape (goal 0169 slice 5) is structurally NOT mirror-file-backed --
+// it renders straight from its own Payload/Size, so it takes an early,
+// separate branch here rather than folding into the mirror-fetch effect
+// below (which stays scoped to the two Kinds that actually have a
+// mirror file, image and ink).
+function AtlasBoardObjectNodeInner({ data }: NodeProps) {
const { t } = useTranslation('atlas')
const { object } = data
const [src, setSrc] = useState(null)
const [failed, setFailed] = useState(false)
+ const isShape = object.Kind === 'shape'
useEffect(() => {
+ if (isShape) return
let stale = false
setSrc(null)
setFailed(false)
@@ -48,7 +56,15 @@ export const AtlasBoardObjectNode = memo(function AtlasBoardObjectNode({ data }:
return () => {
stale = true
}
- }, [object.ID, object.Payload])
+ }, [object.ID, object.Payload, isShape])
+
+ if (isShape) {
+ return (
+
+
+
+ )
+ }
const Glyph = object.Kind === 'ink' ? PencilIcon : ImageIcon
@@ -70,4 +86,16 @@ export const AtlasBoardObjectNode = memo(function AtlasBoardObjectNode({ data }:
)}
)
-})
+}
+
+// A board-local canvas object (goal 0179/0180's own correction: a
+// canvas object is a thing in space, never a document) -- image, ink,
+// and shape share this one component, discriminated by object.Kind
+// purely for which content to render; none get a title, a flip, or
+// connection handles -- structurally excluded from every card
+// mechanism, the same way AtlasStickyNode's note is. Lands at its own
+// natural/intrinsic size (clamped by this module's own CSS max-width/
+// height for image/ink so a full-resolution screenshot never dwarfs the
+// board; a shape's own size is already user-drawn, so it carries no
+// such clamp) until a future resize persists BoardObject.Size.
+export const AtlasBoardObjectNode = memo(AtlasBoardObjectNodeInner)
diff --git a/frontend/src/atlas/AtlasCreationTray.tsx b/frontend/src/atlas/AtlasCreationTray.tsx
index 3734dc75..1ea7aa29 100644
--- a/frontend/src/atlas/AtlasCreationTray.tsx
+++ b/frontend/src/atlas/AtlasCreationTray.tsx
@@ -1,9 +1,9 @@
-import { Fragment, useRef } from 'react'
+import { createRef, Fragment, useMemo } from 'react'
+import type { RefObject } from 'react'
import { useTranslation } from 'react-i18next'
import { AnchoredOverlay, Button } from '@primer/react'
import { AtlasTableSizePicker } from './AtlasTableSizePicker'
import { AtlasImageInput } from './AtlasImageInput'
-import { AtlasPencilStylePicker } from './AtlasPencilStylePicker'
import { ATLAS_TOOLS, type AtlasArmableTool } from './atlasTools'
import styles from './AtlasCreationTray.module.css'
@@ -28,7 +28,10 @@ export const ATLAS_TOOL_DRAG_MIME = 'application/x-mill-atlas-tool'
// on click too, but STAYS armed across strokes (AtlasBoard.tsx's own
// drag hook owns completion, never disarming itself), with its own
// colour/size options bar shown anchored for as long as it's the
-// armed tool ('drag-to-draw'). Eraser and Laser ('drag-to-erase',
+// armed tool ('drag-to-draw'); Shape (goal 0169 slice 5) shares that
+// same interaction and branch, its own options bar swapped in via the
+// tool's own StylePicker field rather than a second branch. Eraser and
+// Laser ('drag-to-erase',
// 'ephemeral-drag') fall through to the same plain arm-on-click button
// the default branch below already renders for Card/Note/Area --
// neither needs an options popover, so no new branch was needed here
@@ -61,9 +64,18 @@ export function AtlasCreationTray({ armedTool, onToggle, tablePickerOpen, onTabl
}) {
const { t } = useTranslation('atlas')
const tools = ATLAS_TOOLS.filter((tool) => tool.tray === 'quick')
- const tableButtonRef = useRef(null)
- const imageButtonRef = useRef(null)
- const pencilButtonRef = useRef(null)
+ // One anchor ref PER TOOL ID (not one shared ref reused across every
+ // mapped button) -- created once, since ATLAS_TOOLS is a module-level
+ // constant whose ids never change across renders. A single shared ref
+ // was the pre-slice-5 shape here: it silently worked only because
+ // pencil was the sole 'drag-to-draw' tool ever mounted at once: a
+ // second one (shape) reusing the SAME ref object would make both
+ // AnchoredOverlays anchor to whichever DOM node happened to render
+ // last.
+ const anchorRefs = useMemo(
+ () => Object.fromEntries(tools.map((tool) => [tool.id, createRef()])) as Record>,
+ [tools],
+ )
return (