@untheme/utils 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,181 @@
1
+ import * as _untheme_schema from '@untheme/schema';
2
+ import { Template, Overrides, Modifier, Context, Binding, Authored, Theme, Contract, Definition, Modifiers } from '@untheme/schema';
3
+
4
+ /**
5
+ * Deep copy of a theme, facet by facet: identity, tokens, modifiers, and order
6
+ * are each rebuilt through {@link copy}, so no definition object, nested
7
+ * `$value` structure, or override map is shared with the source. Mutating the
8
+ * clone at any depth never reaches the original.
9
+ *
10
+ * Because {@link copy} reaches every value through plain property access,
11
+ * cloning a reactive proxy yields an inert, plain snapshot.
12
+ */
13
+ declare const clone: <T extends Template>(theme: T) => T;
14
+
15
+ /**
16
+ * Deep copy of plain data: records and arrays are rebuilt from fresh
17
+ * containers, so mutating the copy never reaches the source. Every value is
18
+ * reached through plain property access, so copying a reactive proxy yields an
19
+ * inert, plain snapshot detached from the proxy.
20
+ *
21
+ * Non-plain values — functions and class instances, such as those a token's
22
+ * `$extensions` may carry — pass through by reference rather than being
23
+ * duplicated. `structuredClone` is never used: it throws on functions and
24
+ * detaches proxies through a mechanism this copy deliberately avoids.
25
+ */
26
+ declare const copy: <T>(value: T) => T;
27
+
28
+ /**
29
+ * The entries of `to` that deviate from `from`: every key `to` holds whose
30
+ * value is not structurally equal to the one `from` holds. Emitted values are
31
+ * copies, so the result shares no structure with `to`. Keys with structurally
32
+ * equal values drop out; two objects that bind identically yield an empty
33
+ * result.
34
+ */
35
+ declare const delta: <T extends object>(from: T, to: T) => Partial<T>;
36
+
37
+ /**
38
+ * A contract extension for {@link extend}: new tokens (`XTok`) and new modifiers
39
+ * (`XMod`), plus optional overrides of the base's tokens and of its existing
40
+ * modifier contexts.
41
+ *
42
+ * An existing base token accepts an optional bare binding — a value or a
43
+ * `{reference}` that rebinds its `$value` only, never its `$type`. A new token
44
+ * requires a full {@link Authored} definition. Every value position draws its
45
+ * reference suggestions from the union of the base tokens and the extension's
46
+ * own new tokens, so a binding may reference either set.
47
+ *
48
+ * `Tok` is inferred from the base contract and `XTok` from the new-token keys
49
+ * alone; the reference arm of every {@link Binding} carries `NoInfer`, so
50
+ * a reference sitting in a value position never drives inference and never
51
+ * collapses the token union.
52
+ *
53
+ * `XTok` infers as every key present in `tokens`, base overrides included, since
54
+ * a mapped type cannot subtract the known `Tok` keys during inference. The
55
+ * `K extends Tok` guard routes those leaked base keys back to a bare binding, so
56
+ * only genuinely new keys demand a full definition. The arm stays optional: a
57
+ * required arm stops `XTok` from inferring when `tokens` is empty, and the
58
+ * fallback to `string` disables every token-name check.
59
+ *
60
+ * `modifiers` admits overrides of an existing axis's contexts and new axes
61
+ * carrying their complete context maps.
62
+ */
63
+ type Extension<Tok extends string, Mod extends Record<string, Record<string, object>>, XTok extends string, XMod extends Record<string, Record<string, object>>> = {
64
+ id: string;
65
+ name: string;
66
+ tokens: {
67
+ [K in NoInfer<Tok>]?: Binding;
68
+ } & {
69
+ [K in XTok]?: K extends Tok ? Binding : Authored;
70
+ };
71
+ modifiers: {
72
+ [M in keyof Mod]?: {
73
+ [C in keyof Mod[M]]?: {
74
+ [K in NoInfer<Tok | XTok>]?: Binding;
75
+ };
76
+ };
77
+ } & {
78
+ [M in keyof XMod]: {
79
+ [C in keyof XMod[M]]: {
80
+ [K in NoInfer<Tok | XTok>]?: Binding;
81
+ };
82
+ };
83
+ };
84
+ order: ((keyof Mod | keyof XMod) & (string & {}))[];
85
+ };
86
+ /**
87
+ * The deviation between two themes, as produced by {@link diff}: a token
88
+ * override map and per-modifier, per-context override maps, all present (empty
89
+ * when nothing deviates) so consumers can inspect them without guards.
90
+ */
91
+ type Diff<T extends Template> = {
92
+ tokens: Overrides<T>;
93
+ modifiers: {
94
+ [M in Modifier<T>]: {
95
+ [C in Context<T, M>]: Overrides<T>;
96
+ };
97
+ };
98
+ };
99
+ /**
100
+ * A partial overlay of a theme: any subset of identity, tokens, modifiers, and
101
+ * order. Both a `Layer` (identity plus partial overrides) and a `Patch`
102
+ * (anonymous overrides) fit this shape, so one merge serves them all.
103
+ */
104
+ type Overlay<T extends Template> = {
105
+ id?: string;
106
+ name?: string;
107
+ tokens?: Overrides<T>;
108
+ modifiers?: {
109
+ [M in Modifier<T>]?: {
110
+ [C in Context<T, M>]?: Overrides<T>;
111
+ };
112
+ };
113
+ order?: NoInfer<Modifier<T>>[];
114
+ };
115
+
116
+ /**
117
+ * Computes the patch that turns `from` into `to`: every binding `to` holds that
118
+ * deviates from `from`, token by token and context by context. At the token
119
+ * level the bound `$value` is compared and emitted — a token's metadata cannot
120
+ * drift through the patch pipeline, so only the binding is carried. Identity and
121
+ * order are not compared. Empty maps mean the themes bind identically; applying
122
+ * the result to `from` via `merge` restores every binding `to` carries. A
123
+ * binding `from` holds that `to` dropped is not restored — a patch can add and
124
+ * override, never remove.
125
+ */
126
+ declare const diff: <T extends Template>(from: Theme<T>, to: Theme<T>) => Diff<T>;
127
+
128
+ /**
129
+ * Widens a base contract with an {@link Extension} into a fresh contract over
130
+ * the union token and modifier sets. Neither input is mutated.
131
+ *
132
+ * Tokens compose two ways: an extension key that already names a base token
133
+ * rebinds that token's `$value`, preserving its `$type` and metadata; an
134
+ * extension key beyond the base inserts its full definition as a new token.
135
+ * Modifiers spread the new axes over the base's, then deep-merge each context
136
+ * the two share, the extension's bindings winning per token.
137
+ *
138
+ * The base may be an authored contract or any complete {@link Theme} of one —
139
+ * a machine-built theme (a prior extension's output) widens the same way.
140
+ */
141
+ declare const extend: <Tok extends string, Mod extends Record<string, Record<string, object>>, XTok extends string, XMod extends Record<string, Record<string, object>>>(base: Contract<Tok, Mod> | Theme<Contract<Tok, Mod>>, extension: Extension<Tok, Mod, XTok, XMod>) => {
142
+ id: string;
143
+ name: string;
144
+ tokens: { [K in Tok | XTok | (NoInfer<Tok> & string)]: Definition; };
145
+ modifiers: { [M in keyof Mod]?: { [C in keyof Mod[M]]?: { [K_1 in NoInfer<Tok | XTok>]?: Binding | undefined; } | undefined; } | undefined; } & { [M_1 in keyof XMod]: { [C in keyof XMod[M_1]]: { [K_1 in NoInfer<Tok | XTok>]?: Binding | undefined; }; }; } & { [M_2 in _untheme_schema.Modifier<Contract<Tok, Mod>>]: { [C in _untheme_schema.Context<Contract<Tok, Mod>, M_2>]: Overrides<Contract<Tok, Mod>>; }; };
146
+ order: ((keyof Mod | keyof XMod) & string)[];
147
+ };
148
+
149
+ declare const guard: (v: unknown) => v is Template;
150
+
151
+ /**
152
+ * Merges overlays over a complete theme into a fresh theme, left to right:
153
+ * later overlays win where they bind the same token, token by token and context
154
+ * by context. Identity and order transfer from the last overlay that carries
155
+ * them. No input is mutated; with no overlays the result is a plain copy.
156
+ *
157
+ * A token override rebinds a slot's `$value` and nothing else — the slot's
158
+ * `$type`, description, and other metadata are preserved, and the incoming
159
+ * binding replaces the old `$value` whole rather than merging into it. An
160
+ * overlay key with no matching base slot is skipped: there is no `$type` to
161
+ * inherit, so no definition can be fabricated for it.
162
+ *
163
+ * A `Layer` overlay adopts its identity (apply semantics); a `Patch` overlay
164
+ * has none, so the prevailing identity is preserved (update semantics).
165
+ */
166
+ declare const merge: <T extends Template>(theme: Theme<T>, ...overlays: Overlay<T>[]) => Theme<T>;
167
+
168
+ /**
169
+ * Rebuilds a modifiers structure leaf by leaf: every context of every modifier
170
+ * mapped through the callback, each modifier keeping its own context keys. The
171
+ * callback's `at` indexes any other modifiers structure at the leaf's own
172
+ * coordinates, so a leaf can be combined with its counterpart elsewhere; a
173
+ * sparse structure — a layer's or patch's partial modifier map — may hold
174
+ * nothing there, so `at` yields the overrides or `undefined`. The
175
+ * modifier/context pairing is carried generically here, checked once; callers
176
+ * supply only the leaf operation.
177
+ */
178
+ declare const traverse: <T extends Template, R>(modifiers: Modifiers<T>, fn: (overrides: Overrides<T>, at: (other: { [M in Modifier<T>]?: { [C in Context<T, M>]?: Overrides<T>; }; }) => Overrides<T> | undefined) => R) => { [M in Modifier<T>]: { [C in Context<T, M>]: R; }; };
179
+
180
+ export { clone, copy, delta, diff, extend, guard, merge, traverse };
181
+ export type { Diff, Extension, Overlay };
@@ -0,0 +1,181 @@
1
+ import * as _untheme_schema from '@untheme/schema';
2
+ import { Template, Overrides, Modifier, Context, Binding, Authored, Theme, Contract, Definition, Modifiers } from '@untheme/schema';
3
+
4
+ /**
5
+ * Deep copy of a theme, facet by facet: identity, tokens, modifiers, and order
6
+ * are each rebuilt through {@link copy}, so no definition object, nested
7
+ * `$value` structure, or override map is shared with the source. Mutating the
8
+ * clone at any depth never reaches the original.
9
+ *
10
+ * Because {@link copy} reaches every value through plain property access,
11
+ * cloning a reactive proxy yields an inert, plain snapshot.
12
+ */
13
+ declare const clone: <T extends Template>(theme: T) => T;
14
+
15
+ /**
16
+ * Deep copy of plain data: records and arrays are rebuilt from fresh
17
+ * containers, so mutating the copy never reaches the source. Every value is
18
+ * reached through plain property access, so copying a reactive proxy yields an
19
+ * inert, plain snapshot detached from the proxy.
20
+ *
21
+ * Non-plain values — functions and class instances, such as those a token's
22
+ * `$extensions` may carry — pass through by reference rather than being
23
+ * duplicated. `structuredClone` is never used: it throws on functions and
24
+ * detaches proxies through a mechanism this copy deliberately avoids.
25
+ */
26
+ declare const copy: <T>(value: T) => T;
27
+
28
+ /**
29
+ * The entries of `to` that deviate from `from`: every key `to` holds whose
30
+ * value is not structurally equal to the one `from` holds. Emitted values are
31
+ * copies, so the result shares no structure with `to`. Keys with structurally
32
+ * equal values drop out; two objects that bind identically yield an empty
33
+ * result.
34
+ */
35
+ declare const delta: <T extends object>(from: T, to: T) => Partial<T>;
36
+
37
+ /**
38
+ * A contract extension for {@link extend}: new tokens (`XTok`) and new modifiers
39
+ * (`XMod`), plus optional overrides of the base's tokens and of its existing
40
+ * modifier contexts.
41
+ *
42
+ * An existing base token accepts an optional bare binding — a value or a
43
+ * `{reference}` that rebinds its `$value` only, never its `$type`. A new token
44
+ * requires a full {@link Authored} definition. Every value position draws its
45
+ * reference suggestions from the union of the base tokens and the extension's
46
+ * own new tokens, so a binding may reference either set.
47
+ *
48
+ * `Tok` is inferred from the base contract and `XTok` from the new-token keys
49
+ * alone; the reference arm of every {@link Binding} carries `NoInfer`, so
50
+ * a reference sitting in a value position never drives inference and never
51
+ * collapses the token union.
52
+ *
53
+ * `XTok` infers as every key present in `tokens`, base overrides included, since
54
+ * a mapped type cannot subtract the known `Tok` keys during inference. The
55
+ * `K extends Tok` guard routes those leaked base keys back to a bare binding, so
56
+ * only genuinely new keys demand a full definition. The arm stays optional: a
57
+ * required arm stops `XTok` from inferring when `tokens` is empty, and the
58
+ * fallback to `string` disables every token-name check.
59
+ *
60
+ * `modifiers` admits overrides of an existing axis's contexts and new axes
61
+ * carrying their complete context maps.
62
+ */
63
+ type Extension<Tok extends string, Mod extends Record<string, Record<string, object>>, XTok extends string, XMod extends Record<string, Record<string, object>>> = {
64
+ id: string;
65
+ name: string;
66
+ tokens: {
67
+ [K in NoInfer<Tok>]?: Binding;
68
+ } & {
69
+ [K in XTok]?: K extends Tok ? Binding : Authored;
70
+ };
71
+ modifiers: {
72
+ [M in keyof Mod]?: {
73
+ [C in keyof Mod[M]]?: {
74
+ [K in NoInfer<Tok | XTok>]?: Binding;
75
+ };
76
+ };
77
+ } & {
78
+ [M in keyof XMod]: {
79
+ [C in keyof XMod[M]]: {
80
+ [K in NoInfer<Tok | XTok>]?: Binding;
81
+ };
82
+ };
83
+ };
84
+ order: ((keyof Mod | keyof XMod) & (string & {}))[];
85
+ };
86
+ /**
87
+ * The deviation between two themes, as produced by {@link diff}: a token
88
+ * override map and per-modifier, per-context override maps, all present (empty
89
+ * when nothing deviates) so consumers can inspect them without guards.
90
+ */
91
+ type Diff<T extends Template> = {
92
+ tokens: Overrides<T>;
93
+ modifiers: {
94
+ [M in Modifier<T>]: {
95
+ [C in Context<T, M>]: Overrides<T>;
96
+ };
97
+ };
98
+ };
99
+ /**
100
+ * A partial overlay of a theme: any subset of identity, tokens, modifiers, and
101
+ * order. Both a `Layer` (identity plus partial overrides) and a `Patch`
102
+ * (anonymous overrides) fit this shape, so one merge serves them all.
103
+ */
104
+ type Overlay<T extends Template> = {
105
+ id?: string;
106
+ name?: string;
107
+ tokens?: Overrides<T>;
108
+ modifiers?: {
109
+ [M in Modifier<T>]?: {
110
+ [C in Context<T, M>]?: Overrides<T>;
111
+ };
112
+ };
113
+ order?: NoInfer<Modifier<T>>[];
114
+ };
115
+
116
+ /**
117
+ * Computes the patch that turns `from` into `to`: every binding `to` holds that
118
+ * deviates from `from`, token by token and context by context. At the token
119
+ * level the bound `$value` is compared and emitted — a token's metadata cannot
120
+ * drift through the patch pipeline, so only the binding is carried. Identity and
121
+ * order are not compared. Empty maps mean the themes bind identically; applying
122
+ * the result to `from` via `merge` restores every binding `to` carries. A
123
+ * binding `from` holds that `to` dropped is not restored — a patch can add and
124
+ * override, never remove.
125
+ */
126
+ declare const diff: <T extends Template>(from: Theme<T>, to: Theme<T>) => Diff<T>;
127
+
128
+ /**
129
+ * Widens a base contract with an {@link Extension} into a fresh contract over
130
+ * the union token and modifier sets. Neither input is mutated.
131
+ *
132
+ * Tokens compose two ways: an extension key that already names a base token
133
+ * rebinds that token's `$value`, preserving its `$type` and metadata; an
134
+ * extension key beyond the base inserts its full definition as a new token.
135
+ * Modifiers spread the new axes over the base's, then deep-merge each context
136
+ * the two share, the extension's bindings winning per token.
137
+ *
138
+ * The base may be an authored contract or any complete {@link Theme} of one —
139
+ * a machine-built theme (a prior extension's output) widens the same way.
140
+ */
141
+ declare const extend: <Tok extends string, Mod extends Record<string, Record<string, object>>, XTok extends string, XMod extends Record<string, Record<string, object>>>(base: Contract<Tok, Mod> | Theme<Contract<Tok, Mod>>, extension: Extension<Tok, Mod, XTok, XMod>) => {
142
+ id: string;
143
+ name: string;
144
+ tokens: { [K in Tok | XTok | (NoInfer<Tok> & string)]: Definition; };
145
+ modifiers: { [M in keyof Mod]?: { [C in keyof Mod[M]]?: { [K_1 in NoInfer<Tok | XTok>]?: Binding | undefined; } | undefined; } | undefined; } & { [M_1 in keyof XMod]: { [C in keyof XMod[M_1]]: { [K_1 in NoInfer<Tok | XTok>]?: Binding | undefined; }; }; } & { [M_2 in _untheme_schema.Modifier<Contract<Tok, Mod>>]: { [C in _untheme_schema.Context<Contract<Tok, Mod>, M_2>]: Overrides<Contract<Tok, Mod>>; }; };
146
+ order: ((keyof Mod | keyof XMod) & string)[];
147
+ };
148
+
149
+ declare const guard: (v: unknown) => v is Template;
150
+
151
+ /**
152
+ * Merges overlays over a complete theme into a fresh theme, left to right:
153
+ * later overlays win where they bind the same token, token by token and context
154
+ * by context. Identity and order transfer from the last overlay that carries
155
+ * them. No input is mutated; with no overlays the result is a plain copy.
156
+ *
157
+ * A token override rebinds a slot's `$value` and nothing else — the slot's
158
+ * `$type`, description, and other metadata are preserved, and the incoming
159
+ * binding replaces the old `$value` whole rather than merging into it. An
160
+ * overlay key with no matching base slot is skipped: there is no `$type` to
161
+ * inherit, so no definition can be fabricated for it.
162
+ *
163
+ * A `Layer` overlay adopts its identity (apply semantics); a `Patch` overlay
164
+ * has none, so the prevailing identity is preserved (update semantics).
165
+ */
166
+ declare const merge: <T extends Template>(theme: Theme<T>, ...overlays: Overlay<T>[]) => Theme<T>;
167
+
168
+ /**
169
+ * Rebuilds a modifiers structure leaf by leaf: every context of every modifier
170
+ * mapped through the callback, each modifier keeping its own context keys. The
171
+ * callback's `at` indexes any other modifiers structure at the leaf's own
172
+ * coordinates, so a leaf can be combined with its counterpart elsewhere; a
173
+ * sparse structure — a layer's or patch's partial modifier map — may hold
174
+ * nothing there, so `at` yields the overrides or `undefined`. The
175
+ * modifier/context pairing is carried generically here, checked once; callers
176
+ * supply only the leaf operation.
177
+ */
178
+ declare const traverse: <T extends Template, R>(modifiers: Modifiers<T>, fn: (overrides: Overrides<T>, at: (other: { [M in Modifier<T>]?: { [C in Context<T, M>]?: Overrides<T>; }; }) => Overrides<T> | undefined) => R) => { [M in Modifier<T>]: { [C in Context<T, M>]: R; }; };
179
+
180
+ export { clone, copy, delta, diff, extend, guard, merge, traverse };
181
+ export type { Diff, Extension, Overlay };
@@ -0,0 +1,133 @@
1
+ import { isEqual, isRecord, entries, remap, map, isDefinition, isObject } from '@untheme/common';
2
+
3
+ const copy = (value) => {
4
+ if (Array.isArray(value)) {
5
+ const result = value.map((entry) => copy(entry));
6
+ if (!isEqual(value, result)) {
7
+ throw new TypeError("could not copy array");
8
+ }
9
+ return result;
10
+ }
11
+ if (isRecord(value)) {
12
+ const result = {};
13
+ for (const key of Object.keys(value)) {
14
+ result[key] = copy(value[key]);
15
+ }
16
+ if (!isEqual(value, result)) {
17
+ throw new TypeError("could not copy record");
18
+ }
19
+ return result;
20
+ }
21
+ return value;
22
+ };
23
+
24
+ const clone = (theme) => {
25
+ const result = {
26
+ id: theme.id,
27
+ name: theme.name,
28
+ tokens: copy(theme.tokens),
29
+ modifiers: copy(theme.modifiers),
30
+ order: copy(theme.order)
31
+ };
32
+ if (!isEqual(theme, result)) {
33
+ throw new TypeError("unable to clone a theme");
34
+ }
35
+ return result;
36
+ };
37
+
38
+ const delta = (from, to) => {
39
+ const result = {};
40
+ for (const [key, value] of entries(to)) {
41
+ if (!isEqual(from[key], value)) {
42
+ result[key] = copy(value);
43
+ }
44
+ }
45
+ return result;
46
+ };
47
+
48
+ const traverse = (modifiers, fn) => remap(
49
+ modifiers,
50
+ (contexts, modifier) => remap(
51
+ contexts,
52
+ (overrides, context) => fn(overrides, (other) => other[modifier]?.[context])
53
+ )
54
+ );
55
+
56
+ const diff = (from, to) => {
57
+ const tokens = delta(
58
+ map(from.tokens, (slot) => slot.$value),
59
+ map(to.tokens, (slot) => slot.$value)
60
+ );
61
+ const modifiers = traverse(
62
+ to.modifiers,
63
+ (overrides, at) => delta(at(from.modifiers) ?? {}, overrides)
64
+ );
65
+ return { tokens, modifiers };
66
+ };
67
+
68
+ const extend = (base, extension) => {
69
+ const source = Object.assign({}, extension.tokens, base.tokens);
70
+ const tokens = map(source, (slot, token) => {
71
+ if (slot === void 0 || !isDefinition(slot)) {
72
+ throw new TypeError(`token "${token}" is not a definition`);
73
+ }
74
+ const definition = slot;
75
+ const incoming = extension.tokens[token];
76
+ if (incoming === void 0 || isDefinition(incoming)) {
77
+ return copy(definition);
78
+ }
79
+ const rebound = {
80
+ ...copy(definition),
81
+ $value: copy(incoming)
82
+ };
83
+ return rebound;
84
+ });
85
+ const modifiers = {
86
+ ...copy(extension.modifiers),
87
+ ...traverse(base.modifiers, (overrides, at) => ({
88
+ ...copy(overrides),
89
+ ...copy(at(extension.modifiers) ?? {})
90
+ }))
91
+ };
92
+ const listed = new Set(extension.order);
93
+ const order = [
94
+ ...base.order.filter((modifier) => !listed.has(modifier)),
95
+ ...extension.order
96
+ ];
97
+ const result = {
98
+ id: extension.id,
99
+ name: extension.name,
100
+ tokens,
101
+ modifiers,
102
+ order
103
+ };
104
+ return result;
105
+ };
106
+
107
+ const guard = (v) => {
108
+ return typeof v === "object" && v !== null && "id" in v && typeof v["id"] === "string" && "name" in v && typeof v["name"] === "string" && "tokens" in v && isObject(v["tokens"]) && "modifiers" in v && isRecord(v["modifiers"]) && "order" in v && Array.isArray(v["order"]);
109
+ };
110
+
111
+ const merge = (theme, ...overlays) => {
112
+ return overlays.reduce(
113
+ (acc, overlay) => ({
114
+ id: overlay.id ?? acc.id,
115
+ name: overlay.name ?? acc.name,
116
+ tokens: map(acc.tokens, (slot, token) => {
117
+ const binding = overlay.tokens?.[token];
118
+ if (binding === void 0) {
119
+ return slot;
120
+ }
121
+ return { ...slot, $value: copy(binding) };
122
+ }),
123
+ modifiers: traverse(acc.modifiers, (overrides, at) => ({
124
+ ...overrides,
125
+ ...copy(at(overlay.modifiers ?? {}) ?? {})
126
+ })),
127
+ order: copy(overlay.order ?? acc.order)
128
+ }),
129
+ clone(theme)
130
+ );
131
+ };
132
+
133
+ export { clone, copy, delta, diff, extend, guard, merge, traverse };
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # @untheme/utils
2
+
3
+ Common structural helpers for untheme's theme shape. Types come from [`@untheme/schema`](../schema); guards and generic helpers come from [`@untheme/common`](../common).
4
+
5
+ ## Exports
6
+
7
+ ### `merge(theme, ...overlays)`
8
+
9
+ Merges overlays over a complete theme into a fresh theme, left to right: later overlays win where they bind the same token, token by token and context by context. Identity and order transfer from the last overlay that carries them. No input is mutated; with no overlays the result is a plain copy.
10
+
11
+ ```ts
12
+ import { merge } from "@untheme/utils";
13
+
14
+ merge(theme, patch); // patch has no identity → identity preserved (update)
15
+ merge(theme, layer); // layer carries id/name → identity adopted (apply)
16
+ merge(theme, layer, patch); // layer applied, then patched — last binding wins
17
+ ```
18
+
19
+ ### `extend(base, extension)`
20
+
21
+ Widens a base contract with an extension into a fresh contract over the union token and modifier sets. New tokens and new modifiers join; existing tokens are overridden; existing modifier contexts are deep-merged leaf by leaf, so the base's other tokens in that context are kept. The `order` the extension lists wins — any modifier it names is dropped from the base order, then the extension's order is appended. Neither input is mutated.
22
+
23
+ ```ts
24
+ import { extend } from "@untheme/utils";
25
+
26
+ const wide = extend(base, {
27
+ id: "wide",
28
+ name: "Wide",
29
+ tokens: { accent: "#0090ff", bg: "{accent}" }, // new token + override existing
30
+ modifiers: {
31
+ color: { dark: { fg: "{accent}" } }, // override an existing context (leaf)
32
+ density: { compact: {}, cozy: {} }, // a new modifier
33
+ },
34
+ order: ["density", "color"],
35
+ });
36
+ ```
37
+
38
+ Unlike `merge`, which stays within one contract, `extend` is the widening primitive: the result's token and modifier sets grow to include the extension's.
39
+
40
+ ### `diff(from, to)`
41
+
42
+ Computes the patch that turns `from` into `to`: every binding `to` holds that deviates from `from`, token by token and context by context. At the token level only the bound `$value` is compared and emitted — a token's metadata cannot drift through the patch pipeline. Identity and order are not compared. Empty maps mean the themes bind identically; applying the result to `from` via `merge` restores every binding `to` carries. A patch can add and override, never remove — a context override `from` holds that `to` dropped survives the restoration, and identity, order, and slot metadata are not part of it.
43
+
44
+ ```ts
45
+ import { diff, merge } from "@untheme/utils";
46
+
47
+ const deviation = diff(pristine, edited);
48
+ merge(pristine, deviation); // ≅ edited
49
+ ```
50
+
51
+ ## Primitives
52
+
53
+ Supporting helpers that `merge`, `extend`, and `diff` are built on.
54
+
55
+ ### `clone(theme)`
56
+
57
+ Deep copy of a theme, facet by facet: identity, tokens, modifiers, and order are each rebuilt through `copy`, so no definition object, nested `$value` structure, or override map is shared with the source. Because `copy` reaches every value through plain property access, cloning a reactive proxy yields an inert, plain snapshot. `clone` throws a `TypeError` if the rebuilt theme isn't structurally equal to the source.
58
+
59
+ ```ts
60
+ import { clone } from "@untheme/utils";
61
+
62
+ const snapshot = clone(theme); // detached from any reactive proxy
63
+ ```
64
+
65
+ ### `copy(value)`
66
+
67
+ Deep copy of plain data: records and arrays are rebuilt from fresh containers, so mutating the copy never reaches the source. Non-plain values — functions and class instances, such as those a token's `$extensions` may carry — pass through by reference rather than being duplicated. `structuredClone` is never used: it throws on functions and detaches proxies through a mechanism `copy` deliberately avoids.
68
+
69
+ ### `delta(from, to)`
70
+
71
+ The entries of `to` that deviate from `from`: every key `to` holds whose value is not structurally equal to the one `from` holds. Emitted values are copies, so the result shares no structure with `to`. Keys with structurally equal values drop out; two objects that bind identically yield an empty result.
72
+
73
+ ### `traverse(modifiers, fn)`
74
+
75
+ Rebuilds a modifiers structure leaf by leaf: every context of every modifier is mapped through the callback, each modifier keeping its own context keys. The callback's `at` accessor indexes another (possibly sparse) modifiers structure at the same modifier/context coordinates, so a leaf can be combined with its counterpart elsewhere. This is the primitive `diff`, `merge`, and `extend` are all built on.
76
+
77
+ ## Types
78
+
79
+ - `Overlay<T>` — the shape `merge` accepts: any subset of identity, tokens, modifiers, and order. Both a `Layer` and a `Patch` from [`@untheme/schema`](../schema) fit it.
80
+ - `Extension<Tok, Mod, XTok, XMod>` — the shape `extend` accepts: new tokens (`XTok`) and new modifiers (`XMod`) over a base contract's tokens (`Tok`) and modifiers (`Mod`). An existing base token accepts an optional bare binding that rebinds its `$value` only; a new token requires a full slot definition. New modifier axes carry their complete context maps; existing axes take optional overrides.
81
+ - `Diff<T>` — the shape `diff` returns: token and per-context override maps, always present (empty when nothing deviates).
82
+
83
+ ## Related
84
+
85
+ - [`@untheme/schema`](../schema) — token contract types and runtime validation.
86
+ - [`@untheme/core`](../core) — the runtime theme service.
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@untheme/utils",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./.dist/index.mjs",
6
+ "types": "./.dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./.dist/index.d.ts",
10
+ "import": "./.dist/index.mjs"
11
+ }
12
+ },
13
+ "files": [
14
+ ".dist"
15
+ ],
16
+ "dependencies": {
17
+ "@untheme/common": "0.1.0",
18
+ "@untheme/schema": "0.1.0"
19
+ },
20
+ "scripts": {
21
+ "build": "unbuild",
22
+ "stub": "unbuild --stub",
23
+ "lint": "eslint .",
24
+ "typecheck": "tsc --noEmit",
25
+ "test": "vitest run"
26
+ }
27
+ }