wizards 0.3.6 → 0.3.7

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 (62) hide show
  1. package/apps/runtime/dist/agents/studio-assistant.js +285 -31
  2. package/apps/runtime/dist/agents/website.js +256 -34
  3. package/apps/runtime/dist/db/schema.js +1 -1
  4. package/apps/runtime/dist/documents/parse.js +14 -0
  5. package/apps/runtime/dist/engine/steps.js +42 -6
  6. package/apps/runtime/dist/engine/template.js +50 -3
  7. package/apps/runtime/dist/film/project.js +54 -4
  8. package/apps/runtime/dist/routes/studio.js +37 -1
  9. package/apps/runtime/dist/services/brand.js +6 -3
  10. package/apps/runtime/dist/services/project-files.js +59 -3
  11. package/apps/runtime/dist/services/projects.js +150 -2
  12. package/apps/web/dist/assets/AboutDialog-C0dN86zd.js +1 -0
  13. package/apps/web/dist/assets/{ChangelogDialog-m2M7gZiB.js → ChangelogDialog-Cv81t7tj.js} +1 -1
  14. package/apps/web/dist/assets/ChatView-uogJ3nht.js +10 -0
  15. package/apps/web/dist/assets/EditorPage-n8YuTh6j.js +3 -0
  16. package/apps/web/dist/assets/{FlowPreview-BdRy7FO7.js → FlowPreview-D4dp1MWS.js} +1 -1
  17. package/apps/web/dist/assets/{Harness-DmJkTwOO.js → Harness-v7r0e6vK.js} +2 -2
  18. package/apps/web/dist/assets/NewWizardChannels-Be-7VhBi.js +1 -0
  19. package/apps/web/dist/assets/NewWizardPage-C8hPvrK8.js +7 -0
  20. package/apps/web/dist/assets/ProjectPage-B686ArYy.js +1 -0
  21. package/apps/web/dist/assets/PublicRunner-DeCpVfZP.js +1 -0
  22. package/apps/web/dist/assets/{ReadOnly-CaGzgOPe.js → ReadOnly-B-UCgnqW.js} +1 -1
  23. package/apps/web/dist/assets/RunDrawer-C6pzi8AJ.js +4 -0
  24. package/apps/web/dist/assets/{RunnerView-CJ4cLA_0.js → RunnerView-Deenymq0.js} +1 -1
  25. package/apps/web/dist/assets/SettingsPage-CK6L0yEe.js +1 -0
  26. package/apps/web/dist/assets/SetupPage-9CH3qXdH.js +1 -0
  27. package/apps/web/dist/assets/SharePage-B778xUFo.js +1 -0
  28. package/apps/web/dist/assets/changelog-DCJo2mbx.js +1 -0
  29. package/apps/web/dist/assets/{circle-alert-CpuMIhbH.js → circle-alert-DLvC9ePT.js} +1 -1
  30. package/apps/web/dist/assets/{esm-DRaZAcXQ.js → esm-BJskogLD.js} +1 -1
  31. package/apps/web/dist/assets/index-D6hUM8NU.css +1 -0
  32. package/apps/web/dist/assets/index-Df6UxElH.js +12 -0
  33. package/apps/web/dist/assets/{lock-BGotwAyN.js → lock-DR7NakPn.js} +1 -1
  34. package/apps/web/dist/assets/{phone-CA2A8W7I.js → phone-BORh3jfv.js} +1 -1
  35. package/apps/web/dist/assets/{settings-menu-DxswVOqQ.js → settings-menu-DV5D91TD.js} +1 -1
  36. package/apps/web/dist/assets/{ui-DAHMnAVc.js → ui-BOCXzzHi.js} +1 -1
  37. package/apps/web/dist/assets/{upload-DLbJVvfd.js → upload-B3yv8gsk.js} +1 -1
  38. package/apps/web/dist/index.html +3 -8
  39. package/apps/web/dist/mcp/flow.html +2 -2
  40. package/node_modules/@engenty-wizards/plugin-sdk/dist/index.d.ts +3 -3
  41. package/node_modules/@engenty-wizards/shared/dist/projects.d.ts +138 -3
  42. package/node_modules/@engenty-wizards/shared/dist/projects.js +153 -4
  43. package/package.json +1 -1
  44. package/apps/web/dist/assets/AboutDialog-Dz_tYBSU.js +0 -1
  45. package/apps/web/dist/assets/ChatView-D21o-lq8.js +0 -10
  46. package/apps/web/dist/assets/EditorPage-D6j3dQpu.js +0 -3
  47. package/apps/web/dist/assets/Marketplace-B-gy2onK.js +0 -2
  48. package/apps/web/dist/assets/NewWizardChannels-B3FEpsJ-.js +0 -1
  49. package/apps/web/dist/assets/NewWizardPage-CT1lThNM.js +0 -7
  50. package/apps/web/dist/assets/ProjectPage-BIanCF0m.js +0 -2
  51. package/apps/web/dist/assets/PublicRunner-H1fXa9BK.js +0 -1
  52. package/apps/web/dist/assets/RunDrawer-8sjkAbm5.js +0 -4
  53. package/apps/web/dist/assets/SettingsPage-BsdwE1OA.js +0 -1
  54. package/apps/web/dist/assets/SetupPage-sqrv7Auf.js +0 -1
  55. package/apps/web/dist/assets/SharePage-B6ets1ot.js +0 -1
  56. package/apps/web/dist/assets/brand-BRw18WJF.js +0 -2
  57. package/apps/web/dist/assets/changelog-BOFdRjx2.js +0 -1
  58. package/apps/web/dist/assets/index-DY9CslcT.css +0 -1
  59. package/apps/web/dist/assets/index-DkDBEMwz.js +0 -11
  60. package/apps/web/dist/assets/session-DBOQL_bg.js +0 -1
  61. package/apps/web/dist/assets/useMutation-DKyQXgyj.js +0 -1
  62. package/apps/web/dist/assets/volume-2-KaKQ4zMJ.js +0 -1
@@ -1,3 +1,4 @@
1
+ import { FONT_WEIGHTS } from "@engenty-wizards/shared/projects";
1
2
  import { webContext } from "../render/chromium.js";
2
3
  import { htmlToMarkdown } from "../render/convert.js";
3
4
  import { safeFetch } from "../tools/net-guard.js";
@@ -29,28 +30,60 @@ function hex(value) {
29
30
  }
30
31
  const rgb = v.match(/^rgba?\(\s*(\d{1,3})[\s,]+(\d{1,3})[\s,]+(\d{1,3})/);
31
32
  if (rgb) {
32
- return `#${rgb
33
- .slice(1, 4)
34
- .map((n) => Math.min(255, Number(n)).toString(16).padStart(2, "0"))
35
- .join("")}`;
33
+ return toHex(rgb.slice(1, 4).map(Number));
34
+ }
35
+ const hsl = v.match(/^hsla?\(\s*([\d.]+)(?:deg)?[\s,]+([\d.]+)%[\s,]+([\d.]+)%/);
36
+ if (hsl) {
37
+ const [h, sat, l] = [Number(hsl[1]), Number(hsl[2]) / 100, Number(hsl[3]) / 100];
38
+ const f = (n) => {
39
+ const k = (n + h / 30) % 12;
40
+ return 255 * (l - sat * Math.min(l, 1 - l) * Math.max(-1, Math.min(k - 3, 9 - k, 1)));
41
+ };
42
+ return toHex([f(0), f(8), f(4)]);
43
+ }
44
+ const ok = v.match(/^oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)/);
45
+ if (ok) {
46
+ return toHex(oklchToRgb(Number(ok[1]) / (ok[2] ? 100 : 1), Number(ok[3]), Number(ok[4])));
36
47
  }
37
48
  return null;
38
49
  }
50
+ const toHex = (rgb) => `#${rgb
51
+ .map((n) => Math.round(Math.max(0, Math.min(255, n)))
52
+ .toString(16)
53
+ .padStart(2, "0"))
54
+ .join("")}`;
55
+ /** OKLCH (as CSS writes it, Tailwind 4 among others) in sRGB, 0–255. */
56
+ function oklchToRgb(l, c, h) {
57
+ const a = c * Math.cos((h * Math.PI) / 180);
58
+ const b = c * Math.sin((h * Math.PI) / 180);
59
+ const l_ = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3;
60
+ const m_ = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3;
61
+ const s_ = (l - 0.0894841775 * a - 1.291485548 * b) ** 3;
62
+ const linear = [
63
+ 4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_,
64
+ -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_,
65
+ -0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_,
66
+ ];
67
+ return linear.map((x) => 255 * (x <= 0.0031308 ? 12.92 * x : 1.055 * Math.max(0, x) ** (1 / 2.4) - 0.055));
68
+ }
39
69
  /** How far a colour is from grey, 0–255. */
40
70
  function chroma(color) {
41
71
  const [r, g, b] = [1, 3, 5].map((i) => Number.parseInt(color.slice(i, i + 2), 16));
42
72
  return Math.max(r, g, b) - Math.min(r, g, b);
43
73
  }
74
+ /** A colour as CSS writes it: hex, rgb(), hsl() or oklch(). */
75
+ const COLOUR = /#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)|oklch\([^)]*\)/;
44
76
  function coloursOf(css) {
45
77
  const variables = [];
46
- for (const m of css.matchAll(/(--[\w-]+)\s*:\s*(#[0-9a-fA-F]{3,8}|rgba?\([^)]*\))/g)) {
78
+ for (const m of css.matchAll(new RegExp(`(--[\\w-]+)\\s*:\\s*(${COLOUR.source})`, "g"))) {
47
79
  const value = hex(m[2]);
48
- if (value && !variables.some((v) => v.name === m[1])) {
80
+ // Tailwind's own variables are its machinery, not the site's colours.
81
+ if (value && !m[1].startsWith("--tw-") && !variables.some((v) => v.name === m[1])) {
49
82
  variables.push({ name: m[1], value });
50
83
  }
51
84
  }
52
85
  const counts = new Map();
53
- for (const m of css.matchAll(/#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)/g)) {
86
+ for (const m of css.matchAll(new RegExp(COLOUR.source, "g"))) {
54
87
  const value = hex(m[0]);
55
88
  if (value) {
56
89
  counts.set(value, (counts.get(value) ?? 0) + 1);
@@ -173,59 +206,247 @@ export async function readPage(raw, signal) {
173
206
  jsonld: jsonLdOf(html),
174
207
  };
175
208
  }
176
- export async function readWebsite(raw, signal) {
177
- const { res, base, html } = await loadPage(raw, signal);
178
- const tags = (name) => tagsOf(html, name);
179
- const meta = (key) => metaOf(html, key);
209
+ const saysLogo = (tag) => /logo/i.test([
210
+ attr(tag, "src"),
211
+ attr(tag, "alt"),
212
+ attr(tag, "class"),
213
+ attr(tag, "id"),
214
+ attr(tag, "aria-label"),
215
+ ]
216
+ .filter(Boolean)
217
+ .join(" "));
218
+ /**
219
+ * The logos a page shows, best first: the logo its structured data names, pictures that say
220
+ * "logo", what its home link in the header shows, and its icons. `inline`: SVGs drawn into the
221
+ * page count too, as `data:` addresses — for a person to pick from, too long for a model to read.
222
+ */
223
+ function logosOf(html, base, inline = false) {
180
224
  const logos = [];
181
- const addLogo = (href, hint) => {
182
- const url = absolute(href, base);
225
+ const add = (href, hint) => {
226
+ const url = href.startsWith("data:image/svg+xml") ? href : absolute(href, base);
183
227
  if (url && !logos.some((l) => l.url === url)) {
184
228
  logos.push({ url, hint });
185
229
  }
186
230
  };
187
- const images = [];
188
- for (const tag of tags("img")) {
231
+ for (const item of jsonLdOf(html)) {
232
+ const logo = item?.logo;
233
+ const href = typeof logo === "string" ? logo : logo?.url;
234
+ if (typeof href === "string") {
235
+ add(href, "the logo the page names in its structured data");
236
+ }
237
+ }
238
+ for (const tag of tagsOf(html, "img")) {
189
239
  const src = attr(tag, "src") || attr(tag, "data-src");
190
- const alt = attr(tag, "alt");
191
- if (!src || src.startsWith("data:")) {
240
+ if (src && !src.startsWith("data:") && saysLogo(tag)) {
241
+ const alt = attr(tag, "alt");
242
+ add(src, `image on the page${alt ? `, alt "${alt}"` : ""}`);
243
+ }
244
+ }
245
+ // What the link home shows is the logo on most sites, said so or not.
246
+ for (const m of html.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi)) {
247
+ const href = absolute(attr(`<a ${m[1]}>`, "href"), base);
248
+ if (!href || new URL(href).pathname !== "/" || new URL(href).host !== new URL(base).host) {
192
249
  continue;
193
250
  }
194
- if (/logo/i.test(`${src} ${alt} ${attr(tag, "class")} ${attr(tag, "id")}`)) {
195
- addLogo(src, `image on the page${alt ? `, alt "${alt}"` : ""}`);
251
+ const img = tagsOf(m[2], "img")[0];
252
+ const src = img && (attr(img, "src") || attr(img, "data-src"));
253
+ if (src && !src.startsWith("data:")) {
254
+ add(src, "image in the link to the home page");
255
+ }
256
+ const svg = inline ? m[2].match(/<svg\b[\s\S]*?<\/svg>/i)?.[0] : undefined;
257
+ if (svg) {
258
+ add(svgUrl(svg), "drawing in the link to the home page");
196
259
  }
197
- else {
198
- const url = absolute(src, base);
199
- if (url && images.length < 12 && !images.some((i) => i.url === url)) {
200
- images.push({ url, alt });
260
+ }
261
+ if (inline) {
262
+ for (const m of html.matchAll(/<svg\b[^>]*>[\s\S]*?<\/svg>/gi)) {
263
+ const open = m[0].match(/^<svg\b[^>]*>/i)?.[0] ?? "";
264
+ const title = m[0].match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] ?? "";
265
+ if (saysLogo(open) || /logo/i.test(title)) {
266
+ add(svgUrl(m[0]), "drawing on the page");
201
267
  }
202
268
  }
203
269
  }
204
- for (const tag of tags("link")) {
270
+ for (const tag of tagsOf(html, "link")) {
205
271
  const rel = attr(tag, "rel").toLowerCase();
206
272
  if (/apple-touch-icon|mask-icon|(^|\s)icon/.test(rel)) {
207
- addLogo(attr(tag, "href"), `${rel} ${attr(tag, "sizes")}`.trim());
208
- }
209
- }
210
- const og = meta("og:image");
211
- if (og) {
212
- const url = absolute(og, base);
213
- if (url) {
214
- images.unshift({ url, alt: "share image (og:image)" });
273
+ add(attr(tag, "href"), `${rel} ${attr(tag, "sizes")}`.trim());
215
274
  }
216
275
  }
217
- const sheets = tags("link")
276
+ return logos;
277
+ }
278
+ /** An inline SVG as a picture of its own: with its namespace, as a `data:` address. */
279
+ function svgUrl(svg) {
280
+ const own = /\bxmlns\s*=/.test(svg.match(/^<svg\b[^>]*>/i)?.[0] ?? "")
281
+ ? svg
282
+ : svg.replace(/^<svg\b/i, '<svg xmlns="http://www.w3.org/2000/svg"');
283
+ return `data:image/svg+xml;base64,${Buffer.from(own).toString("base64")}`;
284
+ }
285
+ /** The logos on a web page, for a person to pick from (Logos › from a web address). */
286
+ export async function scanLogos(raw, signal) {
287
+ const { base, html } = await loadPage(raw, signal);
288
+ return logosOf(html, base, true)
289
+ .filter((l) => !l.url.startsWith("data:") || l.url.length < 400_000)
290
+ .slice(0, 16);
291
+ }
292
+ /** The CSS a page brings: its `<style>` blocks, style attributes and first stylesheets. */
293
+ async function cssOf(html, base, signal) {
294
+ const sheets = tagsOf(html, "link")
218
295
  .filter((t) => /stylesheet/i.test(attr(t, "rel")))
219
296
  .map((t) => absolute(attr(t, "href"), base))
220
297
  .filter((u) => Boolean(u))
221
298
  .slice(0, 4);
222
299
  const inline = [...html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1]);
223
300
  const styled = [...html.matchAll(/\bstyle\s*=\s*"([^"]*)"/gi)].map((m) => m[1]);
224
- const css = [
301
+ return [
225
302
  ...inline,
226
303
  ...styled,
227
304
  ...(await Promise.all(sheets.map((s) => stylesheet(s, signal)))),
228
305
  ].join("\n");
306
+ }
307
+ /** Family names that are no brand's own: generic and system faces. */
308
+ const GENERIC_FONT = /^(serif|sans-serif|monospace|cursive|fantasy|system-ui|ui-[a-z-]+|-apple-system|blinkmacsystemfont|inherit|initial|unset|revert|emoji|math|fangsong|segoe ui( emoji| symbol)?|helvetica( neue)?|arial|roboto|noto (sans|color emoji)|apple color emoji|courier( new)?|times( new roman)?|georgia|menlo|monaco|consolas|var\(.*|liberation mono|sfmono-regular|sf mono|sf pro( text| display)?|dejavu sans( mono)?)$/i;
309
+ /** "Geist Variable" is Geist: a variable font's file name, not another family. */
310
+ const familyName = (raw) => raw
311
+ .replace(/^[\s"'(]+|[\s"')]+$/g, "")
312
+ .replace(/\s+variable$/i, "")
313
+ .trim();
314
+ /**
315
+ * The typefaces a page uses, most used first, with the weights its `@font-face` rules or its
316
+ * Google / Bunny Fonts links name.
317
+ */
318
+ function fontsOf(css, html) {
319
+ const counts = new Map();
320
+ const weights = new Map();
321
+ const count = (family, n = 1) => counts.set(family, (counts.get(family) ?? 0) + n);
322
+ const weigh = (family, list) => {
323
+ const set = weights.get(family) ?? new Set();
324
+ for (const w of list) {
325
+ set.add(w);
326
+ }
327
+ weights.set(family, set);
328
+ };
329
+ const first = (decl) => decl
330
+ .split(",")
331
+ .map(familyName)
332
+ .find((f) => /[a-z]/i.test(f) && !GENERIC_FONT.test(f));
333
+ // --font-weight-…, --font-size-… and the like are no families.
334
+ for (const m of css.matchAll(/(?:font-family|--font-(?!weight|size|feature|variation|style|stretch)[\w-]+)\s*:\s*([^;}]+)/gi)) {
335
+ const family = first(m[1]);
336
+ if (family) {
337
+ count(family);
338
+ }
339
+ }
340
+ for (const m of css.matchAll(/@font-face\s*\{([^}]*)\}/gi)) {
341
+ const family = first(m[1].match(/font-family\s*:\s*([^;]+)/i)?.[1] ?? "");
342
+ const range = (m[1].match(/font-weight\s*:\s*([^;]+)/i)?.[1] ?? "400")
343
+ .split(/\s+/)
344
+ .map(Number)
345
+ .filter((n) => n >= 100 && n <= 900);
346
+ if (family && range.length) {
347
+ const [lo, hi] = [Math.min(...range), Math.max(...range)];
348
+ weigh(family, FONT_WEIGHTS.filter((w) => w >= lo && w <= hi));
349
+ }
350
+ }
351
+ // fonts.googleapis.com/css2?family=Inter:wght@400;700 and fonts.bunny.net/css?family=inter:400,700
352
+ for (const m of html.matchAll(/fonts\.(?:googleapis\.com|bunny\.net)\/css2?\?([^"'\s>]+)/gi)) {
353
+ for (const part of m[1].replace(/&amp;/g, "&").split("&")) {
354
+ if (!part.startsWith("family=")) {
355
+ continue;
356
+ }
357
+ for (const one of decodeURIComponent(part.slice(7)).split("|")) {
358
+ const [name, spec = ""] = one.split(":");
359
+ const family = familyName(name.replace(/\+/g, " ").replace(/-/g, " ")).replace(/\b\w/g, (c) => c.toUpperCase());
360
+ count(family, 3);
361
+ weigh(family, (spec.match(/\d{3}/g) ?? []).map(Number).filter((w) => w >= 100 && w <= 900));
362
+ }
363
+ }
364
+ }
365
+ return [...counts.entries()]
366
+ .sort((a, b) => b[1] - a[1])
367
+ .slice(0, 6)
368
+ .map(([family]) => ({
369
+ family,
370
+ weights: [...(weights.get(family) ?? [])].sort((a, b) => a - b),
371
+ }));
372
+ }
373
+ /** The typefaces of a web page, for a person to pick from (Typography › from a web address). */
374
+ export async function scanFonts(raw, signal) {
375
+ const { base, html } = await loadPage(raw, signal);
376
+ return fontsOf(await cssOf(html, base, signal), html);
377
+ }
378
+ /**
379
+ * The colours of a web page, for a person to pick from (Colours › from a web address): its
380
+ * theme colour, the colours its stylesheet names, then the ones it uses most. A named one is
381
+ * named after its variable ("--brand-primary" → "brand primary").
382
+ */
383
+ export async function scanColors(raw, signal) {
384
+ const { base, html } = await loadPage(raw, signal);
385
+ const { variables, frequent } = coloursOf(await cssOf(html, base, signal));
386
+ const out = [];
387
+ const add = (name, value) => {
388
+ if (value && !out.some((c) => c.value === value)) {
389
+ out.push({ name, value });
390
+ }
391
+ };
392
+ add("theme color", hex(metaOf(html, "theme-color")));
393
+ for (const v of variables) {
394
+ add(v.name.replace(/^--/, "").replace(/[-_]+/g, " "), v.value);
395
+ }
396
+ for (const value of frequent) {
397
+ add("", value);
398
+ }
399
+ return out.slice(0, 24);
400
+ }
401
+ /**
402
+ * The pictures of a web page, for a person to pick assets from: its share image, then the
403
+ * images it shows, logos left out.
404
+ */
405
+ export async function scanImages(raw, signal) {
406
+ const { base, html } = await loadPage(raw, signal);
407
+ const out = [];
408
+ const add = (href, hint) => {
409
+ const url = absolute(href, base);
410
+ if (url && !out.some((i) => i.url === url)) {
411
+ out.push({ url, hint });
412
+ }
413
+ };
414
+ const og = metaOf(html, "og:image");
415
+ if (og) {
416
+ add(og, "share image");
417
+ }
418
+ for (const tag of tagsOf(html, "img")) {
419
+ const src = attr(tag, "src") || attr(tag, "data-src");
420
+ if (src && !src.startsWith("data:") && !saysLogo(tag)) {
421
+ add(src, attr(tag, "alt"));
422
+ }
423
+ }
424
+ return out.slice(0, 24);
425
+ }
426
+ export async function readWebsite(raw, signal) {
427
+ const { res, base, html } = await loadPage(raw, signal);
428
+ const tags = (name) => tagsOf(html, name);
429
+ const meta = (key) => metaOf(html, key);
430
+ const logos = logosOf(html, base);
431
+ const images = [];
432
+ for (const tag of tags("img")) {
433
+ const src = attr(tag, "src") || attr(tag, "data-src");
434
+ if (!src || src.startsWith("data:") || saysLogo(tag)) {
435
+ continue;
436
+ }
437
+ const url = absolute(src, base);
438
+ if (url && images.length < 12 && !images.some((i) => i.url === url)) {
439
+ images.push({ url, alt: attr(tag, "alt") });
440
+ }
441
+ }
442
+ const og = meta("og:image");
443
+ if (og) {
444
+ const url = absolute(og, base);
445
+ if (url) {
446
+ images.unshift({ url, alt: "share image (og:image)" });
447
+ }
448
+ }
449
+ const css = await cssOf(html, base, signal);
229
450
  const host = new URL(base).hostname;
230
451
  const links = [];
231
452
  for (const m of html.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi)) {
@@ -252,6 +473,7 @@ export async function readWebsite(raw, signal) {
252
473
  ...coloursOf(css),
253
474
  logos: logos.slice(0, 10),
254
475
  images,
476
+ fonts: fontsOf(css, html),
255
477
  },
256
478
  links: links.slice(0, 10),
257
479
  };
@@ -209,7 +209,7 @@ export const projectFile = sqliteTable("project_file", {
209
209
  projectId: text("project_id")
210
210
  .notNull()
211
211
  .references(() => project.id, { onDelete: "cascade" }),
212
- kind: text("kind", { enum: ["logo", "asset", "document"] }).notNull(),
212
+ kind: text("kind", { enum: ["logo", "asset", "document", "pattern"] }).notNull(),
213
213
  name: text("name").notNull(),
214
214
  mime: text("mime").notNull(),
215
215
  size: integer("size").notNull(),
@@ -35,6 +35,20 @@ const MIME_BY_EXT = {
35
35
  jpeg: "image/jpeg",
36
36
  webp: "image/webp",
37
37
  gif: "image/gif",
38
+ svg: "image/svg+xml",
39
+ avif: "image/avif",
40
+ mp4: "video/mp4",
41
+ mov: "video/quicktime",
42
+ webm: "video/webm",
43
+ mp3: "audio/mpeg",
44
+ wav: "audio/wav",
45
+ m4a: "audio/mp4",
46
+ woff2: "font/woff2",
47
+ woff: "font/woff",
48
+ ttf: "font/ttf",
49
+ otf: "font/otf",
50
+ glb: "model/gltf-binary",
51
+ gltf: "model/gltf+json",
38
52
  };
39
53
  /** The type to read a file as: mail servers and browsers often only say "octet-stream". */
40
54
  export function documentMime(name, mime) {
@@ -1,5 +1,6 @@
1
1
  import { pOf } from "@engenty-wizards/shared/decision";
2
2
  import { allFields, dataRef, isDecisionStep, LOCALES, templateRefs, wizardLang, } from "@engenty-wizards/shared/definition";
3
+ import { mainLogo, ruleLines } from "@engenty-wizards/shared/projects";
3
4
  import { SURFACE_GUIDE, validateSurface, } from "@engenty-wizards/shared/surface";
4
5
  import { Agent } from "@mastra/core/agent";
5
6
  import { generateText, Output } from "ai";
@@ -18,7 +19,7 @@ import { runWidgetStep } from "../widgets/step.js";
18
19
  import { decide } from "./decide.js";
19
20
  import { prepareInputs } from "./prepare.js";
20
21
  import { runSurfaceStep } from "./surface.js";
21
- import { answersAsText, colorLine, factLines, renderTemplate, resolveRef, } from "./template.js";
22
+ import { answersAsText, assetSectionLine, colorLine, factLines, logoSectionLine, renderTemplate, resolveRef, typeLines, } from "./template.js";
22
23
  import { StepError } from "./types.js";
23
24
  /** Today's date as the wizard's people write it. */
24
25
  function today(ctx) {
@@ -28,13 +29,26 @@ function today(ctx) {
28
29
  day: "numeric",
29
30
  });
30
31
  }
32
+ const sectionTitle = (s) => s.description ? `${s.title} – ${s.description}` : s.title;
31
33
  function brandBlock(ctx) {
32
34
  const b = ctx.scope.brand;
33
35
  const parts = [
34
36
  b.name,
35
37
  b.about,
36
38
  b.facts?.length ? `Facts:\n${factLines(b.facts)}` : "",
37
- b.colors?.length ? `Colours (the first is the accent): ${colorLine(b.colors)}` : "",
39
+ b.colors?.length
40
+ ? `Colours (the first is the accent): ${colorLine(b.colors, b.colorSections, b.gradients)}`
41
+ : "",
42
+ b.colorRules?.length
43
+ ? `Rules for the colours, in the brand's words:\n${ruleLines(b.colorRules)}`
44
+ : "",
45
+ b.logoRules?.length
46
+ ? `Rules for the logo, in the brand's words:\n${ruleLines(b.logoRules)}`
47
+ : "",
48
+ b.fonts?.length || b.textStyles?.length
49
+ ? `Typography (set text in these fonts, with system fallbacks):\n${typeLines(b.fonts, b.textStyles)}`
50
+ : "",
51
+ b.typeRules?.length ? `Rules for type, in the brand's words:\n${ruleLines(b.typeRules)}` : "",
38
52
  ].filter(Boolean);
39
53
  return parts.length ? `# WHO THIS WIZARD BELONGS TO\n${parts.join("\n")}` : "";
40
54
  }
@@ -656,13 +670,35 @@ function placeableImages(step, ctx) {
656
670
  }
657
671
  }
658
672
  const logos = ctx.projectFiles.filter((f) => f.kind === "logo");
659
- for (const [i, f] of logos.entries()) {
660
- const note = f.description ? `: ${f.description}` : "";
661
- out.push({ id: f.id, label: `${i === 0 ? "Company logo" : "Logo variant"}${note}` });
673
+ const main = mainLogo(logos, ctx.scope.brand.logoSections);
674
+ for (const f of logos) {
675
+ const section = logoSectionLine(ctx.scope.brand.logoSections, f.id);
676
+ out.push({
677
+ id: f.id,
678
+ label: [
679
+ f === main ? "Company logo" : "Logo variant",
680
+ section ? ` — section ${section}` : "",
681
+ f.description ? `: ${f.description}` : "",
682
+ ].join(""),
683
+ });
684
+ }
685
+ for (const p of ctx.scope.brand.patterns ?? []) {
686
+ const file = ctx.projectFiles.find((f) => f.id === p.file);
687
+ const section = ctx.scope.brand.colorSections?.find((s) => s.id === p.section);
688
+ if (file) {
689
+ out.push({
690
+ id: file.id,
691
+ label: `${p.tile ? "Pattern to tile" : "Background fill"}: ${p.name || file.name}${section ? ` (${sectionTitle(section)})` : ""}`,
692
+ });
693
+ }
662
694
  }
663
695
  for (const f of ctx.projectFiles) {
664
696
  if (f.kind === "asset" && f.mime.startsWith("image/")) {
665
- out.push({ id: f.id, label: `${f.description || f.name} (from the project's assets)` });
697
+ const section = assetSectionLine(ctx.scope.brand.assetSections, f.id);
698
+ out.push({
699
+ id: f.id,
700
+ label: `${f.description || f.name} (from the project's assets${section ? `, section ${section}` : ""})`,
701
+ });
666
702
  }
667
703
  }
668
704
  return out;
@@ -1,4 +1,5 @@
1
1
  import { allFields, isAudioValue, isLocationValue, itemsTotals, LOCALES, locationText, wizardLang, } from "@engenty-wizards/shared/definition";
2
+ import { gradientCss, textStyleLine, } from "@engenty-wizards/shared/projects";
2
3
  import { listAsMarkdown } from "@engenty-wizards/shared/store";
3
4
  const TEMPLATE_RE = /\{\{\s*([^}]+?)\s*\}\}/g;
4
5
  function money(n, currency, locale) {
@@ -34,8 +35,52 @@ export function itemsAsMarkdown(field, value, values, lang = "de") {
34
35
  export function factLines(facts) {
35
36
  return facts.map((f) => `${f.label}: ${f.value}`).join("\n");
36
37
  }
37
- export function colorLine(colors) {
38
- return colors.map((c) => (c.name ? `${c.name} ${c.value}` : c.value)).join(", ");
38
+ /**
39
+ * The colours as a model reads them; a section's after the standard ones, with when to use them.
40
+ * A gradients section names its gradients as CSS.
41
+ */
42
+ export function colorLine(colors, sections = [], gradients = []) {
43
+ const line = (list) => list.map((c) => (c.name ? `${c.name} ${c.value}` : c.value)).join(", ");
44
+ const known = (c) => sections.some((s) => s.id === c.section);
45
+ return [
46
+ line(colors.filter((c) => !known(c))),
47
+ ...sections.map((s) => {
48
+ const head = s.description ? `${s.title} (${s.description})` : s.title;
49
+ if (s.kind === "gradients") {
50
+ const own = gradients.filter((g) => g.section === s.id);
51
+ return own.length
52
+ ? `${head}: ${own.map((g) => `${g.name || "gradient"} ${gradientCss(g)}`).join("; ")}`
53
+ : "";
54
+ }
55
+ const own = colors.filter((c) => c.section === s.id);
56
+ return own.length ? `${head}: ${line(own)}` : "";
57
+ }),
58
+ ]
59
+ .filter(Boolean)
60
+ .join(" · ");
61
+ }
62
+ /** The brand's type as a model reads it: its fonts, then its text styles. */
63
+ export function typeLines(fonts = [], styles = []) {
64
+ const families = fonts.map((f) => {
65
+ const weights = f.weights.length ? `, weights ${f.weights.join("/")}` : "";
66
+ return `- ${f.family}${f.role ? ` (${f.role})` : ""}${weights}`;
67
+ });
68
+ const scale = styles.map((s) => `- ${s.name}: ${textStyleLine(s, fonts)}`);
69
+ return [
70
+ families.length ? `Fonts:\n${families.join("\n")}` : "",
71
+ scale.length ? `Text styles:\n${scale.join("\n")}` : "",
72
+ ]
73
+ .filter(Boolean)
74
+ .join("\n");
75
+ }
76
+ const sectionWords = (s) => !s ? "" : s.description ? `${s.title} (${s.description})` : s.title;
77
+ /** The logo's section in words — "Logomark (small spaces, app icons)" — or "" where it has none. */
78
+ export function logoSectionLine(sections, fileId) {
79
+ return sectionWords(sections?.find((x) => x.logos.includes(fileId)));
80
+ }
81
+ /** The title of the asset's section — "Icons" — or "" where it has none. */
82
+ export function assetSectionLine(sections, fileId) {
83
+ return sections?.find((x) => x.assets.includes(fileId))?.title ?? "";
39
84
  }
40
85
  function brandRef(brand, key) {
41
86
  switch (key) {
@@ -49,7 +94,9 @@ function brandRef(brand, key) {
49
94
  case "accent":
50
95
  return brand.colors?.[0]?.value ?? "";
51
96
  case "colors":
52
- return colorLine(brand.colors ?? []);
97
+ return colorLine(brand.colors ?? [], brand.colorSections, brand.gradients);
98
+ case "fonts":
99
+ return typeLines(brand.fonts, brand.textStyles);
53
100
  default:
54
101
  return "";
55
102
  }
@@ -3,8 +3,9 @@ import { existsSync } from "node:fs";
3
3
  import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import { dirname, join, normalize } from "node:path";
5
5
  import { dataRef, FILM_SIZES } from "@engenty-wizards/shared/definition";
6
+ import { gradientCss, ruleLines, textStyleLine } from "@engenty-wizards/shared/projects";
6
7
  import { unzipSync } from "fflate";
7
- import { renderTemplate, resolveRef } from "../engine/template.js";
8
+ import { logoSectionLine, renderTemplate, resolveRef } from "../engine/template.js";
8
9
  import { env } from "../env.js";
9
10
  import { getBlob } from "../files/blobs.js";
10
11
  import { extFor, loadAsset } from "../files/storage.js";
@@ -128,23 +129,72 @@ export async function prepareFilmFolder(step, ctx) {
128
129
  // The space's brand.
129
130
  const b = ctx.scope.brand;
130
131
  const logos = [];
132
+ const patterns = [];
133
+ for (const p of b.patterns ?? []) {
134
+ const f = ctx.projectFiles.find((x) => x.id === p.file);
135
+ try {
136
+ if (f) {
137
+ const { data } = await projectFileContent(f.projectId, f.id);
138
+ const name = `patterns/${safeName(f.name.replace(/\.[^.]+$/, ""))}.${extFor(f.mime) === "bin" ? (f.name.split(".").pop() ?? "bin") : extFor(f.mime)}`;
139
+ await put(join(root, "brand"), name, data);
140
+ patterns.push({ file: name, name: p.name, tile: Boolean(p.tile) });
141
+ }
142
+ }
143
+ catch {
144
+ // a pattern less
145
+ }
146
+ }
131
147
  for (const f of ctx.projectFiles.filter((p) => p.kind === "logo")) {
132
148
  try {
133
149
  const { data } = await projectFileContent(f.projectId, f.id);
134
150
  const name = `logos/${safeName(f.name.replace(/\.[^.]+$/, ""))}.${extFor(f.mime) === "bin" ? (f.name.split(".").pop() ?? "bin") : extFor(f.mime)}`;
135
151
  await put(join(root, "brand"), name, data);
136
- logos.push(name);
152
+ const section = logoSectionLine(b.logoSections, f.id);
153
+ logos.push({
154
+ file: name,
155
+ ...(section ? { section } : {}),
156
+ ...(f.description ? { description: f.description } : {}),
157
+ });
137
158
  }
138
159
  catch {
139
160
  // a logo less
140
161
  }
141
162
  }
142
163
  await put(join(root, "brand"), "brand.json", JSON.stringify({
143
- note: "The space's brand. Colours are named by what they are for; the first is the accent.",
164
+ note: "The space's brand. Colours are named by what they are for; the first is the accent. A logo's or a colour's section says when to use it; colorRules, logoRules and typeRules, where there are any, say how colours, logos and type may be used; text is set in the fonts.",
144
165
  name: b.name ?? "",
145
166
  about: b.about ?? "",
146
- colors: (b.colors ?? []).map((c) => ({ name: c.name, value: c.value })),
167
+ colors: (b.colors ?? []).map((c) => {
168
+ const s = b.colorSections?.find((x) => x.id === c.section);
169
+ return { name: c.name, value: c.value, ...(s ? { section: s.title } : {}) };
170
+ }),
171
+ ...(b.gradients?.length
172
+ ? {
173
+ gradients: b.gradients.map((g) => ({
174
+ name: g.name,
175
+ section: b.colorSections?.find((x) => x.id === g.section)?.title,
176
+ css: gradientCss(g),
177
+ })),
178
+ }
179
+ : {}),
180
+ ...(patterns.length ? { patterns } : {}),
147
181
  logos,
182
+ ...(b.colorRules?.length ? { colorRules: ruleLines(b.colorRules).split("\n") } : {}),
183
+ ...(b.fonts?.length
184
+ ? {
185
+ fonts: b.fonts.map(({ family, role, weights }) => ({ family, role, weights })),
186
+ }
187
+ : {}),
188
+ ...(b.textStyles?.length
189
+ ? {
190
+ textStyles: b.textStyles.map((s) => ({
191
+ name: s.name,
192
+ style: textStyleLine(s, b.fonts ?? []),
193
+ })),
194
+ }
195
+ : {}),
196
+ ...(b.typeRules?.length ? { typeRules: ruleLines(b.typeRules).split("\n") } : {}),
197
+ ...(b.logoRules?.length ? { logoRules: ruleLines(b.logoRules).split("\n") } : {}),
148
198
  facts: (b.facts ?? []).map((f) => `${f.label}: ${f.value}`),
149
199
  }, null, 1));
150
200
  // What the run made before.