@leviosa-ai/detail-page-editor 0.2.2 → 0.3.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.
Files changed (60) hide show
  1. package/components/cardnews/color-input.tsx +9 -9
  2. package/components/detail-page/ai-generate-panel.tsx +37 -37
  3. package/components/detail-page/annotation-canvas.tsx +9 -9
  4. package/components/detail-page/annotation-dialog.tsx +18 -18
  5. package/components/detail-page/canvas-context-menu.tsx +4 -4
  6. package/components/detail-page/chart-inspector.tsx +23 -23
  7. package/components/detail-page/design-reference-picker.tsx +22 -22
  8. package/components/detail-page/detail-page-basic-shapes.tsx +4 -4
  9. package/components/detail-page/detail-page-brand-gifs-panel.tsx +10 -10
  10. package/components/detail-page/detail-page-brand-kit-panel.tsx +5 -5
  11. package/components/detail-page/detail-page-brand-panel-header.tsx +7 -4
  12. package/components/detail-page/detail-page-charts-panel.tsx +4 -4
  13. package/components/detail-page/detail-page-decorations-panel.tsx +5 -5
  14. package/components/detail-page/detail-page-download-dialog.tsx +19 -19
  15. package/components/detail-page/detail-page-editor.tsx +131 -76
  16. package/components/detail-page/detail-page-elements-panel.tsx +4 -4
  17. package/components/detail-page/detail-page-font-picker.tsx +14 -14
  18. package/components/detail-page/detail-page-history-buttons.tsx +3 -3
  19. package/components/detail-page/detail-page-host-context.tsx +63 -0
  20. package/components/detail-page/detail-page-icons-panel.tsx +13 -13
  21. package/components/detail-page/detail-page-konva-json-preview.tsx +9 -9
  22. package/components/detail-page/detail-page-layers-panel.tsx +14 -14
  23. package/components/detail-page/detail-page-my-images-panel.tsx +29 -14
  24. package/components/detail-page/detail-page-my-shapes-panel.tsx +8 -8
  25. package/components/detail-page/detail-page-page-toolbar.tsx +10 -10
  26. package/components/detail-page/detail-page-pages-panel.tsx +13 -13
  27. package/components/detail-page/detail-page-pages-timeline.tsx +4 -4
  28. package/components/detail-page/detail-page-photos-panel.tsx +13 -13
  29. package/components/detail-page/detail-page-properties-panel.tsx +55 -55
  30. package/components/detail-page/detail-page-qr-panel.tsx +13 -13
  31. package/components/detail-page/detail-page-references-panel.tsx +9 -9
  32. package/components/detail-page/detail-page-sidebar-sections.tsx +1 -1
  33. package/components/detail-page/detail-page-tables-panel.tsx +4 -4
  34. package/components/detail-page/detail-page-text-panel.tsx +4 -4
  35. package/components/detail-page/edit-quota-ui.tsx +3 -3
  36. package/components/detail-page/element-recents-strip.tsx +6 -6
  37. package/components/detail-page/fill-control.tsx +12 -12
  38. package/components/detail-page/find-replace-panel.tsx +8 -8
  39. package/components/detail-page/generation-progress-fill.tsx +4 -4
  40. package/components/detail-page/gif-effect-picker.tsx +9 -9
  41. package/components/detail-page/group-prompt-edit-panel.tsx +3 -3
  42. package/components/detail-page/inspector-controls.tsx +9 -9
  43. package/components/detail-page/panel-search-input.tsx +2 -2
  44. package/components/detail-page/prompt-edit-panel.tsx +3 -3
  45. package/components/detail-page/section-reauthor-controller.tsx +2 -2
  46. package/components/detail-page/spec-prompt-edit-panel.tsx +3 -3
  47. package/components/detail-page/svg-prompt-edit-panel.tsx +3 -3
  48. package/components/detail-page/table-inspector.tsx +24 -24
  49. package/components/ui/button.tsx +6 -6
  50. package/components/ui/command.tsx +5 -5
  51. package/components/ui/dialog.tsx +3 -3
  52. package/components/ui/label.tsx +1 -1
  53. package/components/ui/select.tsx +3 -3
  54. package/components/ui/slider.tsx +1 -1
  55. package/components/ui/tooltip.tsx +1 -1
  56. package/lib/detail-page/use-progressive-reveal.ts +82 -0
  57. package/lib/detail-page-canvas/konva-fallback.ts +10 -4
  58. package/package.json +4 -1
  59. package/styles/canvas-bridge.css +35 -0
  60. package/styles/tokens.css +103 -0
@@ -87,14 +87,14 @@ export function DetailPageFontPicker({
87
87
  />
88
88
  </span>
89
89
  {font.latinOnly ? (
90
- <span className="shrink-0 rounded border border-neutral-200 px-1 py-0.5 text-[10px] leading-none text-neutral-500">
90
+ <span className="shrink-0 rounded border border-dpe-ink-200 px-1 py-0.5 text-[10px] leading-none text-dpe-ink-500">
91
91
  {t("detailPage.properties.fontLatinOnly")}
92
92
  </span>
93
93
  ) : null}
94
94
  {loading === font.family ? (
95
95
  <LoaderCircle
96
96
  aria-label={t("detailPage.properties.fontLoading")}
97
- className="shrink-0 animate-spin text-neutral-500"
97
+ className="shrink-0 animate-spin text-dpe-ink-500"
98
98
  size={15}
99
99
  />
100
100
  ) : value === font.family ? (
@@ -116,10 +116,10 @@ export function DetailPageFontPicker({
116
116
  type="button"
117
117
  aria-label={t("detailPage.properties.chooseFont")}
118
118
  aria-expanded={open}
119
- className="flex min-w-0 items-center justify-between gap-2 rounded-md border border-neutral-200 bg-white px-2 py-1.5 text-left text-sm text-neutral-900 outline-none hover:bg-neutral-50 focus:border-neutral-400"
119
+ className="flex min-w-0 items-center justify-between gap-2 rounded-dpe-md border border-dpe-ink-200 bg-dpe-surface px-2 py-1.5 text-left text-sm text-dpe-ink-900 outline-none hover:bg-dpe-ink-50 focus:border-dpe-ink-400"
120
120
  >
121
121
  <span className="min-w-0 truncate">{selected?.label ?? value}</span>
122
- <ChevronDown aria-hidden="true" className="shrink-0 text-neutral-400" size={14} />
122
+ <ChevronDown aria-hidden="true" className="shrink-0 text-dpe-ink-400" size={14} />
123
123
  </button>
124
124
  </PopoverPrimitive.Trigger>
125
125
  <PopoverPrimitive.Portal>
@@ -128,12 +128,12 @@ export function DetailPageFontPicker({
128
128
  align="start"
129
129
  sideOffset={8}
130
130
  collisionPadding={12}
131
- className="z-[120] w-[340px] overflow-hidden rounded-lg border border-neutral-200 bg-white shadow-[0_10px_32px_rgba(0,0,0,0.14)]"
131
+ className="z-[120] w-[340px] overflow-hidden rounded-dpe-lg border border-dpe-ink-200 bg-dpe-surface shadow-[0_10px_32px_rgba(0,0,0,0.14)]"
132
132
  >
133
133
  <div
134
134
  role="group"
135
135
  aria-label={t("detailPage.properties.fontTagFilter")}
136
- className="flex flex-wrap gap-1 border-b border-neutral-100 px-2 py-2"
136
+ className="flex flex-wrap gap-1 border-b border-dpe-ink-100 px-2 py-2"
137
137
  >
138
138
  <button
139
139
  type="button"
@@ -141,8 +141,8 @@ export function DetailPageFontPicker({
141
141
  onClick={() => setTags([])}
142
142
  className={`rounded-full border px-2 py-0.5 text-[11px] leading-4 transition-colors ${
143
143
  tags.length === 0
144
- ? "border-neutral-900 bg-neutral-900 text-white"
145
- : "border-neutral-200 text-neutral-600 hover:bg-neutral-50"
144
+ ? "border-dpe-ink-900 bg-dpe-ink-900 text-dpe-on-accent"
145
+ : "border-dpe-ink-200 text-dpe-ink-600 hover:bg-dpe-ink-50"
146
146
  }`}
147
147
  >
148
148
  {t("detailPage.properties.fontTagAll")}
@@ -155,8 +155,8 @@ export function DetailPageFontPicker({
155
155
  onClick={() => toggleTag(tag)}
156
156
  className={`rounded-full border px-2 py-0.5 text-[11px] leading-4 transition-colors ${
157
157
  tags.includes(tag)
158
- ? "border-neutral-900 bg-neutral-900 text-white"
159
- : "border-neutral-200 text-neutral-600 hover:bg-neutral-50"
158
+ ? "border-dpe-ink-900 bg-dpe-ink-900 text-dpe-on-accent"
159
+ : "border-dpe-ink-200 text-dpe-ink-600 hover:bg-dpe-ink-50"
160
160
  }`}
161
161
  >
162
162
  {t(`detailPage.fontTags.${tag}`)}
@@ -202,17 +202,17 @@ export function DetailPageFontPicker({
202
202
  </CommandList>
203
203
  </Command>
204
204
  {error ? (
205
- <p role="alert" className="border-t border-red-100 bg-red-50 px-3 py-2 text-xs text-red-600">
205
+ <p role="alert" className="border-t border-dpe-danger-100 bg-dpe-danger-50 px-3 py-2 text-xs text-dpe-danger-600">
206
206
  {t("detailPage.properties.fontLoadFailed")}
207
207
  </p>
208
208
  ) : null}
209
209
  {selected ? (
210
- <div className="border-t border-neutral-100 px-3 py-2 text-[11px] leading-4 text-neutral-500">
210
+ <div className="border-t border-dpe-ink-100 px-3 py-2 text-[11px] leading-4 text-dpe-ink-500">
211
211
  <a
212
212
  href={selected.licenseUrl}
213
213
  target="_blank"
214
214
  rel="noreferrer"
215
- className="font-medium text-neutral-700 underline decoration-neutral-300 underline-offset-2"
215
+ className="font-dpe-medium text-dpe-ink-700 underline decoration-dpe-ink-300 underline-offset-2"
216
216
  >
217
217
  {t("detailPage.properties.fontLicense")}: {selected.licenseName}
218
218
  </a>
@@ -223,7 +223,7 @@ export function DetailPageFontPicker({
223
223
  ) : null}
224
224
  </div>
225
225
  ) : null}
226
- <PopoverPrimitive.Arrow className="fill-white" />
226
+ <PopoverPrimitive.Arrow className="fill-dpe-on-accent" />
227
227
  </PopoverPrimitive.Content>
228
228
  </PopoverPrimitive.Portal>
229
229
  </PopoverPrimitive.Root>
@@ -46,10 +46,10 @@ function HistoryButton({
46
46
  disabled={disabled}
47
47
  onClick={onClick}
48
48
  className={[
49
- "flex h-9 w-9 items-center justify-center rounded-md transition-colors",
49
+ "flex h-9 w-9 items-center justify-center rounded-dpe-md transition-colors",
50
50
  disabled
51
- ? "cursor-not-allowed text-neutral-300"
52
- : "text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900",
51
+ ? "cursor-not-allowed text-dpe-ink-300"
52
+ : "text-dpe-ink-600 hover:bg-dpe-ink-100 hover:text-dpe-ink-900",
53
53
  ].join(" ")}
54
54
  >
55
55
  {children}
@@ -429,6 +429,69 @@ export interface DetailPageHostSlots {
429
429
  * 도메인을 알아야 묶이므로 앱이 꽂는다. 안 꽂으면 탭 없이 브랜드 자산만 뜬다.
430
430
  */
431
431
  AuthoredImagesPanel?: ComponentType<{ store: unknown }>;
432
+
433
+ /**
434
+ * 상단 헤더를 통째로 갈아 끼운다.
435
+ *
436
+ * 색·모서리는 토큰(`tokens.css`)으로 바꾸면 되지만, "무엇이 어디에 놓이는가" 는
437
+ * 토큰이 못 바꾼다. 앱마다 헤더에 걸고 싶은 것이 다르므로 여기서 연다.
438
+ *
439
+ * 어려운 조각(되돌리기·내보내기 대화상자)은 만들어서 `parts` 로 넘긴다 — 그것까지
440
+ * 다시 만들라고 하면 슬롯이 아니라 포크가 된다.
441
+ */
442
+ EditorHeader?: ComponentType<EditorHeaderSlotProps>;
443
+
444
+ /**
445
+ * 우측 인스펙터를 감싸거나 갈아 끼운다. 기본 인스펙터는 `defaultInspector` 로 온다 —
446
+ * 자기 크롬만 두르고 속은 그대로 쓰는 것이 보통이다.
447
+ */
448
+ EditorInspector?: ComponentType<EditorInspectorSlotProps>;
449
+
450
+ /**
451
+ * 좌측 레일+패널 껍데기를 갈아 끼운다. 섹션 목록(탭·패널 컴포넌트)은 그대로 넘어가므로
452
+ * 앱은 배치만 다시 짜면 된다.
453
+ */
454
+ EditorSidebar?: ComponentType<EditorSidebarSlotProps>;
455
+ }
456
+
457
+ /** 헤더 슬롯이 받는 것. */
458
+ export interface EditorHeaderSlotProps {
459
+ /** 표시용 이름. 비어 있으면 편집기가 기본 문구를 채워서 준다. */
460
+ productName: string;
461
+ onBack?: () => void;
462
+ save: {
463
+ run: () => void;
464
+ saving: boolean;
465
+ /** 방금 저장됐다. 잠시 뒤 스스로 꺼진다. */
466
+ ok: boolean;
467
+ error: string | null;
468
+ };
469
+ /** 편집기가 만들어 주는 조각들. 자리만 정하면 된다. */
470
+ parts: {
471
+ history: ReactNode;
472
+ download: ReactNode;
473
+ /** 앱이 `headerActions` 로 넘긴 것(크레딧·알림·언어). */
474
+ actions: ReactNode;
475
+ };
476
+ }
477
+
478
+ /** 인스펙터 슬롯이 받는 것. */
479
+ export interface EditorInspectorSlotProps {
480
+ store: unknown;
481
+ defaultInspector: ReactNode;
482
+ }
483
+
484
+ /** 좌측 껍데기 슬롯이 받는 것. */
485
+ export interface EditorSidebarSlotProps {
486
+ store: unknown;
487
+ sections: ReadonlyArray<{
488
+ name: string;
489
+ Tab: ComponentType<Record<string, unknown>>;
490
+ Panel: ComponentType<{ store: unknown }>;
491
+ visibleInList?: boolean;
492
+ }>;
493
+ /** 처음 열어 둘 섹션 이름. */
494
+ defaultSection: string;
432
495
  }
433
496
 
434
497
  export interface DetailPageHost {
@@ -142,20 +142,20 @@ export function DetailPageIconsPanel({ store }: { store: unknown }) {
142
142
 
143
143
  {group === "logos" ? (
144
144
  // 파일은 CC0지만 로고 자체는 상표다. 안내를 지우면 이용 조건을 어기는 쪽으로 샌다.
145
- <p className="mx-3 mt-2 shrink-0 rounded-md bg-amber-50 px-2 py-1.5 text-[11px] leading-relaxed text-amber-800">
145
+ <p className="mx-3 mt-2 shrink-0 rounded-dpe-md bg-dpe-warn-50 px-2 py-1.5 text-[11px] leading-relaxed text-dpe-warn-800">
146
146
  {t("detailPage.icons.logoTrademark")}
147
147
  </p>
148
148
  ) : null}
149
149
 
150
150
  <div ref={scrollRef} className="mt-3 min-h-0 flex-1 overflow-y-auto px-3">
151
151
  {icons.isPending ? (
152
- <div className="flex justify-center py-6 text-neutral-400">
152
+ <div className="flex justify-center py-6 text-dpe-ink-400">
153
153
  <Loader2 aria-hidden="true" className="animate-spin" size={20} />
154
154
  </div>
155
155
  ) : icons.isError ? (
156
- <p className="text-xs text-red-600">{t("detailPage.icons.failed")}</p>
156
+ <p className="text-xs text-dpe-danger-600">{t("detailPage.icons.failed")}</p>
157
157
  ) : cells.length === 0 ? (
158
- <div className="flex flex-col items-center gap-2 py-6 text-center text-neutral-400">
158
+ <div className="flex flex-col items-center gap-2 py-6 text-center text-dpe-ink-400">
159
159
  <ImageOff aria-hidden="true" size={20} />
160
160
  <p className="text-xs">{t("detailPage.icons.empty")}</p>
161
161
  </div>
@@ -169,7 +169,7 @@ export function DetailPageIconsPanel({ store }: { store: unknown }) {
169
169
  onClick={() => insert(item, markup)}
170
170
  title={t("detailPage.shapes.insertHint")}
171
171
  aria-label={item.id}
172
- className="flex aspect-square items-center justify-center rounded-lg border border-neutral-200 p-1.5 hover:border-neutral-400"
172
+ className="flex aspect-square items-center justify-center rounded-dpe-lg border border-dpe-ink-200 p-1.5 hover:border-dpe-ink-400"
173
173
  >
174
174
  {/* eslint-disable-next-line @next/next/no-img-element */}
175
175
  <img
@@ -182,7 +182,7 @@ export function DetailPageIconsPanel({ store }: { store: unknown }) {
182
182
  </div>
183
183
  <div ref={sentinelRef} aria-hidden="true" className="h-px" />
184
184
  {isFetchingNextPage ? (
185
- <div className="flex justify-center py-3 text-neutral-400">
185
+ <div className="flex justify-center py-3 text-dpe-ink-400">
186
186
  <Loader2 aria-hidden="true" className="animate-spin" size={16} />
187
187
  </div>
188
188
  ) : hasNextPage ? (
@@ -190,13 +190,13 @@ export function DetailPageIconsPanel({ store }: { store: unknown }) {
190
190
  <button
191
191
  type="button"
192
192
  onClick={() => void fetchNextPage()}
193
- className="mt-2 w-full rounded-md border border-neutral-200 py-1.5 text-[11px] font-medium text-neutral-500 hover:bg-neutral-50"
193
+ className="mt-2 w-full rounded-dpe-md border border-dpe-ink-200 py-1.5 text-[11px] font-dpe-medium text-dpe-ink-500 hover:bg-dpe-ink-50"
194
194
  >
195
195
  {t("detailPage.icons.loadMore")}
196
196
  </button>
197
197
  ) : truncated ? (
198
198
  // 조용히 자르면 "이게 전부"로 읽힌다.
199
- <p className="mt-2 px-1 text-[11px] text-neutral-400">
199
+ <p className="mt-2 px-1 text-[11px] text-dpe-ink-400">
200
200
  {t("detailPage.icons.truncated")}
201
201
  </p>
202
202
  ) : null}
@@ -204,7 +204,7 @@ export function DetailPageIconsPanel({ store }: { store: unknown }) {
204
204
  )}
205
205
  </div>
206
206
 
207
- <p className="shrink-0 border-t border-neutral-100 px-3 py-2 text-[11px] leading-relaxed text-neutral-500">
207
+ <p className="shrink-0 border-t border-dpe-ink-100 px-3 py-2 text-[11px] leading-relaxed text-dpe-ink-500">
208
208
  {t("detailPage.icons.licenseHint")}
209
209
  </p>
210
210
  </div>
@@ -226,7 +226,7 @@ function SegmentedGroup<T extends string>({
226
226
  <div
227
227
  role="group"
228
228
  aria-label={label}
229
- className="flex flex-1 rounded-md border border-neutral-200 p-0.5"
229
+ className="flex flex-1 rounded-dpe-md border border-dpe-ink-200 p-0.5"
230
230
  >
231
231
  {options.map((option) => {
232
232
  const active = option.value === value;
@@ -236,10 +236,10 @@ function SegmentedGroup<T extends string>({
236
236
  type="button"
237
237
  aria-pressed={active}
238
238
  onClick={() => onChange(option.value)}
239
- className={`flex-1 rounded px-2 py-1 text-[11px] font-medium transition-colors ${
239
+ className={`flex-1 rounded px-2 py-1 text-[11px] font-dpe-medium transition-colors ${
240
240
  active
241
- ? "bg-neutral-900 text-white"
242
- : "text-neutral-500 hover:bg-neutral-100"
241
+ ? "bg-dpe-ink-900 text-dpe-on-accent"
242
+ : "text-dpe-ink-500 hover:bg-dpe-ink-100"
243
243
  }`}
244
244
  >
245
245
  {option.label}
@@ -415,30 +415,30 @@ export function DetailPageKonvaJsonPreview({
415
415
  const scale = 0.48;
416
416
 
417
417
  return (
418
- <div className="h-screen overflow-auto bg-neutral-100">
419
- <div className="sticky top-0 z-10 flex h-12 items-center justify-between border-b border-neutral-200 bg-white px-4">
418
+ <div className="h-screen overflow-auto bg-dpe-ink-100">
419
+ <div className="sticky top-0 z-10 flex h-12 items-center justify-between border-b border-dpe-ink-200 bg-dpe-surface px-4">
420
420
  <div>
421
- <p className="text-sm font-semibold text-neutral-950">Native Konva JSON Preview</p>
422
- <p className="text-xs text-neutral-500">
421
+ <p className="text-sm font-dpe-semibold text-dpe-ink-950">Native Konva JSON Preview</p>
422
+ <p className="text-xs text-dpe-ink-500">
423
423
  Canvas 없이 JSON을 직접 렌더링하는 최소 fallback
424
424
  </p>
425
425
  </div>
426
426
  <button
427
427
  type="button"
428
428
  onClick={() => setShowSlots((value) => !value)}
429
- className="inline-flex h-8 items-center rounded-md border border-neutral-200 px-3 text-xs font-semibold text-neutral-700"
429
+ className="inline-flex h-8 items-center rounded-dpe-md border border-dpe-ink-200 px-3 text-xs font-dpe-semibold text-dpe-ink-700"
430
430
  >
431
431
  {showSlots ? "Hide slots" : "Show slots"}
432
432
  </button>
433
433
  </div>
434
434
  <div className="mx-auto flex w-full max-w-[480px] flex-col gap-6 px-4 py-6">
435
435
  {pages.map((page, pageIndex) => (
436
- <section key={page.id} className="overflow-hidden rounded-md border border-neutral-200 bg-white shadow-sm">
437
- <div className="flex items-center justify-between border-b border-neutral-200 px-3 py-2">
438
- <span className="text-xs font-semibold text-neutral-800">
436
+ <section key={page.id} className="overflow-hidden rounded-dpe-md border border-dpe-ink-200 bg-dpe-surface shadow-sm">
437
+ <div className="flex items-center justify-between border-b border-dpe-ink-200 px-3 py-2">
438
+ <span className="text-xs font-dpe-semibold text-dpe-ink-800">
439
439
  {page.id || `page-${pageIndex + 1}`}
440
440
  </span>
441
- <span className="text-xs text-neutral-500">
441
+ <span className="text-xs text-dpe-ink-500">
442
442
  {Math.round(page.width)} x {Math.round(page.height)}
443
443
  </span>
444
444
  </div>
@@ -167,10 +167,10 @@ function IconButton({
167
167
  }}
168
168
  className={[
169
169
  "flex h-6 w-6 items-center justify-center rounded transition-colors",
170
- active ? "text-neutral-800" : "text-neutral-400",
170
+ active ? "text-dpe-ink-800" : "text-dpe-ink-400",
171
171
  disabled
172
172
  ? "cursor-not-allowed opacity-30"
173
- : "hover:bg-neutral-200/70 hover:text-neutral-800",
173
+ : "hover:bg-dpe-ink-200/70 hover:text-dpe-ink-800",
174
174
  ].join(" ")}
175
175
  >
176
176
  {children}
@@ -394,15 +394,15 @@ const LayerRow = observer(function LayerRow({
394
394
  }
395
395
  }}
396
396
  className={[
397
- "group relative flex items-center gap-1 rounded-md pr-1.5 text-neutral-700",
397
+ "group relative flex items-center gap-1 rounded-dpe-md pr-1.5 text-dpe-ink-700",
398
398
  "min-h-[30px] cursor-default select-none",
399
399
  dragging ? "opacity-40" : "",
400
- over === "inside" ? "ring-2 ring-inset ring-sky-400" : "",
400
+ over === "inside" ? "ring-2 ring-inset ring-dpe-select-400" : "",
401
401
  selected
402
- ? "bg-sky-200 text-sky-950"
402
+ ? "bg-dpe-select-200 text-dpe-select-950"
403
403
  : context.has(el.id)
404
- ? "bg-sky-50 text-sky-900 hover:bg-sky-100"
405
- : "hover:bg-neutral-100",
404
+ ? "bg-dpe-select-50 text-dpe-select-900 hover:bg-dpe-select-100"
405
+ : "hover:bg-dpe-ink-100",
406
406
  ].join(" ")}
407
407
  style={{ paddingLeft: 6 + depth * 14 }}
408
408
  >
@@ -411,7 +411,7 @@ const LayerRow = observer(function LayerRow({
411
411
  aria-hidden="true"
412
412
  data-testid={`drop-line-${over}`}
413
413
  className={[
414
- "pointer-events-none absolute inset-x-1 h-0.5 rounded-full bg-sky-500",
414
+ "pointer-events-none absolute inset-x-1 h-0.5 rounded-full bg-dpe-select-500",
415
415
  over === "before" ? "top-0" : "bottom-0",
416
416
  ].join(" ")}
417
417
  />
@@ -426,7 +426,7 @@ const LayerRow = observer(function LayerRow({
426
426
  e.stopPropagation();
427
427
  onToggle(el.id);
428
428
  }}
429
- className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-neutral-400 hover:bg-neutral-200/70 hover:text-neutral-700"
429
+ className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-dpe-ink-400 hover:bg-dpe-ink-200/70 hover:text-dpe-ink-700"
430
430
  >
431
431
  {isCollapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
432
432
  </button>
@@ -436,13 +436,13 @@ const LayerRow = observer(function LayerRow({
436
436
 
437
437
  <Icon
438
438
  size={14}
439
- className={["shrink-0", selected ? "text-sky-500" : "text-neutral-400"].join(" ")}
439
+ className={["shrink-0", selected ? "text-dpe-select-500" : "text-dpe-ink-400"].join(" ")}
440
440
  />
441
441
 
442
442
  <span
443
443
  className={[
444
444
  "flex-1 truncate text-[13px]",
445
- hidden ? "text-neutral-300 line-through" : "",
445
+ hidden ? "text-dpe-ink-300 line-through" : "",
446
446
  ].join(" ")}
447
447
  title={label}
448
448
  >
@@ -583,17 +583,17 @@ export const DetailPageLayersPanel = observer(function DetailPageLayersPanel({
583
583
 
584
584
  return (
585
585
  <div className="flex h-full min-h-0 flex-col">
586
- <div className="flex items-center justify-between px-3 py-2.5 text-[11px] font-semibold uppercase tracking-[0.06em] text-neutral-400">
586
+ <div className="flex items-center justify-between px-3 py-2.5 text-[11px] font-dpe-semibold uppercase tracking-[0.06em] text-dpe-ink-400">
587
587
  <span>{t("detailPage.layers.title")}</span>
588
588
  {/* 머리글 숫자는 **맨 위 층의 줄 수**다. 그룹을 펴면 줄은 늘지만 레이어 수는
589
589
  그대로다 — 펼침 상태에 따라 숫자가 오르내리면 그건 개수가 아니다. */}
590
- <span className="tabular-nums text-neutral-300">
590
+ <span className="tabular-nums text-dpe-ink-300">
591
591
  {rows.filter((row) => row.depth === 0).length}
592
592
  </span>
593
593
  </div>
594
594
  <div className="min-h-0 flex-1 overflow-y-auto px-1.5 pb-4">
595
595
  {rows.length === 0 ? (
596
- <p className="px-2 py-6 text-center text-xs text-neutral-400">
596
+ <p className="px-2 py-6 text-center text-xs text-dpe-ink-400">
597
597
  {t("detailPage.layers.empty")}
598
598
  </p>
599
599
  ) : (
@@ -13,6 +13,7 @@ import type {
13
13
  BrandAssetKind,
14
14
  } from "./detail-page-host-context";
15
15
  import { insertPersonalImage } from "../../lib/detail-page/insert-image";
16
+ import { useProgressiveReveal } from "../../lib/detail-page/use-progressive-reveal";
16
17
 
17
18
  const UPLOAD_ACCEPT = "image/png,image/jpeg,image/webp,image/svg+xml";
18
19
  const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
@@ -52,7 +53,7 @@ export function DetailPageMyImagesPanel({ store }: { store: unknown }) {
52
53
 
53
54
  return (
54
55
  <div className="flex h-full flex-col">
55
- <div className="flex gap-1 border-b border-neutral-200 px-4 pt-3">
56
+ <div className="flex gap-1 border-b border-dpe-ink-200 px-4 pt-3">
56
57
  <SourceTab
57
58
  label={t("detailPage.brandAssets.sourceBrand")}
58
59
  active={source === "brand"}
@@ -90,10 +91,10 @@ function SourceTab({
90
91
  onClick={onClick}
91
92
  aria-pressed={active}
92
93
  className={
93
- "-mb-px border-b-2 px-2.5 pb-2 text-[11px] font-medium " +
94
+ "-mb-px border-b-2 px-2.5 pb-2 text-[11px] font-dpe-medium " +
94
95
  (active
95
- ? "border-neutral-800 text-neutral-800"
96
- : "border-transparent text-neutral-400 hover:text-neutral-600")
96
+ ? "border-dpe-ink-800 text-dpe-ink-800"
97
+ : "border-transparent text-dpe-ink-400 hover:text-dpe-ink-600")
97
98
  }
98
99
  >
99
100
  {label}
@@ -120,6 +121,8 @@ function BrandAssetGallery({ store }: { store: unknown }) {
120
121
  staleTime: 4 * 60_000,
121
122
  });
122
123
  const items = (assetsQuery.data ?? []).filter(isImageAsset);
124
+ // 브랜드를 옮기면 다시 처음부터 센다.
125
+ const reveal = useProgressiveReveal(items.length, { resetKey: activeBrandId });
123
126
 
124
127
  const uploadMutation = useMutation({
125
128
  mutationFn: async (file: File) => {
@@ -163,7 +166,7 @@ function BrandAssetGallery({ store }: { store: unknown }) {
163
166
  <div className="flex h-full flex-col p-4">
164
167
  <BrandPanelHeader onRefresh={() => void assetsQuery.refetch()} />
165
168
 
166
- <label className="mb-3 flex h-16 cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed border-neutral-300 text-neutral-400 hover:border-neutral-400 hover:text-neutral-600">
169
+ <label className="mb-3 flex h-16 cursor-pointer flex-col items-center justify-center gap-1 rounded-dpe-md border border-dashed border-dpe-ink-300 text-dpe-ink-400 hover:border-dpe-ink-400 hover:text-dpe-ink-600">
167
170
  {uploadMutation.isPending ? (
168
171
  <Loader2 aria-hidden="true" className="animate-spin" size={18} />
169
172
  ) : (
@@ -182,24 +185,24 @@ function BrandAssetGallery({ store }: { store: unknown }) {
182
185
  </label>
183
186
 
184
187
  {brandsLoading || assetsQuery.isLoading ? (
185
- <div className="flex flex-1 items-center justify-center text-neutral-400">
188
+ <div className="flex flex-1 items-center justify-center text-dpe-ink-400">
186
189
  <Loader2 aria-hidden="true" className="animate-spin" size={22} />
187
190
  </div>
188
191
  ) : error || assetsQuery.error ? (
189
- <p className="text-xs font-medium text-red-600">
192
+ <p className="text-xs font-dpe-medium text-dpe-danger-600">
190
193
  {error ?? t("detailPage.brandAssets.loadFailed")}
191
194
  </p>
192
195
  ) : !activeBrand ? (
193
- <p className="text-xs text-neutral-400">
196
+ <p className="text-xs text-dpe-ink-400">
194
197
  {t("detailPage.brandAssets.noBrand")}
195
198
  </p>
196
199
  ) : items.length === 0 ? (
197
- <div className="flex flex-1 flex-col items-center justify-center gap-2 text-center text-neutral-400">
200
+ <div className="flex flex-1 flex-col items-center justify-center gap-2 text-center text-dpe-ink-400">
198
201
  <ImageOff aria-hidden="true" size={22} />
199
202
  <p className="text-xs">{t("detailPage.brandAssets.imagesEmpty")}</p>
200
203
  <Link
201
204
  href="/branding/moodboard"
202
- className="inline-flex items-center gap-1 text-[11px] font-medium text-neutral-600 underline underline-offset-2"
205
+ className="inline-flex items-center gap-1 text-[11px] font-dpe-medium text-dpe-ink-600 underline underline-offset-2"
203
206
  >
204
207
  {t("detailPage.brandAssets.openMoodboard")}
205
208
  <ExternalLink size={11} />
@@ -207,10 +210,11 @@ function BrandAssetGallery({ store }: { store: unknown }) {
207
210
  </div>
208
211
  ) : (
209
212
  <div className="grid grid-cols-2 gap-2 overflow-y-auto">
210
- {items.map((asset) => (
213
+ {items.slice(0, reveal.visible).map((asset) => (
211
214
  <div
212
215
  key={asset.id}
213
- className="group relative overflow-hidden rounded-md border border-neutral-200 hover:border-neutral-500"
216
+ data-dpe-part="asset-card"
217
+ className="group relative overflow-hidden rounded-dpe-md border border-dpe-ink-200 hover:border-dpe-ink-500"
214
218
  >
215
219
  <button
216
220
  type="button"
@@ -228,23 +232,34 @@ function BrandAssetGallery({ store }: { store: unknown }) {
228
232
  <img
229
233
  src={asset.download_url ?? ""}
230
234
  crossOrigin="anonymous"
235
+ loading="lazy"
236
+ decoding="async"
231
237
  alt={asset.display_name ?? asset.filename}
232
238
  className="h-24 w-full object-cover transition group-hover:opacity-90"
233
239
  />
234
240
  </button>
235
- <span className="pointer-events-none absolute bottom-1.5 left-1.5 max-w-[calc(100%-12px)] truncate rounded-sm bg-black/70 px-1.5 py-0.5 text-[10px] text-white">
241
+ <span className="pointer-events-none absolute bottom-1.5 left-1.5 max-w-[calc(100%-12px)] truncate rounded-dpe-sm bg-dpe-scrim/70 px-1.5 py-0.5 text-[10px] text-dpe-on-accent">
236
242
  {asset.display_name ?? asset.filename}
237
243
  </span>
238
244
  <button
239
245
  type="button"
240
246
  onClick={() => deleteMutation.mutate(asset)}
241
- className="absolute right-1 top-1 hidden h-6 w-6 items-center justify-center rounded-md bg-white/95 text-neutral-400 shadow-sm hover:text-red-600 group-hover:flex"
247
+ className="absolute right-1 top-1 hidden h-6 w-6 items-center justify-center rounded-dpe-md bg-dpe-surface/95 text-dpe-ink-400 shadow-sm hover:text-dpe-danger-600 group-hover:flex"
242
248
  aria-label={t("detailPage.brandAssets.delete")}
243
249
  >
244
250
  <Trash2 aria-hidden="true" size={13} />
245
251
  </button>
246
252
  </div>
247
253
  ))}
254
+ {reveal.hasMore && (
255
+ <div
256
+ ref={reveal.sentinelRef}
257
+ data-testid="brand-assets-sentinel"
258
+ className="col-span-2 flex h-10 items-center justify-center text-dpe-ink-300"
259
+ >
260
+ <Loader2 aria-hidden="true" className="animate-spin" size={16} />
261
+ </div>
262
+ )}
248
263
  </div>
249
264
  )}
250
265
  </div>
@@ -84,7 +84,7 @@ export function DetailPageMyShapesPanel({ store }: { store: unknown }) {
84
84
  <div className="flex h-full flex-col p-4">
85
85
  <BrandPanelHeader onRefresh={() => void assetsQuery.refetch()} />
86
86
 
87
- <label className="mb-3 flex h-16 cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed border-neutral-300 text-neutral-400 hover:border-neutral-400 hover:text-neutral-600">
87
+ <label className="mb-3 flex h-16 cursor-pointer flex-col items-center justify-center gap-1 rounded-dpe-md border border-dashed border-dpe-ink-300 text-dpe-ink-400 hover:border-dpe-ink-400 hover:text-dpe-ink-600">
88
88
  {uploadMutation.isPending ? (
89
89
  <Loader2 aria-hidden="true" className="animate-spin" size={18} />
90
90
  ) : (
@@ -115,19 +115,19 @@ export function DetailPageMyShapesPanel({ store }: { store: unknown }) {
115
115
  </label>
116
116
 
117
117
  {brandsLoading || assetsQuery.isLoading ? (
118
- <div className="flex flex-1 items-center justify-center text-neutral-400">
118
+ <div className="flex flex-1 items-center justify-center text-dpe-ink-400">
119
119
  <Loader2 aria-hidden="true" className="animate-spin" size={22} />
120
120
  </div>
121
121
  ) : assetsQuery.error ? (
122
- <p className="text-xs font-medium text-red-600">
122
+ <p className="text-xs font-dpe-medium text-dpe-danger-600">
123
123
  {t("detailPage.brandAssets.loadFailed")}
124
124
  </p>
125
125
  ) : !activeBrand ? (
126
- <p className="text-xs text-neutral-400">
126
+ <p className="text-xs text-dpe-ink-400">
127
127
  {t("detailPage.brandAssets.noBrand")}
128
128
  </p>
129
129
  ) : items.length === 0 ? (
130
- <div className="flex flex-1 flex-col items-center justify-center gap-2 text-center text-neutral-400">
130
+ <div className="flex flex-1 flex-col items-center justify-center gap-2 text-center text-dpe-ink-400">
131
131
  <Shapes aria-hidden="true" size={22} />
132
132
  <p className="text-xs">{t("detailPage.brandAssets.shapesEmpty")}</p>
133
133
  </div>
@@ -136,7 +136,7 @@ export function DetailPageMyShapesPanel({ store }: { store: unknown }) {
136
136
  {items.map((asset) => (
137
137
  <div
138
138
  key={asset.id}
139
- className="group relative aspect-square rounded-md border border-neutral-200 hover:border-neutral-500"
139
+ className="group relative aspect-square rounded-dpe-md border border-dpe-ink-200 hover:border-dpe-ink-500"
140
140
  >
141
141
  <button
142
142
  type="button"
@@ -146,7 +146,7 @@ export function DetailPageMyShapesPanel({ store }: { store: unknown }) {
146
146
  title={t("detailPage.brandAssets.insertHint")}
147
147
  >
148
148
  {insertingId === asset.id ? (
149
- <Loader2 className="animate-spin text-neutral-400" size={18} />
149
+ <Loader2 className="animate-spin text-dpe-ink-400" size={18} />
150
150
  ) : (
151
151
  // eslint-disable-next-line @next/next/no-img-element
152
152
  <img
@@ -160,7 +160,7 @@ export function DetailPageMyShapesPanel({ store }: { store: unknown }) {
160
160
  <button
161
161
  type="button"
162
162
  onClick={() => deleteMutation.mutate(asset)}
163
- className="absolute right-1 top-1 hidden h-6 w-6 items-center justify-center rounded-md bg-white/95 text-neutral-400 shadow-sm hover:text-red-600 group-hover:flex"
163
+ className="absolute right-1 top-1 hidden h-6 w-6 items-center justify-center rounded-dpe-md bg-dpe-surface/95 text-dpe-ink-400 shadow-sm hover:text-dpe-danger-600 group-hover:flex"
164
164
  aria-label={t("detailPage.brandAssets.delete")}
165
165
  >
166
166
  <Trash2 aria-hidden="true" size={13} />