Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions scripts/test/responsive-audit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -362,6 +405,7 @@ function measurePage(minTouch) {
offenders,
small,
clipped,
clippedProse,
buried,
brokenImages,
};
Expand Down Expand Up @@ -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)
Expand Down
16 changes: 14 additions & 2 deletions src/components/control/ProjectCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 && (
<button
type="button"
onClick={() => setCustom(nextStep.text)}
className="ui-link-subtle-button mt-1.5 block w-full truncate px-0 text-left"
className="ui-link-subtle-button mt-1.5 block w-full px-0 text-left line-clamp-2"
title={`${nextStep.text}\n\n${nextStep.sourceTitle}\nClick to use as the prompt below — edit, then Send.`}
>
<span className="font-medium text-text-secondary">{nextStep.label}</span>{" "}
Expand Down
14 changes: 12 additions & 2 deletions src/components/control/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,13 @@ export function PromptInput({
</div>
)}

<div className="flex items-center gap-1.5 border-t border-border-subtle px-3 py-2">
{/* flex-wrap so the status line below can take a row of its own on a
phone. It shared this row with four controls and Send, which left it
72px: the audit measured "Autopilot on: queue is empty, so
FleetCrown…" at 0% shown at 320px and 14% at 390px. No amount of
shorter copy fits a sentence into 72px — it needed the width, not
fewer words. */}
<div className="flex flex-wrap items-center gap-1.5 border-t border-border-subtle px-3 py-2">
{attachments && <AttachButton attachments={attachments} />}
{onToggleAutoContinue && typeof autoContinueEnabled === "boolean" && (
<button
Expand All @@ -228,7 +234,11 @@ export function PromptInput({
)}
<span
className={cn(
"min-w-0 flex-1 truncate text-xs",
// Below sm: its own full-width row (order-last puts it under the
// buttons), wrapping freely — a status nobody can read is not a
// status. From sm up the row has the room it always had, so it
// stays inline and single-line exactly as before.
"order-last w-full text-xs sm:order-none sm:w-auto sm:min-w-0 sm:flex-1 sm:truncate",
micError
? "text-status-negative"
: listening
Expand Down
Loading