@conscience-technology/ctds 0.1.12 → 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.
- package/assets/components/AttachmentChip/AttachmentChip.module.css +75 -33
- package/assets/components/AttachmentChip/AttachmentChip.tsx +118 -34
- package/assets/components/AttachmentChip/index.ts +5 -1
- package/assets/components/Button/Button.tsx +5 -5
- package/assets/components/Input/Input.module.css +3 -0
- package/assets/components/MessageAttachment/MessageAttachment.module.css +89 -0
- package/assets/components/MessageAttachment/MessageAttachment.tsx +70 -0
- package/assets/components/MessageAttachment/index.ts +5 -0
- package/assets/components/MessageState/MessageState.module.css +12 -6
- package/assets/components/MessageState/MessageState.tsx +23 -5
- package/assets/components/Spinner/Spinner.module.css +1 -1
- package/assets/components/Spinner/Spinner.tsx +2 -2
- package/assets/components/Spinner/lottieColor.ts +39 -24
- package/assets/components/Spinner/lottieManifest.ts +8 -8
- package/assets/components/_data.ts +2 -0
- package/assets/components/tokens.css +2 -3
- package/assets/prompts/components/attachment-chip.md +53 -21
- package/assets/prompts/components/message-attachment.md +40 -0
- package/assets/prompts/components/message-state.md +15 -12
- package/assets/prompts/components/spinner.md +4 -4
- package/package.json +1 -1
|
@@ -1,73 +1,115 @@
|
|
|
1
|
-
/* AttachmentChip — [
|
|
2
|
-
|
|
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
|
-
/*
|
|
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-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
-
/*
|
|
41
|
-
|
|
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 .
|
|
96
|
+
.sizeSm .loadingSlot {
|
|
57
97
|
width: var(--space-4);
|
|
58
98
|
height: var(--space-4);
|
|
59
99
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
color: var(--fg);
|
|
100
|
+
.sizeLg .loadingSlot {
|
|
101
|
+
width: var(--space-8);
|
|
102
|
+
height: var(--space-8);
|
|
64
103
|
}
|
|
65
|
-
.
|
|
66
|
-
|
|
67
|
-
|
|
104
|
+
.loadingSpinner {
|
|
105
|
+
width: 100%;
|
|
106
|
+
height: 100%;
|
|
68
107
|
}
|
|
69
108
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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 {
|
|
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<
|
|
18
|
+
extends Omit<HTMLAttributes<HTMLDivElement>, "children" | "onError"> {
|
|
12
19
|
/** 파일명. 확장자가 FileIcon 색/라벨을 결정. */
|
|
13
20
|
filename: string;
|
|
14
|
-
/**
|
|
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
|
-
|
|
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
|
-
*
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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);
|
|
39
|
-
const head = filename.slice(0, Math.max(1, max - ext.length -
|
|
40
|
-
return head + "
|
|
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 = [
|
|
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
|
-
|
|
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
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
}
|
|
@@ -65,10 +65,10 @@ const ICON_SIZE: Record<ButtonSize, number> = {
|
|
|
65
65
|
};
|
|
66
66
|
|
|
67
67
|
const SPINNER_SIZE: Record<ButtonSize, SpinnerSize> = {
|
|
68
|
-
xs: "
|
|
69
|
-
sm: "
|
|
70
|
-
md: "
|
|
71
|
-
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
|
+
}
|
|
@@ -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(--
|
|
23
|
-
height: var(--
|
|
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
|
-
/* 통통거리는 라벨
|
|
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) *
|
|
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%,
|
|
56
|
+
0%, 35%, 100% {
|
|
51
57
|
transform: translateY(0);
|
|
52
58
|
opacity: 0.6;
|
|
53
59
|
}
|
|
54
|
-
|
|
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
|
-
/** 완료 상태
|
|
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: "
|
|
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 =
|
|
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 ||
|
|
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 = "
|
|
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> = {
|
|
@@ -8,34 +8,49 @@
|
|
|
8
8
|
|
|
9
9
|
export type Rgb = [number, number, number];
|
|
10
10
|
|
|
11
|
+
/** oklch(L C H) → sRGB [r,g,b] 0~1. L은 0~1, C는 0~0.4, H는 degree.
|
|
12
|
+
* CTDS 토큰이 전부 oklch라 브라우저 Canvas 미지원을 우회하기 위해 수동 변환. */
|
|
13
|
+
function oklchToRgb(L: number, C: number, Hdeg: number): Rgb {
|
|
14
|
+
const H = (Hdeg * Math.PI) / 180;
|
|
15
|
+
const a = C * Math.cos(H);
|
|
16
|
+
const b = C * Math.sin(H);
|
|
17
|
+
// OKLab → linear sRGB (Björn Ottosson)
|
|
18
|
+
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
19
|
+
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
20
|
+
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
|
21
|
+
const l = l_ * l_ * l_;
|
|
22
|
+
const m = m_ * m_ * m_;
|
|
23
|
+
const s = s_ * s_ * s_;
|
|
24
|
+
const r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
|
|
25
|
+
const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
|
|
26
|
+
const bl = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s;
|
|
27
|
+
// linear → sRGB gamma
|
|
28
|
+
const toSrgb = (x: number) => {
|
|
29
|
+
const c = Math.max(0, Math.min(1, x));
|
|
30
|
+
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
|
|
31
|
+
};
|
|
32
|
+
return [toSrgb(r), toSrgb(g), toSrgb(bl)];
|
|
33
|
+
}
|
|
34
|
+
|
|
11
35
|
/** CSS 색 문자열 → [r,g,b] 0~1 정규화. 서버에서는 null 반환.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* rgb() 폴백 파싱. */
|
|
36
|
+
* oklch()는 수동 변환(브라우저 Canvas가 oklch 입력을 받지 못하는 경우가 많음).
|
|
37
|
+
* rgb()/rgba()/#hex는 그대로 파싱. */
|
|
15
38
|
export function parseCssColor(css: string): Rgb | null {
|
|
16
39
|
if (typeof document === "undefined") return null;
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
if (
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
const normalized = ctx.fillStyle as string;
|
|
29
|
-
const hex = /^#([0-9a-f]{6})$/i.exec(normalized);
|
|
30
|
-
if (hex) {
|
|
31
|
-
const n = parseInt(hex[1], 16);
|
|
32
|
-
return [((n >> 16) & 0xff) / 255, ((n >> 8) & 0xff) / 255, (n & 0xff) / 255];
|
|
33
|
-
}
|
|
34
|
-
// Canvas가 못 받으면 rgba()/rgb() 문자열일 수 있음 → 숫자 파싱
|
|
35
|
-
const m = normalized.match(/-?\d+(\.\d+)?/g);
|
|
36
|
-
if (m && m.length >= 3 && !normalized.startsWith("oklch") && !normalized.startsWith("color(")) {
|
|
37
|
-
return [+m[0] / 255, +m[1] / 255, +m[2] / 255];
|
|
40
|
+
const trimmed = css.trim();
|
|
41
|
+
// oklch(L C H) 또는 oklch(L C H / A) — 수동 변환
|
|
42
|
+
const oklchMatch = /^oklch\(\s*([^\s,/)]+)[\s,]+([^\s,/)]+)[\s,]+([^\s,/)]+)/i.exec(trimmed);
|
|
43
|
+
if (oklchMatch) {
|
|
44
|
+
const parsePart = (s: string, scale: number) =>
|
|
45
|
+
s.endsWith("%") ? (parseFloat(s) / 100) * scale : parseFloat(s);
|
|
46
|
+
const L = parsePart(oklchMatch[1], 1);
|
|
47
|
+
const C = parsePart(oklchMatch[2], 0.4);
|
|
48
|
+
const H = parseFloat(oklchMatch[3]);
|
|
49
|
+
if ([L, C, H].every((v) => Number.isFinite(v))) return oklchToRgb(L, C, H);
|
|
38
50
|
}
|
|
51
|
+
// rgb() / rgba() / #hex — 숫자 파싱 (CSS Typed OM 없이 간단히)
|
|
52
|
+
const m = trimmed.match(/-?\d+(\.\d+)?/g);
|
|
53
|
+
if (m && m.length >= 3) return [+m[0] / 255, +m[1] / 255, +m[2] / 255];
|
|
39
54
|
return null;
|
|
40
55
|
}
|
|
41
56
|
|
|
@@ -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.
|
|
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.
|
|
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:
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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건
|
|
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
|
-
-
|
|
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
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
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"
|
|
32
|
-
|
|
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
|
-
-
|
|
38
|
-
- `--hairline` 1px border, `var(--border)`
|
|
39
|
-
-
|
|
40
|
-
-
|
|
41
|
-
-
|
|
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 >
|
|
45
|
-
-
|
|
46
|
-
-
|
|
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} //
|
|
30
|
-
|
|
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
|
|
65
|
+
IconButton 박스 크기와 1:1 매칭 — 로더가 들어가는 자리의 IconButton·Button 박스와 같은 size로.
|
|
66
66
|
|
|
67
67
|
| size | 실제 크기 | 쓰임 |
|
|
68
68
|
|---|---|---|
|
|
69
|
-
|
|
|
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
|
|