diff --git a/CHANGELOG.md b/CHANGELOG.md index b926b4820..eaf17ff3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ ## [Unreleased] +### Fixed + +- The mood sliders no longer read their value fused with the reset control, so a + slider set to 4 shows the number on its own instead of "4 leeren". The reset + is a small icon beside the value now. +- Date and time fields wear the same focus highlight as every other input. The + ring hugs the field instead of painting wider than it or jumping to the left + when you click in, and the fields no longer set off the zoom on iOS. +- The add-mood sheet keeps its scrollbar clear of the endpoint labels, so the + longest wording is never clipped by the scroll track. +- The health-score configuration lists its topics as one flat set with dividers + instead of grouping them under headings. +- In Settings, under Security, password reset now sits last, after the + authenticator app, security keys and passkeys. +- Sharing a record again with someone whose access had expired works. The old + lapsed grant no longer blocks a fresh invitation with a false report that the + person still has access. +- The scope options when inviting someone start with nothing preselected, so the + choice between the whole record and single sections is one you make rather than + one already made for you. +- The write and manage descriptions on the invite form are one line each now, + with the full detail behind the info icon instead of a wall of text. + ## [1.37.2] — 2026-08-08 ### Added diff --git a/e2e/account-sharing.spec.ts b/e2e/account-sharing.spec.ts index 618ff1ee5..2ed1ab6ce 100644 --- a/e2e/account-sharing.spec.ts +++ b/e2e/account-sharing.spec.ts @@ -191,6 +191,16 @@ test.describe("account sharing", () => { '[data-slot="grant-invite-identifier"]', ); const submit = ownerPage.locator('[data-slot="grant-invite-submit"]'); + + // Nothing under the scope question is preselected now, so the form blocks + // until the owner picks one. This invitation is unscoped, so choose the + // whole record before asserting the button can enable. + const wholeRecord = ownerPage.locator( + '[data-slot="grant-invite-scope-option"][data-scope="all"]', + ); + await wholeRecord.click(); + await expect(wholeRecord).toHaveAttribute("data-selected", "true"); + await expect(async () => { await identifier.fill(E2E_USER.username); await expect(submit).toBeEnabled({ timeout: 1000 }); diff --git a/e2e/delegated-writes.spec.ts b/e2e/delegated-writes.spec.ts index 2957dc2d7..d55d40d49 100644 --- a/e2e/delegated-writes.spec.ts +++ b/e2e/delegated-writes.spec.ts @@ -106,6 +106,15 @@ test.describe("delegated writes", () => { '[data-slot="grant-invite-identifier"]', ); const submit = ownerPage.locator('[data-slot="grant-invite-submit"]'); + + // The scope question preselects nothing, so the form blocks until the owner + // picks one. This invitation is unscoped — choose the whole record. + const wholeRecord = ownerPage.locator( + '[data-slot="grant-invite-scope-option"][data-scope="all"]', + ); + await wholeRecord.click(); + await expect(wholeRecord).toHaveAttribute("data-selected", "true"); + // The controlled input keeps the submit disabled until React has attached, // so retry the pair rather than waiting a fixed time and hoping. await expect(async () => { diff --git a/e2e/settings-score.spec.ts b/e2e/settings-score.spec.ts index 399bd9f2e..f8e57e90a 100644 --- a/e2e/settings-score.spec.ts +++ b/e2e/settings-score.spec.ts @@ -130,14 +130,13 @@ test.describe("Settings → Health Score", () => { 1, ); - // The cardiometabolic triple, visibly under one heading. + // The cardiometabolic triple. The list is flat (no group headings) — the + // rows carry their domain, so the three cardiometabolic pillars are found + // on the row itself. const cardio = page.locator( - '[data-slot="score-config-group"][data-domain="cardiometabolic"]', - ); - await expect(cardio).toHaveCount(1); - await expect(cardio.locator('[data-slot="score-pillar-row"]')).toHaveCount( - 3, + '[data-slot="score-pillar-row"][data-domain="cardiometabolic"]', ); + await expect(cardio).toHaveCount(3); // The rows say nothing about recording or showing — the card says it // once, above them, and the modules screen owns those two questions. diff --git a/messages/de.json b/messages/de.json index 22e52f6b7..a925e5193 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5207,13 +5207,6 @@ "axis": { "countsFor": "{pillar} in den Score einrechnen" }, - "domain": { - "cardiometabolic": "Herz und Stoffwechsel", - "activity": "Aktivität", - "sleep": "Schlaf", - "adiposity": "Körperform", - "wellbeing": "Wohlbefinden" - }, "state": { "counting": "Zählt in deinen Score", "waiting": "Ausgewählt, wartet auf Daten", @@ -9764,9 +9757,11 @@ "accessReadLabel": "Darf lesen", "accessReadCapability": "Sie kann alles lesen, was von dieser Akte geteilt wird. Hinzufügen oder ändern kann sie nichts.", "accessWriteLabel": "Darf lesen und hinzufügen", - "accessWriteCapability": "Sie kann außerdem etwas zur Akte hinzufügen: Messwerte, Laborergebnisse, einen neuen Laborwert, einen Krankheitseintrag, eine Nebenwirkung zu einem Medikament, ein neues Medikament und eine Dosis als genommen oder ausgelassen markieren. Ändern oder löschen kann sie nichts, auch nicht das, was sie selbst eingetragen hat, und eine deiner Erinnerungen verschieben kann sie auch nicht. Das kannst nur du.", + "accessWriteCapability": "Sie kann Einträge hinzufügen, aber nichts ändern oder löschen, auch nicht das, was sie selbst eingetragen hat.", + "accessWriteDetail": "Sie kann Messwerte, Laborergebnisse, einen neuen Laborwert, einen Krankheitseintrag, eine Nebenwirkung zu einem Medikament und ein neues Medikament hinzufügen sowie eine Dosis als genommen oder ausgelassen markieren. Ändern oder löschen kann sie nichts, auch nicht das Eigene, und eine deiner Erinnerungen verschieben kann sie nicht. Das kannst nur du.", "accessManageLabel": "Darf verwalten", - "accessManageCapability": "Sie kann Einträge in deiner Akte anlegen, ändern und löschen, auch solche, die du selbst erfasst hast, und sie kann die daraus erzeugten Auswertungen lesen. Deine Anmeldung, deine Sicherheitseinstellungen, deine Verbindungen und den Zugriff anderer Personen kann sie nicht ändern. Du kannst das jederzeit beenden.", + "accessManageCapability": "Sie kann Einträge anlegen, ändern und löschen, auch solche, die du selbst erfasst hast.", + "accessManageDetail": "Sie kann außerdem die daraus erzeugten Auswertungen lesen. Deine Anmeldung, deine Sicherheitseinstellungen, deine Verbindungen und den Zugriff anderer Personen kann sie nicht ändern. Du kannst das jederzeit beenden.", "manageStepUpNote": "Dafür wird dein zweiter Faktor abgefragt, deshalb geht das nur im Browser.", "scopeLegend": "Was die Person öffnen darf", "scopeAllLabel": "Gesamte Akte", @@ -9778,6 +9773,7 @@ "scopeAggregateNote": "Die Übersicht auf dem Dashboard, der Health Score und die Tageszusammenfassung erscheinen nur, wenn die gesamte Akte geteilt ist.", "scopeDocumentsNote": "Ein Dokument kann alles enthalten. Teile diesen Bereich nur, wenn die Person alles davon lesen darf.", "scopeEmptyHint": "Wähle mindestens einen Bereich aus oder teile wieder die gesamte Akte.", + "scopeChooseHint": "Wähle, ob die gesamte Akte oder nur einzelne Bereiche geteilt werden.", "expiresLabel": "Zugriff endet am (optional)", "expiresHint": "Leer lassen für Zugriff, der gilt, bis ihn jemand beendet. Ein Datum wird bei jeder Anfrage geprüft, der Zugriff endet also am Ende dieses Tages von selbst.", "submit": "Einladung senden", diff --git a/messages/en.json b/messages/en.json index 8998e4fad..12f18a262 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5207,13 +5207,6 @@ "axis": { "countsFor": "Count {pillar} toward the score" }, - "domain": { - "cardiometabolic": "Heart and metabolism", - "activity": "Activity", - "sleep": "Sleep", - "adiposity": "Body shape", - "wellbeing": "Wellbeing" - }, "state": { "counting": "Counting toward your score", "waiting": "Selected, waiting for data", @@ -9764,9 +9757,11 @@ "accessReadLabel": "Can view", "accessReadCapability": "They can read everything this record shares. They cannot add or change anything.", "accessWriteLabel": "Can view and add", - "accessWriteCapability": "They can also add to the record: readings, lab results, a new analyte to track, an illness entry, a side effect against a drug, a new medication, and marking a dose taken or skipped. They cannot edit or delete anything, not even what they added, and they cannot defer one of your reminders. Only you can.", + "accessWriteCapability": "They can add to the record but cannot edit or delete anything, not even what they added.", + "accessWriteDetail": "They can add readings, lab results, a new analyte to track, an illness entry, a side effect against a drug, a new medication, and mark a dose taken or skipped. They cannot edit or delete anything, not even what they added, and they cannot defer one of your reminders. Only you can.", "accessManageLabel": "Can manage", - "accessManageCapability": "They can add, change and remove entries in your record, including ones you entered yourself, and they can read the insights generated from it. They cannot change your login, your security settings, your connections, or who else has access. You can end this at any time.", + "accessManageCapability": "They can add, change and remove entries, including ones you entered yourself.", + "accessManageDetail": "They can also read the insights generated from your record. They cannot change your login, your security settings, your connections, or who else has access. You can end this at any time.", "manageStepUpNote": "Offering this asks for your second factor, so it can only be done in a browser.", "scopeLegend": "What they can open", "scopeAllLabel": "Entire record", @@ -9778,6 +9773,7 @@ "scopeAggregateNote": "The dashboard overview, health score and daily digest only appear when the entire record is shared.", "scopeDocumentsNote": "A document can contain anything. Share this only if they may read all of it.", "scopeEmptyHint": "Pick at least one section, or go back to sharing the entire record.", + "scopeChooseHint": "Choose whether to share the entire record or only selected sections.", "expiresLabel": "Access ends on (optional)", "expiresHint": "Leave this empty for access that lasts until somebody ends it. A date here is checked on every request, so access stops on its own at the end of that day.", "submit": "Send invitation", diff --git a/messages/es.json b/messages/es.json index f8b57316b..3ec016e1c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5207,13 +5207,6 @@ "axis": { "countsFor": "Contar {pillar} para la puntuación" }, - "domain": { - "cardiometabolic": "Corazón y metabolismo", - "activity": "Actividad", - "sleep": "Sueño", - "adiposity": "Forma corporal", - "wellbeing": "Bienestar" - }, "state": { "counting": "Cuenta para tu puntuación", "waiting": "Seleccionado, esperando datos", @@ -9764,9 +9757,11 @@ "accessReadLabel": "Puede ver", "accessReadCapability": "Puede leer todo lo que este historial comparte. No puede añadir ni cambiar nada.", "accessWriteLabel": "Puede ver y añadir", - "accessWriteCapability": "También puede añadir a la ficha: mediciones, resultados de laboratorio, un nuevo analito a seguir, una entrada de enfermedad, un efecto secundario de un medicamento, un medicamento nuevo, y marcar una dosis como tomada u omitida. No puede editar ni borrar nada, ni siquiera lo que añadió, ni aplazar uno de tus recordatorios. Solo tú puedes.", + "accessWriteCapability": "Puede añadir a la ficha, pero no puede editar ni borrar nada, ni siquiera lo que añadió.", + "accessWriteDetail": "Puede añadir mediciones, resultados de laboratorio, un nuevo analito a seguir, una entrada de enfermedad, un efecto secundario de un medicamento y un medicamento nuevo, y marcar una dosis como tomada u omitida. No puede editar ni borrar nada, ni siquiera lo que añadió, ni aplazar uno de tus recordatorios. Solo tú puedes.", "accessManageLabel": "Puede gestionar", - "accessManageCapability": "Puede añadir, cambiar y eliminar entradas de tu ficha, incluidas las que has escrito tú, y puede leer los análisis generados a partir de ella. No puede cambiar tu inicio de sesión, tus ajustes de seguridad, tus servicios conectados ni quién más tiene acceso. Puedes terminarlo cuando quieras.", + "accessManageCapability": "Puede añadir, cambiar y eliminar entradas, incluidas las que has escrito tú.", + "accessManageDetail": "También puede leer los análisis generados a partir de tu ficha. No puede cambiar tu inicio de sesión, tus ajustes de seguridad, tus servicios conectados ni quién más tiene acceso. Puedes terminarlo cuando quieras.", "manageStepUpNote": "Ofrecerlo pide tu segundo factor, así que solo se puede hacer en un navegador.", "scopeLegend": "Qué puede abrir", "scopeAllLabel": "Todo el historial", @@ -9778,6 +9773,7 @@ "scopeAggregateNote": "El resumen del panel, la puntuación de salud y el resumen diario solo aparecen cuando se comparte todo el historial.", "scopeDocumentsNote": "Un documento puede contener cualquier cosa. Comparte esta sección solo si esa persona puede leerlo todo.", "scopeEmptyHint": "Elige al menos una sección o vuelve a compartir todo el historial.", + "scopeChooseHint": "Elige si compartir todo el historial o solo algunas secciones.", "expiresLabel": "El acceso termina el (opcional)", "expiresHint": "Déjalo vacío para un acceso que dure hasta que alguien lo termine. Una fecha aquí se comprueba en cada petición, así que el acceso se detiene solo al final de ese día.", "submit": "Enviar invitación", diff --git a/messages/fr.json b/messages/fr.json index 7daddbd8f..0a07a45b3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5207,13 +5207,6 @@ "axis": { "countsFor": "Compter {pillar} dans le score" }, - "domain": { - "cardiometabolic": "Cœur et métabolisme", - "activity": "Activité", - "sleep": "Sommeil", - "adiposity": "Silhouette", - "wellbeing": "Bien-être" - }, "state": { "counting": "Compte dans votre score", "waiting": "Sélectionné, en attente de données", @@ -9764,9 +9757,11 @@ "accessReadLabel": "Peut consulter", "accessReadCapability": "Elle peut lire tout ce que ce dossier partage. Elle ne peut rien ajouter ni rien modifier.", "accessWriteLabel": "Peut consulter et ajouter", - "accessWriteCapability": "Elle peut aussi ajouter au dossier : des mesures, des résultats de laboratoire, un nouvel analyte à suivre, une entrée de maladie, un effet indésirable lié à un médicament, un nouveau médicament, et marquer une dose comme prise ou sautée. Elle ne peut rien modifier ni supprimer, pas même ce qu'elle a ajouté, et elle ne peut pas reporter un de vos rappels. Vous seul le pouvez.", + "accessWriteCapability": "Elle peut ajouter au dossier, mais ne peut rien modifier ni supprimer, pas même ce qu'elle a ajouté.", + "accessWriteDetail": "Elle peut ajouter des mesures, des résultats de laboratoire, un nouvel analyte à suivre, une entrée de maladie, un effet indésirable lié à un médicament et un nouveau médicament, et marquer une dose comme prise ou sautée. Elle ne peut rien modifier ni supprimer, pas même ce qu'elle a ajouté, et elle ne peut pas reporter un de vos rappels. Vous seul le pouvez.", "accessManageLabel": "Peut gérer", - "accessManageCapability": "Elle peut ajouter, modifier et supprimer des entrées de votre dossier, y compris celles que vous avez saisies vous-même, et lire les analyses qui en sont tirées. Elle ne peut modifier ni votre connexion, ni vos paramètres de sécurité, ni vos services connectés, ni la liste des personnes qui ont accès. Vous pouvez y mettre fin à tout moment.", + "accessManageCapability": "Elle peut ajouter, modifier et supprimer des entrées, y compris celles que vous avez saisies vous-même.", + "accessManageDetail": "Elle peut aussi lire les analyses tirées de votre dossier. Elle ne peut modifier ni votre connexion, ni vos paramètres de sécurité, ni vos services connectés, ni la liste des personnes qui ont accès. Vous pouvez y mettre fin à tout moment.", "manageStepUpNote": "Cette offre demande votre deuxième facteur, elle n'est donc possible que dans un navigateur.", "scopeLegend": "Ce que cette personne peut ouvrir", "scopeAllLabel": "Dossier complet", @@ -9778,6 +9773,7 @@ "scopeAggregateNote": "La vue d'ensemble du tableau de bord, le score de santé et le résumé quotidien n'apparaissent que si le dossier entier est partagé.", "scopeDocumentsNote": "Un document peut contenir n'importe quoi. Ne partagez cette rubrique que si la personne peut tout lire.", "scopeEmptyHint": "Choisissez au moins une rubrique, ou revenez au partage du dossier complet.", + "scopeChooseHint": "Choisissez de partager le dossier complet ou seulement certaines rubriques.", "expiresLabel": "L'accès prend fin le (facultatif)", "expiresHint": "Laissez ce champ vide pour un accès qui dure jusqu'à ce que quelqu'un y mette fin. Une date ici est vérifiée à chaque requête, l'accès s'arrête donc de lui-même à la fin de cette journée.", "submit": "Envoyer l'invitation", diff --git a/messages/it.json b/messages/it.json index 471e33147..a820bcf1e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5207,13 +5207,6 @@ "axis": { "countsFor": "Conta {pillar} nel punteggio" }, - "domain": { - "cardiometabolic": "Cuore e metabolismo", - "activity": "Attività", - "sleep": "Sonno", - "adiposity": "Forma corporea", - "wellbeing": "Benessere" - }, "state": { "counting": "Conta nel tuo punteggio", "waiting": "Selezionato, in attesa di dati", @@ -9764,9 +9757,11 @@ "accessReadLabel": "Può vedere", "accessReadCapability": "Può leggere tutto ciò che questa cartella condivide. Non può aggiungere né modificare nulla.", "accessWriteLabel": "Può vedere e aggiungere", - "accessWriteCapability": "Può anche aggiungere alla cartella: misurazioni, risultati di laboratorio, un nuovo analita da seguire, una voce di malattia, un effetto collaterale di un farmaco, un nuovo farmaco e segnare una dose come presa o saltata. Non può modificare né eliminare nulla, nemmeno ciò che ha aggiunto, e non può rimandare uno dei tuoi promemoria. Solo tu puoi.", + "accessWriteCapability": "Può aggiungere alla cartella, ma non può modificare né eliminare nulla, nemmeno ciò che ha aggiunto.", + "accessWriteDetail": "Può aggiungere misurazioni, risultati di laboratorio, un nuovo analita da seguire, una voce di malattia, un effetto collaterale di un farmaco e un nuovo farmaco, e segnare una dose come presa o saltata. Non può modificare né eliminare nulla, nemmeno ciò che ha aggiunto, e non può rimandare uno dei tuoi promemoria. Solo tu puoi.", "accessManageLabel": "Può gestire", - "accessManageCapability": "Può aggiungere, modificare ed eliminare voci della tua cartella, comprese quelle inserite da te, e può leggere le analisi generate a partire da essa. Non può cambiare il tuo accesso, le tue impostazioni di sicurezza, i tuoi servizi collegati né chi altro ha accesso. Puoi terminare tutto questo in qualsiasi momento.", + "accessManageCapability": "Può aggiungere, modificare ed eliminare voci, comprese quelle inserite da te.", + "accessManageDetail": "Può anche leggere le analisi generate dalla tua cartella. Non può cambiare il tuo accesso, le tue impostazioni di sicurezza, i tuoi servizi collegati né chi altro ha accesso. Puoi terminare tutto questo in qualsiasi momento.", "manageStepUpNote": "Per offrirlo viene richiesto il tuo secondo fattore, quindi si può fare solo da browser.", "scopeLegend": "Che cosa può aprire", "scopeAllLabel": "Intera cartella", @@ -9778,6 +9773,7 @@ "scopeAggregateNote": "La panoramica della dashboard, il punteggio di salute e il riepilogo giornaliero compaiono solo se viene condivisa l'intera cartella.", "scopeDocumentsNote": "Un documento può contenere qualsiasi cosa. Condividi questa sezione solo se quella persona può leggere tutto.", "scopeEmptyHint": "Scegli almeno una sezione oppure torna a condividere l'intera cartella.", + "scopeChooseHint": "Scegli se condividere l'intera cartella o solo alcune sezioni.", "expiresLabel": "L'accesso termina il (facoltativo)", "expiresHint": "Lascia vuoto per un accesso che dura finché qualcuno non lo termina. Una data qui viene controllata a ogni richiesta, quindi l'accesso si interrompe da solo alla fine di quel giorno.", "submit": "Invia invito", diff --git a/messages/pl.json b/messages/pl.json index b4245f672..724882a79 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5207,13 +5207,6 @@ "axis": { "countsFor": "Licz {pillar} do wyniku" }, - "domain": { - "cardiometabolic": "Serce i metabolizm", - "activity": "Aktywność", - "sleep": "Sen", - "adiposity": "Sylwetka", - "wellbeing": "Samopoczucie" - }, "state": { "counting": "Liczy się do Twojego wyniku", "waiting": "Wybrany, czeka na dane", @@ -9764,9 +9757,11 @@ "accessReadLabel": "Może przeglądać", "accessReadCapability": "Może czytać wszystko, co ta karta udostępnia. Nie może niczego dodać ani zmienić.", "accessWriteLabel": "Może przeglądać i dodawać", - "accessWriteCapability": "Może też dodawać do kartoteki: pomiary, wyniki badań, nowy analit do śledzenia, wpis o chorobie, działanie niepożądane leku, nowy lek oraz oznaczyć dawkę jako przyjętą lub pominiętą. Nie może niczego edytować ani usuwać, nawet tego, co sama dodała, ani odłożyć żadnego z Twoich przypomnień. Tylko Ty możesz.", + "accessWriteCapability": "Może dodawać do kartoteki, ale nie może niczego edytować ani usuwać, nawet tego, co sama dodała.", + "accessWriteDetail": "Może dodawać pomiary, wyniki badań, nowy analit do śledzenia, wpis o chorobie, działanie niepożądane leku i nowy lek oraz oznaczyć dawkę jako przyjętą lub pominiętą. Nie może niczego edytować ani usuwać, nawet tego, co sama dodała, ani odłożyć żadnego z Twoich przypomnień. Tylko Ty możesz.", "accessManageLabel": "Może zarządzać", - "accessManageCapability": "Może dodawać, zmieniać i usuwać wpisy w Twojej kartotece, także te, które wprowadziłeś samodzielnie, oraz czytać wygenerowane na jej podstawie analizy. Nie może zmienić Twojego logowania, ustawień bezpieczeństwa, połączonych usług ani tego, kto jeszcze ma dostęp. Możesz to zakończyć w dowolnym momencie.", + "accessManageCapability": "Może dodawać, zmieniać i usuwać wpisy, także te, które wprowadziłeś samodzielnie.", + "accessManageDetail": "Może też czytać analizy wygenerowane na podstawie Twojej kartoteki. Nie może zmienić Twojego logowania, ustawień bezpieczeństwa, połączonych usług ani tego, kto jeszcze ma dostęp. Możesz to zakończyć w dowolnym momencie.", "manageStepUpNote": "Przy takim zaproszeniu prosimy o drugi składnik logowania, dlatego można to zrobić tylko w przeglądarce.", "scopeLegend": "Co ta osoba może otworzyć", "scopeAllLabel": "Cała kartoteka", @@ -9778,6 +9773,7 @@ "scopeAggregateNote": "Przegląd na pulpicie, wynik zdrowia i dzienne podsumowanie pojawiają się tylko wtedy, gdy udostępniona jest cała kartoteka.", "scopeDocumentsNote": "Dokument może zawierać wszystko. Udostępnij tę sekcję tylko wtedy, gdy ta osoba może przeczytać całość.", "scopeEmptyHint": "Wybierz co najmniej jedną sekcję albo wróć do udostępniania całej kartoteki.", + "scopeChooseHint": "Wybierz, czy udostępnić całą kartotekę, czy tylko wybrane sekcje.", "expiresLabel": "Dostęp kończy się (opcjonalnie)", "expiresHint": "Zostaw puste, aby dostęp trwał, dopóki ktoś go nie zakończy. Data jest sprawdzana przy każdym żądaniu, więc dostęp kończy się sam z końcem tego dnia.", "submit": "Wyślij zaproszenie", diff --git a/src/__tests__/sharing-recoverable-errors.test.tsx b/src/__tests__/sharing-recoverable-errors.test.tsx index 43da41195..05e6618cf 100644 --- a/src/__tests__/sharing-recoverable-errors.test.tsx +++ b/src/__tests__/sharing-recoverable-errors.test.tsx @@ -353,8 +353,9 @@ describe("a refused invitation keeps what was typed", () => { const split = INVITE_CARD.indexOf("onError:", start); // The options object ends at the first ` },` after the error arm, so // the slice stops at the mutation rather than running into the JSX below, - // where a `setNarrowed(false)` on a radio would satisfy the matcher for - // the wrong reason. + // where a `setNarrowed(false)` / `setNarrowed(true)` on a radio would + // satisfy the matcher for the wrong reason. The success reset returns the + // scope to unset (`null`), which the JSX never writes. const end = INVITE_CARD.indexOf("\n },", split); const onSuccess = INVITE_CARD.slice(start, split); const onError = INVITE_CARD.slice(split, end); @@ -367,7 +368,7 @@ describe("a refused invitation keeps what was typed", () => { 'setIdentifier("")', 'setExpiresOn("")', 'setAccess("READ")', - "setNarrowed(false)", + "setNarrowed(null)", "setSections([])", ]) { expect(onSuccess, reset).toContain(reset); diff --git a/src/app/api/account/grants/__tests__/invite-access-level.test.ts b/src/app/api/account/grants/__tests__/invite-access-level.test.ts index 2507d194e..3798cc52f 100644 --- a/src/app/api/account/grants/__tests__/invite-access-level.test.ts +++ b/src/app/api/account/grants/__tests__/invite-access-level.test.ts @@ -38,7 +38,12 @@ vi.mock("@/lib/api-handler", async () => { vi.mock("@/lib/db", () => ({ prisma: { user: { findFirst: vi.fn() }, - accountGrant: { create: vi.fn() }, + accountGrant: { + create: vi.fn(), + // inviteGrant sweeps expired live-pair rows before the create; nothing to + // close in these fixtures. + updateMany: vi.fn().mockResolvedValue({ count: 0 }), + }, }, })); diff --git a/src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts b/src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts index 1c290d0a5..265283687 100644 --- a/src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts +++ b/src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts @@ -43,7 +43,12 @@ vi.mock("@/lib/api-handler", async () => { vi.mock("@/lib/db", () => ({ prisma: { user: { findFirst: vi.fn() }, - accountGrant: { create: vi.fn() }, + accountGrant: { + create: vi.fn(), + // inviteGrant sweeps expired live-pair rows before the create. No such + // row exists in these unit fixtures, so it closes nothing. + updateMany: vi.fn().mockResolvedValue({ count: 0 }), + }, }, })); diff --git a/src/components/settings/__tests__/password-card-placement.test.tsx b/src/components/settings/__tests__/password-card-placement.test.tsx index ec428f6f3..de120af1e 100644 --- a/src/components/settings/__tests__/password-card-placement.test.tsx +++ b/src/components/settings/__tests__/password-card-placement.test.tsx @@ -72,14 +72,30 @@ describe("change-password card placement", () => { expect(render()).toContain(SLOT); }); - it("is mounted by Settings → Security before the factor status lands", () => { + it("is mounted by Settings → Security even before the factor status lands", () => { // The mocked query never resolves, which is the case that matters: the // MFA read is still in flight (or failing) and the password card is - // reachable regardless. + // reachable regardless — it sits outside the status conditional. const html = render(); expect(html).toContain(SLOT); }); + it("orders the password card last, after the passkey list", () => { + // The section reads authenticator → security keys → passkeys → password. + // The order lives in one component and the resolved cards only render once + // the status query settles (the render mock keeps it in flight), so the + // guard reads the source: the password card must come after the passkey + // list. Re-reported once as an order regression; this pins it. + const source = readFileSync( + join(ROOT, "src/components/settings/security-section/index.tsx"), + "utf8", + ); + const passkeyIdx = source.indexOf(" { const owners = walk(join(ROOT, "src")) .filter((file) => readFileSync(file, "utf8").includes("PasswordCard")) diff --git a/src/components/settings/access/__tests__/grant-access-level.test.tsx b/src/components/settings/access/__tests__/grant-access-level.test.tsx index d8ee6185a..b1c4f27f0 100644 --- a/src/components/settings/access/__tests__/grant-access-level.test.tsx +++ b/src/components/settings/access/__tests__/grant-access-level.test.tsx @@ -16,11 +16,20 @@ * a rewrite that quietly drops it should fail here. */ import { renderToStaticMarkup } from "react-dom/server"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { I18nProvider } from "@/lib/i18n/context"; import type { GrantList, GrantRow } from "@/lib/queries/use-account-grants"; +/** The shipped English invite copy, the source of truth for the consent text. */ +const inviteBundle = ( + JSON.parse(readFileSync(join(process.cwd(), "messages/en.json"), "utf8")) as { + recordSharing: { invite: Record }; + } +).recordSharing.invite; + const grantsRef: { value: GrantList } = { value: grantList() }; /** * A grant payload, with the audit window every real one carries. @@ -110,8 +119,11 @@ describe("offering a level", () => { // second group of radios in the same idiom, and a group that lost the ring // would be the same defect shipped again one section further down. const html = render(); + // Access options carry an (i) sibling now, so the row is a `
` with the + // radio in a nested `
{choice.mode === "manage" && ( @@ -224,7 +229,7 @@ export function GrantInviteCard() {
{ setNarrowed(false); setError(null); @@ -234,7 +239,7 @@ export function GrantInviteCard() { /> { setNarrowed(true); setError(null); @@ -243,6 +248,16 @@ export function GrantInviteCard() { capability={t("recordSharing.invite.scopeSomeCapability")} />
+ {choice.mode === "unset" && ( + // Nothing chosen: say what the two options decide, rather than + // sending a request or silently defaulting to the whole record. +

+ {t("recordSharing.invite.scopeChooseHint")} +

+ )} {choice.mode === "pick" && (
sections.includes(domain)); // A narrowed invitation with nothing ticked is not a narrow grant, it is a @@ -414,13 +434,17 @@ export function resolveInviteScope({ } /** - * One level, with the sentence that says what it actually does. + * One level, with the one sentence that says what it actually does. * - * The capability line is not decoration and it is not a tooltip: it is the - * part the owner is consenting to, so it sits under the label where it has to - * be read past rather than behind an info icon. Muted because it describes the - * option rather than being the option (UI-STANDARDS §3), `text-xs` because - * that is the meta floor. + * The capability line is the part the owner is consenting to, so it stays + * inline under the label rather than behind an icon — but it is one line, not a + * wall. Where a level has more to say than fits on a line (the exact list WRITE + * may add, the fence MANAGE does NOT reach), that detail rides an `InfoPopover` + * at the option-heading height. The (i) sits OUTSIDE the ` + {detail ? ( + + ) : null} +
); } diff --git a/src/components/settings/score-pillar-row.tsx b/src/components/settings/score-pillar-row.tsx index 99853a15c..8e8f8dfaf 100644 --- a/src/components/settings/score-pillar-row.tsx +++ b/src/components/settings/score-pillar-row.tsx @@ -60,7 +60,7 @@ export function ScorePillarRow({ data-domain={row.domain} data-counts={row.counts ? "true" : "false"} data-eligibility={row.eligibility} - className="flex min-h-11 items-center justify-between gap-4" + className="flex min-h-11 items-center justify-between gap-4 py-3" >
) : ( -
- {groups.map((group) => ( -
- {/* Names an area of health, so it sits in the content - tier. The muted section-label treatment it shipped - with read as switched-off beside the rows it - heads. */} -

- {t(SCORE_DOMAIN_LABEL_KEYS[group.domain])} -

- {/* No rules between the rows: the gap between groups - is the grouping. */} -
- {group.rows.map((row) => ( - toggle(row.id, next)} - /> - ))} -
-
- ))} + // One flat list of topics separated by hairline dividers, the + // way Anamnese lists its facts. The group headings are gone; the + // registry order (domain, then pillar) is preserved by flattening + // the groups in order, and each row keeps its `data-domain` for + // tests and e2e. +
+ {groups.flatMap((group) => + group.rows.map((row) => ( + toggle(row.id, next)} + /> + )), + )}
)} diff --git a/src/components/settings/security-section/index.tsx b/src/components/settings/security-section/index.tsx index 4b439c67b..757b67717 100644 --- a/src/components/settings/security-section/index.tsx +++ b/src/components/settings/security-section/index.tsx @@ -50,13 +50,6 @@ export function SecuritySection() {
{showNudge && } - {/* The password leads, and it renders before the factor status has - landed — it depends on nothing this page fetches. It is also the - only way to change a password now that the Account card is gone, so - a `/api/auth/me/mfa` that never answers must not take it down with - the cards that do need it. */} - - {isLoading || !status ? ( <> @@ -75,6 +68,14 @@ export function SecuritySection() { )} + + {/* The password comes last: authenticator, then security keys, then + passkeys, then the password. It sits OUTSIDE the status conditional + on purpose — it depends on nothing this page fetches and is the only + way to change a password now that the Account card is gone, so a + `/api/auth/me/mfa` that never answers must not take it down with the + cards that do need the status. */} +
); } diff --git a/src/components/ui/__tests__/date-field.test.tsx b/src/components/ui/__tests__/date-field.test.tsx index 53a429e6b..e93dd48e8 100644 --- a/src/components/ui/__tests__/date-field.test.tsx +++ b/src/components/ui/__tests__/date-field.test.tsx @@ -97,4 +97,15 @@ describe("", () => { const html = render(); expect(html).toContain('aria-label="Open date picker"'); }); + + it("uses the Input focus vocabulary — a ring that hugs the box, no offset", () => { + // Drift guard: the field must share the app's focus ring (soft 3 px, no + // offset) so the highlight never paints wider than the div or jumps on + // focus. The old hard `ring-offset-2` ring is banned here. + const html = render(); + expect(html).toContain("focus-within:ring-ring/50"); + expect(html).toContain("focus-within:ring-[3px]"); + expect(html).not.toContain("ring-offset-2"); + expect(html).not.toContain("ring-offset-background"); + }); }); diff --git a/src/components/ui/__tests__/responsive-sheet.test.tsx b/src/components/ui/__tests__/responsive-sheet.test.tsx index 76dc1d77a..7ba0c66c2 100644 --- a/src/components/ui/__tests__/responsive-sheet.test.tsx +++ b/src/components/ui/__tests__/responsive-sheet.test.tsx @@ -128,6 +128,20 @@ describe("", () => { expect(html).not.toContain('data-slot="mock-sheet"'); }); + it("reclaims a scroll gutter on the Dialog body so the scrollbar clears right-aligned labels", () => { + mobile = false; + const html = renderToStaticMarkup( + {}} title="Add mood"> +

body

+
, + ); + // The body carries `-mr-3 pr-3`: a 12 px gutter reclaimed from the dialog's + // own padding so the vertical scrollbar lives beside the text, not on the + // last characters of a right-aligned slider anchor. + expect(html).toContain("-mr-3"); + expect(html).toContain("pr-3"); + }); + it("renders the bottom Sheet branch on narrow viewports", () => { mobile = true; const html = renderToStaticMarkup( diff --git a/src/components/ui/__tests__/slider.test.tsx b/src/components/ui/__tests__/slider.test.tsx index 1908fc897..2c218e5aa 100644 --- a/src/components/ui/__tests__/slider.test.tsx +++ b/src/components/ui/__tests__/slider.test.tsx @@ -105,7 +105,11 @@ describe("", () => { it("offers to clear an answer only when there is one to clear", () => { const set = field({ onClear: () => {}, clearLabel: "zurücksetzen" }); expect(set).toContain('data-slot="slider-field-clear"'); - expect(set).toContain("zurücksetzen"); + // The clear affordance is icon-only: the label rides the accessible name, + // not visible text beside the value, so a set slider never reads "4 leeren". + expect(set).toContain('aria-label="zurücksetzen"'); + // The value span holds only the bare number, never the clear-action label. + expect(set).toMatch(/data-slot="slider-field-value"[^>]*>7<\/span>/); const unanswered = field({ value: null, diff --git a/src/components/ui/__tests__/time-field.test.tsx b/src/components/ui/__tests__/time-field.test.tsx index f50b93c80..fb031e85d 100644 --- a/src/components/ui/__tests__/time-field.test.tsx +++ b/src/components/ui/__tests__/time-field.test.tsx @@ -120,4 +120,13 @@ describe(" SSR", () => { expect(html).toContain("min-h-11"); expect(html).toContain("sm:h-10"); }); + it("uses the Input focus vocabulary — a ring that hugs the box, no offset", () => { + // Drift guard shared with : soft 3 px ring, no offset, so the + // highlight never overflows the div or jumps on focus. + const html = render(, "de"); + expect(html).toContain("focus-within:ring-ring/50"); + expect(html).toContain("focus-within:ring-[3px]"); + expect(html).not.toContain("ring-offset-2"); + expect(html).not.toContain("ring-offset-background"); + }); }); diff --git a/src/components/ui/date-field.tsx b/src/components/ui/date-field.tsx index 9eaa01f59..0d9edae3d 100644 --- a/src/components/ui/date-field.tsx +++ b/src/components/ui/date-field.tsx @@ -56,8 +56,15 @@ const FIELD_HEIGHT_CLASSES = "min-h-11 h-11 sm:min-h-10 sm:h-10"; // ~230 px no matter what width the parent hands it, and the surplus spills out // of whatever dialog or sheet it sits in. See the matching note on the overlay // input below; both ends are needed, one alone still floors the box. +// The focus vocabulary matches `` exactly: a soft translucent 3 px ring +// that hugs the border box with no offset. The earlier `ring-2 ring-offset-2` +// painted a hard 4 px ring OUTSIDE the box — wider than the field's div and +// visibly mismatched next to the app's other inputs, and it shifted the +// highlight box on focus. `text-base md:text-sm` mirrors `` too: a +// sub-16 px input triggers iOS Safari's focus auto-zoom, which reads as a +// layout jump; 16 px below `md:` suppresses it. const FIELD_BASE_CLASSES = - "border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-within:ring-ring relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-2 focus-within:ring-offset-2 focus-within:outline-none"; + "border-input bg-background text-foreground placeholder:text-muted-foreground relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-base md:text-sm shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px] focus-within:outline-none"; export interface DateFieldProps { id?: string; diff --git a/src/components/ui/responsive-sheet.tsx b/src/components/ui/responsive-sheet.tsx index 9cb7a24a7..b9b77ca48 100644 --- a/src/components/ui/responsive-sheet.tsx +++ b/src/components/ui/responsive-sheet.tsx @@ -320,7 +320,16 @@ export function ResponsiveSheet({ // Content that genuinely needs to scroll sideways (a wide table) // owns its own `overflow-x-auto` wrapper — that is the correct // place for it, and it still works inside a clipped port. - "flex min-h-0 flex-1 flex-col gap-4 overflow-x-clip overflow-y-auto", + // + // `-mr-3 pr-3` reclaims a 12 px scroll gutter from the dialog's own + // `p-6` padding: the vertical scrollbar would otherwise render flush + // against the body's right edge, exactly where a right-aligned child + // (a slider's high anchor label) sits, painting on top of the last + // characters. The margin gives the padding straight back, so content + // alignment is unchanged — the scrollbar just lives beside the text + // now, not on it. The mobile Sheet branch has its own `p-4` and + // needs nothing. + "-mr-3 flex min-h-0 flex-1 flex-col gap-4 overflow-x-clip overflow-y-auto pr-3", bodyClassName, )} > diff --git a/src/components/ui/slider.tsx b/src/components/ui/slider.tsx index 53ed01642..7ee57446a 100644 --- a/src/components/ui/slider.tsx +++ b/src/components/ui/slider.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { Slider as SliderPrimitive } from "radix-ui"; +import { X } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -136,9 +137,11 @@ function SliderField({ type="button" onClick={onClear} data-slot="slider-field-clear" - className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 rounded text-xs underline-offset-2 outline-none hover:underline focus-visible:ring-[3px]" + aria-label={clearLabel} + className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 flex size-6 items-center justify-center rounded outline-none focus-visible:ring-[3px]" > - {clearLabel} +