FN-8548: keep GitHub import actions on one mobile row

Keep all selected GitHub issue actions touch-safe and visible on a single mobile row.

- Group detail action controls separately from the comment composer and make their mobile tracks shrinkable.
- Add responsive browser-smoke coverage at 320px, 390px, and 412px plus modal structure tests.
- Document the mobile behavior and add a patch changeset.

Files changed:
 .changeset/fn-8548-mobile-github-import-actions.md |   7 +
 docs/dashboard-guide.md                            |   8 +-
 .../dashboard/app/components/GitHubImportModal.css |  42 +++++-
 .../dashboard/app/components/GitHubImportModal.tsx |  80 ++++++------
 .../__tests__/GitHubImportModal.test.tsx           |  50 ++++++++
 packages/dashboard/app/styles.css                  |  11 +-
 .../dashboard/scripts/browser-layout-smoke.mjs     | 142 ++++++++++++++++++++-
 7 files changed, 290 insertions(+), 50 deletions(-)

Fusion-Task-Id: FN-8548

Fusion-Task-Lineage: 8fab6a1d-0ca9-4246-9707-e1fc84ca58e5

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-23 13:28:19 -07:00
parent e734ed8a48
commit 15b5441afc
7 changed files with 290 additions and 50 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep GitHub issue import actions on one usable mobile row.
category: fix
dev: Adds Blink geometry coverage for 320px, 390px, and 412px action bars.

View File

@@ -378,8 +378,14 @@ Use GitHub import on mobile:
1. Open the compact Header actions or bottom **More** sheet and select **Import from GitHub**.
Expected outcome: the same import workflow opens in the mobile modal layout.
<!--
FNXC:GitHubImportDocs 2026-07-23-13:20:
The mobile issue detail preserves the complete Close issue, Plan, Chat, and Import as task action set
on one touch-safe row. Labels may wrap inside their own actions rather than being hidden or moved to
an inaccessible second row.
-->
2. Choose the repository, issue/PR tab, candidate row, and detail action. For GitHub issues, choose **Import as task** for direct tracked creation or **Plan** to start Planning Mode with the issue context.
Expected outcome: direct import creates the board task with the same GitHub provenance/tracking metadata as the desktop/tablet **Import Tasks** view; Plan opens the Planning Mode interview without source-issue tracking.
Expected outcome: direct import creates the board task with the same GitHub provenance/tracking metadata as the desktop/tablet **Import Tasks** view; Plan opens the Planning Mode interview without source-issue tracking. When all GitHub issue actions are available, their full labels remain on one touch-safe action row.
3. While a candidate detail sheet is open, use the platform Back gesture or control.
Expected outcome: the first Back dismisses only the issue, pull request, or GitLab detail and returns to the import candidate list; a second Back dismisses the import form.

View File

@@ -345,8 +345,16 @@ when the preview is short; the top border separates it from scrolling content.
border-top: 1px solid var(--border);
}
/* Keep the composer and its action controls as independently sized flex rows. */
.github-import-detail-action-row {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-sm);
}
/* Never let the primary action's label clip when the bar is narrow. */
.github-import-detail-actions .btn {
.github-import-detail-action-row .btn {
flex: 0 0 auto;
min-width: max-content;
min-height: 36px;
@@ -1655,6 +1663,38 @@ counterpart added later cannot silently reintroduce the band.
}
/*
FNXC:GitHubImport 2026-07-23-12:18:
The selected-issue mobile action row must keep Import as task alongside optional Close issue,
Plan, and Chat controls. The comment composer remains on its own full-width row, while equal
shrinkable action tracks preserve every full label by wrapping it inside its own button and the
shared touch-target token preserves every required target without clipping or scrolling.
*/
@media (max-width: 768px) {
.github-import-detail-actions {
gap: var(--space-xs);
}
.github-import-detail-action-row {
flex: 1 1 100%;
min-width: 0;
align-items: stretch;
flex-wrap: nowrap;
gap: var(--space-xs);
}
.github-import-detail-action-row .btn {
flex: 1 1 0;
min-width: 0;
min-height: var(--touch-target-min-size) !important;
padding: var(--space-xs);
justify-content: center;
text-align: center;
white-space: normal;
overflow-wrap: anywhere;
}
}
@keyframes github-import-spinner-spin {
to {
transform: rotate(360deg);

View File

@@ -2316,48 +2316,50 @@ export function GitHubImportModal({ isOpen, onClose, onImport, onPlanningMode, o
</button>
</form>
)}
{activeTab === "issues" && selectedIssue && !selectedIssueClosed && (
<div className="github-import-detail-action-row" data-testid="github-import-detail-action-row">
{activeTab === "issues" && selectedIssue && !selectedIssueClosed && (
<button
className="btn btn-danger github-import-issue-close"
data-testid="github-import-issue-close"
onClick={handleCloseIssue}
disabled={closingIssue}
title={t("git.closeIssueTitle", "Close issue #{{number}}", { number: selectedIssue.number })}
>
{closingIssue ? <Loader2 size={14} className="spin" /> : t("git.closeIssue", "Close issue")}
</button>
)}
{activeTab === "issues" && selectedIssue && onPlanningMode && (
<button
type="button"
className="btn github-import-action"
data-testid="github-import-action-plan"
onClick={handlePlanIssue}
disabled={importing || isUrlImported(selectedIssue.html_url)}
>
{t("git.planIssue", "Plan")}
</button>
)}
{onOpenChatWithPrefill && (activeTab === "issues" ? selectedIssue?.html_url?.trim() : selectedPull?.html_url?.trim()) && (
<button
type="button"
className="btn github-import-action"
data-testid="github-import-action-chat"
onClick={handleChatAboutSelection}
>
{t("git.chatAboutIssue", "Chat")}
</button>
)}
<button
className="btn btn-danger github-import-issue-close"
data-testid="github-import-issue-close"
onClick={handleCloseIssue}
disabled={closingIssue}
title={t("git.closeIssueTitle", "Close issue #{{number}}", { number: selectedIssue.number })}
className="btn btn-primary github-import-action"
data-testid="github-import-action-top"
onClick={handleImport}
disabled={
(activeTab === "issues" ? selectedIssueNumber === null || isUrlImported(selectedIssue?.html_url) : selectedPullNumber === null || isUrlImported(selectedPull?.html_url)) || importing
}
>
{closingIssue ? <Loader2 size={14} className="spin" /> : t("git.closeIssue", "Close issue")}
{importing ? <Loader2 size={14} className="spin" /> : activeTab === "pulls" ? t("git.resolveFeedback", "Resolve feedback") : t("git.importAsTask", "Import as task")}
</button>
)}
{activeTab === "issues" && selectedIssue && onPlanningMode && (
<button
type="button"
className="btn github-import-action"
data-testid="github-import-action-plan"
onClick={handlePlanIssue}
disabled={importing || isUrlImported(selectedIssue.html_url)}
>
{t("git.planIssue", "Plan")}
</button>
)}
{onOpenChatWithPrefill && (activeTab === "issues" ? selectedIssue?.html_url?.trim() : selectedPull?.html_url?.trim()) && (
<button
type="button"
className="btn github-import-action"
data-testid="github-import-action-chat"
onClick={handleChatAboutSelection}
>
{t("git.chatAboutIssue", "Chat")}
</button>
)}
<button
className="btn btn-primary github-import-action"
data-testid="github-import-action-top"
onClick={handleImport}
disabled={
(activeTab === "issues" ? selectedIssueNumber === null || isUrlImported(selectedIssue?.html_url) : selectedPullNumber === null || isUrlImported(selectedPull?.html_url)) || importing
}
>
{importing ? <Loader2 size={14} className="spin" /> : activeTab === "pulls" ? t("git.resolveFeedback", "Resolve feedback") : t("git.importAsTask", "Import as task")}
</button>
</div>
</div>
</div>
</FloatingWindow>

View File

@@ -3160,6 +3160,56 @@ describe("GitHubImportModal — detail actions sit at the bottom (operator repor
});
});
it("keeps the four populated issue actions uniquely ordered in the shared mobile bar", async () => {
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([
{ number: 44, title: "Four actions", body: "Body", html_url: "https://github.com/dustinbyrne/kb/issues/44", labels: [], state: "open" },
]);
render(
<GitHubImportModal
isOpen
onClose={onClose}
onImport={onImport}
onPlanningMode={vi.fn()}
onOpenChatWithPrefill={vi.fn()}
tasks={[]}
/>,
);
fireEvent.click(await screen.findByRole("button", { name: /Select issue #44/i }));
const bar = await screen.findByTestId("github-import-detail-actions");
const actionRow = within(bar).getByTestId("github-import-detail-action-row");
const composer = within(bar).getByTestId("github-import-issue-comment-input");
const actions = [
within(actionRow).getByTestId("github-import-issue-close"),
within(actionRow).getByTestId("github-import-action-plan"),
within(actionRow).getByTestId("github-import-action-chat"),
within(actionRow).getByTestId("github-import-action-top"),
];
expect(actions.map((action) => action.textContent?.trim())).toEqual(["Close issue", "Plan", "Chat", "Import as task"]);
expect(composer.closest(".github-import-detail-actions")).toBe(bar);
expect(actionRow.previousElementSibling).toBe(composer.closest("form"));
expect(within(actionRow).getAllByRole("button", { name: /^Close issue$/i })).toHaveLength(1);
expect(within(actionRow).getAllByRole("button", { name: /^Plan$/i })).toHaveLength(1);
expect(within(actionRow).getAllByRole("button", { name: /^Chat$/i })).toHaveLength(1);
expect(within(actionRow).getAllByRole("button", { name: /^Import as task$/i })).toHaveLength(1);
});
it("uses shrinkable, non-wrapping mobile action tracks while retaining desktop behavior", () => {
const source = readFileSync(resolve(__dirname, "../GitHubImportModal.css"), "utf8");
const mobileRule = source.match(/@media\s\(max-width:\s768px\)\s\{[\s\S]*?\.github-import-detail-action-row\s\.btn\s\{[\s\S]*?\n\s{2}\}/)?.[0] ?? "";
expect(mobileRule).toContain(".github-import-detail-action-row {");
expect(mobileRule).toContain("flex-wrap: nowrap;");
expect(mobileRule).toContain("flex: 1 1 100%;");
expect(mobileRule).toContain("gap: var(--space-xs);");
expect(mobileRule).toContain("flex: 1 1 0;");
expect(mobileRule).toContain("min-width: 0;");
expect(mobileRule).toContain("min-height: var(--touch-target-min-size) !important;");
expect(mobileRule).toContain("white-space: normal;");
expect(mobileRule).toContain("overflow-wrap: anywhere;");
expect(source.match(/\.github-import-detail-actions\s*\{[^}]*flex-wrap: wrap;/)?.[0]).toBeTruthy();
});
/*
FNXC:GitHubImport 2026-07-17-12:00:

View File

@@ -71,8 +71,8 @@ html {
recommended by Apple HIG and WCAG 2.5.8. Apply to interactive
elements that are too small on mobile (links, small buttons, icons). */
.touch-target {
min-width: 44px;
min-height: 44px;
min-width: var(--touch-target-min-size);
min-height: var(--touch-target-min-size);
display: inline-flex;
align-items: center;
justify-content: center;
@@ -146,6 +146,13 @@ html {
--space-xl: 24px;
--space-2xl: 32px;
/*
FNXC:DashboardStyling 2026-07-23-13:05:
Mobile action controls need one shared minimum hit-area token so compact layouts can retain the
44px accessibility floor without embedding a raw dimension in component-specific rules.
*/
--touch-target-min-size: 44px;
/*
FNXC:QuickAddActionRow 2026-07-16-14:00:
FN-8147 pins Quick Add action-row control heights to literal values rather than

View File

@@ -1,10 +1,11 @@
#!/usr/bin/env node
/* global WebSocket, URL, fetch, console, setTimeout, clearTimeout */
import { Buffer } from "node:buffer";
import { spawn } from "node:child_process";
import { createServer } from "node:http";
import { superviseSpawn } from "@fusion/core";
import { readFile, readdir, rm, stat, mkdtemp } from "node:fs/promises";
import { readFile, readdir, rm, stat, mkdtemp, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -15,6 +16,7 @@ const dashboardRoot = path.resolve(import.meta.dirname, "..");
const appRoot = path.join(dashboardRoot, "app");
const clientDistRoot = path.join(dashboardRoot, "dist", "client");
const requireBrowser = process.argv.includes("--require-browser") || process.env.FUSION_BROWSER_SMOKE_REQUIRE === "1";
const screenshotPath = process.env.FUSION_BROWSER_SMOKE_SCREENSHOT;
function log(message) {
console.log(`[dashboard-browser-smoke] ${message}`);
@@ -24,13 +26,15 @@ function fail(message) {
throw new Error(message);
}
/*
FNXC:GitHubImport 2026-07-23-13:05:
The mobile GitHub action-bar regression measures emitted CSS, so every smoke invocation must
rebuild the client before loading it. Reusing a stale dist/client artifact could make geometry
assertions pass against styles predating the one-row compact-layout requirement.
*/
async function loadDashboardCss() {
try {
return await readEmittedClientCss();
} catch {
await runCommand("pnpm", ["--filter", "@fusion/dashboard", "build:client"], dashboardRoot);
return readEmittedClientCss();
}
await runCommand("pnpm", ["--filter", "@fusion/dashboard", "build:client"], dashboardRoot);
return readEmittedClientCss();
}
async function readEmittedClientCss() {
@@ -188,6 +192,29 @@ export function createSmokeHtml() {
measure the shared ViewHeader flex geometry. Exercise the unread Inbox's tightest
badge + Compose + Mark all read + Refresh row and both Compose + Refresh-only states.
*/
/*
FNXC:GitHubImport 2026-07-23-12:18:
FN-8548 mirrors the selected GitHub issue's four-action footer with the emitted dashboard CSS.
Blink must measure the production responsive contract at each supported phone width because jsdom
cannot detect wrapping, flex-track shrinkage, overflow, or touch-target geometry.
*/
const githubImportMobileActionFixture = `
<section data-smoke="github-import-mobile-actions" aria-label="GitHub issue detail actions">
<div class="github-import-detail-actions" data-testid="github-import-detail-actions">
<form class="github-import-issue-comment-composer">
<textarea class="input github-import-issue-comment-composer__input" aria-label="Add comment"></textarea>
<button class="btn btn-primary github-import-issue-comment-composer__submit" type="submit">Add comment</button>
</form>
<div class="github-import-detail-action-row" data-testid="github-import-detail-action-row">
<button class="btn btn-danger github-import-issue-close" type="button">Close issue</button>
<button class="btn github-import-action" type="button">Plan</button>
<button class="btn github-import-action" type="button">Chat</button>
<button class="btn btn-primary github-import-action" type="button">Import as task</button>
</div>
</div>
</section>
`;
const mailboxMobileHeaderFixtures = [
["unread-inbox", '<span class="mailbox-unread-badge">9</span><button class="btn btn-sm btn-primary" data-testid="mailbox-header-compose" type="button"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M2 2h10v10H2z"/></svg><span>Compose</span></button><button class="btn btn-sm btn-secondary" data-testid="mailbox-mark-all-read" type="button"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M2 7l3 3 7-7"/></svg><span>Mark all read</span></button><button class="btn-icon" data-testid="mailbox-refresh" type="button" aria-label="Refresh"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M2 7a5 5 0 1 0 2-4"/></svg></button>'],
["read-inbox", '<button class="btn btn-sm btn-primary" data-testid="mailbox-header-compose" type="button"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M2 2h10v10H2z"/></svg><span>Compose</span></button><button class="btn-icon" data-testid="mailbox-refresh" type="button" aria-label="Refresh"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M2 7a5 5 0 1 0 2-4"/></svg></button>'],
@@ -288,6 +315,8 @@ export function createSmokeHtml() {
${mailboxMobileHeaderFixtures}
</section>
${githubImportMobileActionFixture}
<footer class="executor-status-bar">
<div class="executor-status-bar__segment">
<span class="executor-status-bar__indicator executor-status-bar__indicator--running"></span>
@@ -1267,6 +1296,105 @@ async function runSmokeChecks(page, pageUrl) {
JSON.stringify(mailboxMobileHeaderLayout),
);
/*
FNXC:GitHubImport 2026-07-23-12:18:
Four GitHub issue actions must remain one contained, non-overlapping row at 320px, 390px, and
412px. This verifies the full labels and usable 44px minimum targets in a real browser instead
of relying on jsdom's zero-layout model or a CSS-source-only assertion.
*/
for (const width of [320, 390, 412]) {
await page.send("Emulation.setDeviceMetricsOverride", {
width,
height: 844,
deviceScaleFactor: 2,
mobile: true,
});
await evaluate(page, "document.fonts ? document.fonts.ready.then(() => true) : true");
const githubImportActionsLayout = await evaluate(page, `(() => {
const bar = document.querySelector('[data-smoke="github-import-mobile-actions"] .github-import-detail-actions');
const actionRow = bar.querySelector('.github-import-detail-action-row');
const barRect = bar.getBoundingClientRect();
const actionRowRect = actionRow.getBoundingClientRect();
const composer = bar.querySelector('.github-import-issue-comment-composer');
const composerRect = composer.getBoundingClientRect();
const buttons = [...actionRow.querySelectorAll('button')].map((button) => {
const rect = button.getBoundingClientRect();
const style = getComputedStyle(button);
return {
label: button.innerText.trim(),
left: rect.left,
right: rect.right,
top: rect.top,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
overflowX: button.scrollWidth - button.clientWidth,
overflowY: button.scrollHeight - button.clientHeight,
display: style.display,
visibility: style.visibility,
opacity: style.opacity,
textOverflow: style.textOverflow,
};
});
return {
composer: {
bottom: composerRect.bottom,
top: composerRect.top,
},
actionRow: {
left: actionRowRect.left,
right: actionRowRect.right,
top: actionRowRect.top,
bottom: actionRowRect.bottom,
overflowX: actionRow.scrollWidth - actionRow.clientWidth,
overflowY: actionRow.scrollHeight - actionRow.clientHeight,
},
bar: {
left: barRect.left,
right: barRect.right,
top: barRect.top,
bottom: barRect.bottom,
overflowX: bar.scrollWidth - bar.clientWidth,
overflowY: bar.scrollHeight - bar.clientHeight,
},
buttons,
};
})()`);
const expectedLabels = ["Close issue", "Plan", "Chat", "Import as task"];
assertSmokeResult(
`GitHub issue actions remain a visible one-row touch-safe layout at ${width}px`,
githubImportActionsLayout.buttons.length === expectedLabels.length
&& githubImportActionsLayout.buttons.every((button, index, buttons) =>
button.label === expectedLabels[index]
&& button.top === buttons[0].top
&& button.bottom === buttons[0].bottom
&& button.left >= githubImportActionsLayout.actionRow.left - 1
&& button.right <= githubImportActionsLayout.actionRow.right + 1
&& button.width >= 44
&& button.height >= 44
&& button.overflowX <= 1
&& button.overflowY <= 1
&& button.display !== "none"
&& button.visibility !== "hidden"
&& button.opacity !== "0"
&& button.textOverflow !== "ellipsis")
&& githubImportActionsLayout.buttons.every((button, index, buttons) =>
index === 0 || buttons[index - 1].right <= button.left + 1)
&& githubImportActionsLayout.actionRow.overflowX <= 1
&& githubImportActionsLayout.actionRow.overflowY <= 1
&& githubImportActionsLayout.composer.bottom <= githubImportActionsLayout.actionRow.top + 1
&& githubImportActionsLayout.bar.overflowX <= 1
&& githubImportActionsLayout.bar.overflowY <= 1,
JSON.stringify(githubImportActionsLayout),
);
if (screenshotPath && width === 390) {
const screenshot = await page.send("Page.captureScreenshot", { format: "png" });
await writeFile(screenshotPath, Buffer.from(screenshot.data, "base64"));
log(`saved GitHub import mobile screenshot to ${screenshotPath}`);
}
}
await page.send("Emulation.setDeviceMetricsOverride", {
width: 412,
height: 915,