@checkstack/ui 1.12.0 → 1.13.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.
- package/CHANGELOG.md +145 -0
- package/package.json +20 -15
- package/src/components/Accordion.tsx +17 -9
- package/src/components/ActionCard.tsx +4 -4
- package/src/components/BrandIcon.tsx +57 -0
- package/src/components/CodeEditor/CodeEditor.tsx +71 -7
- package/src/components/CodeEditor/TypefoxEditor.tsx +266 -53
- package/src/components/CodeEditor/editorTheme.test.ts +41 -0
- package/src/components/CodeEditor/editorTheme.ts +26 -0
- package/src/components/CodeEditor/index.ts +3 -1
- package/src/components/CodeEditor/monacoGuard.ts +76 -0
- package/src/components/CodeEditor/monacoTsService.ts +5 -37
- package/src/components/CodeEditor/scriptContext.test.ts +15 -7
- package/src/components/CodeEditor/scriptContext.ts +12 -18
- package/src/components/CodeEditor/types.ts +20 -0
- package/src/components/CodeEditor/validateScripts.ts +53 -13
- package/src/components/CodeEditor/vscodeServicesSignal.ts +72 -0
- package/src/components/ConfirmationModal.tsx +7 -1
- package/src/components/DynamicForm/DynamicForm.tsx +101 -53
- package/src/components/DynamicForm/DynamicOptionsField.tsx +19 -14
- package/src/components/DynamicForm/FormField.tsx +84 -24
- package/src/components/DynamicForm/MultiTypeEditorField.tsx +11 -0
- package/src/components/DynamicForm/index.ts +14 -0
- package/src/components/DynamicForm/types.ts +63 -1
- package/src/components/DynamicForm/utils.test.ts +38 -0
- package/src/components/DynamicForm/utils.ts +22 -0
- package/src/components/DynamicForm/validation.logic.test.ts +255 -0
- package/src/components/DynamicForm/validation.logic.ts +210 -0
- package/src/components/DynamicIcon.tsx +39 -17
- package/src/components/Markdown.tsx +68 -2
- package/src/components/Spinner.tsx +56 -0
- package/src/components/StatusBadge.tsx +78 -0
- package/src/components/StrategyConfigCard.tsx +3 -3
- package/src/components/Tabs.tsx +7 -1
- package/src/components/UserMenu.logic.test.ts +37 -0
- package/src/components/UserMenu.logic.ts +30 -0
- package/src/components/UserMenu.tsx +40 -12
- package/src/components/iconRegistry.tsx +27 -0
- package/src/index.ts +3 -0
- package/stories/Introduction.mdx +1 -1
- package/stories/Markdown.stories.tsx +56 -0
- package/stories/Spinner.stories.tsx +90 -0
- package/tsconfig.json +3 -0
|
@@ -1,7 +1,36 @@
|
|
|
1
1
|
import ReactMarkdown from "react-markdown";
|
|
2
2
|
import type { Components } from "react-markdown";
|
|
3
|
+
import remarkGfm from "remark-gfm";
|
|
4
|
+
import rehypeRaw from "rehype-raw";
|
|
5
|
+
import rehypeSanitize from "rehype-sanitize";
|
|
3
6
|
import { cn } from "../utils";
|
|
4
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Allow a SAFE subset of raw HTML in rendered markdown so model output that uses
|
|
10
|
+
* native disclosure widgets (`<details>` / `<summary>` for a foldable diff) and
|
|
11
|
+
* other plain formatting renders as intended instead of leaking the literal
|
|
12
|
+
* tags as text. `rehype-raw` parses the embedded HTML; `rehype-sanitize` then
|
|
13
|
+
* strips anything unsafe (scripts, event handlers, `javascript:` URLs, ...) - its
|
|
14
|
+
* default schema already allow-lists `details`/`summary` plus the usual markdown
|
|
15
|
+
* elements, so the XSS surface stays closed even though chat content is
|
|
16
|
+
* model-generated. Order matters: raw MUST run before sanitize.
|
|
17
|
+
*/
|
|
18
|
+
const rehypePlugins = [rehypeRaw, rehypeSanitize];
|
|
19
|
+
|
|
20
|
+
/** Shared `<details>`/`<summary>` renderers: a styled, click-to-expand fold. */
|
|
21
|
+
const disclosureComponents: Pick<Components, "details" | "summary"> = {
|
|
22
|
+
details: ({ children }) => (
|
|
23
|
+
<details className="my-2 rounded-md border border-border bg-muted/40 px-3 py-2 [&[open]>summary]:mb-2">
|
|
24
|
+
{children}
|
|
25
|
+
</details>
|
|
26
|
+
),
|
|
27
|
+
summary: ({ children }) => (
|
|
28
|
+
<summary className="cursor-pointer select-none font-medium text-foreground marker:text-muted-foreground">
|
|
29
|
+
{children}
|
|
30
|
+
</summary>
|
|
31
|
+
),
|
|
32
|
+
};
|
|
33
|
+
|
|
5
34
|
export interface MarkdownProps {
|
|
6
35
|
/** The markdown content to render */
|
|
7
36
|
children: string;
|
|
@@ -69,11 +98,20 @@ export function Markdown({
|
|
|
69
98
|
del: ({ children }) => (
|
|
70
99
|
<del className="line-through text-muted-foreground">{children}</del>
|
|
71
100
|
),
|
|
101
|
+
|
|
102
|
+
// Collapsible disclosure (e.g. a model-emitted "view diff" fold)
|
|
103
|
+
...disclosureComponents,
|
|
72
104
|
};
|
|
73
105
|
|
|
74
106
|
return (
|
|
75
107
|
<span className={cn(sizeClasses[size], className)}>
|
|
76
|
-
<ReactMarkdown
|
|
108
|
+
<ReactMarkdown
|
|
109
|
+
remarkPlugins={[remarkGfm]}
|
|
110
|
+
rehypePlugins={rehypePlugins}
|
|
111
|
+
components={components}
|
|
112
|
+
>
|
|
113
|
+
{children}
|
|
114
|
+
</ReactMarkdown>
|
|
77
115
|
</span>
|
|
78
116
|
);
|
|
79
117
|
}
|
|
@@ -190,6 +228,28 @@ export function MarkdownBlock({
|
|
|
190
228
|
del: ({ children }) => (
|
|
191
229
|
<del className="line-through text-muted-foreground">{children}</del>
|
|
192
230
|
),
|
|
231
|
+
|
|
232
|
+
// GFM tables (enabled via remark-gfm). Styled to the design tokens; the
|
|
233
|
+
// wrapper scrolls horizontally so a wide table never overflows its bubble.
|
|
234
|
+
table: ({ children }) => (
|
|
235
|
+
<div className="mb-4 overflow-x-auto">
|
|
236
|
+
<table className="w-full border-collapse text-sm">{children}</table>
|
|
237
|
+
</div>
|
|
238
|
+
),
|
|
239
|
+
thead: ({ children }) => <thead className="bg-muted">{children}</thead>,
|
|
240
|
+
tbody: ({ children }) => <tbody>{children}</tbody>,
|
|
241
|
+
tr: ({ children }) => <tr className="border-b border-border">{children}</tr>,
|
|
242
|
+
th: ({ children }) => (
|
|
243
|
+
<th className="border border-border px-2 py-1 text-left font-semibold text-foreground">
|
|
244
|
+
{children}
|
|
245
|
+
</th>
|
|
246
|
+
),
|
|
247
|
+
td: ({ children }) => (
|
|
248
|
+
<td className="border border-border px-2 py-1 align-top">{children}</td>
|
|
249
|
+
),
|
|
250
|
+
|
|
251
|
+
// Collapsible disclosure (e.g. a model-emitted "view diff" fold)
|
|
252
|
+
...disclosureComponents,
|
|
193
253
|
};
|
|
194
254
|
|
|
195
255
|
return (
|
|
@@ -200,7 +260,13 @@ export function MarkdownBlock({
|
|
|
200
260
|
className
|
|
201
261
|
)}
|
|
202
262
|
>
|
|
203
|
-
<ReactMarkdown
|
|
263
|
+
<ReactMarkdown
|
|
264
|
+
remarkPlugins={[remarkGfm]}
|
|
265
|
+
rehypePlugins={rehypePlugins}
|
|
266
|
+
components={components}
|
|
267
|
+
>
|
|
268
|
+
{children}
|
|
269
|
+
</ReactMarkdown>
|
|
204
270
|
</div>
|
|
205
271
|
);
|
|
206
272
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Loader2 } from "lucide-react";
|
|
3
|
+
import { cn } from "../utils";
|
|
4
|
+
import { usePerformance } from "./PerformanceProvider";
|
|
5
|
+
|
|
6
|
+
const sizeClasses = {
|
|
7
|
+
sm: "h-4 w-4",
|
|
8
|
+
md: "h-5 w-5",
|
|
9
|
+
lg: "h-6 w-6",
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
export interface SpinnerProps
|
|
13
|
+
extends Omit<React.ComponentProps<typeof Loader2>, "ref"> {
|
|
14
|
+
/**
|
|
15
|
+
* Visual size of the spinner. Defaults to `sm` (`h-4 w-4`), the dominant
|
|
16
|
+
* inline-in-button need.
|
|
17
|
+
*/
|
|
18
|
+
size?: keyof typeof sizeClasses;
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Spinner - a small INLINE loading indicator (e.g. next to button text or in a
|
|
24
|
+
* table cell). For a centered full-block loader, use `LoadingSpinner` instead.
|
|
25
|
+
*
|
|
26
|
+
* The `animate-spin` class is gated INTERNALLY behind
|
|
27
|
+
* `usePerformance().isLowPower`: on low-power devices the icon renders static
|
|
28
|
+
* (no spin), satisfying `.claude/rules/performance.md` so call sites inherit the
|
|
29
|
+
* guard without re-implementing it.
|
|
30
|
+
*
|
|
31
|
+
* Accessibility: decorative by default (`aria-hidden`), for the common case of
|
|
32
|
+
* sitting next to visible text like "Saving...". Pass `aria-label` to announce
|
|
33
|
+
* it on its own; that switches the icon to `role="status"` and drops
|
|
34
|
+
* `aria-hidden`.
|
|
35
|
+
*/
|
|
36
|
+
export const Spinner: React.FC<SpinnerProps> = ({
|
|
37
|
+
size = "sm",
|
|
38
|
+
className,
|
|
39
|
+
"aria-label": ariaLabel,
|
|
40
|
+
...props
|
|
41
|
+
}) => {
|
|
42
|
+
const { isLowPower } = usePerformance();
|
|
43
|
+
|
|
44
|
+
const accessibility =
|
|
45
|
+
ariaLabel === undefined
|
|
46
|
+
? { "aria-hidden": true }
|
|
47
|
+
: { "aria-label": ariaLabel, role: "status" as const };
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<Loader2
|
|
51
|
+
className={cn(!isLowPower && "animate-spin", sizeClasses[size], className)}
|
|
52
|
+
{...accessibility}
|
|
53
|
+
{...props}
|
|
54
|
+
/>
|
|
55
|
+
);
|
|
56
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "../utils";
|
|
3
|
+
|
|
4
|
+
export type StatusTone = "ok" | "warn" | "error" | "info" | "neutral";
|
|
5
|
+
|
|
6
|
+
const toneClass: Record<StatusTone, string> = {
|
|
7
|
+
ok: "bg-success/10 text-success",
|
|
8
|
+
warn: "bg-warning/10 text-warning",
|
|
9
|
+
error: "bg-destructive/10 text-destructive",
|
|
10
|
+
info: "bg-info/10 text-info",
|
|
11
|
+
neutral: "bg-secondary text-secondary-foreground",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Severity sort order via CSS flex `order`, so the most urgent signal always
|
|
16
|
+
* sits left regardless of plugin registration order: error (red) -> warn
|
|
17
|
+
* (yellow) -> info -> neutral -> ok. Only takes effect when badges share a
|
|
18
|
+
* flex container (every `SystemStateBadgesSlot` render site wraps them in one).
|
|
19
|
+
*/
|
|
20
|
+
const orderClass: Record<StatusTone, string> = {
|
|
21
|
+
error: "order-1",
|
|
22
|
+
warn: "order-2",
|
|
23
|
+
info: "order-3",
|
|
24
|
+
neutral: "order-4",
|
|
25
|
+
ok: "order-5",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export interface StatusBadgeProps {
|
|
29
|
+
/** Severity tone driving the (subtle, tinted) color. */
|
|
30
|
+
tone: StatusTone;
|
|
31
|
+
/** Lucide-style icon component. */
|
|
32
|
+
icon: React.ComponentType<{ className?: string }>;
|
|
33
|
+
/** Full description: shown on hover/focus and exposed to assistive tech. */
|
|
34
|
+
label: string;
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A compact, uniform status symbol: a small tinted chip holding an icon, with
|
|
40
|
+
* the full label revealed on hover/focus (and via `aria-label`). All system
|
|
41
|
+
* state badges (health, incident, SLO, maintenance, anomaly, dependency) render
|
|
42
|
+
* through this so a system's status reads as a tidy, consistent row of icons
|
|
43
|
+
* instead of a stack of differently-styled text pills.
|
|
44
|
+
*/
|
|
45
|
+
export const StatusBadge: React.FC<StatusBadgeProps> = ({
|
|
46
|
+
tone,
|
|
47
|
+
icon: Icon,
|
|
48
|
+
label,
|
|
49
|
+
className,
|
|
50
|
+
}) => {
|
|
51
|
+
return (
|
|
52
|
+
<span
|
|
53
|
+
className={cn(
|
|
54
|
+
"group/status-badge relative inline-flex shrink-0",
|
|
55
|
+
orderClass[tone],
|
|
56
|
+
className,
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
<span
|
|
60
|
+
role="img"
|
|
61
|
+
aria-label={label}
|
|
62
|
+
tabIndex={0}
|
|
63
|
+
className={cn(
|
|
64
|
+
"inline-flex size-6 items-center justify-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
65
|
+
toneClass[tone],
|
|
66
|
+
)}
|
|
67
|
+
>
|
|
68
|
+
<Icon className="h-3.5 w-3.5" />
|
|
69
|
+
</span>
|
|
70
|
+
<span
|
|
71
|
+
role="tooltip"
|
|
72
|
+
className="pointer-events-none invisible absolute bottom-full left-1/2 z-[100] mb-1.5 -translate-x-1/2 whitespace-nowrap rounded border border-border bg-popover px-2 py-1 text-xs text-popover-foreground opacity-0 shadow-lg transition-opacity group-hover/status-badge:visible group-hover/status-badge:opacity-100 group-focus-within/status-badge:visible group-focus-within/status-badge:opacity-100"
|
|
73
|
+
>
|
|
74
|
+
{label}
|
|
75
|
+
</span>
|
|
76
|
+
</span>
|
|
77
|
+
);
|
|
78
|
+
};
|
|
@@ -6,7 +6,7 @@ import { Badge, type BadgeProps } from "./Badge";
|
|
|
6
6
|
import { Toggle } from "./Toggle";
|
|
7
7
|
import { DynamicForm } from "./DynamicForm";
|
|
8
8
|
import type { OptionsResolver } from "./DynamicForm/types";
|
|
9
|
-
import { DynamicIcon, type
|
|
9
|
+
import { DynamicIcon, type IconName } from "./DynamicIcon";
|
|
10
10
|
import { MarkdownBlock } from "./Markdown";
|
|
11
11
|
import { cn } from "../utils";
|
|
12
12
|
|
|
@@ -38,8 +38,8 @@ export interface StrategyConfigCardData {
|
|
|
38
38
|
displayName: string;
|
|
39
39
|
/** Optional description shown below the title */
|
|
40
40
|
description?: string;
|
|
41
|
-
/**
|
|
42
|
-
icon?:
|
|
41
|
+
/** Icon name in PascalCase - a lucide icon or a vendored brand icon. */
|
|
42
|
+
icon?: IconName;
|
|
43
43
|
/** Whether the strategy is currently enabled */
|
|
44
44
|
enabled: boolean;
|
|
45
45
|
}
|
package/src/components/Tabs.tsx
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import React, { useRef } from "react";
|
|
2
|
+
import { cn } from "../utils";
|
|
3
|
+
import { usePerformance } from "./PerformanceProvider";
|
|
2
4
|
|
|
3
5
|
export interface TabItem {
|
|
4
6
|
id: string;
|
|
@@ -126,13 +128,17 @@ export const TabPanel: React.FC<TabPanelProps> = ({
|
|
|
126
128
|
className = "",
|
|
127
129
|
}) => {
|
|
128
130
|
const isActive = activeTab === id;
|
|
131
|
+
const { isLowPower } = usePerformance();
|
|
129
132
|
|
|
130
133
|
return isActive ? (
|
|
131
134
|
<div
|
|
132
135
|
id={`tabpanel-${id}`}
|
|
133
136
|
role="tabpanel"
|
|
134
137
|
aria-labelledby={`tab-${id}`}
|
|
135
|
-
className={
|
|
138
|
+
className={cn(
|
|
139
|
+
!isLowPower && "animate-in fade-in-0 duration-200",
|
|
140
|
+
className,
|
|
141
|
+
)}
|
|
136
142
|
>
|
|
137
143
|
{children}
|
|
138
144
|
</div>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
DESKTOP_POPOVER_CONTENT_CLASS,
|
|
4
|
+
shouldRenderProfileHeaderLink,
|
|
5
|
+
} from "./UserMenu.logic";
|
|
6
|
+
|
|
7
|
+
describe("UserMenu - desktop popover overflow fix", () => {
|
|
8
|
+
it("bounds the popover height to the available viewport space", () => {
|
|
9
|
+
// The core of issue #252: without a max-height the menu overflows past
|
|
10
|
+
// the viewport bottom on short screens and the lower items are unreachable.
|
|
11
|
+
expect(DESKTOP_POPOVER_CONTENT_CLASS).toContain(
|
|
12
|
+
"max-h-[var(--radix-popover-content-available-height)]",
|
|
13
|
+
);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("enables vertical scroll so clipped items stay reachable", () => {
|
|
17
|
+
expect(DESKTOP_POPOVER_CONTENT_CLASS).toContain("overflow-y-auto");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("retains the two-column grid layout", () => {
|
|
21
|
+
expect(DESKTOP_POPOVER_CONTENT_CLASS).toContain("sm:grid-cols-2");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("UserMenu - profile header link decision", () => {
|
|
26
|
+
it("renders a link when a non-empty profileHref is provided", () => {
|
|
27
|
+
expect(shouldRenderProfileHeaderLink("/profile")).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("renders a non-interactive label when profileHref is omitted", () => {
|
|
31
|
+
expect(shouldRenderProfileHeaderLink(undefined)).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("treats an empty string as no link (avoids an href-less anchor)", () => {
|
|
35
|
+
expect(shouldRenderProfileHeaderLink("")).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, DOM-free logic for {@link UserMenu}, separated so it can be unit
|
|
3
|
+
* tested without a browser environment.
|
|
4
|
+
*
|
|
5
|
+
* The repo's CI runs `bun test` from the repo root, which does NOT load the
|
|
6
|
+
* frontend happy-dom preload (that only applies when `bun test` runs with cwd
|
|
7
|
+
* inside `core/ui`). Component-render tests therefore cannot run in CI, so the
|
|
8
|
+
* testable behaviour lives here and is exercised by `UserMenu.logic.test.ts`
|
|
9
|
+
* (the same `*.logic.ts` + `*.logic.test.ts` split used by `ScriptTestPanel`).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Class names for the desktop user-menu popover content.
|
|
14
|
+
*
|
|
15
|
+
* Bounds the popover to the space the trigger leaves below it
|
|
16
|
+
* (`--radix-popover-content-available-height`) and makes the body scroll, so
|
|
17
|
+
* on short viewports the lower menu items stay reachable. The two-column grid
|
|
18
|
+
* (`sm:grid-cols-2`) is retained.
|
|
19
|
+
*/
|
|
20
|
+
export const DESKTOP_POPOVER_CONTENT_CLASS =
|
|
21
|
+
"w-[400px] md:w-[460px] p-2 grid grid-cols-1 sm:grid-cols-2 gap-1 max-h-[var(--radix-popover-content-available-height)] overflow-y-auto";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The name/email header renders as a clickable profile link only when a
|
|
25
|
+
* non-empty `profileHref` is provided; otherwise it is a non-interactive
|
|
26
|
+
* label. This is the single source of truth for that branch in `UserMenu`.
|
|
27
|
+
*/
|
|
28
|
+
export function shouldRenderProfileHeaderLink(profileHref?: string): boolean {
|
|
29
|
+
return typeof profileHref === "string" && profileHref.length > 0;
|
|
30
|
+
}
|
|
@@ -11,6 +11,10 @@ import {
|
|
|
11
11
|
import { MenuCloseContext, DropdownMenuLabel, DropdownMenuSeparator } from "./Menu";
|
|
12
12
|
import { useIsMobile } from "../hooks/useIsMobile";
|
|
13
13
|
import { cn } from "../utils";
|
|
14
|
+
import {
|
|
15
|
+
DESKTOP_POPOVER_CONTENT_CLASS,
|
|
16
|
+
shouldRenderProfileHeaderLink,
|
|
17
|
+
} from "./UserMenu.logic";
|
|
14
18
|
|
|
15
19
|
interface UserMenuProps {
|
|
16
20
|
user: {
|
|
@@ -18,12 +22,21 @@ interface UserMenuProps {
|
|
|
18
22
|
name?: string;
|
|
19
23
|
image?: string;
|
|
20
24
|
};
|
|
25
|
+
/**
|
|
26
|
+
* Optional href for the profile link rendered in the user info header.
|
|
27
|
+
* When provided, the name/email header becomes a focusable anchor that
|
|
28
|
+
* navigates to the profile page - supporting middle-click / open-in-new-tab.
|
|
29
|
+
* UserMenu must NOT import router hooks, so the caller is responsible for
|
|
30
|
+
* constructing this href (e.g. `resolveRoute(authRoutes.routes.profile)`).
|
|
31
|
+
*/
|
|
32
|
+
profileHref?: string;
|
|
21
33
|
children?: React.ReactNode;
|
|
22
34
|
className?: string;
|
|
23
35
|
}
|
|
24
36
|
|
|
25
37
|
export const UserMenu: React.FC<UserMenuProps> = ({
|
|
26
38
|
user,
|
|
39
|
+
profileHref,
|
|
27
40
|
children,
|
|
28
41
|
className,
|
|
29
42
|
}) => {
|
|
@@ -68,17 +81,32 @@ export const UserMenu: React.FC<UserMenuProps> = ({
|
|
|
68
81
|
</button>
|
|
69
82
|
);
|
|
70
83
|
|
|
71
|
-
const
|
|
72
|
-
<
|
|
73
|
-
<
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
const userInfoContent = (
|
|
85
|
+
<div className="flex flex-col">
|
|
86
|
+
<span className="text-sm font-bold text-foreground truncate">
|
|
87
|
+
{user.name || "User"}
|
|
88
|
+
</span>
|
|
89
|
+
<span className="text-xs font-normal text-muted-foreground truncate">
|
|
90
|
+
{user.email}
|
|
91
|
+
</span>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const userInfo = shouldRenderProfileHeaderLink(profileHref) ? (
|
|
96
|
+
<a
|
|
97
|
+
href={profileHref}
|
|
98
|
+
onClick={() => setIsOpen(false)}
|
|
99
|
+
className={cn(
|
|
100
|
+
"col-span-full block px-4 py-2 rounded-sm",
|
|
101
|
+
"hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
|
102
|
+
"transition-colors",
|
|
103
|
+
)}
|
|
104
|
+
>
|
|
105
|
+
{userInfoContent}
|
|
106
|
+
<span className="sr-only"> - Go to profile</span>
|
|
107
|
+
</a>
|
|
108
|
+
) : (
|
|
109
|
+
<DropdownMenuLabel>{userInfoContent}</DropdownMenuLabel>
|
|
82
110
|
);
|
|
83
111
|
|
|
84
112
|
if (isMobile) {
|
|
@@ -111,7 +139,7 @@ export const UserMenu: React.FC<UserMenuProps> = ({
|
|
|
111
139
|
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
|
112
140
|
<PopoverContent
|
|
113
141
|
align="end"
|
|
114
|
-
className=
|
|
142
|
+
className={DESKTOP_POPOVER_CONTENT_CLASS}
|
|
115
143
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
116
144
|
>
|
|
117
145
|
{userInfo}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { icons } from "lucide-react";
|
|
2
|
+
import type { LucideIconName } from "@checkstack/common";
|
|
3
|
+
|
|
4
|
+
// Lazy-loaded icon registry.
|
|
5
|
+
//
|
|
6
|
+
// Importing lucide's `icons` map pulls the ENTIRE ~1600-icon set into one
|
|
7
|
+
// module. This file is therefore imported ONLY via `React.lazy` from
|
|
8
|
+
// `DynamicIcon`, so that whole set lands in a single on-demand chunk that the
|
|
9
|
+
// initial load never fetches (DynamicIcon isn't rendered in the global nav -
|
|
10
|
+
// it's used in dialogs, cards, and specific pages). Statically named-imported
|
|
11
|
+
// icons elsewhere (`import { Plus } from "lucide-react"`) are unaffected and
|
|
12
|
+
// tree-shaken normally into their eager chunks.
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Render a lucide icon by its PascalCase name. Falls back to the `Settings`
|
|
16
|
+
* icon for an unknown name (matching the previous DynamicIcon behaviour).
|
|
17
|
+
*/
|
|
18
|
+
export default function RegistryIcon({
|
|
19
|
+
name,
|
|
20
|
+
className,
|
|
21
|
+
}: {
|
|
22
|
+
name: LucideIconName;
|
|
23
|
+
className?: string;
|
|
24
|
+
}) {
|
|
25
|
+
const Icon = icons[name] ?? icons.Settings;
|
|
26
|
+
return <Icon className={className} />;
|
|
27
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -10,11 +10,13 @@ export * from "./components/SectionHeader";
|
|
|
10
10
|
export * from "./components/StatusCard";
|
|
11
11
|
export * from "./components/EmptyState";
|
|
12
12
|
export * from "./components/LoadingSpinner";
|
|
13
|
+
export * from "./components/Spinner";
|
|
13
14
|
export * from "./components/Menu";
|
|
14
15
|
export * from "./components/UserMenu";
|
|
15
16
|
export * from "./components/EditableText";
|
|
16
17
|
export * from "./components/ConfirmationModal";
|
|
17
18
|
export * from "./components/HealthBadge";
|
|
19
|
+
export * from "./components/StatusBadge";
|
|
18
20
|
export * from "./components/Table";
|
|
19
21
|
export * from "./utils";
|
|
20
22
|
export * from "./components/Select";
|
|
@@ -49,6 +51,7 @@ export * from "./components/StatusUpdateTimeline";
|
|
|
49
51
|
export * from "./components/LinksEditor";
|
|
50
52
|
export * from "./components/Slider";
|
|
51
53
|
export * from "./components/DynamicIcon";
|
|
54
|
+
export * from "./components/BrandIcon";
|
|
52
55
|
export * from "./components/StrategyConfigCard";
|
|
53
56
|
export * from "./components/Markdown";
|
|
54
57
|
export * from "./components/ColorPicker";
|
package/stories/Introduction.mdx
CHANGED
|
@@ -46,5 +46,5 @@ inherit light/dark mode. Use semantic Tailwind classes (`bg-primary`,
|
|
|
46
46
|
- Components are built on Radix primitives where keyboard / focus / ARIA
|
|
47
47
|
behavior is non-trivial.
|
|
48
48
|
- Animation-heavy components respect the `usePerformance` hook's `isLowPower`
|
|
49
|
-
flag — see [the performance rule](https://github.com/enyineer/checkstack/blob/main/.
|
|
49
|
+
flag — see [the performance rule](https://github.com/enyineer/checkstack/blob/main/.claude/rules/performance.md)
|
|
50
50
|
for the full contract plugins are expected to follow.
|
|
@@ -33,3 +33,59 @@ Read more in the [strategies guide](https://example.com).`}
|
|
|
33
33
|
</MarkdownBlock>
|
|
34
34
|
),
|
|
35
35
|
};
|
|
36
|
+
|
|
37
|
+
// GFM tables (and strikethrough / autolinks) render via remark-gfm. The
|
|
38
|
+
// assistant frequently summarizes a draft as a table, so this must render as a
|
|
39
|
+
// real table, not a collapsed line of pipes.
|
|
40
|
+
export const Table: Story = {
|
|
41
|
+
render: () => (
|
|
42
|
+
<MarkdownBlock>
|
|
43
|
+
{`Here's the draft:
|
|
44
|
+
|
|
45
|
+
| Field | Value |
|
|
46
|
+
|---|---|
|
|
47
|
+
| URL | https://example.com/status |
|
|
48
|
+
| Method | GET |
|
|
49
|
+
| Assertion | statusCode = 200 |
|
|
50
|
+
| Interval | 60s |
|
|
51
|
+
| Timeout | 10s |
|
|
52
|
+
|
|
53
|
+
~~old value~~ replaced.`}
|
|
54
|
+
</MarkdownBlock>
|
|
55
|
+
),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// The assistant sometimes folds a long diff behind a native <details> disclosure.
|
|
59
|
+
// Raw HTML is parsed (rehype-raw) and sanitized (rehype-sanitize) so the
|
|
60
|
+
// collapsible renders as a click-to-expand widget instead of leaking the literal
|
|
61
|
+
// tags as text. Click the summary to expand.
|
|
62
|
+
export const Disclosure: Story = {
|
|
63
|
+
render: () => (
|
|
64
|
+
<MarkdownBlock>
|
|
65
|
+
{`Here are the planned changes - please confirm:
|
|
66
|
+
|
|
67
|
+
<details>
|
|
68
|
+
<summary>📝 View diff</summary>
|
|
69
|
+
|
|
70
|
+
**Inline-Script:**
|
|
71
|
+
|
|
72
|
+
\`\`\`diff
|
|
73
|
+
- success: load < 0.6
|
|
74
|
+
+ success: load < (cpus().length * 0.6)
|
|
75
|
+
\`\`\`
|
|
76
|
+
|
|
77
|
+
</details>`}
|
|
78
|
+
</MarkdownBlock>
|
|
79
|
+
),
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Sanitization closes the XSS surface: a script tag / event handler in model
|
|
83
|
+
// output is stripped, so only the safe text survives. This must render the bold
|
|
84
|
+
// text WITHOUT executing or showing any script.
|
|
85
|
+
export const SanitizesUnsafeHtml: Story = {
|
|
86
|
+
render: () => (
|
|
87
|
+
<MarkdownBlock>
|
|
88
|
+
{`Safe **bold** text.<script>window.__pwned = true</script><img src=x onerror="window.__pwned = true">`}
|
|
89
|
+
</MarkdownBlock>
|
|
90
|
+
),
|
|
91
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Loader2 } from "lucide-react";
|
|
3
|
+
import { Spinner } from "../src/components/Spinner";
|
|
4
|
+
import { Button } from "../src/components/Button";
|
|
5
|
+
import { cn } from "../src/utils";
|
|
6
|
+
|
|
7
|
+
const meta: Meta<typeof Spinner> = {
|
|
8
|
+
title: "Components/Feedback/Spinner",
|
|
9
|
+
component: Spinner,
|
|
10
|
+
tags: ["autodocs"],
|
|
11
|
+
argTypes: {
|
|
12
|
+
size: {
|
|
13
|
+
control: "select",
|
|
14
|
+
options: ["sm", "md", "lg"],
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
args: {
|
|
18
|
+
size: "sm",
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export default meta;
|
|
23
|
+
type Story = StoryObj<typeof Spinner>;
|
|
24
|
+
|
|
25
|
+
export const Default: Story = {};
|
|
26
|
+
|
|
27
|
+
export const Sizes: Story = {
|
|
28
|
+
render: () => (
|
|
29
|
+
<div className="flex items-center gap-6">
|
|
30
|
+
<div className="flex flex-col items-center gap-2">
|
|
31
|
+
<Spinner size="sm" />
|
|
32
|
+
<span className="text-xs text-muted-foreground">sm (h-4 w-4)</span>
|
|
33
|
+
</div>
|
|
34
|
+
<div className="flex flex-col items-center gap-2">
|
|
35
|
+
<Spinner size="md" />
|
|
36
|
+
<span className="text-xs text-muted-foreground">md (h-5 w-5)</span>
|
|
37
|
+
</div>
|
|
38
|
+
<div className="flex flex-col items-center gap-2">
|
|
39
|
+
<Spinner size="lg" />
|
|
40
|
+
<span className="text-xs text-muted-foreground">lg (h-6 w-6)</span>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const InButton: Story = {
|
|
47
|
+
render: () => (
|
|
48
|
+
<div className="flex items-center gap-3">
|
|
49
|
+
<Button disabled>
|
|
50
|
+
<Spinner size="sm" className="mr-2" />
|
|
51
|
+
Saving...
|
|
52
|
+
</Button>
|
|
53
|
+
<Button variant="outline" disabled>
|
|
54
|
+
<Spinner size="sm" className="mr-2" />
|
|
55
|
+
Loading
|
|
56
|
+
</Button>
|
|
57
|
+
</div>
|
|
58
|
+
),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const WithAriaLabel: Story = {
|
|
62
|
+
args: {
|
|
63
|
+
"aria-label": "Loading",
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* On low-power devices (or when the OS requests reduced motion) the spinner
|
|
69
|
+
* renders the static icon with no spin. The component handles this internally
|
|
70
|
+
* via `usePerformance().isLowPower`; this story renders the static icon
|
|
71
|
+
* directly to illustrate the result, since stories run with motion enabled.
|
|
72
|
+
*/
|
|
73
|
+
export const LowPowerStatic: Story = {
|
|
74
|
+
render: () => (
|
|
75
|
+
<div className="flex items-center gap-6">
|
|
76
|
+
<div className="flex flex-col items-center gap-2">
|
|
77
|
+
<Loader2 className={cn("h-4 w-4")} aria-hidden />
|
|
78
|
+
<span className="text-xs text-muted-foreground">sm (no spin)</span>
|
|
79
|
+
</div>
|
|
80
|
+
<div className="flex flex-col items-center gap-2">
|
|
81
|
+
<Loader2 className={cn("h-5 w-5")} aria-hidden />
|
|
82
|
+
<span className="text-xs text-muted-foreground">md (no spin)</span>
|
|
83
|
+
</div>
|
|
84
|
+
<div className="flex flex-col items-center gap-2">
|
|
85
|
+
<Loader2 className={cn("h-6 w-6")} aria-hidden />
|
|
86
|
+
<span className="text-xs text-muted-foreground">lg (no spin)</span>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
),
|
|
90
|
+
};
|