@arcote.tech/arc-ds 0.5.2 → 0.5.5

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc-ds",
3
3
  "type": "module",
4
- "version": "0.5.2",
4
+ "version": "0.5.5",
5
5
  "private": false,
6
6
  "author": "Przemysław Krasiński [arcote.tech]",
7
7
  "description": "Design System for Arc framework — CVA-based components with display modes and variant overrides",
@@ -28,7 +28,7 @@
28
28
  "tailwind-merge": "^3.5.0"
29
29
  },
30
30
  "peerDependencies": {
31
- "@arcote.tech/arc": "^0.5.2",
31
+ "@arcote.tech/arc": "^0.5.5",
32
32
  "framer-motion": "^12.0.0",
33
33
  "lucide-react": ">=0.400.0",
34
34
  "radix-ui": "^1.0.0",
@@ -1,5 +1,4 @@
1
1
  import { Box } from "../box/box";
2
- import { useBentoSpan } from "../bento-grid/bento-grid";
3
2
  import { motion } from "framer-motion";
4
3
  import { Plus } from "lucide-react";
5
4
  import type { ComponentType, ReactNode } from "react";
@@ -13,7 +12,6 @@ export interface BentoCardProps {
13
12
  onEdit: () => void;
14
13
  emptyLabel?: ReactNode;
15
14
  className?: string;
16
- span?: string;
17
15
  children?: ReactNode;
18
16
  }
19
17
 
@@ -26,45 +24,39 @@ export function BentoCard({
26
24
  onEdit,
27
25
  emptyLabel,
28
26
  className,
29
- span,
30
27
  children,
31
28
  }: BentoCardProps) {
32
- const autoSpan = useBentoSpan();
33
- const resolvedSpan = span ?? autoSpan;
34
-
35
29
  return (
36
- <div className={`h-full ${resolvedSpan} ${className ?? ""}`.trim()}>
37
- <motion.div
38
- layoutId={layoutId}
39
- className="h-full"
40
- style={{ opacity: isModalOpen ? 0 : 1 }}
30
+ <motion.div
31
+ layoutId={layoutId}
32
+ className={className}
33
+ style={{ opacity: isModalOpen ? 0 : 1 }}
34
+ >
35
+ <Box
36
+ layout={false}
37
+ className="group relative cursor-pointer p-5 hover:shadow-lg transition-all duration-300 hover:scale-[1.01]"
38
+ onClick={onEdit}
41
39
  >
42
- <Box
43
- layout={false}
44
- className="group relative cursor-pointer p-5 hover:shadow-lg transition-all duration-300 h-full min-h-[140px] hover:scale-[1.01]"
45
- onClick={onEdit}
46
- >
47
- <div className="flex items-center gap-2.5 mb-3">
48
- <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10">
49
- <Icon className="h-4 w-4 text-primary" />
50
- </div>
51
- <span className="text-sm font-semibold">{title}</span>
40
+ <div className="flex items-center gap-2.5 mb-3">
41
+ <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10">
42
+ <Icon className="h-4 w-4 text-primary" />
52
43
  </div>
44
+ <span className="text-sm font-semibold">{title}</span>
45
+ </div>
53
46
 
54
- {isEmpty ? (
55
- <div className="flex flex-col items-center justify-center gap-2 py-4 border-2 border-dashed border-muted-foreground/15 rounded-xl transition-colors group-hover:border-primary/30">
56
- <div className="flex h-9 w-9 items-center justify-center rounded-full bg-muted transition-colors group-hover:bg-primary/10">
57
- <Plus className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-primary" />
58
- </div>
59
- {emptyLabel && (
60
- <span className="text-xs text-muted-foreground">{emptyLabel}</span>
61
- )}
47
+ {isEmpty ? (
48
+ <div className="flex flex-col items-center justify-center gap-2 py-4 border-2 border-dashed border-muted-foreground/15 rounded-xl transition-colors group-hover:border-primary/30">
49
+ <div className="flex h-9 w-9 items-center justify-center rounded-full bg-muted transition-colors group-hover:bg-primary/10">
50
+ <Plus className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-primary" />
62
51
  </div>
63
- ) : (
64
- <div className="flex flex-col gap-2">{children}</div>
65
- )}
66
- </Box>
67
- </motion.div>
68
- </div>
52
+ {emptyLabel && (
53
+ <span className="text-xs text-muted-foreground">{emptyLabel}</span>
54
+ )}
55
+ </div>
56
+ ) : (
57
+ <div className="flex flex-col gap-2">{children}</div>
58
+ )}
59
+ </Box>
60
+ </motion.div>
69
61
  );
70
62
  }
@@ -1,51 +1,38 @@
1
- import { Children, createContext, useContext, type ReactNode } from "react";
1
+ import { Children, useMemo, type ReactNode } from "react";
2
2
 
3
3
  export interface BentoGridProps {
4
4
  children: ReactNode;
5
5
  className?: string;
6
6
  }
7
7
 
8
- type BentoGridItemContext = {
9
- index: number;
10
- count: number;
11
- };
12
-
13
- const BentoItemContext = createContext<BentoGridItemContext | null>(null);
14
-
15
- /**
16
- * Hook for children to read their grid span class.
17
- * Returns the appropriate Tailwind class (e.g., "md:row-span-2", "md:col-span-3")
18
- * or empty string for normal cells.
19
- */
20
- export function useBentoSpan(): string {
21
- const ctx = useContext(BentoItemContext);
22
- if (!ctx) return "";
23
- const { index, count } = ctx;
24
-
25
- if (count < 5) return "";
26
- if (index === 0) return "md:row-span-2";
27
- if (index === count - 1 && (count === 6 || count === 8)) return "md:col-span-3";
28
- return "";
29
- }
30
-
31
8
  /**
32
- * Responsive bento grid with auto-layout based on child count.
9
+ * Responsive multi-column layout. Children are distributed round-robin into
10
+ * 3 columns; each column is a flex-col so items take their natural height.
11
+ * No row-spanning, no fixed row heights, no auto-layout magic — every item
12
+ * controls its own size and renders exactly as tall as its content.
33
13
  *
34
- * Children call useBentoSpan() to get their grid span class.
35
- * No wrapper divs children control their own layout.
14
+ * Breakpoints: 1 column (mobile), 2 (md), 3 (lg+). Children are always
15
+ * distributed across 3 buckets; on narrower viewports the buckets stack via
16
+ * the outer `grid-cols-*` utilities.
36
17
  */
37
18
  export function BentoGrid({ children, className }: BentoGridProps) {
38
- const items = Children.toArray(children);
39
- const count = items.length;
40
-
41
- if (count === 0) return null;
19
+ const columns = useMemo(() => {
20
+ const items = Children.toArray(children);
21
+ const cols: ReactNode[][] = [[], [], []];
22
+ items.forEach((item, i) => {
23
+ cols[i % 3].push(item);
24
+ });
25
+ return cols;
26
+ }, [children]);
42
27
 
43
28
  return (
44
- <div className={`grid grid-cols-1 md:grid-cols-3 gap-3 ${className ?? ""}`}>
45
- {items.map((child, index) => (
46
- <BentoItemContext.Provider key={index} value={{ index, count }}>
47
- {child}
48
- </BentoItemContext.Provider>
29
+ <div
30
+ className={`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 items-start ${className ?? ""}`}
31
+ >
32
+ {columns.map((col, i) => (
33
+ <div key={i} className="flex flex-col gap-3">
34
+ {col}
35
+ </div>
49
36
  ))}
50
37
  </div>
51
38
  );
@@ -5,6 +5,7 @@ import { SearchSelect } from "../search-select/search-select";
5
5
  import { Send, Globe } from "lucide-react";
6
6
  import { useState, type ReactNode } from "react";
7
7
  import type { ChatModel, SendMessageOptions } from "./types";
8
+ import { useChatLabels } from "./chat-labels";
8
9
 
9
10
  interface ChatInputProps {
10
11
  onSend: (message: string, options: SendMessageOptions) => void;
@@ -12,10 +13,18 @@ interface ChatInputProps {
12
13
  defaultModel?: string;
13
14
  /** Extra toolbar content (e.g., attach menu) rendered after web search toggle */
14
15
  toolbar?: ReactNode;
15
- /** Label for web search toggle */
16
- webSearchLabel?: string;
17
- /** Placeholder for message input */
18
- placeholder?: string;
16
+ /** Show the model selector dropdown. Default true. */
17
+ showModelSelector?: boolean;
18
+ /** Show the web search toggle. Default true. */
19
+ showWebSearch?: boolean;
20
+ /**
21
+ * Render slot for the send button. Receives `onClick` and `disabled` —
22
+ * consumer wires them to its own button. Defaults to a `<Button icon={Send}>`.
23
+ */
24
+ renderSendButton?: (props: {
25
+ onClick: () => void;
26
+ disabled: boolean;
27
+ }) => ReactNode;
19
28
  /** Disable input (e.g., during generation) */
20
29
  disabled?: boolean;
21
30
  }
@@ -25,10 +34,12 @@ export function ChatInput({
25
34
  models,
26
35
  defaultModel,
27
36
  toolbar,
28
- webSearchLabel = "Web",
29
- placeholder = "Type a message...",
37
+ showModelSelector = true,
38
+ showWebSearch = true,
39
+ renderSendButton,
30
40
  disabled = false,
31
41
  }: ChatInputProps) {
42
+ const labels = useChatLabels();
32
43
  const [message, setMessage] = useState("");
33
44
  const [model, setModel] = useState(defaultModel ?? models[0]?.value ?? "");
34
45
  const [webSearch, setWebSearch] = useState(false);
@@ -41,51 +52,65 @@ export function ChatInput({
41
52
  setMessage("");
42
53
  };
43
54
 
55
+ // Hide the toolbar row entirely if there's nothing in it
56
+ const hasToolbar = showModelSelector || showWebSearch || toolbar;
57
+ const sendDisabled = disabled || !message.trim();
58
+
44
59
  return (
45
60
  <Box className={`p-3 space-y-2 ${disabled ? "opacity-50 pointer-events-none" : ""}`}>
46
61
  {/* Input + send */}
47
- <div className="flex items-end gap-2">
62
+ <div className="flex items-center gap-2">
48
63
  <div className="flex-1">
49
64
  <TextareaField
50
65
  value={message}
51
66
  onChange={(val) => setMessage(val ?? "")}
52
- placeholder={disabled ? "Generowanie..." : placeholder}
67
+ placeholder={disabled ? labels.placeholderGenerating : labels.placeholder}
53
68
  rows={1}
54
69
  />
55
70
  </div>
56
- <Button
57
- size="sm"
58
- icon={Send}
59
- onClick={handleSend}
60
- disabled={disabled || !message.trim()}
61
- />
71
+ {renderSendButton ? (
72
+ renderSendButton({ onClick: handleSend, disabled: sendDisabled })
73
+ ) : (
74
+ <Button
75
+ size="sm"
76
+ icon={Send}
77
+ onClick={handleSend}
78
+ disabled={sendDisabled}
79
+ />
80
+ )}
62
81
  </div>
63
82
 
64
83
  {/* Toolbar */}
65
- <div className="flex items-center gap-1.5">
66
- <div className="w-[130px]">
67
- <SearchSelect
68
- value={model}
69
- onChange={setModel}
70
- options={models}
71
- placeholder="Model..."
72
- position="absolute"
73
- direction="up"
74
- size="sm"
75
- allowClear={false}
76
- />
77
- </div>
84
+ {hasToolbar && (
85
+ <div className="flex items-center gap-1.5">
86
+ {showModelSelector && (
87
+ <div className="w-[130px]">
88
+ <SearchSelect
89
+ value={model}
90
+ onChange={setModel}
91
+ options={models}
92
+ placeholder={labels.modelPlaceholder}
93
+ position="absolute"
94
+ direction="up"
95
+ size="sm"
96
+ allowClear={false}
97
+ />
98
+ </div>
99
+ )}
78
100
 
79
- <Button
80
- variant={webSearch ? "default" : "ghost"}
81
- size="xs"
82
- icon={Globe}
83
- label={webSearchLabel}
84
- onClick={() => setWebSearch((v) => !v)}
85
- />
101
+ {showWebSearch && (
102
+ <Button
103
+ variant={webSearch ? "default" : "ghost"}
104
+ size="xs"
105
+ icon={Globe}
106
+ label={labels.webSearchLabel}
107
+ onClick={() => setWebSearch((v) => !v)}
108
+ />
109
+ )}
86
110
 
87
- {toolbar}
88
- </div>
111
+ {toolbar}
112
+ </div>
113
+ )}
89
114
  </Box>
90
115
  );
91
116
  }
@@ -0,0 +1,89 @@
1
+ import { createContext, useContext, useMemo, type ReactNode } from "react";
2
+
3
+ export interface ChatLabels {
4
+ // ChatInput
5
+ placeholder: string;
6
+ placeholderGenerating: string;
7
+ modelPlaceholder: string;
8
+ webSearchLabel: string;
9
+ // QuestionTabs (wizard)
10
+ submitLabel: string;
11
+ customPlaceholder: string;
12
+ nextLabel: string;
13
+ backLabel: string;
14
+ summaryTitle: string;
15
+ editLabel: string;
16
+ noAnswerLabel: string;
17
+ questionOfLabel: (current: number, total: number) => string;
18
+ /** Secondary button in QuestionTabs — "Continue discussion" instead of answering */
19
+ discussLabel: string;
20
+ // ChatMessage
21
+ questionsLabel: string;
22
+ answerLabel: string;
23
+ // Tool execution log (rendered by chat-component)
24
+ toolCallingLabel: string;
25
+ toolDoneLabel: string;
26
+ errorLabel: string;
27
+ // askQuestions tool view
28
+ answerBelowLabel: string;
29
+ // completeStage tool view
30
+ stageCompleteLabel: string;
31
+ advanceStageLabel: string;
32
+ continueStageLabel: string;
33
+ stageAdvancedLabel: string;
34
+ stageContinuedLabel: string;
35
+ /** Label for the "Przejdź do następnego" button on the final stage. */
36
+ finishConsultationLabel: string;
37
+ }
38
+
39
+ export const defaultChatLabels: ChatLabels = {
40
+ placeholder: "Type a message...",
41
+ placeholderGenerating: "Generating...",
42
+ modelPlaceholder: "Model...",
43
+ webSearchLabel: "Web",
44
+ submitLabel: "Submit",
45
+ customPlaceholder: "Custom answer...",
46
+ nextLabel: "Next",
47
+ backLabel: "Back",
48
+ summaryTitle: "Summary",
49
+ editLabel: "Edit",
50
+ noAnswerLabel: "No answer",
51
+ questionOfLabel: (current, total) => `Question ${current} of ${total}`,
52
+ discussLabel: "Continue discussion",
53
+ questionsLabel: "questions to answer",
54
+ answerLabel: "Answer",
55
+ toolCallingLabel: "Executing...",
56
+ toolDoneLabel: "Done",
57
+ errorLabel: "Error",
58
+ answerBelowLabel: "Answer the questions below",
59
+ stageCompleteLabel: "Stage complete",
60
+ advanceStageLabel: "Go to next stage",
61
+ continueStageLabel: "Keep talking",
62
+ stageAdvancedLabel: "Moved to next stage",
63
+ stageContinuedLabel: "Staying on this stage",
64
+ finishConsultationLabel: "Finish consultation",
65
+ };
66
+
67
+ const ChatLabelsContext = createContext<ChatLabels>(defaultChatLabels);
68
+
69
+ export function ChatLabelsProvider({
70
+ labels,
71
+ children,
72
+ }: {
73
+ labels?: Partial<ChatLabels>;
74
+ children: ReactNode;
75
+ }) {
76
+ const merged = useMemo<ChatLabels>(
77
+ () => ({ ...defaultChatLabels, ...labels }),
78
+ [labels],
79
+ );
80
+ return (
81
+ <ChatLabelsContext.Provider value={merged}>
82
+ {children}
83
+ </ChatLabelsContext.Provider>
84
+ );
85
+ }
86
+
87
+ export function useChatLabels(): ChatLabels {
88
+ return useContext(ChatLabelsContext);
89
+ }
@@ -2,24 +2,20 @@ import { Button } from "../button/button";
2
2
  import { Bot, User, MessageSquare } from "lucide-react";
3
3
  import type { ChatMessageData } from "./types";
4
4
  import { ToolUseBlock } from "./tool-use-block";
5
+ import { useChatLabels } from "./chat-labels";
5
6
 
6
7
  interface ChatMessageProps {
7
8
  message: ChatMessageData;
8
9
  onAnswerQuestions?: () => void;
9
10
  onToolUseClick?: (link: string) => void;
10
- /** Label for questions count, e.g. "questions to answer" */
11
- questionsLabel?: string;
12
- /** Label for the answer button */
13
- answerLabel?: string;
14
11
  }
15
12
 
16
13
  export function ChatMessage({
17
14
  message,
18
15
  onAnswerQuestions,
19
16
  onToolUseClick,
20
- questionsLabel = "questions to answer",
21
- answerLabel = "Answer",
22
17
  }: ChatMessageProps) {
18
+ const { questionsLabel, answerLabel } = useChatLabels();
23
19
  const isUser = message.role === "user";
24
20
  const hasUnansweredQuestions =
25
21
  message.questions && message.questions.length > 0;
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
- import { MessageCircleQuestion, CheckCircle2 } from "lucide-react";
2
+ import { MessageCircleQuestion, User } from "lucide-react";
3
3
 
4
4
  export interface ChatToolQuestionProps {
5
5
  /** Whether the tool is waiting for user response */
@@ -12,23 +12,23 @@ export function ChatToolQuestion({
12
12
  calling,
13
13
  children,
14
14
  }: ChatToolQuestionProps) {
15
- const borderColor = calling
16
- ? "border-amber-500/20 bg-amber-500/5"
17
- : "border-green-500/20 bg-green-500/5";
15
+ // Calling = waiting for user → amber accent.
16
+ // Answered → primary (purple) so it visually matches user message bubbles.
17
+ const containerColor = calling
18
+ ? "border border-amber-500/20 bg-amber-500/5"
19
+ : "border border-primary/20 bg-primary/10";
18
20
 
19
- const iconColor = calling
20
- ? "text-amber-500"
21
- : "text-green-500";
21
+ const iconColor = calling ? "text-amber-500" : "text-primary";
22
22
 
23
23
  return (
24
24
  <div
25
- className={`flex items-start gap-3 w-full rounded-xl border p-3 transition-colors ${borderColor}`}
25
+ className={`flex items-start gap-3 w-full rounded-xl p-3 transition-colors ${containerColor}`}
26
26
  >
27
27
  <div className={`mt-0.5 shrink-0 ${iconColor}`}>
28
28
  {calling ? (
29
29
  <MessageCircleQuestion className="h-4 w-4" />
30
30
  ) : (
31
- <CheckCircle2 className="h-4 w-4" />
31
+ <User className="h-4 w-4" />
32
32
  )}
33
33
  </div>
34
34
  <div className="flex-1 min-w-0 space-y-2 text-sm">{children}</div>
@@ -32,20 +32,22 @@ export interface ChatProps {
32
32
  sidebar?: ReactNode;
33
33
  /** Extra toolbar content for ChatInput (attach menu, etc.) */
34
34
  toolbar?: ReactNode;
35
+ /** Show the model selector dropdown in ChatInput. Default true. */
36
+ showModelSelector?: boolean;
37
+ /** Show the web search toggle in ChatInput. Default true. */
38
+ showWebSearch?: boolean;
39
+ /**
40
+ * Render slot for ChatInput's send button. Receives `onClick` and
41
+ * `disabled` — caller renders its own button (e.g. branded with a logo).
42
+ */
43
+ renderSendButton?: (props: {
44
+ onClick: () => void;
45
+ disabled: boolean;
46
+ }) => ReactNode;
35
47
  /** Max width class for the message area */
36
48
  maxWidth?: string;
37
49
  /** Disable input (during generation) */
38
50
  disabled?: boolean;
39
- /** Labels for i18n */
40
- labels?: {
41
- questionsLabel?: string;
42
- answerLabel?: string;
43
- webSearchLabel?: string;
44
- placeholder?: string;
45
- submitLabel?: string;
46
- completedLabel?: string;
47
- customPlaceholder?: string;
48
- };
49
51
  }
50
52
 
51
53
  export function Chat({
@@ -58,9 +60,11 @@ export function Chat({
58
60
  header,
59
61
  sidebar,
60
62
  toolbar,
63
+ showModelSelector = true,
64
+ showWebSearch = true,
65
+ renderSendButton,
61
66
  maxWidth = "max-w-3xl",
62
67
  disabled = false,
63
- labels,
64
68
  }: ChatProps) {
65
69
  const { inputOverride } = useChatInput();
66
70
  const [answeringMessageId, setAnsweringMessageId] = useState<string | null>(
@@ -103,8 +107,6 @@ export function Chat({
103
107
  : undefined
104
108
  }
105
109
  onToolUseClick={onToolUseClick}
106
- questionsLabel={labels?.questionsLabel}
107
- answerLabel={labels?.answerLabel}
108
110
  />
109
111
  ))}
110
112
  <div ref={messagesEndRef} />
@@ -121,9 +123,6 @@ export function Chat({
121
123
  onAnswerQuestions?.(answeringMessageId!, answers);
122
124
  setAnsweringMessageId(null);
123
125
  }}
124
- submitLabel={labels?.submitLabel}
125
- completedLabel={labels?.completedLabel}
126
- customPlaceholder={labels?.customPlaceholder}
127
126
  />
128
127
  ) : (
129
128
  <ChatInput
@@ -131,8 +130,9 @@ export function Chat({
131
130
  models={models}
132
131
  defaultModel={defaultModel}
133
132
  toolbar={toolbar}
134
- webSearchLabel={labels?.webSearchLabel}
135
- placeholder={labels?.placeholder}
133
+ showModelSelector={showModelSelector}
134
+ showWebSearch={showWebSearch}
135
+ renderSendButton={renderSendButton}
136
136
  disabled={disabled}
137
137
  />
138
138
  )}
@@ -1,29 +1,40 @@
1
1
  import { Box } from "../box/box";
2
2
  import { Button } from "../button/button";
3
3
  import { TextareaField } from "../form";
4
- import { Send, Check } from "lucide-react";
5
- import { useState } from "react";
4
+ import { ArrowLeft, ArrowRight, Check, MessageCircle, Pencil, Send } from "lucide-react";
5
+ import { Fragment, useState } from "react";
6
6
  import type { Question, QuestionAnswers } from "./types";
7
+ import { useChatLabels } from "./chat-labels";
7
8
 
8
9
  interface QuestionTabsProps {
9
10
  questions: Question[];
10
- onSubmit: (answers: QuestionAnswers) => void;
11
- /** Label for submit button */
12
- submitLabel?: string;
13
- /** Label for completed count, e.g. "completed" */
14
- completedLabel?: string;
15
- /** Placeholder for custom answer input */
16
- customPlaceholder?: string;
11
+ /**
12
+ * Called when user finishes answering. `wantsToDiscuss` is true when the
13
+ * user clicked the secondary "Continue discussion" button — consumers
14
+ * should forward this to the LLM so it knows to switch from question
15
+ * mode to open-ended conversation.
16
+ */
17
+ onSubmit: (
18
+ answers: QuestionAnswers,
19
+ options: { wantsToDiscuss: boolean },
20
+ ) => void;
17
21
  }
18
22
 
19
- export function QuestionTabs({
20
- questions,
21
- onSubmit,
22
- submitLabel = "Submit",
23
- completedLabel = "completed",
24
- customPlaceholder = "Custom answer...",
25
- }: QuestionTabsProps) {
26
- const [activeTab, setActiveTab] = useState(0);
23
+ type Step = { type: "question"; index: number } | { type: "summary" };
24
+
25
+ export function QuestionTabs({ questions, onSubmit }: QuestionTabsProps) {
26
+ const {
27
+ submitLabel,
28
+ nextLabel,
29
+ backLabel,
30
+ summaryTitle,
31
+ editLabel,
32
+ noAnswerLabel,
33
+ questionOfLabel,
34
+ customPlaceholder,
35
+ discussLabel,
36
+ } = useChatLabels();
37
+ const [step, setStep] = useState<Step>({ type: "question", index: 0 });
27
38
  const [answers, setAnswers] = useState<QuestionAnswers>(
28
39
  () =>
29
40
  Object.fromEntries(
@@ -31,69 +42,147 @@ export function QuestionTabs({
31
42
  ),
32
43
  );
33
44
 
34
- const current = questions[activeTab];
35
- const currentAnswer = answers[current.id] ?? { selected: [], text: "" };
45
+ const isQuestionStep = step.type === "question";
46
+ const currentIndex = isQuestionStep ? step.index : -1;
47
+ const isLastQuestion = currentIndex === questions.length - 1;
36
48
 
37
- const toggleOption = (option: string) => {
38
- const selected = currentAnswer.selected.includes(option)
39
- ? currentAnswer.selected.filter((o) => o !== option)
40
- : [...currentAnswer.selected, option];
41
- setAnswers((prev) => ({
42
- ...prev,
43
- [current.id]: { ...prev[current.id], selected },
44
- }));
49
+ const goToQuestion = (index: number) =>
50
+ setStep({ type: "question", index });
51
+ const goToSummary = () => setStep({ type: "summary" });
52
+
53
+ const handleNext = () => {
54
+ if (!isQuestionStep) return;
55
+ if (isLastQuestion) {
56
+ goToSummary();
57
+ } else {
58
+ goToQuestion(currentIndex + 1);
59
+ }
45
60
  };
46
61
 
47
- const setText = (text: string) => {
62
+ const handleBackFromSummary = () => {
63
+ goToQuestion(questions.length - 1);
64
+ };
65
+
66
+ const updateAnswer = (
67
+ questionId: string,
68
+ updater: (a: { selected: string[]; text: string }) => {
69
+ selected: string[];
70
+ text: string;
71
+ },
72
+ ) => {
48
73
  setAnswers((prev) => ({
49
74
  ...prev,
50
- [current.id]: { ...prev[current.id], text },
75
+ [questionId]: updater(prev[questionId] ?? { selected: [], text: "" }),
51
76
  }));
52
77
  };
53
78
 
79
+ // ─── Summary step ──────────────────────────────────────────────
80
+ if (step.type === "summary") {
81
+ return (
82
+ <Box className="p-0 overflow-hidden">
83
+ <div className="border-b border-border px-4 py-3">
84
+ <p className="text-sm font-semibold">{summaryTitle}</p>
85
+ </div>
86
+
87
+ <ul className="divide-y divide-border">
88
+ {questions.map((q, i) => {
89
+ const answer = answers[q.id] ?? { selected: [], text: "" };
90
+ const parts = [
91
+ ...answer.selected,
92
+ ...(answer.text.trim() ? [answer.text.trim()] : []),
93
+ ];
94
+ const hasAnswer = parts.length > 0;
95
+ return (
96
+ <li
97
+ key={q.id}
98
+ className="group flex items-start gap-3 px-4 py-2.5"
99
+ >
100
+ <div className="flex-1 min-w-0">
101
+ <p className="text-xs font-medium text-muted-foreground">
102
+ {q.label}
103
+ </p>
104
+ <p
105
+ className={`text-sm mt-0.5 ${
106
+ hasAnswer ? "" : "italic text-muted-foreground/60"
107
+ }`}
108
+ >
109
+ {hasAnswer ? parts.join(", ") : noAnswerLabel}
110
+ </p>
111
+ </div>
112
+ <Button
113
+ variant="ghost"
114
+ size="xs"
115
+ icon={Pencil}
116
+ label={editLabel}
117
+ onClick={() => goToQuestion(i)}
118
+ className="opacity-0 group-hover:opacity-100 transition-opacity"
119
+ />
120
+ </li>
121
+ );
122
+ })}
123
+ </ul>
124
+
125
+ <div className="flex items-center justify-between border-t border-border px-3 py-2 gap-2">
126
+ <Button
127
+ variant="ghost"
128
+ size="sm"
129
+ icon={ArrowLeft}
130
+ label={backLabel}
131
+ onClick={handleBackFromSummary}
132
+ />
133
+ <div className="flex items-center gap-2">
134
+ <Button
135
+ variant="ghost"
136
+ size="sm"
137
+ icon={MessageCircle}
138
+ label={discussLabel}
139
+ onClick={() => onSubmit(answers, { wantsToDiscuss: true })}
140
+ />
141
+ <Button
142
+ size="sm"
143
+ icon={Send}
144
+ label={submitLabel}
145
+ onClick={() => onSubmit(answers, { wantsToDiscuss: false })}
146
+ />
147
+ </div>
148
+ </div>
149
+ </Box>
150
+ );
151
+ }
152
+
153
+ // ─── Question step ─────────────────────────────────────────────
154
+ const current = questions[currentIndex];
155
+ const currentAnswer = answers[current.id] ?? { selected: [], text: "" };
54
156
  const hasCustomText = currentAnswer.text.trim().length > 0;
55
157
 
56
- const totalAnswered = questions.filter(
57
- (q) =>
58
- (answers[q.id]?.selected.length ?? 0) > 0 ||
59
- (answers[q.id]?.text ?? "").trim().length > 0,
60
- ).length;
158
+ const toggleOption = (option: string) => {
159
+ updateAnswer(current.id, (a) => ({
160
+ ...a,
161
+ selected: a.selected.includes(option)
162
+ ? a.selected.filter((o) => o !== option)
163
+ : [...a.selected, option],
164
+ }));
165
+ };
166
+
167
+ const setText = (text: string) => {
168
+ updateAnswer(current.id, (a) => ({ ...a, text }));
169
+ };
61
170
 
62
171
  return (
63
172
  <Box className="p-0 overflow-hidden">
64
- {/* Tabs */}
65
- <div className="flex border-b border-border overflow-x-auto">
66
- {questions.map((q, i) => {
67
- const hasAnswer =
68
- (answers[q.id]?.selected.length ?? 0) > 0 ||
69
- (answers[q.id]?.text ?? "").trim().length > 0;
70
- return (
71
- <button
72
- key={q.id}
73
- type="button"
74
- onClick={() => setActiveTab(i)}
75
- className={`flex items-center gap-1.5 px-3 py-2 text-xs font-medium whitespace-nowrap transition-colors border-b-2 -mb-px ${
76
- i === activeTab
77
- ? "border-primary text-primary"
78
- : "border-transparent text-muted-foreground hover:text-foreground"
79
- }`}
80
- >
81
- {hasAnswer && (
82
- <span className="h-1.5 w-1.5 rounded-full bg-primary shrink-0" />
83
- )}
84
- {q.label}
85
- </button>
86
- );
87
- })}
173
+ {/* Header */}
174
+ <div className="border-b border-border px-4 py-3 space-y-1">
175
+ <p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
176
+ {questionOfLabel(currentIndex + 1, questions.length)}
177
+ </p>
178
+ <p className="text-sm font-semibold">{current.label}</p>
179
+ {current.description && (
180
+ <p className="text-xs text-muted-foreground">{current.description}</p>
181
+ )}
88
182
  </div>
89
183
 
90
- {/* Content */}
184
+ {/* Options */}
91
185
  <div className="p-3 space-y-1.5">
92
- <p className="text-xs text-muted-foreground mb-2">
93
- {current.description}
94
- </p>
95
-
96
- {/* Options */}
97
186
  {current.options.map((option) => {
98
187
  const isSelected = currentAnswer.selected.includes(option);
99
188
  return (
@@ -123,7 +212,7 @@ export function QuestionTabs({
123
212
  );
124
213
  })}
125
214
 
126
- {/* Custom option */}
215
+ {/* Custom answer */}
127
216
  <div
128
217
  className={`flex items-start gap-2.5 w-full rounded-lg px-3 py-2.5 transition-colors ${
129
218
  hasCustomText
@@ -153,17 +242,46 @@ export function QuestionTabs({
153
242
  </div>
154
243
  </div>
155
244
 
156
- {/* Footer */}
157
- <div className="flex items-center justify-between border-t border-border px-3 py-2">
158
- <span className="text-[10px] text-muted-foreground">
159
- {totalAnswered}/{questions.length} {completedLabel}
160
- </span>
161
- <Button
162
- size="sm"
163
- icon={Send}
164
- label={submitLabel}
165
- onClick={() => onSubmit(answers)}
166
- />
245
+ {/* Footer — progress dots + discuss/next buttons */}
246
+ <div className="flex items-center justify-between border-t border-border px-3 py-2 gap-2">
247
+ <div className="flex items-center gap-1">
248
+ {questions.map((q, i) => {
249
+ const answered =
250
+ (answers[q.id]?.selected.length ?? 0) > 0 ||
251
+ (answers[q.id]?.text ?? "").trim().length > 0;
252
+ return (
253
+ <Fragment key={q.id}>
254
+ <button
255
+ type="button"
256
+ onClick={() => goToQuestion(i)}
257
+ className={`h-1.5 rounded-full transition-all ${
258
+ i === currentIndex
259
+ ? "w-4 bg-primary"
260
+ : answered
261
+ ? "w-1.5 bg-primary/60"
262
+ : "w-1.5 bg-muted-foreground/30"
263
+ }`}
264
+ aria-label={q.label}
265
+ />
266
+ </Fragment>
267
+ );
268
+ })}
269
+ </div>
270
+ <div className="flex items-center gap-2">
271
+ <Button
272
+ variant="ghost"
273
+ size="sm"
274
+ icon={MessageCircle}
275
+ label={discussLabel}
276
+ onClick={() => onSubmit(answers, { wantsToDiscuss: true })}
277
+ />
278
+ <Button
279
+ size="sm"
280
+ icon={ArrowRight}
281
+ label={nextLabel}
282
+ onClick={handleNext}
283
+ />
284
+ </div>
167
285
  </div>
168
286
  </Box>
169
287
  );
@@ -0,0 +1,60 @@
1
+ import { cva, type VariantProps } from "class-variance-authority";
2
+ import type { ReactNode } from "react";
3
+ import { cn } from "../../lib/utils";
4
+ import { Box } from "../box/box";
5
+
6
+ /**
7
+ * Sidebar — vertical container for navigation/auxiliary content beside main
8
+ * page content. Typically registered into a layout slot like `sidebar-left`.
9
+ *
10
+ * The `sticky` variant pins the sidebar next to the toolbar and stretches it
11
+ * to fill the visible viewport height. It relies on the CSS variable
12
+ * `--arc-toolbar-height` set by the desktop `Layout` (measured from the
13
+ * actual rendered toolbar via ResizeObserver). Falls back to `5rem` if the
14
+ * variable isn't set.
15
+ */
16
+ export const sidebarVariants = cva("flex flex-col gap-3 p-4", {
17
+ variants: {
18
+ variant: {
19
+ default: "",
20
+ sticky:
21
+ "sticky top-[var(--arc-toolbar-height,5rem)] " +
22
+ "max-h-[calc(100vh-var(--arc-toolbar-height,5rem)-1rem)] " +
23
+ "overflow-y-auto",
24
+ },
25
+ },
26
+ defaultVariants: {
27
+ variant: "default",
28
+ },
29
+ });
30
+
31
+ export interface SidebarProps extends VariantProps<typeof sidebarVariants> {
32
+ children?: ReactNode;
33
+ className?: string;
34
+ /** Title rendered at the top with uppercase muted styling. */
35
+ title?: ReactNode;
36
+ /** Footer rendered at the bottom with a top border. */
37
+ footer?: ReactNode;
38
+ }
39
+
40
+ export function Sidebar({
41
+ variant,
42
+ children,
43
+ className,
44
+ title,
45
+ footer,
46
+ }: SidebarProps) {
47
+ return (
48
+ <Box className={cn(sidebarVariants({ variant }), className)}>
49
+ {title && (
50
+ <h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
51
+ {title}
52
+ </h3>
53
+ )}
54
+ <div className="flex-1 min-h-0">{children}</div>
55
+ {footer && (
56
+ <div className="pt-2 border-t border-border">{footer}</div>
57
+ )}
58
+ </Box>
59
+ );
60
+ }
package/src/index.ts CHANGED
@@ -54,7 +54,7 @@ export {
54
54
  } from "./ds/avatar/avatar";
55
55
  export { BentoCard } from "./ds/bento-card/bento-card";
56
56
  export type { BentoCardProps } from "./ds/bento-card/bento-card";
57
- export { BentoGrid, useBentoSpan } from "./ds/bento-grid/bento-grid";
57
+ export { BentoGrid } from "./ds/bento-grid/bento-grid";
58
58
  export type { BentoGridProps } from "./ds/bento-grid/bento-grid";
59
59
  export { CardModal, CardFormModal, ModalActions } from "./ds/card-modal/card-modal";
60
60
  export type { CardModalProps, CardFormModalProps, ModalActionsProps } from "./ds/card-modal/card-modal";
@@ -67,6 +67,8 @@ export type { SearchSelectProps, SearchSelectOption } from "./ds/search-select/s
67
67
  export { Badge, badgeVariants } from "./ds/badge/badge";
68
68
  export { Box, boxVariants } from "./ds/box/box";
69
69
  export type { BoxProps } from "./ds/box/box";
70
+ export { Sidebar, sidebarVariants } from "./ds/sidebar/sidebar";
71
+ export type { SidebarProps } from "./ds/sidebar/sidebar";
70
72
  export {
71
73
  Button,
72
74
  buttonIconVariants,
@@ -111,6 +113,12 @@ export type { ChatToolLogProps } from "./ds/chat/chat-tool-log";
111
113
  export { ChatToolQuestion } from "./ds/chat/chat-tool-question";
112
114
  export type { ChatToolQuestionProps } from "./ds/chat/chat-tool-question";
113
115
  export { ChatInputProvider, useChatInput } from "./ds/chat/chat-input-provider";
116
+ export {
117
+ ChatLabelsProvider,
118
+ useChatLabels,
119
+ defaultChatLabels,
120
+ } from "./ds/chat/chat-labels";
121
+ export type { ChatLabels } from "./ds/chat/chat-labels";
114
122
  export type {
115
123
  ChatMessageData,
116
124
  ChatModel,
@@ -68,6 +68,12 @@ export function DynamicSlotProvider({ children }: { children: ReactNode }) {
68
68
  /**
69
69
  * Register dynamic content into a named slot.
70
70
  * Content appears while the component is mounted, removed on unmount.
71
+ *
72
+ * Always registers — even with `null` content — so the slot preserves a
73
+ * stable insertion order across re-renders. Readers filter out null entries
74
+ * via `useDynamicSlotContent` below, so conditional injection
75
+ * (`useSlotContent(slot, id, cond ? <x /> : null)`) works without shifting
76
+ * the position of sibling slot entries.
71
77
  */
72
78
  export function useSlotContent(slotId: string, id: string, content: ReactNode) {
73
79
  const ctx = useContext(DynamicSlotContext);
@@ -85,11 +91,15 @@ export function useSlotContent(slotId: string, id: string, content: ReactNode) {
85
91
  }
86
92
 
87
93
  /**
88
- * Read dynamic slot content for a given slot ID.
94
+ * Read dynamic slot content for a given slot ID. Filters out null/undefined
95
+ * entries so consumers get only the renderable pieces.
89
96
  */
90
97
  export function useDynamicSlotContent(slotId: string): ReactNode[] {
91
98
  const ctx = useContext(DynamicSlotContext);
92
99
  if (!ctx) return [];
93
100
  const entries = ctx.entries.get(slotId);
94
- return entries ? entries.map((e) => e.content) : [];
101
+ if (!entries) return [];
102
+ return entries
103
+ .map((e) => e.content)
104
+ .filter((c) => c != null && c !== false);
95
105
  }
@@ -47,8 +47,11 @@ export function Layout({ children }: { children?: ReactNode }) {
47
47
  function SubNavSlot() {
48
48
  const content = useDynamicSlotContent("sub-nav");
49
49
  if (content.length === 0) return null;
50
+ // `relative z-30` creates an explicit stacking context at Z.BASE so any
51
+ // popovers inside the sub-nav (SearchSelect absolute dropdown, etc.) paint
52
+ // above the main content area that follows it in document flow.
50
53
  return (
51
- <div className="flex justify-center py-2">
54
+ <div className="relative z-30 flex justify-center py-2">
52
55
  <Box layoutId="sub-nav" className="flex items-center gap-1 px-3 py-2">
53
56
  {content}
54
57
  </Box>
@@ -56,21 +59,84 @@ function SubNavSlot() {
56
59
  );
57
60
  }
58
61
 
62
+ /**
63
+ * Tracks the desktop toolbar's actual rendered height and exposes it as a
64
+ * CSS variable on `<html>`. The toolbar is `position: fixed` so the layout
65
+ * itself can't measure it via flow — we query the data-attributed boxes
66
+ * (set by `DesktopToolbar`) and observe size changes.
67
+ *
68
+ * Consumers (sidebar, content spacer) read `var(--arc-toolbar-height)` to
69
+ * align with the toolbar without hard-coding pixel values.
70
+ */
71
+ function useDesktopToolbarHeight() {
72
+ useEffect(() => {
73
+ const root = document.documentElement;
74
+ const set = (px: number) => {
75
+ root.style.setProperty("--arc-toolbar-height", `${px}px`);
76
+ };
77
+
78
+ let ro: ResizeObserver | null = null;
79
+ let cancelled = false;
80
+
81
+ const start = () => {
82
+ if (cancelled) return;
83
+ const boxes = Array.from(
84
+ document.querySelectorAll<HTMLElement>("[data-arc-toolbar]"),
85
+ );
86
+ if (boxes.length === 0) {
87
+ // Toolbar not mounted yet — try again next frame
88
+ requestAnimationFrame(start);
89
+ return;
90
+ }
91
+ const measure = () => {
92
+ const tallest = boxes.reduce(
93
+ (max, el) => Math.max(max, el.offsetHeight),
94
+ 0,
95
+ );
96
+ // 16px top margin (top-4) + box height + 16px breathing room
97
+ set(tallest + 16 + 16);
98
+ };
99
+ measure();
100
+ ro = new ResizeObserver(measure);
101
+ for (const el of boxes) ro.observe(el);
102
+ };
103
+
104
+ start();
105
+
106
+ return () => {
107
+ cancelled = true;
108
+ ro?.disconnect();
109
+ root.style.removeProperty("--arc-toolbar-height");
110
+ };
111
+ }, []);
112
+ }
113
+
59
114
  function DesktopLayout({ children }: { children?: ReactNode }) {
60
115
  const renderSlot = useRenderSlot();
116
+ useDesktopToolbarHeight();
61
117
 
62
118
  return (
63
119
  <>
64
120
  <DesktopToolbar />
65
- <div className="h-16" />
121
+ {/* Spacer pushing main content below the fixed toolbar.
122
+ Height matches `--arc-toolbar-height` set by useDesktopToolbarHeight. */}
123
+ <div style={{ height: "var(--arc-toolbar-height, 5rem)" }} />
66
124
  <SubNavSlot />
67
- <div className="mx-auto flex w-full max-w-5xl flex-1 gap-4 p-4">
68
- {renderSlot("sidebar-left", { className: "flex w-64 shrink-0 flex-col gap-4" })}
69
- <div className="min-w-0 flex-1">
70
- {children}
71
- {renderSlot("main-content", { className: "flex flex-col gap-4" })}
125
+ <div className="flex w-full flex-1">
126
+ <div className="flex min-w-0 flex-1 justify-center">
127
+ <div className="flex w-full max-w-5xl gap-4 p-4">
128
+ {renderSlot("sidebar-left", { className: "flex w-64 shrink-0 flex-col gap-4" })}
129
+ <div className="min-w-0 flex-1">
130
+ {children}
131
+ {renderSlot("main-content", { className: "flex flex-col gap-4" })}
132
+ </div>
133
+ {renderSlot("sidebar-right", { className: "flex w-64 shrink-0 flex-col gap-4" })}
134
+ </div>
72
135
  </div>
73
- {renderSlot("sidebar-right", { className: "flex w-64 shrink-0 flex-col gap-4" })}
136
+ {renderSlot("preview-pane", {
137
+ className:
138
+ "flex w-[480px] shrink-0 flex-col gap-4 overflow-y-auto border-l p-4",
139
+ })}
74
140
  </div>
75
141
  </>
76
142
  );
@@ -98,6 +164,7 @@ function DesktopToolbar() {
98
164
  return (
99
165
  <>
100
166
  <div
167
+ data-arc-toolbar="left"
101
168
  className="fixed left-4 top-4"
102
169
  style={{ zIndex: zIndex("workspace") }}
103
170
  >
@@ -108,6 +175,7 @@ function DesktopToolbar() {
108
175
  </div>
109
176
 
110
177
  <div
178
+ data-arc-toolbar="center"
111
179
  className="fixed left-1/2 top-4 max-w-[calc(100vw-26rem)] -translate-x-1/2"
112
180
  style={{ zIndex: zIndex("center") }}
113
181
  >
@@ -117,6 +185,7 @@ function DesktopToolbar() {
117
185
  </div>
118
186
 
119
187
  <div
188
+ data-arc-toolbar="right"
120
189
  className="fixed right-4 top-4"
121
190
  style={{ zIndex: zIndex("settings") }}
122
191
  >