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;