@checkstack/ui 0.0.2

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 (68) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/bunfig.toml +2 -0
  3. package/package.json +40 -0
  4. package/src/components/Accordion.tsx +55 -0
  5. package/src/components/Alert.tsx +90 -0
  6. package/src/components/AmbientBackground.tsx +105 -0
  7. package/src/components/AnimatedCounter.tsx +54 -0
  8. package/src/components/BackLink.tsx +56 -0
  9. package/src/components/Badge.tsx +38 -0
  10. package/src/components/Button.tsx +55 -0
  11. package/src/components/Card.tsx +56 -0
  12. package/src/components/Checkbox.tsx +46 -0
  13. package/src/components/ColorPicker.tsx +69 -0
  14. package/src/components/CommandPalette.tsx +74 -0
  15. package/src/components/ConfirmationModal.tsx +134 -0
  16. package/src/components/DateRangeFilter.tsx +128 -0
  17. package/src/components/DateTimePicker.tsx +65 -0
  18. package/src/components/Dialog.tsx +134 -0
  19. package/src/components/DropdownMenu.tsx +96 -0
  20. package/src/components/DynamicForm/DynamicForm.tsx +126 -0
  21. package/src/components/DynamicForm/DynamicOptionsField.tsx +220 -0
  22. package/src/components/DynamicForm/FormField.tsx +690 -0
  23. package/src/components/DynamicForm/JsonField.tsx +98 -0
  24. package/src/components/DynamicForm/index.ts +11 -0
  25. package/src/components/DynamicForm/types.ts +95 -0
  26. package/src/components/DynamicForm/utils.ts +39 -0
  27. package/src/components/DynamicIcon.tsx +45 -0
  28. package/src/components/EditableText.tsx +141 -0
  29. package/src/components/EmptyState.tsx +32 -0
  30. package/src/components/HealthBadge.tsx +57 -0
  31. package/src/components/InfoBanner.tsx +97 -0
  32. package/src/components/Input.tsx +20 -0
  33. package/src/components/Label.tsx +17 -0
  34. package/src/components/LoadingSpinner.tsx +29 -0
  35. package/src/components/Markdown.tsx +206 -0
  36. package/src/components/NavItem.tsx +112 -0
  37. package/src/components/Page.tsx +58 -0
  38. package/src/components/PageLayout.tsx +83 -0
  39. package/src/components/PaginatedList.tsx +135 -0
  40. package/src/components/Pagination.tsx +195 -0
  41. package/src/components/PermissionDenied.tsx +31 -0
  42. package/src/components/PermissionGate.tsx +97 -0
  43. package/src/components/PluginConfigForm.tsx +91 -0
  44. package/src/components/SectionHeader.tsx +30 -0
  45. package/src/components/Select.tsx +157 -0
  46. package/src/components/StatusCard.tsx +78 -0
  47. package/src/components/StatusUpdateTimeline.tsx +222 -0
  48. package/src/components/StrategyConfigCard.tsx +333 -0
  49. package/src/components/SubscribeButton.tsx +96 -0
  50. package/src/components/Table.tsx +119 -0
  51. package/src/components/Tabs.tsx +141 -0
  52. package/src/components/TemplateEditor.test.ts +156 -0
  53. package/src/components/TemplateEditor.tsx +435 -0
  54. package/src/components/TerminalFeed.tsx +152 -0
  55. package/src/components/Textarea.tsx +22 -0
  56. package/src/components/ThemeProvider.tsx +76 -0
  57. package/src/components/Toast.tsx +118 -0
  58. package/src/components/ToastProvider.tsx +126 -0
  59. package/src/components/Toggle.tsx +47 -0
  60. package/src/components/Tooltip.tsx +20 -0
  61. package/src/components/UserMenu.tsx +79 -0
  62. package/src/hooks/usePagination.e2e.ts +275 -0
  63. package/src/hooks/usePagination.ts +231 -0
  64. package/src/index.ts +53 -0
  65. package/src/themes.css +204 -0
  66. package/src/utils/strip-markdown.ts +44 -0
  67. package/src/utils.ts +8 -0
  68. package/tsconfig.json +6 -0
@@ -0,0 +1,435 @@
1
+ import * as React from "react";
2
+ import { cn } from "../utils";
3
+
4
+ /**
5
+ * A single payload property available for templating
6
+ */
7
+ export interface TemplateProperty {
8
+ /** Full path to the property, e.g., "payload.incident.title" */
9
+ path: string;
10
+ /** Type of the property, e.g., "string", "number", "boolean" */
11
+ type: string;
12
+ /** Optional description of the property */
13
+ description?: string;
14
+ }
15
+
16
+ export interface TemplateEditorProps {
17
+ /** Current template value */
18
+ value: string;
19
+ /** Change handler */
20
+ onChange: (value: string) => void;
21
+ /** Available properties for hints */
22
+ availableProperties?: TemplateProperty[];
23
+ /** Placeholder text */
24
+ placeholder?: string;
25
+ /** Number of rows */
26
+ rows?: number;
27
+ /** Additional class name */
28
+ className?: string;
29
+ /** Disabled state */
30
+ disabled?: boolean;
31
+ /** Template syntax hint, e.g., "{{" */
32
+ templateSyntax?: "mustache" | "dollar";
33
+ }
34
+
35
+ /**
36
+ * Get display type color based on property type
37
+ */
38
+ const getTypeColor = (type: string) => {
39
+ switch (type.toLowerCase()) {
40
+ case "string": {
41
+ return "text-green-600 dark:text-green-400";
42
+ }
43
+ case "number":
44
+ case "integer": {
45
+ return "text-blue-600 dark:text-blue-400";
46
+ }
47
+ case "boolean": {
48
+ return "text-purple-600 dark:text-purple-400";
49
+ }
50
+ case "array": {
51
+ return "text-orange-600 dark:text-orange-400";
52
+ }
53
+ case "object": {
54
+ return "text-yellow-600 dark:text-yellow-400";
55
+ }
56
+ default: {
57
+ return "text-muted-foreground";
58
+ }
59
+ }
60
+ };
61
+
62
+ /**
63
+ * Detects if the cursor is inside an unclosed template context (after {{ without matching }})
64
+ * Returns the query text being typed and the position where {{ starts
65
+ */
66
+ export const detectTemplateContext = (
67
+ value: string,
68
+ cursorPos: number,
69
+ syntax: "mustache" | "dollar" = "mustache"
70
+ ): { isInTemplate: boolean; query: string; startPos: number } => {
71
+ const openToken = syntax === "mustache" ? "{{" : "${";
72
+ const closeToken = syntax === "mustache" ? "}}" : "}";
73
+
74
+ const textBeforeCursor = value.slice(0, cursorPos);
75
+ const lastOpenBrace = textBeforeCursor.lastIndexOf(openToken);
76
+ const lastCloseBrace = textBeforeCursor.lastIndexOf(closeToken);
77
+
78
+ // If we're after {{ and no }} follows before cursor, we're in a template context
79
+ if (lastOpenBrace !== -1 && lastOpenBrace > lastCloseBrace) {
80
+ const query = textBeforeCursor.slice(lastOpenBrace + openToken.length);
81
+ // Only show popup if query doesn't contain newlines (single line context)
82
+ if (!query.includes("\n")) {
83
+ return { isInTemplate: true, query, startPos: lastOpenBrace };
84
+ }
85
+ }
86
+
87
+ return { isInTemplate: false, query: "", startPos: -1 };
88
+ };
89
+
90
+ /**
91
+ * Filter properties based on search query (fuzzy match on path)
92
+ */
93
+ const filterProperties = (
94
+ properties: TemplateProperty[],
95
+ query: string
96
+ ): TemplateProperty[] => {
97
+ if (!query.trim()) return properties;
98
+ const lowerQuery = query.toLowerCase();
99
+ return properties.filter((prop) =>
100
+ prop.path.toLowerCase().includes(lowerQuery)
101
+ );
102
+ };
103
+
104
+ /**
105
+ * TemplateEditor - A textarea with autocomplete for template properties.
106
+ *
107
+ * Shows an autocomplete popup when the user types "{{" with available
108
+ * template properties. Supports keyboard navigation and filtering.
109
+ */
110
+ export const TemplateEditor = React.forwardRef<
111
+ HTMLTextAreaElement,
112
+ TemplateEditorProps
113
+ >(
114
+ (
115
+ {
116
+ value,
117
+ onChange,
118
+ availableProperties = [],
119
+ placeholder = "Enter template...",
120
+ rows = 4,
121
+ className,
122
+ disabled = false,
123
+ templateSyntax = "mustache",
124
+ },
125
+ ref
126
+ ) => {
127
+ const textareaRef = React.useRef<HTMLTextAreaElement | null>(null);
128
+ const containerRef = React.useRef<HTMLDivElement | null>(null);
129
+ const popupRef = React.useRef<HTMLDivElement | null>(null);
130
+
131
+ // Autocomplete state
132
+ const [showPopup, setShowPopup] = React.useState(false);
133
+ const [popupPosition, setPopupPosition] = React.useState({
134
+ top: 0,
135
+ left: 0,
136
+ });
137
+ const [selectedIndex, setSelectedIndex] = React.useState(0);
138
+ const [templateContext, setTemplateContext] = React.useState<{
139
+ query: string;
140
+ startPos: number;
141
+ }>({ query: "", startPos: -1 });
142
+
143
+ // Filtered properties based on query
144
+ const filteredProperties = React.useMemo(
145
+ () => filterProperties(availableProperties, templateContext.query),
146
+ [availableProperties, templateContext.query]
147
+ );
148
+
149
+ // Merge refs
150
+ React.useImperativeHandle(ref, () => textareaRef.current!);
151
+
152
+ /**
153
+ * Calculate popup position based on cursor location in textarea
154
+ */
155
+ const calculatePopupPosition = React.useCallback(() => {
156
+ const textarea = textareaRef.current;
157
+ const container = containerRef.current;
158
+ if (!textarea || !container) return;
159
+
160
+ // Get textarea position relative to container
161
+ const textareaRect = textarea.getBoundingClientRect();
162
+ const containerRect = container.getBoundingClientRect();
163
+
164
+ // Simple positioning: below the textarea with fixed offset
165
+ // For more accurate cursor-based positioning, we'd need to measure text
166
+ const lineHeight =
167
+ Number.parseInt(getComputedStyle(textarea).lineHeight) || 20;
168
+ const paddingTop =
169
+ Number.parseInt(getComputedStyle(textarea).paddingTop) || 8;
170
+
171
+ // Count newlines before cursor to estimate vertical position
172
+ const cursorPos = textarea.selectionStart ?? 0;
173
+ const textBeforeCursor = value.slice(0, cursorPos);
174
+ const lines = textBeforeCursor.split("\n");
175
+ const currentLineIndex = lines.length - 1;
176
+
177
+ // Position popup below current line
178
+ const top =
179
+ textareaRect.top -
180
+ containerRect.top +
181
+ paddingTop +
182
+ (currentLineIndex + 1) * lineHeight;
183
+
184
+ // Estimate horizontal position based on current line length
185
+ const currentLine = lines[currentLineIndex] ?? "";
186
+ const charWidth = 8; // Approximate monospace char width
187
+ const paddingLeft =
188
+ Number.parseInt(getComputedStyle(textarea).paddingLeft) || 12;
189
+ const left = Math.min(
190
+ paddingLeft + currentLine.length * charWidth,
191
+ textareaRect.width - 200 // Keep popup within textarea bounds
192
+ );
193
+
194
+ setPopupPosition({
195
+ top: Math.max(top, lineHeight),
196
+ left: Math.max(left, 0),
197
+ });
198
+ }, [value]);
199
+
200
+ /**
201
+ * Handle text input and detect template context
202
+ */
203
+ const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
204
+ const newValue = e.target.value;
205
+ onChange(newValue);
206
+
207
+ // Check template context after state update
208
+ setTimeout(() => {
209
+ const textarea = textareaRef.current;
210
+ if (!textarea) return;
211
+
212
+ const cursorPos = textarea.selectionStart ?? 0;
213
+ const context = detectTemplateContext(
214
+ newValue,
215
+ cursorPos,
216
+ templateSyntax
217
+ );
218
+
219
+ if (context.isInTemplate && availableProperties.length > 0) {
220
+ setTemplateContext({
221
+ query: context.query,
222
+ startPos: context.startPos,
223
+ });
224
+ setShowPopup(true);
225
+ setSelectedIndex(0);
226
+ calculatePopupPosition();
227
+ } else {
228
+ setShowPopup(false);
229
+ }
230
+ }, 0);
231
+ };
232
+
233
+ /**
234
+ * Handle cursor position changes (click, arrow keys in text)
235
+ */
236
+ const handleSelect = () => {
237
+ const textarea = textareaRef.current;
238
+ if (!textarea || disabled) return;
239
+
240
+ const cursorPos = textarea.selectionStart ?? 0;
241
+ const context = detectTemplateContext(value, cursorPos, templateSyntax);
242
+
243
+ if (context.isInTemplate && availableProperties.length > 0) {
244
+ setTemplateContext({
245
+ query: context.query,
246
+ startPos: context.startPos,
247
+ });
248
+ setShowPopup(true);
249
+ calculatePopupPosition();
250
+ } else {
251
+ setShowPopup(false);
252
+ }
253
+ };
254
+
255
+ /**
256
+ * Insert selected property at the template context position
257
+ */
258
+ const insertProperty = React.useCallback(
259
+ (prop: TemplateProperty) => {
260
+ const textarea = textareaRef.current;
261
+ if (!textarea || disabled || templateContext.startPos === -1) return;
262
+
263
+ const openToken = templateSyntax === "mustache" ? "{{" : "${";
264
+ const closeToken = templateSyntax === "mustache" ? "}}" : "}";
265
+ const template = `${openToken}${prop.path}${closeToken}`;
266
+
267
+ // Replace from {{ to cursor with the complete template
268
+ const cursorPos = textarea.selectionStart ?? 0;
269
+ const newValue =
270
+ value.slice(0, templateContext.startPos) +
271
+ template +
272
+ value.slice(cursorPos);
273
+
274
+ onChange(newValue);
275
+ setShowPopup(false);
276
+
277
+ // Restore focus and move cursor after inserted text
278
+ const newPosition = templateContext.startPos + template.length;
279
+ setTimeout(() => {
280
+ textarea.focus();
281
+ textarea.setSelectionRange(newPosition, newPosition);
282
+ }, 0);
283
+ },
284
+ [value, onChange, templateContext.startPos, templateSyntax, disabled]
285
+ );
286
+
287
+ /**
288
+ * Handle keyboard navigation in popup
289
+ */
290
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
291
+ if (!showPopup || filteredProperties.length === 0) return;
292
+
293
+ switch (e.key) {
294
+ case "ArrowDown": {
295
+ e.preventDefault();
296
+ setSelectedIndex((prev) =>
297
+ prev < filteredProperties.length - 1 ? prev + 1 : 0
298
+ );
299
+ break;
300
+ }
301
+ case "ArrowUp": {
302
+ e.preventDefault();
303
+ setSelectedIndex((prev) =>
304
+ prev > 0 ? prev - 1 : filteredProperties.length - 1
305
+ );
306
+ break;
307
+ }
308
+ case "Enter":
309
+ case "Tab": {
310
+ e.preventDefault();
311
+ const selected = filteredProperties[selectedIndex];
312
+ if (selected) {
313
+ insertProperty(selected);
314
+ }
315
+ break;
316
+ }
317
+ case "Escape": {
318
+ e.preventDefault();
319
+ setShowPopup(false);
320
+ break;
321
+ }
322
+ }
323
+ };
324
+
325
+ /**
326
+ * Close popup when clicking outside
327
+ */
328
+ React.useEffect(() => {
329
+ const handleClickOutside = (event: MouseEvent) => {
330
+ if (
331
+ popupRef.current &&
332
+ !popupRef.current.contains(event.target as Node) &&
333
+ textareaRef.current &&
334
+ !textareaRef.current.contains(event.target as Node)
335
+ ) {
336
+ setShowPopup(false);
337
+ }
338
+ };
339
+
340
+ if (showPopup) {
341
+ document.addEventListener("mousedown", handleClickOutside);
342
+ return () =>
343
+ document.removeEventListener("mousedown", handleClickOutside);
344
+ }
345
+ }, [showPopup]);
346
+
347
+ // Reset selected index when filtered properties change
348
+ React.useEffect(() => {
349
+ setSelectedIndex(0);
350
+ }, [filteredProperties.length]);
351
+
352
+ // Scroll selected item into view
353
+ React.useEffect(() => {
354
+ if (!showPopup || !popupRef.current) return;
355
+ const selectedElement = popupRef.current.querySelector(
356
+ `[data-index="${selectedIndex}"]`
357
+ );
358
+ selectedElement?.scrollIntoView({ block: "nearest" });
359
+ }, [selectedIndex, showPopup]);
360
+
361
+ return (
362
+ <div ref={containerRef} className={cn("relative", className)}>
363
+ {/* Textarea */}
364
+ <textarea
365
+ ref={textareaRef}
366
+ value={value}
367
+ onChange={handleChange}
368
+ onSelect={handleSelect}
369
+ onKeyDown={handleKeyDown}
370
+ placeholder={placeholder}
371
+ rows={rows}
372
+ disabled={disabled}
373
+ className={cn(
374
+ "flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono",
375
+ "placeholder:text-muted-foreground",
376
+ "focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent",
377
+ "disabled:cursor-not-allowed disabled:opacity-50",
378
+ "resize-y min-h-[80px]"
379
+ )}
380
+ />
381
+
382
+ {/* Autocomplete popup */}
383
+ {showPopup && filteredProperties.length > 0 && (
384
+ <div
385
+ ref={popupRef}
386
+ className={cn(
387
+ "absolute z-50 w-72 max-h-48 overflow-y-auto",
388
+ "rounded-md border border-border bg-popover shadow-lg"
389
+ )}
390
+ style={{ top: popupPosition.top, left: popupPosition.left }}
391
+ >
392
+ <div className="p-1">
393
+ {filteredProperties.map((prop, index) => (
394
+ <button
395
+ key={prop.path}
396
+ type="button"
397
+ data-index={index}
398
+ onClick={() => insertProperty(prop)}
399
+ className={cn(
400
+ "w-full flex items-center justify-between gap-2 px-2 py-1.5 text-xs rounded-sm text-left",
401
+ "hover:bg-accent hover:text-accent-foreground transition-colors",
402
+ index === selectedIndex &&
403
+ "bg-accent text-accent-foreground"
404
+ )}
405
+ >
406
+ <code className="font-mono truncate">{prop.path}</code>
407
+ <span
408
+ className={cn(
409
+ "font-normal shrink-0",
410
+ getTypeColor(prop.type)
411
+ )}
412
+ >
413
+ {prop.type}
414
+ </span>
415
+ </button>
416
+ ))}
417
+ </div>
418
+ </div>
419
+ )}
420
+
421
+ {/* Hint text when no popup shown */}
422
+ {availableProperties.length > 0 && !showPopup && (
423
+ <p className="text-xs text-muted-foreground mt-1.5">
424
+ Type{" "}
425
+ <code className="px-1 py-0.5 bg-muted rounded text-[10px]">
426
+ {templateSyntax === "mustache" ? "{{" : "${"}
427
+ </code>{" "}
428
+ for template suggestions
429
+ </p>
430
+ )}
431
+ </div>
432
+ );
433
+ }
434
+ );
435
+ TemplateEditor.displayName = "TemplateEditor";
@@ -0,0 +1,152 @@
1
+ import React, { useRef, useState, useEffect } from "react";
2
+ import { cn } from "../utils";
3
+
4
+ export interface TerminalEntry {
5
+ id: string;
6
+ timestamp: Date;
7
+ content: string;
8
+ variant?: "success" | "warning" | "error" | "info" | "default";
9
+ suffix?: string;
10
+ }
11
+
12
+ interface TerminalFeedProps {
13
+ entries: TerminalEntry[];
14
+ maxEntries?: number;
15
+ /** Maximum height for the terminal content area (e.g., "300px", "50vh") */
16
+ maxHeight?: string;
17
+ title?: string;
18
+ className?: string;
19
+ }
20
+
21
+ const variantConfig = {
22
+ success: {
23
+ symbol: "✓",
24
+ textClass: "text-emerald-400",
25
+ },
26
+ warning: {
27
+ symbol: "⚠",
28
+ textClass: "text-amber-400",
29
+ },
30
+ error: {
31
+ symbol: "✗",
32
+ textClass: "text-red-400",
33
+ },
34
+ info: {
35
+ symbol: "●",
36
+ textClass: "text-blue-400",
37
+ },
38
+ default: {
39
+ symbol: "→",
40
+ textClass: "text-gray-400",
41
+ },
42
+ };
43
+
44
+ const formatTime = (date: Date): string => {
45
+ return date.toLocaleTimeString("en-US", {
46
+ hour12: false,
47
+ hour: "2-digit",
48
+ minute: "2-digit",
49
+ second: "2-digit",
50
+ });
51
+ };
52
+
53
+ /**
54
+ * TerminalFeed - Generic CLI-style activity log
55
+ * A generic terminal display component that receives entries from the caller.
56
+ * The component is agnostic to domain-specific logic (health checks, notifications, etc.)
57
+ *
58
+ * Entries are displayed with newest at the bottom (chronological order).
59
+ * Auto-scrolls to bottom on new entries unless user is hovering the terminal.
60
+ */
61
+ export const TerminalFeed: React.FC<TerminalFeedProps> = ({
62
+ entries,
63
+ maxEntries = 8,
64
+ maxHeight,
65
+ title = "terminal",
66
+ className,
67
+ }) => {
68
+ const contentRef = useRef<HTMLDivElement>(null);
69
+ const [isHovering, setIsHovering] = useState(false);
70
+
71
+ // Get entries to display, then reverse so newest is at bottom
72
+ const displayEntries = entries.slice(0, maxEntries).toReversed();
73
+
74
+ // Auto-scroll to bottom when entries change (unless user is hovering)
75
+ useEffect(() => {
76
+ if (!isHovering && contentRef.current) {
77
+ contentRef.current.scrollTop = contentRef.current.scrollHeight;
78
+ }
79
+ }, [entries, isHovering]);
80
+
81
+ return (
82
+ <div
83
+ className={cn(
84
+ "rounded-lg overflow-hidden border border-border/50",
85
+ "bg-[#0d1117] shadow-xl",
86
+ className
87
+ )}
88
+ >
89
+ {/* Terminal header */}
90
+ <div className="flex items-center gap-2 px-4 py-2 bg-[#161b22] border-b border-border/30">
91
+ {/* Window controls */}
92
+ <div className="flex items-center gap-1.5">
93
+ <div className="w-3 h-3 rounded-full bg-red-500/80" />
94
+ <div className="w-3 h-3 rounded-full bg-yellow-500/80" />
95
+ <div className="w-3 h-3 rounded-full bg-green-500/80" />
96
+ </div>
97
+ {/* Title */}
98
+ <span className="ml-2 text-xs text-gray-400 font-mono">$ {title}</span>
99
+ </div>
100
+
101
+ {/* Terminal content */}
102
+ <div
103
+ ref={contentRef}
104
+ className="p-4 font-mono text-sm space-y-1 min-h-[200px] overflow-y-auto"
105
+ style={maxHeight ? { maxHeight } : undefined}
106
+ onMouseEnter={() => setIsHovering(true)}
107
+ onMouseLeave={() => setIsHovering(false)}
108
+ >
109
+ {displayEntries.length === 0 ? (
110
+ <div className="text-gray-500 animate-pulse">
111
+ Waiting for events...
112
+ </div>
113
+ ) : (
114
+ displayEntries.map((entry) => {
115
+ const config = variantConfig[entry.variant ?? "default"];
116
+ return (
117
+ <div
118
+ key={entry.id}
119
+ className="flex flex-wrap items-start gap-x-2 group"
120
+ >
121
+ {/* Timestamp */}
122
+ <span className="text-gray-500 flex-shrink-0">
123
+ [{formatTime(entry.timestamp)}]
124
+ </span>
125
+ {/* Status symbol */}
126
+ <span className={cn("flex-shrink-0", config.textClass)}>
127
+ {config.symbol}
128
+ </span>
129
+ {/* Content - allows wrapping */}
130
+ <span className="text-gray-300 break-words">
131
+ {entry.content}
132
+ </span>
133
+ {/* Optional suffix (e.g., response time) */}
134
+ {entry.suffix && (
135
+ <span className="text-gray-500 text-xs flex-shrink-0 ml-auto">
136
+ {entry.suffix}
137
+ </span>
138
+ )}
139
+ </div>
140
+ );
141
+ })
142
+ )}
143
+
144
+ {/* Blinking cursor */}
145
+ <div className="flex items-center gap-1 mt-2">
146
+ <span className="text-emerald-400">$</span>
147
+ <span className="w-2 h-4 bg-emerald-400 animate-pulse" />
148
+ </div>
149
+ </div>
150
+ </div>
151
+ );
152
+ };
@@ -0,0 +1,22 @@
1
+ import * as React from "react";
2
+ import { cn } from "../utils";
3
+
4
+ export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
5
+
6
+ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
7
+ ({ className, ...props }, ref) => {
8
+ return (
9
+ <textarea
10
+ className={cn(
11
+ "flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent disabled:cursor-not-allowed disabled:opacity-50",
12
+ className
13
+ )}
14
+ ref={ref}
15
+ {...props}
16
+ />
17
+ );
18
+ }
19
+ );
20
+ Textarea.displayName = "Textarea";
21
+
22
+ export { Textarea };
@@ -0,0 +1,76 @@
1
+ import React, { createContext, useContext, useEffect, useState } from "react";
2
+
3
+ type Theme = "light" | "dark" | "system";
4
+
5
+ interface ThemeProviderProps {
6
+ children: React.ReactNode;
7
+ defaultTheme?: Theme;
8
+ storageKey?: string;
9
+ }
10
+
11
+ interface ThemeProviderState {
12
+ theme: Theme;
13
+ setTheme: (theme: Theme) => void;
14
+ }
15
+
16
+ const initialState: ThemeProviderState = {
17
+ theme: "system",
18
+ setTheme: () => {
19
+ // Will be implemented by provider
20
+ },
21
+ };
22
+
23
+ const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
24
+
25
+ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
26
+ children,
27
+ defaultTheme = "system",
28
+ storageKey = "checkstack-ui-theme",
29
+ ...props
30
+ }) => {
31
+ const [theme, setTheme] = useState<Theme>(
32
+ () => (localStorage.getItem(storageKey) as Theme) || defaultTheme
33
+ );
34
+
35
+ useEffect(() => {
36
+ const root = globalThis.document.documentElement;
37
+
38
+ root.classList.remove("light", "dark");
39
+
40
+ if (theme === "system") {
41
+ const systemTheme = globalThis.matchMedia("(prefers-color-scheme: dark)")
42
+ .matches
43
+ ? "dark"
44
+ : "light";
45
+
46
+ root.classList.add(systemTheme);
47
+ return;
48
+ }
49
+
50
+ root.classList.add(theme);
51
+ }, [theme]);
52
+
53
+ const value = {
54
+ theme,
55
+ setTheme: (newTheme: Theme) => {
56
+ localStorage.setItem(storageKey, newTheme);
57
+ setTheme(newTheme);
58
+ },
59
+ };
60
+
61
+ return (
62
+ <ThemeProviderContext.Provider {...props} value={value}>
63
+ {children}
64
+ </ThemeProviderContext.Provider>
65
+ );
66
+ };
67
+
68
+ export const useTheme = () => {
69
+ const context = useContext(ThemeProviderContext);
70
+
71
+ if (context === undefined) {
72
+ throw new Error("useTheme must be used within a ThemeProvider");
73
+ }
74
+
75
+ return context;
76
+ };