.skills/discourse-admin-ui/references/content-patterns.md
Use this reference for admin page body layout, cards, forms, subheaders, help insets, tables, empty states, and third-level routes.
Wrap page content in the established admin page area class:
<div class="admin-config-page__main-area">
...
</div>
When a page has primary content plus contextual help, use a two-column layout where the primary content takes about two-thirds and the help/reference area takes about one-third. On small screens the columns should stack. If there is no secondary content, keep the main content width consistent with comparable pages instead of expanding into a bespoke full-width layout.
Prefer existing admin classes and nearby page structure before creating new layout CSS.
Use DPageSubheader from discourse/ui-kit/d-page-subheader to introduce a section below the main page header, especially when the section has a description or action buttons.
Use h2-level subheaders through the component, and keep action labels specific. Primary actions use the yielded primary button; secondary actions use default buttons and usually appear only when a primary action exists.
Example to inspect: plugins/discourse-data-explorer/admin/assets/javascripts/admin/templates/admin-plugins/show/explorer/index.gjs.
<DPageSubheader
@titleLabel={{i18n "admin.example.records.title"}}
@descriptionLabel={{i18n "admin.example.records.description"}}
>
<:actions as |actions|>
<actions.Primary
@route="admin.example.records.new"
@label="admin.example.records.add"
/>
</:actions>
</DPageSubheader>
Use AdminConfigAreaCard from discourse/admin/components/admin-config-area-card for grouped settings, setup steps, and form sections. Cards should group related work, put the most important content first, and usually have one primary next action.
Card headings should be sentence-cased. Use @heading for I18n keys or @translatedHeading when the caller already has translated text.
<AdminConfigAreaCard
@heading="admin.config_areas.example.general_settings"
class="admin-example__general-settings"
>
<ExampleSettingsForm @model={{@model}} />
</AdminConfigAreaCard>
Current examples:
frontend/discourse/admin/templates/admin-embedding/index.gjsplugins/discourse-ai/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-features/edit.gjsUse FormKit for new admin forms. Do not build ad hoc form controls when FormKit components cover the interaction. Keep client validation paired with server-side validation; do not rely only on browser or JS validation.
Reference: docs/developer-guides/docs/03-code-internals/21-form-kit.md and frontend/discourse/app/form-kit.
New/edit forms usually belong on third-level routes rather than inline inside a table row. This makes the form linkable, reloadable, and easier to test.
Use help/reference content for contextual docs, caveats, or links that help admins complete the task. Keep it in the secondary column and sentence-case headings. Include an icon in the heading when the surrounding pattern does so.
Do not turn help content into marketing copy. It should answer what the admin needs to know at this point in the workflow.
Use tables for growing sets of structured records where each row has the same attributes and admins need to scan, edit, enable/disable, or delete entries.
Structural classes:
<table class="d-table"><thead class="d-table__header"><tbody class="d-table__body"><tr class="d-table__row">d-table__cell --overviewd-table__cell --detaild-table__cell --controlsd-table__cell-actionsOverview cells should carry the row identity. When rows have a show/edit page, wrap the main name/description in a LinkTo with d-table__overview-link, and mark the primary name with d-table__overview-name.
Every non-overview cell needs a mobile label matching its header:
<td class="d-table__cell --detail">
<div class="d-table__mobile-label">
{{i18n "admin.example.status"}}
</div>
{{record.status}}
</td>
For row actions:
DMenu with DDropdownMenu.btn-small to row action buttons unless the local pattern says otherwise.DToggleSwitch for enable/disable toggles.Current examples:
frontend/discourse/admin/templates/admin-permalinks/index.gjsplugins/discourse-data-explorer/admin/assets/javascripts/admin/templates/admin-plugins/show/explorer/index.gjsplugins/discourse-calendar/assets/javascripts/discourse/components/admin-holidays-list-item.gjsWhen an empty list can be resolved by creating a record, use AdminConfigAreaEmptyList with a clear CTA label and route/action.
<AdminConfigAreaEmptyList
@emptyLabel="admin.example.no_records"
@ctaLabel="admin.example.add_record"
@ctaRoute="admin.example.records.new"
/>
Do not show an empty-list CTA for filtered search results. In that case show a no-results message and a way to clear/reset filters.
Use standalone routes for forms or detailed record editing:
<resource>/new<resource>/:id/editThese routes must reload directly from the browser; add matching backend routes when required. Avoid inline forms inside index tables unless the existing page already depends on that pattern.
Third-level new/edit pages should:
BackButton at the top.AdminConfigAreaCard.<BackButton
@route="adminConfig.flags"
@label="admin.config_areas.flags.back"
/>
<AdminConfigAreaCard @heading="admin.config_areas.flags.form.title">
<AdminFlagsForm @model={{@model}} />
</AdminConfigAreaCard>
Current examples:
frontend/discourse/admin/templates/admin-config/flags/new.gjsplugins/discourse-ai/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-features/edit.gjs