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('', 12, 12, 1.4), // Indexes view: a key. key: () => iconEl('', 12, 12, 1.3), - // Expand to fullscreen: corner arrows. - expand: () => iconEl('', 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('', 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. diff --git a/tests/e2e/zoom-support.spec.js b/tests/e2e/zoom-support.spec.js new file mode 100644 index 0000000..ac5dbf1 --- /dev/null +++ b/tests/e2e/zoom-support.spec.js @@ -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); + }); +}); diff --git a/tests/e2e/zoom.html b/tests/e2e/zoom.html new file mode 100644 index 0000000..44d916a --- /dev/null +++ b/tests/e2e/zoom.html @@ -0,0 +1,54 @@ + + + + + zoom harness + + + + + +
+ + + + diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js index 13dcebb..b3c647e 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -61,7 +61,7 @@ function env(over = {}) { }; } -beforeEach(() => { document.body.innerHTML = ''; }); +beforeEach(() => { document.body.innerHTML = ''; document.documentElement.style.removeProperty('--vp-zoom'); }); describe('createApp basics', () => { it('reads the stored token and derives identity', () => { @@ -175,6 +175,31 @@ describe('renderApp shell', () => { }); }); +describe('applyViewportZoom — html{zoom} viewport-unit divisor (#70)', () => { + it('publishes the measured divisor as --vp-zoom on the document root', () => { + // Inject the measurement seam (real layout needs a browser; viewportZoom is + // unit-tested separately). 1 = the WebKit/Safari case the fix targets. + const app = createApp(env({ measureViewportZoom: () => 1 })); + app.renderApp(); + expect(app.vpZoom).toBe(1); + expect(document.documentElement.style.getPropertyValue('--vp-zoom')).toBe('1'); + }); + + it('leaves --vp-zoom (the CSS default) untouched when the layout is unmeasurable', () => { + // happy-dom has no layout, so the default seam's 100vh probe measures 0 → null. + const app = createApp(env()); + app.renderApp(); + expect(app.vpZoom).toBeUndefined(); + expect(document.documentElement.style.getPropertyValue('--vp-zoom')).toBe(''); + }); + + it('measures via a transient 100vh probe by default, leaving no probe behind', () => { + const app = createApp(env()); + app.renderApp(); // the default seam ran: appended its probe, measured, removed it + expect(document.querySelector('div[style*="100vh"]')).toBeNull(); + }); +}); + describe('loadVersion / loadSchema', () => { it('sets the version + online status', async () => { const e = env({ fetch: makeFetch([[(u, sql) => /version/.test(sql), resp({ json: { data: [{ v: '26.3.1' }] } })]]) }); diff --git a/tests/unit/explain-graph.test.js b/tests/unit/explain-graph.test.js index 5704a1a..2f48a3a 100644 --- a/tests/unit/explain-graph.test.js +++ b/tests/unit/explain-graph.test.js @@ -411,7 +411,7 @@ describe('schema lineage graph', () => { }); describe('openSchemaView — real browser tab', () => { - afterEach(() => { document.body.innerHTML = ''; }); + afterEach(() => { document.body.innerHTML = ''; document.documentElement.style.removeProperty('--vp-zoom'); }); const GRAPH = { focus: { kind: 'db', db: 'lin' }, nodes: [ @@ -443,14 +443,17 @@ describe('openSchemaView — real browser tab', () => { }); const stub = (canvas) => { canvas.getBoundingClientRect = () => ({ left: 0, top: 0, width: 400, height: 200, right: 400, bottom: 200 }); }; - it('builds the graph in the child document: copies CSS, mirrors theme, fills the tab', () => { + it('builds the graph in the child document: copies CSS, mirrors theme + --vp-zoom, fills the tab', () => { document.documentElement.setAttribute('data-theme', 'dark'); // data-density left unset → skipped + document.documentElement.style.setProperty('--vp-zoom', '1'); // opener measured the Safari case const win = makeWin(); const app = tabApp(win); const view = openSchemaView(app); expect(win.document.querySelector('style').textContent).toBe('body{color:red}'); expect(win.document.documentElement.getAttribute('data-theme')).toBe('dark'); expect(win.document.documentElement.getAttribute('data-density')).toBeNull(); + // the opener's measured viewport divisor carries onto the tab so its panel fits (#70) + expect(win.document.documentElement.style.getPropertyValue('--vp-zoom')).toBe('1'); expect(win.document.body.className).toBe('schema-tab'); expect(win.focus).toHaveBeenCalled(); // tab brought to front for key events const canvas = win.document.querySelector('.graph-overlay-canvas'); diff --git a/tests/unit/zoom-support.test.js b/tests/unit/zoom-support.test.js new file mode 100644 index 0000000..1b22865 --- /dev/null +++ b/tests/unit/zoom-support.test.js @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { viewportZoom } from '../../src/core/zoom-support.js'; + +describe('viewportZoom', () => { + it('returns the overshoot of a 100vh probe over the one-screen reference', () => { + // Chromium: 100vh is 1.2× one screen → divisor 1.2. + expect(viewportZoom(960, 800)).toBeCloseTo(1.2, 10); + // WebKit/Safari: 100vh == one screen → divisor 1. + expect(viewportZoom(800, 800)).toBe(1); + }); + it('returns null when either measurement is missing or degenerate', () => { + // happy-dom / no layout: rects are 0 → leave the CSS default in place. + expect(viewportZoom(0, 0)).toBeNull(); + expect(viewportZoom(800, 0)).toBeNull(); + expect(viewportZoom(0, 800)).toBeNull(); + expect(viewportZoom(NaN, 800)).toBeNull(); + expect(viewportZoom(800, NaN)).toBeNull(); + expect(viewportZoom(Infinity, 800)).toBeNull(); + expect(viewportZoom(-960, 800)).toBeNull(); + expect(viewportZoom(960, -800)).toBeNull(); + }); +});