@fluixi-ui/fx 0.1.0-alpha.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fluixi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,164 @@
1
+ # @fluixi-ui/fx
2
+
3
+ Token-aware style objects for Fluixi UI — the engine behind the `fx` prop.
4
+
5
+ ```tsx
6
+ <Box fx={{ p: 4, bg: 'bgSubtle', radius: 'lg', _hover: { bg: 'bgMuted' } }} />
7
+ ```
8
+
9
+ No CSS-in-JS runtime, no dependencies. Values resolve to the `--flx-ui-*` custom
10
+ properties `@fluixi-ui/tokens` defines, so anything written with `fx` follows the
11
+ theme, the accent, and the skin — including a theme driven from `@fluixi-css`.
12
+
13
+ ## Values
14
+
15
+ A property's scale decides both which tokens a string key resolves against and
16
+ what a bare number means:
17
+
18
+ ```tsx
19
+ p: 3 // padding: var(--flx-ui-space-3) — space scale
20
+ m: -2 // margin: calc(var(--flx-ui-space-2) * -1)
21
+ width: 240 // width: 240px — sizing is pixels
22
+ zIndex: 3 // z-index: 3 — unitless stays bare
23
+ radius: 'lg' // border-radius: var(--flx-ui-radius-lg)
24
+ bg: 'primary' // background-color: var(--flx-ui-color-primary)
25
+ bg: 'accent-9'// background-color: var(--flx-ui-accent-9)
26
+ bg: '#ff0000' // background-color: #ff0000 — raw CSS always allowed
27
+ ```
28
+
29
+ Aliases are deliberately few — `p` `px` `py` `m` `mx` `my` `bg` `radius` `shadow`.
30
+ Everything else is the real CSS property name (`paddingTop`, not `pt`), so there's
31
+ no table to learn. `px`/`py` map to the logical properties and follow writing
32
+ direction.
33
+
34
+ ## Selectors
35
+
36
+ A closed set of `_`-prefixed keys. They expand to the library's real conventions,
37
+ not just the CSS pseudo — headless parts signal state through `data-*`/ARIA:
38
+
39
+ ```tsx
40
+ _disabled: { opacity: 'disabled' }
41
+ // :disabled, [disabled], [data-disabled]
42
+ ```
43
+
44
+ State: `_hover` `_active` `_focus` `_focusVisible` `_focusWithin` `_disabled`
45
+ `_checked` `_selected` `_expanded` `_open` `_invalid`
46
+ Pseudo-elements: `_before` `_after` `_placeholder` `_selection`
47
+ Structural: `_first` `_last` `_odd` `_even` `_notFirst` `_notLast` `_children`
48
+ Environment: `_dark` `_light` `_hc` `_rtl` `_motionReduce` `_motionSafe`
49
+
50
+ `_dark` targets `[data-theme='dark']` — the axis `createTheme` drives — and matches
51
+ the element itself as well as a themed ancestor, so scoped theming works.
52
+
53
+ The set being closed is the point: `fx` can't reach an app's class names, so a
54
+ component can never couple itself to consumer CSS.
55
+
56
+ Nesting composes:
57
+
58
+ ```tsx
59
+ _dark: { _hover: { bg: 'primaryHover' } }
60
+ ```
61
+
62
+ ## Responsive
63
+
64
+ Mobile-first objects keyed by breakpoint. `base` carries no media query:
65
+
66
+ ```tsx
67
+ fx={{ p: { base: 3, md: 5 } }}
68
+ ```
69
+
70
+ ## Container queries
71
+
72
+ Respond to the space a component is actually given rather than the viewport.
73
+ The container is an ancestor — an element can't query itself:
74
+
75
+ ```tsx
76
+ <Card fx={{ containerType: 'inline-size' }}>
77
+ <Stack fx={{ _container: { md: { flexDirection: 'row' } } }} />
78
+ </Card>
79
+ ```
80
+
81
+ Keys are a breakpoint name or a raw condition. Element-relative sizes often don't
82
+ line up with the page scale, so raw conditions are first-class:
83
+
84
+ ```tsx
85
+ _container: { '(min-width: 30rem)': { gap: 4 } }
86
+ ```
87
+
88
+ Name a container when the nearest one isn't the one you mean:
89
+
90
+ ```tsx
91
+ <aside fx={{ containerType: 'inline-size', containerName: 'panel' }}>
92
+ <div fx={{ _container: { name: 'panel', lg: { p: 6 } } }} />
93
+ </aside>
94
+ ```
95
+
96
+ At-rules nest rather than combine, so a container query and a media query can
97
+ wrap the same declarations.
98
+
99
+ ## Colour
100
+
101
+ Helpers emit CSS; they never compute a colour in JS. That matters here: tokens
102
+ are custom properties that re-resolve against `data-theme` / `data-accent`, so a
103
+ value computed at render would freeze whichever theme happened to be active.
104
+ `color-mix()` and relative colour syntax re-resolve on their own, so a derived
105
+ colour keeps following the theme, the accent and the skin.
106
+
107
+ ```tsx
108
+ import { alpha, mix, lighten, darken } from '@fluixi-ui/fx';
109
+
110
+ alpha('primary', 12) // color-mix(in oklab, var(--flx-ui-color-primary) 12%, transparent)
111
+ mix('primary', 'bg', 20) // 20% primary over the page background
112
+ lighten('primary', 8) // oklch(from … calc(l + 0.08) c h / alpha)
113
+ darken('border', 10)
114
+ ```
115
+
116
+ Every amount is a percentage, 0–100. `lighten`/`darken` shift lightness in OKLCH,
117
+ which keeps chroma and hue — a tint stays the same colour instead of drifting grey
118
+ the way mixing with white or black does. They need relative colour syntax
119
+ (Chrome 119+, Safari 16.4+, Firefox 128+); for a wider floor use
120
+ `mix(color, 'white', n)`.
121
+
122
+ ## How it lands on the page
123
+
124
+ Flat declarations and every `base` value go to the `style` attribute — most `fx`
125
+ usage never touches the stylesheet. Only selector keys and non-`base` breakpoints
126
+ need a rule; those are hashed, deduped, and inserted under `[data-fx="…"]` into
127
+ `@layer flx.fx`, which sits above components and skins so `fx` wins, and below
128
+ unlayered consumer CSS so an app can still override it.
129
+
130
+ ## Server rendering
131
+
132
+ Inline declarations need nothing. For the rule tier, emit what was collected:
133
+
134
+ ```tsx
135
+ const { css, tokens } = collectFx();
136
+ `<style data-fx-sheet data-fx-tokens="${tokens}">${css}</style>`;
137
+ ```
138
+
139
+ The client adopts that sheet instead of re-inserting its rules.
140
+
141
+ ## Composing
142
+
143
+ `mergeFx` folds a component's own styling, its `slotProps`, and the consumer's
144
+ `fx` into one object — later wins per property, selector blocks and breakpoint
145
+ records merge rather than replace:
146
+
147
+ ```tsx
148
+ resolveFx(mergeFx(componentOwn, slotProps.body?.fx, props.fx));
149
+ ```
150
+
151
+ ## Binding it to an element
152
+
153
+ ```tsx
154
+ const [local, others] = splitProps(props, ['fx', 'style']);
155
+ return <div {...fxProps(() => local.fx, () => local.style)} {...others} />;
156
+ ```
157
+
158
+ Pass accessors, not values. The resolve runs inside a memo, so a reactive `fx`
159
+ updates without re-rendering. A `style` prop is merged last and stays raw CSS —
160
+ no token resolution — so it remains the lowest-level escape hatch.
161
+
162
+ ## License
163
+
164
+ MIT
@@ -0,0 +1,10 @@
1
+ export * from './lib/types.js';
2
+ export * from './lib/scales.js';
3
+ export * from './lib/selectors.js';
4
+ export * from './lib/resolve.js';
5
+ export * from './lib/sheet.js';
6
+ export * from './lib/merge.js';
7
+ export * from './lib/color.js';
8
+ export * from './lib/props.js';
9
+ export { hash } from './lib/hash.js';
10
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,iBAAiB,CAAC;AAChC,cAAc,oBAAoB,CAAC;AACnC,cAAc,kBAAkB,CAAC;AACjC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,gBAAgB,CAAC;AAC/B,OAAO,EAAE,IAAI,EAAE,MAAM,eAAe,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ export * from './lib/types.js';
2
+ export * from './lib/scales.js';
3
+ export * from './lib/selectors.js';
4
+ export * from './lib/resolve.js';
5
+ export * from './lib/sheet.js';
6
+ export * from './lib/merge.js';
7
+ export * from './lib/color.js';
8
+ export * from './lib/props.js';
9
+ export { hash } from './lib/hash.js';
@@ -0,0 +1,18 @@
1
+ import type { ColorValue } from './types.js';
2
+ /**
3
+ * Fade a colour toward transparent.
4
+ *
5
+ * @example
6
+ * ```ts
7
+ * alpha('primary', 12) // color-mix(in oklab, var(--flx-ui-color-primary) 12%, transparent)
8
+ * ```
9
+ */
10
+ export declare function alpha(color: ColorValue, amount: number): string;
11
+ /**
12
+ * Blend two colours. The amount applies to the first, matching how `color-mix`
13
+ * reads: `mix('primary', 'bg', 20)` is 20% primary over the page background.
14
+ */
15
+ export declare function mix(color: ColorValue, into: ColorValue, amount: number): string;
16
+ export declare function lighten(color: ColorValue, amount: number): string;
17
+ export declare function darken(color: ColorValue, amount: number): string;
18
+ //# sourceMappingURL=color.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"color.d.ts","sourceRoot":"","sources":["../../src/lib/color.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAmB7C;;;;;;;GAOG;AACH,wBAAgB,KAAK,CAAC,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAE/D;AAED;;;GAGG;AACH,wBAAgB,GAAG,CAAC,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAG/E;AAeD,wBAAgB,OAAO,CAAC,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAEjE;AAED,wBAAgB,MAAM,CAAC,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAEhE"}
@@ -0,0 +1,53 @@
1
+ import { resolveColor } from './scales.js';
2
+ /**
3
+ * Colour helpers emit CSS, they never compute a colour in JS.
4
+ *
5
+ * That's not a stylistic choice. Tokens are custom properties that re-resolve
6
+ * against `data-theme` / `data-accent`, so anything computed here would freeze a
7
+ * value that was only correct for the theme active at render. `color-mix()` and
8
+ * relative colour syntax re-resolve on their own, so a derived colour keeps
9
+ * following the theme, the accent and the skin.
10
+ *
11
+ * Every amount is a percentage, 0–100.
12
+ */
13
+ function percent(amount) {
14
+ // Trim float noise from the divisions below without pulling in a rounding lib.
15
+ return Math.round(Math.min(100, Math.max(0, amount)) * 1000) / 1000;
16
+ }
17
+ /**
18
+ * Fade a colour toward transparent.
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * alpha('primary', 12) // color-mix(in oklab, var(--flx-ui-color-primary) 12%, transparent)
23
+ * ```
24
+ */
25
+ export function alpha(color, amount) {
26
+ return `color-mix(in oklab, ${resolveColor(String(color))} ${percent(amount)}%, transparent)`;
27
+ }
28
+ /**
29
+ * Blend two colours. The amount applies to the first, matching how `color-mix`
30
+ * reads: `mix('primary', 'bg', 20)` is 20% primary over the page background.
31
+ */
32
+ export function mix(color, into, amount) {
33
+ const from = resolveColor(String(color));
34
+ return `color-mix(in oklab, ${from} ${percent(amount)}%, ${resolveColor(String(into))})`;
35
+ }
36
+ /**
37
+ * Shift lightness in OKLCH, which keeps chroma and hue — so a tint stays the same
38
+ * colour rather than drifting grey the way mixing with white or black does.
39
+ * Alpha is carried through.
40
+ *
41
+ * Relative colour syntax is required (Chrome 119+, Safari 16.4+, Firefox 128+).
42
+ * For a wider floor, `mix(color, 'white', n)` degrades gracefully.
43
+ */
44
+ function shift(color, delta) {
45
+ const sign = delta < 0 ? '-' : '+';
46
+ return `oklch(from ${resolveColor(String(color))} calc(l ${sign} ${percent(Math.abs(delta)) / 100}) c h / alpha)`;
47
+ }
48
+ export function lighten(color, amount) {
49
+ return shift(color, amount);
50
+ }
51
+ export function darken(color, amount) {
52
+ return shift(color, -amount);
53
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * FNV-1a, base36. Only needs to distinguish rule bodies within one document, so
3
+ * 32 bits is ample and the short output keeps the emitted attribute readable.
4
+ */
5
+ export declare function hash(input: string): string;
6
+ //# sourceMappingURL=hash.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hash.d.ts","sourceRoot":"","sources":["../../src/lib/hash.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,wBAAgB,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAO1C"}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * FNV-1a, base36. Only needs to distinguish rule bodies within one document, so
3
+ * 32 bits is ample and the short output keeps the emitted attribute readable.
4
+ */
5
+ export function hash(input) {
6
+ let value = 0x811c9dc5;
7
+ for (let i = 0; i < input.length; i++) {
8
+ value ^= input.charCodeAt(i);
9
+ value = Math.imul(value, 0x01000193);
10
+ }
11
+ return (value >>> 0).toString(36);
12
+ }
@@ -0,0 +1,8 @@
1
+ import type { FxObject, FxProp } from './types.js';
2
+ /**
3
+ * Combine style objects, later winning per property. Used to fold a component's
4
+ * own styling, its `slotProps`, and the consumer's `fx` into one object before
5
+ * resolving — so all three compose instead of clobbering.
6
+ */
7
+ export declare function mergeFx(...inputs: FxProp[]): FxObject;
8
+ //# sourceMappingURL=merge.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"merge.d.ts","sourceRoot":"","sources":["../../src/lib/merge.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAiCnD;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,GAAG,MAAM,EAAE,MAAM,EAAE,GAAG,QAAQ,CAOrD"}
@@ -0,0 +1,46 @@
1
+ function isPlainObject(value) {
2
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
3
+ }
4
+ function flatten(input, out) {
5
+ if (!input)
6
+ return;
7
+ const value = typeof input === 'function' ? input() : input;
8
+ if (!value)
9
+ return;
10
+ if (Array.isArray(value)) {
11
+ for (const entry of value)
12
+ flatten(entry, out);
13
+ return;
14
+ }
15
+ out.push(value);
16
+ }
17
+ function mergeInto(target, source) {
18
+ for (const key of Object.keys(source)) {
19
+ const value = source[key];
20
+ const existing = target[key];
21
+ // Selector blocks and breakpoint records merge; anything else replaces, so a
22
+ // scalar overriding a responsive record (or the reverse) does what it reads like.
23
+ if (isPlainObject(value) && isPlainObject(existing)) {
24
+ const merged = { ...existing };
25
+ mergeInto(merged, value);
26
+ target[key] = merged;
27
+ }
28
+ else {
29
+ target[key] = value;
30
+ }
31
+ }
32
+ }
33
+ /**
34
+ * Combine style objects, later winning per property. Used to fold a component's
35
+ * own styling, its `slotProps`, and the consumer's `fx` into one object before
36
+ * resolving — so all three compose instead of clobbering.
37
+ */
38
+ export function mergeFx(...inputs) {
39
+ const objects = [];
40
+ for (const input of inputs)
41
+ flatten(input, objects);
42
+ const result = {};
43
+ for (const object of objects)
44
+ mergeInto(result, object);
45
+ return result;
46
+ }
@@ -0,0 +1,20 @@
1
+ import type { FxProp } from './types.js';
2
+ export interface FxAttributes {
3
+ style: () => string | undefined;
4
+ 'data-fx': () => string | undefined;
5
+ }
6
+ /**
7
+ * Bind an `fx` prop to the attributes an element needs. Pass accessors, not
8
+ * values — the resolve runs inside a memo, so a reactive `fx` updates the style
9
+ * without re-rendering, and identical objects reuse the same inserted rules.
10
+ *
11
+ * A `style` prop is merged last, so it stays the lowest-level escape hatch.
12
+ *
13
+ * @example
14
+ * ```tsx
15
+ * const [local, others] = splitProps(props, ['fx', 'style']);
16
+ * <div {...fxProps(() => local.fx, () => local.style)} {...others} />
17
+ * ```
18
+ */
19
+ export declare function fxProps(fx: () => FxProp, style?: () => unknown): FxAttributes;
20
+ //# sourceMappingURL=props.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"props.d.ts","sourceRoot":"","sources":["../../src/lib/props.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAEzC,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;IAChC,SAAS,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;CACrC;AAeD;;;;;;;;;;;;GAYG;AACH,wBAAgB,OAAO,CAAC,EAAE,EAAE,MAAM,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,OAAO,GAAG,YAAY,CAiB7E"}
@@ -0,0 +1,50 @@
1
+ import { createMemo } from '@fluixi/reactive/signal';
2
+ import { resolveFx } from './resolve.js';
3
+ import { cssProperty } from './scales.js';
4
+ import { insertFx } from './sheet.js';
5
+ /** A raw `style` prop stays raw CSS — no token resolution, no alias expansion. */
6
+ function serializeStyle(value) {
7
+ if (value == null)
8
+ return '';
9
+ if (typeof value === 'function')
10
+ return serializeStyle(value());
11
+ if (typeof value === 'string')
12
+ return value;
13
+ if (typeof value !== 'object')
14
+ return '';
15
+ return Object.keys(value)
16
+ .map((key) => [key, value[key]])
17
+ .filter(([, entry]) => entry != null && entry !== false)
18
+ .map(([key, entry]) => `${cssProperty(key)}:${String(entry)}`)
19
+ .join(';');
20
+ }
21
+ /**
22
+ * Bind an `fx` prop to the attributes an element needs. Pass accessors, not
23
+ * values — the resolve runs inside a memo, so a reactive `fx` updates the style
24
+ * without re-rendering, and identical objects reuse the same inserted rules.
25
+ *
26
+ * A `style` prop is merged last, so it stays the lowest-level escape hatch.
27
+ *
28
+ * @example
29
+ * ```tsx
30
+ * const [local, others] = splitProps(props, ['fx', 'style']);
31
+ * <div {...fxProps(() => local.fx, () => local.style)} {...others} />
32
+ * ```
33
+ */
34
+ export function fxProps(fx, style) {
35
+ // Inserting from inside the memo keeps resolve and insert in one pass; it's
36
+ // idempotent, so re-running on a dependency change costs a Set lookup.
37
+ const resolved = createMemo(() => {
38
+ const result = resolveFx(fx());
39
+ insertFx(result);
40
+ return result;
41
+ });
42
+ return {
43
+ style: () => {
44
+ const own = resolved().style;
45
+ const user = serializeStyle(style?.());
46
+ return (own && user ? `${own};${user}` : own || user) || undefined;
47
+ },
48
+ 'data-fx': () => resolved().token,
49
+ };
50
+ }
@@ -0,0 +1,8 @@
1
+ import type { FxProp, FxResult } from './types.js';
2
+ /**
3
+ * Resolve a style object into inline declarations plus, when it uses selectors or
4
+ * breakpoints, a block of rule text. Pure — nothing is inserted into the document
5
+ * here; see `insertFx`.
6
+ */
7
+ export declare function resolveFx(input: FxProp): FxResult;
8
+ //# sourceMappingURL=resolve.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolve.d.ts","sourceRoot":"","sources":["../../src/lib/resolve.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAqB,MAAM,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AA+ItE;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,QAAQ,CAkBjD"}
@@ -0,0 +1,142 @@
1
+ import { breakpoint } from '@fluixi-ui/tokens/reference';
2
+ import { hash } from './hash.js';
3
+ import { ALIASES, cssProperty, resolveValue } from './scales.js';
4
+ import { composeSelectors, isSelectorKey, selectorEntry } from './selectors.js';
5
+ const BREAKPOINTS = breakpoint;
6
+ const ROOT = ['&'];
7
+ const CONTAINER_KEY = '_container';
8
+ function isPlainObject(value) {
9
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
10
+ }
11
+ function bucketKey(selectors, atRules) {
12
+ return `${atRules.join('||')}||${selectors.join(', ')}`;
13
+ }
14
+ /**
15
+ * `element` replaces the `&` placeholder, and only inside selectors — a declaration
16
+ * value is free to contain an ampersand (a `url()` query string, say).
17
+ */
18
+ function serializeRules(buckets, inlineKey, element) {
19
+ const out = [];
20
+ for (const [key, bucket] of buckets) {
21
+ if (key === inlineKey || bucket.declarations.size === 0)
22
+ continue;
23
+ const selector = bucket.selectors.map((part) => part.split('&').join(element)).join(', ');
24
+ let body = `${selector}{${[...bucket.declarations.values()].join(';')}}`;
25
+ for (let i = bucket.atRules.length - 1; i >= 0; i--)
26
+ body = `${bucket.atRules[i]}{${body}}`;
27
+ out.push(body);
28
+ }
29
+ return out.join('');
30
+ }
31
+ /**
32
+ * Flatten the array form and unwrap the accessor form. Falsy entries drop out so
33
+ * `fx={[base, dense && {...}]}` reads naturally.
34
+ */
35
+ function normalize(input) {
36
+ if (!input)
37
+ return [];
38
+ const value = typeof input === 'function' ? input() : input;
39
+ if (!value)
40
+ return [];
41
+ if (Array.isArray(value))
42
+ return value.filter(Boolean);
43
+ return [value];
44
+ }
45
+ /**
46
+ * Walk a style object into buckets keyed by selector + media. Nothing is emitted
47
+ * until the walk finishes, so nesting order never affects the result.
48
+ */
49
+ function walk(object, selectors, atRules, buckets) {
50
+ const key = bucketKey(selectors, atRules);
51
+ let bucket = buckets.get(key);
52
+ if (!bucket) {
53
+ bucket = { selectors, atRules, declarations: new Map() };
54
+ buckets.set(key, bucket);
55
+ }
56
+ for (const name of Object.keys(object)) {
57
+ const value = object[name];
58
+ if (value == null || value === false)
59
+ continue;
60
+ if (name === CONTAINER_KEY) {
61
+ if (isPlainObject(value))
62
+ walkContainer(value, selectors, atRules, buckets);
63
+ continue;
64
+ }
65
+ if (isSelectorKey(name)) {
66
+ if (!isPlainObject(value))
67
+ continue;
68
+ const entry = selectorEntry(name);
69
+ // A pseudo-element with no `content` would never render, and supplying it
70
+ // every time is noise.
71
+ const child = 'selector' in entry && /::(before|after)$/.test(entry.selector) && !('content' in value)
72
+ ? { content: "''", ...value }
73
+ : value;
74
+ if ('selector' in entry) {
75
+ walk(child, composeSelectors(selectors, entry.selector), atRules, buckets);
76
+ }
77
+ else {
78
+ walk(child, selectors, [...atRules, entry.atRule], buckets);
79
+ }
80
+ continue;
81
+ }
82
+ const property = ALIASES[name] ?? name;
83
+ if (isPlainObject(value)) {
84
+ for (const step of Object.keys(value)) {
85
+ const stepValue = value[step];
86
+ if (stepValue == null || stepValue === false)
87
+ continue;
88
+ if (step === 'base') {
89
+ declare(bucket, property, stepValue);
90
+ }
91
+ else if (BREAKPOINTS[step]) {
92
+ walk({ [property]: stepValue }, selectors, [...atRules, `@media (min-width: ${BREAKPOINTS[step]})`], buckets);
93
+ }
94
+ }
95
+ continue;
96
+ }
97
+ declare(bucket, property, value);
98
+ }
99
+ }
100
+ /**
101
+ * `_container` is parameterised, so it can't live in the static selector table.
102
+ * Keys are either a breakpoint name or a raw condition starting with `(`; a
103
+ * `name` entry scopes the query to a named container rather than the nearest one.
104
+ */
105
+ function walkContainer(query, selectors, atRules, buckets) {
106
+ const name = typeof query['name'] === 'string' ? `${query['name']} ` : '';
107
+ for (const key of Object.keys(query)) {
108
+ if (key === 'name')
109
+ continue;
110
+ const block = query[key];
111
+ if (!isPlainObject(block))
112
+ continue;
113
+ const condition = BREAKPOINTS[key] ? `(min-width: ${BREAKPOINTS[key]})` : key;
114
+ walk(block, selectors, [...atRules, `@container ${name}${condition}`], buckets);
115
+ }
116
+ }
117
+ function declare(bucket, property, value) {
118
+ bucket.declarations.set(property, `${cssProperty(property)}:${resolveValue(property, value)}`);
119
+ }
120
+ /**
121
+ * Resolve a style object into inline declarations plus, when it uses selectors or
122
+ * breakpoints, a block of rule text. Pure — nothing is inserted into the document
123
+ * here; see `insertFx`.
124
+ */
125
+ export function resolveFx(input) {
126
+ const objects = normalize(input);
127
+ if (objects.length === 0)
128
+ return { style: '', rules: '', token: undefined };
129
+ const buckets = new Map();
130
+ const inlineKey = bucketKey(ROOT, []);
131
+ for (const object of objects)
132
+ walk(object, ROOT, [], buckets);
133
+ const inline = buckets.get(inlineKey);
134
+ const style = inline ? [...inline.declarations.values()].join(';') : '';
135
+ // Hash the unscoped form, then re-serialize against the attribute it names, so
136
+ // identical style objects always land on the same token.
137
+ const unscoped = serializeRules(buckets, inlineKey, '&');
138
+ if (!unscoped)
139
+ return { style, rules: '', token: undefined };
140
+ const token = hash(unscoped);
141
+ return { style, rules: serializeRules(buckets, inlineKey, `[data-fx="${token}"]`), token };
142
+ }