diff --git a/CONTEXT.md b/CONTEXT.md index 7d3cb00..b8f27b2 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,7 +1,7 @@ --- status: truth topic: context -last-verified: 0f609de844c0cbc48e7fb53396a90d5f32776c2b +last-verified: 838d5d6bf30e16a277e5b367b648333e8923759a --- # BazaarPlusPlus Installer Context @@ -30,6 +30,7 @@ Current behavior truth lives under `docs/truth/` (topic-sliced, code-cited, hash - **History** — the facade around the Selected game installation's mod-owned SQLite database, including reads, detail, reveal, video deletion, and storage cleanup (`src-tauri/src/services/history.rs:48-288`); the database is created and primarily written by the mod. - **Semantic problem** — a command failure contract made of a stable code, string parameters, and an optional troubleshooting diagnostic (`src-tauri/src/problem.rs:3-42`). History publishes unavailable/read/action codes, including cleanup preview/execute operation parameters; Install publishes detection/action/game-running/partial-failure codes; Stream publishes service/window/crop capability codes at the native boundary and adds polling/clipboard/opener codes in its frontend workflow (`src-tauri/src/services/history.rs:74-188`, `src-tauri/src/services/history.rs:258-274`, `src-tauri/src/services/install/mod.rs:200-235`, `src-tauri/src/commands/stream.rs:12-110`, `src/features/stream/streamProblems.ts:7-105`). Presenters localize these codes without using the diagnostic as user copy, while the native adapter preserves the structured payload (`src/api/problems.ts:3-49`, `src/api/nativeCommands.ts:5-15`). - **Confirmed operation** — the shared frontend lifecycle for a target-bearing destructive action: confirming, non-dismissible running, retained failure with retry/safe exit, and success-only closure. It refuses conflicting requests and repeated submission in `src/features/shared/confirmedOperation.ts:3-94`; cleanup, reset, and video deletion supply their actual targets and semantic problems. +- **Modal coordinator** — the app-wide frontend owner that renders one registered native dialog at a time, using `critical > confirmation > system > informational` priority and FIFO within each priority. Sources can retain their queue position while changing priority/dismissal policy, and final dismissal restores focus to the connected trigger or the current page heading/main fallback (`src/features/shared/modalCoordinator.ts:1-125`, `src/components/ui/ModalCoordinator.tsx:25-126`). - **Stream runtime / overlay** — the single serialized owner of the local Axum service lifecycle, window selection, and exclusive maintenance; the production service remains on `127.0.0.1:17654` and serves the OBS overlay and settings pages (`src-tauri/src/stream/runtime.rs:43-108`, `src-tauri/src/stream/server.rs:16-69`). - **Stream workflow** — the framework-neutral frontend owner of independent service, polling freshness, window, crop, and one-off action capabilities. It keeps semantic state and derives one snapshot; browser/Tauri concerns enter through injected ports, while React creates the workflow once and only attaches lifecycle and subscription (`src/features/stream/streamWorkflow.ts:53-122`, `src/features/stream/streamWorkflow.ts:185-320`, `src/features/stream/streamWorkflow.ts:640-733`, `src/features/stream/useStreamPage.ts:20-42`). - **Storage cleanup** — preset-driven deletion of old screenshots and run data with upload-safety and referenced-file protections; its IPC is the two scope-tagged, semantic-problem preview/execute operations (`src-tauri/src/commands/history.rs:61-79`, `src-tauri/src/services/history.rs:25-44`, `src-tauri/src/services/history.rs:337-353`). diff --git a/docs/INDEX.md b/docs/INDEX.md index 95acdeb..3b81445 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -20,6 +20,8 @@ Stream capability citation refresh: `2026-07-19` on `5bbe32c870bc06e35e5064f3c84 Destructive-operation citation refresh: `2026-07-19` on `0f609de844c0cbc48e7fb53396a90d5f32776c2b` — the context glossary plus architecture, frontend, Install/Reset, History/Stream, and verification topics were checked against the target-bearing confirmation lifecycle, explicit active dismissal policies, retained semantic failures, and cleanup semantic native contract. +Modal-coordination citation refresh: `2026-07-19` on `838d5d6bf30e16a277e5b367b648333e8923759a` — the context glossary plus architecture, frontend, updater, and verification topics were checked against the global modal priority queue, source lifecycle, dismissal policy, focus restoration, and controlled shell disclosures. + ## Current Manifest | Path | Topic | Status | Last verified | @@ -29,14 +31,14 @@ Destructive-operation citation refresh: `2026-07-19` on `0f609de844c0cbc48e7fb53 | `README.md` | project entrypoint | current-entrypoint | 2026-07-11 | | `.trae/rules/git-commit-message.md` | ignored local rule | ignored-operational | n/a | | `docs/INDEX.md` | documentation manifest | manifest | 2026-07-19 | -| `CONTEXT.md` | entry map + glossary | truth | `0f609de844c0cbc48e7fb53396a90d5f32776c2b` | -| `docs/truth/architecture.md` | architecture | truth | `0f609de844c0cbc48e7fb53396a90d5f32776c2b` | -| `docs/truth/frontend.md` | frontend | truth | `0f609de844c0cbc48e7fb53396a90d5f32776c2b` | +| `CONTEXT.md` | entry map + glossary | truth | `838d5d6bf30e16a277e5b367b648333e8923759a` | +| `docs/truth/architecture.md` | architecture | truth | `838d5d6bf30e16a277e5b367b648333e8923759a` | +| `docs/truth/frontend.md` | frontend | truth | `838d5d6bf30e16a277e5b367b648333e8923759a` | | `docs/truth/install-reset.md` | install-reset | truth | `0f609de844c0cbc48e7fb53396a90d5f32776c2b` | | `docs/truth/launch-modes.md` | launch-modes | truth | `f23d786ab3bf1998f556f5fe05b6e47467a7ea48` | | `docs/truth/history-stream.md` | history-stream | truth | `0f609de844c0cbc48e7fb53396a90d5f32776c2b` | -| `docs/truth/updater-release.md` | updater-release | truth | `45764680a4476063a46a92f4606dd520f0ce29ef` | -| `docs/truth/verification.md` | verification | truth | `0f609de844c0cbc48e7fb53396a90d5f32776c2b` | +| `docs/truth/updater-release.md` | updater-release | truth | `838d5d6bf30e16a277e5b367b648333e8923759a` | +| `docs/truth/verification.md` | verification | truth | `838d5d6bf30e16a277e5b367b648333e8923759a` | | `docs/plans/manual-validation.md` | manual-validation | active-plan | `7500016b1c4adfc7b5d0206c7def0ceabae514d5` | | `docs/agents/issue-tracker.md` | agent skills: issue tracker | operational | 2026-07-11 | | `docs/agents/triage-labels.md` | agent skills: triage labels | operational | 2026-07-11 | diff --git a/docs/truth/architecture.md b/docs/truth/architecture.md index 13678ef..9363947 100644 --- a/docs/truth/architecture.md +++ b/docs/truth/architecture.md @@ -1,7 +1,7 @@ --- status: truth topic: architecture -last-verified: 0f609de844c0cbc48e7fb53396a90d5f32776c2b +last-verified: 838d5d6bf30e16a277e5b367b648333e8923759a --- # Architecture @@ -31,6 +31,7 @@ last-verified: 0f609de844c0cbc48e7fb53396a90d5f32776c2b - Stream Tauri commands map service, window, and crop failures into the shared semantic problem contract before crossing IPC in `src-tauri/src/commands/stream.rs:12-110`; stable codes are part of the generated `SemanticProblemCode` union from `src-tauri/src/problem.rs:3-42`. - The frontend Stream workflow depends inward on command, scheduler, clipboard, and opener ports and exposes capability-oriented semantic snapshots in `src/features/stream/streamWorkflow.ts:25-122`. It owns response ordering and capability gates in `src/features/stream/streamWorkflow.ts:185-320` and `src/features/stream/streamWorkflow.ts:640-733`; the React hook provides outer adapters and only subscribes, starts, and disposes one locale-independent workflow in `src/features/stream/useStreamPage.ts:9-42`. - Frontend destructive confirmation state is centralized in a framework-neutral external-store controller rather than page-local booleans. It owns target retention, single-flight execution, success-only closure, and semantic failure retention in `src/features/shared/confirmedOperation.ts:3-94`; React only memoizes and subscribes to the controller at `src/features/shared/confirmedOperation.ts:82-94`. +- Frontend modal scheduling is a separate framework-neutral external store. It owns priority/FIFO ordering, active-source retention, updates, and safe unregistration in `src/features/shared/modalCoordinator.ts:1-113`; the React provider only registers sources, renders the active one, carries dismissal policy into `Dialog`, and restores focus after the queue drains in `src/components/ui/ModalCoordinator.tsx:25-126` and `src/components/ui/Dialog.tsx:13-55`. ## Build And Generated Artifacts diff --git a/docs/truth/frontend.md b/docs/truth/frontend.md index 3b71b20..4d1bf86 100644 --- a/docs/truth/frontend.md +++ b/docs/truth/frontend.md @@ -1,24 +1,25 @@ --- status: truth topic: frontend -last-verified: 0f609de844c0cbc48e7fb53396a90d5f32776c2b +last-verified: 838d5d6bf30e16a277e5b367b648333e8923759a --- # Frontend ## Shell -- `GlobalShell` wraps the app in `AppBootstrapProvider` and `UpdaterProvider`, then renders the shell header, nav rail, page outlet, payment modal, and update modal in `src/layouts/GlobalShell.tsx:14-21` and `src/layouts/GlobalShell.tsx:54-96`. -- Header dropdowns close on Escape and outside pointer-down events in `src/layouts/GlobalShell.tsx:31-52`. -- The update modal is rendered only for updater phases considered modal phases by `isUpdateModalPhase` in `src/layouts/GlobalShell.tsx:92-95` and `src/features/about/updater.ts:90-103`. +- `GlobalShell` wraps the app in bootstrap, updater, and modal-coordinator providers before rendering the shell header, nav rail, and route outlet in `src/layouts/GlobalShell.tsx:18-27` and `src/layouts/GlobalShell.tsx:66-107`. +- Bilibili and Support are controlled disclosures: Escape closes the open menu and restores its trigger, while outside pointer-down closes it in `src/layouts/GlobalShell.tsx:39-64`; their triggers expose expanded/control/menu relationships and their entries are menu items in `src/layouts/ShellHeader.tsx:457-565` and `src/layouts/ShellHeader.tsx:590-679`. +- Payment and update dialogs register as global modal sources. Payment is informational; idle update phases are system priority; uncancellable download/install adopts critical blocked semantics without losing source identity in `src/layouts/GlobalShell.tsx:109-133`. ## Native-Feel Rules - Global app chrome disables page-style selection by default, keeps text controls selectable, and keeps focus-visible outlines in `src/styles/index.css:42-82`. - `.selectable` and `.user-content` opt content back into text selection in `src/styles/index.css:84-89`. - Reduced motion is honored through `prefers-reduced-motion` in `src/styles/index.css:91-99`. -- App modals use the native `` wrapper and top-layer dialog styling; dialog CSS is in `src/styles/index.css:101-128`, and the updater modal consumes the shared `Dialog` component in `src/layouts/ShellUpdateModal.tsx:40-45`. -- Install, reset, cleanup, and video-delete confirmations share the `Dialog`-composing `ConfirmDialog`. It requires an explicit active dismissal policy for Escape, backdrop, close, and secondary actions; blocked work removes the secondary cancel affordance, disables close, and says that the operation cannot be cancelled in `src/components/ui/ConfirmDialog.tsx:14-62` and `src/components/ui/ConfirmDialog.tsx:98-242`. Current native destructive commands all use the blocked policy because none exposes a cancellation contract (`src/pages/Install.tsx:143-176`, `src/features/history/StorageCleanupCard.tsx:124-171`, `src/pages/RunDetail.tsx:246-277`). +- App modals use the native `` wrapper and top-layer dialog styling. `Dialog` consumes the active source's dismissal policy so Escape and backdrop cannot dismiss blocked work in `src/components/ui/Dialog.tsx:13-55`; dialog CSS is in `src/styles/index.css:101-128`. +- The coordinator displays exactly one registered dialog, prioritizes critical work over confirmations, system messages, and informational dialogs, and preserves FIFO within equal priorities in `src/features/shared/modalCoordinator.ts:36-78`. Unmounting a queued route source is safe; after the queue drains, focus returns to the connected trigger or the current page heading/main fallback in `src/features/shared/modalCoordinator.ts:80-125`, `src/components/ui/ModalCoordinator.tsx:40-60`, and `src/components/ui/PageHeader.tsx:29-38`. +- Install, reset, cleanup, and video-delete confirmations share the `Dialog`-composing `ConfirmDialog`. It requires an explicit active dismissal policy for Escape, backdrop, close, and secondary actions; blocked work removes the secondary cancel affordance, disables close, and says that the operation cannot be cancelled in `src/components/ui/ConfirmDialog.tsx:14-62` and `src/components/ui/ConfirmDialog.tsx:98-242`. Their modal sources begin as confirmations and adopt critical blocked semantics for native work because none exposes a cancellation contract (`src/pages/Install.tsx:131-197`, `src/features/history/StorageCleanupCard.tsx:125-185`, `src/pages/RunDetail.tsx:247-292`). - The current Tauri security config has `csp: null` in `src-tauri/tauri.conf.json:23-25`; treat any CSP hardening claim as future work until code changes. ## Runtime Seam @@ -37,18 +38,18 @@ last-verified: 0f609de844c0cbc48e7fb53396a90d5f32776c2b - Install facts currently show only BazaarPlusPlus, not the broader fact list from the historical design spec, in `src/features/install/InstallActionsPanel.tsx:58-65`. - Install renders exactly one primary action. Its choose/install/repair/launch mode, gate, and loading state are derived from one view model based on path validity, install/version state, compatibility consistency, and the active operation in `src/features/install/installPageState.ts:75-132` and `src/features/install/InstallActionsPanel.tsx:141-222`. - Install warnings and failures are presented from stable semantic codes in bilingual frontend copy; native diagnostics are kept in the diagnostic disclosure rather than used as the message in `src/features/install/installProblems.ts:23-107` and `src/features/install/InstallProblemBanner.tsx:1-32`. -- The reset-local-data button is disabled unless backend action gates allow reset data, and its label switches to a no-data message when the game path is valid but no resettable data exists in `src/features/install/InstallActionsPanel.tsx:103-115`. Both reset confirmations retain the selected game path as their target; failure keeps the modal, semantic problem, acknowledgement, and partial-failure paths available for retry or safe close, while success alone closes and refreshes state in `src/pages/Install.tsx:53-84` and `src/pages/Install.tsx:143-176`. +- The reset-local-data button is disabled unless backend action gates allow reset data, and its label switches to a no-data message when the game path is valid but no resettable data exists in `src/features/install/InstallActionsPanel.tsx:103-115`. Both reset confirmations retain the selected game path as their target; failure keeps the modal, semantic problem, acknowledgement, and partial-failure paths available for retry or safe close, while success alone closes and refreshes state in `src/pages/Install.tsx:54-85` and `src/pages/Install.tsx:151-197`. - History renders loading, blocking failure, and the two successful list states as mutually exclusive branches; refresh failures remain inside the ready branch and keep prior data in `src/pages/History.tsx:42-97` and `src/features/shared/pageState.ts:43-54`. - History summary cards are Runs, Videos, and Win Rate in `src/pages/History.tsx:50-64`. - History rows link to details, show lazy-decoded preview images with an error fallback, and display hero, locale-formatted date, result, progress, rank, and rating in `src/pages/History.tsx:125-230` and `src/features/history/format.ts:4-27`. - History list loading calls `listHistoryRuns` independently from status-only Stream preview discovery; stopped or failed Stream status produces a thumbnail-only problem and never rejects the list request in `src/features/history/useHistoryPage.ts:37-64` and `src/features/history/historyPreview.ts:14-45`. -- Run detail renders explicit initial-loading, not-found, blocking-failure, and ready branches, preserving ready content behind a localized refresh-failure banner in `src/pages/RunDetail.tsx:66-130`. Screenshot, video, delete, and refresh controls share one action gate; video deletion keeps its battle/video target visible, blocks dismissal while running, retains localized semantic failure for retry/close, and closes only after the returned detail replaces page data in `src/features/history/useRunDetailPage.ts:84-153` and `src/pages/RunDetail.tsx:246-277`. -- Storage cleanup submits only generated `StorageCleanupScope` plus `StorageCleanupPreset`. Preview and execute are separately single-flight; the selected scope, preset, counts, and consequence remain in the confirmed-operation target through running/failure, and success refreshes History before publishing the outcome in `src/features/history/useStorageCleanup.ts:23-78` and `src/features/history/StorageCleanupCard.tsx:124-171`. Cleanup failures are localized from semantic problem codes/operation parameters while diagnostics remain separate in `src/features/history/storageCleanupProblems.ts:9-52`. +- Run detail renders explicit initial-loading, not-found, blocking-failure, and ready branches, preserving ready content behind a localized refresh-failure banner in `src/pages/RunDetail.tsx:66-130`. Screenshot, video, delete, and refresh controls share one action gate; video deletion keeps its battle/video target visible, blocks dismissal while running, retains localized semantic failure for retry/close, and closes only after the returned detail replaces page data in `src/features/history/useRunDetailPage.ts:84-153` and `src/pages/RunDetail.tsx:247-292`. +- Storage cleanup submits only generated `StorageCleanupScope` plus `StorageCleanupPreset`. Preview and execute are separately single-flight; the selected scope, preset, counts, and consequence remain in the confirmed-operation target through running/failure, and success refreshes History before publishing the outcome in `src/features/history/useStorageCleanup.ts:23-78` and `src/features/history/StorageCleanupCard.tsx:125-185`. Cleanup failures are localized from semantic problem codes/operation parameters while diagnostics remain separate in `src/features/history/storageCleanupProblems.ts:9-52`. - Stream renders capability-localized service, polling, display-window, crop, and one-off action problems beside the controls that can recover them; diagnostics remain in the optional disclosure rather than becoming user copy in `src/pages/Stream.tsx:34-137`, `src/pages/Stream.tsx:182-319`, and `src/pages/Stream.tsx:324-358`. - Stream status, database, window, and notice copy is derived from the current translator at render time. Stale running/stopped values have distinct presentation and are not presented as authoritative in `src/features/stream/streamPresentation.ts:20-80`. ## Update Modal - The update modal is phase-driven: `available`, `downloading`, `installing`, `ready`, and install-sourced `error` render in the modal path in `src/features/about/updater.ts:90-103`. -- During download/install the modal is not dismissible because `downloadAndInstall` is not cancellable in `src/layouts/ShellUpdateModal.tsx:23-29`. -- Download progress reports downloaded MB and percentage when total size is known in `src/layouts/ShellUpdateModal.tsx:165-194`. +- During download/install the modal source adopts critical blocked policy because `downloadAndInstall` is not cancellable; the shared dialog enforces Escape/backdrop behavior while the modal removes dismiss actions in `src/layouts/GlobalShell.tsx:118-133` and `src/layouts/ShellUpdateModal.tsx:23-41`. +- Download progress reports downloaded MB and percentage when total size is known in `src/layouts/ShellUpdateModal.tsx:162-190`. diff --git a/docs/truth/updater-release.md b/docs/truth/updater-release.md index 674712b..16ef8d0 100644 --- a/docs/truth/updater-release.md +++ b/docs/truth/updater-release.md @@ -1,7 +1,7 @@ --- status: truth topic: updater-release -last-verified: 45764680a4476063a46a92f4606dd520f0ce29ef +last-verified: 838d5d6bf30e16a277e5b367b648333e8923759a --- # Updater And Release @@ -14,6 +14,7 @@ last-verified: 45764680a4476063a46a92f4606dd520f0ce29ef - The updater implementation keeps the `Update` handle alive across user interactions because `downloadAndInstall` must run on the same handle returned by `check()` in `src/features/about/updater.ts:6-14`. - `runCheck` returns `preview` outside Tauri runtime, `available` with version/notes/handle when a plugin update exists, or `current` when none exists in `src/features/about/updater.ts:42-56`. - The state machine deduplicates checks, surfaces manual check errors in the header, keeps startup checks silent, tracks download progress, drops consumed handles after `downloadAndInstall`, and transitions to ready/error in `src/features/about/updater.ts:139-240`. +- The update dialog registers with the global modal coordinator at system priority, so it cannot interrupt an active destructive confirmation; downloading/installing upgrades the same source to critical blocked policy because the native updater operation cannot be cancelled in `src/layouts/GlobalShell.tsx:118-133` and `src/layouts/ShellUpdateModal.tsx:23-41`. - On Windows, `downloadAndInstall` tries `relaunch()` as a fallback while expecting the NSIS installer to own close/restart behavior in `src/features/about/updater.ts:221-230`. ## Reproducible Release Inputs diff --git a/docs/truth/verification.md b/docs/truth/verification.md index b9c1fa0..ef1a79c 100644 --- a/docs/truth/verification.md +++ b/docs/truth/verification.md @@ -1,7 +1,7 @@ --- status: truth topic: verification -last-verified: 0f609de844c0cbc48e7fb53396a90d5f32776c2b +last-verified: 838d5d6bf30e16a277e5b367b648333e8923759a --- # Verification @@ -45,6 +45,7 @@ Use the smallest command that verifies the changed behavior; use the authoritati - Run Detail tests cover its four page states, preserved refresh failure, stale completion, global action gate, target-scoped retry, and bilingual semantic problem presentation in `src/features/history/runDetailPageState.test.ts:56-187` and `src/features/history/runDetailProblems.test.ts:6-39`. Shared confirmation tests cover idle and active Escape/backdrop/close/secondary behavior for blocked, detachable, and genuinely cancelable policies in `src/components/ui/ConfirmDialog.test.tsx:104-205`; the confirmed-operation tests cover blocked dismissal, repeat submission, success-only closure, retained failure, retry, and cleanup/reset/delete targets in `src/features/shared/confirmedOperation.test.ts:15-96`. - Cleanup semantic presentation and native-adapter preservation are covered in `src/features/history/storageCleanupProblems.test.ts:8-30` and `src/api/commandClient.dispatch.test.ts:97-118`; neither test treats diagnostics as user-facing copy. - Install tests cover explicit initial detection, preserved refresh failure and retry, each primary-action branch, shared disabled/loading derivation, bilingual semantic warnings/problems, partial-failure recovery parameters, and native-adapter preservation in `src/features/install/installPageState.test.ts:43-228`, `src/features/install/installProblems.test.ts:10-89`, and `src/api/commandClient.dispatch.test.ts:57-76`. Rust tests pin Install semantic serialization and service-boundary classification in `src-tauri/src/problem.rs:46-84` and `src-tauri/src/services/install/mod.rs:282-366`. +- Modal-coordinator tests cover strict priority, equal-priority FIFO, queued-source unregistration, non-preempting update/support dialogs, active confirmation upgrades, critical preemption, and connected/fallback focus restoration in `src/features/shared/modalCoordinator.test.ts:17-110`. The shell/route integration case covers a routed confirmation upgrading to blocked critical work, route-source unmount, and ordered handoff to update then payment in `src/features/shared/modalCoordinator.integration.test.ts:4-40`; controlled disclosure semantics are rendered in `src/layouts/ShellHeader.test.tsx:88-100`. ## Version And Platform Guards diff --git a/src/components/ui/Dialog.tsx b/src/components/ui/Dialog.tsx index 15dcc42..04ff631 100644 --- a/src/components/ui/Dialog.tsx +++ b/src/components/ui/Dialog.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, type ReactNode } from 'react'; +import { useActiveModalDismissalPolicy } from './ModalCoordinator'; /** * Modal dialog backed by the native element. showModal() gives us the @@ -21,6 +22,8 @@ export function Dialog({ children: ReactNode; }) { const ref = useRef(null); + const dismissalPolicy = useActiveModalDismissalPolicy(); + const dismissalBlocked = dismissalPolicy === 'blocked'; useEffect(() => { const el = ref.current; @@ -38,11 +41,13 @@ export function Dialog({ onCancel={(event) => { // Escape fires `cancel`; we own the close so the parent state stays in sync. event.preventDefault(); - onClose('escape'); + if (!dismissalBlocked) onClose('escape'); }} onClick={(event) => { // A click on the dialog itself (the backdrop area around the card) closes it. - if (event.target === event.currentTarget) onClose('backdrop'); + if (!dismissalBlocked && event.target === event.currentTarget) { + onClose('backdrop'); + } }} > {children} diff --git a/src/components/ui/ModalCoordinator.tsx b/src/components/ui/ModalCoordinator.tsx new file mode 100644 index 0000000..01e8abe --- /dev/null +++ b/src/components/ui/ModalCoordinator.tsx @@ -0,0 +1,127 @@ +import { + createContext, + use, + useEffect, + useLayoutEffect, + useRef, + useSyncExternalStore, + type ReactNode, + type RefObject +} from 'react'; +import { + createModalCoordinator, + restoreModalFocus, + type ModalCoordinator, + type ModalDismissalPolicy, + type ModalPriority, + type RegisteredModal +} from '../../features/shared/modalCoordinator'; + +const ModalCoordinatorContext = createContext(null); +const ActiveModalPolicyContext = createContext( + null +); + +export function ModalCoordinatorProvider({ + children +}: { + children: ReactNode; +}) { + const coordinatorRef = useRef(null); + coordinatorRef.current ??= createModalCoordinator(); + const coordinator = coordinatorRef.current; + const snapshot = useSyncExternalStore( + coordinator.subscribe, + coordinator.getSnapshot, + coordinator.getSnapshot + ); + const previousActiveRef = useRef(null); + + useEffect(() => { + const previous = previousActiveRef.current; + previousActiveRef.current = snapshot.active; + if (!previous || previous.id === snapshot.active?.id) return; + + const wasPreempted = snapshot.queued.some(({ id }) => id === previous.id); + if (wasPreempted || snapshot.active) return; + + const preferred = previous.restoreFocusTo?.() ?? null; + let cancelled = false; + queueMicrotask(() => { + if (cancelled || coordinator.getSnapshot().active) return; + restoreModalFocus(preferred, [ + document.querySelector('[data-page-heading]'), + document.querySelector('main') + ]); + }); + return () => { + cancelled = true; + }; + }, [coordinator, snapshot]); + + return ( + + {children} + + ); +} + +export function ModalSource({ + id, + open, + priority, + dismissalPolicy, + restoreFocusRef, + children +}: { + id: string; + open: boolean; + priority: ModalPriority; + dismissalPolicy: ModalDismissalPolicy; + restoreFocusRef?: RefObject; + children: ReactNode; +}) { + const coordinator = use(ModalCoordinatorContext); + if (!coordinator) { + throw new Error( + 'ModalSource must be used inside ModalCoordinatorProvider.' + ); + } + const snapshot = useSyncExternalStore( + coordinator.subscribe, + coordinator.getSnapshot, + coordinator.getSnapshot + ); + + useLayoutEffect(() => { + if (!open) return; + const activeElement = + document.activeElement instanceof HTMLElement && + document.activeElement !== document.body && + document.activeElement !== document.documentElement + ? document.activeElement + : null; + return coordinator.register({ + id, + priority, + dismissalPolicy, + restoreFocusTo: () => restoreFocusRef?.current ?? activeElement + }); + }, [coordinator, id, open, restoreFocusRef]); + + useLayoutEffect(() => { + if (!open) return; + coordinator.update(id, { priority, dismissalPolicy }); + }, [coordinator, dismissalPolicy, id, open, priority]); + + if (!open || snapshot.active?.id !== id) return null; + return ( + + {children} + + ); +} + +export function useActiveModalDismissalPolicy(): ModalDismissalPolicy | null { + return use(ActiveModalPolicyContext); +} diff --git a/src/components/ui/PageHeader.tsx b/src/components/ui/PageHeader.tsx index 5259d47..a445b92 100644 --- a/src/components/ui/PageHeader.tsx +++ b/src/components/ui/PageHeader.tsx @@ -28,7 +28,11 @@ export function PageHeader({ function Title({ children }: { children: ReactNode }) { return ( -

+

{children}

); diff --git a/src/features/history/StorageCleanupCard.tsx b/src/features/history/StorageCleanupCard.tsx index 27bf4aa..33a0d31 100644 --- a/src/features/history/StorageCleanupCard.tsx +++ b/src/features/history/StorageCleanupCard.tsx @@ -15,6 +15,7 @@ import { type CleanupScope, type PendingCleanup } from './useStorageCleanup'; +import { ModalSource } from '../../components/ui/ModalCoordinator'; const PRESETS: Array<{ preset: StorageCleanupPreset; @@ -121,55 +122,67 @@ export function StorageCleanupCard({ - {cleanup.pending && ( - -

- {t('storageCleanupTarget', { - scope: - cleanup.pending.scope === 'screenshots' - ? t('storageCleanupScreenshotsLabel') - : t('storageCleanupRunDataLabel'), - preset: t( - PRESETS.find(({ preset }) => preset === cleanup.pending?.preset) - ?.labelKey ?? 'storageCleanupPresetAll' - ) - })} -

-

- {pendingBody(cleanup.pending)} -

- {cleanup.pending.preview.skipped_pending_uploads > 0 && ( -

- {t('storageCleanupSkippedPending', { - count: cleanup.pending.preview.skipped_pending_uploads + + {cleanup.pending && ( + +

+ {t('storageCleanupTarget', { + scope: + cleanup.pending.scope === 'screenshots' + ? t('storageCleanupScreenshotsLabel') + : t('storageCleanupRunDataLabel'), + preset: t( + PRESETS.find( + ({ preset }) => preset === cleanup.pending?.preset + )?.labelKey ?? 'storageCleanupPresetAll' + ) })}

- )} - {cleanup.problem && ( - - )} -
- )} +

+ {pendingBody(cleanup.pending)} +

+ {cleanup.pending.preview.skipped_pending_uploads > 0 && ( +

+ {t('storageCleanupSkippedPending', { + count: cleanup.pending.preview.skipped_pending_uploads + })} +

+ )} + {cleanup.problem && ( + + )} + + )} + ); } diff --git a/src/features/shared/modalCoordinator.integration.test.ts b/src/features/shared/modalCoordinator.integration.test.ts new file mode 100644 index 0000000..c40e0d0 --- /dev/null +++ b/src/features/shared/modalCoordinator.integration.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { createModalCoordinator } from './modalCoordinator'; + +describe('shell and routed modal integration', () => { + it('queues background shell sources behind a routed confirmation across source unmounts', () => { + const coordinator = createModalCoordinator(); + coordinator.register({ + id: 'route:delete-video', + priority: 'confirmation', + dismissalPolicy: 'dismissible' + }); + coordinator.register({ + id: 'shell:update', + priority: 'system', + dismissalPolicy: 'dismissible' + }); + coordinator.register({ + id: 'shell:payment', + priority: 'informational', + dismissalPolicy: 'dismissible' + }); + + expect(coordinator.getSnapshot().active?.id).toBe('route:delete-video'); + + coordinator.update('route:delete-video', { + priority: 'critical', + dismissalPolicy: 'blocked' + }); + expect(coordinator.getSnapshot().active).toMatchObject({ + id: 'route:delete-video', + priority: 'critical', + dismissalPolicy: 'blocked' + }); + + coordinator.unregister('route:delete-video'); + expect(coordinator.getSnapshot().active?.id).toBe('shell:update'); + + coordinator.unregister('shell:update'); + expect(coordinator.getSnapshot().active?.id).toBe('shell:payment'); + }); +}); diff --git a/src/features/shared/modalCoordinator.test.ts b/src/features/shared/modalCoordinator.test.ts new file mode 100644 index 0000000..7a1f5a7 --- /dev/null +++ b/src/features/shared/modalCoordinator.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createModalCoordinator, + restoreModalFocus, + type ModalPriority, + type ModalRequest +} from './modalCoordinator'; + +function request(id: string, priority: ModalPriority): ModalRequest { + return { + id, + priority, + dismissalPolicy: priority === 'critical' ? 'blocked' : 'dismissible' + }; +} + +describe('modal coordinator', () => { + it('uses critical, confirmation, system, then informational priority', () => { + const coordinator = createModalCoordinator(); + + coordinator.register(request('payment', 'informational')); + expect(coordinator.getSnapshot().active?.id).toBe('payment'); + + coordinator.register(request('update', 'system')); + expect(coordinator.getSnapshot().active?.id).toBe('update'); + + coordinator.register(request('reset', 'confirmation')); + expect(coordinator.getSnapshot().active?.id).toBe('reset'); + + coordinator.register(request('delete-running', 'critical')); + expect(coordinator.getSnapshot().active?.id).toBe('delete-running'); + }); + + it('does not let update or support requests interrupt an active confirmation', () => { + const coordinator = createModalCoordinator(); + coordinator.register(request('cleanup', 'confirmation')); + coordinator.register(request('update', 'system')); + coordinator.register(request('payment', 'informational')); + + expect(coordinator.getSnapshot().active?.id).toBe('cleanup'); + expect(coordinator.getSnapshot().queued.map(({ id }) => id)).toEqual([ + 'update', + 'payment' + ]); + }); + + it('keeps equal priorities FIFO and safely removes a queued source', () => { + const coordinator = createModalCoordinator(); + coordinator.register(request('first-reset', 'confirmation')); + coordinator.register(request('second-delete', 'confirmation')); + coordinator.register(request('third-cleanup', 'confirmation')); + + coordinator.unregister('second-delete'); + coordinator.unregister('first-reset'); + + expect(coordinator.getSnapshot().active?.id).toBe('third-cleanup'); + expect(coordinator.getSnapshot().queued).toEqual([]); + }); + + it('retains an active confirmation when it adopts critical blocked semantics', () => { + const coordinator = createModalCoordinator(); + coordinator.register(request('reset', 'confirmation')); + coordinator.register(request('other-confirmation', 'confirmation')); + + coordinator.update('reset', { + priority: 'critical', + dismissalPolicy: 'blocked' + }); + + expect(coordinator.getSnapshot().active).toMatchObject({ + id: 'reset', + priority: 'critical', + dismissalPolicy: 'blocked' + }); + expect(coordinator.getSnapshot().queued.map(({ id }) => id)).toEqual([ + 'other-confirmation' + ]); + }); + + it('returns to a preempted source after critical work unregisters', () => { + const coordinator = createModalCoordinator(); + coordinator.register(request('update', 'system')); + coordinator.register(request('reset', 'confirmation')); + coordinator.register(request('native-critical', 'critical')); + + expect(coordinator.getSnapshot().active?.id).toBe('native-critical'); + coordinator.unregister('native-critical'); + expect(coordinator.getSnapshot().active?.id).toBe('reset'); + }); +}); + +describe('modal focus restoration', () => { + it('prefers a still-connected trigger', () => { + const trigger = { isConnected: true, focus: vi.fn() }; + const heading = { isConnected: true, focus: vi.fn() }; + + expect(restoreModalFocus(trigger, [heading])).toBe(trigger); + expect(trigger.focus).toHaveBeenCalledOnce(); + expect(heading.focus).not.toHaveBeenCalled(); + }); + + it('falls back to the first connected page target after route unmount', () => { + const staleTrigger = { isConnected: false, focus: vi.fn() }; + const staleHeading = { isConnected: false, focus: vi.fn() }; + const main = { isConnected: true, focus: vi.fn() }; + + expect(restoreModalFocus(staleTrigger, [staleHeading, main])).toBe(main); + expect(main.focus).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/features/shared/modalCoordinator.ts b/src/features/shared/modalCoordinator.ts new file mode 100644 index 0000000..8bfc1a4 --- /dev/null +++ b/src/features/shared/modalCoordinator.ts @@ -0,0 +1,125 @@ +export type ModalPriority = + | 'critical' + | 'confirmation' + | 'system' + | 'informational'; + +export type ModalDismissalPolicy = 'dismissible' | 'blocked'; + +export interface ModalFocusTarget { + readonly isConnected: boolean; + focus(): void; +} + +export interface ModalRequest { + id: string; + priority: ModalPriority; + dismissalPolicy: ModalDismissalPolicy; + restoreFocusTo?: () => ModalFocusTarget | null; +} + +export type RegisteredModal = ModalRequest & { sequence: number }; + +export type ModalCoordinatorSnapshot = { + active: RegisteredModal | null; + queued: readonly RegisteredModal[]; +}; + +export interface ModalCoordinator { + getSnapshot(): ModalCoordinatorSnapshot; + subscribe(listener: () => void): () => void; + register(request: ModalRequest): () => void; + update(id: string, patch: Partial>): boolean; + unregister(id: string): boolean; +} + +const PRIORITY_RANK: Record = { + critical: 4, + confirmation: 3, + system: 2, + informational: 1 +}; + +export function createModalCoordinator(): ModalCoordinator { + const entries = new Map(); + const listeners = new Set<() => void>(); + let nextSequence = 0; + let activeId: string | null = null; + let snapshot: ModalCoordinatorSnapshot = { active: null, queued: [] }; + + const sortedEntries = () => + [...entries.values()].sort( + (left, right) => + PRIORITY_RANK[right.priority] - PRIORITY_RANK[left.priority] || + left.sequence - right.sequence + ); + + const publish = () => { + const ordered = sortedEntries(); + const current = activeId ? entries.get(activeId) : null; + if (!current) { + activeId = ordered[0]?.id ?? null; + } else { + const challenger = ordered.find(({ id }) => id !== current.id); + if ( + challenger && + PRIORITY_RANK[challenger.priority] > PRIORITY_RANK[current.priority] + ) { + activeId = challenger.id; + } + } + + const active = activeId ? (entries.get(activeId) ?? null) : null; + snapshot = { + active, + queued: sortedEntries().filter(({ id }) => id !== active?.id) + }; + for (const listener of listeners) listener(); + }; + + const unregister = (id: string) => { + if (!entries.delete(id)) return false; + if (activeId === id) activeId = null; + publish(); + return true; + }; + + return { + getSnapshot: () => snapshot, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + register: (request) => { + const existing = entries.get(request.id); + entries.set(request.id, { + ...request, + sequence: existing?.sequence ?? nextSequence++ + }); + publish(); + return () => { + unregister(request.id); + }; + }, + update: (id, patch) => { + const existing = entries.get(id); + if (!existing) return false; + entries.set(id, { ...existing, ...patch, id }); + publish(); + return true; + }, + unregister + }; +} + +export function restoreModalFocus( + preferred: ModalFocusTarget | null, + fallbacks: readonly (ModalFocusTarget | null)[] +): ModalFocusTarget | null { + const target = [preferred, ...fallbacks].find( + (candidate): candidate is ModalFocusTarget => + candidate !== null && candidate.isConnected + ); + target?.focus(); + return target ?? null; +} diff --git a/src/layouts/GlobalShell.tsx b/src/layouts/GlobalShell.tsx index f2a06fc..40bf5da 100644 --- a/src/layouts/GlobalShell.tsx +++ b/src/layouts/GlobalShell.tsx @@ -1,5 +1,5 @@ import { Outlet } from 'react-router-dom'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { AppBootstrapProvider, useAppBootstrap @@ -10,12 +10,18 @@ import { ShellHeader } from './ShellHeader'; import { ShellNavRail } from './ShellNavRail'; import { ShellPaymentModal } from './ShellPaymentModal'; import { ShellUpdateModal } from './ShellUpdateModal'; +import { + ModalCoordinatorProvider, + ModalSource +} from '../components/ui/ModalCoordinator'; export default function GlobalShell() { return ( - + + + ); @@ -25,6 +31,8 @@ function GlobalShellContent() { const [showBilibili, setShowBilibili] = useState(false); const [showSupport, setShowSupport] = useState(false); const [showPaymentModal, setShowPaymentModal] = useState(false); + const bilibiliTriggerRef = useRef(null); + const supportTriggerRef = useRef(null); const app = useAppBootstrap(); const updater = useUpdater(); @@ -32,12 +40,16 @@ function GlobalShellContent() { // behaviour these controlled dropdowns were missing. useEffect(() => { if (!showBilibili && !showSupport) return; - const closeMenus = () => { + const closeMenus = (restoreFocus = false) => { + const focusTarget = showBilibili + ? bilibiliTriggerRef.current + : supportTriggerRef.current; setShowBilibili(false); setShowSupport(false); + if (restoreFocus) queueMicrotask(() => focusTarget?.focus()); }; const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') closeMenus(); + if (event.key === 'Escape') closeMenus(true); }; const onPointerDown = (event: PointerEvent) => { const target = event.target as HTMLElement | null; @@ -55,12 +67,14 @@ function GlobalShellContent() {
{ setShowBilibili((open) => !open); setShowSupport(false); }} showSupport={showSupport} + supportTriggerRef={supportTriggerRef} onToggleSupport={() => { setShowSupport((open) => !open); setShowBilibili(false); @@ -75,7 +89,10 @@ function GlobalShellContent() {
-
+
- {showPaymentModal && ( + setShowPaymentModal(false)} /> - )} - {isUpdateModalPhase(updater) && } + + + +
); } diff --git a/src/layouts/ShellHeader.test.tsx b/src/layouts/ShellHeader.test.tsx index 9e665d9..3b4da89 100644 --- a/src/layouts/ShellHeader.test.tsx +++ b/src/layouts/ShellHeader.test.tsx @@ -24,15 +24,21 @@ const app: AppBootstrapController = { } }; -function renderOpenHeader() { +function renderHeader({ + showBilibili = false, + showSupport = false +}: { + showBilibili?: boolean; + showSupport?: boolean; +} = {}) { return renderToStaticMarkup( undefined} - showSupport={false} + showSupport={showSupport} onToggleSupport={() => undefined} onOpenPayment={() => undefined} onCloseBilibili={() => undefined} @@ -45,7 +51,7 @@ function renderOpenHeader() { describe('ShellHeader Bilibili menu', () => { it('shows the author, CoreDev, and project entries in order', () => { - const html = renderOpenHeader(); + const html = renderHeader({ showBilibili: true }); const authorHrefIndex = html.indexOf('https://example.com/bilibili-author'); const coreDevHrefIndex = html.indexOf( @@ -78,4 +84,18 @@ describe('ShellHeader Bilibili menu', () => { expect(coreDevSubtitleIndex).toBeLessThan(projectIndex); expect(projectSubtitleIndex).toBeGreaterThan(projectIndex); }); + + it('exposes controlled keyboard-operable disclosure semantics', () => { + const closed = renderHeader(); + const bilibiliOpen = renderHeader({ showBilibili: true }); + const supportOpen = renderHeader({ showSupport: true }); + + expect(closed).toContain('aria-controls="shell-bilibili-menu"'); + expect(closed).toContain('aria-controls="shell-support-menu"'); + expect(closed.match(/aria-expanded="false"/g)).toHaveLength(2); + expect(bilibiliOpen).toContain('id="shell-bilibili-menu"'); + expect(bilibiliOpen).toContain('aria-expanded="true"'); + expect(supportOpen).toContain('id="shell-support-menu"'); + expect(supportOpen).toContain('aria-expanded="true"'); + }); }); diff --git a/src/layouts/ShellHeader.tsx b/src/layouts/ShellHeader.tsx index 20e7661..0af7c60 100644 --- a/src/layouts/ShellHeader.tsx +++ b/src/layouts/ShellHeader.tsx @@ -10,7 +10,7 @@ import { QrCode, Users } from 'lucide-react'; -import type { CSSProperties, ReactNode } from 'react'; +import type { CSSProperties, ReactNode, RefObject } from 'react'; import type { AppBootstrapController } from '../features/about/useAppBootstrap'; import { useUpdater } from '../features/about/UpdaterProvider'; import { useI18n } from '../i18n/LocaleProvider'; @@ -19,9 +19,11 @@ import xiaohongshuSvg from '../../static/support/xiaohongshu.svg'; type ShellHeaderProps = { app: AppBootstrapController; + bilibiliTriggerRef?: RefObject; showBilibili: boolean; onToggleBilibili: () => void; showSupport: boolean; + supportTriggerRef?: RefObject; onToggleSupport: () => void; onOpenPayment: () => void; onCloseBilibili: () => void; @@ -30,9 +32,11 @@ type ShellHeaderProps = { export function ShellHeader({ app, + bilibiliTriggerRef, showBilibili, onToggleBilibili, showSupport, + supportTriggerRef, onToggleSupport, onOpenPayment, onCloseBilibili, @@ -54,9 +58,11 @@ export function ShellHeader({ ; showBilibili: boolean; onToggleBilibili: () => void; showSupport: boolean; + supportTriggerRef?: RefObject; onToggleSupport: () => void; onOpenPayment: () => void; onCloseBilibili: () => void; @@ -159,9 +167,11 @@ type ShellHeaderActionsProps = { function ShellHeaderActions({ bootstrap, + bilibiliTriggerRef, showBilibili, onToggleBilibili, showSupport, + supportTriggerRef, onToggleSupport, onOpenPayment, onCloseBilibili, @@ -198,6 +208,7 @@ function ShellHeaderActions({
; showBilibili: boolean; onToggleBilibili: () => void; onCloseBilibili: () => void; @@ -442,6 +456,7 @@ function ShellSocialLinks({
{showBilibili && ( -
+