INVYONE Studio Config Panel — CP 프리미티브 신설 + V2FieldConfigPanel HTML V4 스펙 풀 매칭

- _shared/cp 프리미티브 14종 (CPSection/CPRow/CPSelect 커스텀 popover/CPSegment 평면/CPCrumb 통합 팝오버 등)
- V2FieldConfigPanel: 4 kinds × 10 types × format별 옵션 패널로 재작성 (panel-input-new.jsx 매칭)
  - resolveTriple/applyTriple 양방향 매핑 — 기존 fieldType/source/autoGeneration 호환
  - 미구현(formula/audit/금액 외화/주민·주소·카드/다중 entity) 영역은 Hint 로 명시
- V2PropertiesPanel cp 톤 + 중복 Separator 정리, ScreenDesigner 우측 패널 너비 드래그
- 다크 luminance 분리 / 섹션 헤더 gradient underline / brumb 좌·우 transparent 영역

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 01:00:46 +09:00
parent b81794a2a5
commit 29682e5b63
16 changed files with 5879 additions and 1777 deletions
@@ -133,6 +133,7 @@ export function BlockRenderer({
value={resolvedValue}
isDesignMode={false}
isPreview={true}
isInteractive={true}
formData={context.formRow}
form_data={context.formRow}
onFormDataChange={(fieldName: string, value: any) =>
+54 -14
View File
@@ -146,8 +146,6 @@ import { type ViewType } from "./ViewTabBar";
// 컴포넌트 초기화 (새 시스템)
import "@/lib/registry/components";
// 성능 최적화 도구 초기화 (필요시 사용)
import "@/lib/registry/utils/performanceOptimizer";
interface ScreenDesignerProps {
selectedScreen: ScreenDefinition | null;
@@ -464,6 +462,40 @@ export default function ScreenDesigner({
}: ScreenDesignerProps) {
// POP 모드 여부에 따른 API 분기
const USE_POP_API = isPop;
// 우측 속성 패널 너비 (드래그로 조절, localStorage 보관) — 240~600px
const [rightPanelWidth, setRightPanelWidth] = useState<number>(() => {
if (typeof window === "undefined") return 320;
const saved = window.localStorage.getItem("inv-right-panel-width");
const n = saved ? Number(saved) : NaN;
return Number.isFinite(n) && n >= 240 && n <= 600 ? n : 320;
});
useEffect(() => {
try { window.localStorage.setItem("inv-right-panel-width", String(rightPanelWidth)); } catch {}
}, [rightPanelWidth]);
const startRightPanelDrag = useCallback((e: React.MouseEvent) => {
e.preventDefault();
const startX = e.clientX;
setRightPanelWidth((curr) => {
const startWidth = curr;
const onMove = (ev: MouseEvent) => {
const next = Math.min(600, Math.max(240, startWidth + (startX - ev.clientX)));
setRightPanelWidth(next);
};
const onUp = () => {
document.body.style.cursor = "";
document.body.style.userSelect = "";
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.body.style.cursor = "ew-resize";
document.body.style.userSelect = "none";
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
return curr;
});
}, []);
const [layout, setLayout] = useState<LayoutData>({
screenId: 0,
components: [],
@@ -8735,18 +8767,26 @@ export default function ScreenDesigner({
* - "편집" Edit (500 ).
*/}
{isPropertiesPanelOpen && (
<aside className="inv-right-panel border-border bg-card flex h-full w-[280px] flex-col overflow-hidden border-l shadow-sm">
<div className="border-border flex shrink-0 items-center justify-between border-b px-3 py-2">
<h3 className="text-foreground text-xs font-bold tracking-wider uppercase">
</h3>
<span className="text-muted-foreground text-[0.55rem]">
{groupState.selectedComponents.length > 0
? `${groupState.selectedComponents.length} selected`
: "Inspector"}
</span>
</div>
<aside
style={{ width: rightPanelWidth }}
className="inv-right-panel border-border bg-card relative flex h-full flex-col overflow-hidden border-l shadow-sm"
>
{/* 좌측 리사이즈 핸들 (드래그해서 패널 너비 조절) */}
<div
onMouseDown={startRightPanelDrag}
onDoubleClick={() => setRightPanelWidth(320)}
title="드래그해서 너비 조절 · 더블클릭으로 기본값"
className="hover:bg-primary/40 active:bg-primary/60 transition-colors"
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 4,
cursor: "ew-resize",
zIndex: 20,
}}
/>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
{selectedComponent ? (
<V2PropertiesPanel
+15 -6
View File
@@ -53,9 +53,12 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
<div className={`space-y-2 ${className}`}>
{/* 테두리 섹션 */}
<Collapsible open={openSections.border} onOpenChange={() => toggleSection("border")}>
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-md bg-muted px-2 py-1.5 hover:bg-muted">
<CollapsibleTrigger
className="flex w-full items-center justify-between rounded-md px-2 py-1.5"
style={{ background: "var(--cp-bg-subtle)", border: "1px solid var(--cp-border)" }}
>
<div className="flex items-center gap-1.5">
<Square className="text-primary h-3 w-3" />
<Square className="text-muted-foreground h-3 w-3" />
<span className="text-xs font-medium"></span>
</div>
<ChevronDown
@@ -142,9 +145,12 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
{/* 배경 섹션 */}
<Collapsible open={openSections.background} onOpenChange={() => toggleSection("background")}>
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-md bg-muted px-2 py-1.5 hover:bg-muted">
<CollapsibleTrigger
className="flex w-full items-center justify-between rounded-md px-2 py-1.5"
style={{ background: "var(--cp-bg-subtle)", border: "1px solid var(--cp-border)" }}
>
<div className="flex items-center gap-1.5">
<Palette className="text-primary h-3 w-3" />
<Palette className="text-muted-foreground h-3 w-3" />
<span className="text-xs font-medium"></span>
</div>
<ChevronDown
@@ -186,9 +192,12 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
{/* 텍스트 섹션 */}
<Collapsible open={openSections.text} onOpenChange={() => toggleSection("text")}>
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-md bg-muted px-2 py-1.5 hover:bg-muted">
<CollapsibleTrigger
className="flex w-full items-center justify-between rounded-md px-2 py-1.5"
style={{ background: "var(--cp-bg-subtle)", border: "1px solid var(--cp-border)" }}
>
<div className="flex items-center gap-1.5">
<Type className="text-primary h-3 w-3" />
<Type className="text-muted-foreground h-3 w-3" />
<span className="text-xs font-medium"></span>
</div>
<ChevronDown
@@ -50,6 +50,15 @@ import { columnMetaCache, loadColumnMeta } from "@/lib/registry/DynamicComponent
import { DynamicComponentConfigPanel, hasComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
import StyleEditor from "../StyleEditor";
import { Slider } from "@/components/ui/slider";
import "@/components/v2/config-panels/_shared/cp/cp.css";
import {
CPSection,
CPRow,
CPText,
CPNumber,
CPSwitch,
CPGroup,
} from "@/components/v2/config-panels/_shared/cp";
import { Zap } from "lucide-react";
import { ConditionalConfigPanel } from "@/components/v2/ConditionalConfigPanel";
import { ConditionalConfig } from "@/types/v2-components";
@@ -158,7 +167,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
// 컴포넌트가 선택되지 않았을 때는 안내 메시지만 표시
if (!selectedComponent) {
return (
<div className="flex h-full flex-col overflow-x-auto bg-white">
<div className="flex h-full flex-col overflow-x-auto">
<div className="flex-1 overflow-x-auto overflow-y-auto p-2">
<div className="space-y-4 text-xs">
{/* 안내 메시지 */}
@@ -426,23 +435,17 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
return (
<div className="space-y-1">
{/* DIMENSIONS 섹션 */}
<div className="border-border/50 mb-3 border-b pb-3">
<h4 className="text-muted-foreground py-2 text-[10px] font-semibold tracking-wider uppercase">DIMENSIONS</h4>
<div className="flex gap-2">
<div className="flex-1">
<Label className="text-muted-foreground text-[10px]"></Label>
<Input
{/* DIMENSIONS → 크기 (CPSection + CPRow) */}
<div style={{ padding: "0 12px" }}>
<CPSection title="크기">
<CPRow label="너비">
<CPText
type="number"
min={10}
max={3840}
step="1"
mono
value={localWidth}
onChange={(e) => {
setLocalWidth(e.target.value);
}}
onChange={(v) => setLocalWidth(v)}
onBlur={(e) => {
const value = parseInt(e.target.value) || 0;
const value = parseInt((e.target as HTMLInputElement).value) || 0;
if (value >= 10) {
const snappedValue = Math.round(value / 10) * 10;
handleUpdate("size.width", snappedValue);
@@ -451,29 +454,27 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
const value = parseInt(e.currentTarget.value) || 0;
const value = parseInt((e.currentTarget as HTMLInputElement).value) || 0;
if (value >= 10) {
const snappedValue = Math.round(value / 10) * 10;
handleUpdate("size.width", snappedValue);
setLocalWidth(String(snappedValue));
}
e.currentTarget.blur();
(e.currentTarget as HTMLInputElement).blur();
}
}}
placeholder="100"
className="h-7 w-full text-xs"
suffix="px"
/>
</div>
<div className="flex-1">
<Label className="text-muted-foreground text-[10px]"></Label>
<Input
</CPRow>
<CPRow label="높이">
<CPText
type="number"
mono
value={localHeight}
onChange={(e) => {
setLocalHeight(e.target.value);
}}
onChange={(v) => setLocalHeight(v)}
onBlur={(e) => {
const value = parseInt(e.target.value) || 0;
const value = parseInt((e.target as HTMLInputElement).value) || 0;
if (value >= 10) {
const snappedValue = Math.round(value / 10) * 10;
handleUpdate("size.height", snappedValue);
@@ -482,256 +483,219 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
const value = parseInt(e.currentTarget.value) || 0;
const value = parseInt((e.currentTarget as HTMLInputElement).value) || 0;
if (value >= 10) {
const snappedValue = Math.round(value / 10) * 10;
handleUpdate("size.height", snappedValue);
setLocalHeight(String(snappedValue));
}
e.currentTarget.blur();
(e.currentTarget as HTMLInputElement).blur();
}
}}
step={1}
placeholder="10"
className="h-7 w-full text-xs"
suffix="px"
/>
</div>
<div className="flex-1">
<Label className="text-muted-foreground text-[10px]">Z-Index</Label>
<Input
type="number"
step="1"
</CPRow>
<CPRow label="Z-Index">
<CPNumber
value={currentPosition.z || 1}
onChange={(e) => handleUpdate("position.z", parseInt(e.target.value) || 1)}
className="h-7 w-full text-xs"
onChange={(v) => handleUpdate("position.z", v ?? 1)}
/>
</div>
</div>
</CPRow>
</CPSection>
</div>
{/* Title (group/area) */}
{/* CONTENT → 내용 (group/area) */}
{(selectedComponent.type === "group" || selectedComponent.type === "area") && (
<div className="border-border/50 mb-3 border-b pb-3">
<h4 className="text-muted-foreground py-2 text-[10px] font-semibold tracking-wider uppercase">CONTENT</h4>
<div className="flex items-center justify-between py-1.5">
<span className="text-muted-foreground text-xs"></span>
<div className="w-[160px]">
<Input
<div style={{ padding: "0 12px" }}>
<CPSection title="내용">
<CPRow label="제목">
<CPText
value={(group as any).title || (area as any).title || ""}
onChange={(e) => handleUpdate("title", e.target.value)}
onChange={(v) => handleUpdate("title", v)}
placeholder="제목"
className="h-7 text-xs"
/>
</div>
</div>
{selectedComponent.type === "area" && (
<div className="flex items-center justify-between py-1.5">
<span className="text-muted-foreground text-xs"></span>
<div className="w-[160px]">
<Input
</CPRow>
{selectedComponent.type === "area" && (
<CPRow label="설명">
<CPText
value={(area as any).description || ""}
onChange={(e) => handleUpdate("description", e.target.value)}
onChange={(v) => handleUpdate("description", v)}
placeholder="설명"
className="h-7 text-xs"
/>
</div>
</div>
)}
</CPRow>
)}
</CPSection>
</div>
)}
{/* OPTIONS 섹션 */}
<div className="border-border/50 mb-3 border-b pb-3">
<h4 className="text-muted-foreground py-2 text-[10px] font-semibold tracking-wider uppercase">OPTIONS</h4>
{(isInputField || widget.required !== undefined) &&
(() => {
const colName = widget.columnName || (selectedComponent as any)?.columnName;
const colMeta = colName
? (currentTable as any)?.columns?.find(
(c: any) => (c.columnName || c.column_name || "").toLowerCase() === colName.toLowerCase(),
)
: null;
const isNotNull =
colMeta &&
((colMeta as any).isNullable === "NO" ||
(colMeta as any).isNullable === "N" ||
(colMeta as any).is_nullable === "NO" ||
(colMeta as any).is_nullable === "N");
return (
<div className="flex items-center justify-between py-1.5">
<span className="text-muted-foreground text-xs">
{isNotNull && <span className="text-muted-foreground/60 ml-1">(NOT NULL)</span>}
</span>
<Checkbox
checked={
isNotNull || widget.required === true || selectedComponent.componentConfig?.required === true
{/* OPTIONS → 옵션 (CPRow + CPSwitch) */}
<div style={{ padding: "0 12px" }}>
<CPSection title="옵션">
{(isInputField || widget.required !== undefined) &&
(() => {
const colName = widget.columnName || (selectedComponent as any)?.columnName;
const colMeta = colName
? (currentTable as any)?.columns?.find(
(c: any) => (c.columnName || c.column_name || "").toLowerCase() === colName.toLowerCase(),
)
: null;
const isNotNull =
colMeta &&
((colMeta as any).isNullable === "NO" ||
(colMeta as any).isNullable === "N" ||
(colMeta as any).is_nullable === "NO" ||
(colMeta as any).is_nullable === "N");
return (
<CPRow
label={
<>
{isNotNull && (
<span style={{ marginLeft: 4, fontSize: 9, color: "var(--v5-text-muted)" }}>
(NOT NULL)
</span>
)}
</>
}
onCheckedChange={(checked) => {
if (isNotNull) return;
handleUpdate("required", checked);
handleUpdate("componentConfig.required", checked);
}}
disabled={!!isNotNull}
className="h-4 w-4"
/>
</div>
);
})()}
{(isInputField || widget.readonly !== undefined) && (
<div className="flex items-center justify-between py-1.5">
<span className="text-muted-foreground text-xs"></span>
<Checkbox
checked={widget.readonly === true || selectedComponent.componentConfig?.readonly === true}
onCheckedChange={(checked) => {
handleUpdate("readonly", checked);
handleUpdate("componentConfig.readonly", checked);
>
<CPSwitch
value={
isNotNull || widget.required === true || selectedComponent.componentConfig?.required === true
}
onChange={(checked) => {
if (isNotNull) return;
handleUpdate("required", checked);
handleUpdate("componentConfig.required", checked);
}}
/>
</CPRow>
);
})()}
{(isInputField || widget.readonly !== undefined) && (
<CPRow label="읽기전용">
<CPSwitch
value={widget.readonly === true || selectedComponent.componentConfig?.readonly === true}
onChange={(checked) => {
handleUpdate("readonly", checked);
handleUpdate("componentConfig.readonly", checked);
}}
/>
</CPRow>
)}
<CPRow label="숨김">
<CPSwitch
value={(selectedComponent as any).hidden === true || selectedComponent.componentConfig?.hidden === true}
onChange={(checked) => {
handleUpdate("hidden", checked);
handleUpdate("componentConfig.hidden", checked);
}}
className="h-4 w-4"
/>
</div>
)}
<div className="flex items-center justify-between py-1.5">
<span className="text-muted-foreground text-xs"></span>
<Checkbox
checked={(selectedComponent as any).hidden === true || selectedComponent.componentConfig?.hidden === true}
onCheckedChange={(checked) => {
handleUpdate("hidden", checked);
handleUpdate("componentConfig.hidden", checked);
}}
className="h-4 w-4"
/>
</div>
</CPRow>
</CPSection>
</div>
{/* LABEL 섹션 - 입력 필드에서만 표시 */}
{/* LABEL → 라벨 (CPGroup) */}
{isInputField && (
<Collapsible>
<CollapsibleTrigger className="flex w-full items-center justify-between py-0.5 text-left">
<span className="text-muted-foreground text-[10px] font-semibold tracking-wider uppercase">LABEL</span>
<ChevronDown className="text-muted-foreground/50 h-3 w-3 shrink-0" />
</CollapsibleTrigger>
<CollapsibleContent className="mt-1.5 space-y-1">
{/* 라벨 텍스트 */}
<div className="flex items-center justify-between py-1.5">
<span className="text-muted-foreground text-xs"></span>
<div className="w-[160px]">
<Input
value={
selectedComponent.style?.labelText !== undefined
? selectedComponent.style.labelText
: selectedComponent.label || selectedComponent.componentConfig?.label || ""
<div style={{ padding: "0 12px" }}>
<CPGroup title="라벨" defaultOpen={false}>
<CPRow label="텍스트">
<CPText
value={
selectedComponent.style?.labelText !== undefined
? selectedComponent.style.labelText
: selectedComponent.label || selectedComponent.componentConfig?.label || ""
}
onChange={(v) => {
handleUpdate("style.labelText", v);
handleUpdate("label", v);
}}
placeholder="라벨"
/>
</CPRow>
<CPRow label="위치">
<Select
value={selectedComponent.style?.labelPosition || "top"}
onValueChange={(value) => handleUpdate("style.labelPosition", value)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="top"></SelectItem>
<SelectItem value="bottom"></SelectItem>
<SelectItem value="left"></SelectItem>
<SelectItem value="right"></SelectItem>
</SelectContent>
</Select>
</CPRow>
<CPRow label="간격">
<CPText
mono
value={
selectedComponent.style?.labelPosition === "left" ||
selectedComponent.style?.labelPosition === "right"
? selectedComponent.style?.labelGap || "8px"
: selectedComponent.style?.labelMarginBottom || "4px"
}
onChange={(v) => {
const pos = selectedComponent.style?.labelPosition;
if (pos === "left" || pos === "right") {
handleUpdate("style.labelGap", v);
} else {
handleUpdate("style.labelMarginBottom", v);
}
onChange={(e) => {
handleUpdate("style.labelText", e.target.value);
handleUpdate("label", e.target.value);
}}
placeholder="라벨"
className="h-7 text-xs"
/>
</div>
</div>
{/* 위치 + 간격 */}
<div className="flex gap-2">
<div className="flex-1">
<Label className="text-muted-foreground text-[10px]"></Label>
<Select
value={selectedComponent.style?.labelPosition || "top"}
onValueChange={(value) => handleUpdate("style.labelPosition", value)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="top"></SelectItem>
<SelectItem value="bottom"></SelectItem>
<SelectItem value="left"></SelectItem>
<SelectItem value="right"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1">
<Label className="text-muted-foreground text-[10px]"></Label>
<Input
value={
selectedComponent.style?.labelPosition === "left" ||
selectedComponent.style?.labelPosition === "right"
? selectedComponent.style?.labelGap || "8px"
: selectedComponent.style?.labelMarginBottom || "4px"
}
onChange={(e) => {
const pos = selectedComponent.style?.labelPosition;
if (pos === "left" || pos === "right") {
handleUpdate("style.labelGap", e.target.value);
} else {
handleUpdate("style.labelMarginBottom", e.target.value);
}
}}
className="h-7 text-xs"
/>
</div>
</div>
{/* 크기 + 색상 */}
<div className="flex gap-2">
<div className="flex-1">
<Label className="text-muted-foreground text-[10px]"></Label>
<Input
value={selectedComponent.style?.labelFontSize || "12px"}
onChange={(e) => handleUpdate("style.labelFontSize", e.target.value)}
className="h-7 text-xs"
/>
</div>
<div className="flex-1">
<Label className="text-muted-foreground text-[10px]"></Label>
<ColorPickerWithTransparent
value={selectedComponent.style?.labelColor}
onChange={(value) => handleUpdate("style.labelColor", value)}
defaultColor="#212121"
placeholder="#212121"
/>
</div>
</div>
{/* 굵기 */}
<div className="flex items-center justify-between py-1.5">
<span className="text-muted-foreground text-xs"></span>
<div className="w-[160px]">
<Select
value={selectedComponent.style?.labelFontWeight || "500"}
onValueChange={(value) => handleUpdate("style.labelFontWeight", value)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="400"></SelectItem>
<SelectItem value="500"></SelectItem>
<SelectItem value="600"></SelectItem>
<SelectItem value="700"> </SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 표시 */}
<div className="flex items-center justify-between py-1.5">
<span className="text-muted-foreground text-xs"></span>
<Checkbox
checked={selectedComponent.style?.labelDisplay === true || (selectedComponent as any).labelDisplay === true}
onCheckedChange={(checked) => {
const boolValue = checked === true;
handleUpdate("style.labelDisplay", boolValue);
handleUpdate("labelDisplay", boolValue);
if (boolValue && !selectedComponent.style?.labelText) {
}}
/>
</CPRow>
<CPRow label="크기">
<CPText
mono
value={selectedComponent.style?.labelFontSize || "12px"}
onChange={(v) => handleUpdate("style.labelFontSize", v)}
/>
</CPRow>
<CPRow label="색상">
<ColorPickerWithTransparent
value={selectedComponent.style?.labelColor}
onChange={(value) => handleUpdate("style.labelColor", value)}
defaultColor="#212121"
placeholder="#212121"
/>
</CPRow>
<CPRow label="굵기">
<Select
value={selectedComponent.style?.labelFontWeight || "500"}
onValueChange={(value) => handleUpdate("style.labelFontWeight", value)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="400"></SelectItem>
<SelectItem value="500"></SelectItem>
<SelectItem value="600"></SelectItem>
<SelectItem value="700"> </SelectItem>
</SelectContent>
</Select>
</CPRow>
<CPRow label="표시">
<CPSwitch
value={selectedComponent.style?.labelDisplay === true || (selectedComponent as any).labelDisplay === true}
onChange={(checked) => {
handleUpdate("style.labelDisplay", checked);
handleUpdate("labelDisplay", checked);
if (checked && !selectedComponent.style?.labelText) {
const labelValue = selectedComponent.label || selectedComponent.componentConfig?.label || "";
if (labelValue) {
handleUpdate("style.labelText", labelValue);
}
}
}}
className="h-4 w-4"
/>
</div>
</CollapsibleContent>
</Collapsible>
</CPRow>
</CPGroup>
</div>
)}
</div>
);
@@ -1213,7 +1177,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
};
return (
<div className="flex h-full flex-col bg-white">
<div className="flex h-full flex-col">
{/* 헤더 - 간소화 */}
<div className="border-border border-b px-3 py-2">
{selectedComponent.type === "widget" && (
@@ -1230,19 +1194,13 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
{renderBasicTab()}
{/* 상세 설정 */}
<Separator className="my-2" />
{renderDetailTab()}
{/* 조건부 표시 설정 */}
{selectedComponent && (
<>
<Separator className="my-2" />
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Zap className="text-primary h-3 w-3" />
<h4 className="text-xs font-semibold"> </h4>
</div>
<div className="border-border rounded-md border p-2">
<div style={{ padding: "0 12px" }}>
<CPGroup title="조건부 표시" defaultOpen={false}>
<ConditionalConfigPanel
config={
(selectedComponent as any).conditional || {
@@ -1315,7 +1273,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
}
currentComponentId={selectedComponent.id}
/>
</div>
</CPGroup>
</div>
</>
)}
@@ -1323,22 +1281,19 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
{/* 스타일 설정 */}
{selectedComponent && (
<>
<Separator className="my-2" />
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Palette className="text-primary h-3 w-3" />
<h4 className="text-xs font-semibold"> </h4>
</div>
<StyleEditor
style={selectedComponent.style || {}}
onStyleChange={(style) => {
if (onStyleChange) {
onStyleChange(style);
} else {
handleUpdate("style", style);
}
}}
/>
<div style={{ padding: "0 12px" }}>
<CPGroup title="컴포넌트 스타일" defaultOpen={false}>
<StyleEditor
style={selectedComponent.style || {}}
onStyleChange={(style) => {
if (onStyleChange) {
onStyleChange(style);
} else {
handleUpdate("style", style);
}
}}
/>
</CPGroup>
</div>
</>
)}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,533 @@
"use client";
/**
* ConfigPanel 헤더/탭/그룹/바인드칩/브레드크럼
* - 시안 panel-input-new 의 CPHeader, CPTabs, CPGroup, CPBindChip + V4 헤더 brumb 포팅
*/
import React from "react";
import { CPIconBtn } from "./CPPrimitives";
import { CP_ICONS } from "./icons";
const CP_FONT = "var(--v5-font-sans)";
const CP_MONO = "var(--v5-font-mono)";
// ── 헤더 ───────────────────────────────────────────────
export function CPHeader({
icon,
title,
subtitle,
badge,
onReset,
}: {
icon?: React.ReactNode;
title: React.ReactNode;
subtitle?: React.ReactNode;
badge?: React.ReactNode;
onReset?: () => void;
}) {
return (
<div
style={{
padding: "10px 12px",
borderBottom: "1px solid var(--cp-border-subtle)",
background: "var(--cp-surface)",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
{icon && (
<div
style={{
width: 26,
height: 26,
borderRadius: 7,
background: "linear-gradient(135deg, rgba(var(--v5-primary-rgb),0.15), rgba(var(--v5-cyan-rgb),0.15))",
border: "1px solid var(--cp-border)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--v5-primary)",
flexShrink: 0,
}}
>
{icon}
</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6, lineHeight: 1.1 }}>
<span
style={{
fontSize: 13,
fontWeight: 700,
color: "var(--cp-text)",
letterSpacing: "-0.01em",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{title}
</span>
{badge && (
<span
style={{
fontSize: 9.5,
fontWeight: 700,
letterSpacing: "0.08em",
textTransform: "uppercase",
color: "var(--v5-primary)",
background: "rgba(var(--v5-primary-rgb),0.08)",
border: "1px solid rgba(var(--v5-primary-rgb),0.18)",
padding: "1px 5px",
borderRadius: 3,
flexShrink: 0,
}}
>
{badge}
</span>
)}
</div>
{subtitle && (
<div
style={{
fontSize: 10.5,
color: "var(--cp-text-muted)",
marginTop: 2,
fontFamily: CP_MONO,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{subtitle}
</div>
)}
</div>
{onReset && (
<CPIconBtn title="기본값으로 초기화" onClick={onReset}>
{CP_ICONS.reset}
</CPIconBtn>
)}
</div>
);
}
// ── 탭 ─────────────────────────────────────────────────
export type CPTabItem = { id: string; label: React.ReactNode; count?: number; dot?: boolean };
export function CPTabs({
tabs,
value,
onChange,
variant = "underline",
}: {
tabs: CPTabItem[];
value: string;
onChange: (next: string) => void;
variant?: "underline" | "pill";
}) {
if (variant === "pill") {
return (
<div
style={{
padding: "8px 10px",
display: "flex",
gap: 4,
borderBottom: "1px solid var(--cp-border-subtle)",
background: "var(--cp-bg-subtle)",
}}
>
{tabs.map((t) => {
const active = t.id === value;
return (
<button
key={t.id}
type="button"
onClick={() => onChange(t.id)}
style={{
border: "none",
cursor: "pointer",
padding: "5px 11px",
borderRadius: 6,
fontSize: 11.5,
fontWeight: active ? 700 : 500,
color: active ? "#fff" : "var(--cp-text-sec)",
background: active ? "var(--v5-primary)" : "transparent",
boxShadow: active ? "0 0 14px rgba(var(--v5-primary-rgb),0.35)" : "none",
fontFamily: CP_FONT,
}}
>
{t.label}
{t.count != null && (
<span
style={{
marginLeft: 5,
fontSize: 10,
color: active ? "rgba(255,255,255,.7)" : "var(--cp-text-muted)",
}}
>
{t.count}
</span>
)}
</button>
);
})}
</div>
);
}
return (
<div
style={{
display: "flex",
gap: 0,
padding: "0 12px",
borderBottom: "1px solid var(--cp-border-subtle)",
background: "var(--cp-surface)",
}}
>
{tabs.map((t) => {
const active = t.id === value;
return (
<button
key={t.id}
type="button"
onClick={() => onChange(t.id)}
style={{
border: "none",
background: "transparent",
cursor: "pointer",
padding: "9px 10px 8px",
fontSize: 11.5,
fontWeight: active ? 700 : 500,
color: active ? "var(--cp-text)" : "var(--cp-text-sec)",
borderBottom: active ? "2px solid var(--v5-primary)" : "2px solid transparent",
marginBottom: -1,
fontFamily: CP_FONT,
display: "flex",
alignItems: "center",
gap: 5,
}}
>
{t.label}
{t.dot && (
<span
style={{
width: 5,
height: 5,
borderRadius: 999,
background: "var(--v5-primary)",
}}
/>
)}
</button>
);
})}
</div>
);
}
// ── 접이식 그룹 ────────────────────────────────────────
export function CPGroup({
title,
desc,
defaultOpen = true,
badge,
children,
}: {
title: React.ReactNode;
desc?: React.ReactNode;
defaultOpen?: boolean;
badge?: React.ReactNode;
children?: React.ReactNode;
}) {
const [open, setOpen] = React.useState(defaultOpen);
return (
<div style={{ borderBottom: "1px solid var(--cp-border-subtle)" }}>
<button
type="button"
onClick={() => setOpen(!open)}
style={{
border: "none",
background: "transparent",
cursor: "pointer",
width: "100%",
padding: "10px 0",
display: "flex",
alignItems: "center",
gap: 6,
fontFamily: CP_FONT,
}}
>
<span
style={{
transform: open ? "rotate(0deg)" : "rotate(-90deg)",
transition: "transform .15s var(--v5-ease-move)",
color: "var(--cp-text-sec)",
display: "inline-flex",
}}
>
{CP_ICONS.chevron}
</span>
<span style={{ fontSize: 11.5, fontWeight: 700, color: "var(--cp-text)", letterSpacing: "-0.01em" }}>
{title}
</span>
{badge && <span style={{ fontSize: 10, color: "var(--cp-text-muted)", fontFamily: CP_MONO }}>{badge}</span>}
{desc && <span style={{ fontSize: 10.5, color: "var(--cp-text-muted)", marginLeft: "auto" }}>{desc}</span>}
</button>
{open && <div style={{ padding: "0 0 12px" }}>{children}</div>}
</div>
);
}
// ── 바인드 칩 (data-binding 시각화) ────────────────────
export function CPBindChip({
table,
column,
onClear,
}: {
table: React.ReactNode;
column: React.ReactNode;
onClear?: () => void;
}) {
return (
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: "2px 4px 2px 7px",
background: "rgba(var(--v5-cyan-rgb),0.08)",
border: "1px solid rgba(var(--v5-cyan-rgb),0.22)",
borderRadius: 5,
fontSize: 11,
fontFamily: CP_MONO,
color: "var(--cp-text)",
maxWidth: "100%",
}}
>
<span style={{ display: "inline-flex", color: "rgb(var(--v5-cyan-rgb))" }}>{CP_ICONS.link}</span>
<span style={{ fontWeight: 600 }}>{table}</span>
<span style={{ opacity: 0.4 }}>.</span>
<span style={{ color: "var(--cp-text-sec)" }}>{column}</span>
{onClear && (
<button
type="button"
onClick={onClear}
style={{
marginLeft: 2,
border: "none",
background: "transparent",
color: "var(--cp-text-muted)",
cursor: "pointer",
padding: 2,
borderRadius: 3,
display: "inline-flex",
}}
>
{CP_ICONS.close}
</button>
)}
</div>
);
}
// ── kind ▸ type 헤더 brumb (좌/우 분할 · 양쪽 클릭) ──────
export type CPCrumbType = { id: string; name: string; desc?: string; icon?: React.ReactNode; col?: string };
export type CPCrumbKind = { id: string; name: string; icon?: React.ReactNode };
export function CPCrumb({
kinds,
currentKind,
onChangeKind,
types,
value,
onChange,
}: {
kinds: CPCrumbKind[];
currentKind: string;
onChangeKind: (next: string) => void;
types: CPCrumbType[];
value: string;
onChange: (next: string) => void;
}) {
const [open, setOpen] = React.useState(false);
const ref = React.useRef<HTMLDivElement>(null);
const kind = kinds.find((k) => k.id === currentKind) || kinds[0];
const current = types.find((t) => t.id === value) || types[0];
const hasMultipleKinds = kinds.length > 1;
React.useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const onEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onEsc);
return () => {
document.removeEventListener("mousedown", onDoc);
document.removeEventListener("keydown", onEsc);
};
}, [open]);
const toggle = () => setOpen((v) => !v);
return (
<div className="cp-bar" ref={ref}>
<div className="cp-crumb-split">
{/* 좌측 : kind — 클릭 시 통합 팝오버 토글 */}
<div className="cp-crumb-side cp-crumb-kind-slot">
<button
type="button"
className="cp-crumb-side-btn cp-crumb-kind-btn"
data-open={open}
data-clickable={hasMultipleKinds}
onClick={() => hasMultipleKinds && toggle()}
>
{kind?.icon && <span className="cp-crumb-kind-icon">{kind.icon}</span>}
<span className="cp-crumb-kind-name">{kind?.name}</span>
{hasMultipleKinds && <span className="cp-crumb-kind-chev">{CP_ICONS.chevron}</span>}
</button>
</div>
<div className="cp-crumb-divider" aria-hidden="true" />
{/* 우측 : type — 클릭 시 통합 팝오버 토글 */}
<div className="cp-crumb-side cp-crumb-type-slot">
<button
type="button"
className="cp-crumb-side-btn cp-crumb-type-btn"
data-open={open}
onClick={toggle}
>
<span className="cp-crumb-type-icon">{current?.icon}</span>
<div className="cp-crumb-type-text">
<div className="cp-crumb-type-name">{current?.name}</div>
{current?.desc && <div className="cp-crumb-type-desc">{current.desc}</div>}
</div>
<span className="cp-crumb-type-chev">{CP_ICONS.chevron}</span>
</button>
</div>
</div>
{/* 통합 팝오버 — kinds(좌) + types(우). kind 선택은 popover 안 닫고 type 갱신만 */}
{open && (
<div className="cp-pop cp-pop-combined">
{hasMultipleKinds && (
<div className="cp-pop-col cp-pop-kinds-col">
{kinds.map((k, i) => (
<button
key={k.id}
type="button"
className="cp-pop-kind-item"
data-active={currentKind === k.id}
onClick={() => onChangeKind(k.id)}
style={{ animationDelay: `${i * 18}ms` }}
>
{k.icon && <span className="cp-pop-kind-item-icon">{k.icon}</span>}
<span className="cp-pop-kind-item-name">{k.name}</span>
</button>
))}
</div>
)}
<div className="cp-pop-col cp-pop-types-col">
{types.map((t, i) => (
<button
key={t.id}
type="button"
className="cp-pop-type"
data-active={value === t.id}
onClick={() => {
onChange(t.id);
setOpen(false);
}}
style={{ animationDelay: `${i * 18}ms` }}
>
<span className="cp-pop-type-icon">{t.icon}</span>
<div className="cp-pop-type-text">
<div className="cp-pop-type-name">{t.name}</div>
{t.desc && <div className="cp-pop-type-desc">{t.desc}</div>}
</div>
{t.col && <span className="cp-pop-type-col">{t.col}</span>}
</button>
))}
</div>
</div>
)}
</div>
);
}
// ── 형식 트리거 (본문 안에서 [형식 · ▾]) ───────────────
export type CPFormatItem = { id: string; name: string; desc?: string; icon?: React.ReactNode };
export function CPFormatTrigger({
formats,
value,
onChange,
}: {
formats: CPFormatItem[];
value: string;
onChange: (next: string) => void;
}) {
const [open, setOpen] = React.useState(false);
const ref = React.useRef<HTMLDivElement>(null);
const current = formats.find((f) => f.id === value) || formats[0];
const hasMultiple = formats.length > 1;
React.useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
return (
<div className="cp-fmt-head">
<span className="cp-fmt-head-cap"></span>
<span className="cp-fmt-head-dot">·</span>
<div style={{ position: "relative" }} ref={ref}>
<button
type="button"
className="cp-fmt-trigger"
data-open={open}
onClick={() => hasMultiple && setOpen(!open)}
style={{ cursor: hasMultiple ? "pointer" : "default" }}
>
<span className="cp-fmt-trigger-icon">{current?.icon}</span>
<span className="cp-fmt-trigger-name">{current?.name}</span>
{hasMultiple && <span className="cp-fmt-trigger-chev"></span>}
</button>
{open && (
<div className="cp-fmt-pop">
{formats.map((f) => (
<button
key={f.id}
type="button"
className="cp-fmt-pop-item"
data-active={f.id === value}
onClick={() => {
onChange(f.id);
setOpen(false);
}}
>
<span className="cp-fmt-pop-item-icon">{f.icon}</span>
<div className="cp-fmt-pop-item-text">
<div className="cp-fmt-pop-item-name">{f.name}</div>
{f.desc && <div className="cp-fmt-pop-item-desc">{f.desc}</div>}
</div>
</button>
))}
</div>
)}
</div>
{current?.desc && <span className="cp-fmt-head-desc">{current.desc}</span>}
</div>
);
}
@@ -0,0 +1,851 @@
"use client";
/**
* ConfigPanel 프리미티브 (시안 panel-input-new 의 CP* 포팅)
* - inline style 위주 (시안의 토큰 활용 패턴 보존)
* - v5 토큰만 사용. 라이트/다크 모두 자동 대응
*/
import React from "react";
import { CP_ICONS } from "./icons";
const CP_FONT = "var(--v5-font-sans)";
const CP_MONO = "var(--v5-font-mono)";
// ── Section ──────────────────────────────────────────────
export function CPSection({
title,
desc,
tone = "default",
children,
style,
}: {
title: React.ReactNode;
desc?: React.ReactNode;
tone?: "default" | "solid";
children?: React.ReactNode;
style?: React.CSSProperties;
}) {
return (
<div
style={{
marginBottom: 8,
marginTop: 2,
...style,
}}
>
{tone === "solid" ? (
<div
style={{
display: "flex",
alignItems: "baseline",
flexWrap: "wrap",
gap: "4px 8px",
padding: "6px 10px",
background: "rgba(var(--v5-primary-rgb), 0.05)",
border: "1px solid var(--cp-border)",
borderRadius: 8,
marginBottom: 10,
}}
>
<span style={{ fontSize: 11, fontWeight: 700, letterSpacing: "0.04em", color: "var(--cp-text)", whiteSpace: "nowrap" }}>
{title}
</span>
{desc && <span style={{ fontSize: 11, color: "var(--cp-text-muted)", minWidth: 0 }}>{desc}</span>}
</div>
) : (
<div
style={{
display: "flex",
alignItems: "baseline",
flexWrap: "wrap",
gap: "2px 8px",
padding: "0 0 4px",
marginBottom: 6,
backgroundImage:
"linear-gradient(to right, rgba(var(--v5-primary-rgb), 0.55), transparent 75%)",
backgroundSize: "100% 1px",
backgroundPosition: "bottom left",
backgroundRepeat: "no-repeat",
}}
>
<span
style={{
fontSize: 12,
fontWeight: 700,
letterSpacing: "-0.01em",
color: "var(--cp-text)",
whiteSpace: "nowrap",
}}
>
{title}
</span>
{desc && (
<span
style={{
fontSize: 10.5,
color: "var(--cp-text-muted)",
letterSpacing: 0,
minWidth: 0,
lineHeight: 1.4,
}}
>
· {desc}
</span>
)}
</div>
)}
<div>{children}</div>
</div>
);
}
// ── Row (label 좌, control 우) ───────────────────────────
export function CPRow({
label,
help,
required,
advanced,
children,
align = "center",
}: {
label: React.ReactNode;
help?: React.ReactNode;
required?: boolean;
advanced?: boolean;
children?: React.ReactNode;
align?: "center" | "top";
}) {
return (
<div
style={{
display: "grid",
gridTemplateColumns: "minmax(60px, 80px) minmax(0, 1fr)",
gap: 8,
alignItems: align === "top" ? "flex-start" : "center",
padding: "5px 0",
minHeight: 28,
}}
>
<div
style={{
fontSize: 11.5,
fontWeight: 600,
color: "var(--cp-text-sec)",
lineHeight: 1.3,
paddingTop: align === "top" ? 6 : 0,
display: "flex",
alignItems: "center",
gap: 3,
flexWrap: "wrap",
wordBreak: "keep-all",
}}
>
<span>{label}</span>
{required && <span style={{ color: "var(--v5-red)", fontWeight: 700 }}>*</span>}
{advanced && (
<span
style={{
fontSize: 9,
fontWeight: 700,
letterSpacing: "0.08em",
color: "var(--cp-text-muted)",
textTransform: "uppercase",
padding: "1px 4px",
border: "1px solid var(--cp-border)",
borderRadius: 3,
}}
>
PRO
</span>
)}
</div>
<div style={{ minWidth: 0 }}>
{children}
{help && (
<div style={{ fontSize: 10.5, color: "var(--cp-text-muted)", marginTop: 4, lineHeight: 1.4 }}>{help}</div>
)}
</div>
</div>
);
}
// label 위 / control 아래
export function CPStacked({
label,
help,
required,
advanced,
children,
}: {
label: React.ReactNode;
help?: React.ReactNode;
required?: boolean;
advanced?: boolean;
children?: React.ReactNode;
}) {
return (
<div style={{ padding: "4px 0", marginBottom: 6 }}>
<div
style={{
fontSize: 11,
fontWeight: 600,
color: "var(--cp-text-sec)",
marginBottom: 5,
display: "flex",
alignItems: "center",
gap: 4,
}}
>
<span>{label}</span>
{required && <span style={{ color: "var(--v5-red)" }}>*</span>}
{advanced && (
<span
style={{
fontSize: 9,
fontWeight: 700,
letterSpacing: "0.08em",
color: "var(--cp-text-muted)",
textTransform: "uppercase",
padding: "1px 4px",
border: "1px solid var(--cp-border)",
borderRadius: 3,
marginLeft: "auto",
}}
>
PRO
</span>
)}
</div>
{children}
{help && <div style={{ fontSize: 10.5, color: "var(--cp-text-muted)", marginTop: 4, lineHeight: 1.4 }}>{help}</div>}
</div>
);
}
// ── Inputs ──────────────────────────────────────────────
const inputBaseStyle: React.CSSProperties = {
width: "100%",
boxSizing: "border-box",
height: 28,
padding: "0 8px",
fontSize: 12,
fontFamily: CP_FONT,
background: "var(--cp-surface)",
border: "1px solid var(--cp-border)",
borderRadius: 6,
color: "var(--cp-text)",
outline: "none",
transition: "border-color .15s, box-shadow .15s",
};
type CPTextProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "onChange" | "prefix"> & {
value?: string | number;
onChange?: (value: string) => void;
placeholder?: string;
mono?: boolean;
suffix?: React.ReactNode;
prefix?: React.ReactNode;
};
export function CPText({ value, onChange, placeholder, mono, suffix, prefix, ...rest }: CPTextProps) {
const [focus, setFocus] = React.useState(false);
return (
<div
style={{
display: "flex",
alignItems: "center",
border: focus ? "1px solid var(--v5-primary)" : "1px solid var(--cp-border)",
borderRadius: 6,
boxShadow: focus ? "0 0 0 3px rgba(var(--v5-primary-rgb),0.12)" : "none",
background: "var(--cp-surface)",
height: 28,
transition: "border-color .15s, box-shadow .15s",
overflow: "hidden",
}}
>
{prefix && (
<div
style={{
padding: "0 8px",
fontSize: 11,
color: "var(--cp-text-muted)",
borderRight: "1px solid var(--cp-border-subtle)",
height: "100%",
display: "flex",
alignItems: "center",
}}
>
{prefix}
</div>
)}
<input
{...rest}
value={value ?? ""}
onChange={(e) => onChange && onChange(e.target.value)}
placeholder={placeholder}
onFocus={(e) => {
setFocus(true);
rest.onFocus?.(e);
}}
onBlur={(e) => {
setFocus(false);
rest.onBlur?.(e);
}}
style={{
flex: 1,
minWidth: 0,
border: "none",
outline: "none",
background: "transparent",
height: "100%",
padding: "0 8px",
fontSize: 12,
fontFamily: mono ? CP_MONO : CP_FONT,
color: "var(--cp-text)",
}}
/>
{suffix && (
<div
style={{
padding: "0 8px",
fontSize: 11,
color: "var(--cp-text-muted)",
borderLeft: "1px solid var(--cp-border-subtle)",
height: "100%",
display: "flex",
alignItems: "center",
}}
>
{suffix}
</div>
)}
</div>
);
}
type CPSelectOption = { value: string; label: React.ReactNode; disabled?: boolean };
type CPSelectProps = Omit<React.SelectHTMLAttributes<HTMLSelectElement>, "value" | "onChange"> & {
value?: string;
onChange?: (value: string) => void;
children?: React.ReactNode;
options?: CPSelectOption[];
placeholder?: string;
};
// children 으로 들어온 <option> 엘리먼트들을 옵션 배열로 평탄화
function parseOptionChildren(children: React.ReactNode): CPSelectOption[] {
const out: CPSelectOption[] = [];
React.Children.forEach(children, (child) => {
if (!React.isValidElement(child)) return;
const el = child as React.ReactElement<{
value?: string | number;
children?: React.ReactNode;
disabled?: boolean;
}>;
if (el.type === "option") {
out.push({
value: String(el.props.value ?? ""),
label: el.props.children ?? String(el.props.value ?? ""),
disabled: el.props.disabled,
});
} else if (el.type === "optgroup") {
// optgroup 안의 option 들을 재귀로 평탄화 (label 은 무시)
out.push(...parseOptionChildren(el.props.children));
}
});
return out;
}
export function CPSelect({
value,
onChange,
children,
options,
placeholder,
disabled,
...rest
}: CPSelectProps) {
const opts: CPSelectOption[] = options ?? parseOptionChildren(children);
const [open, setOpen] = React.useState(false);
const wrapRef = React.useRef<HTMLDivElement>(null);
const popRef = React.useRef<HTMLDivElement>(null);
const listRef = React.useRef<HTMLDivElement>(null);
// 첫 옵션이 빈값이면 placeholder 후보로 사용 (호환: <option value="">선택...</option>)
const firstEmpty = opts.find((o) => o.value === "");
const placeholderText =
placeholder ??
(typeof firstEmpty?.label === "string" ? firstEmpty.label : "선택");
const realOpts = opts.filter((o) => o.value !== "");
const current = realOpts.find((o) => o.value === value);
const showPlaceholder = !current;
React.useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
const t = e.target as Node;
if (
wrapRef.current && !wrapRef.current.contains(t) &&
popRef.current && !popRef.current.contains(t)
) {
setOpen(false);
}
};
const onEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onEsc);
return () => {
document.removeEventListener("mousedown", onDoc);
document.removeEventListener("keydown", onEsc);
};
}, [open]);
// 열릴 때 active 항목으로 스크롤
React.useEffect(() => {
if (!open || !listRef.current) return;
const active = listRef.current.querySelector('[data-active="true"]') as HTMLElement | null;
if (active) active.scrollIntoView({ block: "nearest" });
}, [open]);
return (
<div ref={wrapRef} style={{ position: "relative", width: "100%" }}>
<button
type="button"
disabled={disabled}
onClick={() => !disabled && setOpen((v) => !v)}
data-state={open ? "open" : "closed"}
{...(rest as any)}
style={{
...inputBaseStyle,
display: "flex",
alignItems: "center",
textAlign: "left",
paddingRight: 24,
cursor: disabled ? "not-allowed" : "pointer",
color: showPlaceholder ? "var(--cp-text-muted)" : "var(--cp-text)",
backgroundImage:
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10' fill='none' stroke='%239998ad' stroke-width='1.6' stroke-linecap='round'><path d='M2 3.5l3 3 3-3'/></svg>\")",
backgroundRepeat: "no-repeat",
backgroundPosition: "right 8px center",
opacity: disabled ? 0.5 : 1,
fontFamily: CP_FONT,
...(open
? { borderColor: "var(--v5-primary)", boxShadow: "0 0 0 3px rgba(var(--v5-primary-rgb),0.12)" }
: null),
}}
>
<span
style={{
flex: 1,
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{showPlaceholder ? placeholderText : current?.label}
</span>
</button>
{open && (
<div
ref={popRef}
style={{
position: "absolute",
top: "calc(100% + 2px)",
left: 0,
right: 0,
zIndex: 60,
background: "var(--cp-surface)",
border: "1px solid var(--cp-border)",
borderRadius: 4,
boxShadow: "0 8px 22px rgba(0,0,0,0.18)",
padding: 4,
animation: "cp-pop 0.14s var(--v5-ease-enter)",
}}
>
<div
ref={listRef}
style={{ maxHeight: 280, overflowY: "auto", display: "flex", flexDirection: "column" }}
>
{/* placeholder (값 = '') 가 children 으로 들어왔으면 첫 항목으로 노출, 클릭 시 onChange("") */}
{firstEmpty && (
<CPSelectItem
option={firstEmpty}
active={!current}
onPick={(v) => {
onChange?.(v);
setOpen(false);
}}
/>
)}
{realOpts.map((o) => (
<CPSelectItem
key={o.value}
option={o}
active={o.value === value}
onPick={(v) => {
onChange?.(v);
setOpen(false);
}}
/>
))}
{realOpts.length === 0 && !firstEmpty && (
<div
style={{
padding: "8px",
fontSize: 11,
color: "var(--cp-text-muted)",
textAlign: "center",
}}
>
</div>
)}
</div>
</div>
)}
</div>
);
}
function CPSelectItem({
option,
active,
onPick,
}: {
option: CPSelectOption;
active: boolean;
onPick: (v: string) => void;
}) {
return (
<button
type="button"
disabled={option.disabled}
data-active={active}
onClick={() => !option.disabled && onPick(option.value)}
style={{
display: "block",
width: "100%",
textAlign: "left",
padding: "5px 8px 5px 6px",
fontSize: 11.5,
fontFamily: CP_FONT,
color: active ? "var(--cp-text)" : "var(--cp-text-sec)",
fontWeight: active ? 700 : 500,
background: active ? "rgba(var(--v5-primary-rgb), 0.10)" : "transparent",
border: "none",
borderLeft: active ? "2px solid rgb(var(--v5-primary-rgb))" : "2px solid transparent",
borderRadius: 0,
cursor: option.disabled ? "not-allowed" : "pointer",
opacity: option.disabled ? 0.5 : 1,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
transition: "background .1s",
}}
onMouseEnter={(e) => {
if (!active && !option.disabled)
(e.currentTarget as HTMLButtonElement).style.background = "var(--cp-surface-hover)";
}}
onMouseLeave={(e) => {
if (!active) (e.currentTarget as HTMLButtonElement).style.background = "transparent";
}}
>
{option.label}
</button>
);
}
type CPTextareaProps = Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "value" | "onChange"> & {
value?: string;
onChange?: (value: string) => void;
rows?: number;
mono?: boolean;
};
export function CPTextarea({ value, onChange, placeholder, rows = 3, mono, ...rest }: CPTextareaProps) {
const [focus, setFocus] = React.useState(false);
return (
<textarea
{...rest}
value={value ?? ""}
onChange={(e) => onChange && onChange(e.target.value)}
placeholder={placeholder}
rows={rows}
onFocus={(e) => {
setFocus(true);
rest.onFocus?.(e);
}}
onBlur={(e) => {
setFocus(false);
rest.onBlur?.(e);
}}
style={{
...inputBaseStyle,
height: "auto",
minHeight: 28 * rows,
padding: 8,
resize: "vertical",
lineHeight: 1.45,
fontFamily: mono ? CP_MONO : CP_FONT,
borderColor: focus ? "var(--v5-primary)" : "var(--cp-border)",
boxShadow: focus ? "0 0 0 3px rgba(var(--v5-primary-rgb),0.12)" : "none",
}}
/>
);
}
export function CPSwitch({
value,
onChange,
labels,
}: {
value?: boolean;
onChange?: (next: boolean) => void;
labels?: [string, string];
}) {
const v = !!value;
return (
<label style={{ display: "inline-flex", alignItems: "center", gap: 8, cursor: "pointer", userSelect: "none" }}>
<div
style={{
width: 28,
height: 16,
borderRadius: 999,
background: v ? "var(--v5-primary)" : "rgba(var(--v5-primary-rgb),0.15)",
position: "relative",
transition: "background .15s",
boxShadow: v ? "0 0 8px rgba(var(--v5-primary-rgb),0.35)" : "none",
}}
>
<div
style={{
width: 12,
height: 12,
borderRadius: 999,
background: "#fff",
position: "absolute",
top: 2,
left: v ? 14 : 2,
transition: "left .18s var(--v5-ease-enter)",
boxShadow: "0 1px 2px rgba(0,0,0,0.15)",
}}
/>
</div>
{labels && <span style={{ fontSize: 11, color: "var(--cp-text-sec)" }}>{v ? labels[0] : labels[1]}</span>}
<input
type="checkbox"
checked={v}
onChange={(e) => onChange && onChange(e.target.checked)}
style={{ position: "absolute", opacity: 0, pointerEvents: "none" }}
/>
</label>
);
}
export function CPNumber({
value,
onChange,
placeholder,
min,
max,
step,
suffix,
}: {
value?: number | "";
onChange?: (next: number | undefined) => void;
placeholder?: string;
min?: number;
max?: number;
step?: number;
suffix?: React.ReactNode;
}) {
return (
<CPText
type="number"
mono
value={value as any}
onChange={(v) => onChange && onChange(v === "" ? undefined : Number(v))}
placeholder={placeholder}
min={min}
max={max}
step={step}
suffix={suffix}
/>
);
}
export function CPColor({ value, onChange }: { value?: string; onChange?: (next: string) => void }) {
return (
<div
style={{
display: "flex",
alignItems: "center",
border: "1px solid var(--cp-border)",
borderRadius: 6,
background: "var(--cp-surface)",
height: 28,
overflow: "hidden",
}}
>
<div
style={{
width: 26,
height: "100%",
background: value || "#ffffff",
borderRight: "1px solid var(--cp-border-subtle)",
position: "relative",
cursor: "pointer",
}}
>
<input
type="color"
value={value || "#6c5ce7"}
onChange={(e) => onChange && onChange(e.target.value)}
style={{ position: "absolute", inset: 0, opacity: 0, cursor: "pointer" }}
/>
</div>
<input
value={value || ""}
onChange={(e) => onChange && onChange(e.target.value)}
placeholder="#6c5ce7"
style={{
flex: 1,
border: "none",
outline: "none",
background: "transparent",
height: "100%",
padding: "0 8px",
fontSize: 11.5,
fontFamily: CP_MONO,
color: "var(--cp-text)",
textTransform: "uppercase",
}}
/>
</div>
);
}
// 작은 enum 용 (size, align, variant 등) — 평면 toolbar 스타일 (외곽/내부 박스 없음)
type CPSegmentOption = string | { value: string; label: React.ReactNode };
export function CPSegment({
value,
onChange,
options,
}: {
value?: string;
onChange?: (next: string) => void;
options: CPSegmentOption[];
}) {
return (
<div
style={{
display: "inline-flex",
alignItems: "center",
flexWrap: "wrap",
rowGap: 4,
maxWidth: "100%",
}}
>
{options.map((opt, i) => {
const v = typeof opt === "string" ? opt : opt.value;
const l = typeof opt === "string" ? opt : opt.label;
const active = value === v;
return (
<React.Fragment key={v}>
{i > 0 && (
<span
aria-hidden
style={{
width: 1,
height: 12,
background: "var(--cp-border-subtle)",
margin: "0 6px",
flexShrink: 0,
}}
/>
)}
<button
type="button"
onClick={() => onChange && onChange(v)}
style={{
border: "none",
background: "transparent",
color: active ? "var(--cp-text)" : "var(--cp-text-muted)",
fontSize: 11.5,
fontWeight: active ? 700 : 500,
padding: "2px 4px",
borderRadius: 0,
cursor: "pointer",
fontFamily: CP_FONT,
whiteSpace: "nowrap",
transition: "color .12s, font-weight .12s",
letterSpacing: "-0.005em",
}}
onMouseEnter={(e) => {
if (!active) (e.currentTarget as HTMLButtonElement).style.color = "var(--cp-text-sec)";
}}
onMouseLeave={(e) => {
if (!active) (e.currentTarget as HTMLButtonElement).style.color = "var(--cp-text-muted)";
}}
>
{l}
</button>
</React.Fragment>
);
})}
</div>
);
}
// 작은 아이콘 버튼
export function CPIconBtn({
children,
onClick,
title,
tone = "default",
size = 24,
}: {
children?: React.ReactNode;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
title?: string;
tone?: "default" | "primary" | "danger";
size?: number;
}) {
const tones = {
default: { bg: "transparent", color: "var(--cp-text-sec)" },
primary: { bg: "rgba(var(--v5-primary-rgb),0.08)", color: "var(--v5-primary)" },
danger: { bg: "transparent", color: "var(--v5-red)" },
} as const;
const t = tones[tone];
return (
<button
type="button"
onClick={onClick}
title={title}
style={{
width: size,
height: size,
border: "1px solid var(--cp-border)",
background: t.bg,
color: t.color,
borderRadius: 5,
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
padding: 0,
flexShrink: 0,
}}
>
{children}
</button>
);
}
// re-export 편의용
export { CP_ICONS };
@@ -0,0 +1,517 @@
/* ===================================================================
ConfigPanel 공통 스타일 (cp-*)
- 시안 panel-input-new 의 v4-* 패턴을 cp-* 로 일반화
- cp 전용 중성(회색) 토큰 — v5 의 보라 tint border 대신 사용
=================================================================== */
:root {
/* 라이트: 사이트 토큰(--background/--card/--foreground)과 정렬.
hsl alias 로 쓰면 라이트/다크 자동 따라감. */
--cp-surface: hsl(var(--card)); /* 패널 카드면 = 사이트 카드 */
--cp-bg-subtle: hsl(var(--background)); /* 약간 더 어두운 영역 = 사이트 배경 */
--cp-surface-hover: hsl(var(--accent));
--cp-text: hsl(var(--foreground));
--cp-text-sec: hsl(var(--foreground) / 0.78); /* 라벨용 — 밝게 */
--cp-text-muted: hsl(var(--foreground) / 0.55); /* 캡션용 — 가독 OK */
--cp-border: hsl(var(--border));
--cp-border-subtle: hsl(var(--foreground) / 0.06);
--cp-border-strong: hsl(var(--foreground) / 0.18);
}
/* 다크 — 우측 패널만 luminance 살짝 분리. 카드 박싱은 안 함. */
.dark .inv-right-panel,
html.dark .inv-right-panel {
--cp-surface: hsl(220 6% 17%); /* 카드/입력칸 */
--cp-bg-subtle: hsl(220 6% 11%); /* 패널 외곽 — soft dark */
--cp-surface-hover: hsl(220 6% 24%);
--cp-border: hsl(0 0% 100% / 0.16);
--cp-border-subtle: hsl(0 0% 100% / 0.15); /* 섹션 구분선 — 한 단 더 진하게 */
--cp-border-strong: hsl(0 0% 100% / 0.28);
--cp-text-sec: hsl(0 0% 100% / 0.86);
--cp-text-muted: hsl(0 0% 100% / 0.62);
}
@keyframes cp-pop {
0% { opacity: 0; transform: translateY(-4px) scale(0.98); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes cp-slide-in {
0% { opacity: 0; transform: translateX(-8px); }
100% { opacity: 1; transform: translateX(0); }
}
/* ── 헤더 brumb [kind | type ▾] — 좌·우 각자 영역, 외곽 박스 없음 ── */
.cp-bar {
padding: 4px 0;
margin: 2px 0 8px;
position: relative;
}
/* 카드 프레임 제거 — split 은 그냥 좌/우 영역 분할용 flex 컨테이너 */
.cp-crumb-split {
display: flex; align-items: stretch;
background: transparent;
border: none;
border-radius: 0;
overflow: visible;
box-shadow: none;
}
.cp-crumb-split:hover,
.cp-crumb-split:focus-within { box-shadow: none; }
.cp-crumb-side { position: relative; display: flex; min-width: 0; }
.cp-crumb-kind-slot { flex-shrink: 0; }
.cp-crumb-type-slot { flex: 1; min-width: 0; }
.cp-crumb-divider {
width: 1px;
background: var(--cp-border);
flex-shrink: 0;
align-self: stretch;
margin: 4px 0;
}
/* 영역 버튼 공통 */
.cp-crumb-side-btn {
display: flex; align-items: center; gap: 6px;
padding: 7px 10px;
background: transparent;
border: none;
cursor: default;
font-family: var(--v5-font-sans);
text-align: left;
width: 100%;
min-width: 0;
transition: background 0.14s;
}
.cp-crumb-side-btn[data-clickable="true"] { cursor: pointer; }
.cp-crumb-side-btn[data-clickable="true"]:hover { background: rgba(var(--v5-primary-rgb), 0.16); border-radius: 3px; }
.cp-crumb-type-btn { cursor: pointer; }
.cp-crumb-type-btn:hover { background: rgba(var(--v5-primary-rgb), 0.16); border-radius: 3px; }
.cp-crumb-type-btn[data-open="true"],
.cp-crumb-kind-btn[data-open="true"] { background: rgba(var(--v5-primary-rgb), 0.18); border-radius: 3px; }
/* 좌측 : kind (회색 톤, 강조 X) */
.cp-crumb-kind-icon {
display: inline-flex; align-items: center; justify-content: center;
width: 18px; height: 18px;
font-size: 11px; color: var(--cp-text-muted);
flex-shrink: 0;
}
.cp-crumb-kind-name {
font-size: 11.5px; font-weight: 700;
color: var(--cp-text-sec);
letter-spacing: -0.01em;
white-space: nowrap;
}
.cp-crumb-kind-chev {
display: inline-flex; color: var(--cp-text-muted);
transition: transform 0.16s;
font-size: 8px;
}
.cp-crumb-kind-btn[data-open="true"] .cp-crumb-kind-chev { transform: rotate(180deg); }
/* 우측 : type — 평소 회색, hover/active 시에만 primary 강조 */
.cp-crumb-type-icon {
width: 24px; height: 24px;
background: var(--cp-bg-subtle);
border: 1px solid var(--cp-border);
color: var(--cp-text-sec);
border-radius: 5px;
display: flex; align-items: center; justify-content: center;
font-size: 10.5px; font-weight: 700;
font-family: var(--v5-font-mono);
flex-shrink: 0;
transition: background 0.14s, color 0.14s, border-color 0.14s, box-shadow 0.14s;
}
.cp-crumb-type-btn[data-open="true"] .cp-crumb-type-icon,
.cp-crumb-type-btn:hover .cp-crumb-type-icon {
background: var(--v5-primary);
color: #fff;
border-color: var(--v5-primary);
box-shadow: 0 0 8px rgba(var(--v5-primary-rgb), 0.30);
}
.cp-crumb-type-text { display: flex; flex-direction: column; min-width: 0; line-height: 1.2; flex: 1; }
.cp-crumb-type-name {
font-size: 12.5px; font-weight: 700; color: var(--cp-text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
letter-spacing: -0.01em;
}
.cp-crumb-type-desc {
font-size: 9.5px; color: var(--cp-text-muted); margin-top: 1px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.cp-crumb-type-chev {
display: inline-flex; color: var(--cp-text-muted);
flex-shrink: 0;
transition: transform 0.16s;
margin-left: auto;
}
.cp-crumb-type-btn[data-open="true"] .cp-crumb-type-chev { transform: rotate(180deg); }
/* 다크 모드 — split 은 카드 아니므로 별도 보정 불필요. divider 만 살짝 톤다운 */
.dark .cp-crumb-divider, html.dark .cp-crumb-divider {
background: rgba(255, 255, 255, 0.10);
}
/* ── brumb 팝오버 (type 리스트, kind 리스트) — 평면 리스트 ─────────────── */
.cp-pop {
position: absolute;
top: calc(100% - 4px);
z-index: 50;
background: var(--cp-surface);
border: 1px solid var(--cp-border);
border-radius: 4px;
box-shadow: 0 8px 22px rgba(0,0,0,0.18);
padding: 4px;
display: flex; flex-direction: column; gap: 0;
animation: cp-pop 0.14s var(--v5-ease-enter);
}
.dark .cp-pop, html.dark .cp-pop {
box-shadow: 0 14px 36px rgba(0,0,0,0.50), 0 2px 8px rgba(0,0,0,0.30);
}
/* cp-bar 의 padding (8px 12px) 안쪽으로 정렬 */
.cp-pop-kind {
left: 12px; min-width: 130px;
}
.cp-pop-type-list {
left: 12px; right: 12px; min-width: 0;
}
/* 통합 팝오버 — kinds(좌, 작은 컬럼) + types(우, 1fr) */
.cp-pop.cp-pop-combined {
left: 0; right: 0;
display: grid;
grid-template-columns: 100px 1fr;
padding: 0;
overflow: hidden;
}
.cp-pop-combined.cp-pop-types-only {
grid-template-columns: 1fr;
}
.cp-pop-col {
display: flex; flex-direction: column;
padding: 4px;
min-width: 0;
}
.cp-pop-kinds-col {
background: var(--cp-bg-subtle);
border-right: 1px solid var(--cp-border-subtle);
}
/* kind 팝오버 아이템 — 평면 strip */
.cp-pop-kind-item {
display: flex; align-items: center; gap: 7px;
padding: 7px 10px;
background: transparent;
border: none;
border-left: 2px solid transparent; /* layout 안정용 */
border-radius: 0;
cursor: pointer; font-family: var(--v5-font-sans); text-align: left;
animation: cp-slide-in 0.18s var(--v5-ease-enter) backwards;
}
.cp-pop-kind-item:hover { background: var(--cp-surface-hover); }
.cp-pop-kind-item[data-active="true"] {
background: rgba(var(--v5-primary-rgb), 0.10);
border-left-color: rgb(var(--v5-primary-rgb));
}
.cp-pop-kind-item-icon {
display: inline-flex; align-items: center; justify-content: center;
width: 18px; height: 18px;
font-size: 11px; color: var(--cp-text-muted);
flex-shrink: 0;
}
.cp-pop-kind-item-name {
font-size: 12px; font-weight: 700;
color: var(--cp-text);
letter-spacing: -0.01em;
}
.cp-pop-kind-item[data-active="true"] .cp-pop-kind-item-name {
color: var(--cp-text);
}
.cp-pop-kind-item[data-active="true"] .cp-pop-kind-item-icon {
color: var(--v5-primary);
}
.cp-pop-type {
display: flex; align-items: center; gap: 9px;
padding: 8px 9px;
background: transparent;
border: none;
border-left: 2px solid transparent;
border-radius: 0;
cursor: pointer; font-family: var(--v5-font-sans); text-align: left;
animation: cp-slide-in 0.18s var(--v5-ease-enter) backwards;
}
.cp-pop-type:hover { background: var(--cp-surface-hover); }
.cp-pop-type[data-active="true"] {
background: rgba(var(--v5-primary-rgb), 0.10);
border-left-color: rgb(var(--v5-primary-rgb));
}
.cp-pop-type-icon {
width: 24px; height: 24px;
background: var(--cp-bg-subtle);
border: 1px solid var(--cp-border);
color: var(--cp-text-sec);
border-radius: 5px;
display: flex; align-items: center; justify-content: center;
font-size: 10.5px; font-weight: 700; font-family: var(--v5-font-mono);
flex-shrink: 0;
}
.cp-pop-type[data-active="true"] .cp-pop-type-icon {
background: var(--v5-primary); color: #fff;
border-color: var(--v5-primary);
box-shadow: 0 0 8px rgba(var(--v5-primary-rgb), 0.35);
}
.cp-pop-type-text { flex: 1; min-width: 0; line-height: 1.25; }
.cp-pop-type-name { font-size: 12px; font-weight: 700; color: var(--cp-text); }
.cp-pop-type-desc { font-size: 10px; color: var(--cp-text-muted); margin-top: 1px; }
.cp-pop-type-col {
font-size: 8.5px; color: var(--cp-text-muted); font-family: var(--v5-font-mono);
padding: 1px 4px; background: var(--cp-bg-subtle); border-radius: 3px; flex-shrink: 0;
}
/* ── 형식 트리거 (본문 안 [형식 · ▾]) ──────────────────── */
.cp-fmt-head {
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
margin-bottom: 8px; padding-bottom: 6px;
border-bottom: 1px dashed var(--cp-border-subtle);
}
.cp-fmt-head-cap {
font-size: 9.5px; font-weight: 700; letter-spacing: 0.08em;
color: var(--cp-text-muted); text-transform: uppercase; flex-shrink: 0;
}
.cp-fmt-head-dot { color: var(--cp-text-muted); flex-shrink: 0; }
.cp-fmt-head-desc {
font-size: 9.5px; color: var(--cp-text-muted);
font-family: var(--v5-font-mono);
margin-left: auto;
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* 좁은 너비에서 desc 자동 숨김 */
@media (max-width: 340px) {
.cp-fmt-head-desc { display: none; }
}
.cp-fmt-trigger {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 8px; background: var(--cp-bg-subtle);
border: 1px solid var(--cp-border);
border-radius: 5px; cursor: pointer;
font-family: var(--v5-font-sans);
transition: all 0.14s;
}
.cp-fmt-trigger:hover { border-color: var(--v5-primary); background: var(--cp-surface); }
.cp-fmt-trigger[data-open="true"] {
border-color: var(--v5-primary);
box-shadow: 0 0 0 3px rgba(var(--v5-primary-rgb), 0.12);
background: var(--cp-surface);
}
.cp-fmt-trigger-icon {
width: 18px; height: 18px;
background: var(--cp-bg-subtle);
border: 1px solid var(--cp-border);
color: var(--cp-text-sec);
border-radius: 4px;
display: flex; align-items: center; justify-content: center;
font-size: 9px; font-weight: 700; font-family: var(--v5-font-mono);
transition: background 0.14s, color 0.14s, border-color 0.14s;
}
.cp-fmt-trigger[data-open="true"] .cp-fmt-trigger-icon,
.cp-fmt-trigger:hover .cp-fmt-trigger-icon {
background: var(--v5-primary); color: #fff;
border-color: var(--v5-primary);
}
.cp-fmt-trigger-name { font-size: 11.5px; font-weight: 700; color: var(--cp-text); }
.cp-fmt-trigger-chev {
color: var(--cp-text-muted); font-size: 8px;
transition: transform 0.14s; display: inline-flex;
}
.cp-fmt-trigger[data-open="true"] .cp-fmt-trigger-chev { transform: rotate(180deg); }
.cp-fmt-pop {
position: absolute; z-index: 40;
background: var(--cp-surface);
border: 1px solid var(--cp-border);
border-radius: 4px;
box-shadow: 0 8px 22px rgba(0,0,0,0.16);
padding: 4px; min-width: 220px;
animation: cp-pop 0.14s var(--v5-ease-enter);
}
.cp-fmt-pop-item {
display: flex; align-items: center; gap: 8px; width: 100%;
padding: 6px 8px;
background: transparent;
border: none;
border-left: 2px solid transparent;
border-radius: 0;
cursor: pointer; font-family: var(--v5-font-sans); text-align: left;
}
.cp-fmt-pop-item:hover { background: var(--cp-surface-hover); }
.cp-fmt-pop-item[data-active="true"] {
background: rgba(var(--v5-primary-rgb), 0.10);
border-left-color: rgb(var(--v5-primary-rgb));
}
.cp-fmt-pop-item-icon {
width: 18px; height: 18px;
background: var(--cp-bg-subtle);
border: 1px solid var(--cp-border);
color: var(--cp-text-sec);
border-radius: 4px;
display: flex; align-items: center; justify-content: center;
font-size: 9px; font-weight: 700; font-family: var(--v5-font-mono);
flex-shrink: 0;
}
.cp-fmt-pop-item[data-active="true"] .cp-fmt-pop-item-icon {
background: var(--v5-primary); color: #fff;
border-color: var(--v5-primary);
box-shadow: 0 0 6px rgba(var(--v5-primary-rgb), 0.30);
}
.cp-fmt-pop-item-text { flex: 1; min-width: 0; line-height: 1.2; }
.cp-fmt-pop-item-name { font-size: 11px; font-weight: 700; color: var(--cp-text); }
.cp-fmt-pop-item-desc { font-size: 9.5px; color: var(--cp-text-muted); margin-top: 1px; }
/* ── 푸터 ──────────────────────────────────────────────── */
.cp-foot {
padding: 8px 14px;
border-top: 1px solid var(--cp-border-subtle);
background: var(--cp-bg-subtle);
display: flex; align-items: center; gap: 10px;
}
.cp-foot-id {
font-family: var(--v5-font-mono); font-size: 10px;
color: var(--cp-text-muted); flex: 1;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.cp-foot-btns { display: flex; gap: 6px; }
.cp-foot-btn {
padding: 5px 10px; font-size: 11px; font-weight: 600; border-radius: 5px;
cursor: pointer; font-family: var(--v5-font-sans);
border: 1px solid var(--cp-border);
background: var(--cp-surface); color: var(--cp-text);
}
.cp-foot-btn:hover { border-color: var(--v5-primary); }
.cp-foot-btn.primary {
background: var(--v5-primary); color: #fff;
border-color: var(--v5-primary);
box-shadow: var(--v5-glow-sm);
}
.cp-foot-btn.primary:hover { box-shadow: var(--v5-glow-md); }
/* ═══════════════════════════════════════════════════════════
외곽 패널 (inv-right-panel) 전체 cp 톤 통일
- 사용자 요청: "위/아래 외곽 디자인도 cp 와 같은 톤으로"
- shadcn 컴포넌트들의 외관(border, background, font-size, height)을
cp 토큰으로 강제 override.
═══════════════════════════════════════════════════════════ */
/* ① 패널 배경 + 라인 — 사이트 main 톤(더 진함). 안의 카드는 cp-surface(살짝 밝음)로 분리 */
.inv-right-panel {
background: var(--cp-bg-subtle) !important;
font-family: var(--v5-font-sans);
color: var(--cp-text);
}
.inv-right-panel,
.inv-right-panel .border-l,
.inv-right-panel .border-r,
.inv-right-panel .border-t,
.inv-right-panel .border-b,
.inv-right-panel .border-border,
.inv-right-panel .border-border\/50,
.inv-right-panel [class*="border-input"] {
border-color: var(--cp-border) !important;
}
/* ② 입력 필드 외관 통일 (h-7 = 28px, cp-text 12px, cp-surface bg) */
.inv-right-panel input:not([type="checkbox"]):not([type="radio"]):not([type="color"]):not([type="range"]),
.inv-right-panel select,
.inv-right-panel [role="combobox"],
.inv-right-panel [class*="SelectTrigger"] {
height: 28px !important;
min-height: 28px !important;
padding: 0 8px !important;
font-size: 12px !important;
border: 1px solid var(--cp-border) !important;
background: var(--cp-surface) !important;
border-radius: 6px !important;
color: var(--cp-text) !important;
box-shadow: none !important;
}
.inv-right-panel textarea {
font-size: 12px !important;
border: 1px solid var(--cp-border) !important;
background: var(--cp-surface) !important;
border-radius: 6px !important;
color: var(--cp-text) !important;
padding: 8px !important;
}
.inv-right-panel input:focus-visible,
.inv-right-panel select:focus-visible,
.inv-right-panel textarea:focus-visible,
.inv-right-panel [role="combobox"]:focus-visible,
.inv-right-panel [data-state="open"][class*="SelectTrigger"] {
border-color: var(--v5-primary) !important;
box-shadow: 0 0 0 3px rgba(var(--v5-primary-rgb), 0.12) !important;
outline: none !important;
}
.inv-right-panel input::placeholder,
.inv-right-panel textarea::placeholder {
color: var(--cp-text-muted) !important;
opacity: 0.7 !important;
}
/* ③ 라벨 (Label / 작은 캡션) */
.inv-right-panel label {
font-size: 11px !important;
color: var(--cp-text-sec) !important;
font-weight: 500 !important;
}
/* ④ 섹션 헤더 (h3/h4 — DIMENSIONS / OPTIONS / LABEL / 컴포넌트 스타일 등) */
.inv-right-panel h3,
.inv-right-panel h4 {
font-size: 10.5px !important;
font-weight: 700 !important;
letter-spacing: 0.12em !important;
text-transform: uppercase !important;
color: var(--cp-text-muted) !important;
font-family: var(--v5-font-sans) !important;
}
/* ⑤ 섹션 좌측 컬러 아이콘 (조건부 표시 ⚡, 컴포넌트 스타일 🎨)
primary 보라 → 중성 회색으로 다운 */
.inv-right-panel summary svg,
.inv-right-panel summary [class*="lucide"],
.inv-right-panel details > summary svg {
color: var(--cp-text-muted) !important;
}
/* ⑥ 버튼 외관 통일 (Collapsible trigger 등) */
.inv-right-panel button[class*="rounded-lg"],
.inv-right-panel [data-state][role="button"] {
border-color: var(--cp-border) !important;
background: var(--cp-bg-subtle) !important;
}
/* ⑦ Collapsible chevron 작게 + muted */
.inv-right-panel [data-state="closed"] svg.lucide-chevron-down,
.inv-right-panel [data-state="open"] svg.lucide-chevron-down,
.inv-right-panel button svg.lucide-chevron-down {
width: 12px !important;
height: 12px !important;
color: var(--cp-text-muted) !important;
}
/* ⑧ shadcn Select 의 dropdown (열렸을 때 하단 카드) */
[role="listbox"][class*="SelectContent"],
[data-radix-select-content] {
border-color: var(--cp-border) !important;
background: var(--cp-surface) !important;
}
[data-radix-select-item][data-highlighted] {
background: var(--cp-surface-hover) !important;
}
/* ⑨ Separator 색 통일 */
.inv-right-panel [role="separator"],
.inv-right-panel hr {
background-color: var(--cp-border-subtle) !important;
border-color: var(--cp-border-subtle) !important;
}
@@ -0,0 +1,78 @@
/**
* ConfigPanel 전용 미니 SVG 아이콘 셋
* - 시안 panel-input-new 의 I = {...} 그대로
* - 본 프로젝트의 lucide-react 와 별개. 패널 안에서만 사용
*/
import React from "react";
export const CP_ICONS = {
chevron: (
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
<path d="M2 3.5l3 3 3-3" />
</svg>
),
plus: (
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
<path d="M5.5 2v7M2 5.5h7" />
</svg>
),
trash: (
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 3h7M4 3V2h3v1M3 3l.5 6h4L8 3M4.5 5v3M6.5 5v3" />
</svg>
),
grip: (
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor">
<circle cx="2" cy="2" r="1.1" />
<circle cx="7" cy="2" r="1.1" />
<circle cx="2" cy="6.5" r="1.1" />
<circle cx="7" cy="6.5" r="1.1" />
<circle cx="2" cy="11" r="1.1" />
<circle cx="7" cy="11" r="1.1" />
</svg>
),
reset: (
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
<path d="M2 5.5a3.5 3.5 0 103.5-3.5M2 2v3.5h3.5" />
</svg>
),
help: (
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
<circle cx="5.5" cy="5.5" r="4" />
<path d="M4 4.3c0-.8.7-1.5 1.5-1.5s1.5.7 1.5 1.5c0 .8-1.5 1-1.5 2" />
<circle cx="5.5" cy="8" r=".4" fill="currentColor" />
</svg>
),
link: (
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
<path d="M4.5 6.5l2-2M3.5 4.5L2 6a1.5 1.5 0 002 2l1.5-1.5M7.5 6.5L9 5a1.5 1.5 0 00-2-2L5.5 4.5" />
</svg>
),
dot: (
<svg width="7" height="7" viewBox="0 0 7 7">
<circle cx="3.5" cy="3.5" r="2.5" fill="currentColor" />
</svg>
),
code: (
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 3L1.5 5.5 4 8M7 3l2.5 2.5L7 8" />
</svg>
),
close: (
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
<path d="M2 2l6 6M8 2l-6 6" />
</svg>
),
chevRight: (
<svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
<path d="M3 2l3 2.5-3 2.5" />
</svg>
),
alert: (
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
<path d="M5.5 1.5L1 9.5h9L5.5 1.5zM5.5 4.5v2M5.5 8v.01" />
</svg>
),
} as const;
export type CPIconName = keyof typeof CP_ICONS;
@@ -0,0 +1,19 @@
/**
* ConfigPanel 공통 프리미티브 (CP*) — barrel export
* - 새 패널은 `import { CPSection, CPRow, ... } from "../_shared/cp"` 한 줄로 사용
* - 사용하기 전에 `import "./_shared/cp/cp.css"` 한 번 필요 (패널 파일 상단)
*/
export { CP_ICONS } from "./icons";
export type { CPIconName } from "./icons";
export { CPSection, CPRow, CPStacked, CPText, CPSelect, CPTextarea, CPSwitch, CPNumber, CPColor, CPSegment, CPIconBtn } from "./CPPrimitives";
export {
CPHeader,
CPTabs,
CPGroup,
CPBindChip,
CPCrumb,
CPFormatTrigger,
} from "./CPChrome";
export type { CPTabItem, CPCrumbType, CPFormatItem } from "./CPChrome";
@@ -82,9 +82,33 @@ export const InputComponent: React.FC<InputComponentProps> = ({
...fromProps,
} as InputConfig;
const type: InputFieldType = isValidType(componentConfig.type)
? componentConfig.type
: "text";
// DB 저장본의 type 은 'v2-input'/'v2-select'/'v2-date' 같은 컴포넌트 ID 라서
// VALID_TYPES 와 직접 매칭 안 됨. 별칭 + web_type 폴백으로 InputFieldType 추정.
const rawType: any = componentConfig.type;
const webType: any = (componentConfig as any).web_type;
const type: InputFieldType = (() => {
if (isValidType(rawType)) return rawType;
// v2-select 는 entity 든 code 든 dropdown 이든 전부 native select 로 통일
if (rawType === "v2-select") return "select";
if (rawType === "v2-date") {
if (webType === "datetime") return "datetime";
return "date";
}
if (
webType === "select" ||
webType === "code" ||
webType === "dropdown" ||
webType === "radio" ||
webType === "entity"
) return "select";
if (webType === "number" || webType === "decimal") return "number";
if (webType === "date") return "date";
if (webType === "datetime") return "datetime";
if (webType === "textarea") return "textarea";
if (webType === "checkbox" || webType === "boolean") return "checkbox";
if (webType === "file" || webType === "image") return "file";
return "text";
})();
const label = componentConfig.label;
const placeholder = componentConfig.placeholder ?? "";
@@ -95,13 +119,55 @@ export const InputComponent: React.FC<InputComponentProps> = ({
const disabled = componentConfig.disabled ?? false;
const rows = componentConfig.rows ?? 3;
const [value, setValue] = useState<unknown>(
componentConfig.defaultValue ?? "",
// 부모가 formData[columnName] 또는 직접 value 를 넘겨주면 controlled. 없으면 로컬 state.
// 입력 변경 시 부모 콜백(onFormDataChange / onChange) 둘 다 시도해서 BlockRenderer 의
// formRow 갱신 경로를 끊김 없이 잇는다.
const formDataProp = (props as any).formData ?? (props as any).form_data;
const onFormDataChangeProp = (props as any).onFormDataChange;
const onChangeProp = (props as any).onChange;
const columnName: string | undefined =
(component as any).columnName ?? (component as any).column_name;
const controlledValue =
formDataProp && columnName
? formDataProp[columnName]
: (props as any).value !== undefined
? (props as any).value
: undefined;
const [localValue, setLocalValue] = useState<unknown>(
controlledValue !== undefined ? controlledValue : (componentConfig.defaultValue ?? ""),
);
useEffect(() => {
setValue(componentConfig.defaultValue ?? "");
}, [componentConfig.defaultValue]);
if (controlledValue !== undefined) {
setLocalValue((prev: unknown) => (prev === controlledValue ? prev : controlledValue));
} else if (componentConfig.defaultValue !== undefined) {
setLocalValue((prev: unknown) =>
prev === componentConfig.defaultValue ? prev : (componentConfig.defaultValue ?? ""),
);
}
}, [controlledValue, componentConfig.defaultValue]);
const value: unknown = controlledValue !== undefined ? controlledValue : localValue;
const propagate = (v: unknown) => {
// [DEBUG] 입력 흐름 진단용 임시 로그. 동작 확인 후 제거 예정.
// eslint-disable-next-line no-console
console.log("[InputComponent] propagate", {
columnName,
v,
hasOnFormDataChange: typeof onFormDataChangeProp === "function",
hasOnChange: typeof onChangeProp === "function",
controlledValue,
type,
rawType,
webType,
});
setLocalValue(v);
if (typeof onFormDataChangeProp === "function" && columnName) onFormDataChangeProp(columnName, v);
if (typeof onChangeProp === "function") onChangeProp(v);
};
// ─── DOM props filter (React warning 방지) ────────────────────────────
/* eslint-disable @typescript-eslint/no-unused-vars */
@@ -236,7 +302,7 @@ export const InputComponent: React.FC<InputComponentProps> = ({
<input
type="number"
value={typeof value === "number" || typeof value === "string" ? (value as any) : ""}
onChange={(e) => setValue(e.target.valueAsNumber)}
onChange={(e) => propagate(e.target.valueAsNumber)}
min={componentConfig.min}
max={componentConfig.max}
step={componentConfig.step}
@@ -248,7 +314,7 @@ export const InputComponent: React.FC<InputComponentProps> = ({
<input
type="date"
value={typeof value === "string" ? value : ""}
onChange={(e) => setValue(e.target.value)}
onChange={(e) => propagate(e.target.value)}
{...common}
/>
);
@@ -257,7 +323,7 @@ export const InputComponent: React.FC<InputComponentProps> = ({
<input
type="datetime-local"
value={typeof value === "string" ? value : ""}
onChange={(e) => setValue(e.target.value)}
onChange={(e) => propagate(e.target.value)}
{...common}
/>
);
@@ -265,7 +331,7 @@ export const InputComponent: React.FC<InputComponentProps> = ({
return (
<textarea
value={typeof value === "string" ? value : ""}
onChange={(e) => setValue(e.target.value)}
onChange={(e) => propagate(e.target.value)}
rows={rows}
minLength={componentConfig.minLength}
maxLength={componentConfig.maxLength}
@@ -278,7 +344,7 @@ export const InputComponent: React.FC<InputComponentProps> = ({
return (
<select
value={typeof value === "string" ? value : ""}
onChange={(e) => setValue(e.target.value)}
onChange={(e) => propagate(e.target.value)}
{...common}
>
<option value="">{placeholder || "선택하세요"}</option>
@@ -313,7 +379,7 @@ export const InputComponent: React.FC<InputComponentProps> = ({
<input
type="checkbox"
checked={!!value}
onChange={(e) => setValue(e.target.checked)}
onChange={(e) => propagate(e.target.checked)}
disabled={disabled || isDesignMode}
readOnly={readonly}
/>
@@ -326,7 +392,7 @@ export const InputComponent: React.FC<InputComponentProps> = ({
<input
type="text"
value={typeof value === "string" ? value : ""}
onChange={(e) => setValue(e.target.value)}
onChange={(e) => propagate(e.target.value)}
{...common}
readOnly
placeholder={placeholder || "검색 팝업에서 선택"}
@@ -379,7 +445,7 @@ export const InputComponent: React.FC<InputComponentProps> = ({
<input
type="text"
value={typeof value === "string" ? value : ""}
onChange={(e) => setValue(e.target.value)}
onChange={(e) => propagate(e.target.value)}
minLength={componentConfig.minLength}
maxLength={componentConfig.maxLength}
{...common}
+42 -69
View File
@@ -15,90 +15,63 @@
라이트 #f5f5f8 / #ededf2 / #e4e4ec / #d8d8e2 / #1a1a24 / #6c5ce7
═══════════════════════════════════════════════════════════════════════════ */
/* ─── 라이트 모드 ─── */
/* ─── 라이트 모드 ─── 사이트 메인(메뉴/회사관리)과 동일 중성 톤 */
.ide-builder {
/* 배경 e8e8ef — 캔버스. 카드보다 확실히 어둡게 해서 패널이 도드라지게 */
--background: 240 13% 92%;
/* 카드 (패널) #ffffff — v5 솔리드 흰색 */
--background: 240 20% 99%;
--card: 0 0% 100%;
--card-foreground: 240 17% 12%;
/* 팝오버 #ffffff */
--card-foreground: 240 10% 5%;
--popover: 0 0% 100%;
--popover-foreground: 240 17% 12%;
/* 전경 1a1a24 */
--foreground: 240 17% 12%;
/* muted dedde6 / 5a5a6e — 캔버스와 카드 사이 톤 */
--muted: 240 12% 88%;
--muted-foreground: 240 10% 39%;
/* accent (hover 배경) dedde6 */
--accent: 240 12% 88%;
--accent-foreground: 240 17% 12%;
/* secondary f4f4f8 */
--secondary: 240 13% 96%;
--secondary-foreground: 240 17% 12%;
/* 보더 c9c9d6 — 대비 강화 */
--border: 240 13% 81%;
--input: 240 13% 81%;
/* 액센트 (primary) #6c5ce7 — v5 Cosmic 보라 */
--primary: 249 75% 63%;
--primary-foreground: 0 0% 100%;
--ring: 249 75% 63%;
/* destructive dc2626 */
--popover-foreground: 240 10% 5%;
--foreground: 240 10% 5%;
--muted: 240 5% 95%;
--muted-foreground: 240 4% 46%;
--accent: 240 5% 95%;
--accent-foreground: 240 10% 5%;
--secondary: 240 5% 96%;
--secondary-foreground: 240 10% 5%;
--border: 240 6% 88%;
--input: 240 6% 88%;
/* --primary / --ring 은 정의하지 않음 → 사이트 :root 토큰 자동 상속,
data-color preset (purple/blue/green/orange/pink/cyan) 도 자동 따라감 */
--destructive: 0 73% 51%;
--destructive-foreground: 0 0% 100%;
/* 사이드바 (패널 안의 보조 영역) */
/* 사이드바 — 사이트 카드 톤 (사이드바 primary/ring 도 미정의 → 사이트 토큰 상속) */
--sidebar-background: 0 0% 100%;
--sidebar-foreground: 240 17% 12%;
--sidebar-primary: 249 75% 63%;
--sidebar-foreground: 240 10% 5%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 12% 88%;
--sidebar-accent-foreground: 240 17% 12%;
--sidebar-border: 240 13% 81%;
--sidebar-ring: 249 75% 63%;
--sidebar-accent: 240 5% 95%;
--sidebar-accent-foreground: 240 10% 5%;
--sidebar-border: 240 6% 88%;
/* 반경을 살짝 더 타이트하게 — IDE 느낌 */
--radius: 0.375rem;
}
/* ─── 다크 모드 ─── */
/* ─── 다크 모드 ─── 사이트 메인(메뉴/회사관리)과 동일 중성 톤 */
.dark .ide-builder {
/* 배경 121218 */
--background: 240 13% 9%;
/* 카드 (패널) 1a1a22 */
--card: 240 14% 12%;
--card-foreground: 240 14% 92%;
/* 팝오버 1a1a22 */
--popover: 240 14% 12%;
--popover-foreground: 240 14% 92%;
/* 전경 e8e8ee */
--foreground: 240 14% 92%;
/* muted 22222c / 78788a */
--muted: 240 13% 15%;
--muted-foreground: 240 8% 51%;
/* accent 2a2a36 */
--accent: 240 13% 19%;
--accent-foreground: 240 14% 92%;
/* secondary 1a1a22 */
--secondary: 240 14% 12%;
--secondary-foreground: 240 14% 92%;
/* 보더 3a3a48 */
--border: 240 10% 25%;
--input: 240 10% 25%;
/* 액센트 #a29bfe — v5 Cosmic 보라 (다크) */
--primary: 245 99% 81%;
--primary-foreground: 0 0% 100%;
--ring: 245 99% 81%;
/* destructive f87171 */
--background: 220 6% 5%;
--card: 220 6% 9%;
--card-foreground: 220 6% 95%;
--popover: 220 6% 9%;
--popover-foreground: 220 6% 95%;
--foreground: 220 6% 95%;
--muted: 220 6% 14%;
--muted-foreground: 220 4% 56%;
--accent: 220 6% 18%;
--accent-foreground: 220 6% 95%;
--secondary: 220 6% 12%;
--secondary-foreground: 220 6% 95%;
--border: 220 6% 18%;
--input: 220 6% 18%;
/* --primary / --ring 미정의 → 사이트 :root.dark 토큰 + data-color preset 자동 상속 */
--destructive: 0 91% 71%;
--destructive-foreground: 0 0% 100%;
/* 사이드바 (패널 안의 보조 영역) */
--sidebar-background: 240 14% 12%;
--sidebar-foreground: 240 14% 92%;
--sidebar-primary: 245 99% 81%;
/* 사이드바 — 사이트 카드 톤 (primary/ring 미정의 → 사이트 토큰 상속) */
--sidebar-background: 220 6% 9%;
--sidebar-foreground: 220 6% 95%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 13% 19%;
--sidebar-accent-foreground: 240 14% 92%;
--sidebar-border: 240 10% 25%;
--sidebar-ring: 245 99% 81%;
--sidebar-accent: 220 6% 18%;
--sidebar-accent-foreground: 220 6% 95%;
--sidebar-border: 220 6% 18%;
}
/* ═══════════════════════════════════════════════════════════════════════════
+27 -10
View File
@@ -138,33 +138,50 @@
/* --- BLUE --- */
html[data-color="blue"]{
--v5-primary-rgb:59,130,246; --v5-cyan-rgb:14,165,233; --v5-pink-rgb:99,102,241;}
--v5-primary-rgb:59,130,246; --v5-cyan-rgb:14,165,233; --v5-pink-rgb:99,102,241;
--primary:217 91% 60%; --ring:217 91% 60%; --sidebar-primary:217 91% 60%; --sidebar-ring:217 91% 60%;}
html.dark[data-color="blue"]{
--v5-primary-rgb:147,197,253; --v5-cyan-rgb:125,211,252; --v5-pink-rgb:129,140,248;}
--v5-primary-rgb:147,197,253; --v5-cyan-rgb:125,211,252; --v5-pink-rgb:129,140,248;
--primary:213 93% 78%; --ring:213 93% 78%; --sidebar-primary:213 93% 78%; --sidebar-ring:213 93% 78%;}
/* --- GREEN --- */
html[data-color="green"]{
--v5-primary-rgb:16,185,129; --v5-cyan-rgb:20,184,166; --v5-pink-rgb:132,204,22;}
--v5-primary-rgb:16,185,129; --v5-cyan-rgb:20,184,166; --v5-pink-rgb:132,204,22;
--primary:160 84% 39%; --ring:160 84% 39%; --sidebar-primary:160 84% 39%; --sidebar-ring:160 84% 39%;}
html.dark[data-color="green"]{
--v5-primary-rgb:110,231,183; --v5-cyan-rgb:94,234,212; --v5-pink-rgb:190,242,100;}
--v5-primary-rgb:110,231,183; --v5-cyan-rgb:94,234,212; --v5-pink-rgb:190,242,100;
--primary:156 73% 67%; --ring:156 73% 67%; --sidebar-primary:156 73% 67%; --sidebar-ring:156 73% 67%;}
/* --- ORANGE --- */
html[data-color="orange"]{
--v5-primary-rgb:249,115,22; --v5-cyan-rgb:6,182,212; --v5-pink-rgb:251,146,60;}
--v5-primary-rgb:249,115,22; --v5-cyan-rgb:6,182,212; --v5-pink-rgb:251,146,60;
--primary:25 95% 53%; --ring:25 95% 53%; --sidebar-primary:25 95% 53%; --sidebar-ring:25 95% 53%;}
html.dark[data-color="orange"]{
--v5-primary-rgb:253,186,116; --v5-cyan-rgb:103,232,249; --v5-pink-rgb:252,165,165;}
--v5-primary-rgb:253,186,116; --v5-cyan-rgb:103,232,249; --v5-pink-rgb:252,165,165;
--primary:32 97% 72%; --ring:32 97% 72%; --sidebar-primary:32 97% 72%; --sidebar-ring:32 97% 72%;}
/* --- PINK --- */
html[data-color="pink"]{
--v5-primary-rgb:236,72,153; --v5-cyan-rgb:168,85,247; --v5-pink-rgb:244,114,182;}
--v5-primary-rgb:236,72,153; --v5-cyan-rgb:168,85,247; --v5-pink-rgb:244,114,182;
--primary:330 81% 60%; --ring:330 81% 60%; --sidebar-primary:330 81% 60%; --sidebar-ring:330 81% 60%;}
html.dark[data-color="pink"]{
--v5-primary-rgb:244,114,182; --v5-cyan-rgb:192,132,252; --v5-pink-rgb:249,168,212;}
--v5-primary-rgb:244,114,182; --v5-cyan-rgb:192,132,252; --v5-pink-rgb:249,168,212;
--primary:330 86% 70%; --ring:330 86% 70%; --sidebar-primary:330 86% 70%; --sidebar-ring:330 86% 70%;}
/* --- CYAN --- */
html[data-color="cyan"]{
--v5-primary-rgb:8,145,178; --v5-cyan-rgb:14,165,233; --v5-pink-rgb:6,182,212;}
--v5-primary-rgb:8,145,178; --v5-cyan-rgb:14,165,233; --v5-pink-rgb:6,182,212;
--primary:191 91% 36%; --ring:191 91% 36%; --sidebar-primary:191 91% 36%; --sidebar-ring:191 91% 36%;}
html.dark[data-color="cyan"]{
--v5-primary-rgb:125,211,252; --v5-cyan-rgb:103,232,249; --v5-pink-rgb:165,243,252;}
--v5-primary-rgb:125,211,252; --v5-cyan-rgb:103,232,249; --v5-pink-rgb:165,243,252;
--primary:200 94% 74%; --ring:200 94% 74%; --sidebar-primary:200 94% 74%; --sidebar-ring:200 94% 74%;}
/* --- PURPLE (기본) — 사이트 :root 토큰을 그대로 쓰지만 명시적으로 매핑해서
다른 preset → purple 로 돌아갈 때 이전 값이 남지 않게 */
html[data-color="purple"]{
--primary:245 75% 57%; --ring:245 75% 57%; --sidebar-primary:245 75% 57%; --sidebar-ring:245 75% 57%;}
html.dark[data-color="purple"]{
--primary:245 85% 68%; --ring:245 85% 68%; --sidebar-primary:245 85% 68%; --sidebar-ring:245 85% 68%;}
/* ===== COSMIC BACKGROUND ===== */
.v5-cosmos{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
@@ -0,0 +1,182 @@
# DataPort / FieldConfig 시스템 감사 (2026-04-27)
Claude(Opus 4.7) 1차 분석 + Codex(GPT) 교차검증 + 사용자 정정 반영.
> ⚠️ **2026-04-27 정정**: 초판에서 "DataPort 제거" 를 권고했으나 잘못된 결론이었음. DataPort 는
> INVYONE 컴포넌트 규격 v1.0 의 핵심 차별화 요소(vex 대비 강점)로 새로 도입한 통신 프로토콜이며,
> 인프라가 먼저 깔린 후 컴포넌트 어댑터/빌더 UI 가 따라붙는 단계임. 현재 props 콜백은 활성화 전
> 임시 호환 레이어. 본 문서는 정정 후 버전.
---
## TL;DR
- **DataPort 는 INVYONE 의 핵심 통신 규격.** 인프라(타입/Bus/Runtime/메타/DB컬럼) 깔린 상태, 활성화 대기 중.
- **현재 props 콜백 + context drilling 은 임시 호환 레이어.** 콜백 시그니처가 DataPort 포트와 1:1 매칭되게 설계됨.
- **다음 단계 = 컴포넌트 publish/subscribe 어댑터 + 빌더 시각 연결 편집 UI + `BlockRenderer` 에서 `setupConnections` 호출**.
- **FieldConfig 정상 작동.**`v2-table-list` 레거시 `config.columns` fallback 1곳에서 단일 진실 원천 원칙 위반 — 정리 가능.
---
## 1. 설계 의도 (진실의 원천)
`notes/gbpark/2026-04-08-invyone-component-spec.md` §4 / §7 표:
| | vex | invyone |
|---|---|---|
| 컴포넌트 간 통신 | 자체 구현, 컴포넌트마다 다름 | **DataPort 표준 프로토콜** |
| 데이터 전달 | 컴포넌트별 커스텀 이벤트 | **output → input 자동 매칭** |
`notes/gbpark/2026-04-09-invyone-architecture.md` §1.2:
> 컴포넌트끼리 직접 참조하지 않는다. 대신 표준화된 DataPort로 데이터를 주고받는다.
`notes/gbpark/2026-04-10-phase2-fieldconfig-components.md` (FcTable/FcForm/FcSearch props 주석):
- `onRowSelect` ← // selectedRow(row)
- `onRowsSelect` ← // selectedRows(rows)
- `onSearch` ← // searchParams(params)
- `onSubmit` ← // formData(row), `onSaved` ← // savedRow(row)
- `loadRow` ← // DataPort 입력
**현재 콜백 시그니처가 이미 DataPort 포트 이름과 1:1 매칭되도록 설계됨**. props↔DataPort 전환이 어댑터 한 층으로 가능하게 만들어둔 구조.
---
## 2. 현재 구현 상태
| 레이어 | 상태 | 위치 |
|---|---|---|
| 타입 (`DataPort`/`Connection`/`DataPortType`/`ReservedDataPortName`/`DataPortDef`/`CardConnection`) | ✅ 정의됨 | `frontend/types/invyone-component.ts:240~306, 692~693, 898~905, 939~946` |
| Pub/Sub 버스 | ✅ 구현됨 | `frontend/lib/dataPort/DataPortBus.ts` |
| Connection → Bus 브리지 (`setupConnections`) | ✅ 구현됨 | `frontend/lib/dataPort/runtime.ts` |
| 컴포넌트 메타 (`ComponentDefinition.dataPorts: { inputs, outputs }`) | ✅ 9개 등록 | `v2-table-list, input, v2-button-primary, v2-input, search, table, button, v2-table-search-widget, stats` |
| Template DB 컬럼 (`connections jsonb`) | ✅ 생성·저장 경로 | `templateAdapter.ts:93,115` |
| 컴포넌트 publish/subscribe 어댑터 | ❌ 미구현 | (다음 단계) |
| 빌더 시각 연결 편집 UI | ❌ 미구현 | (다음 단계) |
| `BlockRenderer` 에서 `setupConnections` 호출 + cleanup | ❌ 미구현 | (다음 단계) |
| Template.connections 인자를 `saveTemplate` 호출처가 전달 | ❌ 미구현 | `ScreenDesigner.tsx:2797,6757`, `builder/page.tsx:359` |
**현재 작동하는 임시 통신 패턴 (DataPort 활성화 전 1세대):**
```
DashboardCard (state hub)
├─ state: searchParams, selectedRow, formRow, page, pageSize
├─ handlers: handleSearch, handleRowSelect, handleAdd, handleEdit, handleDelete
└─ TemplateRenderer({ template, context })
context.{onSearch, onRowSelect, onAdd, onFormSubmit, ...} ← DataPort 와 1:1 매칭되는 콜백
└─ BlockRenderer ← 콜백을 props 로 펼쳐서 컴포넌트에 전달
└─ 각 v2-* / fc-* 컴포넌트 (props.onSearch, props.onRowSelect, ...)
```
이 구조가 **DataPort 로 전환되었을 때**:
```
DashboardCard
├─ DataPortBus 인스턴스 (화면별)
└─ TemplateRenderer
└─ BlockRenderer
├─ setupConnections(template.connections, bus) ← 마운트 시
├─ cleanup() ← 언마운트 시
└─ 각 컴포넌트
├─ outputs 발생 시 bus.publish(`${id}.${portName}`, value)
└─ inputs 채널 bus.subscribe(...)
```
콜백 props 와 DataPort 는 **공존 가능**한 구조. 어댑터 한 층(예: `usePortPublish(componentId, portName)` 훅)으로 양쪽 호환 가능.
---
## 3. FieldConfig 경로 — 정상 작동
| 위치 | 사용 |
|---|---|
| `FcTable.tsx:29`, `FcForm.tsx:16`, `FcSearch.tsx:16` | `FieldConfig[]` 직접 |
| `FieldRenderer.tsx:30` | `FieldConfig.type` → 위젯 매핑 (10종) |
| `lib/fieldConfig/adapters.ts` | `FieldConfig` → 레거시 columns/formFields/searchFields 변환 (호환 레이어) |
| **`v2-table-list/TableListContainerWrapper.tsx:55,70`** | **fallback: `props.fields` 부재 시 `config.columns` 사용 ← 단일 진실 원천 원칙 위반** |
`TableListComponent.tsx:509,1277``config.columns` 직접 소비 → 이 경로가 유일하게 FieldConfig 원칙 깸. 빌더에서 columns→FieldConfig 정규화 후 fallback 제거 필요.
---
## 4. 모델 이중화 — TemplateComponent vs BlockV2
`invyone-component.ts` 안에 컴포넌트 모델 3개 공존:
| 모델 | 위치 | 실제 사용 |
|---|---|---|
| `Component` (§2) | line 186 | 자체 주석에 "사실상 폐기, 즉시 삭제 가능" — 정리 가능 |
| `TemplateComponent` (§6) | line 804 | **빌더(`ScreenDesigner.tsx:122,2988`, `TemplatesPanel`)에서 사용 중** |
| `BlockV2` (§7) | line 998 | **런타임 진실**`TemplateRenderer`/`BlockRenderer` 가 받는 모델 |
빌더 → `templateAdapter.saveTemplate``convertLegacyToV2` → DB 저장 → `ensureV2Views` → BlockV2 처리.
이중화는 의도된 구조(빌더 모델 vs 런타임 모델 분리). 어댑터로 정합성 유지. **제거 대상 아님**.
---
## 5. 권고
### A) DataPort 인프라 유지 (절대 건드리지 말 것)
타입/Bus/Runtime/메타/DB컬럼 모두 의도해서 깐 인프라. 아직 활성화되지 않은 상태일 뿐.
### B) DataPort 활성화 작업 순서 (다음 단계)
1. **`BlockRenderer` (또는 그 상위 `TemplateRenderer`/`DashboardCard`) 에 Bus 라이프사이클**
- 화면별 `new DataPortBus()` 또는 `defaultDataPortBus` 사용
- `useEffect``setupConnections(template.connections, bus)` 호출 / cleanup
- React Context 로 자식 컴포넌트에 bus 전달
2. **컴포넌트 어댑터 훅** (props↔Bus 양쪽 호환)
```ts
// 예
function usePortPublish(componentId: string, portName: string) {
const bus = useDataPortBus();
return useCallback((value: unknown) => {
bus.publish(DataPortBus.channelOf(componentId, portName), value);
}, [bus, componentId, portName]);
}
```
기존 `onRowSelect` 콜백 호출 시 동시에 `publish('selectedRow', row)` 도 호출하도록 어댑터 추가. 점진 전환.
3. **빌더에 시각 연결 편집 UI**
- 컴포넌트 박스에 `dataPorts.outputs/inputs` 메타 기반으로 포트 핸들 자동 표시
- 드래그로 from.output → to.input 연결 → `Template.connections` 누적
- `PortHandle.tsx` 가 이미 존재 (제어 모드 노드 에디터용으로) — 패턴 재사용 가능
4. **`templateAdapter.saveTemplate` 호출처에 connections 전달**
- `ScreenDesigner.tsx:2797,6757`, `builder/page.tsx:359` 가 빌더 상태의 connections 를 인자로 넘기게
### C) DataPort 와 별개로 정리 가능한 항목
- `Component` (§2 invyone-component.ts:186) 인터페이스 — 자체 주석에 폐기 명시. 참조 0건이면 삭제
- `TableListContainerWrapper.tsx:55,70``config.columns` fallback — 빌더에서 columns→FieldConfig 정규화 후 제거. FieldConfig 단일 원천 회복
### D) 손대지 말 것
- `lib/dataPort/` 전체
- 각 컴포넌트 definition 의 `dataPorts: { ... }` 선언
- `Template.connections`, `Card.inputs/outputs`, `Dashboard.connections` (CardConnection[]) 필드
- `templateAdapter.ts` 의 connections 직렬화
- `TemplateComponent` (§6) — 빌더 모델로 사용 중
---
## 부록: 검증에 사용한 grep
```bash
# DataPort 활성화 경로 (현재 0건 — 다음 단계에서 채워짐)
grep -rn "setupConnections\|defaultDataPortBus\|new DataPortBus\|bus\.publish\|bus\.subscribe" \
--include="*.ts" --include="*.tsx" frontend/
# FieldConfig 사용처
grep -rn "FieldConfig" --include="*.ts" --include="*.tsx" frontend/
# dataPorts 메타 등록
grep -rn "dataPorts\b" --include="*.ts" --include="*.tsx" frontend/
```
---
## 부록: 초판의 잘못된 결론
초판: "DataPort 전체 제거" 권고. 호출처 0건 = dead code 라는 단순 추론에서 나옴.
정정: 인프라 유지 + 활성화 작업이 정답. 콜백 시그니처가 DataPort 포트와 1:1 매칭되도록 미리 설계된 점, 컴포넌트 메타에 dataPorts 가 명시적으로 등록된 점, Template DB 에 connections 컬럼이 미리 만들어진 점이 "활성화 대기 중인 인프라" 임을 보여주는 신호였는데 초판에서 놓쳤음.
@@ -0,0 +1,283 @@
# INVYONE Studio · Config Panel cp 디자인 마이그레이션
날짜: 2026-04-27
작업자: gbpark
상태: 진행 중 (입력 필드 패널 + 외곽 + 사이트 preset 시스템 정리까지 완료, 추가 패널 미작업)
---
## 0. 배경 / 컨텍스트
- 디자이너 시안: `/Users/gbpark/Downloads/ui_kits/app/cli_export/standalone/`
- `panel-input-new` (입력 필드)
- `panel-table-new` (표)
- `panel-lookup-new` (조회)
- `_shared/config-panel.css` + `_shared/config-panel-fields.jsx` (CP* 프리미티브 14종)
- 시안의 핵심 컨셉:
- **3-레벨 분류**: `kind ▸ type ▸ format`
- kind = 행위 (입력/선택/자동/첨부)
- type = 저장 모양 (글자/숫자/금액/날짜 등) → DB 컬럼 타입 박힘
- format = 검증·마스킹·UI variant
- **헤더 brumb**: `[kind ▸ type ▾]` 클릭 시 type 팝오버
- **format trigger**: type 안에서 format 동적 변경 (자유/이메일/전화 등)
- **카드 = 회색 라인 + 작은 폰트(0.55~0.85rem)**, 보라는 active 강조에만
- 라이트/다크 모두 동작, v5 토큰만 사용
---
## 1. 작업 범위
| 영역 | 파일 | 처리 |
|---|---|---|
| **CP 프리미티브 신설** | `frontend/components/v2/config-panels/_shared/cp/` | 신규 5개 파일 |
| **빌더 입력 필드 본체** | `V2FieldConfigPanel.tsx` (914줄) | cp 패턴으로 재작성 |
| **빌더 외곽 패널** | `V2PropertiesPanel.tsx` (1351줄) | DIMENSIONS/OPTIONS/LABEL/조건부 표시/컴포넌트 스타일 cp 패턴화 |
| **빌더 컴포넌트 스타일 카드** | `StyleEditor.tsx` (테두리/배경/텍스트) | cp 톤 정렬 |
| **빌더 우측 패널 컨테이너** | `ScreenDesigner.tsx` 8772 라인 | 너비 드래그 핸들 + cp 톤 헤더 |
| **빌더 IDE 토큰** | `frontend/styles/builder-ide.css` | 사이트 메인 톤(`220 6%`)으로 정렬 |
| **사이트 컬러 preset** | `frontend/styles/v5-layout.css` 라인 139~167 | shadcn `--primary/--ring/--sidebar-primary/-ring` 도 preset 따라감 |
| (사용 안 됨) | `V2InputConfigPanel.tsx` (832줄) | 잘못 수정한 옛 파일. 폐기 보류 (사용처 없음) |
---
## 2. 만든 / 수정한 파일
### 2.1 신설 (CP 프리미티브)
```
frontend/components/v2/config-panels/_shared/cp/
├── cp.css # cp 토큰 + 모든 cp-* 클래스 + 외곽 패널 override
├── icons.tsx # CP_ICONS 12종 SVG (chevron/plus/trash/grip/reset/help/link/dot/code/close/chevRight/alert)
├── CPPrimitives.tsx # CPSection / CPRow / CPStacked / CPText / CPSelect / CPTextarea
│ # CPSwitch / CPNumber / CPColor / CPSegment / CPIconBtn
├── CPChrome.tsx # CPHeader / CPTabs / CPGroup / CPBindChip / CPCrumb / CPFormatTrigger
└── index.ts # barrel export
```
핵심 import:
```ts
import "./_shared/cp/cp.css";
import { CPSection, CPRow, CPText, CPNumber, CPSwitch, CPGroup, CPCrumb, CPFormatTrigger } from "./_shared/cp";
```
### 2.2 수정
- `frontend/components/v2/config-panels/V2FieldConfigPanel.tsx` — 거의 전부 재작성
- `frontend/components/screen/panels/V2PropertiesPanel.tsx` — 외곽 섹션들 cp 패턴화
- `frontend/components/screen/StyleEditor.tsx` — Collapsible trigger cp 박스 톤
- `frontend/components/screen/ScreenDesigner.tsx` — 우측 패널 너비 state + 드래그 핸들 + 헤더 톤
- `frontend/styles/builder-ide.css` — 빌더 토큰 → 사이트 중성 회색 톤
- `frontend/styles/v5-layout.css` — preset 분기에 shadcn `--primary` / `--ring` 추가
---
## 3. 적용된 디자인 컨셉
### 3.1 cp 토큰 (cp.css 맨 위)
```css
:root {
--cp-surface: hsl(var(--card)); /* 카드면 = 사이트 카드 */
--cp-bg-subtle: hsl(var(--background)); /* 외곽 = 사이트 background, 더 진함 */
--cp-surface-hover: hsl(var(--accent));
--cp-text: hsl(var(--foreground));
--cp-text-sec: hsl(var(--foreground) / 0.78); /* 라벨 — 밝음 */
--cp-text-muted: hsl(var(--foreground) / 0.55); /* 캡션 — 가독 OK */
--cp-border: hsl(var(--border));
--cp-border-subtle: hsl(var(--foreground) / 0.06);
--cp-border-strong: hsl(var(--foreground) / 0.18);
}
```
**핵심**: cp 토큰을 직접 hex 가 아닌 사이트 shadcn 토큰의 **alias**.
- 라이트/다크 자동 분기 (사이트 토큰이 분기하니까)
- 메뉴/회사관리/빌더 어디서든 동일 색감
- preset 변경 시도 자동 (사이트 toplevel 토큰이 preset 따라가도록 v5-layout.css 도 손봄 — 3.5 참조)
### 3.2 V2FieldConfigPanel 분류 체계
```
kind = 입력 / 선택 (type 의 group 으로 자동 결정)
type
입력 : 글자(text) · 숫자(number) · 여러 줄(textarea) · 채번(numbering)
선택 : 직접 목록(select) · 카테고리(category) · 테이블 참조(entity)
format = text 안에서만 자유/이메일/전화/URL/사업자번호/통화
```
화면 구조:
```
CPCrumb (입력 | 글자 ▾) ← 좌/우 분할 brumb, 양쪽 클릭 가능
├ CPSection "① 데이터 소속" ← primary/reference/child + 테이블/컬럼
├ CPSection "② 유형별 설정" ← CPFormatTrigger + 형식별 동적 옵션
├ CPSection "③ 데이터 필터" ← entity/category 일 때만
└ CPGroup "고급 설정" ← 접이식 (선택형: 모드/복수/검색, 입력형: autoGen/마스크)
```
데이터 모델 100% 호환 — 기존 config (`fieldType, source, dataRole, options, autoGeneration` 등) 그대로 사용. `resolveFieldType()` 로 추론 + `handleFieldTypeChange()` 로 전환 시 source/inputType 동시 갱신.
### 3.3 V2PropertiesPanel 외곽 cp 패턴
**위에서 아래로 같은 그리드** (`80px 라벨 1fr 컨트롤`):
| 섹션 | cp 컴포넌트 |
|---|---|
| 크기 | `CPSection` + `CPRow × 3` (너비/높이/Z-Index) |
| 내용 (group/area) | `CPSection` + `CPRow × 2` (제목/설명) |
| 옵션 | `CPSection` + `CPRow + CPSwitch × 3` (필수/읽기/숨김) |
| 라벨 | `CPGroup` 접이 + `CPRow × 7` (텍스트/위치/간격/크기/색상/굵기/표시) |
| 조건부 표시 | `CPGroup` 접이 + ConditionalConfigPanel |
| 컴포넌트 스타일 | `CPGroup` 접이 + StyleEditor (안의 테두리/배경/텍스트도 cp 박스 톤) |
영문 헤더 → 한글 (DIMENSIONS→크기, OPTIONS→옵션, LABEL→라벨, CONTENT→내용).
### 3.4 빌더 토큰 정렬 (builder-ide.css)
기존 빌더는 `240 13%` 보라/파랑 tint 토큰 사용 → 메뉴·회사관리(`220 6%` 중성 회색)와 다른 톤.
수정:
- `.ide-builder``--background / --card / --foreground / --border / --accent / --muted / --secondary / --sidebar-*` 모두 사이트 중성 톤으로 정렬
- `--primary / --ring / --sidebar-primary / --sidebar-ring` 정의 **삭제** → 사이트 `:root` 토큰 자동 상속
### 3.5 사이트 preset 시스템 확장 (v5-layout.css)
기존 — preset 변경 시 `--v5-primary-rgb` 만 바뀜. shadcn `--primary` 는 라이트/다크 보라 고정.
추가 — 6개 preset (`purple/blue/green/orange/pink/cyan`) 라이트/다크 분기에 모두 다음 토큰 같이 정의:
```css
html[data-color="orange"]{
--v5-primary-rgb:249,115,22; ...
--primary:25 95% 53%;
--ring:25 95% 53%;
--sidebar-primary:25 95% 53%;
--sidebar-ring:25 95% 53%;
}
```
→ 이제 preset 변경 시 **사이트 전체** (메뉴/회사관리/빌더의 검색 버튼·탭·input focus·라디오·체크) 가 그 색으로 통일.
### 3.6 우측 패널 너비 드래그 핸들 (ScreenDesigner.tsx)
```
좌측 모서리 4px 핸들
├ drag → 240~600px 범위 너비 조절
├ 더블클릭 → 320px 리셋
└ localStorage `inv-right-panel-width` 자동 저장
```
---
## 4. 알려진 버그 / 함정 (다음 작업자 주의)
### 4.1 V2InputConfigPanel.tsx 는 사용 안 됨
- `frontend/components/v2/registerV2Components.ts` 에 등록되어 있지만 실제 빌더가 쓰는 registry (`lib/registry/components/v2-input/index.ts`) 는 `V2FieldConfigPanel` 을 가리킴
- 처음에 잘못 V2InputConfigPanel 을 cp 패턴으로 재작성했는데 화면 변화 없어 한참 헤맸음
- 폐기할지 보류 — 시그니처/매핑 함수 일부는 향후 단독 입력 패널에 재활용 가능
### 4.2 cp-pop 위치 — overflow:hidden 에 잘림
- `cp-crumb-split``overflow: hidden` 라 그 안에 absolute 떨어지는 팝오버는 잘림
- 해결: CPCrumb 의 jsx 에서 cp-pop 을 cp-bar 직속으로 (split 박스 밖) 이동
- 추가로 V2FieldConfigPanel wrapper 에서도 `overflow: hidden` 제거함
### 4.3 V2PropertiesPanel 안 `bg-white` 박힌 곳
- 라인 170, 1180 에 `bg-white` 가 박혀있어 다크 모드 cp 톤을 덮음
- 둘 다 제거함 (`flex h-full flex-col`)
### 4.4 빌더 외곽 톤 ≠ 패널 톤 우려
- 우측 패널 `inv-right-panel` 배경 = `cp-bg-subtle` (사이트 background, 검정)
- 안의 카드 (cp-crumb-split, input) = `cp-surface` (사이트 card, 약간 밝음)
- → 카드가 살짝 떠 보이는 입체감
- 배경 차이가 너무 약하면 사용자가 "구분 안 된다" 할 수 있으니 주의
---
## 5. 미완료 / 다음 작업 후보
### 5.1 다른 V2 패널 cp 마이그레이션 (시안의 표/조회 적용)
`components/v2/config-panels/` 에 40개 V2 패널 더 있음. 사용 빈도 높은 것부터:
- `V2TableListConfigPanel.tsx` (1497줄) — 시안 `panel-table-new` 컨셉 적용 가능
- `V2SelectConfigPanel.tsx` (847줄) — 입력에서 분리된 옛 패널 (V2FieldConfigPanel 이 이미 흡수했지만 여전히 등록되어 있을 수 있음 — 확인 필요)
- `V2DateConfigPanel.tsx` 등 type 별 패널 — 단일 패널 (V2FieldConfigPanel) 로 통합할지 결정
### 5.2 시안의 lookup (조회) 패턴 도입
시안 `panel-lookup-new` 의 핵심 = "③ 결과 매핑" (M21 다컬럼 매핑). 본 프로젝트 `V2SelectConfigPanel.tsx` 또는 새 `entity` type 안에 적용.
### 5.3 V2InputConfigPanel.tsx 처리
- 옵션 A: 폐기 (rm)
- 옵션 B: 그대로 두기
- 옵션 C: registerV2Components.ts 에서 V2FieldConfigPanel 로 교체
### 5.4 입력 필드 `format` 확장
현재 V2FieldConfigPanel 의 text format 은 6개 (자유/이메일/전화/URL/사업자번호/통화). 시안의 V2InputConfigPanel 에는 7개 (+ 마스킹, 여러 줄). textarea 는 별도 type. 통합/분리 전략 재검토 가능.
### 5.5 한 번에 더 많은 검증
- 라이트/다크 둘 다 빌더 + 메뉴/회사관리에서 시각 검증
- 6개 preset (purple/blue/green/orange/pink/cyan) 모두 회사관리·빌더 검색 버튼 색이 정확히 따라가는지
- 빌더에서 입력 필드 외 컴포넌트 (버튼, 테이블, 셀렉트 등) 클릭 시 패널이 cp 톤으로 잘 그려지는지
---
## 6. 검증 방법
```bash
cd frontend && npx tsc --noEmit 2>&1 | grep -E "v2/config-panels/(_shared|V2Field|V2Input)|V2PropertiesPanel|StyleEditor"
```
- 우리 파일 에러 = 0 이어야 함
- 다른 파일 에러는 기존부터 있던 것 (test-scenarios, ddl.ts, ButtonConfigPanel-fixed 등) → 무시
브라우저 검증:
1. `https://localhost:9772/admin/builder` 또는 화면관리 → 템플릿 편집
2. 컴포넌트 (입력 필드) 클릭 → 우측 패널 cp 톤으로 뜨는지
3. brumb 좌측 "입력/선택" 클릭 → kind 팝오버
4. brumb 우측 "글자 ▾" 클릭 → type 팝오버
5. 형식 trigger 클릭 → format 팝오버 (글자 type 일 때만)
6. 데이터 소속 segment (주/참조/하위) 작동 확인
7. 우측 패널 좌측 모서리 hover → ew-resize 커서, 드래그로 너비 조절
8. 우상단 Tweaks → 컬러 preset 변경 → 전체 사이트 색 통일 확인 (검색 버튼 등)
9. 라이트/다크 토글 둘 다 자연스러운지
---
## 7. 관련 파일 맵 (다음 세션이 빠르게 컨텍스트 잡기)
```
[디자인 시안 — 읽기 전용 참고]
~/Downloads/ui_kits/app/cli_export/standalone/
├── _shared/config-panel.css
├── _shared/config-panel-fields.jsx ← CP* 프리미티브 14종 원본
├── panel-input-new.{html,jsx} ← 입력 패널 시안
├── panel-table-new.{html,jsx} ← 표 패널 시안 (다음 작업)
└── panel-lookup-new.{html,jsx} ← 조회 패널 시안 (다음 작업)
[프로젝트 — 작업 산출물]
frontend/components/v2/config-panels/
├── _shared/cp/ ← 신설
│ ├── cp.css ← cp 토큰 + 외곽 패널 override
│ ├── icons.tsx
│ ├── CPPrimitives.tsx
│ ├── CPChrome.tsx
│ └── index.ts
├── V2FieldConfigPanel.tsx ← 본체 (입력+선택 통합)
├── V2InputConfigPanel.tsx ← 사용 안 됨, 폐기 보류
└── (V2TableListConfigPanel.tsx 등 39개 — 미작업)
frontend/components/screen/
├── panels/V2PropertiesPanel.tsx ← 외곽 (DIMENSIONS/OPTIONS/LABEL 등)
├── StyleEditor.tsx ← 컴포넌트 스타일 (테두리/배경/텍스트)
└── ScreenDesigner.tsx ← 빌더 셸 + 우측 패널 wrap + 너비 드래그
frontend/styles/
├── builder-ide.css ← 빌더 토큰 (사이트 중성 톤으로 정렬)
└── v5-layout.css ← v5 토큰 + preset 분기 (shadcn --primary 도 preset 따라감)
frontend/lib/registry/components/v2-input/index.ts
└── config_panel: V2FieldConfigPanel ← 빌더가 실제 쓰는 등록처 (기억해둘 것)
```