From e5a7030d9b1cf6407b30cd66f5986d80234d081a Mon Sep 17 00:00:00 2001
From: Oliver Bryan <04oliverbryan@gmail.com>
Date: Thu, 8 Jan 2026 10:47:48 +0000
Subject: [PATCH] phase 2 complete
---
DEVELOPMENT_PLAN.md | 12 +--
src/App.tsx | 19 +----
src/Index.tsx | 25 +++---
src/Layout.tsx | 10 ++-
src/Settings.tsx | 6 +-
src/components/canvas/MorphableShape.tsx | 23 +++++
src/components/canvas/ShapeCanvas.tsx | 37 ++++++++
src/components/ui/slider.tsx | 104 +++++++++++------------
src/components/ui/toggle.tsx | 75 ++++++++--------
src/hooks/useShapeState.ts | 53 ++++++++++++
10 files changed, 232 insertions(+), 132 deletions(-)
create mode 100644 src/components/canvas/MorphableShape.tsx
create mode 100644 src/components/canvas/ShapeCanvas.tsx
create mode 100644 src/hooks/useShapeState.ts
diff --git a/DEVELOPMENT_PLAN.md b/DEVELOPMENT_PLAN.md
index cbed17d..c51e620 100644
--- a/DEVELOPMENT_PLAN.md
+++ b/DEVELOPMENT_PLAN.md
@@ -104,12 +104,12 @@ src/
### Tasks
-- [ ] Create `useShapeState.ts` hook with default values
-- [ ] Add `beforeunload` warning when state has changed
-- [ ] Create `ShapeCanvas.tsx` - Konva Stage that fills container
-- [ ] Create `MorphableShape.tsx` - renders a simple circle for now
-- [ ] Make shape draggable, update state on drag end
-- [ ] Display current x/y in sidebar (debug, remove later)
+- [x] Create `useShapeState.ts` hook with default values
+- [x] Add `beforeunload` warning when state has changed
+- [x] Create `ShapeCanvas.tsx` - Konva Stage that fills container
+- [x] Create `MorphableShape.tsx` - renders a simple circle for now
+- [x] Make shape draggable, update state on drag end
+- [x] Display current x/y in sidebar (debug, remove later)
### Deliverables
diff --git a/src/App.tsx b/src/App.tsx
index 8ea022e..ce92e6d 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,28 +1,13 @@
import { BrowserRouter, Route, Routes } from "react-router-dom";
import Index from "@/Index";
import Settings from "./Settings";
-import Layout from "./Layout";
function App() {
return (
-
-
-
- }
- />
-
-
-
- }
- />
+ } />
+ } />
);
diff --git a/src/Index.tsx b/src/Index.tsx
index fa5ee68..34b515e 100644
--- a/src/Index.tsx
+++ b/src/Index.tsx
@@ -1,16 +1,18 @@
-import { Stage, Layer, Circle } from "react-konva";
import { useEffect, useState } from "react";
+import ShapeCanvas from "@/components/canvas/ShapeCanvas";
+import { useShapeState } from "@/hooks/useShapeState";
+import Layout from "./Layout";
function Index() {
const [dimensions, setDimensions] = useState({
- width: window.innerWidth,
+ width: window.innerWidth - 320,
height: window.innerHeight,
});
useEffect(() => {
const handleResize = () => {
setDimensions({
- width: window.innerWidth,
+ width: window.innerWidth - 320,
height: window.innerHeight,
});
};
@@ -19,18 +21,15 @@ function Index() {
return () => window.removeEventListener("resize", handleResize);
}, []);
- // canvas fills the available space (accounting for sidebar width of 320px)
- const canvasWidth = dimensions.width - 320;
- const canvasHeight = dimensions.height;
+ const centerX = dimensions.width / 2;
+ const centerY = dimensions.height / 2;
+
+ const [state, setState] = useShapeState(centerX, centerY);
return (
- <>
-
-
-
-
-
- >
+ controls here>}>
+
+
);
}
diff --git a/src/Layout.tsx b/src/Layout.tsx
index 068bfc1..5fad579 100644
--- a/src/Layout.tsx
+++ b/src/Layout.tsx
@@ -3,7 +3,13 @@ import { Link, useLocation } from "react-router-dom";
import { Home, Settings } from "lucide-react";
import { Button } from "@/components/ui/button";
-export default function Layout({ children }: { children: React.ReactNode }) {
+export default function Layout({
+ children,
+ sidebarContent,
+}: {
+ children: React.ReactNode;
+ sidebarContent?: React.ReactNode;
+}) {
const location = useLocation();
return (
@@ -13,7 +19,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
The Shape of Sound
- {/* controls will go here in later phases */}
+ {sidebarContent || null}
diff --git a/src/Settings.tsx b/src/Settings.tsx
index 955efef..9d91fa4 100644
--- a/src/Settings.tsx
+++ b/src/Settings.tsx
@@ -1,7 +1,9 @@
+import Layout from "@/Layout";
+
export default function Settings() {
return (
-
+
);
}
diff --git a/src/components/canvas/MorphableShape.tsx b/src/components/canvas/MorphableShape.tsx
new file mode 100644
index 0000000..6599a2e
--- /dev/null
+++ b/src/components/canvas/MorphableShape.tsx
@@ -0,0 +1,23 @@
+import { Circle } from "react-konva";
+import type { KonvaEventObject } from "konva/lib/Node";
+import type { ShapeState } from "@/types/shape";
+
+export default function MorphableShape({
+ state,
+ onStateChange,
+}: {
+ state: ShapeState;
+ onStateChange: (state: ShapeState) => void;
+}) {
+ const handleDrag = (e: KonvaEventObject
) => {
+ onStateChange({
+ ...state,
+ x: e.target.x(),
+ y: e.target.y(),
+ });
+ };
+
+ return (
+
+ );
+}
diff --git a/src/components/canvas/ShapeCanvas.tsx b/src/components/canvas/ShapeCanvas.tsx
new file mode 100644
index 0000000..482dc89
--- /dev/null
+++ b/src/components/canvas/ShapeCanvas.tsx
@@ -0,0 +1,37 @@
+import { Stage, Layer } from "react-konva";
+import { useEffect, useState } from "react";
+import MorphableShape from "./MorphableShape";
+import type { ShapeState } from "@/types/shape";
+
+export default function ShapeCanvas({
+ state,
+ onStateChange,
+}: {
+ state: ShapeState;
+ onStateChange: (state: ShapeState) => void;
+}) {
+ const [dimensions, setDimensions] = useState({
+ width: window.innerWidth - 320, // account for sidebar
+ height: window.innerHeight,
+ });
+
+ useEffect(() => {
+ const handleResize = () => {
+ setDimensions({
+ width: window.innerWidth - 320,
+ height: window.innerHeight,
+ });
+ };
+
+ window.addEventListener("resize", handleResize);
+ return () => window.removeEventListener("resize", handleResize);
+ }, []);
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/components/ui/slider.tsx b/src/components/ui/slider.tsx
index 5f9617f..0478ae0 100644
--- a/src/components/ui/slider.tsx
+++ b/src/components/ui/slider.tsx
@@ -1,61 +1,57 @@
-import * as React from "react"
-import * as SliderPrimitive from "@radix-ui/react-slider"
+import * as React from "react";
+import * as SliderPrimitive from "@radix-ui/react-slider";
-import { cn } from "@/lib/utils"
+import { cn } from "@/lib/utils";
function Slider({
- className,
- defaultValue,
- value,
- min = 0,
- max = 100,
- ...props
+ className,
+ defaultValue,
+ value,
+ min = 0,
+ max = 100,
+ ...props
}: React.ComponentProps) {
- const _values = React.useMemo(
- () =>
- Array.isArray(value)
- ? value
- : Array.isArray(defaultValue)
- ? defaultValue
- : [min, max],
- [value, defaultValue, min, max]
- )
+ const _values = React.useMemo(
+ () => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]),
+ [value, defaultValue, min, max],
+ );
- return (
-
-
-
-
- {Array.from({ length: _values.length }, (_, index) => (
-
- ))}
-
- )
+ return (
+
+
+
+
+ {Array.from({ length: _values.length }, (_, index) => (
+
+ key={index}
+ className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
+ />
+ ))}
+
+ );
}
-export { Slider }
+export { Slider };
diff --git a/src/components/ui/toggle.tsx b/src/components/ui/toggle.tsx
index 94ec8f5..3dd50f2 100644
--- a/src/components/ui/toggle.tsx
+++ b/src/components/ui/toggle.tsx
@@ -1,47 +1,46 @@
-"use client"
+"use client";
-import * as React from "react"
-import * as TogglePrimitive from "@radix-ui/react-toggle"
-import { cva, type VariantProps } from "class-variance-authority"
+import type * as React from "react";
+import * as TogglePrimitive from "@radix-ui/react-toggle";
+import { cva, type VariantProps } from "class-variance-authority";
-import { cn } from "@/lib/utils"
+import { cn } from "@/lib/utils";
const toggleVariants = cva(
- "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
- {
- variants: {
- variant: {
- default: "bg-transparent",
- outline:
- "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
- },
- size: {
- default: "h-9 px-2 min-w-9",
- sm: "h-8 px-1.5 min-w-8",
- lg: "h-10 px-2.5 min-w-10",
- },
+ "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
+ {
+ variants: {
+ variant: {
+ default: "bg-transparent",
+ outline:
+ "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
+ },
+ size: {
+ default: "h-9 px-2 min-w-9",
+ sm: "h-8 px-1.5 min-w-8",
+ lg: "h-10 px-2.5 min-w-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
},
- defaultVariants: {
- variant: "default",
- size: "default",
- },
- }
-)
+);
function Toggle({
- className,
- variant,
- size,
- ...props
-}: React.ComponentProps &
- VariantProps) {
- return (
-
- )
+ className,
+ variant,
+ size,
+ ...props
+}: React.ComponentProps & VariantProps) {
+ return (
+
+ );
}
-export { Toggle, toggleVariants }
+export { Toggle, toggleVariants };
diff --git a/src/hooks/useShapeState.ts b/src/hooks/useShapeState.ts
new file mode 100644
index 0000000..bf62df4
--- /dev/null
+++ b/src/hooks/useShapeState.ts
@@ -0,0 +1,53 @@
+import { useState, useEffect, useRef } from "react";
+import type { ShapeState } from "@/types/shape";
+
+const DEFAULT_STATE: ShapeState = {
+ x: 0,
+ y: 0,
+ preset: "circle",
+ roundness: 100, // full circle
+ size: 50, // medium
+ wobble: 20, // subtle
+ wobbleSpeed: 50, // medium
+ grain: 0, // none
+ color: "#FF0000", // red (C)
+ octave: 4, // middle octave
+};
+
+export function useShapeState(centerX: number, centerY: number) {
+ const [state, setState] = useState({
+ ...DEFAULT_STATE,
+ x: centerX,
+ y: centerY,
+ });
+
+ const initialStateRef = useRef({
+ ...DEFAULT_STATE,
+ x: centerX,
+ y: centerY,
+ });
+
+ // update center position when canvas resizes
+ useEffect(() => {
+ setState((prev) => ({
+ ...prev,
+ x: centerX,
+ y: centerY,
+ }));
+ }, [centerX, centerY]);
+
+ // beforeunload warning
+ useEffect(() => {
+ const handleBeforeUnload = (e: BeforeUnloadEvent) => {
+ const hasChanged = JSON.stringify(state) !== JSON.stringify(initialStateRef.current);
+ if (hasChanged) {
+ e.preventDefault();
+ }
+ };
+
+ window.addEventListener("beforeunload", handleBeforeUnload);
+ return () => window.removeEventListener("beforeunload", handleBeforeUnload);
+ }, [state]);
+
+ return [state, setState] as const;
+}