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/atomicFile.ts b/app/src/main/atomicFile.ts index 08356c4..03c9e73 100644 --- a/app/src/main/atomicFile.ts +++ b/app/src/main/atomicFile.ts @@ -18,11 +18,15 @@ export function writeFileAtomic(path: string, data: string): void { renameSync(temporary, path); } -/** Parses JSON from `path`; any read or parse failure is reported and returns undefined instead of throwing. */ +/** + * Parses JSON from `path`; any read or parse failure is reported and returns undefined instead of throwing. A byte + * order mark is dropped first: Windows tools (Notepad, PowerShell 5) write one, and JSON.parse refuses it, which would + * quietly reset every setting to its default. + */ export function readJsonFile(path: string, log: (message: string) => void): unknown { if (!existsSync(path)) return undefined; try { - return JSON.parse(readFileSync(path, "utf8")); + return JSON.parse(readFileSync(path, "utf8").replace(/^\uFEFF/, "")); } catch (error) { log(`Could not read ${path}: ${error instanceof Error ? error.message : String(error)}`); return undefined; 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()} + +