# SCRATCHPAD — Invoice Designer Native CSS Layout Engine ## Context Goal: remove bespoke geometry math in the invoice designer and rely on native browser layout (CSS flex/grid + box model). Replace custom drop-parent resolution with `@dnd-kit/core` DOM-driven collision detection. ## Target Deletions - `packages/billing/src/components/invoice-designer/utils/constraintSolver.ts` - `packages/billing/src/components/invoice-designer/utils/constraints.ts` - `packages/billing/src/components/invoice-designer/utils/dropParentResolution.ts` - `packages/billing/src/components/invoice-designer/utils/aspectRatio.ts` ## Decisions - Prefer CSS-first semantics even if it means removing some legacy constraint behaviors. - Drag-drop should be based on DOM geometry via dnd-kit collision detection (not custom math). - Use CSS `aspect-ratio` for images. Avoid JS measurement loops. - Scope decisions (2026-02-13): - Layout modes: flex + grid. - Resizing: enabled via CSS sizing props. - Snapping: edge + grid snapping as discrete insertion behavior. - Resize writes pixel values: - `updateNodeSize` rounds stored `node.size` to whole pixels and writes `node.style.width/height` as `Npx` strings (avoids float drift between size and CSS). ## Detailed Decisions - Resizing: - Drag handles: `image`, `section`, `stack`. - No drag-resize for: `text`, `field`, `divider`, `totals`, `table`, `dynamic-table`. - No table column resizing. - Drag writes pixel sizing only (`px`). Non-px sizing can be entered via the property panel. - Flex main-axis resize should prefer `flex-basis` updates. - Nesting allowlist: - Containers: `document`, `section`, `stack`, `grid` (if a node/type exists). - Leaves: `text`, `field`, `image`, `divider`, `totals`, `table`, `dynamic-table`. - Drag-drop persistence: - Persist only `targetContainerId` + `insertionIndex` state changes. No coordinate-based persistence. - "Basic snapping" definition: - Flex: snap to before/after sibling insertion indices. - Grid: snap to deterministic cell/index insertion based on grid tracks, not arbitrary pixel coordinates. ## Implemented - 2026-02-13: `F001` CSS-first layout model - New node model fields in `packages/billing/src/components/invoice-designer/state/designerStore.ts`: - `node.layout`: `display: flex|grid` + flex/grid properties in CSS semantics. - `node.style`: width/height/min/max, flex item props, media props (aspectRatio/objectFit). - Removed constraint-solver state from the store snapshot and APIs (constraints are no longer part of `exportWorkspace()`). - Updated presets to accept legacy layout shapes while mapping them into CSS layout at insertion time: - `packages/billing/src/components/invoice-designer/constants/presets.ts` - `packages/billing/src/components/invoice-designer/state/designerStore.ts` - Deleted the constraints inspector tests and removed constraints UI from `packages/billing/src/components/invoice-designer/DesignerShell.tsx`. - 2026-02-13: `F002` Layout/style -> DOM style mapping for canvas - New mapping helpers: - `packages/billing/src/components/invoice-designer/utils/cssLayout.ts` - Canvas applies mapped styles: - `packages/billing/src/components/invoice-designer/canvas/DesignCanvas.tsx` - outer node: uses `node.style` for width/height/min/max/flex props, still absolute-positioned during cutover - container child wrapper: uses `node.layout` to set `display`, flex/grid rules, `gap`, `padding` - 2026-02-13: `F003` Flex row/column container layout - Children of flex/grid containers are now rendered as flow items (no absolute `top/left`), enabling native flex row/column layout: - `packages/billing/src/components/invoice-designer/canvas/DesignCanvas.tsx` - Child ordering uses `parent.childIds` when parent is `display:flex|grid` (stable authored order); legacy canvas containers remain position-sorted for now. - 2026-02-13: `F004` Spacing controls (gap/padding + border via existing presets) - Updated Inspector layout panel to edit `gap` and `padding` (px) and flex alignment using CSS semantics: - `packages/billing/src/components/invoice-designer/DesignerShell.tsx` - `resolveFlexPadding` updated to support both legacy numeric padding and CSS `padding: \"Npx\"` during cutover: - `packages/billing/src/components/invoice-designer/utils/layout.ts` - 2026-02-13: `F005` Flex alignment controls (justify/align) - Layout inspector edits `align-items` and `justify-content` for containers and writes CSS values into `node.layout`: - `packages/billing/src/components/invoice-designer/DesignerShell.tsx` - Canvas applies `justifyContent`/`alignItems` via the container style mapping: - `packages/billing/src/components/invoice-designer/utils/cssLayout.ts` - `packages/billing/src/components/invoice-designer/canvas/DesignCanvas.tsx` - 2026-02-13: `F006` Item sizing controls (CSS width/height/min/max) - Added "Sizing (CSS)" inspector panel that edits `node.style` sizing strings (`width`, `height`, `minWidth`, `minHeight`, `maxWidth`, `maxHeight`): - `packages/billing/src/components/invoice-designer/DesignerShell.tsx` - Canvas already applies these values via `resolveNodeBoxStyle(node.style)` (no geometry math). - 2026-02-13: `F007` Flex item controls (grow/shrink/basis) - Added "Flex Item" inspector panel shown when the selected node is inside a `display:flex` parent; edits `flexGrow`, `flexShrink`, and `flexBasis` on `node.style`: - `packages/billing/src/components/invoice-designer/DesignerShell.tsx` - Canvas already applies these values via `resolveNodeBoxStyle(node.style)`. - 2026-02-13: `F008` Grid container mode (CSS grid) - Layout inspector now supports switching a container between `display:flex` and `display:grid`. - When in grid mode, inspector exposes `grid-template-columns`, `grid-template-rows`, and `grid-auto-flow` (gap/padding remain shared): - `packages/billing/src/components/invoice-designer/DesignerShell.tsx` - Canvas already renders grid containers via `resolveContainerLayoutStyle(node.layout)` + flow children rendering. - 2026-02-13: `F009` dnd-kit collision detection + sortable plumbing (start of DOM-driven DnD) - Added dnd-kit collision detection strategy (pointer-within preferred; smallest rect under pointer approximates "deepest" target) with closest-center fallback and Always measuring: - `packages/billing/src/components/invoice-designer/DesignerShell.tsx` - Introduced sortable contexts for flow-layout containers (flex/grid) and `useSortable` for flow children, enabling DOM-measured ordering targets (no coordinate math for flow drags): - `packages/billing/src/components/invoice-designer/canvas/DesignCanvas.tsx` - Added store action to move/reparent nodes by `parentId + insertionIndex`, with allowlist enforcement and cycle prevention: - `packages/billing/src/components/invoice-designer/state/designerStore.ts` - 2026-02-13: `F010` Reorder within container (sortable) - Dragging a flow-layout node (inside a `display:flex|grid` parent) now reorders by updating the parent's `childIds` using dnd-kit sortable `over` resolution (no position math). - 2026-02-13: `F011` Cross-container moves (flow layout) - Flow-layout nodes can be dragged into another eligible container (or onto a child within it) and are reparented by updating `parentId` + `childIds` insertion index (DOM-measured `over` target). - 2026-02-13: `F012` Nesting allowlist enforcement - Reparenting via drag-drop is validated with `canNestWithinParent(childType, parentType)` and cycle prevention (no dropping into descendants): - `packages/billing/src/components/invoice-designer/state/designerStore.ts` - 2026-02-13: `F013` Drop indicators (DOM-derived) - Added a before/after insertion indicator line for flow-layout drags, derived from active/over DOM rect midpoints (no custom geometry solver): - `packages/billing/src/components/invoice-designer/DesignerShell.tsx` - `packages/billing/src/components/invoice-designer/canvas/DesignCanvas.tsx` - Container drop targets are still highlighted via `useDroppable().isOver` rings (DOM-measured). - 2026-02-13: `F014` Invalid drop UX - Invalid drop targets now show a blocked visual state (red insertion line or red target ring) and a `not-allowed` cursor on the drag overlay; drops do not mutate state: - `packages/billing/src/components/invoice-designer/DesignerShell.tsx` - `packages/billing/src/components/invoice-designer/canvas/DesignCanvas.tsx` - Drop end emits an error toast-style banner via existing `dropFeedback` mechanism when invalid. - 2026-02-13: `F015` Image aspect ratio + object-fit (CSS) - Media nodes (`image`, `logo`, `qr`) now render with CSS `aspect-ratio` (wrapper) and `object-fit` (img) support, with inspector controls writing `node.style.aspectRatio` and `node.style.objectFit`: - `packages/billing/src/components/invoice-designer/DesignerShell.tsx` - `packages/billing/src/components/invoice-designer/canvas/DesignCanvas.tsx` - New media nodes default to `objectFit: contain` (and `qr` defaults to `aspectRatio: 1 / 1`) without any JS measurement loops: - `packages/billing/src/components/invoice-designer/state/designerStore.ts` - 2026-02-13: `F016` Resize handles (limited types) - Resize handles are now limited to `image/logo/qr` and container blocks (`section`, `container`) per PRD; resizing continues to write pixel `width/height` into `node.style` via `updateNodeSize` (no constraint solver): - `packages/billing/src/components/invoice-designer/canvas/DesignCanvas.tsx` - 2026-02-13: `F016A` Property-panel sizing strings - Inspector "Sizing (CSS)" panel accepts arbitrary CSS strings (`%`, `rem`, `auto`, `calc(...)`, etc.) for size/min/max while drag-resize continues to write pixel values (px): - `packages/billing/src/components/invoice-designer/DesignerShell.tsx` - `packages/billing/src/components/invoice-designer/state/designerStore.ts` - 2026-02-13: `F016B` Basic snapping (discrete insertion) - Flex parents now resolve insertion as before/after based on DOM rect midpoint along the main axis; grid uses the sortable `over` cell/index deterministically (no pixel snapping math): - `packages/billing/src/components/invoice-designer/DesignerShell.tsx` - 2026-02-13: `F016C` Drag-drop persistence (no coordinates) - Dragging nodes no longer writes `position.x/y` during drop; drag-drop mutations are limited to `targetParentId + insertionIndex` updates (`parentId` + `childIds`): - `packages/billing/src/components/invoice-designer/DesignerShell.tsx` - `packages/billing/src/components/invoice-designer/state/designerStore.ts` - 2026-02-13: `F017` Delete legacy geometry utilities - Deleted legacy solver + geometry/drop-parent modules and their tests (removed from runtime graph): - `packages/billing/src/components/invoice-designer/utils/constraintSolver.ts` - `packages/billing/src/components/invoice-designer/utils/constraints.ts` - `packages/billing/src/components/invoice-designer/utils/dropParentResolution.ts` - `packages/billing/src/components/invoice-designer/utils/aspectRatio.ts` - Simplified component insertion parent resolution in the designer to no longer depend on `dropParentResolution`. - 2026-02-13: `F018` Reduce `utils/layout.ts` - `packages/billing/src/components/invoice-designer/utils/layout.ts` now contains only lightweight helpers (e.g. `resolveFlexPadding`) and type exports; alignment/geometry helpers were removed from this module. - Legacy drag-move guide/preview logic in `DesignerShell` was pared back (no coordinate-based preview updates). - 2026-02-13: `F019` Persist CSS-like layout props in AST import/export - AST export writes CSS-like sizing, flex/grid container props, and media props (`aspectRatio`, `objectFit`) into `style.inline`: - `packages/billing/src/components/invoice-designer/ast/workspaceAst.ts` - AST import now hydrates `DesignerNode.style` and `DesignerNode.layout` from `style.inline` (with safe defaults for containers) so export -> import roundtrips CSS semantics. - Import mapping now treats top-level AST `section` nodes as designer `page` nodes (avoids a duplicate designer-only page wrapper). - 2026-02-13: `F020` Preview uses authoritative renderer CSS semantics - Preview pipeline now passes only the `DesignerWorkspaceSnapshot` fields (removed stale `constraints` plumbing that no longer exists in the store snapshot): - `packages/billing/src/components/invoice-designer/DesignerVisualWorkspace.tsx` - Preview iframe `srcDoc` is now a full HTML document with `html, body { margin: 0; padding: 0; }` so the renderer-scoped CSS (`.invoice-template-root { ... }`) behaves the same inside the iframe as it does in the app / PDF (avoids default iframe body margins affecting layout). - 2026-02-13: `F021` Audit imports of deleted geometry utilities - Confirmed no runtime imports/references remain for deleted modules: - `packages/billing/src/components/invoice-designer/utils/constraintSolver.ts` - `packages/billing/src/components/invoice-designer/utils/constraints.ts` - `packages/billing/src/components/invoice-designer/utils/dropParentResolution.ts` - `packages/billing/src/components/invoice-designer/utils/aspectRatio.ts` - Grep runbook: - `rg -n "utils/(constraintSolver|constraints|dropParentResolution|aspectRatio)" -S .` ## Remaining Design Choices - Collision strategy and snapping thresholds are intentionally selected to minimize custom geometry logic: - Collision detection: - Use `pointerWithin` first (deepest eligible droppable under cursor). - Fallback to `closestCenter` if pointer is not within any droppable. - Configure measuring to keep DOM rects fresh during drag. - Sensors and overlay: - `PointerSensor` with activation distance (e.g. 6px). - `DragOverlay` to avoid layout reflow while dragging. - Sorting strategies: - Flex column: `verticalListSortingStrategy` - Flex row: `horizontalListSortingStrategy` - Grid: `rectSortingStrategy` - Snapping threshold behavior: - Flex uses midpoint rule on hovered rect (before/after) for insertion indicators. - Grid uses sortable `over` resolution directly (deterministic cell/index). ## Developer Notes (DnD + Nesting) - Collision detection (why pointer-within first): - Implemented in `packages/billing/src/components/invoice-designer/DesignerShell.tsx` as `collisionDetection`. - We use `pointerWithin(args)` and return the collisions sorted by smallest droppable rect area first, as a practical proxy for "deepest nested droppable under the cursor". - If no droppable contains the pointer (fast drag / overlay edges), we fall back to `closestCenter(args)` so the drag never “loses” an `over` target. - Measuring is configured as `MeasuringStrategy.Always` on the `DndContext` to avoid stale droppable rects in nested flex/grid layouts. - Droppable id conventions (how `over.id` maps back to a node): - Container drop zones use `id: \`droppable-\${node.id}\`` in `packages/billing/src/components/invoice-designer/canvas/DesignCanvas.tsx`. - Flow items use `useSortable({ id: node.id, ... })` (sortable id is the node id). - When debugging: if `over.id` starts with `droppable-`, it represents “drop into this container”; otherwise it is a sortable item id representing “drop relative to this item”. - Nesting rules (authoritative allowlist): - Implemented in `packages/billing/src/components/invoice-designer/state/hierarchy.ts` and enforced via `canNestWithinParent(...)`. - Store actions that move/reparent nodes must validate: - type allowlist (`canNestWithinParent`) - cycle prevention (no dropping into descendants) - When adding a new node type, update `HIERARCHY_RULES` and ensure `allowedChildren` on container nodes matches the same intent. - Where to tune UX: - Activation threshold (avoid accidental drags): `PointerSensor` `activationConstraint.distance` in `packages/billing/src/components/invoice-designer/DesignerShell.tsx`. - Over-target selection behavior: the `collisionDetection` callback in `packages/billing/src/components/invoice-designer/DesignerShell.tsx`. - Sort strategy selection (row/column/grid): `SortableContext` `strategy` selection in `packages/billing/src/components/invoice-designer/canvas/DesignCanvas.tsx`. ## Implementation Sketch (Non-binding) - Introduce a single "layout props -> style props" mapping function used by: - designer canvas rendering - preview rendering (if different) - AST export (if relevant) - Add dnd-kit: - sensors: pointer + keyboard (optional) - sortable contexts for sibling reordering - collision detection tuned for nested containers - Enforce nesting rules in drop handler (reject invalid parent). - Remove legacy util usage, then delete files. ## Useful Commands - Search for legacy geometry imports: - `rg -n \"constraintSolver|constraints|dropParentResolution|aspectRatio\" packages/billing/src/components/invoice-designer` - Run a single designer test under the server Vitest config: - `cd server && npx vitest run ../packages/billing/src/components/invoice-designer/utils/cssLayout.test.ts` - `cd server && npx vitest run ../packages/billing/src/components/invoice-designer/canvas/DesignCanvas.flexColumn.integration.test.tsx` - `cd server && npx vitest run ../packages/billing/src/components/invoice-designer/canvas/DesignCanvas.flexRow.integration.test.tsx` - `cd server && npx vitest run ../packages/billing/src/components/invoice-designer/canvas/DesignCanvas.spacing.integration.test.tsx` - `cd server && npx vitest run ../packages/billing/src/components/invoice-designer/canvas/DesignCanvas.alignment.integration.test.tsx` - `cd server && npx vitest run ../packages/billing/src/components/invoice-designer/canvas/DesignCanvas.sizing.integration.test.tsx` - `cd server && npx vitest run ../packages/billing/src/components/invoice-designer/canvas/DesignCanvas.flexItem.integration.test.tsx` - `cd server && npx vitest run ../packages/billing/src/components/invoice-designer/canvas/DesignCanvas.grid.integration.test.tsx` - `cd server && npx vitest run ../packages/billing/src/components/invoice-designer/utils/dndCollision.test.ts` - `cd server && npx vitest run ../packages/billing/src/components/invoice-designer/state/designerStore.flowDnd.test.ts` - `cd server && npx vitest run ../packages/billing/src/components/invoice-designer/utils/dropIndicator.test.ts` - `cd server && npx vitest run ../packages/billing/src/components/invoice-designer/canvas/DesignCanvas.mediaAspect.integration.test.tsx` ## Repo/Test Gotchas (Discovered 2026-02-13) - Vitest + React tests were failing when `NODE_ENV=production` leaked into the test process (React test utils expect non-production builds). - Fix: `server/vitest.globalSetup.js` now forces `process.env.NODE_ENV = 'test'`. - Package tests under `../packages/**` were not being discovered when running Vitest from `server/`. - Fix: `server/vitest.config.ts` now includes `../packages/**/*.{test,spec}.*` explicitly. - Coverage provider version must match Vitest major version. - `server/` is currently on `vitest@3.2.4`, so `@vitest/coverage-v8@3.2.4` is added to `server/package.json` to avoid resolving the repo-root `@vitest/coverage-v8@4.x`. - Running tests from repo root: - `npm run test:local` currently shells out to a system `dotenv` binary in some environments (CLI flag mismatch). - Reliable alternative: `cd server && npx vitest run ` (uses `server/vitest.globalSetup.js` to load `../.env.localtest`). ## Notes (Renderer Schema) - Preview/renderer schema must allow the same safe CSS subset that the designer exports into `style.inline`. - Added support for: `flexDirection`, grid inline props (`gridTemplateColumns`, `gridTemplateRows`, `gridAutoFlow`), and media props (`aspectRatio`, `objectFit`) in: - `packages/billing/src/lib/invoice-template-ast/schema.ts`