@animus-ui/system 0.1.3 → 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,29 +789,33 @@ 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
  } });
815
+ Object.defineProperty(system, "getRegistrySnapshot", {
816
+ value: () => snapshot,
817
+ enumerable: false
818
+ });
648
819
  const createGlobalStyles = ((styles, options) => ({
649
820
  __brand: "GlobalStyleBlock",
650
821
  styles,
@@ -658,9 +829,41 @@ var SystemBuilder = class SystemBuilder {
658
829
  };
659
830
  }
660
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
+ }
661
863
  function serializeInstance(propRegistry, groupRegistry, selectorRegistry, conditionRegistry) {
662
864
  const serialized = {};
663
865
  const transforms = {};
866
+ const transformOwners = {};
664
867
  for (const [propName, entry] of Object.entries(propRegistry)) {
665
868
  const s = { property: entry.property };
666
869
  if (entry.properties && entry.properties.length > 0) s.properties = [...entry.properties];
@@ -672,8 +875,11 @@ function serializeInstance(propRegistry, groupRegistry, selectorRegistry, condit
672
875
  const fn = entry.transform;
673
876
  const name = fn.transformName ?? fn.name;
674
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.`);
675
880
  s.transform = name;
676
881
  transforms[name] = fn;
882
+ transformOwners[name] = propName;
677
883
  }
678
884
  }
679
885
  if (entry.currentVar) s.currentVar = entry.currentVar;
@@ -693,6 +899,144 @@ function createSystem(config) {
693
899
  return new SystemBuilder(void 0, void 0, void 0, config?.includes ?? []);
694
900
  }
695
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
696
1040
  //#region src/theme/utils.ts
697
1041
  /** Type guard: true for non-null, non-array objects */
698
1042
  function isObject(value) {
@@ -797,16 +1141,18 @@ function validateModeAliases(modeName, aliases, nestedColors, flatColorKeys, pre
797
1141
  * the `:root:not([data-color-mode])` guard while matching no mode block.
798
1142
  */
799
1143
  const RESERVED_MODE_NAME = "system";
800
- /**
801
- * Theme keys owned by the color-mode options. A scale may not claim them —
802
- * they are skipped by `flattenTheme` and read back as option objects in
803
- * `build()`, so a same-named scale would emit zero tokens AND fabricate a
804
- * manifest field from its values.
805
- *
806
- * NOTE: the pre-existing `mode` / `modes` / `breakpoints` structural keys have
807
- * the same hole and are deliberately NOT covered here (out of scope).
808
- */
809
- 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
+ ]);
810
1156
  const COLOR_SCHEME_VALUES = new Set([
811
1157
  "light",
812
1158
  "dark",
@@ -894,6 +1240,33 @@ function resolveColorModeOptions(modeNames, systemPreference, suppliedBrowserCol
894
1240
  }
895
1241
  return browserColorScheme;
896
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
+ }
897
1270
  /** Validate all color entries, throwing on invalid values. */
898
1271
  function validateColors(colors) {
899
1272
  for (const [key, value] of Object.entries(colors)) if (isObject(value)) validateColors(value);
@@ -905,7 +1278,12 @@ function createState(theme) {
905
1278
  emittedScales: /* @__PURE__ */ new Set(),
906
1279
  contextualVars: /* @__PURE__ */ new Map(),
907
1280
  contextualVarRegistrations: /* @__PURE__ */ new Map(),
908
- 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()
909
1287
  };
910
1288
  }
911
1289
  function copyState(state, nextTheme) {
@@ -915,12 +1293,101 @@ function copyState(state, nextTheme) {
915
1293
  contextualVars: /* @__PURE__ */ new Map(),
916
1294
  contextualVarRegistrations: new Map(state.contextualVarRegistrations),
917
1295
  ...state.carriedManifestV2 ? { carriedManifestV2: { ...state.carriedManifestV2 } } : {},
918
- 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)
919
1302
  };
920
1303
  for (const [scale, vars] of state.contextualVars) next.contextualVars.set(scale, [...vars]);
921
1304
  return next;
922
1305
  }
923
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
+ /**
924
1391
  * ThemeScales — the final phase. Has addScale, extendScale, declareContextualVars, build.
925
1392
  * Also allows addColors and addColorModes for augmentation.
926
1393
  */
@@ -931,39 +1398,94 @@ var ThemeBuilder = class ThemeBuilder {
931
1398
  }
932
1399
  addBreakpoints(breakpoints) {
933
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)}`);
934
- const nextTheme = merge({}, this._state.theme, { breakpoints });
1401
+ const nextTheme = merge(deepCopyPlain(this._state.theme), { breakpoints });
935
1402
  return new ThemeBuilder(copyState(this._state, nextTheme));
936
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
+ */
937
1415
  from(builtTheme) {
1416
+ const source = isLibraryBundle(builtTheme) ? builtTheme.theme ?? builtTheme.tokens ?? {} : builtTheme;
938
1417
  const raw = {};
939
- for (const key of Object.keys(builtTheme)) {
940
- const val = builtTheme[key];
1418
+ for (const key of Object.keys(source)) {
1419
+ const val = source[key];
941
1420
  if (typeof val !== "function") raw[key] = val;
942
1421
  }
943
- const nextTheme = merge({}, this._state.theme, raw);
1422
+ const nextTheme = merge(deepCopyPlain(this._state.theme), deepCopyPlain(raw));
944
1423
  const next = new ThemeBuilder(copyState(this._state, nextTheme));
945
- const manifest = builtTheme.manifest;
946
- if (manifest?.variableMap) for (const tokenPath of Object.keys(manifest.variableMap)) {
947
- const scale = tokenPath.split(".")[0];
948
- 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;
949
1448
  }
950
- if (manifest?.contextualVars) for (const [scale, vars] of Object.entries(manifest.contextualVars)) next._state.contextualVars.set(scale, [...vars]);
951
- if (manifest) if (manifest.manifestVersion === 2) {
952
- next._state.carriedManifestV2 = {
953
- tokenDefinitions: manifest.tokenDefinitions,
954
- modeAliasDefinitions: manifest.modeAliasDefinitions,
955
- registrations: manifest.registrations,
956
- emitterVersion: manifest.emitterVersion,
957
- contractHash: manifest.contractHash,
958
- cssFragments: manifest.cssFragments
959
- };
960
- if (manifest.registrations) for (const [name, registration] of Object.entries(manifest.registrations)) next._state.contextualVarRegistrations.set(name, registration);
961
- } 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);
962
1484
  return next;
963
1485
  }
964
1486
  addColors(colors) {
965
1487
  validateColors(colors);
966
- const nextTheme = merge({}, this._state.theme, { colors });
1488
+ const nextTheme = merge(deepCopyPlain(this._state.theme), { colors });
967
1489
  const next = new ThemeBuilder(copyState(this._state, nextTheme));
968
1490
  next._state.emittedScales.add("colors");
969
1491
  return next;
@@ -982,20 +1504,32 @@ var ThemeBuilder = class ThemeBuilder {
982
1504
  const systemPreference = mergeOptionObject(this._state.theme.systemPreference, options?.systemPreference);
983
1505
  const browserColorScheme = mergeOptionObject(this._state.theme.browserColorScheme, options?.browserColorScheme);
984
1506
  resolveColorModeOptions(modeNames, systemPreference, browserColorScheme);
985
- 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), {
986
1510
  modes: modeConfig,
987
1511
  mode: initialMode,
988
1512
  ...systemPreference ? { systemPreference } : {},
989
- ...browserColorScheme ? { browserColorScheme } : {}
1513
+ ...browserColorScheme ? { browserColorScheme } : {},
1514
+ ...modeBases ? { modeBases } : {}
990
1515
  });
991
1516
  return new ThemeBuilder(copyState(this._state, nextTheme));
992
1517
  }
993
1518
  addScale(config) {
994
- const { name, values, emit } = config;
995
- 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.`);
996
- 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 });
997
1527
  const next = new ThemeBuilder(copyState(this._state, nextTheme));
998
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
+ }
999
1533
  return next;
1000
1534
  }
1001
1535
  declareContextualVars(vars, registrations) {
@@ -1011,7 +1545,7 @@ var ThemeBuilder = class ThemeBuilder {
1011
1545
  return next;
1012
1546
  }
1013
1547
  extendScale(key, updateFn) {
1014
- 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]) });
1015
1549
  return new ThemeBuilder(copyState(this._state, nextTheme));
1016
1550
  }
1017
1551
  /**
@@ -1019,24 +1553,44 @@ var ThemeBuilder = class ThemeBuilder {
1019
1553
  * Flattens nested data at the boundary — produces manifest and serialize().
1020
1554
  */
1021
1555
  build() {
1022
- const theme = merge({}, this._state.theme);
1556
+ const theme = deepCopyPlain(this._state.theme);
1023
1557
  const emittedScales = this._state.emittedScales;
1024
1558
  const contextualVars = this._state.contextualVars;
1025
1559
  const systemPreference = isObject(theme.systemPreference) ? theme.systemPreference : void 0;
1026
1560
  const mergedModeNames = isObject(theme.modes) ? Object.keys(theme.modes) : [];
1027
1561
  validateReservedModeNames(mergedModeNames);
1028
1562
  const browserColorScheme = resolveColorModeOptions(mergedModeNames, systemPreference, isObject(theme.browserColorScheme) ? theme.browserColorScheme : void 0);
1029
- const { tokenMap, variableMap, variables, modeVariables, modeTokens, tokenDefinitions, modeAliasDefinitions } = flattenTheme(theme, emittedScales);
1030
- 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);
1031
1580
  const bpVariables = {};
1032
- 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
+ }
1033
1585
  let contextualVarsSerialized;
1034
1586
  if (contextualVars.size > 0) {
1035
1587
  contextualVarsSerialized = {};
1036
1588
  for (const [scale, vars] of contextualVars) contextualVarsSerialized[scale] = vars;
1037
1589
  }
1038
1590
  const propertyCss = buildPropertyRegistrationCss(contextualVars, this._state.contextualVarRegistrations);
1039
- 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, {
1040
1594
  initialMode: typeof theme.mode === "string" ? theme.mode : void 0,
1041
1595
  systemPreference,
1042
1596
  browserColorScheme
@@ -1059,26 +1613,30 @@ var ThemeBuilder = class ThemeBuilder {
1059
1613
  manifestV2Fields = {
1060
1614
  manifestVersion: 2,
1061
1615
  tokenDefinitions,
1616
+ emittedScales: [...emittedScales].sort(),
1062
1617
  modeAliasDefinitions,
1063
1618
  registrations,
1064
1619
  emitterVersion: EMITTER_VERSION,
1065
1620
  contractHash: computeContractHash({
1066
1621
  tokenDefinitions,
1622
+ emittedScales: [...emittedScales].sort(),
1067
1623
  modeAliasDefinitions,
1068
1624
  initialMode: typeof theme.mode === "string" ? theme.mode : void 0,
1069
1625
  registrations,
1070
1626
  systemPreference,
1071
- browserColorScheme
1627
+ browserColorScheme,
1628
+ modeBases
1072
1629
  }),
1073
1630
  cssFragments
1074
1631
  };
1075
1632
  }
1633
+ const sortedVariableMap = sortRecordByKey(variableMap);
1076
1634
  const manifest = {
1077
1635
  tokenMap: {
1078
1636
  ...tokenMap,
1079
- ...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)])))
1080
1638
  },
1081
- variableMap,
1639
+ variableMap: sortedVariableMap,
1082
1640
  modes: modeTokens,
1083
1641
  variableCss,
1084
1642
  ...contextualVarsSerialized ? { contextualVars: contextualVarsSerialized } : {},
@@ -1130,19 +1688,19 @@ function createTheme() {
1130
1688
  const TOKEN_REF_RE = /\{([^}]+)\}/g;
1131
1689
  /**
1132
1690
  * Flatten the nested theme into dot-path keyed token map and CSS variable declarations.
1133
- * 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.
1134
1695
  */
1135
- function flattenTheme(theme, emittedScales) {
1696
+ function flattenTheme(theme, emittedScales, effectiveModes) {
1136
1697
  const tokenMap = {};
1137
1698
  const variableMap = {};
1138
1699
  const variables = {};
1139
- const modeVariables = {};
1140
- const modeTokens = {};
1141
1700
  const tokenDefinitions = {};
1142
- const modeAliasDefinitions = {};
1143
1701
  for (const [scaleName, scaleValue] of Object.entries(theme)) {
1144
1702
  if (scaleName.startsWith("_")) continue;
1145
- 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;
1146
1704
  if (typeof scaleValue === "function") continue;
1147
1705
  if (!isObject(scaleValue)) continue;
1148
1706
  const flat = flattenToDotPaths(scaleValue);
@@ -1159,54 +1717,242 @@ function flattenTheme(theme, emittedScales) {
1159
1717
  } else tokenMap[tokenPath] = String(rawValue);
1160
1718
  }
1161
1719
  }
1162
- if (theme.modes && isObject(theme.modes) && theme.colors && isObject(theme.colors)) {
1163
- const flatColors = flattenToDotPaths(theme.colors);
1164
- for (const [modeName, modeAliases] of Object.entries(theme.modes)) {
1165
- if (!isObject(modeAliases)) continue;
1166
- const flatAliases = flattenToDotPaths(modeAliases);
1167
- const modeVars = {};
1168
- const modeVals = {};
1169
- const modeAliasDefs = {};
1170
- for (const [aliasDotKey, colorRef] of Object.entries(flatAliases)) {
1171
- if (typeof colorRef !== "string") continue;
1172
- const varName = `--color-${dotToDash(aliasDotKey)}`;
1173
- modeAliasDefs[aliasDotKey] = colorRef;
1174
- const rawValue = flatColors[colorRef];
1175
- modeVals[`colors.${aliasDotKey}`] = rawValue !== void 0 ? String(rawValue) : String(colorRef);
1176
- modeVars[varName] = rawValue !== void 0 ? String(rawValue) : String(colorRef);
1177
- }
1178
- modeVariables[modeName] = modeVars;
1179
- modeTokens[modeName] = modeVals;
1180
- modeAliasDefinitions[modeName] = modeAliasDefs;
1181
- }
1182
- const initialMode = theme.mode;
1183
- if (initialMode && modeVariables[initialMode]) {
1184
- const initialModeVars = {};
1185
- const flatInitialAliases = flattenToDotPaths(theme.modes[initialMode]);
1186
- for (const [aliasDotKey, colorRef] of Object.entries(flatInitialAliases)) {
1187
- if (typeof colorRef !== "string") continue;
1188
- const varName = `--color-${dotToDash(aliasDotKey)}`;
1189
- const paletteVarName = variableMap[`colors.${colorRef}`];
1190
- if (paletteVarName) initialModeVars[varName] = `var(${paletteVarName})`;
1191
- tokenMap[`colors.${aliasDotKey}`] = `var(${varName})`;
1192
- variableMap[`colors.${aliasDotKey}`] = varName;
1193
- }
1194
- 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;
1195
1730
  }
1731
+ tokenMap[`colors.${aliasDotKey}`] = `var(${varName})`;
1732
+ variableMap[`colors.${aliasDotKey}`] = varName;
1196
1733
  }
1197
1734
  return {
1198
1735
  tokenMap,
1199
1736
  variableMap,
1200
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 {
1201
1853
  modeVariables,
1202
- modeTokens,
1203
- tokenDefinitions,
1204
- 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
1205
1951
  };
1206
1952
  }
1207
1953
  /**
1208
1954
  * Classify a RAW token value into its authored form (manifest v2, D6). MUST
1209
- * run before `resolveTokenRefs` — resolution rewrites the string, and the
1955
+ * run before `resolveReferences` — resolution rewrites the string, and the
1210
1956
  * authored graph cannot be reconstructed from resolved CSS (D8). Uses
1211
1957
  * `matchAll` so the shared global {@link TOKEN_REF_RE} never carries a stale
1212
1958
  * `lastIndex` between callers.
@@ -1432,43 +2178,6 @@ function computeContractHash(input) {
1432
2178
  return sha256Hex(JSON.stringify(canonicalize(input)));
1433
2179
  }
1434
2180
  /**
1435
- * Resolve token refs ({scale.key}) in all flattened token values.
1436
- * Operates on the flattened tokenMap — does NOT mutate the nested theme.
1437
- */
1438
- function resolveTokenRefs(tokenMap, _variableMap, _emittedScales) {
1439
- for (const [tokenPath, value] of Object.entries(tokenMap)) {
1440
- if (typeof value !== "string") continue;
1441
- if (!value.includes("{")) continue;
1442
- if (value.startsWith("var(")) continue;
1443
- const scaleName = tokenPath.split(".")[0];
1444
- const resolved = value.replace(TOKEN_REF_RE, (match, ref) => {
1445
- if (ref.split(".")[0] === scaleName) {
1446
- console.warn(`[animus] Self-referential token ref {${ref}} in scale '${scaleName}' — skipped`);
1447
- return match;
1448
- }
1449
- let lookupPath = ref;
1450
- let opacity;
1451
- const slashIdx = ref.indexOf("/");
1452
- if (slashIdx !== -1) {
1453
- lookupPath = ref.slice(0, slashIdx);
1454
- opacity = ref.slice(slashIdx + 1);
1455
- }
1456
- const refValue = tokenMap[lookupPath];
1457
- if (refValue === void 0) {
1458
- console.warn(`[animus] Token ref {${ref}} — path '${lookupPath}' not found in token map`);
1459
- return match;
1460
- }
1461
- if (opacity) {
1462
- const alpha = Number.parseInt(opacity, 10);
1463
- if (alpha === 0) return "transparent";
1464
- if (alpha !== 100) return `color-mix(in srgb, ${refValue} ${alpha}%, transparent)`;
1465
- }
1466
- return refValue;
1467
- });
1468
- if (resolved !== value) tokenMap[tokenPath] = resolved;
1469
- }
1470
- }
1471
- /**
1472
2181
  * Build `@property` registration rules for registered contextual vars.
1473
2182
  * The emitted custom property is `--${name}` — the same name the Rust resolver
1474
2183
  * maps a bare contextual var value to. Rules emit in declaration order and only
@@ -1520,4 +2229,4 @@ function buildVariableCss(rootVariables, breakpointVariables, modeVariables, sys
1520
2229
  return parts.join("\n\n");
1521
2230
  }
1522
2231
  //#endregion
1523
- 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 };