@animus-ui/system 0.1.2 → 0.1.4

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/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { n as createClassResolver, r as createComponent, t as createComposedFamily } from "./createComposedFamily-DJ2aqfqN.js";
2
2
  import { compose } from "./compose.js";
3
- import { a as borderShorthand, c as numericOrStringScale, i as gridItemRatio, l as numericScale, n as size, o as createTransform, r as gridItem, s as createScale, t as percentageOrAbsolute, u as stringScale } from "./size-CusLCguT.js";
3
+ import { a as borderShorthand, c as numericScale, d as createTransform, i as gridItemRatio, l as stringScale, n as size, o as createScale, r as gridItem, s as numericOrStringScale, t as percentageOrAbsolute, u as areTransformsEqual } from "./size-CDBRRzkb.js";
4
4
  //#region src/utils/deepMerge.ts
5
5
  /**
6
6
  * Deep merge utility — replaces lodash.merge for variant accumulation.
@@ -229,6 +229,20 @@ var Animus = class extends AnimusWithBase {
229
229
  }
230
230
  };
231
231
  //#endregion
232
+ //#region src/asset.ts
233
+ /** The reserved scheme marking an unsubstituted asset reference. */
234
+ const ASSET_PLACEHOLDER_PREFIX = "animus-asset:";
235
+ /**
236
+ * Reference a package-owned asset (e.g. a font file) by module specifier:
237
+ * `asset('@acme/tokens/fonts/inter.woff2')`. Resolution — aliases, exports
238
+ * maps, `base`, content hashing — belongs to the host bundler; this function
239
+ * only brands the specifier into its placeholder form. A literal URL string
240
+ * (`'/fonts/inter.woff2'`) remains the pass-through alternative.
241
+ */
242
+ function asset(specifier) {
243
+ return ASSET_PLACEHOLDER_PREFIX + specifier;
244
+ }
245
+ //#endregion
232
246
  //#region src/conditions.ts
233
247
  /**
234
248
  * Built-in condition aliases (design D8, increment 06).
@@ -529,11 +543,15 @@ const BUILT_IN_SELECTORS = {
529
543
  /**
530
544
  * Merge user-provided selectors with built-in defaults.
531
545
  * User selectors override built-in aliases of the same name.
532
- * New aliases get an order value based on their position (500+).
546
+ * New aliases allocate orders CONTINUING from the highest existing order
547
+ * (floored at 490, so the first user alias lands at 500) rather than
548
+ * restarting at 500 each call (mirrors `mergeConditions`) — aliases from
549
+ * successive `.addSelectors()`/`extend()` merges must not collide on
550
+ * order 500.
533
551
  */
534
552
  function mergeSelectors(base, custom) {
535
553
  const merged = { ...base };
536
- let nextOrder = 500;
554
+ let nextOrder = Math.max(490, ...Object.values(merged).map((s) => s.order)) + 10;
537
555
  for (const [key, selector] of Object.entries(custom)) if (key in merged) merged[key] = {
538
556
  selector,
539
557
  order: merged[key].order
@@ -567,13 +585,69 @@ function serializeSelectorMap(map) {
567
585
  }
568
586
  //#endregion
569
587
  //#region src/SystemBuilder.ts
588
+ const snapshotTransformBySource = /* @__PURE__ */ new WeakMap();
589
+ function snapshotTransform(source) {
590
+ const cached = snapshotTransformBySource.get(source);
591
+ if (cached) return cached;
592
+ const wrapper = (value, property, props) => source(value, property, props);
593
+ Object.defineProperty(wrapper, "name", { value: source.name });
594
+ const sourceText = source.toString();
595
+ Object.defineProperty(wrapper, "toString", { value: () => sourceText });
596
+ const named = source;
597
+ if (named.transformName !== void 0) Object.defineProperty(wrapper, "transformName", {
598
+ value: named.transformName,
599
+ enumerable: true
600
+ });
601
+ if (named.transformSource !== void 0) Object.defineProperty(wrapper, "transformSource", {
602
+ value: named.transformSource,
603
+ enumerable: true
604
+ });
605
+ Object.freeze(wrapper);
606
+ snapshotTransformBySource.set(source, wrapper);
607
+ return wrapper;
608
+ }
609
+ /**
610
+ * The one runtime discriminator for a library bundle: `system.toConfig`
611
+ * being callable. A built system instance also has a `.system()` CHAIN
612
+ * METHOD, so presence of a `system` key alone cannot discriminate the two
613
+ * shapes. Both builders' `from()` use this guard; the QuickJS capture
614
+ * script in the Rust system-loader mirrors it by necessity (it cannot
615
+ * import TS) and points back here.
616
+ */
617
+ function isLibraryBundle(value) {
618
+ const system = value?.system;
619
+ return Boolean(system) && typeof system?.toConfig === "function";
620
+ }
570
621
  function orderedPropertiesEqual(existing, incoming) {
571
622
  if (existing === incoming) return true;
572
623
  if (!existing || !incoming || existing.length !== incoming.length) return false;
573
624
  return existing.every((property, index) => property === incoming[index]);
574
625
  }
575
- function arePropDefinitionsEqual(existing, incoming) {
576
- return existing.property === incoming.property && orderedPropertiesEqual(existing.properties, incoming.properties) && existing.scale === incoming.scale && existing.variable === incoming.variable && existing.negative === incoming.negative && existing.strict === incoming.strict && existing.currentVar === incoming.currentVar && existing.transform === incoming.transform;
626
+ function scalesEqual(existing, incoming) {
627
+ if (existing === incoming) return true;
628
+ if (!existing || !incoming || typeof existing !== typeof incoming) return false;
629
+ if (typeof existing === "string" || typeof incoming === "string") return false;
630
+ if (Array.isArray(existing) || Array.isArray(incoming)) return Array.isArray(existing) && Array.isArray(incoming) && existing.length === incoming.length && existing.every((value, index) => value === incoming[index]);
631
+ const existingMap = existing;
632
+ const incomingMap = incoming;
633
+ const existingKeys = Object.keys(existingMap).sort();
634
+ return orderedMembersEqual(existingKeys, Object.keys(incomingMap).sort()) && existingKeys.every((key) => existingMap[key] === incomingMap[key]);
635
+ }
636
+ function arePropDefinitionsEqual(existing, incoming, structuralScale = false) {
637
+ return existing.property === incoming.property && orderedPropertiesEqual(existing.properties, incoming.properties) && (structuralScale ? scalesEqual(existing.scale, incoming.scale) : existing.scale === incoming.scale) && existing.variable === incoming.variable && existing.negative === incoming.negative && existing.strict === incoming.strict && existing.currentVar === incoming.currentVar && areTransformsEqual(existing.transform, incoming.transform);
638
+ }
639
+ function orderedMembersEqual(existing, incoming) {
640
+ return existing.length === incoming.length && existing.every((member, index) => member === incoming[index]);
641
+ }
642
+ /**
643
+ * Divergent-prop error naming both definitions AND both origins — used by the
644
+ * `extend()` merge (sibling/dual-version conflicts, design D3/G4) and by
645
+ * `addGroup`/`addProps` when the colliding entry arrived through `extend()`
646
+ * (origin labels "extended source #n" / "builder state"). When no extension
647
+ * provenance exists, the pre-existing origin-less messages are kept verbatim.
648
+ */
649
+ function divergentPropError(key, existing, incoming, existingOrigin, incomingOrigin) {
650
+ return /* @__PURE__ */ new Error(`Prop "${key}" already registered with a different definition. Existing (${existingOrigin}): property="${existing.property}", scale="${String(existing.scale)}". Incoming (${incomingOrigin}): property="${incoming.property}", scale="${String(incoming.scale)}".`);
577
651
  }
578
652
  var SystemBuilder = class SystemBuilder {
579
653
  #propRegistry;
@@ -581,17 +655,107 @@ var SystemBuilder = class SystemBuilder {
581
655
  #selectorRegistry;
582
656
  #includesRegistry;
583
657
  #conditionRegistry;
584
- constructor(propRegistry, groupRegistry, selectorRegistry, includesRegistry, conditionRegistry) {
658
+ #extendProvenance;
659
+ #extendCount;
660
+ constructor(propRegistry, groupRegistry, selectorRegistry, includesRegistry, conditionRegistry, extendProvenance, extendCount) {
585
661
  this.#propRegistry = propRegistry || {};
586
662
  this.#groupRegistry = groupRegistry || {};
587
663
  this.#selectorRegistry = selectorRegistry || { ...BUILT_IN_SELECTORS };
588
664
  this.#includesRegistry = includesRegistry || [];
589
665
  this.#conditionRegistry = conditionRegistry || { ...BUILT_IN_CONDITIONS };
666
+ this.#extendProvenance = extendProvenance || /* @__PURE__ */ new Map();
667
+ this.#extendCount = extendCount || 0;
668
+ }
669
+ #originOf(provenanceKey) {
670
+ const index = this.#extendProvenance.get(provenanceKey);
671
+ return index === void 0 ? "builder state" : `extended source #${index}`;
672
+ }
673
+ from(source) {
674
+ const instance = isLibraryBundle(source) ? source.system : source;
675
+ return new SystemBuilder(this.#propRegistry, this.#groupRegistry, this.#selectorRegistry, [...this.#includesRegistry, instance], this.#conditionRegistry, this.#extendProvenance, this.#extendCount);
676
+ }
677
+ extend(source) {
678
+ const instance = isLibraryBundle(source) ? source.system : source;
679
+ const snapshot = instance.getRegistrySnapshot?.();
680
+ if (!snapshot) throw new Error("extend: source system carries no registry snapshot — it was built by an older @animus-ui/system. Rebuild the source against this version (a lossy toConfig() reconstruction is never substituted).");
681
+ const sourceIndex = this.#extendCount + 1;
682
+ const incomingOrigin = `extended source #${sourceIndex}`;
683
+ const provenance = new Map(this.#extendProvenance);
684
+ const nextProps = { ...this.#propRegistry };
685
+ for (const [name, incoming] of Object.entries(snapshot.props)) {
686
+ if (name in this.#groupRegistry) throw new Error(`extend: prop "${name}" (${incomingOrigin}) collides with an existing group name (${this.#originOf(`group:${name}`)}). Group names and prop names must be disjoint.`);
687
+ const existing = nextProps[name];
688
+ if (!existing) {
689
+ nextProps[name] = incoming;
690
+ provenance.set(`prop:${name}`, sourceIndex);
691
+ } else if (!arePropDefinitionsEqual(existing, incoming, true)) throw divergentPropError(name, existing, incoming, this.#originOf(`prop:${name}`), incomingOrigin);
692
+ }
693
+ const nextGroups = { ...this.#groupRegistry };
694
+ for (const [name, incoming] of Object.entries(snapshot.groups)) {
695
+ const existing = nextGroups[name];
696
+ if (!existing) {
697
+ if (name in nextProps) throw new Error(`extend: group name "${name}" (${incomingOrigin}) collides with an existing prop name (${this.#originOf(`prop:${name}`)}). Group names and prop names must be disjoint.`);
698
+ nextGroups[name] = [...incoming];
699
+ provenance.set(`group:${name}`, sourceIndex);
700
+ } else if (!orderedMembersEqual(existing, incoming)) throw new Error(`extend: group "${name}" already registered with different membership. Existing (${this.#originOf(`group:${name}`)}): [${existing.join(", ")}]. Incoming (${incomingOrigin}): [${incoming.join(", ")}].`);
701
+ }
702
+ const selectorOverrides = {};
703
+ const newSelectors = {};
704
+ for (const [name, incoming] of Object.entries(snapshot.selectors)) {
705
+ const builtIn = BUILT_IN_SELECTORS[name];
706
+ if (builtIn && builtIn.selector === incoming.selector) continue;
707
+ if (name in this.#conditionRegistry) throw new Error(`extend: selector alias "${name}" (${incomingOrigin}) is already registered as a condition alias (${this.#originOf(`condition:${name}`)}); a name resolves through exactly one registry. Pick a distinct name.`);
708
+ const existing = this.#selectorRegistry[name];
709
+ if (!existing) {
710
+ newSelectors[name] = incoming.selector;
711
+ provenance.set(`selector:${name}`, sourceIndex);
712
+ } else if (existing.selector !== incoming.selector) {
713
+ const existingIndex = provenance.get(`selector:${name}`);
714
+ if (existingIndex === void 0) {
715
+ selectorOverrides[name] = {
716
+ selector: incoming.selector,
717
+ order: existing.order
718
+ };
719
+ provenance.set(`selector:${name}`, sourceIndex);
720
+ } else throw new Error(`extend: selector alias "${name}" already registered with a different selector. Existing (extended source #${existingIndex}): "${existing.selector}". Incoming (${incomingOrigin}): "${incoming.selector}".`);
721
+ }
722
+ }
723
+ const nextSelectors = mergeSelectors({
724
+ ...this.#selectorRegistry,
725
+ ...selectorOverrides
726
+ }, newSelectors);
727
+ const conditionOverrides = {};
728
+ const newConditions = {};
729
+ for (const [name, incoming] of Object.entries(snapshot.conditions)) {
730
+ const builtIn = BUILT_IN_CONDITIONS[name];
731
+ if (builtIn && builtIn.value === incoming.value) continue;
732
+ if (name in nextSelectors) throw new Error(`extend: condition alias "${name}" (${incomingOrigin}) is already registered as a selector alias (${this.#originOf(`selector:${name}`)}); a name resolves through exactly one registry. Pick a distinct name.`);
733
+ const existing = this.#conditionRegistry[name];
734
+ if (!existing) {
735
+ newConditions[name] = incoming.value;
736
+ provenance.set(`condition:${name}`, sourceIndex);
737
+ } else if (existing.value !== incoming.value) {
738
+ const existingIndex = provenance.get(`condition:${name}`);
739
+ if (existingIndex === void 0) {
740
+ conditionOverrides[name] = {
741
+ value: incoming.value,
742
+ order: existing.order,
743
+ kind: incoming.kind
744
+ };
745
+ provenance.set(`condition:${name}`, sourceIndex);
746
+ } else throw new Error(`extend: condition alias "${name}" already registered with a different condition. Existing (extended source #${existingIndex}): "${existing.value}". Incoming (${incomingOrigin}): "${incoming.value}".`);
747
+ }
748
+ }
749
+ const nextConditions = mergeConditions({
750
+ ...this.#conditionRegistry,
751
+ ...conditionOverrides
752
+ }, newConditions, new Set(Object.keys(nextSelectors)));
753
+ return new SystemBuilder(nextProps, nextGroups, nextSelectors, [...this.#includesRegistry, instance], nextConditions, provenance, sourceIndex);
590
754
  }
591
755
  addSelectors(selectors) {
592
756
  for (const name of Object.keys(selectors)) if (name in this.#conditionRegistry) throw new Error(`addSelectors: "${name}" is already registered as a condition alias; a name resolves through exactly one registry. Pick a distinct name.`);
593
757
  const merged = mergeSelectors(this.#selectorRegistry, selectors);
594
- return new SystemBuilder(this.#propRegistry, this.#groupRegistry, merged, this.#includesRegistry, this.#conditionRegistry);
758
+ return new SystemBuilder(this.#propRegistry, this.#groupRegistry, merged, this.#includesRegistry, this.#conditionRegistry, this.#extendProvenance, this.#extendCount);
595
759
  }
596
760
  /**
597
761
  * Register condition aliases (`_motionReduce`, `_cardSm`, …) → at-rule
@@ -605,14 +769,17 @@ var SystemBuilder = class SystemBuilder {
605
769
  */
606
770
  addConditions(conditions) {
607
771
  const merged = mergeConditions(this.#conditionRegistry, conditions, new Set(Object.keys(this.#selectorRegistry)));
608
- return new SystemBuilder(this.#propRegistry, this.#groupRegistry, this.#selectorRegistry, this.#includesRegistry, merged);
772
+ return new SystemBuilder(this.#propRegistry, this.#groupRegistry, this.#selectorRegistry, this.#includesRegistry, merged, this.#extendProvenance, this.#extendCount);
609
773
  }
610
774
  addGroup(name, config) {
611
775
  if (name in this.#propRegistry) throw new Error(`Group name "${name}" collides with an existing prop name. Group names and prop names must be disjoint.`);
612
776
  for (const key of Object.keys(config)) if (key in this.#propRegistry) {
613
777
  const existing = this.#propRegistry[key];
614
778
  const incoming = config[key];
615
- if (!arePropDefinitionsEqual(existing, incoming)) throw new Error(`Prop "${key}" already registered with a different definition. Existing: property="${existing.property}", scale="${String(existing.scale)}". Incoming: property="${incoming.property}", scale="${String(incoming.scale)}".`);
779
+ if (!arePropDefinitionsEqual(existing, incoming, this.#extendProvenance.has(`prop:${key}`))) {
780
+ if (this.#extendProvenance.has(`prop:${key}`)) throw divergentPropError(key, existing, incoming, this.#originOf(`prop:${key}`), "builder state");
781
+ throw new Error(`Prop "${key}" already registered with a different definition. Existing: property="${existing.property}", scale="${String(existing.scale)}". Incoming: property="${incoming.property}", scale="${String(incoming.scale)}".`);
782
+ }
616
783
  }
617
784
  const nextProps = {
618
785
  ...this.#propRegistry,
@@ -622,32 +789,37 @@ var SystemBuilder = class SystemBuilder {
622
789
  return new SystemBuilder(nextProps, {
623
790
  ...this.#groupRegistry,
624
791
  ...newGroup
625
- }, this.#selectorRegistry, this.#includesRegistry, this.#conditionRegistry);
792
+ }, this.#selectorRegistry, this.#includesRegistry, this.#conditionRegistry, this.#extendProvenance, this.#extendCount);
626
793
  }
627
794
  addProps(config) {
628
795
  for (const key of Object.keys(config)) if (key in this.#groupRegistry) throw new Error(`Prop name "${key}" collides with an existing group name. Group names and prop names must be disjoint.`);
629
796
  for (const key of Object.keys(config)) if (key in this.#propRegistry) {
630
797
  const existing = this.#propRegistry[key];
631
798
  const incoming = config[key];
632
- if (!arePropDefinitionsEqual(existing, incoming)) throw new Error(`Prop "${key}" already registered with a different definition.`);
799
+ if (!arePropDefinitionsEqual(existing, incoming, this.#extendProvenance.has(`prop:${key}`))) {
800
+ if (this.#extendProvenance.has(`prop:${key}`)) throw divergentPropError(key, existing, incoming, this.#originOf(`prop:${key}`), "builder state");
801
+ throw new Error(`Prop "${key}" already registered with a different definition.`);
802
+ }
633
803
  }
634
804
  return new SystemBuilder({
635
805
  ...this.#propRegistry,
636
806
  ...config
637
- }, this.#groupRegistry, this.#selectorRegistry, this.#includesRegistry, this.#conditionRegistry);
807
+ }, this.#groupRegistry, this.#selectorRegistry, this.#includesRegistry, this.#conditionRegistry, this.#extendProvenance, this.#extendCount);
638
808
  }
639
809
  build() {
640
- const animus = new Animus(this.#propRegistry, this.#groupRegistry);
641
- const propRegistry = this.#propRegistry;
642
- const groupRegistry = this.#groupRegistry;
643
- const selectorRegistry = this.#selectorRegistry;
644
- const conditionRegistry = this.#conditionRegistry;
810
+ const animus = new Animus(Object.fromEntries(Object.entries(this.#propRegistry).map(([key, entry]) => [key, { ...entry }])), Object.fromEntries(Object.entries(this.#groupRegistry).map(([key, members]) => [key, [...members]])));
811
+ const snapshot = createRegistrySnapshot(this.#propRegistry, this.#groupRegistry, this.#selectorRegistry, this.#conditionRegistry);
645
812
  const system = Object.assign(animus, { toConfig: () => {
646
- return serializeInstance(propRegistry, groupRegistry, selectorRegistry, conditionRegistry);
813
+ return serializeInstance(snapshot.props, snapshot.groups, snapshot.selectors, snapshot.conditions);
647
814
  } });
648
- const createGlobalStyles = ((styles) => ({
815
+ Object.defineProperty(system, "getRegistrySnapshot", {
816
+ value: () => snapshot,
817
+ enumerable: false
818
+ });
819
+ const createGlobalStyles = ((styles, options) => ({
649
820
  __brand: "GlobalStyleBlock",
650
- styles
821
+ styles,
822
+ ...options?.fontFaces?.length ? { fontFaces: [...options.fontFaces] } : {}
651
823
  }));
652
824
  const createKeyframes = ((frames) => keyframes(frames));
653
825
  return {
@@ -657,9 +829,41 @@ var SystemBuilder = class SystemBuilder {
657
829
  };
658
830
  }
659
831
  };
832
+ /**
833
+ * Freeze the builder's registries into the build-time snapshot (design D7):
834
+ * containers, per-entry objects, and the mutable values nested inside a prop
835
+ * (`properties` arrays, object/array scales) are copies, so neither the
836
+ * builder's onward chaining nor post-build mutation of the instance's public
837
+ * registry fields reaches serialized or merged output. Transform functions
838
+ * are cached immutable forwarding wrappers: behavior survives without keeping
839
+ * mutable serialization metadata live.
840
+ */
841
+ function createRegistrySnapshot(propRegistry, groupRegistry, selectorRegistry, conditionRegistry) {
842
+ const props = {};
843
+ for (const [name, entry] of Object.entries(propRegistry)) {
844
+ const copy = { ...entry };
845
+ if (copy.properties) copy.properties = Object.freeze([...copy.properties]);
846
+ if (copy.scale && typeof copy.scale === "object") copy.scale = Object.freeze(Array.isArray(copy.scale) ? [...copy.scale] : { ...copy.scale });
847
+ if (copy.transform) copy.transform = snapshotTransform(copy.transform);
848
+ props[name] = Object.freeze(copy);
849
+ }
850
+ const groups = {};
851
+ for (const [name, members] of Object.entries(groupRegistry)) groups[name] = Object.freeze([...members]);
852
+ const selectors = {};
853
+ for (const [name, entry] of Object.entries(selectorRegistry)) selectors[name] = Object.freeze({ ...entry });
854
+ const conditions = {};
855
+ for (const [name, entry] of Object.entries(conditionRegistry)) conditions[name] = Object.freeze({ ...entry });
856
+ return Object.freeze({
857
+ props: Object.freeze(props),
858
+ groups: Object.freeze(groups),
859
+ selectors: Object.freeze(selectors),
860
+ conditions: Object.freeze(conditions)
861
+ });
862
+ }
660
863
  function serializeInstance(propRegistry, groupRegistry, selectorRegistry, conditionRegistry) {
661
864
  const serialized = {};
662
865
  const transforms = {};
866
+ const transformOwners = {};
663
867
  for (const [propName, entry] of Object.entries(propRegistry)) {
664
868
  const s = { property: entry.property };
665
869
  if (entry.properties && entry.properties.length > 0) s.properties = [...entry.properties];
@@ -671,8 +875,11 @@ function serializeInstance(propRegistry, groupRegistry, selectorRegistry, condit
671
875
  const fn = entry.transform;
672
876
  const name = fn.transformName ?? fn.name;
673
877
  if (name) {
878
+ const existing = transforms[name];
879
+ if (existing && !areTransformsEqual(existing, fn)) throw new Error(`Transform name "${name}" is registered by both props "${transformOwners[name]}" and "${propName}" with different function instances. Share one cached transform instance or give the transforms distinct names.`);
674
880
  s.transform = name;
675
881
  transforms[name] = fn;
882
+ transformOwners[name] = propName;
676
883
  }
677
884
  }
678
885
  if (entry.currentVar) s.currentVar = entry.currentVar;
@@ -692,6 +899,144 @@ function createSystem(config) {
692
899
  return new SystemBuilder(void 0, void 0, void 0, config?.includes ?? []);
693
900
  }
694
901
  //#endregion
902
+ //#region src/theme/resolveReferences.ts
903
+ /**
904
+ * Late-binding token-reference resolution over the flattened theme (D4,
905
+ * openspec change first-class-extension).
906
+ *
907
+ * Replaces the old single-pass, insertion-order-dependent rewrite: references
908
+ * resolve against the COMPLETE flattened map via depth-first traversal of the
909
+ * reference DAG, so declaration order is never observable (G3), references
910
+ * inside emitted scales resolve into the emitted variable declarations
911
+ * instead of leaking literally into CSS (G2), and a token resolves to the
912
+ * same value whether its scale is emitted or inlined (G1 — an emitted target
913
+ * substitutes as `var()`, which the cascade late-binds to exactly the value
914
+ * the inlined form substitutes directly).
915
+ *
916
+ * Pure module: consumes the flatten pass's maps, returns fresh maps, mutates
917
+ * nothing. Runs in QuickJS — ES built-ins only, no Node/WHATWG APIs.
918
+ */
919
+ /** Token ref pattern: {scale.key}, {scale.key.sub}, or {path/NN} (opacity). */
920
+ const TOKEN_REF_RE$1 = /\{([^}]+)\}/g;
921
+ /** DFS node states for cycle detection. */
922
+ const UNVISITED = 0;
923
+ const RESOLVING = 1;
924
+ const RESOLVED = 2;
925
+ /** Parse every `{...}` occurrence via matchAll — no shared lastIndex. */
926
+ function parseReferences(value) {
927
+ const references = [];
928
+ for (const match of value.matchAll(TOKEN_REF_RE$1)) {
929
+ const text = match[1];
930
+ const slashIdx = text.indexOf("/");
931
+ references.push(slashIdx === -1 ? {
932
+ text,
933
+ path: text
934
+ } : {
935
+ text,
936
+ path: text.slice(0, slashIdx),
937
+ opacity: text.slice(slashIdx + 1)
938
+ });
939
+ }
940
+ return references;
941
+ }
942
+ /**
943
+ * Resolve token references over the complete flattened maps.
944
+ *
945
+ * - `tokenMap`/`variableMap`/`variables` are `flattenTheme`'s outputs: an
946
+ * emitted path's tokenMap entry is its `var()` indirection and its RAW
947
+ * authored value is parked in `variables`; a non-emitted path's tokenMap
948
+ * entry IS its raw value.
949
+ * - Substitution per reference: emitted target → `var(--target)`; non-emitted
950
+ * target → its resolved literal; `{path/NN}` keeps the existing
951
+ * `color-mix` output shape (`/0` → `transparent`, `/100` → the base).
952
+ * - Unresolvable target: warn once per missing path, keep the literal — a
953
+ * SUPPORTED pattern (a kit theme may reference tokens its consumer
954
+ * provides later). Dangling-reference errors arrive with the explicit
955
+ * replacement form (D5, increment 04).
956
+ * - Reference cycle: hard error naming the cycle's token paths in traversal
957
+ * order, regardless of emission flags (emission-invariant failure, G1).
958
+ */
959
+ function resolveReferences(tokenMap, variableMap, variables) {
960
+ const paths = Object.keys(tokenMap).sort();
961
+ const known = new Set(paths);
962
+ /** The authored (raw) value behind a path — see the contract note above. */
963
+ const rawValueOf = (path) => {
964
+ const varName = variableMap[path];
965
+ if (varName !== void 0 && variables[varName] !== void 0) return variables[varName];
966
+ return tokenMap[path];
967
+ };
968
+ const referencesByPath = /* @__PURE__ */ new Map();
969
+ for (const path of paths) {
970
+ const raw = rawValueOf(path);
971
+ if (typeof raw === "string" && raw.includes("{")) {
972
+ const references = parseReferences(raw);
973
+ if (references.length > 0) referencesByPath.set(path, references);
974
+ }
975
+ }
976
+ const state = /* @__PURE__ */ new Map();
977
+ const resolved = /* @__PURE__ */ new Map();
978
+ /** Active DFS trail — the cycle report slices it from the revisited node. */
979
+ const trail = [];
980
+ const warnedMissing = /* @__PURE__ */ new Set();
981
+ const substitute = (reference, match) => {
982
+ if (!known.has(reference.path)) {
983
+ if (!warnedMissing.has(reference.path)) {
984
+ warnedMissing.add(reference.path);
985
+ console.warn(`[animus] Token ref {${reference.text}} — path '${reference.path}' not found in token map`);
986
+ }
987
+ return match;
988
+ }
989
+ const targetVar = variableMap[reference.path];
990
+ const targetValue = resolved.get(reference.path);
991
+ const base = targetVar !== void 0 && !targetValue.includes("{") ? `var(${targetVar})` : targetValue;
992
+ if (reference.opacity !== void 0) {
993
+ const alpha = Number.parseInt(reference.opacity, 10);
994
+ if (Number.isNaN(alpha)) return base;
995
+ if (alpha === 0) return "transparent";
996
+ if (alpha !== 100) return `color-mix(in srgb, ${base} ${alpha}%, transparent)`;
997
+ }
998
+ return base;
999
+ };
1000
+ const resolvePath = (path) => {
1001
+ const status = state.get(path) ?? UNVISITED;
1002
+ if (status === RESOLVED) return;
1003
+ if (status === RESOLVING) {
1004
+ const cycle = [...trail.slice(trail.indexOf(path)), path];
1005
+ throw new Error(`build: token reference cycle — ${cycle.map((cyclePath) => `'${cyclePath}'`).join(" → ")}. Reference cycles cannot be resolved; give one of these tokens a literal value.`);
1006
+ }
1007
+ state.set(path, RESOLVING);
1008
+ trail.push(path);
1009
+ const references = referencesByPath.get(path);
1010
+ if (references) {
1011
+ for (const reference of references) if (known.has(reference.path)) resolvePath(reference.path);
1012
+ }
1013
+ let value = rawValueOf(path);
1014
+ if (references) {
1015
+ let index = 0;
1016
+ value = value.replace(TOKEN_REF_RE$1, (match) => substitute(references[index++], match));
1017
+ }
1018
+ trail.pop();
1019
+ state.set(path, RESOLVED);
1020
+ resolved.set(path, value);
1021
+ };
1022
+ for (const path of paths) resolvePath(path);
1023
+ const outTokenMap = {};
1024
+ const outVariables = {};
1025
+ for (const path of paths) {
1026
+ const varName = variableMap[path];
1027
+ if (varName === void 0) outTokenMap[path] = resolved.get(path);
1028
+ else {
1029
+ outTokenMap[path] = tokenMap[path];
1030
+ if (variables[varName] !== void 0) outVariables[varName] = resolved.get(path);
1031
+ }
1032
+ }
1033
+ for (const varName of Object.keys(variables).sort()) if (outVariables[varName] === void 0) outVariables[varName] = variables[varName];
1034
+ return {
1035
+ tokenMap: outTokenMap,
1036
+ variables: outVariables
1037
+ };
1038
+ }
1039
+ //#endregion
695
1040
  //#region src/theme/utils.ts
696
1041
  /** Type guard: true for non-null, non-array objects */
697
1042
  function isObject(value) {
@@ -796,16 +1141,18 @@ function validateModeAliases(modeName, aliases, nestedColors, flatColorKeys, pre
796
1141
  * the `:root:not([data-color-mode])` guard while matching no mode block.
797
1142
  */
798
1143
  const RESERVED_MODE_NAME = "system";
799
- /**
800
- * Theme keys owned by the color-mode options. A scale may not claim them —
801
- * they are skipped by `flattenTheme` and read back as option objects in
802
- * `build()`, so a same-named scale would emit zero tokens AND fabricate a
803
- * manifest field from its values.
804
- *
805
- * NOTE: the pre-existing `mode` / `modes` / `breakpoints` structural keys have
806
- * the same hole and are deliberately NOT covered here (out of scope).
807
- */
808
- const RESERVED_THEME_KEYS = new Set(["systemPreference", "browserColorScheme"]);
1144
+ const RESERVED_THEME_KEYS = new Set([
1145
+ "breakpoints",
1146
+ "modes",
1147
+ "mode",
1148
+ "systemPreference",
1149
+ "browserColorScheme",
1150
+ "modeBases",
1151
+ "__emitted",
1152
+ "manifest",
1153
+ "serialize",
1154
+ "varRef"
1155
+ ]);
809
1156
  const COLOR_SCHEME_VALUES = new Set([
810
1157
  "light",
811
1158
  "dark",
@@ -893,6 +1240,33 @@ function resolveColorModeOptions(modeNames, systemPreference, suppliedBrowserCol
893
1240
  }
894
1241
  return browserColorScheme;
895
1242
  }
1243
+ /**
1244
+ * Validate the D6 `basedOn` mode-base map against the MERGED mode set: every
1245
+ * key and every base must name a declared mode, self-bases are rejected, and
1246
+ * base chains must terminate (a cycle can never fill coverage). Runs at both
1247
+ * gates like the other mode options — `addColorModes` (fail fast) and
1248
+ * `build()` (authoritative: extend/from composition merges modes without
1249
+ * passing through `addColorModes`).
1250
+ */
1251
+ function validateModeBases(modeNames, modeBases) {
1252
+ if (!modeBases) return;
1253
+ const declared = new Set(modeNames);
1254
+ const available = `Available modes: ${modeNames.join(", ")}`;
1255
+ for (const [modeName, base] of Object.entries(modeBases)) {
1256
+ if (!declared.has(modeName)) throw new Error(`addColorModes: basedOn names unknown mode '${modeName}'. ${available}`);
1257
+ if (typeof base !== "string" || !declared.has(base)) throw new Error(`addColorModes: basedOn['${modeName}'] references unknown base mode '${String(base)}'. ${available}`);
1258
+ if (base === modeName) throw new Error(`addColorModes: basedOn['${modeName}'] cannot base a mode on itself.`);
1259
+ }
1260
+ for (const start of Object.keys(modeBases)) {
1261
+ const seen = /* @__PURE__ */ new Set();
1262
+ let cursor = start;
1263
+ while (cursor !== void 0) {
1264
+ if (seen.has(cursor)) throw new Error(`addColorModes: basedOn chain cycles — ${[...seen, cursor].map((mode) => `'${mode}'`).join(" → ")}. Give one mode in the chain a covered literal base.`);
1265
+ seen.add(cursor);
1266
+ cursor = modeBases[cursor];
1267
+ }
1268
+ }
1269
+ }
896
1270
  /** Validate all color entries, throwing on invalid values. */
897
1271
  function validateColors(colors) {
898
1272
  for (const [key, value] of Object.entries(colors)) if (isObject(value)) validateColors(value);
@@ -904,7 +1278,12 @@ function createState(theme) {
904
1278
  emittedScales: /* @__PURE__ */ new Set(),
905
1279
  contextualVars: /* @__PURE__ */ new Map(),
906
1280
  contextualVarRegistrations: /* @__PURE__ */ new Map(),
907
- hasLegacyManifestSource: false
1281
+ hasLegacyManifestSource: false,
1282
+ extendProvenance: /* @__PURE__ */ new Map(),
1283
+ extendCount: 0,
1284
+ inheritedModes: /* @__PURE__ */ new Set(),
1285
+ inheritedModeAliases: /* @__PURE__ */ new Set(),
1286
+ droppedTokenPaths: /* @__PURE__ */ new Map()
908
1287
  };
909
1288
  }
910
1289
  function copyState(state, nextTheme) {
@@ -914,12 +1293,101 @@ function copyState(state, nextTheme) {
914
1293
  contextualVars: /* @__PURE__ */ new Map(),
915
1294
  contextualVarRegistrations: new Map(state.contextualVarRegistrations),
916
1295
  ...state.carriedManifestV2 ? { carriedManifestV2: { ...state.carriedManifestV2 } } : {},
917
- hasLegacyManifestSource: state.hasLegacyManifestSource
1296
+ hasLegacyManifestSource: state.hasLegacyManifestSource,
1297
+ extendProvenance: new Map(state.extendProvenance),
1298
+ extendCount: state.extendCount,
1299
+ inheritedModes: new Set(state.inheritedModes),
1300
+ inheritedModeAliases: new Set(state.inheritedModeAliases),
1301
+ droppedTokenPaths: new Map(state.droppedTokenPaths)
918
1302
  };
919
1303
  for (const [scale, vars] of state.contextualVars) next.contextualVars.set(scale, [...vars]);
920
1304
  return next;
921
1305
  }
922
1306
  /**
1307
+ * Exact leaf-path flatten for extend() provenance: like `flattenToDotPaths`
1308
+ * but WITHOUT the `_` identity-key collapse, so a leaf and a branch can
1309
+ * never share a path spelling. A prefix relation between two tracked paths
1310
+ * is then a GENUINE structural divergence (one sibling authored a leaf
1311
+ * value where the other authored a nested branch) — the review-F1 case that
1312
+ * per-leaf value comparison alone cannot see.
1313
+ */
1314
+ function flattenLeafPathsExact(object, path) {
1315
+ const result = {};
1316
+ for (const key of Object.keys(object)) {
1317
+ const nextKey = path ? `${path}.${key}` : key;
1318
+ const current = object[key];
1319
+ if (isObject(current)) Object.assign(result, flattenLeafPathsExact(current, nextKey));
1320
+ else result[nextKey] = current;
1321
+ }
1322
+ return result;
1323
+ }
1324
+ /**
1325
+ * Deep copy of plain theme data (records, arrays, primitives). `merge`
1326
+ * adopts and MUTATES nested source objects in place, so EVERY builder step
1327
+ * copies before folding: a consumed kit's built theme must never be
1328
+ * corrupted by composition, and prior builder state must never be shared by
1329
+ * reference — a one-level copy lets branching a builder cross-contaminate
1330
+ * both branches and lets build() outputs mutate after the fact.
1331
+ */
1332
+ function deepCopyPlain(value) {
1333
+ if (Array.isArray(value)) return value.map(deepCopyPlain);
1334
+ if (isObject(value)) {
1335
+ const record = value;
1336
+ const copy = {};
1337
+ for (const key of Object.keys(record)) copy[key] = deepCopyPlain(record[key]);
1338
+ return copy;
1339
+ }
1340
+ return value;
1341
+ }
1342
+ /** Structural equality over plain theme data (records, arrays, primitives). */
1343
+ function plainDataEqual(a, b) {
1344
+ if (Object.is(a, b)) return true;
1345
+ if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((item, index) => plainDataEqual(item, b[index]));
1346
+ if (isObject(a) && isObject(b)) {
1347
+ const aRecord = a;
1348
+ const bRecord = b;
1349
+ const aKeys = Object.keys(aRecord);
1350
+ return aKeys.length === Object.keys(bRecord).length && aKeys.every((key) => Object.prototype.hasOwnProperty.call(bRecord, key) && plainDataEqual(aRecord[key], bRecord[key]));
1351
+ }
1352
+ return false;
1353
+ }
1354
+ /**
1355
+ * Re-seed builder state from a source theme's manifest. This is `from()`'s
1356
+ * manifest read, factored out verbatim so `extend()` inherits the SAME
1357
+ * emitted-scale/contextual-var/manifest-v2 carry semantics: the manifest is
1358
+ * non-enumerable, so the ordinary key-copy loop never sees it (D6/D8).
1359
+ */
1360
+ function reseedStateFromManifest(state, manifest, mergeExtensionState = false) {
1361
+ if (manifest?.emittedScales) for (const scale of manifest.emittedScales) state.emittedScales.add(scale);
1362
+ else if (manifest?.variableMap) {
1363
+ const emittedPaths = manifest.manifestVersion === 2 && manifest.tokenDefinitions ? Object.keys(manifest.variableMap).filter((tokenPath) => manifest.tokenDefinitions?.[tokenPath] !== void 0) : Object.keys(manifest.variableMap);
1364
+ for (const tokenPath of emittedPaths) {
1365
+ const scale = tokenPath.split(".")[0];
1366
+ state.emittedScales.add(scale === "colors" ? "colors" : scale);
1367
+ }
1368
+ }
1369
+ if (manifest?.contextualVars) for (const [scale, vars] of Object.entries(manifest.contextualVars)) {
1370
+ const existing = mergeExtensionState ? state.contextualVars.get(scale) : void 0;
1371
+ state.contextualVars.set(scale, existing ? [...new Set([...existing, ...vars])] : [...vars]);
1372
+ }
1373
+ if (manifest) if (manifest.manifestVersion === 2) {
1374
+ state.carriedManifestV2 = {
1375
+ tokenDefinitions: manifest.tokenDefinitions,
1376
+ emittedScales: manifest.emittedScales,
1377
+ modeAliasDefinitions: manifest.modeAliasDefinitions,
1378
+ registrations: manifest.registrations,
1379
+ emitterVersion: manifest.emitterVersion,
1380
+ contractHash: manifest.contractHash,
1381
+ cssFragments: manifest.cssFragments
1382
+ };
1383
+ if (manifest.registrations) for (const [name, registration] of Object.entries(manifest.registrations)) {
1384
+ const existing = state.contextualVarRegistrations.get(name);
1385
+ if (mergeExtensionState && existing && (existing.syntax !== registration.syntax || existing.inherits !== registration.inherits || existing.initialValue !== registration.initialValue)) throw new Error(`extend: contextual variable '${name}' has divergent @property registrations across extended themes`);
1386
+ state.contextualVarRegistrations.set(name, registration);
1387
+ }
1388
+ } else state.hasLegacyManifestSource = true;
1389
+ }
1390
+ /**
923
1391
  * ThemeScales — the final phase. Has addScale, extendScale, declareContextualVars, build.
924
1392
  * Also allows addColors and addColorModes for augmentation.
925
1393
  */
@@ -930,39 +1398,94 @@ var ThemeBuilder = class ThemeBuilder {
930
1398
  }
931
1399
  addBreakpoints(breakpoints) {
932
1400
  for (const [key, value] of Object.entries(breakpoints)) if (typeof value !== "number" || value < 0) throw new Error(`addBreakpoints: breakpoint '${key}' must be a non-negative number, got ${JSON.stringify(value)}`);
933
- const nextTheme = merge({}, this._state.theme, { breakpoints });
1401
+ const nextTheme = merge(deepCopyPlain(this._state.theme), { breakpoints });
934
1402
  return new ThemeBuilder(copyState(this._state, nextTheme));
935
1403
  }
1404
+ /**
1405
+ * FROZEN for the deprecation window (D1/G6): source-WINS precedence over
1406
+ * prior builder state, callable at ANY stage — the phantom Stage passes
1407
+ * through unchanged. Do not add a stage gate; do not flip the merge
1408
+ * direction. Those semantics ship under the new verb only.
1409
+ *
1410
+ * @deprecated Use `extend(source)` — the single extension verb on both
1411
+ * builders: the extended source seeds the base and later local calls win.
1412
+ * `from()` keeps these frozen source-wins semantics for at least one minor
1413
+ * release after `extend()` ships.
1414
+ */
936
1415
  from(builtTheme) {
1416
+ const source = isLibraryBundle(builtTheme) ? builtTheme.theme ?? builtTheme.tokens ?? {} : builtTheme;
937
1417
  const raw = {};
938
- for (const key of Object.keys(builtTheme)) {
939
- const val = builtTheme[key];
1418
+ for (const key of Object.keys(source)) {
1419
+ const val = source[key];
940
1420
  if (typeof val !== "function") raw[key] = val;
941
1421
  }
942
- const nextTheme = merge({}, this._state.theme, raw);
1422
+ const nextTheme = merge(deepCopyPlain(this._state.theme), deepCopyPlain(raw));
943
1423
  const next = new ThemeBuilder(copyState(this._state, nextTheme));
944
- const manifest = builtTheme.manifest;
945
- if (manifest?.variableMap) for (const tokenPath of Object.keys(manifest.variableMap)) {
946
- const scale = tokenPath.split(".")[0];
947
- next._state.emittedScales.add(scale === "colors" ? "colors" : scale);
1424
+ reseedStateFromManifest(next._state, source.manifest);
1425
+ return next;
1426
+ }
1427
+ /**
1428
+ * Extend this theme from a consumed library (D1/D2): the source's complete
1429
+ * configuration — tokens, modes, preferences, registrations, emitted-scale
1430
+ * set — seeds the builder as the BASE, and local calls made after
1431
+ * `extend()` win on conflict (the mirror of `from()`'s source-wins).
1432
+ * Chainable and repeatable, but only before augmentation methods ("inherit
1433
+ * first, then extend" — enforced by the phantom builder stage). Accepts a
1434
+ * built theme or a library bundle, consuming the bundle's theme half
1435
+ * (`theme ?? tokens`, D9) and ignoring the rest.
1436
+ *
1437
+ * Sibling conflicts fail loud (D3/G4): a leaf path defined divergently by
1438
+ * two extended sources throws naming the path and both sources
1439
+ * positionally; equal values coalesce silently, and the consumer's own
1440
+ * post-extend `add*` calls override silently (NS-4).
1441
+ */
1442
+ extend(source) {
1443
+ const themeHalf = isLibraryBundle(source) ? source.theme ?? source.tokens ?? {} : source;
1444
+ const raw = {};
1445
+ for (const key of Object.keys(themeHalf)) {
1446
+ const val = themeHalf[key];
1447
+ if (typeof val !== "function") raw[key] = val;
948
1448
  }
949
- if (manifest?.contextualVars) for (const [scale, vars] of Object.entries(manifest.contextualVars)) next._state.contextualVars.set(scale, [...vars]);
950
- if (manifest) if (manifest.manifestVersion === 2) {
951
- next._state.carriedManifestV2 = {
952
- tokenDefinitions: manifest.tokenDefinitions,
953
- modeAliasDefinitions: manifest.modeAliasDefinitions,
954
- registrations: manifest.registrations,
955
- emitterVersion: manifest.emitterVersion,
956
- contractHash: manifest.contractHash,
957
- cssFragments: manifest.cssFragments
958
- };
959
- if (manifest.registrations) for (const [name, registration] of Object.entries(manifest.registrations)) next._state.contextualVarRegistrations.set(name, registration);
960
- } else next._state.hasLegacyManifestSource = true;
1449
+ const sourceIndex = this._state.extendCount + 1;
1450
+ const provenance = new Map(this._state.extendProvenance);
1451
+ const existingLeaves = flattenLeafPathsExact(this._state.theme);
1452
+ const incomingLeaves = flattenLeafPathsExact(raw);
1453
+ const trackedPrefixes = /* @__PURE__ */ new Map();
1454
+ for (const [trackedPath, index] of provenance) for (let dot = trackedPath.lastIndexOf("."); dot !== -1; dot = trackedPath.lastIndexOf(".", dot - 1)) {
1455
+ const prefix = trackedPath.slice(0, dot);
1456
+ if (!trackedPrefixes.has(prefix)) trackedPrefixes.set(prefix, index);
1457
+ }
1458
+ for (const [path, value] of Object.entries(incomingLeaves)) {
1459
+ const priorIndex = provenance.get(path);
1460
+ if (priorIndex !== void 0) {
1461
+ const existing = existingLeaves[path];
1462
+ if (!plainDataEqual(existing, value)) throw new Error(`extend: path '${path}' is defined divergently by extended theme #${priorIndex} (${JSON.stringify(existing)}) and extended theme #${sourceIndex} (${JSON.stringify(value)}). Sibling themes must agree — override intentionally with an add* call after extend().`);
1463
+ continue;
1464
+ }
1465
+ const branchIndex = trackedPrefixes.get(path);
1466
+ if (branchIndex !== void 0) throw new Error(`extend: path '${path}' is defined divergently by extended theme #${branchIndex} (a nested branch) and extended theme #${sourceIndex} (a leaf value). Sibling themes must agree — override intentionally with an add* call after extend().`);
1467
+ for (let dot = path.lastIndexOf("."); dot !== -1; dot = path.lastIndexOf(".", dot - 1)) {
1468
+ const ancestor = path.slice(0, dot);
1469
+ const ancestorIndex = provenance.get(ancestor);
1470
+ if (ancestorIndex !== void 0 && ancestorIndex !== sourceIndex) throw new Error(`extend: path '${ancestor}' is defined divergently by extended theme #${ancestorIndex} (a leaf value) and extended theme #${sourceIndex} (a nested branch). Sibling themes must agree — override intentionally with an add* call after extend().`);
1471
+ }
1472
+ provenance.set(path, sourceIndex);
1473
+ }
1474
+ const nextTheme = merge(deepCopyPlain(raw), deepCopyPlain(this._state.theme));
1475
+ const next = new ThemeBuilder(copyState(this._state, nextTheme));
1476
+ next._state.extendProvenance = provenance;
1477
+ next._state.extendCount = sourceIndex;
1478
+ if (isObject(raw.modes)) for (const [modeName, modeAliases] of Object.entries(raw.modes)) {
1479
+ next._state.inheritedModes.add(modeName);
1480
+ if (!isObject(modeAliases)) continue;
1481
+ for (const aliasPath of Object.keys(flattenToDotPaths(modeAliases))) next._state.inheritedModeAliases.add(aliasPath);
1482
+ }
1483
+ reseedStateFromManifest(next._state, themeHalf.manifest, true);
961
1484
  return next;
962
1485
  }
963
1486
  addColors(colors) {
964
1487
  validateColors(colors);
965
- const nextTheme = merge({}, this._state.theme, { colors });
1488
+ const nextTheme = merge(deepCopyPlain(this._state.theme), { colors });
966
1489
  const next = new ThemeBuilder(copyState(this._state, nextTheme));
967
1490
  next._state.emittedScales.add("colors");
968
1491
  return next;
@@ -981,20 +1504,32 @@ var ThemeBuilder = class ThemeBuilder {
981
1504
  const systemPreference = mergeOptionObject(this._state.theme.systemPreference, options?.systemPreference);
982
1505
  const browserColorScheme = mergeOptionObject(this._state.theme.browserColorScheme, options?.browserColorScheme);
983
1506
  resolveColorModeOptions(modeNames, systemPreference, browserColorScheme);
984
- const nextTheme = merge({}, this._state.theme, {
1507
+ const modeBases = mergeOptionObject(this._state.theme.modeBases, options?.basedOn);
1508
+ validateModeBases(modeNames, modeBases);
1509
+ const nextTheme = merge(deepCopyPlain(this._state.theme), {
985
1510
  modes: modeConfig,
986
1511
  mode: initialMode,
987
1512
  ...systemPreference ? { systemPreference } : {},
988
- ...browserColorScheme ? { browserColorScheme } : {}
1513
+ ...browserColorScheme ? { browserColorScheme } : {},
1514
+ ...modeBases ? { modeBases } : {}
989
1515
  });
990
1516
  return new ThemeBuilder(copyState(this._state, nextTheme));
991
1517
  }
992
1518
  addScale(config) {
993
- const { name, values, emit } = config;
994
- if (RESERVED_THEME_KEYS.has(name)) throw new Error(`addScale: '${name}' is a reserved theme key owned by addColorModes options — it is skipped by the token flatten pass and read back as an option object, so a scale by this name would emit no tokens. Choose another scale name.`);
995
- const nextTheme = merge({}, this._state.theme, { [name]: values });
1519
+ const { name, values, emit, replace } = config;
1520
+ if (RESERVED_THEME_KEYS.has(name)) throw new Error(`addScale: '${name}' is a reserved theme key owned by the builder or built-theme boundary, so a scale by this name cannot survive build(). Choose another scale name.`);
1521
+ const prior = this._state.theme[name];
1522
+ let nextTheme;
1523
+ if (replace) {
1524
+ nextTheme = deepCopyPlain(this._state.theme);
1525
+ nextTheme[name] = values;
1526
+ } else nextTheme = merge(deepCopyPlain(this._state.theme), { [name]: values });
996
1527
  const next = new ThemeBuilder(copyState(this._state, nextTheme));
997
1528
  if (emit) next._state.emittedScales.add(name);
1529
+ if (replace && isObject(prior)) {
1530
+ const kept = new Set(Object.keys(flattenToDotPaths(values)));
1531
+ for (const dotKey of Object.keys(flattenToDotPaths(prior))) if (!kept.has(dotKey)) next._state.droppedTokenPaths.set(`${name}.${dotKey}`, name);
1532
+ }
998
1533
  return next;
999
1534
  }
1000
1535
  declareContextualVars(vars, registrations) {
@@ -1010,7 +1545,7 @@ var ThemeBuilder = class ThemeBuilder {
1010
1545
  return next;
1011
1546
  }
1012
1547
  extendScale(key, updateFn) {
1013
- const nextTheme = merge({}, this._state.theme, { [key]: updateFn(this._state.theme[key]) });
1548
+ const nextTheme = merge(deepCopyPlain(this._state.theme), { [key]: updateFn(this._state.theme[key]) });
1014
1549
  return new ThemeBuilder(copyState(this._state, nextTheme));
1015
1550
  }
1016
1551
  /**
@@ -1018,24 +1553,44 @@ var ThemeBuilder = class ThemeBuilder {
1018
1553
  * Flattens nested data at the boundary — produces manifest and serialize().
1019
1554
  */
1020
1555
  build() {
1021
- const theme = merge({}, this._state.theme);
1556
+ const theme = deepCopyPlain(this._state.theme);
1022
1557
  const emittedScales = this._state.emittedScales;
1023
1558
  const contextualVars = this._state.contextualVars;
1024
1559
  const systemPreference = isObject(theme.systemPreference) ? theme.systemPreference : void 0;
1025
1560
  const mergedModeNames = isObject(theme.modes) ? Object.keys(theme.modes) : [];
1026
1561
  validateReservedModeNames(mergedModeNames);
1027
1562
  const browserColorScheme = resolveColorModeOptions(mergedModeNames, systemPreference, isObject(theme.browserColorScheme) ? theme.browserColorScheme : void 0);
1028
- const { tokenMap, variableMap, variables, modeVariables, modeTokens, tokenDefinitions, modeAliasDefinitions } = flattenTheme(theme, emittedScales);
1029
- resolveTokenRefs(tokenMap, variableMap, emittedScales);
1563
+ const modeBases = isObject(theme.modeBases) ? theme.modeBases : void 0;
1564
+ validateModeBases(mergedModeNames, modeBases);
1565
+ if (isObject(theme.modes) && isObject(theme.colors)) {
1566
+ const nestedColors = theme.colors;
1567
+ const flatColorKeys = Object.keys(flattenToDotPaths(nestedColors));
1568
+ for (const [modeName, modeAliases] of Object.entries(theme.modes)) {
1569
+ if (!isObject(modeAliases)) continue;
1570
+ validateModeAliases(modeName, modeAliases, nestedColors, flatColorKeys, "");
1571
+ }
1572
+ }
1573
+ const modeAliasDefinitions = collectAuthoredModeAliases(theme);
1574
+ const { effectiveModes, coverageFills } = resolveModeCoverage(modeAliasDefinitions, modeBases, this._state.inheritedModes, this._state.inheritedModeAliases);
1575
+ for (const fill of coverageFills) console.info(`[animus] Mode '${fill.mode}': ${fill.count} alias(es) inherit from '${fill.base}'`);
1576
+ const { tokenMap: flatTokenMap, variableMap, variables: flatVariables, tokenDefinitions } = flattenTheme(theme, emittedScales, effectiveModes);
1577
+ assertNoDroppedReferences(tokenDefinitions, flatTokenMap, this._state.droppedTokenPaths, this._state.extendProvenance);
1578
+ const { tokenMap, variables } = resolveReferences(flatTokenMap, variableMap, flatVariables);
1579
+ const { modeVariables, modeTokens } = resolveModeValueMaps(effectiveModes, variableMap, variables, tokenMap);
1030
1580
  const bpVariables = {};
1031
- if (theme.breakpoints && isObject(theme.breakpoints)) for (const [key, value] of Object.entries(theme.breakpoints)) bpVariables[`--breakpoint-${key}`] = `${value}px`;
1581
+ if (theme.breakpoints && isObject(theme.breakpoints)) {
1582
+ const breakpointEntries = Object.entries(theme.breakpoints).map(([key, value]) => [`--breakpoint-${key}`, `${value}px`]).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
1583
+ for (const [varName, value] of breakpointEntries) bpVariables[varName] = value;
1584
+ }
1032
1585
  let contextualVarsSerialized;
1033
1586
  if (contextualVars.size > 0) {
1034
1587
  contextualVarsSerialized = {};
1035
1588
  for (const [scale, vars] of contextualVars) contextualVarsSerialized[scale] = vars;
1036
1589
  }
1037
1590
  const propertyCss = buildPropertyRegistrationCss(contextualVars, this._state.contextualVarRegistrations);
1038
- const baseVariableCss = buildVariableCss(variables, bpVariables, modeVariables, {
1591
+ const { emittableVariables, emittableModeVariables, omitted } = omitUnresolvedDeclarations(variables, modeVariables);
1592
+ if (omitted.length > 0) console.warn(`[animus] Omitted ${omitted.length} CSS declaration(s) whose token references never resolved: ${omitted.join(", ")}`);
1593
+ const baseVariableCss = buildVariableCss(emittableVariables, bpVariables, emittableModeVariables, {
1039
1594
  initialMode: typeof theme.mode === "string" ? theme.mode : void 0,
1040
1595
  systemPreference,
1041
1596
  browserColorScheme
@@ -1058,26 +1613,30 @@ var ThemeBuilder = class ThemeBuilder {
1058
1613
  manifestV2Fields = {
1059
1614
  manifestVersion: 2,
1060
1615
  tokenDefinitions,
1616
+ emittedScales: [...emittedScales].sort(),
1061
1617
  modeAliasDefinitions,
1062
1618
  registrations,
1063
1619
  emitterVersion: EMITTER_VERSION,
1064
1620
  contractHash: computeContractHash({
1065
1621
  tokenDefinitions,
1622
+ emittedScales: [...emittedScales].sort(),
1066
1623
  modeAliasDefinitions,
1067
1624
  initialMode: typeof theme.mode === "string" ? theme.mode : void 0,
1068
1625
  registrations,
1069
1626
  systemPreference,
1070
- browserColorScheme
1627
+ browserColorScheme,
1628
+ modeBases
1071
1629
  }),
1072
1630
  cssFragments
1073
1631
  };
1074
1632
  }
1633
+ const sortedVariableMap = sortRecordByKey(variableMap);
1075
1634
  const manifest = {
1076
1635
  tokenMap: {
1077
1636
  ...tokenMap,
1078
- ...Object.fromEntries(Object.entries(theme.breakpoints || {}).map(([k, v]) => [`breakpoints.${k}`, String(v)]))
1637
+ ...sortRecordByKey(Object.fromEntries(Object.entries(theme.breakpoints || {}).map(([k, v]) => [`breakpoints.${k}`, String(v)])))
1079
1638
  },
1080
- variableMap,
1639
+ variableMap: sortedVariableMap,
1081
1640
  modes: modeTokens,
1082
1641
  variableCss,
1083
1642
  ...contextualVarsSerialized ? { contextualVars: contextualVarsSerialized } : {},
@@ -1129,19 +1688,19 @@ function createTheme() {
1129
1688
  const TOKEN_REF_RE = /\{([^}]+)\}/g;
1130
1689
  /**
1131
1690
  * Flatten the nested theme into dot-path keyed token map and CSS variable declarations.
1132
- * This is the ONLY place where flattening happens.
1691
+ * This is the ONLY place where flattening happens. Mode VALUE maps are no
1692
+ * longer computed here — they resolve AFTER `resolveReferences` (see
1693
+ * {@link resolveModeValueMaps}), closing the G2 gap where mode-override
1694
+ * declarations bypassed the resolver.
1133
1695
  */
1134
- function flattenTheme(theme, emittedScales) {
1696
+ function flattenTheme(theme, emittedScales, effectiveModes) {
1135
1697
  const tokenMap = {};
1136
1698
  const variableMap = {};
1137
1699
  const variables = {};
1138
- const modeVariables = {};
1139
- const modeTokens = {};
1140
1700
  const tokenDefinitions = {};
1141
- const modeAliasDefinitions = {};
1142
1701
  for (const [scaleName, scaleValue] of Object.entries(theme)) {
1143
1702
  if (scaleName.startsWith("_")) continue;
1144
- if (scaleName === "breakpoints" || scaleName === "mode" || scaleName === "modes" || scaleName === "systemPreference" || scaleName === "browserColorScheme") continue;
1703
+ if (scaleName === "breakpoints" || scaleName === "mode" || scaleName === "modes" || scaleName === "systemPreference" || scaleName === "browserColorScheme" || scaleName === "modeBases") continue;
1145
1704
  if (typeof scaleValue === "function") continue;
1146
1705
  if (!isObject(scaleValue)) continue;
1147
1706
  const flat = flattenToDotPaths(scaleValue);
@@ -1158,54 +1717,242 @@ function flattenTheme(theme, emittedScales) {
1158
1717
  } else tokenMap[tokenPath] = String(rawValue);
1159
1718
  }
1160
1719
  }
1161
- if (theme.modes && isObject(theme.modes) && theme.colors && isObject(theme.colors)) {
1162
- const flatColors = flattenToDotPaths(theme.colors);
1163
- for (const [modeName, modeAliases] of Object.entries(theme.modes)) {
1164
- if (!isObject(modeAliases)) continue;
1165
- const flatAliases = flattenToDotPaths(modeAliases);
1166
- const modeVars = {};
1167
- const modeVals = {};
1168
- const modeAliasDefs = {};
1169
- for (const [aliasDotKey, colorRef] of Object.entries(flatAliases)) {
1170
- if (typeof colorRef !== "string") continue;
1171
- const varName = `--color-${dotToDash(aliasDotKey)}`;
1172
- modeAliasDefs[aliasDotKey] = colorRef;
1173
- const rawValue = flatColors[colorRef];
1174
- modeVals[`colors.${aliasDotKey}`] = rawValue !== void 0 ? String(rawValue) : String(colorRef);
1175
- modeVars[varName] = rawValue !== void 0 ? String(rawValue) : String(colorRef);
1176
- }
1177
- modeVariables[modeName] = modeVars;
1178
- modeTokens[modeName] = modeVals;
1179
- modeAliasDefinitions[modeName] = modeAliasDefs;
1180
- }
1181
- const initialMode = theme.mode;
1182
- if (initialMode && modeVariables[initialMode]) {
1183
- const initialModeVars = {};
1184
- const flatInitialAliases = flattenToDotPaths(theme.modes[initialMode]);
1185
- for (const [aliasDotKey, colorRef] of Object.entries(flatInitialAliases)) {
1186
- if (typeof colorRef !== "string") continue;
1187
- const varName = `--color-${dotToDash(aliasDotKey)}`;
1188
- const paletteVarName = variableMap[`colors.${colorRef}`];
1189
- if (paletteVarName) initialModeVars[varName] = `var(${paletteVarName})`;
1190
- tokenMap[`colors.${aliasDotKey}`] = `var(${varName})`;
1191
- variableMap[`colors.${aliasDotKey}`] = varName;
1192
- }
1193
- Object.assign(variables, initialModeVars);
1720
+ const initialMode = theme.mode;
1721
+ const initialAliases = typeof initialMode === "string" ? effectiveModes[initialMode] : void 0;
1722
+ if (initialAliases) for (const [aliasDotKey, colorRef] of Object.entries(initialAliases)) {
1723
+ const varName = `--color-${dotToDash(aliasDotKey)}`;
1724
+ const paletteVarName = variableMap[`colors.${colorRef}`];
1725
+ if (paletteVarName) {
1726
+ if (paletteVarName !== varName) variables[varName] = `var(${paletteVarName})`;
1727
+ } else {
1728
+ const literal = tokenMap[`colors.${colorRef}`];
1729
+ if (literal !== void 0) variables[varName] = literal;
1194
1730
  }
1731
+ tokenMap[`colors.${aliasDotKey}`] = `var(${varName})`;
1732
+ variableMap[`colors.${aliasDotKey}`] = varName;
1195
1733
  }
1196
1734
  return {
1197
1735
  tokenMap,
1198
1736
  variableMap,
1199
1737
  variables,
1738
+ tokenDefinitions
1739
+ };
1740
+ }
1741
+ /** Rebuild a record with lexicographically sorted keys (wire determinism, G3). */
1742
+ function sortRecordByKey(record) {
1743
+ const sorted = {};
1744
+ for (const key of Object.keys(record).sort()) sorted[key] = record[key];
1745
+ return sorted;
1746
+ }
1747
+ /**
1748
+ * Collect the AUTHORED mode alias graph: mode name → alias dot-path → the
1749
+ * authored color dot-path (manifest v2, D6 — never a resolved value).
1750
+ * Empty when the theme has no modes or no colors, mirroring the original
1751
+ * flatten-pass guard.
1752
+ */
1753
+ function collectAuthoredModeAliases(theme) {
1754
+ const authored = {};
1755
+ if (!isObject(theme.modes) || !isObject(theme.colors)) return authored;
1756
+ for (const [modeName, modeAliases] of Object.entries(theme.modes)) {
1757
+ if (!isObject(modeAliases)) continue;
1758
+ const defs = {};
1759
+ for (const [aliasDotKey, colorRef] of Object.entries(flattenToDotPaths(modeAliases))) {
1760
+ if (typeof colorRef !== "string") continue;
1761
+ defs[aliasDotKey] = colorRef;
1762
+ }
1763
+ authored[modeName] = defs;
1764
+ }
1765
+ return authored;
1766
+ }
1767
+ /**
1768
+ * D6 coverage over the merged mode set: a CONSUMER-declared mode leaving
1769
+ * inherited aliases uncovered must name a base (`basedOn`) whose chain
1770
+ * covers them — otherwise the build fails listing the uncovered set.
1771
+ * Inherited modes are exempt (a kit's own asymmetry is pre-existing
1772
+ * behavior and must round-trip byte-identically). Returns the effective
1773
+ * alias map per mode (base-chain fills + authored, authored winning) and
1774
+ * one aggregated fill report per mode for the build diagnostic.
1775
+ */
1776
+ function resolveModeCoverage(authoredModeAliases, modeBases, inheritedModes, inheritedModeAliases) {
1777
+ const effectiveModes = {};
1778
+ const coverageFills = [];
1779
+ for (const modeName of Object.keys(authoredModeAliases)) {
1780
+ const authored = authoredModeAliases[modeName];
1781
+ const fills = {};
1782
+ if (!inheritedModes.has(modeName)) {
1783
+ const uncovered = [...inheritedModeAliases].filter((alias) => !(alias in authored)).sort();
1784
+ if (uncovered.length > 0) {
1785
+ const base = modeBases?.[modeName];
1786
+ if (base === void 0) throw new Error(`build: mode '${modeName}' leaves ${uncovered.length} inherited alias(es) uncovered and declares no base — uncovered: ${uncovered.join(", ")}. Add basedOn: { '${modeName}': '<mode>' } to addColorModes options or override every inherited alias.`);
1787
+ const stillUncovered = [];
1788
+ for (const alias of uncovered) {
1789
+ let cursor = base;
1790
+ const seen = new Set([modeName]);
1791
+ let resolved;
1792
+ while (cursor !== void 0 && !seen.has(cursor)) {
1793
+ seen.add(cursor);
1794
+ resolved = authoredModeAliases[cursor]?.[alias];
1795
+ if (resolved !== void 0) break;
1796
+ cursor = modeBases?.[cursor];
1797
+ }
1798
+ if (resolved === void 0) stillUncovered.push(alias);
1799
+ else fills[alias] = resolved;
1800
+ }
1801
+ if (stillUncovered.length > 0) throw new Error(`build: mode '${modeName}' resolves through base '${base}' but the chain never covers: ${stillUncovered.join(", ")}. Cover them in a chained mode or override them directly.`);
1802
+ coverageFills.push({
1803
+ mode: modeName,
1804
+ base,
1805
+ count: Object.keys(fills).length
1806
+ });
1807
+ }
1808
+ }
1809
+ effectiveModes[modeName] = {
1810
+ ...fills,
1811
+ ...authored
1812
+ };
1813
+ }
1814
+ return {
1815
+ effectiveModes,
1816
+ coverageFills
1817
+ };
1818
+ }
1819
+ /**
1820
+ * Resolve the per-mode value maps AFTER reference resolution: every mode
1821
+ * declaration carries the RESOLVED value of its target color (emitted →
1822
+ * the resolved declaration value; inlined → the resolved literal), never
1823
+ * the raw flattened string — the G2 closure for `[data-color-mode]` blocks.
1824
+ * Modes iterate in sorted name order and lines in sorted property-name
1825
+ * order, so mode declaration/insertion order is never observable (G3).
1826
+ */
1827
+ function resolveModeValueMaps(effectiveModes, variableMap, variables, tokenMap) {
1828
+ const modeVariables = {};
1829
+ const modeTokens = {};
1830
+ const resolvedColorValue = (colorRef) => {
1831
+ const path = `colors.${colorRef}`;
1832
+ const varName = variableMap[path];
1833
+ if (varName !== void 0) {
1834
+ const declared = variables[varName];
1835
+ if (declared !== void 0) return declared;
1836
+ } else if (tokenMap[path] !== void 0) return tokenMap[path];
1837
+ return String(colorRef);
1838
+ };
1839
+ for (const modeName of Object.keys(effectiveModes).sort()) {
1840
+ const aliases = effectiveModes[modeName];
1841
+ const entries = Object.keys(aliases).map((aliasDotKey) => [`--color-${dotToDash(aliasDotKey)}`, aliasDotKey]).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
1842
+ const modeVars = {};
1843
+ const modeVals = {};
1844
+ for (const [varName, aliasDotKey] of entries) {
1845
+ const value = resolvedColorValue(aliases[aliasDotKey]);
1846
+ modeVars[varName] = value;
1847
+ modeVals[`colors.${aliasDotKey}`] = value;
1848
+ }
1849
+ modeVariables[modeName] = modeVars;
1850
+ modeTokens[modeName] = modeVals;
1851
+ }
1852
+ return {
1200
1853
  modeVariables,
1201
- modeTokens,
1202
- tokenDefinitions,
1203
- modeAliasDefinitions
1854
+ modeTokens
1855
+ };
1856
+ }
1857
+ /**
1858
+ * D5 enforcement: a reference whose target is absent from the merged map
1859
+ * AND was dropped by an explicit `addScale({ replace: true })` fails the
1860
+ * build — unconditional on usage — naming the referencing token (with its
1861
+ * positional origin), the replacement call's scale, and the dropped keys.
1862
+ * Targets never defined ANYWHERE stay warn-and-literal (supported kit
1863
+ * pattern); a re-added key is simply known again and passes.
1864
+ */
1865
+ function assertNoDroppedReferences(tokenDefinitions, flatTokenMap, droppedTokenPaths, extendProvenance) {
1866
+ if (droppedTokenPaths.size === 0) return;
1867
+ const known = new Set(Object.keys(flatTokenMap));
1868
+ const violations = [];
1869
+ const droppedNamed = /* @__PURE__ */ new Set();
1870
+ for (const [tokenPath, definition] of Object.entries(tokenDefinitions)) {
1871
+ if (definition.kind !== "reference") continue;
1872
+ for (const reference of definition.references) {
1873
+ if (known.has(reference.path)) continue;
1874
+ const scale = droppedTokenPaths.get(reference.path);
1875
+ if (scale === void 0) continue;
1876
+ const provenanceIndex = extendProvenance.get(tokenPath);
1877
+ const origin = provenanceIndex === void 0 ? "builder state" : `extended theme #${provenanceIndex}`;
1878
+ violations.push(`'${tokenPath}' (${origin}) references '{${reference.path}}', dropped by addScale({ name: '${scale}', replace: true })`);
1879
+ droppedNamed.add(reference.path);
1880
+ }
1881
+ }
1882
+ if (violations.length > 0) throw new Error(`build: dangling token reference(s) after explicit scale replacement — ${violations.join("; ")}. Dropped keys: ${[...droppedNamed].sort().join(", ")}.`);
1883
+ }
1884
+ /** A resolved value still carrying a `{…}` reference — never shippable (G2). */
1885
+ const UNRESOLVED_REF_RE = /\{[^}]+\}/;
1886
+ /** Custom-property names referenced through `var(--name)` in a value. */
1887
+ const VAR_REF_NAME_RE = /var\(\s*(--[\w-]+)/g;
1888
+ function varRefNames(value) {
1889
+ const names = [];
1890
+ for (const match of value.matchAll(VAR_REF_NAME_RE)) names.push(match[1]);
1891
+ return names;
1892
+ }
1893
+ /**
1894
+ * Split root and mode variable maps into shippable declarations and omitted
1895
+ * var names (G2): any value still containing `{…}` after resolution — a
1896
+ * direct or TRANSITIVE never-defined target — is withheld from emitted CSS.
1897
+ *
1898
+ * Withholding must follow `var()` indirection too: flattenTheme synthesizes
1899
+ * the initial mode's aliases as `var(--target)` BEFORE resolution, so a
1900
+ * withheld target would otherwise leave an emitted alias pointing at a
1901
+ * declaration that exists nowhere. A declaration is dangling when a var()
1902
+ * target of its value was DECLARED in this build but withheld everywhere
1903
+ * the declaration can see (:root for root declarations; the same mode
1904
+ * block or :root for mode declarations); chains drop to a fixpoint. Var
1905
+ * names never declared here (breakpoints, contextual vars) are exempt.
1906
+ */
1907
+ function omitUnresolvedDeclarations(variables, modeVariables) {
1908
+ const omitted = [];
1909
+ const emittableVariables = {};
1910
+ const droppedRoot = /* @__PURE__ */ new Set();
1911
+ for (const [varName, value] of Object.entries(variables)) if (UNRESOLVED_REF_RE.test(value)) {
1912
+ omitted.push(varName);
1913
+ droppedRoot.add(varName);
1914
+ } else emittableVariables[varName] = value;
1915
+ let rootChanged = true;
1916
+ while (rootChanged) {
1917
+ rootChanged = false;
1918
+ for (const [varName, value] of Object.entries(emittableVariables)) if (varRefNames(value).some((name) => droppedRoot.has(name))) {
1919
+ delete emittableVariables[varName];
1920
+ droppedRoot.add(varName);
1921
+ omitted.push(varName);
1922
+ rootChanged = true;
1923
+ }
1924
+ }
1925
+ const emittableModeVariables = {};
1926
+ for (const [modeName, modeVars] of Object.entries(modeVariables)) {
1927
+ const kept = {};
1928
+ const droppedInMode = /* @__PURE__ */ new Set();
1929
+ const dropFromMode = (varName) => {
1930
+ omitted.push(`${varName} ([data-color-mode="${modeName}"])`);
1931
+ droppedInMode.add(varName);
1932
+ };
1933
+ for (const [varName, value] of Object.entries(modeVars)) if (UNRESOLVED_REF_RE.test(value)) dropFromMode(varName);
1934
+ else kept[varName] = value;
1935
+ const dangling = (name) => (droppedInMode.has(name) || droppedRoot.has(name)) && !(name in kept) && !(name in emittableVariables);
1936
+ let modeChanged = true;
1937
+ while (modeChanged) {
1938
+ modeChanged = false;
1939
+ for (const [varName, value] of Object.entries(kept)) if (varRefNames(value).some(dangling)) {
1940
+ delete kept[varName];
1941
+ dropFromMode(varName);
1942
+ modeChanged = true;
1943
+ }
1944
+ }
1945
+ emittableModeVariables[modeName] = kept;
1946
+ }
1947
+ return {
1948
+ emittableVariables,
1949
+ emittableModeVariables,
1950
+ omitted
1204
1951
  };
1205
1952
  }
1206
1953
  /**
1207
1954
  * Classify a RAW token value into its authored form (manifest v2, D6). MUST
1208
- * run before `resolveTokenRefs` — resolution rewrites the string, and the
1955
+ * run before `resolveReferences` — resolution rewrites the string, and the
1209
1956
  * authored graph cannot be reconstructed from resolved CSS (D8). Uses
1210
1957
  * `matchAll` so the shared global {@link TOKEN_REF_RE} never carries a stale
1211
1958
  * `lastIndex` between callers.
@@ -1431,43 +2178,6 @@ function computeContractHash(input) {
1431
2178
  return sha256Hex(JSON.stringify(canonicalize(input)));
1432
2179
  }
1433
2180
  /**
1434
- * Resolve token refs ({scale.key}) in all flattened token values.
1435
- * Operates on the flattened tokenMap — does NOT mutate the nested theme.
1436
- */
1437
- function resolveTokenRefs(tokenMap, _variableMap, _emittedScales) {
1438
- for (const [tokenPath, value] of Object.entries(tokenMap)) {
1439
- if (typeof value !== "string") continue;
1440
- if (!value.includes("{")) continue;
1441
- if (value.startsWith("var(")) continue;
1442
- const scaleName = tokenPath.split(".")[0];
1443
- const resolved = value.replace(TOKEN_REF_RE, (match, ref) => {
1444
- if (ref.split(".")[0] === scaleName) {
1445
- console.warn(`[animus] Self-referential token ref {${ref}} in scale '${scaleName}' — skipped`);
1446
- return match;
1447
- }
1448
- let lookupPath = ref;
1449
- let opacity;
1450
- const slashIdx = ref.indexOf("/");
1451
- if (slashIdx !== -1) {
1452
- lookupPath = ref.slice(0, slashIdx);
1453
- opacity = ref.slice(slashIdx + 1);
1454
- }
1455
- const refValue = tokenMap[lookupPath];
1456
- if (refValue === void 0) {
1457
- console.warn(`[animus] Token ref {${ref}} — path '${lookupPath}' not found in token map`);
1458
- return match;
1459
- }
1460
- if (opacity) {
1461
- const alpha = Number.parseInt(opacity, 10);
1462
- if (alpha === 0) return "transparent";
1463
- if (alpha !== 100) return `color-mix(in srgb, ${refValue} ${alpha}%, transparent)`;
1464
- }
1465
- return refValue;
1466
- });
1467
- if (resolved !== value) tokenMap[tokenPath] = resolved;
1468
- }
1469
- }
1470
- /**
1471
2181
  * Build `@property` registration rules for registered contextual vars.
1472
2182
  * The emitted custom property is `--${name}` — the same name the Rust resolver
1473
2183
  * maps a bare contextual var value to. Rules emit in declaration order and only
@@ -1519,4 +2229,4 @@ function buildVariableCss(rootVariables, breakpointVariables, modeVariables, sys
1519
2229
  return parts.join("\n\n");
1520
2230
  }
1521
2231
  //#endregion
1522
- export { Animus, AnimusExtended, AnimusExtendedWithAll, AnimusWithAll, BUILT_IN_CONDITIONS, BUILT_IN_SELECTORS, SystemBuilder, ThemeBuilder, borderShorthand, compose, createClassResolver, createComponent, createComposedFamily, createScale, createSystem, createTheme, createTransform, gridItem, gridItemRatio, numericOrStringScale, numericScale, percentageOrAbsolute, size, stringScale };
2232
+ export { ASSET_PLACEHOLDER_PREFIX, Animus, AnimusExtended, AnimusExtendedWithAll, AnimusWithAll, BUILT_IN_CONDITIONS, BUILT_IN_SELECTORS, SystemBuilder, ThemeBuilder, areTransformsEqual, asset, borderShorthand, compose, createClassResolver, createComponent, createComposedFamily, createScale, createSystem, createTheme, createTransform, gridItem, gridItemRatio, numericOrStringScale, numericScale, percentageOrAbsolute, size, stringScale };