astro-tractstack 2.0.12 → 2.0.13

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-tractstack",
3
- "version": "2.0.12",
3
+ "version": "2.0.13",
4
4
  "description": "Astro integration for TractStack - redeeming the web from boring experiences",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -155,6 +155,7 @@ const getElement = (
155
155
  nodeId={props.nodeId}
156
156
  ctx={getCtx(props)}
157
157
  isTemplate={isTemplate}
158
+ config={props.config!}
158
159
  />
159
160
  ) : (
160
161
  <>
@@ -215,6 +216,7 @@ const getElement = (
215
216
  nodeId={node.id}
216
217
  first={true}
217
218
  ctx={getCtx(props)}
219
+ config={props.config!}
218
220
  isContextPane={true}
219
221
  />
220
222
  </PanelVisibilityWrapper>
@@ -242,7 +244,12 @@ const getElement = (
242
244
  panelType="add"
243
245
  ctx={getCtx(props)}
244
246
  >
245
- <AddPanePanel nodeId={node.id} first={true} ctx={getCtx(props)} />
247
+ <AddPanePanel
248
+ nodeId={node.id}
249
+ first={true}
250
+ ctx={getCtx(props)}
251
+ config={props.config!}
252
+ />
246
253
  </PanelVisibilityWrapper>
247
254
  )}
248
255
  <div className="py-0.5">
@@ -266,7 +273,12 @@ const getElement = (
266
273
  panelType="add"
267
274
  ctx={getCtx(props)}
268
275
  >
269
- <AddPanePanel nodeId={node.id} first={false} ctx={getCtx(props)} />
276
+ <AddPanePanel
277
+ nodeId={node.id}
278
+ first={false}
279
+ ctx={getCtx(props)}
280
+ config={props.config!}
281
+ />
270
282
  </PanelVisibilityWrapper>
271
283
  </>
272
284
  );
@@ -5,8 +5,9 @@ import AddPaneNewPanel from './AddPanePanel_new';
5
5
  import AddPaneBreakPanel from './AddPanePanel_break';
6
6
  import AddPaneReUsePanel from './AddPanePanel_reuse';
7
7
  import AddPaneCodeHookPanel from './AddPanePanel_codehook';
8
- import { NodesContext, ROOT_NODE_NAME, getCtx } from '@/stores/nodes'; // Import getCtx
8
+ import { NodesContext, ROOT_NODE_NAME, getCtx } from '@/stores/nodes';
9
9
  import { PaneAddMode } from '@/types/compositorTypes';
10
+ import type { BrandConfig } from '@/types/tractstack';
10
11
 
11
12
  interface AddPanePanelProps {
12
13
  nodeId: string;
@@ -14,6 +15,7 @@ interface AddPanePanelProps {
14
15
  ctx?: NodesContext;
15
16
  isStoryFragment?: boolean;
16
17
  isContextPane?: boolean;
18
+ config?: BrandConfig;
17
19
  }
18
20
 
19
21
  const AddPanePanel = ({
@@ -22,6 +24,7 @@ const AddPanePanel = ({
22
24
  ctx,
23
25
  isStoryFragment = false,
24
26
  isContextPane = false,
27
+ config,
25
28
  }: AddPanePanelProps) => {
26
29
  const [reset, setReset] = useState(false);
27
30
  const lookup = first ? `${nodeId}-0` : nodeId;
@@ -62,6 +65,7 @@ const AddPanePanel = ({
62
65
  ctx={nodesCtx}
63
66
  isStoryFragment={isStoryFragment}
64
67
  isContextPane={isContextPane}
68
+ config={config!}
65
69
  />
66
70
  ) : mode === PaneAddMode.BREAK && !isContextPane ? (
67
71
  <AddPaneBreakPanel
@@ -26,7 +26,7 @@ import {
26
26
  import { templateCategories } from '@/utils/compositor/templateMarkdownStyles';
27
27
  import { AiPaneGenerator } from './AiPaneGenerator';
28
28
  import { AddPaneNewCustomCopy } from './AddPanePanel_newCustomCopy';
29
- import { themes, type Theme } from '@/types/tractstack';
29
+ import { themes, type Theme, type BrandConfig } from '@/types/tractstack';
30
30
  import { PaneAddMode, type TemplatePane } from '@/types/compositorTypes';
31
31
  import { useStore } from '@nanostores/react';
32
32
 
@@ -37,6 +37,7 @@ interface AddPaneNewPanelProps {
37
37
  ctx?: NodesContext;
38
38
  isStoryFragment?: boolean;
39
39
  isContextPane?: boolean;
40
+ config?: BrandConfig;
40
41
  }
41
42
 
42
43
  interface PreviewPane {
@@ -65,6 +66,7 @@ const AddPaneNewPanel = ({
65
66
  ctx,
66
67
  isStoryFragment = false,
67
68
  isContextPane = false,
69
+ config,
68
70
  }: AddPaneNewPanelProps) => {
69
71
  const brand = useStore(brandColourStore);
70
72
  const hasAssemblyAI = useStore(hasAssemblyAIStore);
@@ -384,6 +386,7 @@ const AddPaneNewPanel = ({
384
386
  ownerId={nodeId}
385
387
  onComplete={handleApplyGeneratedPane}
386
388
  onCancel={() => setMode('template')}
389
+ config={config!}
387
390
  />
388
391
  </div>
389
392
  )}
@@ -2,15 +2,19 @@ import { useState, useCallback } from 'react';
2
2
  import { AiPanePreview } from './AiPanePreview';
3
3
  import type { TemplatePane } from '@/types/compositorTypes';
4
4
  import prompts from '@/constants/prompts.json';
5
- import StringInput from '@/components/form/StringInput';
5
+ import ColorPickerCombo from '@/components/fields/ColorPickerCombo';
6
+ import { parseAiPane } from '@/utils/compositor/aiPaneParser';
7
+ import { classNames } from '@/utils/helpers';
8
+ import type { BrandConfig } from '@/types/tractstack';
6
9
 
7
10
  interface AiPaneGeneratorProps {
8
11
  ownerId: string;
9
12
  onComplete: (pane: TemplatePane) => void;
10
13
  onCancel: () => void;
14
+ config?: BrandConfig;
11
15
  }
12
16
 
13
- type GenerationStep = 'layout' | 'input' | 'preview' | 'loading';
17
+ type GenerationStep = 'input' | 'preview' | 'loading';
14
18
  type CopyMode = 'prompt' | 'raw';
15
19
 
16
20
  interface GenerationResponse {
@@ -21,25 +25,41 @@ interface GenerationResponse {
21
25
  error?: string;
22
26
  }
23
27
 
24
- const layoutOptions = ['Text Only', 'Text + Image Left', 'Text + Image Right'];
28
+ const harmonyOptions = [
29
+ 'Analogous',
30
+ 'Monochromatic',
31
+ 'Complementary',
32
+ 'Triadic',
33
+ ];
34
+ const themeOptions = ['Light', 'Dark', 'Bright', 'Muted', 'Pastel', 'Earthy'];
25
35
 
26
36
  export function AiPaneGenerator({
27
37
  ownerId,
28
38
  onComplete,
29
39
  onCancel,
40
+ config,
30
41
  }: AiPaneGeneratorProps) {
31
- const [currentStep, setCurrentStep] = useState<GenerationStep>('layout');
32
- const [selectedLayout, setSelectedLayout] = useState<string>(
33
- layoutOptions[0]
34
- );
42
+ const [currentStep, setCurrentStep] = useState<GenerationStep>('input');
43
+ const [selectedLayout] = useState<string>('Text Only');
35
44
  const [copyMode, setCopyMode] = useState<CopyMode>('prompt');
36
45
  const [copyPrompt, setCopyPrompt] = useState('');
37
46
  const [rawCopy, setRawCopy] = useState('');
38
- const [designPrompt, setDesignPrompt] = useState('');
39
47
  const [generatedShell, setGeneratedShell] = useState<string | null>(null);
40
48
  const [generatedCopy, setGeneratedCopy] = useState<string | null>(null);
41
49
  const [error, setError] = useState<string | null>(null);
42
50
 
51
+ const [selectedHarmony, setSelectedHarmony] = useState<string>(
52
+ harmonyOptions[0]
53
+ );
54
+ const [baseColor, setBaseColor] = useState<string>('');
55
+ const [accentColor, setAccentColor] = useState<string>('');
56
+ const [selectedTheme, setSelectedTheme] = useState<string>(themeOptions[0]);
57
+ const [additionalNotes, setAdditionalNotes] = useState<string>('');
58
+
59
+ const [isInjectMode, setIsInjectMode] = useState(false);
60
+ const [injectShell, setInjectShell] = useState('');
61
+ const [injectCopy, setInjectCopy] = useState('');
62
+
43
63
  const callAskLemurAPI = useCallback(
44
64
  async (
45
65
  prompt: string,
@@ -93,12 +113,10 @@ export function AiPaneGenerator({
93
113
 
94
114
  let rawResponseData = result.data.response;
95
115
 
96
- // Handle case where API returns JSON object for shell
97
116
  if (expectJson && typeof rawResponseData === 'object') {
98
- return JSON.stringify(rawResponseData); // Return as string
117
+ return JSON.stringify(rawResponseData);
99
118
  }
100
119
 
101
- // Handle case where API returns string (potentially wrapped)
102
120
  if (typeof rawResponseData === 'string') {
103
121
  let responseString = rawResponseData;
104
122
  try {
@@ -116,10 +134,9 @@ export function AiPaneGenerator({
116
134
  } catch (e) {
117
135
  /* Ignore stripping errors */
118
136
  }
119
- return responseString; // Return string directly
137
+ return responseString;
120
138
  }
121
139
 
122
- // Fallback if response is neither expected string nor object
123
140
  throw new Error('Unexpected response format received from API.');
124
141
  },
125
142
  []
@@ -131,6 +148,17 @@ export function AiPaneGenerator({
131
148
  setGeneratedShell(null);
132
149
  setGeneratedCopy(null);
133
150
 
151
+ let designInput = `Generate a design using a **${selectedHarmony.toLowerCase()}** color scheme with a **${selectedTheme.toLowerCase()}** theme.`;
152
+ if (baseColor) {
153
+ designInput += ` Base the colors around **${baseColor}**.`;
154
+ }
155
+ if (accentColor) {
156
+ designInput += ` Use **${accentColor}** as an accent color.`;
157
+ }
158
+ if (additionalNotes) {
159
+ designInput += ` Refine the design with these additional notes: "${additionalNotes}"`;
160
+ }
161
+
134
162
  try {
135
163
  const shellPromptDetails = prompts.aiPaneShellPrompt;
136
164
  const copyPromptDetails = prompts.aiPaneCopyPrompt;
@@ -142,11 +170,8 @@ export function AiPaneGenerator({
142
170
  throw new Error('AI prompts not found or incomplete in prompts.json');
143
171
  }
144
172
 
145
- // --- This is the updated (sequential) logic ---
146
-
147
- // 1. Prepare and call the Shell API first
148
173
  const formattedShellPrompt = shellPromptDetails.user_template
149
- .replace('{{DESIGN_INPUT}}', designPrompt)
174
+ .replace('{{DESIGN_INPUT}}', designInput)
150
175
  .replace('{{LAYOUT_TYPE}}', selectedLayout);
151
176
 
152
177
  const shellResult = await callAskLemurAPI(
@@ -154,35 +179,34 @@ export function AiPaneGenerator({
154
179
  shellPromptDetails.system || '',
155
180
  true
156
181
  );
157
- setGeneratedShell(shellResult); // Set this for the previewer
182
+ setGeneratedShell(shellResult);
158
183
 
159
- // 2. NOW, create the copy prompt, injecting the shellResult
160
184
  const copyInputContent = copyMode === 'prompt' ? copyPrompt : rawCopy;
161
- // Note: Assumes prompts.json's aiPaneCopyPrompt.user_template now includes {{SHELL_JSON}}
162
185
  const formattedCopyPrompt = copyPromptDetails.user_template
163
186
  .replace('{{COPY_INPUT}}', copyInputContent)
164
- .replace('{{DESIGN_INPUT}}', designPrompt)
187
+ .replace('{{DESIGN_INPUT}}', designInput)
165
188
  .replace('{{LAYOUT_TYPE}}', selectedLayout)
166
- .replace('{{SHELL_JSON}}', shellResult); // <-- This is the new, critical part
189
+ .replace('{{SHELL_JSON}}', shellResult);
167
190
 
168
- // 3. Call Copy API second, using the fully-formed prompt
169
191
  const copyResult = await callAskLemurAPI(
170
192
  formattedCopyPrompt,
171
193
  copyPromptDetails.system || '',
172
194
  false
173
195
  );
174
- setGeneratedCopy(copyResult); // Should be an HTML string
196
+ setGeneratedCopy(copyResult);
175
197
 
176
198
  setCurrentStep('preview');
177
-
178
- // --- End of updated logic ---
179
199
  } catch (err: any) {
180
200
  console.error('AI Pane Generation Error:', err);
181
201
  setError(err.message || 'Failed to generate AI pane.');
182
202
  setCurrentStep('input');
183
203
  }
184
204
  }, [
185
- designPrompt,
205
+ selectedHarmony,
206
+ baseColor,
207
+ accentColor,
208
+ selectedTheme,
209
+ additionalNotes,
186
210
  selectedLayout,
187
211
  copyMode,
188
212
  copyPrompt,
@@ -190,12 +214,51 @@ export function AiPaneGenerator({
190
214
  callAskLemurAPI,
191
215
  ]);
192
216
 
217
+ const handleInject = useCallback(() => {
218
+ setError(null);
219
+ if (!injectShell || !injectCopy) {
220
+ setError('Both Shell JSON and Copy HTML must be provided.');
221
+ return;
222
+ }
223
+ try {
224
+ const shellResponse = JSON.parse(injectShell);
225
+ const copyResponse = JSON.parse(injectCopy);
226
+
227
+ const shellPayloadString = JSON.stringify(shellResponse?.data?.response);
228
+ const copyPayloadString = copyResponse?.data?.response;
229
+
230
+ if (
231
+ !shellPayloadString ||
232
+ shellPayloadString === 'null' ||
233
+ typeof copyPayloadString !== 'string'
234
+ ) {
235
+ throw new Error(
236
+ 'Payloads are in an unexpected format. Could not find "data.response".'
237
+ );
238
+ }
239
+
240
+ const pane = parseAiPane(
241
+ shellPayloadString,
242
+ copyPayloadString,
243
+ selectedLayout
244
+ );
245
+ onComplete(pane);
246
+ } catch (err: any) {
247
+ console.error('Payload Injection Error:', err);
248
+ setError(err.message || 'Failed to parse payloads. Check JSON format.');
249
+ }
250
+ }, [injectShell, injectCopy, selectedLayout, onComplete]);
251
+
193
252
  const handleBack = () => {
194
253
  setError(null);
195
254
  if (currentStep === 'preview') {
196
255
  setCurrentStep('input');
197
256
  } else if (currentStep === 'input') {
198
- setCurrentStep('layout');
257
+ if (isInjectMode) {
258
+ setIsInjectMode(false);
259
+ } else {
260
+ onCancel();
261
+ }
199
262
  } else if (currentStep === 'loading') {
200
263
  setCurrentStep('input');
201
264
  }
@@ -230,72 +293,166 @@ export function AiPaneGenerator({
230
293
  );
231
294
  }
232
295
 
233
- if (currentStep === 'layout') {
234
- return (
235
- <div className="space-y-4 p-4">
236
- <label className="mb-2 block text-lg font-semibold text-gray-800">
237
- Choose a Layout
238
- </label>
239
- <div className="space-y-2">
240
- {layoutOptions.map((layout) => (
241
- <div key={layout} className="flex items-center space-x-2">
242
- <input
243
- type="radio"
244
- id={`layout-${layout}`}
245
- name="layoutOptions"
246
- value={layout}
247
- checked={selectedLayout === layout}
248
- onChange={(e) => setSelectedLayout(e.target.value)}
249
- className="h-4 w-4 border-gray-300 text-cyan-600 focus:ring-cyan-500"
250
- />
251
- <label
252
- htmlFor={`layout-${layout}`}
253
- className="text-sm font-medium text-gray-700"
254
- >
255
- {layout}
256
- </label>
257
- </div>
258
- ))}
259
- </div>
260
- <div className="flex justify-end space-x-2 pt-4">
261
- <button
262
- type="button"
263
- onClick={onCancel}
264
- className="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-bold text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2"
265
- >
266
- Cancel
267
- </button>
268
- <button
269
- type="button"
270
- onClick={() => setCurrentStep('input')}
271
- className="rounded-md border border-transparent bg-cyan-600 px-4 py-2 text-sm font-bold text-white hover:bg-cyan-700 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2"
272
- >
273
- Next
274
- </button>
296
+ if (currentStep === 'input') {
297
+ if (isInjectMode) {
298
+ return (
299
+ <div className="space-y-6 p-4">
300
+ <div>
301
+ <label
302
+ htmlFor="shell-json"
303
+ className="block text-lg font-semibold text-gray-800"
304
+ >
305
+ Shell JSON Payload
306
+ </label>
307
+ <textarea
308
+ id="shell-json"
309
+ value={injectShell}
310
+ onChange={(e) => setInjectShell(e.target.value)}
311
+ placeholder="Paste raw API response for ShellJson here..."
312
+ rows={8}
313
+ className="mt-2 block w-full rounded-md border-gray-300 p-2 font-mono text-sm shadow-sm focus:border-cyan-500 focus:ring-cyan-500"
314
+ />
315
+ </div>
316
+
317
+ <div>
318
+ <label
319
+ htmlFor="copy-html"
320
+ className="block text-lg font-semibold text-gray-800"
321
+ >
322
+ Copy HTML Payload
323
+ </label>
324
+ <textarea
325
+ id="copy-html"
326
+ value={injectCopy}
327
+ onChange={(e) => setInjectCopy(e.target.value)}
328
+ placeholder="Paste raw API response for copyHtml here..."
329
+ rows={8}
330
+ className="mt-2 block w-full rounded-md border-gray-300 p-2 font-mono text-sm shadow-sm focus:border-cyan-500 focus:ring-cyan-500"
331
+ />
332
+ </div>
333
+
334
+ {error && <p className="text-sm text-red-600">{error}</p>}
335
+
336
+ <div className="flex justify-between pt-4">
337
+ <button
338
+ type="button"
339
+ onClick={handleBack}
340
+ className="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-bold text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2"
341
+ >
342
+ Back to Generator
343
+ </button>
344
+ <button
345
+ type="button"
346
+ onClick={handleInject}
347
+ disabled={!injectShell || !injectCopy}
348
+ className={`rounded-md border border-transparent px-4 py-2 text-sm font-bold text-white shadow-sm ${
349
+ !injectShell || !injectCopy
350
+ ? 'cursor-not-allowed bg-gray-400'
351
+ : 'bg-cyan-600 hover:bg-cyan-700 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2'
352
+ }`}
353
+ >
354
+ Create from Payloads
355
+ </button>
356
+ </div>
275
357
  </div>
276
- </div>
277
- );
278
- }
358
+ );
359
+ }
279
360
 
280
- if (currentStep === 'input') {
281
361
  return (
282
362
  <div className="space-y-6 p-4">
363
+ <div>
364
+ <label className="block text-lg font-semibold text-gray-800">
365
+ Color Harmony
366
+ </label>
367
+ <div className="mt-2 flex flex-wrap gap-x-4 gap-y-2">
368
+ {harmonyOptions.map((option) => (
369
+ <div key={option} className="flex items-center space-x-2">
370
+ <input
371
+ type="radio"
372
+ id={`harmony-${option}`}
373
+ name="harmonyOptions"
374
+ value={option}
375
+ checked={selectedHarmony === option}
376
+ onChange={(e) => setSelectedHarmony(e.target.value)}
377
+ className="h-4 w-4 border-gray-300 text-cyan-600 focus:ring-cyan-500"
378
+ />
379
+ <label
380
+ htmlFor={`harmony-${option}`}
381
+ className="text-sm font-medium text-gray-700"
382
+ >
383
+ {option}
384
+ </label>
385
+ </div>
386
+ ))}
387
+ </div>
388
+ </div>
389
+
390
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
391
+ <div>
392
+ <ColorPickerCombo
393
+ title="Base Color (Optional)"
394
+ config={config!}
395
+ defaultColor={baseColor}
396
+ onColorChange={setBaseColor}
397
+ allowNull={true}
398
+ />
399
+ </div>
400
+ <div>
401
+ <ColorPickerCombo
402
+ title="Accent Color (Optional)"
403
+ config={config!}
404
+ defaultColor={accentColor}
405
+ onColorChange={setAccentColor}
406
+ allowNull={true}
407
+ />
408
+ </div>
409
+ </div>
410
+
411
+ <div>
412
+ <label className="block text-lg font-semibold text-gray-800">
413
+ Theme / Mood
414
+ </label>
415
+ <div className="mt-2 flex flex-wrap gap-x-4 gap-y-2">
416
+ {themeOptions.map((option) => (
417
+ <div key={option} className="flex items-center space-x-2">
418
+ <input
419
+ type="radio"
420
+ id={`theme-${option}`}
421
+ name="themeOptions"
422
+ value={option}
423
+ checked={selectedTheme === option}
424
+ onChange={(e) => setSelectedTheme(e.target.value)}
425
+ className="h-4 w-4 border-gray-300 text-cyan-600 focus:ring-cyan-500"
426
+ />
427
+ <label
428
+ htmlFor={`theme-${option}`}
429
+ className="text-sm font-medium text-gray-700"
430
+ >
431
+ {option}
432
+ </label>
433
+ </div>
434
+ ))}
435
+ </div>
436
+ </div>
437
+
283
438
  <div>
284
439
  <label
285
- htmlFor="design-prompt"
440
+ htmlFor="additional-notes"
286
441
  className="block text-lg font-semibold text-gray-800"
287
442
  >
288
- Describe the Design
443
+ Additional Design Notes (Optional)
289
444
  </label>
290
445
  <p className="mb-2 mt-1 text-sm text-gray-500">
291
- Example: "dark, minimalist hero", "bright, playful feature box"
446
+ Add specific requests like "use rounded corners", "add subtle
447
+ texture".
292
448
  </p>
293
- <StringInput
294
- id="design-prompt"
295
- value={designPrompt}
296
- onChange={setDesignPrompt}
297
- placeholder="Enter design prompt..."
298
- className="block w-full rounded-md border border-gray-300 p-2 shadow-sm focus:border-cyan-500 focus:ring-cyan-500 sm:text-sm"
449
+ <textarea
450
+ id="additional-notes"
451
+ value={additionalNotes}
452
+ onChange={(e) => setAdditionalNotes(e.target.value)}
453
+ placeholder="Enter additional notes..."
454
+ rows={3}
455
+ className="block w-full rounded-md border-gray-300 p-2 shadow-sm focus:border-cyan-500 focus:ring-cyan-500 sm:text-sm"
299
456
  />
300
457
  </div>
301
458
 
@@ -380,21 +537,34 @@ export function AiPaneGenerator({
380
537
  onClick={handleBack}
381
538
  className="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-bold text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2"
382
539
  >
383
- Back
540
+ Cancel
384
541
  </button>
385
542
  <button
386
543
  type="button"
387
544
  onClick={handleGenerate}
388
- disabled={
389
- !designPrompt || (copyMode === 'prompt' ? !copyPrompt : !rawCopy)
390
- }
391
- className={`rounded-md border border-transparent px-4 py-2 text-sm font-bold text-white shadow-sm ${
392
- !designPrompt || (copyMode === 'prompt' ? !copyPrompt : !rawCopy)
393
- ? 'cursor-not-allowed bg-gray-400'
394
- : 'bg-cyan-600 hover:bg-cyan-700 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2'
395
- }`}
545
+ disabled={copyMode === 'prompt' ? !copyPrompt : !rawCopy}
546
+ className={classNames(
547
+ `rounded-md border border-transparent px-4 py-2 text-sm font-bold shadow-sm transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2`,
548
+ (copyMode === 'prompt' && !copyPrompt) ||
549
+ (copyMode === `raw` && !rawCopy)
550
+ ? 'cursor-not-allowed bg-gray-300 text-gray-500'
551
+ : 'bg-cyan-600 text-white hover:bg-cyan-700'
552
+ )}
553
+ >
554
+ Generate Pane
555
+ </button>
556
+ </div>
557
+
558
+ <div className="border-t border-gray-200 pt-4 text-center">
559
+ <button
560
+ type="button"
561
+ onClick={() => {
562
+ setError(null);
563
+ setIsInjectMode(true);
564
+ }}
565
+ className="text-sm text-cyan-600 hover:text-cyan-800 hover:underline"
396
566
  >
397
- Generate Preview
567
+ Direct Inject Payload
398
568
  </button>
399
569
  </div>
400
570
  </div>
@@ -1,23 +1,6 @@
1
- import { useState, useEffect, useCallback, useMemo } from 'react';
1
+ import { useState, useEffect, useMemo } from 'react';
2
2
  import type { TemplatePane } from '@/types/compositorTypes';
3
3
  import { parseAiPane } from '@/utils/compositor/aiPaneParser';
4
- import {
5
- PaneSnapshotGenerator,
6
- type SnapshotData,
7
- } from '@/components/compositor/preview/PaneSnapshotGenerator';
8
- import { ulid } from 'ulid';
9
-
10
- type LLMShellLayer = {
11
- mobile?: Record<string, string>;
12
- tablet?: Record<string, string>;
13
- desktop?: Record<string, string>;
14
- };
15
-
16
- type ShellJson = {
17
- bgColour: string;
18
- parentClasses: LLMShellLayer[];
19
- defaultClasses: Record<string, any>;
20
- };
21
4
 
22
5
  interface AiPanePreviewProps {
23
6
  shellJson: string;
@@ -28,49 +11,6 @@ interface AiPanePreviewProps {
28
11
  onBack: () => void;
29
12
  }
30
13
 
31
- function convertObjectToTailwindString(
32
- styleObj: Record<string, string> | undefined
33
- ): string {
34
- if (!styleObj) return '';
35
- return Object.entries(styleObj)
36
- .map(([key, value]) => {
37
- // Basic mapping, might need adjustment based on tailwindClasses structure if prefixes differ
38
- const prefixMap: Record<string, string> = {
39
- mx: 'mx',
40
- my: 'my',
41
- px: 'px',
42
- py: 'py',
43
- textALIGN: 'text',
44
- textSIZE: 'text',
45
- textCOLOR: 'text',
46
- fontWEIGHT: 'font',
47
- fontFACE: 'font',
48
- letterSPACING: 'tracking',
49
- lineHEIGHT: 'leading',
50
- bgCOLOR: 'bg',
51
- rounded: 'rounded',
52
- shadow: 'shadow',
53
- maxW: 'max-w',
54
- // Add other mappings as needed based on keys used in compositorTypes vs tailwindClasses
55
- };
56
- const prefix = prefixMap[key] || key.toLowerCase();
57
- if (value === '') return key; // Handle boolean classes like 'relative', 'flex'
58
- return `${prefix}-${value}`;
59
- })
60
- .join(' ');
61
- }
62
-
63
- function getPreviewClasses(classes: LLMShellLayer | undefined): string {
64
- if (!classes) return '';
65
-
66
- const mobileStyles = convertObjectToTailwindString(classes.mobile);
67
- const tabletStyles = convertObjectToTailwindString(classes.tablet);
68
- const desktopStyles = convertObjectToTailwindString(classes.desktop);
69
-
70
- const combined = `${mobileStyles} ${tabletStyles ? `md:${tabletStyles.split(' ').join(' md:')}` : ''} ${desktopStyles ? `xl:${desktopStyles.split(' ').join(' xl:')}` : ''}`;
71
- return combined.replace(/\s+/g, ' ').trim();
72
- }
73
-
74
14
  export function AiPanePreview({
75
15
  shellJson,
76
16
  copyHtml,
@@ -78,152 +18,82 @@ export function AiPanePreview({
78
18
  onComplete,
79
19
  onBack,
80
20
  }: AiPanePreviewProps) {
81
- const [parsedPaneForApply, setParsedPaneForApply] =
82
- useState<TemplatePane | null>(null);
83
21
  const [error, setError] = useState<string | null>(null);
84
- const [snapshotData, setSnapshotData] = useState<SnapshotData | null>(null);
85
- const [isGeneratingSnapshot, setIsGeneratingSnapshot] =
86
- useState<boolean>(false);
87
- const previewId = useMemo(() => `ai-preview-${ulid()}`, []);
22
+ const [hasCompleted, setHasCompleted] = useState<boolean>(false);
23
+ const [isLoading, setIsLoading] = useState<boolean>(true);
88
24
 
89
25
  useEffect(() => {
90
- let isActive = true;
91
26
  setError(null);
92
- setParsedPaneForApply(null);
93
- setSnapshotData(null);
94
- setIsGeneratingSnapshot(false);
95
- try {
96
- const pane = parseAiPane(shellJson, copyHtml, layout);
97
- if (isActive) {
98
- setParsedPaneForApply(pane);
99
- }
100
- } catch (err: any) {
101
- console.error('Error parsing AI Pane for apply:', err);
102
- if (isActive) {
103
- setError(
104
- err.message || 'Failed to parse generated content for application.'
105
- );
106
- setParsedPaneForApply(null);
107
- }
108
- }
109
- return () => {
110
- isActive = false;
111
- };
112
- }, [shellJson, copyHtml, layout]);
113
-
114
- const previewHtmlString = useMemo(() => {
115
- try {
116
- if (!shellJson || !copyHtml) return '';
117
- const shell: ShellJson = JSON.parse(shellJson);
118
-
119
- let currentHtml = copyHtml;
120
- if (shell.parentClasses && shell.parentClasses.length > 0) {
121
- [...shell.parentClasses].reverse().forEach((layer) => {
122
- const layerClasses = getPreviewClasses(layer);
123
- currentHtml = `<div class="${layerClasses}">${currentHtml}</div>`;
124
- });
125
- }
126
-
127
- const outerStyle = shell.bgColour
128
- ? `background-color: ${shell.bgColour};`
129
- : '';
130
- // Wrap in a div that sets width similar to snapshot generator default for better preview consistency
131
- return `<div style="${outerStyle} width: 800px; padding: 1px; margin: auto;">${currentHtml}</div>`;
132
- } catch (err: any) {
133
- console.error('Error constructing preview HTML string:', err);
134
- setError(err.message || 'Failed to construct preview HTML.');
135
- return '';
136
- }
137
- }, [shellJson, copyHtml]);
138
-
139
- const handleSnapshotComplete = useCallback(
140
- (id: string, data: SnapshotData) => {
141
- if (id === previewId) {
142
- setSnapshotData(data);
143
- setIsGeneratingSnapshot(false);
144
- }
145
- },
146
- [previewId]
147
- );
148
-
149
- const handleSnapshotError = useCallback(
150
- (id: string, errorMsg: string) => {
151
- if (id === previewId) {
152
- console.error(`Snapshot generation failed for ${id}:`, errorMsg);
153
- setError(`Snapshot generation failed: ${errorMsg}`);
154
- setIsGeneratingSnapshot(false);
155
- }
156
- },
157
- [previewId]
158
- );
27
+ setHasCompleted(false);
28
+ setIsLoading(true);
29
+ let isActive = true;
159
30
 
160
- const handleApply = () => {
161
- if (parsedPaneForApply) {
162
- onComplete(parsedPaneForApply);
163
- console.log('FINAL TEMPLATE PANE PAYLOAD:', parsedPaneForApply);
164
- } else if (!error) {
165
- // Attempt parsing again if it failed silently initially
31
+ if (shellJson && copyHtml) {
166
32
  try {
167
33
  const pane = parseAiPane(shellJson, copyHtml, layout);
168
- onComplete(pane);
34
+ if (isActive && !hasCompleted) {
35
+ onComplete(pane);
36
+ setHasCompleted(true);
37
+ setIsLoading(false);
38
+ }
169
39
  } catch (err: any) {
170
- setError(
171
- err.message || 'Failed to parse generated content before applying.'
172
- );
40
+ console.error('Error parsing AI Pane:', err);
41
+ if (isActive) {
42
+ setError(err.message || 'Failed to parse generated content.');
43
+ setIsLoading(false);
44
+ }
173
45
  }
46
+ } else {
47
+ // Handle case where inputs might be initially empty
48
+ setIsLoading(false);
174
49
  }
175
- };
176
50
 
177
- useEffect(() => {
178
- if (previewHtmlString && !snapshotData && !error && !isGeneratingSnapshot) {
179
- setIsGeneratingSnapshot(true);
51
+ return () => {
52
+ isActive = false;
53
+ };
54
+ }, [shellJson, copyHtml, layout, onComplete, hasCompleted]);
55
+
56
+ const displayContent = useMemo(() => {
57
+ if (isLoading) {
58
+ return (
59
+ <div className="p-4 text-center text-gray-500">
60
+ <div className="mx-auto mb-2 h-8 w-8 animate-spin rounded-full border-b-2 border-gray-400"></div>
61
+ <p className="text-sm">Processing...</p>
62
+ </div>
63
+ );
180
64
  }
181
- }, [previewHtmlString, snapshotData, error, isGeneratingSnapshot]);
182
-
183
- const showLoading =
184
- isGeneratingSnapshot ||
185
- (!previewHtmlString && !error && !parsedPaneForApply);
186
- const showPreview = !isGeneratingSnapshot && snapshotData && !error;
187
- const showError = !!error;
65
+ if (error) {
66
+ return (
67
+ <div className="p-4 text-center text-red-600">
68
+ <p className="font-semibold">Error:</p>
69
+ <p className="mt-1 text-sm">{error}</p>
70
+ </div>
71
+ );
72
+ }
73
+ if (hasCompleted) {
74
+ return (
75
+ <div className="p-4 text-center text-green-700">
76
+ <p className="font-semibold">Pane Applied Successfully!</p>
77
+ <p className="mt-1 text-sm">
78
+ You can now go back or continue editing.
79
+ </p>
80
+ </div>
81
+ );
82
+ }
83
+ // Fallback/initial state before useEffect runs if needed
84
+ return (
85
+ <div className="p-4 text-center text-gray-500">
86
+ <p className="text-sm">Preparing...</p>
87
+ </div>
88
+ );
89
+ }, [isLoading, error, hasCompleted]);
188
90
 
189
91
  return (
190
92
  <div className="flex h-full flex-col p-4">
191
93
  <div className="relative mb-4 flex min-h-[200px] flex-grow items-center justify-center overflow-auto rounded border bg-gray-50">
192
- {showError && (
193
- <div className="p-4 text-center text-red-600">
194
- <p className="font-semibold">Error:</p>
195
- <p className="mt-1 text-sm">{error}</p>
196
- </div>
197
- )}
198
- {showLoading && !showError && (
199
- <div className="p-4 text-center text-gray-500">
200
- <div className="mx-auto mb-2 h-8 w-8 animate-spin rounded-full border-b-2 border-gray-400"></div>
201
- <p className="text-sm">
202
- {isGeneratingSnapshot
203
- ? 'Generating Snapshot...'
204
- : 'Constructing Preview...'}
205
- </p>
206
- </div>
207
- )}
208
- {isGeneratingSnapshot && previewHtmlString && (
209
- <div className="pointer-events-none absolute left-[-9999px] top-[-9999px] w-[800px] opacity-0">
210
- <PaneSnapshotGenerator
211
- id={previewId}
212
- htmlString={previewHtmlString}
213
- onComplete={handleSnapshotComplete}
214
- onError={handleSnapshotError}
215
- />
216
- </div>
217
- )}
218
- {showPreview && snapshotData && (
219
- <img
220
- src={snapshotData.imageData}
221
- alt="AI Pane Preview"
222
- className="block h-auto max-w-full"
223
- />
224
- )}
94
+ {displayContent}
225
95
  </div>
226
- <div className="flex flex-shrink-0 justify-between">
96
+ <div className="flex flex-shrink-0 justify-start">
227
97
  <button
228
98
  onClick={onBack}
229
99
  className="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
@@ -231,26 +101,6 @@ export function AiPanePreview({
231
101
  >
232
102
  Back
233
103
  </button>
234
- <button
235
- onClick={handleApply}
236
- disabled={
237
- !parsedPaneForApply ||
238
- !!error ||
239
- !snapshotData ||
240
- isGeneratingSnapshot
241
- }
242
- className={`rounded-md border border-transparent px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors duration-150 ${
243
- !parsedPaneForApply ||
244
- !!error ||
245
- !snapshotData ||
246
- isGeneratingSnapshot
247
- ? 'cursor-not-allowed bg-gray-400'
248
- : 'bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2'
249
- }`}
250
- type="button"
251
- >
252
- Apply Pane
253
- </button>
254
104
  </div>
255
105
  </div>
256
106
  );
@@ -12,11 +12,13 @@ import {
12
12
  /* hasAssemblyAIStore,*/ fullContentMapStore,
13
13
  } from '@/stores/storykeep';
14
14
  import type { NodesContext } from '@/stores/nodes';
15
+ import type { BrandConfig } from '@/types/tractstack';
15
16
 
16
17
  interface PageCreationSelectorProps {
17
18
  nodeId: string;
18
19
  ctx: NodesContext;
19
20
  isTemplate?: boolean;
21
+ config?: BrandConfig;
20
22
  }
21
23
 
22
24
  type CreationMode = {
@@ -33,6 +35,7 @@ export const PageCreationSelector = ({
33
35
  nodeId,
34
36
  ctx,
35
37
  isTemplate = false,
38
+ config,
36
39
  }: PageCreationSelectorProps) => {
37
40
  const [selectedMode, setSelectedMode] =
38
41
  useState<CreationMode['id']>('design');
@@ -137,6 +140,7 @@ export const PageCreationSelector = ({
137
140
  first={true}
138
141
  ctx={ctx}
139
142
  isStoryFragment={true}
143
+ config={config!}
140
144
  />
141
145
  );
142
146
  else if (showGen) return <PageCreationGen nodeId={nodeId} ctx={ctx} />;
@@ -41,10 +41,10 @@
41
41
  },
42
42
  "aiPaneShellPrompt": {
43
43
  "system": "You are an expert web designer. Your task is to generate the structural design for a component as a single JSON object. Respond *only* with the JSON.",
44
- "user_template": "Generate the design JSON for a component with the following characteristics:\n\nDesign Style: \"{{DESIGN_INPUT}}\"\nLayout Type: \"{{LAYOUT_TYPE}}\"\n\nCRITICAL RULES:\n1. You must respond with a JSON object with keys: `bgColour`, `parentClasses`, `defaultClasses`.\n2. The `parentClasses` value *must* be structured with our internal responsive object schema (e.g., `[ { \"mobile\": { \"px\": \"4\", \"py\": \"12\" } } ]`).\n3. The `defaultClasses` value *must* be structured with responsive keys containing **Tailwind class strings** (e.g., `{ \"h2\": { \"mobile\": \"text-4xl font-bold text-white mt-6\" } }`).\n4. For responsive classes, you *must* only use `md:` and `xl:` prefixes.\n\nEXAMPLE:\n{\n \"bgColour\": \"#050710\",\n \"parentClasses\": [\n { \"mobile\": { \"px\": \"6\", \"py\": \"24\" }, \"tablet\": { \"px\": \"8\", \"py\": \"32\" } },\n { \"mobile\": { \"mx\": \"auto\", \"maxW\": \"2xl\", \"textALIGN\": \"center\" }, \"tablet\": { \"maxW\": \"4xl\" } }\n ],\n \"defaultClasses\": {\n \"h2\": { \"mobile\": \"text-4xl font-bold tracking-tight text-white mt-4\", \"tablet\": \"md:text-6xl\", \"desktop\": \"xl:text-7xl\" },\n \"p\": { \"mobile\": \"text-lg leading-8 text-gray-300 mt-6\", \"tablet\": \"md:text-xl\", \"desktop\": \"\" }\n }\n}"
44
+ "user_template": "Generate the design JSON for a component with the following characteristics:\n\nDesign Style: \"{{DESIGN_INPUT}}\"\nLayout Type: \"{{LAYOUT_TYPE}}\"\n\nCRITICAL RULES:\n1. You must respond with a JSON object with keys: `bgColour`, `parentClasses`, `defaultClasses`.\n2. The `parentClasses` value *must* be structured with our internal responsive object schema (e.g., `[ { \"mobile\": { \"px\": \"4\", \"py\": \"12\" } } ]`).\n3. The `defaultClasses` value *must* be structured with responsive keys (`mobile`, `tablet`, `desktop`) containing Tailwind class strings.\n4. Ensure the selected `bgColour` provides **high contrast** (meeting at least WCAG AA standards - 4.5:1 for normal text, 3:1 for large text) with the primary text colors defined in `defaultClasses`.\n\nEXAMPLE:\n{\n \"bgColour\": \"#050710\",\n \"parentClasses\": [\n { \"mobile\": { \"px\": \"6\", \"py\": \"24\" }, \"tablet\": { \"px\": \"8\", \"py\": \"32\" } },\n { \"mobile\": { \"mx\": \"auto\", \"maxW\": \"2xl\", \"textALIGN\": \"center\" }, \"tablet\": { \"maxW\": \"4xl\" } }\n ],\n \"defaultClasses\": {\n \"h2\": { \"mobile\": \"text-4xl font-bold tracking-tight text-white mt-4\", \"tablet\": \"text-6xl\", \"desktop\": \"text-7xl\" },\n \"p\": { \"mobile\": \"text-lg leading-8 text-gray-300 mt-6\", \"tablet\": \"text-xl\", \"desktop\": \"\" }\n }\n}"
45
45
  },
46
46
  "aiPaneCopyPrompt": {
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, and **beautifully spaced**.",
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. 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>"
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>"
49
49
  }
50
50
  }
@@ -250,7 +250,12 @@ function sanitizeButtonClasses(
250
250
  return buttonPayload;
251
251
  }
252
252
 
253
- function walkDom(domNode: Node, parentId: string, parsedNodes: ParsedNode[]) {
253
+ function walkDom(
254
+ domNode: Node,
255
+ parentId: string,
256
+ parsedNodes: ParsedNode[],
257
+ markdownId: string
258
+ ) {
254
259
  if (domNode.nodeType === Node.TEXT_NODE) {
255
260
  const copy = domNode.textContent || '';
256
261
  // Preserve leading/trailing spaces unless the *entire* content is just whitespace.
@@ -305,17 +310,37 @@ function walkDom(domNode: Node, parentId: string, parsedNodes: ParsedNode[]) {
305
310
  const tagName = el.tagName.toLowerCase();
306
311
 
307
312
  if (!ALLOWED_TAGS.has(tagName)) {
308
- el.childNodes.forEach((child) => walkDom(child, parentId, parsedNodes));
313
+ el.childNodes.forEach((child) =>
314
+ walkDom(child, parentId, parsedNodes, markdownId)
315
+ );
309
316
  return;
310
317
  }
311
318
 
312
319
  if (tagName === 'button') {
320
+ let finalParentId = parentId;
321
+
322
+ if (parentId === markdownId) {
323
+ const pNodeId = ulid();
324
+ const pNode: TemplateNode = {
325
+ id: pNodeId,
326
+ nodeType: 'TagElement',
327
+ parentId: parentId,
328
+ tagName: 'p',
329
+ overrideClasses: {},
330
+ };
331
+ parsedNodes.push({
332
+ flatNode: pNode,
333
+ responsiveClasses: {},
334
+ });
335
+ finalParentId = pNodeId;
336
+ }
337
+
313
338
  const buttonPayload = sanitizeButtonClasses(el.getAttribute('class'));
314
339
 
315
340
  const flatNode: TemplateNode = {
316
341
  id: ulid(),
317
342
  nodeType: 'TagElement',
318
- parentId: parentId,
343
+ parentId: finalParentId,
319
344
  tagName: 'a',
320
345
  overrideClasses: {},
321
346
  href: '#',
@@ -330,7 +355,9 @@ function walkDom(domNode: Node, parentId: string, parsedNodes: ParsedNode[]) {
330
355
  responsiveClasses: {},
331
356
  });
332
357
 
333
- el.childNodes.forEach((child) => walkDom(child, flatNode.id, parsedNodes));
358
+ el.childNodes.forEach((child) =>
359
+ walkDom(child, flatNode.id, parsedNodes, markdownId)
360
+ );
334
361
  return;
335
362
  }
336
363
 
@@ -353,7 +380,9 @@ function walkDom(domNode: Node, parentId: string, parsedNodes: ParsedNode[]) {
353
380
  responsiveClasses: responsive,
354
381
  });
355
382
 
356
- el.childNodes.forEach((child) => walkDom(child, flatNode.id, parsedNodes));
383
+ el.childNodes.forEach((child) =>
384
+ walkDom(child, flatNode.id, parsedNodes, markdownId)
385
+ );
357
386
  }
358
387
 
359
388
  function findMostCommonClasses(nodes: ParsedNode[]): ResponsiveClasses {
@@ -476,18 +505,15 @@ export const parseAiPane = (
476
505
  const paneId = ulid();
477
506
  const markdownId = ulid();
478
507
 
479
- // --- MODIFICATION START ---
480
- // Normalize the keys within parentClasses using the new helper
481
508
  const transformedParentClasses: ParentClassesPayload = (
482
509
  shell.parentClasses || []
483
510
  ).map(
484
511
  (layer): ParentClassLayer => ({
485
- mobile: normalizeKeys(layer.mobile), // Use normalizeKeys helper
486
- tablet: normalizeKeys(layer.tablet), // Use normalizeKeys helper
487
- desktop: normalizeKeys(layer.desktop), // Use normalizeKeys helper
512
+ mobile: normalizeKeys(layer.mobile),
513
+ tablet: normalizeKeys(layer.tablet),
514
+ desktop: normalizeKeys(layer.desktop),
488
515
  })
489
516
  );
490
- // --- MODIFICATION END ---
491
517
 
492
518
  const shellDefaults = parseDefaultClassesFromShell(shell.defaultClasses);
493
519
 
@@ -497,12 +523,12 @@ export const parseAiPane = (
497
523
  parentId: paneId,
498
524
  type: 'markdown',
499
525
  markdownId: ulid(),
500
- parentClasses: transformedParentClasses, // Use the transformed version
526
+ parentClasses: transformedParentClasses,
501
527
  defaultClasses: shellDefaults,
502
528
  };
503
529
 
504
530
  const allParsedNodes: ParsedNode[] = [];
505
- walkDom(doc.body, markdownId, allParsedNodes);
531
+ walkDom(doc.body, markdownId, allParsedNodes, markdownId);
506
532
 
507
533
  const templateNodes: TemplateNode[] = [];
508
534
  const nodesByTag = new Map<string, ParsedNode[]>();