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.

Source in the MFEs
resource-fe AppDetailHeader.tsxresource-fe app-detail-header.module.cssresource-fe ResourceInfoPanel.tsx (module wrapper)Reference/detail.tsx (usage)Publish/detail.tsx (usage)detail-page-header skill

AppDetailHeader

Component

The information header for detail pages — name, one-line counters, tags, and a muted meta line.

Download .zip
Install tosrc/components/common/Requiresantd
  • TSXAppDetailHeader.tsx
  • CSSapp-detail-header.module.css
  • MDREADME.md

Anatomy

One flat block, no box of its own — the page Card already frames the screen. It answers three questions in a fixed reading order:

  1. 1left — what this isName (reads as the page title), an optional muted description, then pill tags for what kind this resource is / who owns it.
  2. 2right — the numbersThe counters, collapsed onto ONE right-anchored line as `Label: value` pairs — never a stacked column.
  3. 3below — the machine detailA 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.

Preview
Reference categoryBHD PO-PO — rate reference category.
Keys:42Missing:3Unpublished:5Last published:29/07/2026, 14:10
rate
reference.rate.MOBILE
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

NameTypeDefaultDescription
nameReactNodeThe resource’s name. Reads as the page title.
descriptionReactNodeOne muted line under the name — a description, never an identifier.
tagsReactNodePill 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).
statsDetailHeaderStat[]{ label, value, tone?, small? } — the counters, collapsed onto one line. tone colours the VALUE: ok green, warn amber, info azure, muted grey.
metaDetailHeaderMeta[]{ 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

Domain props belong in a wrapper, not here
Don’t add module-specific props (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.
Counters read `Label: value`, not upper-case small-caps
The label is plain grey (#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.
Skip the identifier if the table already shows it
Don’t print the fully-qualified key/identifier in the 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.
The .claude/skills asset predates this version
The distributable copy under .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.