@tenphi/tasty 3.9.11 → 3.11.0

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 (92) hide show
  1. package/README.md +399 -380
  2. package/dist/babel-types-BKDTT4--.d.ts +18 -0
  3. package/dist/chunks/async-storage-f9j9JRtq.js +37 -0
  4. package/dist/chunks/async-storage-f9j9JRtq.js.map +1 -0
  5. package/dist/chunks/build-config-5d8WlQbq.js +60 -0
  6. package/dist/chunks/build-config-5d8WlQbq.js.map +1 -0
  7. package/dist/chunks/{collector-D2ARGT2L.js → collector-CjCak4iE.js} +8 -6
  8. package/dist/chunks/collector-CjCak4iE.js.map +1 -0
  9. package/dist/chunks/{config-engine-DEmmPPdp.js → config-engine-uOFYZidB.js} +4 -4
  10. package/dist/chunks/{config-engine-DEmmPPdp.js.map → config-engine-uOFYZidB.js.map} +1 -1
  11. package/dist/chunks/config-file-DlZXJMj8.js +25 -0
  12. package/dist/chunks/config-file-DlZXJMj8.js.map +1 -0
  13. package/dist/chunks/{css-definitions-DotDkIXt.js → css-definitions-ubrHP97m.js} +130 -96
  14. package/dist/chunks/css-definitions-ubrHP97m.js.map +1 -0
  15. package/dist/chunks/{debug-D8pg9RGI.js → debug-CJ31U_WE.js} +6 -6
  16. package/dist/chunks/{debug-D8pg9RGI.js.map → debug-CJ31U_WE.js.map} +1 -1
  17. package/dist/chunks/{dsl-DRhFWQzo.js → dsl-DYSJc1-T.js} +11 -6
  18. package/dist/chunks/dsl-DYSJc1-T.js.map +1 -0
  19. package/dist/chunks/{react-runtime-BpxlOTbO.js → react-runtime-CyyCI02N.js} +190 -63
  20. package/dist/chunks/react-runtime-CyyCI02N.js.map +1 -0
  21. package/dist/chunks/{runtime-engine-CKFXUH-A.js → runtime-engine-BQmOu5-W.js} +69 -53
  22. package/dist/chunks/runtime-engine-BQmOu5-W.js.map +1 -0
  23. package/dist/chunks/{runtime-state-BxaLhVdP.js → runtime-state-DEsyUMU-.js} +28 -3
  24. package/dist/chunks/runtime-state-DEsyUMU-.js.map +1 -0
  25. package/dist/chunks/{style-engine-BBbBuSkT.js → style-engine-BX2LshDZ.js} +309 -103
  26. package/dist/chunks/style-engine-BX2LshDZ.js.map +1 -0
  27. package/dist/chunks/{zero-engine-gJrKBRiw.js → zero-engine-Dw0P4yGM.js} +62 -27
  28. package/dist/chunks/zero-engine-Dw0P4yGM.js.map +1 -0
  29. package/dist/{collector-w4mBj5gd.d.ts → collector-CTU-nl0g.d.ts} +2 -2
  30. package/dist/{config-CZWCzBr6.d.ts → config-CcxqSZI2.d.ts} +35 -16
  31. package/dist/core/index.d.ts +4 -4
  32. package/dist/core/index.js +7 -7
  33. package/dist/hydrate-CRyjjAyl.d.ts +22 -0
  34. package/dist/{index-G9JuwM9p.d.ts → index-xtey-KDu.d.ts} +43 -13
  35. package/dist/index.d.ts +4 -4
  36. package/dist/index.js +8 -8
  37. package/dist/{merge-styles-VXwJ0Frr.d.ts → merge-styles-DSkVc6CD.d.ts} +2 -2
  38. package/dist/ssr/astro.js +4 -4
  39. package/dist/ssr/client.d.ts +2 -0
  40. package/dist/ssr/client.js +2 -0
  41. package/dist/ssr/index.d.ts +3 -22
  42. package/dist/ssr/index.js +4 -4
  43. package/dist/ssr/index.js.map +1 -1
  44. package/dist/ssr/next-config.d.ts +1 -1
  45. package/dist/ssr/next-config.js +13 -18
  46. package/dist/ssr/next-config.js.map +1 -1
  47. package/dist/ssr/next.d.ts +1 -1
  48. package/dist/ssr/next.js +3 -3
  49. package/dist/static/index.d.ts +2 -2
  50. package/dist/{types-JEY8PVYP.d.ts → types-DJEM_BOJ.d.ts} +6 -6
  51. package/dist/zero/babel.d.ts +67 -2
  52. package/dist/zero/babel.js +47 -44
  53. package/dist/zero/babel.js.map +1 -1
  54. package/dist/zero/index.d.ts +58 -3
  55. package/dist/zero/index.js +24 -2
  56. package/dist/zero/index.js.map +1 -0
  57. package/dist/zero/next.d.ts +2 -1
  58. package/dist/zero/next.js +3 -7
  59. package/dist/zero/next.js.map +1 -1
  60. package/docs/README.md +4 -1
  61. package/docs/adoption.md +25 -17
  62. package/docs/ai-agents.md +37 -34
  63. package/docs/comparison.md +9 -8
  64. package/docs/configuration.md +174 -59
  65. package/docs/debug.md +3 -2
  66. package/docs/design-system.md +117 -15
  67. package/docs/dsl.md +122 -81
  68. package/docs/getting-started.md +81 -24
  69. package/docs/injector.md +39 -40
  70. package/docs/methodology.md +45 -20
  71. package/docs/migration-v3.md +2 -2
  72. package/docs/pipeline.md +20 -17
  73. package/docs/plugins.md +20 -40
  74. package/docs/react-api.md +28 -8
  75. package/docs/runtime-benchmarks.md +4 -4
  76. package/docs/ssr.md +83 -63
  77. package/docs/styles.md +27 -17
  78. package/docs/tasty-static.md +142 -55
  79. package/package.json +24 -10
  80. package/dist/babel-rZcEwPLM.d.ts +0 -82
  81. package/dist/chunks/async-storage-CG5HnUT5.js +0 -20
  82. package/dist/chunks/async-storage-CG5HnUT5.js.map +0 -1
  83. package/dist/chunks/build-config-CL4eRu2z.js +0 -42
  84. package/dist/chunks/build-config-CL4eRu2z.js.map +0 -1
  85. package/dist/chunks/collector-D2ARGT2L.js.map +0 -1
  86. package/dist/chunks/css-definitions-DotDkIXt.js.map +0 -1
  87. package/dist/chunks/dsl-DRhFWQzo.js.map +0 -1
  88. package/dist/chunks/react-runtime-BpxlOTbO.js.map +0 -1
  89. package/dist/chunks/runtime-engine-CKFXUH-A.js.map +0 -1
  90. package/dist/chunks/runtime-state-BxaLhVdP.js.map +0 -1
  91. package/dist/chunks/style-engine-BBbBuSkT.js.map +0 -1
  92. package/dist/chunks/zero-engine-gJrKBRiw.js.map +0 -1
package/docs/injector.md CHANGED
@@ -10,10 +10,10 @@ The Style Injector is the core engine behind Tasty's styling system, providing:
10
10
 
11
11
  - **Hash-based deduplication** - Identical CSS gets the same className
12
12
  - **DOM-driven lifetime** - Styles are collected once nothing renders them
13
- - **CSS nesting flattening** - Handles `&`, `.Class`, `SubElement` patterns
13
+ - **Selector assembly** - Prepends the doubled `.tX.tX` class and root prefix to the pipeline's selector fragments (sub-element and `&` nesting is flattened earlier, by the pipeline)
14
14
  - **At-rule injection** - First-class `@keyframes`, `@property`, `@font-face`, `@counter-style`, and `@function` support
15
15
  - **Smart cleanup** - CSS rules batched cleanup, keyframes disposed immediately
16
- - **SSR support** - Deterministic class names and CSS extraction
16
+ - **SSR support** - Deterministic class names shared with `ServerStyleCollector`, and hydration of server-rendered classes
17
17
  - **Multiple roots** - Works with Document and ShadowRoot
18
18
  - **Non-stacking cleanups** - Prevents timeout accumulation for better performance
19
19
 
@@ -55,13 +55,15 @@ Injects CSS rules and returns a className with dispose function.
55
55
  ```typescript
56
56
  import { inject } from '@tenphi/tasty';
57
57
 
58
- // Component styling - generates tasty class names
58
+ // Rules flagged needsClassName get the generated class prepended (doubled)
59
59
  const result = inject([{
60
- selector: '.t-abc123',
60
+ selector: '',
61
61
  declarations: 'color: red; padding: 10px;',
62
+ needsClassName: true,
62
63
  }]);
63
64
 
64
- console.log(result.className); // 't-abc123'
65
+ console.log(result.className); // 'th8n0uh' — `t` + base36 hash of the rules
66
+ // Injected: .th8n0uh.th8n0uh { color: red; padding: 10px; }
65
67
 
66
68
  // Release the pin; the class becomes collectible once nothing renders it
67
69
  result.dispose();
@@ -175,7 +177,7 @@ const result = testInjector.inject(rules);
175
177
  Injects CSS keyframes with automatic deduplication.
176
178
 
177
179
  ```typescript
178
- // Generated name (k0, k1, k2...)
180
+ // Generated name (tk0, tk1, … — namePrefix + 'k' + counter)
179
181
  const fadeIn = keyframes({
180
182
  from: { opacity: 0 },
181
183
  to: { opacity: 1 },
@@ -253,6 +255,7 @@ Dispose, ref-counted cleanup and GC therefore behave identically in every mode.
253
255
  - **Configuration is locked after styles are generated** - calling `configure()` after first render will emit a warning and be ignored
254
256
  - `gc.touchInterval`: Number of renders between sweeps. When the counter reaches this value, a sweep is scheduled via `requestIdleCallback`; without idle callbacks nothing is collected automatically.
255
257
  - `gc.grace`: How long a class is left alone after collection first notices nothing is carrying it, in milliseconds (default `10000`).
258
+ - Automatic sweeps are opt-in: they run only when `configure({ gc })` is set (`gc: {}` enables them with these defaults). Without it nothing is collected until you call `gc()` or `cleanup()`.
256
259
 
257
260
  **What a sweep does.** Everything the injector holds falls into one of five bands, and only the last is ever deleted:
258
261
 
@@ -289,7 +292,7 @@ Beyond ordinary rules, the injector has a dedicated entry point per at-rule. All
289
292
 
290
293
  Every one of these takes an optional `root` (a `Document` or `ShadowRoot`).
291
294
 
292
- `func()` also accepts `weak: true`, which registers the rule **without** overriding an existing one of the same name. `configure()` uses it for global `@function` definitions so a component-local definition always wins over the global default, regardless of which is injected first.
295
+ `counterStyle()` and `func()` also accept `weak: true`, which registers the rule **without** overriding an existing one of the same name. `configure()` uses it for global `@counter-style` and `@function` definitions so a component-local definition always wins over the global default, regardless of which is injected first.
293
296
 
294
297
  ```ts
295
298
  import { counterStyle, fontFace, func, property } from '@tenphi/tasty';
@@ -306,22 +309,24 @@ func('$$negative', { args: ['$value'], result: '(-1 * $value)' });
306
309
 
307
310
  ### Style Result Format
308
311
 
309
- The injector works with `StyleResult` objects from the tasty parser:
312
+ The injector works with `StyleResult` objects from the pipeline (`renderStyles`):
310
313
 
311
314
  ```typescript
312
315
  interface StyleResult {
313
- selector: string; // CSS selector
314
- declarations: string; // CSS declarations
315
- atRules?: string[]; // @media, @supports, etc.
316
- nestingLevel?: number; // Nesting depth for specificity
316
+ selector: string; // full selector, or a fragment when needsClassName
317
+ declarations: string; // CSS declarations
318
+ atRules?: string[]; // @media, @container, @supports, …
319
+ needsClassName?: boolean; // prepend the generated `.tX.tX` (OR'd fragments joined with '|||')
320
+ rootPrefix?: string; // root-state prefix placed before the class
321
+ startingStyle?: boolean; // wrap declarations in @starting-style
317
322
  }
318
323
 
319
324
  // Example StyleResult
320
325
  const styleRule: StyleResult = {
321
- selector: '.t-button',
326
+ selector: ':where([data-hovered])',
322
327
  declarations: 'padding: 8px 16px; background: blue; color: white;',
323
328
  atRules: ['@media (min-width: 768px)'],
324
- nestingLevel: 0,
329
+ needsClassName: true,
325
330
  };
326
331
  ```
327
332
 
@@ -339,28 +344,24 @@ API here calls it for you.
339
344
  ### Deduplication & Performance
340
345
 
341
346
  ```typescript
342
- // Identical CSS rules get the same className
343
- const button1 = inject([{
344
- selector: '.t-btn1',
345
- declarations: 'padding: 8px; color: red;'
346
- }]);
347
+ const rule = { selector: '', declarations: 'padding: 8px; color: red;', needsClassName: true };
347
348
 
348
- const button2 = inject([{
349
- selector: '.t-btn2',
350
- declarations: 'padding: 8px; color: red;' // Same declarations
351
- }]);
349
+ const button1 = inject([rule], { cacheKey: 'button' });
350
+ const button2 = inject([rule], { cacheKey: 'button' });
352
351
 
353
- // Both get the same className due to deduplication
352
+ // One class, one copy of the CSS
354
353
  console.log(button1.className === button2.className); // true
355
354
  ```
356
355
 
356
+ Without `cacheKey` the class name still matches (it hashes the rules), but the rule is inserted again and the pin count restarts at 1.
357
+
357
358
  ### Pinning
358
359
 
359
360
  ```typescript
360
361
  // Multiple callers using the same styles
361
- const comp1 = inject([commonStyle]);
362
- const comp2 = inject([commonStyle]);
363
- const comp3 = inject([commonStyle]);
362
+ const comp1 = inject([commonStyle], { cacheKey: 'common' });
363
+ const comp2 = inject([commonStyle], { cacheKey: 'common' });
364
+ const comp3 = inject([commonStyle], { cacheKey: 'common' });
364
365
 
365
366
  // Style is pinned while any caller holds a handle
366
367
  comp1.dispose(); // pins: 3 → 2
@@ -439,15 +440,16 @@ const shadowAnimation = keyframes({
439
440
 
440
441
  ### Server-Side Rendering
441
442
 
443
+ `getCSSText()` reads the injector's stylesheets, so it needs a DOM; on a server it throws. Collect server CSS with `ServerStyleCollector` from `@tenphi/tasty/ssr` (`collector.getCSS()`) — see [SSR](./ssr.md).
444
+
442
445
  ```typescript
443
446
  import { getCSSText, getCSSTextForNode } from '@tenphi/tasty';
447
+ import { render } from '@testing-library/react';
444
448
 
445
- // Extract all CSS for SSR
446
- const cssText = getCSSText();
449
+ const cssText = getCSSText(); // everything the injector holds (browser / test DOM)
447
450
 
448
- // Extract CSS for specific DOM subtree (like jest-styled-components)
449
- const container = render(<MyComponent />);
450
- const componentCSS = getCSSTextForNode(container);
451
+ const { container } = render(<MyComponent />);
452
+ const componentCSS = getCSSTextForNode(container); // only the CSS this subtree uses
451
453
  ```
452
454
 
453
455
  ### Test Environment Detection
@@ -520,17 +522,14 @@ console.log({
520
522
  ### Debug Information
521
523
 
522
524
  ```typescript
523
- // Get detailed information about injected styles
524
- const debugInfo = injector.instance.getDebugInfo();
525
+ import { tastyDebug } from '@tenphi/tasty';
525
526
 
526
- console.log({
527
- activeStyles: debugInfo.activeStyles, // Currently active styles
528
- unusedStyles: debugInfo.unusedStyles, // Styles marked for cleanup
529
- totalSheets: debugInfo.totalSheets, // Number of stylesheets
530
- totalRules: debugInfo.totalRules, // Total CSS rules
531
- });
527
+ const { activeClasses, unusedClasses, hotClasses, totalRuleCount } =
528
+ tastyDebug.summary({ raw: true });
532
529
  ```
533
530
 
531
+ See [Debug Utilities](./debug.md).
532
+
534
533
  ### Cleanup History
535
534
 
536
535
  ```typescript
@@ -119,16 +119,17 @@ Style props accept state maps, so responsive values work through the same API:
119
119
 
120
120
  Tasty exports predefined style prop lists that group properties by role. Use them instead of hand-picking arrays:
121
121
 
122
- | Preset | Properties | Typical use |
123
- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------- |
124
- | `FLOW_STYLES` | flow, gap, columnGap, rowGap, align, justify, placeItems, placeContent, alignItems, alignContent, justifyItems, justifyContent, gridColumns, gridRows, gridTemplate, gridAreas | Layout containers (`Space`, `Grid`) |
125
- | `POSITION_STYLES` | gridArea, gridColumn, gridRow, order, placeSelf, alignSelf, justifySelf, zIndex, physical/logical margin, inset, scroll margin/padding, position | Positioned elements (`Button`, `Badge`) |
126
- | `DIMENSION_STYLES` | width, height, blockSize, inlineSize, flexBasis, flexGrow, flexShrink, flex | Sized elements |
127
- | `COLOR_STYLES` | color, fill, fade, image | Color-customizable elements |
128
- | `BLOCK_STYLES` | padding, blockPadding, inlinePadding, overflow, scrollbar, textAlign, border, blockBorder, inlineBorder, radius, shadow, outline | Block-level containers |
129
- | `CONTAINER_STYLES` | All of the above combined (+ BASE_STYLES) | Fully flexible containers |
130
- | `OUTER_STYLES` | POSITION_STYLES + DIMENSION_STYLES + block outer (border, radius, shadow, outline) | Components whose outer shell is customizable |
131
- | `INNER_STYLES` | BASE_STYLES + COLOR_STYLES + block inner (padding, overflow, scrollbar) + FLOW_STYLES | Components whose inner layout is customizable |
122
+ | Preset | Properties | Typical use |
123
+ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
124
+ | `FLOW_STYLES` | flow, gap, columnGap, rowGap, align, justify, place, placeItems, placeContent, alignItems, alignContent, justifyItems, justifyContent, gridColumns, gridRows, gridTemplate, gridAreas | Layout containers (`Space`, `Grid`) |
125
+ | `POSITION_STYLES` | gridArea, gridColumn, gridRow, order, placeSelf, alignSelf, justifySelf, zIndex, physical/logical margin, inset, scroll margin/padding, position | Positioned elements (`Button`, `Badge`) |
126
+ | `DIMENSION_STYLES` | width, height, blockSize, inlineSize, flexBasis, flexGrow, flexShrink, flex | Sized elements |
127
+ | `COLOR_STYLES` | color, fill, fade, image | Color-customizable elements |
128
+ | `BLOCK_STYLES` | padding, blockPadding, inlinePadding, overflow, scrollbar, textAlign, border, blockBorder, inlineBorder, radius, shadow, outline | Block-level containers |
129
+ | `TEXT_STYLES` | textTransform, fontWeight, fontStyle | Text elements |
130
+ | `CONTAINER_STYLES` | All of the above combined (+ BASE_STYLES) | Fully flexible containers |
131
+ | `OUTER_STYLES` | POSITION_STYLES + DIMENSION_STYLES + block outer (border, radius, shadow, outline) | Components whose outer shell is customizable |
132
+ | `INNER_STYLES` | BASE_STYLES + COLOR_STYLES + block inner (padding, overflow, scrollbar) + FLOW_STYLES | Components whose inner layout is customizable |
132
133
 
133
134
  ```tsx
134
135
  import { tasty, FLOW_STYLES, POSITION_STYLES } from '@tenphi/tasty';
@@ -156,7 +157,10 @@ Match the preset to the component's role:
156
157
 
157
158
  - **Layout containers** (`Space`, `Box`, `Grid`) — `FLOW_STYLES`, optionally with `DIMENSION_STYLES`
158
159
  - **Positioned elements** (`Button`, `Badge`) — `POSITION_STYLES`
159
- - **Text elements** — custom: `['preset', 'color']`
160
+ - **Text elements** — `['color', ...TEXT_STYLES]`
161
+
162
+ `BASE_STYLES` (`display`, `font`, `preset`, `hide`, `whiteSpace`, `opacity`, `transition`) are accepted as props on every component without being listed; extend that set app-wide with [`configure({ baseStyleProps })`](configuration.md#base-style-props).
163
+
160
164
  - **Compound components** — typically none; styling happens via sub-elements and extension
161
165
 
162
166
  ### The governance trade-off
@@ -220,6 +224,26 @@ modProps: ['isLoading', 'isSelected'] as const,
220
224
 
221
225
  For the full API reference, see [React API — Mod Props](react-api.md#mod-props).
222
226
 
227
+ ### variants vs modProps
228
+
229
+ Use `variants` for looks that replace each other (primary, danger, ghost) and `modProps` for states that combine (`isLoading`, `size`):
230
+
231
+ ```tsx
232
+ const Button = tasty({
233
+ as: 'button',
234
+ modProps: { isLoading: Boolean, size: ['sm', 'md', 'lg'] as const },
235
+ styles: { padding: { '': '1.5x 3x', 'size=sm': '1x 2x' }, radius: '1r' },
236
+ variants: {
237
+ default: { fill: '#primary', color: '#on-primary' },
238
+ danger: { fill: '#danger', color: '#on-danger' },
239
+ },
240
+ });
241
+
242
+ <Button variant="danger" size="sm" isLoading />;
243
+ ```
244
+
245
+ `variant` is typed from the variant keys, the `default` variant applies when no `variant` prop is passed, and only variants that render emit CSS. See [React API — Variants](react-api.md#variants).
246
+
223
247
  ---
224
248
 
225
249
  ## tokens prop for dynamic values
@@ -335,10 +359,9 @@ const LargeCard = tasty(Card, {
335
359
 
336
360
  Why? Styled wrappers are:
337
361
 
338
- - **Faster** — styles are parsed and injected once at definition time, not on every render
339
- - **Stable** — the style object is defined once, not recreated on every render
362
+ - **Stable** — the style object is defined once at module level, not recreated on every render
340
363
  - **Composable** — another engineer can extend `LargeCard` further
341
- - **Inspectable** — the component has a name in React DevTools
364
+ - **Inspectable** — give it a `qa` (`tasty(Card, { qa: 'LargeCard', styles })`) and it appears as `TastyWrappedComponent(LargeCard)` in React DevTools and as `data-qa="LargeCard"` in the DOM
342
365
  - **Lint-friendly** — the ESLint plugin's `no-styles-prop` rule flags direct usage
343
366
 
344
367
  The `styles` prop exists as an escape hatch — for prototyping, one-off overrides during development, or cases where wrapping is impractical. It should not be the default way product engineers customize components.
@@ -453,11 +476,13 @@ const DangerCard = tasty(Card, {
453
476
 
454
477
  This is preferred over `<Card styles={{ border: '1bw solid #danger' }}>` because:
455
478
 
456
- 1. Styles are parsed and injected once, not on every render
479
+ 1. The styles live in one module-level definition instead of being rebuilt inside render
457
480
  2. `DangerCard` can be extended further by others
458
- 3. It has a meaningful name in DevTools and code search
481
+ 3. It is searchable in code, and named in DevTools once you give it a `qa`
459
482
  4. The ESLint `no-styles-prop` rule encourages this pattern
460
483
 
484
+ A wrapper does not re-expose the base's sub-element components: `DangerCard.Title` is `undefined` and not part of its type. Keep using the base's parts inside it — `<DangerCard><Card.Title>…</Card.Title></DangerCard>`. The wrapper's `Title` styles still apply, because they target `[data-element="Title"]`.
485
+
461
486
  ---
462
487
 
463
488
  ## How configuration simplifies components
@@ -554,7 +579,7 @@ fill: 'oklch(55% 0.25 265)',
554
579
  fill: '#primary',
555
580
  ```
556
581
 
557
- Tokens ensure consistency across components and make theme changes a one-line update.
582
+ Tokens ensure consistency across components and make theme changes a one-line update. The recommended lint config flags this with `tasty/no-raw-color-values` and `tasty/consistent-token-usage`.
558
583
 
559
584
  ### Using CSS property names when Tasty alternatives exist
560
585
 
@@ -570,7 +595,7 @@ radius: '1r',
570
595
  flow: 'column',
571
596
  ```
572
597
 
573
- Tasty's enhanced properties provide concise syntax, better composability, and simpler overrides. See [recommended props](styles.md#recommended-props) for the full mapping.
598
+ Tasty's enhanced properties provide concise syntax, better composability, and simpler overrides. See [recommended props](styles.md#recommended-props) for the full mapping. `tasty/prefer-shorthand-property` flags the native spellings.
574
599
 
575
600
  ### Changing styles prop at runtime
576
601
 
@@ -593,7 +618,7 @@ const Card = tasty({
593
618
  });
594
619
  ```
595
620
 
596
- Modifiers are compiled into exclusive selectors once. Changing `styles` at runtime forces Tasty to regenerate and re-inject CSS.
621
+ Modifiers are compiled into exclusive selectors once. Changing `styles` at runtime forces Tasty to regenerate and re-inject CSS. `tasty/no-styles-prop` and `tasty/no-runtime-styles-mutation` flag these patterns.
597
622
 
598
623
  ### Overusing style prop
599
624
 
@@ -607,7 +632,7 @@ const DangerButton = tasty(Button, {
607
632
  });
608
633
  ```
609
634
 
610
- The `style` prop bypasses tokens, units, and state maps. It should only be used for third-party library integration.
635
+ The `style` prop bypasses tokens, units, and state maps. It should only be used for third-party library integration. `tasty/no-style-prop` flags it on Tasty components.
611
636
 
612
637
  ### Skipping elements for compound components
613
638
 
@@ -188,8 +188,8 @@ A key whose prefix doesn't match its value type is ignored with a development wa
188
188
  ## 7. Moved subpath
189
189
 
190
190
  ```diff
191
- - import { withTasty } from '@tenphi/tasty/next';
192
- + import { withTasty } from '@tenphi/tasty/zero/next';
191
+ - import { withTastyZero } from '@tenphi/tasty/next';
192
+ + import { withTastyZero } from '@tenphi/tasty/zero/next';
193
193
  ```
194
194
 
195
195
  The build-time extraction Next.js wrapper already lived under the `zero` namespace internally; the top-level `/next` path collided confusingly with `@tenphi/tasty/ssr/next`, which is a different integration.
package/docs/pipeline.md CHANGED
@@ -76,7 +76,7 @@ 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. The `order` hint is the highest source priority among the entries that produced each rule (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.
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
 
@@ -197,7 +197,9 @@ Merging two same-value entries with conditions `C_h` (higher priority) and `C_l`
197
197
 
198
198
  The merge is safe iff for every entry `e_m` strictly between them in priority with a different value,
199
199
 
200
- simplify(C_m & C_l & !C_h) = FALSE
200
+ ```
201
+ simplify(C_m & C_l & !C_h) = FALSE
202
+ ```
201
203
 
202
204
  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.
203
205
 
@@ -362,7 +364,7 @@ This is the companion to **Stage 2a** (user-OR expansion). The split exists beca
362
364
 
363
365
  | Stage | Runs on | Sees ORs from | Sorts branches? |
364
366
  |---|---|---|---|
365
- | 2a `expandOrConditions` | `ParsedStyleEntry.condition` | User-authored `|` in state keys | No — user order is stable |
367
+ | 2a `expandOrConditions` | `ParsedStyleEntry.condition` | User-authored `\|` in state keys | No — user order is stable |
366
368
  | 3 `expandExclusiveOrs` | `ExclusiveStyleEntry.exclusiveCondition` | De Morgan negation inside `buildExclusiveConditions` | Yes — at-rule branches first |
367
369
 
368
370
  ### How It Works
@@ -522,7 +524,7 @@ Converts condition trees into actual CSS selectors and at-rules.
522
524
  - the flat root modifiers + pseudos and the root element's `:is()`/`:not()` groups are combined into one `:where(...)` (`buildSelectorFromVariant`);
523
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;
524
526
  - `@parent` ancestors (`parentGroupsToCSS`) become `:where(... *)` / `:where(:not(... *))`;
525
- - the `@root` context prefix (`rootGroupsToCSS`) becomes `:where(:root...)` (zeroing the `:root` pseudo too).
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.
526
528
 
527
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.
528
530
 
@@ -545,12 +547,13 @@ interface CSSRule {
545
547
  declarations: string; // CSS declarations (e.g. 'color: red;')
546
548
  atRules?: string[]; // Wrapping at-rules
547
549
  rootPrefix?: string; // Root state prefix
550
+ rootState?: string; // Root-state segment without its :root anchor; lets a root selector take states directly
548
551
  startingStyle?: boolean; // Wrap declarations in @starting-style
549
552
  order?: number; // Internal cascade order hint (ascending source priority); stripped before injection
550
553
  }
551
554
  ```
552
555
 
553
- When `renderStyles` runs **without** a selector, returned rules include `needsClassName: true` (compatibility field for the injector); that flag is not part of `CSSRule` inside `materialize.ts`.
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`).
554
557
 
555
558
  ---
556
559
 
@@ -618,7 +621,7 @@ Applies boolean algebra rules to reduce condition complexity and detect impossib
618
621
  - `A & (A | B) = A`
619
622
  - `A | (A & B) = A`
620
623
 
621
- 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.
622
625
 
623
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).
624
627
 
@@ -642,7 +645,7 @@ LRU and small auxiliary caches:
642
645
  | Cache | Size | Key | Purpose |
643
646
  |-------|------|-----|---------|
644
647
  | `pipelineCache` | 5000 | `pipelineCacheKey \|\| stringifyStyles(styles)` | Skip full pipeline for identical styles |
645
- | `parseCache` | 5000 | `trimmedStateKey + '\\0' + isSubElement + '\\0' + JSON.stringify(localPredefinedStates)` | Skip re-parsing identical state keys in context |
648
+ | `parseCache` | 5000 | `trimmedStateKey + '\0' + (isSubElement ? '1' : '0') + '\0' + (localPredefinedStates non-empty ? JSON.stringify(localPredefinedStates) : '')` | Skip re-parsing identical state keys in context |
646
649
  | `simplifyCache` | 5000 | `getConditionUniqueId(node)` | Skip re-simplifying identical conditions |
647
650
  | `conditionCache` | 3000 | `getConditionUniqueId(node)` in `conditionToCSS` | Skip re-materializing identical conditions |
648
651
  | `variantKeyCache` | — | `WeakMap<SelectorVariant, string>` | Stable string keys for variants during materialization |
@@ -702,24 +705,24 @@ Each snapshot yields distinct declarations; no merge.
702
705
 
703
706
  ### Stage 7: Materialize CSS
704
707
 
705
- Using `renderStyles(styles, '.t1')` (the class is doubled — `.t1.t1` — so Tasty owns specificity without `!important`):
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:
706
709
 
707
710
  ```css
708
- .t1[data-hovered] {
709
- color: var(--highlight-color);
710
- --current-color: var(--highlight-color);
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
+ }
711
716
  }
712
717
  @media (prefers-color-scheme: dark) {
713
- .t1:not([data-hovered]) {
718
+ .t1.t1:where(:not([data-hovered])) {
714
719
  color: var(--dark-color);
715
720
  --current-color: var(--dark-color);
716
721
  }
717
722
  }
718
- @media (not (prefers-color-scheme: dark)) {
719
- .t1:not([data-hovered]) {
720
- color: var(--white-color);
721
- --current-color: var(--white-color);
722
- }
723
+ .t1.t1:where([data-hovered]) {
724
+ color: var(--highlight-color);
725
+ --current-color: var(--highlight-color);
723
726
  }
724
727
  ```
725
728
 
package/docs/plugins.md CHANGED
@@ -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** for `tokens` and `globalStyles` (per selector). See [Configuration](configuration.md) for the current per-key semantics.
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
 
@@ -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
- return { ...rest, styles: mergeStyles(glazeStyles(glaze), rest.styles) };
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 be real style properties, must start with a lowercase letter, and must not collide with a prop `tasty()` consumes itself (`as`, `styles`, `variant`, `mods`, `tokens`, …); invalid entries are dropped with a development warning.
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
- Four augmentation points, each matching a runtime registry:
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
- `TastyCustomProps` keys become optional props on every component. `TastyBaseStylePropNames` entries are typed exactly like the style they name, so `radius="1r"` accepts the same values as `styles={{ radius: '1r' }}`.
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 { configure, resetConfig, renderStyles } from '@tenphi/tasty';
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('expands into a color token declaration', () => {
337
- configure({ plugins: [glazePlugin()] });
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
- const rules = renderStyles({ '#glaze-bg': '#purple.10' }, '.test');
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 the per-factory class-name cache or prop-list memo, so a module-scope component created in one test carries state into the next.
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
  ---