diff --git a/.github/workflows/validate-build.yml b/.github/workflows/validate-build.yml index f07e83a9..7ff38012 100644 --- a/.github/workflows/validate-build.yml +++ b/.github/workflows/validate-build.yml @@ -91,6 +91,8 @@ jobs: run: npm run test:nav-lazy-buckets - name: Test code runtime gating run: npm run test:has-code-blocks + - name: Test landing-page heroes clip without trapping the first swipe + run: npm run test:hero-scroll - name: Test docs page weight (lazy images, trimmed Prism, on-demand highlighting, head) run: npm run test:docs-page-weight - name: Cache generated files diff --git a/package.json b/package.json index f5644b04..66ddba2f 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "test:lazy-askai": "node --test tests/lazy-askai/*.test.js", "test:nav-lazy-buckets": "node --test tests/nav-lazy-buckets/*.test.js", "test:has-code-blocks": "node --test tests/has-code-blocks/*.test.js", + "test:hero-scroll": "node --test tests/hero-scroll/*.test.js", "test:docs-page-weight": "node --test tests/docs-page-weight/*.test.js", "test:all": "npm run test:playground && npm run test:interactive && npm run test:negative-cache && npm run test:head-meta && npm run test:property-tooltips && npm run test:signin-nudge && npm run test:chat-panel-navigation && npm run test:toc-collapsible && npm run test:toc-active-anchor && npm run test:nav-scroll && npm run test:kapa-source-groups && npm run test:tooltip-touch && npm run test:tooltip-single-open && npm run test:lazy-askai && npm run test:nav-lazy-buckets && npm run test:has-code-blocks && npm run test:docs-page-weight && npm run test:anon-quota", "build:wasm": "cd blobl-editor/wasm && GOOS=js GOARCH=wasm go build -o ../../src/static/blobl.wasm .", diff --git a/src/css/data-platform.css b/src/css/data-platform.css index f0096a38..55db3df0 100644 --- a/src/css/data-platform.css +++ b/src/css/data-platform.css @@ -44,7 +44,12 @@ html[data-theme="dark"] .data-platform.article { /* ---- Hero Section ---- */ .dp-hero { position: relative; + + /* clip, not hidden: see .home-hero in home.css. An overflowing overflow:hidden + hero eats the first swipe on a phone because WebKit latches the touch to a + box the user can't scroll. */ overflow: hidden; + overflow: clip; background: linear-gradient(180deg, #0e1a33 0%, #1a2a52 100%); color: #fff; padding: 40px 56px 32px; diff --git a/src/css/home.css b/src/css/home.css index 43195da2..6e3521bb 100644 --- a/src/css/home.css +++ b/src/css/home.css @@ -20,7 +20,17 @@ main.article.home > .content { /* Dark Hero Section */ .home-hero { position: relative; + + /* The glows are bigger than the hero and sit partly outside it, so the hero + has to clip. It must not become a SCROLL CONTAINER while doing it: this + section is the whole first screen on a phone, and in WebKit a touch that + starts inside an overflow:hidden box with overflowing content is latched to + that box. Because the box can't be scrolled by the user, the swipe did + nothing and the home page looked frozen until you tapped first. clip does + the same clipping without a scroll container, so the swipe reaches the page. + hidden stays first as the fallback for Safari < 16, which ignores clip. */ overflow: hidden; + overflow: clip; background: #0b1430; color: #fff; padding: 80px 40px 72px; diff --git a/src/css/labs-home.css b/src/css/labs-home.css index c48899ed..f7fa7970 100644 --- a/src/css/labs-home.css +++ b/src/css/labs-home.css @@ -25,7 +25,12 @@ padding: 72px 5vw 56px; text-align: center; background: linear-gradient(180deg, #0b1430 0%, #10173a 100%); + + /* clip, not hidden: see .home-hero in home.css. An overflowing overflow:hidden + hero eats the first swipe on a phone because WebKit latches the touch to a + box the user can't scroll. */ overflow: hidden; + overflow: clip; } .labs-hero-bg { diff --git a/tests/hero-scroll/hero-scroll.test.js b/tests/hero-scroll/hero-scroll.test.js new file mode 100644 index 00000000..ed622087 --- /dev/null +++ b/tests/hero-scroll/hero-scroll.test.js @@ -0,0 +1,82 @@ +/** + * A full-bleed hero clips decorative glows that are deliberately bigger than + * the hero box. Clipping with `overflow: hidden` makes the hero a scroll + * container, and in WebKit a touch that starts inside a scroll container with + * overflowing content is latched to that container: because a hidden box can't + * be scrolled by the user, the swipe does nothing and never reaches the page. + * The hero IS the first screen on a phone, so the landing page looked frozen on + * load until you tapped somewhere first. `overflow: clip` clips identically + * without creating a scroll container. + * + * These are source assertions on purpose: the failure only shows up with real + * touch input on a real WebKit build, which no headless runner here can send. + * What is checkable, and what regressed, is the declaration itself. + */ +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const test = require('node:test') + +const ROOT = path.join(__dirname, '../..') + +// Every hero that paints decorative layers outside its own box and therefore +// has to clip. .ch3-hero (component-home-v3.css) is absent by design: it clips +// in a separate .ch3-hero-bg layer that is pointer-events: none, so no touch +// ever lands in it. +const HEROES = [ + ['src/css/home.css', '.home-hero'], + ['src/css/data-platform.css', '.dp-hero'], + ['src/css/labs-home.css', '.labs-hero'], +] + +const read = (file) => fs.readFileSync(path.join(ROOT, file), 'utf8') +const stripComments = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '') + +// The declarations of the first rule whose selector list contains exactly this +// selector. Naive on purpose: a selector chunk keeps only what follows the last +// brace, which drops any enclosing at-rule prelude. +function ruleBody (css, selector) { + const chunks = stripComments(css).split('}') + for (const chunk of chunks) { + const brace = chunk.lastIndexOf('{') + if (brace < 0) continue + const selectors = chunk.slice(0, brace).split('{').pop().split(',').map((s) => s.trim()) + if (selectors.includes(selector)) return chunk.slice(brace + 1) + } + return null +} + +const overflowValues = (body) => + [...body.matchAll(/(?:^|;)\s*overflow\s*:\s*([^;]+)/g)].map((m) => m[1].trim()) + +for (const [file, selector] of HEROES) { + test(`${selector} clips without becoming a scroll container`, () => { + const body = ruleBody(read(file), selector) + assert.ok(body, `${selector} has a rule in ${file}`) + const values = overflowValues(body) + assert.ok(values.length, `${selector} sets overflow`) + assert.equal(values.at(-1), 'clip', `${selector} ends on overflow: clip, not ${values.at(-1)}`) + assert.equal(values.at(-2), 'hidden', `${selector} keeps overflow: hidden ahead of clip for Safari < 16`) + }) +} + +test('no landing-page hero goes back to clipping with overflow: hidden alone', () => { + const files = ['src/css/home.css', 'src/css/data-platform.css', 'src/css/labs-home.css', 'src/css/component-home-v3.css'] + const offenders = [] + for (const file of files) { + const chunks = stripComments(read(file)).split('}') + for (const chunk of chunks) { + const brace = chunk.lastIndexOf('{') + if (brace < 0) continue + const selectors = chunk.slice(0, brace).split('{').pop().split(',').map((s) => s.trim()) + // A hero box itself, not its inner layers (-hero-bg, -hero-inner, ...). + const heroBox = selectors.some((s) => /(^|[\s>])[.#][a-z0-9-]*-hero$/.test(s)) + if (!heroBox) continue + const values = overflowValues(chunk.slice(brace + 1)) + // visible and clip are both fine: neither makes a scroll container. + const last = values.at(-1) + if (/^(hidden|auto|scroll)\b/.test(last || '')) offenders.push(`${file}: ${selectors.join(', ')} -> overflow: ${last}`) + } + } + assert.deepEqual(offenders, [], 'heroes clip with overflow: clip so a first swipe still scrolls the page') +})