diff --git a/CLAUDE.md b/CLAUDE.md
index 231f398..717fd31 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -3146,6 +3146,73 @@ n'affirme rien — comparer avec `RangDesEnvies`, qui avait dû reconduire un or
**618 tests au vert** (506 au début de la série), aucun avertissement de compilation.
+## Les articles à la une se saisissent un par un — champ à séparateur retiré (2026-08-21)
+
+Défaut remonté en usage sur le champ « À la une (titres séparés par des points-virgules) » :
+« impossible de taper des espaces, des `;`, se comporte très bizarrement ».
+
+### ⚠️ Le séparateur d'un champ relié à chaque frappe est INTAPABLE — et ce n'est pas propre aux unes
+
+`AjoutNumeroRevue.ArticlesTexte` était une vue « une seule ligne » : son *getter* recomposait
+(`ListeSaisie.Joindre`) ce que son *setter* venait de découper (`Decouper`). Reliée en
+`@bind:event="oninput"`, elle **réécrivait la saisie à chaque caractère** :
+
+| Ce qu'on tape | Ce que la propriété rend | Ce qu'on voit |
+|---|---|---|
+| `Ukraine ;` | `;` ouvre une entrée **vide**, élaguée par `RemoveEmptyEntries` | le `;` **disparaît** |
+| `Ukraine ; ` | `TrimEntries` rogne l'espace de fin | l'espace **disparaît** |
+| `Ukraine ; L` | `Joindre` repose son propre `« ; »` | le curseur **saute** |
+
+Autrement dit : **le seul caractère qu'on ne pouvait pas taper dans ce champ était son propre
+séparateur**. Le lot O2 avait longuement tranché *quel* séparateur employer (le point-virgule,
+parce qu'un titre contient souvent une virgule) sans voir qu'aucun ne pouvait fonctionner par
+cette mécanique.
+
+⚠️ **Deux champs voisins ont exactement la même mécanique et donc le même défaut** :
+`EnregistrementLivre.ThemesTexte` et `EnregistrementLivre.Auteur`, tous deux reliés sur
+`oninput` à une propriété qui découpe puis recompose. Non corrigés — le retour d'usage ne
+portait que sur les unes — mais ce sont les mêmes symptômes, et `Auteur` porte en plus la
+reprise des rôles.
+
+### Ce que l'écran fait maintenant
+
+Un titre, `Entrée`, et il rejoint une liste où chaque ligne porte **Modifier** et **Supprimer**
+(demandé tel quel par l'utilisateur). `Modifier` **reprend le titre dans le champ**, là où on
+l'a écrit, et le bouton devient « Enregistrer » avec un « Annuler » à côté.
+
+- **La liste est la donnée** : plus de propriété texte intermédiaire, plus de découpage, donc
+ plus rien à ne pas pouvoir taper — ni `;`, ni virgule, ni deux-points.
+- **`Entrée` ne remonte pas à `OnEntree`** (qui enregistre le numéro entier) : sinon on ne
+ pourrait jamais saisir deux titres.
+- **Le doublon reste refusé au sens de la normalisation**, avec un message plutôt qu'en silence.
+- ⚠️ **Le composant se remet à zéro quand le parent change de `Saisie`** (numéro enregistré,
+ autre numéro ouvert en modification), en comparant la **référence** : sans cela, le titre resté
+ dans le champ appartiendrait au numéro d'avant.
+- ⚠️ `min-width: 0` sur le titre de la ligne, encore une fois : sans lui, un titre long pousse
+ « Supprimer » hors d'un écran de 320 px.
+
+`ArticlesTexte` a disparu, et avec lui le paramètre `virguleAussi` de `ListeSaisie` — il
+n'existait que pour ce champ. `ListeSaisie` ne sert plus qu'aux **thèmes**, où la virgule reste
+acceptée en repli.
+
+### Vérifié en exécution (navigateur, 375 puis 320 px)
+
+Frappe caractère par caractère de `Ukraine, deux ans après ; le retour du train` : **la valeur
+reste exactement ce qui a été tapé**, espaces et `;` compris. `Entrée` la range telle quelle,
+le champ se vide. Un doublon de casse est refusé avec son message ; Modifier reprend le titre,
+Enregistrer le remplace, Supprimer le retire. Numéro 44 créé puis relu par l'API avec ses deux
+titres **intacts**, un article ajouté à un numéro existant par l'écran d'édition, et la liste
+qui se recharge bien d'un numéro à l'autre. Aucun débordement horizontal à 320 px
+(`scrollWidth` = 320).
+
+⚠️ **Défaut voisin constaté au passage, NON corrigé** : le bouton « Ajouter ce numéro » vit dans
+`Revue.razor` alors que le champ « Numéro » vit dans `ChampsNumeroRevue`. Une frappe ne
+re-rendant que le composant qui l'a traitée, le bouton **reste désactivé** tant que le parent ne
+se redessine pas — seul `Entrée` (qui appelle `OnEntree`, donc le parent) enregistre le numéro.
+À traiter.
+
+617 tests au vert (618 avant : le test du découpage des unes n'a plus d'objet).
+
## La 7ᵉ série vérifiée en navigateur — trois défauts, tous invisibles aux tests (2026-08-21)
La série Q à Y s'était close sur un aveu : « **aucun** écran n'a été regardé s'afficher ». Le
diff --git a/MaBibli.Client/Composants/ChampsNumeroRevue.razor b/MaBibli.Client/Composants/ChampsNumeroRevue.razor
index f8a4071..e09f49e 100644
--- a/MaBibli.Client/Composants/ChampsNumeroRevue.razor
+++ b/MaBibli.Client/Composants/ChampsNumeroRevue.razor
@@ -26,16 +26,77 @@
@*
- ⚠️ Séparateur : le POINT-VIRGULE, et pas la virgule des thèmes de livres. Un titre d'article
- en contient souvent (« Ukraine, deux ans après »), et la virgule le couperait en deux.
+ ⚠️ Un titre à la fois, et surtout PAS une ligne de titres séparés par des points-virgules.
+ Le champ unique se reliait à chaque frappe sur une propriété qui découpait puis recomposait
+ la liste : la saisie était donc réécrite sous les doigts — un point-virgule tapé disparaissait
+ (il ouvrait une valeur vide, aussitôt élaguée), et l'espace qui le suit était rogné par le
+ même élagage. Autrement dit, le séparateur du champ était le seul caractère qu'on ne pouvait
+ pas y taper.
+
+ Ici la liste est la donnée : on écrit un titre, Entrée l'y range, et chaque titre se modifie
+ ou se retire ensuite. Une virgule (« Ukraine, deux ans après ») n'a plus rien à couper,
+ puisque plus rien ne découpe.
*@
-
+
+
+
+
+
+
+
+
+ @if (_indexModifie is not null)
+ {
+
+ }
+
+
+ @if (_messageArticle is not null)
+ {
+
@_messageArticle
+ }
+
+ @if (Saisie.Articles.Count > 0)
+ {
+
+ @for (var rang = 0; rang < Saisie.Articles.Count; rang++)
+ {
+ @* La copie locale est indispensable : sans elle, les fermetures des deux
+ boutons captureraient la variable de boucle, donc son état final. *@
+ var index = rang;
+ var titre = Saisie.Articles[rang];
+
+
+ @titre
+
+
+
+
+
+
+
+ }
+
+ }
+
@*
⚠️ Une URL, pas une photo. Aucune source ne donne la couverture d'un NUMÉRO : l'ISSN désigne
@@ -53,6 +114,106 @@
@code {
[Parameter, EditorRequired] public AjoutNumeroRevue Saisie { get; set; } = default!;
+ /// Identifiant du champ « à la une », pour que son libellé le désigne vraiment.
+ private readonly string _idChamp = $"une-{Guid.NewGuid():N}";
+
+ /// Titre en cours de frappe. Il n'est relié à rien d'autre : voir plus haut.
+ private string? _titreSaisi;
+
+ /// Rang du titre qu'on modifie — null = on en ajoute un nouveau.
+ private int? _indexModifie;
+
+ private string? _messageArticle;
+
+ /// Saisie servie au rendu précédent, pour repérer que le parent en a changé.
+ private AjoutNumeroRevue? _saisiePrecedente;
+
+ ///
+ /// Le parent remplace la saisie entière (numéro enregistré, autre numéro ouvert en
+ /// modification) : ce qui restait dans le champ appartenait au numéro d'avant.
+ ///
+ protected override void OnParametersSet()
+ {
+ if (!ReferenceEquals(_saisiePrecedente, Saisie))
+ {
+ _saisiePrecedente = Saisie;
+ RemettreAZero();
+ }
+ }
+
+ private void RemettreAZero()
+ {
+ _titreSaisi = null;
+ _indexModifie = null;
+ _messageArticle = null;
+ }
+
+ ///
+ /// Entrée range le titre, comme le bouton.
+ ///
+ ///
+ /// ⚠️ Cette touche ne remonte pas à OnEntree : celui-ci enregistre le numéro
+ /// entier, et l'on serait alors bien en peine de saisir deux titres à la une.
+ ///
+ private void SurToucheArticle(KeyboardEventArgs e)
+ {
+ if (e.Key == "Enter")
+ {
+ ValiderArticle();
+ }
+ }
+
+ private void ValiderArticle()
+ {
+ var titre = _titreSaisi?.Trim();
+
+ if (string.IsNullOrEmpty(titre))
+ {
+ return;
+ }
+
+ // Même règle de doublon que partout ailleurs : au sens de la normalisation, et le
+ // libellé déjà rangé est celui qui reste.
+ var cle = NormalisationTexte.Normaliser(titre);
+ var deja = Saisie.Articles.FindIndex(a => NormalisationTexte.Normaliser(a) == cle);
+
+ if (deja >= 0 && deja != _indexModifie)
+ {
+ _messageArticle = "Ce titre est déjà à la une de ce numéro.";
+ return;
+ }
+
+ if (_indexModifie is { } index)
+ {
+ Saisie.Articles[index] = titre;
+ }
+ else
+ {
+ Saisie.Articles.Add(titre);
+ }
+
+ RemettreAZero();
+ }
+
+ /// Reprend un titre dans le champ : on le corrige là où on l'a écrit.
+ private void ModifierArticle(int index)
+ {
+ _messageArticle = null;
+ _indexModifie = index;
+ _titreSaisi = Saisie.Articles[index];
+ }
+
+ private void AnnulerModification() => RemettreAZero();
+
+ private void SupprimerArticle(int index)
+ {
+ Saisie.Articles.RemoveAt(index);
+
+ // Le titre qu'on modifiait vient peut-être de disparaître, et les rangs suivants ont
+ // glissé : reprendre la modification écrirait sur le mauvais titre.
+ RemettreAZero();
+ }
+
///
/// Date affichée par le champ, en local.
///
diff --git a/MaBibli.Client/wwwroot/css/app.css b/MaBibli.Client/wwwroot/css/app.css
index 39e2cb3..0062fb3 100644
--- a/MaBibli.Client/wwwroot/css/app.css
+++ b/MaBibli.Client/wwwroot/css/app.css
@@ -1614,6 +1614,37 @@ body {
font-style: italic;
}
+/* Saisie des unes : un titre par ligne, son texte à gauche et ses deux actions à droite.
+ ⚠️ min-width: 0 sur le titre est ce qui fait tenir la ligne à 320 px — un titre d'article
+ est long, et sans lui il pousserait « Supprimer » hors de l'écran (même leçon que la ligne
+ de création du lot M1, et que l'arbre des séries). */
+.liste-unes {
+ list-style: none;
+ margin: 0.25rem 0 0;
+ padding: 0;
+}
+
+.ligne-une {
+ display: flex;
+ gap: 0.5rem;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.35rem 0;
+ border-bottom: 1px solid var(--mb-bord);
+}
+
+.ligne-une-titre {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow-wrap: anywhere;
+ font-style: italic;
+}
+
+.ligne-une .actions-tome {
+ flex: 0 0 auto;
+ margin-top: 0;
+}
+
/* --- Créer depuis une liste : champ + bouton sur une ligne (lot M1) ----
⚠️ min-width: 0 sur le champ est ce qui fait tenir la ligne à 320 px : sans
lui, un élément de formulaire refuse de rétrécir sous sa largeur intrinsèque
diff --git a/MaBibli.Shared/Dtos/EnregistrementLivre.cs b/MaBibli.Shared/Dtos/EnregistrementLivre.cs
index 8f88583..667a042 100644
--- a/MaBibli.Shared/Dtos/EnregistrementLivre.cs
+++ b/MaBibli.Shared/Dtos/EnregistrementLivre.cs
@@ -125,7 +125,7 @@ public record EnregistrementLivre
/// n'en contenant jamais.
///
public static List DecouperThemes(string? saisie) =>
- ListeSaisie.Decouper(saisie, virguleAussi: true);
+ ListeSaisie.Decouper(saisie);
}
/// Un auteur tel qu'il est saisi : son nom, et son rôle sur ce livre.
diff --git a/MaBibli.Shared/Dtos/RevueDto.cs b/MaBibli.Shared/Dtos/RevueDto.cs
index 8539b5e..4ee8a77 100644
--- a/MaBibli.Shared/Dtos/RevueDto.cs
+++ b/MaBibli.Shared/Dtos/RevueDto.cs
@@ -1,6 +1,3 @@
-using System.Text.Json.Serialization;
-using MaBibli.Shared.Textes;
-
namespace MaBibli.Shared.Dtos;
///
@@ -71,21 +68,15 @@ public record AjoutNumeroRevue
///
public string? CoverUrl { get; set; }
- /// Articles à la une, dans l'ordre de la couverture.
- public List Articles { get; set; } = [];
-
///
- /// Vue « une seule ligne » des , pour un champ de saisie simple.
+ /// Articles à la une, dans l'ordre de la couverture.
///
///
- /// ⚠️ Séparateur : le point-virgule seul, contrairement aux thèmes d'un livre. Un
- /// titre d'article contient souvent une virgule (« Ukraine, deux ans après »), qui le
- /// couperait en deux.
+ /// ⚠️ La liste est la donnée, et il n'y a plus de vue « une seule ligne ». Elle a
+ /// existé — un champ texte à points-virgules, relié à chaque frappe sur une propriété qui
+ /// découpait puis recomposait — et c'était un champ dans lequel on ne pouvait taper ni le
+ /// séparateur (aussitôt élagué comme entrée vide) ni l'espace qui le suit. Un titre s'ajoute
+ /// désormais un par un.
///
- [JsonIgnore]
- public string? ArticlesTexte
- {
- get => Articles.Count == 0 ? null : ListeSaisie.Joindre(Articles);
- set => Articles = ListeSaisie.Decouper(value, virguleAussi: false);
- }
+ public List Articles { get; set; } = [];
}
diff --git a/MaBibli.Shared/Textes/ListeSaisie.cs b/MaBibli.Shared/Textes/ListeSaisie.cs
index 6adfd43..d861018 100644
--- a/MaBibli.Shared/Textes/ListeSaisie.cs
+++ b/MaBibli.Shared/Textes/ListeSaisie.cs
@@ -1,18 +1,19 @@
namespace MaBibli.Shared.Textes;
///
-/// Découpage d'un champ texte qui porte plusieurs valeurs : thèmes d'un livre, articles
-/// à la une d'un numéro de revue.
+/// Découpage du champ texte qui porte plusieurs thèmes d'un livre.
///
///
/// Le séparateur est le point-virgule, comme celui des auteurs
-/// (EnregistrementLivre.SeparateurAuteurs) : trois champs voisins du même formulaire ne
-/// doivent pas se saisir de trois façons.
+/// (EnregistrementLivre.SeparateurAuteurs) : deux champs voisins du même formulaire ne
+/// doivent pas se saisir de deux façons. La virgule reste acceptée en repli — elle était le
+/// séparateur jusqu'au 2026-08-21, et un thème (« dark fantasy ») n'en contient jamais.
///
-/// ⚠️ La virgule n'est acceptée que là où elle ne peut rien couper de travers. Un thème
-/// (« dark fantasy ») n'en contient jamais, et l'accepter évite de désapprendre une habitude ;
-/// un titre d'article en contient souvent (« Ukraine, deux ans après »), et la virgule y
-/// couperait le titre en deux. D'où le paramètre plutôt qu'une règle unique.
+/// ⚠️ Ce découpage ne convient pas à tout champ à valeurs multiples. Les articles à la
+/// une d'un numéro y sont passés jusqu'au 2026-08-21 et en sont sortis : un titre contient
+/// souvent une virgule, et surtout un champ relié à chaque frappe sur une propriété qui découpe
+/// puis recompose réécrit la saisie sous les doigts — le séparateur lui-même devient
+/// intapable. Ils se saisissent maintenant un par un.
///
///
public static class ListeSaisie
@@ -28,14 +29,14 @@ public static class ListeSaisie
/// normalisation — « Fantasy » et « fantasy » sont la même étiquette, et le premier
/// libellé tapé est celui qui reste.
///
- public static List Decouper(string? saisie, bool virguleAussi)
+ public static List Decouper(string? saisie)
{
if (string.IsNullOrWhiteSpace(saisie))
{
return [];
}
- char[] separateurs = virguleAussi ? [Separateur, ','] : [Separateur];
+ char[] separateurs = [Separateur, ','];
var vus = new HashSet(StringComparer.Ordinal);
return saisie
diff --git a/MaBibli.Tests/ListeSaisieTests.cs b/MaBibli.Tests/ListeSaisieTests.cs
index 04b35da..5ddc4c4 100644
--- a/MaBibli.Tests/ListeSaisieTests.cs
+++ b/MaBibli.Tests/ListeSaisieTests.cs
@@ -4,24 +4,16 @@ using MaBibli.Shared.Textes;
namespace MaBibli.Tests;
///
-/// Découpage des champs qui portent plusieurs valeurs : thèmes d'un livre, articles à la une
-/// d'un numéro (lot O2).
+/// Découpage du champ des thèmes d'un livre.
///
+///
+/// ⚠️ Les articles à la une d'un numéro passaient ici jusqu'au 2026-08-21. Ils en sont sortis :
+/// un champ relié à chaque frappe sur une propriété qui découpe puis recompose réécrit la saisie
+/// sous les doigts, et l'on ne pouvait y taper ni le point-virgule ni l'espace qui le suit. Ils
+/// se saisissent un par un, et n'ont donc plus de découpage à éprouver.
+///
public class ListeSaisieTests
{
- ///
- /// ⚠️ Le cas qui a tranché le séparateur : un titre d'article contient souvent une virgule,
- /// et elle le couperait en deux.
- ///
- [Fact]
- public void Un_titre_darticle_garde_ses_virgules()
- {
- var articles = ListeSaisie.Decouper(
- "Ukraine, deux ans après ; Le retour du train de nuit", virguleAussi: false);
-
- Assert.Equal(["Ukraine, deux ans après", "Le retour du train de nuit"], articles);
- }
-
///
/// Les thèmes acceptent la virgule en repli : elle était le séparateur jusqu'au 2026-08-21,
/// et un thème n'en contient jamais.
@@ -44,16 +36,16 @@ public class ListeSaisieTests
[Fact]
public void Les_entrees_vides_disparaissent()
{
- Assert.Empty(ListeSaisie.Decouper(" ; ; ", virguleAussi: false));
- Assert.Empty(ListeSaisie.Decouper(null, virguleAussi: true));
+ Assert.Empty(ListeSaisie.Decouper(" ; ; "));
+ Assert.Empty(ListeSaisie.Decouper(null));
}
/// La ligne recomposée se redécoupe à l'identique : c'est ce que fait le formulaire.
[Fact]
public void Joindre_puis_decouper_ne_perd_rien()
{
- List articles = ["Ukraine, deux ans après", "Le retour du train de nuit"];
+ List themes = ["dark fantasy", "space opera"];
- Assert.Equal(articles, ListeSaisie.Decouper(ListeSaisie.Joindre(articles), virguleAussi: false));
+ Assert.Equal(themes, ListeSaisie.Decouper(ListeSaisie.Joindre(themes)));
}
}