diff --git a/frontend/src/components/canvas/AlignmentGuides.tsx b/frontend/src/components/canvas/AlignmentGuides.tsx
new file mode 100644
index 0000000..ecad7ea
--- /dev/null
+++ b/frontend/src/components/canvas/AlignmentGuides.tsx
@@ -0,0 +1,70 @@
+import { useViewport } from '@xyflow/react'
+import type { Guide } from '@/utils/alignment'
+
+interface AlignmentGuidesProps {
+ guides: Guide[]
+ color?: string
+}
+
+/**
+ * SVG overlay that draws alignment guide lines on top of the React Flow canvas.
+ * Coordinates are in canvas (flow) space; we read the viewport transform to
+ * project them into screen space so lines stay locked to nodes when the user
+ * pans or zooms.
+ */
+export function AlignmentGuides({ guides, color = '#00d4ff' }: AlignmentGuidesProps) {
+ const { x: vx, y: vy, zoom } = useViewport()
+
+ if (guides.length === 0) return null
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx
index 9ec2726..7433492 100644
--- a/frontend/src/components/canvas/CanvasContainer.tsx
+++ b/frontend/src/components/canvas/CanvasContainer.tsx
@@ -20,6 +20,8 @@ import { THEMES } from '@/utils/themes'
import { nodeTypes } from './nodes/nodeTypes'
import { edgeTypes } from './edges/edgeTypes'
import { SearchBar } from './SearchBar'
+import { AlignmentGuides } from './AlignmentGuides'
+import { useAlignmentGuides } from '@/hooks/useAlignmentGuides'
import type { NodeData, EdgeData } from '@/types'
interface CanvasContainerProps {
@@ -83,6 +85,8 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
[]
)
+ const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides()
+
return (
+
setLassoMode((m) => !m)}
diff --git a/frontend/src/components/canvas/__tests__/AlignmentGuides.test.tsx b/frontend/src/components/canvas/__tests__/AlignmentGuides.test.tsx
new file mode 100644
index 0000000..d178fdf
--- /dev/null
+++ b/frontend/src/components/canvas/__tests__/AlignmentGuides.test.tsx
@@ -0,0 +1,47 @@
+import { describe, it, expect, vi } from 'vitest'
+import { render } from '@testing-library/react'
+import { AlignmentGuides } from '../AlignmentGuides'
+import type { Guide } from '@/utils/alignment'
+
+vi.mock('@xyflow/react', () => ({
+ useViewport: () => ({ x: 50, y: 100, zoom: 2 }),
+}))
+
+describe('AlignmentGuides', () => {
+ it('renders nothing when no guides', () => {
+ const { container } = render()
+ expect(container.querySelector('svg')).toBeNull()
+ })
+
+ it('projects an x-axis guide through the viewport transform', () => {
+ const guides: Guide[] = [{ axis: 'x', position: 100, start: 0, end: 200 }]
+ const { container } = render()
+ const line = container.querySelector('line')!
+ // x = position * zoom + vx → 100*2 + 50 = 250
+ expect(line.getAttribute('x1')).toBe('250')
+ expect(line.getAttribute('x2')).toBe('250')
+ // y1 = start * zoom + vy → 0*2 + 100 = 100; y2 = 200*2 + 100 = 500
+ expect(line.getAttribute('y1')).toBe('100')
+ expect(line.getAttribute('y2')).toBe('500')
+ })
+
+ it('projects a y-axis guide horizontally', () => {
+ const guides: Guide[] = [{ axis: 'y', position: 50, start: 10, end: 60 }]
+ const { container } = render()
+ const line = container.querySelector('line')!
+ // y = 50*2 + 100 = 200; x1 = 10*2 + 50 = 70; x2 = 60*2 + 50 = 170
+ expect(line.getAttribute('y1')).toBe('200')
+ expect(line.getAttribute('y2')).toBe('200')
+ expect(line.getAttribute('x1')).toBe('70')
+ expect(line.getAttribute('x2')).toBe('170')
+ })
+
+ it('renders one line per guide', () => {
+ const guides: Guide[] = [
+ { axis: 'x', position: 100, start: 0, end: 200 },
+ { axis: 'y', position: 50, start: 10, end: 60 },
+ ]
+ const { container } = render()
+ expect(container.querySelectorAll('line')).toHaveLength(2)
+ })
+})
diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx
index 7cf6da4..e7554d8 100644
--- a/frontend/src/components/panels/Sidebar.tsx
+++ b/frontend/src/components/panels/Sidebar.tsx
@@ -7,6 +7,12 @@ import { useAuthStore } from '@/stores/authStore'
import { scanApi, settingsApi } from '@/api/client'
import { toast } from 'sonner'
import { useLatestRelease } from '@/hooks/useLatestRelease'
+import {
+ type AlignmentSettings,
+ readAlignmentSettings,
+ writeAlignmentSettings,
+ subscribeAlignmentSettings,
+} from '@/utils/alignmentSettings'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
@@ -321,6 +327,7 @@ function ScanHistoryPanel() {
function SettingsPanel() {
const [interval, setIntervalValue] = useState(60)
const [saving, setSaving] = useState(false)
+ const [alignment, setAlignment] = useState(readAlignmentSettings)
useEffect(() => {
settingsApi.get()
@@ -328,6 +335,14 @@ function SettingsPanel() {
.catch(() => {/* use default */})
}, [])
+ useEffect(() => subscribeAlignmentSettings(setAlignment), [])
+
+ const updateAlignment = (patch: Partial) => {
+ const next = { ...alignment, ...patch }
+ setAlignment(next)
+ writeAlignmentSettings(next)
+ }
+
const handleSave = async () => {
setSaving(true)
try {
@@ -369,6 +384,41 @@ function SettingsPanel() {
>
{saving ? 'Saving…' : 'Save'}
+
+
+
Canvas
+
+
+
+
+
+
+ updateAlignment({ threshold: Number(e.target.value) })}
+ className="flex-1 cursor-pointer accent-[#00d4ff]"
+ aria-label="Alignment snap threshold"
+ />
+ {alignment.threshold}px
+
+
+ Distance at which dragged nodes snap to neighbours. Hold Alt while dragging to disable.
+
+
+
)
}
diff --git a/frontend/src/hooks/useAlignmentGuides.ts b/frontend/src/hooks/useAlignmentGuides.ts
new file mode 100644
index 0000000..e189429
--- /dev/null
+++ b/frontend/src/hooks/useAlignmentGuides.ts
@@ -0,0 +1,85 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { useReactFlow, type Node, type NodeDragHandler } from '@xyflow/react'
+import { computeSnap, unionBox, type Box, type Guide } from '@/utils/alignment'
+import {
+ type AlignmentSettings,
+ readAlignmentSettings,
+ writeAlignmentSettings,
+ subscribeAlignmentSettings,
+} from '@/utils/alignmentSettings'
+import type { NodeData } from '@/types'
+
+function nodeBox(n: Node): Box | null {
+ // Skip nodes with a parent — alignment between absolute & parent-relative
+ // coordinates is misleading. Top-level nodes only for v1.
+ if (n.parentId) return null
+ const width = n.measured?.width ?? n.width ?? null
+ const height = n.measured?.height ?? n.height ?? null
+ if (width == null || height == null) return null
+ return { id: n.id, x: n.position.x, y: n.position.y, width, height }
+}
+
+export function useAlignmentGuides() {
+ const [settings, setSettings] = useState(readAlignmentSettings)
+ const [guides, setGuides] = useState([])
+ const altDownRef = useRef(false)
+ const { setNodes, getNodes } = useReactFlow>()
+
+ useEffect(() => subscribeAlignmentSettings(setSettings), [])
+
+ useEffect(() => {
+ const down = (e: KeyboardEvent) => { if (e.altKey) altDownRef.current = true }
+ const up = (e: KeyboardEvent) => { if (!e.altKey) altDownRef.current = false }
+ window.addEventListener('keydown', down)
+ window.addEventListener('keyup', up)
+ return () => {
+ window.removeEventListener('keydown', down)
+ window.removeEventListener('keyup', up)
+ }
+ }, [])
+
+ const update = useCallback((patch: Partial) => {
+ setSettings((s) => {
+ const next = { ...s, ...patch }
+ writeAlignmentSettings(next)
+ return next
+ })
+ }, [])
+
+ const onNodeDrag: NodeDragHandler = useCallback((_event, dragNode, dragNodes) => {
+ if (!settings.enabled || altDownRef.current) {
+ if (guides.length > 0) setGuides([])
+ return
+ }
+ const all = getNodes()
+ const draggedSet = new Set((dragNodes.length > 0 ? dragNodes : [dragNode]).map((n) => n.id))
+ const draggedBoxes = all.filter((n) => draggedSet.has(n.id)).map(nodeBox).filter((b): b is Box => b !== null)
+ if (draggedBoxes.length === 0) {
+ if (guides.length > 0) setGuides([])
+ return
+ }
+ const dragged = draggedBoxes.length === 1 ? draggedBoxes[0] : unionBox(draggedBoxes)
+ if (!dragged) return
+ const candidates = all
+ .filter((n) => !draggedSet.has(n.id))
+ .map(nodeBox)
+ .filter((b): b is Box => b !== null)
+ const result = computeSnap(dragged, candidates, settings.threshold)
+ setGuides(result.guides)
+ if (result.deltaX !== 0 || result.deltaY !== 0) {
+ setNodes((ns) =>
+ ns.map((n) =>
+ draggedSet.has(n.id)
+ ? { ...n, position: { x: n.position.x + result.deltaX, y: n.position.y + result.deltaY } }
+ : n,
+ ),
+ )
+ }
+ }, [settings.enabled, settings.threshold, getNodes, setNodes, guides.length])
+
+ const onNodeDragStop: NodeDragHandler = useCallback(() => {
+ if (guides.length > 0) setGuides([])
+ }, [guides.length])
+
+ return { guides, settings, update, onNodeDrag, onNodeDragStop }
+}
diff --git a/frontend/src/utils/__tests__/alignment.test.ts b/frontend/src/utils/__tests__/alignment.test.ts
new file mode 100644
index 0000000..db2afa8
--- /dev/null
+++ b/frontend/src/utils/__tests__/alignment.test.ts
@@ -0,0 +1,110 @@
+import { describe, it, expect } from 'vitest'
+import { computeSnap, unionBox, type Box } from '../alignment'
+
+const box = (id: string, x: number, y: number, width = 100, height = 50): Box => ({ id, x, y, width, height })
+
+describe('computeSnap', () => {
+ it('returns zero deltas with no candidates', () => {
+ const r = computeSnap(box('a', 0, 0), [], 6)
+ expect(r).toEqual({ deltaX: 0, deltaY: 0, guides: [] })
+ })
+
+ it('snaps left edges when within threshold (same-size boxes show all aligned guides)', () => {
+ const dragged = box('a', 102, 200) // left=102
+ const other = box('b', 100, 0) // left=100 → delta -2 → all three edges tie
+ const r = computeSnap(dragged, [other], 6)
+ expect(r.deltaX).toBe(-2)
+ expect(r.deltaY).toBe(0)
+ // Same width: left/center/right all tie at delta -2. Draw all three guides.
+ expect(r.guides.map((g) => g.position).sort((a, b) => a - b)).toEqual([100, 150, 200])
+ expect(r.guides.every((g) => g.axis === 'x')).toBe(true)
+ })
+
+ it('shows a single guide when only one edge aligns', () => {
+ const dragged = box('a', 102, 200, 80, 50) // left=102, center=142, right=182
+ const other = box('b', 100, 0, 100, 50) // left=100, center=150, right=200
+ const r = computeSnap(dragged, [other], 6)
+ expect(r.deltaX).toBe(-2) // left↔left wins
+ expect(r.guides).toHaveLength(1)
+ expect(r.guides[0].position).toBe(100)
+ })
+
+ it('does not snap when outside threshold', () => {
+ const dragged = box('a', 110, 200)
+ const other = box('b', 100, 0)
+ const r = computeSnap(dragged, [other], 6)
+ expect(r.deltaX).toBe(0)
+ expect(r.guides).toHaveLength(0)
+ })
+
+ it('snaps centerX to centerX', () => {
+ const dragged = box('a', 51, 300, 100, 50) // center=101
+ const other = box('b', 75, 0, 50, 50) // center=100 → delta -1
+ const r = computeSnap(dragged, [other], 6)
+ expect(r.deltaX).toBe(-1)
+ expect(r.guides).toHaveLength(1)
+ expect(r.guides[0].position).toBe(100)
+ })
+
+ it('snaps right edge of dragged to left edge of candidate', () => {
+ const dragged = box('a', 0, 200) // right=100
+ const other = box('b', 102, 0) // left=102 → delta +2
+ const r = computeSnap(dragged, [other], 6)
+ expect(r.deltaX).toBe(2)
+ })
+
+ it('snaps both axes simultaneously', () => {
+ const dragged = box('a', 102, 53, 80, 30)
+ const other = box('b', 100, 50, 100, 50)
+ const r = computeSnap(dragged, [other], 6)
+ expect(r.deltaX).toBe(-2)
+ expect(r.deltaY).toBe(-3)
+ // Distinct sizes → exactly one X guide and one Y guide.
+ expect(r.guides.filter((g) => g.axis === 'x')).toHaveLength(1)
+ expect(r.guides.filter((g) => g.axis === 'y')).toHaveLength(1)
+ })
+
+ it('picks the closest match when multiple candidates compete', () => {
+ const dragged = box('a', 103, 0)
+ const closer = box('b', 100, 0) // delta -3
+ const farther = box('c', 109, 0) // delta +6 — also within threshold but worse
+ const r = computeSnap(dragged, [closer, farther], 6)
+ expect(r.deltaX).toBe(-3)
+ })
+
+ it('ignores the dragged box itself when present in candidates', () => {
+ const dragged = box('a', 102, 0)
+ const r = computeSnap(dragged, [dragged, box('b', 100, 0)], 6)
+ expect(r.deltaX).toBe(-2)
+ })
+
+ it('returns no snap with non-positive threshold', () => {
+ const r = computeSnap(box('a', 100, 0), [box('b', 100, 0)], 0)
+ expect(r.guides).toEqual([])
+ })
+
+ it('guide span covers both involved boxes', () => {
+ const dragged = box('a', 102, 200, 100, 50) // y range 200..250
+ const other = box('b', 100, 0, 100, 50) // y range 0..50
+ const r = computeSnap(dragged, [other], 6)
+ const g = r.guides[0]
+ expect(g.start).toBeLessThanOrEqual(0)
+ expect(g.end).toBeGreaterThanOrEqual(250)
+ })
+})
+
+describe('unionBox', () => {
+ it('returns null for empty input', () => {
+ expect(unionBox([])).toBeNull()
+ })
+
+ it('returns the box itself for one input', () => {
+ const b = box('a', 10, 20, 30, 40)
+ expect(unionBox([b])).toMatchObject({ x: 10, y: 20, width: 30, height: 40 })
+ })
+
+ it('computes union of multiple boxes', () => {
+ const u = unionBox([box('a', 0, 0, 50, 50), box('b', 100, 100, 50, 50)])
+ expect(u).toMatchObject({ x: 0, y: 0, width: 150, height: 150 })
+ })
+})
diff --git a/frontend/src/utils/__tests__/alignmentSettings.test.ts b/frontend/src/utils/__tests__/alignmentSettings.test.ts
new file mode 100644
index 0000000..5c8c260
--- /dev/null
+++ b/frontend/src/utils/__tests__/alignmentSettings.test.ts
@@ -0,0 +1,42 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import {
+ DEFAULT_ALIGNMENT_SETTINGS,
+ readAlignmentSettings,
+ writeAlignmentSettings,
+ subscribeAlignmentSettings,
+} from '../alignmentSettings'
+
+describe('alignmentSettings', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ })
+
+ it('returns defaults when nothing stored', () => {
+ expect(readAlignmentSettings()).toEqual(DEFAULT_ALIGNMENT_SETTINGS)
+ })
+
+ it('roundtrips through localStorage', () => {
+ writeAlignmentSettings({ enabled: false, threshold: 10 })
+ expect(readAlignmentSettings()).toEqual({ enabled: false, threshold: 10 })
+ })
+
+ it('falls back to defaults when stored value is corrupted', () => {
+ localStorage.setItem('homelable.alignmentGuides', '{not json')
+ expect(readAlignmentSettings()).toEqual(DEFAULT_ALIGNMENT_SETTINGS)
+ })
+
+ it('fills missing fields from defaults', () => {
+ localStorage.setItem('homelable.alignmentGuides', JSON.stringify({ enabled: false }))
+ expect(readAlignmentSettings()).toEqual({ enabled: false, threshold: DEFAULT_ALIGNMENT_SETTINGS.threshold })
+ })
+
+ it('notifies subscribers on write', () => {
+ const listener = vi.fn()
+ const unsubscribe = subscribeAlignmentSettings(listener)
+ writeAlignmentSettings({ enabled: false, threshold: 8 })
+ expect(listener).toHaveBeenCalledWith({ enabled: false, threshold: 8 })
+ unsubscribe()
+ writeAlignmentSettings({ enabled: true, threshold: 4 })
+ expect(listener).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/frontend/src/utils/alignment.ts b/frontend/src/utils/alignment.ts
new file mode 100644
index 0000000..f125617
--- /dev/null
+++ b/frontend/src/utils/alignment.ts
@@ -0,0 +1,173 @@
+// Alignment-snap math for drag-time guides (draw.io / Figma style).
+//
+// Given a dragged box and a set of candidate boxes, return the snap delta
+// (clamped by threshold, expressed in canvas px) plus the guide segments that
+// should be drawn while the snap is engaged.
+//
+// All coordinates are absolute canvas coordinates — callers must resolve
+// child-of-parent positions before calling.
+
+export interface Box {
+ id: string
+ x: number
+ y: number
+ width: number
+ height: number
+}
+
+export type GuideAxis = 'x' | 'y'
+
+export interface Guide {
+ axis: GuideAxis
+ // Position along the perpendicular axis (x for vertical guide, y for horizontal).
+ position: number
+ // Span along the parallel axis: min/max of involved boxes' opposite-axis extents.
+ start: number
+ end: number
+}
+
+export interface SnapResult {
+ deltaX: number
+ deltaY: number
+ guides: Guide[]
+}
+
+type EdgeKind = 'min' | 'center' | 'max'
+
+interface AxisLine {
+ kind: EdgeKind
+ pos: number
+ // Span of the source box along the perpendicular axis (used to extend guide segments).
+ spanStart: number
+ spanEnd: number
+}
+
+function xLines(box: Box): AxisLine[] {
+ const top = box.y
+ const bottom = box.y + box.height
+ return [
+ { kind: 'min', pos: box.x, spanStart: top, spanEnd: bottom },
+ { kind: 'center', pos: box.x + box.width / 2, spanStart: top, spanEnd: bottom },
+ { kind: 'max', pos: box.x + box.width, spanStart: top, spanEnd: bottom },
+ ]
+}
+
+function yLines(box: Box): AxisLine[] {
+ const left = box.x
+ const right = box.x + box.width
+ return [
+ { kind: 'min', pos: box.y, spanStart: left, spanEnd: right },
+ { kind: 'center', pos: box.y + box.height / 2, spanStart: left, spanEnd: right },
+ { kind: 'max', pos: box.y + box.height, spanStart: left, spanEnd: right },
+ ]
+}
+
+interface Match {
+ delta: number
+ guidePos: number
+ candidate: AxisLine
+ dragLine: AxisLine
+}
+
+// Find all (dragLine, candidateLine) pairs at the smallest |delta| within threshold.
+// When boxes share dimensions, all three edges align simultaneously — return all.
+function bestMatches(dragLines: AxisLine[], candidateLines: AxisLine[], threshold: number): Match[] {
+ let bestAbs = Infinity
+ const matches: Match[] = []
+ for (const d of dragLines) {
+ for (const c of candidateLines) {
+ const delta = c.pos - d.pos
+ const abs = Math.abs(delta)
+ if (abs > threshold) continue
+ if (abs < bestAbs - 1e-9) {
+ bestAbs = abs
+ matches.length = 0
+ }
+ if (abs <= bestAbs + 1e-9) {
+ matches.push({ delta, guidePos: c.pos, candidate: c, dragLine: d })
+ }
+ }
+ }
+ return matches
+}
+
+/**
+ * Compute snap delta + visible guides for a drag operation.
+ *
+ * @param dragged Bounding box of the dragged node (or selection bbox).
+ * @param candidates Other nodes' boxes to consider.
+ * @param threshold Max distance (canvas px) at which a snap engages.
+ */
+export function computeSnap(dragged: Box, candidates: Box[], threshold: number): SnapResult {
+ if (candidates.length === 0 || threshold <= 0) {
+ return { deltaX: 0, deltaY: 0, guides: [] }
+ }
+
+ const dragX = xLines(dragged)
+ const dragY = yLines(dragged)
+
+ let bestXMatches: Match[] = []
+ let bestYMatches: Match[] = []
+ let xBestAbs = Infinity
+ let yBestAbs = Infinity
+
+ for (const cand of candidates) {
+ if (cand.id === dragged.id) continue
+ const mx = bestMatches(dragX, xLines(cand), threshold)
+ if (mx.length > 0) {
+ const abs = Math.abs(mx[0].delta)
+ if (abs < xBestAbs - 1e-9) {
+ xBestAbs = abs
+ bestXMatches = mx
+ } else if (abs <= xBestAbs + 1e-9) {
+ bestXMatches = bestXMatches.concat(mx)
+ }
+ }
+ const my = bestMatches(dragY, yLines(cand), threshold)
+ if (my.length > 0) {
+ const abs = Math.abs(my[0].delta)
+ if (abs < yBestAbs - 1e-9) {
+ yBestAbs = abs
+ bestYMatches = my
+ } else if (abs <= yBestAbs + 1e-9) {
+ bestYMatches = bestYMatches.concat(my)
+ }
+ }
+ }
+
+ const deltaX = bestXMatches[0]?.delta ?? 0
+ const deltaY = bestYMatches[0]?.delta ?? 0
+
+ const guides: Guide[] = []
+ const seenX = new Set()
+ for (const m of bestXMatches) {
+ if (seenX.has(m.guidePos)) continue
+ seenX.add(m.guidePos)
+ const start = Math.min(m.dragLine.spanStart + deltaY, m.candidate.spanStart)
+ const end = Math.max(m.dragLine.spanEnd + deltaY, m.candidate.spanEnd)
+ guides.push({ axis: 'x', position: m.guidePos, start, end })
+ }
+ const seenY = new Set()
+ for (const m of bestYMatches) {
+ if (seenY.has(m.guidePos)) continue
+ seenY.add(m.guidePos)
+ const start = Math.min(m.dragLine.spanStart + deltaX, m.candidate.spanStart)
+ const end = Math.max(m.dragLine.spanEnd + deltaX, m.candidate.spanEnd)
+ guides.push({ axis: 'y', position: m.guidePos, start, end })
+ }
+
+ return { deltaX, deltaY, guides }
+}
+
+/** Bounding box of multiple boxes — used for multi-selection drag. */
+export function unionBox(boxes: Box[]): Box | null {
+ if (boxes.length === 0) return null
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
+ for (const b of boxes) {
+ if (b.x < minX) minX = b.x
+ if (b.y < minY) minY = b.y
+ if (b.x + b.width > maxX) maxX = b.x + b.width
+ if (b.y + b.height > maxY) maxY = b.y + b.height
+ }
+ return { id: '__union__', x: minX, y: minY, width: maxX - minX, height: maxY - minY }
+}
diff --git a/frontend/src/utils/alignmentSettings.ts b/frontend/src/utils/alignmentSettings.ts
new file mode 100644
index 0000000..caeab6f
--- /dev/null
+++ b/frontend/src/utils/alignmentSettings.ts
@@ -0,0 +1,43 @@
+// Persisted client-side settings for alignment guides.
+// Kept in localStorage (per-user UI preference, not canvas data).
+// Same-tab updates propagate via a CustomEvent so the drag hook and the
+// settings panel can stay in sync without a global store.
+
+export interface AlignmentSettings {
+ enabled: boolean
+ threshold: number
+}
+
+export const DEFAULT_ALIGNMENT_SETTINGS: AlignmentSettings = { enabled: true, threshold: 6 }
+
+const KEY = 'homelable.alignmentGuides'
+const EVENT = 'homelable:alignment-settings-changed'
+
+export function readAlignmentSettings(): AlignmentSettings {
+ try {
+ const raw = localStorage.getItem(KEY)
+ if (!raw) return DEFAULT_ALIGNMENT_SETTINGS
+ const parsed = JSON.parse(raw) as Partial
+ return {
+ enabled: parsed.enabled ?? DEFAULT_ALIGNMENT_SETTINGS.enabled,
+ threshold: typeof parsed.threshold === 'number' ? parsed.threshold : DEFAULT_ALIGNMENT_SETTINGS.threshold,
+ }
+ } catch {
+ return DEFAULT_ALIGNMENT_SETTINGS
+ }
+}
+
+export function writeAlignmentSettings(s: AlignmentSettings): void {
+ try {
+ localStorage.setItem(KEY, JSON.stringify(s))
+ window.dispatchEvent(new CustomEvent(EVENT, { detail: s }))
+ } catch {
+ /* quota / SSR */
+ }
+}
+
+export function subscribeAlignmentSettings(listener: (s: AlignmentSettings) => void): () => void {
+ const handler = (e: Event) => listener((e as CustomEvent).detail)
+ window.addEventListener(EVENT, handler)
+ return () => window.removeEventListener(EVENT, handler)
+}