diff --git a/docs/evidence/framey-dialog-controls-2026-10-26.json b/docs/evidence/framey-dialog-controls-2026-10-26.json new file mode 100644 index 0000000..851a1fd --- /dev/null +++ b/docs/evidence/framey-dialog-controls-2026-10-26.json @@ -0,0 +1,311 @@ +{ + "checkedAtUtc": "2026-10-26T09:28:48.295443+00:00", + "plugin": { + "id": 124, + "name": "AlarMe", + "version": "1.6.3", + "sha256": "17d4166a9e98979df5fdb07c7eae42317acd54a453d3b82757508f971193c0fe" + }, + "status": "partial", + "sharedControls": { + "ConfirmModalOfficialImporters": 43, + "FocusableOfficialImporters": 65, + "claim": "Import counts show shared surface, not successful behavior in every plugin. Only AlarMe received a real-backend follow-up." + }, + "realFrame": { + "os": "Frame OS 0.5.6", + "arch": "aarch64", + "decky": { + "version": "v3.3.0", + "sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6" + }, + "transport": "trusted SSH through coordinator host; receipt-bound real CEF Decky RPC/events", + "isolation": "Exact official archive installed by production APPLY into a random private path. Removable unprivileged user service, not enabled, RuntimeMaxSec=900 and TimeoutStopSec=5.", + "settings": { + "timer_subtle_mode": true, + "timer_volume": 0, + "timer_auto_suspend": false, + "timer_prevent_sleep": false, + "pomodoro_subtle_mode": true, + "pomodoro_volume": 0, + "pomodoro_prevent_sleep": false, + "overlay_enabled": false + }, + "safeRpcResults": [ + { + "method": "create_timer", + "args": [ + 300, + "Framey silent private timer", + true, + false, + false + ], + "result": "91c1f87d" + }, + { + "method": "pause_timer", + "args": [ + "91c1f87d" + ], + "result": true + }, + { + "method": "save_preset_from_timer", + "args": [ + "91c1f87d" + ], + "result": { + "id": "47c046cd", + "seconds": 300, + "label": "Framey silent private timer", + "subtle_mode": true, + "auto_suspend": false, + "prevent_sleep": false + } + }, + { + "method": "remove_preset", + "args": [ + "47c046cd" + ], + "result": true + }, + { + "method": "cancel_timer", + "args": [ + "91c1f87d" + ], + "result": true + }, + { + "method": "stopwatch_start", + "args": [], + "result": { + "status": "running", + "start_time": 1793006404.791917, + "elapsed_ms": 0, + "laps": [], + "prevent_sleep": false, + "current_elapsed_ms": 1.0344982147216797 + } + }, + { + "method": "stopwatch_lap", + "args": [], + "result": { + "status": "running", + "start_time": 1793006404.791917, + "elapsed_ms": 0, + "laps": [ + { + "label": "Lap 1", + "absolute_ms": 31747.3783493042, + "split_ms": 31747.3783493042 + } + ], + "prevent_sleep": false, + "current_elapsed_ms": 31750.77533721924, + "total_lap_count": 1, + "lap_limit_reached": false + } + }, + { + "method": "stopwatch_pause", + "args": [], + "result": { + "status": "paused", + "start_time": null, + "elapsed_ms": 34339.426040649414, + "laps": [ + { + "label": "Lap 1", + "absolute_ms": 31747.3783493042, + "split_ms": 31747.3783493042 + } + ], + "prevent_sleep": false, + "current_elapsed_ms": 34339.426040649414, + "total_lap_count": 1, + "lap_limit_reached": false + } + }, + { + "method": "stopwatch_reset", + "args": [], + "result": { + "status": "idle", + "start_time": null, + "elapsed_ms": 0, + "laps": [], + "current_elapsed_ms": 0 + } + } + ], + "eventCounts": { + "alarme_timers_updated": 3, + "alarme_sleep_inhibitor_updated": 6, + "alarme_timer_tick": 124, + "alarme_presets_updated": 2, + "alarme_stopwatch_updated": 3 + }, + "finalReadback": { + "timers": [], + "stopwatch": { + "status": "idle", + "start_time": null, + "elapsed_ms": 0, + "laps": [], + "current_elapsed_ms": 0 + }, + "sleep": { + "active": false, + "items": [] + }, + "presets": [ + { + "id": "preset-5", + "seconds": 300, + "label": "5 minutes", + "subtle_mode": false, + "auto_suspend": false + }, + { + "id": "preset-10", + "seconds": 600, + "label": "10 minutes", + "subtle_mode": false, + "auto_suspend": false + }, + { + "id": "preset-15", + "seconds": 900, + "label": "15 minutes", + "subtle_mode": false, + "auto_suspend": false + }, + { + "id": "preset-30", + "seconds": 1800, + "label": "30 minutes", + "subtle_mode": false, + "auto_suspend": false + } + ] + }, + "before": { + "decky": "failed", + "panel": "inactive", + "battery": [ + 97 + ] + }, + "after": { + "decky": "failed", + "panel": "inactive", + "battery": [ + 96 + ] + }, + "cleanup": { + "baseAbsent": true, + "unitAbsent": true + }, + "lockReleased": true + }, + "browser": { + "host": "Repository headset.html and opaque allow-scripts iframe with bundled React 18.3.1; loopback RPC/event relay to the real ARM64 backend", + "verified": [ + "labeled silent timer creation and live tick display", + "timer pause through onActivate and backend readback", + "save paused timer as preset through onActivate", + "delete confirmation shows preset name and description", + "Cancel closes dialog without dispatching remove_preset; backend readback retains preset", + "Delete invokes remove_preset once and readback removes the private preset", + "timer cancellation", + "Watch tab activation; stopwatch start/lap/pause", + "fresh panel reopen retains paused 34.339-second stopwatch and one lap", + "stopwatch reset; no active timers or sleep inhibitors in final backend readback" + ], + "domFixture": { + "type": "fixture:result", + "checks": [ + "description visible", + "underlying panel inert", + "dialog fixed", + "Tab wraps in top dialog", + "Cancel callback once", + "parent draft preserved", + "disabled actions", + "old Close retains child", + "programmatic Close skips callback", + "OK does not close newly opened dialog", + "Update changes body", + "Escape cancels top dialog", + "panel reactivated", + "pointer Enter Space activate once each" + ] + }, + "limits": "Local Chromium with real backend, not the deployed headset HTTP host or worn VR. Synthetic DOM checks use programmatic DOM events, not physical controllers." + }, + "fixtureCorrection": { + "issue": "Initial seed used settings/; actual Decky uses settings/. Safety assertion failed on readback before any timer action.", + "resolution": "First private service/data removed and lock released. Correct folder seed read back before second run exposed actions." + }, + "cleanupAudit": { + "servicesAudited": 2, + "allBaseUnitArchiveAbsent": true, + "allLoadStateNotFound": true, + "allActiveStateInactive": true, + "allMainPidZero": true, + "sharedLock": "Fresh atomic lock acquired and released for cleanup audit" + }, + "unverified": [ + "audio playback and sound imports", + "alarms and reminders", + "auto-suspend and /dev/uinput sleep inhibition", + "in-game/global Steam overlay: no injection target in isolated panel", + "background notifications after panel closes", + "worn VR and physical controller/keyboard input" + ], + "validation": { + "fullPython": { + "ran": 1628, + "skipped": 16, + "result": "pass" + }, + "nodeContracts": [ + "tests/page/framey_actions.mjs", + "tests/page/framey_running.mjs" + ], + "siteTestFiles": 2, + "currentStoreRefresh": { + "plugins": 116, + "exactIdsVersionsAndDigestsUnchanged": true + }, + "fakeFrame": { + "blocked": 56, + "installed-removed": 60 + }, + "noContextBrowser": { + "plugins": 116, + "ready": 101, + "failed": 15, + "noRegressionsAgainstCatalogBaseline": true, + "scope": "Initialization only, all backend/network denied; library-dependent plugins lack Steam context" + }, + "contextReplayBrowser": { + "plugins": 116, + "ready": 104, + "failed": 12, + "initializationRegressionsAgainstPriorContextReplay": [], + "scope": "Synthetic library/running replay; backend and network denied, initialization only" + } + }, + "reviewFollowUp": { + "checkedAtUtc": "2026-10-26T10:10:05.650676+00:00", + "change": "Update reinjects the scoped close callback into replacement React dialogs and uses the replacement close callback.", + "verification": "Node regression: updated ConfirmModal OK and Cancel, with and without custom close callbacks; ClosedPromise resolves; original callback not retained; repeated Close/Update inert. Regression reproduced before the fix.", + "limits": "No additional local Chromium or real Frame check for this small follow-up; prior runtime evidence applies to initial dialog and workflow paths." + } +} diff --git a/docs/evidence/framey-dialog-controls-2026-10-26.md b/docs/evidence/framey-dialog-controls-2026-10-26.md new file mode 100644 index 0000000..2dc526a --- /dev/null +++ b/docs/evidence/framey-dialog-controls-2026-10-26.md @@ -0,0 +1,107 @@ +# Framey activation and dialog follow-up — 2026-10-26 + +AlarMe 1.6.3 now has usable timer, preset and stopwatch controls in the local +Framey host with its official backend running on the ARM64 Frame. It remains +**partial**. This check does not establish audio, alarm/reminder, sleep-control, +background-notification or worn-VR compatibility. + +The exact archive is store ID 124, SHA-256 +`17d4166a9e98979df5fdb07c7eae42317acd54a453d3b82757508f971193c0fe`. +See the [structured results](framey-dialog-controls-2026-10-26.json). + +## Reproduced blockers and repairs + +AlarMe uses DFL `Focusable.onActivate` for timer pause, save-as-preset, cancel, +preset deletion, tabs and stopwatch controls. Framey's previous `div` +passthrough ignored that callback: clicks on the save/pause controls did not +dispatch backend calls. Focusable now maps pointer activation and Enter/Space +to plugin callbacks, exposes a keyboard focus target and respects disabled +controls. A click does not invoke both `onClick` and `onActivate` when a plugin +supplies both; nested activation stops bubbling to the parent action. + +The previous ConfirmModal omitted `strDescription`, Cancel, middle actions and +disabled flags. showModal did not inject a `closeModal` callback into custom +dialog components, and every Close handle cleared the same root. A confirmation +opened by another dialog could therefore disappear when the earlier action +closed. Dialogs also appeared below the panel's long page content. + +Dialogs now occupy a scrollable overlay inside the opaque iframe. They preserve +the underlying dialog's mounted state, receive scoped close callbacks, support +Close/ClosedPromise/Update, show confirmation descriptions and cancel/middle +actions, and respect disabled flags. The underlying plugin surface becomes +inert while a dialog is open; Tab stays in the top dialog, Escape cancels it and +closing restores focus. Steam popouts and controller navigation are not emulated. + +43 official archives import ConfirmModal and 65 import Focusable in this +snapshot. These are shared API surfaces, **not** verification or status +promotions for every importing plugin. + +## Real backend and local browser verification + +Held the normal atomic shared headset lock. Installed the exact official archive +through the production archive validation/extraction path into a random private +directory. Used published Decky 3.3.0 ARM64, an unprivileged user service that was +never enabled, with a 900-second runtime bound and five-second stop timeout. +RPCs and events used Framey's production receipt-bound bridge through real Steam +CEF. The Chromium host served repository headset.html, React and framey-host.js; +its status endpoints were fixture-shaped, while AlarMe RPCs/events were live. + +The initial private settings seed used the plugin display name instead of the +archive folder. A safety assertion failed on readback of the defaults before any +timer action. That service/data was removed and the lock released. The corrected +run read back subtle mode, zero volume, no auto-suspend, no sleep inhibition and +no overlay before exposing actions. Its relay allowed only the documented safe +methods; sound, suspend, sleep-inhibitor and arbitrary plugin actions were denied. + +Verified with actual browser clicks and real backend readback: + +1. Add Label opened a custom timer dialog. Created a silent five-minute timer + named `Framey silent private timer`; live backend ticks changed its display. +2. Pause and save-as-preset activated after the Focusable repair. Backend + readback reported a paused timer and its saved preset. +3. Delete Preset showed the preset's name in its confirmation description. + Cancel closed the dialog without dispatching `remove_preset`; readback kept + the preset. Reopening and choosing Delete dispatched it once and removed it. +4. Cancel removed the private timer. Watch opened the stopwatch; Start, Lap and + Pause produced real backend state and events. +5. Closing and reopening the panel retained the paused stopwatch at 34.339 + seconds and one lap. Reset cleared it. Final readback showed no active timers, + no laps and no active sleep inhibitor. + +A separate local Chromium DOM fixture passed 14 checks for nested-dialog state, +scoped handles, descriptions, disabled buttons, Cancel, Escape, Tab wrapping, +Close/ClosedPromise/Update and pointer/Enter/Space activation. These programmatic +DOM checks do not verify physical input devices. + +Review follow-up: Update also injects the scoped close callback into replacement +dialogs. Node regression contracts update to ConfirmModal and exercise both OK +and Cancel, with and without a plugin close callback, then await ClosedPromise. +They verify that the replacement's callback runs once and the original callback +is not retained. This extends the local host's Update behavior so replacements +remain dismissible; this follow-up was not rerun in Chromium or on the headset. + +## Cleanup and remaining limits + +Removed both private service units, bases and uploaded archives; reloaded the +user service manager and released the matching shared lock. A fresh locked audit +confirmed both units were not found, inactive and PID zero, with all test paths +absent. Managed Decky remained failed and the headset panel inactive, matching +the initial state. Battery was 97% before and 96% after. No power settings changed. + +AlarMe stays partial. Audio, sound imports, alarms/reminders, auto-suspend and +`/dev/uinput` sleep inhibition were not exercised. Steam's global/in-game overlay +has no injection target inside Framey's isolated panel. Notifications require +the plugin panel to remain open. The deployed headset HTTP host, worn VR and +physical controller/keyboard input were not tested in this pass. + +The store and documentation use the same exact-digest catalog reason. Aggregate +counts remain **4 works / 49 partial / 52 broken / 11 N/A**. + +Validation: all 116 official IDs/versions/digests unchanged on store refresh; +fake-Frame 60 installed/removed and 56 blocked; full Python suite 1,628 tests, +16 skipped; Framey action/running Node contracts and both site test files pass. +The no-Steam-context Chromium pass attempted all 116 frontends (101 initialized, +15 failed); backend and network access were denied throughout that pass. +The repeat with synthetic library/running context initialized 104 of 116, with no +initialization regressions against the prior context-replay pass. Neither +initialization pass is a useful-functionality check. diff --git a/docs/framey-compatibility.md b/docs/framey-compatibility.md index 873c3f0..dfa65df 100644 --- a/docs/framey-compatibility.md +++ b/docs/framey-compatibility.md @@ -27,7 +27,7 @@ Open the browser audit URL in local Chromium, run `runAudit()` and inspect/downl The current published digests are pinned in the JSON; if the store changed, retain/download those exact archives for reproduction. Audit downloads permit larger ZIPs for inspection; production installer limits remain 50 MiB ZIP / 150 MiB unpacked / 30 MiB per member. -After the VR job released the shared lock, this audit acquired it and used a private temporary Decky 3.3.0 ARM64 user service. Read-only ProtonDB `get_setting` and Steam Status `get_settings` calls succeeded through the live Steam CEF websocket. The service and private base were removed and the lock released; normal managed services stayed inactive, battery was 100 → 99, and no power setting was changed. [Redacted results](evidence/framey-official-backend-2026-10-25.json) record exactly what ran. A [2026-10-26 follow-up](evidence/framey-events-backend-2026-10-26.json) verified default Notebook drawing/save/reopen across a real backend restart and Simple Timer start/cancel, countdown/completion events, a visible toast and persisted recents/subtle mode. Only private test settings were written. Simple Timer remains partial because its own expired-timer startup raises a missing `timer_task` error; sound/suspend are unsupported. A [local audio follow-up](evidence/framey-audio-backends-2026-10-26.md) exercised Volume Mixer, Volume Boost and Mixer through real Decky 3.3.0 amd64 backends on private silent virtual audio targets. Sliders, numeric volume entry, independent server readbacks and Mixer filter persistence across a runtime restart passed. An [ARM64 audio follow-up](evidence/framey-audio-arm64-2026-10-26.md) verified the unmodified official backends in actual Decky 3.3.0 on the Frame user audio session. Volume Mixer and Mixer now qualify as works for per-app volume: Chromium controls changed only a named silent stream, with independent Pulse readbacks. That run left Volume Boost partial because sink writes were denied. A [Volume Boost follow-up](evidence/framey-volume-boost-arm64-2026-10-26.md) now verifies linked/separate channel controls, re-link and reopen through the actual ARM64 backend. Independent Pulse reads confirmed 125%/150% gain writes while the default sink stayed muted; exact original volume/mute was restored. Volume Boost qualifies as works for sink controls. Audible output, Bluetooth playback and worn VR remain unverified. A [running-context follow-up](evidence/framey-running-context-2026-10-26.md) supplies detached running-app/main-app reads and relays actual Steam lifetime callbacks while a panel is open. Real Frame checks verified an empty list, registration/cleanup and the exact Discord Status ARM64 backend reporting no IPC connection. Nonempty games and transitions were synthetic fixtures; real game events, Discord activity and worn VR remain unverified. Discord Status moves to partial; no new works claim is made. A [library context follow-up](evidence/framey-library-context-2026-10-26.md) read 1,106 app overviews, 895 game IDs and the current public Steam ID from real Frame CEF under the shared lock. Local Chromium replayed this detached snapshot to render Deck Settings game selection, Steamback's empty save panel and PlayTime statistics/settings. Steamback and PlayTime used actual Decky 3.3.0 amd64 backends in an isolated container; their ARM64 backends and automatic game hooks remain unverified. Shotty's frontend now starts, but its published backend fails on missing `click`; it stays broken. A [chunked frontend follow-up](evidence/framey-deck-shelves-browser-2026-10-26.md) loaded Deck Shelves 2.0.0 through a managed JavaScript module route and rendered its settings in an opaque local Chromium panel. Its backend was denied; Steam Home shelves and worn VR remain unverified. A [local module lookup follow-up](evidence/framey-module-lookup-2026-10-26.md) supplies a finite registry of Framey-local controls and CSS. TabMaster QAM/docs now render; Steam library-tab editing remains unavailable. That pass exposed Junk-Store executable-script extraction failures; the follow-up below repairs them. SteamGridDB still requires Steam context-menu internals and DeckSP still fails its published MobX fallback. That pass tested TabMaster/Junk-Store in actual isolated amd64 Decky, without ARM64 or worn-VR coverage. A [script entrypoint follow-up](evidence/framey-script-entrypoints-2026-10-26.md) grants owner execute to shebang scripts and ZIP-declared executables without copying archive privilege/write bits. Junk-Store now passes static menu/backend calls in actual Decky 3.3.0 ARM64 and local Chromium Games/Epic navigation, back and reopen. It moves to partial: About still crashes on missing installed-app metadata, Legendary is absent, and login/game operations remain unverified. Existing installations require update/reinstall to repair permissions. A [read-only installed-metadata follow-up](evidence/framey-installed-context-2026-10-26.md) now repairs the About crash. The library snapshot copies only Steam-supplied installed booleans into detached metadata, preserving unknown values as undefined. Junk-Store About/Dependencies, Back and reopen render in local Chromium with actual Frame library data and the official ARM64 backend. It remains partial: dependency/login/game operations, RSS, the optional websocket and worn VR are unverified. Beyond the documented checks, official backends remain unexecuted. The separate VR job proves Frame Control/sample compositor rendering, not any official plugin. No worn-VR official-plugin result is claimed. A [local UI follow-up](evidence/framey-ui-fallbacks-2026-10-26.json) rendered Pause Games and Tailscale Control settings with all backend/network actions denied; it does not verify process pausing or Tailscale changes. A [read-only Steam bridge follow-up](evidence/framey-steam-reads-2026-10-26.json) verified actual Frame country, install folders and system information, plus IsThereAnyDeal's country dropdown in local Chromium. The installed-plugin status was fixture-shaped in that relay; plugin backends stayed denied and the deployed headset HTTP endpoint remains unverified. A [Web Browser frontend follow-up](evidence/framey-web-browser-2026-10-26.md) verified its exact archive now initializes with an inert null Steam React-root lookup. Its GamepadUI CDP/browser backend path remains unavailable, so it stays broken. Earlier focused checks cover [ProtonDB](evidence/framey-browser-2026-10-25.md), [Steam Status/cache](evidence/framey-cache-browser-2026-10-25.md), [Storage Cleaner](evidence/framey-api-v1-browser-2026-10-25.md), and [legacy MusicControl](evidence/framey-legacy-2026-10-25.md). +After the VR job released the shared lock, this audit acquired it and used a private temporary Decky 3.3.0 ARM64 user service. Read-only ProtonDB `get_setting` and Steam Status `get_settings` calls succeeded through the live Steam CEF websocket. The service and private base were removed and the lock released; normal managed services stayed inactive, battery was 100 → 99, and no power setting was changed. [Redacted results](evidence/framey-official-backend-2026-10-25.json) record exactly what ran. A [2026-10-26 follow-up](evidence/framey-events-backend-2026-10-26.json) verified default Notebook drawing/save/reopen across a real backend restart and Simple Timer start/cancel, countdown/completion events, a visible toast and persisted recents/subtle mode. Only private test settings were written. Simple Timer remains partial because its own expired-timer startup raises a missing `timer_task` error; sound/suspend are unsupported. A [local audio follow-up](evidence/framey-audio-backends-2026-10-26.md) exercised Volume Mixer, Volume Boost and Mixer through real Decky 3.3.0 amd64 backends on private silent virtual audio targets. Sliders, numeric volume entry, independent server readbacks and Mixer filter persistence across a runtime restart passed. An [ARM64 audio follow-up](evidence/framey-audio-arm64-2026-10-26.md) verified the unmodified official backends in actual Decky 3.3.0 on the Frame user audio session. Volume Mixer and Mixer now qualify as works for per-app volume: Chromium controls changed only a named silent stream, with independent Pulse readbacks. That run left Volume Boost partial because sink writes were denied. A [Volume Boost follow-up](evidence/framey-volume-boost-arm64-2026-10-26.md) now verifies linked/separate channel controls, re-link and reopen through the actual ARM64 backend. Independent Pulse reads confirmed 125%/150% gain writes while the default sink stayed muted; exact original volume/mute was restored. Volume Boost qualifies as works for sink controls. Audible output, Bluetooth playback and worn VR remain unverified. A [running-context follow-up](evidence/framey-running-context-2026-10-26.md) supplies detached running-app/main-app reads and relays actual Steam lifetime callbacks while a panel is open. Real Frame checks verified an empty list, registration/cleanup and the exact Discord Status ARM64 backend reporting no IPC connection. Nonempty games and transitions were synthetic fixtures; real game events, Discord activity and worn VR remain unverified. Discord Status moves to partial; no new works claim is made. A [library context follow-up](evidence/framey-library-context-2026-10-26.md) read 1,106 app overviews, 895 game IDs and the current public Steam ID from real Frame CEF under the shared lock. Local Chromium replayed this detached snapshot to render Deck Settings game selection, Steamback's empty save panel and PlayTime statistics/settings. Steamback and PlayTime used actual Decky 3.3.0 amd64 backends in an isolated container; their ARM64 backends and automatic game hooks remain unverified. Shotty's frontend now starts, but its published backend fails on missing `click`; it stays broken. A [chunked frontend follow-up](evidence/framey-deck-shelves-browser-2026-10-26.md) loaded Deck Shelves 2.0.0 through a managed JavaScript module route and rendered its settings in an opaque local Chromium panel. Its backend was denied; Steam Home shelves and worn VR remain unverified. A [local module lookup follow-up](evidence/framey-module-lookup-2026-10-26.md) supplies a finite registry of Framey-local controls and CSS. TabMaster QAM/docs now render; Steam library-tab editing remains unavailable. That pass exposed Junk-Store executable-script extraction failures; the follow-up below repairs them. SteamGridDB still requires Steam context-menu internals and DeckSP still fails its published MobX fallback. That pass tested TabMaster/Junk-Store in actual isolated amd64 Decky, without ARM64 or worn-VR coverage. A [script entrypoint follow-up](evidence/framey-script-entrypoints-2026-10-26.md) grants owner execute to shebang scripts and ZIP-declared executables without copying archive privilege/write bits. Junk-Store now passes static menu/backend calls in actual Decky 3.3.0 ARM64 and local Chromium Games/Epic navigation, back and reopen. It moves to partial: About still crashes on missing installed-app metadata, Legendary is absent, and login/game operations remain unverified. Existing installations require update/reinstall to repair permissions. A [read-only installed-metadata follow-up](evidence/framey-installed-context-2026-10-26.md) now repairs the About crash. The library snapshot copies only Steam-supplied installed booleans into detached metadata, preserving unknown values as undefined. Junk-Store About/Dependencies, Back and reopen render in local Chromium with actual Frame library data and the official ARM64 backend. It remains partial: dependency/login/game operations, RSS, the optional websocket and worn VR are unverified. Beyond the documented checks, official backends remain unexecuted. The separate VR job proves Frame Control/sample compositor rendering, not any official plugin. No worn-VR official-plugin result is claimed. A [local UI follow-up](evidence/framey-ui-fallbacks-2026-10-26.json) rendered Pause Games and Tailscale Control settings with all backend/network actions denied; it does not verify process pausing or Tailscale changes. A [read-only Steam bridge follow-up](evidence/framey-steam-reads-2026-10-26.json) verified actual Frame country, install folders and system information, plus IsThereAnyDeal's country dropdown in local Chromium. The installed-plugin status was fixture-shaped in that relay; plugin backends stayed denied and the deployed headset HTTP endpoint remains unverified. A [Web Browser frontend follow-up](evidence/framey-web-browser-2026-10-26.md) verified its exact archive now initializes with an inert null Steam React-root lookup. Its GamepadUI CDP/browser backend path remains unavailable, so it stays broken. A [dialog and activation follow-up](evidence/framey-dialog-controls-2026-10-26.md) fixes DFL Focusable pointer/keyboard activation and confirmation descriptions, cancel/disabled actions, scoped close callbacks and nested dialogs. AlarMe silent timer ticks/pause/cancel, private preset save/cancel-confirm/delete and stopwatch start/lap/pause/reset/reopen pass in local Chromium with its official Decky 3.3.0 ARM64 backend. AlarMe stays partial: audio, alarms/reminders, sleep inhibition, Steam overlays and worn VR are unverified; notifications require the panel to remain open. Earlier focused checks cover [ProtonDB](evidence/framey-browser-2026-10-25.md), [Steam Status/cache](evidence/framey-cache-browser-2026-10-25.md), [Storage Cleaner](evidence/framey-api-v1-browser-2026-10-25.md), and [legacy MusicControl](evidence/framey-legacy-2026-10-25.md). ## Every current plugin @@ -129,7 +129,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say | 120: Reroll 0.2.1 | 1 / 2 | broken | Frontend now initializes with inert game-action subscriptions, but the archive contains x86_64 memscan.so and requests root. No process-memory access was run. | static archive, local browser, fake-Frame | | 121: DeckPass 1.1.1 | 1 / 2 | partial | Initializer succeeds; KeePass Flatpak discovery and secrets integration were not exercised. | static archive, local browser, fake-Frame | | 123: Steam Status 1.2.3 | 2 / 2 | partial | Settings/public status rendered locally; cache errors fixed. Read-only get_settings RPC returned actual Decky 3.3.0 defaults on ARM64 Frame. Persistence, live backend diagnostics and VR controls unverified. | static archive, local browser, fake-Frame, real Frame SSH | -| 124: AlarMe 1.6.3 | 1 / 2 | partial | Initializer succeeds; timers, reminders, suspend inhibition and audio are unverified. | static archive, local browser, fake-Frame | +| 124: AlarMe 1.6.3 | 1 / 2 | partial | Silent labeled timer create/ticks/pause/cancel, preset save/cancel-confirm/delete and stopwatch start/lap/pause/reset/reopen pass in local Chromium with the official Decky 3.3.0 ARM64 backend. Audio, alarms/reminders, suspend inhibition, Steam overlays and worn VR remain unverified; notifications require the panel to stay open. | static archive, local browser, fake-Frame, real Frame SSH | | 125: Decky Localsend 0.4.5 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-localsend/bin/localsend-core. | static archive, local browser, fake-Frame | | 126: Launch Options 1.9.2 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-launch-options/bin/hello. Frontend audit failed: Cannot convert undefined or null to object. | static archive, local browser, fake-Frame | | 127: Decky Translator 0.9.0 | 1 / 2 | partial | Initializer succeeds; x86_64 download and hidraw/controller assumptions need an ARM64 audit. OCR/translation not exercised. | static archive, local browser, fake-Frame | @@ -252,7 +252,7 @@ DFL includes exports imported through decky-frontend-lib / @decky/ui. Steam stor | Reroll | Apps.RegisterForGameActionUserRequest, GameSessions.RegisterForAppLifetimeNotifications, Settings.GetCurrentLanguage | ButtonItem, DialogButton, Field, Focusable, Marquee, ModalRoot, PanelSection, PanelSectionRow, SliderField, libraryAssetImageClasses, mainMenuAppRunningClasses, quickAccessMenuClasses, showModal | SteamUIStore, QAM/global tabs, Steam stores | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. | | DeckPass | Apps.AddShortcut, Apps.RunGame, Apps.SetCustomArtworkForApp, Apps.SetShortcutExe, Apps.SetShortcutLaunchOptions, Apps.SetShortcutName, Input.ControllerKeyboardSendText | Button, ButtonItem, DialogButton, Field, Focusable, ModalRoot, PanelSection, PanelSectionRow, Router, TextField, findSP, showModal, sleep, staticClasses | Router, appDetailsStore, appStore, toaster, window.SP_REACT, toasts, Steam stores | Python | flatpak | | Steam Status | none detected | ButtonItem, ConfirmModal, DialogButton, Field, Focusable, Navigation, PanelSection, PanelSectionRow, SliderField, ToggleField, definePlugin, showModal, staticClasses | toaster, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. | -| AlarMe | GameSessions.RegisterForAppLifetimeNotifications | ButtonItem, ConfirmModal, Dropdown, Focusable, Navigation, PanelSection, PanelSectionRow, ProgressBarWithInfo, SidebarNavigation, SliderField, TextField, ToggleField, findModuleChild, showModal, staticClasses | routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup | Python | No specific hardware requirement detected statically; runtime requirements unverified. | +| AlarMe | GameSessions.RegisterForAppLifetimeNotifications | ButtonItem, ConfirmModal, Dropdown, Focusable, Navigation, PanelSection, PanelSectionRow, ProgressBarWithInfo, SidebarNavigation, SliderField, TextField, ToggleField, findModuleChild, showModal, staticClasses | routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup | Python | Timers/stopwatch use Python. Optional sleep inhibition opens /dev/uinput; sound and auto-suspend are unverified. Steam game overlays have no target in the isolated panel. | | Decky Localsend | none detected | ButtonItem, DialogBody, DialogButton, DialogButtonPrimary, DialogFooter, DialogHeader, Field, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, Router, SidebarNavigation, TextField, ToggleField, showModal, staticClasses | Router, routerHook, toaster, local/Steam routes, toasts, Steam stores | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. | | Launch Options | Apps.RegisterForAppDetails, Apps.SetAppLaunchOptions | ButtonItem, ConfirmModal, DialogBody, DialogButton, DialogButtonPrimary, DialogControlsSection, DialogControlsSectionHeader, DialogFooter, DialogHeader, Dropdown, Field, Focusable, MenuItem, ModalRoot, Navigation, PanelSection, PanelSectionRow, ScrollPanel, SidebarNavigation, SteamSpinner, Tabs, TextField, Toggle, ToggleField, afterPatch, createReactTreePatcher, fakeRenderComponent, findInReactTree, findInTree, findModule, findModuleByExport, showModal, staticClasses, useParams | SteamUIStore, routerHook, local/Steam routes, Steam page patches/module lookup, Steam stores | Python + native payload; x86_64 | flatpak; gamescope | | Decky Translator | User.RegisterForPrepareForSystemSuspendProgress | ButtonItem, DialogButton, Dropdown, DropdownItem, Field, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, Router, SliderField, Tabs, TextField, ToggleField, findModuleChild, showModal, staticClasses | Router, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup | Python | /dev/hidraw; /sys/class/hidraw/hidraw{i}; /sys/class/hidraw/hidraw{i}/device/uevent; x86_64 | diff --git a/scripts/framey-compatibility-doc.py b/scripts/framey-compatibility-doc.py index e95c25c..8ddf2af 100644 --- a/scripts/framey-compatibility-doc.py +++ b/scripts/framey-compatibility-doc.py @@ -73,6 +73,9 @@ def render(data): 'install folders and system information, plus IsThereAnyDeal\'s country dropdown in local Chromium. ' 'The installed-plugin status was fixture-shaped in that relay; plugin backends stayed denied and the deployed headset HTTP endpoint remains unverified. ' 'A [Web Browser frontend follow-up](evidence/framey-web-browser-2026-10-26.md) verified its exact archive now initializes with an inert null Steam React-root lookup. Its GamepadUI CDP/browser backend path remains unavailable, so it stays broken. ' + 'A [dialog and activation follow-up](evidence/framey-dialog-controls-2026-10-26.md) fixes DFL Focusable pointer/keyboard activation and confirmation descriptions, cancel/disabled actions, scoped close callbacks and nested dialogs. ' + 'AlarMe silent timer ticks/pause/cancel, private preset save/cancel-confirm/delete and stopwatch start/lap/pause/reset/reopen pass in local Chromium with its official Decky 3.3.0 ARM64 backend. ' + 'AlarMe stays partial: audio, alarms/reminders, sleep inhibition, Steam overlays and worn VR are unverified; notifications require the panel to remain open. ' 'Earlier focused checks cover [ProtonDB](evidence/framey-browser-2026-10-25.md), ' '[Steam Status/cache](evidence/framey-cache-browser-2026-10-25.md), ' '[Storage Cleaner](evidence/framey-api-v1-browser-2026-10-25.md), and ' diff --git a/tests/page/framey_actions.mjs b/tests/page/framey_actions.mjs index c077011..793792e 100644 --- a/tests/page/framey_actions.mjs +++ b/tests/page/framey_actions.mjs @@ -7,6 +7,7 @@ const testSource=source.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n window.__s assert.notEqual(testSource,source); const states=[]; const React={Fragment:'fragment',Component:class {},createElement:(type,props,...children)=>({type,props:props||{},children}), + cloneElement:(element,props)=>({...element,props:{...element.props,...props}}), useState:value=>[value,next=>states.push(next)], useRef:value=>({current:value}), useEffect(){},isValidElement:value=>!!value?.type}; class Element {constructor(){this.children=[];this.textContent='';this.attributes={};} @@ -357,7 +358,86 @@ await assert.rejects(window.SteamClient.Storage.SetObject('undefined',undefined) assert.equal(window.indexedDB,undefined); // localForage must use bounded plugin storage. const unpatch=window.DFL.beforePatch({},'method',()=>{}); assert.equal(unpatch.unpatch,unpatch);unpatch.unpatch(); -assert.equal(typeof window.DFL.showModal({type:'p'}).Close,'function'); +// Official AlarMe uses Focusable onActivate for pause, save and delete, without onClick. +let activated=0, stopped=0; +const activatable=window.DFL.Focusable({onActivate:()=>activated++}); +assert.equal(activatable.props.role,'button');assert.equal(activatable.props.tabIndex,0); +activatable.props.onClick({stopPropagation:()=>stopped++}); +const ownTarget={}; +for(const key of ['Enter',' '])activatable.props.onKeyDown({key,target:ownTarget,currentTarget:ownTarget, + preventDefault(){},stopPropagation:()=>stopped++}); +activatable.props.onKeyDown({key:'Enter',target:{},currentTarget:ownTarget,preventDefault(){}}); +assert.equal(activated,3);assert.equal(stopped,3); +const inertFocusable=window.DFL.Focusable({onActivate:()=>activated++,disabled:true}); +inertFocusable.props.onClick({}); +inertFocusable.props.onKeyDown({key:'Enter',target:ownTarget,currentTarget:ownTarget}); +assert.equal(activated,3);assert.equal(inertFocusable.props['aria-disabled'],true); +let pointerCalls=0; +window.DFL.Focusable({onClick:()=>pointerCalls++,onActivate:()=>activated++}).props.onClick({stopPropagation(){}}); +assert.equal(pointerCalls,1);assert.equal(activated,3); +// Dialog actions must preserve descriptions, cancellation and the dialog they belong to. +const modalRoot=roots.get(elements.get('framey-modal')); +let confirmed=0, cancelled=0, dismissed=0; +const modalHandle=window.DFL.showModal(React.createElement(window.DFL.ConfirmModal,{ + strTitle:'Remove preset?',strDescription:'Only this private preset will be removed.', + strOKButtonText:'Remove',strCancelButtonText:'Keep', + onOK:()=>confirmed++,onCancel:()=>cancelled++,closeModal:()=>dismissed++})); +let modal=modalRoot.value.at(-1).children.at(-1); +let confirm=modal.type(modal.props); +assert.equal(nodes(confirm,'div')[0].children[0],'Only this private preset will be removed.'); +assert.deepEqual(nodes(confirm,'button').map(button=>button.children[0]),['Remove','Keep']); +nodes(confirm,'button')[1].props.onClick(); +await modalHandle.ClosedPromise; +assert.equal(confirmed,0);assert.equal(cancelled,1);assert.equal(dismissed,1); +assert.equal(modalRoot.value,null); +modalHandle.Close();assert.equal(dismissed,1); +// Opening a confirmation from another dialog preserves its state; stale Close handles +// cannot dismiss the newer dialog. Programmatic Close does not call plugin callbacks. +const parentModal=window.DFL.showModal(React.createElement('p',{closeModal:()=>dismissed++})); +const childModal=window.DFL.showModal(React.createElement(window.DFL.ConfirmModal,{ + strTitle:'Confirm',bOKDisabled:true,bCancelDisabled:true,bMiddleDisabled:true, + onOK:()=>confirmed++,onCancel:()=>cancelled++,onMiddleButton:()=>confirmed++})); +assert.equal(modalRoot.value.length,2);assert.equal(modalRoot.value[0].props.hidden,true); +modal=modalRoot.value.at(-1).children.at(-1);confirm=modal.type(modal.props); +for(const button of nodes(confirm,'button')){assert.equal(button.props.disabled,true);button.props.onClick();} +assert.equal(confirmed,0);assert.equal(cancelled,1); +let prevented=0; +listeners.find(listener=>listener.type==='keydown').handler({key:'Escape',preventDefault:()=>prevented++}); +assert.equal(prevented,1);assert.equal(modalRoot.value.length,2); +parentModal.Close();parentModal.Close();await parentModal.ClosedPromise; +assert.equal(modalRoot.value.length,1);assert.equal(dismissed,1); +childModal.Close();await childModal.ClosedPromise;assert.equal(modalRoot.value,null); +let replacement; +const first=window.DFL.showModal(React.createElement(window.DFL.ConfirmModal,{ + onOK:()=>{replacement=window.DFL.showModal(React.createElement('p',null,'Next dialog'));}})); +modal=modalRoot.value.at(-1).children.at(-1); +nodes(modal.type(modal.props),'button')[0].props.onClick(); +await first.ClosedPromise;assert.equal(modalRoot.value.length,1); +replacement.Update(React.createElement('p',null,'Updated')); +assert.equal(modalRoot.value[0].children.at(-1).children[0],'Updated'); +replacement.Close();await replacement.ClosedPromise; +const alert=window.DFL.ConfirmModal({bAlertDialog:true,onCancel:()=>cancelled++}); +assert.equal(nodes(alert,'button').length,1); +const escapeModal=window.DFL.showModal(React.createElement('p',{onCancel:()=>cancelled++})); +listeners.find(listener=>listener.type==='keydown').handler({key:'Escape',preventDefault(){}}); +await escapeModal.ClosedPromise;assert.equal(cancelled,2);assert.equal(modalRoot.value,null); +// Updated confirmations retain scoped dismissal and use the replacement's callback. +for (const actionIndex of [0,1]) for (const withCallback of [false,true]) { + let oldCloses=0, updatedCloses=0, updatedActions=0; + const updated=window.DFL.showModal(React.createElement('p',{closeModal:()=>oldCloses++})); + updated.Update(React.createElement(window.DFL.ConfirmModal,{ + strTitle:'Updated confirmation',onOK:()=>updatedActions++,onCancel:()=>updatedActions++, + ...(withCallback ? {closeModal:()=>updatedCloses++} : {})})); + const updatedElement=modalRoot.value.at(-1).children.at(-1); + nodes(updatedElement.type(updatedElement.props),'button')[actionIndex].props.onClick(); + assert.equal(modalRoot.value,null,'Updated OK/Cancel must close its own dialog'); + await updated.ClosedPromise; + assert.equal(updatedActions,1);assert.equal(updatedCloses,Number(withCallback));assert.equal(oldCloses,0); + updated.Close();updatedElement.props.closeModal(); + updated.Update(React.createElement('p',null,'Already closed')); + assert.equal(updatedCloses,Number(withCallback));assert.equal(modalRoot.value,null); +} + let selectedMenuItem=0; window.DFL.MenuItem({onSelected:()=>selectedMenuItem++}).props.onClick({}); window.DFL.MenuItem({disabled:true,onSelected:()=>selectedMenuItem++}).props.onClick({}); diff --git a/ui/framey-compatibility.json b/ui/framey-compatibility.json index c7b0b4d..953dbb2 100644 --- a/ui/framey-compatibility.json +++ b/ui/framey-compatibility.json @@ -10876,12 +10876,13 @@ "https://github.com/mpcallahan/DeckyHourNotifications" ], "status": "partial", - "reason": "Initializer succeeds; timers, reminders, suspend inhibition and audio are unverified.", + "reason": "Silent labeled timer create/ticks/pause/cancel, preset save/cancel-confirm/delete and stopwatch start/lap/pause/reset/reopen pass in local Chromium with the official Decky 3.3.0 ARM64 backend. Audio, alarms/reminders, suspend inhibition, Steam overlays and worn VR remain unverified; notifications require the panel to stay open.", "blockInstall": false, "verification": [ "static archive", "local browser", - "fake-Frame" + "fake-Frame", + "real Frame SSH" ], "browser": { "id": 124, @@ -10941,7 +10942,7 @@ "folder": "alarme-decky-plugin" }, "backend": "Python", - "hardware": "No specific hardware requirement detected statically; runtime requirements unverified.", + "hardware": "Timers/stopwatch use Python. Optional sleep inhibition opens /dev/uinput; sound and auto-suspend are unverified. Steam game overlays have no target in the isolated panel.", "uiSurfaces": [ "local/Steam routes", "toasts", @@ -10958,6 +10959,12 @@ "evidence": "docs/evidence/framey-running-context-2026-10-26.json", "mode": "synthetic nonempty running/library replay; backend and network denied", "frontendReady": true + }, + "controlsFollowUp": { + "evidence": "docs/evidence/framey-dialog-controls-2026-10-26.json", + "mode": "local opaque-iframe Chromium with receipt-bound official ARM64 backend RPC/events; private silent data only", + "frontendReady": true, + "status": "partial" } }, { diff --git a/ui/framey-host.js b/ui/framey-host.js index c1417a4..894d544 100644 --- a/ui/framey-host.js +++ b/ui/framey-host.js @@ -197,6 +197,26 @@ }, {passive:true}); const passthrough = tag => ({children, ...props}) => R.createElement(tag, props, children); + const Focusable = ({children, onActivate, onClick, onKeyDown, disabled, tabIndex, role, ...props}) => { + const actionable=typeof onActivate==='function' || typeof onClick==='function'; + const activate = event => { + if (disabled || event.defaultPrevented) return; + event.stopPropagation(); + (onClick || onActivate)?.(event); + }; + return R.createElement('div', {...props, role:role || (actionable ? 'button' : undefined), + tabIndex:tabIndex ?? (actionable ? (disabled ? -1 : 0) : undefined), + 'aria-disabled':actionable && disabled ? true : undefined, + onClick:actionable ? activate : undefined, onKeyDown:event=>{ + onKeyDown?.(event); + if (actionable && !disabled && !event.defaultPrevented && event.target===event.currentTarget && + (event.key==='Enter' || event.key===' ')) { + event.preventDefault(); + event.stopPropagation(); + (onActivate || onClick)?.(event); + } + }}, children); + }; const Field = ({label, description, children}) => R.createElement('label', null, R.createElement('strong', null, label), description && R.createElement('small', null, description), children); const ToggleField = ({label, description, checked, disabled, onChange}) => R.createElement(Field, {label, description}, @@ -263,11 +283,61 @@ const MenuItem = ({children, disabled, bDisabled, onClick, onSelected}) => R.createElement('button', {type:'button',disabled:!!(disabled || bDisabled), onClick:event=>{if(!disabled && !bDisabled)(onClick || onSelected)?.(event);}}, children); - const ConfirmModal = ({strTitle, strOKButtonText, onOK, children}) => R.createElement('section', null, - R.createElement('h2', null, strTitle), children, - R.createElement('button', {type:'button', onClick:()=>{onOK?.(); closeModal();}}, strOKButtonText || 'Close')); + const ConfirmModal = ({strTitle, strDescription, strOKButtonText, strCancelButtonText, + strMiddleButtonText, onOK, onCancel, onMiddleButton, closeModal, + bOKDisabled, bCancelDisabled, bMiddleDisabled, bAlertDialog, children}) => { + const action = (callback, disabled) => () => { + if (disabled) return; + callback?.(); + closeModal?.(); + }; + return R.createElement('section', null, + R.createElement('h2', null, strTitle), + strDescription && R.createElement('div', null, strDescription), children, + R.createElement('button', {type:'button', disabled:!!bOKDisabled, + onClick:action(onOK, bOKDisabled)}, strOKButtonText || 'OK'), + onMiddleButton && R.createElement('button', {type:'button', disabled:!!bMiddleDisabled, + onClick:action(onMiddleButton, bMiddleDisabled)}, strMiddleButtonText || 'More'), + !bAlertDialog && R.createElement('button', {type:'button', disabled:!!bCancelDisabled, + onClick:action(onCancel, bCancelDisabled)}, strCancelButtonText || 'Cancel')); + }; const modalRoot = window.ReactDOM.createRoot(document.getElementById('framey-modal')); - const closeModal = () => modalRoot.render(null); + const modals = []; + let modalId = 0; + const renderModals = () => { + document.getElementById('framey-root').inert = modals.length > 0; + modalRoot.render(modals.length ? modals.map((modal,index) => + R.createElement('section', {key:modal.id, className:'framey-dialog', tabIndex:-1, + ref:node=>{modal.node=node;}, hidden:index!==modals.length-1, + role:'dialog', 'aria-modal':index===modals.length-1 ? true : undefined, + 'aria-label':typeof modal.element?.props?.strTitle==='string' ? modal.element.props.strTitle : 'Plugin dialog'}, + !modal.element?.props?.bHideCloseIcon && R.createElement('button', { + type:'button', disabled:!!modal.element?.props?.bCancelDisabled, + onClick:()=>dismissModal(modal)}, 'Close'), modal.element)) : null); + }; + function dismissModal(modal) { + if (modal.element?.props?.bCancelDisabled) return; + modal.element?.props?.onCancel?.(); + modal.dismiss(); + } + addEventListener('keydown', event => { + const modal=modals.at(-1); + if (!modal) return; + if (event.key === 'Tab') { + const controls = [...(modal.node?.querySelectorAll('button,input,select,textarea,a[href],[tabindex]') || [])] + .filter(node=>!node.disabled && node.tabIndex>=0 && node.getClientRects().length); + const index=controls.indexOf(document.activeElement); + if (index < 0 || (!event.shiftKey && index===controls.length-1) || (event.shiftKey && index===0)) { + event.preventDefault(); + (event.shiftKey ? controls.at(-1) : controls[0])?.focus(); + } + return; + } + if (event.key !== 'Escape') return; + event.preventDefault(); + if (modal.element?.props?.onEscKeypress) modal.element.props.onEscKeypress(); + else dismissModal(modal); + }); const localPath = path => typeof path === 'string' && path.length <= 120 && /^\/[A-Za-z0-9/_-]+$/.test(path) && !path.includes('//'); const routePattern = path => typeof path === 'string' && path.length <= 120 && @@ -321,9 +391,38 @@ }; const noPatch = () => {const undo=()=>{};undo.unpatch=undo;return undo;}; const showModal = element => { - modalRoot.render(R.createElement('section', null, - R.createElement('button', {type:'button',onClick:closeModal}, 'Close'), element)); - return {Close:closeModal}; + let resolveClosed; + const ClosedPromise = new Promise(resolve=>{resolveClosed=resolve;}); + const modal = {id:++modalId, previousFocus:document.activeElement, source:element}; + const Close = () => { + const index=modals.indexOf(modal); + if (index < 0) return; + const wasTop=index===modals.length-1; + modals.splice(index,1); + renderModals(); + resolveClosed(); + if (wasTop) setTimeout(()=>{ + if (modals.length) modals.at(-1).node?.focus(); + else if (modal.previousFocus?.isConnected) modal.previousFocus.focus(); + },0); + }; + modal.dismiss = () => { + if (!modals.includes(modal)) return; + const callback=modal.source?.props?.closeModal; + if (callback !== modal.dismiss) callback?.(); + Close(); + }; + modal.element = R.isValidElement(element) ? R.cloneElement(element, {closeModal:modal.dismiss}) : element; + modals.push(modal); + renderModals(); + setTimeout(()=>{if(modals.at(-1)===modal) modal.node?.focus();},0); + return {Close, ClosedPromise, Update:next=>{ + if (!modals.includes(modal)) return; + // Keep replacement dialogs dismissible inside this isolated host too. + modal.source=next; + modal.element=R.isValidElement(next) ? R.cloneElement(next, {closeModal:modal.dismiss}) : next; + renderModals(); + }}; }; // These are Framey's own styles and components, never Steam's webpack cache. // Keep the registry finite: an unknown Steam implementation stays unavailable. @@ -373,7 +472,7 @@ definePlugin: init => init, PanelSection, PanelSectionRow: passthrough('div'), Field, ToggleField, DropdownItem, Dropdown: DropdownItem, ButtonItem, DialogCheckbox, TextField, SliderField, - Focusable: passthrough('div'), ScrollPanel: passthrough('div'), ScrollPanelGroup: passthrough('div'), + Focusable, ScrollPanel: passthrough('div'), ScrollPanelGroup: passthrough('div'), DialogButton: passthrough('button'), DialogButtonPrimary: passthrough('button'), ConfirmModal, DialogButtonSecondary: passthrough('button'), Button: ButtonItem, GamepadButton: passthrough('button'), Toggle: ToggleField, Spinner, SteamSpinner:Spinner, Tabs, SidebarNavigation, diff --git a/ui/framey_panel.py b/ui/framey_panel.py index 1dfd95f..52d74a8 100644 --- a/ui/framey_panel.py +++ b/ui/framey_panel.py @@ -139,7 +139,9 @@ label{display:grid;gap:6px;margin:10px 0}small{color:#b9c9d6} .framey-sections,.framey-library{display:flex;flex-direction:column;gap:12px}.framey-actions{display:flex;flex-wrap:wrap;gap:8px} .framey-action{min-height:52px}.framey-button-child{display:inline-flex;align-items:center;gap:8px}.framey-green{background:#246b42} .framey-long-title{overflow-wrap:anywhere}.framey-muted{background:#1b3042}.framey-image{max-width:100%;object-fit:contain} -.framey-focus:focus-visible{outline:4px solid #f8d370} +.framey-focus:focus-visible{outline:4px solid #f8d370} +.framey-dialog{position:fixed;inset:12px;margin:0;z-index:100;overflow:auto;box-shadow:0 0 0 24px #0d1925e6} +.framey-dialog[hidden]{display:none}