diff --git a/docs/reports/2026-09-12-ui-visual-review-delivery.md b/docs/reports/2026-09-12-ui-visual-review-delivery.md index c9f21930..6767dead 100644 --- a/docs/reports/2026-09-12-ui-visual-review-delivery.md +++ b/docs/reports/2026-09-12-ui-visual-review-delivery.md @@ -51,6 +51,21 @@ controlli geometrici per tutte e tre le etichette alle cinque larghezze responsi Il successivo aggiornamento del medesimo tag frontend include questa correzione; il digest del follow-up precedente identifica quindi una versione superata. +## Follow-up: altezza del prompt Core (2026-09-13) + +Riprodotto con Playwright il campo schiacciato: altezza inline 0 px, spazio utile 8 px, +a fronte di 31 px necessari per la prima riga. L'autosizing misurava il Core ancora +nascosto durante il caricamento del contesto e non reagiva alla successiva apertura. +Il reset isolato dell'altezza ad auto riportava immediatamente il campo a 31 px. + +Il composer ora evita misure nascoste, ricalcola al cambio di larghezza/visibilità e +al caricamento dei font, conserva un minimo di 32 px e cresce fino a 160 px con scroll. +Il focus usa il contorno esterno esistente, senza il secondo bordo interno. +Nessuna modifica all'invio o al workflow. Passati 9 scenari Playwright e 17 test Vitest +mirati; coperti ingresso dal Core e dall'Admin, Shift+Enter, testo lungo, mobile, +navigazione con bozza conservata e svuotamento. Build Docker frontend aggiornata; +branch di revisione isolato, immagine di ripristino invariata. + ## Verifica | Verifica | Esito | diff --git a/frontend/e2e/ui-visual-review.spec.ts b/frontend/e2e/ui-visual-review.spec.ts index 61970e33..3b9cf4ea 100644 --- a/frontend/e2e/ui-visual-review.spec.ts +++ b/frontend/e2e/ui-visual-review.spec.ts @@ -90,6 +90,50 @@ async function inspect(page: Page) { }); } +for (const route of ["/", "/?thoth_route=administration%2Fdatabase"]) { + test(`Core prompt remains readable after loading ${route}`, async ({ page }, testInfo) => { + const writes = await fixtures(page); + await page.goto(route); + if (route !== "/") { + await navigation(page); + await page.getByRole("button", { name: "Return to session", exact: true }).click(); + } + const prompt = page.getByRole("textbox", { name: "New question", exact: true }); + await expect(prompt).toBeVisible(); + await prompt.focus(); + const readable = async () => { + await expect.poll(() => prompt.evaluate(el => { + const style = getComputedStyle(el); + return el.clientHeight >= parseFloat(style.lineHeight) + parseFloat(style.paddingTop) + parseFloat(style.paddingBottom); + })).toBe(true); + }; + await readable(); + await expect(prompt).toHaveCSS("outline-style", "none"); + await page.screenshot({ path: testInfo.outputPath("Prompt-focused.png") }); + await prompt.fill("First line"); + await prompt.press("Shift+Enter"); + await prompt.press("a"); + await expect(prompt).toHaveValue("First line\na"); + await expect.poll(() => prompt.evaluate(el => el.clientHeight)).toBeGreaterThan(40); + const question = "Compare eligible patients across the available cohorts. ".repeat(30); + await prompt.fill(question); + await page.setViewportSize({ width: 390, height: 900 }); + await readable(); + await expect.poll(() => prompt.evaluate(el => el.clientHeight)).toBe(160); + expect(await prompt.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(true); + await admin(page, "Database"); + await navigation(page); + await page.getByRole("button", { name: "Return to session", exact: true }).click(); + await expect(prompt).toHaveValue(question); + await readable(); + await page.screenshot({ path: testInfo.outputPath("Prompt-multiline-mobile.png") }); + await prompt.fill(""); + await readable(); + await expect.poll(() => prompt.evaluate(el => el.clientHeight)).toBeLessThanOrEqual(40); + expect(writes).toEqual([]); + }); +} + for (const width of [390, 649, 768, 1280, 1600]) { test(`all surfaces retain shared typography at ${width}px`, async ({ page }, testInfo) => { await page.setViewportSize({ width, height: 1000 }); diff --git a/frontend/src/index.css b/frontend/src/index.css index 43923387..e5c93b9f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -158,6 +158,11 @@ outline-offset: 2px; } + /* The composer already exposes keyboard focus on its enclosing focus-within frame. */ + #root textarea.thot-composer-input:focus-visible { + outline: none; + } + #root :where(h1, h2, h3, h4, h5, h6), [data-slot="dialog-content"] :where(h1, h2, h3, h4, h5, h6) { font-family: var(--font-heading); diff --git a/frontend/src/shell/SteerInput.tsx b/frontend/src/shell/SteerInput.tsx index 97eca165..afb07986 100644 --- a/frontend/src/shell/SteerInput.tsx +++ b/frontend/src/shell/SteerInput.tsx @@ -69,12 +69,32 @@ export function SteerInput({ } // Grow the textarea to fit its content (up to a cap, then it scrolls), and shrink - // back when the text is cleared. Runs after every value change so height tracks text. + // back when cleared. Core stays mounted while hidden behind context/Admin pages, + // so a hidden scrollHeight must never become a persisted zero-pixel height. useEffect(() => { const el = taRef.current; if (!el) return; - el.style.height = "auto"; - el.style.height = `${Math.min(el.scrollHeight, 160)}px`; + let disposed = false; + const resize = () => { + if (disposed || el.getClientRects().length === 0) return; + el.style.height = "auto"; + el.style.height = `${Math.min(el.scrollHeight, 160)}px`; + }; + resize(); + let lastWidth = -1; + const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(([entry]) => { + const width = entry.contentRect.width; + if (width === lastWidth) return; + lastWidth = width; + // Ignore height-only notifications from our own autosizing writes. + if (width > 0) resize(); + }); + observer?.observe(el); + void document.fonts?.ready.then(resize); + return () => { + disposed = true; + observer?.disconnect(); + }; }, [text]); async function submit() { @@ -162,7 +182,7 @@ export function SteerInput({ placeholder={sessionId ? "Free steering (!)…" : "Ask a question to start a new session…"} aria-label={sessionId ? "Steering" : "New question"} data-awaiting-input={awaitingInput ? "true" : undefined} - className={["max-h-40 flex-1 resize-none rounded-lg bg-card px-1 py-1 text-sm leading-relaxed outline-none placeholder:text-muted-foreground", awaitingInput && "thot-awaiting-input"].filter(Boolean).join(" ")} + className={["thot-composer-input min-h-8 min-w-0 max-h-40 flex-1 resize-none rounded-lg bg-card px-1 py-1 text-sm leading-relaxed outline-none placeholder:text-muted-foreground", awaitingInput && "thot-awaiting-input"].filter(Boolean).join(" ")} />