@wix/zero-config-implementation 1.97.0 → 1.99.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.97.0",
7
+ "version": "1.99.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": "0b3ebaeffed6f192d5ccd84f8598e0e524b0edb36eb3ca0961bab4c7"
109
+ "falconPackageHash": "905a961b05476216ee10b85e56b0d50818e5247abe594c0917619c0a"
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.
@@ -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',
@@ -43,11 +43,22 @@ function createElementWithDisplayMatcher(tag: string, matcherData: MatchedCssDat
43
43
  }
44
44
  }
45
45
 
46
- function createComponent(rootElement: ExtractedElement): ComponentInfoWithCss {
46
+ function createComponent(rootElement: ExtractedElement, renderDisplayElementAsChild = false): ComponentInfoWithCss {
47
+ const componentRoot = renderDisplayElementAsChild
48
+ ? {
49
+ traceId: 'trace-container',
50
+ name: 'container',
51
+ tag: 'div',
52
+ attributes: {},
53
+ extractorData: new Map<string, unknown>(),
54
+ children: [{ ...rootElement, traceId: 'trace-display', name: 'display' }],
55
+ }
56
+ : rootElement
57
+
47
58
  return {
48
59
  componentName: 'DisplayComponent',
49
60
  props: {},
50
- elements: [rootElement],
61
+ elements: [componentRoot],
51
62
  propUsages: new Map(),
52
63
  css: [],
53
64
  varUsedByTraceId: new Map(),
@@ -71,6 +82,10 @@ function createComponentWithA11yUsages(propPaths: string[]): ComponentInfoWithCs
71
82
  }
72
83
  }
73
84
 
85
+ function getDisplayProperty(result: ReturnType<typeof toEditorReactComponent>) {
86
+ return result.editorElement?.elements?.display?.inlineElement?.cssProperties?.display
87
+ }
88
+
74
89
  describe('toEditorReactComponent accessibility conversion', () => {
75
90
  it('omits the a11y item when the component reads only component-owned fields', () => {
76
91
  const component = createComponentWithA11yUsages(['props.a11y.role', 'props.a11y.tabIndex'])
@@ -115,6 +130,14 @@ function createSemanticElement({
115
130
  }
116
131
 
117
132
  describe('toEditorReactComponent display conversion', () => {
133
+ it('omits display from the root editor element', () => {
134
+ const rootElement = createElementWithDisplayMatcher('div', matcherDataFromCss('display: flex'))
135
+
136
+ const result = toEditorReactComponent(createComponent(rootElement))
137
+
138
+ expect(result.editorElement?.cssProperties?.display).toBeUndefined()
139
+ })
140
+
118
141
  it('resolves local display fallback chains before emitting the manifest display value', () => {
119
142
  const rootElement = createElementWithDisplayMatcher(
120
143
  'span',
@@ -123,10 +146,10 @@ describe('toEditorReactComponent display conversion', () => {
123
146
  ),
124
147
  )
125
148
 
126
- const result = toEditorReactComponent(createComponent(rootElement))
149
+ const result = toEditorReactComponent(createComponent(rootElement, true))
127
150
  expect(result.editorElement).toBeDefined()
128
151
 
129
- expect(result.editorElement?.cssProperties?.display).toEqual({
152
+ expect(getDisplayProperty(result)).toEqual({
130
153
  display: {
131
154
  displayValues: [CSS_PROPERTIES.DISPLAY_VALUE.none, 'inlineFlex'],
132
155
  },
@@ -136,10 +159,10 @@ describe('toEditorReactComponent display conversion', () => {
136
159
  it('keeps using the tag default when no display declaration exists', () => {
137
160
  const rootElement = createElementWithDisplayMatcher('span', matcherDataFromCss(''))
138
161
 
139
- const result = toEditorReactComponent(createComponent(rootElement))
162
+ const result = toEditorReactComponent(createComponent(rootElement, true))
140
163
  expect(result.editorElement).toBeDefined()
141
164
 
142
- expect(result.editorElement?.cssProperties?.display).toEqual({
165
+ expect(getDisplayProperty(result)).toEqual({
143
166
  display: {
144
167
  displayValues: [CSS_PROPERTIES.DISPLAY_VALUE.none, 'inline'],
145
168
  },
@@ -166,9 +189,9 @@ describe('toEditorReactComponent display conversion', () => {
166
189
  it.each(tagDefaults)('maps a <%s> tag default (%s) onto its enum name', (tag, _cssDisplay, expectedEnumValue) => {
167
190
  const rootElement = createElementWithDisplayMatcher(tag, matcherDataFromCss(''))
168
191
 
169
- const result = toEditorReactComponent(createComponent(rootElement))
192
+ const result = toEditorReactComponent(createComponent(rootElement, true))
170
193
 
171
- expect(result.editorElement?.cssProperties?.display).toEqual({
194
+ expect(getDisplayProperty(result)).toEqual({
172
195
  display: {
173
196
  displayValues: [CSS_PROPERTIES.DISPLAY_VALUE.none, expectedEnumValue],
174
197
  },
@@ -177,14 +200,14 @@ describe('toEditorReactComponent display conversion', () => {
177
200
 
178
201
  it('treats an explicit `display: table-cell` declaration the same as the tag default', () => {
179
202
  const fromTag = toEditorReactComponent(
180
- createComponent(createElementWithDisplayMatcher('td', matcherDataFromCss(''))),
203
+ createComponent(createElementWithDisplayMatcher('td', matcherDataFromCss('')), true),
181
204
  )
182
205
  const fromCss = toEditorReactComponent(
183
- createComponent(createElementWithDisplayMatcher('div', matcherDataFromCss('display: table-cell'))),
206
+ createComponent(createElementWithDisplayMatcher('div', matcherDataFromCss('display: table-cell')), true),
184
207
  )
185
208
 
186
- expect(fromCss.editorElement?.cssProperties?.display).toEqual(fromTag.editorElement?.cssProperties?.display)
187
- expect(fromCss.editorElement?.cssProperties?.display).toEqual({
209
+ expect(getDisplayProperty(fromCss)).toEqual(getDisplayProperty(fromTag))
210
+ expect(getDisplayProperty(fromCss)).toEqual({
188
211
  display: {
189
212
  displayValues: [CSS_PROPERTIES.DISPLAY_VALUE.none, CSS_PROPERTIES.DISPLAY_VALUE.tableCell],
190
213
  },
@@ -194,9 +217,9 @@ describe('toEditorReactComponent display conversion', () => {
194
217
  it('still maps kebab-case values that do have an enum representation', () => {
195
218
  const rootElement = createElementWithDisplayMatcher('div', matcherDataFromCss('display: inline-block'))
196
219
 
197
- const result = toEditorReactComponent(createComponent(rootElement))
220
+ const result = toEditorReactComponent(createComponent(rootElement, true))
198
221
 
199
- expect(result.editorElement?.cssProperties?.display).toEqual({
222
+ expect(getDisplayProperty(result)).toEqual({
200
223
  display: {
201
224
  displayValues: [CSS_PROPERTIES.DISPLAY_VALUE.none, CSS_PROPERTIES.DISPLAY_VALUE.inlineBlock],
202
225
  },
@@ -208,8 +231,8 @@ describe('toEditorReactComponent display conversion', () => {
208
231
 
209
232
  for (const [tag] of tagDefaults) {
210
233
  const rootElement = createElementWithDisplayMatcher(tag, matcherDataFromCss(''))
211
- const result = toEditorReactComponent(createComponent(rootElement))
212
- const emittedValues = result.editorElement?.cssProperties?.display?.display?.displayValues ?? []
234
+ const result = toEditorReactComponent(createComponent(rootElement, true))
235
+ const emittedValues = getDisplayProperty(result)?.display?.displayValues ?? []
213
236
 
214
237
  expect(emittedValues.filter((displayValue) => !allowedValues.has(displayValue))).toEqual([])
215
238
  }
@@ -234,9 +257,9 @@ describe('toEditorReactComponent display conversion', () => {
234
257
  'div',
235
258
  matcherDataFromCss(`display: ${toCssSpelling(enumValue)}`),
236
259
  )
237
- const result = toEditorReactComponent(createComponent(rootElement))
260
+ const result = toEditorReactComponent(createComponent(rootElement, true))
238
261
 
239
- return result.editorElement?.cssProperties?.display?.display?.displayValues?.[1] !== enumValue
262
+ return getDisplayProperty(result)?.display?.displayValues?.[1] !== enumValue
240
263
  })
241
264
 
242
265
  expect(unreachable).toEqual([])
@@ -127,7 +127,7 @@ function buildEditorElement(
127
127
  component.innerElementProps,
128
128
  component.propUsages,
129
129
  ),
130
- cssProperties: buildCssProperties(rootElement),
130
+ cssProperties: buildCssProperties(rootElement, true),
131
131
  cssCustomProperties: rootCustomProps,
132
132
  ...(rootStates && { states: rootStates }),
133
133
  ...(displayGroups && { displayGroups }),
@@ -224,7 +224,7 @@ function buildElements(
224
224
  const semanticClass = getSemanticBlockName(element)
225
225
 
226
226
  const data = innerEntry && propUsages ? buildData(innerEntry.props, propUsages) : undefined
227
- const cssProps = buildCssProperties(element)
227
+ const cssProps = buildCssProperties(element, false)
228
228
  const cssCustomProps = nearestCommonAncestorCustomProps.get(element.traceId) ?? {}
229
229
 
230
230
  // Inner-element custom states come from custom-class triggers only; prop
@@ -563,7 +563,10 @@ function toDisplayEnumValue(cssValue: string): DisplayEnumValue {
563
563
  return (CSS_DISPLAY_TO_ENUM[cssValue] ?? cssValue) as DisplayEnumValue
564
564
  }
565
565
 
566
- function buildCssProperties(element: ExtractedElement | undefined): Record<string, CssPropertyItem> {
566
+ function buildCssProperties(
567
+ element: ExtractedElement | undefined,
568
+ isRootElement: boolean,
569
+ ): Record<string, CssPropertyItem> {
567
570
  const result: Record<string, CssPropertyItem> = {}
568
571
 
569
572
  const cssData = element?.extractorData.get('css-properties') as CssPropertiesData | undefined
@@ -574,7 +577,9 @@ function buildCssProperties(element: ExtractedElement | undefined): Record<strin
574
577
  const cssPropertyValues = element ? getMatchedPropertyValues(element) : new Map<string, string>()
575
578
  for (const propName of decidedProperties) {
576
579
  if (propName === CSS_PROPERTY_TYPE.display && element) {
577
- result[propName] = buildDisplayProperty(element)
580
+ if (!isRootElement) {
581
+ result[propName] = buildDisplayProperty(element)
582
+ }
578
583
  continue
579
584
  }
580
585
  const resolvedValue = resolveLonghand(propName, cssPropertyValues)
@@ -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
 
@@ -173,8 +173,8 @@ describe('getCssPropertiesForTag', () => {
173
173
  expect(relevantProperties).not.toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.font)
174
174
  })
175
175
 
176
- it('gives interactive controls both box and text properties', () => {
177
- for (const tag of ['button', 'select', 'textarea']) {
176
+ it('gives controls that render their own text both box and text properties', () => {
177
+ for (const tag of ['select', 'textarea']) {
178
178
  const relevantProperties = getCssPropertiesForTag(tag)
179
179
 
180
180
  expect(relevantProperties).toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.paddingTop)
@@ -183,6 +183,16 @@ describe('getCssPropertiesForTag', () => {
183
183
  }
184
184
  })
185
185
 
186
+ it('treats a button as box-generating but not text-bearing, except for color', () => {
187
+ const relevantProperties = getCssPropertiesForTag('button')
188
+
189
+ expect(relevantProperties).toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.paddingTop)
190
+ // `color` resolves `currentColor` in borders, outlines and fills, text or not
191
+ expect(relevantProperties).toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.color)
192
+ expect(relevantProperties).not.toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.font)
193
+ expect(relevantProperties).not.toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.textAlign)
194
+ })
195
+
186
196
  it('treats an input with no type as text-bearing', () => {
187
197
  const relevantProperties = getCssPropertiesForTag('input')
188
198
 
@@ -30,7 +30,8 @@ export interface SemanticAttributes {
30
30
 
31
31
  /**
32
32
  * Text & Typography - elements where readability and hierarchy are primary.
33
- * Form controls render their own text; `input` varies by type (see below).
33
+ * `select`, `textarea`, and `input` render their own text; `input` varies by type
34
+ * (see below). `button` is absent on purpose - see CONTENT_DEPENDENT_TEXT_TAGS.
34
35
  */
35
36
  const TEXT_TAGS = new Set([
36
37
  'h1',
@@ -48,11 +49,21 @@ const TEXT_TAGS = new Set([
48
49
  'em',
49
50
  'b',
50
51
  'i',
51
- 'button',
52
52
  'select',
53
53
  'textarea',
54
54
  ])
55
55
 
56
+ /**
57
+ * Tags whose text comes from their content rather than their own rendering, so
58
+ * tree building decides once the rendered subtree is known.
59
+ */
60
+ const CONTENT_DEPENDENT_TEXT_TAGS = new Set(['button'])
61
+
62
+ /** Whether the tag's content, rather than the tag itself, decides typography. */
63
+ export function rendersTextThroughContent(tag: string): boolean {
64
+ return CONTENT_DEPENDENT_TEXT_TAGS.has(tag.toLowerCase())
65
+ }
66
+
56
67
  /** Anything else - including a missing or invalid `type` - defaults to text. */
57
68
  const NON_TEXT_INPUT_TYPES = new Set(['checkbox', 'radio', 'range', 'color', 'file', 'image', 'hidden'])
58
69
 
@@ -157,6 +168,10 @@ export function getCssPropertiesForTag(tag: string, attributes: SemanticAttribut
157
168
 
158
169
  if (hasTextProperties) {
159
170
  properties.push(...TEXT_CSS_PROPERTIES)
171
+ } else if (rendersTextThroughContent(tag)) {
172
+ // `color` resolves `currentColor` in borders, outlines and fills even with no
173
+ // text; the rest of the typography waits until tree building sees the content.
174
+ properties.push(CSS_PROPERTY_TYPE.color)
160
175
  }
161
176
  if (hasMediaProperties) {
162
177
  properties.push(...MEDIA_CSS_PROPERTIES)
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
2
2
  import { createRefElementMarker } from '../../../extensions/ref-elements/path-utils'
3
3
  import type { RefElementMatch } from '../../../extensions/ref-elements/types'
4
4
  import { ExtractorStore } from './core/store'
5
- import { createPropTrackerExtractor } from './prop-tracker'
5
+ import { type PropTrackerData, createPropTrackerExtractor } from './prop-tracker'
6
6
 
7
7
  function markRefElementComponent(runtimeComponent: (...args: unknown[]) => unknown, refComponentType: string): void {
8
8
  Object.defineProperty(
@@ -248,4 +248,22 @@ describe('createPropTrackerExtractor', () => {
248
248
  selector: '.card-cta',
249
249
  })
250
250
  })
251
+
252
+ it('persists the empty-slot signal onto the element data', () => {
253
+ const store = new ExtractorStore()
254
+ const { extractor } = createPropTrackerExtractor()
255
+
256
+ extractor.onCreateElement?.({
257
+ type: 'button',
258
+ isDomElement: true,
259
+ tag: 'button',
260
+ traceId: 'trace-1',
261
+ props: { className: 'trigger' },
262
+ children: [],
263
+ hasEmptySlot: true,
264
+ store,
265
+ })
266
+
267
+ expect(store.get<PropTrackerData>('trace-1', 'prop-tracker')?.hasEmptySlot).toBe(true)
268
+ })
251
269
  })
@@ -26,6 +26,8 @@ export interface PropTrackerData {
26
26
  concatenatedAttrs: Map<string, string> // attrName → propName (for concatenated values)
27
27
  /** on*-handler prop names whose value is a function on this element (e.g. ['onClick', 'onChange']). */
28
28
  eventHandlers: string[]
29
+ /** True when a child slot rendered nothing, so the content is unknown. */
30
+ hasEmptySlot: boolean
29
31
  }
30
32
 
31
33
  export interface PropTrackerExtractorState {
@@ -170,6 +172,7 @@ export function createPropTrackerExtractor(options?: CreatePropTrackerExtractorO
170
172
  boundProps: [...boundProps],
171
173
  concatenatedAttrs,
172
174
  eventHandlers,
175
+ hasEmptySlot: event.hasEmptySlot ?? false,
173
176
  } satisfies PropTrackerData)
174
177
 
175
178
  attachMatchedRefElement(traceId, props, store)
@@ -15,6 +15,7 @@ function makeElement(input: {
15
15
  boundProps: [],
16
16
  concatenatedAttrs: new Map(),
17
17
  eventHandlers: input.eventHandlers ?? [],
18
+ hasEmptySlot: false,
18
19
  }
19
20
  return {
20
21
  traceId: 'trace-0',