CtrlK
BlogDocsLog inGet started
Tessl Logo

adobe/stardust

Redesign an existing website to make it better. Built on top of impeccable.

71

Quality

89%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Low

Low-risk findings worth noting

Overview
Quality
Evals
Security
Files

sibling-variance.mjsskills/replica/scripts/

#!/usr/bin/env node
/**
 * skills/replica/scripts/sibling-variance.mjs
 *
 * Live-variance probe for sibling fan-out: BEFORE cloning a gated archetype
 * onto its "same-template" siblings, measure the template-defining computed
 * values on each sibling's LIVE page and diff them against the archetype's.
 * Vendor templates are not constant: eight siblings of one gated archetype
 * varied in ways the crawl JSON never showed — a compact hero (441 vs 528px,
 * wider card, smaller logo), an INVERTED hero scrim (0.6→0.1 vs 0.1→0.5), list
 * bullets split into two visual families (arrow-image `::before` vs plain
 * disc), terms sections in three shapes. Each delta found here is first-class
 * work to budget — a block VARIANT class emitted by the sibling generator —
 * not an edge case discovered at the pixel gate. The probe is read-only
 * evidence; it changes nothing in the clone.
 * Content is not variance: image and background url() FILENAMES are ignored
 * (a sibling's own photo is the source's choice — replicate it); only the
 * layer stack, geometry and computed styles are compared.
 *
 * Usage:
 *   node skills/replica/scripts/sibling-variance.mjs <archetypeURL> <siblingURL> [<siblingURL>…] [options]
 *     --probe <name>=<sel>   template-defining element to compare (repeatable). For
 *                            each: match count, first match's rect + the computed
 *                            group (background layers, colour, padding, font
 *                            size/weight/line-height, radius), its first heading,
 *                            its first image, list-style + `::before` mechanism,
 *                            and — when the selector matches several elements —
 *                            the number of distinct style clusters among them.
 *                            Default when none given: hero = first section of
 *                            --main; card = the most repeated element class in
 *                            the page; li = "main li".
 *     --main <sel>           content root (default main) — also probes the
 *                            section list ([label, height] per top-level section)
 *     --width <px>           viewport width                    (default 1440)
 *     --tolerance <px>       ignore numeric deltas ≤ this      (default 2)
 *     --consent <sel>        extra consent-accept selector
 *     --dismiss <sel,…>      extra overlay-dismiss selectors
 *     --headed               headed stealth real Chrome (bot-managed sites)
 *     --locale <tag>         pin Accept-Language + locale
 *     --json                 machine-readable output
 *
 * Exit codes: 0 every sibling matches the archetype within tolerance, 2 variance
 * found (budget it), 1 error, 3 bot challenge (fail loud — never measured).
 * Every URL is one live navigation: probe a template's siblings in ONE run and
 * keep the JSON as the fan-out brief's evidence.
 */

/* eslint-disable import/no-extraneous-dependencies, import/extensions, no-await-in-loop, no-restricted-syntax, brace-style, object-curly-newline, max-len, no-plusplus, no-continue */
import { chromium } from 'playwright';
import { existsSync } from 'fs';
import { dirname, resolve as resolvePath } from 'path';
import { fileURLToPath, pathToFileURL } from 'url';

const HERE = dirname(fileURLToPath(import.meta.url));
const LIVE_SESSION = ['../../diff/scripts/live-session.mjs', '../diff/live-session.mjs']
  .map((p) => resolvePath(HERE, p)).find((p) => existsSync(p));
if (!LIVE_SESSION) {
  console.error('sibling-variance error: live-session.mjs not found (looked in ../../diff/scripts/ and ../diff/). Copy the diff skill\'s scripts dir alongside this one (replica SKILL.md § Setup).');
  process.exit(1);
}
const { isLiveHttpUrl, launchStealthHeaded, newLiveContext, gotoLive, dismissOverlays, defaultWaitUntil } = await import(pathToFileURL(LIVE_SESSION).href);

const HELP = `sibling-variance — diff template-defining computed values of live siblings against the archetype

Usage: node sibling-variance.mjs <archetypeURL> <siblingURL> [<siblingURL>…] [options]
  --probe <name>=<sel>  template-defining element (repeatable; defaults: hero, card, li)
  --main <sel>          content root (default main)
  --width <px>          viewport width (default 1440)
  --tolerance <px>      ignore numeric deltas ≤ this (default 2)
  --consent <sel>       extra consent-accept selector
  --dismiss <sel,…>     extra overlay-dismiss selectors
  --headed              headed stealth real Chrome
  --locale <tag>        pin Accept-Language + locale
  --json                machine-readable output
  --help                this text

Exit codes: 0 no variance, 2 variance found, 1 error, 3 bot challenge.`;

function parseArgs(argv) {
  const rest = argv.slice(2);
  if (rest.includes('--help') || rest.includes('-h')) { console.log(HELP); process.exit(0); }
  const pos = [];
  const opts = { probes: [], main: 'main', width: 1440, tolerance: 2, consent: null, dismiss: [], headed: false, locale: null, json: false };
  for (let i = 0; i < rest.length; i += 1) {
    const a = rest[i];
    if (a === '--probe') {
      const spec = rest[i += 1] || '';
      const m = spec.match(/^([\w-]+)=(.+)$/);
      if (!m) { console.error(`bad --probe "${spec}" — expected name=<selector>\n\n${HELP}`); process.exit(1); }
      opts.probes.push({ name: m[1], sel: m[2].trim() });
    }
    else if (a === '--main') { opts.main = rest[i += 1]; }
    else if (a === '--width') { opts.width = Number(rest[i += 1]); }
    else if (a === '--tolerance') { opts.tolerance = Number(rest[i += 1]); }
    else if (a === '--consent') { opts.consent = rest[i += 1]; }
    else if (a === '--dismiss') { opts.dismiss = (rest[i += 1] || '').split(',').map((s) => s.trim()).filter(Boolean); }
    else if (a === '--headed') { opts.headed = true; }
    else if (a === '--locale') { opts.locale = rest[i += 1]; }
    else if (a === '--json') { opts.json = true; }
    else if (a.startsWith('--')) { console.error(`unknown flag ${a}\n\n${HELP}`); process.exit(1); }
    else pos.push(a);
  }
  if (pos.length < 2) { console.error(`need <archetypeURL> and at least one <siblingURL>\n\n${HELP}`); process.exit(1); }
  return { archetype: pos[0], siblings: pos.slice(1), opts };
}

// ---------------------------------------------------------------- in-page probe

/* eslint-disable no-undef */
function probePage({ probes, main }) {
  const root = document.querySelector(main) || document.body;
  const rect = (el) => { const r = el.getBoundingClientRect(); return { w: Math.round(r.width), h: Math.round(r.height) }; };
  const visible = (el) => el.getClientRects().length > 0;
  const norm = (s) => (s || '').replace(/\s+/g, ' ').trim();
  const fam = (cs) => (cs.fontFamily || '').split(',')[0].replace(/["']/g, '').trim().toLowerCase();
  // url() filenames are CONTENT (a sibling's own photo — replicate, don't
  // "fix"), never template: layers keep gradients verbatim and reduce every
  // url(...) to a bare `url(…)` marker so only the LAYER STACK is compared.
  const layers = (cs) => (cs.backgroundImage === 'none' ? [] : cs.backgroundImage.split(/,(?![^(]*\))/).map((l) => l.trim().replace(/url\([^)]*\)/g, 'url(…)')));

  // default probes
  const sections = [...root.querySelectorAll(':scope > section, :scope > .section')].filter(visible);
  const firstSection = sections[0] || root.firstElementChild;
  const classCounts = {};
  for (const el of root.querySelectorAll('[class]')) {
    if (!visible(el) || (el.children.length === 0 && !norm(el.textContent))) continue;
    if (['LI', 'A', 'SPAN', 'P', 'IMG', 'SVG', 'PATH', 'BUTTON'].includes(el.tagName)) continue; // atoms, not units
    const c = String(el.className).trim().split(/\s+/)[0];
    if (c) classCounts[c] = (classCounts[c] || 0) + 1;
  }
  const cardClass = Object.entries(classCounts).filter(([, n]) => n >= 3).sort((a, b) => b[1] - a[1])[0]?.[0] || null;
  const list = probes.length ? probes : [
    { name: 'hero', el: firstSection, sel: '(first section)' },
    ...(cardClass ? [{ name: 'card', sel: `.${cardClass}` }] : []),
    { name: 'li', sel: `${main} li` },
  ];

  const signature = (el) => {
    const cs = getComputedStyle(el);
    const h = el.querySelector('h1, h2, h3, h4');
    const img = el.querySelector('img');
    const before = getComputedStyle(el, '::before');
    const hcs = h ? getComputedStyle(h) : null;
    return {
      rect: rect(el),
      backgroundLayers: layers(cs),
      backgroundColor: cs.backgroundColor,
      color: cs.color,
      padding: [cs.paddingTop, cs.paddingRight, cs.paddingBottom, cs.paddingLeft].join(' '),
      font: `${fam(cs)} ${cs.fontSize}/${cs.lineHeight} ${cs.fontWeight}`,
      borderRadius: cs.borderRadius,
      heading: h ? `${h.tagName.toLowerCase()} ${fam(hcs)} ${hcs.fontSize}/${hcs.lineHeight} ${hcs.fontWeight} ${hcs.color}` : null,
      image: img ? rect(img) : null, // geometry only — the src is content
      listStyle: cs.listStyleType !== 'none' || el.tagName === 'LI' ? cs.listStyleType : null,
      before: (before.content && before.content !== 'none' && before.content !== 'normal') || before.backgroundImage !== 'none'
        ? `content:${before.content.replace(/url\([^)]*\)/g, 'url(…)')} bg:${layers(before).join('|') || 'none'} ${Math.round(parseFloat(before.width) || 0)}x${Math.round(parseFloat(before.height) || 0)}`
        : null,
    };
  };
  const clusterKey = (s) => JSON.stringify({ ...s, rect: undefined, heading: s.heading ? s.heading.replace(/^h\d /, '') : null });

  const out = {
    sections: sections.map((el, i) => ({ label: String(el.className || '').split(/\s+/).filter((c) => c && c !== 'section').slice(0, 2).join('.') || `${el.tagName.toLowerCase()}[${i}]`, h: rect(el).h })),
    probes: {},
  };
  for (const p of list) {
    const els = p.el ? [p.el] : [...document.querySelectorAll(p.sel)].filter(visible);
    if (!els.length) { out.probes[p.name] = { sel: p.sel, count: 0 }; continue; }
    const sigs = els.slice(0, 40).map(signature);
    const clusters = new Set(sigs.map(clusterKey)).size;
    out.probes[p.name] = { sel: p.sel, count: els.length, first: sigs[0], clusters, text: norm(els[0].textContent).slice(0, 60) };
  }
  return out;
}
/* eslint-enable no-undef */

// ------------------------------------------------------------------- diffing

const px = (v) => { const m = String(v).match(/^-?[\d.]+(?=px$)/); return m ? parseFloat(m[0]) : null; };

function diffValue(k, a, b, tol) {
  if (JSON.stringify(a) === JSON.stringify(b)) return null;
  if (a && b && typeof a === 'object' && !Array.isArray(a)) {
    const d = Object.keys({ ...a, ...b }).map((kk) => diffValue(kk, a[kk], b[kk], tol)).filter(Boolean);
    return d.length ? `${k}{${d.join(', ')}}` : null;
  }
  const pa = px(a); const pb = px(b);
  if (pa !== null && pb !== null && Math.abs(pa - pb) <= tol) return null;
  if (typeof a === 'number' && typeof b === 'number' && Math.abs(a - b) <= tol) return null;
  // padding / font strings: compare token-wise with tolerance
  if (typeof a === 'string' && typeof b === 'string' && a.split(' ').length === b.split(' ').length && a.split(' ').length > 1) {
    const ta = a.split(' '); const tb = b.split(' ');
    const diffTok = ta.some((t, i) => { const x = px(t); const y = px(tb[i]); return x !== null && y !== null ? Math.abs(x - y) > tol : t !== tb[i]; });
    if (!diffTok) return null;
  }
  const show = (v) => (Array.isArray(v) ? `[${v.join(' | ')}]` : String(v));
  return `${k} ${show(a)} → ${show(b)}`;
}

function compare(arch, sib, tol) {
  const findings = [];
  const sa = arch.sections.map((s) => s.label).join(' > '); const sb = sib.sections.map((s) => s.label).join(' > ');
  if (arch.sections.length !== sib.sections.length) findings.push({ kind: 'SECTIONS', msg: `${arch.sections.length} vs ${sib.sections.length} top-level sections (archetype: ${sa || '-'}; sibling: ${sb || '-'}) — a different template family or an extra/missing section` });
  else {
    const hd = arch.sections.map((s, i) => (Math.abs(s.h - sib.sections[i].h) > tol ? `${s.label} ${s.h}→${sib.sections[i].h}` : null)).filter(Boolean);
    if (hd.length) findings.push({ kind: 'SECTION HEIGHTS', msg: hd.join(', ') });
  }
  for (const [name, A] of Object.entries(arch.probes)) {
    const B = sib.probes[name];
    if (!B || !B.count) { if (A.count) findings.push({ kind: 'MISSING', probe: name, msg: `"${A.sel}" matches ${A.count} on the archetype, 0 on the sibling` }); continue; }
    if (!A.count) { findings.push({ kind: 'EXTRA', probe: name, msg: `"${B.sel}" matches 0 on the archetype, ${B.count} on the sibling` }); continue; }
    if (A.count !== B.count) findings.push({ kind: 'COUNT', probe: name, msg: `${A.count} → ${B.count} matches` });
    if (A.clusters !== B.clusters) findings.push({ kind: 'CLUSTERS', probe: name, msg: `${A.clusters} → ${B.clusters} distinct style families among matches (a second visual family = a variant class)` });
    const d = Object.keys(A.first).map((k) => diffValue(k, A.first[k], B.first[k], tol)).filter(Boolean);
    if (d.length) findings.push({ kind: 'PROBE', probe: name, msg: d.join('; ') });
  }
  return findings;
}

// ---------------------------------------------------------------------- main

async function probeUrl(browser, url, opts) {
  const ctx = await newLiveContext(browser, { locale: opts.locale, viewport: { width: opts.width, height: 900 } });
  const page = await ctx.newPage();
  await gotoLive(page, url, { waitUntil: defaultWaitUntil(url), settleMs: isLiveHttpUrl(url) ? 2500 : 1200, solveWindow: opts.headed });
  await dismissOverlays(page, { extra: [...(opts.consent ? [opts.consent] : []), ...opts.dismiss], lateWindowMs: isLiveHttpUrl(url) ? 6000 : 0 });
  await page.evaluate(async () => {
    const h = document.documentElement.scrollHeight;
    for (let y = 0; y < h; y += 700) { window.scrollTo(0, y); await new Promise((r) => { setTimeout(r, 80); }); }
    window.scrollTo(0, 0);
    await new Promise((r) => { setTimeout(r, 400); });
  });
  await page.waitForTimeout(500);
  const out = await page.evaluate(probePage, { probes: opts.probes, main: opts.main });
  await ctx.close();
  return out;
}

async function main() {
  const { archetype, siblings, opts } = parseArgs(process.argv);
  const browser = opts.headed ? await launchStealthHeaded(chromium) : await chromium.launch();
  let varying = 0;
  try {
    const A = await probeUrl(browser, archetype, opts);
    const results = [];
    for (const url of siblings) {
      const S = await probeUrl(browser, url, opts);
      const findings = compare(A, S, opts.tolerance);
      if (findings.length) varying += 1;
      results.push({ url, findings, raw: S });
    }
    const probesVarying = [...new Set(results.flatMap((r) => r.findings.map((f) => f.probe || f.kind)))];
    if (opts.json) {
      console.log(JSON.stringify({ archetype, width: opts.width, tolerance: opts.tolerance, probes: Object.fromEntries(Object.entries(A.probes).map(([k, v]) => [k, v.sel])), archetypeRaw: A, siblings: results.map(({ url, findings, raw }) => ({ url, findings, raw })), summary: { siblings: siblings.length, varying, probesVarying } }, null, 2));
    } else {
      console.log(`sibling-variance @ ${opts.width}px, tolerance ${opts.tolerance}px\n  archetype: ${archetype}\n  probes: ${Object.entries(A.probes).map(([k, v]) => `${k}=${v.sel} (${v.count})`).join(', ')}\n  sections: ${A.sections.map((s) => `${s.label}:${s.h}`).join(' > ') || '-'}`);
      for (const r of results) {
        console.log(`\n■ ${r.url}: ${r.findings.length ? `${r.findings.length} delta(s)` : '✓ matches the archetype'}`);
        for (const f of r.findings) console.log(`  ${f.kind.padEnd(16)}${f.probe ? `${f.probe}: ` : ''}${f.msg}`);
      }
      console.log(`\n${varying ? `✗ ${varying} of ${siblings.length} sibling(s) vary from the archetype in: ${probesVarying.join(', ')} — budget variant classes for these before cloning; do not assume template constancy.` : `✓ ${siblings.length} sibling(s) match the archetype within tolerance — clone.`}`);
    }
  } finally {
    await browser.close();
  }
  process.exit(varying ? 2 : 0);
}

main().catch((e) => { console.error(`sibling-variance error: ${e.message}`); process.exit(e.name === 'BotChallengeError' ? 3 : 1); });

skills

README.md

tile.json