Patterns
Detail Header
AppDetailHeader — the information header that opens every detail page: the resource name as the page title, counters collapsed onto one right-anchored line, pill tags, and a muted closing line for identifiers and versions.
AppDetailHeader
ComponentThe information header for detail pages — name, one-line counters, tags, and a muted meta line.
src/components/common/RequiresantdAnatomy
One flat block, no box of its own — the page Card already frames the screen. It answers three questions in a fixed reading order:
- 1left — what this is — Name (reads as the page title), an optional muted description, then pill tags for what kind this resource is / who owns it.
- 2right — the numbers — The counters, collapsed onto ONE right-anchored line as `Label: value` pairs — never a stacked column.
- 3below — the machine detail — A closing muted line for identifiers, generated paths, and versions — optional, and skipped entirely when the table below already shows the same identifier.
Deliberately not a bordered Descriptions grid: short values (8 · 0 · —) in a 4-column table read as a spreadsheet of random gaps.
Live example
A working header for a reference category detail — resize the window below ~992px to see the counter line drop to its own full-width row instead of squeezing against the name.
import AppDetailHeader, { type DetailHeaderStat } from '@/components/common/AppDetailHeader';
import { Tag, Tooltip } from 'antd';
const stats: DetailHeaderStat[] = [
{ label: t('publish.columns.keys'), value: row.keyCount ?? 0 },
{ label: t('publish.columns.missing'), value: row.missingCount ?? 0, tone: (row.missingCount ?? 0) > 0 ? 'warn' : 'ok' },
{ label: t('publish.columns.draft'), value: row.draftCount ?? 0, tone: (row.draftCount ?? 0) > 0 ? 'info' : 'default' },
{ label: t('publish.columns.last'), value: row.lastPublishedDate ? new Date(row.lastPublishedDate).toLocaleString() : '—', tone: row.lastPublishedDate ? 'default' : 'muted' },
];
<Card className="w-100" title={<span className="text-primary fw-bold font-size-13">{t('reference.title.detail')}</span>} loading={loading && !row}>
<AppDetailHeader
name={row.name}
description={row.description}
tags={
<Tooltip title={t('reference.label.service')}>
<Tag style={TAG_PILL.service}>{row.owner}</Tag>
</Tooltip>
}
stats={stats}
/>
<Table className="mt-3 w-100" ... /> {/* mt-3, NOT a <Divider> — the header's own bottom gap is the separation */}
</Card>Component
Generic on purpose — it owns layout and typography only. Every string arrives already translated and already formatted, so it never needs to know a module’s i18n keys or its domain. A module whose detail pages always show the same counters should wrap it once (resource-fe does, in ResourceInfoPanel.tsx) instead of rebuilding the stats array on every page.
// React (resource-fe, and every other React MFE that adopts this pattern)
import { Tooltip } from 'antd';
import type { ReactNode } from 'react';
import styles from './app-detail-header.module.css';
export type DetailHeaderStat = {
label: string;
value: ReactNode;
tone?: 'default' | 'ok' | 'warn' | 'info' | 'muted';
small?: boolean;
};
export type DetailHeaderMeta = {
text: ReactNode;
mono?: boolean;
tooltip?: string;
};
type AppDetailHeaderProps = {
name: ReactNode;
description?: ReactNode;
tags?: ReactNode;
stats?: DetailHeaderStat[];
meta?: DetailHeaderMeta[];
};
const AppDetailHeader = ({ name, description, tags, stats, meta }: AppDetailHeaderProps) => (
<section className={styles.header}>
<div className={styles.top}>
<div className={styles.titleBlock}>
<span className={styles.name}>{name}</span>
{description && <span className={styles.muted}>{description}</span>}
</div>
{!!stats?.length && (
<div className={styles.statLine}>
{stats.map((s) => (
<span key={s.label} className={styles.statInline}>
<span className={styles.statLabel}>{s.label}:</span>
<span className={`${styles.statValue} ${s.small ? styles.statValueSmall : ''} ${s.tone && s.tone !== 'default' ? styles[s.tone] : ''}`}>{s.value}</span>
</span>
))}
</div>
)}
</div>
{tags && <div className={styles.tags}>{tags}</div>}
{!!meta?.length && (
<div className={styles.meta}>
{meta.map((m, i) => {
const content = <span className={m.mono ? styles.mono : styles.muted}>{m.text}</span>;
return m.tooltip ? <Tooltip key={i} title={m.tooltip}>{content}</Tooltip> : <span key={i}>{content}</span>;
})}
</div>
)}
</section>
);
export default AppDetailHeader;Props
| Name | Type | Default | Description |
|---|---|---|---|
name | ReactNode | — | The resource’s name. Reads as the page title. |
description | ReactNode | — | One muted line under the name — a description, never an identifier. |
tags | ReactNode | — | Pill tags under the name. React: a prop. Vue: the named "tags" slot. Owned by the page, not the component — define the palette once per module (a TAG_PILL constant). |
stats | DetailHeaderStat[] | — | { label, value, tone?, small? } — the counters, collapsed onto one line. tone colours the VALUE: ok green, warn amber, info azure, muted grey. |
meta | DetailHeaderMeta[] | — | { text, mono?, tooltip? } — the closing muted line. mono prints a machine string (key/path/hash) in the mono face. |
Usage
Render it as the first child inside the page Card, before the table/tabs. Replace the old <Divider /> that used to separate the summary from the table with mt-3 on the table — the header’s own bottom gap is the separation now.
Rules
keyCount, missingCount, …) directly to AppDetailHeader — that is what made an earlier version of this header un-reusable. Keep those in a thin module-specific wrapper (resource-fe: ResourceInfoPanel.tsx) that builds the generic stats array, and let two pages sharing that wrapper stay unable to disagree on the same count.#6b7280) body text with a trailing colon, at the same 13px as the value — not an uppercase, letter-spaced small-caps label. A 20px navy name next to an oversized bold value reads as two competing headings; this keeps the counters as supporting detail.meta line if the table below already shows it as its first column and the breadcrumb names the unit — that is the same string three times. Both resource-fe detail screens drop it for exactly this reason..claude/skills/detail-page-header/assets/ still shows an earlier revision — 14px name, uppercase small-caps labels, no colon, 20px gap. resource-fe’s real, currently shipping component (and the files bundled on this page) use the numbers documented above instead. Copy from this page or from resource-fe/src/components/common/, not from that asset folder, until it is refreshed to match.