Sync multi-theme optimizations from dev (exclude Bento/Style C)
- Magazine (A) + Swiss (B) template updates: ?slide=N, ESC thumbnail fix, reduced-motion, spacing fixes - Swiss validator: Playwright M1/M2 rendered measurement (overflow/whitespace/nav-safe/title-gap) - SKILL.md: Step 0 upstream-update check, image-mixing decision tree, measure-before-edit ladder - checklist.md: image-role guidance, quiet-zone rule, post-render measurement section - Remove author-local golden-source absolute paths; point to in-repo template-swiss.html snapshot - Add ppt-skill-showcase.png and A/B-only unification-plan.md - Exclude all Bento/Style C assets, references, validator, and design-research docs Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
82fe5ae129
commit
ba12c5c9ad
11 files changed
+429
-95
No files matched your search
@@ -1,5 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const file = process.argv[2];
|
||||
const allowExperimental = process.argv.includes('--allow-experimental');
|
||||
@@ -14,6 +17,31 @@ const htmlForSlides = html.replace(/<!--[\s\S]*?-->/g, '');
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
function overflowFix(px) {
|
||||
const n = Math.round(px);
|
||||
if (n <= 40) return `only ${n}px over: nudge content up or tighten one gap/padding by 20-40px; do not delete content`;
|
||||
if (n <= 90) return `${n}px over: compact local gaps/padding and reduce one block height; avoid cutting copy`;
|
||||
if (n <= 160) return `${n}px over: reduce a display title slightly or compress one paragraph before deleting content`;
|
||||
return `${n}px over: switch to a higher-capacity layout or remove/merge content intentionally`;
|
||||
}
|
||||
|
||||
async function loadPlaywright() {
|
||||
const candidates = [
|
||||
createRequire(import.meta.url),
|
||||
createRequire(pathToFileURL(path.join(process.cwd(), 'package.json')).href),
|
||||
];
|
||||
for (const req of candidates) {
|
||||
try {
|
||||
const resolved = req.resolve('playwright');
|
||||
const mod = await import(pathToFileURL(resolved).href);
|
||||
return mod.default || mod;
|
||||
} catch {
|
||||
// Try the next resolution root.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const allowedLayouts = new Set([
|
||||
'SWISS-COVER-ASCII',
|
||||
'SWISS-CLOSING-ASCII',
|
||||
@@ -96,6 +124,179 @@ slides.forEach((slide) => {
|
||||
}
|
||||
});
|
||||
|
||||
async function runRenderedMeasurements() {
|
||||
const playwright = await loadPlaywright();
|
||||
if (!playwright?.chromium) {
|
||||
warnings.push('Rendered measurement skipped: Playwright is not resolvable from the skill folder or current project. Static Swiss checks still ran.');
|
||||
return;
|
||||
}
|
||||
|
||||
const browser = await playwright.chromium.launch({
|
||||
args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'],
|
||||
});
|
||||
try {
|
||||
const ctx = await browser.newContext({
|
||||
viewport: { width: 1600, height: 900 },
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(pathToFileURL(path.resolve(file)).href, { waitUntil: 'domcontentloaded' });
|
||||
await Promise.race([
|
||||
page.evaluate(() => document.fonts && document.fonts.ready),
|
||||
page.waitForTimeout(1800),
|
||||
]);
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
const measures = await page.$$eval('section.slide', (els) => {
|
||||
const TRANSPARENT = /rgba?\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0?\s*\)|transparent/;
|
||||
const titleSelector = [
|
||||
'.h-hero', '.h-hero-zh', '.h-xl', '.h-xl-zh', '.h-statement',
|
||||
'.display', '.display-zh', '.h1-zh', '.h2-zh', '.h-md',
|
||||
'.step-title', 'h1', 'h2', 'h3',
|
||||
].join(',');
|
||||
const hasDirectText = (n) => {
|
||||
for (const c of n.childNodes) {
|
||||
if (c.nodeType === 3 && c.textContent.trim().length > 0) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const colorVisible = (c) => c && !TRANSPARENT.test(c);
|
||||
const labelFor = (n) => {
|
||||
const cls = n.className ? '.' + String(n.className).split(' ').filter(Boolean).join('.') : n.tagName.toLowerCase();
|
||||
const text = n.textContent.trim().replace(/\s+/g, ' ').slice(0, 40);
|
||||
return text ? `${cls} "${text}"` : cls;
|
||||
};
|
||||
const isMeaningful = (el, n) => {
|
||||
const er = el.getBoundingClientRect();
|
||||
const posterArea = er.width * er.height;
|
||||
const tag = n.tagName;
|
||||
const cs = getComputedStyle(n);
|
||||
const r = n.getBoundingClientRect();
|
||||
if (r.width < 6 || r.height < 6) return false;
|
||||
if (n === el || n.classList.contains('canvas-card')) return false;
|
||||
if (cs.position === 'fixed') return false;
|
||||
if (cs.position === 'absolute' && r.width * r.height >= posterArea * 0.82) return false;
|
||||
if (n.matches('canvas.ascii-bg, canvas.mag-bg, .grain, .dot-mat, .ring-mat, .cross-mat')) return false;
|
||||
|
||||
const isText = hasDirectText(n);
|
||||
const isMedia = tag === 'IMG' || tag === 'CANVAS' || tag === 'SVG';
|
||||
const isRule = tag === 'HR' || (r.height <= 4 && (
|
||||
parseFloat(cs.borderTopWidth) >= 1 ||
|
||||
parseFloat(cs.borderBottomWidth) >= 1 ||
|
||||
colorVisible(cs.backgroundColor)
|
||||
));
|
||||
const hasFill = colorVisible(cs.backgroundColor) && r.width * r.height >= 1600 && !['MAIN', 'SECTION'].includes(tag);
|
||||
const hasBorder = (parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth) +
|
||||
parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth)) >= 1 && r.width * r.height >= 1600;
|
||||
return isText || isMedia || isRule || hasFill || hasBorder;
|
||||
};
|
||||
const titleGapChecks = (el, nodes) => {
|
||||
const titles = Array.from(el.querySelectorAll(titleSelector)).filter((n) => n.textContent.trim());
|
||||
const out = [];
|
||||
for (const title of titles) {
|
||||
const tr = title.getBoundingClientRect();
|
||||
const isLocal = title.matches('.h-md, .step-title, h3');
|
||||
const minGap = isLocal ? 14 : 32;
|
||||
let nearest = null;
|
||||
let nearestGap = Infinity;
|
||||
for (const node of nodes) {
|
||||
if (node === title || title.contains(node) || node.contains(title)) continue;
|
||||
const nr = node.getBoundingClientRect();
|
||||
const gap = nr.top - tr.bottom;
|
||||
if (gap < -2) continue;
|
||||
const overlap = Math.max(0, Math.min(tr.right, nr.right) - Math.max(tr.left, nr.left));
|
||||
const overlapRatio = overlap / Math.min(tr.width, nr.width);
|
||||
if (overlapRatio < 0.12 && gap < 96) continue;
|
||||
if (gap < nearestGap) {
|
||||
nearestGap = gap;
|
||||
nearest = node;
|
||||
}
|
||||
}
|
||||
if (nearest && nearestGap < minGap) {
|
||||
out.push({ title: labelFor(title), next: labelFor(nearest), gap: Math.round(nearestGap), minGap });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
return els.map((el, index) => {
|
||||
const er = el.getBoundingClientRect();
|
||||
const H = el.clientHeight;
|
||||
const W = el.clientWidth;
|
||||
const nodes = Array.from(el.querySelectorAll('*')).filter((n) => isMeaningful(el, n));
|
||||
let top = Infinity;
|
||||
let bottom = -Infinity;
|
||||
let topNode = null;
|
||||
let bottomNode = null;
|
||||
for (const n of nodes) {
|
||||
const r = n.getBoundingClientRect();
|
||||
const itemTop = r.top - er.top;
|
||||
const itemBottom = r.bottom - er.top;
|
||||
if (itemTop < top) {
|
||||
top = itemTop;
|
||||
topNode = n;
|
||||
}
|
||||
if (itemBottom > bottom) {
|
||||
bottom = itemBottom;
|
||||
bottomNode = n;
|
||||
}
|
||||
}
|
||||
if (!nodes.length) {
|
||||
top = 0;
|
||||
bottom = 0;
|
||||
}
|
||||
const safeBottom = Math.round(H * 0.93);
|
||||
return {
|
||||
idx: index + 1,
|
||||
id: el.id || '',
|
||||
layout: el.dataset.layout || '',
|
||||
width: W,
|
||||
height: H,
|
||||
scrollOverflow: Math.max(0, Math.round(el.scrollHeight - H)),
|
||||
visual: {
|
||||
top: Math.round(top),
|
||||
bottom: Math.round(bottom),
|
||||
activeRatio: H ? (bottom - top) / H : 0,
|
||||
bottomGap: Math.max(0, Math.round(H - bottom)),
|
||||
topOverflow: Math.max(0, Math.round(-top)),
|
||||
bottomOverflow: Math.max(0, Math.round(bottom - H)),
|
||||
bottomNode: bottomNode ? labelFor(bottomNode) : '',
|
||||
topNode: topNode ? labelFor(topNode) : '',
|
||||
safeBottom,
|
||||
},
|
||||
titleGaps: titleGapChecks(el, nodes),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
for (const m of measures) {
|
||||
const prefix = `Slide ${m.idx}${m.layout ? ` (${m.layout})` : ''}`;
|
||||
if (m.scrollOverflow > 4) {
|
||||
errors.push(`${prefix}: M1 DOM overflow ${m.scrollOverflow}px. ${overflowFix(m.scrollOverflow)}.`);
|
||||
}
|
||||
if (m.visual.bottomOverflow > 4) {
|
||||
errors.push(`${prefix}: M1 visual bottom overflow ${m.visual.bottomOverflow}px; lowest element is ${m.visual.bottomNode}. ${overflowFix(m.visual.bottomOverflow)}.`);
|
||||
}
|
||||
if (m.visual.topOverflow > 4) {
|
||||
errors.push(`${prefix}: M1 visual top overflow ${m.visual.topOverflow}px; highest element is ${m.visual.topNode}. Move content down by the measured overflow plus 16-24px.`);
|
||||
}
|
||||
if (m.visual.bottom > m.visual.safeBottom) {
|
||||
warnings.push(`${prefix}: M1 content reaches ${Math.round(m.visual.bottom)}px; nav-safe line is ${m.visual.safeBottom}px. Lift the lowest block or add .nav-safe-bottom.`);
|
||||
}
|
||||
if (m.visual.bottomGap > 170 && m.visual.activeRatio < 0.74) {
|
||||
warnings.push(`${prefix}: M1 bottom whitespace ${m.visual.bottomGap}px; active content height ${Math.round(m.visual.activeRatio * 100)}%. Restore spacing/content instead of over-correcting overflow.`);
|
||||
}
|
||||
for (const gap of m.titleGaps) {
|
||||
warnings.push(`${prefix}: M2 ${gap.title} has ${gap.gap}px gap before ${gap.next} (min ${gap.minGap}px).`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
await runRenderedMeasurements();
|
||||
|
||||
if (warnings.length) {
|
||||
console.warn('Warnings:');
|
||||
for (const warning of warnings) console.warn(`- ${warning}`);
|
||||
|
||||
Reference in new issue
Block a user