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