@aglyn/plugins-ai 1.0.0-beta.233 → 1.0.0-beta.234

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 (104) hide show
  1. package/package.json +17 -17
  2. package/src/lib/components/ai-job-links.d.ts +6 -0
  3. package/src/lib/components/ai-job-links.js +9 -0
  4. package/src/lib/components/ai-job-links.js.map +1 -1
  5. package/src/lib/components/ai-site-build-page.component.js +41 -3
  6. package/src/lib/components/ai-site-build-page.component.js.map +1 -1
  7. package/src/lib/components/ai-site-start-card.component.js +91 -8
  8. package/src/lib/components/ai-site-start-card.component.js.map +1 -1
  9. package/src/lib/jobs/ai-job-admission.d.ts +2 -1
  10. package/src/lib/jobs/ai-job-admission.js.map +1 -1
  11. package/src/lib/jobs/ai-job-email-step.d.ts +39 -0
  12. package/src/lib/jobs/ai-job-email-step.js +116 -5
  13. package/src/lib/jobs/ai-job-email-step.js.map +1 -1
  14. package/src/lib/jobs/ai-job-layout-language.d.ts +3 -0
  15. package/src/lib/jobs/ai-job-layout-language.js +3 -1
  16. package/src/lib/jobs/ai-job-layout-language.js.map +1 -1
  17. package/src/lib/jobs/ai-job-layout-site-pages.d.ts +2 -0
  18. package/src/lib/jobs/ai-job-layout-site-pages.js +23 -11
  19. package/src/lib/jobs/ai-job-layout-site-pages.js.map +1 -1
  20. package/src/lib/jobs/ai-job-layout-step.js +3 -2
  21. package/src/lib/jobs/ai-job-layout-step.js.map +1 -1
  22. package/src/lib/jobs/ai-job-page-language.d.ts +16 -0
  23. package/src/lib/jobs/ai-job-page-language.js +35 -6
  24. package/src/lib/jobs/ai-job-page-language.js.map +1 -1
  25. package/src/lib/jobs/ai-job-page-step.d.ts +3 -0
  26. package/src/lib/jobs/ai-job-page-step.js +18 -4
  27. package/src/lib/jobs/ai-job-page-step.js.map +1 -1
  28. package/src/lib/jobs/ai-job-plan-step.d.ts +34 -0
  29. package/src/lib/jobs/ai-job-plan-step.js +106 -14
  30. package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
  31. package/src/lib/jobs/ai-job-site-content.d.ts +6 -6
  32. package/src/lib/jobs/ai-job-site-content.js +6 -12
  33. package/src/lib/jobs/ai-job-site-content.js.map +1 -1
  34. package/src/lib/jobs/ai-job-site-step.d.ts +11 -0
  35. package/src/lib/jobs/ai-job-site-step.js +96 -44
  36. package/src/lib/jobs/ai-job-site-step.js.map +1 -1
  37. package/src/lib/jobs/ai-jobs.d.ts +8 -4
  38. package/src/lib/jobs/ai-jobs.js +19 -2
  39. package/src/lib/jobs/ai-jobs.js.map +1 -1
  40. package/src/lib/jobs/ai-layout-stock-photos.d.ts +98 -0
  41. package/src/lib/jobs/ai-layout-stock-photos.js +248 -0
  42. package/src/lib/jobs/ai-layout-stock-photos.js.map +1 -0
  43. package/src/lib/jobs/ai-site-publish.d.ts +11 -0
  44. package/src/lib/jobs/ai-site-publish.js +31 -1
  45. package/src/lib/jobs/ai-site-publish.js.map +1 -1
  46. package/src/lib/jobs/fixtures/ai-juniper-portfolio-page-recording.d.ts +151 -0
  47. package/src/lib/jobs/fixtures/ai-juniper-portfolio-page-recording.js +183 -0
  48. package/src/lib/jobs/fixtures/ai-juniper-portfolio-page-recording.js.map +1 -0
  49. package/src/lib/jobs/fixtures/ai-slow-roads-site-plan-recording.d.ts +27 -0
  50. package/src/lib/jobs/fixtures/ai-slow-roads-site-plan-recording.js +192 -0
  51. package/src/lib/jobs/fixtures/ai-slow-roads-site-plan-recording.js.map +1 -0
  52. package/src/lib/jobs/fixtures/ai-yoga-site-plan-recording.d.ts +27 -0
  53. package/src/lib/jobs/fixtures/ai-yoga-site-plan-recording.js +222 -0
  54. package/src/lib/jobs/fixtures/ai-yoga-site-plan-recording.js.map +1 -0
  55. package/src/lib/layout-language/ai-layout-compiler.d.ts +29 -0
  56. package/src/lib/layout-language/ai-layout-compiler.js +698 -12
  57. package/src/lib/layout-language/ai-layout-compiler.js.map +1 -1
  58. package/src/lib/layout-language/ai-layout-design.d.ts +107 -0
  59. package/src/lib/layout-language/ai-layout-design.js +197 -0
  60. package/src/lib/layout-language/ai-layout-design.js.map +1 -0
  61. package/src/lib/layout-language/ai-layout-frame.d.ts +6 -0
  62. package/src/lib/layout-language/ai-layout-frame.js +22 -6
  63. package/src/lib/layout-language/ai-layout-frame.js.map +1 -1
  64. package/src/lib/layout-language/ai-layout-language.d.ts +2 -0
  65. package/src/lib/layout-language/ai-layout-language.js +15 -1
  66. package/src/lib/layout-language/ai-layout-language.js.map +1 -1
  67. package/src/lib/layout-language/ai-layout-links.d.ts +11 -0
  68. package/src/lib/layout-language/ai-layout-links.js +4 -0
  69. package/src/lib/layout-language/ai-layout-links.js.map +1 -1
  70. package/src/lib/layout-language/ai-layout-pictures.d.ts +11 -5
  71. package/src/lib/layout-language/ai-layout-pictures.js +80 -17
  72. package/src/lib/layout-language/ai-layout-pictures.js.map +1 -1
  73. package/src/lib/model/ai-job-notice.d.ts +9 -1
  74. package/src/lib/model/ai-job-notice.js +23 -0
  75. package/src/lib/model/ai-job-notice.js.map +1 -1
  76. package/src/lib/model/ai-plan-capabilities.d.ts +7 -0
  77. package/src/lib/model/ai-plan-capabilities.js.map +1 -1
  78. package/src/lib/model/ai-site-build-progress.d.ts +12 -2
  79. package/src/lib/model/ai-site-build-progress.js +78 -4
  80. package/src/lib/model/ai-site-build-progress.js.map +1 -1
  81. package/src/lib/model/ai-site-job.d.ts +209 -22
  82. package/src/lib/model/ai-site-job.js +247 -20
  83. package/src/lib/model/ai-site-job.js.map +1 -1
  84. package/src/lib/model/ai-site-kinds.js +6 -1
  85. package/src/lib/model/ai-site-kinds.js.map +1 -1
  86. package/src/lib/model/ai-site-look.d.ts +21 -0
  87. package/src/lib/model/ai-site-look.js +63 -14
  88. package/src/lib/model/ai-site-look.js.map +1 -1
  89. package/src/lib/runtime/ai-doctrine-validators.d.ts +32 -1
  90. package/src/lib/runtime/ai-doctrine-validators.js +227 -4
  91. package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
  92. package/src/lib/runtime/ai-doctrine.js +5 -3
  93. package/src/lib/runtime/ai-doctrine.js.map +1 -1
  94. package/src/lib/runtime/ai-palette.generated.d.ts +2 -0
  95. package/src/lib/runtime/ai-palette.generated.js +18 -2
  96. package/src/lib/runtime/ai-palette.generated.js.map +1 -1
  97. package/src/lib/server/ai-jobs-resume.js.map +1 -1
  98. package/src/lib/server/ai-jobs-route.js +15 -3
  99. package/src/lib/server/ai-jobs-route.js.map +1 -1
  100. package/src/lib/server/assist-edit.js +5 -2
  101. package/src/lib/server/assist-edit.js.map +1 -1
  102. package/src/lib/usage/free-ai-credits-left.d.ts +54 -0
  103. package/src/lib/usage/free-ai-credits-left.js +62 -0
  104. package/src/lib/usage/free-ai-credits-left.js.map +1 -0
@@ -25,7 +25,7 @@ import { parseBreakpointSpan } from "@aglyn/shared-data-enums/breakpoint-span";
25
25
  import { renderEmailHtml } from "@aglyn/shared-util-email/email-render";
26
26
  import { AI_BUILD_PLAN_CREATION_NOUNS, AI_BUILD_PLAN_EMBED_HOST_NAMES, AI_PLAN_ASKS_FOR_VIDEO, aiEmbedHostOf, aiEmbedVideoKey, aiPlanCreateFor, aiPlanSlugKey, aiPlanUndeclaredRefs, isAiPlanNewRef } from "../model/ai-build-plan.js";
27
27
  import { aiCreationNoun, aiPlanCanPlaceForm, aiPlanUncreatable, aiPlanUncreatableKind } from "../model/ai-plan-capabilities.js";
28
- import { aiFreeSiteSectionsWithin } from "../model/ai-site-job.js";
28
+ import { aiFreeSiteSectionsWithin, aiSiteHomeMinSections, aiSitePlanIsHome } from "../model/ai-site-job.js";
29
29
  import { AI_INSTANCE_REF_PROP, validateAiNodeTree } from "./ai-node-tree.js";
30
30
  import { AI_PAGE_SCROLL_TO_PROP, aiPageLinkTarget, aiStoredScrollTargets } from "./ai-page-links.js";
31
31
  import { AI_EMAIL_CLIP_BYTES, AI_OUTPUT_BUDGETS, AI_OUTPUT_SURFACE, AI_TEXT_LIMITS, isHeadlineVariant } from "./ai-palette.js";
@@ -277,6 +277,22 @@ function listNames(ids, tree) {
277
277
  */ export function detectRepeatedSubtrees(tree, otherPages = [], context = {}) {
278
278
  if (context.reusableComponents === false) return [];
279
279
  const index = indexShapes(tree);
280
+ /**
281
+ * A compiled picture card (AGL-3660): a Stack that opens with its picture,
282
+ * in a cell of a grid — or that cell, holding only the card.
283
+ */ const card = (id)=>{
284
+ var _node_nodes, _tree_nodes_first;
285
+ const node = id === undefined ? undefined : tree.nodes[id];
286
+ const first = node == null ? void 0 : (_node_nodes = node.nodes) == null ? void 0 : _node_nodes[0];
287
+ return (node == null ? void 0 : node.componentId) === 'muiStack' && first !== undefined && ((_tree_nodes_first = tree.nodes[first]) == null ? void 0 : _tree_nodes_first.componentId) === 'image';
288
+ };
289
+ const pictureCard = (id)=>{
290
+ var _node_props, _node_nodes, _index_visits_find, _tree_nodes_parentId;
291
+ const node = tree.nodes[id];
292
+ if ((node == null ? void 0 : node.componentId) === 'muiGrid' && ((_node_props = node.props) == null ? void 0 : _node_props['container']) !== true) return ((_node_nodes = node.nodes) == null ? void 0 : _node_nodes.length) === 1 && card(node.nodes[0]);
293
+ const parentId = (_index_visits_find = index.visits.find((visit)=>visit.id === id)) == null ? void 0 : _index_visits_find.ancestors.at(-1);
294
+ return card(id) && parentId !== undefined && ((_tree_nodes_parentId = tree.nodes[parentId]) == null ? void 0 : _tree_nodes_parentId.componentId) === 'muiGrid';
295
+ };
280
296
  const violations = repeatedShapes(tree, index, AI_REPEAT_MIN_NODES, AI_REPEAT_MIN_COUNT, // Counted within the band of the page it sits in (AGL-3660), as rule 8
281
297
  // counts a typed list: a heading over its words in the About band and in
282
298
  // the Services band are two parts of two stories, not one block placed twice.
@@ -288,7 +304,7 @@ function listNames(ids, tree) {
288
304
  var _tree_nodes_id;
289
305
  return ((_tree_nodes_id = tree.nodes[id]) == null ? void 0 : _tree_nodes_id.componentId) === 'section';
290
306
  })) != null ? _reverse_find : tree.rootId;
291
- }, aiRepeatFrameOrSection(tree)).map(({ ids })=>{
307
+ }, aiRepeatFrameOrSection(tree)).filter(({ ids })=>!(context.repeatsCompiled && ids.every(pictureCard))).map(({ ids })=>{
292
308
  const name = displayName(tree.nodes[ids[0]].componentId);
293
309
  return {
294
310
  rule: 1,
@@ -729,6 +745,199 @@ function normalizeHex(value) {
729
745
  }
730
746
  ];
731
747
  }
748
+ /** The props an email block is colored by, the ones rule 6 reads. */ const EMAIL_COLOR_PROPS = [
749
+ 'color',
750
+ 'backgroundColor'
751
+ ];
752
+ /**
753
+ * A color as opaque RGB: hex of 3, 4, 6 or 8 digits, or `rgb()`/`rgba()`. A
754
+ * translucent value is laid over white, the page an email is read on. `null`
755
+ * for anything else, a name or a token included.
756
+ */ function emailRgbOf(value) {
757
+ var _exec;
758
+ const text = value.trim().toLowerCase();
759
+ const hex = (_exec = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(text)) == null ? void 0 : _exec[1];
760
+ let r;
761
+ let g;
762
+ let b;
763
+ let alpha = 1;
764
+ if (hex) {
765
+ const full = hex.length <= 4 ? [
766
+ ...hex
767
+ ].map((digit)=>digit + digit).join('') : hex;
768
+ r = parseInt(full.slice(0, 2), 16);
769
+ g = parseInt(full.slice(2, 4), 16);
770
+ b = parseInt(full.slice(4, 6), 16);
771
+ if (full.length === 8) alpha = parseInt(full.slice(6, 8), 16) / 255;
772
+ } else {
773
+ const fn = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)$/.exec(text);
774
+ if (!fn) return null;
775
+ r = Number(fn[1]);
776
+ g = Number(fn[2]);
777
+ b = Number(fn[3]);
778
+ if (fn[4] !== undefined) alpha = Number(fn[4]);
779
+ if (![
780
+ r,
781
+ g,
782
+ b,
783
+ alpha
784
+ ].every(Number.isFinite) || r > 255 || g > 255 || b > 255 || alpha > 1) return null;
785
+ }
786
+ const over = (channel)=>Math.round(channel * alpha + 255 * (1 - alpha));
787
+ return {
788
+ r: over(r),
789
+ g: over(g),
790
+ b: over(b),
791
+ alpha
792
+ };
793
+ }
794
+ /** How far apart two colors look: the weighted "redmean" RGB distance. */ function emailColorDistance(a, b) {
795
+ const mean = (a.r + b.r) / 2;
796
+ const dr = a.r - b.r;
797
+ const dg = a.g - b.g;
798
+ const db = a.b - b.b;
799
+ return Math.sqrt((2 + mean / 256) * dr * dr + 4 * dg * dg + (2 + (255 - mean) / 256) * db * db);
800
+ }
801
+ /**
802
+ * An email's off-brand colors, settled where each has one reading (AGL-3676).
803
+ * Rule 6 holds an email to the brand's own values, and the guided start's
804
+ * welcome email kept writing tints of them — a pale wash of the primary color
805
+ * behind a section — and stopped on rule 6 after its re-ask. Before the tree
806
+ * is checked, each `color` and `backgroundColor` outside the brand is:
807
+ *
808
+ * - a palette path the brand lists, such as `primary.main`: its value;
809
+ * - a color that parses: the NEAREST opaque brand color, so a pale tint
810
+ * becomes the brand's pale surface and a deep one its deep color;
811
+ * - with no brand color recorded at all: dropped, leaving the block's own
812
+ * default, which is what rule 6 asks of an email with no brand.
813
+ *
814
+ * A value that does not parse (a name, a CSS variable) is left for the
815
+ * re-ask, and so is the whole tree when a snapped color would leave words
816
+ * below 3:1 against what they sit on. Nothing is relaxed: the settled tree
817
+ * is the one rule 6 checks.
818
+ * Reads and returns the tree as the model wrote it.
819
+ */ export function aiSettleOffBrandEmailColors(input, brand) {
820
+ var _ref;
821
+ if (!isRecord(input) || !isRecord(input['nodes'])) return input;
822
+ const entries = Object.entries((_ref = brand == null ? void 0 : brand.colors) != null ? _ref : {}).filter((entry)=>typeof entry[1] === 'string' && entry[1].trim() !== '');
823
+ const allowed = new Set(entries.map(([, value])=>normalizeHex(value)));
824
+ const byPath = new Map(entries.map(([path, value])=>[
825
+ path.toLowerCase(),
826
+ value.trim()
827
+ ]));
828
+ const targets = entries.flatMap(([, value])=>{
829
+ const rgb = emailRgbOf(value);
830
+ return rgb && rgb.alpha === 1 ? [
831
+ {
832
+ value: value.trim(),
833
+ rgb
834
+ }
835
+ ] : [];
836
+ });
837
+ const settle = (value)=>{
838
+ if (!entries.length) return null;
839
+ const named = byPath.get(value.trim().toLowerCase());
840
+ if (named) return named;
841
+ const rgb = emailRgbOf(value);
842
+ if (!rgb || !targets.length) return undefined;
843
+ let best = targets[0];
844
+ for (const target of targets){
845
+ if (emailColorDistance(rgb, target.rgb) < emailColorDistance(rgb, best.rgb)) best = target;
846
+ }
847
+ return best.value;
848
+ };
849
+ let nodes = null;
850
+ for (const [id, node] of Object.entries(input['nodes'])){
851
+ if (!isRecord(node) || !isRecord(node['props'])) continue;
852
+ let props = null;
853
+ for (const name of EMAIL_COLOR_PROPS){
854
+ const value = node['props'][name];
855
+ if (typeof value !== 'string' || !value.trim() || allowed.has(normalizeHex(value))) continue;
856
+ const settled = settle(value);
857
+ if (settled === undefined) continue;
858
+ props != null ? props : props = _extends({}, node['props']);
859
+ if (settled === null) delete props[name];
860
+ else props[name] = settled;
861
+ }
862
+ if (!props) continue;
863
+ nodes != null ? nodes : nodes = _extends({}, input['nodes']);
864
+ nodes[id] = _extends({}, node, {
865
+ props
866
+ });
867
+ }
868
+ if (!nodes || !targets.length) return nodes ? _extends({}, input, {
869
+ nodes
870
+ }) : input;
871
+ // Two colors snapped onto one brand value can leave words the color of what
872
+ // they sit on — a pale green heading on a cream band both become the cream.
873
+ // A settle that leaves any color it touched unreadable is not one reading,
874
+ // so the tree goes back as written and rule 6 asks the model.
875
+ return emailSettleKeepsContrast(input['nodes'], nodes) ? _extends({}, input, {
876
+ nodes
877
+ }) : input;
878
+ }
879
+ /** The least contrast words keep on what they sit on once settled: WCAG's large-text floor. */ const EMAIL_SETTLED_MIN_CONTRAST = 3;
880
+ function emailLuminance({ r, g, b }) {
881
+ const channel = (value)=>{
882
+ const c = value / 255;
883
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
884
+ };
885
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
886
+ }
887
+ function emailContrast(a, b) {
888
+ const [light, dark] = [
889
+ emailLuminance(a),
890
+ emailLuminance(b)
891
+ ].sort((x, y)=>y - x);
892
+ return (light + 0.05) / (dark + 0.05);
893
+ }
894
+ /**
895
+ * Whether every block whose words or ground the settle changed can still be
896
+ * read: its `color` against its own `backgroundColor` (a button's fill), else
897
+ * the nearest enclosing one, else the white an email is read on. A button
898
+ * with no fill of its own is left out: its fill is the block's default.
899
+ */ function emailSettleKeepsContrast(written, settled) {
900
+ const parentOf = new Map();
901
+ for (const [id, node] of Object.entries(settled)){
902
+ if (!isRecord(node) || !Array.isArray(node['nodes'])) continue;
903
+ for (const child of node['nodes'])if (typeof child === 'string') parentOf.set(child, id);
904
+ }
905
+ const propOf = (nodes, id, name)=>{
906
+ const node = nodes[id];
907
+ return isRecord(node) && isRecord(node['props']) ? node['props'][name] : undefined;
908
+ };
909
+ /** The id whose background a node's words sit on, or `null` for the email's white. */ const groundOf = (id)=>{
910
+ if (typeof propOf(settled, id, 'backgroundColor') === 'string') return id;
911
+ const node = settled[id];
912
+ if (isRecord(node) && node['componentId'] === 'emailButton') return undefined;
913
+ const seen = new Set([
914
+ id
915
+ ]);
916
+ for(let at = parentOf.get(id); at && !seen.has(at); at = parentOf.get(at)){
917
+ seen.add(at);
918
+ if (typeof propOf(settled, at, 'backgroundColor') === 'string') return at;
919
+ }
920
+ return null;
921
+ };
922
+ const WHITE = {
923
+ r: 255,
924
+ g: 255,
925
+ b: 255
926
+ };
927
+ for (const id of Object.keys(settled)){
928
+ const color = propOf(settled, id, 'color');
929
+ if (typeof color !== 'string') continue;
930
+ const ground = groundOf(id);
931
+ if (ground === undefined) continue;
932
+ const touched = color !== propOf(written, id, 'color') || ground !== null && propOf(settled, ground, 'backgroundColor') !== propOf(written, ground, 'backgroundColor');
933
+ if (!touched) continue;
934
+ const text = emailRgbOf(color);
935
+ const background = ground === null ? WHITE : emailRgbOf(String(propOf(settled, ground, 'backgroundColor')));
936
+ if (!text || !background) continue;
937
+ if (emailContrast(text, background) < EMAIL_SETTLED_MIN_CONTRAST) return false;
938
+ }
939
+ return true;
940
+ }
732
941
  /**
733
942
  * Rule 8 (tree). A long list typed out item by item is data: bound to a
734
943
  * dataset or a content collection it stays current; typed, it is a copy that
@@ -3692,9 +3901,12 @@ function slugKey(slug) {
3692
3901
  }
3693
3902
  ];
3694
3903
  }
3904
+ // A site start's saved form is a step of its own (AGL-3596), counted on the wall since AGL-3660.
3905
+ const forms = plan.create.filter((entry)=>entry.kind === 'form').length - aiPlanUncreatable(plan, capabilities).filter((entry)=>entry.kind === 'form').length;
3695
3906
  const fits = sitePages !== undefined ? aiFreeSiteSectionsWithin({
3696
3907
  layouts,
3697
- pages
3908
+ pages,
3909
+ forms: Math.max(0, forms)
3698
3910
  }, FREE_AI_TASTE_CREDITS_PER_MONTH) : aiFreePageSectionsWithin({
3699
3911
  layouts,
3700
3912
  pages
@@ -3702,11 +3914,22 @@ function slugKey(slug) {
3702
3914
  if (asked <= fits) return [];
3703
3915
  const job = pages > 1 ? `a Free plan of ${pages} pages` : 'a Free page';
3704
3916
  const beside = layouts ? ` that creates ${layouts > 1 ? `${layouts} layouts` : 'its layout'}` : '';
3917
+ // A site start's home keeps its full five (AGL-3660): told only "plan at
3918
+ // most 8", the live yoga start of 2026-10-08 cut a home of 6 to 4 and kept
3919
+ // its other page, so the redo names where the sections come from.
3920
+ const homeMin = sitePages !== undefined && plan.screens.some(aiSitePlanIsHome) ? aiSiteHomeMinSections({
3921
+ pages,
3922
+ across: fits
3923
+ }) : 0;
3924
+ const keepHome = homeMin ? ` Keep the home page at / at ${homeMin} or more sections, and take the rest from the other ${pages > 2 ? 'pages' : 'page'}.` : '';
3925
+ // A site start's redo keeps the plan full (AGL-3660): the wall's sections
3926
+ // are its budget to use, and a redo told only "at most" cuts far under it.
3927
+ const redo = sitePages !== undefined && fits >= 2 ? `Plan ${fits - 1} to ${fits}: the ${fits} are the budget to use, not a ceiling to stay under. Draw a list's repeated items in one section, and take out only the sections the brief needs least.` : `Plan at most ${fits}: draw a list's repeated items in one section, and leave out a section the brief does not ask for.`;
3705
3928
  return [
3706
3929
  {
3707
3930
  rule: null,
3708
3931
  code: 'plan-over-free-wall',
3709
- message: `This plan asks for ${asked} sections, and ${job}${beside} fits ${fits} in the ${FREE_AI_TASTE_CREDITS_PER_MONTH} AI credits a Free workspace has a month. Plan at most ${fits}: draw a list's repeated items in one section, and leave out a section the brief does not ask for.`,
3932
+ message: `This plan asks for ${asked} sections, and ${job}${beside} fits ${fits} in the ${FREE_AI_TASTE_CREDITS_PER_MONTH} AI credits a Free workspace has a month. ${redo}${keepHome}`,
3710
3933
  paths: plan.screens.map((_, index)=>`screens[${index}].sections`)
3711
3934
  }
3712
3935
  ];