Files
twenty/packages/twenty-ui
Johnny Martin d4e4e2612b feat(front): render Instagram URLs as @handles in link fields (#21642)
LinkedIn and X links already show a readable handle in Twenty's link
fields. Instagram doesn't — it just shows `instagram.com`, which isn't
much help when you're scanning a record.

This adds the same handling for Instagram. `instagram.com/ptcrash` now
shows as `@ptcrash`, in tables, on record pages, and in the edit menu.
Post and reel links (`/p/...`, `/reel/...`) have no handle, so they fall
back to `Instagram`.

How it works:
- `Instagram` added to the `LinkType` enum
- `checkUrlType` detects `instagram.com`
- `getDisplayValueByUrlType` pulls the handle and prefixes `@`
- a shared `isSocialLinkType` helper keeps the three display components
in sync

Tested with unit tests for both helpers, the updated story, and manually
against a record whose Instagram field is
`http://instagram.com/ptcrash`.

Closes #21644

Co-authored-by: Johnny Martin <ptcrash@users.noreply.github.com>
2026-06-23 17:25:50 +02:00
..
2026-06-11 11:02:28 +02:00
2026-06-11 11:02:28 +02:00
2026-06-11 11:02:28 +02:00

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-front was switched over to twenty-ui, and the legacy twenty-ui-deprecated package 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-violation a11y: 'todo' overrides), the CI diff-table workflow, the modules/ui triage (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

  1. Publish as a standalone, versioned npm package.
  2. Replace twenty-ui in twenty-front with no visual change (same design, token for token).
  3. Migrate every component currently exported by twenty-ui-deprecated. (Done — June 2026.)
  4. 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.
  5. 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 @each cover 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, the themeCssVariables shape, ThemeType, color helpers, and the theme-light.css / theme-dark.css exports.
  • 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 the themeCssVariables accessor are static files mirrored token-for-token from twenty-ui-deprecated (matching its own static-CSS approach).
  • A theme parity test asserts the theme CSS and themeCssVariables stay identical to twenty-ui-deprecated's --t-* values.

Consuming the theme

  • Wrap the app once in ThemeProvider and import twenty-ui/theme-light.css and twenty-ui/theme-dark.css.
  • Read theme values with the public hooks, not the raw context:
    • useTheme() returns the resolved ThemeType (numeric tokens such as theme.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 via themeCssVariables (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:

  1. Globally — ship a stylesheet that redefines --t-* under .light / .dark. This covers the whole document, including overlays portaled to document.body.
  2. Per ThemeProvider — pass overrides (a ThemeOverrides map of --t-* name to value). The variables are applied to the provider's subtree.

ThemeProvider props:

  • colorScheme: 'light' | 'dark' — required.
  • applyToRoot?: boolean (default true) — toggles the .light / .dark class on document.documentElement. Keep it true so the whole document, including portaled overlays (tooltips, dropdowns, modals) rendered to document.body, stays themed. Set it false when 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):

  • MenuItem family and TabButton did NOT move to Base UI Menu/Tabs — those primitives require a Root context the deprecated standalone APIs don't have. They are pure presentation with data-* state. The Base UI Menu mapping applies to the Phase-4 modules/ui dropdown instead.
  • AnimatedExpandableContainer kept 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's invalidAvatarUrlsAtomV2 and the icon system's iconsState Jotai atoms were kept — both are public display exports, overriding this doc's earlier "Jotai → local state" idea.
  • CardContent kept framer-motion: its motion props are part of the public prop type and twenty-front's CalendarDayCardContent passes them.
  • ProgressBar is pure SCSS (Base UI Progress was optional and not needed for parity); useProgressAnimation keeps framer motion-values verbatim (public hook).
  • Known behavior deltas to watch in QA: AppTooltip and Modal (without an explicit container) now portal to document.body (Base UI), so overflow-clipping ancestors no longer clip them and AppTooltip's default maxWidth: '40%' resolves against the body; Checkbox/Radio test ids moved from the hidden input to the visible Base UI control.
  • The Storybook axe gate was inert until this migration.storybook/vitest.setup.ts did not register the a11y addon annotations, so a11y.test: 'error' never ran. It is now live; 119 ported stories carry story-level a11y: { 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/inverted attributes, Sass maps + @each assigning --btn-* custom properties, all declarations on a flat (0,1,0) class so styled(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 in twenty-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 (~3040% 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 (via storybook-addon-pseudo-states), in light and dark, with autodocs.
  • 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-ui stories against twenty-ui-deprecated stories with identical names; a pixel-diff threshold is the per-component acceptance gate. See Visual regression below.
  • Performance & sizesize-limit per 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 the twenty-ui-deprecated equivalent 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:

  1. twenty-ui — pixel diff of twenty-ui stories against the main branch baseline. Catches regressions introduced by a PR.
  2. twenty-ui-vs-new-ui — cross-package comparison. The baseline is always twenty-ui-deprecated screenshots from main; PR builds upload twenty-ui screenshots 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-cli cloned (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-in sass; no Babel.
  • sideEffects: ["**/*.css", "**/*.scss"]; emit per-entry CSS plus style.css / theme-light.css / theme-dark.css.
  • Public package (remove private); ships as twenty-ui (the old package already moved to the twenty-ui-deprecated name and is removed after cut-over).
  • Changesets for semver + changelog; GitHub Actions release with npm publish --provenance.
  • Declare react / react-dom as peer dependencies; validate the exports/types map with publint + @arethetypeswrong/cli.
  • Publish the Storybook as living documentation.

Migration & rollout

  1. Build twenty-ui to parity with the same API surface and design Done — validated by the export-parity diff, a11y, and size suites; visual-parity triage via Argos still pending.
  2. Dogfood on a few non-critical twenty-front screens behind a temporary alias.
  3. Codemod imports twenty-ui-deprecatedtwenty-ui (subpaths preserved) across twenty-front (~1,730 files), twenty-front-component-renderer, and twenty-sdk; handle any changed APIs explicitly.
  4. Swap the dependency, run the full test suite + visual diffs, ship.
  5. Remove twenty-ui-deprecated after a soak period.

Roadmap

  • Phase 0 — Foundations scaffolding, tooling, theme parity, harnesses. Still open from Phase 0: the CI diff-table workflow and the modules/ui triage (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 119 a11y: 'todo' stories, and the Argos visual-parity triage.
  • Phase 4 — Application-level UI: migrate the generic/hybrid components from twenty-front/src/modules/ui per 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 → remove twenty-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

  1. Published package name. Resolved: the rename already happened — this package owns twenty-ui; the old one is twenty-ui-deprecated.
  2. Styling: SCSS Modules vs vanilla-extract vs plain CSS Modules. Resolved: SCSS Modules, proven across all 192 components.
  3. Variants helper: clsx + data-* vs cva. Resolved: clsx + data-* attributes with Sass maps/@each (canonical pattern in Button.module.scss); cva not needed.
  4. Visual regression tooling: Chromatic vs self-hosted image snapshots. Resolved: Argos (self-hosted at argos.twenty-internal.com). See Visual regression.
  5. How aggressively to drop framer-motion. Resolved: keep it where animation is the public contract (utilities/animation, AnimatedButton, AnimatedCheckmark, AnimatedExpandableContainer, useProgressAnimation, CardContent); converted to CSS/Base UI transitions everywhere it was an internal micro-transition.
  6. Scope of assets / testing / json-visualizer. Resolved: ported — assets byte-identical, testing decorators converted to SCSS, json-visualizer recursion verbatim with CSS/Collapsible animations.
  7. Where to draw the generic-vs-app-specific line for modules/ui, and whether hybrid components live as a headless core in twenty-ui with a thin app wrapper in twenty-front. (Still open — Phase 4.)