@kinetixui/ui 0.10.0 → 0.12.0

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 (35) hide show
  1. package/dist/index.d.ts +403 -1
  2. package/dist/index.js +1832 -2
  3. package/package.json +5 -2
  4. package/src/components/color-picker.tsx +352 -0
  5. package/src/components/comparison-slider.tsx +98 -0
  6. package/src/components/data-grid.tsx +304 -0
  7. package/src/components/diff-viewer.tsx +155 -0
  8. package/src/components/json-viewer.tsx +168 -0
  9. package/src/components/kanban-board.tsx +246 -0
  10. package/src/components/markdown-editor.tsx +288 -0
  11. package/src/components/marquee.tsx +57 -0
  12. package/src/components/message-bubble.tsx +83 -0
  13. package/src/components/multi-select.tsx +134 -0
  14. package/src/components/notification-center.tsx +119 -0
  15. package/src/components/page-header.tsx +43 -0
  16. package/src/components/tour.tsx +156 -0
  17. package/src/components/tree-view.tsx +273 -0
  18. package/src/components/virtual-list.tsx +92 -0
  19. package/src/index.ts +23 -0
  20. package/src/stories/ColorPicker.stories.tsx +28 -0
  21. package/src/stories/ComparisonSlider.stories.tsx +27 -0
  22. package/src/stories/DataGrid.stories.tsx +54 -0
  23. package/src/stories/DiffViewer.stories.tsx +26 -0
  24. package/src/stories/JsonViewer.stories.tsx +31 -0
  25. package/src/stories/KanbanBoard.stories.tsx +44 -0
  26. package/src/stories/MarkdownEditor.stories.tsx +25 -0
  27. package/src/stories/Marquee.stories.tsx +28 -0
  28. package/src/stories/MessageBubble.stories.tsx +32 -0
  29. package/src/stories/MultiSelect.stories.tsx +35 -0
  30. package/src/stories/NotificationCenter.stories.tsx +41 -0
  31. package/src/stories/PageHeader.stories.tsx +47 -0
  32. package/src/stories/Tour.stories.tsx +70 -0
  33. package/src/stories/TreeView.stories.tsx +38 -0
  34. package/src/stories/VirtualList.stories.tsx +36 -0
  35. package/tailwind.config.ts +4 -0
@@ -0,0 +1,246 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import {
5
+ DndContext,
6
+ DragOverlay,
7
+ KeyboardSensor,
8
+ PointerSensor,
9
+ closestCenter,
10
+ getFirstCollision,
11
+ pointerWithin,
12
+ rectIntersection,
13
+ useDroppable,
14
+ useSensor,
15
+ useSensors,
16
+ type CollisionDetection,
17
+ type DragEndEvent,
18
+ type DragOverEvent,
19
+ type DragStartEvent,
20
+ } from "@dnd-kit/core";
21
+ import {
22
+ SortableContext,
23
+ arrayMove,
24
+ sortableKeyboardCoordinates,
25
+ useSortable,
26
+ verticalListSortingStrategy,
27
+ } from "@dnd-kit/sortable";
28
+ import { CSS } from "@dnd-kit/utilities";
29
+ import { cn } from "../lib/utils";
30
+
31
+ export interface KanbanCard {
32
+ id: string;
33
+ content: React.ReactNode;
34
+ }
35
+
36
+ export interface KanbanColumn {
37
+ id: string;
38
+ title: string;
39
+ cards: KanbanCard[];
40
+ }
41
+
42
+ export interface KanbanBoardProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
43
+ columns: KanbanColumn[];
44
+ onColumnsChange: (columns: KanbanColumn[]) => void;
45
+ }
46
+
47
+ /**
48
+ * KanbanBoard — draggable cards across columns, with keyboard DnD. No DnD
49
+ * primitive existed in this package at all, and hand-rolling accessible
50
+ * drag-and-drop (pointer + touch + keyboard, collision detection, live
51
+ * screen-reader announcements) from scratch would both take far longer
52
+ * than this component's own logic and likely be worse than a battle-tested
53
+ * library — unlike `DiffViewer`'s hand-rolled LCS diff, there's no
54
+ * cross-platform-identical-algorithm argument here, since each native
55
+ * platform reaches for its own idiomatic drag primitive anyway (Compose's
56
+ * drag gestures, SwiftUI's `.draggable`/`.dropDestination`, Flutter's
57
+ * `Draggable`/`DragTarget`). `@dnd-kit` is the dependency: no runtime CSS,
58
+ * accessible by default, and this is exactly its documented "multiple
59
+ * containers" sortable pattern — `onDragOver` re-parents a card into the
60
+ * hovered column live (the standard approach; a little re-render churn
61
+ * mid-drag is expected/upstream-documented), `onDragEnd` commits the
62
+ * final within-column reorder. Column order itself isn't draggable — the
63
+ * `COMPONENT-ADDITIONS.md` entry only asked for card dragging.
64
+ *
65
+ * `collisionDetectionStrategy` below is dnd-kit's own documented fix for a
66
+ * specific multi-container gotcha: each column is both a `useDroppable`
67
+ * (so an empty column, or the space below its last card, is still a valid
68
+ * drop target) and wraps a `SortableContext` of its cards. A column's rect
69
+ * is a strict superset of its cards' rects, so plain `closestCorners` (or
70
+ * `closestCenter`/`rectIntersection` alone) almost always reports the
71
+ * column itself as the collision, never a specific card underneath — over
72
+ * gets stuck on the column id and item-level reordering never fires. The
73
+ * fix (lifted from dnd-kit's own multiple-containers example): try
74
+ * `pointerWithin` first, fall back to `rectIntersection`, and if the
75
+ * winning id is a column, re-run `closestCenter` scoped to just that
76
+ * column's cards to find the specific one under the pointer.
77
+ */
78
+ const KanbanBoard = React.forwardRef<HTMLDivElement, KanbanBoardProps>(
79
+ ({ columns, onColumnsChange, className, ...props }, ref) => {
80
+ const sensors = useSensors(
81
+ useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
82
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
83
+ );
84
+ const [activeCard, setActiveCard] = React.useState<KanbanCard | null>(null);
85
+ const lastOverId = React.useRef<string | null>(null);
86
+
87
+ const findColumn = React.useCallback(
88
+ (id: string) => columns.find((col) => col.id === id || col.cards.some((c) => c.id === id)),
89
+ [columns],
90
+ );
91
+
92
+ const collisionDetectionStrategy: CollisionDetection = React.useCallback(
93
+ (args) => {
94
+ const pointerIntersections = pointerWithin(args);
95
+ const intersections = pointerIntersections.length > 0 ? pointerIntersections : rectIntersection(args);
96
+ let overId = getFirstCollision(intersections, "id");
97
+
98
+ if (overId != null) {
99
+ const overColumn = columns.find((col) => col.id === overId);
100
+ if (overColumn && overColumn.cards.length > 0) {
101
+ const cardIds = new Set(overColumn.cards.map((c) => c.id));
102
+ const cardCollision = getFirstCollision(
103
+ closestCenter({
104
+ ...args,
105
+ droppableContainers: args.droppableContainers.filter((c) => cardIds.has(String(c.id))),
106
+ }),
107
+ "id",
108
+ );
109
+ if (cardCollision != null) overId = cardCollision;
110
+ }
111
+ lastOverId.current = String(overId);
112
+ return [{ id: overId }];
113
+ }
114
+
115
+ return lastOverId.current ? [{ id: lastOverId.current }] : [];
116
+ },
117
+ [columns],
118
+ );
119
+
120
+ function handleDragStart(event: DragStartEvent) {
121
+ lastOverId.current = String(event.active.id);
122
+ const column = findColumn(String(event.active.id));
123
+ const card = column?.cards.find((c) => c.id === event.active.id);
124
+ setActiveCard(card ?? null);
125
+ }
126
+
127
+ function handleDragOver(event: DragOverEvent) {
128
+ const { active, over } = event;
129
+ if (!over) return;
130
+ const activeId = String(active.id);
131
+ const overId = String(over.id);
132
+ if (activeId === overId) return;
133
+
134
+ const activeColumn = findColumn(activeId);
135
+ const overColumn = findColumn(overId);
136
+ if (!activeColumn || !overColumn || activeColumn.id === overColumn.id) return;
137
+
138
+ onColumnsChange(
139
+ columns.map((col) => {
140
+ if (col.id === activeColumn.id) {
141
+ return { ...col, cards: col.cards.filter((c) => c.id !== activeId) };
142
+ }
143
+ if (col.id === overColumn.id) {
144
+ const movedCard = activeColumn.cards.find((c) => c.id === activeId);
145
+ if (!movedCard) return col;
146
+ const overIndex = col.cards.findIndex((c) => c.id === overId);
147
+ const insertAt = overIndex >= 0 ? overIndex : col.cards.length;
148
+ const nextCards = [...col.cards];
149
+ nextCards.splice(insertAt, 0, movedCard);
150
+ return { ...col, cards: nextCards };
151
+ }
152
+ return col;
153
+ }),
154
+ );
155
+ }
156
+
157
+ function handleDragEnd(event: DragEndEvent) {
158
+ setActiveCard(null);
159
+ lastOverId.current = null;
160
+ const { active, over } = event;
161
+ if (!over) return;
162
+ const activeId = String(active.id);
163
+ const overId = String(over.id);
164
+ const activeColumn = findColumn(activeId);
165
+ // overId may be the column itself (dropped in empty space / an empty column)
166
+ const overColumn = columns.find((col) => col.id === overId) ?? findColumn(overId);
167
+ if (!activeColumn || !overColumn || activeColumn.id !== overColumn.id) return;
168
+
169
+ const activeIndex = activeColumn.cards.findIndex((c) => c.id === activeId);
170
+ const rawOverIndex = overColumn.cards.findIndex((c) => c.id === overId);
171
+ const overIndex = rawOverIndex >= 0 ? rawOverIndex : overColumn.cards.length - 1;
172
+ if (activeIndex === -1 || overIndex === -1 || activeIndex === overIndex) return;
173
+
174
+ onColumnsChange(
175
+ columns.map((col) =>
176
+ col.id === activeColumn.id ? { ...col, cards: arrayMove(col.cards, activeIndex, overIndex) } : col,
177
+ ),
178
+ );
179
+ }
180
+
181
+ return (
182
+ <DndContext
183
+ sensors={sensors}
184
+ collisionDetection={collisionDetectionStrategy}
185
+ onDragStart={handleDragStart}
186
+ onDragOver={handleDragOver}
187
+ onDragEnd={handleDragEnd}
188
+ onDragCancel={() => {
189
+ setActiveCard(null);
190
+ lastOverId.current = null;
191
+ }}
192
+ >
193
+ <div ref={ref} role="group" className={cn("flex items-start gap-4 overflow-x-auto font-sans", className)} {...props}>
194
+ {columns.map((column) => (
195
+ <KanbanColumnView key={column.id} column={column} />
196
+ ))}
197
+ </div>
198
+ <DragOverlay>{activeCard ? <KanbanCardView card={activeCard} /> : null}</DragOverlay>
199
+ </DndContext>
200
+ );
201
+ },
202
+ );
203
+ KanbanBoard.displayName = "KanbanBoard";
204
+
205
+ function KanbanColumnView({ column }: { column: KanbanColumn }) {
206
+ const { setNodeRef } = useDroppable({ id: column.id });
207
+ return (
208
+ <div className="flex w-64 shrink-0 flex-col gap-2 rounded-md border bg-muted/30 p-2">
209
+ <div className="flex items-center justify-between px-1 py-1">
210
+ <span className="text-sm font-medium text-foreground">{column.title}</span>
211
+ <span className="text-xs text-muted-foreground">{column.cards.length}</span>
212
+ </div>
213
+ <SortableContext items={column.cards.map((c) => c.id)} strategy={verticalListSortingStrategy}>
214
+ <div ref={setNodeRef} className="flex min-h-10 flex-col gap-2">
215
+ {column.cards.map((card) => (
216
+ <SortableKanbanCard key={card.id} card={card} />
217
+ ))}
218
+ </div>
219
+ </SortableContext>
220
+ </div>
221
+ );
222
+ }
223
+
224
+ function SortableKanbanCard({ card }: { card: KanbanCard }) {
225
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: card.id });
226
+ const style: React.CSSProperties = {
227
+ transform: CSS.Transform.toString(transform),
228
+ transition,
229
+ opacity: isDragging ? 0.4 : 1,
230
+ };
231
+ return (
232
+ <div ref={setNodeRef} style={style} {...attributes} {...listeners}>
233
+ <KanbanCardView card={card} />
234
+ </div>
235
+ );
236
+ }
237
+
238
+ function KanbanCardView({ card }: { card: KanbanCard }) {
239
+ return (
240
+ <div className="cursor-grab touch-none rounded-md border bg-background p-3 text-sm text-foreground shadow-sm outline-none active:cursor-grabbing focus-visible:ring-1 focus-visible:ring-ring">
241
+ {card.content}
242
+ </div>
243
+ );
244
+ }
245
+
246
+ export { KanbanBoard };
@@ -0,0 +1,288 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Bold, Code, Eye, EyeOff, Heading2, Italic, Link as LinkIcon, List, ListOrdered, Quote } from "lucide-react";
5
+ import { cn } from "../lib/utils";
6
+ import { Textarea } from "./textarea";
7
+
8
+ export interface MarkdownEditorProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "onChange"> {
9
+ value: string;
10
+ onChange: (value: string) => void;
11
+ placeholder?: string;
12
+ rows?: number;
13
+ }
14
+
15
+ function wrapSelection(text: string, start: number, end: number, before: string, after: string, placeholder: string) {
16
+ const selected = text.slice(start, end) || placeholder;
17
+ const next = text.slice(0, start) + before + selected + after + text.slice(end);
18
+ return { text: next, selectionStart: start + before.length, selectionEnd: start + before.length + selected.length };
19
+ }
20
+
21
+ function prefixLines(text: string, start: number, end: number, prefix: (lineIndex: number) => string) {
22
+ const lineStart = text.lastIndexOf("\n", Math.max(start - 1, 0)) + 1;
23
+ let lineEnd = text.indexOf("\n", Math.max(end, lineStart));
24
+ if (lineEnd === -1) lineEnd = text.length;
25
+ const block = text.slice(lineStart, lineEnd);
26
+ const lines = block.split("\n");
27
+ const prefixed = lines.map((line, i) => prefix(i) + line).join("\n");
28
+ return {
29
+ text: text.slice(0, lineStart) + prefixed + text.slice(lineEnd),
30
+ selectionStart: start + prefix(0).length,
31
+ selectionEnd: end + (prefixed.length - block.length),
32
+ };
33
+ }
34
+
35
+ /** HTML-escapes source text before any markup is inserted — see the render call below. */
36
+ function escapeHtml(s: string) {
37
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
38
+ }
39
+
40
+ function renderInline(escaped: string): string {
41
+ return escaped
42
+ .replace(/`([^`]+)`/g, "<code>$1</code>")
43
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
44
+ .replace(/\*([^*]+)\*/g, "<em>$1</em>")
45
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, label: string, url: string) => {
46
+ const safe = /^(https?:|mailto:|\/|#)/i.test(url.trim());
47
+ return safe ? `<a href="${url}" target="_blank" rel="noopener noreferrer">${label}</a>` : match;
48
+ });
49
+ }
50
+
51
+ /**
52
+ * A small hand-rolled Markdown → HTML renderer covering exactly the syntax
53
+ * the toolbar below produces (headings, bold, italic, links, inline/block
54
+ * code, bullet/numbered lists, blockquotes, paragraphs) — not a full
55
+ * CommonMark implementation. Kept dependency-free and simple enough to
56
+ * port identically to Compose/SwiftUI/Flutter, the same reasoning as
57
+ * `DiffViewer`'s hand-rolled LCS diff.
58
+ */
59
+
60
+ /**
61
+ * Matches `markerRe` at the start of `line`, then requires at least one
62
+ * space/tab before returning the rest as `content`. Deliberately not a
63
+ * single `markerRe` + `\s+(.*)$` regex: two adjacent unbounded quantifiers
64
+ * over overlapping character classes (`\s` and `.` both match a space) is
65
+ * a catastrophic-backtracking shape CodeQL flags as a ReDoS — walking the
66
+ * whitespace run by hand is linear and can't backtrack.
67
+ */
68
+ function matchMarker(line: string, markerRe: RegExp): { marker: string; content: string } | null {
69
+ const m = markerRe.exec(line);
70
+ if (!m) return null;
71
+ let i = m[0].length;
72
+ if (line[i] !== " " && line[i] !== "\t") return null;
73
+ while (line[i] === " " || line[i] === "\t") i++;
74
+ return { marker: m[0], content: line.slice(i) };
75
+ }
76
+
77
+ function renderMarkdown(source: string): string {
78
+ const lines = source.split("\n");
79
+ const out: string[] = [];
80
+ let inCode = false;
81
+ let codeLines: string[] = [];
82
+ let listType: "ul" | "ol" | null = null;
83
+
84
+ const closeList = () => {
85
+ if (listType) {
86
+ out.push(`</${listType}>`);
87
+ listType = null;
88
+ }
89
+ };
90
+
91
+ for (const line of lines) {
92
+ if (line.trim().startsWith("```")) {
93
+ if (inCode) {
94
+ out.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
95
+ codeLines = [];
96
+ inCode = false;
97
+ } else {
98
+ closeList();
99
+ inCode = true;
100
+ }
101
+ continue;
102
+ }
103
+ if (inCode) {
104
+ codeLines.push(line);
105
+ continue;
106
+ }
107
+
108
+ const heading = matchMarker(line, /^#{1,6}/);
109
+ if (heading) {
110
+ closeList();
111
+ const level = heading.marker.length;
112
+ out.push(`<h${level}>${renderInline(escapeHtml(heading.content))}</h${level}>`);
113
+ continue;
114
+ }
115
+
116
+ const quote = line.match(/^>\s?(.*)$/);
117
+ if (quote) {
118
+ closeList();
119
+ out.push(`<blockquote>${renderInline(escapeHtml(quote[1]!))}</blockquote>`);
120
+ continue;
121
+ }
122
+
123
+ const bullet = matchMarker(line, /^[-*]/);
124
+ if (bullet) {
125
+ if (listType !== "ul") {
126
+ closeList();
127
+ out.push("<ul>");
128
+ listType = "ul";
129
+ }
130
+ out.push(`<li>${renderInline(escapeHtml(bullet.content))}</li>`);
131
+ continue;
132
+ }
133
+
134
+ const numbered = matchMarker(line, /^\d+\./);
135
+ if (numbered) {
136
+ if (listType !== "ol") {
137
+ closeList();
138
+ out.push("<ol>");
139
+ listType = "ol";
140
+ }
141
+ out.push(`<li>${renderInline(escapeHtml(numbered.content))}</li>`);
142
+ continue;
143
+ }
144
+
145
+ closeList();
146
+ if (line.trim() === "") continue;
147
+ out.push(`<p>${renderInline(escapeHtml(line))}</p>`);
148
+ }
149
+ closeList();
150
+ if (inCode) out.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
151
+
152
+ return out.join("\n");
153
+ }
154
+
155
+ /**
156
+ * MarkdownEditor — toolbar + a plain `Textarea` (the markdown source) with
157
+ * an optional rendered preview pane. Deliberately *not* `contenteditable`
158
+ * (no Tiptap/Lexical): `contenteditable` has no native-platform analogue,
159
+ * so a WYSIWYG rich-text editor would be a web-only, eighth standing
160
+ * non-port. A markdown textarea is just a text buffer the toolbar inserts
161
+ * syntax into — SwiftUI's `TextEditor`, Compose's `BasicTextField`, and
162
+ * Flutter's `TextField` all support the same selection-based insertion, so
163
+ * this ports cleanly to all four platforms instead.
164
+ */
165
+ const MarkdownEditor = React.forwardRef<HTMLDivElement, MarkdownEditorProps>(
166
+ ({ value, onChange, placeholder, rows = 10, className, ...props }, ref) => {
167
+ const textareaRef = React.useRef<HTMLTextAreaElement>(null);
168
+ const [showPreview, setShowPreview] = React.useState(false);
169
+ const pendingSelection = React.useRef<{ start: number; end: number } | null>(null);
170
+
171
+ React.useEffect(() => {
172
+ if (pendingSelection.current && textareaRef.current) {
173
+ const { start, end } = pendingSelection.current;
174
+ textareaRef.current.focus();
175
+ textareaRef.current.setSelectionRange(start, end);
176
+ pendingSelection.current = null;
177
+ }
178
+ }, [value]);
179
+
180
+ const applyInline = (before: string, after: string, placeholder: string) => {
181
+ const el = textareaRef.current;
182
+ if (!el) return;
183
+ const result = wrapSelection(value, el.selectionStart, el.selectionEnd, before, after, placeholder);
184
+ pendingSelection.current = { start: result.selectionStart, end: result.selectionEnd };
185
+ onChange(result.text);
186
+ };
187
+
188
+ const applyLinePrefix = (prefix: (lineIndex: number) => string) => {
189
+ const el = textareaRef.current;
190
+ if (!el) return;
191
+ const result = prefixLines(value, el.selectionStart, el.selectionEnd, prefix);
192
+ pendingSelection.current = { start: result.selectionStart, end: result.selectionEnd };
193
+ onChange(result.text);
194
+ };
195
+
196
+ return (
197
+ <div ref={ref} className={cn("overflow-hidden rounded-md border font-sans", className)} {...props}>
198
+ <div role="toolbar" aria-label="Formatting" className="flex items-center gap-0.5 border-b bg-muted/40 p-1">
199
+ <ToolbarButton label="Bold" onClick={() => applyInline("**", "**", "bold text")}>
200
+ <Bold className="size-4" />
201
+ </ToolbarButton>
202
+ <ToolbarButton label="Italic" onClick={() => applyInline("*", "*", "italic text")}>
203
+ <Italic className="size-4" />
204
+ </ToolbarButton>
205
+ <ToolbarButton label="Heading" onClick={() => applyLinePrefix(() => "## ")}>
206
+ <Heading2 className="size-4" />
207
+ </ToolbarButton>
208
+ <ToolbarButton label="Link" onClick={() => applyInline("[", "](https://)", "link text")}>
209
+ <LinkIcon className="size-4" />
210
+ </ToolbarButton>
211
+ <ToolbarButton label="Bulleted list" onClick={() => applyLinePrefix(() => "- ")}>
212
+ <List className="size-4" />
213
+ </ToolbarButton>
214
+ <ToolbarButton label="Numbered list" onClick={() => applyLinePrefix((i) => `${i + 1}. `)}>
215
+ <ListOrdered className="size-4" />
216
+ </ToolbarButton>
217
+ <ToolbarButton label="Code" onClick={() => applyInline("`", "`", "code")}>
218
+ <Code className="size-4" />
219
+ </ToolbarButton>
220
+ <ToolbarButton label="Quote" onClick={() => applyLinePrefix(() => "> ")}>
221
+ <Quote className="size-4" />
222
+ </ToolbarButton>
223
+ <ToolbarButton
224
+ label={showPreview ? "Hide preview" : "Show preview"}
225
+ onClick={() => setShowPreview((v) => !v)}
226
+ active={showPreview}
227
+ className="ml-auto"
228
+ >
229
+ {showPreview ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
230
+ </ToolbarButton>
231
+ </div>
232
+ <div className={cn("grid", showPreview && "grid-cols-2 divide-x")}>
233
+ <Textarea
234
+ ref={textareaRef}
235
+ value={value}
236
+ onChange={(e) => onChange(e.target.value)}
237
+ placeholder={placeholder}
238
+ rows={rows}
239
+ className="resize-none rounded-none border-0 font-mono text-[13px] focus-visible:border-transparent focus-visible:shadow-none"
240
+ />
241
+ {showPreview && (
242
+ <div
243
+ className="overflow-auto p-3 text-sm [&_a]:text-primary [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground [&_code]:rounded-sm [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.9em] [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:text-lg [&_h2]:font-semibold [&_h3]:font-semibold [&_li]:ml-5 [&_ol]:list-decimal [&_p]:mb-2 [&_pre]:overflow-auto [&_pre]:rounded-sm [&_pre]:bg-muted [&_pre]:p-2 [&_ul]:list-disc"
244
+ // Rendered from renderMarkdown above, which HTML-escapes all
245
+ // source text before inserting any markup — safe from
246
+ // injection, same trust boundary as any other self-generated
247
+ // innerHTML (not raw user HTML).
248
+ dangerouslySetInnerHTML={{ __html: renderMarkdown(value) }}
249
+ />
250
+ )}
251
+ </div>
252
+ </div>
253
+ );
254
+ },
255
+ );
256
+ MarkdownEditor.displayName = "MarkdownEditor";
257
+
258
+ function ToolbarButton({
259
+ label,
260
+ onClick,
261
+ active,
262
+ className,
263
+ children,
264
+ }: {
265
+ label: string;
266
+ onClick: () => void;
267
+ active?: boolean;
268
+ className?: string;
269
+ children: React.ReactNode;
270
+ }) {
271
+ return (
272
+ <button
273
+ type="button"
274
+ onClick={onClick}
275
+ aria-label={label}
276
+ aria-pressed={active}
277
+ className={cn(
278
+ "inline-flex size-7 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground",
279
+ active && "bg-accent text-foreground",
280
+ className,
281
+ )}
282
+ >
283
+ {children}
284
+ </button>
285
+ );
286
+ }
287
+
288
+ export { MarkdownEditor };
@@ -0,0 +1,57 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cn } from "../lib/utils";
5
+
6
+ /**
7
+ * Marquee — an auto-scrolling horizontal ticker (logo strip, testimonials),
8
+ * pausing on hover and respecting `prefers-reduced-motion`. Lifted out of
9
+ * the marketing site's `reveal.tsx` (`.kx-marquee`/`.kx-marquee-track` in
10
+ * its `globals.css`) into a portable component: the site version leaned on
11
+ * a hand-written CSS file a CLI-installed app wouldn't have, so this port
12
+ * moves the keyframe onto the shared Tailwind preset (`animate-marquee`,
13
+ * `motion-reduce:animate-none`) instead. Also fixes an accessibility gap
14
+ * in the site version, which marked *both* content copies `aria-hidden`
15
+ * (hiding the marquee from screen readers entirely) — only the duplicate
16
+ * copy needed for the seamless loop is hidden here; the first copy stays
17
+ * in the accessible DOM flow. Gap-fill addition (not in the original
18
+ * Figma source).
19
+ */
20
+ export interface MarqueeProps extends React.HTMLAttributes<HTMLDivElement> {
21
+ /** seconds for one full loop */
22
+ durationSeconds?: number;
23
+ pauseOnHover?: boolean;
24
+ }
25
+
26
+ const Marquee = React.forwardRef<HTMLDivElement, MarqueeProps>(
27
+ ({ className, children, durationSeconds = 32, pauseOnHover = true, style, ...props }, ref) => (
28
+ <div
29
+ ref={ref}
30
+ className={cn(
31
+ "group relative flex overflow-hidden",
32
+ "[mask-image:linear-gradient(90deg,transparent,black_8%,black_92%,transparent)]",
33
+ className,
34
+ )}
35
+ style={{ ["--marquee-duration" as string]: `${durationSeconds}s`, ...style }}
36
+ {...props}
37
+ >
38
+ {/* the track is 2x content width (two copies side by side); animating it
39
+ exactly -50% of its own width moves it one content-width, which is
40
+ what makes the loop seamless when it snaps back to 0. */}
41
+ <div
42
+ className={cn(
43
+ "flex w-max animate-marquee motion-reduce:animate-none",
44
+ pauseOnHover && "group-hover:[animation-play-state:paused]",
45
+ )}
46
+ >
47
+ <div className="flex shrink-0 items-center">{children}</div>
48
+ <div aria-hidden="true" className="flex shrink-0 items-center">
49
+ {children}
50
+ </div>
51
+ </div>
52
+ </div>
53
+ ),
54
+ );
55
+ Marquee.displayName = "Marquee";
56
+
57
+ export { Marquee };
@@ -0,0 +1,83 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Check, CheckCheck } from "lucide-react";
5
+ import { cn } from "../lib/utils";
6
+
7
+ /**
8
+ * MessageBubble — a sent/received chat bubble with grouping, timestamp,
9
+ * and a status tick. `grouped` is a lightweight consecutive-run cue
10
+ * (reduces the outer top corner's radius, the side that would otherwise
11
+ * repeat the previous bubble's rounding) rather than full first/middle/
12
+ * last position tracking — the caller already knows which messages are
13
+ * consecutive, so it stays a single boolean rather than the component
14
+ * inferring group position itself. Gap-fill addition (not in the
15
+ * original Figma source), the last pick from the `COMPONENT-ADDITIONS.md`
16
+ * Tier 2 backlog.
17
+ */
18
+ export interface MessageBubbleProps extends React.HTMLAttributes<HTMLDivElement> {
19
+ variant?: "sent" | "received";
20
+ timestamp?: React.ReactNode;
21
+ status?: "sent" | "delivered" | "read";
22
+ /** part of a consecutive run of bubbles from the same sender */
23
+ grouped?: boolean;
24
+ avatar?: React.ReactNode;
25
+ }
26
+
27
+ const MessageBubble = React.forwardRef<HTMLDivElement, MessageBubbleProps>(
28
+ ({ className, variant = "received", timestamp, status, grouped = false, avatar, children, ...props }, ref) => {
29
+ const sent = variant === "sent";
30
+ return (
31
+ <div ref={ref} className={cn("flex items-end gap-2 font-sans", sent && "flex-row-reverse", className)} {...props}>
32
+ {avatar && !sent && <div className="shrink-0 self-end">{avatar}</div>}
33
+ <div className={cn("flex max-w-[75%] flex-col gap-1", sent ? "items-end" : "items-start")}>
34
+ <div
35
+ className={cn(
36
+ "min-w-0 rounded-2xl px-3.5 py-2 text-body-sm",
37
+ sent ? "bg-primary text-primary-foreground" : "bg-muted text-foreground",
38
+ sent && grouped && "rounded-tr-md",
39
+ !sent && grouped && "rounded-tl-md",
40
+ )}
41
+ >
42
+ {children}
43
+ </div>
44
+ {(timestamp || (sent && status)) && (
45
+ <div className="flex items-center gap-1 px-1 text-label-sm text-muted-foreground">
46
+ {timestamp}
47
+ {sent && status && (
48
+ <span className={cn(status === "read" && "text-primary")}>
49
+ {status === "sent" ? <Check className="size-3.5" /> : <CheckCheck className="size-3.5" />}
50
+ </span>
51
+ )}
52
+ </div>
53
+ )}
54
+ </div>
55
+ </div>
56
+ );
57
+ },
58
+ );
59
+ MessageBubble.displayName = "MessageBubble";
60
+
61
+ const TypingIndicator = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
62
+ ({ className, ...props }, ref) => (
63
+ <div
64
+ ref={ref}
65
+ role="status"
66
+ aria-label="Typing"
67
+ className={cn("flex w-fit items-center gap-1 rounded-2xl bg-muted px-3.5 py-2.5", className)}
68
+ {...props}
69
+ >
70
+ {[0, 1, 2].map((i) => (
71
+ <span
72
+ key={i}
73
+ aria-hidden
74
+ className="size-1.5 animate-typing-dot rounded-full bg-muted-foreground"
75
+ style={{ animationDelay: `${i * 150}ms` }}
76
+ />
77
+ ))}
78
+ </div>
79
+ ),
80
+ );
81
+ TypingIndicator.displayName = "TypingIndicator";
82
+
83
+ export { MessageBubble, TypingIndicator };