From 6b3e1a0428b3b9c6bf51627e3eb9def6a550b851 Mon Sep 17 00:00:00 2001 From: Rajan Singh Date: Tue, 15 Sep 2026 00:10:13 +0200 Subject: [PATCH] Keep both saves painted in side-by-side Boxes view - Fix two-pane layout rendering and add Android coverage - Persist and restore theme preference across reloads --- .../pksx/app/ControllerNavigationTest.java | 86 ++++++++++++++++++- src/app.html | 12 +++ src/lib/components/pksx/DetailRail.svelte | 3 +- src/lib/components/pksx/StorageSlot.svelte | 6 +- src/lib/components/pksx/ToastRegion.svelte | 3 +- src/lib/pksx/theme.svelte.ts | 14 ++- src/routes/+layout.svelte | 1 + src/routes/boxes-two-save.e2e.ts | 52 +++++++++++ src/routes/boxes/+page.svelte | 6 +- src/routes/layout.css | 6 +- src/routes/settings.e2e.ts | 14 +++ 11 files changed, 192 insertions(+), 11 deletions(-) create mode 100644 src/routes/boxes-two-save.e2e.ts diff --git a/android/app/src/androidTest/java/com/pksx/app/ControllerNavigationTest.java b/android/app/src/androidTest/java/com/pksx/app/ControllerNavigationTest.java index 7a211925..31963adb 100644 --- a/android/app/src/androidTest/java/com/pksx/app/ControllerNavigationTest.java +++ b/android/app/src/androidTest/java/com/pksx/app/ControllerNavigationTest.java @@ -5,6 +5,7 @@ import android.content.pm.ActivityInfo; import android.content.pm.PackageInfo; +import android.graphics.Bitmap; import android.graphics.Rect; import android.graphics.RectF; import android.os.Build; @@ -25,6 +26,7 @@ import androidx.test.platform.app.InstrumentationRegistry; import java.io.ByteArrayOutputStream; import java.io.InputStream; +import java.util.HashSet; import java.util.Locale; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -568,6 +570,41 @@ public void settingsReportsInstalledAppVersion() throws Exception { ); } + @Test + public void openingSecondSaveKeepsOriginalPanePainted() throws Exception { + try (NativeDisplayFixture fixture = new NativeDisplayFixture()) { + fixture.setViewport(360, 640, 1, false); + awaitControllerSurface(); + importEmeraldSave(); + importSave("x/011020252224.sav", "x.sav"); + runJavaScript( + "[...document.querySelectorAll('.save-card')]" + + ".find(card => card.textContent.includes('emerald.sav'))" + + ".querySelector('.card-main').click()" + ); + awaitJavaScript("document.querySelector('#box-0-slot-0')?.textContent.includes('ARON')"); + runJavaScript("document.querySelector('.source-chip').click()"); + awaitJavaScript("document.querySelector('[role=dialog][aria-label=\"Box Menu\"]')"); + runJavaScript("document.querySelector('#box-menu-command-3').click()"); + awaitJavaScript( + "document.querySelector('[role=dialog][aria-label=\"Open another collection\"]')" + ); + runJavaScript( + "[...document.querySelectorAll('[data-source-picker-control]')]" + + ".find(control => control.textContent.includes('x.sav')).click()" + ); + awaitJavaScript( + "document.querySelectorAll('.box-pane').length === 2" + + " && !document.querySelector('[data-pane-id=\"pane-active-save\"]')" + + ".hasAttribute('aria-busy')" + + " && document.querySelector('[data-pane-id=\"pane-active-save\"]')" + + ".textContent.includes('ARON')", + ENGINE_TIMEOUT_SECONDS + ); + assertPainted("[data-pane-id=\"pane-active-save\"] .location-grid"); + } + } + @Test public void nativeRotationPreservesBoxesCarryMenuAndTakeover() throws Throwable { assumeTrue("Requires Android 11 display controls", Build.VERSION.SDK_INT >= Build.VERSION_CODES.R); @@ -2206,21 +2243,64 @@ private void importEmeraldSave() throws Exception { awaitJavaScript( "location.pathname === '/' && document.querySelector('#save-file-input')" ); - String encoded = Base64.encodeToString(readAsset("emerald-011020251345.sav"), Base64.NO_WRAP); + importSave("emerald-011020251345.sav", "emerald.sav"); + } + + private void importSave(String assetName, String fileName) throws Exception { + String encoded = Base64.encodeToString(readAsset(assetName), Base64.NO_WRAP); runJavaScript( "(() => { const bytes = Uint8Array.from(atob('" + encoded + "'), value => value.charCodeAt(0)); const transfer = new DataTransfer();" - + " transfer.items.add(new File([bytes], 'emerald.sav'));" + + " transfer.items.add(new File([bytes], '" + + fileName + + "'));" + " const input = document.querySelector('#save-file-input'); input.files = transfer.files;" + " input.dispatchEvent(new Event('change', { bubbles: true })); return true; })()" ); awaitJavaScript( - "document.body.textContent.includes('emerald.sav imported and made active.')", + "document.body.textContent.includes('" + + fileName + + " imported and made active.')", ENGINE_TIMEOUT_SECONDS ); } + private void assertPainted(String selector) throws Exception { + long deadline = SystemClock.uptimeMillis() + TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS); + if (!awaitNextVisualState(deadline)) fail("Timed out waiting for WebView paint"); + JSONObject bounds = new JSONObject( + runJavaScript( + "(() => { const rect=document.querySelector('" + + selector + + "').getBoundingClientRect();" + + " return {left:rect.left,top:rect.top,right:rect.right,bottom:rect.bottom}; })()" + ) + ); + AtomicReference origin = new AtomicReference<>(); + activityRule + .getScenario() + .onActivity( + activity -> { + int[] value = new int[2]; + activity.getBridge().getWebView().getLocationOnScreen(value); + origin.set(value); + } + ); + Bitmap screenshot = InstrumentationRegistry.getInstrumentation().getUiAutomation().takeScreenshot(); + int[] webViewOrigin = origin.get(); + HashSet colors = new HashSet<>(); + int left = webViewOrigin[0] + bounds.getInt("left") + 4; + int top = webViewOrigin[1] + bounds.getInt("top") + 4; + int right = webViewOrigin[0] + bounds.getInt("right") - 4; + int bottom = webViewOrigin[1] + bounds.getInt("bottom") - 4; + for (int y = top; y < bottom; y += 4) { + for (int x = left; x < right; x += 4) colors.add(screenshot.getPixel(x, y)); + } + screenshot.recycle(); + if (colors.size() < 24) fail("Save pane was not painted, sampled colors: " + colors.size()); + } + private byte[] readAsset(String name) throws Exception { try ( InputStream input = InstrumentationRegistry diff --git a/src/app.html b/src/app.html index 4ac41776..7d04c8a5 100644 --- a/src/app.html +++ b/src/app.html @@ -3,6 +3,18 @@ + diff --git a/src/lib/components/pksx/DetailRail.svelte b/src/lib/components/pksx/DetailRail.svelte index 796e77c7..c77e7459 100644 --- a/src/lib/components/pksx/DetailRail.svelte +++ b/src/lib/components/pksx/DetailRail.svelte @@ -253,7 +253,8 @@ var(--paper); } - :global(.app-shell.dark) .portrait-card { + :global(.app-shell.dark) .portrait-card, + :global(:root[data-pksx-theme='dark']) .portrait-card { background: repeating-radial-gradient( circle at 30% 35%, diff --git a/src/lib/components/pksx/StorageSlot.svelte b/src/lib/components/pksx/StorageSlot.svelte index f852126b..408a6327 100644 --- a/src/lib/components/pksx/StorageSlot.svelte +++ b/src/lib/components/pksx/StorageSlot.svelte @@ -145,7 +145,8 @@ ); } - :global(.app-shell.dark) .slot { + :global(.app-shell.dark) .slot, + :global(:root[data-pksx-theme='dark']) .slot { --slot-fill: color-mix( in oklch, var(--paper-hi) 84%, @@ -158,7 +159,8 @@ ); } - :global(.app-shell.dark) .slot.dual-type { + :global(.app-shell.dark) .slot.dual-type, + :global(:root[data-pksx-theme='dark']) .slot.dual-type { background: linear-gradient( 135deg, var(--slot-fill) 0%, diff --git a/src/lib/components/pksx/ToastRegion.svelte b/src/lib/components/pksx/ToastRegion.svelte index 9e4548ce..02be39e6 100644 --- a/src/lib/components/pksx/ToastRegion.svelte +++ b/src/lib/components/pksx/ToastRegion.svelte @@ -73,7 +73,8 @@ will-change: opacity, transform; } - :global(.app-shell.dark) .toast { + :global(.app-shell.dark) .toast, + :global(:root[data-pksx-theme='dark']) .toast { background: color-mix(in srgb, var(--paper-hi), white 4%); border-color: color-mix(in srgb, white, transparent 88%); } diff --git a/src/lib/pksx/theme.svelte.ts b/src/lib/pksx/theme.svelte.ts index 06f912b4..f330707c 100644 --- a/src/lib/pksx/theme.svelte.ts +++ b/src/lib/pksx/theme.svelte.ts @@ -1,12 +1,24 @@ +const storageKey = 'pksx-theme'; + class ThemeState { dark = $state(false); + restore() { + this.dark = document.documentElement.dataset.pksxTheme === 'dark'; + } + setDark(dark: boolean) { this.dark = dark; + document.documentElement.dataset.pksxTheme = dark ? 'dark' : 'light'; + try { + localStorage.setItem(storageKey, dark ? 'dark' : 'light'); + } catch { + // Keep the in-memory preference when storage is unavailable. + } } toggle() { - this.dark = !this.dark; + this.setDark(!this.dark); } } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 893fc685..0ecdda79 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -160,6 +160,7 @@ }); onMount(() => { + theme.restore(); window.addEventListener('keydown', handleRootKeydown, true); const backListener = Capacitor.isNativePlatform() ? CapacitorApp.addListener('backButton', ({ canGoBack }) => handlePlatformBack(canGoBack)) diff --git a/src/routes/boxes-two-save.e2e.ts b/src/routes/boxes-two-save.e2e.ts new file mode 100644 index 00000000..98b5e2dd --- /dev/null +++ b/src/routes/boxes-two-save.e2e.ts @@ -0,0 +1,52 @@ +import { expect, test } from '@playwright/test'; +import path from 'node:path'; + +const emeraldFixturePath = path.resolve( + 'test-fixtures/save-files/bl1ndbeholder-pokemon-saves/emerald-011020251345.sav' +); +const xFixturePath = path.resolve( + 'test-fixtures/save-files/bl1ndbeholder-pokemon-saves/x/011020252224.sav' +); + +test('keeps the original save visible after opening a second save on Android landscape', async ({ + page +}) => { + await page.setViewportSize({ width: 640, height: 360 }); + await page.goto('/'); + await expect(page.locator('[data-destination-root="saves"]')).toHaveAttribute( + 'data-initial-state', + 'ready' + ); + + const importInput = page.getByLabel('Import Save File'); + await importInput.setInputFiles(emeraldFixturePath); + await expect(page.getByText('011020251345.sav imported and made active.')).toBeVisible({ + timeout: 30_000 + }); + await importInput.setInputFiles(xFixturePath); + await expect(page.getByText('011020252224.sav imported and made active.')).toBeVisible({ + timeout: 30_000 + }); + + await page.getByRole('button', { name: 'Open emerald-011020251345.sav in Boxes' }).click(); + await expect(page.locator('[data-destination-root="boxes"]')).toHaveAttribute( + 'data-initial-state', + 'ready', + { timeout: 30_000 } + ); + await page.getByRole('button', { name: 'Open Box Menu for emerald-011020251345.sav' }).click(); + await page + .getByRole('dialog', { name: 'Box Menu' }) + .getByRole('button', { name: 'Open another' }) + .click(); + await page + .getByRole('dialog', { name: 'Open another collection' }) + .getByRole('button', { name: /011020252224\.sav/ }) + .click(); + + const panes = page.locator('.box-pane'); + await expect(panes).toHaveCount(2); + await expect(panes.nth(1)).not.toHaveAttribute('aria-busy', 'true', { timeout: 30_000 }); + await expect(panes.nth(0).locator('.slot.pokemon').first()).toBeVisible(); + await expect(panes.nth(1).getByRole('grid')).toBeVisible(); +}); diff --git a/src/routes/boxes/+page.svelte b/src/routes/boxes/+page.svelte index 8f17ec47..dc015e1b 100644 --- a/src/routes/boxes/+page.svelte +++ b/src/routes/boxes/+page.svelte @@ -5445,7 +5445,11 @@ } .two-pane .box-pane-strip { - display: contents; + grid-area: 1 / 1 / -1 / -1; + display: grid; + grid-template-columns: subgrid; + grid-template-rows: subgrid; + overflow: visible; } .two-pane .box-pane:first-child { diff --git a/src/routes/layout.css b/src/routes/layout.css index dead66c6..85cae3fd 100644 --- a/src/routes/layout.css +++ b/src/routes/layout.css @@ -214,7 +214,8 @@ --pksx-height-band: tall; } -:root:has(.app-shell.dark) { +:root:has(.app-shell.dark), +:root[data-pksx-theme='dark'] { --pksx-color-surface-canvas: #0e0f12; --pksx-color-surface-panel: #181a1e; --pksx-color-surface-subtle: #08090b; @@ -371,7 +372,8 @@ html[data-pksx-route-direction='backward']::view-transition-new(pksx-route) { } } -.app-shell.dark { +.app-shell.dark, +:root[data-pksx-theme='dark'] .app-shell { background: radial-gradient(circle at 20% 10%, rgba(255, 138, 92, 0.04), transparent 50%), radial-gradient(circle at 80% 90%, rgba(120, 140, 180, 0.04), transparent 55%), var(--paper); diff --git a/src/routes/settings.e2e.ts b/src/routes/settings.e2e.ts index c0f58bac..f176f6a7 100644 --- a/src/routes/settings.e2e.ts +++ b/src/routes/settings.e2e.ts @@ -324,6 +324,20 @@ test('Settings owns floor overflow and uses shared theme state', async ({ page } 'aria-pressed', 'true' ); + await page.reload(); + await expect(page.locator('.app-shell')).toHaveClass(/dark/); +}); + +test('Settings uses the system theme until the user chooses an override', async ({ page }) => { + await page.emulateMedia({ colorScheme: 'dark' }); + await openSettings(page); + await expect(page.locator('html')).toHaveAttribute('data-pksx-theme', 'dark'); + await expect(page.locator('.app-shell')).toHaveClass(/dark/); + + await page.getByRole('button', { name: 'Use light theme' }).click(); + await page.reload(); + await expect(page.locator('html')).toHaveAttribute('data-pksx-theme', 'light'); + await expect(page.locator('.app-shell')).not.toHaveClass(/dark/); }); test('raw viewport height selects the inherited Height Band at 560px', async ({ page }) => {