@deneb-ui/cli 2.0.71 → 2.0.73

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.
@@ -1,5 +1,5 @@
1
- 'use strict';
2
-
1
+ const fs = require('fs');
2
+ const path = require('path');
3
3
  const recast = require('recast');
4
4
  const {
5
5
  parseSource,
@@ -9,6 +9,7 @@ const {
9
9
  hasJsxAttribute,
10
10
  collectJsxText,
11
11
  findJsxAttribute,
12
+ unwrapExpr,
12
13
  } = require('./ast.cjs');
13
14
  const {
14
15
  activeAdapters,
@@ -28,11 +29,46 @@ const {
28
29
  DECORATIVE_TAGS,
29
30
  } = require('./adapters.cjs');
30
31
  const { shortHash } = require('./fs-utils.cjs');
32
+ const { resolveImportSpecifier } = require('./scanner.cjs');
31
33
  const { BROAD_CONTENT_CONTAINERS } = require('./fivora-contract.cjs');
32
34
 
33
35
  const TECHNICAL_TEXT_RE = /^(true|false|null|undefined|px|rem|em|auto|hidden|flex|grid|sr-only)$/i;
34
36
  const ARIA_ONLY_RE = /^(aria-|data-state|data-slot|data-orientation)/;
35
37
  const SKIP_ATTR_NAMES = new Set(['className', 'class', 'style', 'key', 'id', 'role', 'type', 'name', 'htmlFor', 'suppressHydrationWarning']);
38
+ const USER_FACING_PROP_NAMES = new Set([
39
+ 'title',
40
+ 'heading',
41
+ 'subheading',
42
+ 'subtitle',
43
+ 'label',
44
+ 'description',
45
+ 'caption',
46
+ 'badge',
47
+ 'buttonText',
48
+ 'ctaText',
49
+ 'helperText',
50
+ 'summary',
51
+ ]);
52
+
53
+ const importedDataCache = new Map();
54
+
55
+ function resolveImportedBinding(specifier, identifierName, currentFileAbs, profile) {
56
+ if (!specifier || !profile?.root || !currentFileAbs) return null;
57
+ const resolved = resolveImportSpecifier(profile, currentFileAbs, specifier);
58
+ if (!resolved || !fs.existsSync(resolved)) return null;
59
+ if (importedDataCache.has(resolved)) {
60
+ return importedDataCache.get(resolved).get(identifierName) || null;
61
+ }
62
+ try {
63
+ const src = fs.readFileSync(resolved, 'utf8');
64
+ const importedAst = parseSource(src, resolved);
65
+ const importedBindings = collectStringBindings(importedAst);
66
+ importedDataCache.set(resolved, importedBindings);
67
+ return importedBindings.get(identifierName) || null;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
36
72
 
37
73
  function fingerprintCandidate(features) {
38
74
  return shortHash(JSON.stringify(features));
@@ -42,9 +78,10 @@ function normalizeText(value) {
42
78
  return String(value || '').replace(/\s+/g, ' ').trim();
43
79
  }
44
80
 
45
- function isStaticSkipText(text) {
81
+ function isStaticSkipText(text, isLogoContext = false) {
46
82
  const value = normalizeText(text);
47
- if (!value || value.length < 2) return true;
83
+ if (!value) return true;
84
+ if (value.length < 2 && !isLogoContext) return true;
48
85
  if (TECHNICAL_TEXT_RE.test(value)) return true;
49
86
  if (/^[{}`\\]/.test(value)) return true;
50
87
  if (/^https?:\/\/(localhost|127\.0\.0\.1)/i.test(value)) return true;
@@ -350,6 +387,7 @@ function collectItemFieldUsage(callback, itemParam) {
350
387
  }
351
388
 
352
389
  let usesItemAsComponent = false;
390
+ let usesDirectItem = false;
353
391
  const componentProps = new Set();
354
392
  recast.types.visit(callback, {
355
393
  visitJSXOpeningElement(pathNode) {
@@ -364,6 +402,11 @@ function collectItemFieldUsage(callback, itemParam) {
364
402
  this.traverse(pathNode);
365
403
  },
366
404
  visitJSXExpressionContainer(pathNode) {
405
+ const rawExpr = pathNode.node.expression;
406
+ const unwrapped = unwrapTypeCasts(rawExpr);
407
+ if (unwrapped && (unwrapped.type === 'Identifier' || unwrapped.type === 'JSXIdentifier') && unwrapped.name === itemParam) {
408
+ usesDirectItem = true;
409
+ }
367
410
  const properties = findItemMemberProperties(pathNode.node.expression);
368
411
  for (const property of properties) {
369
412
  if (componentProps.has(property)) continue;
@@ -385,7 +428,7 @@ function collectItemFieldUsage(callback, itemParam) {
385
428
  },
386
429
  });
387
430
 
388
- return { usage, usesItemAsComponent };
431
+ return { usage, usesItemAsComponent, usesDirectItem };
389
432
  }
390
433
 
391
434
  function classNameOf(node) {
@@ -485,7 +528,14 @@ function analyzeFile({ code, relativeFile, profile, graph, ownerScope, component
485
528
  const mapInfo = inMapCallback(pathNode);
486
529
  let apiOwned = false;
487
530
  if (mapInfo && mapInfo.objectName) {
488
- const binding = bindings.get(mapInfo.objectName);
531
+ let binding = bindings.get(mapInfo.objectName);
532
+ if (!binding && imports[mapInfo.objectName]) {
533
+ const currentFileAbs = profile?.root ? path.join(profile.root, relativeFile) : null;
534
+ binding = resolveImportedBinding(imports[mapInfo.objectName], mapInfo.objectName, currentFileAbs, profile);
535
+ if (binding) {
536
+ bindings.set(mapInfo.objectName, binding);
537
+ }
538
+ }
489
539
  if (!binding || binding.kind !== 'array') apiOwned = true;
490
540
  }
491
541
 
@@ -514,11 +564,24 @@ function analyzeFile({ code, relativeFile, profile, graph, ownerScope, component
514
564
  const parents = parentNames(pathNode);
515
565
  const actionNode = resolveActionWithAdapters(node, adapters) || (ACTION_TAGS.has(name) ? node : null);
516
566
 
567
+ const isHeaderNav =
568
+ parents.some((p) => /header|nav|navbar/i.test(p)) ||
569
+ /header|nav|navbar/i.test(name) ||
570
+ /header|nav/i.test(relativeFile) ||
571
+ componentMeta?.role === 'navigation';
572
+ const ariaLabel = getJsxAttributeLiteral(node, 'aria-label') || '';
573
+ const isExplicitLogo = /\b(site-logo|brand-logo|nav-logo|header-logo|monogram)\b/i.test(className);
574
+ const isLogoContext =
575
+ isExplicitLogo ||
576
+ (isHeaderNav && /\blogo\b/i.test(className)) ||
577
+ (isHeaderNav && /\b(logo|home)\b/i.test(ariaLabel)) ||
578
+ (isHeaderNav && (href === '/' || href === ''));
579
+
517
580
  const baseMeta = {
518
581
  loc,
519
582
  tag: name,
520
583
  file: relativeFile,
521
- ownerScope,
584
+ ownerScope: (isLogoContext && isHeaderNav) ? 'common' : ownerScope,
522
585
  componentName: componentMeta?.name,
523
586
  role: componentMeta?.role,
524
587
  className,
@@ -529,6 +592,43 @@ function analyzeFile({ code, relativeFile, profile, graph, ownerScope, component
529
592
  fromBinding: textInfo.fromBinding,
530
593
  };
531
594
 
595
+ if (isLogoContext) {
596
+ if ((IMAGE_TAGS.has(name) || recognition?.kind === 'image') && src && !src.startsWith('{')) {
597
+ usedLocs.add(loc);
598
+ candidates.push({
599
+ ...baseMeta,
600
+ kind: 'image',
601
+ operation: 'extract-image',
602
+ value: src,
603
+ extra: { alt: alt || 'Logo', isBrandLogo: true },
604
+ confidence: 0.98,
605
+ reason: 'brand-logo-image',
606
+ fingerprint: fingerprintCandidate({ tag: name, kind: 'logo-image' }),
607
+ });
608
+ this.traverse(pathNode);
609
+ return;
610
+ }
611
+
612
+ if (name === 'Link' || name === 'a' || name === 'span') {
613
+ const logoText = textInfo.text || collectJsxText(node);
614
+ if (logoText && !isStaticSkipText(logoText, true)) {
615
+ usedLocs.add(loc);
616
+ candidates.push({
617
+ ...baseMeta,
618
+ kind: 'text',
619
+ operation: 'extract-text',
620
+ value: logoText,
621
+ extra: { tag: name, isBrandLogo: true },
622
+ confidence: 0.98,
623
+ reason: 'brand-logo-text',
624
+ fingerprint: fingerprintCandidate({ tag: name, kind: 'logo-text', text: logoText }),
625
+ });
626
+ this.traverse(pathNode);
627
+ return;
628
+ }
629
+ }
630
+ }
631
+
532
632
  if ((IMAGE_TAGS.has(name) || recognition?.kind === 'image') && src && !src.startsWith('{')) {
533
633
  usedLocs.add(loc);
534
634
  candidates.push({
@@ -569,6 +669,43 @@ function analyzeFile({ code, relativeFile, profile, graph, ownerScope, component
569
669
  });
570
670
  }
571
671
 
672
+ const isCustomComponent = Boolean(name && ((name[0] >= 'A' && name[0] <= 'Z') || name.includes('.')));
673
+ if (isCustomComponent && !apiOwned && node.openingElement && Array.isArray(node.openingElement.attributes)) {
674
+ for (const attr of node.openingElement.attributes) {
675
+ if (attr.type === 'JSXAttribute' && attr.name && USER_FACING_PROP_NAMES.has(attr.name.name)) {
676
+ const propName = attr.name.name;
677
+ let propValue = '';
678
+ if (attr.value) {
679
+ if (attr.value.type === 'StringLiteral' || attr.value.type === 'Literal') {
680
+ propValue = String(attr.value.value || '');
681
+ } else if (attr.value.type === 'JSXExpressionContainer') {
682
+ const expr = attr.value.expression;
683
+ if (expr && (expr.type === 'StringLiteral' || expr.type === 'Literal')) {
684
+ propValue = String(expr.value || '');
685
+ }
686
+ }
687
+ }
688
+ if (propValue && !isStaticSkipText(propValue)) {
689
+ const propLoc = `${loc}:${propName}`;
690
+ if (!usedLocs.has(propLoc)) {
691
+ usedLocs.add(propLoc);
692
+ candidates.push({
693
+ ...baseMeta,
694
+ loc: propLoc,
695
+ kind: 'text',
696
+ operation: 'extract-prop',
697
+ value: propValue,
698
+ extra: { propName, tag: name },
699
+ confidence: 0.88,
700
+ reason: `component-prop-${propName}`,
701
+ fingerprint: fingerprintCandidate({ tag: name, kind: 'prop', propName }),
702
+ });
703
+ }
704
+ }
705
+ }
706
+ }
707
+ }
708
+
572
709
  const insideForm = parents.some((p) => FORM_CONTAINER_TAGS.has(p));
573
710
 
574
711
  const actionableHref = href || (name === 'Button' ? getJsxAttributeLiteral(node, 'href') : null);
@@ -736,13 +873,15 @@ function analyzeFile({ code, relativeFile, profile, graph, ownerScope, component
736
873
  bindings.get(mapInfo.objectName)?.kind === 'array'
737
874
  ) {
738
875
  const arr = bindings.get(mapInfo.objectName);
739
- const { usage: itemUsage, usesItemAsComponent } = collectItemFieldUsage(mapInfo.callback, mapInfo.itemParam);
876
+ const { usage: itemUsage, usesItemAsComponent, usesDirectItem } = collectItemFieldUsage(mapInfo.callback, mapInfo.itemParam);
740
877
  const objectItems = arr.items.every((item) => item.type === 'object');
878
+ const stringItems = arr.items.every((item) => item.type === 'string');
741
879
  const boundProperties = [...itemUsage.keys()];
742
880
  const convertible =
743
- objectItems &&
744
- boundProperties.length > 0 &&
745
- arr.items.some((item) => boundProperties.some((key) => key in item.value));
881
+ (objectItems &&
882
+ boundProperties.length > 0 &&
883
+ arr.items.some((item) => boundProperties.some((key) => key in item.value))) ||
884
+ (stringItems && usesDirectItem);
746
885
 
747
886
  candidates.push({
748
887
  ...baseMeta,
@@ -751,10 +890,11 @@ function analyzeFile({ code, relativeFile, profile, graph, ownerScope, component
751
890
  value: arr.items,
752
891
  extra: {
753
892
  staticCollection: true,
893
+ isPrimitiveArray: stringItems && usesDirectItem,
754
894
  binding: mapInfo.objectName,
755
895
  itemParam: mapInfo.itemParam,
756
896
  indexParam: mapInfo.indexParam,
757
- itemFields: [...itemUsage.entries()].map(([key, role]) => ({ key, role })),
897
+ itemFields: stringItems ? [] : [...itemUsage.entries()].map(([key, role]) => ({ key, role })),
758
898
  objectItems,
759
899
  hasComponentRef: usesItemAsComponent,
760
900
  },
@@ -766,7 +906,7 @@ function analyzeFile({ code, relativeFile, profile, graph, ownerScope, component
766
906
  reason: usesItemAsComponent
767
907
  ? 'collection-holds-component-ref'
768
908
  : convertible
769
- ? 'static-array-map'
909
+ ? (stringItems ? 'static-primitive-array-map' : 'static-array-map')
770
910
  : 'collection-shape-partial',
771
911
  fingerprint: fingerprintCandidate({ tag: name, kind: 'collection', size: arr.items.length }),
772
912
  });
@@ -19,6 +19,8 @@ const {
19
19
  jsxPreviewAttr,
20
20
  jsxTemplatePathAttr,
21
21
  wrapTextInEditableSpan,
22
+ unwrapExpr,
23
+ extractItemMemberName,
22
24
  jsxStyleAttrs,
23
25
  ensureStyleAttrs,
24
26
  b,
@@ -27,10 +29,14 @@ const { toPosix } = require('./fs-utils.cjs');
27
29
  const { BROAD_CONTENT_CONTAINERS } = require('./fivora-contract.cjs');
28
30
 
29
31
  function findElementByLoc(ast, loc) {
32
+ if (!loc) return null;
33
+ const parts = loc.split(':');
34
+ const targetLoc = parts.length > 4 ? parts.slice(0, 4).join(':') : loc;
30
35
  let found = null;
31
36
  recast.types.visit(ast, {
32
37
  visitJSXElement(pathNode) {
33
- if (locKey(pathNode.node) === loc) {
38
+ const k = locKey(pathNode.node);
39
+ if (k === targetLoc || k === loc) {
34
40
  found = pathNode;
35
41
  return false;
36
42
  }
@@ -243,6 +249,14 @@ function applyTransformToElement(pathNode, transform) {
243
249
  ensurePreviewPath(node, transform.field);
244
250
  return;
245
251
  }
252
+ if (transform.operation === 'extract-prop') {
253
+ const propName = transform.propName || 'title';
254
+ replaceAttrValue(node, propName, siteDataBinding(transform.field.split('.'), transform.fallback, transform.fieldType || 'text'));
255
+ if (!hasJsxAttribute(node, 'data-preview-field-path') && !hasJsxAttribute(node, 'data-preview-list-path')) {
256
+ ensurePreviewPath(node, transform.field);
257
+ }
258
+ return;
259
+ }
246
260
  if (transform.operation === 'wrap-text-span') {
247
261
  wrapLiteralTextChildren(node, transform.field, transform.fallback);
248
262
  return;
@@ -397,7 +411,11 @@ function applyCollectionTransform(ast, transform, isClient = false, isTypeScript
397
411
  );
398
412
  }
399
413
 
400
- markItemFields(callback, { listPath, binding, indexName });
414
+ if (transform.isPrimitiveArray) {
415
+ markPrimitiveItemFields(callback, { listPath, binding, indexName });
416
+ } else {
417
+ markItemFields(callback, { listPath, binding, indexName });
418
+ }
401
419
  markComponentRefItemsStatic(callback, binding);
402
420
 
403
421
  const container = findListContainer(mapCall);
@@ -535,9 +553,66 @@ function markComponentRefItemsStatic(callback, binding) {
535
553
  }
536
554
 
537
555
  function itemMemberName(expr, binding) {
538
- if (!expr || expr.type !== 'MemberExpression' || expr.computed) return null;
539
- if (expr.object?.type !== 'Identifier' || expr.object.name !== binding) return null;
540
- return expr.property?.name || null;
556
+ return extractItemMemberName(expr, binding);
557
+ }
558
+
559
+ function isDirectItemExpr(expr, binding) {
560
+ if (!expr) return false;
561
+ const unwrapped = unwrapExpr(expr);
562
+ return Boolean(
563
+ unwrapped &&
564
+ (unwrapped.type === 'Identifier' || unwrapped.type === 'JSXIdentifier') &&
565
+ unwrapped.name === binding
566
+ );
567
+ }
568
+
569
+ function markPrimitiveItemFields(callback, { listPath, binding, indexName }) {
570
+ recast.types.visit(callback, {
571
+ visitJSXElement(pathNode) {
572
+ const node = pathNode.node;
573
+ const textChild = (node.children || []).find(
574
+ (child) => child.type === 'JSXExpressionContainer' && isDirectItemExpr(child.expression, binding)
575
+ );
576
+ if (textChild && !hasJsxAttribute(node, 'data-preview-field-path')) {
577
+ const tagName = getJsxName(node);
578
+ if (
579
+ node.children.length === 1 &&
580
+ (tagName === 'span' || tagName === 'p' || tagName === 'li') &&
581
+ !hasJsxAttribute(node, 'data-preview-item-path')
582
+ ) {
583
+ node.openingElement.attributes.push(
584
+ jsxTemplatePathAttr('data-preview-field-path', listPath, indexName, '')
585
+ );
586
+ } else {
587
+ wrapPrimitiveItemFieldInSpan(node, textChild, listPath, indexName);
588
+ }
589
+ }
590
+ this.traverse(pathNode);
591
+ },
592
+ });
593
+ }
594
+
595
+ function wrapPrimitiveItemFieldInSpan(node, textChild, listPath, indexName) {
596
+ const nextChildren = [];
597
+ for (const child of node.children || []) {
598
+ if (child === textChild) {
599
+ nextChildren.push(
600
+ b.jsxElement(
601
+ b.jsxOpeningElement(
602
+ b.jsxIdentifier('span'),
603
+ [jsxTemplatePathAttr('data-preview-field-path', listPath, indexName, '')],
604
+ false
605
+ ),
606
+ b.jsxClosingElement(b.jsxIdentifier('span')),
607
+ [child],
608
+ false
609
+ )
610
+ );
611
+ } else {
612
+ nextChildren.push(child);
613
+ }
614
+ }
615
+ node.children = nextChildren;
541
616
  }
542
617
 
543
618
  /** The nearest JSX element that wraps the map expression. */
@@ -642,6 +717,61 @@ function bindArrayDeclaration(ast, mapCallPath, listPath, isClient = false, merg
642
717
  },
643
718
  });
644
719
 
720
+ if (!bound) {
721
+ recast.types.visit(ast, {
722
+ visitImportDeclaration(pathNode) {
723
+ if (bound) return false;
724
+ const node = pathNode.node;
725
+ const spec = (node.specifiers || []).find(
726
+ (s) => (s.local?.name || s.imported?.name) === arrayName
727
+ );
728
+ if (!spec) {
729
+ this.traverse(pathNode);
730
+ return;
731
+ }
732
+
733
+ const defaultName = 'DEFAULT_' + arrayName;
734
+ if (spec.type === 'ImportSpecifier') {
735
+ spec.local = b.identifier(defaultName);
736
+ } else if (spec.type === 'ImportDefaultSpecifier') {
737
+ spec.local = b.identifier(defaultName);
738
+ }
739
+
740
+ const fnPath = findEnclosingFunction(mapCallPath);
741
+ if (fnPath && fnPath.node.body?.type === 'BlockStatement') {
742
+ const body = fnPath.node.body.body;
743
+ const already = body.some(
744
+ (stmt) =>
745
+ recast.print(stmt).code.includes(`const ${arrayName} =`) ||
746
+ recast.print(stmt).code.includes(`const ${arrayName}:`)
747
+ );
748
+ if (!already) {
749
+ const localId = b.identifier(arrayName);
750
+ if (isTypeScript) {
751
+ localId.typeAnnotation = b.tsTypeAnnotation(b.tsArrayType(b.tsAnyKeyword()));
752
+ }
753
+ const localDecl = b.variableDeclaration('const', [
754
+ b.variableDeclarator(
755
+ localId,
756
+ mergeDefaultRefs
757
+ ? mergeDefaultItemRefsBinding(listPath.split('.'), defaultName)
758
+ : siteDataListBinding(listPath.split('.'), b.identifier(defaultName))
759
+ ),
760
+ ]);
761
+ const hookIdx = body.findIndex((stmt) => recast.print(stmt).code.includes('useSiteData'));
762
+ if (hookIdx >= 0) {
763
+ body.splice(hookIdx + 1, 0, localDecl);
764
+ } else {
765
+ body.unshift(localDecl);
766
+ }
767
+ }
768
+ }
769
+ bound = true;
770
+ return false;
771
+ },
772
+ });
773
+ }
774
+
645
775
  return bound;
646
776
  }
647
777
 
@@ -823,13 +953,15 @@ function injectSiteDataHook(ast) {
823
953
  }
824
954
 
825
955
  function resolveSiteDataSpecifier(profile, fromRelativeFile) {
826
- const aliases = profile.aliasMap || {};
956
+ const aliases = (profile && profile.aliasMap) || {};
827
957
  const hasAt = Object.keys(aliases).some((k) => k === '@/*' || k.startsWith('@/'));
828
- const siteDataRel = profile.hasSrc ? 'src/data/site-data.json' : 'data/site-data.json';
829
- if (hasAt && profile.hasSrc) return '@/data/site-data.json';
958
+ const hasSrc = Boolean(profile && profile.hasSrc);
959
+ const siteDataRel = hasSrc ? 'src/data/site-data.json' : 'data/site-data.json';
960
+ if (hasAt && hasSrc) return '@/data/site-data.json';
830
961
 
831
- const fromAbs = path.join(profile.root, fromRelativeFile);
832
- const toAbs = path.join(profile.root, siteDataRel);
962
+ const root = profile && profile.root ? profile.root : process.cwd();
963
+ const fromAbs = path.join(root, fromRelativeFile || 'page.tsx');
964
+ const toAbs = path.join(root, siteDataRel);
833
965
  let relSpec = path.relative(path.dirname(fromAbs), toAbs).replace(/\\/g, '/');
834
966
  if (!relSpec.startsWith('.')) relSpec = './' + relSpec;
835
967
  return relSpec;
@@ -853,6 +985,7 @@ function applyFilePlan(filePlan, profile) {
853
985
  'extract-alt',
854
986
  'extract-placeholder',
855
987
  'extract-text',
988
+ 'extract-prop',
856
989
  'wrap-text-span',
857
990
  'collection-conversion',
858
991
  'style-bind',
@@ -915,6 +1048,8 @@ function applyFilePlan(filePlan, profile) {
915
1048
  healEmptyStateConditionals(ast);
916
1049
  healHiddenPreviewMarkers(ast);
917
1050
  healLegacyProductDetailLinks(ast);
1051
+ healSectionOverflowHidden(ast);
1052
+ healDecorativeOverlays(ast);
918
1053
 
919
1054
  // Page keys are stamped in a separate route-driven pass so App Router and
920
1055
  // Pages Router projects are handled by the same logic.
@@ -1152,11 +1287,98 @@ function instrumentLayoutSource(code, siteDataImport, providerImport = '@deneb-u
1152
1287
 
1153
1288
  ensureProviderInitialData(ast, jsonIdent);
1154
1289
  injectPlatformAdditionalPages(ast, providerImport);
1290
+ injectDualModeTheme(ast, jsonIdent, providerImport);
1155
1291
  sanitizeDuplicateBindings(ast);
1156
1292
  const next = printSource(ast, code);
1157
1293
  return { code: next, updated: next !== code };
1158
1294
  }
1159
1295
 
1296
+ /**
1297
+ * Injects <ThemeStyles /> and <ThemeToggle /> into the layout JSX tree.
1298
+ * ThemeStyles applies light/dark variables and auto-contrast rules.
1299
+ * ThemeToggle provides an out-of-the-box floating theme switcher.
1300
+ */
1301
+ function injectDualModeTheme(ast, jsonIdent, providerImport = '@deneb-ui/ui') {
1302
+ if (!ast) return;
1303
+ let hasThemeStyles = false;
1304
+ let hasThemeToggle = false;
1305
+ recast.types.visit(ast, {
1306
+ visitJSXIdentifier(pathNode) {
1307
+ if (pathNode.node.name === 'ThemeStyles') hasThemeStyles = true;
1308
+ if (pathNode.node.name === 'ThemeToggle') hasThemeToggle = true;
1309
+ this.traverse(pathNode);
1310
+ },
1311
+ });
1312
+
1313
+ const importsToAdd = [];
1314
+ if (!hasThemeStyles) importsToAdd.push('ThemeStyles');
1315
+ if (!hasThemeToggle) importsToAdd.push('ThemeToggle');
1316
+ if (importsToAdd.length === 0) return;
1317
+
1318
+ if (!hasThemeStyles) {
1319
+ let stylesInjected = false;
1320
+ const snippet = parseSource(
1321
+ `<ThemeStyles theme={${jsonIdent}?.template?.structure?.theme || ${jsonIdent}?.theme} enableDualMode />`,
1322
+ 'snippet.tsx'
1323
+ );
1324
+ const stylesEl = snippet.program.body[0].expression;
1325
+
1326
+ // Try <head> first
1327
+ recast.types.visit(ast, {
1328
+ visitJSXElement(pathNode) {
1329
+ if (stylesInjected) return false;
1330
+ const name = getJsxName(pathNode.node);
1331
+ if (name === 'head' || name === 'Head') {
1332
+ pathNode.node.children = [b.jsxText('\n '), stylesEl, ...(pathNode.node.children || [])];
1333
+ stylesInjected = true;
1334
+ return false;
1335
+ }
1336
+ this.traverse(pathNode);
1337
+ },
1338
+ });
1339
+
1340
+ // If no <head>, inject inside SiteDataProvider or <body>
1341
+ if (!stylesInjected) {
1342
+ recast.types.visit(ast, {
1343
+ visitJSXElement(pathNode) {
1344
+ if (stylesInjected) return false;
1345
+ const name = getJsxName(pathNode.node);
1346
+ if (name === 'SiteDataProvider' || name === 'body') {
1347
+ pathNode.node.children = [b.jsxText('\n '), stylesEl, ...(pathNode.node.children || [])];
1348
+ stylesInjected = true;
1349
+ return false;
1350
+ }
1351
+ this.traverse(pathNode);
1352
+ },
1353
+ });
1354
+ }
1355
+ }
1356
+
1357
+ if (!hasThemeToggle) {
1358
+ let toggleInjected = false;
1359
+ const snippet = parseSource(
1360
+ '<ThemeToggle showLabel className="fixed bottom-6 left-6 z-40" />',
1361
+ 'snippet.tsx'
1362
+ );
1363
+ const toggleEl = snippet.program.body[0].expression;
1364
+
1365
+ recast.types.visit(ast, {
1366
+ visitJSXElement(pathNode) {
1367
+ if (toggleInjected) return false;
1368
+ const name = getJsxName(pathNode.node);
1369
+ if (name === 'SiteDataProvider' || name === 'body') {
1370
+ pathNode.node.children = [...(pathNode.node.children || []), b.jsxText('\n '), toggleEl];
1371
+ toggleInjected = true;
1372
+ return false;
1373
+ }
1374
+ this.traverse(pathNode);
1375
+ },
1376
+ });
1377
+ }
1378
+
1379
+ ensureImport(ast, providerImport || '@deneb-ui/ui', importsToAdd);
1380
+ }
1381
+
1160
1382
  /**
1161
1383
  * Injects <PlatformAdditionalPages /> into the layout JSX tree after <main>.
1162
1384
  * This component handles all additionalPages visual-editing markers correctly
@@ -1329,8 +1551,9 @@ function sanitizeContradictoryMarkersInSource(code, relativeFile) {
1329
1551
  const healedEmpty = healEmptyStateConditionals(ast);
1330
1552
  const healedHidden = healHiddenPreviewMarkers(ast);
1331
1553
  const healedOverflow = healSectionOverflowHidden(ast);
1332
- if (cleaned === 0 && healedBroad === 0 && healedEmpty === 0 && healedHidden === 0 && healedOverflow === 0) return { code, updated: false };
1333
- return { code: printSource(ast, code), updated: true, count: cleaned + healedBroad + healedEmpty + healedHidden + healedOverflow };
1554
+ const healedOverlay = healDecorativeOverlays(ast);
1555
+ if (cleaned === 0 && healedBroad === 0 && healedEmpty === 0 && healedHidden === 0 && healedOverflow === 0 && healedOverlay === 0) return { code, updated: false };
1556
+ return { code: printSource(ast, code), updated: true, count: cleaned + healedBroad + healedEmpty + healedHidden + healedOverflow + healedOverlay };
1334
1557
  }
1335
1558
 
1336
1559
  function healBroadContainerMarkers(ast) {
@@ -1622,6 +1845,71 @@ function healSectionOverflowHidden(ast) {
1622
1845
  return healed;
1623
1846
  }
1624
1847
 
1848
+ function healDecorativeOverlays(ast) {
1849
+ let healed = 0;
1850
+ recast.types.visit(ast, {
1851
+ visitJSXElement(pathNode) {
1852
+ const node = pathNode.node;
1853
+ const tag = getJsxName(node);
1854
+ const lower = String(tag || '').toLowerCase();
1855
+ if (lower !== 'div' && lower !== 'span') {
1856
+ this.traverse(pathNode);
1857
+ return;
1858
+ }
1859
+ const hasRealChildren = (node.children || []).some(
1860
+ (c) => c.type === 'JSXElement' || (c.type === 'JSXText' && c.value.trim().length > 0)
1861
+ );
1862
+ if (hasRealChildren) {
1863
+ this.traverse(pathNode);
1864
+ return;
1865
+ }
1866
+ const classAttr = (node.openingElement.attributes || []).find(
1867
+ (a) => a.type === 'JSXAttribute' && a.name && (a.name.name === 'className' || a.name.name === 'class')
1868
+ );
1869
+ if (!classAttr || !classAttr.value) {
1870
+ this.traverse(pathNode);
1871
+ return;
1872
+ }
1873
+ let modified = false;
1874
+ const overlayPattern = /\b(?:bg-gradient-|bg-black\/|bg-white\/|bg-slate-\d+\/|backdrop-blur)/;
1875
+ const isAbsolute = /\babsolute\b/;
1876
+ const hasPointerEvents = /\bpointer-events-(?:none|auto)\b/;
1877
+
1878
+ if (classAttr.value.type === 'StringLiteral' || classAttr.value.type === 'Literal') {
1879
+ const val = String(classAttr.value.value || '');
1880
+ if (isAbsolute.test(val) && overlayPattern.test(val) && !hasPointerEvents.test(val)) {
1881
+ classAttr.value.value = `${val} pointer-events-none`;
1882
+ modified = true;
1883
+ }
1884
+ } else if (classAttr.value.type === 'JSXExpressionContainer') {
1885
+ const expr = classAttr.value.expression;
1886
+ if (expr && (expr.type === 'StringLiteral' || expr.type === 'Literal')) {
1887
+ const val = String(expr.value || '');
1888
+ if (isAbsolute.test(val) && overlayPattern.test(val) && !hasPointerEvents.test(val)) {
1889
+ expr.value = `${val} pointer-events-none`;
1890
+ modified = true;
1891
+ }
1892
+ } else if (expr && expr.type === 'TemplateLiteral') {
1893
+ const fullText = (expr.quasis || []).map((q) => q.value?.raw || '').join(' ');
1894
+ if (isAbsolute.test(fullText) && overlayPattern.test(fullText) && !hasPointerEvents.test(fullText)) {
1895
+ const lastQuasi = expr.quasis[expr.quasis.length - 1];
1896
+ if (lastQuasi && lastQuasi.value) {
1897
+ lastQuasi.value.raw = `${lastQuasi.value.raw} pointer-events-none`;
1898
+ if (lastQuasi.value.cooked) {
1899
+ lastQuasi.value.cooked = `${lastQuasi.value.cooked} pointer-events-none`;
1900
+ }
1901
+ modified = true;
1902
+ }
1903
+ }
1904
+ }
1905
+ }
1906
+ if (modified) healed++;
1907
+ this.traverse(pathNode);
1908
+ },
1909
+ });
1910
+ return healed;
1911
+ }
1912
+
1625
1913
  function healMissingSiteDataHooks(code, filePath = 'file.tsx') {
1626
1914
  if (!code.includes('siteData')) return code;
1627
1915
  try {
@@ -1651,6 +1939,7 @@ module.exports = {
1651
1939
  healEmptyStateConditionals,
1652
1940
  healHiddenPreviewMarkers,
1653
1941
  healSectionOverflowHidden,
1942
+ healDecorativeOverlays,
1654
1943
  healLegacyProductDetailLinks,
1655
1944
  healMissingSiteDataHooks,
1656
1945
  injectSiteDataHook,