diff --git a/e2e/coach-answer-layout.spec.ts b/e2e/coach-answer-layout.spec.ts
index fe13504f9..060ab06b2 100644
--- a/e2e/coach-answer-layout.spec.ts
+++ b/e2e/coach-answer-layout.spec.ts
@@ -18,9 +18,11 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
* are not text on the row; the details icon shows them in a tooltip on
* keyboard focus (desktop) and on a tap (phone); Enter and Space keep it
* open, and a second tap or Escape closes it.
- * 2. The follow-up chips sit in the answer's column: their left edge is
- * the bubble's, their text is the answer's size, and a chip is at most
- * 36 px tall beside a pointer and at least 44 px on a phone.
+ * 2. The follow-up chips sit in the answer's column, right under the
+ * answer and above the action row (the row is the last thing in the
+ * column): their left edge is the bubble's, their text is the
+ * answer's size, and a chip is at most 36 px tall beside a pointer and
+ * at least 44 px on a phone.
* 3. Under a question, copy and remember share one row; nothing stacks
* below it.
* 4. Scrolled away from the end, a round button appears; it brings the
@@ -311,7 +313,14 @@ test.describe("Coach answer layout", () => {
await expect(chips.locator("[data-follow-up-id]")).toHaveCount(2);
const chipsBox = await box(chips);
expect(Math.abs(chipsBox.x - bubbleBox.x)).toBeLessThanOrEqual(1);
- expect(chipsBox.y).toBeGreaterThanOrEqual(rowBox.y + rowBox.height - 1);
+ // No empty band between the answer and its replies: the column's own
+ // gap, whatever the hover state of the row below.
+ const chipsGap = chipsBox.y - (bubbleBox.y + bubbleBox.height);
+ expect(chipsGap, "the chips follow the answer").toBeGreaterThanOrEqual(0);
+ expect(chipsGap, "the chips follow the answer").toBeLessThanOrEqual(12);
+ expect(rowBox.y, "the action row comes last").toBeGreaterThanOrEqual(
+ chipsBox.y + chipsBox.height - 1,
+ );
const answerSize = await bubble.evaluate(
(el) => getComputedStyle(el).fontSize,
);
diff --git a/messages/de.json b/messages/de.json
index 18ab0b8cd..90baf90f3 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -3018,7 +3018,7 @@
"hideConversations": "Unterhaltungen ausblenden",
"heroGreeting": "Frage mich etwas zu deinen Daten",
"heroSubline": "Frag nach deinen Verläufen, Medikamenten oder Messwerten.",
- "thinkingElapsed": "{seconds}s",
+ "thinkingElapsed": "{seconds} s",
"thinkingDuration": "Nachgedacht für {seconds}s",
"thinkingExpandAria": "Zeigen, was der Coach berücksichtigt hat",
"thinkingDetail": "Beantwortet aus deinem Gesundheits-Snapshot.",
@@ -6076,7 +6076,6 @@
"categoryGoal": "Ziele",
"categoryConstraint": "Einschränkungen",
"categoryContext": "Kontext",
- "forget": "Vergessen",
"forgetAria": "Diese Angabe vergessen",
"forgetTitle": "Diese Notiz vergessen?",
"forgetBody": "Der Coach nutzt sie ab der nächsten Nachricht nicht mehr. Eine vergessene Notiz lässt sich nicht wiederherstellen.",
diff --git a/messages/en.json b/messages/en.json
index b762e5b47..cb3e738ea 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -3018,7 +3018,7 @@
"hideConversations": "Hide conversations",
"heroGreeting": "Ask me anything about your data",
"heroSubline": "Ask about your trends, medications, or readings.",
- "thinkingElapsed": "{seconds}s",
+ "thinkingElapsed": "{seconds} s",
"thinkingDuration": "Thought for {seconds}s",
"thinkingExpandAria": "Show what the Coach considered",
"thinkingDetail": "Answered from your health snapshot.",
@@ -6076,7 +6076,6 @@
"categoryGoal": "Goals",
"categoryConstraint": "Constraints",
"categoryContext": "Context",
- "forget": "Forget",
"forgetAria": "Forget this fact",
"forgetTitle": "Forget this fact?",
"forgetBody": "The Coach stops using it from the next message on. There is no way to bring a forgotten fact back.",
diff --git a/messages/es.json b/messages/es.json
index 6a25701af..531bcc740 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -3018,7 +3018,7 @@
"hideConversations": "Ocultar conversaciones",
"heroGreeting": "Pregúntame lo que quieras sobre tus datos",
"heroSubline": "Pregunta por tus tendencias, medicamentos o mediciones.",
- "thinkingElapsed": "{seconds}s",
+ "thinkingElapsed": "{seconds} s",
"thinkingDuration": "Pensó durante {seconds}s",
"thinkingExpandAria": "Mostrar lo que el Coach tuvo en cuenta",
"thinkingDetail": "Respondido a partir de tu resumen de salud.",
@@ -6076,7 +6076,6 @@
"categoryGoal": "Objetivos",
"categoryConstraint": "Restricciones",
"categoryContext": "Contexto",
- "forget": "Olvidar",
"forgetAria": "Olvidar este dato",
"forgetTitle": "¿Olvidar este dato?",
"forgetBody": "El Coach dejará de usarlo a partir del próximo mensaje. Un dato olvidado no se puede recuperar.",
diff --git a/messages/fr.json b/messages/fr.json
index 855305c28..e13156a46 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -3018,7 +3018,7 @@
"hideConversations": "Masquer les conversations",
"heroGreeting": "Pose-moi une question sur tes données",
"heroSubline": "Pose une question sur tes tendances, médicaments ou mesures.",
- "thinkingElapsed": "{seconds}s",
+ "thinkingElapsed": "{seconds} s",
"thinkingDuration": "A réfléchi pendant {seconds}s",
"thinkingExpandAria": "Montrer ce que le Coach a pris en compte",
"thinkingDetail": "Réponse établie à partir de ton aperçu santé.",
@@ -6076,7 +6076,6 @@
"categoryGoal": "Objectifs",
"categoryConstraint": "Contraintes",
"categoryContext": "Contexte",
- "forget": "Oublier",
"forgetAria": "Oublier cette information",
"forgetTitle": "Oublier ce fait ?",
"forgetBody": "Le Coach cesse de l’utiliser dès le message suivant. Un fait oublié ne peut pas être récupéré.",
diff --git a/messages/it.json b/messages/it.json
index b56cee77e..bda0a89a9 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -3018,7 +3018,7 @@
"hideConversations": "Nascondi conversazioni",
"heroGreeting": "Chiedimi qualcosa sui tuoi dati",
"heroSubline": "Chiedi dei tuoi andamenti, farmaci o misurazioni.",
- "thinkingElapsed": "{seconds}s",
+ "thinkingElapsed": "{seconds} s",
"thinkingDuration": "Ha riflettuto per {seconds}s",
"thinkingExpandAria": "Mostra cosa ha considerato il Coach",
"thinkingDetail": "Risposta basata sul tuo riepilogo di salute.",
@@ -6076,7 +6076,6 @@
"categoryGoal": "Obiettivi",
"categoryConstraint": "Vincoli",
"categoryContext": "Contesto",
- "forget": "Dimentica",
"forgetAria": "Dimentica questa informazione",
"forgetTitle": "Dimenticare questa informazione?",
"forgetBody": "Il Coach smette di usarla dal messaggio successivo. Un’informazione dimenticata non può essere recuperata.",
diff --git a/messages/ko.json b/messages/ko.json
index 44689fd9c..42368be2b 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -6076,7 +6076,6 @@
"categoryGoal": "목표",
"categoryConstraint": "제약",
"categoryContext": "맥락",
- "forget": "잊기",
"forgetAria": "이 정보 잊기",
"forgetTitle": "이 정보를 잊을까요?",
"forgetBody": "다음 메시지부터 코치가 이 정보를 쓰지 않아요. 한번 잊은 정보는 되돌릴 수 없어요.",
diff --git a/messages/pl.json b/messages/pl.json
index ffdf2715a..c26366b41 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -3018,7 +3018,7 @@
"hideConversations": "Ukryj rozmowy",
"heroGreeting": "Zapytaj mnie o cokolwiek na temat twoich danych",
"heroSubline": "Zapytaj o swoje trendy, leki lub pomiary.",
- "thinkingElapsed": "{seconds}s",
+ "thinkingElapsed": "{seconds} s",
"thinkingDuration": "Myślał przez {seconds}s",
"thinkingExpandAria": "Pokaż, co Coach wziął pod uwagę",
"thinkingDetail": "Odpowiedź na podstawie Twojego podsumowania zdrowia.",
@@ -6076,7 +6076,6 @@
"categoryGoal": "Cele",
"categoryConstraint": "Ograniczenia",
"categoryContext": "Kontekst",
- "forget": "Zapomnij",
"forgetAria": "Zapomnij tę informację",
"forgetTitle": "Zapomnieć tę informację?",
"forgetBody": "Coach przestanie z niej korzystać od następnej wiadomości. Zapomnianej informacji nie da się odzyskać.",
diff --git a/src/__tests__/coach-focus-ring-guard.test.ts b/src/__tests__/coach-focus-ring-guard.test.ts
new file mode 100644
index 000000000..e51241121
--- /dev/null
+++ b/src/__tests__/coach-focus-ring-guard.test.ts
@@ -0,0 +1,56 @@
+/**
+ * Structural guard on keyboard focus in the Coach panel.
+ *
+ * The purple `--ring` is gone from every Coach control: a tap draws nothing,
+ * keyboard focus draws a solid ring in the neutral text-field focus tone
+ * (`--input-focus`). Two checks:
+ *
+ * 1. No class string under `coach-panel/` names the purple ring, border or
+ * outline on focus. The sweep must find a floor of neutral focus rings,
+ * so a matcher that stops matching fails instead of passing on nothing.
+ * 2. The controls built on the `Button` primitive inside an answer (the
+ * reply pills, Undo, Change) pass `COACH_FOCUS_RING`, which replaces the
+ * primitive's purple ring when the classes merge.
+ */
+import { readdirSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+const DIR = join(__dirname, "..", "components", "insights", "coach-panel");
+
+const SOURCES = readdirSync(DIR)
+ .filter((name) => /\.(tsx|ts)$/.test(name))
+ .map((name) => ({ name, text: readFileSync(join(DIR, name), "utf8") }));
+
+const PURPLE_FOCUS =
+ /(?:focus|focus-visible|focus-within):(?:ring-ring|border-ring|outline-ring|ring-primary|border-primary)\b/g;
+
+describe("Coach focus ring", () => {
+ it("never names the purple ring on focus", () => {
+ const offenders = SOURCES.flatMap(({ name, text }) =>
+ [...text.matchAll(PURPLE_FOCUS)].map((m) => `${name}: ${m[0]}`),
+ );
+ expect(offenders).toEqual([]);
+ });
+
+ it("finds the neutral ring where it expects it", () => {
+ const neutral = SOURCES.reduce(
+ (n, { text }) =>
+ n + (text.match(/focus-visible:ring-input-focus\b/g)?.length ?? 0),
+ 0,
+ );
+ expect(neutral).toBeGreaterThanOrEqual(15);
+ });
+
+ it.each(["suggested-replies.tsx", "memory-note.tsx", "assumption-line.tsx"])(
+ "%s merges the neutral ring over the Button primitive",
+ (file) => {
+ const text = SOURCES.find((s) => s.name === file)?.text ?? "";
+ expect(text).toMatch(
+ /import \{ COACH_FOCUS_RING \} from "\.\/focus-ring"/,
+ );
+ expect(text).toMatch(/cn\([\s\S]*?COACH_FOCUS_RING,?\s*\)/);
+ },
+ );
+});
diff --git a/src/components/admin/assistant-section.tsx b/src/components/admin/assistant-section.tsx
index 6d3994a24..466be2503 100644
--- a/src/components/admin/assistant-section.tsx
+++ b/src/components/admin/assistant-section.tsx
@@ -201,7 +201,9 @@ export function AssistantSection() {
diff --git a/src/components/insights/coach-panel/__tests__/coach-reasoning-field.test.tsx b/src/components/insights/coach-panel/__tests__/coach-reasoning-field.test.tsx
index 25ec4c284..7b3489779 100644
--- a/src/components/insights/coach-panel/__tests__/coach-reasoning-field.test.tsx
+++ b/src/components/insights/coach-panel/__tests__/coach-reasoning-field.test.tsx
@@ -78,12 +78,12 @@ describe("", () => {
expect(html).toContain(
"Deeper thinking means more precise answers and a little more waiting.",
);
- expect(html).not.toMatch(/