wand-kit 0.3.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PROMPT.md +221 -19
- package/SKILL.md +100 -20
- package/START-HERE.md +12 -1
- package/brands/current/tokens.json +10 -2
- package/brands/interim/assets/avatar.svg +1 -0
- package/brands/interim/assets/closing-art.png +0 -0
- package/brands/interim/assets/cover-art.png +0 -0
- package/brands/interim/assets/cover-portrait-art.png +0 -0
- package/brands/interim/assets/divider-art.png +0 -0
- package/brands/interim/assets/glyphs/README.md +29 -0
- package/brands/interim/assets/glyphs/activity.svg +15 -0
- package/brands/interim/assets/glyphs/atom.svg +17 -0
- package/brands/interim/assets/glyphs/badge-check.svg +16 -0
- package/brands/interim/assets/glyphs/bell.svg +16 -0
- package/brands/interim/assets/glyphs/blocks.svg +16 -0
- package/brands/interim/assets/glyphs/book-open.svg +16 -0
- package/brands/interim/assets/glyphs/bot.svg +20 -0
- package/brands/interim/assets/glyphs/boxes.svg +26 -0
- package/brands/interim/assets/glyphs/brain.svg +23 -0
- package/brands/interim/assets/glyphs/briefcase.svg +16 -0
- package/brands/interim/assets/glyphs/building-2.svg +21 -0
- package/brands/interim/assets/glyphs/calendar.svg +18 -0
- package/brands/interim/assets/glyphs/chart-column.svg +18 -0
- package/brands/interim/assets/glyphs/chart-line.svg +16 -0
- package/brands/interim/assets/glyphs/chart-pie.svg +16 -0
- package/brands/interim/assets/glyphs/circle-check.svg +16 -0
- package/brands/interim/assets/glyphs/clipboard-check.svg +17 -0
- package/brands/interim/assets/glyphs/clock.svg +16 -0
- package/brands/interim/assets/glyphs/cloud.svg +15 -0
- package/brands/interim/assets/glyphs/code.svg +15 -0
- package/brands/interim/assets/glyphs/coins.svg +18 -0
- package/brands/interim/assets/glyphs/compass.svg +16 -0
- package/brands/interim/assets/glyphs/cpu.svg +24 -0
- package/brands/interim/assets/glyphs/database.svg +17 -0
- package/brands/interim/assets/glyphs/eye.svg +16 -0
- package/brands/interim/assets/glyphs/file-check.svg +17 -0
- package/brands/interim/assets/glyphs/filter.svg +15 -0
- package/brands/interim/assets/glyphs/fingerprint.svg +23 -0
- package/brands/interim/assets/glyphs/flag.svg +16 -0
- package/brands/interim/assets/glyphs/gauge.svg +16 -0
- package/brands/interim/assets/glyphs/gavel.svg +19 -0
- package/brands/interim/assets/glyphs/git-branch.svg +18 -0
- package/brands/interim/assets/glyphs/globe.svg +17 -0
- package/brands/interim/assets/glyphs/graduation-cap.svg +17 -0
- package/brands/interim/assets/glyphs/handshake.svg +19 -0
- package/brands/interim/assets/glyphs/hexagon.svg +15 -0
- package/brands/interim/assets/glyphs/index.json +177 -0
- package/brands/interim/assets/glyphs/infinity.svg +15 -0
- package/brands/interim/assets/glyphs/key.svg +17 -0
- package/brands/interim/assets/glyphs/landmark.svg +20 -0
- package/brands/interim/assets/glyphs/layers.svg +17 -0
- package/brands/interim/assets/glyphs/lightbulb.svg +17 -0
- package/brands/interim/assets/glyphs/link.svg +16 -0
- package/brands/interim/assets/glyphs/list-checks.svg +19 -0
- package/brands/interim/assets/glyphs/lock.svg +16 -0
- package/brands/interim/assets/glyphs/map.svg +17 -0
- package/brands/interim/assets/glyphs/megaphone.svg +16 -0
- package/brands/interim/assets/glyphs/message-square.svg +15 -0
- package/brands/interim/assets/glyphs/milestone.svg +17 -0
- package/brands/interim/assets/glyphs/network.svg +19 -0
- package/brands/interim/assets/glyphs/orbit.svg +19 -0
- package/brands/interim/assets/glyphs/package.svg +18 -0
- package/brands/interim/assets/glyphs/plug.svg +18 -0
- package/brands/interim/assets/glyphs/puzzle.svg +15 -0
- package/brands/interim/assets/glyphs/radar.svg +22 -0
- package/brands/interim/assets/glyphs/receipt.svg +17 -0
- package/brands/interim/assets/glyphs/refresh-cw.svg +18 -0
- package/brands/interim/assets/glyphs/rocket.svg +18 -0
- package/brands/interim/assets/glyphs/route.svg +17 -0
- package/brands/interim/assets/glyphs/scale.svg +19 -0
- package/brands/interim/assets/glyphs/search.svg +16 -0
- package/brands/interim/assets/glyphs/server.svg +18 -0
- package/brands/interim/assets/glyphs/settings.svg +16 -0
- package/brands/interim/assets/glyphs/share-2.svg +19 -0
- package/brands/interim/assets/glyphs/shield-check.svg +16 -0
- package/brands/interim/assets/glyphs/shuffle.svg +19 -0
- package/brands/interim/assets/glyphs/sparkles.svg +19 -0
- package/brands/interim/assets/glyphs/square-stack.svg +17 -0
- package/brands/interim/assets/glyphs/target.svg +17 -0
- package/brands/interim/assets/glyphs/trending-up.svg +16 -0
- package/brands/interim/assets/glyphs/triangle-alert.svg +17 -0
- package/brands/interim/assets/glyphs/trophy.svg +20 -0
- package/brands/interim/assets/glyphs/users.svg +18 -0
- package/brands/interim/assets/glyphs/wallet.svg +16 -0
- package/brands/interim/assets/glyphs/wand-sparkles.svg +22 -0
- package/brands/interim/assets/glyphs/workflow.svg +17 -0
- package/brands/interim/assets/glyphs/zap.svg +15 -0
- package/brands/interim/assets/mark.svg +1 -0
- package/brands/interim/assets/social/composition.json +6833 -0
- package/brands/interim/assets/spark-lavender.png +0 -0
- package/brands/interim/assets/spark-violet.png +0 -0
- package/brands/interim/assets/statement-art.png +0 -0
- package/brands/interim/assets/story/agent-alpha.png +0 -0
- package/brands/interim/assets/story/agent.png +0 -0
- package/brands/interim/assets/story/economy-alpha.png +0 -0
- package/brands/interim/assets/story/economy.png +0 -0
- package/brands/interim/assets/story/index.json +214 -0
- package/brands/interim/assets/story/learning-alpha.png +0 -0
- package/brands/interim/assets/story/learning.png +0 -0
- package/brands/interim/assets/story/organization-alpha.png +0 -0
- package/brands/interim/assets/story/organization.png +0 -0
- package/brands/interim/assets/story/worker-alpha.png +0 -0
- package/brands/interim/assets/story/worker-light.png +0 -0
- package/brands/interim/assets/story/worker.png +0 -0
- package/brands/interim/assets/wordmark-on-dark.png +0 -0
- package/brands/interim/assets/wordmark-on-dark.svg +1 -0
- package/brands/interim/assets/wordmark-on-light.png +0 -0
- package/brands/interim/assets/wordmark-on-light.svg +1 -0
- package/brands/interim/fonts/Geist-Book.ttf +0 -0
- package/brands/interim/fonts/Geist-Medium.ttf +0 -0
- package/brands/interim/fonts/Geist-Regular.ttf +0 -0
- package/brands/interim/fonts/Geist-SemiBold.ttf +0 -0
- package/brands/interim/fonts/Newsreader-Italic.ttf +0 -0
- package/brands/interim/fonts/Newsreader-Medium.ttf +0 -0
- package/brands/interim/fonts/Newsreader-MediumItalic.ttf +0 -0
- package/brands/interim/fonts/Newsreader-Regular.ttf +0 -0
- package/brands/interim/fonts/OFL-Geist.txt +93 -0
- package/brands/interim/fonts/OFL-Newsreader.txt +93 -0
- package/brands/interim/fonts/faces.json +12 -0
- package/brands/interim/lib/composition.js +169 -0
- package/brands/interim/lib/composition.mjs +101 -0
- package/brands/interim/lib/glyphs.js +89 -0
- package/brands/interim/lib/measure.js +121 -0
- package/brands/interim/lib/pdf.js +597 -0
- package/brands/interim/lib/ttf.py +169 -0
- package/brands/interim/lib/wandxml.py +37 -0
- package/brands/interim/tokens.json +299 -0
- package/brands/next/tokens.json +14 -2
- package/examples/social/social.json +23 -0
- package/lib/audit.js +142 -0
- package/lib/brand.js +12 -7
- package/lib/components.js +42 -10
- package/lib/engine.js +72 -9
- package/lib/library.js +180 -0
- package/lib/pptx.js +1 -1
- package/lib/social.js +366 -0
- package/lib/talk.js +37 -4
- package/library/index.json +629 -0
- package/library/interim/avatar-800x800.png +0 -0
- package/library/interim/icons/apple-touch-icon.png +0 -0
- package/library/interim/icons/favicon-16.png +0 -0
- package/library/interim/icons/favicon-32.png +0 -0
- package/library/interim/icons/favicon-48.png +0 -0
- package/library/interim/icons/favicon.ico +0 -0
- package/library/interim/icons/favicon.svg +1 -0
- package/library/interim/icons/icon-192.png +0 -0
- package/library/interim/icons/icon-512.png +0 -0
- package/library/interim/icons/icon-tags.html +5 -0
- package/library/interim/icons/site.webmanifest +19 -0
- package/library/interim/linkedin-banner-1584x396.png +0 -0
- package/library/interim/linkedin-company-1128x191.png +0 -0
- package/library/interim/open-agent-discovery-share-1200x630.png +0 -0
- package/library/interim/open-agent-discovery-share-head-tags.html +17 -0
- package/library/interim/wand-ai-share-1200x630.png +0 -0
- package/library/interim/wand-ai-share-head-tags.html +17 -0
- package/library/interim/x-header-1500x500.png +0 -0
- package/package.json +2 -2
- package/templates/card.mjs +6 -2
- package/templates/page.mjs +1 -1
- package/templates/slide.mjs +1 -1
- package/templates/storyboard.mjs +1 -1
- package/wand.js +65 -6
package/lib/audit.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/audit.js — how a page's link preview looks today, before anyone redraws it.
|
|
3
|
+
*
|
|
4
|
+
* The 2026-10-23 fix (runs/2026-10-23-wand-og-fix) began by reading the live
|
|
5
|
+
* pages: one image for every page, its words outside the centre square Slack
|
|
6
|
+
* keeps, small print that vanished in LinkedIn's compact card, an og:title that
|
|
7
|
+
* repeated the image, an em dash, no twitter tags. This reads the same things
|
|
8
|
+
* from any page and shows its image the way those feeds do.
|
|
9
|
+
*
|
|
10
|
+
* node wand.js social --audit https://wand.ai/ https://wand.ai/insights/… [--out audit]
|
|
11
|
+
* node wand.js social --audit saved-page.html --image its-og-image.png
|
|
12
|
+
*
|
|
13
|
+
* A chat with no web access gets a clear row saying so: save the page (and its
|
|
14
|
+
* image) and pass the files.
|
|
15
|
+
*/
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { pathToFileURL } from "node:url";
|
|
19
|
+
import { browser } from "./engine.js";
|
|
20
|
+
import { fetchTo, feedRows, wholeWithSquare, previewPage } from "./social.js";
|
|
21
|
+
|
|
22
|
+
const LIMIT = 300 * 1024;
|
|
23
|
+
const ENT = { amp: "&", quot: '"', apos: "'", lt: "<", gt: ">", nbsp: " ", mdash: "—", ndash: "–", rsquo: "’", lsquo: "‘", rdquo: "”", ldquo: "“", hellip: "…" };
|
|
24
|
+
const decode = (s) => s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => e[0] === "#" ? String.fromCodePoint(e[1] === "x" || e[1] === "X" ? parseInt(e.slice(2), 16) : +e.slice(1)) : ENT[e.toLowerCase()] ?? m);
|
|
25
|
+
|
|
26
|
+
/** The page's og: and twitter: tags (the first of each, as Slack reads them), how many twitter:card tags, and its <title>. */
|
|
27
|
+
export function readTags(html) {
|
|
28
|
+
const tags = {}, count = {};
|
|
29
|
+
for (const [m] of html.matchAll(/<meta\b[^>]*>/gi)) {
|
|
30
|
+
const attr = {};
|
|
31
|
+
for (const [, k, , a, b] of m.matchAll(/([\w:-]+)\s*=\s*("([^"]*)"|'([^']*)')/g)) attr[k.toLowerCase()] = decode(a ?? b ?? "");
|
|
32
|
+
const key = (attr.property || attr.name || "").toLowerCase();
|
|
33
|
+
if (!/^(og|twitter):/.test(key) || attr.content == null) continue;
|
|
34
|
+
count[key] = (count[key] || 0) + 1;
|
|
35
|
+
if (!(key in tags)) tags[key] = attr.content;
|
|
36
|
+
}
|
|
37
|
+
const title = decode((/<title[^>]*>([\s\S]*?)<\/title>/i.exec(html) || [])[1] || "").trim();
|
|
38
|
+
return { tags, count, title };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Width, height and type from a PNG, JPEG, GIF or WebP's own header. */
|
|
42
|
+
export function imageFacts(file) {
|
|
43
|
+
const b = fs.readFileSync(file), bytes = b.length;
|
|
44
|
+
if (b[0] === 0x89 && b.toString("ascii", 1, 4) === "PNG") return { type: "png", w: b.readUInt32BE(16), h: b.readUInt32BE(20), bytes };
|
|
45
|
+
if (b.toString("ascii", 0, 3) === "GIF") return { type: "gif", w: b.readUInt16LE(6), h: b.readUInt16LE(8), bytes };
|
|
46
|
+
if (b.toString("ascii", 8, 12) === "WEBP") {
|
|
47
|
+
const k = b.toString("ascii", 12, 16);
|
|
48
|
+
if (k === "VP8X") return { type: "webp", w: 1 + b.readUIntLE(24, 3), h: 1 + b.readUIntLE(27, 3), bytes };
|
|
49
|
+
if (k === "VP8L") { const v = b.readUInt32LE(21); return { type: "webp", w: (v & 0x3fff) + 1, h: ((v >> 14) & 0x3fff) + 1, bytes }; }
|
|
50
|
+
return { type: "webp", w: b.readUInt16LE(26) & 0x3fff, h: b.readUInt16LE(28) & 0x3fff, bytes };
|
|
51
|
+
}
|
|
52
|
+
if (b[0] === 0xff && b[1] === 0xd8) {
|
|
53
|
+
for (let i = 2; i < b.length - 9;) {
|
|
54
|
+
if (b[i] !== 0xff) { i++; continue; }
|
|
55
|
+
const m = b[i + 1], len = b.readUInt16BE(i + 2);
|
|
56
|
+
if (m >= 0xc0 && m <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(m)) return { type: "jpeg", w: b.readUInt16BE(i + 7), h: b.readUInt16BE(i + 5), bytes };
|
|
57
|
+
i += 2 + len;
|
|
58
|
+
}
|
|
59
|
+
return { type: "jpeg", w: 0, h: 0, bytes };
|
|
60
|
+
}
|
|
61
|
+
return { type: "unknown", w: 0, h: 0, bytes };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const norm = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
|
65
|
+
/** A short name for a page: the last part of its path, or its host for a home page. */
|
|
66
|
+
const slug = (t) => {
|
|
67
|
+
const u = /^https?:\/\//.test(t) ? new URL(t) : null, last = (u ? u.pathname : t).split("/").filter(Boolean).pop();
|
|
68
|
+
return norm((last || u?.hostname || "page").replace(/\.[a-z0-9]+$/i, "")).replace(/ /g, "-").slice(0, 40) || "page";
|
|
69
|
+
};
|
|
70
|
+
/** The image's words (its alt, less the brand's name) found again in the og:title: a preview that says them twice. */
|
|
71
|
+
const repeats = (title, alt) => {
|
|
72
|
+
const words = (s) => norm(s).split(" ").filter((w) => w && !["wand", "ai", "a", "an", "the"].includes(w));
|
|
73
|
+
const t = new Set(words(title)), a = words(alt);
|
|
74
|
+
return a.length >= 3 && a.filter((w) => t.has(w)).length / a.length >= 0.8;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export async function audit(targets, { image = null, out = "audit" } = {}) {
|
|
78
|
+
if (!targets.length) throw new Error("usage: node wand.js social --audit <url | page.html> [more] [--image og-image.png] [--out audit]");
|
|
79
|
+
out = path.resolve(out); fs.mkdirSync(out, { recursive: true });
|
|
80
|
+
const rows = [], pages = [];
|
|
81
|
+
const row = (ok, gate, msg, warn = false) => rows.push({ ok, warn, gate, msg });
|
|
82
|
+
for (const t of targets) {
|
|
83
|
+
const id = slug(t), isUrl = /^https?:\/\//.test(t);
|
|
84
|
+
let html;
|
|
85
|
+
try {
|
|
86
|
+
if (isUrl) {
|
|
87
|
+
const r = await fetch(t, { signal: AbortSignal.timeout(15000), headers: { "user-agent": "Mozilla/5.0 (wand-kit link-preview audit)" } });
|
|
88
|
+
if (!r.ok) throw new Error(`answered ${r.status}`);
|
|
89
|
+
html = await r.text();
|
|
90
|
+
} else html = fs.readFileSync(path.resolve(t), "utf8");
|
|
91
|
+
} catch (e) {
|
|
92
|
+
row(false, id, `could not read ${t} from here (${e.cause?.code || e.message}). Save the page (File, Save As) and its og:image, then: --audit page.html --image image.png`);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const { tags, count, title } = readTags(html), p = { id, target: t, tags, title };
|
|
96
|
+
pages.push(p);
|
|
97
|
+
const want = ["og:title", "og:description", "og:url", "og:image", "og:image:width", "og:image:height", "og:image:type", "og:image:alt", "twitter:card", "twitter:title", "twitter:image"];
|
|
98
|
+
const missing = want.filter((k) => !tags[k]);
|
|
99
|
+
row(!!tags["og:image"] && !missing.length, `${id} tags`, tags["og:image"] ? (missing.length ? `missing ${missing.join(", ")}` : "every og: and twitter: tag a feed reads") : "no og:image: every feed shows a bare link", !!tags["og:image"] && missing.length > 0);
|
|
100
|
+
if ((count["twitter:card"] || 0) > 1) row(false, `${id} twitter:card`, `${count["twitter:card"]} twitter:card tags; Slack reads the first`, true);
|
|
101
|
+
const dash = Object.entries(tags).filter(([, v]) => /—/.test(v)).map(([k]) => k);
|
|
102
|
+
if (dash.length) row(false, `${id} dashes`, `em dash in ${dash.join(", ")}`, true);
|
|
103
|
+
if (repeats(tags["og:title"], tags["og:image:alt"])) row(false, `${id} title`, "og:title repeats the image's words, so the preview shows them twice", true);
|
|
104
|
+
// the image itself
|
|
105
|
+
const src = image && targets.length === 1 ? image : tags["og:image"] ? new URL(tags["og:image"], isUrl ? t : "https://example.com/").href : null;
|
|
106
|
+
if (tags["og:image"] && !/^https:\/\//.test(tags["og:image"])) row(false, `${id} image url`, `og:image is not an absolute https URL (${tags["og:image"]}); some feeds drop it`, true);
|
|
107
|
+
if (src) {
|
|
108
|
+
try {
|
|
109
|
+
p.png = await fetchTo(src, path.join(out, `live-${id}`), process.cwd());
|
|
110
|
+
const f = imageFacts(p.png); p.facts = f;
|
|
111
|
+
const ratio = f.h ? f.w / f.h : 0, notes = [];
|
|
112
|
+
if (f.bytes > LIMIT) notes.push("over 300 KB: WhatsApp shows no preview");
|
|
113
|
+
if (ratio && Math.abs(ratio / 1.905 - 1) > 0.05) notes.push(`${ratio.toFixed(2)}:1, not 1.91:1: feeds crop it`);
|
|
114
|
+
if (f.w && f.w < 1200) notes.push("under 1200 wide: soft on large cards");
|
|
115
|
+
row(!notes.length, `${id} image`, `${f.w} x ${f.h}, ${Math.round(f.bytes / 1024)} KB, ${f.type}${notes.length ? "; " + notes.join("; ") : ""}`, notes.length > 0);
|
|
116
|
+
} catch (e) { row(false, `${id} image`, `${e.message}`); }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// one picture for every page: nothing in it says what the link is
|
|
120
|
+
const imgs = pages.map((p) => p.tags["og:image"]).filter(Boolean);
|
|
121
|
+
if (pages.length > 1 && new Set(imgs).size < imgs.length) row(false, "pages", `${imgs.length - new Set(imgs).size + 1} pages share one og:image: each needs its own (an article its headline)`, true);
|
|
122
|
+
|
|
123
|
+
const shot = pages.filter((p) => p.png);
|
|
124
|
+
if (shot.length) {
|
|
125
|
+
const u = (f) => pathToFileURL(f).href;
|
|
126
|
+
const body = shot.map((p) => `<h2>${p.target}</h2>${feedRows(u(p.png), { title: p.tags["og:title"] || p.title, url: p.tags["og:url"] || p.target, description: p.tags["og:description"] })}${p.facts?.w ? wholeWithSquare(u(p.png), p.facts.w, p.facts.h) : ""}`).join("\n");
|
|
127
|
+
const pg = previewPage(body), htmlPath = path.join(out, "audit.html"), png = path.join(out, "audit.png");
|
|
128
|
+
fs.writeFileSync(htmlPath, pg.html);
|
|
129
|
+
const b = await browser(), v = await b.newPage({ viewport: { width: pg.w, height: 400 } });
|
|
130
|
+
await v.goto(pathToFileURL(htmlPath).href, { waitUntil: "load" });
|
|
131
|
+
await v.evaluate(() => Promise.all([...document.images].map((i) => (i.complete ? null : new Promise((r) => { i.onload = i.onerror = r; })))));
|
|
132
|
+
await v.screenshot({ path: png, fullPage: true });
|
|
133
|
+
await v.close();
|
|
134
|
+
row(true, "preview", path.relative(process.cwd(), png));
|
|
135
|
+
}
|
|
136
|
+
const ok = rows.every((r) => r.ok || r.warn);
|
|
137
|
+
console.log("");
|
|
138
|
+
for (const r of rows) console.log(` ${r.ok ? " ok " : r.warn ? " -- " : "FAIL"} ${r.gate.padEnd(34)} ${r.msg}`);
|
|
139
|
+
if (shot.length) console.log(`\n review ${path.relative(process.cwd(), path.join(out, "audit.png"))}, as the people scrolling past will see it:\n does the dashed centre square (Slack's and WhatsApp's cut) hold the whole wordmark and the words?\n in LinkedIn's compact card (128pt), can you read the line? is there small print that vanishes?\n is the type the brand's, and does the image say what this page is?`);
|
|
140
|
+
console.log("");
|
|
141
|
+
return { ok, rows, pages };
|
|
142
|
+
}
|
package/lib/brand.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* lib/brand.js — the
|
|
2
|
+
* lib/brand.js — the three brands, resolved and turned into CSS.
|
|
3
3
|
*
|
|
4
4
|
* A brand is a directory with `tokens.json` (swatch, roles, faces, shape, art),
|
|
5
5
|
* `fonts/`, `assets/` and `lib/` (the Python helpers the exporters borrow).
|
|
6
6
|
* The kit runs in two shapes and finds the directory in both:
|
|
7
7
|
*
|
|
8
|
-
* repo power-prompts/studio/kit/ -> ../../../brand and brand-
|
|
8
|
+
* repo power-prompts/studio/kit/ -> ../../../brand, brand-next and brand-interim
|
|
9
9
|
* packaged tools/package.mjs copies each into brands/<name>/ beside the kit
|
|
10
10
|
*
|
|
11
11
|
* `WAND_BRANDS_DIR` overrides both: it holds <name>/ per brand.
|
|
@@ -23,7 +23,7 @@ import path from "node:path";
|
|
|
23
23
|
import { fileURLToPath } from "node:url";
|
|
24
24
|
|
|
25
25
|
const KIT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
26
|
-
const REPO_DIRS = { current: "brand", next: "brand-next" };
|
|
26
|
+
const REPO_DIRS = { current: "brand", next: "brand-next", interim: "brand-interim" };
|
|
27
27
|
export const BRANDS = Object.keys(REPO_DIRS);
|
|
28
28
|
|
|
29
29
|
// The brand a job gets when the user names none. While the two brands overlap
|
|
@@ -37,13 +37,13 @@ export const DEFAULT_BRAND = "current";
|
|
|
37
37
|
export const ASKED_AS = {
|
|
38
38
|
current: ["current brand", "old brand", "existing guidelines", "Geist"],
|
|
39
39
|
next: ["new brand", "new branding", "new brand guidelines", "the website's look"],
|
|
40
|
+
interim: ["interim brand", "interim branding", "the live site's look", "the transitional website", "wand.ai as it is today"],
|
|
40
41
|
};
|
|
41
42
|
|
|
42
43
|
/** The default and the words that switch away from it, as one line. */
|
|
43
44
|
export function brandRule() {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
return `${DEFAULT_BRAND} by default; ${other} only when the user asks for ${words.slice(0, -1).join(", ")} or ${words.at(-1)}`;
|
|
45
|
+
const words = (b) => { const w = ASKED_AS[b].map((x) => `"${x}"`); return `${w.slice(0, -1).join(", ")} or ${w.at(-1)}`; };
|
|
46
|
+
return `${DEFAULT_BRAND} by default; ${BRANDS.filter((b) => b !== DEFAULT_BRAND).map((b) => `${b} only when the user asks for ${words(b)}`).join("; ")}`;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/** Absolute path of a brand directory, or an error naming every place looked. */
|
|
@@ -128,11 +128,16 @@ function brandCss(tokens, roles, fonts, extras = {}) {
|
|
|
128
128
|
`@font-face{font-family:"${f.family}";font-weight:${f.weight};font-style:${f.italic ? "italic" : "normal"};src:url("${url(path.join(fonts, f.file))}")}`).join("\n");
|
|
129
129
|
const F = tokens.faces, S = tokens.shape;
|
|
130
130
|
const face = (k) => `--face-${k}:"${F[k].family}";--w-${k}:${F[k].weight};--tr-${k}:${F[k].tracking}em;--lh-${k}:${F[k].leading}`;
|
|
131
|
+
// a brand whose title phrase changes face (interim: Newsreader for <em>) declares `faces.phrase`; without it the
|
|
132
|
+
// phrase inherits the title's face, so the other brands draw exactly as before
|
|
133
|
+
// a brand may draw its art unshaded (`shape.artShade`, 0.3 otherwise) and set its cover title at its own tracking
|
|
134
|
+
// (`faces.display.coverTracking`, -0.03em otherwise): interim does both, as its approved social design does
|
|
135
|
+
const P = F.phrase, phrase = P ? `;--face-phrase:"${P.family}";--w-phrase:${P.weight};--tr-phrase:${P.tracking}em;--style-phrase:${P.italic ? "italic" : "normal"};--style-phrase-art:${P.artItalic || P.italic ? "italic" : "normal"}` : "";
|
|
131
136
|
// the talk and board runtimes' own controls, dressed in the brand's dark roles
|
|
132
137
|
const d = roles.dark, ui = { bg: d.track, card: d.paper, stroke: d.stroke, ink: d.ink, text: d.text, label: d.muted, accent: d.accent };
|
|
133
138
|
return `${faces}
|
|
134
139
|
:root{${Object.entries(ui).map(([k, v]) => `--ui-${k}:#${v}`).join(";")};--ui-face:"${F.body.family}"}
|
|
135
140
|
.page{${vars(roles.light)};${Object.keys(extras).length ? vars(extras) + ";" : ""}${face("display")};${face("body")};${face("label")};--caps-label:${F.label.caps ? "uppercase" : "none"};
|
|
136
|
-
--radius-k:${S.radius};--card-stroke:${S.cardStroke}px;--emph-stroke:${S.emphStroke}px}
|
|
141
|
+
--radius-k:${S.radius};--card-stroke:${S.cardStroke}px;--emph-stroke:${S.emphStroke}px;--art-shade:${S.artShade ?? 0.3};--tr-cover:${F.display.coverTracking ?? -0.03}em${phrase}}
|
|
137
142
|
.page.dark{${vars(roles.dark)}}`;
|
|
138
143
|
}
|
package/lib/components.js
CHANGED
|
@@ -18,9 +18,10 @@
|
|
|
18
18
|
* content lines. A job's `pages(c)` composes with them and may add its own
|
|
19
19
|
* CSS on top; nothing stops it writing raw markup where no component fits.
|
|
20
20
|
*/
|
|
21
|
+
import fs from "node:fs";
|
|
21
22
|
import path from "node:path";
|
|
22
23
|
import { esc } from "./content.js";
|
|
23
|
-
import { url } from "./brand.js";
|
|
24
|
+
import { url, brandDir, BRANDS } from "./brand.js";
|
|
24
25
|
|
|
25
26
|
export const ARROW = `<svg class="arrow deco" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 1 L9 5 L2 9 Z"/></svg>`;
|
|
26
27
|
|
|
@@ -50,17 +51,20 @@ export function bind({ brand, surface, job, content, dir: jobDir = process.cwd()
|
|
|
50
51
|
* number. `art` is a brand art role ("cover", "overlay", "closing") or a
|
|
51
52
|
* file path; `notes` become speaker notes in the .pptx and the presenter
|
|
52
53
|
* view; `seconds` is the slide's share of the talk, for the presenter's
|
|
53
|
-
* pace; `motion: "none"` keeps a slide still when the deck animates
|
|
54
|
+
* pace; `motion: "none"` keeps a slide still when the deck animates; `footer` replaces the job's footer line
|
|
55
|
+
* on this page (a source note).
|
|
54
56
|
*/
|
|
55
|
-
const page = ({ eyebrow = "", dark = false, art = null, shade = dark, cls: k = "", body = "", notes = "", chrome = true, seconds = 0, motion = "", autosize = true } = {}) => {
|
|
57
|
+
const page = ({ eyebrow = "", dark = false, art = null, shade = dark, cls: k = "", body = "", notes = "", chrome = true, seconds = 0, motion = "", autosize = true, footer = null } = {}) => {
|
|
56
58
|
n += 1;
|
|
57
|
-
|
|
59
|
+
// a brand may draw a role again for a portrait page (`coverPortrait`), where its landscape art would crop away
|
|
60
|
+
const role = art && surface.h > surface.w && brand.tokens.art[`${art}Portrait`] ? `${art}Portrait` : art;
|
|
61
|
+
const artSrc = art ? (/[\/.]/.test(art) && !brand.tokens.art[art] ? url(art) : brand.artUrl(role)) : null;
|
|
58
62
|
const eb = job.tag && eyebrow ? `${esc(job.tag)}<span class="bar">|</span>${esc(eyebrow)}` : esc(job.tag || eyebrow || "");
|
|
59
63
|
return `<section class="page ${dark ? "dark" : "light"}${cls(k)}" data-n="${n}"${notes ? ` data-notes="${esc(notes)}"` : ""}${seconds ? ` data-seconds="${seconds}"` : ""}${motion ? ` data-motion="${esc(motion)}"` : ""}${autosize ? "" : ' data-size="fixed"'}>
|
|
60
64
|
${artSrc ? `<img class="art deco" src="${artSrc}" alt="">${shade ? `<div class="shade deco"></div>` : ""}` : ""}
|
|
61
65
|
${chrome ? `<div class="eyebrow">${eb}</div>${mark(dark)}` : ""}
|
|
62
66
|
<div class="content">${body}</div>
|
|
63
|
-
${chrome ? `<footer><span>${esc(job.footer
|
|
67
|
+
${chrome ? `<footer><span>${esc(footer ?? job.footer ?? "")}</span><span class="pn">${String(n).padStart(2, "0")}</span></footer>` : ""}
|
|
64
68
|
</section>`;
|
|
65
69
|
};
|
|
66
70
|
|
|
@@ -130,13 +134,29 @@ ${chrome ? `<footer><span>${esc(job.footer || "")}</span><span class="pn">${Stri
|
|
|
130
134
|
/** A big outlined numeral in a dark panel on the right (the deck's chapter pages). */
|
|
131
135
|
const chapter = (num) => `<div class="chapter deco"><span>${esc(num)}</span></div>`;
|
|
132
136
|
|
|
137
|
+
/**
|
|
138
|
+
* A photograph, filling its box and given the brand's one grade: blacks lift toward the dark ground,
|
|
139
|
+
* highlights settle toward the light one, so photographs from different shoots read as one set.
|
|
140
|
+
* `src` is a photograph of the brands' set by name ("walk", "corridor"; `brand-next/assets/photos`, which
|
|
141
|
+
* the current brand shares, graded on its own grounds) or a path.
|
|
142
|
+
*/
|
|
143
|
+
const photo = (src, { cls: k = "", alt = "", pos = "" } = {}) => {
|
|
144
|
+
let href = /^(file|https?|data):/.test(src) ? src : url(src);
|
|
145
|
+
if (!/[\/.]/.test(src)) {
|
|
146
|
+
const dirs = [brand.assets, ...BRANDS.filter((b) => b !== brand.name).map((b) => { try { return path.join(brandDir(b), "assets"); } catch { return null; } }).filter(Boolean)];
|
|
147
|
+
const hit = dirs.map((d) => path.join(d, "photos", `${src}.jpg`)).find((f) => fs.existsSync(f));
|
|
148
|
+
if (!hit) throw new Error(`photo("${src}"): no photograph of that name in ${dirs.map((d) => path.join(d, "photos")).join(" or ")}`);
|
|
149
|
+
href = url(hit);
|
|
150
|
+
}
|
|
151
|
+
return `<figure class="photo${cls(k)}"><img src="${href}" alt="${esc(alt)}"${pos ? ` style="object-position:${esc(pos)}"` : ""}><span class="grade"></span></figure>`;
|
|
152
|
+
};
|
|
133
153
|
const img = (src, k = "", alt = "") => `<img class="img${cls(k)}" src="${/^(file|https?|data):/.test(src) ? src : url(src)}" alt="${esc(alt)}">`;
|
|
134
154
|
const icon = (name, k = "") => brand.glyph(name).replace("<svg", `<svg class="icon deco${cls(k)}" aria-hidden="true"`);
|
|
135
155
|
const tile = (name) => `<div class="tile deco">${icon(name)}</div>`;
|
|
136
156
|
|
|
137
157
|
/* ---------------------------------------------------------- motion */
|
|
138
158
|
|
|
139
|
-
/** An entrance, as attributes for raw markup: `<div${c.enter("pop", 2)}>`. Kinds: rise (default), fade, pop, grow (bars, up), widen (bars, across), draw (an SVG path with pathLength="1"), count. */
|
|
159
|
+
/** An entrance, as attributes for raw markup: `<div${c.enter("pop", 2)}>`. Kinds: rise (default), fade, pop, grow (bars, up), widen (bars, across), draw (an SVG path with pathLength="1"), count, words (a title rising word by word), lit (a statement lighting word by word), window (a photograph opening). */
|
|
140
160
|
const enter = (kind = "", i = null) => ` data-a="${esc(kind)}"${i != null ? ` style="--i:${+i}"` : ""}`;
|
|
141
161
|
/** Held back until the presenter moves on; steps of one number come in together. */
|
|
142
162
|
const step = (nth, html, { a = "", tag: t = "div", cls: k = "" } = {}) => `<${t} data-step="${+nth}"${a ? ` data-a="${esc(a)}"` : ""}${k ? ` class="${k}"` : ""}>${html}</${t}>`;
|
|
@@ -235,7 +255,7 @@ ${DIR.some(([k]) => s[k]) ? `<dl class="direction">${DIR.filter(([k]) => s[k]).m
|
|
|
235
255
|
page, sec, h1: h("h1"), h2: h("h2"), h3: h("h3"), h4: h("h4"), h5: h("h5"),
|
|
236
256
|
lead: p("lead"), body: p("body"), note: p("note"), small: p("body sm"), tag, p: (html, k = "") => `<p${cls(k) ? ` class="${k}"` : ""}>${html}</p>`,
|
|
237
257
|
cols, card, chip, strip, phase, flow, table, who, quote, stat, stats, delta, steps, callout, list: listOf,
|
|
238
|
-
cover, closing, chapter, img, icon, tile, mark, wordmark,
|
|
258
|
+
cover, closing, chapter, img, photo, icon, tile, mark, wordmark,
|
|
239
259
|
asset: brand.assetUrl, art: brand.artUrl,
|
|
240
260
|
enter, step, countup, video, demo, sketch, storyboard,
|
|
241
261
|
brand: brand.name, surface: surface.name, count: () => n,
|
|
@@ -249,7 +269,7 @@ html,body{background:#E9E7EE}
|
|
|
249
269
|
.page{position:relative;overflow:hidden;break-after:page;background:var(--bg);color:var(--ink);font-family:var(--face-body);font-weight:var(--w-body);
|
|
250
270
|
letter-spacing:var(--tr-body);line-height:var(--lh-body);-webkit-font-smoothing:antialiased;font-variant-numeric:tabular-nums}
|
|
251
271
|
.art{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:38% 50%}
|
|
252
|
-
.shade{position:absolute;inset:0;background:var(--bg);opacity
|
|
272
|
+
.shade{position:absolute;inset:0;background:var(--bg);opacity:var(--art-shade,.3)}
|
|
253
273
|
.eyebrow{position:absolute;left:var(--x);top:var(--c-eyebrow-top);font-family:var(--face-label);font-weight:var(--w-label);font-size:var(--c-eyebrow-size);
|
|
254
274
|
letter-spacing:.22em;text-transform:var(--caps-label);color:var(--muted);line-height:1.2}
|
|
255
275
|
.dark .eyebrow{color:var(--ink)}
|
|
@@ -265,7 +285,13 @@ footer .pn{font-weight:500}
|
|
|
265
285
|
/* type */
|
|
266
286
|
h1,h2,h3,h4,h5,.display{font-family:var(--face-display);font-weight:var(--w-display);letter-spacing:var(--tr-display);line-height:var(--lh-display);color:var(--ink)}
|
|
267
287
|
h1{font-size:var(--t-h1)}h1.md{font-size:var(--t-h1md)}h2{font-size:var(--t-h2)}h3{font-size:var(--t-h3);line-height:1.22}h4{font-size:var(--t-h4);line-height:1.2}h5{font-size:var(--t-h5);line-height:1.25}
|
|
268
|
-
|
|
288
|
+
/* a title's phrase is its second tone (on the current brand, the accent itself); on the cover, a statement
|
|
289
|
+
and the close, the pages that carry the brand's art, it keeps the accent. A brand may give the phrase its
|
|
290
|
+
own face (interim: Newsreader, the italic on the art pages); the others leave the variables unset */
|
|
291
|
+
h1 em,h2 em,.display em{font-family:var(--face-phrase,inherit);font-weight:var(--w-phrase,inherit);font-style:var(--style-phrase,normal);letter-spacing:var(--tr-phrase,inherit);color:var(--second)}
|
|
292
|
+
h3 em,h4 em,h5 em,.lede em{font-style:normal;color:var(--second-sm)}
|
|
293
|
+
.page:has(>.art) :is(h1,h2,h3,h4,h5,.display,.lede) em{color:var(--accent-text)}
|
|
294
|
+
.page:has(>.art) :is(h1,h2,.display) em{font-style:var(--style-phrase-art,normal)}
|
|
269
295
|
.label{font-family:var(--face-display);font-weight:500;font-size:var(--t-label);letter-spacing:var(--tr-display);color:var(--label);line-height:1.2}
|
|
270
296
|
.label.sm{font-size:calc(var(--t-label) * .8)}
|
|
271
297
|
.lead{font-size:var(--t-lead);line-height:1.5;color:var(--ink)}
|
|
@@ -382,7 +408,7 @@ table.grid{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums
|
|
|
382
408
|
.cover .content{justify-content:center;gap:0}
|
|
383
409
|
.cover.split .content{justify-content:space-between}
|
|
384
410
|
.cover .top{display:flex;flex-direction:column;gap:1.3em}
|
|
385
|
-
.cover h1{font-size:var(--t-display);line-height:1.02;letter-spacing
|
|
411
|
+
.cover h1{font-size:var(--t-display);line-height:1.02;letter-spacing:var(--tr-cover,-.03em)}
|
|
386
412
|
.lede{font-size:var(--t-lede);font-weight:500;line-height:1.45;color:var(--ink);max-width:88%}
|
|
387
413
|
.cover-body{font-size:var(--t-body);line-height:1.6;color:var(--text);max-width:min(80%,44em)}
|
|
388
414
|
.meta{font-size:var(--t-meta);color:var(--text);padding-top:1em;border-top:1px solid var(--rule);line-height:1.4}
|
|
@@ -401,6 +427,12 @@ table.grid{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums
|
|
|
401
427
|
.has-chapter .left{width:62%;display:flex;flex-direction:column;gap:var(--m-gap)}
|
|
402
428
|
/* images, icons */
|
|
403
429
|
.img{display:block;max-width:100%}
|
|
430
|
+
.photo{position:relative;margin:0;overflow:hidden;border-radius:calc(var(--m-radius) * var(--radius-k));background:var(--grade-high)}
|
|
431
|
+
.photo>img{display:block;width:100%;height:100%;object-fit:cover}
|
|
432
|
+
.photo>.grade{position:absolute;inset:0;pointer-events:none}
|
|
433
|
+
.photo>.grade::before,.photo>.grade::after{content:"";position:absolute;inset:0}
|
|
434
|
+
.photo>.grade::before{background:var(--grade-low);mix-blend-mode:lighten;opacity:.42}
|
|
435
|
+
.photo>.grade::after{background:var(--grade-high);mix-blend-mode:darken;opacity:.55}
|
|
404
436
|
.figure{border-radius:calc(var(--m-radius) * var(--radius-k));overflow:hidden}
|
|
405
437
|
.icon{width:1.25em;height:1.25em;stroke:currentColor;fill:none}
|
|
406
438
|
.tile{width:2.3em;height:2.3em;border-radius:calc(var(--m-radius) * .6 * var(--radius-k));background:var(--surface);display:flex;align-items:center;justify-content:center;color:var(--accent-text);flex:none;margin-bottom:.8em}
|
package/lib/engine.js
CHANGED
|
@@ -97,7 +97,21 @@ const stem = (job, file) => (job.file || job.name || path.basename(path.dirname(
|
|
|
97
97
|
/* ------------------------------------------------------------------ gates */
|
|
98
98
|
|
|
99
99
|
/** Runs inside the page. Everything is read from the rendered DOM. */
|
|
100
|
-
|
|
100
|
+
/* The lines a block of text sets in, read from the browser's own line boxes: a box's height misleads (a table cell
|
|
101
|
+
is as tall as its row, a card's paragraph as tall as the card), the fragments of its text do not. Runs inside the page. */
|
|
102
|
+
const LINES_OF = `(el) => {
|
|
103
|
+
const rg = document.createRange(); rg.selectNodeContents(el);
|
|
104
|
+
const rs = [...rg.getClientRects()].filter((q) => q.width > 0.5 && q.height > 0.5).sort((a, b) => a.top - b.top);
|
|
105
|
+
let n = 0, cur = null;
|
|
106
|
+
for (const q of rs) {
|
|
107
|
+
if (cur && q.top < cur.bottom - Math.min(q.height, cur.bottom - cur.top) * 0.5) { cur.bottom = Math.max(cur.bottom, q.bottom); continue; }
|
|
108
|
+
n++; cur = { top: q.top, bottom: q.bottom };
|
|
109
|
+
}
|
|
110
|
+
return n;
|
|
111
|
+
}`;
|
|
112
|
+
|
|
113
|
+
function GATES({ min, chars, tokens, families, base, maxLines = 0, linesOf }) {
|
|
114
|
+
const countLines = eval(linesOf);
|
|
101
115
|
const T = new Set(tokens);
|
|
102
116
|
const pages = [...document.querySelectorAll(".page")];
|
|
103
117
|
const toHex = (c) => {
|
|
@@ -109,15 +123,32 @@ function GATES({ min, chars, tokens, families, base }) {
|
|
|
109
123
|
const lum = (h) => { const c = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255).map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)); return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; };
|
|
110
124
|
const contrast = (a, b) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); };
|
|
111
125
|
const bgOf = (el) => { for (let e = el; e; e = e.parentElement) { const h = toHex(getComputedStyle(e).backgroundColor); if (h) return h; if (e.classList.contains("page")) break; } return null; };
|
|
112
|
-
const res = { fit: [], overlap: [], brand: [], fonts: [], contrast: [], type: [], measure: [], dash: [], texts: [], notes: [], broken: [],
|
|
126
|
+
const res = { fit: [], overlap: [], brand: [], fonts: [], contrast: [], type: [], measure: [], lines: [], dash: [], stretched: [], texts: [], notes: [], broken: [], extent: [],
|
|
113
127
|
motion: { a: document.querySelectorAll("[data-a]").length, steps: document.querySelectorAll("[data-step]").length, video: document.querySelectorAll(".media.video").length, demo: document.querySelectorAll(".media.demo").length, placeholders: document.querySelectorAll(".frame.placeholder").length } };
|
|
114
128
|
document.querySelectorAll("img").forEach((im) => { if (im.getAttribute("src") && !(im.complete && im.naturalWidth)) res.broken.push(decodeURIComponent(im.getAttribute("src").replace(/^file:\/\//, "")).split("/").slice(-2).join("/")); });
|
|
129
|
+
// an image drawn at another shape than its own (a flex column stretches an <img> with width:auto across it, the
|
|
130
|
+
// sales deck's screenshots on 2026-10-27): only object-fit cover, contain or none may change the box's shape
|
|
131
|
+
document.querySelectorAll("img").forEach((im) => {
|
|
132
|
+
if (!im.naturalWidth || !im.naturalHeight || /\.svg($|[?#])/i.test(im.getAttribute("src") || "")) return;
|
|
133
|
+
const r = im.getBoundingClientRect(); if (r.width < 4 || r.height < 4) return;
|
|
134
|
+
if (["cover", "contain", "none", "scale-down"].includes(getComputedStyle(im).objectFit)) return;
|
|
135
|
+
const k = (r.width / r.height) / (im.naturalWidth / im.naturalHeight);
|
|
136
|
+
if (Math.abs(k - 1) > 0.02) res.stretched.push({ page: pages.indexOf(im.closest(".page")) + 1, src: decodeURIComponent(im.getAttribute("src").replace(/^file:\/\//, "")).split("/").pop(), k: +k.toFixed(2) });
|
|
137
|
+
});
|
|
115
138
|
const seen = new Set();
|
|
139
|
+
// a page's composition, coarsely: the blocks of its content in order, and how many a grid holds
|
|
140
|
+
const kindOf = (el) => { const c = [...el.classList].filter((x) => !/^(deco|big|wide|stretch|centre|sm|md)$/.test(x)).slice(0, 2); return el.tagName.toLowerCase() + (c.length ? "." + c.join(".") : ""); };
|
|
141
|
+
res.sig = pages.map((pg) => {
|
|
142
|
+
const c = pg.querySelector(".content");
|
|
143
|
+
if (!c || pg.matches(".cover, .back, .shot, .run")) return null;
|
|
144
|
+
return [...c.children].filter((k) => !k.classList.contains("deco")).map((k) => kindOf(k) + (/\b(cols|strip|stats|steps|grid)\b/.test(k.className) ? `[${k.children.length}]` : "")).join(" ");
|
|
145
|
+
});
|
|
116
146
|
pages.forEach((pg, pi) => {
|
|
117
147
|
const n = pi + 1;
|
|
118
148
|
const content = pg.querySelector(".content");
|
|
119
149
|
const box = (content || pg).getBoundingClientRect();
|
|
120
|
-
|
|
150
|
+
// a page that hides its footer (a cover, a close) is measured against its content box alone
|
|
151
|
+
const fr = pg.querySelector("footer")?.getBoundingClientRect(), foot = fr && fr.height ? fr : null;
|
|
121
152
|
const floor = foot ? Math.min(box.bottom, foot.top - 6) : box.bottom;
|
|
122
153
|
let over = 0, why = "", low = -Infinity;
|
|
123
154
|
for (const el of (content || pg).querySelectorAll("*")) {
|
|
@@ -135,6 +166,9 @@ function GATES({ min, chars, tokens, families, base }) {
|
|
|
135
166
|
const all = [...pg.querySelectorAll("*")];
|
|
136
167
|
const leaves = all.filter((e) => !e.closest(".deco, svg, script, style") && [...e.childNodes].some((c) => c.nodeType === 3 && c.data.trim()));
|
|
137
168
|
const boxes = leaves.map((e) => ({ e, r: e.getBoundingClientRect() })).filter((x) => x.r.width && x.r.height);
|
|
169
|
+
// how far the words run across the page, for a share image that a square crop from the centre must keep
|
|
170
|
+
const words = boxes.filter((x) => content && content.contains(x.e)), pgBox = pg.getBoundingClientRect();
|
|
171
|
+
if (words.length) res.extent.push({ page: n, l: Math.round(Math.min(...words.map((x) => x.r.left)) - pgBox.left), r: Math.round(Math.max(...words.map((x) => x.r.right)) - pgBox.left) });
|
|
138
172
|
for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) {
|
|
139
173
|
const A = boxes[i], B = boxes[j];
|
|
140
174
|
if (A.e.contains(B.e) || B.e.contains(A.e)) continue;
|
|
@@ -163,6 +197,8 @@ function GATES({ min, chars, tokens, families, base }) {
|
|
|
163
197
|
const r = el.getBoundingClientRect(), lh = parseFloat(cs.lineHeight) || size * 1.4;
|
|
164
198
|
const inner = r.height - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
|
|
165
199
|
if (Math.round(inner / lh) >= 2 && r.width / (size * 0.48) > chars) res.measure.push({ page: n, el: sel(el), chars: Math.round(r.width / (size * 0.48)) });
|
|
200
|
+
// a paragraph that wraps past maxLines on a slide: the edits in PowerPoint and Google Slides reflow it, and it breaks
|
|
201
|
+
if (maxLines && cs.display !== "inline") { const k = countLines(el); if (k > maxLines) res.lines.push({ page: n, el: sel(el), lines: k, text: (el.innerText || "").trim().slice(0, 32) }); }
|
|
166
202
|
}
|
|
167
203
|
const text = pg.innerText || "";
|
|
168
204
|
if (text.includes("\u2014")) res.dash.push(n);
|
|
@@ -178,7 +214,8 @@ function GATES({ min, chars, tokens, families, base }) {
|
|
|
178
214
|
* sizes itself to its slides, the same way on every run. Returns the pages
|
|
179
215
|
* it changed, and every slide's fill after.
|
|
180
216
|
*/
|
|
181
|
-
function AUTOSIZE() {
|
|
217
|
+
function AUTOSIZE({ maxLines = 0, linesOf } = {}) {
|
|
218
|
+
const countLines = eval(linesOf);
|
|
182
219
|
const fill = (pg) => {
|
|
183
220
|
const c = pg.querySelector(".content"); if (!c) return 1;
|
|
184
221
|
const kids = [...c.children].filter((k) => !k.classList.contains("deco") && k.getBoundingClientRect().height);
|
|
@@ -187,15 +224,20 @@ function AUTOSIZE() {
|
|
|
187
224
|
return (kids.reduce((a, k) => a + k.getBoundingClientRect().height, 0) + gap * (kids.length - 1)) / c.getBoundingClientRect().height;
|
|
188
225
|
};
|
|
189
226
|
const overflows = (pg) => {
|
|
190
|
-
const c = pg.querySelector(".content"), box = c.getBoundingClientRect(),
|
|
227
|
+
const c = pg.querySelector(".content"), box = c.getBoundingClientRect(), fr = pg.querySelector("footer")?.getBoundingClientRect(), foot = fr && fr.height ? fr : null;
|
|
191
228
|
const floor = foot ? Math.min(box.bottom, foot.top - 6) : box.bottom;
|
|
192
229
|
// as the fit gate reads it: an inline run's line box may rise above the content's top, a block may not
|
|
193
230
|
return [...c.querySelectorAll("*")].some((el) => { if (el.closest(".deco")) return false; const r = el.getBoundingClientRect(); return r.width && r.height && (r.bottom > floor + 0.5 || r.right > box.right + 1 || (getComputedStyle(el).display !== "inline" && r.top < box.top - 0.5)); }) || fill(pg) > 1;
|
|
194
231
|
};
|
|
232
|
+
// the step up is also undone when it would wrap a paragraph past the job's line limit
|
|
233
|
+
const tooLong = (pg) => maxLines > 0 && [...pg.querySelectorAll(".content *")].some((el) => {
|
|
234
|
+
if (el.closest(".deco, svg") || ![...el.childNodes].some((n) => n.nodeType === 3 && n.data.trim())) return false;
|
|
235
|
+
return getComputedStyle(el).display !== "inline" && countLines(el) > maxLines;
|
|
236
|
+
});
|
|
195
237
|
const sized = [], fills = [];
|
|
196
238
|
document.querySelectorAll(".page").forEach((pg, i) => {
|
|
197
239
|
if (pg.matches(".cover, .back, .shot, .run, [data-size]")) return;
|
|
198
|
-
if (fill(pg) < 0.55 && !pg.classList.contains("big")) { pg.classList.add("big"); if (overflows(pg)) pg.classList.remove("big"); else sized.push(i + 1); }
|
|
240
|
+
if (fill(pg) < 0.55 && !pg.classList.contains("big")) { pg.classList.add("big"); if (overflows(pg) || tooLong(pg)) pg.classList.remove("big"); else sized.push(i + 1); }
|
|
199
241
|
fills.push({ page: i + 1, fill: fill(pg) });
|
|
200
242
|
});
|
|
201
243
|
return { sized, fills };
|
|
@@ -238,10 +280,12 @@ export async function build(jobPath, { outputs: wantOutputs = true, outDir = nul
|
|
|
238
280
|
await page.evaluate(() => Promise.all([...document.images].map((i) => (i.complete ? null : new Promise((r) => { i.onload = i.onerror = r; })))));
|
|
239
281
|
await page.waitForTimeout(120);
|
|
240
282
|
|
|
283
|
+
// a slide's paragraphs stay at two lines unless the job says otherwise (Carlos, 2026-10-27: past two "it breaks a lot")
|
|
284
|
+
const maxLines = surface.kind === "slide" ? job.maxLines ?? 2 : job.maxLines ?? 0;
|
|
241
285
|
// a slide deck sizes itself before anything is measured or drawn; the file keeps the sizes it chose
|
|
242
|
-
const auto = surface.kind === "slide" ? await page.evaluate(AUTOSIZE) : { sized: [], fills: [] };
|
|
286
|
+
const auto = surface.kind === "slide" ? await page.evaluate(AUTOSIZE, { maxLines, linesOf: LINES_OF }) : { sized: [], fills: [] };
|
|
243
287
|
if (auto.sized.length) fs.writeFileSync(htmlPath, "<!doctype html>\n" + await page.evaluate(() => document.documentElement.outerHTML));
|
|
244
|
-
const g = await page.evaluate(GATES, { min: surface.min, chars: surface.chars, tokens: [...brand.tokenSet], families: brand.families, base: surface.base });
|
|
288
|
+
const g = await page.evaluate(GATES, { min: surface.min, chars: surface.chars, tokens: [...brand.tokenSet], families: brand.families, base: surface.base, maxLines, linesOf: LINES_OF });
|
|
245
289
|
const pageCount = g.fit.length;
|
|
246
290
|
const text = g.texts.join("\n");
|
|
247
291
|
const cov = content.has ? coverage(content, text, job.splitters) : null;
|
|
@@ -263,9 +307,26 @@ export async function build(jobPath, { outputs: wantOutputs = true, outDir = nul
|
|
|
263
307
|
row(!deny.length, "names", job.denylist?.length ? (deny.length ? `found ${deny.join(", ")}` : `none of ${job.denylist.length} denied names`) : "no denylist", !job.denylist?.length);
|
|
264
308
|
row(!g.contrast.length, "contrast", g.contrast.length ? `${g.contrast.length} low: ` + g.contrast.slice(0, 3).map((o) => `p${o.page} ${o.el} ${o.ratio}:1 (needs ${o.need})`).join("; ") : "every text passes WCAG AA", true);
|
|
265
309
|
row(!g.measure.length, "measure", g.measure.length ? `${g.measure.length} long: ` + g.measure.slice(0, 3).map((o) => `p${o.page} ${o.el} ~${o.chars} chars`).join("; ") : `no line over ${surface.chars} characters`, true);
|
|
310
|
+
// hard when the job sets maxLines, a warning on every other deck
|
|
311
|
+
if (maxLines) row(!g.lines.length, "lines", g.lines.length ? `${g.lines.length} over ${maxLines}: ` + g.lines.slice(0, 4).map((o) => `p${o.page} ${o.el} ${o.lines} lines (${o.text}…)`).join("; ") + (g.lines.length > 4 ? " …" : "") : `no paragraph over ${maxLines} line${maxLines === 1 ? "" : "s"}`, job.maxLines == null);
|
|
266
312
|
const sparse = auto.fills.filter((f) => f.fill < 0.4);
|
|
267
313
|
if (surface.kind === "slide") row(!sparse.length, "space", sparse.length ? `${sparse.map((f) => `p${f.page} ${Math.round(f.fill * 100)}%`).join(", ")} of the slide used: add the proof, a figure or a visual, or join it to the next` : `every slide uses its space${auto.sized.length ? ` (type a step up on p${auto.sized.join(", p")})` : ""}`, true);
|
|
268
|
-
|
|
314
|
+
// the same composition page after page is what makes a set of pages read as a template
|
|
315
|
+
const runs = [], composed = !job.__board && surface.kind !== "card";
|
|
316
|
+
if (composed) for (let i = 1, start = 0; i <= g.sig.length; i++) {
|
|
317
|
+
if (i < g.sig.length && g.sig[i] && g.sig[i] === g.sig[i - 1]) continue;
|
|
318
|
+
if (i - start >= 3 && g.sig[start]) runs.push([start + 1, i]);
|
|
319
|
+
start = i;
|
|
320
|
+
}
|
|
321
|
+
if (composed) row(!runs.length, "variety", runs.length ? runs.map(([a, z]) => `p${a}–p${z}`).join(", ") + " share one composition: vary it (a statement, a split, a figure, a photograph) or join them" : "no composition repeats three pages running", true);
|
|
322
|
+
// Slack's and WhatsApp's thumbnails of a link are a square cut from the image's centre: words outside it are cut
|
|
323
|
+
if (surface.name === "og") {
|
|
324
|
+
const sq = [(surface.w - surface.h) / 2, (surface.w + surface.h) / 2], cut = g.extent.filter((e) => e.l < sq[0] || e.r > sq[1]);
|
|
325
|
+
row(!cut.length, "crop", cut.length ? `${cut.map((e) => `p${e.page} words from x ${e.l} to ${e.r}`).join(", ")}; Slack and WhatsApp keep x ${sq[0]} to ${sq[1]}: keep the words of a share image there` : `every page's words inside the centre square (x ${sq[0]} to ${sq[1]}), whole in Slack's and WhatsApp's square cut`, true);
|
|
326
|
+
}
|
|
327
|
+
row(!g.broken.length && !g.stretched.length, "images", g.broken.length ? `${g.broken.length} did not load: ${g.broken.slice(0, 3).join(", ")}`
|
|
328
|
+
: g.stretched.length ? `${g.stretched.length} drawn out of shape: ` + g.stretched.slice(0, 4).map((o) => `p${o.page} ${o.src} ${o.k > 1 ? "stretched" : "squeezed"} ${Math.round(Math.abs(o.k - 1) * 100)}%`).join("; ") + (g.stretched.length > 4 ? " …" : "")
|
|
329
|
+
: "every image loaded, each at its own shape");
|
|
269
330
|
if (absent.length || g.motion.demo || g.motion.video) row(!absent.length, "media", absent.length ? `missing: ${absent.map((p) => path.basename(p)).join(", ")}` : `${g.motion.demo} demo${g.motion.demo === 1 ? "" : "s"}, ${g.motion.video} clip${g.motion.video === 1 ? "" : "s"}, each with its poster`);
|
|
270
331
|
const board = job.__board || null;
|
|
271
332
|
if (surface.kind === "slide" && !board) row(true, "motion", `${job.talk?.motion === "none" ? "still" : "entrances on every slide"}${g.motion.a ? `, ${g.motion.a} marked` : ""}${g.motion.steps ? `, ${g.motion.steps} held for steps` : ""} (the HTML; static exports show the end state)`);
|
|
@@ -320,6 +381,8 @@ export function printReport(r) {
|
|
|
320
381
|
if (r.outputs.pdf) console.log(`\n ${path.relative(process.cwd(), r.outputs.pdf)} (${(fs.statSync(r.outputs.pdf).size / 1e6).toFixed(1)} MB, ${r.pages} page${r.pages === 1 ? "" : "s"}, ${r.brand} brand, ${r.surface}, ${(r.ms / 1000).toFixed(1)}s)`);
|
|
321
382
|
if (r.outputs.pptx) console.log(` ${path.relative(process.cwd(), r.outputs.pptx)} (${(fs.statSync(r.outputs.pptx).size / 1e6).toFixed(1)} MB)`);
|
|
322
383
|
if (r.outputs.single) console.log(` ${path.relative(process.cwd(), r.outputs.single)} (${(fs.statSync(r.outputs.single).size / 1e6).toFixed(1)} MB, one file, opens offline)`);
|
|
384
|
+
// what no gate sees: read the sheet the way a creative director would, before anyone else does
|
|
385
|
+
if (r.outputs.sheet) console.log(`\n review ${path.relative(process.cwd(), r.outputs.sheet)}, as a creative director:\n does every page carry its one claim? is one page memorable, and which?\n is the accent rare (cover, statement, close, figures)? photographs large or absent?\n hairlines and type doing the separating, not boxes?`);
|
|
323
386
|
console.log(r.ok ? "" : "\n FAILED — fix the rows marked FAIL\n");
|
|
324
387
|
}
|
|
325
388
|
|