Detail pages¶
Status: Implemented Last reviewed: 2026-04-18 Sources of truth:
templates/skeletons/skeleton_detail.html. Reference implementations:apps/practices/templates/practices/practice_detail.html,apps/entities/templates/entities/entity_detail.html,apps/dentists/templates/dentists/dentist_detail.html.
Scope¶
Conventions for object-detail pages: heading + status + actions, two-column card grid for content, card header style, key/value field rendering, audit footer (created / modified), responsive behaviour.
Out of scope (covered elsewhere):
- Status badges (which class for which state) → guidelines/ui/badges.md.
- Embedded mini-lists inside a detail card → guidelines/ui/lists.md "When to break the rules" section.
- Editing the displayed object → guidelines/ui/forms.md.
Hard rules¶
- Heading area: title + status badges + action buttons in a single
d-flexrow. Wrap title and badges on the left, action group on the right. Badges sit under the title in a<div class="mt-2">, never inline with the<h2>. - Cards use
card border-0 shadow-sm h-100inside acol-md-6 mb-4grid cell.border-0is intentional —base.cssputs a border on every card globally (don't fight it). - Card headers use
card-header card-header-neutralwith<i class="bi-..."></i> {% trans "Title" %}only — never<h5>or<h6>inside. Card headers are visual chrome, not semantic headings; promoting them to<h5>/<h6>breaks document outline and thecard-header-neutralstyling. - Key/value display uses
<p class="mb-2"><strong>{% trans "Label" %} :</strong> {{ value|default:"-" }}</p>— never a<table>for two-column layout. Tables are for data (multiple rows of comparable records); they are not a layout primitive. -(or—) for empty optional fields, never blank. Use|default:"-"consistently. Blank cells confuse "field is empty" with "field doesn't apply."- All labels translatable.
{% trans "Nom" %}notNom. Same for badge text and button labels.
Required structure¶
Copy from templates/skeletons/skeleton_detail.html. The shape:
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{{ entity.code }} — {{ entity.name }} - Aletheia{% endblock %}
{% block breadcrumbs %}{% include 'components/breadcrumbs.html' %}{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
{# --- Heading --- #}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="mb-1">
<i class="bi bi-buildings"></i> {{ entity.code }} — {{ entity.name }}
</h2>
<div class="mt-2 d-flex gap-2 flex-wrap">
<span class="badge badge-type {{ entity.entity_type_badge_class }}">
{{ entity.get_entity_type_display }}
</span>
{% if entity.lifecycle_status == 'active' %}
<span class="badge badge-status-active">{{ entity.get_lifecycle_status_display }}</span>
{% else %}
<span class="badge badge-status-inactive">{{ entity.get_lifecycle_status_display }}</span>
{% endif %}
</div>
</div>
<div class="d-flex gap-2">
<a href="{% url '...' entity.code %}" class="btn btn-action-primary">
<i class="bi bi-pencil"></i> {% trans "Modifier" %}
</a>
<a href="{% url '...' %}" class="btn btn-action-secondary">
<i class="bi bi-arrow-left"></i> {% trans "Retour" %}
</a>
</div>
</div>
{# --- Cards grid --- #}
<div class="row">
<div class="col-md-6 mb-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header card-header-neutral">
<i class="bi bi-info-circle"></i> {% trans "Identité" %}
</div>
<div class="card-body">
<p class="mb-2"><strong>{% trans "Nom" %} :</strong> {{ obj.name|default:"-" }}</p>
<p class="mb-2"><strong>{% trans "Email" %} :</strong> {{ obj.email|default:"-" }}</p>
<p class="mb-0"><strong>{% trans "Statut" %} :</strong>
{% if obj.active %}
<span class="badge badge-status-active">{% trans "Oui" %}</span>
{% else %}
<span class="badge badge-status-inactive">{% trans "Non" %}</span>
{% endif %}
</p>
</div>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="card border-0 shadow-sm h-100">
{# … second card … #}
</div>
</div>
{# --- Audit footer (always full-width, always last) --- #}
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header card-header-neutral">
<i class="bi bi-clock-history"></i> {% trans "Audit" %}
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<small class="text-muted">
{% trans "Créé le" %} {{ obj.created_at|date:"d/m/Y H:i" }}
{% if obj.created_by %}{% trans "par" %} {{ obj.created_by.get_full_name }}{% endif %}
</small>
</div>
<div class="col-md-6 text-md-end">
<small class="text-muted">
{% trans "Modifié le" %} {{ obj.updated_at|date:"d/m/Y H:i" }}
{% if obj.updated_by %}{% trans "par" %} {{ obj.updated_by.get_full_name }}{% endif %}
</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
Heading¶
- Container:
d-flex justify-content-between align-items-center mb-4(notemb-4, notmb-3— detail headings get more breathing room than list headings). - Title:
<h2 class="mb-1">with a Bootstrap Icon. The title string can include code + name (e.g. "VSM-001 — Cabinet Saint-Marc") — the icon comes first, then the text. - Status row under the title:
<div class="mt-2 d-flex gap-2 flex-wrap">with one or more<span class="badge ...">perguidelines/ui/badges.md. Multiple badges (type + lifecycle + country) are fine; wrap on small screens. - Action group:
<div class="d-flex gap-2">containing buttons.
Action buttons¶
| Button | Class | Icon | Use for |
|---|---|---|---|
| Modifier | btn-action-primary |
bi-pencil |
Edit (link to form) |
| Supprimer | btn-action-destructive |
bi-trash |
Delete (usually opens confirm modal) |
| Retour | btn-action-secondary |
bi-arrow-left |
Back to list / parent |
Order: secondary navigation actions on the left, then primary edit, then destructive at the right edge if present. The skeleton shows Modifier + Retour; real pages often add navigation links (e.g. "Voir le registre", "Fiche détenteur") before Modifier — that's fine, slot them between Retour and Modifier as btn-action-secondary.
Conditional actions (visible only for users with a feature flag or specific role) wrap each button in {% if 'feature' in user_features %}…{% endif %} — not the entire action group.
Cards grid¶
The two-column grid is the default. Use <div class="col-md-6 mb-4"> for half-width cards and <div class="col-12 mb-4"> for full-width cards. h-100 on the inner card balances the two halves of a row visually.
When to use full width¶
- The audit footer (always full-width, always last).
- A card that contains a table or chart that would be cramped at half-width.
- A card with > 8 fields — the half-width column gets visually heavy.
When the two-column grid breaks down¶
- Very narrow viewports (< md): cards stack naturally — the
col-md-6becomescol-12. - More than ~6 cards: consider tabs or a sticky in-page nav. Stack the categories instead of pouring 12 cards into one scrolling page.
- Heterogeneous content (one card has 3 fields, the next has 20): use
mb-4per card and accept staggered heights instead of forcingh-100parity.h-100is for visual symmetry within a row, not a hard rule.
Card headers¶
- Class:
card-header card-header-neutral. - Content:
<i class="bi bi-..."></i> {% trans "Title" %}— icon first, single space, translatable title. Optionally append a count badge:<span class="badge badge-status-neutral ms-2">{{ count }}</span>. - Hard rule restated: never put
<h5>,<h6>, or any heading element insidecard-header-neutral. Headers are visual chrome; promoting them to headings breaks the page's document outline and the styling.
Key/value display¶
The standard:
Conventions:
- Last field in a card uses
mb-0to avoid trailing whitespace inside the card-body. Everything before usesmb-2. {% trans "Label" %} :— note the space before:. French typography. Always wrap the label in{% trans %}even though it looks "obvious" — seeLANGUAGE_CODE='nl-be'rationale in CLAUDE.md.{{ value|default:"-" }}for optional fields. Use—(em dash) if you prefer; pick one and stick to it within an app.- Inline badges and codes are allowed:
<p class="mb-2"><strong>Code :</strong> <code>{{ obj.code }}</code></p>or with a status badge. Keep the<strong>Label :</strong>prefix consistent. - Never wrap a key/value pair in a
<div>with custom classes — the<p>is the layout primitive.
Layout tables vs. data tables¶
The "no <table> for key/value" rule is precise: it bans layout tables (two-column <tr><th>label</th><td>value</td></tr> rows). It does NOT ban data tables — multi-row, multi-column tables of comparable records (e.g. "Tarifs par acte" with code + amount + bounds). Data tables inside a detail-page card are explicitly fine; use the same table table-hover table-sm styling as list pages (see guidelines/ui/lists.md "When to break the rules" → "Embedded mini-lists").
How to tell which you have:
- Layout table = always 2 columns, label cell is text-only, one row per field. → Convert to <p>.
- Data table = N columns, headers describe a record's fields, body has N rows where N varies. → Keep as <table>.
Audit footer¶
The standard "created / modified" footer renders inside a full-width card at the bottom. The skeleton template has the exact markup. Conventions:
- Always the last card in the layout.
text-md-endon the right column so "Modifié le ..." aligns to the right edge onmd+. Stacks left-aligned on mobile.- Date format:
|date:"d/m/Y H:i"(French short). {% if obj.updated_by %}{% trans "par" %} {{ obj.updated_by.get_full_name }}{% endif %}— author is conditional; useget_full_namenotusername.
If your model uses TimeStampedModel + AuditModel from apps/core/, the created_at / updated_at / created_by / updated_by fields are guaranteed.
Anti-patterns¶
- ❌
<h5>or<h6>insidecard-header card-header-neutral. Use the icon + text pattern. (15 audit hits in 2026-04 — see substitution cheatsheet below.) - ❌ Two-column
<table>for key/value display. Use<p>paragraphs. - ❌ Inline status badges next to the
<h2>title — they go under it in a<div class="mt-2">. - ❌ Action buttons styled with raw Bootstrap classes (
btn-primary,btn-danger). Usebtn-action-primary/secondary/destructive. - ❌ Cards without
h-100in a half-width grid — creates jagged row heights when adjacent cards differ in content length. - ❌ Audit footer placed mid-page or split into two cards. Always full-width at the bottom.
- ❌ Hardcoded date format (
{{ obj.created_at }}) — use|date:"d/m/Y H:i"for consistency. - ❌
<p><b>...instead of<p><strong>...— semantic difference;<strong>is the standard. - ❌ Conditional rendering at the card level when only a field is conditional. Hide the field, not the whole card.
Substitution cheatsheet (for the 2026-04 audit)¶
The audit (roadmap/backlog/ui-design-system-mechanical-fixes-2026-04.md) found 15 violations of the <h5>/<h6>-in-card-header rule. Each is a substitution against the canonical pattern:
<!-- BEFORE -->
<div class="card-header card-header-neutral">
<h5><i class="bi bi-..."></i> Titre</h5>
</div>
<!-- AFTER -->
<div class="card-header card-header-neutral">
<i class="bi bi-..."></i> {% trans "Titre" %}
</div>
Files (per the audit):
- apps/imports/templates/imports/{patient,dentist,appointment}_import.html line 14
- apps/dataquality/templates/dataquality/scan_form.html line 11, scan_detail.html line 189
- Plus ~7 more across imports/, dataquality/, sync/
For the layout-table audit findings: the audit reported ~10 violations, but spot-checks (e.g. apps/annuaire/templates/annuaire/practitioner_detail.html:80, apps/nomos_cnam/templates/nomos_cnam/praticien_detail.html:89) show those are legitimate data tables (roles list, tarifs list), not layout tables. The actual layout-table-violation count is likely lower than 10 — recount during the fix pass and update the audit backlog.
Known deviations¶
- The 15
<h5>/<h6>-in-card-header violations listed in the substitution cheatsheet above. All have the same one-edit fix. - The layout-table count from the 2026-04 audit appears overstated (false positives on data tables). Verify per-file before fixing.
- Some detail pages render an
entity.countrybadge asbadge-status-neutral(seeentity_detail.html:29). Strictly speaking, country is a category not a status — should bebadge-type. Listed here as a candidate for a future challenge pass againstui/badges.md, not a hard violation.
When to break the rules¶
- Pages that are detail-like but mostly read-only summaries (e.g. annuaire
practitioner_detail.htmlshowing INPI/RPPS data) often have one large card with a data table inside, not the two-column key/value grid. The heading + audit footer conventions still apply. - Drilldown / report pages in
apps/finance/are detail-shaped (one entity, full-width view) but have charts and pivots, not key/value cards. Skip the cards grid; keep the heading + action button pattern. - Wizards and multi-step setup flows are not detail pages — they're forms with intermediate states. Use
guidelines/ui/forms.md, not this file.