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

content-inventory.mjsskills/deploy/scripts/

/**
 * skills/deploy/scripts/content-inventory.mjs
 *
 * The SHARED content-inventory engine: one role classifier + one inventory
 * differ, consumed by every gate that measures content fidelity —
 *   - skills/diff/scripts/content-diff.mjs          (post-deploy page probe, Step 10)
 *   - skills/deploy/scripts/section-schema.mjs      (pre-code ENCODE/DECODE contract, #93)
 *   - skills/deploy/scripts/block-roundtrip.mjs     (in-loop per-block gate, #94)
 *
 * Factoring it here is the point, not a convenience: the schema a block is
 * written FROM, the in-loop assertion it is checked WITH, and the post-deploy
 * diff it is finally proved BY must all classify roles identically, or a pass
 * at one layer means nothing at the next. Change the classifier here and every
 * gate moves together.
 *
 * `inventory` runs IN the page (Playwright-serialized, ONE arg):
 *   page.evaluate(inventory, [rootSelector, eyebrow{maxFontPx,maxLen}])
 * It returns { items: [{role, order, text, key, href?, w, family, loaded}], imgCount }
 * with role ∈ heading | eyebrow | cta | body, classified by COMPUTED STYLE +
 * tag so a prototype's .ds-* DOM and an EDS block DOM compare symmetrically.
 *
 * `diffInventories(srcItems, tgtItems, prof)` consumes each source item
 * against the first unused same-key target item (duplicates count correctly)
 * and returns { flags, matchedCount }. All stack-specific labels + hints come
 * from a diff-profiles.mjs profile. Pass fontDelta: Infinity to suppress the
 * FONT FORK pass (the in-loop harness renders with local fonts, so face
 * fidelity is Step 4/10's business there, not the round-trip's).
 *
 * `editableInventory` runs IN the page (ONE arg): page.evaluate(editableInventory, [rootSel])
 * returns { count, items: [{tag, text}] } — the OUTERMOST h1-h6/p/ul/ol/pre/
 * blockquote elements under the root that carry visible text (outermost = no
 * ancestor matching the same list inside the root; empty and image-only elements
 * skipped). That is exactly the set Experience Workspace stamps `data-prose-index`
 * on (deploy SKILL.md § Experience Workspace editability contract): the ENCODE side
 * counts it on the prototype (section-schema `editableTexts`), the DECODE side on
 * the decorated page (content-diff's "editable texts" advisory, the --ew gate) —
 * fewer surviving outermost editables after decoration means authored elements
 * were rebuilt or merged.
 */

/* eslint-disable no-plusplus, no-continue, max-len */

// Runs IN the page (serialized by Playwright, so it takes ONE arg). args =
// [rootSel, eyebrow{maxFontPx,maxLen}] — the content root + label classifier
// thresholds from the active profile. Returns the role-classified inventory.
/* eslint-disable no-undef */
export function inventory(args) {
  const [rootSel, eyebrow] = args;
  const root = document.querySelector(rootSel) || document.querySelector('main') || document.body;
  const ARROWS = /[→➔➜›⇒➤>]+/g;
  const clean = (s) => (s || '').replace(/\s+/g, ' ').trim();
  // match key: lowercase, arrows + trailing punctuation stripped, whitespace collapsed.
  // Typographic punctuation is folded to ASCII first — curly vs straight apostrophes/
  // quotes, ellipsis, en/em dashes were the single largest false-positive source
  // across the e2e benchmark (a verbatim-captured prototype uses curly, authored copy
  // uses ASCII → spurious MISSING/ROLE-SWAP on identical text).
  const norm = (s) => clean(s).replace(ARROWS, ' ')
    .replace(/[‘’′]/g, "'").replace(/[“”″]/g, '"')
    .replace(/…/g, '...').replace(/[–—]/g, '-')
    .replace(/\s+/g, ' ').trim()
    .toLowerCase().replace(/[.,;:!?·•]+$/g, '').trim();

  // Off-screen probe for rendered-face width (#77 method): a FIXED-size string
  // under an element's computed family+weight. Comparing this width across pages
  // for the SAME normalised string reveals a different actual face (e.g. the
  // prototype's system-ui fallback vs the EDS self-hosted Bebas Neue).
  const probe = document.createElement('span');
  probe.style.cssText = 'position:absolute;left:-9999px;top:-9999px;visibility:hidden;white-space:nowrap;font-size:48px';
  document.body.appendChild(probe);
  const widthOf = (text, family, weight) => {
    probe.style.fontFamily = family; probe.style.fontWeight = weight;
    probe.textContent = text || 'Agw1996';
    return Math.round(probe.getBoundingClientRect().width);
  };
  const namedFamily = (ff) => {
    const m = ff.match(/"([^"]+)"|'([^']+)'/);
    return (m && (m[1] || m[2])) || ff.split(',')[0].trim();
  };
  // Per element: the rendered-face width (its full computed stack), and — using
  // the SAME width probe, never document.fonts.check (#77) — whether the first
  // NAMED family actually loaded (probe it alone vs a guaranteed-absent name; equal
  // width ⇒ it fell back). The label then reads e.g. "Bebas Neue" vs "Bellfort→sys".
  const face = (text, cs) => {
    const s = (text || 'Agw1996').slice(0, 40);
    const named = namedFamily(cs.fontFamily);
    const w = widthOf(s, cs.fontFamily, cs.fontWeight);
    const loaded = widthOf(s, `"${named}",monospace`, cs.fontWeight)
      !== widthOf(s, '__no_such_face__,monospace', cs.fontWeight);
    return { w, family: loaded ? named : `${named}→sys`, loaded };
  };

  const out = [];
  let imgCount = 0;
  let order = 0;
  root.querySelectorAll('*').forEach((el) => {
    const tag = el.tagName.toLowerCase();

    // Images are visual-diff's domain (load/stretch/imagery-gap) and their alt
    // text rarely matches verbatim across proto↔EDS — count them, don't diff them.
    if (tag === 'img') { imgCount += 1; return; }

    // A text CTA: an <a> whose label is text (not an image link). Use the FULL
    // label (textContent) so <strong><a>…</a></strong> and <a><strong>…</strong></a>
    // both resolve, then skip the link's inner nodes below via closest('a').
    if (tag === 'a') {
      if (el.querySelector('img, picture')) return; // image/logo link, not a text CTA
      const t = clean(el.textContent);
      if (!t) return;
      out.push({ role: 'cta', order: order++, text: t, key: norm(t), href: el.getAttribute('href') || '', ...face(norm(t), getComputedStyle(el)) });
      return;
    }

    // Own text = DIRECT child text nodes only, so a wrapper <div><p>…</p></div>
    // isn't double-counted (the <p> carries the text, the <div> is empty).
    const own = clean([...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent).join(' '));
    if (!own) return;
    if (el.closest('a')) return; // text inside a link — already captured by the <a>

    const cs = getComputedStyle(el);
    const item = { order: order++, text: own, key: norm(own), ...face(norm(own), cs) };
    if (/^h[1-6]$/.test(tag)) item.role = 'heading';
    else if (cs.textTransform === 'uppercase' && parseFloat(cs.fontSize) <= eyebrow.maxFontPx && own.length <= eyebrow.maxLen) item.role = 'eyebrow';
    else item.role = 'body';
    out.push(item);
  });
  probe.remove();
  return { items: out, imgCount };
}

// Runs IN the page (ONE arg). args = [rootSel]. Outermost editable elements with
// visible text — the Experience Workspace instrumentation set (see header).
export function editableInventory(args) {
  const [rootSel] = args;
  const root = document.querySelector(rootSel) || document.querySelector('main') || document.body;
  const SEL = 'h1, h2, h3, h4, h5, h6, p, ul, ol, pre, blockquote';
  const items = [];
  root.querySelectorAll(SEL).forEach((el) => {
    const anc = el.parentElement && el.parentElement.closest(SEL);
    if (anc && anc !== root && root.contains(anc)) return; // nested inside another editable
    const text = (el.textContent || '').replace(/\s+/g, ' ').trim();
    if (!text) return; // empty or image-only (edited via data-image-index, not a text editor)
    items.push({ tag: el.tagName.toLowerCase(), text: text.slice(0, 70) });
  });
  return { count: items.length, items };
}
/* eslint-enable no-undef */

// Diff: consume each source item against the first unused target item with the
// same key (duplicates count correctly), classifying the outcome by role. All
// stack-specific labels (S/T) + remediation hints (H) come from the profile.
export function diffInventories(srcItems, tgtItems, prof) {
  const flags = [];
  const matched = []; // {proto, eds} pairs for the font pass
  const S = prof.source; const T = prof.target; const H = prof.hints;
  const used = new Array(tgtItems.length).fill(false);
  const findTgt = (key, role) => {
    let fallback = -1;
    for (let i = 0; i < tgtItems.length; i++) {
      if (used[i] || tgtItems[i].key !== key || !key) continue;
      if (tgtItems[i].role === role) return i; // exact role match preferred
      if (fallback < 0) fallback = i; // same text, other role
    }
    return fallback;
  };

  srcItems.forEach((p) => {
    if (!p.key) return;
    const i = findTgt(p.key, p.role);
    if (i < 0) {
      const where = `${S} ${p.role} "${p.text.slice(0, 48)}"`;
      if (p.role === 'cta') flags.push({ sev: '🔴', kind: 'MISSING CTA', msg: `${where}${p.href ? ` → ${p.href}` : ''} has no ${T} link. ${H.MISSING_CTA}` });
      else if (p.role === 'heading') flags.push({ sev: '🔴', kind: 'MISSING HEADING', msg: `${where} has no ${T} heading of the same text. ${H.MISSING_HEADING}` });
      else if (p.role === 'eyebrow') flags.push({ sev: '🔴', kind: 'MISSING EYEBROW', msg: `${where} has no ${T} match. ${H.MISSING_EYEBROW}` });
      else flags.push({ sev: '🟡', kind: 'MISSING BODY', msg: `${where.slice(0, 70)}… not found in ${T}. ${H.MISSING_BODY}` });
      return;
    }
    used[i] = true;
    const e = tgtItems[i];
    if (e.role !== p.role) {
      flags.push({ sev: '🔴', kind: 'ROLE SWAP', msg: `"${p.text.slice(0, 40)}" is a ${p.role} in the ${S} but a ${e.role} in ${T} — ${H.ROLE_SWAP}` });
    }
    matched.push({ p, e });
  });

  // EXTRA: target content with no source — invented copy to verify (advisory).
  tgtItems.forEach((e, i) => {
    if (used[i] || !e.key) return;
    if (e.role === 'body') flags.push({ sev: '🟡', kind: 'EXTRA', msg: `${T} body "${e.text.slice(0, 48)}" has no ${S} source — ${H.EXTRA_BODY}` });
    else flags.push({ sev: '🟠', kind: 'EXTRA', msg: `${T} ${e.role} "${e.text.slice(0, 48)}" has no ${S} source — ${H.EXTRA}` });
  });

  // FONT FORK: matched lines whose rendered FACE differs (width probe, #77). A
  // proprietary→self-hosted-fallback substitution fires on EVERY display line, so
  // GROUP them into one advisory rather than N near-identical paragraphs — the
  // agent/user decides whether the fork is intended.
  const forks = matched
    .filter(({ p, e }) => p.w && e.w && Math.abs(1 - e.w / p.w) > prof.fontDelta)
    .map(({ p, e }) => ({ role: p.role, text: p.text.slice(0, 28), from: p.family, to: e.family, pct: Math.round((e.w / p.w - 1) * 100) }));
  if (forks.length) {
    const shown = forks.slice(0, 8).map((f) => `${f.role} "${f.text}": ${S} ${f.from} vs ${T} ${f.to} (${f.pct}%)`).join('; ');
    const more = forks.length > 8 ? ` (+${forks.length - 8} more)` : '';
    flags.push({ sev: '🟠', kind: 'FONT FORK', msg: `${forks.length} matched line(s) render a DIFFERENT face: ${shown}${more}. ${H.FONT_FORK}` });
  }

  return { flags, matchedCount: matched.length };
}

export function summarise(inv) {
  const by = (r) => inv.items.filter((x) => x.role === r).length;
  return `${inv.items.length} text nodes — ${by('heading')} headings, ${by('eyebrow')} eyebrows, ${by('cta')} CTAs, ${by('body')} body; ${inv.imgCount} img`;
}

skills

README.md

tile.json