@wix/zero-config-implementation 1.98.0 → 1.100.0

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
@@ -4,7 +4,7 @@
4
4
  "registry": "https://registry.npmjs.org/",
5
5
  "access": "public"
6
6
  },
7
- "version": "1.98.0",
7
+ "version": "1.100.0",
8
8
  "description": "Core library for extracting component manifests from JS and CSS files",
9
9
  "type": "module",
10
10
  "main": "dist/index.js",
@@ -106,5 +106,5 @@
106
106
  ]
107
107
  }
108
108
  },
109
- "falconPackageHash": "741408430f509ba8b4ac3b6f2ff22c4fae1bf06d9f8b98e2a9e8e079"
109
+ "falconPackageHash": "80ef17ffc1549b6c4d3f51396955cb0517fe8eceb7b20eed65fc7e7d"
110
110
  }
@@ -94,6 +94,16 @@ const isDOMTag = (type: unknown): type is string => typeof type === 'string' &&
94
94
 
95
95
  type ElementCreator = (type: unknown, props: unknown, ...rest: unknown[]) => ReactElement
96
96
 
97
+ /**
98
+ * Whether a child slot rendered nothing - what a node prop mocked to null leaves
99
+ * behind. `false` is excluded: it is what an unrendered conditional leaves, and a
100
+ * conditional on a mocked boolean would make the slot depend on the faker sequence.
101
+ */
102
+ function isEmptySlot(child: unknown): boolean {
103
+ if (Array.isArray(child)) return child.some(isEmptySlot)
104
+ return child === null || child === undefined
105
+ }
106
+
97
107
  /**
98
108
  * Creates an intercepted version of an element creation function.
99
109
  * Works with both createElement (children as rest params) and jsx/jsxs (children in props).
@@ -103,14 +113,19 @@ function createInterceptor(
103
113
  listeners: CreateElementListener[],
104
114
  getNextId: () => string,
105
115
  store: ExtractorStore,
116
+ /** jsx/jsxs/jsxDEV take children in props and a key as the third argument. */
117
+ childrenInProps: boolean,
106
118
  ): ElementCreator {
107
119
  return (type, elementProps, ...rest) => {
108
120
  const props = (elementProps ?? {}) as Record<string, unknown>
109
121
  const isDomElement = isDOMTag(type)
110
122
  const traceId = isDomElement ? getNextId() : undefined
111
123
 
112
- // Children location differs: createElement uses rest params, jsx/jsxs uses props.children
113
- const children = rest.length > 0 ? rest : props.children ? [props.children] : []
124
+ // Children location differs: createElement takes rest params but also accepts them
125
+ // in props, jsx/jsxs only in props. Keyed on presence, not truthiness, so a slot
126
+ // that rendered nothing is still visible.
127
+ const children = childrenInProps || rest.length === 0 ? ('children' in props ? [props.children] : []) : rest
128
+ const hasEmptySlot = children.some(isEmptySlot)
114
129
 
115
130
  const event: CreateElementEvent = {
116
131
  type,
@@ -119,6 +134,7 @@ function createInterceptor(
119
134
  props: { ...props, children },
120
135
  traceId,
121
136
  children: children as unknown[],
137
+ hasEmptySlot,
122
138
  store,
123
139
  }
124
140
 
@@ -322,10 +338,11 @@ export function renderWithExtractors(
322
338
  listeners,
323
339
  getNextId,
324
340
  store,
341
+ false,
325
342
  )
326
- const interceptedJsx = createInterceptor(originalJsx as ElementCreator, listeners, getNextId, store)
327
- const interceptedJsxs = createInterceptor(originalJsxs as ElementCreator, listeners, getNextId, store)
328
- const interceptedJsxDEV = createInterceptor(originalJsxDEV as ElementCreator, listeners, getNextId, store)
343
+ const interceptedJsx = createInterceptor(originalJsx as ElementCreator, listeners, getNextId, store, true)
344
+ const interceptedJsxs = createInterceptor(originalJsxs as ElementCreator, listeners, getNextId, store, true)
345
+ const interceptedJsxDEV = createInterceptor(originalJsxDEV as ElementCreator, listeners, getNextId, store, true)
329
346
 
330
347
  // The shim reads globalThis[REACT_KEY] on every hook call, so pointing it at
331
348
  // userReact ensures ESM hooks use the same dispatcher that SSR drives.
@@ -131,6 +131,41 @@ describe('resolveLonghand', () => {
131
131
  expect(resolveLonghand('paddingTop', new Map())).toBeUndefined()
132
132
  })
133
133
 
134
+ describe('margin family', () => {
135
+ it('marginTop falls back through marginBlockStart → marginBlock → margin', () => {
136
+ expect(resolveLonghand('marginTop', new Map([['marginBlockStart', '1px']]))).toBe('1px')
137
+ expect(resolveLonghand('marginTop', new Map([['marginBlock', '4px 8px']]))).toBe('4px')
138
+ expect(resolveLonghand('marginTop', new Map([['margin', '1rem 2rem']]))).toBe('1rem')
139
+ })
140
+
141
+ it('marginBottom falls back through marginBlockEnd → marginBlock → margin', () => {
142
+ expect(resolveLonghand('marginBottom', new Map([['marginBlockEnd', '2px']]))).toBe('2px')
143
+ expect(resolveLonghand('marginBottom', new Map([['marginBlock', '4px 8px']]))).toBe('8px')
144
+ expect(resolveLonghand('marginBottom', new Map([['margin', '1rem 2rem']]))).toBe('1rem')
145
+ })
146
+
147
+ it('marginInlineStart falls back through marginLeft → marginInline → margin', () => {
148
+ expect(resolveLonghand('marginInlineStart', new Map([['marginLeft', '7px']]))).toBe('7px')
149
+ expect(resolveLonghand('marginInlineStart', new Map([['marginInline', '12px']]))).toBe('12px')
150
+ expect(resolveLonghand('marginInlineStart', new Map([['marginInline', '12px 24px']]))).toBe('12px')
151
+ expect(resolveLonghand('marginInlineStart', new Map([['margin', '1px 2px 3px 4px']]))).toBe('4px')
152
+ })
153
+
154
+ it('marginInlineEnd falls back through marginRight → marginInline → margin', () => {
155
+ expect(resolveLonghand('marginInlineEnd', new Map([['marginRight', '9px']]))).toBe('9px')
156
+ expect(resolveLonghand('marginInlineEnd', new Map([['marginInline', '12px 24px']]))).toBe('24px')
157
+ expect(resolveLonghand('marginInlineEnd', new Map([['margin', '1px 2px 3px 4px']]))).toBe('2px')
158
+ })
159
+
160
+ it('prefers the literal longhand over any fallback', () => {
161
+ const values = new Map([
162
+ ['marginTop', '5px'],
163
+ ['margin', '10px'],
164
+ ])
165
+ expect(resolveLonghand('marginTop', values)).toBe('5px')
166
+ })
167
+ })
168
+
134
169
  describe('padding family', () => {
135
170
  it('paddingTop falls back through paddingBlockStart → paddingBlock → padding', () => {
136
171
  expect(resolveLonghand('paddingTop', new Map([['paddingBlockStart', '1px']]))).toBe('1px')
@@ -215,6 +215,18 @@ const fromTextDecorationLine: Fallback = (values) => {
215
215
  return lineTokens.join(' ')
216
216
  }
217
217
 
218
+ /**
219
+ * `flex-flow: <flex-direction> || <flex-wrap>` — order is free and either
220
+ * part may be omitted. Pick out just the direction keyword for the schema.
221
+ */
222
+ const FLEX_DIRECTION_KEYWORDS = new Set(['row', 'row-reverse', 'column', 'column-reverse'])
223
+
224
+ const fromFlexFlowDirection: Fallback = (values) => {
225
+ const shorthand = values.get('flexFlow')
226
+ if (shorthand === undefined) return undefined
227
+ return splitTopLevelTokens(shorthand).find((token) => FLEX_DIRECTION_KEYWORDS.has(token.toLowerCase()))
228
+ }
229
+
218
230
  // ─────────────────────────────────────────────────────────────────────────────
219
231
  // Resolution table
220
232
  //
@@ -233,6 +245,12 @@ const FALLBACKS: Partial<Record<CssPropertyName, Fallback[]>> = {
233
245
  paddingInlineStart: [fromKey('paddingLeft'), fromAxis('paddingInline', 'start'), fromBox('padding', 'left')],
234
246
  paddingInlineEnd: [fromKey('paddingRight'), fromAxis('paddingInline', 'end'), fromBox('padding', 'right')],
235
247
 
248
+ // Margin — same block/inline convention as padding above.
249
+ marginTop: [fromKey('marginBlockStart'), fromAxis('marginBlock', 'start'), fromBox('margin', 'top')],
250
+ marginBottom: [fromKey('marginBlockEnd'), fromAxis('marginBlock', 'end'), fromBox('margin', 'bottom')],
251
+ marginInlineStart: [fromKey('marginLeft'), fromAxis('marginInline', 'start'), fromBox('margin', 'left')],
252
+ marginInlineEnd: [fromKey('marginRight'), fromAxis('marginInline', 'end'), fromBox('margin', 'right')],
253
+
236
254
  // Border radius — schema uses all-logical corner names; physical names map under LTR/horizontal-tb.
237
255
  borderStartStartRadius: [fromKey('borderTopLeftRadius'), fromCorner('borderRadius', 'tl')],
238
256
  borderStartEndRadius: [fromKey('borderTopRightRadius'), fromCorner('borderRadius', 'tr')],
@@ -255,6 +273,10 @@ const FALLBACKS: Partial<Record<CssPropertyName, Fallback[]>> = {
255
273
  // and color in one declaration. Pick out just the line tokens for the schema.
256
274
  textDecorationLine: [fromTextDecorationLine],
257
275
 
276
+ // Flex — `flex-flow: column wrap` carries both direction and wrap; pick out
277
+ // just the direction keyword for the schema.
278
+ flexDirection: [fromFlexFlowDirection],
279
+
258
280
  // Shorthand targets (e.g. `font`) live in css-shorthand-composer.ts and are
259
281
  // merged in here — composer-driven, conceptually the inverse of the rows above.
260
282
  ...SHORTHAND_FALLBACKS,
@@ -11,6 +11,7 @@ function makeElement(input: { tag: string; className: string; attributes?: Recor
11
11
  boundProps: [],
12
12
  concatenatedAttrs: new Map(),
13
13
  eventHandlers: [],
14
+ hasEmptySlot: false,
14
15
  }
15
16
  return {
16
17
  traceId: 'trace-0',
@@ -346,6 +346,72 @@ describe('toEditorReactComponent — cssProperties defaultValue', () => {
346
346
  defaultValue: 'var(--wst-primary-background-color)',
347
347
  })
348
348
  })
349
+
350
+ it('excludes width/height from the root element (sizing is handled via layout config)', () => {
351
+ const extractorData = new Map<string, unknown>()
352
+ extractorData.set('css-matcher', matcherDataFromCss('width: 100px; height: 50px; overflow: hidden;'))
353
+ extractorData.set('css-properties', {
354
+ relevant: [
355
+ CSS_PROPERTIES.CSS_PROPERTY_TYPE.width,
356
+ CSS_PROPERTIES.CSS_PROPERTY_TYPE.height,
357
+ CSS_PROPERTIES.CSS_PROPERTY_TYPE.overflow,
358
+ ],
359
+ })
360
+
361
+ const rootElement: ExtractedElement = {
362
+ traceId: 'trace-root',
363
+ name: 'root',
364
+ tag: 'div',
365
+ attributes: {},
366
+ extractorData,
367
+ children: [],
368
+ }
369
+
370
+ const result = toEditorReactComponent(createComponent(rootElement))
371
+
372
+ expect(result.editorElement?.cssProperties?.width).toBeUndefined()
373
+ expect(result.editorElement?.cssProperties?.height).toBeUndefined()
374
+ expect(result.editorElement?.cssProperties?.overflow).toEqual({ defaultValue: 'hidden' })
375
+ })
376
+
377
+ it('keeps width/height on inner (non-root) elements', () => {
378
+ const rootExtractorData = new Map<string, unknown>()
379
+ rootExtractorData.set('css-matcher', matcherDataFromCss(''))
380
+ rootExtractorData.set('css-properties', { relevant: [] })
381
+
382
+ const childExtractorData = new Map<string, unknown>()
383
+ childExtractorData.set('css-matcher', matcherDataFromCss('width: 24px; height: 24px;'))
384
+ childExtractorData.set('css-properties', {
385
+ relevant: [CSS_PROPERTIES.CSS_PROPERTY_TYPE.width, CSS_PROPERTIES.CSS_PROPERTY_TYPE.height],
386
+ })
387
+
388
+ const childElement: ExtractedElement = {
389
+ traceId: 'trace-icon',
390
+ name: 'icon',
391
+ tag: 'img',
392
+ attributes: {},
393
+ extractorData: childExtractorData,
394
+ children: [],
395
+ }
396
+
397
+ const rootElement: ExtractedElement = {
398
+ traceId: 'trace-root',
399
+ name: 'root',
400
+ tag: 'div',
401
+ attributes: {},
402
+ extractorData: rootExtractorData,
403
+ children: [childElement],
404
+ }
405
+
406
+ const result = toEditorReactComponent(createComponent(rootElement))
407
+
408
+ expect(result.editorElement?.elements?.icon?.inlineElement?.cssProperties?.width).toEqual({
409
+ defaultValue: '24px',
410
+ })
411
+ expect(result.editorElement?.elements?.icon?.inlineElement?.cssProperties?.height).toEqual({
412
+ defaultValue: '24px',
413
+ })
414
+ })
349
415
  })
350
416
 
351
417
  describe('toEditorReactComponent selector conversion', () => {
@@ -25,7 +25,11 @@ import type {
25
25
  ExtractedElement,
26
26
  TrackingStores,
27
27
  } from '../information-extractors/react'
28
- import { getDefaultDisplayForTag, resolveDisplayValue } from '../information-extractors/react'
28
+ import {
29
+ ROOT_DISPLAY_CUSTOM_PROPERTY,
30
+ getDefaultDisplayForTag,
31
+ resolveDisplayValue,
32
+ } from '../information-extractors/react'
29
33
  import { findPreferredSemanticClass, normalizeClassNames } from '../utils/css-class'
30
34
  import { buildActiveItemDisplayGroups, buildActiveItemIndexDisplayFilters } from './active-item-index-builder'
31
35
  import { resolveLonghand } from './css-longhand-resolver'
@@ -307,7 +311,7 @@ function buildNearestCommonAncestorCustomProps(
307
311
  const nearestCommonAncestorCustomProps = new Map<string, Record<string, CssCustomPropertyItem>>()
308
312
 
309
313
  for (const varName of varNames) {
310
- if (varName === '--display') continue
314
+ if (varName === ROOT_DISPLAY_CUSTOM_PROPERTY) continue
311
315
 
312
316
  const registration = registeredByVar.get(varName)
313
317
  // Prefer a concrete declared value; fall back to the @property initial-value so a
@@ -563,6 +567,10 @@ function toDisplayEnumValue(cssValue: string): DisplayEnumValue {
563
567
  return (CSS_DISPLAY_TO_ENUM[cssValue] ?? cssValue) as DisplayEnumValue
564
568
  }
565
569
 
570
+ // Root sizing is handled separately via layout config, so `width`/`height`
571
+ // are excluded from the root element's own `cssProperties`.
572
+ const ROOT_EXCLUDED_CSS_PROPERTIES = new Set<string>([CSS_PROPERTY_TYPE.width, CSS_PROPERTY_TYPE.height])
573
+
566
574
  function buildCssProperties(
567
575
  element: ExtractedElement | undefined,
568
576
  isRootElement: boolean,
@@ -576,6 +584,9 @@ function buildCssProperties(
576
584
  }
577
585
  const cssPropertyValues = element ? getMatchedPropertyValues(element) : new Map<string, string>()
578
586
  for (const propName of decidedProperties) {
587
+ if (isRootElement && ROOT_EXCLUDED_CSS_PROPERTIES.has(propName)) {
588
+ continue
589
+ }
579
590
  if (propName === CSS_PROPERTY_TYPE.display && element) {
580
591
  if (!isRootElement) {
581
592
  result[propName] = buildDisplayProperty(element)
@@ -0,0 +1,186 @@
1
+ import { CSS_PROPERTIES } from '@wix/react-component-schema'
2
+ import { describe, expect, it } from 'vitest'
3
+ import type { CssPropertiesData } from '../css-properties'
4
+ import { getCssPropertiesForTag } from '../css-properties'
5
+ import { ExtractorStore } from './store'
6
+ import { type ExtractedElement, buildElementTree } from './tree-builder'
7
+
8
+ const { CSS_PROPERTY_TYPE } = CSS_PROPERTIES
9
+
10
+ function createStoreForTags(tagsByTraceId: Record<string, string>, emptySlotTraceIds: string[] = []): ExtractorStore {
11
+ const store = new ExtractorStore()
12
+ for (const [traceId, tag] of Object.entries(tagsByTraceId)) {
13
+ store.set(traceId, 'css-properties', { relevant: getCssPropertiesForTag(tag) })
14
+ store.set(traceId, 'prop-tracker', {
15
+ tag,
16
+ boundProps: [],
17
+ concatenatedAttrs: new Map<string, string>(),
18
+ eventHandlers: [],
19
+ hasEmptySlot: emptySlotTraceIds.includes(traceId),
20
+ })
21
+ }
22
+ return store
23
+ }
24
+
25
+ function findElementByName(elements: ExtractedElement[], name: string): ExtractedElement | undefined {
26
+ for (const element of elements) {
27
+ if (element.name === name) return element
28
+ const match = findElementByName(element.children, name)
29
+ if (match) return match
30
+ }
31
+ return undefined
32
+ }
33
+
34
+ function relevantPropertiesOf(element: ExtractedElement): string[] {
35
+ const cssData = element.extractorData.get('css-properties') as CssPropertiesData | undefined
36
+ return cssData?.relevant ?? []
37
+ }
38
+
39
+ describe('buildElementTree - button typography', () => {
40
+ const TEXT_PROPERTIES = [
41
+ CSS_PROPERTY_TYPE.font,
42
+ CSS_PROPERTY_TYPE.lineHeight,
43
+ CSS_PROPERTY_TYPE.letterSpacing,
44
+ CSS_PROPERTY_TYPE.textDecorationLine,
45
+ CSS_PROPERTY_TYPE.textTransform,
46
+ CSS_PROPERTY_TYPE.textAlign,
47
+ CSS_PROPERTY_TYPE.textShadow,
48
+ CSS_PROPERTY_TYPE.color,
49
+ ]
50
+
51
+ it('gives a button the full text set when its label sits in a dropped wrapper', () => {
52
+ const html = `
53
+ <div data-trace-id="t1" class="label-button">
54
+ <button data-trace-id="t2" class="label-button__trigger">
55
+ <span data-trace-id="t3" class="_label_1x9fa">Play</span>
56
+ </button>
57
+ </div>
58
+ `
59
+ const store = createStoreForTags({ t1: 'div', t2: 'button', t3: 'span' })
60
+
61
+ const tree = buildElementTree(html, store)
62
+ const trigger = findElementByName(tree, 'labelButtonTrigger')
63
+
64
+ expect(trigger).toBeDefined()
65
+ expect(relevantPropertiesOf(trigger!)).toEqual(expect.arrayContaining(TEXT_PROPERTIES))
66
+ })
67
+
68
+ it('keeps a text-free button to color alone, with no typography', () => {
69
+ const html = `
70
+ <div data-trace-id="t1" class="icon-button">
71
+ <button data-trace-id="t2" class="icon-button__trigger" aria-label="Play">
72
+ <svg data-trace-id="t3" viewBox="0 0 24 24"><path d="M7 4v16l12-8z"></path></svg>
73
+ </button>
74
+ </div>
75
+ `
76
+ const store = createStoreForTags({ t1: 'div', t2: 'button', t3: 'svg' })
77
+
78
+ const tree = buildElementTree(html, store)
79
+ const trigger = findElementByName(tree, 'iconButtonTrigger')
80
+
81
+ expect(trigger).toBeDefined()
82
+ expect(relevantPropertiesOf(trigger!)).toContain(CSS_PROPERTY_TYPE.paddingTop)
83
+ expect(relevantPropertiesOf(trigger!)).toContain(CSS_PROPERTY_TYPE.color)
84
+ for (const textProperty of TEXT_PROPERTIES.filter((property) => property !== CSS_PROPERTY_TYPE.color)) {
85
+ expect(relevantPropertiesOf(trigger!)).not.toContain(textProperty)
86
+ }
87
+ })
88
+
89
+ it('keeps typography on a button whose content prop rendered nothing', () => {
90
+ const html = `
91
+ <div data-trace-id="t1" class="cta-button">
92
+ <button data-trace-id="t2" class="cta-button__trigger"></button>
93
+ </div>
94
+ `
95
+ const store = createStoreForTags({ t1: 'div', t2: 'button' }, ['t2'])
96
+
97
+ const tree = buildElementTree(html, store)
98
+ const trigger = findElementByName(tree, 'ctaButtonTrigger')
99
+
100
+ // Node props mock to null, so an empty button may still render text at runtime
101
+ expect(relevantPropertiesOf(trigger!)).toContain(CSS_PROPERTY_TYPE.font)
102
+ })
103
+
104
+ it('keeps typography on a button whose slotted label sits in a wrapper', () => {
105
+ const html = `
106
+ <div data-trace-id="t1" class="cta-button">
107
+ <button data-trace-id="t2" class="cta-button__trigger">
108
+ <span data-trace-id="t3" class="_label_1x9fa"></span>
109
+ </button>
110
+ </div>
111
+ `
112
+ const store = createStoreForTags({ t1: 'div', t2: 'button', t3: 'span' }, ['t3'])
113
+
114
+ const tree = buildElementTree(html, store)
115
+ const trigger = findElementByName(tree, 'ctaButtonTrigger')
116
+
117
+ // The wrapper is dropped and its slotted label mocked to null, so the button
118
+ // is the only element left that can carry the typography
119
+ expect(relevantPropertiesOf(trigger!)).toContain(CSS_PROPERTY_TYPE.font)
120
+ })
121
+
122
+ it('keeps typography on a button holding an icon beside a slotted label', () => {
123
+ const html = `
124
+ <div data-trace-id="t1" class="cta-button">
125
+ <button data-trace-id="t2" class="cta-button__trigger">
126
+ <svg data-trace-id="t3" viewBox="0 0 24 24"><path d="M7 4v16l12-8z"></path></svg>
127
+ </button>
128
+ </div>
129
+ `
130
+ const store = createStoreForTags({ t1: 'div', t2: 'button', t3: 'svg' }, ['t2'])
131
+
132
+ const tree = buildElementTree(html, store)
133
+ const trigger = findElementByName(tree, 'ctaButtonTrigger')
134
+
135
+ // The icon is the only child left once the slotted label mocks to null
136
+ expect(relevantPropertiesOf(trigger!)).toContain(CSS_PROPERTY_TYPE.font)
137
+ })
138
+
139
+ it('keeps typography off a button that rendered nothing at all', () => {
140
+ const html = `
141
+ <div data-trace-id="t1" class="spacer-button">
142
+ <button data-trace-id="t2" class="spacer-button__trigger"></button>
143
+ </div>
144
+ `
145
+ const store = createStoreForTags({ t1: 'div', t2: 'button' })
146
+
147
+ const tree = buildElementTree(html, store)
148
+ const trigger = findElementByName(tree, 'spacerButtonTrigger')
149
+
150
+ expect(relevantPropertiesOf(trigger!)).not.toContain(CSS_PROPERTY_TYPE.font)
151
+ })
152
+
153
+ it('finds a slot in a wrapper that also rendered an icon', () => {
154
+ const html = `
155
+ <div data-trace-id="t1" class="cta-button">
156
+ <button data-trace-id="t2" class="cta-button__trigger">
157
+ <span data-trace-id="t3" class="_content_1x9fa">
158
+ <svg data-trace-id="t4" viewBox="0 0 24 24"><path d="M7 4v16l12-8z"></path></svg>
159
+ </span>
160
+ </button>
161
+ </div>
162
+ `
163
+ const store = createStoreForTags({ t1: 'div', t2: 'button', t3: 'span', t4: 'svg' }, ['t3'])
164
+
165
+ const tree = buildElementTree(html, store)
166
+ const trigger = findElementByName(tree, 'ctaButtonTrigger')
167
+
168
+ expect(relevantPropertiesOf(trigger!)).toContain(CSS_PROPERTY_TYPE.font)
169
+ })
170
+
171
+ it('ignores text that only SVG metadata renders', () => {
172
+ const html = `
173
+ <div data-trace-id="t1" class="icon-button">
174
+ <button data-trace-id="t2" class="icon-button__trigger">
175
+ <svg data-trace-id="t3" viewBox="0 0 24 24"><title>Play</title><path d="M7 4v16l12-8z"></path></svg>
176
+ </button>
177
+ </div>
178
+ `
179
+ const store = createStoreForTags({ t1: 'div', t2: 'button', t3: 'svg' })
180
+
181
+ const tree = buildElementTree(html, store)
182
+ const trigger = findElementByName(tree, 'iconButtonTrigger')
183
+
184
+ expect(relevantPropertiesOf(trigger!)).not.toContain(CSS_PROPERTY_TYPE.font)
185
+ })
186
+ })
@@ -11,7 +11,7 @@ import { TRACE_ATTR } from '../../../../component-renderer'
11
11
  import { findPreferredSemanticClass, normalizeClassNames } from '../../../../utils/css-class'
12
12
  import { PRESETS_WRAPPER_CLASS_NAME } from '../../utils/mock-generator'
13
13
  import type { CssPropertiesData } from '../css-properties'
14
- import { addTextProperties } from '../css-properties'
14
+ import { addTextProperties, rendersTextThroughContent } from '../css-properties'
15
15
  import type { PropTrackerData } from '../prop-tracker'
16
16
  import type { ExtractorStore } from './store'
17
17
 
@@ -111,11 +111,43 @@ const getAttributes = (element: Element): Record<string, string> => {
111
111
  return attrs
112
112
  }
113
113
 
114
+ const hasVisibleText = (node: Node): boolean => isTextNode(node) && node.value.trim().length > 0
115
+
114
116
  /**
115
117
  * Checks if an element has direct text content (non-whitespace text nodes as children)
116
118
  */
117
- const hasDirectTextContent = (element: Element): boolean =>
118
- element.childNodes.some((child) => isTextNode(child) && child.value.trim().length > 0)
119
+ const hasDirectTextContent = (element: Element): boolean => element.childNodes.some(hasVisibleText)
120
+
121
+ /** Tags whose text is metadata or source, never styleable text in the layout. */
122
+ const NON_RENDERED_TEXT_TAGS = new Set(['title', 'desc', 'metadata', 'script', 'style', 'template'])
123
+
124
+ /** An element whose own content can reach the page. */
125
+ const isRenderedElement = (node: Node): node is Element =>
126
+ isElement(node) && !NON_RENDERED_TEXT_TAGS.has(node.tagName.toLowerCase())
127
+
128
+ /** Checks if an element renders any text anywhere beneath it. */
129
+ const hasDescendantTextContent = (element: Element): boolean =>
130
+ element.childNodes.some(
131
+ (child) => hasVisibleText(child) || (isRenderedElement(child) && hasDescendantTextContent(child)),
132
+ )
133
+
134
+ /** Whether this element's own child slot rendered nothing. */
135
+ const hasEmptySlot = (element: Element, store: ExtractorStore): boolean => {
136
+ const traceId = getAttribute(element, TRACE_ATTR)
137
+ return Boolean(traceId && store.get<PropTrackerData>(traceId, 'prop-tracker')?.hasEmptySlot)
138
+ }
139
+
140
+ /** Whether content is missing from this element's own slot, or from one below it. */
141
+ const hasMissingContent = (element: Element, store: ExtractorStore): boolean =>
142
+ hasEmptySlot(element, store) ||
143
+ element.childNodes.some((child) => isElement(child) && hasMissingContent(child, store))
144
+
145
+ /**
146
+ * Returns true if an element renders no text. Content the probe could not produce
147
+ * leaves this unknown, so an element missing content returns false.
148
+ */
149
+ const rendersNoText = (element: Element, store: ExtractorStore): boolean =>
150
+ !hasDescendantTextContent(element) && !hasMissingContent(element, store)
119
151
 
120
152
  /**
121
153
  * Normalizes a tag name to its semantic camelCase form
@@ -275,8 +307,12 @@ export function buildElementTree(html: string, store: ExtractorStore): Extracted
275
307
  // Check for text content
276
308
  const hasText = hasDirectTextContent(node)
277
309
 
278
- // If element has text content and has css-properties data, add text properties
279
- if (hasText) {
310
+ // Wrappers without a semantic class never reach the tree, so an element whose
311
+ // text comes from its content must own the typography itself.
312
+ const rendersText = hasText || (rendersTextThroughContent(node.tagName) && !rendersNoText(node, store))
313
+
314
+ // If element renders text and has css-properties data, add text properties
315
+ if (rendersText) {
280
316
  const cssData = extractorData.get('css-properties') as CssPropertiesData | undefined
281
317
  if (cssData) {
282
318
  const enhanced: CssPropertiesData = {
@@ -24,6 +24,8 @@ export interface CreateElementEvent {
24
24
  props: Record<string, unknown>
25
25
  traceId?: string
26
26
  children: unknown[]
27
+ /** True when a child slot rendered nothing, e.g. a `children` prop mocked to null. */
28
+ hasEmptySlot?: boolean
27
29
  store: ExtractorStore
28
30
  }
29
31