From 0496428c8ec4b36229f9a24456a59e44530458ab Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:08:43 +0000 Subject: [PATCH 1/4] fix(ui): scale the card menu, hints panel and plugin settings with the UI scale Some UI chrome kept fixed pixel sizes, so Settings -> Appearance -> UI scale (0.85-1.25) did not reach it: - The terminal card's options menu (Don't restore this window, plugin card actions) had a 12px font and pixel paddings while the card header around it scaled. - The canvas shortcut-hints panel looked the same at 0.85 and 1.25. - In Settings -> Plugins the plugin description scaled with the settings panel, but the permission list, the contribution rows ("App on canvas"), the plugin title, author, version, buttons, module and native-hook lists kept fixed 9-17px sizes, so at 1.25 one card mixed large and small text. The menu and the hints panel now take their font size from calc(Npx * var(--ui-scale, 1)) and size their paddings, gaps, radii and minimum widths in em; the hints icon is sized in em too. The plugin settings font sizes use the same calc. At scale 1.0 every size is the same as before. UI_CONTRACT names the menu and the hints panel among the chrome that follows --ui-scale. The title bar is left as is: its height is shared with the native window chrome (macOS traffic-light position, the startup page) and with every viewport-bound overlay through --titlebar-height. --- docs/UI_CONTRACT.md | 2 +- .../features/workspace/WorkspaceCanvas.tsx | 2 +- src/renderer/src/styles/app.css | 54 ++++++++--------- tests/ui-scale-chrome.test.mjs | 58 +++++++++++++++++++ 4 files changed, 87 insertions(+), 29 deletions(-) create mode 100644 tests/ui-scale-chrome.test.mjs diff --git a/docs/UI_CONTRACT.md b/docs/UI_CONTRACT.md index 7acaf490d..609d8cb14 100644 --- a/docs/UI_CONTRACT.md +++ b/docs/UI_CONTRACT.md @@ -65,7 +65,7 @@ This contract preserves the approved MVP concept and prevents feature ownership - An authenticated connection or heartbeat alone never shows agent presence. A branded badge appears only after that agent actually issues a browser command, and its cursor appears only after a real pointer position exists. Claude uses `#D97757`, Codex `#10A37F`, Qwen `#6D44E8`, Kimi `#7C5CFC`, OpenCode `#5A5858`, Hermes `#D6A700`, and an unknown provider `#7A8291`. Browser Settings separately controls indicator visibility without revoking browser access; the agent-access kill switch revokes the connection itself. - With window snapping enabled, drag and resize use a hidden `10px` grid and a `10px` magnetic threshold for neighboring edges, centers, and a consistent `20px` gap. - One trusted context dispatcher owns right-clicks on the canvas. Truly empty canvas offers region creation, a sticky note, the Settings-configured launcher submenu, Browser, and Settings. Holding RMB on empty canvas also opens the configurable radial quick launcher: moving toward an action and releasing activates it, while a stationary right-click continues to open the standard empty-canvas menu. Region and note right-clicks remain context-specific. A region offers rename, color, a note at the pressed point, and region-only deletion. A note frame offers edit, bring-to-front, and deletion. Terminal, Browser, plugin, textarea/input, and contenteditable surfaces keep their native context menus; middle-button panning is unchanged. `Cmd/Ctrl+K` opens a searchable palette over live sessions and the same global creation/launch actions. Regions persist independently of terminal restore, render behind HOME and windows, resize from every edge/corner, and move every terminal, plugin, Browser, or note whose complete bounds were inside the region at drag start. Attached contents follow the region continuously during the gesture and persist once at pointer release; deleting a region never deletes its contents. Sticky notes persist bounded text and geometry, snap like other windows, and resize from every edge/corner. General Settings exposes independent **Save colored regions after exit** and **Save notes after exit** controls. Both default on to preserve existing profiles. Turning either off removes only that collection from disk persistence without deleting its live canvas objects; turning it back on saves the current collection, while leaving it off starts the next launch with that collection empty. -- Context menus, region editors, the radial launcher, and the command palette use official Lucide/provider assets and shared application color tokens. Their dimensions follow root `--ui-scale`. Appearance owns the `0.85–1.25` scale in `0.05` steps and the sage/lilac/night system palette; the scale changes UI chrome rather than canvas zoom or terminal font size. Agents owns the independent context-menu and radial-launcher subsets and their safe reset defaults. +- Context menus, region editors, the radial launcher, and the command palette use official Lucide/provider assets and shared application color tokens. Their dimensions follow root `--ui-scale`, as do the terminal card options menu and the canvas shortcut-hints panel. Appearance owns the `0.85–1.25` scale in `0.05` steps and the sage/lilac/night system palette; the scale changes UI chrome rather than canvas zoom or terminal font size. Agents owns the independent context-menu and radial-launcher subsets and their safe reset defaults. - Settings uses the approved two-pane modal grammar: a persistent icon-labelled sidebar for all seven sections, a separate current-section header, and a scrollable content pane. Settings must not fall back to the legacy light slide-over with one horizontal segmented tab strip. Simple settings use compact copy/control rows; all Settings chrome reads the active system tokens, official Lucide assets, and root `--ui-scale`. - General Settings places the macOS/Windows/Linux/Custom keyboard preset after language, followed by an expandable Keyboard shortcuts section for canvas, terminal, Codex editor and canvas navigation bindings. Recording a shortcut selects Custom; choosing a named preset replaces bindings. Optional commands can be disabled, while submit, newline and whole-draft selection remain bound. Canvas and terminal bindings update immediately; native Codex editor bindings apply to new or restarted cards. Focused terminal and text inputs retain ownership of canvas keyboard shortcuts on every platform. - Empty-canvas drag and middle-button drag pan the canvas. On fresh profiles ordinary scroll over empty canvas pans on both axes; pinch and `Cmd/Ctrl + scroll` zoom around the pointer. **Use scroll wheel to zoom** restores historical ordinary-wheel zoom and is enabled by migration for existing profiles. Only a focused input widget interrupts canvas wheel/pinch, except the HOME session list keeps ordinary local scrolling without a focus precondition; unfocused or non-focusable widgets otherwise leave those events to the canvas. **Wheel/pinch capture over widgets** has Off, On, and Key modes: Off preserves local ownership, On always transfers only wheel/pinch to the canvas, and Key transfers only wheel/pinch while its binding is held. Key and full-navigation bindings accept modifier chords or Mouse3/Mouse4/Mouse5. A focused live Browser page therefore scrolls normally in Off or released-Key mode; unfocused Browser content, On, an active Key binding, the full override, pinch, and `Cmd/Ctrl + scroll` are canvas-owned. The owner selected for a Browser wheel sequence is held until 250 ms of wheel inactivity. Browser summaries and placeholders are always canvas-owned. Fresh profiles use Key with `Command` on macOS or `Ctrl` elsewhere. A separate configurable **Canvas navigation override** (default `Option` on macOS and `Alt` elsewhere) transfers wheel/pinch and drag while held. While full override is held all canvas surfaces show `grab`; an owned drag shows `grabbing` until pointer up/cancel even after the binding is released. diff --git a/src/renderer/src/features/workspace/WorkspaceCanvas.tsx b/src/renderer/src/features/workspace/WorkspaceCanvas.tsx index 6fef8d6a7..965f91a8c 100644 --- a/src/renderer/src/features/workspace/WorkspaceCanvas.tsx +++ b/src/renderer/src/features/workspace/WorkspaceCanvas.tsx @@ -1687,7 +1687,7 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element )} diff --git a/src/renderer/src/styles/app.css b/src/renderer/src/styles/app.css index f4786c22b..b8713372a 100644 --- a/src/renderer/src/styles/app.css +++ b/src/renderer/src/styles/app.css @@ -324,11 +324,11 @@ button { border: 0; } .canvas-controls { height: 44px; display: flex; align-items: center; padding: 4px; border-radius: 12px; color: white; background: rgba(40,41,52,.94); box-shadow: var(--shadow-sm); pointer-events: auto; } .canvas-controls button { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 8px; color: white; background: transparent; cursor: pointer; transition: background .15s ease; } .canvas-controls button:hover { background: rgba(255,255,255,.13); } -.shortcut-hints { min-width: 224px; padding: 9px 11px; display: grid; gap: 7px; border-radius: 12px; color: white; background: rgba(40,41,52,.9); box-shadow: var(--shadow-sm); pointer-events: none; } -.shortcut-hints > div { display: grid; grid-template-columns: minmax(52px, auto) 1fr; align-items: center; gap: 10px; } -.shortcut-hints kbd { min-width: 52px; padding: 5px 7px; border: 1px solid rgba(255,255,255,.14); border-radius: 7px; color: white; background: rgba(255,255,255,.1); font: 800 11px var(--font-mono); text-align: center; } -.shortcut-hints span { color: rgba(255,255,255,.66); font-size: 11px; font-weight: 750; } -.shortcut-hints__more { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 7px 0 2px; border-top: 1px solid rgba(255,255,255,.12); outline: 0; color: white; background: transparent; cursor: pointer; pointer-events: auto; font-size: 11px; font-weight: 750; } +.shortcut-hints { min-width: 20.36em; padding: .82em 1em; display: grid; gap: .64em; border-radius: 1.09em; color: white; background: rgba(40,41,52,.9); box-shadow: var(--shadow-sm); pointer-events: none; font-size: calc(11px * var(--ui-scale, 1)); } +.shortcut-hints > div { display: grid; grid-template-columns: minmax(4.73em, auto) 1fr; align-items: center; gap: .91em; } +.shortcut-hints kbd { min-width: 4.73em; padding: .45em .64em; border: 1px solid rgba(255,255,255,.14); border-radius: .64em; color: white; background: rgba(255,255,255,.1); font: 800 1em var(--font-mono); text-align: center; } +.shortcut-hints span { color: rgba(255,255,255,.66); font-size: 1em; font-weight: 750; } +.shortcut-hints__more { display: flex; align-items: center; justify-content: space-between; gap: 1.09em; padding: .64em 0 .18em; border-top: 1px solid rgba(255,255,255,.12); outline: 0; color: white; background: transparent; cursor: pointer; pointer-events: auto; font-size: 1em; font-weight: 750; } .shortcut-hints__more:hover { color: var(--secondary); } .shortcut-hints__more:focus-visible { outline: 2px solid var(--secondary); outline-offset: 3px; border-radius: 3px; } .shortcut-reference-dialog { width: min(1200px, calc(100vw - 40px)); max-width: none; max-height: none; padding: 0; overflow: visible; border: 1px solid color-mix(in srgb, var(--text) 12%, transparent); border-radius: var(--radius-xl); color: var(--text); background: var(--surface); box-shadow: var(--shadow-lg); font-size: clamp(10px, calc(1.5vh * var(--ui-scale)), calc(13px * var(--ui-scale))); } @@ -1061,34 +1061,34 @@ button { border: 0; } .home-grid-size strong { font: 850 12px var(--font-mono); } .plugin-settings { display: grid; grid-template-columns: minmax(0, 1fr); gap: 2px; } .plugin-install-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 7px; } -.plugin-install-row input { min-width: 0; height: 45px; padding: 0 13px; border: 1px solid rgba(45,45,56,.14); outline: 0; border-radius: 10px; color: var(--text-dark); background: rgba(255,255,255,.58); font: 700 12px var(--font-mono); user-select: text; } +.plugin-install-row input { min-width: 0; height: 45px; padding: 0 13px; border: 1px solid rgba(45,45,56,.14); outline: 0; border-radius: 10px; color: var(--text-dark); background: rgba(255,255,255,.58); font: 700 calc(12px * var(--ui-scale, 1)) var(--font-mono); user-select: text; } .plugin-install-row input:focus { border-color: var(--surface); box-shadow: 0 0 0 3px color-mix(in srgb, var(--surface) 15%, transparent); } -.plugin-install-row button, .plugin-preview__actions button, .installed-plugin__header button, .plugin-contribution button, .home-widget-list__row button { min-height: 36px; padding: 7px 11px; border-radius: 9px; color: var(--text-dark); background: rgba(255,255,255,.56); cursor: pointer; font-size: 11px; font-weight: 850; } +.plugin-install-row button, .plugin-preview__actions button, .installed-plugin__header button, .plugin-contribution button, .home-widget-list__row button { min-height: 36px; padding: 7px 11px; border-radius: 9px; color: var(--text-dark); background: rgba(255,255,255,.56); cursor: pointer; font-size: calc(11px * var(--ui-scale, 1)); font-weight: 850; } .plugin-install-row button:disabled, .plugin-preview__actions button:disabled, .installed-plugin__header button:disabled, .plugin-contribution button:disabled, .home-widget-list__row button:disabled { cursor: not-allowed; opacity: .5; } .plugin-preview { margin-top: 9px; padding: 14px; border-radius: 13px; color: white; background: var(--surface); } .plugin-preview header { display: flex; align-items: center; justify-content: flex-start; gap: 12px; } .plugin-preview header > span { min-width: 0; display: flex; flex-direction: column; gap: 3px; } -.plugin-preview header strong { font-size: 17px; } -.plugin-preview header span { color: var(--text-muted-on-dark); font: 750 11px var(--font-mono); } -.plugin-preview p, .installed-plugin > p { margin: 8px 0 11px; font-size: 12px; line-height: 1.45; opacity: .78; } +.plugin-preview header strong { font-size: calc(17px * var(--ui-scale, 1)); } +.plugin-preview header span { color: var(--text-muted-on-dark); font: 750 calc(11px * var(--ui-scale, 1)) var(--font-mono); } +.plugin-preview p, .installed-plugin > p { margin: 8px 0 11px; font-size: calc(12px * var(--ui-scale, 1)); line-height: 1.45; opacity: .78; } .plugin-preview__actions { margin-top: 13px; display: flex; justify-content: flex-end; gap: 7px; } .plugin-preview__actions .plugin-primary-action { color: var(--text-dark); background: var(--primary); } -.plugin-permissions { display: grid; gap: 6px; font-size: 11px; line-height: 1.35; } +.plugin-permissions { display: grid; gap: 6px; font-size: calc(11px * var(--ui-scale, 1)); line-height: 1.35; } .plugin-permissions > strong { opacity: .72; } .plugin-permissions > span, .plugin-permissions li { opacity: .68; } .plugin-permissions ul { margin: 0; padding-left: 18px; display: grid; gap: 3px; } -.plugin-hook-warning { margin-top: 9px; padding: 10px 11px; display: flex; flex-direction: column; gap: 4px; border: 1px solid rgba(239,208,132,.35); border-radius: 10px; color: #ffe7a9; background: rgba(96,62,34,.48); font-size: 10px; line-height: 1.4; } -.plugin-hook-warning strong { font-size: 10px; } +.plugin-hook-warning { margin-top: 9px; padding: 10px 11px; display: flex; flex-direction: column; gap: 4px; border: 1px solid rgba(239,208,132,.35); border-radius: 10px; color: #ffe7a9; background: rgba(96,62,34,.48); font-size: calc(10px * var(--ui-scale, 1)); line-height: 1.4; } +.plugin-hook-warning strong { font-size: calc(10px * var(--ui-scale, 1)); } .plugin-hook-warning span { opacity: .82; } .plugin-hook-warning ul { margin: 4px 0 0; padding: 0; display: grid; gap: 4px; list-style: none; } .plugin-hook-warning li { min-width: 0; display: flex; align-items: baseline; justify-content: space-between; gap: 8px; } -.plugin-hook-warning code { overflow-wrap: anywhere; font: 750 9px var(--font-mono); opacity: .82; text-align: right; } +.plugin-hook-warning code { overflow-wrap: anywhere; font: 750 calc(9px * var(--ui-scale, 1)) var(--font-mono); opacity: .82; text-align: right; } .plugin-modules { margin: 10px 0 0; padding: 9px 10px; display: grid; gap: 7px; border: 1px solid rgba(255,255,255,.12); border-radius: 10px; } -.plugin-modules legend { padding: 0 5px; font-size: 11px; font-weight: 850; opacity: .72; } +.plugin-modules legend { padding: 0 5px; font-size: calc(11px * var(--ui-scale, 1)); font-weight: 850; opacity: .72; } .plugin-modules label { display: flex; align-items: flex-start; gap: 8px; cursor: pointer; } .plugin-modules label > span { min-width: 0; display: grid; flex: 1; gap: 2px; } -.plugin-modules label strong { font-size: 12px; } -.plugin-modules label small { font-size: 10px; line-height: 1.3; opacity: .68; } +.plugin-modules label strong { font-size: calc(12px * var(--ui-scale, 1)); } +.plugin-modules label small { font-size: calc(10px * var(--ui-scale, 1)); line-height: 1.3; opacity: .68; } .plugin-settings__error { margin: 9px 0 0; color: #8f3939; font-size: 12px; font-weight: 800; line-height: 1.4; } .plugin-settings__empty { margin: 0; padding: 17px; border-radius: 12px; background: rgba(255,255,255,.34); font-size: 12px; text-align: center; opacity: .65; } .plugin-icon { width: 34px; height: 34px; flex: 0 0 auto; border-radius: 8px; object-fit: cover; background: rgba(255,255,255,.12); } @@ -1192,13 +1192,13 @@ button { border: 0; } .installed-plugin__details .plugin-modules { margin: 8px 0; } .installed-plugin__header > span.installed-plugin__title { min-width: 0; display: flex; flex-direction: row; align-items: center; gap: 10px; } .installed-plugin__header > span.installed-plugin__title > span { min-width: 0; display: flex; flex-direction: column; gap: 3px; } -.installed-plugin__header > span strong { overflow: hidden; font-size: 16px; text-overflow: ellipsis; white-space: nowrap; } -.installed-plugin__header > span small { color: var(--text-muted-on-dark); font: 700 10px var(--font-mono); display: flex; align-items: baseline; gap: 5px; } -.installed-plugin__version-arrow { color: var(--primary); font-size: 11px; } +.installed-plugin__header > span strong { overflow: hidden; font-size: calc(16px * var(--ui-scale, 1)); text-overflow: ellipsis; white-space: nowrap; } +.installed-plugin__header > span small { color: var(--text-muted-on-dark); font: 700 calc(10px * var(--ui-scale, 1)) var(--font-mono); display: flex; align-items: baseline; gap: 5px; } +.installed-plugin__version-arrow { color: var(--primary); font-size: calc(11px * var(--ui-scale, 1)); } .installed-plugin__version-new { color: var(--primary); } -.installed-plugin__host { display: block; margin-top: 2px; font: 700 10px var(--font-mono); } +.installed-plugin__host { display: block; margin-top: 2px; font: 700 calc(10px * var(--ui-scale, 1)) var(--font-mono); } .installed-plugin__author-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 8px; } -.installed-plugin__author { color: var(--text-muted-on-dark); font-size: 14px; font-weight: 750; opacity: .85; } +.installed-plugin__author { color: var(--text-muted-on-dark); font-size: calc(14px * var(--ui-scale, 1)); font-weight: 750; opacity: .85; } .installed-plugin__repo-actions { display: inline-flex; gap: 6px; } .installed-plugin__header .installed-plugin__host, .plugin-showcase-tile__host { font-weight: 700; } .installed-plugin__header > div { display: flex; gap: 6px; } @@ -1209,8 +1209,8 @@ button { border: 0; } .plugin-contribution-list { margin-top: 10px; display: grid; gap: 6px; } .plugin-contribution { min-height: 51px; padding: 7px 7px 7px 11px; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 10px; border-radius: 10px; background: var(--surface-soft); } .plugin-contribution > span { min-width: 0; display: flex; flex-direction: column; gap: 3px; } -.plugin-contribution > span strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } -.plugin-contribution > span small { color: var(--text-muted-on-dark); font-size: 10px; } +.plugin-contribution > span strong { overflow: hidden; font-size: calc(12px * var(--ui-scale, 1)); text-overflow: ellipsis; white-space: nowrap; } +.plugin-contribution > span small { color: var(--text-muted-on-dark); font-size: calc(10px * var(--ui-scale, 1)); } /* Settings v3: iteration 7 two-pane structure with iteration 9 token roles. */ .settings-panel .setting-group:not(.setting-group--field) { padding: 1.1em 0; border-bottom: .08em solid color-mix(in srgb, var(--text) 9%, transparent); } @@ -1368,9 +1368,9 @@ button { border: 0; } .terminal-card__search input:focus { border-color: var(--primary); } .terminal-card__search button { width: 26px; height: 26px; display: grid; place-items: center; border-radius: 6px; color: rgba(255,255,255,.78); background: rgba(255,255,255,.07); cursor: pointer; } .terminal-card__search button:hover { color: white; background: rgba(255,255,255,.16); } -.terminal-card__menu { position: absolute; z-index: 18; top: calc(var(--card-header-height) + 6px); right: 10px; padding: 6px 8px; border: 1px solid rgba(255,255,255,.14); border-radius: 9px; color: white; background: rgba(28,30,40,.96); box-shadow: var(--shadow-md); font-size: 12px; font-weight: 700; } -.terminal-card__menu label { display: flex; align-items: center; gap: 8px; padding: 4px 2px; cursor: pointer; } -.terminal-card__menu-action { display: block; width: 100%; padding: 5px 4px; border-radius: 6px; color: inherit; background: transparent; font: inherit; text-align: left; cursor: pointer; } +.terminal-card__menu { position: absolute; z-index: 18; top: calc(var(--card-header-height) + .5em); right: .84em; padding: .5em .67em; border: 1px solid rgba(255,255,255,.14); border-radius: .75em; color: white; background: rgba(28,30,40,.96); box-shadow: var(--shadow-md); font-size: calc(12px * var(--ui-scale, 1)); font-weight: 700; } +.terminal-card__menu label { display: flex; align-items: center; gap: .67em; padding: .33em .17em; cursor: pointer; } +.terminal-card__menu-action { display: block; width: 100%; padding: .42em .33em; border-radius: .5em; color: inherit; background: transparent; font: inherit; text-align: left; cursor: pointer; } .terminal-card__menu-action:hover, .terminal-card__menu-action:focus-visible { background: rgba(255,255,255,.1); } .terminal-card__plugin-badge { flex: 0 0 auto; min-width: 0; max-width: 14em; overflow: hidden; padding: .15em .55em; border-radius: .5em; background: rgba(255,255,255,.14); font: 700 .7em var(--font-mono); text-overflow: ellipsis; white-space: nowrap; } .terminal-card__plugin-badge--info { background: color-mix(in srgb, #5b9cf5 38%, transparent); } diff --git a/tests/ui-scale-chrome.test.mjs b/tests/ui-scale-chrome.test.mjs new file mode 100644 index 000000000..28230b88c --- /dev/null +++ b/tests/ui-scale-chrome.test.mjs @@ -0,0 +1,58 @@ +// UI chrome follows the root --ui-scale (docs/UI_CONTRACT.md): the card options menu, the canvas +// shortcut-hints panel and the Settings → Plugins cards must not keep fixed pixel text sizes. +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const css = await readFile(new URL("../src/renderer/src/styles/app.css", import.meta.url), "utf8"); +const canvas = await readFile(new URL("../src/renderer/src/features/workspace/WorkspaceCanvas.tsx", import.meta.url), "utf8"); +const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +const rule = (selector) => css.match(new RegExp(`^${escape(selector)} \\{[^}]*\\}`, "mu"))?.[0] ?? ""; +const declaration = (block, property) => block.match(new RegExp(`(?:^|[;{ ])${property}: ([^;]+);`, "u"))?.[1]; +const scaledSource = String.raw`calc\(\d+(?:\.\d+)?px \* var\(--ui-scale, 1\)\)`; + +test("the card options menu scales its font with --ui-scale and sizes everything else from it", () => { + const menu = rule(".terminal-card__menu"); + assert.match(menu, /font-size: calc\(12px \* var\(--ui-scale, 1\)\)/u); + for (const selector of [".terminal-card__menu", ".terminal-card__menu label", ".terminal-card__menu-action"]) { + const block = rule(selector); + assert.ok(block, selector); + for (const property of ["padding", "gap", "border-radius", "right"]) { + const value = declaration(block, property); + if (value) assert.doesNotMatch(value, /\dpx/u, `${selector} ${property} follows the menu's font size`); + } + } +}); + +test("the shortcut-hints panel scales its font with --ui-scale and sizes everything else from it", () => { + assert.match(rule(".shortcut-hints"), /font-size: calc\(11px \* var\(--ui-scale, 1\)\)/u); + for (const selector of [".shortcut-hints", ".shortcut-hints > div", ".shortcut-hints kbd", ".shortcut-hints span", ".shortcut-hints__more"]) { + const block = rule(selector); + assert.ok(block, selector); + for (const property of ["min-width", "padding", "gap", "border-radius", "grid-template-columns", "font", "font-size"]) { + const value = declaration(block, property); + if (value) assert.doesNotMatch(value.replace(new RegExp(scaledSource, "gu"), ""), /\dpx/u, `${selector} ${property} follows the panel's font size`); + } + } + assert.match(canvas, /className="shortcut-hints__more"[\s\S]{0,300}?/u, + "the Keyboard shortcuts icon scales with the panel text"); +}); + +test("Settings → Plugins text in the installed, preview, permission and contribution cards follows --ui-scale", () => { + const prefixes = [".plugin-install-row", ".plugin-preview", ".plugin-permissions", ".plugin-hook-warning", ".plugin-modules", ".installed-plugin", ".plugin-contribution"]; + const blocks = css.split("\n").filter((line) => prefixes.some((prefix) => line.startsWith(prefix)) && line.includes("{")); + assert.ok(blocks.length > 20, "plugin settings rules were found"); + let checked = 0; + for (const block of blocks) { + for (const property of ["font", "font-size"]) { + const value = declaration(block, property); + if (!value) continue; + checked += 1; + assert.doesNotMatch(value.replace(new RegExp(scaledSource, "gu"), ""), /\dpx/u, `${block.slice(0, block.indexOf("{")).trim()} ${property} follows --ui-scale`); + } + } + assert.ok(checked > 15, "plugin settings font sizes were checked"); + for (const selector of [".plugin-permissions", ".plugin-contribution > span strong", ".plugin-contribution > span small"]) { + assert.match(declaration(rule(selector), "font-size") ?? "", new RegExp(scaledSource, "u"), selector); + } +}); From 894af84b836056146b0b3140ae66556babdabf26 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:07:54 +0000 Subject: [PATCH 2/4] fix(i18n): finish the Russian canvas hints and format their key caps like the reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Russian canvas hints and Settings still showed English: the wheel/pinch hint, the hard-coded "Shift + drag" key cap in the hints panel and the shortcut reference, the canvas navigation override description ("wheel, pinch и drag ... Drag колёсиком") and the Settings > Canvas wheel capture strings and Off/On/Key labels. - Translate them, reusing the existing on/off keys and adding shortcutShiftDrag and canvasWheelCaptureKey; the hints use one word for the drag gesture ("перетаскивание"). The group-selection label no longer repeats its key cap (ru/en). A test checks that these ru strings hold no Latin words besides key names. - The wheel/pinch and navigation override rows in Settings > Keyboard shortcuts wrap instead of cutting their longer Russian labels off. - The hints panel showed raw bindings ("Alt+ArrowUp") while the shortcut reference showed "Alt+↑". Both use one formatter now. It maps key by key, so on macOS "Meta+Comma" reads "Command+," instead of the reference's ",nd+Comma" (the Comma key was replaced inside "Command"). --- .../src/components/ShortcutReference.tsx | 8 +- .../src/features/settings/SettingsPanel.tsx | 2 +- .../features/workspace/WorkspaceCanvas.tsx | 12 +- src/renderer/src/lib/i18n.ts | 20 ++-- src/renderer/src/lib/shortcuts.ts | 9 ++ src/renderer/src/styles/app.css | 2 + tests/ru-shortcut-hints-i18n.test.mjs | 113 ++++++++++++++++++ 7 files changed, 146 insertions(+), 20 deletions(-) create mode 100644 tests/ru-shortcut-hints-i18n.test.mjs diff --git a/src/renderer/src/components/ShortcutReference.tsx b/src/renderer/src/components/ShortcutReference.tsx index 2f736c8af..8752d086a 100644 --- a/src/renderer/src/components/ShortcutReference.tsx +++ b/src/renderer/src/components/ShortcutReference.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from "react"; import type { AppSettings } from "../../../shared/contracts"; -import { displayCanvasNavigationBinding } from "../lib/shortcuts"; +import { displayCanvasNavigationBinding, displayShortcutBinding } from "../lib/shortcuts"; import { UiIcon } from "./UiIcon"; import { t, type TranslationKey } from "../lib/i18n"; @@ -11,9 +11,7 @@ export function ShortcutReference({ settings, onClose }: { const dialog = useRef(null); const { locale, shortcuts: bindings } = settings; const mac = window.canvasTTY.window.isMacOS; - const display = (binding: string) => binding.replace("Meta", mac ? "Command" : "Super") - .replace("Comma", ",").replace(/ArrowUp/g, "↑").replace(/ArrowDown/g, "↓") - .replace(/ArrowLeft/g, "←").replace(/ArrowRight/g, "→") || t(locale, "disabled"); + const display = (binding: string) => displayShortcutBinding(binding, mac) || t(locale, "disabled"); const groups: { title: TranslationKey; shortcuts: [string[], TranslationKey][] }[] = [ { title: "keyboardCanvas", shortcuts: [ [[bindings.home], "homeShortcut"], @@ -22,7 +20,7 @@ export function ShortcutReference({ settings, onClose }: { [[bindings.commandPalette], "commandPalette"], [[bindings.openSettings], "settings"], [[bindings.toggleDetail], "keyboardToggleDetail"], - [["Shift + drag"], "marqueeSelectionHint"] + [[t(locale, "shortcutShiftDrag")], "marqueeSelectionHint"] ] }, { title: "keyboardNavigation", shortcuts: [ [[bindings.focusUp], "keyboardFocusUp"], diff --git a/src/renderer/src/features/settings/SettingsPanel.tsx b/src/renderer/src/features/settings/SettingsPanel.tsx index db4619189..501730af2 100644 --- a/src/renderer/src/features/settings/SettingsPanel.tsx +++ b/src/renderer/src/features/settings/SettingsPanel.tsx @@ -628,7 +628,7 @@ export function SettingsPanel({ ))} changeCanvasWheelCaptureMode(value as CanvasWheelCaptureMode)} /> {settings.canvasWheelCaptureMode === "key" && ( -
{settings.shortcuts.home}{t(settings.locale, "homeShortcut")}
-
{settings.shortcuts.renameWindow}{t(settings.locale, "renameWindow")}
-
{settings.shortcuts.toggleFullscreen.replace("Meta", window.canvasTTY.window.isMacOS ? "Command" : "Super")}{t(settings.locale, "toggleFullscreen")}
-
{settings.shortcuts.focusUp}{t(settings.locale, "keyboardFocusUp")}
-
Shift + drag{t(settings.locale, "marqueeSelectionHint")}
+ {([["home", "homeShortcut"], ["renameWindow", "renameWindow"], ["toggleFullscreen", "toggleFullscreen"], + ["focusUp", "keyboardFocusUp"]] as const).map(([action, label]) => ( +
{displayShortcutBinding(settings.shortcuts[action], window.canvasTTY.window.isMacOS)}{t(settings.locale, label)}
+ ))} +
{t(settings.locale, "shortcutShiftDrag")}{t(settings.locale, "marqueeSelectionHint")}
{settings.canvasWheelCaptureMode === "key" && settings.canvasWheelOverride !== null && (
{displayCanvasNavigationBinding(settings.canvasWheelOverride, window.canvasTTY.window.isMacOS)} {t(settings.locale, "canvasWheelOverrideHint")}
diff --git a/src/renderer/src/lib/i18n.ts b/src/renderer/src/lib/i18n.ts index b69e9190a..bded1120f 100644 --- a/src/renderer/src/lib/i18n.ts +++ b/src/renderer/src/lib/i18n.ts @@ -491,16 +491,17 @@ const ru = { zoomSensitivity: "Чувствительность зума", useScrollWheelToZoom: "Зум обычным скроллом", useScrollWheelToZoomDescription: "Если выключено, обычный скролл перемещает канвас, а Ctrl/Command + скролл и pinch масштабируют.", - canvasWheelCapture: "Перехват wheel/pinch над виджетами", - canvasWheelCaptureDescription: "Off оставляет обычный wheel/pinch focused input-виджету, включая live Browser page. On и активный бинд передают события канвасу; pinch и Ctrl/Command + скролл над Browser всегда масштабируют canvas. Клики и drag не перехватываются.", - canvasWheelOverride: "Комбинация wheel/pinch", - canvasWheelOverrideHint: "Wheel/pinch поверх виджетов", + canvasWheelCapture: "Перехват колеса и жеста масштаба над виджетами", + canvasWheelCaptureDescription: "«Выключено» оставляет обычную прокрутку колесом и жест масштаба виджету с фокусом ввода, включая открытую страницу браузера. «Включено», а также «По комбинации» при нажатой комбинации, передают их канвасу; жест масштаба и Ctrl/Command + прокрутка над браузером всегда масштабируют канвас. Клики и перетаскивание не перехватываются.", + canvasWheelCaptureKey: "По комбинации", + canvasWheelOverride: "Комбинация для колеса и жеста масштаба", + canvasWheelOverrideHint: "Колесо и жест масштаба поверх виджетов", canvasOverrideShortcutInvalid: "Нужен модификатор, комбинация с ним или Mouse3/Mouse4/Mouse5.", canvasNavigationOverride: "Перехват навигации канвасом", - canvasNavigationOverrideDescription: "Пока комбинация или кнопка мыши удерживается, wheel, pinch и drag над любым виджетом принадлежат канвасу. Drag колёсиком работает всегда.", + canvasNavigationOverrideDescription: "Пока комбинация или кнопка мыши удерживается, прокрутка колесом, жест масштаба и перетаскивание над любым виджетом принадлежат канвасу. Перетаскивание с нажатым колёсиком работает всегда.", canvasNavigationCaptureHint: "Удержите модификатор, нажмите комбинацию или Mouse3/Mouse4/Mouse5. Escape отменяет запись.", canvasNavigationOverrideHint: "Навигация поверх виджетов", - canvasOverrideBindingsMatch: "Комбинации совпадают: полный перехват имеет приоритет и также забирает drag.", + canvasOverrideBindingsMatch: "Комбинации совпадают: полный перехват имеет приоритет и также забирает перетаскивание.", disabled: "Отключено", terminalWheelDirection: "Направление колеса терминала", copyOnSelect: "Копировать при выделении", @@ -769,7 +770,8 @@ const ru = { terminalSearchClose: "Закрыть поиск", fitCanvas: "Показать всё", focusWindowHint: "Фокус на соседнее окно", - marqueeSelectionHint: "Shift + тянуть — выделить группу", + marqueeSelectionHint: "Выделить группу", + shortcutShiftDrag: "Shift + перетаскивание", remarkAnchorStep: "Замечание к шагу", remarkAnchorPage: "Замечание к странице", remarkAnchorTime: "Замечание ко времени", @@ -1524,6 +1526,7 @@ const en: Record = { useScrollWheelToZoomDescription: "When off, plain scrolling pans the canvas while Ctrl/Command + scroll and pinch zoom.", canvasWheelCapture: "Wheel/pinch capture over widgets", canvasWheelCaptureDescription: "Off leaves plain wheel/pinch with the focused input widget, including a live Browser page. On and an active binding route it to the canvas; Browser pinch and Ctrl/Command + scroll always zoom the canvas. Clicks and drag are not captured.", + canvasWheelCaptureKey: "Key", canvasWheelOverride: "Wheel/pinch shortcut", canvasWheelOverrideHint: "Wheel/pinch over widgets", canvasOverrideShortcutInvalid: "Use a modifier, a modifier-based chord, or Mouse3/Mouse4/Mouse5.", @@ -1800,7 +1803,8 @@ const en: Record = { terminalSearchClose: "Close search", fitCanvas: "Fit to content", focusWindowHint: "Focus the neighbouring window", - marqueeSelectionHint: "Shift + drag selects a group", + marqueeSelectionHint: "Select a group", + shortcutShiftDrag: "Shift + drag", remarkAnchorStep: "Remark on step", remarkAnchorPage: "Remark on page", remarkAnchorTime: "Remark on the time", diff --git a/src/renderer/src/lib/shortcuts.ts b/src/renderer/src/lib/shortcuts.ts index 0b037c770..c1044a5ff 100644 --- a/src/renderer/src/lib/shortcuts.ts +++ b/src/renderer/src/lib/shortcuts.ts @@ -123,6 +123,15 @@ export function handleMacNativeSelectAll(event: KeyboardEvent, isMacOS: boolean) return true; } +/** + * An action shortcut as the hints panel and the shortcut reference show it: arrows as glyphs, Meta as Command or + * Super. Key by key, so the Comma key is not looked for inside "Command". + */ +export function displayShortcutBinding(binding: string, isMacOS: boolean): string { + const names: Record = { Meta: isMacOS ? "Command" : "Super", Comma: ",", ArrowUp: "↑", ArrowDown: "↓", ArrowLeft: "←", ArrowRight: "→" }; + return binding.split("+").map((key) => names[key] ?? key).join("+"); +} + export function displayCanvasNavigationBinding(binding: string, isMacOS: boolean): string { if (!isMacOS) return binding; return binding.split("+").map((part) => { diff --git a/src/renderer/src/styles/app.css b/src/renderer/src/styles/app.css index b8713372a..8e2abe703 100644 --- a/src/renderer/src/styles/app.css +++ b/src/renderer/src/styles/app.css @@ -901,6 +901,8 @@ button { border: 0; } .segmented__button--active { color: var(--text-dark); background: var(--primary); } .segmented__button:focus-visible { box-shadow: inset 0 0 0 2px color-mix(in srgb, currentColor 42%, transparent); } .setting-group__control > .segmented + .shortcut-editor { margin-top: .6em; } +/* The canvas navigation rows sit in the narrow control column: their (longer localized) labels wrap instead of being cut off. */ +.setting-group__control .shortcut-editor__row > span { overflow-wrap: break-word; text-overflow: clip; white-space: normal; line-height: 1.3; } .swatch-choices { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: .4em; } .swatch-choices--four { grid-template-columns: repeat(4, minmax(0, 1fr)); } .swatch-choice { min-width: 0; min-height: 4.6em; padding: .48em; display: grid; grid-template-rows: 2em auto; align-items: center; gap: .45em; border: .08em solid transparent; border-radius: .72em; outline: 0; color: var(--text-muted-on-dark); background: var(--surface-soft); cursor: pointer; font-size: .76em; font-weight: 700; transition: color .15s ease, background .15s ease, border-color .15s ease, box-shadow .15s ease; } diff --git a/tests/ru-shortcut-hints-i18n.test.mjs b/tests/ru-shortcut-hints-i18n.test.mjs new file mode 100644 index 000000000..c5d0b2575 --- /dev/null +++ b/tests/ru-shortcut-hints-i18n.test.mjs @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { t } from "../src/renderer/src/lib/i18n.ts"; + +const canvasPath = new URL("../src/renderer/src/features/workspace/WorkspaceCanvas.tsx", import.meta.url); +const referencePath = new URL("../src/renderer/src/components/ShortcutReference.tsx", import.meta.url); +const settingsPath = new URL("../src/renderer/src/features/settings/SettingsPanel.tsx", import.meta.url); + +// Latin words that may stay untranslated in Russian hints: keyboard and mouse key +// names, plus product names and the HOME zone label that the UI shows verbatim. +const allowedLatin = new Set([ + "Shift", "Command", "Super", "Ctrl", "Control", "Alt", "Option", "Meta", + "Enter", "Escape", "Esc", "Space", "Tab", "Backspace", "Delete", + "PageUp", "PageDown", "Mouse3", "Mouse4", "Mouse5", + "HOME", "Codex", "CLI", "TUI" +]); + +function latinWords(text) { + return [...text.matchAll(/[A-Za-z][A-Za-z0-9]*/g)].map((match) => match[0]) + .filter((word) => !allowedLatin.has(word) && !/^F\d{1,2}$/.test(word)); +} + +async function hintKeys() { + const [canvas, reference] = await Promise.all([readFile(canvasPath, "utf8"), readFile(referencePath, "utf8")]); + const aside = canvas.slice(canvas.indexOf('