@conscience-technology/ctds 0.1.13 → 0.1.14

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,73 +1,115 @@
1
- /* AttachmentChip — [FileIcon] filename [X]
2
- 레퍼런스: ChatInput 안에서 쓰이는 둥근 사각 — radius-md, hairline border. */
1
+ /* AttachmentChip — [leading] filename (meta) [actions]
2
+ size sm/md: 1줄 pill
3
+ size lg: 2줄 큰 카드 (파일명 위 / meta 아래)
4
+ 폭은 전부 --input-w-lg (400px) 고정 — 소비처가 폭 걱정 없이 넣기만. */
3
5
 
4
6
  .root {
5
7
  display: inline-flex;
6
8
  align-items: center;
7
9
  gap: var(--space-2);
10
+ width: var(--input-w-lg);
8
11
  min-width: 0;
9
12
  background: var(--bg);
10
13
  border: var(--hairline) solid var(--border);
11
- border-radius: var(--radius-md);
12
14
  color: var(--fg);
13
15
  transition: var(--transition-colors);
16
+ box-sizing: border-box;
14
17
  }
15
18
 
16
- /* 패딩만 size 차이. 오른쪽은 X 버튼 자리라 좁게. 폭은 고정 — label이 길면 ellipsis. */
19
+ /* ───────── size ─────────
20
+ sm/md: 1줄. radius-md.
21
+ lg: 2줄 세로 스택. radius-lg + 넉넉한 padding. */
17
22
  .sizeSm {
18
- padding: var(--space-1) var(--space-1) var(--space-1) var(--space-1);
23
+ padding: var(--space-1) var(--space-1) var(--space-1) var(--space-2);
24
+ gap: var(--space-1_5);
19
25
  font-size: var(--text-xs);
20
- width: calc(var(--space-20) * 2);
26
+ border-radius: var(--radius-md);
21
27
  }
22
28
  .sizeMd {
23
- padding: var(--space-1_5) var(--space-1_5) var(--space-1_5) var(--space-1_5);
29
+ padding: var(--space-1_5) var(--space-1_5) var(--space-1_5) var(--space-2_5);
30
+ gap: var(--space-2);
24
31
  font-size: var(--text-sm);
25
- width: calc(var(--space-24) * 2);
32
+ border-radius: var(--radius-md);
33
+ }
34
+ .sizeLg {
35
+ padding: var(--space-2_5) var(--space-1_5) var(--space-2_5) var(--space-2_5);
36
+ gap: var(--space-2_5);
37
+ font-size: var(--text-sm);
38
+ border-radius: var(--radius-lg);
26
39
  }
27
40
 
28
- /* 파일명 */
29
- .label {
41
+ /* ───────── text block ───────── */
42
+ .text {
43
+ display: flex;
30
44
  flex: 1 1 auto;
31
45
  min-width: 0;
46
+ align-items: baseline;
47
+ gap: var(--space-1);
48
+ }
49
+ .sizeLg .text {
50
+ flex-direction: column;
51
+ align-items: flex-start;
52
+ gap: var(--space-0_5);
53
+ }
54
+
55
+ .label {
56
+ flex: 0 1 auto;
57
+ min-width: 0;
32
58
  overflow: hidden;
33
59
  text-overflow: ellipsis;
34
60
  white-space: nowrap;
35
61
  color: var(--fg-strong);
62
+ font-weight: var(--font-weight-medium);
63
+ line-height: 1.2;
64
+ }
65
+ .sizeLg .label {
66
+ font-size: var(--text-sm);
67
+ }
68
+
69
+ .meta {
70
+ flex: 1 1 auto;
71
+ min-width: 0;
72
+ color: var(--fg-muted);
73
+ font-weight: var(--font-weight-regular);
36
74
  line-height: 1.2;
37
- padding-right: var(--space-1);
75
+ overflow: hidden;
76
+ text-overflow: ellipsis;
77
+ white-space: nowrap;
78
+ display: inline-flex;
79
+ align-items: center;
80
+ gap: var(--space-1);
81
+ font-size: var(--text-xs);
38
82
  }
39
83
 
40
- /* X 버튼 — size에 따라 width/height가 변하도록 토큰 조합 */
41
- .removeBtn {
84
+ /* ───────── loading slot ─────────
85
+ FileIcon 자리에 들어가는 정사각 — 각 사이즈 FileIcon 치수와 동일.
86
+ 내부 Spinner(lottie-dots)가 꽉 채운다. */
87
+ .loadingSlot {
42
88
  display: inline-flex;
43
89
  align-items: center;
44
90
  justify-content: center;
45
- padding: 0;
46
- border: 0;
47
- background: transparent;
48
- color: var(--fg-muted);
49
- border-radius: var(--radius-sm);
50
- cursor: pointer;
51
- transition: var(--transition-colors);
52
- width: var(--space-5);
53
- height: var(--space-5);
54
91
  flex: 0 0 auto;
92
+ color: var(--fg-muted);
93
+ width: var(--space-6);
94
+ height: var(--space-6);
55
95
  }
56
- .sizeSm .removeBtn {
96
+ .sizeSm .loadingSlot {
57
97
  width: var(--space-4);
58
98
  height: var(--space-4);
59
99
  }
60
-
61
- .removeBtn:hover {
62
- background: var(--bg-subtle);
63
- color: var(--fg);
100
+ .sizeLg .loadingSlot {
101
+ width: var(--space-8);
102
+ height: var(--space-8);
64
103
  }
65
- .removeBtn:focus-visible {
66
- outline: none;
67
- box-shadow: var(--ring);
104
+ .loadingSpinner {
105
+ width: 100%;
106
+ height: 100%;
68
107
  }
69
108
 
70
- .removeIcon {
71
- width: 70%;
72
- height: 70%;
109
+ /* ───────── actions ───────── */
110
+ .actions {
111
+ display: inline-flex;
112
+ align-items: center;
113
+ gap: var(--space-1);
114
+ flex: 0 0 auto;
73
115
  }
@@ -1,74 +1,158 @@
1
1
  "use client";
2
2
 
3
- import type { HTMLAttributes, MouseEvent } from "react";
4
- import { X as PhX } from "@phosphor-icons/react";
3
+ import type { HTMLAttributes, MouseEvent, ReactNode } from "react";
4
+ import {
5
+ DotsThree as PhDotsThree,
6
+ DownloadSimple as PhDownload,
7
+ X as PhX,
8
+ } from "@phosphor-icons/react";
5
9
  import { FileIcon, type FileIconSize } from "@/components/ctds/FileIcon";
10
+ import { IconButton, type IconButtonSize } from "@/components/ctds/IconButton";
11
+ import { Spinner } from "@/components/ctds/Spinner";
6
12
  import styles from "./AttachmentChip.module.css";
7
13
 
8
- export type AttachmentChipSize = "sm" | "md";
14
+ export type AttachmentChipSize = "sm" | "md" | "lg";
15
+ export type AttachmentChipState = "idle" | "loading";
9
16
 
10
17
  export interface AttachmentChipProps
11
- extends Omit<HTMLAttributes<HTMLSpanElement>, "children" | "onError"> {
18
+ extends Omit<HTMLAttributes<HTMLDivElement>, "children" | "onError"> {
12
19
  /** 파일명. 확장자가 FileIcon 색/라벨을 결정. */
13
20
  filename: string;
14
- /** 지정 시 우측에 X 노출, 클릭 시 호출. */
15
- onRemove?: (e: MouseEvent<HTMLButtonElement>) => void;
16
- /** sm=padding·icon 작게 / md=기본. */
21
+ /** sm/md=1줄, lg=2줄(파일명 위 / meta 아래). 폭은 전부 400px 고정. */
17
22
  size?: AttachmentChipSize;
23
+ /** loading이면 좌측 뱃지가 Spinner(lottie-dots)로 교체. */
24
+ state?: AttachmentChipState;
25
+ /** sm/md: 파일명 옆 inline 보조 영역. lg: 파일명 아래 2번째 줄.
26
+ * 문자열이면 muted 라벨로, ReactNode면 그대로 렌더 (ex. Button ghost xs). */
27
+ meta?: ReactNode;
28
+ /** 지정 시 우측 X. */
29
+ onRemove?: (e: MouseEvent<HTMLButtonElement>) => void;
30
+ /** 지정 시 우측 다운로드 아이콘. */
31
+ onDownload?: (e: MouseEvent<HTMLButtonElement>) => void;
32
+ /** 지정 시 우측 '더보기' 아이콘. */
33
+ onMore?: (e: MouseEvent<HTMLButtonElement>) => void;
18
34
  }
19
35
 
20
36
  const sizeClass: Record<AttachmentChipSize, string> = {
21
37
  sm: styles.sizeSm,
22
38
  md: styles.sizeMd,
39
+ lg: styles.sizeLg,
23
40
  };
24
41
 
25
- const fileIconSize: Record<AttachmentChipSize, FileIconSize> = {
42
+ /** 좌측 FileIcon 크기 — sm=sm(16), md=md(24), lg=lg(32). */
43
+ const FILE_ICON_SIZE: Record<AttachmentChipSize, FileIconSize> = {
26
44
  sm: "sm",
27
45
  md: "md",
46
+ lg: "lg",
47
+ };
48
+
49
+ /** trailing IconButton 크기 — sm/md=sm(20), lg=md(24). IconButton 공식 사이즈만 사용. */
50
+ const ACTION_BTN_SIZE: Record<AttachmentChipSize, IconButtonSize> = {
51
+ sm: "sm",
52
+ md: "sm",
53
+ lg: "md",
28
54
  };
29
55
 
30
- /** 긴 파일명을 중간 생략으로 자르되 확장자는 유지.
31
- * "very-long-report-name.pdf" → "very-long-rep…pdf" */
32
- function middleEllipsis(filename: string, max = 28): string {
56
+ /** middle ellipsis — 폭 400px 안에서 충분히 들어가는 글자 수.
57
+ * 확장자는 항상 끝에 남긴다. CSS text-overflow:ellipsis가 뒤에서
58
+ * 확장자를 다시 자르지 않도록 폭 cap은 CSS와 함께 넉넉히 잡는다. */
59
+ function middleEllipsis(filename: string, max: number): string {
33
60
  if (filename.length <= max) return filename;
34
61
  const dot = filename.lastIndexOf(".");
35
- if (dot < 0 || dot < filename.length - 6) {
36
- return filename.slice(0, max - 1) + "…";
62
+ const extLen = filename.length - dot;
63
+ // 확장자가 없거나 비정상적으로 긴 경우만 폴백.
64
+ if (dot <= 0 || extLen > 8 || extLen >= max - 4) {
65
+ return filename.slice(0, Math.max(1, max - 3)) + "...";
37
66
  }
38
- const ext = filename.slice(dot); // ".pdf"
39
- const head = filename.slice(0, Math.max(1, max - ext.length - 1));
40
- return head + "…" + ext;
67
+ const ext = filename.slice(dot);
68
+ const head = filename.slice(0, Math.max(1, max - ext.length - 3));
69
+ return head + "..." + ext;
41
70
  }
42
71
 
43
72
  export function AttachmentChip({
44
73
  filename,
45
- onRemove,
46
74
  size = "md",
75
+ state = "idle",
76
+ meta,
77
+ onRemove,
78
+ onDownload,
79
+ onMore,
47
80
  className,
48
81
  ...rest
49
82
  }: AttachmentChipProps) {
50
- const cls = [styles.root, sizeClass[size], className]
83
+ const cls = [
84
+ styles.root,
85
+ sizeClass[size],
86
+ state === "loading" ? styles.isLoading : null,
87
+ className,
88
+ ]
51
89
  .filter(Boolean)
52
90
  .join(" ");
53
91
 
54
- const label = middleEllipsis(filename);
92
+ // 400px 폭에서 안전하게 들어가는 글자 수 (대략치).
93
+ const maxChars = size === "lg" ? 44 : size === "md" ? 40 : 36;
94
+ const label = middleEllipsis(filename, maxChars);
95
+
96
+ const leading =
97
+ state === "loading" ? (
98
+ <span className={styles.loadingSlot} aria-hidden>
99
+ <Spinner
100
+ variant="lottie-dots"
101
+ label="업로드 중"
102
+ className={styles.loadingSpinner}
103
+ />
104
+ </span>
105
+ ) : (
106
+ <FileIcon ext={filename} size={FILE_ICON_SIZE[size]} />
107
+ );
108
+
109
+ const actionSize = ACTION_BTN_SIZE[size];
55
110
 
56
111
  return (
57
- <span className={cls} {...rest}>
58
- <FileIcon ext={filename} size={fileIconSize[size]} />
59
- <span className={styles.label} title={filename}>
60
- {label}
112
+ <div className={cls} {...rest}>
113
+ {leading}
114
+
115
+ <span className={styles.text}>
116
+ <span className={styles.label} title={filename}>
117
+ {label}
118
+ </span>
119
+ {meta != null && meta !== "" ? (
120
+ <span className={styles.meta}>{meta}</span>
121
+ ) : null}
61
122
  </span>
62
- {onRemove ? (
63
- <button
64
- type="button"
65
- className={styles.removeBtn}
66
- onClick={onRemove}
67
- aria-label="첨부 제거"
68
- >
69
- <PhX className={styles.removeIcon} weight="regular" />
70
- </button>
71
- ) : null}
72
- </span>
123
+
124
+ {(onDownload || onMore || onRemove) && (
125
+ <span className={styles.actions}>
126
+ {onDownload ? (
127
+ <IconButton
128
+ size={actionSize}
129
+ variant="ghost"
130
+ onClick={onDownload}
131
+ aria-label="첨부 다운로드"
132
+ icon={<PhDownload />}
133
+ />
134
+ ) : null}
135
+ {onMore ? (
136
+ <IconButton
137
+ size={actionSize}
138
+ variant="ghost"
139
+ onClick={onMore}
140
+ aria-label="첨부 메뉴 열기"
141
+ aria-haspopup="menu"
142
+ icon={<PhDotsThree weight="bold" />}
143
+ />
144
+ ) : null}
145
+ {onRemove ? (
146
+ <IconButton
147
+ size={actionSize}
148
+ variant="ghost"
149
+ onClick={onRemove}
150
+ aria-label="첨부 제거"
151
+ icon={<PhX />}
152
+ />
153
+ ) : null}
154
+ </span>
155
+ )}
156
+ </div>
73
157
  );
74
158
  }
@@ -1,2 +1,6 @@
1
1
  export { AttachmentChip } from "./AttachmentChip";
2
- export type { AttachmentChipProps, AttachmentChipSize } from "./AttachmentChip";
2
+ export type {
3
+ AttachmentChipProps,
4
+ AttachmentChipSize,
5
+ AttachmentChipState,
6
+ } from "./AttachmentChip";
@@ -65,10 +65,10 @@ const ICON_SIZE: Record<ButtonSize, number> = {
65
65
  };
66
66
 
67
67
  const SPINNER_SIZE: Record<ButtonSize, SpinnerSize> = {
68
- xs: "xs",
69
- sm: "sm",
70
- md: "md",
71
- lg: "lg",
68
+ xs: "xl",
69
+ sm: "xl",
70
+ md: "xl",
71
+ lg: "xl",
72
72
  };
73
73
 
74
74
  function sizedIcon(node: ReactNode, size: number): ReactNode {
@@ -149,7 +149,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
149
149
  )}
150
150
  {loading && (
151
151
  <span className={styles.spinner} aria-hidden>
152
- <Spinner size={SPINNER_SIZE[size]} />
152
+ <Spinner variant="lottie-spinner" size={SPINNER_SIZE[size]} />
153
153
  </span>
154
154
  )}
155
155
  </Comp>
@@ -132,6 +132,9 @@
132
132
  font-family: inherit;
133
133
  font-size: inherit;
134
134
  line-height: var(--lh-sm);
135
+ /* 전역 tight 자간(-0.02em)을 상속하면 password dot(●)이 너무 벌어져 보이고
136
+ placeholder도 느슨해 보임. 폼 필드는 자간 조이지 않는다. */
137
+ letter-spacing: var(--tracking-normal);
135
138
  outline: none;
136
139
  /* type="button" 등 non-text input의 기본 center 정렬 무력화. */
137
140
  text-align: start;
@@ -0,0 +1,89 @@
1
+ /* MessageAttachment — AI가 생성한 이미지 큰 표시 (AttachmentThumb은 작은 썸네일).
2
+ loading / placeholder (src 없음) / image 세 상태. radius-md 고정, 생성 중 sparkle 숨쉼. */
3
+
4
+ .root {
5
+ position: relative;
6
+ width: 100%;
7
+ max-width: calc(var(--input-w-lg) * 1.1);
8
+ border-radius: var(--radius-md);
9
+ overflow: hidden;
10
+ background: var(--bg-subtle);
11
+ display: flex;
12
+ align-items: center;
13
+ justify-content: center;
14
+ color: var(--fg-muted);
15
+ }
16
+
17
+ /* aspect ratios */
18
+ .aspectSquare {
19
+ aspect-ratio: 1 / 1;
20
+ }
21
+ .aspectPortrait {
22
+ aspect-ratio: 3 / 4;
23
+ }
24
+ .aspectLandscape {
25
+ aspect-ratio: 4 / 3;
26
+ }
27
+
28
+ /* 이미지 */
29
+ .hasImage {
30
+ background: var(--bg-muted);
31
+ }
32
+ .img {
33
+ width: 100%;
34
+ height: 100%;
35
+ object-fit: cover;
36
+ display: block;
37
+ }
38
+
39
+ /* Placeholder / Loading — bg 이미지 + Spinner lottie-dots */
40
+ .placeholder,
41
+ .loading {
42
+ background: url("/illustrations/image-gen-placeholder.png") center / cover no-repeat,
43
+ var(--bg-muted);
44
+ }
45
+
46
+ .spinner {
47
+ color: var(--bg);
48
+ }
49
+
50
+ /* Download 버튼 — hover 시만 노출, 우상단 */
51
+ .downloadBtn {
52
+ position: absolute;
53
+ top: var(--space-2);
54
+ right: var(--space-2);
55
+ display: inline-flex;
56
+ align-items: center;
57
+ justify-content: center;
58
+ padding: 0;
59
+ border: 0;
60
+ background: var(--bg);
61
+ color: var(--fg);
62
+ border-radius: var(--radius-sm);
63
+ cursor: pointer;
64
+ width: var(--space-8);
65
+ height: var(--space-8);
66
+ box-shadow: var(--shadow-sm);
67
+ opacity: 0;
68
+ transform: scale(0.9);
69
+ transition:
70
+ opacity var(--duration-fast) var(--ease-out),
71
+ transform var(--duration-fast) var(--ease-out),
72
+ var(--transition-colors);
73
+ }
74
+ .root:hover .downloadBtn,
75
+ .downloadBtn:focus-visible {
76
+ opacity: 1;
77
+ transform: scale(1);
78
+ }
79
+ .downloadBtn:hover {
80
+ background: var(--bg-subtle);
81
+ }
82
+ .downloadBtn:focus-visible {
83
+ outline: none;
84
+ box-shadow: var(--shadow-sm), var(--ring);
85
+ }
86
+ .downloadIcon {
87
+ width: 55%;
88
+ height: 55%;
89
+ }
@@ -0,0 +1,70 @@
1
+ "use client";
2
+
3
+ import type { HTMLAttributes, MouseEvent } from "react";
4
+ import { DownloadSimple as PhDownload } from "@phosphor-icons/react";
5
+ import { Spinner } from "@/components/ctds/Spinner";
6
+ import { Tooltip } from "@/components/ctds/Tooltip";
7
+ import styles from "./MessageAttachment.module.css";
8
+
9
+ export type MessageAttachmentAspect = "square" | "portrait" | "landscape";
10
+
11
+ export interface MessageAttachmentProps
12
+ extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
13
+ /** 생성된 이미지 URL. 없거나 loading이면 placeholder. */
14
+ src?: string;
15
+ alt?: string;
16
+ /** 가로세로 비율. portrait=3/4(기본, 레퍼런스), square=1/1, landscape=4/3. */
17
+ aspect?: MessageAttachmentAspect;
18
+ /** 생성 중 — placeholder + sparkle breathe 애니. */
19
+ loading?: boolean;
20
+ /** 다운로드 버튼 클릭. 미지정 시 버튼 숨김. */
21
+ onDownload?: (e: MouseEvent<HTMLButtonElement>) => void;
22
+ }
23
+
24
+ const aspectClass: Record<MessageAttachmentAspect, string> = {
25
+ square: styles.aspectSquare,
26
+ portrait: styles.aspectPortrait,
27
+ landscape: styles.aspectLandscape,
28
+ };
29
+
30
+ export function MessageAttachment({
31
+ src,
32
+ alt = "",
33
+ aspect = "portrait",
34
+ loading = false,
35
+ onDownload,
36
+ className,
37
+ ...rest
38
+ }: MessageAttachmentProps) {
39
+ const cls = [
40
+ styles.root,
41
+ aspectClass[aspect],
42
+ loading ? styles.loading : src ? styles.hasImage : styles.placeholder,
43
+ className,
44
+ ]
45
+ .filter(Boolean)
46
+ .join(" ");
47
+
48
+ return (
49
+ <div className={cls} {...rest}>
50
+ {loading || !src ? (
51
+ <Spinner variant="lottie-dots" size="md" className={styles.spinner} />
52
+ ) : (
53
+ <img src={src} alt={alt} className={styles.img} />
54
+ )}
55
+
56
+ {onDownload && src && !loading ? (
57
+ <Tooltip content="다운로드">
58
+ <button
59
+ type="button"
60
+ className={styles.downloadBtn}
61
+ onClick={onDownload}
62
+ aria-label="이미지 다운로드"
63
+ >
64
+ <PhDownload className={styles.downloadIcon} weight="regular" />
65
+ </button>
66
+ </Tooltip>
67
+ ) : null}
68
+ </div>
69
+ );
70
+ }
@@ -0,0 +1,5 @@
1
+ export { MessageAttachment } from "./MessageAttachment";
2
+ export type {
3
+ MessageAttachmentProps,
4
+ MessageAttachmentAspect,
5
+ } from "./MessageAttachment";
@@ -15,12 +15,13 @@
15
15
  font-weight: var(--font-weight-regular);
16
16
  }
17
17
 
18
+ /* 리더 폭 — Spinner size=sm(20px)과 통일. 아이콘/스피너 전환해도 라벨 위치 안 흔들림. */
18
19
  .leadIcon {
19
20
  display: inline-flex;
20
21
  align-items: center;
21
22
  justify-content: center;
22
- width: var(--space-4);
23
- height: var(--space-4);
23
+ width: var(--loader-size-sm);
24
+ height: var(--loader-size-sm);
24
25
  color: var(--primary);
25
26
  flex: 0 0 auto;
26
27
  }
@@ -31,27 +32,32 @@
31
32
  .leadSpinner {
32
33
  color: var(--primary);
33
34
  flex: 0 0 auto;
35
+ /* Spinner sm = 20px = loader-size-sm. 폭 명시해 동일 width 보장. */
36
+ width: var(--loader-size-sm);
37
+ height: var(--loader-size-sm);
34
38
  }
35
39
 
36
- /* 통통거리는 라벨 (AIMessage의 generating 패턴과 동일 — 중복 정의) */
40
+ /* 통통거리는 라벨 — 글자 수에 비례한 cycle, 각 글자 index 지연으로 wave. */
37
41
  .label {
38
42
  display: inline-flex;
39
43
  align-items: center;
40
44
  }
41
45
  .bounceChar {
42
46
  display: inline-block;
43
- animation: msgstate-char-bounce calc(var(--duration-slower) * 5) var(--ease-in-out) infinite;
47
+ animation: msgstate-char-bounce calc(var(--duration-slower) * 10) var(--ease-in-out) infinite;
44
48
  }
45
49
  .bounceSpace {
46
50
  display: inline-block;
47
51
  white-space: pre;
48
52
  }
53
+ /* bounce는 cycle 앞 35%까지만 (올라감 17.5%→피크→내려옴 35%), 나머지 65%는 쉼.
54
+ cycle을 2배로 늘렸으니 bounce 자체 속도는 그대로, 다음 wave까지 간격만 길어짐. */
49
55
  @keyframes msgstate-char-bounce {
50
- 0%, 70%, 100% {
56
+ 0%, 35%, 100% {
51
57
  transform: translateY(0);
52
58
  opacity: 0.6;
53
59
  }
54
- 35% {
60
+ 17.5% {
55
61
  transform: translateY(calc(-1.5px * var(--motion-distance-scale))); /* lint-hardcoded: ignore */
56
62
  opacity: 1;
57
63
  }
@@ -23,9 +23,11 @@ export type MessageStateKind =
23
23
 
24
24
  export interface MessageStateProps extends HTMLAttributes<HTMLDivElement> {
25
25
  kind?: MessageStateKind;
26
- /** 라벨 override. kind별 기본 라벨이 있지만 커스터마이즈 가능. */
26
+ /** 활성 상태 라벨 override. 미지정 시 kind별 기본("…ing…"). */
27
27
  message?: ReactNode;
28
- /** 완료 상태 — true면 spinner 자리에 ✓, 라벨 통통거림 멈춤. */
28
+ /** 완료 상태 라벨 override. 미지정 시 kind별 done 기본("…ed"). */
29
+ doneMessage?: ReactNode;
30
+ /** 완료 상태 — true면 spinner 자리에 ✓, 라벨 통통거림 멈춤, doneMessage로 전환. */
29
31
  done?: boolean;
30
32
  /** 커스텀 아이콘 — kind="custom"이거나 override 하고 싶을 때. */
31
33
  icon?: ReactNode;
@@ -34,13 +36,24 @@ export interface MessageStateProps extends HTMLAttributes<HTMLDivElement> {
34
36
  const DEFAULT_MESSAGE: Record<MessageStateKind, string> = {
35
37
  generating: "응답 생성 중…",
36
38
  searching: "Searching the web…",
37
- memory: "Updated saved memory",
39
+ memory: "Updating memory…",
38
40
  image: "Looking at the image…",
39
41
  video: "Reviewing the video…",
40
42
  file: "Processing file…",
41
43
  custom: "",
42
44
  };
43
45
 
46
+ /** 완료 상태 라벨 — done=true일 때 message prop이 없으면 이걸 사용. */
47
+ const DEFAULT_DONE_MESSAGE: Record<MessageStateKind, string> = {
48
+ generating: "응답 완료",
49
+ searching: "Searched the web",
50
+ memory: "Updated saved memory",
51
+ image: "Looked at the image",
52
+ video: "Reviewed the video",
53
+ file: "Processed file",
54
+ custom: "",
55
+ };
56
+
44
57
  const KIND_ICON: Record<MessageStateKind, React.ComponentType<any> | null> = {
45
58
  generating: null, // Spinner만
46
59
  searching: PhSearch,
@@ -76,12 +89,15 @@ function renderBouncingText(text: string) {
76
89
  export function MessageState({
77
90
  kind = "generating",
78
91
  message,
92
+ doneMessage,
79
93
  done = false,
80
94
  icon,
81
95
  className,
82
96
  ...rest
83
97
  }: MessageStateProps) {
84
- const label = message ?? DEFAULT_MESSAGE[kind];
98
+ const label = done
99
+ ? doneMessage ?? DEFAULT_DONE_MESSAGE[kind]
100
+ : message ?? DEFAULT_MESSAGE[kind];
85
101
  const KindIcon = KIND_ICON[kind];
86
102
 
87
103
  const cls = [styles.root, className].filter(Boolean).join(" ");
@@ -96,11 +112,13 @@ export function MessageState({
96
112
  <Spinner variant="lottie-dots" size="sm" className={styles.leadSpinner} />
97
113
  );
98
114
 
115
+ const labelStr = typeof label === "string" ? label : null;
116
+
99
117
  return (
100
118
  <div className={cls} data-done={done || undefined} {...rest}>
101
119
  {leadIcon}
102
120
  <span className={styles.label}>
103
- {done || typeof label !== "string" ? label : renderBouncingText(label)}
121
+ {done || !labelStr ? label : renderBouncingText(labelStr)}
104
122
  </span>
105
123
  </div>
106
124
  );
@@ -14,10 +14,10 @@
14
14
  flex-shrink: 0;
15
15
  }
16
16
 
17
- .xs { --loader-size: var(--loader-size-xs); }
18
17
  .sm { --loader-size: var(--loader-size-sm); }
19
18
  .md { --loader-size: var(--loader-size-md); }
20
19
  .lg { --loader-size: var(--loader-size-lg); }
20
+ .xl { --loader-size: var(--loader-size-xl); }
21
21
 
22
22
  /* 자식 전부 기본 숨김 — 활성 variant만 켬. */
23
23
  .ring,
@@ -19,7 +19,7 @@ import styles from "./Spinner.module.css";
19
19
  import type { LottieName } from "./lottieManifest";
20
20
  import { LottiePlayer } from "./LottiePlayer";
21
21
 
22
- export type SpinnerSize = "xs" | "sm" | "md" | "lg";
22
+ export type SpinnerSize = "sm" | "md" | "lg" | "xl";
23
23
  export type SpinnerBaseVariant = "ring" | "dots-bounce" | "dots-fade" | "pulse";
24
24
  export type SpinnerLottieVariant = `lottie-${LottieName}`;
25
25
  export type SpinnerVariant = SpinnerBaseVariant | SpinnerLottieVariant;
@@ -33,10 +33,10 @@ export type SpinnerProps = HTMLAttributes<HTMLSpanElement> & {
33
33
  };
34
34
 
35
35
  const sizeClass: Record<SpinnerSize, string> = {
36
- xs: styles.xs,
37
36
  sm: styles.sm,
38
37
  md: styles.md,
39
38
  lg: styles.lg,
39
+ xl: styles.xl,
40
40
  };
41
41
 
42
42
  const baseVariantClass: Record<SpinnerBaseVariant, string> = {
@@ -42,21 +42,21 @@ export const LOTTIE_MANIFEST: Record<LottieName, LottieManifestEntry> = {
42
42
  name: "rabbit",
43
43
  path: "/lottie/rabbit.json",
44
44
  colorBind: true,
45
- visualScale: 1.4,
45
+ visualScale: 1.6,
46
46
  description: "뛰어가는 토끼. 긴 작업·AI 생성 대기",
47
47
  },
48
48
  infiniti: {
49
49
  name: "infiniti",
50
50
  path: "/lottie/infiniti.json",
51
51
  colorBind: true,
52
- visualScale: 3.2,
52
+ visualScale: 3.6,
53
53
  description: "무한 루프 선. 네트워크·동기화",
54
54
  },
55
55
  dots: {
56
56
  name: "dots",
57
57
  path: "/lottie/dots.json",
58
58
  colorBind: true,
59
- visualScale: 0.95,
59
+ visualScale: 1.08,
60
60
  offsetY: 0.025,
61
61
  description: "그라데이션 dot 흐름. 포인트 로더",
62
62
  },
@@ -64,35 +64,35 @@ export const LOTTIE_MANIFEST: Record<LottieName, LottieManifestEntry> = {
64
64
  name: "spinner",
65
65
  path: "/lottie/spinner.json",
66
66
  colorBind: true,
67
- visualScale: 2,
67
+ visualScale: 2.25,
68
68
  description: "표준 라인 스피너",
69
69
  },
70
70
  clock: {
71
71
  name: "clock",
72
72
  path: "/lottie/clock.json",
73
73
  colorBind: true,
74
- visualScale: 3.8,
74
+ visualScale: 4.25,
75
75
  description: "회전 시계 바늘. 처리 대기·타임아웃",
76
76
  },
77
77
  cosmos: {
78
78
  name: "cosmos",
79
79
  path: "/lottie/cosmos.json",
80
80
  colorBind: true,
81
- visualScale: 1.2,
81
+ visualScale: 1.35,
82
82
  description: "그라데이션 궤도. 몽환적·온보딩",
83
83
  },
84
84
  "image-gen": {
85
85
  name: "image-gen",
86
86
  path: "/lottie/image-gen.json",
87
87
  colorBind: true,
88
- visualScale: 1.1,
88
+ visualScale: 1.25,
89
89
  description: "이미지 생성 대기. AI 이미지",
90
90
  },
91
91
  "loading-square": {
92
92
  name: "loading-square",
93
93
  path: "/lottie/loading-square.json",
94
94
  colorBind: true,
95
- visualScale: 3.5,
95
+ visualScale: 3.9,
96
96
  description: "회전하는 사각형. 미니멀 처리 대기",
97
97
  },
98
98
  };
@@ -607,6 +607,7 @@ export const componentSlugs: Record<string, string> = {
607
607
  "Message Sources": "chat/message-sources",
608
608
  "Message Table": "chat/message-table",
609
609
  "Message State": "chat/message-state",
610
+ "Message Attachment": "chat/message-attachment",
610
611
  };
611
612
 
612
613
  /** 작업중 — 사이드바에서 접근은 가능하지만 "작업중" 뱃지로 표시해 사용 자제 유도. */
@@ -674,6 +675,7 @@ export const components: { id: string; title: string; items: string[] }[] = [
674
675
  "File Icon", "Attachment Thumb", "Attachment Chip", "Quotation Bar", "Chat Input",
675
676
  "Bubble", "Message Actions", "User Message", "AI Message",
676
677
  "Code Block", "Message Quote", "Message Sources", "Message Table", "Message State",
678
+ "Message Attachment",
677
679
  "Chat Sidebar",
678
680
  ],
679
681
  },
@@ -80,12 +80,11 @@
80
80
  --icon-size-md: calc(20px * var(--space-scale));
81
81
  --icon-size-lg: calc(24px * var(--space-scale));
82
82
 
83
- /* Spinner · 로더 표준 크기 — IconButton 체계와 1:1 매칭 (xs=16, sm=20, md=24, lg=32).
84
- 로더는 캔버스를 다 채우지 않는 특성상 아이콘보다 한 단계 큰 bounds가 체감에 맞음. */
85
- --loader-size-xs: calc(16px * var(--space-scale));
83
+ /* Spinner · 로더 표준 크기 — IconButton 박스와 1:1 매칭 (sm=20, md=24, lg=32, xl=40). */
86
84
  --loader-size-sm: calc(20px * var(--space-scale));
87
85
  --loader-size-md: calc(24px * var(--space-scale));
88
86
  --loader-size-lg: calc(32px * var(--space-scale));
87
+ --loader-size-xl: calc(40px * var(--space-scale));
89
88
 
90
89
  /* Loader 성격 — Motion 축이 스왑 ([data-motion] 블록 참고).
91
90
  기본값은 standard와 같은 ring. */
@@ -1,46 +1,78 @@
1
1
  # Attachment Chip
2
2
 
3
3
  ### 원칙
4
- 파일 첨부 1건 pill — `[FileIcon] filename [X]`. 파일명에서 확장자를 뽑아 [[components/file-icon]] 색/라벨을 자동 결정. 긴 파일명은 **중간 ellipsis** + 끝 확장자 유지. ChatInput·Message 양쪽 공용.
4
+ 파일 첨부 1건 — `[FileIcon] filename (meta) [actions]`. 폭 400px 고정, 레이아웃은 `size` 하나로 결정. 파일명에서 확장자를 뽑아 [[components/file-icon]] 색/라벨을 자동 결정. `state="loading"`이면 좌측 뱃지를 Spinner(lottie-dots)로 교체. trailing은 콜백 조합(`onRemove`/`onDownload`/`onMore`)으로 노출. 긴 파일명은 **중간 생략 + 끝 확장자 유지**.
5
5
 
6
6
  ### Do
7
- - 파일명 통째로 넘기면 끝 — `filename="report.pdf"` 하나로 아이콘·라벨·식별까지 다 해결.
8
- - 삭제 가능한 자리엔 `onRemove` 지정 — X 상시 노출 (hover 틴트).
7
+ - 파일명 통째로 넘기면 끝 — `filename="report.pdf"` 하나로 아이콘·라벨·식별까지.
8
+ - size로 레이아웃 선택:
9
+ - `sm` — ChatInput 밀도 자리 (1줄)
10
+ - `md` — 일반 입력·목록 자리 (1줄, 기본값)
11
+ - `lg` — 첨부 상세·메시지 본문 (2줄, 파일명 위 / meta 아래)
12
+ - 업로드 중은 `state="loading"` + `meta` 문구 — 좌측이 Spinner(lottie-dots)로 교체. 상태 문구는 소비처 책임.
13
+ - 즉시 액션(미리보기·업로드 재시도 등)은 `meta`에 Button `variant="ghost"` `size="xs"`로 넣는다 — 전역 Button 체계와 합치.
14
+ - trailing은 콜백을 지정한 만큼만 노출. 복수 지정 시 download → more → remove 순으로 배치.
9
15
  - Thumb(이미지)·Chip(파일) 섞여 흐를 자리는 `flex-wrap: wrap` + `gap: space-2`로 조립.
10
- - 긴 파일명은 middle ellipsis가 알아서 자름 — maxLen 초과면 `name… + ext` 형태.
11
16
 
12
17
  ### Don't
13
- - 파일명과 확장자를 **따로** 받는 props 추가하지 않는다 — 소비처에서 자르면 FileIcon 매핑이 어긋남.
14
- - X를 호버 전용으로 숨기지 않는다 — ChatInput 안에선 상시 노출이 원칙 (이미지 썸네일과 다름).
15
- - Chip 안에 메뉴/추가 액션 넣지 않는다 — 삭제 외엔 상위 ChatInput이 책임.
16
- - 배경을 과한 톤으로 덮지 않는다 — bg는 `var(--bg)`, 테두리는 hairline. ChatInput 안에서 두드러지지 않게.
18
+ - 폭을 임의로 바꾸지 않는다 — 전부 `--input-w-lg` (400px) 고정. 소비처가 폭 걱정 없이 세로/wrap만 결정.
19
+ - 파일명·확장자를 **따로** 받는 props 추가하지 않는다 — 소비처가 자르면 FileIcon 매핑이 어긋남.
20
+ - trailing에 임의 ReactNode 슬롯을 뚫지 않는다 — 접근성·간격·톤 통일이 깨짐. 정의된 콜백 조합만.
21
+ - trailing을 수제 버튼으로 그리지 않는다 — 반드시 IconButton 재사용 (sm/md → IconButton sm 20, lg → IconButton md 24). IconButton에 없는 치수(28·36 등)는 금지.
22
+ - loading 좌측에 베이스 Spinner variant(pulse·dots-bounce 등)를 쓰지 않는다 — CTDS 로더 톤 통일 위해 `lottie-dots` 고정. [[components/spinner]].
23
+ - 배경을 과한 톤으로 덮지 않는다 — bg는 `var(--bg)`, 테두리는 hairline.
24
+ - meta 라인에 가운뎃점 "·"는 OK — 하지만 `··•·|` 같은 꾸밈 구분자는 금지. [[feedback-no-bullet-separator]].
17
25
 
18
26
  ### Fallback
19
27
  size 애매하면 `md`. 모르는 확장자는 FileIcon이 `others`로 폴백하니 그대로 넘겨도 됨.
20
28
 
21
29
  ### 관련
22
30
  - [[components/file-icon]] — 좌측 뱃지
31
+ - [[components/spinner]] — loading 상태 좌측 (lottie-dots variant)
32
+ - [[components/icon-button]] — trailing 액션 버튼
33
+ - [[components/button]] — meta 안 즉시 액션 (ghost xs)
23
34
  - [[components/attachment-thumb]] — 이미지 첨부
24
- - [[components/chat-input]] — 상위 소비처
25
- - [[components/message-bubble]] — 메시지 내 파일 인용에서도 재사용
35
+ - [[components/chat-input]] — 상위 소비처 (size=sm/md)
36
+ - [[components/message-bubble]] — 메시지 내 파일 인용 (size=lg)
26
37
 
27
38
  ### Props 시그니처
28
39
  ```tsx
29
40
  <AttachmentChip
30
- filename={string} // 필수. 확장자 자동 추출
31
- size="sm | md" // default: md
32
- onRemove={(e) => void} // 지정 시 우측 X 노출
41
+ filename={string} // 필수. 확장자 자동 추출
42
+ size="sm | md | lg" // default: md. lg는 2줄 큰 카드
43
+ state="idle | loading" // default: idle. loading이면 좌측이 Spinner
44
+ meta={ReactNode} // 문자열(muted 라벨) 또는 Button ghost xs 등
45
+ onRemove={(e) => void} // 지정 시 우측 X
46
+ onDownload={(e) => void} // 지정 시 우측 download
47
+ onMore={(e) => void} // 지정 시 우측 더보기 (dots)
33
48
  />
34
49
  ```
35
50
 
36
51
  ### 비율·레이아웃
37
- - 외곽 `--radius-full` (pill)
38
- - `--hairline` 1px border, `var(--border)` 색
39
- - 내부 gap: `--space-2`
40
- - padding: md = `space-1_5 space-3 space-1_5 space-1_5` (왼쪽은 FileIcon 바싹 붙게 좁게)
41
- - X: `--space-5` (md) / `--space-4` (sm) 정사각, radius full
52
+ - 폭: 전부 `--input-w-lg` (400px) 고정.
53
+ - 외곽: `--hairline` 1px border, `var(--border)` 색.
54
+ - radius: sm/md=`--radius-md`, lg=`--radius-lg`.
55
+ - 내부 gap (leading ↔ text): sm=`--space-1_5`, md=`--space-2`, lg=`--space-2_5`. actions 간은 전부 `--space-1`.
56
+ - padding:
57
+ - sm: `space-1` 상하, `space-1` 우, `space-2` 좌
58
+ - md: `space-1_5` 상하, `space-1_5` 우, `space-2_5` 좌
59
+ - lg: `space-2_5` 상하, `space-1_5` 우, `space-2_5` 좌 (세로 숨 쉬게 + 좌우는 아이콘 버튼 자리 고려해 타이트)
60
+ - leading FileIcon size: sm→sm(16) · md→md(24) · lg→lg(32).
61
+ - lg 안쪽 두 줄: label=`--text-sm`(14.5) · meta=`--text-xs`(13). 한 단계 차이. 무게/색은 label=medium/fg-strong, meta=regular/fg-muted. (meta는 모든 size 공통 `--text-xs`.)
62
+ - leading loading slot: FileIcon과 동일 치수(sm=`space-4`, md=`space-6`, lg=`space-8`), 내부 Spinner는 100%.
63
+ - trailing IconButton: sm/md→IconButton `sm`(20), lg→IconButton `md`(24). variant는 전부 ghost.
42
64
 
43
65
  ### Middle ellipsis 규칙
44
- `filename.length > 28`이면 `name… + ext`로 치환.
45
- - `"super-long-project-pitch.pptx"` → `"super-long-pr… .pptx"` 식.
46
- - 확장자 없거나 너무 짧으면 일반 ellipsis.
66
+ `filename.length > max`이면 `name... + ext`로 치환. 확장자는 항상 끝에 보존.
67
+ - sm: max=36
68
+ - md: max=40
69
+ - lg: max=44
70
+ - 확장자 없거나(dot<=0) 비정상적으로 긴 확장자(>8자) / 확장자가 max를 거의 다 먹는 경우만 일반 `...` 꼬리로 폴백.
71
+ - 사용 문자는 ASCII `...` (유니코드 `…` 금지, [[feedback-no-em-dash]]).
72
+ - CSS `text-overflow: ellipsis`는 안전망으로 유지하되, 글자 수 cap을 400px 폭보다 보수적으로 잡아 CSS가 확장자를 다시 잘라내지 않게 한다.
73
+
74
+ ### Loading 좌측 뱃지
75
+ `state="loading"`일 때 FileIcon 자리에 Spinner — `variant="lottie-dots"`. 색은 `--fg-muted` currentColor 바인딩. 크기는 FileIcon의 각 size와 동일 치수를 가지는 정사각 슬롯이 감싸고 내부 Spinner는 100%.
76
+
77
+ ### meta 폰트
78
+ meta는 size와 무관하게 항상 `--text-xs`(13) — 파일명보다 한 단계 작은 보조 톤 유지. label(파일명)만 size별로 변동(sm=`--text-xs`, md=`--text-sm`, lg=`--text-sm`).
@@ -0,0 +1,40 @@
1
+ # Message Attachment
2
+
3
+ ### 원칙
4
+ AI가 생성한 **큰 이미지**를 메시지 안에 보여주는 블록. 작은 썸네일(첨부물 바에 들어가는 것)은 [[components/attachment-thumb]]. 생성 중엔 sparkle 숨쉼, 완료되면 cover 이미지. hover 시 다운로드 버튼.
5
+
6
+ ### Do
7
+ - 세로형 포스터가 기본 (`aspect="portrait"` = 3:4) — 레퍼런스 톤.
8
+ - `loading` 중엔 그라데이션 + sparkle 애니. src 세팅되면 자동 전환.
9
+ - 다운로드가 필요하면 `onDownload` 전달 — hover 시 우상단 버튼 노출.
10
+ - 이미지 폭은 `--input-w-lg` 기준으로 약간 크게. 메시지 너비 안에서 자동 fit.
11
+
12
+ ### Don't
13
+ - MessageAttachment를 작은 썸네일 용도로 쓰지 않는다 — [[components/attachment-thumb]].
14
+ - loading과 src 동시에 넘기지 않는다 — loading이 우선.
15
+ - 다운로드 버튼을 상시 노출하지 않는다 — hover/focus-within에서만.
16
+
17
+ ### Fallback
18
+ aspect 애매하면 `portrait`. src 없으면 placeholder (sparkle 정적).
19
+
20
+ ### 관련
21
+ - [[components/attachment-thumb]] — 작은 썸네일
22
+ - [[components/ai-message]] — 상위 소비처
23
+
24
+ ### Props 시그니처
25
+ ```tsx
26
+ <MessageAttachment
27
+ src={string} // 생성된 이미지 URL
28
+ alt={string}
29
+ aspect="portrait | square | landscape" // default: portrait
30
+ loading={boolean} // 생성 중 — sparkle 애니
31
+ onDownload={(e) => void} // 지정 시 hover download 버튼
32
+ />
33
+ ```
34
+
35
+ ### 레이아웃
36
+ - 외곽: `radius-md`, `max-width: input-w-lg × 1.1`, overflow hidden
37
+ - aspect-ratio로 비율 고정 (`3/4`·`1/1`·`4/3`)
38
+ - loading/placeholder bg: `/illustrations/image-gen-placeholder.png` (public에 저장된 그라데이션 placeholder)
39
+ - 로딩 중 중앙 아이콘: [[components/spinner]] `variant="lottie-dots"` `size="md"`, 색은 `--bg` (bg 이미지 위에서 흰 톤)
40
+ - Download 버튼: `space-8` 정사각, 우상단 inset `space-2`, hover 시 fade-in + scale
@@ -26,22 +26,25 @@ kind 애매하면 `generating`, message 애매하면 kind별 기본.
26
26
  ```tsx
27
27
  <MessageState
28
28
  kind="generating | searching | memory | image | video | file | custom"
29
- message={ReactNode} // kind별 기본 라벨 override
30
- done={boolean} // true → ✓ + 애니 정지
29
+ message={ReactNode} // 활성 라벨 override (기본 "…ing…")
30
+ doneMessage={ReactNode} // 완료 라벨 override (기본 "…ed")
31
+ done={boolean} // true → ✓ + 애니 정지 + doneMessage로 전환
31
32
  icon={ReactNode} // kind="custom" 또는 override
32
33
  />
33
34
  ```
34
35
 
35
- ### Kind 매트릭스
36
- | kind | 아이콘 | 기본 라벨 |
37
- |---|---|---|
38
- | generating | Spinner (lottie-dots) | 응답 생성 중… |
39
- | searching | MagnifyingGlass | Searching the web… |
40
- | memory | PencilSimple | Updated saved memory |
41
- | image | Image | Looking at the image… |
42
- | video | VideoCamera | Reviewing the video… |
43
- | file | File | Processing file… |
44
- | custom | `icon` prop | `message` prop |
36
+ ### Kind 매트릭스 (active / done 라벨 pair)
37
+ | kind | 아이콘 | active | done |
38
+ |---|---|---|---|
39
+ | generating | Spinner (lottie-dots) | 응답 생성 중… | 응답 완료 |
40
+ | searching | MagnifyingGlass | Searching the web… | Searched the web |
41
+ | memory | PencilSimple | Updating memory… | Updated saved memory |
42
+ | image | Image | Looking at the image… | Looked at the image |
43
+ | video | VideoCamera | Reviewing the video… | Reviewed the video |
44
+ | file | File | Processing file… | Processed file |
45
+ | custom | `icon` prop | `message` prop | `doneMessage` prop |
46
+
47
+ 사용처가 커스텀 pair를 넘기면 (예: `message="Reading 'report.pdf'…" doneMessage="Read 'report.pdf'"`) 활성·완료 모두 자연스러운 영문 과거형으로.
45
48
 
46
49
  ### 애니메이션
47
50
  - 라벨 각 글자 wave bounce — `duration-slower × 5` 주기, `duration-base` 간격으로 index 지연
@@ -62,14 +62,14 @@
62
62
  **사이즈 주의**: Lottie는 vectorized라 size가 작으면 디테일 뭉개짐. `size="md"` 이상 권장. Button loading 안에 Lottie 쓰는 건 비권장 — 기본 variant(dots/ring)가 적합.
63
63
 
64
64
  ### Size 매트릭스
65
- IconButton 체계와 1:1 매칭. 로더는 캔버스를 다 채우지 않는 특성상 아이콘보다 한 단계 큰 bounds가 체감에 맞음.
65
+ IconButton 박스 크기와 1:1 매칭 — 로더가 들어가는 자리의 IconButton·Button 박스와 같은 size로.
66
66
 
67
67
  | size | 실제 크기 | 쓰임 |
68
68
  |---|---|---|
69
- | xs | 16px | Button xs 내부, 조밀한 인라인 |
70
- | sm | 20px | **기본**. 인라인 라벨 옆, 리스트 아이템, AIMessage generating |
69
+ | sm | 20px | 인라인 라벨 옆, 리스트 아이템, AIMessage generating |
71
70
  | md | 24px | 섹션 로딩, 카드 중앙 |
72
- | lg | 32px | 페이지 중앙 로딩, 모달 바디 로딩 |
71
+ | lg | 32px | **기본**. 조금 더 강조된 영역 |
72
+ | xl | 40px | 페이지 중앙 로딩, 모달 바디 로딩, Button loading(전 사이즈) |
73
73
 
74
74
  Lottie 로더는 캔버스 충전율이 다르므로 `visualScale`로 bounds를 꽉 채우도록 보정됨 — size만 고르면 체감이 일관되게 맞아야 함. 특정 Lottie가 작아 보이면 `lottieManifest.ts`의 `visualScale`을 올려 글로벌 보정, 사용처에서 size를 올리지 말 것.
75
75
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conscience-technology/ctds",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "CTDS — 하이브리드 디자인 시스템 설치 · 온보딩 CLI",
5
5
  "type": "module",
6
6
  "bin": {