From ba846adb5b669df2570e93a34fbe8cbd95eacb46 Mon Sep 17 00:00:00 2001 From: Filipe Moreira Date: Wed, 21 Oct 2026 14:11:37 -0300 Subject: [PATCH 1/2] fix(ui): overlays show above the page, plus Edge polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tab views are native views stacked above the shell's document, so the controller keyboard, the menu, the tab grid and the permission prompt all opened behind the web page: on a real window only the toolbar dimmed. The e2e suite checks the DOM, not the composited screen, so it never noticed. - The keyboard reports its height and the page ends above it, staying live so the field being typed into is in sight. - A modal overlay paints a snapshot of the page and only then asks the live view to step aside (new `page-cover` command and `shell:snapshot` channel, both validated). - Toasts and the ads/controller status moved into the toolbar, the only place the shell can be seen; the untranslated startup toast is gone. Polish found on the way: the `…` menu drops down from its button in window mode with distinct icons, the caption shows Restore while maximized, the history and favourites pages now load the design tokens (they never did, so they rendered unstyled), and faint text is raised to keep WCAG AA on the new greys. Co-Authored-By: Claude Opus 5.5 --- app/src/assets/design/tokens.css | 4 +- app/src/assets/favorites.html | 3 +- app/src/assets/history.html | 3 +- app/src/main/services/commandRouter.ts | 3 +- app/src/main/services/layout.ts | 7 +-- app/src/main/services/shellHost.ts | 2 + app/src/main/shell.ts | 32 ++++++++++-- app/src/preload/chrome.ts | 3 ++ app/src/renderer/shell/app.tsx | 67 +++++++++++++++++++++----- app/src/renderer/shell/bridge.ts | 2 + app/src/renderer/shell/icons.tsx | 16 ++++++ app/src/renderer/shell/keyboard.tsx | 16 +++++- app/src/renderer/shell/quickmenu.tsx | 21 ++++---- app/src/renderer/shell/shell.css | 54 +++++++++------------ app/src/renderer/shell/toasts.tsx | 7 ++- app/src/shared/commands.ts | 5 +- app/src/shared/i18n.ts | 2 + app/src/shared/ipc.ts | 8 ++- app/src/shared/statePatch.ts | 1 + app/tests/unit/services.test.ts | 8 ++- app/tests/unit/statePatch.test.ts | 1 + 21 files changed, 193 insertions(+), 72 deletions(-) diff --git a/app/src/assets/design/tokens.css b/app/src/assets/design/tokens.css index a0a30ce..63cd747 100644 --- a/app/src/assets/design/tokens.css +++ b/app/src/assets/design/tokens.css @@ -22,8 +22,8 @@ --hover: color-mix(in srgb, var(--surface) 86%, var(--text)); --border: color-mix(in srgb, var(--surface) 82%, var(--text)); --muted: color-mix(in srgb, var(--text) 68%, var(--surface)); - /* 50% keeps faint hints at 4.9:1 on the surface, the WCAG AA floor for normal text. */ - --faint: color-mix(in srgb, var(--text) 50%, var(--surface)); + /* 62% keeps faint hints above 4.5:1 on the surface (WCAG AA) in both the dark Joy greys and the Light theme. */ + --faint: color-mix(in srgb, var(--text) 62%, var(--surface)); --input: color-mix(in srgb, var(--bg) 96%, var(--text)); --danger: #ff6b5e; diff --git a/app/src/assets/favorites.html b/app/src/assets/favorites.html index c420cc9..4534868 100644 --- a/app/src/assets/favorites.html +++ b/app/src/assets/favorites.html @@ -5,13 +5,14 @@ Favoritos +

Favoritos

Use o botão ★ (ou Ctrl+D, ou segure LB e aperte Y) em qualquer página. Os favoritos também aparecem na tela inicial.

-
+
diff --git a/app/src/assets/history.html b/app/src/assets/history.html index a283802..f9ef883 100644 --- a/app/src/assets/history.html +++ b/app/src/assets/history.html @@ -5,13 +5,14 @@ Histórico +

Histórico

Páginas visitadas em abas normais. Abas privadas e páginas do próprio JoyChromium nunca são gravadas.

-
+
diff --git a/app/src/main/services/commandRouter.ts b/app/src/main/services/commandRouter.ts index 80be268..607fb7c 100644 --- a/app/src/main/services/commandRouter.ts +++ b/app/src/main/services/commandRouter.ts @@ -79,7 +79,8 @@ export class CommandRouter { break; case "cycle-input-mode": h.cycleInputMode(); break; case "media": await mediaControl(wc, command.action); break; - case "chrome-height": h.setLayout(command.height, 0); break; + case "chrome-height": h.setLayout(command.height, command.bottom ?? 0); break; + case "page-cover": h.setPageCovered(command.covered); break; case "ui-settings": { const patch: Partial = {}; if (command.uiScale !== undefined) patch.uiScale = command.uiScale; diff --git a/app/src/main/services/layout.ts b/app/src/main/services/layout.ts index 4cefc07..3f8a890 100644 --- a/app/src/main/services/layout.ts +++ b/app/src/main/services/layout.ts @@ -1,6 +1,6 @@ import type { Tab } from "../tabs"; -/** Where the tab views go: below the chrome, above the status line. */ +/** Where the tab views go: below the chrome, above the controller keyboard while it is open. */ export interface LayoutInsets { top: number; bottom: number; @@ -16,7 +16,8 @@ export interface ViewBounds { /** Bounds for the tab views, or undefined when the window has no size yet. Pure so it can be unit tested. */ export function tabViewBounds(size: { width: number; height: number }, insets: LayoutInsets, chromeVisible: boolean): ViewBounds { const top = chromeVisible ? insets.top : 0; - const bottom = chromeVisible ? insets.bottom : 0; + // The keyboard sits over the page whether or not the chrome is showing, so its room is kept either way. + const bottom = insets.bottom; return { x: 0, y: top, width: size.width, height: Math.max(0, size.height - top - bottom) }; } @@ -28,7 +29,7 @@ export interface LayoutHost { /** Keeps the tab views under the chrome and in step with fullscreen, page fullscreen and TV/window mode. */ export class WindowLayout { - insets: LayoutInsets = { top: 90, bottom: 26 }; + insets: LayoutInsets = { top: 90, bottom: 0 }; constructor(private readonly host: LayoutHost) {} diff --git a/app/src/main/services/shellHost.ts b/app/src/main/services/shellHost.ts index df9499e..3b0bdb5 100644 --- a/app/src/main/services/shellHost.ts +++ b/app/src/main/services/shellHost.ts @@ -34,6 +34,8 @@ export interface ShellHost { broadcast(): void; syncViews(): void; setLayout(top: number, bottom: number): void; + /** Hides the active tab's view under a modal overlay, which the shell draws over a snapshot of it. */ + setPageCovered(covered: boolean): void; setChromeVisible(visible: boolean): void; /** Minimize even from TV mode, where a fullscreen window cannot be minimized as it is (E4-03). */ minimizeWindow(): void; diff --git a/app/src/main/shell.ts b/app/src/main/shell.ts index 55b4738..ced93af 100644 --- a/app/src/main/shell.ts +++ b/app/src/main/shell.ts @@ -60,11 +60,12 @@ export class Shell { readonly tabs: TabManager; private readonly layout: WindowLayout; chromeVisible = true; + private pageCovered = false; inputMode: InputMode = "Spatial"; keyboard: ShellState["keyboard"]; find: ShellState["find"]; suggestions: ShellState["suggestions"] = []; - private statusText = "STARTING · TV IDENTITY ON"; + private statusText = ""; controllerConnected = false; controllerFamily: ShellState["controllerFamily"] = "generic"; readonly permissions = new PermissionQueue(); @@ -266,8 +267,8 @@ export class Shell { private wireWindow(): void { this.window.on("resize", () => this.layout.apply()); - this.window.on("maximize", () => this.layout.apply()); - this.window.on("unmaximize", () => this.layout.apply()); + this.window.on("maximize", () => { this.layout.apply(); this.broadcast(); }); + this.window.on("unmaximize", () => { this.layout.apply(); this.broadcast(); }); const onActivity = (): void => this.broadcastActivity(); this.window.on("minimize", onActivity); this.window.on("show", onActivity); @@ -302,7 +303,7 @@ export class Shell { this.attachShortcuts(tab.webContents); } if (!contentView.children.includes(tab.view)) contentView.addChildView(tab.view); - tab.view.setVisible(tab === this.tabs.active); + tab.view.setVisible(tab === this.tabs.active && !this.pageCovered); } for (const child of contentView.children) { if (child instanceof Object && !this.tabs.tabs.some((t) => !t.sleeping && t.view === child)) contentView.removeChildView(child); @@ -317,6 +318,16 @@ export class Shell { this.layout.setInsets(top, bottom); } + /** + * The tab views are native views stacked above the shell's own page, so nothing the shell draws can cover them. A + * modal overlay therefore paints a snapshot of the page and asks the live view to step aside until it closes. + */ + setPageCovered(covered: boolean): void { + if (this.pageCovered === covered) return; + this.pageCovered = covered; + this.syncViews(); + } + setChromeVisible(visible: boolean): void { if (!visible) clearTimeout(this.chromeHideTimer); this.layout.setChromeVisible(visible, () => this.deps.store.current.windowMode === "tv"); @@ -422,6 +433,7 @@ export class Shell { suggestions: this.suggestions, shortcutMod: currentShortcutMod(), nativeWindowControls: nativeWindowControls(), + maximized: this.window.isMaximized(), windowMode: s.windowMode, uiScale: s.uiScale, overscan: s.overscan, @@ -485,6 +497,16 @@ export class Shell { })); return shots; }); + ipcMain.handle(Channels.shellSnapshot, async (event): Promise => { + const tab = this.tabs.active; + if (event.sender !== this.window.webContents || !tab || tab.sleeping) return ""; + try { + const image = await tab.webContents.capturePage(); + return image.isEmpty() ? "" : `data:image/jpeg;base64,${image.toJPEG(88).toString("base64")}`; + } catch { + return ""; + } + }); ipcMain.handle(Channels.shellPreferences, (event): ShellPreferences => { if (event.sender !== this.window.webContents) return { vibration: false }; return { vibration: this.deps.store.current.vibration }; @@ -605,7 +627,7 @@ export class Shell { async handle(command: ShellCommand): Promise { // The bar comes back for anything the user asked for, except the renderer reporting its own height. - if (command.type !== "chrome-height") this.revealChrome(); + if (command.type !== "chrome-height" && command.type !== "page-cover") this.revealChrome(); await this.router.run(command); } diff --git a/app/src/preload/chrome.ts b/app/src/preload/chrome.ts index acc5c45..c55d85f 100644 --- a/app/src/preload/chrome.ts +++ b/app/src/preload/chrome.ts @@ -25,6 +25,9 @@ contextBridge.exposeInMainWorld("shell", { thumbnails(): Promise<{ id: number; dataUrl: string }[]> { return ipcRenderer.invoke(Channels.shellThumbnails); }, + snapshot(): Promise { + return ipcRenderer.invoke(Channels.shellSnapshot); + }, onActivity(callback: (activity: ShellActivity) => void): () => void { const listener = (_event: unknown, activity: ShellActivity): void => callback(activity); ipcRenderer.on(Channels.shellActivity, listener); diff --git a/app/src/renderer/shell/app.tsx b/app/src/renderer/shell/app.tsx index eb58dda..5b9dda1 100644 --- a/app/src/renderer/shell/app.tsx +++ b/app/src/renderer/shell/app.tsx @@ -8,7 +8,7 @@ import { t } from "../../shared/i18n"; import { isInternal } from "../../shared/pages"; import { bridge, cmd } from "./bridge"; import { - BackIcon, CaptionCloseIcon, CaptionMaximizeIcon, CaptionMinimizeIcon, CloseIcon, ForwardIcon, GlobeIcon, FullscreenIcon, GamepadIcon, HomeIcon, MenuIcon, PlusIcon, ReloadIcon, + BackIcon, CaptionCloseIcon, CaptionMaximizeIcon, CaptionMinimizeIcon, CaptionRestoreIcon, CloseIcon, ForwardIcon, GlobeIcon, FullscreenIcon, GamepadIcon, HomeIcon, MenuIcon, PlusIcon, ReloadIcon, SearchIcon, SettingsIcon, ShieldIcon, StarIcon, TabsIcon, TvIcon, VolumeIcon, } from "./icons"; import { Keyboard } from "./keyboard"; @@ -21,7 +21,7 @@ import { vibrate } from "./haptics"; const Empty: ShellState = { tabs: [], activeUrl: "", isFavorite: false, inputMode: "Spatial", adBlock: false, controllerConnected: false, chromeVisible: true, theme: { accent: "#2B95FF", background: "#1C1C1C", surface: "#2B2B2B", text: "#F3F3F3" }, - status: "", suggestions: [], shortcutMod: "Ctrl", nativeWindowControls: false, windowMode: "tv", + status: "", suggestions: [], shortcutMod: "Ctrl", nativeWindowControls: false, maximized: false, windowMode: "tv", uiScale: 1, overscan: 0, highContrast: false, language: "pt-BR", controllerFamily: "generic", }; @@ -59,17 +59,51 @@ export function App() { root.setProperty("--safe-x", overscan); }, [state.theme, state.uiScale, state.highContrast, state.windowMode, state.overscan, state.language]); - // The tab views sit below the chrome: report the chrome's real height whenever it changes. + // The tab views sit below the chrome and above the controller keyboard: report both whenever either changes. + const insets = useRef({ top: 0, bottom: 0 }); + const [chromeTop, setChromeTop] = useState(0); + const sendInsets = () => cmd({ type: "chrome-height", height: insets.current.top, bottom: insets.current.bottom }); useEffect(() => { const report = () => { - const top = document.getElementById("chrome")?.getBoundingClientRect().height ?? 0; - bridge().command({ type: "chrome-height", height: Math.round(top) } as never); + insets.current.top = Math.round(document.getElementById("chrome")?.getBoundingClientRect().height ?? 0); + setChromeTop(insets.current.top); + sendInsets(); }; const observer = new ResizeObserver(report); if (chromeRef.current) observer.observe(chromeRef.current); report(); return () => observer.disconnect(); }, []); + const keyboardInset = (bottom: number) => { + if (insets.current.bottom === bottom) return; + insets.current.bottom = bottom; + sendInsets(); + }; + + // The page is a native view above this document, so a modal overlay would open behind it. The overlay paints a + // snapshot of the page instead, and only then asks the live view to step aside, so nothing flickers. + const modal = overlay !== "none" || Boolean(state.permission); + const [cover, setCover] = useState(""); + const covered = useRef(false); + useEffect(() => { + if (!modal) { + setCover(""); + if (covered.current) cmd({ type: "page-cover", covered: false }); + covered.current = false; + return; + } + let live = true; + void bridge().snapshot().then((image) => { + if (!live) return; + setCover(image); + requestAnimationFrame(() => requestAnimationFrame(() => { + if (!live) return; + covered.current = true; + cmd({ type: "page-cover", covered: true }); + })); + }); + return () => { live = false; }; + }, [modal]); useGamepad(state, overlay, setOverlay); @@ -97,7 +131,9 @@ export function App() { {!state.nativeWindowControls && (
- +
)} @@ -114,6 +150,7 @@ export function App() { + + {/* What used to be the status line, as toolbar indicators: Edge shows its tracking shield the same way. */} +
+ + + + + {state.inputMode.toUpperCase()} + +