@tenphi/tasty 0.0.0-snapshot.1d29633 → 0.0.0-snapshot.1f1c00a

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.
Files changed (122) hide show
  1. package/README.md +152 -134
  2. package/dist/{babel-Cg-mJV_t.d.ts → babel-rZcEwPLM.d.ts} +2 -3
  3. package/dist/chunks/async-storage-CG5HnUT5.js +20 -0
  4. package/dist/chunks/async-storage-CG5HnUT5.js.map +1 -0
  5. package/dist/chunks/build-config-CL4eRu2z.js +42 -0
  6. package/dist/chunks/build-config-CL4eRu2z.js.map +1 -0
  7. package/dist/{collector-BMT05AmU.js → chunks/collector-D2ARGT2L.js} +21 -117
  8. package/dist/chunks/collector-D2ARGT2L.js.map +1 -0
  9. package/dist/chunks/config-engine-DEmmPPdp.js +272 -0
  10. package/dist/chunks/config-engine-DEmmPPdp.js.map +1 -0
  11. package/dist/chunks/css-definitions-DotDkIXt.js +876 -0
  12. package/dist/chunks/css-definitions-DotDkIXt.js.map +1 -0
  13. package/dist/chunks/css-resources-P7IBUUZs.js +142 -0
  14. package/dist/chunks/css-resources-P7IBUUZs.js.map +1 -0
  15. package/dist/chunks/debug-D8pg9RGI.js +502 -0
  16. package/dist/chunks/debug-D8pg9RGI.js.map +1 -0
  17. package/dist/chunks/dsl-DRhFWQzo.js +1378 -0
  18. package/dist/chunks/dsl-DRhFWQzo.js.map +1 -0
  19. package/dist/chunks/hydration-TRcz5vnk.js +41 -0
  20. package/dist/chunks/hydration-TRcz5vnk.js.map +1 -0
  21. package/dist/chunks/react-runtime-BpxlOTbO.js +562 -0
  22. package/dist/chunks/react-runtime-BpxlOTbO.js.map +1 -0
  23. package/dist/chunks/runtime-engine-CKFXUH-A.js +2416 -0
  24. package/dist/chunks/runtime-engine-CKFXUH-A.js.map +1 -0
  25. package/dist/chunks/runtime-state-BxaLhVdP.js +127 -0
  26. package/dist/chunks/runtime-state-BxaLhVdP.js.map +1 -0
  27. package/dist/{merge-styles-CkAcnd0Z.js → chunks/shared-utils-y-HjvPN4.js} +60 -55
  28. package/dist/chunks/shared-utils-y-HjvPN4.js.map +1 -0
  29. package/dist/chunks/style-engine-BBbBuSkT.js +5260 -0
  30. package/dist/chunks/style-engine-BBbBuSkT.js.map +1 -0
  31. package/dist/{css-writer-b02uBqNF.js → chunks/zero-engine-gJrKBRiw.js} +6 -121
  32. package/dist/chunks/zero-engine-gJrKBRiw.js.map +1 -0
  33. package/dist/{collector-BFuqdF2F.d.ts → collector-w4mBj5gd.d.ts} +23 -4
  34. package/dist/{config-DmFxnBxg.d.ts → config-CZWCzBr6.d.ts} +171 -72
  35. package/dist/core/index.d.ts +5 -5
  36. package/dist/core/index.js +9 -6
  37. package/dist/{index-D1nMkpUe.d.ts → index-G9JuwM9p.d.ts} +263 -247
  38. package/dist/index.d.ts +5 -5
  39. package/dist/index.js +11 -921
  40. package/dist/{merge-styles-Cx_6ySBv.d.ts → merge-styles-VXwJ0Frr.d.ts} +2 -3
  41. package/dist/ssr/astro-client.d.ts +1 -1
  42. package/dist/ssr/astro-client.js +1 -14
  43. package/dist/ssr/astro-client.js.map +1 -1
  44. package/dist/ssr/astro-middleware-extract-static.d.ts +10 -0
  45. package/dist/ssr/astro-middleware-extract-static.js +9 -0
  46. package/dist/ssr/astro-middleware-extract-static.js.map +1 -0
  47. package/dist/ssr/astro-middleware-extract.d.ts +10 -0
  48. package/dist/ssr/astro-middleware-extract.js +9 -0
  49. package/dist/ssr/astro-middleware-extract.js.map +1 -0
  50. package/dist/ssr/astro-middleware-static.d.ts +3 -2
  51. package/dist/ssr/astro-middleware-static.js +0 -11
  52. package/dist/ssr/astro-middleware-static.js.map +1 -1
  53. package/dist/ssr/astro-middleware.d.ts +3 -2
  54. package/dist/ssr/astro-middleware.js +0 -12
  55. package/dist/ssr/astro-middleware.js.map +1 -1
  56. package/dist/ssr/astro.d.ts +40 -6
  57. package/dist/ssr/astro.js +157 -93
  58. package/dist/ssr/astro.js.map +1 -1
  59. package/dist/ssr/index.d.ts +10 -11
  60. package/dist/ssr/index.js +4 -4
  61. package/dist/ssr/index.js.map +1 -1
  62. package/dist/ssr/next-config.d.ts +64 -0
  63. package/dist/ssr/next-config.js +128 -0
  64. package/dist/ssr/next-config.js.map +1 -0
  65. package/dist/ssr/next.d.ts +9 -8
  66. package/dist/ssr/next.js +29 -43
  67. package/dist/ssr/next.js.map +1 -1
  68. package/dist/static/index.d.ts +8 -9
  69. package/dist/static/index.js +4 -22
  70. package/dist/static/index.js.map +1 -1
  71. package/dist/static/inject.d.ts +1 -2
  72. package/dist/{index-LC3O3Jj4.d.ts → types-JEY8PVYP.d.ts} +862 -811
  73. package/dist/zero/babel.d.ts +1 -1
  74. package/dist/zero/babel.js +41 -107
  75. package/dist/zero/babel.js.map +1 -1
  76. package/dist/zero/index.d.ts +6 -7
  77. package/dist/zero/index.js +1 -1
  78. package/dist/zero/next.d.ts +3 -5
  79. package/dist/zero/next.js +0 -31
  80. package/dist/zero/next.js.map +1 -1
  81. package/docs/README.md +13 -8
  82. package/docs/adoption.md +56 -31
  83. package/docs/ai-agents.md +107 -80
  84. package/docs/comparison.md +69 -40
  85. package/docs/configuration.md +179 -114
  86. package/docs/debug.md +19 -7
  87. package/docs/design-system.md +41 -22
  88. package/docs/dsl.md +310 -201
  89. package/docs/getting-started.md +64 -41
  90. package/docs/injector.md +63 -26
  91. package/docs/methodology.md +44 -38
  92. package/docs/migration-v3.md +49 -49
  93. package/docs/pipeline.md +32 -8
  94. package/docs/plugins.md +38 -34
  95. package/docs/react-api.md +67 -44
  96. package/docs/runtime-benchmarks.md +388 -0
  97. package/docs/ssr.md +219 -61
  98. package/docs/styles.md +95 -18
  99. package/docs/tasty-static.md +138 -105
  100. package/package.json +104 -33
  101. package/dist/async-storage-DKK-wTD4.js +0 -44
  102. package/dist/async-storage-DKK-wTD4.js.map +0 -1
  103. package/dist/collector-BMT05AmU.js.map +0 -1
  104. package/dist/config-DF93rL3u.js +0 -12661
  105. package/dist/config-DF93rL3u.js.map +0 -1
  106. package/dist/context-CA8YKeMn.js +0 -24
  107. package/dist/context-CA8YKeMn.js.map +0 -1
  108. package/dist/core-B5ybnMP1.js +0 -1529
  109. package/dist/core-B5ybnMP1.js.map +0 -1
  110. package/dist/css-writer-b02uBqNF.js.map +0 -1
  111. package/dist/format-global-rules-DklyaXv-.js +0 -22
  112. package/dist/format-global-rules-DklyaXv-.js.map +0 -1
  113. package/dist/format-rules-Cs4e3M9m.js +0 -130
  114. package/dist/format-rules-Cs4e3M9m.js.map +0 -1
  115. package/dist/hydrate-aaUSNJZJ.js +0 -37
  116. package/dist/hydrate-aaUSNJZJ.js.map +0 -1
  117. package/dist/index.js.map +0 -1
  118. package/dist/keyframes-BedEzEXk.js +0 -424
  119. package/dist/keyframes-BedEzEXk.js.map +0 -1
  120. package/dist/merge-styles-CkAcnd0Z.js.map +0 -1
  121. package/dist/resolve-recipes-CiyLtJG9.js +0 -144
  122. package/dist/resolve-recipes-CiyLtJG9.js.map +0 -1
package/docs/react-api.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # React API
2
2
 
3
- The React-specific `tasty()` component factory, component props, and style functions. All Tasty style functions — `tasty()` components, `useStyles()`, `useGlobalStyles()`, `useRawCSS()`, `useKeyframes()`, `useProperty()`, `useFontFace()`, `useCounterStyle()`, and `useFunction()` — are hook-free and compatible with React Server Components. No `'use client'` directive needed. For the shared style language (state maps, tokens, units, extending semantics), see [Style DSL](dsl.md). For global configuration, see [Configuration](configuration.md). For the broader docs map, see the [Docs Hub](README.md).
3
+ Use `tasty()` to turn state-aware style definitions into normal React components, extend existing Tasty components, and expose typed styling APIs to consumers.
4
+
5
+ All Tasty style functions — `tasty()` components, `useStyles()`, `useGlobalStyles()`, `useRawCSS()`, `useKeyframes()`, `useProperty()`, `useFontFace()`, `useCounterStyle()`, and `useFunction()` — are hook-free and compatible with React Server Components. No `'use client'` directive is needed. For state maps, tokens, units, and extension semantics, see [Style DSL](dsl.md). For global configuration, see [Configuration](configuration.md). For the broader docs map, see the [Docs Hub](README.md).
4
6
 
5
7
  > **Note:** This file was previously named `runtime.md`. All functionality documented here works in both server and client contexts — "runtime" referred to style computation during React rendering, not to client-side JavaScript.
6
8
 
@@ -58,14 +60,18 @@ const Link = tasty({
58
60
 
59
61
  const Span = tasty({
60
62
  as: 'span',
61
- styles: { fontWeight: 'bold' },
63
+ styles: { preset: 'strong' },
62
64
  });
63
65
 
64
- <Link href="/blog" padding="1x">Blog</Link>;
66
+ <Link href="/blog" padding="1x">
67
+ Blog
68
+ </Link>;
65
69
  ```
66
70
 
67
71
  The wrapped component only needs to forward `className` (and ideally `style`/`ref`). Tasty-specific props (`qa`, `qaVal`, `mods`, `tokens`, `styleProps`, `modProps`, `tokenProps`) are consumed by Tasty and never leak to the DOM.
68
72
 
73
+ Props forwarded to the `as` component keep their declared types and requiredness. For example, a component with `position: 'top-right' | 'bottom-left'` accepts those values through `tasty({ as: Component })`; CSS positioning can still be set through `styles.position`. A prop explicitly listed in `styleProps` is consumed as a style value instead. The always-available base style props below are also consumed by Tasty, so keep component-specific prop names separate from those reserved names.
74
+
69
75
  ---
70
76
 
71
77
  ## Style Props
@@ -84,7 +90,7 @@ const FlexibleBox = tasty({
84
90
 
85
91
  <FlexibleBox gap="2x" align="center" fill="#surface">
86
92
  Content
87
- </FlexibleBox>
93
+ </FlexibleBox>;
88
94
  ```
89
95
 
90
96
  Style props accept state maps, so responsive values work through the same API:
@@ -113,7 +119,7 @@ import { Element } from '@tenphi/tasty';
113
119
 
114
120
  <Element as="section" padding="4x" fill="#surface">
115
121
  Content
116
- </Element>
122
+ </Element>;
117
123
  ```
118
124
 
119
125
  > Note: `Element` shadows the global DOM `Element` type when imported from `@tenphi/tasty`. In files that need the DOM type, alias the import: `import { Element as TastyElement } from '@tenphi/tasty'`.
@@ -156,9 +162,9 @@ Map modifier names to type descriptors for precise TypeScript types:
156
162
  ```tsx
157
163
  const Button = tasty({
158
164
  modProps: {
159
- isLoading: Boolean, // isLoading?: boolean
160
- isSelected: Boolean, // isSelected?: boolean
161
- size: ['small', 'medium', 'large'] as const, // size?: 'small' | 'medium' | 'large'
165
+ isLoading: Boolean, // isLoading?: boolean
166
+ isSelected: Boolean, // isSelected?: boolean
167
+ size: ['small', 'medium', 'large'] as const, // size?: 'small' | 'medium' | 'large'
162
168
  },
163
169
  styles: {
164
170
  padding: { '': '2x 4x', 'size=small': '1x 2x', 'size=large': '3x 6x' },
@@ -166,18 +172,20 @@ const Button = tasty({
166
172
  },
167
173
  });
168
174
 
169
- <Button isLoading size="large">Submit</Button>
175
+ <Button isLoading size="large">
176
+ Submit
177
+ </Button>;
170
178
  // Renders: <button data-is-loading="" data-size="large">Submit</button>
171
179
  ```
172
180
 
173
181
  Available type descriptors:
174
182
 
175
- | Descriptor | TypeScript type | Example |
176
- |---|---|---|
177
- | `Boolean` | `boolean` | `isLoading: Boolean` |
178
- | `String` | `string` | `label: String` |
179
- | `Number` | `number` | `count: Number` |
180
- | `['a', 'b'] as const` | `'a' \| 'b'` | `size: ['sm', 'md', 'lg'] as const` |
183
+ | Descriptor | TypeScript type | Example |
184
+ | --------------------- | --------------- | ----------------------------------- |
185
+ | `Boolean` | `boolean` | `isLoading: Boolean` |
186
+ | `String` | `string` | `label: String` |
187
+ | `Number` | `number` | `count: Number` |
188
+ | `['a', 'b'] as const` | `'a' \| 'b'` | `size: ['sm', 'md', 'lg'] as const` |
181
189
 
182
190
  ### Merge with `mods`
183
191
 
@@ -190,11 +198,11 @@ Mod props and the `mods` object can be used together. Mod props take precedence:
190
198
 
191
199
  ### When to use `modProps` vs `mods`
192
200
 
193
- | Use case | Recommendation |
194
- |---|---|
201
+ | Use case | Recommendation |
202
+ | -------------------------------------------- | -------------------------------------------------------- |
195
203
  | Component has a fixed set of known modifiers | `modProps` — cleaner API, better TypeScript autocomplete |
196
- | Component needs arbitrary/dynamic modifiers | `mods` — open-ended `Record<string, ModValue>` |
197
- | Both fixed and dynamic | Combine: `modProps` for known keys, `mods` for ad-hoc |
204
+ | Component needs arbitrary/dynamic modifiers | `mods` — open-ended `Record<string, ModValue>` |
205
+ | Both fixed and dynamic | Combine: `modProps` for known keys, `mods` for ad-hoc |
198
206
 
199
207
  For architecture guidance on when to use modifiers vs `styleProps`, see [Methodology — modProps and mods](methodology.md#modprops-and-mods).
200
208
 
@@ -240,7 +248,7 @@ const Card = tasty({
240
248
  styles: { padding: '$card-size', fill: '#card-accent' },
241
249
  });
242
250
 
243
- <Card size="4x" color="#purple" />
251
+ <Card size="4x" color="#purple" />;
244
252
  ```
245
253
 
246
254
  ### Merge with `tokens`
@@ -259,11 +267,11 @@ const Bar = tasty({
259
267
 
260
268
  ### When to use `tokenProps` vs `tokens`
261
269
 
262
- | Use case | Recommendation |
263
- |---|---|
264
- | Component has a fixed set of known token keys | `tokenProps` — cleaner API, better TypeScript autocomplete |
265
- | Component needs arbitrary/dynamic token values | `tokens` — open-ended `Record<string, TokenValue>` |
266
- | Both fixed and dynamic | Combine: `tokenProps` for known keys, `tokens` for ad-hoc |
270
+ | Use case | Recommendation |
271
+ | ---------------------------------------------- | ---------------------------------------------------------- |
272
+ | Component has a fixed set of known token keys | `tokenProps` — cleaner API, better TypeScript autocomplete |
273
+ | Component needs arbitrary/dynamic token values | `tokens` — open-ended `Record<string, TokenValue>` |
274
+ | Both fixed and dynamic | Combine: `tokenProps` for known keys, `tokens` for ad-hoc |
267
275
 
268
276
  For architecture guidance, see [Methodology — tokenProps](methodology.md#tokenprops).
269
277
 
@@ -282,11 +290,11 @@ const Button = tasty({
282
290
  variants: {
283
291
  default: { fill: '#blue', color: '#white' },
284
292
  danger: { fill: '#red', color: '#white' },
285
- outline: { fill: 'transparent', color: '#blue', border: '1bw solid #blue' },
293
+ outline: { fill: '#clear', color: '#blue', border: '1bw solid #blue' },
286
294
  },
287
295
  });
288
296
 
289
- <Button variant="danger">Delete</Button>
297
+ <Button variant="danger">Delete</Button>;
290
298
  ```
291
299
 
292
300
  ### Extending Variants with Base State Maps
@@ -342,7 +350,7 @@ const Card = tasty({
342
350
  <Card>
343
351
  <Card.Title>Card Title</Card.Title>
344
352
  <Card.Content>Card content</Card.Content>
345
- </Card>
353
+ </Card>;
346
354
  ```
347
355
 
348
356
  Each entry in `elements` can be a tag name string or a config object:
@@ -368,7 +376,7 @@ const Card = tasty({
368
376
 
369
377
  <Card>
370
378
  <div data-element="Title">Card Title</div>
371
- </Card>
379
+ </Card>;
372
380
  ```
373
381
 
374
382
  ### Selector Affix (`$`)
@@ -401,7 +409,7 @@ On the client, CSS is injected synchronously into the DOM (idempotent via the in
401
409
 
402
410
  All style functions below are plain functions (not React hooks) and can be used in any environment: client components, SSR with a `ServerStyleCollector`, and React Server Components. They retain their `use` prefix for backward compatibility, but do not use any React hooks internally.
403
411
 
404
- In server-only contexts (Next.js RSC without `'use client'`, Astro without `client:*` directives, SSG), components that use only Tasty style functions produce zero client JavaScript. Tasty never forces the `'use client'` boundary — that decision belongs to your component when it needs React interactivity (state, effects, event handlers).
412
+ In server-only contexts, components that use only Tasty style functions ship no Tasty styling runtime. Astro without `client:*` directives produces no client JavaScript; server-only Next.js RSC follows the same Tasty architecture, while final output depends on the application deployment. Tasty never forces the `'use client'` boundary — that decision belongs to your component when it needs React interactivity (state, effects, event handlers).
405
413
 
406
414
  ### useStyles
407
415
 
@@ -480,7 +488,7 @@ function Spinner() {
480
488
  from: { transform: 'rotate(0deg)' },
481
489
  to: { transform: 'rotate(360deg)' },
482
490
  },
483
- { name: 'spin' }
491
+ { name: 'spin' },
484
492
  );
485
493
 
486
494
  return <div style={{ animation: `${spin} 1s linear infinite` }} />;
@@ -497,10 +505,14 @@ function Pulse({ scale }: { scale: number }) {
497
505
  '100%': { transform: `scale(${scale})` },
498
506
  }),
499
507
  [scale],
500
- { name: 'pulse' }
508
+ { name: 'pulse' },
501
509
  );
502
510
 
503
- return <div style={{ animation: `${pulse} 500ms ease-in-out alternate infinite` }} />;
511
+ return (
512
+ <div
513
+ style={{ animation: `${pulse} 500ms ease-in-out alternate infinite` }}
514
+ />
515
+ );
504
516
  }
505
517
  ```
506
518
 
@@ -556,8 +568,16 @@ For multiple weights/styles, pass an array:
556
568
 
557
569
  ```tsx
558
570
  useFontFace('Brand Sans', [
559
- { src: 'url("/fonts/brand-regular.woff2") format("woff2")', fontWeight: 400, fontDisplay: 'swap' },
560
- { src: 'url("/fonts/brand-bold.woff2") format("woff2")', fontWeight: 700, fontDisplay: 'swap' },
571
+ {
572
+ src: 'url("/fonts/brand-regular.woff2") format("woff2")',
573
+ fontWeight: 400,
574
+ fontDisplay: 'swap',
575
+ },
576
+ {
577
+ src: 'url("/fonts/brand-bold.woff2") format("woff2")',
578
+ fontWeight: 700,
579
+ fontDisplay: 'swap',
580
+ },
561
581
  ]);
562
582
  ```
563
583
 
@@ -575,11 +595,14 @@ Inject a `@counter-style` rule and get back the counter style name. Permanent
575
595
  import { useCounterStyle } from '@tenphi/tasty';
576
596
 
577
597
  function EmojiList() {
578
- const styleName = useCounterStyle({
579
- system: 'cyclic',
580
- symbols: '"👍"',
581
- suffix: '" "',
582
- }, { name: 'thumbs' });
598
+ const styleName = useCounterStyle(
599
+ {
600
+ system: 'cyclic',
601
+ symbols: '"👍"',
602
+ suffix: '" "',
603
+ },
604
+ { name: 'thumbs' },
605
+ );
583
606
 
584
607
  return (
585
608
  <ol style={{ listStyleType: styleName }}>
@@ -606,7 +629,7 @@ Register a CSS `@function` (custom function). Permanent — no cleanup on unmoun
606
629
  ```tsx
607
630
  import { tasty, useFunction } from '@tenphi/tasty';
608
631
 
609
- const Box = tasty({ styles: { marginTop: '$$negative(10px)' } });
632
+ const Box = tasty({ styles: { margin: '$$negative(10px) top' } });
610
633
 
611
634
  function Layout() {
612
635
  useFunction('$$negative', { args: ['$value'], result: '(-1 * $value)' });
@@ -621,8 +644,8 @@ Inside a `tasty()` component you call functions with the same `$$name(...)` suga
621
644
  ```tsx
622
645
  const Box = tasty({
623
646
  styles: {
624
- '@function': { '$$negative': { args: ['$value'], result: '(-1 * $value)' } },
625
- marginTop: '$$negative(10px)',
647
+ '@function': { $$negative: { args: ['$value'], result: '(-1 * $value)' } },
648
+ margin: '$$negative(10px) top',
626
649
  },
627
650
  });
628
651
  ```
@@ -655,6 +678,6 @@ See the [Functions section of the DSL reference](dsl.md#functions-function) for
655
678
  - **[Methodology](methodology.md)** — Recommended patterns: root + sub-elements, styleProps, tokens, wrapping
656
679
  - **[Configuration](configuration.md)** — Tokens, recipes, custom units, style handlers, TypeScript extensions
657
680
  - **[Style Properties](styles.md)** — Complete reference for all enhanced style properties
658
- - **[Zero Runtime (tastyStatic)](tasty-static.md)** — Build-time static styling with Babel plugin
681
+ - **[Build-Time Extraction (`tastyStatic`)](tasty-static.md)** — Static styling with the Babel plugin
659
682
  - **[Server-Side Rendering](ssr.md)** — SSR setup for Next.js, Astro, and generic frameworks
660
683
  - **[Debug Utilities](debug.md)** — Inspect injected CSS, cache state, and active styles at runtime
@@ -0,0 +1,388 @@
1
+ # Runtime Benchmarks
2
+
3
+ Tasty keeps its performance claims in reproducible benchmarks rather than
4
+ combining unlike measurements into one score. The repository measures five
5
+ different costs:
6
+
7
+ 1. Style parsing and generation in Node.
8
+ 2. The React overhead of an empty `tasty({})` wrapper.
9
+ 3. Cold browser generation and injection compared with equivalent CSS that is
10
+ already on the page.
11
+ 4. The steady-state interaction path — mod flips and styled subtrees opening
12
+ and closing after the page has loaded.
13
+ 5. Page-load cold start: network, module compilation, execution and first
14
+ paint, end to end in a throttled browser.
15
+
16
+ The first four are focused microbenchmarks, not page-level scores. The fifth is
17
+ a page-level measurement and is the only one that answers "what does a visitor
18
+ wait for". Run them several times on an otherwise idle machine and use a
19
+ production profile to decide whether any cost matters in an application.
20
+
21
+ ## Reproducing the Results
22
+
23
+ ```bash
24
+ pnpm bench
25
+ pnpm bench:overhead
26
+ pnpm bench:injection
27
+ pnpm bench:interaction
28
+ pnpm bench:cold-start
29
+ ```
30
+
31
+ `pnpm bench` runs the core pipeline benchmarks in Node. The rest use production
32
+ code paths in headless Chromium. The first checkout may require
33
+ `pnpm test:setup` to download Chromium, and `pnpm bench:cold-start` needs a
34
+ current `dist/` — run `pnpm build` first.
35
+
36
+ Run the Node and browser suites separately so they do not compete for CPU. The
37
+ browser timer has 0.1 ms resolution, so the browser benchmarks perform many
38
+ matched operations per sample and divide the absolute difference by the number
39
+ of elements, rules or interactions. Machine load, browser versions, and CPU
40
+ power will move the results — on a loaded machine the absolute columns drift
41
+ several percent while the raw/Tasty delta holds, so read the delta.
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
+
56
+ ## Core Style Pipeline
57
+
58
+ Single-call throughput, three consecutive runs on an Apple M3 Pro with Node
59
+ 24.18.0 on AC power:
60
+
61
+ | Operation | ops/sec | Latency (mean) |
62
+ | ----------------------------------------------------------- | --------------------: | -------------: |
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 |
75
+
76
+ “Cold” cases use unique inputs to bypass the relevant caches. Cached cases
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.
81
+
82
+ The benchmark sources are colocated with the code they exercise:
83
+ [`pipeline.bench.ts`](../src/pipeline/pipeline.bench.ts),
84
+ [`parseStateKey.bench.ts`](../src/pipeline/parseStateKey.bench.ts), and the
85
+ parser benchmark files under [`src/parser`](../src/parser).
86
+
87
+ ## Empty Wrapper Overhead
88
+
89
+ Skipping the style pipeline does not make a `tasty()` component free. Even
90
+ `tasty({})` is a React component between its parent and the host element. React
91
+ tracks another fiber, and Tasty still processes and forwards the element's
92
+ props.
93
+
94
+ [`tasty-overhead.bench.tsx`](../src/tasty-overhead.bench.tsx) compares 10,000
95
+ raw `<div className>` siblings with 10,000 instances of one module-scoped
96
+ `tasty({})` component. Both receive the same props, and the benchmark fails if
97
+ they do not produce equivalent DOM.
98
+
99
+ The benchmark uses production React in headless Chromium. The factory is
100
+ created and its empty class-name cache is warmed before timing, so factory
101
+ creation, style generation, and injection are excluded. A detached container
102
+ excludes layout, paint, and stylesheet matching. Every commit is wrapped in
103
+ `flushSync`, keeping its synchronous reconciliation and commit inside the
104
+ sample. This does not estimate React's concurrent scheduling latency.
105
+
106
+ On an Apple M3 Pro with React 19.2.8 and Chromium 151, three consecutive runs
107
+ produced these ranges:
108
+
109
+ | Work on 10,000 siblings | Raw elements | `tasty({})` | Extra per wrapped element |
110
+ | ----------------------------------- | -----------: | ----------: | ------------------------: |
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 |
114
+
115
+ The useful result is the raw/Tasty time difference divided by 10,000, not the
116
+ ratio between the two times. The ratio becomes large because the raw baseline
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.
119
+
120
+ This is the floor Tasty consumes when it has no styling job. It is not a
121
+ page-level score. Real trees include application components, effects, layout,
122
+ paint, and usually far fewer simultaneous styled-element updates. The benchmark
123
+ also does not measure retained memory; that requires a matched-tree heap
124
+ snapshot experiment with controlled garbage collection.
125
+
126
+ ## Cold Generation and Injection
127
+
128
+ [`tasty-injection.bench.ts`](../src/tasty-injection.bench.ts) measures the extra
129
+ work when Tasty must generate and inject CSS that an otherwise equivalent page
130
+ already has. It does not compare different stylesheet insertion techniques.
131
+
132
+ The benchmark covers two useful workloads:
133
+
134
+ - **One new rule:** add one rule to an existing stylesheet, append its one
135
+ element, and immediately read its computed style. Each timed sample performs
136
+ 50 independent one-rule transactions and reports their total; dividing the
137
+ raw/Tasty difference by 50 produces a stable per-rule result despite
138
+ Chromium's 0.1 ms timer resolution.
139
+ - **1,000 new rules together:** generate and insert all 1,000 rules into one
140
+ stylesheet, append all 1,000 elements, then read every computed color without
141
+ another write in between. This gives the browser one style-resolution
142
+ boundary for the group.
143
+
144
+ For every transaction, the existing-CSS control has the equivalent stylesheet
145
+ parsed, adopted, and attached before timing. The runtime root has a Tasty
146
+ stylesheet pre-created with an unrelated sentinel rule, but not the measured
147
+ rules. Both paths perform the same class assignment, DOM commit, and
148
+ computed-style reads. Only the runtime path calls `computeStyles()` and inserts
149
+ the new rules.
150
+
151
+ Preparation and cleanup happen outside the sample timer. Every runtime style
152
+ value is unique within a cycle, the relevant caches are cleared between cycles,
153
+ and a guard verifies that both paths resolve to the same color. React and the
154
+ `tasty()` wrapper are absent so their independently measured costs do not enter
155
+ the result. Pre-creating both stylesheets also excludes one-time sheet creation
156
+ and adoption from the subtraction.
157
+
158
+ On an Apple M3 Pro with Chromium 151, three consecutive runs produced these
159
+ ranges:
160
+
161
+ | Workload | CSS already present | Tasty runtime | Incremental Tasty cost |
162
+ | ---------------------------------------------------- | ------------------: | -------------: | ---------------------: |
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 |
166
+
167
+ Directly compared, injecting 1,000 rules before one resolution boundary cost
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
170
+ times lower. This is the same Tasty generation and injection path in both cases;
171
+ the group amortizes fixed transaction work and lets the browser resolve all the
172
+ stylesheet writes together.
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
+
180
+ The subtraction is the meaningful result. It includes Tasty's cold style
181
+ generation, cache and injector bookkeeping, rule insertion, and any additional
182
+ style invalidation exposed by that workload's resolution boundary. It does not
183
+ pretend to isolate `insertRule()` from the system that calls it.
184
+
185
+ This is a deliberately cold workload. Reused styles resolve from cache and do
186
+ not inject another rule. Different rule complexity, DOM shape, stylesheet size,
187
+ browser, and hardware will change the number. The single-rule and 1,000-rule
188
+ results are not interchangeable: the first crosses the injection-to-resolution
189
+ boundary once per rule, while the second lets the browser resolve 1,000 writes
190
+ together. Because the same resolution pattern is present in each workload's
191
+ control, the difference answers the narrower delivery question: how much extra
192
+ work did Tasty perform when the same CSS was not already there?
193
+
194
+ ## Steady-State Interaction
195
+
196
+ The benchmarks above measure mounting and whole-tree updates. A running
197
+ application spends most of its time on neither. It flips mods — hovered,
198
+ pressed, selected, expanded — on elements whose styles never change, one
199
+ element at a time, and it mounts and unmounts small styled subtrees as menus
200
+ and dialogs open. Both paths go through the state-map and ref-counting
201
+ machinery rather than the parser, so a regression in them is invisible to every
202
+ other benchmark here.
203
+
204
+ [`tasty-interaction.bench.tsx`](../src/tasty-interaction.bench.tsx) pairs each
205
+ case with a raw-DOM equivalent driven by a hand-written stylesheet that
206
+ produces the same computed color and background in both states. The benchmark
207
+ fails if either arm resolves to anything else, so an arm that quietly rendered
208
+ unstyled elements cannot report a flattering number.
209
+
210
+ Each leaf owns its own `useState`, which is what keeps a single-element
211
+ interaction single: re-rendering the root to flip one row would time the whole
212
+ tree. One toggle is far below Chromium's 0.1 ms timer resolution, so a sample
213
+ flips a 100-element tree three times over — 300 commits — and the churn case
214
+ performs 20 open/close cycles. Divide the raw/Tasty difference by those counts.
215
+
216
+ Two things had to be sized deliberately, and both are the difference between a
217
+ readable number and noise:
218
+
219
+ - **The tree is small (100 elements), not large.** React locates a leaf's
220
+ pending update by walking the sibling list, so in a 1,000-element tree a
221
+ single-element update costs ~68 us of traversal — identical in both arms and
222
+ an order of magnitude above anything the styling layer contributes.
223
+ - **The sample resolves style once, not once per flip.** Forcing a recalc
224
+ between flips costs ~70 us in both arms, which buries the delta the same way.
225
+ The browser's side of an interaction is real, but it is the browser's;
226
+ resolution boundaries are what the injection benchmark above measures.
227
+
228
+ The contract check also reads the injected CSS **before** any toggle and fails
229
+ if the hovered rule is not already there. That a style map's states all ship in
230
+ one chunk on first render is the premise of this case; if the hovered rule
231
+ arrived lazily, the first sample would be timing injection.
232
+
233
+ On an Apple M3 Pro with React 19.2.8 and Chromium 151, three consecutive runs:
234
+
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 |
239
+
240
+ The absolute columns move several percent with machine load; the delta between
241
+ the arms is the stable quantity, so read that rather than either column.
242
+
243
+ Two things are worth reading out of this.
244
+
245
+ A mod flip on an already-mounted element costs about 1.2 us. The CSS for both
246
+ states already exists — Tasty emits every state of a style map in one chunk on
247
+ first render — so both arms perform the same commit, and what is left is
248
+ Tasty's props and mod handling. That is the same order as the ~0.5 us empty
249
+ wrapper measured above, which is most of where it comes from.
250
+
251
+ Subtree churn is not about styling at all. Its ~0.8 us per element sits right
252
+ on the empty-wrapper mount cost, because the styles are already cached:
253
+ reopening a menu re-pays the React wrapper, not the style pipeline.
254
+
255
+ ## Page-Load Cold Start
256
+
257
+ Every benchmark above deliberately excludes the network, module compilation and
258
+ the first render. [`scripts/cold-start`](../scripts/cold-start) measures exactly
259
+ those: what a visitor waits for between requesting a page and seeing styled
260
+ content, in a real Chromium under CDP network and CPU throttling.
261
+
262
+ Three pages render the same 50 styled components and are verified, before any
263
+ timing, to produce the same 50 elements at the same computed color:
264
+
265
+ - **baseline** — the components server-rendered: identical markup, identical
266
+ class names, a linked stylesheet, and no Tasty on the page. Every other
267
+ column is a delta against this one.
268
+ - **runtime** — Tasty generates the CSS in the browser, as a client-rendered
269
+ application does. The run asserts it really did (69 rules generated).
270
+ - **prewarm** — the same, after one throwaway `computeStyles()` against a
271
+ detached root before the first component renders.
272
+
273
+ Each cell is the median of 5 uncached loads in a fresh browser context. The run
274
+ ends at the first contentful paint, observed through a `PerformanceObserver`
275
+ rather than counted in animation frames — `requestAnimationFrame` fires before
276
+ paint, so a page that commits fast can reach its second frame with nothing
277
+ painted yet.
278
+
279
+ Two things about the payload decide whether this measures a deployment or a
280
+ straw man, so both are enforced rather than assumed:
281
+
282
+ - **Assets are served brotli-compressed**, the way a static host serves them.
283
+ The bundle is 51.5 KB on the wire and 180 KB decoded; putting the decoded
284
+ bytes on a 1.6 Mbps link would add ~700 ms and charge it to Tasty. The run
285
+ reads `encodedBodySize` back out of resource timing and fails if what
286
+ crossed the wire is not the compressed size the table reports.
287
+ - **The bundle is built from what the page imports** (`tasty`, `configure`,
288
+ `computeStyles`, `tastyDebug`), so it is tree-shaken as an application's
289
+ would be. Re-exporting the whole library adds ~4 KB brotli of code no page
290
+ here calls.
291
+
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.
307
+
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.
313
+
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.
317
+
318
+ **The cost is the bundle, not the work.** On Slow 4G the extra transfer alone
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):
321
+
322
+ | Phase (Slow 4G, 1x) | baseline | runtime | prewarm |
323
+ | ----------------------- | -------: | ------: | ------: |
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 |
331
+
332
+ Importing Tasty costs about 1 ms of top-level execution; `configure()` costs
333
+ half of one. The rest of the CPU delta — about 10 ms for 50 components — is
334
+ generation and injection, which is the cost the injection benchmark isolates.
335
+
336
+ One asymmetry is worth naming: the control links a render-blocking stylesheet
337
+ and the runtime modes have none, so the control's first paint waits for CSS the
338
+ runtime modes never request. That is the real difference between the two
339
+ delivery models, not a thumb on the scale, but it means the FCP delta is not
340
+ purely "what Tasty costs to execute".
341
+
342
+ **Prewarming moves the wake-up, it does not remove it.** The first styled render
343
+ is ~5.4 ms more expensive than the ones after it, because that is when the
344
+ engine's deferred payload is actually compiled. A throwaway `computeStyles()`
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.
350
+
351
+ **Retained heap.** After a forced collection, the runtime page holds about
352
+ 985 KB more than the control (2,605 KB vs 1,619 KB) for 50 components — the
353
+ parser caches, the chunk cache, the injector's registry and the generated CSS.
354
+ The control is not zero either; most of its 1.6 MB is React and the DOM.
355
+
356
+ CPU throttling changes which line moves. At 4x, module compilation of the
357
+ larger graph becomes visible (2.6 ms → 19 ms) where at 1x it is free: V8
358
+ pre-parses at import and compiles lazily, so a slower CPU pays for code the
359
+ faster one never fully compiled. Transfer numbers from the unthrottled cells
360
+ are not worth reading — with no emulated link, resource timings are scheduling
361
+ jitter.
362
+
363
+ ## Reading the Results Together
364
+
365
+ Do not add the microbenchmark numbers together to estimate an application
366
+ blindly. They describe different paths:
367
+
368
+ - A stable `tasty()` factory can skip the style pipeline on later renders, but
369
+ its React wrapper still participates in reconciliation.
370
+ - A cached style avoids cold parsing and generation and does not insert a new
371
+ rule.
372
+ - A genuinely new style pays generation and injection once, then becomes
373
+ reusable.
374
+ - A mod flip on an already-styled element pays neither; it is a class-name
375
+ change.
376
+ - Browser style resolution, layout, and paint depend on the actual document and
377
+ need application-level profiling.
378
+
379
+ The cold-start measurement is the one that puts the rest in proportion. On a
380
+ slow connection, nearly all of Tasty's page-load cost is transferring the
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
383
+ therefore the lever with the largest effect on first paint, and the runtime
384
+ levers matter for what happens after it.
385
+
386
+ The practical optimization target is therefore repeated work: keep style input
387
+ stable when possible, reuse generated chunks, and generate CSS at build or
388
+ server time when runtime flexibility is unnecessary.