From beab1bdd297257d75c5fdd6bdb8a99601ea8f6d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 21:24:51 +0000 Subject: [PATCH 1/6] =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=E2=80=94=20126?= =?UTF-8?q?=EB=B0=94=ED=80=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016VxkFapkxCRBQYVYBf6TM6 From e37de71f047ce493d2492d1eab2c6ebfb8b19d92 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 21:29:04 +0000 Subject: [PATCH 2/6] =?UTF-8?q?web:=20=EB=8D=B1=EA=B3=BC=20=EB=B3=B4?= =?UTF-8?q?=EB=82=B4=EA=B8=B0=20=EC=B0=BD=20=EC=97=AC=EC=84=AF=EC=9D=84=20?= =?UTF-8?q?dialogKeys=20=EC=8B=9C=ED=97=98=EC=97=90=20=EB=8D=94=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016VxkFapkxCRBQYVYBf6TM6 --- .../components/community/dialogKeys.test.tsx | 191 ++++++++++++++---- 1 file changed, 148 insertions(+), 43 deletions(-) diff --git a/web/src/components/community/dialogKeys.test.tsx b/web/src/components/community/dialogKeys.test.tsx index 2c1f7d749..9e17dbad6 100644 --- a/web/src/components/community/dialogKeys.test.tsx +++ b/web/src/components/community/dialogKeys.test.tsx @@ -7,16 +7,28 @@ // Share to Community, the window Pattern Lab's Share opens, came after them. // It closed on a press outside alone, and its title field took the focus // before anything could note the Share button. +// +// The deck's six came last. In Chromium at 1440px Edit deck details and +// Share deck stayed open on Esc, and Deck performance left the focus on its +// button. Share this deck closed on Esc but never took the focus, and the +// focus fell to the body. At 390px the deck panel did neither. Send to my +// Patternflow stayed open here too. import { useState, type ReactNode } from "react"; import { fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import DeckDetailClient, { type DeckView } from "@/app/community/d/[id]/DeckDetailClient"; +import { deckAdd } from "@/lib/community/deck"; import AddHeaderModal from "./AddHeaderModal"; import AddPerformanceModal from "./AddPerformanceModal"; import AuthModal from "./AuthModal"; +import DeckPanel from "./DeckPanel"; import EditDetailsModal from "./EditDetailsModal"; import PublishModal from "./PublishModal"; import ReportModal from "./ReportModal"; +import SendModuleModal from "./SendModuleModal"; +import ShareDeckModal from "./ShareDeckModal"; +import ShareDeckPackModal from "./ShareDeckPackModal"; const auth = vi.hoisted(() => ({ session: { user: { id: "u1", name: "Tester" } } as { user: { id: string; name: string } } | null, @@ -30,9 +42,22 @@ vi.mock("@/lib/community/auth-client", () => ({ })); vi.mock("@/lib/posthogEvents", () => ({ captureEvent: vi.fn() })); +beforeEach(() => { + // The deck chip only shows where builds are on, as on the Pi. + vi.stubEnv("NEXT_PUBLIC_BUILD_ENABLED", "1"); + // Send starts its build as it opens. Nothing here waits for the answer. + vi.stubGlobal( + "fetch", + vi.fn(() => new Promise(() => {})), + ); +}); + afterEach(() => { auth.session = { user: { id: "u1", name: "Tester" } }; auth.pending = false; + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + window.localStorage.clear(); }); function Page({ @@ -57,8 +82,7 @@ function Page({ ); } -function open() { - const opener = screen.getByText("Open"); +function open(opener: HTMLElement = screen.getByText("Open")) { opener.focus(); fireEvent.click(opener); return opener; @@ -68,56 +92,112 @@ function pressEsc() { fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" }); } -const WINDOWS: [string, (close: () => void) => ReactNode][] = [ - ["sign-in", (close) => ], +const report = (close: () => void) => ( + +); + +const editDetails = (close: () => void) => ( + +); + +const shareToCommunity = (close: () => void) => ( + +); + +const WAVE = { patternId: "p1", title: "Wave", code: "// wave" }; + +const shareDeck = (close: () => void) => ; + +// Each mount opens its window and hands back the button it pressed. +const opened = (dialog: (close: () => void) => ReactNode) => () => { + render(); + return open(); +}; + +const DECK: DeckView = { + id: "d1", + title: "Night set", + description: null, + visibility: "public", + hiddenAt: null, + performanceJson: null, + createdAt: "2026-10-01T00:00:00.000Z", + username: "tester", + displayUsername: "Tester", +}; + +const onDeckPage = (button: string) => () => { + render(); + return open(screen.getByRole("button", { name: button })); +}; + +const deckChip = () => screen.getByTitle(/^Your deck \(/); + +const WINDOWS: [string, () => HTMLElement][] = [ + ["sign-in", opened((close) => )], + ["report", opened(report)], + ["Edit details", opened(editDetails)], [ - "report", - (close) => ( - - ), + "Add firmware header", + opened((close) => ), ], [ - "Edit details", - (close) => ( - - ), + "Publish a performance", + opened((close) => ), ], - ["Add firmware header", (close) => ], - ["Publish a performance", (close) => ], + ["Share to Community", opened(shareToCommunity)], + ["Edit deck details", onDeckPage("Edit details")], + ["Deck performance", onDeckPage("Attach performance")], + ["Share deck", opened(shareDeck)], [ - "Share to Community", - (close) => ( - ( + true} + onDownload={() => {}} onClose={close} /> - ), + )), + ], + [ + "Deck", + () => { + render(); + return open(deckChip()); + }, + ], + [ + "Send to my Patternflow", + opened((close) => ), ], ]; -describe.each(WINDOWS)("the %s window", (_name, dialog) => { +describe.each(WINDOWS)("the %s window", (_name, mount) => { it("takes the focus when it opens", () => { - render(); - const opener = open(); + const opener = mount(); expect(opener).not.toHaveFocus(); expect(screen.getByRole("dialog")).toContainElement(document.activeElement as HTMLElement); }); it("closes on Esc and gives the focus back to the button that opened it", () => { - render(); - const opener = open(); + const opener = mount(); pressEsc(); expect(screen.queryByRole("dialog")).toBeNull(); expect(opener).toHaveFocus(); @@ -126,29 +206,54 @@ describe.each(WINDOWS)("the %s window", (_name, dialog) => { describe("community windows", () => { it("Edit details still starts on the title field", () => { - render(); + render(); open(); expect(screen.getByRole("textbox", { name: "Title" })).toHaveFocus(); }); it("Share to Community still starts on the title field", () => { - render(); + render(); open(); expect(screen.getByRole("textbox", { name: "Title" })).toHaveFocus(); }); + it("Edit deck details still starts on the title field", () => { + onDeckPage("Edit details")(); + expect(screen.getByRole("textbox", { name: "Title" })).toHaveFocus(); + }); + + it("Share deck still starts on the title field", () => { + render(); + open(); + expect(screen.getByRole("textbox", { name: "Title" })).toHaveFocus(); + }); + + // Share deck in the panel closes the panel and opens its own window. The + // focus the panel hands back to the chip is what Share deck returns to. + it("Share deck opened from the deck panel gives the focus back to the deck chip", () => { + deckAdd(WAVE); + render(); + const chip = open(deckChip()); + fireEvent.click(screen.getByRole("button", { name: "Share deck" })); + expect(screen.getByRole("textbox", { name: "Title" })).toHaveFocus(); + + pressEsc(); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(chip).toHaveFocus(); + }); + // The lab's first Share can open before the session is known. The card // holds the focus until then, and the title takes it when the form shows. it("Share to Community moves to the title field once the session is known", () => { auth.pending = true; const onClose = vi.fn(); - const { rerender } = render(); + const { rerender } = render(); const opener = open(); expect(screen.getByText("Checking session…")).toBeTruthy(); expect(screen.getByRole("dialog")).toContainElement(document.activeElement as HTMLElement); auth.pending = false; - rerender(); + rerender(); expect(screen.getByRole("textbox", { name: "Title" })).toHaveFocus(); pressEsc(); @@ -159,7 +264,7 @@ describe("community windows", () => { it("a sign-in form inside Share to Community lets one Esc close it", () => { auth.session = null; const onClose = vi.fn(); - render(); + render(); const opener = open(); expect(screen.getByRole("button", { name: "Create account" })).toBeTruthy(); fireEvent.keyDown(screen.getByLabelText("Password"), { key: "Escape" }); @@ -171,7 +276,7 @@ describe("community windows", () => { it("a sign-in form inside the report window lets one Esc close the report", () => { auth.session = null; const onClose = vi.fn(); - render(); + render(); const opener = open(); expect(screen.getByRole("button", { name: "Create account" })).toBeTruthy(); fireEvent.keyDown(screen.getByLabelText("Password"), { key: "Escape" }); From f8df779cca8ba97488a343679bce67dc8b896930 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 21:30:15 +0000 Subject: [PATCH 3/6] =?UTF-8?q?web:=20=EB=8D=B1=EA=B3=BC=20=EB=B3=B4?= =?UTF-8?q?=EB=82=B4=EA=B8=B0=20=EC=B0=BD=20=EC=97=AC=EC=84=AF=EC=9D=B4=20?= =?UTF-8?q?Esc=20=EB=A1=9C=20=EB=8B=AB=ED=9E=88=EA=B3=A0=20=EC=B4=88?= =?UTF-8?q?=EC=A0=90=EC=9D=84=20=EC=97=B0=20=EB=8B=A8=EC=B6=94=EB=A1=9C=20?= =?UTF-8?q?=EB=8F=8C=EB=A0=A4=EC=A4=80=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016VxkFapkxCRBQYVYBf6TM6 --- .../app/community/d/[id]/DeckDetailClient.tsx | 14 +- .../app/pattern-lab/HardwareModal.test.tsx | 5 +- web/src/app/pattern-lab/HardwareModal.tsx | 6 +- web/src/components/community/DeckPanel.tsx | 421 +++++++++--------- .../components/community/SendModuleModal.tsx | 4 + .../components/community/ShareDeckModal.tsx | 21 +- .../community/ShareDeckPackModal.tsx | 24 +- 7 files changed, 270 insertions(+), 225 deletions(-) diff --git a/web/src/app/community/d/[id]/DeckDetailClient.tsx b/web/src/app/community/d/[id]/DeckDetailClient.tsx index 3450a70f9..612766505 100644 --- a/web/src/app/community/d/[id]/DeckDetailClient.tsx +++ b/web/src/app/community/d/[id]/DeckDetailClient.tsx @@ -25,6 +25,7 @@ import { import { DESCRIPTION_MAX, TITLE_MAX } from "@/lib/community/validate"; import { useDeviceHost } from "@/lib/community/deviceHost"; import { useBackdropClose } from "@/lib/hooks/useBackdropClose"; +import { useDialog } from "@/lib/hooks/useDialog"; import { useZipDownload, zipStatusUrl } from "@/lib/community/zipDownload"; import type { DeckPageItem } from "@/lib/community/server/serialize"; import { captureEvent } from "@/lib/posthogEvents"; @@ -608,6 +609,7 @@ function PerformanceModal({ const [json, setJson] = useState(initialJson ?? ""); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const cardRef = useDialog(onClose); const closeOnBackdrop = useBackdropClose(onClose); const send = async (value: string | null) => { @@ -646,7 +648,12 @@ function PerformanceModal({ return (
-
event.stopPropagation()}> +
event.stopPropagation()} + >
Deck performance {open && ( -
-
event.stopPropagation()} - > -
- Deck - -
+ + {needsAuth ? ( + undefined} + onAuthed={() => { + setNeedsAuth(false); + router.refresh(); + }} + /> + ) : build ? ( +
+ {build.status !== "done" && build.status !== "error" && ( +

+

+ )} + + {build.status === "error" && ( + <> +
The build failed.
+
{build.error}
+
+ +
+ + )} - {needsAuth ? ( - undefined} - onAuthed={() => { - setNeedsAuth(false); - router.refresh(); - }} - /> - ) : build ? ( -
- {build.status !== "done" && build.status !== "error" && ( + {build.status === "done" && build.modulesUrl && ( + <>

-

+

+ Send over Wi-Fi opens the device’s pattern manager with this build + linked — it fetches and installs every module itself. Patterns appear in the + list immediately: no reflash, no reboot, nothing to unzip. (Needs firmware + with loadable-module support.) +

+ +

+ Device address:{" "} + changeDeviceHost(event.target.value)} + spellCheck={false} + aria-label="Device address" + style={{ + font: "inherit", + width: "16ch", + padding: "1px 6px", + border: "1px solid var(--pf-rule, #D9D1C0)", + background: "transparent", + color: "inherit", + }} + />{" "} + (Android can’t resolve .local — use the IP from the + device’s NETWORK screen, hold K2).

- )} + + )} +
+ ) : ( +
+ {/* One list now, so the tab strip is a label. Saved lived in + the other tab; the like replaced it. */} + - {build.status === "error" && ( - <> -
The build failed.
-
{build.error}
-
- -
- - )} + {note &&
{note}
} - {build.status === "done" && build.modulesUrl && ( + {deck.length === 0 ? ( +

+ Your deck is empty. On any pattern that ships a firmware header, use + “Add to deck” — then build them all as loadable modules in one + go. The order here is the order they cycle on the device. +

+ ) : ( <> -

- ✓ {build.namespaces.length} module{build.namespaces.length === 1 ? "" : "s"}{" "} - built · {((build.bytes ?? 0) / 1024).toFixed(0)} KB zip -

+
    + {deck.map((item, index) => ( +
  1. + {index + 1} + + {item.title} + + + {/* Buttons rather than drag: this list is at most ten + rows and has to work on a phone. */} + + + +
  2. + ))} +

- Send over Wi-Fi opens the device’s pattern manager with this build - linked — it fetches and installs every module itself. Patterns appear in the - list immediately: no reflash, no reboot, nothing to unzip. (Needs firmware - with loadable-module support.) + Builds as loadable modules (.pfm): each pattern compiles alone in about + half a second, and the result installs over Wi-Fi from the device’s + pattern manager — no reflash. The classic full-image build stays available + on each pattern’s page.

+ {error &&
{error}
}
- + {busy + ? "Queueing…" + : `Build ${deck.length} module${deck.length === 1 ? "" : "s"}`} + + -
-

- Device address:{" "} - changeDeviceHost(event.target.value)} - spellCheck={false} - aria-label="Device address" - style={{ - font: "inherit", - width: "16ch", - padding: "1px 6px", - border: "1px solid var(--pf-rule, #D9D1C0)", - background: "transparent", - color: "inherit", - }} - />{" "} - (Android can’t resolve .local — use the IP from the - device’s NETWORK screen, hold K2). -

- - )} -
- ) : ( -
- {/* One list now, so the tab strip is a label. Saved lived in - the other tab; the like replaced it. */} - - - {note &&
{note}
} - - {deck.length === 0 ? ( -

- Your deck is empty. On any pattern that ships a firmware header, use - “Add to deck” — then build them all as loadable modules in one - go. The order here is the order they cycle on the device. -

- ) : ( - <> -
    - {deck.map((item, index) => ( -
  1. - {index + 1} - - {item.title} - - - {/* Buttons rather than drag: this list is at most ten - rows and has to work on a phone. */} - - - -
  2. - ))} -
-

- Builds as loadable modules (.pfm): each pattern compiles alone in about - half a second, and the result installs over Wi-Fi from the device’s - pattern manager — no reflash. The classic full-image build stays available - on each pattern’s page. -

- {error &&
{error}
} -
- - - - {/* Two presses: a deck is cheap to rebuild but an - arranged one is not, and this sits next to the button - people actually mean to press. */} - -
- - )} -
- )} -
-
+ + )} +
+ )} + )} {shareOpen && setShareOpen(false)} />} ); } + +// The window around the panel. It is a component of its own so the hook +// mounts with the window and not with the chip, which is always there. Esc +// closes it and the focus goes back to the chip or the dock button that +// opened it. Signing in happens inside it, so one Esc is one window. +function DeckWindow({ onClose, children }: { onClose: () => void; children: ReactNode }) { + const cardRef = useDialog(onClose); + const closeOnBackdrop = useBackdropClose(onClose); + return ( +
+
event.stopPropagation()} + > +
+ Deck + +
+ {children} +
+
+ ); +} diff --git a/web/src/components/community/SendModuleModal.tsx b/web/src/components/community/SendModuleModal.tsx index 4b47c95bc..d29b03d61 100644 --- a/web/src/components/community/SendModuleModal.tsx +++ b/web/src/components/community/SendModuleModal.tsx @@ -9,6 +9,7 @@ import { captureEvent } from "@/lib/posthogEvents"; import { useModuleBuild } from "@/lib/community/useModuleBuild"; import { useZipDownload } from "@/lib/community/zipDownload"; import { useBackdropClose } from "@/lib/hooks/useBackdropClose"; +import { useDialog } from "@/lib/hooks/useDialog"; import AuthModal from "./AuthModal"; import { ZipInstallNote, ZipProgress } from "./ZipDownload"; import styles from "./Community.module.css"; @@ -49,10 +50,13 @@ export default function SendModuleModal( } ), ) { + const cardRef = useDialog(props.onClose); const closeOnBackdrop = useBackdropClose(props.onClose); return (
event.stopPropagation()} > diff --git a/web/src/components/community/ShareDeckModal.tsx b/web/src/components/community/ShareDeckModal.tsx index 60448a0d1..8a919895c 100644 --- a/web/src/components/community/ShareDeckModal.tsx +++ b/web/src/components/community/ShareDeckModal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { authClient } from "@/lib/community/auth-client"; import { COMMUNITY_FETCH_INIT, communityApiUrl, readApiError } from "@/lib/community/apiBase"; @@ -8,6 +8,7 @@ import { useSubmitLatch } from "@/lib/community/submitLatch"; import { PUBLIC_DECKS_MAX, type CollectedPattern } from "@/lib/community/deck"; import { DESCRIPTION_MAX, TITLE_MAX } from "@/lib/community/validate"; import { useBackdropClose } from "@/lib/hooks/useBackdropClose"; +import { useDialog } from "@/lib/hooks/useDialog"; import { VISIBILITY_HINTS, VISIBILITY_LABELS, @@ -41,7 +42,16 @@ export default function ShareDeckModal({ const [busy, setBusy] = useState(false); // One request per press, decided in the click's own tick (#455). const latch = useSubmitLatch(); + // The card takes the focus first, so the hook sees the button that opened + // the window, and the title field takes it once the form is there. While + // the session is being checked there is no field yet. + const cardRef = useDialog(onClose); const closeOnBackdrop = useBackdropClose(onClose); + const titleRef = useRef(null); + const formShown = !isPending && Boolean(session); + useEffect(() => { + if (formShown) titleRef.current?.focus({ preventScroll: true }); + }, [formShown]); const share = async () => { const trimmed = title.trim(); @@ -90,7 +100,12 @@ export default function ShareDeckModal({ return (
-
event.stopPropagation()}> +
event.stopPropagation()} + >
Share deck