raft-ui 0.0.1
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/README.md +24 -0
- package/dist/cn.d.mts +2 -0
- package/dist/cn.mjs +2 -0
- package/dist/index.d.mts +2710 -0
- package/dist/index.mjs +4821 -0
- package/dist/label-DWq4ia5I.mjs +115 -0
- package/dist/styled-props-B3SNekJg.d.mts +16 -0
- package/dist/wip.d.mts +1238 -0
- package/dist/wip.mjs +1416 -0
- package/package.json +70 -0
- package/src/components/avatar/avatar.tsx +375 -0
- package/src/components/avatar/index.ts +17 -0
- package/src/components/badge/badge.tsx +393 -0
- package/src/components/badge/index.ts +2 -0
- package/src/components/banner/banner.tsx +269 -0
- package/src/components/banner/index.ts +8 -0
- package/src/components/bottom-sheet/bottom-sheet.tsx +152 -0
- package/src/components/bottom-sheet/index.ts +22 -0
- package/src/components/button/button.tsx +497 -0
- package/src/components/button/index.ts +2 -0
- package/src/components/checkbox/checkbox-indicator.tsx +128 -0
- package/src/components/checkbox/checkbox.tsx +350 -0
- package/src/components/checkbox/index.ts +2 -0
- package/src/components/combobox/combobox.tsx +603 -0
- package/src/components/combobox/index.ts +52 -0
- package/src/components/context-menu/context-menu.tsx +380 -0
- package/src/components/context-menu/index.ts +34 -0
- package/src/components/conversation-preview-card/conversation-preview-card.tsx +336 -0
- package/src/components/conversation-preview-card/index.ts +32 -0
- package/src/components/description-list/description-list.tsx +116 -0
- package/src/components/description-list/index.ts +14 -0
- package/src/components/dropdown-menu/dropdown-menu.tsx +405 -0
- package/src/components/dropdown-menu/index.ts +38 -0
- package/src/components/empty-state/empty-state.tsx +177 -0
- package/src/components/empty-state/index.ts +16 -0
- package/src/components/field/field.tsx +125 -0
- package/src/components/field/index.ts +18 -0
- package/src/components/inline-code/index.ts +2 -0
- package/src/components/inline-code/inline-code.tsx +33 -0
- package/src/components/input/index.ts +2 -0
- package/src/components/input/input.tsx +76 -0
- package/src/components/input-group/index.ts +7 -0
- package/src/components/input-group/input-group.tsx +215 -0
- package/src/components/kbd/index.ts +2 -0
- package/src/components/kbd/kbd.tsx +43 -0
- package/src/components/label/index.ts +7 -0
- package/src/components/label/label.tsx +99 -0
- package/src/components/lightbox/index.ts +24 -0
- package/src/components/lightbox/lightbox.tsx +245 -0
- package/src/components/list-item/index.ts +20 -0
- package/src/components/list-item/list-item.tsx +263 -0
- package/src/components/media-list-item/index.ts +20 -0
- package/src/components/media-list-item/media-list-item.tsx +169 -0
- package/src/components/notification-center/index.ts +39 -0
- package/src/components/notification-center/notification-center.tsx +558 -0
- package/src/components/panel-header/index.ts +22 -0
- package/src/components/panel-header/panel-header.tsx +199 -0
- package/src/components/popover/index.ts +30 -0
- package/src/components/popover/popover.tsx +248 -0
- package/src/components/preview-shell/index.ts +2 -0
- package/src/components/preview-shell/preview-shell.tsx +51 -0
- package/src/components/quoted-message-card/index.ts +36 -0
- package/src/components/quoted-message-card/quoted-message-card.tsx +391 -0
- package/src/components/radio-group/index.ts +6 -0
- package/src/components/radio-group/radio-group.tsx +119 -0
- package/src/components/section-header/index.ts +16 -0
- package/src/components/section-header/section-header.tsx +117 -0
- package/src/components/section-label/index.ts +2 -0
- package/src/components/section-label/section-label.tsx +38 -0
- package/src/components/segmented-control/index.ts +12 -0
- package/src/components/segmented-control/segmented-control.tsx +129 -0
- package/src/components/select/index.ts +40 -0
- package/src/components/select/select.tsx +382 -0
- package/src/components/spinner/index.ts +2 -0
- package/src/components/spinner/spinner.tsx +228 -0
- package/src/components/status/index.ts +2 -0
- package/src/components/status/status.tsx +152 -0
- package/src/components/tabs/index.ts +24 -0
- package/src/components/tabs/tabs.tsx +417 -0
- package/src/components/text/index.ts +2 -0
- package/src/components/text/text.tsx +130 -0
- package/src/components/textarea/index.ts +2 -0
- package/src/components/textarea/textarea.tsx +136 -0
- package/src/components/toggle-group/index.ts +12 -0
- package/src/components/toggle-group/toggle-group.tsx +111 -0
- package/src/components/tooltip/index.ts +14 -0
- package/src/components/tooltip/tooltip.tsx +186 -0
- package/src/fonts/brutal.css +2 -0
- package/src/index.ts +404 -0
- package/src/legacy/AttentionDot.tsx +28 -0
- package/src/legacy/AvatarListRow.tsx +87 -0
- package/src/legacy/AvatarSlot.tsx +157 -0
- package/src/legacy/Badge.tsx +95 -0
- package/src/legacy/Banner.tsx +74 -0
- package/src/legacy/BottomSheet.tsx +76 -0
- package/src/legacy/CheckMarker.tsx +72 -0
- package/src/legacy/Checkbox.tsx +81 -0
- package/src/legacy/ConversationPreviewCard.tsx +169 -0
- package/src/legacy/DismissBackdrop.tsx +68 -0
- package/src/legacy/EmptyState.tsx +63 -0
- package/src/legacy/ExperimentalBadge.tsx +19 -0
- package/src/legacy/FormField.tsx +106 -0
- package/src/legacy/KeyValueRow.tsx +67 -0
- package/src/legacy/Lightbox.tsx +119 -0
- package/src/legacy/NotificationCenter.tsx +217 -0
- package/src/legacy/PanelHeader.tsx +94 -0
- package/src/legacy/PreviewShell.tsx +40 -0
- package/src/legacy/QuotedMessageCard.tsx +181 -0
- package/src/legacy/ReorderablePanelTabs.tsx +151 -0
- package/src/legacy/SandboxedPreviewFrame.tsx +59 -0
- package/src/legacy/SearchInput.tsx +23 -0
- package/src/legacy/SectionEyebrow.tsx +60 -0
- package/src/legacy/SectionHeader.tsx +68 -0
- package/src/legacy/SegmentedControl.tsx +74 -0
- package/src/legacy/SlugInput.tsx +29 -0
- package/src/legacy/Spinner.tsx +89 -0
- package/src/legacy/StatusDot.tsx +51 -0
- package/src/legacy/SurfaceListItem.tsx +32 -0
- package/src/legacy/Textarea.tsx +137 -0
- package/src/legacy/button.tsx +100 -0
- package/src/legacy/context-menu-divider.tsx +12 -0
- package/src/legacy/menu-item.tsx +66 -0
- package/src/legacy/selection-popover.tsx +212 -0
- package/src/legacy/server-switcher-menu.tsx +203 -0
- package/src/legacy/source.css +240 -0
- package/src/lib/cn.ts +1 -0
- package/src/lib/styled-props.ts +11 -0
- package/src/lib/theme/theme-context.ts +28 -0
- package/src/lib/theme/theme-provider.tsx +181 -0
- package/src/lib/theme/use-theme.ts +12 -0
- package/src/lib/tv.ts +29 -0
- package/src/lib/without-children.ts +6 -0
- package/src/styles.css +467 -0
- package/src/wip.ts +255 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Hash, MessageSquare } from "lucide-react";
|
|
3
|
+
import AvatarSlot from "./AvatarSlot.tsx";
|
|
4
|
+
|
|
5
|
+
export interface QuotedMessageCardProps {
|
|
6
|
+
channelName: string;
|
|
7
|
+
channelKind?: "channel" | "dm";
|
|
8
|
+
isThread?: boolean;
|
|
9
|
+
isArchived?: boolean;
|
|
10
|
+
timestamp: string;
|
|
11
|
+
author: {
|
|
12
|
+
name: string;
|
|
13
|
+
avatar?: string;
|
|
14
|
+
gravatarHash?: string;
|
|
15
|
+
kind: "agent" | "user";
|
|
16
|
+
subtitle?: string;
|
|
17
|
+
};
|
|
18
|
+
content: string;
|
|
19
|
+
attachments?: { label: string }[];
|
|
20
|
+
onClick?: () => void;
|
|
21
|
+
unavailable?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function Tag({ children, className }: { children: ReactNode; className: string }) {
|
|
25
|
+
return (
|
|
26
|
+
<span
|
|
27
|
+
className={`inline-flex items-center gap-1 border-[1.5px] border-black px-1.5 py-0.5 text-[11px] font-bold leading-none ${className}`}
|
|
28
|
+
>
|
|
29
|
+
{children}
|
|
30
|
+
</span>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function ThreadMarker() {
|
|
35
|
+
return (
|
|
36
|
+
<span className="inline-flex items-center gap-1 font-bold text-brutal-pink">
|
|
37
|
+
<MessageSquare size={10} className="shrink-0" />
|
|
38
|
+
Thread
|
|
39
|
+
</span>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function CompactUserAvatar({ author }: { author: QuotedMessageCardProps["author"] }) {
|
|
44
|
+
if (author.kind === "agent") {
|
|
45
|
+
return (
|
|
46
|
+
<AvatarSlot context="compact-list" type="agent" agentAvatarUrl={author.avatar ?? null} />
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<AvatarSlot
|
|
52
|
+
context="compact-list"
|
|
53
|
+
type="human"
|
|
54
|
+
humanAvatarUrl={author.avatar ?? null}
|
|
55
|
+
gravatarHash={author.gravatarHash ?? null}
|
|
56
|
+
/>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function InlineMarkdownPreview({ markdown }: { markdown: string }) {
|
|
61
|
+
return <>{markdown}</>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type PreviewShellProps = {
|
|
65
|
+
variant?: "default" | "muted";
|
|
66
|
+
onClick?: () => void;
|
|
67
|
+
className?: string;
|
|
68
|
+
"data-testid"?: string;
|
|
69
|
+
children: ReactNode;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function PreviewShell({
|
|
73
|
+
variant = "default",
|
|
74
|
+
onClick,
|
|
75
|
+
className,
|
|
76
|
+
children,
|
|
77
|
+
...rest
|
|
78
|
+
}: PreviewShellProps) {
|
|
79
|
+
const Root = onClick ? "button" : "div";
|
|
80
|
+
const skin =
|
|
81
|
+
variant === "muted"
|
|
82
|
+
? "border-2 border-black/30 bg-black/5 italic text-black/40 hover:shadow-brutal-sm"
|
|
83
|
+
: "border border-black/15 bg-white hover:border-black/30 hover:bg-black/5";
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<Root
|
|
87
|
+
{...(onClick ? { type: "button" as const, onClick } : {})}
|
|
88
|
+
{...rest}
|
|
89
|
+
className={`${skin}${className ? ` ${className}` : ""}`}
|
|
90
|
+
>
|
|
91
|
+
{children}
|
|
92
|
+
</Root>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export default function QuotedMessageCard({
|
|
97
|
+
channelName,
|
|
98
|
+
channelKind = "channel",
|
|
99
|
+
isThread = false,
|
|
100
|
+
isArchived = false,
|
|
101
|
+
timestamp,
|
|
102
|
+
author,
|
|
103
|
+
content,
|
|
104
|
+
attachments,
|
|
105
|
+
onClick,
|
|
106
|
+
unavailable = false,
|
|
107
|
+
}: QuotedMessageCardProps) {
|
|
108
|
+
if (unavailable) {
|
|
109
|
+
return (
|
|
110
|
+
<PreviewShell
|
|
111
|
+
variant="muted"
|
|
112
|
+
onClick={onClick}
|
|
113
|
+
data-testid="quoted-message-card"
|
|
114
|
+
className="group block w-full text-left"
|
|
115
|
+
>
|
|
116
|
+
<div className="px-2.5 py-2 sm:px-3 sm:py-2.5">
|
|
117
|
+
<div className="text-[13px] leading-snug">Message unavailable</div>
|
|
118
|
+
</div>
|
|
119
|
+
</PreviewShell>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<PreviewShell
|
|
125
|
+
onClick={onClick}
|
|
126
|
+
data-testid="quoted-message-card"
|
|
127
|
+
className="group block w-full text-left"
|
|
128
|
+
>
|
|
129
|
+
<div className="px-2.5 py-2 sm:px-3 sm:py-2.5">
|
|
130
|
+
<div className="mb-1 flex items-start gap-2 sm:mb-1.5">
|
|
131
|
+
<div className="min-w-0 flex flex-1 flex-wrap items-center gap-x-1.5 gap-y-1 text-xs leading-none">
|
|
132
|
+
<span className="inline-flex items-center gap-1 font-bold text-black/70">
|
|
133
|
+
{channelKind === "dm" ? null : <Hash size={11} className="shrink-0" />}
|
|
134
|
+
{channelKind === "dm" ? `@${channelName}` : channelName}
|
|
135
|
+
</span>
|
|
136
|
+
<span className="text-black/30">·</span>
|
|
137
|
+
<span className="inline-flex min-w-0 items-center gap-1.5">
|
|
138
|
+
<CompactUserAvatar author={author} />
|
|
139
|
+
<span className="truncate font-bold text-black/70">{author.name}</span>
|
|
140
|
+
{author.subtitle ? (
|
|
141
|
+
<span className="truncate font-mono text-[10px] text-black/40">
|
|
142
|
+
{author.subtitle}
|
|
143
|
+
</span>
|
|
144
|
+
) : null}
|
|
145
|
+
</span>
|
|
146
|
+
{isThread ? (
|
|
147
|
+
<>
|
|
148
|
+
<span className="text-black/30">·</span>
|
|
149
|
+
<ThreadMarker />
|
|
150
|
+
</>
|
|
151
|
+
) : null}
|
|
152
|
+
{isArchived ? (
|
|
153
|
+
<>
|
|
154
|
+
<span className="text-black/30">·</span>
|
|
155
|
+
<Tag className="bg-brutal-orange/30 text-black">Archived</Tag>
|
|
156
|
+
</>
|
|
157
|
+
) : null}
|
|
158
|
+
</div>
|
|
159
|
+
<span className="shrink-0 font-mono text-[10px] text-black/40">{timestamp}</span>
|
|
160
|
+
</div>
|
|
161
|
+
<div className="min-w-0">
|
|
162
|
+
<div className="line-clamp-2 text-xs leading-snug text-black sm:text-[13px]">
|
|
163
|
+
<InlineMarkdownPreview markdown={content} />
|
|
164
|
+
</div>
|
|
165
|
+
{attachments?.length ? (
|
|
166
|
+
<div className="mt-2 flex flex-wrap gap-1.5">
|
|
167
|
+
{attachments.map((attachment) => (
|
|
168
|
+
<Tag
|
|
169
|
+
key={attachment.label}
|
|
170
|
+
className="bg-brutal-lavender font-mono text-[10px] font-bold text-black"
|
|
171
|
+
>
|
|
172
|
+
{attachment.label}
|
|
173
|
+
</Tag>
|
|
174
|
+
))}
|
|
175
|
+
</div>
|
|
176
|
+
) : null}
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
</PreviewShell>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { useCallback, useMemo, type ComponentType, type RefObject } from "react";
|
|
2
|
+
import {
|
|
3
|
+
closestCenter,
|
|
4
|
+
DndContext,
|
|
5
|
+
PointerSensor,
|
|
6
|
+
TouchSensor,
|
|
7
|
+
useSensor,
|
|
8
|
+
useSensors,
|
|
9
|
+
type DragEndEvent,
|
|
10
|
+
} from "@dnd-kit/core";
|
|
11
|
+
import {
|
|
12
|
+
arrayMove,
|
|
13
|
+
horizontalListSortingStrategy,
|
|
14
|
+
SortableContext,
|
|
15
|
+
useSortable,
|
|
16
|
+
} from "@dnd-kit/sortable";
|
|
17
|
+
import { CSS } from "@dnd-kit/utilities";
|
|
18
|
+
|
|
19
|
+
export interface PanelTabItem<T extends string> {
|
|
20
|
+
id: T;
|
|
21
|
+
label: string;
|
|
22
|
+
icon: ComponentType<{ size?: number; className?: string }>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function reconcilePanelTabOrder<T extends string>(
|
|
26
|
+
tabs: readonly PanelTabItem<T>[],
|
|
27
|
+
order: readonly string[],
|
|
28
|
+
): PanelTabItem<T>[] {
|
|
29
|
+
const byId = new Map(tabs.map((tab) => [tab.id, tab]));
|
|
30
|
+
const next: PanelTabItem<T>[] = [];
|
|
31
|
+
const seen = new Set<string>();
|
|
32
|
+
|
|
33
|
+
for (const id of order) {
|
|
34
|
+
const tab = byId.get(id as T);
|
|
35
|
+
if (!tab || seen.has(tab.id)) continue;
|
|
36
|
+
next.push(tab);
|
|
37
|
+
seen.add(tab.id);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const tab of tabs) {
|
|
41
|
+
if (seen.has(tab.id)) continue;
|
|
42
|
+
next.push(tab);
|
|
43
|
+
seen.add(tab.id);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return next;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function useOrderedPanelTabs<T extends string>(
|
|
50
|
+
tabs: readonly PanelTabItem<T>[],
|
|
51
|
+
storedOrder: readonly string[],
|
|
52
|
+
) {
|
|
53
|
+
return useMemo(() => reconcilePanelTabOrder(tabs, storedOrder), [tabs, storedOrder]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function SortablePanelTab<T extends string>({
|
|
57
|
+
tab,
|
|
58
|
+
active,
|
|
59
|
+
activeRef,
|
|
60
|
+
onClick,
|
|
61
|
+
}: {
|
|
62
|
+
tab: PanelTabItem<T>;
|
|
63
|
+
active: boolean;
|
|
64
|
+
activeRef?: RefObject<HTMLButtonElement | null>;
|
|
65
|
+
onClick: () => void;
|
|
66
|
+
}) {
|
|
67
|
+
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
68
|
+
id: tab.id,
|
|
69
|
+
});
|
|
70
|
+
const Icon = tab.icon;
|
|
71
|
+
const horizontalTransform = transform ? { ...transform, y: 0 } : null;
|
|
72
|
+
const style = {
|
|
73
|
+
transform: CSS.Transform.toString(horizontalTransform),
|
|
74
|
+
transition,
|
|
75
|
+
zIndex: isDragging ? 20 : undefined,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<button
|
|
80
|
+
ref={(node) => {
|
|
81
|
+
setNodeRef(node);
|
|
82
|
+
if (activeRef) activeRef.current = node;
|
|
83
|
+
}}
|
|
84
|
+
type="button"
|
|
85
|
+
onClick={onClick}
|
|
86
|
+
className={`flex shrink-0 touch-manipulation items-center gap-1.5 whitespace-nowrap border-r-2 border-black px-4 py-1.5 text-xs font-bold transition-colors ${
|
|
87
|
+
active ? "bg-brutal-yellow" : "bg-white hover:bg-black/5"
|
|
88
|
+
}`}
|
|
89
|
+
style={style}
|
|
90
|
+
data-testid={`panel-tab-${tab.id}`}
|
|
91
|
+
{...attributes}
|
|
92
|
+
{...listeners}
|
|
93
|
+
>
|
|
94
|
+
<Icon size={12} />
|
|
95
|
+
<span>{tab.label}</span>
|
|
96
|
+
</button>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export default function ReorderablePanelTabs<T extends string>({
|
|
101
|
+
tabs,
|
|
102
|
+
activeTab,
|
|
103
|
+
activeTabRef,
|
|
104
|
+
onSelectTab,
|
|
105
|
+
onReorderTabs,
|
|
106
|
+
}: {
|
|
107
|
+
tabs: readonly PanelTabItem<T>[];
|
|
108
|
+
activeTab: T;
|
|
109
|
+
activeTabRef?: RefObject<HTMLButtonElement | null>;
|
|
110
|
+
onSelectTab: (tab: T) => void;
|
|
111
|
+
onReorderTabs: (tabs: T[]) => void;
|
|
112
|
+
}) {
|
|
113
|
+
const sensors = useSensors(
|
|
114
|
+
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
|
115
|
+
useSensor(TouchSensor, { activationConstraint: { delay: 350, tolerance: 8 } }),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const tabIds = useMemo(() => tabs.map((tab) => tab.id), [tabs]);
|
|
119
|
+
const handleDragEnd = useCallback(
|
|
120
|
+
({ active, over }: DragEndEvent) => {
|
|
121
|
+
if (!over || active.id === over.id) return;
|
|
122
|
+
const activeId = String(active.id) as T;
|
|
123
|
+
const overId = String(over.id) as T;
|
|
124
|
+
const oldIndex = tabIds.indexOf(activeId);
|
|
125
|
+
const newIndex = tabIds.indexOf(overId);
|
|
126
|
+
if (oldIndex === -1 || newIndex === -1) return;
|
|
127
|
+
onReorderTabs(arrayMove(tabIds, oldIndex, newIndex));
|
|
128
|
+
},
|
|
129
|
+
[onReorderTabs, tabIds],
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div className="overflow-x-auto border-b-2 border-black bg-white scrollbar-none">
|
|
134
|
+
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
|
135
|
+
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
|
|
136
|
+
<div className="flex min-w-max">
|
|
137
|
+
{tabs.map((tab) => (
|
|
138
|
+
<SortablePanelTab
|
|
139
|
+
key={tab.id}
|
|
140
|
+
tab={tab}
|
|
141
|
+
active={activeTab === tab.id}
|
|
142
|
+
activeRef={activeTab === tab.id ? activeTabRef : undefined}
|
|
143
|
+
onClick={() => onSelectTab(tab.id)}
|
|
144
|
+
/>
|
|
145
|
+
))}
|
|
146
|
+
</div>
|
|
147
|
+
</SortableContext>
|
|
148
|
+
</DndContext>
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared isolation primitive for previewing attacker-controlled content
|
|
5
|
+
* (anyone — human or agent — can upload a hostile attachment, or author a
|
|
6
|
+
* markdown ```mermaid block).
|
|
7
|
+
*
|
|
8
|
+
* The iframe is the real security boundary: with no `allow-same-origin` in
|
|
9
|
+
* `sandbox`, the framed content runs in an opaque origin and cannot touch
|
|
10
|
+
* Raft's DOM / cookies / localStorage. This is strictly stronger than
|
|
11
|
+
* sanitizing a string and hoping the sanitizer has no gaps.
|
|
12
|
+
*
|
|
13
|
+
* Two callsites share this one implementation so the isolation config can't
|
|
14
|
+
* drift between them:
|
|
15
|
+
* - HTML attachment preview: `src` (scoped backend URL) + `sandbox="allow-scripts"`
|
|
16
|
+
* (interactive HTML/diagrams need scripts; still no same-origin).
|
|
17
|
+
* - Mermaid diagram: `srcDoc` (front-end-generated SVG doc) + empty `sandbox`
|
|
18
|
+
* (static SVG needs zero scripts — the most locked-down setting).
|
|
19
|
+
*
|
|
20
|
+
* @Bugen security review 2026-05-18 #proj-uiux:df4d393b.
|
|
21
|
+
*/
|
|
22
|
+
export interface SandboxedPreviewFrameProps {
|
|
23
|
+
/** External (scoped) URL to frame. Mutually exclusive with `srcDoc`. */
|
|
24
|
+
src?: string;
|
|
25
|
+
/** Inline document to frame. Mutually exclusive with `src`. */
|
|
26
|
+
srcDoc?: string;
|
|
27
|
+
/**
|
|
28
|
+
* iframe `sandbox` token list. Default `""` = maximally locked (no scripts,
|
|
29
|
+
* no same-origin). Never pass `allow-same-origin` for untrusted content —
|
|
30
|
+
* it dissolves the origin boundary.
|
|
31
|
+
*/
|
|
32
|
+
sandbox?: string;
|
|
33
|
+
title: string;
|
|
34
|
+
className?: string;
|
|
35
|
+
style?: CSSProperties;
|
|
36
|
+
referrerPolicy?: React.HTMLAttributeReferrerPolicy;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default function SandboxedPreviewFrame({
|
|
40
|
+
src,
|
|
41
|
+
srcDoc,
|
|
42
|
+
sandbox = "",
|
|
43
|
+
title,
|
|
44
|
+
className,
|
|
45
|
+
style,
|
|
46
|
+
referrerPolicy = "no-referrer",
|
|
47
|
+
}: SandboxedPreviewFrameProps) {
|
|
48
|
+
return (
|
|
49
|
+
<iframe
|
|
50
|
+
title={title}
|
|
51
|
+
src={src}
|
|
52
|
+
srcDoc={srcDoc}
|
|
53
|
+
sandbox={sandbox}
|
|
54
|
+
referrerPolicy={referrerPolicy}
|
|
55
|
+
className={className}
|
|
56
|
+
style={style}
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { InputHTMLAttributes } from "react";
|
|
2
|
+
import { Search } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
type SearchInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, "className"> & {
|
|
5
|
+
className?: string;
|
|
6
|
+
inputClassName?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export default function SearchInput({
|
|
10
|
+
className = "",
|
|
11
|
+
inputClassName = "",
|
|
12
|
+
...props
|
|
13
|
+
}: SearchInputProps) {
|
|
14
|
+
return (
|
|
15
|
+
<div className={`input-brutal flex w-full items-center gap-2 ${className}`.trim()}>
|
|
16
|
+
<Search size={15} className="shrink-0 text-black/50" />
|
|
17
|
+
<input
|
|
18
|
+
{...props}
|
|
19
|
+
className={`min-w-0 flex-1 bg-transparent font-heading outline-none ${inputClassName}`.trim()}
|
|
20
|
+
/>
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical section-eyebrow label — replaces the inline
|
|
3
|
+
* `text-xs font-bold uppercase text-black/60 tracking-widest` pattern
|
|
4
|
+
* that appears 40+ times across panels, dialogs, and list headers.
|
|
5
|
+
*
|
|
6
|
+
* stdrc 2026-05-11 #wg-theme:93c5897c assigned this to @Wug as the
|
|
7
|
+
* next Tier 1 component after PanelHeader / AvatarSlot / AvatarListRow /
|
|
8
|
+
* Banner. Per @Duoyu audit msg=cc6f5542 the eyebrow class string was the
|
|
9
|
+
* single biggest repeated typographic pattern in the codebase.
|
|
10
|
+
*
|
|
11
|
+
* The component renders the locked typographic token and accepts:
|
|
12
|
+
* - `as` — element type (`"span"` | `"div"` | `"label"`, default `"span"`)
|
|
13
|
+
* - `className` — callsite-specific additions (margin, padding, bg, color)
|
|
14
|
+
* - `htmlFor` — forwarded when `as="label"`
|
|
15
|
+
* - `children` — label text
|
|
16
|
+
*
|
|
17
|
+
* Do NOT inline new section eyebrows — use this primitive instead.
|
|
18
|
+
*
|
|
19
|
+
* Default color is `text-black/60`. To get a fully-black eyebrow
|
|
20
|
+
* (e.g. Sidebar section toggle, MachineDetailPanel running label),
|
|
21
|
+
* pass `className="!text-black"` to override.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { LabelHTMLAttributes, ReactNode } from "react";
|
|
25
|
+
|
|
26
|
+
const BASE_CLASS = "text-xs font-bold uppercase text-black/60 tracking-widest";
|
|
27
|
+
|
|
28
|
+
export type SectionEyebrowProps = {
|
|
29
|
+
/** HTML element to render. Default `"span"`. Use `"div"` for block-level
|
|
30
|
+
* eyebrows (e.g. with `mb-3`), `"label"` for form field labels. */
|
|
31
|
+
as?: "span" | "div" | "label";
|
|
32
|
+
/** Additional classes — appended to the base token. Useful for margin
|
|
33
|
+
* (`mb-3`), padding (`px-3 py-1.5`), background (`bg-white/50`), or
|
|
34
|
+
* color overrides (`!text-black`). */
|
|
35
|
+
className?: string;
|
|
36
|
+
/** Eyebrow text / nodes. */
|
|
37
|
+
children?: ReactNode;
|
|
38
|
+
/** When `as="label"`, forward to the `<label htmlFor>` attribute. */
|
|
39
|
+
htmlFor?: LabelHTMLAttributes<HTMLLabelElement>["htmlFor"];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export default function SectionEyebrow({
|
|
43
|
+
as: Tag = "span",
|
|
44
|
+
className = "",
|
|
45
|
+
children,
|
|
46
|
+
htmlFor,
|
|
47
|
+
}: SectionEyebrowProps) {
|
|
48
|
+
const cls = `${BASE_CLASS} ${className}`.trim();
|
|
49
|
+
if (Tag === "label") {
|
|
50
|
+
return (
|
|
51
|
+
<label className={cls} htmlFor={htmlFor}>
|
|
52
|
+
{children}
|
|
53
|
+
</label>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (Tag === "div") {
|
|
57
|
+
return <div className={cls}>{children}</div>;
|
|
58
|
+
}
|
|
59
|
+
return <span className={cls}>{children}</span>;
|
|
60
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical section header — eyebrow label + optional count badge + optional
|
|
3
|
+
* trailing action button.
|
|
4
|
+
*
|
|
5
|
+
* Generalizes the inline `flex items-center justify-between` + `SectionEyebrow`
|
|
6
|
+
* + counter + action button pattern that appears 15+ times across detail
|
|
7
|
+
* panels, settings, channel members, agent reminders, etc.
|
|
8
|
+
*
|
|
9
|
+
* Background: stdrc 2026-05-12 #wg-theme:a987f888 asked for strong-signal
|
|
10
|
+
* primitives to be lifted in one PR. SectionHeader is the third of the three.
|
|
11
|
+
* It composes the existing `SectionEyebrow` primitive (so the eyebrow token
|
|
12
|
+
* stays canonical) rather than duplicating its class string.
|
|
13
|
+
*
|
|
14
|
+
* Slots:
|
|
15
|
+
* - `label` — the eyebrow text
|
|
16
|
+
* - `icon` — optional leading icon node (e.g. `<Bell size={16} />`),
|
|
17
|
+
* rendered in `text-black/60` next to the eyebrow
|
|
18
|
+
* - `count` — optional integer badge rendered after the label in
|
|
19
|
+
* `text-black/40 font-mono` (rendered only when defined and
|
|
20
|
+
* ≥ 0; pass `undefined` / `null` to hide)
|
|
21
|
+
* - `action` — optional right-aligned action node (e.g. `<button>` to add)
|
|
22
|
+
* - `htmlFor` — when set, the eyebrow becomes a `<label htmlFor>` for the
|
|
23
|
+
* region's main control
|
|
24
|
+
* - `className` — additional classes on the outer wrapper (e.g. `mb-2`,
|
|
25
|
+
* `border-b-2 border-black pb-2`)
|
|
26
|
+
*
|
|
27
|
+
* Typography is locked via `SectionEyebrow`; do NOT pass eyebrow class
|
|
28
|
+
* overrides here — if a section needs a different style, it shouldn't be a
|
|
29
|
+
* SectionHeader.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { ReactNode } from "react";
|
|
33
|
+
import SectionEyebrow from "./SectionEyebrow.tsx";
|
|
34
|
+
|
|
35
|
+
export type SectionHeaderProps = {
|
|
36
|
+
label: ReactNode;
|
|
37
|
+
icon?: ReactNode;
|
|
38
|
+
count?: number | null;
|
|
39
|
+
action?: ReactNode;
|
|
40
|
+
htmlFor?: string;
|
|
41
|
+
className?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export default function SectionHeader({
|
|
45
|
+
label,
|
|
46
|
+
icon,
|
|
47
|
+
count,
|
|
48
|
+
action,
|
|
49
|
+
htmlFor,
|
|
50
|
+
className,
|
|
51
|
+
}: SectionHeaderProps) {
|
|
52
|
+
const wrapperCls = `flex items-center justify-between gap-2${className ? ` ${className}` : ""}`;
|
|
53
|
+
const eyebrowAs: "label" | "div" = htmlFor ? "label" : "div";
|
|
54
|
+
return (
|
|
55
|
+
<div className={wrapperCls}>
|
|
56
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
57
|
+
{icon ? <span className="shrink-0 text-black/60">{icon}</span> : null}
|
|
58
|
+
<SectionEyebrow as={eyebrowAs} htmlFor={htmlFor}>
|
|
59
|
+
{label}
|
|
60
|
+
{typeof count === "number" && count >= 0 ? (
|
|
61
|
+
<span className="ml-2 font-mono text-black/40">{count}</span>
|
|
62
|
+
) : null}
|
|
63
|
+
</SectionEyebrow>
|
|
64
|
+
</div>
|
|
65
|
+
{action ? <div className="shrink-0">{action}</div> : null}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export interface SegmentedControlOption<T extends string> {
|
|
4
|
+
value: T;
|
|
5
|
+
label: ReactNode;
|
|
6
|
+
count?: number;
|
|
7
|
+
icon?: ReactNode;
|
|
8
|
+
testId?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SegmentedControlProps<T extends string> {
|
|
12
|
+
value: T;
|
|
13
|
+
options: SegmentedControlOption<T>[];
|
|
14
|
+
onChange: (value: T) => void;
|
|
15
|
+
ariaLabel?: string;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default function SegmentedControl<T extends string>({
|
|
21
|
+
value,
|
|
22
|
+
options,
|
|
23
|
+
onChange,
|
|
24
|
+
ariaLabel,
|
|
25
|
+
disabled = false,
|
|
26
|
+
className = "",
|
|
27
|
+
}: SegmentedControlProps<T>) {
|
|
28
|
+
return (
|
|
29
|
+
<div
|
|
30
|
+
role="radiogroup"
|
|
31
|
+
aria-label={ariaLabel}
|
|
32
|
+
aria-disabled={disabled || undefined}
|
|
33
|
+
className={`inline-flex max-w-full flex-wrap items-center gap-1 ${
|
|
34
|
+
disabled ? "opacity-50" : ""
|
|
35
|
+
} ${className}`}
|
|
36
|
+
>
|
|
37
|
+
{options.map((option) => {
|
|
38
|
+
const active = option.value === value;
|
|
39
|
+
return (
|
|
40
|
+
<button
|
|
41
|
+
key={option.value}
|
|
42
|
+
type="button"
|
|
43
|
+
role="radio"
|
|
44
|
+
aria-checked={active}
|
|
45
|
+
data-testid={option.testId}
|
|
46
|
+
disabled={disabled}
|
|
47
|
+
onClick={() => onChange(option.value)}
|
|
48
|
+
className={`flex h-7 min-w-0 shrink-0 items-center gap-1 border-2 border-black px-2 text-xs font-bold transition-colors ${
|
|
49
|
+
disabled ? "cursor-not-allowed" : ""
|
|
50
|
+
} ${
|
|
51
|
+
active
|
|
52
|
+
? "bg-brutal-yellow text-black shadow-brutal-sm"
|
|
53
|
+
: disabled
|
|
54
|
+
? "bg-white text-black"
|
|
55
|
+
: "bg-white text-black hover:bg-brutal-yellow/30"
|
|
56
|
+
}`}
|
|
57
|
+
>
|
|
58
|
+
{option.icon}
|
|
59
|
+
<span className="truncate">{option.label}</span>
|
|
60
|
+
{typeof option.count === "number" && option.count > 0 ? (
|
|
61
|
+
<span
|
|
62
|
+
className={`font-mono text-[10px] leading-none ${
|
|
63
|
+
active ? "text-black/60" : "text-black/40"
|
|
64
|
+
}`}
|
|
65
|
+
>
|
|
66
|
+
{option.count}
|
|
67
|
+
</span>
|
|
68
|
+
) : null}
|
|
69
|
+
</button>
|
|
70
|
+
);
|
|
71
|
+
})}
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { InputHTMLAttributes } from "react";
|
|
2
|
+
|
|
3
|
+
type SlugInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, "className"> & {
|
|
4
|
+
className?: string;
|
|
5
|
+
inputClassName?: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export default function SlugInput({
|
|
9
|
+
className = "",
|
|
10
|
+
inputClassName = "",
|
|
11
|
+
...props
|
|
12
|
+
}: SlugInputProps) {
|
|
13
|
+
return (
|
|
14
|
+
<div
|
|
15
|
+
className={`input-brutal flex w-full items-stretch overflow-hidden px-0 py-0 focus-within:shadow-brutal ${className}`.trim()}
|
|
16
|
+
>
|
|
17
|
+
<span
|
|
18
|
+
aria-hidden="true"
|
|
19
|
+
className="flex shrink-0 items-center border-r-2 border-black bg-brutal-yellow/30 px-3 font-heading font-bold text-black/70"
|
|
20
|
+
>
|
|
21
|
+
/
|
|
22
|
+
</span>
|
|
23
|
+
<input
|
|
24
|
+
{...props}
|
|
25
|
+
className={`min-w-0 flex-1 bg-transparent px-3 py-2 font-heading outline-none ${inputClassName}`.trim()}
|
|
26
|
+
/>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|