diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a4fe19e9..b3d5aed3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -72,7 +72,30 @@ jobs:
with:
name: route-width-screenshots
path: test-results/route-width
- if-no-files-found: error
+ if-no-files-found: warn
+
+ pwa:
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ - name: Use Node.js 20.x
+ uses: actions/setup-node@v6
+ with:
+ node-version: 20.x
+ cache: "npm"
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Build exported app
+ run: npm run build
+
+ - name: Install Playwright Browser
+ run: npx playwright install --with-deps chromium
- name: Run PWA Smoke
run: npm run test:e2e:pwa
diff --git a/docs/accessibility/audit-23-25-26-manual-closure-runbook.md b/docs/accessibility/audit-23-25-26-manual-closure-runbook.md
index 42701678..009cfabe 100644
--- a/docs/accessibility/audit-23-25-26-manual-closure-runbook.md
+++ b/docs/accessibility/audit-23-25-26-manual-closure-runbook.md
@@ -80,7 +80,9 @@ Routes:
- `/en/all-tools`
- `/en/data-code-formats`
- `/en/json-formatter`
+- `/en/qr-code-generator`
- `/en/base64-encode-decode`
+- `/en/csv-json-converter`
- `/en/jwt-decoder`
- `/en/regex-tester`
- `/en/pipeline-builder`
@@ -100,11 +102,15 @@ Software keyboard:
- On `/en/json-formatter`, focus the input, type invalid JSON, fix it, and run format.
- Confirm the keyboard does not hide the active input or primary action in a way that prevents completion.
- On `/en/base64-encode-decode`, enter a long URL-like value and copy output.
+- On `/en/qr-code-generator`, edit QR content, download PNG, and confirm copy feedback with the keyboard open.
+- On `/en/csv-json-converter`, convert a short CSV payload, copy the result, and confirm the Convert action remains reachable.
- On `/en/regex-tester`, edit pattern and sample text with the keyboard open.
Pipeline Builder:
- Open `/en/pipeline-builder`.
+- Use the compact Steps, Input/output, and Inspector section jumps in portrait and landscape.
+- Select a step, edit its label in Inspector, and return to the input without searching through the full page.
- Confirm the mobile diagnostics are readable without forcing page-level horizontal scrolling.
- Run the default sample or a local sample.
- Confirm run status and output are visible.
diff --git a/docs/accessibility/audit-25-26-mobile-a11y-matrix.md b/docs/accessibility/audit-25-26-mobile-a11y-matrix.md
index d1be8be5..669a6e98 100644
--- a/docs/accessibility/audit-25-26-mobile-a11y-matrix.md
+++ b/docs/accessibility/audit-25-26-mobile-a11y-matrix.md
@@ -12,6 +12,11 @@ Mobile layout routes:
- `/en/all-tools`
- `/en/data-code-formats`
- `/en/json-formatter`
+- `/en/qr-code-generator`
+- `/en/base64-encode-decode`
+- `/en/csv-json-converter`
+- `/en/jwt-decoder`
+- `/en/regex-tester`
- `/en/pipeline-builder`
- `/en/trust-center`
- `/en/install-app`
@@ -31,6 +36,20 @@ Automated checks:
- Page-level horizontal overflow stays within the 2px tolerance.
- Runtime, console, and same-origin critical resource errors fail the smoke.
+Core mobile workflows run at both `390x844` and `430x932`:
+
+- QR content update, copy feedback, and PNG download.
+- CSV-to-JSON conversion, copy feedback, and JSON download.
+- Pipeline sample loading, section jumps, Inspector editing, execution, output, and copy feedback.
+- JSON, Base64, JWT, Markdown, Regex, Cron, Image Resizer, JSON Diff, and Text Diff core interactions.
+
+Viewport-pressure checks run for QR, CSV, and Pipeline Builder:
+
+- A focused input remains visible in a reduced-height `390x420` keyboard proxy.
+- The primary action remains reachable at `390x420`.
+- Portrait-to-landscape transition to `844x390` does not introduce page-level horizontal overflow.
+- The primary action remains reachable after the landscape transition.
+
Accessibility routes:
- `/en`
@@ -46,13 +65,14 @@ Automated checks:
- Color contrast is covered by token-level guards, because runtime CSS variables are not reliable in every axe environment.
- Skip link, header language menu, mobile navigation focus restoration, command palette, copy feedback, and Pipeline Builder workflows are covered by Playwright smoke journeys.
-## Validation Run
+## Validation Commands
-The June 27, 2026 audit batch passed:
+Run the full candidate commit through:
- `npm test -- --run tests/guards/mobile-a11y-audit-issues-262-263.test.ts tests/guards/playwright-smoke-matrix-guard.test.ts tests/guards/a11y-mobile-baseline.test.ts tests/guards/bf-037-a11y-qa-guard.test.ts tests/guards/bf-036-bf-038-accessibility-visual-qa-guard.test.ts`
- `npm test -- --run tests/component/representative-a11y-axe.test.tsx tests/component/phase3-pipeline-builder-page.test.tsx`
- `npm run test:e2e:smoke`
+- `npm run test:e2e:mobile`
- `npm run test:e2e:pwa`
- `npm run validate`
- `npm run lint`
@@ -69,5 +89,6 @@ The following checks still require real device or assistive-technology verificat
- Lighthouse accessibility score verification on representative pages in a stable Chrome/Lighthouse environment.
- Screen-reader output in NVDA, VoiceOver, or TalkBack for Pipeline Builder and external-request confirmation flows.
- Software keyboard overlap on physical mobile devices for multiline tool inputs.
+- Real orientation-change behavior and browser chrome/visual viewport interaction on physical devices.
Use `docs/accessibility/audit-23-25-26-manual-closure-runbook.md` for the required closure procedure and issue comment template.
diff --git a/package.json b/package.json
index d9710c50..9ac1e1fc 100644
--- a/package.json
+++ b/package.json
@@ -60,9 +60,10 @@
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:e2e:smoke": "node scripts/e2e/run-playwright-smoke.js",
+ "test:e2e:mobile": "node scripts/e2e/run-playwright-smoke.js --mobile-only",
"test:e2e:first-load": "node scripts/e2e/run-playwright-smoke.js --first-load-only --first-load-artifacts",
"test:e2e:route-width": "node scripts/e2e/run-route-width-matrix.js",
- "test:e2e:pwa": "node scripts/e2e/run-playwright-smoke.js --pwa",
+ "test:e2e:pwa": "node scripts/e2e/run-playwright-smoke.js --pwa-only",
"check:i18n": "node scripts/gates/check-i18n.js",
"check:i18n-qa": "node scripts/gates/check-i18n-qa.js",
"check:i18n:ratchet": "node scripts/gates/check-i18n-untranslated-ratchet.js --mode=fail",
diff --git a/public/pwa-screenshots/home-en-1280x720.png b/public/pwa-screenshots/home-en-1280x720.png
index bc168881..3a970183 100644
Binary files a/public/pwa-screenshots/home-en-1280x720.png and b/public/pwa-screenshots/home-en-1280x720.png differ
diff --git a/public/pwa-screenshots/install-android.png b/public/pwa-screenshots/install-android.png
index bc168881..a22a947e 100644
Binary files a/public/pwa-screenshots/install-android.png and b/public/pwa-screenshots/install-android.png differ
diff --git a/public/pwa-screenshots/install-chrome-desktop.png b/public/pwa-screenshots/install-chrome-desktop.png
index bc168881..aab8479e 100644
Binary files a/public/pwa-screenshots/install-chrome-desktop.png and b/public/pwa-screenshots/install-chrome-desktop.png differ
diff --git a/public/pwa-screenshots/install-edge.png b/public/pwa-screenshots/install-edge.png
index bc168881..924e5e0e 100644
Binary files a/public/pwa-screenshots/install-edge.png and b/public/pwa-screenshots/install-edge.png differ
diff --git a/public/pwa-screenshots/install-firefox.png b/public/pwa-screenshots/install-firefox.png
index bc168881..25cdeb28 100644
Binary files a/public/pwa-screenshots/install-firefox.png and b/public/pwa-screenshots/install-firefox.png differ
diff --git a/public/pwa-screenshots/install-ios-safari.png b/public/pwa-screenshots/install-ios-safari.png
index bc168881..28816ab6 100644
Binary files a/public/pwa-screenshots/install-ios-safari.png and b/public/pwa-screenshots/install-ios-safari.png differ
diff --git a/public/pwa-screenshots/install-safari-desktop.png b/public/pwa-screenshots/install-safari-desktop.png
new file mode 100644
index 00000000..841f01e6
Binary files /dev/null and b/public/pwa-screenshots/install-safari-desktop.png differ
diff --git a/public/pwa-screenshots/json-formatter-en-1280x720.png b/public/pwa-screenshots/json-formatter-en-1280x720.png
index bc168881..fd325c59 100644
Binary files a/public/pwa-screenshots/json-formatter-en-1280x720.png and b/public/pwa-screenshots/json-formatter-en-1280x720.png differ
diff --git a/public/runtime/theme-manifest-bootstrap.js b/public/runtime/theme-manifest-bootstrap.js
index c9a19e82..50544377 100644
--- a/public/runtime/theme-manifest-bootstrap.js
+++ b/public/runtime/theme-manifest-bootstrap.js
@@ -1,4 +1,34 @@
(function () {
+ var installPromptBridgeReadySlot = "__byteflowPwaInstallPromptBridgeReady";
+ var installPromptChangeEvent = "byteflow:pwa-install-prompt-change";
+ var installPromptInstalledKey = "byteflow:pwa-install:installed";
+ var installPromptSlot = "__byteflowPwaInstallPrompt";
+
+ function notifyInstallPromptChange() {
+ try {
+ window.dispatchEvent(new CustomEvent(installPromptChangeEvent));
+ } catch {}
+ }
+
+ if (!window[installPromptBridgeReadySlot]) {
+ window[installPromptBridgeReadySlot] = true;
+ window.addEventListener("beforeinstallprompt", function (event) {
+ event.preventDefault();
+ try {
+ localStorage.removeItem(installPromptInstalledKey);
+ } catch {}
+ window[installPromptSlot] = event;
+ notifyInstallPromptChange();
+ });
+ window.addEventListener("appinstalled", function () {
+ try {
+ localStorage.setItem(installPromptInstalledKey, "1");
+ } catch {}
+ window[installPromptSlot] = null;
+ notifyInstallPromptChange();
+ });
+ }
+
try {
var locales = ["en","zh-CN","zh-TW","ja","ko","de","fr"];
var p = window.location.pathname || "/";
diff --git a/scripts/e2e/playwright-smoke-args.js b/scripts/e2e/playwright-smoke-args.js
new file mode 100644
index 00000000..a2622809
--- /dev/null
+++ b/scripts/e2e/playwright-smoke-args.js
@@ -0,0 +1,79 @@
+const EXCLUSIVE_MODE_FLAGS = new Map([
+ ["--pwa-only", "pwaOnly"],
+ ["--first-load-only", "firstLoadOnly"],
+ ["--input-intents-only", "inputIntentsOnly"],
+ ["--mobile-only", "mobileOnly"],
+]);
+
+export function parsePlaywrightSmokeArgs(argv, { defaultPort = 4173 } = {}) {
+ const args = {
+ port: defaultPort,
+ baseUrl: "",
+ skipServer: false,
+ includePwa: false,
+ pwaOnly: false,
+ firstLoadOnly: false,
+ writeFirstLoadArtifacts: false,
+ inputIntentsOnly: false,
+ mobileOnly: false,
+ };
+ const activeExclusiveFlags = new Set();
+ const unknownArgs = new Set();
+
+ for (const arg of argv) {
+ const exclusiveMode = EXCLUSIVE_MODE_FLAGS.get(arg);
+ if (exclusiveMode) {
+ args[exclusiveMode] = true;
+ activeExclusiveFlags.add(arg);
+ continue;
+ }
+
+ if (arg === "--skip-server") {
+ args.skipServer = true;
+ continue;
+ }
+
+ if (arg === "--pwa") {
+ args.includePwa = true;
+ continue;
+ }
+
+ if (arg === "--first-load-artifacts") {
+ args.writeFirstLoadArtifacts = true;
+ continue;
+ }
+
+ if (arg.startsWith("--port=")) {
+ const parsed = Number(arg.slice("--port=".length));
+ if (Number.isFinite(parsed) && parsed > 0) {
+ args.port = parsed;
+ }
+ continue;
+ }
+
+ if (arg.startsWith("--base-url=")) {
+ args.baseUrl = arg.slice("--base-url=".length).trim();
+ continue;
+ }
+
+ unknownArgs.add(arg);
+ }
+
+ const validationErrors = [];
+ if (unknownArgs.size > 0) {
+ validationErrors.push(`Unknown argument(s): ${[...unknownArgs].join(", ")}`);
+ }
+ if (activeExclusiveFlags.size > 1) {
+ validationErrors.push(`Conflicting --*-only flags: ${[...activeExclusiveFlags].join(", ")}`);
+ }
+ if (validationErrors.length > 0) {
+ throw new Error(`[playwright-smoke] Invalid arguments:\n- ${validationErrors.join("\n- ")}`);
+ }
+
+ if (!args.baseUrl) {
+ args.baseUrl = `http://127.0.0.1:${args.port}`;
+ }
+
+ args.baseUrl = args.baseUrl.replace(/\/+$/, "");
+ return args;
+}
diff --git a/scripts/e2e/promise-timeout.js b/scripts/e2e/promise-timeout.js
new file mode 100644
index 00000000..85a8cc07
--- /dev/null
+++ b/scripts/e2e/promise-timeout.js
@@ -0,0 +1,19 @@
+export async function raceWithTimeout(
+ promise,
+ timeoutMs,
+ timeoutMessage,
+ timerApi = globalThis,
+) {
+ let timeoutId
+ const timeoutPromise = new Promise((_, reject) => {
+ timeoutId = timerApi.setTimeout(() => {
+ reject(new Error(timeoutMessage))
+ }, timeoutMs)
+ })
+
+ try {
+ return await Promise.race([promise, timeoutPromise])
+ } finally {
+ if (timeoutId !== undefined) timerApi.clearTimeout(timeoutId)
+ }
+}
diff --git a/scripts/e2e/run-playwright-smoke.js b/scripts/e2e/run-playwright-smoke.js
index bb0d939f..16614d30 100644
--- a/scripts/e2e/run-playwright-smoke.js
+++ b/scripts/e2e/run-playwright-smoke.js
@@ -6,6 +6,8 @@ import process from "node:process";
import axe from "axe-core";
import { chromium } from "playwright";
import serveHandler from "serve-handler";
+import { parsePlaywrightSmokeArgs } from "./playwright-smoke-args.js";
+import { raceWithTimeout } from "./promise-timeout.js";
const DEFAULT_PORT = 4173;
const DEFAULT_ROUTES = [
@@ -35,6 +37,11 @@ const MOBILE_REVIEW_ROUTES = [
"/en/all-tools",
"/en/data-code-formats",
"/en/json-formatter",
+ "/en/qr-code-generator",
+ "/en/base64-encode-decode",
+ "/en/csv-json-converter",
+ "/en/jwt-decoder",
+ "/en/regex-tester",
"/en/pipeline-builder",
"/en/trust-center",
"/en/install-app",
@@ -65,6 +72,7 @@ const ALL_TOOLS_MOBILE_SCROLL_BUDGET_MS = 3500;
const ALL_TOOLS_MOBILE_MAX_FRAME_DELTA_MS = 500;
const FIRST_LOAD_CLS_BUDGET = 0.1;
const FIRST_LOAD_SETTLE_MS = 2_600;
+const PWA_SERVICE_WORKER_READY_TIMEOUT_MS = 20_000;
const FIRST_LOAD_ARTIFACT_DIR = path.resolve(process.cwd(), "output/first-load-audit");
const FIRST_LOAD_AUDIT_CASES = [
{
@@ -199,64 +207,6 @@ function createRuntimeObserver(page, label, baseUrl = "") {
};
}
-function parseArgs(argv) {
- const args = {
- port: DEFAULT_PORT,
- baseUrl: "",
- skipServer: false,
- includePwa: false,
- firstLoadOnly: false,
- writeFirstLoadArtifacts: false,
- inputIntentsOnly: false,
- };
-
- for (const arg of argv) {
- if (arg === "--skip-server") {
- args.skipServer = true;
- continue;
- }
-
- if (arg === "--pwa") {
- args.includePwa = true;
- continue;
- }
-
- if (arg === "--first-load-only") {
- args.firstLoadOnly = true;
- continue;
- }
-
- if (arg === "--first-load-artifacts") {
- args.writeFirstLoadArtifacts = true;
- continue;
- }
-
- if (arg === "--input-intents-only") {
- args.inputIntentsOnly = true;
- continue;
- }
-
- if (arg.startsWith("--port=")) {
- const parsed = Number(arg.slice("--port=".length));
- if (Number.isFinite(parsed) && parsed > 0) {
- args.port = parsed;
- }
- continue;
- }
-
- if (arg.startsWith("--base-url=")) {
- args.baseUrl = arg.slice("--base-url=".length).trim();
- }
- }
-
- if (!args.baseUrl) {
- args.baseUrl = `http://127.0.0.1:${args.port}`;
- }
-
- args.baseUrl = args.baseUrl.replace(/\/+$/, "");
- return args;
-}
-
async function wait(ms) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -1052,6 +1002,49 @@ async function assertNoHorizontalOverflow(page, routeLabel) {
}
}
+async function assertLocatorReachable(page, locator, label, { focus = false, scroll = true } = {}) {
+ await locator.waitFor({ state: "visible", timeout: 15_000 });
+ if (scroll) {
+ await locator.scrollIntoViewIfNeeded();
+ await locator.evaluate((element) => element.scrollIntoView({ block: "center", inline: "nearest" }));
+ }
+ if (focus) await locator.focus();
+
+ const measurement = await locator.evaluate((element) => {
+ const rect = element.getBoundingClientRect();
+ const viewportTop = window.visualViewport?.offsetTop || 0;
+ const viewportHeight = window.visualViewport?.height || window.innerHeight;
+ const viewportBottom = viewportTop + viewportHeight;
+ const visibleTop = Math.max(rect.top, viewportTop);
+ const visibleBottom = Math.min(rect.bottom, viewportBottom);
+ const visibleHeight = Math.max(0, visibleBottom - visibleTop);
+ const hitX = Math.min(window.innerWidth - 1, Math.max(0, rect.left + Math.min(rect.width / 2, 24)));
+ const hitY = Math.min(viewportBottom - 1, Math.max(viewportTop, visibleTop + (visibleHeight / 2)));
+ const hit = document.elementFromPoint(hitX, hitY);
+
+ return {
+ active: document.activeElement === element || element.contains(document.activeElement),
+ height: rect.height,
+ hitTarget: hit === element || element.contains(hit),
+ visibleHeight,
+ width: rect.width,
+ };
+ });
+
+ if (measurement.visibleHeight + 0.5 < Math.min(44, measurement.height)) {
+ throw new Error(`${label} is not sufficiently visible in the active viewport (${Math.round(measurement.visibleHeight)}px visible).`);
+ }
+ if (measurement.width + 0.5 < 44 || measurement.height + 0.5 < 44) {
+ throw new Error(`${label} is smaller than the mobile target floor (${Math.round(measurement.width)}x${Math.round(measurement.height)}).`);
+ }
+ if (!measurement.hitTarget) {
+ throw new Error(`${label} is covered by another element after scrolling into view.`);
+ }
+ if (focus && !measurement.active) {
+ throw new Error(`${label} did not retain focus under reduced-height viewport pressure.`);
+ }
+}
+
async function assertMobileTouchTargets(page, routeLabel) {
const violations = await page.evaluate(() => {
const controls = Array.from(document.querySelectorAll("button, input, select, textarea, [role='button']"));
@@ -1175,7 +1168,25 @@ async function assertInputIntentSizingMatrix(browser, baseUrl) {
try {
await page.goto(`${baseUrl}${audit.route}`, { waitUntil: "domcontentloaded" });
await page.waitForSelector("main", { timeout: 15_000 });
- await page.locator("[data-input-intent]").first().waitFor({ state: "attached", timeout: 15_000 });
+ await page.waitForFunction((requiredIntents) => {
+ const visibleIntents = new Set(
+ Array.from(document.querySelectorAll("[data-input-intent]"))
+ .filter((element) => {
+ const style = window.getComputedStyle(element);
+ const rect = element.getBoundingClientRect();
+ return (
+ style.display !== "none" &&
+ style.visibility !== "hidden" &&
+ Number(style.opacity) !== 0 &&
+ rect.width > 0 &&
+ rect.height > 0
+ );
+ })
+ .map((element) => element.getAttribute("data-input-intent"))
+ .filter(Boolean),
+ );
+ return requiredIntents.every((intent) => visibleIntents.has(intent));
+ }, audit.requiredIntents, { timeout: 15_000 });
const measurements = await page.evaluate(({ mobile }) => {
const isVisible = (element) => {
@@ -1495,6 +1506,80 @@ async function assertMobileTextDiffChecker(page) {
await page.getByText(/Modified/i).first().waitFor({ state: "visible", timeout: 15_000 });
}
+async function assertMobileQrCodeGenerator(page) {
+ const content = page.locator("#qr-content");
+ const payload = "https://byteflow.tools/mobile-qr-smoke";
+ await content.fill(payload);
+ await page.waitForFunction(() => {
+ const canvas = document.querySelector("canvas");
+ const pngButton = Array.from(document.querySelectorAll("button"))
+ .find((button) => button.textContent?.trim() === "PNG");
+ return canvas instanceof HTMLCanvasElement && canvas.width > 0 && pngButton && !pngButton.disabled;
+ }, null, { timeout: 15_000 });
+
+ await clickCopyAndExpectToast(
+ page,
+ page.getByRole("button", { name: /^Copy$/ }).first(),
+ "mobile QR data URL copy",
+ );
+
+ const downloadPromise = page.waitForEvent("download");
+ await page.getByRole("button", { name: "PNG", exact: true }).first().click();
+ await assertDownloadedFile(await downloadPromise, "qr-code.png", "png");
+}
+
+async function assertMobileCsvJsonConverter(page) {
+ const input = page.locator("textarea").first();
+ await input.waitFor({ state: "visible", timeout: 15_000 });
+ await input.fill("id,name\n1,Ada\n2,Lin");
+ await page.getByRole("button", { name: /^Convert$/ }).first().click();
+ await expectTextareaValue(page, /"name": "Ada"/, "mobile CSV to JSON output");
+
+ await clickCopyAndExpectToast(
+ page,
+ page.getByRole("button", { name: /^Copy$/ }).first(),
+ "mobile CSV converter output copy",
+ );
+
+ const downloadPromise = page.waitForEvent("download");
+ await page.getByRole("button", { name: /^Download$/ }).first().click();
+ await assertDownloadedFile(await downloadPromise, "converted.json", "json");
+}
+
+async function assertMobilePipelineBuilder(page) {
+ await page.getByRole("button", { name: /^Sample$/ }).first().click();
+
+ const stepsJump = page.locator('[data-pipeline-jump="steps"]');
+ await assertLocatorReachable(page, stepsJump, "Pipeline steps jump");
+ await stepsJump.click();
+ await page.waitForFunction(() => window.location.hash === "#pipeline-steps", null, { timeout: 5_000 });
+ await assertLocatorReachable(page, page.locator("#pipeline-steps"), "Pipeline steps section", { scroll: false });
+ await page.locator("#pipeline-steps button[aria-pressed]").first().click();
+
+ const inspectorJump = page.locator('[data-pipeline-jump="inspector"]');
+ await assertLocatorReachable(page, inspectorJump, "Pipeline inspector jump");
+ await inspectorJump.click();
+ await page.waitForFunction(() => window.location.hash === "#pipeline-inspector", null, { timeout: 5_000 });
+ await assertLocatorReachable(page, page.locator("#pipeline-inspector"), "Pipeline inspector section", { scroll: false });
+ await page.getByLabel("Step label").fill("Mobile JSON cleanup");
+
+ const inputOutputJump = page.locator('[data-pipeline-jump="input-output"]');
+ await assertLocatorReachable(page, inputOutputJump, "Pipeline input/output jump");
+ await inputOutputJump.click();
+ await page.waitForFunction(() => window.location.hash === "#pipeline-input-output", null, { timeout: 5_000 });
+ await assertLocatorReachable(page, page.locator("#pipeline-input-output"), "Pipeline input/output section", { scroll: false });
+ await page.getByLabel("Initial input").fill('{ "mobile": true, "ok": true }');
+
+ await page.getByRole("button", { name: /Run Recipe/i }).first().click();
+ await expectTextareaValue(page, /"mobile": true/, "mobile Pipeline output");
+ await page.getByText(/^OK$/).first().waitFor({ state: "visible", timeout: 15_000 });
+ await clickCopyAndExpectToast(
+ page,
+ page.getByRole("button", { name: /^Copy$/ }).first(),
+ "mobile Pipeline output copy",
+ );
+}
+
async function assertMobileToolPageJourneys(browser, baseUrl) {
const journeys = [
{ route: "/en/all-tools", run: assertMobileAllTools },
@@ -1507,6 +1592,9 @@ async function assertMobileToolPageJourneys(browser, baseUrl) {
{ route: "/en/image-resizer", run: assertMobileImageResizer },
{ route: "/en/json-diff-viewer", run: assertMobileJsonDiffViewer },
{ route: "/en/text-diff-checker", run: assertMobileTextDiffChecker },
+ { route: "/en/qr-code-generator", run: assertMobileQrCodeGenerator },
+ { route: "/en/csv-json-converter", run: assertMobileCsvJsonConverter },
+ { route: "/en/pipeline-builder", run: assertMobilePipelineBuilder },
];
for (const viewport of MOBILE_TOOL_VIEWPORTS) {
@@ -1527,6 +1615,73 @@ async function assertMobileToolPageJourneys(browser, baseUrl) {
}
}
+async function assertMobileViewportPressure(browser, baseUrl) {
+ const cases = [
+ {
+ route: "/en/qr-code-generator",
+ input: (page) => page.locator("#qr-content"),
+ primaryAction: (page) => page.getByRole("button", { name: "PNG", exact: true }).first(),
+ prepare: async (page) => {
+ await page.locator("#qr-content").fill("https://byteflow.tools/mobile-viewport-pressure");
+ await page.waitForFunction(() => {
+ const button = Array.from(document.querySelectorAll("button"))
+ .find((candidate) => candidate.textContent?.trim() === "PNG");
+ return Boolean(button && !button.disabled);
+ }, null, { timeout: 15_000 });
+ },
+ },
+ {
+ route: "/en/csv-json-converter",
+ input: (page) => page.locator("textarea").first(),
+ primaryAction: (page) => page.getByRole("button", { name: /^Convert$/ }).first(),
+ prepare: async (page) => {
+ await page.locator("textarea").first().fill("id,name\n1,Ada");
+ },
+ },
+ {
+ route: "/en/pipeline-builder",
+ input: (page) => page.getByLabel("Initial input"),
+ primaryAction: (page) => page.getByRole("button", { name: /Run Recipe/i }).first(),
+ prepare: async (page) => {
+ await page.getByRole("button", { name: /^Sample$/ }).first().click();
+ },
+ },
+ ];
+
+ for (const auditCase of cases) {
+ const context = await browser.newContext({
+ serviceWorkers: "block",
+ viewport: { width: 390, height: 844 },
+ isMobile: true,
+ });
+ const page = await context.newPage();
+ const runtime = createRuntimeObserver(page, `${auditCase.route} viewport pressure`, baseUrl);
+
+ try {
+ await page.goto(`${baseUrl}${auditCase.route}`, { waitUntil: "domcontentloaded" });
+ await page.waitForSelector("main", { timeout: 15_000 });
+ await auditCase.prepare(page);
+
+ const input = auditCase.input(page);
+ await input.focus();
+ await page.setViewportSize({ width: 390, height: 420 });
+ await page.waitForTimeout(100);
+ await assertLocatorReachable(page, input, `${auditCase.route} focused input at 390x420`, { focus: true });
+ await assertLocatorReachable(page, auditCase.primaryAction(page), `${auditCase.route} primary action at 390x420`);
+ await assertNoHorizontalOverflow(page, `${auditCase.route} reduced-height 390x420`);
+
+ await page.setViewportSize({ width: 844, height: 390 });
+ await page.waitForTimeout(100);
+ await assertNoHorizontalOverflow(page, `${auditCase.route} landscape 844x390`);
+ await assertLocatorReachable(page, auditCase.primaryAction(page), `${auditCase.route} primary action at 844x390`);
+ runtime.assertClean();
+ } finally {
+ await page.close();
+ await context.close();
+ }
+ }
+}
+
async function assertBase64PipelineSafeNavigationJourney(context, baseUrl) {
const page = await context.newPage();
const runtime = createRuntimeObserver(page, "Base64 -> Pipeline Builder safe navigation", baseUrl);
@@ -1876,14 +2031,18 @@ async function assertPwaShellJourney(browser, baseUrl) {
throw new Error(`Expected default manifest link to be /manifest.json, found ${manifestHref || "none"}`);
}
- const registration = await page.evaluate(async () => {
- if (!("serviceWorker" in navigator)) return null;
- const ready = await navigator.serviceWorker.ready;
- return {
- scope: ready.scope,
- activeScriptUrl: ready.active?.scriptURL || "",
- };
- });
+ const registration = await raceWithTimeout(
+ page.evaluate(async () => {
+ if (!("serviceWorker" in navigator)) return null;
+ const ready = await navigator.serviceWorker.ready;
+ return {
+ scope: ready.scope,
+ activeScriptUrl: ready.active?.scriptURL || "",
+ };
+ }),
+ PWA_SERVICE_WORKER_READY_TIMEOUT_MS,
+ `Timed out after ${PWA_SERVICE_WORKER_READY_TIMEOUT_MS}ms waiting for navigator.serviceWorker.ready at ${baseUrl}. Check /sw.js registration and browser errors.`,
+ );
if (!registration?.activeScriptUrl.endsWith("/sw.js")) {
throw new Error("Service worker did not become active for the PWA shell.");
}
@@ -2147,7 +2306,10 @@ async function runSmoke(baseUrl, { writeFirstLoadArtifacts = false } = {}) {
console.log("[playwright-smoke] PASS mobile journey: command palette -> /en/base64-encode-decode");
await assertMobileToolPageJourneys(browser, baseUrl);
- console.log("[playwright-smoke] PASS mobile journeys: JSON/Base64/JWT/Regex/Cron tool pages");
+ console.log("[playwright-smoke] PASS mobile journeys: core tools plus QR/CSV/Pipeline at 390x844 and 430x932");
+
+ await assertMobileViewportPressure(browser, baseUrl);
+ console.log("[playwright-smoke] PASS mobile viewport pressure: focused inputs and primary actions remain reachable");
await assertMobileReviewMatrix(browser, baseUrl);
console.log("[playwright-smoke] PASS mobile review matrix: no overflow or touch-target regressions");
@@ -2192,16 +2354,30 @@ async function runInputIntentSmoke(baseUrl) {
}
}
+async function runMobileSmoke(baseUrl) {
+ const browser = await chromium.launch({ headless: true });
+ try {
+ await assertMobileToolPageJourneys(browser, baseUrl);
+ await assertMobileViewportPressure(browser, baseUrl);
+ await assertMobileReviewMatrix(browser, baseUrl);
+ console.log("[playwright-smoke] PASS mobile-only: workflows, viewport pressure, touch targets, and overflow");
+ } finally {
+ await browser.close();
+ }
+}
+
async function main() {
const {
baseUrl,
firstLoadOnly,
includePwa,
inputIntentsOnly,
+ mobileOnly,
port,
+ pwaOnly,
skipServer,
writeFirstLoadArtifacts,
- } = parseArgs(process.argv.slice(2));
+ } = parsePlaywrightSmokeArgs(process.argv.slice(2), { defaultPort: DEFAULT_PORT });
let serverHandle = null;
try {
@@ -2211,17 +2387,21 @@ async function main() {
console.log(`[playwright-smoke] Static server ready at ${baseUrl}`);
}
- if (inputIntentsOnly) {
+ if (pwaOnly) {
+ await runPwaSmoke(baseUrl);
+ } else if (mobileOnly) {
+ await runMobileSmoke(baseUrl);
+ } else if (inputIntentsOnly) {
await runInputIntentSmoke(baseUrl);
} else if (firstLoadOnly) {
await runFirstLoadAudit(baseUrl, { writeArtifacts: writeFirstLoadArtifacts });
} else {
await runSmoke(baseUrl, { writeFirstLoadArtifacts });
}
- if (includePwa && !firstLoadOnly && !inputIntentsOnly) {
+ if (includePwa && !firstLoadOnly && !inputIntentsOnly && !mobileOnly && !pwaOnly) {
await runPwaSmoke(baseUrl);
}
- if (!firstLoadOnly && !inputIntentsOnly) {
+ if (!firstLoadOnly && !inputIntentsOnly && !mobileOnly && !pwaOnly) {
console.log("[playwright-smoke] PASS: critical routes render and navigate correctly");
}
} catch (error) {
@@ -2247,4 +2427,8 @@ async function main() {
}
}
-main();
+main().catch((error) => {
+ console.error("[playwright-smoke] FAILED");
+ console.error(error instanceof Error ? error.stack || error.message : String(error));
+ process.exitCode = 1;
+});
diff --git a/scripts/generators/generate-runtime-scripts.js b/scripts/generators/generate-runtime-scripts.js
index 75106b84..cf692860 100644
--- a/scripts/generators/generate-runtime-scripts.js
+++ b/scripts/generators/generate-runtime-scripts.js
@@ -10,6 +10,7 @@ const CHECK_ONLY = process.argv.includes("--check")
const I18N_SOURCE_PATH = path.join(ROOT, "src/core/i18n/i18n.ts")
const PWA_CONSTANTS_PATH = path.join(ROOT, "src/core/pwa/constants.ts")
+const PWA_INSTALL_PROMPT_STORE_PATH = path.join(ROOT, "src/core/pwa/install-prompt-store.ts")
const ROOT_LOCALE_REDIRECT_PATH = path.join(ROOT, "public/runtime/root-locale-redirect.js")
const THEME_MANIFEST_BOOTSTRAP_PATH = path.join(ROOT, "public/runtime/theme-manifest-bootstrap.js")
@@ -46,6 +47,7 @@ function parseLocales(source) {
function readRuntimeScriptConfig() {
const i18nSource = readSource(I18N_SOURCE_PATH)
const pwaSource = readSource(PWA_CONSTANTS_PATH)
+ const installPromptStoreSource = readSource(PWA_INSTALL_PROMPT_STORE_PATH)
const locales = parseLocales(i18nSource)
const defaultLocale = parseStringLiteral(i18nSource, "DEFAULT_LOCALE", I18N_SOURCE_PATH)
const darkThemeColor = parseStringLiteral(pwaSource, "PWA_THEME_COLOR", PWA_CONSTANTS_PATH)
@@ -60,6 +62,10 @@ function readRuntimeScriptConfig() {
defaultLocale,
darkThemeColor,
lightThemeColor,
+ installPromptBridgeReadySlot: parseStringLiteral(installPromptStoreSource, "PWA_INSTALL_PROMPT_BRIDGE_READY_SLOT", PWA_INSTALL_PROMPT_STORE_PATH),
+ installPromptChangeEvent: parseStringLiteral(installPromptStoreSource, "PWA_INSTALL_PROMPT_CHANGE_EVENT", PWA_INSTALL_PROMPT_STORE_PATH),
+ installPromptInstalledKey: parseStringLiteral(installPromptStoreSource, "PWA_INSTALL_INSTALLED_KEY", PWA_INSTALL_PROMPT_STORE_PATH),
+ installPromptSlot: parseStringLiteral(installPromptStoreSource, "PWA_INSTALL_PROMPT_SLOT", PWA_INSTALL_PROMPT_STORE_PATH),
}
}
@@ -131,8 +137,42 @@ function buildThemeManifestBootstrapScript(config) {
const defaultLocale = json(config.defaultLocale)
const lightThemeColor = json(config.lightThemeColor)
const darkThemeColor = json(config.darkThemeColor)
+ const installPromptBridgeReadySlot = json(config.installPromptBridgeReadySlot)
+ const installPromptChangeEvent = json(config.installPromptChangeEvent)
+ const installPromptInstalledKey = json(config.installPromptInstalledKey)
+ const installPromptSlot = json(config.installPromptSlot)
return `(function () {
+ var installPromptBridgeReadySlot = ${installPromptBridgeReadySlot};
+ var installPromptChangeEvent = ${installPromptChangeEvent};
+ var installPromptInstalledKey = ${installPromptInstalledKey};
+ var installPromptSlot = ${installPromptSlot};
+
+ function notifyInstallPromptChange() {
+ try {
+ window.dispatchEvent(new CustomEvent(installPromptChangeEvent));
+ } catch {}
+ }
+
+ if (!window[installPromptBridgeReadySlot]) {
+ window[installPromptBridgeReadySlot] = true;
+ window.addEventListener("beforeinstallprompt", function (event) {
+ event.preventDefault();
+ try {
+ localStorage.removeItem(installPromptInstalledKey);
+ } catch {}
+ window[installPromptSlot] = event;
+ notifyInstallPromptChange();
+ });
+ window.addEventListener("appinstalled", function () {
+ try {
+ localStorage.setItem(installPromptInstalledKey, "1");
+ } catch {}
+ window[installPromptSlot] = null;
+ notifyInstallPromptChange();
+ });
+ }
+
try {
var locales = ${locales};
var p = window.location.pathname || "/";
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index dd8a4df9..2edbb0e8 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -39,7 +39,7 @@ export default function RootLayout({
- {/* This tiny same-origin script must block first paint so saved light themes cannot flash dark. */}
+ {/* This same-origin bootstrap applies theme and owns pre-hydration PWA install events. */}
{/* eslint-disable-next-line @next/next/no-sync-scripts */}
diff --git a/src/components/layout/app-runtime.tsx b/src/components/layout/app-runtime.tsx
index b7b4909f..e366b2bc 100644
--- a/src/components/layout/app-runtime.tsx
+++ b/src/components/layout/app-runtime.tsx
@@ -1,18 +1,22 @@
"use client"
-import { useCallback, useEffect, useState } from "react"
+import { useCallback, useEffect, useRef, useState } from "react"
import Link from "next/link"
import { ArrowUp, Download, WifiOff } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { requireTranslationValue } from "@/core/i18n/i18n"
import { useLang } from "@/core/i18n/lang-provider"
+import {
+ PWA_INSTALL_INSTALLED_KEY,
+ PWA_INSTALL_SESSION_PROMPTED_KEY,
+ type BeforeInstallPromptEvent,
+} from "@/core/pwa/install-prompt-store"
+import { createPwaUpdateCoordinator } from "@/core/pwa/runtime-coordinator"
import { VerificationModePanel } from "./verification-mode-panel"
const PWA_INSTALL_VISIT_COUNT_KEY = "byteflow:pwa-install:visit-count"
const PWA_INSTALL_DISMISSED_UNTIL_KEY = "byteflow:pwa-install:dismissed-until"
-const PWA_INSTALL_SESSION_PROMPTED_KEY = "byteflow:pwa-install:session-prompted"
-const PWA_INSTALL_INSTALLED_KEY = "byteflow:pwa-install:installed"
const PWA_INSTALL_MIN_VISITS = 3
const PWA_INSTALL_DISMISS_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000
const SW_UPDATE_ENABLE_IN_DEV = process.env.NEXT_PUBLIC_ENABLE_SW_DEV === "1"
@@ -25,12 +29,10 @@ type PwaInstallCopy = {
later: string
}
-type BeforeInstallPromptEvent = Event & {
- prompt: () => Promise
- userChoice: Promise<{
- outcome: "accepted" | "dismissed"
- platform: string
- }>
+type AppRuntimeProps = {
+ pathname: string
+ capturedInstallPrompt?: BeforeInstallPromptEvent | null
+ onInstallPromptConsumed?: () => void
}
function isPwaInstalled(): boolean {
@@ -46,42 +48,54 @@ function isPwaInstalled(): boolean {
return standaloneDisplayMode || iosStandalone || persistedInstalledFlag
}
-export function AppRuntime({ pathname }: { pathname: string }) {
+function setPwaInstallStorageValue(key: string, value: string): void {
+ try {
+ window.localStorage.setItem(key, value)
+ } catch {
+ // Install and update controls must still complete when storage is denied.
+ }
+}
+
+function rememberInstallDismissal(): void {
+ setPwaInstallStorageValue(
+ PWA_INSTALL_DISMISSED_UNTIL_KEY,
+ String(Date.now() + PWA_INSTALL_DISMISS_COOLDOWN_MS),
+ )
+}
+
+export function AppRuntime({
+ pathname,
+ capturedInstallPrompt = null,
+ onInstallPromptConsumed,
+}: AppRuntimeProps) {
const { lang, t } = useLang()
- const [deferredInstallPrompt, setDeferredInstallPrompt] = useState(null)
const [showInstallPrompt, setShowInstallPrompt] = useState(false)
const [showBackToTop, setShowBackToTop] = useState(false)
const [isOffline, setIsOffline] = useState(false)
+ const installPromptShownInMemory = useRef(false)
const dismissInstallPrompt = useCallback(() => {
setShowInstallPrompt(false)
- try {
- const dismissedUntil = Date.now() + PWA_INSTALL_DISMISS_COOLDOWN_MS
- window.localStorage.setItem(PWA_INSTALL_DISMISSED_UNTIL_KEY, String(dismissedUntil))
- } catch {
- // Ignore storage errors and keep runtime-only dismissal.
- }
+ rememberInstallDismissal()
}, [])
const handleInstallNow = useCallback(async () => {
- if (!deferredInstallPrompt) return
+ if (!capturedInstallPrompt) return
setShowInstallPrompt(false)
try {
- await deferredInstallPrompt.prompt()
- const choice = await deferredInstallPrompt.userChoice
+ await capturedInstallPrompt.prompt()
+ const choice = await capturedInstallPrompt.userChoice
if (choice.outcome === "accepted") {
- window.localStorage.setItem(PWA_INSTALL_INSTALLED_KEY, "1")
+ setPwaInstallStorageValue(PWA_INSTALL_INSTALLED_KEY, "1")
} else {
- const dismissedUntil = Date.now() + PWA_INSTALL_DISMISS_COOLDOWN_MS
- window.localStorage.setItem(PWA_INSTALL_DISMISSED_UNTIL_KEY, String(dismissedUntil))
+ rememberInstallDismissal()
}
} catch {
- const dismissedUntil = Date.now() + PWA_INSTALL_DISMISS_COOLDOWN_MS
- window.localStorage.setItem(PWA_INSTALL_DISMISSED_UNTIL_KEY, String(dismissedUntil))
+ rememberInstallDismissal()
} finally {
- setDeferredInstallPrompt(null)
+ onInstallPromptConsumed?.()
}
- }, [deferredInstallPrompt])
+ }, [capturedInstallPrompt, onInstallPromptConsumed])
useEffect(() => {
const syncOnlineStatus = () => {
@@ -98,33 +112,26 @@ export function AppRuntime({ pathname }: { pathname: string }) {
}, [])
useEffect(() => {
- const handleBeforeInstallPrompt = (event: Event) => {
- if (isPwaInstalled()) return
- event.preventDefault()
- setDeferredInstallPrompt(event as BeforeInstallPromptEvent)
- }
-
const handleAppInstalled = () => {
- setDeferredInstallPrompt(null)
+ onInstallPromptConsumed?.()
setShowInstallPrompt(false)
- try {
- window.localStorage.setItem(PWA_INSTALL_INSTALLED_KEY, "1")
- } catch {
- // Ignore storage errors and rely on display-mode detection.
- }
+ setPwaInstallStorageValue(PWA_INSTALL_INSTALLED_KEY, "1")
}
- window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt as EventListener)
window.addEventListener("appinstalled", handleAppInstalled)
return () => {
- window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt as EventListener)
window.removeEventListener("appinstalled", handleAppInstalled)
}
- }, [])
+ }, [onInstallPromptConsumed])
useEffect(() => {
- if (!deferredInstallPrompt) return
- if (isPwaInstalled()) return
+ if (!capturedInstallPrompt) return
+ if (isPwaInstalled()) {
+ setShowInstallPrompt(false)
+ onInstallPromptConsumed?.()
+ return
+ }
+ if (installPromptShownInMemory.current) return
let visitCount = 0
try {
@@ -142,27 +149,25 @@ export function AppRuntime({ pathname }: { pathname: string }) {
if (window.sessionStorage.getItem(PWA_INSTALL_SESSION_PROMPTED_KEY) === "1") return
window.sessionStorage.setItem(PWA_INSTALL_SESSION_PROMPTED_KEY, "1")
+ installPromptShownInMemory.current = true
setShowInstallPrompt(true)
} catch {
- if (visitCount + 1 >= PWA_INSTALL_MIN_VISITS) {
- setShowInstallPrompt(true)
- }
+ installPromptShownInMemory.current = true
+ setShowInstallPrompt(true)
}
- }, [deferredInstallPrompt, pathname])
+ }, [capturedInstallPrompt, onInstallPromptConsumed, pathname])
useEffect(() => {
if (process.env.NODE_ENV !== "production" && !SW_UPDATE_ENABLE_IN_DEV) return
if (!("serviceWorker" in navigator)) return
let isActive = true
let updateInterval: NodeJS.Timeout | null = null
- let refreshing = false
let updateToastShown = false
let serviceWorkerRegistration: ServiceWorkerRegistration | null = null
+ const updateCoordinator = createPwaUpdateCoordinator(() => window.location.reload())
const handleControllerChange = () => {
- if (refreshing) return
- refreshing = true
- window.location.reload()
+ updateCoordinator.handleControllerChange()
}
navigator.serviceWorker.addEventListener("controllerchange", handleControllerChange)
@@ -174,7 +179,7 @@ export function AppRuntime({ pathname }: { pathname: string }) {
action: {
label: t.common.reload,
onClick: () => {
- registration.waiting?.postMessage({ type: "SKIP_WAITING" })
+ updateCoordinator.activateWaitingWorker(registration.waiting)
},
},
duration: Number.POSITIVE_INFINITY,
@@ -276,7 +281,7 @@ export function AppRuntime({ pathname }: { pathname: string }) {
) : null}
- {showInstallPrompt && deferredInstallPrompt ? (
+ {showInstallPrompt && capturedInstallPrompt ? (
{pwaInstallCopy.title}
{pwaInstallCopy.message}
diff --git a/src/components/layout/command-palette.tsx b/src/components/layout/command-palette.tsx
index 561b00bb..f186836d 100644
--- a/src/components/layout/command-palette.tsx
+++ b/src/components/layout/command-palette.tsx
@@ -21,6 +21,7 @@ import { getCommandSearchToolByKey } from "@/generated/command-search-index"
import { readFavoriteToolKeys, readRecentToolKeys, TOOL_DISCOVERY_UPDATED_EVENT } from "@/core/storage/tool-discovery-state"
import { useSystemCommands } from "@/core/commands/registry"
import { applyToolSearchScoreBonuses, scoreCommandSearch } from "@/core/search/command-search"
+import { useDialogReturnFocus } from "@/hooks/use-dialog-return-focus"
import {
getToolSearchMetadata,
getToolSearchMetadataTerms,
@@ -91,6 +92,7 @@ type CommandPaletteProps = {
open?: boolean
onOpenChange?: (open: boolean) => void
enableShortcut?: boolean
+ takeReturnFocusTarget?: () => HTMLElement | null
}
function isEditableShortcutTarget(target: EventTarget | null): boolean {
@@ -204,11 +206,17 @@ function ToolCommandRow({
)
}
-export function CommandPalette({ open: openProp, onOpenChange, enableShortcut = true }: CommandPaletteProps = {}) {
+export function CommandPalette({
+ open: openProp,
+ onOpenChange,
+ enableShortcut = true,
+ takeReturnFocusTarget,
+}: CommandPaletteProps = {}) {
const [internalOpen, setInternalOpen] = React.useState(false)
const [favoriteToolKeys, setFavoriteToolKeys] = React.useState
([])
const [recentToolKeys, setRecentToolKeys] = React.useState([])
const router = useRouter()
+ const { captureReturnFocus, restoreReturnFocus } = useDialogReturnFocus(takeReturnFocusTarget)
const { lang, t, englishToolSearchAliases } = useLang()
const commonLabels = t.common
const navigationLabel = requireTranslationValue(t.nav.navigation, "nav.navigation")
@@ -248,13 +256,14 @@ export function CommandPalette({ open: openProp, onOpenChange, enableShortcut =
if (e.defaultPrevented || isEditableShortcutTarget(e.target)) return
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
+ captureReturnFocus()
setOpen((prev) => !prev)
}
}
document.addEventListener("keydown", down)
return () => document.removeEventListener("keydown", down)
- }, [enableShortcut, setOpen])
+ }, [captureReturnFocus, enableShortcut, setOpen])
React.useEffect(() => {
const sync = () => {
@@ -379,6 +388,7 @@ export function CommandPalette({ open: openProp, onOpenChange, enableShortcut =
{
diff --git a/src/components/layout/deferred-app-runtime.tsx b/src/components/layout/deferred-app-runtime.tsx
index 503ebe0d..2d9b5089 100644
--- a/src/components/layout/deferred-app-runtime.tsx
+++ b/src/components/layout/deferred-app-runtime.tsx
@@ -1,6 +1,11 @@
"use client"
import dynamic from "next/dynamic"
+import { useCallback } from "react"
+import {
+ consumePwaInstallPrompt,
+ usePwaInstallPrompt,
+} from "@/core/pwa/install-prompt-store"
import { useDeferredMount } from "@/hooks/use-deferred-mount"
const AppRuntime = dynamic(
@@ -9,9 +14,20 @@ const AppRuntime = dynamic(
)
export function DeferredAppRuntime({ pathname }: { pathname: string }) {
+ const capturedInstallPrompt = usePwaInstallPrompt()
+ const clearCapturedInstallPrompt = useCallback(() => {
+ consumePwaInstallPrompt(capturedInstallPrompt)
+ }, [capturedInstallPrompt])
+
const isMounted = useDeferredMount({ delayMs: 1800, activateOnInteraction: true })
if (!isMounted) return null
- return
+ return (
+
+ )
}
diff --git a/src/components/layout/deferred-command-palette.tsx b/src/components/layout/deferred-command-palette.tsx
index bfa4b5d4..3c2028bb 100644
--- a/src/components/layout/deferred-command-palette.tsx
+++ b/src/components/layout/deferred-command-palette.tsx
@@ -25,9 +25,17 @@ function isEditableShortcutTarget(target: EventTarget | null): boolean {
export function DeferredCommandPalette() {
const [isMounted, setIsMounted] = React.useState(false)
const [open, setOpen] = React.useState(false)
+ const returnFocusRef = React.useRef(null)
+ const takeReturnFocusTarget = React.useCallback(() => {
+ const focusTarget = returnFocusRef.current
+ returnFocusRef.current = null
+ return focusTarget
+ }, [])
React.useEffect(() => {
- const activatePalette = () => {
+ const activatePalette = (focusTarget?: HTMLElement | null) => {
+ const activeElement = focusTarget ?? document.activeElement
+ returnFocusRef.current = activeElement instanceof HTMLElement ? activeElement : null
setIsMounted(true)
setOpen(true)
}
@@ -46,7 +54,7 @@ export function DeferredCommandPalette() {
if (!trigger) return
e.preventDefault()
- activatePalette()
+ activatePalette(trigger instanceof HTMLElement ? trigger : null)
}
document.addEventListener("keydown", down)
@@ -59,5 +67,12 @@ export function DeferredCommandPalette() {
if (!isMounted) return null
- return
+ return (
+
+ )
}
diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx
index a6dfb0dc..ed94a170 100644
--- a/src/components/ui/command.tsx
+++ b/src/components/ui/command.tsx
@@ -36,6 +36,7 @@ function CommandDialog({
className,
showCloseButton = true,
filter,
+ onCloseAutoFocus,
...props
}: React.ComponentProps & {
title?: string
@@ -43,12 +44,14 @@ function CommandDialog({
className?: string
showCloseButton?: boolean
filter?: React.ComponentProps["filter"]
+ onCloseAutoFocus?: React.ComponentProps["onCloseAutoFocus"]
}) {
return (