// Dashboard layout evidence (2026-10-08): scratch guest 9202, headless Chrome inside 9202, through traefik with the real // host name. Session cookie from a 0600 file (never printed). Per page and width: a picture, plus MEASUREMENTS: // sideways scroll, and on /stacks for each card whether the name box overlaps any tag box and the logo/name top offset. const puppeteer = require('puppeteer'); const fs = require('fs'); const sleep = ms => new Promise(r => setTimeout(r, ms)); const HOST = 'felhom.enkisfelhom.hu', BASE = 'https://' + HOST; const sess = fs.readFileSync('/out/session.txt', 'utf8').trim(); const TAG = process.env.TAG; // before | after const out = []; let LANG = 'hu'; function log(...a) { const l = a.join(' '); console.log(l); out.push(l); } async function setLang(p, lang) { await p.goto(BASE + '/launcher', {waitUntil: 'networkidle2'}); const btn = await p.$(`button.lang-globe-item[value="${lang}"]`); if (!btn) throw new Error('no globe item ' + lang); await p.evaluate(b => b.closest('form').submit(), btn); await p.waitForNavigation({waitUntil: 'networkidle2'}).catch(() => {}); log('language now', await p.$eval('html', e => e.lang)); } async function measure(p, name) { const m = await p.evaluate(() => { const r = {scrollW: document.documentElement.scrollWidth, innerW: window.innerWidth}; r.banner = Array.from(document.querySelectorAll('.alerts-container .alert-banner')).map(b => b.className.replace('alert-banner ', '') + ': ' + b.innerText.trim().slice(0, 70)); const btn = document.querySelector('.share-open-btn'); if (btn) { const b = btn.getBoundingClientRect(); r.share = {left: Math.round(b.left), right: Math.round(b.right), top: Math.round(b.top), text: btn.innerText.trim()}; } r.cards = []; for (const c of document.querySelectorAll('.stack-detail-card')) { const h = c.querySelector('h3'), logo = c.querySelector('.stack-logo-lg'); if (!h || !logo) continue; const rg = document.createRange(); rg.selectNodeContents(h); const tr = rg.getBoundingClientRect(), lr = logo.getBoundingClientRect(), cr = c.getBoundingClientRect(); const tags = Array.from(c.querySelectorAll('.tag')).map(t => t.getBoundingClientRect()); const overlap = tags.some(t => !(t.right <= tr.left || t.left >= tr.right || t.bottom <= tr.top || t.top >= tr.bottom)); const outside = tags.some(t => t.right > cr.right + 0.5); const addr = c.querySelector('.subdomain-link'); let addrLines = 0; if (addr) { const ar = document.createRange(); ar.selectNodeContents(addr); { const rs = Array.from(ar.getClientRects()).filter(x => x.width > 0); const t0 = Math.min(...rs.map(x => x.top)); const h0 = Math.min(...rs.map(x => x.height)); addrLines = 1 + rs.filter(x => x.top > t0 + h0 * 0.6).length; } } r.cards.push({name: h.innerText.trim(), tags: tags.length, nameUnderTag: overlap, tagOutsideCard: outside, logoTopMinusNameTop: Math.round(lr.top - tr.top), addr: addr ? addr.innerText.trim().replace(/\s+/g, ' ') : '', addrLines, addrTitle: addr ? addr.title : ''}); } return r; }); log(`${name}: layout width ${m.innerW} (screen ${process.env.W}) scrollWidth ${m.scrollW} — wider than the screen: ${m.innerW > +process.env.W || m.scrollW > +process.env.W}`); log(`${name}: banners ${JSON.stringify(m.banner)}`); if (m.share) log(`${name}: share button ${JSON.stringify(m.share)} past the screen edge: ${m.share.right > +process.env.W}`); if (m.cards.length) { const ov = m.cards.filter(c => c.nameUnderTag).map(c => c.name); const offs = [...new Set(m.cards.map(c => c.logoTopMinusNameTop))]; log(`${name}: ${m.cards.length} cards; name text under a tag on ${ov.length} ${JSON.stringify(ov)}; tag outside card on ${m.cards.filter(c => c.tagOutsideCard).length}; logo top minus name top ${JSON.stringify(offs)}; address on more than one line on ${m.cards.filter(c => c.addrLines > 1).length}`); for (const c of m.cards.filter(c => /paperless|filebrowser|immich/i.test(c.name))) log(`${name}: ${JSON.stringify(c)}`); } } async function shot(p, name, path) { await p.goto(BASE + path + (LANG === 'en' ? '?lang=en' : ''), {waitUntil: 'networkidle2'}); await sleep(1200); log(`${TAG}-${name}: html lang ${await p.$eval('html', e => e.lang)}, version ${await p.evaluate(() => (document.body.innerText.match(/\b0\.\d+\.\d+\S*/) || ['?'])[0])}`); await p.screenshot({path: `/out/${TAG}-${name}.png`}); await measure(p, `${TAG}-${name}`); } async function cards(p, name) { // the three cards the brief names, as one element picture each for (const [key, re] of [['installed', /paperless/i], ['notinstalled', /immich/i], ['builtin', /filebrowser|f.jlkezel/i]]) { const hs = await p.$$('.stack-detail-card'); for (const h of hs) { const t = await h.$eval('h3', e => e.innerText).catch(() => ''); if (re.test(t)) { await h.screenshot({path: `/out/${TAG}-${name}-card-${key}.png`}); break; } } } } (async () => { const b = await puppeteer.launch({args: ['--no-sandbox', '--ignore-certificate-errors', `--host-resolver-rules=MAP * ${process.env.TRAEFIK}`]}); const p = await b.newPage(); const [k, v] = sess.split('='); await p.setCookie({name: k, value: v, domain: HOST, path: '/', secure: true, httpOnly: true}); for (const lang of ['hu', 'en']) { process.env.W = '1440'; await p.setViewport({width: 1440, height: 900, deviceScaleFactor: 1}); LANG = lang; await shot(p, `launcher-${lang}-1440`, '/launcher'); await shot(p, `apps-${lang}-1440`, '/stacks'); await cards(p, `apps-${lang}-1440`); await shot(p, `backups-${lang}-1440`, '/backups'); process.env.W = '390'; await p.setViewport({width: 390, height: 844, deviceScaleFactor: 2, isMobile: true, hasTouch: true}); await shot(p, `launcher-${lang}-390`, '/launcher'); await shot(p, `apps-${lang}-390`, '/stacks'); await cards(p, `apps-${lang}-390`); await shot(p, `backups-${lang}-390`, '/backups'); } fs.writeFileSync(`/out/${TAG}-measure.txt`, out.join('\n') + '\n'); await b.close(); })().catch(e => { console.error(e); process.exit(1); });