@tenphi/tasty 2.0.1 → 2.0.3

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/docs/pipeline.md CHANGED
@@ -6,56 +6,109 @@ This document describes the style rendering pipeline that transforms style objec
6
6
 
7
7
  ## Overview
8
8
 
9
- The pipeline takes a `Styles` object and produces an array of `CSSRule` objects ready for injection into the DOM. Entry points include `renderStylesPipeline` (full pipeline + optional class-name prefixing) and `renderStyles` (direct selector/class mode). The per-handler flow has seven main stages:
9
+ The pipeline takes a `Styles` object and produces an array of `CSSRule` objects ready for injection into the DOM. Entry points include `renderStylesPipeline` (full pipeline + optional class-name prefixing) and `renderStyles` (direct selector/class mode). The per-handler flow is:
10
10
 
11
11
  ```
12
12
  Input: Styles Object
13
13
 
14
- ┌─────────────────────────────────────┐
15
- 1. PARSE CONDITIONS
16
- parseStyleEntries + parseStateKey
17
- └─────────────────────────────────────┘
14
+ ┌──────────────────────────────────────────┐
15
+ 0. PRE-PARSE NORMALIZATION
16
+ extractCompoundStates
17
+ │ (drop don't-care AND atoms) │
18
+ └──────────────────────────────────────────┘
18
19
 
19
- ┌─────────────────────────────────────┐
20
- 2. BUILD EXCLUSIVE CONDITIONS
21
- Negate higher-priority entries
22
- └─────────────────────────────────────┘
20
+ ┌──────────────────────────────────────────┐
21
+ 1. PARSE CONDITIONS
22
+ parseStyleEntries + parseStateKey
23
+ └──────────────────────────────────────────┘
23
24
 
24
- ┌─────────────────────────────────────┐
25
- 3. EXPAND AT-RULE OR BRANCHES
26
- expandExclusiveOrs (when needed)
27
- └─────────────────────────────────────┘
25
+ ┌──────────────────────────────────────────┐
26
+ 1b. MERGE ENTRIES BY VALUE
27
+ mergeEntriesByValue
28
+ │ (collapse same-value non-defaults) │
29
+ └──────────────────────────────────────────┘
28
30
 
29
- ┌─────────────────────────────────────┐
30
- 4. COMPUTE STATE COMBINATIONS
31
- Cartesian product across styles
32
- └─────────────────────────────────────┘
31
+ ┌──────────────────────────────────────────┐
32
+ 2a. EXPAND USER OR BRANCHES
33
+ expandOrConditions
34
+ │ (A | B | C → A, B&!A, C&!A&!B) │
35
+ └──────────────────────────────────────────┘
33
36
 
34
- ┌─────────────────────────────────────┐
35
- 5. CALL HANDLERS
36
- Compute CSS declarations
37
- └─────────────────────────────────────┘
37
+ ┌──────────────────────────────────────────┐
38
+ 2b. BUILD EXCLUSIVE CONDITIONS
39
+ Negate higher-priority entries
40
+ └──────────────────────────────────────────┘
38
41
 
39
- ┌─────────────────────────────────────┐
40
- 6. MERGE BY VALUE
41
- Combine rules with same output
42
- └─────────────────────────────────────┘
42
+ ┌──────────────────────────────────────────┐
43
+ 3. EXPAND DE MORGAN OR BRANCHES
44
+ expandExclusiveOrs
45
+ │ (only for at-rule ORs from negation) │
46
+ └──────────────────────────────────────────┘
43
47
 
44
- ┌─────────────────────────────────────┐
45
- 7. MATERIALIZE CSS
46
- Condition selectors + at-rules
47
- └─────────────────────────────────────┘
48
+ ┌──────────────────────────────────────────┐
49
+ 4. COMPUTE STATE COMBINATIONS
50
+ Cartesian product across styles
51
+ └──────────────────────────────────────────┘
48
52
 
49
- ┌─────────────────────────────────────┐
50
- runPipeline: dedupe identical rules
51
- └─────────────────────────────────────┘
53
+ ┌──────────────────────────────────────────┐
54
+ 5. CALL HANDLERS
55
+ │ Compute CSS declarations │
56
+ └──────────────────────────────────────────┘
57
+
58
+ ┌──────────────────────────────────────────┐
59
+ │ 6. MERGE BY VALUE │
60
+ │ Combine rules with same output │
61
+ └──────────────────────────────────────────┘
62
+
63
+ ┌──────────────────────────────────────────┐
64
+ │ 7. MATERIALIZE CSS │
65
+ │ Condition → selectors + at-rules │
66
+ └──────────────────────────────────────────┘
67
+
68
+ ┌──────────────────────────────────────────┐
69
+ │ runPipeline post-pass: │
70
+ │ - dedupe identical rules │
71
+ │ - emit @starting-style rules last │
72
+ └──────────────────────────────────────────┘
52
73
 
53
74
  Output: CSSRule[]
54
75
  ```
55
76
 
56
- **Simplification** (`simplifyCondition` in `simplify.ts`) is not a separate numbered stage. It runs inside exclusive building, `expandExclusiveOrs` branch cleanup, combination ANDs, merge-by-value ORs, and materialization as needed.
77
+ **Simplification** (`simplifyCondition` in `simplify.ts`) is not a separate numbered stage. It runs inside OR expansion, exclusive building, `expandExclusiveOrs` branch cleanup, combination ANDs, merge-by-value ORs, and materialization. Every call is cached by condition unique-id, so the repetition is cheap.
57
78
 
58
- **Post-pass:** After `processStyles` collects rules from every handler, `runPipeline` filters duplicates using a key of `selector|declarations|atRules|rootPrefix|startingStyle` so identical emitted rules appear once.
79
+ **Post-pass:** After `processStyles` collects rules from every handler, `runPipeline` (`index.ts:188`) filters duplicates using a key of `selector|declarations|atRules|rootPrefix|startingStyle` and then reorders rules so every `@starting-style` rule is emitted **after** all normal rules. This ordering is cascade-critical: `@starting-style` rules share specificity with their normal counterparts, and source order decides which value wins.
80
+
81
+ ---
82
+
83
+ ## Stage 0: Pre-parse Normalization
84
+
85
+ **File:** `exclusive.ts` (`extractCompoundStates`)
86
+
87
+ ### What It Does
88
+
89
+ Runs on each style's value map **before** any parsing. If a compound AND state key shares a value with the "atom absent" variant, the atom is a don't-care and every key is simplified by dropping it. Duplicate keys collapse.
90
+
91
+ ### How It Works
92
+
93
+ 1. Gather the unique set of top-level AND atoms across all keys.
94
+ 2. An atom is **redundant** when every entry that contains it has a same-value partner with the atom absent and the rest of the atoms identical.
95
+ 3. Keys containing `|`, `^`, or `,` at top level are treated as opaque single atoms (they don't participate in atom-level extraction).
96
+ 4. Drop redundant atoms from every key; collapse duplicates.
97
+
98
+ ### Why
99
+
100
+ Removing don't-care dimensions before parsing prevents combinatorial blowup in later stages. `mergeEntriesByValue`, `buildExclusiveConditions`, and materialization all see fewer entries and fewer spurious conditions. Implemented as part of the Apr 2026 fix for overlapping CSS rules (commit 7cd9dbe).
101
+
102
+ ### Example
103
+
104
+ ```typescript
105
+ // Input (value map)
106
+ { '': 'A', '@dark': 'B', '@hc': 'A', '@dark & @hc': 'B' }
107
+ // @hc is a don't-care: its presence never changes the value.
108
+
109
+ // Output
110
+ { '': 'A', '@dark': 'B' }
111
+ ```
59
112
 
60
113
  ---
61
114
 
@@ -120,7 +173,75 @@ The condition tree representation enables:
120
173
 
121
174
  ---
122
175
 
123
- ## Stage 2: Build Exclusive Conditions
176
+ ## Stage 1b: Merge Entries By Value
177
+
178
+ **File:** `exclusive.ts` (`mergeEntriesByValue`)
179
+
180
+ ### What It Does
181
+
182
+ Collapses parsed entries that share the same value. Only **non-default** entries are merged — an entry with the default state (`''` → `TrueCondition`) is never merged with a non-default entry.
183
+
184
+ ### How It Works
185
+
186
+ 1. Group entries by serialized value.
187
+ 2. Within each group, split out default (TRUE) entries.
188
+ 3. Keep default entries as-is; they must retain TRUE so they participate correctly in exclusive building.
189
+ 4. Combine non-default entries into a single entry with condition `OR(e1.condition, e2.condition, …)`, simplified via `simplifyCondition`. The merged entry keeps the **highest** priority in the group.
190
+ 5. Re-sort by priority (highest first).
191
+
192
+ ### Why
193
+
194
+ Without this, a value map like `{ '@dark': 'red', '@dark & @hc': 'red' }` would create two separate entries that later produce two CSS rules with identical output. Merging before exclusive building keeps the exclusive condition algebra small and avoids duplicate CSS.
195
+
196
+ **Why defaults are kept separate:** merging `TRUE | X` collapses to `TRUE`, destroying X's participation in the exclusive cascade. Intermediate-priority states would then lose their `:not(X)` negation, producing overlapping CSS rules. See `exclusive.ts:140-160` for the rationale.
197
+
198
+ ### Example
199
+
200
+ ```typescript
201
+ // Input entries (highest priority first)
202
+ [
203
+ { stateKey: '@dark & @hc', value: 'red', condition: dark & hc },
204
+ { stateKey: '@dark', value: 'red', condition: dark },
205
+ ]
206
+
207
+ // Output: one merged entry
208
+ [
209
+ { stateKey: '@dark & @hc | @dark', value: 'red',
210
+ condition: simplify((dark & hc) | dark) = dark }
211
+ ]
212
+ ```
213
+
214
+ ---
215
+
216
+ ## Stage 2a: Expand User OR Branches
217
+
218
+ **File:** `exclusive.ts` (`expandOrConditions`)
219
+
220
+ ### What It Does
221
+
222
+ Runs **before** `buildExclusiveConditions`. Splits any user-authored OR in a parsed entry's condition into multiple sibling entries, each made exclusive against the OR branches that come before it.
223
+
224
+ ### How It Works
225
+
226
+ For an entry with condition `A | B | C`:
227
+
228
+ ```
229
+ A (first branch, no prior)
230
+ B & !A (second branch exclusive from first)
231
+ C & !A & !B (third branch exclusive from first two)
232
+ ```
233
+
234
+ Each expanded branch gets a `stateKey` suffix like `[0]`, `[1]`, `[2]`. Branches that simplify to `FALSE` are dropped. Branches inherit the original entry's priority.
235
+
236
+ This pass does **not** sort branches — user ORs are authored in the natural order they appear and aren't the product of De Morgan negation, so at-rule-aware sorting isn't required here (that's Stage 3's job).
237
+
238
+ ### Why
239
+
240
+ Running this before exclusive building means the Stage 2b negation cascade sees one branch per entry and never has to reason about nested ORs while computing `!prior`. It also avoids emitting overlapping CSS rules: `{ 'compact | @media(dark)': 'red' }` becomes two mutually exclusive entries rather than one rule whose two branches could both match simultaneously.
241
+
242
+ ---
243
+
244
+ ## Stage 2b: Build Exclusive Conditions
124
245
 
125
246
  **File:** `exclusive.ts` (`buildExclusiveConditions`)
126
247
 
@@ -167,28 +288,41 @@ This eliminates CSS specificity wars. Instead of relying on cascade order, each
167
288
 
168
289
  ---
169
290
 
170
- ## Stage 3: Expand At-Rule OR Branches
291
+ ## Stage 3: Expand De Morgan OR Branches
171
292
 
172
- **File:** `exclusive.ts` (`expandExclusiveOrs`)
293
+ **File:** `exclusive.ts` (`expandExclusiveOrs`, `sortOrBranchesForExpansion`)
173
294
 
174
295
  ### What It Does
175
296
 
176
- Runs **after** `buildExclusiveConditions`. When an entry’s **exclusive** condition contains a top-level OR that mixes **at-rule** context (`media`, `container`, `supports`, `starting`) with other branches, those ORs are split into mutually exclusive branches so each branch keeps the correct at-rule wrapping (e.g. after De Morgan: `!(A & B)` → `!A | !B`).
297
+ Runs **after** `buildExclusiveConditions`. Handles ORs that arise **during** exclusive building from De Morgan negation — e.g. when a higher-priority condition `A & B` gets negated into the next entry's exclusive as `!(A & B) = !A | !B`. When such an OR mixes **at-rule** context (`media`, `container`, `supports`, `starting`) with other branches, each branch needs to keep its own at-rule wrapping.
298
+
299
+ This is the companion to **Stage 2a** (user-OR expansion). The split exists because the two passes have different data and different correctness needs:
300
+
301
+ | Stage | Runs on | Sees ORs from | Sorts branches? |
302
+ |---|---|---|---|
303
+ | 2a `expandOrConditions` | `ParsedStyleEntry.condition` | User-authored `|` in state keys | No — user order is stable |
304
+ | 3 `expandExclusiveOrs` | `ExclusiveStyleEntry.exclusiveCondition` | De Morgan negation inside `buildExclusiveConditions` | Yes — at-rule branches first |
177
305
 
178
306
  ### How It Works
179
307
 
180
308
  1. Collect top-level OR branches of `exclusiveCondition`.
181
- 2. If there is no OR, or **no** branch involves at-rule context, the entry is unchanged (pure selector ORs are handled later via `:is()` / variant merging in materialization).
182
- 3. Otherwise, branches are sorted with `sortOrBranchesForExpansion` so at-rule-heavy branches come first, then each branch is made exclusive against prior branches: `branch & !prior[0] & !prior[1] & ...`, then simplified.
183
- 4. Impossible branches are dropped; expanded entries get a synthetic `stateKey` suffix like `[or:0]`.
309
+ 2. If there is no OR (single branch), the entry is unchanged. Pure selector ORs with no at-rule context are also left alone (materialization handles them via `:is()` / variant merging).
310
+ 3. Otherwise `sortOrBranchesForExpansion` reorders branches so at-rule-heavy branches come first. This is load-bearing for correctness (see below).
311
+ 4. Each branch is made exclusive against prior branches: `branch & !prior[0] & !prior[1] & ...`, then simplified.
312
+ 5. Impossible branches are dropped; expanded entries get a synthetic `stateKey` suffix like `[or:0]`.
184
313
 
185
- ### Why
314
+ ### Why the sort matters
315
+
316
+ Consider `!A | !B` where A is an at-rule (e.g. `@supports(grid)`) and B is a modifier (e.g. `:has(foo)`):
186
317
 
187
- Without this pass, a condition like `!(@supports & :has)` could produce one rule missing the `@supports` wrapper. Exclusive OR expansion ensures negated at-rule groups still nest modifiers correctly.
318
+ - **With at-rule-first sort** (`!A`, then `!B & A`): the first branch emits "outside `@supports`", the second emits "inside `@supports` with `:not(:has(foo))`". Full coverage.
319
+ - **Without the sort** (`!B`, then `!A & B`): the first branch emits `:not(:has(foo))` as a bare selector with no at-rule context — leaking the rule outside `@supports`. The second is incomplete.
320
+
321
+ The pre-build Stage 2a pass doesn't need this because user-authored ORs aren't produced by negation and their branches are expected to apply in each branch's own scope.
188
322
 
189
323
  ### Example (conceptual)
190
324
 
191
- See the comment block in `exclusive.ts` (~195–206): a default value’s exclusive condition can become `!@supports | !:has`; expansion yields one branch under `@supports (not )` and another under `@supports () { :not(:has()) }` instead of a bare `:not(:has())` rule.
325
+ See the comment block in `exclusive.ts:500-523`: a default value whose higher-priority sibling is `@supports(...) & :has(...)` gets an exclusive of `!@supports | !:has`. Expansion yields one branch under `@supports (not ...)` and another under `@supports (...) { :not(:has()) }` instead of a bare `:not(:has())` rule.
192
326
 
193
327
  ---
194
328
 
@@ -402,16 +536,20 @@ Applies boolean algebra rules to reduce condition complexity and detect impossib
402
536
  - `A & (A | B) = A`
403
537
  - `A | (A & B) = A`
404
538
 
405
- 7. **Range intersection**: For **media and container** dimension queries, impossible ranges simplify to `FALSE` (e.g. `@media(w > 400px) & @media(w < 300px)`).
539
+ 7. **Range intersection**: For **media and container** dimension queries, impossible ranges simplify to `FALSE` (e.g. `@media(w > 400px) & @media(w < 300px)`). Ranges with compatible bounds are also merged in place (`w >= 400 & w <= 800` → a single bounded range).
406
540
 
407
541
  8. **Container style queries**: Conflicting or redundant `@container` style conditions on the same property can be reduced (see `simplify.ts` around the container-style conflict pass).
408
542
 
409
543
  9. **Attribute conflict detection**:
410
544
  - `[data-theme="dark"] & [data-theme="light"] = FALSE`
411
545
 
546
+ 10. **Complementary factoring** (OR context): `(A & B) | (A & !B) = A`. Also works on **compound complements** — if two AND-clauses differ only by a child that is a compound negation of the other (e.g. `X` vs `!X` where X is itself `(P & Q)`), the clauses factor correctly.
547
+
548
+ 11. **Consensus / resolution** (AND context, dual of #10): `(A | B) & (A | !B) = A`. Added in commit f9038bd to eliminate overlapping CSS selectors from compound-state OR branches.
549
+
412
550
  ### Why
413
551
 
414
- Simplification reduces CSS output size and catches impossible combinations early, preventing invalid CSS rules from being generated.
552
+ Simplification reduces CSS output size and catches impossible combinations early, preventing invalid CSS rules from being generated. Every `simplifyCondition` call is memoized by the condition's unique id, so the cost of running it many times across stages is negligible after the first hit.
415
553
 
416
554
  ---
417
555
 
@@ -452,7 +590,15 @@ const styles = {
452
590
  'hovered' → ModifierCondition(attribute: 'data-hovered')
453
591
  ```
454
592
 
455
- ### Stages 2–3: Exclusive conditions + expand OR
593
+ ### Stage 0 + 1b: Normalization
594
+
595
+ No compound AND keys, no same-value duplicates — the value map is unchanged.
596
+
597
+ ### Stage 1 + 2a: Parse and expand user ORs
598
+
599
+ No user ORs — three entries pass through unchanged.
600
+
601
+ ### Stage 2b + 3: Exclusive conditions + De Morgan expansion
456
602
 
457
603
  Processing order (highest priority first): `hovered`, `@media(dark)`, default.
458
604
 
@@ -462,7 +608,7 @@ hovered: [data-hovered]
462
608
  !hovered & !@media(dark): :not([data-hovered]) & not @media(dark)
463
609
  ```
464
610
 
465
- No at-rule OR expansion needed on these exclusives.
611
+ The default entry's exclusive is `!hovered & !@media(dark)` — no top-level OR, so Stage 3 expansion does nothing. If a higher-priority entry had been `@media(dark) & :has(foo)`, the default's exclusive would have expanded via De Morgan into two at-rule-aware branches (see Stage 3 for that scenario).
466
612
 
467
613
  ### Stages 4–5: Compute combinations and call handler
468
614
 
@@ -506,9 +652,17 @@ Using `renderStyles(styles, '.t1')` (single class prefix; `renderStylesPipeline`
506
652
 
507
653
  Rather than relying on CSS cascade rules, we generate mutually exclusive selectors. This makes styling predictable and debuggable.
508
654
 
509
- ### 2. OR Handling: DNF, `:is()`, and `expandExclusiveOrs`
655
+ ### 2. OR Handling in Three Layers
656
+
657
+ Boolean OR appears in three different shapes during the pipeline, and each is handled where it's cheapest to get right:
658
+
659
+ 1. **User-authored ORs in state keys** (Stage 2a, `expandOrConditions`): A user-authored condition like `'compact | @media(w < 768px)'` is split into multiple exclusive entries **before** exclusive building so the negation cascade doesn't have to reason about nested ORs.
660
+
661
+ 2. **De Morgan ORs from negation** (Stage 3, `expandExclusiveOrs`): When `buildExclusiveConditions` negates a higher-priority compound like `A & B`, the result is `!A | !B`. If branches involve at-rules, they're split with `sortOrBranchesForExpansion` so at-rule context is preserved per branch.
662
+
663
+ 3. **Pure selector ORs** (materialization): ORs that only mention modifiers/pseudos are kept intact until the `conditionToCSS` layer, where they're merged into `:is()` / `:not()` groups or emitted as comma-separated selectors. There's no gain from expanding these earlier — CSS already has compact syntax for selector-only disjunction.
510
664
 
511
- OR of conditions is ultimately expressed as DNF (OR of ANDs) for CSS—comma-separated selectors, multiple rules, or `:is()` / `:not()` groups. **User-authored** ORs on pure selector conditions are handled in materialization. **`expandExclusiveOrs`** is an additional, **post-exclusive** pass for ORs that appear on **exclusive** conditions and involve **at-rule** branches (often from De Morgan on `@supports` / `@media` / `@container` / `@starting`), so each branch keeps correct at-rule nesting.
665
+ Ultimately every emitted CSS rule corresponds to one conjunctive clause (DNF), produced by whichever of the three paths handled the OR.
512
666
 
513
667
  ### 3. Early Contradiction Detection
514
668
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenphi/tasty",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
4
4
  "description": "A design-system-integrated styling system and DSL for concise, state-aware UI styling",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",