@tenphi/cookbook 0.21.1 → 0.21.2

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.
@@ -169,20 +169,22 @@ export default [
169
169
 
170
170
  ### What `recommended` catches
171
171
 
172
- The `recommended` config enables 18 of the plugin's 27 total rules. It covers the most common issues without turning on the stricter governance rules:
173
-
174
- | Category | Rules | Examples |
175
- | ------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------ |
176
- | Property validation | `known-property`, `valid-boolean-property`, `valid-sub-element` | Flags typos like `pading` or invalid boolean usage |
177
- | Value validation | `valid-value`, `valid-color-token`, `valid-custom-unit` | Catches `#nonexistent` tokens, bad unit syntax |
178
- | State validation | `valid-state-key`, `no-nested-state-map`, `require-default-state` | Validates state key syntax, ensures `''` default exists |
179
- | Structure | `valid-styles-structure`, `no-important`, `no-nested-selector` | Prevents `!important`, invalid nesting |
180
- | Static mode | `static-no-dynamic-values`, `static-valid-selector` | Enforces build-time constraints in `tastyStatic()` |
181
- | Style properties | `valid-preset`, `valid-recipe`, `valid-transition`, `valid-directional-modifier`, `valid-radius-shape` | Validates preset names, recipe references, transition syntax |
172
+ The `recommended` config covers common correctness and style issues:
173
+
174
+ | Category | Rules | Examples |
175
+ | ------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------- |
176
+ | Property validation | `known-property`, `valid-boolean-property`, `valid-sub-element` | Flags typos like `pading` or invalid boolean usage |
177
+ | Value validation | `valid-value`, `valid-color-token`, `valid-custom-unit` | Catches `#nonexistent` tokens, bad unit syntax |
178
+ | State validation | `valid-state-key`, `no-nested-state-map`, `require-default-state` | Validates state key syntax, ensures `''` default exists |
179
+ | Structure | `valid-styles-structure`, `no-important`, `no-nested-selector` | Prevents `!important`, invalid nesting |
180
+ | Static mode | `static-no-dynamic-values`, `static-valid-selector` | Enforces build-time constraints in `tastyStatic()` |
181
+ | Style properties | `valid-preset`, `valid-recipe`, `valid-transition`, `valid-directional-modifier`, `valid-radius-shape` | Validates preset names, recipe references, transition syntax |
182
+ | Motion guidance | `no-raw-transition-duration` | Suggests timing tokens or the default timing for transition durations |
183
+ | Dynamic styles | `no-runtime-styles-mutation`, `no-style-spread` | Warns on JavaScript values and spreads inside runtime style objects |
182
184
 
183
185
  ### Strict config
184
186
 
185
- For stricter governance, use `tasty.configs.strict`. It adds rules that enforce best practices like preferring shorthand properties, consistent token usage, and flagging direct `styles` prop usage:
187
+ For stricter governance, use `tasty.configs.strict`. It adds rules that flag direct `styles` prop usage and other patterns:
186
188
 
187
189
  ```js
188
190
  export default [tasty.configs.strict];
@@ -236,7 +236,7 @@ const ProgressBar = tasty({
236
236
  width: '$progress',
237
237
  height: '100%',
238
238
  fill: '#primary',
239
- transition: 'width 0.3s',
239
+ transition: 'width',
240
240
  },
241
241
  },
242
242
  elements: { Bar: 'div' },
@@ -534,7 +534,7 @@ See [Configuration](configuration.md) for the full `configure()` API.
534
534
 
535
535
  - **Create styled wrappers** instead of passing `styles` directly — faster, composable, inspectable
536
536
  - **Use design tokens and custom units** (`#text`, `2x`, `1r`) instead of raw CSS values
537
- - **Use semantic transition names** (`transition: 'theme 0.3s'`) instead of listing CSS properties
537
+ - **Use semantic transition names** (`transition: 'theme'`) instead of listing CSS properties; omit the duration to use the motion tokens
538
538
  - **Use `elements` prop** to declare typed sub-components for compound components
539
539
  - **Use `styleProps`** to define what product engineers can customize
540
540
  - **Use `modProps`** to expose known modifier states as clean component props
@@ -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/blob/v3.8.0/src/pipeline) — TypeScript file names below are relative to that directory.
5
+ **Implementation:** [`src/pipeline/`](https://github.com/tenphi/tasty/blob/v3.9.6/src/pipeline) — TypeScript file names below are relative to that directory.
6
6
 
7
7
  ## Overview
8
8
 
@@ -86,18 +86,22 @@ Output: CSSRule[]
86
86
 
87
87
  ### What It Does
88
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.
89
+ Runs on each style's value map **before** any parsing, when the map has at least three keys and one of them uses `&`. An atom is a **don't-care** when flipping it never changes which value the map resolves to; every key that mentions a don't-care atom is dropped.
90
90
 
91
91
  ### How It Works
92
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.
93
+ 1. Split each key into its top-level `&` atoms. Keys containing `|`, `^`, or `,` at top level are a single opaque atom. The `_` floor (and a misused `_` key) is kept verbatim and left out of the analysis.
94
+ 2. Treat the map as the cascade it is: the last authored key whose atoms all hold wins, and `''` is always the lowest priority.
95
+ 3. An atom is a don't-care when, in every state, the cascade resolves to the same value with the atom present as with it absent. Only the states `(K₁ minus the atom) ∪ K₀`, for every key `K₁` that contains the atom and every key `K₀` that does not (or none), can tell the two apart, so the check is quadratic in the number of keys rather than exponential in atoms.
96
+ 4. Drop every key that contains a don't-care atom. With the atom forced absent those keys never match, and by definition the result is unchanged.
97
+
98
+ The check is over the cascade, not over pairs of keys. In `{ '': A, checked: B, 'invalid & checked': C, disabled: A }`, `disabled` has the default's value, but it outranks `checked`: a checked, disabled element gets `A`, so `disabled` is not a don't-care. (An earlier pairwise rule — "every key with the atom has a same-value partner without it" — folded `disabled` into `''` here and lost that priority.)
99
+
100
+ Atoms are compared as strings and treated as independent. Real atoms can be related (`theme=dark` vs `theme=light`, nested media ranges); that only rules states out, so a don't-care over independent atoms is one for every real state too. Two guards keep the rewrite safe: a map with more than 32 distinct atoms is left unreduced, and a reduction that would leave the `_` floor alone with `''` (which means "use the floor", see Stage 1) is skipped.
97
101
 
98
102
  ### Why
99
103
 
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).
104
+ 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.
101
105
 
102
106
  ### Example
103
107
 
@@ -200,6 +204,8 @@ simplify(C_m & C_l & !C_h) = FALSE
200
204
 
201
205
  i.e. there is no scenario where the intermediate state could have matched, the lower same-value entry would also have matched, and the higher one would not. This is the only way the merge could leak through and shadow the intermediate state.
202
206
 
207
+ An intermediate whose condition always holds (a tautological key, or same-value keys that already merged into one, like `beta | !beta`) is checked like any other. Only the `''` default and the `_` floor are exempt from the check, and they are always the lowest priorities, so they are never intermediates.
208
+
203
209
  ### Why
204
210
 
205
211
  Without this pass 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. The safety check ensures we never break the cascade in service of this optimization.
@@ -298,7 +304,9 @@ B: B & !A (applies only when A doesn't)
298
304
  C: C & !A & !B (applies only when neither A nor B)
299
305
  ```
300
306
 
301
- Each exclusive condition is passed through `simplifyCondition`. Entries that simplify to `FALSE` (impossible) are filtered out. The default state (`''` → `TrueCondition`) is not added to the “prior” list for negation (see `buildExclusiveConditions`).
307
+ Each exclusive condition is passed through `simplifyCondition`. Entries that simplify to `FALSE` (impossible) are filtered out.
308
+
309
+ Every entry is added to the "prior" list, including one whose condition is `TRUE`. That can be a tautological key, or same-value keys that merged into one in Stage 1b (`alpha: X, '!alpha': X` → `TRUE`). Such an entry always applies at its priority, so negating it correctly makes every lower entry `FALSE`. The bare `''` default is `TRUE` too, but it is always the lowest priority, so no entry comes after it.
302
310
 
303
311
  ### `_` fallback floor
304
312
 
@@ -389,7 +397,7 @@ The pre-build Stage 2a pass doesn't need this because user-authored ORs aren't p
389
397
 
390
398
  ### Example (conceptual)
391
399
 
392
- 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.
400
+ 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.
393
401
 
394
402
  ***
395
403
 
@@ -401,6 +409,8 @@ See the comment block in `exclusive.ts:500-523`: a default value whose higher-pr
401
409
 
402
410
  Computes the Cartesian product of all style entries for a handler, creating snapshots of which value each style has for each possible state combination.
403
411
 
412
+ When every input is a direct value rather than a state map, the pipeline skips stages 0–4 and passes one unconditional snapshot directly to the handler. This avoids building exclusive entries and a Cartesian product for ordinary state-free styles.
413
+
404
414
  ### How It Works
405
415
 
406
416
  1. Collect exclusive entries for each style the handler uses
@@ -460,6 +470,7 @@ Style handlers encapsulate the logic for translating design tokens (like `'2x'`)
460
470
  ### What It Does
461
471
 
462
472
  Combines rules that have identical CSS output into a single rule with an OR condition.
473
+ Zero- and one-rule inputs return unchanged because no merge is possible.
463
474
 
464
475
  ### How It Works
465
476
 
@@ -747,3 +758,16 @@ Impossible combinations are detected at multiple levels (simplification, variant
747
758
  ### 4. Aggressive Caching
748
759
 
749
760
  Parse, simplify, condition-to-CSS, and full-pipeline results are cached independently, enabling fast re-rendering when only parts of the style object change.
761
+
762
+ 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
+
764
+ ***
765
+
766
+ ## Testing Priority
767
+
768
+ 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
+
770
+ * `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.
771
+ * `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
+
773
+ 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.
@@ -246,7 +246,7 @@ function glazeStyles(value: GlazeValue): Styles {
246
246
  styles = Object.freeze({
247
247
  '#glaze-bg': `#${tone}.${intensity}`,
248
248
  fill: '#glaze-bg',
249
- transition: 'fill 0.2s',
249
+ transition: 'fill',
250
250
  }) as Styles;
251
251
  cache.set(key, styles);
252
252
  }
@@ -40,32 +40,49 @@ of elements, rules or interactions. Machine load, browser versions, and CPU
40
40
  power will move the results — on a loaded machine the absolute columns drift
41
41
  several percent while the raw/Tasty delta holds, so read the delta.
42
42
 
43
+ **On a laptop, check the power state first.** Low Power Mode caps CPU frequency,
44
+ and it is the single largest source of error here — larger than background load.
45
+ Measured on the machine below, running on battery with Low Power Mode enabled
46
+ made every one of the 53 Node cases slower, by a median of 26%, and halving the
47
+ background load recovered only 3 points of that. A frequency cap scales every
48
+ case by roughly the same factor, so it does not look like noise; it looks like a
49
+ uniformly slower library. Verify before trusting a run:
50
+
51
+ ```bash
52
+ pmset -g ps | head -2 # want 'AC Power'
53
+ pmset -g | grep -i lowpowermode # want 0
54
+ ```
55
+
43
56
  ## Core Style Pipeline
44
57
 
45
- The following numbers are single-call throughput measured on an Apple M1 Max
46
- with Node 22:
58
+ Single-call throughput, three consecutive runs on an Apple M3 Pro with Node
59
+ 24.18.0 on AC power:
47
60
 
48
61
  | Operation | ops/sec | Latency (mean) |
49
62
  | ----------------------------------------------------------- | --------------------: | -------------: |
50
- | `renderStyles` — 5 flat properties (cold) | \~60,000 | \~17 us |
51
- | `renderStyles` — state map with media/hover/modifier (cold) | \~18,500 | \~54 us |
52
- | `renderStyles` — same styles (cached) | \~5,800,000 | \~0.17 us |
53
- | `parseStateKey` — simple key like `:hover` (cold) | \~790,000 | \~1.3 us |
54
- | `parseStateKey` — complex OR/AND/NOT key (cold) | \~140,000 | \~7 us |
55
- | `parseStateKey` — any key (cached) | \~3,400,000–8,300,000 | \~0.1–0.3 us |
56
- | `parseStyle` — value tokens like `2x 4x` (cold) | \~344,000 | \~2.9 us |
57
- | `parseStyle` — color tokens (cold) | \~567,000 | \~1.8 us |
58
- | `parseStyle` — any value (cached) | \~15,250,000 | \~0.07 us |
63
+ | `renderStyles` — 5 flat properties (cold) | 143,000–158,000 | 6.3–7.0 us |
64
+ | `renderStyles` — state map with media/hover/modifier (cold) | 31,000–36,000 | 28–32 us |
65
+ | `renderStyles` — OR state map across contexts (cold) | 13,000–14,000 | 71–76 us |
66
+ | `renderStyles` — same styles (cached) | 9,830,000–10,420,000 | \~0.10 us |
67
+ | `parseStateKey` — simple key like `:hover` (cold) | 1,910,000–1,980,000 | \~0.50 us |
68
+ | `parseStateKey` — value modifier key (cold) | 1,120,000–1,160,000 | \~0.90 us |
69
+ | `parseStateKey` — complex OR/AND/NOT key (cold) | 341,000–357,000 | 2.8–2.9 us |
70
+ | `parseStateKey` — any key (cached) | 4,900,000–16,530,000 | 0.10–0.20 us |
71
+ | `parseStyle` — value tokens like `2x 4x` (cold) | 575,000–605,000 | \~1.7 us |
72
+ | `parseStyle` — color tokens (cold) | 1,160,000–1,240,000 | 0.80–0.90 us |
73
+ | `parseStyle` — layered functions (cold) | 175,000–184,000 | 5.4–5.7 us |
74
+ | `parseStyle` — any value (cached) | 29,500,000–30,750,000 | \~0.03 us |
59
75
 
60
76
  “Cold” cases use unique inputs to bypass the relevant caches. Cached cases
61
- reuse one input and measure the LRU hot path. Expect roughly ±10% between runs.
62
- These benchmarks do not include React, DOM work, stylesheet injection, style
63
- resolution, layout, or paint.
77
+ reuse one input and measure the LRU hot path. Run-to-run spread on an idle
78
+ machine was 1–7% for most cases, rising to 10–16% for the two `renderStyles`
79
+ cold cases. These benchmarks do not include React, DOM work, stylesheet
80
+ injection, style resolution, layout, or paint.
64
81
 
65
82
  The benchmark sources are colocated with the code they exercise:
66
- [`pipeline.bench.ts`](https://github.com/tenphi/tasty/blob/v3.8.0/src/pipeline/pipeline.bench.ts),
67
- [`parseStateKey.bench.ts`](https://github.com/tenphi/tasty/blob/v3.8.0/src/pipeline/parseStateKey.bench.ts), and the
68
- parser benchmark files under [`src/parser`](https://github.com/tenphi/tasty/blob/v3.8.0/src/parser).
83
+ [`pipeline.bench.ts`](https://github.com/tenphi/tasty/blob/v3.9.6/src/pipeline/pipeline.bench.ts),
84
+ [`parseStateKey.bench.ts`](https://github.com/tenphi/tasty/blob/v3.9.6/src/pipeline/parseStateKey.bench.ts), and the
85
+ parser benchmark files under [`src/parser`](https://github.com/tenphi/tasty/blob/v3.9.6/src/parser).
69
86
 
70
87
  ## Empty Wrapper Overhead
71
88
 
@@ -74,7 +91,7 @@ Skipping the style pipeline does not make a `tasty()` component free. Even
74
91
  tracks another fiber, and Tasty still processes and forwards the element's
75
92
  props.
76
93
 
77
- [`tasty-overhead.bench.tsx`](https://github.com/tenphi/tasty/blob/v3.8.0/src/tasty-overhead.bench.tsx) compares 10,000
94
+ [`tasty-overhead.bench.tsx`](https://github.com/tenphi/tasty/blob/v3.9.6/src/tasty-overhead.bench.tsx) compares 10,000
78
95
  raw `<div className>` siblings with 10,000 instances of one module-scoped
79
96
  `tasty({})` component. Both receive the same props, and the benchmark fails if
80
97
  they do not produce equivalent DOM.
@@ -86,19 +103,19 @@ excludes layout, paint, and stylesheet matching. Every commit is wrapped in
86
103
  `flushSync`, keeping its synchronous reconciliation and commit inside the
87
104
  sample. This does not estimate React's concurrent scheduling latency.
88
105
 
89
- On an Apple M3 Pro with React 19.2.4 and Chromium 151, three consecutive runs
106
+ On an Apple M3 Pro with React 19.2.8 and Chromium 151, three consecutive runs
90
107
  produced these ranges:
91
108
 
92
109
  | Work on 10,000 siblings | Raw elements | `tasty({})` | Extra per wrapped element |
93
110
  | ----------------------------------- | -----------: | -----------: | ------------------------: |
94
- | Mount + remove | 4.5–4.9 ms | 14.0–14.9 ms | 0.95–1.00 us |
95
- | Rerender, same host props | 1.3–1.4 ms | 10.9–11.4 ms | 0.96–1.00 us |
96
- | Rerender, change one host attribute | 2.8–3.4 ms | 15.0–15.7 ms | 1.21–1.27 us |
111
+ | Mount + remove | 4.2–4.3 ms | 9.2–9.7 ms | 0.50–0.54 us |
112
+ | Rerender, same host props | 1.2–1.3 ms | 6.3–6.5 ms | 0.51–0.52 us |
113
+ | Rerender, change one host attribute | 2.5–2.7 ms | 11.2–11.6 ms | 0.86–0.91 us |
97
114
 
98
115
  The useful result is the raw/Tasty time difference divided by 10,000, not the
99
116
  ratio between the two times. The ratio becomes large because the raw baseline
100
- is tiny. In this synthetic workload, an empty wrapper adds roughly 1 us per
101
- participating element, or 1.2–1.3 us when React also changes a DOM attribute.
117
+ is tiny. In this synthetic workload, an empty wrapper adds roughly 0.5 us per
118
+ participating element, or \~0.9 us when React also changes a DOM attribute.
102
119
 
103
120
  This is the floor Tasty consumes when it has no styling job. It is not a
104
121
  page-level score. Real trees include application components, effects, layout,
@@ -108,7 +125,7 @@ snapshot experiment with controlled garbage collection.
108
125
 
109
126
  ## Cold Generation and Injection
110
127
 
111
- [`tasty-injection.bench.ts`](https://github.com/tenphi/tasty/blob/v3.8.0/src/tasty-injection.bench.ts) measures the extra
128
+ [`tasty-injection.bench.ts`](https://github.com/tenphi/tasty/blob/v3.9.6/src/tasty-injection.bench.ts) measures the extra
112
129
  work when Tasty must generate and inject CSS that an otherwise equivalent page
113
130
  already has. It does not compare different stylesheet insertion techniques.
114
131
 
@@ -143,17 +160,23 @@ ranges:
143
160
 
144
161
  | Workload | CSS already present | Tasty runtime | Incremental Tasty cost |
145
162
  | ---------------------------------------------------- | ------------------: | -------------: | ---------------------: |
146
- | One new rule + immediate resolution, per transaction | 2.8–3.3 us | 110.3–113.8 us | 107.3–111.0 us |
147
- | 1,000 new rules + one resolution | 1.86–2.06 ms | 9.01–9.98 ms | 7.13–7.92 ms |
148
- | 1,000-rule workload, incremental cost per rule | — | — | 7.1–7.9 us |
163
+ | One new rule + immediate resolution, per transaction | 2.6–3.0 us | 102.8–107.0 us | 100.3–104.3 us |
164
+ | 1,000 new rules + one resolution | 1.69–1.79 ms | 7.42–7.67 ms | 5.73–5.94 ms |
165
+ | 1,000-rule workload, incremental cost per rule | — | — | 5.7–5.9 us |
149
166
 
150
167
  Directly compared, injecting 1,000 rules before one resolution boundary cost
151
- about 66–71 times as much in total as injecting one rule and resolving it—not
152
- 1,000 times as much. Its average incremental cost per rule was about 14–16
168
+ about 55–59 times as much in total as injecting one rule and resolving it—not
169
+ 1,000 times as much. Its average incremental cost per rule was about 17–18
153
170
  times lower. This is the same Tasty generation and injection path in both cases;
154
171
  the group amortizes fixed transaction work and lets the browser resolve all the
155
172
  stylesheet writes together.
156
173
 
174
+ The per-transaction single-rule figure is the one number here that engine work
175
+ does not move: it is dominated by the browser's injection-to-resolution
176
+ boundary, which is crossed once per rule regardless of how fast generation is.
177
+ The 1,000-rule column, where Tasty's own generation dominates, is where engine
178
+ changes show up.
179
+
157
180
  The subtraction is the meaningful result. It includes Tasty's cold style
158
181
  generation, cache and injector bookkeeping, rule insertion, and any additional
159
182
  style invalidation exposed by that workload's resolution boundary. It does not
@@ -178,7 +201,7 @@ and dialogs open. Both paths go through the state-map and ref-counting
178
201
  machinery rather than the parser, so a regression in them is invisible to every
179
202
  other benchmark here.
180
203
 
181
- [`tasty-interaction.bench.tsx`](https://github.com/tenphi/tasty/blob/v3.8.0/src/tasty-interaction.bench.tsx) pairs each
204
+ [`tasty-interaction.bench.tsx`](https://github.com/tenphi/tasty/blob/v3.9.6/src/tasty-interaction.bench.tsx) pairs each
182
205
  case with a raw-DOM equivalent driven by a hand-written stylesheet that
183
206
  produces the same computed color and background in both states. The benchmark
184
207
  fails if either arm resolves to anything else, so an arm that quietly rendered
@@ -207,32 +230,32 @@ if the hovered rule is not already there. That a style map's states all ship in
207
230
  one chunk on first render is the premise of this case; if the hovered rule
208
231
  arrived lazily, the first sample would be timing injection.
209
232
 
210
- On an Apple M1 Max with React 19.2.8 and Chromium 151, across several runs:
233
+ On an Apple M3 Pro with React 19.2.8 and Chromium 151, three consecutive runs:
211
234
 
212
- | Workload | Raw elements | Tasty mods | Extra per unit |
213
- | ---------------------------------------------------- | -----------: | -----------: | -----------------------: |
214
- | 300 single-element mod toggles in a 100-element tree | 2.1–2.5 ms | 2.7–3.1 ms | 1.6–2.1 us / interaction |
215
- | 20 mount + unmount cycles of a 200-element subtree | 7.6–8.0 ms | 12.6–12.7 ms | 1.22–1.27 us / element |
235
+ | Workload | Raw elements | Tasty mods | Extra per unit |
236
+ | ---------------------------------------------------- | -----------: | ---------: | -----------------------: |
237
+ | 300 single-element mod toggles in a 100-element tree | 1.7–1.8 ms | 2.0–2.1 ms | 1.2–1.3 us / interaction |
238
+ | 20 mount + unmount cycles of a 200-element subtree | 5.2–5.4 ms | 8.3–8.6 ms | 0.77–0.81 us / element |
216
239
 
217
240
  The absolute columns move several percent with machine load; the delta between
218
241
  the arms is the stable quantity, so read that rather than either column.
219
242
 
220
243
  Two things are worth reading out of this.
221
244
 
222
- A mod flip on an already-mounted element costs about 2 us. The CSS for both
245
+ A mod flip on an already-mounted element costs about 1.2 us. The CSS for both
223
246
  states already exists — Tasty emits every state of a style map in one chunk on
224
247
  first render — so both arms perform the same commit, and what is left is
225
- Tasty's props and mod handling. That is the same order as the \~1 us empty
248
+ Tasty's props and mod handling. That is the same order as the \~0.5 us empty
226
249
  wrapper measured above, which is most of where it comes from.
227
250
 
228
- Subtree churn is not about styling at all. Its \~1.25 us per element sits right
251
+ Subtree churn is not about styling at all. Its \~0.8 us per element sits right
229
252
  on the empty-wrapper mount cost, because the styles are already cached:
230
253
  reopening a menu re-pays the React wrapper, not the style pipeline.
231
254
 
232
255
  ## Page-Load Cold Start
233
256
 
234
257
  Every benchmark above deliberately excludes the network, module compilation and
235
- the first render. [`scripts/cold-start`](https://github.com/tenphi/tasty/blob/v3.8.0/scripts/cold-start) measures exactly
258
+ the first render. [`scripts/cold-start`](https://github.com/tenphi/tasty/blob/v3.9.6/scripts/cold-start) measures exactly
236
259
  those: what a visitor waits for between requesting a page and seeing styled
237
260
  content, in a real Chromium under CDP network and CPU throttling.
238
261
 
@@ -257,7 +280,7 @@ Two things about the payload decide whether this measures a deployment or a
257
280
  straw man, so both are enforced rather than assumed:
258
281
 
259
282
  * **Assets are served brotli-compressed**, the way a static host serves them.
260
- The bundle is 52.0 KB on the wire and 186 KB decoded; putting the decoded
283
+ The bundle is 51.5 KB on the wire and 180 KB decoded; putting the decoded
261
284
  bytes on a 1.6 Mbps link would add \~700 ms and charge it to Tasty. The run
262
285
  reads `encodedBodySize` back out of resource timing and fails if what
263
286
  crossed the wire is not the compressed size the table reports.
@@ -266,35 +289,48 @@ straw man, so both are enforced rather than assumed:
266
289
  would be. Re-exporting the whole library adds \~4 KB brotli of code no page
267
290
  here calls.
268
291
 
269
- On an Apple M1 Max with React 19.2.8 and Chromium 151, first contentful paint:
292
+ On an Apple M3 Pro with React 19.2.8 and Chromium 151, first contentful paint,
293
+ median of three full runs of the matrix:
294
+
295
+ | Link / CPU | baseline | runtime | prewarm | Tasty's cost | noise |
296
+ | --------------------- | -------: | ------: | ------: | -----------: | ----: |
297
+ | No throttling, 1x | 36 ms | 40 ms | 40 ms | +4 ms | 0 ms |
298
+ | Fast 4G, 1x | 628 ms | 672 ms | 688 ms | +44 ms | 20 ms |
299
+ | Slow 4G, 1x | 2044 ms | 2308 ms | 2308 ms | +264 ms | 24 ms |
300
+ | No throttling, 4x CPU | 112 ms | 144 ms | 144 ms | +32 ms | 16 ms |
301
+ | Fast 4G, 4x CPU | 656 ms | 728 ms | 736 ms | +72 ms | 4 ms |
302
+ | Slow 4G, 4x CPU | 2064 ms | 2360 ms | 2360 ms | +296 ms | 8 ms |
303
+
304
+ The `noise` column is not an estimate. The `baseline` page contains no Tasty at
305
+ all, so its three samples should be identical; the spread they actually show is
306
+ this cell's measurement error, and it applies to the other columns too.
270
307
 
271
- | Link / CPU | baseline | runtime | prewarm | Tasty's cost |
272
- | --------------------- | -------: | ------: | ------: | -----------: |
273
- | No throttling, 1x | 40 ms | 52 ms | 52 ms | +12 ms |
274
- | Fast 4G, 1x | 624 ms | 680 ms | 676 ms | +56 ms |
275
- | Slow 4G, 1x | 2028 ms | 2304 ms | 2304 ms | +276 ms |
276
- | No throttling, 4x CPU | 148 ms | 196 ms | 196 ms | +48 ms |
277
- | Fast 4G, 4x CPU | 684 ms | 784 ms | 788 ms | +100 ms |
278
- | Slow 4G, 4x CPU | 2096 ms | 2416 ms | 2408 ms | +320 ms |
308
+ Read that column before any other. Only the Slow 4G rows carry a cost an order
309
+ of magnitude above their own noise. The unthrottled 1x row (+4 ms) means "too
310
+ small to measure this way", not "4 ms". And a single run is genuinely not
311
+ enough here: taken alone, the first of these three runs put Fast 4G 1x at
312
+ +72 ms, which the median over three corrects to +44 ms.
279
313
 
280
- That is one full run of the matrix; a second moved every cell by a few percent.
314
+ For the same reason, `prewarm` landing above `runtime` in the Fast 4G 1x row is
315
+ noise, not a cost — the two modes differ only in when the engine compiles, and
316
+ that difference is measured in the phase table below, not in FCP.
281
317
 
282
318
  **The cost is the bundle, not the work.** On Slow 4G the extra transfer alone
283
- accounts for 262 ms of the 276 ms FCP delta — nearly all of it. Everything
284
- Tasty then *does* is small by comparison:
319
+ accounts for 263 ms of the 264 ms FCP delta — effectively all of it. Everything
320
+ Tasty then *does* is small by comparison (median of three runs):
285
321
 
286
322
  | Phase (Slow 4G, 1x) | baseline | runtime | prewarm |
287
323
  | ----------------------- | -------: | ------: | ------: |
288
- | js+css transfer | 1420 ms | 1682 ms | 1681 ms |
289
- | module compile (shared) | 1.2 ms | 1.5 ms | 2.0 ms |
290
- | tasty top-level execute | — | 1.2 ms | 0.9 ms |
291
- | `configure()` | — | 0.6 ms | 0.5 ms |
292
- | prewarm | — | — | 5.3 ms |
293
- | render 1st component | 2.0 ms | 8.2 ms | 2.7 ms |
294
- | render 49 more | 1.0 ms | 7.1 ms | 6.0 ms |
324
+ | js+css transfer | 1423 ms | 1686 ms | 1690 ms |
325
+ | module compile (shared) | 3.5 ms | 3.3 ms | 3.0 ms |
326
+ | tasty top-level execute | — | 1.2 ms | 0.8 ms |
327
+ | `configure()` | — | 0.6 ms | 0.4 ms |
328
+ | prewarm | — | — | 3.3 ms |
329
+ | render 1st component | 3.0 ms | 7.3 ms | 1.9 ms |
330
+ | render 49 more | 1.3 ms | 5.2 ms | 3.8 ms |
295
331
 
296
332
  Importing Tasty costs about 1 ms of top-level execution; `configure()` costs
297
- half of one. The rest of the CPU delta — about 13 ms for 50 components — is
333
+ half of one. The rest of the CPU delta — about 10 ms for 50 components — is
298
334
  generation and injection, which is the cost the injection benchmark isolates.
299
335
 
300
336
  One asymmetry is worth naming: the control links a render-blocking stylesheet
@@ -304,20 +340,21 @@ delivery models, not a thumb on the scale, but it means the FCP delta is not
304
340
  purely "what Tasty costs to execute".
305
341
 
306
342
  **Prewarming moves the wake-up, it does not remove it.** The first styled render
307
- is \~5 ms more expensive than the ones after it, because that is when the
343
+ is \~5.4 ms more expensive than the ones after it, because that is when the
308
344
  engine's deferred payload is actually compiled. A throwaway `computeStyles()`
309
- against a detached root pays it early: `render 1st` drops from 8.2 ms to
310
- 2.7 ms. The prewarm itself costs 5.3 ms, so FCP does not move. It is worth
311
- doing only when something else can overlap it, or when the first render is on a
312
- latency-critical path and the page has idle time before it.
345
+ against a detached root pays it early: `render 1st` drops from 7.3 ms to
346
+ 1.9 ms. The prewarm itself costs 3.3 ms, so the work is moved rather than
347
+ removed and FCP does not move. It is worth doing only when something else can
348
+ overlap it, or when the first render is on a latency-critical path and the page
349
+ has idle time before it.
313
350
 
314
351
  **Retained heap.** After a forced collection, the runtime page holds about
315
- 1,013 KB more than the control (2,632 KB vs 1,619 KB) for 50 components — the
352
+ 985 KB more than the control (2,605 KB vs 1,619 KB) for 50 components — the
316
353
  parser caches, the chunk cache, the injector's registry and the generated CSS.
317
354
  The control is not zero either; most of its 1.6 MB is React and the DOM.
318
355
 
319
356
  CPU throttling changes which line moves. At 4x, module compilation of the
320
- larger graph becomes visible (5.9 ms → 25 ms) where at 1x it is free: V8
357
+ larger graph becomes visible (2.6 ms → 19 ms) where at 1x it is free: V8
321
358
  pre-parses at import and compiles lazily, so a slower CPU pays for code the
322
359
  faster one never fully compiled. Transfer numbers from the unthrottled cells
323
360
  are not worth reading — with no emulated link, resource timings are scheduling
@@ -341,8 +378,8 @@ blindly. They describe different paths:
341
378
 
342
379
  The cold-start measurement is the one that puts the rest in proportion. On a
343
380
  slow connection, nearly all of Tasty's page-load cost is transferring the
344
- library — 262 ms of a 276 ms delta — while the generation and injection the
345
- microbenchmarks obsess over is \~13 ms for 50 components. Bundle size is
381
+ library — 263 ms of a 264 ms delta — while the generation and injection the
382
+ microbenchmarks obsess over is \~10 ms for 50 components. Bundle size is
346
383
  therefore the lever with the largest effect on first paint, and the runtime
347
384
  levers matter for what happens after it.
348
385
 
@@ -506,7 +506,7 @@ const stream = await runWithCollector(collector, () =>
506
506
  | `@tenphi/tasty/ssr/next-config` | Next.js config wrapper: shared, content-hashed global stylesheet |
507
507
  | `@tenphi/tasty/ssr/astro` | Astro: `tastyIntegration`, `tastyMiddleware` |
508
508
  | `@tenphi/tasty/ssr/astro-client` | Astro: client-side cache hydration (auto-injected by integration, or import manually) |
509
- | `@tenphi/tasty/ssr/astro-middleware`<br>`@tenphi/tasty/ssr/astro-middleware-static`<br>`@tenphi/tasty/ssr/astro-middleware-extract`<br>`@tenphi/tasty/ssr/astro-middleware-extract-static` | Astro: the middleware entrypoints `tastyIntegration()` registers via `addMiddleware()`. Exported so Astro can resolve them by specifier; you should not import them. For manual setups use `tastyMiddleware()`. |
509
+ | `@tenphi/tasty/ssr/astro-middleware`<br />`@tenphi/tasty/ssr/astro-middleware-static`<br />`@tenphi/tasty/ssr/astro-middleware-extract`<br />`@tenphi/tasty/ssr/astro-middleware-extract-static` | Astro: the middleware entrypoints `tastyIntegration()` registers via `addMiddleware()`. Exported so Astro can resolve them by specifier; you should not import them. For manual setups use `tastyMiddleware()`. |
510
510
 
511
511
  ### `ServerStyleCollector`
512
512
 
@@ -37,6 +37,24 @@ Use these instead of their raw CSS counterparts:
37
37
 
38
38
  ## Layout
39
39
 
40
+ ### `overscrollBehavior`
41
+
42
+ The native `overscrollBehavior` shorthand and its `overscrollBehaviorBlock`,
43
+ `overscrollBehaviorInline`, `overscrollBehaviorX`, and `overscrollBehaviorY`
44
+ longhands support `auto`, `contain`, `none`, and `chain`. The shorthand can set
45
+ separate X and Y values, for example `overscrollBehavior: 'chain contain'`.
46
+
47
+ `chain` allows scroll chaining to another scrollable area while suppressing
48
+ boundary effects such as bounce. It requires browser support; use a feature
49
+ query and a fallback floor when targeting browsers without it:
50
+
51
+ ```jsx
52
+ overscrollBehaviorBlock: {
53
+ _: 'auto',
54
+ '@supports(overscroll-behavior-block: chain)': 'chain',
55
+ }
56
+ ```
57
+
40
58
  ### `display`
41
59
 
42
60
  Standard CSS display values. Handled together with `hide`, `textOverflow`, `overflow`, and `whiteSpace` for priority resolution.
@@ -566,15 +584,17 @@ When easing is provided without a duration, the default timing variable is used
566
584
  | `zIndex` | `z-index` |
567
585
  | `inset` | `inset`, `top`, `right`, `bottom`, `left` |
568
586
 
569
- Default timing: `$transition` (or `${name}-transition` with `$transition` fallback per semantic name).
587
+ When two entries cover the same property, an entry that names that one property wins over a group such as `theme`, in either order: `opacity 120ms ease-in-out, theme` keeps the opacity timing. Otherwise the later entry wins, as in CSS.
588
+
589
+ Default timing: `$transition` (or `${name}-transition` with `$transition` fallback per semantic name). Prefer omitting the duration or using a duration token instead of repeating raw time values across components. Explicit zero can disable motion.
570
590
 
571
591
  ```jsx
572
- transition="theme 0.3s"
573
- transition="fill 0.2s, radius 0.3s"
574
- transition="fade 0.15s ease-in"
592
+ transition="theme"
593
+ transition="fill, radius"
594
+ transition="fade $transition ease-in"
575
595
  transition="fill ease-in" // easing without duration (uses default timing)
576
596
  transition="radius ease-in-out" // easing keyword only
577
- transition="$$custom-prop 0.3s" // custom property transition
597
+ transition="$$custom-prop" // custom property transition
578
598
  ```
579
599
 
580
600
  **Recognized easing keywords:** `ease`, `ease-in`, `ease-out`, `ease-in-out`, `linear`, `step-start`, `step-end`. CSS easing functions (`cubic-bezier(...)`, `steps(...)`, `linear(...)`) are also recognized.
@@ -583,6 +603,24 @@ If the name is not a semantic name, it is used as a literal CSS property name.
583
603
 
584
604
  ---
585
605
 
606
+ ### `animation`
607
+
608
+ `animation` is the native CSS shorthand. Tasty parses tokens in its value and
609
+ tracks local `@keyframes` names referenced by it, so it needs no custom style
610
+ handler. Define a complete animation with the shorthand, using a configured
611
+ duration token where appropriate (for example,
612
+ `animation: 'pulse $animation-duration ease-in infinite'`).
613
+
614
+ Use `animationDuration`, `animationTimeline`, `animationRange`, and other CSS
615
+ longhands when changing just one part of an existing animation in a state or
616
+ component extension. Setting `animation` resets omitted parts, including its
617
+ timeline and range. The same principle applies to `transitionDuration` and
618
+ other transition longhands when a base `transition` is already defined.
619
+ `transitionBehavior` remains a separate CSS longhand; Tasty's semantic
620
+ `transition` syntax covers name, timing, easing, and delay.
621
+
622
+ ---
623
+
586
624
  ## Scrollbar & Overflow
587
625
 
588
626
  ### `scrollbar`
@@ -489,7 +489,7 @@ const button = tastyStatic({
489
489
  radius: '1r',
490
490
  fill: '#gray.20',
491
491
  color: '#text',
492
- transition: 'fill 0.15s',
492
+ transition: 'fill',
493
493
  });
494
494
 
495
495
  // Variants
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenphi/cookbook",
3
- "version": "0.21.1",
3
+ "version": "0.21.2",
4
4
  "description": "Repository-native static documentation for Astro",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -68,8 +68,8 @@
68
68
  "author": "Andrey Yamanov",
69
69
  "license": "MIT",
70
70
  "dependencies": {
71
- "@tenphi/docs": "0.21.1",
72
- "@tenphi/renderer": "0.21.1"
71
+ "@tenphi/docs": "0.21.2",
72
+ "@tenphi/renderer": "0.21.2"
73
73
  },
74
74
  "peerDependencies": {
75
75
  "astro": "^7.3.2"