Просмотр исходного кода

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

Bernt Christian Egeland 4 дней назад
Родитель
Сommit
c6c46f5a85
87 измененных файлов с 5831 добавлено и 755 удалено
  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",
   "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.",
+  "noResultsFor": "Keine Treffer für „{query}“",
+  "noResultsHint": "Versuchen Sie ein Kennzeichen, eine Telefonnummer, eine Rechnungsnummer oder den Namen einer Einstellung.",
   "recentCustomers": "Zuletzt genutzte Kunden",
   "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",
   "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.",
+  "noResultsFor": "Nothing matches “{query}”",
+  "noResultsHint": "Try a plate, a phone number, an invoice number, or the name of a setting.",
   "recentCustomers": "Recent customers",
   "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",
   "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.",
+  "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",
   "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",
   "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.",
+  "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",
   "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",
   "description": "Cerca veicoli, clienti, interventi e impostazioni",
-  "placeholder": "Cerca per nome, targa, telefono, telaio, fattura…",
+  "placeholder": "Cerca clienti, targhe, fatture, impostazioni…",
   "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",
   "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",
   "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.",
+  "noResultsFor": "Nieko nerasta pagal „{query}“",
+  "noResultsHint": "Pabandykite valstybinį numerį, telefono numerį, sąskaitos numerį arba nustatymo pavadinimą.",
   "recentCustomers": "Naujausi klientai",
   "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",
   "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.",
+  "noResultsFor": "Ingen treff på «{query}»",
+  "noResultsHint": "Prøv et skiltnummer, et telefonnummer, et fakturanummer eller navnet på en innstilling.",
   "recentCustomers": "Nylige kunder",
   "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",
   "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.",
+  "noResultsFor": "Niets gevonden voor \"{query}\"",
+  "noResultsHint": "Probeer een kenteken, een telefoonnummer, een factuurnummer of de naam van een instelling.",
   "recentCustomers": "Recente klanten",
   "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",
   "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.",
+  "noResultsFor": "Brak wyników dla „{query}”",
+  "noResultsHint": "Spróbuj numeru rejestracyjnego, numeru telefonu, numeru faktury lub nazwy ustawienia.",
   "recentCustomers": "Ostatni klienci",
   "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",
   "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.",
+  "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",
   "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",
   "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.",
+  "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",
   "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": "Поиск",
   "description": "Поиск по автомобилям, клиентам, работам и настройкам",
-  "placeholder": "Имя, номер, телефон, VIN, счёт…",
+  "placeholder": "Поиск клиентов, номеров, счетов, настроек…",
   "noResults": "Ничего не найдено.",
+  "noResultsFor": "Ничего не найдено по запросу «{query}»",
+  "noResultsHint": "Попробуйте госномер, номер телефона, номер счёта или название настройки.",
   "recentCustomers": "Недавние клиенты",
   "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",
   "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.",
+  "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",
   "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 { isAiConfigured } from '@/features/integrations/Lib/ai'
 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 { PlateLookupCommand } from '@/features/vehicles/Components/PlateLookupCommand'
 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)
       : 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
     ? (getManifest(lookupConnection.connectorId)?.name ?? null)
     : null
@@ -365,7 +377,17 @@ export default async function DashboardLayout({ children }: { children: React.Re
                                   {children}
                                 </div>
                               </SidebarInset>
-                              <SearchCommand />
+                              <SearchCommand
+                                visibleSubjects={visibleSubjects}
+                                features={features}
+                                tireHotelEnabled={tireHotelEnabled}
+                                aiEnabled={aiEnabled}
+                                isAdminOrOwner={isOwnerOrAdmin}
+                                isCloud={cloudLinked}
+                                supportEnabled={supportEnabled}
+                                isMarine={data.serviceType === 'marine'}
+                                connectors={searchConnectors}
+                              />
                               <PlateLookupCommand />
                               {isOwnerOrAdmin && <NotificationInitializer />}
                               <OnlineTracker />

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

@@ -18,11 +18,16 @@ export default async function AboutSettingsPage() {
 
   return (
     <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>
         <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 font-medium">{version}</span>
           </div>

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

@@ -279,7 +279,12 @@ export function AccountSettings({
       </Card> */}
 
       {/* 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>
         <div className="grid gap-4 sm:grid-cols-2">
           <div className="space-y-2">
@@ -354,10 +359,13 @@ export function AccountSettings({
         </div>
       </AppCard>
 
-      <SignatureCard initial={signature} />
+      <div id="account-signature">
+        <SignatureCard initial={signature} />
+      </div>
 
       {/* Change Password */}
       <AppCard
+        id="account-password"
         icon={KeyRound}
         title={t('account.changePasswordTitle')}
         contentClassName="space-y-4"
@@ -405,7 +413,12 @@ export function AccountSettings({
         </div>
       </AppCard>
       {/* 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 ? (
           <>
             <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 (
     <AppCard
+      id="account-devices"
       icon={MonitorSmartphone}
       title={t('account.devicesTitle')}
       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 (
     <AppCard
+      id="alerts-technician-status"
       icon={Smartphone}
       title={t('alerts.technicianStatus.title')}
       contentClassName="space-y-6"
@@ -127,7 +128,12 @@ function ServiceRequestAlertCard({ settings }: { settings: Record<string, string
   }
 
   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>
 
       <div className="flex items-center justify-between">
@@ -244,6 +250,7 @@ function LowStockAlertCard({ settings }: { settings: Record<string, string> }) {
 
   return (
     <AppCard
+      id="alerts-low-stock"
       icon={PackageSearch}
       title={t('alerts.lowStock.title')}
       action={
@@ -346,7 +353,12 @@ function LowStockAlertCard({ settings }: { settings: Record<string, string> }) {
             {t('alerts.lowStock.save')}
           </Button>
           {enabled && (
-            <Button variant="outline" onClick={handleCheckNow} disabled={checking}>
+            <Button
+              id="alerts-low-stock-check-now"
+              variant="outline"
+              onClick={handleCheckNow}
+              disabled={checking}
+            >
               {checking ? (
                 <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 (
     <div className="space-y-6">
       <AppCard
+        id="appearance-theme"
         icon={Palette}
         title={t('themeLabel')}
         description={t('themeHint')}
@@ -27,6 +28,7 @@ export function AppearanceSettings() {
       </AppCard>
 
       <AppCard
+        id="appearance-font"
         icon={Type}
         title={t('fontLabel')}
         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">
       <ReadOnlyBanner />
       <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 h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg border bg-muted/50">
               {logoUrl ? (
@@ -171,7 +171,12 @@ export function CompanySettings({
             />
           </div>
         </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="space-y-1">
               <Label htmlFor="workshopName" className="text-xs">
@@ -268,7 +273,12 @@ export function CompanySettings({
               )}
               {t('company.saveCompany')}
             </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" />
               {t('company.addNewCompany')}
             </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'
           return (
             <AppCard
+              id="data-protection"
               icon={ShieldCheck}
               title={t('data.backupStatus.title')}
               className="gap-2 border border-primary/30 shadow-sm"
@@ -490,6 +491,7 @@ export function DataSettings({
       <ReadOnlyWrapper>
         {/* Bring data in: one card, one row per source */}
         <AppCard
+          id="data-import"
           icon={Upload}
           title={t('data.importTitle')}
           description={t('data.importHubDescription')}
@@ -497,6 +499,7 @@ export function DataSettings({
         >
           <ul className="divide-y">
             <ImportSourceRow
+              id="data-import-spreadsheet"
               icon={FileSpreadsheet}
               title={t('data.sourceSpreadsheet')}
               description={t('data.sourceSpreadsheetDesc')}
@@ -513,6 +516,7 @@ export function DataSettings({
               ))}
             </ImportSourceRow>
             <ImportSourceRow
+              id="data-import-lubelog"
               icon={Wrench}
               title={
                 <Image
@@ -531,6 +535,7 @@ export function DataSettings({
               </Button>
             </ImportSourceRow>
             <ImportSourceRow
+              id="data-import-invoice-ninja"
               icon={Receipt}
               title={
                 <Image
@@ -549,6 +554,7 @@ export function DataSettings({
               </Button>
             </ImportSourceRow>
             <ImportSourceRow
+              id="data-import-backup"
               icon={FileArchive}
               title={t('data.sourceBackup')}
               description={t('data.sourceBackupDesc')}
@@ -564,6 +570,7 @@ export function DataSettings({
 
         {/* Export */}
         <AppCard
+          id="data-export"
           icon={Download}
           title={t('data.exportTitle')}
           description={t('data.exportDescription')}
@@ -581,7 +588,10 @@ export function DataSettings({
               </button>
             </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 }) => (
                 <label
                   key={key}
@@ -766,13 +776,13 @@ export function DataSettings({
       </Dialog>
 
       {/* 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">
           <AlertTriangle className="h-5 w-5 text-destructive" />
           <CardTitle className="text-lg text-destructive">{t('account.dangerZone')}</CardTitle>
         </CardHeader>
         <CardContent>
-          <div className="flex items-start justify-between gap-4">
+          <div id="data-delete-content" className="flex items-start justify-between gap-4">
             <div>
               <p className="font-medium">{t('account.deleteContentTitle')}</p>
               <p className="text-sm text-muted-foreground">
@@ -807,7 +817,7 @@ export function DataSettings({
           {isOwner && (
             <>
               <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>
                   <p className="font-medium">{t('account.deleteWorkshopTitle')}</p>
                   <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="flex items-start justify-between gap-4">
+          <div id="data-delete-account" className="flex items-start justify-between gap-4">
             <div>
               <p className="font-medium">{t('account.deleteAccountTitle')}</p>
               <p className="text-sm text-muted-foreground">
@@ -1214,18 +1224,21 @@ export function DataSettings({
  * the title slot where it has room.
  */
 function ImportSourceRow({
+  id,
   icon: Icon,
   title,
   description,
   children,
 }: {
+  /** Anchor for deep links from settings search. */
+  id?: string
   icon: React.ComponentType<{ className?: string }>
   title: React.ReactNode
   description: string
   children: React.ReactNode
 }) {
   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">
         <Icon className="h-5 w-5" />
       </div>

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

@@ -107,6 +107,7 @@ export function InspectionReminderSettings({
       <ReadOnlyBanner />
       <ReadOnlyWrapper>
         <AppCard
+          id="inspection-reminders-booking"
           icon={ClipboardCheck}
           title={t('title')}
           action={<DocsLink href="/docs/features/inspection-reminders" variant="hint" />}
@@ -128,18 +129,28 @@ export function InspectionReminderSettings({
           </div>
 
           <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">
               <Label htmlFor="ir-mode">{t('bookingMode')}</Label>
               <Select value={mode} onValueChange={setMode}>

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

@@ -15,7 +15,7 @@ export function InstallSettingsClient() {
 
   return (
     <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">
           <Image
             src="/icons/icon-192.png"
@@ -55,7 +55,7 @@ export function InstallSettingsClient() {
         {dismissed && !installed && (
           <>
             <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>
               <Button variant="outline" size="sm" onClick={restore}>
                 {t('showBanner')}

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

@@ -124,6 +124,7 @@ export function IntegrationsCatalog({
   return (
     <div className="space-y-4">
       <AppCard
+        id="integrations-connected"
         icon={Check}
         title={t('catalog.connectedTitle')}
         badge={connected.length || undefined}
@@ -177,6 +178,7 @@ export function IntegrationsCatalog({
       </AppCard>
 
       <AppCard
+        id="integrations-available"
         icon={Plug}
         title={t('catalog.availableTitle')}
         description={t('catalog.availableDescription')}
@@ -194,6 +196,7 @@ export function IntegrationsCatalog({
         <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" />
           <Input
+            id="integrations-search"
             type="search"
             value={query}
             onChange={(e) => setQuery(e.target.value)}
@@ -204,7 +207,10 @@ export function IntegrationsCatalog({
         </div>
 
         {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
               variant={category === 'all' ? 'default' : 'outline'}
               size="sm"
@@ -291,7 +297,10 @@ export function IntegrationsCatalog({
         )}
 
         {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">
               <Lightbulb className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
               <div>

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

@@ -333,6 +333,7 @@ export function InvoiceSettings({
             {t('invoice.tabs.general')}
           </button>
           <button
+            id="invoice-tab-custom-fields"
             type="button"
             onClick={() => setTab('customFields')}
             className={cn(
@@ -351,6 +352,7 @@ export function InvoiceSettings({
         <ReadOnlyWrapper>
           <div className="space-y-6">
             <AppCard
+              id="invoice-numbering"
               icon={Hash}
               title={t('invoice.numberingTitle')}
               description={t('invoice.numberingDescription')}
@@ -434,6 +436,7 @@ export function InvoiceSettings({
             </AppCard>
 
             <AppCard
+              id="invoice-documents"
               icon={FileText}
               title={t('invoice.documentsTitle')}
               description={t('invoice.documentsDescription')}
@@ -462,6 +465,7 @@ export function InvoiceSettings({
             </AppCard>
 
             <AppCard
+              id="invoice-shop-fee"
               icon={Receipt}
               title={t('invoice.shopFee.title')}
               description={t('invoice.shopFee.description')}
@@ -572,6 +576,7 @@ export function InvoiceSettings({
             </AppCard>
 
             <AppCard
+              id="invoice-lock"
               icon={Lock}
               title={t('invoice.lockTitle')}
               description={t('invoice.lockDescription')}
@@ -670,7 +675,7 @@ export function InvoiceSettings({
               </div>
 
               {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>
                   <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">
@@ -699,6 +704,7 @@ export function InvoiceSettings({
 
             {issuedInvoices > 0 && (
               <AppCard
+                id="invoice-reapply-design"
                 icon={Palette}
                 title={t('invoice.reapplyTitle')}
                 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 { getTranslations } from 'next-intl/server'
 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 }) {
   const data = await getLayoutData()
@@ -50,6 +52,10 @@ export default async function SettingsLayout({ children }: { children: React.Rea
 
   return (
     <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">
         <PageHeader />
         <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 />
 
       {/* 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>
-        <div className="space-y-2">
+        <div id="localization-workshop-language" className="space-y-2">
           <Label>{t('localization.language')}</Label>
           <Select value={workshopLocale} onValueChange={handleLanguageChange}>
             <SelectTrigger className="w-64">
@@ -234,7 +239,7 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
 
         <Separator />
 
-        <div className="flex items-center justify-between">
+        <div id="localization-force-customer-locale" className="flex items-center justify-between">
           <div className="pr-4">
             <Label>{t('localization.forceCustomerLocale')}</Label>
             <p className="text-xs text-muted-foreground">
@@ -276,12 +281,17 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
       </AppCard>
 
       {/* 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>
 
         <ReadOnlyWrapper>
           <div className="space-y-6">
-            <div className="space-y-2">
+            <div id="localization-currency-code" className="space-y-2">
               <Label>{t('currency.currencyLabel')}</Label>
               <Popover open={currencyOpen} onOpenChange={setCurrencyOpen}>
                 <PopoverTrigger asChild>
@@ -328,7 +338,7 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
               </Popover>
             </div>
 
-            <div className="space-y-2">
+            <div id="localization-currency-format" className="space-y-2">
               <Label>{t('currency.formatTitle')}</Label>
               <Select
                 value={currencyFormat}
@@ -367,7 +377,12 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
       </AppCard>
 
       {/* 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>
 
         <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">
       <ReadOnlyBanner />
       <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>
 
           <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 />
       <ReadOnlyWrapper>
         <AppCard
+          id="payment-details"
           icon={Banknote}
           title={t('payment.detailsTitle')}
           action={
@@ -129,7 +130,12 @@ export function PaymentSettings({
           </div>
         </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>
 
           <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>
         </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>
 
           <div className="space-y-2">
@@ -192,7 +203,7 @@ export function PaymentSettings({
           </div>
 
           {!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>
               <div className="flex items-center gap-2">
                 <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>
           <p className="text-sm text-muted-foreground">{t('description')}</p>
         </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" />
           {t('newSchedule')}
         </Button>
@@ -613,7 +613,7 @@ export function ReportScheduleSettings({ schedules, members }: Props) {
           </CardContent>
         </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) => (
             <ScheduleCard
               key={schedule.id}

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

@@ -1,8 +1,10 @@
 'use client'
 
+import { useEffect, useMemo, useRef, useState } from 'react'
 import Link from 'next/link'
 import { usePathname, useRouter } from 'next/navigation'
 import { useTranslations } from 'next-intl'
+import { Search, X } from 'lucide-react'
 import { cn } from '@/lib/utils'
 import type { PlanFeatures } from '@/lib/features'
 import {
@@ -13,127 +15,180 @@ import {
   SelectValue,
 } from '@/components/ui/select'
 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({
   features,
@@ -141,76 +196,61 @@ export function SettingsNav({
   supportEnabled,
   newItems = [],
   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 router = useRouter()
   const t = useTranslations('settings')
 
-  const newPill = (item: SettingsNavItem) =>
+  const pages = visibleSettingsPages({ isCloud, supportEnabled })
+
+  const newPill = (item: SettingsPage) =>
     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">
         {t('nav.new')}
       </span>
     ) : 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) {
-    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 (
-      <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}>
                 <span className="flex items-center gap-2">
                   {t(`nav.items.${item.key}.title`)}
                   {newPill(item)}
                 </span>
               </SelectItem>
-            ))
-          })}
-        </SelectContent>
-      </Select>
+            ))}
+          </SelectContent>
+        </Select>
+      </div>
     )
   }
 
   return (
     <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
         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">
-              {t(`nav.categories.${category.key}`)}
+              {t(`nav.categories.${category}`)}
             </p>
             <div className="flex flex-col gap-0.5">
               {visibleItems.map((item) => {
                 const isActive = pathname === item.href
                 const isLocked = !!(item.gate && features && !features[item.gate])
+                const Icon = SETTINGS_PAGE_ICONS[item.key]
                 return (
                   <Link
                     key={item.href}
@@ -224,7 +264,7 @@ export function SettingsNav({
                           : '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="flex items-center gap-2">
                         <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 (
     <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>
         <SupportSettingsControls />
       </AppCard>

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

@@ -206,7 +206,7 @@ export function TaxSettings({
 
         <ReadOnlyWrapper>
           <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">
                 <Label>{t('currency.enableTax')}</Label>
                 <p className="text-xs text-muted-foreground">{t('currency.enableTaxHint')}</p>
@@ -288,7 +288,7 @@ export function TaxSettings({
                   </Select>
                 </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">
                     <span>{t('tax.componentName')}</span>
                     <span>{t('tax.componentRate')}</span>
@@ -391,6 +391,7 @@ export function TaxSettings({
 
             {taxEnabled && taxSelective && (
               <div
+                id="tax-applies-to"
                 className="space-y-3 rounded-lg border bg-muted/30 p-4"
                 data-testid="tax-applies"
               >
@@ -414,7 +415,7 @@ export function TaxSettings({
             )}
 
             {taxEnabled && (
-              <div className="space-y-2">
+              <div id="tax-mode" className="space-y-2">
                 <Label>{t('tax.modeLabel')}</Label>
                 <p className="text-xs text-muted-foreground">{t('tax.modeHint')}</p>
                 <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 */}
       <AppCard
+        id="team-members"
         icon={Users}
         title={
           <>
@@ -548,7 +549,7 @@ export function TeamSettings({
         contentClassName="space-y-4"
         action={
           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" />
               {t('team.addPerson')}
             </Button>
@@ -692,7 +693,7 @@ export function TeamSettings({
             The Add flow creates these, so the page has to show them, or the
             desk adds somebody and watches them vanish. */}
         {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="text-muted-foreground text-xs">{t('team.boardOnlyHint')}</p>
             {standaloneTechnicians.map((tech) => (
@@ -738,6 +739,7 @@ export function TeamSettings({
       {/* Pending Invitations Card */}
       {isAdmin && pendingInvitations.length > 0 && (
         <AppCard
+          id="team-invitations"
           icon={Mail}
           title={
             <>
@@ -808,6 +810,7 @@ export function TeamSettings({
       {/* Custom Roles Card */}
       {isAdmin && (
         <AppCard
+          id="team-roles"
           icon={ShieldCheck}
           title={t('team.customRoles')}
           action={
@@ -826,7 +829,12 @@ export function TeamSettings({
                     {t('team.createDefaultRoles')}
                   </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" />
                   {t('team.newRole')}
                 </Button>
@@ -1032,7 +1040,7 @@ export function TeamSettings({
       )}
 
       {/* 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="flex items-center gap-3">
             <Badge variant="outline" className={`${roleColors.owner}`}>

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

@@ -227,7 +227,12 @@ function TemplateTab({
   }
 
   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>
 
       {/* The workshop's own saved designs, above the built-in starting
@@ -351,7 +356,10 @@ function TemplateTab({
         })}
       </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>
         <Button asChild variant="outline">
           <Link href={`/invoice-designer?doc=${documentType}`}>
@@ -444,7 +452,7 @@ function SmsTemplateTab({
 
   return (
     <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">
           {t.rich('templates.smsTemplatesDescription', {
             code: (chunks) => (
@@ -652,7 +660,7 @@ export function TemplateSettings({
       {/* Two levels: what kind of template, then which document. The page
           holds three different editors, and the four printed documents are
           only one of them. */}
-      <div className="space-y-3">
+      <div id="templates-tabs" className="space-y-3">
         <div
           role="tablist"
           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" />
       </div>
       <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="space-y-0.5 pr-4">
               <Label htmlFor="tireHotelEnabled">{t('tireHotel.enable')}</Label>
@@ -161,7 +166,7 @@ export function TireHotelSettings({ settings }: { settings: Record<string, strin
 
               <Separator />
 
-              <div>
+              <div id="tire-hotel-tread">
                 <p className="text-sm font-medium">{t('tireHotel.treadTitle')}</p>
                 <p className="text-xs text-muted-foreground">{t('tireHotel.treadHint')}</p>
               </div>
@@ -197,7 +202,7 @@ export function TireHotelSettings({ settings }: { settings: Record<string, strin
 
               <Separator />
 
-              <div>
+              <div id="tire-hotel-billing">
                 <p className="text-sm font-medium">{t('tireHotel.billingTitle')}</p>
                 <p className="text-xs text-muted-foreground">{t('tireHotel.billingHint')}</p>
               </div>
@@ -220,7 +225,7 @@ export function TireHotelSettings({ settings }: { settings: Record<string, strin
 
               <Separator />
 
-              <div>
+              <div id="tire-hotel-prep">
                 <p className="text-sm font-medium">{t('tireHotel.prepTitle')}</p>
                 <p className="text-xs text-muted-foreground">{t('tireHotel.prepHint')}</p>
               </div>

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

@@ -105,7 +105,7 @@ export function WarrantySettings({ settings }: { settings: Record<string, string
 
           <Separator />
 
-          <div className="space-y-4">
+          <div id="warranty-included" className="space-y-4">
             <div className="space-y-0.5">
               <h3 className="text-sm font-semibold">{t('includedTitle')}</h3>
               <p className="text-xs text-muted-foreground">{t('includedHint')}</p>
@@ -150,7 +150,7 @@ export function WarrantySettings({ settings }: { settings: Record<string, string
 
           <Separator />
 
-          <div className="space-y-4">
+          <div id="warranty-not-included" className="space-y-4">
             <div className="space-y-0.5">
               <h3 className="text-sm font-semibold">{t('notIncludedTitle')}</h3>
               <p className="text-xs text-muted-foreground">{t('notIncludedHint')}</p>
@@ -170,7 +170,7 @@ export function WarrantySettings({ settings }: { settings: Record<string, string
 
           <Separator />
 
-          <div className="space-y-4">
+          <div id="warranty-apply-to" className="space-y-4">
             <h3 className="text-sm font-semibold">{t('applyTitle')}</h3>
             <div className="flex items-center justify-between gap-4">
               <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 (
     <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>
           <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>
@@ -143,7 +146,7 @@ export function WebhooksSettings({ webhooks }: Props) {
       </div>
 
       <Tabs value={tab} onValueChange={(v) => setTab(v as 'webhooks' | 'reference')}>
-        <TabsList>
+        <TabsList id="webhooks-tabs">
           <TabsTrigger value="webhooks">
             <WebhookIcon className="mr-2 h-4 w-4" />
             {t('tabWebhooks')}
@@ -1168,7 +1171,7 @@ function EventsReference() {
 function SignatureHelpCard() {
   const t = useTranslations('settings.webhooks')
   return (
-    <Card>
+    <Card id="webhooks-signatures">
       <CardContent className="space-y-3 p-4 text-sm">
         <h2 className="font-semibold">{t('helpTitle')}</h2>
         <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">
       <ReadOnlyBanner />
       <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>
 
           <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>
               <Popover open={techOpen} onOpenChange={setTechOpen} modal={true}>
                 <PopoverTrigger asChild>
@@ -391,7 +398,7 @@ export function WorkshopSettings({
           {/* What a job is called before anybody types a title. Resolved on
               the server when the draft is made, so the preview here uses
               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">
               <Tag className="h-5 w-5 text-muted-foreground" />
               <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
               customer, which is settled here on the job, long before an
               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">
               <Percent className="h-5 w-5 text-muted-foreground" />
               <h3 className="text-lg font-semibold">{t('workshop.partsMarkupTitle')}</h3>
@@ -514,7 +521,7 @@ export function WorkshopSettings({
 
           <Separator />
 
-          <div className="space-y-4">
+          <div id="workshop-units" className="space-y-4">
             <div className="flex flex-row items-center gap-3">
               <Ruler className="h-5 w-5 text-muted-foreground" />
               <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>
 
             <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>
                 <Select value={unitSystem} onValueChange={setUnitSystem}>
                   <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);
   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,
   className,
   contentClassName,
+  id,
 }: {
+  /** Anchor for deep links from search; the card scrolls into view and lights up. */
+  id?: string
   icon?: ComponentType<{ className?: string }>
   title: ReactNode
   /** Small count pill after the title. Pass `list.length || undefined` to hide zeros. */
@@ -48,9 +51,10 @@ export function AppCard({
 }) {
   return (
     <div
+      id={id}
       data-slot="card"
       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.
         // 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)]',

+ 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>
               </div>
 
-              <div className="space-y-2">
+              <div id="customer-portal-url" className="space-y-2">
                 <Label>{t('portal.portalUrl')}</Label>
                 <div className="flex gap-2">
                   <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
                     is configured on another page. Without a pointer, an operator
                     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" />
                 <p className="text-xs text-muted-foreground">
                   {t('portal.serviceRequestAlertsHint')}{' '}
@@ -297,13 +300,14 @@ export function CustomerPortalSettings({
 
         {enabled && (
           <AppCard
+            id="customer-portal-background"
             icon={ImageIcon}
             title={t('portal.background.title')}
             description={t('portal.background.description')}
             contentClassName="space-y-6"
           >
             {/* 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') },
@@ -329,7 +333,7 @@ export function CustomerPortalSettings({
 
             {/* Template grid */}
             {backgroundType === 'template' && (
-              <div className="space-y-2">
+              <div id="customer-portal-background-template" className="space-y-2">
                 <Label>{t('portal.background.chooseTemplate')}</Label>
                 <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
                   {PORTAL_BACKGROUND_TEMPLATES.map((tpl) => {
@@ -365,7 +369,7 @@ export function CustomerPortalSettings({
 
             {/* Image upload */}
             {backgroundType === 'image' && (
-              <div className="space-y-3">
+              <div id="customer-portal-background-image" className="space-y-3">
                 <Label>{t('portal.background.uploadLabel')}</Label>
                 <div className="flex items-center gap-4">
                   <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'
 
-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 { useRouter } from 'next/navigation'
-import {
-  CommandDialog,
-  CommandInput,
-  CommandList,
-  CommandEmpty,
-  CommandGroup,
-  CommandItem,
-} from '@/components/ui/command'
 import {
   Bell,
   Car,
   ClipboardCheck,
+  CornerDownLeft,
   Disc3,
   FileText,
+  type LucideIcon,
   Package,
-  Settings,
+  Plug,
+  ScanLine,
+  Search,
   Users,
   Wrench,
 } 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 { formatTypeKey } from '@/features/vehicles/Lib/typeKey'
 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 {
-  vehicles: {
-    id: string
-    make: string
-    model: string
-    year: number
-    licensePlate: string | null
-    hsn?: string | null
-    tsn?: string | null
-  }[]
+  vehicles: Vehicle[]
   customers: {
     id: string
     name: string
     email: string | null
     phone: string | null
     company: string | null
-    vehicles: {
-      id: string
-      make: string
-      model: string
-      year: number
-      licensePlate: string | null
-    }[]
+    vehicles: Vehicle[]
   }[]
   services: {
     id: string
     title: string
     invoiceNumber: string | null
-    vehicle: {
-      id: string
-      make: string
-      model: string
-      year: number
-      licensePlate: string | null
-    } | null
+    vehicle: Vehicle | null
   }[]
   parts: {
     id: string
@@ -82,26 +93,14 @@ interface SearchResult {
     dueDate: Date | null
     isCompleted: boolean
     customer: { id: string; name: string } | null
-    vehicle: {
-      id: string
-      make: string
-      model: string
-      year: number
-      licensePlate: string | null
-    } | null
+    vehicle: Vehicle | null
   }[]
   inspections: {
     id: string
     status: string
     createdAt: Date
     template: { name: string }
-    vehicle: {
-      id: string
-      make: string
-      model: string
-      year: number
-      licensePlate: string | null
-    }
+    vehicle: Vehicle
   }[]
   tireSets: {
     id: string
@@ -112,97 +111,19 @@ interface SearchResult {
     status: string
     location: { code: 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 = {
@@ -213,29 +134,332 @@ type RecentCustomer = {
   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 t = useTranslations('search')
   const tNav = useTranslations('navigation.sidebar')
-  const typeKeyEnabled = useTypeKeyEnabled()
   const tSettings = useTranslations('settings')
+  const tIntegrations = useTranslations('integrations')
   const tTire = useTranslations('tireHotel')
+  const tPlate = useTranslations('vehicles.plateLookup')
+  const typeKeyEnabled = useTypeKeyEnabled()
+  const plateLookup = usePlateLookupAccess()
+
   const [open, setOpen] = useState(false)
   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 [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(() => {
     const down = (e: KeyboardEvent) => {
@@ -248,65 +472,91 @@ export function SearchCommand() {
     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(() => {
-    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])
 
+  // 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(() => {
-    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)
       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)
+    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.customers.length > 0 ||
     results.services.length > 0 ||
@@ -315,319 +565,374 @@ export function SearchCommand() {
     results.reminders.length > 0 ||
     results.inspections.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 (
     <CommandDialog
       open={open}
-      onOpenChange={setOpen}
+      onOpenChange={(next) => (next ? setOpen(true) : close())}
       title={t('title')}
       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')}>
-            {recentCustomers.map((c) => (
-              <CommandItem
+            {recentCustomers.slice(0, RECENT_CUSTOMERS_SHOWN).map((c) => (
+              <Row
                 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>
         )}
 
-        {/* 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}
-                    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>
         )}
+        {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>
-      <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>
     </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 (
     <div className="space-y-6">
       <AppCard
+        id="license-white-label"
         icon={Key}
         title={t('license.title')}
         description={t('license.description')}
@@ -96,6 +97,7 @@ export function LicenseSettings({
           !licenseValid && !demoMode ? (
             <Button asChild variant="default" size="sm" className="shrink-0">
               <a
+                id="license-purchase"
                 href={`${process.env.NEXT_PUBLIC_TORQVOICE_COM_URL || 'https://torqvoice.com'}/subscriptions/white-label`}
                 target="_blank"
                 rel="noopener noreferrer"
@@ -108,7 +110,7 @@ export function LicenseSettings({
         }
         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>
           {statusBadge}
           {initialExpiresAt && (
@@ -132,6 +134,7 @@ export function LicenseSettings({
         )}
         <div className="flex gap-2">
           <Input
+            id="license-key"
             placeholder={t('license.enterLicenseKey')}
             value={licenseKey}
             onChange={(e) => setLicenseKey(e.target.value)}

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

@@ -119,7 +119,12 @@ export function PasskeySettings() {
 
   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>
 
         {loading ? (
@@ -179,7 +184,7 @@ export function PasskeySettings() {
         )}
 
         <Separator />
-        <Button onClick={handleRegister} disabled={registering}>
+        <Button id="account-register-passkey" onClick={handleRegister} disabled={registering}>
           {registering ? (
             <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">
       {/* Card 1: Current Plan */}
       <AppCard
+        id="subscription-plan"
         title={
           <span className="flex items-center gap-2">
             {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. */}
       {(plan === 'free' || isDemo) && !activating && (
         <AppCard
+          id="subscription-upgrade"
           title={t('subscription.upgradeTitle')}
           description={t('subscription.upgradeToProDescription')}
         >
           {showIntervalSwitch && (
             <div
+              id="subscription-billing-interval"
               role="radiogroup"
               aria-label={t('subscription.billingInterval')}
               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 */}
       <AppCard
+        id="subscription-features"
         title={t('subscription.featuresTitle')}
         description={t('subscription.featuresDescription')}
         contentClassName="space-y-5"
@@ -629,6 +633,7 @@ export function SubscriptionSettings({
       {/* Card 3: Manage Subscription — demos have no Stripe billing to manage */}
       {isPaid && !isDemo && (
         <AppCard
+          id="subscription-manage"
           title={t('subscription.manageTitle')}
           description={accountLinkAvailable ? t('subscription.accountOnTorqvoice') : undefined}
           contentClassName="flex flex-wrap gap-3"
@@ -695,6 +700,7 @@ export function SubscriptionSettings({
 
       {plan === 'pro' && !isDemo && (
         <AppCard
+          id="subscription-upgrade-enterprise"
           title={t('subscription.upgradeTitle')}
           description={t('subscription.upgradeToEnterpriseDescription')}
         >

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

@@ -57,7 +57,7 @@ export function SupportSettingsControls() {
 
       <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">
           <Label>{t('support.contactTitle')}</Label>
           <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>
         <div className="space-y-6">
           {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">
                 {t('suggestionsLabel')}
               </span>
@@ -227,7 +227,12 @@ export function WorkOrderStatusSettings({ statuses }: { statuses: WorkOrderStatu
             </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">
               {STAGES.map((stage, stageIndex) => {
                 const inStage = statuses.filter((s) => s.stage === stage)