Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
24 changes: 18 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions src/core/zoom-support.js
Original file line number Diff line number Diff line change
@@ -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;
}
43 changes: 29 additions & 14 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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'] {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 <html>; 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;
}

Expand Down
29 changes: 29 additions & 0 deletions src/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
7 changes: 6 additions & 1 deletion src/ui/explain-graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -378,12 +378,17 @@ function zoomControls(pz) {
}

// Copy the theme/density data-attributes onto the child tab's <html> 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).
Expand Down
9 changes: 7 additions & 2 deletions src/ui/icons.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,13 @@ export const Icon = {
plan: () => iconEl('<path d="M2 2.6h8M4 5.5h6M4 8.4h4.5M2 5.5h.01M2 8.4h.01"/>', 12, 12, 1.4),
// Indexes view: a key.
key: () => iconEl('<circle cx="4" cy="4" r="2.4"/><path d="M5.7 5.7l4.3 4.3M8.3 8.3l1-1M9.3 9.3l1-1"/>', 12, 12, 1.3),
// Expand to fullscreen: corner arrows.
expand: () => iconEl('<path d="M2 4.5V2h2.5M9.5 2H12v2.5M12 7.5V10H9.5M4.5 10H2V7.5"/>', 12, 12, 1.4),
// Expand to fullscreen: four corner brackets, centred + symmetric in the 12-box
// (2.5 margins, 2.5-long legs). The old path was off-centre (bbox 10×8, touching
// the right edge) on half-pixel coords, so at ~12px each engine's stroke
// rasteriser snapped it differently — Chrome/Firefox blurred the corners into
// solid `[ ]` brackets while Safari kept them crisp. Centred + symmetric renders
// consistently across engines.
expand: () => iconEl('<path d="M2.5 5V2.5H5M7 2.5H9.5V5M9.5 7V9.5H7M5 9.5H2.5V7"/>', 12, 12, 1.4),
// Zoom-out bar (pairs with plus for zoom-in).
minus: () => svg('M2 6h8', 12, 12, { stroke: 1.6 }),
// Curved-arrow undo / redo (mirror images) for the schema node-move history.
Expand Down
39 changes: 39 additions & 0 deletions tests/e2e/zoom-support.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { test, expect } from '@playwright/test';

// End-to-end check of the html{zoom} viewport-unit fix (#70).
//
// The fullscreen graph panels size off vw/vh, but engines disagree on whether
// viewport units honor `zoom`. The app measures the actual overshoot at runtime
// (a 100vh probe vs the one-screen #root) and publishes it as --vp-zoom, which
// the panels divide by. This harness reproduces that mechanism and asserts the
// resulting panel fits exactly one screen (minus its 48px padding), rather than
// overflowing (no correction) or shrinking to ~83% (the Safari bug: dividing by
// --zoom when the engine's vh already tracked zoom).
//
// CAVEAT — this is a regression guard for the *mechanism*, not a Safari oracle.
// Playwright's WebKit applies `zoom` to getBoundingClientRect like Chromium
// (divisor ~1.2), whereas real Safari keeps rects in CSS px (divisor 1.0). So
// this passes on all three CI engines but does NOT exercise real Safari's
// viewport-unit behavior — that path is verified manually (see CHANGELOG / #71).
test.describe('fullscreen panel fits one screen under html{zoom}', () => {
test('--vp-zoom is published and the graph-overlay panel fits the viewport', async ({ page }) => {
await page.goto('/tests/e2e/zoom.html');
await page.waitForFunction(() => window.__ready === true);

const z = await page.evaluate(() => window.__zoom());

// The runtime measurement produced a usable divisor and published it.
expect(z.divisor).toBeGreaterThan(0);
expect(z.vpZoom).toBeCloseTo(z.divisor, 3);

// The panel fits within one screen (never overflows)…
expect(z.panelH).toBeLessThanOrEqual(z.screenH + 1);
expect(z.panelW).toBeLessThanOrEqual(z.screenW + 1);
// …and isn't shrunk by the zoom factor: the gap is just the ~48px padding
// (~5–6% of the screen), not the ~17% the pre-fix `/var(--zoom)` would lop off
// on a 1.0-divisor engine. A relative bound cleanly separates the two and is
// robust to the CI viewport size.
expect((z.screenH - z.panelH) / z.screenH).toBeLessThan(0.12);
expect((z.screenW - z.panelW) / z.screenW).toBeLessThan(0.12);
});
});
54 changes: 54 additions & 0 deletions tests/e2e/zoom.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>zoom harness</title>
<!-- The real stylesheet, so `html{zoom}`, the --vp-zoom default, and the
.graph-overlay-panel viewport math all apply exactly as shipped (#70). -->
<link rel="stylesheet" href="/src/styles.css" />
</head>
<body data-theme="dark">
<!-- #root inherits height:100% from styles.css → reliably one real screen on
every engine (the reference applyViewportZoom measures the overshoot against). -->
<div id="root"></div>
<!-- No bundled deps in this module graph (zoom-support.js imports nothing), so
the unbundled harness needs no import map. -->
<script type="module">
import { viewportZoom } from '/src/core/zoom-support.js';
const root = document.getElementById('root');

// Mirror src/ui/app.js applyViewportZoom: measure the viewport-unit overshoot
// (a 100vh probe vs the one-screen #root) and publish it as --vp-zoom.
function applyVp() {
const probe = document.createElement('div');
probe.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:100vh;visibility:hidden;pointer-events:none';
document.body.appendChild(probe);
const vp = viewportZoom(probe.getBoundingClientRect().height, root.getBoundingClientRect().height);
probe.remove();
if (vp != null) document.documentElement.style.setProperty('--vp-zoom', String(vp));
return vp;
}

window.__zoom = () => {
const divisor = applyVp();
// Build the real fullscreen panel and measure whether it fits one screen.
const overlay = document.createElement('div');
overlay.className = 'graph-overlay';
const panel = document.createElement('div');
panel.className = 'graph-overlay-panel';
overlay.appendChild(panel);
document.body.appendChild(overlay);
const pr = panel.getBoundingClientRect();
const rr = root.getBoundingClientRect();
const out = {
zoom: parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--zoom')),
vpZoom: parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--vp-zoom')),
divisor, screenH: rr.height, screenW: rr.width, panelH: pr.height, panelW: pr.width,
};
overlay.remove();
return out;
};
window.__ready = true;
</script>
</body>
</html>
Loading