@pyreon/unistyle 0.34.0 → 0.36.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/lib/index.js CHANGED
@@ -1,6 +1,7 @@
1
- import { nativeCompat, provide } from "@pyreon/core";
2
- import { ThemeContext } from "@pyreon/styler";
1
+ import { h, nativeCompat, provide } from "@pyreon/core";
2
+ import { ThemeContext, css, normalizeCSS, sheet, useThemeAccessor } from "@pyreon/styler";
3
3
  import { Provider as Provider$1, config, context, isEmpty, set } from "@pyreon/ui-core";
4
+ import { renderEffect } from "@pyreon/reactivity";
4
5
 
5
6
  //#region src/responsive/breakpoints.ts
6
7
  const breakpoints = {
@@ -739,54 +740,114 @@ function resolveVarPass(s, registry) {
739
740
  }
740
741
 
741
742
  //#endregion
742
- //#region src/styles/alignContent.ts
743
- const ALIGN_CONTENT_MAP_SHARED = {
744
- center: "center",
745
- spaceBetween: "space-between",
746
- spaceAround: "space-around",
747
- block: "stretch"
748
- };
749
- const ALIGN_CONTENT_MAP_X = {
750
- left: "flex-start",
751
- right: "flex-end",
752
- ...ALIGN_CONTENT_MAP_SHARED
753
- };
754
- const ALIGN_CONTENT_MAP_Y = {
755
- top: "flex-start",
756
- bottom: "flex-end",
757
- ...ALIGN_CONTENT_MAP_SHARED
758
- };
759
- const ALIGN_CONTENT_DIRECTION = {
760
- inline: "row",
761
- reverseInline: "row-reverse",
762
- rows: "column",
763
- reverseRows: "column-reverse"
764
- };
765
- const alignContent = (attrs) => {
766
- const { direction, alignX, alignY } = attrs;
767
- if (isEmpty(attrs) || !direction || !alignX || !alignY) return null;
768
- const isReverted = direction === "inline" || direction === "reverseInline";
769
- const dir = ALIGN_CONTENT_DIRECTION[direction];
770
- const x = ALIGN_CONTENT_MAP_X[alignX];
771
- const y = ALIGN_CONTENT_MAP_Y[alignY];
772
- return `flex-direction: ${dir}; align-items: ${isReverted ? y : x}; justify-content: ${isReverted ? x : y};`;
773
- };
774
-
775
- //#endregion
776
- //#region src/styles/extendCss.ts
777
- const simpleCss = (strings, ...values) => {
778
- let result = "";
779
- for (let i = 0; i < strings.length; i++) {
780
- result += strings[i];
781
- if (i < values.length) result += String(values[i] ?? "");
743
+ //#region src/cpse.ts
744
+ /**
745
+ * Custom-Property Style Extraction (CPSE) — Phase 0 proof-of-concept primitive.
746
+ *
747
+ * See `.claude/audits/custom-property-style-extraction-2026-06-22.md` for the
748
+ * full RFC. The thesis: decouple a style prop's **CSS-rule identity** from its
749
+ * **value identity**. Instead of baking the resolved value into the rule —
750
+ *
751
+ * gap: 2.25rem // value-DEPENDENT rule → a new rule + a
752
+ * // `styler.resolve` per distinct value
753
+ * // (cost O(distinct value tuples), proven by
754
+ * // styler/__tests__/static-styler-resolve-cost)
755
+ *
756
+ * — emit a value-AGNOSTIC rule that reads a custom property, and deliver the
757
+ * value per-instance as an inline custom property —
758
+ *
759
+ * gap: var(--u-<hash>) // ONE shared rule, resolved ONCE per
760
+ * // definition; cost O(component definitions)
761
+ * style="--u-<hash>: 2.25rem" // per-instance, no resolve / no hash /
762
+ * // no new rule
763
+ *
764
+ * This makes styling cost **flat in app cardinality** and gives **dynamic
765
+ * (signal-driven) values for free** (update the inline custom property — no
766
+ * `styler.resolve`, no rule churn).
767
+ *
768
+ * Phase 0 scope: a single declaration / single property, to PROVE the
769
+ * mechanism (1 rule + 1 resolve for N distinct values; computed-style parity
770
+ * with the value-baked path; nesting-safe). Phases 1-4 (RFC §5) generalize
771
+ * across the 170+ unistyle property mappings, responsive arrays (per-breakpoint
772
+ * vars + media queries), the dynamic path, and rocketstyle integration.
773
+ *
774
+ * Self-contained: depends only on unistyle's own `value()` conversion + an
775
+ * inline FNV-1a. Does NOT import `@pyreon/styler` (keeps the primitive
776
+ * layer-pure; only the measurement TESTS reach for the styler counters).
777
+ */
778
+ const fnv1a = (s) => {
779
+ let h = 2166136261;
780
+ for (let i = 0; i < s.length; i++) {
781
+ h ^= s.charCodeAt(i);
782
+ h = Math.imul(h, 16777619);
782
783
  }
783
- return result;
784
- };
785
- const extendCss = (styles) => {
786
- if (!styles) return "";
787
- if (typeof styles === "function") return styles(simpleCss);
788
- return styles;
784
+ return (h >>> 0).toString(36);
789
785
  };
786
+ /**
787
+ * Extract one style declaration into a value-agnostic rule + a per-instance
788
+ * custom-property value.
789
+ *
790
+ * @param property CSS property name (already in CSS-spec form, e.g. `gap`).
791
+ * @param rawValue The author-supplied value (`36`, `"1rem"`, `"var(--x)"`, …).
792
+ * @param rootSize px→rem base (defaults to 16, matching `value()`).
793
+ */
794
+ function extractStyleVar(property, rawValue, rootSize = 16) {
795
+ const varName = cpseVarName(property);
796
+ const converted = value(rawValue, rootSize);
797
+ return {
798
+ rule: `${property}:var(${varName})`,
799
+ varName,
800
+ varValue: converted == null ? null : String(converted)
801
+ };
802
+ }
803
+ /**
804
+ * Canonical CPSE custom-property name for a CSS property (+ optional
805
+ * breakpoint suffix for the responsive path). Stable + collision-free across
806
+ * distinct property names; shared across components for the same property
807
+ * (intended — every instance sets its own inline value, §nesting test).
808
+ */
809
+ function cpseVarName(property, breakpoint) {
810
+ return breakpoint ? `--u-${fnv1a(property)}-${breakpoint}` : `--u-${fnv1a(property)}`;
811
+ }
812
+ const STRUCTURAL = /[{}&@]|url\(/;
813
+ /**
814
+ * Rewrite every flat `prop: value;` declaration in a resolved CSS fragment to
815
+ * the value-agnostic `prop: var(--u-<hash>[-bp]);` form, writing each value
816
+ * into `varsOut`. Returns the rewritten fragment. Fragments carrying any
817
+ * structure (selectors, at-rules, nesting, url()) are returned UNCHANGED.
818
+ *
819
+ * This operates on `processDescriptor`'s ALREADY-RESOLVED output, so it
820
+ * inherits every unit-conversion / shorthand correctness for free and stays
821
+ * general across the whole property map.
822
+ *
823
+ * @param frag a resolved CSS fragment, e.g. `"gap: 2.25rem;"` or
824
+ * `"margin: 1rem 2rem;"` (possibly several `;`-separated).
825
+ * @param varsOut sink: `varName → value` for the per-instance inline props.
826
+ * @param breakpoint optional suffix so per-breakpoint values get distinct vars.
827
+ */
828
+ function cpseRewrite(frag, varsOut, breakpoint) {
829
+ if (!frag || STRUCTURAL.test(frag)) return frag;
830
+ let out = "";
831
+ for (const decl of frag.split(";")) {
832
+ const trimmed = decl.trim();
833
+ if (!trimmed) continue;
834
+ const colon = trimmed.indexOf(":");
835
+ if (colon < 1) {
836
+ out += `${trimmed};`;
837
+ continue;
838
+ }
839
+ const prop = trimmed.slice(0, colon).trim();
840
+ const val = trimmed.slice(colon + 1).trim();
841
+ if (!val || val.startsWith("var(") || !/^[a-z][a-z-]*$/.test(prop)) {
842
+ out += `${trimmed};`;
843
+ continue;
844
+ }
845
+ const varName = cpseVarName(prop, breakpoint);
846
+ varsOut[varName] = val;
847
+ out += `${prop}:var(${varName});`;
848
+ }
849
+ return out;
850
+ }
790
851
 
791
852
  //#endregion
792
853
  //#region src/styles/shorthands/borderRadius.ts
@@ -2391,7 +2452,7 @@ for (let i = 0; i < propertyMap.length; i++) {
2391
2452
  * pseudo-selectors, and @layer wrapping.
2392
2453
  */
2393
2454
  const _seen = /* @__PURE__ */ new Set();
2394
- const styles = ({ theme: t, css, rootSize }) => {
2455
+ const styles = ({ theme: t, css, rootSize, extractVars, breakpoint }) => {
2395
2456
  if (process.env.NODE_ENV !== "production") _countSink.__pyreon_count__?.("unistyle.styles");
2396
2457
  const calc = (...params) => values(params, rootSize);
2397
2458
  const shorthand = edge(rootSize);
@@ -2405,14 +2466,18 @@ const styles = ({ theme: t, css, rootSize }) => {
2405
2466
  if (_seen.has(idx)) continue;
2406
2467
  _seen.add(idx);
2407
2468
  if (process.env.NODE_ENV !== "production") _countSink.__pyreon_count__?.("unistyle.descriptor");
2408
- fragments.push(processDescriptor(propertyMap[idx], t, css, calc, shorthand, borderRadiusFn));
2469
+ let frag = processDescriptor(propertyMap[idx], t, css, calc, shorthand, borderRadiusFn);
2470
+ if (extractVars && typeof frag === "string") frag = cpseRewrite(frag, extractVars, breakpoint);
2471
+ fragments.push(frag);
2409
2472
  }
2410
2473
  }
2411
2474
  if (fragments.length === 0 && Object.keys(t).length > 0) {
2412
2475
  if (process.env.NODE_ENV !== "production") _countSink.__pyreon_count__?.("unistyle.descriptor.fallback-scan");
2413
2476
  for (const d of propertyMap) {
2414
2477
  if (process.env.NODE_ENV !== "production") _countSink.__pyreon_count__?.("unistyle.descriptor");
2415
- fragments.push(processDescriptor(d, t, css, calc, shorthand, borderRadiusFn));
2478
+ let frag = processDescriptor(d, t, css, calc, shorthand, borderRadiusFn);
2479
+ if (extractVars && typeof frag === "string") frag = cpseRewrite(frag, extractVars, breakpoint);
2480
+ fragments.push(frag);
2416
2481
  }
2417
2482
  }
2418
2483
  return css`
@@ -2421,5 +2486,205 @@ const styles = ({ theme: t, css, rootSize }) => {
2421
2486
  };
2422
2487
 
2423
2488
  //#endregion
2424
- export { ALIGN_CONTENT_DIRECTION, ALIGN_CONTENT_MAP_X, ALIGN_CONTENT_MAP_Y, CSS_VARS_DEFAULT_EXCLUDE, Provider, alignContent, breakpoints, context, createMediaQueries, enrichTheme, extendCss, makeItResponsive, normalizeTheme, resolveCssVarReferences, sortBreakpoints, stripUnit, styles, themeToCssVars, transformTheme, value, values };
2489
+ //#region src/cpse-styled.tsx
2490
+ const RESERVED = /* @__PURE__ */ new Set([
2491
+ "styles",
2492
+ "rootSize",
2493
+ "breakpoints",
2494
+ "class",
2495
+ "ref",
2496
+ "children"
2497
+ ]);
2498
+ const DEFAULT_BREAKPOINTS = {
2499
+ xs: 0,
2500
+ sm: 576,
2501
+ md: 768,
2502
+ lg: 992,
2503
+ xl: 1200
2504
+ };
2505
+ /** A value is responsive if it's a mobile-first array or a breakpoint object
2506
+ * (a `var(--x)` / plain string is NOT — strings are scalar). */
2507
+ const isResponsiveValue = (v) => Array.isArray(v) || v != null && typeof v === "object";
2508
+ const isResponsiveTheme = (t) => {
2509
+ for (const k in t) if (isResponsiveValue(t[k])) return true;
2510
+ return false;
2511
+ };
2512
+ /** Expand a responsive theme into per-breakpoint flat themes (only the
2513
+ * breakpoints that actually carry a value). Mobile-first arrays map index→
2514
+ * sorted-breakpoint; breakpoint objects map by key; scalars land on the base. */
2515
+ function expandResponsive(t, sorted) {
2516
+ const perBp = /* @__PURE__ */ new Map();
2517
+ const put = (bp, prop, val) => {
2518
+ let m = perBp.get(bp);
2519
+ if (!m) {
2520
+ m = {};
2521
+ perBp.set(bp, m);
2522
+ }
2523
+ m[prop] = val;
2524
+ };
2525
+ const rec = t;
2526
+ for (const prop in rec) {
2527
+ const v = rec[prop];
2528
+ if (Array.isArray(v)) {
2529
+ for (let i = 0; i < v.length && i < sorted.length; i++) if (v[i] != null) put(sorted[i], prop, v[i]);
2530
+ } else if (v != null && typeof v === "object") for (const bp in v) {
2531
+ const bv = v[bp];
2532
+ if (sorted.includes(bp) && bv != null) put(bp, prop, bv);
2533
+ }
2534
+ else put(sorted[0], prop, v);
2535
+ }
2536
+ return perBp;
2537
+ }
2538
+ function cpseStyled(tag) {
2539
+ const classByShape = /* @__PURE__ */ new Map();
2540
+ /**
2541
+ * Compute the per-instance var map (always) and the value-agnostic className
2542
+ * (resolved + inserted only on the first sighting of a shape). Returns both.
2543
+ * `styles({ extractVars })` writes vars synchronously while building fragments,
2544
+ * so vars are ready without the expensive `String(...)` (→ `styler.resolve`)
2545
+ * + insert — those run only on a cache miss.
2546
+ */
2547
+ const resolve = (styleTheme, rootSize, breakpoints) => {
2548
+ const vars = {};
2549
+ if (!isResponsiveTheme(styleTheme)) {
2550
+ const result = styles({
2551
+ theme: styleTheme,
2552
+ css,
2553
+ rootSize,
2554
+ extractVars: vars
2555
+ });
2556
+ const shapeKey = `flat:${Object.keys(styleTheme).sort().join("|")}`;
2557
+ let className = classByShape.get(shapeKey);
2558
+ if (className === void 0) {
2559
+ const text = normalizeCSS(String(result));
2560
+ className = text.length > 0 ? sheet.insert(text) : "";
2561
+ classByShape.set(shapeKey, className);
2562
+ }
2563
+ return {
2564
+ className,
2565
+ vars
2566
+ };
2567
+ }
2568
+ const sorted = sortBreakpoints(breakpoints);
2569
+ const perBp = expandResponsive(styleTheme, sorted);
2570
+ const mq = createMediaQueries({
2571
+ breakpoints,
2572
+ rootSize,
2573
+ css
2574
+ });
2575
+ const frags = [];
2576
+ const shapeParts = [];
2577
+ for (const bp of sorted) {
2578
+ const flat = perBp.get(bp);
2579
+ if (!flat || Object.keys(flat).length === 0) continue;
2580
+ const decl = String(styles({
2581
+ theme: flat,
2582
+ css,
2583
+ rootSize,
2584
+ extractVars: vars,
2585
+ breakpoint: bp
2586
+ }));
2587
+ frags.push(String(mq[bp]`${decl}`));
2588
+ shapeParts.push(`${bp}:${Object.keys(flat).sort().join(",")}`);
2589
+ }
2590
+ const shapeKey = `resp:${shapeParts.join("|")}`;
2591
+ let className = classByShape.get(shapeKey);
2592
+ if (className === void 0) {
2593
+ const text = normalizeCSS(frags.join("\n"));
2594
+ className = text.length > 0 ? sheet.insert(text) : "";
2595
+ classByShape.set(shapeKey, className);
2596
+ }
2597
+ return {
2598
+ className,
2599
+ vars
2600
+ };
2601
+ };
2602
+ return (props) => {
2603
+ const theme = useThemeAccessor()() ?? {};
2604
+ const rootSize = props.rootSize ?? theme.rootSize ?? 16;
2605
+ const breakpoints = props.breakpoints ?? theme.breakpoints ?? DEFAULT_BREAKPOINTS;
2606
+ const stylesProp = props.styles;
2607
+ const isDynamic = typeof stylesProp === "function";
2608
+ const getTheme = () => isDynamic ? stylesProp() : stylesProp ?? {};
2609
+ const first = resolve(getTheme(), rootSize, breakpoints);
2610
+ const className = props.class ? `${first.className} ${props.class}` : first.className;
2611
+ const rest = {};
2612
+ for (const k in props) if (!RESERVED.has(k)) rest[k] = props[k];
2613
+ let el = null;
2614
+ const applyVars = (vars) => {
2615
+ if (!el) return;
2616
+ for (const k in vars) el.style.setProperty(k, vars[k]);
2617
+ };
2618
+ const ref = (node) => {
2619
+ el = node;
2620
+ if (el) applyVars(first.vars);
2621
+ const userRef = props.ref;
2622
+ if (typeof userRef === "function") userRef(node);
2623
+ else if (userRef && typeof userRef === "object") userRef.current = node;
2624
+ };
2625
+ if (isDynamic) renderEffect(() => {
2626
+ applyVars(resolve(getTheme(), rootSize, breakpoints).vars);
2627
+ });
2628
+ const children = props.children != null ? [props.children] : [];
2629
+ return h(tag, {
2630
+ class: className,
2631
+ style: first.vars,
2632
+ ref,
2633
+ ...rest
2634
+ }, ...children);
2635
+ };
2636
+ }
2637
+
2638
+ //#endregion
2639
+ //#region src/styles/alignContent.ts
2640
+ const ALIGN_CONTENT_MAP_SHARED = {
2641
+ center: "center",
2642
+ spaceBetween: "space-between",
2643
+ spaceAround: "space-around",
2644
+ block: "stretch"
2645
+ };
2646
+ const ALIGN_CONTENT_MAP_X = {
2647
+ left: "flex-start",
2648
+ right: "flex-end",
2649
+ ...ALIGN_CONTENT_MAP_SHARED
2650
+ };
2651
+ const ALIGN_CONTENT_MAP_Y = {
2652
+ top: "flex-start",
2653
+ bottom: "flex-end",
2654
+ ...ALIGN_CONTENT_MAP_SHARED
2655
+ };
2656
+ const ALIGN_CONTENT_DIRECTION = {
2657
+ inline: "row",
2658
+ reverseInline: "row-reverse",
2659
+ rows: "column",
2660
+ reverseRows: "column-reverse"
2661
+ };
2662
+ const alignContent = (attrs) => {
2663
+ const { direction, alignX, alignY } = attrs;
2664
+ if (isEmpty(attrs) || !direction || !alignX || !alignY) return null;
2665
+ const isReverted = direction === "inline" || direction === "reverseInline";
2666
+ const dir = ALIGN_CONTENT_DIRECTION[direction];
2667
+ const x = ALIGN_CONTENT_MAP_X[alignX];
2668
+ const y = ALIGN_CONTENT_MAP_Y[alignY];
2669
+ return `flex-direction: ${dir}; align-items: ${isReverted ? y : x}; justify-content: ${isReverted ? x : y};`;
2670
+ };
2671
+
2672
+ //#endregion
2673
+ //#region src/styles/extendCss.ts
2674
+ const simpleCss = (strings, ...values) => {
2675
+ let result = "";
2676
+ for (let i = 0; i < strings.length; i++) {
2677
+ result += strings[i];
2678
+ if (i < values.length) result += String(values[i] ?? "");
2679
+ }
2680
+ return result;
2681
+ };
2682
+ const extendCss = (styles) => {
2683
+ if (!styles) return "";
2684
+ if (typeof styles === "function") return styles(simpleCss);
2685
+ return styles;
2686
+ };
2687
+
2688
+ //#endregion
2689
+ export { ALIGN_CONTENT_DIRECTION, ALIGN_CONTENT_MAP_X, ALIGN_CONTENT_MAP_Y, CSS_VARS_DEFAULT_EXCLUDE, Provider, alignContent, breakpoints, context, cpseRewrite, cpseStyled, cpseVarName, createMediaQueries, enrichTheme, extendCss, extractStyleVar, makeItResponsive, normalizeTheme, resolveCssVarReferences, sortBreakpoints, stripUnit, styles, themeToCssVars, transformTheme, value, values };
2425
2690
  //# sourceMappingURL=index.js.map