From 7b343ea9b4709daef18f5739eec2d305d89d893f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Mar 2026 01:39:18 +0000 Subject: [PATCH 1/2] Simplify tweaker to drag-only prompts Co-authored-by: Ben MacLaurin --- packages/tweaker/package.json | 16 +- packages/tweaker/src/constants.ts | 38 +- packages/tweaker/src/gray-scales.ts | 116 -- packages/tweaker/src/index.tsx | 3 +- packages/tweaker/src/tweaker.test.tsx | 170 +++ packages/tweaker/src/tweaker.tsx | 1067 ++++++++--------- packages/tweaker/src/types.ts | 31 +- packages/tweaker/src/utils/color.ts | 110 -- .../src/utils/dragged-elements.test.ts | 39 + .../src/utils/get-moved-dragged-elements.ts | 6 + packages/tweaker/src/utils/modification.ts | 52 - packages/tweaker/src/utils/nearby.ts | 151 +-- packages/tweaker/src/utils/prompt.test.ts | 98 ++ packages/tweaker/src/utils/prompt.ts | 170 ++- .../tweaker/src/utils/translate-preview.ts | 17 + .../src/utils/upsert-dragged-element.ts | 18 + pnpm-lock.yaml | 457 +++++-- 17 files changed, 1352 insertions(+), 1207 deletions(-) delete mode 100644 packages/tweaker/src/gray-scales.ts create mode 100644 packages/tweaker/src/tweaker.test.tsx delete mode 100644 packages/tweaker/src/utils/color.ts create mode 100644 packages/tweaker/src/utils/dragged-elements.test.ts create mode 100644 packages/tweaker/src/utils/get-moved-dragged-elements.ts delete mode 100644 packages/tweaker/src/utils/modification.ts create mode 100644 packages/tweaker/src/utils/prompt.test.ts create mode 100644 packages/tweaker/src/utils/translate-preview.ts create mode 100644 packages/tweaker/src/utils/upsert-dragged-element.ts diff --git a/packages/tweaker/package.json b/packages/tweaker/package.json index b0b5ca1..1d80805 100644 --- a/packages/tweaker/package.json +++ b/packages/tweaker/package.json @@ -1,12 +1,13 @@ { "name": "@ben-million/tweaker", "version": "0.9.2", - "description": "A dev tool for tweaking colors along gray scales in React apps", + "description": "A dev tool for dragging page elements and generating DOM repositioning prompts", "keywords": [ - "color", - "design-system", "dev-tools", - "gray-scale", + "dom", + "drag-and-drop", + "layout", + "prompt", "react" ], "homepage": "https://github.com/millionco/tweaker#readme", @@ -33,14 +34,15 @@ "scripts": { "dev": "tsdown --watch", "build": "rm -rf dist && NODE_ENV=production tsdown", + "test": "vitest run --environment jsdom", "typecheck": "tsc --noEmit" }, - "dependencies": { - "motion": "^12.0.0" - }, "devDependencies": { "@types/react": "^19", "@types/react-dom": "^19", + "jsdom": "^28.1.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", "tsdown": "^0.20.3" }, "peerDependencies": { diff --git a/packages/tweaker/src/constants.ts b/packages/tweaker/src/constants.ts index 6899cbd..4260ffa 100644 --- a/packages/tweaker/src/constants.ts +++ b/packages/tweaker/src/constants.ts @@ -1,27 +1,13 @@ -export const SLIDER_MAX = 10; export const TEXT_PREVIEW_MAX_LENGTH = 30; -export const TYPING_RESET_DELAY_MS = 1500; -export const SHADE_KEYS = [ - "50", - "100", - "200", - "300", - "400", - "500", - "600", - "700", - "800", - "900", - "950", -]; -export const FONT_SIZE_MIN_PX = 1; -export const FONT_SIZE_MAX_PX = 200; -export const PADDING_MIN_PX = -200; -export const PADDING_MAX_PX = 200; -export const MOUSE_COLOR_SENSITIVITY = 0.02; -export const MOUSE_SIZE_SENSITIVITY = 0.1; -export const MOUSE_PADDING_SENSITIVITY = 0.2; -export const MINIMAP_WIDTH_PX = 160; -export const MINIMAP_HEIGHT_PX = 100; -export const THUMB_SIZE_PX = 10; -export const DOM_TREE_MAX_NODES = 40; +export const TWEAKER_OFFSET_PX = 16; +export const TWEAKER_Z_INDEX = 9999; +export const TWEAKER_BUTTON_GAP_PX = 8; +export const TWEAKER_BUTTON_PADDING_X_PX = 12; +export const TWEAKER_BUTTON_PADDING_Y_PX = 8; +export const TWEAKER_BUTTON_BORDER_RADIUS_PX = 999; +export const TWEAKER_BUTTON_FONT_SIZE_PX = 12; +export const TWEAKER_STATUS_FONT_SIZE_PX = 11; +export const TWEAKER_DRAG_THRESHOLD_PX = 3; +export const TWEAKER_HOVER_OUTLINE_WIDTH_PX = 2; +export const TWEAKER_HOVER_OUTLINE_OFFSET_PX = 2; +export const TWEAKER_STATUS_RESET_DELAY_MS = 2000; diff --git a/packages/tweaker/src/gray-scales.ts b/packages/tweaker/src/gray-scales.ts deleted file mode 100644 index f209bc7..0000000 --- a/packages/tweaker/src/gray-scales.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { GrayScale } from "./types"; - -export const GRAY_SCALES: Record = { - neutral: { - label: "Neutral", - shades: { - "50": "oklch(0.985 0 0)", - "100": "oklch(0.97 0 0)", - "200": "oklch(0.922 0 0)", - "300": "oklch(0.87 0 0)", - "400": "oklch(0.708 0 0)", - "500": "oklch(0.556 0 0)", - "600": "oklch(0.439 0 0)", - "700": "oklch(0.371 0 0)", - "800": "oklch(0.269 0 0)", - "900": "oklch(0.205 0 0)", - "950": "oklch(0.145 0 0)", - }, - }, - slate: { - label: "Slate", - shades: { - "50": "oklch(0.984 0.003 247.858)", - "100": "oklch(0.968 0.007 247.896)", - "200": "oklch(0.929 0.013 255.508)", - "300": "oklch(0.869 0.022 252.894)", - "400": "oklch(0.704 0.04 256.788)", - "500": "oklch(0.554 0.046 257.417)", - "600": "oklch(0.446 0.043 257.281)", - "700": "oklch(0.372 0.044 257.287)", - "800": "oklch(0.279 0.041 260.031)", - "900": "oklch(0.208 0.042 265.755)", - "950": "oklch(0.129 0.042 264.695)", - }, - }, - gray: { - label: "Gray", - shades: { - "50": "oklch(0.985 0.002 247.839)", - "100": "oklch(0.967 0.003 264.542)", - "200": "oklch(0.928 0.006 264.531)", - "300": "oklch(0.872 0.01 258.338)", - "400": "oklch(0.707 0.022 261.325)", - "500": "oklch(0.551 0.027 264.364)", - "600": "oklch(0.446 0.03 256.802)", - "700": "oklch(0.373 0.034 259.733)", - "800": "oklch(0.278 0.033 256.848)", - "900": "oklch(0.21 0.034 264.665)", - "950": "oklch(0.13 0.028 261.692)", - }, - }, - zinc: { - label: "Zinc", - shades: { - "50": "oklch(0.985 0 0)", - "100": "oklch(0.967 0.001 286.375)", - "200": "oklch(0.92 0.004 286.32)", - "300": "oklch(0.871 0.006 286.286)", - "400": "oklch(0.705 0.015 286.067)", - "500": "oklch(0.552 0.016 285.938)", - "600": "oklch(0.442 0.017 285.786)", - "700": "oklch(0.37 0.013 285.805)", - "800": "oklch(0.274 0.006 286.033)", - "900": "oklch(0.21 0.006 285.885)", - "950": "oklch(0.141 0.005 285.823)", - }, - }, - stone: { - label: "Stone", - shades: { - "50": "oklch(0.985 0.001 106.423)", - "100": "oklch(0.97 0.001 106.424)", - "200": "oklch(0.923 0.003 48.717)", - "300": "oklch(0.869 0.005 56.366)", - "400": "oklch(0.709 0.01 56.259)", - "500": "oklch(0.553 0.013 58.071)", - "600": "oklch(0.444 0.011 73.639)", - "700": "oklch(0.374 0.01 67.558)", - "800": "oklch(0.268 0.007 34.298)", - "900": "oklch(0.216 0.006 56.043)", - "950": "oklch(0.147 0.004 49.25)", - }, - }, - mauve: { - label: "Mauve", - shades: { - "50": "oklch(0.985 0.003 310)", - "100": "oklch(0.968 0.006 310)", - "200": "oklch(0.925 0.011 310)", - "300": "oklch(0.87 0.017 310)", - "400": "oklch(0.708 0.03 310)", - "500": "oklch(0.556 0.03 310)", - "600": "oklch(0.44 0.028 310)", - "700": "oklch(0.372 0.025 310)", - "800": "oklch(0.27 0.02 310)", - "900": "oklch(0.208 0.018 310)", - "950": "oklch(0.145 0.014 310)", - }, - }, - olive: { - label: "Olive", - shades: { - "50": "oklch(0.985 0.003 130)", - "100": "oklch(0.968 0.006 130)", - "200": "oklch(0.925 0.011 130)", - "300": "oklch(0.87 0.017 130)", - "400": "oklch(0.708 0.028 130)", - "500": "oklch(0.556 0.028 130)", - "600": "oklch(0.44 0.024 130)", - "700": "oklch(0.372 0.02 130)", - "800": "oklch(0.27 0.016 130)", - "900": "oklch(0.208 0.014 130)", - "950": "oklch(0.145 0.01 130)", - }, - }, -}; diff --git a/packages/tweaker/src/index.tsx b/packages/tweaker/src/index.tsx index cac4b37..b790c29 100644 --- a/packages/tweaker/src/index.tsx +++ b/packages/tweaker/src/index.tsx @@ -1,3 +1,2 @@ export { Tweaker } from "./tweaker"; -export { GRAY_SCALES } from "./gray-scales"; -export type { TweakerProps, GrayScale } from "./types"; +export type { TweakerProps } from "./types"; diff --git a/packages/tweaker/src/tweaker.test.tsx b/packages/tweaker/src/tweaker.test.tsx new file mode 100644 index 0000000..1293c3c --- /dev/null +++ b/packages/tweaker/src/tweaker.test.tsx @@ -0,0 +1,170 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Tweaker } from "./tweaker"; + +interface TestRect { + left: number; + top: number; + width: number; + height: number; +} + +const createDomRect = (rect: TestRect): DOMRect => { + const right = rect.left + rect.width; + const bottom = rect.top + rect.height; + + return { + bottom, + height: rect.height, + left: rect.left, + right, + top: rect.top, + width: rect.width, + x: rect.left, + y: rect.top, + toJSON: () => ({}), + } as DOMRect; +}; + +const parseTranslate = (translateValue: string): { x: number; y: number } => { + const match = translateValue.match(/^(-?\d+)px\s+(-?\d+)px$/); + if (!match) { + return { x: 0, y: 0 }; + } + + return { + x: Number(match[1]), + y: Number(match[2]), + }; +}; + +const attachRect = (element: HTMLElement, rect: TestRect) => { + element.getBoundingClientRect = () => { + const translateOffset = parseTranslate(element.style.getPropertyValue("translate")); + return createDomRect({ + ...rect, + left: rect.left + translateOffset.x, + top: rect.top + translateOffset.y, + }); + }; +}; + +describe("Tweaker", () => { + const writeTextMock = vi.fn(async (_text: string) => undefined); + let containerElement: HTMLDivElement; + let root: ReturnType | null = null; + + beforeEach(() => { + containerElement = document.createElement("div"); + document.body.appendChild(containerElement); + writeTextMock.mockReset(); + (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + + Object.defineProperty(navigator, "clipboard", { + value: { writeText: writeTextMock }, + configurable: true, + }); + }); + + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount(); + }); + root = null; + } + containerElement.remove(); + }); + + it("lets users drag elements and copy a position-only prompt", async () => { + const rootInstance = createRoot(containerElement); + root = rootInstance; + + await act(async () => { + rootInstance.render( + <> +
+
One
+
Two
+
Three
+
+ + , + ); + }); + + const stackElement = containerElement.querySelector("#stack"); + const cardTwoElement = containerElement.querySelector("#card-two"); + const cardOneElement = containerElement.querySelector("#card-one"); + const cardThreeElement = containerElement.querySelector("#card-three"); + + if ( + !(stackElement instanceof HTMLElement) || + !(cardOneElement instanceof HTMLElement) || + !(cardTwoElement instanceof HTMLElement) || + !(cardThreeElement instanceof HTMLElement) + ) { + throw new Error("Test elements did not render"); + } + + attachRect(stackElement, { left: 0, top: 0, width: 240, height: 220 }); + attachRect(cardOneElement, { left: 0, top: 60, width: 120, height: 20 }); + attachRect(cardTwoElement, { left: 0, top: 100, width: 120, height: 20 }); + attachRect(cardThreeElement, { left: 0, top: 140, width: 120, height: 20 }); + + await act(async () => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "t", bubbles: true })); + }); + + expect(containerElement.textContent).toContain("Tweaker on"); + + await act(async () => { + cardTwoElement.dispatchEvent( + new MouseEvent("mousedown", { + bubbles: true, + button: 0, + clientX: 20, + clientY: 100, + }), + ); + document.dispatchEvent( + new MouseEvent("mousemove", { + bubbles: true, + clientX: 20, + clientY: 20, + }), + ); + window.dispatchEvent( + new MouseEvent("mouseup", { + bubbles: true, + button: 0, + clientX: 20, + clientY: 20, + }), + ); + }); + + expect(cardTwoElement.style.getPropertyValue("translate")).toBe("0px -80px"); + + await act(async () => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + }); + + expect(writeTextMock).toHaveBeenCalledTimes(1); + + const copiedPrompt = writeTextMock.mock.calls[0]?.[0] ?? ""; + expect(copiedPrompt).toContain("Move from child #2 to child #1"); + expect(copiedPrompt).toContain("Do not use CSS transforms"); + expect(copiedPrompt).not.toContain("color"); + expect(copiedPrompt).not.toContain("font-size"); + + await act(async () => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + }); + + expect(cardTwoElement.style.getPropertyValue("translate")).toBe(""); + expect(containerElement.textContent).toContain("Tweaker off"); + }); +}); diff --git a/packages/tweaker/src/tweaker.tsx b/packages/tweaker/src/tweaker.tsx index 3967d8c..40b44ae 100644 --- a/packages/tweaker/src/tweaker.tsx +++ b/packages/tweaker/src/tweaker.tsx @@ -1,615 +1,536 @@ -import { useState, useCallback, useEffect, useRef } from "react"; -import { motion, AnimatePresence } from "motion/react"; -import type { Modification, TweakerProps } from "./types"; -import { GRAY_SCALES } from "./gray-scales"; +import { useCallback, useEffect, useRef, useState } from "react"; import { - SLIDER_MAX, - TYPING_RESET_DELAY_MS, - FONT_SIZE_MIN_PX, - FONT_SIZE_MAX_PX, - PADDING_MIN_PX, - PADDING_MAX_PX, - MOUSE_COLOR_SENSITIVITY, - MOUSE_SIZE_SENSITIVITY, - MOUSE_PADDING_SENSITIVITY, - MINIMAP_WIDTH_PX, - MINIMAP_HEIGHT_PX, - THUMB_SIZE_PX, + TWEAKER_BUTTON_BORDER_RADIUS_PX, + TWEAKER_BUTTON_FONT_SIZE_PX, + TWEAKER_BUTTON_GAP_PX, + TWEAKER_BUTTON_PADDING_X_PX, + TWEAKER_BUTTON_PADDING_Y_PX, + TWEAKER_DRAG_THRESHOLD_PX, + TWEAKER_HOVER_OUTLINE_OFFSET_PX, + TWEAKER_HOVER_OUTLINE_WIDTH_PX, + TWEAKER_OFFSET_PX, + TWEAKER_STATUS_FONT_SIZE_PX, + TWEAKER_STATUS_RESET_DELAY_MS, + TWEAKER_Z_INDEX, } from "./constants"; -import { - getColorAtPosition, - oklchToCssString, - parseRgb, - rgbToOklch, - findClosestPosition, -} from "./utils/color"; +import type { DraggedElement, ElementSourceMetadata, TweakerProps } from "./types"; import { getSelector, getTextPreview } from "./utils/dom"; -import { - applyModification, - restoreModification, - roundToStep, - roundToHalf, -} from "./utils/modification"; -import { generatePrompt } from "./utils/prompt"; +import { getMovedDraggedElements } from "./utils/get-moved-dragged-elements"; import { gatherRepositionContext } from "./utils/nearby"; import type { RepositionContext } from "./utils/nearby"; +import { generatePrompt } from "./utils/prompt"; +import { applyTranslatePreview, restoreTranslatePreview } from "./utils/translate-preview"; +import { upsertDraggedElement } from "./utils/upsert-dragged-element"; + +interface HoveredElementState { + element: HTMLElement; + originalInlineOutline: string; + originalInlineOutlineOffset: string; +} + +interface ActiveDragSession { + element: HTMLElement; + startClientX: number; + startClientY: number; + startingTranslateX: number; + startingTranslateY: number; + currentTranslateX: number; + currentTranslateY: number; + didCrossDragThreshold: boolean; +} + +interface ReactGrabFrame { + fileName?: string; + functionName?: string; +} + +interface ReactGrabModule { + getStack: (element: Element) => Promise; +} + +const getEventTargetElement = (eventTarget: EventTarget | null): HTMLElement | null => + eventTarget instanceof HTMLElement ? eventTarget : null; + +const isEditableElement = (eventTarget: EventTarget | null): boolean => { + const targetElement = getEventTargetElement(eventTarget); + if (!targetElement) return false; + if (targetElement.isContentEditable) return true; + + const tagName = targetElement.tagName; + return tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT"; +}; -const requestLock = () => { - if (!document.pointerLockElement) { - document.body.requestPointerLock(); +const getReactGrabModule = (): ReactGrabModule | null => { + const maybeReactGrabModule = (window as unknown as Record).__REACT_GRAB_MODULE__; + if (!maybeReactGrabModule || typeof maybeReactGrabModule !== "object") { + return null; } + + const maybeGetStack = (maybeReactGrabModule as Record).getStack; + if (typeof maybeGetStack !== "function") { + return null; + } + + return maybeReactGrabModule as ReactGrabModule; +}; + +const getElementSourceMetadata = async (element: HTMLElement): Promise => { + let componentName: string | null = null; + let sourceFile: string | null = null; + + try { + const reactGrabModule = getReactGrabModule(); + if (!reactGrabModule) { + return { componentName, sourceFile }; + } + + const stack = await reactGrabModule.getStack(element); + for (const frame of stack) { + if (!frame.fileName || frame.fileName.includes("node_modules")) { + continue; + } + + sourceFile = frame.fileName; + if (frame.functionName && /^[A-Z]/.test(frame.functionName)) { + componentName = frame.functionName; + } + break; + } + } catch {} + + return { componentName, sourceFile }; }; -const releaseLock = () => { - if (document.pointerLockElement) { - document.exitPointerLock(); +const createDraggedElement = (element: HTMLElement): DraggedElement => ({ + element, + selector: getSelector(element), + componentName: null, + sourceFile: null, + textPreview: getTextPreview(element), + originalInlineTranslate: element.style.getPropertyValue("translate"), + translateX: 0, + translateY: 0, +}); + +const getStatusMessage = ( + isEnabled: boolean, + promptStatus: string, + movedDraggedElementCount: number, +): string => { + if (!isEnabled) { + return "Toggle on to drag elements and press Enter for a DOM prompt."; + } + + if (promptStatus === "copied") { + return "Prompt copied."; + } + + if (promptStatus === "copy-failed") { + return "Clipboard copy failed."; } + + if (promptStatus === "empty") { + return "Drag an element before copying."; + } + + if (movedDraggedElementCount === 0) { + return "Drag any element. Press Enter to copy or Escape to reset."; + } + + const draggedElementCountLabel = + movedDraggedElementCount === 1 + ? "1 moved element" + : `${movedDraggedElementCount} moved elements`; + + return `${draggedElementCountLabel}. Press Enter to copy or Escape to reset.`; }; -export const Tweaker = ({ scales = GRAY_SCALES, activeScale = "neutral" }: TweakerProps) => { - const [picking, setPicking] = useState(false); - const [modifications, setModifications] = useState([]); - const [activeIndex, setActiveIndex] = useState(-1); - const [inputValue, setInputValue] = useState(""); - const [shiftHeld, setShiftHeld] = useState(false); - const [spaceHeld, setSpaceHeld] = useState(false); - const [controlHeld, setControlHeld] = useState(false); - const typingBuffer = useRef(""); - const typingTimeout = useRef>(undefined); - - const activeMod = activeIndex >= 0 ? modifications[activeIndex] : null; - const hasModifications = modifications.length > 0; - - const activeIndexRef = useRef(activeIndex); - const activeScaleRef = useRef(activeScale); - const modificationsRef = useRef(modifications); - const scalesRef = useRef(scales); - activeIndexRef.current = activeIndex; - activeScaleRef.current = activeScale; - modificationsRef.current = modifications; - scalesRef.current = scales; - - const updateActivePosition = useCallback( - (newPosition: number) => { - if (activeIndex < 0) return; - setModifications((previous) => { - const updated = [...previous]; - updated[activeIndex] = { ...updated[activeIndex], position: newPosition }; - applyModification(updated[activeIndex], scales, activeScale); - return updated; +export const Tweaker = (_props: TweakerProps) => { + const [isEnabled, setIsEnabled] = useState(false); + const [draggedElements, setDraggedElements] = useState([]); + const [promptStatus, setPromptStatus] = useState("idle"); + + const draggedElementsRef = useRef(draggedElements); + const hoveredElementRef = useRef(null); + const activeDragRef = useRef(null); + const promptStatusTimeoutRef = useRef>(undefined); + + draggedElementsRef.current = draggedElements; + + const syncDraggedElements = useCallback( + (getNextDraggedElements: (previousDraggedElements: DraggedElement[]) => DraggedElement[]) => { + setDraggedElements((previousDraggedElements) => { + const nextDraggedElements = getNextDraggedElements(previousDraggedElements); + draggedElementsRef.current = nextDraggedElements; + return nextDraggedElements; }); }, - [activeIndex, activeScale, scales], + [], ); - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - setShiftHeld(event.shiftKey); - if (event.key === " ") setSpaceHeld(true); - if (event.key === "Control") setControlHeld(true); - }; - const handleKeyUp = (event: KeyboardEvent) => { - setShiftHeld(event.shiftKey); - if (event.key === " ") setSpaceHeld(false); - if (event.key === "Control") setControlHeld(false); - }; - document.addEventListener("keydown", handleKeyDown); - document.addEventListener("keyup", handleKeyUp); - return () => { - document.removeEventListener("keydown", handleKeyDown); - document.removeEventListener("keyup", handleKeyUp); - }; - }, []); + const clearHoveredElement = useCallback(() => { + const hoveredElement = hoveredElementRef.current; + if (!hoveredElement) { + return; + } - useEffect(() => { - if (!hasModifications || picking) return; + hoveredElement.element.style.outline = hoveredElement.originalInlineOutline; + hoveredElement.element.style.outlineOffset = hoveredElement.originalInlineOutlineOffset; + hoveredElementRef.current = null; + }, []); - const handleMouseMove = (event: MouseEvent) => { - if (!document.pointerLockElement) return; - const index = activeIndexRef.current; - if (index < 0) return; - - setModifications((previous) => { - const updated = [...previous]; - const current = updated[index]; - - if (event.ctrlKey) { - updated[index] = { - ...current, - translateX: current.translateX + event.movementX, - translateY: current.translateY + event.movementY, - }; - } else if (event.shiftKey) { - const newPaddingY = Math.max( - PADDING_MIN_PX, - Math.min( - PADDING_MAX_PX, - current.paddingY - event.movementY * MOUSE_PADDING_SENSITIVITY, - ), - ); - updated[index] = { ...current, paddingY: Math.round(newPaddingY) }; - } else { - const newPosition = Math.max( - 0, - Math.min(SLIDER_MAX, current.position - event.movementY * MOUSE_COLOR_SENSITIVITY), - ); - const newSize = Math.max( - FONT_SIZE_MIN_PX, - Math.min(FONT_SIZE_MAX_PX, current.fontSize + event.movementX * MOUSE_SIZE_SENSITIVITY), - ); - updated[index] = { - ...current, - position: roundToStep(newPosition), - fontSize: roundToHalf(newSize), - }; - } + const setHoveredElement = useCallback( + (element: HTMLElement | null) => { + const currentHoveredElement = hoveredElementRef.current?.element; + if (currentHoveredElement === element) { + return; + } - applyModification(updated[index], scalesRef.current, activeScaleRef.current); - setInputValue(String(updated[index].position)); - return updated; - }); - }; + clearHoveredElement(); - requestLock(); - document.addEventListener("mousemove", handleMouseMove, true); - return () => { - document.removeEventListener("mousemove", handleMouseMove, true); - releaseLock(); - }; - }, [hasModifications, picking]); - - const gatherRepositionContexts = async ( - modifications: Modification[], - ): Promise> => { - const contextMap = new Map(); - for (let index = 0; index < modifications.length; index++) { - const modification = modifications[index]; - if (modification.translateX !== 0 || modification.translateY !== 0) { - const context = await gatherRepositionContext( - modification.element, - modification.translateX, - modification.translateY, - ); - if (context) contextMap.set(index, context); + if (!element) { + return; } - } - return contextMap; - }; - useEffect(() => { - if (!hasModifications) return; + hoveredElementRef.current = { + element, + originalInlineOutline: element.style.outline, + originalInlineOutlineOffset: element.style.outlineOffset, + }; + element.style.outline = `${TWEAKER_HOVER_OUTLINE_WIDTH_PX}px solid rgba(59, 130, 246, 0.9)`; + element.style.outlineOffset = `${TWEAKER_HOVER_OUTLINE_OFFSET_PX}px`; + }, + [clearHoveredElement], + ); - const handleKeyDown = async (event: KeyboardEvent) => { - if (event.key === "Escape") { - event.preventDefault(); - releaseLock(); - const contextMap = await gatherRepositionContexts(modificationsRef.current); - const prompt = generatePrompt( - modificationsRef.current, - scalesRef.current, - activeScaleRef.current, - contextMap, - ); - navigator.clipboard.writeText(prompt); - modificationsRef.current.forEach(restoreModification); - setModifications([]); - setActiveIndex(-1); - setInputValue(""); + const resetPromptStatus = useCallback(() => { + clearTimeout(promptStatusTimeoutRef.current); + promptStatusTimeoutRef.current = setTimeout(() => { + setPromptStatus("idle"); + }, TWEAKER_STATUS_RESET_DELAY_MS); + }, []); + + const hydrateDraggedElementMetadata = useCallback( + async (element: HTMLElement) => { + const sourceMetadata = await getElementSourceMetadata(element); + syncDraggedElements((previousDraggedElements) => + previousDraggedElements.map((draggedElement) => + draggedElement.element === element + ? { ...draggedElement, ...sourceMetadata } + : draggedElement, + ), + ); + }, + [syncDraggedElements], + ); + + const clearSession = useCallback( + (shouldDisableTweaker: boolean) => { + clearHoveredElement(); + draggedElementsRef.current.forEach(restoreTranslatePreview); + activeDragRef.current = null; + syncDraggedElements(() => []); + clearTimeout(promptStatusTimeoutRef.current); + setPromptStatus("idle"); + if (shouldDisableTweaker) { + setIsEnabled(false); } + }, + [clearHoveredElement, syncDraggedElements], + ); - if (event.key === "Enter") { - event.preventDefault(); - releaseLock(); - const contextMap = await gatherRepositionContexts(modificationsRef.current); - const prompt = generatePrompt( - modificationsRef.current, - scalesRef.current, - activeScaleRef.current, - contextMap, - ); - navigator.clipboard.writeText(prompt); - setPicking(true); + const copyPromptToClipboard = useCallback(async () => { + const movedDraggedElements = getMovedDraggedElements(draggedElementsRef.current); + if (movedDraggedElements.length === 0) { + setPromptStatus("empty"); + resetPromptStatus(); + return; + } + + const repositionContexts = new Map(); + movedDraggedElements.forEach((draggedElement, index) => { + const repositionContext = gatherRepositionContext( + draggedElement.element, + draggedElement.translateX, + draggedElement.translateY, + ); + if (repositionContext) { + repositionContexts.set(index, repositionContext); } - }; + }); - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [hasModifications]); + const prompt = generatePrompt(movedDraggedElements, repositionContexts); + if (!prompt) { + setPromptStatus("empty"); + resetPromptStatus(); + return; + } - useEffect(() => { - if (!activeMod || picking) return; + try { + await navigator.clipboard.writeText(prompt); + setPromptStatus("copied"); + } catch { + setPromptStatus("copy-failed"); + } + + resetPromptStatus(); + }, [resetPromptStatus]); + useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { - const target = event.target as HTMLElement; - if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") return; - if (event.key === "Escape") return; + if (isEditableElement(event.target)) { + return; + } - if ((event.key >= "0" && event.key <= "9") || event.key === ".") { + if (event.key.toLowerCase() === "t") { event.preventDefault(); - const next = typingBuffer.current + event.key; - - if (event.key === "." && typingBuffer.current.includes(".")) return; - - const hasDecimal = typingBuffer.current.includes("."); - if (hasDecimal && event.key !== "." && typingBuffer.current.split(".")[1]?.length >= 1) { + if (isEnabled) { + clearSession(true); return; } - const parsed = parseFloat(next); - if (!isNaN(parsed) && parsed > SLIDER_MAX) { - typingBuffer.current = event.key === "." ? "." : event.key; - } else { - typingBuffer.current = next; - } + setIsEnabled(true); + return; + } - const value = parseFloat(typingBuffer.current); - if (!isNaN(value)) { - const clamped = Math.min(SLIDER_MAX, value); - updateActivePosition(clamped); - setInputValue(typingBuffer.current); - } + if (!isEnabled) { + return; + } - clearTimeout(typingTimeout.current); - typingTimeout.current = setTimeout(() => { - typingBuffer.current = ""; - }, TYPING_RESET_DELAY_MS); + if (event.key === "Escape") { + event.preventDefault(); + clearSession(true); + return; } - if (event.key === "Backspace") { + if (event.key === "Enter") { event.preventDefault(); - typingBuffer.current = typingBuffer.current.slice(0, -1); - if (typingBuffer.current && typingBuffer.current !== ".") { - const value = Math.min(SLIDER_MAX, parseFloat(typingBuffer.current)); - updateActivePosition(value); - setInputValue(typingBuffer.current); - } - clearTimeout(typingTimeout.current); - typingTimeout.current = setTimeout(() => { - typingBuffer.current = ""; - }, TYPING_RESET_DELAY_MS); + void copyPromptToClipboard(); } }; - document.addEventListener("keydown", handleKeyDown); + document.addEventListener("keydown", handleKeyDown, true); return () => { - document.removeEventListener("keydown", handleKeyDown); - clearTimeout(typingTimeout.current); + document.removeEventListener("keydown", handleKeyDown, true); }; - }, [activeMod, picking, activeIndex, updateActivePosition]); + }, [clearSession, copyPromptToClipboard, isEnabled]); useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - const target = event.target as HTMLElement; - if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") return; - if (event.key === "t") { - event.preventDefault(); - setPicking(true); + if (!isEnabled) { + return; + } + + const handleMouseOver = (event: MouseEvent) => { + if (activeDragRef.current) { + return; } - if (hasModifications && (event.key === "b" || event.key === "f" || event.key === "d")) { - event.preventDefault(); - const property: "bg" | "text" | "border" = - event.key === "b" ? "bg" : event.key === "f" ? "text" : "border"; - const index = activeIndexRef.current; - if (index < 0) return; - setModifications((previous) => { - const updated = [...previous]; - restoreModification(updated[index]); - updated[index] = { ...updated[index], property }; - applyModification(updated[index], scalesRef.current, activeScaleRef.current); - return updated; - }); + + const targetElement = getEventTargetElement(event.target); + if (!targetElement || targetElement.closest("[data-tweaker]")) { + return; } - }; - const handleMiddleClick = (event: MouseEvent) => { - if (event.button !== 1) return; - event.preventDefault(); - setPicking(true); + setHoveredElement(targetElement); }; - document.addEventListener("keydown", handleKeyDown); - document.addEventListener("mousedown", handleMiddleClick, true); - document.addEventListener("auxclick", handleMiddleClick, true); - return () => { - document.removeEventListener("keydown", handleKeyDown); - document.removeEventListener("mousedown", handleMiddleClick, true); - document.removeEventListener("auxclick", handleMiddleClick, true); - }; - }, [hasModifications]); + const handleMouseOut = (event: MouseEvent) => { + if (activeDragRef.current) { + return; + } - useEffect(() => { - return () => { - modifications.forEach(restoreModification); - releaseLock(); + const targetElement = getEventTargetElement(event.target); + if (!targetElement || hoveredElementRef.current?.element !== targetElement) { + return; + } + + clearHoveredElement(); }; - }, []); - useEffect(() => { - if (activeMod) { - applyModification(activeMod, scales, activeScale); - } - }, [activeMod?.position, activeMod?.fontSize, activeMod?.paddingY, activeScale, scales]); + const handleMouseDown = (event: MouseEvent) => { + if (event.button !== 0) { + return; + } - useEffect(() => { - if (!picking) return; + const targetElement = getEventTargetElement(event.target); + if (!targetElement || targetElement.closest("[data-tweaker]")) { + return; + } - let hoveredElement: HTMLElement | null = null; + event.preventDefault(); + event.stopPropagation(); + clearHoveredElement(); - const handleMouseOver = (event: MouseEvent) => { - const target = event.target as HTMLElement; - if (target.closest("[data-tweaker]")) return; - hoveredElement = target; - target.style.outline = "2px solid #3b82f6"; - target.style.outlineOffset = "2px"; + const existingDraggedElement = draggedElementsRef.current.find( + (draggedElement) => draggedElement.element === targetElement, + ); + const nextDraggedElement = existingDraggedElement ?? createDraggedElement(targetElement); + + if (!existingDraggedElement) { + syncDraggedElements((previousDraggedElements) => + upsertDraggedElement(previousDraggedElements, nextDraggedElement), + ); + void hydrateDraggedElementMetadata(targetElement); + } + + activeDragRef.current = { + element: targetElement, + startClientX: event.clientX, + startClientY: event.clientY, + startingTranslateX: nextDraggedElement.translateX, + startingTranslateY: nextDraggedElement.translateY, + currentTranslateX: nextDraggedElement.translateX, + currentTranslateY: nextDraggedElement.translateY, + didCrossDragThreshold: false, + }; }; - const handleMouseOut = (event: MouseEvent) => { - const target = event.target as HTMLElement; - target.style.outline = ""; - target.style.outlineOffset = ""; - if (hoveredElement === target) hoveredElement = null; + const handleMouseMove = (event: MouseEvent) => { + const activeDragSession = activeDragRef.current; + if (!activeDragSession) { + return; + } + + event.preventDefault(); + + const movementX = event.clientX - activeDragSession.startClientX; + const movementY = event.clientY - activeDragSession.startClientY; + const nextTranslateX = activeDragSession.startingTranslateX + movementX; + const nextTranslateY = activeDragSession.startingTranslateY + movementY; + const didCrossDragThreshold = + activeDragSession.didCrossDragThreshold || + Math.hypot(movementX, movementY) >= TWEAKER_DRAG_THRESHOLD_PX; + + activeDragRef.current = { + ...activeDragSession, + currentTranslateX: nextTranslateX, + currentTranslateY: nextTranslateY, + didCrossDragThreshold, + }; + + const draggedElement = draggedElementsRef.current.find( + (innerDraggedElement) => innerDraggedElement.element === activeDragSession.element, + ); + if (!draggedElement) { + return; + } + + applyTranslatePreview({ + ...draggedElement, + translateX: nextTranslateX, + translateY: nextTranslateY, + }); }; - const handleClick = async (event: MouseEvent) => { + const handleMouseUp = (event: MouseEvent) => { + if (event.button !== 0) { + return; + } + + const activeDragSession = activeDragRef.current; + if (!activeDragSession) { + return; + } + event.preventDefault(); - event.stopPropagation(); - const target = event.target as HTMLElement; - if (target.closest("[data-tweaker]")) return; - - target.style.outline = ""; - target.style.outlineOffset = ""; - - const computed = getComputedStyle(target); - const [bgRed, bgGreen, bgBlue, bgAlpha] = parseRgb(computed.backgroundColor); - const [textRed, textGreen, textBlue] = parseRgb(computed.color); - const [borderRed, borderGreen, borderBlue, borderAlpha] = parseRgb(computed.borderColor); - const hasBorder = borderAlpha > 0 && parseFloat(computed.borderWidth) > 0; - - const hasBackground = bgAlpha > 0; - const defaultProperty: "bg" | "text" | "border" = hasBackground - ? "bg" - : hasBorder - ? "border" - : "text"; - const targetOklch = - defaultProperty === "bg" - ? rgbToOklch(bgRed, bgGreen, bgBlue) - : defaultProperty === "border" - ? rgbToOklch(borderRed, borderGreen, borderBlue) - : rgbToOklch(textRed, textGreen, textBlue); - - const position = findClosestPosition(scales, activeScale, targetOklch); - const currentSize = parseFloat(computed.fontSize) || 16; - const currentPaddingY = parseFloat(computed.paddingTop) || 0; - - let componentName: string | null = null; - let sourceFile: string | null = null; - try { - const reactGrab = (window as unknown as Record).__REACT_GRAB_MODULE__ as - | { - getStack: ( - element: Element, - ) => Promise< - Array<{ fileName?: string; lineNumber?: number; functionName?: string }> - >; - } - | undefined; - if (reactGrab?.getStack) { - const stack = await reactGrab.getStack(target); - if (stack) { - for (const frame of stack) { - if (frame.fileName && !frame.fileName.includes("node_modules")) { - sourceFile = frame.fileName; - if (frame.functionName && /^[A-Z]/.test(frame.functionName)) { - componentName = frame.functionName; - } - break; - } - } - } + + const finalTranslateX = activeDragSession.didCrossDragThreshold + ? activeDragSession.currentTranslateX + : activeDragSession.startingTranslateX; + const finalTranslateY = activeDragSession.didCrossDragThreshold + ? activeDragSession.currentTranslateY + : activeDragSession.startingTranslateY; + + syncDraggedElements((previousDraggedElements) => { + const draggedElement = previousDraggedElements.find( + (innerDraggedElement) => innerDraggedElement.element === activeDragSession.element, + ); + if (!draggedElement) { + return previousDraggedElements; + } + + const committedDraggedElement = { + ...draggedElement, + translateX: finalTranslateX, + translateY: finalTranslateY, + }; + + if (committedDraggedElement.translateX === 0 && committedDraggedElement.translateY === 0) { + restoreTranslatePreview(committedDraggedElement); + return previousDraggedElements.filter( + (innerDraggedElement) => innerDraggedElement.element !== activeDragSession.element, + ); } - } catch {} - - const newModification: Modification = { - element: target, - selector: getSelector(target), - componentName, - sourceFile, - textPreview: getTextPreview(target), - originalInlineBg: target.style.backgroundColor, - originalInlineColor: target.style.color, - originalInlineBorderColor: target.style.borderColor, - originalInlineFontSize: target.style.fontSize, - originalInlinePaddingTop: target.style.paddingTop, - originalInlinePaddingBottom: target.style.paddingBottom, - originalInlineMarginTop: target.style.marginTop, - originalInlineMarginBottom: target.style.marginBottom, - originalInlineTransform: target.style.transform, - property: defaultProperty, - position, - fontSize: currentSize, - paddingY: currentPaddingY, - translateX: 0, - translateY: 0, - }; - setModifications((previous) => [...previous, newModification]); - setActiveIndex(modifications.length); - setInputValue(String(position)); - setPicking(false); + applyTranslatePreview(committedDraggedElement); + return upsertDraggedElement(previousDraggedElements, committedDraggedElement); + }); + + activeDragRef.current = null; }; document.addEventListener("mouseover", handleMouseOver, true); document.addEventListener("mouseout", handleMouseOut, true); - document.addEventListener("click", handleClick, true); + document.addEventListener("mousedown", handleMouseDown, true); + document.addEventListener("mousemove", handleMouseMove, true); + window.addEventListener("mouseup", handleMouseUp, true); return () => { document.removeEventListener("mouseover", handleMouseOver, true); document.removeEventListener("mouseout", handleMouseOut, true); - document.removeEventListener("click", handleClick, true); - if (hoveredElement) { - hoveredElement.style.outline = ""; - hoveredElement.style.outlineOffset = ""; - } + document.removeEventListener("mousedown", handleMouseDown, true); + document.removeEventListener("mousemove", handleMouseMove, true); + window.removeEventListener("mouseup", handleMouseUp, true); + clearHoveredElement(); + activeDragRef.current = null; }; - }, [picking, activeScale, scales, modifications.length]); - - const fillColor = activeMod - ? oklchToCssString(getColorAtPosition(scales, activeScale, activeMod.position)) - : (scales[activeScale]?.shades["500"] ?? "rgba(255,255,255,0.3)"); - - const propertyLabel = - activeMod?.property === "text" ? "F" : activeMod?.property === "border" ? "D" : "B"; - - const isPaddingMode = shiftHeld && hasModifications && !picking; - const isDragMode = controlHeld && hasModifications && !picking; - - const guideRect = - activeMod && !picking && !spaceHeld ? activeMod.element.getBoundingClientRect() : null; - - const thumbX = activeMod - ? isPaddingMode - ? (MINIMAP_WIDTH_PX - THUMB_SIZE_PX) / 2 - : ((activeMod.fontSize - FONT_SIZE_MIN_PX) / (FONT_SIZE_MAX_PX - FONT_SIZE_MIN_PX)) * - (MINIMAP_WIDTH_PX - THUMB_SIZE_PX) - : 0; - const thumbY = activeMod - ? isPaddingMode - ? (1 - (activeMod.paddingY - PADDING_MIN_PX) / (PADDING_MAX_PX - PADDING_MIN_PX)) * - (MINIMAP_HEIGHT_PX - THUMB_SIZE_PX) - : (1 - activeMod.position / SLIDER_MAX) * (MINIMAP_HEIGHT_PX - THUMB_SIZE_PX) - : MINIMAP_HEIGHT_PX - THUMB_SIZE_PX; + }, [ + clearHoveredElement, + hydrateDraggedElementMetadata, + isEnabled, + setHoveredElement, + syncDraggedElements, + ]); + + useEffect(() => { + return () => { + clearTimeout(promptStatusTimeoutRef.current); + draggedElementsRef.current.forEach(restoreTranslatePreview); + clearHoveredElement(); + activeDragRef.current = null; + }; + }, [clearHoveredElement]); + + const movedDraggedElements = getMovedDraggedElements(draggedElements); + const statusMessage = getStatusMessage(isEnabled, promptStatus, movedDraggedElements.length); return ( - <> - {guideRect && activeMod && ( -
-
- {activeMod.paddingY > 0 && ( - <> -
-
- - )} -
- ↕ {activeMod.paddingY}px - - {activeMod.fontSize}px - - {(activeMod.translateX !== 0 || activeMod.translateY !== 0) && ( - - {Math.round(activeMod.translateX)}, {Math.round(activeMod.translateY)} - - )} -
-
- )} - - {(hasModifications || picking) && ( - -
-
-
-
- - {isDragMode ? "⌃ Move" : isPaddingMode ? "⇧ Padding" : "Style"} - -
-
- - {picking - ? "Picking…" - : isDragMode - ? `x: ${activeMod?.translateX ?? 0}` - : isPaddingMode - ? `↕ ${activeMod?.paddingY ?? 0}px` - : `${propertyLabel} ${inputValue || "0"}`} - - {!picking && activeMod && isDragMode && ( - {`y: ${activeMod.translateY}`} - )} - {!picking && activeMod && !isPaddingMode && !isDragMode && ( - {`${activeMod.fontSize}px`} - )} -
- - )} - - +
+ +
+ {statusMessage} +
+
); }; @@ -621,62 +542,38 @@ const baseTextStyle: React.CSSProperties = { WebkitFontSmoothing: "antialiased", }; -const minimapContainerStyle: React.CSSProperties = { +const tweakerContainerStyle: React.CSSProperties = { position: "fixed", - left: 16, - bottom: 16, - zIndex: 9999, + left: TWEAKER_OFFSET_PX, + bottom: TWEAKER_OFFSET_PX, + zIndex: TWEAKER_Z_INDEX, display: "flex", flexDirection: "column", - gap: 6, -}; - -const minimapFieldStyle: React.CSSProperties = { - position: "relative", - width: MINIMAP_WIDTH_PX, - height: MINIMAP_HEIGHT_PX, - borderRadius: 8, - background: "rgba(0,0,0,0.25)", - backdropFilter: "blur(12px)", - WebkitBackdropFilter: "blur(12px)", - boxShadow: "0 0 0 1px rgba(255,255,255,0.08) inset", - overflow: "hidden", - pointerEvents: "none", + alignItems: "flex-start", + gap: TWEAKER_BUTTON_GAP_PX, }; -const minimapModeStyle: React.CSSProperties = { - padding: "0 2px", - pointerEvents: "none", -}; - -const minimapValuesStyle: React.CSSProperties = { - display: "flex", - justifyContent: "space-between", - padding: "0 2px", - pointerEvents: "none", -}; - -const minimapLabelStyle: React.CSSProperties = { +const tweakerButtonStyle: React.CSSProperties = { ...baseTextStyle, - fontSize: 11, - color: "rgba(255,255,255,0.6)", - whiteSpace: "nowrap", + border: 0, + borderRadius: TWEAKER_BUTTON_BORDER_RADIUS_PX, + padding: `${TWEAKER_BUTTON_PADDING_Y_PX}px ${TWEAKER_BUTTON_PADDING_X_PX}px`, + color: "white", + fontSize: TWEAKER_BUTTON_FONT_SIZE_PX, + cursor: "pointer", + boxShadow: "0 10px 30px rgba(15, 23, 42, 0.25)", }; -const guidelinesContainerStyle: React.CSSProperties = { - position: "fixed", - inset: 0, - zIndex: 9998, - pointerEvents: "none", -}; - -const guidelineLabelStyle: React.CSSProperties = { +const tweakerStatusStyle: React.CSSProperties = { ...baseTextStyle, - fontSize: 10, - lineHeight: "16px", - color: "rgba(255, 99, 132, 0.9)", - background: "rgba(255, 99, 132, 0.08)", - padding: "0 5px", - borderRadius: 3, - whiteSpace: "nowrap", + maxWidth: 320, + padding: `${TWEAKER_BUTTON_PADDING_Y_PX}px ${TWEAKER_BUTTON_PADDING_X_PX}px`, + borderRadius: TWEAKER_BUTTON_PADDING_X_PX, + background: "rgba(15, 23, 42, 0.82)", + color: "rgba(255,255,255,0.86)", + fontSize: TWEAKER_STATUS_FONT_SIZE_PX, + lineHeight: 1.45, + backdropFilter: "blur(12px)", + WebkitBackdropFilter: "blur(12px)", + boxShadow: "0 10px 30px rgba(15, 23, 42, 0.18)", }; diff --git a/packages/tweaker/src/types.ts b/packages/tweaker/src/types.ts index 32d6f63..6949795 100644 --- a/packages/tweaker/src/types.ts +++ b/packages/tweaker/src/types.ts @@ -1,34 +1,17 @@ -export type OKLCH = [number, number, number]; - -export interface GrayScale { - label: string; - shades: Record; -} - -export interface Modification { +export interface DraggedElement { element: HTMLElement; selector: string; componentName: string | null; sourceFile: string | null; textPreview: string; - originalInlineBg: string; - originalInlineColor: string; - originalInlineBorderColor: string; - originalInlineFontSize: string; - originalInlinePaddingTop: string; - originalInlinePaddingBottom: string; - originalInlineMarginTop: string; - originalInlineMarginBottom: string; - property: "bg" | "text" | "border"; - position: number; - fontSize: number; - paddingY: number; + originalInlineTranslate: string; translateX: number; translateY: number; - originalInlineTransform: string; } -export interface TweakerProps { - scales?: Record; - activeScale?: string; +export interface ElementSourceMetadata { + componentName: string | null; + sourceFile: string | null; } + +export interface TweakerProps {} diff --git a/packages/tweaker/src/utils/color.ts b/packages/tweaker/src/utils/color.ts deleted file mode 100644 index ba8074d..0000000 --- a/packages/tweaker/src/utils/color.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { OKLCH } from "../types"; -import { SHADE_KEYS, SLIDER_MAX } from "../constants"; -import type { GrayScale } from "../types"; - -export const parseOklch = (oklchStr: string): OKLCH => { - const match = oklchStr.match(/oklch\(([\d.]+)\s+([\d.]+)\s+([\d.]+)\)/); - if (!match) return [0, 0, 0]; - return [Number(match[1]), Number(match[2]), Number(match[3])]; -}; - -export const lerpOklch = (colorA: OKLCH, colorB: OKLCH, interpolation: number): OKLCH => [ - colorA[0] + (colorB[0] - colorA[0]) * interpolation, - colorA[1] + (colorB[1] - colorA[1]) * interpolation, - colorA[2] + (colorB[2] - colorA[2]) * interpolation, -]; - -export const formatOklch = (oklch: OKLCH): string => - `oklch(${oklch[0].toFixed(3)} ${oklch[1].toFixed(3)} ${oklch[2].toFixed(1)})`; - -export const oklchToCssString = (oklch: OKLCH): string => - `oklch(${oklch[0]} ${oklch[1]} ${oklch[2]})`; - -export const getColorAtPosition = ( - scales: Record, - scaleKey: string, - position: number, -): OKLCH => { - const scale = scales[scaleKey]; - if (!scale) return [0.5, 0, 0]; - - const inverted = SLIDER_MAX - position; - const segment = (inverted / SLIDER_MAX) * (SHADE_KEYS.length - 1); - const index = Math.min(Math.floor(segment), SHADE_KEYS.length - 2); - const interpolation = segment - index; - - const lower = parseOklch(scale.shades[SHADE_KEYS[index]]); - const upper = parseOklch(scale.shades[SHADE_KEYS[index + 1]]); - - return lerpOklch(lower, upper, interpolation); -}; - -export const getClosestShadeLabel = (position: number): string => { - const inverted = SLIDER_MAX - position; - const segment = (inverted / SLIDER_MAX) * (SHADE_KEYS.length - 1); - const index = Math.round(segment); - return SHADE_KEYS[Math.min(index, SHADE_KEYS.length - 1)]; -}; - -export const parseRgb = (color: string): [number, number, number, number] => { - const match = color.match(/rgba?\(\s*([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\s*\)/); - if (!match) return [0, 0, 0, 0]; - return [ - Number(match[1]), - Number(match[2]), - Number(match[3]), - match[4] !== undefined ? Number(match[4]) : 1, - ]; -}; - -export const rgbToOklch = (red: number, green: number, blue: number): OKLCH => { - const linearize = (channel: number): number => { - const normalized = channel / 255; - return normalized <= 0.04045 ? normalized / 12.92 : Math.pow((normalized + 0.055) / 1.055, 2.4); - }; - const linearRed = linearize(red); - const linearGreen = linearize(green); - const linearBlue = linearize(blue); - - const lmsL = Math.cbrt( - 0.4122214708 * linearRed + 0.5363325363 * linearGreen + 0.0514459929 * linearBlue, - ); - const lmsM = Math.cbrt( - 0.2119034982 * linearRed + 0.6806995451 * linearGreen + 0.1073969566 * linearBlue, - ); - const lmsS = Math.cbrt( - 0.0883024619 * linearRed + 0.2817188376 * linearGreen + 0.6299787005 * linearBlue, - ); - - const lightness = 0.2104542553 * lmsL + 0.793617785 * lmsM - 0.0040720468 * lmsS; - const labA = 1.9779984951 * lmsL - 2.428592205 * lmsM + 0.4505937099 * lmsS; - const labB = 0.0259040371 * lmsL + 0.7827717662 * lmsM - 0.808675766 * lmsS; - - const chroma = Math.sqrt(labA * labA + labB * labB); - const hue = Math.atan2(labB, labA) * (180 / Math.PI); - - return [lightness, chroma, hue < 0 ? hue + 360 : hue]; -}; - -export const findClosestPosition = ( - scales: Record, - scaleKey: string, - targetOklch: OKLCH, -): number => { - let bestPosition = 0; - let bestDistance = Infinity; - - for (let position = 0; position <= SLIDER_MAX; position++) { - const color = getColorAtPosition(scales, scaleKey, position); - const distance = - (color[0] - targetOklch[0]) ** 2 + - (color[1] - targetOklch[1]) ** 2 + - ((color[2] - targetOklch[2]) / 360) ** 2; - if (distance < bestDistance) { - bestDistance = distance; - bestPosition = position; - } - } - - return bestPosition; -}; diff --git a/packages/tweaker/src/utils/dragged-elements.test.ts b/packages/tweaker/src/utils/dragged-elements.test.ts new file mode 100644 index 0000000..4a746be --- /dev/null +++ b/packages/tweaker/src/utils/dragged-elements.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import type { DraggedElement } from "../types"; +import { getMovedDraggedElements } from "./get-moved-dragged-elements"; +import { upsertDraggedElement } from "./upsert-dragged-element"; + +const createDraggedElement = ( + element: HTMLElement, + translateX: number, + translateY: number, +): DraggedElement => ({ + element, + selector: "div.card", + componentName: "Card", + sourceFile: null, + textPreview: "Settings", + originalInlineTranslate: "", + translateX, + translateY, +}); + +describe("dragged element utilities", () => { + it("filters out elements that were not moved", () => { + const stillElement = createDraggedElement(document.createElement("div"), 0, 0); + const movedElement = createDraggedElement(document.createElement("div"), 24, -8); + + expect(getMovedDraggedElements([stillElement, movedElement])).toEqual([movedElement]); + }); + + it("upserts by element identity", () => { + const element = document.createElement("div"); + const initialDraggedElement = createDraggedElement(element, 8, 4); + const updatedDraggedElement = createDraggedElement(element, 48, -12); + + const draggedElements = upsertDraggedElement([initialDraggedElement], updatedDraggedElement); + + expect(draggedElements).toHaveLength(1); + expect(draggedElements[0]).toEqual(updatedDraggedElement); + }); +}); diff --git a/packages/tweaker/src/utils/get-moved-dragged-elements.ts b/packages/tweaker/src/utils/get-moved-dragged-elements.ts new file mode 100644 index 0000000..fdb9151 --- /dev/null +++ b/packages/tweaker/src/utils/get-moved-dragged-elements.ts @@ -0,0 +1,6 @@ +import type { DraggedElement } from "../types"; + +export const getMovedDraggedElements = (draggedElements: DraggedElement[]): DraggedElement[] => + draggedElements.filter( + (draggedElement) => draggedElement.translateX !== 0 || draggedElement.translateY !== 0, + ); diff --git a/packages/tweaker/src/utils/modification.ts b/packages/tweaker/src/utils/modification.ts deleted file mode 100644 index 37aa1b4..0000000 --- a/packages/tweaker/src/utils/modification.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { GrayScale, Modification } from "../types"; -import { getColorAtPosition, oklchToCssString } from "./color"; - -export const applyModification = ( - modification: Modification, - scales: Record, - scaleKey: string, -) => { - const oklch = getColorAtPosition(scales, scaleKey, modification.position); - const colorValue = oklchToCssString(oklch); - if (modification.property === "bg") { - modification.element.style.backgroundColor = colorValue; - } else if (modification.property === "text") { - modification.element.style.color = colorValue; - } else { - modification.element.style.borderColor = colorValue; - } - modification.element.style.fontSize = `${modification.fontSize}px`; - - const paddingY = Math.round(modification.paddingY); - - modification.element.style.paddingTop = `${Math.max(0, paddingY)}px`; - modification.element.style.paddingBottom = `${Math.max(0, paddingY)}px`; - - modification.element.style.marginTop = - paddingY < 0 ? `${paddingY}px` : modification.originalInlineMarginTop; - modification.element.style.marginBottom = - paddingY < 0 ? `${paddingY}px` : modification.originalInlineMarginBottom; - - if (modification.translateX !== 0 || modification.translateY !== 0) { - modification.element.style.transform = `translate(${modification.translateX}px, ${modification.translateY}px)`; - } else { - modification.element.style.transform = modification.originalInlineTransform; - } -}; - -export const restoreModification = (modification: Modification) => { - modification.element.style.backgroundColor = modification.originalInlineBg; - modification.element.style.color = modification.originalInlineColor; - modification.element.style.borderColor = modification.originalInlineBorderColor; - modification.element.style.fontSize = modification.originalInlineFontSize; - modification.element.style.paddingTop = modification.originalInlinePaddingTop; - modification.element.style.paddingBottom = modification.originalInlinePaddingBottom; - modification.element.style.marginTop = modification.originalInlineMarginTop; - modification.element.style.marginBottom = modification.originalInlineMarginBottom; - modification.element.style.transform = modification.originalInlineTransform; -}; - -export const roundToStep = (value: number): number => - parseFloat((Math.round(value * 10) / 10).toFixed(1)); - -export const roundToHalf = (value: number): number => Math.round(value * 2) / 2; diff --git a/packages/tweaker/src/utils/nearby.ts b/packages/tweaker/src/utils/nearby.ts index 89a9400..2d5872c 100644 --- a/packages/tweaker/src/utils/nearby.ts +++ b/packages/tweaker/src/utils/nearby.ts @@ -1,22 +1,7 @@ -import { DOM_TREE_MAX_NODES } from "../constants"; import { getSelector, getTextPreview } from "./dom"; -export interface TreeNode { - selector: string; - componentName: string | null; - textPreview: string; - positionX: number; - positionY: number; - width: number; - height: number; - isSelf: boolean; - children: TreeNode[]; - layout: string | null; -} - export interface SiblingInfo { description: string; - edgePosition: number; } export interface ParentLayoutInfo { @@ -24,7 +9,6 @@ export interface ParentLayoutInfo { flowAxis: "vertical" | "horizontal"; flexDirection: string | null; gap: number; - crossGap: number; } interface ElementRect { @@ -37,26 +21,20 @@ interface ElementRect { } export interface RepositionContext { - originalPositionX: number; - originalPositionY: number; - newPositionX: number; - newPositionY: number; translateX: number; translateY: number; - elementWidth: number; - elementHeight: number; originalChildIndex: number; insertionIndex: number; siblingCount: number; + parentDescription: string; + parentComponentName: string | null; previousSibling: SiblingInfo | null; nextSibling: SiblingInfo | null; - gapAbove: number; - gapBelow: number; - gapLeft: number; - gapRight: number; - existingMargin: { top: number; bottom: number; left: number; right: number }; + gapBefore: number; + gapAfter: number; + existingMarginBefore: number; + existingMarginAfter: number; parentLayout: ParentLayoutInfo; - tree: TreeNode; } const parsePxValue = (value: string): number => { @@ -116,11 +94,7 @@ const detectParentLayout = (parent: HTMLElement): ParentLayoutInfo => { flowAxis = autoFlow.includes("column") ? "horizontal" : "vertical"; } - const rowGap = parsePxValue(computed.rowGap); - const columnGap = parsePxValue(computed.columnGap); - - const gap = flowAxis === "vertical" ? rowGap : columnGap; - const crossGap = flowAxis === "vertical" ? columnGap : rowGap; + const gap = parsePxValue(flowAxis === "vertical" ? computed.rowGap : computed.columnGap); const displayLabel = isFlex ? "flex" : isGrid ? "grid" : "block"; @@ -129,7 +103,6 @@ const detectParentLayout = (parent: HTMLElement): ParentLayoutInfo => { flowAxis, flexDirection: isFlex ? flexDirection : null, gap, - crossGap, }; }; @@ -208,44 +181,6 @@ interface SiblingEntry { isSelf: boolean; } -const buildTreeNode = ( - element: HTMLElement, - selfElement: HTMLElement, - depth: number, - nodeCount: { current: number }, -): TreeNode | null => { - if (nodeCount.current >= DOM_TREE_MAX_NODES) return null; - nodeCount.current++; - - const rect = getAbsoluteRect(element); - const children: TreeNode[] = []; - - if (depth < 3) { - for (const child of Array.from(element.children)) { - if (nodeCount.current >= DOM_TREE_MAX_NODES) break; - const childNode = buildTreeNode(child as HTMLElement, selfElement, depth + 1, nodeCount); - if (childNode) children.push(childNode); - } - } - - const layoutInfo = children.length > 0 ? detectParentLayout(element) : null; - - return { - selector: getSelector(element), - componentName: getReactComponentName(element), - textPreview: getTextPreview(element), - positionX: Math.round(rect.left), - positionY: Math.round(rect.top), - width: Math.round(rect.width), - height: Math.round(rect.height), - isSelf: element === selfElement, - children, - layout: layoutInfo - ? `${layoutInfo.display}${layoutInfo.flexDirection ? `-${layoutInfo.flowAxis === "horizontal" ? "row" : "column"}` : ""}` - : null, - }; -}; - const findInsertionIndex = ( siblings: SiblingEntry[], newRect: ElementRect, @@ -286,29 +221,34 @@ const findInsertionIndex = ( return { insertionIndex, previousSibling, nextSibling }; }; -const computeGaps = ( +const computeSpacing = ( newRect: ElementRect, parentContentEdges: ElementRect, + flowAxis: "vertical" | "horizontal", previousSibling: SiblingEntry | null, nextSibling: SiblingEntry | null, -): { gapAbove: number; gapBelow: number; gapLeft: number; gapRight: number } => { - const gapAbove = previousSibling - ? Math.round(newRect.top - previousSibling.rect.bottom) - : Math.round(newRect.top - parentContentEdges.top); +): { gapBefore: number; gapAfter: number } => { + if (flowAxis === "vertical") { + const gapBefore = previousSibling + ? Math.round(newRect.top - previousSibling.rect.bottom) + : Math.round(newRect.top - parentContentEdges.top); + + const gapAfter = nextSibling + ? Math.round(nextSibling.rect.top - newRect.bottom) + : Math.round(parentContentEdges.bottom - newRect.bottom); - const gapBelow = nextSibling - ? Math.round(nextSibling.rect.top - newRect.bottom) - : Math.round(parentContentEdges.bottom - newRect.bottom); + return { gapBefore, gapAfter }; + } - const gapLeft = previousSibling + const gapBefore = previousSibling ? Math.round(newRect.left - previousSibling.rect.right) : Math.round(newRect.left - parentContentEdges.left); - const gapRight = nextSibling + const gapAfter = nextSibling ? Math.round(nextSibling.rect.left - newRect.right) : Math.round(parentContentEdges.right - newRect.right); - return { gapAbove, gapBelow, gapLeft, gapRight }; + return { gapBefore, gapAfter }; }; export const gatherRepositionContext = ( @@ -349,65 +289,48 @@ export const gatherRepositionContext = ( parentLayout.flowAxis, ); - const { gapAbove, gapBelow, gapLeft, gapRight } = computeGaps( + const { gapBefore, gapAfter } = computeSpacing( newRect, parentContentEdges, + parentLayout.flowAxis, previousSibling, nextSibling, ); const computed = getComputedStyle(element); - const existingMargin = { - top: parsePxValue(computed.marginTop), - bottom: parsePxValue(computed.marginBottom), - left: parsePxValue(computed.marginLeft), - right: parsePxValue(computed.marginRight), - }; + const existingMarginBefore = parsePxValue( + parentLayout.flowAxis === "vertical" ? computed.marginTop : computed.marginLeft, + ); + const existingMarginAfter = parsePxValue( + parentLayout.flowAxis === "vertical" ? computed.marginBottom : computed.marginRight, + ); const previousSiblingInfo: SiblingInfo | null = previousSibling ? { description: describeElement(previousSibling.element), - edgePosition: - parentLayout.flowAxis === "vertical" - ? Math.round(previousSibling.rect.bottom) - : Math.round(previousSibling.rect.right), } : null; const nextSiblingInfo: SiblingInfo | null = nextSibling ? { description: describeElement(nextSibling.element), - edgePosition: - parentLayout.flowAxis === "vertical" - ? Math.round(nextSibling.rect.top) - : Math.round(nextSibling.rect.left), } : null; - const nodeCount = { current: 0 }; - const tree = buildTreeNode(parent, element, 0, nodeCount); - if (!tree) return null; - return { - originalPositionX: Math.round(originalRect.left), - originalPositionY: Math.round(originalRect.top), - newPositionX: Math.round(newRect.left), - newPositionY: Math.round(newRect.top), translateX, translateY, - elementWidth: Math.round(originalRect.width), - elementHeight: Math.round(originalRect.height), originalChildIndex, insertionIndex, siblingCount: siblings.length - 1, + parentDescription: getSelector(parent), + parentComponentName: getReactComponentName(parent), previousSibling: previousSiblingInfo, nextSibling: nextSiblingInfo, - gapAbove, - gapBelow, - gapLeft, - gapRight, - existingMargin, + gapBefore, + gapAfter, + existingMarginBefore, + existingMarginAfter, parentLayout, - tree, }; }; diff --git a/packages/tweaker/src/utils/prompt.test.ts b/packages/tweaker/src/utils/prompt.test.ts new file mode 100644 index 0000000..b729a1e --- /dev/null +++ b/packages/tweaker/src/utils/prompt.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import type { DraggedElement } from "../types"; +import type { RepositionContext } from "./nearby"; +import { generatePrompt } from "./prompt"; + +const createDraggedElement = ( + selector: string, + translateX: number, + translateY: number, +): DraggedElement => ({ + element: document.createElement("div"), + selector, + componentName: "Card", + sourceFile: "src/components/card.tsx", + textPreview: "Settings", + originalInlineTranslate: "", + translateX, + translateY, +}); + +const createVerticalContext = (): RepositionContext => ({ + translateX: 0, + translateY: -48, + originalChildIndex: 2, + insertionIndex: 0, + siblingCount: 4, + parentDescription: "div.stack", + parentComponentName: "SettingsPanel", + previousSibling: null, + nextSibling: { description: 'button.primary ("Save")' }, + gapBefore: 12, + gapAfter: 12, + existingMarginBefore: 0, + existingMarginAfter: 0, + parentLayout: { + display: "flex", + flowAxis: "vertical", + flexDirection: "column", + gap: 12, + }, +}); + +const createHorizontalContext = (): RepositionContext => ({ + translateX: 32, + translateY: 0, + originalChildIndex: 1, + insertionIndex: 1, + siblingCount: 2, + parentDescription: "div.row", + parentComponentName: null, + previousSibling: { description: 'button.secondary ("Back")' }, + nextSibling: { description: 'button.primary ("Save")' }, + gapBefore: 24, + gapAfter: 8, + existingMarginBefore: 0, + existingMarginAfter: 0, + parentLayout: { + display: "flex", + flowAxis: "horizontal", + flexDirection: "row", + gap: 0, + }, +}); + +describe("generatePrompt", () => { + it("returns a position-only prompt for reordered vertical layouts", () => { + const draggedElement = createDraggedElement("div.card", 0, -48); + const prompt = generatePrompt([draggedElement], new Map([[0, createVerticalContext()]])); + + expect(prompt).toContain("Reposition the following dragged elements"); + expect(prompt).toContain("Do not use CSS transforms"); + expect(prompt).toContain("Parent: div.stack (flex, column, gap: 12px)"); + expect(prompt).toContain("Move from child #3 to child #1 (of 5)"); + expect(prompt).toContain('Below: button.primary ("Save") — 12px gap'); + expect(prompt).not.toContain("color"); + expect(prompt).not.toContain("font-size"); + expect(prompt).not.toContain("gray scale"); + }); + + it("describes spacing instructions for gapless horizontal layouts", () => { + const draggedElement = createDraggedElement("button.primary", 32, 0); + const prompt = generatePrompt([draggedElement], new Map([[0, createHorizontalContext()]])); + + expect(prompt).toContain('Left: button.secondary ("Back") — 24px gap'); + expect(prompt).toContain('Right: button.primary ("Save") — 8px gap'); + expect(prompt).toContain("Set margin-left: 24px"); + expect(prompt).toContain("Set margin-right: 8px"); + }); + + it("falls back to preview offsets when sibling context is unavailable", () => { + const draggedElement = createDraggedElement("div.card", 18, 6); + const prompt = generatePrompt([draggedElement]); + + expect(prompt).toContain("Match the dragged preview without CSS transforms"); + expect(prompt).toContain("x=18px, y=6px"); + expect(prompt).toContain("src/components/card.tsx"); + }); +}); diff --git a/packages/tweaker/src/utils/prompt.ts b/packages/tweaker/src/utils/prompt.ts index 2c4eeb4..3182629 100644 --- a/packages/tweaker/src/utils/prompt.ts +++ b/packages/tweaker/src/utils/prompt.ts @@ -1,11 +1,10 @@ -import type { GrayScale, Modification } from "../types"; -import type { RepositionContext, ParentLayoutInfo } from "./nearby"; -import { formatOklch, getColorAtPosition, getClosestShadeLabel } from "./color"; - -const describeModification = (modification: Modification): string => { - const nameParts = [modification.selector]; - if (modification.componentName) nameParts.unshift(`<${modification.componentName}>`); - if (modification.textPreview) nameParts.push(`("${modification.textPreview}")`); +import type { DraggedElement } from "../types"; +import type { ParentLayoutInfo, RepositionContext } from "./nearby"; + +const describeDraggedElement = (draggedElement: DraggedElement): string => { + const nameParts = [draggedElement.selector]; + if (draggedElement.componentName) nameParts.unshift(`<${draggedElement.componentName}>`); + if (draggedElement.textPreview) nameParts.push(`("${draggedElement.textPreview}")`); return nameParts.join(" "); }; @@ -20,7 +19,7 @@ const formatParentLayout = (layout: ParentLayoutInfo): string => { return parts.join(", "); }; -const formatGapComparison = ( +const formatSpacingInstruction = ( targetGap: number, parentGap: number, direction: string, @@ -44,22 +43,36 @@ const formatGapComparison = ( return `The ${targetGap}px gap ${direction} is ${Math.abs(difference)}px less than the parent gap (${parentGap}px) — add ${marginProperty}: ${difference}px.`; }; +const formatFallbackInstruction = (draggedElement: DraggedElement): string[] => { + const description = describeDraggedElement(draggedElement); + const lines = [`- ${description}`]; + + if (draggedElement.sourceFile) { + lines.push(` Source: ${draggedElement.sourceFile}`); + } + + lines.push( + ` Match the dragged preview without CSS transforms (preview offset: x=${draggedElement.translateX}px, y=${draggedElement.translateY}px).`, + ); + + return lines; +}; + const formatRepositionInstruction = ( - modification: Modification, + draggedElement: DraggedElement, context: RepositionContext, ): string[] => { - const description = describeModification(modification); + const description = describeDraggedElement(draggedElement); const lines: string[] = []; lines.push(`- ${description}`); - if (modification.sourceFile) lines.push(` Source: ${modification.sourceFile}`); + if (draggedElement.sourceFile) lines.push(` Source: ${draggedElement.sourceFile}`); - const parentDescription = context.tree.selector; - const parentComponentName = context.tree.componentName; - const parentLabel = parentComponentName - ? `<${parentComponentName}> ${parentDescription}` - : parentDescription; + const parentLabel = context.parentComponentName + ? `<${context.parentComponentName}> ${context.parentDescription}` + : context.parentDescription; lines.push(` Parent: ${parentLabel} (${formatParentLayout(context.parentLayout)})`); + lines.push(` Drag preview: x=${draggedElement.translateX}px, y=${draggedElement.translateY}px`); const fromIndex = context.originalChildIndex + 1; const toIndex = context.insertionIndex + 1; @@ -79,18 +92,18 @@ const formatRepositionInstruction = ( if (isHorizontal) { lines.push(" Neighbors at target position:"); if (context.previousSibling) { - lines.push(` Left: ${context.previousSibling.description} — ${context.gapLeft}px gap`); + lines.push(` Left: ${context.previousSibling.description} — ${context.gapBefore}px gap`); } if (context.nextSibling) { - lines.push(` Right: ${context.nextSibling.description} — ${context.gapRight}px gap`); + lines.push(` Right: ${context.nextSibling.description} — ${context.gapAfter}px gap`); } } else { lines.push(" Neighbors at target position:"); if (context.previousSibling) { - lines.push(` Above: ${context.previousSibling.description} — ${context.gapAbove}px gap`); + lines.push(` Above: ${context.previousSibling.description} — ${context.gapBefore}px gap`); } if (context.nextSibling) { - lines.push(` Below: ${context.nextSibling.description} — ${context.gapBelow}px gap`); + lines.push(` Below: ${context.nextSibling.description} — ${context.gapAfter}px gap`); } } @@ -100,11 +113,14 @@ const formatRepositionInstruction = ( lines.push(""); - const margin = context.existingMargin; if (isHorizontal) { - lines.push(` Current element margins: left=${margin.left}px, right=${margin.right}px`); + lines.push( + ` Current element margins: left=${context.existingMarginBefore}px, right=${context.existingMarginAfter}px`, + ); } else { - lines.push(` Current element margins: top=${margin.top}px, bottom=${margin.bottom}px`); + lines.push( + ` Current element margins: top=${context.existingMarginBefore}px, bottom=${context.existingMarginAfter}px`, + ); } lines.push(""); @@ -119,10 +135,10 @@ const formatRepositionInstruction = ( if (isHorizontal) { const leftComparison = context.previousSibling - ? formatGapComparison(context.gapLeft, parentGap, "left") + ? formatSpacingInstruction(context.gapBefore, parentGap, "left") : null; const rightComparison = context.nextSibling - ? formatGapComparison(context.gapRight, parentGap, "right") + ? formatSpacingInstruction(context.gapAfter, parentGap, "right") : null; if (leftComparison) instructions.push(leftComparison); @@ -134,24 +150,24 @@ const formatRepositionInstruction = ( ); } - if (parentGap <= 0 && (context.gapLeft !== 0 || context.gapRight !== 0)) { - if (context.previousSibling && context.gapLeft !== 0) { + if (parentGap <= 0 && (context.gapBefore !== 0 || context.gapAfter !== 0)) { + if (context.previousSibling && context.gapBefore !== 0) { instructions.push( - `Set margin-left: ${context.gapLeft}px for the ${context.gapLeft}px gap to the left.`, + `Set margin-left: ${context.gapBefore}px for the ${context.gapBefore}px gap to the left.`, ); } - if (context.nextSibling && context.gapRight !== 0) { + if (context.nextSibling && context.gapAfter !== 0) { instructions.push( - `Set margin-right: ${context.gapRight}px for the ${context.gapRight}px gap to the right.`, + `Set margin-right: ${context.gapAfter}px for the ${context.gapAfter}px gap to the right.`, ); } } } else { const aboveComparison = context.previousSibling - ? formatGapComparison(context.gapAbove, parentGap, "above") + ? formatSpacingInstruction(context.gapBefore, parentGap, "above") : null; const belowComparison = context.nextSibling - ? formatGapComparison(context.gapBelow, parentGap, "below") + ? formatSpacingInstruction(context.gapAfter, parentGap, "below") : null; if (aboveComparison) instructions.push(aboveComparison); @@ -163,15 +179,15 @@ const formatRepositionInstruction = ( ); } - if (parentGap <= 0 && (context.gapAbove !== 0 || context.gapBelow !== 0)) { - if (context.previousSibling && context.gapAbove !== 0) { + if (parentGap <= 0 && (context.gapBefore !== 0 || context.gapAfter !== 0)) { + if (context.previousSibling && context.gapBefore !== 0) { instructions.push( - `Set margin-top: ${context.gapAbove}px for the ${context.gapAbove}px gap above.`, + `Set margin-top: ${context.gapBefore}px for the ${context.gapBefore}px gap above.`, ); } - if (context.nextSibling && context.gapBelow !== 0) { + if (context.nextSibling && context.gapAfter !== 0) { instructions.push( - `Set margin-bottom: ${context.gapBelow}px for the ${context.gapBelow}px gap below.`, + `Set margin-bottom: ${context.gapAfter}px for the ${context.gapAfter}px gap below.`, ); } } @@ -189,77 +205,29 @@ const formatRepositionInstruction = ( }; export const generatePrompt = ( - modifications: Modification[], - scales: Record, - scaleKey: string, + draggedElements: DraggedElement[], repositionContexts?: Map, ): string => { - if (modifications.length === 0) return ""; + if (draggedElements.length === 0) return ""; - const scaleName = scales[scaleKey]?.label || scaleKey; - const colorLines: string[] = []; - const sizeLines: string[] = []; - const paddingLines: string[] = []; const positionLines: string[] = []; - modifications.forEach((modification, index) => { - const description = describeModification(modification); - - const shade = getClosestShadeLabel(modification.position); - const oklch = getColorAtPosition(scales, scaleKey, modification.position); - const property = - modification.property === "bg" - ? "background color" - : modification.property === "text" - ? "text color" - : "border color"; - colorLines.push( - `- ${property} of ${description} → ${scaleName} ${shade} (${formatOklch(oklch)})`, - ); - if (modification.sourceFile) colorLines.push(` Source: ${modification.sourceFile}`); - - sizeLines.push(`- font-size of ${description} → ${modification.fontSize}px`); - if (modification.sourceFile) sizeLines.push(` Source: ${modification.sourceFile}`); - - paddingLines.push( - `- vertical padding of ${description} → ${Math.round(modification.paddingY)}px`, - ); - if (modification.sourceFile) paddingLines.push(` Source: ${modification.sourceFile}`); - + draggedElements.forEach((draggedElement, index) => { const context = repositionContexts?.get(index); - if (context && (modification.translateX !== 0 || modification.translateY !== 0)) { - positionLines.push(...formatRepositionInstruction(modification, context)); + if (context) { + positionLines.push(...formatRepositionInstruction(draggedElement, context)); + return; } - }); - - const sections: string[] = []; - - if (colorLines.length > 0) { - sections.push( - "Change the following colors using the design system's gray scale:", - "", - ...colorLines, - ); - } - if (sizeLines.length > 0) { - if (sections.length > 0) sections.push(""); - sections.push("Change the following font sizes:", "", ...sizeLines); - } - - if (paddingLines.length > 0) { - if (sections.length > 0) sections.push(""); - sections.push("Change the following padding:", "", ...paddingLines); - } + positionLines.push(...formatFallbackInstruction(draggedElement)); + }); - if (positionLines.length > 0) { - if (sections.length > 0) sections.push(""); - sections.push( - "Reposition the following element (do NOT use CSS transforms — re-order the JSX and adjust spacing):", - "", - ...positionLines, - ); - } + if (positionLines.length === 0) return ""; - return sections.join("\n"); + return [ + "Reposition the following dragged elements within their current parent in the DOM.", + "Do not use CSS transforms in the final implementation — reorder the JSX and adjust spacing instead.", + "", + ...positionLines, + ].join("\n"); }; diff --git a/packages/tweaker/src/utils/translate-preview.ts b/packages/tweaker/src/utils/translate-preview.ts new file mode 100644 index 0000000..341ebc9 --- /dev/null +++ b/packages/tweaker/src/utils/translate-preview.ts @@ -0,0 +1,17 @@ +import type { DraggedElement } from "../types"; + +export const applyTranslatePreview = (draggedElement: DraggedElement) => { + draggedElement.element.style.setProperty( + "translate", + `${draggedElement.translateX}px ${draggedElement.translateY}px`, + ); +}; + +export const restoreTranslatePreview = (draggedElement: DraggedElement) => { + if (draggedElement.originalInlineTranslate) { + draggedElement.element.style.setProperty("translate", draggedElement.originalInlineTranslate); + return; + } + + draggedElement.element.style.removeProperty("translate"); +}; diff --git a/packages/tweaker/src/utils/upsert-dragged-element.ts b/packages/tweaker/src/utils/upsert-dragged-element.ts new file mode 100644 index 0000000..21cb7eb --- /dev/null +++ b/packages/tweaker/src/utils/upsert-dragged-element.ts @@ -0,0 +1,18 @@ +import type { DraggedElement } from "../types"; + +export const upsertDraggedElement = ( + draggedElements: DraggedElement[], + nextDraggedElement: DraggedElement, +): DraggedElement[] => { + const existingDraggedElementIndex = draggedElements.findIndex( + (draggedElement) => draggedElement.element === nextDraggedElement.element, + ); + + if (existingDraggedElementIndex === -1) { + return [...draggedElements, nextDraggedElement]; + } + + const updatedDraggedElements = [...draggedElements]; + updatedDraggedElements[existingDraggedElementIndex] = nextDraggedElement; + return updatedDraggedElements; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d61abb3..3fa34c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,19 +25,9 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18 + version: 4.0.18(jsdom@28.1.0) packages/tweaker: - dependencies: - motion: - specifier: ^12.0.0 - version: 12.35.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: - specifier: '>=18' - version: 19.2.4 - react-dom: - specifier: '>=18' - version: 19.2.4(react@19.2.4) devDependencies: '@types/react': specifier: ^19 @@ -45,12 +35,34 @@ importers: '@types/react-dom': specifier: ^19 version: 19.2.3(@types/react@19.2.14) + jsdom: + specifier: ^28.1.0 + version: 28.1.0 + react: + specifier: ^19.2.4 + version: 19.2.4 + react-dom: + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) tsdown: specifier: ^0.20.3 version: 0.20.3(typescript@5.9.3) packages: + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + + '@asamuzakjp/css-color@5.0.1': + resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/generator@8.0.0-rc.2': resolution: {integrity: sha512-oCQ1IKPwkzCeJzAPb7Fv8rQ9k5+1sG8mf2uoHiMInPYvkRfrDJxbTIbH51U+jstlkghus0vAi3EBvkfvEsYNLQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -76,6 +88,10 @@ packages: resolution: {integrity: sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw==} engines: {node: ^20.19.0 || >=22.12.0} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@changesets/apply-release-plan@7.1.0': resolution: {integrity: sha512-yq8ML3YS7koKQ/9bk1PqO0HMzApIFNwjlwCnwFEXMzNe8NpzeeYYKCmnhWJGkN8g7E51MnWaSbqRcTcdIxUgnQ==} @@ -131,6 +147,37 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.1.1': + resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.0.2': + resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.0': + resolution: {integrity: sha512-H4tuz2nhWgNKLt1inYpoVCfbJbMwX/lQKp3g69rrrIMIYlFD9+zTykOKhNR8uGrAmbS/kT9n6hTFkmDkxLgeTA==} + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -296,6 +343,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -970,6 +1026,10 @@ packages: '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -1004,6 +1064,9 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + birpc@4.0.0: resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} @@ -1026,9 +1089,33 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssstyle@6.2.0: + resolution: {integrity: sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==} + engines: {node: '>=20'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} @@ -1057,6 +1144,10 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -1104,20 +1195,6 @@ packages: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} - framer-motion@12.35.1: - resolution: {integrity: sha512-rL8cLrjYZNShZqKV3U0Qj6Y5WDiZXYEM5giiTLfEqsIZxtspzMDCkKmrO5po76jWfvOg04+Vk+sfBvTD0iMmLw==} - peerDependencies: - '@emotion/is-prop-valid': '*' - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@emotion/is-prop-valid': - optional: true - react: - optional: true - react-dom: - optional: true - fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -1148,6 +1225,18 @@ packages: hookable@6.0.1: resolution: {integrity: sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-id@4.1.3: resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} hasBin: true @@ -1176,6 +1265,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-subdir@1.2.0: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} engines: {node: '>=4'} @@ -1195,6 +1287,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@28.1.0: + resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1210,9 +1311,16 @@ packages: lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + lru-cache@11.2.6: + resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} + engines: {node: 20 || >=22} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1221,30 +1329,13 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} - motion-dom@12.35.1: - resolution: {integrity: sha512-7n6r7TtNOsH2UFSAXzTkfzOeO5616v9B178qBIjmu/WgEyJK0uqwytCEhwKBTuM/HJA40ptAw7hLFpxtPAMRZQ==} - - motion-utils@12.29.2: - resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==} - - motion@12.35.1: - resolution: {integrity: sha512-yEt/49kWC0VU/IEduDfeZw82eDemlPwa1cyo/gcEEUCN4WgpSJpUcxz6BUwakGabvJiTzLQ58J73515I5tfykQ==} - peerDependencies: - '@emotion/is-prop-valid': '*' - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@emotion/is-prop-valid': - optional: true - react: - optional: true - react-dom: - optional: true - mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1294,6 +1385,9 @@ packages: package-manager-detector@0.2.11: resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1333,6 +1427,10 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -1355,6 +1453,10 @@ packages: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -1406,6 +1508,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -1457,6 +1563,9 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} @@ -1480,10 +1589,25 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tldts-core@7.0.25: + resolution: {integrity: sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==} + + tldts@7.0.25: + resolution: {integrity: sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -1524,6 +1648,10 @@ packages: unconfig-core@7.5.0: resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + undici@7.22.0: + resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} + engines: {node: '>=20.18.1'} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -1612,6 +1740,22 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1622,8 +1766,35 @@ packages: engines: {node: '>=8'} hasBin: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + snapshots: + '@acemir/cssom@0.9.31': {} + + '@asamuzakjp/css-color@5.0.1': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.2.6 + + '@asamuzakjp/dom-selector@6.8.1': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.6 + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/generator@8.0.0-rc.2': dependencies: '@babel/parser': 8.0.0-rc.2 @@ -1648,6 +1819,10 @@ snapshots: '@babel/helper-string-parser': 8.0.0-rc.2 '@babel/helper-validator-identifier': 8.0.0-rc.2 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@changesets/apply-release-plan@7.1.0': dependencies: '@changesets/config': 3.1.3 @@ -1791,6 +1966,28 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.0': {} + + '@csstools/css-tokenizer@4.0.0': {} + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -1885,6 +2082,8 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true + '@exodus/bytes@1.15.0': {} + '@inquirer/external-editor@1.0.3': dependencies: chardet: 2.1.1 @@ -2295,6 +2494,8 @@ snapshots: '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 + agent-base@7.1.4: {} + ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} @@ -2321,6 +2522,10 @@ snapshots: dependencies: is-windows: 1.0.2 + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + birpc@4.0.0: {} braces@3.0.3: @@ -2339,8 +2544,33 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + cssstyle@6.2.0: + dependencies: + '@asamuzakjp/css-color': 5.0.1 + '@csstools/css-syntax-patches-for-csstree': 1.1.0 + css-tree: 3.2.1 + lru-cache: 11.2.6 + csstype@3.2.3: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + defu@6.1.4: {} detect-indent@6.1.0: {} @@ -2358,6 +2588,8 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + entities@6.0.1: {} + es-module-lexer@1.7.0: {} esbuild@0.27.3: @@ -2424,15 +2656,6 @@ snapshots: locate-path: 5.0.0 path-exists: 4.0.0 - framer-motion@12.35.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): - dependencies: - motion-dom: 12.35.1 - motion-utils: 12.29.2 - tslib: 2.8.1 - optionalDependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -2469,6 +2692,26 @@ snapshots: hookable@6.0.1: {} + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-id@4.1.3: {} iconv-lite@0.7.2: @@ -2487,6 +2730,8 @@ snapshots: is-number@7.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-subdir@1.2.0: dependencies: better-path-resolve: 1.0.0 @@ -2504,6 +2749,33 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@28.1.0: + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.8.1 + '@bramus/specificity': 2.4.2 + '@exodus/bytes': 1.15.0 + cssstyle: 6.2.0 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.0 + undici: 7.22.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - supports-color + jsesc@3.1.0: {} jsonfile@4.0.0: @@ -2516,10 +2788,14 @@ snapshots: lodash.startcase@4.4.0: {} + lru-cache@11.2.6: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mdn-data@2.27.1: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -2527,22 +2803,10 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 - motion-dom@12.35.1: - dependencies: - motion-utils: 12.29.2 - - motion-utils@12.29.2: {} - - motion@12.35.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): - dependencies: - framer-motion: 12.35.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - tslib: 2.8.1 - optionalDependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - mri@1.2.0: {} + ms@2.1.3: {} + nanoid@3.3.11: {} obug@2.1.1: {} @@ -2615,6 +2879,10 @@ snapshots: dependencies: quansync: 0.2.11 + parse5@8.0.0: + dependencies: + entities: 6.0.1 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -2639,6 +2907,8 @@ snapshots: prettier@2.8.8: {} + punycode@2.3.1: {} + quansync@0.2.11: {} quansync@1.0.0: {} @@ -2659,6 +2929,8 @@ snapshots: pify: 4.0.1 strip-bom: 3.0.0 + require-from-string@2.0.2: {} + resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -2759,6 +3031,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@7.7.4: {} @@ -2794,6 +3070,8 @@ snapshots: strip-bom@3.0.0: {} + symbol-tree@3.2.4: {} + term-size@2.2.1: {} tinybench@2.9.0: {} @@ -2809,10 +3087,24 @@ snapshots: tinyrainbow@3.0.3: {} + tldts-core@7.0.25: {} + + tldts@7.0.25: + dependencies: + tldts-core: 7.0.25 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.25 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + tree-kill@1.2.2: {} tsdown@0.20.3(typescript@5.9.3): @@ -2842,7 +3134,8 @@ snapshots: - synckit - vue-tsc - tslib@2.8.1: {} + tslib@2.8.1: + optional: true typescript@5.9.3: {} @@ -2851,6 +3144,8 @@ snapshots: '@quansync/fs': 1.0.0 quansync: 1.0.0 + undici@7.22.0: {} + universalify@0.1.2: {} unrun@0.2.30: @@ -2868,7 +3163,7 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - vitest@4.0.18: + vitest@4.0.18(jsdom@28.1.0): dependencies: '@vitest/expect': 4.0.18 '@vitest/mocker': 4.0.18(vite@7.3.1) @@ -2890,6 +3185,8 @@ snapshots: tinyrainbow: 3.0.3 vite: 7.3.1 why-is-node-running: 2.3.0 + optionalDependencies: + jsdom: 28.1.0 transitivePeerDependencies: - jiti - less @@ -2903,6 +3200,22 @@ snapshots: - tsx - yaml + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which@2.0.2: dependencies: isexe: 2.0.0 @@ -2911,3 +3224,7 @@ snapshots: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} From 7099e65a1c8b0dba7bd1c63cbfca6cf566d47766 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Mar 2026 01:40:42 +0000 Subject: [PATCH 2/2] Document drag-first tweaker workflow Co-authored-by: Ben MacLaurin --- .changeset/giant-pets-glow.md | 13 ++++++ README.md | 77 ++++++++++++----------------------- 2 files changed, 40 insertions(+), 50 deletions(-) create mode 100644 .changeset/giant-pets-glow.md diff --git a/.changeset/giant-pets-glow.md b/.changeset/giant-pets-glow.md new file mode 100644 index 0000000..c9250f5 --- /dev/null +++ b/.changeset/giant-pets-glow.md @@ -0,0 +1,13 @@ +--- +"@ben-million/tweaker": minor +--- + +Simplify Tweaker into a drag-only DOM repositioning tool. + +Remove the old gray-scale and style-tweaking workflow in favor of: + +- toggling Tweaker on and off +- dragging page elements to preview DOM moves +- pressing Enter to copy a position-only prompt + +This also removes the old color-centric public API and exports, including the gray-scale helpers and related props. diff --git a/README.md b/README.md index b6e320b..b376e18 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # tweaker -A dev tool for tweaking colors along gray scales in React apps. Pick any element, adjust its color along a gray scale, then copy the result as an AI-ready prompt. +A dev tool for dragging page elements and turning the result into an AI-ready DOM repositioning prompt. ## Install ```bash -npm install tweaker +npm install @ben-million/tweaker ``` ## Usage @@ -13,7 +13,7 @@ npm install tweaker Add the `` component to your app layout, ideally only in development: ```tsx -import { Tweaker } from "tweaker"; +import { Tweaker } from "@ben-million/tweaker"; const App = () => ( <> @@ -23,65 +23,42 @@ const App = () => ( ); ``` -### With a specific scale - -```tsx - -``` - -### With custom scales - -```tsx -import { Tweaker } from "tweaker"; -import type { GrayScale } from "tweaker"; - -const customScales: Record = { - brand: { - label: "Brand", - shades: { - "50": "oklch(0.985 0.003 250)", - "100": "oklch(0.968 0.006 250)", - // ... 200-950 - }, - }, -}; - -; -``` - ## Keybinds -| Key | Action | -| ----------- | ------------------------------------------------------ | -| `T` | Enter picking mode | -| `B` | Switch to background color | -| `F` | Switch to text (foreground) color | -| `D` | Switch to border color | -| `Space` | Persist current change, copy prompt, pick next element | -| `Escape` | Copy prompt, restore all changes, and exit | -| `0-9` / `.` | Type a value directly (0–10 scale) | +| Key | Action | +| -------- | ------------------------------------------ | +| `T` | Toggle Tweaker on or off | +| `Enter` | Copy the current DOM repositioning prompt | +| `Escape` | Restore all previews, clear state, and off | ## Workflow -1. Press `T` or click the pill to start picking -2. Click an element — its color is detected and mapped to the gray scale -3. Move your mouse up/down to adjust the shade (vertical slider) -4. Press `B`, `F`, or `D` to switch between background, text, or border -5. Press `Space` to persist and pick the next element (cumulative prompt) -6. Press `Escape` to copy the full prompt and reset +1. Toggle Tweaker on with `T` or the pill in the bottom-left corner +2. Drag any element on the page to preview its new position +3. Repeat for as many elements as you want +4. Press `Enter` to copy a prompt that only describes DOM order and spacing changes +5. Press `Escape` or toggle Tweaker off to restore all previews and clear the session The copied prompt is formatted for AI coding agents: ``` -Change the following colors using the design system's gray scale: +Reposition the following dragged elements within their current parent in the DOM. +Do not use CSS transforms in the final implementation — reorder the JSX and adjust spacing instead. -- background color of div.card ("Settings") → Slate 200 (oklch(0.929 0.013 255.5)) -- text color of p.description ("Configure your...") → Slate 600 (oklch(0.446 0.043 257.3)) -``` +- div.card ("Settings") + Source: src/components/card.tsx + Parent: div.stack (flex, column, gap: 16px) + Drag preview: x=0px, y=-48px + Move from child #3 to child #1 (of 4) -## Built-in scales + Neighbors at target position: + Below: button.primary ("Save") — 16px gap -Neutral, Slate, Gray, Zinc, Stone, Mauve, Olive — matching Tailwind CSS v4 gray palettes. + Current element margins: top=0px, bottom=0px + + → Re-order this element in the JSX to be child #1 of its parent. + → The gaps match the parent's gap (16px) — no extra margins needed. +``` ## License