proto-plugin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/AGENTS.md +350 -0
  2. package/README.md +279 -0
  3. package/components.json +18 -0
  4. package/package.json +73 -0
  5. package/scripts/download-mobbin-reference-images.mjs +112 -0
  6. package/scripts/lib/host-config.example.sh +48 -0
  7. package/scripts/lib/host-config.sh +25 -0
  8. package/scripts/lib/resolve-host-root.mjs +18 -0
  9. package/scripts/lib/source-path.sh +49 -0
  10. package/scripts/link-source-db.sh +128 -0
  11. package/scripts/link-source.sh +40 -0
  12. package/scripts/publish-package.sh +147 -0
  13. package/scripts/share-prototype.sh +279 -0
  14. package/scripts/sync-from-source.sh +74 -0
  15. package/scripts/verify-prototype-component-ids.mjs +151 -0
  16. package/scripts/verify-prototype-preview-states.mjs +135 -0
  17. package/src/app.ts +4 -0
  18. package/src/components/platform-ui/code-block.tsx +21 -0
  19. package/src/components/platform-ui/controls-panel-options.tsx +71 -0
  20. package/src/components/platform-ui/controls-panel-select.tsx +438 -0
  21. package/src/components/platform-ui/empty-state.tsx +15 -0
  22. package/src/components/platform-ui/icon-button.tsx +18 -0
  23. package/src/components/platform-ui/panel-select.tsx +123 -0
  24. package/src/components/platform-ui/panel.tsx +97 -0
  25. package/src/components/platform-ui/review-dropdown-menu.tsx +35 -0
  26. package/src/components/platform-ui/sidebar.tsx +293 -0
  27. package/src/components/platform-ui/toolbar-icon-button.tsx +34 -0
  28. package/src/components/platform-ui/toolbar.tsx +37 -0
  29. package/src/components/prototype-gallery-client.tsx +57 -0
  30. package/src/components/prototype-provider.tsx +20 -0
  31. package/src/components/prototypes/mini-pill-label.tsx +37 -0
  32. package/src/components/prototypes/prototype-change-log-panel.tsx +207 -0
  33. package/src/components/prototypes/prototype-comment-provider.tsx +61 -0
  34. package/src/components/prototypes/prototype-comment-toolbar.tsx +65 -0
  35. package/src/components/prototypes/prototype-control.tsx +31 -0
  36. package/src/components/prototypes/prototype-controls.tsx +553 -0
  37. package/src/components/prototypes/prototype-create-exploration-modal.tsx +117 -0
  38. package/src/components/prototypes/prototype-create-state-modal.tsx +117 -0
  39. package/src/components/prototypes/prototype-default-state-map-page.tsx +66 -0
  40. package/src/components/prototypes/prototype-design-brief-modal.tsx +128 -0
  41. package/src/components/prototypes/prototype-design-brief-panel.tsx +525 -0
  42. package/src/components/prototypes/prototype-design-context-panel.tsx +65 -0
  43. package/src/components/prototypes/prototype-floating-pill.module.scss +1341 -0
  44. package/src/components/prototypes/prototype-mobbin-gallery.tsx +330 -0
  45. package/src/components/prototypes/prototype-reference-docs.tsx +222 -0
  46. package/src/components/prototypes/prototype-review-chrome.module.scss +630 -0
  47. package/src/components/prototypes/prototype-review-chrome.tsx +1241 -0
  48. package/src/components/prototypes/prototype-review-dropdown-menu-item.tsx +1 -0
  49. package/src/components/prototypes/prototype-review-dropdown-menu-separator.tsx +1 -0
  50. package/src/components/prototypes/prototype-review-panel-menu-item.tsx +1 -0
  51. package/src/components/prototypes/prototype-review-panel-menu-separator.tsx +1 -0
  52. package/src/components/prototypes/prototype-review-panel-select.tsx +4 -0
  53. package/src/components/prototypes/prototype-review-sidebar.tsx +167 -0
  54. package/src/components/prototypes/prototype-share-link-restore.tsx +75 -0
  55. package/src/components/prototypes/prototype-share-mode.tsx +206 -0
  56. package/src/components/prototypes/prototype-spec-panel-content.tsx +538 -0
  57. package/src/components/prototypes/prototype-state-canvas-overlay.module.scss +336 -0
  58. package/src/components/prototypes/prototype-state-canvas-overlay.tsx +1023 -0
  59. package/src/components/prototypes/prototype-state-canvas.tsx +38 -0
  60. package/src/components/prototypes/prototype-state-screenshot-preview.tsx +47 -0
  61. package/src/components/prototypes/prototype-target.tsx +120 -0
  62. package/src/components/prototypes/prototype-tweak-highlight.tsx +132 -0
  63. package/src/components/prototypes/prototype-tweaks.tsx +48 -0
  64. package/src/components/prototypes/prototype-variant-explorer.tsx +347 -0
  65. package/src/components/prototypes/prototype-variant-rationale-panel.tsx +49 -0
  66. package/src/components/prototypes/prototype-variant-tabs.tsx +207 -0
  67. package/src/components/prototypes/prototype-vercel-preview-modal.tsx +120 -0
  68. package/src/components/prototypes/state-map-annotation-tooltip.module.scss +89 -0
  69. package/src/components/prototypes/state-map-annotation-tooltip.tsx +137 -0
  70. package/src/components/prototypes/state-map-wireframe-highlight.module.scss +25 -0
  71. package/src/components/prototypes/state-map-wireframe-highlight.tsx +82 -0
  72. package/src/components/shell/prototype-gallery-shell.tsx +79 -0
  73. package/src/components/shell/prototype-screenshot-capture.tsx +45 -0
  74. package/src/components/shell/prototype-shell.module.scss +194 -0
  75. package/src/components/shell/prototype-shell.tsx +160 -0
  76. package/src/components/shell/prototype-state-screenshot-capture.tsx +68 -0
  77. package/src/components/ui/button.tsx +58 -0
  78. package/src/components/ui/dialog.module.scss +81 -0
  79. package/src/components/ui/dialog.tsx +127 -0
  80. package/src/components/ui/dropdown-menu.tsx +200 -0
  81. package/src/components/ui/input.tsx +22 -0
  82. package/src/components/ui/label.tsx +26 -0
  83. package/src/components/ui/option-select.tsx +81 -0
  84. package/src/components/ui/scroll-area.tsx +48 -0
  85. package/src/components/ui/separator.tsx +31 -0
  86. package/src/components/ui/slider.tsx +28 -0
  87. package/src/components/ui/switch.tsx +29 -0
  88. package/src/components/ui/tabs.tsx +55 -0
  89. package/src/components/ui/toggle-group.tsx +80 -0
  90. package/src/components/ui/tooltip.tsx +32 -0
  91. package/src/config/create-prototype-gallery-page.tsx +65 -0
  92. package/src/config/create-prototype-host-app.tsx +121 -0
  93. package/src/config/create-prototype-page.tsx +41 -0
  94. package/src/config/create-prototype-site-layout.tsx +28 -0
  95. package/src/config/define-prototype-config.ts +7 -0
  96. package/src/config/prototype-icon.tsx +25 -0
  97. package/src/config/prototype-site-layout-client.tsx +35 -0
  98. package/src/config/with-prototype.ts +20 -0
  99. package/src/index.ts +303 -0
  100. package/src/lib/pr-split/build-pr-agent-prompt.ts +68 -0
  101. package/src/lib/pr-split/build-pr-split-prototype-url.ts +12 -0
  102. package/src/lib/pr-split/pr-split-highlight.ts +6 -0
  103. package/src/lib/pr-split/pr-split-types.ts +58 -0
  104. package/src/lib/pr-split/resolve-source-preview-url.ts +45 -0
  105. package/src/lib/pr-split/use-pr-vercel-previews.ts +69 -0
  106. package/src/lib/prototype-comments/core/annotation-channel.ts +41 -0
  107. package/src/lib/prototype-comments/core/annotation-status.ts +60 -0
  108. package/src/lib/prototype-comments/core/annotation-target.ts +567 -0
  109. package/src/lib/prototype-comments/core/capture-theme.ts +41 -0
  110. package/src/lib/prototype-comments/core/capture.ts +11 -0
  111. package/src/lib/prototype-comments/core/comment-capture-blocked.ts +47 -0
  112. package/src/lib/prototype-comments/core/comment-threads.ts +52 -0
  113. package/src/lib/prototype-comments/core/element-identification.ts +615 -0
  114. package/src/lib/prototype-comments/core/export.ts +35 -0
  115. package/src/lib/prototype-comments/core/freeze-animations.ts +266 -0
  116. package/src/lib/prototype-comments/core/identify-element.ts +76 -0
  117. package/src/lib/prototype-comments/core/normalize-viewport.ts +35 -0
  118. package/src/lib/prototype-comments/core/react-detection.ts +704 -0
  119. package/src/lib/prototype-comments/core/restore.ts +7 -0
  120. package/src/lib/prototype-comments/core/screenshot.ts +428 -0
  121. package/src/lib/prototype-comments/core/source-location.ts +906 -0
  122. package/src/lib/prototype-comments/core/storage.ts +80 -0
  123. package/src/lib/prototype-comments/core/types.ts +79 -0
  124. package/src/lib/prototype-comments/core/validation.ts +17 -0
  125. package/src/lib/prototype-comments/hooks/useLayoutShift.ts +61 -0
  126. package/src/lib/prototype-comments/index.ts +58 -0
  127. package/src/lib/prototype-comments/react/CommentCaptureToolbar.tsx +1398 -0
  128. package/src/lib/prototype-comments/react/CommentProvider.tsx +298 -0
  129. package/src/lib/prototype-comments/scss.d.ts +10 -0
  130. package/src/lib/prototype-comments/ui/CommentAnnotationOverlay.tsx +253 -0
  131. package/src/lib/prototype-comments/ui/CommentDetailPage.tsx +56 -0
  132. package/src/lib/prototype-comments/ui/CommentHeaderCount.tsx +28 -0
  133. package/src/lib/prototype-comments/ui/CommentThread.tsx +331 -0
  134. package/src/lib/prototype-comments/ui/CommentsGrid.tsx +780 -0
  135. package/src/lib/prototype-comments/ui/CommentsListPage.tsx +25 -0
  136. package/src/lib/prototype-comments/ui/CommentsSidebar.tsx +77 -0
  137. package/src/lib/prototype-comments/ui/annotation-marker/index.tsx +200 -0
  138. package/src/lib/prototype-comments/ui/annotation-marker/styles.module.scss +194 -0
  139. package/src/lib/prototype-comments/ui/annotation-popup/index.tsx +335 -0
  140. package/src/lib/prototype-comments/ui/annotation-popup/styles.module.scss +469 -0
  141. package/src/lib/prototype-comments/ui/capture-styles.module.scss +2225 -0
  142. package/src/lib/prototype-comments/ui/comment-highlight.ts +48 -0
  143. package/src/lib/prototype-comments/ui/comment-thread.module.scss +203 -0
  144. package/src/lib/prototype-comments/ui/comments-grid-card.module.scss +253 -0
  145. package/src/lib/prototype-comments/ui/comments-sidebar.module.scss +123 -0
  146. package/src/lib/prototype-comments/ui/icon-transitions.module.scss +51 -0
  147. package/src/lib/prototype-comments/ui/icons.tsx +1086 -0
  148. package/src/lib/prototype-comments/ui/is-comment-detail-path.ts +7 -0
  149. package/src/lib/prototypes/capture-prototype-viewport.ts +32 -0
  150. package/src/lib/prototypes/changelog-meta-context.tsx +162 -0
  151. package/src/lib/prototypes/changelog-meta-storage.ts +62 -0
  152. package/src/lib/prototypes/changelog-meta.ts +95 -0
  153. package/src/lib/prototypes/create-prototype-registry.ts +74 -0
  154. package/src/lib/prototypes/create-state-screenshot-thumbnail.ts +46 -0
  155. package/src/lib/prototypes/design-exploration-default-storage.ts +68 -0
  156. package/src/lib/prototypes/design-exploration-default.ts +51 -0
  157. package/src/lib/prototypes/design-exploration-types.ts +203 -0
  158. package/src/lib/prototypes/prototype-comment-registry.tsx +145 -0
  159. package/src/lib/prototypes/prototype-comment-review-bridge.tsx +40 -0
  160. package/src/lib/prototypes/prototype-comment-review-state.ts +57 -0
  161. package/src/lib/prototypes/prototype-component-registry.ts +40 -0
  162. package/src/lib/prototypes/prototype-config-types.ts +34 -0
  163. package/src/lib/prototypes/prototype-preview-state-registry.ts +443 -0
  164. package/src/lib/prototypes/prototype-preview-state-types.ts +89 -0
  165. package/src/lib/prototypes/prototype-review-context.tsx +905 -0
  166. package/src/lib/prototypes/prototype-review-url.ts +134 -0
  167. package/src/lib/prototypes/prototype-share-link.ts +175 -0
  168. package/src/lib/prototypes/prototype-state-canvas-constants.ts +118 -0
  169. package/src/lib/prototypes/prototype-state-canvas-types.ts +159 -0
  170. package/src/lib/prototypes/prototype-state-screenshot.ts +39 -0
  171. package/src/lib/prototypes/prototype-tweak-registry.ts +96 -0
  172. package/src/lib/prototypes/prototype-tweak-types.ts +31 -0
  173. package/src/lib/prototypes/reference-docs.ts +137 -0
  174. package/src/lib/prototypes/screenshot-manifest.ts +33 -0
  175. package/src/lib/prototypes/screenshot-src.ts +7 -0
  176. package/src/lib/prototypes/share-command.ts +8 -0
  177. package/src/lib/prototypes/state-map-annotation.ts +70 -0
  178. package/src/lib/prototypes/use-design-exploration-default.ts +114 -0
  179. package/src/lib/prototypes/use-persisted-local-state.ts +140 -0
  180. package/src/lib/prototypes/use-prototype-comments.ts +63 -0
  181. package/src/lib/prototypes/use-prototype-mobile-viewport-frame.ts +46 -0
  182. package/src/lib/prototypes/use-prototype-state-screenshot-src.ts +71 -0
  183. package/src/lib/prototypes/use-prototype-tool-theme.tsx +115 -0
  184. package/src/lib/prototypes/use-prototype-viewport-frame.ts +34 -0
  185. package/src/lib/prototypes/use-register-prototype-reference-docs.ts +16 -0
  186. package/src/lib/prototypes/use-snap-corner-position.ts +417 -0
  187. package/src/lib/prototypes/use-toolbar-hover-panel.ts +98 -0
  188. package/src/lib/prototypes/variant-set-lucide-icon.tsx +55 -0
  189. package/src/lib/tool-portal.ts +33 -0
  190. package/src/lib/use-copy-to-clipboard.ts +81 -0
  191. package/src/lib/utils.ts +6 -0
  192. package/src/lib/vercel-preview/parse-vercel-github-comment.ts +81 -0
  193. package/src/server/create-host-api-route.ts +100 -0
  194. package/src/server/create-prototype-api-routes.ts +428 -0
  195. package/src/server/gallery/create-gallery-api.ts +56 -0
  196. package/src/server/gallery/folders-route.ts +156 -0
  197. package/src/server/gallery/gallery-env.ts +39 -0
  198. package/src/server/gallery/gallery-route.ts +150 -0
  199. package/src/server/gallery/gallery-types.ts +15 -0
  200. package/src/server/gallery/image-route.ts +59 -0
  201. package/src/server/gallery/item-route.ts +92 -0
  202. package/src/server/pr-vercel-preview-route.ts +162 -0
  203. package/src/server/redis/client.ts +55 -0
  204. package/src/server/redis/prototype-changelog-meta.ts +31 -0
  205. package/src/server/redis/prototype-comments.ts +92 -0
  206. package/src/server/redis/prototype-design-exploration-default.ts +32 -0
  207. package/src/server.ts +51 -0
  208. package/src/styles/globals.css +624 -0
  209. package/tsconfig.json +19 -0
@@ -0,0 +1,71 @@
1
+ "use client";
2
+
3
+ import { cn } from "@prototype/lib/utils";
4
+ import { Check } from "lucide-react";
5
+ import type { ReactNode } from "react";
6
+
7
+ /** Flat option list for the review toolbar controls panel (no nested dropdown). */
8
+ export function ControlsPanelOptionGroup({
9
+ title,
10
+ children,
11
+ className,
12
+ }: {
13
+ title?: string;
14
+ children: ReactNode;
15
+ className?: string;
16
+ }) {
17
+ return (
18
+ <div className={cn("flex flex-col gap-0.5", className)}>
19
+ {title ? (
20
+ <p className="px-2.5 pb-0.5 text-[11px] font-medium tracking-wide text-black/45 uppercase">
21
+ {title}
22
+ </p>
23
+ ) : null}
24
+ {children}
25
+ </div>
26
+ );
27
+ }
28
+
29
+ type ControlsPanelOptionProps = {
30
+ children: ReactNode;
31
+ selected?: boolean;
32
+ onSelect?: () => void;
33
+ className?: string;
34
+ };
35
+
36
+ export function ControlsPanelOption({
37
+ children,
38
+ selected = false,
39
+ onSelect,
40
+ className,
41
+ }: ControlsPanelOptionProps) {
42
+ return (
43
+ <button
44
+ type="button"
45
+ className={cn(
46
+ "flex w-full cursor-pointer items-center justify-between gap-2 rounded-md px-2.5 py-1.5 text-left text-sm text-black/88 outline-none transition-colors duration-200 ease hover:bg-black/[0.06] hover:text-black/92 focus-visible:ring-2 focus-visible:ring-black/20",
47
+ selected && "bg-black/[0.08] font-medium text-black/92",
48
+ className,
49
+ )}
50
+ onClick={(event) => {
51
+ event.stopPropagation();
52
+ onSelect?.();
53
+ }}
54
+ onPointerDown={(event) => event.stopPropagation()}
55
+ >
56
+ <span className="min-w-0 flex-1 truncate">{children}</span>
57
+ {selected ? (
58
+ <Check className="size-3.5 shrink-0 text-black/55" strokeWidth={2.5} aria-hidden />
59
+ ) : null}
60
+ </button>
61
+ );
62
+ }
63
+
64
+ export function ControlsPanelOptionSeparator({ className }: { className?: string }) {
65
+ return (
66
+ <div
67
+ role="separator"
68
+ className={cn("my-1 h-px bg-black/10", className)}
69
+ />
70
+ );
71
+ }
@@ -0,0 +1,438 @@
1
+ "use client";
2
+
3
+ import * as SelectPrimitive from "@radix-ui/react-select";
4
+ import { Check, ChevronDown } from "lucide-react";
5
+ import {
6
+ useCallback,
7
+ useEffect,
8
+ useRef,
9
+ useState,
10
+ type PointerEvent as ReactPointerEvent,
11
+ } from "react";
12
+
13
+ import pillStyles from "@prototype/components/prototypes/prototype-floating-pill.module.scss";
14
+ import { MiniPillLabel } from "@prototype/components/prototypes/mini-pill-label";
15
+ import { usePrototypeReviewOptional } from "@prototype/lib/prototypes/prototype-review-context";
16
+ import { usePrototypeToolTheme } from "@prototype/lib/prototypes/use-prototype-tool-theme";
17
+ import {
18
+ DropdownMenuItem,
19
+ DropdownMenuSub,
20
+ DropdownMenuSubContent,
21
+ DropdownMenuSubTrigger,
22
+ } from "@prototype/components/ui/dropdown-menu";
23
+ import { cn } from "@prototype/lib/utils";
24
+
25
+ import { ControlsPanelOptionSeparator } from "./controls-panel-options";
26
+
27
+ export type ControlsPanelSelectOption<T extends string = string> = {
28
+ value: T;
29
+ label: string;
30
+ };
31
+
32
+ export type ControlsPanelSelectSeparator = {
33
+ type: "separator";
34
+ };
35
+
36
+ export type ControlsPanelSelectSubmenuOption = {
37
+ value: string;
38
+ label: string;
39
+ };
40
+
41
+ export type ControlsPanelSelectSubmenu<T extends string = string> = {
42
+ type: "submenu";
43
+ value: T;
44
+ label: string;
45
+ options: ControlsPanelSelectSubmenuOption[];
46
+ };
47
+
48
+ export type ControlsPanelSelectEntry<T extends string = string> =
49
+ | ControlsPanelSelectOption<T>
50
+ | ControlsPanelSelectSeparator
51
+ | ControlsPanelSelectSubmenu<T>;
52
+
53
+ function isControlsPanelSelectSeparator<T extends string>(
54
+ entry: ControlsPanelSelectEntry<T>,
55
+ ): entry is ControlsPanelSelectSeparator {
56
+ return "type" in entry && entry.type === "separator";
57
+ }
58
+
59
+ function isControlsPanelSelectSubmenu<T extends string>(
60
+ entry: ControlsPanelSelectEntry<T>,
61
+ ): entry is ControlsPanelSelectSubmenu<T> {
62
+ return "type" in entry && entry.type === "submenu";
63
+ }
64
+
65
+ function getSelectableOptions<T extends string>(
66
+ entries: ControlsPanelSelectEntry<T>[],
67
+ ): ControlsPanelSelectOption<T>[] {
68
+ return entries.flatMap((entry) => {
69
+ if (isControlsPanelSelectSeparator(entry)) return [];
70
+ if (isControlsPanelSelectSubmenu(entry)) {
71
+ return [{ value: entry.value, label: entry.label }];
72
+ }
73
+ return [entry];
74
+ });
75
+ }
76
+
77
+ type ControlsPanelSelectProps<T extends string = string> = {
78
+ value: T;
79
+ onValueChange: (value: T) => void;
80
+ options: ControlsPanelSelectEntry<T>[];
81
+ className?: string;
82
+ /** Renders as a half-height pill for the review chrome control row. */
83
+ appearance?: "panel" | "miniPill" | "menuList";
84
+ /** Overrides the mini pill trigger label (dropdown options are unchanged). */
85
+ miniPillLabel?: string;
86
+ /** Active nested option when the current value matches a submenu entry. */
87
+ submenuValue?: string;
88
+ onSubmenuValueChange?: (submenuValue: string) => void;
89
+ };
90
+
91
+ /**
92
+ * Marks the portaled select surface so the surrounding review controls menu
93
+ * can keep itself open while the user interacts with this select.
94
+ */
95
+ export const CONTROLS_PANEL_SELECT_CONTENT_ATTR =
96
+ "data-controls-panel-select-content";
97
+
98
+ /**
99
+ * Select for the review controls panel, built on Radix Select. The option
100
+ * list is portaled to document.body and positioned over page content, so opening
101
+ * it overlays rather than reflowing the surrounding menu.
102
+ */
103
+ export function ControlsPanelSelect<T extends string>({
104
+ value,
105
+ onValueChange,
106
+ options,
107
+ className,
108
+ appearance = "panel",
109
+ miniPillLabel,
110
+ submenuValue,
111
+ onSubmenuValueChange,
112
+ }: ControlsPanelSelectProps<T>) {
113
+ const selectableOptions = getSelectableOptions(options);
114
+ const activeLabel =
115
+ selectableOptions.find((option) => option.value === value)?.label ?? "Select";
116
+ const isMiniPill = appearance === "miniPill";
117
+ const isMenuList = appearance === "menuList";
118
+ const review = usePrototypeReviewOptional();
119
+ const { commentTheme } = usePrototypeToolTheme();
120
+ const triggerLabel = isMiniPill ? (miniPillLabel ?? activeLabel) : activeLabel;
121
+ const [open, setOpen] = useState(false);
122
+ const triggerRef = useRef<HTMLButtonElement>(null);
123
+
124
+ useEffect(() => {
125
+ if (!isMenuList || !review) return;
126
+
127
+ review.registerPreviewStates(
128
+ getSelectableOptions(options).map((option) => ({
129
+ id: option.value,
130
+ label: option.label,
131
+ })),
132
+ );
133
+ }, [isMenuList, options, review]);
134
+
135
+ const selectSubmenuEntry = useCallback(
136
+ (
137
+ entry: ControlsPanelSelectSubmenu<T>,
138
+ submenuOption: ControlsPanelSelectSubmenuOption,
139
+ ) => {
140
+ onValueChange(entry.value);
141
+ onSubmenuValueChange?.(submenuOption.value);
142
+ },
143
+ [onSubmenuValueChange, onValueChange],
144
+ );
145
+
146
+ const handleValueChange = useCallback(
147
+ (next: T) => {
148
+ onValueChange(next);
149
+
150
+ const submenuEntry = options.find(
151
+ (entry) => isControlsPanelSelectSubmenu(entry) && entry.value === next,
152
+ );
153
+ if (submenuEntry && isControlsPanelSelectSubmenu(submenuEntry)) {
154
+ const firstOption = submenuEntry.options[0];
155
+ if (firstOption) {
156
+ onSubmenuValueChange?.(firstOption.value);
157
+ }
158
+ }
159
+ },
160
+ [onSubmenuValueChange, onValueChange, options],
161
+ );
162
+
163
+ const closeSelect = useCallback(() => {
164
+ setOpen(false);
165
+ requestAnimationFrame(() => triggerRef.current?.blur());
166
+ }, []);
167
+
168
+ useEffect(() => {
169
+ if (!open) return;
170
+
171
+ const handlePointerDown = (event: PointerEvent) => {
172
+ const target = event.target;
173
+ if (!(target instanceof Element)) return;
174
+ if (triggerRef.current?.contains(target)) return;
175
+ if (target.closest(`[${CONTROLS_PANEL_SELECT_CONTENT_ATTR}]`)) return;
176
+ closeSelect();
177
+ };
178
+
179
+ document.addEventListener("pointerdown", handlePointerDown, true);
180
+ return () => document.removeEventListener("pointerdown", handlePointerDown, true);
181
+ }, [closeSelect, open]);
182
+
183
+ const handleOpenChange = (next: boolean) => {
184
+ setOpen(next);
185
+ if (!next) {
186
+ requestAnimationFrame(() => triggerRef.current?.blur());
187
+ }
188
+ };
189
+
190
+ const handleTriggerPointerDown = (event: ReactPointerEvent<HTMLButtonElement>) => {
191
+ event.stopPropagation();
192
+ };
193
+
194
+ if (isMenuList) {
195
+ return (
196
+ <div className={cn(pillStyles.controlsPanelMenuList, className)}>
197
+ {options.map((entry, index) => {
198
+ if (isControlsPanelSelectSeparator(entry)) {
199
+ return (
200
+ <ControlsPanelOptionSeparator
201
+ key={`separator-${index}`}
202
+ className={pillStyles.dropdownMenuSeparator}
203
+ />
204
+ );
205
+ }
206
+ if (isControlsPanelSelectSubmenu(entry)) {
207
+ const parentSelected = entry.value === value;
208
+ const firstOption = entry.options[0];
209
+
210
+ return (
211
+ <DropdownMenuSub key={entry.value}>
212
+ <DropdownMenuSubTrigger
213
+ className={cn(
214
+ pillStyles.dropdownMenuItem,
215
+ pillStyles.dropdownMenuSubTrigger,
216
+ parentSelected && pillStyles.dropdownMenuItemSelected,
217
+ )}
218
+ onClick={(event) => {
219
+ if (parentSelected) {
220
+ // Hover opens the sub-layout; suppress click-to-open submenu.
221
+ event.preventDefault();
222
+ return;
223
+ }
224
+ if (!firstOption) return;
225
+ event.preventDefault();
226
+ selectSubmenuEntry(entry, firstOption);
227
+ requestAnimationFrame(() => {
228
+ event.currentTarget.ownerDocument.dispatchEvent(
229
+ new KeyboardEvent("keydown", {
230
+ key: "Escape",
231
+ code: "Escape",
232
+ bubbles: true,
233
+ cancelable: true,
234
+ }),
235
+ );
236
+ });
237
+ }}
238
+ >
239
+ <span>{entry.label}</span>
240
+ {parentSelected ? (
241
+ <Check
242
+ className={cn(
243
+ "size-3.5 shrink-0",
244
+ pillStyles.dropdownMenuItemCheck,
245
+ )}
246
+ strokeWidth={2.5}
247
+ aria-hidden
248
+ />
249
+ ) : null}
250
+ </DropdownMenuSubTrigger>
251
+ <DropdownMenuSubContent
252
+ sideOffset={6}
253
+ collisionPadding={{ top: 12, right: 12, bottom: 88, left: 12 }}
254
+ data-prototype-comment-theme={commentTheme}
255
+ className={cn(
256
+ pillStyles.dropdownSubContent,
257
+ "border-0 bg-transparent p-0 text-foreground ring-0",
258
+ )}
259
+ >
260
+ {entry.options.map((subOption) => {
261
+ const selected =
262
+ parentSelected && submenuValue === subOption.value;
263
+ return (
264
+ <DropdownMenuItem
265
+ key={subOption.value}
266
+ className={cn(
267
+ pillStyles.dropdownMenuItem,
268
+ selected && pillStyles.dropdownMenuItemSelected,
269
+ )}
270
+ onSelect={() => selectSubmenuEntry(entry, subOption)}
271
+ >
272
+ <span>{subOption.label}</span>
273
+ {selected ? (
274
+ <Check
275
+ className={cn(
276
+ "size-3.5 shrink-0",
277
+ pillStyles.dropdownMenuItemCheck,
278
+ )}
279
+ strokeWidth={2.5}
280
+ aria-hidden
281
+ />
282
+ ) : null}
283
+ </DropdownMenuItem>
284
+ );
285
+ })}
286
+ </DropdownMenuSubContent>
287
+ </DropdownMenuSub>
288
+ );
289
+ }
290
+
291
+ const selected = entry.value === value;
292
+ return (
293
+ <DropdownMenuItem
294
+ key={entry.value}
295
+ className={cn(
296
+ pillStyles.dropdownMenuItem,
297
+ selected && pillStyles.dropdownMenuItemSelected,
298
+ )}
299
+ onSelect={() => handleValueChange(entry.value)}
300
+ >
301
+ <span>{entry.label}</span>
302
+ {selected ? (
303
+ <Check
304
+ className={cn("size-3.5 shrink-0", pillStyles.dropdownMenuItemCheck)}
305
+ strokeWidth={2.5}
306
+ aria-hidden
307
+ />
308
+ ) : null}
309
+ </DropdownMenuItem>
310
+ );
311
+ })}
312
+ </div>
313
+ );
314
+ }
315
+
316
+ return (
317
+ <SelectPrimitive.Root
318
+ value={value}
319
+ open={open}
320
+ onOpenChange={handleOpenChange}
321
+ onValueChange={(next) => handleValueChange(next as T)}
322
+ >
323
+ {isMiniPill ? (
324
+ <SelectPrimitive.Trigger
325
+ ref={triggerRef}
326
+ className={cn(
327
+ pillStyles.panelSelectTriggerMiniPill,
328
+ "justify-center gap-1",
329
+ className,
330
+ )}
331
+ onPointerDown={handleTriggerPointerDown}
332
+ >
333
+ <span className={pillStyles.miniPillLabelGroup}>
334
+ <MiniPillLabel label={triggerLabel} />
335
+ </span>
336
+ <SelectPrimitive.Icon asChild>
337
+ <ChevronDown
338
+ className="size-3 shrink-0 opacity-60"
339
+ aria-hidden
340
+ />
341
+ </SelectPrimitive.Icon>
342
+ </SelectPrimitive.Trigger>
343
+ ) : (
344
+ <div
345
+ className={cn(
346
+ pillStyles.panelSelect,
347
+ pillStyles.controlsPanelSelect,
348
+ className,
349
+ )}
350
+ >
351
+ <SelectPrimitive.Trigger
352
+ ref={triggerRef}
353
+ className={cn(pillStyles.panelSelectTrigger, "justify-between gap-2")}
354
+ onPointerDown={handleTriggerPointerDown}
355
+ >
356
+ <span className={pillStyles.panelSelectValue}>{activeLabel}</span>
357
+ <SelectPrimitive.Icon asChild>
358
+ <ChevronDown
359
+ className={cn("size-3.5", pillStyles.panelSelectChevron)}
360
+ aria-hidden
361
+ />
362
+ </SelectPrimitive.Icon>
363
+ </SelectPrimitive.Trigger>
364
+ </div>
365
+ )}
366
+
367
+ <SelectPrimitive.Portal>
368
+ <SelectPrimitive.Content
369
+ {...{ [CONTROLS_PANEL_SELECT_CONTENT_ATTR]: "" }}
370
+ data-prototype-root
371
+ data-prototype-comment-theme={commentTheme}
372
+ position="popper"
373
+ side="top"
374
+ align="start"
375
+ sideOffset={4}
376
+ onCloseAutoFocus={(event) => event.preventDefault()}
377
+ className={cn(
378
+ pillStyles.dropdownContent,
379
+ "border-0 bg-transparent p-0 text-foreground ring-0",
380
+ )}
381
+ style={{
382
+ // Above review chrome toolbar (prototype-review-chrome.module.scss z-index: 1050).
383
+ zIndex: 1051,
384
+ minWidth: "max(var(--radix-select-trigger-width), 12rem)",
385
+ }}
386
+ >
387
+ <SelectPrimitive.Viewport className="flex max-h-[min(20rem,60vh)] flex-col gap-0.5">
388
+ {options.map((entry, index) => {
389
+ if (isControlsPanelSelectSeparator(entry)) {
390
+ return (
391
+ <ControlsPanelOptionSeparator
392
+ key={`separator-${index}`}
393
+ className={pillStyles.dropdownMenuSeparator}
394
+ />
395
+ );
396
+ }
397
+ if (isControlsPanelSelectSubmenu(entry)) {
398
+ return (
399
+ <SelectPrimitive.Item
400
+ key={entry.value}
401
+ value={entry.value}
402
+ className={pillStyles.dropdownMenuItem}
403
+ >
404
+ <SelectPrimitive.ItemText>{entry.label}</SelectPrimitive.ItemText>
405
+ <SelectPrimitive.ItemIndicator asChild>
406
+ <Check
407
+ className={cn("size-3.5 shrink-0", pillStyles.dropdownMenuItemCheck)}
408
+ strokeWidth={2.5}
409
+ aria-hidden
410
+ />
411
+ </SelectPrimitive.ItemIndicator>
412
+ </SelectPrimitive.Item>
413
+ );
414
+ }
415
+
416
+ return (
417
+ <SelectPrimitive.Item
418
+ key={entry.value}
419
+ value={entry.value}
420
+ className={pillStyles.dropdownMenuItem}
421
+ >
422
+ <SelectPrimitive.ItemText>{entry.label}</SelectPrimitive.ItemText>
423
+ <SelectPrimitive.ItemIndicator asChild>
424
+ <Check
425
+ className={cn("size-3.5 shrink-0", pillStyles.dropdownMenuItemCheck)}
426
+ strokeWidth={2.5}
427
+ aria-hidden
428
+ />
429
+ </SelectPrimitive.ItemIndicator>
430
+ </SelectPrimitive.Item>
431
+ );
432
+ })}
433
+ </SelectPrimitive.Viewport>
434
+ </SelectPrimitive.Content>
435
+ </SelectPrimitive.Portal>
436
+ </SelectPrimitive.Root>
437
+ );
438
+ }
@@ -0,0 +1,15 @@
1
+ import { cn } from "@prototype/lib/utils";
2
+ import type { ReactNode } from "react";
3
+
4
+ type EmptyStateProps = {
5
+ children: ReactNode;
6
+ className?: string;
7
+ };
8
+
9
+ export function EmptyState({ children, className }: EmptyStateProps) {
10
+ return (
11
+ <p className={cn("px-1 py-2 text-sm text-muted-foreground", className)}>
12
+ {children}
13
+ </p>
14
+ );
15
+ }
@@ -0,0 +1,18 @@
1
+ "use client";
2
+
3
+ import { Button } from "@prototype/components/ui/button";
4
+ import { cn } from "@prototype/lib/utils";
5
+ import type { ComponentProps } from "react";
6
+
7
+ type IconButtonProps = ComponentProps<typeof Button>;
8
+
9
+ export function IconButton({ className, size = "icon", variant = "ghost", ...props }: IconButtonProps) {
10
+ return (
11
+ <Button
12
+ size={size}
13
+ variant={variant}
14
+ className={cn("shrink-0", className)}
15
+ {...props}
16
+ />
17
+ );
18
+ }
@@ -0,0 +1,123 @@
1
+ "use client";
2
+
3
+ import { cn } from "@prototype/lib/utils";
4
+ import { Check, ChevronDown } from "lucide-react";
5
+ import {
6
+ createContext,
7
+ useContext,
8
+ useState,
9
+ type ButtonHTMLAttributes,
10
+ type ReactNode,
11
+ } from "react";
12
+
13
+ const PanelSelectContext = createContext<(() => void) | null>(null);
14
+
15
+ export function usePanelSelectClose() {
16
+ return useContext(PanelSelectContext);
17
+ }
18
+
19
+ type PanelSelectProps = {
20
+ label: string;
21
+ valueLabel: string;
22
+ children: ReactNode;
23
+ className?: string;
24
+ defaultOpen?: boolean;
25
+ };
26
+
27
+ export function PanelSelect({
28
+ label,
29
+ valueLabel,
30
+ children,
31
+ className,
32
+ defaultOpen = false,
33
+ }: PanelSelectProps) {
34
+ const [open, setOpen] = useState(defaultOpen);
35
+ const close = () => setOpen(false);
36
+
37
+ return (
38
+ <PanelSelectContext.Provider value={close}>
39
+ <div className={cn("relative", className)}>
40
+ <button
41
+ type="button"
42
+ aria-expanded={open}
43
+ aria-haspopup="listbox"
44
+ className="flex h-8 min-w-0 items-center gap-2 rounded-md border border-border bg-background px-2.5 text-sm text-foreground shadow-sm transition-colors duration-200 ease hover:bg-muted"
45
+ onClick={(event) => {
46
+ event.stopPropagation();
47
+ setOpen((current) => !current);
48
+ }}
49
+ onPointerDown={(event) => event.stopPropagation()}
50
+ >
51
+ <span className="text-xs text-muted-foreground">{label}</span>
52
+ <span className="truncate font-medium">{valueLabel}</span>
53
+ <ChevronDown
54
+ className={cn(
55
+ "size-4 shrink-0 text-muted-foreground transition-transform duration-200 ease",
56
+ open && "rotate-180",
57
+ )}
58
+ aria-hidden
59
+ />
60
+ </button>
61
+ {open ? (
62
+ <div
63
+ role="listbox"
64
+ className="absolute top-full left-0 z-50 mt-1 min-w-[10rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md"
65
+ onPointerDown={(event) => event.stopPropagation()}
66
+ >
67
+ {children}
68
+ </div>
69
+ ) : null}
70
+ </div>
71
+ </PanelSelectContext.Provider>
72
+ );
73
+ }
74
+
75
+ type PanelMenuItemProps = Omit<
76
+ ButtonHTMLAttributes<HTMLButtonElement>,
77
+ "type" | "onSelect"
78
+ > & {
79
+ selected?: boolean;
80
+ onSelect?: () => void;
81
+ children: ReactNode;
82
+ };
83
+
84
+ export function PanelMenuItem({
85
+ selected = false,
86
+ className,
87
+ children,
88
+ onSelect,
89
+ onClick,
90
+ ...props
91
+ }: PanelMenuItemProps) {
92
+ const closePanelSelect = usePanelSelectClose();
93
+
94
+ return (
95
+ <button
96
+ type="button"
97
+ role="option"
98
+ aria-selected={selected}
99
+ className={cn(
100
+ "flex w-full cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground",
101
+ selected && "bg-accent text-accent-foreground",
102
+ className,
103
+ )}
104
+ onClick={(event) => {
105
+ onClick?.(event);
106
+ if (!event.defaultPrevented) {
107
+ onSelect?.();
108
+ closePanelSelect?.();
109
+ }
110
+ }}
111
+ {...props}
112
+ >
113
+ <span className="min-w-0 flex-1 truncate text-left">{children}</span>
114
+ {selected ? (
115
+ <Check className="size-3.5 shrink-0" strokeWidth={2.5} aria-hidden />
116
+ ) : null}
117
+ </button>
118
+ );
119
+ }
120
+
121
+ export function PanelMenuSeparator({ className }: { className?: string }) {
122
+ return <div className={cn("-mx-1 my-1 h-px bg-border", className)} role="separator" />;
123
+ }