wand-kit 0.3.3 → 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.
Files changed (159) hide show
  1. package/PROMPT.md +182 -16
  2. package/SKILL.md +91 -18
  3. package/START-HERE.md +12 -1
  4. package/brands/interim/assets/avatar.svg +1 -0
  5. package/brands/interim/assets/closing-art.png +0 -0
  6. package/brands/interim/assets/cover-art.png +0 -0
  7. package/brands/interim/assets/cover-portrait-art.png +0 -0
  8. package/brands/interim/assets/divider-art.png +0 -0
  9. package/brands/interim/assets/glyphs/README.md +29 -0
  10. package/brands/interim/assets/glyphs/activity.svg +15 -0
  11. package/brands/interim/assets/glyphs/atom.svg +17 -0
  12. package/brands/interim/assets/glyphs/badge-check.svg +16 -0
  13. package/brands/interim/assets/glyphs/bell.svg +16 -0
  14. package/brands/interim/assets/glyphs/blocks.svg +16 -0
  15. package/brands/interim/assets/glyphs/book-open.svg +16 -0
  16. package/brands/interim/assets/glyphs/bot.svg +20 -0
  17. package/brands/interim/assets/glyphs/boxes.svg +26 -0
  18. package/brands/interim/assets/glyphs/brain.svg +23 -0
  19. package/brands/interim/assets/glyphs/briefcase.svg +16 -0
  20. package/brands/interim/assets/glyphs/building-2.svg +21 -0
  21. package/brands/interim/assets/glyphs/calendar.svg +18 -0
  22. package/brands/interim/assets/glyphs/chart-column.svg +18 -0
  23. package/brands/interim/assets/glyphs/chart-line.svg +16 -0
  24. package/brands/interim/assets/glyphs/chart-pie.svg +16 -0
  25. package/brands/interim/assets/glyphs/circle-check.svg +16 -0
  26. package/brands/interim/assets/glyphs/clipboard-check.svg +17 -0
  27. package/brands/interim/assets/glyphs/clock.svg +16 -0
  28. package/brands/interim/assets/glyphs/cloud.svg +15 -0
  29. package/brands/interim/assets/glyphs/code.svg +15 -0
  30. package/brands/interim/assets/glyphs/coins.svg +18 -0
  31. package/brands/interim/assets/glyphs/compass.svg +16 -0
  32. package/brands/interim/assets/glyphs/cpu.svg +24 -0
  33. package/brands/interim/assets/glyphs/database.svg +17 -0
  34. package/brands/interim/assets/glyphs/eye.svg +16 -0
  35. package/brands/interim/assets/glyphs/file-check.svg +17 -0
  36. package/brands/interim/assets/glyphs/filter.svg +15 -0
  37. package/brands/interim/assets/glyphs/fingerprint.svg +23 -0
  38. package/brands/interim/assets/glyphs/flag.svg +16 -0
  39. package/brands/interim/assets/glyphs/gauge.svg +16 -0
  40. package/brands/interim/assets/glyphs/gavel.svg +19 -0
  41. package/brands/interim/assets/glyphs/git-branch.svg +18 -0
  42. package/brands/interim/assets/glyphs/globe.svg +17 -0
  43. package/brands/interim/assets/glyphs/graduation-cap.svg +17 -0
  44. package/brands/interim/assets/glyphs/handshake.svg +19 -0
  45. package/brands/interim/assets/glyphs/hexagon.svg +15 -0
  46. package/brands/interim/assets/glyphs/index.json +177 -0
  47. package/brands/interim/assets/glyphs/infinity.svg +15 -0
  48. package/brands/interim/assets/glyphs/key.svg +17 -0
  49. package/brands/interim/assets/glyphs/landmark.svg +20 -0
  50. package/brands/interim/assets/glyphs/layers.svg +17 -0
  51. package/brands/interim/assets/glyphs/lightbulb.svg +17 -0
  52. package/brands/interim/assets/glyphs/link.svg +16 -0
  53. package/brands/interim/assets/glyphs/list-checks.svg +19 -0
  54. package/brands/interim/assets/glyphs/lock.svg +16 -0
  55. package/brands/interim/assets/glyphs/map.svg +17 -0
  56. package/brands/interim/assets/glyphs/megaphone.svg +16 -0
  57. package/brands/interim/assets/glyphs/message-square.svg +15 -0
  58. package/brands/interim/assets/glyphs/milestone.svg +17 -0
  59. package/brands/interim/assets/glyphs/network.svg +19 -0
  60. package/brands/interim/assets/glyphs/orbit.svg +19 -0
  61. package/brands/interim/assets/glyphs/package.svg +18 -0
  62. package/brands/interim/assets/glyphs/plug.svg +18 -0
  63. package/brands/interim/assets/glyphs/puzzle.svg +15 -0
  64. package/brands/interim/assets/glyphs/radar.svg +22 -0
  65. package/brands/interim/assets/glyphs/receipt.svg +17 -0
  66. package/brands/interim/assets/glyphs/refresh-cw.svg +18 -0
  67. package/brands/interim/assets/glyphs/rocket.svg +18 -0
  68. package/brands/interim/assets/glyphs/route.svg +17 -0
  69. package/brands/interim/assets/glyphs/scale.svg +19 -0
  70. package/brands/interim/assets/glyphs/search.svg +16 -0
  71. package/brands/interim/assets/glyphs/server.svg +18 -0
  72. package/brands/interim/assets/glyphs/settings.svg +16 -0
  73. package/brands/interim/assets/glyphs/share-2.svg +19 -0
  74. package/brands/interim/assets/glyphs/shield-check.svg +16 -0
  75. package/brands/interim/assets/glyphs/shuffle.svg +19 -0
  76. package/brands/interim/assets/glyphs/sparkles.svg +19 -0
  77. package/brands/interim/assets/glyphs/square-stack.svg +17 -0
  78. package/brands/interim/assets/glyphs/target.svg +17 -0
  79. package/brands/interim/assets/glyphs/trending-up.svg +16 -0
  80. package/brands/interim/assets/glyphs/triangle-alert.svg +17 -0
  81. package/brands/interim/assets/glyphs/trophy.svg +20 -0
  82. package/brands/interim/assets/glyphs/users.svg +18 -0
  83. package/brands/interim/assets/glyphs/wallet.svg +16 -0
  84. package/brands/interim/assets/glyphs/wand-sparkles.svg +22 -0
  85. package/brands/interim/assets/glyphs/workflow.svg +17 -0
  86. package/brands/interim/assets/glyphs/zap.svg +15 -0
  87. package/brands/interim/assets/mark.svg +1 -0
  88. package/brands/interim/assets/social/composition.json +6833 -0
  89. package/brands/interim/assets/spark-lavender.png +0 -0
  90. package/brands/interim/assets/spark-violet.png +0 -0
  91. package/brands/interim/assets/statement-art.png +0 -0
  92. package/brands/interim/assets/story/agent-alpha.png +0 -0
  93. package/brands/interim/assets/story/agent.png +0 -0
  94. package/brands/interim/assets/story/economy-alpha.png +0 -0
  95. package/brands/interim/assets/story/economy.png +0 -0
  96. package/brands/interim/assets/story/index.json +214 -0
  97. package/brands/interim/assets/story/learning-alpha.png +0 -0
  98. package/brands/interim/assets/story/learning.png +0 -0
  99. package/brands/interim/assets/story/organization-alpha.png +0 -0
  100. package/brands/interim/assets/story/organization.png +0 -0
  101. package/brands/interim/assets/story/worker-alpha.png +0 -0
  102. package/brands/interim/assets/story/worker-light.png +0 -0
  103. package/brands/interim/assets/story/worker.png +0 -0
  104. package/brands/interim/assets/wordmark-on-dark.png +0 -0
  105. package/brands/interim/assets/wordmark-on-dark.svg +1 -0
  106. package/brands/interim/assets/wordmark-on-light.png +0 -0
  107. package/brands/interim/assets/wordmark-on-light.svg +1 -0
  108. package/brands/interim/fonts/Geist-Book.ttf +0 -0
  109. package/brands/interim/fonts/Geist-Medium.ttf +0 -0
  110. package/brands/interim/fonts/Geist-Regular.ttf +0 -0
  111. package/brands/interim/fonts/Geist-SemiBold.ttf +0 -0
  112. package/brands/interim/fonts/Newsreader-Italic.ttf +0 -0
  113. package/brands/interim/fonts/Newsreader-Medium.ttf +0 -0
  114. package/brands/interim/fonts/Newsreader-MediumItalic.ttf +0 -0
  115. package/brands/interim/fonts/Newsreader-Regular.ttf +0 -0
  116. package/brands/interim/fonts/OFL-Geist.txt +93 -0
  117. package/brands/interim/fonts/OFL-Newsreader.txt +93 -0
  118. package/brands/interim/fonts/faces.json +12 -0
  119. package/brands/interim/lib/composition.js +169 -0
  120. package/brands/interim/lib/composition.mjs +101 -0
  121. package/brands/interim/lib/glyphs.js +89 -0
  122. package/brands/interim/lib/measure.js +121 -0
  123. package/brands/interim/lib/pdf.js +597 -0
  124. package/brands/interim/lib/ttf.py +169 -0
  125. package/brands/interim/lib/wandxml.py +37 -0
  126. package/brands/interim/tokens.json +299 -0
  127. package/examples/social/social.json +23 -0
  128. package/lib/audit.js +142 -0
  129. package/lib/brand.js +12 -7
  130. package/lib/components.js +13 -8
  131. package/lib/engine.js +55 -9
  132. package/lib/library.js +180 -0
  133. package/lib/pptx.js +1 -1
  134. package/lib/social.js +366 -0
  135. package/library/index.json +629 -0
  136. package/library/interim/avatar-800x800.png +0 -0
  137. package/library/interim/icons/apple-touch-icon.png +0 -0
  138. package/library/interim/icons/favicon-16.png +0 -0
  139. package/library/interim/icons/favicon-32.png +0 -0
  140. package/library/interim/icons/favicon-48.png +0 -0
  141. package/library/interim/icons/favicon.ico +0 -0
  142. package/library/interim/icons/favicon.svg +1 -0
  143. package/library/interim/icons/icon-192.png +0 -0
  144. package/library/interim/icons/icon-512.png +0 -0
  145. package/library/interim/icons/icon-tags.html +5 -0
  146. package/library/interim/icons/site.webmanifest +19 -0
  147. package/library/interim/linkedin-banner-1584x396.png +0 -0
  148. package/library/interim/linkedin-company-1128x191.png +0 -0
  149. package/library/interim/open-agent-discovery-share-1200x630.png +0 -0
  150. package/library/interim/open-agent-discovery-share-head-tags.html +17 -0
  151. package/library/interim/wand-ai-share-1200x630.png +0 -0
  152. package/library/interim/wand-ai-share-head-tags.html +17 -0
  153. package/library/interim/x-header-1500x500.png +0 -0
  154. package/package.json +2 -2
  155. package/templates/card.mjs +6 -2
  156. package/templates/page.mjs +1 -1
  157. package/templates/slide.mjs +1 -1
  158. package/templates/storyboard.mjs +1 -1
  159. package/wand.js +64 -5
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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
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
+ }