From 2c9b11bade48d47554bc25eaa898f72e75754ad4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 8 Mar 2026 10:12:10 +0000 Subject: [PATCH] feat: targeted padding prompt with Tailwind, React fiber, and CSS rule signals - Create utils/react-fiber.ts: walk React fiber for component name, source file, line number - Create utils/tailwind.ts: parse Tailwind padding classes, spacing scale, compute new classes - Create utils/css-rules.ts: walk matched stylesheets for authored padding declarations - Extend Modification type with original 4-side padding, fullClassName, tailwind/css signals - Collect all signals at pick-time in tweaker.tsx (fiber, className, Tailwind, CSS rules) - Rewrite prompt.ts padding section with 3-tier system: Tier 1: Tailwind className find/replace diff Tier 2: CSS rule declaration find/replace Tier 3: Per-side before/after fallback with px values - Add getElementClassName to dom.ts (handles SVG elements) Co-authored-by: Ben MacLaurin --- packages/tweaker/src/tweaker.tsx | 146 +++++++++++++---- packages/tweaker/src/types.ts | 13 ++ packages/tweaker/src/utils/css-rules.ts | 58 +++++++ packages/tweaker/src/utils/dom.ts | 5 + packages/tweaker/src/utils/prompt.ts | 190 ++++++++++++++++++++-- packages/tweaker/src/utils/react-fiber.ts | 93 +++++++++++ packages/tweaker/src/utils/tailwind.ts | 189 +++++++++++++++++++++ 7 files changed, 654 insertions(+), 40 deletions(-) create mode 100644 packages/tweaker/src/utils/css-rules.ts create mode 100644 packages/tweaker/src/utils/react-fiber.ts create mode 100644 packages/tweaker/src/utils/tailwind.ts diff --git a/packages/tweaker/src/tweaker.tsx b/packages/tweaker/src/tweaker.tsx index 92ecef3..bee4f0e 100644 --- a/packages/tweaker/src/tweaker.tsx +++ b/packages/tweaker/src/tweaker.tsx @@ -2,11 +2,38 @@ 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 { 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 } from "./constants"; -import { getColorAtPosition, oklchToCssString, parseRgb, rgbToOklch, findClosestPosition } from "./utils/color"; -import { getSelector, getTextPreview } from "./utils/dom"; -import { applyModification, restoreModification, roundToStep, roundToHalf } from "./utils/modification"; +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, +} from "./constants"; +import { + getColorAtPosition, + oklchToCssString, + parseRgb, + rgbToOklch, + findClosestPosition, +} from "./utils/color"; +import { getSelector, getTextPreview, getElementClassName } from "./utils/dom"; +import { + applyModification, + restoreModification, + roundToStep, + roundToHalf, +} from "./utils/modification"; import { generatePrompt } from "./utils/prompt"; +import { getReactFiberInfo } from "./utils/react-fiber"; +import { parseTailwindPaddingClasses } from "./utils/tailwind"; +import { getMatchedPaddingRule } from "./utils/css-rules"; const requestLock = () => { if (!document.pointerLockElement) { @@ -81,13 +108,39 @@ export const Tweaker = ({ scales = GRAY_SCALES, activeScale = "neutral" }: Tweak const current = updated[index]; if (isPadding) { - const newPaddingY = Math.max(PADDING_MIN_PX, Math.min(PADDING_MAX_PX, current.paddingY - event.movementY * MOUSE_PADDING_SENSITIVITY)); - const newPaddingX = Math.max(PADDING_MIN_PX, Math.min(PADDING_MAX_PX, current.paddingX + event.movementX * MOUSE_PADDING_SENSITIVITY)); - updated[index] = { ...current, paddingY: Math.round(newPaddingY), paddingX: Math.round(newPaddingX) }; + const newPaddingY = Math.max( + PADDING_MIN_PX, + Math.min( + PADDING_MAX_PX, + current.paddingY - event.movementY * MOUSE_PADDING_SENSITIVITY, + ), + ); + const newPaddingX = Math.max( + PADDING_MIN_PX, + Math.min( + PADDING_MAX_PX, + current.paddingX + event.movementX * MOUSE_PADDING_SENSITIVITY, + ), + ); + updated[index] = { + ...current, + paddingY: Math.round(newPaddingY), + paddingX: Math.round(newPaddingX), + }; } 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 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), + }; } applyModification(updated[index], scalesRef.current, activeScaleRef.current); @@ -111,7 +164,11 @@ export const Tweaker = ({ scales = GRAY_SCALES, activeScale = "neutral" }: Tweak if (event.key === "Escape") { event.preventDefault(); releaseLock(); - const prompt = generatePrompt(modificationsRef.current, scalesRef.current, activeScaleRef.current); + const prompt = generatePrompt( + modificationsRef.current, + scalesRef.current, + activeScaleRef.current, + ); navigator.clipboard.writeText(prompt); modificationsRef.current.forEach(restoreModification); setModifications([]); @@ -122,7 +179,11 @@ export const Tweaker = ({ scales = GRAY_SCALES, activeScale = "neutral" }: Tweak if (event.key === " ") { event.preventDefault(); releaseLock(); - const prompt = generatePrompt(modificationsRef.current, scalesRef.current, activeScaleRef.current); + const prompt = generatePrompt( + modificationsRef.current, + scalesRef.current, + activeScaleRef.current, + ); navigator.clipboard.writeText(prompt); setPicking(true); } @@ -244,7 +305,14 @@ export const Tweaker = ({ scales = GRAY_SCALES, activeScale = "neutral" }: Tweak if (activeMod) { applyModification(activeMod, scales, activeScale); } - }, [activeMod?.position, activeMod?.fontSize, activeMod?.paddingX, activeMod?.paddingY, activeScale, scales]); + }, [ + activeMod?.position, + activeMod?.fontSize, + activeMod?.paddingX, + activeMod?.paddingY, + activeScale, + scales, + ]); useEffect(() => { if (!picking) return; @@ -282,7 +350,11 @@ export const Tweaker = ({ scales = GRAY_SCALES, activeScale = "neutral" }: Tweak const hasBorder = borderAlpha > 0 && parseFloat(computed.borderWidth) > 0; const hasBackground = bgAlpha > 0; - const defaultProperty: "bg" | "text" | "border" = hasBackground ? "bg" : hasBorder ? "border" : "text"; + const defaultProperty: "bg" | "text" | "border" = hasBackground + ? "bg" + : hasBorder + ? "border" + : "text"; const targetOklch = defaultProperty === "bg" ? rgbToOklch(bgRed, bgGreen, bgBlue) @@ -292,15 +364,26 @@ export const Tweaker = ({ scales = GRAY_SCALES, activeScale = "neutral" }: Tweak const position = findClosestPosition(scales, activeScale, targetOklch); const currentSize = parseFloat(computed.fontSize) || 16; - const currentPaddingY = parseFloat(computed.paddingTop) || 0; - const currentPaddingX = parseFloat(computed.paddingLeft) || 0; + const currentPaddingTop = parseFloat(computed.paddingTop) || 0; + const currentPaddingRight = parseFloat(computed.paddingRight) || 0; + const currentPaddingBottom = parseFloat(computed.paddingBottom) || 0; + const currentPaddingLeft = parseFloat(computed.paddingLeft) || 0; + + const elementClassName = getElementClassName(target); + const tailwindPadding = parseTailwindPaddingClasses(elementClassName); + const fiberInfo = getReactFiberInfo(target); + const matchedCssRule = getMatchedPaddingRule(target); const newModification: Modification = { element: target, selector: getSelector(target), - componentName: null, - sourceFile: null, + componentName: fiberInfo.componentName, + sourceFile: fiberInfo.sourceFile, + sourceLineNumber: fiberInfo.sourceLineNumber, textPreview: getTextPreview(target), + fullClassName: elementClassName, + tailwindPaddingClasses: tailwindPadding.map((parsed) => parsed.original), + matchedCssRule, originalInlineBg: target.style.backgroundColor, originalInlineColor: target.style.color, originalInlineBorderColor: target.style.borderColor, @@ -313,11 +396,15 @@ export const Tweaker = ({ scales = GRAY_SCALES, activeScale = "neutral" }: Tweak originalInlineMarginBottom: target.style.marginBottom, originalInlineMarginLeft: target.style.marginLeft, originalInlineMarginRight: target.style.marginRight, + originalPaddingTop: currentPaddingTop, + originalPaddingRight: currentPaddingRight, + originalPaddingBottom: currentPaddingBottom, + originalPaddingLeft: currentPaddingLeft, property: defaultProperty, position, fontSize: currentSize, - paddingX: currentPaddingX, - paddingY: currentPaddingY, + paddingX: currentPaddingLeft, + paddingY: currentPaddingTop, }; setModifications((previous) => [...previous, newModification]); @@ -343,7 +430,7 @@ export const Tweaker = ({ scales = GRAY_SCALES, activeScale = "neutral" }: Tweak const fillColor = activeMod ? oklchToCssString(getColorAtPosition(scales, activeScale, activeMod.position)) - : scales[activeScale]?.shades["500"] ?? "rgba(255,255,255,0.3)"; + : (scales[activeScale]?.shades["500"] ?? "rgba(255,255,255,0.3)"); const propertyLabel = activeMod?.property === "text" ? "F" : activeMod?.property === "border" ? "D" : "B"; @@ -352,12 +439,15 @@ export const Tweaker = ({ scales = GRAY_SCALES, activeScale = "neutral" }: Tweak const thumbX = activeMod ? isPaddingMode - ? ((activeMod.paddingX - PADDING_MIN_PX) / (PADDING_MAX_PX - PADDING_MIN_PX)) * (MINIMAP_WIDTH_PX - THUMB_SIZE_PX) - : ((activeMod.fontSize - FONT_SIZE_MIN_PX) / (FONT_SIZE_MAX_PX - FONT_SIZE_MIN_PX)) * (MINIMAP_WIDTH_PX - THUMB_SIZE_PX) + ? ((activeMod.paddingX - PADDING_MIN_PX) / (PADDING_MAX_PX - PADDING_MIN_PX)) * + (MINIMAP_WIDTH_PX - THUMB_SIZE_PX) + : ((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.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; @@ -390,9 +480,7 @@ export const Tweaker = ({ scales = GRAY_SCALES, activeScale = "neutral" }: Tweak />
- - {isPaddingMode ? "⇧ Padding" : "Style"} - + {isPaddingMode ? "⇧ Padding" : "Style"}
@@ -404,9 +492,7 @@ export const Tweaker = ({ scales = GRAY_SCALES, activeScale = "neutral" }: Tweak {!picking && activeMod && ( - {isPaddingMode - ? `X ${activeMod.paddingX}px` - : `${activeMod.fontSize}px`} + {isPaddingMode ? `X ${activeMod.paddingX}px` : `${activeMod.fontSize}px`} )}
diff --git a/packages/tweaker/src/types.ts b/packages/tweaker/src/types.ts index 35ff42d..d282dfb 100644 --- a/packages/tweaker/src/types.ts +++ b/packages/tweaker/src/types.ts @@ -5,12 +5,21 @@ export interface GrayScale { shades: Record; } +export interface CssRuleMatch { + selector: string; + declaration: string; +} + export interface Modification { element: HTMLElement; selector: string; componentName: string | null; sourceFile: string | null; + sourceLineNumber: number | null; textPreview: string; + fullClassName: string; + tailwindPaddingClasses: string[]; + matchedCssRule: CssRuleMatch | null; originalInlineBg: string; originalInlineColor: string; originalInlineBorderColor: string; @@ -23,6 +32,10 @@ export interface Modification { originalInlineMarginBottom: string; originalInlineMarginLeft: string; originalInlineMarginRight: string; + originalPaddingTop: number; + originalPaddingRight: number; + originalPaddingBottom: number; + originalPaddingLeft: number; property: "bg" | "text" | "border"; position: number; fontSize: number; diff --git a/packages/tweaker/src/utils/css-rules.ts b/packages/tweaker/src/utils/css-rules.ts new file mode 100644 index 0000000..a1edbbf --- /dev/null +++ b/packages/tweaker/src/utils/css-rules.ts @@ -0,0 +1,58 @@ +import type { CssRuleMatch } from "../types"; + +const PADDING_PROPERTIES = [ + "padding", + "padding-top", + "padding-right", + "padding-bottom", + "padding-left", + "padding-block", + "padding-inline", +] as const; + +const hasPaddingDeclaration = (style: CSSStyleDeclaration): boolean => + PADDING_PROPERTIES.some((property) => style.getPropertyValue(property) !== ""); + +const buildPaddingDeclaration = (style: CSSStyleDeclaration): string => { + const shorthand = style.getPropertyValue("padding"); + if (shorthand) return `padding: ${shorthand}`; + + const parts: string[] = []; + for (const property of PADDING_PROPERTIES) { + const value = style.getPropertyValue(property); + if (value) parts.push(`${property}: ${value}`); + } + return parts.join("; "); +}; + +export const getMatchedPaddingRule = (element: HTMLElement): CssRuleMatch | null => { + let bestMatch: CssRuleMatch | null = null; + + for (const sheet of Array.from(document.styleSheets)) { + let rules: CSSRuleList; + try { + rules = sheet.cssRules; + } catch { + continue; + } + + for (const rule of Array.from(rules)) { + if (!(rule instanceof CSSStyleRule)) continue; + + try { + if (!element.matches(rule.selectorText)) continue; + } catch { + continue; + } + + if (!hasPaddingDeclaration(rule.style)) continue; + + bestMatch = { + selector: rule.selectorText, + declaration: buildPaddingDeclaration(rule.style), + }; + } + } + + return bestMatch; +}; diff --git a/packages/tweaker/src/utils/dom.ts b/packages/tweaker/src/utils/dom.ts index a536f38..94bf68e 100644 --- a/packages/tweaker/src/utils/dom.ts +++ b/packages/tweaker/src/utils/dom.ts @@ -15,3 +15,8 @@ export const getTextPreview = (element: HTMLElement): string => { ? `${text.slice(0, TEXT_PREVIEW_MAX_LENGTH)}…` : text; }; + +export const getElementClassName = (element: HTMLElement): string => { + if (typeof element.className === "string") return element.className; + return element.getAttribute("class") || ""; +}; diff --git a/packages/tweaker/src/utils/prompt.ts b/packages/tweaker/src/utils/prompt.ts index 7ec3b97..2af7fe7 100644 --- a/packages/tweaker/src/utils/prompt.ts +++ b/packages/tweaker/src/utils/prompt.ts @@ -1,5 +1,173 @@ import type { GrayScale, Modification } from "../types"; import { formatOklch, getColorAtPosition, getClosestShadeLabel } from "./color"; +import { + parseTailwindPaddingClasses, + computeNewPaddingClasses, + replacePaddingClasses, +} from "./tailwind"; + +const buildDescription = (modification: Modification): string => { + const nameParts = [modification.selector]; + if (modification.componentName) nameParts.unshift(`<${modification.componentName}>`); + if (modification.textPreview) nameParts.push(`("${modification.textPreview}")`); + return nameParts.join(" "); +}; + +const buildLocationHeader = (modification: Modification): string | null => { + const parts: string[] = []; + + if (modification.sourceFile) { + const filePath = modification.sourceFile; + const lineInfo = modification.sourceLineNumber + ? ` (line ${modification.sourceLineNumber})` + : ""; + parts.push(`${filePath}${lineInfo}`); + } + + if (modification.componentName) { + parts.push(`<${modification.componentName}>`); + } + + if (parts.length === 0) return null; + return `In ${parts.join(", ")}`; +}; + +const didPaddingChange = (modification: Modification): boolean => { + const newY = Math.round(modification.paddingY); + const newX = Math.round(modification.paddingX); + const topChanged = newY !== Math.round(modification.originalPaddingTop); + const bottomChanged = newY !== Math.round(modification.originalPaddingBottom); + const leftChanged = newX !== Math.round(modification.originalPaddingLeft); + const rightChanged = newX !== Math.round(modification.originalPaddingRight); + return topChanged || bottomChanged || leftChanged || rightChanged; +}; + +const generateTailwindPaddingBlock = (modification: Modification): string[] => { + const parsedClasses = parseTailwindPaddingClasses(modification.fullClassName); + if (parsedClasses.length === 0) return []; + + const newClassNames = computeNewPaddingClasses( + parsedClasses, + Math.round(modification.paddingY), + Math.round(modification.paddingX), + { + top: modification.originalPaddingTop, + right: modification.originalPaddingRight, + bottom: modification.originalPaddingBottom, + left: modification.originalPaddingLeft, + }, + ); + + const didAnyClassChange = parsedClasses.some( + (parsed, index) => parsed.original !== newClassNames[index], + ); + if (!didAnyClassChange) return []; + + const newFullClassName = replacePaddingClasses( + modification.fullClassName, + parsedClasses, + newClassNames, + ); + + const lines: string[] = []; + const locationHeader = buildLocationHeader(modification); + if (locationHeader) lines.push(`${locationHeader}:`); + + lines.push(`Find: className="${modification.fullClassName}"`); + lines.push(`Replace: className="${newFullClassName}"`); + + const changeDetails = parsedClasses + .map((parsed, index) => { + if (parsed.original === newClassNames[index]) return null; + return `${parsed.original} → ${newClassNames[index]}`; + }) + .filter(Boolean); + + if (changeDetails.length > 0) { + lines.push(`Changes: ${changeDetails.join(", ")}`); + } + + return lines; +}; + +const generateCssRulePaddingBlock = (modification: Modification): string[] => { + if (!modification.matchedCssRule) return []; + + const lines: string[] = []; + const locationHeader = buildLocationHeader(modification); + if (locationHeader) lines.push(`${locationHeader}:`); + + const newY = Math.round(modification.paddingY); + const newX = Math.round(modification.paddingX); + + const declaration = modification.matchedCssRule.declaration; + const unit = declaration.includes("rem") ? "rem" : declaration.includes("em") ? "em" : "px"; + + const toCssValue = (px: number): string => { + if (unit === "rem") + return `${(px / 16).toFixed(px % 16 === 0 ? 0 : 3).replace(/\.?0+$/, "")}rem`; + if (unit === "em") return `${(px / 16).toFixed(px % 16 === 0 ? 0 : 3).replace(/\.?0+$/, "")}em`; + return `${px}px`; + }; + + const newDeclaration = + newY === newX + ? `padding: ${toCssValue(newY)}` + : `padding: ${toCssValue(newY)} ${toCssValue(newX)}`; + + lines.push(`In CSS rule "${modification.matchedCssRule.selector}":`); + lines.push(`Find: ${modification.matchedCssRule.declaration}`); + lines.push(`Replace: ${newDeclaration}`); + + return lines; +}; + +const generateFallbackPaddingBlock = (modification: Modification): string[] => { + const lines: string[] = []; + const locationHeader = buildLocationHeader(modification); + const description = buildDescription(modification); + + if (locationHeader) { + lines.push(`${locationHeader}:`); + lines.push(`On ${description}:`); + } else { + lines.push(`On ${description}:`); + } + + const newY = Math.round(modification.paddingY); + const newX = Math.round(modification.paddingX); + const originalTop = Math.round(modification.originalPaddingTop); + const originalBottom = Math.round(modification.originalPaddingBottom); + const originalLeft = Math.round(modification.originalPaddingLeft); + const originalRight = Math.round(modification.originalPaddingRight); + + if (newY !== originalTop) { + lines.push(`padding-top: ${originalTop}px → ${newY}px`); + } + if (newY !== originalBottom) { + lines.push(`padding-bottom: ${originalBottom}px → ${newY}px`); + } + if (newX !== originalLeft) { + lines.push(`padding-left: ${originalLeft}px → ${newX}px`); + } + if (newX !== originalRight) { + lines.push(`padding-right: ${originalRight}px → ${newX}px`); + } + + return lines; +}; + +const generatePaddingBlock = (modification: Modification): string[] => { + if (!didPaddingChange(modification)) return []; + + const tailwindBlock = generateTailwindPaddingBlock(modification); + if (tailwindBlock.length > 0) return tailwindBlock; + + const cssRuleBlock = generateCssRulePaddingBlock(modification); + if (cssRuleBlock.length > 0) return cssRuleBlock; + + return generateFallbackPaddingBlock(modification); +}; export const generatePrompt = ( modifications: Modification[], @@ -11,13 +179,10 @@ export const generatePrompt = ( const scaleName = scales[scaleKey]?.label || scaleKey; const colorLines: string[] = []; const sizeLines: string[] = []; - const paddingLines: string[] = []; + const paddingBlocks: string[][] = []; modifications.forEach((modification) => { - const nameParts = [modification.selector]; - if (modification.componentName) nameParts.unshift(`<${modification.componentName}>`); - if (modification.textPreview) nameParts.push(`("${modification.textPreview}")`); - const description = nameParts.join(" "); + const description = buildDescription(modification); const shade = getClosestShadeLabel(modification.position); const oklch = getColorAtPosition(scales, scaleKey, modification.position); @@ -27,14 +192,16 @@ export const generatePrompt = ( : modification.property === "text" ? "text color" : "border color"; - colorLines.push(`- ${property} of ${description} → ${scaleName} ${shade} (${formatOklch(oklch)})`); + 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(`- padding of ${description} → ${Math.round(modification.paddingY)}px ${Math.round(modification.paddingX)}px`); - if (modification.sourceFile) paddingLines.push(` Source: ${modification.sourceFile}`); + const paddingBlock = generatePaddingBlock(modification); + if (paddingBlock.length > 0) paddingBlocks.push(paddingBlock); }); const sections: string[] = []; @@ -52,9 +219,12 @@ export const generatePrompt = ( sections.push("Change the following font sizes:", "", ...sizeLines); } - if (paddingLines.length > 0) { + if (paddingBlocks.length > 0) { if (sections.length > 0) sections.push(""); - sections.push("Change the following padding:", "", ...paddingLines); + sections.push("Change the following padding:"); + paddingBlocks.forEach((block) => { + sections.push("", ...block); + }); } return sections.join("\n"); diff --git a/packages/tweaker/src/utils/react-fiber.ts b/packages/tweaker/src/utils/react-fiber.ts new file mode 100644 index 0000000..4641e7a --- /dev/null +++ b/packages/tweaker/src/utils/react-fiber.ts @@ -0,0 +1,93 @@ +interface ReactFiberInfo { + componentName: string | null; + sourceFile: string | null; + sourceLineNumber: number | null; +} + +const EMPTY_FIBER_INFO: ReactFiberInfo = { + componentName: null, + sourceFile: null, + sourceLineNumber: null, +}; + +const MAX_FIBER_WALK_DEPTH = 50; + +const getReactFiber = (element: HTMLElement): Record | null => { + const fiberKey = Object.keys(element).find( + (key) => key.startsWith("__reactFiber$") || key.startsWith("__reactInternalInstance$"), + ); + if (!fiberKey) return null; + return (element as unknown as Record>)[fiberKey]; +}; + +const isComponentFiber = (fiber: Record): boolean => + typeof fiber.type === "function" || (typeof fiber.type === "object" && fiber.type !== null); + +const getComponentName = (fiber: Record): string | null => { + const fiberType = fiber.type as + | Record + | ((...args: unknown[]) => unknown) + | null; + if (!fiberType) return null; + if (typeof fiberType === "function") { + return ( + (fiberType as unknown as { displayName?: string; name?: string }).displayName || + (fiberType as unknown as { displayName?: string; name?: string }).name || + null + ); + } + if (typeof fiberType === "object") { + return (fiberType as { displayName?: string }).displayName || null; + } + return null; +}; + +interface DebugSource { + fileName: string; + lineNumber: number; + columnNumber?: number; +} + +const getDebugSource = (fiber: Record): DebugSource | null => { + const source = fiber._debugSource as DebugSource | undefined; + if (source?.fileName) return source; + const owner = fiber._debugOwner as Record | undefined; + if (owner) { + const ownerSource = owner._debugSource as DebugSource | undefined; + if (ownerSource?.fileName) return ownerSource; + } + return null; +}; + +export const getReactFiberInfo = (element: HTMLElement): ReactFiberInfo => { + const fiber = getReactFiber(element); + if (!fiber) return EMPTY_FIBER_INFO; + + let componentName: string | null = null; + let sourceFile: string | null = null; + let sourceLineNumber: number | null = null; + + let current: Record | null = fiber; + let depth = 0; + while (current && depth < MAX_FIBER_WALK_DEPTH) { + if (isComponentFiber(current)) { + const name = getComponentName(current); + if (name && !componentName) { + componentName = name; + } + + const debugSource = getDebugSource(current); + if (debugSource && !sourceFile) { + sourceFile = debugSource.fileName; + sourceLineNumber = debugSource.lineNumber; + } + + if (componentName && sourceFile) break; + } + + current = (current.return ?? current._debugOwner ?? null) as Record | null; + depth++; + } + + return { componentName, sourceFile, sourceLineNumber }; +}; diff --git a/packages/tweaker/src/utils/tailwind.ts b/packages/tweaker/src/utils/tailwind.ts new file mode 100644 index 0000000..7173d4f --- /dev/null +++ b/packages/tweaker/src/utils/tailwind.ts @@ -0,0 +1,189 @@ +const TAILWIND_SPACING_SCALE: [string, number][] = [ + ["0", 0], + ["px", 1], + ["0.5", 2], + ["1", 4], + ["1.5", 6], + ["2", 8], + ["2.5", 10], + ["3", 12], + ["3.5", 14], + ["4", 16], + ["5", 20], + ["6", 24], + ["7", 28], + ["8", 32], + ["9", 36], + ["10", 40], + ["11", 44], + ["12", 48], + ["14", 56], + ["16", 64], + ["20", 80], + ["24", 96], + ["28", 112], + ["32", 128], + ["36", 144], + ["40", 160], + ["44", 176], + ["48", 192], + ["52", 208], + ["56", 224], + ["60", 240], + ["64", 256], + ["72", 288], + ["80", 320], + ["96", 384], +]; + +const TAILWIND_PADDING_REGEX = /^(-?)(?:p|px|py|pt|pr|pb|pl|ps|pe)-(.+)$/; +const TAILWIND_PADDING_PREFIX_REGEX = /^(-?)(p|px|py|pt|pr|pb|pl|ps|pe)-/; +const ARBITRARY_VALUE_REGEX = /^\[(.+)\]$/; + +export interface TailwindPaddingClass { + original: string; + prefix: string; + suffix: string; + pxValue: number; + isNegative: boolean; + isArbitrary: boolean; +} + +const suffixToPx = (suffix: string): number | null => { + const arbitraryMatch = suffix.match(ARBITRARY_VALUE_REGEX); + if (arbitraryMatch) { + const raw = arbitraryMatch[1]; + if (raw.endsWith("px")) return parseFloat(raw); + if (raw.endsWith("rem")) return parseFloat(raw) * 16; + if (raw.endsWith("em")) return parseFloat(raw) * 16; + return parseFloat(raw) || null; + } + const entry = TAILWIND_SPACING_SCALE.find(([key]) => key === suffix); + return entry ? entry[1] : null; +}; + +const parseSingleClass = (token: string): TailwindPaddingClass | null => { + const match = token.match(TAILWIND_PADDING_REGEX); + if (!match) return null; + const isNegative = match[1] === "-"; + const prefixMatch = token.match(TAILWIND_PADDING_PREFIX_REGEX); + if (!prefixMatch) return null; + const prefix = prefixMatch[2]; + const suffix = match[2]; + const pxValue = suffixToPx(suffix); + if (pxValue === null) return null; + return { + original: token, + prefix, + suffix, + pxValue: isNegative ? -pxValue : pxValue, + isNegative, + isArbitrary: ARBITRARY_VALUE_REGEX.test(suffix), + }; +}; + +const hasVariantPrefix = (token: string): boolean => token.includes(":") && !token.startsWith("-"); + +export const parseTailwindPaddingClasses = (className: string): TailwindPaddingClass[] => { + if (!className) return []; + return className + .split(/\s+/) + .filter((token) => token && !hasVariantPrefix(token)) + .map(parseSingleClass) + .filter((result): result is TailwindPaddingClass => result !== null); +}; + +export const pxToTailwindSuffix = (px: number): string => { + const absPx = Math.abs(px); + let closestSuffix = "0"; + let closestDistance = Infinity; + + for (const [suffix, scalePx] of TAILWIND_SPACING_SCALE) { + const distance = Math.abs(scalePx - absPx); + if (distance < closestDistance) { + closestDistance = distance; + closestSuffix = suffix; + } + } + + return closestSuffix; +}; + +interface PaddingSides { + top: number; + right: number; + bottom: number; + left: number; +} + +const getAffectedSides = (prefix: string): (keyof PaddingSides)[] => { + switch (prefix) { + case "p": + return ["top", "right", "bottom", "left"]; + case "px": + return ["left", "right"]; + case "py": + return ["top", "bottom"]; + case "pt": + return ["top"]; + case "pr": + return ["right"]; + case "pb": + return ["bottom"]; + case "pl": + return ["left"]; + case "ps": + return ["left"]; + case "pe": + return ["right"]; + default: + return []; + } +}; + +export const computeNewPaddingClasses = ( + originalClasses: TailwindPaddingClass[], + newPaddingY: number, + newPaddingX: number, + originalPadding: PaddingSides, +): string[] => { + const deltaY = newPaddingY - originalPadding.top; + const deltaX = newPaddingX - originalPadding.left; + + return originalClasses.map((parsed) => { + const sides = getAffectedSides(parsed.prefix); + if (sides.length === 0) return parsed.original; + + const isVertical = sides.some((side) => side === "top" || side === "bottom"); + const isHorizontal = sides.some((side) => side === "left" || side === "right"); + + let delta = 0; + if (isVertical && isHorizontal) { + delta = (deltaY + deltaX) / 2; + } else if (isVertical) { + delta = deltaY; + } else { + delta = deltaX; + } + + const newPx = parsed.pxValue + delta; + const newSuffix = pxToTailwindSuffix(newPx); + const isNegative = newPx < 0; + const negativePrefix = isNegative ? "-" : ""; + return `${negativePrefix}${parsed.prefix}-${newSuffix}`; + }); +}; + +export const replacePaddingClasses = ( + fullClassName: string, + oldClasses: TailwindPaddingClass[], + newClasses: string[], +): string => { + let result = fullClassName; + for (let index = 0; index < oldClasses.length; index++) { + result = result.replace(oldClasses[index].original, newClasses[index]); + } + return result; +}; + +export const tailwindClassPxValue = (parsed: TailwindPaddingClass): number => parsed.pxValue;