astro-tractstack 2.0.14 → 2.0.16

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 (34) hide show
  1. package/dist/index.js +41 -9
  2. package/package.json +1 -1
  3. package/templates/custom/with-examples/CodeHook.astro +4 -0
  4. package/templates/custom/with-examples/SandboxLauncher.tsx +65 -0
  5. package/templates/env.example +3 -0
  6. package/templates/src/components/codehooks/SandboxAuthWrapper.tsx +75 -0
  7. package/templates/src/components/codehooks/SandboxRegisterForm.tsx +202 -0
  8. package/templates/src/components/compositor/Compositor.tsx +2 -0
  9. package/templates/src/components/compositor/Node.tsx +27 -9
  10. package/templates/src/components/compositor/nodes/Pane_DesignLibrary.tsx +13 -11
  11. package/templates/src/components/compositor/nodes/Pane_layout.tsx +16 -14
  12. package/templates/src/components/edit/Header.tsx +8 -2
  13. package/templates/src/components/edit/PanelSwitch.tsx +4 -4
  14. package/templates/src/components/edit/pane/AddPanePanel.tsx +3 -0
  15. package/templates/src/components/edit/pane/AddPanePanel_new.tsx +463 -561
  16. package/templates/src/components/edit/pane/steps/AiDesignStep.tsx +140 -0
  17. package/templates/src/components/edit/pane/steps/CopyInputStep.tsx +105 -0
  18. package/templates/src/components/edit/pane/steps/DesignLibraryStep.tsx +395 -0
  19. package/templates/src/components/edit/panels/StyleImagePanel.tsx +10 -8
  20. package/templates/src/components/edit/state/SaveModal.tsx +41 -0
  21. package/templates/src/constants/prompts.json +3 -1
  22. package/templates/src/pages/api/sandbox.ts +86 -0
  23. package/templates/src/pages/sandbox.astro +137 -0
  24. package/templates/src/types/nodeProps.ts +1 -0
  25. package/templates/src/utils/compositor/aiPaneParser.ts +32 -84
  26. package/templates/src/utils/compositor/designLibraryHelper.ts +87 -2
  27. package/templates/src/utils/profileStorage.ts +13 -0
  28. package/utils/inject-files.ts +41 -10
  29. package/templates/src/components/edit/pane/AiPaneGenerator.tsx +0 -575
  30. package/templates/src/components/edit/pane/AiPanePreview.tsx +0 -107
  31. package/templates/src/components/edit/pane/PageGen.tsx +0 -485
  32. package/templates/src/components/edit/pane/PageGenSelector.tsx +0 -245
  33. package/templates/src/components/edit/pane/PageGenSpecial.tsx +0 -339
  34. package/templates/src/utils/aai/getTitleSlug.ts +0 -72
@@ -45,6 +45,7 @@ interface SaveModalProps {
45
45
  slug: string;
46
46
  isContext: boolean;
47
47
  onClose: () => void;
48
+ isSandboxMode?: boolean;
48
49
  }
49
50
 
50
51
  const PROGRESS_PHASES = {
@@ -61,11 +62,47 @@ const INDETERMINATE_STAGES: SaveStage[] = [
61
62
  'UPDATING_HOME_PAGE',
62
63
  ];
63
64
 
65
+ const SandboxUpgradeNotice = ({ onClose }: { onClose: () => void }) => (
66
+ <Dialog.Root open={true} onOpenChange={() => onClose()} modal={true}>
67
+ <Portal>
68
+ <Dialog.Backdrop className="fixed inset-0 z-[9005] bg-black bg-opacity-75" />
69
+ <Dialog.Positioner className="fixed inset-0 z-[9005] flex items-center justify-center p-4">
70
+ <Dialog.Content className="w-full max-w-md overflow-hidden rounded-lg bg-white shadow-xl">
71
+ <div className="p-6 text-center">
72
+ <Dialog.Title className="text-xl font-bold text-gray-900">
73
+ Save Your Work
74
+ </Dialog.Title>
75
+ <Dialog.Description className="mt-2 text-gray-600">
76
+ To save your changes and get a shareable link, please sign up for
77
+ a full account.
78
+ </Dialog.Description>
79
+ <div className="mt-6 flex justify-center gap-3">
80
+ <a
81
+ href="/sandbox/register"
82
+ className="bg-myblue hover:bg-myorange rounded-md px-4 py-2 font-bold text-white"
83
+ >
84
+ Sign Up Now
85
+ </a>
86
+ <button
87
+ onClick={onClose}
88
+ className="rounded-md bg-gray-200 px-4 py-2 text-gray-800 hover:bg-gray-300"
89
+ >
90
+ Keep Editing
91
+ </button>
92
+ </div>
93
+ </div>
94
+ </Dialog.Content>
95
+ </Dialog.Positioner>
96
+ </Portal>
97
+ </Dialog.Root>
98
+ );
99
+
64
100
  export default function SaveModal({
65
101
  show,
66
102
  slug,
67
103
  isContext,
68
104
  onClose,
105
+ isSandboxMode = false,
69
106
  }: SaveModalProps) {
70
107
  const [stage, setStage] = useState<SaveStage>('PREPARING');
71
108
  const [progress, setProgress] = useState(0);
@@ -871,6 +908,10 @@ export default function SaveModal({
871
908
  }
872
909
  })();
873
910
 
911
+ if (isSandboxMode) {
912
+ return show ? <SandboxUpgradeNotice onClose={onClose} /> : null;
913
+ }
914
+
874
915
  return (
875
916
  <Dialog.Root
876
917
  open={show}
@@ -45,6 +45,8 @@
45
45
  },
46
46
  "aiPaneCopyPrompt": {
47
47
  "system": "You are an expert **web designer and copywriter**. Your task is to generate a single, visually compelling block of HTML content. You must ensure the content is well-written, engaging, **beautifully spaced**, and **highly readable**.",
48
- "user_template": "Here is the design 'shell' and 'theme' (bgColour, parentClasses, and defaultClasses) you must write your HTML for. Your HTML will be placed *inside* this shell. Use the `defaultClasses` as your base theme for styling:\n{{SHELL_JSON}}\n\nNow, generate the HTML content based on these inputs:\n\nContent Prompt: \"{{COPY_INPUT}}\"\nDesign Style: **strictly for visual reference** when choosing element styles **DO NOT** include any words or concepts from the `Design Style` input in the written copy text itself.\"{{DESIGN_INPUT}}\"\nLayout Type: \"{{LAYOUT_TYPE}}\"\n\nCRITICAL RULES:\n1. You are responsible for the **inner layout and visual rhythm**. You MUST add appropriate vertical margins (e.g., `mt-4`, `mt-6`, `mt-8`) directly to any HTML block elements that *deviate* from the default spacing. **Elements must not touch.**\n2. You **MUST NOT** use `<h1>` tags. You must use `<h2>`, `<h3>`, and `<p>` tags for all text content.\n3. For responsive styles, you *must* only use `md:` and `xl:` prefixes.\n4. To make headlines pop, you MUST wrap key words in `<span>` tags with creative classes (e.g., gradient text, different colors).\n5. **All text**, even short links or phrases (like 'Learn more →'), **must** be wrapped in a block element like `<p>` or `<button>`.\n6. You MUST include at least one `<button>` tag for the primary call-to-action.\n7. Verify that **all text elements**, including text within `<span>` tags, `<button>` elements, and any elements using override classes, maintain **high contrast** (meeting at least WCAG AA standards - 4.5:1 for normal text, 3:1 for large text) against the `bgColour` provided in the `SHELL_JSON`. **Prioritize readability above all else**.\n8. Respond *only* with the raw HTML.\n\nEXAMPLE of a good, well-spaced response:\n<h2 class=\"text-4xl font-bold tracking-tight text-white md:text-6xl\"><span class=\"bg-gradient-to-r from-purple-500 to-indigo-400 bg-clip-text text-transparent\">Own the Art.</span> Possess the Reality.</h2>\n<p class=\"mt-6 text-lg leading-8 text-gray-300 md:text-xl\">Every Sneaky Productions NFT is your key. This is where digital rarity meets tangible legacy.</p>\n<button class=\"mt-8 rounded-md bg-indigo-600 px-5 py-3 text-base font-semibold text-white shadow-sm hover:bg-indigo-500\">Secure Your Drop</button>\n<p class=\"mt-4 text-sm text-gray-400\">Learn more <span>→</span></p>"
48
+ "user_template": "Here is the design 'shell' and 'theme' (bgColour, parentClasses, and defaultClasses) you must write your HTML for. Your HTML will be placed *inside* this shell. Use the `defaultClasses` as your base theme for styling:\n{{SHELL_JSON}}\n\nNow, generate the HTML content based on these inputs:\n\nContent Prompt: \"{{COPY_INPUT}}\"\nDesign Style: **strictly for visual reference** when choosing element styles **DO NOT** include any words or concepts from the `Design Style` input in the written copy text itself.\"{{DESIGN_INPUT}}\"\nLayout Type: \"{{LAYOUT_TYPE}}\"\n\nCRITICAL RULES:\n1. You are responsible for the **inner layout and visual rhythm**. You MUST add appropriate vertical margins (e.g., `mt-4`, `mt-6`, `mt-8`) directly to any HTML block elements that *deviate* from the default spacing. **Elements must not touch.**\n2. You **MUST NOT** use `<h1>` tags. You must use `<h2>`, `<h3>`, and `<p>` tags for all text content.\n3. For responsive styles, you *must* only use `md:` and `xl:` prefixes.\n4. To make headlines pop, you MUST wrap key words in `<span>` tags with creative classes (e.g., gradient text, different colors).\n5. **All text**, even short links or phrases (like 'Learn more →'), **must** be wrapped in a block element like `<p>` or `<button>`.\n6. You MUST include at least one `<button>` tag for the primary call-to-action.\n7. Verify that **all text elements**, including text within `<span>` tags, `<button>` elements, and any elements using override classes, maintain **high contrast** (meeting at least WCAG AA standards - 4.5:1 for normal text, 3:1 for large text) against the `bgColour` provided in the `SHELL_JSON`. **Prioritize readability above all else**.\n8. Respond *only* with the raw HTML.\n\nEXAMPLE of a good, well-spaced response:\n<h2 class=\"text-4xl font-bold tracking-tight text-white md:text-6xl\"><span class=\"bg-gradient-to-r from-purple-500 to-indigo-400 bg-clip-text text-transparent\">Own the Art.</span> Possess the Reality.</h2>\n<p class=\"mt-6 text-lg leading-8 text-gray-300 md:text-xl\">Every Sneaky Productions NFT is your key. This is where digital rarity meets tangible legacy.</p>\n<button class=\"mt-8 rounded-md bg-indigo-600 px-5 py-3 text-base font-semibold text-white shadow-sm hover:bg-indigo-500\">Secure Your Drop</button>\n<p class=\"mt-4 text-sm text-gray-400\">Learn more <span>→</span></p>",
49
+ "heroDefault": "A compelling hero section for a website about [topic]. It should have a strong, attention-grabbing headline, a brief paragraph explaining the core value proposition, and a clear call-to-action.",
50
+ "contentDefault": "A content section that follows a hero. It should elaborate on a key feature or benefit related to [topic]. Include a sub-headline and a descriptive paragraph."
49
51
  }
50
52
  }
@@ -0,0 +1,86 @@
1
+ import type { APIRoute } from '@/types/astro';
2
+
3
+ export const POST: APIRoute = async ({ request }) => {
4
+ console.log(1);
5
+ const goBackend =
6
+ import.meta.env.PUBLIC_GO_BACKEND || 'http://localhost:8080';
7
+ const sharedSecret = import.meta.env.PRIVATE_SANDBOX_SECRET;
8
+ const tenantId =
9
+ request.headers.get('X-Tenant-ID') ||
10
+ import.meta.env.PUBLIC_TENANTID ||
11
+ 'default';
12
+ console.log(goBackend);
13
+ console.log(sharedSecret);
14
+ console.log(tenantId);
15
+
16
+ if (!sharedSecret || sharedSecret === 'false' || sharedSecret === 'true') {
17
+ return new Response(
18
+ JSON.stringify({
19
+ success: false,
20
+ error: 'Sandbox feature is not configured on the server.',
21
+ }),
22
+ { status: 501, headers: { 'Content-Type': 'application/json' } }
23
+ );
24
+ }
25
+
26
+ const profileCookie = request.headers
27
+ .get('cookie')
28
+ ?.includes('tractstack_profile');
29
+ if (!profileCookie) {
30
+ return new Response(
31
+ JSON.stringify({
32
+ success: false,
33
+ error: 'Forbidden: Missing sandbox profile.',
34
+ }),
35
+ { status: 403, headers: { 'Content-Type': 'application/json' } }
36
+ );
37
+ }
38
+ console.log(profileCookie);
39
+
40
+ try {
41
+ const body = await request.json();
42
+ const { action, payload } = body;
43
+ console.log(action, payload);
44
+
45
+ if (action !== 'askLemur') {
46
+ return new Response(
47
+ JSON.stringify({ success: false, error: 'Invalid action.' }),
48
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
49
+ );
50
+ }
51
+
52
+ const backendResponse = await fetch(`${goBackend}/api/v1/aai/askLemur`, {
53
+ method: 'POST',
54
+ headers: {
55
+ 'Content-Type': 'application/json',
56
+ 'X-Tenant-ID': tenantId,
57
+ Authorization: `Bearer ${sharedSecret}`,
58
+ },
59
+ body: JSON.stringify(payload),
60
+ });
61
+
62
+ if (!backendResponse.ok) {
63
+ const errorText = await backendResponse.text();
64
+ return new Response(errorText, {
65
+ status: backendResponse.status,
66
+ headers: { 'Content-Type': 'application/json' },
67
+ });
68
+ }
69
+
70
+ const data = await backendResponse.json();
71
+ return new Response(JSON.stringify(data), {
72
+ status: 200,
73
+ headers: { 'Content-Type': 'application/json' },
74
+ });
75
+ } catch (error) {
76
+ const errorMessage =
77
+ error instanceof Error ? error.message : 'An unknown error occurred.';
78
+ return new Response(
79
+ JSON.stringify({ success: false, error: errorMessage }),
80
+ {
81
+ status: 500,
82
+ headers: { 'Content-Type': 'application/json' },
83
+ }
84
+ );
85
+ }
86
+ };
@@ -0,0 +1,137 @@
1
+ ---
2
+ import { ulid } from 'ulid';
3
+ import Layout from '@/layouts/Layout.astro';
4
+ import Header from '@/components/Header.astro';
5
+ import { getFullContentMap } from '@/stores/analytics';
6
+ import { getBrandConfig } from '@/utils/api/brandConfig';
7
+ import { components as codeHookComponents } from '@/custom/CodeHook.astro';
8
+ import StoryKeepHeader from '@/components/edit/Header';
9
+ import StoryKeepToolBar from '@/components/edit/ToolBar';
10
+ import StoryKeepToolMode from '@/components/edit/ToolMode';
11
+ import SettingsPanel from '@/components/edit/SettingsPanel';
12
+ import { Compositor } from '@/components/compositor/Compositor';
13
+ import SandboxAuthWrapper from '@/components/codehooks/SandboxAuthWrapper.tsx';
14
+ import { preHealthCheck } from '@/utils/backend';
15
+
16
+ if (!import.meta.env.PRIVATE_SANDBOX_SECRET) {
17
+ return Astro.redirect('/');
18
+ }
19
+
20
+ const tenantId =
21
+ Astro.locals.tenant?.id || import.meta.env.PUBLIC_TENANTID || 'default';
22
+
23
+ const healthCheckRedirect = await preHealthCheck(tenantId);
24
+ if (healthCheckRedirect !== undefined) {
25
+ return healthCheckRedirect;
26
+ }
27
+
28
+ const brandConfig = await getBrandConfig(tenantId);
29
+
30
+ const emptyStoryFragment = {
31
+ id: ulid(),
32
+ nodeType: 'StoryFragment' as const,
33
+ parentId: null,
34
+ title: 'Sandbox Page',
35
+ slug: 'sandbox-page',
36
+ paneIds: [],
37
+ isChanged: false,
38
+ created: new Date(),
39
+ changed: new Date(),
40
+ };
41
+ const loadData = {
42
+ storyfragmentNodes: [emptyStoryFragment],
43
+ };
44
+ const title = 'Sandbox - TractStack Editor';
45
+ const storyFragmentID = emptyStoryFragment.id;
46
+
47
+ const fullContentMap = await getFullContentMap(tenantId);
48
+ const urlParams: Record<string, string | boolean> = {};
49
+ for (const [key, value] of Astro.url.searchParams) {
50
+ urlParams[key] = value === '' ? true : value;
51
+ }
52
+ ---
53
+
54
+ <Layout
55
+ title={title}
56
+ slug="sandbox"
57
+ brandConfig={brandConfig}
58
+ storyfragmentId={storyFragmentID}
59
+ isStoryKeep={true}
60
+ isEditor={true}
61
+ >
62
+ <SandboxAuthWrapper client:load />
63
+ <Header
64
+ title={title}
65
+ slug="sandbox"
66
+ brandConfig={brandConfig}
67
+ isContext={false}
68
+ isStoryKeep={true}
69
+ isEditable={false}
70
+ menu={null}
71
+ />
72
+
73
+ <section
74
+ id="storykeepHeader"
75
+ role="banner"
76
+ class="z-101 bg-mywhite left-0 right-0 drop-shadow transition-all duration-200"
77
+ >
78
+ <StoryKeepHeader
79
+ slug="sandbox"
80
+ isContext={false}
81
+ isSandboxMode={true}
82
+ client:only="react"
83
+ />
84
+ </section>
85
+
86
+ <div class="flex min-h-screen">
87
+ <StoryKeepToolMode isContext={false} client:only="react" />
88
+
89
+ <main id="mainContent" class="relative flex-1 overflow-x-auto">
90
+ <div class="bg-myblue/20 bg-mylightgrey h-full p-1.5">
91
+ <div
92
+ class="h-fit min-h-screen pb-96"
93
+ style={{
94
+ backgroundImage:
95
+ 'repeating-linear-gradient(135deg, transparent, transparent 10px, rgba(0,0,0,0.05) 10px, rgba(0,0,0,0.05) 20px)',
96
+ }}
97
+ >
98
+ <Compositor
99
+ id={storyFragmentID}
100
+ nodes={loadData}
101
+ config={brandConfig}
102
+ fullContentMap={fullContentMap}
103
+ fullCanonicalURL="/sandbox"
104
+ urlParams={urlParams}
105
+ availableCodeHooks={Object.keys(codeHookComponents)}
106
+ isSandboxMode={true}
107
+ client:only="react"
108
+ />
109
+ </div>
110
+ </div>
111
+ </main>
112
+ </div>
113
+
114
+ <aside
115
+ id="settingsControls"
116
+ class="z-101 pointer-events-none fixed bottom-16 right-2 flex flex-col items-end gap-2 md:bottom-2"
117
+ >
118
+ <div class="pointer-events-none flex-grow"></div>
119
+
120
+ <div class="pointer-events-auto flex-shrink-0">
121
+ <StoryKeepToolBar client:only="react" />
122
+ </div>
123
+
124
+ <div class="pointer-events-auto max-h-full">
125
+ <SettingsPanel
126
+ config={brandConfig}
127
+ availableCodeHooks={Object.keys(codeHookComponents)}
128
+ client:only="react"
129
+ />
130
+ </div>
131
+ </aside>
132
+ </Layout>
133
+
134
+ <script>
135
+ import { setupLayoutObservers } from '@/utils/layout';
136
+ document.addEventListener('astro:page-load', setupLayoutObservers);
137
+ </script>
@@ -27,6 +27,7 @@ export type NodeProps = {
27
27
  ctx?: NodesContext;
28
28
  first?: boolean;
29
29
  onDragStart?: (origin: SelectionOrigin, e: MouseEvent<HTMLElement>) => void;
30
+ isSandboxMode?: boolean;
30
31
  isSelectableText?: boolean;
31
32
  };
32
33
 
@@ -78,7 +78,6 @@ function buildKeyNormalizationLookup(): Map<string, string> {
78
78
 
79
79
  const keyMap = new Map<string, string>();
80
80
  for (const key in tailwindClasses) {
81
- // Store lowercase key -> correctly cased key
82
81
  keyMap.set(key.toLowerCase(), key);
83
82
  }
84
83
  KEY_NORMALIZATION_LOOKUP = keyMap;
@@ -97,7 +96,6 @@ function normalizeKeys(
97
96
  if (Object.prototype.hasOwnProperty.call(styleObj, key)) {
98
97
  const lowerKey = key.toLowerCase();
99
98
  const correctKey = keyMap.get(lowerKey);
100
- // Use the correctly cased key if found, otherwise keep original (handles potential non-Tailwind keys)
101
99
  normalized[correctKey || key] = styleObj[key];
102
100
  }
103
101
  }
@@ -258,22 +256,16 @@ function walkDom(
258
256
  ) {
259
257
  if (domNode.nodeType === Node.TEXT_NODE) {
260
258
  const copy = domNode.textContent || '';
261
- // Preserve leading/trailing spaces unless the *entire* content is just whitespace.
262
- // Trim internal excessive whitespace as a basic sanitation step.
263
259
  const trimmedCopy = copy.replace(/\s+/g, ' ').trim();
264
260
 
265
261
  if (trimmedCopy.length > 0) {
266
- // Use the original copy to preserve meaningful spaces, but cleaned up.
267
262
  let finalCopy = copy.replace(/\s+/g, ' ');
268
- // Preserve single leading space if original had one AND previous sibling exists
269
263
  if (copy.startsWith(' ') && domNode.previousSibling) {
270
264
  finalCopy = ' ' + finalCopy.trimStart();
271
265
  }
272
- // Preserve single trailing space if original had one AND next sibling exists
273
266
  if (copy.endsWith(' ') && domNode.nextSibling) {
274
267
  finalCopy = finalCopy.trimEnd() + ' ';
275
268
  }
276
- // Special case: if it was ONLY space, respect if it was intended between elements
277
269
  if (
278
270
  trimmedCopy.length === 0 &&
279
271
  copy.length > 0 &&
@@ -283,15 +275,13 @@ function walkDom(
283
275
  finalCopy = ' ';
284
276
  }
285
277
 
286
- // Only create node if there's actual content or a meaningful space
287
278
  if (finalCopy.trim().length > 0 || finalCopy === ' ') {
288
279
  const textNode: TemplateNode = {
289
280
  id: ulid(),
290
281
  nodeType: 'TagElement',
291
282
  parentId: parentId,
292
283
  tagName: 'text',
293
- copy: finalCopy, // Use the carefully preserved copy
294
- overrideClasses: {},
284
+ copy: finalCopy,
295
285
  };
296
286
  parsedNodes.push({
297
287
  flatNode: textNode,
@@ -326,7 +316,6 @@ function walkDom(
326
316
  nodeType: 'TagElement',
327
317
  parentId: parentId,
328
318
  tagName: 'p',
329
- overrideClasses: {},
330
319
  };
331
320
  parsedNodes.push({
332
321
  flatNode: pNode,
@@ -341,8 +330,7 @@ function walkDom(
341
330
  id: ulid(),
342
331
  nodeType: 'TagElement',
343
332
  parentId: finalParentId,
344
- tagName: 'a',
345
- overrideClasses: {},
333
+ tagName: 'a', // Buttons are converted to anchor tags for our system
346
334
  href: '#',
347
335
  buttonPayload: {
348
336
  ...buttonPayload,
@@ -368,7 +356,6 @@ function walkDom(
368
356
  nodeType: 'TagElement',
369
357
  parentId: parentId,
370
358
  tagName: tagName,
371
- overrideClasses: {},
372
359
  };
373
360
 
374
361
  if (tagName === 'span') {
@@ -493,14 +480,41 @@ function parseDefaultClassesFromShell(
493
480
  return sanitizedDefaults;
494
481
  }
495
482
 
483
+ /**
484
+ * Parses a raw HTML string from the AI into a structured array of TemplateNodes.
485
+ * @param copyHtml The raw HTML string.
486
+ * @param markdownId The parent ID for the top-level nodes.
487
+ * @returns An array of TemplateNodes representing the structured content.
488
+ */
489
+ export function parseAiCopyHtml(
490
+ copyHtml: string,
491
+ markdownId: string
492
+ ): TemplateNode[] {
493
+ const parser = new DOMParser();
494
+ const doc = parser.parseFromString(copyHtml, 'text/html');
495
+
496
+ const allParsedNodes: ParsedNode[] = [];
497
+ walkDom(doc.body, markdownId, allParsedNodes, markdownId);
498
+
499
+ // When parsing copy in isolation, all classes are treated as potential overrides.
500
+ // The consumer is responsible for merging these with a set of defaults if needed.
501
+ return allParsedNodes.map((pNode) => {
502
+ if (
503
+ Object.keys(pNode.responsiveClasses).length > 0 &&
504
+ pNode.flatNode.tagName !== 'span'
505
+ ) {
506
+ pNode.flatNode.overrideClasses = pNode.responsiveClasses;
507
+ }
508
+ return pNode.flatNode;
509
+ });
510
+ }
511
+
496
512
  export const parseAiPane = (
497
513
  shellJson: string,
498
514
  copyHtml: string,
499
515
  layout: string
500
516
  ): TemplatePane => {
501
517
  const shell: ShellJson = JSON.parse(shellJson);
502
- const parser = new DOMParser();
503
- const doc = parser.parseFromString(copyHtml, 'text/html');
504
518
 
505
519
  const paneId = ulid();
506
520
  const markdownId = ulid();
@@ -527,73 +541,7 @@ export const parseAiPane = (
527
541
  defaultClasses: shellDefaults,
528
542
  };
529
543
 
530
- const allParsedNodes: ParsedNode[] = [];
531
- walkDom(doc.body, markdownId, allParsedNodes, markdownId);
532
-
533
- const templateNodes: TemplateNode[] = [];
534
- const nodesByTag = new Map<string, ParsedNode[]>();
535
-
536
- allParsedNodes.forEach((parsedNode) => {
537
- templateNodes.push(parsedNode.flatNode);
538
- const tagName = parsedNode.flatNode.tagName;
539
-
540
- if (
541
- tagName &&
542
- tagName !== 'span' &&
543
- tagName !== 'text' &&
544
- tagName !== 'em' &&
545
- tagName !== 'strong' &&
546
- tagName !== 'a'
547
- ) {
548
- if (!nodesByTag.has(tagName)) {
549
- nodesByTag.set(tagName, []);
550
- }
551
- nodesByTag.get(tagName)!.push(parsedNode);
552
- }
553
- });
554
-
555
- nodesByTag.forEach((nodes, tagName) => {
556
- const commonResponsiveFromCopy = findMostCommonClasses(nodes);
557
- const requiredCommonFromCopy = ensureRequiredViewports(
558
- commonResponsiveFromCopy
559
- );
560
-
561
- const existingShellDefault = markdownNode.defaultClasses![tagName];
562
- const mergedDefault = ensureRequiredViewports(
563
- mergeResponsive(existingShellDefault, commonResponsiveFromCopy)
564
- );
565
-
566
- markdownNode.defaultClasses![tagName] = mergedDefault;
567
-
568
- nodes.forEach((parsedNode) => {
569
- const requiredNodeResponsive = ensureRequiredViewports(
570
- parsedNode.responsiveClasses
571
- );
572
-
573
- if (!isDeepEqual(requiredNodeResponsive, requiredCommonFromCopy)) {
574
- if (!parsedNode.flatNode.overrideClasses) {
575
- parsedNode.flatNode.overrideClasses = {};
576
- }
577
- parsedNode.flatNode.overrideClasses = parsedNode.responsiveClasses;
578
- }
579
- });
580
- });
581
-
582
- if (layout.includes('Image')) {
583
- const imgNode: TemplateNode = {
584
- id: ulid(),
585
- nodeType: 'TagElement',
586
- parentId: markdownId,
587
- tagName: 'img',
588
- src: '/static.jpg',
589
- overrideClasses: {},
590
- };
591
- if (layout === 'Text + Image Left') {
592
- templateNodes.unshift(imgNode);
593
- } else {
594
- templateNodes.push(imgNode);
595
- }
596
- }
544
+ const templateNodes = parseAiCopyHtml(copyHtml, markdownId);
597
545
 
598
546
  const templatePane: TemplatePane = {
599
547
  id: paneId,
@@ -1,5 +1,6 @@
1
1
  import { ulid } from 'ulid';
2
2
  import { getCtx, type NodesContext } from '@/stores/nodes';
3
+ import { tailwindClasses } from '@/utils/compositor/tailwindClasses';
3
4
  import {
4
5
  type PaneNode,
5
6
  type FlatNode,
@@ -13,8 +14,6 @@ import {
13
14
  type VisualBreakNode,
14
15
  type TemplatePane,
15
16
  type TemplateNode,
16
- type BaseNode,
17
- type TemplateMarkdown,
18
17
  } from '@/types/compositorTypes';
19
18
  import type {
20
19
  BrandConfig,
@@ -329,3 +328,89 @@ export function convertStorageToLiveTemplate(
329
328
 
330
329
  return liveTemplatePane;
331
330
  }
331
+
332
+ // Helper to convert a style object { "px": "4", "fontBOLD": "bold" } to "px-4 font-bold"
333
+ function classObjectToString(
334
+ classObj: Record<string, string> | undefined
335
+ ): string {
336
+ if (!classObj) return '';
337
+
338
+ return Object.entries(classObj)
339
+ .map(([key, value]) => {
340
+ const definition = tailwindClasses[key];
341
+ if (!definition) return ''; // Ignore keys not in our definitions
342
+
343
+ if (definition.useKeyAsClass) {
344
+ return value; // e.g., for 'fontBOLD', value is 'font-bold'
345
+ }
346
+
347
+ // Handle negative values
348
+ if (typeof value === 'string' && value.startsWith('-')) {
349
+ return `-${definition.prefix}${value.substring(1)}`;
350
+ }
351
+
352
+ return `${definition.prefix}${value}`;
353
+ })
354
+ .filter(Boolean)
355
+ .join(' ');
356
+ }
357
+
358
+ /**
359
+ * Translates a TemplatePane from the design library into an AI-compatible JSON shell
360
+ * for the hybrid AI copy generation path.
361
+ * @param template The TemplatePane object selected by the user.
362
+ * @returns A JSON string compatible with the AI's second-stage prompt.
363
+ */
364
+ export function convertTemplateToAIShell(template: TemplatePane): string {
365
+ const shell: any = {
366
+ bgColour: template.bgColour || '#ffffff',
367
+ parentClasses: [],
368
+ defaultClasses: {},
369
+ };
370
+
371
+ // 1. Process parentClasses (layout)
372
+ if (template.markdown?.parentClasses) {
373
+ shell.parentClasses = template.markdown.parentClasses.map((layer) => {
374
+ const newLayer: { mobile?: string; tablet?: string; desktop?: string } =
375
+ {};
376
+ if (layer.mobile && Object.keys(layer.mobile).length > 0) {
377
+ newLayer.mobile = classObjectToString(layer.mobile);
378
+ }
379
+ if (layer.tablet && Object.keys(layer.tablet).length > 0) {
380
+ newLayer.tablet = classObjectToString(layer.tablet);
381
+ }
382
+ if (layer.desktop && Object.keys(layer.desktop).length > 0) {
383
+ newLayer.desktop = classObjectToString(layer.desktop);
384
+ }
385
+ return newLayer;
386
+ });
387
+ }
388
+
389
+ // 2. Process defaultClasses (typography, etc.)
390
+ if (template.markdown?.defaultClasses) {
391
+ for (const tag in template.markdown.defaultClasses) {
392
+ const styles = template.markdown.defaultClasses[tag];
393
+ const newTagStyles: {
394
+ mobile?: string;
395
+ tablet?: string;
396
+ desktop?: string;
397
+ } = {};
398
+
399
+ if (styles.mobile && Object.keys(styles.mobile).length > 0) {
400
+ newTagStyles.mobile = classObjectToString(styles.mobile);
401
+ }
402
+ if (styles.tablet && Object.keys(styles.tablet).length > 0) {
403
+ newTagStyles.tablet = classObjectToString(styles.tablet);
404
+ }
405
+ if (styles.desktop && Object.keys(styles.desktop).length > 0) {
406
+ newTagStyles.desktop = classObjectToString(styles.desktop);
407
+ }
408
+
409
+ if (Object.keys(newTagStyles).length > 0) {
410
+ shell.defaultClasses[tag] = newTagStyles;
411
+ }
412
+ }
413
+ }
414
+
415
+ return JSON.stringify(shell, null, 2);
416
+ }
@@ -148,6 +148,13 @@ export class ProfileStorage {
148
148
  StorageManager.set(this.STORAGE_KEYS.profileToken, token);
149
149
  StorageManager.set(this.STORAGE_KEYS.hasProfile, '1');
150
150
  StorageManager.set(this.STORAGE_KEYS.unlockedProfile, '1');
151
+
152
+ try {
153
+ const maxAge = 60 * 60 * 24;
154
+ document.cookie = `tractstack_profile=true; path=/; SameSite=Lax; max-age=${maxAge}`;
155
+ } catch {
156
+ // Silently fail if cookies are blocked
157
+ }
151
158
  }
152
159
 
153
160
  /**
@@ -157,6 +164,12 @@ export class ProfileStorage {
157
164
  StorageManager.remove(this.STORAGE_KEYS.profileToken);
158
165
  StorageManager.remove(this.STORAGE_KEYS.hasProfile);
159
166
  StorageManager.remove(this.STORAGE_KEYS.unlockedProfile);
167
+ try {
168
+ document.cookie =
169
+ 'tractstack_profile=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax';
170
+ } catch {
171
+ // Silently fail
172
+ }
160
173
  }
161
174
 
162
175
  /**