@conscience-technology/ctds 0.1.8 → 0.1.10

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.
Files changed (78) hide show
  1. package/assets/components/AIMessage/AIMessage.module.css +117 -0
  2. package/assets/components/AIMessage/AIMessage.tsx +90 -0
  3. package/assets/components/AIMessage/index.ts +2 -0
  4. package/assets/components/AttachmentChip/AttachmentChip.module.css +3 -1
  5. package/assets/components/AttachmentThumb/AttachmentThumb.module.css +13 -7
  6. package/assets/components/AttachmentThumb/AttachmentThumb.tsx +4 -1
  7. package/assets/components/Bubble/Bubble.module.css +65 -0
  8. package/assets/components/Bubble/Bubble.tsx +63 -0
  9. package/assets/components/Bubble/index.ts +2 -0
  10. package/assets/components/Button/Button.module.css +15 -2
  11. package/assets/components/Button/Button.tsx +13 -0
  12. package/assets/components/Calendar/Calendar.module.css +163 -0
  13. package/assets/components/Calendar/Calendar.tsx +323 -0
  14. package/assets/components/Calendar/index.ts +2 -0
  15. package/assets/components/ChatInput/ChatInput.module.css +81 -41
  16. package/assets/components/ChatInput/ChatInput.tsx +45 -32
  17. package/assets/components/ChatInput/ModelSelector.tsx +23 -27
  18. package/assets/components/ChatInput/index.ts +0 -1
  19. package/assets/components/CodeBlock/CodeBlock.module.css +79 -0
  20. package/assets/components/CodeBlock/CodeBlock.tsx +71 -0
  21. package/assets/components/CodeBlock/index.ts +2 -0
  22. package/assets/components/ContextMenu/ContextMenu.tsx +10 -1
  23. package/assets/components/DropdownMenu/DropdownMenu.module.css +26 -0
  24. package/assets/components/DropdownMenu/DropdownMenu.tsx +367 -0
  25. package/assets/components/DropdownMenu/index.ts +9 -0
  26. package/assets/components/EventChip/EventChip.module.css +57 -0
  27. package/assets/components/EventChip/EventChip.tsx +41 -0
  28. package/assets/components/EventChip/index.ts +2 -0
  29. package/assets/components/Icon/Icon.tsx +3 -0
  30. package/assets/components/IdentityButton/IdentityButton.module.css +119 -0
  31. package/assets/components/IdentityButton/IdentityButton.tsx +113 -0
  32. package/assets/components/IdentityButton/index.ts +6 -0
  33. package/assets/components/MenuItem/MenuItem.module.css +67 -2
  34. package/assets/components/MenuItem/MenuItem.tsx +22 -2
  35. package/assets/components/MessageActions/MessageActions.module.css +43 -0
  36. package/assets/components/MessageActions/MessageActions.tsx +94 -0
  37. package/assets/components/MessageActions/index.ts +6 -0
  38. package/assets/components/MessageQuote/MessageQuote.module.css +18 -0
  39. package/assets/components/MessageQuote/MessageQuote.tsx +33 -0
  40. package/assets/components/MessageQuote/index.ts +2 -0
  41. package/assets/components/MessageSources/MessageSources.module.css +63 -0
  42. package/assets/components/MessageSources/MessageSources.tsx +50 -0
  43. package/assets/components/MessageSources/index.ts +2 -0
  44. package/assets/components/MessageTable/MessageTable.module.css +46 -0
  45. package/assets/components/MessageTable/MessageTable.tsx +21 -0
  46. package/assets/components/MessageTable/index.ts +2 -0
  47. package/assets/components/QuotationBar/QuotationBar.module.css +2 -2
  48. package/assets/components/Sidebar/Sidebar.module.css +3 -57
  49. package/assets/components/Sidebar/Sidebar.tsx +18 -21
  50. package/assets/components/Spinner/LottiePlayer.tsx +75 -0
  51. package/assets/components/Spinner/Spinner.module.css +167 -0
  52. package/assets/components/Spinner/Spinner.tsx +119 -0
  53. package/assets/components/Spinner/index.ts +10 -0
  54. package/assets/components/Spinner/lottieColor.ts +76 -0
  55. package/assets/components/Spinner/lottieManifest.ts +99 -0
  56. package/assets/components/Tabs/Tabs.module.css +3 -3
  57. package/assets/components/UserMessage/UserMessage.module.css +41 -0
  58. package/assets/components/UserMessage/UserMessage.tsx +66 -0
  59. package/assets/components/UserMessage/index.ts +2 -0
  60. package/assets/components/_data.ts +18 -4
  61. package/assets/components/tokens.css +20 -0
  62. package/assets/prompts/components/ai-message.md +43 -0
  63. package/assets/prompts/components/bubble.md +56 -0
  64. package/assets/prompts/components/calendar.md +123 -0
  65. package/assets/prompts/components/code-block.md +35 -0
  66. package/assets/prompts/components/dropdown-menu.md +145 -0
  67. package/assets/prompts/components/identity-button.md +168 -0
  68. package/assets/prompts/components/menu-item.md +2 -1
  69. package/assets/prompts/components/message-actions.md +49 -0
  70. package/assets/prompts/components/message-quote.md +26 -0
  71. package/assets/prompts/components/message-sources.md +26 -0
  72. package/assets/prompts/components/message-table.md +27 -0
  73. package/assets/prompts/components/sidebar.md +106 -37
  74. package/assets/prompts/components/spinner.md +109 -0
  75. package/assets/prompts/components/user-message.md +45 -0
  76. package/assets/prompts/primitives/scroll.md +22 -11
  77. package/assets/skill/SKILL.md +1 -0
  78. package/package.json +1 -1
@@ -0,0 +1,117 @@
1
+ /* AIMessage — 왼쪽 정렬, bubble 없이 본문 그대로. */
2
+
3
+ .root {
4
+ display: flex;
5
+ flex-direction: column;
6
+ align-items: flex-start;
7
+ gap: var(--space-2);
8
+ width: 100%;
9
+ }
10
+
11
+ .label {
12
+ font-size: var(--text-xs);
13
+ color: var(--fg-muted);
14
+ line-height: 1.2;
15
+ }
16
+
17
+ .body {
18
+ color: var(--fg);
19
+ font-size: var(--text-base);
20
+ line-height: 2;
21
+ width: 100%;
22
+ min-width: 0;
23
+ }
24
+
25
+ /* isGenerating — 한 글자씩 통통 wave */
26
+ .root[data-generating="true"] .body {
27
+ color: var(--fg-muted);
28
+ font-weight: var(--font-weight-medium);
29
+ }
30
+
31
+ .generatingSpinner {
32
+ color: var(--fg-muted);
33
+ vertical-align: middle;
34
+ margin-right: var(--space-1);
35
+ }
36
+
37
+ .bounceChar {
38
+ display: inline-block;
39
+ animation: aimsg-char-bounce calc(var(--duration-slower) * 5) var(--ease-in-out) infinite;
40
+ }
41
+
42
+ .bounceSpace {
43
+ display: inline-block;
44
+ white-space: pre;
45
+ }
46
+
47
+ @keyframes aimsg-char-bounce {
48
+ 0%, 70%, 100% {
49
+ transform: translateY(0);
50
+ opacity: 0.6;
51
+ }
52
+ 35% {
53
+ transform: translateY(calc(-1.5px * var(--motion-distance-scale))); /* lint-hardcoded: ignore */
54
+ opacity: 1;
55
+ }
56
+ }
57
+ /* 본문 안의 기본 markdown 요소들 톤 — AIMessage 안에서만 적용 */
58
+ .body > :global(h1),
59
+ .body > :global(h2),
60
+ .body > :global(h3) {
61
+ color: var(--fg-strong);
62
+ font-weight: var(--font-weight-semibold);
63
+ margin: var(--space-3) 0 var(--space-1);
64
+ }
65
+ .body > :global(p) {
66
+ margin: var(--space-2) 0;
67
+ }
68
+ /* 리스트 — 들여쓰기 최소화. 마커는 primary로 채워 포인트. */
69
+ .body :global(ol),
70
+ .body :global(ul) {
71
+ padding-left: var(--space-4);
72
+ margin: var(--space-2) 0;
73
+ }
74
+ .body :global(li) {
75
+ margin: var(--space-0_5) 0;
76
+ padding-left: 0;
77
+ }
78
+ .body :global(li::marker) {
79
+ color: var(--primary);
80
+ font-variant-numeric: tabular-nums;
81
+ }
82
+ /* 중첩 리스트 — 조금만 더 들여쓰기 */
83
+ .body :global(li > ol),
84
+ .body :global(li > ul) {
85
+ padding-left: var(--space-4);
86
+ margin: var(--space-0_5) 0;
87
+ }
88
+ .body > :global(code) {
89
+ font-family: var(--font-code);
90
+ font-size: 0.9em;
91
+ padding: var(--space-0_5) var(--space-1);
92
+ background: var(--bg-subtle);
93
+ border-radius: var(--radius-xs);
94
+ }
95
+
96
+ .actions {
97
+ display: flex;
98
+ }
99
+
100
+ /* visibility=show: 상시 */
101
+ .root[data-actions-visibility="show"] .actions {
102
+ opacity: 1;
103
+ }
104
+
105
+ /* visibility=hover: hover/focus-within에서만 — 부드러운 fade + translate */
106
+ .root[data-actions-visibility="hover"] .actions {
107
+ opacity: 0;
108
+ transform: translateY(calc(var(--space-0_5) * -1));
109
+ transition:
110
+ opacity var(--duration-slow) var(--ease-out),
111
+ transform var(--duration-slow) var(--ease-out);
112
+ }
113
+ .root[data-actions-visibility="hover"]:hover .actions,
114
+ .root[data-actions-visibility="hover"]:focus-within .actions {
115
+ opacity: 1;
116
+ transform: translateY(0);
117
+ }
@@ -0,0 +1,90 @@
1
+ "use client";
2
+
3
+ import type { HTMLAttributes, ReactNode } from "react";
4
+ import {
5
+ MessageActions,
6
+ type MessageAction,
7
+ } from "@/components/ctds/MessageActions";
8
+ import { Spinner } from "@/components/ctds/Spinner";
9
+ import styles from "./AIMessage.module.css";
10
+
11
+ export type AIMessageActionsVisibility = "show" | "hover";
12
+
13
+ export interface AIMessageProps extends HTMLAttributes<HTMLDivElement> {
14
+ /** 메시지 본문 — markdown 또는 콘텐츠 블록 조합. */
15
+ children: ReactNode;
16
+ /** 메시지 위 작은 라벨 ("LongText", "Code Block" 등). 없으면 숨김. */
17
+ label?: ReactNode;
18
+ /** 액션 노출. 기본 hover. */
19
+ actionsVisibility?: AIMessageActionsVisibility;
20
+ /** 액션 커스터마이즈. 기본 [copy, regenerate]. 비우려면 []. */
21
+ actions?: MessageAction[];
22
+ /** 생성 중이면 액션 숨김. */
23
+ isGenerating?: boolean;
24
+ }
25
+
26
+ const DEFAULT_ACTIONS: MessageAction[] = [
27
+ { kind: "copy" },
28
+ { kind: "regenerate" },
29
+ ];
30
+
31
+ function renderBouncingText(text: string) {
32
+ const chars = Array.from(text);
33
+ return chars.map((ch, i) => {
34
+ if (ch === " ") {
35
+ return (
36
+ <span key={i} className={styles.bounceSpace}>
37
+ {" "}
38
+ </span>
39
+ );
40
+ }
41
+ return (
42
+ <span
43
+ key={i}
44
+ className={styles.bounceChar}
45
+ style={{ animationDelay: `calc(${i} * var(--duration-base))` }}
46
+ >
47
+ {ch}
48
+ </span>
49
+ );
50
+ });
51
+ }
52
+
53
+ export function AIMessage({
54
+ children,
55
+ label,
56
+ actionsVisibility = "hover",
57
+ actions = DEFAULT_ACTIONS,
58
+ isGenerating = false,
59
+ className,
60
+ ...rest
61
+ }: AIMessageProps) {
62
+ const cls = [styles.root, className].filter(Boolean).join(" ");
63
+ const hasActions = actions.length > 0 && !isGenerating;
64
+
65
+ return (
66
+ <div
67
+ className={cls}
68
+ data-actions-visibility={actionsVisibility}
69
+ data-generating={isGenerating ? "true" : undefined}
70
+ {...rest}
71
+ >
72
+ {label ? <div className={styles.label}>{label}</div> : null}
73
+ <div className={styles.body}>
74
+ {isGenerating ? (
75
+ <>
76
+ <Spinner variant="lottie-dots" size="sm" className={styles.generatingSpinner} />
77
+ {typeof children === "string" ? renderBouncingText(children) : children}
78
+ </>
79
+ ) : (
80
+ children
81
+ )}
82
+ </div>
83
+ {hasActions ? (
84
+ <div className={styles.actions}>
85
+ <MessageActions align="start" actions={actions} />
86
+ </div>
87
+ ) : null}
88
+ </div>
89
+ );
90
+ }
@@ -0,0 +1,2 @@
1
+ export { AIMessage } from "./AIMessage";
2
+ export type { AIMessageProps, AIMessageActionsVisibility } from "./AIMessage";
@@ -13,14 +13,16 @@
13
13
  transition: var(--transition-colors);
14
14
  }
15
15
 
16
- /* 패딩만 size 차이. 오른쪽은 X 버튼 자리라 좁게. */
16
+ /* 패딩만 size 차이. 오른쪽은 X 버튼 자리라 좁게. 폭은 고정 — label이 길면 ellipsis. */
17
17
  .sizeSm {
18
18
  padding: var(--space-1) var(--space-1) var(--space-1) var(--space-1);
19
19
  font-size: var(--text-xs);
20
+ width: calc(var(--space-20) * 2);
20
21
  }
21
22
  .sizeMd {
22
23
  padding: var(--space-1_5) var(--space-1_5) var(--space-1_5) var(--space-1_5);
23
24
  font-size: var(--text-sm);
25
+ width: calc(var(--space-24) * 2);
24
26
  }
25
27
 
26
28
  /* 파일명 */
@@ -22,9 +22,12 @@
22
22
  .sizeMd { --at-size: 40px; /* lint-hardcoded: ignore */ }
23
23
  .sizeLg { --at-size: 56px; /* lint-hardcoded: ignore */ }
24
24
 
25
- /* 이미지 */
25
+ /* 이미지 — 다크/라이트 공통 검정 보더(이미지 가장자리 분리).
26
+ <img>에 inset shadow 걸면 이미지 픽셀이 덮어버리므로 부모에 outline 사용. */
26
27
  .hasImage {
27
28
  background: var(--bg-subtle);
29
+ outline: 3px solid rgb(0 0 0 / 0.16); /* lint-hardcoded: ignore */
30
+ outline-offset: -3px;
28
31
  }
29
32
 
30
33
  /* Image glyph placeholder — src 없을 때. 색은 TSX의 CSS 변수로 주입. */
@@ -50,14 +53,17 @@
50
53
  height: calc(var(--at-size) * 0.60);
51
54
  }
52
55
 
53
- /* Loading pulse — 토큰 조합 */
56
+ /* Loading — fg를 어둡게 깐 위에 흰색 lottie spinner.
57
+ Spinner의 colorBind가 바깥 color를 읽으므로 .loading에 색 지정. */
54
58
  .loading {
55
- background: var(--bg-subtle);
56
- animation: at-pulse calc(var(--duration-slower) * 5) var(--ease-in-out) infinite;
59
+ background: color-mix(in oklch, var(--fg) 55%, transparent);
60
+ color: var(--fixed-white);
61
+ overflow: hidden;
57
62
  }
58
- @keyframes at-pulse {
59
- 0%, 100% { opacity: 1; }
60
- 50% { opacity: 0.55; }
63
+ /* wide 로티도 썸네일 안에 꽉 차 보이게 scale-up (overflow:hidden으로 안전) */
64
+ .loading > span[role="status"] {
65
+ transform: scale(1.6);
66
+ transform-origin: center;
61
67
  }
62
68
 
63
69
  /* Remove 버튼 — hover 시만 노출. 코너 overlap. */
@@ -2,6 +2,7 @@
2
2
 
3
3
  import type { CSSProperties, HTMLAttributes, MouseEvent } from "react";
4
4
  import { ImageSquare as PhImageSquare, X as PhX } from "@phosphor-icons/react";
5
+ import { Spinner } from "@/components/ctds/Spinner";
5
6
  import styles from "./AttachmentThumb.module.css";
6
7
 
7
8
  export type AttachmentThumbSize = "sm" | "md" | "lg";
@@ -70,7 +71,9 @@ export function AttachmentThumb({
70
71
  data-removable={onRemove ? "" : undefined}
71
72
  {...rest}
72
73
  >
73
- {loading ? null : src ? (
74
+ {loading ? (
75
+ <Spinner variant="lottie-spinner" size="sm" />
76
+ ) : src ? (
74
77
  <img src={src} alt={alt} className={styles.img} />
75
78
  ) : (
76
79
  <PhImageSquare className={styles.glyph} weight="regular" aria-hidden />
@@ -0,0 +1,65 @@
1
+ /* Bubble — 메시지 말풍선 primitive.
2
+ Shape (4) × Fill (4) 조합. Shape 축에 반응하되 circular/square는 강조 모양으로 수치 고정. */
3
+
4
+ .root {
5
+ display: inline-block;
6
+ max-width: 100%;
7
+ line-height: 1.5;
8
+ word-wrap: break-word;
9
+ white-space: pre-wrap;
10
+ transition: var(--transition-colors);
11
+ }
12
+
13
+ /* Size — md 기본, lg는 패딩·폰트 둘 다 넉넉 */
14
+ .sizeMd {
15
+ padding: var(--space-2) var(--space-3);
16
+ font-size: var(--text-base);
17
+ }
18
+ .sizeLg {
19
+ padding: var(--space-3) var(--space-4);
20
+ font-size: var(--text-base);
21
+ }
22
+
23
+ /* Shape */
24
+ .shapeRounded {
25
+ border-radius: var(--radius-md);
26
+ }
27
+ .shapeSquare {
28
+ border-radius: var(--radius-sm);
29
+ }
30
+ .shapeCircular {
31
+ border-radius: var(--radius-full);
32
+ }
33
+ /* circular-drop — 세 코너는 radius-xl, tail 방향 한 코너만 radius-sm.
34
+ data-tail="end" → 오른쪽 상단만 각짐 (user 메시지, 오른쪽 정렬 꼬리). 기본.
35
+ data-tail="start" → 왼쪽 상단만 각짐 (assistant 메시지, 왼쪽 정렬 꼬리). */
36
+ .shapeCircularDrop {
37
+ border-radius: var(--radius-md);
38
+ }
39
+ .shapeCircularDrop[data-tail="end"] {
40
+ border-top-right-radius: 0;
41
+ }
42
+ .shapeCircularDrop[data-tail="start"] {
43
+ border-top-left-radius: 0;
44
+ }
45
+
46
+ /* Fill */
47
+ .fillNone {
48
+ background: transparent;
49
+ color: var(--fg-strong);
50
+ padding-left: 0;
51
+ padding-right: 0;
52
+ }
53
+ .fillSolid {
54
+ background: var(--primary);
55
+ color: var(--bg);
56
+ }
57
+ .fillTranslucent {
58
+ background: var(--primary-subtle);
59
+ color: var(--fg-strong);
60
+ }
61
+ .fillOutline {
62
+ background: var(--bg);
63
+ color: var(--fg-strong);
64
+ border: var(--hairline) solid var(--border);
65
+ }
@@ -0,0 +1,63 @@
1
+ "use client";
2
+
3
+ import type { HTMLAttributes, ReactNode } from "react";
4
+ import styles from "./Bubble.module.css";
5
+
6
+ export type BubbleShape = "rounded" | "circular-drop" | "circular" | "square";
7
+ export type BubbleFill = "none" | "solid" | "translucent" | "outline";
8
+ export type BubbleSize = "md" | "lg";
9
+ export type BubbleTail = "start" | "end";
10
+
11
+ export interface BubbleProps extends HTMLAttributes<HTMLDivElement> {
12
+ shape?: BubbleShape;
13
+ fill?: BubbleFill;
14
+ size?: BubbleSize;
15
+ /** circular-drop에서 각지는 코너의 방향. UserMessage=end, AIMessage/Quote=start. */
16
+ tail?: BubbleTail;
17
+ children: ReactNode;
18
+ }
19
+
20
+ const shapeClass: Record<BubbleShape, string> = {
21
+ rounded: styles.shapeRounded,
22
+ "circular-drop": styles.shapeCircularDrop,
23
+ circular: styles.shapeCircular,
24
+ square: styles.shapeSquare,
25
+ };
26
+
27
+ const fillClass: Record<BubbleFill, string> = {
28
+ none: styles.fillNone,
29
+ solid: styles.fillSolid,
30
+ translucent: styles.fillTranslucent,
31
+ outline: styles.fillOutline,
32
+ };
33
+
34
+ const sizeClass: Record<BubbleSize, string> = {
35
+ md: styles.sizeMd,
36
+ lg: styles.sizeLg,
37
+ };
38
+
39
+ export function Bubble({
40
+ shape = "rounded",
41
+ fill = "translucent",
42
+ size = "md",
43
+ tail = "end",
44
+ className,
45
+ children,
46
+ ...rest
47
+ }: BubbleProps) {
48
+ const cls = [
49
+ styles.root,
50
+ shapeClass[shape],
51
+ fillClass[fill],
52
+ sizeClass[size],
53
+ className,
54
+ ]
55
+ .filter(Boolean)
56
+ .join(" ");
57
+
58
+ return (
59
+ <div className={cls} data-tail={shape === "circular-drop" ? tail : undefined} {...rest}>
60
+ {children}
61
+ </div>
62
+ );
63
+ }
@@ -0,0 +1,2 @@
1
+ export { Bubble } from "./Bubble";
2
+ export type { BubbleProps, BubbleShape, BubbleFill, BubbleSize, BubbleTail } from "./Bubble";
@@ -48,9 +48,23 @@
48
48
  .root:disabled .icon {
49
49
  opacity: 1;
50
50
  }
51
+ /* Loading — 라벨/아이콘은 보이지만 톤 다운, Spinner가 중앙에 overlay.
52
+ 폭을 유지하기 위해 label·icon은 visibility로 숨김(공간 보존). */
51
53
  .root[data-loading="true"] {
52
54
  cursor: not-allowed;
53
- opacity: 0.5;
55
+ position: relative;
56
+ }
57
+ .root[data-loading="true"] .label,
58
+ .root[data-loading="true"] .icon,
59
+ .root[data-loading="true"] .badge {
60
+ visibility: hidden;
61
+ }
62
+ .root[data-loading="true"] .spinner {
63
+ position: absolute;
64
+ top: 50%;
65
+ left: 50%;
66
+ transform: translate(-50%, -50%);
67
+ color: currentColor;
54
68
  }
55
69
 
56
70
  /* Full width ─────────────────────────────────────────────────── */
@@ -212,5 +226,4 @@
212
226
  /* Loading spinner slot ───────────────────────────────────────── */
213
227
  .spinner {
214
228
  display: inline-flex;
215
- animation: m-spin var(--duration-slower) var(--ease-linear) infinite;
216
229
  }
@@ -10,6 +10,7 @@ import {
10
10
  } from "react";
11
11
  import { Slot } from "@radix-ui/react-slot";
12
12
 
13
+ import { Spinner, type SpinnerSize } from "../Spinner";
13
14
  import styles from "./Button.module.css";
14
15
 
15
16
  export type ButtonVariant =
@@ -63,6 +64,13 @@ const ICON_SIZE: Record<ButtonSize, number> = {
63
64
  lg: 20,
64
65
  };
65
66
 
67
+ const SPINNER_SIZE: Record<ButtonSize, SpinnerSize> = {
68
+ xs: "xs",
69
+ sm: "sm",
70
+ md: "md",
71
+ lg: "lg",
72
+ };
73
+
66
74
  function sizedIcon(node: ReactNode, size: number): ReactNode {
67
75
  if (!isValidElement(node)) return node;
68
76
  const el = node as ReactElement<{ size?: number | string }>;
@@ -139,6 +147,11 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
139
147
  {sizedIcon(rightIcon, ICON_SIZE[size])}
140
148
  </span>
141
149
  )}
150
+ {loading && (
151
+ <span className={styles.spinner} aria-hidden>
152
+ <Spinner size={SPINNER_SIZE[size]} />
153
+ </span>
154
+ )}
142
155
  </Comp>
143
156
  );
144
157
  },
@@ -0,0 +1,163 @@
1
+ /* Calendar ───────────────────────────────────────────────────
2
+ 페이지에 인라인으로 들어가는 월간 캘린더. DatePicker와 분리.
3
+ 레이아웃은 flex column + 그리드 flex-1로 뷰포트 높이에 반응.
4
+ 스크롤 2-레이어 규약: cellEvents는 overflow:hidden 유지 — 넘치면
5
+ hiddenCount로 "+N" 뱃지가 대체한다 (내부 스크롤 금지). */
6
+ .root {
7
+ display: flex;
8
+ flex-direction: column;
9
+ min-height: 0;
10
+ min-width: 0;
11
+ background: var(--bg);
12
+ color: var(--fg);
13
+ }
14
+
15
+ /* Toolbar ──────────────────────────────────────────────────── */
16
+ .toolbar {
17
+ position: relative;
18
+ display: flex;
19
+ align-items: center;
20
+ justify-content: space-between;
21
+ padding-block: var(--space-2);
22
+ gap: var(--space-3);
23
+ }
24
+
25
+ .toolbarSide {
26
+ display: flex;
27
+ align-items: center;
28
+ gap: var(--space-1);
29
+ min-width: 0;
30
+ flex: 1 1 0;
31
+ }
32
+
33
+ .toolbarSide[data-align="end"] {
34
+ justify-content: flex-end;
35
+ }
36
+
37
+ .monthNav {
38
+ position: absolute;
39
+ left: 50%;
40
+ transform: translateX(-50%);
41
+ display: flex;
42
+ align-items: center;
43
+ gap: var(--space-1);
44
+ }
45
+
46
+ .monthTitle {
47
+ min-width: calc(var(--space-2) * 14);
48
+ text-align: center;
49
+ font-family: var(--font-normal);
50
+ font-size: var(--text-base);
51
+ font-weight: var(--font-weight-medium);
52
+ color: var(--fg);
53
+ }
54
+
55
+ /* "오늘"은 현재 달이 아닐 때만 등장. 사라져도 toolbar 높이가 흔들리지 않도록
56
+ 자리를 유지하고 visibility만 토글. */
57
+ .todaySlot {
58
+ visibility: hidden;
59
+ }
60
+ .todaySlot[data-visible] {
61
+ visibility: visible;
62
+ }
63
+
64
+ /* Weekdays ─────────────────────────────────────────────────── */
65
+ .weekdays {
66
+ display: grid;
67
+ gap: 0;
68
+ }
69
+
70
+ .weekday {
71
+ padding-block: var(--space-2);
72
+ text-align: center;
73
+ font-family: var(--label-font);
74
+ font-size: var(--text-xs);
75
+ color: var(--fg-muted);
76
+ letter-spacing: var(--label-tracking);
77
+ }
78
+
79
+ .weekday[data-tone="sat"] { color: var(--info); }
80
+ .weekday[data-tone="sun"] { color: var(--destructive); }
81
+
82
+ /* Month grid ────────────────────────────────────────────────
83
+ 보더는 그리드가 전체 둘레(top/left)를 그리고, 각 셀이 right/bottom만 그린다.
84
+ 이렇게 하면 열 수(5열/7열)가 바뀌어도 왼쪽 세로선이 안정적으로 유지됨. */
85
+ .grid {
86
+ display: grid;
87
+ flex: 1 1 0;
88
+ min-height: 0;
89
+ border-top: var(--hairline) solid var(--border);
90
+ border-left: var(--hairline) solid var(--border);
91
+ }
92
+
93
+ /* Cell ───────────────────────────────────────────────────── */
94
+ .cell {
95
+ position: relative;
96
+ display: flex;
97
+ flex-direction: column;
98
+ gap: var(--space-1);
99
+ min-width: 0;
100
+ min-height: 0;
101
+ padding: var(--space-2);
102
+ border-right: var(--hairline) solid var(--border);
103
+ border-bottom: var(--hairline) solid var(--border);
104
+ overflow: hidden;
105
+ background: var(--bg);
106
+ cursor: pointer;
107
+ transition: var(--transition-colors);
108
+ }
109
+
110
+ .cell:hover:not([data-selected]) {
111
+ background: var(--hover);
112
+ }
113
+
114
+ .cell:focus-visible {
115
+ outline: var(--ring-width) solid var(--ring);
116
+ outline-offset: calc(-1 * var(--ring-width));
117
+ }
118
+
119
+ /* selected 셀 — inset ring. active 상태 위 hover가 border/bg를 덮지 않도록
120
+ ring만 사용 (feedback_hover_over_active 규약). */
121
+ .cell[data-selected] {
122
+ z-index: var(--z-raise);
123
+ box-shadow: inset 0 0 0 var(--ring-width) var(--primary);
124
+ }
125
+
126
+ .cellHead {
127
+ display: flex;
128
+ align-items: center;
129
+ gap: var(--space-1);
130
+ }
131
+
132
+ .dayNum {
133
+ font-family: var(--font-normal);
134
+ font-size: var(--text-sm);
135
+ color: var(--fg);
136
+ line-height: 1;
137
+ }
138
+
139
+ .dayNum[data-tone="muted"] { color: var(--fg-disabled); }
140
+ .dayNum[data-tone="sat"] { color: var(--info); }
141
+ .dayNum[data-tone="sun"] { color: var(--destructive); }
142
+
143
+ .dayNum[data-selected],
144
+ .cell[data-today] .dayNum:not([data-tone="muted"]) {
145
+ color: var(--primary);
146
+ font-weight: var(--font-weight-medium);
147
+ }
148
+
149
+ .cellBadges {
150
+ margin-left: auto;
151
+ display: inline-flex;
152
+ align-items: center;
153
+ gap: var(--space-1);
154
+ }
155
+
156
+ .cellEvents {
157
+ display: flex;
158
+ flex-direction: column;
159
+ gap: var(--space-1);
160
+ flex: 1 1 0;
161
+ min-height: 0;
162
+ overflow: hidden;
163
+ }