Ctrl K
ComunidadGuías…

scripts/readme/capture.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Step 1: capture the console crops the README images are built from, at 2x, in light and dark, from the REAL
// built SPA (web/dist) answered by the screenshot harness's fixtures (scripts/shots/harness.mjs).
//
// Output (.cache/shots/): screen-overview-<theme>.png (1440x900 viewport) and one PNG per card, clipped to its
// DOM box: attention, sync, enroll, remote, rollout, cfg (Security card after three real edits), cfgbar (the
// sticky "3 unsaved changes · Save and apply" bar). Card sizes in CSS px go to .cache/shots/cards.json.
// The sample versions are overridden for the README (versions.mjs); a page that still shows an old one fails.
import fs from 'node:fs';
import path from 'node:path';
import { fx, newContext, startServer, DIST } from '../shots/harness.mjs';
import { SHOTS, THEMES, launch, closeBrowser } from './common.mjs';
import { applyReadmeVersions, STALE } from './versions.mjs';

applyReadmeVersions(fx); // README-only sample versions (see versions.mjs)

if (!fs.existsSync(path.join(DIST, 'index.html'))) {
  console.error('web/dist not found: run `npm run build` in web/ first (or ./generate.sh --build).');
  process.exit(1);
}

const cards = {};

async function open(browser, theme, route, height) {
  const ctx = await newContext(browser, fx.user, { theme, viewport: { width: 1440, height }, deviceScaleFactor: 2, blockGoogleFonts: true });
  const page = await ctx.newPage();
  const errors = [];
  page.on('pageerror', (e) => errors.push(e.message));
  await page.goto(base + route, { waitUntil: 'domcontentloaded' });
  await page.waitForSelector('.v2-shell', { timeout: 15000 });
  await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
  await page.evaluate(() => document.fonts.ready);
  await page.waitForTimeout(1000);
  if (errors.length) throw new Error(`${route}: ${errors.join('; ')}`);
  const stale = (await page.evaluate(() => document.body.innerText)).match(STALE);
  if (stale) throw new Error(`${route}: shows the fixtures' own version ${stale[0]} (extend versions.mjs)`);
  return { ctx, page };
}

/** Page-coordinate box of the card (rounded, bordered ancestor) that holds the heading with exactly `title`. */
function cardBox(page, title) {
  return page.evaluate((title) => {
    const heading = [...document.querySelectorAll('h1,h2,h3,h4')].find((e) => e.textContent.trim() === title);
    let el = heading;
    while (el && el !== document.body) {
      const cs = getComputedStyle(el);
      const r = el.getBoundingClientRect();
      if (parseFloat(cs.borderTopLeftRadius) >= 8 && r.width > 300 && cs.borderTopWidth !== '0px') {
        return { x: r.x + scrollX, y: r.y + scrollY, width: r.width, height: r.height };
      }
      el = el.parentElement;
    }
    return null;
  }, title);
}

async function clip(page, name, theme, box) {
  if (!box) throw new Error(`${name}: card not found`);
  await page.screenshot({ path: path.join(SHOTS, `${name}-${theme}.png`), clip: box });
  cards[name] = { w: box.width, h: box.height };
}

/** Click the segmented option `option` in the setting row titled `title` (a real UI edit). */
function pick(page, title, option) {
  return page.evaluate(([title, option]) => {
    const label = [...document.querySelectorAll('body *')].find((e) => e.children.length === 0 && e.textContent.trim() === title);
    let row = label;
    for (let i = 0; i < 8 && row; i++) {
      row = row.parentElement;
      const b = [...row.querySelectorAll('button,[role=radio],label')].find((x) => x.textContent.trim() === option);
      if (b) { b.click(); return true; }
    }
    return false;
  }, [title, option]);
}

const { server, base } = await startServer();
const browser = await launch();
try {
  for (const theme of THEMES) {
    {
      const { ctx, page } = await open(browser, theme, '/', 900);
      await page.screenshot({ path: path.join(SHOTS, `screen-overview-${theme}.png`) });
      await ctx.close();
    }
    {
      const { ctx, page } = await open(browser, theme, '/', 1320);
      await clip(page, 'attention', theme, await cardBox(page, 'Needs attention'));
      await clip(page, 'sync', theme, await cardBox(page, 'By configuration'));
      await ctx.close();
    }
    {
      const { ctx, page } = await open(browser, theme, '/devices/WH-SCAN-07', 1300);
      await clip(page, 'remote', theme, await cardBox(page, 'Remote actions'));
      await ctx.close();
    }
    {
      // The enroll card down to its QR buttons and steps (leaving out the debug-build notice and the server URLs).
      const { ctx, page } = await open(browser, theme, '/enroll', 1120);
      const card = await cardBox(page, 'Enroll the device');
      const bottom = await page.evaluate(() => {
        const btn = [...document.querySelectorAll('button')].find((b) => b.textContent.trim() === 'Full screen');
        const steps = [...document.querySelectorAll('ol,ul')].find((l) => /Wait a few minutes/.test(l.textContent));
        return Math.max(...[btn, steps].filter(Boolean).map((e) => e.getBoundingClientRect().bottom + scrollY));
      });
      await clip(page, 'enroll', theme, { ...card, height: bottom - card.y + 12 });
      await ctx.close();
    }
    {
      const { ctx, page } = await open(browser, theme, '/settings', 1800);
      const box = await page.evaluate(() => {
        const r = document.querySelector('#agent-rollout').getBoundingClientRect();
        return { x: r.x + scrollX, y: r.y + scrollY, width: r.width, height: r.height };
      });
      await clip(page, 'rollout', theme, box);
      await ctx.close();
    }
    {
      // Default's Security card after three real edits, and the sticky unsaved-changes bar they bring up.
      const { ctx, page } = await open(browser, theme, '/configurations/1', 1100);
      for (const [title, option] of [['Screenshots', 'Block'], ['Factory reset', 'Block'], ['Safe Mode', 'Block']]) {
        if (!(await pick(page, title, option))) throw new Error(`config editor: no ${option} for ${title}`);
        await page.waitForTimeout(120);
      }
      await page.waitForTimeout(400);
      const sec = await cardBox(page, 'Security and restrictions');
      await page.evaluate((y) => window.scrollTo(0, y), sec.y + sec.height - 980);
      await page.waitForTimeout(500);
      const geo = await page.evaluate(() => {
        const box = (el) => { const r = el.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height }; };
        const up = (el, ok) => { while (el && !ok(el)) el = el.parentElement; return el; };
        const rounded = (min) => (el) => parseFloat(getComputedStyle(el).borderTopLeftRadius) >= 8 && el.getBoundingClientRect().width > min;
        const h = [...document.querySelectorAll('h2,h3')].find((e) => e.textContent.trim() === 'Security and restrictions');
        const save = [...document.querySelectorAll('button')].find((b) => b.textContent.trim() === 'Save and apply');
        return { card: box(up(h, rounded(300))), bar: box(up(save, rounded(500))), barText: up(save, rounded(500)).innerText };
      });
      if (!/3 unsaved changes/.test(geo.barText)) throw new Error(`config editor: unexpected bar "${geo.barText}"`);
      await clip(page, 'cfg', theme, geo.card);
      await clip(page, 'cfgbar', theme, geo.bar);
      await ctx.close();
    }
    console.log('captured', theme);
  }
} finally {
  await closeBrowser();
  server.close();
}
fs.writeFileSync(path.join(SHOTS, 'cards.json'), JSON.stringify(cards, null, 1));
console.log('cards', JSON.stringify(cards));