wizards 0.3.6 → 0.3.8

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 (71) hide show
  1. package/apps/runtime/dist/agents/studio-assistant.js +364 -38
  2. package/apps/runtime/dist/agents/website.js +256 -34
  3. package/apps/runtime/dist/assistant/runs.js +24 -0
  4. package/apps/runtime/dist/db/schema.js +1 -1
  5. package/apps/runtime/dist/docs/user/assistant.md +25 -7
  6. package/apps/runtime/dist/documents/parse.js +14 -0
  7. package/apps/runtime/dist/engine/steps.js +42 -6
  8. package/apps/runtime/dist/engine/template.js +50 -3
  9. package/apps/runtime/dist/env.js +5 -0
  10. package/apps/runtime/dist/film/project.js +54 -4
  11. package/apps/runtime/dist/manage.js +4 -4
  12. package/apps/runtime/dist/models.js +6 -0
  13. package/apps/runtime/dist/routes/studio.js +137 -40
  14. package/apps/runtime/dist/services/assistant-chats.js +49 -6
  15. package/apps/runtime/dist/services/brand.js +6 -3
  16. package/apps/runtime/dist/services/project-files.js +59 -3
  17. package/apps/runtime/dist/services/projects.js +150 -2
  18. package/apps/web/dist/assets/AboutDialog-ClV4gNiW.js +1 -0
  19. package/apps/web/dist/assets/{ChangelogDialog-m2M7gZiB.js → ChangelogDialog-DsSEkayM.js} +1 -1
  20. package/apps/web/dist/assets/ChatView-BdYCnDLk.js +10 -0
  21. package/apps/web/dist/assets/EditorPage-7PZzkNBT.js +3 -0
  22. package/apps/web/dist/assets/{FlowPreview-BdRy7FO7.js → FlowPreview-C_x5Kc2j.js} +1 -1
  23. package/apps/web/dist/assets/{Harness-DmJkTwOO.js → Harness-CvvtSTZC.js} +2 -2
  24. package/apps/web/dist/assets/NewWizardChannels-g1qCd_tQ.js +1 -0
  25. package/apps/web/dist/assets/NewWizardPage-B3hswj5r.js +7 -0
  26. package/apps/web/dist/assets/ProjectPage-CFDncRxT.js +1 -0
  27. package/apps/web/dist/assets/PublicRunner-C9N5r-sT.js +1 -0
  28. package/apps/web/dist/assets/{ReadOnly-CaGzgOPe.js → ReadOnly-C1UcqOlS.js} +1 -1
  29. package/apps/web/dist/assets/RunDrawer-B8YzFvgz.js +4 -0
  30. package/apps/web/dist/assets/RunnerView-ii-PP5c-.js +1 -0
  31. package/apps/web/dist/assets/SettingsPage-ClEP5Q0t.js +1 -0
  32. package/apps/web/dist/assets/SetupPage-CDFzo6LT.js +1 -0
  33. package/apps/web/dist/assets/SharePage-BV2S0g84.js +1 -0
  34. package/apps/web/dist/assets/changelog-wZI-UaUi.js +1 -0
  35. package/apps/web/dist/assets/{circle-alert-CpuMIhbH.js → circle-alert-D-Q6DK7b.js} +1 -1
  36. package/apps/web/dist/assets/{esm-DRaZAcXQ.js → esm-CTna4iL0.js} +1 -1
  37. package/apps/web/dist/assets/index-DUjJx-uk.css +1 -0
  38. package/apps/web/dist/assets/index-DnQgOKOQ.js +15 -0
  39. package/apps/web/dist/assets/{lock-BGotwAyN.js → lock-DwGN_KOC.js} +1 -1
  40. package/apps/web/dist/assets/{phone-CA2A8W7I.js → phone-XIVIe17d.js} +1 -1
  41. package/apps/web/dist/assets/{settings-menu-DxswVOqQ.js → settings-menu-JuSRq6NP.js} +1 -1
  42. package/apps/web/dist/assets/{ui-DAHMnAVc.js → ui-DrjXmHdO.js} +2 -2
  43. package/apps/web/dist/assets/{upload-DLbJVvfd.js → upload-CDxTpAqx.js} +1 -1
  44. package/apps/web/dist/index.html +3 -8
  45. package/apps/web/dist/mcp/flow.html +3 -3
  46. package/node_modules/@engenty-wizards/plugin-sdk/dist/index.d.ts +3 -3
  47. package/node_modules/@engenty-wizards/shared/dist/assistant.d.ts +62 -1
  48. package/node_modules/@engenty-wizards/shared/dist/assistant.js +10 -0
  49. package/node_modules/@engenty-wizards/shared/dist/projects.d.ts +138 -3
  50. package/node_modules/@engenty-wizards/shared/dist/projects.js +153 -4
  51. package/package.json +1 -1
  52. package/apps/web/dist/assets/AboutDialog-Dz_tYBSU.js +0 -1
  53. package/apps/web/dist/assets/ChatView-D21o-lq8.js +0 -10
  54. package/apps/web/dist/assets/EditorPage-D6j3dQpu.js +0 -3
  55. package/apps/web/dist/assets/Marketplace-B-gy2onK.js +0 -2
  56. package/apps/web/dist/assets/NewWizardChannels-B3FEpsJ-.js +0 -1
  57. package/apps/web/dist/assets/NewWizardPage-CT1lThNM.js +0 -7
  58. package/apps/web/dist/assets/ProjectPage-BIanCF0m.js +0 -2
  59. package/apps/web/dist/assets/PublicRunner-H1fXa9BK.js +0 -1
  60. package/apps/web/dist/assets/RunDrawer-8sjkAbm5.js +0 -4
  61. package/apps/web/dist/assets/RunnerView-CJ4cLA_0.js +0 -1
  62. package/apps/web/dist/assets/SettingsPage-BsdwE1OA.js +0 -1
  63. package/apps/web/dist/assets/SetupPage-sqrv7Auf.js +0 -1
  64. package/apps/web/dist/assets/SharePage-B6ets1ot.js +0 -1
  65. package/apps/web/dist/assets/brand-BRw18WJF.js +0 -2
  66. package/apps/web/dist/assets/changelog-BOFdRjx2.js +0 -1
  67. package/apps/web/dist/assets/index-DY9CslcT.css +0 -1
  68. package/apps/web/dist/assets/index-DkDBEMwz.js +0 -11
  69. package/apps/web/dist/assets/session-DBOQL_bg.js +0 -1
  70. package/apps/web/dist/assets/useMutation-DKyQXgyj.js +0 -1
  71. 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
  };
@@ -0,0 +1,24 @@
1
+ const running = new Map();
2
+ export function startRun(runId, userId) {
3
+ running.set(runId, { userId, inbox: [] });
4
+ }
5
+ export function endRun(runId) {
6
+ running.delete(runId);
7
+ }
8
+ /** Puts a message into the person's running turn; false when that turn is over. */
9
+ export function signalRun(userId, runId, signal) {
10
+ const run = running.get(runId);
11
+ if (!run || run.userId !== userId) {
12
+ return false;
13
+ }
14
+ run.inbox.push(signal);
15
+ return true;
16
+ }
17
+ /** The messages that came in since the turn last looked, oldest first. */
18
+ export function takeSignals(runId) {
19
+ const run = running.get(runId);
20
+ if (!run?.inbox.length) {
21
+ return [];
22
+ }
23
+ return run.inbox.splice(0);
24
+ }
@@ -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(),
@@ -1,10 +1,10 @@
1
1
  ---
2
- title: The assistant
2
+ title: Goose, the assistant
3
3
  description: The chat at the bottom of every studio page. It knows the app, sees the page you are on, shows you where things are and fills the space in.
4
- version: 0.3.2
4
+ version: 0.3.7
5
5
  ---
6
6
 
7
- The assistant stands at the bottom of every page of the studio but the editor, which has a
7
+ Goose, the assistant, stands at the bottom of every page of the studio but the editor, which has a
8
8
  conversation of its own. It thinks on the High class of Text & reasoning: your subscription,
9
9
  your keys or credits ([Models and account](./models.md)).
10
10
 
@@ -26,20 +26,38 @@ a value into a field for you; you check it and save. It does not press buttons.
26
26
  | | |
27
27
  |---|---|
28
28
  | At the bottom | The default. Click the field or the engenty, and the chat rises above it |
29
- | Small | The engenty at the lower right; a click opens the chat beside it. Data and Results keep it small |
29
+ | Closed | ✕ in the header: the engenty at the lower right; a click opens the chat where it last stood. Data and Results start closed |
30
30
  | Sidebar | Beside the page, on a window 1,520 pixels wide or more |
31
31
 
32
32
  The buttons in the chat's header switch between them; the studio remembers your choice.
33
33
 
34
+ At the bottom, the chat moves out of the way of what it covers: drag it by the grip before its
35
+ name (or the engenty, while it is folded). It stays where you put it. Near the bottom it snaps to
36
+ the middle or the right; near the right side, higher up, it becomes the sidebar, and the
37
+ sidebar's grip pulls it out again. A dashed outline shows where it will snap. The handle on top of
38
+ the chat sets how tall the thread is; a click on it folds the chat away.
39
+
40
+ ## Writing while it works
41
+
42
+ You can write on while Goose answers. What you send waits above the field:
43
+
44
+ | | |
45
+ |---|---|
46
+ | Enter | Goose reads it before its next step, and keeps what you asked before unless the message changes it |
47
+ | ⌘↩ (Ctrl+↩) | It goes out as the next question once the answer is done |
48
+ | Send now | Stops the answer and sends it at once |
49
+
50
+ The stop button stands where Send does while the field is empty.
51
+
34
52
  ## One chat
35
53
 
36
54
  You have one chat, the same on every page and in every space, after a reload and on another
37
55
  device. Nobody else in the team sees it.
38
56
 
39
- - **New chat** (the menu at the chat's name) closes the chat with a short summary and starts an
40
- empty one. The assistant keeps the summaries of your last chats in mind.
57
+ - **New chat** (the pen in the header, or the menu at the chat's name) clears the chat at once and
58
+ sums the old one up meanwhile. Goose keeps the summaries of your last chats in mind.
41
59
  - **Earlier chats** lists the closed ones to read. Ask about something you discussed before, and
42
- the assistant searches them.
60
+ Goose searches them.
43
61
 
44
62
  ## Live chat
45
63
 
@@ -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
  }
@@ -152,6 +152,11 @@ export const env = {
152
152
  /** Encrypts connected accounts and kept browser sessions; falls back to the app secret. */
153
153
  storeKey: str("STORE_ENC_KEY"),
154
154
  devLogin: process.env.NODE_ENV !== "production" && str("DEV_LOGIN") === "1",
155
+ /**
156
+ * `1`: the studio assistant's developer mode may be switched on here (steps, tools, prompt,
157
+ * the turns' events). A managed runtime offers it to the platform's admins instead.
158
+ */
159
+ assistantDev: str("ASSISTANT_DEV") === "1",
155
160
  /**
156
161
  * The Manage-App this runtime belongs to. Set = people sign in there, tenants come from its
157
162
  * tokens and model calls go through its gateway. Empty = the runtime runs alone with one tenant.