@wix/zero-config-implementation 1.76.0 → 1.78.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,4 +1,4 @@
1
- import { g as x } from "./index-CukAHhd0.js";
1
+ import { g as x } from "./index--6I20lGB.js";
2
2
  function h(r, a) {
3
3
  for (var i = 0; i < a.length; i++) {
4
4
  const o = a[i];
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ComponentType } from 'react';
2
+ import { CSS_PROPERTIES } from '@wix/react-component-schema';
2
3
  import { CssNode } from 'css-tree';
3
4
  import { default as default_2 } from 'typescript';
4
5
  import { EditorReactComponent } from '@wix/react-component-schema';
@@ -384,6 +385,13 @@ MergeErrorProps<ErrorPropsArg, ChildProps>,
384
385
  ChildCustomClass
385
386
  >
386
387
 
388
+ /**
389
+ * The subset of manifest `cssPropertyType` values a `@property` `syntax` can resolve to —
390
+ * the schema's CSS data types (`color`, `number`, `length`, …, `customEnum`). Derived from
391
+ * the schema so it stays in sync with `CssCustomPropertyItem['cssPropertyType']`.
392
+ */
393
+ declare type CssDataType = (typeof CSS_PROPERTIES.CSS_DATA_TYPE)[keyof typeof CSS_PROPERTIES.CSS_DATA_TYPE];
394
+
387
395
  /**
388
396
  * API returned by parseCss function for querying parsed CSS
389
397
  */
@@ -426,15 +434,23 @@ export declare interface CSSParserAPI {
426
434
  */
427
435
  getSelectorSpecificity: (selector: string) => [number, number, number] | null;
428
436
  /**
429
- * Determines the CSS property type for a custom property based on how it is used.
430
- * If all usages of varName are within the same CSS property, returns that property name.
431
- * If usages differ, returns the CSS data type inferred from the initial value
432
- * ('color', 'length', 'number', or 'string').
433
- * Returns undefined if the variable is never used via var().
437
+ * Determines the CSS property type for a custom property.
438
+ * A `@property` registration is the top-priority source (declared type). Otherwise
439
+ * falls back to usage: if all usages of varName are within the same CSS property,
440
+ * returns that property name; if usages differ, returns the CSS data type inferred
441
+ * from the initial value ('color', 'length', 'number', or 'string').
442
+ * Returns undefined if the variable is neither registered nor used via var().
434
443
  * @param varName - The CSS variable name (with or without --)
435
444
  * @param defaultValue - The initial value string of the custom property
436
445
  */
437
446
  getVarPropertyType: (varName: string, defaultValue: string) => string | undefined;
447
+ /**
448
+ * Returns every custom property registered via a `@property` at-rule, keyed by the
449
+ * variable name including its leading `--`. Each entry carries the type derived from
450
+ * `syntax`, the default from `initial-value`, and (for a pipe-separated ident list)
451
+ * the enum options.
452
+ */
453
+ getRegisteredCustomProperties: () => Map<string, RegisteredCustomProperty>;
438
454
  /**
439
455
  * Returns every `:global(.modifier)` design-state class found in the CSS, paired
440
456
  * with any native pseudo-class on the same base element within the same rule.
@@ -2423,6 +2439,27 @@ export declare interface ReactExtractor {
2423
2439
  onRenderComplete?(event: RenderCompleteEvent): void;
2424
2440
  }
2425
2441
 
2442
+ /**
2443
+ * A CSS custom property registered via the standard `@property` at-rule.
2444
+ * The `syntax` descriptor gives the type (and, for a pipe-separated ident list, a
2445
+ * finite set of options); the `initial-value` descriptor gives the default. Unlike
2446
+ * usage-inferred types, this is a declared source of truth and takes precedence.
2447
+ */
2448
+ declare interface RegisteredCustomProperty {
2449
+ /**
2450
+ * The manifest `cssPropertyType` derived from the `@property` `syntax` descriptor
2451
+ * (e.g. `color`, `number`, `length`, or `customEnum` for a pipe-separated ident list).
2452
+ */
2453
+ cssPropertyType: CssDataType;
2454
+ /** The default value from the `@property` `initial-value` descriptor, if declared. */
2455
+ defaultValue?: string;
2456
+ /**
2457
+ * The custom-ident options when `syntax` is a pipe-separated ident list
2458
+ * (`a | b | c`). Present only when `cssPropertyType` is `customEnum`.
2459
+ */
2460
+ enumOptions?: string[];
2461
+ }
2462
+
2426
2463
  /**
2427
2464
  * Event emitted after render completes.
2428
2465
  */
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { B as t, D as e, E as o, I as n, N as c, P as l, R as i, a as p, V as E, b as m, c as x, d as f, e as d, f as u, h as C, i as I, j as k, k as y, l as A, m as D, n as P, o as R, p as h, q as B, r as M, s as T, t as b, w } from "./index-CukAHhd0.js";
1
+ import { B as t, D as e, E as o, I as n, N as c, P as l, R as i, a as p, V as E, b as m, c as x, d as f, e as d, f as u, h as C, i as I, j as k, k as y, l as A, m as D, n as P, o as R, p as h, q as B, r as M, s as T, t as b, w } from "./index--6I20lGB.js";
2
2
  import "react";
3
3
  export {
4
4
  t as BaseError,
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "registry": "https://registry.npmjs.org/",
5
5
  "access": "public"
6
6
  },
7
- "version": "1.76.0",
7
+ "version": "1.78.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",
@@ -39,7 +39,7 @@
39
39
  }
40
40
  },
41
41
  "dependencies": {
42
- "@wix/builder-services-wrapper": "^1.42.0",
42
+ "@wix/builder-services-wrapper": "^1.56.0",
43
43
  "@wix/react-component-schema": "1.7.0"
44
44
  },
45
45
  "devDependencies": {
@@ -82,5 +82,5 @@
82
82
  ]
83
83
  }
84
84
  },
85
- "falconPackageHash": "961c9ff14e1a1aaf443e4e28497e18b4774e7858411e613df8e151d3"
85
+ "falconPackageHash": "7ea33e17604c8d13038271d43e3350d8bb9d9531ec0110efca2a7144"
86
86
  }
@@ -76,6 +76,7 @@ import {
76
76
  setCreateElementInterceptor,
77
77
  setJsxInterceptors,
78
78
  } from './react-runtime-interceptor'
79
+ import { getActiveReactModule, setActiveReactModule } from './react-runtime-loader'
79
80
 
80
81
  export const TRACE_ATTR = 'data-trace-id'
81
82
 
@@ -132,6 +133,84 @@ function createInterceptor(
132
133
  }
133
134
  }
134
135
 
136
+ // ─────────────────────────────────────────────────────────────────────────────
137
+ // Dispatcher bridge
138
+ // ─────────────────────────────────────────────────────────────────────────────
139
+
140
+ const REACT_18_INTERNALS_KEY = '__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED'
141
+ const REACT_19_INTERNALS_KEY = '__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE'
142
+
143
+ function readReactInternals(reactModule: unknown): Record<string, unknown> | undefined {
144
+ if (typeof reactModule !== 'object' || reactModule === null) {
145
+ return undefined
146
+ }
147
+
148
+ const reactRecord = reactModule as Record<string, unknown>
149
+ const internals = reactRecord[REACT_18_INTERNALS_KEY] ?? reactRecord[REACT_19_INTERNALS_KEY]
150
+ return typeof internals === 'object' && internals !== null ? (internals as Record<string, unknown>) : undefined
151
+ }
152
+
153
+ /**
154
+ * Forwards the host React's hook dispatcher to the user React so host-bound hook
155
+ * callers (e.g. WixServicesWrapper) resolve the dispatcher the user's react-dom
156
+ * installs during SSR. Returns a function that restores the host React.
157
+ */
158
+ function bridgeHostDispatcherToUserReact(hostReact: unknown, userReact: unknown): () => void {
159
+ const hostInternals = readReactInternals(hostReact)
160
+ const userInternals = readReactInternals(userReact)
161
+ if (!hostInternals || !userInternals) {
162
+ return () => {}
163
+ }
164
+
165
+ // React 18 exposes a `{ current }` holder captured by reference at load time, so
166
+ // forward its `current` to the user holder rather than swapping the holder itself.
167
+ if (hostInternals.ReactCurrentDispatcher && userInternals.ReactCurrentDispatcher) {
168
+ const hostDispatcherHolder = hostInternals.ReactCurrentDispatcher as Record<string, unknown>
169
+ const userDispatcherHolder = userInternals.ReactCurrentDispatcher as Record<string, unknown>
170
+ if (hostDispatcherHolder === userDispatcherHolder) {
171
+ return () => {}
172
+ }
173
+
174
+ const previousDescriptor = Object.getOwnPropertyDescriptor(hostDispatcherHolder, 'current')
175
+ Object.defineProperty(hostDispatcherHolder, 'current', {
176
+ configurable: true,
177
+ get: () => userDispatcherHolder.current,
178
+ set: (dispatcher) => {
179
+ userDispatcherHolder.current = dispatcher
180
+ },
181
+ })
182
+ return () => {
183
+ Object.defineProperty(
184
+ hostDispatcherHolder,
185
+ 'current',
186
+ previousDescriptor ?? { configurable: true, enumerable: true, value: null, writable: true },
187
+ )
188
+ }
189
+ }
190
+
191
+ // React 19 keeps the dispatcher directly on the internals object as `H`, so
192
+ // forward that property to the user internals.
193
+ if ('H' in userInternals) {
194
+ const previousDescriptor = Object.getOwnPropertyDescriptor(hostInternals, 'H')
195
+ Object.defineProperty(hostInternals, 'H', {
196
+ configurable: true,
197
+ get: () => userInternals.H,
198
+ set: (dispatcher) => {
199
+ userInternals.H = dispatcher
200
+ },
201
+ })
202
+ return () => {
203
+ Object.defineProperty(
204
+ hostInternals,
205
+ 'H',
206
+ previousDescriptor ?? { configurable: true, enumerable: true, value: undefined, writable: true },
207
+ )
208
+ }
209
+ }
210
+
211
+ return () => {}
212
+ }
213
+
135
214
  // ─────────────────────────────────────────────────────────────────────────────
136
215
  // Renderer
137
216
  // ─────────────────────────────────────────────────────────────────────────────
@@ -248,6 +327,12 @@ export function renderWithExtractors(
248
327
  const interceptedJsxs = createInterceptor(originalJsxs as ElementCreator, listeners, getNextId, store)
249
328
  const interceptedJsxDEV = createInterceptor(originalJsxDEV as ElementCreator, listeners, getNextId, store)
250
329
 
330
+ // The shim reads globalThis[REACT_KEY] on every hook call, so pointing it at
331
+ // userReact ensures ESM hooks use the same dispatcher that SSR drives.
332
+ const previousActiveReactModule = getActiveReactModule()
333
+ // Restores the host React's hook dispatcher, bridged to the user React below.
334
+ let restoreHostDispatcher: () => void = () => {}
335
+
251
336
  try {
252
337
  // @ts-expect-error Monkey-patch createElement for default-import callers
253
338
  React.createElement = interceptedCreateElement
@@ -268,6 +353,9 @@ export function renderWithExtractors(
268
353
  // Patch user-bundle React modules when they are distinct instances
269
354
  if (userReact) {
270
355
  userReact.createElement = interceptedCreateElement
356
+ setActiveReactModule(userReact)
357
+ // Host-bound hook callers (e.g. WixServicesWrapper) need the user React's dispatcher too.
358
+ restoreHostDispatcher = bridgeHostDispatcherToUserReact(React, userReact)
271
359
  }
272
360
  if (userJsxRuntime) {
273
361
  userJsxRuntime.jsx = interceptedJsx
@@ -281,6 +369,8 @@ export function renderWithExtractors(
281
369
  const element = renderingReact.createElement(Component, componentProps as Record<string, unknown>)
282
370
  return (userRenderToStaticMarkup ?? renderToStaticMarkup)(element)
283
371
  } finally {
372
+ restoreHostDispatcher()
373
+ setActiveReactModule(previousActiveReactModule)
284
374
  // Restore originals
285
375
  // @ts-expect-error Restore monkey-patched createElement
286
376
  React.createElement = originalCreateElement
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
3
3
  import { parseCss } from '../information-extractors/css/parse'
4
4
  import type { MatchedCssData } from '../information-extractors/css/types'
5
5
  import type { ExtractedElement } from '../information-extractors/react'
6
- import type { ComponentInfoWithCss } from '../manifest-pipeline'
6
+ import type { ComponentInfoWithCss, ExtractedCssInfo } from '../manifest-pipeline'
7
7
  import { toEditorReactComponent } from './to-editor-component'
8
8
 
9
9
  function matcherDataFromCss(declarations: string): MatchedCssData {
@@ -86,3 +86,156 @@ describe('toEditorReactComponent display conversion', () => {
86
86
  })
87
87
  })
88
88
  })
89
+
90
+ function cssInfoFromStylesheet(cssString: string): ExtractedCssInfo {
91
+ const api = parseCss(cssString)
92
+ const properties = new Map<string, string>()
93
+ const customProperties = new Map<string, string>()
94
+
95
+ for (const [, selectorProperties] of api.getAllProperties()) {
96
+ for (const property of selectorProperties) {
97
+ if (property.name.startsWith('--')) {
98
+ customProperties.set(property.name, property.value)
99
+ } else {
100
+ properties.set(property.name, property.value)
101
+ }
102
+ }
103
+ }
104
+
105
+ return { filePath: 'property-tokens.css', api, properties, customProperties, isCssModule: false }
106
+ }
107
+
108
+ function createComponentWithCss(cssString: string): ComponentInfoWithCss {
109
+ const rootElement: ExtractedElement = {
110
+ traceId: 'trace-root',
111
+ name: 'root',
112
+ tag: 'div',
113
+ attributes: {},
114
+ extractorData: new Map<string, unknown>(),
115
+ children: [],
116
+ }
117
+
118
+ return {
119
+ componentName: 'PropertyTokens',
120
+ props: {},
121
+ elements: [rootElement],
122
+ propUsages: new Map(),
123
+ css: [cssInfoFromStylesheet(cssString)],
124
+ varUsedByTraceId: new Map(),
125
+ }
126
+ }
127
+
128
+ describe('toEditorReactComponent — @property design tokens', () => {
129
+ it('emits typed cssCustomProperties from @property, defaulting from initial-value with no element declaration', () => {
130
+ const component = createComponentWithCss(`
131
+ @property --sdf-safelight-color { syntax: "<color>"; inherits: true; initial-value: #F02011; }
132
+ @property --sdf-contrast { syntax: "<number>"; inherits: true; initial-value: 1.5; }
133
+ `)
134
+
135
+ const customProps = toEditorReactComponent(component).editorElement?.cssCustomProperties
136
+
137
+ expect(customProps?.['sdf-safelight-color']).toEqual({
138
+ displayName: 'Sdf Safelight Color',
139
+ defaultValue: '#F02011',
140
+ cssPropertyType: 'color',
141
+ })
142
+ expect(customProps?.['sdf-contrast']).toEqual({
143
+ displayName: 'Sdf Contrast',
144
+ defaultValue: '1.5',
145
+ cssPropertyType: 'number',
146
+ })
147
+ })
148
+
149
+ it('emits a customEnum dropdown for a pipe-separated ident-list @property', () => {
150
+ const component = createComponentWithCss(`
151
+ @property --sdf-palette { syntax: "warm | cool | neutral"; inherits: false; initial-value: warm; }
152
+ `)
153
+
154
+ const customProps = toEditorReactComponent(component).editorElement?.cssCustomProperties
155
+
156
+ expect(customProps?.['sdf-palette']).toEqual({
157
+ displayName: 'Sdf Palette',
158
+ defaultValue: 'warm',
159
+ cssPropertyType: 'customEnum',
160
+ customEnum: {
161
+ cssPropertyType: 'string',
162
+ options: [
163
+ { value: 'warm', displayName: 'Warm' },
164
+ { value: 'cool', displayName: 'Cool' },
165
+ { value: 'neutral', displayName: 'Neutral' },
166
+ ],
167
+ },
168
+ })
169
+ })
170
+
171
+ it('drops a @property whose type has no matching schema cssPropertyType', () => {
172
+ const component = createComponentWithCss(`
173
+ @property --sdf-hero-image { syntax: "<image>"; inherits: false; }
174
+ @property --sdf-safelight-color { syntax: "<color>"; inherits: true; initial-value: #F02011; }
175
+ `)
176
+
177
+ const customProps = toEditorReactComponent(component).editorElement?.cssCustomProperties
178
+
179
+ expect(customProps?.['sdf-hero-image']).toBeUndefined()
180
+ expect(customProps?.['sdf-safelight-color']).toBeDefined()
181
+ })
182
+
183
+ it('emits @property-typed and usage-inferred custom properties side by side', () => {
184
+ const matcherData: MatchedCssData = {
185
+ matches: [{ selector: '.root', properties: [], specificity: [0, 1, 0] }],
186
+ customProperties: { '--spacing': '8px' },
187
+ }
188
+ const rootElement: ExtractedElement = {
189
+ traceId: 'trace-root',
190
+ name: 'root',
191
+ tag: 'div',
192
+ attributes: {},
193
+ extractorData: new Map<string, unknown>([['css-matcher', matcherData]]),
194
+ children: [],
195
+ }
196
+ const component: ComponentInfoWithCss = {
197
+ componentName: 'MixedTokens',
198
+ props: {},
199
+ elements: [rootElement],
200
+ propUsages: new Map(),
201
+ css: [
202
+ cssInfoFromStylesheet(`
203
+ @property --brand-color { syntax: "<color>"; inherits: false; initial-value: #123456; }
204
+ .root { --spacing: 8px; padding: var(--spacing); color: var(--brand-color); }
205
+ `),
206
+ ],
207
+ varUsedByTraceId: new Map([
208
+ ['--brand-color', new Set(['trace-root'])],
209
+ ['--spacing', new Set(['trace-root'])],
210
+ ]),
211
+ }
212
+
213
+ const customProps = toEditorReactComponent(component).editorElement?.cssCustomProperties
214
+
215
+ // --brand-color is typed from its @property declaration; --spacing keeps the existing
216
+ // usage-inference behavior (single usage in `padding` → the `padding` property type).
217
+ expect(customProps?.['brand-color']).toEqual({
218
+ displayName: 'Brand Color',
219
+ defaultValue: '#123456',
220
+ cssPropertyType: 'color',
221
+ })
222
+ expect(customProps?.spacing).toEqual({
223
+ displayName: 'Spacing',
224
+ defaultValue: '8px',
225
+ cssPropertyType: 'padding',
226
+ })
227
+ })
228
+
229
+ it('does not leak a registered @property variable into cssProperties', () => {
230
+ const component = createComponentWithCss(`
231
+ @property --sdf-safelight-color { syntax: "<color>"; inherits: true; initial-value: #F02011; }
232
+ .root { --sdf-safelight-color: #F02011; }
233
+ `)
234
+
235
+ const editorElement = toEditorReactComponent(component).editorElement
236
+
237
+ expect(editorElement?.cssCustomProperties?.['sdf-safelight-color']).toBeDefined()
238
+ expect(editorElement?.cssProperties?.['sdf-safelight-color']).toBeUndefined()
239
+ expect(editorElement?.cssProperties?.['--sdf-safelight-color']).toBeUndefined()
240
+ })
241
+ })
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  CssCustomPropertyItem,
3
3
  CssPropertyItem,
4
+ CustomPropertyEnum,
4
5
  DataItem,
5
6
  Display,
6
7
  EditorElement,
@@ -11,7 +12,7 @@ import type {
11
12
  import { CSS_PROPERTIES, ELEMENTS } from '@wix/react-component-schema'
12
13
  import { camelCase } from 'case-anything'
13
14
  import type { ComponentInfoWithCss } from '../index'
14
- import type { MatchedCssData } from '../information-extractors/css/types'
15
+ import type { MatchedCssData, RegisteredCustomProperty } from '../information-extractors/css/types'
15
16
  import type {
16
17
  CoupledComponentInfo,
17
18
  CoupledProp,
@@ -220,6 +221,11 @@ function buildNearestCommonAncestorCustomProps(
220
221
  component: ComponentInfoWithCss,
221
222
  ): Map<string, Record<string, CssCustomPropertyItem>> {
222
223
  const parentMap = buildParentMap(component.elements)
224
+ const rootTraceId = component.elements[0]?.traceId
225
+
226
+ // Custom properties registered via the standard @property at-rule — the declared
227
+ // source of each variable's type, default, and (for ident lists) enum options.
228
+ const registeredByVar = collectRegisteredCustomProperties(component)
223
229
 
224
230
  // Collect default values from matched elements only — this excludes values from theme/modifier
225
231
  // selectors that were not applied during mock rendering (e.g. .component.dark when variant='light').
@@ -235,31 +241,47 @@ function buildNearestCommonAncestorCustomProps(
235
241
  }
236
242
  }
237
243
 
244
+ // Emit every declared variable plus every @property registration — a registered var
245
+ // may have no element-level declaration yet still needs a typed control.
246
+ const varNames = new Set<string>([...allCustomPropertyValues.keys(), ...registeredByVar.keys()])
247
+
238
248
  const nearestCommonAncestorCustomProps = new Map<string, Record<string, CssCustomPropertyItem>>()
239
249
 
240
- for (const [varName, defaultValue] of allCustomPropertyValues) {
250
+ for (const varName of varNames) {
241
251
  if (varName === '--display') continue
242
252
 
253
+ const registration = registeredByVar.get(varName)
254
+ // Prefer a concrete declared value; fall back to the @property initial-value so a
255
+ // component doesn't need an element-level `--x: ...` declaration.
256
+ const defaultValue = allCustomPropertyValues.get(varName) ?? registration?.defaultValue
257
+
243
258
  const usedByTraceIds = component.varUsedByTraceId.get(varName) ?? new Set<string>()
244
259
 
245
260
  let nearestCommonAncestorTraceId: string | undefined
246
261
  if (usedByTraceIds.size > 0) {
247
262
  nearestCommonAncestorTraceId = findNearestCommonAncestor(usedByTraceIds, parentMap)
248
263
  } else {
249
- // Fallback: place on the element whose CSS selector defines the variable
250
- nearestCommonAncestorTraceId = findDefiningElement(component.elements, varName)
264
+ // Fallback: the element whose CSS selector defines the variable; failing that, a
265
+ // registered @property with no var() usage anchors on the component root.
266
+ nearestCommonAncestorTraceId =
267
+ findDefiningElement(component.elements, varName) ?? (registration ? rootTraceId : undefined)
251
268
  }
252
269
 
253
270
  if (!nearestCommonAncestorTraceId) continue
254
271
 
255
- const cssPropertyType = getVarPropertyTypeFromCssInfos(varName, defaultValue, component.css)
272
+ // A @property registration is the top-priority type source; otherwise infer from usage.
273
+ const cssPropertyType = registration
274
+ ? registration.cssPropertyType
275
+ : getVarPropertyTypeFromCssInfos(varName, defaultValue ?? '', component.css)
276
+ const customEnum = buildCustomEnum(registration)
256
277
  const cleanVarName = varName.startsWith('--') ? varName.slice(2) : varName
257
278
 
258
279
  const existingProps = nearestCommonAncestorCustomProps.get(nearestCommonAncestorTraceId) ?? {}
259
280
  existingProps[cleanVarName] = {
260
281
  displayName: formatDisplayName(cleanVarName),
261
- defaultValue,
282
+ ...(defaultValue !== undefined && { defaultValue }),
262
283
  ...(cssPropertyType !== undefined && { cssPropertyType }),
284
+ ...(customEnum && { customEnum }),
263
285
  }
264
286
  nearestCommonAncestorCustomProps.set(nearestCommonAncestorTraceId, existingProps)
265
287
  }
@@ -267,6 +289,35 @@ function buildNearestCommonAncestorCustomProps(
267
289
  return nearestCommonAncestorCustomProps
268
290
  }
269
291
 
292
+ /**
293
+ * Merges @property registrations across all of the component's CSS files, keyed by the
294
+ * `--`-prefixed variable name. The first file to register a variable wins.
295
+ */
296
+ function collectRegisteredCustomProperties(component: ComponentInfoWithCss): Map<string, RegisteredCustomProperty> {
297
+ const merged = new Map<string, RegisteredCustomProperty>()
298
+ for (const cssInfo of component.css) {
299
+ for (const [varName, registration] of cssInfo.api.getRegisteredCustomProperties()) {
300
+ if (!merged.has(varName)) merged.set(varName, registration)
301
+ }
302
+ }
303
+ return merged
304
+ }
305
+
306
+ /**
307
+ * Builds a `customEnum` (a named-option dropdown) from an @property whose `syntax` is a
308
+ * pipe-separated ident list. Option labels are humanized from the idents.
309
+ */
310
+ function buildCustomEnum(registration: RegisteredCustomProperty | undefined): CustomPropertyEnum | undefined {
311
+ if (!registration?.enumOptions || registration.enumOptions.length === 0) return undefined
312
+ return {
313
+ cssPropertyType: CSS_PROPERTIES.CSS_DATA_TYPE.string,
314
+ options: registration.enumOptions.map((value) => ({
315
+ value,
316
+ displayName: formatDisplayName(value),
317
+ })),
318
+ }
319
+ }
320
+
270
321
  /**
271
322
  * Queries each CSS parser API for the property type of a variable and returns
272
323
  * the first defined result.
@@ -121,6 +121,105 @@ describe('parseCss — getStateClasses', () => {
121
121
  })
122
122
  })
123
123
 
124
+ describe('parseCss — @property registration', () => {
125
+ it('maps each syntax type to its cssPropertyType and reads the initial-value default', () => {
126
+ const registered = parseCss(`
127
+ @property --sdf-safelight-color { syntax: "<color>"; inherits: true; initial-value: #F02011; }
128
+ @property --sdf-contrast { syntax: "<number>"; inherits: true; initial-value: 1.5; }
129
+ @property --sdf-radius { syntax: "<length>"; inherits: false; initial-value: 8px; }
130
+ @property --sdf-scale { syntax: "<percentage>"; inherits: false; initial-value: 50%; }
131
+ @property --sdf-tilt { syntax: "<angle>"; inherits: false; initial-value: 90deg; }
132
+ @property --sdf-delay { syntax: "<time>"; inherits: false; initial-value: 200ms; }
133
+ @property --sdf-inset { syntax: "<length-percentage>"; inherits: false; initial-value: 10px; }
134
+ `).getRegisteredCustomProperties()
135
+
136
+ expect(registered.get('--sdf-safelight-color')).toEqual({ cssPropertyType: 'color', defaultValue: '#F02011' })
137
+ expect(registered.get('--sdf-contrast')).toEqual({ cssPropertyType: 'number', defaultValue: '1.5' })
138
+ expect(registered.get('--sdf-radius')).toEqual({ cssPropertyType: 'length', defaultValue: '8px' })
139
+ expect(registered.get('--sdf-scale')).toEqual({ cssPropertyType: 'percentage', defaultValue: '50%' })
140
+ expect(registered.get('--sdf-tilt')).toEqual({ cssPropertyType: 'angle', defaultValue: '90deg' })
141
+ expect(registered.get('--sdf-delay')).toEqual({ cssPropertyType: 'time', defaultValue: '200ms' })
142
+ expect(registered.get('--sdf-inset')).toEqual({ cssPropertyType: 'lengthPercentage', defaultValue: '10px' })
143
+ })
144
+
145
+ it('maps <integer> to number (there is no dedicated integer control)', () => {
146
+ const registered = parseCss(`
147
+ @property --sdf-steps { syntax: "<integer>"; inherits: false; initial-value: 3; }
148
+ `).getRegisteredCustomProperties()
149
+
150
+ expect(registered.get('--sdf-steps')).toEqual({ cssPropertyType: 'number', defaultValue: '3' })
151
+ })
152
+
153
+ it('drops @property registrations whose syntax has no matching schema cssPropertyType', () => {
154
+ // No matching schema cssPropertyType → dropped, not emitted with a fallback type.
155
+ const registered = parseCss(`
156
+ @property --sdf-universal { syntax: "*"; inherits: false; initial-value: anything; }
157
+ @property --sdf-image { syntax: "<image>"; inherits: false; }
158
+ @property --sdf-url { syntax: "<url>"; inherits: false; }
159
+ @property --sdf-resolution { syntax: "<resolution>"; inherits: false; initial-value: 96dpi; }
160
+ @property --sdf-transform { syntax: "<transform-function>"; inherits: false; }
161
+ @property --sdf-ident { syntax: "<custom-ident>"; inherits: false; initial-value: foo; }
162
+ @property --sdf-lone-keyword { syntax: "auto"; inherits: false; initial-value: auto; }
163
+ `).getRegisteredCustomProperties()
164
+
165
+ for (const droppedVarName of [
166
+ '--sdf-universal',
167
+ '--sdf-image',
168
+ '--sdf-url',
169
+ '--sdf-resolution',
170
+ '--sdf-transform',
171
+ '--sdf-ident',
172
+ '--sdf-lone-keyword',
173
+ ]) {
174
+ expect(registered.has(droppedVarName)).toBe(false)
175
+ }
176
+ expect(registered.size).toBe(0)
177
+ })
178
+
179
+ it('picks the first recognized <type> from a mixed type/keyword list', () => {
180
+ const registered = parseCss(`
181
+ @property --sdf-size { syntax: "<length> | auto"; inherits: false; initial-value: auto; }
182
+ @property --sdf-scale { syntax: "small | medium | <length>"; inherits: false; initial-value: medium; }
183
+ `).getRegisteredCustomProperties()
184
+
185
+ expect(registered.get('--sdf-size')).toEqual({ cssPropertyType: 'length', defaultValue: 'auto' })
186
+ expect(registered.get('--sdf-scale')).toEqual({ cssPropertyType: 'length', defaultValue: 'medium' })
187
+ })
188
+
189
+ it('turns a pipe-separated ident list into a customEnum with its option idents', () => {
190
+ const registered = parseCss(`
191
+ @property --sdf-palette { syntax: "warm | cool | neutral"; inherits: false; initial-value: warm; }
192
+ `).getRegisteredCustomProperties()
193
+
194
+ expect(registered.get('--sdf-palette')).toEqual({
195
+ cssPropertyType: 'customEnum',
196
+ enumOptions: ['warm', 'cool', 'neutral'],
197
+ defaultValue: 'warm',
198
+ })
199
+ })
200
+
201
+ it('strips list multipliers when resolving a type component', () => {
202
+ const registered = parseCss(`
203
+ @property --sdf-shadows { syntax: "<length>+"; inherits: false; initial-value: 0px; }
204
+ @property --sdf-stops { syntax: "<color>#"; inherits: false; initial-value: red; }
205
+ `).getRegisteredCustomProperties()
206
+
207
+ expect(registered.get('--sdf-shadows')).toEqual({ cssPropertyType: 'length', defaultValue: '0px' })
208
+ expect(registered.get('--sdf-stops')).toEqual({ cssPropertyType: 'color', defaultValue: 'red' })
209
+ })
210
+
211
+ it('lets a @property registration override the usage-inferred type', () => {
212
+ const api = parseCss(`
213
+ @property --sdf-intensity { syntax: "<number>"; inherits: false; initial-value: 1; }
214
+ .root { --sdf-intensity: 1; background-color: var(--sdf-intensity); }
215
+ `)
216
+
217
+ // Used only in background-color, which would otherwise infer 'backgroundColor';
218
+ // the @property declaration wins.
219
+ expect(api.getVarPropertyType('--sdf-intensity', '1')).toBe('number')
220
+ })
221
+ })
222
+
124
223
  describe('resolveCssPropertyValue', () => {
125
224
  it('uses the fallback when a referenced custom property is missing from the property-level map', () => {
126
225
  const [displayProperty] = parseCss(`