diff --git a/package-lock.json b/package-lock.json index 3a7af74..d2d10ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "levkin.ca", "version": "0.3.0", "devDependencies": { + "playwright": "^1.60.0", "vite": "^6.3.5" } }, @@ -963,6 +964,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", diff --git a/package.json b/package.json index 1e28803..08c58e8 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "devDependencies": { + "playwright": "^1.60.0", "vite": "^6.3.5" } } diff --git a/scripts/debug-sticky.mjs b/scripts/debug-sticky.mjs new file mode 100644 index 0000000..9da2982 --- /dev/null +++ b/scripts/debug-sticky.mjs @@ -0,0 +1,34 @@ +import { chromium } from 'playwright'; + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); +await page.goto('http://localhost:5176/stack-folder/', { waitUntil: 'networkidle' }); + +for (const y of [0, 400, 800, 1200]) { + await page.evaluate((sy) => window.scrollTo(0, sy), y); + await page.waitForTimeout(150); + const data = await page.evaluate(() => { + return [...document.querySelectorAll('.folder')].map((f, i) => { + const cs = getComputedStyle(f); + const r = f.getBoundingClientRect(); + const body = f.querySelector('.body'); + const br = body.getBoundingClientRect(); + const sec = f.closest('.scroll-section'); + const sr = sec.getBoundingClientRect(); + return { + i, + folderTop: Math.round(r.top), + bodyTop: Math.round(br.top), + position: cs.position, + top: cs.top, + zIndex: f.style.zIndex || cs.zIndex, + sectionTop: Math.round(sr.top), + sectionBottom: Math.round(sr.bottom), + sectionH: Math.round(sr.height), + }; + }); + }); + console.log('scroll', y, JSON.stringify(data.filter((d) => d.sectionBottom > 0 && d.sectionTop < 800), null, 2)); +} + +await browser.close(); diff --git a/scripts/test-stack.mjs b/scripts/test-stack.mjs new file mode 100644 index 0000000..8b1d80a --- /dev/null +++ b/scripts/test-stack.mjs @@ -0,0 +1,91 @@ +import { chromium } from 'playwright'; +import { writeFileSync, mkdirSync } from 'fs'; +import { join } from 'path'; + +const BASE = process.env.BASE_URL || 'http://localhost:5176'; +const OUT = '/tmp/levkin-stack-test'; +mkdirSync(OUT, { recursive: true }); + +const pages = [ + { name: 'stack', path: '/stack/' }, + { name: 'stack-folder', path: '/stack-folder/' }, + { name: 'stack-trace', path: '/stack-trace/' }, + { name: 'stack-rack', path: '/stack-rack/' }, +]; + +const scrollY = [0, 400, 800, 1200, 1800, 2400]; + +async function analyze(page, label) { + return page.evaluate(() => { + const sections = [...document.querySelectorAll('.scroll-section')]; + const bodies = [...document.querySelectorAll('.body, .layer-inner, .frame-body, .unit-body')]; + const tabs = [...document.querySelectorAll('.tab, .frame-line, .unit-head')]; + const sticky = [...document.querySelectorAll('.tab, .body, .layer-inner, .frame-line, .frame-body, .unit-head, .unit-body')]; + const rects = (els) => els.map((el, i) => { + const r = el.getBoundingClientRect(); + const cs = getComputedStyle(el); + return { + i, + tag: el.className?.slice?.(0, 40) || el.tagName, + top: Math.round(r.top), + bottom: Math.round(r.bottom), + height: Math.round(r.height), + position: cs.position, + zIndex: cs.zIndex, + visible: r.height > 0 && r.bottom > 0 && r.top < innerHeight, + }; + }); + const visibleBodies = rects(bodies).filter((b) => b.visible && b.height > 80); + const mount = document.querySelector('.mount, .stack-mount'); + const mountH = mount ? Math.round(mount.getBoundingClientRect().height) : 0; + return { + scrollY: Math.round(window.scrollY), + pageH: Math.round(document.documentElement.scrollHeight), + viewport: innerHeight, + sections: sections.length, + visibleBodyCount: visibleBodies.length, + visibleBodies, + mountDocHeight: mountH, + firstSectionTop: sections[0] ? Math.round(sections[0].getBoundingClientRect().top) : null, + lastSectionBottom: sections.at(-1) ? Math.round(sections.at(-1).getBoundingClientRect().bottom) : null, + stickyPositions: rects(sticky.filter((el) => getComputedStyle(el).position === 'sticky')).slice(0, 14), + }; + }); +} + +const browser = await chromium.launch({ headless: true }); +const report = []; + +for (const { name, path } of pages) { + const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); + const url = BASE + path; + await page.goto(url, { waitUntil: 'networkidle' }); + const shots = []; + for (const y of scrollY) { + await page.evaluate((sy) => window.scrollTo(0, sy), y); + await page.waitForTimeout(200); + const data = await analyze(page, name); + const file = join(OUT, `${name}-scroll-${y}.png`); + await page.screenshot({ path: file, fullPage: false }); + shots.push({ y, file, ...data }); + } + report.push({ name, url, shots }); + await page.close(); +} + +await browser.close(); + +const summary = report.map((r) => { + const problems = r.shots.map((s) => { + const issues = []; + if (s.visibleBodyCount > 2) issues.push(`${s.visibleBodyCount} bodies visible (want ≤2)`); + if (s.scrollY === 0 && s.visibleBodyCount > 1) issues.push('top: multiple full bodies'); + if (s.pageH > s.viewport * 8) issues.push(`page very tall: ${s.pageH}px`); + return { y: s.y, issues, visibleBodyCount: s.visibleBodyCount, pageH: s.pageH }; + }); + return { variant: r.name, url: r.url, scrollChecks: problems }; +}); + +writeFileSync(join(OUT, 'report.json'), JSON.stringify({ report, summary }, null, 2)); +console.log(JSON.stringify(summary, null, 2)); +console.log('\nScreenshots:', OUT); diff --git a/shared/stack-layout.css b/shared/stack-layout.css index 0802803..49ef3cf 100644 --- a/shared/stack-layout.css +++ b/shared/stack-layout.css @@ -18,3 +18,18 @@ height: 2rem; margin-bottom: 3rem; } + +/* Only the active layer shows its body; tabs always visible */ +.scroll-section:not(.is-active) .body, +.scroll-section:not(.is-active) .layer-inner, +.scroll-section:not(.is-active) .frame-body, +.scroll-section:not(.is-active) .unit-body { + visibility: hidden; + height: 0; + min-height: 0; + margin-top: 0 !important; + padding: 0; + border: none; + overflow: hidden; + box-shadow: none; +} diff --git a/shared/stack-scroll.js b/shared/stack-scroll.js index da39179..5426fed 100644 --- a/shared/stack-scroll.js +++ b/shared/stack-scroll.js @@ -5,7 +5,7 @@ export function initStackScroll(options = {}) { depthEl = document.getElementById('depth'), depthPrefix = 'L', tabSelector = '[data-goto], .jump', - bodySelector = '.body, .layer-inner, .frame-body, .unit-body', + panelSelector = '.folder, .layer, .frame, .unit', } = options; const sections = document.querySelectorAll(sectionSelector); @@ -29,9 +29,11 @@ export function initStackScroll(options = {}) { sections.forEach((sec) => { const layer = Number(sec.dataset.layer); - const body = sec.querySelector(bodySelector); - if (!body) return; - body.style.zIndex = layer === active ? 100 : 10 + layer; + const isActive = layer === active; + sec.classList.toggle('is-active', isActive); + const panel = sec.querySelector(panelSelector); + if (!panel) return; + panel.style.zIndex = isActive ? 100 : 10 + layer; }); } diff --git a/shared/stack-vars.css b/shared/stack-vars.css index 566d4fa..6cebfc0 100644 --- a/shared/stack-vars.css +++ b/shared/stack-vars.css @@ -3,11 +3,10 @@ --stack-nav: 2.5rem; --stack-stick: 3rem; --stack-step: 2.75rem; - --stack-tab-h: 2.25rem; - /* Height of tab rail (all L0–L6 tabs visible) */ + --stack-tab-h: 2rem; --stack-reveal: calc(var(--stack-stick) + var(--stack-step) * 6 + var(--stack-tab-h)); --stack-slot: 100vh; --stack-slot-last: 50vh; --stack-pull: calc(var(--stack-slot) - var(--stack-reveal)); - --stack-body-h: calc(100dvh - var(--stack-reveal) - 1rem); + --stack-body-h: calc(100dvh - var(--stack-reveal) - 1.25rem); } diff --git a/spec/index.html b/spec/index.html index dc88e8e..ede0f08 100644 --- a/spec/index.html +++ b/spec/index.html @@ -77,7 +77,7 @@
| Status | ACTIVE |
|---|---|
| Entity | Levkin |
| Entity | Levkin Inc. |
| Domain | levkin.ca |
| Updated |