diff --git a/docs/superpowers/specs/2026-05-11-custom-field-name-autocomplete-design.md b/docs/superpowers/specs/2026-05-11-custom-field-name-autocomplete-design.md
new file mode 100644
index 0000000..f78fbcf
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-11-custom-field-name-autocomplete-design.md
@@ -0,0 +1,288 @@
+# Custom Field Name Autocomplete — Design
+
+**Date** : 2026-05-11
+**Statut** : Design validé, prêt pour planification
+
+## Contexte et problème
+
+Aujourd'hui dans Inventory, on définit des "champs personnalisés" (custom fields) à plusieurs endroits :
+- Au niveau d'une machine (entité `CustomField` avec FK `machineId`)
+- Au niveau d'un ModelType (entité `CustomField` avec FK `typeComposantId` / `typePieceId` / `typeProductId`)
+- Au niveau des skeleton requirements d'un ModelType (JSON `customFields` dans `SkeletonPieceRequirement`, `SkeletonSubcomponentRequirement`, `SkeletonProductRequirement`)
+
+À chaque création/modification, l'utilisateur saisit librement un **nom** dans un `` texte. Conséquence : les mêmes concepts métier finissent écrits différemment (« Numéro de série », « N° série », « Num serie »), ce qui empêche toute uniformisation et complique les rapports/recherches.
+
+**Objectif** : proposer une autocomplétion sur le nom du champ qui suggère les noms déjà existants dans la base, tout en autorisant la création libre d'un nouveau nom.
+
+## Décisions clés
+
+| Question | Choix retenu |
+|----------|--------------|
+| Scope des suggestions | **Cross-entité** (machine + composant + pièce + produit confondus) — objectif d'uniformisation globale |
+| Comportement utilisateur | **Création libre** : si l'utilisateur tape un nom sans cliquer sur une suggestion, on garde son texte tel quel |
+| Suggestion du type | Non : la suggestion porte uniquement sur le nom |
+| Compteur d'usage | Non : on reste simple, juste les noms triés alpha |
+| Pattern UI | **Étendre `SearchSelect.vue`** existant avec un prop `creatable` plutôt que datalist natif ou nouveau composant — cohérence visuelle avec le reste de l'app |
+
+## Architecture
+
+```
+Backend Frontend
+───────── ─────────
+GET /api/custom-fields/names ◄── useCustomFieldNameSuggestions()
+ │ │ (cache module-level)
+ │ returns: ["Numéro...", ...] │
+ ▼ ▼
+Union SQL : CustomFieldNameInput.vue (wrapper)
+ custom_fields │
+ + skeleton_piece_requirements │ utilise
+ + skeleton_subcomponent_requirements│
+ + skeleton_product_requirements ▼
+ SearchSelect.vue (creatable=true)
+ ▲
+ │ utilisé par
+ │
+ ┌───────────────┼───────────────┐
+ │ │ │
+ MachineCustomFieldDefEditor│ StructureNodeEditor
+ │
+ PieceModelStructureEditor
+```
+
+## Backend
+
+### Nouveau endpoint : `GET /api/custom-fields/names`
+
+**Fichier** : `src/Controller/CustomFieldNamesController.php`
+
+**Sécurité** : `ROLE_VIEWER` (cohérent avec les autres GET sur `CustomField`).
+
+**Format de réponse** : tableau JSON plat de strings, trié alphabétique, dédupliqué (case-insensitive sur l'union).
+
+```json
+["Numéro de série", "Puissance", "Tension nominale"]
+```
+
+> Pas de wrapper `hydra:` — ce n'est pas une resource API Platform mais un endpoint utilitaire.
+
+### Implémentation SQL
+
+Le controller exécute une seule requête SQL brute via `Doctrine\DBAL\Connection` :
+
+```sql
+SELECT DISTINCT name FROM (
+ SELECT name FROM custom_fields
+ UNION
+ SELECT jsonb_array_elements(customfields)->>'name' AS name
+ FROM skeleton_piece_requirements
+ WHERE customfields IS NOT NULL
+ UNION
+ SELECT jsonb_array_elements(customfields)->>'name' AS name
+ FROM skeleton_subcomponent_requirements
+ WHERE customfields IS NOT NULL
+ UNION
+ SELECT jsonb_array_elements(customfields)->>'name' AS name
+ FROM skeleton_product_requirements
+ WHERE customfields IS NOT NULL
+) AS all_names
+WHERE name IS NOT NULL AND name <> ''
+ORDER BY name ASC
+```
+
+**À vérifier au moment de l'implémentation** :
+- Le nom exact de la colonne JSON `customfields` en lowercase dans PostgreSQL (Doctrine = camelCase, PG = lowercase).
+- Le nom exact des tables `skeleton_*_requirements` (à confirmer dans les migrations).
+- Le type de la colonne (JSON vs JSONB) — `jsonb_array_elements` ne fonctionne que sur JSONB ; pour JSON utiliser `json_array_elements`.
+
+### Pas de cache HTTP
+
+La liste change quand un utilisateur crée un nouveau champ perso. Le cache se fait côté frontend (cf. composable). Pas de header `Cache-Control` particulier.
+
+## Frontend
+
+### 1. Extension de `SearchSelect.vue`
+
+**Nouveau prop** :
+
+```js
+creatable: {
+ type: Boolean,
+ default: false // strict par défaut → zéro régression sur les 10+ usages actuels
+}
+```
+
+**Changements de comportement quand `creatable=true`** :
+
+| Aspect | Mode strict (défaut) | Mode `creatable` |
+|--------|---------------------|------------------|
+| `modelValue` | ID de l'option | **Texte libre** (le nom est la valeur) |
+| `handleInput` | emit `'search'` uniquement | emit aussi `'update:modelValue'` en temps réel |
+| `closeDropdown` (blur) | reset au label de l'option sélectionnée | **garde** le texte tapé |
+| Dropdown | liste filtrée | liste filtrée + une ligne **« Créer XYZ »** en bas si le texte tapé ne matche aucune option (icône `+`, texte plus discret) |
+| Clavier | ↑/↓/Enter sélectionne une option | ↑/↓ navigue, Enter valide soit l'option soit le « Créer XYZ » |
+
+**Garanti** : mode strict 100% inchangé → les 10+ usages actuels de `SearchSelect` ne sont pas affectés.
+
+### 2. Composable `useCustomFieldNameSuggestions`
+
+**Fichier** : `frontend/app/composables/useCustomFieldNameSuggestions.ts`
+
+```ts
+import { ref } from 'vue'
+
+const cache = ref(null)
+const loading = ref(false)
+
+interface Deps {
+ api: ReturnType
+}
+
+export function useCustomFieldNameSuggestions(deps: Deps) {
+ const { api } = deps
+
+ async function load(force = false) {
+ if (cache.value && !force) return cache.value
+ if (loading.value) return cache.value ?? []
+ loading.value = true
+ try {
+ cache.value = await api('/api/custom-fields/names')
+ return cache.value
+ } finally {
+ loading.value = false
+ }
+ }
+
+ function invalidate() {
+ cache.value = null
+ }
+
+ return {
+ suggestions: cache,
+ loading,
+ load,
+ invalidate,
+ }
+}
+```
+
+**Choix de design** :
+- **Cache module-level** (déclaré hors de la fonction) → partagé entre toutes les instances du composable, donc une seule requête HTTP pour toute l'app.
+- **Lazy load** au 1er focus → pas de surcoût au démarrage.
+- **Invalidation manuelle** via `invalidate()` → appelée après chaque save de champ perso pour rafraîchir.
+- **Pattern `Deps`** → cohérent avec la convention du projet (`interface Deps`, injection de `useApi`).
+
+### 3. Composant wrapper `CustomFieldNameInput.vue`
+
+**Fichier** : `frontend/app/components/common/CustomFieldNameInput.vue`
+
+```vue
+
+
+
+
+
+```
+
+**Pourquoi un wrapper** : encapsule le branchement (load, map, props `creatable`/`option-value`) → impossible de l'oublier dans un consommateur, et tous les paramètres restent uniformes par construction.
+
+### 4. Migration des éditeurs
+
+Dans chacun des fichiers ci-dessous, **remplacer le ``** par :
+
+```vue
+
+```
+
+Fichiers concernés :
+- `frontend/app/components/machine/MachineCustomFieldDefEditor.vue` (ligne ~36-41)
+- `frontend/app/components/machine/MachineCustomFieldsCard.vue` (ligne ~57)
+- `frontend/app/components/PieceModelStructureEditor.vue` (ligne ~97-102)
+- `frontend/app/components/StructureNodeEditor.vue` (ligne ~106-111)
+
+> Note : `CustomFieldNameInput` étant dans `components/common/`, il est auto-importé par Nuxt — pas besoin d'`import` dans les consommateurs.
+
+### 5. Invalidation du cache
+
+Après chaque save réussi de champs perso, appeler `invalidate()` pour que la prochaine ouverture du dropdown récupère les nouveaux noms.
+
+| Endroit | Quand |
+|---------|-------|
+| `useMachineCustomFieldDefs` (composable existant) | Après PATCH/POST réussi des custom fields machine |
+| `ModelTypeForm.vue` (save ModelType + skeleton requirements) | Après sauvegarde du ModelType |
+
+Pattern :
+```ts
+const { invalidate } = useCustomFieldNameSuggestions({ api: useApi() })
+
+async function save() {
+ await api(...) // sauvegarde existante
+ invalidate() // ← nouveau
+}
+```
+
+> On n'a pas besoin d'invalider lors d'une simple modification d'un nom existant (au pire la liste a une suggestion en trop, ce n'est pas un bug). On invalide à chaque save pour rester simple.
+
+## Comportement utilisateur
+
+### Cas 1 — Création d'un nouveau champ
+1. User clique « Ajouter un champ »
+2. Un input vide apparaît
+3. User clique dedans → dropdown s'ouvre avec tous les noms existants triés alpha
+4. User tape « num » → dropdown filtre sur `["Numéro de lot", "Numéro de série"]`
+5. User clique sur « Numéro de série » → l'input se remplit exactement avec « Numéro de série »
+6. **OU** user tape « num XYZ » et clique ailleurs → l'input garde « num XYZ », une ligne « Créer 'num XYZ' » lui suggère explicitement la création
+
+### Cas 2 — Modification d'un nom existant
+1. User voit un champ existant nommé « Numéro de série »
+2. User clique dans l'input → dropdown s'ouvre, suggestions filtrées sur « Numéro de série »
+3. User efface et tape « Tension » → dropdown filtre, il peut sélectionner ou retaper librement
+4. Pas de fusion automatique des données — chaque champ reste indépendant
+
+### Cas 3 — Plusieurs inputs visibles en même temps
+- Toutes les instances partagent le même cache (module-level) → une seule requête HTTP pour la session
+- Si user crée un champ « Nouveau nom » dans l'input A et passe à l'input B sans rafraîchir, « Nouveau nom » apparaîtra dans les suggestions de B dès que `invalidate()` a été appelé au save
+
+## Hors-scope
+
+- **Renommage en cascade** : si on change un nom partout (ex: « Num serie » → « Numéro de série » pour les unifier), pas de migration automatique des champs existants. C'est un travail manuel, ou un futur outil dédié.
+- **Compteur d'usage** : peut être ajouté plus tard sans changer l'API (format de réponse extensible).
+- **Suggestion du type** : on ne propose pas un type par défaut quand l'utilisateur sélectionne une suggestion. À évaluer si besoin émerge.
+- **Tests** : pas de tests Vue dans le projet actuellement → validation manuelle. Côté backend, un test PHPUnit du controller est recommandé (cf. plan d'implémentation).
+
+## Fichiers impactés (résumé)
+
+### Nouveaux fichiers
+- `src/Controller/CustomFieldNamesController.php`
+- `frontend/app/composables/useCustomFieldNameSuggestions.ts`
+- `frontend/app/components/common/CustomFieldNameInput.vue`
+
+### Fichiers modifiés
+- `frontend/app/components/common/SearchSelect.vue` (ajout prop `creatable`)
+- `frontend/app/components/machine/MachineCustomFieldDefEditor.vue` (remplacer input)
+- `frontend/app/components/machine/MachineCustomFieldsCard.vue` (remplacer input)
+- `frontend/app/components/PieceModelStructureEditor.vue` (remplacer input)
+- `frontend/app/components/StructureNodeEditor.vue` (remplacer input)
+- `frontend/app/composables/useMachineCustomFieldDefs.ts` (ajout `invalidate()` après save)
+- `frontend/app/components/model-types/ModelTypeForm.vue` (ajout `invalidate()` après save)