From 99ef514dbb47f37a1261d57e818a1614d19e0ebd Mon Sep 17 00:00:00 2001 From: olehwebdev Date: Mon, 28 Sep 2026 14:50:08 +0000 Subject: [PATCH] Replace the system's title bar with the editor's own on Linux On Linux the editor's window had the system's light title bar, naming the app, above the editor's own. The window now hides it (titleBarStyle 'hidden') and Electron draws the window buttons over the editor title bar's right end, in its colours (Window Controls Overlay). The title bar is the window's drag area, its controls excepted, and its right padding grows by --titlebar-controls-w, the width the buttons take (0 on macOS and Windows, which keep their title bars). --- CHANGELOG.md | 4 ++++ docs/DESIGN_SYSTEM.md | 2 +- docs/SPEC.md | 4 ++-- src/main/constants.ts | 6 +++++ src/main/launch/createEditorWindow.ts | 15 ++++++++++--- src/renderer/src/app/styles/tokens.css | 3 +++ .../title-bar/ui/TitleBar/TitleBar.tsx | 13 +++++++---- test/e2e/app.e2e.test.ts | 22 +++++++++++++++++++ 8 files changed, 59 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26022571..ae2bb406 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and - **Edit overrides in VS Code.** **Open in VS Code**, on an override's tab, its right-click menu in the Explorer or the command palette, opens its file there. Save it in VS Code, or any other editor, and the page reloads with your version, just as if you had saved in the app. A tab left open shows the new text; if it has edits you haven't saved, it keeps them and offers the saved file's version instead. **Show in folder** finds the file for any other editor. - **Share your overrides.** The new menu on the Explorer's **Overrides** section (or the command palette) exports a workspace's overrides and rules as one file, and imports one: send it to a teammate and they see the page with your fix, or copy your changes to another workspace. An import adds only what the workspace doesn't have yet, so your own versions are kept, and tells you what it left out. +### Changed + +- **No more white title bar on Linux.** The editor's window no longer has the system's light title bar above its own. The app's dark title bar is now the top of the window: drag it to move the window, and the minimize, maximize and close buttons sit at its right end, in its colours. + ## [0.4.0] - 2026-09-26 Console Editor now shows how a site works, not just what it loaded. Pick any element to see the React, Vue, Angular or web component behind it, traced to its source file even in a production build, and record why components rendered and what the page's stores did. A new Network panel overrides API responses and pauses requests, rules block requests and change headers, and actions run code in any frame with one click. diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 559c7477..4efaed2c 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -56,7 +56,7 @@ All colors are CSS custom properties in `src/renderer/src/app/styles/tokens.css` ### Spacing and density -4 px grid. Rows are 26 px (tree) / 28 px (lists), controls 28 px (`h-7`), title bar 44 px, status bar 26 px, activity rail 48 px wide. The rail holds the views (Explorer, Actions, Inspect, Search), a hairline, the workspaces (28 px tiles in 36 px hit areas; the active one gets a `--fg` bar on the left, gliding with `SPRING_LAYOUT`, the others sit at 65 % opacity), +, and Settings at the bottom. +4 px grid. Rows are 26 px (tree) / 28 px (lists), controls 28 px (`h-7`), title bar 44 px (on Linux also the window's: it is dragged to move it, and the window buttons sit over its right end, past `--titlebar-controls-w` of padding), status bar 26 px, activity rail 48 px wide. The rail holds the views (Explorer, Actions, Inspect, Search), a hairline, the workspaces (28 px tiles in 36 px hit areas; the active one gets a `--fg` bar on the left, gliding with `SPRING_LAYOUT`, the others sit at 65 % opacity), +, and Settings at the bottom. --- diff --git a/docs/SPEC.md b/docs/SPEC.md index 63ba831a..f5198071 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -698,7 +698,7 @@ While store actions are recorded, every action the page's stores handle in every | Original sources | Open read-only from the Explorer or the palette: a tab of their own after the file tabs, a header with a **Read-only** badge, where the file comes from and **Go to bundle code**, and Monaco's read-only message when typed into. Save, pretty-print and diffs don't apply. An original the map has no text for says so, and offers its bundle code | | Go to bundle code / Go to original source (Ctrl/Cmd+Shift+M) | From a line of an original to the code it became in the bundle's tab (opened as usual: pretty-printed, or the override that applies), or from the cursor in a script or stylesheet tab to the original line. Header buttons, the editor's context menu, **View** menu and palette. Exact through pretty-printing and outside your edits; inside them, or where a new build changed the file, a toast says why a jump landed short or couldn't be made | | Command palette (Ctrl/Cmd+K or P) | Fuzzy search over every page file, the original files of loaded maps, overrides and actions; the list refreshes while open as files arrive | -| Layout | Sidebar and preview are fitted to the window (the editor keeps at least 240 px; panel minimums give way below that, e.g. when zoomed in). Hiding the preview takes the native page view out of the window with it. The bottom pane shows the console, the network panel, the Renders log or the Stores log, one tab each, and Ctrl/Cmd+J brings back the one shown last. Visibility, sizes and the bottom pane's tab are saved on every change | +| Layout | Sidebar and preview are fitted to the window (the editor keeps at least 240 px; panel minimums give way below that, e.g. when zoomed in). Hiding the preview takes the native page view out of the window with it. The bottom pane shows the console, the network panel, the Renders log or the Stores log, one tab each, and Ctrl/Cmd+J brings back the one shown last. Visibility, sizes and the bottom pane's tab are saved on every change. On Linux the editor's window has no system title bar (`titleBarStyle: 'hidden'`): its own title bar takes that place, moves the window when dragged (`app-region: drag`, its controls excepted), and leaves room at its right end for the window buttons Electron draws over it (Window Controls Overlay, in the title bar's colours; `--titlebar-controls-w` is the width they take, 0 elsewhere) | | Large files | Scripts, stylesheets and HTML over 1 M characters open in a lite mode: syntax colouring only (Monarch grammars, no language service or validation, folding, minimap or bracket colourization), shown as "Large file" | | Focus | Opening or switching tabs (original sources too) focuses the editor; closing a tab from the keyboard, typing in a field or arrowing through the Explorer never has focus pulled into the code | | Rules | The Explorer's **Rules** section lists the workspace's rules, oldest first: what each does, its pattern, a switch, and how often it applied this session. **+** starts a rule of each kind (Block requests, Change response headers, Allow cross-origin requests); right-click to edit, turn on or off, copy the pattern or delete (asked first). A file's context menu blocks it (by its exact URL, any query) or its iframe, removes a document's Content-Security-Policy, or starts a header rule for it, all but the last in one click with an Undo in the toast; a blocked file stays in the tree, struck through, and offers to unblock it (turning its rule off) or open its rule. The page's own document can't be blocked. The palette lists rules (open, turn on or off) and the new-rule actions | @@ -753,7 +753,7 @@ The site's `WebContentsView` can move to a window of its own (`PageWindow`), e.g | Performance | The inspector on large apps in real Chromium (`test/perf/inspector.perf.test.ts`): a React and a Vue 3 app of some 14,000 components (a 2,000-row table, a chain 1,500 deep in React, 300 in Vue) and stores of 20,000 items (Redux Toolkit, Pinia), as production builds. It times a commit of every row with the hooks off, idle and recording renders, and after picking; hovering and picking deep in the table and at the end of the chain; the page stack; a level of the tree; a batch's size; Redux dispatches and Pinia actions idle, recording and after it stopped. And the built app's UI (`test/perf/ui.perf.test.ts`) fed what large apps record at the pace recording sends it (2,000 commits of 200 components, 1,000 store actions with 50-call stacks, 2,000 requests with 20-call initiators, their places traced through a real bundle's map): how long the window's main thread is blocked, its longest task, the memory the log takes, and how long **By component** takes to show. Each prints its medians and fails past a budget set well above what a laptop measures, so only work growing with the whole page on a hot path trips it; it isn't part of CI | `npm run test:perf` (on a display: `xvfb-run -a` on headless Linux) | | Architecture | Feature-Sliced Design layer rules; the code-structure rules (files of at most 150 lines, one function, component, class or store per file named after it, data-only constants/types/index files, no `switch`, no sibling names that differ only in case) | `npm run lint:fsd`, `npm run lint:structure` | | Integration | Engine in real Chromium against the fixture site: gzip, static and runtime SRI, globs, CSS/HTML overrides, 404, redeploy detection, source maps, disable. Iframes through the session-aware WebSocket transport (`test/helpers/chromium.ts`): same-site, cross-site and nested iframes, SRI inside iframes, iframe HTML overrides, same-site navigation, removal, reload; each asserts the iframe really is a separate target. The console on a page of service iframes (same-site, and two on sites of their own): every frame's first log line on its own frame, code run in one frame and another frame's logs reacting, top-level `await` and expanding the result, uncaught errors and rejections, a frame keeping its id across a navigation, a cross-site frame's function refused from the top page but run in the frame itself (why actions run in their frame). Workers against the fixture's `/workers/` page (`workers.chromium.test.ts`, a fresh browser context per test): every kind runs while intercepted; edits reach a dedicated and a module worker's first script and imports, what a nested worker imports (its first script is served in Chromium 141, reported as missed in 152+), a shared worker, a service worker's script and imports, and an audio worklet's module; each file is listed with its worker and read through its session; a shared worker's races are forced by holding back its session's commands; workers under a cross-site iframe are served on its session and removed with it; workers are reported gone; an edit to an installed service worker applies on the next reload, and one whose page was left is reinstalled when its site loads again under other overrides (a workspace switch); the page's `registration.update()` is reported and the next reload undoes it; a service worker is listed again, and not reinstalled, when the page comes back to its site; the cache setting reaches what workers load. Rules (`rules.chromium.test.ts`): blocking before the server sees the request (a script, one inside a cross-site iframe, an iframe's document, a redirect's later hop), a URL both blocked and overridden, the page itself never blocked; a gzipped document's CSP removed and another's added, X-Frame-Options removed so a page can be framed, a document no rule changes never read, Cache-Control and Content-Type on streamed responses, headers back once a rule is off; CORS with credentials, a preflight the API refuses, redirects. Source maps named by headers, `X-SourceMap`, comments and `data:` URIs, a stylesheet's, and an override-served bundle's. The network log and response overrides (`network.chromium.test.ts`, the fixture's `/network/` page): what the log lists for fetch(), GraphQL, a failing call, an event stream and a worker's fetch, page loads counted, request and response bodies read, never a stream's (which keeps streaming); a response override answering fetch() and XHR with its body, status and header changes (the request still reaching the server) and marking the row, its delay, only its method and GraphQL operation, a dedicated worker's fetch, and an event stream left streaming or replaced as a whole. Held and unsent requests (`held.chromium.test.ts`): an override that doesn't send answering another origin's POST and its preflight with the server never hit (and, without it, the server turning the preflight away); breakpoints holding a response (its row marked) until answered edited or as it was, a request before it is sent (its row marked) sent elsewhere or answered, one failed, one the page gave up on let go, everything let go when interception stops, and nothing stopped once off. In `network.chromium.test.ts` too: patch mode keeping the live values it wasn't told to change and answering a failing upstream with the saved text; the page and its worker taken offline and slowed down; a WebSocket's handshake, text and binary messages read incrementally, and its close; the log written as HAR with its bodies and read back as overrides that answer without the server. The page stack (`stack.chromium.test.ts`): a React app the test bundles from the repo's react and react-dom, found through the hook stand-in with its version, as a production and a development build, and by its keys with **Framework hooks** off (and no hook left in the page); what Vue, Pinia, Angular, Next.js and webpack leave in a page; a cross-site iframe's React, which found the stand-in in place before it loaded; no console rows. The component inspector (`inspect.chromium.test.ts`), with real pointer input on carts in React and Vue the test bundles (`test/helpers/bundleApp.ts`), as minified production builds with source maps and as development builds: a production React component's chain, props, state, context and handler, each function traced through the map to its source line; a click the page never gets, and the element as `$0`; the chain's other components, and an element that is gone; a production Vue component through its app's vnode tree, with what the app provides, and a development build's from the element; picking inside a cross-site iframe on its own session; Esc in the page; a useState hook set and rendered, JSON and unsettable values refused; Vue's data and a setup ref set, a computed one refused; the Components tree of a React app through the hook stand-in and by its containers without it, and of a production Vue app (each function placed once however many of its components a level lists), nodes opened as picks at their depth, picks' paths; renders recorded on a React app whose buttons each commit for another reason (own state and a store, a context with a memo child skipped, a parent's state with changed props, a class's state), with triggers, frames, durations and order, nothing after stopping, and a production build's mount recorded from a document loaded while recording, its functions placed through the map. The page stack (`stack.chromium.test.ts`) also counts a frame's scripts that name a source map by comment or header. The other frameworks (`frameworks.chromium.test.ts`), each bundled by the test from the repo's own packages: a production Angular component read through its view registry (found once, then taken from the page without walking the heap again; inputs, a settable signal, a field that isn't, each class placed by the map; hosts named by their tags while hovering), a development build through `window.ng` and its tree, a production build's tree through the registry; a Vue 2 component's props, data (set), what the app provides, chain and tree; a Lit component in its shadow root, its properties and state (set), host chain and tree; a plain page's listeners with names, places and options; a Vue 3 listener's function, not Vue's invoker; store actions (`stores.chromium.test.ts`): Redux Toolkit's (the slice's type, payload, what changed, the handler's line through the map; the page stack names Redux), the React commit after one naming it and nothing once stopped, Zustand's through its devtools, Pinia's actions and direct changes (heard again once an action that never settles stops counting) and Vuex's mutations (found when recording starts and by a document loaded while recording), NgRx's through its StoreDevtools; a request keeping the stack of the handler that sent it; saved actions (`stateActions.chromium.test.ts`): a picked component's action written, the page loaded again, and the action setting the value in React (a hook), Vue 3 production and development (a ref and data), Vue 2 (data), Angular development (a signal; production gets none) and Lit (through two shadow roots) | `npm test` (skips if no Chromium; `npx playwright install chromium`) | -| End-to-end | Built Electron app driven by Playwright: open site, edit, save, page runs it, disable/enable, edit files inside a cross-site and a nested iframe, the fixture's `/workers/` page with its workers' files listed under their worker's badge and edited (imports of each kind of worker; a module worker's static import; the first script of a dedicated worker, a shared worker, a service worker and a worklet; a service worker's script edited again, turned off and on, and deleted; a worker under a cross-site iframe), the update check's toast with Bypass service workers off and its reload, what stays listed when the page leaves (its site's service worker) and when it leaves the site (nothing), each workspace running its own edit of the service worker's script (`workers.e2e.test.ts`), persistence across restart, a second launch handing over its URL, workspaces (a new one starts empty and doesn't serve another's overrides, takes its site's favicon, switching back restores the page, tabs and overrides with no history from the other, renaming, all of it across a restart), the console (each frame's rows, running code in a picked frame and seeing another react, filtering by frame, naming a frame and keeping the name across a restart, clearing), actions (`actions.e2e.test.ts`: code run in the console saved as an action for its frame, run with one click and another frame reacting, one for a frame picked from the page showing what it threw, run from the palette, changed and deleted, each workspace's own across a restart), the Actions panel in a window of its own (`actionsWindow.e2e.test.ts`: moved there, running and making actions from it, the workspace and the settings following it, Keep on top, reopened where it was after a restart, back by closing it, from the sidebar's notice and from the View menu), the website in a window of its own (`pageWindow.e2e.test.ts`: moved there from the preview's toolbar without reloading, the editor's saved edits served to it, navigating from its toolbar, back from its button, by closing it, from the title bar and from the View menu; the site's pop-ups moving with it; put back, or asked for its address bar, while its window still loads; where it was and that it was open kept across a restart). Rules: block a script from the file tree, turn it off and on, undo a quick rule, remove a page's CSP, allow CORS for an API with a preflight, keep an unapplied edit on its tab and apply it from File › Save, rules per workspace, delete, and blocking from the first load after a restart. Updates against a local feed: the automatic announcement, What's New with the release's notes, a download refused for its checksum and then accepted. The original sources behind a bundle: listed, opened read-only, jumping to the pretty-printed bundle line and back, found from the palette. The Network panel (`network.e2e.test.ts`): the page's fetch and GraphQL calls listed, Fetch/XHR first and the document under All; a response opened, edited, saved and served to the page, the row marked and the override in the Explorer with its method; another status applied from the response row; the override turned off; requests like the selected one paused and an edited response sent to the page, a paused response saved as an override that answers from then on, a breakpoint written in the menu holding a request before it is sent, and that request failed; the live response patched from the response row, a quick edit saved and served, the page taken offline and back from the network speed menu, a WebSocket's messages in its details, a response edited as a tree with the keyboard (a value retyped, a key renamed, a member removed) and saved, and the requests exported as HAR and imported back as overrides. The page stack (`stack.e2e.test.ts`): the status bar names the fixture's UI libraries and opens the Page stack, which lists each finding by kind with its version, build and evidence, and scans again. The component inspector (`inspect.e2e.test.ts`) on a production React cart framed from another site: the toolbar's button picks and the Inspect view shows what is under the pointer; a click opens the Component page, named `CartItem` and placed at its source line through the map, with props, state, context, the handler by its original name and the chain; **Open original** and **Go to bundle code** land on its definition; the View menu starts and stops picking, and Esc in the editor stops it; the Inspect view's tree lists the cart frame's components down to the one picked, selected; a state value named `qty` (from the original) is set from the Component page and the cart shows it; **Record renders** from the palette records a real click, the Renders tab says it was a click on `button#add-A1` and that `CartItem` rendered for `state qty 5 → 6`, and the Component page lists that render. A map from a file (`mapFile.e2e.test.ts`): a production React bundle that names no map, picked, given its map through **Load a source map…** (the system's dialog stubbed in the main process) and named `CartItem` at its line, then forgotten; a plain page's element shown with its listener and where it is defined; the inspected cart's renders by component, and its set value kept as an action that sets it again after a reload; a Redux Toolkit cart (`dataFlow.e2e.test.ts`): its actions recorded from the palette with what they changed and the line that dispatched them (and their stack), the commit after one naming it, and a request's **Sent by** stack traced to the handler | `npm run test:e2e` (on headless Linux: `xvfb-run npm run test:e2e`) | +| End-to-end | Built Electron app driven by Playwright: on Linux, the editor's title bar in place of the system's (the window buttons clear of its controls, the bar a drag area and its controls not), open site, edit, save, page runs it, disable/enable, edit files inside a cross-site and a nested iframe, the fixture's `/workers/` page with its workers' files listed under their worker's badge and edited (imports of each kind of worker; a module worker's static import; the first script of a dedicated worker, a shared worker, a service worker and a worklet; a service worker's script edited again, turned off and on, and deleted; a worker under a cross-site iframe), the update check's toast with Bypass service workers off and its reload, what stays listed when the page leaves (its site's service worker) and when it leaves the site (nothing), each workspace running its own edit of the service worker's script (`workers.e2e.test.ts`), persistence across restart, a second launch handing over its URL, workspaces (a new one starts empty and doesn't serve another's overrides, takes its site's favicon, switching back restores the page, tabs and overrides with no history from the other, renaming, all of it across a restart), the console (each frame's rows, running code in a picked frame and seeing another react, filtering by frame, naming a frame and keeping the name across a restart, clearing), actions (`actions.e2e.test.ts`: code run in the console saved as an action for its frame, run with one click and another frame reacting, one for a frame picked from the page showing what it threw, run from the palette, changed and deleted, each workspace's own across a restart), the Actions panel in a window of its own (`actionsWindow.e2e.test.ts`: moved there, running and making actions from it, the workspace and the settings following it, Keep on top, reopened where it was after a restart, back by closing it, from the sidebar's notice and from the View menu), the website in a window of its own (`pageWindow.e2e.test.ts`: moved there from the preview's toolbar without reloading, the editor's saved edits served to it, navigating from its toolbar, back from its button, by closing it, from the title bar and from the View menu; the site's pop-ups moving with it; put back, or asked for its address bar, while its window still loads; where it was and that it was open kept across a restart). Rules: block a script from the file tree, turn it off and on, undo a quick rule, remove a page's CSP, allow CORS for an API with a preflight, keep an unapplied edit on its tab and apply it from File › Save, rules per workspace, delete, and blocking from the first load after a restart. Updates against a local feed: the automatic announcement, What's New with the release's notes, a download refused for its checksum and then accepted. The original sources behind a bundle: listed, opened read-only, jumping to the pretty-printed bundle line and back, found from the palette. The Network panel (`network.e2e.test.ts`): the page's fetch and GraphQL calls listed, Fetch/XHR first and the document under All; a response opened, edited, saved and served to the page, the row marked and the override in the Explorer with its method; another status applied from the response row; the override turned off; requests like the selected one paused and an edited response sent to the page, a paused response saved as an override that answers from then on, a breakpoint written in the menu holding a request before it is sent, and that request failed; the live response patched from the response row, a quick edit saved and served, the page taken offline and back from the network speed menu, a WebSocket's messages in its details, a response edited as a tree with the keyboard (a value retyped, a key renamed, a member removed) and saved, and the requests exported as HAR and imported back as overrides. The page stack (`stack.e2e.test.ts`): the status bar names the fixture's UI libraries and opens the Page stack, which lists each finding by kind with its version, build and evidence, and scans again. The component inspector (`inspect.e2e.test.ts`) on a production React cart framed from another site: the toolbar's button picks and the Inspect view shows what is under the pointer; a click opens the Component page, named `CartItem` and placed at its source line through the map, with props, state, context, the handler by its original name and the chain; **Open original** and **Go to bundle code** land on its definition; the View menu starts and stops picking, and Esc in the editor stops it; the Inspect view's tree lists the cart frame's components down to the one picked, selected; a state value named `qty` (from the original) is set from the Component page and the cart shows it; **Record renders** from the palette records a real click, the Renders tab says it was a click on `button#add-A1` and that `CartItem` rendered for `state qty 5 → 6`, and the Component page lists that render. A map from a file (`mapFile.e2e.test.ts`): a production React bundle that names no map, picked, given its map through **Load a source map…** (the system's dialog stubbed in the main process) and named `CartItem` at its line, then forgotten; a plain page's element shown with its listener and where it is defined; the inspected cart's renders by component, and its set value kept as an action that sets it again after a reload; a Redux Toolkit cart (`dataFlow.e2e.test.ts`): its actions recorded from the palette with what they changed and the line that dispatched them (and their stack), the commit after one naming it, and a request's **Sent by** stack traced to the handler | `npm run test:e2e` (on headless Linux: `xvfb-run npm run test:e2e`) | | Packaged | The installed app (asar, fuses, signature) fixes the demo store's checkout through the UI, driven over `--remote-debugging-port` since the fuses disable Node's inspector. The release workflow runs it on six runners, one per architecture: macOS (from the disk image), Windows (after a silent install; the x64 runner also checks that the ARM installer refuses it) and Linux (from the installed `.deb`, with Ubuntu's user-namespace restriction left on, also checking that the desktop entry and icons are the package's and the app added none of its own) | `npm run test:packaged -- ` | | Update | An installed app updates itself to a build one patch higher, served by a local stand-in for GitHub: the notification, What's New, the download, **Restart to update**, the restarted app running the new version (and What's New after it); an AppImage's desktop entry starts the old file before and the renamed one after. The release workflow runs it for the Windows installers (then uninstalls, checking the updater's cache goes too) and the AppImages on their four runners. The `.deb` path (as root, through `sudo`, and with the password refused) and the AppImage installing on quit were checked by hand | `npm run test:update -- ` | diff --git a/src/main/constants.ts b/src/main/constants.ts index ddc382af..41e329da 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -16,5 +16,11 @@ export const IMAGE_DATA_URL = /^data:image\/[\w.+-]+[;,]/i; /** Matches the --canvas token, so nothing flashes before a window's UI paints. */ export const CANVAS_COLOR = '#08080a'; +/** Matches the --fg-muted token: the window buttons drawn over the title bar, as quiet as its other icons. */ +export const TITLE_BAR_SYMBOL_COLOR = '#9d9ea5'; + +/** The editor's title bar (--titlebar-h, 44 px) less its bottom border, which runs on under the window buttons. */ +export const TITLE_BAR_HEIGHT = 43; + /** Where electron-vite puts the preload script, relative to the main bundle. */ export const PRELOAD_SCRIPT = '../preload/index.js'; diff --git a/src/main/launch/createEditorWindow.ts b/src/main/launch/createEditorWindow.ts index b709454d..4838c6cc 100644 --- a/src/main/launch/createEditorWindow.ts +++ b/src/main/launch/createEditorWindow.ts @@ -1,12 +1,21 @@ -import { BrowserWindow } from 'electron'; +import { BrowserWindow, type BrowserWindowConstructorOptions } from 'electron'; import { join } from 'node:path'; import appIcon from '../../../build/icons/512x512.png?asset&asarUnpack'; -import { CANVAS_COLOR, PRELOAD_SCRIPT } from '../constants'; +import { CANVAS_COLOR, PRELOAD_SCRIPT, TITLE_BAR_HEIGHT, TITLE_BAR_SYMBOL_COLOR } from '../constants'; /** The window's size when it first opens, and the smallest it can be made. */ const WINDOW_SIZE = { width: 1600, height: 1000 }; const MIN_WINDOW_SIZE = { width: 960, height: 600 }; +/** + * On Linux the system's title bar (a light bar naming the app, above the editor's own) gives way to the + * editor's title bar, with the window buttons drawn over its right end in its colours. + */ +const LINUX_TITLE_BAR: BrowserWindowConstructorOptions = { + titleBarStyle: 'hidden', + titleBarOverlay: { color: CANVAS_COLOR, symbolColor: TITLE_BAR_SYMBOL_COLOR, height: TITLE_BAR_HEIGHT }, +}; + /** The editor's window, hidden until its UI is ready to show. */ export function createEditorWindow(): BrowserWindow { return new BrowserWindow({ @@ -16,7 +25,7 @@ export function createEditorWindow(): BrowserWindow { minHeight: MIN_WINDOW_SIZE.height, title: 'Console Editor', // Elsewhere the window takes the app's own icon; on Linux it has to be given one. - ...(process.platform === 'linux' ? { icon: appIcon } : {}), + ...(process.platform === 'linux' ? { icon: appIcon, ...LINUX_TITLE_BAR } : {}), backgroundColor: CANVAS_COLOR, // The app menu keeps its shortcuts; on Windows/Linux Alt shows the bar. autoHideMenuBar: true, diff --git a/src/renderer/src/app/styles/tokens.css b/src/renderer/src/app/styles/tokens.css index 2d4e1dac..b3c64993 100644 --- a/src/renderer/src/app/styles/tokens.css +++ b/src/renderer/src/app/styles/tokens.css @@ -58,6 +58,9 @@ /* Layout */ --titlebar-h: 44px; + /* What the window buttons drawn over the title bar's right end take (Linux, where the system's title bar + gives way to the app's): the width Window Controls Overlay leaves out of the title bar; 0 without one. */ + --titlebar-controls-w: calc(100vw - env(titlebar-area-x, 0px) - env(titlebar-area-width, 100vw)); --statusbar-h: 26px; --rail-w: 48px; /* Width of the website preview; set by the editor page while it is shown. */ diff --git a/src/renderer/src/widgets/title-bar/ui/TitleBar/TitleBar.tsx b/src/renderer/src/widgets/title-bar/ui/TitleBar/TitleBar.tsx index ef1822fc..03e1a6de 100644 --- a/src/renderer/src/widgets/title-bar/ui/TitleBar/TitleBar.tsx +++ b/src/renderer/src/widgets/title-bar/ui/TitleBar/TitleBar.tsx @@ -33,11 +33,16 @@ export function TitleBar({ onOpenPalette, sidebarVisible, previewVisible, onTogg const errors = useConsoleStore(selectErrorCount); return ( -
+ // The window's title bar where the system's gives way to it (Linux): dragging it moves the window, and its + // right end leaves room for the window buttons. Its controls stay clickable. +
@@ -64,7 +69,7 @@ export function TitleBar({ onOpenPalette, sidebarVisible, previewVisible, onTogg onClick={onOpenPalette} data-testid="palette-trigger" className={cn( - 'flex h-7 w-[280px] items-center gap-2 rounded-lg border border-line bg-surface-raised/60 px-2.5 text-[12.5px] text-fg-subtle', + 'flex h-7 w-[280px] items-center gap-2 rounded-lg border border-line bg-surface-raised/60 px-2.5 text-[12.5px] text-fg-subtle [app-region:no-drag]', 'transition-[border-color,background-color,color] duration-150 ease-out-expo hover:border-line-strong hover:bg-surface-raised hover:text-fg-muted', )} > @@ -73,7 +78,7 @@ export function TitleBar({ onOpenPalette, sidebarVisible, previewVisible, onTogg -
+
{ await rm(userData, { recursive: true, force: true, maxRetries: 5 }); }); + it.skipIf(process.platform !== 'linux')("takes the system's title bar's place on Linux, with room for the window buttons", async () => { + const bar = await win.evaluate(() => { + // Window Controls Overlay: not in TypeScript's DOM types yet. + const { windowControlsOverlay } = navigator as Navigator & { windowControlsOverlay: { visible: boolean; getTitlebarAreaRect(): DOMRect } }; + const area = windowControlsOverlay.getTitlebarAreaRect(); + const region = (testId: string) => getComputedStyle(document.querySelector(`[data-testid="${testId}"]`)!).getPropertyValue('app-region'); + return { + overlay: windowControlsOverlay.visible, + areaRight: area.x + area.width, + togglesRight: document.querySelector('[data-testid="layout-toggles"]')!.getBoundingClientRect().right, + bar: region('title-bar'), + toggles: region('layout-toggles'), + palette: region('palette-trigger'), + }; + }); + // Electron draws the window buttons over the bar's right end; the bar's own controls end before them. + expect(bar.overlay).toBe(true); + expect(bar.togglesRight).toBeLessThanOrEqual(bar.areaRight); + // Dragging the bar moves the window; its controls are clicked, not dragged. + expect(bar).toMatchObject({ bar: 'drag', toggles: 'no-drag', palette: 'no-drag' }); + }); + it('opens a website and lists its scripts, stylesheets and document', async () => { await goTo(win, site.url); await fileRow(win, `${site.url}/app.js`).waitFor();