wizards 0.3.5 → 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 (69) 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/docs/user/test-and-share.md +21 -11
  5. package/apps/runtime/dist/documents/parse.js +14 -0
  6. package/apps/runtime/dist/engine/steps.js +42 -6
  7. package/apps/runtime/dist/engine/template.js +50 -3
  8. package/apps/runtime/dist/film/project.js +54 -4
  9. package/apps/runtime/dist/routes/studio.js +52 -3
  10. package/apps/runtime/dist/services/brand.js +6 -3
  11. package/apps/runtime/dist/services/project-files.js +59 -3
  12. package/apps/runtime/dist/services/projects.js +150 -2
  13. package/apps/runtime/dist/services/wizards.js +25 -0
  14. package/apps/web/dist/assets/AboutDialog-C0dN86zd.js +1 -0
  15. package/apps/web/dist/assets/{ChangelogDialog-VEBTlC3f.js → ChangelogDialog-Cv81t7tj.js} +1 -1
  16. package/apps/web/dist/assets/ChatView-uogJ3nht.js +10 -0
  17. package/apps/web/dist/assets/EditorPage-n8YuTh6j.js +3 -0
  18. package/apps/web/dist/assets/{FlowPreview-86xWWfdk.js → FlowPreview-D4dp1MWS.js} +1 -1
  19. package/apps/web/dist/assets/{Harness-CF6H2TYJ.js → Harness-v7r0e6vK.js} +2 -2
  20. package/apps/web/dist/assets/NewWizardChannels-Be-7VhBi.js +1 -0
  21. package/apps/web/dist/assets/NewWizardPage-C8hPvrK8.js +7 -0
  22. package/apps/web/dist/assets/ProjectPage-B686ArYy.js +1 -0
  23. package/apps/web/dist/assets/PublicRunner-DeCpVfZP.js +1 -0
  24. package/apps/web/dist/assets/{ReadOnly-mhKW496R.js → ReadOnly-B-UCgnqW.js} +1 -1
  25. package/apps/web/dist/assets/RunDrawer-C6pzi8AJ.js +4 -0
  26. package/apps/web/dist/assets/{RunnerView-CTRFgyu5.js → RunnerView-Deenymq0.js} +1 -1
  27. package/apps/web/dist/assets/SettingsPage-CK6L0yEe.js +1 -0
  28. package/apps/web/dist/assets/SetupPage-9CH3qXdH.js +1 -0
  29. package/apps/web/dist/assets/SharePage-B778xUFo.js +1 -0
  30. package/apps/web/dist/assets/changelog-DCJo2mbx.js +1 -0
  31. package/apps/web/dist/assets/{circle-alert-CYs622_r.js → circle-alert-DLvC9ePT.js} +1 -1
  32. package/apps/web/dist/assets/{esm-B52LIMhP.js → esm-BJskogLD.js} +1 -1
  33. package/apps/web/dist/assets/index-D6hUM8NU.css +1 -0
  34. package/apps/web/dist/assets/index-Df6UxElH.js +12 -0
  35. package/apps/web/dist/assets/{lock-BvSWZOin.js → lock-DR7NakPn.js} +1 -1
  36. package/apps/web/dist/assets/{phone-CI2Flerg.js → phone-BORh3jfv.js} +1 -1
  37. package/apps/web/dist/assets/{settings-menu-BOOPxcJH.js → settings-menu-DV5D91TD.js} +1 -1
  38. package/apps/web/dist/assets/{ui-CMdSOoUZ.js → ui-BOCXzzHi.js} +1 -1
  39. package/apps/web/dist/assets/{upload-Cz9HgBT9.js → upload-B3yv8gsk.js} +1 -1
  40. package/apps/web/dist/index.html +3 -8
  41. package/apps/web/dist/mcp/flow.html +2 -2
  42. package/node_modules/@engenty-wizards/plugin-sdk/dist/index.d.ts +3 -3
  43. package/node_modules/@engenty-wizards/shared/dist/changes.d.ts +22 -0
  44. package/node_modules/@engenty-wizards/shared/dist/changes.js +61 -0
  45. package/node_modules/@engenty-wizards/shared/dist/definition.d.ts +8 -8
  46. package/node_modules/@engenty-wizards/shared/dist/marketplace.d.ts +1 -1
  47. package/node_modules/@engenty-wizards/shared/dist/projects.d.ts +138 -3
  48. package/node_modules/@engenty-wizards/shared/dist/projects.js +153 -4
  49. package/node_modules/@engenty-wizards/shared/dist/wizard-package.d.ts +1 -1
  50. package/package.json +1 -1
  51. package/apps/web/dist/assets/AboutDialog-D1N4-3FE.js +0 -1
  52. package/apps/web/dist/assets/ChatView-DSRUEpb6.js +0 -10
  53. package/apps/web/dist/assets/EditorPage-UkaHDklX.js +0 -3
  54. package/apps/web/dist/assets/Marketplace-C-Vz3yEk.js +0 -2
  55. package/apps/web/dist/assets/NewWizardChannels-Cc7gDitg.js +0 -1
  56. package/apps/web/dist/assets/NewWizardPage-sY5q-l99.js +0 -7
  57. package/apps/web/dist/assets/ProjectPage-iSdpJAZS.js +0 -2
  58. package/apps/web/dist/assets/PublicRunner-BgRXMUxS.js +0 -1
  59. package/apps/web/dist/assets/RunDrawer-B0gJzdxH.js +0 -4
  60. package/apps/web/dist/assets/SettingsPage-CBF8i6j6.js +0 -1
  61. package/apps/web/dist/assets/SetupPage-Fpz9aSLN.js +0 -1
  62. package/apps/web/dist/assets/SharePage-Dv3Csh4A.js +0 -1
  63. package/apps/web/dist/assets/brand-BamUjXVr.js +0 -2
  64. package/apps/web/dist/assets/changelog-ViHoNW7e.js +0 -1
  65. package/apps/web/dist/assets/index-Bdx1Qity.js +0 -11
  66. package/apps/web/dist/assets/index-CDdJDojq.css +0 -1
  67. package/apps/web/dist/assets/session-BEHF6g4Y.js +0 -1
  68. package/apps/web/dist/assets/useMutation-DKyQXgyj.js +0 -1
  69. package/apps/web/dist/assets/volume-2-CyQuz1nn.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(),
@@ -15,18 +15,30 @@ measured from the latest runs after five of them.
15
15
 
16
16
  ## Publish
17
17
 
18
- "Publish" makes the draft the live version. The editor's top bar and the wizard's card on the
19
- start page say where it runs:
18
+ A wizard goes live the first time you share it: "Share" publishes the draft as version 1 as the
19
+ dialog opens (nobody has the link yet, and "Only you" under Access keeps it closed). A draft with
20
+ issues is not published; the dialog lists what to fix first, with "Let the assistant fix it".
21
+
22
+ After that the editor's top bar has "Make changes live" whenever the draft differs from the
23
+ version people run. It opens a dialog that says:
24
+
25
+ | Part | Says |
26
+ |---|---|
27
+ | What changed | The wizard's own settings, each step by its title (new, removed, or which of its parts: fields, instructions, result …), the steps' order, the files |
28
+ | Tested | Whether a test run used exactly this draft: "Tested 4 minutes ago – with these changes" and "See the run", or "Not tested with these changes yet" with "Test now" |
29
+ | Notes | Anyone in the middle of a run carries on with the version they started; what a run costs now where that changed; with an account, that the version goes to the cloud too |
30
+
31
+ "Go live" makes the draft the next version; the link stays the same. Issues stop it, as on the
32
+ first share. The editor's top bar and the wizard's card on the start page say where it runs:
20
33
 
21
34
  | It says | Means |
22
35
  |---|---|
23
- | Not published yet · Draft | Only you can run it, as a test |
36
+ | Not published yet · Draft | Only you can run it, as a test. Sharing it publishes it |
24
37
  | Live · version 3 | The link runs this version. With a cloud icon: on engenty.ai, for everyone with the link |
25
38
  | Live · 1 note | It runs in the cloud, one step there with less (see "Share") |
26
39
  | Local | Published on an install without an account: the link answers only on this computer |
27
40
  | Being sent | The version did not reach engenty.ai yet; the studio tries again by itself |
28
41
  | Not in the cloud | engenty.ai does not run this version. "Share" says why and what helps |
29
- | Unpublished changes | You changed the draft since. The link still runs the published version |
30
42
 
31
43
  On a card, "Link" copies the address others can open, and the arrow opens it. A wizard that
32
44
  runs only on this computer has no address for others: its "Link" says so and how an account
@@ -34,8 +46,7 @@ gives it one, and the arrow opens it here.
34
46
 
35
47
  ## Share
36
48
 
37
- "Share" is its own step beside "Publish": it says who may open the wizard and how others reach
38
- it, and copies the link.
49
+ "Share" says who may open the wizard and how others reach it, and copies the link.
39
50
 
40
51
  | Part | Does |
41
52
  |---|---|
@@ -46,8 +57,7 @@ it, and copies the link.
46
57
  | Copy link | The link people open. It opens the first browser channel, and the wizard offers the others on its first page |
47
58
  | The gear | Runs a day (50 unless you change it, with what that costs at most), each message channel's keyword, and "Create a new link": the old link and its QR code stop working, in the cloud too |
48
59
 
49
- Where the wizard is not published yet, or the draft has changes, the dialog says so on top and
50
- publishes from there.
60
+ Where the draft has changes, the dialog says so on top and leads to "Make changes live".
51
61
 
52
62
  One visitor can start 6 runs an hour.
53
63
 
@@ -138,13 +148,13 @@ for anyone else. To let others run the wizard:
138
148
 
139
149
  | Way | How |
140
150
  |---|---|
141
- | The cloud | With an [account](./models.md#account) signed in, "Publish" also sends the version to engenty.ai. Its link there runs on the account's credits, also while your computer is off |
151
+ | The cloud | With an [account](./models.md#account) signed in, every version that goes live (the first share, and each "Go live") also goes to engenty.ai. Its link there runs on the account's credits, also while your computer is off |
142
152
  | Your own server | [Run engenty wizards on a server](./server.md) with a public https address |
143
153
 
144
154
  ### In the cloud
145
155
 
146
- Once an account is signed in under Settings → Account, every "Publish" sends the published
147
- version to engenty.ai. The avatar then carries a small cloud, and its menu says "Connected to
156
+ Once an account is signed in under Settings → Account, every version that goes live is sent to
157
+ engenty.ai too. The avatar then carries a small cloud, and its menu says "Connected to
148
158
  engenty.ai" with the credits. The share dialog gives the wizard's address at engenty.ai, which
149
159
  stays the same when you publish again. Where the cloud does not run this version, the dialog
150
160
  says so on top:
@@ -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
  }