UI development — api-control-plane
One skill for all UI work in portals/api-control-plane. It replaces the four generated
oxygen-* skills and .claude/oxygen-ui/*.md: everything load-bearing from those is condensed
below, and the full vendor reference is still on disk (see Where the truth lives).
Where the truth lives
Verify before you invent. In priority order:
| Question |
Source |
| Does this component/prop/sub-component exist? |
node_modules/@wso2/oxygen-ui/dist/**/*.d.ts — the compiled types, ground truth |
| Full Oxygen component/pattern/theming reference |
node_modules/@wso2/oxygen-ui/.ai/{components,patterns,theming,migration}.md (shipped with the package; identical to the docs previously copied into .claude/oxygen-ui/) |
| Which icon names exist |
node_modules/@wso2/oxygen-ui-icons-react/dist/index.d.ts → 8 brand icons + export * from "lucide-react" |
| Data-access layer rules |
src/api/README.md |
| i18n rules |
src/i18n/README.md |
| Test conventions |
src/test/README.md |
| Layer bans, i18n lint rules |
eslint.config.js — the restrictions are documented inline there |
| Online / upstream |
https://github.com/wso2/oxygen-ui (packages/oxygen-ui/.ai/*.md, src/components/**). No hosted docs site exists as of v0.13.1 — fetch the repo, not a guessed URL. |
Vendor docs lag the package. Two known errors in them — do not copy:
<Grid item xs={12}> → this app is on @mui/material v9 Grid v2: <Grid size={{ xs: 12, md: 4 }}>, no item.
HomeIcon/TrashIcon naming → this codebase uses bare lucide names (Home, Trash2, Plus). Both aliases resolve; stay with the bare form.
- They also omit components that do exist (
AppBreadcrumbs, PageTitle.Actions, PageTitle.BackButton, ColorSchemeSVG).
npx @wso2/oxygen-ui init --claude / update --claude regenerates .claude/oxygen-ui/ and the
oxygen-* skills. If someone runs it, delete the regenerated files again — this skill is the
project's single entry point, and node_modules/@wso2/oxygen-ui/.ai/ already carries the reference.
Non-negotiables
- Every source file starts with the Apache-2.0 header (
Copyright (c) 2026, WSO2 LLC. block — copy it from any neighbouring file).
- All components come from
@wso2/oxygen-ui, never @mui/material. All icons from @wso2/oxygen-ui-icons-react, never lucide-react/@mui/icons-material.
- Backend access goes through a hook in
src/api/resources/<resource> — nothing else. ESLint blocks endpoints, queries, query keys, transport, generated types and legacy clients.
- No user-facing literal strings.
<FormattedMessage> for JSX, intl.formatMessage for string props. IDs are apiControlPlane.<area>.<Module>.<slug>.
- No colour, radius, blur, shadow or border literals. Theme tokens (
bgcolor: 'background.paper', color: 'text.secondary', spacing numbers) or a recipe from src/theme/receipes.ts. Local sx is for layout only.
- Scoped pages wrap their body in
ScopeGate (see Routing, scope and navigation).
- Never “resolve” a rule from
.claude/rules/* with a // TODO. Those rules (XSS/output encoding, dependency management, error handling) apply to this portal's .ts/.tsx too.
Verification loop, in order: npm run typecheck → npm run lint → npm run test → npm run i18n
(commit src/i18n/messages/, never src/i18n/compiled/).
Project map
src/
api/ resources/<name>/{endpoints,queries,hooks}.ts + core/ (http, scope, spec, queryKeys)
components/ app-wide: StateViews (Loading/Empty/Error), ConfirmDialog, Notifications,
ErrorBoundary, AppLoader, ComingSoon, cards/*, common/*
contexts/auth/ AuthProvider + AuthStateContext
hooks/ cross-cutting hooks (ProductActivation)
i18n/ I18nProvider, useLocale, useFormatters, formats.ts, messages/ (source catalogs)
navigation/ navigationRegistry.tsx (the sidebar), navigationTypes, useNavigationItems
pages/
auth/ LoginPage, AuthCallbackPage
appShell/ AppLayout, AppHeader, AppSidebar, *QuickSelector
appShellPages/<feature>/ the actual pages (+ components/, utils/ per feature)
routes/ paths.ts (route builders), AppRoutes.tsx, ProtectedRoute
scope/ ConsoleScopeProvider/Context, ScopeGate, consoleRouteParams
slots/ Slot + Hideable extension primitives
theme/ receipes.ts (shared style recipes)
test/ renderWithProviders, MSW server + toolkit, mock scope/auth
extensions.tsx extension registration; hostPort.tsx the value handed to extensions
Provider order (App.tsx, outermost first): I18nProvider → OxygenUIThemeProvider →
NotificationProvider → AppQueryProvider → ApiClientProvider → ErrorBoundary →
BrowserRouter → AuthProvider → ExtensionsProvider → AppRoutes. Inside a protected route:
ConsoleScopeProvider → AppLayout. Don't add a provider without a reason that names its position.
Oxygen UI essentials
Imports
import { Box, Button, Card, PageTitle, Stack, Typography } from '@wso2/oxygen-ui';
import { Plus, Search, Trash2 } from '@wso2/oxygen-ui-icons-react'; // lucide names, size={18}
import { DataGrid, DatePickers, TreeView, AdapterDateFns } from '@wso2/oxygen-ui'; // MUI X namespaces
@wso2/oxygen-ui re-exports all of @mui/material plus Oxygen's own components, styled,
alpha, useTheme, Theme. MUI X ships as namespaces: <DataGrid.DataGrid …>,
<DatePickers.DatePicker …>, <TreeView.SimpleTreeView>. Charts live in
@wso2/oxygen-ui-charts-react, which is not installed here — adding it is a dependency change
(.claude/rules/js-dependency-management.md).
Oxygen's own components (beyond MUI)
| Component |
Sub-components / notes |
AppShell |
.Navbar .Sidebar .Main .Footer .NotificationPanel; props initialCollapsed, collapseOnSelectOnMobile. Wired once in AppLayout — pages never touch it. |
Header |
.Toggle .Brand .BrandLogo .BrandTitle .Switchers .Actions .Spacer; minimal hides switchers |
Sidebar |
.Nav .Category .CategoryLabel .Item .ItemIcon .ItemLabel .ItemBadge .Footer .User*; props collapsed, activeItem, expandedMenus, onSelect, onToggleExpand. An Item with nested children toggles instead of navigating. |
Footer |
.Copyright .Version .Divider (left) · .Link (right) |
UserMenu |
.Trigger .Header .Item .Logout .Divider |
NotificationPanel |
.Header{,Icon,Title,Badge,Close} .Tabs .Actions .List .Item{,Avatar,Title,Message,Timestamp,Action} .EmptyState |
PageTitle |
.Header .SubHeader .Avatar .Link .Actions .BackButton — the standard page heading |
PageContent |
padding/max-width wrapper; fullWidth. Already applied by AppLayout, so a page starts at its own content. |
AppBreadcrumbs |
items: BreadcrumbItem[]; rendered by AppLayout from route scope |
ListingTable |
.Provider .Container .Toolbar .Head .Body .Footer .Row .Cell .SortLabel .RowActions .CellIcon .EmptyState .DensityControl; variant='table'|'card', density, striped, bordered. Prefer over raw MUI Table. |
Form |
.Section .Header .Subheader .Body .Stack .ElementWrapper .Wizard .CardButton .Card{Header,Content,Actions,Media} |
ComplexSelect |
.MenuItem + .MenuItem.Icon / .MenuItem.Text — icon+text options inside a Select |
SearchBar, SearchBarWithAdvancedFilter, StatCard, CodeBlock, ColorSchemeToggle, ColorSchemeImage, ColorSchemeSVG, ThemeSwitcher, NotificationBanner, ParticleBackground, Layout (.Navbar .Sidebar .Content .Header) |
single-purpose; check the .d.ts for props |
Hooks: useTheme, useThemeSwitcher, useThemeContent, useAppShell, useNotifications
(Oxygen's own — not this app's src/components/Notifications), useHeader, useSidebar,
useNotificationPanel, useListingTable / useListingTableRequired.
Utils: formatRelativeTime, pxToRem, alpha.
Theming
OxygenUIThemeProvider is mounted once, directly in App.tsx, with a one-entry registry
(AcrylicOrangeTheme) declared at module scope in that same file — module scope because the
provider keys a theme-resolving effect on the array's identity. Don't call the provider anywhere
else, and don't wrap it in an app-level provider of your own.
It needs nothing else from us: it already renders <CssBaseline enableColorScheme /> internally,
and with no emotionCache/nonce prop it falls back to <StyledEngineProvider injectFirst> — so
app styles override Oxygen styles without a hand-rolled Emotion cache. Under a CSP, pass the
provider's own nonce prop rather than building a cache.
Three tiers, in order of preference:
- Theme — global decisions owned by Oxygen (
theme.palette.*, theme.typography.*, theme.border.*, theme.zIndex.*, theme.oxygen.* for blur/gradient/glass/syntax).
- Recipes —
src/theme/receipes.ts: hairline(theme), glassSurfaceSx(theme), interactiveCardSx, stickyBottomBarSx(theme), overlayBarShadow. A repeated multi-property treatment goes here, once.
- Local
sx — layout only: flex, gap, grid, min/max sizing.
<Card sx={(theme) => ({ ...glassSurfaceSx(theme), display: 'flex', gap: 2 })} />
Dark mode: the theme is CSS-variable based (--oxygen-*, data-color-scheme attribute), so token
usage adapts for free. Read the mode with useTheme().palette.mode only when behaviour (not colour)
must branch.
Page patterns
Page skeleton
export function ThingListPage() {
// Gate the whole body, not just the JSX: out of scope the query stays disabled
// and `isPending` never clears, so a loading branch would hang forever.
return (
<ScopeGate
prompt="Things are created and managed at the project level."
requires="project"
to={routes.things}
>
<ThingList />
</ScopeGate>
);
}
function ThingList() {
const intl = useIntl();
const thingsQuery = useThings();
const { notify } = useNotifications();
// `isPending`, not `isLoading` — a disabled query has isLoading=false with no
// data, which would flash the empty state.
if (thingsQuery.isPending) return <LoadingState label="Loading things" />;
if (thingsQuery.error) return <ErrorState message="Unable to load things" />;
const things = thingsQuery.data?.list ?? [];
return (
<>
<PageTitle>
<PageTitle.Header><FormattedMessage {...messages.title} /></PageTitle.Header>
<PageTitle.SubHeader><FormattedMessage {...messages.subtitle} /></PageTitle.SubHeader>
<PageTitle.Actions>
<Button startIcon={<Plus />} variant="contained"
<FormattedMessage {...messages.create} />
</Button>
</PageTitle.Actions>
</PageTitle>
{things.length === 0 ? (
<EmptyState
title={intl.formatMessage(messages.emptyTitle)}
description={intl.formatMessage(messages.emptyBody)}
actionLabel={intl.formatMessage(messages.create)}
/>
) : (
<Stack spacing={2}>{/* toolbar row, then grid/list */}</Stack>
)}
</>
);
}
State views come from src/components/StateViews — LoadingState({label, fullScreen}),
EmptyState({title, description, actionLabel, onAction}), ErrorState({title, message}).
Never hand-roll a spinner or an error Alert.
Reference implementations to copy from: apis/ApiListPage.tsx (list + search + grid/list toggle +
delete), apis/ApiDetailPage.tsx (tabbed detail), gateways/GatewayCreatePage.tsx (create flow),
apis/overview/ApiKeysPanel.tsx (ListingTable), projects/NewProjectDialog.tsx (dialog form).
Feedback and confirmation
const { notify } = useNotifications(); // src/components/Notifications
notify('Deleted "Orders API".', 'success'); // 'success' | 'info' | 'warning' | 'error'
Mutation errors already surface globally (the QueryClient's onMutationError notifies), so a
per-call onError is for better copy, never for making the error visible at all.
Destructive actions use ConfirmDialog (src/components/ConfirmDialog), with destructive and —
for irreversible deletes — confirmPhrase + confirmInputLabel (type-the-name):
<ConfirmDialog
open={toDelete !== null}
title="Delete API"
message={`This permanently deletes "${toDelete?.displayName}" and all related details.`}
confirmLabel="Delete"
confirmPhrase={toDelete?.displayName ?? ''}
destructive
loading={mutation.isPending}
=> setToDelete(null)}
/>
Forms
Controlled state + error/helperText; group with Form.Section / Form.Stack; multi-step with
Form.Wizard (or Stepper where a wizard is overkill). Labels and helper text go through
intl.formatMessage. Field errors from the API arrive as ApiError.fieldErrors — map them onto the
matching field instead of dumping the message into a banner.
<Form.Section>
<Form.Header><FormattedMessage {...messages.detailsHeader} /></Form.Header>
<Form.Subheader><FormattedMessage {...messages.detailsHint} /></Form.Subheader>
<Form.Stack spacing={2}>
<TextField
error={Boolean(errors.name)}
helperText={errors.name}
label={intl.formatMessage(messages.nameLabel)}
=> setName(event.target.value)}
required
value={name}
/>
</Form.Stack>
</Form.Section>
Accessibility and small conventions
- A
Select needs labelId pointing at its FormLabel id — a bare FormLabel leaves the combobox with no accessible name (and nothing for tests to query).
- Icon-only buttons need
aria-label (translated).
- Icon size is explicit:
<Search size={18} />.
- Newer files sort JSX props and
sx keys alphabetically; follow the file you're editing.
- Truncate rather than widen:
minWidth: 0 + overflow: 'hidden' + noWrap.
Data access (src/api)
Full rules in src/api/README.md. What a UI author must know:
- Import only hooks:
useThings(), useThing(id), useCreateThing(), useUpdateThing(), useDeleteThing(). Endpoints/queries/keys/http/generated types are ESLint-blocked from the UI.
- Hooks resolve org/project from route scope via
useApiScope(); pass overrides only for a genuine cross-scope read.
- Scoped queries are
enabled-gated, so branch on isPending, not isLoading.
- Errors are always
ApiError — branch on error.code, never HTTP status; fieldErrors, details, trackingId are available. Never render error.message as translated copy.
- Lists: read
pagination.total, never list.length.
- Adding a backend call = three files (
*.endpoints.ts, *.queries.ts, *.hooks.ts) copied from resources/restApis/, plus npm run api:codegen if the spec changed. Never hand-write a request/response type — derive from the operationId with ResponseOf/BodyOf/QueryOf/PathOf.
- A legacy layer (
client.ts, mvpApi.ts, */*Client.ts) still serves some pages. Don't extend it; port the page.
Route scope for rendering (not fetching) comes from useConsoleScope():
{ params, activeScope, organization(s), project(s), component, capabilities, isOrganizationScope, isProjectScope, isApiScope, isLoading, projectsError }.
i18n
Full rules in src/i18n/README.md. The short form:
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; // direct import, always
const messages = defineMessages({ // module scope — extraction is static
title: { id: 'apiControlPlane.pages.apis.ApiListPage.title', defaultMessage: 'APIs' },
nameLabel: {
id: 'apiControlPlane.pages.apis.ApiListPage.nameLabel',
defaultMessage: 'Name',
description: 'Label for the API name field. Noun, not a command.',
},
});
- ID =
apiControlPlane.<area>.<Module>.<slug>; <area> mirrors the src/ path (/→., filename dropped). Renaming an ID discards its translations; defaultMessage may change freely.
- One sentence = one message. Never concatenate; use ICU placeholders/plurals/rich-text tags.
- Never translate backend or user data — pass it through as a value.
- Dates/numbers via
useFormatters() (shortDate, dateTime, relativeTime) or <FormattedDate>/<FormattedNumber> — never toLocaleString() or a module-scope Intl.*.
- After changing strings:
npm run i18n, commit src/i18n/messages/. npm run lint:i18n lists remaining hardcoded JSX (the rule is warn, so npm run lint --quiet skips it). Check layout growth with ?lang=en-XA.
Routing, scope and navigation
Three tiers of scope live in the URL: /organizations/:orgHandle,
.../projects/:projectHandler, .../apis/:apiHandler. A page that needs deeper scope than the
current URL still mounts — at a scope-less alias where select-scope (SELECT_SCOPE_SEGMENT)
replaces the missing segments — and ScopeGate renders a picker until the handles are filled in.
That is why every sidebar item stays clickable at every scope.
Adding a page, end to end:
src/routes/paths.ts — add a builder using projectPath/apiPath so the alias is generated for you:thingDetail: (orgHandle = ':orgHandle', projectHandler: ScopeHandle = ':projectHandler') =>
projectPath(orgHandle, projectHandler, 'things/detail'),
src/routes/AppRoutes.tsx — lazy()-import the page and register every path it answers on:{scopedRoutes(projectScopedPaths(routes.thingDetail), <ThingDetailPage />)}
(apiScopedPaths for API-level pages; a single <Route> only when the page has no alias.)
- The page wraps its body in
ScopeGate with requires + to={routes.thingDetail}.
src/navigation/navigationRegistry.tsx — add the sidebar entry. Build to/match from the same builder via the helpers, never by hand: orgLevelTo, apiLevelTo, matchRoutes, submenu([...]) for a parent with children, adaptive([...tiers]) for one item that degrades across scopes, apiCapability(...) for capability gating (which only applies once an API is in scope).
Never hand-write a path string or a match regex: routes.* is the single source, and
paths.test.ts / navigationRegistry.test.ts guard the pairing.
Extension points: Slot / Hideable (src/slots/) — a named additive slot plus a suppressible
region for built-in UI; extensions receive a plain CloudHostPort value (src/hostPort.tsx), not a
shared context. Keep src/slots/index.tsx free of portal-specific types; it is copied verbatim into
other hosts.
Two slot families exist today, both declared in src/extensions.tsx: sidebar.<level> for a
top-level nav item (routed by AppRoutes, merged into the sidebar by useNavigationItems) and
settings.<level>.tabs for a Settings sub-nav tab (listed by useSettingsTabs, routed nested under
SettingsLayout). An entry's slot and level must agree — a descriptor claiming
settings.project.tabs with level: 'organization' is dropped by both, rather than rendered
against the wrong scope's Port. Filter with isSidebarExtension/settingsTabExtensions; never
re-spell a slot name as a literal at the use site.
Tests
Full conventions in src/test/README.md.
- Colocate as
*.test.ts / *.test.tsx. Always render through renderWithProviders (never wrap providers by hand):const { user } = renderWithProviders(<ApiListPage />, {
route: '/organizations/api-platform-demo/projects/retail-apis/apis',
scope: makeConsoleScope(),
authState: authStatePresets.authenticated(),
});
- Mock at the network boundary with MSW, never a client or a hook. The server ships no default handlers and fails unhandled requests, so each test declares its endpoints via
collection / resource / accepts / noContent / failure / recorder and spec-typed fixtures (aRestApi, manyRestApis). Build URLs with apiUrl('/things'); call resetHttpClient() in beforeEach.
renderWithProviders supplies IntlProvider with empty messages, so assert on the English defaultMessage.
- Prefer
userEvent; query by role/label; await with findBy*. To prove no request fired, await a short timeout then assert requests.count() === 0 — waitFor proves nothing there.
- Hook tests go one per shape, not per resource; endpoint tests one per resource.
Migrating existing MUI code
| From |
To |
@mui/material, @mui/material/styles (styled, alpha, useTheme) |
@wso2/oxygen-ui |
@mui/icons-material, lucide-react |
@wso2/oxygen-ui-icons-react (bare lucide names, explicit size) |
@mui/x-data-grid / x-date-pickers / x-tree-view |
DataGrid.* / DatePickers.* / TreeView.* from @wso2/oxygen-ui |
ThemeProvider + createTheme, CssBaseline, a custom Emotion cache |
nothing — OxygenUIThemeProvider in App.tsx already owns all three |
AppBar/Toolbar/Drawer layout |
AppShell + Header + Sidebar (already in AppLayout) |
Table/TableHead/TableRow/TableCell |
ListingTable.* |
<Grid item xs={12} md={4}> |
<Grid size={{ xs: 12, md: 4 }}> |
useColorScheme() |
<ColorSchemeToggle />, or useTheme().palette.mode |
| hardcoded colours/spacing |
theme tokens, or a recipe in src/theme/receipes.ts |
| hardcoded JSX strings |
FormattedMessage / intl.formatMessage (add to defineMessages) |
| direct client/axios calls |
a hook from src/api/resources/<resource> |
Review checklist
- Apache header present; imports only from
@wso2/oxygen-ui + @wso2/oxygen-ui-icons-react.
- No user-facing literal; every new message has an ID in the house format (and a
description where a translator could misread it).
- Data via hooks;
isPending used for the loading branch; ApiError.code for branching; pagination.total for counts.
- Scoped page wrapped in
ScopeGate; to is the page's own routes.* builder; route registered for the alias paths too; sidebar to/match derived from the same builder.
- Colours/spacing/borders via tokens or recipes; local
sx limited to layout.
- Loading/empty/error use
StateViews; destructive actions use ConfirmDialog; feedback via useNotifications.
Select has labelId; icon-only buttons have a translated aria-label.
npm run typecheck && npm run lint && npm run test clean; npm run i18n run and src/i18n/messages/ committed (never src/i18n/compiled/).
1---2name: apicp-ui3description: Build or change UI in portals/api-control-plane — pages, components, forms, dialogs, listings, app shell/navigation, theming, and MUI→Oxygen migration. Covers the Oxygen UI (@wso2/oxygen-ui) component/theming API plus this app's own rules for data access (hooks only), i18n (react-intl), routing/scope gating, and tests. Use for any .tsx/.ts work under portals/api-control-plane/src, and whenever asked how a page, listing, form, sidebar item, or theme override should be built here.4---56# UI development — api-control-plane78One skill for all UI work in `portals/api-control-plane`. It replaces the four generated9`oxygen-*` skills and `.claude/oxygen-ui/*.md`: everything load-bearing from those is condensed10below, and the full vendor reference is still on disk (see *Where the truth lives*).1112## Where the truth lives1314Verify before you invent. In priority order:1516| Question | Source |17| --- | --- |18| Does this component/prop/sub-component exist? | `node_modules/@wso2/oxygen-ui/dist/**/*.d.ts` — the compiled types, ground truth |19| Full Oxygen component/pattern/theming reference | `node_modules/@wso2/oxygen-ui/.ai/{components,patterns,theming,migration}.md` (shipped with the package; identical to the docs previously copied into `.claude/oxygen-ui/`) |20| Which icon names exist | `node_modules/@wso2/oxygen-ui-icons-react/dist/index.d.ts` → 8 brand icons + `export * from "lucide-react"` |21| Data-access layer rules | `src/api/README.md` |22| i18n rules | `src/i18n/README.md` |23| Test conventions | `src/test/README.md` |24| Layer bans, i18n lint rules | `eslint.config.js` — the restrictions are documented inline there |25| Online / upstream | <https://github.com/wso2/oxygen-ui> (`packages/oxygen-ui/.ai/*.md`, `src/components/**`). No hosted docs site exists as of v0.13.1 — fetch the repo, not a guessed URL. |2627Vendor docs lag the package. Two known errors in them — **do not copy**:28- `<Grid item xs={12}>` → this app is on `@mui/material` v9 Grid v2: `<Grid size={{ xs: 12, md: 4 }}>`, no `item`.29- `HomeIcon`/`TrashIcon` naming → this codebase uses bare lucide names (`Home`, `Trash2`, `Plus`). Both aliases resolve; stay with the bare form.30- They also omit components that do exist (`AppBreadcrumbs`, `PageTitle.Actions`, `PageTitle.BackButton`, `ColorSchemeSVG`).3132`npx @wso2/oxygen-ui init --claude` / `update --claude` regenerates `.claude/oxygen-ui/` and the33`oxygen-*` skills. If someone runs it, delete the regenerated files again — this skill is the34project's single entry point, and `node_modules/@wso2/oxygen-ui/.ai/` already carries the reference.3536## Non-negotiables37381. **Every source file starts with the Apache-2.0 header** (`Copyright (c) 2026, WSO2 LLC.` block — copy it from any neighbouring file).392. **All components come from `@wso2/oxygen-ui`**, never `@mui/material`. All icons from `@wso2/oxygen-ui-icons-react`, never `lucide-react`/`@mui/icons-material`.403. **Backend access goes through a hook in `src/api/resources/<resource>`** — nothing else. ESLint blocks endpoints, queries, query keys, transport, generated types and legacy clients.414. **No user-facing literal strings.** `<FormattedMessage>` for JSX, `intl.formatMessage` for string props. IDs are `apiControlPlane.<area>.<Module>.<slug>`.425. **No colour, radius, blur, shadow or border literals.** Theme tokens (`bgcolor: 'background.paper'`, `color: 'text.secondary'`, `spacing` numbers) or a recipe from `src/theme/receipes.ts`. Local `sx` is for layout only.436. **Scoped pages wrap their body in `ScopeGate`** (see *Routing, scope and navigation*).447. **Never “resolve” a rule from `.claude/rules/*` with a `// TODO`.** Those rules (XSS/output encoding, dependency management, error handling) apply to this portal's `.ts`/`.tsx` too.4546Verification loop, in order: `npm run typecheck` → `npm run lint` → `npm run test` → `npm run i18n`47(commit `src/i18n/messages/`, never `src/i18n/compiled/`).4849## Project map5051```52src/53 api/ resources/<name>/{endpoints,queries,hooks}.ts + core/ (http, scope, spec, queryKeys)54 components/ app-wide: StateViews (Loading/Empty/Error), ConfirmDialog, Notifications,55 ErrorBoundary, AppLoader, ComingSoon, cards/*, common/*56 contexts/auth/ AuthProvider + AuthStateContext57 hooks/ cross-cutting hooks (ProductActivation)58 i18n/ I18nProvider, useLocale, useFormatters, formats.ts, messages/ (source catalogs)59 navigation/ navigationRegistry.tsx (the sidebar), navigationTypes, useNavigationItems60 pages/61 auth/ LoginPage, AuthCallbackPage62 appShell/ AppLayout, AppHeader, AppSidebar, *QuickSelector63 appShellPages/<feature>/ the actual pages (+ components/, utils/ per feature)64 routes/ paths.ts (route builders), AppRoutes.tsx, ProtectedRoute65 scope/ ConsoleScopeProvider/Context, ScopeGate, consoleRouteParams66 slots/ Slot + Hideable extension primitives67 theme/ receipes.ts (shared style recipes)68 test/ renderWithProviders, MSW server + toolkit, mock scope/auth69 extensions.tsx extension registration; hostPort.tsx the value handed to extensions70```7172Provider order (`App.tsx`, outermost first): `I18nProvider` → `OxygenUIThemeProvider` →73`NotificationProvider` → `AppQueryProvider` → `ApiClientProvider` → `ErrorBoundary` →74`BrowserRouter` → `AuthProvider` → `ExtensionsProvider` → `AppRoutes`. Inside a protected route:75`ConsoleScopeProvider` → `AppLayout`. Don't add a provider without a reason that names its position.7677---7879## Oxygen UI essentials8081### Imports8283```tsx84import { Box, Button, Card, PageTitle, Stack, Typography } from '@wso2/oxygen-ui';85import { Plus, Search, Trash2 } from '@wso2/oxygen-ui-icons-react'; // lucide names, size={18}86import { DataGrid, DatePickers, TreeView, AdapterDateFns } from '@wso2/oxygen-ui'; // MUI X namespaces87```8889`@wso2/oxygen-ui` re-exports **all** of `@mui/material` plus Oxygen's own components, `styled`,90`alpha`, `useTheme`, `Theme`. MUI X ships as namespaces: `<DataGrid.DataGrid …>`,91`<DatePickers.DatePicker …>`, `<TreeView.SimpleTreeView>`. Charts live in92`@wso2/oxygen-ui-charts-react`, which is **not installed here** — adding it is a dependency change93(`.claude/rules/js-dependency-management.md`).9495### Oxygen's own components (beyond MUI)9697| Component | Sub-components / notes |98| --- | --- |99| `AppShell` | `.Navbar` `.Sidebar` `.Main` `.Footer` `.NotificationPanel`; props `initialCollapsed`, `collapseOnSelectOnMobile`. Wired once in `AppLayout` — pages never touch it. |100| `Header` | `.Toggle` `.Brand` `.BrandLogo` `.BrandTitle` `.Switchers` `.Actions` `.Spacer`; `minimal` hides switchers |101| `Sidebar` | `.Nav` `.Category` `.CategoryLabel` `.Item` `.ItemIcon` `.ItemLabel` `.ItemBadge` `.Footer` `.User*`; props `collapsed`, `activeItem`, `expandedMenus`, `onSelect`, `onToggleExpand`. An `Item` with nested children toggles instead of navigating. |102| `Footer` | `.Copyright` `.Version` `.Divider` (left) · `.Link` (right) |103| `UserMenu` | `.Trigger` `.Header` `.Item` `.Logout` `.Divider` |104| `NotificationPanel` | `.Header{,Icon,Title,Badge,Close}` `.Tabs` `.Actions` `.List` `.Item{,Avatar,Title,Message,Timestamp,Action}` `.EmptyState` |105| `PageTitle` | `.Header` `.SubHeader` `.Avatar` `.Link` `.Actions` `.BackButton` — the standard page heading |106| `PageContent` | padding/max-width wrapper; `fullWidth`. Already applied by `AppLayout`, so a page starts at its own content. |107| `AppBreadcrumbs` | `items: BreadcrumbItem[]`; rendered by `AppLayout` from route scope |108| `ListingTable` | `.Provider` `.Container` `.Toolbar` `.Head` `.Body` `.Footer` `.Row` `.Cell` `.SortLabel` `.RowActions` `.CellIcon` `.EmptyState` `.DensityControl`; `variant='table'\|'card'`, `density`, `striped`, `bordered`. Prefer over raw MUI `Table`. |109| `Form` | `.Section` `.Header` `.Subheader` `.Body` `.Stack` `.ElementWrapper` `.Wizard` `.CardButton` `.Card{Header,Content,Actions,Media}` |110| `ComplexSelect` | `.MenuItem` + `.MenuItem.Icon` / `.MenuItem.Text` — icon+text options inside a `Select` |111| `SearchBar`, `SearchBarWithAdvancedFilter`, `StatCard`, `CodeBlock`, `ColorSchemeToggle`, `ColorSchemeImage`, `ColorSchemeSVG`, `ThemeSwitcher`, `NotificationBanner`, `ParticleBackground`, `Layout` (`.Navbar` `.Sidebar` `.Content` `.Header`) | single-purpose; check the `.d.ts` for props |112113Hooks: `useTheme`, `useThemeSwitcher`, `useThemeContent`, `useAppShell`, `useNotifications`114(Oxygen's own — not this app's `src/components/Notifications`), `useHeader`, `useSidebar`,115`useNotificationPanel`, `useListingTable` / `useListingTableRequired`.116Utils: `formatRelativeTime`, `pxToRem`, `alpha`.117118### Theming119120`OxygenUIThemeProvider` is mounted once, directly in `App.tsx`, with a one-entry registry121(`AcrylicOrangeTheme`) declared at module scope in that same file — module scope because the122provider keys a theme-resolving effect on the array's identity. Don't call the provider anywhere123else, and don't wrap it in an app-level provider of your own.124125It needs nothing else from us: it already renders `<CssBaseline enableColorScheme />` internally,126and with no `emotionCache`/`nonce` prop it falls back to `<StyledEngineProvider injectFirst>` — so127app styles override Oxygen styles without a hand-rolled Emotion cache. Under a CSP, pass the128provider's own `nonce` prop rather than building a cache.129130Three tiers, in order of preference:1311321. **Theme** — global decisions owned by Oxygen (`theme.palette.*`, `theme.typography.*`, `theme.border.*`, `theme.zIndex.*`, `theme.oxygen.*` for blur/gradient/glass/syntax).1332. **Recipes** — `src/theme/receipes.ts`: `hairline(theme)`, `glassSurfaceSx(theme)`, `interactiveCardSx`, `stickyBottomBarSx(theme)`, `overlayBarShadow`. A repeated multi-property treatment goes here, once.1343. **Local `sx`** — layout only: flex, gap, grid, min/max sizing.135136```tsx137<Card sx={(theme) => ({ ...glassSurfaceSx(theme), display: 'flex', gap: 2 })} />138```139140Dark mode: the theme is CSS-variable based (`--oxygen-*`, `data-color-scheme` attribute), so token141usage adapts for free. Read the mode with `useTheme().palette.mode` only when behaviour (not colour)142must branch.143144---145146## Page patterns147148### Page skeleton149150```tsx151export function ThingListPage() {152 // Gate the whole body, not just the JSX: out of scope the query stays disabled153 // and `isPending` never clears, so a loading branch would hang forever.154 return (155 <ScopeGate156 prompt="Things are created and managed at the project level."157 requires="project"158 to={routes.things}159 >160 <ThingList />161 </ScopeGate>162 );163}164165function ThingList() {166 const intl = useIntl();167 const thingsQuery = useThings();168 const { notify } = useNotifications();169170 // `isPending`, not `isLoading` — a disabled query has isLoading=false with no171 // data, which would flash the empty state.172 if (thingsQuery.isPending) return <LoadingState label="Loading things" />;173 if (thingsQuery.error) return <ErrorState message="Unable to load things" />;174175 const things = thingsQuery.data?.list ?? [];176177 return (178 <>179 <PageTitle>180 <PageTitle.Header><FormattedMessage {...messages.title} /></PageTitle.Header>181 <PageTitle.SubHeader><FormattedMessage {...messages.subtitle} /></PageTitle.SubHeader>182 <PageTitle.Actions>183 <Button startIcon={<Plus />} variant="contained" onClick={create}>184 <FormattedMessage {...messages.create} />185 </Button>186 </PageTitle.Actions>187 </PageTitle>188189 {things.length === 0 ? (190 <EmptyState191 title={intl.formatMessage(messages.emptyTitle)}192 description={intl.formatMessage(messages.emptyBody)}193 actionLabel={intl.formatMessage(messages.create)}194 onAction={create}195 />196 ) : (197 <Stack spacing={2}>{/* toolbar row, then grid/list */}</Stack>198 )}199 </>200 );201}202```203204State views come from `src/components/StateViews` — `LoadingState({label, fullScreen})`,205`EmptyState({title, description, actionLabel, onAction})`, `ErrorState({title, message})`.206Never hand-roll a spinner or an error `Alert`.207208Reference implementations to copy from: `apis/ApiListPage.tsx` (list + search + grid/list toggle +209delete), `apis/ApiDetailPage.tsx` (tabbed detail), `gateways/GatewayCreatePage.tsx` (create flow),210`apis/overview/ApiKeysPanel.tsx` (`ListingTable`), `projects/NewProjectDialog.tsx` (dialog form).211212### Feedback and confirmation213214```tsx215const { notify } = useNotifications(); // src/components/Notifications216notify('Deleted "Orders API".', 'success'); // 'success' | 'info' | 'warning' | 'error'217```218219Mutation errors already surface globally (the QueryClient's `onMutationError` notifies), so a220per-call `onError` is for *better* copy, never for making the error visible at all.221222Destructive actions use `ConfirmDialog` (`src/components/ConfirmDialog`), with `destructive` and —223for irreversible deletes — `confirmPhrase` + `confirmInputLabel` (type-the-name):224225```tsx226<ConfirmDialog227 open={toDelete !== null}228 title="Delete API"229 message={`This permanently deletes "${toDelete?.displayName}" and all related details.`}230 confirmLabel="Delete"231 confirmPhrase={toDelete?.displayName ?? ''}232 destructive233 loading={mutation.isPending}234 onCancel={() => setToDelete(null)}235 onConfirm={confirmDelete}236/>237```238239### Forms240241Controlled state + `error`/`helperText`; group with `Form.Section` / `Form.Stack`; multi-step with242`Form.Wizard` (or `Stepper` where a wizard is overkill). Labels and helper text go through243`intl.formatMessage`. Field errors from the API arrive as `ApiError.fieldErrors` — map them onto the244matching field instead of dumping the message into a banner.245246```tsx247<Form.Section>248 <Form.Header><FormattedMessage {...messages.detailsHeader} /></Form.Header>249 <Form.Subheader><FormattedMessage {...messages.detailsHint} /></Form.Subheader>250 <Form.Stack spacing={2}>251 <TextField252 error={Boolean(errors.name)}253 helperText={errors.name}254 label={intl.formatMessage(messages.nameLabel)}255 onChange={(event) => setName(event.target.value)}256 required257 value={name}258 />259 </Form.Stack>260</Form.Section>261```262263### Accessibility and small conventions264265- A `Select` needs `labelId` pointing at its `FormLabel id` — a bare `FormLabel` leaves the combobox with no accessible name (and nothing for tests to query).266- Icon-only buttons need `aria-label` (translated).267- Icon size is explicit: `<Search size={18} />`.268- Newer files sort JSX props and `sx` keys alphabetically; follow the file you're editing.269- Truncate rather than widen: `minWidth: 0` + `overflow: 'hidden'` + `noWrap`.270271---272273## Data access (`src/api`)274275Full rules in `src/api/README.md`. What a UI author must know:276277- Import **only** hooks: `useThings()`, `useThing(id)`, `useCreateThing()`, `useUpdateThing()`, `useDeleteThing()`. Endpoints/queries/keys/http/generated types are ESLint-blocked from the UI.278- Hooks resolve org/project from route scope via `useApiScope()`; pass overrides only for a genuine cross-scope read.279- Scoped queries are `enabled`-gated, so **branch on `isPending`**, not `isLoading`.280- Errors are always `ApiError` — branch on `error.code`, never HTTP status; `fieldErrors`, `details`, `trackingId` are available. Never render `error.message` as translated copy.281- Lists: read `pagination.total`, never `list.length`.282- Adding a backend call = three files (`*.endpoints.ts`, `*.queries.ts`, `*.hooks.ts`) copied from `resources/restApis/`, plus `npm run api:codegen` if the spec changed. Never hand-write a request/response type — derive from the `operationId` with `ResponseOf`/`BodyOf`/`QueryOf`/`PathOf`.283- A legacy layer (`client.ts`, `mvpApi.ts`, `*/*Client.ts`) still serves some pages. Don't extend it; port the page.284285Route scope for rendering (not fetching) comes from `useConsoleScope()`:286`{ params, activeScope, organization(s), project(s), component, capabilities, isOrganizationScope, isProjectScope, isApiScope, isLoading, projectsError }`.287288---289290## i18n291292Full rules in `src/i18n/README.md`. The short form:293294```tsx295import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; // direct import, always296297const messages = defineMessages({ // module scope — extraction is static298 title: { id: 'apiControlPlane.pages.apis.ApiListPage.title', defaultMessage: 'APIs' },299 nameLabel: {300 id: 'apiControlPlane.pages.apis.ApiListPage.nameLabel',301 defaultMessage: 'Name',302 description: 'Label for the API name field. Noun, not a command.',303 },304});305```306307- ID = `apiControlPlane.<area>.<Module>.<slug>`; `<area>` mirrors the `src/` path (`/`→`.`, filename dropped). Renaming an ID discards its translations; `defaultMessage` may change freely.308- One sentence = one message. Never concatenate; use ICU placeholders/plurals/rich-text tags.309- Never translate backend or user data — pass it through as a *value*.310- Dates/numbers via `useFormatters()` (`shortDate`, `dateTime`, `relativeTime`) or `<FormattedDate>`/`<FormattedNumber>` — never `toLocaleString()` or a module-scope `Intl.*`.311- After changing strings: `npm run i18n`, commit `src/i18n/messages/`. `npm run lint:i18n` lists remaining hardcoded JSX (the rule is `warn`, so `npm run lint --quiet` skips it). Check layout growth with `?lang=en-XA`.312313---314315## Routing, scope and navigation316317Three tiers of scope live in the URL: `/organizations/:orgHandle`,318`.../projects/:projectHandler`, `.../apis/:apiHandler`. A page that needs deeper scope than the319current URL still mounts — at a **scope-less alias** where `select-scope` (`SELECT_SCOPE_SEGMENT`)320replaces the missing segments — and `ScopeGate` renders a picker until the handles are filled in.321That is why every sidebar item stays clickable at every scope.322323Adding a page, end to end:3243251. **`src/routes/paths.ts`** — add a builder using `projectPath`/`apiPath` so the alias is generated for you:326 ```ts327 thingDetail: (orgHandle = ':orgHandle', projectHandler: ScopeHandle = ':projectHandler') =>328 projectPath(orgHandle, projectHandler, 'things/detail'),329 ```3302. **`src/routes/AppRoutes.tsx`** — `lazy()`-import the page and register every path it answers on:331 ```tsx332 {scopedRoutes(projectScopedPaths(routes.thingDetail), <ThingDetailPage />)}333 ```334 (`apiScopedPaths` for API-level pages; a single `<Route>` only when the page has no alias.)3353. **The page** wraps its body in `ScopeGate` with `requires` + `to={routes.thingDetail}`.3364. **`src/navigation/navigationRegistry.tsx`** — add the sidebar entry. Build `to`/`match` from the *same* builder via the helpers, never by hand: `orgLevelTo`, `apiLevelTo`, `matchRoutes`, `submenu([...])` for a parent with children, `adaptive([...tiers])` for one item that degrades across scopes, `apiCapability(...)` for capability gating (which only applies once an API is in scope).337338Never hand-write a path string or a `match` regex: `routes.*` is the single source, and339`paths.test.ts` / `navigationRegistry.test.ts` guard the pairing.340341Extension points: `Slot` / `Hideable` (`src/slots/`) — a named additive slot plus a suppressible342region for built-in UI; extensions receive a plain `CloudHostPort` value (`src/hostPort.tsx`), not a343shared context. Keep `src/slots/index.tsx` free of portal-specific types; it is copied verbatim into344other hosts.345346Two slot families exist today, both declared in `src/extensions.tsx`: `sidebar.<level>` for a347top-level nav item (routed by `AppRoutes`, merged into the sidebar by `useNavigationItems`) and348`settings.<level>.tabs` for a Settings sub-nav tab (listed by `useSettingsTabs`, routed nested under349`SettingsLayout`). An entry's `slot` and `level` must agree — a descriptor claiming350`settings.project.tabs` with `level: 'organization'` is dropped by both, rather than rendered351against the wrong scope's Port. Filter with `isSidebarExtension`/`settingsTabExtensions`; never352re-spell a slot name as a literal at the use site.353354---355356## Tests357358Full conventions in `src/test/README.md`.359360- Colocate as `*.test.ts` / `*.test.tsx`. Always render through `renderWithProviders` (never wrap providers by hand):361 ```tsx362 const { user } = renderWithProviders(<ApiListPage />, {363 route: '/organizations/api-platform-demo/projects/retail-apis/apis',364 scope: makeConsoleScope(),365 authState: authStatePresets.authenticated(),366 });367 ```368- Mock at the network boundary with MSW, never a client or a hook. The server ships **no default handlers** and fails unhandled requests, so each test declares its endpoints via `collection` / `resource` / `accepts` / `noContent` / `failure` / `recorder` and spec-typed fixtures (`aRestApi`, `manyRestApis`). Build URLs with `apiUrl('/things')`; call `resetHttpClient()` in `beforeEach`.369- `renderWithProviders` supplies `IntlProvider` with empty messages, so assert on the English `defaultMessage`.370- Prefer `userEvent`; query by role/label; await with `findBy*`. To prove *no* request fired, await a short timeout then assert `requests.count() === 0` — `waitFor` proves nothing there.371- Hook tests go one per *shape*, not per resource; endpoint tests one per resource.372373---374375## Migrating existing MUI code376377| From | To |378| --- | --- |379| `@mui/material`, `@mui/material/styles` (`styled`, `alpha`, `useTheme`) | `@wso2/oxygen-ui` |380| `@mui/icons-material`, `lucide-react` | `@wso2/oxygen-ui-icons-react` (bare lucide names, explicit `size`) |381| `@mui/x-data-grid` / `x-date-pickers` / `x-tree-view` | `DataGrid.*` / `DatePickers.*` / `TreeView.*` from `@wso2/oxygen-ui` |382| `ThemeProvider` + `createTheme`, `CssBaseline`, a custom Emotion cache | nothing — `OxygenUIThemeProvider` in `App.tsx` already owns all three |383| `AppBar`/`Toolbar`/`Drawer` layout | `AppShell` + `Header` + `Sidebar` (already in `AppLayout`) |384| `Table`/`TableHead`/`TableRow`/`TableCell` | `ListingTable.*` |385| `<Grid item xs={12} md={4}>` | `<Grid size={{ xs: 12, md: 4 }}>` |386| `useColorScheme()` | `<ColorSchemeToggle />`, or `useTheme().palette.mode` |387| hardcoded colours/spacing | theme tokens, or a recipe in `src/theme/receipes.ts` |388| hardcoded JSX strings | `FormattedMessage` / `intl.formatMessage` (add to `defineMessages`) |389| direct client/axios calls | a hook from `src/api/resources/<resource>` |390391## Review checklist392393- Apache header present; imports only from `@wso2/oxygen-ui` + `@wso2/oxygen-ui-icons-react`.394- No user-facing literal; every new message has an ID in the house format (and a `description` where a translator could misread it).395- Data via hooks; `isPending` used for the loading branch; `ApiError.code` for branching; `pagination.total` for counts.396- Scoped page wrapped in `ScopeGate`; `to` is the page's own `routes.*` builder; route registered for the alias paths too; sidebar `to`/`match` derived from the same builder.397- Colours/spacing/borders via tokens or recipes; local `sx` limited to layout.398- Loading/empty/error use `StateViews`; destructive actions use `ConfirmDialog`; feedback via `useNotifications`.399- `Select` has `labelId`; icon-only buttons have a translated `aria-label`.400- `npm run typecheck && npm run lint && npm run test` clean; `npm run i18n` run and `src/i18n/messages/` committed (never `src/i18n/compiled/`).