meno-core 1.1.4 → 1.1.6

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 (37) hide show
  1. package/dist/chunks/{chunk-ZEDLSHYQ.js → chunk-GHNLTFCN.js} +44 -12
  2. package/dist/chunks/chunk-GHNLTFCN.js.map +7 -0
  3. package/dist/chunks/{chunk-UOF4MCAD.js → chunk-KX2LPIZZ.js} +607 -16
  4. package/dist/chunks/{chunk-UOF4MCAD.js.map → chunk-KX2LPIZZ.js.map} +3 -3
  5. package/dist/chunks/{chunk-FQBIC2OB.js → chunk-YMBUSHII.js} +1 -1
  6. package/dist/chunks/{chunk-FQBIC2OB.js.map → chunk-YMBUSHII.js.map} +1 -1
  7. package/dist/lib/client/index.js +56 -12
  8. package/dist/lib/client/index.js.map +2 -2
  9. package/dist/lib/server/index.js +121 -26
  10. package/dist/lib/server/index.js.map +3 -3
  11. package/dist/lib/shared/index.js +8 -2
  12. package/dist/lib/shared/index.js.map +1 -1
  13. package/lib/client/core/ComponentBuilder.test.ts +137 -0
  14. package/lib/client/core/ComponentBuilder.ts +52 -13
  15. package/lib/client/core/builders/embedBuilder.ts +15 -1
  16. package/lib/client/core/builders/linkNodeBuilder.ts +15 -1
  17. package/lib/client/core/builders/localeListBuilder.ts +15 -1
  18. package/lib/client/templateEngine.test.ts +106 -3
  19. package/lib/client/templateEngine.ts +50 -10
  20. package/lib/server/fileWatcher.test.ts +90 -0
  21. package/lib/server/fileWatcher.ts +36 -0
  22. package/lib/server/services/configService.ts +0 -5
  23. package/lib/server/ssr/htmlGenerator.test.ts +105 -3
  24. package/lib/server/ssr/htmlGenerator.ts +94 -42
  25. package/lib/server/ssr/ssrRenderer.test.ts +100 -0
  26. package/lib/server/ssr/ssrRenderer.ts +59 -14
  27. package/lib/shared/cssGeneration.test.ts +36 -0
  28. package/lib/shared/cssGeneration.ts +6 -1
  29. package/lib/shared/tailwindThemeScale.ts +1 -0
  30. package/lib/shared/templateStyleVars.ts +49 -0
  31. package/lib/shared/types/comment.ts +9 -3
  32. package/lib/shared/utilityClassConfig.ts +469 -10
  33. package/lib/shared/utilityClassMapper.test.ts +200 -2
  34. package/lib/shared/utilityClassMapper.ts +153 -0
  35. package/lib/shared/utilityClassNames.ts +90 -0
  36. package/package.json +1 -1
  37. package/dist/chunks/chunk-ZEDLSHYQ.js.map +0 -7
@@ -46,8 +46,13 @@ import {
46
46
  } from '../../shared/itemTemplateUtils';
47
47
  import { DEFAULT_I18N_CONFIG, isI18nValue, resolveI18nValue } from '../../shared/i18n';
48
48
  import { getChildPath, pathToString } from '../../shared/pathArrayUtils';
49
- import { responsiveStylesToClasses } from '../../shared/utilityClassMapper';
49
+ import {
50
+ responsiveStylesToClasses,
51
+ mergeInstanceClasses,
52
+ stripClassOverriddenStyleProps,
53
+ } from '../../shared/utilityClassMapper';
50
54
  import { getCachedResponsiveScalesConfig } from '../responsiveStyleResolver';
55
+ import { getTemplateVars, TEMPLATE_VARS_KEY } from '../../shared/templateStyleVars';
51
56
  import { UtilityClassCollector } from '../styles/UtilityClassCollector';
52
57
  import { processCMSTemplate, processCMSPropsTemplate, RAW_HTML_PREFIX } from './cmsTemplateProcessor';
53
58
  import type { PrefetchService } from '../services/PrefetchService';
@@ -762,6 +767,14 @@ export class ComponentBuilder {
762
767
  ): Record<string, unknown> {
763
768
  const nodeStyle = node.style || (isComponentNode(node) ? node.props?.style : undefined);
764
769
 
770
+ // Template-bound style values were var-bridged by processStructure (the style carries
771
+ // `var(--m-<bp>-<prop>)`, the concrete value lives on the node) — thread the assignments
772
+ // through props so buildHtmlElement can set them inline via its ref. See templateStyleVars.ts.
773
+ const templateVars = getTemplateVars(node);
774
+ if (templateVars) {
775
+ props = { ...props, [TEMPLATE_VARS_KEY]: templateVars };
776
+ }
777
+
765
778
  if (nodeStyle && typeof nodeStyle === 'object') {
766
779
  // Process item templates in style values (for List context)
767
780
  let processedStyle = nodeStyle as Record<string, unknown>;
@@ -1128,26 +1141,38 @@ export class ComponentBuilder {
1128
1141
  // root. A `<Heading class="text-muted p-[24px]" />` instance carries its
1129
1142
  // override as props.class — the class-based equivalent of the legacy
1130
1143
  // instance style object. SSR applies it onto the component root
1131
- // (ssrRenderer.ts `instanceClassName`); the live client canvas must too, or
1144
+ // (ssrRenderer.ts renderComponent); the live client canvas must too, or
1132
1145
  // instance-level classes silently vanish in Meno Select while rendering
1133
- // correctly in a real Astro build. Last in source order so it wins,
1134
- // matching cx's instance-last precedence. Collect the tokens so their CSS
1135
- // is injected into the canvas's utility sheet (html/link roots also get it
1136
- // via the recursive mergeAttributes; collecting here covers component roots).
1146
+ // correctly in a real Astro build. Conflict-aware, instance-over-root: a
1147
+ // root class targeting the same (breakpoint, CSS property) is dropped, and
1148
+ // the same properties are stripped from the root's style object a
1149
+ // prop-bound root style (maxWidth: {_mapping} "100%") would otherwise
1150
+ // mint a root utility class (max-w-full) that fights the instance's
1151
+ // max-w-[283px] on stylesheet order (equal specificity). Mirrors the
1152
+ // meno-astro runtime's mergeInstanceClasses/inlineStyle pair. Collect the
1153
+ // tokens so their CSS is injected into the canvas's utility sheet
1154
+ // (html/link roots also get it via the recursive mergeAttributes;
1155
+ // collecting here covers component roots).
1137
1156
  const instanceClass = typeof finalProps.class === 'string' ? finalProps.class : '';
1138
1157
  if (instanceClass) {
1139
1158
  const root = processedStructure as ComponentNode & {
1140
1159
  props?: Record<string, unknown>;
1141
1160
  attributes?: Record<string, string | number | boolean>;
1161
+ style?: StyleValue;
1142
1162
  };
1163
+ if (root.style) {
1164
+ const stripped = stripClassOverriddenStyleProps(root.style, instanceClass);
1165
+ if (stripped) root.style = stripped;
1166
+ else delete root.style;
1167
+ }
1143
1168
  if (isHtmlNode(root) || isLinkNode(root)) {
1144
1169
  if (!root.attributes) root.attributes = {};
1145
- const existing = (root.attributes.class || '') as string;
1146
- root.attributes.class = existing ? `${existing} ${instanceClass}` : instanceClass;
1170
+ const own = ((root.attributes.class || '') as string).split(/\s+/).filter(Boolean);
1171
+ root.attributes.class = mergeInstanceClasses(own, instanceClass).join(' ');
1147
1172
  } else {
1148
1173
  if (!root.props) root.props = {};
1149
- const existing = (root.props.className || '') as string;
1150
- root.props.className = existing ? `${existing} ${instanceClass}` : instanceClass;
1174
+ const own = ((root.props.className || '') as string).split(/\s+/).filter(Boolean);
1175
+ root.props.className = mergeInstanceClasses(own, instanceClass).join(' ');
1151
1176
  }
1152
1177
  const tokens = instanceClass.split(/\s+/).filter(Boolean);
1153
1178
  if (tokens.length > 0) UtilityClassCollector.collect(tokens);
@@ -1230,19 +1255,24 @@ export class ComponentBuilder {
1230
1255
  const { __componentProps, ...customPropsForDOM } = customProps || {};
1231
1256
  const isComponentRoot = !!(customProps && '__componentProps' in customProps);
1232
1257
 
1258
+ // Pull the var-bridge assignments off props (threaded by applyStyleClasses) — they're
1259
+ // applied to the DOM element in the ref below, never spread as a React prop.
1260
+ const { [TEMPLATE_VARS_KEY]: templateVarsRaw, ...propsSansTemplateVars } = props;
1261
+ const templateVars = templateVarsRaw as Record<string, string> | undefined;
1262
+
1233
1263
  // Strip responsive style objects (e.g. { base: { objectFit: 'cover' } }) from inline style.
1234
1264
  // These produce invalid React inline styles — utility classes handle all styling.
1235
1265
  // Flat style objects are kept as inline style for backward compatibility.
1236
1266
  // The style prop is preserved in applyStyleClasses for component instance style merging
1237
1267
  // (buildCustomComponent reads finalProps.style via mergeNodeStyles).
1238
- let propsForElement = props;
1239
- const nodeStyle = props.style;
1268
+ let propsForElement = propsSansTemplateVars;
1269
+ const nodeStyle = propsSansTemplateVars.style;
1240
1270
  if (
1241
1271
  nodeStyle &&
1242
1272
  typeof nodeStyle === 'object' &&
1243
1273
  ('base' in (nodeStyle as object) || 'tablet' in (nodeStyle as object) || 'mobile' in (nodeStyle as object))
1244
1274
  ) {
1245
- const { style: _, ...rest } = props;
1275
+ const { style: _, ...rest } = propsSansTemplateVars;
1246
1276
  propsForElement = rest;
1247
1277
  }
1248
1278
 
@@ -1311,6 +1341,15 @@ export class ComponentBuilder {
1311
1341
  el.style.setProperty(varName, value);
1312
1342
  }
1313
1343
  }
1344
+
1345
+ // Apply var-bridge assignments for template-bound style values — the element's
1346
+ // utility classes read var(--m-<bp>-<prop>); the concrete value is set here,
1347
+ // mirroring the meno-astro runtime's inline style. See templateStyleVars.ts.
1348
+ if (templateVars) {
1349
+ for (const [varName, value] of Object.entries(templateVars)) {
1350
+ el.style.setProperty(varName, value);
1351
+ }
1352
+ }
1314
1353
  }
1315
1354
 
1316
1355
  const componentProps =
@@ -18,6 +18,7 @@ import {
18
18
  resolveExtractedMappings,
19
19
  } from '../../../shared/interactiveStyleMappings';
20
20
  import { UtilityClassCollector } from '../../styles/UtilityClassCollector';
21
+ import { getTemplateVars } from '../../../shared/templateStyleVars';
21
22
  import DOMPurify from 'isomorphic-dompurify';
22
23
  import { inlineSvgStyleRules } from '../../../shared/inlineSvgStyleRules';
23
24
  import type { ElementRegistry } from '../../elementRegistry';
@@ -291,6 +292,14 @@ export function buildEmbed(node: EmbedNode, ctx: BuilderContext, deps: EmbedBuil
291
292
  el.style.setProperty(varName, value);
292
293
  }
293
294
  }
295
+ // Var-bridge for template-bound style values (see templateStyleVars.ts) —
296
+ // the utility classes read var(--m-<bp>-<prop>); set the concrete value inline.
297
+ const templateVars = getTemplateVars(node);
298
+ if (templateVars) {
299
+ for (const [varName, value] of Object.entries(templateVars)) {
300
+ el.style.setProperty(varName, value);
301
+ }
302
+ }
294
303
  }
295
304
  },
296
305
  dangerouslySetInnerHTML: { __html: sanitizedHtml },
@@ -399,7 +408,12 @@ export function buildEmbed(node: EmbedNode, ctx: BuilderContext, deps: EmbedBuil
399
408
  (extractedAttributes as Record<string, unknown>).className ??
400
409
  '') as string;
401
410
  if (attrClass) {
402
- classNames.push(...attrClass.split(/\s+/));
411
+ // Class-string styling: utility tokens stored in attributes.class (e.g. h-[30px])
412
+ // must inject their CSS into the live canvas, same as ComponentBuilder does for
413
+ // element nodes. collect() skips foreign tokens that generate no rule.
414
+ const classTokens = attrClass.split(/\s+/).filter(Boolean);
415
+ UtilityClassCollector.collect(classTokens);
416
+ classNames.push(...classTokens);
403
417
  }
404
418
  delete (extractedAttributes as Record<string, unknown>).class;
405
419
  delete (extractedAttributes as Record<string, unknown>).className;
@@ -19,6 +19,7 @@ import {
19
19
  } from '../../../shared/interactiveStyleMappings';
20
20
  import { processItemPropsTemplate, type ValueResolver } from '../../../shared/itemTemplateUtils';
21
21
  import { UtilityClassCollector } from '../../styles/UtilityClassCollector';
22
+ import { getTemplateVars } from '../../../shared/templateStyleVars';
22
23
  import { resolveI18nValue, DEFAULT_I18N_CONFIG } from '../../../shared/i18n';
23
24
  import { isCurrentLink } from '../../../shared/linkUtils';
24
25
  import type { ElementRegistry } from '../../elementRegistry';
@@ -87,6 +88,14 @@ export function buildLinkNode(
87
88
  el.style.setProperty(varName, value);
88
89
  }
89
90
  }
91
+ // Var-bridge for template-bound style values (see templateStyleVars.ts) —
92
+ // the utility classes read var(--m-<bp>-<prop>); set the concrete value inline.
93
+ const templateVars = getTemplateVars(node);
94
+ if (templateVars) {
95
+ for (const [varName, value] of Object.entries(templateVars)) {
96
+ el.style.setProperty(varName, value);
97
+ }
98
+ }
90
99
  }
91
100
  },
92
101
  };
@@ -194,7 +203,12 @@ export function buildLinkNode(
194
203
  (extractedAttributes as Record<string, unknown>).className ??
195
204
  '') as string;
196
205
  if (attrClass) {
197
- classNames.push(...attrClass.split(/\s+/));
206
+ // Class-string styling: utility tokens stored in attributes.class (e.g. h-[30px])
207
+ // must inject their CSS into the live canvas, same as ComponentBuilder does for
208
+ // element nodes. collect() skips foreign tokens that generate no rule.
209
+ const classTokens = attrClass.split(/\s+/).filter(Boolean);
210
+ UtilityClassCollector.collect(classTokens);
211
+ classNames.push(...classTokens);
198
212
  }
199
213
  delete (extractedAttributes as Record<string, unknown>).class;
200
214
  delete (extractedAttributes as Record<string, unknown>).className;
@@ -18,6 +18,7 @@ import {
18
18
  resolveExtractedMappings,
19
19
  } from '../../../shared/interactiveStyleMappings';
20
20
  import { UtilityClassCollector } from '../../styles/UtilityClassCollector';
21
+ import { getTemplateVars } from '../../../shared/templateStyleVars';
21
22
  import type { ElementRegistry } from '../../elementRegistry';
22
23
  import type { BuilderContext } from './types';
23
24
 
@@ -68,6 +69,14 @@ export function buildLocaleList(node: LocaleListNode, ctx: BuilderContext, deps:
68
69
  el.style.setProperty(varName, value);
69
70
  }
70
71
  }
72
+ // Var-bridge for template-bound style values (see templateStyleVars.ts) —
73
+ // the utility classes read var(--m-<bp>-<prop>); set the concrete value inline.
74
+ const templateVars = getTemplateVars(node);
75
+ if (templateVars) {
76
+ for (const [varName, value] of Object.entries(templateVars)) {
77
+ el.style.setProperty(varName, value);
78
+ }
79
+ }
71
80
  }
72
81
  },
73
82
  };
@@ -160,7 +169,12 @@ export function buildLocaleList(node: LocaleListNode, ctx: BuilderContext, deps:
160
169
  (extractedAttributes as Record<string, unknown>).className ??
161
170
  '') as string;
162
171
  if (attrClass) {
163
- classNames.push(...attrClass.split(/\s+/));
172
+ // Class-string styling: utility tokens stored in attributes.class (e.g. h-[30px])
173
+ // must inject their CSS into the live canvas, same as ComponentBuilder does for
174
+ // element nodes. collect() skips foreign tokens that generate no rule.
175
+ const classTokens = attrClass.split(/\s+/).filter(Boolean);
176
+ UtilityClassCollector.collect(classTokens);
177
+ classNames.push(...classTokens);
164
178
  }
165
179
  delete (extractedAttributes as Record<string, unknown>).class;
166
180
  delete (extractedAttributes as Record<string, unknown>).className;
@@ -605,8 +605,14 @@ describe('Template Engine - processStructure', () => {
605
605
  const node = result as ComponentNode;
606
606
  const baseStyle = (node.style as any)?.base;
607
607
  expect(baseStyle).toBeDefined();
608
- expect(baseStyle?.opacity).toBe('0');
609
- expect(baseStyle?.fontSize).toBe('16px');
608
+ // Template-bound values are var-bridged (templateStyleVars.ts): the style reads a
609
+ // CSS variable (so the class is never auto-responsive-scaled, matching the real
610
+ // Astro build) and the concrete resolved value rides on __templateVars for the
611
+ // element's inline style.
612
+ expect(baseStyle?.opacity).toBe('var(--m-base-opacity)');
613
+ expect(baseStyle?.fontSize).toBe('var(--m-base-font-size)');
614
+ const vars = (node as any).__templateVars;
615
+ expect(vars).toEqual({ '--m-base-opacity': '0', '--m-base-font-size': '16px' });
610
616
  });
611
617
 
612
618
  test('defers full templates containing dotted terms (e.g. decimals) to item processing', () => {
@@ -647,12 +653,109 @@ describe('Template Engine - processStructure', () => {
647
653
  const result = processStructure(structure, createContext({}));
648
654
  const node = result as ComponentNode;
649
655
  const baseStyle = (node.style as any)?.base;
650
- expect(baseStyle?.display).toBe('none');
656
+ // Var-bridged (see templateStyleVars.ts) — resolved value lands on __templateVars.
657
+ expect(baseStyle?.display).toBe('var(--m-base-display)');
658
+ expect((node as any).__templateVars).toEqual({ '--m-base-display': 'none' });
651
659
 
652
660
  resetGlobalTemplateContext();
653
661
  });
654
662
  });
655
663
 
664
+ describe('Template style var-bridge (templateStyleVars.ts)', () => {
665
+ test('bridges per breakpoint: tablet/mobile templates get breakpoint-scoped var names', () => {
666
+ const structure: ComponentNode = {
667
+ type: 'node',
668
+ tag: 'div',
669
+ style: {
670
+ base: { gap: '{{gap}}px' },
671
+ tablet: { gap: '{{gapTablet}}px' },
672
+ },
673
+ };
674
+ const result = processStructure(structure, createContext({ gap: 40, gapTablet: 24 })) as ComponentNode;
675
+ expect((result.style as any).base.gap).toBe('var(--m-base-gap)');
676
+ expect((result.style as any).tablet.gap).toBe('var(--m-tablet-gap)');
677
+ expect((result as any).__templateVars).toEqual({
678
+ '--m-base-gap': '40px',
679
+ '--m-tablet-gap': '24px',
680
+ });
681
+ });
682
+
683
+ test('bridges flat (non-responsive) style objects as base', () => {
684
+ const structure: ComponentNode = {
685
+ type: 'node',
686
+ tag: 'div',
687
+ style: { maxWidth: '{{maxWidth}}' } as any,
688
+ };
689
+ const result = processStructure(structure, createContext({ maxWidth: '283px' })) as ComponentNode;
690
+ expect((result.style as any).maxWidth).toBe('var(--m-base-max-width)');
691
+ expect((result as any).__templateVars).toEqual({ '--m-base-max-width': '283px' });
692
+ });
693
+
694
+ test('does NOT bridge _mapping values — they resolve to static, auto-scalable classes', () => {
695
+ const structure: ComponentNode = {
696
+ type: 'node',
697
+ tag: 'div',
698
+ style: {
699
+ base: { padding: { _mapping: true, prop: 'size', values: { small: '8px', large: '24px' } } },
700
+ } as any,
701
+ };
702
+ const result = processStructure(structure, createContext({ size: 'large' })) as ComponentNode;
703
+ expect((result.style as any).base.padding).toBe('24px');
704
+ expect((result as any).__templateVars).toBeUndefined();
705
+ });
706
+
707
+ test('undefined prop still bridges — the empty substitution yields an invalid var value, so the property is unset (matching Astro)', () => {
708
+ const structure: ComponentNode = {
709
+ type: 'node',
710
+ tag: 'div',
711
+ style: { base: { gap: '{{gap}}px' } },
712
+ };
713
+ const result = processStructure(structure, createContext({})) as ComponentNode;
714
+ expect((result.style as any).base.gap).toBe('var(--m-base-gap)');
715
+ expect((result as any).__templateVars).toEqual({ '--m-base-gap': 'px' });
716
+ });
717
+
718
+ test('does NOT bridge a template preserved verbatim by processCodeTemplates', () => {
719
+ // When the template survives resolution unchanged (here: the DoS length guard
720
+ // returns the original {{...}} match), the resolved value still contains a
721
+ // template and the bridge skips it — legacy pass-through.
722
+ const tooLong = `{{${'a'.repeat(501)}}}px`;
723
+ const structure: ComponentNode = {
724
+ type: 'node',
725
+ tag: 'div',
726
+ style: { base: { gap: tooLong } },
727
+ };
728
+ const result = processStructure(structure, createContext({ gap: 40 })) as ComponentNode;
729
+ expect((result.style as any).base.gap).toBe(tooLong);
730
+ expect((result as any).__templateVars).toBeUndefined();
731
+ });
732
+
733
+ test('does NOT bridge item templates ({{item.x}} resolves later during rendering)', () => {
734
+ const structure: ComponentNode = {
735
+ type: 'node',
736
+ tag: 'div',
737
+ style: { base: { color: '{{item.color}}' } },
738
+ };
739
+ const result = processStructure(structure, createContext({})) as ComponentNode;
740
+ expect((result.style as any).base.color).toBe('{{item.color}}');
741
+ expect((result as any).__templateVars).toBeUndefined();
742
+ });
743
+
744
+ test('does NOT bridge component INSTANCE node styles (they merge into the child root)', () => {
745
+ const structure: ComponentNode = {
746
+ type: 'component',
747
+ component: 'Card',
748
+ props: {},
749
+ style: { base: { gap: '{{gap}}px' } },
750
+ } as ComponentNode;
751
+ const result = processStructure(structure, createContext({ gap: 40 })) as ComponentNode;
752
+ // Instance styles keep the concrete resolved value — the child structure root
753
+ // renders them, and the vars would otherwise be applied to no element.
754
+ expect((result.style as any).base.gap).toBe('40px');
755
+ expect((result as any).__templateVars).toBeUndefined();
756
+ });
757
+ });
758
+
656
759
  describe('Edge cases and error handling', () => {
657
760
  test('should return null for null structure', () => {
658
761
  const result = processStructure(null, createContext({}));
@@ -33,6 +33,7 @@ import {
33
33
  import { applyStylesToNode } from '../shared/styleNodeUtils';
34
34
  import { isRichTextMarker, richTextMarkerToHtml } from '../shared/propResolver';
35
35
  import { hasItemTemplates } from '../shared/itemTemplateUtils';
36
+ import { templateVarName, TEMPLATE_VARS_KEY } from '../shared/templateStyleVars';
36
37
  import { safeEvaluate } from '../shared/expressionEvaluator';
37
38
  import { RAW_HTML_PREFIX } from '../shared/constants';
38
39
  import { getGlobalTemplateContext } from '../shared/globalTemplateContext';
@@ -274,6 +275,21 @@ function processStyleValue(
274
275
  return undefined;
275
276
  }
276
277
 
278
+ /**
279
+ * True when an ORIGINAL style value was a prop-template string that processStyleValue
280
+ * successfully resolved — the case the var-bridge applies to (see templateStyleVars.ts).
281
+ * Item templates ({{item.x}}) resolve later during rendering, and an unresolved template
282
+ * (undefined prop) keeps its legacy pass-through behavior, so neither bridges.
283
+ */
284
+ function isBridgeableTemplateValue(original: unknown, resolved: string | number): boolean {
285
+ return (
286
+ typeof original === 'string' &&
287
+ hasTemplates(original) &&
288
+ !hasItemTemplates(original) &&
289
+ !(typeof resolved === 'string' && resolved.includes('{{'))
290
+ );
291
+ }
292
+
277
293
  /**
278
294
  * Build evaluation context for template processing.
279
295
  * Merges global context, props, componentDef, and optionally itemContext.
@@ -670,6 +686,12 @@ export function processStructure(
670
686
 
671
687
  // First pass: process all keys and resolve style mappings
672
688
  let resolvedStyle: StyleValue | null = null;
689
+ // Var-bridge assignments for template-resolved style values (see templateStyleVars.ts).
690
+ // Component INSTANCE nodes are exempt: their style merges into the child structure
691
+ // root (mergeNodeStyles / SSR renderComponent), which renders a different element
692
+ // than the one these vars would be applied to.
693
+ const templateVars: Record<string, string> = {};
694
+ const bridgeTemplates = preservedType !== NODE_TYPE.COMPONENT;
673
695
 
674
696
  for (const [key, value] of Object.entries(structure)) {
675
697
  try {
@@ -777,6 +799,22 @@ export function processStructure(
777
799
  if (processedStyle && typeof processedStyle === 'object' && !Array.isArray(processedStyle)) {
778
800
  // Check if it's a responsive style object
779
801
  const styleEvalContext = buildEvalContext(context, false);
802
+ // Land one resolved declaration, var-bridging template-resolved values: the
803
+ // style gets `var(--m-<bp>-<prop>)` and the concrete value is stashed for the
804
+ // element's inline style — mirroring the meno-astro runtime, so a prop-bound
805
+ // value renders constant across breakpoints (no auto-responsive scaling) in
806
+ // Fast Design Mode exactly as it does in a real build. See templateStyleVars.ts.
807
+ const landStyleValue = (target: StyleObject, bp: string, styleKey: string, styleValue: unknown): void => {
808
+ const processedValue = processStyleValue(styleValue, styleEvalContext);
809
+ if (processedValue === undefined) return;
810
+ if (bridgeTemplates && isBridgeableTemplateValue(styleValue, processedValue)) {
811
+ const varName = templateVarName(bp, styleKey);
812
+ templateVars[varName] = String(processedValue);
813
+ target[styleKey] = `var(${varName})`;
814
+ } else {
815
+ target[styleKey] = processedValue;
816
+ }
817
+ };
780
818
  if (isResponsiveStyle(processedStyle as StyleValue)) {
781
819
  // Preserve responsive styles (for both SSR and editor)
782
820
  // responsiveStylesToClasses will generate prefixed classes (tablet:, mobile:)
@@ -784,25 +822,21 @@ export function processStructure(
784
822
  const resolvedResponsive: ResponsiveStyleObject = {};
785
823
  for (const [bkeyName, bkeyValue] of Object.entries(processedStyle)) {
786
824
  if (typeof bkeyValue === 'object' && bkeyValue !== null) {
787
- resolvedResponsive[bkeyName] = {};
825
+ const bpTarget: StyleObject = {};
826
+ resolvedResponsive[bkeyName] = bpTarget;
788
827
  for (const [styleKey, styleValue] of Object.entries(bkeyValue)) {
789
- const processedValue = processStyleValue(styleValue, styleEvalContext);
790
- if (processedValue !== undefined) {
791
- resolvedResponsive[bkeyName]![styleKey] = processedValue;
792
- }
828
+ landStyleValue(bpTarget, bkeyName, styleKey, styleValue);
793
829
  }
794
830
  }
795
831
  }
796
832
  resolvedStyle = resolvedResponsive;
797
833
  } else {
798
834
  // Legacy flat style object - resolve mappings and evaluate templates
799
- resolvedStyle = {};
835
+ const flatTarget: StyleObject = {};
800
836
  for (const [styleKey, styleValue] of Object.entries(processedStyle)) {
801
- const processedValue = processStyleValue(styleValue, styleEvalContext);
802
- if (processedValue !== undefined) {
803
- resolvedStyle[styleKey] = processedValue;
804
- }
837
+ landStyleValue(flatTarget, 'base', styleKey, styleValue);
805
838
  }
839
+ resolvedStyle = flatTarget;
806
840
  }
807
841
  }
808
842
  } else if (key === 'props' && typeof value === 'object' && value !== null) {
@@ -987,6 +1021,12 @@ export function processStructure(
987
1021
  }
988
1022
  }
989
1023
 
1024
+ // Attach the var-bridge assignments (ephemeral, render-only) so the element
1025
+ // renderers can set them inline — client builders via ref, SSR via style="…".
1026
+ if (Object.keys(templateVars).length > 0) {
1027
+ (processed as unknown as Record<string, unknown>)[TEMPLATE_VARS_KEY] = templateVars;
1028
+ }
1029
+
990
1030
  return processed;
991
1031
  }
992
1032
 
@@ -243,6 +243,96 @@ describe('FileWatcher astro CMS template pages', () => {
243
243
  });
244
244
  });
245
245
 
246
+ describe('FileWatcher theme.css', () => {
247
+ let projectRoot: string;
248
+ let stylesDir: string;
249
+
250
+ beforeEach(() => {
251
+ projectRoot = join(tmpdir(), `meno-fw-theme-${Date.now()}-${Math.random().toString(36).slice(2)}`);
252
+ stylesDir = join(projectRoot, 'src', 'styles');
253
+ mkdirSync(join(projectRoot, 'src'), { recursive: true });
254
+ });
255
+
256
+ afterEach(() => {
257
+ try {
258
+ rmSync(projectRoot, { recursive: true, force: true });
259
+ } catch {
260
+ /* ignore */
261
+ }
262
+ });
263
+
264
+ // theme.css holds BOTH token halves (colors + variables), so one edit must
265
+ // invalidate both service caches — an external write (e.g. an AI tool adding
266
+ // a token) previously stayed invisible until a dev-server restart.
267
+ test('fires onColorsChange AND onVariablesChange when theme.css is written', async () => {
268
+ let colorChanges = 0;
269
+ let variableChanges = 0;
270
+ const watcher = new FileWatcher({
271
+ onColorsChange: async () => {
272
+ colorChanges++;
273
+ },
274
+ onVariablesChange: async () => {
275
+ variableChanges++;
276
+ },
277
+ });
278
+
279
+ mkdirSync(stylesDir, { recursive: true });
280
+ writeFileSync(join(stylesDir, 'theme.css'), ':root { --bg: #fff; }');
281
+ watcher.watchThemeCss(stylesDir);
282
+ await settle(50);
283
+
284
+ writeFileSync(join(stylesDir, 'theme.css'), ':root { --bg: #fff; --accent: #f00; }');
285
+ await waitFor(() => colorChanges >= 1 && variableChanges >= 1);
286
+
287
+ watcher.stopAll();
288
+ expect(colorChanges).toBeGreaterThanOrEqual(1);
289
+ expect(variableChanges).toBeGreaterThanOrEqual(1);
290
+ });
291
+
292
+ // src/styles may not exist at startup (blank project — theme.css lands later
293
+ // via the JSON migration or an external tool), so the watcher must defer-attach.
294
+ test('fires after src/styles is created post-startup', async () => {
295
+ let colorChanges = 0;
296
+ const watcher = new FileWatcher({
297
+ onColorsChange: async () => {
298
+ colorChanges++;
299
+ },
300
+ });
301
+
302
+ watcher.watchThemeCss(stylesDir);
303
+ await settle(50);
304
+ mkdirSync(stylesDir, { recursive: true });
305
+ await settle(100);
306
+ writeFileSync(join(stylesDir, 'theme.css'), ':root { --bg: #fff; }');
307
+ await waitFor(() => colorChanges >= 1);
308
+
309
+ watcher.stopAll();
310
+ expect(colorChanges).toBeGreaterThanOrEqual(1);
311
+ });
312
+
313
+ test('ignores writes to other files in src/styles', async () => {
314
+ let fired = 0;
315
+ const watcher = new FileWatcher({
316
+ onColorsChange: async () => {
317
+ fired++;
318
+ },
319
+ onVariablesChange: async () => {
320
+ fired++;
321
+ },
322
+ });
323
+
324
+ mkdirSync(stylesDir, { recursive: true });
325
+ watcher.watchThemeCss(stylesDir);
326
+ await settle(50);
327
+
328
+ writeFileSync(join(stylesDir, 'global.css'), 'body { margin: 0; }');
329
+ await settle(200);
330
+
331
+ watcher.stopAll();
332
+ expect(fired).toBe(0);
333
+ });
334
+ });
335
+
246
336
  describe('FileWatcher project.config.json', () => {
247
337
  let projectRoot: string;
248
338
  let prevRoot: string;
@@ -152,6 +152,7 @@ export class FileWatcher {
152
152
  private templatesWatcher: FSWatcher | null = null;
153
153
  private colorsWatcher: FSWatcher | null = null;
154
154
  private variablesWatcher: FSWatcher | null = null;
155
+ private themeCssWatcher: FSWatcher | null = null;
155
156
  private enumsWatcher: FSWatcher | null = null;
156
157
  private cmsWatcher: FSWatcher | null = null;
157
158
  private imagesWatcher: FSWatcher | null = null;
@@ -261,6 +262,35 @@ export class FileWatcher {
261
262
  });
262
263
  }
263
264
 
265
+ /**
266
+ * Start watching `src/styles/theme.css` — the token source of truth for
267
+ * astro-format projects, where colors AND variables share the one file
268
+ * (colors.json / variables.json were migrated away, so the two watchers
269
+ * above never fire there). An external edit (e.g. an AI tool adding tokens)
270
+ * must invalidate both service caches and refresh connected clients, or the
271
+ * new tokens only appear after a dev-server restart. Deferred attach:
272
+ * `src/styles` may not exist yet (theme.css lands later via migration or an
273
+ * external tool). No-op in legacy JSON projects (no `src/`).
274
+ */
275
+ watchThemeCss(dirPath: string = join(getProjectRoot(), 'src', 'styles')): void {
276
+ attachWhenDirExists(
277
+ dirPath,
278
+ () =>
279
+ watch(dirPath, { recursive: false }, async (_event, filename) => {
280
+ if (filename !== 'theme.css') return;
281
+ if (this.callbacks.onColorsChange) {
282
+ await this.callbacks.onColorsChange();
283
+ }
284
+ if (this.callbacks.onVariablesChange) {
285
+ await this.callbacks.onVariablesChange();
286
+ }
287
+ }),
288
+ (w) => {
289
+ this.themeCssWatcher = w;
290
+ },
291
+ );
292
+ }
293
+
264
294
  /**
265
295
  * Start watching enums.json file
266
296
  */
@@ -425,6 +455,7 @@ export class FileWatcher {
425
455
  this.watchTemplates();
426
456
  this.watchColors();
427
457
  this.watchVariables();
458
+ this.watchThemeCss();
428
459
  this.watchEnums();
429
460
  this.watchCMS();
430
461
  this.watchImages();
@@ -465,6 +496,11 @@ export class FileWatcher {
465
496
  this.variablesWatcher = null;
466
497
  }
467
498
 
499
+ if (this.themeCssWatcher) {
500
+ this.themeCssWatcher.close();
501
+ this.themeCssWatcher = null;
502
+ }
503
+
468
504
  if (this.enumsWatcher) {
469
505
  this.enumsWatcher.close();
470
506
  this.enumsWatcher = null;
@@ -66,7 +66,6 @@ interface RawProjectConfig {
66
66
  imageFormat?: ImageFormat;
67
67
  remConversion?: Partial<RemConversionConfig>;
68
68
  customCode?: CustomCodeConfig;
69
- showMenoBadge?: boolean;
70
69
  }
71
70
 
72
71
  /**
@@ -343,10 +342,6 @@ export class ConfigService {
343
342
  return this.config.customCode;
344
343
  }
345
344
 
346
- getShowMenoBadge(): boolean {
347
- return this.config?.showMenoBadge === true;
348
- }
349
-
350
345
  getImageFormat(): ImageFormat {
351
346
  if (this.config?.imageFormat === 'avif') return 'avif';
352
347
  return 'webp';