@conscience-technology/ctds 0.1.10 → 0.1.12
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/AIMessage/AIMessage.module.css +27 -7
- package/assets/components/AIMessage/AIMessage.tsx +18 -2
- package/assets/components/AttachmentThumb/AttachmentThumb.module.css +25 -4
- package/assets/components/CodeBlock/CodeBlock.module.css +3 -3
- package/assets/components/MessageQuote/MessageQuote.module.css +12 -1
- package/assets/components/MessageSources/MessageSources.module.css +35 -40
- package/assets/components/MessageSources/MessageSources.tsx +32 -22
- package/assets/components/MessageState/MessageState.module.css +66 -0
- package/assets/components/MessageState/MessageState.tsx +107 -0
- package/assets/components/MessageState/index.ts +2 -0
- package/assets/components/MessageTable/MessageTable.module.css +33 -20
- package/assets/components/Spinner/LottiePlayer.tsx +19 -6
- package/assets/components/Spinner/Spinner.module.css +15 -13
- package/assets/components/Spinner/Spinner.tsx +2 -4
- package/assets/components/Spinner/lottieColor.ts +62 -36
- package/assets/components/Spinner/lottieManifest.ts +18 -17
- package/assets/components/_data.ts +2 -1
- package/assets/prompts/components/ai-message.md +3 -1
- package/assets/prompts/components/calendar.md +11 -18
- package/assets/prompts/components/event-chip.md +74 -0
- package/assets/prompts/components/message-sources.md +17 -10
- package/assets/prompts/components/message-state.md +53 -0
- package/package.json +1 -1
|
@@ -22,16 +22,24 @@
|
|
|
22
22
|
min-width: 0;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
/* isGenerating — 한 글자씩 통통 wave */
|
|
25
|
+
/* isGenerating — 한 글자씩 통통 wave. Spinner+텍스트 세로 중앙 정렬. */
|
|
26
26
|
.root[data-generating="true"] .body {
|
|
27
27
|
color: var(--fg-muted);
|
|
28
28
|
font-weight: var(--font-weight-medium);
|
|
29
|
+
display: inline-flex;
|
|
30
|
+
align-items: center;
|
|
31
|
+
gap: var(--space-2);
|
|
32
|
+
line-height: 1.4;
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
.generatingSpinner {
|
|
32
|
-
color: var(--
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
color: var(--primary);
|
|
37
|
+
flex: 0 0 auto;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.generatingText {
|
|
41
|
+
display: inline-flex;
|
|
42
|
+
align-items: center;
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
.bounceChar {
|
|
@@ -60,12 +68,22 @@
|
|
|
60
68
|
.body > :global(h3) {
|
|
61
69
|
color: var(--fg-strong);
|
|
62
70
|
font-weight: var(--font-weight-semibold);
|
|
63
|
-
margin: var(--space-
|
|
71
|
+
margin: var(--space-6) 0 var(--space-2);
|
|
72
|
+
line-height: 1.3;
|
|
73
|
+
}
|
|
74
|
+
/* 본문 첫 요소는 상단 margin 제거 (AIMessage body gap이 대신) */
|
|
75
|
+
.body > :first-child {
|
|
76
|
+
margin-top: 0;
|
|
64
77
|
}
|
|
65
78
|
.body > :global(p) {
|
|
66
79
|
margin: var(--space-2) 0;
|
|
67
80
|
}
|
|
68
|
-
/*
|
|
81
|
+
/* 블록 요소 (CodeBlock·MessageQuote·MessageSources·MessageTable wrapper) — 섹션 분리감 */
|
|
82
|
+
.body > :global(div),
|
|
83
|
+
.body > :global(blockquote) {
|
|
84
|
+
margin: var(--space-3) 0;
|
|
85
|
+
}
|
|
86
|
+
/* 리스트 — 들여쓰기 최소화. 마커는 fg-muted(다크그레이보다 한 단계 연함). */
|
|
69
87
|
.body :global(ol),
|
|
70
88
|
.body :global(ul) {
|
|
71
89
|
padding-left: var(--space-4);
|
|
@@ -76,7 +94,7 @@
|
|
|
76
94
|
padding-left: 0;
|
|
77
95
|
}
|
|
78
96
|
.body :global(li::marker) {
|
|
79
|
-
color: var(--
|
|
97
|
+
color: var(--fg-muted);
|
|
80
98
|
font-variant-numeric: tabular-nums;
|
|
81
99
|
}
|
|
82
100
|
/* 중첩 리스트 — 조금만 더 들여쓰기 */
|
|
@@ -95,6 +113,8 @@
|
|
|
95
113
|
|
|
96
114
|
.actions {
|
|
97
115
|
display: flex;
|
|
116
|
+
align-items: center;
|
|
117
|
+
gap: var(--space-2);
|
|
98
118
|
}
|
|
99
119
|
|
|
100
120
|
/* visibility=show: 상시 */
|
|
@@ -5,6 +5,10 @@ import {
|
|
|
5
5
|
MessageActions,
|
|
6
6
|
type MessageAction,
|
|
7
7
|
} from "@/components/ctds/MessageActions";
|
|
8
|
+
import {
|
|
9
|
+
MessageSources,
|
|
10
|
+
type MessageSource,
|
|
11
|
+
} from "@/components/ctds/MessageSources";
|
|
8
12
|
import { Spinner } from "@/components/ctds/Spinner";
|
|
9
13
|
import styles from "./AIMessage.module.css";
|
|
10
14
|
|
|
@@ -19,6 +23,10 @@ export interface AIMessageProps extends HTMLAttributes<HTMLDivElement> {
|
|
|
19
23
|
actionsVisibility?: AIMessageActionsVisibility;
|
|
20
24
|
/** 액션 커스터마이즈. 기본 [copy, regenerate]. 비우려면 []. */
|
|
21
25
|
actions?: MessageAction[];
|
|
26
|
+
/** 소스 목록 — 액션바 옆에 inline chip으로 노출. */
|
|
27
|
+
sources?: MessageSource[];
|
|
28
|
+
/** sources chip 클릭 시 (popover 열기 등). */
|
|
29
|
+
onSourcesClick?: () => void;
|
|
22
30
|
/** 생성 중이면 액션 숨김. */
|
|
23
31
|
isGenerating?: boolean;
|
|
24
32
|
}
|
|
@@ -55,12 +63,15 @@ export function AIMessage({
|
|
|
55
63
|
label,
|
|
56
64
|
actionsVisibility = "hover",
|
|
57
65
|
actions = DEFAULT_ACTIONS,
|
|
66
|
+
sources,
|
|
67
|
+
onSourcesClick,
|
|
58
68
|
isGenerating = false,
|
|
59
69
|
className,
|
|
60
70
|
...rest
|
|
61
71
|
}: AIMessageProps) {
|
|
62
72
|
const cls = [styles.root, className].filter(Boolean).join(" ");
|
|
63
|
-
const
|
|
73
|
+
const hasSources = sources && sources.length > 0 && !isGenerating;
|
|
74
|
+
const hasActions = (actions.length > 0 || hasSources) && !isGenerating;
|
|
64
75
|
|
|
65
76
|
return (
|
|
66
77
|
<div
|
|
@@ -74,7 +85,9 @@ export function AIMessage({
|
|
|
74
85
|
{isGenerating ? (
|
|
75
86
|
<>
|
|
76
87
|
<Spinner variant="lottie-dots" size="sm" className={styles.generatingSpinner} />
|
|
77
|
-
{
|
|
88
|
+
<span className={styles.generatingText}>
|
|
89
|
+
{typeof children === "string" ? renderBouncingText(children) : children}
|
|
90
|
+
</span>
|
|
78
91
|
</>
|
|
79
92
|
) : (
|
|
80
93
|
children
|
|
@@ -83,6 +96,9 @@ export function AIMessage({
|
|
|
83
96
|
{hasActions ? (
|
|
84
97
|
<div className={styles.actions}>
|
|
85
98
|
<MessageActions align="start" actions={actions} />
|
|
99
|
+
{hasSources ? (
|
|
100
|
+
<MessageSources sources={sources!} onClick={onSourcesClick} />
|
|
101
|
+
) : null}
|
|
86
102
|
</div>
|
|
87
103
|
) : null}
|
|
88
104
|
</div>
|
|
@@ -53,15 +53,36 @@
|
|
|
53
53
|
height: calc(var(--at-size) * 0.60);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
/* Loading —
|
|
57
|
-
Spinner
|
|
56
|
+
/* Loading — bg-muted ↔ 더 어두운 톤 사이를 미묘하게 흐르는 그라데이션 + 중앙 흰색 lottie spinner.
|
|
57
|
+
Spinner colorBind가 .loading의 color(=fixed-white)를 상속받아 흰색으로 recolor된다. */
|
|
58
58
|
.loading {
|
|
59
|
-
background:
|
|
59
|
+
background: var(--bg-muted);
|
|
60
60
|
color: var(--fixed-white);
|
|
61
61
|
overflow: hidden;
|
|
62
62
|
}
|
|
63
|
-
|
|
63
|
+
.loading::before {
|
|
64
|
+
content: "";
|
|
65
|
+
position: absolute;
|
|
66
|
+
inset: 0;
|
|
67
|
+
background: linear-gradient(
|
|
68
|
+
120deg,
|
|
69
|
+
var(--bg-muted) 0%,
|
|
70
|
+
color-mix(in oklch, var(--fg) 18%, var(--bg-muted)) 50%,
|
|
71
|
+
var(--bg-muted) 100%
|
|
72
|
+
);
|
|
73
|
+
background-size: 220% 220%;
|
|
74
|
+
border-radius: inherit;
|
|
75
|
+
animation: at-shimmer calc(var(--duration-slower) * 6) var(--ease-in-out) infinite;
|
|
76
|
+
}
|
|
77
|
+
@keyframes at-shimmer {
|
|
78
|
+
0% { background-position: 0% 50%; }
|
|
79
|
+
50% { background-position: 100% 50%; }
|
|
80
|
+
100% { background-position: 0% 50%; }
|
|
81
|
+
}
|
|
82
|
+
/* Spinner는 pulse 위 레이어로 중앙 고정, wide 로티도 꽉 차 보이게 scale-up */
|
|
64
83
|
.loading > span[role="status"] {
|
|
84
|
+
position: relative;
|
|
85
|
+
z-index: var(--z-raise);
|
|
65
86
|
transform: scale(1.6);
|
|
66
87
|
transform-origin: center;
|
|
67
88
|
}
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
justify-content: center;
|
|
39
39
|
padding: 0;
|
|
40
40
|
border: 0;
|
|
41
|
-
background:
|
|
41
|
+
background: transparent;
|
|
42
42
|
color: var(--fg-muted);
|
|
43
43
|
border-radius: var(--radius-sm);
|
|
44
44
|
cursor: pointer;
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
transition: var(--transition-colors);
|
|
48
48
|
}
|
|
49
49
|
.copyBtn:hover {
|
|
50
|
-
background: var(--
|
|
51
|
-
color: var(--fg);
|
|
50
|
+
background: var(--hover);
|
|
51
|
+
color: var(--fg-strong);
|
|
52
52
|
}
|
|
53
53
|
.copyBtn:focus-visible {
|
|
54
54
|
outline: none;
|
|
@@ -12,7 +12,18 @@
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
.blockquote {
|
|
15
|
+
position: relative;
|
|
15
16
|
padding-left: var(--space-4);
|
|
16
|
-
border-left: var(--space-0_5) solid var(--border-strong);
|
|
17
17
|
color: var(--fg-muted);
|
|
18
18
|
}
|
|
19
|
+
/* bar를 pseudo로 — 상·하에서 살짝 들어가 짧은 느낌 */
|
|
20
|
+
.blockquote::before {
|
|
21
|
+
content: "";
|
|
22
|
+
position: absolute;
|
|
23
|
+
left: 0;
|
|
24
|
+
top: var(--space-1);
|
|
25
|
+
bottom: var(--space-1);
|
|
26
|
+
width: var(--space-0_5);
|
|
27
|
+
background: var(--border-strong);
|
|
28
|
+
border-radius: var(--radius-full);
|
|
29
|
+
}
|
|
@@ -1,63 +1,58 @@
|
|
|
1
|
-
/* MessageSources — AIMessage
|
|
1
|
+
/* MessageSources — AIMessage 액션바 inline chip.
|
|
2
|
+
[□□□ 겹친 favicon] Sources +N 형태. 작고 가벼운 chip. */
|
|
2
3
|
|
|
3
4
|
.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
5
|
display: inline-flex;
|
|
23
6
|
align-items: center;
|
|
24
|
-
gap: var(--space-
|
|
25
|
-
padding: var(--space-
|
|
26
|
-
|
|
7
|
+
gap: var(--space-1_5);
|
|
8
|
+
padding: var(--space-1_5) var(--space-3) var(--space-1_5) var(--space-2);
|
|
9
|
+
border: 0;
|
|
10
|
+
background: transparent;
|
|
11
|
+
color: var(--fg-muted);
|
|
27
12
|
border-radius: var(--radius-full);
|
|
28
|
-
|
|
29
|
-
font-size: var(--text-
|
|
30
|
-
|
|
13
|
+
cursor: pointer;
|
|
14
|
+
font-size: var(--text-sm);
|
|
15
|
+
line-height: 1;
|
|
31
16
|
transition: var(--transition-colors);
|
|
32
|
-
max-width: 100%;
|
|
33
|
-
min-width: 0;
|
|
34
17
|
}
|
|
35
|
-
.
|
|
36
|
-
background: var(--bg-
|
|
18
|
+
.root:hover {
|
|
19
|
+
background: var(--bg-subtle);
|
|
37
20
|
color: var(--fg-strong);
|
|
38
21
|
}
|
|
39
|
-
.
|
|
22
|
+
.root:focus-visible {
|
|
40
23
|
outline: none;
|
|
41
24
|
box-shadow: var(--ring);
|
|
42
25
|
}
|
|
43
26
|
|
|
44
|
-
.
|
|
27
|
+
/* favicon stack — 서로 살짝 겹침 (negative margin).
|
|
28
|
+
각 favicon은 box-shadow로 bg와 같은 cutout 링을 둘러 겹침 경계를 명확히. */
|
|
29
|
+
.stack {
|
|
30
|
+
display: inline-flex;
|
|
31
|
+
align-items: center;
|
|
32
|
+
}
|
|
33
|
+
.favicon {
|
|
45
34
|
display: inline-flex;
|
|
46
35
|
align-items: center;
|
|
47
36
|
justify-content: center;
|
|
48
37
|
width: var(--space-4);
|
|
49
38
|
height: var(--space-4);
|
|
39
|
+
border-radius: var(--radius-full);
|
|
40
|
+
background: var(--bg-muted);
|
|
41
|
+
box-shadow: 0 0 0 var(--hairline) var(--bg);
|
|
42
|
+
overflow: hidden;
|
|
43
|
+
margin-left: calc(var(--space-1) * -1);
|
|
50
44
|
flex: 0 0 auto;
|
|
51
|
-
color: var(--fg-muted);
|
|
52
45
|
}
|
|
53
|
-
.
|
|
54
|
-
|
|
55
|
-
height: 80%;
|
|
46
|
+
.favicon:first-child {
|
|
47
|
+
margin-left: 0;
|
|
56
48
|
}
|
|
57
49
|
|
|
58
|
-
.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
50
|
+
.label {
|
|
51
|
+
display: inline-flex;
|
|
52
|
+
align-items: baseline;
|
|
53
|
+
gap: var(--space-0_5);
|
|
54
|
+
}
|
|
55
|
+
.extra {
|
|
56
|
+
color: var(--fg-muted);
|
|
57
|
+
font-variant-numeric: tabular-nums;
|
|
63
58
|
}
|
|
@@ -1,50 +1,60 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import type { HTMLAttributes } from "react";
|
|
4
|
-
import { Link as PhLink } from "@phosphor-icons/react";
|
|
3
|
+
import type { HTMLAttributes, MouseEvent, ReactNode } from "react";
|
|
5
4
|
import styles from "./MessageSources.module.css";
|
|
6
5
|
|
|
7
6
|
export interface MessageSource {
|
|
8
7
|
id: string;
|
|
9
8
|
label: string;
|
|
10
9
|
href?: string;
|
|
11
|
-
/**
|
|
12
|
-
icon?:
|
|
10
|
+
/** 왼쪽 favicon. 없으면 중성 원형 placeholder. */
|
|
11
|
+
icon?: ReactNode;
|
|
13
12
|
}
|
|
14
13
|
|
|
15
|
-
export interface MessageSourcesProps extends HTMLAttributes<
|
|
14
|
+
export interface MessageSourcesProps extends HTMLAttributes<HTMLButtonElement> {
|
|
16
15
|
sources: MessageSource[];
|
|
17
|
-
/**
|
|
16
|
+
/** 라벨 override. 기본 "Sources". */
|
|
18
17
|
label?: string;
|
|
18
|
+
/** 겹쳐 보여줄 favicon 최대 개수. 기본 3. */
|
|
19
|
+
maxFavicons?: number;
|
|
20
|
+
/** chip 클릭 시 (popover 열기 등). 외부에서 처리. */
|
|
21
|
+
onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
|
|
19
22
|
}
|
|
20
23
|
|
|
24
|
+
/** AIMessage의 MessageActions 바 옆에 inline으로 들어가는 작은 chip.
|
|
25
|
+
* [겹친 favicon stack] + 라벨 (+N) 형태. 클릭 시 popover 등은 소비처가 조립. */
|
|
21
26
|
export function MessageSources({
|
|
22
27
|
sources,
|
|
23
28
|
label = "Sources",
|
|
29
|
+
maxFavicons = 3,
|
|
30
|
+
onClick,
|
|
24
31
|
className,
|
|
25
32
|
...rest
|
|
26
33
|
}: MessageSourcesProps) {
|
|
34
|
+
const visible = sources.slice(0, maxFavicons);
|
|
35
|
+
|
|
27
36
|
const cls = [styles.root, className].filter(Boolean).join(" ");
|
|
28
37
|
|
|
29
38
|
return (
|
|
30
|
-
<
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
39
|
+
<button
|
|
40
|
+
type="button"
|
|
41
|
+
className={cls}
|
|
42
|
+
onClick={onClick}
|
|
43
|
+
aria-label={`${label} (${sources.length})`}
|
|
44
|
+
{...rest}
|
|
45
|
+
>
|
|
46
|
+
<span className={styles.stack} aria-hidden>
|
|
47
|
+
{visible.map((s, i) => (
|
|
48
|
+
<span
|
|
35
49
|
key={s.id}
|
|
36
|
-
className={styles.
|
|
37
|
-
|
|
38
|
-
target={s.href ? "_blank" : undefined}
|
|
39
|
-
rel={s.href ? "noopener noreferrer" : undefined}
|
|
50
|
+
className={styles.favicon}
|
|
51
|
+
style={{ ["--stack-i" as string]: String(i) }}
|
|
40
52
|
>
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
</span>
|
|
44
|
-
<span className={styles.chipLabel}>{s.label}</span>
|
|
45
|
-
</a>
|
|
53
|
+
{s.icon ?? null}
|
|
54
|
+
</span>
|
|
46
55
|
))}
|
|
47
|
-
</
|
|
48
|
-
|
|
56
|
+
</span>
|
|
57
|
+
<span className={styles.label}>{label}</span>
|
|
58
|
+
</button>
|
|
49
59
|
);
|
|
50
60
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/* MessageState — AI 응답 중 "뭘 하고 있는지" 라인.
|
|
2
|
+
Lottie/Spinner 리더 + 통통거리는 라벨. done=true면 ✓ 로 전환, 라벨 정적. */
|
|
3
|
+
|
|
4
|
+
.root {
|
|
5
|
+
display: inline-flex;
|
|
6
|
+
align-items: center;
|
|
7
|
+
gap: var(--space-2);
|
|
8
|
+
color: var(--fg-muted);
|
|
9
|
+
font-size: var(--text-sm);
|
|
10
|
+
line-height: 1.4;
|
|
11
|
+
font-weight: var(--font-weight-medium);
|
|
12
|
+
}
|
|
13
|
+
.root[data-done] {
|
|
14
|
+
color: var(--fg);
|
|
15
|
+
font-weight: var(--font-weight-regular);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.leadIcon {
|
|
19
|
+
display: inline-flex;
|
|
20
|
+
align-items: center;
|
|
21
|
+
justify-content: center;
|
|
22
|
+
width: var(--space-4);
|
|
23
|
+
height: var(--space-4);
|
|
24
|
+
color: var(--primary);
|
|
25
|
+
flex: 0 0 auto;
|
|
26
|
+
}
|
|
27
|
+
.root[data-done] .leadIcon {
|
|
28
|
+
color: var(--success);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.leadSpinner {
|
|
32
|
+
color: var(--primary);
|
|
33
|
+
flex: 0 0 auto;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* 통통거리는 라벨 (AIMessage의 generating 패턴과 동일 — 중복 정의) */
|
|
37
|
+
.label {
|
|
38
|
+
display: inline-flex;
|
|
39
|
+
align-items: center;
|
|
40
|
+
}
|
|
41
|
+
.bounceChar {
|
|
42
|
+
display: inline-block;
|
|
43
|
+
animation: msgstate-char-bounce calc(var(--duration-slower) * 5) var(--ease-in-out) infinite;
|
|
44
|
+
}
|
|
45
|
+
.bounceSpace {
|
|
46
|
+
display: inline-block;
|
|
47
|
+
white-space: pre;
|
|
48
|
+
}
|
|
49
|
+
@keyframes msgstate-char-bounce {
|
|
50
|
+
0%, 70%, 100% {
|
|
51
|
+
transform: translateY(0);
|
|
52
|
+
opacity: 0.6;
|
|
53
|
+
}
|
|
54
|
+
35% {
|
|
55
|
+
transform: translateY(calc(-1.5px * var(--motion-distance-scale))); /* lint-hardcoded: ignore */
|
|
56
|
+
opacity: 1;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* done일 땐 애니 정지 (bounce span도 opacity 1 static) */
|
|
61
|
+
.root[data-done] .bounceChar,
|
|
62
|
+
.root[data-done] .bounceSpace {
|
|
63
|
+
animation: none;
|
|
64
|
+
opacity: 1;
|
|
65
|
+
transform: none;
|
|
66
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
4
|
+
import {
|
|
5
|
+
MagnifyingGlass as PhSearch,
|
|
6
|
+
PencilSimple as PhPencil,
|
|
7
|
+
Image as PhImage,
|
|
8
|
+
VideoCamera as PhVideo,
|
|
9
|
+
File as PhFile,
|
|
10
|
+
Check as PhCheck,
|
|
11
|
+
} from "@phosphor-icons/react";
|
|
12
|
+
import { Spinner } from "@/components/ctds/Spinner";
|
|
13
|
+
import styles from "./MessageState.module.css";
|
|
14
|
+
|
|
15
|
+
export type MessageStateKind =
|
|
16
|
+
| "generating"
|
|
17
|
+
| "searching"
|
|
18
|
+
| "memory"
|
|
19
|
+
| "image"
|
|
20
|
+
| "video"
|
|
21
|
+
| "file"
|
|
22
|
+
| "custom";
|
|
23
|
+
|
|
24
|
+
export interface MessageStateProps extends HTMLAttributes<HTMLDivElement> {
|
|
25
|
+
kind?: MessageStateKind;
|
|
26
|
+
/** 라벨 override. kind별 기본 라벨이 있지만 커스터마이즈 가능. */
|
|
27
|
+
message?: ReactNode;
|
|
28
|
+
/** 완료 상태 — true면 spinner 자리에 ✓, 라벨 통통거림 멈춤. */
|
|
29
|
+
done?: boolean;
|
|
30
|
+
/** 커스텀 아이콘 — kind="custom"이거나 override 하고 싶을 때. */
|
|
31
|
+
icon?: ReactNode;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const DEFAULT_MESSAGE: Record<MessageStateKind, string> = {
|
|
35
|
+
generating: "응답 생성 중…",
|
|
36
|
+
searching: "Searching the web…",
|
|
37
|
+
memory: "Updated saved memory",
|
|
38
|
+
image: "Looking at the image…",
|
|
39
|
+
video: "Reviewing the video…",
|
|
40
|
+
file: "Processing file…",
|
|
41
|
+
custom: "",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const KIND_ICON: Record<MessageStateKind, React.ComponentType<any> | null> = {
|
|
45
|
+
generating: null, // Spinner만
|
|
46
|
+
searching: PhSearch,
|
|
47
|
+
memory: PhPencil,
|
|
48
|
+
image: PhImage,
|
|
49
|
+
video: PhVideo,
|
|
50
|
+
file: PhFile,
|
|
51
|
+
custom: null,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function renderBouncingText(text: string) {
|
|
55
|
+
const chars = Array.from(text);
|
|
56
|
+
return chars.map((ch, i) => {
|
|
57
|
+
if (ch === " ") {
|
|
58
|
+
return (
|
|
59
|
+
<span key={i} className={styles.bounceSpace}>
|
|
60
|
+
{" "}
|
|
61
|
+
</span>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return (
|
|
65
|
+
<span
|
|
66
|
+
key={i}
|
|
67
|
+
className={styles.bounceChar}
|
|
68
|
+
style={{ animationDelay: `calc(${i} * var(--duration-base))` }}
|
|
69
|
+
>
|
|
70
|
+
{ch}
|
|
71
|
+
</span>
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function MessageState({
|
|
77
|
+
kind = "generating",
|
|
78
|
+
message,
|
|
79
|
+
done = false,
|
|
80
|
+
icon,
|
|
81
|
+
className,
|
|
82
|
+
...rest
|
|
83
|
+
}: MessageStateProps) {
|
|
84
|
+
const label = message ?? DEFAULT_MESSAGE[kind];
|
|
85
|
+
const KindIcon = KIND_ICON[kind];
|
|
86
|
+
|
|
87
|
+
const cls = [styles.root, className].filter(Boolean).join(" ");
|
|
88
|
+
|
|
89
|
+
const leadIcon = done ? (
|
|
90
|
+
<PhCheck className={styles.leadIcon} weight="bold" aria-hidden />
|
|
91
|
+
) : icon ? (
|
|
92
|
+
<span className={styles.leadIcon}>{icon}</span>
|
|
93
|
+
) : KindIcon ? (
|
|
94
|
+
<KindIcon className={styles.leadIcon} weight="regular" aria-hidden />
|
|
95
|
+
) : (
|
|
96
|
+
<Spinner variant="lottie-dots" size="sm" className={styles.leadSpinner} />
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div className={cls} data-done={done || undefined} {...rest}>
|
|
101
|
+
{leadIcon}
|
|
102
|
+
<span className={styles.label}>
|
|
103
|
+
{done || typeof label !== "string" ? label : renderBouncingText(label)}
|
|
104
|
+
</span>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
/* MessageTable — markdown table
|
|
2
|
-
|
|
1
|
+
/* MessageTable — AIMessage 안 markdown table. TableHeader/TableRow의 line variant 톤에 맞춤:
|
|
2
|
+
외곽 border 없음, header도 bg 없이 하단 hairline만. row도 하단 hairline만.
|
|
3
|
+
wrapper는 가로 overflow 시 scroll. */
|
|
3
4
|
|
|
4
5
|
.root {
|
|
5
6
|
width: 100%;
|
|
6
7
|
overflow-x: auto;
|
|
7
|
-
border: var(--hairline) solid var(--border);
|
|
8
|
-
border-radius: var(--radius-md);
|
|
9
8
|
}
|
|
10
9
|
|
|
11
10
|
.root :global(table) {
|
|
@@ -16,31 +15,45 @@
|
|
|
16
15
|
table-layout: auto;
|
|
17
16
|
}
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
/* header — bg 투명, 하단 hairline만 */
|
|
19
|
+
.root :global(thead tr) {
|
|
20
|
+
border-bottom: var(--hairline) solid var(--border);
|
|
21
|
+
}
|
|
22
|
+
.root :global(th) {
|
|
23
|
+
color: var(--fg-strong);
|
|
24
|
+
font-weight: var(--font-weight-medium);
|
|
25
|
+
text-align: left;
|
|
26
|
+
padding: var(--space-2) var(--space-2);
|
|
27
|
+
line-height: var(--label-sm-line-height);
|
|
28
|
+
font-size: var(--label-sm-size);
|
|
21
29
|
}
|
|
22
30
|
|
|
23
|
-
.
|
|
31
|
+
/* 첫 셀 좌측 padding 0, 마지막 셀 우측 padding 0 — TableHeader.line과 짝 */
|
|
32
|
+
.root :global(thead th:first-child),
|
|
33
|
+
.root :global(tbody td:first-child) {
|
|
34
|
+
padding-left: 0;
|
|
35
|
+
}
|
|
36
|
+
.root :global(thead th:last-child),
|
|
37
|
+
.root :global(tbody td:last-child) {
|
|
38
|
+
padding-right: 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* row — 하단 hairline만, 배경 투명 */
|
|
42
|
+
.root :global(tbody tr) {
|
|
43
|
+
border-bottom: var(--hairline) solid var(--border);
|
|
44
|
+
}
|
|
45
|
+
.root :global(tbody tr:last-child) {
|
|
46
|
+
border-bottom: 0;
|
|
47
|
+
}
|
|
24
48
|
.root :global(td) {
|
|
25
|
-
padding: var(--space-2) var(--space-
|
|
49
|
+
padding: var(--space-2) var(--space-2);
|
|
26
50
|
text-align: left;
|
|
27
51
|
vertical-align: top;
|
|
28
|
-
border-bottom: var(--hairline) solid var(--border);
|
|
29
52
|
line-height: 1.5;
|
|
30
53
|
}
|
|
31
54
|
|
|
32
|
-
|
|
33
|
-
color: var(--fg-strong);
|
|
34
|
-
font-weight: var(--font-weight-semibold);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/* 숫자 셀 좌측 정렬 유지(피드백 no-right-align-numbers), 숫자는 tabular */
|
|
55
|
+
/* 숫자 셀도 좌측 정렬 유지 (feedback-no-right-align-numbers), 숫자 폭 균일 */
|
|
38
56
|
.root :global(td),
|
|
39
57
|
.root :global(th) {
|
|
40
58
|
font-variant-numeric: tabular-nums;
|
|
41
59
|
}
|
|
42
|
-
|
|
43
|
-
/* 마지막 행 border 제거 */
|
|
44
|
-
.root :global(tr:last-child td) {
|
|
45
|
-
border-bottom: 0;
|
|
46
|
-
}
|
|
@@ -53,20 +53,33 @@ export function LottiePlayer({ name, className }: LottiePlayerProps) {
|
|
|
53
53
|
// 바깥 요소의 computed color를 읽어 재색상화
|
|
54
54
|
const el = containerRef.current;
|
|
55
55
|
const css = el ? getComputedStyle(el).color : "rgb(0,0,0)";
|
|
56
|
-
const rgb = parseCssColor(css)
|
|
57
|
-
|
|
56
|
+
const rgb = parseCssColor(css);
|
|
57
|
+
// eslint-disable-next-line no-console
|
|
58
|
+
console.log(
|
|
59
|
+
`[LottiePlayer] name=${name} | computed.color="${css}" | parsed=${
|
|
60
|
+
rgb ? `[${rgb.map((v) => (v * 255).toFixed(0)).join(",")}]` : "NULL(fallback to black)"
|
|
61
|
+
}`,
|
|
62
|
+
);
|
|
63
|
+
setData(recolorLottie(raw, rgb ?? [0, 0, 0]));
|
|
58
64
|
});
|
|
59
65
|
return () => {
|
|
60
66
|
cancelled = true;
|
|
61
67
|
};
|
|
62
68
|
}, [name, entry.colorBind]);
|
|
63
69
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
:
|
|
70
|
+
// offset은 bounds(--loader-size) 비율 기준. 스케일된 자기 요소 % 혼동을 피하려고 calc로 묶음.
|
|
71
|
+
const style = {
|
|
72
|
+
...(entry.visualScale ? { "--lottie-scale": entry.visualScale } : {}),
|
|
73
|
+
...(entry.offsetX
|
|
74
|
+
? { "--lottie-offset-x": `calc(var(--loader-size) * ${entry.offsetX})` }
|
|
75
|
+
: {}),
|
|
76
|
+
...(entry.offsetY
|
|
77
|
+
? { "--lottie-offset-y": `calc(var(--loader-size) * ${entry.offsetY})` }
|
|
78
|
+
: {}),
|
|
79
|
+
} as CSSProperties;
|
|
67
80
|
|
|
68
81
|
return (
|
|
69
|
-
<div ref={containerRef} className={className} style={
|
|
82
|
+
<div ref={containerRef} className={className} style={style}>
|
|
70
83
|
{data ? (
|
|
71
84
|
<Lottie src={data} loop autoplay className={styles.lottieFill} />
|
|
72
85
|
) : null}
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
align-items: center;
|
|
10
10
|
justify-content: center;
|
|
11
11
|
position: relative;
|
|
12
|
-
color: currentColor;
|
|
13
12
|
width: var(--loader-size, var(--loader-size-sm));
|
|
14
13
|
height: var(--loader-size, var(--loader-size-sm));
|
|
15
14
|
flex-shrink: 0;
|
|
@@ -90,23 +89,26 @@
|
|
|
90
89
|
}
|
|
91
90
|
|
|
92
91
|
/* ─────────────────────────────────────────────────────────
|
|
93
|
-
Lottie variants —
|
|
94
|
-
|
|
92
|
+
Lottie variants — 전부 정사각 bounds. LottiePlayer가 preserveAspectRatio=slice로
|
|
93
|
+
비정사각 캔버스를 가장자리 잘라 cover. visualScale/offset은 매니페스트에서.
|
|
95
94
|
───────────────────────────────────────────────────────── */
|
|
96
95
|
.v-lottie {
|
|
97
|
-
overflow:
|
|
98
|
-
|
|
99
|
-
.v-lottie.wide {
|
|
100
|
-
width: calc(var(--loader-size) * 2);
|
|
96
|
+
overflow: hidden;
|
|
97
|
+
position: relative;
|
|
101
98
|
}
|
|
102
99
|
|
|
100
|
+
/* 안쪽 로티 박스를 scale 배수만큼 "실제로" 크게 그려서 wrapper를 꽉 채우게 함.
|
|
101
|
+
transform:scale은 lottie-react wrapper에서 letterbox가 생겨 체감이 반감되므로 사용하지 않음.
|
|
102
|
+
offset은 중앙 기준 translate(%) — 상위 bounds 비율 기반 (ex. 5% = 아래로 bounds의 5% 밀기). */
|
|
103
103
|
.lottieFill {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
transform
|
|
104
|
+
position: absolute;
|
|
105
|
+
width: calc(100% * var(--lottie-scale, 1));
|
|
106
|
+
height: calc(100% * var(--lottie-scale, 1));
|
|
107
|
+
left: 50%;
|
|
108
|
+
top: 50%;
|
|
109
|
+
transform:
|
|
110
|
+
translate(-50%, -50%)
|
|
111
|
+
translate(var(--lottie-offset-x, 0), var(--lottie-offset-y, 0));
|
|
110
112
|
}
|
|
111
113
|
|
|
112
114
|
/* ─────────────────────────────────────────────────────────
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { forwardRef, type HTMLAttributes } from "react";
|
|
18
18
|
import styles from "./Spinner.module.css";
|
|
19
|
-
import {
|
|
19
|
+
import type { LottieName } from "./lottieManifest";
|
|
20
20
|
import { LottiePlayer } from "./LottiePlayer";
|
|
21
21
|
|
|
22
22
|
export type SpinnerSize = "xs" | "sm" | "md" | "lg";
|
|
@@ -58,9 +58,7 @@ const Spinner = forwardRef<HTMLSpanElement, SpinnerProps>(
|
|
|
58
58
|
if (variant) {
|
|
59
59
|
if (isLottieVariant(variant)) {
|
|
60
60
|
lottieName = variant.slice("lottie-".length) as LottieName;
|
|
61
|
-
|
|
62
|
-
LOTTIE_MANIFEST[lottieName].aspect === "wide" ? styles.wide : "";
|
|
63
|
-
variantPart = `${styles["v-lottie"]} ${aspectClass}`.trim();
|
|
61
|
+
variantPart = styles["v-lottie"];
|
|
64
62
|
} else {
|
|
65
63
|
variantPart = baseVariantClass[variant];
|
|
66
64
|
}
|
|
@@ -9,63 +9,89 @@
|
|
|
9
9
|
export type Rgb = [number, number, number];
|
|
10
10
|
|
|
11
11
|
/** CSS 색 문자열 → [r,g,b] 0~1 정규화. 서버에서는 null 반환.
|
|
12
|
-
* oklch()
|
|
13
|
-
*
|
|
12
|
+
* getComputedStyle이 oklch()를 그대로 반환하는 브라우저가 많아, Canvas 2D fillStyle에
|
|
13
|
+
* 던져 브라우저가 sRGB hex로 정규화해주는 트릭을 사용. Canvas가 못 받으면 "#000"이 유지되므로
|
|
14
|
+
* rgb() 폴백 파싱. */
|
|
14
15
|
export function parseCssColor(css: string): Rgb | null {
|
|
15
16
|
if (typeof document === "undefined") return null;
|
|
16
17
|
const canvas = document.createElement("canvas");
|
|
17
|
-
canvas.width = 1;
|
|
18
|
-
canvas.height = 1;
|
|
18
|
+
canvas.width = canvas.height = 1;
|
|
19
19
|
const ctx = canvas.getContext("2d");
|
|
20
20
|
if (!ctx) return null;
|
|
21
|
+
// 센티넬로 "#000"을 먼저 넣고 입력 시도 — Canvas가 입력을 못 받으면 "#000"이 그대로 유지됨.
|
|
21
22
|
ctx.fillStyle = "#000";
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
try {
|
|
24
|
+
ctx.fillStyle = css;
|
|
25
|
+
} catch {
|
|
26
|
+
// 일부 브라우저는 invalid color 시 예외 throw
|
|
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];
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
27
40
|
}
|
|
28
41
|
|
|
29
|
-
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* Rec. 601 luma 가중치. 흰색(1,1,1)→target 밝게, 검정(0,0,0)→target 어둡게. */
|
|
42
|
+
/** 색 자리를 target으로 치환. colorBind=true 전용 — 단색 로티 가정.
|
|
43
|
+
* 원본 색의 알파만 보존하고 rgb는 target 그대로.
|
|
44
|
+
* (이전 버전은 원본 luma 비율로 scale → primary처럼 중간 밝기 target일 때
|
|
45
|
+
* 원본이 검정이면 결과도 검정으로 묶여버렸음.) */
|
|
34
46
|
export function recolorLottie(data: unknown, target: Rgb): unknown {
|
|
35
47
|
const [tr, tg, tb] = target;
|
|
36
|
-
// target 자신의 luma — 원본이 target과 같은 밝기(=중간)일 때 target 그대로 나오도록 anchor.
|
|
37
|
-
const targetLuma = 0.299 * tr + 0.587 * tg + 0.114 * tb;
|
|
38
|
-
|
|
39
|
-
function recolorOne(src: Rgb): Rgb {
|
|
40
|
-
const [sr, sg, sb] = src;
|
|
41
|
-
const srcLuma = 0.299 * sr + 0.587 * sg + 0.114 * sb;
|
|
42
|
-
if (targetLuma === 0) {
|
|
43
|
-
// target이 완전 검정이면 비율 scale 불가 — 원본 luma만큼 어둡게.
|
|
44
|
-
return [srcLuma * tr, srcLuma * tg, srcLuma * tb];
|
|
45
|
-
}
|
|
46
|
-
// 비율 조정 — srcLuma=targetLuma면 target 그대로, 더 밝으면 target을 더 밝게.
|
|
47
|
-
const k = srcLuma / targetLuma;
|
|
48
|
-
const clamp = (v: number) => Math.max(0, Math.min(1, v));
|
|
49
|
-
return [clamp(tr * k), clamp(tg * k), clamp(tb * k)];
|
|
50
|
-
}
|
|
51
48
|
|
|
52
|
-
function walk(node: unknown): unknown {
|
|
49
|
+
function walk(node: unknown, parentKey?: string): unknown {
|
|
53
50
|
if (Array.isArray(node)) {
|
|
54
51
|
if (
|
|
55
52
|
(node.length === 3 || node.length === 4) &&
|
|
56
53
|
node.every((v) => typeof v === "number" && v >= 0 && v <= 1)
|
|
57
54
|
) {
|
|
58
|
-
const [r, g, b] = node as number[];
|
|
59
|
-
const [nr, ng, nb] = recolorOne([r, g, b]);
|
|
60
55
|
const alpha = node.length === 4 ? node[3] : undefined;
|
|
61
|
-
return alpha !== undefined ? [
|
|
56
|
+
return alpha !== undefined ? [tr, tg, tb, alpha] : [tr, tg, tb];
|
|
62
57
|
}
|
|
63
|
-
return node.map(walk);
|
|
58
|
+
return node.map((v) => walk(v));
|
|
64
59
|
}
|
|
65
60
|
if (node && typeof node === "object") {
|
|
61
|
+
const obj = node as Record<string, unknown>;
|
|
66
62
|
const out: Record<string, unknown> = {};
|
|
67
|
-
|
|
68
|
-
|
|
63
|
+
// 그라데이션 fill(ty:"gf"|"gs")은 g.k.k가 플랫 배열 [stop, r, g, b, stop, r, g, b, ...].
|
|
64
|
+
// 전체를 target 단색으로 눕힘 (stop만 유지).
|
|
65
|
+
if (
|
|
66
|
+
parentKey === "g" &&
|
|
67
|
+
obj.k &&
|
|
68
|
+
typeof obj.k === "object" &&
|
|
69
|
+
!Array.isArray(obj.k) &&
|
|
70
|
+
Array.isArray((obj.k as Record<string, unknown>).k)
|
|
71
|
+
) {
|
|
72
|
+
const inner = obj.k as Record<string, unknown>;
|
|
73
|
+
const flat = inner.k as unknown[];
|
|
74
|
+
if (
|
|
75
|
+
flat.length >= 4 &&
|
|
76
|
+
flat.length % 4 === 0 &&
|
|
77
|
+
flat.every((v) => typeof v === "number" && v >= 0 && v <= 1)
|
|
78
|
+
) {
|
|
79
|
+
const flatted: number[] = [];
|
|
80
|
+
for (let i = 0; i < flat.length; i += 4) {
|
|
81
|
+
flatted.push(flat[i] as number, tr, tg, tb);
|
|
82
|
+
}
|
|
83
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
84
|
+
if (k === "k") {
|
|
85
|
+
out[k] = { ...inner, k: flatted };
|
|
86
|
+
} else {
|
|
87
|
+
out[k] = walk(v, k);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
94
|
+
out[k] = walk(v, k);
|
|
69
95
|
}
|
|
70
96
|
return out;
|
|
71
97
|
}
|
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
* `colorBind`:
|
|
7
7
|
* true — 단색 로티. 런타임에 모든 색을 currentColor로 치환해 Palette/Hue 축을 추종.
|
|
8
8
|
* false — 다색 로티. 원본 색 유지 (브랜드 톤 또는 작가 의도).
|
|
9
|
-
*
|
|
9
|
+
*
|
|
10
|
+
* 모든 Spinner 로티는 정사각형 bounds로 강제 렌더(SVG preserveAspectRatio=xMidYMid slice).
|
|
11
|
+
* 원본 캔버스 비율이 1:1이 아니어도 bounds는 항상 1:1. 비정사각 로티는 가장자리가
|
|
12
|
+
* 잘릴 수 있으므로 중요한 디테일이 중앙에 모여있는 로티만 등록.
|
|
10
13
|
*/
|
|
11
14
|
|
|
12
15
|
export type LottieName =
|
|
@@ -23,10 +26,13 @@ export type LottieManifestEntry = {
|
|
|
23
26
|
name: LottieName;
|
|
24
27
|
path: string;
|
|
25
28
|
colorBind: boolean;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
*
|
|
29
|
+
/** 원본 캔버스가 bounds를 다 채우지 못할 때(여백이 커 보일 때) scale-up 보정.
|
|
30
|
+
* 1 = 원본 그대로. 비정사각 캔버스가 cover로 잘린 뒤 추가 확대도 가능.
|
|
31
|
+
* overflow:hidden이 켜져있어 bounds 밖은 잘림. */
|
|
29
32
|
visualScale?: number;
|
|
33
|
+
/** 캔버스 안에서 그림이 중앙에서 벗어나 있을 때 translate 보정. 단위는 bounds 비율(0~1 권장). */
|
|
34
|
+
offsetX?: number;
|
|
35
|
+
offsetY?: number;
|
|
30
36
|
/** 한줄 설명 — showcase · 자동완성용 */
|
|
31
37
|
description: string;
|
|
32
38
|
};
|
|
@@ -36,7 +42,6 @@ export const LOTTIE_MANIFEST: Record<LottieName, LottieManifestEntry> = {
|
|
|
36
42
|
name: "rabbit",
|
|
37
43
|
path: "/lottie/rabbit.json",
|
|
38
44
|
colorBind: true,
|
|
39
|
-
aspect: "square",
|
|
40
45
|
visualScale: 1.4,
|
|
41
46
|
description: "뛰어가는 토끼. 긴 작업·AI 생성 대기",
|
|
42
47
|
},
|
|
@@ -44,45 +49,42 @@ export const LOTTIE_MANIFEST: Record<LottieName, LottieManifestEntry> = {
|
|
|
44
49
|
name: "infiniti",
|
|
45
50
|
path: "/lottie/infiniti.json",
|
|
46
51
|
colorBind: true,
|
|
47
|
-
|
|
48
|
-
visualScale: 1.3,
|
|
52
|
+
visualScale: 3.2,
|
|
49
53
|
description: "무한 루프 선. 네트워크·동기화",
|
|
50
54
|
},
|
|
51
55
|
dots: {
|
|
52
56
|
name: "dots",
|
|
53
57
|
path: "/lottie/dots.json",
|
|
54
|
-
colorBind:
|
|
55
|
-
|
|
56
|
-
|
|
58
|
+
colorBind: true,
|
|
59
|
+
visualScale: 0.95,
|
|
60
|
+
offsetY: 0.025,
|
|
57
61
|
description: "그라데이션 dot 흐름. 포인트 로더",
|
|
58
62
|
},
|
|
59
63
|
spinner: {
|
|
60
64
|
name: "spinner",
|
|
61
65
|
path: "/lottie/spinner.json",
|
|
62
66
|
colorBind: true,
|
|
63
|
-
|
|
67
|
+
visualScale: 2,
|
|
64
68
|
description: "표준 라인 스피너",
|
|
65
69
|
},
|
|
66
70
|
clock: {
|
|
67
71
|
name: "clock",
|
|
68
72
|
path: "/lottie/clock.json",
|
|
69
73
|
colorBind: true,
|
|
70
|
-
|
|
71
|
-
visualScale: 1.8,
|
|
74
|
+
visualScale: 3.8,
|
|
72
75
|
description: "회전 시계 바늘. 처리 대기·타임아웃",
|
|
73
76
|
},
|
|
74
77
|
cosmos: {
|
|
75
78
|
name: "cosmos",
|
|
76
79
|
path: "/lottie/cosmos.json",
|
|
77
80
|
colorBind: true,
|
|
78
|
-
|
|
81
|
+
visualScale: 1.2,
|
|
79
82
|
description: "그라데이션 궤도. 몽환적·온보딩",
|
|
80
83
|
},
|
|
81
84
|
"image-gen": {
|
|
82
85
|
name: "image-gen",
|
|
83
86
|
path: "/lottie/image-gen.json",
|
|
84
87
|
colorBind: true,
|
|
85
|
-
aspect: "square",
|
|
86
88
|
visualScale: 1.1,
|
|
87
89
|
description: "이미지 생성 대기. AI 이미지",
|
|
88
90
|
},
|
|
@@ -90,8 +92,7 @@ export const LOTTIE_MANIFEST: Record<LottieName, LottieManifestEntry> = {
|
|
|
90
92
|
name: "loading-square",
|
|
91
93
|
path: "/lottie/loading-square.json",
|
|
92
94
|
colorBind: true,
|
|
93
|
-
|
|
94
|
-
visualScale: 2.8,
|
|
95
|
+
visualScale: 3.5,
|
|
95
96
|
description: "회전하는 사각형. 미니멀 처리 대기",
|
|
96
97
|
},
|
|
97
98
|
};
|
|
@@ -606,6 +606,7 @@ export const componentSlugs: Record<string, string> = {
|
|
|
606
606
|
"Message Quote": "chat/message-quote",
|
|
607
607
|
"Message Sources": "chat/message-sources",
|
|
608
608
|
"Message Table": "chat/message-table",
|
|
609
|
+
"Message State": "chat/message-state",
|
|
609
610
|
};
|
|
610
611
|
|
|
611
612
|
/** 작업중 — 사이드바에서 접근은 가능하지만 "작업중" 뱃지로 표시해 사용 자제 유도. */
|
|
@@ -672,7 +673,7 @@ export const components: { id: string; title: string; items: string[] }[] = [
|
|
|
672
673
|
items: [
|
|
673
674
|
"File Icon", "Attachment Thumb", "Attachment Chip", "Quotation Bar", "Chat Input",
|
|
674
675
|
"Bubble", "Message Actions", "User Message", "AI Message",
|
|
675
|
-
"Code Block", "Message Quote", "Message Sources", "Message Table",
|
|
676
|
+
"Code Block", "Message Quote", "Message Sources", "Message Table", "Message State",
|
|
676
677
|
"Chat Sidebar",
|
|
677
678
|
],
|
|
678
679
|
},
|
|
@@ -30,7 +30,9 @@ visibility 애매하면 `hover`, actions 애매하면 기본 `[copy, regenerate]
|
|
|
30
30
|
label={ReactNode} // 상단 메타 라벨
|
|
31
31
|
actionsVisibility="show | hover" // default: hover
|
|
32
32
|
actions={[{ kind: "copy" }, { kind: "regenerate" }]} // 비우려면 []
|
|
33
|
-
|
|
33
|
+
sources={[{ id, label, href?, icon? }]} // 액션바 옆 inline chip
|
|
34
|
+
onSourcesClick={() => void} // chip 클릭 (popover 열기 등)
|
|
35
|
+
isGenerating={boolean} // true면 액션/sources 숨김
|
|
34
36
|
>
|
|
35
37
|
본문 — markdown 또는 블록 조합
|
|
36
38
|
</AIMessage>
|
|
@@ -26,13 +26,14 @@
|
|
|
26
26
|
`today` 생략하면 `new Date()`. `selectedDate` 없이도 성립 — 그냥 뷰어로 쓸 수 있다. `events` 빈 배열이어도 그리드만 그려진다. `tone` 애매하면 `default`.
|
|
27
27
|
|
|
28
28
|
### 관련
|
|
29
|
+
- [[components/event-chip]] — 셀 안에 쌓이는 일정 블록. Calendar가 자동으로 그려주지만 톤·props 체계는 EventChip 문서를 1차 참조.
|
|
29
30
|
- [[components/date-picker]] — "날짜 입력"이 목적이면 그쪽. Popover + Input 트리거. Calendar와 utils(buildMonthGrid 등) 공유.
|
|
30
|
-
- [[components/badge]] —
|
|
31
|
+
- [[components/badge]] — "+N"·"오늘" 메타 뱃지는 Badge soft. EventChip 톤 체계도 Badge와 동일 토큰.
|
|
31
32
|
- [[components/icon-button]] — 월 이동 네비게이션(이전해·이전달·다음달·다음해 4개). ghost + sm.
|
|
32
|
-
- [[components/button]] — "오늘" 복귀 버튼은 outline·sm
|
|
33
|
-
- [[axes/shape]] — 셀 radius는 없음(격자형이라 radius 신호를 셀이 받지 않음).
|
|
34
|
-
- [[axes/density]] — compact일수록 셀 padding이 줄고
|
|
35
|
-
- [[tokens/color]] — `--primary`(선택), `--hover`(셀 hover), `--border`(hairline), `--
|
|
33
|
+
- [[components/button]] — "오늘" 복귀 버튼은 outline·sm.
|
|
34
|
+
- [[axes/shape]] — 셀 radius는 없음(격자형이라 radius 신호를 셀이 받지 않음). EventChip·Badge만 Shape 축에 반응.
|
|
35
|
+
- [[axes/density]] — compact일수록 셀 padding이 줄고 EventChip이 더 많이 보임.
|
|
36
|
+
- [[tokens/color]] — `--primary`(선택), `--hover`(셀 hover), `--border`(hairline), `--info`/`--destructive`(요일 신호).
|
|
36
37
|
|
|
37
38
|
### Props 시그니처
|
|
38
39
|
|
|
@@ -54,23 +55,15 @@ type CalendarEvent = {
|
|
|
54
55
|
id: string;
|
|
55
56
|
date: string; // yyyy-MM-dd
|
|
56
57
|
title: string;
|
|
57
|
-
code?: string; // 보조 라벨 (사건번호·코드 등)
|
|
58
58
|
tone?: "default" | "accent" | "success" | "warning" | "danger" | "info" | "neutral";
|
|
59
|
-
resolved?: boolean; // 체크 아이콘
|
|
60
59
|
};
|
|
60
|
+
// 셀 안 렌더링은 EventChip이 담당. 상세 라벨(코드·시각·주최자)은 Calendar 밖
|
|
61
|
+
// 사이드 패널·상세 뷰에서 다룬다. [[components/event-chip]] 참조.
|
|
61
62
|
```
|
|
62
63
|
|
|
63
|
-
### Tone
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|---|---|---|---|
|
|
67
|
-
| `default` | `--bg-muted` | `--fg` | 중립 — 기본값. 미분류 일정 |
|
|
68
|
-
| `neutral` | `--secondary-bg` | `--secondary-fg` | 약한 강조 — 사내 공지·루틴 |
|
|
69
|
-
| `accent` | `--primary-subtle` | `--secondary-fg` | 브랜드 액션 — "내 일정"·주요 미팅 |
|
|
70
|
-
| `success` | `--success-subtle` | `--success` | 확정·완료 |
|
|
71
|
-
| `warning` | `--warning-subtle` | `--warning` | 임박·주의 |
|
|
72
|
-
| `danger` | `--destructive-subtle` | `--destructive` | 마감·실패 |
|
|
73
|
-
| `info` | `--info-subtle` | `--info` | 참고·외부 캘린더 |
|
|
64
|
+
### Tone
|
|
65
|
+
|
|
66
|
+
이벤트 톤 7개(default/neutral/accent/success/warning/danger/info) — 상세 매트릭스는 [[components/event-chip]]에서 1차 정의한다. Calendar는 `event.tone`을 그대로 EventChip에 전달.
|
|
74
67
|
|
|
75
68
|
**금지 조합**
|
|
76
69
|
- `tone="danger"`를 "눈에 띄게" 쓰려고 전부 danger로 깔기 — 색이 의미 신호를 잃는다. 셀당 1개가 danger라면 다른 셀은 default/accent여야 한다.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Event Chip
|
|
2
|
+
|
|
3
|
+
### 원칙
|
|
4
|
+
|
|
5
|
+
캘린더 셀 안에서 "하루에 쌓이는 일정 하나"를 표현하는 작은 정보 블록. [[components/chip]]은 필터 선택 토큰 전용, [[components/badge]]는 카운트·상태 라벨 전용 — 캘린더 셀 안 일정은 이 자리. 가로 폭은 부모(셀)에 꽉 차고, 세로로 쌓이며, 넘치는 title은 ellipsis로 접힌다.
|
|
6
|
+
|
|
7
|
+
### Do
|
|
8
|
+
- props는 `title` + `tone` + `faded` 세 개만. title 하나로 끝난다 — 부가 라벨(코드·시각·주최자)은 셀 안이 아니라 상세 패널에서 다룬다.
|
|
9
|
+
- 톤은 7종(default/neutral/accent/success/warning/danger/info)만 사용. Badge soft와 동일 토큰 체계.
|
|
10
|
+
- 폭은 부모 flex column에 맞춰 자동. `min-width: 0`을 부모에 걸어주면 ellipsis가 자연히 동작한다.
|
|
11
|
+
- outside 월(전/다음 달)이나 과거 등 "눈에 띄지 않아야 할" 자리는 `faded`로 흐리게. 캘린더가 자동으로 넘겨준다.
|
|
12
|
+
- 라운드는 Shape 축에 반응하는 `--radius-xs`. 자체 radius 리터럴 금지.
|
|
13
|
+
|
|
14
|
+
### Don't
|
|
15
|
+
- title 외 보조 라벨(코드·사건번호·주최자) 넣기 금지 — 셀 안은 밀도가 승부처라 한 줄만. 그게 필요하면 셀 클릭 → 상세 패널.
|
|
16
|
+
- 클릭 가능한 "안쪽 버튼" 금지 — EventChip 자체는 presentational. 상호작용은 부모 셀이 책임진다. 안에 또 버튼이 들어가면 캘린더 클릭 타겟이 미궁에 빠진다.
|
|
17
|
+
- pill radius(완전 둥근) 쓰지 않는다 — Shape 축이 radius를 쥐고 있는데 pill로 고정하면 Soft/Standard/Crisp 신호가 셀 안에서 깨진다.
|
|
18
|
+
- "눈에 띄게" 하려고 전부 `danger` 톤으로 깔기 금지 — 색이 의미 신호를 잃는다.
|
|
19
|
+
- 아이콘·아바타 삽입 금지 — 이 자리는 "하루 셀 안 한 줄"이지 ListItem이 아니다.
|
|
20
|
+
|
|
21
|
+
### Fallback
|
|
22
|
+
`tone` 애매하면 `default`. `faded`는 캘린더 외 자리에서는 생략해도 됨.
|
|
23
|
+
|
|
24
|
+
### 관련
|
|
25
|
+
- [[components/calendar]] — 1차 소비자. 셀 안에서 EventChip을 세로로 쌓고, 넘치면 "+N" Badge로 접는다.
|
|
26
|
+
- [[components/badge]] — 톤 토큰 체계 공유 (soft variant). "+N"·"오늘" 같은 메타 라벨은 Badge.
|
|
27
|
+
- [[components/chip]] — 혼동 금지. Chip은 필터 선택 토큰("X 제거 가능"), EventChip은 캘린더 일정 토큰.
|
|
28
|
+
- [[axes/shape]] — radius가 Shape 축에 반응.
|
|
29
|
+
- [[tokens/color]] — `--bg-muted`(default) · `--secondary-bg`(neutral) · `--primary-subtle`(accent) · `*-subtle`(상태) · `--fg`/`--fg-muted`(글자).
|
|
30
|
+
|
|
31
|
+
### Props 시그니처
|
|
32
|
+
|
|
33
|
+
```tsx
|
|
34
|
+
<EventChip
|
|
35
|
+
title={string} // required
|
|
36
|
+
tone="default | neutral | accent | success | warning | danger | info"
|
|
37
|
+
faded={boolean} // outside 월·과거 등 흐리게
|
|
38
|
+
/>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Tone 매트릭스
|
|
42
|
+
|
|
43
|
+
| tone | 배경 | 글자 | 쓰임새 |
|
|
44
|
+
|---|---|---|---|
|
|
45
|
+
| `default` | `--bg-muted` | `--fg` | 미분류 · 중립 일정 (기본값) |
|
|
46
|
+
| `neutral` | `--secondary-bg` | `--secondary-fg` | 사내 공지 · 루틴 |
|
|
47
|
+
| `accent` | `--primary-subtle` | `--secondary-fg` | 주요 미팅 · 내 일정 |
|
|
48
|
+
| `success` | `--success-subtle` | `--success` | 확정 · 완료 |
|
|
49
|
+
| `warning` | `--warning-subtle` | `--warning` | 임박 · 주의 |
|
|
50
|
+
| `danger` | `--destructive-subtle` | `--destructive` | 마감 · 긴급 |
|
|
51
|
+
| `info` | `--info-subtle` | `--info` | 참고 · 외부 캘린더 |
|
|
52
|
+
|
|
53
|
+
### 조합 예시
|
|
54
|
+
|
|
55
|
+
```tsx
|
|
56
|
+
// 캘린더 셀 안쪽 — Calendar가 내부에서 쓰는 방식.
|
|
57
|
+
// 부모가 min-width: 0 걸어주면 title ellipsis가 자동.
|
|
58
|
+
<div className="cellEvents">
|
|
59
|
+
{events.map((e) => (
|
|
60
|
+
<EventChip key={e.id} title={e.title} tone={e.tone} faded={outside} />
|
|
61
|
+
))}
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
// 캘린더 외부 — 선택된 날짜의 상세 리스트에서 재사용.
|
|
65
|
+
// Calendar와 짝 이루는 사이드 패널에서도 톤 체계가 통일되게 유지된다.
|
|
66
|
+
<aside>
|
|
67
|
+
{dayEvents.map((e) => (
|
|
68
|
+
<div key={e.id} className="dayRow">
|
|
69
|
+
<EventChip title={e.title} tone={e.tone} />
|
|
70
|
+
<time>{e.time}</time>
|
|
71
|
+
</div>
|
|
72
|
+
))}
|
|
73
|
+
</aside>
|
|
74
|
+
```
|
|
@@ -1,26 +1,33 @@
|
|
|
1
1
|
# Message Sources
|
|
2
2
|
|
|
3
3
|
### 원칙
|
|
4
|
-
[[components/ai-message]]
|
|
4
|
+
AIMessage의 **하단 액션바 옆에 inline으로 붙는 chip**. 겹친 favicon stack + 라벨("Sources"). 블록이 아니라 액션 액세서리 — 실제론 [[components/ai-message]]의 `sources` prop으로 전달해 자동 배치. 전체 소스 리스트는 chip 클릭 시 popover로 (소비처가 조립).
|
|
5
5
|
|
|
6
6
|
### Do
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
7
|
+
- `<AIMessage sources={...}>` prop으로 전달. standalone으로 쓸 일은 거의 없음.
|
|
8
|
+
- `maxFavicons`로 겹쳐 보여줄 favicon 개수 조정 (기본 3). 넘어도 라벨은 그대로 — 숫자 노출 안 함(라벨만).
|
|
9
|
+
- 각 소스에 `icon` 넘겨 favicon을 명시, 없으면 중성 원형 placeholder.
|
|
10
|
+
- 클릭 시 소비처에서 popover(소스 전체 리스트) 열기 — 디자인 시스템 범위 밖.
|
|
10
11
|
|
|
11
12
|
### Don't
|
|
12
|
-
-
|
|
13
|
-
- Sources를 메시지 본문 중간에 두지 않는다 — 반드시
|
|
13
|
+
- 큰 chip 리스트로 늘려 메시지 하단에 깔지 않는다 (예전 디자인 — 폐기).
|
|
14
|
+
- Sources를 메시지 본문 중간에 두지 않는다 — 반드시 하단 액션바 라인.
|
|
15
|
+
- chip 안에 긴 설명·날짜 넣지 않는다 — 라벨 하나만.
|
|
14
16
|
|
|
15
17
|
### Props 시그니처
|
|
16
18
|
```tsx
|
|
17
19
|
<MessageSources
|
|
18
20
|
sources={[{ id, label, href?, icon? }]}
|
|
19
|
-
label="Sources"
|
|
21
|
+
label="Sources" // default
|
|
22
|
+
maxFavicons={3} // default
|
|
23
|
+
onClick={() => void} // 소스 전체 popover 열기 등
|
|
20
24
|
/>
|
|
21
25
|
```
|
|
22
26
|
|
|
23
27
|
### 레이아웃
|
|
24
|
-
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
28
|
+
- 외곽: ghost pill (transparent → hover bg-subtle)
|
|
29
|
+
- padding: `space-0_5 space-2 space-0_5 space-1`
|
|
30
|
+
- 폰트: `text-xs`, `fg-muted` → hover `fg-strong`
|
|
31
|
+
- favicon stack: 각 원 `space-4`(16px), 서로 `space-1` negative margin으로 겹침
|
|
32
|
+
- 각 favicon에 box-shadow로 bg 색 ring (cutout 효과)
|
|
33
|
+
- `+N`은 `fg-muted`, tabular-nums
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Message State
|
|
2
|
+
|
|
3
|
+
### 원칙
|
|
4
|
+
AI 응답 중간에 쌓이는 **"뭘 하고 있는지"** 라인. Lottie-ish Spinner 또는 kind별 아이콘 + 통통거리는 라벨. `done=true`면 ✓ 체크 + 라벨 정적. [[components/ai-message]] 안에서 children으로 여러 개 쌓아 "작업 플로우" 전달.
|
|
5
|
+
|
|
6
|
+
### Do
|
|
7
|
+
- `kind`로 상태 분류: generating / searching / memory / image / video / file / custom.
|
|
8
|
+
- 완료되면 **동일 state를 done=true로 유지** — 사라지면 흐름이 안 보임.
|
|
9
|
+
- 라벨 override는 `message` prop. kind는 아이콘만 쓰고 커스텀 라벨 가능.
|
|
10
|
+
- AIMessage 안의 children에 여러 MessageState를 쌓고, 끝나면 아래에 실제 응답 본문.
|
|
11
|
+
|
|
12
|
+
### Don't
|
|
13
|
+
- MessageState를 bubble로 감싸지 않는다 — 플랫 라인.
|
|
14
|
+
- Spinner와 kind 아이콘을 동시에 띄우지 않는다 — 하나만 (generating=spinner, 그 외=kind icon).
|
|
15
|
+
- done 상태에서 라벨 통통거리면 안 됨 — 애니 정지.
|
|
16
|
+
- 메시지 완료 후에도 MessageState 계속 노출하지 말고 **접거나 하나로 요약** (예: “3 steps completed ✓”).
|
|
17
|
+
|
|
18
|
+
### Fallback
|
|
19
|
+
kind 애매하면 `generating`, message 애매하면 kind별 기본.
|
|
20
|
+
|
|
21
|
+
### 관련
|
|
22
|
+
- [[components/ai-message]] — 상위 소비처 (`isGenerating`은 전체 메시지가 생성 중일 때, MessageState는 **메시지 안의 세부 작업**)
|
|
23
|
+
- [[components/spinner]] — lottie-dots variant 재사용
|
|
24
|
+
|
|
25
|
+
### Props 시그니처
|
|
26
|
+
```tsx
|
|
27
|
+
<MessageState
|
|
28
|
+
kind="generating | searching | memory | image | video | file | custom"
|
|
29
|
+
message={ReactNode} // kind별 기본 라벨 override
|
|
30
|
+
done={boolean} // true → ✓ + 애니 정지
|
|
31
|
+
icon={ReactNode} // kind="custom" 또는 override
|
|
32
|
+
/>
|
|
33
|
+
```
|
|
34
|
+
|
|
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 |
|
|
45
|
+
|
|
46
|
+
### 애니메이션
|
|
47
|
+
- 라벨 각 글자 wave bounce — `duration-slower × 5` 주기, `duration-base` 간격으로 index 지연
|
|
48
|
+
- done=true → `animation: none; opacity: 1; transform: none` 로 정지
|
|
49
|
+
- 리더 아이콘: active primary 색, done success 색
|
|
50
|
+
|
|
51
|
+
### 레이아웃
|
|
52
|
+
- `inline-flex`, gap `space-2`, `text-sm`, line-height 1.4
|
|
53
|
+
- 리더 `space-4` 정사각 (아이콘·spinner 둘 다)
|