How we adopt @plane/propel into the existing Plane codebase and evolve its APIs as
we go. This is the dev-facing companion to design.md (the shared
property + anatomy vocabulary).
We are not trying to specify every component perfectly up front. We let the real codebase tell us what's missing:
- We don't pre-answer every design question. Open questions about what should be fixed vs. adjustable on each component don't block adoption — we move with what we know.
- We use what we have to refactor plane-ee. propel as it stands today is enough to start replacing the existing UI.
- We find the shortcomings, and update propel. Every place propel can't yet express what plane-ee needs is a real, prioritized signal — not a hypothetical.
- We discover the design constraints by making the breaking changes. The refactor surfaces the constraints that matter; we capture them as we hit them.
- The end state: propel represents all of plane-ui. Once it does, humans and AI can build Plane UI out of propel, because every pattern Plane needs lives in the library.
The throughline is make everything explicit first, add defaults last. Explicit APIs turn "what does this component need?" into a question the type-checker answers for us.
-
No default props at the beginning. Every axis is required; nothing is implied. (This matches propel's standing rule of no
cvadefaultVariants— see thepropel-no-default-variantsconvention.) -
Design for what we know now. Model only the props and parts we can justify today. Don't speculate.
-
Expose required changes through the
elementslayer as we learn. When the refactor proves a component needs an axis it doesn't have, add it to theelementsprimitive first, then let thecomponentscompositions follow. -
Use required props to find every call site. When a component grows a new axis, make it required rather than defaulted, so TypeScript lights up every place that must choose. Worked example:
A component ships with no
variant. The refactor reveals it actually renders two ways,"light"and"dark", and today everything implicitly gets"light".// before: one implicit look type Props = { … }; // after: make the axis explicit AND required — do NOT default it to "light" type Props = { variant: "light" | "dark"; … };
Now every existing call site is a type error. Each error is a decision we get to make deliberately (
lightordark) instead of inheriting a silent default — so we fix exactly the right places and miss none. -
Let AI set the defaults at the finish line. Once we give the green light that a component is "finished," its real-world usage tells us which value is the common case. At that point we can have AI analyze the call sites and set sensible defaults — turning required props back into optional ones where it's safe, with evidence behind each default instead of a guess.
The payoff of doing it in this order: defaults are derived from how the component is actually used across Plane, not assumed before we've used it once.
Every propel primitive wraps a Base UI component, and Base
UI's render prop is how we compose them. A few rules we've learned — they apply
whenever you give a behavior part (a Trigger, a Close) the look of a styled
primitive (Button, IconButton).
render lets a Base UI part render as another element/component, merging its
resolved props (event handlers, aria-*, data-*, ref) onto it. Crucially,
Base UI's mergeProps concatenates className (it does not replace it) and
passes the merged result down to the render target.
To make a trigger or close look like a Button, write the styled primitive as the
outer element and pass the dialog/menu part through its render:
// ✅ correct — Button's look wins, AlertDialogClose supplies the close behavior
<Button variant="primary" tone="danger" magnitude="xl" render={<AlertDialogClose />}>
Delete
</Button>
// ✅ corner close — IconButton for the icon-only ✕
<IconButton variant="ghost" tone="neutral" magnitude="lg" aria-label="Close" render={<DialogClose />}>
<X />
</IconButton>Not the reverse:
// ❌ wrong — renders a bare/ghost element, NOT a styled button
<AlertDialogClose render={<Button variant="secondary" tone="neutral" magnitude="xl" />}>
Cancel
</AlertDialogClose>Many propel parts force their own className (e.g. AlertDialogClose applies
alertDialogCloseVariants()), and every propel primitive is written
className={variants()} {...props} — so props.className always wins over the
element's own className. Combined with Base UI concatenating and passing className
to the render target:
- Styled primitive outside: its
buttonVariantsis the className that gets spread last onto the inner part → the Button look wins, and the inner leaf (the Base UIClose) still wires the behavior. ✅ - Styled primitive inside
render: the outer part's forced variant className is passed into the Button and clobbersbuttonVariants→ you get a bare ghost element. ❌ (Verified:Cancel/Deleterendered as plain text this way.)
So the mnemonic is: the element whose look you want goes on the outside.
With the styled primitive outside, the inner part contributes everything behavioral
— onClick to open/close, aria-haspopup/aria-expanded, focus management,
data-* state attributes — while children, ref, and the visible styling come from
the outer primitive. Use Button for text buttons and IconButton (icon as
children, required aria-label) for icon-only controls like the corner ✕.
This relies on propel's consistent className={variants()} {...props} ordering
(props win) in every primitive. Keep that convention — it's also why no propel
component takes a className prop (see propel-no-className-prop-internal).
The remaining work is to formalize component anatomy from what the stories already show, in three steps:
- Define the
elementscomponent anatomy from the styles used in Storybook. Where a story lays out a component with raw<div className="flex …">, that markup names a missing part. We've already redrawn these layout boundaries in the overlay stories (groupingTitle+Descriptionas an intro and the buttons as actions, with the parent owning the gap) — that's the input for naming the parts. - Name the anatomy extensions that differ from standard Base UI anatomy. Most
parts are Base UI's (
Popup,Title,Close, …); the extensions are the layout regions Base UI doesn't ship (Header/Body/Actions/ an intro group). Decide these names (and the open ownership questions — where padding lives, the corner-close treatment) with design. Seedesign.md→ Component anatomy. - Define the new compositions for each
elementsprimitive's API. With the parts named, give each primitive the parts as real surfaces, and update thecomponents-tier ready-mades to compose them — so consumers stop writing raw layout and compose the anatomy instead.
This is sequenced deliberately: we don't harden a part into an API until its boundary is proven correct in a story first.