@conscience-technology/ctds 0.1.9 → 0.1.11

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 (77) hide show
  1. package/assets/components/AIMessage/AIMessage.module.css +129 -0
  2. package/assets/components/AIMessage/AIMessage.tsx +104 -0
  3. package/assets/components/AIMessage/index.ts +2 -0
  4. package/assets/components/AttachmentChip/AttachmentChip.module.css +3 -1
  5. package/assets/components/AttachmentThumb/AttachmentThumb.module.css +13 -7
  6. package/assets/components/AttachmentThumb/AttachmentThumb.tsx +4 -1
  7. package/assets/components/Bubble/Bubble.module.css +65 -0
  8. package/assets/components/Bubble/Bubble.tsx +63 -0
  9. package/assets/components/Bubble/index.ts +2 -0
  10. package/assets/components/Button/Button.module.css +15 -2
  11. package/assets/components/Button/Button.tsx +13 -0
  12. package/assets/components/Calendar/Calendar.module.css +163 -0
  13. package/assets/components/Calendar/Calendar.tsx +323 -0
  14. package/assets/components/Calendar/index.ts +2 -0
  15. package/assets/components/ChatInput/ChatInput.module.css +82 -45
  16. package/assets/components/ChatInput/ChatInput.tsx +46 -34
  17. package/assets/components/ChatInput/ModelSelector.tsx +23 -27
  18. package/assets/components/ChatInput/index.ts +0 -1
  19. package/assets/components/CodeBlock/CodeBlock.module.css +79 -0
  20. package/assets/components/CodeBlock/CodeBlock.tsx +71 -0
  21. package/assets/components/CodeBlock/index.ts +2 -0
  22. package/assets/components/ContextMenu/ContextMenu.tsx +10 -1
  23. package/assets/components/DropdownMenu/DropdownMenu.module.css +26 -0
  24. package/assets/components/DropdownMenu/DropdownMenu.tsx +367 -0
  25. package/assets/components/DropdownMenu/index.ts +9 -0
  26. package/assets/components/EventChip/EventChip.module.css +57 -0
  27. package/assets/components/EventChip/EventChip.tsx +41 -0
  28. package/assets/components/EventChip/index.ts +2 -0
  29. package/assets/components/Icon/Icon.tsx +3 -0
  30. package/assets/components/IdentityButton/IdentityButton.module.css +119 -0
  31. package/assets/components/IdentityButton/IdentityButton.tsx +113 -0
  32. package/assets/components/IdentityButton/index.ts +6 -0
  33. package/assets/components/MenuItem/MenuItem.module.css +67 -2
  34. package/assets/components/MenuItem/MenuItem.tsx +22 -2
  35. package/assets/components/MessageActions/MessageActions.module.css +43 -0
  36. package/assets/components/MessageActions/MessageActions.tsx +94 -0
  37. package/assets/components/MessageActions/index.ts +6 -0
  38. package/assets/components/MessageQuote/MessageQuote.module.css +29 -0
  39. package/assets/components/MessageQuote/MessageQuote.tsx +33 -0
  40. package/assets/components/MessageQuote/index.ts +2 -0
  41. package/assets/components/MessageSources/MessageSources.module.css +58 -0
  42. package/assets/components/MessageSources/MessageSources.tsx +64 -0
  43. package/assets/components/MessageSources/index.ts +2 -0
  44. package/assets/components/MessageTable/MessageTable.module.css +59 -0
  45. package/assets/components/MessageTable/MessageTable.tsx +21 -0
  46. package/assets/components/MessageTable/index.ts +2 -0
  47. package/assets/components/QuotationBar/QuotationBar.module.css +2 -2
  48. package/assets/components/Sidebar/Sidebar.module.css +3 -57
  49. package/assets/components/Sidebar/Sidebar.tsx +18 -21
  50. package/assets/components/Spinner/LottiePlayer.tsx +82 -0
  51. package/assets/components/Spinner/Spinner.module.css +170 -0
  52. package/assets/components/Spinner/Spinner.tsx +117 -0
  53. package/assets/components/Spinner/index.ts +10 -0
  54. package/assets/components/Spinner/lottieColor.ts +106 -0
  55. package/assets/components/Spinner/lottieManifest.ts +100 -0
  56. package/assets/components/Tabs/Tabs.module.css +3 -3
  57. package/assets/components/UserMessage/UserMessage.module.css +41 -0
  58. package/assets/components/UserMessage/UserMessage.tsx +66 -0
  59. package/assets/components/UserMessage/index.ts +2 -0
  60. package/assets/components/_data.ts +18 -4
  61. package/assets/components/tokens.css +20 -0
  62. package/assets/prompts/components/ai-message.md +45 -0
  63. package/assets/prompts/components/bubble.md +56 -0
  64. package/assets/prompts/components/calendar.md +116 -0
  65. package/assets/prompts/components/code-block.md +35 -0
  66. package/assets/prompts/components/dropdown-menu.md +145 -0
  67. package/assets/prompts/components/event-chip.md +74 -0
  68. package/assets/prompts/components/identity-button.md +168 -0
  69. package/assets/prompts/components/message-actions.md +49 -0
  70. package/assets/prompts/components/message-quote.md +26 -0
  71. package/assets/prompts/components/message-sources.md +33 -0
  72. package/assets/prompts/components/message-table.md +27 -0
  73. package/assets/prompts/components/sidebar.md +48 -3
  74. package/assets/prompts/components/spinner.md +109 -0
  75. package/assets/prompts/components/user-message.md +45 -0
  76. package/assets/prompts/primitives/scroll.md +22 -11
  77. package/package.json +1 -1
@@ -0,0 +1,29 @@
1
+ /* MessageQuote — AIMessage 안 인용 블록. plain(그냥 글) / blockquote(왼쪽 bar 리더). */
2
+
3
+ .root {
4
+ margin: 0;
5
+ color: var(--fg);
6
+ font-size: var(--text-base);
7
+ line-height: 2;
8
+ }
9
+
10
+ .plain {
11
+ /* 그냥 글 — bar 없음 */
12
+ }
13
+
14
+ .blockquote {
15
+ position: relative;
16
+ padding-left: var(--space-4);
17
+ color: var(--fg-muted);
18
+ }
19
+ /* bar를 pseudo로 — 상·하에서 살짝 들어가 짧은 느낌 */
20
+ .blockquote::before {
21
+ content: "";
22
+ position: absolute;
23
+ left: 0;
24
+ top: var(--space-1);
25
+ bottom: var(--space-1);
26
+ width: var(--space-0_5);
27
+ background: var(--border-strong);
28
+ border-radius: var(--radius-full);
29
+ }
@@ -0,0 +1,33 @@
1
+ "use client";
2
+
3
+ import type { HTMLAttributes, ReactNode } from "react";
4
+ import styles from "./MessageQuote.module.css";
5
+
6
+ export type MessageQuoteVariant = "plain" | "blockquote";
7
+
8
+ export interface MessageQuoteProps extends HTMLAttributes<HTMLQuoteElement> {
9
+ children: ReactNode;
10
+ /** plain — 그냥 글. blockquote — 왼쪽 vertical bar 리더. 기본 blockquote. */
11
+ variant?: MessageQuoteVariant;
12
+ }
13
+
14
+ export function MessageQuote({
15
+ children,
16
+ variant = "blockquote",
17
+ className,
18
+ ...rest
19
+ }: MessageQuoteProps) {
20
+ const cls = [
21
+ styles.root,
22
+ variant === "blockquote" ? styles.blockquote : styles.plain,
23
+ className,
24
+ ]
25
+ .filter(Boolean)
26
+ .join(" ");
27
+
28
+ return (
29
+ <blockquote className={cls} {...rest}>
30
+ {children}
31
+ </blockquote>
32
+ );
33
+ }
@@ -0,0 +1,2 @@
1
+ export { MessageQuote } from "./MessageQuote";
2
+ export type { MessageQuoteProps, MessageQuoteVariant } from "./MessageQuote";
@@ -0,0 +1,58 @@
1
+ /* MessageSources — AIMessage 액션바 inline chip.
2
+ [□□□ 겹친 favicon] Sources +N 형태. 작고 가벼운 chip. */
3
+
4
+ .root {
5
+ display: inline-flex;
6
+ align-items: center;
7
+ gap: var(--space-1);
8
+ padding: var(--space-0_5) var(--space-2) var(--space-0_5) var(--space-1);
9
+ border: 0;
10
+ background: transparent;
11
+ color: var(--fg-muted);
12
+ border-radius: var(--radius-full);
13
+ cursor: pointer;
14
+ font-size: var(--text-xs);
15
+ line-height: 1;
16
+ transition: var(--transition-colors);
17
+ }
18
+ .root:hover {
19
+ background: var(--bg-subtle);
20
+ color: var(--fg-strong);
21
+ }
22
+ .root:focus-visible {
23
+ outline: none;
24
+ box-shadow: var(--ring);
25
+ }
26
+
27
+ /* favicon stack — 서로 살짝 겹침 (negative margin).
28
+ 각 favicon은 box-shadow로 bg와 같은 cutout 링을 둘러 겹침 경계를 명확히. */
29
+ .stack {
30
+ display: inline-flex;
31
+ align-items: center;
32
+ }
33
+ .favicon {
34
+ display: inline-flex;
35
+ align-items: center;
36
+ justify-content: center;
37
+ width: var(--space-4);
38
+ height: var(--space-4);
39
+ border-radius: var(--radius-full);
40
+ background: var(--bg-muted);
41
+ box-shadow: 0 0 0 var(--hairline) var(--bg);
42
+ overflow: hidden;
43
+ margin-left: calc(var(--space-1) * -1);
44
+ flex: 0 0 auto;
45
+ }
46
+ .favicon:first-child {
47
+ margin-left: 0;
48
+ }
49
+
50
+ .label {
51
+ display: inline-flex;
52
+ align-items: baseline;
53
+ gap: var(--space-0_5);
54
+ }
55
+ .extra {
56
+ color: var(--fg-muted);
57
+ font-variant-numeric: tabular-nums;
58
+ }
@@ -0,0 +1,64 @@
1
+ "use client";
2
+
3
+ import type { HTMLAttributes, MouseEvent, ReactNode } from "react";
4
+ import styles from "./MessageSources.module.css";
5
+
6
+ export interface MessageSource {
7
+ id: string;
8
+ label: string;
9
+ href?: string;
10
+ /** 왼쪽 favicon. 없으면 중성 원형 placeholder. */
11
+ icon?: ReactNode;
12
+ }
13
+
14
+ export interface MessageSourcesProps extends HTMLAttributes<HTMLButtonElement> {
15
+ sources: MessageSource[];
16
+ /** 라벨 override. 기본 "Sources". */
17
+ label?: string;
18
+ /** 겹쳐 보여줄 favicon 최대 개수. 기본 3. */
19
+ maxFavicons?: number;
20
+ /** chip 클릭 시 (popover 열기 등). 외부에서 처리. */
21
+ onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
22
+ }
23
+
24
+ /** AIMessage의 MessageActions 바 옆에 inline으로 들어가는 작은 chip.
25
+ * [겹친 favicon stack] + 라벨 (+N) 형태. 클릭 시 popover 등은 소비처가 조립. */
26
+ export function MessageSources({
27
+ sources,
28
+ label = "Sources",
29
+ maxFavicons = 3,
30
+ onClick,
31
+ className,
32
+ ...rest
33
+ }: MessageSourcesProps) {
34
+ const visible = sources.slice(0, maxFavicons);
35
+ const extra = sources.length - visible.length;
36
+
37
+ const cls = [styles.root, className].filter(Boolean).join(" ");
38
+
39
+ return (
40
+ <button
41
+ type="button"
42
+ className={cls}
43
+ onClick={onClick}
44
+ aria-label={`${label} (${sources.length})`}
45
+ {...rest}
46
+ >
47
+ <span className={styles.stack} aria-hidden>
48
+ {visible.map((s, i) => (
49
+ <span
50
+ key={s.id}
51
+ className={styles.favicon}
52
+ style={{ ["--stack-i" as string]: String(i) }}
53
+ >
54
+ {s.icon ?? null}
55
+ </span>
56
+ ))}
57
+ </span>
58
+ <span className={styles.label}>
59
+ {label}
60
+ {extra > 0 ? <span className={styles.extra}> +{extra}</span> : null}
61
+ </span>
62
+ </button>
63
+ );
64
+ }
@@ -0,0 +1,2 @@
1
+ export { MessageSources } from "./MessageSources";
2
+ export type { MessageSourcesProps, MessageSource } from "./MessageSources";
@@ -0,0 +1,59 @@
1
+ /* MessageTable — AIMessage 안 markdown table. TableHeader/TableRow의 line variant 톤에 맞춤:
2
+ 외곽 border 없음, header도 bg 없이 하단 hairline만. row도 하단 hairline만.
3
+ wrapper는 가로 overflow 시 scroll. */
4
+
5
+ .root {
6
+ width: 100%;
7
+ overflow-x: auto;
8
+ }
9
+
10
+ .root :global(table) {
11
+ width: 100%;
12
+ border-collapse: collapse;
13
+ font-size: var(--text-sm);
14
+ color: var(--fg);
15
+ table-layout: auto;
16
+ }
17
+
18
+ /* header — bg 투명, 하단 hairline만 */
19
+ .root :global(thead tr) {
20
+ border-bottom: var(--hairline) solid var(--border);
21
+ }
22
+ .root :global(th) {
23
+ color: var(--fg-strong);
24
+ font-weight: var(--font-weight-medium);
25
+ text-align: left;
26
+ padding: var(--space-2) var(--space-2);
27
+ line-height: var(--label-sm-line-height);
28
+ font-size: var(--label-sm-size);
29
+ }
30
+
31
+ /* 첫 셀 좌측 padding 0, 마지막 셀 우측 padding 0 — TableHeader.line과 짝 */
32
+ .root :global(thead th:first-child),
33
+ .root :global(tbody td:first-child) {
34
+ padding-left: 0;
35
+ }
36
+ .root :global(thead th:last-child),
37
+ .root :global(tbody td:last-child) {
38
+ padding-right: 0;
39
+ }
40
+
41
+ /* row — 하단 hairline만, 배경 투명 */
42
+ .root :global(tbody tr) {
43
+ border-bottom: var(--hairline) solid var(--border);
44
+ }
45
+ .root :global(tbody tr:last-child) {
46
+ border-bottom: 0;
47
+ }
48
+ .root :global(td) {
49
+ padding: var(--space-2) var(--space-2);
50
+ text-align: left;
51
+ vertical-align: top;
52
+ line-height: 1.5;
53
+ }
54
+
55
+ /* 숫자 셀도 좌측 정렬 유지 (feedback-no-right-align-numbers), 숫자 폭 균일 */
56
+ .root :global(td),
57
+ .root :global(th) {
58
+ font-variant-numeric: tabular-nums;
59
+ }
@@ -0,0 +1,21 @@
1
+ "use client";
2
+
3
+ import type { HTMLAttributes, ReactNode } from "react";
4
+ import styles from "./MessageTable.module.css";
5
+
6
+ export interface MessageTableProps extends HTMLAttributes<HTMLDivElement> {
7
+ /** children에 <table>...</table> 전달. CTDS가 톤만 입힘. */
8
+ children: ReactNode;
9
+ }
10
+
11
+ /** AIMessage 안의 markdown table을 "AI 응답 톤"으로 감싸는 wrapper.
12
+ * 소비처는 <table><thead><tr><th>…</th></tr></thead><tbody>…</tbody></table> 그대로 전달.
13
+ * wrapper가 가로 스크롤·hairline·padding 처리. */
14
+ export function MessageTable({ children, className, ...rest }: MessageTableProps) {
15
+ const cls = [styles.root, className].filter(Boolean).join(" ");
16
+ return (
17
+ <div className={cls} {...rest}>
18
+ {children}
19
+ </div>
20
+ );
21
+ }
@@ -0,0 +1,2 @@
1
+ export { MessageTable } from "./MessageTable";
2
+ export type { MessageTableProps } from "./MessageTable";
@@ -7,8 +7,8 @@
7
7
  gap: var(--space-2);
8
8
  min-width: 0;
9
9
  padding: var(--space-2) var(--space-2) var(--space-2) var(--space-3);
10
- background: var(--bg);
11
- border: var(--hairline) solid var(--border);
10
+ background: var(--bg-subtle);
11
+ border: 0;
12
12
  border-radius: var(--radius-md);
13
13
  color: var(--fg);
14
14
  font-size: var(--text-sm);
@@ -137,76 +137,22 @@
137
137
  }
138
138
 
139
139
  /* Profile ──────────────────────────────────────────────
140
- Footer 내부 사용자 식별 영역. Avatar(lg)+2줄 텍스트.
141
- 좌우 음수 margin으로 footer padding-inline(--space-4) 상쇄 → hover 영역이
142
- 사이드바 좌/우 끝까지 꽉 참. 안쪽 콘텐츠는 padding-inline으로 복원. */
140
+ IdentityButton의 preset 래퍼. 음수 margin으로 Footer padding-inline(--space-4) 상쇄 →
141
+ hover 영역이 사이드바 좌/우 끝까지 꽉 참. 안쪽 콘텐츠는 IdentityButton 자체 padding으로 복원.
142
+ radius 0은 "사이드바에 꽉 찬 바닥 블록" 느낌을 유지하기 위함. */
143
143
  .profile {
144
- display: flex;
145
- align-items: center;
146
- gap: var(--space-2);
147
144
  margin-inline: calc(var(--space-4) * -1);
148
145
  padding: var(--space-2) var(--space-4);
149
146
  border-radius: 0;
150
- min-width: 0;
151
147
  width: auto;
152
- cursor: pointer;
153
- transition: var(--transition-colors);
154
- }
155
-
156
- .profile:hover {
157
- background: var(--hover);
158
- }
159
-
160
- .profile:focus-visible {
161
- outline: none;
162
- background: var(--hover);
163
148
  }
164
149
 
165
150
  .profile[data-collapsed] {
166
- justify-content: center;
167
- gap: 0;
168
151
  /* collapsed root padding(--space-2)에 맞춰 margin도 축소. */
169
152
  margin-inline: calc(var(--space-2) * -1);
170
153
  padding-inline: var(--space-2);
171
154
  }
172
155
 
173
- .profileBody {
174
- display: flex;
175
- flex-direction: column;
176
- justify-content: center;
177
- min-width: 0;
178
- flex: 1 1 auto;
179
- }
180
-
181
- .profileName {
182
- font-family: var(--label-font);
183
- font-size: var(--label-sm-size);
184
- letter-spacing: var(--label-tracking);
185
- line-height: var(--lh-xs);
186
- font-weight: var(--font-weight-medium);
187
- color: var(--fg);
188
- white-space: nowrap;
189
- overflow: hidden;
190
- text-overflow: ellipsis;
191
- }
192
-
193
- .profileSub {
194
- font-family: var(--label-font);
195
- font-size: var(--text-xs);
196
- line-height: var(--lh-xs);
197
- font-weight: var(--font-weight-regular);
198
- color: var(--fg-muted);
199
- white-space: nowrap;
200
- overflow: hidden;
201
- text-overflow: ellipsis;
202
- }
203
-
204
- .profileTrailing {
205
- display: inline-flex;
206
- align-items: center;
207
- flex: 0 0 auto;
208
- }
209
-
210
156
  /* Section ────────────────────────────────────────────
211
157
  스크롤 트랙(.body) 안쪽의 "items" 레이어. 좌우 padding은 여기가 담당.
212
158
  .body 자체에 두면 스크롤바가 padding 안쪽으로 밀려 좌/우 끝에 안 붙음. */
@@ -8,7 +8,8 @@ import {
8
8
  type ReactNode,
9
9
  } from "react";
10
10
 
11
- import { Avatar, type AvatarProps } from "../Avatar";
11
+ import { type AvatarProps } from "../Avatar";
12
+ import { IdentityButton } from "../IdentityButton";
12
13
  import { MenuItem, type MenuItemAction } from "../MenuItem";
13
14
  import styles from "./Sidebar.module.css";
14
15
 
@@ -98,16 +99,17 @@ function SidebarFooter({ children, className, ...rest }: HTMLAttributes<HTMLDivE
98
99
 
99
100
  /* Profile ──────────────────────────────────────────────
100
101
  사이드바 하단 사용자 식별 영역.
101
- Avatar(size=md) + name(1행) + sub(1행) 조합.
102
- collapsed일 땐 Avatar만 노출(aria-label로 접근성 유지).
102
+ [[components/identity-button]]의 preset 래퍼 — Avatar + 2줄 라벨 + trailing 뼈대 공유.
103
103
  사이즈·간격·폰트는 MenuItem md 리듬에 맞춰 통일 — 인라인 스타일로 조립 금지. */
104
- export interface SidebarProfileProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
104
+ export interface SidebarProfileProps {
105
105
  name: string;
106
106
  sub?: ReactNode;
107
107
  /** Avatar에 그대로 패스스루. src/initials/icon/status 등 자유롭게. */
108
108
  avatar?: Omit<AvatarProps, "size" | "name" | "alt">;
109
109
  /** 오른쪽 끝 보조 UI(IconButton 등). collapsed일 땐 숨김. */
110
110
  trailing?: ReactNode;
111
+ className?: string;
112
+ onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
111
113
  }
112
114
 
113
115
  function SidebarProfile({
@@ -116,27 +118,22 @@ function SidebarProfile({
116
118
  avatar,
117
119
  trailing,
118
120
  className,
119
- ...rest
121
+ onClick,
120
122
  }: SidebarProfileProps) {
121
123
  const { collapsed } = useSidebar();
122
124
  return (
123
- <div
125
+ <IdentityButton
126
+ size="md"
127
+ variant="ghost"
128
+ avatar={avatar}
129
+ name={name}
130
+ sub={sub}
131
+ trailing={trailing}
132
+ collapsed={collapsed}
124
133
  className={[styles.profile, className ?? ""].filter(Boolean).join(" ")}
125
- data-collapsed={collapsed || undefined}
126
- title={collapsed ? name : undefined}
127
- {...rest}
128
- >
129
- <Avatar size="md" name={name} alt={name} {...avatar} />
130
- {!collapsed && (
131
- <div className={styles.profileBody}>
132
- <span className={styles.profileName}>{name}</span>
133
- {sub && <span className={styles.profileSub}>{sub}</span>}
134
- </div>
135
- )}
136
- {!collapsed && trailing && (
137
- <div className={styles.profileTrailing}>{trailing}</div>
138
- )}
139
- </div>
134
+ aria-label={collapsed ? name : undefined}
135
+ onClick={onClick}
136
+ />
140
137
  );
141
138
  }
142
139
 
@@ -0,0 +1,82 @@
1
+ "use client";
2
+
3
+ /**
4
+ * LottiePlayer — Spinner가 lottie-* variant일 때 내부에서 쓰는 플레이어.
5
+ *
6
+ * - 서버 사이드에서는 아무것도 렌더 안 함 (lottie-react는 client-only).
7
+ * - fetch로 /lottie/<name>.json 로드 → 캐시 (모듈 레벨).
8
+ * - 매니페스트의 colorBind=true면 바깥 요소 computed color로 recolor.
9
+ */
10
+
11
+ import { useEffect, useRef, useState, type CSSProperties } from "react";
12
+ import { Lottie } from "lottie-react";
13
+ import { LOTTIE_MANIFEST, type LottieName } from "./lottieManifest";
14
+ import { parseCssColor, recolorLottie } from "./lottieColor";
15
+ import styles from "./Spinner.module.css";
16
+
17
+ /** JSON 캐시 — 같은 로티를 여러 자리에서 쓸 때 fetch 1회만. */
18
+ const cache = new Map<LottieName, unknown>();
19
+ const inflight = new Map<LottieName, Promise<unknown>>();
20
+
21
+ async function loadLottie(name: LottieName): Promise<unknown> {
22
+ if (cache.has(name)) return cache.get(name);
23
+ if (inflight.has(name)) return inflight.get(name)!;
24
+ const p = fetch(LOTTIE_MANIFEST[name].path)
25
+ .then((r) => r.json())
26
+ .then((json) => {
27
+ cache.set(name, json);
28
+ inflight.delete(name);
29
+ return json;
30
+ });
31
+ inflight.set(name, p);
32
+ return p;
33
+ }
34
+
35
+ export type LottiePlayerProps = {
36
+ name: LottieName;
37
+ className?: string;
38
+ };
39
+
40
+ export function LottiePlayer({ name, className }: LottiePlayerProps) {
41
+ const entry = LOTTIE_MANIFEST[name];
42
+ const [data, setData] = useState<unknown>(null);
43
+ const containerRef = useRef<HTMLDivElement>(null);
44
+
45
+ useEffect(() => {
46
+ let cancelled = false;
47
+ loadLottie(name).then((raw) => {
48
+ if (cancelled) return;
49
+ if (!entry.colorBind) {
50
+ setData(raw);
51
+ return;
52
+ }
53
+ // 바깥 요소의 computed color를 읽어 재색상화
54
+ const el = containerRef.current;
55
+ const css = el ? getComputedStyle(el).color : "rgb(0,0,0)";
56
+ const rgb = parseCssColor(css) ?? [0, 0, 0];
57
+ setData(recolorLottie(raw, rgb));
58
+ });
59
+ return () => {
60
+ cancelled = true;
61
+ };
62
+ }, [name, entry.colorBind]);
63
+
64
+ // offset은 bounds(--loader-size) 비율 기준. 스케일된 자기 요소 % 혼동을 피하려고 calc로 묶음.
65
+ const style = {
66
+ ...(entry.visualScale ? { "--lottie-scale": entry.visualScale } : {}),
67
+ ...(entry.offsetX
68
+ ? { "--lottie-offset-x": `calc(var(--loader-size) * ${entry.offsetX})` }
69
+ : {}),
70
+ ...(entry.offsetY
71
+ ? { "--lottie-offset-y": `calc(var(--loader-size) * ${entry.offsetY})` }
72
+ : {}),
73
+ } as CSSProperties;
74
+
75
+ return (
76
+ <div ref={containerRef} className={className} style={style}>
77
+ {data ? (
78
+ <Lottie src={data} loop autoplay className={styles.lottieFill} />
79
+ ) : null}
80
+ </div>
81
+ );
82
+ }