@wix/zero-config-implementation 1.105.0 → 1.107.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.
@@ -1,6 +1,5 @@
1
1
  import type {
2
2
  CssCustomPropertyItem,
3
- CssNumber,
4
3
  CssPropertyItem,
5
4
  CustomPropertyEnum,
6
5
  DataItem,
@@ -17,7 +16,7 @@ import { buildRefElementManifestKey } from '../extensions/ref-elements/path-util
17
16
  import type { RefElementMatch } from '../extensions/ref-elements/types'
18
17
  import type { ComponentInfoWithCss } from '../index'
19
18
  import { cssPropertyTypeFromUsages, inferCssDataType, stripVarFallback } from '../information-extractors/css/parse'
20
- import type { MatchedCssData, RegisteredCustomProperty } from '../information-extractors/css/types'
19
+ import type { CssSelectorMatch, MatchedCssData, RegisteredCustomProperty } from '../information-extractors/css/types'
21
20
  import type {
22
21
  CoupledComponentInfo,
23
22
  CoupledProp,
@@ -78,15 +77,59 @@ function collectStateClassTriggers(component: ComponentInfoWithCss): StateClassT
78
77
  const EXCLUDED_PROPS = new Set(['id', 'className', 'elementProps', 'wix'])
79
78
 
80
79
  export function toEditorReactComponent(component: ComponentInfoWithCss): EditorReactComponent {
81
- const nearestCommonAncestorCustomProps = buildNearestCommonAncestorCustomProps(component)
80
+ const statesByTraceId = buildStatesByTraceId(component)
81
+ const nearestCommonAncestorCustomProps = buildNearestCommonAncestorCustomProps(component, statesByTraceId)
82
82
  return {
83
- editorElement: buildEditorElement(component, nearestCommonAncestorCustomProps),
83
+ editorElement: buildEditorElement(component, nearestCommonAncestorCustomProps, statesByTraceId),
84
84
  }
85
85
  }
86
86
 
87
+ /** The `states` block of every emitted element, keyed by traceId. */
88
+ type StatesByTraceId = Map<string, States>
89
+
90
+ /**
91
+ * Builds every element's `states` block up front, so CSS defaults can be split per state
92
+ * before the element entries are assembled. Native states need a semantic block name to
93
+ * synthesize `<block>--<state>`; custom class triggers don't (they carry the className
94
+ * verbatim and match the element directly), so they're built even when the element
95
+ * resolves no semantic class. Prop triggers (`ElementState<>`) are component-level and
96
+ * attach to the root only. Ref elements (and their subtrees) emit no states.
97
+ */
98
+ function buildStatesByTraceId(component: ComponentInfoWithCss): StatesByTraceId {
99
+ const statesByTraceId: StatesByTraceId = new Map()
100
+ const rootElement = component.elements[0]
101
+ if (!rootElement) return statesByTraceId
102
+
103
+ const stateClassTriggers = collectStateClassTriggers(component)
104
+ const rootPropTriggers = Object.values(component.props).filter((prop) => prop.isStateTrigger)
105
+
106
+ const visit = (element: ExtractedElement, isRootElement: boolean): void => {
107
+ if (!isRootElement && element.extractorData.get('ref-element')) return
108
+ const semanticClass = getSemanticBlockName(element)
109
+ const states = mergeStates(
110
+ semanticClass ? buildStatesBlock(element, semanticClass, stateClassTriggers.focus) : undefined,
111
+ buildCustomStatesBlock({
112
+ element,
113
+ propTriggers: isRootElement ? rootPropTriggers : [],
114
+ classTriggers: stateClassTriggers.custom,
115
+ }),
116
+ )
117
+ if (states) statesByTraceId.set(element.traceId, states)
118
+ for (const child of element.children) visit(child, false)
119
+ }
120
+ visit(rootElement, true)
121
+
122
+ return statesByTraceId
123
+ }
124
+
125
+ function stateNamesOf(statesByTraceId: StatesByTraceId, traceId: string): string[] {
126
+ return Object.keys(statesByTraceId.get(traceId) ?? {})
127
+ }
128
+
87
129
  function buildEditorElement(
88
130
  component: ComponentInfoWithCss,
89
131
  nearestCommonAncestorCustomProps: Map<string, Record<string, CssCustomPropertyItem>>,
132
+ statesByTraceId: StatesByTraceId,
90
133
  ): EditorElement {
91
134
  const rootElement = component.elements[0]
92
135
  if (!rootElement) {
@@ -98,23 +141,9 @@ function buildEditorElement(
98
141
  const childElements = rootElement.children
99
142
  const rootCustomProps = nearestCommonAncestorCustomProps.get(rootElement.traceId) ?? {}
100
143
 
101
- const stateClassTriggers = collectStateClassTriggers(component)
102
-
103
- // Native states need a semantic block name to synthesize `<block>--<state>`; custom
104
- // class triggers don't (they carry the className verbatim and match the element
105
- // directly), so they're built even when the element resolves no semantic class.
106
144
  const rootSemanticClass = getSemanticBlockName(rootElement)
107
145
  const rootSemanticClasses = rootSemanticClass ? [rootSemanticClass] : []
108
- const rootStates = rootElement
109
- ? mergeStates(
110
- rootSemanticClass ? buildStatesBlock(rootElement, rootSemanticClass, stateClassTriggers.focus) : undefined,
111
- buildCustomStatesBlock({
112
- element: rootElement,
113
- propTriggers: Object.values(component.props).filter((prop) => prop.isStateTrigger),
114
- classTriggers: stateClassTriggers.custom,
115
- }),
116
- )
117
- : undefined
146
+ const rootStates = statesByTraceId.get(rootElement.traceId)
118
147
 
119
148
  const displayGroups = buildActiveItemDisplayGroups(component.props)
120
149
  const displayFilters = buildActiveItemIndexDisplayFilters(component.props)
@@ -126,12 +155,12 @@ function buildEditorElement(
126
155
  elements: buildElements(
127
156
  childElements,
128
157
  nearestCommonAncestorCustomProps,
129
- stateClassTriggers,
158
+ statesByTraceId,
130
159
  rootSemanticClasses,
131
160
  component.innerElementProps,
132
161
  component.propUsages,
133
162
  ),
134
- cssProperties: buildCssProperties(rootElement, true),
163
+ cssProperties: buildCssProperties(rootElement, true, stateNamesOf(statesByTraceId, rootElement.traceId)),
135
164
  cssCustomProperties: rootCustomProps,
136
165
  ...(rootStates && { states: rootStates }),
137
166
  ...(displayGroups && { displayGroups }),
@@ -202,7 +231,7 @@ function buildData(
202
231
  function buildElements(
203
232
  elements: ExtractedElement[],
204
233
  nearestCommonAncestorCustomProps: Map<string, Record<string, CssCustomPropertyItem>>,
205
- stateClassTriggers: StateClassTriggers,
234
+ statesByTraceId: StatesByTraceId,
206
235
  ancestorSemanticClasses: string[],
207
236
  innerElementProps?: CoupledComponentInfo['innerElementProps'],
208
237
  propUsages?: TrackingStores['propUsages'],
@@ -228,15 +257,9 @@ function buildElements(
228
257
  const semanticClass = getSemanticBlockName(element)
229
258
 
230
259
  const data = innerEntry && propUsages ? buildData(innerEntry.props, propUsages) : undefined
231
- const cssProps = buildCssProperties(element, false)
260
+ const cssProps = buildCssProperties(element, false, stateNamesOf(statesByTraceId, element.traceId))
232
261
  const cssCustomProps = nearestCommonAncestorCustomProps.get(element.traceId) ?? {}
233
-
234
- // Inner-element custom states come from custom-class triggers only; prop
235
- // triggers (`ElementState<>`) are component-level and attach to the root.
236
- const states = mergeStates(
237
- semanticClass ? buildStatesBlock(element, semanticClass, stateClassTriggers.focus) : undefined,
238
- buildCustomStatesBlock({ element, propTriggers: [], classTriggers: stateClassTriggers.custom }),
239
- )
262
+ const states = statesByTraceId.get(element.traceId)
240
263
 
241
264
  result[element.name] = {
242
265
  elementType: ELEMENTS.ELEMENT_TYPE.inlineElement,
@@ -257,7 +280,7 @@ function buildElements(
257
280
  ? buildElements(
258
281
  element.children,
259
282
  nearestCommonAncestorCustomProps,
260
- stateClassTriggers,
283
+ statesByTraceId,
261
284
  semanticClass ? [...ancestorSemanticClasses, semanticClass] : ancestorSemanticClasses,
262
285
  innerElementProps,
263
286
  propUsages,
@@ -283,8 +306,10 @@ function buildElements(
283
306
  */
284
307
  function buildNearestCommonAncestorCustomProps(
285
308
  component: ComponentInfoWithCss,
309
+ statesByTraceId: StatesByTraceId,
286
310
  ): Map<string, Record<string, CssCustomPropertyItem>> {
287
311
  const parentMap = buildParentMap(component.elements)
312
+ const elementsByTraceId = buildElementsByTraceId(component.elements)
288
313
  const rootTraceId = component.elements[0]?.traceId
289
314
 
290
315
  // Custom properties registered via the standard @property at-rule — the declared
@@ -307,7 +332,13 @@ function buildNearestCommonAncestorCustomProps(
307
332
 
308
333
  // Emit every declared variable plus every @property registration — a registered var
309
334
  // may have no element-level declaration yet still needs a typed control.
310
- const varNames = new Set<string>([...allCustomPropertyValues.keys(), ...registeredByVar.keys()])
335
+ // Variables declared only under a state (`.root:hover { --x: … }`) have no regular default
336
+ // but still get a control on their declaring element, carrying the state default.
337
+ const varNames = new Set<string>([
338
+ ...allCustomPropertyValues.keys(),
339
+ ...registeredByVar.keys(),
340
+ ...collectStateDeclaredVarNames(component.elements, statesByTraceId),
341
+ ])
311
342
 
312
343
  const nearestCommonAncestorCustomProps = new Map<string, Record<string, CssCustomPropertyItem>>()
313
344
 
@@ -320,7 +351,7 @@ function buildNearestCommonAncestorCustomProps(
320
351
  const defaultValue = allCustomPropertyValues.get(varName) ?? registration?.defaultValue
321
352
 
322
353
  // Declaration wins over usage; multiple declaring elements resolve to their NCA.
323
- const definingTraceIds = findDefiningElements(component.elements, varName)
354
+ const definingTraceIds = findDefiningElements(component.elements, varName, statesByTraceId)
324
355
 
325
356
  let nearestCommonAncestorTraceId: string | undefined
326
357
  if (definingTraceIds.size > 0) {
@@ -342,21 +373,28 @@ function buildNearestCommonAncestorCustomProps(
342
373
 
343
374
  if (!nearestCommonAncestorTraceId) continue
344
375
 
345
- // A @property registration is the top-priority type source; otherwise infer from usage.
376
+ const statesDefaultValues = buildCustomPropertyStatesDefaultValues(
377
+ elementsByTraceId.get(nearestCommonAncestorTraceId),
378
+ stateNamesOf(statesByTraceId, nearestCommonAncestorTraceId),
379
+ varName,
380
+ defaultValue,
381
+ )
382
+ // A @property registration is the top-priority type source; otherwise infer from usage,
383
+ // then from the value — a state value when the variable has no regular declaration.
384
+ const valueForTypeInference = defaultValue ?? Object.values(statesDefaultValues ?? {})[0] ?? ''
346
385
  const cssPropertyType = registration
347
386
  ? registration.cssPropertyType
348
- : getVarPropertyTypeFromCssInfos(varName, defaultValue ?? '', component.css)
387
+ : getVarPropertyTypeFromCssInfos(varName, valueForTypeInference, component.css)
349
388
  const customEnum = buildCustomEnum(registration)
350
- const numberLimits = buildCssNumber(registration, cssPropertyType)
351
389
  const cleanVarName = varName.startsWith('--') ? varName.slice(2) : varName
352
390
 
353
391
  const existingProps = nearestCommonAncestorCustomProps.get(nearestCommonAncestorTraceId) ?? {}
354
392
  existingProps[cleanVarName] = {
355
393
  displayName: formatDisplayName(cleanVarName),
356
394
  ...(defaultValue !== undefined && { defaultValue }),
395
+ ...(statesDefaultValues && { statesDefaultValues }),
357
396
  ...(cssPropertyType !== undefined && { cssPropertyType }),
358
397
  ...(customEnum && { customEnum }),
359
- ...(numberLimits && { number: numberLimits }),
360
398
  }
361
399
  nearestCommonAncestorCustomProps.set(nearestCommonAncestorTraceId, existingProps)
362
400
  }
@@ -364,6 +402,65 @@ function buildNearestCommonAncestorCustomProps(
364
402
  return nearestCommonAncestorCustomProps
365
403
  }
366
404
 
405
+ /**
406
+ * Reads the per-state values a variable is declared with on its placement element. Only the
407
+ * placement element's own state declarations count: a value declared under `:hover` on a
408
+ * descendant belongs to that descendant's state, not to the ancestor the variable was placed on.
409
+ * Values equal to the regular default are omitted.
410
+ */
411
+ function buildCustomPropertyStatesDefaultValues(
412
+ placementElement: ExtractedElement | undefined,
413
+ stateNames: string[],
414
+ varName: string,
415
+ defaultValue: string | undefined,
416
+ ): Record<string, string> | undefined {
417
+ const matcherData = placementElement?.extractorData.get('css-matcher') as MatchedCssData | undefined
418
+ const statesDefaultValues: Record<string, string> = {}
419
+ for (const stateName of stateNames) {
420
+ const stateValue = matcherData?.stateCustomProperties?.[stateName]?.[varName]
421
+ if (stateValue !== undefined && stateValue !== defaultValue) statesDefaultValues[stateName] = stateValue
422
+ }
423
+ return Object.keys(statesDefaultValues).length > 0 ? statesDefaultValues : undefined
424
+ }
425
+
426
+ /**
427
+ * The custom properties an element declares under each state it exposes. Declarations under a
428
+ * CSS state the element has no `states` entry for are left out — there is nowhere to attach them.
429
+ */
430
+ function exposedStateCustomProperties(
431
+ element: ExtractedElement,
432
+ statesByTraceId: StatesByTraceId,
433
+ ): Record<string, string>[] {
434
+ const matcherData = element.extractorData.get('css-matcher') as MatchedCssData | undefined
435
+ return stateNamesOf(statesByTraceId, element.traceId).flatMap((stateName) => {
436
+ const stateCustomProperties = matcherData?.stateCustomProperties?.[stateName]
437
+ return stateCustomProperties ? [stateCustomProperties] : []
438
+ })
439
+ }
440
+
441
+ /** Collects every variable declared under an exposed state anywhere in the element tree. */
442
+ function collectStateDeclaredVarNames(elements: ExtractedElement[], statesByTraceId: StatesByTraceId): Set<string> {
443
+ const varNames = new Set<string>()
444
+ for (const element of elements) {
445
+ for (const stateCustomProperties of exposedStateCustomProperties(element, statesByTraceId)) {
446
+ for (const varName of Object.keys(stateCustomProperties)) varNames.add(varName)
447
+ }
448
+ for (const varName of collectStateDeclaredVarNames(element.children, statesByTraceId)) varNames.add(varName)
449
+ }
450
+ return varNames
451
+ }
452
+
453
+ function buildElementsByTraceId(elements: ExtractedElement[]): Map<string, ExtractedElement> {
454
+ const elementsByTraceId = new Map<string, ExtractedElement>()
455
+ for (const element of elements) {
456
+ elementsByTraceId.set(element.traceId, element)
457
+ for (const [traceId, descendant] of buildElementsByTraceId(element.children)) {
458
+ elementsByTraceId.set(traceId, descendant)
459
+ }
460
+ }
461
+ return elementsByTraceId
462
+ }
463
+
367
464
  /**
368
465
  * Merges @property registrations across all of the component's CSS files, keyed by the
369
466
  * `--`-prefixed variable name. The first file to register a variable wins.
@@ -393,42 +490,6 @@ function buildCustomEnum(registration: RegisteredCustomProperty | undefined): Cu
393
490
  }
394
491
  }
395
492
 
396
- function parseFiniteDescriptor(rawValue: string | undefined): number | undefined {
397
- const trimmed = rawValue?.trim()
398
- if (!trimmed) return undefined
399
- const parsed = Number(trimmed)
400
- return Number.isFinite(parsed) ? parsed : undefined
401
- }
402
-
403
- /**
404
- * Builds the clamp the panel's number control applies, from an @property's range
405
- * descriptors. Anything unusable is dropped rather than passed through.
406
- */
407
- function buildCssNumber(
408
- registration: RegisteredCustomProperty | undefined,
409
- cssPropertyType: CssCustomPropertyItem['cssPropertyType'],
410
- ): CssNumber | undefined {
411
- if (!registration?.range || cssPropertyType !== CSS_PROPERTIES.CSS_DATA_TYPE.number) return undefined
412
-
413
- let min = parseFiniteDescriptor(registration.range.min)
414
- let max = parseFiniteDescriptor(registration.range.max)
415
- if (min !== undefined && max !== undefined && min >= max) {
416
- min = undefined
417
- max = undefined
418
- }
419
-
420
- const step = parseFiniteDescriptor(registration.range.step)
421
- const multiplier = step !== undefined && step > 0 ? step : undefined
422
-
423
- if (min === undefined && max === undefined && multiplier === undefined) return undefined
424
-
425
- return {
426
- ...(min !== undefined && { min: String(min) }),
427
- ...(max !== undefined && { max: String(max) }),
428
- ...(multiplier !== undefined && { multiplier: String(multiplier) }),
429
- }
430
- }
431
-
432
493
  function getVarPropertyTypeFromCssInfos(
433
494
  varName: string,
434
495
  defaultValue: string,
@@ -516,14 +577,24 @@ function findNearestCommonAncestor(traceIds: Set<string>, parentMap: Map<string,
516
577
  }
517
578
 
518
579
  /**
519
- * Finds every surviving element whose matched CSS declares the given custom property.
580
+ * Finds every surviving element whose matched CSS declares the given custom property,
581
+ * in its regular appearance or in any state it exposes.
520
582
  */
521
- function findDefiningElements(elements: ExtractedElement[], varName: string): Set<string> {
583
+ function findDefiningElements(
584
+ elements: ExtractedElement[],
585
+ varName: string,
586
+ statesByTraceId: StatesByTraceId,
587
+ ): Set<string> {
522
588
  const definingTraceIds = new Set<string>()
523
589
  for (const element of elements) {
524
590
  const matcherData = element.extractorData.get('css-matcher') as MatchedCssData | undefined
525
- if (matcherData?.customProperties[varName] !== undefined) definingTraceIds.add(element.traceId)
526
- for (const traceId of findDefiningElements(element.children, varName)) definingTraceIds.add(traceId)
591
+ const declaresInState = exposedStateCustomProperties(element, statesByTraceId).some(
592
+ (stateCustomProperties) => stateCustomProperties[varName] !== undefined,
593
+ )
594
+ if (matcherData?.customProperties[varName] !== undefined || declaresInState) definingTraceIds.add(element.traceId)
595
+ for (const traceId of findDefiningElements(element.children, varName, statesByTraceId)) {
596
+ definingTraceIds.add(traceId)
597
+ }
527
598
  }
528
599
  return definingTraceIds
529
600
  }
@@ -533,22 +604,39 @@ function findDefiningElements(elements: ExtractedElement[], varName: string): Se
533
604
  // ─────────────────────────────────────────────────────────────────────────────
534
605
 
535
606
  /**
536
- * Returns the CSS property values matched for this specific element's selector(s).
537
- * Reads from css-matcher.matches which is populated by matchCssSelectors.
538
- * When multiple rules match (e.g. .foo and .foo.dark), later match wins for same property.
607
+ * The CSS property values (camelCased names) declared by one layer of matches — the regular
608
+ * matches or one state's matches — split by whether each declaration sits inside an at-rule.
539
609
  */
540
- function getMatchedPropertyValues(element: ExtractedElement): Map<string, string> {
541
- const matcherData = element.extractorData.get('css-matcher') as MatchedCssData | undefined
542
- const matches = matcherData?.matches
543
- if (!matches || matches.length === 0) return new Map()
610
+ interface LayerValues {
611
+ topLevel: Map<string, string>
612
+ inAtRule: Map<string, string>
613
+ }
544
614
 
545
- const values = new Map<string, string>()
546
- for (const match of matches) {
615
+ /**
616
+ * Collects one layer's property values. When multiple rules match (e.g. .foo and .foo.dark),
617
+ * later match wins for same property within each half of the layer.
618
+ */
619
+ function collectPropertyValues(matches: CssSelectorMatch[] | undefined): LayerValues {
620
+ const layerValues: LayerValues = { topLevel: new Map(), inAtRule: new Map() }
621
+ for (const match of matches ?? []) {
547
622
  for (const prop of match.properties) {
548
- values.set(camelCase(prop.name), prop.value)
623
+ const targetValues = prop.inAtRule ? layerValues.inAtRule : layerValues.topLevel
624
+ targetValues.set(camelCase(prop.name), prop.value)
549
625
  }
550
626
  }
551
- return values
627
+ return layerValues
628
+ }
629
+
630
+ /**
631
+ * Resolves a manifest default from one layer, ignoring any `var()` fallback. Top-level
632
+ * declarations are resolved first, so a conditional at-rule (`@media`) value — even a longhand
633
+ * under a top-level shorthand — only fills in what the top level leaves undeclared: the
634
+ * manifest default describes the unconditional appearance.
635
+ */
636
+ function resolveLayerDefault(propName: string, layerValues: LayerValues): string | undefined {
637
+ const resolvedValue =
638
+ resolveLonghand(propName, layerValues.topLevel) ?? resolveLonghand(propName, layerValues.inAtRule)
639
+ return resolvedValue !== undefined ? stripVarFallback(resolvedValue) : undefined
552
640
  }
553
641
 
554
642
  const { CSS_PROPERTY_TYPE, DISPLAY_VALUE } = CSS_PROPERTIES
@@ -594,6 +682,7 @@ const ROOT_EXCLUDED_CSS_PROPERTIES = new Set<string>([
594
682
  function buildCssProperties(
595
683
  element: ExtractedElement | undefined,
596
684
  isRootElement: boolean,
685
+ stateNames: string[],
597
686
  ): Record<string, CssPropertyItem> {
598
687
  const result: Record<string, CssPropertyItem> = {}
599
688
 
@@ -602,47 +691,78 @@ function buildCssProperties(
602
691
  if (!decidedProperties || decidedProperties.length === 0) {
603
692
  return result
604
693
  }
605
- const cssPropertyValues = element ? getMatchedPropertyValues(element) : new Map<string, string>()
694
+ const matcherData = element?.extractorData.get('css-matcher') as MatchedCssData | undefined
695
+ const cssPropertyValues = collectPropertyValues(matcherData?.matches)
696
+ // Each state layer is resolved on its own — merging it over the regular values would let
697
+ // a regular longhand (`padding-top`) beat a state shorthand (`padding`).
698
+ const stateLayers = stateNames.flatMap((stateName) => {
699
+ const stateMatches = matcherData?.stateMatches?.[stateName]
700
+ return stateMatches ? [{ stateName, values: collectPropertyValues(stateMatches) }] : []
701
+ })
606
702
  for (const propName of decidedProperties) {
607
703
  if (isRootElement && ROOT_EXCLUDED_CSS_PROPERTIES.has(propName)) {
608
704
  continue
609
705
  }
610
706
  if (propName === CSS_PROPERTY_TYPE.display && element) {
611
707
  if (!isRootElement) {
612
- result[propName] = buildDisplayProperty(element)
708
+ result[propName] = buildDisplayProperty(element, stateNames)
613
709
  }
614
710
  continue
615
711
  }
616
- const resolvedValue = resolveLonghand(propName, cssPropertyValues)
617
- const defaultValue = resolvedValue !== undefined ? stripVarFallback(resolvedValue) : undefined
712
+ const defaultValue = resolveLayerDefault(propName, cssPropertyValues)
713
+ const statesDefaultValues: Record<string, string> = {}
714
+ for (const stateLayer of stateLayers) {
715
+ const stateDefaultValue = resolveLayerDefault(propName, stateLayer.values)
716
+ if (stateDefaultValue !== undefined && stateDefaultValue !== defaultValue) {
717
+ statesDefaultValues[stateLayer.stateName] = stateDefaultValue
718
+ }
719
+ }
618
720
  result[propName] = {
619
721
  ...(defaultValue !== undefined && { defaultValue }),
722
+ ...(Object.keys(statesDefaultValues).length > 0 && { statesDefaultValues }),
620
723
  }
621
724
  }
622
725
 
623
726
  return result
624
727
  }
625
728
 
626
- function buildDisplayProperty(element: ExtractedElement): CssPropertyItem {
729
+ function buildDisplayProperty(element: ExtractedElement, stateNames: string[]): CssPropertyItem {
627
730
  const matcherData = element.extractorData.get('css-matcher') as MatchedCssData | undefined
628
731
  const resolvedFromCss = matcherData ? resolveDisplayValue(matcherData) : undefined
629
732
  const hasExplicitDisplayDeclaration =
630
733
  matcherData?.matches.some((match) => match.properties.some((property) => property.name === 'display')) ?? false
631
734
 
632
- if (hasExplicitDisplayDeclaration && resolvedFromCss === undefined) {
633
- return {
634
- display: {
635
- displayValues: [DISPLAY_VALUE.none],
636
- },
637
- }
735
+ // An explicit but unresolvable regular display leaves only `none` as a known option.
736
+ const isRegularDisplayUnresolved = hasExplicitDisplayDeclaration && resolvedFromCss === undefined
737
+ const currentEnumValue = isRegularDisplayUnresolved
738
+ ? undefined
739
+ : toDisplayEnumValue(resolvedFromCss ?? getDefaultDisplayForTag(element.tag))
740
+ const displayValues: DisplayEnumValue[] = currentEnumValue
741
+ ? [DISPLAY_VALUE.none, currentEnumValue]
742
+ : [DISPLAY_VALUE.none]
743
+
744
+ // Each state's effective display becomes its state default, and is offered as an option too.
745
+ // The state is resolved over the regular matches with its custom properties merged in, so a
746
+ // state that only changes the variable a regular `display: var(--layout)` reads still counts;
747
+ // a state's own `display` wins on specificity (and, on a tie, by coming later).
748
+ const statesDefaultValues: Record<string, DisplayEnumValue> = {}
749
+ for (const stateName of stateNames) {
750
+ const stateMatches = matcherData?.stateMatches?.[stateName]
751
+ const stateCustomProperties = matcherData?.stateCustomProperties?.[stateName]
752
+ if (!stateMatches && !stateCustomProperties) continue
753
+ const stateDisplayValue = resolveDisplayValue({
754
+ matches: [...(matcherData?.matches ?? []), ...(stateMatches ?? [])],
755
+ customProperties: { ...matcherData?.customProperties, ...stateCustomProperties },
756
+ })
757
+ if (stateDisplayValue === undefined) continue
758
+ const stateEnumValue = toDisplayEnumValue(stateDisplayValue)
759
+ if (stateEnumValue === currentEnumValue) continue
760
+ statesDefaultValues[stateName] = stateEnumValue
761
+ if (!displayValues.includes(stateEnumValue)) displayValues.push(stateEnumValue)
638
762
  }
639
763
 
640
- const currentValue = resolvedFromCss ?? getDefaultDisplayForTag(element.tag)
641
- const currentEnumValue = toDisplayEnumValue(currentValue)
642
-
643
764
  return {
644
- display: {
645
- displayValues: [DISPLAY_VALUE.none, currentEnumValue],
646
- },
765
+ display: { displayValues },
766
+ ...(Object.keys(statesDefaultValues).length > 0 && { statesDefaultValues }),
647
767
  }
648
768
  }
@@ -121,6 +121,55 @@ describe('parseCss — getStateClasses', () => {
121
121
  })
122
122
  })
123
123
 
124
+ describe('parseCss — getSelectorStateScope', () => {
125
+ // Selectors are keyed by css-tree's generated form, so look the single rule up by its key.
126
+ function scopeOf(selector: string) {
127
+ const api = parseCss(`${selector} { color: red; }`)
128
+ const [generatedSelector] = api.getAllProperties().keys()
129
+ return api.getSelectorStateScope(generatedSelector)
130
+ }
131
+
132
+ it.each([
133
+ ['.root', { kind: 'regular' }],
134
+ ['.root:hover', { kind: 'state', stateName: 'hover' }],
135
+ ['.root:global(.button--hover)', { kind: 'state', stateName: 'hover' }],
136
+ ['.root:global(.button--active)', { kind: 'state', stateName: 'active' }],
137
+ ['.root:active', { kind: 'state', stateName: 'active' }],
138
+ ['.root:focus-visible', { kind: 'state', stateName: 'focus' }],
139
+ ['.root:global(.button--featured)', { kind: 'state', stateName: 'featured' }],
140
+ ['.root:global(.button--hover):hover', { kind: 'state', stateName: 'hover' }],
141
+ ['.root:not([dir])', { kind: 'regular' }],
142
+ ['.root:not(:disabled)', { kind: 'regular' }],
143
+ ['.root:first-child', { kind: 'regular' }],
144
+ ['.root:hover .label', { kind: 'unmappedState' }],
145
+ ['.root:global(.button--active):hover', { kind: 'unmappedState' }],
146
+ ['.root:hover:focus-visible', { kind: 'unmappedState' }],
147
+ ['.root:is(:hover, :focus)', { kind: 'unmappedState' }],
148
+ ['.root:is(:global(.button--selected))', { kind: 'unmappedState' }],
149
+ ['.root:where(:global(.button--selected))', { kind: 'unmappedState' }],
150
+ ])('classifies %s', (selector, expectedScope) => {
151
+ expect(scopeOf(selector)).toEqual(expectedScope)
152
+ })
153
+
154
+ it('treats a global ancestor theme class as regular (no local base to modify)', () => {
155
+ expect(scopeOf(':global(.dark) .root')).toEqual({ kind: 'regular' })
156
+ })
157
+
158
+ it('classifies each selector of a selector list on its own', () => {
159
+ const api = parseCss('.root:global(.button--hover), .root:hover, .root { color: red; }')
160
+ expect(api.getSelectorStateScope('.root:global(.button--hover)')).toEqual({ kind: 'state', stateName: 'hover' })
161
+ expect(api.getSelectorStateScope('.root:hover')).toEqual({ kind: 'state', stateName: 'hover' })
162
+ expect(api.getSelectorStateScope('.root')).toEqual({ kind: 'regular' })
163
+ })
164
+
165
+ it('flags declarations inside a conditional at-rule', () => {
166
+ const api = parseCss('.root { color: red; } @media (min-width: 600px) { .root { background: blue; } }')
167
+ const properties = api.getPropertiesForSelector('.root')
168
+ expect(properties.find((property) => property.name === 'color')?.inAtRule).toBeUndefined()
169
+ expect(properties.find((property) => property.name === 'background')?.inAtRule).toBe(true)
170
+ })
171
+ })
172
+
124
173
  describe('parseCss — @property registration', () => {
125
174
  it('maps each syntax type to its cssPropertyType and reads the initial-value default', () => {
126
175
  const registered = parseCss(`