fix(dashboard): stop clipping the composer Save button label on mobile (#2161)

The board composer's Save button rendered its label cut off on mobile
("Sav|").

## Root cause (measured, not guessed)

Reproduced in Chromium at a 412px viewport and measured the live layout:

- The primary action group needs **~275px** inside a **260px** column —
a 15px deficit.
- All five icon controls carry an explicit `min-width: 36px`
touch-target floor, so they **cannot** absorb it.
- Save's own automatic minimum size — which would normally floor a flex
item at its min-content width — is **zeroed by `overflow: hidden`**. Per
the flexbox spec, automatic minimum size applies only when `overflow` is
`visible`.

That `overflow: hidden` exists for a **vertical** reason
(FN-7680/FN-7683 height equalization). So a height fix silently made
Save the only horizontally-shrinkable control in the row: it absorbed
the entire deficit (**40px actual vs 55px needed**) and clipped its own
label.

## Fix

- Save is pinned to its content width (`flex: 0 0 auto; min-width:
max-content`) so it can never be squeezed. `overflow: hidden` stays — it
still owns the vertical clamp.
- The group may `flex-wrap: wrap` with `justify-content: flex-end`, so a
genuine deficit reflows to a second right-aligned line instead of
clipping.

**Not breakpoint-scoped** (FN-5751): the mechanism is width-driven, not
media-driven — mobile only trips it first because its 36px touch targets
are wider than the desktop chips. Where the row already fits, both rules
are inert.

This revises the older "wraps as one unit, never splitting Save from its
neighbors" intent: at widths where the row genuinely cannot fit, Save
wrapping to its own right-aligned line is strictly better than a clipped
label.

## Verification (in-browser, both breakpoints)

| | 412px (mobile) | 1400px (desktop) |
|---|---|---|
| label clipped | **no** (scrollWidth 67 === clientWidth 67) | no |
| Save width | 69px (full) | 69px |
| Save height | 36px — equal to icon siblings | unchanged |
| overflow | none (right edge 292 === container 292) | none |
| layout | wraps to a second right-aligned line | **no-op** — still one
line (group height 28px) |
| icon touch targets | all five still ≥36px | unchanged |

Gate green · lint clean · 288 dashboard composer tests pass.

## On the test

The CSS guard is a **string-match, not a layout proof** — jsdom has no
flex layout and cannot observe clipping, so the real proof is the
browser measurement above. The test exists so the invariant-bearing
declarations can't be silently dropped or re-scoped into a media query.
It was **confirmed non-vacuous**: 3 of its 4 cases fail against the
pre-fix CSS.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Fixed the Quick Entry Save button label being clipped on narrow mobile
screens.
* Improved action layout wrapping while preserving icon touch-target
sizing.

* **Tests**
* Added regression coverage to verify the Save button remains fully
visible across narrow layouts.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-15 23:45:53 -07:00
committed by GitHub
parent db05a77fea
commit 7aeefe21e3
3 changed files with 155 additions and 1 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: The task composer's Save button no longer has its label cut off on mobile.
category: fix
dev: At narrow widths `.quick-entry-primary-group` needed ~275px in a 260px column. Its five icon controls are floored by `min-width: 36px`, while Save's automatic minimum size was zeroed by the `overflow: hidden` that FN-7680/FN-7683 added for height equalization (per spec, automatic minimum size applies only when overflow is `visible`), making Save the sole shrinkable item — it absorbed the whole deficit and clipped its own label. Save is now pinned with `flex: 0 0 auto; min-width: max-content`, and the group may `flex-wrap: wrap` with `justify-content: flex-end` so a deficit reflows instead of clipping. Not breakpoint-scoped (FN-5751); inert where the row already fits. Verified in-browser at 412px and 1400px.

View File

@@ -0,0 +1,116 @@
/*
FNXC:QuickAddActionRow 2026-07-15-22:55:
## Symptom Verification
Original symptom: on mobile the composer's Save button rendered its label cut off ("Sav|").
Measured in-browser at a 412px viewport: the primary group needed ~275px inside a 260px column;
Save resolved to width 40px against a 55px scrollWidth, and `overflow: hidden` clipped the label.
Exact reproduction: viewport 412x915 → open a board column composer → expand the action row.
Assertion it is gone: verified IN A REAL BROWSER (Chromium via agent-browser), because jsdom does
not implement flex layout and CANNOT observe clipping. Post-fix measurements at 412px:
scrollWidth === clientWidth (67/67, not clipped), width 69px, height 36px (still equal to its
icon siblings), right edge flush with the container (292 === 292, no overflow), and all five
icons keeping their >=36px touch targets.
Desktop 1400px re-measured as a no-op: group height 28px (still one line), still right-aligned,
never clipped.
## Surface Enumeration
- Mobile (412px) — the reported surface. Fixed and measured.
- Desktop/tablet (1400px) — verified the rules are inert where the row already fits (~249px).
- The rules are deliberately NOT inside a media query (FN-5751: never a breakpoint-only fix);
the mechanism is width-driven, so a narrow desktop column must degrade the same way.
- Icon touch targets (36px) must survive — the fix must not claw width back from them.
## What this file can and cannot do
This is a STRING-MATCH guard, not a layout proof. It exists so the two invariant-bearing
declarations cannot be silently deleted or re-scoped into a media query by a later refactor.
It would happily pass on CSS that does not actually lay out correctly — do not treat a green run
here as evidence the button renders. Re-verify in a browser when touching this row.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";
const css = readFileSync(resolve(__dirname, "../components/QuickEntryBox.css"), "utf8");
/** Extract a top-level rule body, asserting it is not nested inside an @media block. */
function ruleBody(selector: string): { body: string; index: number } {
const index = css.indexOf(`${selector} {`);
expect(index, `rule "${selector}" must exist`).toBeGreaterThan(-1);
const start = css.indexOf("{", index) + 1;
let depth = 1;
let i = start;
while (i < css.length && depth > 0) {
if (css[i] === "{") depth += 1;
if (css[i] === "}") depth -= 1;
i += 1;
}
return { body: css.slice(start, i - 1), index };
}
/** True when `index` falls inside any @media block — i.e. the rule is breakpoint-scoped. */
function isInsideMediaQuery(index: number): boolean {
const before = css.slice(0, index);
let depth = 0;
const re = /@media[^{]*\{|\{|\}/g;
let m: RegExpExecArray | null;
const stack: boolean[] = [];
while ((m = re.exec(before))) {
if (m[0].startsWith("@media")) {
stack.push(true);
depth += 1;
} else if (m[0] === "{") {
stack.push(false);
depth += 1;
} else {
stack.pop();
depth -= 1;
}
}
return stack.some(Boolean);
}
describe("QuickEntryBox.css — Save button is never clipped (mobile report)", () => {
it("pins Save to its content width so it cannot absorb the row's shrink", () => {
// Root cause: every icon sibling has an explicit `min-width: 36px` floor, while Save's own
// automatic minimum size is zeroed by the `overflow: hidden` that FN-7680/FN-7683 added for
// HEIGHT equalization (per spec, automatic minimum size applies only when overflow is
// visible). That made Save the sole shrinkable control, so it ate the entire deficit.
const { body } = ruleBody('.quick-entry-primary-group [data-testid="quick-entry-save"]');
expect(body).toMatch(/flex:\s*0\s+0\s+auto/);
expect(body).toMatch(/min-width:\s*max-content/);
});
it("lets the primary group wrap so a deficit becomes a second line, not clipped text", () => {
const { body } = ruleBody(".quick-entry-primary-group");
expect(body).toMatch(/flex-wrap:\s*wrap/);
// Regression guard: `nowrap` is what forced the row to overflow/clip instead of reflowing.
expect(body).not.toMatch(/flex-wrap:\s*nowrap/);
// Save must stay right-aligned as the primary action once the group can wrap.
expect(body).toMatch(/justify-content:\s*flex-end/);
});
it("applies at every width, not only under a mobile breakpoint (FN-5751)", () => {
// The squeeze is width-driven; mobile only triggers it first because its 36px touch targets
// are wider than the desktop chips. A breakpoint-scoped fix would leave a narrow desktop
// board column clipping the same way.
for (const selector of [
".quick-entry-primary-group",
'.quick-entry-primary-group [data-testid="quick-entry-save"]',
]) {
const { index } = ruleBody(selector);
expect(isInsideMediaQuery(index), `${selector} must not be breakpoint-scoped`).toBe(false);
}
});
it("keeps the icon touch-target floor the fix must not claw width back from", () => {
// If a later change drops these, Save stops being the only shrinkable item and the
// measured premise of this fix silently changes.
expect(css).toMatch(/min-width:\s*36px/);
});
});

View File

@@ -217,15 +217,46 @@ rules only own grouping/spacing/alignment.
min-width: 0;
}
/*
FNXC:QuickAddActionRow 2026-07-15-22:40:
Save's label was cut off on mobile ("Sav|"). Measured cause, not a guess: at a 412px viewport the
primary group needs ~275px inside a 260px column. All five icon controls carry an explicit
`min-width: 36px` touch-target floor, so they cannot absorb the 15px deficit — and Save's own
automatic minimum size (`min-width: auto`, which would normally floor a flex item at its
min-content width) is ZEROED by the `overflow: hidden` on `.quick-entry-actions .btn` above.
Per the flexbox spec, automatic minimum size only applies when overflow is `visible`. That
`overflow: hidden` exists for a VERTICAL reason (FN-7680/FN-7683 height equalization), so a
height fix silently made Save the only horizontally-shrinkable control in the row: it absorbed
the entire deficit (40px actual vs 55px needed) and clipped its own label.
Two rules restore the invariant "the primary action's label is never clipped, at any width":
- Save is pinned to its content width (`flex-shrink: 0` + `min-width: max-content`) so it can
never be squeezed. `overflow: hidden` stays — it still owns the vertical clamp.
- The group may WRAP so a genuine deficit resolves as a second line instead of clipped text or
an overflowing row. `justify-content: flex-end` keeps Save right-aligned when it wraps.
Deliberately NOT breakpoint-scoped (FN-5751): the mechanism is width-driven, not media-driven —
mobile only triggers it first because its 36px touch targets are larger than the desktop chips.
Where the row already fits (desktop, ~249px of content) both rules are inert, verified in-browser
at 412px and 1400px. The earlier "wraps as one unit, never splitting Save from its neighbors"
intent is revised here: at widths where the row cannot fit, Save wrapping to its own right-aligned
line is strictly better than a clipped label.
*/
.quick-entry-primary-group {
display: flex;
align-items: center;
flex-wrap: nowrap;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-sm);
flex: 0 0 auto;
margin-left: auto;
}
.quick-entry-primary-group [data-testid="quick-entry-save"] {
flex: 0 0 auto;
min-width: max-content;
}
/*
FNXC:QuickAddActionRow 2026-07-08-00:00:
FN-7680 — Save (`btn btn-task-create btn-sm`) rendered a different box height