From f23d93121737a851ec28814e67ad9d96c154d3c5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?=
Date: Sun, 21 Jun 2026 18:12:15 +0900
Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20=EB=85=B8=ED=8A=B8=20=EC=8B=9C?=
=?UTF-8?q?=EA=B0=81=20px=20=ED=95=84=EB=93=9C=EB=A5=BC=20f64=EB=A1=9C=20w?=
=?UTF-8?q?idening?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
note_width, note_border_radius, note_glow_size를 u32에서 f64로 변경해
소수 입력을 받을 수 있도록 모델 타입 확장. 기존 정수 저장값은 serde가
그대로 역직렬화하므로 별도 변환 불필요. 레거시 borderRadius 마이그레이션은
f64 캐스트 추가
---
src-tauri/src/models/mod.rs | 10 +++++-----
src-tauri/src/state/migration.rs | 2 +-
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs
index 231a68d9..a1cdf408 100644
--- a/src-tauri/src/models/mod.rs
+++ b/src-tauri/src/models/mod.rs
@@ -206,10 +206,10 @@ pub struct KeyPosition {
pub note_color: NoteColor,
pub note_opacity: u32,
#[serde(default)]
- pub note_border_radius: Option,
+ pub note_border_radius: Option,
/// 노트 넓이(px). None이면 키 width를 사용(자동).
#[serde(default)]
- pub note_width: Option,
+ pub note_width: Option,
/// 노트 정렬 (left/center/right). 기본값 center.
#[serde(default)]
pub note_alignment: NoteAlignment,
@@ -218,7 +218,7 @@ pub struct KeyPosition {
#[serde(default = "default_note_glow_enabled")]
pub note_glow_enabled: bool,
#[serde(default = "default_note_glow_size")]
- pub note_glow_size: u32,
+ pub note_glow_size: f64,
#[serde(default = "default_note_glow_opacity")]
pub note_glow_opacity: u32,
#[serde(default)]
@@ -760,8 +760,8 @@ fn default_note_effect_enabled() -> bool {
fn default_note_glow_enabled() -> bool {
false
}
-fn default_note_glow_size() -> u32 {
- 20
+fn default_note_glow_size() -> f64 {
+ 20.0
}
fn default_note_glow_opacity() -> u32 {
70
diff --git a/src-tauri/src/state/migration.rs b/src-tauri/src/state/migration.rs
index f846160d..8f0d981d 100644
--- a/src-tauri/src/state/migration.rs
+++ b/src-tauri/src/state/migration.rs
@@ -329,7 +329,7 @@ pub(crate) fn normalize_state(mut data: AppStoreData) -> AppStoreData {
if let Some(legacy_border_radius) = data.note_settings.border_radius.take() {
for positions in data.key_positions.values_mut() {
for pos in positions.iter_mut() {
- pos.note_border_radius = Some(legacy_border_radius);
+ pos.note_border_radius = Some(legacy_border_radius as f64);
}
}
}
From ca85a6aba4923cf4e0cff2bc13488f0f00c30b4f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?=
Date: Sun, 21 Jun 2026 18:38:23 +0900
Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EB=85=B8=ED=8A=B8=20=ED=81=AC?=
=?UTF-8?q?=EA=B8=B0=C2=B7=ED=85=8C=EB=91=90=EB=A6=AC=20=EB=91=90=EA=BB=98?=
=?UTF-8?q?=20=EB=93=B1=20=EC=88=98=EC=B9=98=20=EA=B0=92=20=EC=86=8C?=
=?UTF-8?q?=EC=88=98=20=EC=9E=85=EB=A0=A5=20=EC=A7=80=EC=9B=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
키/노트 크기, 테두리 두께·라운딩, 글로우 크기, 오프셋, 폰트 크기 등
시각적 px 값에 0.1 단위 소수 입력을 허용. 카운트·투명도 등은 정수 유지
- OptionalNumberInput에 allowDecimal/decimalScale 추가 (NumberInput과 동일한
소수 sanitize·precision 처리, numpad Decimal 키, inputMode decimal)
- Note/Style/Batch 탭의 px 입력에 allowDecimal 적용
- zod 스키마에서 noteWidth/noteBorderRadius/noteGlowSize의 정수 제약 해제
- noteWidth 렌더 시 Math.round 제거로 소수 폭 반영
- 구형 키 설정 모달의 글로우 크기 입력도 소수 지원
- 빈 값만 unset, 부호·소수점 중간 입력은 commit 보류
zod·serde 회귀 테스트 추가
---
src-tauri/src/models/mod.rs | 48 +++++++++++
.../Grid/PropertiesPanel/PropertyInputs.tsx | 86 +++++++++++++++----
.../batch/BatchNoteTabContent.tsx | 20 +++--
.../batch/BatchStyleTabContent.tsx | 6 ++
.../PropertiesPanel/single/NoteTabContent.tsx | 14 ++-
.../single/StyleTabContent.tsx | 6 ++
.../main/Grid/PropertiesPanel/types.ts | 2 +
.../Modal/content/settings/NoteTabContent.tsx | 22 ++++-
.../hooks/shared/useLayoutComputation.ts | 4 +-
src/types/key/keys.test.ts | 26 ++++++
src/types/key/keys.ts | 5 +-
11 files changed, 204 insertions(+), 35 deletions(-)
create mode 100644 src/types/key/keys.test.ts
diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs
index a1cdf408..4f1322f3 100644
--- a/src-tauri/src/models/mod.rs
+++ b/src-tauri/src/models/mod.rs
@@ -1812,3 +1812,51 @@ pub struct SettingsPatch {
#[serde(skip_serializing_if = "Option::is_none")]
pub obs_mode_enabled: Option,
}
+
+#[cfg(test)]
+mod tests {
+ use super::KeyPosition;
+
+ // 필수 필드만 채운 최소 KeyPosition JSON. 시각 px 필드는 호출부에서 주입
+ fn key_position_json(visual_px: &str) -> String {
+ format!(
+ r##"{{
+ "dx": 0.0, "dy": 0.0, "width": 60.0, "height": 60.0,
+ "count": 0, "noteColor": "#FFFFFF", "noteOpacity": 80,
+ {visual_px}
+ }}"##
+ )
+ }
+
+ // 기존 정수 저장값이 f64 필드로 그대로 역직렬화되는지 (하위 호환)
+ #[test]
+ fn visual_px_fields_accept_integer_json() {
+ let json =
+ key_position_json(r#""noteWidth": 100, "noteBorderRadius": 8, "noteGlowSize": 20"#);
+ let pos: KeyPosition = serde_json::from_str(&json).unwrap();
+ assert_eq!(pos.note_width, Some(100.0));
+ assert_eq!(pos.note_border_radius, Some(8.0));
+ assert_eq!(pos.note_glow_size, 20.0);
+ }
+
+ // 소수 저장값이 정상 역직렬화되는지
+ #[test]
+ fn visual_px_fields_accept_decimal_json() {
+ let json = key_position_json(
+ r#""noteWidth": 100.5, "noteBorderRadius": 8.5, "noteGlowSize": 20.5"#,
+ );
+ let pos: KeyPosition = serde_json::from_str(&json).unwrap();
+ assert_eq!(pos.note_width, Some(100.5));
+ assert_eq!(pos.note_border_radius, Some(8.5));
+ assert_eq!(pos.note_glow_size, 20.5);
+ }
+
+ // note_glow_size 미지정 시 기본값(20.0) 적용
+ #[test]
+ fn note_glow_size_defaults_to_20() {
+ let json = key_position_json(r#""noteWidth": null"#);
+ let pos: KeyPosition = serde_json::from_str(&json).unwrap();
+ assert_eq!(pos.note_glow_size, 20.0);
+ assert_eq!(pos.note_width, None);
+ }
+}
diff --git a/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.tsx b/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.tsx
index dabf6467..e6ce598e 100644
--- a/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.tsx
+++ b/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.tsx
@@ -300,16 +300,61 @@ export const OptionalNumberInput: React.FC = ({
width = '54px',
placeholder,
allowNegative = false,
+ allowDecimal = false,
+ decimalScale = 1,
isMixed = false,
mixedPlaceholder = 'Mixed',
}) => {
const hasSuffix = !!suffix;
+ const resolvedDecimalScale = allowDecimal
+ ? Math.max(0, Math.floor(decimalScale))
+ : 0;
+ const supportsDecimal = resolvedDecimalScale > 0;
+ const inputMode = supportsDecimal ? 'decimal' : 'numeric';
+
+ const normalizePrecision = (num: number): number => {
+ if (!supportsDecimal) return num;
+ return Number(num.toFixed(resolvedDecimalScale));
+ };
+
+ // 숫자/부호/소수점만 남기고, 부호는 맨 앞에 하나, 소수점도 하나만 유지
+ const sanitizeInput = (raw: string): string => {
+ const pattern = supportsDecimal
+ ? allowNegative
+ ? /[^0-9.-]/g
+ : /[^0-9.]/g
+ : allowNegative
+ ? /[^0-9-]/g
+ : /[^0-9]/g;
+ let sanitized = raw.replace(pattern, '');
+
+ if (allowNegative) {
+ const isNegative = sanitized.startsWith('-');
+ sanitized = sanitized.replace(/-/g, '');
+ if (isNegative) sanitized = `-${sanitized}`;
+ }
+
+ if (!supportsDecimal) return sanitized;
+
+ const sign = sanitized.startsWith('-') ? '-' : '';
+ const unsigned = sign ? sanitized.slice(1) : sanitized;
+ const dotIndex = unsigned.indexOf('.');
+ if (dotIndex === -1) return `${sign}${unsigned}`;
+
+ const integerPart = unsigned.slice(0, dotIndex);
+ const fractionalPart = unsigned
+ .slice(dotIndex + 1)
+ .replace(/\./g, '')
+ .slice(0, resolvedDecimalScale);
+ return `${sign}${integerPart}.${fractionalPart}`;
+ };
const getDisplayValue = (val: number, focused: boolean): string => {
+ const normalized = normalizePrecision(val);
if (hasSuffix && !focused) {
- return `${val}${suffix}`;
+ return `${normalized}${suffix}`;
}
- return String(val);
+ return String(normalized);
};
const [localValue, setLocalValue] = useState(() => {
@@ -349,31 +394,30 @@ export const OptionalNumberInput: React.FC = ({
if (e.ctrlKey || e.metaKey) return;
if (/^[0-9]$/.test(e.key)) return;
if (allowNegative && e.key === '-') return;
+ if (supportsDecimal && (e.key === '.' || e.key === 'Decimal')) return;
e.preventDefault();
};
- const numericPattern = allowNegative ? /[^0-9-]/g : /[^0-9]/g;
-
const handleChange = (e: React.ChangeEvent) => {
- const raw = e.target.value.replace(numericPattern, '');
- // 마이너스는 맨 앞에만 허용
- const newValue = allowNegative
- ? (raw.startsWith('-') ? '-' : '') + raw.replace(/-/g, '')
- : raw;
+ const newValue = sanitizeInput(e.target.value);
setLocalValue(newValue);
setHasUserInput(true);
- if (newValue === '' || newValue === '-') {
+ // 빈 값만 unset, 부호·소수점만 남은 중간 상태는 commit하지 않고 입력 유지
+ if (newValue === '') {
onChange(undefined);
return;
}
+ if (newValue === '-' || newValue === '.' || newValue === '-.') {
+ return;
+ }
const numValue = Number(newValue);
if (!Number.isFinite(numValue)) return;
const clamped = Math.min(Math.max(numValue, min), max);
- onChange(clamped);
+ onChange(normalizePrecision(clamped));
};
const handleFocus = () => {
@@ -388,9 +432,7 @@ export const OptionalNumberInput: React.FC = ({
const handleBlur = () => {
setIsFocused(false);
- const cleaned = allowNegative
- ? localValue.replace(/[^0-9-]/g, '')
- : localValue.replace(/[^0-9]/g, '');
+ const cleaned = sanitizeInput(localValue);
// Mixed 상태에서 사용자 입력이 없었으면 Mixed 유지
if (isMixed && !hasUserInput) {
@@ -400,7 +442,13 @@ export const OptionalNumberInput: React.FC = ({
return;
}
- if (cleaned === '' || cleaned === '-' || isNaN(Number(cleaned))) {
+ if (
+ cleaned === '' ||
+ cleaned === '-' ||
+ cleaned === '.' ||
+ cleaned === '-.' ||
+ isNaN(Number(cleaned))
+ ) {
setLocalValue('');
onChange(undefined);
setHasUserInput(false);
@@ -409,7 +457,7 @@ export const OptionalNumberInput: React.FC = ({
}
const numValue = Number(cleaned);
- const clamped = Math.min(Math.max(numValue, min), max);
+ const clamped = normalizePrecision(Math.min(Math.max(numValue, min), max));
setLocalValue(getDisplayValue(clamped, false));
onChange(clamped);
setHasUserInput(false);
@@ -443,7 +491,7 @@ export const OptionalNumberInput: React.FC = ({
)}
= ({
return (
= ({
return (
= ({
{/* 오프셋 */}
pos.noteOffsetX, 0).value || undefined
- }
+ value={getMixedValue((pos) => pos.noteOffsetX, 0).value || undefined}
onChange={(value) =>
handleBatchStyleChangeComplete('noteOffsetX', value)
}
prefix="X"
allowNegative
+ allowDecimal
+ decimalScale={1}
min={NOTE_SETTINGS_CONSTRAINTS.noteOffsetX.min}
max={NOTE_SETTINGS_CONSTRAINTS.noteOffsetX.max}
placeholder="0"
isMixed={getMixedValue((pos) => pos.noteOffsetX, 0).isMixed}
/>
pos.noteOffsetY, 0).value || undefined
- }
+ value={getMixedValue((pos) => pos.noteOffsetY, 0).value || undefined}
onChange={(value) =>
handleBatchStyleChangeComplete('noteOffsetY', value)
}
prefix="Y"
allowNegative
+ allowDecimal
+ decimalScale={1}
min={NOTE_SETTINGS_CONSTRAINTS.noteOffsetY.min}
max={NOTE_SETTINGS_CONSTRAINTS.noteOffsetY.max}
placeholder="0"
@@ -154,6 +154,8 @@ const BatchNoteTabContent: React.FC = ({
}
suffix="px"
min={1}
+ allowDecimal
+ decimalScale={1}
placeholder="Auto"
isMixed={noteWidthMixed.isMixed}
/>
@@ -332,6 +334,8 @@ const BatchNoteTabContent: React.FC = ({
suffix="px"
min={NOTE_SETTINGS_CONSTRAINTS.noteBorderWidth.min}
max={NOTE_SETTINGS_CONSTRAINTS.noteBorderWidth.max}
+ allowDecimal
+ decimalScale={1}
isMixed={
getMixedValue(
(pos) => pos.noteBorderWidth,
@@ -356,6 +360,8 @@ const BatchNoteTabContent: React.FC = ({
suffix="px"
min={NOTE_SETTINGS_CONSTRAINTS.borderRadius.min}
max={NOTE_SETTINGS_CONSTRAINTS.borderRadius.max}
+ allowDecimal
+ decimalScale={1}
isMixed={
getMixedValue(
(pos) => pos.noteBorderRadius,
@@ -410,6 +416,8 @@ const BatchNoteTabContent: React.FC = ({
suffix="px"
min={0}
max={50}
+ allowDecimal
+ decimalScale={1}
isMixed={getMixedValue((pos) => pos.noteGlowSize, 20).isMixed}
/>
diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx
index a7621253..dad73426 100644
--- a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx
+++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx
@@ -630,6 +630,8 @@ const BatchStyleTabContent: React.FC = ({
suffix="px"
min={0}
max={20}
+ allowDecimal
+ decimalScale={1}
/>
@@ -646,6 +648,8 @@ const BatchStyleTabContent: React.FC = ({
suffix="px"
min={0}
max={100}
+ allowDecimal
+ decimalScale={1}
/>
@@ -722,6 +726,8 @@ const BatchStyleTabContent: React.FC = ({
suffix="px"
min={8}
max={72}
+ allowDecimal
+ decimalScale={1}
/>
diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/NoteTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/NoteTabContent.tsx
index 161e50e1..69134671 100644
--- a/src/renderer/components/main/Grid/PropertiesPanel/single/NoteTabContent.tsx
+++ b/src/renderer/components/main/Grid/PropertiesPanel/single/NoteTabContent.tsx
@@ -462,6 +462,8 @@ const NoteTabContent: React.FC = ({
onChange={(value) => handleStyleChangeComplete('noteOffsetX', value)}
prefix="X"
allowNegative
+ allowDecimal
+ decimalScale={1}
min={NOTE_SETTINGS_CONSTRAINTS.noteOffsetX.min}
max={NOTE_SETTINGS_CONSTRAINTS.noteOffsetX.max}
placeholder="0"
@@ -471,6 +473,8 @@ const NoteTabContent: React.FC = ({
onChange={(value) => handleStyleChangeComplete('noteOffsetY', value)}
prefix="Y"
allowNegative
+ allowDecimal
+ decimalScale={1}
min={NOTE_SETTINGS_CONSTRAINTS.noteOffsetY.min}
max={NOTE_SETTINGS_CONSTRAINTS.noteOffsetY.max}
placeholder="0"
@@ -484,7 +488,9 @@ const NoteTabContent: React.FC = ({
onChange={(value) => handleStyleChangeComplete('noteWidth', value)}
suffix="px"
min={1}
- placeholder={`${Math.round(keyPosition.width)}px`}
+ allowDecimal
+ decimalScale={1}
+ placeholder={`${keyPosition.width}px`}
/>
@@ -642,6 +648,8 @@ const NoteTabContent: React.FC = ({
suffix="px"
min={NOTE_SETTINGS_CONSTRAINTS.noteBorderWidth.min}
max={NOTE_SETTINGS_CONSTRAINTS.noteBorderWidth.max}
+ allowDecimal
+ decimalScale={1}
/>
@@ -658,6 +666,8 @@ const NoteTabContent: React.FC = ({
suffix="px"
min={NOTE_SETTINGS_CONSTRAINTS.borderRadius.min}
max={NOTE_SETTINGS_CONSTRAINTS.borderRadius.max}
+ allowDecimal
+ decimalScale={1}
/>
@@ -701,6 +711,8 @@ const NoteTabContent: React.FC = ({
suffix="px"
min={0}
max={50}
+ allowDecimal
+ decimalScale={1}
/>
diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx
index 422c840a..ff886dfe 100644
--- a/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx
+++ b/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx
@@ -526,6 +526,8 @@ const StyleTabContent: React.FC = ({
suffix="px"
min={0}
max={20}
+ allowDecimal
+ decimalScale={1}
/>
@@ -537,6 +539,8 @@ const StyleTabContent: React.FC = ({
suffix="px"
min={0}
max={100}
+ allowDecimal
+ decimalScale={1}
/>
@@ -595,6 +599,8 @@ const StyleTabContent: React.FC = ({
suffix="px"
min={8}
max={72}
+ allowDecimal
+ decimalScale={1}
/>
diff --git a/src/renderer/components/main/Grid/PropertiesPanel/types.ts b/src/renderer/components/main/Grid/PropertiesPanel/types.ts
index af058036..0640e73c 100644
--- a/src/renderer/components/main/Grid/PropertiesPanel/types.ts
+++ b/src/renderer/components/main/Grid/PropertiesPanel/types.ts
@@ -63,6 +63,8 @@ export interface OptionalNumberInputProps {
width?: string;
placeholder?: string;
allowNegative?: boolean;
+ allowDecimal?: boolean;
+ decimalScale?: number;
isMixed?: boolean;
mixedPlaceholder?: string;
}
diff --git a/src/renderer/components/main/Modal/content/settings/NoteTabContent.tsx b/src/renderer/components/main/Modal/content/settings/NoteTabContent.tsx
index e07eba02..ba7851de 100644
--- a/src/renderer/components/main/Modal/content/settings/NoteTabContent.tsx
+++ b/src/renderer/components/main/Modal/content/settings/NoteTabContent.tsx
@@ -222,17 +222,30 @@ const NoteTabContent = forwardRef(
}
};
- // 글로우 크기 핸들러
+ // 글로우 크기 핸들러 (소수 0.1 단위 허용)
+ const sanitizeGlowSize = (raw: string): string => {
+ const cleaned = raw.replace(/[^0-9.]/g, '');
+ const dotIndex = cleaned.indexOf('.');
+ if (dotIndex === -1) return cleaned;
+ return (
+ cleaned.slice(0, dotIndex + 1) +
+ cleaned
+ .slice(dotIndex + 1)
+ .replace(/\./g, '')
+ .slice(0, 1)
+ );
+ };
+
const handleGlowSizeChange = (e: React.ChangeEvent) => {
- const newValue = e.target.value.replace(/[^0-9]/g, '');
+ const newValue = sanitizeGlowSize(e.target.value);
setState((prev) => ({ ...prev, displayGlowSize: newValue }));
};
const handleGlowSizeBlur = (e: React.FocusEvent) => {
- const parsed = parseInt(e.target.value.replace(/[^0-9]/g, ''), 10);
+ const parsed = parseFloat(sanitizeGlowSize(e.target.value));
const clamped = Number.isNaN(parsed)
? 20
- : Math.min(Math.max(parsed, 0), 50);
+ : Number(Math.min(Math.max(parsed, 0), 50).toFixed(1));
setState((prev) => ({
...prev,
glowSize: clamped,
@@ -391,6 +404,7 @@ const NoteTabContent = forwardRef(
{
+ it('noteWidth 소수 허용', () => {
+ expect(keyPositionSchema.shape.noteWidth.parse(100.5)).toBe(100.5);
+ expect(keyPositionSchema.shape.noteWidth.parse(100)).toBe(100);
+ });
+
+ it('noteBorderRadius 소수 허용', () => {
+ expect(keyPositionSchema.shape.noteBorderRadius.parse(8.5)).toBe(8.5);
+ });
+
+ it('noteGlowSize 소수 허용', () => {
+ expect(keyPositionSchema.shape.noteGlowSize.parse(20.5)).toBe(20.5);
+ });
+
+ it('noteBorderWidth 소수 허용', () => {
+ expect(keyPositionSchema.shape.noteBorderWidth.parse(2.5)).toBe(2.5);
+ });
+
+ // 정수 유지 필드는 여전히 소수를 거부해야 한다
+ it('noteOpacity는 소수 거부(정수 유지)', () => {
+ expect(() => keyPositionSchema.shape.noteOpacity.parse(80.5)).toThrow();
+ });
+});
diff --git a/src/types/key/keys.ts b/src/types/key/keys.ts
index e2d2298a..b07c6b3e 100644
--- a/src/types/key/keys.ts
+++ b/src/types/key/keys.ts
@@ -249,12 +249,11 @@ export const keyPositionSchema = z.object({
// 노트 모서리 반경 (키별 설정, 없으면 기본값 사용)
noteBorderRadius: z
.number()
- .int()
.min(NOTE_SETTINGS_CONSTRAINTS.borderRadius.min)
.max(NOTE_SETTINGS_CONSTRAINTS.borderRadius.max)
.optional(),
// 노트 넓이(px). 비어있으면 해당 키 width를 사용(자동)
- noteWidth: z.number().int().positive().optional(),
+ noteWidth: z.number().positive().optional(),
// 노트 정렬 (left/center/right). 기본값 center.
noteAlignment: z
.enum(['left', 'center', 'right'])
@@ -262,7 +261,7 @@ export const keyPositionSchema = z.object({
.default('center'),
noteEffectEnabled: z.boolean().optional().default(true),
noteGlowEnabled: z.boolean().optional().default(false),
- noteGlowSize: z.number().int().min(0).max(50).optional().default(20),
+ noteGlowSize: z.number().min(0).max(50).optional().default(20),
noteGlowOpacity: z.number().int().min(0).max(100).optional().default(70),
// 그라디언트용 글로우 투명도(Top/Bottom). 없으면 noteGlowOpacity를 사용.
noteGlowOpacityTop: z.number().int().min(0).max(100).optional(),