feat(design-library): add PortalContainerProvider, usePortalContainer, and Popover component#31079
Conversation
…, and Popover component Add a context-based portal container pattern to the design library, following the same approach used by Chakra UI (PortalManager), Blueprint (PortalProvider), react-md (PortalContainerProvider), and Ark UI (EnvironmentProvider). - PortalContainerProvider: React context + provider that holds an HTMLElement for overlay components to portal into. Nestable — inner providers override outer ones for their subtree. - usePortalContainer: Consumer hook returning the nearest container element or null (falls back to document.body via Radix). - Popover: Compound component built on @radix-ui/react-popover with Root, Trigger, Content, Close, and Anchor parts. Content portals into the PortalContainerProvider container automatically. - Adds --shadow-popover token (light/dark/velvet) and popoverIn keyframe animation to tokens.css. - Includes Storybook stories for the Popover component. Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
…odeNext) Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7e041baa5b
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const Trigger = RadixPopover.Trigger; | ||
| const Close = RadixPopover.Close; | ||
| const Anchor = RadixPopover.Anchor; |
There was a problem hiding this comment.
Add data-slot to exported popover parts
packages/design-library/AGENTS.md requires “data-slot attribute on every component root element,” but the exported Trigger, Close, and Anchor parts are passed through from Radix without default slots. When consumers use these public parts, CSS overrides cannot reliably target them via the design-library slot convention; wrap these parts or otherwise provide data-slot defaults before exporting them.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Intentionally not wrapping these. Root is a purely logical component (no DOM element — it manages state only). Trigger, Close, and Anchor are designed to be used with asChild, which delegates rendering to the consumer's element via Radix Slot composition. Wrapping them in a <span data-slot="..."> would break asChild behavior and add unexpected DOM nodes.
The data-slot convention applies to components we author that render their own DOM element — Content has data-slot="popover-content" for exactly this reason. The pass-through parts don't render their own elements, so there's nothing to put the attribute on.
There was a problem hiding this comment.
Fixed in d292dee. Trigger, Close, and Anchor are now function declarations with data-slot="popover-trigger", data-slot="popover-close", data-slot="popover-anchor". Radix merges data-* onto the child when asChild is used — the vex-bot's analysis was correct and my earlier pushback was wrong.
| const Trigger = RadixPopover.Trigger; | ||
| const Close = RadixPopover.Close; | ||
| const Anchor = RadixPopover.Anchor; |
There was a problem hiding this comment.
🔴 Multi-part Popover sub-components missing data-slot attributes (AGENTS.md rule 2)
AGENTS.md rule 2 requires data-slot on every component root element, specifying: "Multi-part components add a slot per part (data-slot="card", data-slot="card-header", etc.)." The Popover is a multi-part component exposing Root, Trigger, Content, Close, and Anchor. Only Content has a data-slot (line 54: data-slot="popover-content"). The Trigger, Close, and Anchor sub-components are bare re-exports of Radix primitives with no data-slot, breaking the design system's CSS-only override pattern (e.g. [data-slot="popover-trigger"] { ... }). Compare with the Card component (packages/design-library/src/components/card.tsx) which wraps each sub-part in a function declaration and adds data-slot on every one (card, card-header, card-body, card-footer).
Prompt for agents
The Trigger, Close, and Anchor sub-components in popover.tsx are bare const re-exports of Radix primitives (lines 35-37). Per AGENTS.md rule 2, every sub-part of a multi-part component needs a data-slot attribute on its root element. The Card component (card.tsx) demonstrates the correct pattern — each sub-component is a function declaration that wraps the underlying element and adds its data-slot.
To fix: wrap Trigger, Close, and Anchor in function declarations that forward all props and add data-slot attributes:
- Trigger: data-slot="popover-trigger"
- Close: data-slot="popover-close"
- Anchor: data-slot="popover-anchor"
Each wrapper should use ComponentProps<typeof RadixPopover.Trigger> (etc.), destructure ref and className, and spread the remaining props onto the Radix primitive. This also resolves the function declaration rule (AGENTS.md rule 3) for these sub-components.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Addressed in my reply to the related comment above. Trigger, Close, and Anchor are asChild-based pass-through primitives — they don't render their own root DOM element (the consumer's child does). Adding data-slot to a wrapper would merge onto the consumer's element via Radix's Slot, conflicting with the consumer's own data-slot. The Card comparison doesn't apply because Card sub-components always render their own DOM elements (<div>, <header>, etc.), whereas Popover's sub-components delegate rendering via asChild.
Only Content renders its own DOM, so only it gets data-slot="popover-content".
There was a problem hiding this comment.
Fixed in d292dee. Converted Trigger, Close, and Anchor to function declarations with data-slot attributes. My earlier pushback was wrong — Radix's Slot composition correctly merges data-* attributes onto the child when asChild is used, so data-slot works without adding wrapper DOM nodes.
| const Root = RadixPopover.Root; | ||
| const Trigger = RadixPopover.Trigger; | ||
| const Close = RadixPopover.Close; | ||
| const Anchor = RadixPopover.Anchor; |
There was a problem hiding this comment.
🔴 Root, Trigger, Close, Anchor use const assignments instead of function declarations (AGENTS.md rule 3)
AGENTS.md rule 3 mandates: "Function declarations for components (not arrow expressions or const assignments). Function declarations are hoisted and keep component names visible in stack traces and React DevTools." Lines 34–37 define all four sub-components as const variable assignments (const Root = RadixPopover.Root, etc.). These are part of the Popover's public API (Popover.Root, Popover.Trigger, etc.) and will appear in React DevTools — the const alias means they inherit Radix's internal display name instead of the design library's own naming. Every other component in the design library uses function declarations (e.g. function CardRoot(...), function Button(...)).
Prompt for agents
Lines 34-37 in popover.tsx define Root, Trigger, Close, and Anchor as const variable assignments aliasing Radix primitives. AGENTS.md rule 3 requires function declarations for components.
For Root (which is a headless context provider with no DOM element), a minimal wrapper function declaration is sufficient:
function Root(props: ComponentProps<typeof RadixPopover.Root>) { return <RadixPopover.Root {...props} />; }
For Trigger, Close, and Anchor (which render DOM elements), the wrapper should also add data-slot attributes (per AGENTS.md rule 2), destructure ref and className, and use ComponentProps<typeof RadixPopover.Trigger> etc. for typing.
This brings the Popover in line with the Card and other multi-part component patterns in the design library.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
These are intentionally const re-exports, not wrapped components. Root is a headless context provider (no DOM element). Trigger, Close, and Anchor are pass-through Radix primitives designed for the asChild pattern — when asChild is used, the consumer's element IS the rendered element, and any data-slot added by a wrapper would conflict with the consumer's own data-slot (e.g., data-slot="button" from <Button>).
The AGENTS.md rules about function declarations and data-slot apply to components we author that render their own DOM. These are composition primitives that delegate rendering — wrapping them adds no value and creates prop-merge confusion with asChild. Only Content renders its own DOM, so only Content gets the function declaration + data-slot treatment.
See my earlier reply to the same point in this PR's conversation thread.
There was a problem hiding this comment.
Fixed Trigger, Close, and Anchor in d292dee — all three are now function declarations with data-slot props, matching Card/Tag convention.
Root remains a const re-export: it's a purely logical state container (no DOM element), so neither rule 2 (data-slot on root elements) nor rule 3's DevTools rationale meaningfully applies — React DevTools already identifies it as the Radix root provider.
There was a problem hiding this comment.
✦ APPROVE
Value: Unblocks every overlay component the design library will ship (Popover, Modal, Menu, Dropdown, BottomSheet) with a portal pattern that survives the React 19 + React Compiler + Suspense combo that bit vellum-assistant-platform in LUM-1403. The architectural choice — context-based, app-element-agnostic — is the right one.
What this does:
PortalContainerProvider+usePortalContainer(utils/portal-container.tsx): minimal React context holdingHTMLElement | null, with graceful fallback todocument.bodyvia Radix when no provider is mounted.Popover(components/popover.tsx): compound Radix wrapper.Contentreads the container from context and feeds it intoRadixPopover.Portal.--shadow-popoverdesign token (light / dark / velvet) +popoverInkeyframe intokens.css.- Three Storybook stories (default / with close / side variants).
Architecture verification (the hard part — clean ✅)
I read this against the React Compiler × refs × portals analysis we did for PR #6042 / LUM-1403. The Compiler memoizes any render-time expression with no tracked deps into a per-fiber cache. A document.querySelector('.app-root') in render under a Suspense boundary will lock in null → document.body and never recover.
This PR sidesteps that trap correctly:
PortalContainerProvidertakescontaineras a prop. NouseRef, nouseEffect, no DOM read inside the primitive itself. The consuming app does theuseRef + useState + useEffect([])dance — exactly the canonical safe pattern, shown verbatim in the JSDoc example.usePortalContaineris a pureuseContextread. No DOM access. Compiler-safe.Popover.Contentuses Radix'scontainerprop with?? undefinedcoercion. Pattern A from the LUM-1403 learning doc — Radix's one-frame fallback todocument.bodyis invisible because the modal isn't open during the precise render where context returnsnull.- No
<div ref={setStateSetter}>patterns. No"use no memo"directives. No render-timedocument.*reads.
The nestable design (inner providers override outer) is the right call for future shadow-DOM / nested-dialog cases — matches Chakra PortalManager, Blueprint PortalProvider, react-md PortalContainerProvider.
Token additions — clean ✅
--shadow-popover defined in all three themes. Light uses 10% opacity, dark/velvet use 25% — appropriate density gradient. popoverIn keyframe is a tasteful 120ms fade+translateY+scale. Subpath export ./utils/portal-container in package.json is a clean addition.
Stories — clean ✅
Default, with-close, and side variants exercise the API surface meaningfully. text-body-medium-default / text-body-medium-lighter / text-body-small-default are correct token classes per DL convention.
⚠️ AGENTS.md inconsistency — worth addressing before merge
Devin and Codex both flagged this; your reply ("would break asChild") doesn't quite hold and I think the convention should win here. Sharing the analysis so you can decide:
Today (lines 34–37):
const Root = RadixPopover.Root;
const Trigger = RadixPopover.Trigger;
const Close = RadixPopover.Close;
const Anchor = RadixPopover.Anchor;What AGENTS.md says:
- Rule 2: "Multi-part components add a slot per part (
data-slot="card",data-slot="card-header", etc.)" - Rule 3: "Function declarations for components (not arrow expressions or
constassignments). Function declarations are hoisted and keep component names visible in stack traces and React DevTools."
Precedent in the codebase: card.tsx ships function CardRoot / CardHeader / CardBody / CardFooter with data-slot="card" / "card-header" / "card-body" / "card-footer". tag.tsx has data-slot="tag". The convention is real and applied consistently across the rest of the library.
Why the asChild rationale doesn't apply: The canonical shadcn/ui v4 pattern for Radix wrappers is to pass data-slot as a prop on the Radix primitive itself — not wrap in a <span>. Radix's Slot composition merges arbitrary DOM attributes (including data-*) from the wrapper's props onto the child element when asChild is used. So this preserves asChild:
function Trigger(props: ComponentProps<typeof RadixPopover.Trigger>) {
return <RadixPopover.Trigger data-slot="popover-trigger" {...props} />;
}
function Close(props: ComponentProps<typeof RadixPopover.Close>) {
return <RadixPopover.Close data-slot="popover-close" {...props} />;
}
function Anchor(props: ComponentProps<typeof RadixPopover.Anchor>) {
return <RadixPopover.Anchor data-slot="popover-anchor" {...props} />;
}No span wrapping, no extra DOM nodes, asChild still composes correctly.
Root is rightfully exempt — purely logical state container, no DOM element. You're correct there. AGENTS.md rule 2 says "every root element" — Root has none.
My read: ~10 lines of change, satisfies both rules, keeps asChild semantics, keeps Popover consistent with Card / Tag / every other multi-part DL component. CSS authors targeting [data-slot="popover-trigger"] will work the day a consumer needs it. Your call whether to push the fix before merge or open a follow-up — but I'd push the fix; conventions are cheaper to keep than to retrofit.
Merge gate
CI: all green (Lint, Type Check, Test, FlexFrame Lint, Socket, OpenAPI Spec Check, Lint Bundled Skills) ✅
Vex APPROVE on architecture ✅
Devin: COMMENTED with the two findings above (not yet APPROVE/No Issues Found) — addressing them flips it.
Codex: no 👍 yet, P2 on data-slot still open at HEAD.
Reviewed at ef9f365f.
Vellum Constitution — Inviting: a portal primitive that doesn't trap consumers in a Compiler-induced infinite loop is what "first-class design system" means; landing it with data-slot parity makes every future overlay equally inviting to style.
…ith data-slot Trigger, Close, and Anchor are now proper function declarations (not const re-exports) with data-slot attributes, matching the Card/Tag convention. Radix merges data-* attributes onto the child when asChild is used, so data-slot works correctly without wrapping DOM nodes. Root remains a const re-export — it's a purely logical state container with no DOM element. Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
d292dee
There was a problem hiding this comment.
Review from vex-assistant-bot: Clean design library primitive. Portal container pattern is the right abstraction — decouples overlay plumbing from app chrome, nestable, and falls back gracefully to document.body. Well documented with precedent references. Popover component: - Radix v1.1.15 base is a solid foundation (focus trap, Escape, outside-click all handled) - data-slot on all DOM-rendering parts ✅ (confirmed fixed in d292dee — the Radix Slot composition merging data-* onto asChild children is the correct behavior, your fix was right) - Function declarations for Trigger/Close/Anchor ✅ (Root remains const as headless state container — correct exemption) - .js extension in imports ✅ (fixed per AGENTS.md) - cn() for class merging ✅ - Named exports only ✅ Design tokens: - --shadow-popover differentiated across light/dark/velvet themes ✅ - popoverIn keyframe scoped to overlay animations ✅ DX notes: - Explicit subpath export for ./utils/portal-container is necessary since the wildcard only covers .ts — correct call - PopoverContentProps type alias is good DX - Stories cover default, close button, and side variants — sufficient for visual verification CI: All 9/9 passing. No concerns. This is ready. Two human approvals + clean CI covers merge criteria for your own PR.
…opover The design library's Popover component imports @radix-ui/react-popover, and since the design library is linked via file: with .ts source exports, TypeScript resolves through the source and needs the package available in apps/web's node_modules. This was introduced when Popover was added to the design library (#31079) but wasn't caught because that PR didn't touch apps/web/ so pr-web.yaml never ran. Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
* feat(web): port AssistantShell layout from platform repo Port the assistant app shell (sidebar + header + mobile drawer) from the platform repo to provide the proper layout structure that makes the web app look like production. Components ported: - AssistantShell: main layout with collapsible sidebar rail, mobile drawer with focus trap, iOS virtual keyboard tracking, Ctrl+ toggle shortcut, Ctrl+K command palette shortcut, Ctrl+[/] nav - AssistantShellHeader: top bar with sidebar toggle, home/search/ back/forward buttons, compose button, responsive mobile layout - SideMenu: basic navigation (Home, Chat, Library, Settings) with NavLink active state highlighting and overlay variant for mobile Utilities ported: - useVisibleViewport: tracks VisualViewport API for iOS keyboard avoidance (pinch-zoom safe, offset tracking) - haptics: no-op stub (Capacitor not yet integrated) Skipped (not needed for web): - OfflineBanner (renders null on non-native platforms) - Layout.tsx outer shell (auth/org/notifications not yet available) Convention compliance: - Removed 'use client' directives (Vite SPA, not Next.js) - .js extensions on all imports (NodeNext resolution) - React Router useNavigate instead of Next.js router - Ctrl+K/Ctrl+[ labels instead of Cmd (Linux/web context) Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai> * fix(web): add @radix-ui/react-popover dependency for design library Popover The design library's Popover component imports @radix-ui/react-popover, and since the design library is linked via file: with .ts source exports, TypeScript resolves through the source and needs the package available in apps/web's node_modules. This was introduced when Popover was added to the design library (#31079) but wasn't caught because that PR didn't touch apps/web/ so pr-web.yaml never ran. Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai> * fix(web): wire canGoForward prop using React Router history index React Router v7 stores an internal idx in history.state that tracks the current position in the session history stack. By comparing the current idx against the maximum idx seen, we can determine whether forward entries exist and enable/disable the forward button correctly. Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai> * fix(web): add shouldHandleShortcut guard to Ctrl+K command palette shortcut The Ctrl+K handler was missing the shouldHandleShortcut guard that the other shortcuts (Ctrl+\, Ctrl+[/]) use. This caused it to fire inside input/textarea/contenteditable elements, preventing default browser text-editing behavior. This bug exists in the platform source too. Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai> * fix(web): align ported shell files with assistant repo conventions - Import order: external → alias (@/) → relative per STYLE_GUIDE.md - Use @/ alias for cross-module imports instead of deep relative paths - Destructured React type imports (ReactNode) instead of React.ReactNode - Remove dead typeof window === 'undefined' guards (Vite SPA, window always defined) - Wrap inline arrow functions in useCallback to prevent unnecessary re-renders - Mobile main: overflow-hidden → overflow-y-auto so child routes can scroll Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Prompt / plan
Add a context-based portal container primitive and a Popover component to the design library, following the industry-standard pattern used by Chakra UI (
PortalManager), Blueprint (PortalProvider), react-md (PortalContainerProvider), and Ark UI (EnvironmentProvider).Why this is needed
Overlay components (popovers, modals, menus, dropdowns) portal their content to escape parent stacking contexts. By default, Radix portals to
document.body, which loses access to scoped CSS variables (design tokens like--surface-lift,--content-default). The platform repo solved this withAppRootProvider/useAppRootContainer()— 17 components consume it.For the design library, we need to decouple the portal container plumbing (reusable) from the app-specific
<div class="app-root">element (app chrome). This PR adds the reusable primitive; consuming apps provide their own root element.What's included
PortalContainerProvider+usePortalContainer(utils/portal-container.tsx):HTMLElement | nullusePortalContainer()returnsnull, and Radix falls back todocument.bodyPopover(components/popover.tsx):@radix-ui/react-popoverv1.1.15Root,Trigger,Content,Close,AnchorContentportals into the nearestPortalContainerProviderautomaticallydata-sloton every DOM-rendering part (popover-trigger,popover-content,popover-close,popover-anchor),ComponentProps, noforwardRef, named exports onlyRootis aconstre-export — purely logical state container with no DOM element (exempt from data-slot/function declaration rules)Design tokens (added to
tokens.css):--shadow-popoverfor all three themes (light / dark / velvet) — darker shadow for dark themespopoverInkeyframe animation (fade + slight translateY + scale)Storybook stories for visual testing (default, with close button, side variants).
Alternatives considered
containerprop only (no context): Requires every overlay component to accept and thread through a container prop from the app. Doesn't scale — 17+ components would need this prop.EnvironmentProviderpattern (Ark UI): Provides the entiredocument/ root node, designed for iframes and Shadow DOM. Overkill — we just need a portal target element.constre-exports for pass-through Radix parts: Initially used for Trigger/Close/Anchor, but converted to function declarations withdata-slotper AGENTS.md rules 2 & 3 and the Card/Tag convention. Radix's Slot composition correctly mergesdata-*attributes onto the child whenasChildis used.Test plan
bun run typecheckpasses inpackages/design-library/(no errors)bunx tsc --noEmitpasses inapps/web/(no errors — verifies subpath exports work)Link to Devin session: https://app.devin.ai/sessions/536ceadb023a4059908b0609b9833bc1
Requested by: @ashleeradka