@conscience-technology/ctds 0.1.11 → 0.1.13

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.
@@ -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(--fg-muted);
33
- vertical-align: middle;
34
- margin-right: var(--space-1);
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 {
@@ -85,7 +85,9 @@ export function AIMessage({
85
85
  {isGenerating ? (
86
86
  <>
87
87
  <Spinner variant="lottie-dots" size="sm" className={styles.generatingSpinner} />
88
- {typeof children === "string" ? renderBouncingText(children) : children}
88
+ <span className={styles.generatingText}>
89
+ {typeof children === "string" ? renderBouncingText(children) : children}
90
+ </span>
89
91
  </>
90
92
  ) : (
91
93
  children
@@ -53,15 +53,36 @@
53
53
  height: calc(var(--at-size) * 0.60);
54
54
  }
55
55
 
56
- /* Loading — fg를 어둡게 깐 위에 흰색 lottie spinner.
57
- Spinner의 colorBind가 바깥 color를 읽으므로 .loading에 색 지정. */
56
+ /* Loading — bg-muted ↔ 더 어두운 톤 사이를 미묘하게 흐르는 그라데이션 + 중앙 흰색 lottie spinner.
57
+ Spinner colorBind가 .loading의 color(=fixed-white)를 상속받아 흰색으로 recolor된다. */
58
58
  .loading {
59
- background: color-mix(in oklch, var(--fg) 55%, transparent);
59
+ background: var(--bg-muted);
60
60
  color: var(--fixed-white);
61
61
  overflow: hidden;
62
62
  }
63
- /* wide 로티도 썸네일 안에 꽉 차 보이게 scale-up (overflow:hidden으로 안전) */
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
  }
@@ -4,14 +4,14 @@
4
4
  .root {
5
5
  display: inline-flex;
6
6
  align-items: center;
7
- gap: var(--space-1);
8
- padding: var(--space-0_5) var(--space-2) var(--space-0_5) var(--space-1);
7
+ gap: var(--space-1_5);
8
+ padding: var(--space-1_5) var(--space-3) var(--space-1_5) var(--space-2);
9
9
  border: 0;
10
10
  background: transparent;
11
11
  color: var(--fg-muted);
12
12
  border-radius: var(--radius-full);
13
13
  cursor: pointer;
14
- font-size: var(--text-xs);
14
+ font-size: var(--text-sm);
15
15
  line-height: 1;
16
16
  transition: var(--transition-colors);
17
17
  }
@@ -32,7 +32,6 @@ export function MessageSources({
32
32
  ...rest
33
33
  }: MessageSourcesProps) {
34
34
  const visible = sources.slice(0, maxFavicons);
35
- const extra = sources.length - visible.length;
36
35
 
37
36
  const cls = [styles.root, className].filter(Boolean).join(" ");
38
37
 
@@ -55,10 +54,7 @@ export function MessageSources({
55
54
  </span>
56
55
  ))}
57
56
  </span>
58
- <span className={styles.label}>
59
- {label}
60
- {extra > 0 ? <span className={styles.extra}> +{extra}</span> : null}
61
- </span>
57
+ <span className={styles.label}>{label}</span>
62
58
  </button>
63
59
  );
64
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
+ }
@@ -0,0 +1,2 @@
1
+ export { MessageState } from "./MessageState";
2
+ export type { MessageStateProps, MessageStateKind } from "./MessageState";
@@ -53,8 +53,14 @@ 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) ?? [0, 0, 0];
57
- setData(recolorLottie(raw, rgb));
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;
@@ -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;
@@ -8,39 +8,50 @@
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
- * oklch()/color() 같은 비-sRGB 색공간을 Canvas 2D fillStyle로 넣고 다시 읽어
13
- * 브라우저가 rgb()/rgba() 문자열로 토해내게 한 뒤 파싱 (가장 안정적인 변환). */
36
+ * oklch()는 수동 변환(브라우저 Canvas가 oklch 입력을 받지 못하는 경우가 많음).
37
+ * rgb()/rgba()/#hex는 그대로 파싱. */
14
38
  export function parseCssColor(css: string): Rgb | null {
15
39
  if (typeof document === "undefined") return null;
16
- const canvas = document.createElement("canvas");
17
- canvas.width = 1;
18
- canvas.height = 1;
19
- const ctx = canvas.getContext("2d");
20
- if (!ctx) return null;
21
- ctx.fillStyle = "#000";
22
- ctx.fillStyle = css;
23
- const computed = ctx.fillStyle as string;
24
- // Canvas가 지원 못 하면 "#000"이 그대로 남음 → 원본이 지원되지 않는 색이거나 oklch 미지원 브라우저.
25
- // getComputedStyle로 폴백 (대부분 rgb() 반환).
26
- let hex = /^#([0-9a-f]{6})$/i.exec(computed);
27
- if (!hex) {
28
- const el = document.createElement("div");
29
- el.style.color = css;
30
- document.body.appendChild(el);
31
- ctx.fillStyle = "#000";
32
- ctx.fillStyle = getComputedStyle(el).color;
33
- document.body.removeChild(el);
34
- hex = /^#([0-9a-f]{6})$/i.exec(ctx.fillStyle as string);
35
- }
36
- if (hex) {
37
- const n = parseInt(hex[1], 16);
38
- return [((n >> 16) & 0xff) / 255, ((n >> 8) & 0xff) / 255, (n & 0xff) / 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);
39
50
  }
40
- // 최후 폴백 — rgba() / rgb() 문자열 파싱
41
- const m = (computed as string).match(/-?\d+(\.\d+)?/g);
42
- if (!m || m.length < 3) return null;
43
- return [+m[0] / 255, +m[1] / 255, +m[2] / 255];
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];
54
+ return null;
44
55
  }
45
56
 
46
57
  /** 색 자리를 target으로 치환. colorBind=true 전용 — 단색 로티 가정.
@@ -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
  },
@@ -1,11 +1,11 @@
1
1
  # Message Sources
2
2
 
3
3
  ### 원칙
4
- AIMessage의 **하단 액션바 옆에 inline으로 붙는 작은 chip**. 겹친 favicon stack + 첫 소스 라벨 + `+N` 패턴. 블록이 아니라 액션 액세서리 — 실제론 [[components/ai-message]]의 `sources` prop으로 전달해 자동 배치.
4
+ AIMessage의 **하단 액션바 옆에 inline으로 붙는 chip**. 겹친 favicon stack + 라벨("Sources"). 블록이 아니라 액션 액세서리 — 실제론 [[components/ai-message]]의 `sources` prop으로 전달해 자동 배치. 전체 소스 리스트는 chip 클릭 시 popover로 (소비처가 조립).
5
5
 
6
6
  ### Do
7
7
  - `<AIMessage sources={...}>` prop으로 전달. standalone으로 쓸 일은 거의 없음.
8
- - `maxFavicons`로 겹쳐 보여줄 favicon 개수 조정 (기본 3). 나머지는 `+N`.
8
+ - `maxFavicons`로 겹쳐 보여줄 favicon 개수 조정 (기본 3). 넘어도 라벨은 그대로 — 숫자 노출 안 함(라벨만).
9
9
  - 각 소스에 `icon` 넘겨 favicon을 명시, 없으면 중성 원형 placeholder.
10
10
  - 클릭 시 소비처에서 popover(소스 전체 리스트) 열기 — 디자인 시스템 범위 밖.
11
11
 
@@ -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 계속 노출하지 말고 **접거나 하나로 요약** (예: &ldquo;3 steps completed ✓&rdquo;).
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 둘 다)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conscience-technology/ctds",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "CTDS — 하이브리드 디자인 시스템 설치 · 온보딩 CLI",
5
5
  "type": "module",
6
6
  "bin": {