Repository: jetkvm/kvm
Pull request #305: Add keyboard macros
Author: login: Savid
Labels: name: component: ui
description: Web UI frontend and browser-side issues
Created: 2025-03-29T08:10:39Z | Merged: 2025-04-10T20:51:06Z

- [ui] add macros section to settings to create/edit/delete macros
- [ui] add macro bar under the actionbar to execute macros
- [server] add json rpc to get/set the macros
- [server] save macros to config

## Macro bar

![image](https://github.com/user-attachments/assets/d41a5040-cb27-4407-887a-ebaa6a0fdc54)

## Settings
![image](https://github.com/user-attachments/assets/9f2cac4e-045b-4b4d-ba52-e2f4fd5a2c74)
![image](https://github.com/user-attachments/assets/acbe5b5b-c3b4-4384-bcea-53fcd7d31365)
![image](https://github.com/user-attachments/assets/d72c9c52-c4ca-4831-8763-8d7cb1a704f9)




Diff:
diff --git a/config.go b/config.go
index 642f113fd..1c1b98d8b 100644
--- a/config.go
+++ b/config.go
@@ -14,6 +14,64 @@ type WakeOnLanDevice struct {
 	MacAddress string `json:"macAddress"`
 }
 
+// Constants for keyboard macro limits
+const (
+	MaxMacrosPerDevice = 25
+	MaxStepsPerMacro   = 10
+	MaxKeysPerStep     = 10
+	MinStepDelay       = 50
+	MaxStepDelay       = 2000
+)
+
+type KeyboardMacroStep struct {
+	Keys      []string `json:"keys"`
+	Modifiers []string `json:"modifiers"`
+	Delay     int      `json:"delay"`
+}
+
+func (s *KeyboardMacroStep) Validate() error {
+	if len(s.Keys) > MaxKeysPerStep {
+		return fmt.Errorf("too many keys in step (max %d)", MaxKeysPerStep)
+	}
+
+	if s.Delay < MinStepDelay {
+		s.Delay = MinStepDelay
+	} else if s.Delay > MaxStepDelay {
+		s.Delay = MaxStepDelay
+	}
+
+	return nil
+}
+
+type KeyboardMacro struct {
+	ID        string              `json:"id"`
+	Name      string              `json:"name"`
+	Steps     []KeyboardMacroStep `json:"steps"`
+	SortOrder int                 `json:"sortOrder,omitempty"`
+}
+
+func (m *KeyboardMacro) Validate() error {
+	if m.Name == "" {
+		return fmt.Errorf("macro name cannot be empty")
+	}
+
+	if len(m.Steps) == 0 {
+		return fmt.Errorf("macro must have at least one step")
+	}
+
+	if len(m.Steps) > MaxStepsPerMacro {
+		return fmt.Errorf("too many steps in macro (max %d)", MaxStepsPerMacro)
+	}
+
+	for i := range m.Steps {
+		if err := m.Steps[i].Validate(); err != nil {
+			return fmt.Errorf("invalid step %d: %w", i+1, err)
+		}
+	}
+
+	return nil
+}
+
 type Config struct {
 	CloudURL             string             `json:"cloud_url"`
 	CloudAppURL          string             `json:"cloud_app_url"`
@@ -26,6 +84,7 @@ type Config struct {
 	LocalAuthToken       string             `json:"local_auth_token"`
 	LocalAuthMode        string             `json:"localAuthMode"` //TODO: fix it with migration
 	WakeOnLanDevices     []WakeOnLanDevice  `json:"wake_on_lan_devices"`
+	KeyboardMacros       []KeyboardMacro    `json:"keyboard_macros"`
 	EdidString           string             `json:"hdmi_edid_string"`
 	ActiveExtension      string             `json:"active_extension"`
 	DisplayMaxBrightness int                `json:"display_max_brightness"`
@@ -43,6 +102,7 @@ var defaultConfig = &Config{
 	CloudAppURL:          "https://app.jetkvm.com",
 	AutoUpdateEnabled:    true, // Set a default value
 	ActiveExtension:      "",
+	KeyboardMacros:       []KeyboardMacro{},
 	DisplayMaxBrightness: 64,
 	DisplayDimAfterSec:   120,  // 2 minutes
 	DisplayOffAfterSec:   1800, // 30 minutes
diff --git a/jsonrpc.go b/jsonrpc.go
index 9ce1f1ba9..e5deb497d 100644
--- a/jsonrpc.go
+++ b/jsonrpc.go
@@ -797,6 +797,99 @@ func rpcSetScrollSensitivity(sensitivity string) error {
 	return nil
 }
 
+func getKeyboardMacros() (interface{}, error) {
+	macros := make([]KeyboardMacro, len(config.KeyboardMacros))
+	copy(macros, config.KeyboardMacros)
+
+	return macros, nil
+}
+
+type KeyboardMacrosParams struct {
+	Macros []interface{} `json:"macros"`
+}
+
+func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
+	if params.Macros == nil {
+		return nil, fmt.Errorf("missing or invalid macros parameter")
+	}
+
+	newMacros := make([]KeyboardMacro, 0, len(params.Macros))
+
+	for i, item := range params.Macros {
+		macroMap, ok := item.(map[string]interface{})
+		if !ok {
+			return nil, fmt.Errorf("invalid macro at index %d", i)
+		}
+
+		id, _ := macroMap["id"].(string)
+		if id == "" {
+			id = fmt.Sprintf("macro-%d", time.Now().UnixNano())
+		}
+
+		name, _ := macroMap["name"].(string)
+
+		sortOrder := i + 1
+		if sortOrderFloat, ok := macroMap["sortOrder"].(float64); ok {
+			sortOrder = int(sortOrderFloat)
+		}
+
+		steps := []KeyboardMacroStep{}
+		if stepsArray, ok := macroMap["steps"].([]interface{}); ok {
+			for _, stepItem := range stepsArray {
+				stepMap, ok := stepItem.(map[string]interface{})
+				if !ok {
+					continue
+				}
+
+				step := KeyboardMacroStep{}
+
+				if keysArray, ok := stepMap["keys"].([]interface{}); ok {
+					for _, k := range keysArray {
+						if keyStr, ok := k.(string); ok {
+							step.Keys = append(step.Keys, keyStr)
+						}
+					}
+				}
+
+				if modsArray, ok := stepMap["modifiers"].([]interface{}); ok {
+					for _, m := range modsArray {
+						if modStr, ok := m.(string); ok {
+							step.Modifiers = append(step.Modifiers, modStr)
+						}
+					}
+				}
+
+				if delay, ok := stepMap["delay"].(float64); ok {
+					step.Delay = int(delay)
+				}
+
+				steps = append(steps, step)
+			}
+		}
+
+		macro := KeyboardMacro{
+			ID:        id,
+			Name:      name,
+			Steps:     steps,
+			SortOrder: sortOrder,
+		}
+
+		if err := macro.Validate(); err != nil {
+			return nil, fmt.Errorf("invalid macro at index %d: %w", i, err)
+		}
+
+		newMacros = append(newMacros, macro)
+	}
+
+	config.KeyboardMacros = newMacros
+
+	if err := SaveConfig(); err != nil {
+		return nil, err
+	}
+
+	return nil, nil
+}
+
 var rpcHandlers = map[string]RPCHandler{
 	"ping":                   {Func: rpcPing},
 	"getDeviceID":            {Func: rpcGetDeviceID},
@@ -862,4 +955,6 @@ var rpcHandlers = map[string]RPCHandler{
 	"setCloudUrl":            {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
 	"getScrollSensitivity":   {Func: rpcGetScrollSensitivity},
 	"setScrollSensitivity":   {Func: rpcSetScrollSensitivity, Params: []string{"sensitivity"}},
+	"getKeyboardMacros":      {Func: getKeyboardMacros},
+	"setKeyboardMacros":      {Func: setKeyboardMacros, Params: []string{"params"}},
 }
diff --git a/ui/src/components/Checkbox.tsx b/ui/src/components/Checkbox.tsx
index 261a425fb..e3237b1f0 100644
--- a/ui/src/components/Checkbox.tsx
+++ b/ui/src/components/Checkbox.tsx
@@ -37,11 +37,11 @@ type CheckBoxProps = {
 } & Omit<JSX.IntrinsicElements["input"], "size" | "type">;
 
 const Checkbox = forwardRef<HTMLInputElement, CheckBoxProps>(function Checkbox(
-  { size = "MD", ...props },
+  { size = "MD", className, ...props },
   ref,
 ) {
   const classes = checkboxVariants({ size });
-  return <input ref={ref} {...props} type="checkbox" className={classes} />;
+  return <input ref={ref} {...props} type="checkbox" className={clsx(classes, className)} />;
 });
 Checkbox.displayName = "Checkbox";
 
diff --git a/ui/src/components/Combobox.tsx b/ui/src/components/Combobox.tsx
new file mode 100644
index 000000000..8055043f8
--- /dev/null
+++ b/ui/src/components/Combobox.tsx
@@ -0,0 +1,119 @@
+import { useRef } from "react";
+import clsx from "clsx";
+import { Combobox as HeadlessCombobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
+import { cva } from "@/cva.config";
+import Card from "./Card";
+
+export interface ComboboxOption {
+  value: string;
+  label: string;
+}
+
+const sizes = {
+  XS: "h-[24.5px] pl-3 pr-8 text-xs",
+  SM: "h-[32px] pl-3 pr-8 text-[13px]",
+  MD: "h-[40px] pl-4 pr-10 text-sm",
+  LG: "h-[48px] pl-4 pr-10 px-5 text-base",
+} as const;
+
+const comboboxVariants = cva({
+  variants: { size: sizes },
+});
+
+type BaseProps = React.ComponentProps<typeof HeadlessCombobox>;
+
+interface ComboboxProps extends Omit<BaseProps, 'displayValue'> {
+  displayValue: (option: ComboboxOption) => string;
+  onInputChange: (option: string) => void;
+  options: () => ComboboxOption[];
+  placeholder?: string;
+  emptyMessage?: string;
+  size?: keyof typeof sizes;
+  disabledMessage?: string;
+}
+
+export function Combobox({
+  onInputChange,
+  displayValue,
+  options,
+  disabled = false,
+  placeholder = "Search...",
+  emptyMessage = "No results found",
+  size = "MD",
+  onChange,
+  disabledMessage = "Input disabled",
+  ...otherProps
+}: ComboboxProps) {
+  const inputRef = useRef<HTMLInputElement>(null);
+  const classes = comboboxVariants({ size });
+
+  return (
+    <HeadlessCombobox 
+      onChange={onChange}
+      {...otherProps}
+    >
+      {() => (
+        <>
+          <Card className="w-auto !border border-solid !border-slate-800/30 shadow outline-0 dark:!border-slate-300/30">
+            <ComboboxInput
+            ref={inputRef}
+            className={clsx(
+              classes,
+              
+              // General styling
+              "block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0 transition duration-300",
+              
+              // Hover
+              "hover:bg-blue-50/80 active:bg-blue-100/60",
+              
+              // Dark mode
+              "dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700 dark:active:bg-slate-800/60",
+              
+              // Focus
+              "focus:outline-blue-600 focus:ring-2 focus:ring-blue-700 focus:ring-offset-2 dark:focus:outline-blue-500 dark:focus:ring-blue-500",
+              
+              // Disabled
+              disabled && "pointer-events-none select-none bg-slate-50 text-slate-500/80 dark:bg-slate-800 dark:text-slate-400/80 disabled:hover:bg-white dark:disabled:hover:bg-slate-800"
+            )}
+            placeholder={disabled ? disabledMessage : placeholder}
+            displayValue={displayValue}
+            onChange={(event) => onInputChange(event.target.value)}
+            disabled={disabled}
+            />
+          </Card>
+          
+          {options().length > 0 && (
+            <ComboboxOptions className="absolute left-0 z-[100] mt-1 w-full max-h-60 overflow-auto rounded-md bg-white py-1 text-sm shadow-lg ring-1 ring-black/5 dark:bg-slate-800 dark:ring-slate-700 hide-scrollbar">
+              {options().map((option) => (
+                <ComboboxOption 
+                key={option.value} 
+                value={option}
+                className={clsx(
+                  // General styling
+                  "cursor-default select-none py-2 px-4",
+                  
+                  // Hover and active states
+                  "hover:bg-blue-50/80 ui-active:bg-blue-50/80 ui-active:text-blue-900",
+                  
+                  // Dark mode
+                  "dark:text-slate-300 dark:hover:bg-slate-700 dark:ui-active:bg-slate-700 dark:ui-active:text-blue-200"
+                )}
+                >
+                  {option.label}
+                </ComboboxOption>
+              ))}
+            </ComboboxOptions>
+          )}
+          
+          {options().length === 0 && inputRef.current?.value && (
+            <div className="absolute left-0 z-[100] mt-1 w-full rounded-md bg-white dark:bg-slate-800 py-2 px-4 text-sm shadow-lg ring-1 ring-black/5 dark:ring-slate-700">
+              <div className="text-slate-500 dark:text-slate-400">
+                {emptyMessage}
+              </div>
+            </div>
+          )}
+        </>
+      )}
+    </HeadlessCombobox>
+  );
+}
\ No newline at end of file
diff --git a/ui/src/components/ConfirmDialog.tsx b/ui/src/components/ConfirmDialog.tsx
new file mode 100644
index 000000000..57391e2b8
--- /dev/null
+++ b/ui/src/components/ConfirmDialog.tsx
@@ -0,0 +1,106 @@
+import { ExclamationTriangleIcon, CheckCircleIcon, InformationCircleIcon } from "@heroicons/react/24/outline";
+import { cx } from "@/cva.config";
+import { Button } from "@/components/Button";
+import Modal from "@/components/Modal";
+
+type Variant = "danger" | "success" | "warning" | "info";
+
+interface ConfirmDialogProps {
+  open: boolean;
+  onClose: () => void;
+  title: string;
+  description: string;
+  variant?: Variant;
+  confirmText?: string;
+  cancelText?: string | null;
+  onConfirm: () => void;
+  isConfirming?: boolean;
+}
+
+const variantConfig = {
+  danger: {
+    icon: ExclamationTriangleIcon,
+    iconClass: "text-red-600",
+    iconBgClass: "bg-red-100",
+    buttonTheme: "danger",
+  },
+  success: {
+    icon: CheckCircleIcon,
+    iconClass: "text-green-600",
+    iconBgClass: "bg-green-100",
+    buttonTheme: "primary",
+  },
+  warning: {
+    icon: ExclamationTriangleIcon,
+    iconClass: "text-yellow-600",
+    iconBgClass: "bg-yellow-100",
+    buttonTheme: "lightDanger",
+  },
+  info: {
+    icon: InformationCircleIcon,
+    iconClass: "text-blue-600",
+    iconBgClass: "bg-blue-100",
+    buttonTheme: "primary",
+  },
+} as Record<Variant, {
+    icon: React.ElementType;
+    iconClass: string;
+    iconBgClass: string;
+    buttonTheme: "danger" | "primary" | "blank" | "light" | "lightDanger";
+}>;
+
+export function ConfirmDialog({
+  open,
+  onClose,
+  title,
+  description,
+  variant = "info",
+  confirmText = "Confirm",
+  cancelText = "Cancel",
+  onConfirm,
+  isConfirming = false,
+}: ConfirmDialogProps) {
+  const { icon: Icon, iconClass, iconBgClass, buttonTheme } = variantConfig[variant];
+
+  return (
+    <Modal open={open} onClose={onClose}>
+      <div className="mx-auto max-w-xl px-4 transition-all duration-300 ease-in-out">
+        <div className="relative w-full overflow-hidden rounded-lg bg-white p-6 text-left align-middle shadow-xl transition-all dark:bg-slate-800 pointer-events-auto">
+          <div className="space-y-4">
+            <div className="sm:flex sm:items-start">
+              <div className={cx("mx-auto flex size-12 shrink-0 items-center justify-center rounded-full sm:mx-0 sm:size-10", iconBgClass)}>
+                <Icon aria-hidden="true" className={cx("size-6", iconClass)} />
+              </div>
+              <div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
+                <h2 className="text-lg font-bold leading-tight text-black dark:text-white">
+                  {title}
+                </h2>
+                <div className="mt-2 text-sm leading-snug text-slate-600 dark:text-slate-400">
+                  {description}
+                </div>
+              </div>
+            </div>
+
+            <div className="flex justify-end gap-x-2">
+              {cancelText && (
+                <Button
+                  size="SM"
+                  theme="blank"
+                  text={cancelText}
+                  onClick={onClose}
+                />
+              )}
+              <Button
+                size="SM"
+                theme={buttonTheme}
+                text={isConfirming ? `${confirmText}...` : confirmText}
+                onClick={onConfirm}
+                disabled={isConfirming}
+              />
+            </div>
+          </div>
+        </div>
+      </div>
+    </Modal>
+  );
+} 
\ No newline at end of file
diff --git a/ui/src/components/FieldLabel.tsx b/ui/src/components/FieldLabel.tsx
index 42e6edeaa..f9065a1d7 100644
--- a/ui/src/components/FieldLabel.tsx
+++ b/ui/src/components/FieldLabel.tsx
@@ -49,4 +49,4 @@ export default function FieldLabel({
   } else {
     return <></>;
   }
-}
+}
\ No newline at end of file
diff --git a/ui/src/components/MacroBar.tsx b/ui/src/components/MacroBar.tsx
new file mode 100644
index 000000000..066c21f21
--- /dev/null
+++ b/ui/src/components/MacroBar.tsx
@@ -0,0 +1,48 @@
+import { useEffect } from "react";
+import { LuCommand } from "react-icons/lu";
+
+import { Button } from "@components/Button";
+import Container from "@components/Container";
+import { useMacrosStore } from "@/hooks/stores";
+import useKeyboard from "@/hooks/useKeyboard";
+import { useJsonRpc } from "@/hooks/useJsonRpc";
+
+export default function MacroBar() {
+  const { macros, initialized, loadMacros, setSendFn } = useMacrosStore();
+  const { executeMacro } = useKeyboard();
+  const [send] = useJsonRpc();
+
+  useEffect(() => {
+    setSendFn(send);
+    
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros, setSendFn, send]);
+
+  if (macros.length === 0) {
+    return null;
+  }
+
+  return (
+    <Container className="bg-white dark:bg-slate-900 border-b border-b-slate-800/20 dark:border-b-slate-300/20">
+      <div className="flex items-center gap-x-2 py-1.5">
+        <div className="absolute -ml-5">
+          <LuCommand className="h-4 w-4 text-slate-500" />
+        </div>
+        <div className="flex flex-wrap gap-2">
+          {macros.map(macro => (
+            <Button
+              key={macro.id}
+              aria-label={macro.name}
+              size="XS"
+              theme="light"
+              text={macro.name}
+              onClick={() => executeMacro(macro.steps)}
+            />
+          ))}
+        </div>
+      </div>
+    </Container>
+  );
+} 
\ No newline at end of file
diff --git a/ui/src/components/MacroForm.tsx b/ui/src/components/MacroForm.tsx
new file mode 100644
index 000000000..135817c2d
--- /dev/null
+++ b/ui/src/components/MacroForm.tsx
@@ -0,0 +1,271 @@
+import { useState } from "react";
+
+import { LuPlus } from "react-icons/lu";
+
+import { KeySequence } from "@/hooks/stores";
+import { Button } from "@/components/Button";
+import { InputFieldWithLabel, FieldError } from "@/components/InputField";
+import Fieldset from "@/components/Fieldset";
+import { MacroStepCard } from "@/components/MacroStepCard";
+import { DEFAULT_DELAY, MAX_STEPS_PER_MACRO, MAX_KEYS_PER_STEP } from "@/constants/macros";
+import FieldLabel from "@/components/FieldLabel";
+
+interface ValidationErrors {
+  name?: string;
+  steps?: Record<number, {
+    keys?: string;
+    modifiers?: string;
+    delay?: string;
+  }>;
+}
+
+interface MacroFormProps {
+  initialData: Partial<KeySequence>;
+  onSubmit: (macro: Partial<KeySequence>) => Promise<void>;
+  onCancel: () => void;
+  isSubmitting?: boolean;
+  submitText?: string;
+}
+
+export function MacroForm({
+  initialData,
+  onSubmit,
+  onCancel,
+  isSubmitting = false,
+  submitText = "Save Macro",
+}: MacroFormProps) {
+  const [macro, setMacro] = useState<Partial<KeySequence>>(initialData);
+  const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
+  const [errors, setErrors] = useState<ValidationErrors>({});
+  const [errorMessage, setErrorMessage] = useState<string | null>(null);
+
+  const showTemporaryError = (message: string) => {
+    setErrorMessage(message);
+    setTimeout(() => setErrorMessage(null), 3000);
+  };
+
+  const validateForm = (): boolean => {
+    const newErrors: ValidationErrors = {};
+
+    // Name validation
+    if (!macro.name?.trim()) {
+      newErrors.name = "Name is required";
+    } else if (macro.name.trim().length > 50) {
+      newErrors.name = "Name must be less than 50 characters";
+    }
+  
+    if (!macro.steps?.length) {
+      newErrors.steps = { 0: { keys: "At least one step is required" } };
+    } else {
+      const hasKeyOrModifier = macro.steps.some(step => 
+        (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0
+      );
+
+      if (!hasKeyOrModifier) {
+        newErrors.steps = { 0: { keys: "At least one step must have keys or modifiers" } };
+      }
+    }
+
+    setErrors(newErrors);
+    return Object.keys(newErrors).length === 0;
+  };
+
+  const handleSubmit = async () => {
+    if (!validateForm()) {
+      showTemporaryError("Please fix the validation errors");
+      return;
+    }
+
+    try {
+      await onSubmit(macro);
+    } catch (error) {
+      if (error instanceof Error) {
+        showTemporaryError(error.message);
+      } else {
+        showTemporaryError("An error occurred while saving");
+      }
+    }
+  };
+
+  const handleKeySelect = (stepIndex: number, option: { value: string | null; keys?: string[] }) => {
+    const newSteps = [...(macro.steps || [])];
+    if (!newSteps[stepIndex]) return;
+
+    if (option.keys) {
+      newSteps[stepIndex].keys = option.keys;
+    } else if (option.value) {
+      if (!newSteps[stepIndex].keys) {
+        newSteps[stepIndex].keys = [];
+      }
+      const keysArray = Array.isArray(newSteps[stepIndex].keys) ? newSteps[stepIndex].keys : [];
+      if (keysArray.length >= MAX_KEYS_PER_STEP) {
+        showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`);
+        return;
+      }
+      newSteps[stepIndex].keys = [...keysArray, option.value];
+    }
+    setMacro({ ...macro, steps: newSteps });
+    
+    if (errors.steps?.[stepIndex]?.keys) {
+      const newErrors = { ...errors };
+      delete newErrors.steps?.[stepIndex].keys;
+      if (Object.keys(newErrors.steps?.[stepIndex] || {}).length === 0) {
+        delete newErrors.steps?.[stepIndex];
+      }
+      if (Object.keys(newErrors.steps || {}).length === 0) {
+        delete newErrors.steps;
+      }
+      setErrors(newErrors);
+    }
+  };
+
+  const handleKeyQueryChange = (stepIndex: number, query: string) => {
+    setKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
+  };
+
+  const handleModifierChange = (stepIndex: number, modifiers: string[]) => {
+    const newSteps = [...(macro.steps || [])];
+    newSteps[stepIndex].modifiers = modifiers;
+    setMacro({ ...macro, steps: newSteps });
+    
+    // Clear step errors when modifiers are added
+    if (errors.steps?.[stepIndex]?.keys && modifiers.length > 0) {
+      const newErrors = { ...errors };
+      delete newErrors.steps?.[stepIndex].keys;
+      if (Object.keys(newErrors.steps?.[stepIndex] || {}).length === 0) {
+        delete newErrors.steps?.[stepIndex];
+      }
+      if (Object.keys(newErrors.steps || {}).length === 0) {
+        delete newErrors.steps;
+      }
+      setErrors(newErrors);
+    }
+  };
+
+  const handleDelayChange = (stepIndex: number, delay: number) => {
+    const newSteps = [...(macro.steps || [])];
+    newSteps[stepIndex].delay = delay;
+    setMacro({ ...macro, steps: newSteps });
+  };
+
+  const handleStepMove = (stepIndex: number, direction: 'up' | 'down') => {
+    const newSteps = [...(macro.steps || [])];
+    const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
+    [newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]];
+    setMacro({ ...macro, steps: newSteps });
+  };
+
+  const isMaxStepsReached = (macro.steps?.length || 0) >= MAX_STEPS_PER_MACRO;
+
+  return (
+    <>
+      <div className="space-y-4">
+        <Fieldset>
+          <InputFieldWithLabel
+            type="text"
+            label="Macro Name"
+            placeholder="Macro Name"
+            value={macro.name}
+            error={errors.name}
+            onChange={e => {
+              setMacro(prev => ({ ...prev, name: e.target.value }));
+              if (errors.name) {
+                const newErrors = { ...errors };
+                delete newErrors.name;
+                setErrors(newErrors);
+              }
+            }}
+          />
+        </Fieldset>
+
+        <div>
+          <div className="flex items-center justify-between text-sm">
+            <div className="flex items-center gap-1">
+              <FieldLabel label="Steps" description={`Keys/modifiers executed in sequence with a delay between each step.`} />
+            </div>
+            <span className="text-slate-500 dark:text-slate-400">
+              {macro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps
+            </span>
+          </div>
+          {errors.steps && errors.steps[0]?.keys && (
+            <div className="mt-2">
+              <FieldError error={errors.steps[0].keys} />
+            </div>
+          )}
+          <Fieldset>
+            <div className="mt-2 space-y-4">
+              {(macro.steps || []).map((step, stepIndex) => (
+                <MacroStepCard
+                  key={stepIndex}
+                  step={step}
+                  stepIndex={stepIndex}
+                  onDelete={macro.steps && macro.steps.length > 1 ? () => {
+                    const newSteps = [...(macro.steps || [])];
+                    newSteps.splice(stepIndex, 1);
+                    setMacro(prev => ({ ...prev, steps: newSteps }));
+                  } : undefined}
+                  onMoveUp={() => handleStepMove(stepIndex, 'up')}
+                  onMoveDown={() => handleStepMove(stepIndex, 'down')}
+                  onKeySelect={(option) => handleKeySelect(stepIndex, option)}
+                  onKeyQueryChange={(query) => handleKeyQueryChange(stepIndex, query)}
+                  keyQuery={keyQueries[stepIndex] || ''}
+                  onModifierChange={(modifiers) => handleModifierChange(stepIndex, modifiers)}
+                  onDelayChange={(delay) => handleDelayChange(stepIndex, delay)}
+                  isLastStep={stepIndex === (macro.steps?.length || 0) - 1}
+                />
+              ))}
+            </div>
+          </Fieldset>
+
+          <div className="mt-4">
+            <Button
+              size="MD"
+              theme="light"
+              fullWidth
+              LeadingIcon={LuPlus}
+              text={`Add Step ${isMaxStepsReached ? `(${MAX_STEPS_PER_MACRO} max)` : ''}`}
+              onClick={() => {
+                if (isMaxStepsReached) {
+                  showTemporaryError(`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`);
+                  return;
+                }
+                
+                setMacro(prev => ({
+                  ...prev,
+                  steps: [
+                    ...(prev.steps || []), 
+                    { keys: [], modifiers: [], delay: DEFAULT_DELAY }
+                  ],
+                }));
+                setErrors({});
+              }}
+              disabled={isMaxStepsReached}
+            />
+          </div>
+
+          {errorMessage && (
+            <div className="mt-4">
+              <FieldError error={errorMessage} />
+            </div>
+          )}
+
+          <div className="mt-6 flex items-center gap-x-2">
+            <Button
+              size="SM"
+              theme="primary"
+              text={isSubmitting ? "Saving..." : submitText}
+              onClick={handleSubmit}
+              disabled={isSubmitting}
+            />
+            <Button
+              size="SM"
+              theme="light"
+              text="Cancel"
+              onClick={onCancel}
+            />
+          </div>
+        </div>
+      </div>
+    </>
+  );
+}
\ No newline at end of file
diff --git a/ui/src/components/MacroStepCard.tsx b/ui/src/components/MacroStepCard.tsx
new file mode 100644
index 000000000..8642c28c3
--- /dev/null
+++ b/ui/src/components/MacroStepCard.tsx
@@ -0,0 +1,235 @@
+import { LuArrowUp, LuArrowDown, LuX, LuTrash2 } from "react-icons/lu";
+
+import { Button } from "@/components/Button";
+import { Combobox } from "@/components/Combobox";
+import { SelectMenuBasic } from "@/components/SelectMenuBasic";
+import Card from "@/components/Card";
+import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
+import { MAX_KEYS_PER_STEP, DEFAULT_DELAY } from "@/constants/macros";
+import FieldLabel from "@/components/FieldLabel";
+
+// Filter out modifier keys since they're handled in the modifiers section
+const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta'];
+
+const keyOptions = Object.keys(keys)
+  .filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
+  .map(key => ({
+    value: key,
+    label: keyDisplayMap[key] || key,
+  }));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers: Record<string, typeof modifierOptions> = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+const basePresetDelays = [
+  { value: "50", label: "50ms" },
+  { value: "100", label: "100ms" },
+  { value: "200", label: "200ms" },
+  { value: "300", label: "300ms" },
+  { value: "500", label: "500ms" },
+  { value: "750", label: "750ms" },
+  { value: "1000", label: "1000ms" },
+  { value: "1500", label: "1500ms" },
+  { value: "2000", label: "2000ms" },
+];
+
+const PRESET_DELAYS = basePresetDelays.map(delay => {
+  if (parseInt(delay.value, 10) === DEFAULT_DELAY) {
+    return { ...delay, label: "Default" };
+  }
+  return delay;
+});
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  onKeySelect: (option: { value: string | null; keys?: string[] }) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+export function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  const getFilteredKeys = () => {
+    const selectedKeys = ensureArray(step.keys);
+    const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value));
+    
+    if (keyQuery === '') {
+      return availableKeys;
+    } else {
+      return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase()));
+    }
+  };
+
+  return (
+    <Card className="p-4">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <span className="flex h-6 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+
+        <div className="flex items-center space-x-2">
+          <div className="flex items-center gap-1">
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+              LeadingIcon={LuArrowUp}
+            />
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+              LeadingIcon={LuArrowDown}
+            />
+          </div>
+          {onDelete && (
+            <Button
+              size="XS"
+              theme="light"
+              className="text-red-500 dark:text-red-400"
+              text="Delete"
+              LeadingIcon={LuTrash2}
+              onClick={onDelete}
+            />
+          )}
+        </div>
+      </div>
+
+      <div className="space-y-4 mt-2">
+        <div className="w-full flex flex-col gap-2">
+          <FieldLabel label="Modifiers" />
+          <div className="inline-flex flex-wrap gap-3">
+            {Object.entries(groupedModifiers).map(([group, mods]) => (
+              <div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
+                <span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
+                  {group}
+                </span>
+                <div className="flex flex-wrap gap-4 pt-1">
+                  {mods.map(option => (
+                    <Button
+                      key={option.value}
+                      size="XS"
+                      theme={ensureArray(step.modifiers).includes(option.value) ? "primary" : "light"}
+                      text={option.label.split(' ')[1] || option.label}
+                      onClick={() => {
+                        const modifiersArray = ensureArray(step.modifiers);
+                        const isSelected = modifiersArray.includes(option.value);
+                        const newModifiers = isSelected
+                          ? modifiersArray.filter(m => m !== option.value)
+                          : [...modifiersArray, option.value];
+                        onModifierChange(newModifiers);
+                      }}
+                    />
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <FieldLabel label="Keys" description={`Maximum ${MAX_KEYS_PER_STEP} keys per step.`} />
+          </div>
+          {ensureArray(step.keys) && step.keys.length > 0 && (
+            <div className="flex flex-wrap gap-1 pb-2">
+              {step.keys.map((key, keyIndex) => (
+                <span
+                  key={keyIndex}
+                  className="inline-flex items-center py-0.5 rounded-md bg-blue-100 px-1 text-xs font-medium text-blue-800 dark:bg-blue-900/40 dark:text-blue-200"
+                >
+                  <span className="px-1">
+                    {keyDisplayMap[key] || key}
+                  </span>
+                  <Button
+                    size="XS"
+                    className=""
+                    theme="blank"
+                    onClick={() => {
+                      const newKeys = ensureArray(step.keys).filter((_, i) => i !== keyIndex);
+                      onKeySelect({ value: null, keys: newKeys });
+                    }}
+                    LeadingIcon={LuX}
+                  />
+                </span>
+              ))}
+            </div>
+          )}
+          <div className="relative w-full">
+            <Combobox
+              onChange={(value: { value: string; label: string }) => {
+                onKeySelect(value);
+                onKeyQueryChange('');
+              }}
+              displayValue={() => keyQuery}
+              onInputChange={onKeyQueryChange}
+              options={getFilteredKeys}
+              disabledMessage="Max keys reached"
+              size="SM"
+              immediate
+              disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP}
+              placeholder={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP ? "Max keys reached" : "Search for key..."}
+              emptyMessage="No matching keys found"
+            />
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <FieldLabel label="Step Duration" description="Time to wait before executing the next step." />
+          </div>
+          <div className="flex items-center gap-3">
+            <SelectMenuBasic
+              size="SM"
+              fullWidth
+              value={step.delay.toString()}
+              onChange={(e) => onDelayChange(parseInt(e.target.value, 10))}
+              options={PRESET_DELAYS}
+            />
+          </div>
+        </div>
+      </div>
+    </Card>
+  );
+} 
\ No newline at end of file
diff --git a/ui/src/components/VirtualKeyboard.tsx b/ui/src/components/VirtualKeyboard.tsx
index ec5906c09..09a94a6da 100644
--- a/ui/src/components/VirtualKeyboard.tsx
+++ b/ui/src/components/VirtualKeyboard.tsx
@@ -11,7 +11,7 @@ import "react-simple-keyboard/build/css/index.css";
 
 import { useHidStore, useUiStore } from "@/hooks/stores";
 import { cx } from "@/cva.config";
-import { keys, modifiers } from "@/keyboardMappings";
+import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
 import useKeyboard from "@/hooks/useKeyboard";
 import DetachIconRaw from "@/assets/detach-icon.svg";
 import AttachIconRaw from "@/assets/attach-icon.svg";
@@ -260,136 +260,7 @@ function KeyboardWrapper() {
                           buttons: "CtrlAltDelete AltMetaEscape",
                         },
                       ]}
-                      display={{
-                        CtrlAltDelete: "Ctrl + Alt + Delete",
-                        AltMetaEscape: "Alt + Meta + Escape",
-                        Escape: "esc",
-                        Tab: "tab",
-                        Backspace: "backspace",
-                        "(Backspace)": "backspace",
-                        Enter: "enter",
-                        CapsLock: "caps lock",
-                        ShiftLeft: "shift",
-                        ShiftRight: "shift",
-                        ControlLeft: "ctrl",
-                        AltLeft: "alt",
-                        AltRight: "alt",
-                        MetaLeft: "meta",
-                        MetaRight: "meta",
-                        KeyQ: "q",
-                        KeyW: "w",
-                        KeyE: "e",
-                        KeyR: "r",
-                        KeyT: "t",
-                        KeyY: "y",
-                        KeyU: "u",
-                        KeyI: "i",
-                        KeyO: "o",
-                        KeyP: "p",
-                        KeyA: "a",
-                        KeyS: "s",
-                        KeyD: "d",
-                        KeyF: "f",
-                        KeyG: "g",
-                        KeyH: "h",
-                        KeyJ: "j",
-                        KeyK: "k",
-                        KeyL: "l",
-                        KeyZ: "z",
-                        KeyX: "x",
-                        KeyC: "c",
-                        KeyV: "v",
-                        KeyB: "b",
-                        KeyN: "n",
-                        KeyM: "m",
-
-                        "(KeyQ)": "Q",
-                        "(KeyW)": "W",
-                        "(KeyE)": "E",
-                        "(KeyR)": "R",
-                        "(KeyT)": "T",
-                        "(KeyY)": "Y",
-                        "(KeyU)": "U",
-                        "(KeyI)": "I",
-                        "(KeyO)": "O",
-                        "(KeyP)": "P",
-                        "(KeyA)": "A",
-                        "(KeyS)": "S",
-                        "(KeyD)": "D",
-                        "(KeyF)": "F",
-                        "(KeyG)": "G",
-                        "(KeyH)": "H",
-                        "(KeyJ)": "J",
-                        "(KeyK)": "K",
-                        "(KeyL)": "L",
-                        "(KeyZ)": "Z",
-                        "(KeyX)": "X",
-                        "(KeyC)": "C",
-                        "(KeyV)": "V",
-                        "(KeyB)": "B",
-                        "(KeyN)": "N",
-                        "(KeyM)": "M",
-                        Digit1: "1",
-                        Digit2: "2",
-                        Digit3: "3",
-                        Digit4: "4",
-                        Digit5: "5",
-                        Digit6: "6",
-                        Digit7: "7",
-                        Digit8: "8",
-                        Digit9: "9",
-                        Digit0: "0",
-
-                        "(Digit1)": "!",
-                        "(Digit2)": "@",
-                        "(Digit3)": "#",
-                        "(Digit4)": "$",
-                        "(Digit5)": "%",
-                        "(Digit6)": "^",
-                        "(Digit7)": "&",
-                        "(Digit8)": "*",
-                        "(Digit9)": "(",
-                        "(Digit0)": ")",
-                        Minus: "-",
-                        "(Minus)": "_",
-
-                        Equal: "=",
-                        "(Equal)": "+",
-                        BracketLeft: "[",
-                        BracketRight: "]",
-                        "(BracketLeft)": "{",
-                        "(BracketRight)": "}",
-                        Backslash: "\\",
-                        "(Backslash)": "|",
-
-                        Semicolon: ";",
-                        "(Semicolon)": ":",
-                        Quote: "'",
-                        "(Quote)": '"',
-                        Comma: ",",
-                        "(Comma)": "<",
-                        Period: ".",
-                        "(Period)": ">",
-                        Slash: "/",
-                        "(Slash)": "?",
-                        Space: " ",
-                        Backquote: "`",
-                        "(Backquote)": "~",
-                        IntlBackslash: "\\",
-
-                        F1: "F1",
-                        F2: "F2",
-                        F3: "F3",
-                        F4: "F4",
-                        F5: "F5",
-                        F6: "F6",
-                        F7: "F7",
-                        F8: "F8",
-                        F9: "F9",
-                        F10: "F10",
-                        F11: "F11",
-                        F12: "F12",
-                      }}
+                      display={keyDisplayMap}
                       layout={{
                         default: [
                           "CtrlAltDelete AltMetaEscape",
diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx
index 99c0191d0..3d72f309f 100644
--- a/ui/src/components/WebRTCVideo.tsx
+++ b/ui/src/components/WebRTCVideo.tsx
@@ -13,6 +13,7 @@ import { useResizeObserver } from "@/hooks/useResizeObserver";
 import { cx } from "@/cva.config";
 import VirtualKeyboard from "@components/VirtualKeyboard";
 import Actionbar from "@components/ActionBar";
+import MacroBar from "@/components/MacroBar";
 import InfoBar from "@components/InfoBar";
 import useKeyboard from "@/hooks/useKeyboard";
 import { useJsonRpc } from "@/hooks/useJsonRpc";
@@ -552,16 +553,19 @@ export default function WebRTCVideo() {
 
   return (
     <div className="grid h-full w-full grid-rows-layout">
-      <div className="min-h-[39.5px]">
-        <fieldset disabled={peerConnection?.connectionState !== "connected"}>
-          <Actionbar
-            requestFullscreen={async () =>
-              videoElm.current?.requestFullscreen({
-                navigationUI: "show",
-              })
-            }
-          />
-        </fieldset>
+      <div className="min-h-[39.5px] flex flex-col">
+        <div className="flex flex-col">
+          <fieldset disabled={peerConnection?.connectionState !== "connected"} className="contents">
+            <Actionbar
+              requestFullscreen={async () =>
+                videoElm.current?.requestFullscreen({
+                  navigationUI: "show",
+                })
+              }
+            />
+            <MacroBar />
+          </fieldset>
+        </div>
       </div>
 
       <div
diff --git a/ui/src/constants/macros.ts b/ui/src/constants/macros.ts
new file mode 100644
index 000000000..853cfe9d8
--- /dev/null
+++ b/ui/src/constants/macros.ts
@@ -0,0 +1,5 @@
+export const DEFAULT_DELAY = 50;
+export const MAX_STEPS_PER_MACRO = 10;
+export const MAX_KEYS_PER_STEP = 10;
+export const MAX_TOTAL_MACROS = 25; 
+export const COPY_SUFFIX = "(copy)";
\ No newline at end of file
diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts
index f30c28c92..0fa4121bf 100644
--- a/ui/src/hooks/stores.ts
+++ b/ui/src/hooks/stores.ts
@@ -1,5 +1,18 @@
 import { create } from "zustand";
 import { createJSONStorage, persist } from "zustand/middleware";
+import { MAX_STEPS_PER_MACRO, MAX_TOTAL_MACROS, MAX_KEYS_PER_STEP } from "@/constants/macros";
+
+// Define the JsonRpc types for better type checking
+interface JsonRpcResponse {
+  jsonrpc: string;
+  result?: unknown;
+  error?: {
+    code: number;
+    message: string;
+    data?: unknown;
+  };
+  id: number | string | null;
+}
 
 // Utility function to append stats to a Map
 const appendStatToMap = <T extends { timestamp: number }>(
@@ -649,3 +662,146 @@ export const useDeviceStore = create<DeviceState>(set => ({
   setAppVersion: version => set({ appVersion: version }),
   setSystemVersion: version => set({ systemVersion: version }),
 }));
+
+export interface KeySequenceStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+export interface KeySequence {
+  id: string;
+  name: string;
+  steps: KeySequenceStep[];
+  sortOrder?: number;
+}
+
+export interface MacrosState {
+  macros: KeySequence[];
+  loading: boolean;
+  initialized: boolean;
+  loadMacros: () => Promise<void>;
+  saveMacros: (macros: KeySequence[]) => Promise<void>;
+  sendFn: ((method: string, params: unknown, callback?: ((resp: JsonRpcResponse) => void) | undefined) => void) | null;
+  setSendFn: (sendFn: ((method: string, params: unknown, callback?: ((resp: JsonRpcResponse) => void) | undefined) => void)) => void;
+}
+
+export const generateMacroId = () => {
+  return Math.random().toString(36).substring(2, 9);
+};
+
+export const useMacrosStore = create<MacrosState>((set, get) => ({
+  macros: [],
+  loading: false,
+  initialized: false,
+  sendFn: null,
+
+  setSendFn: (sendFn) => {
+    set({ sendFn });
+  },
+
+  loadMacros: async () => {
+    if (get().initialized) return;
+
+    const { sendFn } = get();
+    if (!sendFn) {
+      console.warn("JSON-RPC send function not available.");
+      return;
+    }
+
+    set({ loading: true });
+
+    try {
+      await new Promise<void>((resolve, reject) => {
+        sendFn("getKeyboardMacros", {}, (response) => {
+          if (response.error) {
+            console.error("Error loading macros:", response.error);
+            reject(new Error(response.error.message));
+            return;
+          }
+
+          const macros = (response.result as KeySequence[]) || [];
+
+          const sortedMacros = [...macros].sort((a, b) => {
+            if (a.sortOrder !== undefined && b.sortOrder !== undefined) {
+              return a.sortOrder - b.sortOrder;
+            }
+            if (a.sortOrder !== undefined) return -1;
+            if (b.sortOrder !== undefined) return 1;
+            return 0;
+          });
+
+          set({
+            macros: sortedMacros,
+            initialized: true
+          });
+
+          resolve();
+        });
+      });
+    } catch (error) {
+      console.error("Failed to load macros:", error);
+    } finally {
+      set({ loading: false });
+    }
+  },
+
+  saveMacros: async (macros: KeySequence[]) => {
+    const { sendFn } = get();
+    if (!sendFn) {
+      console.warn("JSON-RPC send function not available.");
+      throw new Error("JSON-RPC send function not available");
+    }
+
+    if (macros.length > MAX_TOTAL_MACROS) {
+      console.error(`Cannot save: exceeded maximum of ${MAX_TOTAL_MACROS} macros`);
+      throw new Error(`Cannot save: exceeded maximum of ${MAX_TOTAL_MACROS} macros`);
+    }
+
+    for (const macro of macros) {
+      if (macro.steps.length > MAX_STEPS_PER_MACRO) {
+        console.error(`Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`);
+        throw new Error(`Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`);
+      }
+
+      for (let i = 0; i < macro.steps.length; i++) {
+        const step = macro.steps[i];
+        if (step.keys && step.keys.length > MAX_KEYS_PER_STEP) {
+          console.error(`Cannot save: macro "${macro.name}" step ${i+1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`);
+          throw new Error(`Cannot save: macro "${macro.name}" step ${i+1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`);
+        }
+      }
+    }
+
+    set({ loading: true });
+
+    try {
+      const macrosWithSortOrder = macros.map((macro, index) => ({
+        ...macro,
+        sortOrder: macro.sortOrder !== undefined ? macro.sortOrder : index
+      }));
+
+      const response = await new Promise<JsonRpcResponse>((resolve) => {
+        sendFn("setKeyboardMacros", { params: { macros: macrosWithSortOrder } }, (response) => {
+          resolve(response);
+        });
+      });
+
+      if (response.error) {
+        console.error("Error saving macros:", response.error);
+        const errorMessage = typeof response.error.data === 'string'
+          ? response.error.data
+          : response.error.message || "Failed to save macros";
+        throw new Error(errorMessage);
+      }
+
+      // Only update the store if the request was successful
+      set({ macros: macrosWithSortOrder });
+    } catch (error) {
+      console.error("Failed to save macros:", error);
+      throw error;
+    } finally {
+      set({ loading: false });
+    }
+  }
+}));
\ No newline at end of file
diff --git a/ui/src/hooks/useKeyboard.ts b/ui/src/hooks/useKeyboard.ts
index 137fc8bb7..0ce1eefc6 100644
--- a/ui/src/hooks/useKeyboard.ts
+++ b/ui/src/hooks/useKeyboard.ts
@@ -2,6 +2,7 @@ import { useCallback } from "react";
 
 import { useHidStore, useRTCStore } from "@/hooks/stores";
 import { useJsonRpc } from "@/hooks/useJsonRpc";
+import { keys, modifiers } from "@/keyboardMappings";
 
 export default function useKeyboard() {
   const [send] = useJsonRpc();
@@ -28,5 +29,28 @@ export default function useKeyboard() {
     sendKeyboardEvent([], []);
   }, [sendKeyboardEvent]);
 
-  return { sendKeyboardEvent, resetKeyboardState };
+  const executeMacro = async (steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[]) => {
+    for (const [index, step] of steps.entries()) {
+      const keyValues = step.keys?.map(key => keys[key]).filter(Boolean) || [];
+      const modifierValues = step.modifiers?.map(mod => modifiers[mod]).filter(Boolean) || [];
+
+      // If the step has keys and/or modifiers, press them and hold for the delay
+      if (keyValues.length > 0 || modifierValues.length > 0) {
+        sendKeyboardEvent(keyValues, modifierValues);
+        await new Promise(resolve => setTimeout(resolve, step.delay || 50));
+
+        resetKeyboardState();
+      } else {
+        // This is a delay-only step, just wait for the delay amount
+        await new Promise(resolve => setTimeout(resolve, step.delay || 50));
+      }
+
+      // Add a small pause between steps if not the last step
+      if (index < steps.length - 1) {
+        await new Promise(resolve => setTimeout(resolve, 10));
+      }
+    }
+  };
+
+  return { sendKeyboardEvent, resetKeyboardState, executeMacro };
 }
diff --git a/ui/src/keyboardMappings.ts b/ui/src/keyboardMappings.ts
index ffc781c4e..347939aa8 100644
--- a/ui/src/keyboardMappings.ts
+++ b/ui/src/keyboardMappings.ts
@@ -212,3 +212,80 @@ export const modifiers = {
   MetaLeft: 0x08,
   MetaRight: 0x80,
 } as Record<string, number>;
+
+export const modifierDisplayMap: Record<string, string> = {
+  ControlLeft: "Left Ctrl",
+  ControlRight: "Right Ctrl",
+  ShiftLeft: "Left Shift",
+  ShiftRight: "Right Shift",
+  AltLeft: "Left Alt",
+  AltRight: "Right Alt",
+  MetaLeft: "Left Meta",
+  MetaRight: "Right Meta",
+} as Record<string, string>;
+
+export const keyDisplayMap: Record<string, string> = {
+  CtrlAltDelete: "Ctrl + Alt + Delete",
+  AltMetaEscape: "Alt + Meta + Escape",
+  Escape: "esc",
+  Tab: "tab",
+  Backspace: "backspace",
+  Enter: "enter",
+  CapsLock: "caps lock",
+  ShiftLeft: "shift",
+  ShiftRight: "shift",
+  ControlLeft: "ctrl",
+  AltLeft: "alt",
+  AltRight: "alt",
+  MetaLeft: "meta",
+  MetaRight: "meta",
+  Space: " ",
+  Home: "home",
+  PageUp: "pageup",
+  Delete: "delete",
+  End: "end",
+  PageDown: "pagedown",
+  ArrowLeft: "←",
+  ArrowRight: "→",
+  ArrowUp: "↑",
+  ArrowDown: "↓",
+  
+  // Letters
+  KeyA: "a", KeyB: "b", KeyC: "c", KeyD: "d", KeyE: "e",
+  KeyF: "f", KeyG: "g", KeyH: "h", KeyI: "i", KeyJ: "j",
+  KeyK: "k", KeyL: "l", KeyM: "m", KeyN: "n", KeyO: "o",
+  KeyP: "p", KeyQ: "q", KeyR: "r", KeyS: "s", KeyT: "t",
+  KeyU: "u", KeyV: "v", KeyW: "w", KeyX: "x", KeyY: "y",
+  KeyZ: "z",
+
+  // Numbers
+  Digit1: "1", Digit2: "2", Digit3: "3", Digit4: "4", Digit5: "5",
+  Digit6: "6", Digit7: "7", Digit8: "8", Digit9: "9", Digit0: "0",
+
+  // Symbols
+  Minus: "-",
+  Equal: "=",
+  BracketLeft: "[",
+  BracketRight: "]",
+  Backslash: "\\",
+  Semicolon: ";",
+  Quote: "'",
+  Comma: ",",
+  Period: ".",
+  Slash: "/",
+  Backquote: "`",
+  IntlBackslash: "\\",
+
+  // Function keys
+  F1: "F1", F2: "F2", F3: "F3", F4: "F4",
+  F5: "F5", F6: "F6", F7: "F7", F8: "F8",
+  F9: "F9", F10: "F10", F11: "F11", F12: "F12",
+
+  // Numpad
+  Numpad0: "Num 0", Numpad1: "Num 1", Numpad2: "Num 2",
+  Numpad3: "Num 3", Numpad4: "Num 4", Numpad5: "Num 5",
+  Numpad6: "Num 6", Numpad7: "Num 7", Numpad8: "Num 8",
+  Numpad9: "Num 9", NumpadAdd: "Num +", NumpadSubtract: "Num -",
+  NumpadMultiply: "Num *", NumpadDivide: "Num /", NumpadDecimal: "Num .",
+  NumpadEnter: "Num Enter"
+};
diff --git a/ui/src/main.tsx b/ui/src/main.tsx
index 066ee571d..e09a2a92e 100644
--- a/ui/src/main.tsx
+++ b/ui/src/main.tsx
@@ -43,6 +43,9 @@ import SettingsAppearanceRoute from "./routes/devices.$id.settings.appearance";
 import * as SettingsGeneralIndexRoute from "./routes/devices.$id.settings.general._index";
 import SettingsGeneralUpdateRoute from "./routes/devices.$id.settings.general.update";
 import SecurityAccessLocalAuthRoute from "./routes/devices.$id.settings.access.local-auth";
+import SettingsMacrosRoute from "./routes/devices.$id.settings.macros";
+import SettingsMacrosAddRoute from "./routes/devices.$id.settings.macros.add";
+import SettingsMacrosEditRoute from "./routes/devices.$id.settings.macros.edit";
 
 export const isOnDevice = import.meta.env.MODE === "device";
 export const isInCloud = !isOnDevice;
@@ -175,6 +178,23 @@ if (isOnDevice) {
               path: "appearance",
               element: <SettingsAppearanceRoute />,
             },
+            {
+              path: "macros",
+              children: [
+                {
+                  index: true,
+                  element: <SettingsMacrosRoute />,
+                },
+                {
+                  path: "add",
+                  element: <SettingsMacrosAddRoute />,
+                },
+                {
+                  path: ":macroId/edit",
+                  element: <SettingsMacrosEditRoute />,
+                },
+              ],
+            },
           ],
         },
       ],
@@ -283,6 +303,23 @@ if (isOnDevice) {
                       path: "appearance",
                       element: <SettingsAppearanceRoute />,
                     },
+                    {
+                      path: "macros",
+                      children: [
+                        {
+                          index: true,
+                          element: <SettingsMacrosRoute />,
+                        },
+                        {
+                          path: "add",
+                          element: <SettingsMacrosAddRoute />,
+                        },
+                        {
+                          path: ":macroId/edit",
+                          element: <SettingsMacrosEditRoute />,
+                        },
+                      ],
+                    },
                   ],
                 },
               ],
diff --git a/ui/src/routes/devices.$id.settings.macros.add.tsx b/ui/src/routes/devices.$id.settings.macros.add.tsx
new file mode 100644
index 000000000..1b3ce303a
--- /dev/null
+++ b/ui/src/routes/devices.$id.settings.macros.add.tsx
@@ -0,0 +1,63 @@
+import { useNavigate } from "react-router-dom";
+import { useState } from "react";
+
+import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
+import { SettingsPageHeader } from "@/components/SettingsPageheader";
+import { MacroForm } from "@/components/MacroForm";
+import { DEFAULT_DELAY } from "@/constants/macros";
+import notifications from "@/notifications";
+
+export default function SettingsMacrosAddRoute() {
+  const { macros, saveMacros } = useMacrosStore();
+  const [isSaving, setIsSaving] = useState(false);
+  const navigate = useNavigate();
+
+  const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+    return macros.map((macro, index) => ({
+      ...macro,
+      sortOrder: index + 1,
+    }));
+  };
+
+  const handleAddMacro = async (macro: Partial<KeySequence>) => {
+    setIsSaving(true);
+    try {
+      const newMacro: KeySequence = {
+        id: generateMacroId(),
+        name: macro.name!.trim(),
+        steps: macro.steps || [],
+        sortOrder: macros.length + 1,
+      };
+
+      await saveMacros(normalizeSortOrders([...macros, newMacro]));
+      notifications.success(`Macro "${newMacro.name}" created successfully`);
+      navigate("../");
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to create macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to create macro");
+      }
+    } finally {
+      setIsSaving(false);
+    }
+  };
+
+  return (
+    <div className="space-y-4">
+      <SettingsPageHeader
+        title="Add New Macro"
+        description="Create a new keyboard macro"
+      />
+      <MacroForm
+        initialData={{
+          name: "",
+          steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }],
+        }}
+        onSubmit={handleAddMacro}
+        onCancel={() => navigate("../")}
+        isSubmitting={isSaving}
+      />
+    </div>
+  );
+} 
\ No newline at end of file
diff --git a/ui/src/routes/devices.$id.settings.macros.edit.tsx b/ui/src/routes/devices.$id.settings.macros.edit.tsx
new file mode 100644
index 000000000..336fe858b
--- /dev/null
+++ b/ui/src/routes/devices.$id.settings.macros.edit.tsx
@@ -0,0 +1,134 @@
+import { useNavigate, useParams } from "react-router-dom";
+import { useState, useEffect } from "react";
+import { LuTrash2 } from "react-icons/lu";
+
+import { KeySequence, useMacrosStore } from "@/hooks/stores";
+import { SettingsPageHeader } from "@/components/SettingsPageheader";
+import { MacroForm } from "@/components/MacroForm";
+import notifications from "@/notifications";
+import { Button } from "@/components/Button";
+import { ConfirmDialog } from "@/components/ConfirmDialog";
+
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+export default function SettingsMacrosEditRoute() {
+  const { macros, saveMacros } = useMacrosStore();
+  const [isUpdating, setIsUpdating] = useState(false);
+  const [isDeleting, setIsDeleting] = useState(false);
+  const navigate = useNavigate();
+  const { macroId } = useParams<{ macroId: string }>();
+  const [macro, setMacro] = useState<KeySequence | null>(null);
+  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+
+  useEffect(() => {
+    const foundMacro = macros.find(m => m.id === macroId);
+    if (foundMacro) {
+      setMacro({
+        ...foundMacro,
+        steps: foundMacro.steps.map(step => ({
+          ...step,
+          keys: Array.isArray(step.keys) ? step.keys : [],
+          modifiers: Array.isArray(step.modifiers) ? step.modifiers : [],
+          delay: typeof step.delay === 'number' ? step.delay : 0
+        }))
+      });
+    } else {
+      navigate("../");
+    }
+  }, [macroId, macros, navigate]);
+
+  const handleUpdateMacro = async (updatedMacro: Partial<KeySequence>) => {
+    if (!macro) return;
+
+    setIsUpdating(true);
+    try {
+      const newMacros = macros.map(m => 
+        m.id === macro.id ? {
+          ...macro,
+          name: updatedMacro.name!.trim(),
+          steps: updatedMacro.steps || [],
+        } : m
+      );
+
+      await saveMacros(normalizeSortOrders(newMacros));
+      notifications.success(`Macro "${updatedMacro.name}" updated successfully`);
+      navigate("../");
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to update macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to update macro");
+      }
+    } finally {
+      setIsUpdating(false);
+    }
+  };
+
+  const handleDeleteMacro = async () => {
+    if (!macro) return;
+
+    setIsDeleting(true);
+    try {
+      const updatedMacros = normalizeSortOrders(macros.filter(m => m.id !== macro.id));
+      await saveMacros(updatedMacros);
+      notifications.success(`Macro "${macro.name}" deleted successfully`);
+      navigate("../macros");
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to delete macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to delete macro");
+      }
+    } finally {
+      setIsDeleting(false);
+    }
+  };
+
+  if (!macro) return null;
+
+  return (
+    <div className="space-y-4">
+      <div className="flex items-center justify-between">
+        <SettingsPageHeader
+          title="Edit Macro"
+          description="Modify your keyboard macro"
+        />
+        <Button
+          size="SM"
+          theme="light"
+          text="Delete Macro"
+          className="text-red-500 dark:text-red-400"
+          LeadingIcon={LuTrash2}
+          onClick={() => setShowDeleteConfirm(true)}
+          disabled={isDeleting}
+        />
+      </div>
+      <MacroForm
+        initialData={macro}
+        onSubmit={handleUpdateMacro}
+        onCancel={() => navigate("../")}
+        isSubmitting={isUpdating}
+        submitText="Save Changes"
+      />
+
+      <ConfirmDialog
+        open={showDeleteConfirm}
+        onClose={() => setShowDeleteConfirm(false)}
+        title="Delete Macro"
+        description="Are you sure you want to delete this macro? This action cannot be undone."
+        variant="danger"
+        confirmText={isDeleting ? "Deleting" : "Delete"}
+        onConfirm={() => {
+          handleDeleteMacro();
+          setShowDeleteConfirm(false);
+        }}
+        isConfirming={isDeleting}
+      />
+    </div>
+  );
+} 
\ No newline at end of file
diff --git a/ui/src/routes/devices.$id.settings.macros.tsx b/ui/src/routes/devices.$id.settings.macros.tsx
new file mode 100644
index 000000000..f809f57ce
--- /dev/null
+++ b/ui/src/routes/devices.$id.settings.macros.tsx
@@ -0,0 +1,306 @@
+import { useEffect, Fragment, useMemo, useState, useCallback } from "react";
+import { useNavigate } from "react-router-dom";
+import { LuPenLine, LuCopy, LuMoveRight, LuCornerDownRight, LuArrowUp, LuArrowDown, LuTrash2, LuCommand } from "react-icons/lu";
+
+import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
+import { SettingsPageHeader } from "@/components/SettingsPageheader";
+import { Button } from "@/components/Button";
+import EmptyCard from "@/components/EmptyCard";
+import Card from "@/components/Card";
+import { MAX_TOTAL_MACROS, COPY_SUFFIX, DEFAULT_DELAY } from "@/constants/macros";
+import { keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings";
+import notifications from "@/notifications";
+import { ConfirmDialog } from "@/components/ConfirmDialog";
+import LoadingSpinner from "@/components/LoadingSpinner";
+
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+export default function SettingsMacrosRoute() {
+  const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore();
+  const navigate = useNavigate();
+  const [actionLoadingId, setActionLoadingId] = useState<string | null>(null);
+  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+  const [macroToDelete, setMacroToDelete] = useState<KeySequence | null>(null);
+  
+  const isMaxMacrosReached = useMemo(() => 
+    macros.length >= MAX_TOTAL_MACROS, 
+    [macros.length]
+  );
+
+  useEffect(() => {
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros]);
+
+  const handleDuplicateMacro = useCallback(async (macro: KeySequence) => {
+    if (!macro?.id || !macro?.name) {
+      notifications.error("Invalid macro data");
+      return;
+    }
+
+    if (isMaxMacrosReached) {
+      notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    setActionLoadingId(macro.id);
+
+    const newMacroCopy: KeySequence = {
+      ...JSON.parse(JSON.stringify(macro)),
+      id: generateMacroId(),
+      name: `${macro.name} ${COPY_SUFFIX}`,
+      sortOrder: macros.length + 1,
+    };
+
+    try {
+      await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
+      notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to duplicate macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to duplicate macro");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  }, [isMaxMacrosReached, macros, saveMacros, setActionLoadingId]);
+
+  const handleMoveMacro = useCallback(async (index: number, direction: 'up' | 'down', macroId: string) => {
+    if (!Array.isArray(macros) || macros.length === 0) {
+      notifications.error("No macros available");
+      return;
+    }
+
+    const newIndex = direction === 'up' ? index - 1 : index + 1;
+    if (newIndex < 0 || newIndex >= macros.length) return;
+
+    setActionLoadingId(macroId);
+
+    try {
+      const newMacros = [...macros];
+      [newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]];
+      const updatedMacros = normalizeSortOrders(newMacros);
+
+      await saveMacros(updatedMacros);
+      notifications.success("Macro order updated successfully");
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to reorder macros: ${error.message}`);
+      } else {
+        notifications.error("Failed to reorder macros");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  }, [macros, saveMacros, setActionLoadingId]);
+
+  const handleDeleteMacro = useCallback(async () => {
+    if (!macroToDelete?.id) return;
+
+    setActionLoadingId(macroToDelete.id);
+    try {
+      const updatedMacros = normalizeSortOrders(macros.filter(m => m.id !== macroToDelete.id));
+      await saveMacros(updatedMacros);
+      notifications.success(`Macro "${macroToDelete.name}" deleted successfully`);
+      setShowDeleteConfirm(false);
+      setMacroToDelete(null);
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to delete macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to delete macro");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  }, [macroToDelete, macros, saveMacros]);
+
+  const MacroList = useMemo(() => (
+    <div className="space-y-2">
+      {macros.map((macro, index) => (
+        <Card key={macro.id} className="p-2 bg-white dark:bg-slate-800">
+          <div className="flex items-center justify-between">
+            <div className="flex flex-col gap-1 px-2">
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'up', macro.id)}
+                disabled={index === 0 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowUp}
+                aria-label={`Move ${macro.name} up`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'down', macro.id)}
+                disabled={index === macros.length - 1 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowDown}
+                aria-label={`Move ${macro.name} down`}
+              />
+            </div>
+
+            <div className="flex-1 min-w-0 flex flex-col justify-center ml-2">
+              <h3 className="truncate text-sm font-semibold text-black dark:text-white">
+                {macro.name}
+              </h3>
+              <p className="mt-1 ml-4 text-xs text-slate-500 dark:text-slate-400 overflow-hidden">
+                <span className="flex flex-col items-start gap-1">
+                  {macro.steps.map((step, stepIndex) => {
+                    const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight;
+
+                    return (
+                      <span key={stepIndex} className="inline-flex items-center">
+                        <StepIcon className="mr-1 text-slate-400 dark:text-slate-500 h-3 w-3 flex-shrink-0" />
+                        <span className="bg-slate-50 dark:bg-slate-800 px-2 py-0.5 rounded-md border border-slate-200/50 dark:border-slate-700/50">
+                          {(Array.isArray(step.modifiers) && step.modifiers.length > 0) || (Array.isArray(step.keys) && step.keys.length > 0) ? (
+                            <>
+                              {Array.isArray(step.modifiers) && step.modifiers.map((modifier, idx) => (
+                                <Fragment key={`mod-${idx}`}>
+                                  <span className="font-medium text-slate-600 dark:text-slate-200">
+                                    {modifierDisplayMap[modifier] || modifier}
+                                  </span>
+                                  {idx < step.modifiers.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+
+                              {Array.isArray(step.modifiers) && step.modifiers.length > 0 && Array.isArray(step.keys) && step.keys.length > 0 && (
+                                <span className="text-slate-400 dark:text-slate-600"> + </span>
+                              )}
+
+                              {Array.isArray(step.keys) && step.keys.map((key, idx) => (
+                                <Fragment key={`key-${idx}`}>
+                                  <span className="font-medium text-blue-600 dark:text-blue-400">
+                                    {keyDisplayMap[key] || key}
+                                  </span>
+                                  {idx < step.keys.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+                            </>
+                          ) : (
+                            <span className="font-medium text-slate-500 dark:text-slate-400">Delay only</span>
+                          )}
+                          {step.delay !== DEFAULT_DELAY && (
+                             <span className="ml-1 text-slate-400 dark:text-slate-500">({step.delay}ms)</span>
+                          )}
+                        </span>
+                      </span>
+                    );
+                  })}
+                </span>
+              </p>
+            </div>
+
+            <div className="flex items-center gap-1 ml-4">
+              <Button
+                size="XS"
+                className="text-red-500 dark:text-red-400"
+                theme="light"
+                LeadingIcon={LuTrash2}
+                onClick={() => {
+                  setMacroToDelete(macro);
+                  setShowDeleteConfirm(true);
+                }}
+                disabled={actionLoadingId === macro.id}
+                aria-label={`Delete macro ${macro.name}`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                LeadingIcon={LuCopy}
+                onClick={() => handleDuplicateMacro(macro)}
+                disabled={actionLoadingId === macro.id}
+                aria-label={`Duplicate macro ${macro.name}`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                LeadingIcon={LuPenLine}
+                text="Edit"
+                onClick={() => navigate(`${macro.id}/edit`)}
+                disabled={actionLoadingId === macro.id}
+                aria-label={`Edit macro ${macro.name}`}
+              />
+            </div>
+          </div>
+        </Card>
+      ))}
+
+      <ConfirmDialog
+        open={showDeleteConfirm}
+        onClose={() => {
+          setShowDeleteConfirm(false);
+          setMacroToDelete(null);
+        }}
+        title="Delete Macro"
+        description={`Are you sure you want to delete "${macroToDelete?.name}"? This action cannot be undone.`}
+        variant="danger"
+        confirmText={actionLoadingId === macroToDelete?.id ? "Deleting..." : "Delete"}
+        onConfirm={handleDeleteMacro}
+        isConfirming={actionLoadingId === macroToDelete?.id}
+      />
+    </div>
+  ), [macros, actionLoadingId, showDeleteConfirm, macroToDelete, handleDeleteMacro]);
+
+  return (
+    <div className="space-y-4">
+      <div className="flex items-center justify-between">
+        <SettingsPageHeader
+          title="Keyboard Macros"
+          description={`Combine keystrokes into a single action for faster workflows.`}
+        />
+        { macros.length > 0 && (
+          <div className="flex items-center pl-2">
+            <Button
+              size="SM"
+              theme="primary"
+              text={isMaxMacrosReached ? `Max Reached` : "Add New Macro"}
+              onClick={() => navigate("add")}
+              disabled={isMaxMacrosReached}
+              aria-label="Add new macro"
+            />
+          </div>
+        )}
+      </div>
+
+      <div className="space-y-4">
+        {loading && macros.length === 0 ? (
+          <EmptyCard
+            IconElm={LuCommand}
+            headline="Loading macros..."
+            BtnElm={
+              <div className="my-2 flex flex-col items-center space-y-2 text-center">
+                <LoadingSpinner className="h-6 w-6 text-blue-700 dark:text-blue-500" />
+              </div>
+            }
+          />
+        ) : macros.length === 0 ? (
+          <EmptyCard
+            IconElm={LuCommand}
+            headline="Create Your First Macro"
+            BtnElm={
+              <Button
+                size="SM"
+                theme="primary"
+                text="Add New Macro"
+                onClick={() => navigate("add")}
+                disabled={isMaxMacrosReached}
+                aria-label="Add new macro"
+              />
+            }
+          />
+        ) : MacroList}
+      </div>
+    </div>
+  );
+}
diff --git a/ui/src/routes/devices.$id.settings.tsx b/ui/src/routes/devices.$id.settings.tsx
index 4742445ef..db7d6b02b 100644
--- a/ui/src/routes/devices.$id.settings.tsx
+++ b/ui/src/routes/devices.$id.settings.tsx
@@ -8,6 +8,7 @@ import {
   LuWrench,
   LuArrowLeft,
   LuPalette,
+  LuCommand,
 } from "react-icons/lu";
 import React, { useEffect, useRef, useState } from "react";
 
@@ -195,6 +196,17 @@ export default function SettingsRoute() {
                     </div>
                   </NavLink>
                 </div>
+                <div className="shrink-0">
+                  <NavLink
+                    to="macros"
+                    className={({ isActive }) => (isActive ? "active" : "")}
+                  >
+                    <div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
+                      <LuCommand className="h-4 w-4 shrink-0" />
+                      <h1>Keyboard Macros</h1>
+                    </div>
+                  </NavLink>
+                </div>
                 <div className="shrink-0">
                   <NavLink
                     to="advanced"


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This pull request implements support for keyboard macros by adding a macros section in both the settings UI and action bar, as well as introducing JSON RPC endpoints on the server to get and set macros.  
- Adds new UI components and routing for managing keyboard macros  
- Enhances client-side hooks and state management to support macro execution  
- Implements server-side API endpoints and configuration updates to persist macros

### Reviewed Changes

Copilot reviewed 9 out of 10 changed files in this pull request and generated 1 comment.

<details>
<summary>Show a summary per file</summary>

| File | Description |
| ---- | ----------- |
| ui/src/routes/devices.$id.settings.tsx | Added a new navigation link for keyboard macros in settings |
| ui/src/main.tsx | Registered new routes for macros in the app routing configuration |
| ui/src/hooks/useKeyboard.ts | Added an executeMacro function to process macro steps |
| ui/src/hooks/stores.ts | Introduced macros state and functions to load and save keyboard macros |
| ui/src/components/WebRTCVideo.tsx | Integrated MacroBar component into the video layout |
| ui/src/components/MacroBar.tsx | New component rendering a macro bar with buttons for each macro |
| jsonrpc.go | Added JSON RPC handlers for getting and setting keyboard macros |
| config.go | Added configuration types and validation for keyboard macros |
</details>


<details>
<summary>Files not reviewed (1)</summary>

* **ui/src/index.css**: Language not supported
</details>



state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T22:40:47Z
user: login: adamshiervani
body: Amazing work — thanks for taking the time to put this together. It’s a superb implementation, and the attention to detail really shows. Much appreciated.

Most of the comments are around UI consistency and readability: use existing components where possible, and split the macro settings file into multiple routes to keep things clear.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T23:24:58Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T08:23:51Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T08:24:13Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T08:24:23Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T08:24:28Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T08:24:57Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T08:25:11Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T08:25:53Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T08:26:23Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T08:26:32Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T08:26:47Z
user: login: adamshiervani
body: - Even better second iteration
- Arrows feel much better than DnD
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T10:15:36Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T14:42:22Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T14:42:33Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T14:42:49Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T14:43:09Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T14:43:21Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T14:44:57Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T14:45:00Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T14:45:06Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T14:45:12Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-05T13:58:53Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-05T13:59:13Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-05T14:00:47Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-05T14:00:51Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-05T14:00:58Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-05T14:01:04Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-05T14:01:38Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-05T14:01:41Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-05T14:01:47Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-05T14:02:21Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-05T14:05:29Z
user: login: adamshiervani
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T22:21:49Z
user: login: adamshiervani
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T22:25:29Z
user: login: adamshiervani
body: I don't have too much left to say. You've done a wonderful job. Fix the remaining things from the last review, and these tiny change requests and then we should be able to merge
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T22:29:04Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T13:44:08Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T13:44:18Z
user: login: Savid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T13:44:30Z
user: login: adamshiervani
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T19:58:34Z
user: login: ym
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T20:46:04Z

Review comments:
diff_hunk: @@ -14,6 +14,65 @@ type WakeOnLanDevice struct {
 	MacAddress string `json:"macAddress"`
 }
 
+// Constants for keyboard macro limits
+const (
+	MaxMacrosPerDevice = 25
+	MaxStepsPerMacro   = 10
+	MaxKeysPerStep     = 10
+	MinStepDelay       = 50
+	MaxStepDelay       = 2000
+)
+
+type KeyboardMacroStep struct {
+	Keys      []string `json:"keys"`
+	Modifiers []string `json:"modifiers"`
+	Delay     int      `json:"delay"`
+}
+
+func (s *KeyboardMacroStep) Validate() error {
+	if len(s.Keys) > MaxKeysPerStep {
+		return fmt.Errorf("too many keys in step (max %d)", MaxKeysPerStep)
+	}
+
+	if s.Delay < MinStepDelay {
+		s.Delay = MinStepDelay
+	} else if s.Delay > MaxStepDelay {
+		s.Delay = MaxStepDelay
+	}
+
+	return nil
+}
+
+type KeyboardMacro struct {
+	ID          string              `json:"id"`
+	Name        string              `json:"name"`
+	Description string              `json:"description,omitempty"`
+	Steps       []KeyboardMacroStep `json:"steps"`
+	SortOrder   int                 `json:"sortOrder,omitempty"`
+}
+
+func (m *KeyboardMacro) Validate() error {
+	if m.Name == "" {
+		return fmt.Errorf("macro name cannot be empty")
+	}
+
+	if len(m.Steps) == 0 {
+		return fmt.Errorf("macro must have at least one step")
+	}
+
+	if len(m.Steps) > MaxStepsPerMacro {
+		m.Steps = m.Steps[:MaxStepsPerMacro]
path: config.go
user: login: Copilot
body: The validation function silently truncates the macro steps when their count exceeds MaxStepsPerMacro, which could lead to unexpected loss of data; consider returning an error instead to alert the user rather than altering the input.
```suggestion
		return fmt.Errorf("too many steps in macro (max %d)", MaxStepsPerMacro)
```
created_at: 2025-04-01T22:40:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,1439 @@
+import { useState, useEffect, useRef, useCallback } from "react";
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: adamshiervani
body: Can we split this into multiple routes instead of managing all the state in a single component? It feels like too much state to handle here.

created_at: 2025-04-01T23:02:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,1439 @@
+import { useState, useEffect, useRef, useCallback } from "react";
+import { LuPlus, LuTrash, LuX, LuPenLine, LuLoader, LuGripVertical, LuInfo } from "react-icons/lu";
+import { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
+
+import { KeySequence, useMacrosStore } from "../hooks/stores";
+import { SettingsPageHeader } from "../components/SettingsPageheader";
+import { Button } from "../components/Button";
+import { keys, modifiers } from "../keyboardMappings";
+import { useJsonRpc } from "../hooks/useJsonRpc";
+import notifications from "../notifications";
+import { SettingsItem } from "../routes/devices.$id.settings";
+
+const DEFAULT_DELAY = 50;
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface KeyOption {
+  value: string;
+  label: string;
+}
+
+interface KeyOptionData {
+  value: string | null;
+  keys?: string[];
+  label?: string;
+}
+
+const generateId = () => {
+  return `macro-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
+};
+
+const keyOptions = Object.keys(keys).map(key => ({
+  value: key,
+  label: key,
+}));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+interface KeyComboboxProps {
+  stepIndex: number;
+  step: MacroStep;
+  onSelect: (option: KeyOptionData) => void;
+  query: string;
+  onQueryChange: (query: string) => void;
+  getFilteredOptions: () => KeyOption[];
+  disabled?: boolean;
+}
+
+function KeyCombobox({
+  onSelect,
+  query,
+  onQueryChange,
+  getFilteredOptions,
+  disabled = false,
+}: KeyComboboxProps) {
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  return (
+    <div className="relative w-full">
+      <Combobox immediate onChange={onSelect} disabled={disabled}>
+        {() => (
+          <>
+            <div className="relative">
+              <ComboboxInput
+                ref={inputRef}
+                className={`macro-input ${disabled ? 'opacity-60 cursor-not-allowed' : ''}`}
+                placeholder={disabled ? "Max keys reached" : "Search for key..."}
+                displayValue={() => query}
+                onChange={(event) => onQueryChange(event.target.value)}
+                disabled={disabled}
+              />
+            </div>
+            
+            <ComboboxOptions className="absolute left-0 z-50 mt-1 w-full max-h-60 overflow-auto rounded-md bg-white dark:bg-slate-800 py-1 text-sm shadow-lg">
+              {getFilteredOptions().map((option) => (
+                <ComboboxOption 
+                  key={option.value} 
+                  value={option}
+                  className="cursor-default select-none py-1.5 px-3 ui-active:bg-blue-100 ui-active:text-blue-900 dark:text-slate-300 dark:ui-active:bg-blue-900/40 dark:ui-active:text-blue-200"
+                >
+                  {option.label}
+                </ComboboxOption>
+              ))}
+              {getFilteredOptions().length === 0 && (
+                <div className="py-2 px-3 text-sm text-slate-500 dark:text-slate-400">
+                  No matching keys found
+                </div>
+              )}
+            </ComboboxOptions>
+          </>
+        )}
+      </Combobox>
+    </div>
+  );
+}
+
+const PRESET_DELAYS = [
+  { value: 50, label: "50ms" },
+  { value: 100, label: "100ms" },
+  { value: 200, label: "200ms" },
+  { value: 300, label: "300ms" },
+  { value: 500, label: "500ms" },
+  { value: 750, label: "750ms" },
+  { value: 1000, label: "1000ms" },
+  { value: 1500, label: "1500ms" },
+  { value: 2000, label: "2000ms" },
+];
+
+const MAX_STEPS_PER_MACRO = 10;
+const MAX_TOTAL_MACROS = 25;
+const MAX_KEYS_PER_STEP = 10;
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+// Helper function to normalize sort orders, ensuring they start at 1 and have no gaps
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  isDesktop: boolean;
+  onKeySelect: (option: KeyOptionData) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  getFilteredKeys: () => KeyOption[];
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  getFilteredKeys,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  return (
+    <div className="macro-step-card rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 p-4 shadow-sm">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <button
+              type="button"
+              className="p-1 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400 disabled:opacity-50"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="m18 15-6-6-6 6"/>
+              </svg>
+            </button>
+            <button
+              type="button"
+              className="p-1 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400 disabled:opacity-50"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="m6 9 6 6 6-6"/>
+              </svg>
+            </button>
+          </div>
+          <span className="macro-step-number flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+        
+        <div className="flex items-center space-x-2">
+          {onDelete && (
+            <button
+              type="button"
+              className="flex items-center text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
+              onClick={onDelete}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="M3 6h18"></path>
+                <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
+                <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
+              </svg>
+              <span className="ml-1">Delete</span>
+            </button>
+          )}
+        </div>
+      </div>
+      
+      <div className="space-y-4 mt-2">
+        <div className="w-full flex flex-col gap-2">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Modifiers:
+          </label>
+          <div className="macro-modifiers-container inline-flex flex-wrap gap-3">
+            {Object.entries(groupedModifiers).map(([group, mods]) => (
+              <div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
+                <span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
+                  {group}
+                </span>
+                <div className="flex flex-wrap gap-1">
+                  {mods.map(option => (
+                    <label 
+                      key={option.value} 
+                      className={`flex items-center px-2 py-1 rounded border cursor-pointer text-xs font-medium transition-colors ${
+                        ensureArray(step.modifiers).includes(option.value) 
+                          ? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-200' 
+                          : 'bg-slate-100 border-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700'
+                      }`}
+                    >
+                      <input
+                        type="checkbox"
+                        className="sr-only"
+                        checked={ensureArray(step.modifiers).includes(option.value)}
+                        onChange={e => {
+                          const modifiersArray = ensureArray(step.modifiers);
+                          const newModifiers = e.target.checked
+                            ? [...modifiersArray, option.value]
+                            : modifiersArray.filter(m => m !== option.value);
+                          onModifierChange(newModifiers);
+                        }}
+                      />
+                      {option.label.split(' ')[1] || option.label}
+                    </label>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Keys:
+          </label>
+          
+          <div className="macro-key-group flex flex-wrap gap-1 mb-2">
+            {ensureArray(step.keys).map((key, keyIndex) => (
+              <span
+                key={keyIndex}
+                className="macro-key-badge inline-flex items-center rounded-md bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
+              >
+                {key}
+                <button
+                  type="button"
+                  className="ml-1 text-xs text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
+                  onClick={() => {
+                    const newKeys = ensureArray(step.keys).filter((_, i) => i !== keyIndex);
+                    onKeySelect({ value: null, keys: newKeys });
+                  }}
+                >
+                  ×
+                </button>
+              </span>
+            ))}
+          </div>
+
+          <KeyCombobox
+            stepIndex={stepIndex}
+            step={step}
+            onSelect={onKeySelect}
+            query={keyQuery}
+            onQueryChange={onKeyQueryChange}
+            getFilteredOptions={getFilteredKeys}
+            disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP}
+          />
+          
+          {ensureArray(step.keys).length >= MAX_KEYS_PER_STEP && (
+            <span className="text-xs text-amber-600 dark:text-amber-400 mt-1">
+              (max keys reached)
+            </span>
+          )}
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+              Step Duration:
+            </label>
+            <div className="group relative">
+              <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+              <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                <p>The time to wait after pressing the keys in this step before moving to the next step. This helps ensure reliable key presses when automating keyboard input.</p>
+              </div>
+            </div>
+          </div>
+          <div className="flex items-center gap-3">
+            <select
+              className="w-full rounded-md border border-slate-300 bg-slate-50 p-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
+              value={step.delay}
+              onChange={(e) => onDelayChange(parseInt(e.target.value, 10))}
+            >
+              {PRESET_DELAYS.map((option) => (
+                <option key={option.value} value={option.value}>
+                  {option.label}
+                </option>
+              ))}
+            </select>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// Helper to update step keys used by both new and edit flows
+const updateStepKeys = (
+  steps: MacroStep[],
+  stepIndex: number,
+  keyOption: { value: string | null; keys?: string[] },
+  showTemporaryError: (msg: string) => void
+) => {
+  const newSteps = [...steps];
+  if (keyOption.keys) {
+    newSteps[stepIndex].keys = keyOption.keys;
+  } else if (keyOption.value) {
+    if (!newSteps[stepIndex].keys) {
+      newSteps[stepIndex].keys = [];
+    }
+    const keysArray = ensureArray(newSteps[stepIndex].keys);
+    if (keysArray.length >= MAX_KEYS_PER_STEP) {
+      showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`);
+      return newSteps;
+    }
+    newSteps[stepIndex].keys = [...keysArray, keyOption.value];
+  }
+  return newSteps;
+};
+
+const useTouchSort = (items: KeySequence[], onSort: (newItems: KeySequence[]) => void) => {
+  const [touchStartY, setTouchStartY] = useState<number | null>(null);
+  const [touchedIndex, setTouchedIndex] = useState<number | null>(null);
+
+  const handleTouchStart = useCallback((e: React.TouchEvent, index: number) => {
+    const touch = e.touches[0];
+    setTouchStartY(touch.clientY);
+    setTouchedIndex(index);
+    
+    const element = e.currentTarget as HTMLElement;
+    const rect = element.getBoundingClientRect();
+    
+    // Create ghost element
+    const ghost = element.cloneNode(true) as HTMLElement;
+    ghost.id = 'ghost-macro';
+    ghost.className = 'macro-sortable ghost';
+    ghost.style.height = `${rect.height}px`;
+    element.parentNode?.insertBefore(ghost, element);
+    
+    // Set up dragged element
+    element.style.position = 'fixed';
+    element.style.left = `${rect.left}px`;
+    element.style.top = `${rect.top}px`;
+    element.style.width = `${rect.width}px`;
+    element.style.zIndex = '50';
+  }, []);
+
+  const handleTouchMove = useCallback((e: React.TouchEvent) => {
+    if (touchStartY === null || touchedIndex === null) return;
+    
+    const touch = e.touches[0];
+    const deltaY = touch.clientY - touchStartY;
+    const element = e.currentTarget as HTMLElement;
+    
+    // Smooth movement of dragged element
+    element.style.transform = `translateY(${deltaY}px)`;
+    
+    const macroElements = document.querySelectorAll('[data-macro-item]');
+    const draggedRect = element.getBoundingClientRect();
+    const draggedMiddle = draggedRect.top + draggedRect.height / 2;
+    
+    macroElements.forEach((el, i) => {
+      if (i === touchedIndex) return;
+      
+      const rect = el.getBoundingClientRect();
+      const elementMiddle = rect.top + rect.height / 2;
+      const distance = Math.abs(draggedMiddle - elementMiddle);
+      
+      if (distance < rect.height) {
+        const direction = draggedMiddle > elementMiddle ? -1 : 1;
+        (el as HTMLElement).style.transform = `translateY(${direction * rect.height}px)`;
+        (el as HTMLElement).style.transition = 'transform 0.15s ease-out';
+      } else {
+        (el as HTMLElement).style.transform = '';
+        (el as HTMLElement).style.transition = 'transform 0.15s ease-out';
+      }
+    });
+  }, [touchStartY, touchedIndex]);
+
+  const handleTouchEnd = useCallback(async (e: React.TouchEvent) => {
+    if (touchedIndex === null) return;
+    
+    const element = e.currentTarget as HTMLElement;
+    const touch = e.changedTouches[0];
+    
+    // Remove ghost element
+    const ghost = document.getElementById('ghost-macro');
+    ghost?.parentNode?.removeChild(ghost);
+    
+    // Reset dragged element styles
+    element.style.position = '';
+    element.style.left = '';
+    element.style.top = '';
+    element.style.width = '';
+    element.style.zIndex = '';
+    element.style.transform = '';
+    element.style.boxShadow = '';
+    element.style.transition = '';
+    
+    const macroElements = document.querySelectorAll('[data-macro-item]');
+    let targetIndex = touchedIndex;
+    
+    // Find the closest element to the final touch position
+    const finalY = touch.clientY;
+    let closestDistance = Infinity;
+    
+    macroElements.forEach((el, i) => {
+      if (i === touchedIndex) return;
+      
+      const rect = el.getBoundingClientRect();
+      const distance = Math.abs(finalY - (rect.top + rect.height / 2));
+      
+      if (distance < closestDistance) {
+        closestDistance = distance;
+        targetIndex = i;
+      }
+      
+      // Reset other elements
+      (el as HTMLElement).style.transform = '';
+      (el as HTMLElement).style.transition = '';
+    });
+    
+    if (targetIndex !== touchedIndex && closestDistance < 50) {
+      const itemsCopy = [...items];
+      const [draggedItem] = itemsCopy.splice(touchedIndex, 1);
+      itemsCopy.splice(targetIndex, 0, draggedItem);
+      onSort(itemsCopy);
+    }
+    
+    setTouchStartY(null);
+    setTouchedIndex(null);
+  }, [touchedIndex, items, onSort]);
+
+  return { handleTouchStart, handleTouchMove, handleTouchEnd };
+};
+
+interface StepError {
+  keys?: string;
+  modifiers?: string;
+  delay?: string;
+}
+
+interface ValidationErrors {
+  name?: string;
+  description?: string;
+  steps?: Record<number, StepError>;
+}
+
+export default function SettingsMacrosRoute() {
+  const { macros, loading, initialized, loadMacros, saveMacros, setSendFn } = useMacrosStore();
+  const [editingMacro, setEditingMacro] = useState<KeySequence | null>(null);
+  const [newMacro, setNewMacro] = useState<Partial<KeySequence>>({
+    name: "",
+    description: "",
+    steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }],
+  });
+  const [isDragging, setIsDragging] = useState(false);
+  const dragItem = useRef<number | null>(null);
+  const dragOverItem = useRef<number | null>(null);
+  
+  const [macroToDelete, setMacroToDelete] = useState<string | null>(null);
+  
+  const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
+  const [editKeyQueries, setEditKeyQueries] = useState<Record<number, string>>({});
+  
+  const [errorMessage, setErrorMessage] = useState<string | null>(null);
+  const [isDesktop, setIsDesktop] = useState(window.innerWidth >= 768);
+  
+  const [send] = useJsonRpc();
+
+  const isMaxMacrosReached = macros.length >= MAX_TOTAL_MACROS;
+  const isMaxStepsReachedForNewMacro = (newMacro.steps?.length || 0) >= MAX_STEPS_PER_MACRO;
+  
+  const showTemporaryError = useCallback((message: string) => {
+    setErrorMessage(message);
+    setTimeout(() => setErrorMessage(null), 3000);
+  }, []);
+  
+  // Helper for both new and edit key select
+  const handleKeySelectUpdate = (stepIndex: number, option: KeyOptionData, isEditing = false) => {
+    if (isEditing && editingMacro) {
+      const updatedSteps = updateStepKeys(editingMacro.steps, stepIndex, option, showTemporaryError);
+      setEditingMacro({ ...editingMacro, steps: updatedSteps });
+    } else {
+      const updatedSteps = updateStepKeys(newMacro.steps || [], stepIndex, option, showTemporaryError);
+      setNewMacro({ ...newMacro, steps: updatedSteps });
+    }
+  };
+  
+  const handleKeySelect = (stepIndex: number, option: KeyOptionData) => {
+    handleKeySelectUpdate(stepIndex, option, false);
+  };
+  
+  const handleEditKeySelect = (stepIndex: number, option: KeyOptionData) => {
+    handleKeySelectUpdate(stepIndex, option, true);
+  };
+  
+  const handleKeyQueryChange = (stepIndex: number, query: string) => {
+    setKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
+  };
+  
+  const handleEditKeyQueryChange = (stepIndex: number, query: string) => {
+    setEditKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
+  };
+  
+  const getFilteredKeys = (stepIndex: number, isEditing = false) => {
+    const query = isEditing 
+      ? (editKeyQueries[stepIndex] || '')
+      : (keyQueries[stepIndex] || '');
+    
+    if (query === '') {
+      return keyOptions;
+    } else {
+      return keyOptions.filter(option => option.label.toLowerCase().includes(query.toLowerCase()));
+    }
+  };
+  
+  useEffect(() => {
+    setSendFn(send);
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros, setSendFn, send]);
+  
+  const [errors, setErrors] = useState<ValidationErrors>({});
+  
+  const clearErrors = useCallback(() => {
+    setErrors({});
+  }, []);
+  
+  const validateMacro = (macro: Partial<KeySequence>): ValidationErrors => {
+    const errors: ValidationErrors = {};
+
+    // Name validation
+    if (!macro.name?.trim()) {
+      errors.name = "Name is required";
+    } else if (macro.name.trim().length > 50) {
+      errors.name = "Name must be less than 50 characters";
+    }
+
+    // Description validation (optional)
+    if (macro.description && macro.description.trim().length > 200) {
+      errors.description = "Description must be less than 200 characters";
+    }
+
+    // Steps validation
+    if (!macro.steps?.length) {
+      errors.steps = { 0: { keys: "At least one step is required" } };
+      return errors;
+    }
+
+    // Check if at least one step has keys or modifiers
+    const hasKeyOrModifier = macro.steps.some(step => 
+      (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0
+    );
+
+    if (!hasKeyOrModifier) {
+      errors.steps = { 0: { keys: "At least one step must have keys or modifiers" } };
+      return errors;
+    }
+
+    const stepErrors: Record<number, StepError> = {};
+    
+    macro.steps.forEach((step, index) => {
+      const stepError: StepError = {};
+
+      // Keys validation (only if keys are present)
+      if (step.keys?.length && step.keys.length > MAX_KEYS_PER_STEP) {
+        stepError.keys = `Maximum ${MAX_KEYS_PER_STEP} keys allowed`;
+      }
+
+      // Delay validation
+      if (typeof step.delay !== 'number' || step.delay < 0) {
+        stepError.delay = "Invalid delay value";
+      }
+
+      if (Object.keys(stepError).length > 0) {
+        stepErrors[index] = stepError;
+      }
+    });
+
+    if (Object.keys(stepErrors).length > 0) {
+      errors.steps = stepErrors;
+    }
+
+    return errors;
+  };
+
+  const resetNewMacro = () => {
+    setNewMacro({
+      name: "",
+      description: "",
+      steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }],
+    });
+    setKeyQueries({});
+    setErrors({});
+  };
+
+  const [isSaving, setIsSaving] = useState(false);
+  const [isUpdating, setIsUpdating] = useState(false);
+  const [isDeleting, setIsDeleting] = useState(false);
+
+  const handleAddMacro = useCallback(async () => {
+    if (isMaxMacrosReached) {
+      showTemporaryError(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    const validationErrors = validateMacro(newMacro);
+    if (Object.keys(validationErrors).length > 0) {
+      setErrors(validationErrors);
+      return;
+    }
+
+    setIsSaving(true);
+    try {
+      const macro: KeySequence = {
+        id: generateId(),
+        name: newMacro.name!.trim(),
+        description: newMacro.description?.trim() || "",
+        steps: newMacro.steps || [],
+        sortOrder: macros.length + 1,
+      };
+
+      await saveMacros(normalizeSortOrders([...macros, macro]));
+      resetNewMacro();
+      setShowAddMacro(false);
+      notifications.success(`Macro "${macro.name}" created successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to create macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to create macro");
+        showTemporaryError("Failed to save macro");
+      }
+    } finally {
+      setIsSaving(false);
+    }
+  }, [isMaxMacrosReached, newMacro, macros, saveMacros, showTemporaryError]);
+
+  const handleDragStart = (index: number) => {
+    dragItem.current = index;
+    setIsDragging(true);
+    
+    const allItems = document.querySelectorAll('[data-macro-item]');
+    const draggedElement = allItems[index];
+    if (draggedElement) {
+      draggedElement.classList.add('dragging');
+    }
+  };
+  
+  const handleDragOver = (e: React.DragEvent, index: number) => {
+    e.preventDefault();
+    dragOverItem.current = index;
+    
+    const allItems = document.querySelectorAll('[data-macro-item]');
+    allItems.forEach(el => el.classList.remove('drop-target'));
+    
+    const targetElement = allItems[index];
+    if (targetElement) {
+      targetElement.classList.add('drop-target');
+    }
+  };
+  
+  const handleDrop = async (e: React.DragEvent) => {
+    e.preventDefault();
+    if (dragItem.current === null || dragOverItem.current === null) return;
+    
+    const macroCopy = [...macros];
+    const draggedItem = macroCopy.splice(dragItem.current, 1)[0];
+    macroCopy.splice(dragOverItem.current, 0, draggedItem);
+    const updatedMacros = normalizeSortOrders(macroCopy);
+
+    try {
+      await saveMacros(updatedMacros);
+      notifications.success("Macro order updated successfully");
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to reorder macros: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to reorder macros");
+        showTemporaryError("Failed to save reordered macros");
+      }
+    }
+    
+    const allItems = document.querySelectorAll('[data-macro-item]');
+    allItems.forEach(el => {
+      el.classList.remove('drop-target');
+      el.classList.remove('dragging');
+    });
+    
+    dragItem.current = null;
+    dragOverItem.current = null;
+    setIsDragging(false);
+  };
+
+  const handleUpdateMacro = useCallback(async () => {
+    if (!editingMacro) return;
+
+    const validationErrors = validateMacro(editingMacro);
+    if (Object.keys(validationErrors).length > 0) {
+      setErrors(validationErrors);
+      return;
+    }
+
+    setIsUpdating(true);
+    try {
+      const newMacros = macros.map(m => 
+        m.id === editingMacro.id ? {
+          ...editingMacro,
+          name: editingMacro.name.trim(),
+          description: editingMacro.description?.trim() || "",
+        } : m
+      );
+
+      await saveMacros(normalizeSortOrders(newMacros));
+      setEditingMacro(null);
+      clearErrors();
+      notifications.success(`Macro "${editingMacro.name}" updated successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to update macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to update macro");
+        showTemporaryError("Failed to update macro");
+      }
+    } finally {
+      setIsUpdating(false);
+    }
+  }, [editingMacro, macros, saveMacros, showTemporaryError, clearErrors]);
+
+  const handleEditMacro = (macro: KeySequence) => {
+    setEditingMacro({
+      ...macro,
+      description: macro.description || "",
+      steps: macro.steps.map(step => ({
+        ...step,
+        keys: ensureArray(step.keys),
+        modifiers: ensureArray(step.modifiers),
+        delay: typeof step.delay === 'number' ? step.delay : DEFAULT_DELAY
+      }))
+    });
+    clearErrors();
+    setEditKeyQueries({});
+  };
+
+  const handleDeleteMacro = async (id: string) => {
+    const macroToBeDeleted = macros.find(m => m.id === id);
+    if (!macroToBeDeleted) return;
+
+    setIsDeleting(true);
+    try {
+      const updatedMacros = normalizeSortOrders(macros.filter(macro => macro.id !== id));
+      await saveMacros(updatedMacros);
+      if (editingMacro?.id === id) {
+        setEditingMacro(null);
+      }
+      setMacroToDelete(null);
+      notifications.success(`Macro "${macroToBeDeleted.name}" deleted successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to delete macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to delete macro");
+        showTemporaryError("Failed to delete macro");
+      }
+    } finally {
+      setIsDeleting(false);
+    }
+  };
+
+  const handleDuplicateMacro = async (macro: KeySequence) => {
+    if (isMaxMacrosReached) {
+      showTemporaryError(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    const newMacroCopy: KeySequence = {
+      ...JSON.parse(JSON.stringify(macro)),
+      id: generateId(),
+      name: `${macro.name} (copy)`,
+      sortOrder: macros.length + 1,
+    };
+
+    newMacroCopy.steps = newMacroCopy.steps.map(step => ({
+      ...step,
+      keys: ensureArray(step.keys),
+      modifiers: ensureArray(step.modifiers),
+      delay: step.delay || DEFAULT_DELAY
+    }));
+
+    try {
+      await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
+      notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to duplicate macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to duplicate macro");
+        showTemporaryError("Failed to duplicate macro");
+      }
+    }
+  };
+
+  const handleStepMove = (stepIndex: number, direction: 'up' | 'down', steps: MacroStep[]) => {
+    const newSteps = [...steps];
+    const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
+    [newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]];
+    return newSteps;
+  };
+
+  useEffect(() => {
+    const handleResize = () => {
+      setIsDesktop(window.innerWidth >= 768);
+    };
+    
+    window.addEventListener('resize', handleResize);
+    return () => window.removeEventListener('resize', handleResize);
+  }, []);
+
+  const { handleTouchStart, handleTouchMove, handleTouchEnd } = useTouchSort(
+    macros,
+    async (newMacros) => {
+      const updatedMacros = normalizeSortOrders(newMacros);
+      try {
+        await saveMacros(updatedMacros);
+        notifications.success("Macro order updated successfully");
+      } catch (error) {
+        if (error instanceof Error) {
+          notifications.error(`Failed to reorder macros: ${error.message}`);
+          showTemporaryError(error.message);
+        } else {
+          notifications.error("Failed to reorder macros");
+          showTemporaryError("Failed to save reordered macros");
+        }
+      }
+    }
+  );
+
+  const [showClearConfirm, setShowClearConfirm] = useState(false);
+  const [showAddMacro, setShowAddMacro] = useState(false);
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && editingMacro) {
+        setEditingMacro(null);
+        setErrors({});
+      }
+      if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
+        if (editingMacro) {
+          handleUpdateMacro();
+        } else if (!isMaxMacrosReached) {
+          handleAddMacro();
+        }
+      }
+    };
+
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [editingMacro, isMaxMacrosReached, handleAddMacro, handleUpdateMacro]);
+
+  const handleModifierChange = (stepIndex: number, modifiers: string[]) => {
+    if (editingMacro) {
+      const newSteps = [...editingMacro.steps];
+      newSteps[stepIndex].modifiers = modifiers;
+      setEditingMacro({ ...editingMacro, steps: newSteps });
+    } else {
+      const newSteps = [...(newMacro.steps || [])];
+      newSteps[stepIndex].modifiers = modifiers;
+      setNewMacro({ ...newMacro, steps: newSteps });
+    }
+  };
+
+  const handleDelayChange = (stepIndex: number, delay: number) => {
+    if (editingMacro) {
+      const newSteps = [...editingMacro.steps];
+      newSteps[stepIndex].delay = delay;
+      setEditingMacro({ ...editingMacro, steps: newSteps });
+    } else {
+      const newSteps = [...(newMacro.steps || [])];
+      newSteps[stepIndex].delay = delay;
+      setNewMacro({ ...newMacro, steps: newSteps });
+    }
+  };
+
+  const ErrorMessage = ({ error }: { error?: string }) => {
+    if (!error) return null;
+    return (
+      <p className="mt-1 text-xs text-red-500 dark:text-red-400">
+        {error}
+      </p>
+    );
+  };
+
+  return (
+    <div className="space-y-4">
+      <SettingsPageHeader
+        title="Keyboard Macros"
+        description="Create and manage keyboard macros for quick actions"
+      />
+
+      {errorMessage && (
+        <div className="macro-error">
+          <div className="flex">
+            <div className="flex-shrink-0">
+              <svg className="macro-error-icon" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
+                <path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.414 1.414L10 11.414l1.72 1.72a.75.75 0 101.414-1.414L11.414 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.586 8.28 7.22z" clipRule="evenodd" />
+              </svg>
+            </div>
+            <div className="ml-3">
+              <h3 className="macro-error-text">{errorMessage}</h3>
+            </div>
+          </div>
+        </div>
+      )}
+
+      <div className="flex items-center justify-between mb-4">
+        <SettingsItem 
+          title="Macros"
+          description={`${macros.length}/${MAX_TOTAL_MACROS}`}
+        >
+          <div className="flex items-center gap-2">
+            {!showAddMacro && (
+              <Button
+                size="SM"
+                theme="primary"
+                text={isMaxMacrosReached ? `Max Reached` : "Add New Macro"}
+                onClick={() => setShowAddMacro(true)}
+                disabled={isMaxMacrosReached}
+              />
+            )}
+          </div>
+        </SettingsItem>
+      </div>
+
+      {loading && (
+        <div className="flex items-center justify-center p-8">
+          <LuLoader className="h-6 w-6 animate-spin text-blue-500" />
+        </div>
+      )}
+      <div className={`space-y-4 ${loading ? 'hidden' : ''}`}>
+        {showAddMacro && (
+          <div className="rounded-md border border-slate-200 bg-white p-3 dark:border-slate-700 dark:bg-slate-800">
+            <div className="flex items-center justify-between mb-2">
+              <h3 className="text-sm font-semibold text-black dark:text-white">Add New Macro</h3>
+            </div>
+            <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
+              <div className="flex flex-col">
+                <input
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: adamshiervani
body: Use existing InputField component
created_at: 2025-04-01T23:05:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,1439 @@
+import { useState, useEffect, useRef, useCallback } from "react";
+import { LuPlus, LuTrash, LuX, LuPenLine, LuLoader, LuGripVertical, LuInfo } from "react-icons/lu";
+import { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
+
+import { KeySequence, useMacrosStore } from "../hooks/stores";
+import { SettingsPageHeader } from "../components/SettingsPageheader";
+import { Button } from "../components/Button";
+import { keys, modifiers } from "../keyboardMappings";
+import { useJsonRpc } from "../hooks/useJsonRpc";
+import notifications from "../notifications";
+import { SettingsItem } from "../routes/devices.$id.settings";
+
+const DEFAULT_DELAY = 50;
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface KeyOption {
+  value: string;
+  label: string;
+}
+
+interface KeyOptionData {
+  value: string | null;
+  keys?: string[];
+  label?: string;
+}
+
+const generateId = () => {
+  return `macro-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
+};
+
+const keyOptions = Object.keys(keys).map(key => ({
+  value: key,
+  label: key,
+}));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+interface KeyComboboxProps {
+  stepIndex: number;
+  step: MacroStep;
+  onSelect: (option: KeyOptionData) => void;
+  query: string;
+  onQueryChange: (query: string) => void;
+  getFilteredOptions: () => KeyOption[];
+  disabled?: boolean;
+}
+
+function KeyCombobox({
+  onSelect,
+  query,
+  onQueryChange,
+  getFilteredOptions,
+  disabled = false,
+}: KeyComboboxProps) {
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  return (
+    <div className="relative w-full">
+      <Combobox immediate onChange={onSelect} disabled={disabled}>
+        {() => (
+          <>
+            <div className="relative">
+              <ComboboxInput
+                ref={inputRef}
+                className={`macro-input ${disabled ? 'opacity-60 cursor-not-allowed' : ''}`}
+                placeholder={disabled ? "Max keys reached" : "Search for key..."}
+                displayValue={() => query}
+                onChange={(event) => onQueryChange(event.target.value)}
+                disabled={disabled}
+              />
+            </div>
+            
+            <ComboboxOptions className="absolute left-0 z-50 mt-1 w-full max-h-60 overflow-auto rounded-md bg-white dark:bg-slate-800 py-1 text-sm shadow-lg">
+              {getFilteredOptions().map((option) => (
+                <ComboboxOption 
+                  key={option.value} 
+                  value={option}
+                  className="cursor-default select-none py-1.5 px-3 ui-active:bg-blue-100 ui-active:text-blue-900 dark:text-slate-300 dark:ui-active:bg-blue-900/40 dark:ui-active:text-blue-200"
+                >
+                  {option.label}
+                </ComboboxOption>
+              ))}
+              {getFilteredOptions().length === 0 && (
+                <div className="py-2 px-3 text-sm text-slate-500 dark:text-slate-400">
+                  No matching keys found
+                </div>
+              )}
+            </ComboboxOptions>
+          </>
+        )}
+      </Combobox>
+    </div>
+  );
+}
+
+const PRESET_DELAYS = [
+  { value: 50, label: "50ms" },
+  { value: 100, label: "100ms" },
+  { value: 200, label: "200ms" },
+  { value: 300, label: "300ms" },
+  { value: 500, label: "500ms" },
+  { value: 750, label: "750ms" },
+  { value: 1000, label: "1000ms" },
+  { value: 1500, label: "1500ms" },
+  { value: 2000, label: "2000ms" },
+];
+
+const MAX_STEPS_PER_MACRO = 10;
+const MAX_TOTAL_MACROS = 25;
+const MAX_KEYS_PER_STEP = 10;
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+// Helper function to normalize sort orders, ensuring they start at 1 and have no gaps
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  isDesktop: boolean;
+  onKeySelect: (option: KeyOptionData) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  getFilteredKeys: () => KeyOption[];
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  getFilteredKeys,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  return (
+    <div className="macro-step-card rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 p-4 shadow-sm">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <button
+              type="button"
+              className="p-1 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400 disabled:opacity-50"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="m18 15-6-6-6 6"/>
+              </svg>
+            </button>
+            <button
+              type="button"
+              className="p-1 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400 disabled:opacity-50"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="m6 9 6 6 6-6"/>
+              </svg>
+            </button>
+          </div>
+          <span className="macro-step-number flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+        
+        <div className="flex items-center space-x-2">
+          {onDelete && (
+            <button
+              type="button"
+              className="flex items-center text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
+              onClick={onDelete}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="M3 6h18"></path>
+                <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
+                <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
+              </svg>
+              <span className="ml-1">Delete</span>
+            </button>
+          )}
+        </div>
+      </div>
+      
+      <div className="space-y-4 mt-2">
+        <div className="w-full flex flex-col gap-2">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Modifiers:
+          </label>
+          <div className="macro-modifiers-container inline-flex flex-wrap gap-3">
+            {Object.entries(groupedModifiers).map(([group, mods]) => (
+              <div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
+                <span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
+                  {group}
+                </span>
+                <div className="flex flex-wrap gap-1">
+                  {mods.map(option => (
+                    <label 
+                      key={option.value} 
+                      className={`flex items-center px-2 py-1 rounded border cursor-pointer text-xs font-medium transition-colors ${
+                        ensureArray(step.modifiers).includes(option.value) 
+                          ? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-200' 
+                          : 'bg-slate-100 border-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700'
+                      }`}
+                    >
+                      <input
+                        type="checkbox"
+                        className="sr-only"
+                        checked={ensureArray(step.modifiers).includes(option.value)}
+                        onChange={e => {
+                          const modifiersArray = ensureArray(step.modifiers);
+                          const newModifiers = e.target.checked
+                            ? [...modifiersArray, option.value]
+                            : modifiersArray.filter(m => m !== option.value);
+                          onModifierChange(newModifiers);
+                        }}
+                      />
+                      {option.label.split(' ')[1] || option.label}
+                    </label>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Keys:
+          </label>
+          
+          <div className="macro-key-group flex flex-wrap gap-1 mb-2">
+            {ensureArray(step.keys).map((key, keyIndex) => (
+              <span
+                key={keyIndex}
+                className="macro-key-badge inline-flex items-center rounded-md bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
+              >
+                {key}
+                <button
+                  type="button"
+                  className="ml-1 text-xs text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
+                  onClick={() => {
+                    const newKeys = ensureArray(step.keys).filter((_, i) => i !== keyIndex);
+                    onKeySelect({ value: null, keys: newKeys });
+                  }}
+                >
+                  ×
+                </button>
+              </span>
+            ))}
+          </div>
+
+          <KeyCombobox
+            stepIndex={stepIndex}
+            step={step}
+            onSelect={onKeySelect}
+            query={keyQuery}
+            onQueryChange={onKeyQueryChange}
+            getFilteredOptions={getFilteredKeys}
+            disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP}
+          />
+          
+          {ensureArray(step.keys).length >= MAX_KEYS_PER_STEP && (
+            <span className="text-xs text-amber-600 dark:text-amber-400 mt-1">
+              (max keys reached)
+            </span>
+          )}
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+              Step Duration:
+            </label>
+            <div className="group relative">
+              <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+              <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                <p>The time to wait after pressing the keys in this step before moving to the next step. This helps ensure reliable key presses when automating keyboard input.</p>
+              </div>
+            </div>
+          </div>
+          <div className="flex items-center gap-3">
+            <select
+              className="w-full rounded-md border border-slate-300 bg-slate-50 p-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
+              value={step.delay}
+              onChange={(e) => onDelayChange(parseInt(e.target.value, 10))}
+            >
+              {PRESET_DELAYS.map((option) => (
+                <option key={option.value} value={option.value}>
+                  {option.label}
+                </option>
+              ))}
+            </select>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// Helper to update step keys used by both new and edit flows
+const updateStepKeys = (
+  steps: MacroStep[],
+  stepIndex: number,
+  keyOption: { value: string | null; keys?: string[] },
+  showTemporaryError: (msg: string) => void
+) => {
+  const newSteps = [...steps];
+  if (keyOption.keys) {
+    newSteps[stepIndex].keys = keyOption.keys;
+  } else if (keyOption.value) {
+    if (!newSteps[stepIndex].keys) {
+      newSteps[stepIndex].keys = [];
+    }
+    const keysArray = ensureArray(newSteps[stepIndex].keys);
+    if (keysArray.length >= MAX_KEYS_PER_STEP) {
+      showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`);
+      return newSteps;
+    }
+    newSteps[stepIndex].keys = [...keysArray, keyOption.value];
+  }
+  return newSteps;
+};
+
+const useTouchSort = (items: KeySequence[], onSort: (newItems: KeySequence[]) => void) => {
+  const [touchStartY, setTouchStartY] = useState<number | null>(null);
+  const [touchedIndex, setTouchedIndex] = useState<number | null>(null);
+
+  const handleTouchStart = useCallback((e: React.TouchEvent, index: number) => {
+    const touch = e.touches[0];
+    setTouchStartY(touch.clientY);
+    setTouchedIndex(index);
+    
+    const element = e.currentTarget as HTMLElement;
+    const rect = element.getBoundingClientRect();
+    
+    // Create ghost element
+    const ghost = element.cloneNode(true) as HTMLElement;
+    ghost.id = 'ghost-macro';
+    ghost.className = 'macro-sortable ghost';
+    ghost.style.height = `${rect.height}px`;
+    element.parentNode?.insertBefore(ghost, element);
+    
+    // Set up dragged element
+    element.style.position = 'fixed';
+    element.style.left = `${rect.left}px`;
+    element.style.top = `${rect.top}px`;
+    element.style.width = `${rect.width}px`;
+    element.style.zIndex = '50';
+  }, []);
+
+  const handleTouchMove = useCallback((e: React.TouchEvent) => {
+    if (touchStartY === null || touchedIndex === null) return;
+    
+    const touch = e.touches[0];
+    const deltaY = touch.clientY - touchStartY;
+    const element = e.currentTarget as HTMLElement;
+    
+    // Smooth movement of dragged element
+    element.style.transform = `translateY(${deltaY}px)`;
+    
+    const macroElements = document.querySelectorAll('[data-macro-item]');
+    const draggedRect = element.getBoundingClientRect();
+    const draggedMiddle = draggedRect.top + draggedRect.height / 2;
+    
+    macroElements.forEach((el, i) => {
+      if (i === touchedIndex) return;
+      
+      const rect = el.getBoundingClientRect();
+      const elementMiddle = rect.top + rect.height / 2;
+      const distance = Math.abs(draggedMiddle - elementMiddle);
+      
+      if (distance < rect.height) {
+        const direction = draggedMiddle > elementMiddle ? -1 : 1;
+        (el as HTMLElement).style.transform = `translateY(${direction * rect.height}px)`;
+        (el as HTMLElement).style.transition = 'transform 0.15s ease-out';
+      } else {
+        (el as HTMLElement).style.transform = '';
+        (el as HTMLElement).style.transition = 'transform 0.15s ease-out';
+      }
+    });
+  }, [touchStartY, touchedIndex]);
+
+  const handleTouchEnd = useCallback(async (e: React.TouchEvent) => {
+    if (touchedIndex === null) return;
+    
+    const element = e.currentTarget as HTMLElement;
+    const touch = e.changedTouches[0];
+    
+    // Remove ghost element
+    const ghost = document.getElementById('ghost-macro');
+    ghost?.parentNode?.removeChild(ghost);
+    
+    // Reset dragged element styles
+    element.style.position = '';
+    element.style.left = '';
+    element.style.top = '';
+    element.style.width = '';
+    element.style.zIndex = '';
+    element.style.transform = '';
+    element.style.boxShadow = '';
+    element.style.transition = '';
+    
+    const macroElements = document.querySelectorAll('[data-macro-item]');
+    let targetIndex = touchedIndex;
+    
+    // Find the closest element to the final touch position
+    const finalY = touch.clientY;
+    let closestDistance = Infinity;
+    
+    macroElements.forEach((el, i) => {
+      if (i === touchedIndex) return;
+      
+      const rect = el.getBoundingClientRect();
+      const distance = Math.abs(finalY - (rect.top + rect.height / 2));
+      
+      if (distance < closestDistance) {
+        closestDistance = distance;
+        targetIndex = i;
+      }
+      
+      // Reset other elements
+      (el as HTMLElement).style.transform = '';
+      (el as HTMLElement).style.transition = '';
+    });
+    
+    if (targetIndex !== touchedIndex && closestDistance < 50) {
+      const itemsCopy = [...items];
+      const [draggedItem] = itemsCopy.splice(touchedIndex, 1);
+      itemsCopy.splice(targetIndex, 0, draggedItem);
+      onSort(itemsCopy);
+    }
+    
+    setTouchStartY(null);
+    setTouchedIndex(null);
+  }, [touchedIndex, items, onSort]);
+
+  return { handleTouchStart, handleTouchMove, handleTouchEnd };
+};
+
+interface StepError {
+  keys?: string;
+  modifiers?: string;
+  delay?: string;
+}
+
+interface ValidationErrors {
+  name?: string;
+  description?: string;
+  steps?: Record<number, StepError>;
+}
+
+export default function SettingsMacrosRoute() {
+  const { macros, loading, initialized, loadMacros, saveMacros, setSendFn } = useMacrosStore();
+  const [editingMacro, setEditingMacro] = useState<KeySequence | null>(null);
+  const [newMacro, setNewMacro] = useState<Partial<KeySequence>>({
+    name: "",
+    description: "",
+    steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }],
+  });
+  const [isDragging, setIsDragging] = useState(false);
+  const dragItem = useRef<number | null>(null);
+  const dragOverItem = useRef<number | null>(null);
+  
+  const [macroToDelete, setMacroToDelete] = useState<string | null>(null);
+  
+  const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
+  const [editKeyQueries, setEditKeyQueries] = useState<Record<number, string>>({});
+  
+  const [errorMessage, setErrorMessage] = useState<string | null>(null);
+  const [isDesktop, setIsDesktop] = useState(window.innerWidth >= 768);
+  
+  const [send] = useJsonRpc();
+
+  const isMaxMacrosReached = macros.length >= MAX_TOTAL_MACROS;
+  const isMaxStepsReachedForNewMacro = (newMacro.steps?.length || 0) >= MAX_STEPS_PER_MACRO;
+  
+  const showTemporaryError = useCallback((message: string) => {
+    setErrorMessage(message);
+    setTimeout(() => setErrorMessage(null), 3000);
+  }, []);
+  
+  // Helper for both new and edit key select
+  const handleKeySelectUpdate = (stepIndex: number, option: KeyOptionData, isEditing = false) => {
+    if (isEditing && editingMacro) {
+      const updatedSteps = updateStepKeys(editingMacro.steps, stepIndex, option, showTemporaryError);
+      setEditingMacro({ ...editingMacro, steps: updatedSteps });
+    } else {
+      const updatedSteps = updateStepKeys(newMacro.steps || [], stepIndex, option, showTemporaryError);
+      setNewMacro({ ...newMacro, steps: updatedSteps });
+    }
+  };
+  
+  const handleKeySelect = (stepIndex: number, option: KeyOptionData) => {
+    handleKeySelectUpdate(stepIndex, option, false);
+  };
+  
+  const handleEditKeySelect = (stepIndex: number, option: KeyOptionData) => {
+    handleKeySelectUpdate(stepIndex, option, true);
+  };
+  
+  const handleKeyQueryChange = (stepIndex: number, query: string) => {
+    setKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
+  };
+  
+  const handleEditKeyQueryChange = (stepIndex: number, query: string) => {
+    setEditKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
+  };
+  
+  const getFilteredKeys = (stepIndex: number, isEditing = false) => {
+    const query = isEditing 
+      ? (editKeyQueries[stepIndex] || '')
+      : (keyQueries[stepIndex] || '');
+    
+    if (query === '') {
+      return keyOptions;
+    } else {
+      return keyOptions.filter(option => option.label.toLowerCase().includes(query.toLowerCase()));
+    }
+  };
+  
+  useEffect(() => {
+    setSendFn(send);
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros, setSendFn, send]);
+  
+  const [errors, setErrors] = useState<ValidationErrors>({});
+  
+  const clearErrors = useCallback(() => {
+    setErrors({});
+  }, []);
+  
+  const validateMacro = (macro: Partial<KeySequence>): ValidationErrors => {
+    const errors: ValidationErrors = {};
+
+    // Name validation
+    if (!macro.name?.trim()) {
+      errors.name = "Name is required";
+    } else if (macro.name.trim().length > 50) {
+      errors.name = "Name must be less than 50 characters";
+    }
+
+    // Description validation (optional)
+    if (macro.description && macro.description.trim().length > 200) {
+      errors.description = "Description must be less than 200 characters";
+    }
+
+    // Steps validation
+    if (!macro.steps?.length) {
+      errors.steps = { 0: { keys: "At least one step is required" } };
+      return errors;
+    }
+
+    // Check if at least one step has keys or modifiers
+    const hasKeyOrModifier = macro.steps.some(step => 
+      (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0
+    );
+
+    if (!hasKeyOrModifier) {
+      errors.steps = { 0: { keys: "At least one step must have keys or modifiers" } };
+      return errors;
+    }
+
+    const stepErrors: Record<number, StepError> = {};
+    
+    macro.steps.forEach((step, index) => {
+      const stepError: StepError = {};
+
+      // Keys validation (only if keys are present)
+      if (step.keys?.length && step.keys.length > MAX_KEYS_PER_STEP) {
+        stepError.keys = `Maximum ${MAX_KEYS_PER_STEP} keys allowed`;
+      }
+
+      // Delay validation
+      if (typeof step.delay !== 'number' || step.delay < 0) {
+        stepError.delay = "Invalid delay value";
+      }
+
+      if (Object.keys(stepError).length > 0) {
+        stepErrors[index] = stepError;
+      }
+    });
+
+    if (Object.keys(stepErrors).length > 0) {
+      errors.steps = stepErrors;
+    }
+
+    return errors;
+  };
+
+  const resetNewMacro = () => {
+    setNewMacro({
+      name: "",
+      description: "",
+      steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }],
+    });
+    setKeyQueries({});
+    setErrors({});
+  };
+
+  const [isSaving, setIsSaving] = useState(false);
+  const [isUpdating, setIsUpdating] = useState(false);
+  const [isDeleting, setIsDeleting] = useState(false);
+
+  const handleAddMacro = useCallback(async () => {
+    if (isMaxMacrosReached) {
+      showTemporaryError(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    const validationErrors = validateMacro(newMacro);
+    if (Object.keys(validationErrors).length > 0) {
+      setErrors(validationErrors);
+      return;
+    }
+
+    setIsSaving(true);
+    try {
+      const macro: KeySequence = {
+        id: generateId(),
+        name: newMacro.name!.trim(),
+        description: newMacro.description?.trim() || "",
+        steps: newMacro.steps || [],
+        sortOrder: macros.length + 1,
+      };
+
+      await saveMacros(normalizeSortOrders([...macros, macro]));
+      resetNewMacro();
+      setShowAddMacro(false);
+      notifications.success(`Macro "${macro.name}" created successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to create macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to create macro");
+        showTemporaryError("Failed to save macro");
+      }
+    } finally {
+      setIsSaving(false);
+    }
+  }, [isMaxMacrosReached, newMacro, macros, saveMacros, showTemporaryError]);
+
+  const handleDragStart = (index: number) => {
+    dragItem.current = index;
+    setIsDragging(true);
+    
+    const allItems = document.querySelectorAll('[data-macro-item]');
+    const draggedElement = allItems[index];
+    if (draggedElement) {
+      draggedElement.classList.add('dragging');
+    }
+  };
+  
+  const handleDragOver = (e: React.DragEvent, index: number) => {
+    e.preventDefault();
+    dragOverItem.current = index;
+    
+    const allItems = document.querySelectorAll('[data-macro-item]');
+    allItems.forEach(el => el.classList.remove('drop-target'));
+    
+    const targetElement = allItems[index];
+    if (targetElement) {
+      targetElement.classList.add('drop-target');
+    }
+  };
+  
+  const handleDrop = async (e: React.DragEvent) => {
+    e.preventDefault();
+    if (dragItem.current === null || dragOverItem.current === null) return;
+    
+    const macroCopy = [...macros];
+    const draggedItem = macroCopy.splice(dragItem.current, 1)[0];
+    macroCopy.splice(dragOverItem.current, 0, draggedItem);
+    const updatedMacros = normalizeSortOrders(macroCopy);
+
+    try {
+      await saveMacros(updatedMacros);
+      notifications.success("Macro order updated successfully");
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to reorder macros: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to reorder macros");
+        showTemporaryError("Failed to save reordered macros");
+      }
+    }
+    
+    const allItems = document.querySelectorAll('[data-macro-item]');
+    allItems.forEach(el => {
+      el.classList.remove('drop-target');
+      el.classList.remove('dragging');
+    });
+    
+    dragItem.current = null;
+    dragOverItem.current = null;
+    setIsDragging(false);
+  };
+
+  const handleUpdateMacro = useCallback(async () => {
+    if (!editingMacro) return;
+
+    const validationErrors = validateMacro(editingMacro);
+    if (Object.keys(validationErrors).length > 0) {
+      setErrors(validationErrors);
+      return;
+    }
+
+    setIsUpdating(true);
+    try {
+      const newMacros = macros.map(m => 
+        m.id === editingMacro.id ? {
+          ...editingMacro,
+          name: editingMacro.name.trim(),
+          description: editingMacro.description?.trim() || "",
+        } : m
+      );
+
+      await saveMacros(normalizeSortOrders(newMacros));
+      setEditingMacro(null);
+      clearErrors();
+      notifications.success(`Macro "${editingMacro.name}" updated successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to update macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to update macro");
+        showTemporaryError("Failed to update macro");
+      }
+    } finally {
+      setIsUpdating(false);
+    }
+  }, [editingMacro, macros, saveMacros, showTemporaryError, clearErrors]);
+
+  const handleEditMacro = (macro: KeySequence) => {
+    setEditingMacro({
+      ...macro,
+      description: macro.description || "",
+      steps: macro.steps.map(step => ({
+        ...step,
+        keys: ensureArray(step.keys),
+        modifiers: ensureArray(step.modifiers),
+        delay: typeof step.delay === 'number' ? step.delay : DEFAULT_DELAY
+      }))
+    });
+    clearErrors();
+    setEditKeyQueries({});
+  };
+
+  const handleDeleteMacro = async (id: string) => {
+    const macroToBeDeleted = macros.find(m => m.id === id);
+    if (!macroToBeDeleted) return;
+
+    setIsDeleting(true);
+    try {
+      const updatedMacros = normalizeSortOrders(macros.filter(macro => macro.id !== id));
+      await saveMacros(updatedMacros);
+      if (editingMacro?.id === id) {
+        setEditingMacro(null);
+      }
+      setMacroToDelete(null);
+      notifications.success(`Macro "${macroToBeDeleted.name}" deleted successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to delete macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to delete macro");
+        showTemporaryError("Failed to delete macro");
+      }
+    } finally {
+      setIsDeleting(false);
+    }
+  };
+
+  const handleDuplicateMacro = async (macro: KeySequence) => {
+    if (isMaxMacrosReached) {
+      showTemporaryError(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    const newMacroCopy: KeySequence = {
+      ...JSON.parse(JSON.stringify(macro)),
+      id: generateId(),
+      name: `${macro.name} (copy)`,
+      sortOrder: macros.length + 1,
+    };
+
+    newMacroCopy.steps = newMacroCopy.steps.map(step => ({
+      ...step,
+      keys: ensureArray(step.keys),
+      modifiers: ensureArray(step.modifiers),
+      delay: step.delay || DEFAULT_DELAY
+    }));
+
+    try {
+      await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
+      notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to duplicate macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to duplicate macro");
+        showTemporaryError("Failed to duplicate macro");
+      }
+    }
+  };
+
+  const handleStepMove = (stepIndex: number, direction: 'up' | 'down', steps: MacroStep[]) => {
+    const newSteps = [...steps];
+    const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
+    [newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]];
+    return newSteps;
+  };
+
+  useEffect(() => {
+    const handleResize = () => {
+      setIsDesktop(window.innerWidth >= 768);
+    };
+    
+    window.addEventListener('resize', handleResize);
+    return () => window.removeEventListener('resize', handleResize);
+  }, []);
+
+  const { handleTouchStart, handleTouchMove, handleTouchEnd } = useTouchSort(
+    macros,
+    async (newMacros) => {
+      const updatedMacros = normalizeSortOrders(newMacros);
+      try {
+        await saveMacros(updatedMacros);
+        notifications.success("Macro order updated successfully");
+      } catch (error) {
+        if (error instanceof Error) {
+          notifications.error(`Failed to reorder macros: ${error.message}`);
+          showTemporaryError(error.message);
+        } else {
+          notifications.error("Failed to reorder macros");
+          showTemporaryError("Failed to save reordered macros");
+        }
+      }
+    }
+  );
+
+  const [showClearConfirm, setShowClearConfirm] = useState(false);
+  const [showAddMacro, setShowAddMacro] = useState(false);
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && editingMacro) {
+        setEditingMacro(null);
+        setErrors({});
+      }
+      if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
+        if (editingMacro) {
+          handleUpdateMacro();
+        } else if (!isMaxMacrosReached) {
+          handleAddMacro();
+        }
+      }
+    };
+
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [editingMacro, isMaxMacrosReached, handleAddMacro, handleUpdateMacro]);
+
+  const handleModifierChange = (stepIndex: number, modifiers: string[]) => {
+    if (editingMacro) {
+      const newSteps = [...editingMacro.steps];
+      newSteps[stepIndex].modifiers = modifiers;
+      setEditingMacro({ ...editingMacro, steps: newSteps });
+    } else {
+      const newSteps = [...(newMacro.steps || [])];
+      newSteps[stepIndex].modifiers = modifiers;
+      setNewMacro({ ...newMacro, steps: newSteps });
+    }
+  };
+
+  const handleDelayChange = (stepIndex: number, delay: number) => {
+    if (editingMacro) {
+      const newSteps = [...editingMacro.steps];
+      newSteps[stepIndex].delay = delay;
+      setEditingMacro({ ...editingMacro, steps: newSteps });
+    } else {
+      const newSteps = [...(newMacro.steps || [])];
+      newSteps[stepIndex].delay = delay;
+      setNewMacro({ ...newMacro, steps: newSteps });
+    }
+  };
+
+  const ErrorMessage = ({ error }: { error?: string }) => {
+    if (!error) return null;
+    return (
+      <p className="mt-1 text-xs text-red-500 dark:text-red-400">
+        {error}
+      </p>
+    );
+  };
+
+  return (
+    <div className="space-y-4">
+      <SettingsPageHeader
+        title="Keyboard Macros"
+        description="Create and manage keyboard macros for quick actions"
+      />
+
+      {errorMessage && (
+        <div className="macro-error">
+          <div className="flex">
+            <div className="flex-shrink-0">
+              <svg className="macro-error-icon" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
+                <path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.414 1.414L10 11.414l1.72 1.72a.75.75 0 101.414-1.414L11.414 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.586 8.28 7.22z" clipRule="evenodd" />
+              </svg>
+            </div>
+            <div className="ml-3">
+              <h3 className="macro-error-text">{errorMessage}</h3>
+            </div>
+          </div>
+        </div>
+      )}
+
+      <div className="flex items-center justify-between mb-4">
+        <SettingsItem 
+          title="Macros"
+          description={`${macros.length}/${MAX_TOTAL_MACROS}`}
+        >
+          <div className="flex items-center gap-2">
+            {!showAddMacro && (
+              <Button
+                size="SM"
+                theme="primary"
+                text={isMaxMacrosReached ? `Max Reached` : "Add New Macro"}
+                onClick={() => setShowAddMacro(true)}
+                disabled={isMaxMacrosReached}
+              />
+            )}
+          </div>
+        </SettingsItem>
+      </div>
+
+      {loading && (
+        <div className="flex items-center justify-center p-8">
+          <LuLoader className="h-6 w-6 animate-spin text-blue-500" />
+        </div>
+      )}
+      <div className={`space-y-4 ${loading ? 'hidden' : ''}`}>
+        {showAddMacro && (
+          <div className="rounded-md border border-slate-200 bg-white p-3 dark:border-slate-700 dark:bg-slate-800">
+            <div className="flex items-center justify-between mb-2">
+              <h3 className="text-sm font-semibold text-black dark:text-white">Add New Macro</h3>
+            </div>
+            <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
+              <div className="flex flex-col">
+                <input
+                  type="text"
+                  className={`macro-input ${errors.name ? 'border-red-500 dark:border-red-500' : ''}`}
+                  value={newMacro.name}
+                  onChange={e => {
+                    setNewMacro(prev => ({ ...prev, name: e.target.value }));
+                    if (errors.name) {
+                      const newErrors = { ...errors };
+                      delete newErrors.name;
+                      setErrors(newErrors);
+                    }
+                  }}
+                  placeholder="Macro Name"
+                />
+                <ErrorMessage error={errors.name} />
+              </div>
+              <div className="flex flex-col">
+                <input
+                  type="text"
+                  className={`macro-input ${errors.description ? 'border-red-500 dark:border-red-500' : ''}`}
+                  value={newMacro.description}
+                  onChange={e => {
+                    setNewMacro(prev => ({ ...prev, description: e.target.value }));
+                    if (errors.description) {
+                      const newErrors = { ...errors };
+                      delete newErrors.description;
+                      setErrors(newErrors);
+                    }
+                  }}
+                  placeholder="Description (optional)"
+                />
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: adamshiervani
body: I would suggest we remove the description - the title will be able to convey enough information to the consumer.
created_at: 2025-04-01T23:06:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,1439 @@
+import { useState, useEffect, useRef, useCallback } from "react";
+import { LuPlus, LuTrash, LuX, LuPenLine, LuLoader, LuGripVertical, LuInfo } from "react-icons/lu";
+import { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
+
+import { KeySequence, useMacrosStore } from "../hooks/stores";
+import { SettingsPageHeader } from "../components/SettingsPageheader";
+import { Button } from "../components/Button";
+import { keys, modifiers } from "../keyboardMappings";
+import { useJsonRpc } from "../hooks/useJsonRpc";
+import notifications from "../notifications";
+import { SettingsItem } from "../routes/devices.$id.settings";
+
+const DEFAULT_DELAY = 50;
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface KeyOption {
+  value: string;
+  label: string;
+}
+
+interface KeyOptionData {
+  value: string | null;
+  keys?: string[];
+  label?: string;
+}
+
+const generateId = () => {
+  return `macro-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
+};
+
+const keyOptions = Object.keys(keys).map(key => ({
+  value: key,
+  label: key,
+}));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+interface KeyComboboxProps {
+  stepIndex: number;
+  step: MacroStep;
+  onSelect: (option: KeyOptionData) => void;
+  query: string;
+  onQueryChange: (query: string) => void;
+  getFilteredOptions: () => KeyOption[];
+  disabled?: boolean;
+}
+
+function KeyCombobox({
+  onSelect,
+  query,
+  onQueryChange,
+  getFilteredOptions,
+  disabled = false,
+}: KeyComboboxProps) {
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  return (
+    <div className="relative w-full">
+      <Combobox immediate onChange={onSelect} disabled={disabled}>
+        {() => (
+          <>
+            <div className="relative">
+              <ComboboxInput
+                ref={inputRef}
+                className={`macro-input ${disabled ? 'opacity-60 cursor-not-allowed' : ''}`}
+                placeholder={disabled ? "Max keys reached" : "Search for key..."}
+                displayValue={() => query}
+                onChange={(event) => onQueryChange(event.target.value)}
+                disabled={disabled}
+              />
+            </div>
+            
+            <ComboboxOptions className="absolute left-0 z-50 mt-1 w-full max-h-60 overflow-auto rounded-md bg-white dark:bg-slate-800 py-1 text-sm shadow-lg">
+              {getFilteredOptions().map((option) => (
+                <ComboboxOption 
+                  key={option.value} 
+                  value={option}
+                  className="cursor-default select-none py-1.5 px-3 ui-active:bg-blue-100 ui-active:text-blue-900 dark:text-slate-300 dark:ui-active:bg-blue-900/40 dark:ui-active:text-blue-200"
+                >
+                  {option.label}
+                </ComboboxOption>
+              ))}
+              {getFilteredOptions().length === 0 && (
+                <div className="py-2 px-3 text-sm text-slate-500 dark:text-slate-400">
+                  No matching keys found
+                </div>
+              )}
+            </ComboboxOptions>
+          </>
+        )}
+      </Combobox>
+    </div>
+  );
+}
+
+const PRESET_DELAYS = [
+  { value: 50, label: "50ms" },
+  { value: 100, label: "100ms" },
+  { value: 200, label: "200ms" },
+  { value: 300, label: "300ms" },
+  { value: 500, label: "500ms" },
+  { value: 750, label: "750ms" },
+  { value: 1000, label: "1000ms" },
+  { value: 1500, label: "1500ms" },
+  { value: 2000, label: "2000ms" },
+];
+
+const MAX_STEPS_PER_MACRO = 10;
+const MAX_TOTAL_MACROS = 25;
+const MAX_KEYS_PER_STEP = 10;
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+// Helper function to normalize sort orders, ensuring they start at 1 and have no gaps
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  isDesktop: boolean;
+  onKeySelect: (option: KeyOptionData) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  getFilteredKeys: () => KeyOption[];
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  getFilteredKeys,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  return (
+    <div className="macro-step-card rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 p-4 shadow-sm">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <button
+              type="button"
+              className="p-1 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400 disabled:opacity-50"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="m18 15-6-6-6 6"/>
+              </svg>
+            </button>
+            <button
+              type="button"
+              className="p-1 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400 disabled:opacity-50"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="m6 9 6 6 6-6"/>
+              </svg>
+            </button>
+          </div>
+          <span className="macro-step-number flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+        
+        <div className="flex items-center space-x-2">
+          {onDelete && (
+            <button
+              type="button"
+              className="flex items-center text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
+              onClick={onDelete}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="M3 6h18"></path>
+                <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
+                <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
+              </svg>
+              <span className="ml-1">Delete</span>
+            </button>
+          )}
+        </div>
+      </div>
+      
+      <div className="space-y-4 mt-2">
+        <div className="w-full flex flex-col gap-2">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Modifiers:
+          </label>
+          <div className="macro-modifiers-container inline-flex flex-wrap gap-3">
+            {Object.entries(groupedModifiers).map(([group, mods]) => (
+              <div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
+                <span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
+                  {group}
+                </span>
+                <div className="flex flex-wrap gap-1">
+                  {mods.map(option => (
+                    <label 
+                      key={option.value} 
+                      className={`flex items-center px-2 py-1 rounded border cursor-pointer text-xs font-medium transition-colors ${
+                        ensureArray(step.modifiers).includes(option.value) 
+                          ? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-200' 
+                          : 'bg-slate-100 border-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700'
+                      }`}
+                    >
+                      <input
+                        type="checkbox"
+                        className="sr-only"
+                        checked={ensureArray(step.modifiers).includes(option.value)}
+                        onChange={e => {
+                          const modifiersArray = ensureArray(step.modifiers);
+                          const newModifiers = e.target.checked
+                            ? [...modifiersArray, option.value]
+                            : modifiersArray.filter(m => m !== option.value);
+                          onModifierChange(newModifiers);
+                        }}
+                      />
+                      {option.label.split(' ')[1] || option.label}
+                    </label>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Keys:
+          </label>
+          
+          <div className="macro-key-group flex flex-wrap gap-1 mb-2">
+            {ensureArray(step.keys).map((key, keyIndex) => (
+              <span
+                key={keyIndex}
+                className="macro-key-badge inline-flex items-center rounded-md bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
+              >
+                {key}
+                <button
+                  type="button"
+                  className="ml-1 text-xs text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
+                  onClick={() => {
+                    const newKeys = ensureArray(step.keys).filter((_, i) => i !== keyIndex);
+                    onKeySelect({ value: null, keys: newKeys });
+                  }}
+                >
+                  ×
+                </button>
+              </span>
+            ))}
+          </div>
+
+          <KeyCombobox
+            stepIndex={stepIndex}
+            step={step}
+            onSelect={onKeySelect}
+            query={keyQuery}
+            onQueryChange={onKeyQueryChange}
+            getFilteredOptions={getFilteredKeys}
+            disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP}
+          />
+          
+          {ensureArray(step.keys).length >= MAX_KEYS_PER_STEP && (
+            <span className="text-xs text-amber-600 dark:text-amber-400 mt-1">
+              (max keys reached)
+            </span>
+          )}
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+              Step Duration:
+            </label>
+            <div className="group relative">
+              <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+              <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                <p>The time to wait after pressing the keys in this step before moving to the next step. This helps ensure reliable key presses when automating keyboard input.</p>
+              </div>
+            </div>
+          </div>
+          <div className="flex items-center gap-3">
+            <select
+              className="w-full rounded-md border border-slate-300 bg-slate-50 p-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
+              value={step.delay}
+              onChange={(e) => onDelayChange(parseInt(e.target.value, 10))}
+            >
+              {PRESET_DELAYS.map((option) => (
+                <option key={option.value} value={option.value}>
+                  {option.label}
+                </option>
+              ))}
+            </select>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// Helper to update step keys used by both new and edit flows
+const updateStepKeys = (
+  steps: MacroStep[],
+  stepIndex: number,
+  keyOption: { value: string | null; keys?: string[] },
+  showTemporaryError: (msg: string) => void
+) => {
+  const newSteps = [...steps];
+  if (keyOption.keys) {
+    newSteps[stepIndex].keys = keyOption.keys;
+  } else if (keyOption.value) {
+    if (!newSteps[stepIndex].keys) {
+      newSteps[stepIndex].keys = [];
+    }
+    const keysArray = ensureArray(newSteps[stepIndex].keys);
+    if (keysArray.length >= MAX_KEYS_PER_STEP) {
+      showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`);
+      return newSteps;
+    }
+    newSteps[stepIndex].keys = [...keysArray, keyOption.value];
+  }
+  return newSteps;
+};
+
+const useTouchSort = (items: KeySequence[], onSort: (newItems: KeySequence[]) => void) => {
+  const [touchStartY, setTouchStartY] = useState<number | null>(null);
+  const [touchedIndex, setTouchedIndex] = useState<number | null>(null);
+
+  const handleTouchStart = useCallback((e: React.TouchEvent, index: number) => {
+    const touch = e.touches[0];
+    setTouchStartY(touch.clientY);
+    setTouchedIndex(index);
+    
+    const element = e.currentTarget as HTMLElement;
+    const rect = element.getBoundingClientRect();
+    
+    // Create ghost element
+    const ghost = element.cloneNode(true) as HTMLElement;
+    ghost.id = 'ghost-macro';
+    ghost.className = 'macro-sortable ghost';
+    ghost.style.height = `${rect.height}px`;
+    element.parentNode?.insertBefore(ghost, element);
+    
+    // Set up dragged element
+    element.style.position = 'fixed';
+    element.style.left = `${rect.left}px`;
+    element.style.top = `${rect.top}px`;
+    element.style.width = `${rect.width}px`;
+    element.style.zIndex = '50';
+  }, []);
+
+  const handleTouchMove = useCallback((e: React.TouchEvent) => {
+    if (touchStartY === null || touchedIndex === null) return;
+    
+    const touch = e.touches[0];
+    const deltaY = touch.clientY - touchStartY;
+    const element = e.currentTarget as HTMLElement;
+    
+    // Smooth movement of dragged element
+    element.style.transform = `translateY(${deltaY}px)`;
+    
+    const macroElements = document.querySelectorAll('[data-macro-item]');
+    const draggedRect = element.getBoundingClientRect();
+    const draggedMiddle = draggedRect.top + draggedRect.height / 2;
+    
+    macroElements.forEach((el, i) => {
+      if (i === touchedIndex) return;
+      
+      const rect = el.getBoundingClientRect();
+      const elementMiddle = rect.top + rect.height / 2;
+      const distance = Math.abs(draggedMiddle - elementMiddle);
+      
+      if (distance < rect.height) {
+        const direction = draggedMiddle > elementMiddle ? -1 : 1;
+        (el as HTMLElement).style.transform = `translateY(${direction * rect.height}px)`;
+        (el as HTMLElement).style.transition = 'transform 0.15s ease-out';
+      } else {
+        (el as HTMLElement).style.transform = '';
+        (el as HTMLElement).style.transition = 'transform 0.15s ease-out';
+      }
+    });
+  }, [touchStartY, touchedIndex]);
+
+  const handleTouchEnd = useCallback(async (e: React.TouchEvent) => {
+    if (touchedIndex === null) return;
+    
+    const element = e.currentTarget as HTMLElement;
+    const touch = e.changedTouches[0];
+    
+    // Remove ghost element
+    const ghost = document.getElementById('ghost-macro');
+    ghost?.parentNode?.removeChild(ghost);
+    
+    // Reset dragged element styles
+    element.style.position = '';
+    element.style.left = '';
+    element.style.top = '';
+    element.style.width = '';
+    element.style.zIndex = '';
+    element.style.transform = '';
+    element.style.boxShadow = '';
+    element.style.transition = '';
+    
+    const macroElements = document.querySelectorAll('[data-macro-item]');
+    let targetIndex = touchedIndex;
+    
+    // Find the closest element to the final touch position
+    const finalY = touch.clientY;
+    let closestDistance = Infinity;
+    
+    macroElements.forEach((el, i) => {
+      if (i === touchedIndex) return;
+      
+      const rect = el.getBoundingClientRect();
+      const distance = Math.abs(finalY - (rect.top + rect.height / 2));
+      
+      if (distance < closestDistance) {
+        closestDistance = distance;
+        targetIndex = i;
+      }
+      
+      // Reset other elements
+      (el as HTMLElement).style.transform = '';
+      (el as HTMLElement).style.transition = '';
+    });
+    
+    if (targetIndex !== touchedIndex && closestDistance < 50) {
+      const itemsCopy = [...items];
+      const [draggedItem] = itemsCopy.splice(touchedIndex, 1);
+      itemsCopy.splice(targetIndex, 0, draggedItem);
+      onSort(itemsCopy);
+    }
+    
+    setTouchStartY(null);
+    setTouchedIndex(null);
+  }, [touchedIndex, items, onSort]);
+
+  return { handleTouchStart, handleTouchMove, handleTouchEnd };
+};
+
+interface StepError {
+  keys?: string;
+  modifiers?: string;
+  delay?: string;
+}
+
+interface ValidationErrors {
+  name?: string;
+  description?: string;
+  steps?: Record<number, StepError>;
+}
+
+export default function SettingsMacrosRoute() {
+  const { macros, loading, initialized, loadMacros, saveMacros, setSendFn } = useMacrosStore();
+  const [editingMacro, setEditingMacro] = useState<KeySequence | null>(null);
+  const [newMacro, setNewMacro] = useState<Partial<KeySequence>>({
+    name: "",
+    description: "",
+    steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }],
+  });
+  const [isDragging, setIsDragging] = useState(false);
+  const dragItem = useRef<number | null>(null);
+  const dragOverItem = useRef<number | null>(null);
+  
+  const [macroToDelete, setMacroToDelete] = useState<string | null>(null);
+  
+  const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
+  const [editKeyQueries, setEditKeyQueries] = useState<Record<number, string>>({});
+  
+  const [errorMessage, setErrorMessage] = useState<string | null>(null);
+  const [isDesktop, setIsDesktop] = useState(window.innerWidth >= 768);
+  
+  const [send] = useJsonRpc();
+
+  const isMaxMacrosReached = macros.length >= MAX_TOTAL_MACROS;
+  const isMaxStepsReachedForNewMacro = (newMacro.steps?.length || 0) >= MAX_STEPS_PER_MACRO;
+  
+  const showTemporaryError = useCallback((message: string) => {
+    setErrorMessage(message);
+    setTimeout(() => setErrorMessage(null), 3000);
+  }, []);
+  
+  // Helper for both new and edit key select
+  const handleKeySelectUpdate = (stepIndex: number, option: KeyOptionData, isEditing = false) => {
+    if (isEditing && editingMacro) {
+      const updatedSteps = updateStepKeys(editingMacro.steps, stepIndex, option, showTemporaryError);
+      setEditingMacro({ ...editingMacro, steps: updatedSteps });
+    } else {
+      const updatedSteps = updateStepKeys(newMacro.steps || [], stepIndex, option, showTemporaryError);
+      setNewMacro({ ...newMacro, steps: updatedSteps });
+    }
+  };
+  
+  const handleKeySelect = (stepIndex: number, option: KeyOptionData) => {
+    handleKeySelectUpdate(stepIndex, option, false);
+  };
+  
+  const handleEditKeySelect = (stepIndex: number, option: KeyOptionData) => {
+    handleKeySelectUpdate(stepIndex, option, true);
+  };
+  
+  const handleKeyQueryChange = (stepIndex: number, query: string) => {
+    setKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
+  };
+  
+  const handleEditKeyQueryChange = (stepIndex: number, query: string) => {
+    setEditKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
+  };
+  
+  const getFilteredKeys = (stepIndex: number, isEditing = false) => {
+    const query = isEditing 
+      ? (editKeyQueries[stepIndex] || '')
+      : (keyQueries[stepIndex] || '');
+    
+    if (query === '') {
+      return keyOptions;
+    } else {
+      return keyOptions.filter(option => option.label.toLowerCase().includes(query.toLowerCase()));
+    }
+  };
+  
+  useEffect(() => {
+    setSendFn(send);
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros, setSendFn, send]);
+  
+  const [errors, setErrors] = useState<ValidationErrors>({});
+  
+  const clearErrors = useCallback(() => {
+    setErrors({});
+  }, []);
+  
+  const validateMacro = (macro: Partial<KeySequence>): ValidationErrors => {
+    const errors: ValidationErrors = {};
+
+    // Name validation
+    if (!macro.name?.trim()) {
+      errors.name = "Name is required";
+    } else if (macro.name.trim().length > 50) {
+      errors.name = "Name must be less than 50 characters";
+    }
+
+    // Description validation (optional)
+    if (macro.description && macro.description.trim().length > 200) {
+      errors.description = "Description must be less than 200 characters";
+    }
+
+    // Steps validation
+    if (!macro.steps?.length) {
+      errors.steps = { 0: { keys: "At least one step is required" } };
+      return errors;
+    }
+
+    // Check if at least one step has keys or modifiers
+    const hasKeyOrModifier = macro.steps.some(step => 
+      (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0
+    );
+
+    if (!hasKeyOrModifier) {
+      errors.steps = { 0: { keys: "At least one step must have keys or modifiers" } };
+      return errors;
+    }
+
+    const stepErrors: Record<number, StepError> = {};
+    
+    macro.steps.forEach((step, index) => {
+      const stepError: StepError = {};
+
+      // Keys validation (only if keys are present)
+      if (step.keys?.length && step.keys.length > MAX_KEYS_PER_STEP) {
+        stepError.keys = `Maximum ${MAX_KEYS_PER_STEP} keys allowed`;
+      }
+
+      // Delay validation
+      if (typeof step.delay !== 'number' || step.delay < 0) {
+        stepError.delay = "Invalid delay value";
+      }
+
+      if (Object.keys(stepError).length > 0) {
+        stepErrors[index] = stepError;
+      }
+    });
+
+    if (Object.keys(stepErrors).length > 0) {
+      errors.steps = stepErrors;
+    }
+
+    return errors;
+  };
+
+  const resetNewMacro = () => {
+    setNewMacro({
+      name: "",
+      description: "",
+      steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }],
+    });
+    setKeyQueries({});
+    setErrors({});
+  };
+
+  const [isSaving, setIsSaving] = useState(false);
+  const [isUpdating, setIsUpdating] = useState(false);
+  const [isDeleting, setIsDeleting] = useState(false);
+
+  const handleAddMacro = useCallback(async () => {
+    if (isMaxMacrosReached) {
+      showTemporaryError(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    const validationErrors = validateMacro(newMacro);
+    if (Object.keys(validationErrors).length > 0) {
+      setErrors(validationErrors);
+      return;
+    }
+
+    setIsSaving(true);
+    try {
+      const macro: KeySequence = {
+        id: generateId(),
+        name: newMacro.name!.trim(),
+        description: newMacro.description?.trim() || "",
+        steps: newMacro.steps || [],
+        sortOrder: macros.length + 1,
+      };
+
+      await saveMacros(normalizeSortOrders([...macros, macro]));
+      resetNewMacro();
+      setShowAddMacro(false);
+      notifications.success(`Macro "${macro.name}" created successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to create macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to create macro");
+        showTemporaryError("Failed to save macro");
+      }
+    } finally {
+      setIsSaving(false);
+    }
+  }, [isMaxMacrosReached, newMacro, macros, saveMacros, showTemporaryError]);
+
+  const handleDragStart = (index: number) => {
+    dragItem.current = index;
+    setIsDragging(true);
+    
+    const allItems = document.querySelectorAll('[data-macro-item]');
+    const draggedElement = allItems[index];
+    if (draggedElement) {
+      draggedElement.classList.add('dragging');
+    }
+  };
+  
+  const handleDragOver = (e: React.DragEvent, index: number) => {
+    e.preventDefault();
+    dragOverItem.current = index;
+    
+    const allItems = document.querySelectorAll('[data-macro-item]');
+    allItems.forEach(el => el.classList.remove('drop-target'));
+    
+    const targetElement = allItems[index];
+    if (targetElement) {
+      targetElement.classList.add('drop-target');
+    }
+  };
+  
+  const handleDrop = async (e: React.DragEvent) => {
+    e.preventDefault();
+    if (dragItem.current === null || dragOverItem.current === null) return;
+    
+    const macroCopy = [...macros];
+    const draggedItem = macroCopy.splice(dragItem.current, 1)[0];
+    macroCopy.splice(dragOverItem.current, 0, draggedItem);
+    const updatedMacros = normalizeSortOrders(macroCopy);
+
+    try {
+      await saveMacros(updatedMacros);
+      notifications.success("Macro order updated successfully");
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to reorder macros: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to reorder macros");
+        showTemporaryError("Failed to save reordered macros");
+      }
+    }
+    
+    const allItems = document.querySelectorAll('[data-macro-item]');
+    allItems.forEach(el => {
+      el.classList.remove('drop-target');
+      el.classList.remove('dragging');
+    });
+    
+    dragItem.current = null;
+    dragOverItem.current = null;
+    setIsDragging(false);
+  };
+
+  const handleUpdateMacro = useCallback(async () => {
+    if (!editingMacro) return;
+
+    const validationErrors = validateMacro(editingMacro);
+    if (Object.keys(validationErrors).length > 0) {
+      setErrors(validationErrors);
+      return;
+    }
+
+    setIsUpdating(true);
+    try {
+      const newMacros = macros.map(m => 
+        m.id === editingMacro.id ? {
+          ...editingMacro,
+          name: editingMacro.name.trim(),
+          description: editingMacro.description?.trim() || "",
+        } : m
+      );
+
+      await saveMacros(normalizeSortOrders(newMacros));
+      setEditingMacro(null);
+      clearErrors();
+      notifications.success(`Macro "${editingMacro.name}" updated successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to update macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to update macro");
+        showTemporaryError("Failed to update macro");
+      }
+    } finally {
+      setIsUpdating(false);
+    }
+  }, [editingMacro, macros, saveMacros, showTemporaryError, clearErrors]);
+
+  const handleEditMacro = (macro: KeySequence) => {
+    setEditingMacro({
+      ...macro,
+      description: macro.description || "",
+      steps: macro.steps.map(step => ({
+        ...step,
+        keys: ensureArray(step.keys),
+        modifiers: ensureArray(step.modifiers),
+        delay: typeof step.delay === 'number' ? step.delay : DEFAULT_DELAY
+      }))
+    });
+    clearErrors();
+    setEditKeyQueries({});
+  };
+
+  const handleDeleteMacro = async (id: string) => {
+    const macroToBeDeleted = macros.find(m => m.id === id);
+    if (!macroToBeDeleted) return;
+
+    setIsDeleting(true);
+    try {
+      const updatedMacros = normalizeSortOrders(macros.filter(macro => macro.id !== id));
+      await saveMacros(updatedMacros);
+      if (editingMacro?.id === id) {
+        setEditingMacro(null);
+      }
+      setMacroToDelete(null);
+      notifications.success(`Macro "${macroToBeDeleted.name}" deleted successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to delete macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to delete macro");
+        showTemporaryError("Failed to delete macro");
+      }
+    } finally {
+      setIsDeleting(false);
+    }
+  };
+
+  const handleDuplicateMacro = async (macro: KeySequence) => {
+    if (isMaxMacrosReached) {
+      showTemporaryError(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    const newMacroCopy: KeySequence = {
+      ...JSON.parse(JSON.stringify(macro)),
+      id: generateId(),
+      name: `${macro.name} (copy)`,
+      sortOrder: macros.length + 1,
+    };
+
+    newMacroCopy.steps = newMacroCopy.steps.map(step => ({
+      ...step,
+      keys: ensureArray(step.keys),
+      modifiers: ensureArray(step.modifiers),
+      delay: step.delay || DEFAULT_DELAY
+    }));
+
+    try {
+      await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
+      notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to duplicate macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to duplicate macro");
+        showTemporaryError("Failed to duplicate macro");
+      }
+    }
+  };
+
+  const handleStepMove = (stepIndex: number, direction: 'up' | 'down', steps: MacroStep[]) => {
+    const newSteps = [...steps];
+    const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
+    [newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]];
+    return newSteps;
+  };
+
+  useEffect(() => {
+    const handleResize = () => {
+      setIsDesktop(window.innerWidth >= 768);
+    };
+    
+    window.addEventListener('resize', handleResize);
+    return () => window.removeEventListener('resize', handleResize);
+  }, []);
+
+  const { handleTouchStart, handleTouchMove, handleTouchEnd } = useTouchSort(
+    macros,
+    async (newMacros) => {
+      const updatedMacros = normalizeSortOrders(newMacros);
+      try {
+        await saveMacros(updatedMacros);
+        notifications.success("Macro order updated successfully");
+      } catch (error) {
+        if (error instanceof Error) {
+          notifications.error(`Failed to reorder macros: ${error.message}`);
+          showTemporaryError(error.message);
+        } else {
+          notifications.error("Failed to reorder macros");
+          showTemporaryError("Failed to save reordered macros");
+        }
+      }
+    }
+  );
+
+  const [showClearConfirm, setShowClearConfirm] = useState(false);
+  const [showAddMacro, setShowAddMacro] = useState(false);
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && editingMacro) {
+        setEditingMacro(null);
+        setErrors({});
+      }
+      if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
+        if (editingMacro) {
+          handleUpdateMacro();
+        } else if (!isMaxMacrosReached) {
+          handleAddMacro();
+        }
+      }
+    };
+
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [editingMacro, isMaxMacrosReached, handleAddMacro, handleUpdateMacro]);
+
+  const handleModifierChange = (stepIndex: number, modifiers: string[]) => {
+    if (editingMacro) {
+      const newSteps = [...editingMacro.steps];
+      newSteps[stepIndex].modifiers = modifiers;
+      setEditingMacro({ ...editingMacro, steps: newSteps });
+    } else {
+      const newSteps = [...(newMacro.steps || [])];
+      newSteps[stepIndex].modifiers = modifiers;
+      setNewMacro({ ...newMacro, steps: newSteps });
+    }
+  };
+
+  const handleDelayChange = (stepIndex: number, delay: number) => {
+    if (editingMacro) {
+      const newSteps = [...editingMacro.steps];
+      newSteps[stepIndex].delay = delay;
+      setEditingMacro({ ...editingMacro, steps: newSteps });
+    } else {
+      const newSteps = [...(newMacro.steps || [])];
+      newSteps[stepIndex].delay = delay;
+      setNewMacro({ ...newMacro, steps: newSteps });
+    }
+  };
+
+  const ErrorMessage = ({ error }: { error?: string }) => {
+    if (!error) return null;
+    return (
+      <p className="mt-1 text-xs text-red-500 dark:text-red-400">
+        {error}
+      </p>
+    );
+  };
+
+  return (
+    <div className="space-y-4">
+      <SettingsPageHeader
+        title="Keyboard Macros"
+        description="Create and manage keyboard macros for quick actions"
+      />
+
+      {errorMessage && (
+        <div className="macro-error">
+          <div className="flex">
+            <div className="flex-shrink-0">
+              <svg className="macro-error-icon" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
+                <path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.414 1.414L10 11.414l1.72 1.72a.75.75 0 101.414-1.414L11.414 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.586 8.28 7.22z" clipRule="evenodd" />
+              </svg>
+            </div>
+            <div className="ml-3">
+              <h3 className="macro-error-text">{errorMessage}</h3>
+            </div>
+          </div>
+        </div>
+      )}
+
+      <div className="flex items-center justify-between mb-4">
+        <SettingsItem 
+          title="Macros"
+          description={`${macros.length}/${MAX_TOTAL_MACROS}`}
+        >
+          <div className="flex items-center gap-2">
+            {!showAddMacro && (
+              <Button
+                size="SM"
+                theme="primary"
+                text={isMaxMacrosReached ? `Max Reached` : "Add New Macro"}
+                onClick={() => setShowAddMacro(true)}
+                disabled={isMaxMacrosReached}
+              />
+            )}
+          </div>
+        </SettingsItem>
+      </div>
+
+      {loading && (
+        <div className="flex items-center justify-center p-8">
+          <LuLoader className="h-6 w-6 animate-spin text-blue-500" />
+        </div>
+      )}
+      <div className={`space-y-4 ${loading ? 'hidden' : ''}`}>
+        {showAddMacro && (
+          <div className="rounded-md border border-slate-200 bg-white p-3 dark:border-slate-700 dark:bg-slate-800">
+            <div className="flex items-center justify-between mb-2">
+              <h3 className="text-sm font-semibold text-black dark:text-white">Add New Macro</h3>
+            </div>
+            <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
+              <div className="flex flex-col">
+                <input
+                  type="text"
+                  className={`macro-input ${errors.name ? 'border-red-500 dark:border-red-500' : ''}`}
+                  value={newMacro.name}
+                  onChange={e => {
+                    setNewMacro(prev => ({ ...prev, name: e.target.value }));
+                    if (errors.name) {
+                      const newErrors = { ...errors };
+                      delete newErrors.name;
+                      setErrors(newErrors);
+                    }
+                  }}
+                  placeholder="Macro Name"
+                />
+                <ErrorMessage error={errors.name} />
+              </div>
+              <div className="flex flex-col">
+                <input
+                  type="text"
+                  className={`macro-input ${errors.description ? 'border-red-500 dark:border-red-500' : ''}`}
+                  value={newMacro.description}
+                  onChange={e => {
+                    setNewMacro(prev => ({ ...prev, description: e.target.value }));
+                    if (errors.description) {
+                      const newErrors = { ...errors };
+                      delete newErrors.description;
+                      setErrors(newErrors);
+                    }
+                  }}
+                  placeholder="Description (optional)"
+                />
+                <ErrorMessage error={errors.description} />
+              </div>
+            </div>
+            
+            <div className="mt-4">
+              <div className="macro-section-header">
+                <label className="macro-section-title">
+                  Steps:
+                </label>
+                <span className="macro-section-subtitle">
+                  {newMacro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps
+                </span>
+              </div>
+              {errors.steps && errors.steps[0]?.keys && (
+                <div className="mt-2">
+                  <ErrorMessage error={errors.steps[0].keys} />
+                </div>
+              )}
+              <div className="mt-2 text-xs text-slate-500 dark:text-slate-400">
+                You can add up to {MAX_STEPS_PER_MACRO} steps per macro
+              </div>
+              <div className="mt-2 space-y-4">
+                {(newMacro.steps || []).map((step, stepIndex) => (
+                  <MacroStepCard
+                    key={stepIndex}
+                    step={step}
+                    stepIndex={stepIndex}
+                    onDelete={newMacro.steps && newMacro.steps.length > 1 ? () => {
+                      const newSteps = [...(newMacro.steps || [])];
+                      newSteps.splice(stepIndex, 1);
+                      setNewMacro(prev => ({ ...prev, steps: newSteps }));
+                    } : undefined}
+                    onMoveUp={() => {
+                      const newSteps = handleStepMove(stepIndex, 'up', newMacro.steps || []);
+                      setNewMacro(prev => ({ ...prev, steps: newSteps }));
+                    }}
+                    onMoveDown={() => {
+                      const newSteps = handleStepMove(stepIndex, 'down', newMacro.steps || []);
+                      setNewMacro(prev => ({ ...prev, steps: newSteps }));
+                    }}
+                    isDesktop={isDesktop}
+                    onKeySelect={(option) => handleKeySelect(stepIndex, option)}
+                    onKeyQueryChange={(query) => handleKeyQueryChange(stepIndex, query)}
+                    keyQuery={keyQueries[stepIndex] || ''}
+                    getFilteredKeys={() => getFilteredKeys(stepIndex)}
+                    onModifierChange={(modifiers) => handleModifierChange(stepIndex, modifiers)}
+                    onDelayChange={(delay) => handleDelayChange(stepIndex, delay)}
+                    isLastStep={stepIndex === (newMacro.steps?.length || 0) - 1}
+                  />
+                ))}
+
+                <div className="mt-4 border-t border-slate-200 pt-4 dark:border-slate-700">
+                  <button
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: adamshiervani
body: Use existing Button Component
created_at: 2025-04-01T23:07:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,1439 @@
+import { useState, useEffect, useRef, useCallback } from "react";
+import { LuPlus, LuTrash, LuX, LuPenLine, LuLoader, LuGripVertical, LuInfo } from "react-icons/lu";
+import { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
+
+import { KeySequence, useMacrosStore } from "../hooks/stores";
+import { SettingsPageHeader } from "../components/SettingsPageheader";
+import { Button } from "../components/Button";
+import { keys, modifiers } from "../keyboardMappings";
+import { useJsonRpc } from "../hooks/useJsonRpc";
+import notifications from "../notifications";
+import { SettingsItem } from "../routes/devices.$id.settings";
+
+const DEFAULT_DELAY = 50;
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface KeyOption {
+  value: string;
+  label: string;
+}
+
+interface KeyOptionData {
+  value: string | null;
+  keys?: string[];
+  label?: string;
+}
+
+const generateId = () => {
+  return `macro-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
+};
+
+const keyOptions = Object.keys(keys).map(key => ({
+  value: key,
+  label: key,
+}));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+interface KeyComboboxProps {
+  stepIndex: number;
+  step: MacroStep;
+  onSelect: (option: KeyOptionData) => void;
+  query: string;
+  onQueryChange: (query: string) => void;
+  getFilteredOptions: () => KeyOption[];
+  disabled?: boolean;
+}
+
+function KeyCombobox({
+  onSelect,
+  query,
+  onQueryChange,
+  getFilteredOptions,
+  disabled = false,
+}: KeyComboboxProps) {
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  return (
+    <div className="relative w-full">
+      <Combobox immediate onChange={onSelect} disabled={disabled}>
+        {() => (
+          <>
+            <div className="relative">
+              <ComboboxInput
+                ref={inputRef}
+                className={`macro-input ${disabled ? 'opacity-60 cursor-not-allowed' : ''}`}
+                placeholder={disabled ? "Max keys reached" : "Search for key..."}
+                displayValue={() => query}
+                onChange={(event) => onQueryChange(event.target.value)}
+                disabled={disabled}
+              />
+            </div>
+            
+            <ComboboxOptions className="absolute left-0 z-50 mt-1 w-full max-h-60 overflow-auto rounded-md bg-white dark:bg-slate-800 py-1 text-sm shadow-lg">
+              {getFilteredOptions().map((option) => (
+                <ComboboxOption 
+                  key={option.value} 
+                  value={option}
+                  className="cursor-default select-none py-1.5 px-3 ui-active:bg-blue-100 ui-active:text-blue-900 dark:text-slate-300 dark:ui-active:bg-blue-900/40 dark:ui-active:text-blue-200"
+                >
+                  {option.label}
+                </ComboboxOption>
+              ))}
+              {getFilteredOptions().length === 0 && (
+                <div className="py-2 px-3 text-sm text-slate-500 dark:text-slate-400">
+                  No matching keys found
+                </div>
+              )}
+            </ComboboxOptions>
+          </>
+        )}
+      </Combobox>
+    </div>
+  );
+}
+
+const PRESET_DELAYS = [
+  { value: 50, label: "50ms" },
+  { value: 100, label: "100ms" },
+  { value: 200, label: "200ms" },
+  { value: 300, label: "300ms" },
+  { value: 500, label: "500ms" },
+  { value: 750, label: "750ms" },
+  { value: 1000, label: "1000ms" },
+  { value: 1500, label: "1500ms" },
+  { value: 2000, label: "2000ms" },
+];
+
+const MAX_STEPS_PER_MACRO = 10;
+const MAX_TOTAL_MACROS = 25;
+const MAX_KEYS_PER_STEP = 10;
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+// Helper function to normalize sort orders, ensuring they start at 1 and have no gaps
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  isDesktop: boolean;
+  onKeySelect: (option: KeyOptionData) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  getFilteredKeys: () => KeyOption[];
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+function MacroStepCard({
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: adamshiervani
body: Extract into file. We need to cut the size of this file to increase readability
created_at: 2025-04-01T23:09:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,1439 @@
+import { useState, useEffect, useRef, useCallback } from "react";
+import { LuPlus, LuTrash, LuX, LuPenLine, LuLoader, LuGripVertical, LuInfo } from "react-icons/lu";
+import { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
+
+import { KeySequence, useMacrosStore } from "../hooks/stores";
+import { SettingsPageHeader } from "../components/SettingsPageheader";
+import { Button } from "../components/Button";
+import { keys, modifiers } from "../keyboardMappings";
+import { useJsonRpc } from "../hooks/useJsonRpc";
+import notifications from "../notifications";
+import { SettingsItem } from "../routes/devices.$id.settings";
+
+const DEFAULT_DELAY = 50;
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface KeyOption {
+  value: string;
+  label: string;
+}
+
+interface KeyOptionData {
+  value: string | null;
+  keys?: string[];
+  label?: string;
+}
+
+const generateId = () => {
+  return `macro-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
+};
+
+const keyOptions = Object.keys(keys).map(key => ({
+  value: key,
+  label: key,
+}));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+interface KeyComboboxProps {
+  stepIndex: number;
+  step: MacroStep;
+  onSelect: (option: KeyOptionData) => void;
+  query: string;
+  onQueryChange: (query: string) => void;
+  getFilteredOptions: () => KeyOption[];
+  disabled?: boolean;
+}
+
+function KeyCombobox({
+  onSelect,
+  query,
+  onQueryChange,
+  getFilteredOptions,
+  disabled = false,
+}: KeyComboboxProps) {
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  return (
+    <div className="relative w-full">
+      <Combobox immediate onChange={onSelect} disabled={disabled}>
+        {() => (
+          <>
+            <div className="relative">
+              <ComboboxInput
+                ref={inputRef}
+                className={`macro-input ${disabled ? 'opacity-60 cursor-not-allowed' : ''}`}
+                placeholder={disabled ? "Max keys reached" : "Search for key..."}
+                displayValue={() => query}
+                onChange={(event) => onQueryChange(event.target.value)}
+                disabled={disabled}
+              />
+            </div>
+            
+            <ComboboxOptions className="absolute left-0 z-50 mt-1 w-full max-h-60 overflow-auto rounded-md bg-white dark:bg-slate-800 py-1 text-sm shadow-lg">
+              {getFilteredOptions().map((option) => (
+                <ComboboxOption 
+                  key={option.value} 
+                  value={option}
+                  className="cursor-default select-none py-1.5 px-3 ui-active:bg-blue-100 ui-active:text-blue-900 dark:text-slate-300 dark:ui-active:bg-blue-900/40 dark:ui-active:text-blue-200"
+                >
+                  {option.label}
+                </ComboboxOption>
+              ))}
+              {getFilteredOptions().length === 0 && (
+                <div className="py-2 px-3 text-sm text-slate-500 dark:text-slate-400">
+                  No matching keys found
+                </div>
+              )}
+            </ComboboxOptions>
+          </>
+        )}
+      </Combobox>
+    </div>
+  );
+}
+
+const PRESET_DELAYS = [
+  { value: 50, label: "50ms" },
+  { value: 100, label: "100ms" },
+  { value: 200, label: "200ms" },
+  { value: 300, label: "300ms" },
+  { value: 500, label: "500ms" },
+  { value: 750, label: "750ms" },
+  { value: 1000, label: "1000ms" },
+  { value: 1500, label: "1500ms" },
+  { value: 2000, label: "2000ms" },
+];
+
+const MAX_STEPS_PER_MACRO = 10;
+const MAX_TOTAL_MACROS = 25;
+const MAX_KEYS_PER_STEP = 10;
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+// Helper function to normalize sort orders, ensuring they start at 1 and have no gaps
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  isDesktop: boolean;
+  onKeySelect: (option: KeyOptionData) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  getFilteredKeys: () => KeyOption[];
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  getFilteredKeys,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  return (
+    <div className="macro-step-card rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 p-4 shadow-sm">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <button
+              type="button"
+              className="p-1 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400 disabled:opacity-50"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="m18 15-6-6-6 6"/>
+              </svg>
+            </button>
+            <button
+              type="button"
+              className="p-1 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400 disabled:opacity-50"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="m6 9 6 6 6-6"/>
+              </svg>
+            </button>
+          </div>
+          <span className="macro-step-number flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+        
+        <div className="flex items-center space-x-2">
+          {onDelete && (
+            <button
+              type="button"
+              className="flex items-center text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
+              onClick={onDelete}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="M3 6h18"></path>
+                <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
+                <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
+              </svg>
+              <span className="ml-1">Delete</span>
+            </button>
+          )}
+        </div>
+      </div>
+      
+      <div className="space-y-4 mt-2">
+        <div className="w-full flex flex-col gap-2">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Modifiers:
+          </label>
+          <div className="macro-modifiers-container inline-flex flex-wrap gap-3">
+            {Object.entries(groupedModifiers).map(([group, mods]) => (
+              <div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
+                <span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
+                  {group}
+                </span>
+                <div className="flex flex-wrap gap-1">
+                  {mods.map(option => (
+                    <label 
+                      key={option.value} 
+                      className={`flex items-center px-2 py-1 rounded border cursor-pointer text-xs font-medium transition-colors ${
+                        ensureArray(step.modifiers).includes(option.value) 
+                          ? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-200' 
+                          : 'bg-slate-100 border-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700'
+                      }`}
+                    >
+                      <input
+                        type="checkbox"
+                        className="sr-only"
+                        checked={ensureArray(step.modifiers).includes(option.value)}
+                        onChange={e => {
+                          const modifiersArray = ensureArray(step.modifiers);
+                          const newModifiers = e.target.checked
+                            ? [...modifiersArray, option.value]
+                            : modifiersArray.filter(m => m !== option.value);
+                          onModifierChange(newModifiers);
+                        }}
+                      />
+                      {option.label.split(' ')[1] || option.label}
+                    </label>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Keys:
+          </label>
+          
+          <div className="macro-key-group flex flex-wrap gap-1 mb-2">
+            {ensureArray(step.keys).map((key, keyIndex) => (
+              <span
+                key={keyIndex}
+                className="macro-key-badge inline-flex items-center rounded-md bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
+              >
+                {key}
+                <button
+                  type="button"
+                  className="ml-1 text-xs text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
+                  onClick={() => {
+                    const newKeys = ensureArray(step.keys).filter((_, i) => i !== keyIndex);
+                    onKeySelect({ value: null, keys: newKeys });
+                  }}
+                >
+                  ×
+                </button>
+              </span>
+            ))}
+          </div>
+
+          <KeyCombobox
+            stepIndex={stepIndex}
+            step={step}
+            onSelect={onKeySelect}
+            query={keyQuery}
+            onQueryChange={onKeyQueryChange}
+            getFilteredOptions={getFilteredKeys}
+            disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP}
+          />
+          
+          {ensureArray(step.keys).length >= MAX_KEYS_PER_STEP && (
+            <span className="text-xs text-amber-600 dark:text-amber-400 mt-1">
+              (max keys reached)
+            </span>
+          )}
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+              Step Duration:
+            </label>
+            <div className="group relative">
+              <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+              <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                <p>The time to wait after pressing the keys in this step before moving to the next step. This helps ensure reliable key presses when automating keyboard input.</p>
+              </div>
+            </div>
+          </div>
+          <div className="flex items-center gap-3">
+            <select
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: adamshiervani
body: Use existing component
created_at: 2025-04-01T23:11:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,1439 @@
+import { useState, useEffect, useRef, useCallback } from "react";
+import { LuPlus, LuTrash, LuX, LuPenLine, LuLoader, LuGripVertical, LuInfo } from "react-icons/lu";
+import { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
+
+import { KeySequence, useMacrosStore } from "../hooks/stores";
+import { SettingsPageHeader } from "../components/SettingsPageheader";
+import { Button } from "../components/Button";
+import { keys, modifiers } from "../keyboardMappings";
+import { useJsonRpc } from "../hooks/useJsonRpc";
+import notifications from "../notifications";
+import { SettingsItem } from "../routes/devices.$id.settings";
+
+const DEFAULT_DELAY = 50;
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface KeyOption {
+  value: string;
+  label: string;
+}
+
+interface KeyOptionData {
+  value: string | null;
+  keys?: string[];
+  label?: string;
+}
+
+const generateId = () => {
+  return `macro-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
+};
+
+const keyOptions = Object.keys(keys).map(key => ({
+  value: key,
+  label: key,
+}));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+interface KeyComboboxProps {
+  stepIndex: number;
+  step: MacroStep;
+  onSelect: (option: KeyOptionData) => void;
+  query: string;
+  onQueryChange: (query: string) => void;
+  getFilteredOptions: () => KeyOption[];
+  disabled?: boolean;
+}
+
+function KeyCombobox({
+  onSelect,
+  query,
+  onQueryChange,
+  getFilteredOptions,
+  disabled = false,
+}: KeyComboboxProps) {
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  return (
+    <div className="relative w-full">
+      <Combobox immediate onChange={onSelect} disabled={disabled}>
+        {() => (
+          <>
+            <div className="relative">
+              <ComboboxInput
+                ref={inputRef}
+                className={`macro-input ${disabled ? 'opacity-60 cursor-not-allowed' : ''}`}
+                placeholder={disabled ? "Max keys reached" : "Search for key..."}
+                displayValue={() => query}
+                onChange={(event) => onQueryChange(event.target.value)}
+                disabled={disabled}
+              />
+            </div>
+            
+            <ComboboxOptions className="absolute left-0 z-50 mt-1 w-full max-h-60 overflow-auto rounded-md bg-white dark:bg-slate-800 py-1 text-sm shadow-lg">
+              {getFilteredOptions().map((option) => (
+                <ComboboxOption 
+                  key={option.value} 
+                  value={option}
+                  className="cursor-default select-none py-1.5 px-3 ui-active:bg-blue-100 ui-active:text-blue-900 dark:text-slate-300 dark:ui-active:bg-blue-900/40 dark:ui-active:text-blue-200"
+                >
+                  {option.label}
+                </ComboboxOption>
+              ))}
+              {getFilteredOptions().length === 0 && (
+                <div className="py-2 px-3 text-sm text-slate-500 dark:text-slate-400">
+                  No matching keys found
+                </div>
+              )}
+            </ComboboxOptions>
+          </>
+        )}
+      </Combobox>
+    </div>
+  );
+}
+
+const PRESET_DELAYS = [
+  { value: 50, label: "50ms" },
+  { value: 100, label: "100ms" },
+  { value: 200, label: "200ms" },
+  { value: 300, label: "300ms" },
+  { value: 500, label: "500ms" },
+  { value: 750, label: "750ms" },
+  { value: 1000, label: "1000ms" },
+  { value: 1500, label: "1500ms" },
+  { value: 2000, label: "2000ms" },
+];
+
+const MAX_STEPS_PER_MACRO = 10;
+const MAX_TOTAL_MACROS = 25;
+const MAX_KEYS_PER_STEP = 10;
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+// Helper function to normalize sort orders, ensuring they start at 1 and have no gaps
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  isDesktop: boolean;
+  onKeySelect: (option: KeyOptionData) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  getFilteredKeys: () => KeyOption[];
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  getFilteredKeys,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  return (
+    <div className="macro-step-card rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 p-4 shadow-sm">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <button
+              type="button"
+              className="p-1 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400 disabled:opacity-50"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="m18 15-6-6-6 6"/>
+              </svg>
+            </button>
+            <button
+              type="button"
+              className="p-1 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400 disabled:opacity-50"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="m6 9 6 6 6-6"/>
+              </svg>
+            </button>
+          </div>
+          <span className="macro-step-number flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+        
+        <div className="flex items-center space-x-2">
+          {onDelete && (
+            <button
+              type="button"
+              className="flex items-center text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
+              onClick={onDelete}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="M3 6h18"></path>
+                <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
+                <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
+              </svg>
+              <span className="ml-1">Delete</span>
+            </button>
+          )}
+        </div>
+      </div>
+      
+      <div className="space-y-4 mt-2">
+        <div className="w-full flex flex-col gap-2">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Modifiers:
+          </label>
+          <div className="macro-modifiers-container inline-flex flex-wrap gap-3">
+            {Object.entries(groupedModifiers).map(([group, mods]) => (
+              <div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
+                <span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
+                  {group}
+                </span>
+                <div className="flex flex-wrap gap-1">
+                  {mods.map(option => (
+                    <label 
+                      key={option.value} 
+                      className={`flex items-center px-2 py-1 rounded border cursor-pointer text-xs font-medium transition-colors ${
+                        ensureArray(step.modifiers).includes(option.value) 
+                          ? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-200' 
+                          : 'bg-slate-100 border-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700'
+                      }`}
+                    >
+                      <input
+                        type="checkbox"
+                        className="sr-only"
+                        checked={ensureArray(step.modifiers).includes(option.value)}
+                        onChange={e => {
+                          const modifiersArray = ensureArray(step.modifiers);
+                          const newModifiers = e.target.checked
+                            ? [...modifiersArray, option.value]
+                            : modifiersArray.filter(m => m !== option.value);
+                          onModifierChange(newModifiers);
+                        }}
+                      />
+                      {option.label.split(' ')[1] || option.label}
+                    </label>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Keys:
+          </label>
+          
+          <div className="macro-key-group flex flex-wrap gap-1 mb-2">
+            {ensureArray(step.keys).map((key, keyIndex) => (
+              <span
+                key={keyIndex}
+                className="macro-key-badge inline-flex items-center rounded-md bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
+              >
+                {key}
+                <button
+                  type="button"
+                  className="ml-1 text-xs text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
+                  onClick={() => {
+                    const newKeys = ensureArray(step.keys).filter((_, i) => i !== keyIndex);
+                    onKeySelect({ value: null, keys: newKeys });
+                  }}
+                >
+                  ×
+                </button>
+              </span>
+            ))}
+          </div>
+
+          <KeyCombobox
+            stepIndex={stepIndex}
+            step={step}
+            onSelect={onKeySelect}
+            query={keyQuery}
+            onQueryChange={onKeyQueryChange}
+            getFilteredOptions={getFilteredKeys}
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: adamshiervani
body: It took me some time to understand the logic behind this dropdown when using the UI for the first time. 

Add a map for the label in the Combobox. `KeyA` should be `A`, `NumPad0` should be `Num Pad 0` etc...
created_at: 2025-04-01T23:15:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,1439 @@
+import { useState, useEffect, useRef, useCallback } from "react";
+import { LuPlus, LuTrash, LuX, LuPenLine, LuLoader, LuGripVertical, LuInfo } from "react-icons/lu";
+import { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
+
+import { KeySequence, useMacrosStore } from "../hooks/stores";
+import { SettingsPageHeader } from "../components/SettingsPageheader";
+import { Button } from "../components/Button";
+import { keys, modifiers } from "../keyboardMappings";
+import { useJsonRpc } from "../hooks/useJsonRpc";
+import notifications from "../notifications";
+import { SettingsItem } from "../routes/devices.$id.settings";
+
+const DEFAULT_DELAY = 50;
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface KeyOption {
+  value: string;
+  label: string;
+}
+
+interface KeyOptionData {
+  value: string | null;
+  keys?: string[];
+  label?: string;
+}
+
+const generateId = () => {
+  return `macro-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
+};
+
+const keyOptions = Object.keys(keys).map(key => ({
+  value: key,
+  label: key,
+}));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+interface KeyComboboxProps {
+  stepIndex: number;
+  step: MacroStep;
+  onSelect: (option: KeyOptionData) => void;
+  query: string;
+  onQueryChange: (query: string) => void;
+  getFilteredOptions: () => KeyOption[];
+  disabled?: boolean;
+}
+
+function KeyCombobox({
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: adamshiervani
body: Look at the existing Input Field component or Select Menu, and adopt the same interface and styling. This should feel like that, just have a "dropdown". We should be able to use this elsewhere too.
created_at: 2025-04-01T23:17:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,1439 @@
+import { useState, useEffect, useRef, useCallback } from "react";
+import { LuPlus, LuTrash, LuX, LuPenLine, LuLoader, LuGripVertical, LuInfo } from "react-icons/lu";
+import { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
+
+import { KeySequence, useMacrosStore } from "../hooks/stores";
+import { SettingsPageHeader } from "../components/SettingsPageheader";
+import { Button } from "../components/Button";
+import { keys, modifiers } from "../keyboardMappings";
+import { useJsonRpc } from "../hooks/useJsonRpc";
+import notifications from "../notifications";
+import { SettingsItem } from "../routes/devices.$id.settings";
+
+const DEFAULT_DELAY = 50;
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface KeyOption {
+  value: string;
+  label: string;
+}
+
+interface KeyOptionData {
+  value: string | null;
+  keys?: string[];
+  label?: string;
+}
+
+const generateId = () => {
+  return `macro-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
+};
+
+const keyOptions = Object.keys(keys).map(key => ({
+  value: key,
+  label: key,
+}));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+interface KeyComboboxProps {
+  stepIndex: number;
+  step: MacroStep;
+  onSelect: (option: KeyOptionData) => void;
+  query: string;
+  onQueryChange: (query: string) => void;
+  getFilteredOptions: () => KeyOption[];
+  disabled?: boolean;
+}
+
+function KeyCombobox({
+  onSelect,
+  query,
+  onQueryChange,
+  getFilteredOptions,
+  disabled = false,
+}: KeyComboboxProps) {
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  return (
+    <div className="relative w-full">
+      <Combobox immediate onChange={onSelect} disabled={disabled}>
+        {() => (
+          <>
+            <div className="relative">
+              <ComboboxInput
+                ref={inputRef}
+                className={`macro-input ${disabled ? 'opacity-60 cursor-not-allowed' : ''}`}
+                placeholder={disabled ? "Max keys reached" : "Search for key..."}
+                displayValue={() => query}
+                onChange={(event) => onQueryChange(event.target.value)}
+                disabled={disabled}
+              />
+            </div>
+            
+            <ComboboxOptions className="absolute left-0 z-50 mt-1 w-full max-h-60 overflow-auto rounded-md bg-white dark:bg-slate-800 py-1 text-sm shadow-lg">
+              {getFilteredOptions().map((option) => (
+                <ComboboxOption 
+                  key={option.value} 
+                  value={option}
+                  className="cursor-default select-none py-1.5 px-3 ui-active:bg-blue-100 ui-active:text-blue-900 dark:text-slate-300 dark:ui-active:bg-blue-900/40 dark:ui-active:text-blue-200"
+                >
+                  {option.label}
+                </ComboboxOption>
+              ))}
+              {getFilteredOptions().length === 0 && (
+                <div className="py-2 px-3 text-sm text-slate-500 dark:text-slate-400">
+                  No matching keys found
+                </div>
+              )}
+            </ComboboxOptions>
+          </>
+        )}
+      </Combobox>
+    </div>
+  );
+}
+
+const PRESET_DELAYS = [
+  { value: 50, label: "50ms" },
+  { value: 100, label: "100ms" },
+  { value: 200, label: "200ms" },
+  { value: 300, label: "300ms" },
+  { value: 500, label: "500ms" },
+  { value: 750, label: "750ms" },
+  { value: 1000, label: "1000ms" },
+  { value: 1500, label: "1500ms" },
+  { value: 2000, label: "2000ms" },
+];
+
+const MAX_STEPS_PER_MACRO = 10;
+const MAX_TOTAL_MACROS = 25;
+const MAX_KEYS_PER_STEP = 10;
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+// Helper function to normalize sort orders, ensuring they start at 1 and have no gaps
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  isDesktop: boolean;
+  onKeySelect: (option: KeyOptionData) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  getFilteredKeys: () => KeyOption[];
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  getFilteredKeys,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  return (
+    <div className="macro-step-card rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 p-4 shadow-sm">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <button
+              type="button"
+              className="p-1 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400 disabled:opacity-50"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="m18 15-6-6-6 6"/>
+              </svg>
+            </button>
+            <button
+              type="button"
+              className="p-1 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400 disabled:opacity-50"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="m6 9 6 6 6-6"/>
+              </svg>
+            </button>
+          </div>
+          <span className="macro-step-number flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+        
+        <div className="flex items-center space-x-2">
+          {onDelete && (
+            <button
+              type="button"
+              className="flex items-center text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
+              onClick={onDelete}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="M3 6h18"></path>
+                <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
+                <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
+              </svg>
+              <span className="ml-1">Delete</span>
+            </button>
+          )}
+        </div>
+      </div>
+      
+      <div className="space-y-4 mt-2">
+        <div className="w-full flex flex-col gap-2">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Modifiers:
+          </label>
+          <div className="macro-modifiers-container inline-flex flex-wrap gap-3">
+            {Object.entries(groupedModifiers).map(([group, mods]) => (
+              <div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
+                <span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
+                  {group}
+                </span>
+                <div className="flex flex-wrap gap-1">
+                  {mods.map(option => (
+                    <label 
+                      key={option.value} 
+                      className={`flex items-center px-2 py-1 rounded border cursor-pointer text-xs font-medium transition-colors ${
+                        ensureArray(step.modifiers).includes(option.value) 
+                          ? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-200' 
+                          : 'bg-slate-100 border-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700'
+                      }`}
+                    >
+                      <input
+                        type="checkbox"
+                        className="sr-only"
+                        checked={ensureArray(step.modifiers).includes(option.value)}
+                        onChange={e => {
+                          const modifiersArray = ensureArray(step.modifiers);
+                          const newModifiers = e.target.checked
+                            ? [...modifiersArray, option.value]
+                            : modifiersArray.filter(m => m !== option.value);
+                          onModifierChange(newModifiers);
+                        }}
+                      />
+                      {option.label.split(' ')[1] || option.label}
+                    </label>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Keys:
+          </label>
+          
+          <div className="macro-key-group flex flex-wrap gap-1 mb-2">
+            {ensureArray(step.keys).map((key, keyIndex) => (
+              <span
+                key={keyIndex}
+                className="macro-key-badge inline-flex items-center rounded-md bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
+              >
+                {key}
+                <button
+                  type="button"
+                  className="ml-1 text-xs text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
+                  onClick={() => {
+                    const newKeys = ensureArray(step.keys).filter((_, i) => i !== keyIndex);
+                    onKeySelect({ value: null, keys: newKeys });
+                  }}
+                >
+                  ×
+                </button>
+              </span>
+            ))}
+          </div>
+
+          <KeyCombobox
+            stepIndex={stepIndex}
+            step={step}
+            onSelect={onKeySelect}
+            query={keyQuery}
+            onQueryChange={onKeyQueryChange}
+            getFilteredOptions={getFilteredKeys}
+            disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP}
+          />
+          
+          {ensureArray(step.keys).length >= MAX_KEYS_PER_STEP && (
+            <span className="text-xs text-amber-600 dark:text-amber-400 mt-1">
+              (max keys reached)
+            </span>
+          )}
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+              Step Duration:
+            </label>
+            <div className="group relative">
+              <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+              <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                <p>The time to wait after pressing the keys in this step before moving to the next step. This helps ensure reliable key presses when automating keyboard input.</p>
+              </div>
+            </div>
+          </div>
+          <div className="flex items-center gap-3">
+            <select
+              className="w-full rounded-md border border-slate-300 bg-slate-50 p-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
+              value={step.delay}
+              onChange={(e) => onDelayChange(parseInt(e.target.value, 10))}
+            >
+              {PRESET_DELAYS.map((option) => (
+                <option key={option.value} value={option.value}>
+                  {option.label}
+                </option>
+              ))}
+            </select>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// Helper to update step keys used by both new and edit flows
+const updateStepKeys = (
+  steps: MacroStep[],
+  stepIndex: number,
+  keyOption: { value: string | null; keys?: string[] },
+  showTemporaryError: (msg: string) => void
+) => {
+  const newSteps = [...steps];
+  if (keyOption.keys) {
+    newSteps[stepIndex].keys = keyOption.keys;
+  } else if (keyOption.value) {
+    if (!newSteps[stepIndex].keys) {
+      newSteps[stepIndex].keys = [];
+    }
+    const keysArray = ensureArray(newSteps[stepIndex].keys);
+    if (keysArray.length >= MAX_KEYS_PER_STEP) {
+      showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`);
+      return newSteps;
+    }
+    newSteps[stepIndex].keys = [...keysArray, keyOption.value];
+  }
+  return newSteps;
+};
+
+const useTouchSort = (items: KeySequence[], onSort: (newItems: KeySequence[]) => void) => {
+  const [touchStartY, setTouchStartY] = useState<number | null>(null);
+  const [touchedIndex, setTouchedIndex] = useState<number | null>(null);
+
+  const handleTouchStart = useCallback((e: React.TouchEvent, index: number) => {
+    const touch = e.touches[0];
+    setTouchStartY(touch.clientY);
+    setTouchedIndex(index);
+    
+    const element = e.currentTarget as HTMLElement;
+    const rect = element.getBoundingClientRect();
+    
+    // Create ghost element
+    const ghost = element.cloneNode(true) as HTMLElement;
+    ghost.id = 'ghost-macro';
+    ghost.className = 'macro-sortable ghost';
+    ghost.style.height = `${rect.height}px`;
+    element.parentNode?.insertBefore(ghost, element);
+    
+    // Set up dragged element
+    element.style.position = 'fixed';
+    element.style.left = `${rect.left}px`;
+    element.style.top = `${rect.top}px`;
+    element.style.width = `${rect.width}px`;
+    element.style.zIndex = '50';
+  }, []);
+
+  const handleTouchMove = useCallback((e: React.TouchEvent) => {
+    if (touchStartY === null || touchedIndex === null) return;
+    
+    const touch = e.touches[0];
+    const deltaY = touch.clientY - touchStartY;
+    const element = e.currentTarget as HTMLElement;
+    
+    // Smooth movement of dragged element
+    element.style.transform = `translateY(${deltaY}px)`;
+    
+    const macroElements = document.querySelectorAll('[data-macro-item]');
+    const draggedRect = element.getBoundingClientRect();
+    const draggedMiddle = draggedRect.top + draggedRect.height / 2;
+    
+    macroElements.forEach((el, i) => {
+      if (i === touchedIndex) return;
+      
+      const rect = el.getBoundingClientRect();
+      const elementMiddle = rect.top + rect.height / 2;
+      const distance = Math.abs(draggedMiddle - elementMiddle);
+      
+      if (distance < rect.height) {
+        const direction = draggedMiddle > elementMiddle ? -1 : 1;
+        (el as HTMLElement).style.transform = `translateY(${direction * rect.height}px)`;
+        (el as HTMLElement).style.transition = 'transform 0.15s ease-out';
+      } else {
+        (el as HTMLElement).style.transform = '';
+        (el as HTMLElement).style.transition = 'transform 0.15s ease-out';
+      }
+    });
+  }, [touchStartY, touchedIndex]);
+
+  const handleTouchEnd = useCallback(async (e: React.TouchEvent) => {
+    if (touchedIndex === null) return;
+    
+    const element = e.currentTarget as HTMLElement;
+    const touch = e.changedTouches[0];
+    
+    // Remove ghost element
+    const ghost = document.getElementById('ghost-macro');
+    ghost?.parentNode?.removeChild(ghost);
+    
+    // Reset dragged element styles
+    element.style.position = '';
+    element.style.left = '';
+    element.style.top = '';
+    element.style.width = '';
+    element.style.zIndex = '';
+    element.style.transform = '';
+    element.style.boxShadow = '';
+    element.style.transition = '';
+    
+    const macroElements = document.querySelectorAll('[data-macro-item]');
+    let targetIndex = touchedIndex;
+    
+    // Find the closest element to the final touch position
+    const finalY = touch.clientY;
+    let closestDistance = Infinity;
+    
+    macroElements.forEach((el, i) => {
+      if (i === touchedIndex) return;
+      
+      const rect = el.getBoundingClientRect();
+      const distance = Math.abs(finalY - (rect.top + rect.height / 2));
+      
+      if (distance < closestDistance) {
+        closestDistance = distance;
+        targetIndex = i;
+      }
+      
+      // Reset other elements
+      (el as HTMLElement).style.transform = '';
+      (el as HTMLElement).style.transition = '';
+    });
+    
+    if (targetIndex !== touchedIndex && closestDistance < 50) {
+      const itemsCopy = [...items];
+      const [draggedItem] = itemsCopy.splice(touchedIndex, 1);
+      itemsCopy.splice(targetIndex, 0, draggedItem);
+      onSort(itemsCopy);
+    }
+    
+    setTouchStartY(null);
+    setTouchedIndex(null);
+  }, [touchedIndex, items, onSort]);
+
+  return { handleTouchStart, handleTouchMove, handleTouchEnd };
+};
+
+interface StepError {
+  keys?: string;
+  modifiers?: string;
+  delay?: string;
+}
+
+interface ValidationErrors {
+  name?: string;
+  description?: string;
+  steps?: Record<number, StepError>;
+}
+
+export default function SettingsMacrosRoute() {
+  const { macros, loading, initialized, loadMacros, saveMacros, setSendFn } = useMacrosStore();
+  const [editingMacro, setEditingMacro] = useState<KeySequence | null>(null);
+  const [newMacro, setNewMacro] = useState<Partial<KeySequence>>({
+    name: "",
+    description: "",
+    steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }],
+  });
+  const [isDragging, setIsDragging] = useState(false);
+  const dragItem = useRef<number | null>(null);
+  const dragOverItem = useRef<number | null>(null);
+  
+  const [macroToDelete, setMacroToDelete] = useState<string | null>(null);
+  
+  const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
+  const [editKeyQueries, setEditKeyQueries] = useState<Record<number, string>>({});
+  
+  const [errorMessage, setErrorMessage] = useState<string | null>(null);
+  const [isDesktop, setIsDesktop] = useState(window.innerWidth >= 768);
+  
+  const [send] = useJsonRpc();
+
+  const isMaxMacrosReached = macros.length >= MAX_TOTAL_MACROS;
+  const isMaxStepsReachedForNewMacro = (newMacro.steps?.length || 0) >= MAX_STEPS_PER_MACRO;
+  
+  const showTemporaryError = useCallback((message: string) => {
+    setErrorMessage(message);
+    setTimeout(() => setErrorMessage(null), 3000);
+  }, []);
+  
+  // Helper for both new and edit key select
+  const handleKeySelectUpdate = (stepIndex: number, option: KeyOptionData, isEditing = false) => {
+    if (isEditing && editingMacro) {
+      const updatedSteps = updateStepKeys(editingMacro.steps, stepIndex, option, showTemporaryError);
+      setEditingMacro({ ...editingMacro, steps: updatedSteps });
+    } else {
+      const updatedSteps = updateStepKeys(newMacro.steps || [], stepIndex, option, showTemporaryError);
+      setNewMacro({ ...newMacro, steps: updatedSteps });
+    }
+  };
+  
+  const handleKeySelect = (stepIndex: number, option: KeyOptionData) => {
+    handleKeySelectUpdate(stepIndex, option, false);
+  };
+  
+  const handleEditKeySelect = (stepIndex: number, option: KeyOptionData) => {
+    handleKeySelectUpdate(stepIndex, option, true);
+  };
+  
+  const handleKeyQueryChange = (stepIndex: number, query: string) => {
+    setKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
+  };
+  
+  const handleEditKeyQueryChange = (stepIndex: number, query: string) => {
+    setEditKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
+  };
+  
+  const getFilteredKeys = (stepIndex: number, isEditing = false) => {
+    const query = isEditing 
+      ? (editKeyQueries[stepIndex] || '')
+      : (keyQueries[stepIndex] || '');
+    
+    if (query === '') {
+      return keyOptions;
+    } else {
+      return keyOptions.filter(option => option.label.toLowerCase().includes(query.toLowerCase()));
+    }
+  };
+  
+  useEffect(() => {
+    setSendFn(send);
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros, setSendFn, send]);
+  
+  const [errors, setErrors] = useState<ValidationErrors>({});
+  
+  const clearErrors = useCallback(() => {
+    setErrors({});
+  }, []);
+  
+  const validateMacro = (macro: Partial<KeySequence>): ValidationErrors => {
+    const errors: ValidationErrors = {};
+
+    // Name validation
+    if (!macro.name?.trim()) {
+      errors.name = "Name is required";
+    } else if (macro.name.trim().length > 50) {
+      errors.name = "Name must be less than 50 characters";
+    }
+
+    // Description validation (optional)
+    if (macro.description && macro.description.trim().length > 200) {
+      errors.description = "Description must be less than 200 characters";
+    }
+
+    // Steps validation
+    if (!macro.steps?.length) {
+      errors.steps = { 0: { keys: "At least one step is required" } };
+      return errors;
+    }
+
+    // Check if at least one step has keys or modifiers
+    const hasKeyOrModifier = macro.steps.some(step => 
+      (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0
+    );
+
+    if (!hasKeyOrModifier) {
+      errors.steps = { 0: { keys: "At least one step must have keys or modifiers" } };
+      return errors;
+    }
+
+    const stepErrors: Record<number, StepError> = {};
+    
+    macro.steps.forEach((step, index) => {
+      const stepError: StepError = {};
+
+      // Keys validation (only if keys are present)
+      if (step.keys?.length && step.keys.length > MAX_KEYS_PER_STEP) {
+        stepError.keys = `Maximum ${MAX_KEYS_PER_STEP} keys allowed`;
+      }
+
+      // Delay validation
+      if (typeof step.delay !== 'number' || step.delay < 0) {
+        stepError.delay = "Invalid delay value";
+      }
+
+      if (Object.keys(stepError).length > 0) {
+        stepErrors[index] = stepError;
+      }
+    });
+
+    if (Object.keys(stepErrors).length > 0) {
+      errors.steps = stepErrors;
+    }
+
+    return errors;
+  };
+
+  const resetNewMacro = () => {
+    setNewMacro({
+      name: "",
+      description: "",
+      steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }],
+    });
+    setKeyQueries({});
+    setErrors({});
+  };
+
+  const [isSaving, setIsSaving] = useState(false);
+  const [isUpdating, setIsUpdating] = useState(false);
+  const [isDeleting, setIsDeleting] = useState(false);
+
+  const handleAddMacro = useCallback(async () => {
+    if (isMaxMacrosReached) {
+      showTemporaryError(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    const validationErrors = validateMacro(newMacro);
+    if (Object.keys(validationErrors).length > 0) {
+      setErrors(validationErrors);
+      return;
+    }
+
+    setIsSaving(true);
+    try {
+      const macro: KeySequence = {
+        id: generateId(),
+        name: newMacro.name!.trim(),
+        description: newMacro.description?.trim() || "",
+        steps: newMacro.steps || [],
+        sortOrder: macros.length + 1,
+      };
+
+      await saveMacros(normalizeSortOrders([...macros, macro]));
+      resetNewMacro();
+      setShowAddMacro(false);
+      notifications.success(`Macro "${macro.name}" created successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to create macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to create macro");
+        showTemporaryError("Failed to save macro");
+      }
+    } finally {
+      setIsSaving(false);
+    }
+  }, [isMaxMacrosReached, newMacro, macros, saveMacros, showTemporaryError]);
+
+  const handleDragStart = (index: number) => {
+    dragItem.current = index;
+    setIsDragging(true);
+    
+    const allItems = document.querySelectorAll('[data-macro-item]');
+    const draggedElement = allItems[index];
+    if (draggedElement) {
+      draggedElement.classList.add('dragging');
+    }
+  };
+  
+  const handleDragOver = (e: React.DragEvent, index: number) => {
+    e.preventDefault();
+    dragOverItem.current = index;
+    
+    const allItems = document.querySelectorAll('[data-macro-item]');
+    allItems.forEach(el => el.classList.remove('drop-target'));
+    
+    const targetElement = allItems[index];
+    if (targetElement) {
+      targetElement.classList.add('drop-target');
+    }
+  };
+  
+  const handleDrop = async (e: React.DragEvent) => {
+    e.preventDefault();
+    if (dragItem.current === null || dragOverItem.current === null) return;
+    
+    const macroCopy = [...macros];
+    const draggedItem = macroCopy.splice(dragItem.current, 1)[0];
+    macroCopy.splice(dragOverItem.current, 0, draggedItem);
+    const updatedMacros = normalizeSortOrders(macroCopy);
+
+    try {
+      await saveMacros(updatedMacros);
+      notifications.success("Macro order updated successfully");
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to reorder macros: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to reorder macros");
+        showTemporaryError("Failed to save reordered macros");
+      }
+    }
+    
+    const allItems = document.querySelectorAll('[data-macro-item]');
+    allItems.forEach(el => {
+      el.classList.remove('drop-target');
+      el.classList.remove('dragging');
+    });
+    
+    dragItem.current = null;
+    dragOverItem.current = null;
+    setIsDragging(false);
+  };
+
+  const handleUpdateMacro = useCallback(async () => {
+    if (!editingMacro) return;
+
+    const validationErrors = validateMacro(editingMacro);
+    if (Object.keys(validationErrors).length > 0) {
+      setErrors(validationErrors);
+      return;
+    }
+
+    setIsUpdating(true);
+    try {
+      const newMacros = macros.map(m => 
+        m.id === editingMacro.id ? {
+          ...editingMacro,
+          name: editingMacro.name.trim(),
+          description: editingMacro.description?.trim() || "",
+        } : m
+      );
+
+      await saveMacros(normalizeSortOrders(newMacros));
+      setEditingMacro(null);
+      clearErrors();
+      notifications.success(`Macro "${editingMacro.name}" updated successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to update macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to update macro");
+        showTemporaryError("Failed to update macro");
+      }
+    } finally {
+      setIsUpdating(false);
+    }
+  }, [editingMacro, macros, saveMacros, showTemporaryError, clearErrors]);
+
+  const handleEditMacro = (macro: KeySequence) => {
+    setEditingMacro({
+      ...macro,
+      description: macro.description || "",
+      steps: macro.steps.map(step => ({
+        ...step,
+        keys: ensureArray(step.keys),
+        modifiers: ensureArray(step.modifiers),
+        delay: typeof step.delay === 'number' ? step.delay : DEFAULT_DELAY
+      }))
+    });
+    clearErrors();
+    setEditKeyQueries({});
+  };
+
+  const handleDeleteMacro = async (id: string) => {
+    const macroToBeDeleted = macros.find(m => m.id === id);
+    if (!macroToBeDeleted) return;
+
+    setIsDeleting(true);
+    try {
+      const updatedMacros = normalizeSortOrders(macros.filter(macro => macro.id !== id));
+      await saveMacros(updatedMacros);
+      if (editingMacro?.id === id) {
+        setEditingMacro(null);
+      }
+      setMacroToDelete(null);
+      notifications.success(`Macro "${macroToBeDeleted.name}" deleted successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to delete macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to delete macro");
+        showTemporaryError("Failed to delete macro");
+      }
+    } finally {
+      setIsDeleting(false);
+    }
+  };
+
+  const handleDuplicateMacro = async (macro: KeySequence) => {
+    if (isMaxMacrosReached) {
+      showTemporaryError(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    const newMacroCopy: KeySequence = {
+      ...JSON.parse(JSON.stringify(macro)),
+      id: generateId(),
+      name: `${macro.name} (copy)`,
+      sortOrder: macros.length + 1,
+    };
+
+    newMacroCopy.steps = newMacroCopy.steps.map(step => ({
+      ...step,
+      keys: ensureArray(step.keys),
+      modifiers: ensureArray(step.modifiers),
+      delay: step.delay || DEFAULT_DELAY
+    }));
+
+    try {
+      await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
+      notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to duplicate macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to duplicate macro");
+        showTemporaryError("Failed to duplicate macro");
+      }
+    }
+  };
+
+  const handleStepMove = (stepIndex: number, direction: 'up' | 'down', steps: MacroStep[]) => {
+    const newSteps = [...steps];
+    const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
+    [newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]];
+    return newSteps;
+  };
+
+  useEffect(() => {
+    const handleResize = () => {
+      setIsDesktop(window.innerWidth >= 768);
+    };
+    
+    window.addEventListener('resize', handleResize);
+    return () => window.removeEventListener('resize', handleResize);
+  }, []);
+
+  const { handleTouchStart, handleTouchMove, handleTouchEnd } = useTouchSort(
+    macros,
+    async (newMacros) => {
+      const updatedMacros = normalizeSortOrders(newMacros);
+      try {
+        await saveMacros(updatedMacros);
+        notifications.success("Macro order updated successfully");
+      } catch (error) {
+        if (error instanceof Error) {
+          notifications.error(`Failed to reorder macros: ${error.message}`);
+          showTemporaryError(error.message);
+        } else {
+          notifications.error("Failed to reorder macros");
+          showTemporaryError("Failed to save reordered macros");
+        }
+      }
+    }
+  );
+
+  const [showClearConfirm, setShowClearConfirm] = useState(false);
+  const [showAddMacro, setShowAddMacro] = useState(false);
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && editingMacro) {
+        setEditingMacro(null);
+        setErrors({});
+      }
+      if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
+        if (editingMacro) {
+          handleUpdateMacro();
+        } else if (!isMaxMacrosReached) {
+          handleAddMacro();
+        }
+      }
+    };
+
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [editingMacro, isMaxMacrosReached, handleAddMacro, handleUpdateMacro]);
+
+  const handleModifierChange = (stepIndex: number, modifiers: string[]) => {
+    if (editingMacro) {
+      const newSteps = [...editingMacro.steps];
+      newSteps[stepIndex].modifiers = modifiers;
+      setEditingMacro({ ...editingMacro, steps: newSteps });
+    } else {
+      const newSteps = [...(newMacro.steps || [])];
+      newSteps[stepIndex].modifiers = modifiers;
+      setNewMacro({ ...newMacro, steps: newSteps });
+    }
+  };
+
+  const handleDelayChange = (stepIndex: number, delay: number) => {
+    if (editingMacro) {
+      const newSteps = [...editingMacro.steps];
+      newSteps[stepIndex].delay = delay;
+      setEditingMacro({ ...editingMacro, steps: newSteps });
+    } else {
+      const newSteps = [...(newMacro.steps || [])];
+      newSteps[stepIndex].delay = delay;
+      setNewMacro({ ...newMacro, steps: newSteps });
+    }
+  };
+
+  const ErrorMessage = ({ error }: { error?: string }) => {
+    if (!error) return null;
+    return (
+      <p className="mt-1 text-xs text-red-500 dark:text-red-400">
+        {error}
+      </p>
+    );
+  };
+
+  return (
+    <div className="space-y-4">
+      <SettingsPageHeader
+        title="Keyboard Macros"
+        description="Create and manage keyboard macros for quick actions"
+      />
+
+      {errorMessage && (
+        <div className="macro-error">
+          <div className="flex">
+            <div className="flex-shrink-0">
+              <svg className="macro-error-icon" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
+                <path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.414 1.414L10 11.414l1.72 1.72a.75.75 0 101.414-1.414L11.414 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.586 8.28 7.22z" clipRule="evenodd" />
+              </svg>
+            </div>
+            <div className="ml-3">
+              <h3 className="macro-error-text">{errorMessage}</h3>
+            </div>
+          </div>
+        </div>
+      )}
+
+      <div className="flex items-center justify-between mb-4">
+        <SettingsItem 
+          title="Macros"
+          description={`${macros.length}/${MAX_TOTAL_MACROS}`}
+        >
+          <div className="flex items-center gap-2">
+            {!showAddMacro && (
+              <Button
+                size="SM"
+                theme="primary"
+                text={isMaxMacrosReached ? `Max Reached` : "Add New Macro"}
+                onClick={() => setShowAddMacro(true)}
+                disabled={isMaxMacrosReached}
+              />
+            )}
+          </div>
+        </SettingsItem>
+      </div>
+
+      {loading && (
+        <div className="flex items-center justify-center p-8">
+          <LuLoader className="h-6 w-6 animate-spin text-blue-500" />
+        </div>
+      )}
+      <div className={`space-y-4 ${loading ? 'hidden' : ''}`}>
+        {showAddMacro && (
+          <div className="rounded-md border border-slate-200 bg-white p-3 dark:border-slate-700 dark:bg-slate-800">
+            <div className="flex items-center justify-between mb-2">
+              <h3 className="text-sm font-semibold text-black dark:text-white">Add New Macro</h3>
+            </div>
+            <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
+              <div className="flex flex-col">
+                <input
+                  type="text"
+                  className={`macro-input ${errors.name ? 'border-red-500 dark:border-red-500' : ''}`}
+                  value={newMacro.name}
+                  onChange={e => {
+                    setNewMacro(prev => ({ ...prev, name: e.target.value }));
+                    if (errors.name) {
+                      const newErrors = { ...errors };
+                      delete newErrors.name;
+                      setErrors(newErrors);
+                    }
+                  }}
+                  placeholder="Macro Name"
+                />
+                <ErrorMessage error={errors.name} />
+              </div>
+              <div className="flex flex-col">
+                <input
+                  type="text"
+                  className={`macro-input ${errors.description ? 'border-red-500 dark:border-red-500' : ''}`}
+                  value={newMacro.description}
+                  onChange={e => {
+                    setNewMacro(prev => ({ ...prev, description: e.target.value }));
+                    if (errors.description) {
+                      const newErrors = { ...errors };
+                      delete newErrors.description;
+                      setErrors(newErrors);
+                    }
+                  }}
+                  placeholder="Description (optional)"
+                />
+                <ErrorMessage error={errors.description} />
+              </div>
+            </div>
+            
+            <div className="mt-4">
+              <div className="macro-section-header">
+                <label className="macro-section-title">
+                  Steps:
+                </label>
+                <span className="macro-section-subtitle">
+                  {newMacro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps
+                </span>
+              </div>
+              {errors.steps && errors.steps[0]?.keys && (
+                <div className="mt-2">
+                  <ErrorMessage error={errors.steps[0].keys} />
+                </div>
+              )}
+              <div className="mt-2 text-xs text-slate-500 dark:text-slate-400">
+                You can add up to {MAX_STEPS_PER_MACRO} steps per macro
+              </div>
+              <div className="mt-2 space-y-4">
+                {(newMacro.steps || []).map((step, stepIndex) => (
+                  <MacroStepCard
+                    key={stepIndex}
+                    step={step}
+                    stepIndex={stepIndex}
+                    onDelete={newMacro.steps && newMacro.steps.length > 1 ? () => {
+                      const newSteps = [...(newMacro.steps || [])];
+                      newSteps.splice(stepIndex, 1);
+                      setNewMacro(prev => ({ ...prev, steps: newSteps }));
+                    } : undefined}
+                    onMoveUp={() => {
+                      const newSteps = handleStepMove(stepIndex, 'up', newMacro.steps || []);
+                      setNewMacro(prev => ({ ...prev, steps: newSteps }));
+                    }}
+                    onMoveDown={() => {
+                      const newSteps = handleStepMove(stepIndex, 'down', newMacro.steps || []);
+                      setNewMacro(prev => ({ ...prev, steps: newSteps }));
+                    }}
+                    isDesktop={isDesktop}
+                    onKeySelect={(option) => handleKeySelect(stepIndex, option)}
+                    onKeyQueryChange={(query) => handleKeyQueryChange(stepIndex, query)}
+                    keyQuery={keyQueries[stepIndex] || ''}
+                    getFilteredKeys={() => getFilteredKeys(stepIndex)}
+                    onModifierChange={(modifiers) => handleModifierChange(stepIndex, modifiers)}
+                    onDelayChange={(delay) => handleDelayChange(stepIndex, delay)}
+                    isLastStep={stepIndex === (newMacro.steps?.length || 0) - 1}
+                  />
+                ))}
+
+                <div className="mt-4 border-t border-slate-200 pt-4 dark:border-slate-700">
+                  <button
+                    type="button"
+                    className={`w-full flex items-center justify-center gap-1 rounded-md px-3 py-2 text-sm font-medium transition-colors ${
+                      isMaxStepsReachedForNewMacro
+                        ? 'bg-slate-100 text-slate-400 cursor-not-allowed dark:bg-slate-800 dark:text-slate-500'
+                        : 'bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700'
+                    }`}
+                    onClick={() => {
+                      if (isMaxStepsReachedForNewMacro) {
+                        showTemporaryError(`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`);
+                        return;
+                      }
+                      
+                      setNewMacro(prev => ({
+                        ...prev,
+                        steps: [
+                          ...(prev.steps || []), 
+                          { keys: [], modifiers: [], delay: DEFAULT_DELAY }
+                        ],
+                      }));
+                      clearErrors();
+                    }}
+                    disabled={isMaxStepsReachedForNewMacro}
+                  >
+                    <LuPlus className="h-4 w-4" />
+                    <span>Add Step {isMaxStepsReachedForNewMacro && `(${MAX_STEPS_PER_MACRO} max)`}</span>
+                  </button>
+                </div>
+
+                <div className="mt-6 flex items-center justify-between border-t border-slate-200 pt-4 dark:border-slate-700">
+                  {showClearConfirm ? (
+                    <div className="flex items-center gap-2">
+                      <span className="text-sm text-slate-600 dark:text-slate-400">
+                        Cancel changes?
+                      </span>
+                      <Button
+                        size="SM"
+                        theme="danger"
+                        text="Yes"
+                        onClick={() => {
+                          resetNewMacro();
+                          setShowAddMacro(false);
+                          setShowClearConfirm(false);
+                        }}
+                      />
+                      <Button
+                        size="SM"
+                        theme="light"
+                        text="No"
+                        onClick={() => setShowClearConfirm(false)}
+                      />
+                    </div>
+                  ) : (
+                    <div className="flex gap-x-2">
+                      <Button
+                        size="SM"
+                        theme="primary"
+                        text={isSaving ? "Saving..." : "Save Macro"}
+                        onClick={handleAddMacro}
+                        disabled={isSaving}
+                      />
+                      <Button
+                        size="SM"
+                        theme="light"
+                        text="Cancel"
+                        LeadingIcon={LuX}
+                        onClick={() => {
+                          if (newMacro.name || newMacro.description || newMacro.steps?.some(s => s.keys?.length || s.modifiers?.length)) {
+                            setShowClearConfirm(true);
+                          } else {
+                            resetNewMacro();
+                            setShowAddMacro(false);
+                          }
+                        }}
+                      />
+                    </div>
+                  )}
+                </div>
+              </div>
+            </div>
+          </div>
+        )}
+        {macros.length > 0 && (
+          <div className="space-y-2">
+            
+            {macros.length === 0 ? (
+              <p className="text-center text-sm text-slate-500 dark:text-slate-400 py-4">
+                No macros created yet. Add your first macro above.
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: adamshiervani
body: This is never displayed.

Use EmptyCard instead. If there are no items, remove the CTA from the top header and show EmptyCard. Once items exist, display the “Add Macro” CTA in the header. Only one CTA should be shown at a time.

created_at: 2025-04-01T23:20:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,1439 @@
+import { useState, useEffect, useRef, useCallback } from "react";
+import { LuPlus, LuTrash, LuX, LuPenLine, LuLoader, LuGripVertical, LuInfo } from "react-icons/lu";
+import { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
+
+import { KeySequence, useMacrosStore } from "../hooks/stores";
+import { SettingsPageHeader } from "../components/SettingsPageheader";
+import { Button } from "../components/Button";
+import { keys, modifiers } from "../keyboardMappings";
+import { useJsonRpc } from "../hooks/useJsonRpc";
+import notifications from "../notifications";
+import { SettingsItem } from "../routes/devices.$id.settings";
+
+const DEFAULT_DELAY = 50;
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface KeyOption {
+  value: string;
+  label: string;
+}
+
+interface KeyOptionData {
+  value: string | null;
+  keys?: string[];
+  label?: string;
+}
+
+const generateId = () => {
+  return `macro-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
+};
+
+const keyOptions = Object.keys(keys).map(key => ({
+  value: key,
+  label: key,
+}));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+interface KeyComboboxProps {
+  stepIndex: number;
+  step: MacroStep;
+  onSelect: (option: KeyOptionData) => void;
+  query: string;
+  onQueryChange: (query: string) => void;
+  getFilteredOptions: () => KeyOption[];
+  disabled?: boolean;
+}
+
+function KeyCombobox({
+  onSelect,
+  query,
+  onQueryChange,
+  getFilteredOptions,
+  disabled = false,
+}: KeyComboboxProps) {
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  return (
+    <div className="relative w-full">
+      <Combobox immediate onChange={onSelect} disabled={disabled}>
+        {() => (
+          <>
+            <div className="relative">
+              <ComboboxInput
+                ref={inputRef}
+                className={`macro-input ${disabled ? 'opacity-60 cursor-not-allowed' : ''}`}
+                placeholder={disabled ? "Max keys reached" : "Search for key..."}
+                displayValue={() => query}
+                onChange={(event) => onQueryChange(event.target.value)}
+                disabled={disabled}
+              />
+            </div>
+            
+            <ComboboxOptions className="absolute left-0 z-50 mt-1 w-full max-h-60 overflow-auto rounded-md bg-white dark:bg-slate-800 py-1 text-sm shadow-lg">
+              {getFilteredOptions().map((option) => (
+                <ComboboxOption 
+                  key={option.value} 
+                  value={option}
+                  className="cursor-default select-none py-1.5 px-3 ui-active:bg-blue-100 ui-active:text-blue-900 dark:text-slate-300 dark:ui-active:bg-blue-900/40 dark:ui-active:text-blue-200"
+                >
+                  {option.label}
+                </ComboboxOption>
+              ))}
+              {getFilteredOptions().length === 0 && (
+                <div className="py-2 px-3 text-sm text-slate-500 dark:text-slate-400">
+                  No matching keys found
+                </div>
+              )}
+            </ComboboxOptions>
+          </>
+        )}
+      </Combobox>
+    </div>
+  );
+}
+
+const PRESET_DELAYS = [
+  { value: 50, label: "50ms" },
+  { value: 100, label: "100ms" },
+  { value: 200, label: "200ms" },
+  { value: 300, label: "300ms" },
+  { value: 500, label: "500ms" },
+  { value: 750, label: "750ms" },
+  { value: 1000, label: "1000ms" },
+  { value: 1500, label: "1500ms" },
+  { value: 2000, label: "2000ms" },
+];
+
+const MAX_STEPS_PER_MACRO = 10;
+const MAX_TOTAL_MACROS = 25;
+const MAX_KEYS_PER_STEP = 10;
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+// Helper function to normalize sort orders, ensuring they start at 1 and have no gaps
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  isDesktop: boolean;
+  onKeySelect: (option: KeyOptionData) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  getFilteredKeys: () => KeyOption[];
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  getFilteredKeys,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  return (
+    <div className="macro-step-card rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 p-4 shadow-sm">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <button
+              type="button"
+              className="p-1 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400 disabled:opacity-50"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="m18 15-6-6-6 6"/>
+              </svg>
+            </button>
+            <button
+              type="button"
+              className="p-1 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400 disabled:opacity-50"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="m6 9 6 6 6-6"/>
+              </svg>
+            </button>
+          </div>
+          <span className="macro-step-number flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+        
+        <div className="flex items-center space-x-2">
+          {onDelete && (
+            <button
+              type="button"
+              className="flex items-center text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
+              onClick={onDelete}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="M3 6h18"></path>
+                <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
+                <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
+              </svg>
+              <span className="ml-1">Delete</span>
+            </button>
+          )}
+        </div>
+      </div>
+      
+      <div className="space-y-4 mt-2">
+        <div className="w-full flex flex-col gap-2">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Modifiers:
+          </label>
+          <div className="macro-modifiers-container inline-flex flex-wrap gap-3">
+            {Object.entries(groupedModifiers).map(([group, mods]) => (
+              <div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
+                <span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
+                  {group}
+                </span>
+                <div className="flex flex-wrap gap-1">
+                  {mods.map(option => (
+                    <label 
+                      key={option.value} 
+                      className={`flex items-center px-2 py-1 rounded border cursor-pointer text-xs font-medium transition-colors ${
+                        ensureArray(step.modifiers).includes(option.value) 
+                          ? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-200' 
+                          : 'bg-slate-100 border-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700'
+                      }`}
+                    >
+                      <input
+                        type="checkbox"
+                        className="sr-only"
+                        checked={ensureArray(step.modifiers).includes(option.value)}
+                        onChange={e => {
+                          const modifiersArray = ensureArray(step.modifiers);
+                          const newModifiers = e.target.checked
+                            ? [...modifiersArray, option.value]
+                            : modifiersArray.filter(m => m !== option.value);
+                          onModifierChange(newModifiers);
+                        }}
+                      />
+                      {option.label.split(' ')[1] || option.label}
+                    </label>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Keys:
+          </label>
+          
+          <div className="macro-key-group flex flex-wrap gap-1 mb-2">
+            {ensureArray(step.keys).map((key, keyIndex) => (
+              <span
+                key={keyIndex}
+                className="macro-key-badge inline-flex items-center rounded-md bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
+              >
+                {key}
+                <button
+                  type="button"
+                  className="ml-1 text-xs text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
+                  onClick={() => {
+                    const newKeys = ensureArray(step.keys).filter((_, i) => i !== keyIndex);
+                    onKeySelect({ value: null, keys: newKeys });
+                  }}
+                >
+                  ×
+                </button>
+              </span>
+            ))}
+          </div>
+
+          <KeyCombobox
+            stepIndex={stepIndex}
+            step={step}
+            onSelect={onKeySelect}
+            query={keyQuery}
+            onQueryChange={onKeyQueryChange}
+            getFilteredOptions={getFilteredKeys}
+            disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP}
+          />
+          
+          {ensureArray(step.keys).length >= MAX_KEYS_PER_STEP && (
+            <span className="text-xs text-amber-600 dark:text-amber-400 mt-1">
+              (max keys reached)
+            </span>
+          )}
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+              Step Duration:
+            </label>
+            <div className="group relative">
+              <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+              <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                <p>The time to wait after pressing the keys in this step before moving to the next step. This helps ensure reliable key presses when automating keyboard input.</p>
+              </div>
+            </div>
+          </div>
+          <div className="flex items-center gap-3">
+            <select
+              className="w-full rounded-md border border-slate-300 bg-slate-50 p-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
+              value={step.delay}
+              onChange={(e) => onDelayChange(parseInt(e.target.value, 10))}
+            >
+              {PRESET_DELAYS.map((option) => (
+                <option key={option.value} value={option.value}>
+                  {option.label}
+                </option>
+              ))}
+            </select>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// Helper to update step keys used by both new and edit flows
+const updateStepKeys = (
+  steps: MacroStep[],
+  stepIndex: number,
+  keyOption: { value: string | null; keys?: string[] },
+  showTemporaryError: (msg: string) => void
+) => {
+  const newSteps = [...steps];
+  if (keyOption.keys) {
+    newSteps[stepIndex].keys = keyOption.keys;
+  } else if (keyOption.value) {
+    if (!newSteps[stepIndex].keys) {
+      newSteps[stepIndex].keys = [];
+    }
+    const keysArray = ensureArray(newSteps[stepIndex].keys);
+    if (keysArray.length >= MAX_KEYS_PER_STEP) {
+      showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`);
+      return newSteps;
+    }
+    newSteps[stepIndex].keys = [...keysArray, keyOption.value];
+  }
+  return newSteps;
+};
+
+const useTouchSort = (items: KeySequence[], onSort: (newItems: KeySequence[]) => void) => {
+  const [touchStartY, setTouchStartY] = useState<number | null>(null);
+  const [touchedIndex, setTouchedIndex] = useState<number | null>(null);
+
+  const handleTouchStart = useCallback((e: React.TouchEvent, index: number) => {
+    const touch = e.touches[0];
+    setTouchStartY(touch.clientY);
+    setTouchedIndex(index);
+    
+    const element = e.currentTarget as HTMLElement;
+    const rect = element.getBoundingClientRect();
+    
+    // Create ghost element
+    const ghost = element.cloneNode(true) as HTMLElement;
+    ghost.id = 'ghost-macro';
+    ghost.className = 'macro-sortable ghost';
+    ghost.style.height = `${rect.height}px`;
+    element.parentNode?.insertBefore(ghost, element);
+    
+    // Set up dragged element
+    element.style.position = 'fixed';
+    element.style.left = `${rect.left}px`;
+    element.style.top = `${rect.top}px`;
+    element.style.width = `${rect.width}px`;
+    element.style.zIndex = '50';
+  }, []);
+
+  const handleTouchMove = useCallback((e: React.TouchEvent) => {
+    if (touchStartY === null || touchedIndex === null) return;
+    
+    const touch = e.touches[0];
+    const deltaY = touch.clientY - touchStartY;
+    const element = e.currentTarget as HTMLElement;
+    
+    // Smooth movement of dragged element
+    element.style.transform = `translateY(${deltaY}px)`;
+    
+    const macroElements = document.querySelectorAll('[data-macro-item]');
+    const draggedRect = element.getBoundingClientRect();
+    const draggedMiddle = draggedRect.top + draggedRect.height / 2;
+    
+    macroElements.forEach((el, i) => {
+      if (i === touchedIndex) return;
+      
+      const rect = el.getBoundingClientRect();
+      const elementMiddle = rect.top + rect.height / 2;
+      const distance = Math.abs(draggedMiddle - elementMiddle);
+      
+      if (distance < rect.height) {
+        const direction = draggedMiddle > elementMiddle ? -1 : 1;
+        (el as HTMLElement).style.transform = `translateY(${direction * rect.height}px)`;
+        (el as HTMLElement).style.transition = 'transform 0.15s ease-out';
+      } else {
+        (el as HTMLElement).style.transform = '';
+        (el as HTMLElement).style.transition = 'transform 0.15s ease-out';
+      }
+    });
+  }, [touchStartY, touchedIndex]);
+
+  const handleTouchEnd = useCallback(async (e: React.TouchEvent) => {
+    if (touchedIndex === null) return;
+    
+    const element = e.currentTarget as HTMLElement;
+    const touch = e.changedTouches[0];
+    
+    // Remove ghost element
+    const ghost = document.getElementById('ghost-macro');
+    ghost?.parentNode?.removeChild(ghost);
+    
+    // Reset dragged element styles
+    element.style.position = '';
+    element.style.left = '';
+    element.style.top = '';
+    element.style.width = '';
+    element.style.zIndex = '';
+    element.style.transform = '';
+    element.style.boxShadow = '';
+    element.style.transition = '';
+    
+    const macroElements = document.querySelectorAll('[data-macro-item]');
+    let targetIndex = touchedIndex;
+    
+    // Find the closest element to the final touch position
+    const finalY = touch.clientY;
+    let closestDistance = Infinity;
+    
+    macroElements.forEach((el, i) => {
+      if (i === touchedIndex) return;
+      
+      const rect = el.getBoundingClientRect();
+      const distance = Math.abs(finalY - (rect.top + rect.height / 2));
+      
+      if (distance < closestDistance) {
+        closestDistance = distance;
+        targetIndex = i;
+      }
+      
+      // Reset other elements
+      (el as HTMLElement).style.transform = '';
+      (el as HTMLElement).style.transition = '';
+    });
+    
+    if (targetIndex !== touchedIndex && closestDistance < 50) {
+      const itemsCopy = [...items];
+      const [draggedItem] = itemsCopy.splice(touchedIndex, 1);
+      itemsCopy.splice(targetIndex, 0, draggedItem);
+      onSort(itemsCopy);
+    }
+    
+    setTouchStartY(null);
+    setTouchedIndex(null);
+  }, [touchedIndex, items, onSort]);
+
+  return { handleTouchStart, handleTouchMove, handleTouchEnd };
+};
+
+interface StepError {
+  keys?: string;
+  modifiers?: string;
+  delay?: string;
+}
+
+interface ValidationErrors {
+  name?: string;
+  description?: string;
+  steps?: Record<number, StepError>;
+}
+
+export default function SettingsMacrosRoute() {
+  const { macros, loading, initialized, loadMacros, saveMacros, setSendFn } = useMacrosStore();
+  const [editingMacro, setEditingMacro] = useState<KeySequence | null>(null);
+  const [newMacro, setNewMacro] = useState<Partial<KeySequence>>({
+    name: "",
+    description: "",
+    steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }],
+  });
+  const [isDragging, setIsDragging] = useState(false);
+  const dragItem = useRef<number | null>(null);
+  const dragOverItem = useRef<number | null>(null);
+  
+  const [macroToDelete, setMacroToDelete] = useState<string | null>(null);
+  
+  const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
+  const [editKeyQueries, setEditKeyQueries] = useState<Record<number, string>>({});
+  
+  const [errorMessage, setErrorMessage] = useState<string | null>(null);
+  const [isDesktop, setIsDesktop] = useState(window.innerWidth >= 768);
+  
+  const [send] = useJsonRpc();
+
+  const isMaxMacrosReached = macros.length >= MAX_TOTAL_MACROS;
+  const isMaxStepsReachedForNewMacro = (newMacro.steps?.length || 0) >= MAX_STEPS_PER_MACRO;
+  
+  const showTemporaryError = useCallback((message: string) => {
+    setErrorMessage(message);
+    setTimeout(() => setErrorMessage(null), 3000);
+  }, []);
+  
+  // Helper for both new and edit key select
+  const handleKeySelectUpdate = (stepIndex: number, option: KeyOptionData, isEditing = false) => {
+    if (isEditing && editingMacro) {
+      const updatedSteps = updateStepKeys(editingMacro.steps, stepIndex, option, showTemporaryError);
+      setEditingMacro({ ...editingMacro, steps: updatedSteps });
+    } else {
+      const updatedSteps = updateStepKeys(newMacro.steps || [], stepIndex, option, showTemporaryError);
+      setNewMacro({ ...newMacro, steps: updatedSteps });
+    }
+  };
+  
+  const handleKeySelect = (stepIndex: number, option: KeyOptionData) => {
+    handleKeySelectUpdate(stepIndex, option, false);
+  };
+  
+  const handleEditKeySelect = (stepIndex: number, option: KeyOptionData) => {
+    handleKeySelectUpdate(stepIndex, option, true);
+  };
+  
+  const handleKeyQueryChange = (stepIndex: number, query: string) => {
+    setKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
+  };
+  
+  const handleEditKeyQueryChange = (stepIndex: number, query: string) => {
+    setEditKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
+  };
+  
+  const getFilteredKeys = (stepIndex: number, isEditing = false) => {
+    const query = isEditing 
+      ? (editKeyQueries[stepIndex] || '')
+      : (keyQueries[stepIndex] || '');
+    
+    if (query === '') {
+      return keyOptions;
+    } else {
+      return keyOptions.filter(option => option.label.toLowerCase().includes(query.toLowerCase()));
+    }
+  };
+  
+  useEffect(() => {
+    setSendFn(send);
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros, setSendFn, send]);
+  
+  const [errors, setErrors] = useState<ValidationErrors>({});
+  
+  const clearErrors = useCallback(() => {
+    setErrors({});
+  }, []);
+  
+  const validateMacro = (macro: Partial<KeySequence>): ValidationErrors => {
+    const errors: ValidationErrors = {};
+
+    // Name validation
+    if (!macro.name?.trim()) {
+      errors.name = "Name is required";
+    } else if (macro.name.trim().length > 50) {
+      errors.name = "Name must be less than 50 characters";
+    }
+
+    // Description validation (optional)
+    if (macro.description && macro.description.trim().length > 200) {
+      errors.description = "Description must be less than 200 characters";
+    }
+
+    // Steps validation
+    if (!macro.steps?.length) {
+      errors.steps = { 0: { keys: "At least one step is required" } };
+      return errors;
+    }
+
+    // Check if at least one step has keys or modifiers
+    const hasKeyOrModifier = macro.steps.some(step => 
+      (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0
+    );
+
+    if (!hasKeyOrModifier) {
+      errors.steps = { 0: { keys: "At least one step must have keys or modifiers" } };
+      return errors;
+    }
+
+    const stepErrors: Record<number, StepError> = {};
+    
+    macro.steps.forEach((step, index) => {
+      const stepError: StepError = {};
+
+      // Keys validation (only if keys are present)
+      if (step.keys?.length && step.keys.length > MAX_KEYS_PER_STEP) {
+        stepError.keys = `Maximum ${MAX_KEYS_PER_STEP} keys allowed`;
+      }
+
+      // Delay validation
+      if (typeof step.delay !== 'number' || step.delay < 0) {
+        stepError.delay = "Invalid delay value";
+      }
+
+      if (Object.keys(stepError).length > 0) {
+        stepErrors[index] = stepError;
+      }
+    });
+
+    if (Object.keys(stepErrors).length > 0) {
+      errors.steps = stepErrors;
+    }
+
+    return errors;
+  };
+
+  const resetNewMacro = () => {
+    setNewMacro({
+      name: "",
+      description: "",
+      steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }],
+    });
+    setKeyQueries({});
+    setErrors({});
+  };
+
+  const [isSaving, setIsSaving] = useState(false);
+  const [isUpdating, setIsUpdating] = useState(false);
+  const [isDeleting, setIsDeleting] = useState(false);
+
+  const handleAddMacro = useCallback(async () => {
+    if (isMaxMacrosReached) {
+      showTemporaryError(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    const validationErrors = validateMacro(newMacro);
+    if (Object.keys(validationErrors).length > 0) {
+      setErrors(validationErrors);
+      return;
+    }
+
+    setIsSaving(true);
+    try {
+      const macro: KeySequence = {
+        id: generateId(),
+        name: newMacro.name!.trim(),
+        description: newMacro.description?.trim() || "",
+        steps: newMacro.steps || [],
+        sortOrder: macros.length + 1,
+      };
+
+      await saveMacros(normalizeSortOrders([...macros, macro]));
+      resetNewMacro();
+      setShowAddMacro(false);
+      notifications.success(`Macro "${macro.name}" created successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to create macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to create macro");
+        showTemporaryError("Failed to save macro");
+      }
+    } finally {
+      setIsSaving(false);
+    }
+  }, [isMaxMacrosReached, newMacro, macros, saveMacros, showTemporaryError]);
+
+  const handleDragStart = (index: number) => {
+    dragItem.current = index;
+    setIsDragging(true);
+    
+    const allItems = document.querySelectorAll('[data-macro-item]');
+    const draggedElement = allItems[index];
+    if (draggedElement) {
+      draggedElement.classList.add('dragging');
+    }
+  };
+  
+  const handleDragOver = (e: React.DragEvent, index: number) => {
+    e.preventDefault();
+    dragOverItem.current = index;
+    
+    const allItems = document.querySelectorAll('[data-macro-item]');
+    allItems.forEach(el => el.classList.remove('drop-target'));
+    
+    const targetElement = allItems[index];
+    if (targetElement) {
+      targetElement.classList.add('drop-target');
+    }
+  };
+  
+  const handleDrop = async (e: React.DragEvent) => {
+    e.preventDefault();
+    if (dragItem.current === null || dragOverItem.current === null) return;
+    
+    const macroCopy = [...macros];
+    const draggedItem = macroCopy.splice(dragItem.current, 1)[0];
+    macroCopy.splice(dragOverItem.current, 0, draggedItem);
+    const updatedMacros = normalizeSortOrders(macroCopy);
+
+    try {
+      await saveMacros(updatedMacros);
+      notifications.success("Macro order updated successfully");
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to reorder macros: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to reorder macros");
+        showTemporaryError("Failed to save reordered macros");
+      }
+    }
+    
+    const allItems = document.querySelectorAll('[data-macro-item]');
+    allItems.forEach(el => {
+      el.classList.remove('drop-target');
+      el.classList.remove('dragging');
+    });
+    
+    dragItem.current = null;
+    dragOverItem.current = null;
+    setIsDragging(false);
+  };
+
+  const handleUpdateMacro = useCallback(async () => {
+    if (!editingMacro) return;
+
+    const validationErrors = validateMacro(editingMacro);
+    if (Object.keys(validationErrors).length > 0) {
+      setErrors(validationErrors);
+      return;
+    }
+
+    setIsUpdating(true);
+    try {
+      const newMacros = macros.map(m => 
+        m.id === editingMacro.id ? {
+          ...editingMacro,
+          name: editingMacro.name.trim(),
+          description: editingMacro.description?.trim() || "",
+        } : m
+      );
+
+      await saveMacros(normalizeSortOrders(newMacros));
+      setEditingMacro(null);
+      clearErrors();
+      notifications.success(`Macro "${editingMacro.name}" updated successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to update macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to update macro");
+        showTemporaryError("Failed to update macro");
+      }
+    } finally {
+      setIsUpdating(false);
+    }
+  }, [editingMacro, macros, saveMacros, showTemporaryError, clearErrors]);
+
+  const handleEditMacro = (macro: KeySequence) => {
+    setEditingMacro({
+      ...macro,
+      description: macro.description || "",
+      steps: macro.steps.map(step => ({
+        ...step,
+        keys: ensureArray(step.keys),
+        modifiers: ensureArray(step.modifiers),
+        delay: typeof step.delay === 'number' ? step.delay : DEFAULT_DELAY
+      }))
+    });
+    clearErrors();
+    setEditKeyQueries({});
+  };
+
+  const handleDeleteMacro = async (id: string) => {
+    const macroToBeDeleted = macros.find(m => m.id === id);
+    if (!macroToBeDeleted) return;
+
+    setIsDeleting(true);
+    try {
+      const updatedMacros = normalizeSortOrders(macros.filter(macro => macro.id !== id));
+      await saveMacros(updatedMacros);
+      if (editingMacro?.id === id) {
+        setEditingMacro(null);
+      }
+      setMacroToDelete(null);
+      notifications.success(`Macro "${macroToBeDeleted.name}" deleted successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to delete macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to delete macro");
+        showTemporaryError("Failed to delete macro");
+      }
+    } finally {
+      setIsDeleting(false);
+    }
+  };
+
+  const handleDuplicateMacro = async (macro: KeySequence) => {
+    if (isMaxMacrosReached) {
+      showTemporaryError(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    const newMacroCopy: KeySequence = {
+      ...JSON.parse(JSON.stringify(macro)),
+      id: generateId(),
+      name: `${macro.name} (copy)`,
+      sortOrder: macros.length + 1,
+    };
+
+    newMacroCopy.steps = newMacroCopy.steps.map(step => ({
+      ...step,
+      keys: ensureArray(step.keys),
+      modifiers: ensureArray(step.modifiers),
+      delay: step.delay || DEFAULT_DELAY
+    }));
+
+    try {
+      await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
+      notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to duplicate macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to duplicate macro");
+        showTemporaryError("Failed to duplicate macro");
+      }
+    }
+  };
+
+  const handleStepMove = (stepIndex: number, direction: 'up' | 'down', steps: MacroStep[]) => {
+    const newSteps = [...steps];
+    const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
+    [newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]];
+    return newSteps;
+  };
+
+  useEffect(() => {
+    const handleResize = () => {
+      setIsDesktop(window.innerWidth >= 768);
+    };
+    
+    window.addEventListener('resize', handleResize);
+    return () => window.removeEventListener('resize', handleResize);
+  }, []);
+
+  const { handleTouchStart, handleTouchMove, handleTouchEnd } = useTouchSort(
+    macros,
+    async (newMacros) => {
+      const updatedMacros = normalizeSortOrders(newMacros);
+      try {
+        await saveMacros(updatedMacros);
+        notifications.success("Macro order updated successfully");
+      } catch (error) {
+        if (error instanceof Error) {
+          notifications.error(`Failed to reorder macros: ${error.message}`);
+          showTemporaryError(error.message);
+        } else {
+          notifications.error("Failed to reorder macros");
+          showTemporaryError("Failed to save reordered macros");
+        }
+      }
+    }
+  );
+
+  const [showClearConfirm, setShowClearConfirm] = useState(false);
+  const [showAddMacro, setShowAddMacro] = useState(false);
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && editingMacro) {
+        setEditingMacro(null);
+        setErrors({});
+      }
+      if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
+        if (editingMacro) {
+          handleUpdateMacro();
+        } else if (!isMaxMacrosReached) {
+          handleAddMacro();
+        }
+      }
+    };
+
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [editingMacro, isMaxMacrosReached, handleAddMacro, handleUpdateMacro]);
+
+  const handleModifierChange = (stepIndex: number, modifiers: string[]) => {
+    if (editingMacro) {
+      const newSteps = [...editingMacro.steps];
+      newSteps[stepIndex].modifiers = modifiers;
+      setEditingMacro({ ...editingMacro, steps: newSteps });
+    } else {
+      const newSteps = [...(newMacro.steps || [])];
+      newSteps[stepIndex].modifiers = modifiers;
+      setNewMacro({ ...newMacro, steps: newSteps });
+    }
+  };
+
+  const handleDelayChange = (stepIndex: number, delay: number) => {
+    if (editingMacro) {
+      const newSteps = [...editingMacro.steps];
+      newSteps[stepIndex].delay = delay;
+      setEditingMacro({ ...editingMacro, steps: newSteps });
+    } else {
+      const newSteps = [...(newMacro.steps || [])];
+      newSteps[stepIndex].delay = delay;
+      setNewMacro({ ...newMacro, steps: newSteps });
+    }
+  };
+
+  const ErrorMessage = ({ error }: { error?: string }) => {
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: adamshiervani
body: Use the Fielderror in InputField.tsx
created_at: 2025-04-01T23:21:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,1439 @@
+import { useState, useEffect, useRef, useCallback } from "react";
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: Savid
body: added extra routes for adding and editing
created_at: 2025-04-03T08:23:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,1439 @@
+import { useState, useEffect, useRef, useCallback } from "react";
+import { LuPlus, LuTrash, LuX, LuPenLine, LuLoader, LuGripVertical, LuInfo } from "react-icons/lu";
+import { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
+
+import { KeySequence, useMacrosStore } from "../hooks/stores";
+import { SettingsPageHeader } from "../components/SettingsPageheader";
+import { Button } from "../components/Button";
+import { keys, modifiers } from "../keyboardMappings";
+import { useJsonRpc } from "../hooks/useJsonRpc";
+import notifications from "../notifications";
+import { SettingsItem } from "../routes/devices.$id.settings";
+
+const DEFAULT_DELAY = 50;
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface KeyOption {
+  value: string;
+  label: string;
+}
+
+interface KeyOptionData {
+  value: string | null;
+  keys?: string[];
+  label?: string;
+}
+
+const generateId = () => {
+  return `macro-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
+};
+
+const keyOptions = Object.keys(keys).map(key => ({
+  value: key,
+  label: key,
+}));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+interface KeyComboboxProps {
+  stepIndex: number;
+  step: MacroStep;
+  onSelect: (option: KeyOptionData) => void;
+  query: string;
+  onQueryChange: (query: string) => void;
+  getFilteredOptions: () => KeyOption[];
+  disabled?: boolean;
+}
+
+function KeyCombobox({
+  onSelect,
+  query,
+  onQueryChange,
+  getFilteredOptions,
+  disabled = false,
+}: KeyComboboxProps) {
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  return (
+    <div className="relative w-full">
+      <Combobox immediate onChange={onSelect} disabled={disabled}>
+        {() => (
+          <>
+            <div className="relative">
+              <ComboboxInput
+                ref={inputRef}
+                className={`macro-input ${disabled ? 'opacity-60 cursor-not-allowed' : ''}`}
+                placeholder={disabled ? "Max keys reached" : "Search for key..."}
+                displayValue={() => query}
+                onChange={(event) => onQueryChange(event.target.value)}
+                disabled={disabled}
+              />
+            </div>
+            
+            <ComboboxOptions className="absolute left-0 z-50 mt-1 w-full max-h-60 overflow-auto rounded-md bg-white dark:bg-slate-800 py-1 text-sm shadow-lg">
+              {getFilteredOptions().map((option) => (
+                <ComboboxOption 
+                  key={option.value} 
+                  value={option}
+                  className="cursor-default select-none py-1.5 px-3 ui-active:bg-blue-100 ui-active:text-blue-900 dark:text-slate-300 dark:ui-active:bg-blue-900/40 dark:ui-active:text-blue-200"
+                >
+                  {option.label}
+                </ComboboxOption>
+              ))}
+              {getFilteredOptions().length === 0 && (
+                <div className="py-2 px-3 text-sm text-slate-500 dark:text-slate-400">
+                  No matching keys found
+                </div>
+              )}
+            </ComboboxOptions>
+          </>
+        )}
+      </Combobox>
+    </div>
+  );
+}
+
+const PRESET_DELAYS = [
+  { value: 50, label: "50ms" },
+  { value: 100, label: "100ms" },
+  { value: 200, label: "200ms" },
+  { value: 300, label: "300ms" },
+  { value: 500, label: "500ms" },
+  { value: 750, label: "750ms" },
+  { value: 1000, label: "1000ms" },
+  { value: 1500, label: "1500ms" },
+  { value: 2000, label: "2000ms" },
+];
+
+const MAX_STEPS_PER_MACRO = 10;
+const MAX_TOTAL_MACROS = 25;
+const MAX_KEYS_PER_STEP = 10;
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+// Helper function to normalize sort orders, ensuring they start at 1 and have no gaps
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  isDesktop: boolean;
+  onKeySelect: (option: KeyOptionData) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  getFilteredKeys: () => KeyOption[];
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  getFilteredKeys,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  return (
+    <div className="macro-step-card rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 p-4 shadow-sm">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <button
+              type="button"
+              className="p-1 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400 disabled:opacity-50"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="m18 15-6-6-6 6"/>
+              </svg>
+            </button>
+            <button
+              type="button"
+              className="p-1 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400 disabled:opacity-50"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="m6 9 6 6 6-6"/>
+              </svg>
+            </button>
+          </div>
+          <span className="macro-step-number flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+        
+        <div className="flex items-center space-x-2">
+          {onDelete && (
+            <button
+              type="button"
+              className="flex items-center text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
+              onClick={onDelete}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="M3 6h18"></path>
+                <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
+                <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
+              </svg>
+              <span className="ml-1">Delete</span>
+            </button>
+          )}
+        </div>
+      </div>
+      
+      <div className="space-y-4 mt-2">
+        <div className="w-full flex flex-col gap-2">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Modifiers:
+          </label>
+          <div className="macro-modifiers-container inline-flex flex-wrap gap-3">
+            {Object.entries(groupedModifiers).map(([group, mods]) => (
+              <div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
+                <span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
+                  {group}
+                </span>
+                <div className="flex flex-wrap gap-1">
+                  {mods.map(option => (
+                    <label 
+                      key={option.value} 
+                      className={`flex items-center px-2 py-1 rounded border cursor-pointer text-xs font-medium transition-colors ${
+                        ensureArray(step.modifiers).includes(option.value) 
+                          ? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-200' 
+                          : 'bg-slate-100 border-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700'
+                      }`}
+                    >
+                      <input
+                        type="checkbox"
+                        className="sr-only"
+                        checked={ensureArray(step.modifiers).includes(option.value)}
+                        onChange={e => {
+                          const modifiersArray = ensureArray(step.modifiers);
+                          const newModifiers = e.target.checked
+                            ? [...modifiersArray, option.value]
+                            : modifiersArray.filter(m => m !== option.value);
+                          onModifierChange(newModifiers);
+                        }}
+                      />
+                      {option.label.split(' ')[1] || option.label}
+                    </label>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Keys:
+          </label>
+          
+          <div className="macro-key-group flex flex-wrap gap-1 mb-2">
+            {ensureArray(step.keys).map((key, keyIndex) => (
+              <span
+                key={keyIndex}
+                className="macro-key-badge inline-flex items-center rounded-md bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
+              >
+                {key}
+                <button
+                  type="button"
+                  className="ml-1 text-xs text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
+                  onClick={() => {
+                    const newKeys = ensureArray(step.keys).filter((_, i) => i !== keyIndex);
+                    onKeySelect({ value: null, keys: newKeys });
+                  }}
+                >
+                  ×
+                </button>
+              </span>
+            ))}
+          </div>
+
+          <KeyCombobox
+            stepIndex={stepIndex}
+            step={step}
+            onSelect={onKeySelect}
+            query={keyQuery}
+            onQueryChange={onKeyQueryChange}
+            getFilteredOptions={getFilteredKeys}
+            disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP}
+          />
+          
+          {ensureArray(step.keys).length >= MAX_KEYS_PER_STEP && (
+            <span className="text-xs text-amber-600 dark:text-amber-400 mt-1">
+              (max keys reached)
+            </span>
+          )}
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+              Step Duration:
+            </label>
+            <div className="group relative">
+              <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+              <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                <p>The time to wait after pressing the keys in this step before moving to the next step. This helps ensure reliable key presses when automating keyboard input.</p>
+              </div>
+            </div>
+          </div>
+          <div className="flex items-center gap-3">
+            <select
+              className="w-full rounded-md border border-slate-300 bg-slate-50 p-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
+              value={step.delay}
+              onChange={(e) => onDelayChange(parseInt(e.target.value, 10))}
+            >
+              {PRESET_DELAYS.map((option) => (
+                <option key={option.value} value={option.value}>
+                  {option.label}
+                </option>
+              ))}
+            </select>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// Helper to update step keys used by both new and edit flows
+const updateStepKeys = (
+  steps: MacroStep[],
+  stepIndex: number,
+  keyOption: { value: string | null; keys?: string[] },
+  showTemporaryError: (msg: string) => void
+) => {
+  const newSteps = [...steps];
+  if (keyOption.keys) {
+    newSteps[stepIndex].keys = keyOption.keys;
+  } else if (keyOption.value) {
+    if (!newSteps[stepIndex].keys) {
+      newSteps[stepIndex].keys = [];
+    }
+    const keysArray = ensureArray(newSteps[stepIndex].keys);
+    if (keysArray.length >= MAX_KEYS_PER_STEP) {
+      showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`);
+      return newSteps;
+    }
+    newSteps[stepIndex].keys = [...keysArray, keyOption.value];
+  }
+  return newSteps;
+};
+
+const useTouchSort = (items: KeySequence[], onSort: (newItems: KeySequence[]) => void) => {
+  const [touchStartY, setTouchStartY] = useState<number | null>(null);
+  const [touchedIndex, setTouchedIndex] = useState<number | null>(null);
+
+  const handleTouchStart = useCallback((e: React.TouchEvent, index: number) => {
+    const touch = e.touches[0];
+    setTouchStartY(touch.clientY);
+    setTouchedIndex(index);
+    
+    const element = e.currentTarget as HTMLElement;
+    const rect = element.getBoundingClientRect();
+    
+    // Create ghost element
+    const ghost = element.cloneNode(true) as HTMLElement;
+    ghost.id = 'ghost-macro';
+    ghost.className = 'macro-sortable ghost';
+    ghost.style.height = `${rect.height}px`;
+    element.parentNode?.insertBefore(ghost, element);
+    
+    // Set up dragged element
+    element.style.position = 'fixed';
+    element.style.left = `${rect.left}px`;
+    element.style.top = `${rect.top}px`;
+    element.style.width = `${rect.width}px`;
+    element.style.zIndex = '50';
+  }, []);
+
+  const handleTouchMove = useCallback((e: React.TouchEvent) => {
+    if (touchStartY === null || touchedIndex === null) return;
+    
+    const touch = e.touches[0];
+    const deltaY = touch.clientY - touchStartY;
+    const element = e.currentTarget as HTMLElement;
+    
+    // Smooth movement of dragged element
+    element.style.transform = `translateY(${deltaY}px)`;
+    
+    const macroElements = document.querySelectorAll('[data-macro-item]');
+    const draggedRect = element.getBoundingClientRect();
+    const draggedMiddle = draggedRect.top + draggedRect.height / 2;
+    
+    macroElements.forEach((el, i) => {
+      if (i === touchedIndex) return;
+      
+      const rect = el.getBoundingClientRect();
+      const elementMiddle = rect.top + rect.height / 2;
+      const distance = Math.abs(draggedMiddle - elementMiddle);
+      
+      if (distance < rect.height) {
+        const direction = draggedMiddle > elementMiddle ? -1 : 1;
+        (el as HTMLElement).style.transform = `translateY(${direction * rect.height}px)`;
+        (el as HTMLElement).style.transition = 'transform 0.15s ease-out';
+      } else {
+        (el as HTMLElement).style.transform = '';
+        (el as HTMLElement).style.transition = 'transform 0.15s ease-out';
+      }
+    });
+  }, [touchStartY, touchedIndex]);
+
+  const handleTouchEnd = useCallback(async (e: React.TouchEvent) => {
+    if (touchedIndex === null) return;
+    
+    const element = e.currentTarget as HTMLElement;
+    const touch = e.changedTouches[0];
+    
+    // Remove ghost element
+    const ghost = document.getElementById('ghost-macro');
+    ghost?.parentNode?.removeChild(ghost);
+    
+    // Reset dragged element styles
+    element.style.position = '';
+    element.style.left = '';
+    element.style.top = '';
+    element.style.width = '';
+    element.style.zIndex = '';
+    element.style.transform = '';
+    element.style.boxShadow = '';
+    element.style.transition = '';
+    
+    const macroElements = document.querySelectorAll('[data-macro-item]');
+    let targetIndex = touchedIndex;
+    
+    // Find the closest element to the final touch position
+    const finalY = touch.clientY;
+    let closestDistance = Infinity;
+    
+    macroElements.forEach((el, i) => {
+      if (i === touchedIndex) return;
+      
+      const rect = el.getBoundingClientRect();
+      const distance = Math.abs(finalY - (rect.top + rect.height / 2));
+      
+      if (distance < closestDistance) {
+        closestDistance = distance;
+        targetIndex = i;
+      }
+      
+      // Reset other elements
+      (el as HTMLElement).style.transform = '';
+      (el as HTMLElement).style.transition = '';
+    });
+    
+    if (targetIndex !== touchedIndex && closestDistance < 50) {
+      const itemsCopy = [...items];
+      const [draggedItem] = itemsCopy.splice(touchedIndex, 1);
+      itemsCopy.splice(targetIndex, 0, draggedItem);
+      onSort(itemsCopy);
+    }
+    
+    setTouchStartY(null);
+    setTouchedIndex(null);
+  }, [touchedIndex, items, onSort]);
+
+  return { handleTouchStart, handleTouchMove, handleTouchEnd };
+};
+
+interface StepError {
+  keys?: string;
+  modifiers?: string;
+  delay?: string;
+}
+
+interface ValidationErrors {
+  name?: string;
+  description?: string;
+  steps?: Record<number, StepError>;
+}
+
+export default function SettingsMacrosRoute() {
+  const { macros, loading, initialized, loadMacros, saveMacros, setSendFn } = useMacrosStore();
+  const [editingMacro, setEditingMacro] = useState<KeySequence | null>(null);
+  const [newMacro, setNewMacro] = useState<Partial<KeySequence>>({
+    name: "",
+    description: "",
+    steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }],
+  });
+  const [isDragging, setIsDragging] = useState(false);
+  const dragItem = useRef<number | null>(null);
+  const dragOverItem = useRef<number | null>(null);
+  
+  const [macroToDelete, setMacroToDelete] = useState<string | null>(null);
+  
+  const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
+  const [editKeyQueries, setEditKeyQueries] = useState<Record<number, string>>({});
+  
+  const [errorMessage, setErrorMessage] = useState<string | null>(null);
+  const [isDesktop, setIsDesktop] = useState(window.innerWidth >= 768);
+  
+  const [send] = useJsonRpc();
+
+  const isMaxMacrosReached = macros.length >= MAX_TOTAL_MACROS;
+  const isMaxStepsReachedForNewMacro = (newMacro.steps?.length || 0) >= MAX_STEPS_PER_MACRO;
+  
+  const showTemporaryError = useCallback((message: string) => {
+    setErrorMessage(message);
+    setTimeout(() => setErrorMessage(null), 3000);
+  }, []);
+  
+  // Helper for both new and edit key select
+  const handleKeySelectUpdate = (stepIndex: number, option: KeyOptionData, isEditing = false) => {
+    if (isEditing && editingMacro) {
+      const updatedSteps = updateStepKeys(editingMacro.steps, stepIndex, option, showTemporaryError);
+      setEditingMacro({ ...editingMacro, steps: updatedSteps });
+    } else {
+      const updatedSteps = updateStepKeys(newMacro.steps || [], stepIndex, option, showTemporaryError);
+      setNewMacro({ ...newMacro, steps: updatedSteps });
+    }
+  };
+  
+  const handleKeySelect = (stepIndex: number, option: KeyOptionData) => {
+    handleKeySelectUpdate(stepIndex, option, false);
+  };
+  
+  const handleEditKeySelect = (stepIndex: number, option: KeyOptionData) => {
+    handleKeySelectUpdate(stepIndex, option, true);
+  };
+  
+  const handleKeyQueryChange = (stepIndex: number, query: string) => {
+    setKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
+  };
+  
+  const handleEditKeyQueryChange = (stepIndex: number, query: string) => {
+    setEditKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
+  };
+  
+  const getFilteredKeys = (stepIndex: number, isEditing = false) => {
+    const query = isEditing 
+      ? (editKeyQueries[stepIndex] || '')
+      : (keyQueries[stepIndex] || '');
+    
+    if (query === '') {
+      return keyOptions;
+    } else {
+      return keyOptions.filter(option => option.label.toLowerCase().includes(query.toLowerCase()));
+    }
+  };
+  
+  useEffect(() => {
+    setSendFn(send);
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros, setSendFn, send]);
+  
+  const [errors, setErrors] = useState<ValidationErrors>({});
+  
+  const clearErrors = useCallback(() => {
+    setErrors({});
+  }, []);
+  
+  const validateMacro = (macro: Partial<KeySequence>): ValidationErrors => {
+    const errors: ValidationErrors = {};
+
+    // Name validation
+    if (!macro.name?.trim()) {
+      errors.name = "Name is required";
+    } else if (macro.name.trim().length > 50) {
+      errors.name = "Name must be less than 50 characters";
+    }
+
+    // Description validation (optional)
+    if (macro.description && macro.description.trim().length > 200) {
+      errors.description = "Description must be less than 200 characters";
+    }
+
+    // Steps validation
+    if (!macro.steps?.length) {
+      errors.steps = { 0: { keys: "At least one step is required" } };
+      return errors;
+    }
+
+    // Check if at least one step has keys or modifiers
+    const hasKeyOrModifier = macro.steps.some(step => 
+      (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0
+    );
+
+    if (!hasKeyOrModifier) {
+      errors.steps = { 0: { keys: "At least one step must have keys or modifiers" } };
+      return errors;
+    }
+
+    const stepErrors: Record<number, StepError> = {};
+    
+    macro.steps.forEach((step, index) => {
+      const stepError: StepError = {};
+
+      // Keys validation (only if keys are present)
+      if (step.keys?.length && step.keys.length > MAX_KEYS_PER_STEP) {
+        stepError.keys = `Maximum ${MAX_KEYS_PER_STEP} keys allowed`;
+      }
+
+      // Delay validation
+      if (typeof step.delay !== 'number' || step.delay < 0) {
+        stepError.delay = "Invalid delay value";
+      }
+
+      if (Object.keys(stepError).length > 0) {
+        stepErrors[index] = stepError;
+      }
+    });
+
+    if (Object.keys(stepErrors).length > 0) {
+      errors.steps = stepErrors;
+    }
+
+    return errors;
+  };
+
+  const resetNewMacro = () => {
+    setNewMacro({
+      name: "",
+      description: "",
+      steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }],
+    });
+    setKeyQueries({});
+    setErrors({});
+  };
+
+  const [isSaving, setIsSaving] = useState(false);
+  const [isUpdating, setIsUpdating] = useState(false);
+  const [isDeleting, setIsDeleting] = useState(false);
+
+  const handleAddMacro = useCallback(async () => {
+    if (isMaxMacrosReached) {
+      showTemporaryError(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    const validationErrors = validateMacro(newMacro);
+    if (Object.keys(validationErrors).length > 0) {
+      setErrors(validationErrors);
+      return;
+    }
+
+    setIsSaving(true);
+    try {
+      const macro: KeySequence = {
+        id: generateId(),
+        name: newMacro.name!.trim(),
+        description: newMacro.description?.trim() || "",
+        steps: newMacro.steps || [],
+        sortOrder: macros.length + 1,
+      };
+
+      await saveMacros(normalizeSortOrders([...macros, macro]));
+      resetNewMacro();
+      setShowAddMacro(false);
+      notifications.success(`Macro "${macro.name}" created successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to create macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to create macro");
+        showTemporaryError("Failed to save macro");
+      }
+    } finally {
+      setIsSaving(false);
+    }
+  }, [isMaxMacrosReached, newMacro, macros, saveMacros, showTemporaryError]);
+
+  const handleDragStart = (index: number) => {
+    dragItem.current = index;
+    setIsDragging(true);
+    
+    const allItems = document.querySelectorAll('[data-macro-item]');
+    const draggedElement = allItems[index];
+    if (draggedElement) {
+      draggedElement.classList.add('dragging');
+    }
+  };
+  
+  const handleDragOver = (e: React.DragEvent, index: number) => {
+    e.preventDefault();
+    dragOverItem.current = index;
+    
+    const allItems = document.querySelectorAll('[data-macro-item]');
+    allItems.forEach(el => el.classList.remove('drop-target'));
+    
+    const targetElement = allItems[index];
+    if (targetElement) {
+      targetElement.classList.add('drop-target');
+    }
+  };
+  
+  const handleDrop = async (e: React.DragEvent) => {
+    e.preventDefault();
+    if (dragItem.current === null || dragOverItem.current === null) return;
+    
+    const macroCopy = [...macros];
+    const draggedItem = macroCopy.splice(dragItem.current, 1)[0];
+    macroCopy.splice(dragOverItem.current, 0, draggedItem);
+    const updatedMacros = normalizeSortOrders(macroCopy);
+
+    try {
+      await saveMacros(updatedMacros);
+      notifications.success("Macro order updated successfully");
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to reorder macros: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to reorder macros");
+        showTemporaryError("Failed to save reordered macros");
+      }
+    }
+    
+    const allItems = document.querySelectorAll('[data-macro-item]');
+    allItems.forEach(el => {
+      el.classList.remove('drop-target');
+      el.classList.remove('dragging');
+    });
+    
+    dragItem.current = null;
+    dragOverItem.current = null;
+    setIsDragging(false);
+  };
+
+  const handleUpdateMacro = useCallback(async () => {
+    if (!editingMacro) return;
+
+    const validationErrors = validateMacro(editingMacro);
+    if (Object.keys(validationErrors).length > 0) {
+      setErrors(validationErrors);
+      return;
+    }
+
+    setIsUpdating(true);
+    try {
+      const newMacros = macros.map(m => 
+        m.id === editingMacro.id ? {
+          ...editingMacro,
+          name: editingMacro.name.trim(),
+          description: editingMacro.description?.trim() || "",
+        } : m
+      );
+
+      await saveMacros(normalizeSortOrders(newMacros));
+      setEditingMacro(null);
+      clearErrors();
+      notifications.success(`Macro "${editingMacro.name}" updated successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to update macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to update macro");
+        showTemporaryError("Failed to update macro");
+      }
+    } finally {
+      setIsUpdating(false);
+    }
+  }, [editingMacro, macros, saveMacros, showTemporaryError, clearErrors]);
+
+  const handleEditMacro = (macro: KeySequence) => {
+    setEditingMacro({
+      ...macro,
+      description: macro.description || "",
+      steps: macro.steps.map(step => ({
+        ...step,
+        keys: ensureArray(step.keys),
+        modifiers: ensureArray(step.modifiers),
+        delay: typeof step.delay === 'number' ? step.delay : DEFAULT_DELAY
+      }))
+    });
+    clearErrors();
+    setEditKeyQueries({});
+  };
+
+  const handleDeleteMacro = async (id: string) => {
+    const macroToBeDeleted = macros.find(m => m.id === id);
+    if (!macroToBeDeleted) return;
+
+    setIsDeleting(true);
+    try {
+      const updatedMacros = normalizeSortOrders(macros.filter(macro => macro.id !== id));
+      await saveMacros(updatedMacros);
+      if (editingMacro?.id === id) {
+        setEditingMacro(null);
+      }
+      setMacroToDelete(null);
+      notifications.success(`Macro "${macroToBeDeleted.name}" deleted successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to delete macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to delete macro");
+        showTemporaryError("Failed to delete macro");
+      }
+    } finally {
+      setIsDeleting(false);
+    }
+  };
+
+  const handleDuplicateMacro = async (macro: KeySequence) => {
+    if (isMaxMacrosReached) {
+      showTemporaryError(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    const newMacroCopy: KeySequence = {
+      ...JSON.parse(JSON.stringify(macro)),
+      id: generateId(),
+      name: `${macro.name} (copy)`,
+      sortOrder: macros.length + 1,
+    };
+
+    newMacroCopy.steps = newMacroCopy.steps.map(step => ({
+      ...step,
+      keys: ensureArray(step.keys),
+      modifiers: ensureArray(step.modifiers),
+      delay: step.delay || DEFAULT_DELAY
+    }));
+
+    try {
+      await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
+      notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to duplicate macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to duplicate macro");
+        showTemporaryError("Failed to duplicate macro");
+      }
+    }
+  };
+
+  const handleStepMove = (stepIndex: number, direction: 'up' | 'down', steps: MacroStep[]) => {
+    const newSteps = [...steps];
+    const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
+    [newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]];
+    return newSteps;
+  };
+
+  useEffect(() => {
+    const handleResize = () => {
+      setIsDesktop(window.innerWidth >= 768);
+    };
+    
+    window.addEventListener('resize', handleResize);
+    return () => window.removeEventListener('resize', handleResize);
+  }, []);
+
+  const { handleTouchStart, handleTouchMove, handleTouchEnd } = useTouchSort(
+    macros,
+    async (newMacros) => {
+      const updatedMacros = normalizeSortOrders(newMacros);
+      try {
+        await saveMacros(updatedMacros);
+        notifications.success("Macro order updated successfully");
+      } catch (error) {
+        if (error instanceof Error) {
+          notifications.error(`Failed to reorder macros: ${error.message}`);
+          showTemporaryError(error.message);
+        } else {
+          notifications.error("Failed to reorder macros");
+          showTemporaryError("Failed to save reordered macros");
+        }
+      }
+    }
+  );
+
+  const [showClearConfirm, setShowClearConfirm] = useState(false);
+  const [showAddMacro, setShowAddMacro] = useState(false);
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && editingMacro) {
+        setEditingMacro(null);
+        setErrors({});
+      }
+      if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
+        if (editingMacro) {
+          handleUpdateMacro();
+        } else if (!isMaxMacrosReached) {
+          handleAddMacro();
+        }
+      }
+    };
+
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [editingMacro, isMaxMacrosReached, handleAddMacro, handleUpdateMacro]);
+
+  const handleModifierChange = (stepIndex: number, modifiers: string[]) => {
+    if (editingMacro) {
+      const newSteps = [...editingMacro.steps];
+      newSteps[stepIndex].modifiers = modifiers;
+      setEditingMacro({ ...editingMacro, steps: newSteps });
+    } else {
+      const newSteps = [...(newMacro.steps || [])];
+      newSteps[stepIndex].modifiers = modifiers;
+      setNewMacro({ ...newMacro, steps: newSteps });
+    }
+  };
+
+  const handleDelayChange = (stepIndex: number, delay: number) => {
+    if (editingMacro) {
+      const newSteps = [...editingMacro.steps];
+      newSteps[stepIndex].delay = delay;
+      setEditingMacro({ ...editingMacro, steps: newSteps });
+    } else {
+      const newSteps = [...(newMacro.steps || [])];
+      newSteps[stepIndex].delay = delay;
+      setNewMacro({ ...newMacro, steps: newSteps });
+    }
+  };
+
+  const ErrorMessage = ({ error }: { error?: string }) => {
+    if (!error) return null;
+    return (
+      <p className="mt-1 text-xs text-red-500 dark:text-red-400">
+        {error}
+      </p>
+    );
+  };
+
+  return (
+    <div className="space-y-4">
+      <SettingsPageHeader
+        title="Keyboard Macros"
+        description="Create and manage keyboard macros for quick actions"
+      />
+
+      {errorMessage && (
+        <div className="macro-error">
+          <div className="flex">
+            <div className="flex-shrink-0">
+              <svg className="macro-error-icon" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
+                <path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.414 1.414L10 11.414l1.72 1.72a.75.75 0 101.414-1.414L11.414 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.586 8.28 7.22z" clipRule="evenodd" />
+              </svg>
+            </div>
+            <div className="ml-3">
+              <h3 className="macro-error-text">{errorMessage}</h3>
+            </div>
+          </div>
+        </div>
+      )}
+
+      <div className="flex items-center justify-between mb-4">
+        <SettingsItem 
+          title="Macros"
+          description={`${macros.length}/${MAX_TOTAL_MACROS}`}
+        >
+          <div className="flex items-center gap-2">
+            {!showAddMacro && (
+              <Button
+                size="SM"
+                theme="primary"
+                text={isMaxMacrosReached ? `Max Reached` : "Add New Macro"}
+                onClick={() => setShowAddMacro(true)}
+                disabled={isMaxMacrosReached}
+              />
+            )}
+          </div>
+        </SettingsItem>
+      </div>
+
+      {loading && (
+        <div className="flex items-center justify-center p-8">
+          <LuLoader className="h-6 w-6 animate-spin text-blue-500" />
+        </div>
+      )}
+      <div className={`space-y-4 ${loading ? 'hidden' : ''}`}>
+        {showAddMacro && (
+          <div className="rounded-md border border-slate-200 bg-white p-3 dark:border-slate-700 dark:bg-slate-800">
+            <div className="flex items-center justify-between mb-2">
+              <h3 className="text-sm font-semibold text-black dark:text-white">Add New Macro</h3>
+            </div>
+            <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
+              <div className="flex flex-col">
+                <input
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: Savid
body: converted everything over that i could see, lmk if i missed a shared component
created_at: 2025-04-03T08:24:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,1439 @@
+import { useState, useEffect, useRef, useCallback } from "react";
+import { LuPlus, LuTrash, LuX, LuPenLine, LuLoader, LuGripVertical, LuInfo } from "react-icons/lu";
+import { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
+
+import { KeySequence, useMacrosStore } from "../hooks/stores";
+import { SettingsPageHeader } from "../components/SettingsPageheader";
+import { Button } from "../components/Button";
+import { keys, modifiers } from "../keyboardMappings";
+import { useJsonRpc } from "../hooks/useJsonRpc";
+import notifications from "../notifications";
+import { SettingsItem } from "../routes/devices.$id.settings";
+
+const DEFAULT_DELAY = 50;
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface KeyOption {
+  value: string;
+  label: string;
+}
+
+interface KeyOptionData {
+  value: string | null;
+  keys?: string[];
+  label?: string;
+}
+
+const generateId = () => {
+  return `macro-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
+};
+
+const keyOptions = Object.keys(keys).map(key => ({
+  value: key,
+  label: key,
+}));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+interface KeyComboboxProps {
+  stepIndex: number;
+  step: MacroStep;
+  onSelect: (option: KeyOptionData) => void;
+  query: string;
+  onQueryChange: (query: string) => void;
+  getFilteredOptions: () => KeyOption[];
+  disabled?: boolean;
+}
+
+function KeyCombobox({
+  onSelect,
+  query,
+  onQueryChange,
+  getFilteredOptions,
+  disabled = false,
+}: KeyComboboxProps) {
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  return (
+    <div className="relative w-full">
+      <Combobox immediate onChange={onSelect} disabled={disabled}>
+        {() => (
+          <>
+            <div className="relative">
+              <ComboboxInput
+                ref={inputRef}
+                className={`macro-input ${disabled ? 'opacity-60 cursor-not-allowed' : ''}`}
+                placeholder={disabled ? "Max keys reached" : "Search for key..."}
+                displayValue={() => query}
+                onChange={(event) => onQueryChange(event.target.value)}
+                disabled={disabled}
+              />
+            </div>
+            
+            <ComboboxOptions className="absolute left-0 z-50 mt-1 w-full max-h-60 overflow-auto rounded-md bg-white dark:bg-slate-800 py-1 text-sm shadow-lg">
+              {getFilteredOptions().map((option) => (
+                <ComboboxOption 
+                  key={option.value} 
+                  value={option}
+                  className="cursor-default select-none py-1.5 px-3 ui-active:bg-blue-100 ui-active:text-blue-900 dark:text-slate-300 dark:ui-active:bg-blue-900/40 dark:ui-active:text-blue-200"
+                >
+                  {option.label}
+                </ComboboxOption>
+              ))}
+              {getFilteredOptions().length === 0 && (
+                <div className="py-2 px-3 text-sm text-slate-500 dark:text-slate-400">
+                  No matching keys found
+                </div>
+              )}
+            </ComboboxOptions>
+          </>
+        )}
+      </Combobox>
+    </div>
+  );
+}
+
+const PRESET_DELAYS = [
+  { value: 50, label: "50ms" },
+  { value: 100, label: "100ms" },
+  { value: 200, label: "200ms" },
+  { value: 300, label: "300ms" },
+  { value: 500, label: "500ms" },
+  { value: 750, label: "750ms" },
+  { value: 1000, label: "1000ms" },
+  { value: 1500, label: "1500ms" },
+  { value: 2000, label: "2000ms" },
+];
+
+const MAX_STEPS_PER_MACRO = 10;
+const MAX_TOTAL_MACROS = 25;
+const MAX_KEYS_PER_STEP = 10;
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+// Helper function to normalize sort orders, ensuring they start at 1 and have no gaps
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  isDesktop: boolean;
+  onKeySelect: (option: KeyOptionData) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  getFilteredKeys: () => KeyOption[];
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  getFilteredKeys,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  return (
+    <div className="macro-step-card rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 p-4 shadow-sm">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <button
+              type="button"
+              className="p-1 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400 disabled:opacity-50"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="m18 15-6-6-6 6"/>
+              </svg>
+            </button>
+            <button
+              type="button"
+              className="p-1 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400 disabled:opacity-50"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="m6 9 6 6 6-6"/>
+              </svg>
+            </button>
+          </div>
+          <span className="macro-step-number flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+        
+        <div className="flex items-center space-x-2">
+          {onDelete && (
+            <button
+              type="button"
+              className="flex items-center text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
+              onClick={onDelete}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="M3 6h18"></path>
+                <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
+                <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
+              </svg>
+              <span className="ml-1">Delete</span>
+            </button>
+          )}
+        </div>
+      </div>
+      
+      <div className="space-y-4 mt-2">
+        <div className="w-full flex flex-col gap-2">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Modifiers:
+          </label>
+          <div className="macro-modifiers-container inline-flex flex-wrap gap-3">
+            {Object.entries(groupedModifiers).map(([group, mods]) => (
+              <div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
+                <span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
+                  {group}
+                </span>
+                <div className="flex flex-wrap gap-1">
+                  {mods.map(option => (
+                    <label 
+                      key={option.value} 
+                      className={`flex items-center px-2 py-1 rounded border cursor-pointer text-xs font-medium transition-colors ${
+                        ensureArray(step.modifiers).includes(option.value) 
+                          ? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-200' 
+                          : 'bg-slate-100 border-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700'
+                      }`}
+                    >
+                      <input
+                        type="checkbox"
+                        className="sr-only"
+                        checked={ensureArray(step.modifiers).includes(option.value)}
+                        onChange={e => {
+                          const modifiersArray = ensureArray(step.modifiers);
+                          const newModifiers = e.target.checked
+                            ? [...modifiersArray, option.value]
+                            : modifiersArray.filter(m => m !== option.value);
+                          onModifierChange(newModifiers);
+                        }}
+                      />
+                      {option.label.split(' ')[1] || option.label}
+                    </label>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Keys:
+          </label>
+          
+          <div className="macro-key-group flex flex-wrap gap-1 mb-2">
+            {ensureArray(step.keys).map((key, keyIndex) => (
+              <span
+                key={keyIndex}
+                className="macro-key-badge inline-flex items-center rounded-md bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
+              >
+                {key}
+                <button
+                  type="button"
+                  className="ml-1 text-xs text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
+                  onClick={() => {
+                    const newKeys = ensureArray(step.keys).filter((_, i) => i !== keyIndex);
+                    onKeySelect({ value: null, keys: newKeys });
+                  }}
+                >
+                  ×
+                </button>
+              </span>
+            ))}
+          </div>
+
+          <KeyCombobox
+            stepIndex={stepIndex}
+            step={step}
+            onSelect={onKeySelect}
+            query={keyQuery}
+            onQueryChange={onKeyQueryChange}
+            getFilteredOptions={getFilteredKeys}
+            disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP}
+          />
+          
+          {ensureArray(step.keys).length >= MAX_KEYS_PER_STEP && (
+            <span className="text-xs text-amber-600 dark:text-amber-400 mt-1">
+              (max keys reached)
+            </span>
+          )}
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+              Step Duration:
+            </label>
+            <div className="group relative">
+              <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+              <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                <p>The time to wait after pressing the keys in this step before moving to the next step. This helps ensure reliable key presses when automating keyboard input.</p>
+              </div>
+            </div>
+          </div>
+          <div className="flex items-center gap-3">
+            <select
+              className="w-full rounded-md border border-slate-300 bg-slate-50 p-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
+              value={step.delay}
+              onChange={(e) => onDelayChange(parseInt(e.target.value, 10))}
+            >
+              {PRESET_DELAYS.map((option) => (
+                <option key={option.value} value={option.value}>
+                  {option.label}
+                </option>
+              ))}
+            </select>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// Helper to update step keys used by both new and edit flows
+const updateStepKeys = (
+  steps: MacroStep[],
+  stepIndex: number,
+  keyOption: { value: string | null; keys?: string[] },
+  showTemporaryError: (msg: string) => void
+) => {
+  const newSteps = [...steps];
+  if (keyOption.keys) {
+    newSteps[stepIndex].keys = keyOption.keys;
+  } else if (keyOption.value) {
+    if (!newSteps[stepIndex].keys) {
+      newSteps[stepIndex].keys = [];
+    }
+    const keysArray = ensureArray(newSteps[stepIndex].keys);
+    if (keysArray.length >= MAX_KEYS_PER_STEP) {
+      showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`);
+      return newSteps;
+    }
+    newSteps[stepIndex].keys = [...keysArray, keyOption.value];
+  }
+  return newSteps;
+};
+
+const useTouchSort = (items: KeySequence[], onSort: (newItems: KeySequence[]) => void) => {
+  const [touchStartY, setTouchStartY] = useState<number | null>(null);
+  const [touchedIndex, setTouchedIndex] = useState<number | null>(null);
+
+  const handleTouchStart = useCallback((e: React.TouchEvent, index: number) => {
+    const touch = e.touches[0];
+    setTouchStartY(touch.clientY);
+    setTouchedIndex(index);
+    
+    const element = e.currentTarget as HTMLElement;
+    const rect = element.getBoundingClientRect();
+    
+    // Create ghost element
+    const ghost = element.cloneNode(true) as HTMLElement;
+    ghost.id = 'ghost-macro';
+    ghost.className = 'macro-sortable ghost';
+    ghost.style.height = `${rect.height}px`;
+    element.parentNode?.insertBefore(ghost, element);
+    
+    // Set up dragged element
+    element.style.position = 'fixed';
+    element.style.left = `${rect.left}px`;
+    element.style.top = `${rect.top}px`;
+    element.style.width = `${rect.width}px`;
+    element.style.zIndex = '50';
+  }, []);
+
+  const handleTouchMove = useCallback((e: React.TouchEvent) => {
+    if (touchStartY === null || touchedIndex === null) return;
+    
+    const touch = e.touches[0];
+    const deltaY = touch.clientY - touchStartY;
+    const element = e.currentTarget as HTMLElement;
+    
+    // Smooth movement of dragged element
+    element.style.transform = `translateY(${deltaY}px)`;
+    
+    const macroElements = document.querySelectorAll('[data-macro-item]');
+    const draggedRect = element.getBoundingClientRect();
+    const draggedMiddle = draggedRect.top + draggedRect.height / 2;
+    
+    macroElements.forEach((el, i) => {
+      if (i === touchedIndex) return;
+      
+      const rect = el.getBoundingClientRect();
+      const elementMiddle = rect.top + rect.height / 2;
+      const distance = Math.abs(draggedMiddle - elementMiddle);
+      
+      if (distance < rect.height) {
+        const direction = draggedMiddle > elementMiddle ? -1 : 1;
+        (el as HTMLElement).style.transform = `translateY(${direction * rect.height}px)`;
+        (el as HTMLElement).style.transition = 'transform 0.15s ease-out';
+      } else {
+        (el as HTMLElement).style.transform = '';
+        (el as HTMLElement).style.transition = 'transform 0.15s ease-out';
+      }
+    });
+  }, [touchStartY, touchedIndex]);
+
+  const handleTouchEnd = useCallback(async (e: React.TouchEvent) => {
+    if (touchedIndex === null) return;
+    
+    const element = e.currentTarget as HTMLElement;
+    const touch = e.changedTouches[0];
+    
+    // Remove ghost element
+    const ghost = document.getElementById('ghost-macro');
+    ghost?.parentNode?.removeChild(ghost);
+    
+    // Reset dragged element styles
+    element.style.position = '';
+    element.style.left = '';
+    element.style.top = '';
+    element.style.width = '';
+    element.style.zIndex = '';
+    element.style.transform = '';
+    element.style.boxShadow = '';
+    element.style.transition = '';
+    
+    const macroElements = document.querySelectorAll('[data-macro-item]');
+    let targetIndex = touchedIndex;
+    
+    // Find the closest element to the final touch position
+    const finalY = touch.clientY;
+    let closestDistance = Infinity;
+    
+    macroElements.forEach((el, i) => {
+      if (i === touchedIndex) return;
+      
+      const rect = el.getBoundingClientRect();
+      const distance = Math.abs(finalY - (rect.top + rect.height / 2));
+      
+      if (distance < closestDistance) {
+        closestDistance = distance;
+        targetIndex = i;
+      }
+      
+      // Reset other elements
+      (el as HTMLElement).style.transform = '';
+      (el as HTMLElement).style.transition = '';
+    });
+    
+    if (targetIndex !== touchedIndex && closestDistance < 50) {
+      const itemsCopy = [...items];
+      const [draggedItem] = itemsCopy.splice(touchedIndex, 1);
+      itemsCopy.splice(targetIndex, 0, draggedItem);
+      onSort(itemsCopy);
+    }
+    
+    setTouchStartY(null);
+    setTouchedIndex(null);
+  }, [touchedIndex, items, onSort]);
+
+  return { handleTouchStart, handleTouchMove, handleTouchEnd };
+};
+
+interface StepError {
+  keys?: string;
+  modifiers?: string;
+  delay?: string;
+}
+
+interface ValidationErrors {
+  name?: string;
+  description?: string;
+  steps?: Record<number, StepError>;
+}
+
+export default function SettingsMacrosRoute() {
+  const { macros, loading, initialized, loadMacros, saveMacros, setSendFn } = useMacrosStore();
+  const [editingMacro, setEditingMacro] = useState<KeySequence | null>(null);
+  const [newMacro, setNewMacro] = useState<Partial<KeySequence>>({
+    name: "",
+    description: "",
+    steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }],
+  });
+  const [isDragging, setIsDragging] = useState(false);
+  const dragItem = useRef<number | null>(null);
+  const dragOverItem = useRef<number | null>(null);
+  
+  const [macroToDelete, setMacroToDelete] = useState<string | null>(null);
+  
+  const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
+  const [editKeyQueries, setEditKeyQueries] = useState<Record<number, string>>({});
+  
+  const [errorMessage, setErrorMessage] = useState<string | null>(null);
+  const [isDesktop, setIsDesktop] = useState(window.innerWidth >= 768);
+  
+  const [send] = useJsonRpc();
+
+  const isMaxMacrosReached = macros.length >= MAX_TOTAL_MACROS;
+  const isMaxStepsReachedForNewMacro = (newMacro.steps?.length || 0) >= MAX_STEPS_PER_MACRO;
+  
+  const showTemporaryError = useCallback((message: string) => {
+    setErrorMessage(message);
+    setTimeout(() => setErrorMessage(null), 3000);
+  }, []);
+  
+  // Helper for both new and edit key select
+  const handleKeySelectUpdate = (stepIndex: number, option: KeyOptionData, isEditing = false) => {
+    if (isEditing && editingMacro) {
+      const updatedSteps = updateStepKeys(editingMacro.steps, stepIndex, option, showTemporaryError);
+      setEditingMacro({ ...editingMacro, steps: updatedSteps });
+    } else {
+      const updatedSteps = updateStepKeys(newMacro.steps || [], stepIndex, option, showTemporaryError);
+      setNewMacro({ ...newMacro, steps: updatedSteps });
+    }
+  };
+  
+  const handleKeySelect = (stepIndex: number, option: KeyOptionData) => {
+    handleKeySelectUpdate(stepIndex, option, false);
+  };
+  
+  const handleEditKeySelect = (stepIndex: number, option: KeyOptionData) => {
+    handleKeySelectUpdate(stepIndex, option, true);
+  };
+  
+  const handleKeyQueryChange = (stepIndex: number, query: string) => {
+    setKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
+  };
+  
+  const handleEditKeyQueryChange = (stepIndex: number, query: string) => {
+    setEditKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
+  };
+  
+  const getFilteredKeys = (stepIndex: number, isEditing = false) => {
+    const query = isEditing 
+      ? (editKeyQueries[stepIndex] || '')
+      : (keyQueries[stepIndex] || '');
+    
+    if (query === '') {
+      return keyOptions;
+    } else {
+      return keyOptions.filter(option => option.label.toLowerCase().includes(query.toLowerCase()));
+    }
+  };
+  
+  useEffect(() => {
+    setSendFn(send);
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros, setSendFn, send]);
+  
+  const [errors, setErrors] = useState<ValidationErrors>({});
+  
+  const clearErrors = useCallback(() => {
+    setErrors({});
+  }, []);
+  
+  const validateMacro = (macro: Partial<KeySequence>): ValidationErrors => {
+    const errors: ValidationErrors = {};
+
+    // Name validation
+    if (!macro.name?.trim()) {
+      errors.name = "Name is required";
+    } else if (macro.name.trim().length > 50) {
+      errors.name = "Name must be less than 50 characters";
+    }
+
+    // Description validation (optional)
+    if (macro.description && macro.description.trim().length > 200) {
+      errors.description = "Description must be less than 200 characters";
+    }
+
+    // Steps validation
+    if (!macro.steps?.length) {
+      errors.steps = { 0: { keys: "At least one step is required" } };
+      return errors;
+    }
+
+    // Check if at least one step has keys or modifiers
+    const hasKeyOrModifier = macro.steps.some(step => 
+      (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0
+    );
+
+    if (!hasKeyOrModifier) {
+      errors.steps = { 0: { keys: "At least one step must have keys or modifiers" } };
+      return errors;
+    }
+
+    const stepErrors: Record<number, StepError> = {};
+    
+    macro.steps.forEach((step, index) => {
+      const stepError: StepError = {};
+
+      // Keys validation (only if keys are present)
+      if (step.keys?.length && step.keys.length > MAX_KEYS_PER_STEP) {
+        stepError.keys = `Maximum ${MAX_KEYS_PER_STEP} keys allowed`;
+      }
+
+      // Delay validation
+      if (typeof step.delay !== 'number' || step.delay < 0) {
+        stepError.delay = "Invalid delay value";
+      }
+
+      if (Object.keys(stepError).length > 0) {
+        stepErrors[index] = stepError;
+      }
+    });
+
+    if (Object.keys(stepErrors).length > 0) {
+      errors.steps = stepErrors;
+    }
+
+    return errors;
+  };
+
+  const resetNewMacro = () => {
+    setNewMacro({
+      name: "",
+      description: "",
+      steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }],
+    });
+    setKeyQueries({});
+    setErrors({});
+  };
+
+  const [isSaving, setIsSaving] = useState(false);
+  const [isUpdating, setIsUpdating] = useState(false);
+  const [isDeleting, setIsDeleting] = useState(false);
+
+  const handleAddMacro = useCallback(async () => {
+    if (isMaxMacrosReached) {
+      showTemporaryError(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    const validationErrors = validateMacro(newMacro);
+    if (Object.keys(validationErrors).length > 0) {
+      setErrors(validationErrors);
+      return;
+    }
+
+    setIsSaving(true);
+    try {
+      const macro: KeySequence = {
+        id: generateId(),
+        name: newMacro.name!.trim(),
+        description: newMacro.description?.trim() || "",
+        steps: newMacro.steps || [],
+        sortOrder: macros.length + 1,
+      };
+
+      await saveMacros(normalizeSortOrders([...macros, macro]));
+      resetNewMacro();
+      setShowAddMacro(false);
+      notifications.success(`Macro "${macro.name}" created successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to create macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to create macro");
+        showTemporaryError("Failed to save macro");
+      }
+    } finally {
+      setIsSaving(false);
+    }
+  }, [isMaxMacrosReached, newMacro, macros, saveMacros, showTemporaryError]);
+
+  const handleDragStart = (index: number) => {
+    dragItem.current = index;
+    setIsDragging(true);
+    
+    const allItems = document.querySelectorAll('[data-macro-item]');
+    const draggedElement = allItems[index];
+    if (draggedElement) {
+      draggedElement.classList.add('dragging');
+    }
+  };
+  
+  const handleDragOver = (e: React.DragEvent, index: number) => {
+    e.preventDefault();
+    dragOverItem.current = index;
+    
+    const allItems = document.querySelectorAll('[data-macro-item]');
+    allItems.forEach(el => el.classList.remove('drop-target'));
+    
+    const targetElement = allItems[index];
+    if (targetElement) {
+      targetElement.classList.add('drop-target');
+    }
+  };
+  
+  const handleDrop = async (e: React.DragEvent) => {
+    e.preventDefault();
+    if (dragItem.current === null || dragOverItem.current === null) return;
+    
+    const macroCopy = [...macros];
+    const draggedItem = macroCopy.splice(dragItem.current, 1)[0];
+    macroCopy.splice(dragOverItem.current, 0, draggedItem);
+    const updatedMacros = normalizeSortOrders(macroCopy);
+
+    try {
+      await saveMacros(updatedMacros);
+      notifications.success("Macro order updated successfully");
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to reorder macros: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to reorder macros");
+        showTemporaryError("Failed to save reordered macros");
+      }
+    }
+    
+    const allItems = document.querySelectorAll('[data-macro-item]');
+    allItems.forEach(el => {
+      el.classList.remove('drop-target');
+      el.classList.remove('dragging');
+    });
+    
+    dragItem.current = null;
+    dragOverItem.current = null;
+    setIsDragging(false);
+  };
+
+  const handleUpdateMacro = useCallback(async () => {
+    if (!editingMacro) return;
+
+    const validationErrors = validateMacro(editingMacro);
+    if (Object.keys(validationErrors).length > 0) {
+      setErrors(validationErrors);
+      return;
+    }
+
+    setIsUpdating(true);
+    try {
+      const newMacros = macros.map(m => 
+        m.id === editingMacro.id ? {
+          ...editingMacro,
+          name: editingMacro.name.trim(),
+          description: editingMacro.description?.trim() || "",
+        } : m
+      );
+
+      await saveMacros(normalizeSortOrders(newMacros));
+      setEditingMacro(null);
+      clearErrors();
+      notifications.success(`Macro "${editingMacro.name}" updated successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to update macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to update macro");
+        showTemporaryError("Failed to update macro");
+      }
+    } finally {
+      setIsUpdating(false);
+    }
+  }, [editingMacro, macros, saveMacros, showTemporaryError, clearErrors]);
+
+  const handleEditMacro = (macro: KeySequence) => {
+    setEditingMacro({
+      ...macro,
+      description: macro.description || "",
+      steps: macro.steps.map(step => ({
+        ...step,
+        keys: ensureArray(step.keys),
+        modifiers: ensureArray(step.modifiers),
+        delay: typeof step.delay === 'number' ? step.delay : DEFAULT_DELAY
+      }))
+    });
+    clearErrors();
+    setEditKeyQueries({});
+  };
+
+  const handleDeleteMacro = async (id: string) => {
+    const macroToBeDeleted = macros.find(m => m.id === id);
+    if (!macroToBeDeleted) return;
+
+    setIsDeleting(true);
+    try {
+      const updatedMacros = normalizeSortOrders(macros.filter(macro => macro.id !== id));
+      await saveMacros(updatedMacros);
+      if (editingMacro?.id === id) {
+        setEditingMacro(null);
+      }
+      setMacroToDelete(null);
+      notifications.success(`Macro "${macroToBeDeleted.name}" deleted successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to delete macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to delete macro");
+        showTemporaryError("Failed to delete macro");
+      }
+    } finally {
+      setIsDeleting(false);
+    }
+  };
+
+  const handleDuplicateMacro = async (macro: KeySequence) => {
+    if (isMaxMacrosReached) {
+      showTemporaryError(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    const newMacroCopy: KeySequence = {
+      ...JSON.parse(JSON.stringify(macro)),
+      id: generateId(),
+      name: `${macro.name} (copy)`,
+      sortOrder: macros.length + 1,
+    };
+
+    newMacroCopy.steps = newMacroCopy.steps.map(step => ({
+      ...step,
+      keys: ensureArray(step.keys),
+      modifiers: ensureArray(step.modifiers),
+      delay: step.delay || DEFAULT_DELAY
+    }));
+
+    try {
+      await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
+      notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to duplicate macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to duplicate macro");
+        showTemporaryError("Failed to duplicate macro");
+      }
+    }
+  };
+
+  const handleStepMove = (stepIndex: number, direction: 'up' | 'down', steps: MacroStep[]) => {
+    const newSteps = [...steps];
+    const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
+    [newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]];
+    return newSteps;
+  };
+
+  useEffect(() => {
+    const handleResize = () => {
+      setIsDesktop(window.innerWidth >= 768);
+    };
+    
+    window.addEventListener('resize', handleResize);
+    return () => window.removeEventListener('resize', handleResize);
+  }, []);
+
+  const { handleTouchStart, handleTouchMove, handleTouchEnd } = useTouchSort(
+    macros,
+    async (newMacros) => {
+      const updatedMacros = normalizeSortOrders(newMacros);
+      try {
+        await saveMacros(updatedMacros);
+        notifications.success("Macro order updated successfully");
+      } catch (error) {
+        if (error instanceof Error) {
+          notifications.error(`Failed to reorder macros: ${error.message}`);
+          showTemporaryError(error.message);
+        } else {
+          notifications.error("Failed to reorder macros");
+          showTemporaryError("Failed to save reordered macros");
+        }
+      }
+    }
+  );
+
+  const [showClearConfirm, setShowClearConfirm] = useState(false);
+  const [showAddMacro, setShowAddMacro] = useState(false);
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && editingMacro) {
+        setEditingMacro(null);
+        setErrors({});
+      }
+      if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
+        if (editingMacro) {
+          handleUpdateMacro();
+        } else if (!isMaxMacrosReached) {
+          handleAddMacro();
+        }
+      }
+    };
+
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [editingMacro, isMaxMacrosReached, handleAddMacro, handleUpdateMacro]);
+
+  const handleModifierChange = (stepIndex: number, modifiers: string[]) => {
+    if (editingMacro) {
+      const newSteps = [...editingMacro.steps];
+      newSteps[stepIndex].modifiers = modifiers;
+      setEditingMacro({ ...editingMacro, steps: newSteps });
+    } else {
+      const newSteps = [...(newMacro.steps || [])];
+      newSteps[stepIndex].modifiers = modifiers;
+      setNewMacro({ ...newMacro, steps: newSteps });
+    }
+  };
+
+  const handleDelayChange = (stepIndex: number, delay: number) => {
+    if (editingMacro) {
+      const newSteps = [...editingMacro.steps];
+      newSteps[stepIndex].delay = delay;
+      setEditingMacro({ ...editingMacro, steps: newSteps });
+    } else {
+      const newSteps = [...(newMacro.steps || [])];
+      newSteps[stepIndex].delay = delay;
+      setNewMacro({ ...newMacro, steps: newSteps });
+    }
+  };
+
+  const ErrorMessage = ({ error }: { error?: string }) => {
+    if (!error) return null;
+    return (
+      <p className="mt-1 text-xs text-red-500 dark:text-red-400">
+        {error}
+      </p>
+    );
+  };
+
+  return (
+    <div className="space-y-4">
+      <SettingsPageHeader
+        title="Keyboard Macros"
+        description="Create and manage keyboard macros for quick actions"
+      />
+
+      {errorMessage && (
+        <div className="macro-error">
+          <div className="flex">
+            <div className="flex-shrink-0">
+              <svg className="macro-error-icon" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
+                <path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.414 1.414L10 11.414l1.72 1.72a.75.75 0 101.414-1.414L11.414 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.586 8.28 7.22z" clipRule="evenodd" />
+              </svg>
+            </div>
+            <div className="ml-3">
+              <h3 className="macro-error-text">{errorMessage}</h3>
+            </div>
+          </div>
+        </div>
+      )}
+
+      <div className="flex items-center justify-between mb-4">
+        <SettingsItem 
+          title="Macros"
+          description={`${macros.length}/${MAX_TOTAL_MACROS}`}
+        >
+          <div className="flex items-center gap-2">
+            {!showAddMacro && (
+              <Button
+                size="SM"
+                theme="primary"
+                text={isMaxMacrosReached ? `Max Reached` : "Add New Macro"}
+                onClick={() => setShowAddMacro(true)}
+                disabled={isMaxMacrosReached}
+              />
+            )}
+          </div>
+        </SettingsItem>
+      </div>
+
+      {loading && (
+        <div className="flex items-center justify-center p-8">
+          <LuLoader className="h-6 w-6 animate-spin text-blue-500" />
+        </div>
+      )}
+      <div className={`space-y-4 ${loading ? 'hidden' : ''}`}>
+        {showAddMacro && (
+          <div className="rounded-md border border-slate-200 bg-white p-3 dark:border-slate-700 dark:bg-slate-800">
+            <div className="flex items-center justify-between mb-2">
+              <h3 className="text-sm font-semibold text-black dark:text-white">Add New Macro</h3>
+            </div>
+            <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
+              <div className="flex flex-col">
+                <input
+                  type="text"
+                  className={`macro-input ${errors.name ? 'border-red-500 dark:border-red-500' : ''}`}
+                  value={newMacro.name}
+                  onChange={e => {
+                    setNewMacro(prev => ({ ...prev, name: e.target.value }));
+                    if (errors.name) {
+                      const newErrors = { ...errors };
+                      delete newErrors.name;
+                      setErrors(newErrors);
+                    }
+                  }}
+                  placeholder="Macro Name"
+                />
+                <ErrorMessage error={errors.name} />
+              </div>
+              <div className="flex flex-col">
+                <input
+                  type="text"
+                  className={`macro-input ${errors.description ? 'border-red-500 dark:border-red-500' : ''}`}
+                  value={newMacro.description}
+                  onChange={e => {
+                    setNewMacro(prev => ({ ...prev, description: e.target.value }));
+                    if (errors.description) {
+                      const newErrors = { ...errors };
+                      delete newErrors.description;
+                      setErrors(newErrors);
+                    }
+                  }}
+                  placeholder="Description (optional)"
+                />
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: Savid
body: good idea! removed description
created_at: 2025-04-03T08:24:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,1439 @@
+import { useState, useEffect, useRef, useCallback } from "react";
+import { LuPlus, LuTrash, LuX, LuPenLine, LuLoader, LuGripVertical, LuInfo } from "react-icons/lu";
+import { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
+
+import { KeySequence, useMacrosStore } from "../hooks/stores";
+import { SettingsPageHeader } from "../components/SettingsPageheader";
+import { Button } from "../components/Button";
+import { keys, modifiers } from "../keyboardMappings";
+import { useJsonRpc } from "../hooks/useJsonRpc";
+import notifications from "../notifications";
+import { SettingsItem } from "../routes/devices.$id.settings";
+
+const DEFAULT_DELAY = 50;
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface KeyOption {
+  value: string;
+  label: string;
+}
+
+interface KeyOptionData {
+  value: string | null;
+  keys?: string[];
+  label?: string;
+}
+
+const generateId = () => {
+  return `macro-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
+};
+
+const keyOptions = Object.keys(keys).map(key => ({
+  value: key,
+  label: key,
+}));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+interface KeyComboboxProps {
+  stepIndex: number;
+  step: MacroStep;
+  onSelect: (option: KeyOptionData) => void;
+  query: string;
+  onQueryChange: (query: string) => void;
+  getFilteredOptions: () => KeyOption[];
+  disabled?: boolean;
+}
+
+function KeyCombobox({
+  onSelect,
+  query,
+  onQueryChange,
+  getFilteredOptions,
+  disabled = false,
+}: KeyComboboxProps) {
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  return (
+    <div className="relative w-full">
+      <Combobox immediate onChange={onSelect} disabled={disabled}>
+        {() => (
+          <>
+            <div className="relative">
+              <ComboboxInput
+                ref={inputRef}
+                className={`macro-input ${disabled ? 'opacity-60 cursor-not-allowed' : ''}`}
+                placeholder={disabled ? "Max keys reached" : "Search for key..."}
+                displayValue={() => query}
+                onChange={(event) => onQueryChange(event.target.value)}
+                disabled={disabled}
+              />
+            </div>
+            
+            <ComboboxOptions className="absolute left-0 z-50 mt-1 w-full max-h-60 overflow-auto rounded-md bg-white dark:bg-slate-800 py-1 text-sm shadow-lg">
+              {getFilteredOptions().map((option) => (
+                <ComboboxOption 
+                  key={option.value} 
+                  value={option}
+                  className="cursor-default select-none py-1.5 px-3 ui-active:bg-blue-100 ui-active:text-blue-900 dark:text-slate-300 dark:ui-active:bg-blue-900/40 dark:ui-active:text-blue-200"
+                >
+                  {option.label}
+                </ComboboxOption>
+              ))}
+              {getFilteredOptions().length === 0 && (
+                <div className="py-2 px-3 text-sm text-slate-500 dark:text-slate-400">
+                  No matching keys found
+                </div>
+              )}
+            </ComboboxOptions>
+          </>
+        )}
+      </Combobox>
+    </div>
+  );
+}
+
+const PRESET_DELAYS = [
+  { value: 50, label: "50ms" },
+  { value: 100, label: "100ms" },
+  { value: 200, label: "200ms" },
+  { value: 300, label: "300ms" },
+  { value: 500, label: "500ms" },
+  { value: 750, label: "750ms" },
+  { value: 1000, label: "1000ms" },
+  { value: 1500, label: "1500ms" },
+  { value: 2000, label: "2000ms" },
+];
+
+const MAX_STEPS_PER_MACRO = 10;
+const MAX_TOTAL_MACROS = 25;
+const MAX_KEYS_PER_STEP = 10;
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+// Helper function to normalize sort orders, ensuring they start at 1 and have no gaps
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  isDesktop: boolean;
+  onKeySelect: (option: KeyOptionData) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  getFilteredKeys: () => KeyOption[];
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  getFilteredKeys,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  return (
+    <div className="macro-step-card rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 p-4 shadow-sm">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <button
+              type="button"
+              className="p-1 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400 disabled:opacity-50"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="m18 15-6-6-6 6"/>
+              </svg>
+            </button>
+            <button
+              type="button"
+              className="p-1 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400 disabled:opacity-50"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="m6 9 6 6 6-6"/>
+              </svg>
+            </button>
+          </div>
+          <span className="macro-step-number flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+        
+        <div className="flex items-center space-x-2">
+          {onDelete && (
+            <button
+              type="button"
+              className="flex items-center text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
+              onClick={onDelete}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="M3 6h18"></path>
+                <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
+                <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
+              </svg>
+              <span className="ml-1">Delete</span>
+            </button>
+          )}
+        </div>
+      </div>
+      
+      <div className="space-y-4 mt-2">
+        <div className="w-full flex flex-col gap-2">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Modifiers:
+          </label>
+          <div className="macro-modifiers-container inline-flex flex-wrap gap-3">
+            {Object.entries(groupedModifiers).map(([group, mods]) => (
+              <div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
+                <span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
+                  {group}
+                </span>
+                <div className="flex flex-wrap gap-1">
+                  {mods.map(option => (
+                    <label 
+                      key={option.value} 
+                      className={`flex items-center px-2 py-1 rounded border cursor-pointer text-xs font-medium transition-colors ${
+                        ensureArray(step.modifiers).includes(option.value) 
+                          ? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-200' 
+                          : 'bg-slate-100 border-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700'
+                      }`}
+                    >
+                      <input
+                        type="checkbox"
+                        className="sr-only"
+                        checked={ensureArray(step.modifiers).includes(option.value)}
+                        onChange={e => {
+                          const modifiersArray = ensureArray(step.modifiers);
+                          const newModifiers = e.target.checked
+                            ? [...modifiersArray, option.value]
+                            : modifiersArray.filter(m => m !== option.value);
+                          onModifierChange(newModifiers);
+                        }}
+                      />
+                      {option.label.split(' ')[1] || option.label}
+                    </label>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Keys:
+          </label>
+          
+          <div className="macro-key-group flex flex-wrap gap-1 mb-2">
+            {ensureArray(step.keys).map((key, keyIndex) => (
+              <span
+                key={keyIndex}
+                className="macro-key-badge inline-flex items-center rounded-md bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
+              >
+                {key}
+                <button
+                  type="button"
+                  className="ml-1 text-xs text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
+                  onClick={() => {
+                    const newKeys = ensureArray(step.keys).filter((_, i) => i !== keyIndex);
+                    onKeySelect({ value: null, keys: newKeys });
+                  }}
+                >
+                  ×
+                </button>
+              </span>
+            ))}
+          </div>
+
+          <KeyCombobox
+            stepIndex={stepIndex}
+            step={step}
+            onSelect={onKeySelect}
+            query={keyQuery}
+            onQueryChange={onKeyQueryChange}
+            getFilteredOptions={getFilteredKeys}
+            disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP}
+          />
+          
+          {ensureArray(step.keys).length >= MAX_KEYS_PER_STEP && (
+            <span className="text-xs text-amber-600 dark:text-amber-400 mt-1">
+              (max keys reached)
+            </span>
+          )}
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+              Step Duration:
+            </label>
+            <div className="group relative">
+              <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+              <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                <p>The time to wait after pressing the keys in this step before moving to the next step. This helps ensure reliable key presses when automating keyboard input.</p>
+              </div>
+            </div>
+          </div>
+          <div className="flex items-center gap-3">
+            <select
+              className="w-full rounded-md border border-slate-300 bg-slate-50 p-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
+              value={step.delay}
+              onChange={(e) => onDelayChange(parseInt(e.target.value, 10))}
+            >
+              {PRESET_DELAYS.map((option) => (
+                <option key={option.value} value={option.value}>
+                  {option.label}
+                </option>
+              ))}
+            </select>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// Helper to update step keys used by both new and edit flows
+const updateStepKeys = (
+  steps: MacroStep[],
+  stepIndex: number,
+  keyOption: { value: string | null; keys?: string[] },
+  showTemporaryError: (msg: string) => void
+) => {
+  const newSteps = [...steps];
+  if (keyOption.keys) {
+    newSteps[stepIndex].keys = keyOption.keys;
+  } else if (keyOption.value) {
+    if (!newSteps[stepIndex].keys) {
+      newSteps[stepIndex].keys = [];
+    }
+    const keysArray = ensureArray(newSteps[stepIndex].keys);
+    if (keysArray.length >= MAX_KEYS_PER_STEP) {
+      showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`);
+      return newSteps;
+    }
+    newSteps[stepIndex].keys = [...keysArray, keyOption.value];
+  }
+  return newSteps;
+};
+
+const useTouchSort = (items: KeySequence[], onSort: (newItems: KeySequence[]) => void) => {
+  const [touchStartY, setTouchStartY] = useState<number | null>(null);
+  const [touchedIndex, setTouchedIndex] = useState<number | null>(null);
+
+  const handleTouchStart = useCallback((e: React.TouchEvent, index: number) => {
+    const touch = e.touches[0];
+    setTouchStartY(touch.clientY);
+    setTouchedIndex(index);
+    
+    const element = e.currentTarget as HTMLElement;
+    const rect = element.getBoundingClientRect();
+    
+    // Create ghost element
+    const ghost = element.cloneNode(true) as HTMLElement;
+    ghost.id = 'ghost-macro';
+    ghost.className = 'macro-sortable ghost';
+    ghost.style.height = `${rect.height}px`;
+    element.parentNode?.insertBefore(ghost, element);
+    
+    // Set up dragged element
+    element.style.position = 'fixed';
+    element.style.left = `${rect.left}px`;
+    element.style.top = `${rect.top}px`;
+    element.style.width = `${rect.width}px`;
+    element.style.zIndex = '50';
+  }, []);
+
+  const handleTouchMove = useCallback((e: React.TouchEvent) => {
+    if (touchStartY === null || touchedIndex === null) return;
+    
+    const touch = e.touches[0];
+    const deltaY = touch.clientY - touchStartY;
+    const element = e.currentTarget as HTMLElement;
+    
+    // Smooth movement of dragged element
+    element.style.transform = `translateY(${deltaY}px)`;
+    
+    const macroElements = document.querySelectorAll('[data-macro-item]');
+    const draggedRect = element.getBoundingClientRect();
+    const draggedMiddle = draggedRect.top + draggedRect.height / 2;
+    
+    macroElements.forEach((el, i) => {
+      if (i === touchedIndex) return;
+      
+      const rect = el.getBoundingClientRect();
+      const elementMiddle = rect.top + rect.height / 2;
+      const distance = Math.abs(draggedMiddle - elementMiddle);
+      
+      if (distance < rect.height) {
+        const direction = draggedMiddle > elementMiddle ? -1 : 1;
+        (el as HTMLElement).style.transform = `translateY(${direction * rect.height}px)`;
+        (el as HTMLElement).style.transition = 'transform 0.15s ease-out';
+      } else {
+        (el as HTMLElement).style.transform = '';
+        (el as HTMLElement).style.transition = 'transform 0.15s ease-out';
+      }
+    });
+  }, [touchStartY, touchedIndex]);
+
+  const handleTouchEnd = useCallback(async (e: React.TouchEvent) => {
+    if (touchedIndex === null) return;
+    
+    const element = e.currentTarget as HTMLElement;
+    const touch = e.changedTouches[0];
+    
+    // Remove ghost element
+    const ghost = document.getElementById('ghost-macro');
+    ghost?.parentNode?.removeChild(ghost);
+    
+    // Reset dragged element styles
+    element.style.position = '';
+    element.style.left = '';
+    element.style.top = '';
+    element.style.width = '';
+    element.style.zIndex = '';
+    element.style.transform = '';
+    element.style.boxShadow = '';
+    element.style.transition = '';
+    
+    const macroElements = document.querySelectorAll('[data-macro-item]');
+    let targetIndex = touchedIndex;
+    
+    // Find the closest element to the final touch position
+    const finalY = touch.clientY;
+    let closestDistance = Infinity;
+    
+    macroElements.forEach((el, i) => {
+      if (i === touchedIndex) return;
+      
+      const rect = el.getBoundingClientRect();
+      const distance = Math.abs(finalY - (rect.top + rect.height / 2));
+      
+      if (distance < closestDistance) {
+        closestDistance = distance;
+        targetIndex = i;
+      }
+      
+      // Reset other elements
+      (el as HTMLElement).style.transform = '';
+      (el as HTMLElement).style.transition = '';
+    });
+    
+    if (targetIndex !== touchedIndex && closestDistance < 50) {
+      const itemsCopy = [...items];
+      const [draggedItem] = itemsCopy.splice(touchedIndex, 1);
+      itemsCopy.splice(targetIndex, 0, draggedItem);
+      onSort(itemsCopy);
+    }
+    
+    setTouchStartY(null);
+    setTouchedIndex(null);
+  }, [touchedIndex, items, onSort]);
+
+  return { handleTouchStart, handleTouchMove, handleTouchEnd };
+};
+
+interface StepError {
+  keys?: string;
+  modifiers?: string;
+  delay?: string;
+}
+
+interface ValidationErrors {
+  name?: string;
+  description?: string;
+  steps?: Record<number, StepError>;
+}
+
+export default function SettingsMacrosRoute() {
+  const { macros, loading, initialized, loadMacros, saveMacros, setSendFn } = useMacrosStore();
+  const [editingMacro, setEditingMacro] = useState<KeySequence | null>(null);
+  const [newMacro, setNewMacro] = useState<Partial<KeySequence>>({
+    name: "",
+    description: "",
+    steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }],
+  });
+  const [isDragging, setIsDragging] = useState(false);
+  const dragItem = useRef<number | null>(null);
+  const dragOverItem = useRef<number | null>(null);
+  
+  const [macroToDelete, setMacroToDelete] = useState<string | null>(null);
+  
+  const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
+  const [editKeyQueries, setEditKeyQueries] = useState<Record<number, string>>({});
+  
+  const [errorMessage, setErrorMessage] = useState<string | null>(null);
+  const [isDesktop, setIsDesktop] = useState(window.innerWidth >= 768);
+  
+  const [send] = useJsonRpc();
+
+  const isMaxMacrosReached = macros.length >= MAX_TOTAL_MACROS;
+  const isMaxStepsReachedForNewMacro = (newMacro.steps?.length || 0) >= MAX_STEPS_PER_MACRO;
+  
+  const showTemporaryError = useCallback((message: string) => {
+    setErrorMessage(message);
+    setTimeout(() => setErrorMessage(null), 3000);
+  }, []);
+  
+  // Helper for both new and edit key select
+  const handleKeySelectUpdate = (stepIndex: number, option: KeyOptionData, isEditing = false) => {
+    if (isEditing && editingMacro) {
+      const updatedSteps = updateStepKeys(editingMacro.steps, stepIndex, option, showTemporaryError);
+      setEditingMacro({ ...editingMacro, steps: updatedSteps });
+    } else {
+      const updatedSteps = updateStepKeys(newMacro.steps || [], stepIndex, option, showTemporaryError);
+      setNewMacro({ ...newMacro, steps: updatedSteps });
+    }
+  };
+  
+  const handleKeySelect = (stepIndex: number, option: KeyOptionData) => {
+    handleKeySelectUpdate(stepIndex, option, false);
+  };
+  
+  const handleEditKeySelect = (stepIndex: number, option: KeyOptionData) => {
+    handleKeySelectUpdate(stepIndex, option, true);
+  };
+  
+  const handleKeyQueryChange = (stepIndex: number, query: string) => {
+    setKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
+  };
+  
+  const handleEditKeyQueryChange = (stepIndex: number, query: string) => {
+    setEditKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
+  };
+  
+  const getFilteredKeys = (stepIndex: number, isEditing = false) => {
+    const query = isEditing 
+      ? (editKeyQueries[stepIndex] || '')
+      : (keyQueries[stepIndex] || '');
+    
+    if (query === '') {
+      return keyOptions;
+    } else {
+      return keyOptions.filter(option => option.label.toLowerCase().includes(query.toLowerCase()));
+    }
+  };
+  
+  useEffect(() => {
+    setSendFn(send);
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros, setSendFn, send]);
+  
+  const [errors, setErrors] = useState<ValidationErrors>({});
+  
+  const clearErrors = useCallback(() => {
+    setErrors({});
+  }, []);
+  
+  const validateMacro = (macro: Partial<KeySequence>): ValidationErrors => {
+    const errors: ValidationErrors = {};
+
+    // Name validation
+    if (!macro.name?.trim()) {
+      errors.name = "Name is required";
+    } else if (macro.name.trim().length > 50) {
+      errors.name = "Name must be less than 50 characters";
+    }
+
+    // Description validation (optional)
+    if (macro.description && macro.description.trim().length > 200) {
+      errors.description = "Description must be less than 200 characters";
+    }
+
+    // Steps validation
+    if (!macro.steps?.length) {
+      errors.steps = { 0: { keys: "At least one step is required" } };
+      return errors;
+    }
+
+    // Check if at least one step has keys or modifiers
+    const hasKeyOrModifier = macro.steps.some(step => 
+      (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0
+    );
+
+    if (!hasKeyOrModifier) {
+      errors.steps = { 0: { keys: "At least one step must have keys or modifiers" } };
+      return errors;
+    }
+
+    const stepErrors: Record<number, StepError> = {};
+    
+    macro.steps.forEach((step, index) => {
+      const stepError: StepError = {};
+
+      // Keys validation (only if keys are present)
+      if (step.keys?.length && step.keys.length > MAX_KEYS_PER_STEP) {
+        stepError.keys = `Maximum ${MAX_KEYS_PER_STEP} keys allowed`;
+      }
+
+      // Delay validation
+      if (typeof step.delay !== 'number' || step.delay < 0) {
+        stepError.delay = "Invalid delay value";
+      }
+
+      if (Object.keys(stepError).length > 0) {
+        stepErrors[index] = stepError;
+      }
+    });
+
+    if (Object.keys(stepErrors).length > 0) {
+      errors.steps = stepErrors;
+    }
+
+    return errors;
+  };
+
+  const resetNewMacro = () => {
+    setNewMacro({
+      name: "",
+      description: "",
+      steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }],
+    });
+    setKeyQueries({});
+    setErrors({});
+  };
+
+  const [isSaving, setIsSaving] = useState(false);
+  const [isUpdating, setIsUpdating] = useState(false);
+  const [isDeleting, setIsDeleting] = useState(false);
+
+  const handleAddMacro = useCallback(async () => {
+    if (isMaxMacrosReached) {
+      showTemporaryError(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    const validationErrors = validateMacro(newMacro);
+    if (Object.keys(validationErrors).length > 0) {
+      setErrors(validationErrors);
+      return;
+    }
+
+    setIsSaving(true);
+    try {
+      const macro: KeySequence = {
+        id: generateId(),
+        name: newMacro.name!.trim(),
+        description: newMacro.description?.trim() || "",
+        steps: newMacro.steps || [],
+        sortOrder: macros.length + 1,
+      };
+
+      await saveMacros(normalizeSortOrders([...macros, macro]));
+      resetNewMacro();
+      setShowAddMacro(false);
+      notifications.success(`Macro "${macro.name}" created successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to create macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to create macro");
+        showTemporaryError("Failed to save macro");
+      }
+    } finally {
+      setIsSaving(false);
+    }
+  }, [isMaxMacrosReached, newMacro, macros, saveMacros, showTemporaryError]);
+
+  const handleDragStart = (index: number) => {
+    dragItem.current = index;
+    setIsDragging(true);
+    
+    const allItems = document.querySelectorAll('[data-macro-item]');
+    const draggedElement = allItems[index];
+    if (draggedElement) {
+      draggedElement.classList.add('dragging');
+    }
+  };
+  
+  const handleDragOver = (e: React.DragEvent, index: number) => {
+    e.preventDefault();
+    dragOverItem.current = index;
+    
+    const allItems = document.querySelectorAll('[data-macro-item]');
+    allItems.forEach(el => el.classList.remove('drop-target'));
+    
+    const targetElement = allItems[index];
+    if (targetElement) {
+      targetElement.classList.add('drop-target');
+    }
+  };
+  
+  const handleDrop = async (e: React.DragEvent) => {
+    e.preventDefault();
+    if (dragItem.current === null || dragOverItem.current === null) return;
+    
+    const macroCopy = [...macros];
+    const draggedItem = macroCopy.splice(dragItem.current, 1)[0];
+    macroCopy.splice(dragOverItem.current, 0, draggedItem);
+    const updatedMacros = normalizeSortOrders(macroCopy);
+
+    try {
+      await saveMacros(updatedMacros);
+      notifications.success("Macro order updated successfully");
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to reorder macros: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to reorder macros");
+        showTemporaryError("Failed to save reordered macros");
+      }
+    }
+    
+    const allItems = document.querySelectorAll('[data-macro-item]');
+    allItems.forEach(el => {
+      el.classList.remove('drop-target');
+      el.classList.remove('dragging');
+    });
+    
+    dragItem.current = null;
+    dragOverItem.current = null;
+    setIsDragging(false);
+  };
+
+  const handleUpdateMacro = useCallback(async () => {
+    if (!editingMacro) return;
+
+    const validationErrors = validateMacro(editingMacro);
+    if (Object.keys(validationErrors).length > 0) {
+      setErrors(validationErrors);
+      return;
+    }
+
+    setIsUpdating(true);
+    try {
+      const newMacros = macros.map(m => 
+        m.id === editingMacro.id ? {
+          ...editingMacro,
+          name: editingMacro.name.trim(),
+          description: editingMacro.description?.trim() || "",
+        } : m
+      );
+
+      await saveMacros(normalizeSortOrders(newMacros));
+      setEditingMacro(null);
+      clearErrors();
+      notifications.success(`Macro "${editingMacro.name}" updated successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to update macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to update macro");
+        showTemporaryError("Failed to update macro");
+      }
+    } finally {
+      setIsUpdating(false);
+    }
+  }, [editingMacro, macros, saveMacros, showTemporaryError, clearErrors]);
+
+  const handleEditMacro = (macro: KeySequence) => {
+    setEditingMacro({
+      ...macro,
+      description: macro.description || "",
+      steps: macro.steps.map(step => ({
+        ...step,
+        keys: ensureArray(step.keys),
+        modifiers: ensureArray(step.modifiers),
+        delay: typeof step.delay === 'number' ? step.delay : DEFAULT_DELAY
+      }))
+    });
+    clearErrors();
+    setEditKeyQueries({});
+  };
+
+  const handleDeleteMacro = async (id: string) => {
+    const macroToBeDeleted = macros.find(m => m.id === id);
+    if (!macroToBeDeleted) return;
+
+    setIsDeleting(true);
+    try {
+      const updatedMacros = normalizeSortOrders(macros.filter(macro => macro.id !== id));
+      await saveMacros(updatedMacros);
+      if (editingMacro?.id === id) {
+        setEditingMacro(null);
+      }
+      setMacroToDelete(null);
+      notifications.success(`Macro "${macroToBeDeleted.name}" deleted successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to delete macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to delete macro");
+        showTemporaryError("Failed to delete macro");
+      }
+    } finally {
+      setIsDeleting(false);
+    }
+  };
+
+  const handleDuplicateMacro = async (macro: KeySequence) => {
+    if (isMaxMacrosReached) {
+      showTemporaryError(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    const newMacroCopy: KeySequence = {
+      ...JSON.parse(JSON.stringify(macro)),
+      id: generateId(),
+      name: `${macro.name} (copy)`,
+      sortOrder: macros.length + 1,
+    };
+
+    newMacroCopy.steps = newMacroCopy.steps.map(step => ({
+      ...step,
+      keys: ensureArray(step.keys),
+      modifiers: ensureArray(step.modifiers),
+      delay: step.delay || DEFAULT_DELAY
+    }));
+
+    try {
+      await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
+      notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to duplicate macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to duplicate macro");
+        showTemporaryError("Failed to duplicate macro");
+      }
+    }
+  };
+
+  const handleStepMove = (stepIndex: number, direction: 'up' | 'down', steps: MacroStep[]) => {
+    const newSteps = [...steps];
+    const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
+    [newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]];
+    return newSteps;
+  };
+
+  useEffect(() => {
+    const handleResize = () => {
+      setIsDesktop(window.innerWidth >= 768);
+    };
+    
+    window.addEventListener('resize', handleResize);
+    return () => window.removeEventListener('resize', handleResize);
+  }, []);
+
+  const { handleTouchStart, handleTouchMove, handleTouchEnd } = useTouchSort(
+    macros,
+    async (newMacros) => {
+      const updatedMacros = normalizeSortOrders(newMacros);
+      try {
+        await saveMacros(updatedMacros);
+        notifications.success("Macro order updated successfully");
+      } catch (error) {
+        if (error instanceof Error) {
+          notifications.error(`Failed to reorder macros: ${error.message}`);
+          showTemporaryError(error.message);
+        } else {
+          notifications.error("Failed to reorder macros");
+          showTemporaryError("Failed to save reordered macros");
+        }
+      }
+    }
+  );
+
+  const [showClearConfirm, setShowClearConfirm] = useState(false);
+  const [showAddMacro, setShowAddMacro] = useState(false);
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && editingMacro) {
+        setEditingMacro(null);
+        setErrors({});
+      }
+      if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
+        if (editingMacro) {
+          handleUpdateMacro();
+        } else if (!isMaxMacrosReached) {
+          handleAddMacro();
+        }
+      }
+    };
+
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [editingMacro, isMaxMacrosReached, handleAddMacro, handleUpdateMacro]);
+
+  const handleModifierChange = (stepIndex: number, modifiers: string[]) => {
+    if (editingMacro) {
+      const newSteps = [...editingMacro.steps];
+      newSteps[stepIndex].modifiers = modifiers;
+      setEditingMacro({ ...editingMacro, steps: newSteps });
+    } else {
+      const newSteps = [...(newMacro.steps || [])];
+      newSteps[stepIndex].modifiers = modifiers;
+      setNewMacro({ ...newMacro, steps: newSteps });
+    }
+  };
+
+  const handleDelayChange = (stepIndex: number, delay: number) => {
+    if (editingMacro) {
+      const newSteps = [...editingMacro.steps];
+      newSteps[stepIndex].delay = delay;
+      setEditingMacro({ ...editingMacro, steps: newSteps });
+    } else {
+      const newSteps = [...(newMacro.steps || [])];
+      newSteps[stepIndex].delay = delay;
+      setNewMacro({ ...newMacro, steps: newSteps });
+    }
+  };
+
+  const ErrorMessage = ({ error }: { error?: string }) => {
+    if (!error) return null;
+    return (
+      <p className="mt-1 text-xs text-red-500 dark:text-red-400">
+        {error}
+      </p>
+    );
+  };
+
+  return (
+    <div className="space-y-4">
+      <SettingsPageHeader
+        title="Keyboard Macros"
+        description="Create and manage keyboard macros for quick actions"
+      />
+
+      {errorMessage && (
+        <div className="macro-error">
+          <div className="flex">
+            <div className="flex-shrink-0">
+              <svg className="macro-error-icon" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
+                <path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.414 1.414L10 11.414l1.72 1.72a.75.75 0 101.414-1.414L11.414 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.586 8.28 7.22z" clipRule="evenodd" />
+              </svg>
+            </div>
+            <div className="ml-3">
+              <h3 className="macro-error-text">{errorMessage}</h3>
+            </div>
+          </div>
+        </div>
+      )}
+
+      <div className="flex items-center justify-between mb-4">
+        <SettingsItem 
+          title="Macros"
+          description={`${macros.length}/${MAX_TOTAL_MACROS}`}
+        >
+          <div className="flex items-center gap-2">
+            {!showAddMacro && (
+              <Button
+                size="SM"
+                theme="primary"
+                text={isMaxMacrosReached ? `Max Reached` : "Add New Macro"}
+                onClick={() => setShowAddMacro(true)}
+                disabled={isMaxMacrosReached}
+              />
+            )}
+          </div>
+        </SettingsItem>
+      </div>
+
+      {loading && (
+        <div className="flex items-center justify-center p-8">
+          <LuLoader className="h-6 w-6 animate-spin text-blue-500" />
+        </div>
+      )}
+      <div className={`space-y-4 ${loading ? 'hidden' : ''}`}>
+        {showAddMacro && (
+          <div className="rounded-md border border-slate-200 bg-white p-3 dark:border-slate-700 dark:bg-slate-800">
+            <div className="flex items-center justify-between mb-2">
+              <h3 className="text-sm font-semibold text-black dark:text-white">Add New Macro</h3>
+            </div>
+            <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
+              <div className="flex flex-col">
+                <input
+                  type="text"
+                  className={`macro-input ${errors.name ? 'border-red-500 dark:border-red-500' : ''}`}
+                  value={newMacro.name}
+                  onChange={e => {
+                    setNewMacro(prev => ({ ...prev, name: e.target.value }));
+                    if (errors.name) {
+                      const newErrors = { ...errors };
+                      delete newErrors.name;
+                      setErrors(newErrors);
+                    }
+                  }}
+                  placeholder="Macro Name"
+                />
+                <ErrorMessage error={errors.name} />
+              </div>
+              <div className="flex flex-col">
+                <input
+                  type="text"
+                  className={`macro-input ${errors.description ? 'border-red-500 dark:border-red-500' : ''}`}
+                  value={newMacro.description}
+                  onChange={e => {
+                    setNewMacro(prev => ({ ...prev, description: e.target.value }));
+                    if (errors.description) {
+                      const newErrors = { ...errors };
+                      delete newErrors.description;
+                      setErrors(newErrors);
+                    }
+                  }}
+                  placeholder="Description (optional)"
+                />
+                <ErrorMessage error={errors.description} />
+              </div>
+            </div>
+            
+            <div className="mt-4">
+              <div className="macro-section-header">
+                <label className="macro-section-title">
+                  Steps:
+                </label>
+                <span className="macro-section-subtitle">
+                  {newMacro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps
+                </span>
+              </div>
+              {errors.steps && errors.steps[0]?.keys && (
+                <div className="mt-2">
+                  <ErrorMessage error={errors.steps[0].keys} />
+                </div>
+              )}
+              <div className="mt-2 text-xs text-slate-500 dark:text-slate-400">
+                You can add up to {MAX_STEPS_PER_MACRO} steps per macro
+              </div>
+              <div className="mt-2 space-y-4">
+                {(newMacro.steps || []).map((step, stepIndex) => (
+                  <MacroStepCard
+                    key={stepIndex}
+                    step={step}
+                    stepIndex={stepIndex}
+                    onDelete={newMacro.steps && newMacro.steps.length > 1 ? () => {
+                      const newSteps = [...(newMacro.steps || [])];
+                      newSteps.splice(stepIndex, 1);
+                      setNewMacro(prev => ({ ...prev, steps: newSteps }));
+                    } : undefined}
+                    onMoveUp={() => {
+                      const newSteps = handleStepMove(stepIndex, 'up', newMacro.steps || []);
+                      setNewMacro(prev => ({ ...prev, steps: newSteps }));
+                    }}
+                    onMoveDown={() => {
+                      const newSteps = handleStepMove(stepIndex, 'down', newMacro.steps || []);
+                      setNewMacro(prev => ({ ...prev, steps: newSteps }));
+                    }}
+                    isDesktop={isDesktop}
+                    onKeySelect={(option) => handleKeySelect(stepIndex, option)}
+                    onKeyQueryChange={(query) => handleKeyQueryChange(stepIndex, query)}
+                    keyQuery={keyQueries[stepIndex] || ''}
+                    getFilteredKeys={() => getFilteredKeys(stepIndex)}
+                    onModifierChange={(modifiers) => handleModifierChange(stepIndex, modifiers)}
+                    onDelayChange={(delay) => handleDelayChange(stepIndex, delay)}
+                    isLastStep={stepIndex === (newMacro.steps?.length || 0) - 1}
+                  />
+                ))}
+
+                <div className="mt-4 border-t border-slate-200 pt-4 dark:border-slate-700">
+                  <button
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: Savid
body: converted everything over that i could see, lmk if i missed a shared component
created_at: 2025-04-03T08:24:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,1439 @@
+import { useState, useEffect, useRef, useCallback } from "react";
+import { LuPlus, LuTrash, LuX, LuPenLine, LuLoader, LuGripVertical, LuInfo } from "react-icons/lu";
+import { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
+
+import { KeySequence, useMacrosStore } from "../hooks/stores";
+import { SettingsPageHeader } from "../components/SettingsPageheader";
+import { Button } from "../components/Button";
+import { keys, modifiers } from "../keyboardMappings";
+import { useJsonRpc } from "../hooks/useJsonRpc";
+import notifications from "../notifications";
+import { SettingsItem } from "../routes/devices.$id.settings";
+
+const DEFAULT_DELAY = 50;
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface KeyOption {
+  value: string;
+  label: string;
+}
+
+interface KeyOptionData {
+  value: string | null;
+  keys?: string[];
+  label?: string;
+}
+
+const generateId = () => {
+  return `macro-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
+};
+
+const keyOptions = Object.keys(keys).map(key => ({
+  value: key,
+  label: key,
+}));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+interface KeyComboboxProps {
+  stepIndex: number;
+  step: MacroStep;
+  onSelect: (option: KeyOptionData) => void;
+  query: string;
+  onQueryChange: (query: string) => void;
+  getFilteredOptions: () => KeyOption[];
+  disabled?: boolean;
+}
+
+function KeyCombobox({
+  onSelect,
+  query,
+  onQueryChange,
+  getFilteredOptions,
+  disabled = false,
+}: KeyComboboxProps) {
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  return (
+    <div className="relative w-full">
+      <Combobox immediate onChange={onSelect} disabled={disabled}>
+        {() => (
+          <>
+            <div className="relative">
+              <ComboboxInput
+                ref={inputRef}
+                className={`macro-input ${disabled ? 'opacity-60 cursor-not-allowed' : ''}`}
+                placeholder={disabled ? "Max keys reached" : "Search for key..."}
+                displayValue={() => query}
+                onChange={(event) => onQueryChange(event.target.value)}
+                disabled={disabled}
+              />
+            </div>
+            
+            <ComboboxOptions className="absolute left-0 z-50 mt-1 w-full max-h-60 overflow-auto rounded-md bg-white dark:bg-slate-800 py-1 text-sm shadow-lg">
+              {getFilteredOptions().map((option) => (
+                <ComboboxOption 
+                  key={option.value} 
+                  value={option}
+                  className="cursor-default select-none py-1.5 px-3 ui-active:bg-blue-100 ui-active:text-blue-900 dark:text-slate-300 dark:ui-active:bg-blue-900/40 dark:ui-active:text-blue-200"
+                >
+                  {option.label}
+                </ComboboxOption>
+              ))}
+              {getFilteredOptions().length === 0 && (
+                <div className="py-2 px-3 text-sm text-slate-500 dark:text-slate-400">
+                  No matching keys found
+                </div>
+              )}
+            </ComboboxOptions>
+          </>
+        )}
+      </Combobox>
+    </div>
+  );
+}
+
+const PRESET_DELAYS = [
+  { value: 50, label: "50ms" },
+  { value: 100, label: "100ms" },
+  { value: 200, label: "200ms" },
+  { value: 300, label: "300ms" },
+  { value: 500, label: "500ms" },
+  { value: 750, label: "750ms" },
+  { value: 1000, label: "1000ms" },
+  { value: 1500, label: "1500ms" },
+  { value: 2000, label: "2000ms" },
+];
+
+const MAX_STEPS_PER_MACRO = 10;
+const MAX_TOTAL_MACROS = 25;
+const MAX_KEYS_PER_STEP = 10;
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+// Helper function to normalize sort orders, ensuring they start at 1 and have no gaps
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  isDesktop: boolean;
+  onKeySelect: (option: KeyOptionData) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  getFilteredKeys: () => KeyOption[];
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+function MacroStepCard({
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: Savid
body: extracted into MacroForm and MacroStepCard components
created_at: 2025-04-03T08:24:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,1439 @@
+import { useState, useEffect, useRef, useCallback } from "react";
+import { LuPlus, LuTrash, LuX, LuPenLine, LuLoader, LuGripVertical, LuInfo } from "react-icons/lu";
+import { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
+
+import { KeySequence, useMacrosStore } from "../hooks/stores";
+import { SettingsPageHeader } from "../components/SettingsPageheader";
+import { Button } from "../components/Button";
+import { keys, modifiers } from "../keyboardMappings";
+import { useJsonRpc } from "../hooks/useJsonRpc";
+import notifications from "../notifications";
+import { SettingsItem } from "../routes/devices.$id.settings";
+
+const DEFAULT_DELAY = 50;
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface KeyOption {
+  value: string;
+  label: string;
+}
+
+interface KeyOptionData {
+  value: string | null;
+  keys?: string[];
+  label?: string;
+}
+
+const generateId = () => {
+  return `macro-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
+};
+
+const keyOptions = Object.keys(keys).map(key => ({
+  value: key,
+  label: key,
+}));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+interface KeyComboboxProps {
+  stepIndex: number;
+  step: MacroStep;
+  onSelect: (option: KeyOptionData) => void;
+  query: string;
+  onQueryChange: (query: string) => void;
+  getFilteredOptions: () => KeyOption[];
+  disabled?: boolean;
+}
+
+function KeyCombobox({
+  onSelect,
+  query,
+  onQueryChange,
+  getFilteredOptions,
+  disabled = false,
+}: KeyComboboxProps) {
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  return (
+    <div className="relative w-full">
+      <Combobox immediate onChange={onSelect} disabled={disabled}>
+        {() => (
+          <>
+            <div className="relative">
+              <ComboboxInput
+                ref={inputRef}
+                className={`macro-input ${disabled ? 'opacity-60 cursor-not-allowed' : ''}`}
+                placeholder={disabled ? "Max keys reached" : "Search for key..."}
+                displayValue={() => query}
+                onChange={(event) => onQueryChange(event.target.value)}
+                disabled={disabled}
+              />
+            </div>
+            
+            <ComboboxOptions className="absolute left-0 z-50 mt-1 w-full max-h-60 overflow-auto rounded-md bg-white dark:bg-slate-800 py-1 text-sm shadow-lg">
+              {getFilteredOptions().map((option) => (
+                <ComboboxOption 
+                  key={option.value} 
+                  value={option}
+                  className="cursor-default select-none py-1.5 px-3 ui-active:bg-blue-100 ui-active:text-blue-900 dark:text-slate-300 dark:ui-active:bg-blue-900/40 dark:ui-active:text-blue-200"
+                >
+                  {option.label}
+                </ComboboxOption>
+              ))}
+              {getFilteredOptions().length === 0 && (
+                <div className="py-2 px-3 text-sm text-slate-500 dark:text-slate-400">
+                  No matching keys found
+                </div>
+              )}
+            </ComboboxOptions>
+          </>
+        )}
+      </Combobox>
+    </div>
+  );
+}
+
+const PRESET_DELAYS = [
+  { value: 50, label: "50ms" },
+  { value: 100, label: "100ms" },
+  { value: 200, label: "200ms" },
+  { value: 300, label: "300ms" },
+  { value: 500, label: "500ms" },
+  { value: 750, label: "750ms" },
+  { value: 1000, label: "1000ms" },
+  { value: 1500, label: "1500ms" },
+  { value: 2000, label: "2000ms" },
+];
+
+const MAX_STEPS_PER_MACRO = 10;
+const MAX_TOTAL_MACROS = 25;
+const MAX_KEYS_PER_STEP = 10;
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+// Helper function to normalize sort orders, ensuring they start at 1 and have no gaps
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  isDesktop: boolean;
+  onKeySelect: (option: KeyOptionData) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  getFilteredKeys: () => KeyOption[];
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  getFilteredKeys,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  return (
+    <div className="macro-step-card rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 p-4 shadow-sm">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <button
+              type="button"
+              className="p-1 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400 disabled:opacity-50"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="m18 15-6-6-6 6"/>
+              </svg>
+            </button>
+            <button
+              type="button"
+              className="p-1 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400 disabled:opacity-50"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="m6 9 6 6 6-6"/>
+              </svg>
+            </button>
+          </div>
+          <span className="macro-step-number flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+        
+        <div className="flex items-center space-x-2">
+          {onDelete && (
+            <button
+              type="button"
+              className="flex items-center text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
+              onClick={onDelete}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="M3 6h18"></path>
+                <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
+                <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
+              </svg>
+              <span className="ml-1">Delete</span>
+            </button>
+          )}
+        </div>
+      </div>
+      
+      <div className="space-y-4 mt-2">
+        <div className="w-full flex flex-col gap-2">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Modifiers:
+          </label>
+          <div className="macro-modifiers-container inline-flex flex-wrap gap-3">
+            {Object.entries(groupedModifiers).map(([group, mods]) => (
+              <div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
+                <span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
+                  {group}
+                </span>
+                <div className="flex flex-wrap gap-1">
+                  {mods.map(option => (
+                    <label 
+                      key={option.value} 
+                      className={`flex items-center px-2 py-1 rounded border cursor-pointer text-xs font-medium transition-colors ${
+                        ensureArray(step.modifiers).includes(option.value) 
+                          ? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-200' 
+                          : 'bg-slate-100 border-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700'
+                      }`}
+                    >
+                      <input
+                        type="checkbox"
+                        className="sr-only"
+                        checked={ensureArray(step.modifiers).includes(option.value)}
+                        onChange={e => {
+                          const modifiersArray = ensureArray(step.modifiers);
+                          const newModifiers = e.target.checked
+                            ? [...modifiersArray, option.value]
+                            : modifiersArray.filter(m => m !== option.value);
+                          onModifierChange(newModifiers);
+                        }}
+                      />
+                      {option.label.split(' ')[1] || option.label}
+                    </label>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Keys:
+          </label>
+          
+          <div className="macro-key-group flex flex-wrap gap-1 mb-2">
+            {ensureArray(step.keys).map((key, keyIndex) => (
+              <span
+                key={keyIndex}
+                className="macro-key-badge inline-flex items-center rounded-md bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
+              >
+                {key}
+                <button
+                  type="button"
+                  className="ml-1 text-xs text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
+                  onClick={() => {
+                    const newKeys = ensureArray(step.keys).filter((_, i) => i !== keyIndex);
+                    onKeySelect({ value: null, keys: newKeys });
+                  }}
+                >
+                  ×
+                </button>
+              </span>
+            ))}
+          </div>
+
+          <KeyCombobox
+            stepIndex={stepIndex}
+            step={step}
+            onSelect={onKeySelect}
+            query={keyQuery}
+            onQueryChange={onKeyQueryChange}
+            getFilteredOptions={getFilteredKeys}
+            disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP}
+          />
+          
+          {ensureArray(step.keys).length >= MAX_KEYS_PER_STEP && (
+            <span className="text-xs text-amber-600 dark:text-amber-400 mt-1">
+              (max keys reached)
+            </span>
+          )}
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+              Step Duration:
+            </label>
+            <div className="group relative">
+              <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+              <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                <p>The time to wait after pressing the keys in this step before moving to the next step. This helps ensure reliable key presses when automating keyboard input.</p>
+              </div>
+            </div>
+          </div>
+          <div className="flex items-center gap-3">
+            <select
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: Savid
body: converted everything over that i could see, lmk if i missed a shared component
created_at: 2025-04-03T08:25:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,1439 @@
+import { useState, useEffect, useRef, useCallback } from "react";
+import { LuPlus, LuTrash, LuX, LuPenLine, LuLoader, LuGripVertical, LuInfo } from "react-icons/lu";
+import { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
+
+import { KeySequence, useMacrosStore } from "../hooks/stores";
+import { SettingsPageHeader } from "../components/SettingsPageheader";
+import { Button } from "../components/Button";
+import { keys, modifiers } from "../keyboardMappings";
+import { useJsonRpc } from "../hooks/useJsonRpc";
+import notifications from "../notifications";
+import { SettingsItem } from "../routes/devices.$id.settings";
+
+const DEFAULT_DELAY = 50;
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface KeyOption {
+  value: string;
+  label: string;
+}
+
+interface KeyOptionData {
+  value: string | null;
+  keys?: string[];
+  label?: string;
+}
+
+const generateId = () => {
+  return `macro-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
+};
+
+const keyOptions = Object.keys(keys).map(key => ({
+  value: key,
+  label: key,
+}));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+interface KeyComboboxProps {
+  stepIndex: number;
+  step: MacroStep;
+  onSelect: (option: KeyOptionData) => void;
+  query: string;
+  onQueryChange: (query: string) => void;
+  getFilteredOptions: () => KeyOption[];
+  disabled?: boolean;
+}
+
+function KeyCombobox({
+  onSelect,
+  query,
+  onQueryChange,
+  getFilteredOptions,
+  disabled = false,
+}: KeyComboboxProps) {
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  return (
+    <div className="relative w-full">
+      <Combobox immediate onChange={onSelect} disabled={disabled}>
+        {() => (
+          <>
+            <div className="relative">
+              <ComboboxInput
+                ref={inputRef}
+                className={`macro-input ${disabled ? 'opacity-60 cursor-not-allowed' : ''}`}
+                placeholder={disabled ? "Max keys reached" : "Search for key..."}
+                displayValue={() => query}
+                onChange={(event) => onQueryChange(event.target.value)}
+                disabled={disabled}
+              />
+            </div>
+            
+            <ComboboxOptions className="absolute left-0 z-50 mt-1 w-full max-h-60 overflow-auto rounded-md bg-white dark:bg-slate-800 py-1 text-sm shadow-lg">
+              {getFilteredOptions().map((option) => (
+                <ComboboxOption 
+                  key={option.value} 
+                  value={option}
+                  className="cursor-default select-none py-1.5 px-3 ui-active:bg-blue-100 ui-active:text-blue-900 dark:text-slate-300 dark:ui-active:bg-blue-900/40 dark:ui-active:text-blue-200"
+                >
+                  {option.label}
+                </ComboboxOption>
+              ))}
+              {getFilteredOptions().length === 0 && (
+                <div className="py-2 px-3 text-sm text-slate-500 dark:text-slate-400">
+                  No matching keys found
+                </div>
+              )}
+            </ComboboxOptions>
+          </>
+        )}
+      </Combobox>
+    </div>
+  );
+}
+
+const PRESET_DELAYS = [
+  { value: 50, label: "50ms" },
+  { value: 100, label: "100ms" },
+  { value: 200, label: "200ms" },
+  { value: 300, label: "300ms" },
+  { value: 500, label: "500ms" },
+  { value: 750, label: "750ms" },
+  { value: 1000, label: "1000ms" },
+  { value: 1500, label: "1500ms" },
+  { value: 2000, label: "2000ms" },
+];
+
+const MAX_STEPS_PER_MACRO = 10;
+const MAX_TOTAL_MACROS = 25;
+const MAX_KEYS_PER_STEP = 10;
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+// Helper function to normalize sort orders, ensuring they start at 1 and have no gaps
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  isDesktop: boolean;
+  onKeySelect: (option: KeyOptionData) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  getFilteredKeys: () => KeyOption[];
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  getFilteredKeys,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  return (
+    <div className="macro-step-card rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 p-4 shadow-sm">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <button
+              type="button"
+              className="p-1 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400 disabled:opacity-50"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="m18 15-6-6-6 6"/>
+              </svg>
+            </button>
+            <button
+              type="button"
+              className="p-1 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400 disabled:opacity-50"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="m6 9 6 6 6-6"/>
+              </svg>
+            </button>
+          </div>
+          <span className="macro-step-number flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+        
+        <div className="flex items-center space-x-2">
+          {onDelete && (
+            <button
+              type="button"
+              className="flex items-center text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
+              onClick={onDelete}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="M3 6h18"></path>
+                <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
+                <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
+              </svg>
+              <span className="ml-1">Delete</span>
+            </button>
+          )}
+        </div>
+      </div>
+      
+      <div className="space-y-4 mt-2">
+        <div className="w-full flex flex-col gap-2">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Modifiers:
+          </label>
+          <div className="macro-modifiers-container inline-flex flex-wrap gap-3">
+            {Object.entries(groupedModifiers).map(([group, mods]) => (
+              <div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
+                <span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
+                  {group}
+                </span>
+                <div className="flex flex-wrap gap-1">
+                  {mods.map(option => (
+                    <label 
+                      key={option.value} 
+                      className={`flex items-center px-2 py-1 rounded border cursor-pointer text-xs font-medium transition-colors ${
+                        ensureArray(step.modifiers).includes(option.value) 
+                          ? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-200' 
+                          : 'bg-slate-100 border-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700'
+                      }`}
+                    >
+                      <input
+                        type="checkbox"
+                        className="sr-only"
+                        checked={ensureArray(step.modifiers).includes(option.value)}
+                        onChange={e => {
+                          const modifiersArray = ensureArray(step.modifiers);
+                          const newModifiers = e.target.checked
+                            ? [...modifiersArray, option.value]
+                            : modifiersArray.filter(m => m !== option.value);
+                          onModifierChange(newModifiers);
+                        }}
+                      />
+                      {option.label.split(' ')[1] || option.label}
+                    </label>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Keys:
+          </label>
+          
+          <div className="macro-key-group flex flex-wrap gap-1 mb-2">
+            {ensureArray(step.keys).map((key, keyIndex) => (
+              <span
+                key={keyIndex}
+                className="macro-key-badge inline-flex items-center rounded-md bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
+              >
+                {key}
+                <button
+                  type="button"
+                  className="ml-1 text-xs text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
+                  onClick={() => {
+                    const newKeys = ensureArray(step.keys).filter((_, i) => i !== keyIndex);
+                    onKeySelect({ value: null, keys: newKeys });
+                  }}
+                >
+                  ×
+                </button>
+              </span>
+            ))}
+          </div>
+
+          <KeyCombobox
+            stepIndex={stepIndex}
+            step={step}
+            onSelect={onKeySelect}
+            query={keyQuery}
+            onQueryChange={onKeyQueryChange}
+            getFilteredOptions={getFilteredKeys}
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: Savid
body: pulled out some of the logic from the VirtualKeyboard component and added it back down into keyboardMappings.ts file. lmk if you want a different approach
created_at: 2025-04-03T08:25:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,1439 @@
+import { useState, useEffect, useRef, useCallback } from "react";
+import { LuPlus, LuTrash, LuX, LuPenLine, LuLoader, LuGripVertical, LuInfo } from "react-icons/lu";
+import { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
+
+import { KeySequence, useMacrosStore } from "../hooks/stores";
+import { SettingsPageHeader } from "../components/SettingsPageheader";
+import { Button } from "../components/Button";
+import { keys, modifiers } from "../keyboardMappings";
+import { useJsonRpc } from "../hooks/useJsonRpc";
+import notifications from "../notifications";
+import { SettingsItem } from "../routes/devices.$id.settings";
+
+const DEFAULT_DELAY = 50;
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface KeyOption {
+  value: string;
+  label: string;
+}
+
+interface KeyOptionData {
+  value: string | null;
+  keys?: string[];
+  label?: string;
+}
+
+const generateId = () => {
+  return `macro-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
+};
+
+const keyOptions = Object.keys(keys).map(key => ({
+  value: key,
+  label: key,
+}));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+interface KeyComboboxProps {
+  stepIndex: number;
+  step: MacroStep;
+  onSelect: (option: KeyOptionData) => void;
+  query: string;
+  onQueryChange: (query: string) => void;
+  getFilteredOptions: () => KeyOption[];
+  disabled?: boolean;
+}
+
+function KeyCombobox({
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: Savid
body: I redid this component into Combobox. tried to style as similarly as i could
created_at: 2025-04-03T08:26:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,1439 @@
+import { useState, useEffect, useRef, useCallback } from "react";
+import { LuPlus, LuTrash, LuX, LuPenLine, LuLoader, LuGripVertical, LuInfo } from "react-icons/lu";
+import { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
+
+import { KeySequence, useMacrosStore } from "../hooks/stores";
+import { SettingsPageHeader } from "../components/SettingsPageheader";
+import { Button } from "../components/Button";
+import { keys, modifiers } from "../keyboardMappings";
+import { useJsonRpc } from "../hooks/useJsonRpc";
+import notifications from "../notifications";
+import { SettingsItem } from "../routes/devices.$id.settings";
+
+const DEFAULT_DELAY = 50;
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface KeyOption {
+  value: string;
+  label: string;
+}
+
+interface KeyOptionData {
+  value: string | null;
+  keys?: string[];
+  label?: string;
+}
+
+const generateId = () => {
+  return `macro-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
+};
+
+const keyOptions = Object.keys(keys).map(key => ({
+  value: key,
+  label: key,
+}));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+interface KeyComboboxProps {
+  stepIndex: number;
+  step: MacroStep;
+  onSelect: (option: KeyOptionData) => void;
+  query: string;
+  onQueryChange: (query: string) => void;
+  getFilteredOptions: () => KeyOption[];
+  disabled?: boolean;
+}
+
+function KeyCombobox({
+  onSelect,
+  query,
+  onQueryChange,
+  getFilteredOptions,
+  disabled = false,
+}: KeyComboboxProps) {
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  return (
+    <div className="relative w-full">
+      <Combobox immediate onChange={onSelect} disabled={disabled}>
+        {() => (
+          <>
+            <div className="relative">
+              <ComboboxInput
+                ref={inputRef}
+                className={`macro-input ${disabled ? 'opacity-60 cursor-not-allowed' : ''}`}
+                placeholder={disabled ? "Max keys reached" : "Search for key..."}
+                displayValue={() => query}
+                onChange={(event) => onQueryChange(event.target.value)}
+                disabled={disabled}
+              />
+            </div>
+            
+            <ComboboxOptions className="absolute left-0 z-50 mt-1 w-full max-h-60 overflow-auto rounded-md bg-white dark:bg-slate-800 py-1 text-sm shadow-lg">
+              {getFilteredOptions().map((option) => (
+                <ComboboxOption 
+                  key={option.value} 
+                  value={option}
+                  className="cursor-default select-none py-1.5 px-3 ui-active:bg-blue-100 ui-active:text-blue-900 dark:text-slate-300 dark:ui-active:bg-blue-900/40 dark:ui-active:text-blue-200"
+                >
+                  {option.label}
+                </ComboboxOption>
+              ))}
+              {getFilteredOptions().length === 0 && (
+                <div className="py-2 px-3 text-sm text-slate-500 dark:text-slate-400">
+                  No matching keys found
+                </div>
+              )}
+            </ComboboxOptions>
+          </>
+        )}
+      </Combobox>
+    </div>
+  );
+}
+
+const PRESET_DELAYS = [
+  { value: 50, label: "50ms" },
+  { value: 100, label: "100ms" },
+  { value: 200, label: "200ms" },
+  { value: 300, label: "300ms" },
+  { value: 500, label: "500ms" },
+  { value: 750, label: "750ms" },
+  { value: 1000, label: "1000ms" },
+  { value: 1500, label: "1500ms" },
+  { value: 2000, label: "2000ms" },
+];
+
+const MAX_STEPS_PER_MACRO = 10;
+const MAX_TOTAL_MACROS = 25;
+const MAX_KEYS_PER_STEP = 10;
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+// Helper function to normalize sort orders, ensuring they start at 1 and have no gaps
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  isDesktop: boolean;
+  onKeySelect: (option: KeyOptionData) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  getFilteredKeys: () => KeyOption[];
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  getFilteredKeys,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  return (
+    <div className="macro-step-card rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 p-4 shadow-sm">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <button
+              type="button"
+              className="p-1 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400 disabled:opacity-50"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="m18 15-6-6-6 6"/>
+              </svg>
+            </button>
+            <button
+              type="button"
+              className="p-1 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400 disabled:opacity-50"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="m6 9 6 6 6-6"/>
+              </svg>
+            </button>
+          </div>
+          <span className="macro-step-number flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+        
+        <div className="flex items-center space-x-2">
+          {onDelete && (
+            <button
+              type="button"
+              className="flex items-center text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
+              onClick={onDelete}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="M3 6h18"></path>
+                <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
+                <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
+              </svg>
+              <span className="ml-1">Delete</span>
+            </button>
+          )}
+        </div>
+      </div>
+      
+      <div className="space-y-4 mt-2">
+        <div className="w-full flex flex-col gap-2">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Modifiers:
+          </label>
+          <div className="macro-modifiers-container inline-flex flex-wrap gap-3">
+            {Object.entries(groupedModifiers).map(([group, mods]) => (
+              <div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
+                <span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
+                  {group}
+                </span>
+                <div className="flex flex-wrap gap-1">
+                  {mods.map(option => (
+                    <label 
+                      key={option.value} 
+                      className={`flex items-center px-2 py-1 rounded border cursor-pointer text-xs font-medium transition-colors ${
+                        ensureArray(step.modifiers).includes(option.value) 
+                          ? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-200' 
+                          : 'bg-slate-100 border-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700'
+                      }`}
+                    >
+                      <input
+                        type="checkbox"
+                        className="sr-only"
+                        checked={ensureArray(step.modifiers).includes(option.value)}
+                        onChange={e => {
+                          const modifiersArray = ensureArray(step.modifiers);
+                          const newModifiers = e.target.checked
+                            ? [...modifiersArray, option.value]
+                            : modifiersArray.filter(m => m !== option.value);
+                          onModifierChange(newModifiers);
+                        }}
+                      />
+                      {option.label.split(' ')[1] || option.label}
+                    </label>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Keys:
+          </label>
+          
+          <div className="macro-key-group flex flex-wrap gap-1 mb-2">
+            {ensureArray(step.keys).map((key, keyIndex) => (
+              <span
+                key={keyIndex}
+                className="macro-key-badge inline-flex items-center rounded-md bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
+              >
+                {key}
+                <button
+                  type="button"
+                  className="ml-1 text-xs text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
+                  onClick={() => {
+                    const newKeys = ensureArray(step.keys).filter((_, i) => i !== keyIndex);
+                    onKeySelect({ value: null, keys: newKeys });
+                  }}
+                >
+                  ×
+                </button>
+              </span>
+            ))}
+          </div>
+
+          <KeyCombobox
+            stepIndex={stepIndex}
+            step={step}
+            onSelect={onKeySelect}
+            query={keyQuery}
+            onQueryChange={onKeyQueryChange}
+            getFilteredOptions={getFilteredKeys}
+            disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP}
+          />
+          
+          {ensureArray(step.keys).length >= MAX_KEYS_PER_STEP && (
+            <span className="text-xs text-amber-600 dark:text-amber-400 mt-1">
+              (max keys reached)
+            </span>
+          )}
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+              Step Duration:
+            </label>
+            <div className="group relative">
+              <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+              <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                <p>The time to wait after pressing the keys in this step before moving to the next step. This helps ensure reliable key presses when automating keyboard input.</p>
+              </div>
+            </div>
+          </div>
+          <div className="flex items-center gap-3">
+            <select
+              className="w-full rounded-md border border-slate-300 bg-slate-50 p-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
+              value={step.delay}
+              onChange={(e) => onDelayChange(parseInt(e.target.value, 10))}
+            >
+              {PRESET_DELAYS.map((option) => (
+                <option key={option.value} value={option.value}>
+                  {option.label}
+                </option>
+              ))}
+            </select>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// Helper to update step keys used by both new and edit flows
+const updateStepKeys = (
+  steps: MacroStep[],
+  stepIndex: number,
+  keyOption: { value: string | null; keys?: string[] },
+  showTemporaryError: (msg: string) => void
+) => {
+  const newSteps = [...steps];
+  if (keyOption.keys) {
+    newSteps[stepIndex].keys = keyOption.keys;
+  } else if (keyOption.value) {
+    if (!newSteps[stepIndex].keys) {
+      newSteps[stepIndex].keys = [];
+    }
+    const keysArray = ensureArray(newSteps[stepIndex].keys);
+    if (keysArray.length >= MAX_KEYS_PER_STEP) {
+      showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`);
+      return newSteps;
+    }
+    newSteps[stepIndex].keys = [...keysArray, keyOption.value];
+  }
+  return newSteps;
+};
+
+const useTouchSort = (items: KeySequence[], onSort: (newItems: KeySequence[]) => void) => {
+  const [touchStartY, setTouchStartY] = useState<number | null>(null);
+  const [touchedIndex, setTouchedIndex] = useState<number | null>(null);
+
+  const handleTouchStart = useCallback((e: React.TouchEvent, index: number) => {
+    const touch = e.touches[0];
+    setTouchStartY(touch.clientY);
+    setTouchedIndex(index);
+    
+    const element = e.currentTarget as HTMLElement;
+    const rect = element.getBoundingClientRect();
+    
+    // Create ghost element
+    const ghost = element.cloneNode(true) as HTMLElement;
+    ghost.id = 'ghost-macro';
+    ghost.className = 'macro-sortable ghost';
+    ghost.style.height = `${rect.height}px`;
+    element.parentNode?.insertBefore(ghost, element);
+    
+    // Set up dragged element
+    element.style.position = 'fixed';
+    element.style.left = `${rect.left}px`;
+    element.style.top = `${rect.top}px`;
+    element.style.width = `${rect.width}px`;
+    element.style.zIndex = '50';
+  }, []);
+
+  const handleTouchMove = useCallback((e: React.TouchEvent) => {
+    if (touchStartY === null || touchedIndex === null) return;
+    
+    const touch = e.touches[0];
+    const deltaY = touch.clientY - touchStartY;
+    const element = e.currentTarget as HTMLElement;
+    
+    // Smooth movement of dragged element
+    element.style.transform = `translateY(${deltaY}px)`;
+    
+    const macroElements = document.querySelectorAll('[data-macro-item]');
+    const draggedRect = element.getBoundingClientRect();
+    const draggedMiddle = draggedRect.top + draggedRect.height / 2;
+    
+    macroElements.forEach((el, i) => {
+      if (i === touchedIndex) return;
+      
+      const rect = el.getBoundingClientRect();
+      const elementMiddle = rect.top + rect.height / 2;
+      const distance = Math.abs(draggedMiddle - elementMiddle);
+      
+      if (distance < rect.height) {
+        const direction = draggedMiddle > elementMiddle ? -1 : 1;
+        (el as HTMLElement).style.transform = `translateY(${direction * rect.height}px)`;
+        (el as HTMLElement).style.transition = 'transform 0.15s ease-out';
+      } else {
+        (el as HTMLElement).style.transform = '';
+        (el as HTMLElement).style.transition = 'transform 0.15s ease-out';
+      }
+    });
+  }, [touchStartY, touchedIndex]);
+
+  const handleTouchEnd = useCallback(async (e: React.TouchEvent) => {
+    if (touchedIndex === null) return;
+    
+    const element = e.currentTarget as HTMLElement;
+    const touch = e.changedTouches[0];
+    
+    // Remove ghost element
+    const ghost = document.getElementById('ghost-macro');
+    ghost?.parentNode?.removeChild(ghost);
+    
+    // Reset dragged element styles
+    element.style.position = '';
+    element.style.left = '';
+    element.style.top = '';
+    element.style.width = '';
+    element.style.zIndex = '';
+    element.style.transform = '';
+    element.style.boxShadow = '';
+    element.style.transition = '';
+    
+    const macroElements = document.querySelectorAll('[data-macro-item]');
+    let targetIndex = touchedIndex;
+    
+    // Find the closest element to the final touch position
+    const finalY = touch.clientY;
+    let closestDistance = Infinity;
+    
+    macroElements.forEach((el, i) => {
+      if (i === touchedIndex) return;
+      
+      const rect = el.getBoundingClientRect();
+      const distance = Math.abs(finalY - (rect.top + rect.height / 2));
+      
+      if (distance < closestDistance) {
+        closestDistance = distance;
+        targetIndex = i;
+      }
+      
+      // Reset other elements
+      (el as HTMLElement).style.transform = '';
+      (el as HTMLElement).style.transition = '';
+    });
+    
+    if (targetIndex !== touchedIndex && closestDistance < 50) {
+      const itemsCopy = [...items];
+      const [draggedItem] = itemsCopy.splice(touchedIndex, 1);
+      itemsCopy.splice(targetIndex, 0, draggedItem);
+      onSort(itemsCopy);
+    }
+    
+    setTouchStartY(null);
+    setTouchedIndex(null);
+  }, [touchedIndex, items, onSort]);
+
+  return { handleTouchStart, handleTouchMove, handleTouchEnd };
+};
+
+interface StepError {
+  keys?: string;
+  modifiers?: string;
+  delay?: string;
+}
+
+interface ValidationErrors {
+  name?: string;
+  description?: string;
+  steps?: Record<number, StepError>;
+}
+
+export default function SettingsMacrosRoute() {
+  const { macros, loading, initialized, loadMacros, saveMacros, setSendFn } = useMacrosStore();
+  const [editingMacro, setEditingMacro] = useState<KeySequence | null>(null);
+  const [newMacro, setNewMacro] = useState<Partial<KeySequence>>({
+    name: "",
+    description: "",
+    steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }],
+  });
+  const [isDragging, setIsDragging] = useState(false);
+  const dragItem = useRef<number | null>(null);
+  const dragOverItem = useRef<number | null>(null);
+  
+  const [macroToDelete, setMacroToDelete] = useState<string | null>(null);
+  
+  const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
+  const [editKeyQueries, setEditKeyQueries] = useState<Record<number, string>>({});
+  
+  const [errorMessage, setErrorMessage] = useState<string | null>(null);
+  const [isDesktop, setIsDesktop] = useState(window.innerWidth >= 768);
+  
+  const [send] = useJsonRpc();
+
+  const isMaxMacrosReached = macros.length >= MAX_TOTAL_MACROS;
+  const isMaxStepsReachedForNewMacro = (newMacro.steps?.length || 0) >= MAX_STEPS_PER_MACRO;
+  
+  const showTemporaryError = useCallback((message: string) => {
+    setErrorMessage(message);
+    setTimeout(() => setErrorMessage(null), 3000);
+  }, []);
+  
+  // Helper for both new and edit key select
+  const handleKeySelectUpdate = (stepIndex: number, option: KeyOptionData, isEditing = false) => {
+    if (isEditing && editingMacro) {
+      const updatedSteps = updateStepKeys(editingMacro.steps, stepIndex, option, showTemporaryError);
+      setEditingMacro({ ...editingMacro, steps: updatedSteps });
+    } else {
+      const updatedSteps = updateStepKeys(newMacro.steps || [], stepIndex, option, showTemporaryError);
+      setNewMacro({ ...newMacro, steps: updatedSteps });
+    }
+  };
+  
+  const handleKeySelect = (stepIndex: number, option: KeyOptionData) => {
+    handleKeySelectUpdate(stepIndex, option, false);
+  };
+  
+  const handleEditKeySelect = (stepIndex: number, option: KeyOptionData) => {
+    handleKeySelectUpdate(stepIndex, option, true);
+  };
+  
+  const handleKeyQueryChange = (stepIndex: number, query: string) => {
+    setKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
+  };
+  
+  const handleEditKeyQueryChange = (stepIndex: number, query: string) => {
+    setEditKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
+  };
+  
+  const getFilteredKeys = (stepIndex: number, isEditing = false) => {
+    const query = isEditing 
+      ? (editKeyQueries[stepIndex] || '')
+      : (keyQueries[stepIndex] || '');
+    
+    if (query === '') {
+      return keyOptions;
+    } else {
+      return keyOptions.filter(option => option.label.toLowerCase().includes(query.toLowerCase()));
+    }
+  };
+  
+  useEffect(() => {
+    setSendFn(send);
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros, setSendFn, send]);
+  
+  const [errors, setErrors] = useState<ValidationErrors>({});
+  
+  const clearErrors = useCallback(() => {
+    setErrors({});
+  }, []);
+  
+  const validateMacro = (macro: Partial<KeySequence>): ValidationErrors => {
+    const errors: ValidationErrors = {};
+
+    // Name validation
+    if (!macro.name?.trim()) {
+      errors.name = "Name is required";
+    } else if (macro.name.trim().length > 50) {
+      errors.name = "Name must be less than 50 characters";
+    }
+
+    // Description validation (optional)
+    if (macro.description && macro.description.trim().length > 200) {
+      errors.description = "Description must be less than 200 characters";
+    }
+
+    // Steps validation
+    if (!macro.steps?.length) {
+      errors.steps = { 0: { keys: "At least one step is required" } };
+      return errors;
+    }
+
+    // Check if at least one step has keys or modifiers
+    const hasKeyOrModifier = macro.steps.some(step => 
+      (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0
+    );
+
+    if (!hasKeyOrModifier) {
+      errors.steps = { 0: { keys: "At least one step must have keys or modifiers" } };
+      return errors;
+    }
+
+    const stepErrors: Record<number, StepError> = {};
+    
+    macro.steps.forEach((step, index) => {
+      const stepError: StepError = {};
+
+      // Keys validation (only if keys are present)
+      if (step.keys?.length && step.keys.length > MAX_KEYS_PER_STEP) {
+        stepError.keys = `Maximum ${MAX_KEYS_PER_STEP} keys allowed`;
+      }
+
+      // Delay validation
+      if (typeof step.delay !== 'number' || step.delay < 0) {
+        stepError.delay = "Invalid delay value";
+      }
+
+      if (Object.keys(stepError).length > 0) {
+        stepErrors[index] = stepError;
+      }
+    });
+
+    if (Object.keys(stepErrors).length > 0) {
+      errors.steps = stepErrors;
+    }
+
+    return errors;
+  };
+
+  const resetNewMacro = () => {
+    setNewMacro({
+      name: "",
+      description: "",
+      steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }],
+    });
+    setKeyQueries({});
+    setErrors({});
+  };
+
+  const [isSaving, setIsSaving] = useState(false);
+  const [isUpdating, setIsUpdating] = useState(false);
+  const [isDeleting, setIsDeleting] = useState(false);
+
+  const handleAddMacro = useCallback(async () => {
+    if (isMaxMacrosReached) {
+      showTemporaryError(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    const validationErrors = validateMacro(newMacro);
+    if (Object.keys(validationErrors).length > 0) {
+      setErrors(validationErrors);
+      return;
+    }
+
+    setIsSaving(true);
+    try {
+      const macro: KeySequence = {
+        id: generateId(),
+        name: newMacro.name!.trim(),
+        description: newMacro.description?.trim() || "",
+        steps: newMacro.steps || [],
+        sortOrder: macros.length + 1,
+      };
+
+      await saveMacros(normalizeSortOrders([...macros, macro]));
+      resetNewMacro();
+      setShowAddMacro(false);
+      notifications.success(`Macro "${macro.name}" created successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to create macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to create macro");
+        showTemporaryError("Failed to save macro");
+      }
+    } finally {
+      setIsSaving(false);
+    }
+  }, [isMaxMacrosReached, newMacro, macros, saveMacros, showTemporaryError]);
+
+  const handleDragStart = (index: number) => {
+    dragItem.current = index;
+    setIsDragging(true);
+    
+    const allItems = document.querySelectorAll('[data-macro-item]');
+    const draggedElement = allItems[index];
+    if (draggedElement) {
+      draggedElement.classList.add('dragging');
+    }
+  };
+  
+  const handleDragOver = (e: React.DragEvent, index: number) => {
+    e.preventDefault();
+    dragOverItem.current = index;
+    
+    const allItems = document.querySelectorAll('[data-macro-item]');
+    allItems.forEach(el => el.classList.remove('drop-target'));
+    
+    const targetElement = allItems[index];
+    if (targetElement) {
+      targetElement.classList.add('drop-target');
+    }
+  };
+  
+  const handleDrop = async (e: React.DragEvent) => {
+    e.preventDefault();
+    if (dragItem.current === null || dragOverItem.current === null) return;
+    
+    const macroCopy = [...macros];
+    const draggedItem = macroCopy.splice(dragItem.current, 1)[0];
+    macroCopy.splice(dragOverItem.current, 0, draggedItem);
+    const updatedMacros = normalizeSortOrders(macroCopy);
+
+    try {
+      await saveMacros(updatedMacros);
+      notifications.success("Macro order updated successfully");
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to reorder macros: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to reorder macros");
+        showTemporaryError("Failed to save reordered macros");
+      }
+    }
+    
+    const allItems = document.querySelectorAll('[data-macro-item]');
+    allItems.forEach(el => {
+      el.classList.remove('drop-target');
+      el.classList.remove('dragging');
+    });
+    
+    dragItem.current = null;
+    dragOverItem.current = null;
+    setIsDragging(false);
+  };
+
+  const handleUpdateMacro = useCallback(async () => {
+    if (!editingMacro) return;
+
+    const validationErrors = validateMacro(editingMacro);
+    if (Object.keys(validationErrors).length > 0) {
+      setErrors(validationErrors);
+      return;
+    }
+
+    setIsUpdating(true);
+    try {
+      const newMacros = macros.map(m => 
+        m.id === editingMacro.id ? {
+          ...editingMacro,
+          name: editingMacro.name.trim(),
+          description: editingMacro.description?.trim() || "",
+        } : m
+      );
+
+      await saveMacros(normalizeSortOrders(newMacros));
+      setEditingMacro(null);
+      clearErrors();
+      notifications.success(`Macro "${editingMacro.name}" updated successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to update macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to update macro");
+        showTemporaryError("Failed to update macro");
+      }
+    } finally {
+      setIsUpdating(false);
+    }
+  }, [editingMacro, macros, saveMacros, showTemporaryError, clearErrors]);
+
+  const handleEditMacro = (macro: KeySequence) => {
+    setEditingMacro({
+      ...macro,
+      description: macro.description || "",
+      steps: macro.steps.map(step => ({
+        ...step,
+        keys: ensureArray(step.keys),
+        modifiers: ensureArray(step.modifiers),
+        delay: typeof step.delay === 'number' ? step.delay : DEFAULT_DELAY
+      }))
+    });
+    clearErrors();
+    setEditKeyQueries({});
+  };
+
+  const handleDeleteMacro = async (id: string) => {
+    const macroToBeDeleted = macros.find(m => m.id === id);
+    if (!macroToBeDeleted) return;
+
+    setIsDeleting(true);
+    try {
+      const updatedMacros = normalizeSortOrders(macros.filter(macro => macro.id !== id));
+      await saveMacros(updatedMacros);
+      if (editingMacro?.id === id) {
+        setEditingMacro(null);
+      }
+      setMacroToDelete(null);
+      notifications.success(`Macro "${macroToBeDeleted.name}" deleted successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to delete macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to delete macro");
+        showTemporaryError("Failed to delete macro");
+      }
+    } finally {
+      setIsDeleting(false);
+    }
+  };
+
+  const handleDuplicateMacro = async (macro: KeySequence) => {
+    if (isMaxMacrosReached) {
+      showTemporaryError(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    const newMacroCopy: KeySequence = {
+      ...JSON.parse(JSON.stringify(macro)),
+      id: generateId(),
+      name: `${macro.name} (copy)`,
+      sortOrder: macros.length + 1,
+    };
+
+    newMacroCopy.steps = newMacroCopy.steps.map(step => ({
+      ...step,
+      keys: ensureArray(step.keys),
+      modifiers: ensureArray(step.modifiers),
+      delay: step.delay || DEFAULT_DELAY
+    }));
+
+    try {
+      await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
+      notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to duplicate macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to duplicate macro");
+        showTemporaryError("Failed to duplicate macro");
+      }
+    }
+  };
+
+  const handleStepMove = (stepIndex: number, direction: 'up' | 'down', steps: MacroStep[]) => {
+    const newSteps = [...steps];
+    const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
+    [newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]];
+    return newSteps;
+  };
+
+  useEffect(() => {
+    const handleResize = () => {
+      setIsDesktop(window.innerWidth >= 768);
+    };
+    
+    window.addEventListener('resize', handleResize);
+    return () => window.removeEventListener('resize', handleResize);
+  }, []);
+
+  const { handleTouchStart, handleTouchMove, handleTouchEnd } = useTouchSort(
+    macros,
+    async (newMacros) => {
+      const updatedMacros = normalizeSortOrders(newMacros);
+      try {
+        await saveMacros(updatedMacros);
+        notifications.success("Macro order updated successfully");
+      } catch (error) {
+        if (error instanceof Error) {
+          notifications.error(`Failed to reorder macros: ${error.message}`);
+          showTemporaryError(error.message);
+        } else {
+          notifications.error("Failed to reorder macros");
+          showTemporaryError("Failed to save reordered macros");
+        }
+      }
+    }
+  );
+
+  const [showClearConfirm, setShowClearConfirm] = useState(false);
+  const [showAddMacro, setShowAddMacro] = useState(false);
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && editingMacro) {
+        setEditingMacro(null);
+        setErrors({});
+      }
+      if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
+        if (editingMacro) {
+          handleUpdateMacro();
+        } else if (!isMaxMacrosReached) {
+          handleAddMacro();
+        }
+      }
+    };
+
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [editingMacro, isMaxMacrosReached, handleAddMacro, handleUpdateMacro]);
+
+  const handleModifierChange = (stepIndex: number, modifiers: string[]) => {
+    if (editingMacro) {
+      const newSteps = [...editingMacro.steps];
+      newSteps[stepIndex].modifiers = modifiers;
+      setEditingMacro({ ...editingMacro, steps: newSteps });
+    } else {
+      const newSteps = [...(newMacro.steps || [])];
+      newSteps[stepIndex].modifiers = modifiers;
+      setNewMacro({ ...newMacro, steps: newSteps });
+    }
+  };
+
+  const handleDelayChange = (stepIndex: number, delay: number) => {
+    if (editingMacro) {
+      const newSteps = [...editingMacro.steps];
+      newSteps[stepIndex].delay = delay;
+      setEditingMacro({ ...editingMacro, steps: newSteps });
+    } else {
+      const newSteps = [...(newMacro.steps || [])];
+      newSteps[stepIndex].delay = delay;
+      setNewMacro({ ...newMacro, steps: newSteps });
+    }
+  };
+
+  const ErrorMessage = ({ error }: { error?: string }) => {
+    if (!error) return null;
+    return (
+      <p className="mt-1 text-xs text-red-500 dark:text-red-400">
+        {error}
+      </p>
+    );
+  };
+
+  return (
+    <div className="space-y-4">
+      <SettingsPageHeader
+        title="Keyboard Macros"
+        description="Create and manage keyboard macros for quick actions"
+      />
+
+      {errorMessage && (
+        <div className="macro-error">
+          <div className="flex">
+            <div className="flex-shrink-0">
+              <svg className="macro-error-icon" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
+                <path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.414 1.414L10 11.414l1.72 1.72a.75.75 0 101.414-1.414L11.414 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.586 8.28 7.22z" clipRule="evenodd" />
+              </svg>
+            </div>
+            <div className="ml-3">
+              <h3 className="macro-error-text">{errorMessage}</h3>
+            </div>
+          </div>
+        </div>
+      )}
+
+      <div className="flex items-center justify-between mb-4">
+        <SettingsItem 
+          title="Macros"
+          description={`${macros.length}/${MAX_TOTAL_MACROS}`}
+        >
+          <div className="flex items-center gap-2">
+            {!showAddMacro && (
+              <Button
+                size="SM"
+                theme="primary"
+                text={isMaxMacrosReached ? `Max Reached` : "Add New Macro"}
+                onClick={() => setShowAddMacro(true)}
+                disabled={isMaxMacrosReached}
+              />
+            )}
+          </div>
+        </SettingsItem>
+      </div>
+
+      {loading && (
+        <div className="flex items-center justify-center p-8">
+          <LuLoader className="h-6 w-6 animate-spin text-blue-500" />
+        </div>
+      )}
+      <div className={`space-y-4 ${loading ? 'hidden' : ''}`}>
+        {showAddMacro && (
+          <div className="rounded-md border border-slate-200 bg-white p-3 dark:border-slate-700 dark:bg-slate-800">
+            <div className="flex items-center justify-between mb-2">
+              <h3 className="text-sm font-semibold text-black dark:text-white">Add New Macro</h3>
+            </div>
+            <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
+              <div className="flex flex-col">
+                <input
+                  type="text"
+                  className={`macro-input ${errors.name ? 'border-red-500 dark:border-red-500' : ''}`}
+                  value={newMacro.name}
+                  onChange={e => {
+                    setNewMacro(prev => ({ ...prev, name: e.target.value }));
+                    if (errors.name) {
+                      const newErrors = { ...errors };
+                      delete newErrors.name;
+                      setErrors(newErrors);
+                    }
+                  }}
+                  placeholder="Macro Name"
+                />
+                <ErrorMessage error={errors.name} />
+              </div>
+              <div className="flex flex-col">
+                <input
+                  type="text"
+                  className={`macro-input ${errors.description ? 'border-red-500 dark:border-red-500' : ''}`}
+                  value={newMacro.description}
+                  onChange={e => {
+                    setNewMacro(prev => ({ ...prev, description: e.target.value }));
+                    if (errors.description) {
+                      const newErrors = { ...errors };
+                      delete newErrors.description;
+                      setErrors(newErrors);
+                    }
+                  }}
+                  placeholder="Description (optional)"
+                />
+                <ErrorMessage error={errors.description} />
+              </div>
+            </div>
+            
+            <div className="mt-4">
+              <div className="macro-section-header">
+                <label className="macro-section-title">
+                  Steps:
+                </label>
+                <span className="macro-section-subtitle">
+                  {newMacro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps
+                </span>
+              </div>
+              {errors.steps && errors.steps[0]?.keys && (
+                <div className="mt-2">
+                  <ErrorMessage error={errors.steps[0].keys} />
+                </div>
+              )}
+              <div className="mt-2 text-xs text-slate-500 dark:text-slate-400">
+                You can add up to {MAX_STEPS_PER_MACRO} steps per macro
+              </div>
+              <div className="mt-2 space-y-4">
+                {(newMacro.steps || []).map((step, stepIndex) => (
+                  <MacroStepCard
+                    key={stepIndex}
+                    step={step}
+                    stepIndex={stepIndex}
+                    onDelete={newMacro.steps && newMacro.steps.length > 1 ? () => {
+                      const newSteps = [...(newMacro.steps || [])];
+                      newSteps.splice(stepIndex, 1);
+                      setNewMacro(prev => ({ ...prev, steps: newSteps }));
+                    } : undefined}
+                    onMoveUp={() => {
+                      const newSteps = handleStepMove(stepIndex, 'up', newMacro.steps || []);
+                      setNewMacro(prev => ({ ...prev, steps: newSteps }));
+                    }}
+                    onMoveDown={() => {
+                      const newSteps = handleStepMove(stepIndex, 'down', newMacro.steps || []);
+                      setNewMacro(prev => ({ ...prev, steps: newSteps }));
+                    }}
+                    isDesktop={isDesktop}
+                    onKeySelect={(option) => handleKeySelect(stepIndex, option)}
+                    onKeyQueryChange={(query) => handleKeyQueryChange(stepIndex, query)}
+                    keyQuery={keyQueries[stepIndex] || ''}
+                    getFilteredKeys={() => getFilteredKeys(stepIndex)}
+                    onModifierChange={(modifiers) => handleModifierChange(stepIndex, modifiers)}
+                    onDelayChange={(delay) => handleDelayChange(stepIndex, delay)}
+                    isLastStep={stepIndex === (newMacro.steps?.length || 0) - 1}
+                  />
+                ))}
+
+                <div className="mt-4 border-t border-slate-200 pt-4 dark:border-slate-700">
+                  <button
+                    type="button"
+                    className={`w-full flex items-center justify-center gap-1 rounded-md px-3 py-2 text-sm font-medium transition-colors ${
+                      isMaxStepsReachedForNewMacro
+                        ? 'bg-slate-100 text-slate-400 cursor-not-allowed dark:bg-slate-800 dark:text-slate-500'
+                        : 'bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700'
+                    }`}
+                    onClick={() => {
+                      if (isMaxStepsReachedForNewMacro) {
+                        showTemporaryError(`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`);
+                        return;
+                      }
+                      
+                      setNewMacro(prev => ({
+                        ...prev,
+                        steps: [
+                          ...(prev.steps || []), 
+                          { keys: [], modifiers: [], delay: DEFAULT_DELAY }
+                        ],
+                      }));
+                      clearErrors();
+                    }}
+                    disabled={isMaxStepsReachedForNewMacro}
+                  >
+                    <LuPlus className="h-4 w-4" />
+                    <span>Add Step {isMaxStepsReachedForNewMacro && `(${MAX_STEPS_PER_MACRO} max)`}</span>
+                  </button>
+                </div>
+
+                <div className="mt-6 flex items-center justify-between border-t border-slate-200 pt-4 dark:border-slate-700">
+                  {showClearConfirm ? (
+                    <div className="flex items-center gap-2">
+                      <span className="text-sm text-slate-600 dark:text-slate-400">
+                        Cancel changes?
+                      </span>
+                      <Button
+                        size="SM"
+                        theme="danger"
+                        text="Yes"
+                        onClick={() => {
+                          resetNewMacro();
+                          setShowAddMacro(false);
+                          setShowClearConfirm(false);
+                        }}
+                      />
+                      <Button
+                        size="SM"
+                        theme="light"
+                        text="No"
+                        onClick={() => setShowClearConfirm(false)}
+                      />
+                    </div>
+                  ) : (
+                    <div className="flex gap-x-2">
+                      <Button
+                        size="SM"
+                        theme="primary"
+                        text={isSaving ? "Saving..." : "Save Macro"}
+                        onClick={handleAddMacro}
+                        disabled={isSaving}
+                      />
+                      <Button
+                        size="SM"
+                        theme="light"
+                        text="Cancel"
+                        LeadingIcon={LuX}
+                        onClick={() => {
+                          if (newMacro.name || newMacro.description || newMacro.steps?.some(s => s.keys?.length || s.modifiers?.length)) {
+                            setShowClearConfirm(true);
+                          } else {
+                            resetNewMacro();
+                            setShowAddMacro(false);
+                          }
+                        }}
+                      />
+                    </div>
+                  )}
+                </div>
+              </div>
+            </div>
+          </div>
+        )}
+        {macros.length > 0 && (
+          <div className="space-y-2">
+            
+            {macros.length === 0 ? (
+              <p className="text-center text-sm text-slate-500 dark:text-slate-400 py-4">
+                No macros created yet. Add your first macro above.
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: Savid
body: cleaned up, thanks!
created_at: 2025-04-03T08:26:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,1439 @@
+import { useState, useEffect, useRef, useCallback } from "react";
+import { LuPlus, LuTrash, LuX, LuPenLine, LuLoader, LuGripVertical, LuInfo } from "react-icons/lu";
+import { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
+
+import { KeySequence, useMacrosStore } from "../hooks/stores";
+import { SettingsPageHeader } from "../components/SettingsPageheader";
+import { Button } from "../components/Button";
+import { keys, modifiers } from "../keyboardMappings";
+import { useJsonRpc } from "../hooks/useJsonRpc";
+import notifications from "../notifications";
+import { SettingsItem } from "../routes/devices.$id.settings";
+
+const DEFAULT_DELAY = 50;
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface KeyOption {
+  value: string;
+  label: string;
+}
+
+interface KeyOptionData {
+  value: string | null;
+  keys?: string[];
+  label?: string;
+}
+
+const generateId = () => {
+  return `macro-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
+};
+
+const keyOptions = Object.keys(keys).map(key => ({
+  value: key,
+  label: key,
+}));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+interface KeyComboboxProps {
+  stepIndex: number;
+  step: MacroStep;
+  onSelect: (option: KeyOptionData) => void;
+  query: string;
+  onQueryChange: (query: string) => void;
+  getFilteredOptions: () => KeyOption[];
+  disabled?: boolean;
+}
+
+function KeyCombobox({
+  onSelect,
+  query,
+  onQueryChange,
+  getFilteredOptions,
+  disabled = false,
+}: KeyComboboxProps) {
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  return (
+    <div className="relative w-full">
+      <Combobox immediate onChange={onSelect} disabled={disabled}>
+        {() => (
+          <>
+            <div className="relative">
+              <ComboboxInput
+                ref={inputRef}
+                className={`macro-input ${disabled ? 'opacity-60 cursor-not-allowed' : ''}`}
+                placeholder={disabled ? "Max keys reached" : "Search for key..."}
+                displayValue={() => query}
+                onChange={(event) => onQueryChange(event.target.value)}
+                disabled={disabled}
+              />
+            </div>
+            
+            <ComboboxOptions className="absolute left-0 z-50 mt-1 w-full max-h-60 overflow-auto rounded-md bg-white dark:bg-slate-800 py-1 text-sm shadow-lg">
+              {getFilteredOptions().map((option) => (
+                <ComboboxOption 
+                  key={option.value} 
+                  value={option}
+                  className="cursor-default select-none py-1.5 px-3 ui-active:bg-blue-100 ui-active:text-blue-900 dark:text-slate-300 dark:ui-active:bg-blue-900/40 dark:ui-active:text-blue-200"
+                >
+                  {option.label}
+                </ComboboxOption>
+              ))}
+              {getFilteredOptions().length === 0 && (
+                <div className="py-2 px-3 text-sm text-slate-500 dark:text-slate-400">
+                  No matching keys found
+                </div>
+              )}
+            </ComboboxOptions>
+          </>
+        )}
+      </Combobox>
+    </div>
+  );
+}
+
+const PRESET_DELAYS = [
+  { value: 50, label: "50ms" },
+  { value: 100, label: "100ms" },
+  { value: 200, label: "200ms" },
+  { value: 300, label: "300ms" },
+  { value: 500, label: "500ms" },
+  { value: 750, label: "750ms" },
+  { value: 1000, label: "1000ms" },
+  { value: 1500, label: "1500ms" },
+  { value: 2000, label: "2000ms" },
+];
+
+const MAX_STEPS_PER_MACRO = 10;
+const MAX_TOTAL_MACROS = 25;
+const MAX_KEYS_PER_STEP = 10;
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+// Helper function to normalize sort orders, ensuring they start at 1 and have no gaps
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  isDesktop: boolean;
+  onKeySelect: (option: KeyOptionData) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  getFilteredKeys: () => KeyOption[];
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  getFilteredKeys,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  return (
+    <div className="macro-step-card rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 p-4 shadow-sm">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <button
+              type="button"
+              className="p-1 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400 disabled:opacity-50"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="m18 15-6-6-6 6"/>
+              </svg>
+            </button>
+            <button
+              type="button"
+              className="p-1 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400 disabled:opacity-50"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="m6 9 6 6 6-6"/>
+              </svg>
+            </button>
+          </div>
+          <span className="macro-step-number flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+        
+        <div className="flex items-center space-x-2">
+          {onDelete && (
+            <button
+              type="button"
+              className="flex items-center text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
+              onClick={onDelete}
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <path d="M3 6h18"></path>
+                <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
+                <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
+              </svg>
+              <span className="ml-1">Delete</span>
+            </button>
+          )}
+        </div>
+      </div>
+      
+      <div className="space-y-4 mt-2">
+        <div className="w-full flex flex-col gap-2">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Modifiers:
+          </label>
+          <div className="macro-modifiers-container inline-flex flex-wrap gap-3">
+            {Object.entries(groupedModifiers).map(([group, mods]) => (
+              <div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
+                <span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
+                  {group}
+                </span>
+                <div className="flex flex-wrap gap-1">
+                  {mods.map(option => (
+                    <label 
+                      key={option.value} 
+                      className={`flex items-center px-2 py-1 rounded border cursor-pointer text-xs font-medium transition-colors ${
+                        ensureArray(step.modifiers).includes(option.value) 
+                          ? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-200' 
+                          : 'bg-slate-100 border-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700'
+                      }`}
+                    >
+                      <input
+                        type="checkbox"
+                        className="sr-only"
+                        checked={ensureArray(step.modifiers).includes(option.value)}
+                        onChange={e => {
+                          const modifiersArray = ensureArray(step.modifiers);
+                          const newModifiers = e.target.checked
+                            ? [...modifiersArray, option.value]
+                            : modifiersArray.filter(m => m !== option.value);
+                          onModifierChange(newModifiers);
+                        }}
+                      />
+                      {option.label.split(' ')[1] || option.label}
+                    </label>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Keys:
+          </label>
+          
+          <div className="macro-key-group flex flex-wrap gap-1 mb-2">
+            {ensureArray(step.keys).map((key, keyIndex) => (
+              <span
+                key={keyIndex}
+                className="macro-key-badge inline-flex items-center rounded-md bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
+              >
+                {key}
+                <button
+                  type="button"
+                  className="ml-1 text-xs text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
+                  onClick={() => {
+                    const newKeys = ensureArray(step.keys).filter((_, i) => i !== keyIndex);
+                    onKeySelect({ value: null, keys: newKeys });
+                  }}
+                >
+                  ×
+                </button>
+              </span>
+            ))}
+          </div>
+
+          <KeyCombobox
+            stepIndex={stepIndex}
+            step={step}
+            onSelect={onKeySelect}
+            query={keyQuery}
+            onQueryChange={onKeyQueryChange}
+            getFilteredOptions={getFilteredKeys}
+            disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP}
+          />
+          
+          {ensureArray(step.keys).length >= MAX_KEYS_PER_STEP && (
+            <span className="text-xs text-amber-600 dark:text-amber-400 mt-1">
+              (max keys reached)
+            </span>
+          )}
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+              Step Duration:
+            </label>
+            <div className="group relative">
+              <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+              <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                <p>The time to wait after pressing the keys in this step before moving to the next step. This helps ensure reliable key presses when automating keyboard input.</p>
+              </div>
+            </div>
+          </div>
+          <div className="flex items-center gap-3">
+            <select
+              className="w-full rounded-md border border-slate-300 bg-slate-50 p-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
+              value={step.delay}
+              onChange={(e) => onDelayChange(parseInt(e.target.value, 10))}
+            >
+              {PRESET_DELAYS.map((option) => (
+                <option key={option.value} value={option.value}>
+                  {option.label}
+                </option>
+              ))}
+            </select>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// Helper to update step keys used by both new and edit flows
+const updateStepKeys = (
+  steps: MacroStep[],
+  stepIndex: number,
+  keyOption: { value: string | null; keys?: string[] },
+  showTemporaryError: (msg: string) => void
+) => {
+  const newSteps = [...steps];
+  if (keyOption.keys) {
+    newSteps[stepIndex].keys = keyOption.keys;
+  } else if (keyOption.value) {
+    if (!newSteps[stepIndex].keys) {
+      newSteps[stepIndex].keys = [];
+    }
+    const keysArray = ensureArray(newSteps[stepIndex].keys);
+    if (keysArray.length >= MAX_KEYS_PER_STEP) {
+      showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`);
+      return newSteps;
+    }
+    newSteps[stepIndex].keys = [...keysArray, keyOption.value];
+  }
+  return newSteps;
+};
+
+const useTouchSort = (items: KeySequence[], onSort: (newItems: KeySequence[]) => void) => {
+  const [touchStartY, setTouchStartY] = useState<number | null>(null);
+  const [touchedIndex, setTouchedIndex] = useState<number | null>(null);
+
+  const handleTouchStart = useCallback((e: React.TouchEvent, index: number) => {
+    const touch = e.touches[0];
+    setTouchStartY(touch.clientY);
+    setTouchedIndex(index);
+    
+    const element = e.currentTarget as HTMLElement;
+    const rect = element.getBoundingClientRect();
+    
+    // Create ghost element
+    const ghost = element.cloneNode(true) as HTMLElement;
+    ghost.id = 'ghost-macro';
+    ghost.className = 'macro-sortable ghost';
+    ghost.style.height = `${rect.height}px`;
+    element.parentNode?.insertBefore(ghost, element);
+    
+    // Set up dragged element
+    element.style.position = 'fixed';
+    element.style.left = `${rect.left}px`;
+    element.style.top = `${rect.top}px`;
+    element.style.width = `${rect.width}px`;
+    element.style.zIndex = '50';
+  }, []);
+
+  const handleTouchMove = useCallback((e: React.TouchEvent) => {
+    if (touchStartY === null || touchedIndex === null) return;
+    
+    const touch = e.touches[0];
+    const deltaY = touch.clientY - touchStartY;
+    const element = e.currentTarget as HTMLElement;
+    
+    // Smooth movement of dragged element
+    element.style.transform = `translateY(${deltaY}px)`;
+    
+    const macroElements = document.querySelectorAll('[data-macro-item]');
+    const draggedRect = element.getBoundingClientRect();
+    const draggedMiddle = draggedRect.top + draggedRect.height / 2;
+    
+    macroElements.forEach((el, i) => {
+      if (i === touchedIndex) return;
+      
+      const rect = el.getBoundingClientRect();
+      const elementMiddle = rect.top + rect.height / 2;
+      const distance = Math.abs(draggedMiddle - elementMiddle);
+      
+      if (distance < rect.height) {
+        const direction = draggedMiddle > elementMiddle ? -1 : 1;
+        (el as HTMLElement).style.transform = `translateY(${direction * rect.height}px)`;
+        (el as HTMLElement).style.transition = 'transform 0.15s ease-out';
+      } else {
+        (el as HTMLElement).style.transform = '';
+        (el as HTMLElement).style.transition = 'transform 0.15s ease-out';
+      }
+    });
+  }, [touchStartY, touchedIndex]);
+
+  const handleTouchEnd = useCallback(async (e: React.TouchEvent) => {
+    if (touchedIndex === null) return;
+    
+    const element = e.currentTarget as HTMLElement;
+    const touch = e.changedTouches[0];
+    
+    // Remove ghost element
+    const ghost = document.getElementById('ghost-macro');
+    ghost?.parentNode?.removeChild(ghost);
+    
+    // Reset dragged element styles
+    element.style.position = '';
+    element.style.left = '';
+    element.style.top = '';
+    element.style.width = '';
+    element.style.zIndex = '';
+    element.style.transform = '';
+    element.style.boxShadow = '';
+    element.style.transition = '';
+    
+    const macroElements = document.querySelectorAll('[data-macro-item]');
+    let targetIndex = touchedIndex;
+    
+    // Find the closest element to the final touch position
+    const finalY = touch.clientY;
+    let closestDistance = Infinity;
+    
+    macroElements.forEach((el, i) => {
+      if (i === touchedIndex) return;
+      
+      const rect = el.getBoundingClientRect();
+      const distance = Math.abs(finalY - (rect.top + rect.height / 2));
+      
+      if (distance < closestDistance) {
+        closestDistance = distance;
+        targetIndex = i;
+      }
+      
+      // Reset other elements
+      (el as HTMLElement).style.transform = '';
+      (el as HTMLElement).style.transition = '';
+    });
+    
+    if (targetIndex !== touchedIndex && closestDistance < 50) {
+      const itemsCopy = [...items];
+      const [draggedItem] = itemsCopy.splice(touchedIndex, 1);
+      itemsCopy.splice(targetIndex, 0, draggedItem);
+      onSort(itemsCopy);
+    }
+    
+    setTouchStartY(null);
+    setTouchedIndex(null);
+  }, [touchedIndex, items, onSort]);
+
+  return { handleTouchStart, handleTouchMove, handleTouchEnd };
+};
+
+interface StepError {
+  keys?: string;
+  modifiers?: string;
+  delay?: string;
+}
+
+interface ValidationErrors {
+  name?: string;
+  description?: string;
+  steps?: Record<number, StepError>;
+}
+
+export default function SettingsMacrosRoute() {
+  const { macros, loading, initialized, loadMacros, saveMacros, setSendFn } = useMacrosStore();
+  const [editingMacro, setEditingMacro] = useState<KeySequence | null>(null);
+  const [newMacro, setNewMacro] = useState<Partial<KeySequence>>({
+    name: "",
+    description: "",
+    steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }],
+  });
+  const [isDragging, setIsDragging] = useState(false);
+  const dragItem = useRef<number | null>(null);
+  const dragOverItem = useRef<number | null>(null);
+  
+  const [macroToDelete, setMacroToDelete] = useState<string | null>(null);
+  
+  const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
+  const [editKeyQueries, setEditKeyQueries] = useState<Record<number, string>>({});
+  
+  const [errorMessage, setErrorMessage] = useState<string | null>(null);
+  const [isDesktop, setIsDesktop] = useState(window.innerWidth >= 768);
+  
+  const [send] = useJsonRpc();
+
+  const isMaxMacrosReached = macros.length >= MAX_TOTAL_MACROS;
+  const isMaxStepsReachedForNewMacro = (newMacro.steps?.length || 0) >= MAX_STEPS_PER_MACRO;
+  
+  const showTemporaryError = useCallback((message: string) => {
+    setErrorMessage(message);
+    setTimeout(() => setErrorMessage(null), 3000);
+  }, []);
+  
+  // Helper for both new and edit key select
+  const handleKeySelectUpdate = (stepIndex: number, option: KeyOptionData, isEditing = false) => {
+    if (isEditing && editingMacro) {
+      const updatedSteps = updateStepKeys(editingMacro.steps, stepIndex, option, showTemporaryError);
+      setEditingMacro({ ...editingMacro, steps: updatedSteps });
+    } else {
+      const updatedSteps = updateStepKeys(newMacro.steps || [], stepIndex, option, showTemporaryError);
+      setNewMacro({ ...newMacro, steps: updatedSteps });
+    }
+  };
+  
+  const handleKeySelect = (stepIndex: number, option: KeyOptionData) => {
+    handleKeySelectUpdate(stepIndex, option, false);
+  };
+  
+  const handleEditKeySelect = (stepIndex: number, option: KeyOptionData) => {
+    handleKeySelectUpdate(stepIndex, option, true);
+  };
+  
+  const handleKeyQueryChange = (stepIndex: number, query: string) => {
+    setKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
+  };
+  
+  const handleEditKeyQueryChange = (stepIndex: number, query: string) => {
+    setEditKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
+  };
+  
+  const getFilteredKeys = (stepIndex: number, isEditing = false) => {
+    const query = isEditing 
+      ? (editKeyQueries[stepIndex] || '')
+      : (keyQueries[stepIndex] || '');
+    
+    if (query === '') {
+      return keyOptions;
+    } else {
+      return keyOptions.filter(option => option.label.toLowerCase().includes(query.toLowerCase()));
+    }
+  };
+  
+  useEffect(() => {
+    setSendFn(send);
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros, setSendFn, send]);
+  
+  const [errors, setErrors] = useState<ValidationErrors>({});
+  
+  const clearErrors = useCallback(() => {
+    setErrors({});
+  }, []);
+  
+  const validateMacro = (macro: Partial<KeySequence>): ValidationErrors => {
+    const errors: ValidationErrors = {};
+
+    // Name validation
+    if (!macro.name?.trim()) {
+      errors.name = "Name is required";
+    } else if (macro.name.trim().length > 50) {
+      errors.name = "Name must be less than 50 characters";
+    }
+
+    // Description validation (optional)
+    if (macro.description && macro.description.trim().length > 200) {
+      errors.description = "Description must be less than 200 characters";
+    }
+
+    // Steps validation
+    if (!macro.steps?.length) {
+      errors.steps = { 0: { keys: "At least one step is required" } };
+      return errors;
+    }
+
+    // Check if at least one step has keys or modifiers
+    const hasKeyOrModifier = macro.steps.some(step => 
+      (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0
+    );
+
+    if (!hasKeyOrModifier) {
+      errors.steps = { 0: { keys: "At least one step must have keys or modifiers" } };
+      return errors;
+    }
+
+    const stepErrors: Record<number, StepError> = {};
+    
+    macro.steps.forEach((step, index) => {
+      const stepError: StepError = {};
+
+      // Keys validation (only if keys are present)
+      if (step.keys?.length && step.keys.length > MAX_KEYS_PER_STEP) {
+        stepError.keys = `Maximum ${MAX_KEYS_PER_STEP} keys allowed`;
+      }
+
+      // Delay validation
+      if (typeof step.delay !== 'number' || step.delay < 0) {
+        stepError.delay = "Invalid delay value";
+      }
+
+      if (Object.keys(stepError).length > 0) {
+        stepErrors[index] = stepError;
+      }
+    });
+
+    if (Object.keys(stepErrors).length > 0) {
+      errors.steps = stepErrors;
+    }
+
+    return errors;
+  };
+
+  const resetNewMacro = () => {
+    setNewMacro({
+      name: "",
+      description: "",
+      steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }],
+    });
+    setKeyQueries({});
+    setErrors({});
+  };
+
+  const [isSaving, setIsSaving] = useState(false);
+  const [isUpdating, setIsUpdating] = useState(false);
+  const [isDeleting, setIsDeleting] = useState(false);
+
+  const handleAddMacro = useCallback(async () => {
+    if (isMaxMacrosReached) {
+      showTemporaryError(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    const validationErrors = validateMacro(newMacro);
+    if (Object.keys(validationErrors).length > 0) {
+      setErrors(validationErrors);
+      return;
+    }
+
+    setIsSaving(true);
+    try {
+      const macro: KeySequence = {
+        id: generateId(),
+        name: newMacro.name!.trim(),
+        description: newMacro.description?.trim() || "",
+        steps: newMacro.steps || [],
+        sortOrder: macros.length + 1,
+      };
+
+      await saveMacros(normalizeSortOrders([...macros, macro]));
+      resetNewMacro();
+      setShowAddMacro(false);
+      notifications.success(`Macro "${macro.name}" created successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to create macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to create macro");
+        showTemporaryError("Failed to save macro");
+      }
+    } finally {
+      setIsSaving(false);
+    }
+  }, [isMaxMacrosReached, newMacro, macros, saveMacros, showTemporaryError]);
+
+  const handleDragStart = (index: number) => {
+    dragItem.current = index;
+    setIsDragging(true);
+    
+    const allItems = document.querySelectorAll('[data-macro-item]');
+    const draggedElement = allItems[index];
+    if (draggedElement) {
+      draggedElement.classList.add('dragging');
+    }
+  };
+  
+  const handleDragOver = (e: React.DragEvent, index: number) => {
+    e.preventDefault();
+    dragOverItem.current = index;
+    
+    const allItems = document.querySelectorAll('[data-macro-item]');
+    allItems.forEach(el => el.classList.remove('drop-target'));
+    
+    const targetElement = allItems[index];
+    if (targetElement) {
+      targetElement.classList.add('drop-target');
+    }
+  };
+  
+  const handleDrop = async (e: React.DragEvent) => {
+    e.preventDefault();
+    if (dragItem.current === null || dragOverItem.current === null) return;
+    
+    const macroCopy = [...macros];
+    const draggedItem = macroCopy.splice(dragItem.current, 1)[0];
+    macroCopy.splice(dragOverItem.current, 0, draggedItem);
+    const updatedMacros = normalizeSortOrders(macroCopy);
+
+    try {
+      await saveMacros(updatedMacros);
+      notifications.success("Macro order updated successfully");
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to reorder macros: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to reorder macros");
+        showTemporaryError("Failed to save reordered macros");
+      }
+    }
+    
+    const allItems = document.querySelectorAll('[data-macro-item]');
+    allItems.forEach(el => {
+      el.classList.remove('drop-target');
+      el.classList.remove('dragging');
+    });
+    
+    dragItem.current = null;
+    dragOverItem.current = null;
+    setIsDragging(false);
+  };
+
+  const handleUpdateMacro = useCallback(async () => {
+    if (!editingMacro) return;
+
+    const validationErrors = validateMacro(editingMacro);
+    if (Object.keys(validationErrors).length > 0) {
+      setErrors(validationErrors);
+      return;
+    }
+
+    setIsUpdating(true);
+    try {
+      const newMacros = macros.map(m => 
+        m.id === editingMacro.id ? {
+          ...editingMacro,
+          name: editingMacro.name.trim(),
+          description: editingMacro.description?.trim() || "",
+        } : m
+      );
+
+      await saveMacros(normalizeSortOrders(newMacros));
+      setEditingMacro(null);
+      clearErrors();
+      notifications.success(`Macro "${editingMacro.name}" updated successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to update macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to update macro");
+        showTemporaryError("Failed to update macro");
+      }
+    } finally {
+      setIsUpdating(false);
+    }
+  }, [editingMacro, macros, saveMacros, showTemporaryError, clearErrors]);
+
+  const handleEditMacro = (macro: KeySequence) => {
+    setEditingMacro({
+      ...macro,
+      description: macro.description || "",
+      steps: macro.steps.map(step => ({
+        ...step,
+        keys: ensureArray(step.keys),
+        modifiers: ensureArray(step.modifiers),
+        delay: typeof step.delay === 'number' ? step.delay : DEFAULT_DELAY
+      }))
+    });
+    clearErrors();
+    setEditKeyQueries({});
+  };
+
+  const handleDeleteMacro = async (id: string) => {
+    const macroToBeDeleted = macros.find(m => m.id === id);
+    if (!macroToBeDeleted) return;
+
+    setIsDeleting(true);
+    try {
+      const updatedMacros = normalizeSortOrders(macros.filter(macro => macro.id !== id));
+      await saveMacros(updatedMacros);
+      if (editingMacro?.id === id) {
+        setEditingMacro(null);
+      }
+      setMacroToDelete(null);
+      notifications.success(`Macro "${macroToBeDeleted.name}" deleted successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to delete macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to delete macro");
+        showTemporaryError("Failed to delete macro");
+      }
+    } finally {
+      setIsDeleting(false);
+    }
+  };
+
+  const handleDuplicateMacro = async (macro: KeySequence) => {
+    if (isMaxMacrosReached) {
+      showTemporaryError(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    const newMacroCopy: KeySequence = {
+      ...JSON.parse(JSON.stringify(macro)),
+      id: generateId(),
+      name: `${macro.name} (copy)`,
+      sortOrder: macros.length + 1,
+    };
+
+    newMacroCopy.steps = newMacroCopy.steps.map(step => ({
+      ...step,
+      keys: ensureArray(step.keys),
+      modifiers: ensureArray(step.modifiers),
+      delay: step.delay || DEFAULT_DELAY
+    }));
+
+    try {
+      await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
+      notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
+    } catch (error) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to duplicate macro: ${error.message}`);
+        showTemporaryError(error.message);
+      } else {
+        notifications.error("Failed to duplicate macro");
+        showTemporaryError("Failed to duplicate macro");
+      }
+    }
+  };
+
+  const handleStepMove = (stepIndex: number, direction: 'up' | 'down', steps: MacroStep[]) => {
+    const newSteps = [...steps];
+    const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
+    [newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]];
+    return newSteps;
+  };
+
+  useEffect(() => {
+    const handleResize = () => {
+      setIsDesktop(window.innerWidth >= 768);
+    };
+    
+    window.addEventListener('resize', handleResize);
+    return () => window.removeEventListener('resize', handleResize);
+  }, []);
+
+  const { handleTouchStart, handleTouchMove, handleTouchEnd } = useTouchSort(
+    macros,
+    async (newMacros) => {
+      const updatedMacros = normalizeSortOrders(newMacros);
+      try {
+        await saveMacros(updatedMacros);
+        notifications.success("Macro order updated successfully");
+      } catch (error) {
+        if (error instanceof Error) {
+          notifications.error(`Failed to reorder macros: ${error.message}`);
+          showTemporaryError(error.message);
+        } else {
+          notifications.error("Failed to reorder macros");
+          showTemporaryError("Failed to save reordered macros");
+        }
+      }
+    }
+  );
+
+  const [showClearConfirm, setShowClearConfirm] = useState(false);
+  const [showAddMacro, setShowAddMacro] = useState(false);
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && editingMacro) {
+        setEditingMacro(null);
+        setErrors({});
+      }
+      if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
+        if (editingMacro) {
+          handleUpdateMacro();
+        } else if (!isMaxMacrosReached) {
+          handleAddMacro();
+        }
+      }
+    };
+
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [editingMacro, isMaxMacrosReached, handleAddMacro, handleUpdateMacro]);
+
+  const handleModifierChange = (stepIndex: number, modifiers: string[]) => {
+    if (editingMacro) {
+      const newSteps = [...editingMacro.steps];
+      newSteps[stepIndex].modifiers = modifiers;
+      setEditingMacro({ ...editingMacro, steps: newSteps });
+    } else {
+      const newSteps = [...(newMacro.steps || [])];
+      newSteps[stepIndex].modifiers = modifiers;
+      setNewMacro({ ...newMacro, steps: newSteps });
+    }
+  };
+
+  const handleDelayChange = (stepIndex: number, delay: number) => {
+    if (editingMacro) {
+      const newSteps = [...editingMacro.steps];
+      newSteps[stepIndex].delay = delay;
+      setEditingMacro({ ...editingMacro, steps: newSteps });
+    } else {
+      const newSteps = [...(newMacro.steps || [])];
+      newSteps[stepIndex].delay = delay;
+      setNewMacro({ ...newMacro, steps: newSteps });
+    }
+  };
+
+  const ErrorMessage = ({ error }: { error?: string }) => {
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: Savid
body: converted everything over that i could see, lmk if i missed a shared component
created_at: 2025-04-03T08:26:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+import { useEffect, Fragment, useMemo, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { LuPenLine, LuLoader, LuCopy, LuMoveRight, LuCornerDownRight, LuArrowUp, LuArrowDown } from "react-icons/lu";
+
+import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
+import { SettingsPageHeader } from "@/components/SettingsPageheader";
+import { Button } from "@/components/Button";
+import EmptyCard from "@/components/EmptyCard";
+import Card from "@/components/Card";
+import { MAX_TOTAL_MACROS, COPY_SUFFIX } from "@/constants/macros";
+import { keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings";
+import notifications from "@/notifications";
+import { SettingsItem } from "@/routes/devices.$id.settings";
+
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+export default function SettingsMacrosRoute() {
+  const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore();
+  const navigate = useNavigate();
+  const [actionLoadingId, setActionLoadingId] = useState<string | null>(null);
+  
+  const isMaxMacrosReached = useMemo(() => 
+    macros.length >= MAX_TOTAL_MACROS, 
+    [macros.length]
+  );
+
+  useEffect(() => {
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros]);
+
+  const handleDuplicateMacro = async (macro: KeySequence) => {
+    if (!macro?.id || !macro?.name) {
+      notifications.error("Invalid macro data");
+      return;
+    }
+
+    if (isMaxMacrosReached) {
+      notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    setActionLoadingId(macro.id);
+
+    const newMacroCopy: KeySequence = {
+      ...JSON.parse(JSON.stringify(macro)),
+      id: generateMacroId(),
+      name: `${macro.name} ${COPY_SUFFIX}`,
+      sortOrder: macros.length + 1,
+    };
+
+    try {
+      await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
+      notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to duplicate macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to duplicate macro");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  };
+
+  const handleMoveMacro = async (index: number, direction: 'up' | 'down', macroId: string) => {
+    if (!Array.isArray(macros) || macros.length === 0) {
+      notifications.error("No macros available");
+      return;
+    }
+
+    const newIndex = direction === 'up' ? index - 1 : index + 1;
+    if (newIndex < 0 || newIndex >= macros.length) return;
+
+    setActionLoadingId(macroId);
+
+    try {
+      const newMacros = [...macros];
+      [newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]];
+      const updatedMacros = normalizeSortOrders(newMacros);
+
+      await saveMacros(updatedMacros);
+      notifications.success("Macro order updated successfully");
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to reorder macros: ${error.message}`);
+      } else {
+        notifications.error("Failed to reorder macros");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  };
+
+  const MacroList = useMemo(() => (
+    <div className="space-y-2">
+      {macros.map((macro, index) => (
+        <Card key={macro.id} className="p-2 bg-white dark:bg-slate-800">
+          <div className="flex items-center justify-between">
+            <div className="flex flex-col gap-1 px-2">
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'up', macro.id)}
+                disabled={index === 0 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowUp}
+                aria-label={`Move ${macro.name} up`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'down', macro.id)}
+                disabled={index === macros.length - 1 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowDown}
+                aria-label={`Move ${macro.name} down`}
+              />
+            </div>
+
+            <div className="flex-1 min-w-0 flex flex-col justify-center ml-2">
+              <h3 className="truncate text-sm font-semibold text-black dark:text-white">
+                {macro.name}
+              </h3>
+              <p className="mt-1 ml-4 text-xs text-slate-500 dark:text-slate-400 overflow-hidden">
+                <span className="flex flex-col items-start gap-1">
+                  {macro.steps.map((step, stepIndex) => {
+                    const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight;
+
+                    return (
+                      <span key={stepIndex} className="inline-flex items-center">
+                        <StepIcon className="mr-1 text-slate-400 dark:text-slate-500 h-3 w-3 flex-shrink-0" />
+                        <span className="bg-slate-50 dark:bg-slate-800 px-2 py-0.5 rounded-md border border-slate-200/50 dark:border-slate-700/50">
+                          {(Array.isArray(step.modifiers) && step.modifiers.length > 0) || (Array.isArray(step.keys) && step.keys.length > 0) ? (
+                            <>
+                              {Array.isArray(step.modifiers) && step.modifiers.map((modifier, idx) => (
+                                <Fragment key={`mod-${idx}`}>
+                                  <span className="font-medium text-slate-600 dark:text-slate-200">
+                                    {modifierDisplayMap[modifier] || modifier}
+                                  </span>
+                                  {idx < step.modifiers.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+
+                              {Array.isArray(step.modifiers) && step.modifiers.length > 0 && Array.isArray(step.keys) && step.keys.length > 0 && (
+                                <span className="text-slate-400 dark:text-slate-600"> + </span>
+                              )}
+
+                              {Array.isArray(step.keys) && step.keys.map((key, idx) => (
+                                <Fragment key={`key-${idx}`}>
+                                  <span className="font-medium text-blue-600 dark:text-blue-400">
+                                    {keyDisplayMap[key] || key}
+                                  </span>
+                                  {idx < step.keys.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+                            </>
+                          ) : (
+                            <span className="font-medium text-slate-500 dark:text-slate-400">Delay only</span>
+                          )}
+                          <span className="ml-1 text-slate-400 dark:text-slate-500">({step.delay}ms)</span>
+                        </span>
+                      </span>
+                    );
+                  })}
+                </span>
+              </p>
+            </div>
+
+            <div className="flex items-center gap-1 ml-4">
+              <Button
+                size="XS"
+                theme="light"
+                LeadingIcon={LuCopy}
+                onClick={() => handleDuplicateMacro(macro)}
+                disabled={actionLoadingId === macro.id}
+                aria-label={`Duplicate macro ${macro.name}`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                LeadingIcon={LuPenLine}
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: adamshiervani
body: Add `text="Edit"` because when entering this view it's a bit hard to know where to go next
created_at: 2025-04-03T08:59:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,362 @@
+import { useState } from "react";
+
+import { LuPlus, LuInfo } from "react-icons/lu";
+
+import { KeySequence } from "@/hooks/stores";
+import { Button } from "@/components/Button";
+import { InputFieldWithLabel, FieldError } from "@/components/InputField";
+import Fieldset from "@/components/Fieldset";
+import { MacroStepCard } from "@/components/MacroStepCard";
+import Modal from "@/components/Modal";
+import { DEFAULT_DELAY, MAX_STEPS_PER_MACRO, MAX_KEYS_PER_STEP } from "@/constants/macros";
+
+interface ValidationErrors {
+  name?: string;
+  steps?: Record<number, {
+    keys?: string;
+    modifiers?: string;
+    delay?: string;
+  }>;
+}
+
+interface MacroFormProps {
+  initialData: Partial<KeySequence>;
+  onSubmit: (macro: Partial<KeySequence>) => Promise<void>;
+  onCancel: () => void;
+  isSubmitting?: boolean;
+  submitText?: string;
+  showCancelConfirm?: boolean;
+  onCancelConfirm?: () => void;
+  showDelete?: boolean;
+  onDelete?: () => void;
+  isDeleting?: boolean;
+}
+
+export function MacroForm({
+  initialData,
+  onSubmit,
+  onCancel,
+  isSubmitting = false,
+  submitText = "Save Macro",
+  showCancelConfirm = false,
+  onCancelConfirm,
+  showDelete = false,
+  onDelete,
+  isDeleting = false
+}: MacroFormProps) {
+  const [macro, setMacro] = useState<Partial<KeySequence>>(initialData);
+  const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
+  const [errors, setErrors] = useState<ValidationErrors>({});
+  const [errorMessage, setErrorMessage] = useState<string | null>(null);
+  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+
+  const showTemporaryError = (message: string) => {
+    setErrorMessage(message);
+    setTimeout(() => setErrorMessage(null), 3000);
+  };
+
+  const validateForm = (): boolean => {
+    const newErrors: ValidationErrors = {};
+
+    // Name validation
+    if (!macro.name?.trim()) {
+      newErrors.name = "Name is required";
+    } else if (macro.name.trim().length > 50) {
+      newErrors.name = "Name must be less than 50 characters";
+    }
+  
+    if (!macro.steps?.length) {
+      newErrors.steps = { 0: { keys: "At least one step is required" } };
+    } else {
+      const hasKeyOrModifier = macro.steps.some(step => 
+        (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0
+      );
+
+      if (!hasKeyOrModifier) {
+        newErrors.steps = { 0: { keys: "At least one step must have keys or modifiers" } };
+      }
+    }
+
+    setErrors(newErrors);
+    return Object.keys(newErrors).length === 0;
+  };
+
+  const handleSubmit = async () => {
+    if (!validateForm()) {
+      showTemporaryError("Please fix the validation errors");
+      return;
+    }
+
+    try {
+      await onSubmit(macro);
+    } catch (error) {
+      if (error instanceof Error) {
+        showTemporaryError(error.message);
+      } else {
+        showTemporaryError("An error occurred while saving");
+      }
+    }
+  };
+
+  const handleKeySelect = (stepIndex: number, option: { value: string | null; keys?: string[] }) => {
+    const newSteps = [...(macro.steps || [])];
+    if (!newSteps[stepIndex]) return;
+
+    if (option.keys) {
+      newSteps[stepIndex].keys = option.keys;
+    } else if (option.value) {
+      if (!newSteps[stepIndex].keys) {
+        newSteps[stepIndex].keys = [];
+      }
+      const keysArray = Array.isArray(newSteps[stepIndex].keys) ? newSteps[stepIndex].keys : [];
+      if (keysArray.length >= MAX_KEYS_PER_STEP) {
+        showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`);
+        return;
+      }
+      newSteps[stepIndex].keys = [...keysArray, option.value];
+    }
+    setMacro({ ...macro, steps: newSteps });
+    
+    if (errors.steps?.[stepIndex]?.keys) {
+      const newErrors = { ...errors };
+      delete newErrors.steps?.[stepIndex].keys;
+      if (Object.keys(newErrors.steps?.[stepIndex] || {}).length === 0) {
+        delete newErrors.steps?.[stepIndex];
+      }
+      if (Object.keys(newErrors.steps || {}).length === 0) {
+        delete newErrors.steps;
+      }
+      setErrors(newErrors);
+    }
+  };
+
+  const handleKeyQueryChange = (stepIndex: number, query: string) => {
+    setKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
+  };
+
+  const handleModifierChange = (stepIndex: number, modifiers: string[]) => {
+    const newSteps = [...(macro.steps || [])];
+    newSteps[stepIndex].modifiers = modifiers;
+    setMacro({ ...macro, steps: newSteps });
+    
+    // Clear step errors when modifiers are added
+    if (errors.steps?.[stepIndex]?.keys && modifiers.length > 0) {
+      const newErrors = { ...errors };
+      delete newErrors.steps?.[stepIndex].keys;
+      if (Object.keys(newErrors.steps?.[stepIndex] || {}).length === 0) {
+        delete newErrors.steps?.[stepIndex];
+      }
+      if (Object.keys(newErrors.steps || {}).length === 0) {
+        delete newErrors.steps;
+      }
+      setErrors(newErrors);
+    }
+  };
+
+  const handleDelayChange = (stepIndex: number, delay: number) => {
+    const newSteps = [...(macro.steps || [])];
+    newSteps[stepIndex].delay = delay;
+    setMacro({ ...macro, steps: newSteps });
+  };
+
+  const handleStepMove = (stepIndex: number, direction: 'up' | 'down') => {
+    const newSteps = [...(macro.steps || [])];
+    const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
+    [newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]];
+    setMacro({ ...macro, steps: newSteps });
+  };
+
+  const isMaxStepsReached = (macro.steps?.length || 0) >= MAX_STEPS_PER_MACRO;
+
+  return (
+    <>
+      <div className="space-y-4">
+        <Fieldset>
+          <InputFieldWithLabel
+            type="text"
+            label="Macro Name"
+            placeholder="Macro Name"
+            value={macro.name}
+            error={errors.name}
+            onChange={e => {
+              setMacro(prev => ({ ...prev, name: e.target.value }));
+              if (errors.name) {
+                const newErrors = { ...errors };
+                delete newErrors.name;
+                setErrors(newErrors);
+              }
+            }}
+          />
+        </Fieldset>
+
+        <div>
+          <div className="flex items-center justify-between text-sm">
+            <div className="flex items-center gap-1">
+              <label className="font-medium text-slate-700 dark:text-slate-200">
+                Steps
+              </label>
+              <div className="group relative cursor-pointer">
+                <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+                <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                  <p>Each step is a collection of keys and/or modifiers that will be executed in order. You can add up to a maximum of {MAX_STEPS_PER_MACRO} steps per macro.</p>
+                </div>
+              </div>
+            </div>
+            <span className="text-slate-500 dark:text-slate-400">
+              {macro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps
+            </span>
+          </div>
+          {errors.steps && errors.steps[0]?.keys && (
+            <div className="mt-2">
+              <FieldError error={errors.steps[0].keys} />
+            </div>
+          )}
+          <Fieldset>
+            <div className="mt-2 space-y-4">
+              {(macro.steps || []).map((step, stepIndex) => (
+                <MacroStepCard
+                  key={stepIndex}
+                  step={step}
+                  stepIndex={stepIndex}
+                  onDelete={macro.steps && macro.steps.length > 1 ? () => {
+                    const newSteps = [...(macro.steps || [])];
+                    newSteps.splice(stepIndex, 1);
+                    setMacro(prev => ({ ...prev, steps: newSteps }));
+                  } : undefined}
+                  onMoveUp={() => handleStepMove(stepIndex, 'up')}
+                  onMoveDown={() => handleStepMove(stepIndex, 'down')}
+                  onKeySelect={(option) => handleKeySelect(stepIndex, option)}
+                  onKeyQueryChange={(query) => handleKeyQueryChange(stepIndex, query)}
+                  keyQuery={keyQueries[stepIndex] || ''}
+                  onModifierChange={(modifiers) => handleModifierChange(stepIndex, modifiers)}
+                  onDelayChange={(delay) => handleDelayChange(stepIndex, delay)}
+                  isLastStep={stepIndex === (macro.steps?.length || 0) - 1}
+                />
+              ))}
+            </div>
+          </Fieldset>
+
+          <div className="mt-4">
+            <Button
+              size="MD"
+              theme="light"
+              fullWidth
+              LeadingIcon={LuPlus}
+              text={`Add Step ${isMaxStepsReached ? `(${MAX_STEPS_PER_MACRO} max)` : ''}`}
+              onClick={() => {
+                if (isMaxStepsReached) {
+                  showTemporaryError(`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`);
+                  return;
+                }
+                
+                setMacro(prev => ({
+                  ...prev,
+                  steps: [
+                    ...(prev.steps || []), 
+                    { keys: [], modifiers: [], delay: DEFAULT_DELAY }
+                  ],
+                }));
+                setErrors({});
+              }}
+              disabled={isMaxStepsReached}
+            />
+          </div>
+
+          {errorMessage && (
+            <div className="mt-4">
+              <FieldError error={errorMessage} />
+            </div>
+          )}
+
+          <div className="mt-6 flex items-center justify-between">
+            {showCancelConfirm ? (
+              <div className="flex items-center gap-2">
+                <span className="text-sm text-slate-600 dark:text-slate-400">
+                  Cancel changes?
+                </span>
+                <Button
+                  size="SM"
+                  theme="danger"
+                  text="Yes"
+                  onClick={onCancelConfirm}
+                />
+                <Button
+                  size="SM"
+                  theme="light"
+                  text="No"
+                  onClick={() => onCancel()}
+                />
+              </div>
+            ) : (
+              <>
+                <div className="flex gap-x-2">
+                  <Button
+                    size="SM"
+                    theme="primary"
+                    text={isSubmitting ? "Saving..." : submitText}
+                    onClick={handleSubmit}
+                    disabled={isSubmitting}
+                  />
+                  <Button
+                    size="SM"
+                    theme="light"
+                    text="Cancel"
+                    onClick={onCancel}
+                  />
+                </div>
+                {showDelete && (
+                  <Button
+                    size="SM"
+                    theme="danger"
+                    text={isDeleting ? "Deleting..." : "Delete Macro"}
+                    onClick={() => setShowDeleteConfirm(true)}
+                    disabled={isDeleting}
+                  />
+                )}
+              </>
+            )}
+          </div>
+        </div>
+      </div>
+
+      <Modal
+        open={showDeleteConfirm}
+        onClose={() => setShowDeleteConfirm(false)}
+      >
+        <div className="mx-auto max-w-xl px-4 transition-all duration-300 ease-in-out">
path: ui/src/components/MacroForm.tsx
user: login: adamshiervani
body: Extract into confirm dialog, this can be useful elsewhere. Inspo -> https://tailwindcss.com/plus/ui-blocks/application-ui/overlays/modal-dialogs
created_at: 2025-04-03T09:03:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,362 @@
+import { useState } from "react";
+
+import { LuPlus, LuInfo } from "react-icons/lu";
+
+import { KeySequence } from "@/hooks/stores";
+import { Button } from "@/components/Button";
+import { InputFieldWithLabel, FieldError } from "@/components/InputField";
+import Fieldset from "@/components/Fieldset";
+import { MacroStepCard } from "@/components/MacroStepCard";
+import Modal from "@/components/Modal";
+import { DEFAULT_DELAY, MAX_STEPS_PER_MACRO, MAX_KEYS_PER_STEP } from "@/constants/macros";
+
+interface ValidationErrors {
+  name?: string;
+  steps?: Record<number, {
+    keys?: string;
+    modifiers?: string;
+    delay?: string;
+  }>;
+}
+
+interface MacroFormProps {
+  initialData: Partial<KeySequence>;
+  onSubmit: (macro: Partial<KeySequence>) => Promise<void>;
+  onCancel: () => void;
+  isSubmitting?: boolean;
+  submitText?: string;
+  showCancelConfirm?: boolean;
+  onCancelConfirm?: () => void;
+  showDelete?: boolean;
+  onDelete?: () => void;
+  isDeleting?: boolean;
+}
+
+export function MacroForm({
+  initialData,
+  onSubmit,
+  onCancel,
+  isSubmitting = false,
+  submitText = "Save Macro",
+  showCancelConfirm = false,
+  onCancelConfirm,
+  showDelete = false,
+  onDelete,
+  isDeleting = false
+}: MacroFormProps) {
+  const [macro, setMacro] = useState<Partial<KeySequence>>(initialData);
+  const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
+  const [errors, setErrors] = useState<ValidationErrors>({});
+  const [errorMessage, setErrorMessage] = useState<string | null>(null);
+  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+
+  const showTemporaryError = (message: string) => {
+    setErrorMessage(message);
+    setTimeout(() => setErrorMessage(null), 3000);
+  };
+
+  const validateForm = (): boolean => {
+    const newErrors: ValidationErrors = {};
+
+    // Name validation
+    if (!macro.name?.trim()) {
+      newErrors.name = "Name is required";
+    } else if (macro.name.trim().length > 50) {
+      newErrors.name = "Name must be less than 50 characters";
+    }
+  
+    if (!macro.steps?.length) {
+      newErrors.steps = { 0: { keys: "At least one step is required" } };
+    } else {
+      const hasKeyOrModifier = macro.steps.some(step => 
+        (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0
+      );
+
+      if (!hasKeyOrModifier) {
+        newErrors.steps = { 0: { keys: "At least one step must have keys or modifiers" } };
+      }
+    }
+
+    setErrors(newErrors);
+    return Object.keys(newErrors).length === 0;
+  };
+
+  const handleSubmit = async () => {
+    if (!validateForm()) {
+      showTemporaryError("Please fix the validation errors");
+      return;
+    }
+
+    try {
+      await onSubmit(macro);
+    } catch (error) {
+      if (error instanceof Error) {
+        showTemporaryError(error.message);
+      } else {
+        showTemporaryError("An error occurred while saving");
+      }
+    }
+  };
+
+  const handleKeySelect = (stepIndex: number, option: { value: string | null; keys?: string[] }) => {
+    const newSteps = [...(macro.steps || [])];
+    if (!newSteps[stepIndex]) return;
+
+    if (option.keys) {
+      newSteps[stepIndex].keys = option.keys;
+    } else if (option.value) {
+      if (!newSteps[stepIndex].keys) {
+        newSteps[stepIndex].keys = [];
+      }
+      const keysArray = Array.isArray(newSteps[stepIndex].keys) ? newSteps[stepIndex].keys : [];
+      if (keysArray.length >= MAX_KEYS_PER_STEP) {
+        showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`);
+        return;
+      }
+      newSteps[stepIndex].keys = [...keysArray, option.value];
+    }
+    setMacro({ ...macro, steps: newSteps });
+    
+    if (errors.steps?.[stepIndex]?.keys) {
+      const newErrors = { ...errors };
+      delete newErrors.steps?.[stepIndex].keys;
+      if (Object.keys(newErrors.steps?.[stepIndex] || {}).length === 0) {
+        delete newErrors.steps?.[stepIndex];
+      }
+      if (Object.keys(newErrors.steps || {}).length === 0) {
+        delete newErrors.steps;
+      }
+      setErrors(newErrors);
+    }
+  };
+
+  const handleKeyQueryChange = (stepIndex: number, query: string) => {
+    setKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
+  };
+
+  const handleModifierChange = (stepIndex: number, modifiers: string[]) => {
+    const newSteps = [...(macro.steps || [])];
+    newSteps[stepIndex].modifiers = modifiers;
+    setMacro({ ...macro, steps: newSteps });
+    
+    // Clear step errors when modifiers are added
+    if (errors.steps?.[stepIndex]?.keys && modifiers.length > 0) {
+      const newErrors = { ...errors };
+      delete newErrors.steps?.[stepIndex].keys;
+      if (Object.keys(newErrors.steps?.[stepIndex] || {}).length === 0) {
+        delete newErrors.steps?.[stepIndex];
+      }
+      if (Object.keys(newErrors.steps || {}).length === 0) {
+        delete newErrors.steps;
+      }
+      setErrors(newErrors);
+    }
+  };
+
+  const handleDelayChange = (stepIndex: number, delay: number) => {
+    const newSteps = [...(macro.steps || [])];
+    newSteps[stepIndex].delay = delay;
+    setMacro({ ...macro, steps: newSteps });
+  };
+
+  const handleStepMove = (stepIndex: number, direction: 'up' | 'down') => {
+    const newSteps = [...(macro.steps || [])];
+    const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
+    [newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]];
+    setMacro({ ...macro, steps: newSteps });
+  };
+
+  const isMaxStepsReached = (macro.steps?.length || 0) >= MAX_STEPS_PER_MACRO;
+
+  return (
+    <>
+      <div className="space-y-4">
+        <Fieldset>
+          <InputFieldWithLabel
+            type="text"
+            label="Macro Name"
+            placeholder="Macro Name"
+            value={macro.name}
+            error={errors.name}
+            onChange={e => {
+              setMacro(prev => ({ ...prev, name: e.target.value }));
+              if (errors.name) {
+                const newErrors = { ...errors };
+                delete newErrors.name;
+                setErrors(newErrors);
+              }
+            }}
+          />
+        </Fieldset>
+
+        <div>
+          <div className="flex items-center justify-between text-sm">
+            <div className="flex items-center gap-1">
+              <label className="font-medium text-slate-700 dark:text-slate-200">
+                Steps
+              </label>
+              <div className="group relative cursor-pointer">
+                <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+                <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                  <p>Each step is a collection of keys and/or modifiers that will be executed in order. You can add up to a maximum of {MAX_STEPS_PER_MACRO} steps per macro.</p>
+                </div>
+              </div>
+            </div>
+            <span className="text-slate-500 dark:text-slate-400">
+              {macro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps
+            </span>
+          </div>
+          {errors.steps && errors.steps[0]?.keys && (
+            <div className="mt-2">
+              <FieldError error={errors.steps[0].keys} />
+            </div>
+          )}
+          <Fieldset>
+            <div className="mt-2 space-y-4">
+              {(macro.steps || []).map((step, stepIndex) => (
+                <MacroStepCard
+                  key={stepIndex}
+                  step={step}
+                  stepIndex={stepIndex}
+                  onDelete={macro.steps && macro.steps.length > 1 ? () => {
+                    const newSteps = [...(macro.steps || [])];
+                    newSteps.splice(stepIndex, 1);
+                    setMacro(prev => ({ ...prev, steps: newSteps }));
+                  } : undefined}
+                  onMoveUp={() => handleStepMove(stepIndex, 'up')}
+                  onMoveDown={() => handleStepMove(stepIndex, 'down')}
+                  onKeySelect={(option) => handleKeySelect(stepIndex, option)}
+                  onKeyQueryChange={(query) => handleKeyQueryChange(stepIndex, query)}
+                  keyQuery={keyQueries[stepIndex] || ''}
+                  onModifierChange={(modifiers) => handleModifierChange(stepIndex, modifiers)}
+                  onDelayChange={(delay) => handleDelayChange(stepIndex, delay)}
+                  isLastStep={stepIndex === (macro.steps?.length || 0) - 1}
+                />
+              ))}
+            </div>
+          </Fieldset>
+
+          <div className="mt-4">
+            <Button
+              size="MD"
+              theme="light"
+              fullWidth
+              LeadingIcon={LuPlus}
+              text={`Add Step ${isMaxStepsReached ? `(${MAX_STEPS_PER_MACRO} max)` : ''}`}
+              onClick={() => {
+                if (isMaxStepsReached) {
+                  showTemporaryError(`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`);
+                  return;
+                }
+                
+                setMacro(prev => ({
+                  ...prev,
+                  steps: [
+                    ...(prev.steps || []), 
+                    { keys: [], modifiers: [], delay: DEFAULT_DELAY }
+                  ],
+                }));
+                setErrors({});
+              }}
+              disabled={isMaxStepsReached}
+            />
+          </div>
+
+          {errorMessage && (
+            <div className="mt-4">
+              <FieldError error={errorMessage} />
+            </div>
+          )}
+
+          <div className="mt-6 flex items-center justify-between">
+            {showCancelConfirm ? (
path: ui/src/components/MacroForm.tsx
user: login: adamshiervani
body: I can't find any consumer using this. Legacy, or think we need it?
created_at: 2025-04-03T09:04:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,246 @@
+import { LuArrowUp, LuArrowDown, LuX, LuInfo } from "react-icons/lu";
+
+import { Button } from "@/components/Button";
+import { Combobox } from "@/components/Combobox";
+import { SelectMenuBasic } from "@/components/SelectMenuBasic";
+import Card from "@/components/Card";
+import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
+import { MAX_KEYS_PER_STEP } from "@/constants/macros";
+
+// Filter out modifier keys since they're handled in the modifiers section
+const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta'];
+
+const keyOptions = Object.keys(keys)
+  .filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
+  .map(key => ({
+    value: key,
+    label: keyDisplayMap[key] || key,
+  }));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers: Record<string, typeof modifierOptions> = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+const PRESET_DELAYS = [
+  { value: "50", label: "50ms" },
+  { value: "100", label: "100ms" },
+  { value: "200", label: "200ms" },
+  { value: "300", label: "300ms" },
+  { value: "500", label: "500ms" },
+  { value: "750", label: "750ms" },
+  { value: "1000", label: "1000ms" },
+  { value: "1500", label: "1500ms" },
+  { value: "2000", label: "2000ms" },
+];
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  onKeySelect: (option: { value: string | null; keys?: string[] }) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+export function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  const getFilteredKeys = () => {
+    const selectedKeys = ensureArray(step.keys);
+    const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value));
+    
+    if (keyQuery === '') {
+      return availableKeys;
+    } else {
+      return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase()));
+    }
+  };
+
+  return (
+    <Card className="p-4">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+              LeadingIcon={LuArrowUp}
+            />
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+              LeadingIcon={LuArrowDown}
+            />
+          </div>
+          <span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+        
+        <div className="flex items-center space-x-2">
+          {onDelete && (
+            <Button
+              size="XS"
+              theme="danger"
+              text="Delete"
+              onClick={onDelete}
+            />
+          )}
+        </div>
+      </div>
+      
+      <div className="space-y-4 mt-2">
+        <div className="w-full flex flex-col gap-2">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Modifiers
+          </label>
path: ui/src/components/MacroStepCard.tsx
user: login: adamshiervani
body: Replace with `FieldLabel`
created_at: 2025-04-03T09:10:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,246 @@
+import { LuArrowUp, LuArrowDown, LuX, LuInfo } from "react-icons/lu";
+
+import { Button } from "@/components/Button";
+import { Combobox } from "@/components/Combobox";
+import { SelectMenuBasic } from "@/components/SelectMenuBasic";
+import Card from "@/components/Card";
+import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
+import { MAX_KEYS_PER_STEP } from "@/constants/macros";
+
+// Filter out modifier keys since they're handled in the modifiers section
+const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta'];
+
+const keyOptions = Object.keys(keys)
+  .filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
+  .map(key => ({
+    value: key,
+    label: keyDisplayMap[key] || key,
+  }));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers: Record<string, typeof modifierOptions> = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+const PRESET_DELAYS = [
+  { value: "50", label: "50ms" },
+  { value: "100", label: "100ms" },
+  { value: "200", label: "200ms" },
+  { value: "300", label: "300ms" },
+  { value: "500", label: "500ms" },
+  { value: "750", label: "750ms" },
+  { value: "1000", label: "1000ms" },
+  { value: "1500", label: "1500ms" },
+  { value: "2000", label: "2000ms" },
+];
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  onKeySelect: (option: { value: string | null; keys?: string[] }) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+export function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  const getFilteredKeys = () => {
+    const selectedKeys = ensureArray(step.keys);
+    const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value));
+    
+    if (keyQuery === '') {
+      return availableKeys;
+    } else {
+      return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase()));
+    }
+  };
+
+  return (
+    <Card className="p-4">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+              LeadingIcon={LuArrowUp}
+            />
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+              LeadingIcon={LuArrowDown}
+            />
+          </div>
+          <span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+        
+        <div className="flex items-center space-x-2">
+          {onDelete && (
+            <Button
+              size="XS"
+              theme="danger"
+              text="Delete"
+              onClick={onDelete}
+            />
+          )}
+        </div>
+      </div>
+      
+      <div className="space-y-4 mt-2">
+        <div className="w-full flex flex-col gap-2">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Modifiers
+          </label>
+          <div className="inline-flex flex-wrap gap-3">
+            {Object.entries(groupedModifiers).map(([group, mods]) => (
+              <div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
+                <span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
+                  {group}
+                </span>
+                <div className="flex flex-wrap gap-1">
+                  {mods.map(option => (
+                    <label 
+                      key={option.value} 
+                      className={`flex items-center px-2 py-1 rounded border cursor-pointer text-xs font-medium transition-colors ${
+                        ensureArray(step.modifiers).includes(option.value) 
+                          ? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-200' 
+                          : 'bg-slate-100 border-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700'
+                      }`}
+                    >
+                      <input
+                        type="checkbox"
+                        className="sr-only"
+                        checked={ensureArray(step.modifiers).includes(option.value)}
+                        onChange={e => {
+                          const modifiersArray = ensureArray(step.modifiers);
+                          const newModifiers = e.target.checked
+                            ? [...modifiersArray, option.value]
+                            : modifiersArray.filter(m => m !== option.value);
+                          onModifierChange(newModifiers);
+                        }}
+                      />
+                      {option.label.split(' ')[1] || option.label}
+                    </label>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+              Keys
+            </label>
+            <div className="group relative cursor-pointer">
+              <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+              <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                <p>You can add up to a maximum of {MAX_KEYS_PER_STEP} keys to press per step.</p>
+              </div>
+            </div>
+          </div>
path: ui/src/components/MacroStepCard.tsx
user: login: adamshiervani
body: Replace with `FieldLabel`, it has a label and description. Now that it's visible, we need to shorten the copy. Otherwise, it will be too overwhelming.

Suggestion: `Maximum ${MAX_KEYS_PER_STEP} keys per step.`
created_at: 2025-04-03T09:12:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,246 @@
+import { LuArrowUp, LuArrowDown, LuX, LuInfo } from "react-icons/lu";
+
+import { Button } from "@/components/Button";
+import { Combobox } from "@/components/Combobox";
+import { SelectMenuBasic } from "@/components/SelectMenuBasic";
+import Card from "@/components/Card";
+import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
+import { MAX_KEYS_PER_STEP } from "@/constants/macros";
+
+// Filter out modifier keys since they're handled in the modifiers section
+const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta'];
+
+const keyOptions = Object.keys(keys)
+  .filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
+  .map(key => ({
+    value: key,
+    label: keyDisplayMap[key] || key,
+  }));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers: Record<string, typeof modifierOptions> = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+const PRESET_DELAYS = [
+  { value: "50", label: "50ms" },
+  { value: "100", label: "100ms" },
+  { value: "200", label: "200ms" },
+  { value: "300", label: "300ms" },
+  { value: "500", label: "500ms" },
+  { value: "750", label: "750ms" },
+  { value: "1000", label: "1000ms" },
+  { value: "1500", label: "1500ms" },
+  { value: "2000", label: "2000ms" },
+];
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  onKeySelect: (option: { value: string | null; keys?: string[] }) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+export function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  const getFilteredKeys = () => {
+    const selectedKeys = ensureArray(step.keys);
+    const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value));
+    
+    if (keyQuery === '') {
+      return availableKeys;
+    } else {
+      return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase()));
+    }
+  };
+
+  return (
+    <Card className="p-4">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+              LeadingIcon={LuArrowUp}
+            />
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+              LeadingIcon={LuArrowDown}
+            />
+          </div>
+          <span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+        
+        <div className="flex items-center space-x-2">
+          {onDelete && (
+            <Button
+              size="XS"
+              theme="danger"
+              text="Delete"
+              onClick={onDelete}
+            />
+          )}
+        </div>
+      </div>
+      
+      <div className="space-y-4 mt-2">
+        <div className="w-full flex flex-col gap-2">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Modifiers
+          </label>
+          <div className="inline-flex flex-wrap gap-3">
+            {Object.entries(groupedModifiers).map(([group, mods]) => (
+              <div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
+                <span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
+                  {group}
+                </span>
+                <div className="flex flex-wrap gap-1">
+                  {mods.map(option => (
+                    <label 
+                      key={option.value} 
+                      className={`flex items-center px-2 py-1 rounded border cursor-pointer text-xs font-medium transition-colors ${
+                        ensureArray(step.modifiers).includes(option.value) 
+                          ? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-200' 
+                          : 'bg-slate-100 border-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700'
+                      }`}
+                    >
+                      <input
+                        type="checkbox"
+                        className="sr-only"
+                        checked={ensureArray(step.modifiers).includes(option.value)}
+                        onChange={e => {
+                          const modifiersArray = ensureArray(step.modifiers);
+                          const newModifiers = e.target.checked
+                            ? [...modifiersArray, option.value]
+                            : modifiersArray.filter(m => m !== option.value);
+                          onModifierChange(newModifiers);
+                        }}
+                      />
+                      {option.label.split(' ')[1] || option.label}
+                    </label>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+              Keys
+            </label>
+            <div className="group relative cursor-pointer">
+              <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+              <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                <p>You can add up to a maximum of {MAX_KEYS_PER_STEP} keys to press per step.</p>
+              </div>
+            </div>
+          </div>
+          <div className="flex flex-wrap gap-1 pb-2">
+            {ensureArray(step.keys).map((key, keyIndex) => (
+              <span
+                key={keyIndex}
+                className="inline-flex items-center rounded-md bg-blue-100 px-1 text-xs font-medium text-blue-800 dark:bg-blue-900/40 dark:text-blue-200"
path: ui/src/components/MacroStepCard.tsx
user: login: adamshiervani
body: Extreme nit: `py-0.5` 🙈 
created_at: 2025-04-03T09:13:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,246 @@
+import { LuArrowUp, LuArrowDown, LuX, LuInfo } from "react-icons/lu";
+
+import { Button } from "@/components/Button";
+import { Combobox } from "@/components/Combobox";
+import { SelectMenuBasic } from "@/components/SelectMenuBasic";
+import Card from "@/components/Card";
+import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
+import { MAX_KEYS_PER_STEP } from "@/constants/macros";
+
+// Filter out modifier keys since they're handled in the modifiers section
+const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta'];
+
+const keyOptions = Object.keys(keys)
+  .filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
+  .map(key => ({
+    value: key,
+    label: keyDisplayMap[key] || key,
+  }));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers: Record<string, typeof modifierOptions> = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+const PRESET_DELAYS = [
+  { value: "50", label: "50ms" },
+  { value: "100", label: "100ms" },
+  { value: "200", label: "200ms" },
+  { value: "300", label: "300ms" },
+  { value: "500", label: "500ms" },
+  { value: "750", label: "750ms" },
+  { value: "1000", label: "1000ms" },
+  { value: "1500", label: "1500ms" },
+  { value: "2000", label: "2000ms" },
+];
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  onKeySelect: (option: { value: string | null; keys?: string[] }) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+export function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  const getFilteredKeys = () => {
+    const selectedKeys = ensureArray(step.keys);
+    const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value));
+    
+    if (keyQuery === '') {
+      return availableKeys;
+    } else {
+      return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase()));
+    }
+  };
+
+  return (
+    <Card className="p-4">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+              LeadingIcon={LuArrowUp}
+            />
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+              LeadingIcon={LuArrowDown}
+            />
+          </div>
+          <span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+        
+        <div className="flex items-center space-x-2">
+          {onDelete && (
+            <Button
+              size="XS"
+              theme="danger"
+              text="Delete"
+              onClick={onDelete}
+            />
+          )}
+        </div>
+      </div>
+      
+      <div className="space-y-4 mt-2">
+        <div className="w-full flex flex-col gap-2">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Modifiers
+          </label>
+          <div className="inline-flex flex-wrap gap-3">
+            {Object.entries(groupedModifiers).map(([group, mods]) => (
+              <div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
+                <span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
+                  {group}
+                </span>
+                <div className="flex flex-wrap gap-1">
+                  {mods.map(option => (
+                    <label 
+                      key={option.value} 
+                      className={`flex items-center px-2 py-1 rounded border cursor-pointer text-xs font-medium transition-colors ${
+                        ensureArray(step.modifiers).includes(option.value) 
+                          ? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-200' 
+                          : 'bg-slate-100 border-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700'
+                      }`}
+                    >
+                      <input
+                        type="checkbox"
+                        className="sr-only"
+                        checked={ensureArray(step.modifiers).includes(option.value)}
+                        onChange={e => {
+                          const modifiersArray = ensureArray(step.modifiers);
+                          const newModifiers = e.target.checked
+                            ? [...modifiersArray, option.value]
+                            : modifiersArray.filter(m => m !== option.value);
+                          onModifierChange(newModifiers);
+                        }}
+                      />
+                      {option.label.split(' ')[1] || option.label}
+                    </label>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+              Keys
+            </label>
+            <div className="group relative cursor-pointer">
+              <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+              <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                <p>You can add up to a maximum of {MAX_KEYS_PER_STEP} keys to press per step.</p>
+              </div>
+            </div>
+          </div>
+          <div className="flex flex-wrap gap-1 pb-2">
+            {ensureArray(step.keys).map((key, keyIndex) => (
+              <span
+                key={keyIndex}
+                className="inline-flex items-center rounded-md bg-blue-100 px-1 text-xs font-medium text-blue-800 dark:bg-blue-900/40 dark:text-blue-200"
+              >
+                <span className="px-1">
+                  {keyDisplayMap[key] || key}
+                </span>
+                <Button
+                  size="XS"
+                  className=""
+                  theme="blank"
+                  onClick={() => {
+                    const newKeys = ensureArray(step.keys).filter((_, i) => i !== keyIndex);
+                    onKeySelect({ value: null, keys: newKeys });
+                  }}
+                  LeadingIcon={LuX}
+                />
+              </span>
+            ))}
+          </div>
+          <div className="relative w-full">
+            <Combobox
+              onChange={(value: { value: string; label: string }) => onKeySelect(value)}
+              displayValue={() => keyQuery}
+              onInputChange={onKeyQueryChange}
+              options={getFilteredKeys}
+              disabledMessage="Max keys reached"
+              size="SM"
+              immediate
+              disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP}
+              placeholder={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP ? "Max keys reached" : "Search for key..."}
+              emptyMessage="No matching keys found"
+            />
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+              Step Duration
+            </label>
+            <div className="group relative cursor-pointer">
+              <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+              <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                <p>The time to wait after pressing the keys in this step before moving to the next step. This helps ensure reliable key presses when automating keyboard input.</p>
+              </div>
+            </div>
+          </div>
path: ui/src/components/MacroStepCard.tsx
user: login: adamshiervani
body: Use `FieldLabel` instead

Suggestion: `Delay After Step` and `Time to wait before executing the next step.`
created_at: 2025-04-03T09:21:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,126 @@
+import { useRef } from "react";
+import clsx from "clsx";
+import { Combobox as HeadlessCombobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
+import { cva } from "@/cva.config";
+import Card from "./Card";
+
+export interface ComboboxOption {
+  value: string;
+  label: string;
+}
+
+const sizes = {
+  XS: "h-[24.5px] pl-3 pr-8 text-xs",
+  SM: "h-[32px] pl-3 pr-8 text-[13px]",
+  MD: "h-[40px] pl-4 pr-10 text-sm",
+  LG: "h-[48px] pl-4 pr-10 px-5 text-base",
+} as const;
+
+const comboboxVariants = cva({
+  variants: { size: sizes },
+});
+
+type BaseProps = React.ComponentProps<typeof HeadlessCombobox>;
+
+interface ComboboxProps extends Omit<BaseProps, 'displayValue'> {
+  displayValue: (option: ComboboxOption) => string;
+  onInputChange: (option: string) => void;
+  options: () => ComboboxOption[];
+  placeholder?: string;
+  emptyMessage?: string;
+  size?: keyof typeof sizes;
+  disabledMessage?: string;
+}
+
+export function Combobox({
+  onInputChange,
+  displayValue,
+  options,
+  disabled = false,
+  placeholder = "Search...",
+  emptyMessage = "No results found",
+  size = "MD",
+  onChange,
+  disabledMessage = "Input disabled",
+  ...otherProps
+}: ComboboxProps) {
+  const inputRef = useRef<HTMLInputElement>(null);
+  const classes = comboboxVariants({ size });
+
+  const handleChange = (value: unknown) => {
+    if (onChange) {
+      onChange(value);
+      inputRef.current?.blur();
+    }
+  };
+
+  return (
+    <HeadlessCombobox 
+      onChange={handleChange}
+      {...otherProps}
+    >
+      {() => (
+        <>
+          <Card className="w-auto !border border-solid !border-slate-800/30 shadow outline-0 dark:!border-slate-300/30">
+            <ComboboxInput
+            ref={inputRef}
+            className={clsx(
+              classes,
+              
+              // General styling
+              "block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0 transition duration-300",
+              
+              // Hover
+              "hover:bg-blue-50/80 active:bg-blue-100/60",
+              
+              // Dark mode
+              "dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700 dark:active:bg-slate-800/60",
+              
+              // Focus
+              "focus:outline-blue-600 focus:ring-2 focus:ring-blue-700 focus:ring-offset-2 dark:focus:outline-blue-500 dark:focus:ring-blue-500",
+              
+              // Disabled
+              disabled && "pointer-events-none select-none bg-slate-50 text-slate-500/80 dark:bg-slate-800 dark:text-slate-400/80 disabled:hover:bg-white dark:disabled:hover:bg-slate-800"
+            )}
+            placeholder={disabled ? disabledMessage : placeholder}
+            displayValue={displayValue}
+            onChange={(event) => onInputChange(event.target.value)}
+            disabled={disabled}
+            />
+          </Card>
+          
+          {options().length > 0 && (
+            <ComboboxOptions className="absolute left-0 z-[100] mt-1 w-full max-h-60 overflow-auto rounded-md bg-white py-1 text-sm shadow-lg ring-1 ring-black/5 dark:bg-slate-800 dark:ring-slate-700 hide-scrollbar">
path: ui/src/components/Combobox.tsx
user: login: adamshiervani
body: The only way to get rid of it, is if you remove the search term. Any idea why it doesn't collapse after selection?
created_at: 2025-04-03T09:24:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,246 @@
+import { LuArrowUp, LuArrowDown, LuX, LuInfo } from "react-icons/lu";
+
+import { Button } from "@/components/Button";
+import { Combobox } from "@/components/Combobox";
+import { SelectMenuBasic } from "@/components/SelectMenuBasic";
+import Card from "@/components/Card";
+import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
+import { MAX_KEYS_PER_STEP } from "@/constants/macros";
+
+// Filter out modifier keys since they're handled in the modifiers section
+const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta'];
+
+const keyOptions = Object.keys(keys)
+  .filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
+  .map(key => ({
+    value: key,
+    label: keyDisplayMap[key] || key,
+  }));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers: Record<string, typeof modifierOptions> = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+const PRESET_DELAYS = [
+  { value: "50", label: "50ms" },
+  { value: "100", label: "100ms" },
+  { value: "200", label: "200ms" },
+  { value: "300", label: "300ms" },
+  { value: "500", label: "500ms" },
+  { value: "750", label: "750ms" },
+  { value: "1000", label: "1000ms" },
+  { value: "1500", label: "1500ms" },
+  { value: "2000", label: "2000ms" },
+];
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  onKeySelect: (option: { value: string | null; keys?: string[] }) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+export function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  const getFilteredKeys = () => {
+    const selectedKeys = ensureArray(step.keys);
+    const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value));
+    
+    if (keyQuery === '') {
+      return availableKeys;
+    } else {
+      return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase()));
+    }
+  };
+
+  return (
+    <Card className="p-4">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+              LeadingIcon={LuArrowUp}
+            />
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+              LeadingIcon={LuArrowDown}
+            />
+          </div>
+          <span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+        
+        <div className="flex items-center space-x-2">
+          {onDelete && (
+            <Button
+              size="XS"
+              theme="danger"
path: ui/src/components/MacroStepCard.tsx
user: login: adamshiervani
body: Too much attention. I would suggest 
```
<Button
    size="XS"
    theme="light"
    className="text-red-500"
    text="Delete"
    LeadingIcon={LuTrash2}
    onClick={onDelete}
/>
```
created_at: 2025-04-03T09:28:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,246 @@
+import { LuArrowUp, LuArrowDown, LuX, LuInfo } from "react-icons/lu";
+
+import { Button } from "@/components/Button";
+import { Combobox } from "@/components/Combobox";
+import { SelectMenuBasic } from "@/components/SelectMenuBasic";
+import Card from "@/components/Card";
+import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
+import { MAX_KEYS_PER_STEP } from "@/constants/macros";
+
+// Filter out modifier keys since they're handled in the modifiers section
+const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta'];
+
+const keyOptions = Object.keys(keys)
+  .filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
+  .map(key => ({
+    value: key,
+    label: keyDisplayMap[key] || key,
+  }));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers: Record<string, typeof modifierOptions> = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+const PRESET_DELAYS = [
+  { value: "50", label: "50ms" },
+  { value: "100", label: "100ms" },
+  { value: "200", label: "200ms" },
+  { value: "300", label: "300ms" },
+  { value: "500", label: "500ms" },
+  { value: "750", label: "750ms" },
+  { value: "1000", label: "1000ms" },
+  { value: "1500", label: "1500ms" },
+  { value: "2000", label: "2000ms" },
+];
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  onKeySelect: (option: { value: string | null; keys?: string[] }) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+export function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  const getFilteredKeys = () => {
+    const selectedKeys = ensureArray(step.keys);
+    const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value));
+    
+    if (keyQuery === '') {
+      return availableKeys;
+    } else {
+      return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase()));
+    }
+  };
+
+  return (
+    <Card className="p-4">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+              LeadingIcon={LuArrowUp}
+            />
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+              LeadingIcon={LuArrowDown}
+            />
+          </div>
+          <span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
path: ui/src/components/MacroStepCard.tsx
user: login: adamshiervani
body: Move the arrows to the other side of, and the left of, the Delete button. Increase the size of the step to h-6, maybe?
created_at: 2025-04-03T09:30:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,246 @@
+import { LuArrowUp, LuArrowDown, LuX, LuInfo } from "react-icons/lu";
+
+import { Button } from "@/components/Button";
+import { Combobox } from "@/components/Combobox";
+import { SelectMenuBasic } from "@/components/SelectMenuBasic";
+import Card from "@/components/Card";
+import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
+import { MAX_KEYS_PER_STEP } from "@/constants/macros";
+
+// Filter out modifier keys since they're handled in the modifiers section
+const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta'];
+
+const keyOptions = Object.keys(keys)
+  .filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
+  .map(key => ({
+    value: key,
+    label: keyDisplayMap[key] || key,
+  }));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers: Record<string, typeof modifierOptions> = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+const PRESET_DELAYS = [
+  { value: "50", label: "50ms" },
+  { value: "100", label: "100ms" },
+  { value: "200", label: "200ms" },
+  { value: "300", label: "300ms" },
+  { value: "500", label: "500ms" },
+  { value: "750", label: "750ms" },
+  { value: "1000", label: "1000ms" },
+  { value: "1500", label: "1500ms" },
+  { value: "2000", label: "2000ms" },
+];
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  onKeySelect: (option: { value: string | null; keys?: string[] }) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+export function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  const getFilteredKeys = () => {
+    const selectedKeys = ensureArray(step.keys);
+    const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value));
+    
+    if (keyQuery === '') {
+      return availableKeys;
+    } else {
+      return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase()));
+    }
+  };
+
+  return (
+    <Card className="p-4">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+              LeadingIcon={LuArrowUp}
+            />
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+              LeadingIcon={LuArrowDown}
+            />
+          </div>
+          <span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+        
+        <div className="flex items-center space-x-2">
+          {onDelete && (
+            <Button
+              size="XS"
+              theme="danger"
+              text="Delete"
+              onClick={onDelete}
+            />
+          )}
+        </div>
+      </div>
+      
+      <div className="space-y-4 mt-2">
+        <div className="w-full flex flex-col gap-2">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Modifiers
+          </label>
+          <div className="inline-flex flex-wrap gap-3">
+            {Object.entries(groupedModifiers).map(([group, mods]) => (
+              <div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
+                <span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
+                  {group}
+                </span>
+                <div className="flex flex-wrap gap-1">
+                  {mods.map(option => (
+                    <label 
+                      key={option.value} 
+                      className={`flex items-center px-2 py-1 rounded border cursor-pointer text-xs font-medium transition-colors ${
+                        ensureArray(step.modifiers).includes(option.value) 
+                          ? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-200' 
+                          : 'bg-slate-100 border-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700'
+                      }`}
+                    >
+                      <input
+                        type="checkbox"
+                        className="sr-only"
+                        checked={ensureArray(step.modifiers).includes(option.value)}
+                        onChange={e => {
+                          const modifiersArray = ensureArray(step.modifiers);
+                          const newModifiers = e.target.checked
+                            ? [...modifiersArray, option.value]
+                            : modifiersArray.filter(m => m !== option.value);
+                          onModifierChange(newModifiers);
+                        }}
+                      />
+                      {option.label.split(' ')[1] || option.label}
+                    </label>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+              Keys
+            </label>
+            <div className="group relative cursor-pointer">
+              <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+              <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                <p>You can add up to a maximum of {MAX_KEYS_PER_STEP} keys to press per step.</p>
+              </div>
+            </div>
+          </div>
+          <div className="flex flex-wrap gap-1 pb-2">
path: ui/src/components/MacroStepCard.tsx
user: login: adamshiervani
body: Wrap this in a condition; otherwise its padding will take space when nothing is in it.
created_at: 2025-04-03T09:33:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,246 @@
+import { LuArrowUp, LuArrowDown, LuX, LuInfo } from "react-icons/lu";
+
+import { Button } from "@/components/Button";
+import { Combobox } from "@/components/Combobox";
+import { SelectMenuBasic } from "@/components/SelectMenuBasic";
+import Card from "@/components/Card";
+import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
+import { MAX_KEYS_PER_STEP } from "@/constants/macros";
+
+// Filter out modifier keys since they're handled in the modifiers section
+const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta'];
+
+const keyOptions = Object.keys(keys)
+  .filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
+  .map(key => ({
+    value: key,
+    label: keyDisplayMap[key] || key,
+  }));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers: Record<string, typeof modifierOptions> = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+const PRESET_DELAYS = [
+  { value: "50", label: "50ms" },
+  { value: "100", label: "100ms" },
+  { value: "200", label: "200ms" },
+  { value: "300", label: "300ms" },
+  { value: "500", label: "500ms" },
+  { value: "750", label: "750ms" },
+  { value: "1000", label: "1000ms" },
+  { value: "1500", label: "1500ms" },
+  { value: "2000", label: "2000ms" },
+];
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  onKeySelect: (option: { value: string | null; keys?: string[] }) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+export function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  const getFilteredKeys = () => {
+    const selectedKeys = ensureArray(step.keys);
+    const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value));
+    
+    if (keyQuery === '') {
+      return availableKeys;
+    } else {
+      return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase()));
+    }
+  };
+
+  return (
+    <Card className="p-4">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+              LeadingIcon={LuArrowUp}
+            />
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+              LeadingIcon={LuArrowDown}
+            />
+          </div>
+          <span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+        
+        <div className="flex items-center space-x-2">
+          {onDelete && (
+            <Button
+              size="XS"
+              theme="danger"
+              text="Delete"
+              onClick={onDelete}
+            />
+          )}
+        </div>
+      </div>
+      
+      <div className="space-y-4 mt-2">
+        <div className="w-full flex flex-col gap-2">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Modifiers
+          </label>
+          <div className="inline-flex flex-wrap gap-3">
+            {Object.entries(groupedModifiers).map(([group, mods]) => (
+              <div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
+                <span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
+                  {group}
+                </span>
+                <div className="flex flex-wrap gap-1">
+                  {mods.map(option => (
+                    <label 
+                      key={option.value} 
+                      className={`flex items-center px-2 py-1 rounded border cursor-pointer text-xs font-medium transition-colors ${
+                        ensureArray(step.modifiers).includes(option.value) 
+                          ? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-200' 
+                          : 'bg-slate-100 border-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700'
+                      }`}
+                    >
+                      <input
+                        type="checkbox"
+                        className="sr-only"
+                        checked={ensureArray(step.modifiers).includes(option.value)}
+                        onChange={e => {
+                          const modifiersArray = ensureArray(step.modifiers);
+                          const newModifiers = e.target.checked
+                            ? [...modifiersArray, option.value]
+                            : modifiersArray.filter(m => m !== option.value);
+                          onModifierChange(newModifiers);
+                        }}
+                      />
+                      {option.label.split(' ')[1] || option.label}
+                    </label>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+              Keys
+            </label>
+            <div className="group relative cursor-pointer">
+              <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+              <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                <p>You can add up to a maximum of {MAX_KEYS_PER_STEP} keys to press per step.</p>
+              </div>
+            </div>
+          </div>
+          <div className="flex flex-wrap gap-1 pb-2">
+            {ensureArray(step.keys).map((key, keyIndex) => (
+              <span
+                key={keyIndex}
+                className="inline-flex items-center rounded-md bg-blue-100 px-1 text-xs font-medium text-blue-800 dark:bg-blue-900/40 dark:text-blue-200"
+              >
+                <span className="px-1">
+                  {keyDisplayMap[key] || key}
+                </span>
+                <Button
+                  size="XS"
+                  className=""
+                  theme="blank"
+                  onClick={() => {
+                    const newKeys = ensureArray(step.keys).filter((_, i) => i !== keyIndex);
+                    onKeySelect({ value: null, keys: newKeys });
+                  }}
+                  LeadingIcon={LuX}
+                />
+              </span>
+            ))}
+          </div>
+          <div className="relative w-full">
+            <Combobox
+              onChange={(value: { value: string; label: string }) => onKeySelect(value)}
+              displayValue={() => keyQuery}
+              onInputChange={onKeyQueryChange}
+              options={getFilteredKeys}
+              disabledMessage="Max keys reached"
+              size="SM"
+              immediate
+              disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP}
+              placeholder={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP ? "Max keys reached" : "Search for key..."}
+              emptyMessage="No matching keys found"
+            />
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+              Step Duration
+            </label>
+            <div className="group relative cursor-pointer">
+              <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+              <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                <p>The time to wait after pressing the keys in this step before moving to the next step. This helps ensure reliable key presses when automating keyboard input.</p>
+              </div>
+            </div>
+          </div>
+          <div className="flex items-center gap-3">
+            <SelectMenuBasic
+              size="SM"
+              fullWidth
+              value={step.delay.toString()}
+              onChange={(e) => onDelayChange(parseInt(e.target.value, 10))}
+              options={PRESET_DELAYS}
+            />
+          </div>
+        </div>
path: ui/src/components/MacroStepCard.tsx
user: login: adamshiervani
body: I suggest setting “Default” as the actual default value. I don’t expect most people to use anything apart from the default, or even not know what to choose between 100 ms and 200 ms.

That way we’re future-proofed - some systems have issues with certain input cadences during pasting, so this might need to become a configurable setting down the line or, maybe, we even change the implementation to a send-ack thing without fixed timing.
created_at: 2025-04-03T09:45:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+import { useEffect, Fragment, useMemo, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { LuPenLine, LuLoader, LuCopy, LuMoveRight, LuCornerDownRight, LuArrowUp, LuArrowDown } from "react-icons/lu";
+
+import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
+import { SettingsPageHeader } from "@/components/SettingsPageheader";
+import { Button } from "@/components/Button";
+import EmptyCard from "@/components/EmptyCard";
+import Card from "@/components/Card";
+import { MAX_TOTAL_MACROS, COPY_SUFFIX } from "@/constants/macros";
+import { keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings";
+import notifications from "@/notifications";
+import { SettingsItem } from "@/routes/devices.$id.settings";
+
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+export default function SettingsMacrosRoute() {
+  const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore();
+  const navigate = useNavigate();
+  const [actionLoadingId, setActionLoadingId] = useState<string | null>(null);
+  
+  const isMaxMacrosReached = useMemo(() => 
+    macros.length >= MAX_TOTAL_MACROS, 
+    [macros.length]
+  );
+
+  useEffect(() => {
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros]);
+
+  const handleDuplicateMacro = async (macro: KeySequence) => {
+    if (!macro?.id || !macro?.name) {
+      notifications.error("Invalid macro data");
+      return;
+    }
+
+    if (isMaxMacrosReached) {
+      notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    setActionLoadingId(macro.id);
+
+    const newMacroCopy: KeySequence = {
+      ...JSON.parse(JSON.stringify(macro)),
+      id: generateMacroId(),
+      name: `${macro.name} ${COPY_SUFFIX}`,
+      sortOrder: macros.length + 1,
+    };
+
+    try {
+      await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
+      notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to duplicate macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to duplicate macro");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  };
+
+  const handleMoveMacro = async (index: number, direction: 'up' | 'down', macroId: string) => {
+    if (!Array.isArray(macros) || macros.length === 0) {
+      notifications.error("No macros available");
+      return;
+    }
+
+    const newIndex = direction === 'up' ? index - 1 : index + 1;
+    if (newIndex < 0 || newIndex >= macros.length) return;
+
+    setActionLoadingId(macroId);
+
+    try {
+      const newMacros = [...macros];
+      [newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]];
+      const updatedMacros = normalizeSortOrders(newMacros);
+
+      await saveMacros(updatedMacros);
+      notifications.success("Macro order updated successfully");
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to reorder macros: ${error.message}`);
+      } else {
+        notifications.error("Failed to reorder macros");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  };
+
+  const MacroList = useMemo(() => (
+    <div className="space-y-2">
+      {macros.map((macro, index) => (
+        <Card key={macro.id} className="p-2 bg-white dark:bg-slate-800">
+          <div className="flex items-center justify-between">
+            <div className="flex flex-col gap-1 px-2">
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'up', macro.id)}
+                disabled={index === 0 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowUp}
+                aria-label={`Move ${macro.name} up`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'down', macro.id)}
+                disabled={index === macros.length - 1 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowDown}
+                aria-label={`Move ${macro.name} down`}
+              />
+            </div>
+
+            <div className="flex-1 min-w-0 flex flex-col justify-center ml-2">
+              <h3 className="truncate text-sm font-semibold text-black dark:text-white">
+                {macro.name}
+              </h3>
+              <p className="mt-1 ml-4 text-xs text-slate-500 dark:text-slate-400 overflow-hidden">
+                <span className="flex flex-col items-start gap-1">
+                  {macro.steps.map((step, stepIndex) => {
+                    const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight;
+
+                    return (
+                      <span key={stepIndex} className="inline-flex items-center">
+                        <StepIcon className="mr-1 text-slate-400 dark:text-slate-500 h-3 w-3 flex-shrink-0" />
+                        <span className="bg-slate-50 dark:bg-slate-800 px-2 py-0.5 rounded-md border border-slate-200/50 dark:border-slate-700/50">
+                          {(Array.isArray(step.modifiers) && step.modifiers.length > 0) || (Array.isArray(step.keys) && step.keys.length > 0) ? (
+                            <>
+                              {Array.isArray(step.modifiers) && step.modifiers.map((modifier, idx) => (
+                                <Fragment key={`mod-${idx}`}>
+                                  <span className="font-medium text-slate-600 dark:text-slate-200">
+                                    {modifierDisplayMap[modifier] || modifier}
+                                  </span>
+                                  {idx < step.modifiers.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+
+                              {Array.isArray(step.modifiers) && step.modifiers.length > 0 && Array.isArray(step.keys) && step.keys.length > 0 && (
+                                <span className="text-slate-400 dark:text-slate-600"> + </span>
+                              )}
+
+                              {Array.isArray(step.keys) && step.keys.map((key, idx) => (
+                                <Fragment key={`key-${idx}`}>
+                                  <span className="font-medium text-blue-600 dark:text-blue-400">
+                                    {keyDisplayMap[key] || key}
+                                  </span>
+                                  {idx < step.keys.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+                            </>
+                          ) : (
+                            <span className="font-medium text-slate-500 dark:text-slate-400">Delay only</span>
+                          )}
+                          <span className="ml-1 text-slate-400 dark:text-slate-500">({step.delay}ms)</span>
+                        </span>
+                      </span>
+                    );
+                  })}
+                </span>
+              </p>
+            </div>
+
+            <div className="flex items-center gap-1 ml-4">
+              <Button
+                size="XS"
+                theme="light"
+                LeadingIcon={LuCopy}
+                onClick={() => handleDuplicateMacro(macro)}
+                disabled={actionLoadingId === macro.id}
+                aria-label={`Duplicate macro ${macro.name}`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                LeadingIcon={LuPenLine}
+                onClick={() => navigate(`${macro.id}/edit`)}
+                disabled={actionLoadingId === macro.id}
+                aria-label={`Edit macro ${macro.name}`}
+              />
+            </div>
+          </div>
+        </Card>
+      ))}
+    </div>
+  ), [macros, actionLoadingId]);
+
+  return (
+    <div className="space-y-4">
+      <SettingsPageHeader
+        title="Keyboard Macros"
+        description="Create and manage keyboard macros for quick actions"
+      />
+      <div className="flex items-center justify-between mb-4">
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: adamshiervani
body: Replace with `w-full mb-4` and you'll get what you intended here.
created_at: 2025-04-03T09:55:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+import { useEffect, Fragment, useMemo, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { LuPenLine, LuLoader, LuCopy, LuMoveRight, LuCornerDownRight, LuArrowUp, LuArrowDown } from "react-icons/lu";
+
+import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
+import { SettingsPageHeader } from "@/components/SettingsPageheader";
+import { Button } from "@/components/Button";
+import EmptyCard from "@/components/EmptyCard";
+import Card from "@/components/Card";
+import { MAX_TOTAL_MACROS, COPY_SUFFIX } from "@/constants/macros";
+import { keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings";
+import notifications from "@/notifications";
+import { SettingsItem } from "@/routes/devices.$id.settings";
+
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+export default function SettingsMacrosRoute() {
+  const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore();
+  const navigate = useNavigate();
+  const [actionLoadingId, setActionLoadingId] = useState<string | null>(null);
+  
+  const isMaxMacrosReached = useMemo(() => 
+    macros.length >= MAX_TOTAL_MACROS, 
+    [macros.length]
+  );
+
+  useEffect(() => {
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros]);
+
+  const handleDuplicateMacro = async (macro: KeySequence) => {
+    if (!macro?.id || !macro?.name) {
+      notifications.error("Invalid macro data");
+      return;
+    }
+
+    if (isMaxMacrosReached) {
+      notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    setActionLoadingId(macro.id);
+
+    const newMacroCopy: KeySequence = {
+      ...JSON.parse(JSON.stringify(macro)),
+      id: generateMacroId(),
+      name: `${macro.name} ${COPY_SUFFIX}`,
+      sortOrder: macros.length + 1,
+    };
+
+    try {
+      await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
+      notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to duplicate macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to duplicate macro");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  };
+
+  const handleMoveMacro = async (index: number, direction: 'up' | 'down', macroId: string) => {
+    if (!Array.isArray(macros) || macros.length === 0) {
+      notifications.error("No macros available");
+      return;
+    }
+
+    const newIndex = direction === 'up' ? index - 1 : index + 1;
+    if (newIndex < 0 || newIndex >= macros.length) return;
+
+    setActionLoadingId(macroId);
+
+    try {
+      const newMacros = [...macros];
+      [newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]];
+      const updatedMacros = normalizeSortOrders(newMacros);
+
+      await saveMacros(updatedMacros);
+      notifications.success("Macro order updated successfully");
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to reorder macros: ${error.message}`);
+      } else {
+        notifications.error("Failed to reorder macros");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  };
+
+  const MacroList = useMemo(() => (
+    <div className="space-y-2">
+      {macros.map((macro, index) => (
+        <Card key={macro.id} className="p-2 bg-white dark:bg-slate-800">
+          <div className="flex items-center justify-between">
+            <div className="flex flex-col gap-1 px-2">
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'up', macro.id)}
+                disabled={index === 0 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowUp}
+                aria-label={`Move ${macro.name} up`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'down', macro.id)}
+                disabled={index === macros.length - 1 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowDown}
+                aria-label={`Move ${macro.name} down`}
+              />
+            </div>
+
+            <div className="flex-1 min-w-0 flex flex-col justify-center ml-2">
+              <h3 className="truncate text-sm font-semibold text-black dark:text-white">
+                {macro.name}
+              </h3>
+              <p className="mt-1 ml-4 text-xs text-slate-500 dark:text-slate-400 overflow-hidden">
+                <span className="flex flex-col items-start gap-1">
+                  {macro.steps.map((step, stepIndex) => {
+                    const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight;
+
+                    return (
+                      <span key={stepIndex} className="inline-flex items-center">
+                        <StepIcon className="mr-1 text-slate-400 dark:text-slate-500 h-3 w-3 flex-shrink-0" />
+                        <span className="bg-slate-50 dark:bg-slate-800 px-2 py-0.5 rounded-md border border-slate-200/50 dark:border-slate-700/50">
+                          {(Array.isArray(step.modifiers) && step.modifiers.length > 0) || (Array.isArray(step.keys) && step.keys.length > 0) ? (
+                            <>
+                              {Array.isArray(step.modifiers) && step.modifiers.map((modifier, idx) => (
+                                <Fragment key={`mod-${idx}`}>
+                                  <span className="font-medium text-slate-600 dark:text-slate-200">
+                                    {modifierDisplayMap[modifier] || modifier}
+                                  </span>
+                                  {idx < step.modifiers.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+
+                              {Array.isArray(step.modifiers) && step.modifiers.length > 0 && Array.isArray(step.keys) && step.keys.length > 0 && (
+                                <span className="text-slate-400 dark:text-slate-600"> + </span>
+                              )}
+
+                              {Array.isArray(step.keys) && step.keys.map((key, idx) => (
+                                <Fragment key={`key-${idx}`}>
+                                  <span className="font-medium text-blue-600 dark:text-blue-400">
+                                    {keyDisplayMap[key] || key}
+                                  </span>
+                                  {idx < step.keys.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+                            </>
+                          ) : (
+                            <span className="font-medium text-slate-500 dark:text-slate-400">Delay only</span>
+                          )}
+                          <span className="ml-1 text-slate-400 dark:text-slate-500">({step.delay}ms)</span>
+                        </span>
+                      </span>
+                    );
+                  })}
+                </span>
+              </p>
+            </div>
+
+            <div className="flex items-center gap-1 ml-4">
+              <Button
+                size="XS"
+                theme="light"
+                LeadingIcon={LuCopy}
+                onClick={() => handleDuplicateMacro(macro)}
+                disabled={actionLoadingId === macro.id}
+                aria-label={`Duplicate macro ${macro.name}`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                LeadingIcon={LuPenLine}
+                onClick={() => navigate(`${macro.id}/edit`)}
+                disabled={actionLoadingId === macro.id}
+                aria-label={`Edit macro ${macro.name}`}
+              />
+            </div>
+          </div>
+        </Card>
+      ))}
+    </div>
+  ), [macros, actionLoadingId]);
+
+  return (
+    <div className="space-y-4">
+      <SettingsPageHeader
+        title="Keyboard Macros"
+        description="Create and manage keyboard macros for quick actions"
+      />
+      <div className="flex items-center justify-between mb-4">
+        <SettingsItem 
+          title="Macros"
+          description={`${loading ? '?' : macros.length}/${MAX_TOTAL_MACROS}`}
+        >
+          { macros.length > 0 && (
+            <div className="flex items-center gap-2">
+              <Button
+                size="SM"
+                theme="primary"
+                text={isMaxMacrosReached ? `Max Reached` : "Add New Macro"}
+                onClick={() => navigate("add")}
+                disabled={isMaxMacrosReached}
+                aria-label="Add new macro"
+              />
+            </div>
+          )}
+        </SettingsItem>
+      </div>
+
+      <div className="space-y-4">
+        {loading && macros.length === 0 ? (
+          <EmptyCard
+            headline="Loading macros..."
+            description="Please wait while we fetch your macros"
+            BtnElm={
+              <LuLoader className="h-6 w-6 animate-spin text-blue-500" />
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: adamshiervani
body: Use `LoadingSpinner`
created_at: 2025-04-03T09:56:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+import { useEffect, Fragment, useMemo, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { LuPenLine, LuLoader, LuCopy, LuMoveRight, LuCornerDownRight, LuArrowUp, LuArrowDown } from "react-icons/lu";
+
+import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
+import { SettingsPageHeader } from "@/components/SettingsPageheader";
+import { Button } from "@/components/Button";
+import EmptyCard from "@/components/EmptyCard";
+import Card from "@/components/Card";
+import { MAX_TOTAL_MACROS, COPY_SUFFIX } from "@/constants/macros";
+import { keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings";
+import notifications from "@/notifications";
+import { SettingsItem } from "@/routes/devices.$id.settings";
+
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+export default function SettingsMacrosRoute() {
+  const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore();
+  const navigate = useNavigate();
+  const [actionLoadingId, setActionLoadingId] = useState<string | null>(null);
+  
+  const isMaxMacrosReached = useMemo(() => 
+    macros.length >= MAX_TOTAL_MACROS, 
+    [macros.length]
+  );
+
+  useEffect(() => {
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros]);
+
+  const handleDuplicateMacro = async (macro: KeySequence) => {
+    if (!macro?.id || !macro?.name) {
+      notifications.error("Invalid macro data");
+      return;
+    }
+
+    if (isMaxMacrosReached) {
+      notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    setActionLoadingId(macro.id);
+
+    const newMacroCopy: KeySequence = {
+      ...JSON.parse(JSON.stringify(macro)),
+      id: generateMacroId(),
+      name: `${macro.name} ${COPY_SUFFIX}`,
+      sortOrder: macros.length + 1,
+    };
+
+    try {
+      await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
+      notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to duplicate macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to duplicate macro");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  };
+
+  const handleMoveMacro = async (index: number, direction: 'up' | 'down', macroId: string) => {
+    if (!Array.isArray(macros) || macros.length === 0) {
+      notifications.error("No macros available");
+      return;
+    }
+
+    const newIndex = direction === 'up' ? index - 1 : index + 1;
+    if (newIndex < 0 || newIndex >= macros.length) return;
+
+    setActionLoadingId(macroId);
+
+    try {
+      const newMacros = [...macros];
+      [newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]];
+      const updatedMacros = normalizeSortOrders(newMacros);
+
+      await saveMacros(updatedMacros);
+      notifications.success("Macro order updated successfully");
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to reorder macros: ${error.message}`);
+      } else {
+        notifications.error("Failed to reorder macros");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  };
+
+  const MacroList = useMemo(() => (
+    <div className="space-y-2">
+      {macros.map((macro, index) => (
+        <Card key={macro.id} className="p-2 bg-white dark:bg-slate-800">
+          <div className="flex items-center justify-between">
+            <div className="flex flex-col gap-1 px-2">
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'up', macro.id)}
+                disabled={index === 0 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowUp}
+                aria-label={`Move ${macro.name} up`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'down', macro.id)}
+                disabled={index === macros.length - 1 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowDown}
+                aria-label={`Move ${macro.name} down`}
+              />
+            </div>
+
+            <div className="flex-1 min-w-0 flex flex-col justify-center ml-2">
+              <h3 className="truncate text-sm font-semibold text-black dark:text-white">
+                {macro.name}
+              </h3>
+              <p className="mt-1 ml-4 text-xs text-slate-500 dark:text-slate-400 overflow-hidden">
+                <span className="flex flex-col items-start gap-1">
+                  {macro.steps.map((step, stepIndex) => {
+                    const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight;
+
+                    return (
+                      <span key={stepIndex} className="inline-flex items-center">
+                        <StepIcon className="mr-1 text-slate-400 dark:text-slate-500 h-3 w-3 flex-shrink-0" />
+                        <span className="bg-slate-50 dark:bg-slate-800 px-2 py-0.5 rounded-md border border-slate-200/50 dark:border-slate-700/50">
+                          {(Array.isArray(step.modifiers) && step.modifiers.length > 0) || (Array.isArray(step.keys) && step.keys.length > 0) ? (
+                            <>
+                              {Array.isArray(step.modifiers) && step.modifiers.map((modifier, idx) => (
+                                <Fragment key={`mod-${idx}`}>
+                                  <span className="font-medium text-slate-600 dark:text-slate-200">
+                                    {modifierDisplayMap[modifier] || modifier}
+                                  </span>
+                                  {idx < step.modifiers.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+
+                              {Array.isArray(step.modifiers) && step.modifiers.length > 0 && Array.isArray(step.keys) && step.keys.length > 0 && (
+                                <span className="text-slate-400 dark:text-slate-600"> + </span>
+                              )}
+
+                              {Array.isArray(step.keys) && step.keys.map((key, idx) => (
+                                <Fragment key={`key-${idx}`}>
+                                  <span className="font-medium text-blue-600 dark:text-blue-400">
+                                    {keyDisplayMap[key] || key}
+                                  </span>
+                                  {idx < step.keys.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+                            </>
+                          ) : (
+                            <span className="font-medium text-slate-500 dark:text-slate-400">Delay only</span>
+                          )}
+                          <span className="ml-1 text-slate-400 dark:text-slate-500">({step.delay}ms)</span>
+                        </span>
+                      </span>
+                    );
+                  })}
+                </span>
+              </p>
+            </div>
+
+            <div className="flex items-center gap-1 ml-4">
+              <Button
+                size="XS"
+                theme="light"
+                LeadingIcon={LuCopy}
+                onClick={() => handleDuplicateMacro(macro)}
+                disabled={actionLoadingId === macro.id}
+                aria-label={`Duplicate macro ${macro.name}`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                LeadingIcon={LuPenLine}
+                onClick={() => navigate(`${macro.id}/edit`)}
+                disabled={actionLoadingId === macro.id}
+                aria-label={`Edit macro ${macro.name}`}
+              />
+            </div>
+          </div>
+        </Card>
+      ))}
+    </div>
+  ), [macros, actionLoadingId]);
+
+  return (
+    <div className="space-y-4">
+      <SettingsPageHeader
+        title="Keyboard Macros"
+        description="Create and manage keyboard macros for quick actions"
+      />
+      <div className="flex items-center justify-between mb-4">
+        <SettingsItem 
+          title="Macros"
+          description={`${loading ? '?' : macros.length}/${MAX_TOTAL_MACROS}`}
+        >
+          { macros.length > 0 && (
+            <div className="flex items-center gap-2">
+              <Button
+                size="SM"
+                theme="primary"
+                text={isMaxMacrosReached ? `Max Reached` : "Add New Macro"}
+                onClick={() => navigate("add")}
+                disabled={isMaxMacrosReached}
+                aria-label="Add new macro"
+              />
+            </div>
+          )}
+        </SettingsItem>
+      </div>
+
+      <div className="space-y-4">
+        {loading && macros.length === 0 ? (
+          <EmptyCard
+            headline="Loading macros..."
+            description="Please wait while we fetch your macros"
+            BtnElm={
+              <LuLoader className="h-6 w-6 animate-spin text-blue-500" />
+            }
+          />
+        ) : macros.length === 0 ? (
+          <EmptyCard
+            headline="No macros created yet"
+            BtnElm={
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: adamshiervani
body: Suggestion: 

```
IconElm={LuCommand}
headline="Create Your First Macro"
description="Combine keystrokes into a single action for faster workflows."
```
created_at: 2025-04-03T09:56:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+import { useEffect, Fragment, useMemo, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { LuPenLine, LuLoader, LuCopy, LuMoveRight, LuCornerDownRight, LuArrowUp, LuArrowDown } from "react-icons/lu";
+
+import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
+import { SettingsPageHeader } from "@/components/SettingsPageheader";
+import { Button } from "@/components/Button";
+import EmptyCard from "@/components/EmptyCard";
+import Card from "@/components/Card";
+import { MAX_TOTAL_MACROS, COPY_SUFFIX } from "@/constants/macros";
+import { keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings";
+import notifications from "@/notifications";
+import { SettingsItem } from "@/routes/devices.$id.settings";
+
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+export default function SettingsMacrosRoute() {
+  const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore();
+  const navigate = useNavigate();
+  const [actionLoadingId, setActionLoadingId] = useState<string | null>(null);
+  
+  const isMaxMacrosReached = useMemo(() => 
+    macros.length >= MAX_TOTAL_MACROS, 
+    [macros.length]
+  );
+
+  useEffect(() => {
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros]);
+
+  const handleDuplicateMacro = async (macro: KeySequence) => {
+    if (!macro?.id || !macro?.name) {
+      notifications.error("Invalid macro data");
+      return;
+    }
+
+    if (isMaxMacrosReached) {
+      notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    setActionLoadingId(macro.id);
+
+    const newMacroCopy: KeySequence = {
+      ...JSON.parse(JSON.stringify(macro)),
+      id: generateMacroId(),
+      name: `${macro.name} ${COPY_SUFFIX}`,
+      sortOrder: macros.length + 1,
+    };
+
+    try {
+      await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
+      notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to duplicate macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to duplicate macro");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  };
+
+  const handleMoveMacro = async (index: number, direction: 'up' | 'down', macroId: string) => {
+    if (!Array.isArray(macros) || macros.length === 0) {
+      notifications.error("No macros available");
+      return;
+    }
+
+    const newIndex = direction === 'up' ? index - 1 : index + 1;
+    if (newIndex < 0 || newIndex >= macros.length) return;
+
+    setActionLoadingId(macroId);
+
+    try {
+      const newMacros = [...macros];
+      [newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]];
+      const updatedMacros = normalizeSortOrders(newMacros);
+
+      await saveMacros(updatedMacros);
+      notifications.success("Macro order updated successfully");
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to reorder macros: ${error.message}`);
+      } else {
+        notifications.error("Failed to reorder macros");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  };
+
+  const MacroList = useMemo(() => (
+    <div className="space-y-2">
+      {macros.map((macro, index) => (
+        <Card key={macro.id} className="p-2 bg-white dark:bg-slate-800">
+          <div className="flex items-center justify-between">
+            <div className="flex flex-col gap-1 px-2">
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'up', macro.id)}
+                disabled={index === 0 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowUp}
+                aria-label={`Move ${macro.name} up`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'down', macro.id)}
+                disabled={index === macros.length - 1 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowDown}
+                aria-label={`Move ${macro.name} down`}
+              />
+            </div>
+
+            <div className="flex-1 min-w-0 flex flex-col justify-center ml-2">
+              <h3 className="truncate text-sm font-semibold text-black dark:text-white">
+                {macro.name}
+              </h3>
+              <p className="mt-1 ml-4 text-xs text-slate-500 dark:text-slate-400 overflow-hidden">
+                <span className="flex flex-col items-start gap-1">
+                  {macro.steps.map((step, stepIndex) => {
+                    const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight;
+
+                    return (
+                      <span key={stepIndex} className="inline-flex items-center">
+                        <StepIcon className="mr-1 text-slate-400 dark:text-slate-500 h-3 w-3 flex-shrink-0" />
+                        <span className="bg-slate-50 dark:bg-slate-800 px-2 py-0.5 rounded-md border border-slate-200/50 dark:border-slate-700/50">
+                          {(Array.isArray(step.modifiers) && step.modifiers.length > 0) || (Array.isArray(step.keys) && step.keys.length > 0) ? (
+                            <>
+                              {Array.isArray(step.modifiers) && step.modifiers.map((modifier, idx) => (
+                                <Fragment key={`mod-${idx}`}>
+                                  <span className="font-medium text-slate-600 dark:text-slate-200">
+                                    {modifierDisplayMap[modifier] || modifier}
+                                  </span>
+                                  {idx < step.modifiers.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+
+                              {Array.isArray(step.modifiers) && step.modifiers.length > 0 && Array.isArray(step.keys) && step.keys.length > 0 && (
+                                <span className="text-slate-400 dark:text-slate-600"> + </span>
+                              )}
+
+                              {Array.isArray(step.keys) && step.keys.map((key, idx) => (
+                                <Fragment key={`key-${idx}`}>
+                                  <span className="font-medium text-blue-600 dark:text-blue-400">
+                                    {keyDisplayMap[key] || key}
+                                  </span>
+                                  {idx < step.keys.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+                            </>
+                          ) : (
+                            <span className="font-medium text-slate-500 dark:text-slate-400">Delay only</span>
+                          )}
+                          <span className="ml-1 text-slate-400 dark:text-slate-500">({step.delay}ms)</span>
+                        </span>
+                      </span>
+                    );
+                  })}
+                </span>
+              </p>
+            </div>
+
+            <div className="flex items-center gap-1 ml-4">
+              <Button
+                size="XS"
+                theme="light"
+                LeadingIcon={LuCopy}
+                onClick={() => handleDuplicateMacro(macro)}
+                disabled={actionLoadingId === macro.id}
+                aria-label={`Duplicate macro ${macro.name}`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                LeadingIcon={LuPenLine}
+                onClick={() => navigate(`${macro.id}/edit`)}
+                disabled={actionLoadingId === macro.id}
+                aria-label={`Edit macro ${macro.name}`}
+              />
+            </div>
+          </div>
+        </Card>
+      ))}
+    </div>
+  ), [macros, actionLoadingId]);
+
+  return (
+    <div className="space-y-4">
+      <SettingsPageHeader
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: adamshiervani
body: We don't need this to be shown if there are no macros. The `EmptyCard` will suffice.
created_at: 2025-04-03T09:57:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+import { useEffect, Fragment, useMemo, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { LuPenLine, LuLoader, LuCopy, LuMoveRight, LuCornerDownRight, LuArrowUp, LuArrowDown } from "react-icons/lu";
+
+import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
+import { SettingsPageHeader } from "@/components/SettingsPageheader";
+import { Button } from "@/components/Button";
+import EmptyCard from "@/components/EmptyCard";
+import Card from "@/components/Card";
+import { MAX_TOTAL_MACROS, COPY_SUFFIX } from "@/constants/macros";
+import { keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings";
+import notifications from "@/notifications";
+import { SettingsItem } from "@/routes/devices.$id.settings";
+
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+export default function SettingsMacrosRoute() {
+  const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore();
+  const navigate = useNavigate();
+  const [actionLoadingId, setActionLoadingId] = useState<string | null>(null);
+  
+  const isMaxMacrosReached = useMemo(() => 
+    macros.length >= MAX_TOTAL_MACROS, 
+    [macros.length]
+  );
+
+  useEffect(() => {
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros]);
+
+  const handleDuplicateMacro = async (macro: KeySequence) => {
+    if (!macro?.id || !macro?.name) {
+      notifications.error("Invalid macro data");
+      return;
+    }
+
+    if (isMaxMacrosReached) {
+      notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    setActionLoadingId(macro.id);
+
+    const newMacroCopy: KeySequence = {
+      ...JSON.parse(JSON.stringify(macro)),
+      id: generateMacroId(),
+      name: `${macro.name} ${COPY_SUFFIX}`,
+      sortOrder: macros.length + 1,
+    };
+
+    try {
+      await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
+      notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to duplicate macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to duplicate macro");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  };
+
+  const handleMoveMacro = async (index: number, direction: 'up' | 'down', macroId: string) => {
+    if (!Array.isArray(macros) || macros.length === 0) {
+      notifications.error("No macros available");
+      return;
+    }
+
+    const newIndex = direction === 'up' ? index - 1 : index + 1;
+    if (newIndex < 0 || newIndex >= macros.length) return;
+
+    setActionLoadingId(macroId);
+
+    try {
+      const newMacros = [...macros];
+      [newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]];
+      const updatedMacros = normalizeSortOrders(newMacros);
+
+      await saveMacros(updatedMacros);
+      notifications.success("Macro order updated successfully");
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to reorder macros: ${error.message}`);
+      } else {
+        notifications.error("Failed to reorder macros");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  };
+
+  const MacroList = useMemo(() => (
+    <div className="space-y-2">
+      {macros.map((macro, index) => (
+        <Card key={macro.id} className="p-2 bg-white dark:bg-slate-800">
+          <div className="flex items-center justify-between">
+            <div className="flex flex-col gap-1 px-2">
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'up', macro.id)}
+                disabled={index === 0 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowUp}
+                aria-label={`Move ${macro.name} up`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'down', macro.id)}
+                disabled={index === macros.length - 1 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowDown}
+                aria-label={`Move ${macro.name} down`}
+              />
+            </div>
+
+            <div className="flex-1 min-w-0 flex flex-col justify-center ml-2">
+              <h3 className="truncate text-sm font-semibold text-black dark:text-white">
+                {macro.name}
+              </h3>
+              <p className="mt-1 ml-4 text-xs text-slate-500 dark:text-slate-400 overflow-hidden">
+                <span className="flex flex-col items-start gap-1">
+                  {macro.steps.map((step, stepIndex) => {
+                    const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight;
+
+                    return (
+                      <span key={stepIndex} className="inline-flex items-center">
+                        <StepIcon className="mr-1 text-slate-400 dark:text-slate-500 h-3 w-3 flex-shrink-0" />
+                        <span className="bg-slate-50 dark:bg-slate-800 px-2 py-0.5 rounded-md border border-slate-200/50 dark:border-slate-700/50">
+                          {(Array.isArray(step.modifiers) && step.modifiers.length > 0) || (Array.isArray(step.keys) && step.keys.length > 0) ? (
+                            <>
+                              {Array.isArray(step.modifiers) && step.modifiers.map((modifier, idx) => (
+                                <Fragment key={`mod-${idx}`}>
+                                  <span className="font-medium text-slate-600 dark:text-slate-200">
+                                    {modifierDisplayMap[modifier] || modifier}
+                                  </span>
+                                  {idx < step.modifiers.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+
+                              {Array.isArray(step.modifiers) && step.modifiers.length > 0 && Array.isArray(step.keys) && step.keys.length > 0 && (
+                                <span className="text-slate-400 dark:text-slate-600"> + </span>
+                              )}
+
+                              {Array.isArray(step.keys) && step.keys.map((key, idx) => (
+                                <Fragment key={`key-${idx}`}>
+                                  <span className="font-medium text-blue-600 dark:text-blue-400">
+                                    {keyDisplayMap[key] || key}
+                                  </span>
+                                  {idx < step.keys.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+                            </>
+                          ) : (
+                            <span className="font-medium text-slate-500 dark:text-slate-400">Delay only</span>
+                          )}
+                          <span className="ml-1 text-slate-400 dark:text-slate-500">({step.delay}ms)</span>
+                        </span>
+                      </span>
+                    );
+                  })}
+                </span>
+              </p>
+            </div>
+
+            <div className="flex items-center gap-1 ml-4">
+              <Button
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: adamshiervani
body: What do you say about adding a button with 
```
className="text-red-500"
theme="light"
LeadingIcon={LuTrash2}
```

dont forget to confirm it, though
created_at: 2025-04-03T10:03:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+import { useEffect, Fragment, useMemo, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { LuPenLine, LuLoader, LuCopy, LuMoveRight, LuCornerDownRight, LuArrowUp, LuArrowDown } from "react-icons/lu";
+
+import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
+import { SettingsPageHeader } from "@/components/SettingsPageheader";
+import { Button } from "@/components/Button";
+import EmptyCard from "@/components/EmptyCard";
+import Card from "@/components/Card";
+import { MAX_TOTAL_MACROS, COPY_SUFFIX } from "@/constants/macros";
+import { keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings";
+import notifications from "@/notifications";
+import { SettingsItem } from "@/routes/devices.$id.settings";
+
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+export default function SettingsMacrosRoute() {
+  const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore();
+  const navigate = useNavigate();
+  const [actionLoadingId, setActionLoadingId] = useState<string | null>(null);
+  
+  const isMaxMacrosReached = useMemo(() => 
+    macros.length >= MAX_TOTAL_MACROS, 
+    [macros.length]
+  );
+
+  useEffect(() => {
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros]);
+
+  const handleDuplicateMacro = async (macro: KeySequence) => {
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: adamshiervani
body: You're dependent on react state. Wrap in `useCallback`
created_at: 2025-04-03T10:04:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+import { useEffect, Fragment, useMemo, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { LuPenLine, LuLoader, LuCopy, LuMoveRight, LuCornerDownRight, LuArrowUp, LuArrowDown } from "react-icons/lu";
+
+import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
+import { SettingsPageHeader } from "@/components/SettingsPageheader";
+import { Button } from "@/components/Button";
+import EmptyCard from "@/components/EmptyCard";
+import Card from "@/components/Card";
+import { MAX_TOTAL_MACROS, COPY_SUFFIX } from "@/constants/macros";
+import { keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings";
+import notifications from "@/notifications";
+import { SettingsItem } from "@/routes/devices.$id.settings";
+
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+export default function SettingsMacrosRoute() {
+  const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore();
+  const navigate = useNavigate();
+  const [actionLoadingId, setActionLoadingId] = useState<string | null>(null);
+  
+  const isMaxMacrosReached = useMemo(() => 
+    macros.length >= MAX_TOTAL_MACROS, 
+    [macros.length]
+  );
+
+  useEffect(() => {
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros]);
+
+  const handleDuplicateMacro = async (macro: KeySequence) => {
+    if (!macro?.id || !macro?.name) {
+      notifications.error("Invalid macro data");
+      return;
+    }
+
+    if (isMaxMacrosReached) {
+      notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    setActionLoadingId(macro.id);
+
+    const newMacroCopy: KeySequence = {
+      ...JSON.parse(JSON.stringify(macro)),
+      id: generateMacroId(),
+      name: `${macro.name} ${COPY_SUFFIX}`,
+      sortOrder: macros.length + 1,
+    };
+
+    try {
+      await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
+      notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to duplicate macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to duplicate macro");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  };
+
+  const handleMoveMacro = async (index: number, direction: 'up' | 'down', macroId: string) => {
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: adamshiervani
body: You're dependent on react state. Wrap in `useCallback`
created_at: 2025-04-03T10:04:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,362 @@
+import { useState } from "react";
+
+import { LuPlus, LuInfo } from "react-icons/lu";
+
+import { KeySequence } from "@/hooks/stores";
+import { Button } from "@/components/Button";
+import { InputFieldWithLabel, FieldError } from "@/components/InputField";
+import Fieldset from "@/components/Fieldset";
+import { MacroStepCard } from "@/components/MacroStepCard";
+import Modal from "@/components/Modal";
+import { DEFAULT_DELAY, MAX_STEPS_PER_MACRO, MAX_KEYS_PER_STEP } from "@/constants/macros";
+
+interface ValidationErrors {
+  name?: string;
+  steps?: Record<number, {
+    keys?: string;
+    modifiers?: string;
+    delay?: string;
+  }>;
+}
+
+interface MacroFormProps {
+  initialData: Partial<KeySequence>;
+  onSubmit: (macro: Partial<KeySequence>) => Promise<void>;
+  onCancel: () => void;
+  isSubmitting?: boolean;
+  submitText?: string;
+  showCancelConfirm?: boolean;
+  onCancelConfirm?: () => void;
+  showDelete?: boolean;
+  onDelete?: () => void;
+  isDeleting?: boolean;
+}
+
+export function MacroForm({
+  initialData,
+  onSubmit,
+  onCancel,
+  isSubmitting = false,
+  submitText = "Save Macro",
+  showCancelConfirm = false,
+  onCancelConfirm,
+  showDelete = false,
+  onDelete,
+  isDeleting = false
+}: MacroFormProps) {
+  const [macro, setMacro] = useState<Partial<KeySequence>>(initialData);
+  const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
+  const [errors, setErrors] = useState<ValidationErrors>({});
+  const [errorMessage, setErrorMessage] = useState<string | null>(null);
+  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+
+  const showTemporaryError = (message: string) => {
+    setErrorMessage(message);
+    setTimeout(() => setErrorMessage(null), 3000);
+  };
+
+  const validateForm = (): boolean => {
+    const newErrors: ValidationErrors = {};
+
+    // Name validation
+    if (!macro.name?.trim()) {
+      newErrors.name = "Name is required";
+    } else if (macro.name.trim().length > 50) {
+      newErrors.name = "Name must be less than 50 characters";
+    }
+  
+    if (!macro.steps?.length) {
+      newErrors.steps = { 0: { keys: "At least one step is required" } };
+    } else {
+      const hasKeyOrModifier = macro.steps.some(step => 
+        (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0
+      );
+
+      if (!hasKeyOrModifier) {
+        newErrors.steps = { 0: { keys: "At least one step must have keys or modifiers" } };
+      }
+    }
+
+    setErrors(newErrors);
+    return Object.keys(newErrors).length === 0;
+  };
+
+  const handleSubmit = async () => {
+    if (!validateForm()) {
+      showTemporaryError("Please fix the validation errors");
+      return;
+    }
+
+    try {
+      await onSubmit(macro);
+    } catch (error) {
+      if (error instanceof Error) {
+        showTemporaryError(error.message);
+      } else {
+        showTemporaryError("An error occurred while saving");
+      }
+    }
+  };
+
+  const handleKeySelect = (stepIndex: number, option: { value: string | null; keys?: string[] }) => {
+    const newSteps = [...(macro.steps || [])];
+    if (!newSteps[stepIndex]) return;
+
+    if (option.keys) {
+      newSteps[stepIndex].keys = option.keys;
+    } else if (option.value) {
+      if (!newSteps[stepIndex].keys) {
+        newSteps[stepIndex].keys = [];
+      }
+      const keysArray = Array.isArray(newSteps[stepIndex].keys) ? newSteps[stepIndex].keys : [];
+      if (keysArray.length >= MAX_KEYS_PER_STEP) {
+        showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`);
+        return;
+      }
+      newSteps[stepIndex].keys = [...keysArray, option.value];
+    }
+    setMacro({ ...macro, steps: newSteps });
+    
+    if (errors.steps?.[stepIndex]?.keys) {
+      const newErrors = { ...errors };
+      delete newErrors.steps?.[stepIndex].keys;
+      if (Object.keys(newErrors.steps?.[stepIndex] || {}).length === 0) {
+        delete newErrors.steps?.[stepIndex];
+      }
+      if (Object.keys(newErrors.steps || {}).length === 0) {
+        delete newErrors.steps;
+      }
+      setErrors(newErrors);
+    }
+  };
+
+  const handleKeyQueryChange = (stepIndex: number, query: string) => {
+    setKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
+  };
+
+  const handleModifierChange = (stepIndex: number, modifiers: string[]) => {
+    const newSteps = [...(macro.steps || [])];
+    newSteps[stepIndex].modifiers = modifiers;
+    setMacro({ ...macro, steps: newSteps });
+    
+    // Clear step errors when modifiers are added
+    if (errors.steps?.[stepIndex]?.keys && modifiers.length > 0) {
+      const newErrors = { ...errors };
+      delete newErrors.steps?.[stepIndex].keys;
+      if (Object.keys(newErrors.steps?.[stepIndex] || {}).length === 0) {
+        delete newErrors.steps?.[stepIndex];
+      }
+      if (Object.keys(newErrors.steps || {}).length === 0) {
+        delete newErrors.steps;
+      }
+      setErrors(newErrors);
+    }
+  };
+
+  const handleDelayChange = (stepIndex: number, delay: number) => {
+    const newSteps = [...(macro.steps || [])];
+    newSteps[stepIndex].delay = delay;
+    setMacro({ ...macro, steps: newSteps });
+  };
+
+  const handleStepMove = (stepIndex: number, direction: 'up' | 'down') => {
+    const newSteps = [...(macro.steps || [])];
+    const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
+    [newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]];
+    setMacro({ ...macro, steps: newSteps });
+  };
+
+  const isMaxStepsReached = (macro.steps?.length || 0) >= MAX_STEPS_PER_MACRO;
+
+  return (
+    <>
+      <div className="space-y-4">
+        <Fieldset>
+          <InputFieldWithLabel
+            type="text"
+            label="Macro Name"
+            placeholder="Macro Name"
+            value={macro.name}
+            error={errors.name}
+            onChange={e => {
+              setMacro(prev => ({ ...prev, name: e.target.value }));
+              if (errors.name) {
+                const newErrors = { ...errors };
+                delete newErrors.name;
+                setErrors(newErrors);
+              }
+            }}
+          />
+        </Fieldset>
+
+        <div>
+          <div className="flex items-center justify-between text-sm">
+            <div className="flex items-center gap-1">
+              <label className="font-medium text-slate-700 dark:text-slate-200">
+                Steps
+              </label>
+              <div className="group relative cursor-pointer">
+                <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+                <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                  <p>Each step is a collection of keys and/or modifiers that will be executed in order. You can add up to a maximum of {MAX_STEPS_PER_MACRO} steps per macro.</p>
+                </div>
+              </div>
+            </div>
+            <span className="text-slate-500 dark:text-slate-400">
+              {macro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps
+            </span>
+          </div>
+          {errors.steps && errors.steps[0]?.keys && (
+            <div className="mt-2">
+              <FieldError error={errors.steps[0].keys} />
+            </div>
+          )}
+          <Fieldset>
+            <div className="mt-2 space-y-4">
+              {(macro.steps || []).map((step, stepIndex) => (
+                <MacroStepCard
+                  key={stepIndex}
+                  step={step}
+                  stepIndex={stepIndex}
+                  onDelete={macro.steps && macro.steps.length > 1 ? () => {
+                    const newSteps = [...(macro.steps || [])];
+                    newSteps.splice(stepIndex, 1);
+                    setMacro(prev => ({ ...prev, steps: newSteps }));
+                  } : undefined}
+                  onMoveUp={() => handleStepMove(stepIndex, 'up')}
+                  onMoveDown={() => handleStepMove(stepIndex, 'down')}
+                  onKeySelect={(option) => handleKeySelect(stepIndex, option)}
+                  onKeyQueryChange={(query) => handleKeyQueryChange(stepIndex, query)}
+                  keyQuery={keyQueries[stepIndex] || ''}
+                  onModifierChange={(modifiers) => handleModifierChange(stepIndex, modifiers)}
+                  onDelayChange={(delay) => handleDelayChange(stepIndex, delay)}
+                  isLastStep={stepIndex === (macro.steps?.length || 0) - 1}
+                />
+              ))}
+            </div>
+          </Fieldset>
+
+          <div className="mt-4">
+            <Button
+              size="MD"
+              theme="light"
+              fullWidth
+              LeadingIcon={LuPlus}
+              text={`Add Step ${isMaxStepsReached ? `(${MAX_STEPS_PER_MACRO} max)` : ''}`}
+              onClick={() => {
+                if (isMaxStepsReached) {
+                  showTemporaryError(`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`);
+                  return;
+                }
+                
+                setMacro(prev => ({
+                  ...prev,
+                  steps: [
+                    ...(prev.steps || []), 
+                    { keys: [], modifiers: [], delay: DEFAULT_DELAY }
+                  ],
+                }));
+                setErrors({});
+              }}
+              disabled={isMaxStepsReached}
+            />
+          </div>
+
+          {errorMessage && (
+            <div className="mt-4">
+              <FieldError error={errorMessage} />
+            </div>
+          )}
+
+          <div className="mt-6 flex items-center justify-between">
+            {showCancelConfirm ? (
+              <div className="flex items-center gap-2">
+                <span className="text-sm text-slate-600 dark:text-slate-400">
+                  Cancel changes?
+                </span>
+                <Button
+                  size="SM"
+                  theme="danger"
+                  text="Yes"
+                  onClick={onCancelConfirm}
+                />
+                <Button
+                  size="SM"
+                  theme="light"
+                  text="No"
+                  onClick={() => onCancel()}
+                />
+              </div>
+            ) : (
+              <>
+                <div className="flex gap-x-2">
+                  <Button
+                    size="SM"
+                    theme="primary"
+                    text={isSubmitting ? "Saving..." : submitText}
+                    onClick={handleSubmit}
+                    disabled={isSubmitting}
+                  />
+                  <Button
+                    size="SM"
+                    theme="light"
+                    text="Cancel"
+                    onClick={onCancel}
+                  />
+                </div>
+                {showDelete && (
path: ui/src/components/MacroForm.tsx
user: login: adamshiervani
body: I noticed, was reaching for this button when I wanted to Save the Macro. I would suggest adding it to the other side of the `SettingsPageHeader` in `devices.$id.settings.macros.add.tsx`. 

Can you also add the `LuTrash2` to it, I really like that icon.
created_at: 2025-04-03T10:10:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+import { useEffect, Fragment, useMemo, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { LuPenLine, LuLoader, LuCopy, LuMoveRight, LuCornerDownRight, LuArrowUp, LuArrowDown } from "react-icons/lu";
+
+import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
+import { SettingsPageHeader } from "@/components/SettingsPageheader";
+import { Button } from "@/components/Button";
+import EmptyCard from "@/components/EmptyCard";
+import Card from "@/components/Card";
+import { MAX_TOTAL_MACROS, COPY_SUFFIX } from "@/constants/macros";
+import { keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings";
+import notifications from "@/notifications";
+import { SettingsItem } from "@/routes/devices.$id.settings";
+
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+export default function SettingsMacrosRoute() {
+  const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore();
+  const navigate = useNavigate();
+  const [actionLoadingId, setActionLoadingId] = useState<string | null>(null);
+  
+  const isMaxMacrosReached = useMemo(() => 
+    macros.length >= MAX_TOTAL_MACROS, 
+    [macros.length]
+  );
+
+  useEffect(() => {
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros]);
+
+  const handleDuplicateMacro = async (macro: KeySequence) => {
+    if (!macro?.id || !macro?.name) {
+      notifications.error("Invalid macro data");
+      return;
+    }
+
+    if (isMaxMacrosReached) {
+      notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    setActionLoadingId(macro.id);
+
+    const newMacroCopy: KeySequence = {
+      ...JSON.parse(JSON.stringify(macro)),
+      id: generateMacroId(),
+      name: `${macro.name} ${COPY_SUFFIX}`,
+      sortOrder: macros.length + 1,
+    };
+
+    try {
+      await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
+      notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to duplicate macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to duplicate macro");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  };
+
+  const handleMoveMacro = async (index: number, direction: 'up' | 'down', macroId: string) => {
+    if (!Array.isArray(macros) || macros.length === 0) {
+      notifications.error("No macros available");
+      return;
+    }
+
+    const newIndex = direction === 'up' ? index - 1 : index + 1;
+    if (newIndex < 0 || newIndex >= macros.length) return;
+
+    setActionLoadingId(macroId);
+
+    try {
+      const newMacros = [...macros];
+      [newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]];
+      const updatedMacros = normalizeSortOrders(newMacros);
+
+      await saveMacros(updatedMacros);
+      notifications.success("Macro order updated successfully");
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to reorder macros: ${error.message}`);
+      } else {
+        notifications.error("Failed to reorder macros");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  };
+
+  const MacroList = useMemo(() => (
+    <div className="space-y-2">
+      {macros.map((macro, index) => (
+        <Card key={macro.id} className="p-2 bg-white dark:bg-slate-800">
+          <div className="flex items-center justify-between">
+            <div className="flex flex-col gap-1 px-2">
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'up', macro.id)}
+                disabled={index === 0 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowUp}
+                aria-label={`Move ${macro.name} up`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'down', macro.id)}
+                disabled={index === macros.length - 1 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowDown}
+                aria-label={`Move ${macro.name} down`}
+              />
+            </div>
+
+            <div className="flex-1 min-w-0 flex flex-col justify-center ml-2">
+              <h3 className="truncate text-sm font-semibold text-black dark:text-white">
+                {macro.name}
+              </h3>
+              <p className="mt-1 ml-4 text-xs text-slate-500 dark:text-slate-400 overflow-hidden">
+                <span className="flex flex-col items-start gap-1">
+                  {macro.steps.map((step, stepIndex) => {
+                    const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight;
+
+                    return (
+                      <span key={stepIndex} className="inline-flex items-center">
+                        <StepIcon className="mr-1 text-slate-400 dark:text-slate-500 h-3 w-3 flex-shrink-0" />
+                        <span className="bg-slate-50 dark:bg-slate-800 px-2 py-0.5 rounded-md border border-slate-200/50 dark:border-slate-700/50">
+                          {(Array.isArray(step.modifiers) && step.modifiers.length > 0) || (Array.isArray(step.keys) && step.keys.length > 0) ? (
+                            <>
+                              {Array.isArray(step.modifiers) && step.modifiers.map((modifier, idx) => (
+                                <Fragment key={`mod-${idx}`}>
+                                  <span className="font-medium text-slate-600 dark:text-slate-200">
+                                    {modifierDisplayMap[modifier] || modifier}
+                                  </span>
+                                  {idx < step.modifiers.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+
+                              {Array.isArray(step.modifiers) && step.modifiers.length > 0 && Array.isArray(step.keys) && step.keys.length > 0 && (
+                                <span className="text-slate-400 dark:text-slate-600"> + </span>
+                              )}
+
+                              {Array.isArray(step.keys) && step.keys.map((key, idx) => (
+                                <Fragment key={`key-${idx}`}>
+                                  <span className="font-medium text-blue-600 dark:text-blue-400">
+                                    {keyDisplayMap[key] || key}
+                                  </span>
+                                  {idx < step.keys.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+                            </>
+                          ) : (
+                            <span className="font-medium text-slate-500 dark:text-slate-400">Delay only</span>
+                          )}
+                          <span className="ml-1 text-slate-400 dark:text-slate-500">({step.delay}ms)</span>
+                        </span>
+                      </span>
+                    );
+                  })}
+                </span>
+              </p>
+            </div>
+
+            <div className="flex items-center gap-1 ml-4">
+              <Button
+                size="XS"
+                theme="light"
+                LeadingIcon={LuCopy}
+                onClick={() => handleDuplicateMacro(macro)}
+                disabled={actionLoadingId === macro.id}
+                aria-label={`Duplicate macro ${macro.name}`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                LeadingIcon={LuPenLine}
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: Savid
body: done
created_at: 2025-04-03T14:42:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,362 @@
+import { useState } from "react";
+
+import { LuPlus, LuInfo } from "react-icons/lu";
+
+import { KeySequence } from "@/hooks/stores";
+import { Button } from "@/components/Button";
+import { InputFieldWithLabel, FieldError } from "@/components/InputField";
+import Fieldset from "@/components/Fieldset";
+import { MacroStepCard } from "@/components/MacroStepCard";
+import Modal from "@/components/Modal";
+import { DEFAULT_DELAY, MAX_STEPS_PER_MACRO, MAX_KEYS_PER_STEP } from "@/constants/macros";
+
+interface ValidationErrors {
+  name?: string;
+  steps?: Record<number, {
+    keys?: string;
+    modifiers?: string;
+    delay?: string;
+  }>;
+}
+
+interface MacroFormProps {
+  initialData: Partial<KeySequence>;
+  onSubmit: (macro: Partial<KeySequence>) => Promise<void>;
+  onCancel: () => void;
+  isSubmitting?: boolean;
+  submitText?: string;
+  showCancelConfirm?: boolean;
+  onCancelConfirm?: () => void;
+  showDelete?: boolean;
+  onDelete?: () => void;
+  isDeleting?: boolean;
+}
+
+export function MacroForm({
+  initialData,
+  onSubmit,
+  onCancel,
+  isSubmitting = false,
+  submitText = "Save Macro",
+  showCancelConfirm = false,
+  onCancelConfirm,
+  showDelete = false,
+  onDelete,
+  isDeleting = false
+}: MacroFormProps) {
+  const [macro, setMacro] = useState<Partial<KeySequence>>(initialData);
+  const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
+  const [errors, setErrors] = useState<ValidationErrors>({});
+  const [errorMessage, setErrorMessage] = useState<string | null>(null);
+  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+
+  const showTemporaryError = (message: string) => {
+    setErrorMessage(message);
+    setTimeout(() => setErrorMessage(null), 3000);
+  };
+
+  const validateForm = (): boolean => {
+    const newErrors: ValidationErrors = {};
+
+    // Name validation
+    if (!macro.name?.trim()) {
+      newErrors.name = "Name is required";
+    } else if (macro.name.trim().length > 50) {
+      newErrors.name = "Name must be less than 50 characters";
+    }
+  
+    if (!macro.steps?.length) {
+      newErrors.steps = { 0: { keys: "At least one step is required" } };
+    } else {
+      const hasKeyOrModifier = macro.steps.some(step => 
+        (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0
+      );
+
+      if (!hasKeyOrModifier) {
+        newErrors.steps = { 0: { keys: "At least one step must have keys or modifiers" } };
+      }
+    }
+
+    setErrors(newErrors);
+    return Object.keys(newErrors).length === 0;
+  };
+
+  const handleSubmit = async () => {
+    if (!validateForm()) {
+      showTemporaryError("Please fix the validation errors");
+      return;
+    }
+
+    try {
+      await onSubmit(macro);
+    } catch (error) {
+      if (error instanceof Error) {
+        showTemporaryError(error.message);
+      } else {
+        showTemporaryError("An error occurred while saving");
+      }
+    }
+  };
+
+  const handleKeySelect = (stepIndex: number, option: { value: string | null; keys?: string[] }) => {
+    const newSteps = [...(macro.steps || [])];
+    if (!newSteps[stepIndex]) return;
+
+    if (option.keys) {
+      newSteps[stepIndex].keys = option.keys;
+    } else if (option.value) {
+      if (!newSteps[stepIndex].keys) {
+        newSteps[stepIndex].keys = [];
+      }
+      const keysArray = Array.isArray(newSteps[stepIndex].keys) ? newSteps[stepIndex].keys : [];
+      if (keysArray.length >= MAX_KEYS_PER_STEP) {
+        showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`);
+        return;
+      }
+      newSteps[stepIndex].keys = [...keysArray, option.value];
+    }
+    setMacro({ ...macro, steps: newSteps });
+    
+    if (errors.steps?.[stepIndex]?.keys) {
+      const newErrors = { ...errors };
+      delete newErrors.steps?.[stepIndex].keys;
+      if (Object.keys(newErrors.steps?.[stepIndex] || {}).length === 0) {
+        delete newErrors.steps?.[stepIndex];
+      }
+      if (Object.keys(newErrors.steps || {}).length === 0) {
+        delete newErrors.steps;
+      }
+      setErrors(newErrors);
+    }
+  };
+
+  const handleKeyQueryChange = (stepIndex: number, query: string) => {
+    setKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
+  };
+
+  const handleModifierChange = (stepIndex: number, modifiers: string[]) => {
+    const newSteps = [...(macro.steps || [])];
+    newSteps[stepIndex].modifiers = modifiers;
+    setMacro({ ...macro, steps: newSteps });
+    
+    // Clear step errors when modifiers are added
+    if (errors.steps?.[stepIndex]?.keys && modifiers.length > 0) {
+      const newErrors = { ...errors };
+      delete newErrors.steps?.[stepIndex].keys;
+      if (Object.keys(newErrors.steps?.[stepIndex] || {}).length === 0) {
+        delete newErrors.steps?.[stepIndex];
+      }
+      if (Object.keys(newErrors.steps || {}).length === 0) {
+        delete newErrors.steps;
+      }
+      setErrors(newErrors);
+    }
+  };
+
+  const handleDelayChange = (stepIndex: number, delay: number) => {
+    const newSteps = [...(macro.steps || [])];
+    newSteps[stepIndex].delay = delay;
+    setMacro({ ...macro, steps: newSteps });
+  };
+
+  const handleStepMove = (stepIndex: number, direction: 'up' | 'down') => {
+    const newSteps = [...(macro.steps || [])];
+    const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
+    [newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]];
+    setMacro({ ...macro, steps: newSteps });
+  };
+
+  const isMaxStepsReached = (macro.steps?.length || 0) >= MAX_STEPS_PER_MACRO;
+
+  return (
+    <>
+      <div className="space-y-4">
+        <Fieldset>
+          <InputFieldWithLabel
+            type="text"
+            label="Macro Name"
+            placeholder="Macro Name"
+            value={macro.name}
+            error={errors.name}
+            onChange={e => {
+              setMacro(prev => ({ ...prev, name: e.target.value }));
+              if (errors.name) {
+                const newErrors = { ...errors };
+                delete newErrors.name;
+                setErrors(newErrors);
+              }
+            }}
+          />
+        </Fieldset>
+
+        <div>
+          <div className="flex items-center justify-between text-sm">
+            <div className="flex items-center gap-1">
+              <label className="font-medium text-slate-700 dark:text-slate-200">
+                Steps
+              </label>
+              <div className="group relative cursor-pointer">
+                <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+                <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                  <p>Each step is a collection of keys and/or modifiers that will be executed in order. You can add up to a maximum of {MAX_STEPS_PER_MACRO} steps per macro.</p>
+                </div>
+              </div>
+            </div>
+            <span className="text-slate-500 dark:text-slate-400">
+              {macro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps
+            </span>
+          </div>
+          {errors.steps && errors.steps[0]?.keys && (
+            <div className="mt-2">
+              <FieldError error={errors.steps[0].keys} />
+            </div>
+          )}
+          <Fieldset>
+            <div className="mt-2 space-y-4">
+              {(macro.steps || []).map((step, stepIndex) => (
+                <MacroStepCard
+                  key={stepIndex}
+                  step={step}
+                  stepIndex={stepIndex}
+                  onDelete={macro.steps && macro.steps.length > 1 ? () => {
+                    const newSteps = [...(macro.steps || [])];
+                    newSteps.splice(stepIndex, 1);
+                    setMacro(prev => ({ ...prev, steps: newSteps }));
+                  } : undefined}
+                  onMoveUp={() => handleStepMove(stepIndex, 'up')}
+                  onMoveDown={() => handleStepMove(stepIndex, 'down')}
+                  onKeySelect={(option) => handleKeySelect(stepIndex, option)}
+                  onKeyQueryChange={(query) => handleKeyQueryChange(stepIndex, query)}
+                  keyQuery={keyQueries[stepIndex] || ''}
+                  onModifierChange={(modifiers) => handleModifierChange(stepIndex, modifiers)}
+                  onDelayChange={(delay) => handleDelayChange(stepIndex, delay)}
+                  isLastStep={stepIndex === (macro.steps?.length || 0) - 1}
+                />
+              ))}
+            </div>
+          </Fieldset>
+
+          <div className="mt-4">
+            <Button
+              size="MD"
+              theme="light"
+              fullWidth
+              LeadingIcon={LuPlus}
+              text={`Add Step ${isMaxStepsReached ? `(${MAX_STEPS_PER_MACRO} max)` : ''}`}
+              onClick={() => {
+                if (isMaxStepsReached) {
+                  showTemporaryError(`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`);
+                  return;
+                }
+                
+                setMacro(prev => ({
+                  ...prev,
+                  steps: [
+                    ...(prev.steps || []), 
+                    { keys: [], modifiers: [], delay: DEFAULT_DELAY }
+                  ],
+                }));
+                setErrors({});
+              }}
+              disabled={isMaxStepsReached}
+            />
+          </div>
+
+          {errorMessage && (
+            <div className="mt-4">
+              <FieldError error={errorMessage} />
+            </div>
+          )}
+
+          <div className="mt-6 flex items-center justify-between">
+            {showCancelConfirm ? (
+              <div className="flex items-center gap-2">
+                <span className="text-sm text-slate-600 dark:text-slate-400">
+                  Cancel changes?
+                </span>
+                <Button
+                  size="SM"
+                  theme="danger"
+                  text="Yes"
+                  onClick={onCancelConfirm}
+                />
+                <Button
+                  size="SM"
+                  theme="light"
+                  text="No"
+                  onClick={() => onCancel()}
+                />
+              </div>
+            ) : (
+              <>
+                <div className="flex gap-x-2">
+                  <Button
+                    size="SM"
+                    theme="primary"
+                    text={isSubmitting ? "Saving..." : submitText}
+                    onClick={handleSubmit}
+                    disabled={isSubmitting}
+                  />
+                  <Button
+                    size="SM"
+                    theme="light"
+                    text="Cancel"
+                    onClick={onCancel}
+                  />
+                </div>
+                {showDelete && (
+                  <Button
+                    size="SM"
+                    theme="danger"
+                    text={isDeleting ? "Deleting..." : "Delete Macro"}
+                    onClick={() => setShowDeleteConfirm(true)}
+                    disabled={isDeleting}
+                  />
+                )}
+              </>
+            )}
+          </div>
+        </div>
+      </div>
+
+      <Modal
+        open={showDeleteConfirm}
+        onClose={() => setShowDeleteConfirm(false)}
+      >
+        <div className="mx-auto max-w-xl px-4 transition-all duration-300 ease-in-out">
path: ui/src/components/MacroForm.tsx
user: login: Savid
body: made the ConfirmDialog component
created_at: 2025-04-03T14:42:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,362 @@
+import { useState } from "react";
+
+import { LuPlus, LuInfo } from "react-icons/lu";
+
+import { KeySequence } from "@/hooks/stores";
+import { Button } from "@/components/Button";
+import { InputFieldWithLabel, FieldError } from "@/components/InputField";
+import Fieldset from "@/components/Fieldset";
+import { MacroStepCard } from "@/components/MacroStepCard";
+import Modal from "@/components/Modal";
+import { DEFAULT_DELAY, MAX_STEPS_PER_MACRO, MAX_KEYS_PER_STEP } from "@/constants/macros";
+
+interface ValidationErrors {
+  name?: string;
+  steps?: Record<number, {
+    keys?: string;
+    modifiers?: string;
+    delay?: string;
+  }>;
+}
+
+interface MacroFormProps {
+  initialData: Partial<KeySequence>;
+  onSubmit: (macro: Partial<KeySequence>) => Promise<void>;
+  onCancel: () => void;
+  isSubmitting?: boolean;
+  submitText?: string;
+  showCancelConfirm?: boolean;
+  onCancelConfirm?: () => void;
+  showDelete?: boolean;
+  onDelete?: () => void;
+  isDeleting?: boolean;
+}
+
+export function MacroForm({
+  initialData,
+  onSubmit,
+  onCancel,
+  isSubmitting = false,
+  submitText = "Save Macro",
+  showCancelConfirm = false,
+  onCancelConfirm,
+  showDelete = false,
+  onDelete,
+  isDeleting = false
+}: MacroFormProps) {
+  const [macro, setMacro] = useState<Partial<KeySequence>>(initialData);
+  const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
+  const [errors, setErrors] = useState<ValidationErrors>({});
+  const [errorMessage, setErrorMessage] = useState<string | null>(null);
+  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+
+  const showTemporaryError = (message: string) => {
+    setErrorMessage(message);
+    setTimeout(() => setErrorMessage(null), 3000);
+  };
+
+  const validateForm = (): boolean => {
+    const newErrors: ValidationErrors = {};
+
+    // Name validation
+    if (!macro.name?.trim()) {
+      newErrors.name = "Name is required";
+    } else if (macro.name.trim().length > 50) {
+      newErrors.name = "Name must be less than 50 characters";
+    }
+  
+    if (!macro.steps?.length) {
+      newErrors.steps = { 0: { keys: "At least one step is required" } };
+    } else {
+      const hasKeyOrModifier = macro.steps.some(step => 
+        (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0
+      );
+
+      if (!hasKeyOrModifier) {
+        newErrors.steps = { 0: { keys: "At least one step must have keys or modifiers" } };
+      }
+    }
+
+    setErrors(newErrors);
+    return Object.keys(newErrors).length === 0;
+  };
+
+  const handleSubmit = async () => {
+    if (!validateForm()) {
+      showTemporaryError("Please fix the validation errors");
+      return;
+    }
+
+    try {
+      await onSubmit(macro);
+    } catch (error) {
+      if (error instanceof Error) {
+        showTemporaryError(error.message);
+      } else {
+        showTemporaryError("An error occurred while saving");
+      }
+    }
+  };
+
+  const handleKeySelect = (stepIndex: number, option: { value: string | null; keys?: string[] }) => {
+    const newSteps = [...(macro.steps || [])];
+    if (!newSteps[stepIndex]) return;
+
+    if (option.keys) {
+      newSteps[stepIndex].keys = option.keys;
+    } else if (option.value) {
+      if (!newSteps[stepIndex].keys) {
+        newSteps[stepIndex].keys = [];
+      }
+      const keysArray = Array.isArray(newSteps[stepIndex].keys) ? newSteps[stepIndex].keys : [];
+      if (keysArray.length >= MAX_KEYS_PER_STEP) {
+        showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`);
+        return;
+      }
+      newSteps[stepIndex].keys = [...keysArray, option.value];
+    }
+    setMacro({ ...macro, steps: newSteps });
+    
+    if (errors.steps?.[stepIndex]?.keys) {
+      const newErrors = { ...errors };
+      delete newErrors.steps?.[stepIndex].keys;
+      if (Object.keys(newErrors.steps?.[stepIndex] || {}).length === 0) {
+        delete newErrors.steps?.[stepIndex];
+      }
+      if (Object.keys(newErrors.steps || {}).length === 0) {
+        delete newErrors.steps;
+      }
+      setErrors(newErrors);
+    }
+  };
+
+  const handleKeyQueryChange = (stepIndex: number, query: string) => {
+    setKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
+  };
+
+  const handleModifierChange = (stepIndex: number, modifiers: string[]) => {
+    const newSteps = [...(macro.steps || [])];
+    newSteps[stepIndex].modifiers = modifiers;
+    setMacro({ ...macro, steps: newSteps });
+    
+    // Clear step errors when modifiers are added
+    if (errors.steps?.[stepIndex]?.keys && modifiers.length > 0) {
+      const newErrors = { ...errors };
+      delete newErrors.steps?.[stepIndex].keys;
+      if (Object.keys(newErrors.steps?.[stepIndex] || {}).length === 0) {
+        delete newErrors.steps?.[stepIndex];
+      }
+      if (Object.keys(newErrors.steps || {}).length === 0) {
+        delete newErrors.steps;
+      }
+      setErrors(newErrors);
+    }
+  };
+
+  const handleDelayChange = (stepIndex: number, delay: number) => {
+    const newSteps = [...(macro.steps || [])];
+    newSteps[stepIndex].delay = delay;
+    setMacro({ ...macro, steps: newSteps });
+  };
+
+  const handleStepMove = (stepIndex: number, direction: 'up' | 'down') => {
+    const newSteps = [...(macro.steps || [])];
+    const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
+    [newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]];
+    setMacro({ ...macro, steps: newSteps });
+  };
+
+  const isMaxStepsReached = (macro.steps?.length || 0) >= MAX_STEPS_PER_MACRO;
+
+  return (
+    <>
+      <div className="space-y-4">
+        <Fieldset>
+          <InputFieldWithLabel
+            type="text"
+            label="Macro Name"
+            placeholder="Macro Name"
+            value={macro.name}
+            error={errors.name}
+            onChange={e => {
+              setMacro(prev => ({ ...prev, name: e.target.value }));
+              if (errors.name) {
+                const newErrors = { ...errors };
+                delete newErrors.name;
+                setErrors(newErrors);
+              }
+            }}
+          />
+        </Fieldset>
+
+        <div>
+          <div className="flex items-center justify-between text-sm">
+            <div className="flex items-center gap-1">
+              <label className="font-medium text-slate-700 dark:text-slate-200">
+                Steps
+              </label>
+              <div className="group relative cursor-pointer">
+                <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+                <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                  <p>Each step is a collection of keys and/or modifiers that will be executed in order. You can add up to a maximum of {MAX_STEPS_PER_MACRO} steps per macro.</p>
+                </div>
+              </div>
+            </div>
+            <span className="text-slate-500 dark:text-slate-400">
+              {macro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps
+            </span>
+          </div>
+          {errors.steps && errors.steps[0]?.keys && (
+            <div className="mt-2">
+              <FieldError error={errors.steps[0].keys} />
+            </div>
+          )}
+          <Fieldset>
+            <div className="mt-2 space-y-4">
+              {(macro.steps || []).map((step, stepIndex) => (
+                <MacroStepCard
+                  key={stepIndex}
+                  step={step}
+                  stepIndex={stepIndex}
+                  onDelete={macro.steps && macro.steps.length > 1 ? () => {
+                    const newSteps = [...(macro.steps || [])];
+                    newSteps.splice(stepIndex, 1);
+                    setMacro(prev => ({ ...prev, steps: newSteps }));
+                  } : undefined}
+                  onMoveUp={() => handleStepMove(stepIndex, 'up')}
+                  onMoveDown={() => handleStepMove(stepIndex, 'down')}
+                  onKeySelect={(option) => handleKeySelect(stepIndex, option)}
+                  onKeyQueryChange={(query) => handleKeyQueryChange(stepIndex, query)}
+                  keyQuery={keyQueries[stepIndex] || ''}
+                  onModifierChange={(modifiers) => handleModifierChange(stepIndex, modifiers)}
+                  onDelayChange={(delay) => handleDelayChange(stepIndex, delay)}
+                  isLastStep={stepIndex === (macro.steps?.length || 0) - 1}
+                />
+              ))}
+            </div>
+          </Fieldset>
+
+          <div className="mt-4">
+            <Button
+              size="MD"
+              theme="light"
+              fullWidth
+              LeadingIcon={LuPlus}
+              text={`Add Step ${isMaxStepsReached ? `(${MAX_STEPS_PER_MACRO} max)` : ''}`}
+              onClick={() => {
+                if (isMaxStepsReached) {
+                  showTemporaryError(`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`);
+                  return;
+                }
+                
+                setMacro(prev => ({
+                  ...prev,
+                  steps: [
+                    ...(prev.steps || []), 
+                    { keys: [], modifiers: [], delay: DEFAULT_DELAY }
+                  ],
+                }));
+                setErrors({});
+              }}
+              disabled={isMaxStepsReached}
+            />
+          </div>
+
+          {errorMessage && (
+            <div className="mt-4">
+              <FieldError error={errorMessage} />
+            </div>
+          )}
+
+          <div className="mt-6 flex items-center justify-between">
+            {showCancelConfirm ? (
path: ui/src/components/MacroForm.tsx
user: login: Savid
body: good catch, legacy. deleted
created_at: 2025-04-03T14:42:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,246 @@
+import { LuArrowUp, LuArrowDown, LuX, LuInfo } from "react-icons/lu";
+
+import { Button } from "@/components/Button";
+import { Combobox } from "@/components/Combobox";
+import { SelectMenuBasic } from "@/components/SelectMenuBasic";
+import Card from "@/components/Card";
+import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
+import { MAX_KEYS_PER_STEP } from "@/constants/macros";
+
+// Filter out modifier keys since they're handled in the modifiers section
+const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta'];
+
+const keyOptions = Object.keys(keys)
+  .filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
+  .map(key => ({
+    value: key,
+    label: keyDisplayMap[key] || key,
+  }));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers: Record<string, typeof modifierOptions> = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+const PRESET_DELAYS = [
+  { value: "50", label: "50ms" },
+  { value: "100", label: "100ms" },
+  { value: "200", label: "200ms" },
+  { value: "300", label: "300ms" },
+  { value: "500", label: "500ms" },
+  { value: "750", label: "750ms" },
+  { value: "1000", label: "1000ms" },
+  { value: "1500", label: "1500ms" },
+  { value: "2000", label: "2000ms" },
+];
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  onKeySelect: (option: { value: string | null; keys?: string[] }) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+export function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  const getFilteredKeys = () => {
+    const selectedKeys = ensureArray(step.keys);
+    const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value));
+    
+    if (keyQuery === '') {
+      return availableKeys;
+    } else {
+      return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase()));
+    }
+  };
+
+  return (
+    <Card className="p-4">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+              LeadingIcon={LuArrowUp}
+            />
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+              LeadingIcon={LuArrowDown}
+            />
+          </div>
+          <span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+        
+        <div className="flex items-center space-x-2">
+          {onDelete && (
+            <Button
+              size="XS"
+              theme="danger"
+              text="Delete"
+              onClick={onDelete}
+            />
+          )}
+        </div>
+      </div>
+      
+      <div className="space-y-4 mt-2">
+        <div className="w-full flex flex-col gap-2">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Modifiers
+          </label>
path: ui/src/components/MacroStepCard.tsx
user: login: Savid
body: updated
created_at: 2025-04-03T14:43:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,246 @@
+import { LuArrowUp, LuArrowDown, LuX, LuInfo } from "react-icons/lu";
+
+import { Button } from "@/components/Button";
+import { Combobox } from "@/components/Combobox";
+import { SelectMenuBasic } from "@/components/SelectMenuBasic";
+import Card from "@/components/Card";
+import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
+import { MAX_KEYS_PER_STEP } from "@/constants/macros";
+
+// Filter out modifier keys since they're handled in the modifiers section
+const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta'];
+
+const keyOptions = Object.keys(keys)
+  .filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
+  .map(key => ({
+    value: key,
+    label: keyDisplayMap[key] || key,
+  }));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers: Record<string, typeof modifierOptions> = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+const PRESET_DELAYS = [
+  { value: "50", label: "50ms" },
+  { value: "100", label: "100ms" },
+  { value: "200", label: "200ms" },
+  { value: "300", label: "300ms" },
+  { value: "500", label: "500ms" },
+  { value: "750", label: "750ms" },
+  { value: "1000", label: "1000ms" },
+  { value: "1500", label: "1500ms" },
+  { value: "2000", label: "2000ms" },
+];
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  onKeySelect: (option: { value: string | null; keys?: string[] }) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+export function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  const getFilteredKeys = () => {
+    const selectedKeys = ensureArray(step.keys);
+    const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value));
+    
+    if (keyQuery === '') {
+      return availableKeys;
+    } else {
+      return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase()));
+    }
+  };
+
+  return (
+    <Card className="p-4">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+              LeadingIcon={LuArrowUp}
+            />
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+              LeadingIcon={LuArrowDown}
+            />
+          </div>
+          <span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+        
+        <div className="flex items-center space-x-2">
+          {onDelete && (
+            <Button
+              size="XS"
+              theme="danger"
+              text="Delete"
+              onClick={onDelete}
+            />
+          )}
+        </div>
+      </div>
+      
+      <div className="space-y-4 mt-2">
+        <div className="w-full flex flex-col gap-2">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Modifiers
+          </label>
+          <div className="inline-flex flex-wrap gap-3">
+            {Object.entries(groupedModifiers).map(([group, mods]) => (
+              <div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
+                <span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
+                  {group}
+                </span>
+                <div className="flex flex-wrap gap-1">
+                  {mods.map(option => (
+                    <label 
+                      key={option.value} 
+                      className={`flex items-center px-2 py-1 rounded border cursor-pointer text-xs font-medium transition-colors ${
+                        ensureArray(step.modifiers).includes(option.value) 
+                          ? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-200' 
+                          : 'bg-slate-100 border-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700'
+                      }`}
+                    >
+                      <input
+                        type="checkbox"
+                        className="sr-only"
+                        checked={ensureArray(step.modifiers).includes(option.value)}
+                        onChange={e => {
+                          const modifiersArray = ensureArray(step.modifiers);
+                          const newModifiers = e.target.checked
+                            ? [...modifiersArray, option.value]
+                            : modifiersArray.filter(m => m !== option.value);
+                          onModifierChange(newModifiers);
+                        }}
+                      />
+                      {option.label.split(' ')[1] || option.label}
+                    </label>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+              Keys
+            </label>
+            <div className="group relative cursor-pointer">
+              <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+              <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                <p>You can add up to a maximum of {MAX_KEYS_PER_STEP} keys to press per step.</p>
+              </div>
+            </div>
+          </div>
path: ui/src/components/MacroStepCard.tsx
user: login: Savid
body: done
created_at: 2025-04-03T14:43:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+import { useEffect, Fragment, useMemo, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { LuPenLine, LuLoader, LuCopy, LuMoveRight, LuCornerDownRight, LuArrowUp, LuArrowDown } from "react-icons/lu";
+
+import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
+import { SettingsPageHeader } from "@/components/SettingsPageheader";
+import { Button } from "@/components/Button";
+import EmptyCard from "@/components/EmptyCard";
+import Card from "@/components/Card";
+import { MAX_TOTAL_MACROS, COPY_SUFFIX } from "@/constants/macros";
+import { keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings";
+import notifications from "@/notifications";
+import { SettingsItem } from "@/routes/devices.$id.settings";
+
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+export default function SettingsMacrosRoute() {
+  const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore();
+  const navigate = useNavigate();
+  const [actionLoadingId, setActionLoadingId] = useState<string | null>(null);
+  
+  const isMaxMacrosReached = useMemo(() => 
+    macros.length >= MAX_TOTAL_MACROS, 
+    [macros.length]
+  );
+
+  useEffect(() => {
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros]);
+
+  const handleDuplicateMacro = async (macro: KeySequence) => {
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: Savid
body: added, i think it might be over optimisation though
created_at: 2025-04-03T14:44:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+import { useEffect, Fragment, useMemo, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { LuPenLine, LuLoader, LuCopy, LuMoveRight, LuCornerDownRight, LuArrowUp, LuArrowDown } from "react-icons/lu";
+
+import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
+import { SettingsPageHeader } from "@/components/SettingsPageheader";
+import { Button } from "@/components/Button";
+import EmptyCard from "@/components/EmptyCard";
+import Card from "@/components/Card";
+import { MAX_TOTAL_MACROS, COPY_SUFFIX } from "@/constants/macros";
+import { keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings";
+import notifications from "@/notifications";
+import { SettingsItem } from "@/routes/devices.$id.settings";
+
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+export default function SettingsMacrosRoute() {
+  const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore();
+  const navigate = useNavigate();
+  const [actionLoadingId, setActionLoadingId] = useState<string | null>(null);
+  
+  const isMaxMacrosReached = useMemo(() => 
+    macros.length >= MAX_TOTAL_MACROS, 
+    [macros.length]
+  );
+
+  useEffect(() => {
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros]);
+
+  const handleDuplicateMacro = async (macro: KeySequence) => {
+    if (!macro?.id || !macro?.name) {
+      notifications.error("Invalid macro data");
+      return;
+    }
+
+    if (isMaxMacrosReached) {
+      notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    setActionLoadingId(macro.id);
+
+    const newMacroCopy: KeySequence = {
+      ...JSON.parse(JSON.stringify(macro)),
+      id: generateMacroId(),
+      name: `${macro.name} ${COPY_SUFFIX}`,
+      sortOrder: macros.length + 1,
+    };
+
+    try {
+      await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
+      notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to duplicate macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to duplicate macro");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  };
+
+  const handleMoveMacro = async (index: number, direction: 'up' | 'down', macroId: string) => {
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: Savid
body: added, i think it might be over optimisation though
created_at: 2025-04-03T14:44:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+import { useEffect, Fragment, useMemo, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { LuPenLine, LuLoader, LuCopy, LuMoveRight, LuCornerDownRight, LuArrowUp, LuArrowDown } from "react-icons/lu";
+
+import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
+import { SettingsPageHeader } from "@/components/SettingsPageheader";
+import { Button } from "@/components/Button";
+import EmptyCard from "@/components/EmptyCard";
+import Card from "@/components/Card";
+import { MAX_TOTAL_MACROS, COPY_SUFFIX } from "@/constants/macros";
+import { keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings";
+import notifications from "@/notifications";
+import { SettingsItem } from "@/routes/devices.$id.settings";
+
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+export default function SettingsMacrosRoute() {
+  const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore();
+  const navigate = useNavigate();
+  const [actionLoadingId, setActionLoadingId] = useState<string | null>(null);
+  
+  const isMaxMacrosReached = useMemo(() => 
+    macros.length >= MAX_TOTAL_MACROS, 
+    [macros.length]
+  );
+
+  useEffect(() => {
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros]);
+
+  const handleDuplicateMacro = async (macro: KeySequence) => {
+    if (!macro?.id || !macro?.name) {
+      notifications.error("Invalid macro data");
+      return;
+    }
+
+    if (isMaxMacrosReached) {
+      notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    setActionLoadingId(macro.id);
+
+    const newMacroCopy: KeySequence = {
+      ...JSON.parse(JSON.stringify(macro)),
+      id: generateMacroId(),
+      name: `${macro.name} ${COPY_SUFFIX}`,
+      sortOrder: macros.length + 1,
+    };
+
+    try {
+      await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
+      notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to duplicate macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to duplicate macro");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  };
+
+  const handleMoveMacro = async (index: number, direction: 'up' | 'down', macroId: string) => {
+    if (!Array.isArray(macros) || macros.length === 0) {
+      notifications.error("No macros available");
+      return;
+    }
+
+    const newIndex = direction === 'up' ? index - 1 : index + 1;
+    if (newIndex < 0 || newIndex >= macros.length) return;
+
+    setActionLoadingId(macroId);
+
+    try {
+      const newMacros = [...macros];
+      [newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]];
+      const updatedMacros = normalizeSortOrders(newMacros);
+
+      await saveMacros(updatedMacros);
+      notifications.success("Macro order updated successfully");
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to reorder macros: ${error.message}`);
+      } else {
+        notifications.error("Failed to reorder macros");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  };
+
+  const MacroList = useMemo(() => (
+    <div className="space-y-2">
+      {macros.map((macro, index) => (
+        <Card key={macro.id} className="p-2 bg-white dark:bg-slate-800">
+          <div className="flex items-center justify-between">
+            <div className="flex flex-col gap-1 px-2">
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'up', macro.id)}
+                disabled={index === 0 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowUp}
+                aria-label={`Move ${macro.name} up`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'down', macro.id)}
+                disabled={index === macros.length - 1 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowDown}
+                aria-label={`Move ${macro.name} down`}
+              />
+            </div>
+
+            <div className="flex-1 min-w-0 flex flex-col justify-center ml-2">
+              <h3 className="truncate text-sm font-semibold text-black dark:text-white">
+                {macro.name}
+              </h3>
+              <p className="mt-1 ml-4 text-xs text-slate-500 dark:text-slate-400 overflow-hidden">
+                <span className="flex flex-col items-start gap-1">
+                  {macro.steps.map((step, stepIndex) => {
+                    const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight;
+
+                    return (
+                      <span key={stepIndex} className="inline-flex items-center">
+                        <StepIcon className="mr-1 text-slate-400 dark:text-slate-500 h-3 w-3 flex-shrink-0" />
+                        <span className="bg-slate-50 dark:bg-slate-800 px-2 py-0.5 rounded-md border border-slate-200/50 dark:border-slate-700/50">
+                          {(Array.isArray(step.modifiers) && step.modifiers.length > 0) || (Array.isArray(step.keys) && step.keys.length > 0) ? (
+                            <>
+                              {Array.isArray(step.modifiers) && step.modifiers.map((modifier, idx) => (
+                                <Fragment key={`mod-${idx}`}>
+                                  <span className="font-medium text-slate-600 dark:text-slate-200">
+                                    {modifierDisplayMap[modifier] || modifier}
+                                  </span>
+                                  {idx < step.modifiers.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+
+                              {Array.isArray(step.modifiers) && step.modifiers.length > 0 && Array.isArray(step.keys) && step.keys.length > 0 && (
+                                <span className="text-slate-400 dark:text-slate-600"> + </span>
+                              )}
+
+                              {Array.isArray(step.keys) && step.keys.map((key, idx) => (
+                                <Fragment key={`key-${idx}`}>
+                                  <span className="font-medium text-blue-600 dark:text-blue-400">
+                                    {keyDisplayMap[key] || key}
+                                  </span>
+                                  {idx < step.keys.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+                            </>
+                          ) : (
+                            <span className="font-medium text-slate-500 dark:text-slate-400">Delay only</span>
+                          )}
+                          <span className="ml-1 text-slate-400 dark:text-slate-500">({step.delay}ms)</span>
+                        </span>
+                      </span>
+                    );
+                  })}
+                </span>
+              </p>
+            </div>
+
+            <div className="flex items-center gap-1 ml-4">
+              <Button
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: Savid
body: added
created_at: 2025-04-03T14:45:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+import { useEffect, Fragment, useMemo, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { LuPenLine, LuLoader, LuCopy, LuMoveRight, LuCornerDownRight, LuArrowUp, LuArrowDown } from "react-icons/lu";
+
+import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
+import { SettingsPageHeader } from "@/components/SettingsPageheader";
+import { Button } from "@/components/Button";
+import EmptyCard from "@/components/EmptyCard";
+import Card from "@/components/Card";
+import { MAX_TOTAL_MACROS, COPY_SUFFIX } from "@/constants/macros";
+import { keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings";
+import notifications from "@/notifications";
+import { SettingsItem } from "@/routes/devices.$id.settings";
+
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+export default function SettingsMacrosRoute() {
+  const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore();
+  const navigate = useNavigate();
+  const [actionLoadingId, setActionLoadingId] = useState<string | null>(null);
+  
+  const isMaxMacrosReached = useMemo(() => 
+    macros.length >= MAX_TOTAL_MACROS, 
+    [macros.length]
+  );
+
+  useEffect(() => {
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros]);
+
+  const handleDuplicateMacro = async (macro: KeySequence) => {
+    if (!macro?.id || !macro?.name) {
+      notifications.error("Invalid macro data");
+      return;
+    }
+
+    if (isMaxMacrosReached) {
+      notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    setActionLoadingId(macro.id);
+
+    const newMacroCopy: KeySequence = {
+      ...JSON.parse(JSON.stringify(macro)),
+      id: generateMacroId(),
+      name: `${macro.name} ${COPY_SUFFIX}`,
+      sortOrder: macros.length + 1,
+    };
+
+    try {
+      await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
+      notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to duplicate macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to duplicate macro");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  };
+
+  const handleMoveMacro = async (index: number, direction: 'up' | 'down', macroId: string) => {
+    if (!Array.isArray(macros) || macros.length === 0) {
+      notifications.error("No macros available");
+      return;
+    }
+
+    const newIndex = direction === 'up' ? index - 1 : index + 1;
+    if (newIndex < 0 || newIndex >= macros.length) return;
+
+    setActionLoadingId(macroId);
+
+    try {
+      const newMacros = [...macros];
+      [newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]];
+      const updatedMacros = normalizeSortOrders(newMacros);
+
+      await saveMacros(updatedMacros);
+      notifications.success("Macro order updated successfully");
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to reorder macros: ${error.message}`);
+      } else {
+        notifications.error("Failed to reorder macros");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  };
+
+  const MacroList = useMemo(() => (
+    <div className="space-y-2">
+      {macros.map((macro, index) => (
+        <Card key={macro.id} className="p-2 bg-white dark:bg-slate-800">
+          <div className="flex items-center justify-between">
+            <div className="flex flex-col gap-1 px-2">
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'up', macro.id)}
+                disabled={index === 0 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowUp}
+                aria-label={`Move ${macro.name} up`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'down', macro.id)}
+                disabled={index === macros.length - 1 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowDown}
+                aria-label={`Move ${macro.name} down`}
+              />
+            </div>
+
+            <div className="flex-1 min-w-0 flex flex-col justify-center ml-2">
+              <h3 className="truncate text-sm font-semibold text-black dark:text-white">
+                {macro.name}
+              </h3>
+              <p className="mt-1 ml-4 text-xs text-slate-500 dark:text-slate-400 overflow-hidden">
+                <span className="flex flex-col items-start gap-1">
+                  {macro.steps.map((step, stepIndex) => {
+                    const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight;
+
+                    return (
+                      <span key={stepIndex} className="inline-flex items-center">
+                        <StepIcon className="mr-1 text-slate-400 dark:text-slate-500 h-3 w-3 flex-shrink-0" />
+                        <span className="bg-slate-50 dark:bg-slate-800 px-2 py-0.5 rounded-md border border-slate-200/50 dark:border-slate-700/50">
+                          {(Array.isArray(step.modifiers) && step.modifiers.length > 0) || (Array.isArray(step.keys) && step.keys.length > 0) ? (
+                            <>
+                              {Array.isArray(step.modifiers) && step.modifiers.map((modifier, idx) => (
+                                <Fragment key={`mod-${idx}`}>
+                                  <span className="font-medium text-slate-600 dark:text-slate-200">
+                                    {modifierDisplayMap[modifier] || modifier}
+                                  </span>
+                                  {idx < step.modifiers.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+
+                              {Array.isArray(step.modifiers) && step.modifiers.length > 0 && Array.isArray(step.keys) && step.keys.length > 0 && (
+                                <span className="text-slate-400 dark:text-slate-600"> + </span>
+                              )}
+
+                              {Array.isArray(step.keys) && step.keys.map((key, idx) => (
+                                <Fragment key={`key-${idx}`}>
+                                  <span className="font-medium text-blue-600 dark:text-blue-400">
+                                    {keyDisplayMap[key] || key}
+                                  </span>
+                                  {idx < step.keys.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+                            </>
+                          ) : (
+                            <span className="font-medium text-slate-500 dark:text-slate-400">Delay only</span>
+                          )}
+                          <span className="ml-1 text-slate-400 dark:text-slate-500">({step.delay}ms)</span>
+                        </span>
+                      </span>
+                    );
+                  })}
+                </span>
+              </p>
+            </div>
+
+            <div className="flex items-center gap-1 ml-4">
+              <Button
+                size="XS"
+                theme="light"
+                LeadingIcon={LuCopy}
+                onClick={() => handleDuplicateMacro(macro)}
+                disabled={actionLoadingId === macro.id}
+                aria-label={`Duplicate macro ${macro.name}`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                LeadingIcon={LuPenLine}
+                onClick={() => navigate(`${macro.id}/edit`)}
+                disabled={actionLoadingId === macro.id}
+                aria-label={`Edit macro ${macro.name}`}
+              />
+            </div>
+          </div>
+        </Card>
+      ))}
+    </div>
+  ), [macros, actionLoadingId]);
+
+  return (
+    <div className="space-y-4">
+      <SettingsPageHeader
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: Savid
body: done
created_at: 2025-04-03T14:45:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,246 @@
+import { LuArrowUp, LuArrowDown, LuX, LuInfo } from "react-icons/lu";
+
+import { Button } from "@/components/Button";
+import { Combobox } from "@/components/Combobox";
+import { SelectMenuBasic } from "@/components/SelectMenuBasic";
+import Card from "@/components/Card";
+import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
+import { MAX_KEYS_PER_STEP } from "@/constants/macros";
+
+// Filter out modifier keys since they're handled in the modifiers section
+const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta'];
+
+const keyOptions = Object.keys(keys)
+  .filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
+  .map(key => ({
+    value: key,
+    label: keyDisplayMap[key] || key,
+  }));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers: Record<string, typeof modifierOptions> = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+const PRESET_DELAYS = [
+  { value: "50", label: "50ms" },
+  { value: "100", label: "100ms" },
+  { value: "200", label: "200ms" },
+  { value: "300", label: "300ms" },
+  { value: "500", label: "500ms" },
+  { value: "750", label: "750ms" },
+  { value: "1000", label: "1000ms" },
+  { value: "1500", label: "1500ms" },
+  { value: "2000", label: "2000ms" },
+];
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  onKeySelect: (option: { value: string | null; keys?: string[] }) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+export function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  const getFilteredKeys = () => {
+    const selectedKeys = ensureArray(step.keys);
+    const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value));
+    
+    if (keyQuery === '') {
+      return availableKeys;
+    } else {
+      return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase()));
+    }
+  };
+
+  return (
+    <Card className="p-4">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+              LeadingIcon={LuArrowUp}
+            />
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+              LeadingIcon={LuArrowDown}
+            />
+          </div>
+          <span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+        
+        <div className="flex items-center space-x-2">
+          {onDelete && (
+            <Button
+              size="XS"
+              theme="danger"
+              text="Delete"
+              onClick={onDelete}
+            />
+          )}
+        </div>
+      </div>
+      
+      <div className="space-y-4 mt-2">
+        <div className="w-full flex flex-col gap-2">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Modifiers
+          </label>
+          <div className="inline-flex flex-wrap gap-3">
+            {Object.entries(groupedModifiers).map(([group, mods]) => (
+              <div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
+                <span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
+                  {group}
+                </span>
+                <div className="flex flex-wrap gap-1">
+                  {mods.map(option => (
+                    <label 
+                      key={option.value} 
+                      className={`flex items-center px-2 py-1 rounded border cursor-pointer text-xs font-medium transition-colors ${
+                        ensureArray(step.modifiers).includes(option.value) 
+                          ? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-200' 
+                          : 'bg-slate-100 border-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700'
+                      }`}
+                    >
+                      <input
+                        type="checkbox"
+                        className="sr-only"
+                        checked={ensureArray(step.modifiers).includes(option.value)}
+                        onChange={e => {
+                          const modifiersArray = ensureArray(step.modifiers);
+                          const newModifiers = e.target.checked
+                            ? [...modifiersArray, option.value]
+                            : modifiersArray.filter(m => m !== option.value);
+                          onModifierChange(newModifiers);
+                        }}
+                      />
+                      {option.label.split(' ')[1] || option.label}
+                    </label>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+              Keys
+            </label>
+            <div className="group relative cursor-pointer">
+              <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+              <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                <p>You can add up to a maximum of {MAX_KEYS_PER_STEP} keys to press per step.</p>
+              </div>
+            </div>
+          </div>
+          <div className="flex flex-wrap gap-1 pb-2">
+            {ensureArray(step.keys).map((key, keyIndex) => (
+              <span
+                key={keyIndex}
+                className="inline-flex items-center rounded-md bg-blue-100 px-1 text-xs font-medium text-blue-800 dark:bg-blue-900/40 dark:text-blue-200"
path: ui/src/components/MacroStepCard.tsx
user: login: Savid
body: updated
created_at: 2025-04-05T13:58:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,246 @@
+import { LuArrowUp, LuArrowDown, LuX, LuInfo } from "react-icons/lu";
+
+import { Button } from "@/components/Button";
+import { Combobox } from "@/components/Combobox";
+import { SelectMenuBasic } from "@/components/SelectMenuBasic";
+import Card from "@/components/Card";
+import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
+import { MAX_KEYS_PER_STEP } from "@/constants/macros";
+
+// Filter out modifier keys since they're handled in the modifiers section
+const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta'];
+
+const keyOptions = Object.keys(keys)
+  .filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
+  .map(key => ({
+    value: key,
+    label: keyDisplayMap[key] || key,
+  }));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers: Record<string, typeof modifierOptions> = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+const PRESET_DELAYS = [
+  { value: "50", label: "50ms" },
+  { value: "100", label: "100ms" },
+  { value: "200", label: "200ms" },
+  { value: "300", label: "300ms" },
+  { value: "500", label: "500ms" },
+  { value: "750", label: "750ms" },
+  { value: "1000", label: "1000ms" },
+  { value: "1500", label: "1500ms" },
+  { value: "2000", label: "2000ms" },
+];
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  onKeySelect: (option: { value: string | null; keys?: string[] }) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+export function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  const getFilteredKeys = () => {
+    const selectedKeys = ensureArray(step.keys);
+    const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value));
+    
+    if (keyQuery === '') {
+      return availableKeys;
+    } else {
+      return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase()));
+    }
+  };
+
+  return (
+    <Card className="p-4">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+              LeadingIcon={LuArrowUp}
+            />
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+              LeadingIcon={LuArrowDown}
+            />
+          </div>
+          <span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+        
+        <div className="flex items-center space-x-2">
+          {onDelete && (
+            <Button
+              size="XS"
+              theme="danger"
+              text="Delete"
+              onClick={onDelete}
+            />
+          )}
+        </div>
+      </div>
+      
+      <div className="space-y-4 mt-2">
+        <div className="w-full flex flex-col gap-2">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Modifiers
+          </label>
+          <div className="inline-flex flex-wrap gap-3">
+            {Object.entries(groupedModifiers).map(([group, mods]) => (
+              <div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
+                <span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
+                  {group}
+                </span>
+                <div className="flex flex-wrap gap-1">
+                  {mods.map(option => (
+                    <label 
+                      key={option.value} 
+                      className={`flex items-center px-2 py-1 rounded border cursor-pointer text-xs font-medium transition-colors ${
+                        ensureArray(step.modifiers).includes(option.value) 
+                          ? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-200' 
+                          : 'bg-slate-100 border-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700'
+                      }`}
+                    >
+                      <input
+                        type="checkbox"
+                        className="sr-only"
+                        checked={ensureArray(step.modifiers).includes(option.value)}
+                        onChange={e => {
+                          const modifiersArray = ensureArray(step.modifiers);
+                          const newModifiers = e.target.checked
+                            ? [...modifiersArray, option.value]
+                            : modifiersArray.filter(m => m !== option.value);
+                          onModifierChange(newModifiers);
+                        }}
+                      />
+                      {option.label.split(' ')[1] || option.label}
+                    </label>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+              Keys
+            </label>
+            <div className="group relative cursor-pointer">
+              <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+              <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                <p>You can add up to a maximum of {MAX_KEYS_PER_STEP} keys to press per step.</p>
+              </div>
+            </div>
+          </div>
+          <div className="flex flex-wrap gap-1 pb-2">
+            {ensureArray(step.keys).map((key, keyIndex) => (
+              <span
+                key={keyIndex}
+                className="inline-flex items-center rounded-md bg-blue-100 px-1 text-xs font-medium text-blue-800 dark:bg-blue-900/40 dark:text-blue-200"
+              >
+                <span className="px-1">
+                  {keyDisplayMap[key] || key}
+                </span>
+                <Button
+                  size="XS"
+                  className=""
+                  theme="blank"
+                  onClick={() => {
+                    const newKeys = ensureArray(step.keys).filter((_, i) => i !== keyIndex);
+                    onKeySelect({ value: null, keys: newKeys });
+                  }}
+                  LeadingIcon={LuX}
+                />
+              </span>
+            ))}
+          </div>
+          <div className="relative w-full">
+            <Combobox
+              onChange={(value: { value: string; label: string }) => onKeySelect(value)}
+              displayValue={() => keyQuery}
+              onInputChange={onKeyQueryChange}
+              options={getFilteredKeys}
+              disabledMessage="Max keys reached"
+              size="SM"
+              immediate
+              disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP}
+              placeholder={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP ? "Max keys reached" : "Search for key..."}
+              emptyMessage="No matching keys found"
+            />
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+              Step Duration
+            </label>
+            <div className="group relative cursor-pointer">
+              <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+              <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                <p>The time to wait after pressing the keys in this step before moving to the next step. This helps ensure reliable key presses when automating keyboard input.</p>
+              </div>
+            </div>
+          </div>
path: ui/src/components/MacroStepCard.tsx
user: login: Savid
body: updated
created_at: 2025-04-05T13:59:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,126 @@
+import { useRef } from "react";
+import clsx from "clsx";
+import { Combobox as HeadlessCombobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
+import { cva } from "@/cva.config";
+import Card from "./Card";
+
+export interface ComboboxOption {
+  value: string;
+  label: string;
+}
+
+const sizes = {
+  XS: "h-[24.5px] pl-3 pr-8 text-xs",
+  SM: "h-[32px] pl-3 pr-8 text-[13px]",
+  MD: "h-[40px] pl-4 pr-10 text-sm",
+  LG: "h-[48px] pl-4 pr-10 px-5 text-base",
+} as const;
+
+const comboboxVariants = cva({
+  variants: { size: sizes },
+});
+
+type BaseProps = React.ComponentProps<typeof HeadlessCombobox>;
+
+interface ComboboxProps extends Omit<BaseProps, 'displayValue'> {
+  displayValue: (option: ComboboxOption) => string;
+  onInputChange: (option: string) => void;
+  options: () => ComboboxOption[];
+  placeholder?: string;
+  emptyMessage?: string;
+  size?: keyof typeof sizes;
+  disabledMessage?: string;
+}
+
+export function Combobox({
+  onInputChange,
+  displayValue,
+  options,
+  disabled = false,
+  placeholder = "Search...",
+  emptyMessage = "No results found",
+  size = "MD",
+  onChange,
+  disabledMessage = "Input disabled",
+  ...otherProps
+}: ComboboxProps) {
+  const inputRef = useRef<HTMLInputElement>(null);
+  const classes = comboboxVariants({ size });
+
+  const handleChange = (value: unknown) => {
+    if (onChange) {
+      onChange(value);
+      inputRef.current?.blur();
+    }
+  };
+
+  return (
+    <HeadlessCombobox 
+      onChange={handleChange}
+      {...otherProps}
+    >
+      {() => (
+        <>
+          <Card className="w-auto !border border-solid !border-slate-800/30 shadow outline-0 dark:!border-slate-300/30">
+            <ComboboxInput
+            ref={inputRef}
+            className={clsx(
+              classes,
+              
+              // General styling
+              "block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0 transition duration-300",
+              
+              // Hover
+              "hover:bg-blue-50/80 active:bg-blue-100/60",
+              
+              // Dark mode
+              "dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700 dark:active:bg-slate-800/60",
+              
+              // Focus
+              "focus:outline-blue-600 focus:ring-2 focus:ring-blue-700 focus:ring-offset-2 dark:focus:outline-blue-500 dark:focus:ring-blue-500",
+              
+              // Disabled
+              disabled && "pointer-events-none select-none bg-slate-50 text-slate-500/80 dark:bg-slate-800 dark:text-slate-400/80 disabled:hover:bg-white dark:disabled:hover:bg-slate-800"
+            )}
+            placeholder={disabled ? disabledMessage : placeholder}
+            displayValue={displayValue}
+            onChange={(event) => onInputChange(event.target.value)}
+            disabled={disabled}
+            />
+          </Card>
+          
+          {options().length > 0 && (
+            <ComboboxOptions className="absolute left-0 z-[100] mt-1 w-full max-h-60 overflow-auto rounded-md bg-white py-1 text-sm shadow-lg ring-1 ring-black/5 dark:bg-slate-800 dark:ring-slate-700 hide-scrollbar">
path: ui/src/components/Combobox.tsx
user: login: Savid
body: cleaned up the combobox component, also clearing search input in the MacroStepCard component when a key is selected. not sure if this is a good behaviour? if you're selecting a key, then you're probably going search for another key straight away or blur the input
created_at: 2025-04-05T14:00:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,246 @@
+import { LuArrowUp, LuArrowDown, LuX, LuInfo } from "react-icons/lu";
+
+import { Button } from "@/components/Button";
+import { Combobox } from "@/components/Combobox";
+import { SelectMenuBasic } from "@/components/SelectMenuBasic";
+import Card from "@/components/Card";
+import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
+import { MAX_KEYS_PER_STEP } from "@/constants/macros";
+
+// Filter out modifier keys since they're handled in the modifiers section
+const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta'];
+
+const keyOptions = Object.keys(keys)
+  .filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
+  .map(key => ({
+    value: key,
+    label: keyDisplayMap[key] || key,
+  }));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers: Record<string, typeof modifierOptions> = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+const PRESET_DELAYS = [
+  { value: "50", label: "50ms" },
+  { value: "100", label: "100ms" },
+  { value: "200", label: "200ms" },
+  { value: "300", label: "300ms" },
+  { value: "500", label: "500ms" },
+  { value: "750", label: "750ms" },
+  { value: "1000", label: "1000ms" },
+  { value: "1500", label: "1500ms" },
+  { value: "2000", label: "2000ms" },
+];
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  onKeySelect: (option: { value: string | null; keys?: string[] }) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+export function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  const getFilteredKeys = () => {
+    const selectedKeys = ensureArray(step.keys);
+    const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value));
+    
+    if (keyQuery === '') {
+      return availableKeys;
+    } else {
+      return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase()));
+    }
+  };
+
+  return (
+    <Card className="p-4">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+              LeadingIcon={LuArrowUp}
+            />
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+              LeadingIcon={LuArrowDown}
+            />
+          </div>
+          <span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+        
+        <div className="flex items-center space-x-2">
+          {onDelete && (
+            <Button
+              size="XS"
+              theme="danger"
path: ui/src/components/MacroStepCard.tsx
user: login: Savid
body: done
created_at: 2025-04-05T14:00:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,246 @@
+import { LuArrowUp, LuArrowDown, LuX, LuInfo } from "react-icons/lu";
+
+import { Button } from "@/components/Button";
+import { Combobox } from "@/components/Combobox";
+import { SelectMenuBasic } from "@/components/SelectMenuBasic";
+import Card from "@/components/Card";
+import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
+import { MAX_KEYS_PER_STEP } from "@/constants/macros";
+
+// Filter out modifier keys since they're handled in the modifiers section
+const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta'];
+
+const keyOptions = Object.keys(keys)
+  .filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
+  .map(key => ({
+    value: key,
+    label: keyDisplayMap[key] || key,
+  }));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers: Record<string, typeof modifierOptions> = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+const PRESET_DELAYS = [
+  { value: "50", label: "50ms" },
+  { value: "100", label: "100ms" },
+  { value: "200", label: "200ms" },
+  { value: "300", label: "300ms" },
+  { value: "500", label: "500ms" },
+  { value: "750", label: "750ms" },
+  { value: "1000", label: "1000ms" },
+  { value: "1500", label: "1500ms" },
+  { value: "2000", label: "2000ms" },
+];
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  onKeySelect: (option: { value: string | null; keys?: string[] }) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+export function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  const getFilteredKeys = () => {
+    const selectedKeys = ensureArray(step.keys);
+    const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value));
+    
+    if (keyQuery === '') {
+      return availableKeys;
+    } else {
+      return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase()));
+    }
+  };
+
+  return (
+    <Card className="p-4">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+              LeadingIcon={LuArrowUp}
+            />
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+              LeadingIcon={LuArrowDown}
+            />
+          </div>
+          <span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
path: ui/src/components/MacroStepCard.tsx
user: login: Savid
body: done
created_at: 2025-04-05T14:00:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,246 @@
+import { LuArrowUp, LuArrowDown, LuX, LuInfo } from "react-icons/lu";
+
+import { Button } from "@/components/Button";
+import { Combobox } from "@/components/Combobox";
+import { SelectMenuBasic } from "@/components/SelectMenuBasic";
+import Card from "@/components/Card";
+import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
+import { MAX_KEYS_PER_STEP } from "@/constants/macros";
+
+// Filter out modifier keys since they're handled in the modifiers section
+const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta'];
+
+const keyOptions = Object.keys(keys)
+  .filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
+  .map(key => ({
+    value: key,
+    label: keyDisplayMap[key] || key,
+  }));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers: Record<string, typeof modifierOptions> = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+const PRESET_DELAYS = [
+  { value: "50", label: "50ms" },
+  { value: "100", label: "100ms" },
+  { value: "200", label: "200ms" },
+  { value: "300", label: "300ms" },
+  { value: "500", label: "500ms" },
+  { value: "750", label: "750ms" },
+  { value: "1000", label: "1000ms" },
+  { value: "1500", label: "1500ms" },
+  { value: "2000", label: "2000ms" },
+];
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  onKeySelect: (option: { value: string | null; keys?: string[] }) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+export function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  const getFilteredKeys = () => {
+    const selectedKeys = ensureArray(step.keys);
+    const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value));
+    
+    if (keyQuery === '') {
+      return availableKeys;
+    } else {
+      return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase()));
+    }
+  };
+
+  return (
+    <Card className="p-4">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+              LeadingIcon={LuArrowUp}
+            />
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+              LeadingIcon={LuArrowDown}
+            />
+          </div>
+          <span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+        
+        <div className="flex items-center space-x-2">
+          {onDelete && (
+            <Button
+              size="XS"
+              theme="danger"
+              text="Delete"
+              onClick={onDelete}
+            />
+          )}
+        </div>
+      </div>
+      
+      <div className="space-y-4 mt-2">
+        <div className="w-full flex flex-col gap-2">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Modifiers
+          </label>
+          <div className="inline-flex flex-wrap gap-3">
+            {Object.entries(groupedModifiers).map(([group, mods]) => (
+              <div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
+                <span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
+                  {group}
+                </span>
+                <div className="flex flex-wrap gap-1">
+                  {mods.map(option => (
+                    <label 
+                      key={option.value} 
+                      className={`flex items-center px-2 py-1 rounded border cursor-pointer text-xs font-medium transition-colors ${
+                        ensureArray(step.modifiers).includes(option.value) 
+                          ? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-200' 
+                          : 'bg-slate-100 border-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700'
+                      }`}
+                    >
+                      <input
+                        type="checkbox"
+                        className="sr-only"
+                        checked={ensureArray(step.modifiers).includes(option.value)}
+                        onChange={e => {
+                          const modifiersArray = ensureArray(step.modifiers);
+                          const newModifiers = e.target.checked
+                            ? [...modifiersArray, option.value]
+                            : modifiersArray.filter(m => m !== option.value);
+                          onModifierChange(newModifiers);
+                        }}
+                      />
+                      {option.label.split(' ')[1] || option.label}
+                    </label>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+              Keys
+            </label>
+            <div className="group relative cursor-pointer">
+              <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+              <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                <p>You can add up to a maximum of {MAX_KEYS_PER_STEP} keys to press per step.</p>
+              </div>
+            </div>
+          </div>
+          <div className="flex flex-wrap gap-1 pb-2">
path: ui/src/components/MacroStepCard.tsx
user: login: Savid
body: updated
created_at: 2025-04-05T14:01:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+import { useEffect, Fragment, useMemo, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { LuPenLine, LuLoader, LuCopy, LuMoveRight, LuCornerDownRight, LuArrowUp, LuArrowDown } from "react-icons/lu";
+
+import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
+import { SettingsPageHeader } from "@/components/SettingsPageheader";
+import { Button } from "@/components/Button";
+import EmptyCard from "@/components/EmptyCard";
+import Card from "@/components/Card";
+import { MAX_TOTAL_MACROS, COPY_SUFFIX } from "@/constants/macros";
+import { keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings";
+import notifications from "@/notifications";
+import { SettingsItem } from "@/routes/devices.$id.settings";
+
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+export default function SettingsMacrosRoute() {
+  const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore();
+  const navigate = useNavigate();
+  const [actionLoadingId, setActionLoadingId] = useState<string | null>(null);
+  
+  const isMaxMacrosReached = useMemo(() => 
+    macros.length >= MAX_TOTAL_MACROS, 
+    [macros.length]
+  );
+
+  useEffect(() => {
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros]);
+
+  const handleDuplicateMacro = async (macro: KeySequence) => {
+    if (!macro?.id || !macro?.name) {
+      notifications.error("Invalid macro data");
+      return;
+    }
+
+    if (isMaxMacrosReached) {
+      notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    setActionLoadingId(macro.id);
+
+    const newMacroCopy: KeySequence = {
+      ...JSON.parse(JSON.stringify(macro)),
+      id: generateMacroId(),
+      name: `${macro.name} ${COPY_SUFFIX}`,
+      sortOrder: macros.length + 1,
+    };
+
+    try {
+      await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
+      notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to duplicate macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to duplicate macro");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  };
+
+  const handleMoveMacro = async (index: number, direction: 'up' | 'down', macroId: string) => {
+    if (!Array.isArray(macros) || macros.length === 0) {
+      notifications.error("No macros available");
+      return;
+    }
+
+    const newIndex = direction === 'up' ? index - 1 : index + 1;
+    if (newIndex < 0 || newIndex >= macros.length) return;
+
+    setActionLoadingId(macroId);
+
+    try {
+      const newMacros = [...macros];
+      [newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]];
+      const updatedMacros = normalizeSortOrders(newMacros);
+
+      await saveMacros(updatedMacros);
+      notifications.success("Macro order updated successfully");
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to reorder macros: ${error.message}`);
+      } else {
+        notifications.error("Failed to reorder macros");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  };
+
+  const MacroList = useMemo(() => (
+    <div className="space-y-2">
+      {macros.map((macro, index) => (
+        <Card key={macro.id} className="p-2 bg-white dark:bg-slate-800">
+          <div className="flex items-center justify-between">
+            <div className="flex flex-col gap-1 px-2">
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'up', macro.id)}
+                disabled={index === 0 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowUp}
+                aria-label={`Move ${macro.name} up`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'down', macro.id)}
+                disabled={index === macros.length - 1 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowDown}
+                aria-label={`Move ${macro.name} down`}
+              />
+            </div>
+
+            <div className="flex-1 min-w-0 flex flex-col justify-center ml-2">
+              <h3 className="truncate text-sm font-semibold text-black dark:text-white">
+                {macro.name}
+              </h3>
+              <p className="mt-1 ml-4 text-xs text-slate-500 dark:text-slate-400 overflow-hidden">
+                <span className="flex flex-col items-start gap-1">
+                  {macro.steps.map((step, stepIndex) => {
+                    const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight;
+
+                    return (
+                      <span key={stepIndex} className="inline-flex items-center">
+                        <StepIcon className="mr-1 text-slate-400 dark:text-slate-500 h-3 w-3 flex-shrink-0" />
+                        <span className="bg-slate-50 dark:bg-slate-800 px-2 py-0.5 rounded-md border border-slate-200/50 dark:border-slate-700/50">
+                          {(Array.isArray(step.modifiers) && step.modifiers.length > 0) || (Array.isArray(step.keys) && step.keys.length > 0) ? (
+                            <>
+                              {Array.isArray(step.modifiers) && step.modifiers.map((modifier, idx) => (
+                                <Fragment key={`mod-${idx}`}>
+                                  <span className="font-medium text-slate-600 dark:text-slate-200">
+                                    {modifierDisplayMap[modifier] || modifier}
+                                  </span>
+                                  {idx < step.modifiers.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+
+                              {Array.isArray(step.modifiers) && step.modifiers.length > 0 && Array.isArray(step.keys) && step.keys.length > 0 && (
+                                <span className="text-slate-400 dark:text-slate-600"> + </span>
+                              )}
+
+                              {Array.isArray(step.keys) && step.keys.map((key, idx) => (
+                                <Fragment key={`key-${idx}`}>
+                                  <span className="font-medium text-blue-600 dark:text-blue-400">
+                                    {keyDisplayMap[key] || key}
+                                  </span>
+                                  {idx < step.keys.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+                            </>
+                          ) : (
+                            <span className="font-medium text-slate-500 dark:text-slate-400">Delay only</span>
+                          )}
+                          <span className="ml-1 text-slate-400 dark:text-slate-500">({step.delay}ms)</span>
+                        </span>
+                      </span>
+                    );
+                  })}
+                </span>
+              </p>
+            </div>
+
+            <div className="flex items-center gap-1 ml-4">
+              <Button
+                size="XS"
+                theme="light"
+                LeadingIcon={LuCopy}
+                onClick={() => handleDuplicateMacro(macro)}
+                disabled={actionLoadingId === macro.id}
+                aria-label={`Duplicate macro ${macro.name}`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                LeadingIcon={LuPenLine}
+                onClick={() => navigate(`${macro.id}/edit`)}
+                disabled={actionLoadingId === macro.id}
+                aria-label={`Edit macro ${macro.name}`}
+              />
+            </div>
+          </div>
+        </Card>
+      ))}
+    </div>
+  ), [macros, actionLoadingId]);
+
+  return (
+    <div className="space-y-4">
+      <SettingsPageHeader
+        title="Keyboard Macros"
+        description="Create and manage keyboard macros for quick actions"
+      />
+      <div className="flex items-center justify-between mb-4">
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: Savid
body: i reworked this section, so might not apply anymore
created_at: 2025-04-05T14:01:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+import { useEffect, Fragment, useMemo, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { LuPenLine, LuLoader, LuCopy, LuMoveRight, LuCornerDownRight, LuArrowUp, LuArrowDown } from "react-icons/lu";
+
+import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
+import { SettingsPageHeader } from "@/components/SettingsPageheader";
+import { Button } from "@/components/Button";
+import EmptyCard from "@/components/EmptyCard";
+import Card from "@/components/Card";
+import { MAX_TOTAL_MACROS, COPY_SUFFIX } from "@/constants/macros";
+import { keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings";
+import notifications from "@/notifications";
+import { SettingsItem } from "@/routes/devices.$id.settings";
+
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+export default function SettingsMacrosRoute() {
+  const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore();
+  const navigate = useNavigate();
+  const [actionLoadingId, setActionLoadingId] = useState<string | null>(null);
+  
+  const isMaxMacrosReached = useMemo(() => 
+    macros.length >= MAX_TOTAL_MACROS, 
+    [macros.length]
+  );
+
+  useEffect(() => {
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros]);
+
+  const handleDuplicateMacro = async (macro: KeySequence) => {
+    if (!macro?.id || !macro?.name) {
+      notifications.error("Invalid macro data");
+      return;
+    }
+
+    if (isMaxMacrosReached) {
+      notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    setActionLoadingId(macro.id);
+
+    const newMacroCopy: KeySequence = {
+      ...JSON.parse(JSON.stringify(macro)),
+      id: generateMacroId(),
+      name: `${macro.name} ${COPY_SUFFIX}`,
+      sortOrder: macros.length + 1,
+    };
+
+    try {
+      await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
+      notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to duplicate macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to duplicate macro");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  };
+
+  const handleMoveMacro = async (index: number, direction: 'up' | 'down', macroId: string) => {
+    if (!Array.isArray(macros) || macros.length === 0) {
+      notifications.error("No macros available");
+      return;
+    }
+
+    const newIndex = direction === 'up' ? index - 1 : index + 1;
+    if (newIndex < 0 || newIndex >= macros.length) return;
+
+    setActionLoadingId(macroId);
+
+    try {
+      const newMacros = [...macros];
+      [newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]];
+      const updatedMacros = normalizeSortOrders(newMacros);
+
+      await saveMacros(updatedMacros);
+      notifications.success("Macro order updated successfully");
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to reorder macros: ${error.message}`);
+      } else {
+        notifications.error("Failed to reorder macros");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  };
+
+  const MacroList = useMemo(() => (
+    <div className="space-y-2">
+      {macros.map((macro, index) => (
+        <Card key={macro.id} className="p-2 bg-white dark:bg-slate-800">
+          <div className="flex items-center justify-between">
+            <div className="flex flex-col gap-1 px-2">
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'up', macro.id)}
+                disabled={index === 0 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowUp}
+                aria-label={`Move ${macro.name} up`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'down', macro.id)}
+                disabled={index === macros.length - 1 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowDown}
+                aria-label={`Move ${macro.name} down`}
+              />
+            </div>
+
+            <div className="flex-1 min-w-0 flex flex-col justify-center ml-2">
+              <h3 className="truncate text-sm font-semibold text-black dark:text-white">
+                {macro.name}
+              </h3>
+              <p className="mt-1 ml-4 text-xs text-slate-500 dark:text-slate-400 overflow-hidden">
+                <span className="flex flex-col items-start gap-1">
+                  {macro.steps.map((step, stepIndex) => {
+                    const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight;
+
+                    return (
+                      <span key={stepIndex} className="inline-flex items-center">
+                        <StepIcon className="mr-1 text-slate-400 dark:text-slate-500 h-3 w-3 flex-shrink-0" />
+                        <span className="bg-slate-50 dark:bg-slate-800 px-2 py-0.5 rounded-md border border-slate-200/50 dark:border-slate-700/50">
+                          {(Array.isArray(step.modifiers) && step.modifiers.length > 0) || (Array.isArray(step.keys) && step.keys.length > 0) ? (
+                            <>
+                              {Array.isArray(step.modifiers) && step.modifiers.map((modifier, idx) => (
+                                <Fragment key={`mod-${idx}`}>
+                                  <span className="font-medium text-slate-600 dark:text-slate-200">
+                                    {modifierDisplayMap[modifier] || modifier}
+                                  </span>
+                                  {idx < step.modifiers.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+
+                              {Array.isArray(step.modifiers) && step.modifiers.length > 0 && Array.isArray(step.keys) && step.keys.length > 0 && (
+                                <span className="text-slate-400 dark:text-slate-600"> + </span>
+                              )}
+
+                              {Array.isArray(step.keys) && step.keys.map((key, idx) => (
+                                <Fragment key={`key-${idx}`}>
+                                  <span className="font-medium text-blue-600 dark:text-blue-400">
+                                    {keyDisplayMap[key] || key}
+                                  </span>
+                                  {idx < step.keys.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+                            </>
+                          ) : (
+                            <span className="font-medium text-slate-500 dark:text-slate-400">Delay only</span>
+                          )}
+                          <span className="ml-1 text-slate-400 dark:text-slate-500">({step.delay}ms)</span>
+                        </span>
+                      </span>
+                    );
+                  })}
+                </span>
+              </p>
+            </div>
+
+            <div className="flex items-center gap-1 ml-4">
+              <Button
+                size="XS"
+                theme="light"
+                LeadingIcon={LuCopy}
+                onClick={() => handleDuplicateMacro(macro)}
+                disabled={actionLoadingId === macro.id}
+                aria-label={`Duplicate macro ${macro.name}`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                LeadingIcon={LuPenLine}
+                onClick={() => navigate(`${macro.id}/edit`)}
+                disabled={actionLoadingId === macro.id}
+                aria-label={`Edit macro ${macro.name}`}
+              />
+            </div>
+          </div>
+        </Card>
+      ))}
+    </div>
+  ), [macros, actionLoadingId]);
+
+  return (
+    <div className="space-y-4">
+      <SettingsPageHeader
+        title="Keyboard Macros"
+        description="Create and manage keyboard macros for quick actions"
+      />
+      <div className="flex items-center justify-between mb-4">
+        <SettingsItem 
+          title="Macros"
+          description={`${loading ? '?' : macros.length}/${MAX_TOTAL_MACROS}`}
+        >
+          { macros.length > 0 && (
+            <div className="flex items-center gap-2">
+              <Button
+                size="SM"
+                theme="primary"
+                text={isMaxMacrosReached ? `Max Reached` : "Add New Macro"}
+                onClick={() => navigate("add")}
+                disabled={isMaxMacrosReached}
+                aria-label="Add new macro"
+              />
+            </div>
+          )}
+        </SettingsItem>
+      </div>
+
+      <div className="space-y-4">
+        {loading && macros.length === 0 ? (
+          <EmptyCard
+            headline="Loading macros..."
+            description="Please wait while we fetch your macros"
+            BtnElm={
+              <LuLoader className="h-6 w-6 animate-spin text-blue-500" />
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: Savid
body: done
created_at: 2025-04-05T14:01:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+import { useEffect, Fragment, useMemo, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { LuPenLine, LuLoader, LuCopy, LuMoveRight, LuCornerDownRight, LuArrowUp, LuArrowDown } from "react-icons/lu";
+
+import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
+import { SettingsPageHeader } from "@/components/SettingsPageheader";
+import { Button } from "@/components/Button";
+import EmptyCard from "@/components/EmptyCard";
+import Card from "@/components/Card";
+import { MAX_TOTAL_MACROS, COPY_SUFFIX } from "@/constants/macros";
+import { keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings";
+import notifications from "@/notifications";
+import { SettingsItem } from "@/routes/devices.$id.settings";
+
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+export default function SettingsMacrosRoute() {
+  const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore();
+  const navigate = useNavigate();
+  const [actionLoadingId, setActionLoadingId] = useState<string | null>(null);
+  
+  const isMaxMacrosReached = useMemo(() => 
+    macros.length >= MAX_TOTAL_MACROS, 
+    [macros.length]
+  );
+
+  useEffect(() => {
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros]);
+
+  const handleDuplicateMacro = async (macro: KeySequence) => {
+    if (!macro?.id || !macro?.name) {
+      notifications.error("Invalid macro data");
+      return;
+    }
+
+    if (isMaxMacrosReached) {
+      notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    setActionLoadingId(macro.id);
+
+    const newMacroCopy: KeySequence = {
+      ...JSON.parse(JSON.stringify(macro)),
+      id: generateMacroId(),
+      name: `${macro.name} ${COPY_SUFFIX}`,
+      sortOrder: macros.length + 1,
+    };
+
+    try {
+      await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
+      notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to duplicate macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to duplicate macro");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  };
+
+  const handleMoveMacro = async (index: number, direction: 'up' | 'down', macroId: string) => {
+    if (!Array.isArray(macros) || macros.length === 0) {
+      notifications.error("No macros available");
+      return;
+    }
+
+    const newIndex = direction === 'up' ? index - 1 : index + 1;
+    if (newIndex < 0 || newIndex >= macros.length) return;
+
+    setActionLoadingId(macroId);
+
+    try {
+      const newMacros = [...macros];
+      [newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]];
+      const updatedMacros = normalizeSortOrders(newMacros);
+
+      await saveMacros(updatedMacros);
+      notifications.success("Macro order updated successfully");
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to reorder macros: ${error.message}`);
+      } else {
+        notifications.error("Failed to reorder macros");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  };
+
+  const MacroList = useMemo(() => (
+    <div className="space-y-2">
+      {macros.map((macro, index) => (
+        <Card key={macro.id} className="p-2 bg-white dark:bg-slate-800">
+          <div className="flex items-center justify-between">
+            <div className="flex flex-col gap-1 px-2">
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'up', macro.id)}
+                disabled={index === 0 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowUp}
+                aria-label={`Move ${macro.name} up`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'down', macro.id)}
+                disabled={index === macros.length - 1 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowDown}
+                aria-label={`Move ${macro.name} down`}
+              />
+            </div>
+
+            <div className="flex-1 min-w-0 flex flex-col justify-center ml-2">
+              <h3 className="truncate text-sm font-semibold text-black dark:text-white">
+                {macro.name}
+              </h3>
+              <p className="mt-1 ml-4 text-xs text-slate-500 dark:text-slate-400 overflow-hidden">
+                <span className="flex flex-col items-start gap-1">
+                  {macro.steps.map((step, stepIndex) => {
+                    const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight;
+
+                    return (
+                      <span key={stepIndex} className="inline-flex items-center">
+                        <StepIcon className="mr-1 text-slate-400 dark:text-slate-500 h-3 w-3 flex-shrink-0" />
+                        <span className="bg-slate-50 dark:bg-slate-800 px-2 py-0.5 rounded-md border border-slate-200/50 dark:border-slate-700/50">
+                          {(Array.isArray(step.modifiers) && step.modifiers.length > 0) || (Array.isArray(step.keys) && step.keys.length > 0) ? (
+                            <>
+                              {Array.isArray(step.modifiers) && step.modifiers.map((modifier, idx) => (
+                                <Fragment key={`mod-${idx}`}>
+                                  <span className="font-medium text-slate-600 dark:text-slate-200">
+                                    {modifierDisplayMap[modifier] || modifier}
+                                  </span>
+                                  {idx < step.modifiers.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+
+                              {Array.isArray(step.modifiers) && step.modifiers.length > 0 && Array.isArray(step.keys) && step.keys.length > 0 && (
+                                <span className="text-slate-400 dark:text-slate-600"> + </span>
+                              )}
+
+                              {Array.isArray(step.keys) && step.keys.map((key, idx) => (
+                                <Fragment key={`key-${idx}`}>
+                                  <span className="font-medium text-blue-600 dark:text-blue-400">
+                                    {keyDisplayMap[key] || key}
+                                  </span>
+                                  {idx < step.keys.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+                            </>
+                          ) : (
+                            <span className="font-medium text-slate-500 dark:text-slate-400">Delay only</span>
+                          )}
+                          <span className="ml-1 text-slate-400 dark:text-slate-500">({step.delay}ms)</span>
+                        </span>
+                      </span>
+                    );
+                  })}
+                </span>
+              </p>
+            </div>
+
+            <div className="flex items-center gap-1 ml-4">
+              <Button
+                size="XS"
+                theme="light"
+                LeadingIcon={LuCopy}
+                onClick={() => handleDuplicateMacro(macro)}
+                disabled={actionLoadingId === macro.id}
+                aria-label={`Duplicate macro ${macro.name}`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                LeadingIcon={LuPenLine}
+                onClick={() => navigate(`${macro.id}/edit`)}
+                disabled={actionLoadingId === macro.id}
+                aria-label={`Edit macro ${macro.name}`}
+              />
+            </div>
+          </div>
+        </Card>
+      ))}
+    </div>
+  ), [macros, actionLoadingId]);
+
+  return (
+    <div className="space-y-4">
+      <SettingsPageHeader
+        title="Keyboard Macros"
+        description="Create and manage keyboard macros for quick actions"
+      />
+      <div className="flex items-center justify-between mb-4">
+        <SettingsItem 
+          title="Macros"
+          description={`${loading ? '?' : macros.length}/${MAX_TOTAL_MACROS}`}
+        >
+          { macros.length > 0 && (
+            <div className="flex items-center gap-2">
+              <Button
+                size="SM"
+                theme="primary"
+                text={isMaxMacrosReached ? `Max Reached` : "Add New Macro"}
+                onClick={() => navigate("add")}
+                disabled={isMaxMacrosReached}
+                aria-label="Add new macro"
+              />
+            </div>
+          )}
+        </SettingsItem>
+      </div>
+
+      <div className="space-y-4">
+        {loading && macros.length === 0 ? (
+          <EmptyCard
+            headline="Loading macros..."
+            description="Please wait while we fetch your macros"
+            BtnElm={
+              <LuLoader className="h-6 w-6 animate-spin text-blue-500" />
+            }
+          />
+        ) : macros.length === 0 ? (
+          <EmptyCard
+            headline="No macros created yet"
+            BtnElm={
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: Savid
body: updated
created_at: 2025-04-05T14:01:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,362 @@
+import { useState } from "react";
+
+import { LuPlus, LuInfo } from "react-icons/lu";
+
+import { KeySequence } from "@/hooks/stores";
+import { Button } from "@/components/Button";
+import { InputFieldWithLabel, FieldError } from "@/components/InputField";
+import Fieldset from "@/components/Fieldset";
+import { MacroStepCard } from "@/components/MacroStepCard";
+import Modal from "@/components/Modal";
+import { DEFAULT_DELAY, MAX_STEPS_PER_MACRO, MAX_KEYS_PER_STEP } from "@/constants/macros";
+
+interface ValidationErrors {
+  name?: string;
+  steps?: Record<number, {
+    keys?: string;
+    modifiers?: string;
+    delay?: string;
+  }>;
+}
+
+interface MacroFormProps {
+  initialData: Partial<KeySequence>;
+  onSubmit: (macro: Partial<KeySequence>) => Promise<void>;
+  onCancel: () => void;
+  isSubmitting?: boolean;
+  submitText?: string;
+  showCancelConfirm?: boolean;
+  onCancelConfirm?: () => void;
+  showDelete?: boolean;
+  onDelete?: () => void;
+  isDeleting?: boolean;
+}
+
+export function MacroForm({
+  initialData,
+  onSubmit,
+  onCancel,
+  isSubmitting = false,
+  submitText = "Save Macro",
+  showCancelConfirm = false,
+  onCancelConfirm,
+  showDelete = false,
+  onDelete,
+  isDeleting = false
+}: MacroFormProps) {
+  const [macro, setMacro] = useState<Partial<KeySequence>>(initialData);
+  const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
+  const [errors, setErrors] = useState<ValidationErrors>({});
+  const [errorMessage, setErrorMessage] = useState<string | null>(null);
+  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+
+  const showTemporaryError = (message: string) => {
+    setErrorMessage(message);
+    setTimeout(() => setErrorMessage(null), 3000);
+  };
+
+  const validateForm = (): boolean => {
+    const newErrors: ValidationErrors = {};
+
+    // Name validation
+    if (!macro.name?.trim()) {
+      newErrors.name = "Name is required";
+    } else if (macro.name.trim().length > 50) {
+      newErrors.name = "Name must be less than 50 characters";
+    }
+  
+    if (!macro.steps?.length) {
+      newErrors.steps = { 0: { keys: "At least one step is required" } };
+    } else {
+      const hasKeyOrModifier = macro.steps.some(step => 
+        (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0
+      );
+
+      if (!hasKeyOrModifier) {
+        newErrors.steps = { 0: { keys: "At least one step must have keys or modifiers" } };
+      }
+    }
+
+    setErrors(newErrors);
+    return Object.keys(newErrors).length === 0;
+  };
+
+  const handleSubmit = async () => {
+    if (!validateForm()) {
+      showTemporaryError("Please fix the validation errors");
+      return;
+    }
+
+    try {
+      await onSubmit(macro);
+    } catch (error) {
+      if (error instanceof Error) {
+        showTemporaryError(error.message);
+      } else {
+        showTemporaryError("An error occurred while saving");
+      }
+    }
+  };
+
+  const handleKeySelect = (stepIndex: number, option: { value: string | null; keys?: string[] }) => {
+    const newSteps = [...(macro.steps || [])];
+    if (!newSteps[stepIndex]) return;
+
+    if (option.keys) {
+      newSteps[stepIndex].keys = option.keys;
+    } else if (option.value) {
+      if (!newSteps[stepIndex].keys) {
+        newSteps[stepIndex].keys = [];
+      }
+      const keysArray = Array.isArray(newSteps[stepIndex].keys) ? newSteps[stepIndex].keys : [];
+      if (keysArray.length >= MAX_KEYS_PER_STEP) {
+        showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`);
+        return;
+      }
+      newSteps[stepIndex].keys = [...keysArray, option.value];
+    }
+    setMacro({ ...macro, steps: newSteps });
+    
+    if (errors.steps?.[stepIndex]?.keys) {
+      const newErrors = { ...errors };
+      delete newErrors.steps?.[stepIndex].keys;
+      if (Object.keys(newErrors.steps?.[stepIndex] || {}).length === 0) {
+        delete newErrors.steps?.[stepIndex];
+      }
+      if (Object.keys(newErrors.steps || {}).length === 0) {
+        delete newErrors.steps;
+      }
+      setErrors(newErrors);
+    }
+  };
+
+  const handleKeyQueryChange = (stepIndex: number, query: string) => {
+    setKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
+  };
+
+  const handleModifierChange = (stepIndex: number, modifiers: string[]) => {
+    const newSteps = [...(macro.steps || [])];
+    newSteps[stepIndex].modifiers = modifiers;
+    setMacro({ ...macro, steps: newSteps });
+    
+    // Clear step errors when modifiers are added
+    if (errors.steps?.[stepIndex]?.keys && modifiers.length > 0) {
+      const newErrors = { ...errors };
+      delete newErrors.steps?.[stepIndex].keys;
+      if (Object.keys(newErrors.steps?.[stepIndex] || {}).length === 0) {
+        delete newErrors.steps?.[stepIndex];
+      }
+      if (Object.keys(newErrors.steps || {}).length === 0) {
+        delete newErrors.steps;
+      }
+      setErrors(newErrors);
+    }
+  };
+
+  const handleDelayChange = (stepIndex: number, delay: number) => {
+    const newSteps = [...(macro.steps || [])];
+    newSteps[stepIndex].delay = delay;
+    setMacro({ ...macro, steps: newSteps });
+  };
+
+  const handleStepMove = (stepIndex: number, direction: 'up' | 'down') => {
+    const newSteps = [...(macro.steps || [])];
+    const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
+    [newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]];
+    setMacro({ ...macro, steps: newSteps });
+  };
+
+  const isMaxStepsReached = (macro.steps?.length || 0) >= MAX_STEPS_PER_MACRO;
+
+  return (
+    <>
+      <div className="space-y-4">
+        <Fieldset>
+          <InputFieldWithLabel
+            type="text"
+            label="Macro Name"
+            placeholder="Macro Name"
+            value={macro.name}
+            error={errors.name}
+            onChange={e => {
+              setMacro(prev => ({ ...prev, name: e.target.value }));
+              if (errors.name) {
+                const newErrors = { ...errors };
+                delete newErrors.name;
+                setErrors(newErrors);
+              }
+            }}
+          />
+        </Fieldset>
+
+        <div>
+          <div className="flex items-center justify-between text-sm">
+            <div className="flex items-center gap-1">
+              <label className="font-medium text-slate-700 dark:text-slate-200">
+                Steps
+              </label>
+              <div className="group relative cursor-pointer">
+                <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+                <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                  <p>Each step is a collection of keys and/or modifiers that will be executed in order. You can add up to a maximum of {MAX_STEPS_PER_MACRO} steps per macro.</p>
+                </div>
+              </div>
+            </div>
+            <span className="text-slate-500 dark:text-slate-400">
+              {macro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps
+            </span>
+          </div>
+          {errors.steps && errors.steps[0]?.keys && (
+            <div className="mt-2">
+              <FieldError error={errors.steps[0].keys} />
+            </div>
+          )}
+          <Fieldset>
+            <div className="mt-2 space-y-4">
+              {(macro.steps || []).map((step, stepIndex) => (
+                <MacroStepCard
+                  key={stepIndex}
+                  step={step}
+                  stepIndex={stepIndex}
+                  onDelete={macro.steps && macro.steps.length > 1 ? () => {
+                    const newSteps = [...(macro.steps || [])];
+                    newSteps.splice(stepIndex, 1);
+                    setMacro(prev => ({ ...prev, steps: newSteps }));
+                  } : undefined}
+                  onMoveUp={() => handleStepMove(stepIndex, 'up')}
+                  onMoveDown={() => handleStepMove(stepIndex, 'down')}
+                  onKeySelect={(option) => handleKeySelect(stepIndex, option)}
+                  onKeyQueryChange={(query) => handleKeyQueryChange(stepIndex, query)}
+                  keyQuery={keyQueries[stepIndex] || ''}
+                  onModifierChange={(modifiers) => handleModifierChange(stepIndex, modifiers)}
+                  onDelayChange={(delay) => handleDelayChange(stepIndex, delay)}
+                  isLastStep={stepIndex === (macro.steps?.length || 0) - 1}
+                />
+              ))}
+            </div>
+          </Fieldset>
+
+          <div className="mt-4">
+            <Button
+              size="MD"
+              theme="light"
+              fullWidth
+              LeadingIcon={LuPlus}
+              text={`Add Step ${isMaxStepsReached ? `(${MAX_STEPS_PER_MACRO} max)` : ''}`}
+              onClick={() => {
+                if (isMaxStepsReached) {
+                  showTemporaryError(`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`);
+                  return;
+                }
+                
+                setMacro(prev => ({
+                  ...prev,
+                  steps: [
+                    ...(prev.steps || []), 
+                    { keys: [], modifiers: [], delay: DEFAULT_DELAY }
+                  ],
+                }));
+                setErrors({});
+              }}
+              disabled={isMaxStepsReached}
+            />
+          </div>
+
+          {errorMessage && (
+            <div className="mt-4">
+              <FieldError error={errorMessage} />
+            </div>
+          )}
+
+          <div className="mt-6 flex items-center justify-between">
+            {showCancelConfirm ? (
+              <div className="flex items-center gap-2">
+                <span className="text-sm text-slate-600 dark:text-slate-400">
+                  Cancel changes?
+                </span>
+                <Button
+                  size="SM"
+                  theme="danger"
+                  text="Yes"
+                  onClick={onCancelConfirm}
+                />
+                <Button
+                  size="SM"
+                  theme="light"
+                  text="No"
+                  onClick={() => onCancel()}
+                />
+              </div>
+            ) : (
+              <>
+                <div className="flex gap-x-2">
+                  <Button
+                    size="SM"
+                    theme="primary"
+                    text={isSubmitting ? "Saving..." : submitText}
+                    onClick={handleSubmit}
+                    disabled={isSubmitting}
+                  />
+                  <Button
+                    size="SM"
+                    theme="light"
+                    text="Cancel"
+                    onClick={onCancel}
+                  />
+                </div>
+                {showDelete && (
path: ui/src/components/MacroForm.tsx
user: login: Savid
body: yeah cool, removed the delete button from the macro form, added it to the edit route top right
created_at: 2025-04-05T14:02:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,246 @@
+import { LuArrowUp, LuArrowDown, LuX, LuInfo } from "react-icons/lu";
+
+import { Button } from "@/components/Button";
+import { Combobox } from "@/components/Combobox";
+import { SelectMenuBasic } from "@/components/SelectMenuBasic";
+import Card from "@/components/Card";
+import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
+import { MAX_KEYS_PER_STEP } from "@/constants/macros";
+
+// Filter out modifier keys since they're handled in the modifiers section
+const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta'];
+
+const keyOptions = Object.keys(keys)
+  .filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
+  .map(key => ({
+    value: key,
+    label: keyDisplayMap[key] || key,
+  }));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers: Record<string, typeof modifierOptions> = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+const PRESET_DELAYS = [
+  { value: "50", label: "50ms" },
+  { value: "100", label: "100ms" },
+  { value: "200", label: "200ms" },
+  { value: "300", label: "300ms" },
+  { value: "500", label: "500ms" },
+  { value: "750", label: "750ms" },
+  { value: "1000", label: "1000ms" },
+  { value: "1500", label: "1500ms" },
+  { value: "2000", label: "2000ms" },
+];
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  onKeySelect: (option: { value: string | null; keys?: string[] }) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+export function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  const getFilteredKeys = () => {
+    const selectedKeys = ensureArray(step.keys);
+    const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value));
+    
+    if (keyQuery === '') {
+      return availableKeys;
+    } else {
+      return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase()));
+    }
+  };
+
+  return (
+    <Card className="p-4">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+              LeadingIcon={LuArrowUp}
+            />
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+              LeadingIcon={LuArrowDown}
+            />
+          </div>
+          <span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+        
+        <div className="flex items-center space-x-2">
+          {onDelete && (
+            <Button
+              size="XS"
+              theme="danger"
+              text="Delete"
+              onClick={onDelete}
+            />
+          )}
+        </div>
+      </div>
+      
+      <div className="space-y-4 mt-2">
+        <div className="w-full flex flex-col gap-2">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Modifiers
+          </label>
+          <div className="inline-flex flex-wrap gap-3">
+            {Object.entries(groupedModifiers).map(([group, mods]) => (
+              <div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
+                <span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
+                  {group}
+                </span>
+                <div className="flex flex-wrap gap-1">
+                  {mods.map(option => (
+                    <label 
+                      key={option.value} 
+                      className={`flex items-center px-2 py-1 rounded border cursor-pointer text-xs font-medium transition-colors ${
+                        ensureArray(step.modifiers).includes(option.value) 
+                          ? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-200' 
+                          : 'bg-slate-100 border-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700'
+                      }`}
+                    >
+                      <input
+                        type="checkbox"
+                        className="sr-only"
+                        checked={ensureArray(step.modifiers).includes(option.value)}
+                        onChange={e => {
+                          const modifiersArray = ensureArray(step.modifiers);
+                          const newModifiers = e.target.checked
+                            ? [...modifiersArray, option.value]
+                            : modifiersArray.filter(m => m !== option.value);
+                          onModifierChange(newModifiers);
+                        }}
+                      />
+                      {option.label.split(' ')[1] || option.label}
+                    </label>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+              Keys
+            </label>
+            <div className="group relative cursor-pointer">
+              <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+              <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                <p>You can add up to a maximum of {MAX_KEYS_PER_STEP} keys to press per step.</p>
+              </div>
+            </div>
+          </div>
+          <div className="flex flex-wrap gap-1 pb-2">
+            {ensureArray(step.keys).map((key, keyIndex) => (
+              <span
+                key={keyIndex}
+                className="inline-flex items-center rounded-md bg-blue-100 px-1 text-xs font-medium text-blue-800 dark:bg-blue-900/40 dark:text-blue-200"
+              >
+                <span className="px-1">
+                  {keyDisplayMap[key] || key}
+                </span>
+                <Button
+                  size="XS"
+                  className=""
+                  theme="blank"
+                  onClick={() => {
+                    const newKeys = ensureArray(step.keys).filter((_, i) => i !== keyIndex);
+                    onKeySelect({ value: null, keys: newKeys });
+                  }}
+                  LeadingIcon={LuX}
+                />
+              </span>
+            ))}
+          </div>
+          <div className="relative w-full">
+            <Combobox
+              onChange={(value: { value: string; label: string }) => onKeySelect(value)}
+              displayValue={() => keyQuery}
+              onInputChange={onKeyQueryChange}
+              options={getFilteredKeys}
+              disabledMessage="Max keys reached"
+              size="SM"
+              immediate
+              disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP}
+              placeholder={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP ? "Max keys reached" : "Search for key..."}
+              emptyMessage="No matching keys found"
+            />
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+              Step Duration
+            </label>
+            <div className="group relative cursor-pointer">
+              <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+              <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                <p>The time to wait after pressing the keys in this step before moving to the next step. This helps ensure reliable key presses when automating keyboard input.</p>
+              </div>
+            </div>
+          </div>
+          <div className="flex items-center gap-3">
+            <SelectMenuBasic
+              size="SM"
+              fullWidth
+              value={step.delay.toString()}
+              onChange={(e) => onDelayChange(parseInt(e.target.value, 10))}
+              options={PRESET_DELAYS}
+            />
+          </div>
+        </div>
path: ui/src/components/MacroStepCard.tsx
user: login: Savid
body: not quite understanding what you mean? atm it's default values here as you add step https://github.com/jetkvm/kvm/blob/40acb6e2e5f16ca318dddc3f5270a0da3f2494d7/ui/src/components/MacroForm.tsx#L237

or do you mean we just hide this value from the UI for now?
created_at: 2025-04-05T14:05:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,126 @@
+import { useRef } from "react";
+import clsx from "clsx";
+import { Combobox as HeadlessCombobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
+import { cva } from "@/cva.config";
+import Card from "./Card";
+
+export interface ComboboxOption {
+  value: string;
+  label: string;
+}
+
+const sizes = {
+  XS: "h-[24.5px] pl-3 pr-8 text-xs",
+  SM: "h-[32px] pl-3 pr-8 text-[13px]",
+  MD: "h-[40px] pl-4 pr-10 text-sm",
+  LG: "h-[48px] pl-4 pr-10 px-5 text-base",
+} as const;
+
+const comboboxVariants = cva({
+  variants: { size: sizes },
+});
+
+type BaseProps = React.ComponentProps<typeof HeadlessCombobox>;
+
+interface ComboboxProps extends Omit<BaseProps, 'displayValue'> {
+  displayValue: (option: ComboboxOption) => string;
+  onInputChange: (option: string) => void;
+  options: () => ComboboxOption[];
+  placeholder?: string;
+  emptyMessage?: string;
+  size?: keyof typeof sizes;
+  disabledMessage?: string;
+}
+
+export function Combobox({
+  onInputChange,
+  displayValue,
+  options,
+  disabled = false,
+  placeholder = "Search...",
+  emptyMessage = "No results found",
+  size = "MD",
+  onChange,
+  disabledMessage = "Input disabled",
+  ...otherProps
+}: ComboboxProps) {
+  const inputRef = useRef<HTMLInputElement>(null);
+  const classes = comboboxVariants({ size });
+
+  const handleChange = (value: unknown) => {
+    if (onChange) {
+      onChange(value);
+      inputRef.current?.blur();
+    }
+  };
+
+  return (
+    <HeadlessCombobox 
+      onChange={handleChange}
+      {...otherProps}
+    >
+      {() => (
+        <>
+          <Card className="w-auto !border border-solid !border-slate-800/30 shadow outline-0 dark:!border-slate-300/30">
+            <ComboboxInput
+            ref={inputRef}
+            className={clsx(
+              classes,
+              
+              // General styling
+              "block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0 transition duration-300",
+              
+              // Hover
+              "hover:bg-blue-50/80 active:bg-blue-100/60",
+              
+              // Dark mode
+              "dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700 dark:active:bg-slate-800/60",
+              
+              // Focus
+              "focus:outline-blue-600 focus:ring-2 focus:ring-blue-700 focus:ring-offset-2 dark:focus:outline-blue-500 dark:focus:ring-blue-500",
+              
+              // Disabled
+              disabled && "pointer-events-none select-none bg-slate-50 text-slate-500/80 dark:bg-slate-800 dark:text-slate-400/80 disabled:hover:bg-white dark:disabled:hover:bg-slate-800"
+            )}
+            placeholder={disabled ? disabledMessage : placeholder}
+            displayValue={displayValue}
+            onChange={(event) => onInputChange(event.target.value)}
+            disabled={disabled}
+            />
+          </Card>
+          
+          {options().length > 0 && (
+            <ComboboxOptions className="absolute left-0 z-[100] mt-1 w-full max-h-60 overflow-auto rounded-md bg-white py-1 text-sm shadow-lg ring-1 ring-black/5 dark:bg-slate-800 dark:ring-slate-700 hide-scrollbar">
path: ui/src/components/Combobox.tsx
user: login: adamshiervani
body: I definitely prefer the current way. I certainly "got it" much quicker with the "do one letter, then clear state".
created_at: 2025-04-07T22:21:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,246 @@
+import { LuArrowUp, LuArrowDown, LuX, LuInfo } from "react-icons/lu";
+
+import { Button } from "@/components/Button";
+import { Combobox } from "@/components/Combobox";
+import { SelectMenuBasic } from "@/components/SelectMenuBasic";
+import Card from "@/components/Card";
+import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
+import { MAX_KEYS_PER_STEP } from "@/constants/macros";
+
+// Filter out modifier keys since they're handled in the modifiers section
+const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta'];
+
+const keyOptions = Object.keys(keys)
+  .filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
+  .map(key => ({
+    value: key,
+    label: keyDisplayMap[key] || key,
+  }));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers: Record<string, typeof modifierOptions> = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+const PRESET_DELAYS = [
+  { value: "50", label: "50ms" },
+  { value: "100", label: "100ms" },
+  { value: "200", label: "200ms" },
+  { value: "300", label: "300ms" },
+  { value: "500", label: "500ms" },
+  { value: "750", label: "750ms" },
+  { value: "1000", label: "1000ms" },
+  { value: "1500", label: "1500ms" },
+  { value: "2000", label: "2000ms" },
+];
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  onKeySelect: (option: { value: string | null; keys?: string[] }) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+export function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  const getFilteredKeys = () => {
+    const selectedKeys = ensureArray(step.keys);
+    const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value));
+    
+    if (keyQuery === '') {
+      return availableKeys;
+    } else {
+      return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase()));
+    }
+  };
+
+  return (
+    <Card className="p-4">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+              LeadingIcon={LuArrowUp}
+            />
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+              LeadingIcon={LuArrowDown}
+            />
+          </div>
+          <span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+        
+        <div className="flex items-center space-x-2">
+          {onDelete && (
+            <Button
+              size="XS"
+              theme="danger"
+              text="Delete"
+              onClick={onDelete}
+            />
+          )}
+        </div>
+      </div>
+      
+      <div className="space-y-4 mt-2">
+        <div className="w-full flex flex-col gap-2">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Modifiers
+          </label>
+          <div className="inline-flex flex-wrap gap-3">
+            {Object.entries(groupedModifiers).map(([group, mods]) => (
+              <div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
+                <span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
+                  {group}
+                </span>
+                <div className="flex flex-wrap gap-1">
+                  {mods.map(option => (
+                    <label 
+                      key={option.value} 
+                      className={`flex items-center px-2 py-1 rounded border cursor-pointer text-xs font-medium transition-colors ${
+                        ensureArray(step.modifiers).includes(option.value) 
+                          ? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-200' 
+                          : 'bg-slate-100 border-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700'
+                      }`}
+                    >
+                      <input
+                        type="checkbox"
+                        className="sr-only"
+                        checked={ensureArray(step.modifiers).includes(option.value)}
+                        onChange={e => {
+                          const modifiersArray = ensureArray(step.modifiers);
+                          const newModifiers = e.target.checked
+                            ? [...modifiersArray, option.value]
+                            : modifiersArray.filter(m => m !== option.value);
+                          onModifierChange(newModifiers);
+                        }}
+                      />
+                      {option.label.split(' ')[1] || option.label}
+                    </label>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+              Keys
+            </label>
+            <div className="group relative cursor-pointer">
+              <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+              <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                <p>You can add up to a maximum of {MAX_KEYS_PER_STEP} keys to press per step.</p>
+              </div>
+            </div>
+          </div>
+          <div className="flex flex-wrap gap-1 pb-2">
+            {ensureArray(step.keys).map((key, keyIndex) => (
+              <span
+                key={keyIndex}
+                className="inline-flex items-center rounded-md bg-blue-100 px-1 text-xs font-medium text-blue-800 dark:bg-blue-900/40 dark:text-blue-200"
+              >
+                <span className="px-1">
+                  {keyDisplayMap[key] || key}
+                </span>
+                <Button
+                  size="XS"
+                  className=""
+                  theme="blank"
+                  onClick={() => {
+                    const newKeys = ensureArray(step.keys).filter((_, i) => i !== keyIndex);
+                    onKeySelect({ value: null, keys: newKeys });
+                  }}
+                  LeadingIcon={LuX}
+                />
+              </span>
+            ))}
+          </div>
+          <div className="relative w-full">
+            <Combobox
+              onChange={(value: { value: string; label: string }) => onKeySelect(value)}
+              displayValue={() => keyQuery}
+              onInputChange={onKeyQueryChange}
+              options={getFilteredKeys}
+              disabledMessage="Max keys reached"
+              size="SM"
+              immediate
+              disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP}
+              placeholder={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP ? "Max keys reached" : "Search for key..."}
+              emptyMessage="No matching keys found"
+            />
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+              Step Duration
+            </label>
+            <div className="group relative cursor-pointer">
+              <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+              <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                <p>The time to wait after pressing the keys in this step before moving to the next step. This helps ensure reliable key presses when automating keyboard input.</p>
+              </div>
+            </div>
+          </div>
+          <div className="flex items-center gap-3">
+            <SelectMenuBasic
+              size="SM"
+              fullWidth
+              value={step.delay.toString()}
+              onChange={(e) => onDelayChange(parseInt(e.target.value, 10))}
+              options={PRESET_DELAYS}
+            />
+          </div>
+        </div>
path: ui/src/components/MacroStepCard.tsx
user: login: adamshiervani
body: I mean the ‘50ms’ should be replaced with ‘Default’ - most people likely don’t know what they want, so seeing ‘50’ might just confuse them. 

The rest can stay the same, so when for some reason the Default timing doesn't work, they have a way out. 
created_at: 2025-04-07T22:25:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,307 @@
+import { useEffect, Fragment, useMemo, useState, useCallback } from "react";
+import { useNavigate } from "react-router-dom";
+import { LuPenLine, LuCopy, LuMoveRight, LuCornerDownRight, LuArrowUp, LuArrowDown, LuTrash2, LuCommand } from "react-icons/lu";
+
+import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
+import { SettingsPageHeader } from "@/components/SettingsPageheader";
+import { Button } from "@/components/Button";
+import EmptyCard from "@/components/EmptyCard";
+import Card from "@/components/Card";
+import { MAX_TOTAL_MACROS, COPY_SUFFIX } from "@/constants/macros";
+import { keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings";
+import notifications from "@/notifications";
+import { ConfirmDialog } from "@/components/ConfirmDialog";
+import LoadingSpinner from "@/components/LoadingSpinner";
+
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+export default function SettingsMacrosRoute() {
+  const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore();
+  const navigate = useNavigate();
+  const [actionLoadingId, setActionLoadingId] = useState<string | null>(null);
+  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+  const [macroToDelete, setMacroToDelete] = useState<KeySequence | null>(null);
+  
+  const isMaxMacrosReached = useMemo(() => 
+    macros.length >= MAX_TOTAL_MACROS, 
+    [macros.length]
+  );
+
+  useEffect(() => {
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros]);
+
+  const handleDuplicateMacro = useCallback(async (macro: KeySequence) => {
+    if (!macro?.id || !macro?.name) {
+      notifications.error("Invalid macro data");
+      return;
+    }
+
+    if (isMaxMacrosReached) {
+      notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    setActionLoadingId(macro.id);
+
+    const newMacroCopy: KeySequence = {
+      ...JSON.parse(JSON.stringify(macro)),
+      id: generateMacroId(),
+      name: `${macro.name} ${COPY_SUFFIX}`,
+      sortOrder: macros.length + 1,
+    };
+
+    try {
+      await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
+      notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to duplicate macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to duplicate macro");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  }, [isMaxMacrosReached, macros, saveMacros, setActionLoadingId]);
+
+  const handleMoveMacro = useCallback(async (index: number, direction: 'up' | 'down', macroId: string) => {
+    if (!Array.isArray(macros) || macros.length === 0) {
+      notifications.error("No macros available");
+      return;
+    }
+
+    const newIndex = direction === 'up' ? index - 1 : index + 1;
+    if (newIndex < 0 || newIndex >= macros.length) return;
+
+    setActionLoadingId(macroId);
+
+    try {
+      const newMacros = [...macros];
+      [newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]];
+      const updatedMacros = normalizeSortOrders(newMacros);
+
+      await saveMacros(updatedMacros);
+      notifications.success("Macro order updated successfully");
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to reorder macros: ${error.message}`);
+      } else {
+        notifications.error("Failed to reorder macros");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  }, [macros, saveMacros, setActionLoadingId]);
+
+  const handleDeleteMacro = useCallback(async () => {
+    if (!macroToDelete?.id) return;
+
+    setActionLoadingId(macroToDelete.id);
+    try {
+      const updatedMacros = normalizeSortOrders(macros.filter(m => m.id !== macroToDelete.id));
+      await saveMacros(updatedMacros);
+      notifications.success(`Macro "${macroToDelete.name}" deleted successfully`);
+      setShowDeleteConfirm(false);
+      setMacroToDelete(null);
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to delete macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to delete macro");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  }, [macroToDelete, macros, saveMacros]);
+
+  const MacroList = useMemo(() => (
+    <div className="space-y-2">
+      {macros.map((macro, index) => (
+        <Card key={macro.id} className="p-2 bg-white dark:bg-slate-800">
+          <div className="flex items-center justify-between">
+            <div className="flex flex-col gap-1 px-2">
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'up', macro.id)}
+                disabled={index === 0 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowUp}
+                aria-label={`Move ${macro.name} up`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'down', macro.id)}
+                disabled={index === macros.length - 1 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowDown}
+                aria-label={`Move ${macro.name} down`}
+              />
+            </div>
+
+            <div className="flex-1 min-w-0 flex flex-col justify-center ml-2">
+              <h3 className="truncate text-sm font-semibold text-black dark:text-white">
+                {macro.name}
+              </h3>
+              <p className="mt-1 ml-4 text-xs text-slate-500 dark:text-slate-400 overflow-hidden">
+                <span className="flex flex-col items-start gap-1">
+                  {macro.steps.map((step, stepIndex) => {
+                    const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight;
+
+                    return (
+                      <span key={stepIndex} className="inline-flex items-center">
+                        <StepIcon className="mr-1 text-slate-400 dark:text-slate-500 h-3 w-3 flex-shrink-0" />
+                        <span className="bg-slate-50 dark:bg-slate-800 px-2 py-0.5 rounded-md border border-slate-200/50 dark:border-slate-700/50">
+                          {(Array.isArray(step.modifiers) && step.modifiers.length > 0) || (Array.isArray(step.keys) && step.keys.length > 0) ? (
+                            <>
+                              {Array.isArray(step.modifiers) && step.modifiers.map((modifier, idx) => (
+                                <Fragment key={`mod-${idx}`}>
+                                  <span className="font-medium text-slate-600 dark:text-slate-200">
+                                    {modifierDisplayMap[modifier] || modifier}
+                                  </span>
+                                  {idx < step.modifiers.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+
+                              {Array.isArray(step.modifiers) && step.modifiers.length > 0 && Array.isArray(step.keys) && step.keys.length > 0 && (
+                                <span className="text-slate-400 dark:text-slate-600"> + </span>
+                              )}
+
+                              {Array.isArray(step.keys) && step.keys.map((key, idx) => (
+                                <Fragment key={`key-${idx}`}>
+                                  <span className="font-medium text-blue-600 dark:text-blue-400">
+                                    {keyDisplayMap[key] || key}
+                                  </span>
+                                  {idx < step.keys.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+                            </>
+                          ) : (
+                            <span className="font-medium text-slate-500 dark:text-slate-400">Delay only</span>
+                          )}
+                          <span className="ml-1 text-slate-400 dark:text-slate-500">({step.delay}ms)</span>
+                        </span>
+                      </span>
+                    );
+                  })}
+                </span>
+              </p>
+            </div>
+
+            <div className="flex items-center gap-1 ml-4">
+              <Button
+                size="XS"
+                className="text-red-500 dark:text-red-400"
+                theme="light"
+                LeadingIcon={LuTrash2}
+                onClick={() => {
+                  setMacroToDelete(macro);
+                  setShowDeleteConfirm(true);
+                }}
+                disabled={actionLoadingId === macro.id}
+                aria-label={`Delete macro ${macro.name}`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                LeadingIcon={LuCopy}
+                onClick={() => handleDuplicateMacro(macro)}
+                disabled={actionLoadingId === macro.id}
+                aria-label={`Duplicate macro ${macro.name}`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                LeadingIcon={LuPenLine}
+                text="Edit"
+                onClick={() => navigate(`${macro.id}/edit`)}
+                disabled={actionLoadingId === macro.id}
+                aria-label={`Edit macro ${macro.name}`}
+              />
+            </div>
+          </div>
+        </Card>
+      ))}
+
+      <ConfirmDialog
+        open={showDeleteConfirm}
+        onClose={() => {
+          setShowDeleteConfirm(false);
+          setMacroToDelete(null);
+        }}
+        title="Delete Macro"
+        description={`Are you sure you want to delete "${macroToDelete?.name}"? This action cannot be undone.`}
+        variant="danger"
+        confirmText={actionLoadingId === macroToDelete?.id ? "Deleting..." : "Delete"}
+        onConfirm={handleDeleteMacro}
+        isConfirming={actionLoadingId === macroToDelete?.id}
+      />
+    </div>
+  ), [macros, actionLoadingId, showDeleteConfirm, macroToDelete, handleDeleteMacro]);
+
+  return (
+    <div className="space-y-4">
+      {macros.length > 0 && (
+        <div className="flex items-center justify-between">
+          <SettingsPageHeader
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: adamshiervani
body: Could we still have the Page Header on length === 0?
created_at: 2025-04-07T22:26:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,133 @@
+import { useNavigate, useParams } from "react-router-dom";
+import { useState, useEffect } from "react";
+import { LuTrash2 } from "react-icons/lu";
+
+import { KeySequence, useMacrosStore } from "@/hooks/stores";
+import { SettingsPageHeader } from "@/components/SettingsPageheader";
+import { MacroForm } from "@/components/MacroForm";
+import notifications from "@/notifications";
+import { Button } from "@/components/Button";
+import { ConfirmDialog } from "@/components/ConfirmDialog";
+
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+export default function SettingsMacrosEditRoute() {
+  const { macros, saveMacros } = useMacrosStore();
+  const [isUpdating, setIsUpdating] = useState(false);
+  const [isDeleting, setIsDeleting] = useState(false);
+  const navigate = useNavigate();
+  const { macroId } = useParams<{ macroId: string }>();
+  const [macro, setMacro] = useState<KeySequence | null>(null);
+  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+
+  useEffect(() => {
+    const foundMacro = macros.find(m => m.id === macroId);
+    if (foundMacro) {
+      setMacro({
+        ...foundMacro,
+        steps: foundMacro.steps.map(step => ({
+          ...step,
+          keys: Array.isArray(step.keys) ? step.keys : [],
+          modifiers: Array.isArray(step.modifiers) ? step.modifiers : [],
+          delay: typeof step.delay === 'number' ? step.delay : 0
+        }))
+      });
+    } else {
+      navigate("../");
+    }
+  }, [macroId, macros, navigate]);
+
+  const handleUpdateMacro = async (updatedMacro: Partial<KeySequence>) => {
+    if (!macro) return;
+
+    setIsUpdating(true);
+    try {
+      const newMacros = macros.map(m => 
+        m.id === macro.id ? {
+          ...macro,
+          name: updatedMacro.name!.trim(),
+          steps: updatedMacro.steps || [],
+        } : m
+      );
+
+      await saveMacros(normalizeSortOrders(newMacros));
+      notifications.success(`Macro "${updatedMacro.name}" updated successfully`);
+      navigate("../");
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to update macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to update macro");
+      }
+    } finally {
+      setIsUpdating(false);
+    }
+  };
+
+  const handleDeleteMacro = async () => {
+    if (!macro) return;
+
+    setIsDeleting(true);
+    try {
+      const updatedMacros = normalizeSortOrders(macros.filter(m => m.id !== macro.id));
+      await saveMacros(updatedMacros);
+      notifications.success(`Macro "${macro.name}" deleted successfully`);
+      navigate("../macros");
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to delete macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to delete macro");
+      }
+    } finally {
+      setIsDeleting(false);
+    }
+  };
+
+  if (!macro) return null;
+
+  return (
+    <div className="space-y-4">
+      <div className="flex items-center justify-between">
+        <SettingsPageHeader
+          title="Edit Macro"
+          description="Modify your keyboard macro"
+        />
+        <Button
+          size="SM"
+          theme="danger"
path: ui/src/routes/devices.$id.settings.macros.edit.tsx
user: login: adamshiervani
body: Could you make this `theme=light` and with but with red text? It currently takes to much focus
created_at: 2025-04-07T22:28:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,133 @@
+import { useNavigate, useParams } from "react-router-dom";
+import { useState, useEffect } from "react";
+import { LuTrash2 } from "react-icons/lu";
+
+import { KeySequence, useMacrosStore } from "@/hooks/stores";
+import { SettingsPageHeader } from "@/components/SettingsPageheader";
+import { MacroForm } from "@/components/MacroForm";
+import notifications from "@/notifications";
+import { Button } from "@/components/Button";
+import { ConfirmDialog } from "@/components/ConfirmDialog";
+
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+export default function SettingsMacrosEditRoute() {
+  const { macros, saveMacros } = useMacrosStore();
+  const [isUpdating, setIsUpdating] = useState(false);
+  const [isDeleting, setIsDeleting] = useState(false);
+  const navigate = useNavigate();
+  const { macroId } = useParams<{ macroId: string }>();
+  const [macro, setMacro] = useState<KeySequence | null>(null);
+  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+
+  useEffect(() => {
+    const foundMacro = macros.find(m => m.id === macroId);
+    if (foundMacro) {
+      setMacro({
+        ...foundMacro,
+        steps: foundMacro.steps.map(step => ({
+          ...step,
+          keys: Array.isArray(step.keys) ? step.keys : [],
+          modifiers: Array.isArray(step.modifiers) ? step.modifiers : [],
+          delay: typeof step.delay === 'number' ? step.delay : 0
+        }))
+      });
+    } else {
+      navigate("../");
+    }
+  }, [macroId, macros, navigate]);
+
+  const handleUpdateMacro = async (updatedMacro: Partial<KeySequence>) => {
+    if (!macro) return;
+
+    setIsUpdating(true);
+    try {
+      const newMacros = macros.map(m => 
+        m.id === macro.id ? {
+          ...macro,
+          name: updatedMacro.name!.trim(),
+          steps: updatedMacro.steps || [],
+        } : m
+      );
+
+      await saveMacros(normalizeSortOrders(newMacros));
+      notifications.success(`Macro "${updatedMacro.name}" updated successfully`);
+      navigate("../");
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to update macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to update macro");
+      }
+    } finally {
+      setIsUpdating(false);
+    }
+  };
+
+  const handleDeleteMacro = async () => {
+    if (!macro) return;
+
+    setIsDeleting(true);
+    try {
+      const updatedMacros = normalizeSortOrders(macros.filter(m => m.id !== macro.id));
+      await saveMacros(updatedMacros);
+      notifications.success(`Macro "${macro.name}" deleted successfully`);
+      navigate("../macros");
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to delete macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to delete macro");
+      }
+    } finally {
+      setIsDeleting(false);
+    }
+  };
+
+  if (!macro) return null;
+
+  return (
+    <div className="space-y-4">
+      <div className="flex items-center justify-between">
+        <SettingsPageHeader
+          title="Edit Macro"
+          description="Modify your keyboard macro"
+        />
+        <Button
+          size="SM"
+          theme="danger"
path: ui/src/routes/devices.$id.settings.macros.edit.tsx
user: login: Savid
body: updated
created_at: 2025-04-09T13:44:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,307 @@
+import { useEffect, Fragment, useMemo, useState, useCallback } from "react";
+import { useNavigate } from "react-router-dom";
+import { LuPenLine, LuCopy, LuMoveRight, LuCornerDownRight, LuArrowUp, LuArrowDown, LuTrash2, LuCommand } from "react-icons/lu";
+
+import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
+import { SettingsPageHeader } from "@/components/SettingsPageheader";
+import { Button } from "@/components/Button";
+import EmptyCard from "@/components/EmptyCard";
+import Card from "@/components/Card";
+import { MAX_TOTAL_MACROS, COPY_SUFFIX } from "@/constants/macros";
+import { keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings";
+import notifications from "@/notifications";
+import { ConfirmDialog } from "@/components/ConfirmDialog";
+import LoadingSpinner from "@/components/LoadingSpinner";
+
+const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
+  return macros.map((macro, index) => ({
+    ...macro,
+    sortOrder: index + 1,
+  }));
+};
+
+export default function SettingsMacrosRoute() {
+  const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore();
+  const navigate = useNavigate();
+  const [actionLoadingId, setActionLoadingId] = useState<string | null>(null);
+  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+  const [macroToDelete, setMacroToDelete] = useState<KeySequence | null>(null);
+  
+  const isMaxMacrosReached = useMemo(() => 
+    macros.length >= MAX_TOTAL_MACROS, 
+    [macros.length]
+  );
+
+  useEffect(() => {
+    if (!initialized) {
+      loadMacros();
+    }
+  }, [initialized, loadMacros]);
+
+  const handleDuplicateMacro = useCallback(async (macro: KeySequence) => {
+    if (!macro?.id || !macro?.name) {
+      notifications.error("Invalid macro data");
+      return;
+    }
+
+    if (isMaxMacrosReached) {
+      notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
+      return;
+    }
+
+    setActionLoadingId(macro.id);
+
+    const newMacroCopy: KeySequence = {
+      ...JSON.parse(JSON.stringify(macro)),
+      id: generateMacroId(),
+      name: `${macro.name} ${COPY_SUFFIX}`,
+      sortOrder: macros.length + 1,
+    };
+
+    try {
+      await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
+      notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to duplicate macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to duplicate macro");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  }, [isMaxMacrosReached, macros, saveMacros, setActionLoadingId]);
+
+  const handleMoveMacro = useCallback(async (index: number, direction: 'up' | 'down', macroId: string) => {
+    if (!Array.isArray(macros) || macros.length === 0) {
+      notifications.error("No macros available");
+      return;
+    }
+
+    const newIndex = direction === 'up' ? index - 1 : index + 1;
+    if (newIndex < 0 || newIndex >= macros.length) return;
+
+    setActionLoadingId(macroId);
+
+    try {
+      const newMacros = [...macros];
+      [newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]];
+      const updatedMacros = normalizeSortOrders(newMacros);
+
+      await saveMacros(updatedMacros);
+      notifications.success("Macro order updated successfully");
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to reorder macros: ${error.message}`);
+      } else {
+        notifications.error("Failed to reorder macros");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  }, [macros, saveMacros, setActionLoadingId]);
+
+  const handleDeleteMacro = useCallback(async () => {
+    if (!macroToDelete?.id) return;
+
+    setActionLoadingId(macroToDelete.id);
+    try {
+      const updatedMacros = normalizeSortOrders(macros.filter(m => m.id !== macroToDelete.id));
+      await saveMacros(updatedMacros);
+      notifications.success(`Macro "${macroToDelete.name}" deleted successfully`);
+      setShowDeleteConfirm(false);
+      setMacroToDelete(null);
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        notifications.error(`Failed to delete macro: ${error.message}`);
+      } else {
+        notifications.error("Failed to delete macro");
+      }
+    } finally {
+      setActionLoadingId(null);
+    }
+  }, [macroToDelete, macros, saveMacros]);
+
+  const MacroList = useMemo(() => (
+    <div className="space-y-2">
+      {macros.map((macro, index) => (
+        <Card key={macro.id} className="p-2 bg-white dark:bg-slate-800">
+          <div className="flex items-center justify-between">
+            <div className="flex flex-col gap-1 px-2">
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'up', macro.id)}
+                disabled={index === 0 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowUp}
+                aria-label={`Move ${macro.name} up`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                onClick={() => handleMoveMacro(index, 'down', macro.id)}
+                disabled={index === macros.length - 1 || actionLoadingId === macro.id}
+                LeadingIcon={LuArrowDown}
+                aria-label={`Move ${macro.name} down`}
+              />
+            </div>
+
+            <div className="flex-1 min-w-0 flex flex-col justify-center ml-2">
+              <h3 className="truncate text-sm font-semibold text-black dark:text-white">
+                {macro.name}
+              </h3>
+              <p className="mt-1 ml-4 text-xs text-slate-500 dark:text-slate-400 overflow-hidden">
+                <span className="flex flex-col items-start gap-1">
+                  {macro.steps.map((step, stepIndex) => {
+                    const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight;
+
+                    return (
+                      <span key={stepIndex} className="inline-flex items-center">
+                        <StepIcon className="mr-1 text-slate-400 dark:text-slate-500 h-3 w-3 flex-shrink-0" />
+                        <span className="bg-slate-50 dark:bg-slate-800 px-2 py-0.5 rounded-md border border-slate-200/50 dark:border-slate-700/50">
+                          {(Array.isArray(step.modifiers) && step.modifiers.length > 0) || (Array.isArray(step.keys) && step.keys.length > 0) ? (
+                            <>
+                              {Array.isArray(step.modifiers) && step.modifiers.map((modifier, idx) => (
+                                <Fragment key={`mod-${idx}`}>
+                                  <span className="font-medium text-slate-600 dark:text-slate-200">
+                                    {modifierDisplayMap[modifier] || modifier}
+                                  </span>
+                                  {idx < step.modifiers.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+
+                              {Array.isArray(step.modifiers) && step.modifiers.length > 0 && Array.isArray(step.keys) && step.keys.length > 0 && (
+                                <span className="text-slate-400 dark:text-slate-600"> + </span>
+                              )}
+
+                              {Array.isArray(step.keys) && step.keys.map((key, idx) => (
+                                <Fragment key={`key-${idx}`}>
+                                  <span className="font-medium text-blue-600 dark:text-blue-400">
+                                    {keyDisplayMap[key] || key}
+                                  </span>
+                                  {idx < step.keys.length - 1 && (
+                                    <span className="text-slate-400 dark:text-slate-600"> + </span>
+                                  )}
+                                </Fragment>
+                              ))}
+                            </>
+                          ) : (
+                            <span className="font-medium text-slate-500 dark:text-slate-400">Delay only</span>
+                          )}
+                          <span className="ml-1 text-slate-400 dark:text-slate-500">({step.delay}ms)</span>
+                        </span>
+                      </span>
+                    );
+                  })}
+                </span>
+              </p>
+            </div>
+
+            <div className="flex items-center gap-1 ml-4">
+              <Button
+                size="XS"
+                className="text-red-500 dark:text-red-400"
+                theme="light"
+                LeadingIcon={LuTrash2}
+                onClick={() => {
+                  setMacroToDelete(macro);
+                  setShowDeleteConfirm(true);
+                }}
+                disabled={actionLoadingId === macro.id}
+                aria-label={`Delete macro ${macro.name}`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                LeadingIcon={LuCopy}
+                onClick={() => handleDuplicateMacro(macro)}
+                disabled={actionLoadingId === macro.id}
+                aria-label={`Duplicate macro ${macro.name}`}
+              />
+              <Button
+                size="XS"
+                theme="light"
+                LeadingIcon={LuPenLine}
+                text="Edit"
+                onClick={() => navigate(`${macro.id}/edit`)}
+                disabled={actionLoadingId === macro.id}
+                aria-label={`Edit macro ${macro.name}`}
+              />
+            </div>
+          </div>
+        </Card>
+      ))}
+
+      <ConfirmDialog
+        open={showDeleteConfirm}
+        onClose={() => {
+          setShowDeleteConfirm(false);
+          setMacroToDelete(null);
+        }}
+        title="Delete Macro"
+        description={`Are you sure you want to delete "${macroToDelete?.name}"? This action cannot be undone.`}
+        variant="danger"
+        confirmText={actionLoadingId === macroToDelete?.id ? "Deleting..." : "Delete"}
+        onConfirm={handleDeleteMacro}
+        isConfirming={actionLoadingId === macroToDelete?.id}
+      />
+    </div>
+  ), [macros, actionLoadingId, showDeleteConfirm, macroToDelete, handleDeleteMacro]);
+
+  return (
+    <div className="space-y-4">
+      {macros.length > 0 && (
+        <div className="flex items-center justify-between">
+          <SettingsPageHeader
path: ui/src/routes/devices.$id.settings.macros.tsx
user: login: Savid
body: done
created_at: 2025-04-09T13:44:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,246 @@
+import { LuArrowUp, LuArrowDown, LuX, LuInfo } from "react-icons/lu";
+
+import { Button } from "@/components/Button";
+import { Combobox } from "@/components/Combobox";
+import { SelectMenuBasic } from "@/components/SelectMenuBasic";
+import Card from "@/components/Card";
+import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
+import { MAX_KEYS_PER_STEP } from "@/constants/macros";
+
+// Filter out modifier keys since they're handled in the modifiers section
+const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta'];
+
+const keyOptions = Object.keys(keys)
+  .filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
+  .map(key => ({
+    value: key,
+    label: keyDisplayMap[key] || key,
+  }));
+
+const modifierOptions = Object.keys(modifiers).map(modifier => ({
+  value: modifier,
+  label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
+}));
+
+const groupedModifiers: Record<string, typeof modifierOptions> = {
+  Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
+  Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
+  Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
+  Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
+};
+
+const PRESET_DELAYS = [
+  { value: "50", label: "50ms" },
+  { value: "100", label: "100ms" },
+  { value: "200", label: "200ms" },
+  { value: "300", label: "300ms" },
+  { value: "500", label: "500ms" },
+  { value: "750", label: "750ms" },
+  { value: "1000", label: "1000ms" },
+  { value: "1500", label: "1500ms" },
+  { value: "2000", label: "2000ms" },
+];
+
+interface MacroStep {
+  keys: string[];
+  modifiers: string[];
+  delay: number;
+}
+
+interface MacroStepCardProps {
+  step: MacroStep;
+  stepIndex: number;
+  onDelete?: () => void;
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  onKeySelect: (option: { value: string | null; keys?: string[] }) => void;
+  onKeyQueryChange: (query: string) => void;
+  keyQuery: string;
+  onModifierChange: (modifiers: string[]) => void;
+  onDelayChange: (delay: number) => void;
+  isLastStep: boolean;
+}
+
+const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
+  return Array.isArray(arr) ? arr : [];
+};
+
+export function MacroStepCard({
+  step,
+  stepIndex,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  onKeySelect,
+  onKeyQueryChange,
+  keyQuery,
+  onModifierChange,
+  onDelayChange,
+  isLastStep
+}: MacroStepCardProps) {
+  const getFilteredKeys = () => {
+    const selectedKeys = ensureArray(step.keys);
+    const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value));
+    
+    if (keyQuery === '') {
+      return availableKeys;
+    } else {
+      return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase()));
+    }
+  };
+
+  return (
+    <Card className="p-4">
+      <div className="mb-2 flex items-center justify-between">
+        <div className="flex items-center gap-1.5">
+          <div className="flex items-center gap-1">
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveUp}
+              disabled={stepIndex === 0}
+              LeadingIcon={LuArrowUp}
+            />
+            <Button
+              size="XS"
+              theme="light"
+              onClick={onMoveDown}
+              disabled={isLastStep}
+              LeadingIcon={LuArrowDown}
+            />
+          </div>
+          <span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
+            {stepIndex + 1}
+          </span>
+        </div>
+        
+        <div className="flex items-center space-x-2">
+          {onDelete && (
+            <Button
+              size="XS"
+              theme="danger"
+              text="Delete"
+              onClick={onDelete}
+            />
+          )}
+        </div>
+      </div>
+      
+      <div className="space-y-4 mt-2">
+        <div className="w-full flex flex-col gap-2">
+          <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+            Modifiers
+          </label>
+          <div className="inline-flex flex-wrap gap-3">
+            {Object.entries(groupedModifiers).map(([group, mods]) => (
+              <div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
+                <span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
+                  {group}
+                </span>
+                <div className="flex flex-wrap gap-1">
+                  {mods.map(option => (
+                    <label 
+                      key={option.value} 
+                      className={`flex items-center px-2 py-1 rounded border cursor-pointer text-xs font-medium transition-colors ${
+                        ensureArray(step.modifiers).includes(option.value) 
+                          ? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-200' 
+                          : 'bg-slate-100 border-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700'
+                      }`}
+                    >
+                      <input
+                        type="checkbox"
+                        className="sr-only"
+                        checked={ensureArray(step.modifiers).includes(option.value)}
+                        onChange={e => {
+                          const modifiersArray = ensureArray(step.modifiers);
+                          const newModifiers = e.target.checked
+                            ? [...modifiersArray, option.value]
+                            : modifiersArray.filter(m => m !== option.value);
+                          onModifierChange(newModifiers);
+                        }}
+                      />
+                      {option.label.split(' ')[1] || option.label}
+                    </label>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+              Keys
+            </label>
+            <div className="group relative cursor-pointer">
+              <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+              <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                <p>You can add up to a maximum of {MAX_KEYS_PER_STEP} keys to press per step.</p>
+              </div>
+            </div>
+          </div>
+          <div className="flex flex-wrap gap-1 pb-2">
+            {ensureArray(step.keys).map((key, keyIndex) => (
+              <span
+                key={keyIndex}
+                className="inline-flex items-center rounded-md bg-blue-100 px-1 text-xs font-medium text-blue-800 dark:bg-blue-900/40 dark:text-blue-200"
+              >
+                <span className="px-1">
+                  {keyDisplayMap[key] || key}
+                </span>
+                <Button
+                  size="XS"
+                  className=""
+                  theme="blank"
+                  onClick={() => {
+                    const newKeys = ensureArray(step.keys).filter((_, i) => i !== keyIndex);
+                    onKeySelect({ value: null, keys: newKeys });
+                  }}
+                  LeadingIcon={LuX}
+                />
+              </span>
+            ))}
+          </div>
+          <div className="relative w-full">
+            <Combobox
+              onChange={(value: { value: string; label: string }) => onKeySelect(value)}
+              displayValue={() => keyQuery}
+              onInputChange={onKeyQueryChange}
+              options={getFilteredKeys}
+              disabledMessage="Max keys reached"
+              size="SM"
+              immediate
+              disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP}
+              placeholder={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP ? "Max keys reached" : "Search for key..."}
+              emptyMessage="No matching keys found"
+            />
+          </div>
+        </div>
+        
+        <div className="w-full flex flex-col gap-1">
+          <div className="flex items-center gap-1">
+            <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
+              Step Duration
+            </label>
+            <div className="group relative cursor-pointer">
+              <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
+              <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
+                <p>The time to wait after pressing the keys in this step before moving to the next step. This helps ensure reliable key presses when automating keyboard input.</p>
+              </div>
+            </div>
+          </div>
+          <div className="flex items-center gap-3">
+            <SelectMenuBasic
+              size="SM"
+              fullWidth
+              value={step.delay.toString()}
+              onChange={(e) => onDelayChange(parseInt(e.target.value, 10))}
+              options={PRESET_DELAYS}
+            />
+          </div>
+        </div>
path: ui/src/components/MacroStepCard.tsx
user: login: Savid
body: Got yah, done
created_at: 2025-04-09T13:44:30Z
author_association: CONTRIBUTOR

PR comments:
user: login: CLAassistant
created_at: 2025-03-29T08:10:46Z
body: [![CLA assistant check](https://cla-assistant.io/pull/badge/signed)](https://cla-assistant.io/jetkvm/kvm?pullRequest=305) <br/>All committers have signed the CLA.
author_association: NONE
user: login: Nevexo
created_at: 2025-03-29T11:24:43Z
body: This looks amazing! Thanks for working on it.
author_association: CONTRIBUTOR
user: login: Savid
created_at: 2025-04-09T13:46:48Z
body: i'm getting some local dev issues from this commit https://github.com/jetkvm/kvm/pull/336 when running ui from https://github.com/jetkvm/kvm/blob/dev/ui/dev_device.sh
author_association: CONTRIBUTOR
user: login: adamshiervani
created_at: 2025-04-09T22:06:25Z
body: Fixed and merged in: https://github.com/jetkvm/kvm/pull/342
author_association: CONTRIBUTOR
user: login: adamshiervani
created_at: 2025-04-10T20:51:41Z
body: Great work, @Savid! Will be included in the next version 0.4.0.
author_association: CONTRIBUTOR
user: login: ReanimationXP
created_at: 2025-04-12T21:15:35Z
body: This is really awesome - I'm not sure what exactly the edit buttons do when viewing the macros steps, so maybe you already have this, but it would be cool to expand this for easy use with really long strings (one-liners, long passwords, password prefixes, api keys, etc). Could this be improved to support those use cases as well? It'd be nice to have like a text area for something like duckyscript rather than having to enter each character in one at a time via a GUI, so I could put common one-liners like "apt-get update; apt-get upgrade;ENTER" on a button without it being 5 pages long and taking half an hour to enter in :)  (actual duckyscript would be slightly different but you get the idea. it supports specifying the delay and strings and other things too.)

Quick Intro: https://github.com/dekuNukem/duckyPad/blob/master/duckyscript_info.md
Full Reference: https://docs.hak5.org/hak5-usb-rubber-ducky/duckyscript-tm-quick-reference

I don't know that supporting the full spec would be necessary because DuckyScript 3 gets into if statements and so forth, but support for the basics like strings, special characters and escaping presumably, and special keys would be really really awesome. I can't purchase a unit right now due to US orders being shut down over tariffs, but I applied for a PoE beta unit, and if I were sent one I would happily look into making this a reality, and I'd love to have OP's help if they don't want to do it themselves :)

author_association: NONE