From 67d9c36ef50ecbef75aba6a2a95e6138580e8af0 Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:14:19 +0200 Subject: [PATCH] fix(control): a sentence you can read, and the check that noticed it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /control's "Suggested next" line carried `truncate` — one line, ellipsis. On prod that rendered "Suggested next (profile): Conduct a 1-month baseline m…": 594 of its 644 pixels hidden at 390px, 432 at 1440. The remainder existed only in the `title` tooltip, and a phone has no hover. It is the card's one actionable sentence, and clicking it loads the text into the composer — so the sentence IS the decision, and a tenth of a sentence is not one. OutcomeStreak.tsx, in this same directory, documents having already fixed this exact mistake for its glyph row. /activity has always clamped the same "what's next" content to two lines (ActivityEventRow). This makes the three agree: line-clamp-2. Same class in the composer's status line, worse: it shared a row with four icons and Send, leaving it 72px — measured at 0% shown at 320px and 14% at 390px. Shorter copy cannot fix that; no sentence fits 72px. It needed the width, so below `sm` it wraps onto a row of its own (order-last w-full) and from `sm` up it stays exactly as it was. THE GATE, and why it is not a lint rule. responsive-audit already looks for clipped text, and deliberately EXEMPTS `text-overflow: ellipsis` as "a deliberate design choice". That exemption is right for what it was written for — a project name in a narrow rail, a filename, an id — and 76 files under src/components truncate exactly that way. A rule banning `truncate`, or `truncate` beside a `title`, would fire on ~42 legitimate sites and be turned off within a week. The distinction is prose, which a static rule cannot judge but a real viewport can measure. So the check lives where the viewports are: flag an ellipsis only when the text READS as a sentence (>=60 chars, >=8 spaces — an id has neither) AND under half of it is visible (a chip losing its tail is fine; a line showing its first eight words is not). Proven by mutation, in one environment, with only the classNames differing: - with `truncate` -> 2 findings ("40% shown, -552px", "22% shown, -402px") - with the fix -> silent at 320 / 390 / 768 / 1440 and it flags none of the 76 legitimate truncations. Verified against prod before the fix (2 findings at 320 and at 390, silent at 768/1440 where the line fits) and against a local server after it. npm run verify passes. Separately observed and NOT addressed here: /control logs a React #418 hydration mismatch at every viewport. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UvjGNAS9CMfEGNW26tUR4P --- scripts/test/responsive-audit.mjs | 50 +++++++++++++++++++++++++ src/components/control/ProjectCard.tsx | 16 +++++++- src/components/control/prompt-input.tsx | 14 ++++++- 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/scripts/test/responsive-audit.mjs b/scripts/test/responsive-audit.mjs index 5f3645e7..9a274d77 100644 --- a/scripts/test/responsive-audit.mjs +++ b/scripts/test/responsive-audit.mjs @@ -315,6 +315,49 @@ function measurePage(minTouch) { })) .slice(0, 5); + // A SENTENCE cut off by a single-line ellipsis. + // + // The vertical check above deliberately exempts `text-overflow: ellipsis`, + // and that exemption is right for what it was written for: a project name in + // a narrow rail, a filename, an id. Truncating an identifier is a normal + // affordance — you still recognise it, and 76 files in src/components use + // `truncate` for exactly that. A blanket rule would fire on all of them. + // + // It is not right for prose. /control's "Suggested next (profile): …" line + // showed 50 of its 644 pixels — the rest lived only in a `title` tooltip, and + // a phone has no hover. Clicking that line loads it into the composer, so the + // sentence was the decision and a tenth of a sentence is not one. + // + // Three conditions keep this narrow enough to stay silent on the legitimate + // cases: the text must READ as a sentence (long, many spaces — an id has + // neither), and more than half of it must be hidden (a chip losing its tail + // is fine; a line showing its first eight words is not). + const PROSE_MIN_CHARS = 60; + const PROSE_MIN_SPACES = 8; + const PROSE_MAX_VISIBLE_FRACTION = 0.5; + const clippedProse = [...document.querySelectorAll("body *")] + .filter((el) => { + const cs = getComputedStyle(el); + if (cs.textOverflow !== "ellipsis" || cs.whiteSpace !== "nowrap") return false; + if (cs.visibility === "hidden") return false; + const t = (el.textContent || "").trim(); + if (t.length < PROSE_MIN_CHARS) return false; + if ((t.match(/\s/g) || []).length < PROSE_MIN_SPACES) return false; + const full = el.scrollWidth; + if (full <= el.clientWidth + 2 || full === 0) return false; + return el.clientWidth / full < PROSE_MAX_VISIBLE_FRACTION; + }) + .map((el) => ({ + tag: el.tagName.toLowerCase(), + text: (el.textContent || "").trim().slice(0, 44), + cls: + ((el.className || "").toString().match(/ui-[\w-]+/g) || []).join(".") || + (el.className || "").toString().slice(0, 40), + hidden: el.scrollWidth - el.clientWidth, + shownPct: Math.round((el.clientWidth / el.scrollWidth) * 100), + })) + .slice(0, 5); + // Content trapped under fixed chrome (mobile bottom nav). Reachable only if // the page scrolls far enough; on a short page it is permanently covered. const bars = [...document.querySelectorAll("body *")].filter((el) => { @@ -362,6 +405,7 @@ function measurePage(minTouch) { offenders, small, clipped, + clippedProse, buried, brokenImages, }; @@ -525,6 +569,12 @@ async function main() { console.log( ` ⚠ clipped text: ${r.clipped.map((c) => `"${c.text}"[${c.cls}] (-${c.hidden}px)`).join(", ")}`, ); + if (r.clippedProse.length > 0) + console.log( + ` ⚠ sentence cut off by a single-line ellipsis (the rest is hover-only, so a phone cannot read it): ${r.clippedProse + .map((c) => `"${c.text}…"[${c.cls}] ${c.shownPct}% shown, -${c.hidden}px`) + .join(", ")}`, + ); if (r.brokenImages.length > 0) console.log(` ⚠ broken image(s): ${r.brokenImages.join(", ")}`); if (badRequests.length > 0) diff --git a/src/components/control/ProjectCard.tsx b/src/components/control/ProjectCard.tsx index ea339181..dd031012 100644 --- a/src/components/control/ProjectCard.tsx +++ b/src/components/control/ProjectCard.tsx @@ -502,12 +502,24 @@ export function ProjectCard({ "configure GOOGLE_CLIENT_ID…" from a months-old enrich while the agent's handoff said something else entirely and 7 real dispatches that day ignored both. The agent's handoff wins when there is one, - and each source is NAMED so a suggestion can't pass as a fact. */} + and each source is NAMED so a suggestion can't pass as a fact. + + line-clamp-2, NOT truncate. `truncate` is one line with an ellipsis, + so this rendered as "Suggested next (profile): Conduct a 1-month + baseline m…" — measured on prod, 594 of its 644 pixels hidden at + 390px and 432 at 1440px. The rest of the sentence existed only in + the `title` tooltip, and a phone has no hover; OutcomeStreak.tsx in + this same directory documents having already fixed that exact + mistake for its glyph row. It costs more here than there: clicking + this button loads the text into the composer, so the sentence IS + the decision, and a tenth of a sentence is not one. /activity + already clamps the same "what's next" content to two lines + (ActivityEventRow) — the two surfaces now agree. */} {nextStep && (