diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.test.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.test.tsx
index 7c1ca8bc8..74613e8a0 100644
--- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.test.tsx
+++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.test.tsx
@@ -269,6 +269,94 @@ describe("CustomStylesSectionBuilder", () => {
]);
});
+ it("clamps manually typed style values to the schema bounds", () => {
+ styleRules.splice(0, styleRules.length);
+ renderCustomStyles();
+
+ fireEvent.change(screen.getByLabelText("Margin Top"), { target: { value: "100" } });
+ fireEvent.change(screen.getByLabelText("Font Size"), { target: { value: "2" } });
+
+ expect(updateResumeData).toHaveBeenCalledTimes(2);
+
+ const marginRecipe = updateResumeData.mock.calls[0]?.[0] as (draft: {
+ metadata: { styleRules: unknown[] };
+ }) => void;
+ const marginDraft = { metadata: { styleRules: [] } };
+ marginRecipe(marginDraft);
+
+ expect(marginDraft.metadata.styleRules).toEqual([
+ {
+ id: "style-global-heading",
+ label: "All sections: Section heading",
+ enabled: true,
+ target: { scope: "global" },
+ slots: { heading: { marginTop: 72 } },
+ },
+ ]);
+
+ const fontSizeRecipe = updateResumeData.mock.calls[1]?.[0] as (draft: {
+ metadata: { styleRules: unknown[] };
+ }) => void;
+ const fontSizeDraft = { metadata: { styleRules: [] } };
+ fontSizeRecipe(fontSizeDraft);
+
+ expect(fontSizeDraft.metadata.styleRules).toEqual([
+ {
+ id: "style-global-heading",
+ label: "All sections: Section heading",
+ enabled: true,
+ target: { scope: "global" },
+ slots: { heading: { fontSize: 6 } },
+ },
+ ]);
+ });
+
+ it("keeps intermediate numeric text while the input is focused", () => {
+ styleRules.splice(0, styleRules.length);
+ renderCustomStyles();
+
+ const fontSizeInput = screen.getByLabelText("Font Size");
+ fireEvent.focus(fontSizeInput);
+ fireEvent.change(fontSizeInput, { target: { value: "1" } });
+
+ expect(fontSizeInput).toHaveValue(1);
+
+ fireEvent.change(fontSizeInput, { target: { value: "12" } });
+ expect(fontSizeInput).toHaveValue(12);
+
+ fireEvent.blur(fontSizeInput);
+ expect(fontSizeInput).toHaveValue(12);
+ expect(updateResumeData).toHaveBeenCalledTimes(2);
+ });
+
+ it("commits normalized legacy values when the input loses focus", () => {
+ styleRules[0] = {
+ id: "style-global-heading",
+ label: "All sections: Section heading",
+ enabled: true,
+ target: { scope: "global" },
+ slots: { heading: { borderWidth: 100 } },
+ };
+ renderCustomStyles();
+
+ const borderWidthInput = screen.getByLabelText("Border Width");
+ expect(borderWidthInput).toHaveValue(100);
+
+ fireEvent.focus(borderWidthInput);
+ fireEvent.blur(borderWidthInput);
+
+ expect(borderWidthInput).toHaveValue(24);
+ expect(updateResumeData).toHaveBeenCalledTimes(1);
+
+ const recipe = updateResumeData.mock.calls[0]?.[0] as (draft: {
+ metadata: { styleRules: StyleRule[] };
+ }) => void;
+ const draft = { metadata: { styleRules: structuredClone(styleRules) } };
+ recipe(draft);
+
+ expect(draft.metadata.styleRules[0]?.slots.heading?.borderWidth).toBe(24);
+ });
+
it("stores list slot rules for rich text lists", async () => {
styleRules.splice(0, styleRules.length);
renderCustomStyles();
diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.tsx
index 0cf01aba5..0cdfa35ed 100644
--- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.tsx
+++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.tsx
@@ -9,7 +9,7 @@ import type { ReactNode } from "react";
import type { ComboboxOption } from "@/components/ui/combobox";
import { Trans } from "@lingui/react/macro";
import { EyeIcon, EyeSlashIcon, PencilSimpleIcon, TrashSimpleIcon } from "@phosphor-icons/react";
-import { useMemo, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { sectionTypeSchema } from "@reactive-resume/schema/resume/data";
import { Button } from "@reactive-resume/ui/components/button";
import { Input } from "@reactive-resume/ui/components/input";
@@ -329,23 +329,63 @@ type NumberInputProps = {
onChange: (value: number | undefined) => void;
};
+function parseBoundedNumberInput(value: string, min: number, max: number): number | undefined {
+ if (value === "") return undefined;
+
+ const number = Number(value);
+ if (!Number.isFinite(number)) return undefined;
+
+ return Math.min(max, Math.max(min, number));
+}
+
+function useBoundedNumberInput(
+ value: number | undefined,
+ min: number,
+ max: number,
+ onChange: (value: number | undefined) => void,
+) {
+ const [inputValue, setInputValue] = useState(value?.toString() ?? "");
+ const isFocused = useRef(false);
+
+ useEffect(() => {
+ if (!isFocused.current) setInputValue(value?.toString() ?? "");
+ }, [value]);
+
+ return {
+ inputValue,
+ onFocus: () => {
+ isFocused.current = true;
+ },
+ onBlur: () => {
+ isFocused.current = false;
+ const normalizedValue = parseBoundedNumberInput(inputValue, min, max);
+ setInputValue(normalizedValue?.toString() ?? "");
+ if (normalizedValue !== value) onChange(normalizedValue);
+ },
+ onInputChange: (nextValue: string) => {
+ setInputValue(nextValue);
+ onChange(parseBoundedNumberInput(nextValue, min, max));
+ },
+ };
+}
+
function NumberInput({ label, id, value, min, max, step = 1, onChange }: NumberInputProps) {
const inputId = id ?? `style-${label.toLowerCase().replaceAll(" ", "-")}`;
+ const boundedInput = useBoundedNumberInput(value, min, max, onChange);
return (
{
- const value = event.target.value;
- onChange(value === "" ? undefined : Number(value));
- }}
+ onFocus={boundedInput.onFocus}
+ onBlur={boundedInput.onBlur}
+ onChange={(event) => boundedInput.onInputChange(event.target.value)}
/>
);
@@ -833,21 +873,22 @@ function CompactNumberInput({
step = 1,
onChange,
}: CompactNumberInputProps) {
+ const boundedInput = useBoundedNumberInput(value, min, max, onChange);
+
return (
{
- const value = event.target.value;
- onChange(value === "" ? undefined : Number(value));
- }}
+ onFocus={boundedInput.onFocus}
+ onBlur={boundedInput.onBlur}
+ onChange={(event) => boundedInput.onInputChange(event.target.value)}
/>
);
}