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**. 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. 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. 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. 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. 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); 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. */ /* 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; flex: 0 0 auto;
min-width: max-content; min-width: max-content;
min-height: 36px; 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 { @keyframes github-import-spinner-spin {
to { to {
transform: rotate(360deg); transform: rotate(360deg);

View File

@@ -2316,48 +2316,50 @@ export function GitHubImportModal({ isOpen, onClose, onImport, onPlanningMode, o
</button> </button>
</form> </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 <button
className="btn btn-danger github-import-issue-close" className="btn btn-primary github-import-action"
data-testid="github-import-issue-close" data-testid="github-import-action-top"
onClick={handleCloseIssue} onClick={handleImport}
disabled={closingIssue} disabled={
title={t("git.closeIssueTitle", "Close issue #{{number}}", { number: selectedIssue.number })} (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> </button>
)} </div>
{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> </div>
</FloatingWindow> </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: 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 recommended by Apple HIG and WCAG 2.5.8. Apply to interactive
elements that are too small on mobile (links, small buttons, icons). */ elements that are too small on mobile (links, small buttons, icons). */
.touch-target { .touch-target {
min-width: 44px; min-width: var(--touch-target-min-size);
min-height: 44px; min-height: var(--touch-target-min-size);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -146,6 +146,13 @@ html {
--space-xl: 24px; --space-xl: 24px;
--space-2xl: 32px; --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: FNXC:QuickAddActionRow 2026-07-16-14:00:
FN-8147 pins Quick Add action-row control heights to literal values rather than FN-8147 pins Quick Add action-row control heights to literal values rather than

View File

@@ -1,10 +1,11 @@
#!/usr/bin/env node #!/usr/bin/env node
/* global WebSocket, URL, fetch, console, setTimeout, clearTimeout */ /* global WebSocket, URL, fetch, console, setTimeout, clearTimeout */
import { Buffer } from "node:buffer";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { createServer } from "node:http"; import { createServer } from "node:http";
import { superviseSpawn } from "@fusion/core"; 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 { existsSync } from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
@@ -15,6 +16,7 @@ const dashboardRoot = path.resolve(import.meta.dirname, "..");
const appRoot = path.join(dashboardRoot, "app"); const appRoot = path.join(dashboardRoot, "app");
const clientDistRoot = path.join(dashboardRoot, "dist", "client"); const clientDistRoot = path.join(dashboardRoot, "dist", "client");
const requireBrowser = process.argv.includes("--require-browser") || process.env.FUSION_BROWSER_SMOKE_REQUIRE === "1"; 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) { function log(message) {
console.log(`[dashboard-browser-smoke] ${message}`); console.log(`[dashboard-browser-smoke] ${message}`);
@@ -24,13 +26,15 @@ function fail(message) {
throw new Error(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() { async function loadDashboardCss() {
try { await runCommand("pnpm", ["--filter", "@fusion/dashboard", "build:client"], dashboardRoot);
return await readEmittedClientCss(); return readEmittedClientCss();
} catch {
await runCommand("pnpm", ["--filter", "@fusion/dashboard", "build:client"], dashboardRoot);
return readEmittedClientCss();
}
} }
async function readEmittedClientCss() { async function readEmittedClientCss() {
@@ -188,6 +192,29 @@ export function createSmokeHtml() {
measure the shared ViewHeader flex geometry. Exercise the unread Inbox's tightest 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. 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 = [ 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>'], ["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>'], ["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} ${mailboxMobileHeaderFixtures}
</section> </section>
${githubImportMobileActionFixture}
<footer class="executor-status-bar"> <footer class="executor-status-bar">
<div class="executor-status-bar__segment"> <div class="executor-status-bar__segment">
<span class="executor-status-bar__indicator executor-status-bar__indicator--running"></span> <span class="executor-status-bar__indicator executor-status-bar__indicator--running"></span>
@@ -1267,6 +1296,105 @@ async function runSmokeChecks(page, pageUrl) {
JSON.stringify(mailboxMobileHeaderLayout), 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", { await page.send("Emulation.setDeviceMetricsOverride", {
width: 412, width: 412,
height: 915, height: 915,