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(/]*disabled/); + expect(html).not.toMatch(/]*\sdisabled=""/); }); it("is locked with one sentence when the admin turned reasoning off", () => { const html = render(ADMIN_OFF, "high"); - expect(html).toMatch(/]*disabled/); + expect(html).toMatch(/]*\sdisabled=""/); expect(html).toContain('data-locked="true"'); expect(html).toContain("The admin has turned deeper thinking off."); expect(html).not.toContain("a little more waiting"); @@ -127,3 +127,40 @@ describe("", () => { expect(html).toContain("Hoch (vom Admin begrenzt)"); }); }); + +describe("the two homes of the field", () => { + const selectClass = (html: string) => + html.match(/]*class="([^"]*)"/)?.[1] ?? ""; + + it("stacks as wide as the quick settings sheet", () => { + const html = render(OPEN); + expect(html).toContain('data-no-colon="true"'); + expect(selectClass(html)).toMatch(/\bw-full\b/); + expect(selectClass(html)).toMatch(/\bmt-1\b/); + }); + + it("takes the shape of Tone and Reply length in Settings → Coach", () => { + const html = renderToStaticMarkup( + {}} + />, + ); + // Their small label with its colon, a content-wide select, the card's + // own gap instead of margins. + expect(html).toMatch(/data-slot="coach-reasoning-field" class="[^"]*gap-2/); + expect(html).toContain('data-no-colon="false"'); + expect(html).toMatch(/]*class="[^"]*\btext-xs\b/); + expect(selectClass(html)).toMatch(/\bw-fit\b/); + expect(selectClass(html)).not.toMatch(/\bw-full\b|\bmt-1\b/); + }); + + it("looks locked when the operator switched reasoning off", () => { + const html = render(ADMIN_OFF); + expect(html).toMatch(/]*\sdisabled=""/); + expect(selectClass(html)).toContain("disabled:opacity-50"); + }); +}); diff --git a/src/components/insights/coach-panel/__tests__/message-actions.test.tsx b/src/components/insights/coach-panel/__tests__/message-actions.test.tsx index 928dee229..5100f9dd9 100644 --- a/src/components/insights/coach-panel/__tests__/message-actions.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-actions.test.tsx @@ -192,15 +192,20 @@ describe("user action row", () => { describe("follow-up chips in the answer column", () => { const offer = { messageId: "m2", followUps: CHIPS }; - it("render inside the column, after the action row", () => { + it("render inside the column, right after the answer, above the action row", () => { const html = render( {}} />, ); const column = slot(html, "coach-answer-column"); + const answer = column.indexOf('data-slot="coach-answer-bubble"'); const actions = column.indexOf('data-slot="coach-answer-actions"'); const chips = column.indexOf('data-slot="coach-follow-up-chips"'); - expect(actions).toBeGreaterThan(-1); - expect(chips).toBeGreaterThan(actions); + expect(answer).toBeGreaterThan(-1); + expect(chips).toBeGreaterThan(answer); + // The row is the last thing in the column: beside a pointer it is + // invisible until hover, and above the chips it left an empty band + // between the answer and its replies. + expect(actions).toBeGreaterThan(chips); // The answer's own text size, not the button's default. expect(slot(column, "coach-follow-up-chips")).toMatch( /class="[^"]*\btext-sm\b[^"]*font-normal/, diff --git a/src/components/insights/coach-panel/__tests__/suggested-replies.test.tsx b/src/components/insights/coach-panel/__tests__/suggested-replies.test.tsx index de20a63ed..3ecd1b1b9 100644 --- a/src/components/insights/coach-panel/__tests__/suggested-replies.test.tsx +++ b/src/components/insights/coach-panel/__tests__/suggested-replies.test.tsx @@ -75,6 +75,29 @@ describe("", () => { } }); + it("marks keyboard focus with the neutral ring, never the purple one", () => { + const tree = SuggestedReplies({ + replies: replies(() => {}), + messageId: "m1", + disabled: false, + }); + const html = tree ? renderToStaticMarkup(tree) : ""; + // The class the browser gets, after the primitive's variant classes and + // ours are merged. + const classes = [...html.matchAll(/]*class="([^"]*)"/g)].map( + (m) => m[1], + ); + expect(classes).toHaveLength(4); + for (const cls of classes) { + expect(cls).toContain("focus-visible:ring-input-focus"); + expect(cls).toContain("focus-visible:border-border"); + expect(cls).toContain("focus-visible:ring-2"); + expect(cls).not.toMatch( + /(?:^|\s)focus-visible:(?:ring-ring|border-ring|ring-\[3px\])/, + ); + } + }); + it("sends the tapped reply and hands focus to the composer", () => { focusComposer.mockClear(); const onSelect = vi.fn(); diff --git a/src/components/insights/coach-panel/__tests__/turn-activity.test.tsx b/src/components/insights/coach-panel/__tests__/turn-activity.test.tsx index 9a56084c8..73fb2fa6d 100644 --- a/src/components/insights/coach-panel/__tests__/turn-activity.test.tsx +++ b/src/components/insights/coach-panel/__tests__/turn-activity.test.tsx @@ -270,8 +270,17 @@ describe("the running line", () => { ); expect(html).toContain('data-state="running"'); expect(html).toContain('aria-label="Show what the Coach looked at"'); - // The seconds so far sit on the right of the line. - expect(html).toMatch(/data-slot="coach-turn-steps-seconds"[^>]*>1[23]s]*>[^<]*<\/span>]*data-slot="coach-turn-steps-seconds"[^>]*>1[23]\u00a0s]*data-slot="coach-turn-steps-seconds"[^>]*>/, + )?.[0]; + expect(seconds).toContain("tabular-nums"); + expect(seconds).not.toMatch(/\bml-auto\b|text-foreground/); } }); diff --git a/src/components/insights/coach-panel/assumption-line.tsx b/src/components/insights/coach-panel/assumption-line.tsx index 89284f10b..ab84da991 100644 --- a/src/components/insights/coach-panel/assumption-line.tsx +++ b/src/components/insights/coach-panel/assumption-line.tsx @@ -14,11 +14,13 @@ import { useId, useState } from "react"; import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { COACH_ASSUMPTION_KEYS } from "@/lib/ai/coach/dialog-keys"; import type { CoachAssumption, CoachFollowUp } from "@/lib/ai/coach/types"; import { SuggestedReplies } from "./suggested-replies"; +import { COACH_FOCUS_RING } from "./focus-ring"; /** The alternatives the server offers for one assumption. */ export function alternativesFor( @@ -96,7 +98,10 @@ export function CoachAssumptionLine({ onClick={() => setOpenKind(open ? null : assumption.kind)} // Inline in the meta line; the hit area reaches past the text (a // 44 px target on phones) without making the line any taller. - className="text-foreground relative h-auto px-1 py-0 text-xs font-normal underline underline-offset-4 after:absolute after:-inset-x-1 after:-inset-y-3.5 after:content-[''] sm:after:-inset-y-1.5" + className={cn( + "text-foreground relative h-auto px-1 py-0 text-xs font-normal underline underline-offset-4 after:absolute after:-inset-x-1 after:-inset-y-3.5 after:content-[''] sm:after:-inset-y-1.5", + COACH_FOCUS_RING, + )} > {t(COACH_ASSUMPTION_KEYS.change)} diff --git a/src/components/insights/coach-panel/attachment-picker.tsx b/src/components/insights/coach-panel/attachment-picker.tsx index 286b9a037..732754dda 100644 --- a/src/components/insights/coach-panel/attachment-picker.tsx +++ b/src/components/insights/coach-panel/attachment-picker.tsx @@ -208,7 +208,7 @@ export function AttachmentPicker({ data-selected={isSelected ? "true" : undefined} className={cn( "border-border hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-3 text-left", - "focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none", + "focus-visible:ring-input-focus focus-visible:ring-[3px] focus-visible:outline-none", isSelected && "border-primary/40 bg-primary/5", disabled && "cursor-not-allowed opacity-60 hover:bg-transparent", diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx index e35376530..435d324ad 100644 --- a/src/components/insights/coach-panel/chat-bubble.tsx +++ b/src/components/insights/coach-panel/chat-bubble.tsx @@ -810,23 +810,13 @@ function ChatBubbleImpl({ suggestedAction ?? metricSource?.suggestedAction ?? null; return action ? : null; })()} - {/* One row under the answer: copy, read aloud, try again and the - time on the left; the tokens and model it cost on the right. - Only on a settled reply (not while it streams, not on an error - or a refusal). */} - {settled && content && ( - - )} {/* The replies the latest answer offers, in its own column: a question's choices or a proposal's two answers when it has them, - else its follow-up chips. One pattern for all of them. */} + else its follow-up chips. One pattern for all of them. They sit + right under the answer, above the action row: beside a pointer + the row stays invisible until the message is hovered or holds + focus, and above the pills it left an empty band between the + answer and its replies. */} {replies && replies.replies.length > 0 ? ( ) : null} + {/* One row under the answer, the last thing in its column: copy, + read aloud, try again, details and the time. Only on a settled + reply (not while it streams, not on an error or a refusal). */} + {settled && content && ( + + )} ); diff --git a/src/components/insights/coach-panel/coach-reasoning-field.tsx b/src/components/insights/coach-panel/coach-reasoning-field.tsx index 8ea3a5038..8c97d8600 100644 --- a/src/components/insights/coach-panel/coach-reasoning-field.tsx +++ b/src/components/insights/coach-panel/coach-reasoning-field.tsx @@ -22,6 +22,7 @@ */ import { useQueryClient } from "@tanstack/react-query"; +import { cn } from "@/lib/utils"; import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; import { useTranslations } from "@/lib/i18n/context"; @@ -118,6 +119,12 @@ export interface CoachReasoningSelectProps { state: CoachReasoningState; /** Locks the select while a write is in flight. */ busy?: boolean; + /** + * `sheet` (default): the quick settings' stacked field, as wide as the + * sheet. `card`: Settings → Coach, where it sits in the grid beside Tone + * and Reply length and takes their small label and content-wide select. + */ + variant?: "sheet" | "card"; onChange: (next: ReasoningLevel) => void; } @@ -127,21 +134,30 @@ export function CoachReasoningSelect({ value, state, busy = false, + variant = "sheet", onChange, }: CoachReasoningSelectProps) { const { t } = useTranslations(); const options = reasoningOptions(state, t); const locked = reasoningLocked(state); + const card = variant === "card"; return ( -
-
- {/* v1.41 — how hard the Coach thinks; the same field sits in the - Coach's quick settings. */} - - setDraft((prev) => ({ ...prev, reasoning })) - } - /> + {/* v1.41 — how hard the Coach thinks, a third field of the same + shape; the same field sits in the Coach's quick settings. */} + + setDraft((prev) => ({ ...prev, reasoning })) + } + /> + {/* Data clusters + analysis window live on ONE owner: "What I can see" behind the Coach's settings gear, which diff --git a/src/components/ui/native-select.tsx b/src/components/ui/native-select.tsx index b344ba353..dc5bc33fc 100644 --- a/src/components/ui/native-select.tsx +++ b/src/components/ui/native-select.tsx @@ -47,6 +47,8 @@ import { cn } from "@/lib/utils"; // single element, so every call site's width / margin className still composes // straight onto it. `pe-9` reserves room so a long value never runs under the // chevron. The chevron glyph is the same path Lucide's `ChevronDownIcon` draws. +// Disabled: dimmed and not-allowed, the same as `` and the shadcn +// ``, so a locked field never reads as one that takes a choice. // Colour: `currentColor` inside a background-image data URI resolves to black // (no CSS cascade reaches it), which read as a near-black arrow on the light // theme. The stroke is therefore baked per theme into the @@ -55,7 +57,7 @@ import { cn } from "@/lib/utils"; // chevron uses. const NATIVE_SELECT_CLASS = - "border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:border-input-focus flex h-11 sm:h-10 w-full appearance-none rounded-md border ps-3 pe-9 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:outline-none bg-[image:var(--native-select-chevron)] bg-[length:1rem] bg-[position:right_0.75rem_center] bg-no-repeat"; + "border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:border-input-focus flex h-11 sm:h-10 w-full appearance-none rounded-md border ps-3 pe-9 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 bg-[image:var(--native-select-chevron)] bg-[length:1rem] bg-[position:right_0.75rem_center] bg-no-repeat"; export type NativeSelectProps = React.SelectHTMLAttributes;