---
title: Per-window zoom (each popped-out window remembers its own size)
---

# Per-window zoom (each pop-out window has its own saved zoom)

## What it is

### What it is

**Per-window zoom** gives every Omniscio **secondary window** — the ones you pop out
into their own desktop window — its **own** zoom level, like the zoom in a web
browser. It scales the **whole window** (text, icons, images) so the whole thing
gets bigger or smaller together, and it is **saved per window**: close a window
and reopen it and its zoom comes back.

The key word is **independent**. Zooming one pop-out does **not** touch any other
window, and — by design — it **never affects the main Omniscio window**. The main
window keeps the app-wide **Text size** behavior it always had (Text size is one
global setting shared across the app; per-window zoom is separate, per-window, and
scales everything, not just text). So you can, say, make a popped-out session
window large on a second monitor while the main window stays exactly as it was.

## Where to find it

Inside any **popped-out window** — a session you opened in its own window, a project window, the vault or scratchpad window. The controls are the familiar browser zoom keys, so there is no menu to find.

## How it behaves

### The controls

Inside any of these windows:

- **Ctrl and `+`** (or `=`) — zoom in one step.
- **Ctrl and `-`** — zoom out one step.
- **Ctrl and `0`** — reset to 100%.
- **Ctrl and the mouse wheel** — scroll up to zoom in, down to zoom out.

The numpad `+` / `-` / `0` keys work too. There is no Settings control and no
menu — zoom lives entirely in the keyboard and wheel shortcuts, inside the window
you want to change.

### The range (75%–150%)

Zoom moves along a **seven-step ladder** — 75%, 87.5%, 100%, 112.5%, 125%, 137.5%,
150% — so 100% is the middle and you can go three steps smaller or three steps
larger. This is deliberately the **same scale as the app-wide Text size** feature,
so it feels like the size control you already know — just per-window, and scaling
everything on the window instead of only the text. Stepping past either end simply
stays at that end (it won't go below 75% or above 150%).

### Saved per window

Each window remembers its own zoom across close and reopen:

- **Session pop-outs** remember **per session** — pop out session A at 125% and
  session B at 90%, and each comes back at its own level next time.
- **Project / integration windows** remember **per project**.
- The **single-instance windows** — **KMS**, **Scratchpad**, **Quick Launch**,
  **Job Monitor**, **Support Chat**, and **Clipboard History** — each remember
  their own one zoom.

A brand-new window that you have never zoomed simply opens at its normal 100% —
nothing is stored until the first time you actually change the zoom.

### Which windows are in scope

Per-window zoom applies to these pop-out / secondary windows:

- Session pop-out (a session opened in its own window via **Pop Out**)
- Project or integration window ("Open in new window")
- KMS window
- Scratchpad window
- Quick Launch window
- Job Monitor window
- Support Chat window
- Clipboard History window

It does **not** apply to:

- **The main Omniscio window** — never gets per-window CSS zoom; it keeps the app-wide
  **Text size** instead, and there **Ctrl+= / Ctrl+-** _and_ **Ctrl+mouse-wheel** step
  that Text size (see [text-size.md](text-size.md)). So Ctrl+scroll works on the main
  window too — it just steps the shared Text size rather than zooming one window.
- **The embedded isolated-session view** — this is a session rendered _inside_ the
  main window (the experimental "Isolated session view" performance mode), so
  zooming it would zoom part of the main window; it is excluded and never zooms on
  its own.
- **Mobile / the phone web app** — zoom here is desktop-window-only; the phone
  keeps its own separate Mobile text size.

### The editor nuance (KMS and Scratchpad)

KMS and Scratchpad hold a **rich-text editor**, and that editor already uses
**Ctrl+-** for its own action (strikethrough). So per-window zoom is polite about
it: while your cursor is **inside a text field or the rich-text editor**, the
**keyboard** zoom shortcuts **step aside** so the editor keeps its own Ctrl+- (and
Ctrl+=). To zoom one of those windows, either use **Ctrl + mouse wheel** (which
always zooms, everywhere), or click somewhere outside the editor first and then
press Ctrl + / -.

## For agents

### For agents — how it works

- **The hook.** One shared renderer hook,
  [`useWindowZoom(zoomKey)`](/src/renderer/src/hooks/useWindowZoom.ts), is mounted
  **once** in each in-scope window's root component (`DetachedSessionApp`,
  `DetachedWindowApp`, `KmsWindowApp`, `ScratchpadWindowApp`, `SupportChatWindowApp`,
  `quick-launch.tsx`, `job-monitor.tsx`, `clipboard-history-window.tsx`). It applies
  the saved factor as the CSS **`zoom`** property on `document.documentElement`
  (`style.setProperty('zoom', …)`), owns the Ctrl `+`/`-`/`0` keys and Ctrl+wheel,
  and persists the factor (debounced ~300 ms, with a `pagehide`/unmount flush so a
  zoom made right before closing still saves). Passing `zoomKey = null` disables the
  hook entirely — that is how the embedded isolated-view guest opts out
  (`useWindowZoom(embedded ? null : \`session:${sessionId}\`)`).
- **Independence is structural.** The main window simply **never mounts the hook**,
  and CSS `zoom` is **per-document**, so one window's zoom physically cannot reach
  another. There is no `HostZoomMap`, no per-window session partition, and no custom
  protocol — zero blast radius on auth / storage / cookies. The global `fontSize`
  (Text size) still applies as the base in every window; `zoom` multiplies on top,
  exactly like a browser's default font size × page zoom.
- **The ladder + clamp.** The 7-step ladder and the step/clamp helpers live in
  [`/src/shared/window-zoom.ts`](/src/shared/window-zoom.ts) (`ZOOM_STEPS`,
  `clampZoom`, `stepZoom`, `DEFAULT_ZOOM = 1.0`) — the single source of truth the
  hook, the Main-side clamp, and the tests all derive from. Factors are clamped
  **both** on the client and again server-side, and a non-finite value heals to 1.0.
- **The window-fill.** CSS `zoom` scales the whole document, so a pop-out shell
  sized `100vh` renders at `100vh × zoom` and — zoomed **out** — stops covering a
  maximized window, exposing the dark window background (the fullscreen zoom-out
  bug). So the hook writes the factor to a `--window-zoom` CSS variable (in lock-step
  with `zoom`, from one shared value), and each pop-out's top-level shell uses the
  shared **`.window-zoom-fill`** class — `height: calc(100vh / var(--window-zoom, 1))`
  in [`/src/renderer/src/styles/globals.css`](/src/renderer/src/styles/globals.css) —
  so it spans the window at any zoom (the `× zoom` render lands back on the window
  edge). Height-only (shells fill width via `auto`); the `1` fallback is native
  `100vh` for the main window and any window with no saved zoom, so it is a
  zero-regression drop-in. It applies to the shells that have a viewport fill
  (`DetachedSessionApp`, `DetachedWindowApp`, `KmsWindowApp`, `ScratchpadWindowApp`,
  `SupportChatWindowApp`); the fixed/content-sized windows (Quick Launch, Job Monitor,
  Clipboard History) have no such shell and need nothing. **The shared `#root` needs the
  same compensation.** The `?window=` project/integration and `?detached=` session
  pop-outs reuse the main `index.html` `#root`, which is
  `position: fixed; overflow: hidden; height: 100dvh` — so `#root` ALSO uses
  `height: calc(100dvh / var(--window-zoom, 1))`. A bare `100dvh` there renders at
  `100dvh × zoom`, so a zoomed-**out** `#root` shrank to the top-left and CLIPPED the
  `.window-zoom-fill` shell, cramming the whole pop-out — header included — into a corner
  with a dark gap on the right + bottom (the reported Tasks pop-out bug). KMS and
  Scratchpad use their own roots (`#kms-window-root` / their own) with no explicit height,
  so they were never affected — which is why only `#root` needed the fix, and why the
  KMS/Scratchpad zoom tests never caught it.
- **The frameless title strip.** The frameless pop-outs (Tasks / project, KMS,
  Scratchpad — `titleBarStyle: 'hidden'`, custom HTML caption buttons via `WindowControls`)
  draw their own in-renderer title strip with custom min/max/close buttons. CSS `zoom`
  scales the whole document, so a bare fixed-height strip rendered at `height × zoom`
  (51 px at 125 %, 31 px at 75 %) and the buttons no longer sat on it (the "messed up at
  the top" bug). Each frameless strip therefore carries the sibling **`.window-zoom-chrome`**
  class — `zoom: calc(1 / var(--window-zoom, 1))` in the same
  [`globals.css`](/src/renderer/src/styles/globals.css) — which **counter-zooms** the strip
  (net scale = zoom × 1/zoom = 1) so its height, label, KMS tabs, and caption buttons all
  render at their natural DIP size. The strips:
  [`StandaloneWindowTitleBar`](/src/renderer/src/components/ui/StandaloneWindowTitleBar.tsx)
  (Tasks + Scratchpad) and
  [`KmsWindowTitleBar`](/src/renderer/src/features/kms/KmsWindowTitleBar.tsx) (KMS).
  `.window-zoom-fill` (the shell) is unchanged and measured-correct at every zoom.
- **Floating overlays + tooltips.** A `position: fixed` overlay (a tooltip bubble, a
  portaled menu / popover) has its `top`/`left` resolved in the pre-zoom layout space and
  then re-scaled by the window's `zoom` at paint — but it is positioned from a
  `getBoundingClientRect()` anchor that is ALREADY zoomed, so an uncorrected overlay lands
  at `anchor × zoom`: visibly ABOVE the trigger when zoomed out (the reported Tasks
  chevron-tooltip "way above the chevron" bug), below it when zoomed in, and the error grows
  with the anchor's distance down the window. The shared clamp primitive
  [`containToViewport`](/src/renderer/src/lib/clamp-to-viewport.ts) therefore clamps in
  visual space and then DIVIDES the final `top`/`left` by the root `zoom` — a private
  `rootZoom(node)` that reads `getComputedStyle(<html>).zoom`; any missing / non-positive /
  unparseable value heals to `1`, so it is a **no-op at 100% and in the never-zoomed main
  window**. Every native-`title` / aria-label / hotkey tooltip is drawn by one bubble that
  positions through this helper, so they are all zoom-correct in a pop-out for free. Locked
  by [`overlay-viewport-clamp-contract.md`](/.claude/memory/contracts/overlay-viewport-clamp-contract.md)
  I2a. (Sibling gaps still open, unreported: `capToViewport`'s size cap and `clampToViewport`'s
  flip carry the same latent re-scale under zoom — see that contract's CSS-root-zoom known-gap.)
- **The IPC channels.** Two Zod-validated channels move the factor:
  **`window:zoom-get`** (`IPC.WINDOW_ZOOM_GET`) returns `{ factor: number | null }`
  (null when nothing is saved), and **`window:zoom-set`** (`IPC.WINDOW_ZOOM_SET`)
  persists `{ zoomKey, factor }` (void). Handlers are in
  [`/src/main/ipc/window-zoom-handlers.ts`](/src/main/ipc/window-zoom-handlers.ts)
  via `wrapHandler`; `zoomKey` is validated by a strict schema (length-capped,
  conservative charset) and `factor` must be finite.
- **The table.** Main persists one row per window identity in a `window_zoom`
  SQLite table (`zoom_key TEXT PRIMARY KEY`, `factor REAL`, `updated_at TEXT`),
  created by the `create-window-zoom` migration and read/written through
  [`/src/main/db/queries-window-zoom.ts`](/src/main/db/queries-window-zoom.ts)
  (`getWindowZoom` / `saveWindowZoom`, an `INSERT … ON CONFLICT` upsert). The
  `zoomKey` is the window identity: `session:<id>`, `project-window:<id>`, or a fixed
  singleton key (`kms`, `scratchpad`, `quicklaunch`, `jobmonitor`, `support-chat`,
  `clipboard-history`).

The invariants (main-window-never-zoomed, per-document keying, null-key exclusion,
server-side clamp, zoom-compensated window-fill, zoom-immune chrome strip) are locked by
the contract at `/.claude/memory/contracts/per-window-zoom-contract.md` and its guard
tests.

## Related

### Related

- [text-size.md](text-size.md) — the app-**wide** Text size feature (Desktop /
  Mobile), the sibling model this reuses the ladder from; it changes text
  everywhere, whereas per-window zoom is per-window and scales everything.
- [detached-session-window.md](detached-session-window.md) — how a session pops out
  into its own window (one of the windows this zooms).
- [pop-out-project-window.md](pop-out-project-window.md) — popping a whole project or
  integration panel into its own window.
- [isolated-session-view.md](isolated-session-view.md) — the embedded in-window guest
  that is deliberately excluded from per-window zoom.

