**What** - Add `useTheme()` and `useThemeColorScheme()` as the public theme accessors, and migrate the 88 internal `useContext(ThemeContext)` call sites to them. `ThemeContext` stays exported. - Make `ThemeProvider` overridable and scopeable: new `applyToRoot` (default `true`), `overrides` (a `--t-*` map), and `className` props. When scoping is requested it renders a `display: contents` wrapper that also serves as the themed portal container, exposed via `ThemeScopeContext` / `useThemeContainer()`. `AppTooltip` and `Modal` portal into that container, falling back to `document.body`. - Document the `--t-*` override contract in the README; barrels regenerated. **Why** Consumers had no stable theme accessor (they reached into the raw context) and no supported way to re-theme. This adds both without behavior change. **Reviewer notes** - The default path is unchanged: `applyToRoot` defaults to `true`, so the colorScheme class still lands on `<html>` and portaled overlays (tooltips, dropdowns, modals) stay themed. The global class is load-bearing for body portals; scoping is opt-in. - `twenty-front` is untouched (migrating its consumers is a separate follow-up). <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22007?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
twenty-ui
Status: Cut-over complete (June 2026). All 192 components and all 70 stories were migrated from the legacy library with full public-API parity (export-identifier diff: 0 missing / 0 extra across all 13 subpath modules),
twenty-frontwas switched over totwenty-ui, and the legacytwenty-ui-deprecatedpackage was removed. Gates green: typecheck, lint, jest, build, storybook:build, storybook:test (225 stories incl. play functions + live axe gate), size. Remaining: the Argos visual-parity triage, the a11y fix pass (119 stories carry inherited-violationa11y: 'todo'overrides), the CI diff-table workflow, themodules/uitriage (Phase 4), and the publish pipeline (Phase 5). The sections below remain the design document for the full effort; see Migration outcomes for decisions taken during the port.
twenty-ui is Twenty's UI library, the successor to the legacy twenty-ui-deprecated package
(now removed). It is built on a headless component library and a zero-runtime, CSS-variable styling layer.
Goals
- Publish as a standalone, versioned npm package.
- Replace
twenty-uiintwenty-frontwith no visual change (same design, token for token). - Migrate every component currently exported by
twenty-ui-deprecated. (Done — June 2026.) - Absorb the generic, reusable UI currently living in
twenty-front/src/modules/ui(dropdowns, modals, tab lists, side panels, navigation, field inputs/displays, etc.), decoupling it from application concerns so it ships from the library. - Enforce a quality bar in CI: bundle size, render/load time, and accessibility, measured against the old library.
Current state (twenty-ui-deprecated)
| Aspect | Today |
|---|---|
| Exports | ~180 components across 13 subpath entry points (display, input, layout, navigation, feedback, components, theme, theme-constants, utilities, accessibility, assets, json-visualizer, testing) + 3 CSS files |
| Styling | Linaria (@linaria/react) compiled via @wyw-in-js/vite; theming via generated --t-* CSS variables |
| Behavior | Hand-rolled (modals, menus, tooltips, selects, etc.); react-tooltip for tooltips |
| Build | Vite library mode, dual ESM/CJS, vite-plugin-dts, auto-generated barrels |
| Icons | @tabler/icons-react re-exports + custom icons + Jotai-backed IconsProvider |
| Consumption | ~1,730 files in twenty-front import it (mostly display and theme-constants), plus twenty-front-component-renderer and twenty-sdk; imported by package name |
| Published | No (private: true) |
twenty-front/src/modules/ui/ (application-level UI) consumes twenty-ui-deprecated today. Its generic, reusable
components are now in scope — they migrate into twenty-ui (see Application-level UI migration).
Decision 1 — Headless library: Base UI
Adopt Base UI (mui/base-ui, published to npm as
@base-ui/react, MIT) as the behavioral foundation; build Twenty's visual
design on top of it.
| Base UI | shadcn/ui | Radix | |
|---|---|---|---|
| Distribution | npm package | copy-paste source | npm package |
| Styling | bring your own | Tailwind | bring your own |
| State styling | data-* + className-as-function |
(underlying primitive) | data-* |
| Maintenance | MUI team, full-time; frequent stable releases | community | WorkOS; slower cadence, creators departed |
Rationale
- Publishable and unstyled — Base UI ships as an npm dependency and imposes no styling, so consumers apply their own tokens. shadcn is copy-paste source (not installable) and Tailwind-coupled; it is suitable only as a scaffolding/reference tool, not the foundation.
- Active long-term investment — Base UI is maintained by the team behind Radix, Floating UI, and Material UI. Radix is a viable, still-maintained fallback, but its core authors now work on Base UI and its stable-release cadence has slowed.
- Modern, broad primitives — Combobox/Autocomplete with built-in search, Select, Number Field, Navigation Menu, Toast, etc., several of which replace hand-rolled or single-purpose dependencies in
twenty-ui. className-as-function-of-state pairs cleanly with CSS/SCSS Modules.- Small, tree-shakeable dependency tree; peer-compatible with the repo's React 18.
Pin the latest stable release at implementation time; isolate Base UI behind the package's own component APIs so upgrades stay localized.
Decision 2 — Styling: SCSS Modules (drop Linaria)
Use SCSS Modules (*.module.scss) over the existing CSS-variable theme. Drop Linaria.
| Approach | Runtime | Build complexity | Scoping | Verdict |
|---|---|---|---|---|
| Linaria (today) | zero | high (Babel + wyw-in-js) | auto | overkill |
| Plain global CSS | zero | none | none (collision risk) | unsafe for a library |
| CSS Modules | zero | none (native Vite) | auto | strong baseline |
| SCSS Modules | zero | low (sass only) |
auto | recommended |
| vanilla-extract | zero | medium (TS compile) | auto | viable alternative (typed tokens) |
Rationale
- Theming is already CSS variables and component state comes from Base UI as
data-*attributes, so the two features Linaria provides (JS theming and prop interpolation) are not needed. - SCSS Modules are zero-runtime and auto-scoped, native to Vite (no Babel/
wyw-in-js), and faster to build. - Sass mixins, maps, and
@eachcover variant/size generation and responsive breakpoints. - Type-safe class names via generated
*.module.scss.d.ts(vite-plugin-sass-dts).
Conventions: one Component.module.scss per component; tokens only via var(--t-*); state via
data-* selectors; multi-variant composition via clsx (or cva for a typed variants API);
shared mixins.scss / breakpoints.scss; global unscoped CSS only for theme variables, reset, and keyframes.
Architecture
packages/twenty-ui/
├── package.json # public exports mirror twenty-ui's subpath map
├── project.json # Nx targets: build, lint, test, storybook, size
├── vite.config.ts # library mode, no wyw-in-js
├── vitest.config.ts # storybook component tests
├── .storybook/
├── .size-limit.json # per-entry bundle budgets
├── scripts/ # generateBarrels.ts
└── src/
├── styles/ # global: reset, theme vars, mixins, breakpoints
├── theme/ theme-constants/
├── display/ input/ layout/ navigation/ feedback/ components/
├── accessibility/ utilities/ json-visualizer/ assets/ testing/
Public API parity. Keep the same subpath exports, component names, and prop signatures as
twenty-ui-deprecated so the final swap is a codemod + dependency rename, not a rewrite of ~1,730 files.
Keep auto-generated barrels and dual ESM/CJS + dts output. (Achieved: the export-identifier sets of
all 13 module barrels are identical between the two packages.)
Internal changes vs twenty-ui-deprecated: Linaria → SCSS Modules; hand-rolled behavior + react-tooltip
→ Base UI; prefer Base UI/CSS transitions over framer-motion where possible; keep the icon system
as-is. The internal path alias is @ui/* (same convention as the deprecated package, so ported files
diff cleanly against their sources).
Theming
theme-constants has ~943 importers and must be a drop-in replacement.
- Keep the public API identical:
ThemeProvider,ThemeContext,useTheme, thethemeCssVariablesshape,ThemeType, color helpers, and thetheme-light.css/theme-dark.cssexports. - Reuse
twenty-ui-deprecated's token values verbatim to guarantee identical design. - Tokens live in
src/theme/(THEME_LIGHT/THEME_DARK); the--t-*CSS variables and thethemeCssVariablesaccessor are static files mirrored token-for-token fromtwenty-ui-deprecated(matching its own static-CSS approach). - A theme parity test asserts the theme CSS and
themeCssVariablesstay identical totwenty-ui-deprecated's--t-*values.
Consuming the theme
- Wrap the app once in
ThemeProviderand importtwenty-ui/theme-light.cssandtwenty-ui/theme-dark.css. - Read theme values with the public hooks, not the raw context:
useTheme()returns the resolvedThemeType(numeric tokens such astheme.icon.size.sm). Outside a provider it yields the default light theme.useThemeColorScheme()returns the active'light' | 'dark'.
- Colors are consumed as
var(--t-*)strings viathemeCssVariables(the browser resolves them against the in-scope--t-*), so overriding the variables re-themes color automatically.
Customizing the theme (--t-* override contract)
The supported way to re-theme is to override the --t-* CSS variables. Their names are exactly the keys declared in theme-light.css / theme-dark.css. Two mechanisms:
- Globally — ship a stylesheet that redefines
--t-*under.light/.dark. This covers the whole document, including overlays portaled todocument.body. - Per
ThemeProvider— passoverrides(aThemeOverridesmap of--t-*name to value). The variables are applied to the provider's subtree.
ThemeProvider props:
colorScheme: 'light' | 'dark'— required.applyToRoot?: boolean(defaulttrue) — toggles the.light/.darkclass ondocument.documentElement. Keep ittrueso the whole document, including portaled overlays (tooltips, dropdowns, modals) rendered todocument.body, stays themed. Set itfalsewhen the host manages its own color-scheme class or when mounting more than one provider, so the providers don't clobber the root.overrides?: ThemeOverrides—--t-*variable overrides scoped to this provider's subtree.className?: string— extra class on the scoped wrapper.
Scoped theming and portals. When applyToRoot is false or overrides is set, the provider scopes the variables to a wrapper element. Overlays portal to document.body (outside that wrapper), so for them to inherit the scoped variables the provider also exposes a themed portal container via ThemeScopeContext; read it with useThemeContainer() and pass it as the overlay's portal container. The library's AppTooltip and Modal already do this, falling back to document.body when no scoped provider is present (the default global path is unchanged).
Component migration map
Components split into two buckets. (The migration is complete; see Migration outcomes below for where reality diverged from this map.)
Backed by a Base UI primitive (behavioral):
twenty-ui-deprecated |
Base UI |
|---|---|
Modal |
Dialog / AlertDialog |
AppTooltip, OverflowingTextWithTooltip |
Tooltip (removes react-tooltip) |
Toggle |
Switch |
Checkbox / Radio |
Checkbox / Radio (+ groups) |
Menu, MenuItem |
Menu / ContextMenu / Menubar |
TabButton |
Tabs |
SearchInput, text inputs |
Input + Field |
CardPicker |
RadioGroup / ToggleGroup |
ColorPicker |
Popover + custom |
ProgressBar |
Progress |
Avatar, AvatarGroup |
Avatar |
AnimatedExpandableContainer |
Collapsible / Accordion |
Pure presentation, built in-house (SCSS Modules): button family, typography, Chip/Pill/Tag/LinkChip,
Banner/Callout/Info/Status, Card/Section/Separator, Loader, TintedIconTile,
ColorSample, Checkmark, placeholders, the icon system, CodeEditor (Monaco), json-visualizer,
and the utilities / theme / testing / accessibility helpers.
Migration outcomes (June 2026)
Decisions taken during the port where reality diverged from the map above (public API parity won every time):
MenuItemfamily andTabButtondid NOT move to Base UI Menu/Tabs — those primitives require aRootcontext the deprecated standalone APIs don't have. They are pure presentation withdata-*state. The Base UIMenumapping applies to the Phase-4modules/uidropdown instead.AnimatedExpandableContainerkept framer-motion (sanctioned fallback): its exported utils are framer variant factories whose signatures are public API, and Collapsible/grid-rows could not match scrollHeight mid-animation retargeting or initial-mount animation.Avatar'sinvalidAvatarUrlsAtomV2and the icon system'siconsStateJotai atoms were kept — both are publicdisplayexports, overriding this doc's earlier "Jotai → local state" idea.CardContentkept framer-motion: its motion props are part of the public prop type and twenty-front'sCalendarDayCardContentpasses them.ProgressBaris pure SCSS (Base UI Progress was optional and not needed for parity);useProgressAnimationkeeps framer motion-values verbatim (public hook).- Known behavior deltas to watch in QA:
AppTooltipandModal(without an explicitcontainer) now portal todocument.body(Base UI), so overflow-clipping ancestors no longer clip them andAppTooltip's defaultmaxWidth: '40%'resolves against the body;Checkbox/Radiotest ids moved from the hidden input to the visible Base UI control. - The Storybook axe gate was inert until this migration —
.storybook/vitest.setup.tsdid not register the a11y addon annotations, soa11y.test: 'error'never ran. It is now live; 119 ported stories carry story-levela11y: { test: 'todo' }overrides (// TODO(a11y)comments) pending a dedicated fix pass. - The canonical styling pattern is established in
src/input/button/components/Button/Button.tsx+Button.module.scss:data-variant/accent/position/invertedattributes, Sass maps +@eachassigning--btn-*custom properties, all declarations on a flat(0,1,0)class sostyled(Component)consumer overrides keep working.
Application-level UI migration (twenty-front/src/modules/ui)
twenty-front/src/modules/ui holds ~250 application-level UI building blocks that consume twenty-ui-deprecated
today: display, feedback (snackbar/dialog managers), field (input + display), input
(incl. relation picker), layout (dropdown, modal, tab-list, side-panel, page, table, resizable-panel,
expandable-list, selectable-list, top-bar, …), navigation (drawer, breadcrumb, step-bar, menu-item),
drag-and-drop, suggestion, theme, and utilities (hotkey, scroll, focus, responsive, drag-select, …).
These are a different kind of migration than the twenty-ui-deprecated swap: they are stateful and
app-coupled — Jotai atoms, hooks, contexts, and (in places) GraphQL/router/Recoil-style state — rather
than pure presentation. The goal is to extract the generic, reusable parts into twenty-ui
while leaving genuinely app-specific wiring in twenty-front.
Approach
- Triage, don't lift-and-shift. Per component, classify as: (a) generic → migrate to
twenty-ui; (b) app-specific → keep intwenty-front; (c) hybrid → split a presentational/headless core (library) from an app-wired wrapper (front). - Decouple state. Replace internal Jotai/global state with controlled props (
props down, events up); where a component needs local state, keep it self-contained. The library must not import app stores, GraphQL, or routing. - Prefer Base UI primitives for behavior already covered there — Dropdown→
Menu/Popover, modal/side-panel→Dialog, tab-list→Tabs, expandable/selectable lists→Collapsible/list patterns, drag-and-drop stays on the existing dnd lib but exposed generically. - Same parity bar as the rest of the package: stories (all states, light/dark), interaction + a11y tests, visual-parity diff, within-budget size entry.
Out of scope (stays in twenty-front): components bound to domain entities, record/table data fetching,
workspace/router/permission logic, and anything whose only consumer is a single feature screen.
A component-by-component triage of modules/ui (generic / app-specific / hybrid, with target subpath
and state-decoupling notes) is the remaining Phase 4 prerequisite (the twenty-ui-deprecated inventory itself is complete — every component is migrated).
Hardest components to migrate (risk hotspots)
A predicted ranking of where the effort and risk concentrate, to inform sequencing and staffing. This is a hypothesis to validate during the Phase 0 inventory/triage, not a final list.
In twenty-ui-deprecated (all migrated — predictions held; see Migration outcomes)
| Component | Why it's hard | Migration shape |
|---|---|---|
CodeEditor (input/code-editor) |
Hard dependency on Monaco; Linaria theme defined via defineTheme() bound to Monaco lifecycle |
Port Monaco integration ~verbatim; only re-skin via CSS vars — Base UI doesn't apply |
Button family — Button (~560 LOC), IconButton (~330), AnimatedButton (~520) |
Huge Linaria style matrix (variant × accent × inverted × disabled × position, 140+ branches each); router Link; framer-motion on the animated one |
Establish the canonical "computed class / cva + data-*" pattern here first; this trio is the project's key inflection point (~30–40% of library effort) |
Modal + ModalBackdrop (layout/modal) |
framer-motion AnimatePresence, portal + z-index layering, responsive Linaria sizing |
Base UI Dialog + CSS transitions; backdrop animation has no direct Base UI equivalent |
AnimatedExpandableContainer |
scroll-height measurement + framer-motion height/opacity animation | Base UI Collapsible + CSS grid-rows / JS height fallback |
AppTooltip, OverflowingTextWithTooltip |
react-tooltip dependency + overflow detection; Linaria css template |
Swap to Base UI Tooltip (+ Floating UI); behavioral divergence is likely and needs parity tests |
JsonNestedNode (json-visualizer) |
recursive tree with per-node framer-motion expand/collapse | Recursion is fine; replace animation, keep structure |
MenuItem, ProgressBar/CircularProgressBar, Avatar/AvatarGroup |
framer-motion micro-animations; Avatar uses a Jotai atom for broken-image fallback |
CSS transitions; Avatar → Base UI Avatar, Jotai → local state/props |
IconsProvider, ThemeProvider |
Jotai-backed icon registry; runtime CSS-var parsing — every component depends on them | Low per-unit effort but high blast radius; freeze the public API, swap internals carefully |
Cross-cutting: ~120 files use Linaria prop interpolation and ~26 use framer-motion — the two systemic conversions (→ SCSS Modules, → CSS/Base UI transitions) dominate, not any single component.
In twenty-front/src/modules/ui
Here difficulty is decoupling from app state, not visuals. Ranked hardest:
| Area | Why it's hard | Decoupling needed |
|---|---|---|
layout/dropdown |
Floating UI positioning + open-state atoms + hotkey scoping; foundational to many features | Generic positioning wrapper; controlled open state; injectable keyboard handling |
utilities/hotkey + utilities/focus |
Hand-rolled global hotkey scope stack and focus stack as shared runtime state | Extract as an injectable system; the rest of the library must not assume the global stack |
navigation/navigation-drawer (~40 files) |
Deeply bound to currentWorkspaceState, auth, Apollo error handling, multi-workspace switching |
Mostly app-specific — migrate only the generic drawer shell, leave workspace logic in twenty-front |
layout/selectable-list |
2D arrow-key navigation state machine over atom families | Pure grid-position functions + a controlled selection API |
layout/expandable-list |
Floating UI + DOM overflow measurement | Layout-agnostic overflow API, drop Floating UI coupling |
layout/table |
Generic types but heavy sorting/metadata atoms | Make field-agnostic; lift state out |
layout/modal |
ModalComponentInstanceContext, click-outside + escape via hotkeys, stacking indices |
Injectable container; remove context coupling |
layout/resizable-panel, utilities/drag-select, utilities/scroll |
Direct DOM/pointer manipulation, set CSS vars on documentElement, scroll-wrapper atom coupling |
Callback/controlled APIs; pure geometry utils; optional scroll coupling |
feedback (snackbar + dialog managers) |
Snackbar formats Apollo errors; dialog uses framer-motion | Generic error objects; CSS animations |
layout/tab-list |
Router useNavigate, measurement system, dropdown coupling |
Callback-based navigation; extract measurement |
input date pickers (internal/date, ~47 files) |
temporal-polyfill, reads currentWorkspaceMemberState for tz/locale |
Parameterize locale/timezone via props |
Probably should NOT migrate (too app-coupled, keep in twenty-front): field/input & field/display
(bound to FieldMetadata / object-record), the full navigation-drawer workspace/auth UI, snackbar
Apollo error formatting, and the icon/theme-color pickers tied to Twenty's icon set and theme system.
Test, benchmark & parity strategy
- Workbench — Storybook (
@storybook/react-vite). Every component has stories covering variants, sizes, and states (viastorybook-addon-pseudo-states), in light and dark, withautodocs. - Functional — component/interaction tests via
@storybook/addon-vitest(real browser); unit tests (Jest) for hooks/utilities; coverage gate via@storybook/addon-coverage. - Accessibility — Storybook a11y addon (axe-core) with
parameters.a11y.test = 'error'so violations fail CI. - Visual parity — visual regression via Argos (self-hosted) plus a cross-package comparison project that diffs
twenty-uistories againsttwenty-ui-deprecatedstories with identical names; a pixel-diff threshold is the per-component acceptance gate. See Visual regression below. - Performance & size —
size-limitper entry point with budgets; tree-shaking fixtures (importing one component must not pull the library); build-time tracking; render benchmarks via React Profiler; load-time via Lighthouse/Playwright on the built Storybook. As one concrete benchmark, a dedicated stress story renders a very large number of a single component (e.g. 10,000 buttons) and measures total render time — compared against thetwenty-ui-deprecatedequivalent and gated against a budget to catch per-instance overhead regressions. (Stress story not yet built.)
CI surfaces a per-PR diff table (twenty-ui-deprecated vs twenty-ui) for size, a11y, and visual changes. (Not yet built.)
Visual regression
Two Argos projects (on argos.twenty-internal.com) provide visual regression in CI:
twenty-ui— pixel diff oftwenty-uistories against themainbranch baseline. Catches regressions introduced by a PR.twenty-ui-vs-new-ui— cross-package comparison. The baseline is alwaystwenty-ui-deprecatedscreenshots frommain; PR builds uploadtwenty-uiscreenshots and diff them against that baseline. This shows exactly which components still differ between the two implementations.
For the cross-package comparison to produce meaningful diffs, stories in twenty-ui must use the same title hierarchy as twenty-ui-deprecated (e.g. UI/Input/Toggle/Toggle). (Done: all 70 story titles are byte-identical.)
Local visual diff
Run a pixel diff of twenty-ui components against twenty-ui-deprecated using the self-hosted Argos instance.
Prerequisites:
- AWS SSO configured and logged in (
aws sso login --profile twenty-dev) twenty-infra/super-clicloned (sibling of this repo)
1. Start the Argos tunnel
In the twenty-infra/super-cli directory:
yarn cli argos-tunnel
This port-forwards the Argos service to http://127.0.0.1:4002.
Wait until the CLI shows "Argos tunnel is running".
2. Set your Argos token
Create a .env file in packages/twenty-ui/ (gitignored):
ARGOS_TOKEN=<your-token-from-argos-project-settings>
3. Run the visual diff
From the repo root:
npx nx storybook:visual-diff twenty-ui
This builds Storybook, captures screenshots of every story, and uploads
them to Argos with build name <username>/twenty-ui. The diff
compares against the latest approved baseline.
To run twenty-ui-deprecated's visual diff in the same Argos instance (to build the
cross-package comparison baseline):
npx nx storybook:visual-diff twenty-ui-deprecated
4. View results
Open http://127.0.0.1:4002 in your browser (while the tunnel is running)
to review diffs.
Build & publishing
- Vite library mode, dual ESM/CJS,
vite-plugin-dts,vite-plugin-svgr; SCSS via Vite's built-insass; no Babel. sideEffects: ["**/*.css", "**/*.scss"]; emit per-entry CSS plusstyle.css/theme-light.css/theme-dark.css.- Public package (remove
private); ships astwenty-ui(the old package already moved to thetwenty-ui-deprecatedname and is removed after cut-over). - Changesets for semver + changelog; GitHub Actions release with
npm publish --provenance. - Declare
react/react-domas peer dependencies; validate theexports/types map withpublint+@arethetypeswrong/cli. - Publish the Storybook as living documentation.
Migration & rollout
BuildDone — validated by the export-parity diff, a11y, and size suites; visual-parity triage via Argos still pending.twenty-uito parity with the same API surface and design- Dogfood on a few non-critical
twenty-frontscreens behind a temporary alias. - Codemod imports
twenty-ui-deprecated→twenty-ui(subpaths preserved) acrosstwenty-front(~1,730 files),twenty-front-component-renderer, andtwenty-sdk; handle any changed APIs explicitly. - Swap the dependency, run the full test suite + visual diffs, ship.
- Remove
twenty-ui-deprecatedafter a soak period.
Roadmap
Phase 0 — Foundations✅ scaffolding, tooling, theme parity, harnesses. Still open from Phase 0: the CI diff-table workflow and themodules/uitriage (moved under Phase 4).Phase 1 — Primitives✅ done June 2026 (canonical pattern:Button.tsx+ data-attribute Sass matrix).Phase 2 — Behavioral✅ done June 2026 (Base UI where mapped; see Migration outcomes for exceptions).Phase 3 — Long tail✅ done June 2026. Follow-up debt: the a11y fix pass for the 119a11y: 'todo'stories, and the Argos visual-parity triage.- Phase 4 — Application-level UI: migrate the generic/hybrid components from
twenty-front/src/modules/uiper the triage — decouple state, split headless cores, swap each behind its existing@/ui/...import path. - Phase 5 — Hardening & publish: close gaps; finalize release pipeline; cut
1.0.0; publish docs. Phase 6 — Cut-over✅ done June 2026: codemod imports → swap dependency → removetwenty-ui-deprecated.
A component is done only with: stories (all states, light/dark), passing interaction + a11y tests, a passing visual-parity diff, and a within-budget size entry.
Risks
| Risk | Mitigation |
|---|---|
| Base UI pre-1.0 API churn | Pin exact version; gate GA on stable release; isolate behind component APIs |
| Visual drift | Reuse exact tokens; visual-parity snapshots as the per-component gate |
| Theme API mismatch (~943 consumers) | Freeze theme-constants contract; generated-CSS diff test |
| 1,721 import sites | Preserve subpaths/names; automate with a codemod |
| No Base UI primitive for some components | Build in-house; use Base UI utilities where helpful |
| Bundle regressions | size-limit budgets + PR diff; prefer CSS transitions over framer-motion |
modules/ui components entangled with app state (Jotai/GraphQL/router) |
Triage first; split headless core from app wrapper; controlled props only — no app imports in the library |
Open questions
Published package name.Resolved: the rename already happened — this package ownstwenty-ui; the old one istwenty-ui-deprecated.Styling: SCSS Modules vs vanilla-extract vs plain CSS Modules.Resolved: SCSS Modules, proven across all 192 components.Variants helper:Resolved:clsx+data-*vscva.clsx+data-*attributes with Sass maps/@each(canonical pattern inButton.module.scss);cvanot needed.Visual regression tooling: Chromatic vs self-hosted image snapshots.Resolved: Argos (self-hosted at argos.twenty-internal.com). See Visual regression.How aggressively to dropResolved: keep it where animation is the public contract (framer-motion.utilities/animation,AnimatedButton,AnimatedCheckmark,AnimatedExpandableContainer,useProgressAnimation,CardContent); converted to CSS/Base UI transitions everywhere it was an internal micro-transition.Scope ofResolved: ported — assets byte-identical, testing decorators converted to SCSS, json-visualizer recursion verbatim with CSS/Collapsible animations.assets/testing/json-visualizer.- Where to draw the generic-vs-app-specific line for
modules/ui, and whether hybrid components live as a headless core intwenty-uiwith a thin app wrapper intwenty-front. (Still open — Phase 4.)