@gradeui/ui 1.0.0 → 1.2.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.
@@ -0,0 +1,226 @@
1
+ ---
2
+ name: Composer
3
+ import: "@gradeui/ui"
4
+ props:
5
+ - placeholder?: string
6
+ - initialText?: string — plain text content to seed on mount
7
+ - initialJson?: string — Lexical state JSON (from a previous onSubmit round-trip)
8
+ - formats?: ComposerFormat[] | false — available formats (defaults to bold/italic/underline/strikethrough/code/h1/h2/blockquote/ul/ol); pass false for plain text only
9
+ - toolbar?: boolean | "top" — show the formatting toolbar above the editor; default false
10
+ - triggers?: ComposerTriggerConfig[] — mention/slash configs, eg. `[{ char: "@", items: people }, { char: "/", items: commands }]`
11
+ - attachments?: boolean | ComposerAttachmentConfig — enable image paste + paperclip when true/object; default off
12
+ - onSubmit?: (content: ComposerContent, attachments?: ComposerAttachment[]) => void
13
+ - isLoading?: boolean — disables editor, swaps default Send for Stop
14
+ - onStop?: () => void
15
+ - maxLength?: number
16
+ - autoFocus?: boolean
17
+ - submitOnEnter?: boolean — default true (Shift-Enter still inserts newline)
18
+ - leftActions?: ReactNode — override the default paperclip
19
+ - rightActions?: ReactNode — override the default Send/Stop
20
+ - hideSend?: boolean — hide the default Send without replacing it
21
+ - steps?: ComposerStep[] — scripted demo sequence
22
+ - trigger?: DemoTrigger — "mount" | "inView" | "manual"; default "mount"
23
+ - play?: boolean — for trigger="manual"
24
+ - speed?: DemoSpeed — "slow" | "normal" | "fast"; default "normal"
25
+ - loop?: boolean
26
+ - loopDelay?: number — ms between loop iterations, default 2000
27
+ - readOnly?: boolean — disables editing AND focusability; programmatic playback still works; use for marketing demos so the script doesn't steal focus
28
+ - bare?: boolean — strip the card chrome
29
+ - className?: string
30
+ when_to_use: |
31
+ THE PRIMITIVE for any text composition surface — Slack / Discord /
32
+ Teams chat input, AI chat / copilot prompt box, comment thread input,
33
+ GitHub / Linear / Jira comment box, Reddit / Twitter reply box,
34
+ Notion / Linear document body, email composer, post body, anywhere
35
+ a user types text and submits.
36
+
37
+ CONCRETE TEST — if you find yourself writing a `<textarea>` (or
38
+ `<Input>` styled tall) with a row of `<Bold>` / `<Italic>` /
39
+ `<Paperclip>` / `<Send>` buttons below or beside it, STOP. That is
40
+ `<Composer>`. Use it.
41
+
42
+ Common shapes:
43
+ Chat input with formatting + attachments + send
44
+ → <Composer formats={["bold","italic","code"]} toolbar attachments />
45
+ AI prompt box with paperclip + send
46
+ → <AIChatComposer /> (preset wrapping Composer)
47
+ Comment / reply input
48
+ → <ComposerReply triggers={[{char:"@", items: people}]} />
49
+ Document body editor
50
+ → <Composer toolbar formats={[...]} bare />
51
+
52
+ Built on Lexical for rich text, mentions, slash commands. The
53
+ `attachments` prop wires image paste + paperclip + chip preview row
54
+ with object URL lifecycle handled internally — don't roll that
55
+ plumbing yourself. The `triggers` prop wires @mentions and /slash
56
+ commands with a typeahead popover. The `formats` array picks which
57
+ toolbar buttons render when `toolbar` is on.
58
+
59
+ Shares the lib/demo step vocabulary with <Code> so scripted
60
+ typing/format/mention demos animate in the same rhythm as your
61
+ terminal demos.
62
+ composes_with: [AIChatComposer (preset wrapping this with paperclip + send + attachments), ComposerReply (preset for comment threads), AIChat (uses AIChatComposer internally), Card (host above for reply boxes), Avatar (in leftActions slot for "your" avatar next to the input)]
63
+ aliases: [
64
+ composer, message input, message bar, rich text editor, rich text input,
65
+ mention input, slash input, text editor, prompt input, comment composer,
66
+ comment input, reply input, reply box,
67
+ chat input, chat box, chat input bar, chat composer, chat field,
68
+ slack input, slack composer, slack message box, discord input,
69
+ discord composer, teams chat input, message composer, post composer,
70
+ textarea with toolbar, formatting input, formatted text input,
71
+ message field, send message input, write a message, compose message
72
+ ]
73
+ ---
74
+
75
+ ```jsx
76
+ // Plain text chat-style composer
77
+ <Composer
78
+ placeholder="Ask anything…"
79
+ onSubmit={(content) => send(content.text)}
80
+ formats={false}
81
+ />
82
+
83
+ // Comment composer with mentions
84
+ <Composer
85
+ placeholder="Add a comment…"
86
+ triggers={[{ char: "@", items: teamMembers }]}
87
+ onSubmit={(content) => postComment(content.text, content.mentions)}
88
+ submitOnEnter={false}
89
+ formats={["bold", "italic", "code"]}
90
+ toolbar
91
+ />
92
+
93
+ // AI chat composer with attachments, mentions AND slash commands
94
+ <Composer
95
+ placeholder="Describe a UI, or paste a screenshot…"
96
+ triggers={[
97
+ { char: "@", items: docs },
98
+ { char: "/", items: commands, stripTrigger: true },
99
+ ]}
100
+ attachments
101
+ onSubmit={(content, atts) => {
102
+ sendToAssistant(content.text, content.mentions, atts?.map(a => a.file));
103
+ }}
104
+ isLoading={isStreaming}
105
+ onStop={stop}
106
+ />
107
+
108
+ // Marketing demo — scripted playback
109
+ <Composer
110
+ placeholder="Type a message…"
111
+ triggers={[{ char: "@", items: [{ id: "1", value: "alice" }] }]}
112
+ steps={[
113
+ { type: "type", text: "Hey " },
114
+ { type: "mention", trigger: "@", value: "alice", query: "ali" },
115
+ { type: "type", text: ", check out " },
116
+ { type: "select", text: "check out" },
117
+ { type: "format", format: "italic" },
118
+ { type: "wait", ms: 800 },
119
+ { type: "submit" },
120
+ ]}
121
+ trigger="inView"
122
+ speed="normal"
123
+ loop
124
+ />
125
+ ```
126
+
127
+ ## Demo step vocabulary
128
+
129
+ Shares `type` / `wait` / `clear` with `<Code>` (driven by the same `useScriptedDemo` hook). Adds Composer-specific verbs:
130
+
131
+ - `{ type: "mention", trigger, value, query? }` — insert a mention/slash token. Pass `query` to show the typeahead in flight, then resolve to `value`.
132
+ - `{ type: "format", format }` — apply a format to the current selection.
133
+ - `{ type: "select", text }` — select a substring (precondition for `format`).
134
+ - `{ type: "newline" }` — insert a paragraph break.
135
+ - `{ type: "submit" }` — fire `onSubmit`.
136
+
137
+ ## Imperative handle
138
+
139
+ ```tsx
140
+ const ref = useRef<ComposerHandle>(null);
141
+ ref.current?.focus();
142
+ ref.current?.clear();
143
+ ref.current?.insert("…");
144
+ ref.current?.restart(); // replay scripted steps from the start
145
+ ref.current?.restart(3000); // replay after a 3s delay
146
+ ref.current?.getContent(); // { text, json, mentions }
147
+ ref.current?.getEditor(); // underlying Lexical editor (escape hatch)
148
+ ```
149
+
150
+ ## Themes
151
+
152
+ All colours read from CSS variables (`--gds-composer-*` palette in `globals.css`). The mention pills, toolbar buttons, attachment chips, and editor surface all rebrand with the active gradeui theme without component changes.
153
+
154
+ ## Anti-patterns
155
+
156
+ ```jsx
157
+ // ❌ Rolling a chat / Slack / Discord input as <textarea> + manual
158
+ // toolbar buttons + Send button. This is the EXACT shape Composer
159
+ // exists to consolidate — caught in the wild on a "Slack clone"
160
+ // generation where the model assembled this inline.
161
+ // Loses: attachment intake + object URL lifecycle, mention popover,
162
+ // slash commands, action-row slots, the Lexical state graph for
163
+ // rich content round-trip, the scripted-demo step machine.
164
+ <div className="border rounded-xl bg-card">
165
+ <textarea
166
+ placeholder="Message #general"
167
+ value={inputText}
168
+ onChange={(e) => setInputText(e.target.value)}
169
+ onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) handleSend(); }}
170
+ rows={3}
171
+ className="w-full bg-transparent p-3 resize-none focus:outline-none"
172
+ />
173
+ <Row justify="between" align="center" className="px-3 py-2 border-t">
174
+ <Row gap="xs">
175
+ <Button size="icon" variant="ghost"><Bold /></Button>
176
+ <Button size="icon" variant="ghost"><Italic /></Button>
177
+ <Button size="icon" variant="ghost"><List /></Button>
178
+ <Button size="icon" variant="ghost"><Smile /></Button>
179
+ <Button size="icon" variant="ghost"><Paperclip /></Button>
180
+ </Row>
181
+ <Button onClick={handleSend}>Send</Button>
182
+ </Row>
183
+ </div>
184
+
185
+ // ✅ The Grade way. Same shape, every affordance free.
186
+ <Composer
187
+ placeholder="Message #general"
188
+ formats={["bold", "italic", "code", "ul"]}
189
+ toolbar
190
+ attachments
191
+ triggers={[{ char: "@", items: teamMembers }]}
192
+ onSubmit={(content, atts) => handleSend(content.text, atts)}
193
+ />
194
+ ```
195
+
196
+ ```jsx
197
+ // ❌ Reaching for <Input> (single-line) for a multi-line chat / reply
198
+ // surface. Input is for one-line text fields. Use Composer for any
199
+ // surface where the user might type more than one line — chat,
200
+ // comments, post bodies.
201
+ <Input
202
+ placeholder="Reply to thread…"
203
+ value={reply}
204
+ onChange={(e) => setReply(e.target.value)}
205
+ />
206
+ <Button onClick={postReply}>Reply</Button>
207
+
208
+ // ✅ ComposerReply preset has the right defaults for a reply box.
209
+ <ComposerReply
210
+ placeholder="Reply to thread…"
211
+ triggers={[{ char: "@", items: people }]}
212
+ onSubmit={(content) => postReply(content.text)}
213
+ />
214
+ ```
215
+
216
+ ```jsx
217
+ // ❌ Importing TipTap, Lexical, Slate, or any other editor framework
218
+ // directly into a scaffold. Composer already wraps Lexical and
219
+ // handles all the plumbing.
220
+ import { useEditor, EditorContent } from "@tiptap/react";
221
+ const editor = useEditor({ extensions: [StarterKit, ...] });
222
+ <EditorContent editor={editor} />
223
+
224
+ // ✅ Use Composer. Same capability, integrated with the design system.
225
+ <Composer toolbar formats={["bold", "italic", "h1", "h2", "blockquote", "ul", "ol"]} />
226
+ ```
@@ -5,36 +5,129 @@ subcomponents: [DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogD
5
5
  props:
6
6
  - Dialog: open?, onOpenChange? — Radix controlled/uncontrolled pattern
7
7
  - DialogTrigger: asChild? (wrap a Button)
8
+ - DialogContent: surface? (solid | translucent | glass | glass-strong) — what the modal panel is *made of*. Defaults to `solid` (opaque `bg-background`). `glass` lets the page show through softly — pairs with rich backdrops or AI-suggestion modals.
8
9
  - DialogContent: accepts native div HTML attrs
9
10
  - DialogFooter: used for action rows
10
- when_to_use: Modal interruptions — confirmations, focused forms, detail views. Dialog is the right primitive for Apple HIG / React Native "Alert" (modal) semantics. For non-blocking inline messaging use Callout; for transient notifications use Toaster (Sonner). Always include DialogTitle (a11y requirement).
11
- composes_with: [Button (as DialogTrigger asChild, and inside DialogFooter), Input/Textarea/Select inside DialogContent]
12
- aliases: [modal, popup, overlay, alert, system alert, alert dialog, modal dialog, confirm dialog, react native modal, rn alert]
11
+ when_to_use: Modal interruptions — confirmations, focused forms, detail views, AI suggestion sheets. Dialog is the right primitive for Apple HIG / React Native "Alert" (modal) semantics. For non-blocking inline messaging use Callout; for transient notifications use Toaster (Sonner). Always include DialogTitle (a11y requirement).
12
+ composes_with: [Button (as DialogTrigger asChild, and inside DialogFooter), Input/Textarea/Select inside DialogContent, Code (for changelog / diff modals), MediaSurface (for image / preview modals)]
13
+ aliases: [modal, popup, overlay, alert, system alert, alert dialog, modal dialog, confirm dialog, react native modal, rn alert, glass modal, frosted modal, ai suggestion modal]
13
14
  ---
14
15
 
16
+ DialogContent sits at elevation-5 (the dialog tier). The Presence axes still apply: `surface` picks the material, `gds-aura-*` adds radiating state, the overlay scrim handles dimming the page.
17
+
18
+ ---
19
+
20
+ ### Scenario 1 — Destructive confirmation (default opaque)
21
+
22
+ You're confirming a destructive action: delete, discard, revoke. Keep the dialog opaque — the user should focus on the decision, not the page behind it. The raised Button + tonal `--btn-glow` keeps the destructive action visually heavy without going red-everywhere.
23
+
15
24
  ```jsx
16
25
  <Dialog>
17
- <DialogTrigger asChild><Button>Delete</Button></DialogTrigger>
26
+ <DialogTrigger asChild><Button variant="outline">Delete project</Button></DialogTrigger>
18
27
  <DialogContent>
19
28
  <DialogHeader>
20
29
  <DialogTitle>Delete project?</DialogTitle>
21
- <DialogDescription>This cannot be undone.</DialogDescription>
30
+ <DialogDescription>
31
+ This will remove the project, its screens, and all comments. This cannot be undone.
32
+ </DialogDescription>
22
33
  </DialogHeader>
23
34
  <DialogFooter>
24
35
  <Button variant="outline">Cancel</Button>
25
- <Button variant="destructive">Delete</Button>
36
+ <Button variant="raised" style={{ "--btn-glow": "var(--destructive)" }}>
37
+ Delete forever
38
+ </Button>
26
39
  </DialogFooter>
27
40
  </DialogContent>
28
41
  </Dialog>
29
42
  ```
30
43
 
31
- DialogContent ships at elevation-5. Reach for the raised Button variant inside DialogFooter when the action carries weight ("Delete", "Publish", "Ship"):
44
+ No `surface` prop `solid` is the right answer for high-stakes confirmations. The opacity reinforces "stop and decide".
45
+
46
+ ---
47
+
48
+ ### Scenario 2 — Glass modal over a rich canvas (creative-tool aesthetic)
49
+
50
+ You're building a creative tool — Studio, a presentation builder, a photo editor. The canvas behind the dialog is visually rich (a layout in progress, an image, generative art). A solid dialog cuts a hole through the work. Glass keeps the work visible while focusing attention.
32
51
 
33
52
  ```jsx
34
- <DialogFooter>
35
- <Button variant="outline">Cancel</Button>
36
- <Button variant="raised" style={{ "--btn-glow": "var(--destructive)" }}>
37
- Delete forever
38
- </Button>
39
- </DialogFooter>
53
+ <Dialog>
54
+ <DialogTrigger asChild><Button>Add a comment</Button></DialogTrigger>
55
+ <DialogContent surface="glass" className="shadow-elevation-5">
56
+ <DialogHeader>
57
+ <DialogTitle>Comment on Hero section</DialogTitle>
58
+ <DialogDescription>
59
+ Visible to your team and to Studio when it next regenerates this screen.
60
+ </DialogDescription>
61
+ </DialogHeader>
62
+ <Textarea placeholder="What should change about this section?" />
63
+ <DialogFooter>
64
+ <Button variant="ghost">Cancel</Button>
65
+ <Button>Post comment</Button>
66
+ </DialogFooter>
67
+ </DialogContent>
68
+ </Dialog>
40
69
  ```
70
+
71
+ `surface="glass"` is the canvas-tool signature. The user keeps spatial awareness of what they were just looking at; the dialog feels like a layer above the work, not a separate page.
72
+
73
+ ---
74
+
75
+ ### Scenario 3 — AI suggestion sheet (translucent + aura)
76
+
77
+ Studio is offering a suggestion. It shouldn't feel as heavy as a destructive confirmation — it's a recommendation, not a demand. Translucent (no blur) is lighter than glass; the aura ring announces "this is from an AI agent".
78
+
79
+ ```jsx
80
+ <Dialog open={hasSuggestion}>
81
+ <DialogContent
82
+ surface="translucent"
83
+ className="shadow-elevation-5 gds-aura-ring"
84
+ style={{ "--aura-color": "var(--selected-glow)" }}
85
+ >
86
+ <DialogHeader>
87
+ <DialogTitle>Three buttons could align</DialogTitle>
88
+ <DialogDescription>
89
+ Toolbar buttons match TabsList height when size="sm". Apply across all three?
90
+ </DialogDescription>
91
+ </DialogHeader>
92
+
93
+ <Card surface="glass" className="shadow-elevation-2">
94
+ <CardContent>
95
+ <Code source={suggestedDiff} language="tsx" diff={{ added: [2, 3, 4] }} bare />
96
+ </CardContent>
97
+ </Card>
98
+
99
+ <DialogFooter>
100
+ <Button variant="ghost">Dismiss</Button>
101
+ <Button>Apply suggestion</Button>
102
+ </DialogFooter>
103
+ </DialogContent>
104
+ </Dialog>
105
+ ```
106
+
107
+ Three Presence axes layered: `surface="translucent"` (material), `shadow-elevation-5` (depth), `gds-aura-ring` (state signal). The inner Card uses `surface="glass"` for a different reason — to read as a nested floating preview rather than a flat content block.
108
+
109
+ ---
110
+
111
+ ### Anti-patterns
112
+
113
+ **DO NOT use `surface="glass"` for destructive confirmations.** Glass implies "the page is still alive behind this" — users will be less decisive. Opaque is the right material for high-stakes choices.
114
+
115
+ **DO NOT roll glass by hand on DialogContent.**
116
+
117
+ ```jsx
118
+ {/* ❌ Misses edge highlight, no theme tuning, no inspector knob. */}
119
+ <DialogContent className="bg-background/50 backdrop-blur-md">
120
+
121
+ {/* ✅ */}
122
+ <DialogContent surface="glass">
123
+ ```
124
+
125
+ **DO NOT skip DialogTitle.** Screen readers announce the title on open — without it the dialog reads as "[unlabeled dialog]". If the design has no visible title, wrap a visually-hidden title:
126
+
127
+ ```jsx
128
+ <DialogHeader>
129
+ <DialogTitle className="sr-only">Image preview</DialogTitle>
130
+ </DialogHeader>
131
+ ```
132
+
133
+ **DO NOT use Dialog for ambient messaging.** Toast for transient ("Saved"), Callout for inline ("3 unread comments"), Dialog only when the user MUST respond before continuing.
@@ -6,17 +6,26 @@ props:
6
6
  - DropdownMenu: open?, defaultOpen?, onOpenChange?, modal? (default true)
7
7
  - DropdownMenuTrigger: asChild?: boolean — usually wraps a Button
8
8
  - DropdownMenuContent: align? "start" | "center" | "end"; side? "top" | "right" | "bottom" | "left"; sideOffset? number
9
+ - DropdownMenuContent: surface? (solid | translucent | glass | glass-strong) — what the menu surface is *made of*. `solid` (default) is `bg-popover`. `translucent` matches Apple HIG / iOS menu sheets. `glass` for menus floating over rich canvases.
10
+ - DropdownMenuSubContent: surface? (solid | translucent | glass | glass-strong) — same axis applied to nested submenu surfaces
9
11
  - DropdownMenuItem: onSelect?, disabled?, asChild?, inset?
10
12
  - DropdownMenuCheckboxItem / DropdownMenuRadioItem: checked? / value, onCheckedChange? / onValueChange? (radio is on the group)
11
13
  - DropdownMenuSub / DropdownMenuSubTrigger / DropdownMenuSubContent: nested menu — sub-trigger shows children, sub-content holds the deeper items
12
14
  - DropdownMenuShortcut: children — right-aligned kbd hint
13
15
  when_to_use: A small action menu attached to a trigger — overflow "…" buttons on cards, user-avatar menus in headers, "Insert" menus in editors. For a full searchable list, use Command. For ONE primary action plus a secondary, use a Button next to a smaller ghost Button instead of a dropdown.
14
16
  composes_with: [Button (as trigger asChild), Avatar (user menu), Card (overflow on a tile), Tooltip (on the trigger)]
15
- aliases: [dropdown, dropdown menu, overflow menu, kebab menu, more menu, action menu, context-style menu, menu, pull-down menu, pulldown menu, context menu, popup menu, actions menu]
17
+ aliases: [dropdown, dropdown menu, overflow menu, kebab menu, more menu, action menu, context-style menu, menu, pull-down menu, pulldown menu, context menu, popup menu, actions menu, glass menu, frosted menu, ios menu, hig menu]
16
18
  ---
17
19
 
20
+ DropdownMenuContent sits at elevation-4. Pick the material from the scenarios below — the `surface` prop is the discoverable lever.
21
+
22
+ ---
23
+
24
+ ### Scenario 1 — Overflow menu on a row/card (default opaque)
25
+
26
+ The canonical "…" menu attached to a row or card. The content behind is a list — readability of the menu items matters more than seeing what's underneath.
27
+
18
28
  ```jsx
19
- // Overflow menu on a card/row — trigger an icon-only Button.
20
29
  <DropdownMenu>
21
30
  <DropdownMenuTrigger asChild>
22
31
  <Button variant="ghost" size="icon" aria-label="Open menu">
@@ -26,6 +35,7 @@ aliases: [dropdown, dropdown menu, overflow menu, kebab menu, more menu, action
26
35
  <DropdownMenuContent align="end">
27
36
  <DropdownMenuItem onSelect={onDuplicate}>
28
37
  <Copy /> Duplicate
38
+ <DropdownMenuShortcut>⌘D</DropdownMenuShortcut>
29
39
  </DropdownMenuItem>
30
40
  <DropdownMenuItem onSelect={onShare}>
31
41
  <Share2 /> Share
@@ -38,8 +48,87 @@ aliases: [dropdown, dropdown menu, overflow menu, kebab menu, more menu, action
38
48
  </DropdownMenu>
39
49
  ```
40
50
 
41
- DropdownMenuContent ships at elevation-4. For frosted overlays on rich canvases, opt into glass:
51
+ `solid` is the right default. Menu items are read-targets give them a clean opaque background.
52
+
53
+ ---
54
+
55
+ ### Scenario 2 — Translucent menu (iOS / Apple HIG)
56
+
57
+ You want the iOS-native menu feel: light translucency that picks up the colour of whatever's beneath without committing to a full blur. The Apple HIG canonical material for context menus.
58
+
59
+ ```jsx
60
+ <DropdownMenu>
61
+ <DropdownMenuTrigger asChild>
62
+ <Button variant="ghost" size="icon"><MoreVertical /></Button>
63
+ </DropdownMenuTrigger>
64
+ <DropdownMenuContent
65
+ surface="translucent"
66
+ className="shadow-elevation-4"
67
+ align="end"
68
+ >
69
+ <DropdownMenuLabel>Sort by</DropdownMenuLabel>
70
+ <DropdownMenuRadioGroup value={sort} onValueChange={setSort}>
71
+ <DropdownMenuRadioItem value="recent">Most recent</DropdownMenuRadioItem>
72
+ <DropdownMenuRadioItem value="alpha">A–Z</DropdownMenuRadioItem>
73
+ <DropdownMenuRadioItem value="size">Size</DropdownMenuRadioItem>
74
+ </DropdownMenuRadioGroup>
75
+ </DropdownMenuContent>
76
+ </DropdownMenu>
77
+ ```
78
+
79
+ 82% opacity. The background tints the menu without demanding the user filter it out.
80
+
81
+ ---
82
+
83
+ ### Scenario 3 — Glass menu in a canvas tool
84
+
85
+ Studio's layer-context menu, an image editor's right-click, a slide-tool insert menu. The canvas behind is the work. Glass lets the menu float without cutting a hole through the work.
86
+
87
+ ```jsx
88
+ <DropdownMenu>
89
+ <DropdownMenuTrigger asChild>
90
+ <Button variant="ghost" size="icon"><Plus /></Button>
91
+ </DropdownMenuTrigger>
92
+ <DropdownMenuContent
93
+ surface="glass"
94
+ className="shadow-elevation-4 w-56"
95
+ align="start"
96
+ >
97
+ <DropdownMenuLabel>Insert</DropdownMenuLabel>
98
+ <DropdownMenuItem><LayoutTemplate /> Layout</DropdownMenuItem>
99
+ <DropdownMenuItem><Image /> Media</DropdownMenuItem>
100
+ <DropdownMenuItem><Code2 /> Code block</DropdownMenuItem>
101
+ <DropdownMenuSeparator />
102
+ <DropdownMenuSub>
103
+ <DropdownMenuSubTrigger><Sparkles /> AI suggestion</DropdownMenuSubTrigger>
104
+ <DropdownMenuSubContent surface="glass" className="shadow-elevation-4">
105
+ <DropdownMenuItem>Layout variant</DropdownMenuItem>
106
+ <DropdownMenuItem>Tone shift</DropdownMenuItem>
107
+ <DropdownMenuItem>Density pass</DropdownMenuItem>
108
+ </DropdownMenuSubContent>
109
+ </DropdownMenuSub>
110
+ </DropdownMenuContent>
111
+ </DropdownMenu>
112
+ ```
113
+
114
+ Pass `surface="glass"` to BOTH the root content AND the sub-content — submenus default to `solid` so a glass parent with an opaque child looks broken. Match the surface consistently down the menu tree.
115
+
116
+ ---
117
+
118
+ ### Anti-patterns
119
+
120
+ **DO NOT roll glass by hand on DropdownMenuContent.**
42
121
 
43
122
  ```jsx
44
- <DropdownMenuContent className="gds-surface-glass">…</DropdownMenuContent>
123
+ {/* ❌ Misses the iOS-native edge highlight + theme blur tuning. */}
124
+ <DropdownMenuContent className="bg-popover/55 backdrop-blur-md">
125
+
126
+ {/* ✅ */}
127
+ <DropdownMenuContent surface="glass">
45
128
  ```
129
+
130
+ **DO NOT mix surfaces between content and sub-content.** A glass root with a solid submenu (or vice-versa) reads as two materials competing for attention. Pick one for the whole tree.
131
+
132
+ **DO NOT use DropdownMenu for searchable lists.** Past ~7 items the menu becomes a scrollable list and the right primitive is Command (a search-first list inside a Popover or Dialog).
133
+
134
+ **DO NOT put long-form text in menu items.** Items are action labels — verbs. If you need help text, that's a Popover surface, not a menu.
@@ -6,13 +6,21 @@ props:
6
6
  - HoverCard: open?, defaultOpen?, onOpenChange?, openDelay? (default 700), closeDelay? (default 300)
7
7
  - HoverCardTrigger: asChild?: boolean — usually a Link or Button
8
8
  - HoverCardContent: side?, align?, sideOffset?, alignOffset?, className?
9
- when_to_use: Rich preview content surfaced on hover user profile mini-cards on @-mentions, link previews, definition popups. Pointer-only by design (no touch-friendly trigger); pair with a click target for touch devices, or fall back to Popover. NEVER use HoverCard for critical info — if the user can't reach it via keyboard or touch, it might as well not exist for accessibility.
10
- composes_with: [Avatar (user preview), Card (richer content), Link (the trigger)]
11
- aliases: [hover card, hover preview, mention preview, profile peek, link preview, rich tooltip, link preview card, profile hover, peek card]
9
+ - HoverCardContent: surface? (solid | translucent | glass | glass-strong) what the preview surface is *made of*. `solid` (default) is `bg-popover`. `glass` for hover previews over rich content (a media feed, a layout canvas).
10
+ when_to_use: Rich preview content surfaced on hover — user profile mini-cards on @-mentions, link previews, definition popups, layer-thumbnail peeks. Pointer-only by design (no touch-friendly trigger); pair with a click target for touch devices, or fall back to Popover. NEVER use HoverCard for critical info — if the user can't reach it via keyboard or touch, it might as well not exist for accessibility.
11
+ composes_with: [Avatar (user preview), Card (richer content), Link (the trigger), MediaSurface (link/layer previews), Code (snippet previews)]
12
+ aliases: [hover card, hover preview, mention preview, profile peek, link preview, rich tooltip, link preview card, profile hover, peek card, glass preview, frosted preview]
12
13
  ---
13
14
 
15
+ HoverCardContent sits at elevation-4. The surface choice depends entirely on what's behind the trigger.
16
+
17
+ ---
18
+
19
+ ### Scenario 1 — User mention preview (default opaque)
20
+
21
+ The trigger is inline text in a comment thread, document, or feed. The reader's eye is on the prose; the hover-card needs to feel like a small contained card popping up next to the link. Opaque is correct.
22
+
14
23
  ```jsx
15
- // User mention preview — pointer-only enrichment.
16
24
  <HoverCard>
17
25
  <HoverCardTrigger asChild>
18
26
  <a href="/u/elena" className="font-medium underline">@elena</a>
@@ -28,8 +36,94 @@ aliases: [hover card, hover preview, mention preview, profile peek, link preview
28
36
  <span className="text-sm text-muted-foreground">
29
37
  Design lead · Joined Mar 2025
30
38
  </span>
39
+ <span className="text-sm">Currently focused on the layout-quality skill suite.</span>
31
40
  </Stack>
32
41
  </Row>
33
42
  </HoverCardContent>
34
43
  </HoverCard>
35
44
  ```
45
+
46
+ ---
47
+
48
+ ### Scenario 2 — Glass layer preview in a canvas tool
49
+
50
+ You're hovering a layer name in the Studio layer list. The canvas alongside shows the actual layer in context. A glass hover-card carrying a thumbnail of the layer keeps the canvas visible AND gives the preview presence.
51
+
52
+ ```jsx
53
+ <HoverCard openDelay={300}>
54
+ <HoverCardTrigger asChild>
55
+ <button className="text-sm hover:underline">Hero card · v0</button>
56
+ </HoverCardTrigger>
57
+ <HoverCardContent
58
+ surface="glass"
59
+ className="w-80 shadow-elevation-4"
60
+ side="right"
61
+ align="start"
62
+ >
63
+ <Stack gap="sm">
64
+ <MediaSurface
65
+ aspect="video"
66
+ source={{ kind: "image", src: "/previews/hero-v0.png" }}
67
+ alt="Hero card v0 thumbnail"
68
+ />
69
+ <Stack gap="xs">
70
+ <span className="text-sm font-medium">Hero card · v0</span>
71
+ <span className="text-xs text-muted-foreground">Last edited 2m ago by Elena</span>
72
+ </Stack>
73
+ </Stack>
74
+ </HoverCardContent>
75
+ </HoverCard>
76
+ ```
77
+
78
+ Tighter `openDelay` (300ms vs the default 700) because the user is scanning a list — they want previews to come up faster.
79
+
80
+ ---
81
+
82
+ ### Scenario 3 — Code snippet preview (translucent)
83
+
84
+ You're showing a hover preview of a code reference (a function name in docs, a symbol in a comment). Translucent lets the page peek through without committing to glass blur — feels lighter for a quick read.
85
+
86
+ ```jsx
87
+ <HoverCard>
88
+ <HoverCardTrigger asChild>
89
+ <code className="font-mono text-sm rounded bg-muted px-1.5 py-0.5">surfaceBg()</code>
90
+ </HoverCardTrigger>
91
+ <HoverCardContent
92
+ surface="translucent"
93
+ className="w-96 shadow-elevation-4 p-0"
94
+ >
95
+ <Stack gap="xs" className="p-4 pb-2">
96
+ <span className="text-sm font-medium">surfaceBg(surface, defaultBgClass)</span>
97
+ <span className="text-xs text-muted-foreground">@gradeui/ui · lib/surface</span>
98
+ </Stack>
99
+ <Code
100
+ source={`function surfaceBg(surface, defaultBgClass) {
101
+ return surface === "solid" ? defaultBgClass : "";
102
+ }`}
103
+ language="ts"
104
+ bare
105
+ className="text-xs p-4"
106
+ />
107
+ </HoverCardContent>
108
+ </HoverCard>
109
+ ```
110
+
111
+ ---
112
+
113
+ ### Anti-patterns
114
+
115
+ **DO NOT use HoverCard on touch devices for critical info.** There's no hover on touch — the preview is unreachable. Either provide a click fallback or use Popover.
116
+
117
+ **DO NOT roll glass by hand on HoverCardContent.**
118
+
119
+ ```jsx
120
+ {/* ❌ */}
121
+ <HoverCardContent className="bg-popover/60 backdrop-blur-md">
122
+
123
+ {/* ✅ */}
124
+ <HoverCardContent surface="glass">
125
+ ```
126
+
127
+ **DO NOT use HoverCard for tooltips.** Tooltips are tiny, label-only, and dismiss instantly. HoverCard is for rich content with delay. If the content is a few words, reach for Tooltip.
128
+
129
+ **DO NOT use HoverCard as a primary navigation surface.** It dismisses on pointer-out — if the user has to traverse a path to reach a button inside, the preview will close before they get there.