diff --git a/CLAUDE.md b/CLAUDE.md
index 66ca7b8..dea8c7f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -199,6 +199,7 @@ ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
- **Composables** : `interface Deps { ... }` + `export function useXxx(deps: Deps)`
- **Communication composants** : Props + Events uniquement (pas de provide/inject)
- **API** : `useApi.ts` wraps fetch avec `credentials: 'include'` pour les cookies session
+- **⚠️ Préfixe `/api`** : `useApi()` **prepend déjà** `apiBaseUrl` (= `/api` par défaut, cf. `nuxt.config.ts`). Les appels doivent donc utiliser des chemins **sans** `/api` au début. Ex : `api.get('/custom-fields/names')` et **PAS** `api.get('/api/custom-fields/names')` (sinon 404 sur `/api/api/...`).
- **Content-Type** : `application/ld+json` pour POST/PUT, `application/merge-patch+json` pour PATCH
- **Auth** : `useProfileSession` + middleware global `profile.global.ts`
- **Permissions** : `usePermissions.ts` miroir de la hiérarchie backend côté client
diff --git a/config/services.yaml b/config/services.yaml
index d811e2d..1b1b920 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -69,3 +69,8 @@ when@test:
autowire: true
autoconfigure: true
public: true
+
+ App\Service\SkeletonStructureService:
+ autowire: true
+ autoconfigure: true
+ public: true
diff --git a/docs/superpowers/plans/2026-05-11-custom-field-name-autocomplete.md b/docs/superpowers/plans/2026-05-11-custom-field-name-autocomplete.md
new file mode 100644
index 0000000..3e64e66
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-11-custom-field-name-autocomplete.md
@@ -0,0 +1,926 @@
+# Custom Field Name Autocomplete — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Ajouter une autocomplétion sur les noms de champs personnalisés dans tous les éditeurs (machine + ModelType) pour permettre la réutilisation des noms existants tout en gardant la possibilité d'en créer de nouveaux.
+
+**Architecture:**
+- **Backend** : un endpoint utilitaire `GET /api/custom-fields/names` qui retourne la liste plate des noms distincts de la table `custom_fields`.
+- **Frontend** : extension de `SearchSelect.vue` avec un prop `creatable`, composable `useCustomFieldNameSuggestions` avec cache module-level, composant wrapper `CustomFieldNameInput.vue`, migration de 4 éditeurs.
+
+**Tech Stack:** Symfony 8 + API Platform + Doctrine DBAL, Nuxt 4 + Vue 3 Composition API + TypeScript, DaisyUI.
+
+**Référence spec:** `docs/superpowers/specs/2026-05-11-custom-field-name-autocomplete-design.md`
+
+---
+
+## Task 1: Backend — Controller `CustomFieldNamesController`
+
+**Files:**
+- Create: `src/Controller/CustomFieldNamesController.php`
+
+- [ ] **Step 1: Créer le controller**
+
+Créer `src/Controller/CustomFieldNamesController.php` avec le contenu :
+
+```php
+ ''
+ ORDER BY name ASC
+ SQL;
+
+ $names = $this->connection->fetchFirstColumn($sql);
+
+ return new JsonResponse($names);
+ }
+}
+```
+
+- [ ] **Step 2: Vérifier que la route est bien exposée**
+
+Exécuter :
+```bash
+docker exec -u www-data php-inventory-apache php bin/console debug:router | grep custom-fields/names
+```
+
+Attendu : une ligne contenant `GET /api/custom-fields/names` et `api_custom_fields_names`.
+
+- [ ] **Step 3: Tester manuellement le endpoint**
+
+```bash
+curl -s -b "$(curl -s -c - -X POST http://localhost:8081/api/session/profile \
+ -H 'Content-Type: application/json' \
+ -d '{"username":"admin","password":"admin"}' | grep PHPSESSID)" \
+ http://localhost:8081/api/custom-fields/names | head -c 500
+```
+
+> Si la session est galère à monter en curl, on peut tester via le navigateur après login (DevTools → fetch).
+
+Attendu : un tableau JSON `["Numéro de série", "Tension", ...]` (ou `[]` si la base de dev est vide).
+
+- [ ] **Step 4: Lancer php-cs-fixer**
+
+```bash
+make php-cs-fixer-allow-risky
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/Controller/CustomFieldNamesController.php
+git commit -m "feat(custom-fields) : ajoute endpoint GET /api/custom-fields/names
+
+Retourne la liste plate des noms de champs perso distincts (table
+custom_fields), pour alimenter une autocompletion cote frontend."
+```
+
+> ⚠️ Le pre-commit hook va lancer PHPUnit. Si des tests existants échouent (peu probable car on n'a touché à rien d'existant), résoudre le souci avant de continuer.
+
+---
+
+## Task 2: Backend — Test PHPUnit du endpoint
+
+**Files:**
+- Create: `tests/Api/Controller/CustomFieldNamesControllerTest.php`
+
+- [ ] **Step 1: Repérer un test existant à copier-coller pour le style**
+
+Lire `tests/Api/Controller/HealthCheckController*Test.php` ou un controller simple existant pour récupérer le pattern (auth helpers, `ApiTestCase`). Adapter selon ce qu'on trouve.
+
+```bash
+ls tests/Api/Controller/ | head
+```
+
+- [ ] **Step 2: Créer le test**
+
+Créer `tests/Api/Controller/CustomFieldNamesControllerTest.php` :
+
+```php
+createUnauthenticatedClient();
+ $client->request('GET', '/api/custom-fields/names');
+
+ self::assertResponseStatusCodeSame(401);
+ }
+
+ public function testReturnsEmptyArrayWhenNoCustomFields(): void
+ {
+ $client = $this->createViewerClient();
+ $client->request('GET', '/api/custom-fields/names');
+
+ self::assertResponseIsSuccessful();
+ $data = json_decode($client->getResponse()->getContent(), true);
+ self::assertIsArray($data);
+ }
+
+ public function testReturnsDistinctSortedNames(): void
+ {
+ // Crée 3 machines avec des CustomField : "Tension", "Numéro de série", "Tension" (doublon)
+ $machine1 = $this->createMachine();
+ $this->createCustomField(['name' => 'Tension', 'machine' => $machine1]);
+ $this->createCustomField(['name' => 'Numéro de série', 'machine' => $machine1]);
+
+ $machine2 = $this->createMachine();
+ $this->createCustomField(['name' => 'Tension', 'machine' => $machine2]); // doublon
+
+ $client = $this->createViewerClient();
+ $client->request('GET', '/api/custom-fields/names');
+
+ self::assertResponseIsSuccessful();
+ $data = json_decode($client->getResponse()->getContent(), true);
+
+ self::assertContains('Tension', $data);
+ self::assertContains('Numéro de série', $data);
+ // Pas de doublon
+ self::assertSame(count(array_unique($data)), count($data));
+ // Tri alpha
+ $sorted = $data;
+ sort($sorted, SORT_STRING);
+ self::assertSame($sorted, $data);
+ }
+}
+```
+
+> Si la factory `createCustomField` n'a pas la signature attendue (1er argument = array), regarder `tests/AbstractApiTestCase.php` pour adapter aux helpers réels du projet.
+
+- [ ] **Step 3: Vérifier que les helpers utilisés existent**
+
+```bash
+grep -n "createCustomField\|createMachine\|createViewerClient\|createUnauthenticatedClient" tests/AbstractApiTestCase.php | head
+```
+
+Si l'un des helpers manque ou a une autre signature, **adapter le test** plutôt que d'ajouter de nouveaux helpers.
+
+- [ ] **Step 4: Lancer le test ciblé**
+
+```bash
+make test FILES=tests/Api/Controller/CustomFieldNamesControllerTest.php
+```
+
+Attendu : 3 tests OK.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add tests/Api/Controller/CustomFieldNamesControllerTest.php
+git commit -m "test(custom-fields) : ajoute test PHPUnit pour endpoint /api/custom-fields/names"
+```
+
+---
+
+## Task 3: Frontend — Étendre `SearchSelect.vue` avec le prop `creatable`
+
+**Files:**
+- Modify: `frontend/app/components/common/SearchSelect.vue`
+
+- [ ] **Step 1: Ajouter le prop `creatable` au composant**
+
+Dans le bloc `defineProps` (lignes ~91-141), ajouter après le prop `serverSearch` :
+
+```js
+creatable: {
+ type: Boolean,
+ default: false
+}
+```
+
+- [ ] **Step 2: Modifier `handleInput` pour emit en mode creatable**
+
+Remplacer la fonction `handleInput` (lignes ~284-289) par :
+
+```js
+function handleInput () {
+ if (!openDropdown.value) {
+ openDropdown.value = true
+ }
+ if (props.creatable) {
+ emit('update:modelValue', searchTerm.value)
+ }
+ emit('search', searchTerm.value)
+}
+```
+
+- [ ] **Step 3: Modifier `closeDropdown` pour ne pas reset en mode creatable**
+
+Remplacer la fonction `closeDropdown` (lignes ~297-304) par :
+
+```js
+function closeDropdown () {
+ openDropdown.value = false
+ if (props.creatable) {
+ return // garde le texte tapé tel quel
+ }
+ if (searchTerm.value.trim() === '' && selectedOption.value) {
+ emit('update:modelValue', '')
+ } else if (selectedOption.value) {
+ searchTerm.value = resolveLabel(selectedOption.value)
+ }
+}
+```
+
+- [ ] **Step 4: Ajouter une computed `creatableSuggestion`**
+
+Dans le bloc `
+```
+
+> `SearchSelect` n'expose pas nativement un événement `@focus`. Vérifier dans la Task 3 si on l'a ajouté ou si on doit charger autrement.
+
+- [ ] **Step 2: Exposer `@focus` depuis `SearchSelect.vue`**
+
+Retourner sur `SearchSelect.vue` et vérifier si `@focus` est propagé. Si non, modifier le handler `handleFocus` (ligne ~267-272) pour également émettre :
+
+```js
+const emit = defineEmits(['update:modelValue', 'search', 'focus'])
+
+function handleFocus () {
+ openDropdown.value = true
+ if (searchTerm.value === '' && selectedOption.value) {
+ searchTerm.value = resolveLabel(selectedOption.value)
+ }
+ emit('focus')
+}
+```
+
+Ajouter `'focus'` à la liste des emits si pas déjà présent.
+
+- [ ] **Step 3: Vérifier le typecheck**
+
+```bash
+cd frontend && npx nuxi typecheck
+```
+
+- [ ] **Step 4: Vérifier l'auto-import Nuxt**
+
+Le composant étant dans `components/common/`, Nuxt devrait l'auto-importer. Vérifier après build :
+
+```bash
+cd frontend && npm run dev
+```
+
+Sans erreur de référence `CustomFieldNameInput is not defined` quand on l'utilisera dans les tâches suivantes.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add frontend/app/components/common/CustomFieldNameInput.vue frontend/app/components/common/SearchSelect.vue
+git commit -m "feat(custom-fields) : ajoute CustomFieldNameInput wrapper
+
+Encapsule SearchSelect en mode creatable, branche useCustomFieldName-
+Suggestions, charge la liste au focus. Permet de remplacer un simple
+ par
+dans les editeurs de champs perso."
+```
+
+---
+
+## Task 6: Frontend — Migrer `MachineCustomFieldDefEditor.vue`
+
+**Files:**
+- Modify: `frontend/app/components/machine/MachineCustomFieldDefEditor.vue`
+
+- [ ] **Step 1: Remplacer l'input du nom**
+
+Dans `frontend/app/components/machine/MachineCustomFieldDefEditor.vue`, localiser les lignes 36-41 :
+
+```vue
+
+```
+
+Remplacer par :
+
+```vue
+
+```
+
+- [ ] **Step 2: Vérifier le typecheck**
+
+```bash
+cd frontend && npx nuxi typecheck
+```
+
+- [ ] **Step 3: Test manuel rapide**
+
+Ouvrir une machine en édition, ajouter un champ perso, vérifier que l'input :
+1. Affiche un dropdown au focus avec les noms existants
+2. Filtre quand on tape
+3. Sélection d'une suggestion → input rempli
+4. Texte libre + blur → garde le texte tapé
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add frontend/app/components/machine/MachineCustomFieldDefEditor.vue
+git commit -m "feat(custom-fields) : autocomplete sur le nom dans MachineCustomFieldDefEditor"
+```
+
+---
+
+## Task 7: Frontend — Migrer `MachineCustomFieldsCard.vue`
+
+**Files:**
+- Modify: `frontend/app/components/machine/MachineCustomFieldsCard.vue`
+
+- [ ] **Step 1: Remplacer l'input du nom**
+
+Dans `frontend/app/components/machine/MachineCustomFieldsCard.vue`, localiser les lignes ~53-59 :
+
+```vue
+
+```
+
+⚠️ **Attention** : cet input utilise `:value` + `@blur` (pas `v-model`) parce qu'il déclenche une mise à jour seulement au blur (avec un appel API).
+
+Remplacer par :
+
+```vue
+ handleDefinitionUpdate(field, 'name', value)"
+/>
+```
+
+> Le `@update:model-value` se déclenchera à chaque changement (donc à chaque caractère tapé en mode creatable). Si ce comportement génère trop d'appels API, on peut wrapper avec un `debounce` côté `handleDefinitionUpdate`. Pour l'instant, on garde simple.
+
+- [ ] **Step 2: Vérifier le comportement de `handleDefinitionUpdate`**
+
+Vérifier que cette fonction est idempotente (rejouer un même nom = pas d'effet). Cherche la fonction dans le composant et confirme qu'elle compare l'ancienne/nouvelle valeur avant d'appeler l'API.
+
+```bash
+grep -n "handleDefinitionUpdate" frontend/app/components/machine/MachineCustomFieldsCard.vue
+```
+
+Si elle ne dédoublonne pas, l'ajout d'un test rapide `if (field.name === value) return` peut éviter des PATCH inutiles.
+
+- [ ] **Step 3: Vérifier le typecheck**
+
+```bash
+cd frontend && npx nuxi typecheck
+```
+
+- [ ] **Step 4: Test manuel**
+
+Sur la page d'une machine, modifier inline un nom de champ perso → vérifier que ça déclenche un PATCH unique (DevTools Network).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add frontend/app/components/machine/MachineCustomFieldsCard.vue
+git commit -m "feat(custom-fields) : autocomplete sur le nom dans MachineCustomFieldsCard"
+```
+
+---
+
+## Task 8: Frontend — Migrer `PieceModelStructureEditor.vue`
+
+**Files:**
+- Modify: `frontend/app/components/PieceModelStructureEditor.vue`
+
+- [ ] **Step 1: Remplacer l'input du nom**
+
+Dans `frontend/app/components/PieceModelStructureEditor.vue`, localiser les lignes 97-102 :
+
+```vue
+
+```
+
+Remplacer par :
+
+```vue
+
+```
+
+- [ ] **Step 2: Vérifier le typecheck**
+
+```bash
+cd frontend && npx nuxi typecheck
+```
+
+- [ ] **Step 3: Test manuel**
+
+Ouvrir un ModelType (catégorie composant ou skeleton), ajouter une pièce dans le skeleton, lui ajouter un champ perso → vérifier dropdown.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add frontend/app/components/PieceModelStructureEditor.vue
+git commit -m "feat(custom-fields) : autocomplete sur le nom dans PieceModelStructureEditor"
+```
+
+---
+
+## Task 9: Frontend — Migrer `StructureNodeEditor.vue`
+
+**Files:**
+- Modify: `frontend/app/components/StructureNodeEditor.vue`
+
+- [ ] **Step 1: Remplacer l'input du nom**
+
+Dans `frontend/app/components/StructureNodeEditor.vue`, localiser les lignes 106-111 :
+
+```vue
+
+```
+
+Remplacer par :
+
+```vue
+
+```
+
+- [ ] **Step 2: Vérifier le typecheck**
+
+```bash
+cd frontend && npx nuxi typecheck
+```
+
+- [ ] **Step 3: Test manuel**
+
+Ouvrir un ModelType de catégorie machine, naviguer dans la structure (composants/sous-composants), ajouter un champ perso à un node → vérifier dropdown.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add frontend/app/components/StructureNodeEditor.vue
+git commit -m "feat(custom-fields) : autocomplete sur le nom dans StructureNodeEditor"
+```
+
+---
+
+## Task 10: Frontend — Invalidation du cache après save
+
+**Files:**
+- Modify: `frontend/app/composables/useMachineCustomFieldDefs.ts`
+- Modify: `frontend/app/components/model-types/ModelTypeForm.vue`
+
+- [ ] **Step 1: Repérer le save dans `useMachineCustomFieldDefs.ts`**
+
+```bash
+grep -n "POST\|PATCH\|api(" frontend/app/composables/useMachineCustomFieldDefs.ts | head -20
+```
+
+Localiser la(es) fonction(s) qui sauvegarde les champs perso (probablement `saveDefinitions`, `addCustomFields`, etc.).
+
+- [ ] **Step 2: Ajouter l'invalidation après save**
+
+Dans `useMachineCustomFieldDefs.ts`, en haut du fichier, après les imports existants :
+
+```ts
+import { useCustomFieldNameSuggestions } from './useCustomFieldNameSuggestions'
+```
+
+Dans le corps du composable, ajouter (à placer près des autres `use*` calls) :
+
+```ts
+const { invalidate: invalidateCustomFieldNames } = useCustomFieldNameSuggestions({ api: useApi() })
+```
+
+Puis, après chaque save réussi (à la fin du `try` du POST/PATCH des definitions), appeler :
+
+```ts
+invalidateCustomFieldNames()
+```
+
+> Identifier précisément les points de save dans le fichier — probablement 1 ou 2 endroits maximum.
+
+- [ ] **Step 3: Repérer le save dans `ModelTypeForm.vue`**
+
+```bash
+grep -n "POST\|PATCH\|api(\|emit('saved')\|emit('save'" frontend/app/components/model-types/ModelTypeForm.vue | head -20
+```
+
+- [ ] **Step 4: Ajouter l'invalidation après save**
+
+Dans le `
+```
+
+**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)
diff --git a/frontend/app/components/PieceModelStructureEditor.vue b/frontend/app/components/PieceModelStructureEditor.vue
index 6d08a92..5509c73 100644
--- a/frontend/app/components/PieceModelStructureEditor.vue
+++ b/frontend/app/components/PieceModelStructureEditor.vue
@@ -94,12 +94,11 @@