diff --git a/package-lock.json b/package-lock.json index ad6c50d..da4d642 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tooltip": "^1.2.8", @@ -3614,6 +3615,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -3773,6 +3780,21 @@ } } }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dismissable-layer": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", @@ -4096,6 +4118,37 @@ } } }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-separator": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", diff --git a/package.json b/package.json index 7754ba0..c983a6b 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tooltip": "^1.2.8", diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index 2f1fe20..516b6ed 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -3,7 +3,10 @@ import { useMachineStore } from "../stores/useMachineStore"; import { useUIStore } from "../stores/useUIStore"; import { WorkflowStepper } from "./WorkflowStepper"; import { ErrorPopoverContent } from "./ErrorPopover"; -import { getStateVisualInfo } from "../utils/machineStateHelpers"; +import { + getStateVisualInfo, + getStatusIndicatorState, +} from "../utils/machineStateHelpers"; import { CheckCircleIcon, BoltIcon, @@ -14,6 +17,7 @@ import { } from "@heroicons/react/24/solid"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import StatusIndicator from "@/components/ui/status-indicator"; import { Popover, PopoverTrigger } from "@/components/ui/popover"; import { Tooltip, @@ -66,20 +70,18 @@ export function AppHeader() { }; const StatusIcon = stateIcons[stateVisual.iconName]; + // Get connection indicator state (idle when disconnected, state-dependent when connected) + const connectionIndicatorState = isConnected + ? getStatusIndicatorState(machineStatus) + : "idle"; + return (
{/* Machine Connection Status - Responsive width column */}
-
-
+

diff --git a/src/components/PatternCanvas.tsx b/src/components/PatternCanvas.tsx index 249784d..7fe875b 100644 --- a/src/components/PatternCanvas.tsx +++ b/src/components/PatternCanvas.tsx @@ -283,9 +283,9 @@ export function PatternCanvas() {

- +
{containerSize.width > 0 && ( diff --git a/src/components/ProgressMonitor.tsx b/src/components/ProgressMonitor.tsx index 793afe6..87128a0 100644 --- a/src/components/ProgressMonitor.tsx +++ b/src/components/ProgressMonitor.tsx @@ -1,4 +1,4 @@ -import { useRef, useEffect, useState, useMemo } from "react"; +import { useRef, useEffect, useMemo } from "react"; import { useShallow } from "zustand/react/shallow"; import { useMachineStore } from "../stores/useMachineStore"; import { usePatternStore } from "../stores/usePatternStore"; @@ -26,6 +26,7 @@ import { } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; +import { ScrollArea } from "@/components/ui/scroll-area"; export function ProgressMonitor() { // Machine store @@ -52,8 +53,6 @@ export function ProgressMonitor() { // Pattern store const pesData = usePatternStore((state) => state.pesData); const currentBlockRef = useRef(null); - const colorBlocksScrollRef = useRef(null); - const [showGradient, setShowGradient] = useState(true); // State indicators const isMaskTraceComplete = @@ -135,31 +134,6 @@ export function ProgressMonitor() { } }, [currentBlockIndex]); - // Handle scroll to detect if at bottom - const handleColorBlocksScroll = () => { - if (colorBlocksScrollRef.current) { - const { scrollTop, scrollHeight, clientHeight } = - colorBlocksScrollRef.current; - const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5; // 5px threshold - setShowGradient(!isAtBottom); - } - }; - - // Check initial scroll state and update on resize - useEffect(() => { - const checkScrollable = () => { - if (colorBlocksScrollRef.current) { - const { scrollHeight, clientHeight } = colorBlocksScrollRef.current; - const isScrollable = scrollHeight > clientHeight; - setShowGradient(isScrollable); - } - }; - - checkScrollable(); - window.addEventListener("resize", checkScrollable); - return () => window.removeEventListener("resize", checkScrollable); - }, [colorBlocks]); - return ( @@ -242,12 +216,8 @@ export function ProgressMonitor() {

Color Blocks

-
-
+ +
{colorBlocks.map((block, index) => { const isCompleted = currentStitch >= block.endStitch; const isCurrent = index === currentBlockIndex; @@ -362,11 +332,7 @@ export function ProgressMonitor() { ); })}
- {/* Gradient overlay to indicate more content below - only on desktop and when not at bottom */} - {showGradient && ( -
- )} -
+
)} diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..51ecedc --- /dev/null +++ b/src/components/ui/scroll-area.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; + +import { cn } from "@/lib/utils"; + +function ScrollArea({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + + ); +} + +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { ScrollArea, ScrollBar }; diff --git a/src/components/ui/status-indicator.tsx b/src/components/ui/status-indicator.tsx new file mode 100644 index 0000000..8e318e0 --- /dev/null +++ b/src/components/ui/status-indicator.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import { cn } from "@/lib/utils"; + +interface StatusIndicatorProps { + state: "active" | "down" | "fixing" | "idle"; + label?: string; + className?: string; + size?: "sm" | "md" | "lg"; + labelClassName?: string; +} + +const getStateColors = (state: StatusIndicatorProps["state"]) => { + switch (state) { + case "active": + return { dot: "bg-green-500", ping: "bg-green-300" }; + case "down": + return { dot: "bg-red-500", ping: "bg-red-300" }; + case "fixing": + return { dot: "bg-yellow-500", ping: "bg-yellow-300" }; + case "idle": + default: + return { dot: "bg-slate-700", ping: "bg-slate-400" }; + } +}; + +const getSizeClasses = (size: StatusIndicatorProps["size"]) => { + switch (size) { + case "sm": + return { dot: "h-2 w-2", ping: "h-2 w-2" }; + case "lg": + return { dot: "h-4 w-4", ping: "h-4 w-4" }; + case "md": + default: + return { dot: "h-3 w-3", ping: "h-3 w-3" }; + } +}; + +const StatusIndicator: React.FC = ({ + state = "idle", + label, + className, + size = "md", + labelClassName, +}) => { + const shouldAnimate = + state === "active" || state === "fixing" || state === "down"; + const colors = getStateColors(state); + const sizeClasses = getSizeClasses(size); + + return ( +
+
+ {shouldAnimate && ( + + )} + +
+ {label && ( +

+ {label} +

+ )} +
+ ); +}; + +export default StatusIndicator; diff --git a/src/utils/machineStateHelpers.ts b/src/utils/machineStateHelpers.ts index 8548bc6..231c84c 100644 --- a/src/utils/machineStateHelpers.ts +++ b/src/utils/machineStateHelpers.ts @@ -205,3 +205,30 @@ export function getStateVisualInfo(status: MachineStatus): StateVisualInfo { return visualMap[category]; } + +/** + * Map machine state category to status indicator state. + * Returns the appropriate state for the StatusIndicator component. + */ +export function getStatusIndicatorState( + status: MachineStatus, +): "active" | "down" | "fixing" | "idle" { + const category = getMachineStateCategory(status); + + switch (category) { + case MachineStateCategory.IDLE: + return "active"; // Gray, no animation + case MachineStateCategory.ACTIVE: + return "active"; // Green with animation + case MachineStateCategory.WAITING: + return "fixing"; // Yellow with animation + case MachineStateCategory.COMPLETE: + return "active"; // Green with animation + case MachineStateCategory.INTERRUPTED: + return "down"; // Red with animation + case MachineStateCategory.ERROR: + return "down"; // Red with animation + default: + return "idle"; + } +}