diff --git a/package.json b/package.json
index aa71af71d..c4ddddd14 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,7 @@
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"sideEffects": false,
- "packageManager": "pnpm@10.19.0",
+ "packageManager": "pnpm@10.30.0",
"exports": {
".": {
"import": "./dist/index.js",
diff --git a/src/stories/Tasty.docs.mdx b/src/stories/Tasty.docs.mdx
index e946993f7..b13afef7a 100644
--- a/src/stories/Tasty.docs.mdx
+++ b/src/stories/Tasty.docs.mdx
@@ -50,6 +50,77 @@ const PrimaryButton = tasty(Button, {
```
+#### Extending vs. Replacing State Maps
+
+When a style property uses a state map, the merge behavior depends on whether the child provides a `''` (default) key:
+
+- **No `''` key** — extend mode: parent states are preserved, child adds/overrides
+- **Has `''` key** — replace mode: child defines everything from scratch
+
+```jsx live=false
+// Parent has: fill: { '': '#white', hovered: '#blue', disabled: '#gray' }
+
+// ✅ Extend — no '' key, parent states preserved
+const MyButton = tasty(Button, {
+ styles: {
+ fill: {
+ 'loading': '#yellow', // append new state
+ 'disabled': '#gray.20', // override existing state in place
+ },
+ },
+});
+
+// Replace — has '' key, parent states dropped
+const MyButton = tasty(Button, {
+ styles: {
+ fill: {
+ '': '#red',
+ 'hovered': '#blue',
+ },
+ },
+});
+```
+
+Use `'@inherit'` to pull a parent state value. In extend mode it repositions the state; in replace mode it cherry-picks it:
+
+```jsx live=false
+// Extend mode: reposition disabled to end (highest CSS priority)
+fill: {
+ 'loading': '#yellow',
+ disabled: '@inherit',
+}
+
+// Replace mode: cherry-pick disabled from parent
+fill: {
+ '': '#red',
+ disabled: '@inherit',
+}
+```
+
+Use `null` inside a state map to remove a state, or `false` to block it entirely (tombstone):
+
+```jsx live=false
+fill: { pressed: null } // removes pressed from the result
+fill: { disabled: false } // tombstone — no CSS for disabled, blocks recipe too
+```
+
+#### Resetting Properties with `null` and `false`
+
+```jsx live=false
+const SimpleButton = tasty(Button, {
+ styles: {
+ fill: null, // discard parent's fill, let recipe fill in
+ border: false, // no border at all (tombstone — blocks recipe too)
+ },
+});
+```
+
+| Value | Meaning | Recipe fills in? |
+|-------|---------|-----------------|
+| `undefined` | Not provided — parent preserved | N/A |
+| `null` | Intentional unset — parent discarded | Yes |
+| `false` | Tombstone — blocks everything | No |
+
### Essential Patterns
```jsx
diff --git a/src/tasty/STYLE-EXTEND-SPEC.md b/src/tasty/STYLE-EXTEND-SPEC.md
new file mode 100644
index 000000000..b512a6c83
--- /dev/null
+++ b/src/tasty/STYLE-EXTEND-SPEC.md
@@ -0,0 +1,444 @@
+# Style Extend Specification
+
+## Problem
+
+`mergeStyles` replaces style values wholesale. When a style property uses a state map (e.g., `fill: { '': '#white', hovered: '#blue', disabled: '#gray' }`), any extension via `tasty(Component, { styles: { fill: ... } })` **erases all parent states**. This forces consumers to duplicate the entire parent state map just to add one new state.
+
+Similarly, there is no way to "reset" a property back to a recipe-provided value when extending a component that already defines that property.
+
+## Design Constraints
+
+- All syntax must be **JSON-serializable** — plain strings, booleans, objects. No Symbols, no functions, no class instances.
+- Must be compatible with `tastyStatic` (build-time extraction).
+- Must add **near-zero overhead** on the hot path (when the feature is not used).
+- Must work within sub-element style blocks (e.g., `Icon: { color: { ... } }`).
+
+## Primitives
+
+| Marker | Where | Meaning |
+|---|---|---|
+| State map **without** `''` key | `mergeStyles` | Extend mode — merge with parent's state map |
+| State map **with** `''` key | `mergeStyles` | Replace mode — child defines everything |
+| `'@inherit'` as a state value | `mergeStyles` (inside any state map) | Use parent's value for this key |
+| `null` as a state value | `mergeStyles` (inside a state map) | Remove this state from the result |
+| `false` as a state value | `mergeStyles` (inside a state map) | Tombstone — blocks recipe, produces no CSS for this state |
+| `null` as a property value | `mergeStyles` | Intentional unset — discard parent's value, let recipe fill in |
+| `false` as a regular property value | Survives all layers | Tombstone — blocks parent value AND recipe, produces no CSS |
+
+The mode is determined implicitly: if the child state map contains a `''` (default) key, it is in **replace mode** (the child covers the base case and defines everything). If the `''` key is absent, it is in **extend mode** (the child expects to add to the parent's states).
+
+`'@inherit'` works in both modes. In extend mode it repositions a parent state. In replace mode it cherry-picks a parent state into the child's result.
+
+---
+
+## `null` vs `undefined` vs `false` — Value Semantics
+
+These three values have distinct meanings, applied consistently across both regular properties and sub-elements:
+
+| Value | Meaning | Recipe fills in? | CSS output |
+|---|---|---|---|
+| `undefined` | "I didn't provide this" — ignored, parent value is preserved | N/A (parent stays) | Parent's CSS |
+| `null` | "I intentionally unset this" — parent value is discarded | Yes | Recipe's CSS (or none) |
+| `false` | "I want nothing here" — stays as `false` through all layers | No (blocked) | None |
+
+`false` acts as a tombstone: it persists through `mergeStyles` and `resolveRecipes`, overriding the recipe value during resolution. The CSS generator treats `false` as a no-output value.
+
+### Behavior change for sub-elements
+
+The current `mergeStyles` uses loose equality (`== null`) for sub-element handling, treating both `null` and `undefined` identically. This spec changes sub-elements to use strict equality (`=== null`) to align with the property-level semantics above:
+
+- `SubElement: undefined` — ignored, parent's sub-element styles are preserved.
+- `SubElement: null` — parent's sub-element styles are discarded (deleted from result).
+- `SubElement: false` — parent's sub-element styles are discarded (deleted from result).
+
+Both `null` and `false` delete the sub-element from the merged result. Unlike regular properties, there is no tombstone distinction for sub-elements — `resolveRecipes` treats the absent key the same way regardless of which sentinel was used.
+
+---
+
+## Extend Mode — State Map Without Default Key
+
+When the child state map does **not** contain a `''` key, `mergeStyles` enters extend mode. All parent states are preserved; the child adds, overrides, repositions, or removes individual states.
+
+### Basic usage
+
+```js
+// Parent (e.g., Button variant styles)
+fill: {
+ '': '#white #primary',
+ hovered: '#white #primary-text',
+ pressed: '#white #primary',
+ disabled: '#white #primary-disabled',
+}
+
+// Extension (no '' key → extend mode)
+fill: {
+ 'custom-state': '#custom',
+}
+
+// Result: all parent states preserved, new state appended
+fill: {
+ '': '#white #primary',
+ hovered: '#white #primary-text',
+ pressed: '#white #primary',
+ disabled: '#white #primary-disabled',
+ 'custom-state': '#custom',
+}
+```
+
+### Merge rules
+
+- **Shared keys** — parent's position in the map, child's value (override in place).
+- **Parent-only keys** — kept in their original position and value.
+- **Child-only keys** — appended at the end (highest CSS specificity / priority).
+
+### Override a specific state
+
+```js
+fill: {
+ disabled: '#gray.20', // override parent's disabled value, keeps its position
+}
+```
+
+### Remove a state
+
+```js
+fill: {
+ pressed: null, // remove parent's pressed state from the result
+}
+```
+
+### Block a state with `false`
+
+`false` inside a state map acts as a tombstone — it persists through all layers and blocks the recipe from filling in:
+
+```js
+fill: {
+ disabled: false, // no CSS output for disabled, recipe cannot override
+}
+```
+
+### Extend when parent is a plain string
+
+When the parent value is not a state map (e.g., `fill: '#purple'`), it is automatically normalized to `{ '': parentValue }` before merging:
+
+```js
+// Parent: fill: '#purple'
+// Child: fill: { hovered: '#blue' }
+// Result: fill: { '': '#purple', hovered: '#blue' }
+```
+
+### Extend when parent has no value for this property
+
+When the parent does not define the property at all, `@inherit` and `null` entries are stripped, and the remaining entries are used as-is:
+
+```js
+// Parent: (no fill defined)
+// Child: fill: { hovered: '#blue' }
+// Result: fill: { hovered: '#blue' }
+```
+
+### Extend within sub-elements
+
+Works identically inside sub-element blocks:
+
+```js
+styles: {
+ Icon: {
+ color: {
+ loading: '#gray', // no '' → extend parent's Icon.color
+ },
+ },
+}
+```
+
+---
+
+## Replace Mode — State Map With Default Key
+
+When the child state map **contains** a `''` key, `mergeStyles` enters replace mode. The child defines the complete state map; parent states are dropped unless explicitly pulled via `@inherit`.
+
+### Basic usage
+
+```js
+// Parent
+fill: {
+ '': '#white #primary',
+ hovered: '#white #primary-text',
+ pressed: '#white #primary',
+ disabled: '#white #primary-disabled',
+}
+
+// Replacement (has '' key → replace mode)
+fill: {
+ '': '#red',
+ hovered: '#blue',
+}
+
+// Result: only child states
+fill: {
+ '': '#red',
+ hovered: '#blue',
+}
+```
+
+### Cherry-pick parent states with `@inherit`
+
+```js
+fill: {
+ '': '#red',
+ hovered: '#blue',
+ disabled: '@inherit', // pull disabled value from parent
+}
+
+// Result
+fill: {
+ '': '#red',
+ hovered: '#blue',
+ disabled: '#white #primary-disabled',
+}
+```
+
+---
+
+## `@inherit` — Pull a Parent State
+
+### Syntax
+
+Use `'@inherit'` as the **value** for a state key. This tells `mergeStyles` to resolve the value from the parent's state map for this key.
+
+**In extend mode**, `@inherit` repositions the parent state:
+
+1. Remove it from its original position in the parent order.
+2. Place it at **this position** in the child's entry order.
+
+```js
+// Parent
+fill: {
+ '': '#white #primary',
+ hovered: '#white #primary-text',
+ pressed: '#white #primary',
+ disabled: '#white #primary-disabled',
+}
+
+// Extension: add custom-state, but keep disabled as highest priority
+fill: {
+ 'custom-state': '#custom',
+ disabled: '@inherit',
+}
+
+// Result
+fill: {
+ '': '#white #primary',
+ hovered: '#white #primary-text',
+ pressed: '#white #primary',
+ 'custom-state': '#custom',
+ disabled: '#white #primary-disabled', // repositioned to end
+}
+```
+
+**In replace mode**, `@inherit` cherry-picks a parent state into the child's result at the declared position.
+
+### Edge cases
+
+- `'@inherit'` for a key that doesn't exist in the parent — dev-mode warning, entry is skipped.
+- `'@inherit'` with no parent value at all — silently stripped.
+
+---
+
+## `null` — Reset to Recipe Layer
+
+### Problem it solves
+
+When extending a component, sometimes you want to discard the parent component's value for a property and fall back to whatever the recipe provides. Currently this is impossible — the parent's value always wins.
+
+### Simple reset
+
+Use `null` as the property value. `mergeStyles` treats `null` as an intentional unset — the parent's value is discarded. Later, `resolveRecipes` merges recipe values under component values, and since the property is absent, the recipe value fills in:
+
+```js
+const MyButton = tasty(Button, {
+ styles: {
+ fill: null, // discard Button's fill, use recipe's fill instead
+ },
+});
+```
+
+### Hard remove with `false`
+
+Use `false` to block the property entirely — the parent value is discarded AND the recipe cannot fill in:
+
+```js
+const MyButton = tasty(Button, {
+ styles: {
+ fill: false, // no fill at all, recipe is blocked too
+ },
+});
+```
+
+`false` persists through all layers as a tombstone value. When `resolveRecipes` builds `{ ...recipeValues, ...componentValues }`, `false` overwrites the recipe's value. The CSS generator then treats `false` as no-output.
+
+### Implementation in `mergeStyles`
+
+For regular properties (non-selectors), add a strict null check:
+
+```
+if value === null:
+ delete resultStyles[key] // remove from merged result
+else if value === false:
+ resultStyles[key] = false // keep as tombstone
+else:
+ existing spread behavior
+```
+
+For sub-elements (selectors), change from loose (`== null`) to strict equality:
+
+```
+if value === false OR value === null:
+ delete resultStyles[key] // remove sub-element
+else if value === undefined:
+ resultStyles[key] = parentStyles[key] // keep parent's value
+else:
+ deep merge (with state map / @inherit / null support inside properties)
+```
+
+### Implementation in `resolveRecipes`
+
+No changes needed. The existing `{ ...recipeValues, ...componentValues }` spread naturally handles both cases:
+
+- `null` was deleted by `mergeStyles`, so the recipe value fills in.
+- `false` was kept by `mergeStyles`, so it overwrites the recipe value.
+
+---
+
+## Combined Example
+
+Wrapping `Button` to add a "loading" visual state while preserving all existing states:
+
+```js
+const LoadingButton = tasty(Button, {
+ styles: {
+ fill: {
+ loading: '#white #primary.60',
+ disabled: '@inherit', // keep disabled as highest priority
+ },
+ border: {
+ loading: '#clear',
+ },
+ cursor: {
+ loading: 'wait',
+ },
+ },
+});
+```
+
+Resetting a property to its recipe value:
+
+```js
+const SimpleButton = tasty(Button, {
+ styles: {
+ fill: null, // use recipe's fill instead of Button's complex fill
+ border: false, // no border at all, even if recipe defines one
+ },
+});
+```
+
+---
+
+## Merge Algorithm
+
+### `mergeStyles` changes
+
+For each property in `newStyles`:
+
+**Sub-element keys (selectors):**
+
+Selector keys are collected from **both** parent and child styles to ensure new sub-elements are also processed.
+
+```
+if value === false OR value === null:
+ delete resultStyles[key] // remove sub-element
+else if value === undefined:
+ resultStyles[key] = styles[key] // keep parent (unchanged from current behavior)
+else:
+ deep merge (with state map / @inherit / null support inside properties)
+```
+
+**Regular properties (non-selectors):**
+
+```
+1. if value === undefined:
+ keep parent's value (or delete if parent has none)
+2. else if value === null:
+ delete resultStyles[key] // unset, recipe fills in
+3. else if value is a state map (object, not array):
+ a. determine mode: isExtend = !( '' in childMap )
+ b. normalize parent to state map (string → { '': value })
+ c. if isExtend (no '' key):
+ - collect all parent entries
+ - for each child entry:
+ - if value is '@inherit': mark parent key for repositioning
+ - if value is null: mark parent key for removal
+ - otherwise: mark as override (if key exists in parent) or append
+ - build result:
+ i. parent entries in original order (skip removed, skip repositioned)
+ ii. apply in-place overrides at their parent positions
+ iii. append new + repositioned entries interleaved in child declaration order
+ d. if replace (has '' key):
+ - iterate child entries in order:
+ - if value is '@inherit': resolve from parent
+ - if value is null: skip
+ - otherwise: pass through
+ e. if no parent value: strip null and @inherit entries, use rest as-is
+4. otherwise: existing behavior (spread override, including false as tombstone)
+```
+
+### `resolveRecipes` changes
+
+No changes needed. The existing merge order `{ ...recipeValues, ...componentValues }` handles both cases naturally:
+
+- Properties deleted (via `null`) are absent from `componentValues`, so recipe values fill in.
+- Properties set to `false` remain in `componentValues` and overwrite recipe values.
+
+---
+
+## Performance
+
+| Path | Cost | When |
+|---|---|---|
+| No state map in any property | One `typeof` check per property (~3ns each) | Every `mergeStyles` call |
+| State map detected | One `'' in childMap` check + one pass over entries | When state map is used |
+| `@inherit` in child | One string comparison per child entry | Only when `@inherit` value is used |
+| `null` check | One `=== null` per property | Every `mergeStyles` call |
+| `false` tombstone | No extra cost (normal spread) | N/A |
+
+Hot path overhead for a 15-property style object: ~45ns (fifteen `typeof` checks + `=== null` comparisons). This is well within noise for a function that already does object spreads and key iteration.
+
+---
+
+## Type Changes
+
+### `StyleValueStateMap`
+
+`'@inherit'` is scoped to `StyleValueStateMap` only — it is not part of `StyleValue`:
+
+```ts
+export type StyleValueStateMap = {
+ [key: string]: StyleValue | '@inherit';
+};
+```
+
+### `StyleValue`
+
+No changes — `'@inherit'` is not added here. It is only valid inside state maps.
+
+```ts
+export type StyleValue = T | boolean | number | null | undefined;
+```
+
+### `Styles` index signature
+
+`null` is already allowed via `StyleValue`. `false` is already allowed via `boolean`. No changes needed to the top-level `Styles` type for the reset/tombstone behavior.
+
+### Special keys excluded from CSS generation
+
+`@inherit` values must be stripped before entering the style generation pipeline. They are consumed by `mergeStyles` during the merge phase and never reach the CSS generator.
diff --git a/src/tasty/states/index.ts b/src/tasty/states/index.ts
index 4b5dda222..56fd929ad 100644
--- a/src/tasty/states/index.ts
+++ b/src/tasty/states/index.ts
@@ -53,6 +53,9 @@ const BUILTIN_STATES = new Set([
'@keyframes',
'@properties',
'@supports',
+ // @inherit is a value (not a key), but reserved here to prevent
+ // users from accidentally defining a state named '@inherit'.
+ '@inherit',
]);
// Reserved prefixes that are built-in
@@ -65,6 +68,7 @@ const RESERVED_PREFIXES = [
'@keyframes',
'@properties',
'@supports',
+ '@inherit',
];
// Global predefined states storage
diff --git a/src/tasty/utils/merge-styles.test.ts b/src/tasty/utils/merge-styles.test.ts
new file mode 100644
index 000000000..6e7456175
--- /dev/null
+++ b/src/tasty/utils/merge-styles.test.ts
@@ -0,0 +1,603 @@
+import { Styles } from '../styles/types';
+
+import { mergeStyles } from './merge-styles';
+
+describe('mergeStyles', () => {
+ describe('basic merging (existing behavior)', () => {
+ it('should merge flat styles', () => {
+ const result = mergeStyles(
+ { fill: '#white', padding: '2x' },
+ { fill: '#blue', radius: '1r' },
+ );
+ expect(result).toEqual({
+ fill: '#blue',
+ padding: '2x',
+ radius: '1r',
+ });
+ });
+
+ it('should handle undefined/null inputs', () => {
+ expect(mergeStyles(undefined, { fill: '#blue' })).toEqual({
+ fill: '#blue',
+ });
+ expect(mergeStyles({ fill: '#blue' }, undefined)).toEqual({
+ fill: '#blue',
+ });
+ expect(mergeStyles(null, { fill: '#blue' })).toEqual({
+ fill: '#blue',
+ });
+ });
+
+ it('should merge multiple style objects', () => {
+ const result = mergeStyles(
+ { fill: '#white' },
+ { padding: '2x' },
+ { radius: '1r' },
+ );
+ expect(result).toEqual({
+ fill: '#white',
+ padding: '2x',
+ radius: '1r',
+ });
+ });
+
+ it('should deep merge sub-element styles', () => {
+ const result = mergeStyles(
+ { Title: { preset: 'h3', color: '#dark' } } as Styles,
+ { Title: { color: '#blue' } } as Styles,
+ );
+ expect(result).toEqual({
+ Title: { preset: 'h3', color: '#blue' },
+ });
+ });
+ });
+
+ describe('sub-element null/undefined/false semantics', () => {
+ it('should keep parent sub-element when child is undefined', () => {
+ const result = mergeStyles(
+ { Title: { preset: 'h3' } } as Styles,
+ { Title: undefined } as Styles,
+ );
+ expect(result).toEqual({
+ Title: { preset: 'h3' },
+ });
+ });
+
+ it('should remove sub-element when child is null (recipe fills in)', () => {
+ const result = mergeStyles(
+ { Title: { preset: 'h3' } } as Styles,
+ { Title: null } as Styles,
+ );
+ expect(result.Title).toBeUndefined();
+ expect('Title' in result).toBe(false);
+ });
+
+ it('should delete sub-element when child is false', () => {
+ const result = mergeStyles(
+ { Title: { preset: 'h3' } } as Styles,
+ { Title: false } as Styles,
+ );
+ expect(result.Title).toBeUndefined();
+ expect('Title' in result).toBe(false);
+ });
+ });
+
+ describe('regular property null/false semantics', () => {
+ it('should remove property when child is null', () => {
+ const result = mergeStyles({ fill: '#white', padding: '2x' }, {
+ fill: null,
+ } as Styles);
+ expect(result.padding).toBe('2x');
+ expect('fill' in result).toBe(false);
+ });
+
+ it('should NOT remove property when child is undefined', () => {
+ const result = mergeStyles({ fill: '#white', padding: '2x' }, {
+ fill: undefined,
+ } as Styles);
+ expect(result.fill).toBe('#white');
+ expect(result.padding).toBe('2x');
+ });
+
+ it('should keep false as tombstone value', () => {
+ const result = mergeStyles({ fill: '#white' }, { fill: false } as Styles);
+ expect(result.fill).toBe(false);
+ });
+ });
+
+ describe('extend mode — state map without default key', () => {
+ const parentStyles: Styles = {
+ fill: {
+ '': '#white #primary',
+ hovered: '#white #primary-text',
+ pressed: '#white #primary',
+ disabled: '#white #primary-disabled',
+ },
+ };
+
+ it('should preserve parent states and append new states', () => {
+ const result = mergeStyles(parentStyles, {
+ fill: {
+ 'custom-state': '#custom',
+ },
+ } as Styles);
+
+ const keys = Object.keys(result.fill as object);
+ expect(keys).toEqual([
+ '',
+ 'hovered',
+ 'pressed',
+ 'disabled',
+ 'custom-state',
+ ]);
+ expect((result.fill as any)['']).toBe('#white #primary');
+ expect((result.fill as any).hovered).toBe('#white #primary-text');
+ expect((result.fill as any).pressed).toBe('#white #primary');
+ expect((result.fill as any).disabled).toBe('#white #primary-disabled');
+ expect((result.fill as any)['custom-state']).toBe('#custom');
+ });
+
+ it('should override an existing state in place', () => {
+ const result = mergeStyles(parentStyles, {
+ fill: {
+ disabled: '#gray.20',
+ },
+ } as Styles);
+
+ const keys = Object.keys(result.fill as object);
+ expect(keys).toEqual(['', 'hovered', 'pressed', 'disabled']);
+ expect((result.fill as any).disabled).toBe('#gray.20');
+ expect((result.fill as any)['']).toBe('#white #primary');
+ });
+
+ it('should remove a state with null', () => {
+ const result = mergeStyles(parentStyles, {
+ fill: {
+ pressed: null,
+ },
+ } as Styles);
+
+ const keys = Object.keys(result.fill as object);
+ expect(keys).toEqual(['', 'hovered', 'disabled']);
+ expect((result.fill as any).pressed).toBeUndefined();
+ });
+
+ it('should normalize plain string parent to state map', () => {
+ const result = mergeStyles({ fill: '#purple' }, {
+ fill: {
+ hovered: '#blue',
+ },
+ } as Styles);
+
+ expect(result.fill).toEqual({
+ '': '#purple',
+ hovered: '#blue',
+ });
+ });
+
+ it('should handle boolean parent value', () => {
+ const result = mergeStyles(
+ { fill: true } as Styles,
+ {
+ fill: {
+ hovered: '#blue',
+ },
+ } as Styles,
+ );
+
+ expect(result.fill).toEqual({
+ '': true,
+ hovered: '#blue',
+ });
+ });
+
+ it('should handle false parent value (no parent to extend)', () => {
+ const result = mergeStyles(
+ { fill: false } as Styles,
+ {
+ fill: {
+ hovered: '#blue',
+ },
+ } as Styles,
+ );
+
+ expect(result.fill).toEqual({
+ hovered: '#blue',
+ });
+ });
+
+ it('should handle number parent value', () => {
+ const result = mergeStyles(
+ { opacity: 1 } as Styles,
+ {
+ opacity: {
+ hovered: 0.8,
+ },
+ } as Styles,
+ );
+
+ expect(result.opacity).toEqual({
+ '': 1,
+ hovered: 0.8,
+ });
+ });
+
+ it('should handle empty parent state map', () => {
+ const result = mergeStyles(
+ { fill: {} } as Styles,
+ {
+ fill: {
+ hovered: '#blue',
+ },
+ } as Styles,
+ );
+
+ expect(result.fill).toEqual({ hovered: '#blue' });
+ });
+ });
+
+ describe('replace mode — state map with default key', () => {
+ const parentStyles: Styles = {
+ fill: {
+ '': '#white #primary',
+ hovered: '#white #primary-text',
+ pressed: '#white #primary',
+ disabled: '#white #primary-disabled',
+ },
+ };
+
+ it('should replace all parent states', () => {
+ const result = mergeStyles(parentStyles, {
+ fill: {
+ '': '#red',
+ hovered: '#blue',
+ },
+ } as Styles);
+
+ const keys = Object.keys(result.fill as object);
+ expect(keys).toEqual(['', 'hovered']);
+ expect((result.fill as any)['']).toBe('#red');
+ expect((result.fill as any).hovered).toBe('#blue');
+ });
+
+ it('should cherry-pick parent states with @inherit', () => {
+ const result = mergeStyles(parentStyles, {
+ fill: {
+ '': '#red',
+ hovered: '#blue',
+ disabled: '@inherit',
+ },
+ } as Styles);
+
+ const keys = Object.keys(result.fill as object);
+ expect(keys).toEqual(['', 'hovered', 'disabled']);
+ expect((result.fill as any)['']).toBe('#red');
+ expect((result.fill as any).hovered).toBe('#blue');
+ expect((result.fill as any).disabled).toBe('#white #primary-disabled');
+ });
+
+ it('should strip null entries', () => {
+ const result = mergeStyles(parentStyles, {
+ fill: {
+ '': '#red',
+ hovered: '#blue',
+ pressed: null,
+ },
+ } as Styles);
+
+ const keys = Object.keys(result.fill as object);
+ expect(keys).toEqual(['', 'hovered']);
+ });
+
+ it('should skip @inherit for non-existent parent key', () => {
+ const result = mergeStyles(parentStyles, {
+ fill: {
+ '': '#red',
+ nonexistent: '@inherit',
+ },
+ } as Styles);
+
+ const keys = Object.keys(result.fill as object);
+ expect(keys).toEqual(['']);
+ expect((result.fill as any).nonexistent).toBeUndefined();
+ });
+
+ it('should work when parent has no value for the property', () => {
+ const result = mergeStyles({ padding: '2x' }, {
+ fill: {
+ '': '#white',
+ hovered: '#blue',
+ },
+ } as Styles);
+
+ expect(result.fill).toEqual({
+ '': '#white',
+ hovered: '#blue',
+ });
+ });
+ });
+
+ describe('@inherit — state repositioning (extend mode)', () => {
+ const parentStyles: Styles = {
+ fill: {
+ '': '#white #primary',
+ hovered: '#white #primary-text',
+ pressed: '#white #primary',
+ disabled: '#white #primary-disabled',
+ },
+ };
+
+ it('should reposition a parent state to child order', () => {
+ const result = mergeStyles(parentStyles, {
+ fill: {
+ 'custom-state': '#custom',
+ disabled: '@inherit',
+ },
+ } as Styles);
+
+ const keys = Object.keys(result.fill as object);
+ expect(keys).toEqual([
+ '',
+ 'hovered',
+ 'pressed',
+ 'custom-state',
+ 'disabled',
+ ]);
+ expect((result.fill as any).disabled).toBe('#white #primary-disabled');
+ expect((result.fill as any)['custom-state']).toBe('#custom');
+ });
+
+ it('should reposition multiple states', () => {
+ const result = mergeStyles(parentStyles, {
+ fill: {
+ 'custom-state': '#custom',
+ pressed: '@inherit',
+ disabled: '@inherit',
+ },
+ } as Styles);
+
+ const keys = Object.keys(result.fill as object);
+ expect(keys).toEqual([
+ '',
+ 'hovered',
+ 'custom-state',
+ 'pressed',
+ 'disabled',
+ ]);
+ });
+
+ it('should preserve child declaration order for interleaved new and @inherit entries', () => {
+ const result = mergeStyles(parentStyles, {
+ fill: {
+ newA: '#a',
+ pressed: '@inherit',
+ newB: '#b',
+ disabled: '@inherit',
+ },
+ } as Styles);
+
+ const keys = Object.keys(result.fill as object);
+ expect(keys).toEqual([
+ '',
+ 'hovered',
+ 'newA',
+ 'pressed',
+ 'newB',
+ 'disabled',
+ ]);
+ });
+
+ it('should skip @inherit for non-existent parent key', () => {
+ const result = mergeStyles(parentStyles, {
+ fill: {
+ nonexistent: '@inherit',
+ },
+ } as Styles);
+
+ const keys = Object.keys(result.fill as object);
+ expect(keys).toEqual(['', 'hovered', 'pressed', 'disabled']);
+ expect((result.fill as any).nonexistent).toBeUndefined();
+ });
+ });
+
+ describe('extend mode within sub-elements', () => {
+ it('should extend state maps inside sub-element blocks', () => {
+ const result = mergeStyles(
+ {
+ Icon: {
+ color: {
+ '': '#gray',
+ disabled: '#light-gray',
+ },
+ },
+ } as Styles,
+ {
+ Icon: {
+ color: {
+ loading: '#dark-gray',
+ },
+ },
+ } as Styles,
+ );
+
+ const iconStyles = result.Icon as any;
+ expect(iconStyles.color).toEqual({
+ '': '#gray',
+ disabled: '#light-gray',
+ loading: '#dark-gray',
+ });
+ });
+
+ it('should resolve @inherit inside a new sub-element (no parent property)', () => {
+ const result = mergeStyles({ fill: '#white' }, {
+ Icon: {
+ color: {
+ hovered: '#blue',
+ },
+ },
+ } as Styles);
+
+ const iconStyles = result.Icon as any;
+ expect(iconStyles.color).toEqual({ hovered: '#blue' });
+ });
+ });
+
+ describe('combined extend + @inherit + null', () => {
+ it('should handle extend + remove + reposition together', () => {
+ const parentStyles: Styles = {
+ fill: {
+ '': '#white',
+ hovered: '#blue',
+ pressed: '#green',
+ focused: '#purple',
+ disabled: '#gray',
+ },
+ };
+
+ const result = mergeStyles(parentStyles, {
+ fill: {
+ pressed: null,
+ custom: '#custom',
+ disabled: '@inherit',
+ },
+ } as Styles);
+
+ const keys = Object.keys(result.fill as object);
+ expect(keys).toEqual(['', 'hovered', 'focused', 'custom', 'disabled']);
+ expect((result.fill as any).pressed).toBeUndefined();
+ expect((result.fill as any).disabled).toBe('#gray');
+ expect((result.fill as any).custom).toBe('#custom');
+ });
+ });
+
+ describe('chaining multiple merges', () => {
+ it('should support multi-level extension', () => {
+ const base: Styles = {
+ fill: {
+ '': '#white',
+ hovered: '#blue',
+ },
+ };
+ const level1: Styles = {
+ fill: {
+ pressed: '#green',
+ },
+ } as Styles;
+ const level2: Styles = {
+ fill: {
+ disabled: '#gray',
+ },
+ } as Styles;
+
+ const result = mergeStyles(base, level1, level2);
+
+ expect(Object.keys(result.fill as object)).toEqual([
+ '',
+ 'hovered',
+ 'pressed',
+ 'disabled',
+ ]);
+ });
+
+ it('should support multi-level @inherit chaining', () => {
+ const base: Styles = {
+ fill: {
+ '': '#white',
+ hovered: '#blue',
+ disabled: '#gray',
+ },
+ };
+ const level1: Styles = {
+ fill: {
+ pressed: '#green',
+ disabled: '@inherit',
+ },
+ } as Styles;
+ const level2: Styles = {
+ fill: {
+ loading: '#yellow',
+ disabled: '@inherit',
+ },
+ } as Styles;
+
+ const result = mergeStyles(base, level1, level2);
+
+ const keys = Object.keys(result.fill as object);
+ expect(keys).toEqual(['', 'hovered', 'pressed', 'loading', 'disabled']);
+ expect((result.fill as any).disabled).toBe('#gray');
+ });
+ });
+
+ describe('extend mode with no parent value', () => {
+ it('should strip @inherit when no parent exists', () => {
+ const result = mergeStyles({ padding: '2x' }, {
+ fill: {
+ hovered: '#blue',
+ disabled: '@inherit',
+ },
+ } as Styles);
+
+ expect(result.fill).toEqual({ hovered: '#blue' });
+ expect((result.fill as any).disabled).toBeUndefined();
+ });
+
+ it('should strip null entries when no parent exists', () => {
+ const result = mergeStyles({}, {
+ fill: {
+ hovered: '#blue',
+ pressed: null,
+ },
+ } as Styles);
+
+ expect(result.fill).toEqual({ hovered: '#blue' });
+ });
+
+ it('should handle extend with all entries removed via null', () => {
+ const parentStyles: Styles = {
+ fill: {
+ '': '#white',
+ hovered: '#blue',
+ },
+ };
+
+ const result = mergeStyles(parentStyles, {
+ fill: {
+ '': null,
+ hovered: null,
+ },
+ } as Styles);
+
+ expect(result.fill).toEqual({});
+ });
+ });
+
+ describe('replace mode within sub-elements', () => {
+ it('should replace state maps inside sub-element blocks', () => {
+ const result = mergeStyles(
+ {
+ Icon: {
+ color: {
+ '': '#gray',
+ disabled: '#light-gray',
+ hovered: '#dark-gray',
+ },
+ },
+ } as Styles,
+ {
+ Icon: {
+ color: {
+ '': '#blue',
+ disabled: '@inherit',
+ },
+ },
+ } as Styles,
+ );
+
+ const iconStyles = result.Icon as any;
+ expect(iconStyles.color).toEqual({
+ '': '#blue',
+ disabled: '#light-gray',
+ });
+ });
+ });
+});
diff --git a/src/tasty/utils/merge-styles.ts b/src/tasty/utils/merge-styles.ts
index 442d2d7bd..af57a17c2 100644
--- a/src/tasty/utils/merge-styles.ts
+++ b/src/tasty/utils/merge-styles.ts
@@ -1,6 +1,183 @@
import { isSelector } from '../pipeline';
import { Styles, StylesWithoutSelectors } from '../styles/types';
+import { isDevEnv } from './is-dev-env';
+
+const devMode = isDevEnv();
+
+const INHERIT_VALUE = '@inherit';
+
+/**
+ * Check if a value is a state map (object, not array).
+ */
+function isStateMap(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+/**
+ * Normalize a parent value to a state map.
+ * - Already a state map → return as-is
+ * - Non-null, non-false primitive → wrap as `{ '': value }`
+ * - null / undefined / false → return null (no parent to merge with)
+ */
+function normalizeToStateMap(value: unknown): Record | null {
+ if (isStateMap(value)) return value as Record;
+ if (value != null && value !== false) return { '': value };
+ return null;
+}
+
+/**
+ * Resolve a child state map against a parent value.
+ *
+ * Mode is determined by whether the child contains a `''` (default) key:
+ * - No `''` → extend mode: parent entries preserved, child adds/overrides/repositions
+ * - Has `''` → replace mode: child defines everything, `@inherit` cherry-picks from parent
+ *
+ * In both modes:
+ * - `@inherit` value → resolve from parent state map
+ * - `null` value → remove this state from the result
+ * - `false` value → tombstone, persists through all layers, blocks recipe
+ */
+function resolveStateMap(
+ parentValue: unknown,
+ childMap: Record,
+): Record {
+ const isExtend = !('' in childMap);
+ const parentMap = normalizeToStateMap(parentValue);
+
+ if (!parentMap) {
+ // No parent to merge with — strip nulls and @inherit, return child entries
+ const result: Record = {};
+ for (const key of Object.keys(childMap)) {
+ const val = childMap[key];
+ if (val === null || val === INHERIT_VALUE) continue;
+ result[key] = val;
+ }
+ return result;
+ }
+
+ if (isExtend) {
+ return resolveExtendMode(parentMap, childMap);
+ }
+
+ return resolveReplaceMode(parentMap, childMap);
+}
+
+/**
+ * Extend mode: parent entries are preserved, child entries add/override/reposition.
+ */
+function resolveExtendMode(
+ parentMap: Record,
+ childMap: Record,
+): Record {
+ const inheritKeys = new Set();
+ const removeKeys = new Set();
+ const overrideKeys = new Map();
+
+ for (const key of Object.keys(childMap)) {
+ const val = childMap[key];
+ if (val === INHERIT_VALUE) {
+ if (key in parentMap) {
+ inheritKeys.add(key);
+ } else if (devMode) {
+ console.warn(
+ `[Tasty] @inherit used for state '${key}' that does not exist in the parent style map. Entry skipped.`,
+ );
+ }
+ } else if (val === null) {
+ removeKeys.add(key);
+ } else if (key in parentMap) {
+ overrideKeys.set(key, val);
+ }
+ }
+
+ // 1. Parent entries in order (skip removed, skip repositioned, apply overrides)
+ const result: Record = {};
+ for (const key of Object.keys(parentMap)) {
+ if (removeKeys.has(key)) continue;
+ if (inheritKeys.has(key)) continue;
+ if (overrideKeys.has(key)) {
+ result[key] = overrideKeys.get(key);
+ } else {
+ result[key] = parentMap[key];
+ }
+ }
+
+ // 2. Append new + repositioned entries in child declaration order
+ for (const key of Object.keys(childMap)) {
+ if (inheritKeys.has(key)) {
+ result[key] = parentMap[key];
+ } else if (
+ !removeKeys.has(key) &&
+ !overrideKeys.has(key) &&
+ // Skip @inherit for keys that weren't in the parent (already warned above)
+ childMap[key] !== INHERIT_VALUE
+ ) {
+ result[key] = childMap[key];
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Replace mode: child entries define the result, `@inherit` pulls from parent.
+ */
+function resolveReplaceMode(
+ parentMap: Record,
+ childMap: Record,
+): Record {
+ const result: Record = {};
+
+ for (const key of Object.keys(childMap)) {
+ const val = childMap[key];
+ if (val === INHERIT_VALUE) {
+ if (key in parentMap) {
+ result[key] = parentMap[key];
+ } else if (devMode) {
+ console.warn(
+ `[Tasty] @inherit used for state '${key}' that does not exist in the parent style map. Entry skipped.`,
+ );
+ }
+ } else if (val !== null) {
+ result[key] = val;
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Merge sub-element properties with state map / null / undefined support.
+ */
+function mergeSubElementStyles(
+ parentSub: StylesWithoutSelectors | undefined,
+ childSub: StylesWithoutSelectors,
+): StylesWithoutSelectors {
+ const parent = parentSub as Record | undefined;
+ const child = childSub as Record;
+ const merged: Record = { ...parent, ...child };
+
+ for (const key of Object.keys(child)) {
+ const val = child[key];
+
+ if (val === undefined) {
+ if (parent && key in parent) {
+ merged[key] = parent[key];
+ }
+ } else if (val === null) {
+ delete merged[key];
+ } else if (isStateMap(val)) {
+ merged[key] = resolveStateMap(
+ parent ? parent[key] : undefined,
+ val as Record,
+ );
+ }
+ }
+
+ return merged as StylesWithoutSelectors;
+}
+
export function mergeStyles(...objects: (Styles | undefined | null)[]): Styles {
let styles: Styles = objects[0] ? { ...objects[0] } : {};
let pos = 1;
@@ -14,18 +191,44 @@ export function mergeStyles(...objects: (Styles | undefined | null)[]): Styles {
if (newStyles) {
const resultStyles = { ...styles, ...newStyles };
- for (let key of selectorKeys) {
- if (newStyles?.[key] === false) {
- // Remove sub-element styles when explicitly set to false
+ // Collect all selector keys from both parent and child
+ const newSelectorKeys = Object.keys(newStyles).filter(isSelector);
+ const allSelectorKeys = new Set([...selectorKeys, ...newSelectorKeys]);
+
+ for (const key of allSelectorKeys) {
+ const newValue = newStyles?.[key];
+
+ if (newValue === false || newValue === null) {
delete resultStyles[key];
- } else if (newStyles?.[key] == null) {
- // Nullish values (null/undefined) are ignored - restore original styles
+ } else if (newValue === undefined) {
resultStyles[key] = styles[key];
- } else if (newStyles?.[key]) {
- resultStyles[key] = {
- ...(styles[key] as StylesWithoutSelectors),
- ...(newStyles[key] as StylesWithoutSelectors),
- };
+ } else if (newValue) {
+ resultStyles[key] = mergeSubElementStyles(
+ styles[key] as StylesWithoutSelectors,
+ newValue as StylesWithoutSelectors,
+ );
+ }
+ }
+
+ // Handle non-selector properties: state maps, null, undefined
+ for (const key of Object.keys(newStyles)) {
+ if (isSelector(key)) continue;
+
+ const newValue = newStyles[key];
+
+ if (newValue === undefined) {
+ if (key in styles) {
+ resultStyles[key] = styles[key];
+ } else {
+ delete resultStyles[key];
+ }
+ } else if (newValue === null) {
+ delete resultStyles[key];
+ } else if (isStateMap(newValue)) {
+ (resultStyles as Record)[key] = resolveStateMap(
+ styles[key],
+ newValue as Record,
+ );
}
}
diff --git a/src/tasty/utils/styles.ts b/src/tasty/utils/styles.ts
index 70c024863..3821bb17b 100644
--- a/src/tasty/utils/styles.ts
+++ b/src/tasty/utils/styles.ts
@@ -35,7 +35,7 @@ export function normalizeColorTokenValue(
}
export type StyleValueStateMap = {
- [key: string]: StyleValue;
+ [key: string]: StyleValue | '@inherit';
};
/**
diff --git a/tsconfig.json b/tsconfig.json
index 8adb750c3..56d212aa5 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -19,7 +19,8 @@
"preserveSymlinks": true,
"noImplicitAny": false,
"target": "es2022",
- "noEmit": true
+ "noEmit": true,
+ "types": ["vitest/globals"]
},
"include": ["src/**/*"]
}