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
6 changes: 5 additions & 1 deletion .changeset/p5-3-component-override-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,8 @@
'@typestyles/cli': minor
---

Add `styles.scope()` for proximity-correct nested theme overrides via CSS `@scope`, the `@typestyles/cli` package (`typestyles snapshot`), and the opt-in `@typestyles/no-removed-public-classname` ESLint rule for guarding semantic class renames in publishable packages (P5.3).
Add `styles.scope()` for proximity-correct nested theme overrides via CSS `@scope` (P5.3).

Introduce **`@typestyles/cli`**, a new package with the `typestyles` binary and subcommands. The first command is `typestyles snapshot`, which scans semantic `styles.class` / `styles.component` class names and writes `.typestyles-public-classnames.json` for semver guarding. Snapshot logic and heavy deps (`typescript`, `fast-glob`) live in this package so the core `typestyles` runtime stays lean.

Also ships the opt-in `@typestyles/no-removed-public-classname` ESLint rule (consumes `@typestyles/cli` programmatically).
5 changes: 5 additions & 0 deletions .changeset/p6-token-name-template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'typestyles': minor
---

Add optional `nameTemplate` on `createTokens` and `tokens.create` for custom CSS custom property names — migration from existing variable systems, Style Dictionary / DTCG naming conventions, and cross-namespace aliasing. Default `--{scopeId}-{namespace}-{path}` behavior is unchanged when `nameTemplate` is omitted. Theme overrides, `tokens.use()`, and `@property` registration share the same resolved names.
3 changes: 0 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,6 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build typestyles and check bundle budget
run: pnpm --filter typestyles build

- name: Run tests
run: pnpm run test

Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,6 @@ dist-ssr
.turbo
.pnpm-store
.content-collections
docs/.astro
docs/.astro

.parcel-cache
6 changes: 5 additions & 1 deletion IMPROVEMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,5 +382,9 @@ in var-ui's own roadmap, not here.
- Recipes/cookbook section (resurrect the `recipes.astro` redirect)
- W3C Design Tokens import + Figma sync
- Responsive object syntax (breakpoint shorthand in style values)
- Custom CSS variable name control (`nameTemplate` on `tokens.create`)
- [x] **Custom CSS variable name control (`nameTemplate` on `tokens.create`)**
- Shipped: `nameTemplate` on `createTokens` and `tokens.create` with `TokenNameContext`
(`scopeId`, `scope`, `namespace`, `path`, `segments`); segment-preserving
`flattenTokenPaths`; theme/`tokens.use()`/`@property` wired through the same
path→name registry. Spec: `specs/token-name-template.md`.
- 1.0 stability policy and criteria
4 changes: 3 additions & 1 deletion docs/content/docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,12 @@ bundles share a page.

### `createTokens(options?)`

Returns a token + theme API bound to an optional `scopeId`. When set, `tokens.create('color', …)` emits `--{scopeId}-color-*` variables and `tokens.createTheme('dark', …)` registers `.theme-{scopeId}-dark` (sanitized segments). With **`layers`**, **`tokenLayer`** is required and token/theme CSS is wrapped in that layer.
Returns a token + theme API bound to an optional `scopeId`. When set, `tokens.create('color', …)` emits `--{scopeId}-color-*` variables and `tokens.createTheme('dark', …)` registers `.theme-{scopeId}-dark` (sanitized segments). With **`layers`**, **`tokenLayer`** is required and token/theme CSS is wrapped in that layer. Optional **`nameTemplate`** on the instance or per `tokens.create` call controls emitted `--*` names (see [Tokens](/docs/tokens#custom-css-variable-names-nametemplate)).

The default `import { tokens } from 'typestyles'` is `createTokens()` (no scope).

Exported types: **`TokenNameContext`**, **`TokenNameTemplate`**, **`FlatTokenPathEntry`**. Helper: **`flattenTokenPaths`** (segment-preserving flatten for custom templates).

### `keyframes`

Keyframe animation API.
Expand Down
25 changes: 25 additions & 0 deletions docs/content/docs/style-dictionary.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,31 @@ const semantic = tokens.create('color', {

The second strategy is worth the extra layer when you ship **multiple brand themes** that share the same semantic contract.

## Matching external CSS names

When Style Dictionary (or another tool) already defines your canonical `--*` naming convention, mirror it with **`nameTemplate`** on `tokens.create` so TypeStyles refs point at the same names TypeStyles injects — without duplicating `:root` blocks from SD.

```ts
const tokens = createTokens({ scopeId: 'acme' });

// SD might emit --color-brand-500; match that pattern (drop scope from var names if globals are unscoped).
const color = tokens.create('color', generatedPalette, {
nameTemplate: ({ segments }) => `--color-${segments.join('-')}`,
});

const semantic = tokens.create(
'color-semantic',
{
text: { primary: color.brand[500] },
},
{
nameTemplate: ({ path }) => `--ds-color-${path}`,
},
);
```

**Defaults are still recommended** for new apps — custom names are for interop and migration, not aesthetics. **`scopeId`** on `createTokens` / `createStyles` still scopes theme **classes** even when var names match globals.

## Gotchas

- **Do not let Style Dictionary emit the CSS.** If both Style Dictionary and TypeStyles ship `:root { --color-brand: … }`, you get duplicate declarations and scoping conflicts. Keep TypeStyles as the single source of CSS.
Expand Down
34 changes: 34 additions & 0 deletions docs/content/docs/tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,40 @@ export const space = tokens.create('space', {
// With scopeId 'app': padding: space.md → var(--app-space-md)
```

## Custom CSS variable names (`nameTemplate`)

The default naming pattern (`--{scopeId}-{namespace}-{path}`) is recommended for greenfield TypeStyles apps. When migrating from an existing CSS variable system, matching Style Dictionary output, or aliasing across namespaces, pass an optional **`nameTemplate`** function to control emitted `--*` names while keeping typed `var(--…)` references and theme integration.

```ts
const tokens = createTokens({ scopeId: 'acme' });

const primitive = tokens.create('color', palette, {
nameTemplate: ({ segments }) => `--color-${segments.join('-')}`,
});
// --color-brand-500 (no acme- prefix on these vars)

const semantic = tokens.create(
'semantic-color',
{
text: { primary: primitive.brand[500] },
},
{
nameTemplate: ({ path }) => `--ds-color-${path}`,
},
);
// --ds-color-text-primary: var(--color-brand-500)
```

Set a default on the instance with `createTokens({ nameTemplate })`, or override per namespace in `tokens.create(…, { nameTemplate })`. Templates receive `scopeId`, `scope`, `namespace`, flattened `path`, and `segments` (object keys at each nesting level — use `segments` when your external spec uses a different joiner than `-`).

**Migration notes:**

- Omitting `scopeId` from a custom template restores cross-package collision risk — keep `scopeId` on **classes** even when vars match global names.
- Theme overrides use the same names registered at `tokens.create` time; renaming a template after shipping is a breaking change for plain CSS targeting `--*`.
- Do not let Style Dictionary emit `:root` CSS — TypeStyles remains the single injector. Mirror SD naming via `nameTemplate` instead.

See [Style Dictionary & W3C tokens](/docs/style-dictionary#matching-external-css-names) for pipeline examples.

## Referencing tokens defined elsewhere

When tokens are created in another module or package, use `tokens.use(namespace)` to get the same `var(--namespace-key)` references **without** emitting another `:root` rule. The namespace must already be registered (via `tokens.create`) before those variables exist in CSS.
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`) and its code-split chunks. */
const INDEX_GZIP_BUDGET = 17_300;
const INDEX_GZIP_BUDGET = 18_600; // +~1.2 KB for P6 nameTemplate (token-naming, flattenTokenPaths)

function gzipSize(filePath) {
return zlib.gzipSync(fs.readFileSync(filePath)).length;
Expand Down
4 changes: 3 additions & 1 deletion packages/typestyles/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export type {
LayeredComponentFn,
} from './styles';
export type { CreateTokensOptions, TokensApi } from './tokens';
export type { TokenNameContext, TokenNameTemplate } from './token-naming';

export type { CascadeLayersInput, CascadeLayersObjectInput, ResolvedCascadeLayers } from './layers';

Expand Down Expand Up @@ -82,6 +83,7 @@ export type {
TokenRegistry,
ThemeOverrides,
FlatTokenEntry,
FlatTokenPathEntry,
KeyframeStops,
VariantDefinitions,
VariantOptionStyle,
Expand Down Expand Up @@ -129,7 +131,7 @@ export type {
DeepPartialTokenValues,
} from './types';

export { flattenTokenEntries, isTokenDescriptor } from './types';
export { flattenTokenEntries, flattenTokenPaths, isTokenDescriptor } from './types';

export { createVar, assignVars };

Expand Down
24 changes: 20 additions & 4 deletions packages/typestyles/src/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ import type {
ThemeSurface,
TokenValues,
} from './types';
import { flattenTokenEntries } from './types';
import { flattenTokenEntries, flattenTokenPaths } from './types';
import { sanitizeClassSegment, scopedTokenNamespace } from './class-naming';
import type { ThemeTokenNaming } from './token-naming';
import { insertRule, insertRules } from './sheet';
import type { ResolvedCascadeLayers } from './layers';
import { applyLayerToRules } from './layers';
Expand Down Expand Up @@ -319,10 +320,22 @@ function mergeCompiled(a: CompiledCondition, b: CompiledCondition): CompiledCond
// CSS declaration building
// ---------------------------------------------------------------------------

function buildDeclarations(scopeId: string | undefined, overrides: ThemeOverrides): string {
function buildDeclarations(
scopeId: string | undefined,
overrides: ThemeOverrides,
naming?: ThemeTokenNaming,
): string {
const parts: string[] = [];
for (const [namespace, values] of Object.entries(overrides)) {
if (values === null || values === undefined) continue;

if (naming) {
for (const { path, segments, value } of flattenTokenPaths(values as TokenValues)) {
parts.push(`${naming.resolveName(namespace, path, segments)}: ${value}`);
}
continue;
}

const cssNs = scopedTokenNamespace(scopeId, namespace);
for (const [key, value] of flattenTokenEntries(values as TokenValues)) {
parts.push(`--${cssNs}-${key}: ${value}`);
Expand Down Expand Up @@ -529,6 +542,7 @@ export function createTheme(
config: ThemeConfig,
scopeId?: string,
layerContext?: ThemeEmitLayerContext,
naming?: ThemeTokenNaming,
): ThemeSurface {
if (process.env.NODE_ENV !== 'production') {
if (config.modes && config.colorMode) {
Expand All @@ -551,7 +565,7 @@ export function createTheme(
};

// 1. Base rule
const baseDecls = config.base ? buildDeclarations(scopeId, config.base) : '';
const baseDecls = config.base ? buildDeclarations(scopeId, config.base, naming) : '';
if (baseDecls) {
emitRule(`theme:${segment}:base`, `.${className} { ${baseDecls}; }`);
} else {
Expand All @@ -564,7 +578,7 @@ export function createTheme(
const rules: Array<{ key: string; css: string }> = [];

for (const mode of modes) {
const decls = buildDeclarations(scopeId, mode.overrides);
const decls = buildDeclarations(scopeId, mode.overrides, naming);
if (!decls) {
if (process.env.NODE_ENV !== 'production') {
console.warn(
Expand Down Expand Up @@ -615,6 +629,7 @@ export function createDarkMode(
darkOverrides: ThemeOverrides,
scopeId?: string,
layerContext?: ThemeEmitLayerContext,
naming?: ThemeTokenNaming,
): ThemeSurface {
return createTheme(
name,
Expand All @@ -623,5 +638,6 @@ export function createDarkMode(
},
scopeId,
layerContext,
naming,
);
}
52 changes: 52 additions & 0 deletions packages/typestyles/src/token-naming.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import { buildTokenNameContext, defaultTokenNameTemplate, resolveTokenName } from './token-naming';
import { flattenTokenPaths } from './types';

describe('defaultTokenNameTemplate', () => {
it('matches scopedTokenNamespace + path when scopeId is set', () => {
const ctx = buildTokenNameContext('my-pkg', 'color', 'brand-primary', ['brand', 'primary']);
expect(defaultTokenNameTemplate(ctx)).toBe('--my-pkg-color-brand-primary');
});

it('omits scope when unscoped', () => {
const ctx = buildTokenNameContext(undefined, 'color', 'primary', ['primary']);
expect(defaultTokenNameTemplate(ctx)).toBe('--color-primary');
});
});

describe('resolveTokenName', () => {
it('normalizes unsafe characters in template output', () => {
const ctx = buildTokenNameContext(undefined, 'color', 'brand-primary', ['brand', 'primary']);
const name = resolveTokenName(() => '--color-brand primary!', ctx);
expect(name).toBe('--color-brand-primary');
});

it('preserves underscores from custom segment joiners', () => {
const ctx = buildTokenNameContext(undefined, 'color', 'brand-primary', ['brand', 'primary']);
const name = resolveTokenName(({ segments }) => `--color-${segments.join('_')}`, ctx);
expect(name).toBe('--color-brand_primary');
});

it('throws in dev for invalid names after sanitization', () => {
const ctx = buildTokenNameContext(undefined, 'color', 'x', ['x']);
expect(() => resolveTokenName(() => '--', ctx)).toThrow(/invalid custom property name/);
});
});

describe('flattenTokenPaths', () => {
it('preserves segment arrays for nested keys', () => {
const entries = flattenTokenPaths({
brand: { 500: '#0066ff', 'line-height': '1.5' },
});

expect(entries).toEqual([
{ path: 'brand-500', segments: ['brand', '500'], value: '#0066ff' },
{ path: 'brand-line-height', segments: ['brand', 'line-height'], value: '1.5' },
]);
});

it('preserves numeric leaf keys in segments', () => {
const entries = flattenTokenPaths({ brand: { 500: '#0066ff' } });
expect(entries[0]?.segments).toEqual(['brand', '500']);
});
});
Loading
Loading