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.
- package/PROMPT.md +182 -16
- package/SKILL.md +91 -18
- package/START-HERE.md +12 -1
- 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/examples/social/social.json +23 -0
- package/lib/audit.js +142 -0
- package/lib/brand.js +12 -7
- package/lib/components.js +13 -8
- package/lib/engine.js +55 -9
- package/lib/library.js +180 -0
- package/lib/pptx.js +1 -1
- package/lib/social.js +366 -0
- 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 +64 -5
package/lib/brand.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* lib/brand.js — the
|
|
2
|
+
* lib/brand.js — the three brands, resolved and turned into CSS.
|
|
3
3
|
*
|
|
4
4
|
* A brand is a directory with `tokens.json` (swatch, roles, faces, shape, art),
|
|
5
5
|
* `fonts/`, `assets/` and `lib/` (the Python helpers the exporters borrow).
|
|
6
6
|
* The kit runs in two shapes and finds the directory in both:
|
|
7
7
|
*
|
|
8
|
-
* repo power-prompts/studio/kit/ -> ../../../brand and brand-
|
|
8
|
+
* repo power-prompts/studio/kit/ -> ../../../brand, brand-next and brand-interim
|
|
9
9
|
* packaged tools/package.mjs copies each into brands/<name>/ beside the kit
|
|
10
10
|
*
|
|
11
11
|
* `WAND_BRANDS_DIR` overrides both: it holds <name>/ per brand.
|
|
@@ -23,7 +23,7 @@ import path from "node:path";
|
|
|
23
23
|
import { fileURLToPath } from "node:url";
|
|
24
24
|
|
|
25
25
|
const KIT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
26
|
-
const REPO_DIRS = { current: "brand", next: "brand-next" };
|
|
26
|
+
const REPO_DIRS = { current: "brand", next: "brand-next", interim: "brand-interim" };
|
|
27
27
|
export const BRANDS = Object.keys(REPO_DIRS);
|
|
28
28
|
|
|
29
29
|
// The brand a job gets when the user names none. While the two brands overlap
|
|
@@ -37,13 +37,13 @@ export const DEFAULT_BRAND = "current";
|
|
|
37
37
|
export const ASKED_AS = {
|
|
38
38
|
current: ["current brand", "old brand", "existing guidelines", "Geist"],
|
|
39
39
|
next: ["new brand", "new branding", "new brand guidelines", "the website's look"],
|
|
40
|
+
interim: ["interim brand", "interim branding", "the live site's look", "the transitional website", "wand.ai as it is today"],
|
|
40
41
|
};
|
|
41
42
|
|
|
42
43
|
/** The default and the words that switch away from it, as one line. */
|
|
43
44
|
export function brandRule() {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
return `${DEFAULT_BRAND} by default; ${other} only when the user asks for ${words.slice(0, -1).join(", ")} or ${words.at(-1)}`;
|
|
45
|
+
const words = (b) => { const w = ASKED_AS[b].map((x) => `"${x}"`); return `${w.slice(0, -1).join(", ")} or ${w.at(-1)}`; };
|
|
46
|
+
return `${DEFAULT_BRAND} by default; ${BRANDS.filter((b) => b !== DEFAULT_BRAND).map((b) => `${b} only when the user asks for ${words(b)}`).join("; ")}`;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/** Absolute path of a brand directory, or an error naming every place looked. */
|
|
@@ -128,11 +128,16 @@ function brandCss(tokens, roles, fonts, extras = {}) {
|
|
|
128
128
|
`@font-face{font-family:"${f.family}";font-weight:${f.weight};font-style:${f.italic ? "italic" : "normal"};src:url("${url(path.join(fonts, f.file))}")}`).join("\n");
|
|
129
129
|
const F = tokens.faces, S = tokens.shape;
|
|
130
130
|
const face = (k) => `--face-${k}:"${F[k].family}";--w-${k}:${F[k].weight};--tr-${k}:${F[k].tracking}em;--lh-${k}:${F[k].leading}`;
|
|
131
|
+
// a brand whose title phrase changes face (interim: Newsreader for <em>) declares `faces.phrase`; without it the
|
|
132
|
+
// phrase inherits the title's face, so the other brands draw exactly as before
|
|
133
|
+
// a brand may draw its art unshaded (`shape.artShade`, 0.3 otherwise) and set its cover title at its own tracking
|
|
134
|
+
// (`faces.display.coverTracking`, -0.03em otherwise): interim does both, as its approved social design does
|
|
135
|
+
const P = F.phrase, phrase = P ? `;--face-phrase:"${P.family}";--w-phrase:${P.weight};--tr-phrase:${P.tracking}em;--style-phrase:${P.italic ? "italic" : "normal"};--style-phrase-art:${P.artItalic || P.italic ? "italic" : "normal"}` : "";
|
|
131
136
|
// the talk and board runtimes' own controls, dressed in the brand's dark roles
|
|
132
137
|
const d = roles.dark, ui = { bg: d.track, card: d.paper, stroke: d.stroke, ink: d.ink, text: d.text, label: d.muted, accent: d.accent };
|
|
133
138
|
return `${faces}
|
|
134
139
|
:root{${Object.entries(ui).map(([k, v]) => `--ui-${k}:#${v}`).join(";")};--ui-face:"${F.body.family}"}
|
|
135
140
|
.page{${vars(roles.light)};${Object.keys(extras).length ? vars(extras) + ";" : ""}${face("display")};${face("body")};${face("label")};--caps-label:${F.label.caps ? "uppercase" : "none"};
|
|
136
|
-
--radius-k:${S.radius};--card-stroke:${S.cardStroke}px;--emph-stroke:${S.emphStroke}px}
|
|
141
|
+
--radius-k:${S.radius};--card-stroke:${S.cardStroke}px;--emph-stroke:${S.emphStroke}px;--art-shade:${S.artShade ?? 0.3};--tr-cover:${F.display.coverTracking ?? -0.03}em${phrase}}
|
|
137
142
|
.page.dark{${vars(roles.dark)}}`;
|
|
138
143
|
}
|
package/lib/components.js
CHANGED
|
@@ -51,17 +51,20 @@ export function bind({ brand, surface, job, content, dir: jobDir = process.cwd()
|
|
|
51
51
|
* number. `art` is a brand art role ("cover", "overlay", "closing") or a
|
|
52
52
|
* file path; `notes` become speaker notes in the .pptx and the presenter
|
|
53
53
|
* view; `seconds` is the slide's share of the talk, for the presenter's
|
|
54
|
-
* pace; `motion: "none"` keeps a slide still when the deck animates
|
|
54
|
+
* pace; `motion: "none"` keeps a slide still when the deck animates; `footer` replaces the job's footer line
|
|
55
|
+
* on this page (a source note).
|
|
55
56
|
*/
|
|
56
|
-
const page = ({ eyebrow = "", dark = false, art = null, shade = dark, cls: k = "", body = "", notes = "", chrome = true, seconds = 0, motion = "", autosize = true } = {}) => {
|
|
57
|
+
const page = ({ eyebrow = "", dark = false, art = null, shade = dark, cls: k = "", body = "", notes = "", chrome = true, seconds = 0, motion = "", autosize = true, footer = null } = {}) => {
|
|
57
58
|
n += 1;
|
|
58
|
-
|
|
59
|
+
// a brand may draw a role again for a portrait page (`coverPortrait`), where its landscape art would crop away
|
|
60
|
+
const role = art && surface.h > surface.w && brand.tokens.art[`${art}Portrait`] ? `${art}Portrait` : art;
|
|
61
|
+
const artSrc = art ? (/[\/.]/.test(art) && !brand.tokens.art[art] ? url(art) : brand.artUrl(role)) : null;
|
|
59
62
|
const eb = job.tag && eyebrow ? `${esc(job.tag)}<span class="bar">|</span>${esc(eyebrow)}` : esc(job.tag || eyebrow || "");
|
|
60
63
|
return `<section class="page ${dark ? "dark" : "light"}${cls(k)}" data-n="${n}"${notes ? ` data-notes="${esc(notes)}"` : ""}${seconds ? ` data-seconds="${seconds}"` : ""}${motion ? ` data-motion="${esc(motion)}"` : ""}${autosize ? "" : ' data-size="fixed"'}>
|
|
61
64
|
${artSrc ? `<img class="art deco" src="${artSrc}" alt="">${shade ? `<div class="shade deco"></div>` : ""}` : ""}
|
|
62
65
|
${chrome ? `<div class="eyebrow">${eb}</div>${mark(dark)}` : ""}
|
|
63
66
|
<div class="content">${body}</div>
|
|
64
|
-
${chrome ? `<footer><span>${esc(job.footer
|
|
67
|
+
${chrome ? `<footer><span>${esc(footer ?? job.footer ?? "")}</span><span class="pn">${String(n).padStart(2, "0")}</span></footer>` : ""}
|
|
65
68
|
</section>`;
|
|
66
69
|
};
|
|
67
70
|
|
|
@@ -266,7 +269,7 @@ html,body{background:#E9E7EE}
|
|
|
266
269
|
.page{position:relative;overflow:hidden;break-after:page;background:var(--bg);color:var(--ink);font-family:var(--face-body);font-weight:var(--w-body);
|
|
267
270
|
letter-spacing:var(--tr-body);line-height:var(--lh-body);-webkit-font-smoothing:antialiased;font-variant-numeric:tabular-nums}
|
|
268
271
|
.art{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:38% 50%}
|
|
269
|
-
.shade{position:absolute;inset:0;background:var(--bg);opacity
|
|
272
|
+
.shade{position:absolute;inset:0;background:var(--bg);opacity:var(--art-shade,.3)}
|
|
270
273
|
.eyebrow{position:absolute;left:var(--x);top:var(--c-eyebrow-top);font-family:var(--face-label);font-weight:var(--w-label);font-size:var(--c-eyebrow-size);
|
|
271
274
|
letter-spacing:.22em;text-transform:var(--caps-label);color:var(--muted);line-height:1.2}
|
|
272
275
|
.dark .eyebrow{color:var(--ink)}
|
|
@@ -283,10 +286,12 @@ footer .pn{font-weight:500}
|
|
|
283
286
|
h1,h2,h3,h4,h5,.display{font-family:var(--face-display);font-weight:var(--w-display);letter-spacing:var(--tr-display);line-height:var(--lh-display);color:var(--ink)}
|
|
284
287
|
h1{font-size:var(--t-h1)}h1.md{font-size:var(--t-h1md)}h2{font-size:var(--t-h2)}h3{font-size:var(--t-h3);line-height:1.22}h4{font-size:var(--t-h4);line-height:1.2}h5{font-size:var(--t-h5);line-height:1.25}
|
|
285
288
|
/* a title's phrase is its second tone (on the current brand, the accent itself); on the cover, a statement
|
|
286
|
-
and the close, the pages that carry the brand's art, it keeps the accent
|
|
287
|
-
|
|
289
|
+
and the close, the pages that carry the brand's art, it keeps the accent. A brand may give the phrase its
|
|
290
|
+
own face (interim: Newsreader, the italic on the art pages); the others leave the variables unset */
|
|
291
|
+
h1 em,h2 em,.display em{font-family:var(--face-phrase,inherit);font-weight:var(--w-phrase,inherit);font-style:var(--style-phrase,normal);letter-spacing:var(--tr-phrase,inherit);color:var(--second)}
|
|
288
292
|
h3 em,h4 em,h5 em,.lede em{font-style:normal;color:var(--second-sm)}
|
|
289
293
|
.page:has(>.art) :is(h1,h2,h3,h4,h5,.display,.lede) em{color:var(--accent-text)}
|
|
294
|
+
.page:has(>.art) :is(h1,h2,.display) em{font-style:var(--style-phrase-art,normal)}
|
|
290
295
|
.label{font-family:var(--face-display);font-weight:500;font-size:var(--t-label);letter-spacing:var(--tr-display);color:var(--label);line-height:1.2}
|
|
291
296
|
.label.sm{font-size:calc(var(--t-label) * .8)}
|
|
292
297
|
.lead{font-size:var(--t-lead);line-height:1.5;color:var(--ink)}
|
|
@@ -403,7 +408,7 @@ table.grid{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums
|
|
|
403
408
|
.cover .content{justify-content:center;gap:0}
|
|
404
409
|
.cover.split .content{justify-content:space-between}
|
|
405
410
|
.cover .top{display:flex;flex-direction:column;gap:1.3em}
|
|
406
|
-
.cover h1{font-size:var(--t-display);line-height:1.02;letter-spacing
|
|
411
|
+
.cover h1{font-size:var(--t-display);line-height:1.02;letter-spacing:var(--tr-cover,-.03em)}
|
|
407
412
|
.lede{font-size:var(--t-lede);font-weight:500;line-height:1.45;color:var(--ink);max-width:88%}
|
|
408
413
|
.cover-body{font-size:var(--t-body);line-height:1.6;color:var(--text);max-width:min(80%,44em)}
|
|
409
414
|
.meta{font-size:var(--t-meta);color:var(--text);padding-top:1em;border-top:1px solid var(--rule);line-height:1.4}
|
package/lib/engine.js
CHANGED
|
@@ -97,7 +97,21 @@ const stem = (job, file) => (job.file || job.name || path.basename(path.dirname(
|
|
|
97
97
|
/* ------------------------------------------------------------------ gates */
|
|
98
98
|
|
|
99
99
|
/** Runs inside the page. Everything is read from the rendered DOM. */
|
|
100
|
-
|
|
100
|
+
/* The lines a block of text sets in, read from the browser's own line boxes: a box's height misleads (a table cell
|
|
101
|
+
is as tall as its row, a card's paragraph as tall as the card), the fragments of its text do not. Runs inside the page. */
|
|
102
|
+
const LINES_OF = `(el) => {
|
|
103
|
+
const rg = document.createRange(); rg.selectNodeContents(el);
|
|
104
|
+
const rs = [...rg.getClientRects()].filter((q) => q.width > 0.5 && q.height > 0.5).sort((a, b) => a.top - b.top);
|
|
105
|
+
let n = 0, cur = null;
|
|
106
|
+
for (const q of rs) {
|
|
107
|
+
if (cur && q.top < cur.bottom - Math.min(q.height, cur.bottom - cur.top) * 0.5) { cur.bottom = Math.max(cur.bottom, q.bottom); continue; }
|
|
108
|
+
n++; cur = { top: q.top, bottom: q.bottom };
|
|
109
|
+
}
|
|
110
|
+
return n;
|
|
111
|
+
}`;
|
|
112
|
+
|
|
113
|
+
function GATES({ min, chars, tokens, families, base, maxLines = 0, linesOf }) {
|
|
114
|
+
const countLines = eval(linesOf);
|
|
101
115
|
const T = new Set(tokens);
|
|
102
116
|
const pages = [...document.querySelectorAll(".page")];
|
|
103
117
|
const toHex = (c) => {
|
|
@@ -109,9 +123,18 @@ function GATES({ min, chars, tokens, families, base }) {
|
|
|
109
123
|
const lum = (h) => { const c = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255).map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)); return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; };
|
|
110
124
|
const contrast = (a, b) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); };
|
|
111
125
|
const bgOf = (el) => { for (let e = el; e; e = e.parentElement) { const h = toHex(getComputedStyle(e).backgroundColor); if (h) return h; if (e.classList.contains("page")) break; } return null; };
|
|
112
|
-
const res = { fit: [], overlap: [], brand: [], fonts: [], contrast: [], type: [], measure: [], dash: [], texts: [], notes: [], broken: [],
|
|
126
|
+
const res = { fit: [], overlap: [], brand: [], fonts: [], contrast: [], type: [], measure: [], lines: [], dash: [], stretched: [], texts: [], notes: [], broken: [], extent: [],
|
|
113
127
|
motion: { a: document.querySelectorAll("[data-a]").length, steps: document.querySelectorAll("[data-step]").length, video: document.querySelectorAll(".media.video").length, demo: document.querySelectorAll(".media.demo").length, placeholders: document.querySelectorAll(".frame.placeholder").length } };
|
|
114
128
|
document.querySelectorAll("img").forEach((im) => { if (im.getAttribute("src") && !(im.complete && im.naturalWidth)) res.broken.push(decodeURIComponent(im.getAttribute("src").replace(/^file:\/\//, "")).split("/").slice(-2).join("/")); });
|
|
129
|
+
// an image drawn at another shape than its own (a flex column stretches an <img> with width:auto across it, the
|
|
130
|
+
// sales deck's screenshots on 2026-10-27): only object-fit cover, contain or none may change the box's shape
|
|
131
|
+
document.querySelectorAll("img").forEach((im) => {
|
|
132
|
+
if (!im.naturalWidth || !im.naturalHeight || /\.svg($|[?#])/i.test(im.getAttribute("src") || "")) return;
|
|
133
|
+
const r = im.getBoundingClientRect(); if (r.width < 4 || r.height < 4) return;
|
|
134
|
+
if (["cover", "contain", "none", "scale-down"].includes(getComputedStyle(im).objectFit)) return;
|
|
135
|
+
const k = (r.width / r.height) / (im.naturalWidth / im.naturalHeight);
|
|
136
|
+
if (Math.abs(k - 1) > 0.02) res.stretched.push({ page: pages.indexOf(im.closest(".page")) + 1, src: decodeURIComponent(im.getAttribute("src").replace(/^file:\/\//, "")).split("/").pop(), k: +k.toFixed(2) });
|
|
137
|
+
});
|
|
115
138
|
const seen = new Set();
|
|
116
139
|
// a page's composition, coarsely: the blocks of its content in order, and how many a grid holds
|
|
117
140
|
const kindOf = (el) => { const c = [...el.classList].filter((x) => !/^(deco|big|wide|stretch|centre|sm|md)$/.test(x)).slice(0, 2); return el.tagName.toLowerCase() + (c.length ? "." + c.join(".") : ""); };
|
|
@@ -124,7 +147,8 @@ function GATES({ min, chars, tokens, families, base }) {
|
|
|
124
147
|
const n = pi + 1;
|
|
125
148
|
const content = pg.querySelector(".content");
|
|
126
149
|
const box = (content || pg).getBoundingClientRect();
|
|
127
|
-
|
|
150
|
+
// a page that hides its footer (a cover, a close) is measured against its content box alone
|
|
151
|
+
const fr = pg.querySelector("footer")?.getBoundingClientRect(), foot = fr && fr.height ? fr : null;
|
|
128
152
|
const floor = foot ? Math.min(box.bottom, foot.top - 6) : box.bottom;
|
|
129
153
|
let over = 0, why = "", low = -Infinity;
|
|
130
154
|
for (const el of (content || pg).querySelectorAll("*")) {
|
|
@@ -142,6 +166,9 @@ function GATES({ min, chars, tokens, families, base }) {
|
|
|
142
166
|
const all = [...pg.querySelectorAll("*")];
|
|
143
167
|
const leaves = all.filter((e) => !e.closest(".deco, svg, script, style") && [...e.childNodes].some((c) => c.nodeType === 3 && c.data.trim()));
|
|
144
168
|
const boxes = leaves.map((e) => ({ e, r: e.getBoundingClientRect() })).filter((x) => x.r.width && x.r.height);
|
|
169
|
+
// how far the words run across the page, for a share image that a square crop from the centre must keep
|
|
170
|
+
const words = boxes.filter((x) => content && content.contains(x.e)), pgBox = pg.getBoundingClientRect();
|
|
171
|
+
if (words.length) res.extent.push({ page: n, l: Math.round(Math.min(...words.map((x) => x.r.left)) - pgBox.left), r: Math.round(Math.max(...words.map((x) => x.r.right)) - pgBox.left) });
|
|
145
172
|
for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) {
|
|
146
173
|
const A = boxes[i], B = boxes[j];
|
|
147
174
|
if (A.e.contains(B.e) || B.e.contains(A.e)) continue;
|
|
@@ -170,6 +197,8 @@ function GATES({ min, chars, tokens, families, base }) {
|
|
|
170
197
|
const r = el.getBoundingClientRect(), lh = parseFloat(cs.lineHeight) || size * 1.4;
|
|
171
198
|
const inner = r.height - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
|
|
172
199
|
if (Math.round(inner / lh) >= 2 && r.width / (size * 0.48) > chars) res.measure.push({ page: n, el: sel(el), chars: Math.round(r.width / (size * 0.48)) });
|
|
200
|
+
// a paragraph that wraps past maxLines on a slide: the edits in PowerPoint and Google Slides reflow it, and it breaks
|
|
201
|
+
if (maxLines && cs.display !== "inline") { const k = countLines(el); if (k > maxLines) res.lines.push({ page: n, el: sel(el), lines: k, text: (el.innerText || "").trim().slice(0, 32) }); }
|
|
173
202
|
}
|
|
174
203
|
const text = pg.innerText || "";
|
|
175
204
|
if (text.includes("\u2014")) res.dash.push(n);
|
|
@@ -185,7 +214,8 @@ function GATES({ min, chars, tokens, families, base }) {
|
|
|
185
214
|
* sizes itself to its slides, the same way on every run. Returns the pages
|
|
186
215
|
* it changed, and every slide's fill after.
|
|
187
216
|
*/
|
|
188
|
-
function AUTOSIZE() {
|
|
217
|
+
function AUTOSIZE({ maxLines = 0, linesOf } = {}) {
|
|
218
|
+
const countLines = eval(linesOf);
|
|
189
219
|
const fill = (pg) => {
|
|
190
220
|
const c = pg.querySelector(".content"); if (!c) return 1;
|
|
191
221
|
const kids = [...c.children].filter((k) => !k.classList.contains("deco") && k.getBoundingClientRect().height);
|
|
@@ -194,15 +224,20 @@ function AUTOSIZE() {
|
|
|
194
224
|
return (kids.reduce((a, k) => a + k.getBoundingClientRect().height, 0) + gap * (kids.length - 1)) / c.getBoundingClientRect().height;
|
|
195
225
|
};
|
|
196
226
|
const overflows = (pg) => {
|
|
197
|
-
const c = pg.querySelector(".content"), box = c.getBoundingClientRect(),
|
|
227
|
+
const c = pg.querySelector(".content"), box = c.getBoundingClientRect(), fr = pg.querySelector("footer")?.getBoundingClientRect(), foot = fr && fr.height ? fr : null;
|
|
198
228
|
const floor = foot ? Math.min(box.bottom, foot.top - 6) : box.bottom;
|
|
199
229
|
// as the fit gate reads it: an inline run's line box may rise above the content's top, a block may not
|
|
200
230
|
return [...c.querySelectorAll("*")].some((el) => { if (el.closest(".deco")) return false; const r = el.getBoundingClientRect(); return r.width && r.height && (r.bottom > floor + 0.5 || r.right > box.right + 1 || (getComputedStyle(el).display !== "inline" && r.top < box.top - 0.5)); }) || fill(pg) > 1;
|
|
201
231
|
};
|
|
232
|
+
// the step up is also undone when it would wrap a paragraph past the job's line limit
|
|
233
|
+
const tooLong = (pg) => maxLines > 0 && [...pg.querySelectorAll(".content *")].some((el) => {
|
|
234
|
+
if (el.closest(".deco, svg") || ![...el.childNodes].some((n) => n.nodeType === 3 && n.data.trim())) return false;
|
|
235
|
+
return getComputedStyle(el).display !== "inline" && countLines(el) > maxLines;
|
|
236
|
+
});
|
|
202
237
|
const sized = [], fills = [];
|
|
203
238
|
document.querySelectorAll(".page").forEach((pg, i) => {
|
|
204
239
|
if (pg.matches(".cover, .back, .shot, .run, [data-size]")) return;
|
|
205
|
-
if (fill(pg) < 0.55 && !pg.classList.contains("big")) { pg.classList.add("big"); if (overflows(pg)) pg.classList.remove("big"); else sized.push(i + 1); }
|
|
240
|
+
if (fill(pg) < 0.55 && !pg.classList.contains("big")) { pg.classList.add("big"); if (overflows(pg) || tooLong(pg)) pg.classList.remove("big"); else sized.push(i + 1); }
|
|
206
241
|
fills.push({ page: i + 1, fill: fill(pg) });
|
|
207
242
|
});
|
|
208
243
|
return { sized, fills };
|
|
@@ -245,10 +280,12 @@ export async function build(jobPath, { outputs: wantOutputs = true, outDir = nul
|
|
|
245
280
|
await page.evaluate(() => Promise.all([...document.images].map((i) => (i.complete ? null : new Promise((r) => { i.onload = i.onerror = r; })))));
|
|
246
281
|
await page.waitForTimeout(120);
|
|
247
282
|
|
|
283
|
+
// a slide's paragraphs stay at two lines unless the job says otherwise (Carlos, 2026-10-27: past two "it breaks a lot")
|
|
284
|
+
const maxLines = surface.kind === "slide" ? job.maxLines ?? 2 : job.maxLines ?? 0;
|
|
248
285
|
// a slide deck sizes itself before anything is measured or drawn; the file keeps the sizes it chose
|
|
249
|
-
const auto = surface.kind === "slide" ? await page.evaluate(AUTOSIZE) : { sized: [], fills: [] };
|
|
286
|
+
const auto = surface.kind === "slide" ? await page.evaluate(AUTOSIZE, { maxLines, linesOf: LINES_OF }) : { sized: [], fills: [] };
|
|
250
287
|
if (auto.sized.length) fs.writeFileSync(htmlPath, "<!doctype html>\n" + await page.evaluate(() => document.documentElement.outerHTML));
|
|
251
|
-
const g = await page.evaluate(GATES, { min: surface.min, chars: surface.chars, tokens: [...brand.tokenSet], families: brand.families, base: surface.base });
|
|
288
|
+
const g = await page.evaluate(GATES, { min: surface.min, chars: surface.chars, tokens: [...brand.tokenSet], families: brand.families, base: surface.base, maxLines, linesOf: LINES_OF });
|
|
252
289
|
const pageCount = g.fit.length;
|
|
253
290
|
const text = g.texts.join("\n");
|
|
254
291
|
const cov = content.has ? coverage(content, text, job.splitters) : null;
|
|
@@ -270,6 +307,8 @@ export async function build(jobPath, { outputs: wantOutputs = true, outDir = nul
|
|
|
270
307
|
row(!deny.length, "names", job.denylist?.length ? (deny.length ? `found ${deny.join(", ")}` : `none of ${job.denylist.length} denied names`) : "no denylist", !job.denylist?.length);
|
|
271
308
|
row(!g.contrast.length, "contrast", g.contrast.length ? `${g.contrast.length} low: ` + g.contrast.slice(0, 3).map((o) => `p${o.page} ${o.el} ${o.ratio}:1 (needs ${o.need})`).join("; ") : "every text passes WCAG AA", true);
|
|
272
309
|
row(!g.measure.length, "measure", g.measure.length ? `${g.measure.length} long: ` + g.measure.slice(0, 3).map((o) => `p${o.page} ${o.el} ~${o.chars} chars`).join("; ") : `no line over ${surface.chars} characters`, true);
|
|
310
|
+
// hard when the job sets maxLines, a warning on every other deck
|
|
311
|
+
if (maxLines) row(!g.lines.length, "lines", g.lines.length ? `${g.lines.length} over ${maxLines}: ` + g.lines.slice(0, 4).map((o) => `p${o.page} ${o.el} ${o.lines} lines (${o.text}…)`).join("; ") + (g.lines.length > 4 ? " …" : "") : `no paragraph over ${maxLines} line${maxLines === 1 ? "" : "s"}`, job.maxLines == null);
|
|
273
312
|
const sparse = auto.fills.filter((f) => f.fill < 0.4);
|
|
274
313
|
if (surface.kind === "slide") row(!sparse.length, "space", sparse.length ? `${sparse.map((f) => `p${f.page} ${Math.round(f.fill * 100)}%`).join(", ")} of the slide used: add the proof, a figure or a visual, or join it to the next` : `every slide uses its space${auto.sized.length ? ` (type a step up on p${auto.sized.join(", p")})` : ""}`, true);
|
|
275
314
|
// the same composition page after page is what makes a set of pages read as a template
|
|
@@ -280,7 +319,14 @@ export async function build(jobPath, { outputs: wantOutputs = true, outDir = nul
|
|
|
280
319
|
start = i;
|
|
281
320
|
}
|
|
282
321
|
if (composed) row(!runs.length, "variety", runs.length ? runs.map(([a, z]) => `p${a}–p${z}`).join(", ") + " share one composition: vary it (a statement, a split, a figure, a photograph) or join them" : "no composition repeats three pages running", true);
|
|
283
|
-
|
|
322
|
+
// Slack's and WhatsApp's thumbnails of a link are a square cut from the image's centre: words outside it are cut
|
|
323
|
+
if (surface.name === "og") {
|
|
324
|
+
const sq = [(surface.w - surface.h) / 2, (surface.w + surface.h) / 2], cut = g.extent.filter((e) => e.l < sq[0] || e.r > sq[1]);
|
|
325
|
+
row(!cut.length, "crop", cut.length ? `${cut.map((e) => `p${e.page} words from x ${e.l} to ${e.r}`).join(", ")}; Slack and WhatsApp keep x ${sq[0]} to ${sq[1]}: keep the words of a share image there` : `every page's words inside the centre square (x ${sq[0]} to ${sq[1]}), whole in Slack's and WhatsApp's square cut`, true);
|
|
326
|
+
}
|
|
327
|
+
row(!g.broken.length && !g.stretched.length, "images", g.broken.length ? `${g.broken.length} did not load: ${g.broken.slice(0, 3).join(", ")}`
|
|
328
|
+
: g.stretched.length ? `${g.stretched.length} drawn out of shape: ` + g.stretched.slice(0, 4).map((o) => `p${o.page} ${o.src} ${o.k > 1 ? "stretched" : "squeezed"} ${Math.round(Math.abs(o.k - 1) * 100)}%`).join("; ") + (g.stretched.length > 4 ? " …" : "")
|
|
329
|
+
: "every image loaded, each at its own shape");
|
|
284
330
|
if (absent.length || g.motion.demo || g.motion.video) row(!absent.length, "media", absent.length ? `missing: ${absent.map((p) => path.basename(p)).join(", ")}` : `${g.motion.demo} demo${g.motion.demo === 1 ? "" : "s"}, ${g.motion.video} clip${g.motion.video === 1 ? "" : "s"}, each with its poster`);
|
|
285
331
|
const board = job.__board || null;
|
|
286
332
|
if (surface.kind === "slide" && !board) row(true, "motion", `${job.talk?.motion === "none" ? "still" : "entrances on every slide"}${g.motion.a ? `, ${g.motion.a} marked` : ""}${g.motion.steps ? `, ${g.motion.steps} held for steps` : ""} (the HTML; static exports show the end state)`);
|
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. */
|