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.
Files changed (134) hide show
  1. package/README.md +24 -0
  2. package/dist/cn.d.mts +2 -0
  3. package/dist/cn.mjs +2 -0
  4. package/dist/index.d.mts +2710 -0
  5. package/dist/index.mjs +4821 -0
  6. package/dist/label-DWq4ia5I.mjs +115 -0
  7. package/dist/styled-props-B3SNekJg.d.mts +16 -0
  8. package/dist/wip.d.mts +1238 -0
  9. package/dist/wip.mjs +1416 -0
  10. package/package.json +70 -0
  11. package/src/components/avatar/avatar.tsx +375 -0
  12. package/src/components/avatar/index.ts +17 -0
  13. package/src/components/badge/badge.tsx +393 -0
  14. package/src/components/badge/index.ts +2 -0
  15. package/src/components/banner/banner.tsx +269 -0
  16. package/src/components/banner/index.ts +8 -0
  17. package/src/components/bottom-sheet/bottom-sheet.tsx +152 -0
  18. package/src/components/bottom-sheet/index.ts +22 -0
  19. package/src/components/button/button.tsx +497 -0
  20. package/src/components/button/index.ts +2 -0
  21. package/src/components/checkbox/checkbox-indicator.tsx +128 -0
  22. package/src/components/checkbox/checkbox.tsx +350 -0
  23. package/src/components/checkbox/index.ts +2 -0
  24. package/src/components/combobox/combobox.tsx +603 -0
  25. package/src/components/combobox/index.ts +52 -0
  26. package/src/components/context-menu/context-menu.tsx +380 -0
  27. package/src/components/context-menu/index.ts +34 -0
  28. package/src/components/conversation-preview-card/conversation-preview-card.tsx +336 -0
  29. package/src/components/conversation-preview-card/index.ts +32 -0
  30. package/src/components/description-list/description-list.tsx +116 -0
  31. package/src/components/description-list/index.ts +14 -0
  32. package/src/components/dropdown-menu/dropdown-menu.tsx +405 -0
  33. package/src/components/dropdown-menu/index.ts +38 -0
  34. package/src/components/empty-state/empty-state.tsx +177 -0
  35. package/src/components/empty-state/index.ts +16 -0
  36. package/src/components/field/field.tsx +125 -0
  37. package/src/components/field/index.ts +18 -0
  38. package/src/components/inline-code/index.ts +2 -0
  39. package/src/components/inline-code/inline-code.tsx +33 -0
  40. package/src/components/input/index.ts +2 -0
  41. package/src/components/input/input.tsx +76 -0
  42. package/src/components/input-group/index.ts +7 -0
  43. package/src/components/input-group/input-group.tsx +215 -0
  44. package/src/components/kbd/index.ts +2 -0
  45. package/src/components/kbd/kbd.tsx +43 -0
  46. package/src/components/label/index.ts +7 -0
  47. package/src/components/label/label.tsx +99 -0
  48. package/src/components/lightbox/index.ts +24 -0
  49. package/src/components/lightbox/lightbox.tsx +245 -0
  50. package/src/components/list-item/index.ts +20 -0
  51. package/src/components/list-item/list-item.tsx +263 -0
  52. package/src/components/media-list-item/index.ts +20 -0
  53. package/src/components/media-list-item/media-list-item.tsx +169 -0
  54. package/src/components/notification-center/index.ts +39 -0
  55. package/src/components/notification-center/notification-center.tsx +558 -0
  56. package/src/components/panel-header/index.ts +22 -0
  57. package/src/components/panel-header/panel-header.tsx +199 -0
  58. package/src/components/popover/index.ts +30 -0
  59. package/src/components/popover/popover.tsx +248 -0
  60. package/src/components/preview-shell/index.ts +2 -0
  61. package/src/components/preview-shell/preview-shell.tsx +51 -0
  62. package/src/components/quoted-message-card/index.ts +36 -0
  63. package/src/components/quoted-message-card/quoted-message-card.tsx +391 -0
  64. package/src/components/radio-group/index.ts +6 -0
  65. package/src/components/radio-group/radio-group.tsx +119 -0
  66. package/src/components/section-header/index.ts +16 -0
  67. package/src/components/section-header/section-header.tsx +117 -0
  68. package/src/components/section-label/index.ts +2 -0
  69. package/src/components/section-label/section-label.tsx +38 -0
  70. package/src/components/segmented-control/index.ts +12 -0
  71. package/src/components/segmented-control/segmented-control.tsx +129 -0
  72. package/src/components/select/index.ts +40 -0
  73. package/src/components/select/select.tsx +382 -0
  74. package/src/components/spinner/index.ts +2 -0
  75. package/src/components/spinner/spinner.tsx +228 -0
  76. package/src/components/status/index.ts +2 -0
  77. package/src/components/status/status.tsx +152 -0
  78. package/src/components/tabs/index.ts +24 -0
  79. package/src/components/tabs/tabs.tsx +417 -0
  80. package/src/components/text/index.ts +2 -0
  81. package/src/components/text/text.tsx +130 -0
  82. package/src/components/textarea/index.ts +2 -0
  83. package/src/components/textarea/textarea.tsx +136 -0
  84. package/src/components/toggle-group/index.ts +12 -0
  85. package/src/components/toggle-group/toggle-group.tsx +111 -0
  86. package/src/components/tooltip/index.ts +14 -0
  87. package/src/components/tooltip/tooltip.tsx +186 -0
  88. package/src/fonts/brutal.css +2 -0
  89. package/src/index.ts +404 -0
  90. package/src/legacy/AttentionDot.tsx +28 -0
  91. package/src/legacy/AvatarListRow.tsx +87 -0
  92. package/src/legacy/AvatarSlot.tsx +157 -0
  93. package/src/legacy/Badge.tsx +95 -0
  94. package/src/legacy/Banner.tsx +74 -0
  95. package/src/legacy/BottomSheet.tsx +76 -0
  96. package/src/legacy/CheckMarker.tsx +72 -0
  97. package/src/legacy/Checkbox.tsx +81 -0
  98. package/src/legacy/ConversationPreviewCard.tsx +169 -0
  99. package/src/legacy/DismissBackdrop.tsx +68 -0
  100. package/src/legacy/EmptyState.tsx +63 -0
  101. package/src/legacy/ExperimentalBadge.tsx +19 -0
  102. package/src/legacy/FormField.tsx +106 -0
  103. package/src/legacy/KeyValueRow.tsx +67 -0
  104. package/src/legacy/Lightbox.tsx +119 -0
  105. package/src/legacy/NotificationCenter.tsx +217 -0
  106. package/src/legacy/PanelHeader.tsx +94 -0
  107. package/src/legacy/PreviewShell.tsx +40 -0
  108. package/src/legacy/QuotedMessageCard.tsx +181 -0
  109. package/src/legacy/ReorderablePanelTabs.tsx +151 -0
  110. package/src/legacy/SandboxedPreviewFrame.tsx +59 -0
  111. package/src/legacy/SearchInput.tsx +23 -0
  112. package/src/legacy/SectionEyebrow.tsx +60 -0
  113. package/src/legacy/SectionHeader.tsx +68 -0
  114. package/src/legacy/SegmentedControl.tsx +74 -0
  115. package/src/legacy/SlugInput.tsx +29 -0
  116. package/src/legacy/Spinner.tsx +89 -0
  117. package/src/legacy/StatusDot.tsx +51 -0
  118. package/src/legacy/SurfaceListItem.tsx +32 -0
  119. package/src/legacy/Textarea.tsx +137 -0
  120. package/src/legacy/button.tsx +100 -0
  121. package/src/legacy/context-menu-divider.tsx +12 -0
  122. package/src/legacy/menu-item.tsx +66 -0
  123. package/src/legacy/selection-popover.tsx +212 -0
  124. package/src/legacy/server-switcher-menu.tsx +203 -0
  125. package/src/legacy/source.css +240 -0
  126. package/src/lib/cn.ts +1 -0
  127. package/src/lib/styled-props.ts +11 -0
  128. package/src/lib/theme/theme-context.ts +28 -0
  129. package/src/lib/theme/theme-provider.tsx +181 -0
  130. package/src/lib/theme/use-theme.ts +12 -0
  131. package/src/lib/tv.ts +29 -0
  132. package/src/lib/without-children.ts +6 -0
  133. package/src/styles.css +467 -0
  134. package/src/wip.ts +255 -0
@@ -0,0 +1,89 @@
1
+ import type { ComponentProps } from "react";
2
+ import { cn } from "../lib/cn.ts";
3
+
4
+ type SpinnerSize = "sm" | "lg";
5
+
6
+ type SpinnerProps = ComponentProps<"span"> & {
7
+ size?: SpinnerSize;
8
+ };
9
+
10
+ const SPINNER_STYLE = `
11
+ @keyframes sitefire-braille-spinner {
12
+ 0%,
13
+ 9.99% {
14
+ content: "⠋";
15
+ }
16
+ 10%,
17
+ 19.99% {
18
+ content: "⠙";
19
+ }
20
+ 20%,
21
+ 29.99% {
22
+ content: "⠹";
23
+ }
24
+ 30%,
25
+ 39.99% {
26
+ content: "⠸";
27
+ }
28
+ 40%,
29
+ 49.99% {
30
+ content: "⠼";
31
+ }
32
+ 50%,
33
+ 59.99% {
34
+ content: "⠴";
35
+ }
36
+ 60%,
37
+ 69.99% {
38
+ content: "⠦";
39
+ }
40
+ 70%,
41
+ 79.99% {
42
+ content: "⠧";
43
+ }
44
+ 80%,
45
+ 89.99% {
46
+ content: "⠇";
47
+ }
48
+ 90%,
49
+ 100% {
50
+ content: "⠏";
51
+ }
52
+ }
53
+
54
+ [data-slot="spinner"] [data-spinner-glyph]::before {
55
+ content: "⠋";
56
+ animation: sitefire-braille-spinner 800ms steps(1, end) infinite;
57
+ }
58
+
59
+ @media (prefers-reduced-motion: reduce) {
60
+ [data-slot="spinner"] [data-spinner-glyph]::before {
61
+ animation-duration: 1600ms;
62
+ }
63
+ }
64
+ `;
65
+
66
+ export default function Spinner({
67
+ className,
68
+ size = "sm",
69
+ "aria-label": ariaLabel = "Loading",
70
+ ...props
71
+ }: SpinnerProps) {
72
+ return (
73
+ <span
74
+ data-slot="spinner"
75
+ data-size={size}
76
+ role="status"
77
+ aria-label={ariaLabel}
78
+ className={cn(
79
+ "relative inline-flex shrink-0 items-center justify-center font-mono leading-none text-current tabular-nums",
80
+ size === "sm" ? "h-4 text-base" : "h-6 text-xl",
81
+ className,
82
+ )}
83
+ {...props}
84
+ >
85
+ <style>{SPINNER_STYLE}</style>
86
+ <span data-spinner-glyph="" aria-hidden="true" className="inline-block min-w-[1ch]" />
87
+ </span>
88
+ );
89
+ }
@@ -0,0 +1,51 @@
1
+ import type { HTMLAttributes } from "react";
2
+
3
+ type AgentActivity = "online" | "thinking" | "working" | "error" | "offline";
4
+
5
+ type SpanPassthroughProps = HTMLAttributes<HTMLSpanElement> &
6
+ Record<`data-${string}`, string | undefined>;
7
+
8
+ export interface StatusDotProps extends Omit<SpanPassthroughProps, "children"> {
9
+ activity?: AgentActivity;
10
+ tone?: string;
11
+ size?: "sm" | "md" | "lg";
12
+ pulse?: boolean;
13
+ }
14
+
15
+ const SIZE_CLASS: Record<NonNullable<StatusDotProps["size"]>, string> = {
16
+ sm: "size-2",
17
+ md: "size-2.5",
18
+ lg: "size-[11px]",
19
+ };
20
+
21
+ function getActivityDotClass(activity: AgentActivity): string {
22
+ switch (activity) {
23
+ case "online":
24
+ return "bg-brutal-lime";
25
+ case "thinking":
26
+ case "working":
27
+ return "bg-brutal-yellow animate-pulse";
28
+ case "error":
29
+ return "bg-brutal-orange";
30
+ case "offline":
31
+ default:
32
+ return "bg-gray-400";
33
+ }
34
+ }
35
+
36
+ export default function StatusDot({
37
+ activity,
38
+ tone,
39
+ size = "md",
40
+ pulse = false,
41
+ className,
42
+ ...rest
43
+ }: StatusDotProps) {
44
+ const colorClass = activity ? getActivityDotClass(activity) : (tone ?? "bg-gray-400");
45
+ return (
46
+ <span
47
+ {...rest}
48
+ className={`inline-block shrink-0 rounded-full border border-black ${SIZE_CLASS[size]} ${colorClass} ${pulse ? "animate-pulse" : ""} ${className ?? ""}`}
49
+ />
50
+ );
51
+ }
@@ -0,0 +1,32 @@
1
+ import type { HTMLAttributes, ReactNode } from "react";
2
+ import { cn } from "../lib/cn.ts";
3
+
4
+ export default function SurfaceListItem({
5
+ children,
6
+ selected = false,
7
+ interactive = true,
8
+ className = "",
9
+ ...props
10
+ }: HTMLAttributes<HTMLDivElement> & {
11
+ children: ReactNode;
12
+ selected?: boolean;
13
+ interactive?: boolean;
14
+ }) {
15
+ return (
16
+ <div
17
+ className={cn(
18
+ "border-2 px-4 py-3 transition-colors",
19
+ selected
20
+ ? "border-black bg-brutal-cyan/15 shadow-brutal-sm"
21
+ : [
22
+ "border-black/30 bg-white",
23
+ interactive ? "hover:border-black hover:shadow-brutal-sm" : "",
24
+ ].join(" "),
25
+ className,
26
+ )}
27
+ {...props}
28
+ >
29
+ {children}
30
+ </div>
31
+ );
32
+ }
@@ -0,0 +1,137 @@
1
+ import { forwardRef, type ReactNode, type TextareaHTMLAttributes } from "react";
2
+
3
+ /**
4
+ * Canonical brutal-style textarea primitive with error + counter support.
5
+ *
6
+ * Replaces bare `<textarea className="input-brutal" />` + ad-hoc error
7
+ * state + character-counter spans that drift across forms (SettingsPanel,
8
+ * CreateChannelDialog, ReportIssueDialog, PreJoinAgreementSection, etc).
9
+ *
10
+ * cindyz 2026-05-12 #engineering:89667b54 msg=dcb9d29b asked for this
11
+ * after cross's pre-join agreement PR introduced a feature-local
12
+ * red-border textarea error state that didn't exist anywhere else in the
13
+ * app. This primitive locks the error visual so future callsites don't
14
+ * re-invent it.
15
+ *
16
+ * **Visual contract:**
17
+ * - Default: `input-brutal` (border-2 black, white bg, shadow-brutal-sm, focus shadow-brutal)
18
+ * - Error: red border + red ring + coral-tinted bg (bg-brutal-red/5)
19
+ * - Counter: bottom-right, red when over limit
20
+ * - Message: below the field, red when error
21
+ *
22
+ * **Why not just a CSS variant on `.input-brutal`?**
23
+ * Textarea callsites almost always need counter + error message together;
24
+ * coordinating those through className alone means every callsite
25
+ * re-writes the counter/error layout. Wrapping in a component is cheaper.
26
+ *
27
+ * Inputs (not textarea) follow the same error visual but are usually
28
+ * small enough that a dedicated `BrutalInput` wrapper is overkill — use
29
+ * `className={error ? "input-brutal !border-brutal-red ring-2 ring-brutal-red/60" : "input-brutal"}`
30
+ * inline, or extend this primitive if a second form-field callsite
31
+ * emerges.
32
+ *
33
+ * @example
34
+ * ```tsx
35
+ * <Textarea
36
+ * value={body}
37
+ * onChange={(e) => setBody(e.target.value)}
38
+ * maxLength={500}
39
+ * error={body.length > 500 ? "Agreement body must be 500 characters or fewer" : undefined}
40
+ * rows={6}
41
+ * placeholder="Type your server's join agreement…"
42
+ * />
43
+ * ```
44
+ */
45
+
46
+ export interface TextareaProps extends Omit<
47
+ TextareaHTMLAttributes<HTMLTextAreaElement>,
48
+ "children"
49
+ > {
50
+ /** Error message — renders red border + ring + message below. When
51
+ * empty string or undefined, field is in normal state. */
52
+ error?: string | null;
53
+ /** Show a character counter at the bottom-right. Uses `maxLength` if
54
+ * not explicitly set. Counter turns red once current length exceeds
55
+ * maxLength (or when `error` is set). */
56
+ showCounter?: boolean;
57
+ /** Optional hint / helper text shown below field when not in error
58
+ * state. */
59
+ hint?: ReactNode;
60
+ /** Additional classes — appended to the `<textarea>` element. Use for
61
+ * layout tweaks (e.g. `!min-h-32`). */
62
+ className?: string;
63
+ /** Additional classes on the outer wrapper `<div>`. Use for margin /
64
+ * width tweaks. */
65
+ wrapperClassName?: string;
66
+ /** Custom length function — defaults to `value.length` (UTF-16 code
67
+ * units). Pass `(v) => Array.from(v).length` for Unicode code-point
68
+ * counting (correct for emoji / surrogate pairs). */
69
+ getLength?: (value: string) => number;
70
+ /** Override the displayed count directly (e.g. when the parent already
71
+ * computes a code-point count). Takes precedence over `getLength`. */
72
+ count?: number;
73
+ /** Counter display limit — falls back to native `maxLength` when not
74
+ * set. Use when you want soft validation (counter turns red over
75
+ * limit, but no native browser truncation). Common with
76
+ * `getLength`/`count` for Unicode-correct enforcement. */
77
+ limit?: number;
78
+ }
79
+
80
+ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(
81
+ {
82
+ error,
83
+ showCounter,
84
+ hint,
85
+ className = "",
86
+ wrapperClassName = "",
87
+ maxLength,
88
+ value,
89
+ defaultValue,
90
+ getLength,
91
+ count,
92
+ limit,
93
+ ...rest
94
+ },
95
+ ref,
96
+ ) {
97
+ const hasError = !!error;
98
+ const rawValue =
99
+ typeof value === "string" ? value : typeof defaultValue === "string" ? defaultValue : "";
100
+ const length = count !== undefined ? count : getLength ? getLength(rawValue) : rawValue.length;
101
+ const counterLimit = limit ?? maxLength;
102
+ const overLimit = counterLimit !== undefined && length > counterLimit;
103
+
104
+ const stateClass = hasError ? "!border-brutal-red ring-2 ring-brutal-red/60 bg-brutal-red/5" : "";
105
+
106
+ return (
107
+ <div className={`flex flex-col gap-1 ${wrapperClassName}`.trim()}>
108
+ <textarea
109
+ ref={ref}
110
+ value={value}
111
+ defaultValue={defaultValue}
112
+ maxLength={maxLength}
113
+ aria-invalid={hasError || undefined}
114
+ className={`input-brutal w-full ${stateClass} ${className}`.trim()}
115
+ {...rest}
116
+ />
117
+ <div className="flex items-start justify-between gap-3 text-xs">
118
+ <div
119
+ className={`min-w-0 flex-1 ${hasError ? "font-bold text-brutal-red" : "text-black/60"}`}
120
+ >
121
+ {hasError ? error : hint}
122
+ </div>
123
+ {showCounter && counterLimit !== undefined && (
124
+ <div
125
+ className={`shrink-0 font-mono tabular-nums ${
126
+ overLimit || hasError ? "font-bold text-brutal-red" : "text-black/50"
127
+ }`}
128
+ >
129
+ {length}/{counterLimit}
130
+ </div>
131
+ )}
132
+ </div>
133
+ </div>
134
+ );
135
+ });
136
+
137
+ export default Textarea;
@@ -0,0 +1,100 @@
1
+ import type { ButtonHTMLAttributes, ReactNode } from "react";
2
+
3
+ type ButtonSize = "xs" | "sm" | "md" | "lg";
4
+ type ButtonShape = "icon" | "text" | "iconText";
5
+
6
+ /**
7
+ * Button tones map to the brutal palette. Callers say `tone="pink"`,
8
+ * never `className="bg-brutal-pink"`. Adding a new tone means adding a
9
+ * row here, not a one-off override at the callsite.
10
+ *
11
+ * Hidden context (PR #1905 / #proj-theme:ac79cf20 stdrc msg=6ce4d8bf):
12
+ * the prior API exposed size+shape but left bg/color to caller
13
+ * className. Tightening the boundary makes theme swaps possible without
14
+ * re-auditing every callsite.
15
+ */
16
+ export const BUTTON_TONE_CLASSES = {
17
+ white: "bg-white text-black",
18
+ yellow: "bg-brutal-yellow text-black",
19
+ pink: "bg-brutal-pink text-black",
20
+ cyan: "bg-brutal-cyan text-black",
21
+ lavender: "bg-brutal-lavender text-black",
22
+ orange: "bg-brutal-orange text-black",
23
+ lime: "bg-brutal-lime text-black",
24
+ red: "bg-brutal-red text-black",
25
+ stone: "bg-brutal-stone text-black",
26
+ } as const;
27
+
28
+ export type ButtonTone = keyof typeof BUTTON_TONE_CLASSES;
29
+
30
+ const SIZE_SHAPE_CLASSES: Record<ButtonSize, Record<ButtonShape, string>> = {
31
+ xs: {
32
+ icon: "size-6 text-[11px]",
33
+ text: "h-6 px-2 text-[11px]",
34
+ iconText: "h-6 gap-1 px-2 text-[11px]",
35
+ },
36
+ sm: {
37
+ icon: "size-7 text-xs",
38
+ text: "h-7 px-2.5 text-xs",
39
+ iconText: "h-7 gap-1.5 px-2.5 text-xs",
40
+ },
41
+ md: {
42
+ icon: "size-8 text-sm",
43
+ text: "h-8 px-3 text-sm",
44
+ iconText: "h-8 gap-2 px-3 text-sm",
45
+ },
46
+ lg: {
47
+ icon: "size-10 text-sm",
48
+ text: "h-10 px-4 text-sm",
49
+ iconText: "h-10 gap-2 px-4 text-sm",
50
+ },
51
+ };
52
+
53
+ export function brutalButtonClassName({
54
+ size = "sm",
55
+ shape = "text",
56
+ tone = "white",
57
+ className = "",
58
+ }: {
59
+ size?: ButtonSize;
60
+ shape?: ButtonShape;
61
+ tone?: ButtonTone;
62
+ className?: string;
63
+ } = {}) {
64
+ return [
65
+ "btn-brutal-sm inline-flex shrink-0 items-center justify-center font-bold leading-none",
66
+ BUTTON_TONE_CLASSES[tone],
67
+ SIZE_SHAPE_CLASSES[size][shape],
68
+ className,
69
+ ]
70
+ .filter(Boolean)
71
+ .join(" ");
72
+ }
73
+
74
+ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
75
+ size?: ButtonSize;
76
+ shape?: ButtonShape;
77
+ /** Background tone from the brutal palette. Defaults to `white`. */
78
+ tone?: ButtonTone;
79
+ children?: ReactNode;
80
+ }
81
+
82
+ export default function Button({
83
+ size = "sm",
84
+ shape = "text",
85
+ tone,
86
+ className = "",
87
+ type = "button",
88
+ children,
89
+ ...props
90
+ }: ButtonProps) {
91
+ return (
92
+ <button
93
+ type={type}
94
+ className={brutalButtonClassName({ size, shape, tone, className })}
95
+ {...props}
96
+ >
97
+ {children}
98
+ </button>
99
+ );
100
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Canonical divider between groups of context-menu items. Per CLAUDE.md
3
+ * "Menus & Dropdowns": `border-t-2 border-black` with no margin — spacing
4
+ * between items is controlled by item padding, not divider margins.
5
+ *
6
+ * Centralized so context menus (Sidebar channel / DM / agent menus today,
7
+ * future surfaces) don't re-roll the same `<div className="border-t-2
8
+ * border-black" />` literal. stdrc 2026-05-24 audit follow-up.
9
+ */
10
+ export default function ContextMenuDivider() {
11
+ return <div className="border-t-2 border-black" />;
12
+ }
@@ -0,0 +1,66 @@
1
+ import type { ButtonHTMLAttributes, ReactNode } from "react";
2
+
3
+ /**
4
+ * Pure-action menu row primitive.
5
+ *
6
+ * Shared by the family of "click → run an action" menus across the app:
7
+ * - Right-click context menus (Sidebar channel/DM/agent, MessageItem,
8
+ * SavedPanel, ThreadsInbox)
9
+ * - Sidebar add-popover menus (Add channel → Join existing / Create new …)
10
+ * - Mark-as-read / pin / archive / delete rows
11
+ *
12
+ * Origin: stdrc 2026-05-25 #proj-theme:ac79cf20 msg=dae4c262 +
13
+ * msg=245a96f0 + msg=8fdf9cba. Inventory pass found ~30 hand-rolled
14
+ * `flex w-full … hover:bg-brutal-yellow transition-colors` rows that
15
+ * had drifted on a few axes (`uppercase tracking-wide` on Sidebar
16
+ * sort/add popovers, density modifiers in some places but not others).
17
+ *
18
+ * Distinct from `SelectionPopover` — that primitive is for single-select
19
+ * dropdowns where the trailing ✓ marks the chosen option. This row is
20
+ * pure action: no ✓, no selected-state fill. When state-changing items
21
+ * like "Mark as Read ↔ Mark as Unread" or "Pin ↔ Unpin" toggle their
22
+ * label based on current state, that's just label state, not a toggle
23
+ * UI affordance (stdrc msg=245a96f0).
24
+ *
25
+ * Hover token: `bg-brutal-yellow/30` matches SegmentedControl unselected
26
+ * hover + SelectionPopover row hover, so all selection-family and
27
+ * action-menu surfaces share a single hover color. Right-click menus
28
+ * intentionally do NOT differentiate destructive vs non-destructive at
29
+ * the row level — every irreversible action is gated by a
30
+ * `ConfirmDialog`, so the menu itself stays visually uniform (stdrc
31
+ * msg=60e789d1: "Agent 的 Stop 和 Restart 菜单也没有特别的 hover 样式
32
+ * … 因为它反正会弹出一个窗口提示,所以不需要在菜单上做特别呈现").
33
+ */
34
+ export type MenuItemProps = ButtonHTMLAttributes<HTMLButtonElement> & {
35
+ /** Optional leading icon. Conventionally a lucide icon at `size={14}`. */
36
+ icon?: ReactNode;
37
+ /** Optional right-aligned slot (e.g. shortcut hint). */
38
+ trailing?: ReactNode;
39
+ };
40
+
41
+ export default function MenuItem({
42
+ icon,
43
+ trailing,
44
+ className = "",
45
+ type = "button",
46
+ role = "menuitem",
47
+ children,
48
+ ...rest
49
+ }: MenuItemProps) {
50
+ // Compact-viewport density (`[@media(max-height:600px)]:py-1`) is
51
+ // applied unconditionally — harmless on tall viewports and keeps
52
+ // Sidebar / MessageItem / SavedPanel context menus visually uniform
53
+ // in small viewports without per-callsite opt-in.
54
+ const base =
55
+ "flex w-full items-center gap-2 px-3 py-2 [@media(max-height:600px)]:py-1 text-sm text-left transition-colors";
56
+ const tone =
57
+ "font-medium text-black hover:bg-brutal-yellow/30 disabled:cursor-not-allowed disabled:text-black/30";
58
+
59
+ return (
60
+ <button type={type} role={role} className={`${base} ${tone} ${className}`} {...rest}>
61
+ {icon}
62
+ <span className="min-w-0 flex-1 truncate">{children}</span>
63
+ {trailing}
64
+ </button>
65
+ );
66
+ }
@@ -0,0 +1,212 @@
1
+ import type { KeyboardEvent, ReactNode } from "react";
2
+ import { Button as BaseButton } from "@base-ui/react/button";
3
+ import { Check } from "lucide-react";
4
+ import { tv } from "../lib/tv.ts";
5
+ import { useThemeFamily } from "../lib/theme/use-theme.ts";
6
+
7
+ // Single-select popover content: a header, an optional search box, and a list of
8
+ // checkable rows (the trailing ✓ is the selected indicator; hover is the shared
9
+ // selection-family yellow/30). Presentational — the original had no portal/effect,
10
+ // so place it inside a base-ui Popover.Positioner at the callsite. `shadow-brutal`
11
+ // maps to --shadow-hover; the search box reuses the brutal field skin (--shadow).
12
+ const selectionPopover = tv({
13
+ slots: {
14
+ root: ["min-w-[220px]"],
15
+ header: ["flex items-center justify-between border-b border-black px-3 py-2"],
16
+ title: ["text-[10px] font-bold uppercase tracking-wide text-black/60"],
17
+ clear: ["text-[10px] font-bold uppercase tracking-wide text-black/50 hover:text-black"],
18
+ searchWrap: ["border-b border-black px-2 py-2"],
19
+ searchInput: [
20
+ "w-full border-2 border-black bg-white px-2 py-1",
21
+ "font-mono text-xs shadow-[var(--shadow)]",
22
+ "focus:shadow-[var(--shadow-hover)] focus:outline-none",
23
+ ],
24
+ list: ["max-h-64 overflow-y-auto"],
25
+ empty: ["px-3 py-3 text-center font-mono text-[11px] text-black/40"],
26
+ row: [
27
+ "flex h-9 w-full items-center justify-between gap-2 overflow-hidden px-3",
28
+ "border-b text-left text-xs font-bold last:border-b-0",
29
+ ],
30
+ rowMain: ["flex min-w-0 items-center gap-2"],
31
+ leading: ["flex size-5 shrink-0 items-center justify-center"],
32
+ label: ["truncate leading-6"],
33
+ check: ["shrink-0"],
34
+ },
35
+ variants: {
36
+ theme: {
37
+ brutal: {
38
+ root: ["border-2 border-black bg-white shadow-[var(--shadow-hover)]"],
39
+ row: ["border-black/10"],
40
+ },
41
+ // Elegant styling is deferred; see the component TODO.
42
+ elegant: {},
43
+ },
44
+ disabled: {
45
+ true: {
46
+ row: "cursor-not-allowed bg-white text-black/30",
47
+ },
48
+ false: {
49
+ row: "bg-white text-black hover:bg-brutal-yellow/30",
50
+ },
51
+ },
52
+ italic: {
53
+ true: {
54
+ label: "-mx-1 whitespace-nowrap px-1 italic text-black/70",
55
+ },
56
+ false: {
57
+ label: "",
58
+ },
59
+ },
60
+ },
61
+ defaultVariants: {
62
+ disabled: false,
63
+ italic: false,
64
+ },
65
+ });
66
+
67
+ export type SelectionPopoverOption = {
68
+ key: string;
69
+ checked: boolean;
70
+ label: string;
71
+ onClick: () => void;
72
+ disabled?: boolean;
73
+ italic?: boolean;
74
+ avatar?: ReactNode;
75
+ reserveLeadingSlot?: boolean;
76
+ leading?: ReactNode;
77
+ };
78
+
79
+ type SelectionPopoverBaseProps = {
80
+ title: string;
81
+ options: SelectionPopoverOption[];
82
+ showClear?: boolean;
83
+ onClear?: () => void;
84
+ showHeader?: boolean;
85
+ emptyLabel?: string;
86
+ className?: string;
87
+ };
88
+
89
+ type SearchableProps = SelectionPopoverBaseProps & {
90
+ searchable: true;
91
+ search: string;
92
+ onSearchChange: (value: string) => void;
93
+ searchPlaceholder?: string;
94
+ onInputKeyDown?: (
95
+ event: KeyboardEvent<HTMLInputElement>,
96
+ options: SelectionPopoverOption[],
97
+ ) => void;
98
+ };
99
+
100
+ type PlainProps = SelectionPopoverBaseProps & {
101
+ searchable?: false;
102
+ search?: never;
103
+ onSearchChange?: never;
104
+ searchPlaceholder?: never;
105
+ onInputKeyDown?: never;
106
+ };
107
+
108
+ export type SelectionPopoverProps = SearchableProps | PlainProps;
109
+
110
+ function SelectionPopoverOptionRow({
111
+ checked,
112
+ onClick,
113
+ label,
114
+ disabled = false,
115
+ italic = false,
116
+ avatar = null,
117
+ reserveLeadingSlot = false,
118
+ leading = null,
119
+ }: Omit<SelectionPopoverOption, "key">) {
120
+ const theme = useThemeFamily();
121
+ const leadingContent = leading ?? avatar;
122
+ const {
123
+ row,
124
+ rowMain,
125
+ leading: leadingSlot,
126
+ label: labelSlot,
127
+ check,
128
+ } = selectionPopover({
129
+ theme,
130
+ disabled,
131
+ italic,
132
+ });
133
+ return (
134
+ <BaseButton
135
+ type="button"
136
+ disabled={disabled}
137
+ onClick={onClick}
138
+ data-slot="selection-popover-row"
139
+ className={row()}
140
+ >
141
+ <span className={rowMain()}>
142
+ {(reserveLeadingSlot || leadingContent) && (
143
+ <span className={leadingSlot()}>{leadingContent}</span>
144
+ )}
145
+ <span className={labelSlot()}>{label}</span>
146
+ </span>
147
+ {checked && <Check size={12} className={check()} />}
148
+ </BaseButton>
149
+ );
150
+ }
151
+
152
+ export function SelectionPopover(props: SelectionPopoverProps) {
153
+ const theme = useThemeFamily();
154
+ const {
155
+ title,
156
+ options,
157
+ showClear = false,
158
+ onClear,
159
+ showHeader = true,
160
+ emptyLabel = "No matches",
161
+ className,
162
+ } = props;
163
+ const {
164
+ root,
165
+ header,
166
+ title: titleSlot,
167
+ clear,
168
+ searchWrap,
169
+ searchInput,
170
+ list,
171
+ empty,
172
+ } = selectionPopover({ theme });
173
+
174
+ return (
175
+ <div data-slot="selection-popover" className={root({ className })}>
176
+ {showHeader && (
177
+ <div className={header()}>
178
+ <span className={titleSlot()}>{title}</span>
179
+ {showClear && onClear && (
180
+ <BaseButton type="button" onClick={onClear} className={clear()}>
181
+ Clear
182
+ </BaseButton>
183
+ )}
184
+ </div>
185
+ )}
186
+ {props.searchable && (
187
+ <div className={searchWrap()}>
188
+ {/* Reuses the brutal field skin (input-brutal → --shadow / --shadow-hover). */}
189
+ <input
190
+ type="text"
191
+ aria-label={props.searchPlaceholder ?? title}
192
+ value={props.search}
193
+ onChange={(event) => props.onSearchChange(event.target.value)}
194
+ onKeyDown={(event) => {
195
+ event.stopPropagation();
196
+ props.onInputKeyDown?.(event, options);
197
+ }}
198
+ placeholder={props.searchPlaceholder ?? "Search…"}
199
+ className={searchInput()}
200
+ />
201
+ </div>
202
+ )}
203
+ <div className={list()}>
204
+ {options.length === 0 ? (
205
+ <div className={empty()}>{emptyLabel}</div>
206
+ ) : (
207
+ options.map(({ key, ...option }) => <SelectionPopoverOptionRow key={key} {...option} />)
208
+ )}
209
+ </div>
210
+ </div>
211
+ );
212
+ }