diff --git a/packages/@luke-ui/react/src/core/styles/recipe.browser.test.ts b/packages/@luke-ui/react/src/core/styles/recipe.browser.test.ts
index 4ba02e7f..ca2a7872 100644
--- a/packages/@luke-ui/react/src/core/styles/recipe.browser.test.ts
+++ b/packages/@luke-ui/react/src/core/styles/recipe.browser.test.ts
@@ -86,11 +86,11 @@ test('slot functions append a consumer className', () => {
});
test('textInputRecipe styles a standalone input and appends a consumer className', () => {
- const medium = textInputRecipe({ size: 'medium' });
+ const medium = textInputRecipe({ size: 'px40' });
expect(textInputRecipe()).toBe(medium);
- expect(textInputRecipe({ size: 'small' })).not.toBe(medium);
- expect(textInputRecipe({ className: 'mine', size: 'medium' })).toBe(`${medium} mine`);
+ expect(textInputRecipe({ size: 'px32' })).not.toBe(medium);
+ expect(textInputRecipe({ className: 'mine', size: 'px40' })).toBe(`${medium} mine`);
});
test('an unconditional compoundSlots entry applies to every listed slot and not to unlisted ones', () => {
diff --git a/packages/@luke-ui/react/src/core/styles/stylesheet-contract.browser.test.tsx b/packages/@luke-ui/react/src/core/styles/stylesheet-contract.browser.test.tsx
index 7ed9ac51..b19c4762 100644
--- a/packages/@luke-ui/react/src/core/styles/stylesheet-contract.browser.test.tsx
+++ b/packages/@luke-ui/react/src/core/styles/stylesheet-contract.browser.test.tsx
@@ -105,7 +105,7 @@ test('builds the public stylesheet with the layer and global-rule contract', ()
// Chromium folds the prefix away in cssText above. Assert the raw file so Safari's form cannot
// disappear while the contract still passes.
expect(builtCss).toContain('-webkit-text-size-adjust: 100%');
- expectOwnedBy(rules, iconRecipe({ size: 'medium' }).split(' '), 'luke-ui.recipes');
+ expectOwnedBy(rules, iconRecipe({ size: 'px24' }).split(' '), 'luke-ui.recipes');
});
test('puts utility props in the utilities layer', () => {
diff --git a/packages/@luke-ui/react/src/core/switch-field/switch-field.browser.test.tsx b/packages/@luke-ui/react/src/core/switch-field/switch-field.browser.test.tsx
index 26b09cfb..52577a6a 100644
--- a/packages/@luke-ui/react/src/core/switch-field/switch-field.browser.test.tsx
+++ b/packages/@luke-ui/react/src/core/switch-field/switch-field.browser.test.tsx
@@ -38,7 +38,7 @@ function SwitchScene() {
description="Receive updates by email."
label="Email notifications"
name="description-small"
- size="small"
+ size="px16"
/>
@@ -53,7 +53,7 @@ function TextInputFieldScene() {
errorMessage="Please enter a valid email."
label="Invalid small"
name="invalid-small"
- size="small"
+ size="px32"
/>
{
'focus',
]);
expect(Object.keys(vars.controlFinish)).toEqual(['recessed', 'resting', 'raised']);
- expect(Object.keys(vars.controlSize)).toEqual(['small', 'medium']);
+ expect(Object.keys(vars.controlSize)).toEqual(['px32', 'px40']);
for (const style of typeStyles) {
expect(Object.keys(vars.font[style]).sort()).toEqual([
'fontFamily',
diff --git a/packages/@luke-ui/react/src/theme/contract.ts b/packages/@luke-ui/react/src/theme/contract.ts
index aee43d9d..c96ead7e 100644
--- a/packages/@luke-ui/react/src/theme/contract.ts
+++ b/packages/@luke-ui/react/src/theme/contract.ts
@@ -61,7 +61,8 @@ const fontStyleContract = Object.fromEntries(
/**
* Fixed spacing values selected from a 4px scale. Keys encode their step; values are emitted as
- * `rem` (16 → 1rem).
+ * `rem` (16 → 1rem). Temporary `sp*` naming — migrate to `literalScale('px', …)` before the next
+ * release (#847).
*/
export const spaceScale = [
['sp4', rem(4)],
@@ -183,10 +184,10 @@ export const themeContractTree = {
},
/** The fixed spacing scale used by components and layout utilities. */
space: spaceContract,
- /** Structural block sizes for small and medium controls. */
+ /** Structural block sizes for `px32` and `px40` controls. */
controlSize: {
- small: null,
- medium: null,
+ px32: null,
+ px40: null,
},
/** Interaction treatment shared by every control recipe. */
interaction: {
diff --git a/packages/@luke-ui/react/src/theme/literal-scale.test.ts b/packages/@luke-ui/react/src/theme/literal-scale.test.ts
new file mode 100644
index 00000000..6a04a673
--- /dev/null
+++ b/packages/@luke-ui/react/src/theme/literal-scale.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, expectTypeOf, it } from 'vite-plus/test';
+import { literalScale } from './literal-scale.js';
+import { rem } from './rem.js';
+
+describe('literalScale', () => {
+ it('derives px keys and rem values from the same numbers', () => {
+ const scale = literalScale('px', [16, 20, 24, 32] as const);
+
+ expect(scale).toEqual({
+ px16: rem(16),
+ px20: rem(20),
+ px24: rem(24),
+ px32: rem(32),
+ });
+ expect(Object.keys(scale)).toEqual(['px16', 'px20', 'px24', 'px32']);
+ });
+
+ it('keeps key unions tied to the numeric steps', () => {
+ const scale = literalScale('px', [32, 40] as const);
+
+ expectTypeOf(scale).toEqualTypeOf<{
+ readonly px32: `${string}rem`;
+ readonly px40: `${string}rem`;
+ }>();
+ expectTypeOf().toEqualTypeOf<'px32' | 'px40'>();
+ });
+
+ it('accepts another prefix so spacing can reuse the same helper', () => {
+ const scale = literalScale('sp', [4, 8] as const);
+
+ expect(scale).toEqual({
+ sp4: rem(4),
+ sp8: rem(8),
+ });
+ expectTypeOf(scale).toEqualTypeOf<{
+ readonly sp4: `${string}rem`;
+ readonly sp8: `${string}rem`;
+ }>();
+ });
+});
diff --git a/packages/@luke-ui/react/src/theme/literal-scale.ts b/packages/@luke-ui/react/src/theme/literal-scale.ts
new file mode 100644
index 00000000..b7b27567
--- /dev/null
+++ b/packages/@luke-ui/react/src/theme/literal-scale.ts
@@ -0,0 +1,22 @@
+import { rem } from './rem.js';
+
+type LiteralKey = `${Prefix}${Step}`;
+
+/**
+ * Builds a curated literal size scale from numeric steps. Each step becomes both the key and the
+ * `rem` value at a 16px root (`16` → `px16: rem(16)`), so names cannot drift from values.
+ *
+ * The prefix names the kind of value (`px` for fixed lengths). Spacing still uses hand-written
+ * `sp*` entries today; migrate it through this helper before the next release (#847).
+ */
+export function literalScale<
+ const Prefix extends string,
+ const Steps extends ReadonlyArray,
+>(
+ prefix: Prefix,
+ steps: Steps,
+): { readonly [Step in Steps[number] as LiteralKey]: `${string}rem` } {
+ return Object.fromEntries(steps.map((step) => [`${prefix}${step}`, rem(step)])) as {
+ readonly [Step in Steps[number] as LiteralKey]: `${string}rem`;
+ };
+}
diff --git a/packages/@luke-ui/react/src/theme/rem.ts b/packages/@luke-ui/react/src/theme/rem.ts
index 43514fda..4b693a11 100644
--- a/packages/@luke-ui/react/src/theme/rem.ts
+++ b/packages/@luke-ui/react/src/theme/rem.ts
@@ -1,6 +1,6 @@
/**
* Converts a value at a 16px root to `rem` (16 → 1rem).
- * Private to the theme pipeline — not a public API.
+ * Shared by theme generation and runtime literal scales — not a public API.
*/
export function rem(pixels: number): `${string}rem` {
return `${formatRemNumber(pixels / 16)}rem`;
diff --git a/packages/@luke-ui/react/src/theme/token-values.test.ts b/packages/@luke-ui/react/src/theme/token-values.test.ts
new file mode 100644
index 00000000..67497757
--- /dev/null
+++ b/packages/@luke-ui/react/src/theme/token-values.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from 'vite-plus/test';
+import { literalScale } from './literal-scale.js';
+import { CONTROL_SIZE_VALUES } from './token-values.js';
+
+describe('CONTROL_SIZE_VALUES', () => {
+ it('derives controlSize paths from literalScale', () => {
+ const scale = literalScale('px', [32, 40] as const);
+
+ expect(CONTROL_SIZE_VALUES).toEqual({
+ 'controlSize.px32': scale.px32,
+ 'controlSize.px40': scale.px40,
+ });
+ });
+});
diff --git a/packages/@luke-ui/react/src/theme/token-values.ts b/packages/@luke-ui/react/src/theme/token-values.ts
index fe09e5c8..eb548ae8 100644
--- a/packages/@luke-ui/react/src/theme/token-values.ts
+++ b/packages/@luke-ui/react/src/theme/token-values.ts
@@ -9,19 +9,22 @@
import type { IdentityPath } from './contract.js';
import { FONT_METRIC_SCALE } from './font-metric-scale.js';
+import { literalScale } from './literal-scale.js';
import { MOTION_DURATION_SCALE } from './motion.js';
import { pathEntry, pathRecord } from './path-record.js';
-import { rem } from './rem.js';
import type { TypeStyle } from './type-styles.js';
import { typeStyleMetricStep, typeStyles } from './type-styles.js';
-/** Structural block sizes for the small and medium controls. */
+const controlSizeScale = literalScale('px', [32, 40] as const);
+
+/** Structural block sizes for the `px32` and `px40` controls. */
export const CONTROL_SIZE_VALUES: {
[Path in Extract]: string;
-} = {
- 'controlSize.medium': rem(40),
- 'controlSize.small': rem(32),
-};
+} = pathRecord(
+ (Object.keys(controlSizeScale) as Array).map((key) =>
+ pathEntry(`controlSize.${key}`, controlSizeScale[key]),
+ ),
+);
type FontValueLeaf = 'fontSize' | 'letterSpacing' | 'lineHeight';
type FontValueKey = `font.${TypeStyle}.${FontValueLeaf}`;