diff --git a/.changeset/p5-3-component-override-contract.md b/.changeset/p5-3-component-override-contract.md index ff46984..81e5394 100644 --- a/.changeset/p5-3-component-override-contract.md +++ b/.changeset/p5-3-component-override-contract.md @@ -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). diff --git a/.changeset/p6-token-name-template.md b/.changeset/p6-token-name-template.md new file mode 100644 index 0000000..b6ee9dd --- /dev/null +++ b/.changeset/p6-token-name-template.md @@ -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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8e3698..119bd0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index bfe348b..816bf9a 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,6 @@ dist-ssr .turbo .pnpm-store .content-collections -docs/.astro \ No newline at end of file +docs/.astro + +.parcel-cache \ No newline at end of file diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md index 0416f98..9649f9b 100644 --- a/IMPROVEMENTS.md +++ b/IMPROVEMENTS.md @@ -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 diff --git a/docs/content/docs/api-reference.md b/docs/content/docs/api-reference.md index 93d6300..c971ef1 100644 --- a/docs/content/docs/api-reference.md +++ b/docs/content/docs/api-reference.md @@ -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. diff --git a/docs/content/docs/style-dictionary.md b/docs/content/docs/style-dictionary.md index 71800e6..495f7b0 100644 --- a/docs/content/docs/style-dictionary.md +++ b/docs/content/docs/style-dictionary.md @@ -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. diff --git a/docs/content/docs/tokens.md b/docs/content/docs/tokens.md index 0c13e9c..723b18f 100644 --- a/docs/content/docs/tokens.md +++ b/docs/content/docs/tokens.md @@ -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. diff --git a/packages/typestyles/scripts/check-bundle-size.mjs b/packages/typestyles/scripts/check-bundle-size.mjs index 3c9e070..22a6f58 100644 --- a/packages/typestyles/scripts/check-bundle-size.mjs +++ b/packages/typestyles/scripts/check-bundle-size.mjs @@ -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; diff --git a/packages/typestyles/src/index.ts b/packages/typestyles/src/index.ts index 567f902..523d4a1 100644 --- a/packages/typestyles/src/index.ts +++ b/packages/typestyles/src/index.ts @@ -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'; @@ -82,6 +83,7 @@ export type { TokenRegistry, ThemeOverrides, FlatTokenEntry, + FlatTokenPathEntry, KeyframeStops, VariantDefinitions, VariantOptionStyle, @@ -129,7 +131,7 @@ export type { DeepPartialTokenValues, } from './types'; -export { flattenTokenEntries, isTokenDescriptor } from './types'; +export { flattenTokenEntries, flattenTokenPaths, isTokenDescriptor } from './types'; export { createVar, assignVars }; diff --git a/packages/typestyles/src/theme.ts b/packages/typestyles/src/theme.ts index 7fef76e..0bb8620 100644 --- a/packages/typestyles/src/theme.ts +++ b/packages/typestyles/src/theme.ts @@ -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'; @@ -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}`); @@ -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) { @@ -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 { @@ -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( @@ -615,6 +629,7 @@ export function createDarkMode( darkOverrides: ThemeOverrides, scopeId?: string, layerContext?: ThemeEmitLayerContext, + naming?: ThemeTokenNaming, ): ThemeSurface { return createTheme( name, @@ -623,5 +638,6 @@ export function createDarkMode( }, scopeId, layerContext, + naming, ); } diff --git a/packages/typestyles/src/token-naming.test.ts b/packages/typestyles/src/token-naming.test.ts new file mode 100644 index 0000000..b4414b1 --- /dev/null +++ b/packages/typestyles/src/token-naming.test.ts @@ -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']); + }); +}); diff --git a/packages/typestyles/src/token-naming.ts b/packages/typestyles/src/token-naming.ts new file mode 100644 index 0000000..4609cb7 --- /dev/null +++ b/packages/typestyles/src/token-naming.ts @@ -0,0 +1,103 @@ +import { sanitizeClassSegment } from './class-naming'; + +export type TokenNameContext = { + /** Raw `scopeId` from `createTokens`, trimmed; undefined when unscoped. */ + scopeId: string | undefined; + /** Sanitized scope segment used in default naming (`app` from `@acme/ui`). */ + scope: string; + /** First argument to `tokens.create('color', …)`. */ + namespace: string; + /** Flattened leaf path with default `-` join (`brand-primary`). */ + path: string; + /** Path segments before joining (`['brand', 'primary']`). */ + segments: readonly string[]; +}; + +export type TokenNameTemplate = (ctx: TokenNameContext) => string; + +export function defaultTokenNameTemplate(ctx: TokenNameContext): string { + const ns = ctx.scopeId ? `${ctx.scope}-${ctx.namespace}` : ctx.namespace; + return `--${ns}-${ctx.path}`; +} + +export function buildTokenNameContext( + scopeId: string | undefined, + namespace: string, + path: string, + segments: readonly string[], +): TokenNameContext { + const trimmedScope = scopeId?.trim() || undefined; + return { + scopeId: trimmedScope, + scope: trimmedScope ? sanitizeClassSegment(trimmedScope) : '', + namespace, + path, + segments, + }; +} + +const VALID_TOKEN_NAME = /^--[a-zA-Z0-9_-]+$/; + +function normalizeTokenPropertyName(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) return ''; + + const withPrefix = trimmed.startsWith('--') ? trimmed : `--${trimmed}`; + const body = withPrefix.slice(2); + const safeBody = body + .replace(/[^a-zA-Z0-9_-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + + return safeBody ? `--${safeBody}` : ''; +} + +function assertValidTokenName(name: string, namespace: string, path: string): void { + if (!VALID_TOKEN_NAME.test(name)) { + throw new Error( + `[typestyles] tokens.create('${namespace}'): nameTemplate produced invalid custom property name ` + + `"${name}" for path "${path}". Names must match /^--[a-z0-9-]+$/ after sanitization.`, + ); + } +} + +export function resolveTokenName( + template: TokenNameTemplate | undefined, + ctx: TokenNameContext, + namespaceForError = ctx.namespace, +): string { + const raw = (template ?? defaultTokenNameTemplate)(ctx); + const name = normalizeTokenPropertyName(raw); + + if (process.env.NODE_ENV !== 'production') { + assertValidTokenName(name, namespaceForError, ctx.path); + } else if (!VALID_TOKEN_NAME.test(name)) { + throw new Error( + `[typestyles] tokens.create('${namespaceForError}'): invalid custom property name for path "${ctx.path}".`, + ); + } + + return name; +} + +export type ThemeTokenNaming = { + resolveName(namespace: string, path: string, segments: readonly string[]): string; +}; + +export function createThemeTokenNaming( + scopeId: string | undefined, + instanceDefaultTemplate: TokenNameTemplate | undefined, + namespaceTemplates: ReadonlyMap, + nameByPathByNamespace: ReadonlyMap>, +): ThemeTokenNaming { + return { + resolveName(namespace, path, segments) { + const registered = nameByPathByNamespace.get(namespace)?.get(path); + if (registered) return registered; + + const template = namespaceTemplates.get(namespace) ?? instanceDefaultTemplate; + const ctx = buildTokenNameContext(scopeId, namespace, path, segments); + return resolveTokenName(template, ctx, namespace); + }, + }; +} diff --git a/packages/typestyles/src/tokens.test.ts b/packages/typestyles/src/tokens.test.ts index 48c614b..5cd1a6a 100644 --- a/packages/typestyles/src/tokens.test.ts +++ b/packages/typestyles/src/tokens.test.ts @@ -145,6 +145,96 @@ describe('tokens.create', () => { var: 'var(--color-accent)', }); }); + + describe('nameTemplate', () => { + it('keeps default output when nameTemplate is omitted', () => { + const api = createTokens({ scopeId: 'app' }); + const color = api.create('color', { primary: '#0066ff' }); + expect(color.primary).toBe('var(--app-color-primary)'); + }); + + it('uses custom template for emission and proxy refs', () => { + const api = createTokens({ scopeId: 'acme' }); + const color = api.create( + 'color', + { brand: { 500: '#0066ff' } }, + { + nameTemplate: ({ segments }) => `--color-${segments.join('-')}`, + }, + ); + + expect(color.brand[500]).toBe('var(--color-brand-500)'); + + flushSync(); + const css = getRegisteredCss(); + expect(css).toContain('--color-brand-500: #0066ff'); + expect(css).not.toContain('--acme-color-brand-500'); + }); + + it('joins segments with a custom separator', () => { + const api = createTokens(); + const color = api.create( + 'color', + { brand: { primary: '#0066ff' } }, + { + nameTemplate: ({ segments }) => `--color-${segments.join('_')}`, + }, + ); + + expect(color.brand.primary).toBe('var(--color-brand_primary)'); + }); + + it('applies instance default with per-namespace override', () => { + const api = createTokens({ + scopeId: 'app', + nameTemplate: ({ scope, namespace, path }) => `--${scope}-${namespace}-${path}`, + }); + + const scoped = api.create('space', { md: '16px' }); + expect(scoped.md).toBe('var(--app-space-md)'); + + const plain = api.create( + 'color', + { primary: '#0066ff' }, + { nameTemplate: ({ path }) => `--color-${path}` }, + ); + expect(plain.primary).toBe('var(--color-primary)'); + }); + + it('registers @property on templated names for descriptor leaves', () => { + const api = createTokens(); + api.create( + 'motion', + { duration: { value: '200ms', syntax: '