update-portal-ui
GitHub提供 Portal React 前端页面更新与设计指南,涵盖组件规范、链接渲染规则及国际化模式。确保用户可见文本正确翻译与格式化,并指导正确使用内部/外部链接组件以避免样式丢失问题。
Trigger Scenarios
Install
npx skills add authgear/authgear-server --skill update-portal-ui -g -y
SKILL.md
Frontmatter
{
"name": "update-portal-ui",
"description": "Guidelines for updating or designing pages in the portal React frontend (portal\/src). Covers component conventions, link rendering rules, i18n patterns, and common pitfalls."
}
Follow this skill when adding, editing, or reviewing UI in portal/src.
All user-facing text must be translated
Every string a user can see or hear must go through renderToString/FormattedMessage/locale-data (portal/src/locale-data/en.json) — never a bare string literal. This applies beyond obvious JSX text nodes:
- Chart/graph library config: dataset
labels, legend text, tooltip callbacks, axis titles passed intochart.js(or any charting lib) config objects are still user-facing text, even though they live inside a plain JS config object, not JSX. Wrap them withrenderToStringthe same as any other label. - Locale-aware formatting of derived values: any
Intl.DisplayNames,Intl.NumberFormat,Intl.DateTimeFormat, orluxonDateTime#toFormat/toLocaleStringcall that produces user-visible output (country names, chart axis date labels, etc.) must be constructed with the active portal locale, not a hardcoded locale (e.g.new Intl.DisplayNames(["en"], ...)) and not left to the library's default. Grep for.toFormat(/.toLocaleString(/new Intl.in your diff and confirm each one is passed (or chained with) the activelocale, not silently defaulting. - A string can be "translated" everywhere else in a file and still miss one of these — check every literal individually, don't assume a file is compliant because most of it uses
FormattedMessage.
Link components
The portal has three link components. Use the right one — using the wrong one causes links to render as unstyled plain text inside certain wrappers.
| Component | Import path | Use when |
|---|---|---|
Link |
../../Link (or relative path to portal/src/Link.tsx) |
Internal navigation (React Router) |
ExternalLink |
../../ExternalLink |
External URLs (href, opens in new tab) |
LinkButton |
../../LinkButton |
A button that visually looks like a link |
Never use Link from react-router-dom directly — it renders a plain <a> tag with no FluentUI styling.
Why this matters: the WidgetDescription / Text trap
WidgetDescription wraps its children in a FluentUI Text component. FluentUI's Text overrides the colour of plain <a> tags to match surrounding text, making links invisible as links.
portal/src/Link.tsxandportal/src/ExternalLink.tsxboth wrap FluentUI'sFluentLink, which keeps its own link styling even insideText. ✓react-router-dom'sLinkrenders a bare<a>— styling is stripped insideText. ✗
Rule: Whenever a link appears inside WidgetDescription, Text (FluentUI), or any component that internally wraps FluentUI Text, use Link or ExternalLink from portal/src, not from react-router-dom.
Inline links inside FormattedMessage (i18n)
To embed a clickable link inside a translated string:
-
In the translation string (
portal/src/locale-data/en.json), use an XML-like tag:"my-key": "Read the <docLink>documentation</docLink> for details." -
In the component, pass a render function in
FormattedMessagevalueswhose key matches the tag name exactly:<FormattedMessage id="my-key" values={{ // eslint-disable-next-line react/no-unstable-nested-components docLink: (chunks: React.ReactNode) => ( <ExternalLink href="https://docs.authgear.com/..."> {chunks} </ExternalLink> ), }} /> -
Use
Linkfor internal routes,ExternalLinkfor external URLs. Never use react-router-dom'sLinkhere.
Passing rich content to callbacks that accept descriptions
Some components (e.g. FluentUI ChoiceGroup via onRenderLabel) accept a label-render callback. If the description contains a link, the callback must accept React.ReactNode, not string:
// Correct — accepts ReactNode so JSX can be passed
const onRenderLabel = useCallback((description: React.ReactNode) => {
return (option?: IChoiceGroupOption) => (
<div>
<Text>{option?.text}</Text>
<Text>{description}</Text>
</div>
);
}, []);
// Then pass FormattedMessage directly — no cast needed
onRenderLabel(
<FormattedMessage id="..." values={{ reactRouterLink: ... }} />
)
Never cast JSX to string with as any as string — the link will not render correctly.
Verification checklist
Before submitting a portal UI change:
- No hardcoded user-facing string literals anywhere in the diff, including non-JSX config objects (chart library
label/legend/tooltip config, form option lists, etc.) — all go throughrenderToString/FormattedMessage. - Every
Intl.DisplayNames/Intl.NumberFormat/Intl.DateTimeFormat/luxontoFormat/toLocaleStringcall that produces user-visible text uses the active portal locale, not a hardcoded or default locale. - Links inside
WidgetDescriptionor FluentUITextuseLinkorExternalLinkfromportal/src, not fromreact-router-dom. - Inline links in
FormattedMessagevaluesuseLinkorExternalLinkfromportal/src. - Callbacks that may receive rich content (links, JSX) are typed
React.ReactNode, notstring. - Run
cd portal && npm run typecheck— must pass clean.
Version History
- 2dd6d88 Current 2026-07-24 16:31


