@conscience-technology/ctds 0.1.8 → 0.1.10

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 (78) hide show
  1. package/assets/components/AIMessage/AIMessage.module.css +117 -0
  2. package/assets/components/AIMessage/AIMessage.tsx +90 -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 +81 -41
  16. package/assets/components/ChatInput/ChatInput.tsx +45 -32
  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 +18 -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 +63 -0
  42. package/assets/components/MessageSources/MessageSources.tsx +50 -0
  43. package/assets/components/MessageSources/index.ts +2 -0
  44. package/assets/components/MessageTable/MessageTable.module.css +46 -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 +75 -0
  51. package/assets/components/Spinner/Spinner.module.css +167 -0
  52. package/assets/components/Spinner/Spinner.tsx +119 -0
  53. package/assets/components/Spinner/index.ts +10 -0
  54. package/assets/components/Spinner/lottieColor.ts +76 -0
  55. package/assets/components/Spinner/lottieManifest.ts +99 -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 +43 -0
  63. package/assets/prompts/components/bubble.md +56 -0
  64. package/assets/prompts/components/calendar.md +123 -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/identity-button.md +168 -0
  68. package/assets/prompts/components/menu-item.md +2 -1
  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 +26 -0
  72. package/assets/prompts/components/message-table.md +27 -0
  73. package/assets/prompts/components/sidebar.md +106 -37
  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/assets/skill/SKILL.md +1 -0
  78. package/package.json +1 -1
@@ -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,63 @@
1
+ /* MessageSources — AIMessage 하단 소스 chip 리스트. */
2
+
3
+ .root {
4
+ display: flex;
5
+ flex-direction: column;
6
+ gap: var(--space-1);
7
+ }
8
+
9
+ .label {
10
+ font-size: var(--text-xs);
11
+ color: var(--fg-muted);
12
+ line-height: 1.2;
13
+ }
14
+
15
+ .chips {
16
+ display: flex;
17
+ flex-wrap: wrap;
18
+ gap: var(--space-1);
19
+ }
20
+
21
+ .chip {
22
+ display: inline-flex;
23
+ align-items: center;
24
+ gap: var(--space-1);
25
+ padding: var(--space-1) var(--space-2);
26
+ background: var(--bg-subtle);
27
+ border-radius: var(--radius-full);
28
+ color: var(--fg);
29
+ font-size: var(--text-xs);
30
+ text-decoration: none;
31
+ transition: var(--transition-colors);
32
+ max-width: 100%;
33
+ min-width: 0;
34
+ }
35
+ .chip:hover {
36
+ background: var(--bg-muted);
37
+ color: var(--fg-strong);
38
+ }
39
+ .chip:focus-visible {
40
+ outline: none;
41
+ box-shadow: var(--ring);
42
+ }
43
+
44
+ .chipIcon {
45
+ display: inline-flex;
46
+ align-items: center;
47
+ justify-content: center;
48
+ width: var(--space-4);
49
+ height: var(--space-4);
50
+ flex: 0 0 auto;
51
+ color: var(--fg-muted);
52
+ }
53
+ .linkIcon {
54
+ width: 80%;
55
+ height: 80%;
56
+ }
57
+
58
+ .chipLabel {
59
+ overflow: hidden;
60
+ text-overflow: ellipsis;
61
+ white-space: nowrap;
62
+ min-width: 0;
63
+ }
@@ -0,0 +1,50 @@
1
+ "use client";
2
+
3
+ import type { HTMLAttributes } from "react";
4
+ import { Link as PhLink } from "@phosphor-icons/react";
5
+ import styles from "./MessageSources.module.css";
6
+
7
+ export interface MessageSource {
8
+ id: string;
9
+ label: string;
10
+ href?: string;
11
+ /** 좌측 favicon 등 커스텀 아이콘. 없으면 기본 Link. */
12
+ icon?: React.ReactNode;
13
+ }
14
+
15
+ export interface MessageSourcesProps extends HTMLAttributes<HTMLDivElement> {
16
+ sources: MessageSource[];
17
+ /** 상단 라벨. 기본 "Sources". */
18
+ label?: string;
19
+ }
20
+
21
+ export function MessageSources({
22
+ sources,
23
+ label = "Sources",
24
+ className,
25
+ ...rest
26
+ }: MessageSourcesProps) {
27
+ const cls = [styles.root, className].filter(Boolean).join(" ");
28
+
29
+ return (
30
+ <div className={cls} {...rest}>
31
+ <div className={styles.label}>{label}</div>
32
+ <div className={styles.chips}>
33
+ {sources.map((s) => (
34
+ <a
35
+ key={s.id}
36
+ className={styles.chip}
37
+ href={s.href || "#"}
38
+ target={s.href ? "_blank" : undefined}
39
+ rel={s.href ? "noopener noreferrer" : undefined}
40
+ >
41
+ <span className={styles.chipIcon}>
42
+ {s.icon ?? <PhLink className={styles.linkIcon} weight="regular" aria-hidden />}
43
+ </span>
44
+ <span className={styles.chipLabel}>{s.label}</span>
45
+ </a>
46
+ ))}
47
+ </div>
48
+ </div>
49
+ );
50
+ }
@@ -0,0 +1,2 @@
1
+ export { MessageSources } from "./MessageSources";
2
+ export type { MessageSourcesProps, MessageSource } from "./MessageSources";
@@ -0,0 +1,46 @@
1
+ /* MessageTable — markdown table 톤.
2
+ wrapper는 가로 스크롤 컨테이너, 안쪽 table이 트랙. */
3
+
4
+ .root {
5
+ width: 100%;
6
+ overflow-x: auto;
7
+ border: var(--hairline) solid var(--border);
8
+ border-radius: var(--radius-md);
9
+ }
10
+
11
+ .root :global(table) {
12
+ width: 100%;
13
+ border-collapse: collapse;
14
+ font-size: var(--text-sm);
15
+ color: var(--fg);
16
+ table-layout: auto;
17
+ }
18
+
19
+ .root :global(thead) {
20
+ background: var(--bg-subtle);
21
+ }
22
+
23
+ .root :global(th),
24
+ .root :global(td) {
25
+ padding: var(--space-2) var(--space-3);
26
+ text-align: left;
27
+ vertical-align: top;
28
+ border-bottom: var(--hairline) solid var(--border);
29
+ line-height: 1.5;
30
+ }
31
+
32
+ .root :global(th) {
33
+ color: var(--fg-strong);
34
+ font-weight: var(--font-weight-semibold);
35
+ }
36
+
37
+ /* 숫자 셀 좌측 정렬 유지(피드백 no-right-align-numbers), 숫자는 tabular */
38
+ .root :global(td),
39
+ .root :global(th) {
40
+ font-variant-numeric: tabular-nums;
41
+ }
42
+
43
+ /* 마지막 행 border 제거 */
44
+ .root :global(tr:last-child td) {
45
+ border-bottom: 0;
46
+ }
@@ -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,75 @@
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
+ const scaleStyle = entry.visualScale
65
+ ? ({ "--lottie-scale": entry.visualScale } as CSSProperties)
66
+ : undefined;
67
+
68
+ return (
69
+ <div ref={containerRef} className={className} style={scaleStyle}>
70
+ {data ? (
71
+ <Lottie src={data} loop autoplay className={styles.lottieFill} />
72
+ ) : null}
73
+ </div>
74
+ );
75
+ }
@@ -0,0 +1,167 @@
1
+ /* Spinner — 공통 바깥 박스
2
+ variant 지정 → data-variant로 분기
3
+ variant 미지정 → [data-motion="…"] 조상 셀렉터로 자동 매핑 (Motion 축 추종)
4
+
5
+ 색은 currentColor로 묶여 부모 color를 그대로 승계 (Palette/Hue 축 호환). */
6
+
7
+ .root {
8
+ display: inline-flex;
9
+ align-items: center;
10
+ justify-content: center;
11
+ position: relative;
12
+ color: currentColor;
13
+ width: var(--loader-size, var(--loader-size-sm));
14
+ height: var(--loader-size, var(--loader-size-sm));
15
+ flex-shrink: 0;
16
+ }
17
+
18
+ .xs { --loader-size: var(--loader-size-xs); }
19
+ .sm { --loader-size: var(--loader-size-sm); }
20
+ .md { --loader-size: var(--loader-size-md); }
21
+ .lg { --loader-size: var(--loader-size-lg); }
22
+
23
+ /* 자식 전부 기본 숨김 — 활성 variant만 켬. */
24
+ .ring,
25
+ .dot,
26
+ .pulse {
27
+ display: none;
28
+ }
29
+
30
+ /* ─────────────────────────────────────────────────────────
31
+ 공통 variant 규칙 (키: ring / dots-bounce / dots-fade / pulse)
32
+ 아래는 "활성 variant → 어떤 자식을 어떻게 보이게 할지"만 정의.
33
+ 무엇이 활성 variant인가는 더 아래 "매칭" 섹션이 결정.
34
+ ───────────────────────────────────────────────────────── */
35
+
36
+ /* ring */
37
+ .v-ring .ring {
38
+ display: block;
39
+ width: 100%;
40
+ height: 100%;
41
+ animation: m-spin calc(var(--duration-slower) * 2) linear infinite; /* lint-hardcoded: ignore */
42
+ }
43
+
44
+ /* dots 공통 */
45
+ .v-dots {
46
+ width: auto;
47
+ min-width: var(--loader-size);
48
+ gap: calc(var(--loader-size) * 0.22);
49
+ }
50
+ .v-dots .dot {
51
+ display: block;
52
+ width: calc(var(--loader-size) * 0.32);
53
+ height: calc(var(--loader-size) * 0.32);
54
+ border-radius: 50%;
55
+ background: currentColor;
56
+ flex-shrink: 0;
57
+ }
58
+
59
+ /* dots-bounce */
60
+ .v-dots-bounce .dot {
61
+ animation: m-dots-bounce calc(var(--duration-slower) * 1.5) var(--ease-in-out) infinite;
62
+ }
63
+ .v-dots-bounce .dot:nth-child(3) {
64
+ animation-delay: calc(var(--duration-base) * -1);
65
+ }
66
+ .v-dots-bounce .dot:nth-child(4) {
67
+ animation-delay: calc(var(--duration-slow) * -1);
68
+ }
69
+
70
+ /* dots-fade */
71
+ .v-dots-fade .dot {
72
+ animation: m-dots-fade calc(var(--duration-slower) * 1.5) var(--ease-in-out) infinite;
73
+ }
74
+ .v-dots-fade .dot:nth-child(3) {
75
+ animation-delay: calc(var(--duration-base) * -1);
76
+ }
77
+ .v-dots-fade .dot:nth-child(4) {
78
+ animation-delay: calc(var(--duration-slow) * -1);
79
+ }
80
+
81
+ /* pulse */
82
+ .v-pulse .pulse {
83
+ display: block;
84
+ width: 100%;
85
+ height: 100%;
86
+ border-radius: 50%;
87
+ background: currentColor;
88
+ opacity: 0.6;
89
+ animation: m-pulse calc(var(--duration-slower) * 2) var(--ease-in-out) infinite;
90
+ }
91
+
92
+ /* ─────────────────────────────────────────────────────────
93
+ Lottie variants — LottiePlayer가 바깥 박스 100% 채움.
94
+ bounds는 .root가 결정 (size prop), aspect=wide 로티는 .wide로 폭을 2배.
95
+ ───────────────────────────────────────────────────────── */
96
+ .v-lottie {
97
+ overflow: visible;
98
+ }
99
+ .v-lottie.wide {
100
+ width: calc(var(--loader-size) * 2);
101
+ }
102
+
103
+ .lottieFill {
104
+ width: 100%;
105
+ height: 100%;
106
+ /* 로티 원본이 캔버스를 덜 채워 체감 크기가 작은 경우, 매니페스트의 visualScale이
107
+ --lottie-scale을 지정 → bounds 안에서 scale-up. 지정 없으면 1 (원본). */
108
+ transform: scale(var(--lottie-scale, 1));
109
+ transform-origin: center;
110
+ }
111
+
112
+ /* ─────────────────────────────────────────────────────────
113
+ v-auto — variant 미지정 시 Motion 축을 따라감.
114
+ 조상 data-motion 값에 따라 분기. 기본(미지정)은 ring.
115
+ ───────────────────────────────────────────────────────── */
116
+
117
+ .v-auto .ring {
118
+ display: block;
119
+ width: 100%;
120
+ height: 100%;
121
+ animation: m-spin calc(var(--duration-slower) * 2) linear infinite; /* lint-hardcoded: ignore */
122
+ }
123
+
124
+ [data-motion="bouncy"] .v-auto .ring,
125
+ [data-motion="calm"] .v-auto .ring {
126
+ display: none;
127
+ animation: none;
128
+ }
129
+
130
+ [data-motion="bouncy"] .v-auto,
131
+ [data-motion="calm"] .v-auto {
132
+ width: auto;
133
+ min-width: var(--loader-size);
134
+ gap: calc(var(--loader-size) * 0.22);
135
+ }
136
+
137
+ [data-motion="bouncy"] .v-auto .dot,
138
+ [data-motion="calm"] .v-auto .dot {
139
+ display: block;
140
+ width: calc(var(--loader-size) * 0.32);
141
+ height: calc(var(--loader-size) * 0.32);
142
+ border-radius: 50%;
143
+ background: currentColor;
144
+ flex-shrink: 0;
145
+ }
146
+
147
+ /* bouncy → dots-bounce */
148
+ [data-motion="bouncy"] .v-auto .dot {
149
+ animation: m-dots-bounce calc(var(--duration-slower) * 1.5) var(--ease-in-out) infinite;
150
+ }
151
+ [data-motion="bouncy"] .v-auto .dot:nth-child(3) {
152
+ animation-delay: calc(var(--duration-base) * -1);
153
+ }
154
+ [data-motion="bouncy"] .v-auto .dot:nth-child(4) {
155
+ animation-delay: calc(var(--duration-slow) * -1);
156
+ }
157
+
158
+ /* calm → dots-fade */
159
+ [data-motion="calm"] .v-auto .dot {
160
+ animation: m-dots-fade calc(var(--duration-slower) * 1.5) var(--ease-in-out) infinite;
161
+ }
162
+ [data-motion="calm"] .v-auto .dot:nth-child(3) {
163
+ animation-delay: calc(var(--duration-base) * -1);
164
+ }
165
+ [data-motion="calm"] .v-auto .dot:nth-child(4) {
166
+ animation-delay: calc(var(--duration-slow) * -1);
167
+ }