Microcopy¶
Status: Implemented Last reviewed: 2026-05-01 Sources of truth:
scripts/audit_i18n_source.py(curated EN→FR dictionary — the binding canonical translations);templates/skeletons/skeleton_{form,list,detail}.html(reference button verbs, empty states, audit-row copy);templates/components/messages.html(banner / toast surface — receives the strings written here);apps/imports/views.py(canonical module-level message constants);apps/register/templates/register/assembly_confirm_delete.html(canonical destructive-confirmation copy);apps/sync/views.py:bulk_sync_practices(canonical bulk summary copy).
Scope¶
Tone and word choice for every short string the user reads: button verbs, link labels, table column headers, form labels and help text, empty-state copy, error messages, success / warning / info messages, confirmation-dialog copy, page titles, breadcrumbs, tooltip titles, placeholder text. The goal is that a contributor (or Claude) writes the exact word without grepping four pages for precedent.
This file is the content layer that sits on top of guidelines/i18n/translation-rules.md. The mechanics — which function (_() vs gettext_lazy vs {% trans %}), the nl-be trick, the _(f"…") antipattern, the percent-format placeholder convention — live there. This file only picks the words.
Out of scope (cross-refs)¶
- i18n mechanics —
guidelines/i18n/translation-rules.md. Wrapping, lazy vs eager, placeholder syntax, the.poworkflow. - Status badge palette (which colour for Actif / Inactif / En attente) —
guidelines/ui/badges.md. Microcopy decides the word; badges decide the colour. - Channel choice (toast vs banner vs
hx-confirmvs_confirm_delete.htmlvs modal) —guidelines/ux/feedback-and-confirmation.md. Microcopy fills the channels; that file picks them. - Form layout, validation rendering, char-counter wiring —
guidelines/ui/forms.md. This file gives the text of the validation message; that file gives the placement. - List table structure, action column, two empty states —
guidelines/ui/lists.md. This file gives the empty-state sentence; that file gives the layout.
Hard rules¶
- Button verbs are imperatives drawn from the canonical glossary. Use Enregistrer / Annuler / Supprimer / Modifier / Ajouter / Retour / Voir / Rechercher / Filtrer / Synchroniser / Importer / Exporter / Téléverser / Envoyer / Approuver / Rejeter / Confirmer. Do not introduce synonyms (no Sauvegarder, Abandonner, Effacer, Éditer, Charger, Soumettre). The full glossary is in Canonical glossary below — it is the binding list.
- Address the user as
vous. Sentence case for everything except proper nouns and brand names. Headings, button labels, column headers, choice labels: only the first word and proper nouns are capitalised.Tableau de bord, notTableau De Bord.Ajouter un cabinet, notAjouter Un Cabinet. Brand / module names that are proper nouns stay capitalised:Doctolib,Pennylane,Logosw,Helios,CCAM,RPPS,Sites Web(the module's UI name). - Error messages name the field and the constraint, never blame the user. Right:
Le SIRET doit comporter 14 chiffres.Wrong:Vous avez mal saisi le SIRET.Imperative tone is fine for the fix (Corriger le format du fichier.); accusatory tone (Vous avez oublié X) is not. - Empty-state copy is two parts: the absence and the next step.
Aucun X. Modifier les filtres ou ajouter un X.— never justAucun résultat.on a page where the user can act. The "filtered" empty state and the "no records yet" empty state read differently and live inguidelines/ui/lists.md; the words for both follow this rule. - Destructive confirmations name the verb and the target, and explain the consequence. Right:
Supprimer l'assemblée du 10/05/2026 ?followed by an irreversibility alert. Wrong:Êtes-vous sûr ?alone, or with no consequence statement.hx-confirm(one short sentence ending in?) is the only place a bare confirm-style line is acceptable, and even there it must name the target type (Supprimer cet horaire ?, notSupprimer ?). Full-page confirm pages always carry the consequence — seeguidelines/ux/feedback-and-confirmation.md.
Canonical glossary¶
Bind these EN→FR pairs in code review. The same dictionary lives in scripts/audit_i18n_source.py:load_curated_dictionary and is the audit's "definitely English" lookup — keeping the two in sync means a drift here is caught on the next audit_i18n_source run.
| Action / concept | Canonical FR | Do not use |
|---|---|---|
| Save | Enregistrer | Sauvegarder, Soumettre |
| Cancel | Annuler | Abandonner, Quitter |
| Delete | Supprimer | Effacer, Détruire, Retirer (unless un-link, see below) |
| Edit | Modifier | Éditer, Changer (unless mass-action), Mettre à jour (rare) |
| Add (new record) | Ajouter | Créer (use only when the verb is "create from scratch"), Nouveau (adjective only) |
| Submit (form) | Envoyer | Soumettre, Valider (reserve for approval flows) |
| Login / Logout | Connexion / Déconnexion | Se connecter / Se déconnecter as bare buttons |
| Search | Rechercher | Chercher (verb only), Recherche (noun only) |
| Filter | Filtrer | Trier (= sort), Affiner |
| Sort | Trier | Ordonner |
| Export | Exporter | Télécharger (= download a file the server already has — keep separate) |
| Import | Importer | Charger, Téléverser (= upload one file, see below) |
| Upload | Téléverser | Importer (importer = bulk-load into the model; téléverser = put a file on the server) |
| Sync | Synchroniser | Mettre à jour |
| Approve / Reject | Approuver / Rejeter | Valider / Refuser (reserve Valider for finalising a state machine, Refuser for hard refusals) |
| Confirm | Confirmer | Valider |
| Back | Retour | Revenir, Retour à la liste (only when the target page is genuinely the list — be specific) |
| View / Open | Voir (table action) / Ouvrir (modal trigger) | Consulter (formal — only in tooltips) |
| Active / Inactive | Actif / Inactif | Activé / Désactivé (those are event states, not steady states) |
| Pending | En attente | À traiter (use for queues), En cours (= currently running) |
| Completed / Failed / Skipped | Terminé / Échoué / Ignoré | — |
| Draft / Published | Brouillon / Publié | — |
| Settings | Paramètres | Configuration (reserve for technical config), Réglages |
| Dashboard | Tableau de bord | Tableau, Accueil (Accueil = home page) |
| Profile | Profil | Compte (only for the account-management page itself) |
When introducing a new verb that's not in the table, check the .po catalogue first (grep -i 'msgid "Sauvegard' locale/fr/LC_MESSAGES/django.po etc.) and reuse the existing canonical form. If the action is genuinely new, add it to both the table above and scripts/audit_i18n_source.py.
Tone¶
- Vouvoiement, never tutoiement.
Veuillez vous connecter, notConnecte-toi. Even internal tools and admin pages stay vous-form — the project has no separate "internal-team-only" tone. - Statements over questions, except in confirmations.
Le fichier est trop volumineux (max 150 MB).is a statement, notAvez-vous un fichier trop volumineux ?. The only place questions are appropriate is destructive confirmation (Supprimer cette page ?). Veuillez …is acceptable for client-side validation messages and email notifications because the system is asking the user to do something. Avoid it elsewhere — for in-app status copy, the imperative or descriptive form is shorter (Sélectionner un cabinetvsVeuillez sélectionner un cabinet).- No exclamation marks. Even success messages are flat:
Patient enregistré., notPatient enregistré !. The one exception is the canonical async-launch toastImport lancé avec succès ! Vous pouvez suivre sa progression…— a documented historical exception, kept because the string is shared by four import views viaIMPORT_STARTED_MSG. - No emoji in user-facing copy. Bootstrap Icons (
<i class="bi bi-…">) carry the visual signal; the text stays plain.
Sentence shapes by surface¶
Buttons and link labels¶
One imperative verb, optionally + a short noun phrase. No trailing period.
{% trans "Enregistrer" %} {# preferred — single verb #}
{% trans "Ajouter un cabinet" %} {# verb + scoped target #}
{% trans "Voir tout" %} {# canonical for "see all" links #}
{% trans "Retour" %} / {% trans "Retour à la liste" %}
Never {% trans "Cliquer pour ajouter" %} (redundant — buttons are always clicked) or {% trans "Page d'ajout" %} on a button (link label, not a button).
Headings¶
Sentence case, no trailing punctuation, French verb-headed where the page is action-oriented (Supprimer un patient, Ajouter un dentiste); noun-headed where the page is informational (Patients, Cabinets, Tableau de bord).
Column headers (list pages)¶
Noun phrases, sentence case, no trailing punctuation, no verb form. Pick one grammatical form per list and stick to it. Date d'envoi, not Envoyé le. Nom du dentiste, not Le dentiste s'appelle. The {% table_header %} tag (per guidelines/ui/search-sort-filter.md) wraps the label — the string still follows this rule.
Form labels and help text¶
Labels are noun phrases ending in nothing (no colon — colons live in detail-page key/value <p>s, not in form labels). Help text is one sentence, ends in a period, written as a description not as instructions:
Not Veuillez entrer votre SIRET en 14 chiffres sans espace. (instructional + accusatory).
Placeholders¶
Rechercher… (or Rechercher une page…) is the canonical search placeholder — three trailing dots, French ellipsis style. Other placeholders should not duplicate the label (label = Email, placeholder ≠ Votre email — leave the placeholder empty or use exemple@cabinet.fr).
Status / choice labels¶
The canonical four-state status palette is Actif / Inactif / En attente / (neutral). Choice labels are sentence case (En attente, not EN ATTENTE) and the displayed string is _()-wrapped while the code identifier stays English (STATUS_PENDING = 'pending').
Error-message tone¶
The pattern: what's wrong, then how to fix. Field name + constraint → optional one-clause fix.
raise forms.ValidationError(_("Le SIRET doit comporter 14 chiffres."))
raise forms.ValidationError(_("Le fichier doit être au format texte (.txt)."))
raise forms.ValidationError(_("Le fichier est trop volumineux (max 150 MB)."))
raise forms.ValidationError(_("Le format doit être YYYY-MM (ex. 2025-10)."))
messages.error(request, _("Action inconnue : %(action)s") % {"action": action})
messages.error(request, _("Erreur lors du lancement de l'import : %(error)s") % {"error": str(e)})
Patterns to avoid:
Vous avez oublié X→Le champ X est obligatoire.Erreur ! Quelque chose ne va pas.→ name the failing operation and the cause.Internal error / Server error / 500→Une erreur est survenue lors de %(action)s. Réessayer dans quelques minutes.(only when the actual cause is genuinely unknown — otherwise be specific).- Bare exception strings (
messages.error(request, str(exc))) — wrap in a French preamble:_("Erreur : %(detail)s") % {"detail": str(exc)}.
Success-message tone¶
Past participle, French, with the subject named, no exclamation, no emoji.
messages.success(request, _("Patient enregistré."))
messages.success(request, _("Statut mis à jour."))
messages.success(request, _("Synchronisation réussie pour %(name)s.") % {"name": practice.display_name})
messages.success(request, _("Documents régénérés."))
Avoid the noun form (Enregistrement réussi.) — past participle of the verb is shorter and matches the button label (Enregistrer → Enregistré).
Empty-state copy¶
Two lines. Line 1 names the absence; line 2 suggests the next step. Per guidelines/ui/lists.md, list pages have two empty states — "no records at all" and "no records for the current filters" — and the copy differs:
{# No records at all #}
<i class="bi bi-people text-muted" style="font-size: 4rem;"></i>
<p class="text-muted mt-3">{% trans "Aucun patient." %}</p>
<a href="{% url 'patients:create' %}" class="btn btn-action-primary mt-2">
<i class="bi bi-plus-circle"></i> {% trans "Ajouter un patient" %}
</a>
{# No records for the current filters #}
<i class="bi bi-search text-muted" style="font-size: 4rem;"></i>
<p class="text-muted mt-3">{% trans "Aucun patient pour ces filtres." %}</p>
<button class="btn btn-action-secondary mt-2" hx-…>
{% trans "Réinitialiser les filtres" %}
</button>
The bare {% trans "Aucun résultat." %} is acceptable only inside a <tbody> row when the page already shows the active filters above the table — otherwise expand to Aucun X. (named target).
Confirmation copy¶
hx-confirm— one French sentence ending in?, naming the verb and the target type.Supprimer cet horaire ?,Supprimer ce bloc ?. Don't interpolate the record's name (the dialog is a yes/no, not an audit log)._confirm_delete.html— headingSupprimer un X(verb + indefinite article), details card showing the record's identifying fields, irreversibility<div class="alert alert-warning">explaining downstream consequences, primary buttonSupprimer définitivement(red), secondaryAnnuler.- Two-step edit confirmation (Alpine
confirmingflag, seeguidelines/ui/forms.md) — the modal-style copy isConfirmer la modification/Confirmer l'enregistrementpaired with a one-sentence consequence.
Plurals¶
Use {% blocktrans %} with count for any sentence whose grammar changes with the count:
{% blocktrans count counter=count %}
{{ counter }} cabinet sélectionné.
{% plural %}
{{ counter }} cabinets sélectionnés.
{% endblocktrans %}
The (s) shortcut (%(count)s cabinet(s)) is acceptable for terse status lines and bulk-summary banners (the canonical bulk summary in apps/sync/views.py:bulk_sync_practices uses it: Synchronisation : %(success)s réussi(s), %(error)s erreur(s).). Don't use (s) in body copy or empty-state lines — those get the blocktrans form.
Punctuation¶
- French keeps a non-breaking space before
:,;,?,!. The codebase does not automate this — write it manually with a literal NBSP (U+00A0) when the rendering matters (legal documents, exports), and accept the regular space everywhere else. The .po extractor preserves whatever you typed. - Detail-page key/value rows use
<strong>Label :</strong> valuewith a regular space + colon — that's the canonical render perguidelines/ui/detail-pages.md. Don't try to NBSP-pad it. - Ellipsis:
…(U+2026) for placeholders ending in ellipsis (Rechercher…); three ASCII dots...are tolerated in legacy strings but new code uses….
Anti-patterns¶
- ❌
Are you sure?/Êtes-vous sûr ?alone — see Hard rule 5. - ❌ Synonym drift —
Sauvegarder,Abandonner,Effacer,Éditerinstead of the glossary verbs. - ❌ Title Case in headings or buttons —
Tableau De Bord,Ajouter Un Cabinet. Sentence case only. - ❌ Tutoiement —
Connecte-toi,Tu n'as pas accès. Vouvoiement only. - ❌ Exclamation marks in success / status copy —
Patient enregistré !should bePatient enregistré.. - ❌ Emoji in
_()strings — even when the surrounding code shows status. Use Bootstrap Icons via<i class="bi …">outside the trans block. - ❌
_(f"…")— handled by the conformance hook (Rule 5, block-severity), but worth re-stating: f-strings inside_()never resolve. Seeguidelines/i18n/translation-rules.md. - ❌ Concatenating translatable fragments (
_("Utilisateur") + " " + name + " " + _("créé")) — French translators can't reorder. Single placeholder string instead. - ❌ Bare exception text in
messages.error— wrap with a French preamble. - ❌
Aucun résultat.as the only empty-state copy on a page with actionable filters or an "add new" path.
Known deviations¶
- Skeleton templates ship with accent-stripped French strings (
Aucun resultat,Aucun element,Coordonnees,Cree le,Modifie le,caracteres,Texte explicatif). Fixed in this graduation — the skeletons are the starting point developers copy, so accent drift here propagates to every new page. See commit message accompanying this guideline. apps/finance_workflow/,apps/websites/,apps/register/admin.py,apps/crm/,apps/core/models.py(ContractDocument file fields) — pockets of English source survive the 2026-04 French-source convergence.scripts/audit_i18n_source.py"suspected-english" bucket is the rolling backlog (perguidelines/i18n/translation-rules.md"Known deviations"). When picked up, apply the glossary above.Êtes-vous sûr de vouloir supprimer …appears in four_confirm_delete.htmltemplates (patients,dentists,accounts,sync/practitioner_review.html). It violates Hard rule 5 (no consequence stated) and uses a question form where the canonical pattern is verb-headed (Supprimer un X). Tracked alongside the existingux-confirm-delete-template-convergencebacklog item — converge the copy in the same sweep.Synchroniseras both button verb and tooltip is fine — but the tooltip should match the button label exactly when the action is the same. Audit pass material; not a per-PR concern.
When to break the rules¶
- Brand and proper-noun strings stay capitalised even mid-sentence.
Synchronisation Doctolib,Sync Pennylane,Sites Web(the module name in the navbar — kept as-is for brand consistency with the Helios platform). - Module-specific verbs allowed when the domain term has no idiomatic French equivalent.
Booking,Closing,Funnelsurvive in CRM/finance UI as accepted French jargon; don't translate toRéservation/Clôture/Tunnelif the team uses the English term in the actual workflow. - Bulk-summary
(s)plurals stay despite the cleanerblocktransform — they're terser and the bulk message is a status line, not body copy. Documented in Plurals. - Email-notification copy is allowed to be more formal (
Veuillez approuver ou rejeter ce mouvement dans Aletheia.) than in-app copy. Email is a standalone surface; the user has had to leave the app to read it, so the in-app brevity rules don't apply. - Legal / regulatory strings (assembly minutes, share-register exports, contract documents) follow French legal-writing conventions, including the NBSP before
:;?!, even where the rest of the codebase doesn't bother.