@conscience-technology/ctds 0.1.10 → 0.1.11

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.
@@ -60,12 +60,22 @@
60
60
  .body > :global(h3) {
61
61
  color: var(--fg-strong);
62
62
  font-weight: var(--font-weight-semibold);
63
- margin: var(--space-3) 0 var(--space-1);
63
+ margin: var(--space-6) 0 var(--space-2);
64
+ line-height: 1.3;
65
+ }
66
+ /* 본문 첫 요소는 상단 margin 제거 (AIMessage body gap이 대신) */
67
+ .body > :first-child {
68
+ margin-top: 0;
64
69
  }
65
70
  .body > :global(p) {
66
71
  margin: var(--space-2) 0;
67
72
  }
68
- /* 리스트 — 들여쓰기 최소화. 마커는 primary로 채워 포인트. */
73
+ /* 블록 요소 (CodeBlock·MessageQuote·MessageSources·MessageTable wrapper) — 섹션 분리감 */
74
+ .body > :global(div),
75
+ .body > :global(blockquote) {
76
+ margin: var(--space-3) 0;
77
+ }
78
+ /* 리스트 — 들여쓰기 최소화. 마커는 fg-muted(다크그레이보다 한 단계 연함). */
69
79
  .body :global(ol),
70
80
  .body :global(ul) {
71
81
  padding-left: var(--space-4);
@@ -76,7 +86,7 @@
76
86
  padding-left: 0;
77
87
  }
78
88
  .body :global(li::marker) {
79
- color: var(--primary);
89
+ color: var(--fg-muted);
80
90
  font-variant-numeric: tabular-nums;
81
91
  }
82
92
  /* 중첩 리스트 — 조금만 더 들여쓰기 */
@@ -95,6 +105,8 @@
95
105
 
96
106
  .actions {
97
107
  display: flex;
108
+ align-items: center;
109
+ gap: var(--space-2);
98
110
  }
99
111
 
100
112
  /* 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 hasActions = actions.length > 0 && !isGenerating;
73
+ const hasSources = sources && sources.length > 0 && !isGenerating;
74
+ const hasActions = (actions.length > 0 || hasSources) && !isGenerating;
64
75
 
65
76
  return (
66
77
  <div
@@ -83,6 +94,9 @@ export function AIMessage({
83
94
  {hasActions ? (
84
95
  <div className={styles.actions}>
85
96
  <MessageActions align="start" actions={actions} />
97
+ {hasSources ? (
98
+ <MessageSources sources={sources!} onClick={onSourcesClick} />
99
+ ) : null}
86
100
  </div>
87
101
  ) : null}
88
102
  </div>
@@ -38,7 +38,7 @@
38
38
  justify-content: center;
39
39
  padding: 0;
40
40
  border: 0;
41
- background: var(--bg-muted);
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(--bg);
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 하단 소스 chip 리스트. */
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
7
  gap: var(--space-1);
25
- padding: var(--space-1) var(--space-2);
26
- background: var(--bg-subtle);
8
+ padding: var(--space-0_5) var(--space-2) var(--space-0_5) var(--space-1);
9
+ border: 0;
10
+ background: transparent;
11
+ color: var(--fg-muted);
27
12
  border-radius: var(--radius-full);
28
- color: var(--fg);
13
+ cursor: pointer;
29
14
  font-size: var(--text-xs);
30
- text-decoration: none;
15
+ line-height: 1;
31
16
  transition: var(--transition-colors);
32
- max-width: 100%;
33
- min-width: 0;
34
17
  }
35
- .chip:hover {
36
- background: var(--bg-muted);
18
+ .root:hover {
19
+ background: var(--bg-subtle);
37
20
  color: var(--fg-strong);
38
21
  }
39
- .chip:focus-visible {
22
+ .root:focus-visible {
40
23
  outline: none;
41
24
  box-shadow: var(--ring);
42
25
  }
43
26
 
44
- .chipIcon {
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
- .linkIcon {
54
- width: 80%;
55
- height: 80%;
46
+ .favicon:first-child {
47
+ margin-left: 0;
56
48
  }
57
49
 
58
- .chipLabel {
59
- overflow: hidden;
60
- text-overflow: ellipsis;
61
- white-space: nowrap;
62
- min-width: 0;
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,64 @@
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
- /** 좌측 favicon 등 커스텀 아이콘. 없으면 기본 Link. */
12
- icon?: React.ReactNode;
10
+ /** 왼쪽 favicon. 없으면 중성 원형 placeholder. */
11
+ icon?: ReactNode;
13
12
  }
14
13
 
15
- export interface MessageSourcesProps extends HTMLAttributes<HTMLDivElement> {
14
+ export interface MessageSourcesProps extends HTMLAttributes<HTMLButtonElement> {
16
15
  sources: MessageSource[];
17
- /** 상단 라벨. 기본 "Sources". */
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
+ const extra = sources.length - visible.length;
36
+
27
37
  const cls = [styles.root, className].filter(Boolean).join(" ");
28
38
 
29
39
  return (
30
- <div className={cls} {...rest}>
31
- <div className={styles.label}>{label}</div>
32
- <div className={styles.chips}>
33
- {sources.map((s) => (
34
- <a
40
+ <button
41
+ type="button"
42
+ className={cls}
43
+ onClick={onClick}
44
+ aria-label={`${label} (${sources.length})`}
45
+ {...rest}
46
+ >
47
+ <span className={styles.stack} aria-hidden>
48
+ {visible.map((s, i) => (
49
+ <span
35
50
  key={s.id}
36
- className={styles.chip}
37
- href={s.href || "#"}
38
- target={s.href ? "_blank" : undefined}
39
- rel={s.href ? "noopener noreferrer" : undefined}
51
+ className={styles.favicon}
52
+ style={{ ["--stack-i" as string]: String(i) }}
40
53
  >
41
- <span className={styles.chipIcon}>
42
- {s.icon ?? <PhLink className={styles.linkIcon} weight="regular" aria-hidden />}
43
- </span>
44
- <span className={styles.chipLabel}>{s.label}</span>
45
- </a>
54
+ {s.icon ?? null}
55
+ </span>
46
56
  ))}
47
- </div>
48
- </div>
57
+ </span>
58
+ <span className={styles.label}>
59
+ {label}
60
+ {extra > 0 ? <span className={styles.extra}> +{extra}</span> : null}
61
+ </span>
62
+ </button>
49
63
  );
50
64
  }
@@ -1,11 +1,10 @@
1
- /* MessageTable — markdown table 톤.
2
- wrapper는 가로 스크롤 컨테이너, 안쪽 table이 트랙. */
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
- .root :global(thead) {
20
- background: var(--bg-subtle);
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
- .root :global(th),
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-3);
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
- .root :global(th) {
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
- }
@@ -61,12 +61,19 @@ export function LottiePlayer({ name, className }: LottiePlayerProps) {
61
61
  };
62
62
  }, [name, entry.colorBind]);
63
63
 
64
- const scaleStyle = entry.visualScale
65
- ? ({ "--lottie-scale": entry.visualScale } as CSSProperties)
66
- : undefined;
64
+ // offset은 bounds(--loader-size) 비율 기준. 스케일된 자기 요소 % 혼동을 피하려고 calc로 묶음.
65
+ const style = {
66
+ ...(entry.visualScale ? { "--lottie-scale": entry.visualScale } : {}),
67
+ ...(entry.offsetX
68
+ ? { "--lottie-offset-x": `calc(var(--loader-size) * ${entry.offsetX})` }
69
+ : {}),
70
+ ...(entry.offsetY
71
+ ? { "--lottie-offset-y": `calc(var(--loader-size) * ${entry.offsetY})` }
72
+ : {}),
73
+ } as CSSProperties;
67
74
 
68
75
  return (
69
- <div ref={containerRef} className={className} style={scaleStyle}>
76
+ <div ref={containerRef} className={className} style={style}>
70
77
  {data ? (
71
78
  <Lottie src={data} loop autoplay className={styles.lottieFill} />
72
79
  ) : null}
@@ -90,23 +90,26 @@
90
90
  }
91
91
 
92
92
  /* ─────────────────────────────────────────────────────────
93
- Lottie variants — LottiePlayer가 바깥 박스 100% 채움.
94
- bounds는 .root가 결정 (size prop), aspect=wide 로티는 .wide로 폭을 2배.
93
+ Lottie variants — 전부 정사각 bounds. LottiePlayer가 preserveAspectRatio=slice로
94
+ 비정사각 캔버스를 가장자리 잘라 cover. visualScale/offset은 매니페스트에서.
95
95
  ───────────────────────────────────────────────────────── */
96
96
  .v-lottie {
97
- overflow: visible;
98
- }
99
- .v-lottie.wide {
100
- width: calc(var(--loader-size) * 2);
97
+ overflow: hidden;
98
+ position: relative;
101
99
  }
102
100
 
101
+ /* 안쪽 로티 박스를 scale 배수만큼 "실제로" 크게 그려서 wrapper를 꽉 채우게 함.
102
+ transform:scale은 lottie-react wrapper에서 letterbox가 생겨 체감이 반감되므로 사용하지 않음.
103
+ offset은 중앙 기준 translate(%) — 상위 bounds 비율 기반 (ex. 5% = 아래로 bounds의 5% 밀기). */
103
104
  .lottieFill {
104
- width: 100%;
105
- height: 100%;
106
- /* 로티 원본이 캔버스를 덜 채워 체감 크기가 작은 경우, 매니페스트의 visualScale이
107
- --lottie-scale을 지정 → bounds 안에서 scale-up. 지정 없으면 1 (원본). */
108
- transform: scale(var(--lottie-scale, 1));
109
- transform-origin: center;
105
+ position: absolute;
106
+ width: calc(100% * var(--lottie-scale, 1));
107
+ height: calc(100% * var(--lottie-scale, 1));
108
+ left: 50%;
109
+ top: 50%;
110
+ transform:
111
+ translate(-50%, -50%)
112
+ translate(var(--lottie-offset-x, 0), var(--lottie-offset-y, 0));
110
113
  }
111
114
 
112
115
  /* ─────────────────────────────────────────────────────────
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { forwardRef, type HTMLAttributes } from "react";
18
18
  import styles from "./Spinner.module.css";
19
- import { LOTTIE_MANIFEST, type LottieName } from "./lottieManifest";
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
- const aspectClass =
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,8 +9,8 @@
9
9
  export type Rgb = [number, number, number];
10
10
 
11
11
  /** CSS 색 문자열 → [r,g,b] 0~1 정규화. 서버에서는 null 반환.
12
- * oklch()/color()처럼 getComputedStyle이 색공간 문자열 그대로 뱉는 경우를 피하려고
13
- * canvas 2d에 그려 실제 sRGB 픽셀값을 읽어낸다. */
12
+ * oklch()/color() 같은 비-sRGB 색공간을 Canvas 2D fillStyle로 넣고 다시 읽어
13
+ * 브라우저가 rgb()/rgba() 문자열로 토해내게 한 뒤 파싱 (가장 안정적인 변환). */
14
14
  export function parseCssColor(css: string): Rgb | null {
15
15
  if (typeof document === "undefined") return null;
16
16
  const canvas = document.createElement("canvas");
@@ -20,52 +20,82 @@ export function parseCssColor(css: string): Rgb | null {
20
20
  if (!ctx) return null;
21
21
  ctx.fillStyle = "#000";
22
22
  ctx.fillStyle = css;
23
- // 브라우저가 유효하지 않다고 판단한 색은 이전 fillStyle(#000)이 유지됨 → 바로 그리고 읽기
24
- ctx.fillRect(0, 0, 1, 1);
25
- const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
26
- return [r / 255, g / 255, b / 255];
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];
39
+ }
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];
27
44
  }
28
45
 
29
- /** 원본 색의 밝기(luminance)만 추출해 target 색조에 적용.
30
- * 단색 로티: 모든 색이 비슷한 밝기 → target 색 그대로.
31
- * 그라데이션/다색 로티: 밝기 계조는 유지, 색조만 target으로 → 그라데이션 보존.
32
- *
33
- * Rec. 601 luma 가중치. 흰색(1,1,1)→target 밝게, 검정(0,0,0)→target 어둡게. */
46
+ /** 색 자리를 target으로 치환. colorBind=true 전용 — 단색 로티 가정.
47
+ * 원본 색의 알파만 보존하고 rgb는 target 그대로.
48
+ * (이전 버전은 원본 luma 비율로 scale → primary처럼 중간 밝기 target일 때
49
+ * 원본이 검정이면 결과도 검정으로 묶여버렸음.) */
34
50
  export function recolorLottie(data: unknown, target: Rgb): unknown {
35
51
  const [tr, tg, tb] = target;
36
- // target 자신의 luma — 원본이 target과 같은 밝기(=중간)일 때 target 그대로 나오도록 anchor.
37
- const targetLuma = 0.299 * tr + 0.587 * tg + 0.114 * tb;
38
52
 
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
-
52
- function walk(node: unknown): unknown {
53
+ function walk(node: unknown, parentKey?: string): unknown {
53
54
  if (Array.isArray(node)) {
54
55
  if (
55
56
  (node.length === 3 || node.length === 4) &&
56
57
  node.every((v) => typeof v === "number" && v >= 0 && v <= 1)
57
58
  ) {
58
- const [r, g, b] = node as number[];
59
- const [nr, ng, nb] = recolorOne([r, g, b]);
60
59
  const alpha = node.length === 4 ? node[3] : undefined;
61
- return alpha !== undefined ? [nr, ng, nb, alpha] : [nr, ng, nb];
60
+ return alpha !== undefined ? [tr, tg, tb, alpha] : [tr, tg, tb];
62
61
  }
63
- return node.map(walk);
62
+ return node.map((v) => walk(v));
64
63
  }
65
64
  if (node && typeof node === "object") {
65
+ const obj = node as Record<string, unknown>;
66
66
  const out: Record<string, unknown> = {};
67
- for (const [k, v] of Object.entries(node as Record<string, unknown>)) {
68
- out[k] = walk(v);
67
+ // 그라데이션 fill(ty:"gf"|"gs")은 g.k.k가 플랫 배열 [stop, r, g, b, stop, r, g, b, ...].
68
+ // 전체를 target 단색으로 눕힘 (stop만 유지).
69
+ if (
70
+ parentKey === "g" &&
71
+ obj.k &&
72
+ typeof obj.k === "object" &&
73
+ !Array.isArray(obj.k) &&
74
+ Array.isArray((obj.k as Record<string, unknown>).k)
75
+ ) {
76
+ const inner = obj.k as Record<string, unknown>;
77
+ const flat = inner.k as unknown[];
78
+ if (
79
+ flat.length >= 4 &&
80
+ flat.length % 4 === 0 &&
81
+ flat.every((v) => typeof v === "number" && v >= 0 && v <= 1)
82
+ ) {
83
+ const flatted: number[] = [];
84
+ for (let i = 0; i < flat.length; i += 4) {
85
+ flatted.push(flat[i] as number, tr, tg, tb);
86
+ }
87
+ for (const [k, v] of Object.entries(obj)) {
88
+ if (k === "k") {
89
+ out[k] = { ...inner, k: flatted };
90
+ } else {
91
+ out[k] = walk(v, k);
92
+ }
93
+ }
94
+ return out;
95
+ }
96
+ }
97
+ for (const [k, v] of Object.entries(obj)) {
98
+ out[k] = walk(v, k);
69
99
  }
70
100
  return out;
71
101
  }
@@ -6,7 +6,10 @@
6
6
  * `colorBind`:
7
7
  * true — 단색 로티. 런타임에 모든 색을 currentColor로 치환해 Palette/Hue 축을 추종.
8
8
  * false — 다색 로티. 원본 색 유지 (브랜드 톤 또는 작가 의도).
9
- * `aspect`: 'square' | 'wide' — 바깥 박스 크기 결정에 참조.
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
- aspect: "square" | "wide";
27
- /** 로티 원본이 캔버스를 덜 채워 체감 크기가 작을 때, 쇼케이스·사용처에서 쓸 보정 배율.
28
- * 1 = 원본 그대로. 1보다 크면 bounds 안에서 scale-up (overflow 숨김 전제). */
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
- aspect: "wide",
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: false,
55
- aspect: "square",
56
- visualScale: 1.3,
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
- aspect: "wide",
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
- aspect: "wide",
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
- aspect: "square",
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
- aspect: "square",
94
- visualScale: 2.8,
95
+ visualScale: 3.5,
95
96
  description: "회전하는 사각형. 미니멀 처리 대기",
96
97
  },
97
98
  };
@@ -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
- isGenerating={boolean} // true면 액션 숨김
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]] — 이벤트 pill의 톤 체계는 Badge soft와 동일 토큰. "+N"·"오늘" 뱃지도 Badge 재사용.
31
+ - [[components/badge]] — "+N"·"오늘" 메타 뱃지는 Badge soft. EventChip 톤 체계도 Badge와 동일 토큰.
31
32
  - [[components/icon-button]] — 월 이동 네비게이션(이전해·이전달·다음달·다음해 4개). ghost + sm.
32
- - [[components/button]] — "오늘" 복귀 버튼은 outline·sm, "주말" 토글은 ghost/secondary·sm.
33
- - [[axes/shape]] — 셀 radius는 없음(격자형이라 radius 신호를 셀이 받지 않음). pill·badge만 Shape 축에 반응.
34
- - [[axes/density]] — compact일수록 셀 padding이 줄고 pill이 더 많이 보임.
35
- - [[tokens/color]] — `--primary`(선택), `--hover`(셀 hover), `--border`(hairline), `--bg-muted` + `*-subtle`(pill 배경), `--info`/`--destructive`(요일 신호).
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
- | tone | 배경 | 글자 | 쓰임새 |
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]] 하단에 붙는 소스 참조 chip 리스트. "Sources" 라벨 + 가로 wrap chip. 각 chip은 외부 링크 (favicon·링크 아이콘 좌측).
4
+ AIMessage의 **하단 액션바 옆에 inline으로 붙는 작은 chip**. 겹친 favicon stack + 첫 소스 라벨 + `+N` 패턴. 블록이 아니라 액션 액세서리 — 실제론 [[components/ai-message]]의 `sources` prop으로 전달해 자동 배치.
5
5
 
6
6
  ### Do
7
- - Search 응답 뒤에 자동으로 노출 — AI가 참고한 출처 투명하게.
8
- - chip label은 호스트명 중심으로 짧게 (`"nytimes.com"`, `"wikipedia.org — 제목"`).
9
- - 긴 라벨은 중간 ellipsis가 아니라 끝 ellipsis (호스트명 보존).
7
+ - `<AIMessage sources={...}>` prop으로 전달. standalone으로 쓸 일은 거의 없음.
8
+ - `maxFavicons`로 겹쳐 보여줄 favicon 개수 조정 (기본 3). 나머지는 `+N`.
9
+ - 각 소스에 `icon` 넘겨 favicon을 명시, 없으면 중성 원형 placeholder.
10
+ - 클릭 시 소비처에서 popover(소스 전체 리스트) 열기 — 디자인 시스템 범위 밖.
10
11
 
11
12
  ### Don't
12
- - Sources 안에 추가 메타(날짜·발행자) 넣지 않는다 — 깔끔한 chip만.
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" // default
21
+ label="Sources" // default
22
+ maxFavicons={3} // default
23
+ onClick={() => void} // 소스 전체 popover 열기 등
20
24
  />
21
25
  ```
22
26
 
23
27
  ### 레이아웃
24
- - label: text-xs, fg-muted
25
- - chip: `space-1 space-2` padding, `bg-subtle`, `radius-full`
26
- - gap: `space-1` (chip↔chip)
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conscience-technology/ctds",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "CTDS — 하이브리드 디자인 시스템 설치 · 온보딩 CLI",
5
5
  "type": "module",
6
6
  "bin": {