Procházet zdrojové kódy

Search settings by name from the palette and the settings sidebar (#474)

Bernt Christian Egeland před 4 dny
rodič
revize
c6c46f5a85
87 změnil soubory, kde provedl 5831 přidání a 755 odebrání
  1. 34 2
      messages/de/search.json
  2. 34 2
      messages/en/search.json
  3. 34 2
      messages/es/search.json
  4. 34 2
      messages/fr/search.json
  5. 34 2
      messages/it/search.json
  6. 34 2
      messages/lt/search.json
  7. 34 2
      messages/nb/search.json
  8. 34 2
      messages/nl/search.json
  9. 34 2
      messages/pl/search.json
  10. 34 2
      messages/pt-BR/search.json
  11. 34 2
      messages/pt-PT/search.json
  12. 34 2
      messages/ru/search.json
  13. 34 2
      messages/tr/search.json
  14. 160 0
      src/__tests__/features/search/matcher.test.ts
  15. 107 0
      src/__tests__/features/search/settings-items.test.ts
  16. 190 0
      src/__tests__/features/settings/search-index.test.ts
  17. 24 2
      src/app/(authenticated)/layout.tsx
  18. 8 3
      src/app/(authenticated)/settings/about/page.tsx
  19. 16 3
      src/app/(authenticated)/settings/account/account-settings.tsx
  20. 1 0
      src/app/(authenticated)/settings/account/signed-in-devices.tsx
  21. 14 2
      src/app/(authenticated)/settings/alerts/alerts-settings.tsx
  22. 2 0
      src/app/(authenticated)/settings/appearance/appearance-settings.tsx
  23. 13 3
      src/app/(authenticated)/settings/company/company-settings.tsx
  24. 19 6
      src/app/(authenticated)/settings/data/data-settings.tsx
  25. 23 12
      src/app/(authenticated)/settings/inspection-reminders/inspection-reminder-settings.tsx
  26. 2 2
      src/app/(authenticated)/settings/install/install-client.tsx
  27. 11 2
      src/app/(authenticated)/settings/integrations/integrations-catalog.tsx
  28. 7 1
      src/app/(authenticated)/settings/invoice/invoice-settings.tsx
  29. 6 0
      src/app/(authenticated)/settings/layout.tsx
  30. 22 7
      src/app/(authenticated)/settings/localization/localization-settings.tsx
  31. 6 1
      src/app/(authenticated)/settings/maintenance/maintenance-settings.tsx
  32. 14 3
      src/app/(authenticated)/settings/payment/payment-settings.tsx
  33. 2 2
      src/app/(authenticated)/settings/report-schedule/report-schedule-settings.tsx
  34. 195 155
      src/app/(authenticated)/settings/settings-nav.tsx
  35. 6 1
      src/app/(authenticated)/settings/support/page.tsx
  36. 4 3
      src/app/(authenticated)/settings/tax/tax-settings.tsx
  37. 12 4
      src/app/(authenticated)/settings/team/team-settings.tsx
  38. 12 4
      src/app/(authenticated)/settings/templates/template-settings.tsx
  39. 9 4
      src/app/(authenticated)/settings/tire-hotel/tire-hotel-settings.tsx
  40. 3 3
      src/app/(authenticated)/settings/warranty/warranty-settings.tsx
  41. 6 3
      src/app/(authenticated)/settings/webhooks/webhooks-settings.tsx
  42. 14 7
      src/app/(authenticated)/settings/workshop/workshop-settings.tsx
  43. 58 0
      src/app/globals.css
  44. 5 1
      src/components/app-card.tsx
  45. 9 5
      src/features/portal/Components/CustomerPortalSettings.tsx
  46. 789 484
      src/features/search/Components/SearchCommand.tsx
  47. 301 0
      src/features/search/Lib/commands.ts
  48. 281 0
      src/features/search/Lib/matcher.ts
  49. 53 0
      src/features/search/Lib/recent.ts
  50. 125 0
      src/features/search/Lib/settingsItems.ts
  51. 67 0
      src/features/settings/Components/SettingsSpotlight.tsx
  52. 4 1
      src/features/settings/Components/license-settings.tsx
  53. 7 2
      src/features/settings/Components/passkey-settings.tsx
  54. 33 0
      src/features/settings/Lib/searchIndex/about.ts
  55. 113 0
      src/features/settings/Lib/searchIndex/account.ts
  56. 114 0
      src/features/settings/Lib/searchIndex/alerts.ts
  57. 21 0
      src/features/settings/Lib/searchIndex/appearance.ts
  58. 96 0
      src/features/settings/Lib/searchIndex/company.ts
  59. 79 0
      src/features/settings/Lib/searchIndex/customerPortal.ts
  60. 170 0
      src/features/settings/Lib/searchIndex/data.ts
  61. 57 0
      src/features/settings/Lib/searchIndex/emailTemplates.ts
  62. 66 0
      src/features/settings/Lib/searchIndex/index.ts
  63. 84 0
      src/features/settings/Lib/searchIndex/inspectionReminders.ts
  64. 28 0
      src/features/settings/Lib/searchIndex/install.ts
  65. 77 0
      src/features/settings/Lib/searchIndex/integrations.ts
  66. 206 0
      src/features/settings/Lib/searchIndex/invoice.ts
  67. 43 0
      src/features/settings/Lib/searchIndex/license.ts
  68. 98 0
      src/features/settings/Lib/searchIndex/localization.ts
  69. 50 0
      src/features/settings/Lib/searchIndex/maintenance.ts
  70. 71 0
      src/features/settings/Lib/searchIndex/payment.ts
  71. 75 0
      src/features/settings/Lib/searchIndex/reportSchedule.ts
  72. 77 0
      src/features/settings/Lib/searchIndex/subscription.ts
  73. 29 0
      src/features/settings/Lib/searchIndex/support.ts
  74. 125 0
      src/features/settings/Lib/searchIndex/tax.ts
  75. 115 0
      src/features/settings/Lib/searchIndex/team.ts
  76. 167 0
      src/features/settings/Lib/searchIndex/templates.ts
  77. 93 0
      src/features/settings/Lib/searchIndex/tireHotel.ts
  78. 41 0
      src/features/settings/Lib/searchIndex/types.ts
  79. 82 0
      src/features/settings/Lib/searchIndex/warranty.ts
  80. 123 0
      src/features/settings/Lib/searchIndex/webhooks.ts
  81. 74 0
      src/features/settings/Lib/searchIndex/workOrderStatuses.ts
  82. 158 0
      src/features/settings/Lib/searchIndex/workshop.ts
  83. 61 0
      src/features/settings/Lib/settingsPageIcons.ts
  84. 142 0
      src/features/settings/Lib/settingsPages.ts
  85. 6 0
      src/features/subscription/Components/subscription-settings.tsx
  86. 1 1
      src/features/support/Components/SupportSettingsControls.tsx
  87. 7 2
      src/features/work-order-statuses/Components/WorkOrderStatusSettings.tsx

+ 34 - 2
messages/de/search.json

@@ -1,9 +1,41 @@
 {
 {
   "title": "Suche",
   "title": "Suche",
   "description": "Fahrzeuge, Kunden, Aufträge und Einstellungen durchsuchen",
   "description": "Fahrzeuge, Kunden, Aufträge und Einstellungen durchsuchen",
-  "placeholder": "Nach Name, Kennzeichen, Telefon, FIN, Rechnung suchen…",
+  "placeholder": "Kunden, Kennzeichen, Rechnungen, Einstellungen suchen…",
   "noResults": "Keine Treffer.",
   "noResults": "Keine Treffer.",
+  "noResultsFor": "Keine Treffer für „{query}“",
+  "noResultsHint": "Versuchen Sie ein Kennzeichen, eine Telefonnummer, eine Rechnungsnummer oder den Namen einer Einstellung.",
   "recentCustomers": "Zuletzt genutzte Kunden",
   "recentCustomers": "Zuletzt genutzte Kunden",
   "services": "Serviceeinträge",
   "services": "Serviceeinträge",
-  "toClose": "zum Schließen"
+  "toClose": "zum Schließen",
+  "groups": {
+    "recent": "Zuletzt",
+    "settings": "Einstellungen",
+    "pages": "Seiten",
+    "actions": "Aktionen",
+    "integrations": "Integrationen"
+  },
+  "hints": {
+    "navigate": "navigieren",
+    "open": "öffnen",
+    "close": "schließen",
+    "searching": "Suche läuft…",
+    "start": "Einstellungen, Seiten, Kunden, Fahrzeuge, Rechnungen und Ersatzteile, alles von hier aus."
+  },
+  "open": "Öffnen",
+  "pro": "Pro",
+  "actions": {
+    "newWorkOrder": "Neuer Arbeitsauftrag",
+    "newCustomer": "Neuer Kunde",
+    "newVehicle": "Neues Fahrzeug",
+    "newQuote": "Neues Angebot",
+    "newPart": "Neues Ersatzteil",
+    "inviteMember": "Teammitglied einladen"
+  },
+  "settingsSearch": {
+    "placeholder": "Einstellungen suchen…",
+    "label": "Einstellungen suchen",
+    "empty": "Keine Einstellung passt zu „{query}“",
+    "shortcutHint": "Drücken Sie überall {shortcut}, um zu suchen"
+  }
 }
 }

+ 34 - 2
messages/en/search.json

@@ -1,9 +1,41 @@
 {
 {
   "title": "Search",
   "title": "Search",
   "description": "Search for vehicles, customers, services and settings",
   "description": "Search for vehicles, customers, services and settings",
-  "placeholder": "Search by name, plate, phone, VIN, invoice…",
+  "placeholder": "Search customers, plates, invoices, settings…",
   "noResults": "No results found.",
   "noResults": "No results found.",
+  "noResultsFor": "Nothing matches “{query}”",
+  "noResultsHint": "Try a plate, a phone number, an invoice number, or the name of a setting.",
   "recentCustomers": "Recent customers",
   "recentCustomers": "Recent customers",
   "services": "Service records",
   "services": "Service records",
-  "toClose": "to close"
+  "toClose": "to close",
+  "groups": {
+    "recent": "Recent",
+    "settings": "Settings",
+    "pages": "Pages",
+    "actions": "Actions",
+    "integrations": "Integrations"
+  },
+  "hints": {
+    "navigate": "navigate",
+    "open": "open",
+    "close": "close",
+    "searching": "Searching…",
+    "start": "Settings, pages, customers, vehicles, invoices and parts, all from here."
+  },
+  "open": "Open",
+  "pro": "Pro",
+  "actions": {
+    "newWorkOrder": "New work order",
+    "newCustomer": "New customer",
+    "newVehicle": "New vehicle",
+    "newQuote": "New quote",
+    "newPart": "New part",
+    "inviteMember": "Invite a team member"
+  },
+  "settingsSearch": {
+    "placeholder": "Search settings…",
+    "label": "Search settings",
+    "empty": "No setting matches “{query}”",
+    "shortcutHint": "Press {shortcut} anywhere to search"
+  }
 }
 }

+ 34 - 2
messages/es/search.json

@@ -1,9 +1,41 @@
 {
 {
   "title": "Buscar",
   "title": "Buscar",
   "description": "Busca vehículos, clientes, trabajos y ajustes",
   "description": "Busca vehículos, clientes, trabajos y ajustes",
-  "placeholder": "Buscar por nombre, matrícula, teléfono, VIN, factura…",
+  "placeholder": "Buscar clientes, matrículas, facturas, ajustes…",
   "noResults": "Sin resultados.",
   "noResults": "Sin resultados.",
+  "noResultsFor": "Nada coincide con \"{query}\"",
+  "noResultsHint": "Prueba con una matrícula, un número de teléfono, un número de factura o el nombre de un ajuste.",
   "recentCustomers": "Clientes recientes",
   "recentCustomers": "Clientes recientes",
   "services": "Historial de servicio",
   "services": "Historial de servicio",
-  "toClose": "para cerrar"
+  "toClose": "para cerrar",
+  "groups": {
+    "recent": "Recientes",
+    "settings": "Configuración",
+    "pages": "Páginas",
+    "actions": "Acciones",
+    "integrations": "Integraciones"
+  },
+  "hints": {
+    "navigate": "navegar",
+    "open": "abrir",
+    "close": "cerrar",
+    "searching": "Buscando…",
+    "start": "Ajustes, páginas, clientes, vehículos, facturas y piezas, todo desde aquí."
+  },
+  "open": "Abrir",
+  "pro": "Pro",
+  "actions": {
+    "newWorkOrder": "Nueva orden de trabajo",
+    "newCustomer": "Nuevo cliente",
+    "newVehicle": "Nuevo vehículo",
+    "newQuote": "Nuevo presupuesto",
+    "newPart": "Nueva pieza",
+    "inviteMember": "Invitar a un miembro del equipo"
+  },
+  "settingsSearch": {
+    "placeholder": "Buscar ajustes…",
+    "label": "Buscar ajustes",
+    "empty": "Ningún ajuste coincide con \"{query}\"",
+    "shortcutHint": "Pulsa {shortcut} en cualquier lugar para buscar"
+  }
 }
 }

+ 34 - 2
messages/fr/search.json

@@ -1,9 +1,41 @@
 {
 {
   "title": "Recherche",
   "title": "Recherche",
   "description": "Rechercher véhicules, clients, interventions et paramètres",
   "description": "Rechercher véhicules, clients, interventions et paramètres",
-  "placeholder": "Nom, plaque, téléphone, VIN, facture…",
+  "placeholder": "Rechercher clients, plaques, factures, paramètres…",
   "noResults": "Aucun résultat.",
   "noResults": "Aucun résultat.",
+  "noResultsFor": "Aucun résultat pour « {query} »",
+  "noResultsHint": "Essayez une plaque, un numéro de téléphone, un numéro de facture ou le nom d’un paramètre.",
   "recentCustomers": "Clients récents",
   "recentCustomers": "Clients récents",
   "services": "Interventions",
   "services": "Interventions",
-  "toClose": "pour fermer"
+  "toClose": "pour fermer",
+  "groups": {
+    "recent": "Récents",
+    "settings": "Paramètres",
+    "pages": "Pages",
+    "actions": "Actions",
+    "integrations": "Intégrations"
+  },
+  "hints": {
+    "navigate": "naviguer",
+    "open": "ouvrir",
+    "close": "fermer",
+    "searching": "Recherche…",
+    "start": "Paramètres, pages, clients, véhicules, factures et pièces, tout depuis ici."
+  },
+  "open": "Ouvrir",
+  "pro": "Pro",
+  "actions": {
+    "newWorkOrder": "Nouvel ordre de travail",
+    "newCustomer": "Nouveau client",
+    "newVehicle": "Nouveau véhicule",
+    "newQuote": "Nouveau devis",
+    "newPart": "Nouvelle pièce",
+    "inviteMember": "Inviter un membre de l’équipe"
+  },
+  "settingsSearch": {
+    "placeholder": "Rechercher un paramètre…",
+    "label": "Rechercher un paramètre",
+    "empty": "Aucun paramètre ne correspond à « {query} »",
+    "shortcutHint": "Appuyez sur {shortcut} n’importe où pour rechercher"
+  }
 }
 }

+ 34 - 2
messages/it/search.json

@@ -1,9 +1,41 @@
 {
 {
   "title": "Cerca",
   "title": "Cerca",
   "description": "Cerca veicoli, clienti, interventi e impostazioni",
   "description": "Cerca veicoli, clienti, interventi e impostazioni",
-  "placeholder": "Cerca per nome, targa, telefono, telaio, fattura…",
+  "placeholder": "Cerca clienti, targhe, fatture, impostazioni…",
   "noResults": "Nessun risultato.",
   "noResults": "Nessun risultato.",
+  "noResultsFor": "Nessun risultato per \"{query}\"",
+  "noResultsHint": "Prova con una targa, un numero di telefono, un numero di fattura o il nome di un’impostazione.",
   "recentCustomers": "Clienti recenti",
   "recentCustomers": "Clienti recenti",
   "services": "Interventi",
   "services": "Interventi",
-  "toClose": "per chiudere"
+  "toClose": "per chiudere",
+  "groups": {
+    "recent": "Recenti",
+    "settings": "Impostazioni",
+    "pages": "Pagine",
+    "actions": "Azioni",
+    "integrations": "Integrazioni"
+  },
+  "hints": {
+    "navigate": "naviga",
+    "open": "apri",
+    "close": "chiudi",
+    "searching": "Ricerca in corso…",
+    "start": "Impostazioni, pagine, clienti, veicoli, fatture e ricambi, tutto da qui."
+  },
+  "open": "Apri",
+  "pro": "Pro",
+  "actions": {
+    "newWorkOrder": "Nuovo ordine di lavoro",
+    "newCustomer": "Nuovo cliente",
+    "newVehicle": "Nuovo veicolo",
+    "newQuote": "Nuovo preventivo",
+    "newPart": "Nuovo ricambio",
+    "inviteMember": "Invita un membro del team"
+  },
+  "settingsSearch": {
+    "placeholder": "Cerca impostazioni…",
+    "label": "Cerca impostazioni",
+    "empty": "Nessuna impostazione corrisponde a \"{query}\"",
+    "shortcutHint": "Premi {shortcut} ovunque per cercare"
+  }
 }
 }

+ 34 - 2
messages/lt/search.json

@@ -1,9 +1,41 @@
 {
 {
   "title": "Paieška",
   "title": "Paieška",
   "description": "Ieškokite transporto priemonių, klientų, darbų ir nustatymų",
   "description": "Ieškokite transporto priemonių, klientų, darbų ir nustatymų",
-  "placeholder": "Ieškoti pagal vardą, numerį, telefoną, VIN, sąskaitą…",
+  "placeholder": "Ieškoti klientų, numerių, sąskaitų, nustatymų…",
   "noResults": "Rezultatų nėra.",
   "noResults": "Rezultatų nėra.",
+  "noResultsFor": "Nieko nerasta pagal „{query}“",
+  "noResultsHint": "Pabandykite valstybinį numerį, telefono numerį, sąskaitos numerį arba nustatymo pavadinimą.",
   "recentCustomers": "Naujausi klientai",
   "recentCustomers": "Naujausi klientai",
   "services": "Serviso įrašai",
   "services": "Serviso įrašai",
-  "toClose": "kad uždarytumėte"
+  "toClose": "kad uždarytumėte",
+  "groups": {
+    "recent": "Naujausi",
+    "settings": "Nustatymai",
+    "pages": "Puslapiai",
+    "actions": "Veiksmai",
+    "integrations": "Integracijos"
+  },
+  "hints": {
+    "navigate": "naršyti",
+    "open": "atidaryti",
+    "close": "uždaryti",
+    "searching": "Ieškoma…",
+    "start": "Nustatymai, puslapiai, klientai, transporto priemonės, sąskaitos ir dalys, viskas iš čia."
+  },
+  "open": "Atidaryti",
+  "pro": "Pro",
+  "actions": {
+    "newWorkOrder": "Naujas darbo užsakymas",
+    "newCustomer": "Naujas klientas",
+    "newVehicle": "Nauja transporto priemonė",
+    "newQuote": "Naujas pasiūlymas",
+    "newPart": "Nauja dalis",
+    "inviteMember": "Pakviesti komandos narį"
+  },
+  "settingsSearch": {
+    "placeholder": "Ieškoti nustatymų…",
+    "label": "Ieškoti nustatymų",
+    "empty": "Joks nustatymas neatitinka „{query}“",
+    "shortcutHint": "Paspauskite {shortcut} bet kur, kad ieškotumėte"
+  }
 }
 }

+ 34 - 2
messages/nb/search.json

@@ -1,9 +1,41 @@
 {
 {
   "title": "Søk",
   "title": "Søk",
   "description": "Søk etter kjøretøy, kunder, jobber og innstillinger",
   "description": "Søk etter kjøretøy, kunder, jobber og innstillinger",
-  "placeholder": "Søk på navn, skilt, telefon, VIN, faktura…",
+  "placeholder": "Søk etter kunder, skilt, fakturaer, innstillinger…",
   "noResults": "Ingen treff.",
   "noResults": "Ingen treff.",
+  "noResultsFor": "Ingen treff på «{query}»",
+  "noResultsHint": "Prøv et skiltnummer, et telefonnummer, et fakturanummer eller navnet på en innstilling.",
   "recentCustomers": "Nylige kunder",
   "recentCustomers": "Nylige kunder",
   "services": "Servicehistorikk",
   "services": "Servicehistorikk",
-  "toClose": "for å lukke"
+  "toClose": "for å lukke",
+  "groups": {
+    "recent": "Nylige",
+    "settings": "Innstillinger",
+    "pages": "Sider",
+    "actions": "Handlinger",
+    "integrations": "Integrasjoner"
+  },
+  "hints": {
+    "navigate": "naviger",
+    "open": "åpne",
+    "close": "lukk",
+    "searching": "Søker…",
+    "start": "Innstillinger, sider, kunder, kjøretøy, fakturaer og deler, alt herfra."
+  },
+  "open": "Åpne",
+  "pro": "Pro",
+  "actions": {
+    "newWorkOrder": "Ny arbeidsordre",
+    "newCustomer": "Ny kunde",
+    "newVehicle": "Nytt kjøretøy",
+    "newQuote": "Nytt tilbud",
+    "newPart": "Ny del",
+    "inviteMember": "Inviter et teammedlem"
+  },
+  "settingsSearch": {
+    "placeholder": "Søk i innstillinger…",
+    "label": "Søk i innstillinger",
+    "empty": "Ingen innstilling passer med «{query}»",
+    "shortcutHint": "Trykk {shortcut} hvor som helst for å søke"
+  }
 }
 }

+ 34 - 2
messages/nl/search.json

@@ -1,9 +1,41 @@
 {
 {
   "title": "Zoeken",
   "title": "Zoeken",
   "description": "Zoek naar voertuigen, klanten, werkorders en instellingen",
   "description": "Zoek naar voertuigen, klanten, werkorders en instellingen",
-  "placeholder": "Zoek op naam, kenteken, telefoon, VIN, factuur…",
+  "placeholder": "Zoek klanten, kentekens, facturen, instellingen…",
   "noResults": "Geen resultaten.",
   "noResults": "Geen resultaten.",
+  "noResultsFor": "Niets gevonden voor \"{query}\"",
+  "noResultsHint": "Probeer een kenteken, een telefoonnummer, een factuurnummer of de naam van een instelling.",
   "recentCustomers": "Recente klanten",
   "recentCustomers": "Recente klanten",
   "services": "Serviceregistraties",
   "services": "Serviceregistraties",
-  "toClose": "om te sluiten"
+  "toClose": "om te sluiten",
+  "groups": {
+    "recent": "Recent",
+    "settings": "Instellingen",
+    "pages": "Pagina’s",
+    "actions": "Acties",
+    "integrations": "Integraties"
+  },
+  "hints": {
+    "navigate": "navigeren",
+    "open": "openen",
+    "close": "sluiten",
+    "searching": "Zoeken…",
+    "start": "Instellingen, pagina’s, klanten, voertuigen, facturen en onderdelen, allemaal vanaf hier."
+  },
+  "open": "Openen",
+  "pro": "Pro",
+  "actions": {
+    "newWorkOrder": "Nieuwe werkorder",
+    "newCustomer": "Nieuwe klant",
+    "newVehicle": "Nieuw voertuig",
+    "newQuote": "Nieuwe offerte",
+    "newPart": "Nieuw onderdeel",
+    "inviteMember": "Teamlid uitnodigen"
+  },
+  "settingsSearch": {
+    "placeholder": "Instellingen zoeken…",
+    "label": "Instellingen zoeken",
+    "empty": "Geen instelling komt overeen met \"{query}\"",
+    "shortcutHint": "Druk overal op {shortcut} om te zoeken"
+  }
 }
 }

+ 34 - 2
messages/pl/search.json

@@ -1,9 +1,41 @@
 {
 {
   "title": "Szukaj",
   "title": "Szukaj",
   "description": "Szukaj pojazdów, klientów, zleceń i ustawień",
   "description": "Szukaj pojazdów, klientów, zleceń i ustawień",
-  "placeholder": "Szukaj po nazwie, rejestracji, telefonie, VIN, fakturze…",
+  "placeholder": "Szukaj klientów, rejestracji, faktur, ustawień…",
   "noResults": "Brak wyników.",
   "noResults": "Brak wyników.",
+  "noResultsFor": "Brak wyników dla „{query}”",
+  "noResultsHint": "Spróbuj numeru rejestracyjnego, numeru telefonu, numeru faktury lub nazwy ustawienia.",
   "recentCustomers": "Ostatni klienci",
   "recentCustomers": "Ostatni klienci",
   "services": "Historia serwisowa",
   "services": "Historia serwisowa",
-  "toClose": "aby zamknąć"
+  "toClose": "aby zamknąć",
+  "groups": {
+    "recent": "Ostatnie",
+    "settings": "Ustawienia",
+    "pages": "Strony",
+    "actions": "Akcje",
+    "integrations": "Integracje"
+  },
+  "hints": {
+    "navigate": "nawiguj",
+    "open": "otwórz",
+    "close": "zamknij",
+    "searching": "Szukanie…",
+    "start": "Ustawienia, strony, klienci, pojazdy, faktury i części, wszystko stąd."
+  },
+  "open": "Otwórz",
+  "pro": "Pro",
+  "actions": {
+    "newWorkOrder": "Nowe zlecenie",
+    "newCustomer": "Nowy klient",
+    "newVehicle": "Nowy pojazd",
+    "newQuote": "Nowa wycena",
+    "newPart": "Nowa część",
+    "inviteMember": "Zaproś członka zespołu"
+  },
+  "settingsSearch": {
+    "placeholder": "Szukaj ustawień…",
+    "label": "Szukaj ustawień",
+    "empty": "Żadne ustawienie nie pasuje do „{query}”",
+    "shortcutHint": "Naciśnij {shortcut} w dowolnym miejscu, aby wyszukać"
+  }
 }
 }

+ 34 - 2
messages/pt-BR/search.json

@@ -1,9 +1,41 @@
 {
 {
   "title": "Buscar",
   "title": "Buscar",
   "description": "Busque veículos, clientes, serviços e configurações",
   "description": "Busque veículos, clientes, serviços e configurações",
-  "placeholder": "Buscar por nome, placa, telefone, chassi, nota…",
+  "placeholder": "Buscar clientes, placas, faturas, configurações…",
   "noResults": "Nenhum resultado.",
   "noResults": "Nenhum resultado.",
+  "noResultsFor": "Nada corresponde a \"{query}\"",
+  "noResultsHint": "Tente uma placa, um telefone, um número de fatura ou o nome de uma configuração.",
   "recentCustomers": "Clientes recentes",
   "recentCustomers": "Clientes recentes",
   "services": "Histórico de serviço",
   "services": "Histórico de serviço",
-  "toClose": "para fechar"
+  "toClose": "para fechar",
+  "groups": {
+    "recent": "Recentes",
+    "settings": "Configurações",
+    "pages": "Páginas",
+    "actions": "Ações",
+    "integrations": "Integrações"
+  },
+  "hints": {
+    "navigate": "navegar",
+    "open": "abrir",
+    "close": "fechar",
+    "searching": "Buscando…",
+    "start": "Configurações, páginas, clientes, veículos, faturas e peças, tudo daqui."
+  },
+  "open": "Abrir",
+  "pro": "Pro",
+  "actions": {
+    "newWorkOrder": "Nova ordem de serviço",
+    "newCustomer": "Novo cliente",
+    "newVehicle": "Novo veículo",
+    "newQuote": "Novo orçamento",
+    "newPart": "Nova peça",
+    "inviteMember": "Convidar um membro da equipe"
+  },
+  "settingsSearch": {
+    "placeholder": "Buscar configurações…",
+    "label": "Buscar configurações",
+    "empty": "Nenhuma configuração corresponde a \"{query}\"",
+    "shortcutHint": "Pressione {shortcut} em qualquer lugar para buscar"
+  }
 }
 }

+ 34 - 2
messages/pt-PT/search.json

@@ -1,9 +1,41 @@
 {
 {
   "title": "Pesquisar",
   "title": "Pesquisar",
   "description": "Pesquise viaturas, clientes, serviços e definições",
   "description": "Pesquise viaturas, clientes, serviços e definições",
-  "placeholder": "Pesquisar por nome, matrícula, telefone, VIN, fatura…",
+  "placeholder": "Pesquisar clientes, matrículas, faturas, definições…",
   "noResults": "Nenhum resultado encontrado.",
   "noResults": "Nenhum resultado encontrado.",
+  "noResultsFor": "Nada corresponde a \"{query}\"",
+  "noResultsHint": "Experimente uma matrícula, um número de telefone, um número de fatura ou o nome de uma definição.",
   "recentCustomers": "Clientes recentes",
   "recentCustomers": "Clientes recentes",
   "services": "Registos de serviço",
   "services": "Registos de serviço",
-  "toClose": "para fechar"
+  "toClose": "para fechar",
+  "groups": {
+    "recent": "Recentes",
+    "settings": "Definições",
+    "pages": "Páginas",
+    "actions": "Ações",
+    "integrations": "Integrações"
+  },
+  "hints": {
+    "navigate": "navegar",
+    "open": "abrir",
+    "close": "fechar",
+    "searching": "A pesquisar…",
+    "start": "Definições, páginas, clientes, viaturas, faturas e peças, tudo a partir daqui."
+  },
+  "open": "Abrir",
+  "pro": "Pro",
+  "actions": {
+    "newWorkOrder": "Nova ordem de serviço",
+    "newCustomer": "Novo cliente",
+    "newVehicle": "Nova viatura",
+    "newQuote": "Novo orçamento",
+    "newPart": "Nova peça",
+    "inviteMember": "Convidar um membro da equipa"
+  },
+  "settingsSearch": {
+    "placeholder": "Pesquisar definições…",
+    "label": "Pesquisar definições",
+    "empty": "Nenhuma definição corresponde a \"{query}\"",
+    "shortcutHint": "Prima {shortcut} em qualquer lado para pesquisar"
+  }
 }
 }

+ 34 - 2
messages/ru/search.json

@@ -1,9 +1,41 @@
 {
 {
   "title": "Поиск",
   "title": "Поиск",
   "description": "Поиск по автомобилям, клиентам, работам и настройкам",
   "description": "Поиск по автомобилям, клиентам, работам и настройкам",
-  "placeholder": "Имя, номер, телефон, VIN, счёт…",
+  "placeholder": "Поиск клиентов, номеров, счетов, настроек…",
   "noResults": "Ничего не найдено.",
   "noResults": "Ничего не найдено.",
+  "noResultsFor": "Ничего не найдено по запросу «{query}»",
+  "noResultsHint": "Попробуйте госномер, номер телефона, номер счёта или название настройки.",
   "recentCustomers": "Недавние клиенты",
   "recentCustomers": "Недавние клиенты",
   "services": "История работ",
   "services": "История работ",
-  "toClose": "чтобы закрыть"
+  "toClose": "чтобы закрыть",
+  "groups": {
+    "recent": "Недавние",
+    "settings": "Настройки",
+    "pages": "Страницы",
+    "actions": "Действия",
+    "integrations": "Интеграции"
+  },
+  "hints": {
+    "navigate": "навигация",
+    "open": "открыть",
+    "close": "закрыть",
+    "searching": "Поиск…",
+    "start": "Настройки, страницы, клиенты, транспорт, счета и запчасти, всё отсюда."
+  },
+  "open": "Открыть",
+  "pro": "Pro",
+  "actions": {
+    "newWorkOrder": "Новый заказ-наряд",
+    "newCustomer": "Новый клиент",
+    "newVehicle": "Новое транспортное средство",
+    "newQuote": "Новое предложение",
+    "newPart": "Новая запчасть",
+    "inviteMember": "Пригласить сотрудника"
+  },
+  "settingsSearch": {
+    "placeholder": "Поиск настроек…",
+    "label": "Поиск настроек",
+    "empty": "Нет настроек по запросу «{query}»",
+    "shortcutHint": "Нажмите {shortcut} в любом месте, чтобы начать поиск"
+  }
 }
 }

+ 34 - 2
messages/tr/search.json

@@ -1,9 +1,41 @@
 {
 {
   "title": "Ara",
   "title": "Ara",
   "description": "Araç, müşteri, iş emri ve ayarlarda arayın",
   "description": "Araç, müşteri, iş emri ve ayarlarda arayın",
-  "placeholder": "İsim, plaka, telefon, şasi, fatura ara…",
+  "placeholder": "Müşteri, plaka, fatura, ayar ara…",
   "noResults": "Sonuç yok.",
   "noResults": "Sonuç yok.",
+  "noResultsFor": "\"{query}\" ile eşleşen sonuç yok",
+  "noResultsHint": "Bir plaka, telefon numarası, fatura numarası veya bir ayarın adını deneyin.",
   "recentCustomers": "Son müşteriler",
   "recentCustomers": "Son müşteriler",
   "services": "Servis kayıtları",
   "services": "Servis kayıtları",
-  "toClose": "kapatmak için"
+  "toClose": "kapatmak için",
+  "groups": {
+    "recent": "Son kullanılanlar",
+    "settings": "Ayarlar",
+    "pages": "Sayfalar",
+    "actions": "İşlemler",
+    "integrations": "Entegrasyonlar"
+  },
+  "hints": {
+    "navigate": "gezin",
+    "open": "aç",
+    "close": "kapat",
+    "searching": "Aranıyor…",
+    "start": "Ayarlar, sayfalar, müşteriler, araçlar, faturalar ve parçalar, hepsi buradan."
+  },
+  "open": "Aç",
+  "pro": "Pro",
+  "actions": {
+    "newWorkOrder": "Yeni iş emri",
+    "newCustomer": "Yeni müşteri",
+    "newVehicle": "Yeni araç",
+    "newQuote": "Yeni teklif",
+    "newPart": "Yeni parça",
+    "inviteMember": "Ekip üyesi davet et"
+  },
+  "settingsSearch": {
+    "placeholder": "Ayarlarda ara…",
+    "label": "Ayarlarda ara",
+    "empty": "\"{query}\" ile eşleşen ayar yok",
+    "shortcutHint": "Aramak için herhangi bir yerde {shortcut} tuşuna basın"
+  }
 }
 }

+ 160 - 0
src/__tests__/features/search/matcher.test.ts

@@ -0,0 +1,160 @@
+import { describe, expect, it } from 'vitest'
+import {
+  fold,
+  matchItem,
+  prepareItem,
+  prepareQuery,
+  rank,
+  splitByRanges,
+} from '@/features/search/Lib/matcher'
+
+function prepared<T extends { title: string }>(items: T[]) {
+  return items.map((item) => ({ item, prepared: prepareItem(item) }))
+}
+
+const SETTINGS = prepared([
+  { title: 'Company', keywords: ['business', 'branding'], context: 'General' },
+  { title: 'Localization', keywords: ['language', 'timezone'], context: 'General' },
+  { title: 'Invoice & Quotes', keywords: ['invoice', 'numbering'], context: 'Billing & Documents' },
+  { title: 'Due Days', description: 'Number of days until invoice is due', context: 'Invoice' },
+  { title: 'Tax', keywords: ['vat', 'mva', 'mwst'], context: 'Billing & Documents' },
+  { title: 'Tax rate', keywords: ['percent'], context: 'Tax' },
+  { title: 'Data', keywords: ['export', 'import', 'backup'], context: 'System' },
+  { title: 'Workshop', keywords: ['labor', 'technician'], context: 'Workshop' },
+])
+
+function titles(query: string) {
+  return rank(prepareQuery(query), SETTINGS).map((r) => r.item.title)
+}
+
+describe('fold', () => {
+  it('lower-cases and strips accents', () => {
+    expect(fold('Lokalisierung')).toBe('lokalisierung')
+    expect(fold('Tåx')).toBe('tax')
+    expect(fold('Réglages')).toBe('reglages')
+  })
+
+  it('folds the Nordic and German letters to what people type for them', () => {
+    expect(fold('Miljøgebyr')).toBe('miljogebyr')
+    expect(fold('Værksted')).toBe('vaerksted')
+    expect(fold('Straße')).toBe('strasse')
+    expect(fold('Måned')).toBe('maned')
+  })
+})
+
+describe('matchItem', () => {
+  it('matches nothing to an empty query, with a zero score', () => {
+    const m = matchItem(prepareQuery(''), prepareItem({ title: 'Tax' }))
+    expect(m).toEqual({ score: 0, ranges: [], strong: true })
+  })
+
+  it('returns null when a word is nowhere to be found', () => {
+    expect(matchItem(prepareQuery('zzz'), prepareItem({ title: 'Tax' }))).toBeNull()
+  })
+
+  it('needs every word to land somewhere', () => {
+    const item = prepareItem({ title: 'Due Days', description: 'days until the invoice is due' })
+    expect(matchItem(prepareQuery('due invoice'), item)).not.toBeNull()
+    expect(matchItem(prepareQuery('due payroll'), item)).toBeNull()
+  })
+
+  it('reports the title ranges in the original text, accents included', () => {
+    const item = prepareItem({ title: 'Miljøgebyr' })
+    const m = matchItem(prepareQuery('miljo'), item)
+    expect(m?.ranges).toEqual([[0, 5]])
+    expect(splitByRanges('Miljøgebyr', m?.ranges ?? [])).toEqual([
+      { text: 'Miljø', hit: true },
+      { text: 'gebyr', hit: false },
+    ])
+  })
+
+  it('keeps ranges right when a letter folds to two', () => {
+    const item = prepareItem({ title: 'Værksted' })
+    const m = matchItem(prepareQuery('vaerk'), item)
+    expect(m?.ranges).toEqual([[0, 4]])
+  })
+
+  it('is strong for a direct title or alias hit and weak for a loose one', () => {
+    const item = prepareItem({ title: 'Localization', keywords: ['language'] })
+    expect(matchItem(prepareQuery('local'), item)?.strong).toBe(true)
+    expect(matchItem(prepareQuery('lang'), item)?.strong).toBe(true)
+    expect(matchItem(prepareQuery('lcl'), item)?.strong).toBe(false)
+  })
+
+  it('does not loose-match two letters', () => {
+    expect(matchItem(prepareQuery('lz'), prepareItem({ title: 'Localization' }))).toBeNull()
+  })
+
+  it('highlights the loose-matched letters', () => {
+    const m = matchItem(prepareQuery('lcl'), prepareItem({ title: 'Localization' }))
+    expect(m?.ranges).toEqual([
+      [0, 1],
+      [2, 3],
+      [4, 5],
+    ])
+  })
+})
+
+describe('rank', () => {
+  it('puts the exact title first, then prefixes, then aliases', () => {
+    expect(titles('tax')).toEqual(['Tax', 'Tax rate'])
+    expect(titles('ta')[0]).toBe('Tax')
+  })
+
+  it('finds a page by its alias in any language', () => {
+    expect(titles('vat')).toEqual(['Tax'])
+    expect(titles('mva')).toEqual(['Tax'])
+    expect(titles('backup')).toEqual(['Data'])
+  })
+
+  it('prefers a word that opens the title over one buried in it', () => {
+    const items = prepared([{ title: 'Quote validity' }, { title: 'Valid until' }])
+    expect(rank(prepareQuery('valid'), items).map((r) => r.item.title)).toEqual([
+      'Valid until',
+      'Quote validity',
+    ])
+  })
+
+  it('ranks the phrase as typed above the words apart', () => {
+    const items = prepared([
+      { title: 'Days due', description: 'due days for quotes' },
+      { title: 'Due Days' },
+    ])
+    expect(rank(prepareQuery('due days'), items).map((r) => r.item.title)).toEqual([
+      'Due Days',
+      'Days due',
+    ])
+  })
+
+  it('finds a field through its description', () => {
+    expect(titles('until invoice')).toEqual(['Due Days'])
+  })
+
+  it('finds a page through its breadcrumb', () => {
+    expect(titles('billing')).toEqual(['Tax', 'Invoice & Quotes'])
+  })
+
+  it('still finds a typo-free subsequence as a last resort, below real hits', () => {
+    const items = prepared([{ title: 'Workshop' }, { title: 'Work orders' }])
+    expect(rank(prepareQuery('wrk'), items).map((r) => r.item.title)).toEqual([
+      'Workshop',
+      'Work orders',
+    ])
+  })
+
+  it('is stable: ties keep the original order', () => {
+    const items = prepared([{ title: 'Alpha one' }, { title: 'Alpha two' }])
+    expect(rank(prepareQuery('alpha'), items).map((r) => r.item.title)).toEqual([
+      'Alpha one',
+      'Alpha two',
+    ])
+  })
+
+  it('honours the limit', () => {
+    expect(rank(prepareQuery('a'), SETTINGS, 2)).toHaveLength(2)
+  })
+
+  it('returns everything, in order, for an empty query', () => {
+    expect(titles('')).toHaveLength(SETTINGS.length)
+  })
+})

+ 107 - 0
src/__tests__/features/search/settings-items.test.ts

@@ -0,0 +1,107 @@
+import { renderHook } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { useSettingsSearchItems } from '@/features/search/Lib/settingsItems'
+import { prepareQuery, rank } from '@/features/search/Lib/matcher'
+import {
+  visibleActionCommands,
+  visiblePageCommands,
+} from '@/features/search/Lib/commands'
+
+/**
+ * The real index, titled in English through the test setup's next-intl
+ * mock, run through the real matcher: what a workshop actually types.
+ */
+function items(opts: Parameters<typeof useSettingsSearchItems>[0] = {}) {
+  return renderHook(() => useSettingsSearchItems(opts)).result.current
+}
+
+function top(query: string, n = 3, opts?: Parameters<typeof useSettingsSearchItems>[0]) {
+  return rank(prepareQuery(query), items(opts), n).map((r) => r.item)
+}
+
+describe('settings search items', () => {
+  it('lists every page on a self-hosted install, with the cloud-only ones left out', () => {
+    const pages = items({ isCloud: false }).filter((i) => i.item.kind === 'settingsPage')
+    const keys = pages.map((p) => p.item.page)
+    expect(keys).toContain('company')
+    expect(keys).toContain('license')
+    expect(keys).not.toContain('subscription')
+    expect(keys).not.toContain('support')
+  })
+
+  it('swaps the licence page for the subscription page on the cloud', () => {
+    const keys = items({ isCloud: true, supportEnabled: true })
+      .filter((i) => i.item.kind === 'settingsPage')
+      .map((p) => p.item.page)
+    expect(keys).toContain('subscription')
+    expect(keys).toContain('support')
+    expect(keys).not.toContain('license')
+  })
+
+  it('finds the tax page by its foreign aliases', () => {
+    for (const word of ['vat', 'mva', 'mwst']) {
+      expect(top(word, 1)[0]?.page, word).toBe('tax')
+    }
+  })
+
+  it('lands "logo" on the company logo card, with its anchor', () => {
+    const hit = top('logo', 1)[0]
+    expect(hit?.page).toBe('company')
+    expect(hit?.anchor).toBeTruthy()
+    expect(hit?.href).toContain('#')
+  })
+
+  it('lands "due days" on the invoice field', () => {
+    const hit = top('due days', 1)[0]
+    expect(hit?.page).toBe('invoice')
+    expect(hit?.href).toBe('/settings/invoice#dueDays')
+  })
+
+  it('finds a setting by what it does, not only what it is called', () => {
+    expect(top('timezone', 1)[0]?.page).toBe('localization')
+    expect(top('two factor', 1)[0]?.page).toBe('account')
+    expect(top('backup', 1)[0]?.page).toBe('data')
+    expect(top('dark mode', 1)[0]?.page).toBe('appearance')
+  })
+
+  it('puts the page itself above its fields when the page is what was typed', () => {
+    const [first] = top('warranty', 1)
+    expect(first?.kind).toBe('settingsPage')
+    expect(first?.page).toBe('warranty')
+  })
+
+  it('marks a gated page as locked without the feature', () => {
+    const features = { customTemplates: false } as never
+    const templates = items({ features }).find((i) => i.item.id === 'page:templates')
+    expect(templates?.item.locked).toBe(true)
+  })
+
+  it('writes a breadcrumb for every setting', () => {
+    for (const { item } of items()) {
+      if (item.kind === 'setting') expect(item.breadcrumb, item.id).toContain(' › ')
+    }
+  })
+})
+
+describe('page and action commands', () => {
+  it('hides pages the role cannot open', () => {
+    const pages = visiblePageCommands({ visibleSubjects: ['customers'] }).map((p) => p.id)
+    expect(pages).toContain('page:customers')
+    expect(pages).not.toContain('page:billing')
+    expect(pages).not.toContain('page:settings')
+  })
+
+  it('lists opt-in modules only when they are on', () => {
+    expect(visiblePageCommands({}).map((p) => p.id)).not.toContain('page:tire-hotel')
+    expect(visiblePageCommands({ tireHotelEnabled: true }).map((p) => p.id)).toContain(
+      'page:tire-hotel'
+    )
+  })
+
+  it('offers the invitation only to admins', () => {
+    expect(visibleActionCommands({}).map((a) => a.id)).not.toContain('action:invite-member')
+    expect(visibleActionCommands({ isAdminOrOwner: true }).map((a) => a.id)).toContain(
+      'action:invite-member'
+    )
+  })
+})

+ 190 - 0
src/__tests__/features/settings/search-index.test.ts

@@ -0,0 +1,190 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { describe, expect, it } from 'vitest'
+import type { SettingsEntry } from '@/features/settings/Lib/searchIndex/types'
+import { SETTINGS_PAGES, settingsPage } from '@/features/settings/Lib/settingsPages'
+
+/**
+ * Every page module under searchIndex/, found by glob so a module an agent
+ * is still writing is checked before it is wired into the aggregate.
+ */
+const modules = import.meta.glob('/src/features/settings/Lib/searchIndex/*.ts', {
+  eager: true,
+}) as Record<string, Record<string, unknown>>
+
+const entriesByModule: Record<string, SettingsEntry[]> = {}
+for (const [file, mod] of Object.entries(modules)) {
+  const name = path.basename(file, '.ts')
+  if (name === 'index' || name === 'types') continue
+  const exported = Object.values(mod).find(Array.isArray) as SettingsEntry[] | undefined
+  entriesByModule[name] = exported ?? []
+}
+const allEntries = Object.values(entriesByModule).flat()
+
+const root = process.cwd()
+const settingsDir = path.join(root, 'src/app/(authenticated)/settings')
+
+function readMessages(locale: string): Record<string, unknown> {
+  return JSON.parse(fs.readFileSync(path.join(root, 'messages', locale, 'settings.json'), 'utf8'))
+}
+
+function resolveKey(obj: unknown, key: string): unknown {
+  let cur = obj
+  for (const seg of key.split('.')) {
+    if (cur == null || typeof cur !== 'object') return undefined
+    cur = (cur as Record<string, unknown>)[seg]
+  }
+  return cur
+}
+
+function listFiles(dir: string, out: string[] = []): string[] {
+  if (!fs.existsSync(dir)) return out
+  for (const name of fs.readdirSync(dir)) {
+    const full = path.join(dir, name)
+    if (fs.statSync(full).isDirectory()) listFiles(full, out)
+    else if (/\.tsx?$/.test(name)) out.push(full)
+  }
+  return out
+}
+
+const pageSources = new Map<string, string>()
+function pageSource(href: string): string {
+  const slug = href.replace('/settings/', '')
+  const cached = pageSources.get(slug)
+  if (cached) return cached
+  const files = listFiles(path.join(settingsDir, slug))
+  const text = files.map((f) => fs.readFileSync(f, 'utf8')).join('\n')
+  pageSources.set(slug, text)
+  return text
+}
+
+let featureSource: string | null = null
+function sharedSource(): string {
+  if (featureSource === null) {
+    featureSource = [
+      ...listFiles(path.join(root, 'src/features')),
+      ...listFiles(path.join(root, 'src/components')),
+    ]
+      .map((f) => fs.readFileSync(f, 'utf8'))
+      .join('\n')
+  }
+  return featureSource
+}
+
+function hasAnchor(source: string, anchor: string): boolean {
+  return (
+    source.includes(`id="${anchor}"`) ||
+    source.includes(`id={'${anchor}'}`) ||
+    source.includes(`id="${anchor}" `) ||
+    source.includes(`id='${anchor}'`)
+  )
+}
+
+const en = readMessages('en')
+const locales = fs
+  .readdirSync(path.join(root, 'messages'))
+  .filter((l) => fs.existsSync(path.join(root, 'messages', l, 'settings.json')))
+
+describe('settings search index', () => {
+  it('has at least one module', () => {
+    expect(Object.keys(entriesByModule).length).toBeGreaterThan(0)
+  })
+
+  it('names every module after a settings page and exports entries for that page', () => {
+    for (const [name, entries] of Object.entries(entriesByModule)) {
+      expect(
+        SETTINGS_PAGES.map((p) => p.key),
+        `module ${name}`
+      ).toContain(name)
+      expect(entries.length, `module ${name} has entries`).toBeGreaterThan(0)
+      for (const entry of entries) {
+        expect(entry.page, `${entry.id} lives in ${name}.ts`).toBe(name)
+      }
+    }
+  })
+
+  it('gives every entry a unique id prefixed with its page', () => {
+    const seen = new Set<string>()
+    for (const entry of allEntries) {
+      expect(entry.id.startsWith(`${entry.page}.`), `${entry.id} starts with ${entry.page}.`).toBe(
+        true
+      )
+      expect(seen.has(entry.id), `${entry.id} is unique`).toBe(false)
+      seen.add(entry.id)
+    }
+  })
+
+  it('points every entry at a page that exists on disk', () => {
+    for (const entry of allEntries) {
+      const page = settingsPage(entry.page)
+      const slug = page.href.replace('/settings/', '')
+      expect(
+        fs.existsSync(path.join(settingsDir, slug, 'page.tsx')),
+        `${entry.id}: ${page.href}`
+      ).toBe(true)
+    }
+  })
+
+  it('resolves every title and description to an English string', () => {
+    for (const entry of allEntries) {
+      expect(typeof resolveKey(en, entry.title), `${entry.id} title ${entry.title}`).toBe('string')
+      if (entry.description) {
+        expect(
+          typeof resolveKey(en, entry.description),
+          `${entry.id} description ${entry.description}`
+        ).toBe('string')
+      }
+    }
+  })
+
+  it('never titles an entry with a string that needs values', () => {
+    // The palette calls t(key) with nothing to fill in, so "{unit}" would print raw.
+    for (const entry of allEntries) {
+      for (const key of [entry.title, entry.description]) {
+        if (!key) continue
+        const text = String(resolveKey(en, key))
+        const needed = [...text.matchAll(/\{(\w+)/g)].map((m) => m[1])
+        for (const name of needed) {
+          expect(entry.values?.[name], `${entry.id}: ${key} needs {${name}}`).toBeDefined()
+        }
+      }
+    }
+  })
+
+  it('resolves every title in every locale', () => {
+    for (const locale of locales) {
+      const messages = readMessages(locale)
+      for (const entry of allEntries) {
+        expect(typeof resolveKey(messages, entry.title), `${locale}: ${entry.id} title`).toBe(
+          'string'
+        )
+      }
+    }
+  })
+
+  it('keeps keywords lower case, trimmed and non-empty', () => {
+    for (const entry of allEntries) {
+      expect(entry.keywords.length, `${entry.id} has keywords`).toBeGreaterThan(0)
+      for (const keyword of entry.keywords) {
+        expect(keyword, `${entry.id} keyword "${keyword}"`).toBe(keyword.trim().toLowerCase())
+        expect(keyword.length, `${entry.id} keyword "${keyword}"`).toBeGreaterThan(0)
+      }
+    }
+  })
+
+  it('finds every anchor as a literal id in the page source', () => {
+    for (const entry of allEntries) {
+      if (!entry.anchor) continue
+      const page = settingsPage(entry.page)
+      const found =
+        hasAnchor(pageSource(page.href), entry.anchor) || hasAnchor(sharedSource(), entry.anchor)
+      expect(found, `${entry.id}: id="${entry.anchor}" in ${page.href}`).toBe(true)
+    }
+  })
+
+  it('writes queries without a leading question mark', () => {
+    for (const entry of allEntries) {
+      if (entry.query) expect(entry.query.startsWith('?'), `${entry.id} query`).toBe(false)
+    }
+  })
+})

+ 24 - 2
src/app/(authenticated)/layout.tsx

@@ -41,7 +41,8 @@ import { isTireHotelEnabled } from '@/features/tire-hotel/Lib/tireHotelSettings'
 import { findLookupConnection } from '@/features/integrations/Lib/vehicle-lookup'
 import { findLookupConnection } from '@/features/integrations/Lib/vehicle-lookup'
 import { isAiConfigured } from '@/features/integrations/Lib/ai'
 import { isAiConfigured } from '@/features/integrations/Lib/ai'
 import { WORKSHOP_CHAT_ENABLED } from '@/features/ai/constants'
 import { WORKSHOP_CHAT_ENABLED } from '@/features/ai/constants'
-import { getManifest } from '@/integrations/registry'
+import { getManifest, listManifests } from '@/integrations/registry'
+import { isCloudLinked } from '@/lib/torqvoice-com-link'
 import { PlateLookupProvider } from '@/components/plate-lookup-context'
 import { PlateLookupProvider } from '@/components/plate-lookup-context'
 import { PlateLookupCommand } from '@/features/vehicles/Components/PlateLookupCommand'
 import { PlateLookupCommand } from '@/features/vehicles/Components/PlateLookupCommand'
 import { OPEN_SERVICE_STATUSES } from '@/lib/service-record'
 import { OPEN_SERVICE_STATUSES } from '@/lib/service-record'
@@ -233,6 +234,17 @@ export default async function DashboardLayout({ children }: { children: React.Re
       ? await findLookupConnection(data.organizationId)
       ? await findLookupConnection(data.organizationId)
       : null
       : null
   const plateLookupAvailable = lookupConnection !== null
   const plateLookupAvailable = lookupConnection !== null
+
+  // What the search palette can reach without asking the server: which
+  // settings pages exist on this install, and the connectors the catalog
+  // offers, trimmed to a name and a logo.
+  const cloudLinked = await isCloudLinked()
+  const searchConnectors = listManifests().map((m) => ({
+    id: m.id,
+    name: m.name,
+    category: m.category,
+    logo: m.logo,
+  }))
   const plateLookupRegistry = lookupConnection
   const plateLookupRegistry = lookupConnection
     ? (getManifest(lookupConnection.connectorId)?.name ?? null)
     ? (getManifest(lookupConnection.connectorId)?.name ?? null)
     : null
     : null
@@ -365,7 +377,17 @@ export default async function DashboardLayout({ children }: { children: React.Re
                                   {children}
                                   {children}
                                 </div>
                                 </div>
                               </SidebarInset>
                               </SidebarInset>
-                              <SearchCommand />
+                              <SearchCommand
+                                visibleSubjects={visibleSubjects}
+                                features={features}
+                                tireHotelEnabled={tireHotelEnabled}
+                                aiEnabled={aiEnabled}
+                                isAdminOrOwner={isOwnerOrAdmin}
+                                isCloud={cloudLinked}
+                                supportEnabled={supportEnabled}
+                                isMarine={data.serviceType === 'marine'}
+                                connectors={searchConnectors}
+                              />
                               <PlateLookupCommand />
                               <PlateLookupCommand />
                               {isOwnerOrAdmin && <NotificationInitializer />}
                               {isOwnerOrAdmin && <NotificationInitializer />}
                               <OnlineTracker />
                               <OnlineTracker />

+ 8 - 3
src/app/(authenticated)/settings/about/page.tsx

@@ -18,11 +18,16 @@ export default async function AboutSettingsPage() {
 
 
   return (
   return (
     <div className="space-y-6">
     <div className="space-y-6">
-      <AppCard icon={Info} title={t('about.title')} contentClassName="space-y-4">
+      <AppCard
+        id="about-torqvoice"
+        icon={Info}
+        title={t('about.title')}
+        contentClassName="space-y-4"
+      >
         <p className="text-sm text-muted-foreground">{t('about.description')}</p>
         <p className="text-sm text-muted-foreground">{t('about.description')}</p>
         <Separator />
         <Separator />
-        <div className="space-y-2">
-          <div className="flex items-center justify-between">
+        <div id="about-links" className="space-y-2">
+          <div id="about-version" className="flex items-center justify-between">
             <span className="text-sm text-muted-foreground">{t('about.version')}</span>
             <span className="text-sm text-muted-foreground">{t('about.version')}</span>
             <span className="text-sm font-medium">{version}</span>
             <span className="text-sm font-medium">{version}</span>
           </div>
           </div>

+ 16 - 3
src/app/(authenticated)/settings/account/account-settings.tsx

@@ -279,7 +279,12 @@ export function AccountSettings({
       </Card> */}
       </Card> */}
 
 
       {/* Update Name */}
       {/* Update Name */}
-      <AppCard icon={User} title={t('account.profileTitle')} contentClassName="space-y-4">
+      <AppCard
+        id="account-profile"
+        icon={User}
+        title={t('account.profileTitle')}
+        contentClassName="space-y-4"
+      >
         <p className="text-sm text-muted-foreground">{t('account.profileDescription')}</p>
         <p className="text-sm text-muted-foreground">{t('account.profileDescription')}</p>
         <div className="grid gap-4 sm:grid-cols-2">
         <div className="grid gap-4 sm:grid-cols-2">
           <div className="space-y-2">
           <div className="space-y-2">
@@ -354,10 +359,13 @@ export function AccountSettings({
         </div>
         </div>
       </AppCard>
       </AppCard>
 
 
-      <SignatureCard initial={signature} />
+      <div id="account-signature">
+        <SignatureCard initial={signature} />
+      </div>
 
 
       {/* Change Password */}
       {/* Change Password */}
       <AppCard
       <AppCard
+        id="account-password"
         icon={KeyRound}
         icon={KeyRound}
         title={t('account.changePasswordTitle')}
         title={t('account.changePasswordTitle')}
         contentClassName="space-y-4"
         contentClassName="space-y-4"
@@ -405,7 +413,12 @@ export function AccountSettings({
         </div>
         </div>
       </AppCard>
       </AppCard>
       {/* Two-Factor Authentication */}
       {/* Two-Factor Authentication */}
-      <AppCard icon={Shield} title={t('account.twoFactorTitle')} contentClassName="space-y-4">
+      <AppCard
+        id="account-two-factor"
+        icon={Shield}
+        title={t('account.twoFactorTitle')}
+        contentClassName="space-y-4"
+      >
         {twoFactorEnabled ? (
         {twoFactorEnabled ? (
           <>
           <>
             <div className="flex items-center gap-2 rounded-lg bg-emerald-500/10 px-4 py-3">
             <div className="flex items-center gap-2 rounded-lg bg-emerald-500/10 px-4 py-3">

+ 1 - 0
src/app/(authenticated)/settings/account/signed-in-devices.tsx

@@ -78,6 +78,7 @@ export function SignedInDevices({ devices }: { devices: SignedInDevice[] }) {
 
 
   return (
   return (
     <AppCard
     <AppCard
+      id="account-devices"
       icon={MonitorSmartphone}
       icon={MonitorSmartphone}
       title={t('account.devicesTitle')}
       title={t('account.devicesTitle')}
       description={t('account.devicesDescription')}
       description={t('account.devicesDescription')}

+ 14 - 2
src/app/(authenticated)/settings/alerts/alerts-settings.tsx

@@ -67,6 +67,7 @@ function TechnicianStatusAlerts({ settings }: { settings: Record<string, string>
 
 
   return (
   return (
     <AppCard
     <AppCard
+      id="alerts-technician-status"
       icon={Smartphone}
       icon={Smartphone}
       title={t('alerts.technicianStatus.title')}
       title={t('alerts.technicianStatus.title')}
       contentClassName="space-y-6"
       contentClassName="space-y-6"
@@ -127,7 +128,12 @@ function ServiceRequestAlertCard({ settings }: { settings: Record<string, string
   }
   }
 
 
   return (
   return (
-    <AppCard icon={Wrench} title={t('alerts.serviceRequest.title')} contentClassName="space-y-6">
+    <AppCard
+      id="alerts-service-request"
+      icon={Wrench}
+      title={t('alerts.serviceRequest.title')}
+      contentClassName="space-y-6"
+    >
       <p className="text-sm text-muted-foreground">{t('alerts.serviceRequest.description')}</p>
       <p className="text-sm text-muted-foreground">{t('alerts.serviceRequest.description')}</p>
 
 
       <div className="flex items-center justify-between">
       <div className="flex items-center justify-between">
@@ -244,6 +250,7 @@ function LowStockAlertCard({ settings }: { settings: Record<string, string> }) {
 
 
   return (
   return (
     <AppCard
     <AppCard
+      id="alerts-low-stock"
       icon={PackageSearch}
       icon={PackageSearch}
       title={t('alerts.lowStock.title')}
       title={t('alerts.lowStock.title')}
       action={
       action={
@@ -346,7 +353,12 @@ function LowStockAlertCard({ settings }: { settings: Record<string, string> }) {
             {t('alerts.lowStock.save')}
             {t('alerts.lowStock.save')}
           </Button>
           </Button>
           {enabled && (
           {enabled && (
-            <Button variant="outline" onClick={handleCheckNow} disabled={checking}>
+            <Button
+              id="alerts-low-stock-check-now"
+              variant="outline"
+              onClick={handleCheckNow}
+              disabled={checking}
+            >
               {checking ? (
               {checking ? (
                 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
                 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
               ) : (
               ) : (

+ 2 - 0
src/app/(authenticated)/settings/appearance/appearance-settings.tsx

@@ -18,6 +18,7 @@ export function AppearanceSettings() {
   return (
   return (
     <div className="space-y-6">
     <div className="space-y-6">
       <AppCard
       <AppCard
+        id="appearance-theme"
         icon={Palette}
         icon={Palette}
         title={t('themeLabel')}
         title={t('themeLabel')}
         description={t('themeHint')}
         description={t('themeHint')}
@@ -27,6 +28,7 @@ export function AppearanceSettings() {
       </AppCard>
       </AppCard>
 
 
       <AppCard
       <AppCard
+        id="appearance-font"
         icon={Type}
         icon={Type}
         title={t('fontLabel')}
         title={t('fontLabel')}
         description={t('fontHint')}
         description={t('fontHint')}

+ 13 - 3
src/app/(authenticated)/settings/company/company-settings.tsx

@@ -113,7 +113,7 @@ export function CompanySettings({
     <div className="space-y-4">
     <div className="space-y-4">
       <ReadOnlyBanner />
       <ReadOnlyBanner />
       <ReadOnlyWrapper>
       <ReadOnlyWrapper>
-        <AppCard icon={ImageIcon} title={t('company.logoTitle')}>
+        <AppCard id="company-logo" icon={ImageIcon} title={t('company.logoTitle')}>
           <div className="flex items-center gap-4">
           <div className="flex items-center gap-4">
             <div className="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg border bg-muted/50">
             <div className="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg border bg-muted/50">
               {logoUrl ? (
               {logoUrl ? (
@@ -171,7 +171,12 @@ export function CompanySettings({
             />
             />
           </div>
           </div>
         </AppCard>
         </AppCard>
-        <AppCard icon={Building2} title={t('company.detailsTitle')} contentClassName="space-y-4">
+        <AppCard
+          id="company-details"
+          icon={Building2}
+          title={t('company.detailsTitle')}
+          contentClassName="space-y-4"
+        >
           <div className="grid gap-3 sm:grid-cols-2">
           <div className="grid gap-3 sm:grid-cols-2">
             <div className="space-y-1">
             <div className="space-y-1">
               <Label htmlFor="workshopName" className="text-xs">
               <Label htmlFor="workshopName" className="text-xs">
@@ -268,7 +273,12 @@ export function CompanySettings({
               )}
               )}
               {t('company.saveCompany')}
               {t('company.saveCompany')}
             </Button>
             </Button>
-            <Button variant="outline" size="sm" onClick={() => setShowCreateOrg(true)}>
+            <Button
+              id="company-add-company"
+              variant="outline"
+              size="sm"
+              onClick={() => setShowCreateOrg(true)}
+            >
               <Plus className="mr-1.5 h-3.5 w-3.5" />
               <Plus className="mr-1.5 h-3.5 w-3.5" />
               {t('company.addNewCompany')}
               {t('company.addNewCompany')}
             </Button>
             </Button>

+ 19 - 6
src/app/(authenticated)/settings/data/data-settings.tsx

@@ -453,6 +453,7 @@ export function DataSettings({
             ageHours < 3 ? 'bg-emerald-500' : ageHours < 26 ? 'bg-amber-500' : 'bg-red-500'
             ageHours < 3 ? 'bg-emerald-500' : ageHours < 26 ? 'bg-amber-500' : 'bg-red-500'
           return (
           return (
             <AppCard
             <AppCard
+              id="data-protection"
               icon={ShieldCheck}
               icon={ShieldCheck}
               title={t('data.backupStatus.title')}
               title={t('data.backupStatus.title')}
               className="gap-2 border border-primary/30 shadow-sm"
               className="gap-2 border border-primary/30 shadow-sm"
@@ -490,6 +491,7 @@ export function DataSettings({
       <ReadOnlyWrapper>
       <ReadOnlyWrapper>
         {/* Bring data in: one card, one row per source */}
         {/* Bring data in: one card, one row per source */}
         <AppCard
         <AppCard
+          id="data-import"
           icon={Upload}
           icon={Upload}
           title={t('data.importTitle')}
           title={t('data.importTitle')}
           description={t('data.importHubDescription')}
           description={t('data.importHubDescription')}
@@ -497,6 +499,7 @@ export function DataSettings({
         >
         >
           <ul className="divide-y">
           <ul className="divide-y">
             <ImportSourceRow
             <ImportSourceRow
+              id="data-import-spreadsheet"
               icon={FileSpreadsheet}
               icon={FileSpreadsheet}
               title={t('data.sourceSpreadsheet')}
               title={t('data.sourceSpreadsheet')}
               description={t('data.sourceSpreadsheetDesc')}
               description={t('data.sourceSpreadsheetDesc')}
@@ -513,6 +516,7 @@ export function DataSettings({
               ))}
               ))}
             </ImportSourceRow>
             </ImportSourceRow>
             <ImportSourceRow
             <ImportSourceRow
+              id="data-import-lubelog"
               icon={Wrench}
               icon={Wrench}
               title={
               title={
                 <Image
                 <Image
@@ -531,6 +535,7 @@ export function DataSettings({
               </Button>
               </Button>
             </ImportSourceRow>
             </ImportSourceRow>
             <ImportSourceRow
             <ImportSourceRow
+              id="data-import-invoice-ninja"
               icon={Receipt}
               icon={Receipt}
               title={
               title={
                 <Image
                 <Image
@@ -549,6 +554,7 @@ export function DataSettings({
               </Button>
               </Button>
             </ImportSourceRow>
             </ImportSourceRow>
             <ImportSourceRow
             <ImportSourceRow
+              id="data-import-backup"
               icon={FileArchive}
               icon={FileArchive}
               title={t('data.sourceBackup')}
               title={t('data.sourceBackup')}
               description={t('data.sourceBackupDesc')}
               description={t('data.sourceBackupDesc')}
@@ -564,6 +570,7 @@ export function DataSettings({
 
 
         {/* Export */}
         {/* Export */}
         <AppCard
         <AppCard
+          id="data-export"
           icon={Download}
           icon={Download}
           title={t('data.exportTitle')}
           title={t('data.exportTitle')}
           description={t('data.exportDescription')}
           description={t('data.exportDescription')}
@@ -581,7 +588,10 @@ export function DataSettings({
               </button>
               </button>
             </div>
             </div>
 
 
-            <div className="grid grid-cols-1 gap-1 sm:grid-cols-2 lg:grid-cols-3">
+            <div
+              id="data-export-options"
+              className="grid grid-cols-1 gap-1 sm:grid-cols-2 lg:grid-cols-3"
+            >
               {OPTION_META.map(({ key, labelKey, descKey }) => (
               {OPTION_META.map(({ key, labelKey, descKey }) => (
                 <label
                 <label
                   key={key}
                   key={key}
@@ -766,13 +776,13 @@ export function DataSettings({
       </Dialog>
       </Dialog>
 
 
       {/* Danger Zone */}
       {/* Danger Zone */}
-      <Card className="border-destructive/30 shadow-sm">
+      <Card id="data-danger-zone" className="border-destructive/30 shadow-sm">
         <CardHeader className="flex flex-row items-center gap-3 pb-4">
         <CardHeader className="flex flex-row items-center gap-3 pb-4">
           <AlertTriangle className="h-5 w-5 text-destructive" />
           <AlertTriangle className="h-5 w-5 text-destructive" />
           <CardTitle className="text-lg text-destructive">{t('account.dangerZone')}</CardTitle>
           <CardTitle className="text-lg text-destructive">{t('account.dangerZone')}</CardTitle>
         </CardHeader>
         </CardHeader>
         <CardContent>
         <CardContent>
-          <div className="flex items-start justify-between gap-4">
+          <div id="data-delete-content" className="flex items-start justify-between gap-4">
             <div>
             <div>
               <p className="font-medium">{t('account.deleteContentTitle')}</p>
               <p className="font-medium">{t('account.deleteContentTitle')}</p>
               <p className="text-sm text-muted-foreground">
               <p className="text-sm text-muted-foreground">
@@ -807,7 +817,7 @@ export function DataSettings({
           {isOwner && (
           {isOwner && (
             <>
             <>
               <div className="my-4 border-t border-destructive/20" />
               <div className="my-4 border-t border-destructive/20" />
-              <div className="flex items-start justify-between gap-4">
+              <div id="data-delete-workshop" className="flex items-start justify-between gap-4">
                 <div>
                 <div>
                   <p className="font-medium">{t('account.deleteWorkshopTitle')}</p>
                   <p className="font-medium">{t('account.deleteWorkshopTitle')}</p>
                   <p className="text-sm text-muted-foreground">
                   <p className="text-sm text-muted-foreground">
@@ -830,7 +840,7 @@ export function DataSettings({
           )}
           )}
 
 
           <div className="my-4 border-t border-destructive/20" />
           <div className="my-4 border-t border-destructive/20" />
-          <div className="flex items-start justify-between gap-4">
+          <div id="data-delete-account" className="flex items-start justify-between gap-4">
             <div>
             <div>
               <p className="font-medium">{t('account.deleteAccountTitle')}</p>
               <p className="font-medium">{t('account.deleteAccountTitle')}</p>
               <p className="text-sm text-muted-foreground">
               <p className="text-sm text-muted-foreground">
@@ -1214,18 +1224,21 @@ export function DataSettings({
  * the title slot where it has room.
  * the title slot where it has room.
  */
  */
 function ImportSourceRow({
 function ImportSourceRow({
+  id,
   icon: Icon,
   icon: Icon,
   title,
   title,
   description,
   description,
   children,
   children,
 }: {
 }: {
+  /** Anchor for deep links from settings search. */
+  id?: string
   icon: React.ComponentType<{ className?: string }>
   icon: React.ComponentType<{ className?: string }>
   title: React.ReactNode
   title: React.ReactNode
   description: string
   description: string
   children: React.ReactNode
   children: React.ReactNode
 }) {
 }) {
   return (
   return (
-    <li className="flex flex-col gap-3 px-5 py-4 sm:flex-row sm:items-center">
+    <li id={id} className="flex flex-col gap-3 px-5 py-4 sm:flex-row sm:items-center">
       <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
       <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
         <Icon className="h-5 w-5" />
         <Icon className="h-5 w-5" />
       </div>
       </div>

+ 23 - 12
src/app/(authenticated)/settings/inspection-reminders/inspection-reminder-settings.tsx

@@ -107,6 +107,7 @@ export function InspectionReminderSettings({
       <ReadOnlyBanner />
       <ReadOnlyBanner />
       <ReadOnlyWrapper>
       <ReadOnlyWrapper>
         <AppCard
         <AppCard
+          id="inspection-reminders-booking"
           icon={ClipboardCheck}
           icon={ClipboardCheck}
           title={t('title')}
           title={t('title')}
           action={<DocsLink href="/docs/features/inspection-reminders" variant="hint" />}
           action={<DocsLink href="/docs/features/inspection-reminders" variant="hint" />}
@@ -128,18 +129,28 @@ export function InspectionReminderSettings({
           </div>
           </div>
 
 
           <div className="grid gap-6 md:grid-cols-2">
           <div className="grid gap-6 md:grid-cols-2">
-            {field('ir-duration', t('duration'), t('durationHint'), duration, setDuration, 15)}
-            {field('ir-lead', t('leadDays'), t('leadDaysHint'), leadDays, setLeadDays, 1)}
-            {field('ir-horizon', t('horizonWeeks'), t('horizonHint'), horizon, setHorizon, 1)}
-            {field('ir-reserve', t('reserve'), t('reserveHint'), reserve, setReserve, 0)}
-            {field(
-              'ir-link',
-              t('linkValidDays'),
-              t('linkValidHint', { min: MIN_LINK_DAYS }),
-              linkDays,
-              setLinkDays,
-              MIN_LINK_DAYS
-            )}
+            <div id="inspection-reminders-duration">
+              {field('ir-duration', t('duration'), t('durationHint'), duration, setDuration, 15)}
+            </div>
+            <div id="inspection-reminders-lead-days">
+              {field('ir-lead', t('leadDays'), t('leadDaysHint'), leadDays, setLeadDays, 1)}
+            </div>
+            <div id="inspection-reminders-horizon">
+              {field('ir-horizon', t('horizonWeeks'), t('horizonHint'), horizon, setHorizon, 1)}
+            </div>
+            <div id="inspection-reminders-reserve">
+              {field('ir-reserve', t('reserve'), t('reserveHint'), reserve, setReserve, 0)}
+            </div>
+            <div id="inspection-reminders-link-valid">
+              {field(
+                'ir-link',
+                t('linkValidDays'),
+                t('linkValidHint', { min: MIN_LINK_DAYS }),
+                linkDays,
+                setLinkDays,
+                MIN_LINK_DAYS
+              )}
+            </div>
             <div className="space-y-1.5">
             <div className="space-y-1.5">
               <Label htmlFor="ir-mode">{t('bookingMode')}</Label>
               <Label htmlFor="ir-mode">{t('bookingMode')}</Label>
               <Select value={mode} onValueChange={setMode}>
               <Select value={mode} onValueChange={setMode}>

+ 2 - 2
src/app/(authenticated)/settings/install/install-client.tsx

@@ -15,7 +15,7 @@ export function InstallSettingsClient() {
 
 
   return (
   return (
     <div className="space-y-6">
     <div className="space-y-6">
-      <AppCard icon={Download} title={t('title')} contentClassName="space-y-4">
+      <AppCard id="install-app" icon={Download} title={t('title')} contentClassName="space-y-4">
         <div className="flex items-start gap-3">
         <div className="flex items-start gap-3">
           <Image
           <Image
             src="/icons/icon-192.png"
             src="/icons/icon-192.png"
@@ -55,7 +55,7 @@ export function InstallSettingsClient() {
         {dismissed && !installed && (
         {dismissed && !installed && (
           <>
           <>
             <Separator />
             <Separator />
-            <div className="flex flex-wrap items-center justify-between gap-2">
+            <div id="install-banner" className="flex flex-wrap items-center justify-between gap-2">
               <p className="text-sm text-muted-foreground">{t('bannerHidden')}</p>
               <p className="text-sm text-muted-foreground">{t('bannerHidden')}</p>
               <Button variant="outline" size="sm" onClick={restore}>
               <Button variant="outline" size="sm" onClick={restore}>
                 {t('showBanner')}
                 {t('showBanner')}

+ 11 - 2
src/app/(authenticated)/settings/integrations/integrations-catalog.tsx

@@ -124,6 +124,7 @@ export function IntegrationsCatalog({
   return (
   return (
     <div className="space-y-4">
     <div className="space-y-4">
       <AppCard
       <AppCard
+        id="integrations-connected"
         icon={Check}
         icon={Check}
         title={t('catalog.connectedTitle')}
         title={t('catalog.connectedTitle')}
         badge={connected.length || undefined}
         badge={connected.length || undefined}
@@ -177,6 +178,7 @@ export function IntegrationsCatalog({
       </AppCard>
       </AppCard>
 
 
       <AppCard
       <AppCard
+        id="integrations-available"
         icon={Plug}
         icon={Plug}
         title={t('catalog.availableTitle')}
         title={t('catalog.availableTitle')}
         description={t('catalog.availableDescription')}
         description={t('catalog.availableDescription')}
@@ -194,6 +196,7 @@ export function IntegrationsCatalog({
         <div className="relative mb-4">
         <div className="relative mb-4">
           <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
           <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
           <Input
           <Input
+            id="integrations-search"
             type="search"
             type="search"
             value={query}
             value={query}
             onChange={(e) => setQuery(e.target.value)}
             onChange={(e) => setQuery(e.target.value)}
@@ -204,7 +207,10 @@ export function IntegrationsCatalog({
         </div>
         </div>
 
 
         {categories.length > 1 && (
         {categories.length > 1 && (
-          <div className="-mx-1 mb-4 flex gap-2 overflow-x-auto px-1 sm:flex-wrap sm:overflow-visible">
+          <div
+            id="integrations-categories"
+            className="-mx-1 mb-4 flex gap-2 overflow-x-auto px-1 sm:flex-wrap sm:overflow-visible"
+          >
             <Button
             <Button
               variant={category === 'all' ? 'default' : 'outline'}
               variant={category === 'all' ? 'default' : 'outline'}
               size="sm"
               size="sm"
@@ -291,7 +297,10 @@ export function IntegrationsCatalog({
         )}
         )}
 
 
         {canSuggest && (
         {canSuggest && (
-          <div className="mt-4 flex flex-col gap-3 rounded-lg border border-dashed px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
+          <div
+            id="integrations-suggest"
+            className="mt-4 flex flex-col gap-3 rounded-lg border border-dashed px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
+          >
             <div className="flex items-start gap-3">
             <div className="flex items-start gap-3">
               <Lightbulb className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
               <Lightbulb className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
               <div>
               <div>

+ 7 - 1
src/app/(authenticated)/settings/invoice/invoice-settings.tsx

@@ -333,6 +333,7 @@ export function InvoiceSettings({
             {t('invoice.tabs.general')}
             {t('invoice.tabs.general')}
           </button>
           </button>
           <button
           <button
+            id="invoice-tab-custom-fields"
             type="button"
             type="button"
             onClick={() => setTab('customFields')}
             onClick={() => setTab('customFields')}
             className={cn(
             className={cn(
@@ -351,6 +352,7 @@ export function InvoiceSettings({
         <ReadOnlyWrapper>
         <ReadOnlyWrapper>
           <div className="space-y-6">
           <div className="space-y-6">
             <AppCard
             <AppCard
+              id="invoice-numbering"
               icon={Hash}
               icon={Hash}
               title={t('invoice.numberingTitle')}
               title={t('invoice.numberingTitle')}
               description={t('invoice.numberingDescription')}
               description={t('invoice.numberingDescription')}
@@ -434,6 +436,7 @@ export function InvoiceSettings({
             </AppCard>
             </AppCard>
 
 
             <AppCard
             <AppCard
+              id="invoice-documents"
               icon={FileText}
               icon={FileText}
               title={t('invoice.documentsTitle')}
               title={t('invoice.documentsTitle')}
               description={t('invoice.documentsDescription')}
               description={t('invoice.documentsDescription')}
@@ -462,6 +465,7 @@ export function InvoiceSettings({
             </AppCard>
             </AppCard>
 
 
             <AppCard
             <AppCard
+              id="invoice-shop-fee"
               icon={Receipt}
               icon={Receipt}
               title={t('invoice.shopFee.title')}
               title={t('invoice.shopFee.title')}
               description={t('invoice.shopFee.description')}
               description={t('invoice.shopFee.description')}
@@ -572,6 +576,7 @@ export function InvoiceSettings({
             </AppCard>
             </AppCard>
 
 
             <AppCard
             <AppCard
+              id="invoice-lock"
               icon={Lock}
               icon={Lock}
               title={t('invoice.lockTitle')}
               title={t('invoice.lockTitle')}
               description={t('invoice.lockDescription')}
               description={t('invoice.lockDescription')}
@@ -670,7 +675,7 @@ export function InvoiceSettings({
               </div>
               </div>
 
 
               {unfrozen > 0 && (
               {unfrozen > 0 && (
-                <div className="rounded-md border p-3">
+                <div id="invoice-freeze-legacy" className="rounded-md border p-3">
                   <h4 className="text-sm font-medium">{t('invoice.freezeTitle')}</h4>
                   <h4 className="text-sm font-medium">{t('invoice.freezeTitle')}</h4>
                   <div className="mt-1.5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
                   <div className="mt-1.5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
                     <p className="text-xs leading-relaxed text-muted-foreground">
                     <p className="text-xs leading-relaxed text-muted-foreground">
@@ -699,6 +704,7 @@ export function InvoiceSettings({
 
 
             {issuedInvoices > 0 && (
             {issuedInvoices > 0 && (
               <AppCard
               <AppCard
+                id="invoice-reapply-design"
                 icon={Palette}
                 icon={Palette}
                 title={t('invoice.reapplyTitle')}
                 title={t('invoice.reapplyTitle')}
                 description={t('invoice.reapplyDescription')}
                 description={t('invoice.reapplyDescription')}

+ 6 - 0
src/app/(authenticated)/settings/layout.tsx

@@ -10,6 +10,8 @@ import { getCachedMembership } from '@/lib/cached-session'
 import { hasPermission, PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { hasPermission, PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { getTranslations } from 'next-intl/server'
 import { getTranslations } from 'next-intl/server'
 import { newSettingsEntries } from '@/features/settings/Lib/featureHints'
 import { newSettingsEntries } from '@/features/settings/Lib/featureHints'
+import { SettingsSpotlight } from '@/features/settings/Components/SettingsSpotlight'
+import { Suspense } from 'react'
 
 
 export default async function SettingsLayout({ children }: { children: React.ReactNode }) {
 export default async function SettingsLayout({ children }: { children: React.ReactNode }) {
   const data = await getLayoutData()
   const data = await getLayoutData()
@@ -50,6 +52,10 @@ export default async function SettingsLayout({ children }: { children: React.Rea
 
 
   return (
   return (
     <SettingsPermissionProvider canEdit={canEditSettings}>
     <SettingsPermissionProvider canEdit={canEditSettings}>
+      {/* Lands a deep link from search on its card or field. */}
+      <Suspense fallback={null}>
+        <SettingsSpotlight />
+      </Suspense>
       <div className="flex h-svh flex-col">
       <div className="flex h-svh flex-col">
         <PageHeader />
         <PageHeader />
         <div className="flex min-h-0 flex-1 flex-col gap-4 p-4 pt-0">
         <div className="flex min-h-0 flex-1 flex-col gap-4 p-4 pt-0">

+ 22 - 7
src/app/(authenticated)/settings/localization/localization-settings.tsx

@@ -214,9 +214,14 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
       <ReadOnlyBanner />
       <ReadOnlyBanner />
 
 
       {/* Language */}
       {/* Language */}
-      <AppCard icon={Globe} title={t('localization.languageTitle')} contentClassName="space-y-4">
+      <AppCard
+        id="localization-language"
+        icon={Globe}
+        title={t('localization.languageTitle')}
+        contentClassName="space-y-4"
+      >
         <p className="text-sm text-muted-foreground">{t('localization.languageDescription')}</p>
         <p className="text-sm text-muted-foreground">{t('localization.languageDescription')}</p>
-        <div className="space-y-2">
+        <div id="localization-workshop-language" className="space-y-2">
           <Label>{t('localization.language')}</Label>
           <Label>{t('localization.language')}</Label>
           <Select value={workshopLocale} onValueChange={handleLanguageChange}>
           <Select value={workshopLocale} onValueChange={handleLanguageChange}>
             <SelectTrigger className="w-64">
             <SelectTrigger className="w-64">
@@ -234,7 +239,7 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
 
 
         <Separator />
         <Separator />
 
 
-        <div className="flex items-center justify-between">
+        <div id="localization-force-customer-locale" className="flex items-center justify-between">
           <div className="pr-4">
           <div className="pr-4">
             <Label>{t('localization.forceCustomerLocale')}</Label>
             <Label>{t('localization.forceCustomerLocale')}</Label>
             <p className="text-xs text-muted-foreground">
             <p className="text-xs text-muted-foreground">
@@ -276,12 +281,17 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
       </AppCard>
       </AppCard>
 
 
       {/* Currency */}
       {/* Currency */}
-      <AppCard icon={Coins} title={t('currency.title')} contentClassName="space-y-6">
+      <AppCard
+        id="localization-currency"
+        icon={Coins}
+        title={t('currency.title')}
+        contentClassName="space-y-6"
+      >
         <p className="text-sm text-muted-foreground">{t('currency.description')}</p>
         <p className="text-sm text-muted-foreground">{t('currency.description')}</p>
 
 
         <ReadOnlyWrapper>
         <ReadOnlyWrapper>
           <div className="space-y-6">
           <div className="space-y-6">
-            <div className="space-y-2">
+            <div id="localization-currency-code" className="space-y-2">
               <Label>{t('currency.currencyLabel')}</Label>
               <Label>{t('currency.currencyLabel')}</Label>
               <Popover open={currencyOpen} onOpenChange={setCurrencyOpen}>
               <Popover open={currencyOpen} onOpenChange={setCurrencyOpen}>
                 <PopoverTrigger asChild>
                 <PopoverTrigger asChild>
@@ -328,7 +338,7 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
               </Popover>
               </Popover>
             </div>
             </div>
 
 
-            <div className="space-y-2">
+            <div id="localization-currency-format" className="space-y-2">
               <Label>{t('currency.formatTitle')}</Label>
               <Label>{t('currency.formatTitle')}</Label>
               <Select
               <Select
                 value={currencyFormat}
                 value={currencyFormat}
@@ -367,7 +377,12 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
       </AppCard>
       </AppCard>
 
 
       {/* Date & Time */}
       {/* Date & Time */}
-      <AppCard icon={Calendar} title={t('appearance.dateTimeTitle')} contentClassName="space-y-6">
+      <AppCard
+        id="localization-date-time"
+        icon={Calendar}
+        title={t('appearance.dateTimeTitle')}
+        contentClassName="space-y-6"
+      >
         <p className="text-sm text-muted-foreground">{t('appearance.dateTimeDescription')}</p>
         <p className="text-sm text-muted-foreground">{t('appearance.dateTimeDescription')}</p>
 
 
         <ReadOnlyWrapper>
         <ReadOnlyWrapper>

+ 6 - 1
src/app/(authenticated)/settings/maintenance/maintenance-settings.tsx

@@ -49,7 +49,12 @@ export function MaintenanceSettings({ settings }: { settings: Record<string, str
     <div className="space-y-6">
     <div className="space-y-6">
       <ReadOnlyBanner />
       <ReadOnlyBanner />
       <ReadOnlyWrapper>
       <ReadOnlyWrapper>
-        <AppCard icon={Gauge} title={t('maintenance.title')} contentClassName="space-y-6">
+        <AppCard
+          id="maintenance-predicted"
+          icon={Gauge}
+          title={t('maintenance.title')}
+          contentClassName="space-y-6"
+        >
           <p className="text-sm text-muted-foreground">{t('maintenance.description')}</p>
           <p className="text-sm text-muted-foreground">{t('maintenance.description')}</p>
 
 
           <div className="flex items-center justify-between">
           <div className="flex items-center justify-between">

+ 14 - 3
src/app/(authenticated)/settings/payment/payment-settings.tsx

@@ -89,6 +89,7 @@ export function PaymentSettings({
       <ReadOnlyBanner />
       <ReadOnlyBanner />
       <ReadOnlyWrapper>
       <ReadOnlyWrapper>
         <AppCard
         <AppCard
+          id="payment-details"
           icon={Banknote}
           icon={Banknote}
           title={t('payment.detailsTitle')}
           title={t('payment.detailsTitle')}
           action={
           action={
@@ -129,7 +130,12 @@ export function PaymentSettings({
           </div>
           </div>
         </AppCard>
         </AppCard>
 
 
-        <AppCard icon={CreditCard} title={t('payment.onlinePayments')} contentClassName="space-y-4">
+        <AppCard
+          id="payment-online"
+          icon={CreditCard}
+          title={t('payment.onlinePayments')}
+          contentClassName="space-y-4"
+        >
           <p className="text-sm text-muted-foreground">{t('payment.onlinePaymentsDescription')}</p>
           <p className="text-sm text-muted-foreground">{t('payment.onlinePaymentsDescription')}</p>
 
 
           <ul className="divide-y rounded-lg border">
           <ul className="divide-y rounded-lg border">
@@ -163,7 +169,12 @@ export function PaymentSettings({
           <p className="text-xs text-muted-foreground">{t('payment.onlinePaymentsHint')}</p>
           <p className="text-xs text-muted-foreground">{t('payment.onlinePaymentsHint')}</p>
         </AppCard>
         </AppCard>
 
 
-        <AppCard icon={FileText} title={t('payment.termsOfSale')} contentClassName="space-y-4">
+        <AppCard
+          id="payment-terms-of-sale"
+          icon={FileText}
+          title={t('payment.termsOfSale')}
+          contentClassName="space-y-4"
+        >
           <p className="text-sm text-muted-foreground">{t('payment.termsOfSaleDescription')}</p>
           <p className="text-sm text-muted-foreground">{t('payment.termsOfSaleDescription')}</p>
 
 
           <div className="space-y-2">
           <div className="space-y-2">
@@ -192,7 +203,7 @@ export function PaymentSettings({
           </div>
           </div>
 
 
           {!termsOfSaleUrl && termsOfSale && (
           {!termsOfSaleUrl && termsOfSale && (
-            <div className="space-y-1">
+            <div id="payment-public-terms-url" className="space-y-1">
               <Label className="text-xs text-muted-foreground">{t('payment.publicTermsUrl')}</Label>
               <Label className="text-xs text-muted-foreground">{t('payment.publicTermsUrl')}</Label>
               <div className="flex items-center gap-2">
               <div className="flex items-center gap-2">
                 <code className="flex-1 rounded bg-muted px-3 py-2 text-xs break-all">
                 <code className="flex-1 rounded bg-muted px-3 py-2 text-xs break-all">

+ 2 - 2
src/app/(authenticated)/settings/report-schedule/report-schedule-settings.tsx

@@ -593,7 +593,7 @@ export function ReportScheduleSettings({ schedules, members }: Props) {
           <h2 className="text-lg font-semibold">{t('title')}</h2>
           <h2 className="text-lg font-semibold">{t('title')}</h2>
           <p className="text-sm text-muted-foreground">{t('description')}</p>
           <p className="text-sm text-muted-foreground">{t('description')}</p>
         </div>
         </div>
-        <Button size="sm" onClick={openNew}>
+        <Button id="report-schedule-new" size="sm" onClick={openNew}>
           <Plus className="mr-1.5 h-3.5 w-3.5" />
           <Plus className="mr-1.5 h-3.5 w-3.5" />
           {t('newSchedule')}
           {t('newSchedule')}
         </Button>
         </Button>
@@ -613,7 +613,7 @@ export function ReportScheduleSettings({ schedules, members }: Props) {
           </CardContent>
           </CardContent>
         </Card>
         </Card>
       ) : (
       ) : (
-        <div className="grid gap-4 sm:grid-cols-2">
+        <div id="report-schedule-list" className="grid gap-4 sm:grid-cols-2">
           {schedules.map((schedule) => (
           {schedules.map((schedule) => (
             <ScheduleCard
             <ScheduleCard
               key={schedule.id}
               key={schedule.id}

+ 195 - 155
src/app/(authenticated)/settings/settings-nav.tsx

@@ -1,8 +1,10 @@
 'use client'
 'use client'
 
 
+import { useEffect, useMemo, useRef, useState } from 'react'
 import Link from 'next/link'
 import Link from 'next/link'
 import { usePathname, useRouter } from 'next/navigation'
 import { usePathname, useRouter } from 'next/navigation'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
+import { Search, X } from 'lucide-react'
 import { cn } from '@/lib/utils'
 import { cn } from '@/lib/utils'
 import type { PlanFeatures } from '@/lib/features'
 import type { PlanFeatures } from '@/lib/features'
 import {
 import {
@@ -13,127 +15,180 @@ import {
   SelectValue,
   SelectValue,
 } from '@/components/ui/select'
 } from '@/components/ui/select'
 import {
 import {
-  Banknote,
-  Building2,
-  CalendarClock,
-  CreditCard,
-  Database,
-  Disc3,
-  Download,
-  FileText,
-  Gauge,
-  Globe,
-  Info,
-  Key,
-  Layout,
-  Mail,
-  Palette,
-  Percent,
-  UserCog,
-  UsersRound,
-  Webhook,
-  Wrench,
-  BellRing,
-  LifeBuoy,
-  Plug,
-  ClipboardCheck,
-  ShieldCheck,
-  ListChecks,
-} from 'lucide-react'
-
-type SettingsNavItem = {
-  key: string
-  href: string
-  icon: React.ComponentType<{ className?: string }>
-  gate?: keyof PlanFeatures
-  cloudOnly?: boolean
-  selfHostedOnly?: boolean
+  SETTINGS_CATEGORIES,
+  type SettingsPage,
+  visibleSettingsPages,
+} from '@/features/settings/Lib/settingsPages'
+import { SETTINGS_PAGE_ICONS } from '@/features/settings/Lib/settingsPageIcons'
+import { requestSpotlight } from '@/features/settings/Components/SettingsSpotlight'
+import { useSettingsSearchItems } from '@/features/search/Lib/settingsItems'
+import { prepareQuery, rank, splitByRanges } from '@/features/search/Lib/matcher'
+
+type NavProps = {
+  features?: PlanFeatures
+  isCloud?: boolean
+  supportEnabled?: boolean
   /**
   /**
-   * Hidden unless the platform has switched support requests on. Implies cloud
-   * already, since the flag is only ever true in cloud mode.
+   * Paths that wear a "New" pill, decided on the server. Recently shipped and
+   * older than this workshop, so the pill is news to whoever reads it.
    */
    */
-  supportOnly?: boolean
+  newItems?: string[]
+  mobile?: boolean
 }
 }
 
 
-type SettingsCategory = {
-  key: string
-  items: SettingsNavItem[]
-}
+const RESULT_LIMIT = 12
+
+/**
+ * The box above the settings menu. Typing narrows the whole of settings,
+ * fields included, to a short list that opens the page and lands on the
+ * thing, so nobody has to guess which page "logo" or "due days" is under.
+ * Arrow keys move, Enter opens, Escape clears.
+ */
+function SettingsSearch({
+  features,
+  isCloud,
+  supportEnabled,
+}: Pick<NavProps, 'features' | 'isCloud' | 'supportEnabled'>) {
+  const t = useTranslations('search.settingsSearch')
+  const tSettings = useTranslations('settings')
+  const router = useRouter()
+  const [query, setQuery] = useState('')
+  const [active, setActive] = useState(0)
+  const inputRef = useRef<HTMLInputElement>(null)
+  const items = useSettingsSearchItems({ features, isCloud, supportEnabled })
+
+  const results = useMemo(() => {
+    const q = prepareQuery(query)
+    if (q.tokens.length === 0) return []
+    return rank(q, items, RESULT_LIMIT)
+  }, [query, items])
+
+  // A fresh list starts at the top; the arrow keys walk it from there.
+  useEffect(() => setActive(0), [results])
+
+  const open = (href: string, anchor?: string) => {
+    setQuery('')
+    router.push(href)
+    if (anchor) requestSpotlight(anchor)
+  }
+
+  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
+    if (e.key === 'ArrowDown') {
+      e.preventDefault()
+      setActive((i) => Math.min(i + 1, results.length - 1))
+    } else if (e.key === 'ArrowUp') {
+      e.preventDefault()
+      setActive((i) => Math.max(i - 1, 0))
+    } else if (e.key === 'Enter') {
+      const hit = results[active]
+      if (hit) {
+        e.preventDefault()
+        open(hit.item.href, hit.item.anchor)
+      }
+    } else if (e.key === 'Escape') {
+      setQuery('')
+      inputRef.current?.blur()
+    }
+  }
+
+  const searching = query.trim().length > 0
 
 
-const settingsCategories: SettingsCategory[] = [
-  {
-    key: 'general',
-    items: [
-      { key: 'company', href: '/settings/company', icon: Building2 },
-      { key: 'subscription', href: '/settings/subscription', icon: CreditCard, cloudOnly: true },
-      { key: 'account', href: '/settings/account', icon: UserCog },
-      { key: 'team', href: '/settings/team', icon: UsersRound },
-      { key: 'localization', href: '/settings/localization', icon: Globe },
-      { key: 'appearance', href: '/settings/appearance', icon: Palette },
-      { key: 'support', href: '/settings/support', icon: LifeBuoy, supportOnly: true },
-    ],
-  },
-  {
-    key: 'billing',
-    items: [
-      { key: 'invoice', href: '/settings/invoice', icon: FileText },
-      { key: 'templates', href: '/settings/templates', icon: Layout, gate: 'customTemplates' },
-      { key: 'payment', href: '/settings/payment', icon: Banknote, gate: 'payments' },
-      { key: 'tax', href: '/settings/tax', icon: Percent },
-      { key: 'warranty', href: '/settings/warranty', icon: ShieldCheck },
-    ],
-  },
-  {
-    key: 'communications',
-    items: [
-      {
-        key: 'emailTemplates',
-        href: '/settings/email-templates',
-        icon: Mail,
-        gate: 'customTemplates',
-      },
-      {
-        key: 'customerPortal',
-        href: '/settings/customer-portal',
-        icon: Globe,
-        gate: 'customerPortal',
-      },
-    ],
-  },
-  {
-    key: 'workshop',
-    items: [
-      { key: 'workshop', href: '/settings/workshop', icon: Wrench },
-      { key: 'workOrderStatuses', href: '/settings/work-order-statuses', icon: ListChecks },
-      { key: 'maintenance', href: '/settings/maintenance', icon: Gauge },
-      { key: 'inspectionReminders', href: '/settings/inspection-reminders', icon: ClipboardCheck },
-      { key: 'alerts', href: '/settings/alerts', icon: BellRing },
-      { key: 'tireHotel', href: '/settings/tire-hotel', icon: Disc3, gate: 'tireHotel' },
-      {
-        key: 'reportSchedule',
-        href: '/settings/report-schedule',
-        icon: CalendarClock,
-        gate: 'reports',
-      },
-    ],
-  },
-  {
-    key: 'integrations',
-    items: [
-      { key: 'integrations', href: '/settings/integrations', icon: Plug },
-      { key: 'webhooks', href: '/settings/webhooks', icon: Webhook, gate: 'api' },
-    ],
-  },
-  {
-    key: 'system',
-    items: [
-      { key: 'data', href: '/settings/data', icon: Database },
-      { key: 'install', href: '/settings/install', icon: Download },
-      { key: 'license', href: '/settings/license', icon: Key, selfHostedOnly: true },
-      { key: 'about', href: '/settings/about', icon: Info },
-    ],
-  },
-]
+  return (
+    <div className="relative">
+      <div className="relative">
+        <Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+        <input
+          ref={inputRef}
+          type="search"
+          role="combobox"
+          aria-label={t('label')}
+          aria-expanded={searching}
+          aria-controls="settings-search-results"
+          aria-autocomplete="list"
+          autoComplete="off"
+          value={query}
+          onChange={(e) => setQuery(e.target.value)}
+          onKeyDown={onKeyDown}
+          placeholder={t('placeholder')}
+          data-testid="settings-search"
+          className="h-9 w-full rounded-lg border bg-background pr-8 pl-9 text-sm shadow-xs outline-none transition-[border-color,box-shadow] placeholder:text-muted-foreground focus:border-primary/50 focus:ring-2 focus:ring-primary/15 [&::-webkit-search-cancel-button]:hidden"
+        />
+        {searching && (
+          <button
+            type="button"
+            onClick={() => {
+              setQuery('')
+              inputRef.current?.focus()
+            }}
+            className="absolute top-1/2 right-2 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
+            aria-label={t('label')}
+          >
+            <X className="h-3.5 w-3.5" />
+          </button>
+        )}
+      </div>
+
+      {searching && (
+        <div
+          id="settings-search-results"
+          role="listbox"
+          className="mt-2 overflow-hidden rounded-lg border bg-popover shadow-md"
+        >
+          {results.length === 0 ? (
+            <p className="px-3 py-4 text-center text-xs text-muted-foreground">
+              {t('empty', { query: query.trim() })}
+            </p>
+          ) : (
+            <ul className="max-h-[60vh] overflow-y-auto py-1">
+              {results.map(({ item, match }, i) => {
+                const Icon = item.icon
+                return (
+                  <li key={item.id} role="presentation">
+                    <button
+                      type="button"
+                      role="option"
+                      aria-selected={i === active}
+                      onMouseEnter={() => setActive(i)}
+                      onClick={() => open(item.href, item.anchor)}
+                      className={cn(
+                        'flex w-full cursor-pointer items-center gap-3 px-3 py-2 text-left text-sm transition-colors',
+                        i === active ? 'bg-primary/10 text-foreground' : 'hover:bg-muted'
+                      )}
+                    >
+                      <Icon
+                        className={cn(
+                          'h-4 w-4 shrink-0',
+                          i === active ? 'text-primary' : 'text-muted-foreground'
+                        )}
+                      />
+                      <span className="min-w-0 flex-1">
+                        <span className="block truncate">
+                          {splitByRanges(item.title, match.ranges).map((part, k) =>
+                            part.hit ? (
+                              <mark key={k} className="bg-transparent font-semibold text-primary">
+                                {part.text}
+                              </mark>
+                            ) : (
+                              <span key={k}>{part.text}</span>
+                            )
+                          )}
+                        </span>
+                        <span className="block truncate text-[11px] text-muted-foreground">
+                          {item.breadcrumb}
+                          {item.locked ? ` · ${tSettings('nav.pro')}` : ''}
+                        </span>
+                      </span>
+                    </button>
+                  </li>
+                )
+              })}
+            </ul>
+          )}
+        </div>
+      )}
+    </div>
+  )
+}
 
 
 export function SettingsNav({
 export function SettingsNav({
   features,
   features,
@@ -141,76 +196,61 @@ export function SettingsNav({
   supportEnabled,
   supportEnabled,
   newItems = [],
   newItems = [],
   mobile,
   mobile,
-}: {
-  features?: PlanFeatures
-  isCloud?: boolean
-  supportEnabled?: boolean
-  /**
-   * Paths that wear a "New" pill, decided on the server. Recently shipped and
-   * older than this workshop, so the pill is news to whoever reads it.
-   */
-  newItems?: string[]
-  mobile?: boolean
-}) {
+}: NavProps) {
   const pathname = usePathname()
   const pathname = usePathname()
   const router = useRouter()
   const router = useRouter()
   const t = useTranslations('settings')
   const t = useTranslations('settings')
 
 
-  const newPill = (item: SettingsNavItem) =>
+  const pages = visibleSettingsPages({ isCloud, supportEnabled })
+
+  const newPill = (item: SettingsPage) =>
     newItems.includes(item.href) ? (
     newItems.includes(item.href) ? (
       <span className="shrink-0 rounded bg-primary/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-primary">
       <span className="shrink-0 rounded bg-primary/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-primary">
         {t('nav.new')}
         {t('nav.new')}
       </span>
       </span>
     ) : null
     ) : null
 
 
-  const filterItem = (item: SettingsNavItem) => {
-    if (item.cloudOnly && !isCloud) return false
-    if (item.selfHostedOnly && isCloud) return false
-    if (item.supportOnly && !supportEnabled) return false
-    return true
-  }
-
   if (mobile) {
   if (mobile) {
-    const allItems = settingsCategories.flatMap((c) => c.items.filter(filterItem))
-    const activeItem = allItems.find((item) => pathname === item.href)
+    const activeItem = pages.find((item) => pathname === item.href)
 
 
     return (
     return (
-      <Select value={activeItem?.href ?? pathname} onValueChange={(value) => router.push(value)}>
-        <SelectTrigger className="w-full">
-          <SelectValue placeholder={t('title')} />
-        </SelectTrigger>
-        <SelectContent>
-          {settingsCategories.map((category) => {
-            const visibleItems = category.items.filter(filterItem)
-            if (visibleItems.length === 0) return null
-            return visibleItems.map((item) => (
+      <div className="space-y-3">
+        <SettingsSearch features={features} isCloud={isCloud} supportEnabled={supportEnabled} />
+        <Select value={activeItem?.href ?? pathname} onValueChange={(value) => router.push(value)}>
+          <SelectTrigger className="w-full">
+            <SelectValue placeholder={t('title')} />
+          </SelectTrigger>
+          <SelectContent>
+            {pages.map((item) => (
               <SelectItem key={item.href} value={item.href}>
               <SelectItem key={item.href} value={item.href}>
                 <span className="flex items-center gap-2">
                 <span className="flex items-center gap-2">
                   {t(`nav.items.${item.key}.title`)}
                   {t(`nav.items.${item.key}.title`)}
                   {newPill(item)}
                   {newPill(item)}
                 </span>
                 </span>
               </SelectItem>
               </SelectItem>
-            ))
-          })}
-        </SelectContent>
-      </Select>
+            ))}
+          </SelectContent>
+        </Select>
+      </div>
     )
     )
   }
   }
 
 
   return (
   return (
     <nav className="flex flex-col gap-4">
     <nav className="flex flex-col gap-4">
-      {settingsCategories.map((category) => {
-        const visibleItems = category.items.filter(filterItem)
+      <SettingsSearch features={features} isCloud={isCloud} supportEnabled={supportEnabled} />
+      {SETTINGS_CATEGORIES.map((category) => {
+        const visibleItems = pages.filter((p) => p.category === category)
         if (visibleItems.length === 0) return null
         if (visibleItems.length === 0) return null
         return (
         return (
-          <div key={category.key}>
+          <div key={category}>
             <p className="mb-1 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground/60">
             <p className="mb-1 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground/60">
-              {t(`nav.categories.${category.key}`)}
+              {t(`nav.categories.${category}`)}
             </p>
             </p>
             <div className="flex flex-col gap-0.5">
             <div className="flex flex-col gap-0.5">
               {visibleItems.map((item) => {
               {visibleItems.map((item) => {
                 const isActive = pathname === item.href
                 const isActive = pathname === item.href
                 const isLocked = !!(item.gate && features && !features[item.gate])
                 const isLocked = !!(item.gate && features && !features[item.gate])
+                const Icon = SETTINGS_PAGE_ICONS[item.key]
                 return (
                 return (
                   <Link
                   <Link
                     key={item.href}
                     key={item.href}
@@ -224,7 +264,7 @@ export function SettingsNav({
                           : 'text-muted-foreground hover:bg-muted hover:text-foreground'
                           : 'text-muted-foreground hover:bg-muted hover:text-foreground'
                     )}
                     )}
                   >
                   >
-                    <item.icon className="h-4 w-4 shrink-0" />
+                    <Icon className="h-4 w-4 shrink-0" />
                     <div className="min-w-0 flex-1">
                     <div className="min-w-0 flex-1">
                       <div className="flex items-center gap-2">
                       <div className="flex items-center gap-2">
                         <p className={cn('truncate', isActive && 'font-medium')}>
                         <p className={cn('truncate', isActive && 'font-medium')}>

+ 6 - 1
src/app/(authenticated)/settings/support/page.tsx

@@ -19,7 +19,12 @@ export default async function SupportSettingsPage() {
 
 
   return (
   return (
     <div className="space-y-6">
     <div className="space-y-6">
-      <AppCard icon={Headset} title={t('support.title')} contentClassName="space-y-4">
+      <AppCard
+        id="support-settings"
+        icon={Headset}
+        title={t('support.title')}
+        contentClassName="space-y-4"
+      >
         <p className="text-sm text-muted-foreground">{t('support.description')}</p>
         <p className="text-sm text-muted-foreground">{t('support.description')}</p>
         <SupportSettingsControls />
         <SupportSettingsControls />
       </AppCard>
       </AppCard>

+ 4 - 3
src/app/(authenticated)/settings/tax/tax-settings.tsx

@@ -206,7 +206,7 @@ export function TaxSettings({
 
 
         <ReadOnlyWrapper>
         <ReadOnlyWrapper>
           <div className="space-y-6">
           <div className="space-y-6">
-            <div className="flex items-center justify-between gap-4">
+            <div id="tax-enabled" className="flex items-center justify-between gap-4">
               <div className="space-y-1">
               <div className="space-y-1">
                 <Label>{t('currency.enableTax')}</Label>
                 <Label>{t('currency.enableTax')}</Label>
                 <p className="text-xs text-muted-foreground">{t('currency.enableTaxHint')}</p>
                 <p className="text-xs text-muted-foreground">{t('currency.enableTaxHint')}</p>
@@ -288,7 +288,7 @@ export function TaxSettings({
                   </Select>
                   </Select>
                 </div>
                 </div>
 
 
-                <div className="space-y-2">
+                <div id="tax-components" className="space-y-2">
                   <div className="hidden grid-cols-[1fr_7rem_1fr_2rem] gap-2 text-xs text-muted-foreground sm:grid">
                   <div className="hidden grid-cols-[1fr_7rem_1fr_2rem] gap-2 text-xs text-muted-foreground sm:grid">
                     <span>{t('tax.componentName')}</span>
                     <span>{t('tax.componentName')}</span>
                     <span>{t('tax.componentRate')}</span>
                     <span>{t('tax.componentRate')}</span>
@@ -391,6 +391,7 @@ export function TaxSettings({
 
 
             {taxEnabled && taxSelective && (
             {taxEnabled && taxSelective && (
               <div
               <div
+                id="tax-applies-to"
                 className="space-y-3 rounded-lg border bg-muted/30 p-4"
                 className="space-y-3 rounded-lg border bg-muted/30 p-4"
                 data-testid="tax-applies"
                 data-testid="tax-applies"
               >
               >
@@ -414,7 +415,7 @@ export function TaxSettings({
             )}
             )}
 
 
             {taxEnabled && (
             {taxEnabled && (
-              <div className="space-y-2">
+              <div id="tax-mode" className="space-y-2">
                 <Label>{t('tax.modeLabel')}</Label>
                 <Label>{t('tax.modeLabel')}</Label>
                 <p className="text-xs text-muted-foreground">{t('tax.modeHint')}</p>
                 <p className="text-xs text-muted-foreground">{t('tax.modeHint')}</p>
                 <div className="inline-flex rounded-md border p-0.5">
                 <div className="inline-flex rounded-md border p-0.5">

+ 12 - 4
src/app/(authenticated)/settings/team/team-settings.tsx

@@ -533,6 +533,7 @@ export function TeamSettings({
 
 
       {/* Members Card */}
       {/* Members Card */}
       <AppCard
       <AppCard
+        id="team-members"
         icon={Users}
         icon={Users}
         title={
         title={
           <>
           <>
@@ -548,7 +549,7 @@ export function TeamSettings({
         contentClassName="space-y-4"
         contentClassName="space-y-4"
         action={
         action={
           isAdmin ? (
           isAdmin ? (
-            <Button size="sm" onClick={() => setAdding(true)}>
+            <Button id="team-add-person" size="sm" onClick={() => setAdding(true)}>
               <Plus className="mr-1 h-4 w-4" />
               <Plus className="mr-1 h-4 w-4" />
               {t('team.addPerson')}
               {t('team.addPerson')}
             </Button>
             </Button>
@@ -692,7 +693,7 @@ export function TeamSettings({
             The Add flow creates these, so the page has to show them, or the
             The Add flow creates these, so the page has to show them, or the
             desk adds somebody and watches them vanish. */}
             desk adds somebody and watches them vanish. */}
         {standaloneTechnicians.length > 0 && (
         {standaloneTechnicians.length > 0 && (
-          <div className="space-y-2 border-t pt-4">
+          <div id="team-board-only" className="space-y-2 border-t pt-4">
             <p className="font-medium text-sm">{t('team.boardOnly')}</p>
             <p className="font-medium text-sm">{t('team.boardOnly')}</p>
             <p className="text-muted-foreground text-xs">{t('team.boardOnlyHint')}</p>
             <p className="text-muted-foreground text-xs">{t('team.boardOnlyHint')}</p>
             {standaloneTechnicians.map((tech) => (
             {standaloneTechnicians.map((tech) => (
@@ -738,6 +739,7 @@ export function TeamSettings({
       {/* Pending Invitations Card */}
       {/* Pending Invitations Card */}
       {isAdmin && pendingInvitations.length > 0 && (
       {isAdmin && pendingInvitations.length > 0 && (
         <AppCard
         <AppCard
+          id="team-invitations"
           icon={Mail}
           icon={Mail}
           title={
           title={
             <>
             <>
@@ -808,6 +810,7 @@ export function TeamSettings({
       {/* Custom Roles Card */}
       {/* Custom Roles Card */}
       {isAdmin && (
       {isAdmin && (
         <AppCard
         <AppCard
+          id="team-roles"
           icon={ShieldCheck}
           icon={ShieldCheck}
           title={t('team.customRoles')}
           title={t('team.customRoles')}
           action={
           action={
@@ -826,7 +829,12 @@ export function TeamSettings({
                     {t('team.createDefaultRoles')}
                     {t('team.createDefaultRoles')}
                   </Button>
                   </Button>
                 )}
                 )}
-                <Button size="sm" variant="outline" onClick={() => openRoleForm()}>
+                <Button
+                  id="team-new-role"
+                  size="sm"
+                  variant="outline"
+                  onClick={() => openRoleForm()}
+                >
                   <Plus className="mr-1 h-4 w-4" />
                   <Plus className="mr-1 h-4 w-4" />
                   {t('team.newRole')}
                   {t('team.newRole')}
                 </Button>
                 </Button>
@@ -1032,7 +1040,7 @@ export function TeamSettings({
       )}
       )}
 
 
       {/* Role Descriptions */}
       {/* Role Descriptions */}
-      <AppCard title={t('team.builtInRoles')}>
+      <AppCard id="team-built-in-roles" title={t('team.builtInRoles')}>
         <div className="space-y-3 text-sm">
         <div className="space-y-3 text-sm">
           <div className="flex items-center gap-3">
           <div className="flex items-center gap-3">
             <Badge variant="outline" className={`${roleColors.owner}`}>
             <Badge variant="outline" className={`${roleColors.owner}`}>

+ 12 - 4
src/app/(authenticated)/settings/templates/template-settings.tsx

@@ -227,7 +227,12 @@ function TemplateTab({
   }
   }
 
 
   return (
   return (
-    <AppCard icon={Palette} title={t('templates.presets')} contentClassName="space-y-4">
+    <AppCard
+      id="templates-designs"
+      icon={Palette}
+      title={t('templates.presets')}
+      contentClassName="space-y-4"
+    >
       <p className="text-sm text-muted-foreground">{t('templates.designerIntro')}</p>
       <p className="text-sm text-muted-foreground">{t('templates.designerIntro')}</p>
 
 
       {/* The workshop's own saved designs, above the built-in starting
       {/* The workshop's own saved designs, above the built-in starting
@@ -351,7 +356,10 @@ function TemplateTab({
         })}
         })}
       </div>
       </div>
 
 
-      <div className="flex items-center justify-between gap-4 border-t pt-4">
+      <div
+        id="templates-open-designer"
+        className="flex items-center justify-between gap-4 border-t pt-4"
+      >
         <p className="text-xs text-muted-foreground">{t('templates.designerHint')}</p>
         <p className="text-xs text-muted-foreground">{t('templates.designerHint')}</p>
         <Button asChild variant="outline">
         <Button asChild variant="outline">
           <Link href={`/invoice-designer?doc=${documentType}`}>
           <Link href={`/invoice-designer?doc=${documentType}`}>
@@ -444,7 +452,7 @@ function SmsTemplateTab({
 
 
   return (
   return (
     <div className="space-y-4">
     <div className="space-y-4">
-      <AppCard icon={MessageSquare} title={t('templates.smsTemplates')}>
+      <AppCard id="templates-sms" icon={MessageSquare} title={t('templates.smsTemplates')}>
         <p className="mb-4 text-sm text-muted-foreground">
         <p className="mb-4 text-sm text-muted-foreground">
           {t.rich('templates.smsTemplatesDescription', {
           {t.rich('templates.smsTemplatesDescription', {
             code: (chunks) => (
             code: (chunks) => (
@@ -652,7 +660,7 @@ export function TemplateSettings({
       {/* Two levels: what kind of template, then which document. The page
       {/* Two levels: what kind of template, then which document. The page
           holds three different editors, and the four printed documents are
           holds three different editors, and the four printed documents are
           only one of them. */}
           only one of them. */}
-      <div className="space-y-3">
+      <div id="templates-tabs" className="space-y-3">
         <div
         <div
           role="tablist"
           role="tablist"
           aria-label={t('templates.title')}
           aria-label={t('templates.title')}

+ 9 - 4
src/app/(authenticated)/settings/tire-hotel/tire-hotel-settings.tsx

@@ -116,7 +116,12 @@ export function TireHotelSettings({ settings }: { settings: Record<string, strin
         <DocsLink href="/docs/features/tire-hotel" variant="hint" className="mt-1" />
         <DocsLink href="/docs/features/tire-hotel" variant="hint" className="mt-1" />
       </div>
       </div>
       <ReadOnlyWrapper>
       <ReadOnlyWrapper>
-        <AppCard icon={Warehouse} title={t('tireHotel.cardTitle')} contentClassName="space-y-6">
+        <AppCard
+          id="tire-hotel-storage"
+          icon={Warehouse}
+          title={t('tireHotel.cardTitle')}
+          contentClassName="space-y-6"
+        >
           <div className="flex items-center justify-between">
           <div className="flex items-center justify-between">
             <div className="space-y-0.5 pr-4">
             <div className="space-y-0.5 pr-4">
               <Label htmlFor="tireHotelEnabled">{t('tireHotel.enable')}</Label>
               <Label htmlFor="tireHotelEnabled">{t('tireHotel.enable')}</Label>
@@ -161,7 +166,7 @@ export function TireHotelSettings({ settings }: { settings: Record<string, strin
 
 
               <Separator />
               <Separator />
 
 
-              <div>
+              <div id="tire-hotel-tread">
                 <p className="text-sm font-medium">{t('tireHotel.treadTitle')}</p>
                 <p className="text-sm font-medium">{t('tireHotel.treadTitle')}</p>
                 <p className="text-xs text-muted-foreground">{t('tireHotel.treadHint')}</p>
                 <p className="text-xs text-muted-foreground">{t('tireHotel.treadHint')}</p>
               </div>
               </div>
@@ -197,7 +202,7 @@ export function TireHotelSettings({ settings }: { settings: Record<string, strin
 
 
               <Separator />
               <Separator />
 
 
-              <div>
+              <div id="tire-hotel-billing">
                 <p className="text-sm font-medium">{t('tireHotel.billingTitle')}</p>
                 <p className="text-sm font-medium">{t('tireHotel.billingTitle')}</p>
                 <p className="text-xs text-muted-foreground">{t('tireHotel.billingHint')}</p>
                 <p className="text-xs text-muted-foreground">{t('tireHotel.billingHint')}</p>
               </div>
               </div>
@@ -220,7 +225,7 @@ export function TireHotelSettings({ settings }: { settings: Record<string, strin
 
 
               <Separator />
               <Separator />
 
 
-              <div>
+              <div id="tire-hotel-prep">
                 <p className="text-sm font-medium">{t('tireHotel.prepTitle')}</p>
                 <p className="text-sm font-medium">{t('tireHotel.prepTitle')}</p>
                 <p className="text-xs text-muted-foreground">{t('tireHotel.prepHint')}</p>
                 <p className="text-xs text-muted-foreground">{t('tireHotel.prepHint')}</p>
               </div>
               </div>

+ 3 - 3
src/app/(authenticated)/settings/warranty/warranty-settings.tsx

@@ -105,7 +105,7 @@ export function WarrantySettings({ settings }: { settings: Record<string, string
 
 
           <Separator />
           <Separator />
 
 
-          <div className="space-y-4">
+          <div id="warranty-included" className="space-y-4">
             <div className="space-y-0.5">
             <div className="space-y-0.5">
               <h3 className="text-sm font-semibold">{t('includedTitle')}</h3>
               <h3 className="text-sm font-semibold">{t('includedTitle')}</h3>
               <p className="text-xs text-muted-foreground">{t('includedHint')}</p>
               <p className="text-xs text-muted-foreground">{t('includedHint')}</p>
@@ -150,7 +150,7 @@ export function WarrantySettings({ settings }: { settings: Record<string, string
 
 
           <Separator />
           <Separator />
 
 
-          <div className="space-y-4">
+          <div id="warranty-not-included" className="space-y-4">
             <div className="space-y-0.5">
             <div className="space-y-0.5">
               <h3 className="text-sm font-semibold">{t('notIncludedTitle')}</h3>
               <h3 className="text-sm font-semibold">{t('notIncludedTitle')}</h3>
               <p className="text-xs text-muted-foreground">{t('notIncludedHint')}</p>
               <p className="text-xs text-muted-foreground">{t('notIncludedHint')}</p>
@@ -170,7 +170,7 @@ export function WarrantySettings({ settings }: { settings: Record<string, string
 
 
           <Separator />
           <Separator />
 
 
-          <div className="space-y-4">
+          <div id="warranty-apply-to" className="space-y-4">
             <h3 className="text-sm font-semibold">{t('applyTitle')}</h3>
             <h3 className="text-sm font-semibold">{t('applyTitle')}</h3>
             <div className="flex items-center justify-between gap-4">
             <div className="flex items-center justify-between gap-4">
               <div className="space-y-0.5">
               <div className="space-y-0.5">

+ 6 - 3
src/app/(authenticated)/settings/webhooks/webhooks-settings.tsx

@@ -129,7 +129,10 @@ export function WebhooksSettings({ webhooks }: Props) {
 
 
   return (
   return (
     <div className="space-y-6">
     <div className="space-y-6">
-      <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
+      <div
+        id="webhooks-header"
+        className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
+      >
         <div>
         <div>
           <h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
           <h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
           <p className="mt-1 max-w-2xl text-sm text-muted-foreground">{t('subtitle')}</p>
           <p className="mt-1 max-w-2xl text-sm text-muted-foreground">{t('subtitle')}</p>
@@ -143,7 +146,7 @@ export function WebhooksSettings({ webhooks }: Props) {
       </div>
       </div>
 
 
       <Tabs value={tab} onValueChange={(v) => setTab(v as 'webhooks' | 'reference')}>
       <Tabs value={tab} onValueChange={(v) => setTab(v as 'webhooks' | 'reference')}>
-        <TabsList>
+        <TabsList id="webhooks-tabs">
           <TabsTrigger value="webhooks">
           <TabsTrigger value="webhooks">
             <WebhookIcon className="mr-2 h-4 w-4" />
             <WebhookIcon className="mr-2 h-4 w-4" />
             {t('tabWebhooks')}
             {t('tabWebhooks')}
@@ -1168,7 +1171,7 @@ function EventsReference() {
 function SignatureHelpCard() {
 function SignatureHelpCard() {
   const t = useTranslations('settings.webhooks')
   const t = useTranslations('settings.webhooks')
   return (
   return (
-    <Card>
+    <Card id="webhooks-signatures">
       <CardContent className="space-y-3 p-4 text-sm">
       <CardContent className="space-y-3 p-4 text-sm">
         <h2 className="font-semibold">{t('helpTitle')}</h2>
         <h2 className="font-semibold">{t('helpTitle')}</h2>
         <p className="text-muted-foreground">{t('helpIntro')}</p>
         <p className="text-muted-foreground">{t('helpIntro')}</p>

+ 14 - 7
src/app/(authenticated)/settings/workshop/workshop-settings.tsx

@@ -225,12 +225,19 @@ export function WorkshopSettings({
     <div className="space-y-6">
     <div className="space-y-6">
       <ReadOnlyBanner />
       <ReadOnlyBanner />
       <ReadOnlyWrapper>
       <ReadOnlyWrapper>
-        <ServiceTypeSelector serviceType={serviceType} onServiceTypeChange={setServiceType} />
-        <AppCard icon={Wrench} title={t('workshop.title')} contentClassName="space-y-6">
+        <div id="workshop-service-type">
+          <ServiceTypeSelector serviceType={serviceType} onServiceTypeChange={setServiceType} />
+        </div>
+        <AppCard
+          id="workshop-defaults"
+          icon={Wrench}
+          title={t('workshop.title')}
+          contentClassName="space-y-6"
+        >
           <p className="text-sm text-muted-foreground">{t('workshop.description')}</p>
           <p className="text-sm text-muted-foreground">{t('workshop.description')}</p>
 
 
           <div className="grid gap-4 sm:grid-cols-2">
           <div className="grid gap-4 sm:grid-cols-2">
-            <div className="space-y-2">
+            <div id="workshop-default-technician" className="space-y-2">
               <Label>{t('workshop.defaultTechnician')}</Label>
               <Label>{t('workshop.defaultTechnician')}</Label>
               <Popover open={techOpen} onOpenChange={setTechOpen} modal={true}>
               <Popover open={techOpen} onOpenChange={setTechOpen} modal={true}>
                 <PopoverTrigger asChild>
                 <PopoverTrigger asChild>
@@ -391,7 +398,7 @@ export function WorkshopSettings({
           {/* What a job is called before anybody types a title. Resolved on
           {/* What a job is called before anybody types a title. Resolved on
               the server when the draft is made, so the preview here uses
               the server when the draft is made, so the preview here uses
               sample values; the rules are in workOrderTitle.ts. */}
               sample values; the rules are in workOrderTitle.ts. */}
-          <div className="space-y-4">
+          <div id="workshop-title-template" className="space-y-4">
             <div className="flex flex-row items-center gap-3">
             <div className="flex flex-row items-center gap-3">
               <Tag className="h-5 w-5 text-muted-foreground" />
               <Tag className="h-5 w-5 text-muted-foreground" />
               <h3 className="text-lg font-semibold">{t('workshop.titleTemplateTitle')}</h3>
               <h3 className="text-lg font-semibold">{t('workshop.titleTemplateTitle')}</h3>
@@ -443,7 +450,7 @@ export function WorkshopSettings({
           {/* Pricing, not paperwork: the markup decides what a part costs the
           {/* Pricing, not paperwork: the markup decides what a part costs the
               customer, which is settled here on the job, long before an
               customer, which is settled here on the job, long before an
               invoice exists. */}
               invoice exists. */}
-          <div className="space-y-4">
+          <div id="workshop-parts-markup" className="space-y-4">
             <div className="flex flex-row items-center gap-3">
             <div className="flex flex-row items-center gap-3">
               <Percent className="h-5 w-5 text-muted-foreground" />
               <Percent className="h-5 w-5 text-muted-foreground" />
               <h3 className="text-lg font-semibold">{t('workshop.partsMarkupTitle')}</h3>
               <h3 className="text-lg font-semibold">{t('workshop.partsMarkupTitle')}</h3>
@@ -514,7 +521,7 @@ export function WorkshopSettings({
 
 
           <Separator />
           <Separator />
 
 
-          <div className="space-y-4">
+          <div id="workshop-units" className="space-y-4">
             <div className="flex flex-row items-center gap-3">
             <div className="flex flex-row items-center gap-3">
               <Ruler className="h-5 w-5 text-muted-foreground" />
               <Ruler className="h-5 w-5 text-muted-foreground" />
               <h3 className="text-lg font-semibold">{t('workshop.unitsTitle')}</h3>
               <h3 className="text-lg font-semibold">{t('workshop.unitsTitle')}</h3>
@@ -522,7 +529,7 @@ export function WorkshopSettings({
             <p className="text-sm text-muted-foreground">{t('workshop.unitsDescription')}</p>
             <p className="text-sm text-muted-foreground">{t('workshop.unitsDescription')}</p>
 
 
             <div className="grid gap-4 sm:grid-cols-2">
             <div className="grid gap-4 sm:grid-cols-2">
-              <div className="space-y-2">
+              <div id="workshop-unit-system" className="space-y-2">
                 <Label htmlFor="unitSystem">{t('workshop.distanceLabel')}</Label>
                 <Label htmlFor="unitSystem">{t('workshop.distanceLabel')}</Label>
                 <Select value={unitSystem} onValueChange={setUnitSystem}>
                 <Select value={unitSystem} onValueChange={setUnitSystem}>
                   <SelectTrigger id="unitSystem" className="w-full sm:w-56">
                   <SelectTrigger id="unitSystem" className="w-full sm:w-56">

+ 58 - 0
src/app/globals.css

@@ -784,3 +784,61 @@ html body[data-scroll-locked] {
   color: var(--muted-foreground);
   color: var(--muted-foreground);
   box-shadow: inset 0 0 0 1px var(--border);
   box-shadow: inset 0 0 0 1px var(--border);
 }
 }
+
+/* Search palette and settings deep links.
+
+   The spotlight is what a deep link from the palette lands on: the card or
+   the field grows a ring in the theme colour that fades over a couple of
+   seconds, so the eye is taken to the thing searched for rather than left at
+   the top of the page. The progress line sits under the palette's input
+   while the workshop's records are being fetched; local matches arrive on
+   the keystroke itself and never wait for it. */
+@keyframes settings-spotlight {
+  0% {
+    box-shadow: 0 0 0 0 color-mix(in oklab, var(--primary) 0%, transparent);
+  }
+  20% {
+    box-shadow:
+      0 0 0 3px color-mix(in oklab, var(--primary) 55%, transparent),
+      0 0 28px 6px color-mix(in oklab, var(--primary) 22%, transparent);
+  }
+  100% {
+    box-shadow:
+      0 0 0 3px color-mix(in oklab, var(--primary) 0%, transparent),
+      0 0 0 0 color-mix(in oklab, var(--primary) 0%, transparent);
+  }
+}
+
+.settings-spotlight {
+  animation: settings-spotlight 2.4s cubic-bezier(0.2, 0.7, 0.2, 1) 1;
+  border-radius: max(var(--radius), 6px);
+}
+
+@keyframes search-progress {
+  0% {
+    transform: translateX(-100%);
+  }
+  100% {
+    transform: translateX(300%);
+  }
+}
+
+.search-progress {
+  position: absolute;
+  inset-inline: 0;
+  bottom: -1px;
+  height: 2px;
+  overflow: hidden;
+  pointer-events: none;
+}
+
+.search-progress::after {
+  content: '';
+  position: absolute;
+  inset-block: 0;
+  left: 0;
+  width: 33%;
+  border-radius: 999px;
+  background: linear-gradient(90deg, transparent, var(--primary), transparent);
+  animation: search-progress 1.1s ease-in-out infinite;
+}

+ 5 - 1
src/components/app-card.tsx

@@ -28,7 +28,10 @@ export function AppCard({
   children,
   children,
   className,
   className,
   contentClassName,
   contentClassName,
+  id,
 }: {
 }: {
+  /** Anchor for deep links from search; the card scrolls into view and lights up. */
+  id?: string
   icon?: ComponentType<{ className?: string }>
   icon?: ComponentType<{ className?: string }>
   title: ReactNode
   title: ReactNode
   /** Small count pill after the title. Pass `list.length || undefined` to hide zeros. */
   /** Small count pill after the title. Pass `list.length || undefined` to hide zeros. */
@@ -48,9 +51,10 @@ export function AppCard({
 }) {
 }) {
   return (
   return (
     <div
     <div
+      id={id}
       data-slot="card"
       data-slot="card"
       className={cn(
       className={cn(
-        'group/card relative flex flex-col overflow-hidden rounded-lg border border-card-edge bg-card text-card-foreground',
+        'group/card relative flex flex-col overflow-hidden rounded-lg border border-card-edge bg-card text-card-foreground scroll-mt-4',
         // Two shadows instead of one: a crisp 1px seat and a soft, low drop.
         // Two shadows instead of one: a crisp 1px seat and a soft, low drop.
         // Reads as depth rather than the flat grey smudge of shadow-sm.
         // Reads as depth rather than the flat grey smudge of shadow-sm.
         'shadow-[0_1px_2px_rgb(0_0_0/0.05),0_12px_32px_-16px_rgb(0_0_0/0.18)]',
         'shadow-[0_1px_2px_rgb(0_0_0/0.05),0_12px_32px_-16px_rgb(0_0_0/0.18)]',

+ 9 - 5
src/features/portal/Components/CustomerPortalSettings.tsx

@@ -262,7 +262,7 @@ export function CustomerPortalSettings({
                 <p className="text-xs text-muted-foreground">{t('portal.hoursHint')}</p>
                 <p className="text-xs text-muted-foreground">{t('portal.hoursHint')}</p>
               </div>
               </div>
 
 
-              <div className="space-y-2">
+              <div id="customer-portal-url" className="space-y-2">
                 <Label>{t('portal.portalUrl')}</Label>
                 <Label>{t('portal.portalUrl')}</Label>
                 <div className="flex gap-2">
                 <div className="flex gap-2">
                   <Input value={portalUrl} readOnly className="font-mono text-sm" />
                   <Input value={portalUrl} readOnly className="font-mono text-sm" />
@@ -282,7 +282,10 @@ export function CustomerPortalSettings({
               {/* Requests are submitted here but the alert that announces them
               {/* Requests are submitted here but the alert that announces them
                     is configured on another page. Without a pointer, an operator
                     is configured on another page. Without a pointer, an operator
                     turning the portal on has no reason to go looking for it. */}
                     turning the portal on has no reason to go looking for it. */}
-              <div className="flex items-start gap-2 rounded-md border bg-muted/40 p-3">
+              <div
+                id="customer-portal-request-alerts"
+                className="flex items-start gap-2 rounded-md border bg-muted/40 p-3"
+              >
                 <Bell className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
                 <Bell className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
                 <p className="text-xs text-muted-foreground">
                 <p className="text-xs text-muted-foreground">
                   {t('portal.serviceRequestAlertsHint')}{' '}
                   {t('portal.serviceRequestAlertsHint')}{' '}
@@ -297,13 +300,14 @@ export function CustomerPortalSettings({
 
 
         {enabled && (
         {enabled && (
           <AppCard
           <AppCard
+            id="customer-portal-background"
             icon={ImageIcon}
             icon={ImageIcon}
             title={t('portal.background.title')}
             title={t('portal.background.title')}
             description={t('portal.background.description')}
             description={t('portal.background.description')}
             contentClassName="space-y-6"
             contentClassName="space-y-6"
           >
           >
             {/* Type selector */}
             {/* Type selector */}
-            <div className="grid grid-cols-3 gap-2">
+            <div id="customer-portal-background-type" className="grid grid-cols-3 gap-2">
               {(
               {(
                 [
                 [
                   { id: 'none', label: t('portal.background.typeNone') },
                   { id: 'none', label: t('portal.background.typeNone') },
@@ -329,7 +333,7 @@ export function CustomerPortalSettings({
 
 
             {/* Template grid */}
             {/* Template grid */}
             {backgroundType === 'template' && (
             {backgroundType === 'template' && (
-              <div className="space-y-2">
+              <div id="customer-portal-background-template" className="space-y-2">
                 <Label>{t('portal.background.chooseTemplate')}</Label>
                 <Label>{t('portal.background.chooseTemplate')}</Label>
                 <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
                 <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
                   {PORTAL_BACKGROUND_TEMPLATES.map((tpl) => {
                   {PORTAL_BACKGROUND_TEMPLATES.map((tpl) => {
@@ -365,7 +369,7 @@ export function CustomerPortalSettings({
 
 
             {/* Image upload */}
             {/* Image upload */}
             {backgroundType === 'image' && (
             {backgroundType === 'image' && (
-              <div className="space-y-3">
+              <div id="customer-portal-background-image" className="space-y-3">
                 <Label>{t('portal.background.uploadLabel')}</Label>
                 <Label>{t('portal.background.uploadLabel')}</Label>
                 <div className="flex items-center gap-4">
                 <div className="flex items-center gap-4">
                   <div className="relative h-24 w-40 shrink-0 overflow-hidden rounded-lg border bg-muted">
                   <div className="relative h-24 w-40 shrink-0 overflow-hidden rounded-lg border bg-muted">

+ 789 - 484
src/features/search/Components/SearchCommand.tsx

@@ -1,67 +1,78 @@
 'use client'
 'use client'
 
 
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { formatQuantity } from '@/lib/format-quantity'
+import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
 import { useRouter } from 'next/navigation'
 import { useRouter } from 'next/navigation'
-import {
-  CommandDialog,
-  CommandInput,
-  CommandList,
-  CommandEmpty,
-  CommandGroup,
-  CommandItem,
-} from '@/components/ui/command'
 import {
 import {
   Bell,
   Bell,
   Car,
   Car,
   ClipboardCheck,
   ClipboardCheck,
+  CornerDownLeft,
   Disc3,
   Disc3,
   FileText,
   FileText,
+  type LucideIcon,
   Package,
   Package,
-  Settings,
+  Plug,
+  ScanLine,
+  Search,
   Users,
   Users,
   Wrench,
   Wrench,
 } from 'lucide-react'
 } from 'lucide-react'
+import {
+  CommandDialog,
+  CommandGroup,
+  CommandInput,
+  CommandItem,
+  CommandList,
+} from '@/components/ui/command'
+import { formatQuantity } from '@/lib/format-quantity'
+import { cn } from '@/lib/utils'
 import { globalSearch, getRecentCustomers } from '@/features/search/Actions/searchActions'
 import { globalSearch, getRecentCustomers } from '@/features/search/Actions/searchActions'
 import { formatTypeKey } from '@/features/vehicles/Lib/typeKey'
 import { formatTypeKey } from '@/features/vehicles/Lib/typeKey'
 import { useTypeKeyEnabled } from '@/components/type-key-context'
 import { useTypeKeyEnabled } from '@/components/type-key-context'
+import { openPlateLookup, usePlateLookupAccess } from '@/components/plate-lookup-context'
+import { requestSpotlight } from '@/features/settings/Components/SettingsSpotlight'
+import { CRUMB, useSettingsSearchItems } from '@/features/search/Lib/settingsItems'
+import {
+  type CommandContext,
+  visibleActionCommands,
+  visiblePageCommands,
+} from '@/features/search/Lib/commands'
+import {
+  type PreparedItem,
+  type Range,
+  prepareItem,
+  prepareQuery,
+  rank,
+  splitByRanges,
+} from '@/features/search/Lib/matcher'
+import { pushRecent, readRecent, type RecentPick } from '@/features/search/Lib/recent'
+
+type Vehicle = {
+  id: string
+  make: string
+  model: string
+  year: number
+  licensePlate: string | null
+  hsn?: string | null
+  tsn?: string | null
+}
 
 
 interface SearchResult {
 interface SearchResult {
-  vehicles: {
-    id: string
-    make: string
-    model: string
-    year: number
-    licensePlate: string | null
-    hsn?: string | null
-    tsn?: string | null
-  }[]
+  vehicles: Vehicle[]
   customers: {
   customers: {
     id: string
     id: string
     name: string
     name: string
     email: string | null
     email: string | null
     phone: string | null
     phone: string | null
     company: string | null
     company: string | null
-    vehicles: {
-      id: string
-      make: string
-      model: string
-      year: number
-      licensePlate: string | null
-    }[]
+    vehicles: Vehicle[]
   }[]
   }[]
   services: {
   services: {
     id: string
     id: string
     title: string
     title: string
     invoiceNumber: string | null
     invoiceNumber: string | null
-    vehicle: {
-      id: string
-      make: string
-      model: string
-      year: number
-      licensePlate: string | null
-    } | null
+    vehicle: Vehicle | null
   }[]
   }[]
   parts: {
   parts: {
     id: string
     id: string
@@ -82,26 +93,14 @@ interface SearchResult {
     dueDate: Date | null
     dueDate: Date | null
     isCompleted: boolean
     isCompleted: boolean
     customer: { id: string; name: string } | null
     customer: { id: string; name: string } | null
-    vehicle: {
-      id: string
-      make: string
-      model: string
-      year: number
-      licensePlate: string | null
-    } | null
+    vehicle: Vehicle | null
   }[]
   }[]
   inspections: {
   inspections: {
     id: string
     id: string
     status: string
     status: string
     createdAt: Date
     createdAt: Date
     template: { name: string }
     template: { name: string }
-    vehicle: {
-      id: string
-      make: string
-      model: string
-      year: number
-      licensePlate: string | null
-    }
+    vehicle: Vehicle
   }[]
   }[]
   tireSets: {
   tireSets: {
     id: string
     id: string
@@ -112,97 +111,19 @@ interface SearchResult {
     status: string
     status: string
     location: { code: string } | null
     location: { code: string } | null
     customer: { id: string; name: string } | null
     customer: { id: string; name: string } | null
-    vehicle: {
-      id: string
-      make: string
-      model: string
-      year: number
-      licensePlate: string | null
-    } | null
+    vehicle: Vehicle | null
   }[]
   }[]
 }
 }
 
 
-/**
- * The settings pages worth reaching from search.
- *
- * Named by key rather than by label, because the settings sidebar already
- * names every one of these in twelve languages. Two lists of the same page
- * names would drift, and the one nobody edits is the one that goes stale.
- *
- * The keywords stay in English on purpose: they are aliases, so that someone
- * typing "vat" or "smtp" still lands on the right page whatever language the
- * app is in. The translated title and description are matched as well.
- */
-const SEARCHABLE_SETTINGS = [
-  {
-    key: 'company',
-    href: '/settings/company',
-    keywords: ['company', 'business', 'branding', 'logo', 'name', 'address'],
-  },
-  {
-    key: 'account',
-    href: '/settings/account',
-    keywords: ['account', 'profile', 'password', 'email', '2fa'],
-  },
-  { key: 'team', href: '/settings/team', keywords: ['team', 'members', 'roles', 'invite'] },
-  {
-    key: 'invoice',
-    href: '/settings/invoice',
-    keywords: ['invoice', 'layout', 'prefix', 'footer', 'due'],
-  },
-  {
-    key: 'templates',
-    href: '/settings/templates',
-    keywords: ['template', 'styling', 'colors', 'font', 'header', 'quote'],
-  },
-  {
-    key: 'payment',
-    href: '/settings/payment',
-    keywords: ['payment', 'bank', 'vipps', 'stripe', 'terms'],
-  },
-  { key: 'tax', href: '/settings/tax', keywords: ['tax', 'vat', 'rate'] },
-  {
-    key: 'warranty',
-    href: '/settings/warranty',
-    keywords: ['warranty', 'guarantee', 'terms', 'default'],
-  },
-  {
-    key: 'localization',
-    href: '/settings/localization',
-    keywords: [
-      'localization',
-      'language',
-      'currency',
-      'locale',
-      'timezone',
-      'date',
-      'time',
-      'unit',
-    ],
-  },
-  { key: 'customFields', href: '/settings/custom-fields', keywords: ['custom fields', 'fields'] },
-  { key: 'email', href: '/settings/email', keywords: ['email', 'mail', 'smtp', 'sending'] },
-  {
-    key: 'workshop',
-    href: '/settings/workshop',
-    keywords: ['workshop', 'technician', 'labor', 'hours'],
-  },
-  {
-    key: 'appearance',
-    href: '/settings/appearance',
-    keywords: ['appearance', 'theme', 'dark', 'light', 'font', 'typeface', 'colour', 'color'],
-  },
-  { key: 'data', href: '/settings/data', keywords: ['data', 'export', 'import', 'backup'] },
-  { key: 'about', href: '/settings/about', keywords: ['about', 'version', 'info'] },
-] as const
-
-function useDebounce<T>(value: T, delay: number): T {
-  const [debounced, setDebounced] = useState(value)
-  useEffect(() => {
-    const timer = setTimeout(() => setDebounced(value), delay)
-    return () => clearTimeout(timer)
-  }, [value, delay])
-  return debounced
+const EMPTY: SearchResult = {
+  vehicles: [],
+  customers: [],
+  services: [],
+  parts: [],
+  quotes: [],
+  reminders: [],
+  inspections: [],
+  tireSets: [],
 }
 }
 
 
 type RecentCustomer = {
 type RecentCustomer = {
@@ -213,29 +134,332 @@ type RecentCustomer = {
   company: string | null
   company: string | null
 }
 }
 
 
-export function SearchCommand() {
+/** A connector the catalog offers, trimmed to what the palette shows. */
+export type SearchConnector = { id: string; name: string; category: string; logo: string }
+
+export type SearchCommandProps = CommandContext & {
+  isCloud?: boolean
+  supportEnabled?: boolean
+  isMarine?: boolean
+  connectors?: SearchConnector[]
+}
+
+/**
+ * Anything the palette knows without asking the server: a settings page or
+ * a setting on it, a connector, a page of the app, or something to start.
+ */
+type LocalItem = {
+  kind: 'setting' | 'settingsPage' | 'connector' | 'page' | 'action'
+  id: string
+  href: string
+  title: string
+  subtitle?: string
+  icon?: LucideIcon
+  logo?: string
+  locked?: boolean
+  anchor?: string
+  run?: () => void
+}
+
+type PreparedLocal = { item: LocalItem; prepared: PreparedItem }
+
+/** How many to show per group when searching. Everything else is a scroll away in the app itself. */
+const LIMITS: Record<LocalItem['kind'], number> = {
+  setting: 8,
+  settingsPage: 8,
+  connector: 4,
+  page: 5,
+  action: 4,
+}
+
+/** Enough recent customers to be useful before typing, few enough that the actions stay in view. */
+const RECENT_CUSTOMERS_SHOWN = 8
+
+/** Wait this long after the last keystroke before asking the server. Local matches never wait. */
+const REMOTE_DEBOUNCE_MS = 150
+
+function Highlight({ text, ranges }: { text: string; ranges: Range[] }) {
+  return (
+    <>
+      {splitByRanges(text, ranges).map((part, i) =>
+        part.hit ? (
+          <mark key={i} className="bg-transparent font-semibold text-primary">
+            {part.text}
+          </mark>
+        ) : (
+          <Fragment key={i}>{part.text}</Fragment>
+        )
+      )}
+    </>
+  )
+}
+
+function Kbd({ children }: { children: ReactNode }) {
+  return (
+    <kbd className="pointer-events-none inline-flex h-5 min-w-5 items-center justify-center rounded border bg-muted px-1 font-mono text-[10px] font-medium text-muted-foreground">
+      {children}
+    </kbd>
+  )
+}
+
+function Chip({ icon: Icon, logo }: { icon?: LucideIcon; logo?: string }) {
+  return (
+    <span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground ring-1 ring-inset ring-foreground/5 group-data-[selected=true]/row:bg-primary/10 group-data-[selected=true]/row:text-primary group-data-[selected=true]/row:ring-primary/20">
+      {logo ? (
+        <img src={logo} alt="" className="h-4 w-4 object-contain" />
+      ) : Icon ? (
+        <Icon className="h-4! w-4!" />
+      ) : null}
+    </span>
+  )
+}
+
+/**
+ * One row. The chip lights up in the theme colour on the selected row, and
+ * the return-key hint appears there and nowhere else, so the eye always
+ * knows which row Enter will open.
+ */
+function Row({
+  value,
+  onSelect,
+  icon,
+  logo,
+  title,
+  subtitle,
+  trailing,
+  locked,
+  className,
+  proLabel,
+}: {
+  value: string
+  onSelect: () => void
+  icon?: LucideIcon
+  logo?: string
+  title: ReactNode
+  subtitle?: ReactNode
+  /** A mono tag on the right: a plate, a shelf code, an invoice number. */
+  trailing?: ReactNode
+  locked?: boolean
+  className?: string
+  proLabel?: string
+}) {
+  return (
+    <CommandItem
+      value={value}
+      onSelect={onSelect}
+      className={cn('group/row cursor-pointer gap-3 rounded-lg', className)}
+    >
+      <Chip icon={icon} logo={logo} />
+      <div className="min-w-0 flex-1">
+        <div className="flex items-center gap-2">
+          <span className="truncate text-sm">{title}</span>
+          {locked && proLabel && (
+            <span className="shrink-0 rounded bg-amber-500/15 px-1.5 py-px text-[10px] font-semibold text-amber-600">
+              {proLabel}
+            </span>
+          )}
+        </div>
+        {subtitle && <div className="truncate text-xs text-muted-foreground">{subtitle}</div>}
+      </div>
+      {trailing && (
+        <span className="hidden shrink-0 font-mono text-xs text-muted-foreground sm:inline">
+          {trailing}
+        </span>
+      )}
+      <span className="hidden shrink-0 items-center gap-1 text-[11px] text-muted-foreground opacity-0 transition-opacity group-data-[selected=true]/row:opacity-100 sm:flex">
+        <CornerDownLeft className="h-3! w-3!" />
+      </span>
+    </CommandItem>
+  )
+}
+
+function vehicleLine(v: Vehicle) {
+  return `${v.year} ${v.make} ${v.model}`
+}
+
+function iconForHref(href: string): LucideIcon {
+  if (href.startsWith('/customers')) return Users
+  if (href.startsWith('/vehicles')) return Car
+  if (href.startsWith('/quotes')) return FileText
+  if (href.startsWith('/inventory')) return Package
+  if (href.startsWith('/tire-hotel')) return Disc3
+  if (href.startsWith('/inspections')) return ClipboardCheck
+  if (href.startsWith('/reminders')) return Bell
+  if (href.startsWith('/settings')) return Search
+  return Wrench
+}
+
+export function SearchCommand({
+  visibleSubjects,
+  features,
+  tireHotelEnabled,
+  aiEnabled,
+  isAdminOrOwner,
+  isCloud,
+  supportEnabled,
+  isMarine,
+  connectors = [],
+}: SearchCommandProps) {
   const router = useRouter()
   const router = useRouter()
   const t = useTranslations('search')
   const t = useTranslations('search')
   const tNav = useTranslations('navigation.sidebar')
   const tNav = useTranslations('navigation.sidebar')
-  const typeKeyEnabled = useTypeKeyEnabled()
   const tSettings = useTranslations('settings')
   const tSettings = useTranslations('settings')
+  const tIntegrations = useTranslations('integrations')
   const tTire = useTranslations('tireHotel')
   const tTire = useTranslations('tireHotel')
+  const tPlate = useTranslations('vehicles.plateLookup')
+  const typeKeyEnabled = useTypeKeyEnabled()
+  const plateLookup = usePlateLookupAccess()
+
   const [open, setOpen] = useState(false)
   const [open, setOpen] = useState(false)
   const [query, setQuery] = useState('')
   const [query, setQuery] = useState('')
-  const [results, setResults] = useState<SearchResult>({
-    vehicles: [],
-    customers: [],
-    services: [],
-    parts: [],
-    quotes: [],
-    reminders: [],
-    inspections: [],
-    tireSets: [],
-  })
+  const [results, setResults] = useState<SearchResult>(EMPTY)
   const [loading, setLoading] = useState(false)
   const [loading, setLoading] = useState(false)
   const [recentCustomers, setRecentCustomers] = useState<RecentCustomer[]>([])
   const [recentCustomers, setRecentCustomers] = useState<RecentCustomer[]>([])
-  const recentFetchedRef = useRef(false)
-  const debouncedQuery = useDebounce(query, 300)
+  const [recent, setRecent] = useState<RecentPick[]>([])
+  const cacheRef = useRef(new Map<string, SearchResult>())
+  const seqRef = useRef(0)
+
+  const canSettings = !visibleSubjects || visibleSubjects.includes('settings')
+  const ctx: CommandContext = useMemo(
+    () => ({ visibleSubjects, features, tireHotelEnabled, aiEnabled, isAdminOrOwner }),
+    [visibleSubjects, features, tireHotelEnabled, aiEnabled, isAdminOrOwner]
+  )
+
+  const settingsItems = useSettingsSearchItems({ isCloud, supportEnabled, features })
+
+  // Everything searchable without the server, titled in the workshop's
+  // language and prepared once; typing only ranks it.
+  const localItems = useMemo<PreparedLocal[]>(() => {
+    const out: PreparedLocal[] = []
+    const settingsWord = tNav('settings')
+
+    for (const page of visiblePageCommands(ctx)) {
+      const title = tNav(isMarine && page.marineTitleKey ? page.marineTitleKey : page.titleKey)
+      const item: LocalItem = {
+        kind: 'page',
+        id: page.id,
+        href: page.href,
+        title,
+        icon: isMarine && page.marineIcon ? page.marineIcon : page.icon,
+      }
+      out.push({ item, prepared: prepareItem({ title, keywords: page.keywords }) })
+    }
+
+    for (const action of visibleActionCommands(ctx)) {
+      const title = t(`actions.${action.titleKey}`)
+      const item: LocalItem = {
+        kind: 'action',
+        id: action.id,
+        href: action.href,
+        title,
+        icon: action.icon,
+      }
+      out.push({ item, prepared: prepareItem({ title, keywords: action.keywords }) })
+    }
+    if (plateLookup.available) {
+      const title = tPlate('open')
+      out.push({
+        item: {
+          kind: 'action',
+          id: 'action:plate-lookup',
+          href: '#',
+          title,
+          subtitle: plateLookup.registryName ?? undefined,
+          icon: ScanLine,
+          run: () => openPlateLookup(),
+        },
+        prepared: prepareItem({
+          title,
+          keywords: ['plate', 'registry', 'lookup', 'license plate', 'vin', 'registration'],
+        }),
+      })
+    }
+
+    if (canSettings) {
+      for (const { item, prepared } of settingsItems) {
+        const subtitle = item.description
+          ? `${item.breadcrumb} · ${item.description}`
+          : item.breadcrumb
+        out.push({
+          item: {
+            kind: item.kind,
+            id: item.id,
+            href: item.href,
+            title: item.title,
+            subtitle: `${settingsWord}${CRUMB}${subtitle}`,
+            icon: item.icon,
+            locked: item.locked,
+            anchor: item.anchor,
+          },
+          prepared,
+        })
+      }
+      const integrationsWord = tSettings('nav.items.integrations.title')
+      for (const c of connectors) {
+        const category = tIntegrations(`categories.${c.category}`)
+        out.push({
+          item: {
+            kind: 'connector',
+            id: `connector:${c.id}`,
+            href: `/settings/integrations/${c.id}`,
+            title: c.name,
+            subtitle: `${settingsWord}${CRUMB}${integrationsWord}${CRUMB}${category}`,
+            icon: Plug,
+            logo: c.logo,
+          },
+          prepared: prepareItem({
+            title: c.name,
+            keywords: [c.id, c.category, 'integration', 'connect', 'connector'],
+            context: category,
+          }),
+        })
+      }
+    }
+    return out
+  }, [
+    ctx,
+    isMarine,
+    canSettings,
+    settingsItems,
+    connectors,
+    plateLookup.available,
+    plateLookup.registryName,
+    t,
+    tNav,
+    tSettings,
+    tIntegrations,
+    tPlate,
+  ])
+
+  const preparedQuery = useMemo(() => prepareQuery(query), [query])
+  const hasQuery = preparedQuery.tokens.length > 0
+  const ranked = useMemo(
+    () => (hasQuery ? rank(preparedQuery, localItems) : []),
+    [hasQuery, preparedQuery, localItems]
+  )
+
+  // Direct hits go above the workshop's records; loose ones only show when
+  // nothing else does, so "ola" never buries a customer under Localization.
+  const { strongGroups, weakGroups } = useMemo(() => {
+    type Group = { kind: LocalItem['kind']; rows: typeof ranked; best: number }
+    const build = (rows: typeof ranked) => {
+      const byKind = new Map<LocalItem['kind'], Group>()
+      for (const row of rows) {
+        const kind = row.item.kind
+        let group = byKind.get(kind)
+        if (!group) {
+          group = { kind, rows: [], best: row.match.score }
+          byKind.set(kind, group)
+        }
+        if (group.rows.length < LIMITS[kind]) group.rows.push(row)
+      }
+      return [...byKind.values()].sort((a, b) => b.best - a.best)
+    }
+    return {
+      strongGroups: build(ranked.filter((r) => r.match.strong)),
+      weakGroups: build(ranked.filter((r) => !r.match.strong)),
+    }
+  }, [ranked])
 
 
   useEffect(() => {
   useEffect(() => {
     const down = (e: KeyboardEvent) => {
     const down = (e: KeyboardEvent) => {
@@ -248,65 +472,91 @@ export function SearchCommand() {
     return () => document.removeEventListener('keydown', down)
     return () => document.removeEventListener('keydown', down)
   }, [])
   }, [])
 
 
-  // Fetch recent customers when dialog opens
+  // On opening: forget what the server said last time (records change), read
+  // what this person opened lately, and fetch the customers worked on last.
   useEffect(() => {
   useEffect(() => {
-    if (open && !recentFetchedRef.current) {
-      recentFetchedRef.current = true
-      getRecentCustomers().then((res) => {
-        if (res.success && res.data) {
-          setRecentCustomers(res.data)
-        }
-      })
-    }
-    if (!open) {
-      recentFetchedRef.current = false
+    if (!open) return
+    cacheRef.current.clear()
+    // eslint-disable-next-line react-hooks/set-state-in-effect -- reading the store on open is the point
+    setRecent(readRecent())
+    let cancelled = false
+    getRecentCustomers().then((res) => {
+      if (!cancelled && res.success && res.data) setRecentCustomers(res.data)
+    })
+    return () => {
+      cancelled = true
     }
     }
   }, [open])
   }, [open])
 
 
+  // The server search: debounced a little, cached for the life of the
+  // dialog, and never allowed to overwrite a newer answer with an older one.
+  // Previous results stay on screen while the next ones load, so the list
+  // never blinks empty between keystrokes.
   useEffect(() => {
   useEffect(() => {
-    if (!debouncedQuery || debouncedQuery.length < 2) {
-      // eslint-disable-next-line react-hooks/set-state-in-effect -- clearing results on query change is intentional
-      setResults({
-        vehicles: [],
-        customers: [],
-        services: [],
-        parts: [],
-        quotes: [],
-        reminders: [],
-        inspections: [],
-        tireSets: [],
-      })
+    const term = query.trim()
+    if (term.length < 2) {
+      // eslint-disable-next-line react-hooks/set-state-in-effect -- clearing results on a short query is intentional
+      setResults(EMPTY)
       setLoading(false)
       setLoading(false)
       return
       return
     }
     }
-
-    let cancelled = false
+    const key = term.toLowerCase()
+    const cached = cacheRef.current.get(key)
+    if (cached) {
+      setResults(cached)
+      setLoading(false)
+      return
+    }
+    const seq = ++seqRef.current
     setLoading(true)
     setLoading(true)
+    const timer = setTimeout(() => {
+      globalSearch(term).then((res) => {
+        if (seq !== seqRef.current) return
+        if (res.success && res.data) {
+          cacheRef.current.set(key, res.data)
+          setResults(res.data)
+        }
+        setLoading(false)
+      })
+    }, REMOTE_DEBOUNCE_MS)
+    return () => clearTimeout(timer)
+  }, [query])
 
 
-    globalSearch(debouncedQuery).then((res) => {
-      if (cancelled) return
-      if (res.success && res.data) {
-        setResults(res.data)
-      }
-      setLoading(false)
-    })
+  const close = useCallback(() => {
+    setOpen(false)
+    setQuery('')
+  }, [])
 
 
-    return () => {
-      cancelled = true
-    }
-  }, [debouncedQuery])
+  const pickLocal = useCallback(
+    (item: LocalItem) => {
+      close()
+      pushRecent({
+        id: item.id,
+        href: item.href,
+        title: item.title,
+        subtitle: item.subtitle,
+        kind: 'local',
+      })
+      if (item.run) {
+        item.run()
+        return
+      }
+      router.push(item.href)
+      if (item.anchor) requestSpotlight(item.anchor)
+    },
+    [close, router]
+  )
 
 
-  const handleSelect = useCallback(
-    (path: string) => {
-      setOpen(false)
-      setQuery('')
-      router.push(path)
+  const pickRecord = useCallback(
+    (href: string, title: string, subtitle?: string) => {
+      close()
+      pushRecent({ id: href, href, title, subtitle, kind: 'record' })
+      router.push(href)
     },
     },
-    [router]
+    [close, router]
   )
   )
 
 
-  const hasQuery = debouncedQuery.length >= 2
-  const hasResults =
+  const hasRecords =
     results.vehicles.length > 0 ||
     results.vehicles.length > 0 ||
     results.customers.length > 0 ||
     results.customers.length > 0 ||
     results.services.length > 0 ||
     results.services.length > 0 ||
@@ -315,319 +565,374 @@ export function SearchCommand() {
     results.reminders.length > 0 ||
     results.reminders.length > 0 ||
     results.inspections.length > 0 ||
     results.inspections.length > 0 ||
     results.tireSets.length > 0
     results.tireSets.length > 0
-  // Matched on what the user can actually read, plus the English aliases, so
-  // "vat" finds the tax page in every language and "mva" finds it in Norwegian.
-  const settingsEntries = useMemo(
-    () =>
-      SEARCHABLE_SETTINGS.map((entry) => ({
-        ...entry,
-        label: tSettings(`nav.items.${entry.key}.title`),
-        description: tSettings(`nav.items.${entry.key}.description`),
-      })),
-    [tSettings]
-  )
+  const showWeak = !hasRecords && strongGroups.length === 0
+  const nothing =
+    hasQuery && !loading && !hasRecords && strongGroups.length === 0 && weakGroups.length === 0
+
+  const groupHeading = (kind: LocalItem['kind']) => {
+    switch (kind) {
+      case 'setting':
+      case 'settingsPage':
+        return t('groups.settings')
+      case 'connector':
+        return t('groups.integrations')
+      case 'page':
+        return t('groups.pages')
+      case 'action':
+        return t('groups.actions')
+    }
+  }
 
 
-  const matchedSettings = useMemo(() => {
-    if (!hasQuery) return settingsEntries
-    const q = debouncedQuery.toLowerCase()
-    return settingsEntries.filter(
-      (entry) =>
-        entry.label.toLowerCase().includes(q) ||
-        entry.description.toLowerCase().includes(q) ||
-        entry.keywords.some((k) => k.toLowerCase().includes(q))
-    )
-  }, [hasQuery, debouncedQuery, settingsEntries])
-  const showDefault = !hasQuery
+  // Settings pages and settings share a heading; cmdk needs one group per
+  // heading or it draws it twice, so the two kinds are merged here.
+  const mergeSettings = (groups: typeof strongGroups) => {
+    const merged: typeof strongGroups = []
+    for (const g of groups) {
+      const heading = groupHeading(g.kind)
+      const existing = merged.find((m) => groupHeading(m.kind) === heading)
+      if (existing) {
+        existing.rows = [...existing.rows, ...g.rows]
+          .sort((a, b) => b.match.score - a.match.score)
+          .slice(0, LIMITS.setting)
+        existing.best = Math.max(existing.best, g.best)
+      } else merged.push({ ...g, rows: [...g.rows] })
+    }
+    return merged.sort((a, b) => b.best - a.best)
+  }
+
+  const renderLocalGroups = (groups: typeof strongGroups) =>
+    mergeSettings(groups).map((group) => (
+      <CommandGroup key={group.kind} heading={groupHeading(group.kind)}>
+        {group.rows.map(({ item, match }) => (
+          <Row
+            key={item.id}
+            value={item.id}
+            onSelect={() => pickLocal(item)}
+            icon={item.icon}
+            logo={item.logo}
+            title={<Highlight text={item.title} ranges={match.ranges} />}
+            subtitle={item.subtitle}
+            locked={item.locked}
+            proLabel={t('pro')}
+          />
+        ))}
+      </CommandGroup>
+    ))
+
+  // Recent picks: local ones are looked up again so a renamed page reads
+  // right and a vanished one drops out; records keep the words they had.
+  const recentRows = useMemo(() => {
+    const byId = new Map(localItems.map(({ item }) => [item.id, item]))
+    return recent
+      .map((pick) => {
+        if (pick.kind === 'local') {
+          const item = byId.get(pick.id)
+          return item ? { pick, item } : null
+        }
+        return { pick, item: null }
+      })
+      .filter((r): r is { pick: RecentPick; item: LocalItem | null } => r !== null)
+      .slice(0, 6)
+  }, [recent, localItems])
 
 
   return (
   return (
     <CommandDialog
     <CommandDialog
       open={open}
       open={open}
-      onOpenChange={setOpen}
+      onOpenChange={(next) => (next ? setOpen(true) : close())}
       title={t('title')}
       title={t('title')}
       description={t('description')}
       description={t('description')}
-      className="sm:max-w-2xl"
-      shouldFilter={!hasQuery}
+      className="top-[6vh] translate-y-0 gap-0 rounded-xl border-border/60 shadow-2xl sm:top-[12vh] sm:max-w-2xl"
+      showCloseButton={false}
+      shouldFilter={false}
     >
     >
-      <CommandInput placeholder={t('placeholder')} value={query} onValueChange={setQuery} />
-      <CommandList className="min-h-[300px]">
-        {!loading && hasQuery && !hasResults && matchedSettings.length === 0 && (
-          <CommandEmpty>{t('noResults')}</CommandEmpty>
+      <div className="relative">
+        <CommandInput
+          placeholder={t('placeholder')}
+          value={query}
+          onValueChange={setQuery}
+          autoFocus
+        />
+        {loading && <div className="search-progress" aria-hidden />}
+      </div>
+      <CommandList className="max-h-[min(62vh,560px)] min-h-[220px] scroll-py-2">
+        {nothing && (
+          <div className="flex flex-col items-center gap-1 px-6 py-12 text-center">
+            <Search className="mb-2 h-5 w-5 text-muted-foreground/60" />
+            <p className="text-sm font-medium">{t('noResultsFor', { query: query.trim() })}</p>
+            <p className="text-xs text-muted-foreground">{t('noResultsHint')}</p>
+          </div>
+        )}
+
+        {!hasQuery && recentRows.length > 0 && (
+          <CommandGroup heading={t('groups.recent')}>
+            {recentRows.map(({ pick, item }) =>
+              item ? (
+                <Row
+                  key={pick.id}
+                  value={`recent:${pick.id}`}
+                  onSelect={() => pickLocal(item)}
+                  icon={item.icon}
+                  logo={item.logo}
+                  title={item.title}
+                  subtitle={item.subtitle}
+                  locked={item.locked}
+                  proLabel={t('pro')}
+                />
+              ) : (
+                <Row
+                  key={pick.id}
+                  value={`recent:${pick.id}`}
+                  onSelect={() => pickRecord(pick.href, pick.title, pick.subtitle)}
+                  icon={iconForHref(pick.href)}
+                  title={pick.title}
+                  subtitle={pick.subtitle}
+                />
+              )
+            )}
+          </CommandGroup>
         )}
         )}
 
 
-        {/* Default view: recent customers + all settings */}
-        {showDefault && recentCustomers.length > 0 && (
+        {!hasQuery && recentCustomers.length > 0 && (
           <CommandGroup heading={t('recentCustomers')}>
           <CommandGroup heading={t('recentCustomers')}>
-            {recentCustomers.map((c) => (
-              <CommandItem
+            {recentCustomers.slice(0, RECENT_CUSTOMERS_SHOWN).map((c) => (
+              <Row
                 key={c.id}
                 key={c.id}
-                value={`${c.name} ${c.email || ''} ${c.phone || ''} ${c.company || ''}`}
-                onSelect={() => handleSelect(`/customers/${c.id}`)}
-              >
-                <Users className="mr-2 h-4 w-4 text-muted-foreground" />
-                <div className="flex flex-col">
-                  <span>{c.name}</span>
-                  <span className="text-xs text-muted-foreground">
-                    {[c.company, c.email, c.phone].filter(Boolean).join(' · ')}
-                  </span>
-                </div>
-              </CommandItem>
+                value={`recent-customer:${c.id}`}
+                onSelect={() =>
+                  pickRecord(
+                    `/customers/${c.id}`,
+                    c.name,
+                    [c.company, c.email, c.phone].filter(Boolean).join(' · ') || undefined
+                  )
+                }
+                icon={Users}
+                title={c.name}
+                subtitle={[c.company, c.email, c.phone].filter(Boolean).join(' · ')}
+              />
             ))}
             ))}
           </CommandGroup>
           </CommandGroup>
         )}
         )}
 
 
-        {/* Search results: grouped by entity type — kept visible while loading to avoid flicker */}
-        {hasQuery && (
-          <>
-            {results.customers.length > 0 && (
-              <CommandGroup heading={tNav('customers')}>
-                {results.customers.map((c) => (
-                  <div key={c.id}>
-                    <CommandItem
-                      value={`${c.name} ${c.email || ''} ${c.phone || ''} ${c.company || ''}`}
-                      onSelect={() => handleSelect(`/customers/${c.id}`)}
-                    >
-                      <Users className="mr-2 h-4 w-4 text-muted-foreground" />
-                      <div className="flex flex-col">
-                        <span>{c.name}</span>
-                        <span className="text-xs text-muted-foreground">
-                          {[c.company, c.email, c.phone].filter(Boolean).join(' · ')}
-                        </span>
-                      </div>
-                    </CommandItem>
-                    {c.vehicles.map((v) => (
-                      <CommandItem
-                        key={v.id}
-                        value={`${c.name} ${v.year} ${v.make} ${v.model} ${v.licensePlate || ''}`}
-                        onSelect={() => handleSelect(`/vehicles/${v.id}`)}
-                        className="pl-10"
-                      >
-                        <Car className="mr-2 h-3.5 w-3.5 text-muted-foreground" />
-                        <span className="text-sm">
-                          {v.year} {v.make} {v.model}
-                        </span>
-                        {v.licensePlate && (
-                          <span className="ml-2 font-mono text-xs text-muted-foreground">
-                            {v.licensePlate}
-                          </span>
-                        )}
-                      </CommandItem>
-                    ))}
-                  </div>
-                ))}
-              </CommandGroup>
-            )}
-            {results.vehicles.length > 0 && (
-              <CommandGroup heading={tNav('vehicles')}>
-                {results.vehicles.map((v) => (
-                  <CommandItem
+        {!hasQuery && (
+          <CommandGroup heading={t('groups.actions')}>
+            {localItems
+              .filter(({ item }) => item.kind === 'action')
+              .map(({ item }) => (
+                <Row
+                  key={item.id}
+                  value={item.id}
+                  onSelect={() => pickLocal(item)}
+                  icon={item.icon}
+                  title={item.title}
+                  subtitle={item.subtitle}
+                />
+              ))}
+          </CommandGroup>
+        )}
+
+        {hasQuery && renderLocalGroups(strongGroups)}
+
+        {hasQuery && results.customers.length > 0 && (
+          <CommandGroup heading={tNav('customers')}>
+            {results.customers.map((c) => (
+              <Fragment key={c.id}>
+                <Row
+                  value={`customer:${c.id}`}
+                  onSelect={() =>
+                    pickRecord(
+                      `/customers/${c.id}`,
+                      c.name,
+                      [c.company, c.email, c.phone].filter(Boolean).join(' · ') || undefined
+                    )
+                  }
+                  icon={Users}
+                  title={c.name}
+                  subtitle={[c.company, c.email, c.phone].filter(Boolean).join(' · ')}
+                />
+                {c.vehicles.map((v) => (
+                  <Row
                     key={v.id}
                     key={v.id}
-                    value={`${v.year} ${v.make} ${v.model} ${v.licensePlate || ''}`}
-                    onSelect={() => handleSelect(`/vehicles/${v.id}`)}
-                  >
-                    <Car className="mr-2 h-4 w-4 text-muted-foreground" />
-                    <span>
-                      {v.year} {v.make} {v.model}
-                    </span>
-                    {v.licensePlate && (
-                      <span className="ml-2 font-mono text-xs text-muted-foreground">
-                        {v.licensePlate}
-                      </span>
-                    )}
-                    {typeKeyEnabled && (v.hsn || v.tsn) && (
-                      <span className="ml-2 font-mono text-xs text-muted-foreground">
-                        {formatTypeKey(v.hsn, v.tsn)}
-                      </span>
-                    )}
-                  </CommandItem>
-                ))}
-              </CommandGroup>
-            )}
-            {results.services.length > 0 && (
-              <CommandGroup heading={t('services')}>
-                {results.services.map((s) => (
-                  <CommandItem
-                    key={s.id}
-                    value={`${s.title} ${s.invoiceNumber || ''} ${s.vehicle ? `${s.vehicle.make} ${s.vehicle.model}` : ''}`}
-                    onSelect={() =>
-                      handleSelect(
-                        s.vehicle ? `/vehicles/${s.vehicle.id}/service/${s.id}` : `/sales/${s.id}`
-                      )
-                    }
-                  >
-                    <Wrench className="mr-2 h-4 w-4 text-muted-foreground" />
-                    <div className="flex flex-col">
-                      <span>{s.title}</span>
-                      <span className="text-xs text-muted-foreground">
-                        {[
-                          s.invoiceNumber,
-                          s.vehicle
-                            ? `${s.vehicle.year} ${s.vehicle.make} ${s.vehicle.model}`
-                            : null,
-                          s.vehicle?.licensePlate,
-                        ]
-                          .filter(Boolean)
-                          .join(' · ')}
-                      </span>
-                    </div>
-                  </CommandItem>
+                    value={`customer-vehicle:${v.id}`}
+                    onSelect={() => pickRecord(`/vehicles/${v.id}`, vehicleLine(v), c.name)}
+                    icon={Car}
+                    title={vehicleLine(v)}
+                    subtitle={c.name}
+                    trailing={v.licensePlate}
+                    className="ml-10"
+                  />
                 ))}
                 ))}
-              </CommandGroup>
-            )}
-            {results.quotes.length > 0 && (
-              <CommandGroup heading={tNav('quotes')}>
-                {results.quotes.map((q) => (
-                  <CommandItem
-                    key={q.id}
-                    value={`${q.title} ${q.quoteNumber || ''}`}
-                    onSelect={() => handleSelect(`/quotes/${q.id}`)}
-                  >
-                    <FileText className="mr-2 h-4 w-4 text-muted-foreground" />
-                    <div className="flex flex-col">
-                      <span>{q.title}</span>
-                      <span className="text-xs text-muted-foreground">
-                        {[q.quoteNumber, q.status].filter(Boolean).join(' · ')}
-                      </span>
-                    </div>
-                  </CommandItem>
-                ))}
-              </CommandGroup>
-            )}
-            {results.parts.length > 0 && (
-              <CommandGroup heading={tNav('inventory')}>
-                {results.parts.map((p) => (
-                  <CommandItem
-                    key={p.id}
-                    value={`${p.name} ${p.partNumber || ''}`}
-                    onSelect={() => handleSelect('/inventory')}
-                  >
-                    <Package className="mr-2 h-4 w-4 text-muted-foreground" />
-                    <div className="flex flex-col">
-                      <span>{p.name}</span>
-                      <span className="text-xs text-muted-foreground">
-                        {[p.partNumber, `${formatQuantity(p.quantity, p.unit)} in stock`]
-                          .filter(Boolean)
-                          .join(' · ')}
-                      </span>
-                    </div>
-                  </CommandItem>
-                ))}
-              </CommandGroup>
-            )}
-            {results.reminders.length > 0 && (
-              <CommandGroup heading={tNav('reminders')}>
-                {results.reminders.map((r) => (
-                  <CommandItem
-                    key={r.id}
-                    value={`${r.title} ${r.vehicle ? `${r.vehicle.make} ${r.vehicle.model} ${r.vehicle.licensePlate || ''}` : r.customer?.name || ''}`}
-                    onSelect={() =>
-                      handleSelect(
-                        r.vehicle ? `/vehicles/${r.vehicle.id}?tab=reminders` : '/reminders'
-                      )
-                    }
-                  >
-                    <Bell className="mr-2 h-4 w-4 text-muted-foreground" />
-                    <div className="flex flex-col">
-                      <span>
-                        {r.title}
-                        {r.isCompleted && (
-                          <span className="ml-1.5 text-muted-foreground line-through">(done)</span>
-                        )}
-                      </span>
-                      <span className="text-xs text-muted-foreground">
-                        {r.vehicle
-                          ? `${r.vehicle.year} ${r.vehicle.make} ${r.vehicle.model}${r.vehicle.licensePlate ? ` · ${r.vehicle.licensePlate}` : ''}`
-                          : r.customer?.name || ''}
-                      </span>
-                    </div>
-                  </CommandItem>
-                ))}
-              </CommandGroup>
-            )}
-            {/* Shelf code leads: the desk asks "where are these tires", and
-                the code is the answer they are about to read out loud. */}
-            {results.tireSets.length > 0 && (
-              <CommandGroup heading={tNav('tireHotel')}>
-                {results.tireSets.map((set) => (
-                  <CommandItem
-                    key={set.id}
-                    value={`tire ${set.reference || ''} ${set.location?.code || ''} ${set.customer?.name || ''} ${set.vehicle?.make || ''} ${set.vehicle?.model || ''} ${set.vehicle?.licensePlate || ''} ${set.size || ''}`}
-                    onSelect={() => handleSelect(`/tire-hotel/${set.id}`)}
-                  >
-                    <Disc3 className="mr-2 h-4 w-4 text-muted-foreground" />
-                    <div className="flex min-w-0 flex-col">
-                      <span className="truncate">
-                        {set.location?.code ? (
-                          <span className="font-mono">{set.location.code}</span>
-                        ) : (
-                          <span className="text-muted-foreground">
-                            {tTire(`statuses.${set.status}`)}
-                          </span>
-                        )}
-                        {set.vehicle?.licensePlate && ` · ${set.vehicle.licensePlate}`}
-                      </span>
-                      <span className="truncate text-xs text-muted-foreground">
-                        {set.customer?.name ?? ''}
-                        {set.vehicle
-                          ? `${set.customer ? ' · ' : ''}${set.vehicle.make} ${set.vehicle.model}`
-                          : ''}
-                        {set.size ? ` · ${set.size}` : ''}
-                        {` · ${tTire('list.tireCount', { count: set.quantity })}`}
-                      </span>
-                    </div>
-                  </CommandItem>
-                ))}
-              </CommandGroup>
-            )}
-            {results.inspections.length > 0 && (
-              <CommandGroup heading={tNav('inspections')}>
-                {results.inspections.map((insp) => (
-                  <CommandItem
-                    key={insp.id}
-                    value={`${insp.template.name} ${insp.vehicle.make} ${insp.vehicle.model} ${insp.vehicle.licensePlate || ''}`}
-                    onSelect={() => handleSelect(`/inspections/${insp.id}`)}
-                  >
-                    <ClipboardCheck className="mr-2 h-4 w-4 text-muted-foreground" />
-                    <div className="flex flex-col">
-                      <span>{insp.template.name}</span>
-                      <span className="text-xs text-muted-foreground">
-                        {`${insp.vehicle.year} ${insp.vehicle.make} ${insp.vehicle.model}`}
-                        {insp.vehicle.licensePlate && ` · ${insp.vehicle.licensePlate}`}
-                        {` · ${insp.status}`}
-                      </span>
-                    </div>
-                  </CommandItem>
-                ))}
-              </CommandGroup>
-            )}
-          </>
+              </Fragment>
+            ))}
+          </CommandGroup>
         )}
         )}
-
-        {/* Settings group: shown in both default and search views */}
-        {matchedSettings.length > 0 && (
-          <CommandGroup heading={tNav('settings')}>
-            {matchedSettings.map((s) => (
-              <CommandItem
-                key={s.href}
-                value={`${s.label} ${s.keywords.join(' ')}`}
-                onSelect={() => handleSelect(s.href)}
-              >
-                <Settings className="mr-2 h-4 w-4 text-muted-foreground" />
-                <div className="flex flex-col">
-                  <span>{s.label}</span>
-                  <span className="text-xs text-muted-foreground">{s.description}</span>
-                </div>
-              </CommandItem>
+        {hasQuery && results.vehicles.length > 0 && (
+          <CommandGroup heading={tNav(isMarine ? 'vessels' : 'vehicles')}>
+            {results.vehicles.map((v) => (
+              <Row
+                key={v.id}
+                value={`vehicle:${v.id}`}
+                onSelect={() =>
+                  pickRecord(`/vehicles/${v.id}`, vehicleLine(v), v.licensePlate ?? undefined)
+                }
+                icon={Car}
+                title={vehicleLine(v)}
+                subtitle={
+                  typeKeyEnabled && (v.hsn || v.tsn) ? formatTypeKey(v.hsn, v.tsn) : undefined
+                }
+                trailing={v.licensePlate}
+              />
+            ))}
+          </CommandGroup>
+        )}
+        {hasQuery && results.services.length > 0 && (
+          <CommandGroup heading={t('services')}>
+            {results.services.map((s) => {
+              const href = s.vehicle
+                ? `/vehicles/${s.vehicle.id}/service/${s.id}`
+                : `/sales/${s.id}`
+              const subtitle = [s.vehicle ? vehicleLine(s.vehicle) : null, s.vehicle?.licensePlate]
+                .filter(Boolean)
+                .join(' · ')
+              return (
+                <Row
+                  key={s.id}
+                  value={`service:${s.id}`}
+                  onSelect={() => pickRecord(href, s.title, subtitle || undefined)}
+                  icon={Wrench}
+                  title={s.title}
+                  subtitle={subtitle}
+                  trailing={s.invoiceNumber}
+                />
+              )
+            })}
+          </CommandGroup>
+        )}
+        {hasQuery && results.quotes.length > 0 && (
+          <CommandGroup heading={tNav('quotes')}>
+            {results.quotes.map((q) => (
+              <Row
+                key={q.id}
+                value={`quote:${q.id}`}
+                onSelect={() => pickRecord(`/quotes/${q.id}`, q.title, q.quoteNumber ?? undefined)}
+                icon={FileText}
+                title={q.title}
+                subtitle={q.status}
+                trailing={q.quoteNumber}
+              />
+            ))}
+          </CommandGroup>
+        )}
+        {hasQuery && results.parts.length > 0 && (
+          <CommandGroup heading={tNav('inventory')}>
+            {results.parts.map((p) => (
+              <Row
+                key={p.id}
+                value={`part:${p.id}`}
+                onSelect={() => pickRecord('/inventory', p.name, p.partNumber ?? undefined)}
+                icon={Package}
+                title={p.name}
+                subtitle={`${formatQuantity(p.quantity, p.unit)} in stock`}
+                trailing={p.partNumber}
+              />
             ))}
             ))}
           </CommandGroup>
           </CommandGroup>
         )}
         )}
+        {hasQuery && results.reminders.length > 0 && (
+          <CommandGroup heading={tNav('reminders')}>
+            {results.reminders.map((r) => {
+              const href = r.vehicle ? `/vehicles/${r.vehicle.id}?tab=reminders` : '/reminders'
+              const subtitle = r.vehicle
+                ? `${vehicleLine(r.vehicle)}${r.vehicle.licensePlate ? ` · ${r.vehicle.licensePlate}` : ''}`
+                : (r.customer?.name ?? '')
+              return (
+                <Row
+                  key={r.id}
+                  value={`reminder:${r.id}`}
+                  onSelect={() => pickRecord(href, r.title, subtitle || undefined)}
+                  icon={Bell}
+                  title={
+                    <span className={cn(r.isCompleted && 'text-muted-foreground line-through')}>
+                      {r.title}
+                    </span>
+                  }
+                  subtitle={subtitle}
+                />
+              )
+            })}
+          </CommandGroup>
+        )}
+        {/* Shelf code leads: the desk asks "where are these tires", and the
+            code is the answer they are about to read out loud. */}
+        {hasQuery && results.tireSets.length > 0 && (
+          <CommandGroup heading={tNav('tireHotel')}>
+            {results.tireSets.map((set) => {
+              const title = set.location?.code ?? tTire(`statuses.${set.status}`)
+              const subtitle = [
+                set.customer?.name,
+                set.vehicle ? `${set.vehicle.make} ${set.vehicle.model}` : null,
+                set.size,
+                tTire('list.tireCount', { count: set.quantity }),
+              ]
+                .filter(Boolean)
+                .join(' · ')
+              return (
+                <Row
+                  key={set.id}
+                  value={`tire:${set.id}`}
+                  onSelect={() => pickRecord(`/tire-hotel/${set.id}`, title, subtitle)}
+                  icon={Disc3}
+                  title={<span className={cn(set.location?.code && 'font-mono')}>{title}</span>}
+                  subtitle={subtitle}
+                  trailing={set.vehicle?.licensePlate}
+                />
+              )
+            })}
+          </CommandGroup>
+        )}
+        {hasQuery && results.inspections.length > 0 && (
+          <CommandGroup heading={tNav('inspections')}>
+            {results.inspections.map((insp) => (
+              <Row
+                key={insp.id}
+                value={`inspection:${insp.id}`}
+                onSelect={() =>
+                  pickRecord(
+                    `/inspections/${insp.id}`,
+                    insp.template.name,
+                    vehicleLine(insp.vehicle)
+                  )
+                }
+                icon={ClipboardCheck}
+                title={insp.template.name}
+                subtitle={`${vehicleLine(insp.vehicle)} · ${insp.status}`}
+                trailing={insp.vehicle.licensePlate}
+              />
+            ))}
+          </CommandGroup>
+        )}
+
+        {hasQuery && showWeak && renderLocalGroups(weakGroups)}
       </CommandList>
       </CommandList>
-      <div className="border-t px-3 py-2">
-        <span className="text-xs text-muted-foreground">
-          <kbd className="pointer-events-none inline-flex h-5 items-center rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
-            ESC
-          </kbd>
-          <span className="ml-1.5">{t('toClose')}</span>
-        </span>
+
+      <div className="flex items-center justify-between gap-4 border-t px-3 py-2 text-[11px] text-muted-foreground">
+        <div className="hidden items-center gap-3 sm:flex">
+          <span className="flex items-center gap-1">
+            <Kbd>↑</Kbd>
+            <Kbd>↓</Kbd>
+            <span className="ml-1">{t('hints.navigate')}</span>
+          </span>
+          <span className="flex items-center gap-1">
+            <Kbd>↵</Kbd>
+            <span className="ml-1">{t('hints.open')}</span>
+          </span>
+          <span className="flex items-center gap-1">
+            <Kbd>esc</Kbd>
+            <span className="ml-1">{t('hints.close')}</span>
+          </span>
+        </div>
+        <div className="min-w-0 truncate">
+          {loading ? t('hints.searching') : !hasQuery ? t('hints.start') : null}
+        </div>
       </div>
       </div>
     </CommandDialog>
     </CommandDialog>
   )
   )

+ 301 - 0
src/features/search/Lib/commands.ts

@@ -0,0 +1,301 @@
+import {
+  BarChart3,
+  Bell,
+  CalendarDays,
+  Car,
+  ClipboardCheck,
+  ClipboardList,
+  Columns3,
+  Disc3,
+  FileText,
+  History,
+  Layers,
+  LayoutDashboard,
+  type LucideIcon,
+  MessageSquare,
+  Package,
+  Receipt,
+  Settings,
+  Ship,
+  Sparkles,
+  Timer,
+  UserPlus,
+  Users,
+  Wrench,
+} from 'lucide-react'
+import type { PlanFeatures } from '@/lib/features'
+
+/**
+ * Where the palette can take someone besides a record or a setting: the
+ * pages of the app and the things one starts from the header's plus menu.
+ *
+ * Visibility follows the sidebar: a page the role cannot open is not
+ * offered, and a module that is off is not listed. Keywords are English
+ * aliases, so "jobs" finds Work Orders in every language.
+ */
+export type PageCommand = {
+  id: string
+  href: string
+  /** Key under `navigation.sidebar`. */
+  titleKey: string
+  /** Used instead of `titleKey` for a marine workshop. */
+  marineTitleKey?: string
+  subject: string
+  keywords: string[]
+  icon: LucideIcon
+  marineIcon?: LucideIcon
+  /** Listed only when the module or plan allows it. */
+  needs?: 'tireHotel' | 'ai' | 'reports'
+}
+
+export const PAGE_COMMANDS: PageCommand[] = [
+  {
+    id: 'page:dashboard',
+    href: '/',
+    titleKey: 'dashboard',
+    subject: 'dashboard',
+    keywords: ['home', 'overview', 'start', 'today'],
+    icon: LayoutDashboard,
+  },
+  {
+    id: 'page:customers',
+    href: '/customers',
+    titleKey: 'customers',
+    subject: 'customers',
+    keywords: ['clients', 'people', 'contacts', 'owners'],
+    icon: Users,
+  },
+  {
+    id: 'page:messages',
+    href: '/messages',
+    titleKey: 'messages',
+    subject: 'customers',
+    keywords: ['inbox', 'sms', 'chat', 'conversations', 'email'],
+    icon: MessageSquare,
+  },
+  {
+    id: 'page:vehicles',
+    href: '/vehicles',
+    titleKey: 'vehicles',
+    marineTitleKey: 'vessels',
+    subject: 'vehicles',
+    keywords: ['cars', 'fleet', 'plates', 'vin', 'boats'],
+    icon: Car,
+    marineIcon: Ship,
+  },
+  {
+    id: 'page:reminders',
+    href: '/reminders',
+    titleKey: 'reminders',
+    subject: 'vehicles',
+    keywords: ['due', 'follow up', 'service due', 'mot', 'eu-kontroll'],
+    icon: Bell,
+  },
+  {
+    id: 'page:work-orders',
+    href: '/work-orders',
+    titleKey: 'workOrders',
+    subject: 'work_orders',
+    keywords: ['jobs', 'service records', 'repair orders', 'ro', 'open jobs'],
+    icon: ClipboardList,
+  },
+  {
+    id: 'page:inspections',
+    href: '/inspections',
+    titleKey: 'inspections',
+    subject: 'inspections',
+    keywords: ['checklists', 'vehicle check', 'health check', 'mot'],
+    icon: ClipboardCheck,
+  },
+  {
+    id: 'page:calendar',
+    href: '/calendar',
+    titleKey: 'calendar',
+    subject: 'work_orders',
+    keywords: ['schedule', 'bookings', 'appointments', 'agenda', 'week'],
+    icon: CalendarDays,
+  },
+  {
+    id: 'page:work-board',
+    href: '/work-board',
+    titleKey: 'workBoard',
+    subject: 'work_board',
+    keywords: ['board', 'kanban', 'bays', 'planning', 'presenter'],
+    icon: Columns3,
+  },
+  {
+    id: 'page:tire-hotel',
+    href: '/tire-hotel',
+    titleKey: 'tireHotel',
+    subject: 'tire_hotel',
+    keywords: ['tyres', 'tires', 'storage', 'wheels', 'season', 'shelf'],
+    icon: Disc3,
+    needs: 'tireHotel',
+  },
+  {
+    id: 'page:quotes',
+    href: '/quotes',
+    titleKey: 'quotes',
+    subject: 'quotes',
+    keywords: ['estimates', 'offers', 'proposals'],
+    icon: FileText,
+  },
+  {
+    id: 'page:billing',
+    href: '/billing',
+    titleKey: 'billing',
+    subject: 'billing',
+    keywords: ['invoices', 'payments', 'receipts', 'unpaid', 'overdue', 'recurring'],
+    icon: Receipt,
+  },
+  {
+    id: 'page:inventory',
+    href: '/inventory',
+    titleKey: 'inventory',
+    subject: 'inventory',
+    keywords: ['parts', 'stock', 'warehouse', 'supplies', 'barcode'],
+    icon: Package,
+  },
+  {
+    id: 'page:labor-presets',
+    href: '/labor-presets',
+    titleKey: 'laborPresets',
+    subject: 'labor_presets',
+    keywords: ['labor packages', 'labour', 'service packages', 'menu pricing'],
+    icon: Layers,
+  },
+  {
+    id: 'page:reports',
+    href: '/reports',
+    titleKey: 'reports',
+    subject: 'reports',
+    keywords: ['revenue', 'statistics', 'analytics', 'charts', 'kpi'],
+    icon: BarChart3,
+    needs: 'reports',
+  },
+  {
+    id: 'page:timesheets',
+    href: '/timesheets',
+    titleKey: 'timesheets',
+    subject: 'time_tracking',
+    keywords: ['hours', 'clock', 'time tracking', 'attendance', 'punch'],
+    icon: Timer,
+  },
+  {
+    id: 'page:ai',
+    href: '/ai',
+    titleKey: 'aiAssistant',
+    subject: 'ai_assistant',
+    keywords: ['assistant', 'chat', 'ask', 'gpt', 'claude'],
+    icon: Sparkles,
+    needs: 'ai',
+  },
+  {
+    id: 'page:audit-log',
+    href: '/audit-log',
+    titleKey: 'auditLog',
+    subject: 'settings',
+    keywords: ['history', 'activity', 'who changed', 'log'],
+    icon: History,
+  },
+  {
+    id: 'page:settings',
+    href: '/settings',
+    titleKey: 'settings',
+    subject: 'settings',
+    keywords: ['preferences', 'configuration', 'options', 'setup'],
+    icon: Settings,
+  },
+]
+
+export type ActionCommand = {
+  id: string
+  href: string
+  /** Key under `search.actions`. */
+  titleKey: string
+  subject: string
+  keywords: string[]
+  icon: LucideIcon
+  adminOnly?: boolean
+}
+
+export const ACTION_COMMANDS: ActionCommand[] = [
+  {
+    id: 'action:new-work-order',
+    href: '/work-orders?new=1',
+    titleKey: 'newWorkOrder',
+    subject: 'work_orders',
+    keywords: ['create job', 'add job', 'start job', 'new job', 'new service'],
+    icon: Wrench,
+  },
+  {
+    id: 'action:new-customer',
+    href: '/customers?create=true',
+    titleKey: 'newCustomer',
+    subject: 'customers',
+    keywords: ['add customer', 'create customer', 'new client', 'add person'],
+    icon: Users,
+  },
+  {
+    id: 'action:new-vehicle',
+    href: '/vehicles?create=true',
+    titleKey: 'newVehicle',
+    subject: 'vehicles',
+    keywords: ['add vehicle', 'add car', 'register car', 'new car', 'new boat'],
+    icon: Car,
+  },
+  {
+    id: 'action:new-quote',
+    href: '/quotes?create=true',
+    titleKey: 'newQuote',
+    subject: 'quotes',
+    keywords: ['add quote', 'create quote', 'new estimate', 'new offer'],
+    icon: FileText,
+  },
+  {
+    id: 'action:new-part',
+    href: '/inventory?create=true',
+    titleKey: 'newPart',
+    subject: 'inventory',
+    keywords: ['add part', 'add stock', 'new item', 'add inventory'],
+    icon: Package,
+  },
+  {
+    id: 'action:invite-member',
+    href: '/settings/team?add=true',
+    titleKey: 'inviteMember',
+    subject: 'settings',
+    keywords: ['add user', 'add member', 'add technician', 'invite', 'new user', 'team'],
+    icon: UserPlus,
+    adminOnly: true,
+  },
+]
+
+export type CommandContext = {
+  visibleSubjects?: string[]
+  features?: PlanFeatures
+  tireHotelEnabled?: boolean
+  aiEnabled?: boolean
+  isAdminOrOwner?: boolean
+}
+
+export function canAccessSubject(ctx: CommandContext, subject: string): boolean {
+  return !ctx.visibleSubjects || ctx.visibleSubjects.includes(subject)
+}
+
+export function visiblePageCommands(ctx: CommandContext): PageCommand[] {
+  return PAGE_COMMANDS.filter((page) => {
+    if (!canAccessSubject(ctx, page.subject)) return false
+    if (page.needs === 'tireHotel') return !!ctx.tireHotelEnabled
+    if (page.needs === 'ai') return !!ctx.aiEnabled
+    if (page.needs === 'reports') return ctx.features?.reports !== false
+    return true
+  })
+}
+
+export function visibleActionCommands(ctx: CommandContext): ActionCommand[] {
+  return ACTION_COMMANDS.filter((action) => {
+    if (action.adminOnly && !ctx.isAdminOrOwner) return false
+    return canAccessSubject(ctx, action.subject)
+  })
+}

+ 281 - 0
src/features/search/Lib/matcher.ts

@@ -0,0 +1,281 @@
+/**
+ * The palette's matching engine. Pure string work on a few hundred prepared
+ * items, so it runs on every keystroke with nothing to debounce.
+ *
+ * Ranking, best first, per query word:
+ *   the title is the word, starts with it, has a word starting with it,
+ *   contains it; then the same on an alias; then the breadcrumb and the
+ *   description; and last a loose subsequence of the title ("lcl" for
+ *   Localization), which only counts for three letters or more.
+ * Every word of the query has to land somewhere. A result is `strong` when
+ * each word hit a title or alias directly, which is how the palette decides
+ * whether settings and pages go above the workshop's own records or below.
+ *
+ * Accents are folded both ways, so "Lokalisierung" is found by "lokalis"
+ * and "Tax" by "tåx"; æ, ø, å and ß fold to the letters people type for them.
+ */
+
+export type Range = [start: number, end: number]
+
+export type Searchable = {
+  title: string
+  /** English aliases and foreign words people type for this thing. */
+  keywords?: readonly string[]
+  /** One quiet line, matched last. */
+  description?: string
+  /** Where it lives, matched between aliases and the description ("Billing · Invoice"). */
+  context?: string
+}
+
+type Folded = { text: string; map: number[] }
+
+const FOLD_SPECIAL: Record<string, string> = {
+  æ: 'ae',
+  ø: 'o',
+  å: 'a',
+  ß: 'ss',
+  œ: 'oe',
+  đ: 'd',
+  ł: 'l',
+  þ: 'th',
+  ð: 'd',
+}
+
+/** Lower case, accents stripped, with a map from each folded char back to its source index. */
+function foldWithMap(source: string): Folded {
+  let text = ''
+  const map: number[] = []
+  const lower = source.toLowerCase()
+  for (let i = 0; i < lower.length; i++) {
+    const ch = lower[i]
+    const special = FOLD_SPECIAL[ch]
+    const out = special ?? ch.normalize('NFD').replace(/[̀-ͯ]/g, '')
+    for (const c of out) {
+      text += c
+      map.push(i)
+    }
+  }
+  return { text, map }
+}
+
+/** Lower case and accent free, for comparing without needing ranges back. */
+export function fold(source: string): string {
+  return foldWithMap(source).text
+}
+
+export type PreparedItem = {
+  title: Folded
+  /** Start offsets of each word in the folded title. */
+  wordStarts: number[]
+  keywords: string[]
+  context: string
+  description: string
+}
+
+export function prepareItem(s: Searchable): PreparedItem {
+  const title = foldWithMap(s.title)
+  const wordStarts: number[] = []
+  const re = /[a-z0-9]+/g
+  let m: RegExpExecArray | null = re.exec(title.text)
+  while (m) {
+    wordStarts.push(m.index)
+    m = re.exec(title.text)
+  }
+  return {
+    title,
+    wordStarts,
+    keywords: (s.keywords ?? []).map(fold),
+    context: fold(s.context ?? ''),
+    description: fold(s.description ?? ''),
+  }
+}
+
+export type PreparedQuery = {
+  /** The whole query, folded and single-spaced. */
+  text: string
+  tokens: string[]
+}
+
+export function prepareQuery(query: string): PreparedQuery {
+  const text = fold(query).replace(/\s+/g, ' ').trim()
+  const tokens = Array.from(new Set(text.split(' ').filter(Boolean)))
+  return { text, tokens }
+}
+
+export type Match = {
+  score: number
+  /** Character ranges in the original title to highlight. */
+  ranges: Range[]
+  /** Every word hit a title or an alias directly. */
+  strong: boolean
+}
+
+/** A word starts here: index 0 or the previous character is not a letter or digit. */
+function atWordStart(text: string, index: number): boolean {
+  if (index === 0) return true
+  return !/[a-z0-9]/.test(text[index - 1])
+}
+
+/** Best score for one token on one folded string, with how it matched. */
+function scoreText(
+  token: string,
+  text: string,
+  weights: { exact: number; prefix: number; wordStart: number; contains: number }
+): { score: number; index: number } | null {
+  if (!text) return null
+  if (text === token) return { score: weights.exact, index: 0 }
+  const coverage = Math.round((token.length / Math.max(text.length, 1)) * 40)
+  if (text.startsWith(token)) return { score: weights.prefix + coverage, index: 0 }
+  let index = text.indexOf(token)
+  let firstIndex = -1
+  while (index !== -1) {
+    if (firstIndex === -1) firstIndex = index
+    if (atWordStart(text, index)) return { score: weights.wordStart + coverage, index }
+    index = text.indexOf(token, index + 1)
+  }
+  if (firstIndex !== -1) return { score: weights.contains, index: firstIndex }
+  return null
+}
+
+const TITLE = { exact: 1000, prefix: 800, wordStart: 620, contains: 420 }
+const KEYWORD = { exact: 560, prefix: 400, wordStart: 340, contains: 250 }
+const CONTEXT = { exact: 230, prefix: 220, wordStart: 210, contains: 160 }
+const DESCRIPTION = { exact: 200, prefix: 200, wordStart: 190, contains: 130 }
+
+/** Direct hits on a title or alias; anything below is a loose match. */
+const STRONG_FLOOR = 250
+
+/**
+ * Subsequence match: every character of the token appears in order. Scores
+ * by how tightly the characters sit together and whether they open words,
+ * so "lcl" likes "LoCaLization" and "wrk" likes "WoRKshop".
+ */
+function fuzzy(token: string, text: string): { score: number; positions: number[] } | null {
+  if (token.length < 3) return null
+  const positions: number[] = []
+  let from = 0
+  for (const ch of token) {
+    const at = text.indexOf(ch, from)
+    if (at === -1) return null
+    positions.push(at)
+    from = at + 1
+  }
+  const span = positions[positions.length - 1] - positions[0] + 1
+  const tightness = token.length / span
+  const wordStarts = positions.filter((p) => atWordStart(text, p)).length
+  const score = Math.round(40 + 140 * tightness + 25 * wordStarts)
+  return { score: Math.min(score, 280), positions }
+}
+
+function mergeRanges(ranges: Range[]): Range[] {
+  const sorted = [...ranges].sort((a, b) => a[0] - b[0])
+  const out: Range[] = []
+  for (const r of sorted) {
+    const last = out[out.length - 1]
+    if (last && r[0] <= last[1]) last[1] = Math.max(last[1], r[1])
+    else out.push([r[0], r[1]])
+  }
+  return out
+}
+
+function toSourceRange(folded: Folded, start: number, end: number): Range {
+  return [folded.map[start], folded.map[end - 1] + 1]
+}
+
+export function matchItem(query: PreparedQuery, item: PreparedItem): Match | null {
+  if (query.tokens.length === 0) return { score: 0, ranges: [], strong: true }
+  let total = 0
+  let strong = true
+  const ranges: Range[] = []
+  const title = item.title.text
+
+  for (const token of query.tokens) {
+    let best = 0
+    let bestRange: Range | null = null
+    let fuzzyPositions: number[] | null = null
+
+    const onTitle = scoreText(token, title, TITLE)
+    if (onTitle) {
+      best = onTitle.score
+      bestRange = toSourceRange(item.title, onTitle.index, onTitle.index + token.length)
+    }
+    for (const keyword of item.keywords) {
+      const hit = scoreText(token, keyword, KEYWORD)
+      if (hit && hit.score > best) {
+        best = hit.score
+        bestRange = null
+      }
+    }
+    const onContext = scoreText(token, item.context, CONTEXT)
+    if (onContext && onContext.score > best) {
+      best = onContext.score
+      bestRange = null
+    }
+    const onDescription = scoreText(token, item.description, DESCRIPTION)
+    if (onDescription && onDescription.score > best) {
+      best = onDescription.score
+      bestRange = null
+    }
+    if (best === 0) {
+      const loose = fuzzy(token, title)
+      if (!loose) return null
+      best = loose.score
+      fuzzyPositions = loose.positions
+    }
+
+    if (best < STRONG_FLOOR) strong = false
+    total += best
+    if (bestRange) ranges.push(bestRange)
+    if (fuzzyPositions) {
+      for (const p of fuzzyPositions) ranges.push(toSourceRange(item.title, p, p + 1))
+    }
+  }
+
+  // The words as typed, in that order, are worth more than the words apart:
+  // "due days" should beat a title that merely has both somewhere.
+  if (query.tokens.length > 1 && query.text.length > 0) {
+    if (title === query.text) total += 500
+    else if (title.startsWith(query.text)) total += 400
+    else if (title.includes(query.text)) total += 250
+  }
+
+  return { score: total, ranges: mergeRanges(ranges), strong }
+}
+
+export type Ranked<T> = { item: T; match: Match }
+
+/**
+ * Ranks `items` for `query`, best first; items that do not match are left
+ * out. Ties go to the shorter title, then to the earlier item, so the list
+ * is stable while someone types.
+ */
+export function rank<T>(
+  query: PreparedQuery,
+  items: readonly { item: T; prepared: PreparedItem }[],
+  limit = Number.POSITIVE_INFINITY
+): Ranked<T>[] {
+  const out: (Ranked<T> & { order: number; length: number })[] = []
+  items.forEach(({ item, prepared }, order) => {
+    const match = matchItem(query, prepared)
+    if (match) out.push({ item, match, order, length: prepared.title.text.length })
+  })
+  out.sort((a, b) => b.match.score - a.match.score || a.length - b.length || a.order - b.order)
+  return out.slice(0, limit).map(({ item, match }) => ({ item, match }))
+}
+
+/**
+ * Splits a title into plain and highlighted pieces by its match ranges, for
+ * rendering without a dangerouslySetInnerHTML.
+ */
+export function splitByRanges(text: string, ranges: Range[]): { text: string; hit: boolean }[] {
+  if (ranges.length === 0) return [{ text, hit: false }]
+  const parts: { text: string; hit: boolean }[] = []
+  let cursor = 0
+  for (const [start, end] of ranges) {
+    if (start > cursor) parts.push({ text: text.slice(cursor, start), hit: false })
+    parts.push({ text: text.slice(start, end), hit: true })
+    cursor = end
+  }
+  if (cursor < text.length) parts.push({ text: text.slice(cursor), hit: false })
+  return parts
+}

+ 53 - 0
src/features/search/Lib/recent.ts

@@ -0,0 +1,53 @@
+/**
+ * What this person opened from the palette lately, kept in this browser.
+ * Shown before they type, so the setting they keep coming back to is one
+ * keystroke away. Local items are stored by id and resolved against the
+ * live index when read, so a page that no longer exists simply drops out;
+ * records keep their own words, since the index knows nothing about them.
+ */
+const KEY = 'torqvoice.search.recent'
+const MAX = 8
+
+export type RecentPick = {
+  id: string
+  href: string
+  title: string
+  subtitle?: string
+  kind: 'local' | 'record'
+  at: number
+}
+
+export function readRecent(): RecentPick[] {
+  try {
+    const raw = window.localStorage.getItem(KEY)
+    if (!raw) return []
+    const parsed: unknown = JSON.parse(raw)
+    if (!Array.isArray(parsed)) return []
+    return parsed.filter(
+      (p): p is RecentPick =>
+        !!p && typeof p === 'object' && typeof p.id === 'string' && typeof p.href === 'string'
+    )
+  } catch {
+    return []
+  }
+}
+
+export function pushRecent(pick: Omit<RecentPick, 'at'>): void {
+  try {
+    const next = [
+      { ...pick, at: Date.now() },
+      ...readRecent().filter((p) => p.id !== pick.id),
+    ].slice(0, MAX)
+    window.localStorage.setItem(KEY, JSON.stringify(next))
+  } catch {
+    // Private mode or a full store: the palette works without a memory.
+  }
+}
+
+export function clearRecent(): void {
+  try {
+    window.localStorage.removeItem(KEY)
+  } catch {
+    // Nothing to clear.
+  }
+}

+ 125 - 0
src/features/search/Lib/settingsItems.ts

@@ -0,0 +1,125 @@
+'use client'
+
+import { useMemo } from 'react'
+import { useTranslations } from 'next-intl'
+import type { LucideIcon } from 'lucide-react'
+import type { PlanFeatures } from '@/lib/features'
+import {
+  type SettingsPage,
+  type SettingsPageKey,
+  type SettingsVisibility,
+  visibleSettingsPages,
+} from '@/features/settings/Lib/settingsPages'
+import { SETTINGS_PAGE_ICONS } from '@/features/settings/Lib/settingsPageIcons'
+import { SETTINGS_SEARCH_INDEX } from '@/features/settings/Lib/searchIndex'
+import { type PreparedItem, prepareItem } from './matcher'
+
+/** The separator between breadcrumb words, the same in every language. */
+export const CRUMB = ' › '
+
+export type SettingsSearchItem = {
+  kind: 'settingsPage' | 'setting'
+  /** Unique across the palette; a page is `page:<key>`, a setting its index id. */
+  id: string
+  page: SettingsPageKey
+  /** Where to go, query and anchor included. */
+  href: string
+  title: string
+  description?: string
+  /** Category for a page; category and page for a setting. */
+  breadcrumb: string
+  keywords: string[]
+  /** The plan does not include the page; it opens, but shows the upgrade note. */
+  locked: boolean
+  anchor?: string
+  icon: LucideIcon
+}
+
+export type PreparedSettingsItem = { item: SettingsSearchItem; prepared: PreparedItem }
+
+function hrefFor(page: SettingsPage, query?: string, anchor?: string): string {
+  let href = page.href
+  if (query) href += `?${query}`
+  if (anchor) href += `#${anchor}`
+  return href
+}
+
+/**
+ * Every settings page and every indexed setting on this install, titled in
+ * the workshop's language and prepared for the matcher. Memoised on the
+ * install flags and the translator, so typing never rebuilds it.
+ */
+export function useSettingsSearchItems(
+  v: SettingsVisibility & { features?: PlanFeatures }
+): PreparedSettingsItem[] {
+  const t = useTranslations('settings')
+  const { isCloud, supportEnabled, features } = v
+
+  return useMemo(() => {
+    const pages = visibleSettingsPages({ isCloud, supportEnabled })
+    const byKey = new Map(pages.map((p) => [p.key, p]))
+    const out: PreparedSettingsItem[] = []
+
+    for (const page of pages) {
+      const title = t(`nav.items.${page.key}.title`)
+      const description = t(`nav.items.${page.key}.description`)
+      const item: SettingsSearchItem = {
+        kind: 'settingsPage',
+        id: `page:${page.key}`,
+        page: page.key,
+        href: page.href,
+        title,
+        description,
+        breadcrumb: t(`nav.categories.${page.category}`),
+        // The description's words double as aliases: "branding" finds Company.
+        keywords: [page.key.toLowerCase(), ...description.toLowerCase().split(/[\s,&]+/)].filter(
+          (w) => w.length > 2
+        ),
+        locked: !!(page.gate && features && !features[page.gate]),
+        icon: SETTINGS_PAGE_ICONS[page.key],
+      }
+      out.push({
+        item,
+        prepared: prepareItem({
+          title,
+          keywords: item.keywords,
+          description,
+          context: item.breadcrumb,
+        }),
+      })
+    }
+
+    for (const entry of SETTINGS_SEARCH_INDEX) {
+      const page = byKey.get(entry.page)
+      if (!page) continue
+      const pageTitle = t(`nav.items.${page.key}.title`)
+      const title = t(entry.title, entry.values)
+      const description = entry.description ? t(entry.description, entry.values) : undefined
+      const breadcrumb = `${t(`nav.categories.${page.category}`)}${CRUMB}${pageTitle}`
+      const item: SettingsSearchItem = {
+        kind: 'setting',
+        id: entry.id,
+        page: page.key,
+        href: hrefFor(page, entry.query, entry.anchor),
+        title,
+        description,
+        breadcrumb,
+        keywords: entry.keywords,
+        locked: !!(page.gate && features && !features[page.gate]),
+        anchor: entry.anchor,
+        icon: SETTINGS_PAGE_ICONS[page.key],
+      }
+      out.push({
+        item,
+        prepared: prepareItem({
+          title,
+          keywords: entry.keywords,
+          description,
+          context: pageTitle,
+        }),
+      })
+    }
+
+    return out
+  }, [t, isCloud, supportEnabled, features])
+}

+ 67 - 0
src/features/settings/Components/SettingsSpotlight.tsx

@@ -0,0 +1,67 @@
+'use client'
+
+import { useEffect } from 'react'
+import { usePathname, useSearchParams } from 'next/navigation'
+
+/** Fired by the search palette with the anchor it just navigated to. */
+export const SPOTLIGHT_EVENT = 'torqvoice:settings-spotlight'
+
+export function requestSpotlight(anchor: string) {
+  document.dispatchEvent(new CustomEvent(SPOTLIGHT_EVENT, { detail: anchor }))
+}
+
+const FOCUSABLE = 'input, textarea, select, button, [role="switch"], [role="combobox"], [tabindex]'
+
+/**
+ * Brings the element with this id into view and lights it up for a moment.
+ * The settings pages render after their data arrives, so the lookup retries
+ * for a couple of seconds before giving up quietly.
+ */
+function spotlight(anchor: string, attempt = 0) {
+  const el = document.getElementById(anchor)
+  if (!el) {
+    if (attempt < 50) window.setTimeout(() => spotlight(anchor, attempt + 1), 50)
+    return
+  }
+  const isCard = el.matches('[data-slot="card"]')
+  el.scrollIntoView({ block: isCard ? 'start' : 'center', behavior: 'smooth' })
+  el.classList.remove('settings-spotlight')
+  // Restart the animation even when the same anchor is asked for twice.
+  void el.offsetWidth
+  el.classList.add('settings-spotlight')
+  window.setTimeout(() => el.classList.remove('settings-spotlight'), 2600)
+  if (el.matches(FOCUSABLE) && !el.matches('[disabled]')) {
+    window.setTimeout(() => (el as HTMLElement).focus({ preventScroll: true }), 350)
+  }
+}
+
+/**
+ * Mounted once in the settings layout. Watches the URL hash, so a deep link
+ * from the palette (or a bookmark) lands on the card or the field rather than
+ * at the top of the page, and listens for the palette's own event for the
+ * case where only the hash changes and no navigation happens.
+ */
+export function SettingsSpotlight() {
+  const pathname = usePathname()
+  const searchParams = useSearchParams()
+
+  useEffect(() => {
+    const fromHash = () => {
+      const anchor = window.location.hash.slice(1)
+      if (anchor) spotlight(decodeURIComponent(anchor))
+    }
+    const fromEvent = (e: Event) => {
+      const anchor = (e as CustomEvent<string>).detail
+      if (anchor) spotlight(anchor)
+    }
+    fromHash()
+    window.addEventListener('hashchange', fromHash)
+    document.addEventListener(SPOTLIGHT_EVENT, fromEvent)
+    return () => {
+      window.removeEventListener('hashchange', fromHash)
+      document.removeEventListener(SPOTLIGHT_EVENT, fromEvent)
+    }
+  }, [pathname, searchParams])
+
+  return null
+}

+ 4 - 1
src/features/settings/Components/license-settings.tsx

@@ -89,6 +89,7 @@ export function LicenseSettings({
   return (
   return (
     <div className="space-y-6">
     <div className="space-y-6">
       <AppCard
       <AppCard
+        id="license-white-label"
         icon={Key}
         icon={Key}
         title={t('license.title')}
         title={t('license.title')}
         description={t('license.description')}
         description={t('license.description')}
@@ -96,6 +97,7 @@ export function LicenseSettings({
           !licenseValid && !demoMode ? (
           !licenseValid && !demoMode ? (
             <Button asChild variant="default" size="sm" className="shrink-0">
             <Button asChild variant="default" size="sm" className="shrink-0">
               <a
               <a
+                id="license-purchase"
                 href={`${process.env.NEXT_PUBLIC_TORQVOICE_COM_URL || 'https://torqvoice.com'}/subscriptions/white-label`}
                 href={`${process.env.NEXT_PUBLIC_TORQVOICE_COM_URL || 'https://torqvoice.com'}/subscriptions/white-label`}
                 target="_blank"
                 target="_blank"
                 rel="noopener noreferrer"
                 rel="noopener noreferrer"
@@ -108,7 +110,7 @@ export function LicenseSettings({
         }
         }
         contentClassName="space-y-4"
         contentClassName="space-y-4"
       >
       >
-        <div className="flex flex-wrap items-center gap-2">
+        <div id="license-status" className="flex flex-wrap items-center gap-2">
           <Label>{t('license.status')}</Label>
           <Label>{t('license.status')}</Label>
           {statusBadge}
           {statusBadge}
           {initialExpiresAt && (
           {initialExpiresAt && (
@@ -132,6 +134,7 @@ export function LicenseSettings({
         )}
         )}
         <div className="flex gap-2">
         <div className="flex gap-2">
           <Input
           <Input
+            id="license-key"
             placeholder={t('license.enterLicenseKey')}
             placeholder={t('license.enterLicenseKey')}
             value={licenseKey}
             value={licenseKey}
             onChange={(e) => setLicenseKey(e.target.value)}
             onChange={(e) => setLicenseKey(e.target.value)}

+ 7 - 2
src/features/settings/Components/passkey-settings.tsx

@@ -119,7 +119,12 @@ export function PasskeySettings() {
 
 
   return (
   return (
     <>
     <>
-      <AppCard icon={Fingerprint} title={t('account.passkey.title')} contentClassName="space-y-4">
+      <AppCard
+        id="account-passkeys"
+        icon={Fingerprint}
+        title={t('account.passkey.title')}
+        contentClassName="space-y-4"
+      >
         <p className="text-sm text-muted-foreground">{t('account.passkey.description')}</p>
         <p className="text-sm text-muted-foreground">{t('account.passkey.description')}</p>
 
 
         {loading ? (
         {loading ? (
@@ -179,7 +184,7 @@ export function PasskeySettings() {
         )}
         )}
 
 
         <Separator />
         <Separator />
-        <Button onClick={handleRegister} disabled={registering}>
+        <Button id="account-register-passkey" onClick={handleRegister} disabled={registering}>
           {registering ? (
           {registering ? (
             <Loader2 className="mr-2 h-4 w-4 animate-spin" />
             <Loader2 className="mr-2 h-4 w-4 animate-spin" />
           ) : (
           ) : (

+ 33 - 0
src/features/settings/Lib/searchIndex/about.ts

@@ -0,0 +1,33 @@
+import type { SettingsEntry } from './types'
+
+/** About: the card, the version line and the outside links. */
+export const aboutEntries: SettingsEntry[] = [
+  {
+    id: 'about.torqvoice',
+    page: 'about',
+    title: 'about.title',
+    anchor: 'about-torqvoice',
+    keywords: ['about', 'about torqvoice', 'info', 'website'],
+  },
+  {
+    id: 'about.version',
+    page: 'about',
+    title: 'about.version',
+    anchor: 'about-version',
+    keywords: ['version', 'app version', 'build', 'release', 'which version'],
+  },
+  {
+    id: 'about.changelog',
+    page: 'about',
+    title: 'about.changelog',
+    anchor: 'about-links',
+    keywords: ['changelog', 'release notes', "what's new", 'updates', 'new features'],
+  },
+  {
+    id: 'about.documentation',
+    page: 'about',
+    title: 'about.documentation',
+    anchor: 'about-links',
+    keywords: ['documentation', 'docs', 'help', 'manual', 'user guide'],
+  },
+]

+ 113 - 0
src/features/settings/Lib/searchIndex/account.ts

@@ -0,0 +1,113 @@
+import type { SettingsEntry } from './types'
+
+/**
+ * Account. Cards carry ids of their own; the signature card is wrapped in a
+ * div with one. The two-factor buttons swap with the state, so both land on
+ * the card. Deleting the account lives under Data now and is indexed there.
+ */
+export const accountEntries: SettingsEntry[] = [
+  {
+    id: 'account.profile',
+    page: 'account',
+    title: 'account.profileTitle',
+    description: 'account.profileDescription',
+    anchor: 'account-profile',
+    keywords: ['profile', 'my profile', 'user', 'personal details'],
+  },
+  {
+    id: 'account.name',
+    page: 'account',
+    title: 'account.name',
+    anchor: 'display-name',
+    keywords: ['name', 'display name', 'my name', 'full name'],
+  },
+  {
+    id: 'account.email',
+    page: 'account',
+    title: 'account.emailLabel',
+    anchor: 'profile-email',
+    keywords: ['email', 'change email', 'login email', 'verify email', 'email verification'],
+  },
+  {
+    id: 'account.signature',
+    page: 'account',
+    title: 'account.signatureTitle',
+    description: 'account.signatureDescription',
+    anchor: 'account-signature',
+    keywords: ['signature', 'sign', 'draw signature', 'signature image', 'autograph'],
+  },
+  {
+    id: 'account.password',
+    page: 'account',
+    title: 'account.changePasswordTitle',
+    description: 'account.changePasswordDescription',
+    anchor: 'account-password',
+    keywords: ['password', 'change password', 'new password', 'reset password'],
+  },
+  {
+    id: 'account.newPassword',
+    page: 'account',
+    title: 'account.newPassword',
+    anchor: 'newPassword',
+    keywords: ['new password', 'update password', 'stronger password'],
+  },
+  {
+    id: 'account.twoFactor',
+    page: 'account',
+    title: 'account.twoFactorTitle',
+    anchor: 'account-two-factor',
+    keywords: ['two-factor', '2fa', 'mfa', 'two factor', 'authenticator', 'totp', 'security'],
+  },
+  {
+    id: 'account.enable2FA',
+    page: 'account',
+    title: 'account.enable2FA',
+    anchor: 'account-two-factor',
+    keywords: ['enable two-factor', 'turn on 2fa', 'authenticator app', 'google authenticator'],
+  },
+  {
+    id: 'account.disable2FA',
+    page: 'account',
+    title: 'account.disable2FA',
+    anchor: 'account-two-factor',
+    keywords: ['disable two-factor', 'turn off 2fa', 'remove 2fa'],
+  },
+  {
+    id: 'account.devices',
+    page: 'account',
+    title: 'account.devicesTitle',
+    description: 'account.devicesDescription',
+    anchor: 'account-devices',
+    keywords: ['devices', 'sessions', 'signed in', 'logged in', 'active sessions', 'browsers'],
+  },
+  {
+    id: 'account.signOutOthers',
+    page: 'account',
+    title: 'account.signOutOthers',
+    anchor: 'account-devices',
+    keywords: ['sign out other devices', 'log out everywhere', 'end sessions', 'logout', 'revoke'],
+  },
+  {
+    id: 'account.passkeys',
+    page: 'account',
+    title: 'account.passkey.title',
+    description: 'account.passkey.description',
+    anchor: 'account-passkeys',
+    keywords: [
+      'passkey',
+      'passkeys',
+      'face id',
+      'touch id',
+      'fingerprint',
+      'security key',
+      'webauthn',
+    ],
+  },
+  {
+    id: 'account.registerPasskey',
+    page: 'account',
+    title: 'account.passkey.register',
+    anchor: 'account-register-passkey',
+    keywords: ['add passkey', 'register passkey', 'new passkey', 'biometric login'],
+  },
+]

+ 114 - 0
src/features/settings/Lib/searchIndex/alerts.ts

@@ -0,0 +1,114 @@
+import type { SettingsEntry } from './types'
+
+/**
+ * Alerts. Three cards with ids of their own; the switches and inputs carry
+ * theirs. Fields under a switch only render once it is on.
+ */
+export const alertsEntries: SettingsEntry[] = [
+  {
+    id: 'alerts.technicianStatus',
+    page: 'alerts',
+    title: 'alerts.technicianStatus.title',
+    description: 'alerts.technicianStatus.description',
+    anchor: 'alerts-technician-status',
+    keywords: ['technician app', 'mobile app', 'android', 'ios', 'phone app', 'job moved'],
+  },
+  {
+    id: 'alerts.technicianStatusInApp',
+    page: 'alerts',
+    title: 'alerts.technicianStatus.inApp',
+    description: 'alerts.technicianStatus.inAppHint',
+    anchor: 'technicianStatus',
+    keywords: [
+      'notify the desk',
+      'job finished notification',
+      'technician notification',
+      'status alert',
+    ],
+  },
+  {
+    id: 'alerts.serviceRequest',
+    page: 'alerts',
+    title: 'alerts.serviceRequest.title',
+    description: 'alerts.serviceRequest.description',
+    anchor: 'alerts-service-request',
+    keywords: [
+      'service request',
+      'portal request',
+      'booking request',
+      'customer portal',
+      'new request alert',
+    ],
+  },
+  {
+    id: 'alerts.serviceRequestEmail',
+    page: 'alerts',
+    title: 'alerts.serviceRequest.email',
+    description: 'alerts.serviceRequest.emailHint',
+    anchor: 'serviceRequestEmail',
+    keywords: ['service request email', 'email notification', 'email alert', 'request email'],
+  },
+  {
+    id: 'alerts.serviceRequestRecipients',
+    page: 'alerts',
+    title: 'alerts.serviceRequest.recipients',
+    description: 'alerts.serviceRequest.recipientsHint',
+    anchor: 'serviceRequestRecipients',
+    keywords: ['alert recipients', 'send to', 'notification email address', 'who gets alerts'],
+  },
+  {
+    id: 'alerts.lowStock',
+    page: 'alerts',
+    title: 'alerts.lowStock.title',
+    description: 'alerts.lowStock.description',
+    anchor: 'alerts-low-stock',
+    keywords: ['low stock', 'reorder', 'parts need reordering', 'out of stock', 'inventory alert'],
+  },
+  {
+    id: 'alerts.lowStockEnabled',
+    page: 'alerts',
+    title: 'alerts.lowStock.enable',
+    description: 'alerts.lowStock.enableHint',
+    anchor: 'lowStockEnabled',
+    keywords: ['enable low stock alerts', 'stock alerts', 'turn on reorder alerts'],
+  },
+  {
+    id: 'alerts.lowStockThreshold',
+    page: 'alerts',
+    title: 'alerts.lowStock.defaultThreshold',
+    description: 'alerts.lowStock.defaultThresholdHint',
+    anchor: 'lowStockThreshold',
+    keywords: ['reorder point', 'min qty', 'minimum quantity', 'stock threshold', 'reorder level'],
+  },
+  {
+    id: 'alerts.lowStockInApp',
+    page: 'alerts',
+    title: 'alerts.lowStock.inApp',
+    description: 'alerts.lowStock.inAppHint',
+    anchor: 'lowStockInApp',
+    keywords: ['in-app notification', 'notification bell', 'low stock notification'],
+  },
+  {
+    id: 'alerts.lowStockEmail',
+    page: 'alerts',
+    title: 'alerts.lowStock.email',
+    description: 'alerts.lowStock.emailHint',
+    anchor: 'lowStockEmail',
+    keywords: ['email digest', 'low stock email', 'reorder email'],
+  },
+  {
+    id: 'alerts.lowStockInterval',
+    page: 'alerts',
+    title: 'alerts.lowStock.emailInterval',
+    description: 'alerts.lowStock.emailIntervalHint',
+    anchor: 'lowStockInterval',
+    keywords: ['email interval', 'hours between emails', 'digest frequency', 'email limit'],
+  },
+  {
+    id: 'alerts.lowStockCheckNow',
+    page: 'alerts',
+    title: 'alerts.lowStock.checkNow',
+    anchor: 'alerts-low-stock-check-now',
+    keywords: ['check stock now', 'run low stock check', 'test alerts'],
+  },
+]

+ 21 - 0
src/features/settings/Lib/searchIndex/appearance.ts

@@ -0,0 +1,21 @@
+import type { SettingsEntry } from './types'
+
+/** Appearance: theme and font, both kept on this device. */
+export const appearanceEntries: SettingsEntry[] = [
+  {
+    id: 'appearance.theme',
+    page: 'appearance',
+    title: 'appearance.themeLabel',
+    description: 'appearance.themeHint',
+    anchor: 'appearance-theme',
+    keywords: ['theme', 'dark mode', 'light mode', 'colour', 'color scheme', 'display'],
+  },
+  {
+    id: 'appearance.font',
+    page: 'appearance',
+    title: 'appearance.fontLabel',
+    description: 'appearance.fontHint',
+    anchor: 'appearance-font',
+    keywords: ['font', 'typeface', 'text size', 'typography', 'lettering'],
+  },
+]

+ 96 - 0
src/features/settings/Lib/searchIndex/company.ts

@@ -0,0 +1,96 @@
+import type { SettingsEntry } from './types'
+
+/**
+ * Company. Two cards, logo and details; the detail fields are reached through
+ * the inputs' own ids.
+ */
+export const companyEntries: SettingsEntry[] = [
+  {
+    id: 'company.logo',
+    page: 'company',
+    title: 'company.logoTitle',
+    description: 'company.logoHint',
+    anchor: 'company-logo',
+    keywords: ['logo', 'upload logo', 'company logo', 'branding', 'brand', 'image'],
+  },
+  {
+    id: 'company.details',
+    page: 'company',
+    title: 'company.detailsTitle',
+    anchor: 'company-details',
+    keywords: [
+      'company details',
+      'business details',
+      'business info',
+      'workshop details',
+      'contact info',
+    ],
+  },
+  {
+    id: 'company.shopName',
+    page: 'company',
+    title: 'company.shopName',
+    anchor: 'workshopName',
+    keywords: ['company name', 'shop name', 'workshop name', 'business name', 'rename'],
+  },
+  {
+    id: 'company.orgNumber',
+    page: 'company',
+    title: 'company.orgNumber',
+    anchor: 'orgNumber',
+    keywords: [
+      'organization number',
+      'org number',
+      'business number',
+      'vat number',
+      'company registration',
+      'orgnr',
+      'abn',
+      'cvr',
+    ],
+  },
+  {
+    id: 'company.orgNumberLabel',
+    page: 'company',
+    title: 'company.orgNumberLabel',
+    description: 'company.orgNumberLabelHint',
+    anchor: 'orgNumberLabel',
+    keywords: ['org number label', 'vat no', 'abn', 'registration label'],
+  },
+  {
+    id: 'company.phone',
+    page: 'company',
+    title: 'company.phone',
+    anchor: 'workshopPhone',
+    keywords: ['phone', 'phone number', 'telephone', 'company phone', 'contact number'],
+  },
+  {
+    id: 'company.email',
+    page: 'company',
+    title: 'company.email',
+    anchor: 'workshopEmail',
+    keywords: ['email', 'company email', 'workshop email', 'contact email', 'reply address'],
+  },
+  {
+    id: 'company.slogan',
+    page: 'company',
+    title: 'company.slogan',
+    description: 'company.sloganHint',
+    anchor: 'workshopSlogan',
+    keywords: ['slogan', 'tagline', 'motto', 'subtitle'],
+  },
+  {
+    id: 'company.address',
+    page: 'company',
+    title: 'company.address',
+    anchor: 'workshopAddress',
+    keywords: ['address', 'street', 'location', 'postal address', 'company address'],
+  },
+  {
+    id: 'company.addNewCompany',
+    page: 'company',
+    title: 'company.addNewCompany',
+    anchor: 'company-add-company',
+    keywords: ['add company', 'new company', 'new workshop', 'second workshop', 'organization'],
+  },
+]

+ 79 - 0
src/features/settings/Lib/searchIndex/customerPortal.ts

@@ -0,0 +1,79 @@
+import type { SettingsEntry } from './types'
+
+/**
+ * Customer portal. Everything below the enable switch, and the whole
+ * background card, renders only once the portal is on.
+ */
+export const customerPortalEntries: SettingsEntry[] = [
+  {
+    id: 'customerPortal.enabled',
+    page: 'customerPortal',
+    title: 'portal.enablePortal',
+    description: 'portal.enablePortalHint',
+    anchor: 'portal-enabled',
+    keywords: ['enable portal', 'customer portal', 'self-service', 'customer login', 'magic link'],
+  },
+  {
+    id: 'customerPortal.slug',
+    page: 'customerPortal',
+    title: 'portal.customSlug',
+    description: 'portal.slugHint',
+    anchor: 'portal-slug',
+    keywords: ['portal slug', 'portal address', 'custom url', 'portal name'],
+  },
+  {
+    id: 'customerPortal.description',
+    page: 'customerPortal',
+    title: 'portal.descriptionLabel',
+    description: 'portal.descriptionHint',
+    anchor: 'portal-description',
+    keywords: ['about us', 'workshop description', 'landing page text', 'portal text'],
+  },
+  {
+    id: 'customerPortal.hours',
+    page: 'customerPortal',
+    title: 'portal.hoursLabel',
+    description: 'portal.hoursHint',
+    anchor: 'portal-hours',
+    keywords: ['opening hours', 'business hours', 'open times', 'åpningstider'],
+  },
+  {
+    id: 'customerPortal.url',
+    page: 'customerPortal',
+    title: 'portal.portalUrl',
+    description: 'portal.shareHint',
+    anchor: 'customer-portal-url',
+    keywords: ['portal link', 'portal url', 'share portal', 'copy link'],
+  },
+  {
+    id: 'customerPortal.requestAlerts',
+    page: 'customerPortal',
+    title: 'portal.serviceRequestAlertsLink',
+    description: 'portal.serviceRequestAlertsHint',
+    anchor: 'customer-portal-request-alerts',
+    keywords: ['service requests', 'booking requests', 'request alerts', 'notifications'],
+  },
+  {
+    id: 'customerPortal.background',
+    page: 'customerPortal',
+    title: 'portal.background.title',
+    description: 'portal.background.description',
+    anchor: 'customer-portal-background',
+    keywords: ['portal background', 'portal look', 'landing page', 'portal theme'],
+  },
+  {
+    id: 'customerPortal.backgroundTemplate',
+    page: 'customerPortal',
+    title: 'portal.background.chooseTemplate',
+    anchor: 'customer-portal-background-template',
+    keywords: ['background template', 'gradient', 'portal colours'],
+  },
+  {
+    id: 'customerPortal.backgroundImage',
+    page: 'customerPortal',
+    title: 'portal.background.uploadLabel',
+    description: 'portal.background.uploadHint',
+    anchor: 'customer-portal-background-image',
+    keywords: ['background image', 'upload background', 'portal photo', 'cover image'],
+  },
+]

+ 170 - 0
src/features/settings/Lib/searchIndex/data.ts

@@ -0,0 +1,170 @@
+import type { SettingsEntry } from './types'
+
+/**
+ * Data: backup status, the import hub (one row per source), export, and the
+ * danger zone. The import history and sample data cards come from other
+ * namespaces and are reached through the import entry's keywords.
+ */
+export const dataEntries: SettingsEntry[] = [
+  {
+    id: 'data.protection',
+    page: 'data',
+    title: 'data.backupStatus.title',
+    anchor: 'data-protection',
+    keywords: [
+      'backup status',
+      'last backup',
+      'data protection',
+      'automatic backup',
+      'hourly backup',
+      'encryption',
+      'gdpr',
+    ],
+  },
+  {
+    id: 'data.import',
+    page: 'data',
+    title: 'data.importTitle',
+    description: 'data.importHubDescription',
+    anchor: 'data-import',
+    keywords: [
+      'import',
+      'import data',
+      'migrate',
+      'move data',
+      'switch system',
+      'import history',
+      'undo import',
+    ],
+  },
+  {
+    id: 'data.importSpreadsheet',
+    page: 'data',
+    title: 'data.sourceSpreadsheet',
+    description: 'data.sourceSpreadsheetDesc',
+    anchor: 'data-import-spreadsheet',
+    keywords: [
+      'csv',
+      'excel',
+      'xlsx',
+      'spreadsheet',
+      'import customers',
+      'import vehicles',
+      'google contacts',
+      'outlook',
+    ],
+  },
+  {
+    id: 'data.importLubelog',
+    page: 'data',
+    title: 'data.lubelogTitle',
+    description: 'data.sourceLubelogDesc',
+    anchor: 'data-import-lubelog',
+    keywords: ['lubelog', 'lube log', 'import lubelog', 'service records import'],
+  },
+  {
+    id: 'data.importInvoiceNinja',
+    page: 'data',
+    title: 'data.invoiceNinjaTitle',
+    description: 'data.sourceInvoiceNinjaDesc',
+    anchor: 'data-import-invoice-ninja',
+    keywords: ['invoice ninja', 'invoiceninja', 'import invoices', 'import clients'],
+  },
+  {
+    id: 'data.restore',
+    page: 'data',
+    title: 'data.restoreTitle',
+    description: 'data.sourceBackupDesc',
+    anchor: 'data-import-backup',
+    keywords: [
+      'restore backup',
+      'import backup',
+      'upload backup',
+      'recover data',
+      'restore',
+      'zip backup',
+    ],
+  },
+  {
+    id: 'data.export',
+    page: 'data',
+    title: 'data.exportTitle',
+    description: 'data.exportDescription',
+    anchor: 'data-export',
+    keywords: [
+      'export',
+      'export backup',
+      'backup',
+      'download data',
+      'export data',
+      'data export',
+      'zip',
+    ],
+  },
+  {
+    id: 'data.downloadBackup',
+    page: 'data',
+    title: 'data.downloadBackup',
+    anchor: 'data-export-options',
+    keywords: [
+      'download backup',
+      'include in backup',
+      'backup files',
+      'export files',
+      'audit logs',
+      'select all',
+    ],
+  },
+  {
+    id: 'data.dangerZone',
+    page: 'data',
+    title: 'account.dangerZone',
+    anchor: 'data-danger-zone',
+    keywords: ['danger zone', 'delete', 'remove data', 'erase'],
+  },
+  {
+    id: 'data.deleteContent',
+    page: 'data',
+    title: 'account.deleteContentTitle',
+    description: 'account.deleteContentDescription',
+    anchor: 'data-delete-content',
+    keywords: [
+      'delete content',
+      'delete all content',
+      'delete customers',
+      'delete vehicles',
+      'clear data',
+      'wipe data',
+      'reset',
+    ],
+  },
+  {
+    id: 'data.deleteWorkshop',
+    page: 'data',
+    title: 'account.deleteWorkshopTitle',
+    description: 'account.deleteWorkshopDescription',
+    anchor: 'data-delete-workshop',
+    keywords: [
+      'delete workshop',
+      'delete organization',
+      'close workshop',
+      'remove workshop',
+      'delete company',
+    ],
+  },
+  {
+    id: 'data.deleteAccount',
+    page: 'data',
+    title: 'account.deleteAccountTitle',
+    description: 'account.deleteAccountDescription',
+    anchor: 'data-delete-account',
+    keywords: [
+      'delete account',
+      'close account',
+      'remove account',
+      'delete my data',
+      'right to be forgotten',
+      'gdpr',
+    ],
+  },
+]

+ 57 - 0
src/features/settings/Lib/searchIndex/emailTemplates.ts

@@ -0,0 +1,57 @@
+import type { SettingsEntry } from './types'
+
+/**
+ * Email templates. The group cards and the rows inside them are drawn from
+ * EMAIL_KIND_GROUPS, so there is no literal id to land on: these entries open
+ * the page and let the person scroll to the group.
+ */
+export const emailTemplatesEntries: SettingsEntry[] = [
+  {
+    id: 'emailTemplates.documents',
+    page: 'emailTemplates',
+    title: 'emailTemplates.groups.documents',
+    keywords: ['document emails', 'invoice email', 'quote email', 'email wording'],
+  },
+  {
+    id: 'emailTemplates.invoiceSent',
+    page: 'emailTemplates',
+    title: 'emailTemplates.kinds.invoice_sent.name',
+    description: 'emailTemplates.kinds.invoice_sent.description',
+    keywords: ['invoice email', 'invoice mail', 'email template', 'invoice message'],
+  },
+  {
+    id: 'emailTemplates.quoteSent',
+    page: 'emailTemplates',
+    title: 'emailTemplates.kinds.quote_sent.name',
+    description: 'emailTemplates.kinds.quote_sent.description',
+    keywords: ['quote email', 'estimate email', 'quote mail'],
+  },
+  {
+    id: 'emailTemplates.inspectionSent',
+    page: 'emailTemplates',
+    title: 'emailTemplates.kinds.inspection_sent.name',
+    description: 'emailTemplates.kinds.inspection_sent.description',
+    keywords: ['inspection email', 'inspection report email', 'report mail'],
+  },
+  {
+    id: 'emailTemplates.message',
+    page: 'emailTemplates',
+    title: 'emailTemplates.kinds.message.name',
+    description: 'emailTemplates.kinds.message.description',
+    keywords: ['customer message email', 'notification email', 'reminder email', 'status email'],
+  },
+  {
+    id: 'emailTemplates.portalSignin',
+    page: 'emailTemplates',
+    title: 'emailTemplates.kinds.portal_signin.name',
+    description: 'emailTemplates.kinds.portal_signin.description',
+    keywords: ['portal sign-in email', 'magic link email', 'login email', 'portal email'],
+  },
+  {
+    id: 'emailTemplates.teamInvitation',
+    page: 'emailTemplates',
+    title: 'emailTemplates.kinds.team_invitation.name',
+    description: 'emailTemplates.kinds.team_invitation.description',
+    keywords: ['invitation email', 'invite email', 'team invite', 'new user email'],
+  },
+]

+ 66 - 0
src/features/settings/Lib/searchIndex/index.ts

@@ -0,0 +1,66 @@
+import type { SettingsEntry } from './types'
+import { aboutEntries } from './about'
+import { accountEntries } from './account'
+import { alertsEntries } from './alerts'
+import { appearanceEntries } from './appearance'
+import { companyEntries } from './company'
+import { customerPortalEntries } from './customerPortal'
+import { dataEntries } from './data'
+import { emailTemplatesEntries } from './emailTemplates'
+import { inspectionRemindersEntries } from './inspectionReminders'
+import { installEntries } from './install'
+import { integrationsEntries } from './integrations'
+import { invoiceEntries } from './invoice'
+import { licenseEntries } from './license'
+import { localizationEntries } from './localization'
+import { maintenanceEntries } from './maintenance'
+import { paymentEntries } from './payment'
+import { reportScheduleEntries } from './reportSchedule'
+import { subscriptionEntries } from './subscription'
+import { supportEntries } from './support'
+import { taxEntries } from './tax'
+import { teamEntries } from './team'
+import { templatesEntries } from './templates'
+import { tireHotelEntries } from './tireHotel'
+import { warrantyEntries } from './warranty'
+import { webhooksEntries } from './webhooks'
+import { workOrderStatusesEntries } from './workOrderStatuses'
+import { workshopEntries } from './workshop'
+
+export type { SettingsEntry } from './types'
+
+/**
+ * Everything inside the settings pages that search can land on, one module
+ * per page. `search-index.test.ts` holds each entry to the messages and the
+ * page source, so a renamed key or a dropped anchor fails the tests rather
+ * than a search.
+ */
+export const SETTINGS_SEARCH_INDEX: readonly SettingsEntry[] = [
+  ...companyEntries,
+  ...subscriptionEntries,
+  ...accountEntries,
+  ...teamEntries,
+  ...localizationEntries,
+  ...appearanceEntries,
+  ...supportEntries,
+  ...invoiceEntries,
+  ...templatesEntries,
+  ...paymentEntries,
+  ...taxEntries,
+  ...warrantyEntries,
+  ...emailTemplatesEntries,
+  ...customerPortalEntries,
+  ...workshopEntries,
+  ...workOrderStatusesEntries,
+  ...maintenanceEntries,
+  ...inspectionRemindersEntries,
+  ...alertsEntries,
+  ...tireHotelEntries,
+  ...reportScheduleEntries,
+  ...integrationsEntries,
+  ...webhooksEntries,
+  ...dataEntries,
+  ...installEntries,
+  ...licenseEntries,
+  ...aboutEntries,
+]

+ 84 - 0
src/features/settings/Lib/searchIndex/inspectionReminders.ts

@@ -0,0 +1,84 @@
+import type { SettingsEntry } from './types'
+
+/**
+ * Inspection reminders. The number fields are drawn by a helper, so each sits
+ * in a wrapper with a literal id; the select and phone input have their own.
+ */
+export const inspectionRemindersEntries: SettingsEntry[] = [
+  {
+    id: 'inspectionReminders.booking',
+    page: 'inspectionReminders',
+    title: 'inspectionReminders.title',
+    description: 'inspectionReminders.description',
+    anchor: 'inspection-reminders-booking',
+    keywords: [
+      'inspection reminders',
+      'booking link',
+      'periodic inspection',
+      'eu control',
+      'eu-kontroll',
+      'mot',
+    ],
+  },
+  {
+    id: 'inspectionReminders.duration',
+    page: 'inspectionReminders',
+    title: 'inspectionReminders.duration',
+    description: 'inspectionReminders.durationHint',
+    anchor: 'inspection-reminders-duration',
+    keywords: ['inspection duration', 'slot length', 'booking length', 'minutes per inspection'],
+  },
+  {
+    id: 'inspectionReminders.leadDays',
+    page: 'inspectionReminders',
+    title: 'inspectionReminders.leadDays',
+    description: 'inspectionReminders.leadDaysHint',
+    anchor: 'inspection-reminders-lead-days',
+    keywords: ['lead time', 'earliest booking', 'days ahead', 'notice period'],
+  },
+  {
+    id: 'inspectionReminders.horizon',
+    page: 'inspectionReminders',
+    title: 'inspectionReminders.horizonWeeks',
+    description: 'inspectionReminders.horizonHint',
+    anchor: 'inspection-reminders-horizon',
+    keywords: ['booking horizon', 'weeks ahead', 'how far ahead', 'booking window'],
+  },
+  {
+    id: 'inspectionReminders.reserve',
+    page: 'inspectionReminders',
+    title: 'inspectionReminders.reserve',
+    description: 'inspectionReminders.reserveHint',
+    anchor: 'inspection-reminders-reserve',
+    keywords: ['walk-ins', 'reserve bays', 'keep capacity free', 'drop-in'],
+  },
+  {
+    id: 'inspectionReminders.linkValid',
+    page: 'inspectionReminders',
+    title: 'inspectionReminders.linkValidDays',
+    anchor: 'inspection-reminders-link-valid',
+    keywords: ['link expiry', 'link valid', 'booking link expires', 'link lifetime'],
+  },
+  {
+    id: 'inspectionReminders.bookingMode',
+    page: 'inspectionReminders',
+    title: 'inspectionReminders.bookingMode',
+    description: 'inspectionReminders.bookingModeHint',
+    anchor: 'ir-mode',
+    keywords: [
+      'booking mode',
+      'direct booking',
+      'booking request',
+      'approve bookings',
+      'online booking',
+    ],
+  },
+  {
+    id: 'inspectionReminders.phone',
+    page: 'inspectionReminders',
+    title: 'inspectionReminders.phone',
+    description: 'inspectionReminders.phoneHint',
+    anchor: 'ir-phone',
+    keywords: ['phone number', 'contact phone', 'reminder phone', 'call us'],
+  },
+]

+ 28 - 0
src/features/settings/Lib/searchIndex/install.ts

@@ -0,0 +1,28 @@
+import type { SettingsEntry } from './types'
+
+/** Install app: the one card, and the switch back for a dismissed banner. */
+export const installEntries: SettingsEntry[] = [
+  {
+    id: 'install.app',
+    page: 'install',
+    title: 'install.title',
+    anchor: 'install-app',
+    keywords: [
+      'install app',
+      'pwa',
+      'home screen',
+      'add to home screen',
+      'desktop app',
+      'mobile app',
+      'add torqvoice to this device',
+    ],
+  },
+  {
+    id: 'install.showBanner',
+    page: 'install',
+    title: 'install.showBanner',
+    description: 'install.bannerHidden',
+    anchor: 'install-banner',
+    keywords: ['install banner', 'show banner', 'install prompt', 'hidden banner'],
+  },
+]

+ 77 - 0
src/features/settings/Lib/searchIndex/integrations.ts

@@ -0,0 +1,77 @@
+import type { SettingsEntry } from './types'
+
+/**
+ * Integrations catalog. The cards and controls are labelled from the
+ * `integrations` namespace, so the entries borrow the nav item's title and
+ * description and carry the specific words as keywords. Individual
+ * connectors are indexed from the registry, not here.
+ */
+export const integrationsEntries: SettingsEntry[] = [
+  {
+    id: 'integrations.connected',
+    page: 'integrations',
+    title: 'nav.items.integrations.title',
+    description: 'nav.items.integrations.description',
+    anchor: 'integrations-connected',
+    keywords: [
+      'connected',
+      'connected services',
+      'my integrations',
+      'manage integration',
+      'active integrations',
+      'connected apps',
+    ],
+  },
+  {
+    id: 'integrations.available',
+    page: 'integrations',
+    title: 'nav.items.integrations.title',
+    description: 'nav.items.integrations.description',
+    anchor: 'integrations-available',
+    keywords: [
+      'available integrations',
+      'add integration',
+      'connect app',
+      'app store',
+      'marketplace',
+      'catalog',
+      'calendars',
+      'video calls',
+    ],
+  },
+  {
+    id: 'integrations.search',
+    page: 'integrations',
+    title: 'nav.items.integrations.title',
+    anchor: 'integrations-search',
+    keywords: ['search integrations', 'find integration', 'find connector', 'connectors'],
+  },
+  {
+    id: 'integrations.categories',
+    page: 'integrations',
+    title: 'nav.items.integrations.title',
+    anchor: 'integrations-categories',
+    keywords: [
+      'integration categories',
+      'accounting integration',
+      'calendar integration',
+      'payment integration',
+      'messaging integration',
+      'plate lookup',
+      'registry',
+    ],
+  },
+  {
+    id: 'integrations.suggest',
+    page: 'integrations',
+    title: 'nav.items.integrations.title',
+    anchor: 'integrations-suggest',
+    keywords: [
+      'suggest integration',
+      'request integration',
+      'missing integration',
+      'new integration',
+      'feature request',
+    ],
+  },
+]

+ 206 - 0
src/features/settings/Lib/searchIndex/invoice.ts

@@ -0,0 +1,206 @@
+import type { SettingsEntry } from './types'
+
+/**
+ * Invoice & Quotes. Cards carry ids of their own; fields are reached through
+ * the inputs' existing ids. The shop fee fields only render once the fee is
+ * switched on, and the lock triggers sit under their switches. Custom fields
+ * live behind `?tab=customFields`.
+ */
+export const invoiceEntries: SettingsEntry[] = [
+  {
+    id: 'invoice.numbering',
+    page: 'invoice',
+    title: 'invoice.numberingTitle',
+    description: 'invoice.numberingDescription',
+    anchor: 'invoice-numbering',
+    keywords: ['numbering', 'invoice number', 'quote number', 'sequence', 'dates'],
+  },
+  {
+    id: 'invoice.invoicePrefix',
+    page: 'invoice',
+    title: 'invoice.invoiceNumberFormat',
+    anchor: 'invoicePrefix',
+    keywords: ['invoice prefix', 'prefix', 'number format', 'invoice number format', 'year'],
+  },
+  {
+    id: 'invoice.nextInvoiceNumber',
+    page: 'invoice',
+    title: 'invoice.nextInvoiceNumber',
+    anchor: 'invoiceStartNumber',
+    keywords: [
+      'next invoice number',
+      'start number',
+      'first invoice number',
+      'counter',
+      'continue numbering',
+    ],
+  },
+  {
+    id: 'invoice.dueDays',
+    page: 'invoice',
+    title: 'invoice.dueDays',
+    description: 'invoice.dueDaysHint',
+    anchor: 'dueDays',
+    keywords: ['due days', 'due date', 'payment due', 'net 30', 'days to pay', 'credit days'],
+  },
+  {
+    id: 'invoice.quotePrefix',
+    page: 'invoice',
+    title: 'invoice.quoteNumberFormat',
+    anchor: 'quotePrefix',
+    keywords: ['quote prefix', 'estimate prefix', 'quote number', 'quote number format'],
+  },
+  {
+    id: 'invoice.quoteValidDays',
+    page: 'invoice',
+    title: 'invoice.quoteValidDays',
+    description: 'invoice.quoteValidDaysHint',
+    anchor: 'quoteValidDays',
+    keywords: ['quote validity', 'quote expiry', 'valid until', 'estimate valid', 'expires'],
+  },
+  {
+    id: 'invoice.documents',
+    page: 'invoice',
+    title: 'invoice.documentsTitle',
+    description: 'invoice.documentsDescription',
+    anchor: 'invoice-documents',
+    keywords: ['documents', 'footer', 'pdf', 'email attachment'],
+  },
+  {
+    id: 'invoice.footer',
+    page: 'invoice',
+    title: 'invoice.customFooter',
+    description: 'invoice.footerHint',
+    anchor: 'footerNote',
+    keywords: ['footer', 'footer note', 'invoice footer', 'thank you note', 'bottom text'],
+  },
+  {
+    id: 'invoice.attachPdf',
+    page: 'invoice',
+    title: 'invoice.attachPdfLabel',
+    description: 'invoice.attachPdfHint',
+    anchor: 'attachPdf',
+    keywords: ['attach pdf', 'pdf attachment', 'email attachment', 'send link instead'],
+  },
+  {
+    id: 'invoice.shopFee',
+    page: 'invoice',
+    title: 'invoice.shopFee.title',
+    description: 'invoice.shopFee.description',
+    anchor: 'invoice-shop-fee',
+    keywords: [
+      'shop fee',
+      'shop supplies',
+      'environmental fee',
+      'sundries',
+      'consumables',
+      'disposal fee',
+      'miljøavgift',
+    ],
+  },
+  {
+    id: 'invoice.shopFeeEnabled',
+    page: 'invoice',
+    title: 'invoice.shopFee.enabled',
+    description: 'invoice.shopFee.enabledHint',
+    anchor: 'shopFeeEnabled',
+    keywords: ['charge shop fee', 'enable shop fee', 'shop supplies charge'],
+  },
+  {
+    id: 'invoice.shopFeeLabel',
+    page: 'invoice',
+    title: 'invoice.shopFee.label',
+    anchor: 'shopFeeLabel',
+    keywords: ['shop fee name', 'shop fee label', 'fee text'],
+  },
+  {
+    id: 'invoice.shopFeeAppliesTo',
+    page: 'invoice',
+    title: 'invoice.shopFee.appliesTo',
+    anchor: 'shopFeeAppliesTo',
+    keywords: ['shop fee on quotes', 'shop fee on work orders', 'fee applies to'],
+  },
+  {
+    id: 'invoice.shopFeeMode',
+    page: 'invoice',
+    title: 'invoice.shopFee.mode',
+    anchor: 'shopFeeMode',
+    keywords: ['flat fee', 'percentage fee', 'shop fee type', 'how fee is charged'],
+  },
+  {
+    id: 'invoice.shopFeeAmount',
+    page: 'invoice',
+    title: 'invoice.shopFee.amount',
+    description: 'invoice.shopFee.amountHint',
+    anchor: 'shopFeeAmount',
+    keywords: ['shop fee amount', 'flat amount', 'fixed fee'],
+  },
+  {
+    id: 'invoice.shopFeePercent',
+    page: 'invoice',
+    title: 'invoice.shopFee.percent',
+    anchor: 'shopFeePercent',
+    keywords: ['shop fee percent', 'shop fee percentage', 'percent of labor'],
+  },
+  {
+    id: 'invoice.shopFeeBase',
+    page: 'invoice',
+    title: 'invoice.shopFee.base',
+    anchor: 'shopFeeBase',
+    keywords: ['percentage of', 'labor and parts', 'fee base'],
+  },
+  {
+    id: 'invoice.shopFeeCap',
+    page: 'invoice',
+    title: 'invoice.shopFee.cap',
+    description: 'invoice.shopFee.capHint',
+    anchor: 'shopFeeCap',
+    keywords: ['maximum fee', 'fee cap', 'shop fee limit', 'max shop fee'],
+  },
+  {
+    id: 'invoice.lock',
+    page: 'invoice',
+    title: 'invoice.lockTitle',
+    description: 'invoice.lockDescription',
+    anchor: 'invoice-lock',
+    keywords: ['lock', 'locked invoice', 'freeze', 'read only', 'finalize', 'prevent edits'],
+  },
+  {
+    id: 'invoice.invoiceLock',
+    page: 'invoice',
+    title: 'invoice.lockInvoicesLabel',
+    anchor: 'invoiceLockEnabled',
+    keywords: ['lock invoices', 'lock when paid', 'lock when sent', 'invoice lock'],
+  },
+  {
+    id: 'invoice.quoteLock',
+    page: 'invoice',
+    title: 'invoice.lockQuotesLabel',
+    anchor: 'quoteLockEnabled',
+    keywords: ['lock quotes', 'lock when accepted', 'quote lock'],
+  },
+  {
+    id: 'invoice.freezeLegacy',
+    page: 'invoice',
+    title: 'invoice.freezeTitle',
+    anchor: 'invoice-freeze-legacy',
+    keywords: ['old invoices', 'lock old invoices', 'freeze invoices', 'legacy invoices'],
+  },
+  {
+    id: 'invoice.reapplyDesign',
+    page: 'invoice',
+    title: 'invoice.reapplyTitle',
+    description: 'invoice.reapplyDescription',
+    anchor: 'invoice-reapply-design',
+    keywords: ['re-apply design', 'reapply design', 'rebrand', 'new design on old invoices'],
+  },
+  {
+    id: 'invoice.customFields',
+    page: 'invoice',
+    title: 'customFields.title',
+    description: 'customFields.description',
+    anchor: 'invoice-tab-custom-fields',
+    query: 'tab=customFields',
+    keywords: ['custom fields', 'extra fields', 'own fields', 'additional fields', 'custom data'],
+  },
+]

+ 43 - 0
src/features/settings/Lib/searchIndex/license.ts

@@ -0,0 +1,43 @@
+import type { SettingsEntry } from './types'
+
+/** License (self-hosted only): the white-label card and its key field. */
+export const licenseEntries: SettingsEntry[] = [
+  {
+    id: 'license.whiteLabel',
+    page: 'license',
+    title: 'license.title',
+    description: 'license.description',
+    anchor: 'license-white-label',
+    keywords: ['white label', 'white-label', 'license', 'licence', 'remove branding', 'branding'],
+  },
+  {
+    id: 'license.key',
+    page: 'license',
+    title: 'license.enterLicenseKey',
+    description: 'license.keyHint',
+    anchor: 'license-key',
+    keywords: [
+      'license key',
+      'licence key',
+      'activate license',
+      'validate license',
+      'serial',
+      'product key',
+    ],
+  },
+  {
+    id: 'license.status',
+    page: 'license',
+    title: 'license.validate',
+    description: 'license.signedHint',
+    anchor: 'license-status',
+    keywords: ['license status', 'expired license', 'last checked', 'verify license', 'expires'],
+  },
+  {
+    id: 'license.purchase',
+    page: 'license',
+    title: 'license.purchaseWhiteLabel',
+    anchor: 'license-purchase',
+    keywords: ['buy license', 'purchase license', 'white label price', 'upgrade'],
+  },
+]

+ 98 - 0
src/features/settings/Lib/searchIndex/localization.ts

@@ -0,0 +1,98 @@
+import type { SettingsEntry } from './types'
+
+/**
+ * Localization: language, currency, date and time. The date and time card is
+ * titled from the `appearance` keys because it moved here from Appearance.
+ */
+export const localizationEntries: SettingsEntry[] = [
+  {
+    id: 'localization.language',
+    page: 'localization',
+    title: 'localization.languageTitle',
+    description: 'localization.languageDescription',
+    anchor: 'localization-language',
+    keywords: ['language', 'workshop language', 'locale', 'translation', 'document language'],
+  },
+  {
+    id: 'localization.workshopLanguage',
+    page: 'localization',
+    title: 'localization.language',
+    anchor: 'localization-workshop-language',
+    keywords: ['language', 'change language', 'invoice language', 'språk', 'sprache', 'langue'],
+  },
+  {
+    id: 'localization.forceCustomerLocale',
+    page: 'localization',
+    title: 'localization.forceCustomerLocale',
+    description: 'localization.forceCustomerLocaleHint',
+    anchor: 'localization-force-customer-locale',
+    keywords: ['force language', 'customer language', 'portal language', 'customer locale'],
+  },
+  {
+    id: 'localization.defaultCountryCode',
+    page: 'localization',
+    title: 'localization.defaultCountryCode',
+    description: 'localization.defaultCountryCodeHint',
+    anchor: 'default-country-code',
+    keywords: ['country code', 'dial code', 'phone prefix', 'calling code', '+47', 'country'],
+  },
+  {
+    id: 'localization.currency',
+    page: 'localization',
+    title: 'currency.title',
+    description: 'currency.description',
+    anchor: 'localization-currency',
+    keywords: ['currency', 'money', 'valuta', 'währung', 'devise', 'moneda'],
+  },
+  {
+    id: 'localization.currencyCode',
+    page: 'localization',
+    title: 'currency.currencyLabel',
+    anchor: 'localization-currency-code',
+    keywords: ['currency', 'change currency', 'usd', 'eur', 'nok', 'gbp'],
+  },
+  {
+    id: 'localization.currencyFormat',
+    page: 'localization',
+    title: 'currency.formatTitle',
+    anchor: 'localization-currency-format',
+    keywords: ['currency format', 'currency symbol', 'currency code', 'price format'],
+  },
+  {
+    id: 'localization.dateTime',
+    page: 'localization',
+    title: 'appearance.dateTimeTitle',
+    description: 'appearance.dateTimeDescription',
+    anchor: 'localization-date-time',
+    keywords: ['date', 'time', 'date and time', 'clock', 'format'],
+  },
+  {
+    id: 'localization.dateFormat',
+    page: 'localization',
+    title: 'appearance.dateFormat',
+    anchor: 'date-format',
+    keywords: ['date format', 'dd/mm/yyyy', 'mm/dd/yyyy', 'date style'],
+  },
+  {
+    id: 'localization.timeFormat',
+    page: 'localization',
+    title: 'appearance.timeFormat',
+    anchor: 'time-format',
+    keywords: ['time format', '24 hour', '12 hour', 'am pm', 'clock'],
+  },
+  {
+    id: 'localization.weekStartDay',
+    page: 'localization',
+    title: 'workshop.weekStartDay',
+    anchor: 'week-start-day',
+    keywords: ['week start', 'first day of week', 'monday', 'sunday', 'calendar'],
+  },
+  {
+    id: 'localization.timezone',
+    page: 'localization',
+    title: 'appearance.timezone',
+    description: 'appearance.timezoneHint',
+    anchor: 'timezone',
+    keywords: ['timezone', 'time zone', 'utc', 'gmt', 'local time'],
+  },
+]

+ 50 - 0
src/features/settings/Lib/searchIndex/maintenance.ts

@@ -0,0 +1,50 @@
+import type { SettingsEntry } from './types'
+
+/** Maintenance. One card; the switch and inputs carry ids of their own. */
+export const maintenanceEntries: SettingsEntry[] = [
+  {
+    id: 'maintenance.predicted',
+    page: 'maintenance',
+    title: 'maintenance.title',
+    description: 'maintenance.description',
+    anchor: 'maintenance-predicted',
+    keywords: [
+      'predicted maintenance',
+      'service due',
+      'mileage prediction',
+      'service intervals',
+      'due soon',
+    ],
+  },
+  {
+    id: 'maintenance.enabled',
+    page: 'maintenance',
+    title: 'maintenance.enablePredicted',
+    description: 'maintenance.enablePredictedHint',
+    anchor: 'maintenance-enabled',
+    keywords: ['enable predicted maintenance', 'predictions', 'turn on maintenance'],
+  },
+  {
+    id: 'maintenance.serviceInterval',
+    page: 'maintenance',
+    title: 'maintenance.serviceInterval',
+    values: { unit: 'km / mi' },
+    description: 'maintenance.serviceIntervalHint',
+    anchor: 'serviceInterval',
+    keywords: [
+      'service interval',
+      'oil change interval',
+      'km between services',
+      'miles between services',
+    ],
+  },
+  {
+    id: 'maintenance.approachingThreshold',
+    page: 'maintenance',
+    title: 'maintenance.approachingThreshold',
+    values: { unit: 'km / mi' },
+    description: 'maintenance.approachingThresholdHint',
+    anchor: 'approachingThreshold',
+    keywords: ['approaching threshold', 'due soon warning', 'service warning', 'reminder distance'],
+  },
+]

+ 71 - 0
src/features/settings/Lib/searchIndex/payment.ts

@@ -0,0 +1,71 @@
+import type { SettingsEntry } from './types'
+
+/**
+ * Payment. What prints on the invoice and what a customer agrees to; the
+ * payment vendors themselves are connected under Integrations.
+ */
+export const paymentEntries: SettingsEntry[] = [
+  {
+    id: 'payment.details',
+    page: 'payment',
+    title: 'payment.detailsTitle',
+    description: 'payment.detailsDescription',
+    anchor: 'payment-details',
+    keywords: ['payment details', 'bank details', 'payment info', 'accounts'],
+  },
+  {
+    id: 'payment.bankAccount',
+    page: 'payment',
+    title: 'payment.bankAccount',
+    description: 'payment.bankAccountHint',
+    anchor: 'bankAccount',
+    keywords: ['bank account', 'iban', 'account number', 'swift', 'bic', 'kontonummer'],
+  },
+  {
+    id: 'payment.paymentTerms',
+    page: 'payment',
+    title: 'payment.paymentTerms',
+    description: 'payment.paymentTermsHint',
+    anchor: 'paymentTerms',
+    keywords: ['payment terms', 'net 14', 'due on receipt', 'terms text', 'betalingsbetingelser'],
+  },
+  {
+    id: 'payment.online',
+    page: 'payment',
+    title: 'payment.onlinePayments',
+    description: 'payment.onlinePaymentsDescription',
+    anchor: 'payment-online',
+    keywords: ['online payments', 'card payments', 'stripe', 'vipps', 'paypal', 'pay online'],
+  },
+  {
+    id: 'payment.termsOfSale',
+    page: 'payment',
+    title: 'payment.termsOfSale',
+    description: 'payment.termsOfSaleDescription',
+    anchor: 'payment-terms-of-sale',
+    keywords: ['terms of sale', 'terms and conditions', 'sales terms', 'salgsbetingelser'],
+  },
+  {
+    id: 'payment.termsOfSaleText',
+    page: 'payment',
+    title: 'payment.termsOfSaleText',
+    anchor: 'termsOfSale',
+    keywords: ['terms text', 'conditions text', 'withdrawal', 'returns policy'],
+  },
+  {
+    id: 'payment.externalTermsUrl',
+    page: 'payment',
+    title: 'payment.externalTermsUrl',
+    description: 'payment.externalTermsUrlHint',
+    anchor: 'termsOfSaleUrl',
+    keywords: ['terms url', 'terms link', 'external terms', 'website terms'],
+  },
+  {
+    id: 'payment.publicTermsUrl',
+    page: 'payment',
+    title: 'payment.publicTermsUrl',
+    description: 'payment.publicTermsUrlHint',
+    anchor: 'payment-public-terms-url',
+    keywords: ['public terms link', 'share terms', 'vipps terms', 'terms page'],
+  },
+]

+ 75 - 0
src/features/settings/Lib/searchIndex/reportSchedule.ts

@@ -0,0 +1,75 @@
+import type { SettingsEntry } from './types'
+
+/**
+ * Report schedule. The form lives in a dialog opened by the New Schedule
+ * button, so its fields land on that button; Send Now sits on each schedule
+ * in the list.
+ */
+export const reportScheduleEntries: SettingsEntry[] = [
+  {
+    id: 'reportSchedule.new',
+    page: 'reportSchedule',
+    title: 'reportSchedule.newSchedule',
+    description: 'reportSchedule.emptyDescription',
+    anchor: 'report-schedule-new',
+    keywords: [
+      'new schedule',
+      'scheduled report',
+      'automated reports',
+      'email reports',
+      'report delivery',
+    ],
+  },
+  {
+    id: 'reportSchedule.frequency',
+    page: 'reportSchedule',
+    title: 'reportSchedule.frequency',
+    anchor: 'report-schedule-new',
+    keywords: ['report frequency', 'daily report', 'weekly report', 'monthly report', 'how often'],
+  },
+  {
+    id: 'reportSchedule.dateRange',
+    page: 'reportSchedule',
+    title: 'reportSchedule.dateRangeLabel',
+    anchor: 'report-schedule-new',
+    keywords: ['report date range', 'report period', 'last 30 days', 'year to date'],
+  },
+  {
+    id: 'reportSchedule.sections',
+    page: 'reportSchedule',
+    title: 'reportSchedule.sections',
+    description: 'reportSchedule.sectionsDescription',
+    anchor: 'report-schedule-new',
+    keywords: [
+      'report sections',
+      'revenue report',
+      'tax report',
+      'past due',
+      'parts usage',
+      'retention',
+    ],
+  },
+  {
+    id: 'reportSchedule.recipients',
+    page: 'reportSchedule',
+    title: 'reportSchedule.recipients',
+    description: 'reportSchedule.recipientsDescription',
+    anchor: 'report-schedule-new',
+    keywords: ['report recipients', 'who gets the report', 'email report to', 'team members'],
+  },
+  {
+    id: 'reportSchedule.endDate',
+    page: 'reportSchedule',
+    title: 'reportSchedule.endDate',
+    description: 'reportSchedule.endDateDescription',
+    anchor: 'report-schedule-new',
+    keywords: ['end date', 'stop schedule', 'schedule expiry'],
+  },
+  {
+    id: 'reportSchedule.sendNow',
+    page: 'reportSchedule',
+    title: 'reportSchedule.sendNow',
+    anchor: 'report-schedule-list',
+    keywords: ['send report now', 'send now', 'test report', 'run report'],
+  },
+]

+ 77 - 0
src/features/settings/Lib/searchIndex/subscription.ts

@@ -0,0 +1,77 @@
+import type { SettingsEntry } from './types'
+
+/**
+ * Subscription. Which cards show depends on the plan: the upgrade card for a
+ * free or demo workshop, the manage card for a paid one, and an Enterprise
+ * upgrade card for Pro. Buttons that come and go land on their card.
+ */
+export const subscriptionEntries: SettingsEntry[] = [
+  {
+    id: 'subscription.plan',
+    page: 'subscription',
+    title: 'subscription.title',
+    description: 'subscription.description',
+    anchor: 'subscription-plan',
+    keywords: ['subscription', 'plan', 'current plan', 'billing', 'renewal', 'pricing'],
+  },
+  {
+    id: 'subscription.upgrade',
+    page: 'subscription',
+    title: 'subscription.upgradeTitle',
+    description: 'subscription.upgradeToProDescription',
+    anchor: 'subscription-upgrade',
+    keywords: ['upgrade', 'change plan', 'buy', 'pro', 'purchase', 'checkout'],
+  },
+  {
+    id: 'subscription.billingInterval',
+    page: 'subscription',
+    title: 'subscription.billingInterval',
+    anchor: 'subscription-billing-interval',
+    keywords: ['monthly', 'annual', 'yearly', 'billing interval', 'billing period'],
+  },
+  {
+    id: 'subscription.upgradeToEnterprise',
+    page: 'subscription',
+    title: 'subscription.upgradeToEnterprise',
+    description: 'subscription.upgradeToEnterpriseDescription',
+    anchor: 'subscription-upgrade-enterprise',
+    keywords: ['enterprise', 'upgrade to enterprise', 'multiple workshops', 'more users'],
+  },
+  {
+    id: 'subscription.features',
+    page: 'subscription',
+    title: 'subscription.featuresTitle',
+    description: 'subscription.featuresDescription',
+    anchor: 'subscription-features',
+    keywords: ['plan features', 'limits', 'usage', 'customer limit', 'member limit', 'included'],
+  },
+  {
+    id: 'subscription.manage',
+    page: 'subscription',
+    title: 'subscription.manageTitle',
+    anchor: 'subscription-manage',
+    keywords: ['manage subscription', 'billing', 'receipts', 'invoices from torqvoice'],
+  },
+  {
+    id: 'subscription.manageBilling',
+    page: 'subscription',
+    title: 'subscription.manageBilling',
+    description: 'subscription.manageBillingDescription',
+    anchor: 'subscription-manage',
+    keywords: ['payment method', 'credit card', 'billing portal', 'update card', 'stripe'],
+  },
+  {
+    id: 'subscription.cancel',
+    page: 'subscription',
+    title: 'subscription.cancelSubscription',
+    anchor: 'subscription-manage',
+    keywords: ['cancel subscription', 'cancel plan', 'downgrade', 'unsubscribe', 'stop paying'],
+  },
+  {
+    id: 'subscription.resume',
+    page: 'subscription',
+    title: 'subscription.resumeSubscription',
+    anchor: 'subscription-manage',
+    keywords: ['resume subscription', 'reactivate', 'undo cancel'],
+  },
+]

+ 29 - 0
src/features/settings/Lib/searchIndex/support.ts

@@ -0,0 +1,29 @@
+import type { SettingsEntry } from './types'
+
+/** Support: the support button's visibility and a way to open the form. */
+export const supportEntries: SettingsEntry[] = [
+  {
+    id: 'support.settings',
+    page: 'support',
+    title: 'support.title',
+    description: 'support.description',
+    anchor: 'support-settings',
+    keywords: ['support', 'help', 'contact support', 'customer service', 'helpdesk'],
+  },
+  {
+    id: 'support.showButton',
+    page: 'support',
+    title: 'support.showButton',
+    description: 'support.showButtonHint',
+    anchor: 'support-visible',
+    keywords: ['support button', 'help button', 'hide support', 'show support', 'chat bubble'],
+  },
+  {
+    id: 'support.contact',
+    page: 'support',
+    title: 'support.contactTitle',
+    description: 'support.contactHint',
+    anchor: 'support-contact',
+    keywords: ['contact support', 'support form', 'report a bug', 'ask for help', 'feedback'],
+  },
+]

+ 125 - 0
src/features/settings/Lib/searchIndex/tax.ts

@@ -0,0 +1,125 @@
+import type { SettingsEntry } from './types'
+
+/**
+ * Tax. One card; everything below the enable switch renders only while tax
+ * is on, and the split and selective fields only under their own switches.
+ * The per-charge switches are drawn from a list, so they land on their box.
+ */
+export const taxEntries: SettingsEntry[] = [
+  {
+    id: 'tax.enabled',
+    page: 'tax',
+    title: 'currency.enableTax',
+    description: 'currency.enableTaxHint',
+    anchor: 'tax-enabled',
+    keywords: ['enable tax', 'vat', 'sales tax', 'mva', 'moms', 'mwst', 'tva', 'iva'],
+  },
+  {
+    id: 'tax.defaultRate',
+    page: 'tax',
+    title: 'currency.defaultTaxRate',
+    description: 'currency.defaultTaxRateHint',
+    anchor: 'defaultTaxRate',
+    keywords: ['tax rate', 'vat rate', 'tax percentage', 'gst', 'mva', 'moms', 'btw', 'iva'],
+  },
+  {
+    id: 'tax.label',
+    page: 'tax',
+    title: 'tax.labelLabel',
+    description: 'tax.labelHint',
+    anchor: 'taxLabel',
+    keywords: ['tax name', 'tax label', 'vat label', 'mva', 'mwst', 'gst'],
+  },
+  {
+    id: 'tax.split',
+    page: 'tax',
+    title: 'tax.splitLabel',
+    description: 'tax.splitHint',
+    anchor: 'taxSplit',
+    keywords: [
+      'split tax',
+      'two taxes',
+      'gst qst',
+      'gst pst',
+      'hst',
+      'cgst sgst',
+      'multiple taxes',
+    ],
+  },
+  {
+    id: 'tax.preset',
+    page: 'tax',
+    title: 'tax.presetLabel',
+    anchor: 'taxPreset',
+    keywords: ['tax preset', 'region', 'quebec', 'canada', 'india'],
+  },
+  {
+    id: 'tax.components',
+    page: 'tax',
+    title: 'tax.addComponent',
+    description: 'tax.combinedRateHint',
+    anchor: 'tax-components',
+    keywords: ['tax components', 'add tax', 'combined rate', 'second tax'],
+  },
+  {
+    id: 'tax.registration',
+    page: 'tax',
+    title: 'tax.componentRegistration',
+    description: 'tax.registrationHint',
+    anchor: 'tax-components',
+    keywords: ['tax registration', 'vat number', 'gst number', 'tax id', 'registration number'],
+  },
+  {
+    id: 'tax.selective',
+    page: 'tax',
+    title: 'tax.selectiveLabel',
+    description: 'tax.selectiveHint',
+    anchor: 'taxSelective',
+    keywords: ['tax only parts', 'no tax on labor', 'tax exempt', 'selective tax'],
+  },
+  {
+    id: 'tax.appliesTo',
+    page: 'tax',
+    title: 'tax.appliesTitle',
+    description: 'tax.appliesHint',
+    anchor: 'tax-applies-to',
+    keywords: ['charge tax on', 'taxable', 'tax on parts', 'tax on labor', 'tax on shop fee'],
+  },
+  {
+    id: 'tax.appliesParts',
+    page: 'tax',
+    title: 'tax.appliesParts',
+    anchor: 'tax-applies-to',
+    keywords: ['tax on parts', 'parts taxable', 'vat on parts'],
+  },
+  {
+    id: 'tax.appliesLabor',
+    page: 'tax',
+    title: 'tax.appliesLabor',
+    anchor: 'tax-applies-to',
+    keywords: ['tax on labor', 'labour tax', 'labor taxable'],
+  },
+  {
+    id: 'tax.appliesShopFee',
+    page: 'tax',
+    title: 'tax.appliesShopFee',
+    anchor: 'tax-applies-to',
+    keywords: ['tax on shop fee', 'shop fee taxable', 'vat on fees'],
+  },
+  {
+    id: 'tax.mode',
+    page: 'tax',
+    title: 'tax.modeLabel',
+    description: 'tax.modeHint',
+    anchor: 'tax-mode',
+    keywords: ['inclusive', 'exclusive', 'prices include tax', 'gross', 'net', 'tax included'],
+  },
+  {
+    id: 'tax.lineItemsInclTax',
+    page: 'tax',
+    title: 'tax.lineItemsInclTaxLabel',
+    description: 'tax.lineItemsInclTaxHint',
+    anchor: 'lineItemsInclTax',
+    keywords: ['line prices incl tax', 'show prices with tax', 'gross prices', 'incl vat'],
+  },
+]

+ 115 - 0
src/features/settings/Lib/searchIndex/team.ts

@@ -0,0 +1,115 @@
+import type { SettingsEntry } from './types'
+
+/**
+ * Team. The members card is titled with the workshop's own name, so its
+ * entry uses the page heading. `add=true` opens the add-person dialog, which
+ * is where technicians, office staff and board-only names are created.
+ */
+export const teamEntries: SettingsEntry[] = [
+  {
+    id: 'team.members',
+    page: 'team',
+    title: 'team.title',
+    description: 'team.orgDescription',
+    anchor: 'team-members',
+    keywords: ['members', 'team members', 'staff', 'employees', 'users', 'people', 'roles'],
+  },
+  {
+    id: 'team.addPerson',
+    page: 'team',
+    title: 'team.addPersonTitle',
+    anchor: 'team-add-person',
+    query: 'add=true',
+    keywords: ['add person', 'add user', 'add member', 'new employee', 'invite', 'add staff'],
+  },
+  {
+    id: 'team.addTechnician',
+    page: 'team',
+    title: 'team.addTechnician',
+    description: 'team.addTechnicianHint',
+    anchor: 'team-add-person',
+    query: 'add=true',
+    keywords: ['add technician', 'add mechanic', 'new mechanic', 'technician app'],
+  },
+  {
+    id: 'team.inviteByEmail',
+    page: 'team',
+    title: 'team.inviteByEmail',
+    description: 'team.inviteMemberHint',
+    anchor: 'team-add-person',
+    query: 'add=true',
+    keywords: ['invite', 'invite by email', 'invitation', 'office staff', 'invite user'],
+  },
+  {
+    id: 'team.giveApp',
+    page: 'team',
+    title: 'team.giveApp',
+    anchor: 'team-members',
+    keywords: [
+      'technician app',
+      'mobile app',
+      'phone app',
+      'setup code',
+      'torqvoice tech',
+      'qr code',
+    ],
+  },
+  {
+    id: 'team.role',
+    page: 'team',
+    title: 'team.role',
+    description: 'team.roleHint',
+    anchor: 'team-members',
+    keywords: ['assign role', 'change role', 'member role', 'admin', 'permissions'],
+  },
+  {
+    id: 'team.boardOnly',
+    page: 'team',
+    title: 'team.boardOnly',
+    description: 'team.boardOnlyHint',
+    anchor: 'team-board-only',
+    keywords: ['board only', 'apprentice', 'contractor', 'no account', 'work board', 'schedule'],
+  },
+  {
+    id: 'team.pendingInvitations',
+    page: 'team',
+    title: 'team.pendingInvitations',
+    anchor: 'team-invitations',
+    keywords: ['pending invitations', 'invites', 'resend invitation', 'cancel invitation'],
+  },
+  {
+    id: 'team.customRoles',
+    page: 'team',
+    title: 'team.customRoles',
+    anchor: 'team-roles',
+    keywords: ['custom roles', 'roles', 'permissions', 'access control', 'rbac', 'rights'],
+  },
+  {
+    id: 'team.newRole',
+    page: 'team',
+    title: 'team.newRole',
+    anchor: 'team-new-role',
+    keywords: ['new role', 'create role', 'add role'],
+  },
+  {
+    id: 'team.permissions',
+    page: 'team',
+    title: 'team.permissions',
+    anchor: 'team-roles',
+    keywords: ['permissions', 'access', 'read only', 'view only', 'rights'],
+  },
+  {
+    id: 'team.createDefaultRoles',
+    page: 'team',
+    title: 'team.createDefaultRoles',
+    anchor: 'team-roles',
+    keywords: ['standard roles', 'default roles', 'technician role'],
+  },
+  {
+    id: 'team.builtInRoles',
+    page: 'team',
+    title: 'team.builtInRoles',
+    anchor: 'team-built-in-roles',
+    keywords: ['built-in roles', 'owner', 'admin', 'no role', 'role descriptions'],
+  },
+]

+ 167 - 0
src/features/settings/Lib/searchIndex/templates.ts

@@ -0,0 +1,167 @@
+import type { SettingsEntry } from './types'
+
+/**
+ * Templates. Each document tab shows the same designs card, so the tab is
+ * the query and the card the anchor. The checklist tabs render components
+ * of their own and land on the tab strip. The SMS templates are drawn from
+ * a list, so each lands on the SMS card.
+ */
+export const templatesEntries: SettingsEntry[] = [
+  {
+    id: 'templates.designs',
+    page: 'templates',
+    title: 'templates.presets',
+    description: 'templates.designerIntro',
+    anchor: 'templates-designs',
+    keywords: ['template presets', 'invoice template', 'invoice design', 'pdf look', 'layout'],
+  },
+  {
+    id: 'templates.openDesigner',
+    page: 'templates',
+    title: 'templates.openDesigner',
+    description: 'templates.designerHint',
+    anchor: 'templates-open-designer',
+    keywords: ['designer', 'invoice designer', 'colors', 'fonts', 'logo', 'edit template'],
+  },
+  {
+    id: 'templates.yourDesigns',
+    page: 'templates',
+    title: 'designer.yourDesigns',
+    description: 'designer.autoRuleHint',
+    anchor: 'templates-designs',
+    keywords: ['saved designs', 'default design', 'parts sale', 'parts only', 'set as default'],
+  },
+  {
+    id: 'templates.invoice',
+    page: 'templates',
+    title: 'templates.tabs.invoice',
+    description: 'templates.invoiceDescription',
+    anchor: 'templates-designs',
+    keywords: ['invoice template', 'invoice pdf', 'invoice look', 'invoice layout'],
+  },
+  {
+    id: 'templates.quotation',
+    page: 'templates',
+    title: 'templates.tabs.quotation',
+    anchor: 'templates-designs',
+    query: 'tab=quotation',
+    keywords: ['quote template', 'quotation template', 'estimate template', 'quote pdf'],
+  },
+  {
+    id: 'templates.workOrders',
+    page: 'templates',
+    title: 'templates.tabs.workOrders',
+    description: 'templates.workOrdersDescription',
+    anchor: 'templates-designs',
+    query: 'tab=workOrders',
+    keywords: ['work order template', 'job card', 'job sheet', 'printable work order'],
+  },
+  {
+    id: 'templates.certificates',
+    page: 'templates',
+    title: 'templates.tabs.certificates',
+    description: 'templates.certificatesDescription',
+    anchor: 'templates-designs',
+    query: 'tab=certificates',
+    keywords: ['inspection certificate', 'certificate template', 'inspection pdf'],
+  },
+  {
+    id: 'templates.inspections',
+    page: 'templates',
+    title: 'templates.tabs.inspections',
+    description: 'templates.inspectionsDescription',
+    anchor: 'templates-tabs',
+    query: 'tab=inspections',
+    keywords: ['checklists', 'inspection checklist', 'inspection template', 'check items'],
+  },
+  {
+    id: 'templates.conditionMap',
+    page: 'templates',
+    title: 'templates.tabs.conditionMap',
+    description: 'templates.conditionMapDescription',
+    anchor: 'templates-tabs',
+    query: 'tab=conditionMap',
+    keywords: ['condition map', 'dents', 'scratches', 'damage marks', 'mark types'],
+  },
+  {
+    id: 'templates.sms',
+    page: 'templates',
+    title: 'templates.smsTemplates',
+    description: 'templates.smsDescription',
+    anchor: 'templates-sms',
+    query: 'tab=sms',
+    keywords: ['sms templates', 'text messages', 'sms wording', 'message templates'],
+  },
+  {
+    id: 'templates.smsInvoiceReady',
+    page: 'templates',
+    title: 'templates.invoiceReady',
+    description: 'templates.invoiceReadyDescription',
+    anchor: 'templates-sms',
+    query: 'tab=sms',
+    keywords: ['invoice sms', 'invoice ready text', 'invoice text message'],
+  },
+  {
+    id: 'templates.smsQuoteReady',
+    page: 'templates',
+    title: 'templates.quoteReady',
+    description: 'templates.quoteReadyDescription',
+    anchor: 'templates-sms',
+    query: 'tab=sms',
+    keywords: ['quote sms', 'quote ready text', 'estimate text message'],
+  },
+  {
+    id: 'templates.smsInspectionReady',
+    page: 'templates',
+    title: 'templates.inspectionReady',
+    description: 'templates.inspectionReadyDescription',
+    anchor: 'templates-sms',
+    query: 'tab=sms',
+    keywords: ['inspection sms', 'inspection report text', 'inspection text message'],
+  },
+  {
+    id: 'templates.smsStatusInProgress',
+    page: 'templates',
+    title: 'templates.statusInProgress',
+    description: 'templates.statusInProgressDescription',
+    anchor: 'templates-sms',
+    query: 'tab=sms',
+    keywords: ['in progress sms', 'work started text', 'status sms'],
+  },
+  {
+    id: 'templates.smsStatusWaitingParts',
+    page: 'templates',
+    title: 'templates.statusWaitingParts',
+    description: 'templates.statusWaitingPartsDescription',
+    anchor: 'templates-sms',
+    query: 'tab=sms',
+    keywords: ['waiting for parts sms', 'parts delay text', 'status sms'],
+  },
+  {
+    id: 'templates.smsStatusReady',
+    page: 'templates',
+    title: 'templates.statusReady',
+    description: 'templates.statusReadyDescription',
+    anchor: 'templates-sms',
+    query: 'tab=sms',
+    keywords: ['ready for pickup sms', 'car ready text', 'collection text'],
+  },
+  {
+    id: 'templates.smsStatusCompleted',
+    page: 'templates',
+    title: 'templates.statusCompleted',
+    description: 'templates.statusCompletedDescription',
+    anchor: 'templates-sms',
+    query: 'tab=sms',
+    keywords: ['completed sms', 'job done text', 'status sms'],
+  },
+  {
+    id: 'templates.smsPaymentReceived',
+    page: 'templates',
+    title: 'templates.paymentReceived',
+    description: 'templates.paymentReceivedDescription',
+    anchor: 'templates-sms',
+    query: 'tab=sms',
+    keywords: ['payment received sms', 'payment confirmation text', 'receipt sms'],
+  },
+]

+ 93 - 0
src/features/settings/Lib/searchIndex/tireHotel.ts

@@ -0,0 +1,93 @@
+import type { SettingsEntry } from './types'
+
+/**
+ * Tire hotel. One card; the sections inside carry ids of their own. Every
+ * field below the switch only renders once the tire hotel is enabled.
+ */
+export const tireHotelEntries: SettingsEntry[] = [
+  {
+    id: 'tireHotel.storage',
+    page: 'tireHotel',
+    title: 'tireHotel.cardTitle',
+    description: 'tireHotel.description',
+    anchor: 'tire-hotel-storage',
+    keywords: [
+      'tire hotel',
+      'tyre hotel',
+      'tire storage',
+      'seasonal tires',
+      'dekkhotell',
+      'reifenhotel',
+    ],
+  },
+  {
+    id: 'tireHotel.enabled',
+    page: 'tireHotel',
+    title: 'tireHotel.enable',
+    description: 'tireHotel.enableHint',
+    anchor: 'tireHotelEnabled',
+    keywords: ['enable tire hotel', 'turn on tire storage', 'tire hotel on'],
+  },
+  {
+    id: 'tireHotel.defaultCapacity',
+    page: 'tireHotel',
+    title: 'tireHotel.defaultCapacity',
+    description: 'tireHotel.defaultCapacityHint',
+    anchor: 'tireHotelCapacity',
+    keywords: ['shelf capacity', 'tires per shelf', 'rack size', 'storage capacity'],
+  },
+  {
+    id: 'tireHotel.warnPercent',
+    page: 'tireHotel',
+    title: 'tireHotel.warnPercent',
+    description: 'tireHotel.warnPercentHint',
+    anchor: 'tireHotelWarn',
+    keywords: ['storage full warning', 'capacity warning', 'full percent', 'almost full'],
+  },
+  {
+    id: 'tireHotel.tread',
+    page: 'tireHotel',
+    title: 'tireHotel.treadTitle',
+    description: 'tireHotel.treadHint',
+    anchor: 'tire-hotel-tread',
+    keywords: [
+      'tread depth',
+      'summer limit',
+      'winter limit',
+      'minimum tread',
+      'replacement threshold',
+      'mønsterdybde',
+    ],
+  },
+  {
+    id: 'tireHotel.billing',
+    page: 'tireHotel',
+    title: 'tireHotel.billingTitle',
+    description: 'tireHotel.billingHint',
+    anchor: 'tire-hotel-billing',
+    keywords: ['storage billing', 'storage fee', 'tire storage price'],
+  },
+  {
+    id: 'tireHotel.seasonalPrice',
+    page: 'tireHotel',
+    title: 'tireHotel.seasonalPrice',
+    anchor: 'tireHotelSeasonal',
+    keywords: ['seasonal price', 'storage price', 'price per season', 'tire hotel price'],
+  },
+  {
+    id: 'tireHotel.prep',
+    page: 'tireHotel',
+    title: 'tireHotel.prepTitle',
+    description: 'tireHotel.prepHint',
+    anchor: 'tire-hotel-prep',
+    keywords: [
+      'prep pricing',
+      'wash tires',
+      'wash rims',
+      'balance',
+      'tpms service',
+      'new valves',
+      'tire repair',
+    ],
+  },
+]

+ 41 - 0
src/features/settings/Lib/searchIndex/types.ts

@@ -0,0 +1,41 @@
+import type { SettingsPageKey } from '@/features/settings/Lib/settingsPages'
+
+/**
+ * One thing a person might be looking for inside a settings page: a card, a
+ * field, a switch, a tab. The search palette and the settings search box both
+ * read these, so "invoice prefix" lands on the input rather than on the page.
+ *
+ * Titles and descriptions are keys in the `settings` message namespace, so
+ * every entry reads in the workshop's own language without a second list of
+ * words to keep in step. Keywords are English aliases (plus a few common
+ * foreign words where a workshop would type them, such as "mva" for tax)
+ * and are matched whatever the language.
+ */
+export type SettingsEntry = {
+  /** Stable, unique across the whole index: `<page>.<something>`. */
+  id: string
+  page: SettingsPageKey
+  /**
+   * Key under the `settings` namespace, for example `invoice.dueDays`.
+   * Resolved with `t(title)` at render time.
+   */
+  title: string
+  /** Optional key under the `settings` namespace; one quiet line under the title. */
+  description?: string
+  /**
+   * The `id` attribute of the element on the page: a card's `id`, or the
+   * input's own id for a field. The palette opens the page and lands on it.
+   * Left out for an entry that is the page itself.
+   */
+  anchor?: string
+  /** Query string the page needs to show this entry, for example `tab=customFields`. */
+  query?: string
+  /**
+   * Values for the title's placeholders, for the few labels that carry one
+   * ("Service interval ({unit})"). Plain words: the palette has no workshop
+   * unit to hand, so "km / mi" says what the field is without claiming which.
+   */
+  values?: Record<string, string>
+  /** English aliases, lower case. */
+  keywords: string[]
+}

+ 82 - 0
src/features/settings/Lib/searchIndex/warranty.ts

@@ -0,0 +1,82 @@
+import type { SettingsEntry } from './types'
+
+/** Warranty. One card in three parts: included, not included, and where it applies. */
+export const warrantyEntries: SettingsEntry[] = [
+  {
+    id: 'warranty.defaultStatus',
+    page: 'warranty',
+    title: 'warranty.defaultStatus',
+    anchor: 'warranty-default-status',
+    keywords: ['default warranty', 'warranty included', 'no warranty', 'guarantee'],
+  },
+  {
+    id: 'warranty.included',
+    page: 'warranty',
+    title: 'warranty.includedTitle',
+    description: 'warranty.includedHint',
+    anchor: 'warranty-included',
+    keywords: ['warranty included', 'warranty period', 'guarantee terms', 'garanti'],
+  },
+  {
+    id: 'warranty.months',
+    page: 'warranty',
+    title: 'warranty.months',
+    anchor: 'warrantyDefaultMonths',
+    keywords: ['warranty months', 'warranty duration', 'warranty length', 'guarantee period'],
+  },
+  {
+    id: 'warranty.mileage',
+    page: 'warranty',
+    title: 'warranty.mileage',
+    values: { unit: 'km / mi' },
+    description: 'warranty.mileageHint',
+    anchor: 'warrantyDefaultMileage',
+    keywords: ['warranty mileage', 'distance limit', 'km limit', 'miles limit'],
+  },
+  {
+    id: 'warranty.terms',
+    page: 'warranty',
+    title: 'warranty.terms',
+    description: 'warranty.termsHint',
+    anchor: 'warrantyDefaultTerms',
+    keywords: ['warranty terms', 'warranty text', 'guarantee text', 'warranty conditions'],
+  },
+  {
+    id: 'warranty.notIncluded',
+    page: 'warranty',
+    title: 'warranty.notIncludedTitle',
+    description: 'warranty.notIncludedHint',
+    anchor: 'warranty-not-included',
+    keywords: ['no warranty', 'without warranty', 'disclaimer', 'customer supplied parts'],
+  },
+  {
+    id: 'warranty.notIncludedText',
+    page: 'warranty',
+    title: 'warranty.notIncludedText',
+    anchor: 'warrantyNotIncludedText',
+    keywords: ['no warranty statement', 'disclaimer text', 'statutory rights'],
+  },
+  {
+    id: 'warranty.applyTo',
+    page: 'warranty',
+    title: 'warranty.applyTitle',
+    anchor: 'warranty-apply-to',
+    keywords: ['warranty on quotes', 'warranty on work orders', 'apply warranty'],
+  },
+  {
+    id: 'warranty.applyToQuotes',
+    page: 'warranty',
+    title: 'warranty.applyToQuotes',
+    description: 'warranty.applyToQuotesHint',
+    anchor: 'warrantyApplyToQuotes',
+    keywords: ['warranty on quotes', 'quote warranty', 'estimate warranty'],
+  },
+  {
+    id: 'warranty.applyToWorkOrders',
+    page: 'warranty',
+    title: 'warranty.applyToWorkOrders',
+    description: 'warranty.applyToWorkOrdersHint',
+    anchor: 'warrantyApplyToWorkOrders',
+    keywords: ['warranty on work orders', 'warranty on invoices', 'invoice warranty'],
+  },
+]

+ 123 - 0
src/features/settings/Lib/searchIndex/webhooks.ts

@@ -0,0 +1,123 @@
+import type { SettingsEntry } from './types'
+
+/**
+ * Webhooks. The tabs are local state rather than a query string, so the
+ * tab entries land on the tab bar. The create form lives in a dialog behind
+ * "Add webhook", so its fields land on the header that holds the button.
+ */
+export const webhooksEntries: SettingsEntry[] = [
+  {
+    id: 'webhooks.add',
+    page: 'webhooks',
+    title: 'webhooks.create',
+    description: 'webhooks.createDescription',
+    anchor: 'webhooks-header',
+    keywords: [
+      'add webhook',
+      'new webhook',
+      'create webhook',
+      'endpoint url',
+      'callback url',
+      'http post',
+      'zapier',
+      'real-time event notifications',
+    ],
+  },
+  {
+    id: 'webhooks.events',
+    page: 'webhooks',
+    title: 'webhooks.events',
+    description: 'webhooks.eventsHint',
+    anchor: 'webhooks-header',
+    keywords: ['webhook events', 'subscribe', 'all events', 'event triggers', 'which events'],
+  },
+  {
+    id: 'webhooks.list',
+    page: 'webhooks',
+    title: 'webhooks.tabWebhooks',
+    anchor: 'webhooks-tabs',
+    keywords: ['webhook list', 'my webhooks', 'edit webhook', 'delete webhook', 'endpoints'],
+  },
+  {
+    id: 'webhooks.deliveries',
+    page: 'webhooks',
+    title: 'webhooks.deliveries',
+    description: 'webhooks.deliveriesDescription',
+    anchor: 'webhooks-tabs',
+    keywords: [
+      'deliveries',
+      'delivery log',
+      'webhook history',
+      'failed deliveries',
+      'retry',
+      'payload',
+    ],
+  },
+  {
+    id: 'webhooks.sendTest',
+    page: 'webhooks',
+    title: 'webhooks.sendTest',
+    anchor: 'webhooks-tabs',
+    keywords: ['test webhook', 'send test', 'ping', 'test event'],
+  },
+  {
+    id: 'webhooks.autoPaused',
+    page: 'webhooks',
+    title: 'webhooks.autoDisabled',
+    description: 'webhooks.autoDisabledBanner',
+    anchor: 'webhooks-tabs',
+    keywords: [
+      'pause webhook',
+      'disable webhook',
+      'enable webhook',
+      'auto-paused',
+      're-enable',
+      'resume webhook',
+    ],
+  },
+  {
+    id: 'webhooks.rotateSecret',
+    page: 'webhooks',
+    title: 'webhooks.rotateSecret',
+    description: 'webhooks.confirmRotateDescription',
+    anchor: 'webhooks-tabs',
+    keywords: [
+      'rotate secret',
+      'signing secret',
+      'webhook secret',
+      'new secret',
+      'regenerate secret',
+    ],
+  },
+  {
+    id: 'webhooks.signatures',
+    page: 'webhooks',
+    title: 'webhooks.helpTitle',
+    description: 'webhooks.helpIntro',
+    anchor: 'webhooks-signatures',
+    keywords: [
+      'webhook signature',
+      'verify signature',
+      'hmac',
+      'sha256',
+      'x-torqvoice-signature',
+      'headers',
+      'webhook secret',
+    ],
+  },
+  {
+    id: 'webhooks.reference',
+    page: 'webhooks',
+    title: 'webhooks.tabReference',
+    description: 'webhooks.referenceIntro',
+    anchor: 'webhooks-tabs',
+    keywords: [
+      'events reference',
+      'event types',
+      'sample payload',
+      'envelope',
+      'webhook documentation',
+      'json',
+    ],
+  },
+]

+ 74 - 0
src/features/settings/Lib/searchIndex/workOrderStatuses.ts

@@ -0,0 +1,74 @@
+import type { SettingsEntry } from './types'
+
+/**
+ * Work order statuses. The page renders WorkOrderStatusSettings from
+ * src/features/work-order-statuses; the card and the quick-add row carry ids
+ * there. Name, colour and notify live in the add/edit dialog, so those
+ * entries land on the card that opens it.
+ */
+export const workOrderStatusesEntries: SettingsEntry[] = [
+  {
+    id: 'workOrderStatuses.stages',
+    page: 'workOrderStatuses',
+    title: 'workOrderStatuses.cardTitle',
+    description: 'workOrderStatuses.description',
+    anchor: 'work-order-statuses-stages',
+    keywords: ['work order status', 'job status', 'custom status', 'stages', 'workflow', 'steps'],
+  },
+  {
+    id: 'workOrderStatuses.add',
+    page: 'workOrderStatuses',
+    title: 'workOrderStatuses.add',
+    anchor: 'work-order-statuses-stages',
+    keywords: ['add status', 'new status', 'create status', 'custom status'],
+  },
+  {
+    id: 'workOrderStatuses.quickAdd',
+    page: 'workOrderStatuses',
+    title: 'workOrderStatuses.suggestionsLabel',
+    anchor: 'work-order-statuses-quick-add',
+    keywords: [
+      'quick add',
+      'suggested statuses',
+      'ready for pickup',
+      'waiting for customer',
+      'waiting for approval',
+      'waiting for parts',
+    ],
+  },
+  {
+    id: 'workOrderStatuses.notify',
+    page: 'workOrderStatuses',
+    title: 'workOrderStatuses.notify',
+    description: 'workOrderStatuses.notifyHint',
+    anchor: 'work-order-statuses-stages',
+    keywords: [
+      'notify customer',
+      'status message',
+      'sms when ready',
+      'tell the customer',
+      'status notification',
+    ],
+  },
+  {
+    id: 'workOrderStatuses.message',
+    page: 'workOrderStatuses',
+    title: 'workOrderStatuses.message',
+    anchor: 'work-order-statuses-stages',
+    keywords: ['status message', 'message template', 'customer message', 'pickup message'],
+  },
+  {
+    id: 'workOrderStatuses.color',
+    page: 'workOrderStatuses',
+    title: 'workOrderStatuses.color',
+    anchor: 'work-order-statuses-stages',
+    keywords: ['status color', 'status colour', 'badge color', 'label color'],
+  },
+  {
+    id: 'workOrderStatuses.stage',
+    page: 'workOrderStatuses',
+    title: 'workOrderStatuses.stage',
+    anchor: 'work-order-statuses-stages',
+    keywords: ['stage', 'pending', 'in progress', 'completed', 'open job'],
+  },
+]

+ 158 - 0
src/features/settings/Lib/searchIndex/workshop.ts

@@ -0,0 +1,158 @@
+import type { SettingsEntry } from './types'
+
+/**
+ * Workshop. One card of defaults with sections inside it; the sections carry
+ * ids of their own and the fields are reached through the inputs' ids.
+ */
+export const workshopEntries: SettingsEntry[] = [
+  {
+    id: 'workshop.serviceType',
+    page: 'workshop',
+    title: 'company.serviceType',
+    description: 'company.serviceTypeDescription',
+    anchor: 'workshop-service-type',
+    keywords: ['service type', 'automotive', 'marine', 'boat', 'car workshop', 'business type'],
+  },
+  {
+    id: 'workshop.defaults',
+    page: 'workshop',
+    title: 'workshop.title',
+    description: 'workshop.description',
+    anchor: 'workshop-defaults',
+    keywords: ['workshop defaults', 'technician defaults', 'labor defaults', 'shop defaults'],
+  },
+  {
+    id: 'workshop.defaultTechnician',
+    page: 'workshop',
+    title: 'workshop.defaultTechnician',
+    anchor: 'workshop-default-technician',
+    keywords: [
+      'default technician',
+      'default mechanic',
+      'assign technician',
+      'add technician',
+      'mechanic',
+    ],
+  },
+  {
+    id: 'workshop.defaultLaborRate',
+    page: 'workshop',
+    title: 'workshop.defaultLaborRate',
+    anchor: 'defaultLaborRate',
+    keywords: [
+      'labor rate',
+      'labour rate',
+      'hourly rate',
+      'price per hour',
+      'timepris',
+      'shop rate',
+    ],
+  },
+  {
+    id: 'workshop.workDayStart',
+    page: 'workshop',
+    title: 'workshop.workDayStart',
+    anchor: 'workDayStart',
+    keywords: ['work day start', 'opening hours', 'opening time', 'start of day', 'business hours'],
+  },
+  {
+    id: 'workshop.workDayEnd',
+    page: 'workshop',
+    title: 'workshop.workDayEnd',
+    anchor: 'workDayEnd',
+    keywords: ['work day end', 'closing time', 'opening hours', 'end of day', 'business hours'],
+  },
+  {
+    id: 'workshop.titleTemplate',
+    page: 'workshop',
+    title: 'workshop.titleTemplateTitle',
+    description: 'workshop.titleTemplateDescription',
+    anchor: 'workshop-title-template',
+    keywords: [
+      'work order title',
+      'title template',
+      'job title',
+      'default title',
+      'job name',
+      'tags',
+    ],
+  },
+  {
+    id: 'workshop.titleTemplateField',
+    page: 'workshop',
+    title: 'workshop.titleTemplate',
+    description: 'workshop.titleTemplateHint',
+    anchor: 'workOrderTitleTemplate',
+    keywords: ['title template', 'title tags', 'work order name', 'plate in title'],
+  },
+  {
+    id: 'workshop.partsMarkup',
+    page: 'workshop',
+    title: 'workshop.partsMarkupTitle',
+    description: 'workshop.partsMarkupDescription',
+    anchor: 'workshop-parts-markup',
+    keywords: ['parts markup', 'markup', 'margin', 'parts pricing', 'profit', 'avanse'],
+  },
+  {
+    id: 'workshop.defaultMarkupPercent',
+    page: 'workshop',
+    title: 'workshop.defaultMarkupPercent',
+    description: 'workshop.defaultMarkupPercentHint',
+    anchor: 'defaultMarkupPercent',
+    keywords: ['default markup', 'markup percent', 'markup %', 'parts margin'],
+  },
+  {
+    id: 'workshop.markupAppliesToInventory',
+    page: 'workshop',
+    title: 'workshop.markupAppliesToInventory',
+    description: 'workshop.markupAppliesToInventoryHint',
+    anchor: 'markupAppliesToInventory',
+    keywords: ['inventory markup', 'stock markup', 'markup inventory parts', 'cost plus'],
+  },
+  {
+    id: 'workshop.typeKey',
+    page: 'workshop',
+    title: 'workshop.typeKeyTitle',
+    description: 'workshop.typeKeyEnabled',
+    anchor: 'typeKeyEnabled',
+    keywords: [
+      'hsn',
+      'tsn',
+      'type key',
+      'german type key',
+      'schlüsselnummer',
+      'registration certificate',
+    ],
+  },
+  {
+    id: 'workshop.units',
+    page: 'workshop',
+    title: 'workshop.unitsTitle',
+    description: 'workshop.unitsDescription',
+    anchor: 'workshop-units',
+    keywords: ['units', 'measurements', 'kilometers', 'miles', 'liters', 'gallons'],
+  },
+  {
+    id: 'workshop.unitSystem',
+    page: 'workshop',
+    title: 'workshop.unitSystem',
+    anchor: 'workshop-unit-system',
+    keywords: ['unit system', 'metric', 'imperial', 'km or miles', 'mileage unit', 'distance unit'],
+  },
+  {
+    id: 'workshop.engineSizeUnit',
+    page: 'workshop',
+    title: 'workshop.engineSizeUnit',
+    description: 'workshop.engineSizeUnitHint',
+    anchor: 'engineSizeUnit',
+    keywords: ['engine size', 'displacement', 'litres', 'cc', 'cubic centimeters', 'engine volume'],
+  },
+  {
+    id: 'workshop.defaultStockUnit',
+    page: 'workshop',
+    title: 'workshop.defaultStockUnit',
+    description: 'workshop.defaultStockUnitHint',
+    anchor: 'defaultUnit',
+    keywords: ['stock unit', 'inventory unit', 'default unit', 'unit of measure', 'pcs'],
+  },
+]

+ 61 - 0
src/features/settings/Lib/settingsPageIcons.ts

@@ -0,0 +1,61 @@
+import {
+  Banknote,
+  BellRing,
+  Building2,
+  CalendarClock,
+  ClipboardCheck,
+  CreditCard,
+  Database,
+  Disc3,
+  Download,
+  FileText,
+  Gauge,
+  Globe,
+  Info,
+  Key,
+  Layout,
+  LifeBuoy,
+  ListChecks,
+  type LucideIcon,
+  Mail,
+  Palette,
+  Percent,
+  Plug,
+  ShieldCheck,
+  UserCog,
+  UsersRound,
+  Webhook,
+  Wrench,
+} from 'lucide-react'
+import type { SettingsPageKey } from './settingsPages'
+
+/** The icon each settings page wears, in the sidebar and in search. */
+export const SETTINGS_PAGE_ICONS: Record<SettingsPageKey, LucideIcon> = {
+  company: Building2,
+  subscription: CreditCard,
+  account: UserCog,
+  team: UsersRound,
+  localization: Globe,
+  appearance: Palette,
+  support: LifeBuoy,
+  invoice: FileText,
+  templates: Layout,
+  payment: Banknote,
+  tax: Percent,
+  warranty: ShieldCheck,
+  emailTemplates: Mail,
+  customerPortal: Globe,
+  workshop: Wrench,
+  workOrderStatuses: ListChecks,
+  maintenance: Gauge,
+  inspectionReminders: ClipboardCheck,
+  alerts: BellRing,
+  tireHotel: Disc3,
+  reportSchedule: CalendarClock,
+  integrations: Plug,
+  webhooks: Webhook,
+  data: Database,
+  install: Download,
+  license: Key,
+  about: Info,
+}

+ 142 - 0
src/features/settings/Lib/settingsPages.ts

@@ -0,0 +1,142 @@
+import type { PlanFeatures } from '@/lib/features'
+
+/**
+ * Every settings page, in the order the settings sidebar draws them.
+ *
+ * One list, read by the sidebar, the search palette and the settings search
+ * box, so a page added here is reachable from all three at once. Titles and
+ * descriptions are `settings.nav.items.<key>.title` / `.description`, which
+ * exist in every locale already.
+ */
+export type SettingsPageKey =
+  | 'company'
+  | 'subscription'
+  | 'account'
+  | 'team'
+  | 'localization'
+  | 'appearance'
+  | 'support'
+  | 'invoice'
+  | 'templates'
+  | 'payment'
+  | 'tax'
+  | 'warranty'
+  | 'emailTemplates'
+  | 'customerPortal'
+  | 'workshop'
+  | 'workOrderStatuses'
+  | 'maintenance'
+  | 'inspectionReminders'
+  | 'alerts'
+  | 'tireHotel'
+  | 'reportSchedule'
+  | 'integrations'
+  | 'webhooks'
+  | 'data'
+  | 'install'
+  | 'license'
+  | 'about'
+
+export type SettingsCategoryKey =
+  | 'general'
+  | 'billing'
+  | 'communications'
+  | 'workshop'
+  | 'integrations'
+  | 'system'
+
+export type SettingsPage = {
+  key: SettingsPageKey
+  href: string
+  category: SettingsCategoryKey
+  /** Plan feature the page needs; shown locked (not hidden) without it. */
+  gate?: keyof PlanFeatures
+  cloudOnly?: boolean
+  selfHostedOnly?: boolean
+  /**
+   * Hidden unless the platform has switched support requests on. Implies cloud
+   * already, since the flag is only ever true in cloud mode.
+   */
+  supportOnly?: boolean
+}
+
+export const SETTINGS_CATEGORIES: SettingsCategoryKey[] = [
+  'general',
+  'billing',
+  'communications',
+  'workshop',
+  'integrations',
+  'system',
+]
+
+export const SETTINGS_PAGES: SettingsPage[] = [
+  { key: 'company', href: '/settings/company', category: 'general' },
+  { key: 'subscription', href: '/settings/subscription', category: 'general', cloudOnly: true },
+  { key: 'account', href: '/settings/account', category: 'general' },
+  { key: 'team', href: '/settings/team', category: 'general' },
+  { key: 'localization', href: '/settings/localization', category: 'general' },
+  { key: 'appearance', href: '/settings/appearance', category: 'general' },
+  { key: 'support', href: '/settings/support', category: 'general', supportOnly: true },
+
+  { key: 'invoice', href: '/settings/invoice', category: 'billing' },
+  { key: 'templates', href: '/settings/templates', category: 'billing', gate: 'customTemplates' },
+  { key: 'payment', href: '/settings/payment', category: 'billing', gate: 'payments' },
+  { key: 'tax', href: '/settings/tax', category: 'billing' },
+  { key: 'warranty', href: '/settings/warranty', category: 'billing' },
+
+  {
+    key: 'emailTemplates',
+    href: '/settings/email-templates',
+    category: 'communications',
+    gate: 'customTemplates',
+  },
+  {
+    key: 'customerPortal',
+    href: '/settings/customer-portal',
+    category: 'communications',
+    gate: 'customerPortal',
+  },
+
+  { key: 'workshop', href: '/settings/workshop', category: 'workshop' },
+  { key: 'workOrderStatuses', href: '/settings/work-order-statuses', category: 'workshop' },
+  { key: 'maintenance', href: '/settings/maintenance', category: 'workshop' },
+  { key: 'inspectionReminders', href: '/settings/inspection-reminders', category: 'workshop' },
+  { key: 'alerts', href: '/settings/alerts', category: 'workshop' },
+  { key: 'tireHotel', href: '/settings/tire-hotel', category: 'workshop', gate: 'tireHotel' },
+  {
+    key: 'reportSchedule',
+    href: '/settings/report-schedule',
+    category: 'workshop',
+    gate: 'reports',
+  },
+
+  { key: 'integrations', href: '/settings/integrations', category: 'integrations' },
+  { key: 'webhooks', href: '/settings/webhooks', category: 'integrations', gate: 'api' },
+
+  { key: 'data', href: '/settings/data', category: 'system' },
+  { key: 'install', href: '/settings/install', category: 'system' },
+  { key: 'license', href: '/settings/license', category: 'system', selfHostedOnly: true },
+  { key: 'about', href: '/settings/about', category: 'system' },
+]
+
+/** What decides whether a page is on this install at all. */
+export type SettingsVisibility = {
+  isCloud?: boolean
+  supportEnabled?: boolean
+}
+
+/** Pages that exist on this install, in sidebar order. Gated pages stay (they show as locked). */
+export function visibleSettingsPages(v: SettingsVisibility): SettingsPage[] {
+  return SETTINGS_PAGES.filter((page) => {
+    if (page.cloudOnly && !v.isCloud) return false
+    if (page.selfHostedOnly && v.isCloud) return false
+    if (page.supportOnly && !v.supportEnabled) return false
+    return true
+  })
+}
+
+export function settingsPage(key: SettingsPageKey): SettingsPage {
+  const page = SETTINGS_PAGES.find((p) => p.key === key)
+  if (!page) throw new Error(`Unknown settings page: ${key}`)
+  return page
+}

+ 6 - 0
src/features/subscription/Components/subscription-settings.tsx

@@ -369,6 +369,7 @@ export function SubscriptionSettings({
     <div className="space-y-6">
     <div className="space-y-6">
       {/* Card 1: Current Plan */}
       {/* Card 1: Current Plan */}
       <AppCard
       <AppCard
+        id="subscription-plan"
         title={
         title={
           <span className="flex items-center gap-2">
           <span className="flex items-center gap-2">
             {planIcon} {t('subscription.title')}
             {planIcon} {t('subscription.title')}
@@ -462,11 +463,13 @@ export function SubscriptionSettings({
       {/* Upgrade, right under the current plan: it is what a free workshop opens this page for. */}
       {/* Upgrade, right under the current plan: it is what a free workshop opens this page for. */}
       {(plan === 'free' || isDemo) && !activating && (
       {(plan === 'free' || isDemo) && !activating && (
         <AppCard
         <AppCard
+          id="subscription-upgrade"
           title={t('subscription.upgradeTitle')}
           title={t('subscription.upgradeTitle')}
           description={t('subscription.upgradeToProDescription')}
           description={t('subscription.upgradeToProDescription')}
         >
         >
           {showIntervalSwitch && (
           {showIntervalSwitch && (
             <div
             <div
+              id="subscription-billing-interval"
               role="radiogroup"
               role="radiogroup"
               aria-label={t('subscription.billingInterval')}
               aria-label={t('subscription.billingInterval')}
               className="mb-4 inline-flex items-center rounded-full border bg-muted/40 p-1 text-sm"
               className="mb-4 inline-flex items-center rounded-full border bg-muted/40 p-1 text-sm"
@@ -576,6 +579,7 @@ export function SubscriptionSettings({
 
 
       {/* Card 2: Plan Features */}
       {/* Card 2: Plan Features */}
       <AppCard
       <AppCard
+        id="subscription-features"
         title={t('subscription.featuresTitle')}
         title={t('subscription.featuresTitle')}
         description={t('subscription.featuresDescription')}
         description={t('subscription.featuresDescription')}
         contentClassName="space-y-5"
         contentClassName="space-y-5"
@@ -629,6 +633,7 @@ export function SubscriptionSettings({
       {/* Card 3: Manage Subscription — demos have no Stripe billing to manage */}
       {/* Card 3: Manage Subscription — demos have no Stripe billing to manage */}
       {isPaid && !isDemo && (
       {isPaid && !isDemo && (
         <AppCard
         <AppCard
+          id="subscription-manage"
           title={t('subscription.manageTitle')}
           title={t('subscription.manageTitle')}
           description={accountLinkAvailable ? t('subscription.accountOnTorqvoice') : undefined}
           description={accountLinkAvailable ? t('subscription.accountOnTorqvoice') : undefined}
           contentClassName="flex flex-wrap gap-3"
           contentClassName="flex flex-wrap gap-3"
@@ -695,6 +700,7 @@ export function SubscriptionSettings({
 
 
       {plan === 'pro' && !isDemo && (
       {plan === 'pro' && !isDemo && (
         <AppCard
         <AppCard
+          id="subscription-upgrade-enterprise"
           title={t('subscription.upgradeTitle')}
           title={t('subscription.upgradeTitle')}
           description={t('subscription.upgradeToEnterpriseDescription')}
           description={t('subscription.upgradeToEnterpriseDescription')}
         >
         >

+ 1 - 1
src/features/support/Components/SupportSettingsControls.tsx

@@ -57,7 +57,7 @@ export function SupportSettingsControls() {
 
 
       <Separator />
       <Separator />
 
 
-      <div className="flex items-center justify-between gap-4">
+      <div id="support-contact" className="flex items-center justify-between gap-4">
         <div className="space-y-0.5">
         <div className="space-y-0.5">
           <Label>{t('support.contactTitle')}</Label>
           <Label>{t('support.contactTitle')}</Label>
           <p className="text-xs text-muted-foreground">{t('support.contactHint')}</p>
           <p className="text-xs text-muted-foreground">{t('support.contactHint')}</p>

+ 7 - 2
src/features/work-order-statuses/Components/WorkOrderStatusSettings.tsx

@@ -202,7 +202,7 @@ export function WorkOrderStatusSettings({ statuses }: { statuses: WorkOrderStatu
       <ReadOnlyWrapper>
       <ReadOnlyWrapper>
         <div className="space-y-6">
         <div className="space-y-6">
           {offered.length > 0 && (
           {offered.length > 0 && (
-            <div className="flex flex-wrap items-center gap-2">
+            <div id="work-order-statuses-quick-add" className="flex flex-wrap items-center gap-2">
               <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
               <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
                 {t('suggestionsLabel')}
                 {t('suggestionsLabel')}
               </span>
               </span>
@@ -227,7 +227,12 @@ export function WorkOrderStatusSettings({ statuses }: { statuses: WorkOrderStatu
             </div>
             </div>
           )}
           )}
 
 
-          <AppCard icon={ListChecks} title={t('cardTitle')} contentClassName="p-0">
+          <AppCard
+            id="work-order-statuses-stages"
+            icon={ListChecks}
+            title={t('cardTitle')}
+            contentClassName="p-0"
+          >
             <div className="divide-y divide-card-edge">
             <div className="divide-y divide-card-edge">
               {STAGES.map((stage, stageIndex) => {
               {STAGES.map((stage, stageIndex) => {
                 const inStage = statuses.filter((s) => s.stage === stage)
                 const inStage = statuses.filter((s) => s.stage === stage)