@animus-ui/system 0.1.3 → 0.1.5

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.
Files changed (47) hide show
  1. package/README.md +31 -6
  2. package/dist/Animus.d.ts +1 -1
  3. package/dist/Animus.d.ts.map +1 -1
  4. package/dist/AnimusExtended.d.ts +1 -1
  5. package/dist/AnimusExtended.d.ts.map +1 -1
  6. package/dist/SystemBuilder.d.ts +180 -11
  7. package/dist/SystemBuilder.d.ts.map +1 -1
  8. package/dist/assert-root-slot-B83scqPh.js +21 -0
  9. package/dist/asset.d.ts +25 -0
  10. package/dist/asset.d.ts.map +1 -0
  11. package/dist/compose.d.ts +3 -1
  12. package/dist/compose.d.ts.map +1 -1
  13. package/dist/compose.js +2 -1
  14. package/dist/composeWithContext.d.ts +3 -1
  15. package/dist/composeWithContext.d.ts.map +1 -1
  16. package/dist/composeWithContext.js +3 -2
  17. package/dist/conditions.d.ts +52 -4
  18. package/dist/conditions.d.ts.map +1 -1
  19. package/dist/{createComposedFamily-DJ2aqfqN.js → createComposedFamily-DhjtkyWd.js} +81 -7
  20. package/dist/groups/index.js +1 -1
  21. package/dist/index.d.ts +6 -5
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +887 -147
  24. package/dist/runtime/assert-root-slot.d.ts +17 -0
  25. package/dist/runtime/assert-root-slot.d.ts.map +1 -0
  26. package/dist/runtime/createComposedFamily.d.ts.map +1 -1
  27. package/dist/runtime/is-dev.d.ts +46 -0
  28. package/dist/runtime/is-dev.d.ts.map +1 -0
  29. package/dist/runtime/resolveClasses.d.ts +15 -7
  30. package/dist/runtime/resolveClasses.d.ts.map +1 -1
  31. package/dist/runtime/witness.d.ts +4 -2
  32. package/dist/runtime/witness.d.ts.map +1 -1
  33. package/dist/runtime-entry.js +1 -1
  34. package/dist/selectors.d.ts +5 -1
  35. package/dist/selectors.d.ts.map +1 -1
  36. package/dist/{size-CusLCguT.js → size-DHdmBc64.js} +35 -8
  37. package/dist/theme/createTheme.d.ts +125 -13
  38. package/dist/theme/createTheme.d.ts.map +1 -1
  39. package/dist/theme/index.d.ts +1 -1
  40. package/dist/theme/index.d.ts.map +1 -1
  41. package/dist/theme/resolveReferences.d.ts +49 -0
  42. package/dist/theme/resolveReferences.d.ts.map +1 -0
  43. package/dist/transforms/createTransform.d.ts +22 -0
  44. package/dist/transforms/createTransform.d.ts.map +1 -1
  45. package/dist/types/theme.d.ts +28 -4
  46. package/dist/types/theme.d.ts.map +1 -1
  47. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { n as createClassResolver, r as createComponent, t as createComposedFamily } from "./createComposedFamily-DJ2aqfqN.js";
1
+ import { n as createClassResolver, r as createComponent, t as createComposedFamily } from "./createComposedFamily-DhjtkyWd.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-DHdmBc64.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,122 @@ 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
  }
755
+ /**
756
+ * Register custom selector aliases (`_hoverChild`, …) → `&`-relative selector
757
+ * strings. Overriding a built-in SELECTOR alias is legal; a name owned by the
758
+ * CONDITION registry (built-in or registered earlier in this chain) maps to
759
+ * the branded `ReservedByConditionRegistry` instead of its selector string —
760
+ * the compile-time complement of the cross-registry throw below. The clash is
761
+ * checked in VALUE position so `S` stays a naked inference site; subtracting
762
+ * the reserved names from the KEY position is impossible — `Exclude` is a
763
+ * silent no-op against a template-literal pattern. `NarrowedAliases` keeps a
764
+ * widened `Conds` from swallowing the whole `_` namespace.
765
+ *
766
+ * It wraps the ACCUMULATION too: a widened registration contributes nothing
767
+ * to `Sels` rather than widening it, so the pattern can never reach the
768
+ * published `RegistryBrand` — see `addConditions` for the full argument.
769
+ */
591
770
  addSelectors(selectors) {
592
771
  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
772
  const merged = mergeSelectors(this.#selectorRegistry, selectors);
594
- return new SystemBuilder(this.#propRegistry, this.#groupRegistry, merged, this.#includesRegistry, this.#conditionRegistry);
773
+ return new SystemBuilder(this.#propRegistry, this.#groupRegistry, merged, this.#includesRegistry, this.#conditionRegistry, this.#extendProvenance, this.#extendCount);
595
774
  }
596
775
  /**
597
776
  * Register condition aliases (`_motionReduce`, `_cardSm`, …) → at-rule
@@ -602,17 +781,36 @@ var SystemBuilder = class SystemBuilder {
602
781
  * with an at-rule name is a compile-time type error (design D9; the runtime
603
782
  * `inferConditionKind` throw is defense-in-depth). The registered keys are
604
783
  * accumulated into the phantom `Conds` union and surfaced on `build()`.
784
+ *
785
+ * A key owned by the SELECTOR registry (built-in or registered earlier in
786
+ * this chain) maps to the branded `ReservedBySelectorRegistry` — see
787
+ * `addSelectors` for why the clash is checked in value position. Overriding a
788
+ * built-in CONDITION alias stays legal (design D3); only the OPPOSITE
789
+ * registry is subtracted.
790
+ *
791
+ * `NarrowedAliases` wraps the ACCUMULATION, not just the gate: a widened
792
+ * registration (`Record<`_${string}`, …>`) contributes nothing to `Conds`.
793
+ * Accumulating the pattern instead would carry it out through `build()`'s
794
+ * `RegistryBrand`, and a consumer publishing `ConditionsOf` into the
795
+ * augmentable `Conditions` interface would then have every `_` key type as
796
+ * registered — `UnknownConditionAlias` would never fire again, anywhere in
797
+ * that project. The gate keeps its own `NarrowedAliases` regardless:
798
+ * `extend()`-sourced unions can still arrive widened from a system built
799
+ * before this narrowing.
605
800
  */
606
801
  addConditions(conditions) {
607
802
  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);
803
+ return new SystemBuilder(this.#propRegistry, this.#groupRegistry, this.#selectorRegistry, this.#includesRegistry, merged, this.#extendProvenance, this.#extendCount);
609
804
  }
610
805
  addGroup(name, config) {
611
806
  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
807
  for (const key of Object.keys(config)) if (key in this.#propRegistry) {
613
808
  const existing = this.#propRegistry[key];
614
809
  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)}".`);
810
+ if (!arePropDefinitionsEqual(existing, incoming, this.#extendProvenance.has(`prop:${key}`))) {
811
+ if (this.#extendProvenance.has(`prop:${key}`)) throw divergentPropError(key, existing, incoming, this.#originOf(`prop:${key}`), "builder state");
812
+ 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)}".`);
813
+ }
616
814
  }
617
815
  const nextProps = {
618
816
  ...this.#propRegistry,
@@ -622,29 +820,33 @@ var SystemBuilder = class SystemBuilder {
622
820
  return new SystemBuilder(nextProps, {
623
821
  ...this.#groupRegistry,
624
822
  ...newGroup
625
- }, this.#selectorRegistry, this.#includesRegistry, this.#conditionRegistry);
823
+ }, this.#selectorRegistry, this.#includesRegistry, this.#conditionRegistry, this.#extendProvenance, this.#extendCount);
626
824
  }
627
825
  addProps(config) {
628
826
  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
827
  for (const key of Object.keys(config)) if (key in this.#propRegistry) {
630
828
  const existing = this.#propRegistry[key];
631
829
  const incoming = config[key];
632
- if (!arePropDefinitionsEqual(existing, incoming)) throw new Error(`Prop "${key}" already registered with a different definition.`);
830
+ if (!arePropDefinitionsEqual(existing, incoming, this.#extendProvenance.has(`prop:${key}`))) {
831
+ if (this.#extendProvenance.has(`prop:${key}`)) throw divergentPropError(key, existing, incoming, this.#originOf(`prop:${key}`), "builder state");
832
+ throw new Error(`Prop "${key}" already registered with a different definition.`);
833
+ }
633
834
  }
634
835
  return new SystemBuilder({
635
836
  ...this.#propRegistry,
636
837
  ...config
637
- }, this.#groupRegistry, this.#selectorRegistry, this.#includesRegistry, this.#conditionRegistry);
838
+ }, this.#groupRegistry, this.#selectorRegistry, this.#includesRegistry, this.#conditionRegistry, this.#extendProvenance, this.#extendCount);
638
839
  }
639
840
  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;
841
+ 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]])));
842
+ const snapshot = createRegistrySnapshot(this.#propRegistry, this.#groupRegistry, this.#selectorRegistry, this.#conditionRegistry);
645
843
  const system = Object.assign(animus, { toConfig: () => {
646
- return serializeInstance(propRegistry, groupRegistry, selectorRegistry, conditionRegistry);
844
+ return serializeInstance(snapshot.props, snapshot.groups, snapshot.selectors, snapshot.conditions);
647
845
  } });
846
+ Object.defineProperty(system, "getRegistrySnapshot", {
847
+ value: () => snapshot,
848
+ enumerable: false
849
+ });
648
850
  const createGlobalStyles = ((styles, options) => ({
649
851
  __brand: "GlobalStyleBlock",
650
852
  styles,
@@ -658,9 +860,41 @@ var SystemBuilder = class SystemBuilder {
658
860
  };
659
861
  }
660
862
  };
863
+ /**
864
+ * Freeze the builder's registries into the build-time snapshot (design D7):
865
+ * containers, per-entry objects, and the mutable values nested inside a prop
866
+ * (`properties` arrays, object/array scales) are copies, so neither the
867
+ * builder's onward chaining nor post-build mutation of the instance's public
868
+ * registry fields reaches serialized or merged output. Transform functions
869
+ * are cached immutable forwarding wrappers: behavior survives without keeping
870
+ * mutable serialization metadata live.
871
+ */
872
+ function createRegistrySnapshot(propRegistry, groupRegistry, selectorRegistry, conditionRegistry) {
873
+ const props = {};
874
+ for (const [name, entry] of Object.entries(propRegistry)) {
875
+ const copy = { ...entry };
876
+ if (copy.properties) copy.properties = Object.freeze([...copy.properties]);
877
+ if (copy.scale && typeof copy.scale === "object") copy.scale = Object.freeze(Array.isArray(copy.scale) ? [...copy.scale] : { ...copy.scale });
878
+ if (copy.transform) copy.transform = snapshotTransform(copy.transform);
879
+ props[name] = Object.freeze(copy);
880
+ }
881
+ const groups = {};
882
+ for (const [name, members] of Object.entries(groupRegistry)) groups[name] = Object.freeze([...members]);
883
+ const selectors = {};
884
+ for (const [name, entry] of Object.entries(selectorRegistry)) selectors[name] = Object.freeze({ ...entry });
885
+ const conditions = {};
886
+ for (const [name, entry] of Object.entries(conditionRegistry)) conditions[name] = Object.freeze({ ...entry });
887
+ return Object.freeze({
888
+ props: Object.freeze(props),
889
+ groups: Object.freeze(groups),
890
+ selectors: Object.freeze(selectors),
891
+ conditions: Object.freeze(conditions)
892
+ });
893
+ }
661
894
  function serializeInstance(propRegistry, groupRegistry, selectorRegistry, conditionRegistry) {
662
895
  const serialized = {};
663
896
  const transforms = {};
897
+ const transformOwners = {};
664
898
  for (const [propName, entry] of Object.entries(propRegistry)) {
665
899
  const s = { property: entry.property };
666
900
  if (entry.properties && entry.properties.length > 0) s.properties = [...entry.properties];
@@ -672,8 +906,11 @@ function serializeInstance(propRegistry, groupRegistry, selectorRegistry, condit
672
906
  const fn = entry.transform;
673
907
  const name = fn.transformName ?? fn.name;
674
908
  if (name) {
909
+ const existing = transforms[name];
910
+ 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
911
  s.transform = name;
676
912
  transforms[name] = fn;
913
+ transformOwners[name] = propName;
677
914
  }
678
915
  }
679
916
  if (entry.currentVar) s.currentVar = entry.currentVar;
@@ -693,6 +930,144 @@ function createSystem(config) {
693
930
  return new SystemBuilder(void 0, void 0, void 0, config?.includes ?? []);
694
931
  }
695
932
  //#endregion
933
+ //#region src/theme/resolveReferences.ts
934
+ /**
935
+ * Late-binding token-reference resolution over the flattened theme (D4,
936
+ * openspec change first-class-extension).
937
+ *
938
+ * Replaces the old single-pass, insertion-order-dependent rewrite: references
939
+ * resolve against the COMPLETE flattened map via depth-first traversal of the
940
+ * reference DAG, so declaration order is never observable (G3), references
941
+ * inside emitted scales resolve into the emitted variable declarations
942
+ * instead of leaking literally into CSS (G2), and a token resolves to the
943
+ * same value whether its scale is emitted or inlined (G1 — an emitted target
944
+ * substitutes as `var()`, which the cascade late-binds to exactly the value
945
+ * the inlined form substitutes directly).
946
+ *
947
+ * Pure module: consumes the flatten pass's maps, returns fresh maps, mutates
948
+ * nothing. Runs in QuickJS — ES built-ins only, no Node/WHATWG APIs.
949
+ */
950
+ /** Token ref pattern: {scale.key}, {scale.key.sub}, or {path/NN} (opacity). */
951
+ const TOKEN_REF_RE$1 = /\{([^}]+)\}/g;
952
+ /** DFS node states for cycle detection. */
953
+ const UNVISITED = 0;
954
+ const RESOLVING = 1;
955
+ const RESOLVED = 2;
956
+ /** Parse every `{...}` occurrence via matchAll — no shared lastIndex. */
957
+ function parseReferences(value) {
958
+ const references = [];
959
+ for (const match of value.matchAll(TOKEN_REF_RE$1)) {
960
+ const text = match[1];
961
+ const slashIdx = text.indexOf("/");
962
+ references.push(slashIdx === -1 ? {
963
+ text,
964
+ path: text
965
+ } : {
966
+ text,
967
+ path: text.slice(0, slashIdx),
968
+ opacity: text.slice(slashIdx + 1)
969
+ });
970
+ }
971
+ return references;
972
+ }
973
+ /**
974
+ * Resolve token references over the complete flattened maps.
975
+ *
976
+ * - `tokenMap`/`variableMap`/`variables` are `flattenTheme`'s outputs: an
977
+ * emitted path's tokenMap entry is its `var()` indirection and its RAW
978
+ * authored value is parked in `variables`; a non-emitted path's tokenMap
979
+ * entry IS its raw value.
980
+ * - Substitution per reference: emitted target → `var(--target)`; non-emitted
981
+ * target → its resolved literal; `{path/NN}` keeps the existing
982
+ * `color-mix` output shape (`/0` → `transparent`, `/100` → the base).
983
+ * - Unresolvable target: warn once per missing path, keep the literal — a
984
+ * SUPPORTED pattern (a kit theme may reference tokens its consumer
985
+ * provides later). Dangling-reference errors arrive with the explicit
986
+ * replacement form (D5, increment 04).
987
+ * - Reference cycle: hard error naming the cycle's token paths in traversal
988
+ * order, regardless of emission flags (emission-invariant failure, G1).
989
+ */
990
+ function resolveReferences(tokenMap, variableMap, variables) {
991
+ const paths = Object.keys(tokenMap).sort();
992
+ const known = new Set(paths);
993
+ /** The authored (raw) value behind a path — see the contract note above. */
994
+ const rawValueOf = (path) => {
995
+ const varName = variableMap[path];
996
+ if (varName !== void 0 && variables[varName] !== void 0) return variables[varName];
997
+ return tokenMap[path];
998
+ };
999
+ const referencesByPath = /* @__PURE__ */ new Map();
1000
+ for (const path of paths) {
1001
+ const raw = rawValueOf(path);
1002
+ if (typeof raw === "string" && raw.includes("{")) {
1003
+ const references = parseReferences(raw);
1004
+ if (references.length > 0) referencesByPath.set(path, references);
1005
+ }
1006
+ }
1007
+ const state = /* @__PURE__ */ new Map();
1008
+ const resolved = /* @__PURE__ */ new Map();
1009
+ /** Active DFS trail — the cycle report slices it from the revisited node. */
1010
+ const trail = [];
1011
+ const warnedMissing = /* @__PURE__ */ new Set();
1012
+ const substitute = (reference, match) => {
1013
+ if (!known.has(reference.path)) {
1014
+ if (!warnedMissing.has(reference.path)) {
1015
+ warnedMissing.add(reference.path);
1016
+ console.warn(`[animus] Token ref {${reference.text}} — path '${reference.path}' not found in token map`);
1017
+ }
1018
+ return match;
1019
+ }
1020
+ const targetVar = variableMap[reference.path];
1021
+ const targetValue = resolved.get(reference.path);
1022
+ const base = targetVar !== void 0 && !targetValue.includes("{") ? `var(${targetVar})` : targetValue;
1023
+ if (reference.opacity !== void 0) {
1024
+ const alpha = Number.parseInt(reference.opacity, 10);
1025
+ if (Number.isNaN(alpha)) return base;
1026
+ if (alpha === 0) return "transparent";
1027
+ if (alpha !== 100) return `color-mix(in srgb, ${base} ${alpha}%, transparent)`;
1028
+ }
1029
+ return base;
1030
+ };
1031
+ const resolvePath = (path) => {
1032
+ const status = state.get(path) ?? UNVISITED;
1033
+ if (status === RESOLVED) return;
1034
+ if (status === RESOLVING) {
1035
+ const cycle = [...trail.slice(trail.indexOf(path)), path];
1036
+ 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.`);
1037
+ }
1038
+ state.set(path, RESOLVING);
1039
+ trail.push(path);
1040
+ const references = referencesByPath.get(path);
1041
+ if (references) {
1042
+ for (const reference of references) if (known.has(reference.path)) resolvePath(reference.path);
1043
+ }
1044
+ let value = rawValueOf(path);
1045
+ if (references) {
1046
+ let index = 0;
1047
+ value = value.replace(TOKEN_REF_RE$1, (match) => substitute(references[index++], match));
1048
+ }
1049
+ trail.pop();
1050
+ state.set(path, RESOLVED);
1051
+ resolved.set(path, value);
1052
+ };
1053
+ for (const path of paths) resolvePath(path);
1054
+ const outTokenMap = {};
1055
+ const outVariables = {};
1056
+ for (const path of paths) {
1057
+ const varName = variableMap[path];
1058
+ if (varName === void 0) outTokenMap[path] = resolved.get(path);
1059
+ else {
1060
+ outTokenMap[path] = tokenMap[path];
1061
+ if (variables[varName] !== void 0) outVariables[varName] = resolved.get(path);
1062
+ }
1063
+ }
1064
+ for (const varName of Object.keys(variables).sort()) if (outVariables[varName] === void 0) outVariables[varName] = variables[varName];
1065
+ return {
1066
+ tokenMap: outTokenMap,
1067
+ variables: outVariables
1068
+ };
1069
+ }
1070
+ //#endregion
696
1071
  //#region src/theme/utils.ts
697
1072
  /** Type guard: true for non-null, non-array objects */
698
1073
  function isObject(value) {
@@ -797,16 +1172,18 @@ function validateModeAliases(modeName, aliases, nestedColors, flatColorKeys, pre
797
1172
  * the `:root:not([data-color-mode])` guard while matching no mode block.
798
1173
  */
799
1174
  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"]);
1175
+ const RESERVED_THEME_KEYS = new Set([
1176
+ "breakpoints",
1177
+ "modes",
1178
+ "mode",
1179
+ "systemPreference",
1180
+ "browserColorScheme",
1181
+ "modeBases",
1182
+ "__emitted",
1183
+ "manifest",
1184
+ "serialize",
1185
+ "varRef"
1186
+ ]);
810
1187
  const COLOR_SCHEME_VALUES = new Set([
811
1188
  "light",
812
1189
  "dark",
@@ -894,6 +1271,33 @@ function resolveColorModeOptions(modeNames, systemPreference, suppliedBrowserCol
894
1271
  }
895
1272
  return browserColorScheme;
896
1273
  }
1274
+ /**
1275
+ * Validate the D6 `basedOn` mode-base map against the MERGED mode set: every
1276
+ * key and every base must name a declared mode, self-bases are rejected, and
1277
+ * base chains must terminate (a cycle can never fill coverage). Runs at both
1278
+ * gates like the other mode options — `addColorModes` (fail fast) and
1279
+ * `build()` (authoritative: extend/from composition merges modes without
1280
+ * passing through `addColorModes`).
1281
+ */
1282
+ function validateModeBases(modeNames, modeBases) {
1283
+ if (!modeBases) return;
1284
+ const declared = new Set(modeNames);
1285
+ const available = `Available modes: ${modeNames.join(", ")}`;
1286
+ for (const [modeName, base] of Object.entries(modeBases)) {
1287
+ if (!declared.has(modeName)) throw new Error(`addColorModes: basedOn names unknown mode '${modeName}'. ${available}`);
1288
+ if (typeof base !== "string" || !declared.has(base)) throw new Error(`addColorModes: basedOn['${modeName}'] references unknown base mode '${String(base)}'. ${available}`);
1289
+ if (base === modeName) throw new Error(`addColorModes: basedOn['${modeName}'] cannot base a mode on itself.`);
1290
+ }
1291
+ for (const start of Object.keys(modeBases)) {
1292
+ const seen = /* @__PURE__ */ new Set();
1293
+ let cursor = start;
1294
+ while (cursor !== void 0) {
1295
+ 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.`);
1296
+ seen.add(cursor);
1297
+ cursor = modeBases[cursor];
1298
+ }
1299
+ }
1300
+ }
897
1301
  /** Validate all color entries, throwing on invalid values. */
898
1302
  function validateColors(colors) {
899
1303
  for (const [key, value] of Object.entries(colors)) if (isObject(value)) validateColors(value);
@@ -905,7 +1309,12 @@ function createState(theme) {
905
1309
  emittedScales: /* @__PURE__ */ new Set(),
906
1310
  contextualVars: /* @__PURE__ */ new Map(),
907
1311
  contextualVarRegistrations: /* @__PURE__ */ new Map(),
908
- hasLegacyManifestSource: false
1312
+ hasLegacyManifestSource: false,
1313
+ extendProvenance: /* @__PURE__ */ new Map(),
1314
+ extendCount: 0,
1315
+ inheritedModes: /* @__PURE__ */ new Set(),
1316
+ inheritedModeAliases: /* @__PURE__ */ new Set(),
1317
+ droppedTokenPaths: /* @__PURE__ */ new Map()
909
1318
  };
910
1319
  }
911
1320
  function copyState(state, nextTheme) {
@@ -915,12 +1324,101 @@ function copyState(state, nextTheme) {
915
1324
  contextualVars: /* @__PURE__ */ new Map(),
916
1325
  contextualVarRegistrations: new Map(state.contextualVarRegistrations),
917
1326
  ...state.carriedManifestV2 ? { carriedManifestV2: { ...state.carriedManifestV2 } } : {},
918
- hasLegacyManifestSource: state.hasLegacyManifestSource
1327
+ hasLegacyManifestSource: state.hasLegacyManifestSource,
1328
+ extendProvenance: new Map(state.extendProvenance),
1329
+ extendCount: state.extendCount,
1330
+ inheritedModes: new Set(state.inheritedModes),
1331
+ inheritedModeAliases: new Set(state.inheritedModeAliases),
1332
+ droppedTokenPaths: new Map(state.droppedTokenPaths)
919
1333
  };
920
1334
  for (const [scale, vars] of state.contextualVars) next.contextualVars.set(scale, [...vars]);
921
1335
  return next;
922
1336
  }
923
1337
  /**
1338
+ * Exact leaf-path flatten for extend() provenance: like `flattenToDotPaths`
1339
+ * but WITHOUT the `_` identity-key collapse, so a leaf and a branch can
1340
+ * never share a path spelling. A prefix relation between two tracked paths
1341
+ * is then a GENUINE structural divergence (one sibling authored a leaf
1342
+ * value where the other authored a nested branch) — the review-F1 case that
1343
+ * per-leaf value comparison alone cannot see.
1344
+ */
1345
+ function flattenLeafPathsExact(object, path) {
1346
+ const result = {};
1347
+ for (const key of Object.keys(object)) {
1348
+ const nextKey = path ? `${path}.${key}` : key;
1349
+ const current = object[key];
1350
+ if (isObject(current)) Object.assign(result, flattenLeafPathsExact(current, nextKey));
1351
+ else result[nextKey] = current;
1352
+ }
1353
+ return result;
1354
+ }
1355
+ /**
1356
+ * Deep copy of plain theme data (records, arrays, primitives). `merge`
1357
+ * adopts and MUTATES nested source objects in place, so EVERY builder step
1358
+ * copies before folding: a consumed kit's built theme must never be
1359
+ * corrupted by composition, and prior builder state must never be shared by
1360
+ * reference — a one-level copy lets branching a builder cross-contaminate
1361
+ * both branches and lets build() outputs mutate after the fact.
1362
+ */
1363
+ function deepCopyPlain(value) {
1364
+ if (Array.isArray(value)) return value.map(deepCopyPlain);
1365
+ if (isObject(value)) {
1366
+ const record = value;
1367
+ const copy = {};
1368
+ for (const key of Object.keys(record)) copy[key] = deepCopyPlain(record[key]);
1369
+ return copy;
1370
+ }
1371
+ return value;
1372
+ }
1373
+ /** Structural equality over plain theme data (records, arrays, primitives). */
1374
+ function plainDataEqual(a, b) {
1375
+ if (Object.is(a, b)) return true;
1376
+ if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((item, index) => plainDataEqual(item, b[index]));
1377
+ if (isObject(a) && isObject(b)) {
1378
+ const aRecord = a;
1379
+ const bRecord = b;
1380
+ const aKeys = Object.keys(aRecord);
1381
+ return aKeys.length === Object.keys(bRecord).length && aKeys.every((key) => Object.prototype.hasOwnProperty.call(bRecord, key) && plainDataEqual(aRecord[key], bRecord[key]));
1382
+ }
1383
+ return false;
1384
+ }
1385
+ /**
1386
+ * Re-seed builder state from a source theme's manifest. This is `from()`'s
1387
+ * manifest read, factored out verbatim so `extend()` inherits the SAME
1388
+ * emitted-scale/contextual-var/manifest-v2 carry semantics: the manifest is
1389
+ * non-enumerable, so the ordinary key-copy loop never sees it (D6/D8).
1390
+ */
1391
+ function reseedStateFromManifest(state, manifest, mergeExtensionState = false) {
1392
+ if (manifest?.emittedScales) for (const scale of manifest.emittedScales) state.emittedScales.add(scale);
1393
+ else if (manifest?.variableMap) {
1394
+ const emittedPaths = manifest.manifestVersion === 2 && manifest.tokenDefinitions ? Object.keys(manifest.variableMap).filter((tokenPath) => manifest.tokenDefinitions?.[tokenPath] !== void 0) : Object.keys(manifest.variableMap);
1395
+ for (const tokenPath of emittedPaths) {
1396
+ const scale = tokenPath.split(".")[0];
1397
+ state.emittedScales.add(scale === "colors" ? "colors" : scale);
1398
+ }
1399
+ }
1400
+ if (manifest?.contextualVars) for (const [scale, vars] of Object.entries(manifest.contextualVars)) {
1401
+ const existing = mergeExtensionState ? state.contextualVars.get(scale) : void 0;
1402
+ state.contextualVars.set(scale, existing ? [...new Set([...existing, ...vars])] : [...vars]);
1403
+ }
1404
+ if (manifest) if (manifest.manifestVersion === 2) {
1405
+ state.carriedManifestV2 = {
1406
+ tokenDefinitions: manifest.tokenDefinitions,
1407
+ emittedScales: manifest.emittedScales,
1408
+ modeAliasDefinitions: manifest.modeAliasDefinitions,
1409
+ registrations: manifest.registrations,
1410
+ emitterVersion: manifest.emitterVersion,
1411
+ contractHash: manifest.contractHash,
1412
+ cssFragments: manifest.cssFragments
1413
+ };
1414
+ if (manifest.registrations) for (const [name, registration] of Object.entries(manifest.registrations)) {
1415
+ const existing = state.contextualVarRegistrations.get(name);
1416
+ 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`);
1417
+ state.contextualVarRegistrations.set(name, registration);
1418
+ }
1419
+ } else state.hasLegacyManifestSource = true;
1420
+ }
1421
+ /**
924
1422
  * ThemeScales — the final phase. Has addScale, extendScale, declareContextualVars, build.
925
1423
  * Also allows addColors and addColorModes for augmentation.
926
1424
  */
@@ -931,39 +1429,94 @@ var ThemeBuilder = class ThemeBuilder {
931
1429
  }
932
1430
  addBreakpoints(breakpoints) {
933
1431
  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 });
1432
+ const nextTheme = merge(deepCopyPlain(this._state.theme), { breakpoints });
935
1433
  return new ThemeBuilder(copyState(this._state, nextTheme));
936
1434
  }
1435
+ /**
1436
+ * FROZEN for the deprecation window (D1/G6): source-WINS precedence over
1437
+ * prior builder state, callable at ANY stage — the phantom Stage passes
1438
+ * through unchanged. Do not add a stage gate; do not flip the merge
1439
+ * direction. Those semantics ship under the new verb only.
1440
+ *
1441
+ * @deprecated Use `extend(source)` — the single extension verb on both
1442
+ * builders: the extended source seeds the base and later local calls win.
1443
+ * `from()` keeps these frozen source-wins semantics for at least one minor
1444
+ * release after `extend()` ships.
1445
+ */
937
1446
  from(builtTheme) {
1447
+ const source = isLibraryBundle(builtTheme) ? builtTheme.theme ?? builtTheme.tokens ?? {} : builtTheme;
938
1448
  const raw = {};
939
- for (const key of Object.keys(builtTheme)) {
940
- const val = builtTheme[key];
1449
+ for (const key of Object.keys(source)) {
1450
+ const val = source[key];
941
1451
  if (typeof val !== "function") raw[key] = val;
942
1452
  }
943
- const nextTheme = merge({}, this._state.theme, raw);
1453
+ const nextTheme = merge(deepCopyPlain(this._state.theme), deepCopyPlain(raw));
944
1454
  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);
1455
+ reseedStateFromManifest(next._state, source.manifest);
1456
+ return next;
1457
+ }
1458
+ /**
1459
+ * Extend this theme from a consumed library (D1/D2): the source's complete
1460
+ * configuration — tokens, modes, preferences, registrations, emitted-scale
1461
+ * set — seeds the builder as the BASE, and local calls made after
1462
+ * `extend()` win on conflict (the mirror of `from()`'s source-wins).
1463
+ * Chainable and repeatable, but only before augmentation methods ("inherit
1464
+ * first, then extend" — enforced by the phantom builder stage). Accepts a
1465
+ * built theme or a library bundle, consuming the bundle's theme half
1466
+ * (`theme ?? tokens`, D9) and ignoring the rest.
1467
+ *
1468
+ * Sibling conflicts fail loud (D3/G4): a leaf path defined divergently by
1469
+ * two extended sources throws naming the path and both sources
1470
+ * positionally; equal values coalesce silently, and the consumer's own
1471
+ * post-extend `add*` calls override silently (NS-4).
1472
+ */
1473
+ extend(source) {
1474
+ const themeHalf = isLibraryBundle(source) ? source.theme ?? source.tokens ?? {} : source;
1475
+ const raw = {};
1476
+ for (const key of Object.keys(themeHalf)) {
1477
+ const val = themeHalf[key];
1478
+ if (typeof val !== "function") raw[key] = val;
949
1479
  }
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;
1480
+ const sourceIndex = this._state.extendCount + 1;
1481
+ const provenance = new Map(this._state.extendProvenance);
1482
+ const existingLeaves = flattenLeafPathsExact(this._state.theme);
1483
+ const incomingLeaves = flattenLeafPathsExact(raw);
1484
+ const trackedPrefixes = /* @__PURE__ */ new Map();
1485
+ for (const [trackedPath, index] of provenance) for (let dot = trackedPath.lastIndexOf("."); dot !== -1; dot = trackedPath.lastIndexOf(".", dot - 1)) {
1486
+ const prefix = trackedPath.slice(0, dot);
1487
+ if (!trackedPrefixes.has(prefix)) trackedPrefixes.set(prefix, index);
1488
+ }
1489
+ for (const [path, value] of Object.entries(incomingLeaves)) {
1490
+ const priorIndex = provenance.get(path);
1491
+ if (priorIndex !== void 0) {
1492
+ const existing = existingLeaves[path];
1493
+ 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().`);
1494
+ continue;
1495
+ }
1496
+ const branchIndex = trackedPrefixes.get(path);
1497
+ 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().`);
1498
+ for (let dot = path.lastIndexOf("."); dot !== -1; dot = path.lastIndexOf(".", dot - 1)) {
1499
+ const ancestor = path.slice(0, dot);
1500
+ const ancestorIndex = provenance.get(ancestor);
1501
+ 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().`);
1502
+ }
1503
+ provenance.set(path, sourceIndex);
1504
+ }
1505
+ const nextTheme = merge(deepCopyPlain(raw), deepCopyPlain(this._state.theme));
1506
+ const next = new ThemeBuilder(copyState(this._state, nextTheme));
1507
+ next._state.extendProvenance = provenance;
1508
+ next._state.extendCount = sourceIndex;
1509
+ if (isObject(raw.modes)) for (const [modeName, modeAliases] of Object.entries(raw.modes)) {
1510
+ next._state.inheritedModes.add(modeName);
1511
+ if (!isObject(modeAliases)) continue;
1512
+ for (const aliasPath of Object.keys(flattenToDotPaths(modeAliases))) next._state.inheritedModeAliases.add(aliasPath);
1513
+ }
1514
+ reseedStateFromManifest(next._state, themeHalf.manifest, true);
962
1515
  return next;
963
1516
  }
964
1517
  addColors(colors) {
965
1518
  validateColors(colors);
966
- const nextTheme = merge({}, this._state.theme, { colors });
1519
+ const nextTheme = merge(deepCopyPlain(this._state.theme), { colors });
967
1520
  const next = new ThemeBuilder(copyState(this._state, nextTheme));
968
1521
  next._state.emittedScales.add("colors");
969
1522
  return next;
@@ -982,20 +1535,32 @@ var ThemeBuilder = class ThemeBuilder {
982
1535
  const systemPreference = mergeOptionObject(this._state.theme.systemPreference, options?.systemPreference);
983
1536
  const browserColorScheme = mergeOptionObject(this._state.theme.browserColorScheme, options?.browserColorScheme);
984
1537
  resolveColorModeOptions(modeNames, systemPreference, browserColorScheme);
985
- const nextTheme = merge({}, this._state.theme, {
1538
+ const modeBases = mergeOptionObject(this._state.theme.modeBases, options?.basedOn);
1539
+ validateModeBases(modeNames, modeBases);
1540
+ const nextTheme = merge(deepCopyPlain(this._state.theme), {
986
1541
  modes: modeConfig,
987
1542
  mode: initialMode,
988
1543
  ...systemPreference ? { systemPreference } : {},
989
- ...browserColorScheme ? { browserColorScheme } : {}
1544
+ ...browserColorScheme ? { browserColorScheme } : {},
1545
+ ...modeBases ? { modeBases } : {}
990
1546
  });
991
1547
  return new ThemeBuilder(copyState(this._state, nextTheme));
992
1548
  }
993
1549
  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 });
1550
+ const { name, values, emit, replace } = config;
1551
+ 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.`);
1552
+ const prior = this._state.theme[name];
1553
+ let nextTheme;
1554
+ if (replace) {
1555
+ nextTheme = deepCopyPlain(this._state.theme);
1556
+ nextTheme[name] = values;
1557
+ } else nextTheme = merge(deepCopyPlain(this._state.theme), { [name]: values });
997
1558
  const next = new ThemeBuilder(copyState(this._state, nextTheme));
998
1559
  if (emit) next._state.emittedScales.add(name);
1560
+ if (replace && isObject(prior)) {
1561
+ const kept = new Set(Object.keys(flattenToDotPaths(values)));
1562
+ for (const dotKey of Object.keys(flattenToDotPaths(prior))) if (!kept.has(dotKey)) next._state.droppedTokenPaths.set(`${name}.${dotKey}`, name);
1563
+ }
999
1564
  return next;
1000
1565
  }
1001
1566
  declareContextualVars(vars, registrations) {
@@ -1011,7 +1576,7 @@ var ThemeBuilder = class ThemeBuilder {
1011
1576
  return next;
1012
1577
  }
1013
1578
  extendScale(key, updateFn) {
1014
- const nextTheme = merge({}, this._state.theme, { [key]: updateFn(this._state.theme[key]) });
1579
+ const nextTheme = merge(deepCopyPlain(this._state.theme), { [key]: updateFn(this._state.theme[key]) });
1015
1580
  return new ThemeBuilder(copyState(this._state, nextTheme));
1016
1581
  }
1017
1582
  /**
@@ -1019,24 +1584,44 @@ var ThemeBuilder = class ThemeBuilder {
1019
1584
  * Flattens nested data at the boundary — produces manifest and serialize().
1020
1585
  */
1021
1586
  build() {
1022
- const theme = merge({}, this._state.theme);
1587
+ const theme = deepCopyPlain(this._state.theme);
1023
1588
  const emittedScales = this._state.emittedScales;
1024
1589
  const contextualVars = this._state.contextualVars;
1025
1590
  const systemPreference = isObject(theme.systemPreference) ? theme.systemPreference : void 0;
1026
1591
  const mergedModeNames = isObject(theme.modes) ? Object.keys(theme.modes) : [];
1027
1592
  validateReservedModeNames(mergedModeNames);
1028
1593
  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);
1594
+ const modeBases = isObject(theme.modeBases) ? theme.modeBases : void 0;
1595
+ validateModeBases(mergedModeNames, modeBases);
1596
+ if (isObject(theme.modes) && isObject(theme.colors)) {
1597
+ const nestedColors = theme.colors;
1598
+ const flatColorKeys = Object.keys(flattenToDotPaths(nestedColors));
1599
+ for (const [modeName, modeAliases] of Object.entries(theme.modes)) {
1600
+ if (!isObject(modeAliases)) continue;
1601
+ validateModeAliases(modeName, modeAliases, nestedColors, flatColorKeys, "");
1602
+ }
1603
+ }
1604
+ const modeAliasDefinitions = collectAuthoredModeAliases(theme);
1605
+ const { effectiveModes, coverageFills } = resolveModeCoverage(modeAliasDefinitions, modeBases, this._state.inheritedModes, this._state.inheritedModeAliases);
1606
+ for (const fill of coverageFills) console.info(`[animus] Mode '${fill.mode}': ${fill.count} alias(es) inherit from '${fill.base}'`);
1607
+ const { tokenMap: flatTokenMap, variableMap, variables: flatVariables, tokenDefinitions } = flattenTheme(theme, emittedScales, effectiveModes);
1608
+ assertNoDroppedReferences(tokenDefinitions, flatTokenMap, this._state.droppedTokenPaths, this._state.extendProvenance);
1609
+ const { tokenMap, variables } = resolveReferences(flatTokenMap, variableMap, flatVariables);
1610
+ const { modeVariables, modeTokens } = resolveModeValueMaps(effectiveModes, variableMap, variables, tokenMap);
1031
1611
  const bpVariables = {};
1032
- if (theme.breakpoints && isObject(theme.breakpoints)) for (const [key, value] of Object.entries(theme.breakpoints)) bpVariables[`--breakpoint-${key}`] = `${value}px`;
1612
+ if (theme.breakpoints && isObject(theme.breakpoints)) {
1613
+ const breakpointEntries = Object.entries(theme.breakpoints).map(([key, value]) => [`--breakpoint-${key}`, `${value}px`]).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
1614
+ for (const [varName, value] of breakpointEntries) bpVariables[varName] = value;
1615
+ }
1033
1616
  let contextualVarsSerialized;
1034
1617
  if (contextualVars.size > 0) {
1035
1618
  contextualVarsSerialized = {};
1036
1619
  for (const [scale, vars] of contextualVars) contextualVarsSerialized[scale] = vars;
1037
1620
  }
1038
1621
  const propertyCss = buildPropertyRegistrationCss(contextualVars, this._state.contextualVarRegistrations);
1039
- const baseVariableCss = buildVariableCss(variables, bpVariables, modeVariables, {
1622
+ const { emittableVariables, emittableModeVariables, omitted } = omitUnresolvedDeclarations(variables, modeVariables);
1623
+ if (omitted.length > 0) console.warn(`[animus] Omitted ${omitted.length} CSS declaration(s) whose token references never resolved: ${omitted.join(", ")}`);
1624
+ const baseVariableCss = buildVariableCss(emittableVariables, bpVariables, emittableModeVariables, {
1040
1625
  initialMode: typeof theme.mode === "string" ? theme.mode : void 0,
1041
1626
  systemPreference,
1042
1627
  browserColorScheme
@@ -1059,26 +1644,30 @@ var ThemeBuilder = class ThemeBuilder {
1059
1644
  manifestV2Fields = {
1060
1645
  manifestVersion: 2,
1061
1646
  tokenDefinitions,
1647
+ emittedScales: [...emittedScales].sort(),
1062
1648
  modeAliasDefinitions,
1063
1649
  registrations,
1064
1650
  emitterVersion: EMITTER_VERSION,
1065
1651
  contractHash: computeContractHash({
1066
1652
  tokenDefinitions,
1653
+ emittedScales: [...emittedScales].sort(),
1067
1654
  modeAliasDefinitions,
1068
1655
  initialMode: typeof theme.mode === "string" ? theme.mode : void 0,
1069
1656
  registrations,
1070
1657
  systemPreference,
1071
- browserColorScheme
1658
+ browserColorScheme,
1659
+ modeBases
1072
1660
  }),
1073
1661
  cssFragments
1074
1662
  };
1075
1663
  }
1664
+ const sortedVariableMap = sortRecordByKey(variableMap);
1076
1665
  const manifest = {
1077
1666
  tokenMap: {
1078
1667
  ...tokenMap,
1079
- ...Object.fromEntries(Object.entries(theme.breakpoints || {}).map(([k, v]) => [`breakpoints.${k}`, String(v)]))
1668
+ ...sortRecordByKey(Object.fromEntries(Object.entries(theme.breakpoints || {}).map(([k, v]) => [`breakpoints.${k}`, String(v)])))
1080
1669
  },
1081
- variableMap,
1670
+ variableMap: sortedVariableMap,
1082
1671
  modes: modeTokens,
1083
1672
  variableCss,
1084
1673
  ...contextualVarsSerialized ? { contextualVars: contextualVarsSerialized } : {},
@@ -1130,19 +1719,19 @@ function createTheme() {
1130
1719
  const TOKEN_REF_RE = /\{([^}]+)\}/g;
1131
1720
  /**
1132
1721
  * Flatten the nested theme into dot-path keyed token map and CSS variable declarations.
1133
- * This is the ONLY place where flattening happens.
1722
+ * This is the ONLY place where flattening happens. Mode VALUE maps are no
1723
+ * longer computed here — they resolve AFTER `resolveReferences` (see
1724
+ * {@link resolveModeValueMaps}), closing the G2 gap where mode-override
1725
+ * declarations bypassed the resolver.
1134
1726
  */
1135
- function flattenTheme(theme, emittedScales) {
1727
+ function flattenTheme(theme, emittedScales, effectiveModes) {
1136
1728
  const tokenMap = {};
1137
1729
  const variableMap = {};
1138
1730
  const variables = {};
1139
- const modeVariables = {};
1140
- const modeTokens = {};
1141
1731
  const tokenDefinitions = {};
1142
- const modeAliasDefinitions = {};
1143
1732
  for (const [scaleName, scaleValue] of Object.entries(theme)) {
1144
1733
  if (scaleName.startsWith("_")) continue;
1145
- if (scaleName === "breakpoints" || scaleName === "mode" || scaleName === "modes" || scaleName === "systemPreference" || scaleName === "browserColorScheme") continue;
1734
+ if (scaleName === "breakpoints" || scaleName === "mode" || scaleName === "modes" || scaleName === "systemPreference" || scaleName === "browserColorScheme" || scaleName === "modeBases") continue;
1146
1735
  if (typeof scaleValue === "function") continue;
1147
1736
  if (!isObject(scaleValue)) continue;
1148
1737
  const flat = flattenToDotPaths(scaleValue);
@@ -1159,54 +1748,242 @@ function flattenTheme(theme, emittedScales) {
1159
1748
  } else tokenMap[tokenPath] = String(rawValue);
1160
1749
  }
1161
1750
  }
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);
1751
+ const initialMode = theme.mode;
1752
+ const initialAliases = typeof initialMode === "string" ? effectiveModes[initialMode] : void 0;
1753
+ if (initialAliases) for (const [aliasDotKey, colorRef] of Object.entries(initialAliases)) {
1754
+ const varName = `--color-${dotToDash(aliasDotKey)}`;
1755
+ const paletteVarName = variableMap[`colors.${colorRef}`];
1756
+ if (paletteVarName) {
1757
+ if (paletteVarName !== varName) variables[varName] = `var(${paletteVarName})`;
1758
+ } else {
1759
+ const literal = tokenMap[`colors.${colorRef}`];
1760
+ if (literal !== void 0) variables[varName] = literal;
1195
1761
  }
1762
+ tokenMap[`colors.${aliasDotKey}`] = `var(${varName})`;
1763
+ variableMap[`colors.${aliasDotKey}`] = varName;
1196
1764
  }
1197
1765
  return {
1198
1766
  tokenMap,
1199
1767
  variableMap,
1200
1768
  variables,
1769
+ tokenDefinitions
1770
+ };
1771
+ }
1772
+ /** Rebuild a record with lexicographically sorted keys (wire determinism, G3). */
1773
+ function sortRecordByKey(record) {
1774
+ const sorted = {};
1775
+ for (const key of Object.keys(record).sort()) sorted[key] = record[key];
1776
+ return sorted;
1777
+ }
1778
+ /**
1779
+ * Collect the AUTHORED mode alias graph: mode name → alias dot-path → the
1780
+ * authored color dot-path (manifest v2, D6 — never a resolved value).
1781
+ * Empty when the theme has no modes or no colors, mirroring the original
1782
+ * flatten-pass guard.
1783
+ */
1784
+ function collectAuthoredModeAliases(theme) {
1785
+ const authored = {};
1786
+ if (!isObject(theme.modes) || !isObject(theme.colors)) return authored;
1787
+ for (const [modeName, modeAliases] of Object.entries(theme.modes)) {
1788
+ if (!isObject(modeAliases)) continue;
1789
+ const defs = {};
1790
+ for (const [aliasDotKey, colorRef] of Object.entries(flattenToDotPaths(modeAliases))) {
1791
+ if (typeof colorRef !== "string") continue;
1792
+ defs[aliasDotKey] = colorRef;
1793
+ }
1794
+ authored[modeName] = defs;
1795
+ }
1796
+ return authored;
1797
+ }
1798
+ /**
1799
+ * D6 coverage over the merged mode set: a CONSUMER-declared mode leaving
1800
+ * inherited aliases uncovered must name a base (`basedOn`) whose chain
1801
+ * covers them — otherwise the build fails listing the uncovered set.
1802
+ * Inherited modes are exempt (a kit's own asymmetry is pre-existing
1803
+ * behavior and must round-trip byte-identically). Returns the effective
1804
+ * alias map per mode (base-chain fills + authored, authored winning) and
1805
+ * one aggregated fill report per mode for the build diagnostic.
1806
+ */
1807
+ function resolveModeCoverage(authoredModeAliases, modeBases, inheritedModes, inheritedModeAliases) {
1808
+ const effectiveModes = {};
1809
+ const coverageFills = [];
1810
+ for (const modeName of Object.keys(authoredModeAliases)) {
1811
+ const authored = authoredModeAliases[modeName];
1812
+ const fills = {};
1813
+ if (!inheritedModes.has(modeName)) {
1814
+ const uncovered = [...inheritedModeAliases].filter((alias) => !(alias in authored)).sort();
1815
+ if (uncovered.length > 0) {
1816
+ const base = modeBases?.[modeName];
1817
+ 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.`);
1818
+ const stillUncovered = [];
1819
+ for (const alias of uncovered) {
1820
+ let cursor = base;
1821
+ const seen = new Set([modeName]);
1822
+ let resolved;
1823
+ while (cursor !== void 0 && !seen.has(cursor)) {
1824
+ seen.add(cursor);
1825
+ resolved = authoredModeAliases[cursor]?.[alias];
1826
+ if (resolved !== void 0) break;
1827
+ cursor = modeBases?.[cursor];
1828
+ }
1829
+ if (resolved === void 0) stillUncovered.push(alias);
1830
+ else fills[alias] = resolved;
1831
+ }
1832
+ 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.`);
1833
+ coverageFills.push({
1834
+ mode: modeName,
1835
+ base,
1836
+ count: Object.keys(fills).length
1837
+ });
1838
+ }
1839
+ }
1840
+ effectiveModes[modeName] = {
1841
+ ...fills,
1842
+ ...authored
1843
+ };
1844
+ }
1845
+ return {
1846
+ effectiveModes,
1847
+ coverageFills
1848
+ };
1849
+ }
1850
+ /**
1851
+ * Resolve the per-mode value maps AFTER reference resolution: every mode
1852
+ * declaration carries the RESOLVED value of its target color (emitted →
1853
+ * the resolved declaration value; inlined → the resolved literal), never
1854
+ * the raw flattened string — the G2 closure for `[data-color-mode]` blocks.
1855
+ * Modes iterate in sorted name order and lines in sorted property-name
1856
+ * order, so mode declaration/insertion order is never observable (G3).
1857
+ */
1858
+ function resolveModeValueMaps(effectiveModes, variableMap, variables, tokenMap) {
1859
+ const modeVariables = {};
1860
+ const modeTokens = {};
1861
+ const resolvedColorValue = (colorRef) => {
1862
+ const path = `colors.${colorRef}`;
1863
+ const varName = variableMap[path];
1864
+ if (varName !== void 0) {
1865
+ const declared = variables[varName];
1866
+ if (declared !== void 0) return declared;
1867
+ } else if (tokenMap[path] !== void 0) return tokenMap[path];
1868
+ return String(colorRef);
1869
+ };
1870
+ for (const modeName of Object.keys(effectiveModes).sort()) {
1871
+ const aliases = effectiveModes[modeName];
1872
+ const entries = Object.keys(aliases).map((aliasDotKey) => [`--color-${dotToDash(aliasDotKey)}`, aliasDotKey]).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
1873
+ const modeVars = {};
1874
+ const modeVals = {};
1875
+ for (const [varName, aliasDotKey] of entries) {
1876
+ const value = resolvedColorValue(aliases[aliasDotKey]);
1877
+ modeVars[varName] = value;
1878
+ modeVals[`colors.${aliasDotKey}`] = value;
1879
+ }
1880
+ modeVariables[modeName] = modeVars;
1881
+ modeTokens[modeName] = modeVals;
1882
+ }
1883
+ return {
1201
1884
  modeVariables,
1202
- modeTokens,
1203
- tokenDefinitions,
1204
- modeAliasDefinitions
1885
+ modeTokens
1886
+ };
1887
+ }
1888
+ /**
1889
+ * D5 enforcement: a reference whose target is absent from the merged map
1890
+ * AND was dropped by an explicit `addScale({ replace: true })` fails the
1891
+ * build — unconditional on usage — naming the referencing token (with its
1892
+ * positional origin), the replacement call's scale, and the dropped keys.
1893
+ * Targets never defined ANYWHERE stay warn-and-literal (supported kit
1894
+ * pattern); a re-added key is simply known again and passes.
1895
+ */
1896
+ function assertNoDroppedReferences(tokenDefinitions, flatTokenMap, droppedTokenPaths, extendProvenance) {
1897
+ if (droppedTokenPaths.size === 0) return;
1898
+ const known = new Set(Object.keys(flatTokenMap));
1899
+ const violations = [];
1900
+ const droppedNamed = /* @__PURE__ */ new Set();
1901
+ for (const [tokenPath, definition] of Object.entries(tokenDefinitions)) {
1902
+ if (definition.kind !== "reference") continue;
1903
+ for (const reference of definition.references) {
1904
+ if (known.has(reference.path)) continue;
1905
+ const scale = droppedTokenPaths.get(reference.path);
1906
+ if (scale === void 0) continue;
1907
+ const provenanceIndex = extendProvenance.get(tokenPath);
1908
+ const origin = provenanceIndex === void 0 ? "builder state" : `extended theme #${provenanceIndex}`;
1909
+ violations.push(`'${tokenPath}' (${origin}) references '{${reference.path}}', dropped by addScale({ name: '${scale}', replace: true })`);
1910
+ droppedNamed.add(reference.path);
1911
+ }
1912
+ }
1913
+ if (violations.length > 0) throw new Error(`build: dangling token reference(s) after explicit scale replacement — ${violations.join("; ")}. Dropped keys: ${[...droppedNamed].sort().join(", ")}.`);
1914
+ }
1915
+ /** A resolved value still carrying a `{…}` reference — never shippable (G2). */
1916
+ const UNRESOLVED_REF_RE = /\{[^}]+\}/;
1917
+ /** Custom-property names referenced through `var(--name)` in a value. */
1918
+ const VAR_REF_NAME_RE = /var\(\s*(--[\w-]+)/g;
1919
+ function varRefNames(value) {
1920
+ const names = [];
1921
+ for (const match of value.matchAll(VAR_REF_NAME_RE)) names.push(match[1]);
1922
+ return names;
1923
+ }
1924
+ /**
1925
+ * Split root and mode variable maps into shippable declarations and omitted
1926
+ * var names (G2): any value still containing `{…}` after resolution — a
1927
+ * direct or TRANSITIVE never-defined target — is withheld from emitted CSS.
1928
+ *
1929
+ * Withholding must follow `var()` indirection too: flattenTheme synthesizes
1930
+ * the initial mode's aliases as `var(--target)` BEFORE resolution, so a
1931
+ * withheld target would otherwise leave an emitted alias pointing at a
1932
+ * declaration that exists nowhere. A declaration is dangling when a var()
1933
+ * target of its value was DECLARED in this build but withheld everywhere
1934
+ * the declaration can see (:root for root declarations; the same mode
1935
+ * block or :root for mode declarations); chains drop to a fixpoint. Var
1936
+ * names never declared here (breakpoints, contextual vars) are exempt.
1937
+ */
1938
+ function omitUnresolvedDeclarations(variables, modeVariables) {
1939
+ const omitted = [];
1940
+ const emittableVariables = {};
1941
+ const droppedRoot = /* @__PURE__ */ new Set();
1942
+ for (const [varName, value] of Object.entries(variables)) if (UNRESOLVED_REF_RE.test(value)) {
1943
+ omitted.push(varName);
1944
+ droppedRoot.add(varName);
1945
+ } else emittableVariables[varName] = value;
1946
+ let rootChanged = true;
1947
+ while (rootChanged) {
1948
+ rootChanged = false;
1949
+ for (const [varName, value] of Object.entries(emittableVariables)) if (varRefNames(value).some((name) => droppedRoot.has(name))) {
1950
+ delete emittableVariables[varName];
1951
+ droppedRoot.add(varName);
1952
+ omitted.push(varName);
1953
+ rootChanged = true;
1954
+ }
1955
+ }
1956
+ const emittableModeVariables = {};
1957
+ for (const [modeName, modeVars] of Object.entries(modeVariables)) {
1958
+ const kept = {};
1959
+ const droppedInMode = /* @__PURE__ */ new Set();
1960
+ const dropFromMode = (varName) => {
1961
+ omitted.push(`${varName} ([data-color-mode="${modeName}"])`);
1962
+ droppedInMode.add(varName);
1963
+ };
1964
+ for (const [varName, value] of Object.entries(modeVars)) if (UNRESOLVED_REF_RE.test(value)) dropFromMode(varName);
1965
+ else kept[varName] = value;
1966
+ const dangling = (name) => (droppedInMode.has(name) || droppedRoot.has(name)) && !(name in kept) && !(name in emittableVariables);
1967
+ let modeChanged = true;
1968
+ while (modeChanged) {
1969
+ modeChanged = false;
1970
+ for (const [varName, value] of Object.entries(kept)) if (varRefNames(value).some(dangling)) {
1971
+ delete kept[varName];
1972
+ dropFromMode(varName);
1973
+ modeChanged = true;
1974
+ }
1975
+ }
1976
+ emittableModeVariables[modeName] = kept;
1977
+ }
1978
+ return {
1979
+ emittableVariables,
1980
+ emittableModeVariables,
1981
+ omitted
1205
1982
  };
1206
1983
  }
1207
1984
  /**
1208
1985
  * Classify a RAW token value into its authored form (manifest v2, D6). MUST
1209
- * run before `resolveTokenRefs` — resolution rewrites the string, and the
1986
+ * run before `resolveReferences` — resolution rewrites the string, and the
1210
1987
  * authored graph cannot be reconstructed from resolved CSS (D8). Uses
1211
1988
  * `matchAll` so the shared global {@link TOKEN_REF_RE} never carries a stale
1212
1989
  * `lastIndex` between callers.
@@ -1432,43 +2209,6 @@ function computeContractHash(input) {
1432
2209
  return sha256Hex(JSON.stringify(canonicalize(input)));
1433
2210
  }
1434
2211
  /**
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
2212
  * Build `@property` registration rules for registered contextual vars.
1473
2213
  * The emitted custom property is `--${name}` — the same name the Rust resolver
1474
2214
  * maps a bare contextual var value to. Rules emit in declaration order and only
@@ -1520,4 +2260,4 @@ function buildVariableCss(rootVariables, breakpointVariables, modeVariables, sys
1520
2260
  return parts.join("\n\n");
1521
2261
  }
1522
2262
  //#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 };
2263
+ 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 };