diff --git a/CHANGELOG.md b/CHANGELOG.md
index c1b6243..5de928f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,10 +11,14 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
### Added
- Playwright e2e now runs on **WebKit** in addition to Chromium and Firefox, so
- Safari regressions on the `html{zoom}`-based layout fail CI instead of
+ many Safari regressions on the `html{zoom}`-based layout fail CI instead of
shipping silently. README gained a **Supported browsers** stance: desktop
- Chromium/Firefox/Safari are supported (Safari verified green on CI); the full
- browser/ClickHouse/IdP matrix is tracked in #71. (#69)
+ Chromium/Firefox/Safari are supported; the full browser/ClickHouse/IdP matrix
+ is tracked in #71. (#69)
+- `tests/e2e/zoom-support.spec.js` regression-guards the fullscreen-panel sizing
+ mechanism (#70) on all three engines. Caveat now documented: Playwright's WebKit
+ is **not** a faithful Safari proxy for `zoom` × `getBoundingClientRect`/viewport
+ units — it behaves like Chromium there — so that path is verified manually (#71).
### Changed
- State reactivity now uses `@preact/signals-core` (the third bundled runtime
@@ -25,6 +29,19 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
schema-panel spike was evaluated and **rejected** — the app stays
framework-free (ADR-0001 addendum). (#88)
+### Fixed
+- The fullscreen schema / EXPLAIN graph panels were mis-sized on **Safari** (#70).
+ They size off viewport units, and engines disagree on how `vw`/`vh` interact
+ with `html{zoom}`: Chromium's ignore `zoom` (so `100vh` overshoots one screen by
+ the zoom factor and must be divided back), but WebKit/Safari's track `zoom`, so
+ the existing `calc(.../var(--zoom))` correction shrank those panels to ~83%. The
+ divisor is now measured at runtime (a `100vh` probe vs the one-screen `#root`)
+ and published as `--vp-zoom` — ~`--zoom` on Chromium, ~1 on Safari — so the
+ panels fit exactly one screen on both. The rest of the UI was already correct on
+ Safari (its pointer/caret/drag corrections self-calibrate to the live rect
+ ratio). A `@supports not (zoom: 1)` rule still neutralizes the factor to 1 on
+ engines that can't parse `zoom` at all.
+
## [0.1.5] - 2026-06-29
### Added
diff --git a/README.md b/README.md
index a03051e..de449f0 100644
--- a/README.md
+++ b/README.md
@@ -449,12 +449,24 @@ docs/ ARCHITECTURE.md, DEPLOYMENT.md, ASSET-DISTRIBUTION.md,
Current **desktop** engines — Chromium (Chrome/Edge), Firefox, and **Safari
(WebKit)** — are all supported. The whole layout and the pointer/caret/drag math
-ride on `html { zoom: var(--zoom) }`, and WebKit is the engine most likely to
-diverge on `zoom` × `getBoundingClientRect`/viewport units, so it is exercised
-on **every CI run**: the Playwright e2e suite runs the editor-alignment,
-editor-insertion, schema-graph and EXPLAIN-pipeline specs on all three engines
-(`webkit` included as of #69), and Safari/WebKit passes them. A regression that
-breaks Safari now fails CI rather than shipping silently.
+ride on `html { zoom: var(--zoom) }`. The pointer/caret/drag corrections
+self-calibrate (they divide by the live `getBoundingClientRect`/`offsetWidth`
+ratio — the zoom factor on Chromium, `1` on Safari — both correct), so those work
+across engines.
+
+Engines do diverge on one thing — **viewport units under `zoom`**: Chromium's
+`vw`/`vh` ignore it, Safari's track it. The fullscreen graph panels size off
+`vw`/`vh`, so the divisor they apply is **measured at runtime** and published as
+`--vp-zoom` (~`--zoom` on Chromium, ~`1` on Safari), letting them fit one screen
+on both (#70). An engine that can't parse `zoom` at all falls back via
+`@supports not (zoom: 1)` to a consistent 1× layout.
+
+CI exercises the editor-alignment, editor-insertion, schema-graph and
+EXPLAIN-pipeline specs on all three engines (`webkit` added in #69), plus a
+panel-sizing spec. **Caveat:** Playwright's WebKit applies `zoom` to
+`getBoundingClientRect`/viewport units like Chromium, *not* like real Safari, so
+it is not a faithful Safari proxy for that specific behavior — the real-Safari
+viewport-unit path is verified manually (tracked in the #71 matrix).
> There is no responsive CSS today (fixed px, `overflow:hidden` on
> `html`/`body`), so the app targets **desktop** browsers; the formal
diff --git a/src/core/zoom-support.js b/src/core/zoom-support.js
new file mode 100644
index 0000000..232deca
--- /dev/null
+++ b/src/core/zoom-support.js
@@ -0,0 +1,24 @@
+// Pure helper for the page's CSS `zoom` layout dependency. The whole UI rides on
+// `html { zoom: var(--zoom) }` (see styles.css). Most of the layout and all the
+// pointer/caret/drag math are fine across engines — `getBoundingClientRect` and
+// pointer coords share a coordinate space within an engine, so the corrections
+// self-calibrate (they divide by the live rect/offset ratio, which is the zoom
+// factor on Chromium and 1 on WebKit/Safari, and both are correct).
+//
+// The one place engines genuinely diverge is **viewport units under `zoom`**:
+// Chromium's `vw`/`vh` ignore `zoom`, so a `100vh` element is `--zoom`× too tall;
+// WebKit/Safari's track `zoom`, so `100vh` is exactly one screen. The fullscreen
+// graph panels size off `vw`/`vh`, so they need a per-engine divisor — measured
+// live (see app.applyViewportZoom) and published as `--vp-zoom` (#70).
+
+// The divisor the fullscreen panels must apply to viewport units so they fill
+// exactly one real screen: the overshoot of a `height:100vh` probe (`vhPx`) over
+// a known one-screen reference (`refPx`, the `height:100%`-sized #root). Chromium
+// → ~`--zoom`; WebKit/Safari → ~1. Returns `null` when either measurement is
+// missing or degenerate (e.g. happy-dom has no layout) so the caller leaves the
+// CSS default (`--vp-zoom: var(--zoom)`) in place rather than mis-set it.
+export function viewportZoom(vhPx, refPx) {
+ if (!Number.isFinite(vhPx) || vhPx <= 0) return null;
+ if (!Number.isFinite(refPx) || refPx <= 0) return null;
+ return vhPx / refPx;
+}
diff --git a/src/styles.css b/src/styles.css
index e6190a1..17f1cc4 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -7,11 +7,24 @@ html { zoom: var(--zoom); }
--mono: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
--accent: #0079AD;
--accent-dim: #005F8A;
- /* Page zoom. Normal flow scales cleanly, but `zoom` does NOT divide viewport
- units, so an in-flow `100vh`/`100vw`/`100%`-tall element renders --zoom times
- too big and overflows the window. The two full-viewport graph panels below
- divide their viewport sizing by --zoom to fit; bump both together here. */
+ /* Page zoom. Normal flow scales cleanly, but viewport units interact with
+ `zoom` differently per engine: on Chromium `vh`/`vw` ignore `zoom`, so a
+ `100vh`/`100vw` element renders --zoom times too big and overflows; on
+ WebKit/Safari they track `zoom`, so `100vh` is exactly one screen. The
+ fullscreen graph panels below divide their viewport sizing by --vp-zoom —
+ the per-engine divisor measured at runtime (app.applyViewportZoom, #70):
+ ~--zoom on Chromium, ~1 on Safari. It defaults to --zoom here so behavior is
+ unchanged (Chromium-correct) until the measurement runs / if JS is off. */
--zoom: 1.2;
+ --vp-zoom: var(--zoom);
+}
+
+/* Fallback for engines that can't even parse `zoom` (no `@supports (zoom: 1)`):
+ neutralize the factor so the `html{zoom}` no-op AND every `calc(.../var(--vp-zoom))`
+ viewport panel collapse to a consistent 1× layout, instead of dividing by a
+ zoom that was never applied. (--vp-zoom inherits --zoom, so it follows to 1 too.) */
+@supports not (zoom: 1) {
+ :root { --zoom: 1; }
}
[data-theme='dark'] {
@@ -735,10 +748,11 @@ body {
}
.graph-overlay-panel {
/* `.graph-overlay` is a fixed inset:0 backdrop with 24px padding. A plain
- 100% here would render --zoom times too tall (see --zoom) and spill past the
- viewport; divide the viewport box (minus the 48px padding) by --zoom so the
- panel fits with its breathing room intact. */
- width: calc((100vw - 48px) / var(--zoom)); height: calc((100vh - 48px) / var(--zoom));
+ 100% here can render --zoom times too tall (see --zoom/--vp-zoom) and spill
+ past the viewport; divide the viewport box (minus the 48px padding) by
+ --vp-zoom (the per-engine viewport divisor) so the panel fits with its
+ breathing room intact on both Chromium and Safari. */
+ width: calc((100vw - 48px) / var(--vp-zoom)); height: calc((100vh - 48px) / var(--vp-zoom));
background: var(--bg-modal); border: 1px solid var(--border);
border-radius: 11px; overflow: hidden;
display: flex; flex-direction: column;
@@ -788,14 +802,15 @@ body {
.graph-overlay-canvas.schema-canvas.modkey .eg-card text { cursor: move; }
/* The schema graph's own browser tab: the panel fills the viewport chromelessly.
- Divide by --zoom like the panel (see --zoom) so the body is exactly one screen
- tall rather than 1.2× (which would otherwise only be hidden by overflow:hidden). */
-body.schema-tab { margin: 0; height: calc(100vh / var(--zoom)); overflow: hidden; background: var(--bg-editor); }
+ Divide by --vp-zoom like the panel (see --zoom/--vp-zoom) so the body is exactly
+ one screen tall on both engines (the opener mirrors its measured --vp-zoom onto
+ this tab's ; falls back to --zoom otherwise). */
+body.schema-tab { margin: 0; height: calc(100vh / var(--vp-zoom)); overflow: hidden; background: var(--bg-editor); }
/* Full-bleed in its own browser tab — no backdrop padding, but still divide the
- viewport sizing by --zoom (see --zoom) so the panel fills exactly one screen
- instead of overflowing the bottom (which hid the detail pane's DDL). */
+ viewport sizing by --vp-zoom (see --zoom/--vp-zoom) so the panel fills exactly
+ one screen instead of overflowing the bottom (which hid the detail pane's DDL). */
body.schema-tab .graph-overlay-panel {
- width: calc(100vw / var(--zoom)); height: calc(100vh / var(--zoom));
+ width: calc(100vw / var(--vp-zoom)); height: calc(100vh / var(--vp-zoom));
border: none; border-radius: 0;
}
diff --git a/src/ui/app.js b/src/ui/app.js
index fab5ccb..7fb7f96 100644
--- a/src/ui/app.js
+++ b/src/ui/app.js
@@ -21,6 +21,7 @@ import { newResult, applyStreamLine, parseErrorPos } from '../core/stream.js';
import { encodeShare } from '../core/share.js';
import { assembleReferenceData, buildCompletions } from '../core/completions.js';
import { generatePKCE, randomState } from '../core/pkce.js';
+import { viewportZoom } from '../core/zoom-support.js';
import * as oauthCfg from '../net/oauth-config.js';
import * as oauth from '../net/oauth.js';
import * as ch from '../net/ch-client.js';
@@ -372,6 +373,30 @@ export function createApp(env = {}) {
);
}
app.updateBanner = updateBanner;
+ // Measure the engine's viewport-unit overshoot under html{zoom} (#70): a
+ // `height:100vh` probe against the `height:100%`-sized #root (reliably one
+ // screen on every engine). Returns the divisor (~--zoom on Chromium, ~1 on
+ // WebKit/Safari) or null when there's no layout to measure (happy-dom).
+ // Injected so the controller stays testable without a real layout engine.
+ app.measureViewportZoom = env.measureViewportZoom || (() => {
+ const probe = doc.createElement('div');
+ probe.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:100vh;visibility:hidden;pointer-events:none';
+ doc.body.appendChild(probe);
+ const vp = viewportZoom(probe.getBoundingClientRect().height, app.root.getBoundingClientRect().height);
+ probe.remove();
+ return vp;
+ });
+ // Publish the measured divisor as --vp-zoom, which the fullscreen graph panels
+ // divide their vw/vh sizing by. Leaves the CSS default (--vp-zoom: var(--zoom),
+ // the Chromium-correct value) untouched when unmeasurable, so behavior never
+ // regresses. app.vpZoom is mirrored onto the schema graph's child tab.
+ function applyViewportZoom() {
+ const vp = app.measureViewportZoom();
+ if (vp == null) return;
+ app.vpZoom = vp;
+ doc.documentElement.style.setProperty('--vp-zoom', String(vp));
+ }
+ app.applyViewportZoom = applyViewportZoom;
async function loadColumns(db, table, tableObj) {
tableObj.columns = 'loading';
renderSchema(app);
@@ -980,6 +1005,10 @@ export function renderApp(app, helpers) {
app.state.libraryDirty.value;
renderLibraryTitle(app);
});
+ // The shell is mounted (and laid out in a real engine), so the viewport-unit
+ // overshoot is measurable now — publish --vp-zoom before any fullscreen graph
+ // panel can open, so it sizes correctly on this engine (#70).
+ app.applyViewportZoom();
app.loadVersion();
app.loadSchema();
app.loadReference();
diff --git a/src/ui/explain-graph.js b/src/ui/explain-graph.js
index df8abd2..8b73a30 100644
--- a/src/ui/explain-graph.js
+++ b/src/ui/explain-graph.js
@@ -378,12 +378,17 @@ function zoomControls(pz) {
}
// Copy the theme/density data-attributes onto the child tab's so its
-// CSS custom properties resolve to the same colours as the main window.
+// CSS custom properties resolve to the same colours as the main window. Also
+// carry the opener's measured --vp-zoom (the per-engine viewport-unit divisor,
+// #70) so the tab's fullscreen panel sizes correctly; if the opener never
+// measured it, the tab keeps the CSS default (--vp-zoom: var(--zoom)).
function mirrorTheme(src, dst) {
for (const attr of ['data-theme', 'data-density']) {
const v = src.documentElement.getAttribute(attr);
if (v != null) dst.documentElement.setAttribute(attr, v);
}
+ const vp = src.documentElement.style.getPropertyValue('--vp-zoom');
+ if (vp) dst.documentElement.style.setProperty('--vp-zoom', vp);
}
// Headline title for a focus: "default" (whole-DB) or "default.events" (table).
diff --git a/src/ui/icons.js b/src/ui/icons.js
index b16bdbb..5a0fd40 100644
--- a/src/ui/icons.js
+++ b/src/ui/icons.js
@@ -76,8 +76,13 @@ export const Icon = {
plan: () => iconEl('