Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Use when migrating, porting, or rewriting frontend code from operate/client/ or tasklist/client/ to the orchestration cluster webapp. Trigger when migrating legacy pages/components, converting React Router to TanStack Router, replacing MobX with TanStack Query, rewriting styled-components as SCSS, converting legacy tests to Vitest browser mode, or asking how a legacy pattern maps to the new app.
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-03 | ✗→✓ | ▲ Improved | 52% | 0% |
| case-09 | ✗→✓ | ▲ Improved | 73% | 0% |
| case-15 | ✗→✓ | ▲ Improved | 170% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 116% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 130% | 0% |
The orchestration cluster webapp (webapp/client/apps/orchestration-cluster-webapp/) is replacing the legacy Operate, Tasklist, and Admin frontends. Migration is a rewrite using new patterns, not a code port. The legacy code is the specification of what the feature does; the new code follows a different architecture for how.
This skill is the translation layer. It maps legacy patterns to their target equivalents so you produce code that fits the new codebase from the start. For target conventions (pod areas + shared structure, data loading tiers, forms, feature flags), defer to the frontend-feature skill. For test-writing details, defer to frontend-unit-test and frontend-integration-test.
Read the legacy code and extract what it does for users, not how it's implemented. Identify:
Focus on behavior. The implementation details (MobX stores, styled-components, React Router hooks) are all going to change.
Look up every endpoint the legacy feature calls in @camunda/camunda-api-zod-schemas. The legacy code typically imports from this package already — check its import statements. If an endpoint is missing from the schema package, it needs to be added before or alongside the migration.
Decide how the feature decomposes in the new architecture. The general rule:
src/routes/_auth/<pod>/src/operate/, src/tasklist/, or src/admin/). The pod decides its own internal structure — there is no prescribed layout.src/shared/<concern>/ — http, auth, config, errors, i18n, theme. Use what's already there; extend it rather than duplicating in the pod area.Produce the code directly. If the decomposition is genuinely ambiguous (e.g., a legacy page that mixes two concerns and could become one page or two), flag it for the user with your recommendation and reasoning.
Dependencies flow one way: routes → pod area → shared. Pod code must never import from a route file. Shared code must never import from a pod area. If a type (like search param schemas) needs to be shared between a route and pod code, define it in the pod area and import it into the route — not the other way around.
This is the core of the migration. Each legacy pattern has a target equivalent — apply them systematically. The summary is below; see references/pattern-mapping.md for side-by-side code examples.
Rewrite tests using the new patterns (Vitest browser mode, MSW via worker fixture, expect.element()). Don't port legacy test code — the testing infrastructure is fundamentally different. See the testing transformation table below and references/pattern-mapping.md for full before/after examples.
# From webapp/client/ npm run prettier:format # Auto-format (always use this script, never npx prettier) npm run lint
# From webapp/client/apps/orchestration-cluster-webapp/ npm run typecheck # TypeScript across all tsconfigs (always use this script, never npx tsc) npm run test:unit
| Legacy | Target | |--------|--------| | createBrowserRouter + createRoutesFromElements | TanStack Router file-based routes | | lazy={() => import('./Page')} with Component export | createFileRoute in a file under src/routes/ | | Paths.processes() path builder object | File path is the URL — src/routes/_auth/operate/processes/index.tsx | | useParams() / useSearchParams() | useParams({ from: '/route-path' }) / useSearch({ from: '/route-path' }) (typed, validated) | | useNavigate() + navigate(path) | useNavigate() from @tanstack/react-router or <Link> | | useOutletContext() for parent data | Shared queryOptions — child re-calls the same query (TanStack Query deduplicates) | | <Navigate to={path} /> redirect | throw redirect({ to: path }) in beforeLoad | | ErrorBoundary per route | errorComponent on the route definition |
Auth-gated routes go under _auth/ (pathless layout route). Product routes go under _auth/operate/, _auth/tasklist/, or _auth/admin/.
| Legacy | Target | |--------|--------| | use*.query.ts files scattered across modules | Centralized queryOptions in #/shared/http/queries.ts | | Endpoint URL construction inline in hooks | Request factories in #/shared/http/endpoints.ts | | requestAndParse() / requestWithThrow() | request() from #/shared/http/request | | useQuery / useInfiniteQuery in components | useSuspenseQuery / useSuspenseInfiniteQuery in components | | No data prefetching | queryClient.ensureQueryData(queries.xxx()) in route beforeLoad | | document.title in useEffect | head() function on the route | | refetchInterval for polling | Same — refetchInterval on the query options |
The pattern is: define the endpoint in endpoints.ts, wrap it in queryOptions in queries.ts, prefetch in the route loader, consume with useSuspenseQuery in the pod page component. This replaces the legacy pattern where each feature scattered its own query hooks.
This is where most legacy code diverges from the target. Legacy apps use MobX stores for everything — server cache, UI state, filters, form drafts. The target splits state across purpose-built mechanisms:
| What the MobX store holds | Target mechanism | Why | |---------------------------|------------------|-----| | Server data (fetched from API) | TanStack Query via queries.ts | Automatic caching, deduplication, background refresh, garbage collection | | Filters, sort order, pagination cursor | URL search params with Zod validateSearch | Linkable, shareable, survives refresh, back/forward navigation works | | Selected items, active tab | URL search params | Same reasons — if the user can share the URL and expect the same view, it belongs in the URL | | Modal open/close, menu open, input draft | useState | Ephemeral — losing it on navigation is fine | | Authentication session | MobX store (authentication.store.ts) | Already exists in the target, keep it | | Theme preference | MobX store | Already exists in the target, keep it |
When you encounter a MobX store in legacy code, don't port it. Decompose it by asking: "Would a user want to bookmark or share this state?" If yes → URL. If it's server data → TanStack Query. If it's ephemeral → useState.
| Legacy | Target | |--------|--------| | styled-components (styled.div, styled(Tile)) | SCSS Modules (.module.scss) | | css helper from styled-components | Regular SCSS | | Transient props ($isActive) | CSS class toggling or data attributes | | @carbon/elements styles.productiveHeading02 | @carbon/type SCSS mixins (@include type.type-style('productive-heading-02')) | | Hardcoded px / rem values | Carbon spacing tokens (var(--cds-spacing-05), $spacing-05) | | Carbon token CSS vars (--cds-border-subtle-01) | Same — keep using Carbon CSS custom properties |
SCSS modules use dot notation for class access: className={styles.container}.
| Legacy | Target | |--------|--------| | export default Component or export {Component} inline | export {Component} block at end of file | | const Component: React.FC = () => ... | function Component() { ... } or const Component: React.FC = () => ... (both OK, function declarations preferred) | | index.tsx as main file in component dir | PascalCase.tsx named file (no barrel files) | | Import via directory (./Dashboard) | Import via file (#/shared/dashboard/Dashboard or #/operate/components/Dashboard) | | React.memo() wrapping | useMemo for derived data inside components; React.memo only when profiling shows a need | | observer() from mobx-react-lite (for MobX) | Remove — no MobX observation needed for server state or URL state |
| Legacy | Target | |--------|--------| | Try/catch in components | errorComponent on the route handles render errors | | Manual 401 checks | Centralized in request() — clears query cache, disables session | | notificationsStore.displayNotification() | Build a notification module when needed, or use Carbon's InlineNotification inline | | <ErrorBoundary> per route | errorComponent per route (TanStack Router's built-in mechanism) |
This is the biggest divergence. Legacy tests use jsdom + Testing Library globals + vi.mock(). The target uses a real Chromium browser + MSW-only mocking + no global screen.
| Legacy | Target | |--------|--------| | import {render, screen} from 'modules/testing-library' | import {it} from '#/vitest-modules/test-extend' + import {render} from 'vitest-browser-react' or renderWithRouter | | screen.findByRole() / screen.getByRole() | screen.getByRole() from render return value | | await screen.findByText('...') | await expect.element(screen.getByText('...')).toBeVisible() | | waitFor(() => expect(...)) | await expect.element(...) — retries automatically | | screen.queryByText('...') for absence | await expect.element(screen.getByText('...')).not.toBeVisible() | | vi.mock('../../stores/auth') | MSW endpoint mocks — mock the HTTP, not the module | | nodeMockServer.use(http.post(...)) | worker.use(mockEndpoint({successResponse: ...})) | | mockFetchProcesses().withSuccess(data) | mockProcessesEndpoint({successResponse: HttpResponse.json(data)}) | | MemoryRouter + QueryClientProvider wrapper | renderWithRouter('/path') — creates real TanStack Router + QueryClient | | getMockQueryClient() | Built into renderWithRouter | | user.click() from userEvent | screen.getByRole('button').click() — direct locator interaction | | // given / when / then comments | No section comments — use blank lines for visual grouping |
Endpoint mocks live in shared-test-modules/mock-handlers.ts. If the mock you need doesn't exist, add it there using createEndpointMock() — never create mocks inline in test files.
Legacy (Operate): No i18n — all strings are hardcoded in English. There are no i18next imports, translation files, or useTranslation() calls.
Target: The orchestration cluster webapp uses i18next + react-i18next. When migrating from Operate, wrap user-facing strings in t('key') via useTranslation() and add translation keys to the appropriate namespace in src/shared/i18n/. Check the target's namespace structure before adding keys.
The orchestration cluster webapp does not use Mixpanel tracking. Do not migrate legacy tracking imports, event calls, event types, tracking-only state, configuration, or tests. When tracking shares a callback with feature behavior, remove only the tracking code and preserve the functional behavior.
Not every migration is a full page. You might be adding a single component, column, filter, or panel to a page that already exists in the orchestration webapp.
For partial work:
queries.ts and the endpoint to endpoints.tsPorting MobX stores verbatim. This is the most common mistake. Legacy stores mix server cache, UI state, and derived data in one class. In the target, these concerns are separated. Decompose the store, don't port it.
Scattering query definitions. Legacy apps have use*.query.ts files next to each feature. The target centralizes all queries in #/shared/http/queries.ts and all endpoints in #/shared/http/endpoints.ts. This makes the full API surface visible in one place.
Using vi.mock(). Legacy tests mock MobX stores and modules with vi.mock(). The target mocks HTTP responses with MSW. If you need to control what a component sees, control it at the network level. The only acceptable use of vi.mock() is for things like vi.useFakeTimers() where there's no HTTP-level alternative.
Creating inline MSW handlers in test files. Don't write http.post('*/v2/...', () => HttpResponse.json(...)) directly in tests. All endpoint mocks must be defined in shared-test-modules/mock-handlers.ts using createEndpointMock() and consumed via worker.use(mockXxxEndpoint({successResponse: ...})). If the mock you need doesn't exist yet, add it to shared-test-modules/mock-handlers.ts — never inline it.
Using waitFor or findBy*. These don't exist in Vitest browser mode. Use await expect.element(...) which retries automatically.
Creating barrel files. The target doesn't use index.ts barrel files. Import files directly using #/ path aliases.
Putting fetch logic in page components. Data fetching belongs in route loaders (beforeLoad or loader). Page components consume data via useSuspenseQuery, they don't initiate fetches.
Using styled-components. Even if the legacy code uses them, the target uses SCSS modules exclusively. Rewrite, don't port.
Checking isLoading / isError on useSuspenseQuery. Unlike useQuery, useSuspenseQuery never returns loading or error states — it suspends the component (handled by <Suspense> or the route's pendingComponent) and throws on errors (handled by errorComponent). If you need inline loading/error handling within a component, use useQuery instead, but prefer useSuspenseQuery with route-level boundaries as the default.
Porting useEffect for navigation. Legacy code uses useEffect(() => navigate(...)) for conditional redirects. The target uses throw redirect() in beforeLoad — it's synchronous relative to the route lifecycle and avoids the flash of the wrong page.
docs/monorepo-docs/frontend/orchestration-cluster-webapp.md — tech stack, layout, scriptsdocs/monorepo-docs/frontend/data-loading.md — TanStack Router + Query patternsdocs/monorepo-docs/frontend/forms.md — form library guidancedocs/monorepo-docs/frontend/testing.md — unit, integration, a11y, visual testingdocs/monorepo-docs/frontend/code-style.md — naming, exports, commentsdocs/monorepo-docs/frontend/development-process/creating-a-new-page.md — page creation checklistdocs/monorepo-docs/frontend/development-process/before-starting.md — pre-feature considerationsdocs/monorepo-docs/frontend/legacy-components.md — legacy app overview + migration epic linkreferences/pattern-mapping.md — detailed side-by-side code examples for every transformationOther measured skills in the registry, with their headline benchmark lift.