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/library.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/library.js — approved Wand files, handed out as they are.
|
|
3
|
+
*
|
|
4
|
+
* `library/index.json` lists every file a person approved: the social set on
|
|
5
|
+
* the interim brand (banners, avatar, the share images that went live, the
|
|
6
|
+
* site's icons), and the brands' own logos and art, which it names inside the
|
|
7
|
+
* brand folders rather than copying. Asked for "the LinkedIn banner", a chat
|
|
8
|
+
* hands over that file, byte for byte; it never redraws or re-exports one.
|
|
9
|
+
*
|
|
10
|
+
* node wand.js library [words] [--brand interim] what there is
|
|
11
|
+
* node wand.js library get <id> [<id>…] [--to dir] copy it, with where it goes
|
|
12
|
+
* node wand.js library --check every file is the one approved, and the kit still draws it
|
|
13
|
+
*
|
|
14
|
+
* index.json:
|
|
15
|
+
* recipes { <brand>: a social.json spec } how the kit draws the files it can draw
|
|
16
|
+
* assets [{ id, brand, kind, title, size, use, approved, recipe?, tags, files: [file] }]
|
|
17
|
+
* file { path, as?, from?, delivered?, tolerance?, px, bytes, sha256 }
|
|
18
|
+
* path inside library/, or "brand:<name>/<path in the brand folder>"
|
|
19
|
+
* as the name it is handed out under (a brand file: wand-<brand>-<name>)
|
|
20
|
+
* from the recipe's output it is checked against ("#tags": the card's tag block)
|
|
21
|
+
* delivered where the stored bytes came from, when they are a file people already received
|
|
22
|
+
* tolerance what the redraw may differ by: { mean (levels in 255, default 1), off (% of pixels
|
|
23
|
+
* over 24 levels, default 0.25) }; past 4 levels or 2% it fails
|
|
24
|
+
* `tools/library.mjs` (developer-only, in the repo) adds a file and rewrites bytes, sha256 and px.
|
|
25
|
+
*/
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import os from "node:os";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import crypto from "node:crypto";
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
import { brandDir, DEFAULT_BRAND } from "./brand.js";
|
|
32
|
+
|
|
33
|
+
const KIT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
34
|
+
export const LIB = path.join(KIT, "library");
|
|
35
|
+
|
|
36
|
+
export const load = () => JSON.parse(fs.readFileSync(path.join(LIB, "index.json"), "utf8"));
|
|
37
|
+
|
|
38
|
+
/** The file on disk for an index path. */
|
|
39
|
+
export function resolve(p) {
|
|
40
|
+
const m = /^brand:([a-z]+)\/(.+)$/.exec(p);
|
|
41
|
+
return m ? path.join(brandDir(m[1]), m[2]) : path.join(LIB, p);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The name a file is handed out under. */
|
|
45
|
+
export function handOutName(file) {
|
|
46
|
+
if (file.as) return file.as;
|
|
47
|
+
const m = /^brand:([a-z]+)\/(.+)$/.exec(file.path);
|
|
48
|
+
return m ? `wand-${m[1]}-${path.basename(m[2])}` : path.basename(file.path);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const sha256 = (f) => crypto.createHash("sha256").update(fs.readFileSync(f)).digest("hex");
|
|
52
|
+
|
|
53
|
+
/** Pixel size of a PNG, an ICO's largest frame or an SVG's viewBox; null for anything else. */
|
|
54
|
+
export function px(f) {
|
|
55
|
+
const b = fs.readFileSync(f);
|
|
56
|
+
if (b[0] === 0x89 && b.toString("ascii", 1, 4) === "PNG") return `${b.readUInt32BE(16)} x ${b.readUInt32BE(20)}`;
|
|
57
|
+
if (b.readUInt16LE(0) === 0 && b.readUInt16LE(2) === 1) { let m = 0; for (let i = 0; i < b.readUInt16LE(4); i++) m = Math.max(m, b[6 + 16 * i] || 256); return `${m} x ${m}`; }
|
|
58
|
+
const vb = /viewBox="[-\d.]+ [-\d.]+ ([\d.]+) ([\d.]+)"/.exec(b.toString("utf8", 0, 600));
|
|
59
|
+
return vb ? `${+vb[1]} x ${+vb[2]} (vector)` : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Assets whose id, title, kind, brand or tags hold every word. */
|
|
63
|
+
export function list(words = [], { brand = null } = {}) {
|
|
64
|
+
const ws = words.map((w) => w.toLowerCase());
|
|
65
|
+
return load().assets.filter((a) => (!brand || a.brand === brand) &&
|
|
66
|
+
ws.every((w) => [a.id, a.title, a.kind, a.brand, ...(a.tags || [])].join(" ").toLowerCase().includes(w)));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** One asset by id; a bare name ("wordmark") resolves to the brand asked for, or the default brand's. */
|
|
70
|
+
export function find(id, { brand = null } = {}) {
|
|
71
|
+
const all = load().assets, exact = all.find((a) => a.id === id);
|
|
72
|
+
if (exact) return { asset: exact };
|
|
73
|
+
const cands = all.filter((a) => a.id.startsWith(id + "-") || a.id === `${id}-${brand || DEFAULT_BRAND}`);
|
|
74
|
+
const pick = cands.filter((a) => a.brand === (brand || DEFAULT_BRAND));
|
|
75
|
+
if (pick.length === 1) return { asset: pick[0] };
|
|
76
|
+
if (cands.length === 1) return { asset: cands[0] };
|
|
77
|
+
// not an id: the assets the word finds, for the chat to choose from
|
|
78
|
+
return { asset: null, cands: cands.length ? cands : list(id.split(/[\s-]+/), { brand }) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Copy the assets' files into `to`; returns what was copied, for the note the chat gives with them. */
|
|
82
|
+
export function get(ids, { to = "wand-assets", brand = null } = {}) {
|
|
83
|
+
const done = [], missing = [];
|
|
84
|
+
for (const id of ids) {
|
|
85
|
+
const { asset, cands } = find(id, { brand });
|
|
86
|
+
if (!asset) { missing.push({ id, cands: (cands || []).map((a) => a.id) }); continue; }
|
|
87
|
+
const files = asset.files.map((f) => {
|
|
88
|
+
const src = resolve(f.path), dest = path.join(to, f.path.startsWith("brand:") ? "" : path.dirname(f.path).split(path.sep).slice(1).join(path.sep), handOutName(f));
|
|
89
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
90
|
+
fs.copyFileSync(src, dest);
|
|
91
|
+
if (f.sha256 && sha256(dest) !== f.sha256) throw new Error(`${dest}: does not match the approved file (run node wand.js library --check)`);
|
|
92
|
+
return { dest, px: f.px, bytes: f.bytes };
|
|
93
|
+
});
|
|
94
|
+
done.push({ asset, files });
|
|
95
|
+
}
|
|
96
|
+
return { done, missing };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The PNG inside each of an .ico's frames. */
|
|
100
|
+
function icoFrames(f) {
|
|
101
|
+
const b = fs.readFileSync(f), n = b.readUInt16LE(4);
|
|
102
|
+
return Array.from({ length: n }, (_, i) => { const o = 6 + 16 * i, len = b.readUInt32LE(o + 8), at = b.readUInt32LE(o + 12); return b.subarray(at, at + len); });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A browser page that diffs two images (paths or PNG buffers): mean and largest difference, levels in 255, and the share of pixels visibly off. */
|
|
106
|
+
async function differ(b) {
|
|
107
|
+
const page = await b.newPage();
|
|
108
|
+
const url = (f) => `data:image/png;base64,${(Buffer.isBuffer(f) ? f : fs.readFileSync(f)).toString("base64")}`;
|
|
109
|
+
const diff = (a, c) => page.evaluate(async ([da, dc]) => {
|
|
110
|
+
const load = (s) => new Promise((ok, no) => { const i = new Image(); i.onload = () => ok(i); i.onerror = () => no(new Error("could not decode")); i.src = s; });
|
|
111
|
+
const [A, C] = await Promise.all([load(da), load(dc)]);
|
|
112
|
+
if (A.naturalWidth !== C.naturalWidth || A.naturalHeight !== C.naturalHeight) return { size: `${A.naturalWidth} x ${A.naturalHeight} against ${C.naturalWidth} x ${C.naturalHeight}` };
|
|
113
|
+
const data = (im) => { const cv = document.createElement("canvas"); cv.width = im.naturalWidth; cv.height = im.naturalHeight; const x = cv.getContext("2d"); x.drawImage(im, 0, 0); return x.getImageData(0, 0, cv.width, cv.height).data; };
|
|
114
|
+
const p = data(A), q = data(C); let sum = 0, max = 0, off = 0;
|
|
115
|
+
for (let i = 0; i < p.length; i += 4) {
|
|
116
|
+
let px = 0;
|
|
117
|
+
for (let c = 0; c < 4; c++) { const d = Math.abs(p[i + c] - q[i + c]); sum += d; if (d > px) px = d; }
|
|
118
|
+
if (px > max) max = px; if (px > 24) off++;
|
|
119
|
+
}
|
|
120
|
+
return { mean: sum / p.length, max, off: (100 * off * 4) / p.length };
|
|
121
|
+
}, [url(a), url(c)]);
|
|
122
|
+
return { diff, close: () => page.close() };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Every file present and the one approved (its sha256), then every file the kit can draw drawn again from the
|
|
127
|
+
* recipes and compared: within its tolerance it is ok; up to 4 levels a warning to look at; past that a failure,
|
|
128
|
+
* because the kit no longer draws what was approved (a regression, or a design change that needs approving).
|
|
129
|
+
*/
|
|
130
|
+
export async function check({ quiet = false } = {}) {
|
|
131
|
+
const idx = load(), rows = [];
|
|
132
|
+
const row = (ok, gate, msg, warn = false) => rows.push({ ok, warn, gate, msg });
|
|
133
|
+
for (const a of idx.assets) {
|
|
134
|
+
const bad = a.files.filter((f) => { const p = resolve(f.path); return !fs.existsSync(p) || sha256(p) !== f.sha256; });
|
|
135
|
+
row(!bad.length, a.id, bad.length ? `not the approved file: ${bad.map((f) => f.path).join(", ")}` : `${a.files.length} file${a.files.length === 1 ? "" : "s"}, as approved`);
|
|
136
|
+
}
|
|
137
|
+
// the redraw: one social job per brand that has recipes, into a temp folder, compared file by file
|
|
138
|
+
const { social } = await import("./social.js");
|
|
139
|
+
const { browser } = await import("./engine.js");
|
|
140
|
+
for (const [brand, spec] of Object.entries(idx.recipes || {})) {
|
|
141
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `wand-library-${brand}-`));
|
|
142
|
+
const r = await social({ ...spec, brand, name: `library-${brand}` }, { dir, zip: false, quiet: true });
|
|
143
|
+
if (!r.ok) { row(false, `redraw ${brand}`, r.rows.filter((x) => !x.ok && !x.warn).map((x) => `${x.gate}: ${x.msg}`).join("; ")); continue; }
|
|
144
|
+
const out = r.out, byId = Object.fromEntries(r.images.map((i) => [i.id, i]));
|
|
145
|
+
const d = await differ(await browser());
|
|
146
|
+
for (const a of idx.assets.filter((x) => x.brand === brand && x.recipe)) {
|
|
147
|
+
const img = byId[a.recipe];
|
|
148
|
+
if (!img) { row(false, `redraw ${a.id}`, `no card "${a.recipe}" in the ${brand} recipes`); continue; }
|
|
149
|
+
const notes = [];
|
|
150
|
+
let worst = 0, worstOff = 0, failed = false, warned = false;
|
|
151
|
+
for (const f of a.files.filter((x) => x.from)) {
|
|
152
|
+
const mine = resolve(f.path);
|
|
153
|
+
if (f.from === "#tags") { if (fs.readFileSync(mine, "utf8") !== img.tags) { failed = true; notes.push(`${path.basename(f.path)} differs from the tags the kit writes`); } continue; }
|
|
154
|
+
const theirs = path.join(out, f.from);
|
|
155
|
+
if (!fs.existsSync(theirs)) { failed = true; notes.push(`the kit drew no ${f.from}`); continue; }
|
|
156
|
+
// an .ico holds PNGs, whose bytes differ between browser builds even where their pixels do not: compare each frame
|
|
157
|
+
const pairs = /\.ico$/i.test(f.from) ? icoFrames(mine).map((a, i) => [a, icoFrames(theirs)[i]]) : /\.png$/i.test(f.from) ? [[mine, theirs]] : null;
|
|
158
|
+
if (!pairs) { if (fs.readFileSync(mine, "utf8") !== fs.readFileSync(theirs, "utf8")) { failed = true; notes.push(`${f.from} differs`); } continue; }
|
|
159
|
+
if (pairs.some(([, b]) => !b)) { failed = true; notes.push(`${f.from}: not the same frames`); continue; }
|
|
160
|
+
const each = await Promise.all(pairs.map(([a, b]) => d.diff(a, b)));
|
|
161
|
+
const r2 = each.find((x) => x.size) || { mean: Math.max(...each.map((x) => x.mean)), off: Math.max(...each.map((x) => x.off)) };
|
|
162
|
+
if (r2.size) { failed = true; notes.push(`${f.from}: ${r2.size}`); continue; }
|
|
163
|
+
// a mean alone hides a moved line of type in a large image; the share of pixels visibly off (over 24 levels) does not
|
|
164
|
+
const tol = { mean: 1, off: 0.25, ...(f.tolerance || {}) }, said = `${f.from}: ${r2.mean.toFixed(2)} levels, ${r2.off.toFixed(2)}% of pixels visibly off`;
|
|
165
|
+
worst = Math.max(worst, r2.mean); worstOff = Math.max(worstOff, r2.off);
|
|
166
|
+
if (r2.mean > 4 || r2.off > 2) { failed = true; notes.push(said); }
|
|
167
|
+
else if (r2.mean > tol.mean || r2.off > tol.off) { warned = true; notes.push(`${said} (allowed ${tol.mean}, ${tol.off}%)`); }
|
|
168
|
+
}
|
|
169
|
+
row(!failed && !warned, `redraw ${a.id}`, failed || warned ? `${notes.join("; ")}; the kit's redraw is in ${out}` : `the kit draws it within ${worst.toFixed(2)} levels in 255, ${worstOff.toFixed(2)}% of pixels visibly off`, warned && !failed);
|
|
170
|
+
}
|
|
171
|
+
await d.close();
|
|
172
|
+
}
|
|
173
|
+
const ok = rows.every((r) => r.ok || r.warn);
|
|
174
|
+
if (!quiet) {
|
|
175
|
+
console.log("");
|
|
176
|
+
for (const r of rows) console.log(` ${r.ok ? " ok " : r.warn ? " -- " : "FAIL"} ${r.gate.padEnd(34)} ${r.msg}`);
|
|
177
|
+
console.log(`\n ${idx.assets.length} assets, ${idx.assets.reduce((n, a) => n + a.files.length, 0)} files${ok ? "" : "\n\n FAILED — a file is not the one approved, or the kit no longer draws it"}\n`);
|
|
178
|
+
}
|
|
179
|
+
return { ok, rows };
|
|
180
|
+
}
|
package/lib/pptx.js
CHANGED
|
@@ -238,7 +238,7 @@ export async function exportPptx({ htmlPath, outPath, brand: brandName, surface,
|
|
|
238
238
|
|
|
239
239
|
const msg = `${pages.length} slide${pages.length === 1 ? "" : "s"}, ${lines} text lines${links ? `, ${links} links` : ""}, ${fonts}`;
|
|
240
240
|
if (missing.length) return { ok: false, warn: false, msg: `${missing.length} of ${lines} lines not verbatim in the XML: ${missing.slice(0, 3).join("; ")}` };
|
|
241
|
-
return { ok:
|
|
241
|
+
return { ok: !warn, warn, msg };
|
|
242
242
|
}
|
|
243
243
|
|
|
244
244
|
/** A block of several lines (a title, a paragraph) moves as one: a group. */
|
package/lib/social.js
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/social.js — the social set, drawn from a brand's approved composition.
|
|
3
|
+
*
|
|
4
|
+
* A brand that carries one (`tokens.art.composition`: on the interim brand, the
|
|
5
|
+
* approved X header, LinkedIn banners and avatar measured into one artwork)
|
|
6
|
+
* draws its social images from it, not from a page: every format is a window
|
|
7
|
+
* onto the same art at one scale, so a banner, a share image and a profile
|
|
8
|
+
* photo are the approved design at that size, not an imitation of it. The
|
|
9
|
+
* brand's `lib/composition.mjs` writes each page; this renders them, reads each
|
|
10
|
+
* card's gate, and writes the PNGs, the share and icon tags, INSTALL.md (where
|
|
11
|
+
* each file goes and how to check it once it is live), a preview of each image
|
|
12
|
+
* where it will be seen, and a zip of the hand-over.
|
|
13
|
+
*
|
|
14
|
+
* node wand.js social <social.json> [--brand interim]
|
|
15
|
+
*
|
|
16
|
+
* social.json:
|
|
17
|
+
* { "name": "wand-social",
|
|
18
|
+
* "site": "Wand", og:site_name, and the icons' app name
|
|
19
|
+
* "line": { "lead": "A new workforce.", "phrase": "A new operating era." }, the words, unless a card names its own
|
|
20
|
+
* "upload": "https://wand.ai/assets/og/20261023/", where the share images will live
|
|
21
|
+
* "cards": [ { "kind": "linkedin-banner" }, { "kind": "x-header" }, { "kind": "linkedin-company" },
|
|
22
|
+
* { "kind": "share", "id": "wand-ai-share", "url": "https://wand.ai/", "title": "Wand AI", "description": "...", "alt": "...",
|
|
23
|
+
* "before": "https://wand.ai/assets/og/wand-share.png" }, the image live now, shown above the new one
|
|
24
|
+
* { "kind": "share", "id": "article-share", "url": "...", "lead": "An Open Agentic Web", "phrase": "Needs Open Discovery", ... },
|
|
25
|
+
* { "kind": "square" }, { "kind": "avatar" },
|
|
26
|
+
* { "kind": "icons", "upload": "/assets/20261022/" }, favicons, app icons, the manifest
|
|
27
|
+
* { "kind": "profile-photo", "photo": "me-cutout.png" } ] } a cut-out (transparent ground) over the banner's art
|
|
28
|
+
*/
|
|
29
|
+
import fs from "node:fs";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
import { createRequire } from "node:module";
|
|
32
|
+
import { pathToFileURL } from "node:url";
|
|
33
|
+
import { loadBrand } from "./brand.js";
|
|
34
|
+
import { browser } from "./engine.js";
|
|
35
|
+
|
|
36
|
+
const require = createRequire(import.meta.url);
|
|
37
|
+
const LIMIT = 300 * 1024; // WhatsApp drops a larger link preview
|
|
38
|
+
export const KINDS = ["linkedin-banner", "x-header", "linkedin-company", "share", "square", "avatar", "profile-photo", "icons"];
|
|
39
|
+
|
|
40
|
+
// the icon set from the brand's avatar: favicons are the disc on transparent; the apple-touch-icon and the manifest
|
|
41
|
+
// icons are the disc's colour full bleed, because iOS and Android cut their own shape (runs/2026-10-22-wand-og)
|
|
42
|
+
const ICONS = [["favicon-16", 16, "disc"], ["favicon-32", 32, "disc"], ["favicon-48", 48, "disc"],
|
|
43
|
+
["apple-touch-icon", 180, "square"], ["icon-192", 192, "square"], ["icon-512", 512, "square"]];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Draw a social set. `spec` is the path of a social.json, or the spec itself with `dir` naming where relative paths
|
|
47
|
+
* resolve and the output goes. `zip: false` and `quiet: true` are for callers that only want the files (library --check).
|
|
48
|
+
*/
|
|
49
|
+
export async function social(spec, { brand: brandOverride = null, dir = null, zip = true, quiet = false } = {}) {
|
|
50
|
+
const t0 = Date.now();
|
|
51
|
+
if (typeof spec === "string") { const file = path.resolve(spec); dir = path.dirname(file); spec = JSON.parse(fs.readFileSync(file, "utf8")); }
|
|
52
|
+
dir = path.resolve(dir || process.cwd());
|
|
53
|
+
const brandName = brandOverride || spec.brand || "interim";
|
|
54
|
+
const brand = loadBrand(brandName);
|
|
55
|
+
if (!brand.tokens.art.composition) throw new Error(`the ${brandName} brand has no approved social composition (the interim brand has one); for social cards on it, use --surface og or square`);
|
|
56
|
+
const { composition } = await import(pathToFileURL(path.join(brand.dir, "lib", "composition.mjs")).href);
|
|
57
|
+
const C = composition(brand.dir), F = C.comp.formats;
|
|
58
|
+
const out = path.join(dir, brandOverride && brandOverride !== (spec.brand || "interim") ? `out-${brandOverride}` : "out");
|
|
59
|
+
const work = path.join(out, "work");
|
|
60
|
+
fs.rmSync(out, { recursive: true, force: true }); fs.mkdirSync(work, { recursive: true });
|
|
61
|
+
const name = spec.name || path.basename(dir);
|
|
62
|
+
const line = spec.line || {};
|
|
63
|
+
const mode = spec.mode || "dark";
|
|
64
|
+
const log = quiet ? () => {} : console.log;
|
|
65
|
+
|
|
66
|
+
const pages = [], rows = [];
|
|
67
|
+
for (const [i, card] of (spec.cards || []).entries()) {
|
|
68
|
+
if (!KINDS.includes(card.kind)) throw new Error(`card ${i + 1}: unknown kind "${card.kind}" (one of ${KINDS.join(", ")})`);
|
|
69
|
+
const id = card.id || card.kind;
|
|
70
|
+
if (card.kind === "icons") { pages.push({ card, id, icons: true }); continue; }
|
|
71
|
+
const f = F[card.kind === "profile-photo" ? "avatar" : card.kind];
|
|
72
|
+
const text = { lead: card.lead || line.lead, phrase: card.phrase || line.phrase };
|
|
73
|
+
let html;
|
|
74
|
+
if (card.kind === "avatar") {
|
|
75
|
+
html = avatarPage(fs.readFileSync(path.join(brand.dir, "assets", brand.tokens.art.avatar), "utf8"), f);
|
|
76
|
+
} else if (card.kind === "profile-photo") {
|
|
77
|
+
// the banner's art behind the profile photo's circle, continued below the banner's edge, the cut-out over it
|
|
78
|
+
const b = F["linkedin-banner"], p = b.profile, r = (p.d * b.w) / 2 / b.s;
|
|
79
|
+
const cx = (p.cx * b.w) / b.s + b.x0, cy = (p.cy * b.h) / b.s + b.y0;
|
|
80
|
+
if (card.photo && !fs.existsSync(path.resolve(dir, card.photo))) throw new Error(`card ${i + 1}: photo ${card.photo} not found`);
|
|
81
|
+
html = C.page({ id, w: f.w, h: f.h, s: f.w / (2 * r), x0: cx - r, y0: cy - r, mode, lockup: false, photo: card.photo ? path.resolve(dir, card.photo) : null });
|
|
82
|
+
} else if (card.kind === "share") {
|
|
83
|
+
html = C.page({ id, w: f.w, h: f.h, mode, ...C.window(f, f.window), set: { ...f.set, ...(card.set || {}) }, text, rules: f.rules });
|
|
84
|
+
} else if (card.kind === "square") {
|
|
85
|
+
// the art's right half on its diagonal, the lockup in the corner it leaves, every part at one scale
|
|
86
|
+
const [bx0, by0, bx1, by1] = C.comp.ring.box, x0 = (bx0 + bx1) / 2 - f.ringCentre[0] / f.s, y0 = (by0 + by1) / 2 - f.ringCentre[1] / f.s;
|
|
87
|
+
const wm = C.comp.lockup.wordmark, P = C.paths(mode), sparkTop = wm.top + ((P.sparkTop - P.lettersTop) * wm.height) / P.lettersH;
|
|
88
|
+
html = C.page({ id, w: f.w, h: f.h, s: f.s, x0, y0, mode, text, rules: f.rules,
|
|
89
|
+
lockup: { dx: x0 + f.lockupAt[0] / f.s - C.comp.lockup.lead.inkLeft, dy: y0 + f.lockupAt[1] / f.s - sparkTop } });
|
|
90
|
+
} else {
|
|
91
|
+
html = C.page({ id, w: f.w, h: f.h, s: f.s, x0: f.x0, y0: f.y0, mode, text, lockup: f.lockup || {}, ring: f.ring || {}, rules: f.rules });
|
|
92
|
+
}
|
|
93
|
+
const htmlPath = path.join(work, `${id}.html`);
|
|
94
|
+
fs.writeFileSync(htmlPath, html);
|
|
95
|
+
pages.push({ card, id, f, htmlPath, png: path.join(out, `${id}-${f.w}x${f.h}.png`) });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const b = await browser();
|
|
99
|
+
for (const p of pages) {
|
|
100
|
+
if (p.icons) { rows.push(await icons(b, p, { brand, out, site: spec.site })); continue; }
|
|
101
|
+
const page = await b.newPage({ viewport: { width: p.f.w, height: p.f.h }, deviceScaleFactor: p.f.scale || 1 });
|
|
102
|
+
const errors = []; page.on("pageerror", (e) => errors.push(String(e)));
|
|
103
|
+
await page.goto(pathToFileURL(p.htmlPath).href, { waitUntil: "load" });
|
|
104
|
+
await page.waitForSelector("#gate", { state: "attached", timeout: 30000 });
|
|
105
|
+
p.gate = JSON.parse(await page.$eval("#gate", (e) => e.textContent));
|
|
106
|
+
await page.screenshot({ path: p.png, clip: { x: 0, y: 0, width: p.f.w, height: p.f.h } });
|
|
107
|
+
// a share image over WhatsApp's limit goes as a JPEG at high quality instead: the same picture, a quarter the bytes
|
|
108
|
+
if (p.card.kind === "share" && fs.statSync(p.png).size > LIMIT) {
|
|
109
|
+
const jpg = p.png.replace(/\.png$/, ".jpg");
|
|
110
|
+
await page.screenshot({ path: jpg, type: "jpeg", quality: 92, clip: { x: 0, y: 0, width: p.f.w, height: p.f.h } });
|
|
111
|
+
fs.rmSync(p.png); p.png = jpg;
|
|
112
|
+
}
|
|
113
|
+
await page.close();
|
|
114
|
+
p.files = [p.png];
|
|
115
|
+
const kb = Math.round(fs.statSync(p.png).size / 1024), over = p.card.kind === "share" && kb * 1024 > LIMIT;
|
|
116
|
+
const size = `${p.f.w * (p.f.scale || 1)} x ${p.f.h * (p.f.scale || 1)}, ${kb} KB`;
|
|
117
|
+
rows.push({ ok: p.gate.ok && !errors.length && !over, gate: p.id, msg: p.gate.ok ? `${size}${p.gate.lockup ? `, type ${p.gate.typePx}px` : ""}${errors.length ? `; console: ${errors[0]}` : ""}${over ? "; over 300 KB" : ""}` : p.gate.reasons.join("; ") });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// the tags, one block a page for the share images, absolute https URLs, one twitter:card; then the icons' block
|
|
121
|
+
const shares = pages.filter((p) => p.card.kind === "share"), iconSet = pages.find((p) => p.icons);
|
|
122
|
+
if (shares.length || iconSet) {
|
|
123
|
+
const missing = shares.filter((p) => !p.card.url || !p.card.title);
|
|
124
|
+
for (const p of shares) p.tags = shareTags(p, { upload: spec.upload, site: spec.site, line });
|
|
125
|
+
const blocks = [...shares.map((p) => p.tags), ...(iconSet ? [iconSet.tags] : [])];
|
|
126
|
+
fs.writeFileSync(path.join(out, "head-tags.html"), blocks.join("\n"));
|
|
127
|
+
const dash = shares.filter((p) => /—/.test(`${p.card.title} ${p.card.description} ${p.card.alt}`));
|
|
128
|
+
if (shares.length) rows.push({ ok: !missing.length && !dash.length, warn: !dash.length, gate: "tags", msg: dash.length ? `em dash in the tags of ${dash.map((p) => p.id).join(", ")}` : missing.length ? `${missing.map((p) => p.id).join(", ")}: no url or title, so its tags are placeholders` : `head-tags.html, ${shares.length} page${shares.length === 1 ? "" : "s"}${spec.upload ? "" : " (set `upload` for the image URLs)"}` });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// the image each page shares today, if the spec names it: shown above the new one, so the change is visible
|
|
132
|
+
for (const p of shares.filter((q) => q.card.before)) {
|
|
133
|
+
try { p.before = await fetchTo(p.card.before, path.join(work, `before-${p.id}`), dir); }
|
|
134
|
+
catch (e) { rows.push({ ok: false, warn: true, gate: "before", msg: `${p.id}: ${e.message}` }); }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// each image where it is seen: a profile, the feeds' crops of a share image, a square, an avatar, the icons
|
|
138
|
+
const sheet = path.join(out, "preview.png");
|
|
139
|
+
const ph = previewHtml(pages, F);
|
|
140
|
+
fs.writeFileSync(path.join(work, "preview.html"), ph.html);
|
|
141
|
+
const pv = await b.newPage({ viewport: { width: ph.w, height: 400 }, deviceScaleFactor: 1 });
|
|
142
|
+
await pv.goto(pathToFileURL(path.join(work, "preview.html")).href, { waitUntil: "load" });
|
|
143
|
+
await pv.evaluate(() => Promise.all([...document.images].map((i) => (i.complete ? null : new Promise((r) => { i.onload = i.onerror = r; })))));
|
|
144
|
+
await pv.screenshot({ path: sheet, fullPage: true });
|
|
145
|
+
await pv.close();
|
|
146
|
+
|
|
147
|
+
// the hand-over: where each file goes and how to check it once live, then everything in one zip
|
|
148
|
+
fs.writeFileSync(path.join(out, "INSTALL.md"), install(pages, { upload: spec.upload, brand: brand.name, out }));
|
|
149
|
+
const files = pages.flatMap((p) => p.files || []);
|
|
150
|
+
let zipPath = null;
|
|
151
|
+
if (zip) {
|
|
152
|
+
const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
|
153
|
+
zipPath = path.join(out, `${name}-${stamp}.zip`);
|
|
154
|
+
const JSZip = require("jszip"), z = new JSZip();
|
|
155
|
+
for (const f of [...files, ...["head-tags.html", "INSTALL.md", "preview.png"].map((x) => path.join(out, x)).filter((x) => fs.existsSync(x))]) z.file(path.relative(out, f), fs.readFileSync(f));
|
|
156
|
+
fs.writeFileSync(zipPath, await z.generateAsync({ type: "nodebuffer", compression: "DEFLATE" }));
|
|
157
|
+
}
|
|
158
|
+
rows.push({ ok: true, gate: "renders", msg: `${files.length} file${files.length === 1 ? "" : "s"} in ${path.relative(process.cwd(), out)}/, ${path.relative(process.cwd(), sheet)}, INSTALL.md${zipPath ? `, ${path.basename(zipPath)}` : ""}` });
|
|
159
|
+
|
|
160
|
+
const report = { ok: rows.every((r) => r.ok || r.warn), rows, brand: brand.name, name, out, zip: zipPath,
|
|
161
|
+
images: pages.map((p) => ({ id: p.id, kind: p.card.kind, png: p.png || null, files: p.files || [], tags: p.tags || null, gate: p.gate || null })), ms: Date.now() - t0 };
|
|
162
|
+
fs.writeFileSync(path.join(work, "report.json"), JSON.stringify(report, null, 1));
|
|
163
|
+
log("");
|
|
164
|
+
for (const r of rows) log(` ${r.ok ? " ok " : r.warn ? " -- " : "FAIL"} ${r.gate.padEnd(16)} ${r.msg}`);
|
|
165
|
+
log(`\n ${pages.length} social cards, ${brand.name} brand, ${((Date.now() - t0) / 1000).toFixed(1)}s`);
|
|
166
|
+
// what no gate sees: read the preview the way the people scrolling past will
|
|
167
|
+
log(`\n review ${path.relative(process.cwd(), sheet)}, as a creative director:\n at LinkedIn's compact size (the 128pt card), do the wordmark and the words still read? is there anything too small to?\n does Slack's square keep the whole lockup? does the phrase end where the trails' dots meet it?\n is the ring whole and clear of the words? on the profiles, is the lockup clear of the photo's circle?`);
|
|
168
|
+
log(report.ok ? "" : "\n FAILED — fix the rows marked FAIL\n");
|
|
169
|
+
return report;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const text0 = (card, line) => ["Wand.", card.lead || line.lead, card.phrase || line.phrase].filter(Boolean).join(" ");
|
|
173
|
+
const esc = (t) => String(t ?? "").replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
174
|
+
|
|
175
|
+
/** One page's share block: og and twitter tags with the image's absolute URL, type, size and alt. */
|
|
176
|
+
function shareTags({ card, png, f }, { upload, site, line }) {
|
|
177
|
+
const img = (upload || "https://example.com/og/") + path.basename(png), alt = card.alt || text0(card, line);
|
|
178
|
+
return `<!-- ${card.url || "the page's URL"} -->
|
|
179
|
+
<meta property="og:type" content="${card.type || (card.url && /\/$/.test(card.url) ? "website" : "article")}">
|
|
180
|
+
${site ? `<meta property="og:site_name" content="${esc(site)}">\n` : ""}<meta property="og:title" content="${esc(card.title)}">
|
|
181
|
+
<meta property="og:description" content="${esc(card.description)}">
|
|
182
|
+
<meta property="og:url" content="${esc(card.url)}">
|
|
183
|
+
<meta property="og:image" content="${img}">
|
|
184
|
+
<meta property="og:image:secure_url" content="${img}">
|
|
185
|
+
<meta property="og:image:type" content="image/${png.endsWith(".jpg") ? "jpeg" : "png"}">
|
|
186
|
+
<meta property="og:image:width" content="${f.w}">
|
|
187
|
+
<meta property="og:image:height" content="${f.h}">
|
|
188
|
+
<meta property="og:image:alt" content="${esc(alt)}">
|
|
189
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
190
|
+
<meta name="twitter:title" content="${esc(card.title)}">
|
|
191
|
+
<meta name="twitter:description" content="${esc(card.description)}">
|
|
192
|
+
<meta name="twitter:image" content="${img}">
|
|
193
|
+
<meta name="twitter:image:alt" content="${esc(alt)}">
|
|
194
|
+
`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The avatar: the brand's avatar.svg full bleed, its disc as the square, since every platform cuts its own circle. */
|
|
198
|
+
function avatarPage(svg, f) {
|
|
199
|
+
const body = svg.replace(/<circle ([^>]*)fill="(#[0-9a-fA-F]{6})"\s*\/>/, (m, a, fill) => `<rect width="100%" height="100%" fill="${fill}"/>`);
|
|
200
|
+
return `<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0}svg{display:block;width:${f.w}px;height:${f.h}px}</style></head><body>${body}
|
|
201
|
+
<script type="application/json" id="gate">{"ok":true,"reasons":[]}</script></body></html>`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The icon set from the brand's avatar.svg into out/icons/: PNGs at each size, favicon.ico (16, 32 and 48, PNG
|
|
206
|
+
* inside), favicon.svg, site.webmanifest; and the <link> block every page carries, kept for head-tags.html.
|
|
207
|
+
*/
|
|
208
|
+
async function icons(b, p, { brand, out, site }) {
|
|
209
|
+
const svg = fs.readFileSync(path.join(brand.dir, "assets", brand.tokens.art.avatar), "utf8");
|
|
210
|
+
const dir = path.join(out, "icons"); fs.mkdirSync(dir, { recursive: true });
|
|
211
|
+
const ground = brand.roles.dark.bg, disc = (svg.match(/<circle [^>]*fill="#([0-9a-fA-F]{6})"/) || [])[1];
|
|
212
|
+
if (!disc) throw new Error("icons: the brand's avatar.svg has no disc (a <circle> with a fill)");
|
|
213
|
+
const files = {};
|
|
214
|
+
for (const [name, size, shape] of ICONS) {
|
|
215
|
+
const body = shape === "disc" ? svg : svg.replace(/<circle [^>]*fill="(#[0-9a-fA-F]{6})"\s*\/>/, '<rect width="100%" height="100%" fill="$1"/>');
|
|
216
|
+
const page = await b.newPage({ viewport: { width: size, height: size }, deviceScaleFactor: 1 });
|
|
217
|
+
await page.setContent(`<!doctype html><html><head><style>html,body{margin:0;background:transparent}svg{display:block;width:${size}px;height:${size}px}</style></head><body>${body}</body></html>`);
|
|
218
|
+
const f = path.join(dir, `${name}.png`);
|
|
219
|
+
await page.screenshot({ path: f, omitBackground: shape === "disc", clip: { x: 0, y: 0, width: size, height: size } });
|
|
220
|
+
await page.close();
|
|
221
|
+
files[name] = f;
|
|
222
|
+
}
|
|
223
|
+
const ico = path.join(dir, "favicon.ico");
|
|
224
|
+
fs.writeFileSync(ico, icoFile([16, 32, 48].map((s) => ({ size: s, png: fs.readFileSync(files[`favicon-${s}`]) }))));
|
|
225
|
+
const fav = path.join(dir, "favicon.svg");
|
|
226
|
+
fs.writeFileSync(fav, svg.trim() + "\n");
|
|
227
|
+
const manifest = path.join(dir, "site.webmanifest");
|
|
228
|
+
fs.writeFileSync(manifest, JSON.stringify({ name: site || "Wand", short_name: site || "Wand",
|
|
229
|
+
icons: [192, 512].map((s) => ({ src: `icon-${s}.png`, sizes: `${s}x${s}`, type: "image/png" })),
|
|
230
|
+
theme_color: `#${ground}`, background_color: `#${ground}`, display: "standalone" }, null, 2) + "\n");
|
|
231
|
+
const base = p.card.upload || "/";
|
|
232
|
+
p.tags = `<!-- every page: the icons (the files in icons/, uploaded to ${base}) -->
|
|
233
|
+
<link rel="icon" href="${base}favicon.ico" sizes="32x32">
|
|
234
|
+
<link rel="icon" type="image/svg+xml" href="${base}favicon.svg">
|
|
235
|
+
<link rel="apple-touch-icon" href="${base}apple-touch-icon.png">
|
|
236
|
+
<link rel="manifest" href="${base}site.webmanifest">
|
|
237
|
+
`;
|
|
238
|
+
p.files = [ico, fav, ...Object.values(files), manifest];
|
|
239
|
+
p.png = files["icon-512"];
|
|
240
|
+
const kb = Math.round(p.files.reduce((a, f) => a + fs.statSync(f).size, 0) / 1024);
|
|
241
|
+
return { ok: true, gate: p.id, msg: `${p.files.length} files in icons/ (favicon.ico 16/32/48, favicon.svg, ${ICONS.map((i) => i[1]).join(", ")} px, site.webmanifest), ${kb} KB` };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** An .ico holding PNGs, which every browser since IE 11 reads: a 6-byte header, a 16-byte entry each, the files. */
|
|
245
|
+
function icoFile(images) {
|
|
246
|
+
const head = Buffer.alloc(6 + 16 * images.length);
|
|
247
|
+
head.writeUInt16LE(0, 0); head.writeUInt16LE(1, 2); head.writeUInt16LE(images.length, 4);
|
|
248
|
+
let at = head.length;
|
|
249
|
+
images.forEach(({ size, png }, i) => {
|
|
250
|
+
const o = 6 + 16 * i;
|
|
251
|
+
head.writeUInt8(size >= 256 ? 0 : size, o); head.writeUInt8(size >= 256 ? 0 : size, o + 1);
|
|
252
|
+
head.writeUInt16LE(1, o + 4); head.writeUInt16LE(32, o + 6);
|
|
253
|
+
head.writeUInt32LE(png.length, o + 8); head.writeUInt32LE(at, o + 12);
|
|
254
|
+
at += png.length;
|
|
255
|
+
});
|
|
256
|
+
return Buffer.concat([head, ...images.map((x) => x.png)]);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** A file or an https URL, copied to `to` (+ its extension); the path it landed at. */
|
|
260
|
+
export async function fetchTo(src, to, dir = process.cwd()) {
|
|
261
|
+
if (/^https?:\/\//.test(src)) {
|
|
262
|
+
let r;
|
|
263
|
+
try { r = await fetch(src, { signal: AbortSignal.timeout(15000), headers: { "user-agent": "Mozilla/5.0 (wand-kit social preview)" } }); }
|
|
264
|
+
catch (e) { throw new Error(`could not fetch ${src} from here (${e.cause?.code || e.name}); save it and pass the file instead`); }
|
|
265
|
+
if (!r.ok) throw new Error(`${src} answered ${r.status}`);
|
|
266
|
+
const buf = Buffer.from(await r.arrayBuffer()), f = `${to}${ext(buf)}`;
|
|
267
|
+
fs.writeFileSync(f, buf); return f;
|
|
268
|
+
}
|
|
269
|
+
const p = path.resolve(dir, src);
|
|
270
|
+
if (!fs.existsSync(p)) throw new Error(`${src} not found`);
|
|
271
|
+
const f = `${to}${path.extname(p) || ext(fs.readFileSync(p))}`;
|
|
272
|
+
fs.copyFileSync(p, f); return f;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const ext = (buf) => (buf[0] === 0x89 && buf[1] === 0x50 ? ".png" : buf[0] === 0xff && buf[1] === 0xd8 ? ".jpg" : buf.slice(8, 12).toString() === "WEBP" ? ".webp" : buf.slice(0, 3).toString() === "GIF" ? ".gif" : ".img");
|
|
276
|
+
|
|
277
|
+
/** Where each kind goes, and how to know it worked. One file a hand-over carries, so the person uploading needs nothing else. */
|
|
278
|
+
function install(pages, { upload, brand, out }) {
|
|
279
|
+
const has = (k) => pages.filter((p) => p.card.kind === k);
|
|
280
|
+
const name = (p) => (p.files || []).map((f) => `\`${path.relative(out, f)}\``).join(", ");
|
|
281
|
+
const s = [`# Installing the social set`, ``, `Drawn on the ${brand} brand from its approved composition by wand-kit. \`preview.png\` shows each image where it will be seen. Nothing here is verified until it is live: check each place after uploading.`, ``];
|
|
282
|
+
const one = (title, list, steps) => { if (list.length) s.push(`## ${title}`, ``, `${list.map(name).join(", ")}`, ``, ...steps, ``); };
|
|
283
|
+
one("LinkedIn profile banner", has("linkedin-banner"), ["- LinkedIn, your profile: the pencil on the banner, Upload, then Apply. LinkedIn shows it at 1584 x 396; the file is twice that so it stays sharp.", "- On a phone the banner is cut at the sides and the photo sits elsewhere; the lockup is placed for the desktop profile."]);
|
|
284
|
+
one("X header", has("x-header"), ["- X, Edit profile: the header image. X shows 1500 x 500 and trims a little from the top and bottom on phones."]);
|
|
285
|
+
one("LinkedIn company page banner", has("linkedin-company"), ["- The company page, as an admin: Edit page, Header, upload. LinkedIn shows it at 1128 x 191."]);
|
|
286
|
+
one("Profile picture", [...has("avatar"), ...has("profile-photo")], ["- Upload the square; every platform cuts its own circle. The profile photo that continues the banner lines up on LinkedIn's desktop profile."]);
|
|
287
|
+
one("Square post", has("square"), ["- A 1080 x 1080 post for LinkedIn, X or Instagram."]);
|
|
288
|
+
if (has("share").length) {
|
|
289
|
+
const urls = has("share").map((p) => p.card.url).filter(Boolean);
|
|
290
|
+
s.push(`## Share images (link previews)`, ``, `${has("share").map(name).join(", ")}, and \`head-tags.html\``, ``,
|
|
291
|
+
`1. Upload the images to ${upload ? `\`${upload}\`` : "a new folder on the site"}. Always a new folder or file name: every platform caches an image by its URL, so a replaced file at the same address keeps showing the old one.`,
|
|
292
|
+
`2. Paste each page's block from \`head-tags.html\` into that page's \`<head>\`, replacing the \`og:\` and \`twitter:\` tags there now. The og:title does not repeat the image's words.`,
|
|
293
|
+
`3. LinkedIn: Post Inspector re-reads the page on every inspect.${urls.map((u) => `\n - https://www.linkedin.com/post-inspector/inspect/${encodeURIComponent(u)}`).join("")}`,
|
|
294
|
+
`4. WhatsApp and Facebook: the Sharing Debugger, Scrape Again.${urls.map((u) => `\n - https://developers.facebook.com/tools/debug/?q=${encodeURIComponent(u)}`).join("")}`,
|
|
295
|
+
`5. Slack: paste each link in a message to yourself once its cache (about 30 minutes) has passed. X fetches a link it has not seen.`,
|
|
296
|
+
``, `A post made before the change may keep its old preview; a new share takes the new image. Which card a platform shows (large, or LinkedIn's compact one in a post with text) is its choice: the images are built to read in both.`, ``);
|
|
297
|
+
}
|
|
298
|
+
const ic = has("icons");
|
|
299
|
+
if (ic.length) s.push(`## Site icons`, ``, `Everything in \`icons/\`, and the icon block in \`head-tags.html\``, ``,
|
|
300
|
+
`1. Upload the files in \`icons/\` to \`${ic[0].card.upload || "/"}\`.`, `2. Paste the icon block from \`head-tags.html\` into every page's \`<head>\`, replacing the icon and manifest links there now.`,
|
|
301
|
+
`3. Browsers keep a favicon for a long time: check in a private window, and add the site to a phone's home screen to see the app icon.`, ``);
|
|
302
|
+
return s.join("\n");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const FEEDS_CSS = `.row{display:flex;gap:20px;margin-bottom:16px;align-items:flex-start}
|
|
306
|
+
.li{display:flex;gap:16px;background:#fff;color:#191919;border:1px solid #e0dfdc;border-radius:10px;padding:14px;width:470px}
|
|
307
|
+
.li img{width:128px;height:72px;object-fit:cover;border-radius:8px}.li span{display:block;color:#666;margin-top:6px}
|
|
308
|
+
.slack{display:flex;gap:14px;background:#1a1d21;border:1px solid #35373b;border-radius:8px;padding:14px;width:470px}
|
|
309
|
+
.slack p{color:#d1d2d3;margin:6px 0 0;font-size:13px}.slack img{width:80px;height:80px;object-fit:cover;border-radius:6px;flex:none}
|
|
310
|
+
.x img{width:470px;height:235px;object-fit:cover;border-radius:16px;display:block}
|
|
311
|
+
.wa{display:flex;gap:12px;align-items:center;background:#d9fdd3;color:#111;border-radius:10px;padding:10px;width:470px}.wa img{width:72px;height:72px;object-fit:cover;border-radius:6px}
|
|
312
|
+
.whole{position:relative;width:600px}.whole img{display:block;width:600px}.whole i{position:absolute;top:0;bottom:0;border:2px dashed #ffd400;box-sizing:border-box}`;
|
|
313
|
+
const PAGE_CSS = `body{margin:0;padding:24px 40px 40px;background:#3c3c3c;font:15px/1.4 -apple-system,Helvetica,Arial,sans-serif;color:#eee;width:1000px}
|
|
314
|
+
h2{font-size:14px;font-weight:600;margin:26px 0 10px;color:#ddd}h3{font-size:13px;font-weight:500;margin:12px 0 8px;color:#bbb}`;
|
|
315
|
+
|
|
316
|
+
/** A share image as the feeds show it: LinkedIn's compact card, Slack's square, X's 2:1, WhatsApp's thumbnail. */
|
|
317
|
+
export function feedRows(src, { title, url, description }) {
|
|
318
|
+
const host = (url || "").replace(/^https?:\/\//, "").split("/")[0] || "site";
|
|
319
|
+
return `<div class="row"><div class="li"><img src="${src}"><div><b>${esc(title)}</b><span>${esc(host)}</span></div></div>
|
|
320
|
+
<div class="slack"><div><b>${esc(title)}</b><p>${esc(description || "")}</p></div><img src="${src}"></div></div>
|
|
321
|
+
<div class="row"><div class="x"><img src="${src}"></div><div class="wa"><img src="${src}"><b>${esc(title)}</b></div></div>`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** The image whole, its centre square outlined: what Slack's and WhatsApp's cut keeps. */
|
|
325
|
+
export function wholeWithSquare(src, w, h) {
|
|
326
|
+
const k = 600 / w, side = Math.min(w, h) * k;
|
|
327
|
+
return `<div class="whole"><img src="${src}"><i style="left:${(600 - side) / 2}px;width:${side}px"></i></div>`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export const previewPage = (body, w = 1080) => ({ w, html: `<!doctype html><html><head><meta charset="utf-8"><style>${PAGE_CSS}\n${FEEDS_CSS}
|
|
331
|
+
.profile{position:relative;background:#fff;border-radius:10px;overflow:hidden}.banner{display:block}
|
|
332
|
+
.avatar{position:absolute;border-radius:50%;box-shadow:0 0 0 5px #fff}
|
|
333
|
+
.tab{display:inline-flex;gap:8px;align-items:center;background:#dee1e6;color:#222;border-radius:8px 8px 0 0;padding:8px 14px;margin-right:12px}
|
|
334
|
+
.home{display:inline-block;text-align:center;color:#ddd;font-size:12px;margin-right:16px}.home img{display:block;border-radius:22%;margin-bottom:6px}
|
|
335
|
+
</style></head><body>${body}</body></html>` });
|
|
336
|
+
|
|
337
|
+
/** One page showing each image where it lands: a LinkedIn and an X profile, and a share image in the feeds that cut and shrink it. */
|
|
338
|
+
function previewHtml(pages, F) {
|
|
339
|
+
const u = (p) => pathToFileURL(p).href;
|
|
340
|
+
const find = (k) => pages.find((p) => p.card.kind === k);
|
|
341
|
+
const photo = find("profile-photo") || find("avatar");
|
|
342
|
+
const parts = [];
|
|
343
|
+
// where each platform puts the profile photo on its banner: LinkedIn's measured on a desktop profile, X's from its layout
|
|
344
|
+
const AT = { "linkedin-banner": F["linkedin-banner"].profile, "x-header": { cx: 0.1, cy: 1, d: 0.15 } };
|
|
345
|
+
const profile = (p, label, w) => {
|
|
346
|
+
const f = F[p.card.kind], h = (w * f.h) / f.w, pr = AT[p.card.kind];
|
|
347
|
+
const d = pr.d * w, cx = pr.cx * w, cy = pr.cy * h;
|
|
348
|
+
return `<h2>${label}</h2><div class="profile" style="width:${w}px;height:${h + d * 0.62}px"><img class="banner" src="${u(p.png)}" style="width:${w}px;height:${h}px">${photo ? `<img class="avatar" src="${u(photo.png)}" style="width:${d}px;height:${d}px;left:${cx - d / 2}px;top:${cy - d / 2}px">` : ""}</div>`;
|
|
349
|
+
};
|
|
350
|
+
for (const k of ["linkedin-banner", "x-header"]) { const p = find(k); if (p) parts.push(profile(p, k === "x-header" ? "X profile" : "LinkedIn profile, desktop", 1000)); }
|
|
351
|
+
const co = find("linkedin-company"); if (co) parts.push(`<h2>LinkedIn company page</h2><img class="banner" src="${u(co.png)}" style="width:1000px">`);
|
|
352
|
+
for (const p of pages.filter((q) => q.card.kind === "share")) {
|
|
353
|
+
const meta = { title: p.card.title || p.id, url: p.card.url, description: p.card.description };
|
|
354
|
+
parts.push(`<h2>Share image: ${p.id}</h2>`);
|
|
355
|
+
if (p.before) parts.push(`<h3>Live now</h3>${feedRows(u(p.before), meta)}<h3>New</h3>`);
|
|
356
|
+
parts.push(feedRows(u(p.png), meta), wholeWithSquare(u(p.png), p.f.w, p.f.h));
|
|
357
|
+
}
|
|
358
|
+
const sq = find("square"); if (sq) parts.push(`<h2>Square</h2><img src="${u(sq.png)}" style="width:420px;border-radius:12px">`);
|
|
359
|
+
const ic = pages.find((p) => p.icons);
|
|
360
|
+
if (ic) {
|
|
361
|
+
const f = (n) => u(path.join(path.dirname(ic.png), n));
|
|
362
|
+
parts.push(`<h2>Icons</h2><div><span class="tab"><img src="${f("favicon-16.png")}" width="16" height="16">Wand</span><span class="tab"><img src="${f("favicon-32.png")}" width="32" height="32">32 px</span></div>
|
|
363
|
+
<div style="margin-top:16px"><span class="home"><img src="${f("apple-touch-icon.png")}" width="60" height="60">iPhone</span><span class="home"><img src="${f("icon-192.png")}" width="64" height="64" style="border-radius:50%">Android</span></div>`);
|
|
364
|
+
}
|
|
365
|
+
return previewPage(parts.join("\n"));
|
|
366
|
+
}
|
package/lib/talk.js
CHANGED
|
@@ -10,9 +10,11 @@
|
|
|
10
10
|
* What it adds to the pages, none of which the PDF, the PNGs or the .pptx see
|
|
11
11
|
* (they are read from the static render):
|
|
12
12
|
* - a crossfade between slides, the slide being left kept under the next;
|
|
13
|
-
* - entrances: `data-a` (rise, fade, pop, grow, widen, draw, count
|
|
14
|
-
* the stagger, played going forward; with
|
|
15
|
-
* marks nothing has its blocks staggered in
|
|
13
|
+
* - entrances: `data-a` (rise, fade, pop, grow, widen, draw, count, words,
|
|
14
|
+
* lit, window) with `--i` as the stagger, played going forward; with
|
|
15
|
+
* `motion: "auto"` a slide that marks nothing has its blocks staggered in
|
|
16
|
+
* for it, its title's words rising out of their lines, and a page's art
|
|
17
|
+
* comes up like first light;
|
|
16
18
|
* - steps: `data-step="n"` holds an element back until the presenter moves
|
|
17
19
|
* on, a group per number; going back undoes the last group;
|
|
18
20
|
* - media: a `.media.video` plays while its slide is on, a `.media.demo`
|
|
@@ -137,6 +139,19 @@ body.notes-open .viewport{grid-template-columns:1fr min(440px,34vw)}
|
|
|
137
139
|
@keyframes wa-grow{from{transform:scaleY(0)}}
|
|
138
140
|
@keyframes wa-widen{from{transform:scaleX(0)}}
|
|
139
141
|
@keyframes wa-draw{from{stroke-dashoffset:1}to{stroke-dashoffset:0}}
|
|
142
|
+
/* a title's words rise out of their lines; a statement lights word by word; a photograph opens like a window */
|
|
143
|
+
.wa-w{display:inline-block;overflow:clip;overflow-clip-margin:.14em;vertical-align:top;padding-bottom:.08em;margin-bottom:-.08em}
|
|
144
|
+
.wa-wi{display:inline-block}
|
|
145
|
+
.page.play [data-a="words"],.page.play [data-a="lit"]{animation:none}
|
|
146
|
+
.page.play [data-a="words"] .wa-wi{animation:wa-word 760ms var(--ease) both;animation-delay:calc(min(var(--i,0),12) * 70ms + min(var(--wi,0),24) * 40ms + 60ms)}
|
|
147
|
+
.page.play [data-a="lit"] .wa-wi{animation:wa-lit 420ms linear both;animation-delay:calc(min(var(--i,0),12) * 70ms + min(var(--wi,0),40) * 70ms + 240ms)}
|
|
148
|
+
.page.play [data-a="window"]{animation-name:wa-window;animation-duration:1300ms}
|
|
149
|
+
/* the art of a page (the cover's light, a statement, the close) comes up like first light */
|
|
150
|
+
.page.play>.art{animation:wa-dawn 1800ms var(--ease) both}
|
|
151
|
+
@keyframes wa-word{from{transform:translateY(105%)}}
|
|
152
|
+
@keyframes wa-lit{from{opacity:.16}}
|
|
153
|
+
@keyframes wa-window{from{clip-path:inset(16% 24%);transform:scale(1.06)}to{clip-path:inset(0)}}
|
|
154
|
+
@keyframes wa-dawn{from{opacity:0;transform:translateY(3%)}}
|
|
140
155
|
|
|
141
156
|
/* media: posters until the slide is on and the media is ready */
|
|
142
157
|
.talk-on .stage .media.video.playing>.poster,.talk-on .stage .media.demo.is-ready>.poster{opacity:0;transition:opacity 400ms var(--ease-io)}
|
|
@@ -270,11 +285,29 @@ export const TALK_JS = String.raw`(() => {
|
|
|
270
285
|
if (s.dataset.motion === "none" || $('[data-a]:not([data-a="count"]), [data-step]', s)) return;
|
|
271
286
|
const content = $(".content", s); if (!content) return;
|
|
272
287
|
let i = 0;
|
|
273
|
-
const mark = (el) => { if (el.classList.contains("deco") || el.matches("script, style")) return; if (!el.hasAttribute("data-a")) el.setAttribute("data-a", ""); el.style.setProperty("--i", i++); };
|
|
288
|
+
const mark = (el) => { if (el.classList.contains("deco") || el.matches("script, style")) return; if (!el.hasAttribute("data-a")) el.setAttribute("data-a", el.matches("h1, h2, .display") ? "words" : ""); el.style.setProperty("--i", i++); };
|
|
274
289
|
const walk = (el) => { for (const ch of el.children) { if (ch.matches(GROUPS) && ch.children.length > 1) { for (const g of ch.children) mark(g); } else if (ch.matches(".top, .bottom") ) walk(ch); else mark(ch); } };
|
|
275
290
|
walk(content);
|
|
276
291
|
});
|
|
277
292
|
|
|
293
|
+
/* the words of a rising title or a lit statement, each in its own line box (spaces stay text, so lines break as before) */
|
|
294
|
+
$$('[data-a="words"], [data-a="lit"]').forEach((el) => {
|
|
295
|
+
let n = 0;
|
|
296
|
+
const walk = (node) => [...node.childNodes].forEach((ch) => {
|
|
297
|
+
if (ch.nodeType === 3) {
|
|
298
|
+
const frag = document.createDocumentFragment();
|
|
299
|
+
ch.data.split(/(\s+)/).forEach((part) => {
|
|
300
|
+
if (!part) return;
|
|
301
|
+
if (/^\s+$/.test(part)) { frag.append(document.createTextNode(part)); return; }
|
|
302
|
+
const w = document.createElement("span"), i = document.createElement("span");
|
|
303
|
+
w.className = "wa-w"; i.className = "wa-wi"; i.style.setProperty("--wi", n++); i.textContent = part; w.append(i); frag.append(w);
|
|
304
|
+
});
|
|
305
|
+
ch.replaceWith(frag);
|
|
306
|
+
} else if (ch.nodeType === 1 && !ch.classList.contains("wa-w")) walk(ch);
|
|
307
|
+
});
|
|
308
|
+
walk(el);
|
|
309
|
+
});
|
|
310
|
+
|
|
278
311
|
/* ------------------------------------------------------------- the scale */
|
|
279
312
|
function fit(box) { const k = box.clientWidth / W; if (k) box.style.setProperty("--k", k); }
|
|
280
313
|
new ResizeObserver(() => { fit(stage); $$(".mini").forEach(fit); }).observe(stage);
|