Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/p2-10-true-atomic-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'typestyles': minor
---

Rename hash-only class naming mode to `compact` and implement true per-declaration `atomic` output with cross-component dedup (P2.10). `styles.class`, `styles.component`, and `styles.hashClass` in `atomic` mode now emit one class per CSS declaration; identical declarations share a class.
11 changes: 5 additions & 6 deletions IMPROVEMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ Bugs and credibility issues that lose evaluations on contact. Do these first.
cross-link from best-practices/performance pages (which currently just say
"use inline styles").

- [ ] **P1.9 — Streaming SSR story** (PR: #94)
- [x] **P1.9 — Streaming SSR story** (PR: #94)
- Document streaming SSR (`renderToPipeableStream`) and RSC patterns against the
request-scoped collection from P0.4; add helpers where they remove boilerplate.
- Shipped: expanded [SSR guide](/docs/ssr) (request-safe collection, RSC decision
Expand All @@ -92,11 +92,10 @@ Bugs and credibility issues that lose evaluations on contact. Do these first.

## P2 — Ecosystem & DX

- [ ] **P2.10 — True atomic output** (PR: )
- Current "atomic" naming mode is whole-object hashing, not atomic CSS. Without
per-declaration dedup, CSS grows linearly with the codebase while
StyleX/Tailwind plateau. Ship per-property atomic decomposition; rename the
current mode honestly.
- [x] **P2.10 — True atomic output** (PR: #95)
- [x] Rename hash-only `atomic` mode to `compact` (honest naming for whole-object hashes).
- [x] Ship `atomic` mode with per-declaration decomposition and dedup across components.
- [x] Docs + changeset; open PR.

- [ ] **P2.11 — ESLint plugin MVP** (PR: )
- `@typestyles/eslint-plugin` with first rules: shorthand/longhand conflict
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,7 @@ High-level tradeoffs (details and nuance: [docs — framework comparison](./docs
| Zero-runtime path | Yes (opt-in build plugins) | Compiler default | Varies | Build output | Always | Always | Build output |
| SSR support | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Runtime overhead | Minimal (off when extracted) | None | Moderate | Low–none | None | None | None |
| Main entry size (gzip) | ~14.9 KB | N/A (compile-time) | ~12 KB+ (varies) | Build output | N/A | N/A | N/A |
| Main entry size (gzip) | ~15.3 KB | N/A (compile-time) | ~12 KB+ (varies) | Build output | N/A | N/A | N/A |

Color helpers (`rgb`, `oklch`, `mix`, …) live on `typestyles/color` so the common import path stays smaller. CI enforces a gzip budget on `dist/index.js`.

Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ per package or micro-frontend for isolation.

### `createStyles(options?)`

Returns a new style API (same shape as `styles`) with its own class naming config. Pass `Partial<ClassNamingConfig>`: `mode` (`'semantic' | 'hashed' | 'atomic'`), `prefix`, `scopeId`. Optionally pass **`utils`** — a map of shorthand expanders — to get a utility-aware API in one step (same typing as `styles.withUtils(…)`; see [Styles](/docs/styles#utility-shortcuts)). Optionally pass **`layers`** (tuple or `{ order, prependFrameworkLayers? }`) to enable **`@layer`** output; then every **`class`**, **`hashClass`**, and **`component`** call must include a third argument **`{ layer: '…' }`** (see [Cascade layers](/docs/cascade-layers)).
Returns a new style API (same shape as `styles`) with its own class naming config. Pass `Partial<ClassNamingConfig>`: `mode` (`'semantic' | 'hashed' | 'compact' | 'atomic'`), `prefix`, `scopeId`. Optionally pass **`utils`** — a map of shorthand expanders — to get a utility-aware API in one step (same typing as `styles.withUtils(…)`; see [Styles](/docs/styles#utility-shortcuts)). Optionally pass **`layers`** (tuple or `{ order, prependFrameworkLayers? }`) to enable **`@layer`** output; then every **`class`**, **`hashClass`**, and **`component`** call must include a third argument **`{ layer: '…' }`** (see [Cascade layers](/docs/cascade-layers)).

The default `import { styles } from 'typestyles'` is `createStyles()` with default options.

Expand Down
40 changes: 27 additions & 13 deletions docs/content/docs/class-naming.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: Class naming
description: Per-instance semantic, hashed, or atomic class names via createStyles; scoped tokens via createTokens
---

By default, typestyles emits **readable semantic** class names: `button-base`, `card-elevated`, `button-intent-primary`. You can switch to **hashed** or **hash-only** names for smaller strings, fewer accidental collisions across packages, or closer parity with CSS-in-JS tools that minify class names.
By default, typestyles emits **readable semantic** class names: `button-base`, `card-elevated`, `button-intent-primary`. You can switch to **hashed**, **compact** (hash-only whole-object), or **atomic** (one class per declaration) names for smaller strings, deduped CSS, or closer parity with CSS-in-JS tools that minify class names.

Naming applies to:

Expand Down Expand Up @@ -46,12 +46,12 @@ For **CSS cascade layers** (`@layer`) — optional, and off by default — see [

Returns a style API with the same methods as the default `styles` export. Options are a partial **`ClassNamingConfig`** merged onto defaults:

| Option | Type | Default | Description |
| --------- | ----------------------------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mode` | `'semantic' \| 'hashed' \| 'atomic'` | `'semantic'` | How class strings are built (see below). |
| `prefix` | `string` | `'ts'` | Leading segment for hashed/atomic output and for `hashClass`. |
| `scopeId` | `string` | `''` | Optional id (package name, app name) so two packages can reuse the same logical namespace without sharing the same class string. In `semantic` mode the sanitized scope is prefixed onto class names; in `hashed`/`atomic` mode it is mixed into the hash input. |
| `layers` | `readonly string[]` or `{ order, prependFrameworkLayers? }` | _(omitted)_ | When set, enables `@layer` output and requires `{ layer }` on each `class` / `hashClass` / `component` call. See [Cascade layers](/docs/cascade-layers). |
| Option | Type | Default | Description |
| --------- | ----------------------------------------------------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mode` | `'semantic' \| 'hashed' \| 'compact' \| 'atomic'` | `'semantic'` | How class strings are built (see below). |
| `prefix` | `string` | `'ts'` | Leading segment for hashed/compact/atomic output and for `hashClass`. |
| `scopeId` | `string` | `''` | Optional id (package name, app name) so two packages can reuse the same logical namespace without sharing the same class string. In `semantic` mode the sanitized scope is prefixed onto class names; in `hashed`/`compact`/`atomic` mode it is mixed into the hash input. |
| `layers` | `readonly string[]` or `{ order, prependFrameworkLayers? }` | _(omitted)_ | When set, enables `@layer` output and requires `{ layer }` on each `class` / `hashClass` / `component` call. See [Cascade layers](/docs/cascade-layers). |

The instance also exposes **`styles.classNaming`**: a read-only snapshot of the resolved config (useful for debugging).

Expand All @@ -78,27 +78,41 @@ With **`scopeId`** set, the sanitized scope is prefixed onto every class name
- `createStyles({ scopeId: 'my-ui' })` + `styles.component('button', { … })` → `my-ui-button-base`
- `createStyles({ scopeId: '@acme/ds' })` + `styles.class('card', { … })` → `acme-ds-card`

This keeps semantic names readable while making isolation real: two packages can both register `styles.component('button', …)` without their CSS rules overwriting each other. In development, typestyles also logs an error if two different definitions ever emit the same class string (cross-scope collisions, or hash collisions in `hashed`/`atomic` mode).
This keeps semantic names readable while making isolation real: two packages can both register `styles.component('button', …)` without their CSS rules overwriting each other. In development, typestyles also logs an error if two different definitions ever emit the same class string (cross-scope collisions, or hash collisions in `hashed`/`compact` mode).

### `hashed`

Deterministic names of the form **`{prefix}-{namespace-slug}-{hash}`**. The hash is computed from (when set) `scopeId`, the namespace, a variant segment (e.g. `base`, `intent-primary`, `root-compound-0`), and the serialized style object for that rule. Identical definitions produce identical class strings.

Use this when you want shorter, scoped names while still recognizing the namespace in DevTools.

### `compact`

**`{prefix}-{hash}`** only—no namespace slug in the string. Same hash inputs as `hashed`, so behavior is equally deterministic. Each component rule is still **one class per style chunk** (base, variant option, compound rule, etc.).

Use this when you want the shortest hash-only class strings without per-declaration splitting.

### `atomic`

**`{prefix}-{hash}`** only—no namespace slug in the string. Same hash inputs as `hashed`, so behavior is equally deterministic.
**One class per CSS declaration.** Identical property values share a class across the codebase—CSS size plateaus as you add components instead of growing linearly with every rule chunk.

- `styles.class('card', { padding: '1rem', color: 'red' })` → two classes joined with a space
- `styles.component('button', { base: { color: 'red', padding: '8px' } })` → `button.base` is a space-separated list of atomic classes
- Nested selectors (`&:hover`, attribute selectors) and `@media` blocks decompose the same way; each inner declaration gets its own class

Hash inputs include (when set) `scopeId`, the declaration path (property + nested context), and the value. For Tailwind-style **utility prop APIs**, see [`@typestyles/props`](/docs/atomic-css).

#### Migrating from the old `atomic` name

This mode is a **prototype** for hash-only ergonomics: each component rule is still **one class per chunk of CSS** (the same as today), not one utility class per CSS declaration. True per-property atomic output is a separate roadmap area; for Tailwind-style utilities, use [`@typestyles/props`](/docs/atomic-css).
Before P2.10, `atomic` meant hash-only **whole-object** classes (no namespace slug). That mode is now **`compact`**. If you were using `mode: 'atomic'` for short hash-only class strings, switch to **`mode: 'compact'`**. Use **`mode: 'atomic'`** when you want true per-declaration output and dedup.

## `styles.hashClass`

`hashClass` on a given instance uses that instance’s **`prefix`** and **`scopeId`**. If **`scopeId`** is empty, the hash input matches the historical behavior (properties only, plus label handling) for the same style shape.

## Monorepos and `scopeId`

Two packages might both use `styles.component('button', …)`. Give each package its own **`createStyles({ scopeId: '…' })`**: in **`semantic`** mode the scope is prefixed onto the class name (`pkg-a-button-base` vs `pkg-b-button-base`); in **`hashed`** / **`atomic`** mode the scope is mixed into the hash so identical style objects in different packages do not map to the same class string.
Two packages might both use `styles.component('button', …)`. Give each package its own **`createStyles({ scopeId: '…' })`**: in **`semantic`** mode the scope is prefixed onto the class name (`pkg-a-button-base` vs `pkg-b-button-base`); in **`hashed`**, **`compact`**, or **`atomic`** mode the scope is mixed into the hash so identical style objects in different packages do not map to the same class string.

For tokens, use **`createTokens({ scopeId })`** per package so `--color-*` and `.theme-*` rules do not overwrite each other on `:root` or clash by name.

Expand All @@ -108,7 +122,7 @@ Use the **same** `createStyles` / `createTokens` options (including `scopeId`) o

## Testing

Use a **dedicated** `createStyles({ … })` per test file or suite when you need hashed/atomic mode. There is no global naming state to reset—only call **`reset()`** (and related sheet helpers) to clear injected CSS between tests. Default **`import { styles } from 'typestyles'`** is still shared across tests, so prefer a local `createStyles()` when asserting on class strings under non-semantic modes.
Use a **dedicated** `createStyles({ … })` per test file or suite when you need hashed, compact, or atomic mode. There is no global naming state to reset—only call **`reset()`** (and related sheet helpers) to clear injected CSS between tests. Default **`import { styles } from 'typestyles'`** is still shared across tests, so prefer a local `createStyles()` when asserting on class strings under non-semantic modes.

```ts
import { createStyles, reset } from 'typestyles';
Expand All @@ -120,7 +134,7 @@ beforeEach(() => {
});
```

If you assert on class strings under **`hashed`** or **`atomic`**, prefer stable snapshots or assert on substrings (prefix, absence of semantic segments) rather than hard-coding full hashes unless you fix `scopeId` and styles.
If you assert on class strings under **`hashed`**, **`compact`**, or **`atomic`**, prefer stable snapshots or assert on substrings (prefix, absence of semantic segments) rather than hard-coding full hashes unless you fix `scopeId` and styles.

See also [Testing](/docs/testing).

Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ it('matches snapshot', () => {

### Class naming mode

If you use [hashed or atomic class naming](/docs/class-naming) in tests, prefer a dedicated **`createStyles({ mode, prefix, scopeId })`** instance in that file so naming options never leak. The default **`import { styles } from 'typestyles'`** is a single shared instance. Always call **`reset()`** (from `typestyles`) in `beforeEach` when tests inject CSS. Assertions that depend on exact class strings may need snapshots or prefix-based checks when `mode` is not `semantic`.
If you use [hashed, compact, or atomic class naming](/docs/class-naming) in tests, prefer a dedicated **`createStyles({ mode, prefix, scopeId })`** instance in that file so naming options never leak. The default **`import { styles } from 'typestyles'`** is a single shared instance. Always call **`reset()`** (from `typestyles`) in `beforeEach` when tests inject CSS. Assertions that depend on exact class strings may need snapshots or prefix-based checks when `mode` is not `semantic`.

## CSS testing strategies

Expand Down
2 changes: 1 addition & 1 deletion packages/typestyles/scripts/check-bundle-size.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const indexPath = path.join(distDir, 'index.js');
const colorPath = path.join(distDir, 'color.js');

/** Gzip budget for the main runtime entry (`dist/index.js`). */
const INDEX_GZIP_BUDGET = 15_500;
const INDEX_GZIP_BUDGET = 15_700;

function gzipSize(filePath) {
return zlib.gzipSync(fs.readFileSync(filePath)).length;
Expand Down
Loading
Loading