@tenphi/cookbook 0.28.0 → 0.30.1
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 +13 -11
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/docs/ai-agents.md +5 -3
- package/docs/architecture.md +10 -7
- package/docs/authoring.mdx +94 -36
- package/docs/cli.md +92 -33
- package/docs/component-styles.md +286 -282
- package/docs/configuration.md +232 -153
- package/docs/content-sources.md +39 -21
- package/docs/custom-components.md +117 -169
- package/docs/customization-rules.md +23 -14
- package/docs/deployment.md +101 -53
- package/docs/es/getting-started.md +7 -5
- package/docs/es/index.md +1 -1
- package/docs/examples.md +65 -28
- package/docs/fonts-and-typography.md +32 -27
- package/docs/getting-started.md +17 -10
- package/docs/ja/getting-started.md +7 -5
- package/docs/ja/index.md +1 -1
- package/docs/migration.md +188 -65
- package/docs/plugins.md +28 -16
- package/docs/publishing.md +3 -2
- package/docs/quality-checks.md +18 -42
- package/docs/recipes.md +38 -54
- package/docs/ru/getting-started.md +7 -5
- package/docs/ru/index.md +1 -1
- package/docs/site-navigation.md +47 -47
- package/docs/theme-and-components.md +126 -59
- package/docs/troubleshooting.md +4 -3
- package/docs/upstream/manifest.json +2 -2
- package/docs/upstream/tasty/README.md +404 -381
- package/docs/upstream/tasty/docs/README.md +4 -1
- package/docs/upstream/tasty/docs/adoption.md +25 -17
- package/docs/upstream/tasty/docs/ai-agents.md +42 -34
- package/docs/upstream/tasty/docs/comparison.md +9 -8
- package/docs/upstream/tasty/docs/configuration.md +187 -69
- package/docs/upstream/tasty/docs/debug.md +3 -2
- package/docs/upstream/tasty/docs/design-system.md +117 -15
- package/docs/upstream/tasty/docs/dsl.md +211 -117
- package/docs/upstream/tasty/docs/getting-started.md +81 -24
- package/docs/upstream/tasty/docs/injector.md +41 -40
- package/docs/upstream/tasty/docs/methodology.md +46 -21
- package/docs/upstream/tasty/docs/migration-v3.md +3 -3
- package/docs/upstream/tasty/docs/pipeline.md +111 -116
- package/docs/upstream/tasty/docs/plugins.md +21 -41
- package/docs/upstream/tasty/docs/react-api.md +73 -10
- package/docs/upstream/tasty/docs/runtime-benchmarks.md +46 -46
- package/docs/upstream/tasty/docs/ssr.md +92 -66
- package/docs/upstream/tasty/docs/styles.md +37 -17
- package/docs/upstream/tasty/docs/tasty-static.md +175 -57
- package/docs/zh/getting-started.md +7 -5
- package/docs/zh/index.md +1 -1
- package/package.json +3 -3
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
This document describes the style rendering pipeline that transforms style objects into CSS rules. The pipeline ensures that each style value is applied to exactly one condition through exclusive condition building, boolean simplification, and intelligent CSS generation.
|
|
4
4
|
|
|
5
|
-
**Implementation:** [`src/pipeline/`](https://github.com/tenphi/tasty/
|
|
5
|
+
**Implementation:** [`src/pipeline/`](https://github.com/tenphi/tasty/tree/main/src/pipeline) — TypeScript file names below are relative to that directory.
|
|
6
6
|
|
|
7
7
|
## Overview
|
|
8
8
|
|
|
@@ -76,9 +76,9 @@ Output: CSSRule[]
|
|
|
76
76
|
|
|
77
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.
|
|
78
78
|
|
|
79
|
-
**Post-pass:** After `processStyles` collects rules from every handler, `runPipeline` (`index.ts`) filters duplicates using a key of `selector|declarations|atRules|rootPrefix|startingStyle`, then **stable-sorts rules by their cascade `order` hint (ascending source priority)** so lower-priority rules come first and higher-priority rules win, and finally emits every `@starting-style` rule **after** all normal rules.
|
|
79
|
+
**Post-pass:** After `processStyles` collects rules from every handler, `runPipeline` (`index.ts`) filters duplicates using a key of `selector|declarations|atRules|rootPrefix|startingStyle`, then **stable-sorts rules by their cascade `order` hint (ascending source priority)** so lower-priority rules come first and higher-priority rules win, and finally emits every `@starting-style` rule **after** all normal rules. Each Stage 4 snapshot takes the highest source priority among its entries as its `order` hint; when Stage 6 merges same-output rules, the merged rule keeps the lowest `order` of its group, so a rule carrying the `_` floor's value keeps the floor's early position (threaded `ComputedRule → CSSRule`). This ordering is cascade-critical because Stage 7 equalizes specificity with `:where()`: once every state selector carries zero specificity, source order alone decides which of two overlapping rules wins — most importantly the `_` fallback floor (low order, emitted first) versus the states layered over it, and `@starting-style` versus its normal counterpart. Mutually-exclusive rules are unaffected by ordering (they never both match), so reordering them is safe.
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
---
|
|
82
82
|
|
|
83
83
|
## Stage 0: Pre-parse Normalization
|
|
84
84
|
|
|
@@ -114,7 +114,7 @@ Removing don't-care dimensions before parsing prevents combinatorial blowup in l
|
|
|
114
114
|
{ '': 'A', '@dark': 'B' }
|
|
115
115
|
```
|
|
116
116
|
|
|
117
|
-
|
|
117
|
+
---
|
|
118
118
|
|
|
119
119
|
## Stage 1: Parse Conditions
|
|
120
120
|
|
|
@@ -129,9 +129,9 @@ Converts each state key in a style value map (like `'hovered & !disabled'`, `'@m
|
|
|
129
129
|
### How It Works
|
|
130
130
|
|
|
131
131
|
1. **Tokenization**: The state key is split into tokens using a regex pattern that recognizes:
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
132
|
+
- Operators: `&` (AND), `|` (OR), `!` (NOT), `^` (XOR)
|
|
133
|
+
- Parentheses for grouping
|
|
134
|
+
- State tokens: `@media(...)`, `@root(...)`, `@parent(...)`, `@own(...)`, `@supports(...)`, `@(...)`, `@starting`, predefined states, modifiers, pseudo-classes
|
|
135
135
|
|
|
136
136
|
2. **Recursive Descent Parsing**: Tokens are parsed with operator precedence:
|
|
137
137
|
```
|
|
@@ -139,27 +139,26 @@ Converts each state key in a style value map (like `'hovered & !disabled'`, `'@m
|
|
|
139
139
|
```
|
|
140
140
|
|
|
141
141
|
3. **State Token Interpretation**: Each state token is converted to a specific condition type:
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
142
|
+
- `hovered` → `ModifierCondition` with `attribute: 'data-hovered'`
|
|
143
|
+
- `theme=dark` → `ModifierCondition` with `attribute: 'data-theme', value: 'dark'`
|
|
144
|
+
- `:hover` → `PseudoCondition`
|
|
145
|
+
- `@media(w < 768px)` → `MediaCondition` (`subtype: 'dimension'`) with bounds
|
|
146
|
+
- `@media(prefers-color-scheme: dark)` → `MediaCondition` (`subtype: 'feature'`, `feature` + `featureValue`)
|
|
147
|
+
- `@root(schema=dark)` → `RootCondition` wrapping the inner condition
|
|
148
|
+
- `@parent(hovered)` → `ParentCondition` (optional `direct` for immediate parent)
|
|
149
|
+
- `@own(hovered)` → `OwnCondition` wrapping the parsed inner condition
|
|
150
|
+
- `@supports(display: grid)` → `SupportsCondition`
|
|
151
|
+
- `@(w < 600px)` → `ContainerCondition` (dimension, style, or raw subtypes)
|
|
152
|
+
- `@mobile` → Resolved via predefined states, then parsed recursively
|
|
153
153
|
|
|
154
154
|
Pipeline warnings for invalid inputs (e.g. bad `$` selector affix) are emitted from `warnings.ts`.
|
|
155
155
|
|
|
156
156
|
### Why
|
|
157
157
|
|
|
158
158
|
The condition tree representation enables:
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
* Flexible CSS generation (different output for media vs. selectors)
|
|
159
|
+
- Boolean algebra operations (simplification, negation)
|
|
160
|
+
- Semantic analysis (detect contradictions)
|
|
161
|
+
- Flexible CSS generation (different output for media vs. selectors)
|
|
163
162
|
|
|
164
163
|
### Example
|
|
165
164
|
|
|
@@ -178,7 +177,7 @@ The condition tree representation enables:
|
|
|
178
177
|
}
|
|
179
178
|
```
|
|
180
179
|
|
|
181
|
-
|
|
180
|
+
---
|
|
182
181
|
|
|
183
182
|
## Stage 1b: Merge Entries By Value
|
|
184
183
|
|
|
@@ -248,7 +247,7 @@ The compound dark/HC dedup pattern `{ '': light, '@dark': dark, '@hc': hc, '@dar
|
|
|
248
247
|
// correctly resolved.
|
|
249
248
|
```
|
|
250
249
|
|
|
251
|
-
|
|
250
|
+
---
|
|
252
251
|
|
|
253
252
|
## Stage 2a: Expand User OR Branches
|
|
254
253
|
|
|
@@ -276,7 +275,7 @@ This pass does **not** sort branches — user ORs are authored in the natural or
|
|
|
276
275
|
|
|
277
276
|
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.
|
|
278
277
|
|
|
279
|
-
|
|
278
|
+
---
|
|
280
279
|
|
|
281
280
|
## Stage 2b: Build Exclusive Conditions
|
|
282
281
|
|
|
@@ -289,7 +288,6 @@ Ensures each style entry applies in exactly one scenario by ANDing each conditio
|
|
|
289
288
|
### How It Works
|
|
290
289
|
|
|
291
290
|
Given entries ordered by priority (highest first):
|
|
292
|
-
|
|
293
291
|
```
|
|
294
292
|
A: value1 (priority 2)
|
|
295
293
|
B: value2 (priority 1)
|
|
@@ -297,7 +295,6 @@ C: value3 (priority 0)
|
|
|
297
295
|
```
|
|
298
296
|
|
|
299
297
|
Produces:
|
|
300
|
-
|
|
301
298
|
```
|
|
302
299
|
A: A (highest priority, no negation needed)
|
|
303
300
|
B: B & !A (applies only when A doesn't)
|
|
@@ -337,10 +334,9 @@ being negated, swaps out cleanly. See the DSL guide's "`_` vs the `''` default".
|
|
|
337
334
|
### Why
|
|
338
335
|
|
|
339
336
|
This eliminates CSS specificity wars. Instead of relying on cascade order, each CSS rule matches in exactly one scenario. Benefits:
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
* Easier debugging (each rule is mutually exclusive)
|
|
337
|
+
- Predictable styling regardless of rule order
|
|
338
|
+
- No conflicts from overlapping conditions
|
|
339
|
+
- Easier debugging (each rule is mutually exclusive)
|
|
344
340
|
|
|
345
341
|
### Example
|
|
346
342
|
|
|
@@ -354,7 +350,7 @@ This eliminates CSS specificity wars. Instead of relying on cascade order, each
|
|
|
354
350
|
// !compact & !@media(w < 768px): default, applies when neither
|
|
355
351
|
```
|
|
356
352
|
|
|
357
|
-
|
|
353
|
+
---
|
|
358
354
|
|
|
359
355
|
## Stage 3: Expand De Morgan OR Branches
|
|
360
356
|
|
|
@@ -366,10 +362,10 @@ Runs **after** `buildExclusiveConditions`. Handles ORs that arise **during** exc
|
|
|
366
362
|
|
|
367
363
|
This is the companion to **Stage 2a** (user-OR expansion). The split exists because the two passes have different data and different correctness needs:
|
|
368
364
|
|
|
369
|
-
| Stage
|
|
370
|
-
|
|
371
|
-
| 2a `expandOrConditions` | `ParsedStyleEntry.condition`
|
|
372
|
-
| 3 `expandExclusiveOrs`
|
|
365
|
+
| Stage | Runs on | Sees ORs from | Sorts branches? |
|
|
366
|
+
|---|---|---|---|
|
|
367
|
+
| 2a `expandOrConditions` | `ParsedStyleEntry.condition` | User-authored `\|` in state keys | No — user order is stable |
|
|
368
|
+
| 3 `expandExclusiveOrs` | `ExclusiveStyleEntry.exclusiveCondition` | De Morgan negation inside `buildExclusiveConditions` | Yes — at-rule branches first |
|
|
373
369
|
|
|
374
370
|
### How It Works
|
|
375
371
|
|
|
@@ -383,13 +379,13 @@ This is the companion to **Stage 2a** (user-OR expansion). The split exists beca
|
|
|
383
379
|
|
|
384
380
|
**At-rules before selectors.** Consider `!A | !B` where A is an at-rule (e.g. `@supports(grid)`) and B is a modifier (e.g. `:has(foo)`):
|
|
385
381
|
|
|
386
|
-
|
|
387
|
-
|
|
382
|
+
- **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.
|
|
383
|
+
- **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.
|
|
388
384
|
|
|
389
385
|
**`@supports` before other at-rules (Kleene logic).** A `@supports` feature query is special: anything ANDed with it can become *unknown* — not simply false — when the feature is unsupported. The canonical case is a feature guard over a dependent query, e.g. `@supports(container-type: scroll-state) & @(scroll-state(scrolled: block-end))` (`S & C`). The default's exclusive is `!(S & C) = !S | !C`, which `simplify` sorts alphabetically into `[!C, !S]`.
|
|
390
386
|
|
|
391
|
-
|
|
392
|
-
|
|
387
|
+
- **Expanding `!C` first** emits a bare `@container (not scroll-state(...))` rule. In a browser without `scroll-state` support that query is unknown (not false), so it matches nothing and the default `inset` is never applied — a coverage hole.
|
|
388
|
+
- **Expanding `!S` first** yields `!S | (S & !C)`: a bare `@supports (not (container-type: scroll-state))` fallback carrying the default, plus the dependent negation nested inside the supported scope. Full coverage, and the unsupported path never references the guarded query.
|
|
393
389
|
|
|
394
390
|
Because `!S | (S & !R) ≡ !S | !R` in two-valued logic, this reordering is a no-op for cases without an `@supports` guard. The same supports-first ordering is also applied in `makeOrBranchesExclusive` (`materialize.ts`) for ORs that survive nested inside an AND (e.g. `compact & (!S | !C)`).
|
|
395
391
|
|
|
@@ -399,7 +395,7 @@ The pre-build Stage 2a pass doesn't need this because user-authored ORs aren't p
|
|
|
399
395
|
|
|
400
396
|
See the comment block on `expandExclusiveOrs` in `exclusive.ts`: 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.
|
|
401
397
|
|
|
402
|
-
|
|
398
|
+
---
|
|
403
399
|
|
|
404
400
|
## Stage 4: Compute State Combinations
|
|
405
401
|
|
|
@@ -416,10 +412,10 @@ When every input is a direct value rather than a state map, the pipeline skips s
|
|
|
416
412
|
1. Collect exclusive entries for each style the handler uses
|
|
417
413
|
2. Compute Cartesian product: every combination of entries
|
|
418
414
|
3. For each combination:
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
415
|
+
- AND all `exclusiveCondition` values together
|
|
416
|
+
- `simplifyCondition` the result
|
|
417
|
+
- Skip if simplified to `FALSE`
|
|
418
|
+
- Record the values for each style
|
|
423
419
|
|
|
424
420
|
### Why
|
|
425
421
|
|
|
@@ -439,7 +435,7 @@ Style handlers often depend on multiple style properties (e.g., `padding` might
|
|
|
439
435
|
// { padding: '1x', size: 'small', condition: B & D }
|
|
440
436
|
```
|
|
441
437
|
|
|
442
|
-
|
|
438
|
+
---
|
|
443
439
|
|
|
444
440
|
## Stage 5: Call Handlers
|
|
445
441
|
|
|
@@ -452,16 +448,16 @@ Invokes style handlers with computed value snapshots to produce CSS declarations
|
|
|
452
448
|
### How It Works
|
|
453
449
|
|
|
454
450
|
1. For each state snapshot (condition + values):
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
451
|
+
- Call the handler with the values
|
|
452
|
+
- Handler returns CSS properties (e.g., `{ 'padding-top': '16px', 'padding-bottom': '16px' }`)
|
|
453
|
+
- Handler may also return `$` (selector suffix) for pseudo-elements
|
|
458
454
|
2. Create computed rules with the condition, declarations, and selector suffix
|
|
459
455
|
|
|
460
456
|
### Why
|
|
461
457
|
|
|
462
458
|
Style handlers encapsulate the logic for translating design tokens (like `'2x'`) to actual CSS values (like `'16px'`). They can also handle complex multi-property styles (e.g., `padding` → `padding-top`, `padding-right`, etc.).
|
|
463
459
|
|
|
464
|
-
|
|
460
|
+
---
|
|
465
461
|
|
|
466
462
|
## Stage 6: Merge By Value
|
|
467
463
|
|
|
@@ -476,8 +472,8 @@ Zero- and one-rule inputs return unchanged because no merge is possible.
|
|
|
476
472
|
|
|
477
473
|
1. Group rules by `selectorSuffix` plus a stable string for declarations (JSON via an internal `declStringCache` `WeakMap` on declaration objects)
|
|
478
474
|
2. For rules in the same group:
|
|
479
|
-
|
|
480
|
-
|
|
475
|
+
- Merge their conditions with OR
|
|
476
|
+
- `simplifyCondition` the resulting condition
|
|
481
477
|
3. Output one rule per group
|
|
482
478
|
|
|
483
479
|
### Why
|
|
@@ -495,7 +491,7 @@ Different state combinations might produce the same CSS output. Rather than emit
|
|
|
495
491
|
// condition: A | B → { color: 'red' }
|
|
496
492
|
```
|
|
497
493
|
|
|
498
|
-
|
|
494
|
+
---
|
|
499
495
|
|
|
500
496
|
## Stage 7: Materialize CSS
|
|
501
497
|
|
|
@@ -508,15 +504,15 @@ Converts condition trees into actual CSS selectors and at-rules.
|
|
|
508
504
|
### How It Works
|
|
509
505
|
|
|
510
506
|
1. **Condition to CSS components** (`conditionToCSS`): Walk the condition tree and build `SelectorVariant` data:
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
507
|
+
- `ModifierCondition` → attribute selectors (e.g. `[data-hovered]`); optional `operator` (`=`, `^=`, `$=`, `*=`)
|
|
508
|
+
- `PseudoCondition` → pseudo-class (e.g. `:hover`)
|
|
509
|
+
- `MediaCondition` → `@media` (dimension, feature, or type)
|
|
510
|
+
- `ContainerCondition` → `@container` (dimension, style query, or raw)
|
|
511
|
+
- `RootCondition` → `rootGroups` / root prefix fragments
|
|
512
|
+
- `ParentCondition` → `parentGroups` / ancestor selectors (`direct` → child combinator path)
|
|
513
|
+
- `OwnCondition` → `ownGroups` on the **styled** element (sub-element / `&` scope), optimized with `optimizeGroups`
|
|
514
|
+
- `SupportsCondition` → `@supports` at-rules
|
|
515
|
+
- `StartingCondition` → `@starting-style` wrapper
|
|
520
516
|
|
|
521
517
|
2. **AND / OR on variants**: AND merges variant dimensions; OR yields multiple variants (later merged into `:is()` / `:not()` groups where appropriate).
|
|
522
518
|
|
|
@@ -525,23 +521,21 @@ Converts condition trees into actual CSS selectors and at-rules.
|
|
|
525
521
|
4. **`materializeComputedRule`**: Groups variants by sorted at-rules plus root-prefix key; within each group, `mergeVariantsIntoSelectorGroups` merges variants that differ only in flat modifier/pseudo parts; builds selector strings and emits one or more `CSSRule` objects. The rule's `order` hint (cascade priority) is propagated here for the post-pass sort.
|
|
526
522
|
|
|
527
523
|
5. **`:where()` specificity equalization**: Every stateful part of the selector is wrapped in `:where(...)` so it contributes **zero specificity**:
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
* the `@root` context prefix (`rootGroupsToCSS`) becomes `:where(:root...)` (zeroing the `:root` pseudo too).
|
|
524
|
+
- the flat root modifiers + pseudos and the root element's `:is()`/`:not()` groups are combined into one `:where(...)` (`buildSelectorFromVariant`);
|
|
525
|
+
- the sub-element (`@own`) groups are wrapped in a **separate** `:where(...)` so the sub-element's structural `[data-element="X"]` specificity is preserved while its states are zeroed. When the suffix ends in a pseudo-element, these are the pseudo-element's own states and stay after it (`a::before:where(:hover)`). States a pseudo-element can't have (attributes, modifiers, classes, tree-structural pseudo-classes) are dropped with a `PSEUDO_ELEMENT_STATE_IGNORED` warning before Stage 0, using the CSS language alone, never per-browser support (`pseudo-element-states.ts`). A direct selector that ends in a pseudo-element (`.card::before`) is rendered separately: its root states are checked the same way and go after the pseudo-element, its `@parent` segments before it, at the `pseudoElementAt` each `CSSRule` records;
|
|
526
|
+
- `@parent` ancestors (`parentGroupsToCSS`) become `:where(... *)` / `:where(:not(... *))`;
|
|
527
|
+
- the `@root` context prefix (`rootStateToPrefix`) becomes `:where(:root...)` (zeroing the `:root` pseudo too). When a direct selector can only match the root itself (`:root`, `html`, `:where(:root)`), the state segment joins it instead — `:root:where([data-schema="dark"])` — since an ancestor prefix would ask for a `:root` inside the root.
|
|
533
528
|
|
|
534
529
|
The only specificity anchors that remain are the doubled component class (`.tXX.tXX`, added by the injector) and sub-element `[data-element]` attributes. Because Stage 2b already makes ordinary rules mutually exclusive, zeroing specificity never changes *which* rule matches — it only makes additive layering (`_` fallback floor, `@starting-style`) resolve deterministically by source order (see the post-pass). Canonical sorting/dedup/subsumption still run first; `:where()` is only the final wrapper, and both output paths (injector and direct-selector / SSR) consume the same wrapped fragments.
|
|
535
530
|
|
|
536
531
|
### Why
|
|
537
532
|
|
|
538
533
|
CSS has different mechanisms for different condition types:
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
* Supports → `@supports` blocks
|
|
534
|
+
- Modifiers → attribute selectors
|
|
535
|
+
- Media queries → `@media` blocks
|
|
536
|
+
- Container queries → `@container` blocks
|
|
537
|
+
- Root state → `:root` / root groups
|
|
538
|
+
- Supports → `@supports` blocks
|
|
545
539
|
|
|
546
540
|
The materialization layer handles these differences while maintaining the logical semantics of the condition tree.
|
|
547
541
|
|
|
@@ -553,14 +547,15 @@ interface CSSRule {
|
|
|
553
547
|
declarations: string; // CSS declarations (e.g. 'color: red;')
|
|
554
548
|
atRules?: string[]; // Wrapping at-rules
|
|
555
549
|
rootPrefix?: string; // Root state prefix
|
|
550
|
+
rootState?: string; // Root-state segment without its :root anchor; lets a root selector take states directly
|
|
556
551
|
startingStyle?: boolean; // Wrap declarations in @starting-style
|
|
557
552
|
order?: number; // Internal cascade order hint (ascending source priority); stripped before injection
|
|
558
553
|
}
|
|
559
554
|
```
|
|
560
555
|
|
|
561
|
-
When `renderStyles` runs **without** a selector, returned rules include `needsClassName: true` (compatibility field for the injector); that flag is not part of `CSSRule`
|
|
556
|
+
When `renderStyles` runs **without** a selector, returned rules include `needsClassName: true` (compatibility field for the injector); that flag is not part of `CSSRule` (defined in `materialize-types.ts`).
|
|
562
557
|
|
|
563
|
-
|
|
558
|
+
---
|
|
564
559
|
|
|
565
560
|
## Condition Types
|
|
566
561
|
|
|
@@ -587,12 +582,12 @@ ConditionNode
|
|
|
587
582
|
|
|
588
583
|
### Key Operations
|
|
589
584
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
585
|
+
- `and(...conditions)`: Create AND with short-circuit and flattening
|
|
586
|
+
- `or(...conditions)`: Create OR with short-circuit and flattening
|
|
587
|
+
- `not(condition)`: Negate with De Morgan's law support
|
|
588
|
+
- `getConditionUniqueId(condition)`: Get canonical ID for comparison
|
|
594
589
|
|
|
595
|
-
|
|
590
|
+
---
|
|
596
591
|
|
|
597
592
|
## Simplification
|
|
598
593
|
|
|
@@ -605,33 +600,33 @@ Applies boolean algebra rules to reduce condition complexity and detect impossib
|
|
|
605
600
|
### Rules Applied
|
|
606
601
|
|
|
607
602
|
1. **Identity Laws**:
|
|
608
|
-
|
|
609
|
-
|
|
603
|
+
- `A & TRUE = A`
|
|
604
|
+
- `A | FALSE = A`
|
|
610
605
|
|
|
611
606
|
2. **Annihilator Laws**:
|
|
612
|
-
|
|
613
|
-
|
|
607
|
+
- `A & FALSE = FALSE`
|
|
608
|
+
- `A | TRUE = TRUE`
|
|
614
609
|
|
|
615
610
|
3. **Contradiction Detection**:
|
|
616
|
-
|
|
611
|
+
- `A & !A = FALSE`
|
|
617
612
|
|
|
618
613
|
4. **Tautology Detection**:
|
|
619
|
-
|
|
614
|
+
- `A | !A = TRUE`
|
|
620
615
|
|
|
621
616
|
5. **Idempotent Laws** (via deduplication):
|
|
622
|
-
|
|
623
|
-
|
|
617
|
+
- `A & A = A`
|
|
618
|
+
- `A | A = A`
|
|
624
619
|
|
|
625
620
|
6. **Absorption Laws**:
|
|
626
|
-
|
|
627
|
-
|
|
621
|
+
- `A & (A | B) = A`
|
|
622
|
+
- `A | (A & B) = A`
|
|
628
623
|
|
|
629
|
-
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).
|
|
624
|
+
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). Only bounds in the same unit are compared: `600px` and `40em` are never merged or found contradictory, because a query `em` follows the user's default font size. A bound with no numeric value (`calc(…)`) and a range whose two bounds use different units are left as they are.
|
|
630
625
|
|
|
631
626
|
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).
|
|
632
627
|
|
|
633
628
|
9. **Attribute conflict detection**:
|
|
634
|
-
|
|
629
|
+
- `[data-theme="dark"] & [data-theme="light"] = FALSE`
|
|
635
630
|
|
|
636
631
|
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.
|
|
637
632
|
|
|
@@ -641,22 +636,22 @@ Applies boolean algebra rules to reduce condition complexity and detect impossib
|
|
|
641
636
|
|
|
642
637
|
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.
|
|
643
638
|
|
|
644
|
-
|
|
639
|
+
---
|
|
645
640
|
|
|
646
641
|
## Caching Strategy
|
|
647
642
|
|
|
648
643
|
LRU and small auxiliary caches:
|
|
649
644
|
|
|
650
|
-
| Cache
|
|
651
|
-
|
|
652
|
-
| `pipelineCache`
|
|
653
|
-
| `parseCache`
|
|
654
|
-
| `simplifyCache`
|
|
655
|
-
| `conditionCache`
|
|
656
|
-
| `variantKeyCache` | —
|
|
657
|
-
| `declStringCache` | —
|
|
645
|
+
| Cache | Size | Key | Purpose |
|
|
646
|
+
|-------|------|-----|---------|
|
|
647
|
+
| `pipelineCache` | 5000 | `pipelineCacheKey \|\| stringifyStyles(styles)` | Skip full pipeline for identical styles |
|
|
648
|
+
| `parseCache` | 5000 | `trimmedStateKey + '\0' + (isSubElement ? '1' : '0') + '\0' + (localPredefinedStates non-empty ? JSON.stringify(localPredefinedStates) : '')` | Skip re-parsing identical state keys in context |
|
|
649
|
+
| `simplifyCache` | 5000 | `getConditionUniqueId(node)` | Skip re-simplifying identical conditions |
|
|
650
|
+
| `conditionCache` | 3000 | `getConditionUniqueId(node)` in `conditionToCSS` | Skip re-materializing identical conditions |
|
|
651
|
+
| `variantKeyCache` | — | `WeakMap<SelectorVariant, string>` | Stable string keys for variants during materialization |
|
|
652
|
+
| `declStringCache` | — | `WeakMap<Record<string,string>, string>` | Stable JSON keys for declaration objects in `mergeByValue` |
|
|
658
653
|
|
|
659
|
-
|
|
654
|
+
---
|
|
660
655
|
|
|
661
656
|
## Example Walkthrough
|
|
662
657
|
|
|
@@ -710,28 +705,28 @@ Each snapshot yields distinct declarations; no merge.
|
|
|
710
705
|
|
|
711
706
|
### Stage 7: Materialize CSS
|
|
712
707
|
|
|
713
|
-
Using `renderStyles(styles, '.t1')
|
|
708
|
+
Using `renderStyles(styles, '.t1', { doubleSelector: true })`. The injector doubles the class the same way (`.t1.t1`) so Tasty owns specificity without `!important`, and every state is wrapped in `:where()` so it adds none. Rules come out in ascending priority:
|
|
714
709
|
|
|
715
710
|
```css
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
711
|
+
@media (not (prefers-color-scheme: dark)) {
|
|
712
|
+
.t1.t1:where(:not([data-hovered])) {
|
|
713
|
+
color: var(--white-color);
|
|
714
|
+
--current-color: var(--white-color);
|
|
715
|
+
}
|
|
719
716
|
}
|
|
720
717
|
@media (prefers-color-scheme: dark) {
|
|
721
|
-
.t1:not([data-hovered]) {
|
|
718
|
+
.t1.t1:where(:not([data-hovered])) {
|
|
722
719
|
color: var(--dark-color);
|
|
723
720
|
--current-color: var(--dark-color);
|
|
724
721
|
}
|
|
725
722
|
}
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
--current-color: var(--white-color);
|
|
730
|
-
}
|
|
723
|
+
.t1.t1:where([data-hovered]) {
|
|
724
|
+
color: var(--highlight-color);
|
|
725
|
+
--current-color: var(--highlight-color);
|
|
731
726
|
}
|
|
732
727
|
```
|
|
733
728
|
|
|
734
|
-
|
|
729
|
+
---
|
|
735
730
|
|
|
736
731
|
## Key Design Decisions
|
|
737
732
|
|
|
@@ -761,13 +756,13 @@ Parse, simplify, condition-to-CSS, and full-pipeline results are cached independ
|
|
|
761
756
|
|
|
762
757
|
Cache keys must respect key order where it carries meaning. A state map resolves by key order, so `{ '': A, checked: B, disabled: C }` and `{ '': A, disabled: C, checked: B }` are different styles and must get different keys (and classes). Only the style keys of a styles object, including a sub-element's, are order-insensitive, so they may be sorted. `stringifyStyles` (pipeline cache) and `generateChunkCacheKey` (class allocation) both follow this rule.
|
|
763
758
|
|
|
764
|
-
|
|
759
|
+
---
|
|
765
760
|
|
|
766
761
|
## Testing Priority
|
|
767
762
|
|
|
768
763
|
Every stage above is an optimization that must leave one thing unchanged: in any state, the value that applies is the value of the last authored key whose condition holds. `src/test/state-oracle.ts` states that rule directly, with none of the pipeline's machinery, and two suites compare the pipeline against it:
|
|
769
764
|
|
|
770
|
-
|
|
771
|
-
|
|
765
|
+
- `src/pipeline/exclusive.test.ts` (Node) evaluates the conditions after each of Stages 0–3 on thousands of generated maps, and names the stage that disagrees.
|
|
766
|
+
- `src/pipeline/state-priority.test.ts` (browser) renders hand-written, permuted, and generated maps to CSS, applies every combination of states (modifiers, value modifiers, `:has()`, `@root`, `@parent`, `@own`, media, supports), and compares what the browser computes against the oracle. It also checks that rules stay mutually exclusive, which a correct computed value alone can hide. It runs through the pipeline, the runtime injector, and the SSR collector, with all maps mounted at once so a cache-key collision shows up.
|
|
772
767
|
|
|
773
768
|
A change to any stage should keep both green. When a generated map fails, the message includes the map, the failing states, and the CSS; the map is usually worth adding to the hand-written cases once it is reduced.
|
|
@@ -37,7 +37,7 @@ configure({ plugins: [spacingPlugin()] });
|
|
|
37
37
|
|
|
38
38
|
**Configuration is locked after the first render.** `configure()` warns and does nothing once any style has been generated, so it must run before your first component renders — at module scope in your entry file, or in a design-system module every component imports. `resetConfig()` reopens it, but it exists for tests.
|
|
39
39
|
|
|
40
|
-
**Repeated `configure()` calls merge
|
|
40
|
+
**Repeated `configure()` calls merge.** Each call adds to what earlier calls registered, key by key, in every registry (`states`, `units`, `functions`, `handlers`, `propHandlers`, `baseStyleProps`, `tokens`, `replaceTokens`, `recipes`, `keyframes`, `properties`, `fontFaces`, `counterStyles`); `globalStyles` merges per selector. A later value for the same key wins.
|
|
41
41
|
|
|
42
42
|
---
|
|
43
43
|
|
|
@@ -69,7 +69,7 @@ Two distinctions do most of the work:
|
|
|
69
69
|
A handler receives a map of the style properties it declared and returns CSS declarations. See [Configuration → Custom Style Handlers](configuration.md#custom-style-handlers) for the definition forms, return shape, the shared-handler groups you must be careful of, and chunk membership. The essentials:
|
|
70
70
|
|
|
71
71
|
- Values arrive **state-resolved but unparsed** — the raw authored DSL string (`'2x'`, `'#purple.5'`, `true`, `4`). Call the exported `parseStyle()` / `parseColor()` yourself.
|
|
72
|
-
- Return a `CSSMap` with **kebab-case** keys, an array of them, or nothing. `$` is a selector suffix, not a property.
|
|
72
|
+
- Return a `CSSMap` with **kebab-case** keys, an array of them, or nothing. `$` is a selector suffix, not a property. A property whose value is an array gets one declaration per entry, for fallback chains.
|
|
73
73
|
- Handlers must be pure and synchronous. They are called once per state combination and the results are cached.
|
|
74
74
|
- Use `defineHandler(deps, fn)` for multi-dependency handlers so the dependency types are inferred.
|
|
75
75
|
|
|
@@ -108,6 +108,7 @@ configure({
|
|
|
108
108
|
|
|
109
109
|
```ts
|
|
110
110
|
import { configure, mergeStyles } from '@tenphi/tasty';
|
|
111
|
+
import type { Styles } from '@tenphi/tasty';
|
|
111
112
|
|
|
112
113
|
configure({
|
|
113
114
|
propHandlers: {
|
|
@@ -115,7 +116,11 @@ configure({
|
|
|
115
116
|
const { glaze, ...rest } = props;
|
|
116
117
|
if (!glaze) return rest;
|
|
117
118
|
|
|
118
|
-
|
|
119
|
+
// glazeStyles: see the worked example below
|
|
120
|
+
return {
|
|
121
|
+
...rest,
|
|
122
|
+
styles: mergeStyles(glazeStyles(glaze), rest.styles as Styles),
|
|
123
|
+
};
|
|
119
124
|
},
|
|
120
125
|
},
|
|
121
126
|
});
|
|
@@ -159,7 +164,7 @@ configure({ baseStyleProps: ['radius', 'shadow'] });
|
|
|
159
164
|
<Card radius="1r" shadow />
|
|
160
165
|
```
|
|
161
166
|
|
|
162
|
-
Names must
|
|
167
|
+
Names must start with a lowercase letter and contain only letters and digits, and must not collide with a prop `tasty()` consumes itself (`as`, `styles`, `variant`, `mods`, `tokens`, …); invalid entries are dropped with a development warning. Names are not checked against the style registry, so a misspelled name is still harvested as a style prop.
|
|
163
168
|
|
|
164
169
|
`configure()` may run _after_ your components are defined — each factory resolves its prop list lazily and refreshes when the registry changes.
|
|
165
170
|
|
|
@@ -169,39 +174,9 @@ Names must be real style properties, must start with a lowercase letter, and mus
|
|
|
169
174
|
|
|
170
175
|
## Typing your extension
|
|
171
176
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
```ts
|
|
175
|
-
// tasty.d.ts
|
|
176
|
-
import type { StylePropValue } from '@tenphi/tasty';
|
|
177
|
-
|
|
178
|
-
declare module '@tenphi/tasty' {
|
|
179
|
-
// configure({ handlers }) — a new style property
|
|
180
|
-
interface StylesInterface {
|
|
181
|
-
stripe?: StylePropValue<string>;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// configure({ propHandlers }) — a new component prop
|
|
185
|
-
interface TastyCustomProps {
|
|
186
|
-
glaze: 'soft' | 'strong' | { tone: string; intensity?: number };
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// configure({ baseStyleProps }) — promoted style names, typed like the style
|
|
190
|
-
interface TastyBaseStylePropNames {
|
|
191
|
-
radius: true;
|
|
192
|
-
shadow: true;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// configure({ tokens }) / recipes / presets — autocomplete for names
|
|
196
|
-
interface TastyNamedColors {
|
|
197
|
-
'glaze-bg': true;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
```
|
|
177
|
+
Every extension point has a matching augmentation interface: `StylesInterface` for `handlers`, `TastyCustomProps` for `propHandlers`, `TastyBaseStylePropNames` for `baseStyleProps`, and `TastyNamedColors`, `TastyPresetNames`, and `TastyThemeNames` for the names your `tokens` and `presets` define. [Configuration → TypeScript](configuration.md#typescript) covers all six and the rules that go with them.
|
|
201
178
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
**Also update `tasty.config.ts`.** The ESLint plugin and VS Code extension validate style and token names against it, and neither is derived from the runtime registries:
|
|
179
|
+
**Also update `tasty.config.ts`.** The ESLint plugin and VS Code extension validate style and token names against it, and neither reads the runtime registries or your augmentations:
|
|
205
180
|
|
|
206
181
|
```ts
|
|
207
182
|
// tasty.config.ts
|
|
@@ -299,6 +274,8 @@ configure({ plugins: [glazePlugin()] });
|
|
|
299
274
|
|
|
300
275
|
```ts
|
|
301
276
|
// tasty.d.ts
|
|
277
|
+
import '@tenphi/tasty';
|
|
278
|
+
|
|
302
279
|
declare module '@tenphi/tasty' {
|
|
303
280
|
interface TastyCustomProps {
|
|
304
281
|
glaze: import('./glaze-plugin').GlazeConfig | string;
|
|
@@ -327,17 +304,20 @@ The object value is unambiguous here precisely because `glaze` is a **prop**. A
|
|
|
327
304
|
## Testing a plugin
|
|
328
305
|
|
|
329
306
|
```ts
|
|
330
|
-
import {
|
|
307
|
+
import { resetConfig, renderStyles } from '@tenphi/tasty';
|
|
308
|
+
import type { PropHandler, Styles } from '@tenphi/tasty';
|
|
331
309
|
|
|
332
310
|
describe('glaze plugin', () => {
|
|
333
311
|
beforeEach(() => resetConfig());
|
|
334
312
|
afterEach(() => resetConfig());
|
|
335
313
|
|
|
336
|
-
it('
|
|
337
|
-
|
|
314
|
+
it('turns the glaze prop into styles', () => {
|
|
315
|
+
const glaze = glazePlugin().propHandlers!.glaze as PropHandler;
|
|
316
|
+
const out = glaze({ glaze: 'purple' })!;
|
|
338
317
|
|
|
339
|
-
|
|
318
|
+
expect(out).not.toHaveProperty('glaze');
|
|
340
319
|
|
|
320
|
+
const rules = renderStyles(out.styles as Styles, '.test');
|
|
341
321
|
expect(rules[0].declarations).toContain('--glaze-bg-color');
|
|
342
322
|
});
|
|
343
323
|
});
|
|
@@ -345,7 +325,7 @@ describe('glaze plugin', () => {
|
|
|
345
325
|
|
|
346
326
|
Two things to know:
|
|
347
327
|
|
|
348
|
-
- **Create components inside each test.** `resetConfig()` does not clear
|
|
328
|
+
- **Create components inside each test.** `resetConfig()` does not clear a component's own class-name cache, which `tasty()` keeps in DOM environments, so a module-scope component can keep returning a class whose CSS the reset removed.
|
|
349
329
|
- **Dev-mode warnings may not fire.** `isDevEnv()` reports `false` for `NODE_ENV=test`. Warnings that check it lazily can be enabled with `vi.stubEnv('NODE_ENV', 'development')`; some older ones capture it at module load and cannot be triggered from a test at all.
|
|
350
330
|
|
|
351
331
|
---
|