@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.
- package/README.md +31 -6
- package/dist/Animus.d.ts +1 -1
- package/dist/Animus.d.ts.map +1 -1
- package/dist/AnimusExtended.d.ts +1 -1
- package/dist/AnimusExtended.d.ts.map +1 -1
- package/dist/SystemBuilder.d.ts +180 -11
- package/dist/SystemBuilder.d.ts.map +1 -1
- package/dist/assert-root-slot-B83scqPh.js +21 -0
- package/dist/asset.d.ts +25 -0
- package/dist/asset.d.ts.map +1 -0
- package/dist/compose.d.ts +3 -1
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +2 -1
- package/dist/composeWithContext.d.ts +3 -1
- package/dist/composeWithContext.d.ts.map +1 -1
- package/dist/composeWithContext.js +3 -2
- package/dist/conditions.d.ts +52 -4
- package/dist/conditions.d.ts.map +1 -1
- package/dist/{createComposedFamily-DJ2aqfqN.js → createComposedFamily-DhjtkyWd.js} +81 -7
- package/dist/groups/index.js +1 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +887 -147
- package/dist/runtime/assert-root-slot.d.ts +17 -0
- package/dist/runtime/assert-root-slot.d.ts.map +1 -0
- package/dist/runtime/createComposedFamily.d.ts.map +1 -1
- package/dist/runtime/is-dev.d.ts +46 -0
- package/dist/runtime/is-dev.d.ts.map +1 -0
- package/dist/runtime/resolveClasses.d.ts +15 -7
- package/dist/runtime/resolveClasses.d.ts.map +1 -1
- package/dist/runtime/witness.d.ts +4 -2
- package/dist/runtime/witness.d.ts.map +1 -1
- package/dist/runtime-entry.js +1 -1
- package/dist/selectors.d.ts +5 -1
- package/dist/selectors.d.ts.map +1 -1
- package/dist/{size-CusLCguT.js → size-DHdmBc64.js} +35 -8
- package/dist/theme/createTheme.d.ts +125 -13
- package/dist/theme/createTheme.d.ts.map +1 -1
- package/dist/theme/index.d.ts +1 -1
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/resolveReferences.d.ts +49 -0
- package/dist/theme/resolveReferences.d.ts.map +1 -0
- package/dist/transforms/createTransform.d.ts +22 -0
- package/dist/transforms/createTransform.d.ts.map +1 -1
- package/dist/types/theme.d.ts +28 -4
- package/dist/types/theme.d.ts.map +1 -1
- 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-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
576
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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(
|
|
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(
|
|
940
|
-
const val =
|
|
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(
|
|
1453
|
+
const nextTheme = merge(deepCopyPlain(this._state.theme), deepCopyPlain(raw));
|
|
944
1454
|
const next = new ThemeBuilder(copyState(this._state, nextTheme));
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
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(
|
|
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
|
|
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
|
|
996
|
-
const
|
|
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(
|
|
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 =
|
|
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
|
|
1030
|
-
|
|
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))
|
|
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
|
|
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
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
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
|
-
|
|
1204
|
-
|
|
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 `
|
|
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 };
|