Diagnostics for focus and layering (--diag / WAYBETTER_DIAG=1): one timestamped line per event in the app's log folder
For the macOS reports (the menu never shows and the window goes inactive on a click; the find field gets no keyboard): off unless the app is started with `--diag` or WAYBETTER_DIAG=1. Then native code and the shell append to `diag.log` in the app's log folder (truncated at launch): menu open requests and every close with its reason, overlay counts, raise/lower and `shell_raise` results, native `shell_focus` calls and their result, the shell document's focus/blur/visibility, and where the find bar's focus is right after it opens and 300 ms later. A line is an event and a few words in plain ASCII (capped); never a URL, a query or page text. `diag_get` / `diag_log` are shell-only (in commands::SHELL and the template's list).
This commit is contained in:
parent
5e950f4f62
commit
fc0d34d8ce
8 changed files with 147 additions and 69 deletions
89
crates/waybetter-host/src/diag.rs
Normal file
89
crates/waybetter-host/src/diag.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
//! Focus and layering diagnostics, off unless asked for at launch (`--diag` or the environment
|
||||||
|
//! variable `WAYBETTER_DIAG=1`). While on, native code and the shell append one timestamped line per
|
||||||
|
//! event to `diag.log` in the app's log folder: menu open requests and every close with its reason,
|
||||||
|
//! overlays and `shell_raise` calls with their results, native focus calls, and the shell document's
|
||||||
|
//! focus, blur and visibility changes. A line is an event name and a few words: never a URL, a
|
||||||
|
//! query, page text or anything about an account ([`clean`] keeps plain printable ASCII only and
|
||||||
|
//! caps the length). The file is truncated at each launch with diagnostics on.
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
pub const ARG: &str = "--diag";
|
||||||
|
pub const ENV: &str = "WAYBETTER_DIAG";
|
||||||
|
pub const FILE: &str = "diag.log";
|
||||||
|
const MAX_LINE: usize = 300;
|
||||||
|
|
||||||
|
static ON: AtomicBool = AtomicBool::new(false);
|
||||||
|
static SINK: Mutex<Option<(std::fs::File, Instant)>> = Mutex::new(None);
|
||||||
|
|
||||||
|
/// Whether this launch asks for diagnostics (the first argument is the program itself).
|
||||||
|
pub fn asked<I, S>(args: I, env: Option<&str>) -> bool
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = S>,
|
||||||
|
S: AsRef<str>,
|
||||||
|
{
|
||||||
|
env.is_some_and(|value| value == "1") || args.into_iter().skip(1).any(|arg| arg.as_ref() == ARG)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A line as it is written: printable ASCII only (anything else becomes `?`), one line, capped.
|
||||||
|
pub fn clean(line: &str) -> String {
|
||||||
|
line.chars().take(MAX_LINE).map(|ch| if (' '..='~').contains(&ch) { ch } else { '?' }).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts writing to `dir/diag.log` (created, truncated). Nothing happens without a folder.
|
||||||
|
pub fn start(dir: Option<PathBuf>) {
|
||||||
|
let Some(dir) = dir else { return };
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
if let Ok(file) = std::fs::File::create(dir.join(FILE)) {
|
||||||
|
if let Ok(mut sink) = SINK.lock() {
|
||||||
|
*sink = Some((file, Instant::now()));
|
||||||
|
ON.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log("host", "diagnostics on");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn active() -> bool {
|
||||||
|
ON.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One event from `source` (`host` or `shell`), with milliseconds since the start.
|
||||||
|
pub fn log(source: &str, line: &str) {
|
||||||
|
if !active() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Ok(mut sink) = SINK.lock() {
|
||||||
|
if let Some((file, started)) = sink.as_mut() {
|
||||||
|
let _ = writeln!(file, "{:>9} {:<5} {}", started.elapsed().as_millis(), clean(source), clean(line));
|
||||||
|
let _ = file.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn asked_by_the_argument_or_the_environment_only() {
|
||||||
|
assert!(asked(["app", "--diag"], None));
|
||||||
|
assert!(asked(["app"], Some("1")));
|
||||||
|
assert!(!asked(["app"], Some("yes")));
|
||||||
|
assert!(!asked(["--diag"], None), "the first argument is the program");
|
||||||
|
assert!(!asked(["app", "--diag=1", "diag"], None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_line_is_plain_short_ascii() {
|
||||||
|
assert_eq!(clean("menu close: blur (hasFocus=false)"), "menu close: blur (hasFocus=false)");
|
||||||
|
assert_eq!(clean("two\nlines\ttab é"), "two?lines?tab ?");
|
||||||
|
assert_eq!(clean(&"x".repeat(1000)).len(), MAX_LINE);
|
||||||
|
// Off by default: nothing is written.
|
||||||
|
assert!(!active());
|
||||||
|
log("shell", "ignored");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
//! Google Fonts, native side, for every app that sets [`crate::app::AppConfig::fonts`]: the
|
|
||||||
//! shell-only `gfonts_*` commands ([`crate::commands::GFONTS`]) over [`crate::fonts`]'s downloads
|
|
||||||
//! and cache (`fonts.googleapis.com` and `fonts.gstatic.com` only, app-data `fonts/`). Site tabs
|
|
||||||
//! never request fonts and gain no command: native code hands them cached bytes
|
|
||||||
//! ([`crate::fonts::page_script_for`]).
|
|
||||||
//!
|
|
||||||
//! When a font is installed or removed the app's [`crate::app::SiteHooks::fonts_changed`] runs, so
|
|
||||||
//! it can give every site tab its theme (and the font bytes) again.
|
|
||||||
|
|
||||||
use crate::fonts::{self, InstalledFont};
|
|
||||||
use crate::gfonts::Weights;
|
|
||||||
use crate::host::shell_only;
|
|
||||||
use tauri::{AppHandle, Manager, Webview};
|
|
||||||
|
|
||||||
/// Opens the font cache in app data. Does nothing unless the app set [`crate::app::AppConfig::fonts`].
|
|
||||||
pub(crate) fn init(app: &AppHandle) {
|
|
||||||
if !crate::app::config(app).fonts {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Ok(dir) = app.path().app_data_dir() {
|
|
||||||
fonts::init(&dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn changed(app: &AppHandle) {
|
|
||||||
crate::app::hooks(app).fonts_changed(app);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub(crate) async fn gfonts_installed(webview: Webview) -> Result<Vec<InstalledFont>, String> {
|
|
||||||
shell_only(&webview)?;
|
|
||||||
fonts::installed()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub(crate) async fn gfonts_install(webview: Webview, app: AppHandle, family: String, weights: Weights) -> Result<InstalledFont, String> {
|
|
||||||
shell_only(&webview)?;
|
|
||||||
let installed = fonts::install(family, weights).await?;
|
|
||||||
changed(&app);
|
|
||||||
Ok(installed)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub(crate) async fn gfonts_remove(webview: Webview, app: AppHandle, family: String) -> Result<(), String> {
|
|
||||||
shell_only(&webview)?;
|
|
||||||
fonts::remove(&family)?;
|
|
||||||
changed(&app);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub(crate) async fn gfonts_faces(webview: Webview, family: String) -> Result<serde_json::Value, String> {
|
|
||||||
shell_only(&webview)?;
|
|
||||||
fonts::faces(&family)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub(crate) async fn gfonts_preview(webview: Webview, family: String, weights: Weights, text: String) -> Result<Vec<serde_json::Value>, String> {
|
|
||||||
shell_only(&webview)?;
|
|
||||||
fonts::preview(family, weights, text).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The fonts commands ([`crate::commands::GFONTS`]).
|
|
||||||
pub(crate) fn handler() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send + Sync + 'static {
|
|
||||||
tauri::generate_handler![gfonts_installed, gfonts_install, gfonts_remove, gfonts_faces, gfonts_preview]
|
|
||||||
}
|
|
||||||
|
|
@ -151,7 +151,7 @@ const COMMANDS = {
|
||||||
*/
|
*/
|
||||||
export const HOST_COMMANDS = {
|
export const HOST_COMMANDS = {
|
||||||
shell: [
|
shell: [
|
||||||
'tabs_sync', 'tab_navigate', 'tab_action', 'tab_zoom', 'tab_find', 'safe_mode_get', 'safe_mode_set', 'open_external_url', 'shell_focus', 'shell_ready', 'tab_still_ready', 'shell_raise', 'window_system_menu',
|
'tabs_sync', 'tab_navigate', 'tab_action', 'tab_zoom', 'tab_find', 'safe_mode_get', 'safe_mode_set', 'diag_get', 'diag_log', 'open_external_url', 'shell_focus', 'shell_ready', 'tab_still_ready', 'shell_raise', 'window_system_menu',
|
||||||
'profiles_get', 'profiles_set', 'i18n_get', 'i18n_set',
|
'profiles_get', 'profiles_set', 'i18n_get', 'i18n_set',
|
||||||
'plugins_list', 'plugin_set_enabled', 'plugins_open_folder', 'custom_css_get', 'custom_css_set', 'features_get', 'features_set', 'mode_get', 'mode_set', 'settings_get', 'settings_set',
|
'plugins_list', 'plugin_set_enabled', 'plugins_open_folder', 'custom_css_get', 'custom_css_set', 'features_get', 'features_set', 'mode_get', 'mode_set', 'settings_get', 'settings_set',
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { useEffect, useLayoutEffect, useReducer, useRef, useState, type Keyboard
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { parseUpdateStatus, type UpdateStatus } from '../updateModel.ts';
|
import { parseUpdateStatus, type UpdateStatus } from '../updateModel.ts';
|
||||||
import { invoke, listen } from './bridge.ts';
|
import { invoke, listen } from './bridge.ts';
|
||||||
|
import { describe, diag } from './diag.ts';
|
||||||
import { decks } from './deckStore.ts';
|
import { decks } from './deckStore.ts';
|
||||||
import { refreshDev } from './devHost.ts';
|
import { refreshDev } from './devHost.ts';
|
||||||
import { useDev, usePaneTick, useT } from './hooks.ts';
|
import { useDev, usePaneTick, useT } from './hooks.ts';
|
||||||
|
|
@ -107,6 +108,7 @@ export function AppMenu() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function show(request: AppMenuRequest = {}) {
|
function show(request: AppMenuRequest = {}) {
|
||||||
|
diag(`menu open: anchor=${request.anchor ?? 'button'} keyboard=${!!request.keyboard} hasFocus=${document.hasFocus()} active=${describe(document.activeElement)}`);
|
||||||
const logo = request.anchor === 'logo' ? document.querySelector('.bt-titlebar-logo') : null;
|
const logo = request.anchor === 'logo' ? document.querySelector('.bt-titlebar-logo') : null;
|
||||||
const rect = (logo ?? button.current)?.getBoundingClientRect();
|
const rect = (logo ?? button.current)?.getBoundingClientRect();
|
||||||
if (rect) {
|
if (rect) {
|
||||||
|
|
@ -135,7 +137,8 @@ export function AppMenu() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `escape`: back to what had focus before. `action` and `away`: nothing focused, so typing returns to the active pane. */
|
/** `escape`: back to what had focus before. `action` and `away`: nothing focused, so typing returns to the active pane. */
|
||||||
function close(how: 'escape' | 'action' | 'away' = 'away') {
|
function close(how: 'escape' | 'action' | 'away' = 'away', why = '') {
|
||||||
|
diag(`menu close: ${how}${why ? ` (${why})` : ''} hasFocus=${document.hasFocus()}`);
|
||||||
clearTimeout(hover.current.timer);
|
clearTimeout(hover.current.timer);
|
||||||
hover.current = { el: null };
|
hover.current = { el: null };
|
||||||
focusNext.current = null;
|
focusNext.current = null;
|
||||||
|
|
@ -173,7 +176,11 @@ export function AppMenu() {
|
||||||
// The window lost the keyboard: the menu closes, unless the focus is back a moment later (taking the
|
// The window lost the keyboard: the menu closes, unless the focus is back a moment later (taking the
|
||||||
// keyboard from a tab for Alt+F / F10 can pass through a blur on its way to the shell).
|
// keyboard from a tab for Alt+F / F10 can pass through a blur on its way to the shell).
|
||||||
let leaving: ReturnType<typeof setTimeout> | undefined;
|
let leaving: ReturnType<typeof setTimeout> | undefined;
|
||||||
const away = () => { clearTimeout(leaving); leaving = setTimeout(() => { if (!document.hasFocus()) close(); }, 160); };
|
const away = () => {
|
||||||
|
diag(`menu: window blur hasFocus=${document.hasFocus()}`);
|
||||||
|
clearTimeout(leaving);
|
||||||
|
leaving = setTimeout(() => { if (!document.hasFocus()) close('away', 'blur'); else diag('menu: blur passed, focus is back'); }, 160);
|
||||||
|
};
|
||||||
const resized = () => close();
|
const resized = () => close();
|
||||||
// Keys while nothing in the menu has focus (a click on its padding): Esc closes, arrows enter it.
|
// Keys while nothing in the menu has focus (a click on its padding): Esc closes, arrows enter it.
|
||||||
const onKey = (event: KeyboardEvent) => {
|
const onKey = (event: KeyboardEvent) => {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { invoke, listen } from './bridge.ts';
|
import { invoke, listen } from './bridge.ts';
|
||||||
|
import { describe, diag } from './diag.ts';
|
||||||
import { useT } from './hooks.ts';
|
import { useT } from './hooks.ts';
|
||||||
import { formatNumber } from './i18n.ts';
|
import { formatNumber } from './i18n.ts';
|
||||||
import { Icon } from './icons.tsx';
|
import { Icon } from './icons.tsx';
|
||||||
|
|
@ -47,6 +48,8 @@ export function FindBar({ tabId, url, onClose }: { tabId: string; url: string; o
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
invoke('shell_focus').catch(() => {});
|
invoke('shell_focus').catch(() => {});
|
||||||
input.current?.focus();
|
input.current?.focus();
|
||||||
|
diag(`find bar open: hasFocus=${document.hasFocus()} active=${describe(document.activeElement)}`);
|
||||||
|
setTimeout(() => diag(`find bar +300ms: hasFocus=${document.hasFocus()} active=${describe(document.activeElement)}`), 300);
|
||||||
const again = (event: Event) => { if ((event as CustomEvent<string>).detail === tabId) { input.current?.focus(); input.current?.select(); } };
|
const again = (event: Event) => { if ((event as CustomEvent<string>).detail === tabId) { input.current?.focus(); input.current?.select(); } };
|
||||||
const onStep = (event: Event) => { const detail = (event as CustomEvent<{ id: string; direction: 1 | -1 }>).detail; if (detail.id === tabId) step(detail.direction); };
|
const onStep = (event: Event) => { const detail = (event as CustomEvent<{ id: string; direction: 1 | -1 }>).detail; if (detail.id === tabId) step(detail.direction); };
|
||||||
window.addEventListener('bt-find', again);
|
window.addEventListener('bt-find', again);
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import { NewTabPage } from './NewTabPage.tsx';
|
||||||
import type { PaletteAction, PaletteContext } from './paletteModel.ts';
|
import type { PaletteAction, PaletteContext } from './paletteModel.ts';
|
||||||
import { PaneThemeHost, openPaneTheme } from './PaneTheme.tsx';
|
import { PaneThemeHost, openPaneTheme } from './PaneTheme.tsx';
|
||||||
import { focusedCommands, installPluginCommands, notePanelActive, refreshPluginNames, runPluginCommand } from './pluginCommandStore.ts';
|
import { focusedCommands, installPluginCommands, notePanelActive, refreshPluginNames, runPluginCommand } from './pluginCommandStore.ts';
|
||||||
|
import { initDiag } from './diag.ts';
|
||||||
import { installSafeMode } from './safeStore.ts';
|
import { installSafeMode } from './safeStore.ts';
|
||||||
import { hasFeature, site } from './site.ts';
|
import { hasFeature, site } from './site.ts';
|
||||||
import {
|
import {
|
||||||
|
|
@ -609,6 +610,8 @@ export default function Shell() {
|
||||||
|
|
||||||
// The site's own settings and events (e.g. X's Twitter/X wording).
|
// The site's own settings and events (e.g. X's Twitter/X wording).
|
||||||
useEffect(() => site().start?.(), []);
|
useEffect(() => site().start?.(), []);
|
||||||
|
// Focus and layering diagnostics, when the app was started with them (`--diag`).
|
||||||
|
useEffect(() => { void initDiag(); }, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unlisten = [
|
const unlisten = [
|
||||||
|
|
|
||||||
36
packages/shell/src/frame/diag.ts
Normal file
36
packages/shell/src/frame/diag.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
// Focus and layering diagnostics from the shell (waybetter-host `diag.rs`): off unless the app was
|
||||||
|
// started with `--diag` (or WAYBETTER_DIAG=1). While on, each event is one short line in the app's
|
||||||
|
// log folder (`diag.log`): menu requests and closes with their reason, overlays and raising, native
|
||||||
|
// focus calls and the shell document's focus, blur and visibility. Event names and states only;
|
||||||
|
// never a URL, a query or page text.
|
||||||
|
|
||||||
|
import { invoke } from './bridge.ts';
|
||||||
|
|
||||||
|
let enabled = false;
|
||||||
|
|
||||||
|
/** Asks native code once whether diagnostics are on, and starts following the document's focus. */
|
||||||
|
export async function initDiag(): Promise<void> {
|
||||||
|
try { enabled = await invoke<boolean>('diag_get') === true; } catch { enabled = false; }
|
||||||
|
if (!enabled) return;
|
||||||
|
const state = () => `hasFocus=${document.hasFocus()} visible=${document.visibilityState} active=${describe(document.activeElement)}`;
|
||||||
|
window.addEventListener('focus', () => diag(`window focus (${state()})`));
|
||||||
|
window.addEventListener('blur', () => diag(`window blur (${state()})`));
|
||||||
|
document.addEventListener('visibilitychange', () => diag(`visibility ${document.visibilityState}`));
|
||||||
|
diag(`diagnostics on (${state()})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An element as a few plain words: its tag and first class (never its text). */
|
||||||
|
export function describe(element: Element | null): string {
|
||||||
|
if (!element) return 'none';
|
||||||
|
const name = element.tagName.toLowerCase();
|
||||||
|
const first = typeof element.className === 'string' ? element.className.split(/\s+/).filter(Boolean)[0] : '';
|
||||||
|
return first ? `${name}.${first}` : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One event line (nothing happens while diagnostics are off). */
|
||||||
|
export function diag(line: string): void {
|
||||||
|
if (!enabled) return;
|
||||||
|
invoke('diag_log', { line: line.slice(0, 280) }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const diagOn = () => enabled;
|
||||||
|
|
@ -9,6 +9,7 @@ import type { PaneInfo } from './accountModel.ts';
|
||||||
import { currentWindow, invoke } from './bridge.ts';
|
import { currentWindow, invoke } from './bridge.ts';
|
||||||
import { columnSourceUrl, composeTabId, deckById, decks } from './deckStore.ts';
|
import { columnSourceUrl, composeTabId, deckById, decks } from './deckStore.ts';
|
||||||
import type { PaletteAction } from './paletteModel.ts';
|
import type { PaletteAction } from './paletteModel.ts';
|
||||||
|
import { diag } from './diag.ts';
|
||||||
import { site } from './site.ts';
|
import { site } from './site.ts';
|
||||||
import { latestOnly } from './syncGate.ts';
|
import { latestOnly } from './syncGate.ts';
|
||||||
|
|
||||||
|
|
@ -195,6 +196,7 @@ function columnBoxes() {
|
||||||
export function setOverlay(delta: number) {
|
export function setOverlay(delta: number) {
|
||||||
const before = shell.overlay;
|
const before = shell.overlay;
|
||||||
shell.overlay = Math.max(0, before + delta);
|
shell.overlay = Math.max(0, before + delta);
|
||||||
|
diag(`overlay ${before} -> ${shell.overlay}`);
|
||||||
if ((before === 0) !== (shell.overlay === 0)) {
|
if ((before === 0) !== (shell.overlay === 0)) {
|
||||||
window.dispatchEvent(new Event('bt-overlay-changed'));
|
window.dispatchEvent(new Event('bt-overlay-changed'));
|
||||||
syncLayer();
|
syncLayer();
|
||||||
|
|
@ -251,12 +253,16 @@ function syncLayer() {
|
||||||
root.classList.add('bt-raised');
|
root.classList.add('bt-raised');
|
||||||
await frames();
|
await frames();
|
||||||
layer.raised = true;
|
layer.raised = true;
|
||||||
|
diag('raise: on');
|
||||||
await invoke('shell_raise', { on: true });
|
await invoke('shell_raise', { on: true });
|
||||||
|
diag(`raised (hasFocus=${document.hasFocus()})`);
|
||||||
} else {
|
} else {
|
||||||
// Under the tabs first, then opaque again: the panes never blink.
|
// Under the tabs first, then opaque again: the panes never blink.
|
||||||
const refocus = document.hasFocus() && (!document.activeElement || document.activeElement === document.body);
|
const refocus = document.hasFocus() && (!document.activeElement || document.activeElement === document.body);
|
||||||
layer.raised = false;
|
layer.raised = false;
|
||||||
|
diag('raise: off');
|
||||||
await invoke('shell_raise', { on: false });
|
await invoke('shell_raise', { on: false });
|
||||||
|
diag(`lowered (hasFocus=${document.hasFocus()})`);
|
||||||
await frames();
|
await frames();
|
||||||
if (shell.overlay === 0) root.classList.remove('bt-raised');
|
if (shell.overlay === 0) root.classList.remove('bt-raised');
|
||||||
// Closed from the keyboard or a click in the shell (not by the window losing focus) with
|
// Closed from the keyboard or a click in the shell (not by the window losing focus) with
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue