From 160694bb45cbc2719167aae2fe833ea7fa180b84 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Sat, 15 Aug 2026 07:29:42 +0800 Subject: [PATCH 1/2] Split pure helpers out of component modules preloadZapLogo/drawPreloadedZapLogo move from images.tsx into lib/zapLogo.ts, and isWriteModalOpen moves from WriteModal.tsx into writeNfcHook.tsx. Both were pure functions living in modules that also export React components, so route files that only wanted the helper pulled the component graph in with it. Tests that mocked writeNfcHook wholesale now spread importOriginal() so the enums and isWriteModalOpen stay real. Claude-Session: https://claude.ai/code/session_01MevSjtLDnHofR1Eub9vKGN --- src/App.tsx | 3 +- .../integration/create-mappings-edit.test.tsx | 12 +---- .../integration/index-route.test.tsx | 15 +----- src/__tests__/unit/lib/images.test.ts | 7 +-- .../unit/routes/create.custom.test.tsx | 18 ++----- .../unit/routes/create.index.test.tsx | 18 ++----- src/__tests__/unit/routes/create.nfc.test.tsx | 14 +----- src/components/WriteModal.tsx | 8 --- src/lib/images.tsx | 50 +++---------------- src/lib/writeNfcHook.tsx | 8 +++ src/lib/zapLogo.ts | 43 ++++++++++++++++ src/routes/-pages/Index.tsx | 8 ++- src/routes/-pages/MappingEditor.tsx | 9 +++- src/routes/create.custom.tsx | 9 +++- src/routes/create.index.tsx | 9 +++- src/routes/create.nfc.tsx | 9 +++- src/routes/index.tsx | 2 +- 17 files changed, 110 insertions(+), 132 deletions(-) create mode 100644 src/lib/zapLogo.ts diff --git a/src/App.tsx b/src/App.tsx index 8831cfc..93023d3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,7 +23,8 @@ import { } from "@/lib/purchasesSetup"; import { routeTree } from "./routeTree.gen"; import { useStatusStore } from "./lib/store"; -import { DatabaseIcon, PlayIcon, preloadZapLogo } from "./lib/images"; +import { DatabaseIcon, PlayIcon } from "./lib/images"; +import { preloadZapLogo } from "./lib/zapLogo"; import { ConnectionProvider } from "./components/ConnectionProvider"; import { ReconnectingIndicator } from "./components/ReconnectingIndicator"; import { MediaFinishedToast } from "./components/MediaFinishedToast.tsx"; diff --git a/src/__tests__/integration/create-mappings-edit.test.tsx b/src/__tests__/integration/create-mappings-edit.test.tsx index 185a225..85cf212 100644 --- a/src/__tests__/integration/create-mappings-edit.test.tsx +++ b/src/__tests__/integration/create-mappings-edit.test.tsx @@ -144,17 +144,9 @@ vi.mock("react-hot-toast", () => ({ }, })); -vi.mock("@/lib/writeNfcHook", () => ({ +vi.mock("@/lib/writeNfcHook", async (importOriginal) => ({ + ...(await importOriginal()), useNfcWriter: () => mockNfcWriter, - WriteMethod: { - Auto: "auto", - LocalNFC: "local", - RemoteReader: "remote", - }, - WriteAction: { - Write: "write", - Read: "read", - }, })); vi.mock("@capacitor-mlkit/barcode-scanning", () => ({ diff --git a/src/__tests__/integration/index-route.test.tsx b/src/__tests__/integration/index-route.test.tsx index 022e19b..d9556bd 100644 --- a/src/__tests__/integration/index-route.test.tsx +++ b/src/__tests__/integration/index-route.test.tsx @@ -116,20 +116,9 @@ vi.mock("@/hooks/useScanOperations", () => ({ })); // Mock useNfcWriter -vi.mock("@/lib/writeNfcHook", () => ({ +vi.mock("@/lib/writeNfcHook", async (importOriginal) => ({ + ...(await importOriginal()), useNfcWriter: vi.fn(() => mockNfcWriterState), - WriteMethod: { - Auto: "auto", - LocalNFC: "local", - RemoteReader: "remote", - }, - WriteAction: { - Write: "write", - Read: "read", - Format: "format", - Erase: "erase", - MakeReadOnly: "makeReadOnly", - }, })); // Use vi.hoisted for pro purchase mock diff --git a/src/__tests__/unit/lib/images.test.ts b/src/__tests__/unit/lib/images.test.ts index 60d718c..c8fcad8 100644 --- a/src/__tests__/unit/lib/images.test.ts +++ b/src/__tests__/unit/lib/images.test.ts @@ -34,7 +34,7 @@ describe("Zap logo preload", () => { } vi.stubGlobal("Image", MockImage); - const { preloadZapLogo, ZAP_LOGO_URL } = await import("@/lib/images"); + const { preloadZapLogo, ZAP_LOGO_URL } = await import("@/lib/zapLogo"); const first = preloadZapLogo(); const second = preloadZapLogo(); @@ -84,7 +84,8 @@ describe("Zap logo preload", () => { vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue( context as unknown as CanvasRenderingContext2D, ); - const { preloadZapLogo, ZapLogo } = await import("@/lib/images"); + const { preloadZapLogo } = await import("@/lib/zapLogo"); + const { ZapLogo } = await import("@/lib/images"); await preloadZapLogo(); render(createElement(ZapLogo)); @@ -140,7 +141,7 @@ describe("Zap logo preload", () => { } vi.stubGlobal("Image", MockImage); - const { preloadZapLogo } = await import("@/lib/images"); + const { preloadZapLogo } = await import("@/lib/zapLogo"); await expect(preloadZapLogo()).resolves.toBeUndefined(); }); diff --git a/src/__tests__/unit/routes/create.custom.test.tsx b/src/__tests__/unit/routes/create.custom.test.tsx index 803c5a3..059855a 100644 --- a/src/__tests__/unit/routes/create.custom.test.tsx +++ b/src/__tests__/unit/routes/create.custom.test.tsx @@ -59,18 +59,10 @@ vi.mock("@/lib/preferencesStore", () => ({ }), })); -// Mock NFC writer -vi.mock("@/lib/writeNfcHook", () => ({ +// Mock NFC writer, keeping the real enums and isWriteModalOpen +vi.mock("@/lib/writeNfcHook", async (importOriginal) => ({ + ...(await importOriginal()), useNfcWriter: () => mockNfcWriter, - WriteMethod: { - Auto: "auto", - LocalNFC: "local", - RemoteReader: "remote", - }, - WriteAction: { - Write: "write", - Read: "read", - }, })); // Mock hooks @@ -102,10 +94,6 @@ vi.mock("@/components/ZapScriptInput.tsx", () => ({ // Mock WriteModal to simplify testing vi.mock("@/components/WriteModal", () => ({ - isWriteModalOpen: ( - writeIntent: boolean, - writer: { status: unknown; verifyError: unknown }, - ) => writeIntent && (writer.status === null || writer.verifyError !== null), WriteModal: ({ isOpen }: { isOpen: boolean }) => isOpen ?
Write Modal
: null, })); diff --git a/src/__tests__/unit/routes/create.index.test.tsx b/src/__tests__/unit/routes/create.index.test.tsx index d6396ad..dff7926 100644 --- a/src/__tests__/unit/routes/create.index.test.tsx +++ b/src/__tests__/unit/routes/create.index.test.tsx @@ -114,18 +114,10 @@ vi.mock("@/lib/toastUtils", () => ({ showRateLimitedErrorToast: mockShowRateLimitedErrorToast, })); -// Mock NFC writer -vi.mock("@/lib/writeNfcHook", () => ({ +// Mock NFC writer, keeping the real enums and isWriteModalOpen +vi.mock("@/lib/writeNfcHook", async (importOriginal) => ({ + ...(await importOriginal()), useNfcWriter: () => mockNfcWriter, - WriteMethod: { - Auto: "auto", - LocalNFC: "local", - RemoteReader: "remote", - }, - WriteAction: { - Write: "write", - Read: "read", - }, })); // Mock Capacitor @@ -143,10 +135,6 @@ vi.mock("@/hooks/usePageHeadingFocus", () => ({ // Mock WriteModal to simplify testing vi.mock("@/components/WriteModal", () => ({ - isWriteModalOpen: ( - writeIntent: boolean, - writer: { status: unknown; verifyError: unknown }, - ) => writeIntent && (writer.status === null || writer.verifyError !== null), WriteModal: ({ isOpen, close }: { isOpen: boolean; close: () => void }) => isOpen ? (
diff --git a/src/__tests__/unit/routes/create.nfc.test.tsx b/src/__tests__/unit/routes/create.nfc.test.tsx index de4cd90..3665980 100644 --- a/src/__tests__/unit/routes/create.nfc.test.tsx +++ b/src/__tests__/unit/routes/create.nfc.test.tsx @@ -38,19 +38,9 @@ vi.mock("@tanstack/react-router", async (importOriginal) => { }); // Mock NFC writer -vi.mock("@/lib/writeNfcHook", () => ({ +vi.mock("@/lib/writeNfcHook", async (importOriginal) => ({ + ...(await importOriginal()), useNfcWriter: () => ({ ...mockNfcWriter }), - WriteMethod: { - Auto: "auto", - LocalNFC: "local", - RemoteReader: "remote", - }, - WriteAction: { - Write: "write", - Read: "read", - Format: "format", - Erase: "erase", - }, })); // Mock hooks diff --git a/src/components/WriteModal.tsx b/src/components/WriteModal.tsx index ff59b81..b1e419b 100644 --- a/src/components/WriteModal.tsx +++ b/src/components/WriteModal.tsx @@ -4,18 +4,10 @@ import { useSmartSwipe } from "@/hooks/useSmartSwipe"; import { useBackButtonHandler } from "@/hooks/useBackButtonHandler"; import { useFocusTrap } from "@/hooks/useFocusTrap"; import { ScanResult } from "@/lib/models"; -import type { WriteNfcHook } from "@/lib/writeNfcHook"; import { ScanSpinner } from "./ScanSpinner"; import { Button } from "./wui/Button"; import { useAnnouncer } from "./A11yAnnouncer"; -export function isWriteModalOpen( - writeIntent: boolean, - writer: Pick, -): boolean { - return writeIntent && (writer.status === null || writer.verifyError !== null); -} - export function WriteModal(props: { isOpen: boolean; close: () => void; diff --git a/src/lib/images.tsx b/src/lib/images.tsx index 1e8b52f..f13eae6 100644 --- a/src/lib/images.tsx +++ b/src/lib/images.tsx @@ -1,49 +1,11 @@ import { useLayoutEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; - -export const ZAP_LOGO_URL = `${__APP_BASE_PATH__}lockup.webp`; -export const ZAP_LOGO_WIDTH = 160; -export const ZAP_LOGO_HEIGHT = 36; - -let zapLogoImage: HTMLImageElement | null = null; -let zapLogoReady: Promise | null = null; - -export function preloadZapLogo(): Promise { - if (typeof Image === "undefined") return Promise.resolve(); - - if (!zapLogoReady) { - zapLogoImage = new Image(ZAP_LOGO_WIDTH, ZAP_LOGO_HEIGHT); - zapLogoImage.decoding = "sync"; - zapLogoImage.fetchPriority = "high"; - zapLogoImage.src = ZAP_LOGO_URL; - zapLogoReady = - typeof zapLogoImage.decode === "function" - ? zapLogoImage.decode().catch(() => undefined) - : Promise.resolve(); - } - - return zapLogoReady; -} - -function drawPreloadedZapLogo( - canvas: HTMLCanvasElement, - pixelRatio = window.devicePixelRatio || 1, -): boolean { - if (!zapLogoImage?.complete || zapLogoImage.naturalWidth === 0) return false; - - const context = canvas.getContext("2d"); - if (!context) return false; - - const scale = Math.max(1, pixelRatio); - canvas.width = Math.round(ZAP_LOGO_WIDTH * scale); - canvas.height = Math.round(ZAP_LOGO_HEIGHT * scale); - context.setTransform(scale, 0, 0, scale, 0, 0); - context.imageSmoothingEnabled = true; - context.imageSmoothingQuality = "high"; - context.clearRect(0, 0, ZAP_LOGO_WIDTH, ZAP_LOGO_HEIGHT); - context.drawImage(zapLogoImage, 0, 0, ZAP_LOGO_WIDTH, ZAP_LOGO_HEIGHT); - return true; -} +import { + drawPreloadedZapLogo, + preloadZapLogo, + ZAP_LOGO_HEIGHT, + ZAP_LOGO_WIDTH, +} from "./zapLogo"; export function ZapLogo() { const { t } = useTranslation(); diff --git a/src/lib/writeNfcHook.tsx b/src/lib/writeNfcHook.tsx index aa8653d..1345812 100644 --- a/src/lib/writeNfcHook.tsx +++ b/src/lib/writeNfcHook.tsx @@ -41,6 +41,14 @@ export interface WriteNfcHook { getVerifyError: () => NfcVerificationError | null; } +/** The write modal stays open until a write settles, or while a verify failed. */ +export function isWriteModalOpen( + writeIntent: boolean, + writer: Pick, +): boolean { + return writeIntent && (writer.status === null || writer.verifyError !== null); +} + export enum WriteMethod { Auto = "auto", LocalNFC = "local", diff --git a/src/lib/zapLogo.ts b/src/lib/zapLogo.ts new file mode 100644 index 0000000..81d0e3d --- /dev/null +++ b/src/lib/zapLogo.ts @@ -0,0 +1,43 @@ +export const ZAP_LOGO_URL = `${__APP_BASE_PATH__}lockup.webp`; +export const ZAP_LOGO_WIDTH = 160; +export const ZAP_LOGO_HEIGHT = 36; + +let zapLogoImage: HTMLImageElement | null = null; +let zapLogoReady: Promise | null = null; + +export function preloadZapLogo(): Promise { + if (typeof Image === "undefined") return Promise.resolve(); + + if (!zapLogoReady) { + zapLogoImage = new Image(ZAP_LOGO_WIDTH, ZAP_LOGO_HEIGHT); + zapLogoImage.decoding = "sync"; + zapLogoImage.fetchPriority = "high"; + zapLogoImage.src = ZAP_LOGO_URL; + zapLogoReady = + typeof zapLogoImage.decode === "function" + ? zapLogoImage.decode().catch(() => undefined) + : Promise.resolve(); + } + + return zapLogoReady; +} + +export function drawPreloadedZapLogo( + canvas: HTMLCanvasElement, + pixelRatio = window.devicePixelRatio || 1, +): boolean { + if (!zapLogoImage?.complete || zapLogoImage.naturalWidth === 0) return false; + + const context = canvas.getContext("2d"); + if (!context) return false; + + const scale = Math.max(1, pixelRatio); + canvas.width = Math.round(ZAP_LOGO_WIDTH * scale); + canvas.height = Math.round(ZAP_LOGO_HEIGHT * scale); + context.setTransform(scale, 0, 0, scale, 0, 0); + context.imageSmoothingEnabled = true; + context.imageSmoothingQuality = "high"; + context.clearRect(0, 0, ZAP_LOGO_WIDTH, ZAP_LOGO_HEIGHT); + context.drawImage(zapLogoImage, 0, 0, ZAP_LOGO_WIDTH, ZAP_LOGO_HEIGHT); + return true; +} diff --git a/src/routes/-pages/Index.tsx b/src/routes/-pages/Index.tsx index 7f45a12..751fd0a 100644 --- a/src/routes/-pages/Index.tsx +++ b/src/routes/-pages/Index.tsx @@ -4,9 +4,13 @@ import { useTranslation } from "react-i18next"; import { Capacitor } from "@capacitor/core"; import { logger } from "@/lib/logger"; import { showRateLimitedErrorToast } from "@/lib/toastUtils"; -import { useNfcWriter, WriteMethod } from "@/lib/writeNfcHook.tsx"; +import { + isWriteModalOpen, + useNfcWriter, + WriteMethod, +} from "@/lib/writeNfcHook.tsx"; import { useProPurchase } from "@/components/ProPurchase.tsx"; -import { isWriteModalOpen, WriteModal } from "@/components/WriteModal.tsx"; +import { WriteModal } from "@/components/WriteModal.tsx"; import { useAnnouncer } from "@/components/A11yAnnouncer"; import { cancelSession } from "@/lib/nfc"; import { CoreAPI } from "@/lib/coreApi"; diff --git a/src/routes/-pages/MappingEditor.tsx b/src/routes/-pages/MappingEditor.tsx index f9ca699..5f713a3 100644 --- a/src/routes/-pages/MappingEditor.tsx +++ b/src/routes/-pages/MappingEditor.tsx @@ -22,9 +22,14 @@ import { import { Button } from "@/components/wui/Button"; import { PageFrame } from "@/components/PageFrame"; import { SlideModal } from "@/components/SlideModal"; -import { useNfcWriter, WriteAction, WriteMethod } from "@/lib/writeNfcHook"; +import { + isWriteModalOpen, + useNfcWriter, + WriteAction, + WriteMethod, +} from "@/lib/writeNfcHook"; import { usePreferencesStore } from "@/lib/preferencesStore"; -import { isWriteModalOpen, WriteModal } from "@/components/WriteModal"; +import { WriteModal } from "@/components/WriteModal"; import { useSmartSwipe } from "@/hooks/useSmartSwipe"; import { usePageHeadingFocus } from "@/hooks/usePageHeadingFocus"; import { appBackNavigationOptions } from "@/lib/tabSessionStore"; diff --git a/src/routes/create.custom.tsx b/src/routes/create.custom.tsx index 421067e..9445303 100644 --- a/src/routes/create.custom.tsx +++ b/src/routes/create.custom.tsx @@ -7,8 +7,13 @@ import { BackIcon, CreateIcon } from "@/lib/images"; import { HeaderButton } from "@/components/wui/HeaderButton"; import { Button } from "@/components/wui/Button"; import { useSmartSwipe } from "@/hooks/useSmartSwipe"; -import { isWriteModalOpen, WriteModal } from "@/components/WriteModal"; -import { useNfcWriter, WriteAction, WriteMethod } from "@/lib/writeNfcHook"; +import { WriteModal } from "@/components/WriteModal"; +import { + isWriteModalOpen, + useNfcWriter, + WriteAction, + WriteMethod, +} from "@/lib/writeNfcHook"; import { logger } from "@/lib/logger"; import { PageFrame } from "@/components/PageFrame"; import { usePreferencesStore, selectCustomText } from "@/lib/preferencesStore"; diff --git a/src/routes/create.index.tsx b/src/routes/create.index.tsx index fdafac1..37ec169 100644 --- a/src/routes/create.index.tsx +++ b/src/routes/create.index.tsx @@ -7,7 +7,12 @@ import { ListPlusIcon, NfcIcon } from "lucide-react"; import { usePageHeadingFocus } from "@/hooks/usePageHeadingFocus"; import { NextIcon, PlayIcon, SearchIcon, TextIcon } from "@/lib/images"; import { useStatusStore } from "@/lib/store"; -import { useNfcWriter, WriteAction, WriteMethod } from "@/lib/writeNfcHook"; +import { + isWriteModalOpen, + useNfcWriter, + WriteAction, + WriteMethod, +} from "@/lib/writeNfcHook"; import { CoreAPI } from "@/lib/coreApi"; import { isCoreFeatureAvailable } from "@/lib/featureGates"; import { logger } from "@/lib/logger"; @@ -15,7 +20,7 @@ import { showRateLimitedErrorToast } from "@/lib/toastUtils"; import type { PlayingResponse, SearchResultGame } from "@/lib/models"; import { MediaDetailsModal } from "@/components/MediaDetailsModal"; import { Card } from "@/components/wui/Card"; -import { isWriteModalOpen, WriteModal } from "@/components/WriteModal"; +import { WriteModal } from "@/components/WriteModal"; import { PageFrame } from "@/components/PageFrame"; import { usePreferencesStore } from "@/lib/preferencesStore"; diff --git a/src/routes/create.nfc.tsx b/src/routes/create.nfc.tsx index 3d4cba2..6df7d97 100644 --- a/src/routes/create.nfc.tsx +++ b/src/routes/create.nfc.tsx @@ -10,8 +10,13 @@ import { logger } from "@/lib/logger"; // } from "lucide-react"; import { useSmartSwipe } from "@/hooks/useSmartSwipe"; import { useHaptics } from "@/hooks/useHaptics"; -import { isWriteModalOpen, WriteModal } from "@/components/WriteModal"; -import { useNfcWriter, WriteAction, WriteMethod } from "@/lib/writeNfcHook"; +import { WriteModal } from "@/components/WriteModal"; +import { + isWriteModalOpen, + useNfcWriter, + WriteAction, + WriteMethod, +} from "@/lib/writeNfcHook"; import { usePreferencesStore } from "@/lib/preferencesStore"; import { PageFrame } from "@/components/PageFrame"; import { HeaderButton } from "@/components/wui/HeaderButton"; diff --git a/src/routes/index.tsx b/src/routes/index.tsx index d63ea90..fe1cd60 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,5 +1,5 @@ import { createFileRoute } from "@tanstack/react-router"; -import { preloadZapLogo } from "@/lib/images"; +import { preloadZapLogo } from "@/lib/zapLogo"; import { Index } from "./-pages/Index"; export const Route = createFileRoute("/")({ From cc2a364282fbc0a0582da8c40e888400e300d6c5 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Sat, 15 Aug 2026 13:37:03 +0800 Subject: [PATCH 2/2] Use the @/ alias for the moved zapLogo imports routes/index.tsx already imported the new module as @/lib/zapLogo, so App.tsx and images.tsx were the odd ones out. Moving App.tsx's two lines into the aliased block is what import-x/order wants once they stop being relative. Claude-Session: https://claude.ai/code/session_01MevSjtLDnHofR1Eub9vKGN --- src/App.tsx | 4 ++-- src/lib/images.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 93023d3..6f8e01e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,15 +16,15 @@ import { isPluginAvailable, } from "@/lib/capacitorBridge"; import { useDeepLinks } from "@/lib/deepLinks"; +import { DatabaseIcon, PlayIcon } from "@/lib/images"; import { ensurePurchasesUser, getPurchaseAccess, resetPurchasesUser, } from "@/lib/purchasesSetup"; +import { preloadZapLogo } from "@/lib/zapLogo"; import { routeTree } from "./routeTree.gen"; import { useStatusStore } from "./lib/store"; -import { DatabaseIcon, PlayIcon } from "./lib/images"; -import { preloadZapLogo } from "./lib/zapLogo"; import { ConnectionProvider } from "./components/ConnectionProvider"; import { ReconnectingIndicator } from "./components/ReconnectingIndicator"; import { MediaFinishedToast } from "./components/MediaFinishedToast.tsx"; diff --git a/src/lib/images.tsx b/src/lib/images.tsx index f13eae6..87d44bb 100644 --- a/src/lib/images.tsx +++ b/src/lib/images.tsx @@ -5,7 +5,7 @@ import { preloadZapLogo, ZAP_LOGO_HEIGHT, ZAP_LOGO_WIDTH, -} from "./zapLogo"; +} from "@/lib/zapLogo"; export function ZapLogo() { const { t } = useTranslation();