@leviosa-ai/detail-page-editor 0.11.8 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
1
  "use client";
2
2
 
3
3
  import { Pipette } from "lucide-react";
4
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
- import { createPortal } from "react-dom";
4
+ import { useCallback, useEffect, useMemo, useState } from "react";
5
+ import { Popover as PopoverPrimitive } from "radix-ui";
6
6
  import { useTranslation } from "react-i18next";
7
7
 
8
8
  // ── Preset palette — popular colors for cardnews/social media ──────────────
@@ -21,8 +21,6 @@ const PRESET_COLORS = [
21
21
 
22
22
  const RECENT_COLORS_KEY = "leviosa-recent-colors";
23
23
  const MAX_RECENT = 8;
24
- const POPOVER_WIDTH = 224; // w-56 = 14rem = 224px
25
- const POPOVER_HEIGHT_ESTIMATE = 320;
26
24
 
27
25
  /** Checkerboard SVG data URI for transparent backgrounds */
28
26
  export const CHECKER_BG = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Crect width='4' height='4' fill='%23ccc'/%3E%3Crect x='4' y='4' width='4' height='4' fill='%23ccc'/%3E%3Crect x='4' width='4' height='4' fill='%23fff'/%3E%3Crect y='4' width='4' height='4' fill='%23fff'/%3E%3C/svg%3E")`;
@@ -52,35 +50,6 @@ function addRecentColor(color: string): string[] {
52
50
  return recent;
53
51
  }
54
52
 
55
- /** Calculate popover position with viewport boundary clamping */
56
- function calcPopoverPos(
57
- anchorRect: DOMRect,
58
- align: "start" | "end",
59
- side: "auto" | "top" | "bottom",
60
- ): { top: number; left: number } {
61
- const vw = window.innerWidth;
62
- const vh = window.innerHeight;
63
-
64
- let top = side === "top"
65
- ? anchorRect.top - POPOVER_HEIGHT_ESTIMATE - 6
66
- : anchorRect.bottom + 6;
67
- let left = align === "end" ? anchorRect.right - POPOVER_WIDTH : anchorRect.left;
68
-
69
- // Flip upward if overflowing bottom
70
- if (side === "auto" && top + POPOVER_HEIGHT_ESTIMATE > vh) {
71
- top = anchorRect.top - POPOVER_HEIGHT_ESTIMATE - 6;
72
- }
73
- // Clamp to viewport edges
74
- if (top < 4) top = 4;
75
- if (left + POPOVER_WIDTH > vw) left = vw - POPOVER_WIDTH - 4;
76
- if (left < 4) left = 4;
77
-
78
- return { top, left };
79
- }
80
-
81
- // ── Global: close other ColorInput popovers when a new one opens ──────────
82
- const colorInputCloseCallbacks = new Set<() => void>();
83
-
84
53
  function isTransparent(v: string): boolean {
85
54
  return v === "transparent" || v === "rgba(0,0,0,0)" || v === "rgba(0, 0, 0, 0)";
86
55
  }
@@ -130,10 +99,6 @@ interface ColorInputProps {
130
99
  size?: "sm" | "md";
131
100
  /** Allow transparent color selection (default: false) */
132
101
  allowTransparent?: boolean;
133
- /** Horizontal popover alignment relative to the swatch (default: "end") */
134
- popoverAlign?: "start" | "end";
135
- /** Vertical popover placement preference (default: "auto") */
136
- popoverSide?: "auto" | "top" | "bottom";
137
102
  }
138
103
 
139
104
  /**
@@ -144,17 +109,11 @@ export function ColorInput({
144
109
  onChange,
145
110
  size = "sm",
146
111
  allowTransparent = false,
147
- popoverAlign = "end",
148
- popoverSide = "auto",
149
112
  }: ColorInputProps) {
150
113
  const { t } = useTranslation("marketing");
151
114
  const [open, setOpen] = useState(false);
152
- const [measured, setMeasured] = useState(false);
153
115
  const [hexInput, setHexInput] = useState("");
154
116
  const [recentColors, setRecentColors] = useState<string[]>([]);
155
- const containerRef = useRef<HTMLDivElement>(null);
156
- const popoverRef = useRef<HTMLDivElement>(null);
157
- const [popoverPos, setPopoverPos] = useState<{ top: number; left: number } | null>(null);
158
117
  const swatchPx = size === "md" ? "w-8 h-8" : "w-7 h-7";
159
118
 
160
119
  const isTransparentValue = isTransparent(value);
@@ -165,88 +124,12 @@ export function ColorInput({
165
124
  [isTransparentValue, value],
166
125
  );
167
126
 
168
- const closePopover = useCallback(() => setOpen(false), []);
169
-
170
- // Register/unregister close callback for mutual exclusion
127
+ // 열릴 때만 최근 색과 hex 칸을 채운다. 닫힌 채로 값이 바뀌어도 다음에 열 때 맞다.
171
128
  useEffect(() => {
172
- colorInputCloseCallbacks.add(closePopover);
173
- return () => { colorInputCloseCallbacks.delete(closePopover); };
174
- }, [closePopover]);
175
-
176
- const openPopover = useCallback(() => {
177
- // Close all other open ColorInput popovers
178
- colorInputCloseCallbacks.forEach((cb) => { if (cb !== closePopover) cb(); });
179
-
129
+ if (!open) return;
180
130
  setRecentColors(getRecentColors());
181
131
  setHexInput(isTransparentValue ? "" : safeColor);
182
- setMeasured(false);
183
- if (containerRef.current) {
184
- setPopoverPos(calcPopoverPos(containerRef.current.getBoundingClientRect(), popoverAlign, popoverSide));
185
- }
186
- setOpen(true);
187
- }, [safeColor, isTransparentValue, closePopover, popoverAlign, popoverSide]);
188
-
189
- // Track scroll & resize to reposition popover
190
- useEffect(() => {
191
- if (!open) return;
192
- const reposition = () => {
193
- if (containerRef.current) {
194
- setPopoverPos(calcPopoverPos(containerRef.current.getBoundingClientRect(), popoverAlign, popoverSide));
195
- }
196
- };
197
- // Capture phase to catch scrolls in any container (e.g. layer panel overflow-y-auto)
198
- window.addEventListener("scroll", reposition, true);
199
- window.addEventListener("resize", reposition);
200
- return () => {
201
- window.removeEventListener("scroll", reposition, true);
202
- window.removeEventListener("resize", reposition);
203
- };
204
- }, [open, popoverAlign, popoverSide]);
205
-
206
- // After popover renders, re-measure actual height and adjust position
207
- useEffect(() => {
208
- if (!open || !popoverRef.current || !containerRef.current) return;
209
- const popoverEl = popoverRef.current;
210
- const anchorRect = containerRef.current.getBoundingClientRect();
211
- const actualHeight = popoverEl.offsetHeight;
212
- const vh = window.innerHeight;
213
- const vw = window.innerWidth;
214
-
215
- let top = popoverSide === "top"
216
- ? anchorRect.top - actualHeight - 6
217
- : anchorRect.bottom + 6;
218
- let left = popoverAlign === "end" ? anchorRect.right - POPOVER_WIDTH : anchorRect.left;
219
-
220
- if (popoverSide === "auto" && top + actualHeight > vh) {
221
- top = anchorRect.top - actualHeight - 6;
222
- }
223
- if (top < 4) top = 4;
224
- if (left + POPOVER_WIDTH > vw) left = vw - POPOVER_WIDTH - 4;
225
- if (left < 4) left = 4;
226
-
227
- // eslint-disable-next-line react-hooks/set-state-in-effect
228
- setPopoverPos({ top, left });
229
- setMeasured(true);
230
- }, [open, popoverAlign, popoverSide]);
231
-
232
- // Close on outside click (check both swatch container and portal popover)
233
- useEffect(() => {
234
- if (!open) return;
235
- const handle = (e: MouseEvent | TouchEvent) => {
236
- const target = e.target as Node;
237
- if (
238
- containerRef.current && !containerRef.current.contains(target) &&
239
- popoverRef.current && !popoverRef.current.contains(target)
240
- ) {
241
- setOpen(false);
242
- }
243
- };
244
- document.addEventListener("mousedown", handle);
245
- document.addEventListener("touchstart", handle);
246
- return () => {
247
- document.removeEventListener("mousedown", handle);
248
- document.removeEventListener("touchstart", handle);
249
- };
132
+ // eslint-disable-next-line react-hooks/exhaustive-deps
250
133
  }, [open]);
251
134
 
252
135
  const selectColor = useCallback((color: string) => {
@@ -273,28 +156,38 @@ export function ColorInput({
273
156
  }, [hexInput, selectColor]);
274
157
 
275
158
  return (
276
- <div ref={containerRef} className="relative inline-block">
159
+ <PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
277
160
  {/* Swatch button */}
278
- <button
279
- type="button"
280
- onClick={() => open ? setOpen(false) : openPopover()}
281
- className={`${swatchPx} rounded-dpe-lg border-2 border-[var(--color-border)] cursor-pointer shrink-0 transition-all hover:border-[var(--color-primary)] hover:shadow-md hover:scale-105 active:scale-95`}
282
- style={{
283
- backgroundColor: isTransparentValue ? undefined : safeColor,
284
- backgroundImage: isTransparentValue ? CHECKER_BG : undefined,
285
- backgroundSize: isTransparentValue ? "8px 8px" : undefined,
286
- boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.1)",
287
- }}
288
- title={isTransparentValue ? "transparent" : safeColor}
289
- />
290
-
291
- {/* Popover — rendered via portal to escape overflow:hidden containers */}
292
- {open && popoverPos && createPortal(
293
- <div
294
- ref={popoverRef}
161
+ <PopoverPrimitive.Trigger asChild>
162
+ <button
163
+ type="button"
164
+ className={`${swatchPx} rounded-dpe-md border border-dpe-ink-200 cursor-pointer shrink-0 transition-colors hover:border-dpe-ink-400`}
165
+ style={{
166
+ backgroundColor: isTransparentValue ? undefined : safeColor,
167
+ backgroundImage: isTransparentValue ? CHECKER_BG : undefined,
168
+ backgroundSize: isTransparentValue ? "8px 8px" : undefined,
169
+ boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.1)",
170
+ }}
171
+ title={isTransparentValue ? "transparent" : safeColor}
172
+ />
173
+ </PopoverPrimitive.Trigger>
174
+
175
+ {/* 자리는 Radix 가 잡는다 — 스와치 바로 아래에 붙이고, 자리가 없으면 알아서 뒤집는다.
176
+ 예전에는 좌표를 손으로 재서 `fixed` 로 찍었는데, 스크롤되는 패널 안에서 스와치와
177
+ 어긋난 자리에 떴다.
178
+
179
+ 포털은 body 로 나가므로 편집기 뿌리 딱지를 다시 달아 준다 — 안 달면 소비자 앱이
180
+ `[data-dpe-root]` 로 좁혀 둔 색·모서리 토큰이 이 팝오버에만 안 먹어서, 각진
181
+ 인스펙터 위에 둥근 카드가 하나 뜬다. */}
182
+ <PopoverPrimitive.Portal>
183
+ <PopoverPrimitive.Content
295
184
  data-layer-panel
296
- className={`fixed z-[200] w-56 bg-[var(--color-background)] border border-[var(--color-border)] rounded-dpe-xl shadow-lg p-3 space-y-2.5 transition-opacity duration-75 ${measured ? "opacity-100" : "opacity-0 pointer-events-none"}`}
297
- style={{ top: popoverPos.top, left: popoverPos.left }}
185
+ data-dpe-root=""
186
+ side="bottom"
187
+ align="start"
188
+ sideOffset={6}
189
+ collisionPadding={8}
190
+ className="z-[200] w-56 bg-dpe-surface border border-dpe-ink-200 rounded-dpe-md shadow-[0_12px_40px_rgba(0,0,0,0.16)] p-3 space-y-2.5"
298
191
  >
299
192
  {/* Transparent + Preset palette */}
300
193
  <div className="grid grid-cols-6 gap-1.5">
@@ -302,7 +195,7 @@ export function ColorInput({
302
195
  <button
303
196
  type="button"
304
197
  onClick={() => selectColor("transparent")}
305
- className={`w-7 h-7 rounded-dpe-md border transition-transform hover:scale-110 active:scale-95 relative overflow-hidden ${
198
+ className={`w-7 h-7 rounded-dpe-md border transition-colors relative overflow-hidden ${
306
199
  isTransparentValue ? "border-[var(--color-primary)] ring-1 ring-[var(--color-primary)]" : "border-[var(--color-border)]"
307
200
  }`}
308
201
  style={{ backgroundImage: CHECKER_BG, backgroundSize: "8px 8px" }}
@@ -319,7 +212,7 @@ export function ColorInput({
319
212
  key={color}
320
213
  type="button"
321
214
  onClick={() => selectColor(color)}
322
- className={`w-7 h-7 rounded-dpe-md border transition-transform hover:scale-110 active:scale-95 ${
215
+ className={`w-7 h-7 rounded-dpe-md border transition-colors ${
323
216
  !isTransparentValue && safeColor.toUpperCase() === color ? "border-[var(--color-primary)] ring-1 ring-[var(--color-primary)]" : "border-[var(--color-border)]"
324
217
  }`}
325
218
  style={{ backgroundColor: color }}
@@ -338,7 +231,7 @@ export function ColorInput({
338
231
  key={color}
339
232
  type="button"
340
233
  onClick={() => selectColor(color)}
341
- className={`size-5 rounded-dpe-md border transition-transform hover:scale-110 active:scale-95 ${
234
+ className={`size-5 rounded-dpe-md border transition-colors ${
342
235
  !isTransparentValue && safeColor.toUpperCase() === color ? "border-[var(--color-primary)] ring-1 ring-[var(--color-primary)]" : "border-[var(--color-border)]"
343
236
  }`}
344
237
  style={{ backgroundColor: color }}
@@ -356,7 +249,7 @@ export function ColorInput({
356
249
  <button
357
250
  type="button"
358
251
  onClick={openEyeDropper}
359
- className="flex shrink-0 items-center gap-1 rounded-dpe-lg px-1.5 py-1.5 text-[11px] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-primary)]"
252
+ className="flex shrink-0 items-center gap-1 rounded-dpe-md px-1.5 py-1.5 text-[11px] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-primary)]"
360
253
  title={t("cardnews.colorInput.eyedropper")}
361
254
  >
362
255
  <Pipette className="w-3.5 h-3.5" />
@@ -369,12 +262,11 @@ export function ColorInput({
369
262
  onChange={(e) => setHexInput(e.target.value)}
370
263
  onKeyDown={(e) => e.key === "Enter" && handleHexSubmit()}
371
264
  placeholder="#000000"
372
- className="min-w-0 flex-1 rounded-dpe-lg border border-[var(--color-border)] px-2 py-1.5 text-xs font-mono focus:outline-none focus:border-[var(--color-primary)]"
265
+ className="min-w-0 flex-1 rounded-dpe-md border border-dpe-ink-200 bg-dpe-surface px-2 py-1.5 text-xs font-mono text-dpe-ink-900 focus:outline-none focus:border-dpe-ink-400"
373
266
  />
374
267
  </div>
375
- </div>,
376
- document.body
377
- )}
378
- </div>
268
+ </PopoverPrimitive.Content>
269
+ </PopoverPrimitive.Portal>
270
+ </PopoverPrimitive.Root>
379
271
  );
380
272
  }
@@ -200,6 +200,9 @@ export const CanvasContextMenu = observer(function CanvasContextMenu({
200
200
  <div
201
201
  role="menu"
202
202
  data-dp-canvas-menu
203
+ // 포털이 body 로 나가 편집기 뿌리 밖에 놓인다 — 딱지를 다시 달아야 소비자 앱이
204
+ // `[data-dpe-root]` 로 좁혀 둔 색·모서리 토큰이 이 메뉴에도 먹는다.
205
+ data-dpe-root=""
203
206
  style={{ left, top, width: MENU_WIDTH }}
204
207
  className="fixed z-[71] rounded-dpe-lg border border-dpe-ink-200 bg-dpe-surface py-1 shadow-lg"
205
208
  >
@@ -58,7 +58,8 @@ type ExportStoreLike = {
58
58
  };
59
59
 
60
60
  // 등록 플랫폼은 파일명 접미사에만 반영된다(상세페이지 폭은 750px 고정). 셀러가
61
- // 어디에 올릴지 고르며 맥락을 잡도록 노출한다.
61
+ // 어디에 올릴지 고르며 맥락을 잡도록 노출한다. 캐러셀은 인스타그램 한 곳으로만
62
+ // 나가므로 이 목록을 아예 안 띄운다 — `profile.registerPlatform` 이 그것을 정한다.
62
63
  const PLATFORMS = [
63
64
  { value: "naver", label: "네이버 스마트 스토어" },
64
65
  { value: "coupang", label: "쿠팡" },
@@ -220,7 +221,7 @@ export const DetailPageDownloadDialog = observer(function DetailPageDownloadDial
220
221
  setError(null);
221
222
  const mime = format === "png" ? "image/png" : "image/jpeg";
222
223
  const ext = format === "png" ? "png" : "jpg";
223
- const base = `${fileName}-${platform}`;
224
+ const base = profile.registerPlatform ? `${fileName}-${platform}` : fileName;
224
225
  try {
225
226
  if (format === "psd" || format === "svg" || format === "ai") {
226
227
  // 문서 JSON 기반 내보내기. ag-psd 포함 모듈은 이 시점에만 로드한다.
@@ -384,23 +385,25 @@ export const DetailPageDownloadDialog = observer(function DetailPageDownloadDial
384
385
  </Select>
385
386
  </div>
386
387
 
387
- <div className="space-y-1.5">
388
- <Label className="text-xs text-dpe-ink-500">
389
- {t("editor.registerPlatform")}
390
- </Label>
391
- <Select value={platform} onValueChange={setPlatform}>
392
- <SelectTrigger className="w-full">
393
- <SelectValue />
394
- </SelectTrigger>
395
- <SelectContent>
396
- {PLATFORMS.map((p) => (
397
- <SelectItem key={p.value} value={p.value}>
398
- {p.label}
399
- </SelectItem>
400
- ))}
401
- </SelectContent>
402
- </Select>
403
- </div>
388
+ {profile.registerPlatform && (
389
+ <div className="space-y-1.5">
390
+ <Label className="text-xs text-dpe-ink-500">
391
+ {t("editor.registerPlatform")}
392
+ </Label>
393
+ <Select value={platform} onValueChange={setPlatform}>
394
+ <SelectTrigger className="w-full">
395
+ <SelectValue />
396
+ </SelectTrigger>
397
+ <SelectContent>
398
+ {PLATFORMS.map((p) => (
399
+ <SelectItem key={p.value} value={p.value}>
400
+ {p.label}
401
+ </SelectItem>
402
+ ))}
403
+ </SelectContent>
404
+ </Select>
405
+ </div>
406
+ )}
404
407
 
405
408
  <div className="space-y-1.5">
406
409
  <Label className="text-xs text-dpe-ink-500">{t("editor.pageScope")}</Label>
@@ -1509,13 +1509,18 @@ const TextGifSection = observer(function TextGifSection({
1509
1509
  );
1510
1510
  });
1511
1511
 
1512
- /** 칸 모양 선택지. 서버 `GET /images/data-gif-effects` 의 SHAPES 와 같아야 한다. */
1513
- const CELL_SHAPES: Array<{ id: string; label: string }> = [
1514
- { id: "circle", label: "동그라미" },
1515
- { id: "square", label: "네모" },
1516
- { id: "rounded", label: "둥근 네모" },
1517
- { id: "diamond", label: "마름모" },
1518
- { id: "hexagon", label: "육각형" },
1512
+ /**
1513
+ * 칸 모양 선택지. 서버 `GET /images/data-gif-effects` 의 SHAPES 와 같아야 한다.
1514
+ *
1515
+ * 이름은 도형 패널이 이미 번역해 둔 것을 그대로 쓴다 — 같은 도형을 두 벌 번역해 두면
1516
+ * 한쪽만 언어를 타서 목록에 한국어와 영어가 섞인다. `square` 만 그쪽 이름이 `rect` 다.
1517
+ */
1518
+ const CELL_SHAPES: Array<{ id: string; labelKey: string }> = [
1519
+ { id: "circle", labelKey: "detailPage.shapes.basic.circle" },
1520
+ { id: "square", labelKey: "detailPage.shapes.basic.rect" },
1521
+ { id: "rounded", labelKey: "detailPage.shapes.basic.rounded" },
1522
+ { id: "diamond", labelKey: "detailPage.shapes.basic.diamond" },
1523
+ { id: "hexagon", labelKey: "detailPage.shapes.basic.hexagon" },
1519
1524
  ];
1520
1525
 
1521
1526
  /**
@@ -1756,7 +1761,7 @@ const CellGridGifSection = observer(function CellGridGifSection({
1756
1761
  >
1757
1762
  {CELL_SHAPES.map((option) => (
1758
1763
  <option key={option.id} value={option.id}>
1759
- {option.label}
1764
+ {t(option.labelKey)}
1760
1765
  </option>
1761
1766
  ))}
1762
1767
  </select>
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useMemo, useState } from "react";
4
4
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
5
+ import { useTranslation } from "react-i18next";
5
6
  import { BookmarkPlus, ImageOff, Loader2 } from "lucide-react";
6
7
 
7
8
  import { BrandPanelHeader } from "./detail-page-brand-panel-header";
@@ -38,6 +39,7 @@ export function DetailPageReferencesPanel({
38
39
  generatedId?: string;
39
40
  }) {
40
41
  const { api, brand, toast } = useDetailPageHost();
42
+ const { t } = useTranslation("branding");
41
43
  const queryClient = useQueryClient();
42
44
  const { activeBrand, activeBrandId, isLoading: brandsLoading } = brand.useBrandWorkspace();
43
45
  const [error, setError] = useState<string | null>(null);
@@ -62,10 +64,10 @@ export function DetailPageReferencesPanel({
62
64
  }
63
65
  return [...byGroup.entries()].map(([key, items]) => ({
64
66
  key,
65
- name: items[0]?.reference_name || "상세페이지",
67
+ name: items[0]?.reference_name || t("detailPage.references.untitled"),
66
68
  items: [...items].sort((a, b) => a.screen_index - b.screen_index),
67
69
  }));
68
- }, [listQuery.data]);
70
+ }, [listQuery.data, t]);
69
71
 
70
72
  const saveMutation = useMutation({
71
73
  mutationFn: async () => {
@@ -76,7 +78,7 @@ export function DetailPageReferencesPanel({
76
78
  onSuccess: async (result) => {
77
79
  setError(null);
78
80
  const screens = result.assets.filter((asset) => asset.role === "screen").length;
79
- toast.success(`레퍼런스로 저장했어요 (화면 ${screens}장).`);
81
+ toast.success(t("detailPage.references.saved", { count: screens }));
80
82
  await queryClient.invalidateQueries({
81
83
  queryKey: [REFERENCES_QUERY_ROOT, activeBrandId],
82
84
  });
@@ -85,7 +87,7 @@ export function DetailPageReferencesPanel({
85
87
  setError(
86
88
  err instanceof Error && err.message !== "no-instance"
87
89
  ? err.message
88
- : "레퍼런스로 저장하지 못했어요.",
90
+ : t("detailPage.references.saveFailed"),
89
91
  );
90
92
  },
91
93
  });
@@ -106,7 +108,7 @@ export function DetailPageReferencesPanel({
106
108
  ) : (
107
109
  <BookmarkPlus aria-hidden="true" size={18} />
108
110
  )}
109
- <span className="text-[11px]">이 상세페이지를 레퍼런스로 저장</span>
111
+ <span className="text-[11px]">{t("detailPage.references.saveThis")}</span>
110
112
  </button>
111
113
  ) : null}
112
114
 
@@ -119,18 +121,15 @@ export function DetailPageReferencesPanel({
119
121
  <Loader2 aria-hidden="true" className="animate-spin" size={22} />
120
122
  </div>
121
123
  ) : !activeBrand ? (
122
- <p className="text-xs text-dpe-ink-400">브랜드를 먼저 골라 주세요.</p>
124
+ <p className="text-xs text-dpe-ink-400">{t("detailPage.references.pickBrand")}</p>
123
125
  ) : listQuery.error ? (
124
126
  <p className="text-xs font-dpe-medium text-dpe-danger-600">
125
- 레퍼런스를 불러오지 못했어요.
127
+ {t("detailPage.references.loadFailed")}
126
128
  </p>
127
129
  ) : groups.length === 0 ? (
128
130
  <div className="flex flex-1 flex-col items-center justify-center gap-2 text-center text-dpe-ink-400">
129
131
  <ImageOff aria-hidden="true" size={22} />
130
- <p className="text-xs">
131
- 아직 저장한 레퍼런스가 없어요. 마음에 드는 상세페이지를 저장해 두면 다음
132
- 상품에서 바로 참고할 수 있어요.
133
- </p>
132
+ <p className="text-xs">{t("detailPage.references.empty")}</p>
134
133
  </div>
135
134
  ) : (
136
135
  <div className="space-y-4 overflow-y-auto">
@@ -138,7 +137,9 @@ export function DetailPageReferencesPanel({
138
137
  <section key={group.key}>
139
138
  <h3 className="mb-1.5 truncate text-[11px] font-dpe-medium text-dpe-ink-600">
140
139
  {group.name}
141
- <span className="ml-1 text-dpe-ink-400">{group.items.length}장</span>
140
+ <span className="ml-1 text-dpe-ink-400">
141
+ {t("detailPage.references.screenCount", { count: group.items.length })}
142
+ </span>
142
143
  </h3>
143
144
  <div className="grid grid-cols-2 gap-2">
144
145
  {group.items.map((item) => (
@@ -90,7 +90,7 @@ export function EditUsageBadge({
90
90
  if (unlimited) {
91
91
  return (
92
92
  <span
93
- className="ml-auto text-[11px] tabular-nums text-muted-foreground"
93
+ className="ml-auto text-[11px] tabular-nums text-dpe-ink-500"
94
94
  title={t("detailPage.editQuota.unlimitedTitle")}
95
95
  >
96
96
  {t("detailPage.editQuota.usedUnlimited", { used })}
@@ -104,10 +104,10 @@ export function EditUsageBadge({
104
104
  className={
105
105
  "ml-auto text-[11px] tabular-nums " +
106
106
  (remaining === 0
107
- ? "text-destructive"
107
+ ? "text-dpe-danger-600"
108
108
  : warn
109
109
  ? "text-dpe-warn-500"
110
- : "text-muted-foreground")
110
+ : "text-dpe-ink-500")
111
111
  }
112
112
  title={t("detailPage.editQuota.remainingTitle", { remaining })}
113
113
  >
@@ -130,17 +130,17 @@ export function EditQuotaBlock({
130
130
  : t("detailPage.editQuota.textEdit");
131
131
  return (
132
132
  <div className="flex flex-col items-center gap-2 px-4 py-4 text-center">
133
- <p className="text-[13px] font-dpe-medium text-foreground">
133
+ <p className="text-[13px] font-dpe-medium text-dpe-ink-900">
134
134
  {t("detailPage.editQuota.quotaExhausted", { label })}
135
135
  </p>
136
- <p className="text-[11px] leading-4 text-muted-foreground">
136
+ <p className="text-[11px] leading-4 text-dpe-ink-500">
137
137
  {t("detailPage.editQuota.quotaHint")}
138
138
  </p>
139
139
  <button
140
140
  type="button"
141
141
  onClick={onBuyMore}
142
142
  disabled={!onBuyMore}
143
- className="mt-1 inline-flex items-center gap-1.5 rounded-dpe-lg bg-primary px-3 py-1.5 text-xs font-dpe-medium text-primary-foreground disabled:opacity-40"
143
+ className="mt-1 inline-flex items-center gap-1.5 rounded-dpe-lg bg-dpe-ink-900 px-3 py-1.5 text-xs font-dpe-medium text-dpe-on-accent disabled:opacity-40"
144
144
  >
145
145
  <Sparkles size={13} />
146
146
  {t("detailPage.editQuota.buyCredits")}
@@ -38,18 +38,18 @@ export function progressPercent(progress: number): number {
38
38
 
39
39
  /**
40
40
  * 밝은 배경 패널(카피·SVG 프롬프트 편집)의 하단에 얹는 얇은 프로그레스 라인.
41
- * 테마 토큰(primary) 기반이라 라이트/다크 모두 자연스럽다. 부모는 `relative`여야
42
- * 하며, 이 요소는 컨테이너 하단에 절대배치된다.
41
+ * 색은 AI 표식 토큰(`dpe-ai`)이다 — 이 선이 도는 동안 일어나는 일이 생성이기
42
+ * 때문이다. 부모는 `relative`여야 하며, 이 요소는 컨테이너 하단에 절대배치된다.
43
43
  */
44
44
  export function GenerationProgressLine({ progress }: { progress: number }) {
45
45
  const pct = progressPercent(progress);
46
46
  return (
47
47
  <span
48
48
  aria-hidden="true"
49
- className="pointer-events-none absolute inset-x-0 bottom-0 h-[3px] bg-primary/15"
49
+ className="pointer-events-none absolute inset-x-0 bottom-0 h-[3px] bg-dpe-ai/15"
50
50
  >
51
51
  <span
52
- className="block h-full bg-primary"
52
+ className="block h-full bg-dpe-ai"
53
53
  style={{ width: `${pct}%`, transition: "width 200ms ease-out" }}
54
54
  />
55
55
  </span>
@@ -106,10 +106,10 @@ export function GroupPromptEditPanel({
106
106
  const badgeLimit = hasText ? textLimit : svgLimit;
107
107
 
108
108
  return (
109
- <div className="flex flex-col overflow-hidden rounded-dpe-xl border border-border bg-card">
110
- <div className="flex items-center gap-1.5 border-b border-border px-3 py-2">
111
- <Sparkles size={13} className="text-primary" />
112
- <span className="text-xs font-dpe-medium text-foreground">프롬프트로 편집</span>
109
+ <div className="flex flex-col overflow-hidden rounded-dpe-xl border border-dpe-ink-200 bg-dpe-surface">
110
+ <div className="flex items-center gap-1.5 border-b border-dpe-ink-200 px-3 py-2">
111
+ <Sparkles size={13} className="text-dpe-ai" />
112
+ <span className="text-xs font-dpe-medium text-dpe-ink-900">프롬프트로 편집</span>
113
113
  {unlimited || badgeLimit > 0 ? (
114
114
  <EditUsageBadge used={badgeUsed} limit={badgeLimit} unlimited={unlimited} />
115
115
  ) : null}
@@ -132,13 +132,13 @@ export function GroupPromptEditPanel({
132
132
  busy ? "수정 중…" : '어떻게 바꿀까요? (예: "더 힘있게", "톤을 통일해서")'
133
133
  }
134
134
  disabled={busy}
135
- className="max-h-32 flex-1 resize-none bg-transparent px-2 py-1.5 text-[13px] leading-5 text-foreground outline-none placeholder:text-muted-foreground/70 disabled:opacity-60"
135
+ className="max-h-32 flex-1 resize-none bg-transparent px-2 py-1.5 text-[13px] leading-5 text-dpe-ink-900 outline-none placeholder:text-dpe-ink-400 disabled:opacity-60"
136
136
  />
137
137
  <button
138
138
  type="button"
139
139
  onClick={send}
140
140
  disabled={busy || !input.trim()}
141
- className="grid h-8 w-8 shrink-0 place-items-center rounded-dpe-lg bg-primary text-primary-foreground disabled:opacity-40"
141
+ className="grid h-8 w-8 shrink-0 place-items-center rounded-dpe-lg bg-dpe-ink-900 text-dpe-on-accent disabled:opacity-40"
142
142
  aria-label="프롬프트로 수정"
143
143
  >
144
144
  {busy ? (
@@ -97,10 +97,10 @@ export function PromptEditPanel({
97
97
  }, [api, toast, input, busy, blocked, generatedId, slotRole, currentText, maxLength, renderKind, onApplied, onUsage, t]);
98
98
 
99
99
  return (
100
- <div className="relative flex flex-col overflow-hidden rounded-dpe-xl border border-border bg-card">
101
- <div className="flex items-center gap-1.5 border-b border-border px-3 py-2">
102
- <Sparkles size={13} className="text-primary" />
103
- <span className="text-xs font-dpe-medium text-foreground">{t("detailPage.promptEdit.header")}</span>
100
+ <div className="relative flex flex-col overflow-hidden rounded-dpe-xl border border-dpe-ink-200 bg-dpe-surface">
101
+ <div className="flex items-center gap-1.5 border-b border-dpe-ink-200 px-3 py-2">
102
+ <Sparkles size={13} className="text-dpe-ai" />
103
+ <span className="text-xs font-dpe-medium text-dpe-ink-900">{t("detailPage.promptEdit.header")}</span>
104
104
  {unlimited || editLimit > 0 ? (
105
105
  <EditUsageBadge used={editsUsed} limit={editLimit} unlimited={unlimited} />
106
106
  ) : null}
@@ -123,13 +123,13 @@ export function PromptEditPanel({
123
123
  busy ? t("detailPage.promptEdit.editing") : t("detailPage.promptEdit.textPlaceholder")
124
124
  }
125
125
  disabled={busy}
126
- className="max-h-32 flex-1 resize-none bg-transparent px-2 py-1.5 text-[13px] leading-5 text-foreground outline-none placeholder:text-muted-foreground/70 disabled:opacity-60"
126
+ className="max-h-32 flex-1 resize-none bg-transparent px-2 py-1.5 text-[13px] leading-5 text-dpe-ink-900 outline-none placeholder:text-dpe-ink-400 disabled:opacity-60"
127
127
  />
128
128
  <button
129
129
  type="button"
130
130
  onClick={send}
131
131
  disabled={busy || !input.trim()}
132
- className="grid h-8 w-8 shrink-0 place-items-center rounded-dpe-lg bg-primary text-primary-foreground disabled:opacity-40"
132
+ className="grid h-8 w-8 shrink-0 place-items-center rounded-dpe-lg bg-dpe-ink-900 text-dpe-on-accent disabled:opacity-40"
133
133
  aria-label={t("detailPage.promptEdit.send")}
134
134
  >
135
135
  {busy ? (
@@ -122,10 +122,10 @@ export function SpecPromptEditPanel({
122
122
  ]);
123
123
 
124
124
  return (
125
- <div className="relative flex flex-col overflow-hidden rounded-dpe-xl border border-border bg-card">
126
- <div className="flex items-center gap-1.5 border-b border-border px-3 py-2">
127
- <Sparkles size={13} className="text-primary" />
128
- <span className="text-xs font-dpe-medium text-foreground">
125
+ <div className="relative flex flex-col overflow-hidden rounded-dpe-xl border border-dpe-ink-200 bg-dpe-surface">
126
+ <div className="flex items-center gap-1.5 border-b border-dpe-ink-200 px-3 py-2">
127
+ <Sparkles size={13} className="text-dpe-ai" />
128
+ <span className="text-xs font-dpe-medium text-dpe-ink-900">
129
129
  {t("detailPage.promptEdit.header")}
130
130
  </span>
131
131
  {unlimited || editLimit > 0 ? (
@@ -152,13 +152,13 @@ export function SpecPromptEditPanel({
152
152
  : t(`detailPage.specPromptEdit.placeholder.${specKind}`)
153
153
  }
154
154
  disabled={busy}
155
- className="max-h-32 flex-1 resize-none bg-transparent px-2 py-1.5 text-[13px] leading-5 text-foreground outline-none placeholder:text-muted-foreground/70 disabled:opacity-60"
155
+ className="max-h-32 flex-1 resize-none bg-transparent px-2 py-1.5 text-[13px] leading-5 text-dpe-ink-900 outline-none placeholder:text-dpe-ink-400 disabled:opacity-60"
156
156
  />
157
157
  <button
158
158
  type="button"
159
159
  onClick={send}
160
160
  disabled={busy || !input.trim()}
161
- className="grid h-8 w-8 shrink-0 place-items-center rounded-dpe-lg bg-primary text-primary-foreground disabled:opacity-40"
161
+ className="grid h-8 w-8 shrink-0 place-items-center rounded-dpe-lg bg-dpe-ink-900 text-dpe-on-accent disabled:opacity-40"
162
162
  aria-label={t("detailPage.promptEdit.send")}
163
163
  >
164
164
  {busy ? (
@@ -170,7 +170,7 @@ export function SpecPromptEditPanel({
170
170
  </div>
171
171
  )}
172
172
  {busy ? <GenerationProgressLine progress={progress} /> : null}
173
- <p className="px-3 pb-2 text-[11px] leading-relaxed text-muted-foreground">
173
+ <p className="px-3 pb-2 text-[11px] leading-relaxed text-dpe-ink-500">
174
174
  {t("detailPage.specPromptEdit.hint")}
175
175
  </p>
176
176
  </div>
@@ -90,10 +90,10 @@ export function SvgPromptEditPanel({
90
90
  }, [api, toast, input, busy, blocked, generatedId, slotRole, currentSvg, onApplied, onUsage, t]);
91
91
 
92
92
  return (
93
- <div className="relative flex flex-col overflow-hidden rounded-dpe-xl border border-border bg-card">
94
- <div className="flex items-center gap-1.5 border-b border-border px-3 py-2">
95
- <Sparkles size={13} className="text-primary" />
96
- <span className="text-xs font-dpe-medium text-foreground">{t("detailPage.promptEdit.header")}</span>
93
+ <div className="relative flex flex-col overflow-hidden rounded-dpe-xl border border-dpe-ink-200 bg-dpe-surface">
94
+ <div className="flex items-center gap-1.5 border-b border-dpe-ink-200 px-3 py-2">
95
+ <Sparkles size={13} className="text-dpe-ai" />
96
+ <span className="text-xs font-dpe-medium text-dpe-ink-900">{t("detailPage.promptEdit.header")}</span>
97
97
  {unlimited || editLimit > 0 ? (
98
98
  <EditUsageBadge used={editsUsed} limit={editLimit} unlimited={unlimited} />
99
99
  ) : null}
@@ -116,13 +116,13 @@ export function SvgPromptEditPanel({
116
116
  busy ? t("detailPage.promptEdit.editing") : t("detailPage.svgPromptEdit.placeholder")
117
117
  }
118
118
  disabled={busy}
119
- className="max-h-32 flex-1 resize-none bg-transparent px-2 py-1.5 text-[13px] leading-5 text-foreground outline-none placeholder:text-muted-foreground/70 disabled:opacity-60"
119
+ className="max-h-32 flex-1 resize-none bg-transparent px-2 py-1.5 text-[13px] leading-5 text-dpe-ink-900 outline-none placeholder:text-dpe-ink-400 disabled:opacity-60"
120
120
  />
121
121
  <button
122
122
  type="button"
123
123
  onClick={send}
124
124
  disabled={busy || !input.trim()}
125
- className="grid h-8 w-8 shrink-0 place-items-center rounded-dpe-lg bg-primary text-primary-foreground disabled:opacity-40"
125
+ className="grid h-8 w-8 shrink-0 place-items-center rounded-dpe-lg bg-dpe-ink-900 text-dpe-on-accent disabled:opacity-40"
126
126
  aria-label={t("detailPage.promptEdit.send")}
127
127
  >
128
128
  {busy ? (
@@ -1,3 +1,16 @@
1
+ /**
2
+ * 편집기 버튼.
3
+ *
4
+ * 색은 **편집기 토큰(`dpe-*`)만** 쓴다. `bg-primary` · `text-primary-foreground` 는
5
+ * 소비자 앱의 CSS 변수를 읽는 이름이고, leviosa-agency 는 `--color-primary-foreground`
6
+ * 를 안 둔다. 그래서 글자색 클래스가 한 줄도 안 구워졌고, 글자가 본문
7
+ * (`--color-fg: #1a1a1a`)을 물려받아 호출부가 덮은 배경(`bg-dpe-ink-900`, 같은 앱에서
8
+ * `#1a1a1a`)과 정확히 같은 값이 됐다 — 다운로드 팝오버의 저장 버튼이 검은 판때기로
9
+ * 보인 이유다.
10
+ *
11
+ * 테두리도 같다. 색 없는 `border` 는 currentColor 라, 글자색이 어두우면 테두리가
12
+ * 통째로 먹으로 나온다. 색을 쓰는 자리마다 토큰을 적는다.
13
+ */
1
14
  import * as React from "react"
2
15
  import { cva, type VariantProps } from "class-variance-authority"
3
16
  import { Slot } from "radix-ui"
@@ -5,20 +18,20 @@ import { Slot } from "radix-ui"
5
18
  import { cn } from "../../lib/utils"
6
19
 
7
20
  const buttonVariants = cva(
8
- "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-dpe-md text-sm font-dpe-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
21
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-dpe-md text-sm font-dpe-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-dpe-ink-400 focus-visible:ring-dpe-ink-300/50 focus-visible:ring-[3px] aria-invalid:ring-dpe-danger-500/20 dark:aria-invalid:ring-dpe-danger-500/40 aria-invalid:border-dpe-danger-500",
9
22
  {
10
23
  variants: {
11
24
  variant: {
12
- default: "bg-primary text-primary-foreground hover:bg-primary/90",
25
+ default: "bg-dpe-ink-900 text-dpe-on-accent hover:bg-dpe-ink-900/90",
13
26
  destructive:
14
- "bg-destructive text-dpe-on-accent hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
27
+ "bg-dpe-danger-500 text-dpe-on-accent hover:bg-dpe-danger-500/90 focus-visible:ring-dpe-danger-500/20 dark:focus-visible:ring-dpe-danger-500/40 dark:bg-dpe-danger-500/60",
15
28
  outline:
16
- "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
29
+ "border border-dpe-ink-200 bg-dpe-surface text-dpe-ink-900 shadow-xs hover:bg-dpe-ink-100 dark:bg-dpe-ink-100/30 dark:hover:bg-dpe-ink-100/50",
17
30
  secondary:
18
- "bg-secondary text-secondary-foreground hover:bg-secondary/80",
31
+ "bg-dpe-ink-100 text-dpe-ink-900 hover:bg-dpe-ink-100/80",
19
32
  ghost:
20
- "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
21
- link: "text-primary underline-offset-4 hover:underline",
33
+ "hover:bg-dpe-ink-100 hover:text-dpe-ink-900 dark:hover:bg-dpe-ink-100/50",
34
+ link: "text-dpe-ink-900 underline-offset-4 hover:underline",
22
35
  },
23
36
  size: {
24
37
  default: "h-9 px-4 py-2 has-[>svg]:px-3",
@@ -1,5 +1,10 @@
1
1
  "use client";
2
2
 
3
+ /**
4
+ * 편집기 모달. 포털이 body 로 나가므로 `data-dpe-root` 를 다시 달고(아래), 판의
5
+ * 글자색과 테두리색은 물려받지 않고 적는다 — 색 없는 `border` 는 currentColor 다.
6
+ * 이유는 select.tsx 주석에 있다.
7
+ */
3
8
  import * as React from "react";
4
9
  import { XIcon } from "lucide-react";
5
10
  import { Dialog as DialogPrimitive } from "radix-ui";
@@ -63,8 +68,9 @@ function DialogContent({
63
68
  <DialogOverlay />
64
69
  <DialogPrimitive.Content
65
70
  data-slot="dialog-content"
71
+ data-dpe-root=""
66
72
  className={cn(
67
- "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[115] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-dpe-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
73
+ "bg-dpe-surface text-dpe-ink-900 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[115] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-dpe-lg border border-dpe-ink-200 p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
68
74
  className,
69
75
  )}
70
76
  onOpenAutoFocus={(event) => {
@@ -79,7 +85,7 @@ function DialogContent({
79
85
  {showCloseButton && (
80
86
  <DialogPrimitive.Close
81
87
  data-slot="dialog-close"
82
- className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-3 right-3 flex size-11 items-center justify-center rounded-full opacity-70 transition-opacity hover:opacity-100 hover:bg-accent active:bg-accent focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5"
88
+ className="ring-offset-dpe-surface focus:ring-dpe-ink-300 data-[state=open]:bg-dpe-ink-100 data-[state=open]:text-dpe-ink-500 absolute top-3 right-3 flex size-11 items-center justify-center rounded-full opacity-70 transition-opacity hover:opacity-100 hover:bg-dpe-ink-100 active:bg-dpe-ink-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5"
83
89
  >
84
90
  <XIcon />
85
91
  <span className="sr-only">Close</span>
@@ -147,7 +153,7 @@ function DialogDescription({
147
153
  return (
148
154
  <DialogPrimitive.Description
149
155
  data-slot="dialog-description"
150
- className={cn("text-muted-foreground text-sm", className)}
156
+ className={cn("text-dpe-ink-500 text-sm", className)}
151
157
  {...props}
152
158
  />
153
159
  );
@@ -1,5 +1,18 @@
1
1
  "use client"
2
2
 
3
+ /**
4
+ * Radix Select 위에 얹은 드롭다운.
5
+ *
6
+ * 색은 **편집기 토큰(`dpe-*`)만** 쓴다. shadcn 원본이 달고 온 `bg-popover` ·
7
+ * `text-popover-foreground` · `border-input` · `bg-accent` · `text-muted-foreground`
8
+ * 는 전부 소비자 앱의 팔레트를 읽는 이름이라, 이 패키지가 그 값을 정할 방법이 없다.
9
+ * leviosa-agency 는 `--color-popover` 를 아예 안 두므로 그 유틸리티가 한 줄도 안
10
+ * 구워졌고, 목록 판이 **투명해져** 뒤의 라벨과 글자가 겹쳐 보였다.
11
+ *
12
+ * 글자색은 물려받지 않고 판마다 적는다(`text-dpe-ink-900`). 이 버그가 아픈 이유가
13
+ * 정확히 "안 적으면 바깥 것을 물려받는다" 였다 — 판은 토큰으로 칠하면서 글자를
14
+ * 물려받게 두면 갈아입힌 앱에서 같은 자리에 같은 일이 다시 난다.
15
+ */
3
16
  import * as React from "react"
4
17
  import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
5
18
  import { Select as SelectPrimitive } from "radix-ui"
@@ -37,7 +50,7 @@ function SelectTrigger({
37
50
  data-slot="select-trigger"
38
51
  data-size={size}
39
52
  className={cn(
40
- "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-dpe-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
53
+ "border-dpe-ink-200 text-dpe-ink-900 data-[placeholder]:text-dpe-ink-400 [&_svg:not([class*='text-'])]:text-dpe-ink-500 focus-visible:border-dpe-ink-400 focus-visible:ring-dpe-ink-300/50 aria-invalid:ring-dpe-danger-500/20 dark:aria-invalid:ring-dpe-danger-500/40 aria-invalid:border-dpe-danger-500 dark:bg-dpe-ink-100/30 dark:hover:bg-dpe-ink-100/50 flex w-fit items-center justify-between gap-2 rounded-dpe-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
41
54
  className
42
55
  )}
43
56
  {...props}
@@ -53,16 +66,21 @@ function SelectTrigger({
53
66
  function SelectContent({
54
67
  className,
55
68
  children,
56
- position = "item-aligned",
57
- align = "center",
69
+ // 트리거 **아래**에 붙인다. Radix 기본값(`item-aligned`)은 고른 항목이 트리거를 덮게
70
+ // 얹어서, 목록이 트리거 위로 겹쳐 뜬다.
71
+ position = "popper",
72
+ align = "start",
58
73
  ...props
59
74
  }: React.ComponentProps<typeof SelectPrimitive.Content>) {
60
75
  return (
61
76
  <SelectPrimitive.Portal>
62
77
  <SelectPrimitive.Content
78
+ // 포털이 body 로 나가 편집기 뿌리 밖에 선다 — 딱지를 다시 달아야 소비자 앱이
79
+ // `[data-dpe-root]` 로 좁혀 둔 토큰이 이 목록에도 먹는다.
80
+ data-dpe-root=""
63
81
  data-slot="select-content"
64
82
  className={cn(
65
- "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-dpe-md border shadow-md",
83
+ "bg-dpe-surface text-dpe-ink-900 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-dpe-md border border-dpe-ink-200 shadow-md",
66
84
  position === "popper" &&
67
85
  "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
68
86
  className
@@ -94,7 +112,7 @@ function SelectLabel({
94
112
  return (
95
113
  <SelectPrimitive.Label
96
114
  data-slot="select-label"
97
- className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
115
+ className={cn("text-dpe-ink-500 px-2 py-1.5 text-xs", className)}
98
116
  {...props}
99
117
  />
100
118
  )
@@ -109,7 +127,7 @@ function SelectItem({
109
127
  <SelectPrimitive.Item
110
128
  data-slot="select-item"
111
129
  className={cn(
112
- "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-dpe-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
130
+ "text-dpe-ink-900 focus:bg-dpe-ink-100 [&_svg:not([class*='text-'])]:text-dpe-ink-500 relative flex w-full cursor-default items-center gap-2 rounded-dpe-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
113
131
  className
114
132
  )}
115
133
  {...props}
@@ -134,7 +152,7 @@ function SelectSeparator({
134
152
  return (
135
153
  <SelectPrimitive.Separator
136
154
  data-slot="select-separator"
137
- className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
155
+ className={cn("bg-dpe-ink-200 pointer-events-none -mx-1 my-1 h-px", className)}
138
156
  {...props}
139
157
  />
140
158
  )
@@ -1,5 +1,10 @@
1
1
  "use client"
2
2
 
3
+ /**
4
+ * 해상도 배율 같은 값 슬라이더. 궤도·채움·손잡이 전부 편집기 토큰이다 — 원본의
5
+ * `bg-muted` · `bg-primary` 는 소비자 앱의 이름이라, 그 앱이 안 두면 채운 구간이
6
+ * 통째로 안 보인다(select.tsx 주석 참고).
7
+ */
3
8
  import * as React from "react"
4
9
  import { Slider as SliderPrimitive } from "radix-ui"
5
10
 
@@ -39,13 +44,13 @@ function Slider({
39
44
  <SliderPrimitive.Track
40
45
  data-slot="slider-track"
41
46
  className={cn(
42
- "bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
47
+ "bg-dpe-ink-200 relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
43
48
  )}
44
49
  >
45
50
  <SliderPrimitive.Range
46
51
  data-slot="slider-range"
47
52
  className={cn(
48
- "bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
53
+ "bg-dpe-ink-900 absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
49
54
  )}
50
55
  />
51
56
  </SliderPrimitive.Track>
@@ -53,7 +58,7 @@ function Slider({
53
58
  <SliderPrimitive.Thumb
54
59
  data-slot="slider-thumb"
55
60
  key={index}
56
- className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-dpe-surface shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
61
+ className="border-dpe-ink-900 ring-dpe-ink-300/50 block size-4 shrink-0 rounded-full border bg-dpe-surface shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
57
62
  />
58
63
  ))}
59
64
  </SliderPrimitive.Root>
@@ -1,5 +1,12 @@
1
1
  "use client"
2
2
 
3
+ /**
4
+ * 켬/끔 스위치. 켠 색은 먹(`dpe-ink-900`), 끈 색은 `dpe-ink-300` 이다.
5
+ *
6
+ * 끈 색이 `ink-100` 이면 흰 패널(`dpe-surface`) 위에서 궤도가 거의 안 보인다 —
7
+ * 손잡이도 흰색이고 테두리는 투명이라, 꺼진 스위치가 통째로 사라진다. 한 단계
8
+ * 내려서 꺼짐이 꺼짐으로 읽히게 한다.
9
+ */
3
10
  import * as React from "react"
4
11
  import { Switch as SwitchPrimitive } from "radix-ui"
5
12
 
@@ -17,7 +24,7 @@ function Switch({
17
24
  data-slot="switch"
18
25
  data-size={size}
19
26
  className={cn(
20
- "peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
27
+ "peer data-[state=checked]:bg-dpe-ink-900 data-[state=unchecked]:bg-dpe-ink-300 focus-visible:border-dpe-ink-400 focus-visible:ring-dpe-ink-300/50 dark:data-[state=unchecked]:bg-dpe-ink-100/80 group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
21
28
  className
22
29
  )}
23
30
  {...props}
@@ -25,7 +32,7 @@ function Switch({
25
32
  <SwitchPrimitive.Thumb
26
33
  data-slot="switch-thumb"
27
34
  className={cn(
28
- "bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
35
+ "bg-dpe-surface dark:data-[state=unchecked]:bg-dpe-ink-900 dark:data-[state=checked]:bg-dpe-on-accent pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
29
36
  )}
30
37
  />
31
38
  </SwitchPrimitive.Root>
@@ -1,5 +1,12 @@
1
1
  "use client"
2
2
 
3
+ /**
4
+ * 말풍선 도움말. 어두운 판(`dpe-ink-900`) 위 `dpe-on-accent` 글자다.
5
+ *
6
+ * `text-background` 는 소비자 앱의 이름이라, 그 앱이 안 두면 클래스가 아예 안 구워지고
7
+ * 글자색이 물려받은 값(먹)으로 남는다 — 어두운 판 위 어두운 글자. 판만 토큰으로
8
+ * 옮기고 글자를 안 옮기면 오히려 원본보다 안 보인다.
9
+ */
3
10
  import * as React from "react"
4
11
  import { Tooltip as TooltipPrimitive } from "radix-ui"
5
12
 
@@ -39,16 +46,17 @@ function TooltipContent({
39
46
  return (
40
47
  <TooltipPrimitive.Portal>
41
48
  <TooltipPrimitive.Content
49
+ data-dpe-root=""
42
50
  data-slot="tooltip-content"
43
51
  sideOffset={sideOffset}
44
52
  className={cn(
45
- "z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-dpe-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
53
+ "z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-dpe-md bg-dpe-ink-900 px-3 py-1.5 text-xs text-balance text-dpe-on-accent fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
46
54
  className
47
55
  )}
48
56
  {...props}
49
57
  >
50
58
  {children}
51
- <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
59
+ <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-dpe-ink-900 fill-dpe-ink-900" />
52
60
  </TooltipPrimitive.Content>
53
61
  </TooltipPrimitive.Portal>
54
62
  )
package/i18n/en.json CHANGED
@@ -888,6 +888,16 @@
888
888
  "texts": "Rewrite every text in this group with one prompt.",
889
889
  "shapes": "Redraw every shape in this group with one prompt.",
890
890
  "both": "Rewrite the texts and redraw the shapes in this group with one prompt."
891
+ },
892
+ "references": {
893
+ "untitled": "Detail page",
894
+ "saveThis": "Save this detail page as a reference",
895
+ "saved": "Saved as a reference ({{count}} screens).",
896
+ "saveFailed": "Could not save it as a reference.",
897
+ "pickBrand": "Pick a brand first.",
898
+ "loadFailed": "Could not load your references.",
899
+ "empty": "No saved references yet. Save a detail page you like and it is ready to reference on your next product.",
900
+ "screenCount": "{{count}} screens"
891
901
  }
892
902
  }
893
903
  },
package/i18n/ko.json CHANGED
@@ -888,6 +888,16 @@
888
888
  "texts": "그룹 안 텍스트를 한 번에 프롬프트로 다시 씁니다.",
889
889
  "shapes": "그룹 안 도형을 프롬프트로 함께 수정합니다.",
890
890
  "both": "그룹 안 텍스트와 도형을 한 번에 프롬프트로 수정합니다."
891
+ },
892
+ "references": {
893
+ "untitled": "상세페이지",
894
+ "saveThis": "이 상세페이지를 레퍼런스로 저장",
895
+ "saved": "레퍼런스로 저장했어요 (화면 {{count}}장).",
896
+ "saveFailed": "레퍼런스로 저장하지 못했어요.",
897
+ "pickBrand": "브랜드를 먼저 골라 주세요.",
898
+ "loadFailed": "레퍼런스를 불러오지 못했어요.",
899
+ "empty": "아직 저장한 레퍼런스가 없어요. 마음에 드는 상세페이지를 저장해 두면 다음 상품에서 바로 참고할 수 있어요.",
900
+ "screenCount": "{{count}}장"
891
901
  }
892
902
  }
893
903
  },
@@ -10,6 +10,13 @@ export type DetailPageEditorProfile = {
10
10
  };
11
11
  maxPages: number;
12
12
  exports: readonly DetailPageEditorFormat[];
13
+ /**
14
+ * 다운로드 팝오버에 "등록 플랫폼"을 물어볼지. 그 답은 파일명 접미사에만 쓰이고,
15
+ * 셀러가 상품을 어디에 올릴지 고르며 맥락을 잡으라고 있는 것이다. 캐러셀은
16
+ * 인스타그램 한 곳으로 나가므로 물어볼 것이 없다 — 고를 수 없는 선택지를
17
+ * 남겨 두면 접미사만 거짓말을 한다(`-naver` 붙은 인스타 이미지).
18
+ */
19
+ registerPlatform: boolean;
13
20
  wording: "section" | "plate";
14
21
  };
15
22
 
@@ -17,13 +24,19 @@ const DETAIL_PAGE_PROFILE: DetailPageEditorProfile = {
17
24
  page: { width: "document", height: "auto", fixed: false },
18
25
  maxPages: Number.POSITIVE_INFINITY,
19
26
  exports: ["png", "jpeg", "psd", "ai", "svg"],
27
+ registerPlatform: true,
20
28
  wording: "section",
21
29
  };
22
30
 
23
31
  const CAROUSEL_PROFILE: DetailPageEditorProfile = {
24
32
  page: { width: 1080, height: 1350, fixed: true },
25
33
  maxPages: 10,
26
- exports: ["jpeg"],
34
+ // 상세페이지와 같은 다섯 가지다. 한때 JPG 하나로 좁혀 뒀던 것은 인스타그램이
35
+ // JPG 로 받는다는 이유였는데, 내보낸 파일이 곧장 업로드로만 가는 것이 아니다 —
36
+ // 투명 배경을 살리려면 PNG 가, 다른 도구로 넘겨 손보려면 PSD·AI·SVG 가 필요하다.
37
+ // 첫 항목이 기본값이므로 JPG 를 앞에 둬서 지금까지의 기본 동작은 그대로 둔다.
38
+ exports: ["jpeg", "png", "psd", "ai", "svg"],
39
+ registerPlatform: false,
27
40
  wording: "plate",
28
41
  };
29
42
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leviosa-ai/detail-page-editor",
3
- "version": "0.11.8",
3
+ "version": "0.12.1",
4
4
  "description": "Leviosa detail-page editor shell. Source-only: consumers compile it through Next transpilePackages. Every app coupling (sourcing server, brand store, cache keys, toast) arrives through the DetailPageHost interface, and every URL it fetches comes from configureDetailPageEditor.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -67,7 +67,7 @@
67
67
  "woff2-encoder": "2.0.0"
68
68
  },
69
69
  "peerDependencies": {
70
- "@leviosa-ai/canvas": "0.4.0",
70
+ "@leviosa-ai/canvas": "0.5.0",
71
71
  "@tanstack/react-query": "^5.100.3",
72
72
  "konva": "^10.0.0",
73
73
  "lucide-react": "^0.563.0 || ^1.0.0",