- name
- apicp-ui
- description
- 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.
# 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
1. **Every source file starts with the Apache-2.0 header** (`Copyright (c) 2026, WSO2 LLC.` block — copy it from any neighbouring file).
2. **All components come from `@wso2/oxygen-ui`**, never `@mui/material`. All icons from `@wso2/oxygen-ui-icons-react`, never `lucide-react`/`@mui/icons-material`.
3. **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.
4. **No user-facing literal strings.** `<FormattedMessage>` for JSX, `intl.formatMessage` for string props. IDs are `apiControlPlane.<area>.<Module>.<slug>`.
5. **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.
6. **Scoped pages wrap their body in `ScopeGate`** (see *Routing, scope and navigation*).
7. **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, useDocumentTitle)
i18n/ I18nProvider, useLocale, useFormatters, formats.ts, messages/ (source catalogs)
navigation/ navigationRegistry.tsx (the sidebar), navigationTypes, useNavigationItems,
usePageTitle (browser tab title)
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
```tsx
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:
1. **Theme** — global decisions owned by Oxygen (`theme.palette.*`, `theme.typography.*`, `theme.border.*`, `theme.zIndex.*`, `theme.oxygen.*` for blur/gradient/glass/syntax).
2. **Recipes** — `src/theme/receipes.ts`: `hairline(theme)`, `glassSurfaceSx(theme)`, `interactiveCardSx`, `stickyBottomBarSx(theme)`, `overlayBarShadow`. A repeated multi-property treatment goes here, once.
3. **Local `sx`** — layout only: flex, gap, grid, min/max sizing.
```tsx
<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
```tsx
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" onClick={create}>
<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)}
onAction={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
```tsx
Auf GitHub ansehen