@multiplatform.one/theme 6.1.0 → 6.3.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 (187) hide show
  1. package/README.md +73 -0
  2. package/package.json +22 -9
  3. package/src/dates.spec.ts +75 -0
  4. package/src/dates.ts +130 -0
  5. package/src/devWarn.spec.ts +144 -0
  6. package/src/devWarn.ts +235 -0
  7. package/src/figma/figmaTokens.spec.ts +244 -0
  8. package/src/figma/figmaTokens.ts +503 -0
  9. package/src/font.ts +6 -8
  10. package/src/fonts/createFontLoader.web.ts +15 -4
  11. package/src/index.ts +7 -0
  12. package/src/keyboardFocusRing.ts +45 -0
  13. package/src/menuRow.spec.ts +32 -0
  14. package/src/menuRow.ts +31 -0
  15. package/src/theme/FontKnobStyles.native.tsx +24 -0
  16. package/src/theme/FontKnobStyles.tsx +256 -0
  17. package/src/theme/animations/reactNative.ts +6 -1
  18. package/src/theme/chartPalette.spec.ts +159 -0
  19. package/src/theme/chartPalette.ts +170 -0
  20. package/src/theme/colorRules.ts +33 -1
  21. package/src/theme/cookies.spec.ts +32 -0
  22. package/src/theme/cookiesPersistence.spec.ts +86 -0
  23. package/src/theme/cornerSmoothing.native.ts +16 -0
  24. package/src/theme/cornerSmoothing.spec.ts +27 -0
  25. package/src/theme/cornerSmoothing.ts +46 -0
  26. package/src/theme/createDefaultThemeConfig.ts +32 -8
  27. package/src/theme/createThemeConfig.spec.ts +137 -0
  28. package/src/theme/createThemes.ts +77 -2
  29. package/src/theme/defaults/accent.ts +39 -0
  30. package/src/theme/defaults/base.ts +63 -0
  31. package/src/theme/defaults/builderOptions.ts +174 -0
  32. package/src/theme/defaults/fonts.spec.ts +87 -0
  33. package/src/theme/defaults/fonts.ts +89 -0
  34. package/src/theme/defaults/index.ts +4 -0
  35. package/src/theme/devtools/ThemeDevtoolsPanel.tsx +196 -2
  36. package/src/theme/devtools/buildSnapshot.ts +11 -0
  37. package/src/theme/devtools/knobIcons.tsx +141 -0
  38. package/src/theme/devtools/vizConstants.ts +1 -1
  39. package/src/theme/focusState.spec.ts +174 -0
  40. package/src/theme/focusState.ts +112 -0
  41. package/src/theme/fontKnobStyles.spec.ts +98 -0
  42. package/src/theme/hairline.native.ts +43 -0
  43. package/src/theme/hairline.spec.ts +63 -0
  44. package/src/theme/hairline.ts +101 -0
  45. package/src/theme/index.ts +8 -0
  46. package/src/theme/intent.spec.tsx +2 -2
  47. package/src/theme/intents.ts +6 -2
  48. package/src/theme/knobs.ts +134 -2
  49. package/src/theme/layoutTokens.spec.ts +75 -0
  50. package/src/theme/layoutTokens.ts +213 -0
  51. package/src/theme/layoutTokensHooks.spec.tsx +157 -0
  52. package/src/theme/presets.spec.ts +17 -0
  53. package/src/theme/presets.ts +22 -2
  54. package/src/theme/readableColor.ts +101 -0
  55. package/src/theme/recipes.ts +130 -7
  56. package/src/theme/resolveKnobs.spec.ts +560 -21
  57. package/src/theme/resolveKnobs.ts +267 -23
  58. package/src/theme/shared.tsx +26 -2
  59. package/src/theme/useResolvedKnobs.ts +67 -26
  60. package/src/theme/useResolvedKnobsBehavior.spec.tsx +166 -0
  61. package/types/audit/constraintAudit.d.ts +83 -0
  62. package/types/audit/constraintAudit.d.ts.map +1 -0
  63. package/types/audit/diffReport.d.ts +45 -0
  64. package/types/audit/diffReport.d.ts.map +1 -0
  65. package/types/audit/index.d.ts +5 -0
  66. package/types/audit/index.d.ts.map +1 -0
  67. package/types/dates.d.ts +52 -0
  68. package/types/dates.d.ts.map +1 -0
  69. package/types/devWarn.d.ts +104 -0
  70. package/types/devWarn.d.ts.map +1 -0
  71. package/types/figma/figmaTokens.d.ts +168 -0
  72. package/types/figma/figmaTokens.d.ts.map +1 -0
  73. package/types/font.d.ts +5 -0
  74. package/types/font.d.ts.map +1 -0
  75. package/types/fonts/createFontLoader.d.ts +3 -0
  76. package/types/fonts/createFontLoader.d.ts.map +1 -0
  77. package/types/fonts/createFontLoader.web.d.ts +3 -0
  78. package/types/fonts/createFontLoader.web.d.ts.map +1 -0
  79. package/types/fonts/index.d.ts +3 -0
  80. package/types/fonts/index.d.ts.map +1 -0
  81. package/types/fonts/types.d.ts +12 -0
  82. package/types/fonts/types.d.ts.map +1 -0
  83. package/types/index.d.ts +9 -0
  84. package/types/index.d.ts.map +1 -0
  85. package/types/keyboardFocusRing.d.ts +32 -0
  86. package/types/keyboardFocusRing.d.ts.map +1 -0
  87. package/types/menuRow.d.ts +32 -0
  88. package/types/menuRow.d.ts.map +1 -0
  89. package/types/theme/FontKnobStyles.d.ts +49 -0
  90. package/types/theme/FontKnobStyles.d.ts.map +1 -0
  91. package/types/theme/FontKnobStyles.native.d.ts +12 -0
  92. package/types/theme/FontKnobStyles.native.d.ts.map +1 -0
  93. package/types/theme/Intent.d.ts +20 -0
  94. package/types/theme/Intent.d.ts.map +1 -0
  95. package/types/theme/Preset.d.ts +15 -0
  96. package/types/theme/Preset.d.ts.map +1 -0
  97. package/types/theme/PresetContext.d.ts +18 -0
  98. package/types/theme/PresetContext.d.ts.map +1 -0
  99. package/types/theme/Tint.d.ts +19 -0
  100. package/types/theme/Tint.d.ts.map +1 -0
  101. package/types/theme/animations/css.d.ts +24 -0
  102. package/types/theme/animations/css.d.ts.map +1 -0
  103. package/types/theme/animations/index.d.ts +2 -0
  104. package/types/theme/animations/index.d.ts.map +1 -0
  105. package/types/theme/animations/index.web.d.ts +2 -0
  106. package/types/theme/animations/index.web.d.ts.map +1 -0
  107. package/types/theme/animations/reactNative.d.ts +104 -0
  108. package/types/theme/animations/reactNative.d.ts.map +1 -0
  109. package/types/theme/chartPalette.d.ts +62 -0
  110. package/types/theme/chartPalette.d.ts.map +1 -0
  111. package/types/theme/colorRules.d.ts +69 -0
  112. package/types/theme/colorRules.d.ts.map +1 -0
  113. package/types/theme/cookies.d.ts +76 -0
  114. package/types/theme/cookies.d.ts.map +1 -0
  115. package/types/theme/cornerSmoothing.d.ts +31 -0
  116. package/types/theme/cornerSmoothing.d.ts.map +1 -0
  117. package/types/theme/cornerSmoothing.native.d.ts +14 -0
  118. package/types/theme/cornerSmoothing.native.d.ts.map +1 -0
  119. package/types/theme/createDefaultThemeConfig.d.ts +37 -0
  120. package/types/theme/createDefaultThemeConfig.d.ts.map +1 -0
  121. package/types/theme/createThemes.d.ts +38 -0
  122. package/types/theme/createThemes.d.ts.map +1 -0
  123. package/types/theme/defaults/accent.d.ts +3 -0
  124. package/types/theme/defaults/accent.d.ts.map +1 -0
  125. package/types/theme/defaults/base.d.ts +3 -0
  126. package/types/theme/defaults/base.d.ts.map +1 -0
  127. package/types/theme/defaults/builderOptions.d.ts +3 -0
  128. package/types/theme/defaults/builderOptions.d.ts.map +1 -0
  129. package/types/theme/defaults/fonts.d.ts +7 -0
  130. package/types/theme/defaults/fonts.d.ts.map +1 -0
  131. package/types/theme/defaults/index.d.ts +5 -0
  132. package/types/theme/defaults/index.d.ts.map +1 -0
  133. package/types/theme/devtools/ColorLineVisualizer.d.ts +17 -0
  134. package/types/theme/devtools/ColorLineVisualizer.d.ts.map +1 -0
  135. package/types/theme/devtools/ThemeDevtoolsPanel.d.ts +87 -0
  136. package/types/theme/devtools/ThemeDevtoolsPanel.d.ts.map +1 -0
  137. package/types/theme/devtools/buildSnapshot.d.ts +12 -0
  138. package/types/theme/devtools/buildSnapshot.d.ts.map +1 -0
  139. package/types/theme/devtools/knobIcons.d.ts +33 -0
  140. package/types/theme/devtools/knobIcons.d.ts.map +1 -0
  141. package/types/theme/devtools/vizConstants.d.ts +5 -0
  142. package/types/theme/devtools/vizConstants.d.ts.map +1 -0
  143. package/types/theme/focusState.d.ts +42 -0
  144. package/types/theme/focusState.d.ts.map +1 -0
  145. package/types/theme/hairline.d.ts +88 -0
  146. package/types/theme/hairline.d.ts.map +1 -0
  147. package/types/theme/hairline.native.d.ts +42 -0
  148. package/types/theme/hairline.native.d.ts.map +1 -0
  149. package/types/theme/index.d.ts +29 -0
  150. package/types/theme/index.d.ts.map +1 -0
  151. package/types/theme/intents.d.ts +3 -0
  152. package/types/theme/intents.d.ts.map +1 -0
  153. package/types/theme/knobs.d.ts +236 -0
  154. package/types/theme/knobs.d.ts.map +1 -0
  155. package/types/theme/layoutTokens.d.ts +104 -0
  156. package/types/theme/layoutTokens.d.ts.map +1 -0
  157. package/types/theme/preset.types.d.ts +21 -0
  158. package/types/theme/preset.types.d.ts.map +1 -0
  159. package/types/theme/presets.d.ts +4 -0
  160. package/types/theme/presets.d.ts.map +1 -0
  161. package/types/theme/readableColor.d.ts +17 -0
  162. package/types/theme/readableColor.d.ts.map +1 -0
  163. package/types/theme/recipes.d.ts +200 -0
  164. package/types/theme/recipes.d.ts.map +1 -0
  165. package/types/theme/resolveKnobs.d.ts +22 -0
  166. package/types/theme/resolveKnobs.d.ts.map +1 -0
  167. package/types/theme/shared.d.ts +40 -0
  168. package/types/theme/shared.d.ts.map +1 -0
  169. package/types/theme/theme.d.ts +19 -0
  170. package/types/theme/theme.d.ts.map +1 -0
  171. package/types/theme/theme.native.d.ts +10 -0
  172. package/types/theme/theme.native.d.ts.map +1 -0
  173. package/types/theme/useColorScale.d.ts +7 -0
  174. package/types/theme/useColorScale.d.ts.map +1 -0
  175. package/types/theme/useColorScale.native.d.ts +6 -0
  176. package/types/theme/useColorScale.native.d.ts.map +1 -0
  177. package/types/theme/useResolvedKnobs.d.ts +32 -0
  178. package/types/theme/useResolvedKnobs.d.ts.map +1 -0
  179. package/types/theme/useTheme.d.ts +31 -0
  180. package/types/theme/useTheme.d.ts.map +1 -0
  181. package/types/theme/useTheme.native.d.ts +10 -0
  182. package/types/theme/useTheme.native.d.ts.map +1 -0
  183. package/CHANGELOG.md +0 -53
  184. package/src/theme/__snapshots__/resolveKnobs.spec.ts.snap +0 -69
  185. package/src/theme/pokemonScreen.spec.ts +0 -109
  186. package/tsconfig.json +0 -11
  187. package/vitest.config.mjs +0 -9
@@ -1,8 +1,25 @@
1
- import { describe, expect, it } from "vitest";
2
- import { defaultKnobs, emptyStateKnobs, Space, Size } from "./knobs";
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import {
3
+ defaultKnobs,
4
+ emptyStateKnobs,
5
+ Space,
6
+ Size,
7
+ Density,
8
+ CornerSmoothing,
9
+ FieldLabelPlacement,
10
+ RequiredMarking,
11
+ TableZebra,
12
+ BulkBarPlacement,
13
+ SelectAllScope,
14
+ TimestampStyle,
15
+ DisabledStyle,
16
+ FormAutofocus,
17
+ } from "./knobs";
3
18
  import type { Knobs } from "./knobs";
4
19
  import type { KnobProps } from "./recipes";
5
- import { resolveKnobs } from "./resolveKnobs";
20
+ import { cornerSmoothClassName } from "./cornerSmoothing";
21
+ import { defaultPreset, boldPreset } from "./presets";
22
+ import { applyDensity, capContainerRadius, resolveKnobs } from "./resolveKnobs";
6
23
  import {
7
24
  normalizeToHex,
8
25
  relativeLuminance,
@@ -18,8 +35,12 @@ function knobsWith(overrides: Partial<Knobs>): Knobs {
18
35
  const expectedKnobPropKeys: (keyof KnobProps)[] = [
19
36
  "borderRadius",
20
37
  "borderRadiusOuter",
38
+ "containerRadius",
21
39
  "elevation",
22
40
  "surface",
41
+ "cardSurface",
42
+ "elevatedSurface",
43
+ "featureSurface",
23
44
  "inputSurface",
24
45
  "panelPadding",
25
46
  "gap",
@@ -36,6 +57,15 @@ const expectedKnobPropKeys: (keyof KnobProps)[] = [
36
57
  "inputBackground",
37
58
  "space",
38
59
  "size",
60
+ "density",
61
+ "fieldLabelPlacement",
62
+ "requiredMarking",
63
+ "tableZebra",
64
+ "bulkBarPlacement",
65
+ "selectAllScope",
66
+ "timestampStyle",
67
+ "disabledStyle",
68
+ "formAutofocus",
39
69
  ];
40
70
 
41
71
  // ── colorRules unit tests ────────────────────────────────────
@@ -190,6 +220,33 @@ describe("resolveKnobs", () => {
190
220
  });
191
221
  });
192
222
 
223
+ // ── elevation on NATIVE (SB-N-04) ──────────────────────────
224
+ // Android's RCTView `elevation` prop is a Double; a token string reaching a
225
+ // plain RN View kills the ReactHost. Native must emit the numeric
226
+ // tokens.size values Tamagui would resolve those tokens to.
227
+
228
+ describe("elevation knob (native)", () => {
229
+ it("emits RN-safe numbers, never token strings", async () => {
230
+ vi.resetModules();
231
+ vi.doMock("@multiplatform.one/platform", () => ({ isWeb: false }));
232
+ const { resolveKnobs: resolveNative } = await import("./resolveKnobs");
233
+ try {
234
+ expect(resolveNative(knobsWith({ elevation: "none" })).knobProps.elevation).toBeUndefined();
235
+ const small = resolveNative(
236
+ knobsWith({ elevation: "small", hover: { elevation: "medium" } }),
237
+ );
238
+ expect(small.knobProps.elevation).toBe(20);
239
+ expect(small.knobProps.elevatedSurface.elevation).toBe(20);
240
+ expect(small.elevation.hoverKnobProps?.elevation).toBe(28);
241
+ expect(resolveNative(knobsWith({ elevation: "medium" })).knobProps.elevation).toBe(28);
242
+ expect(resolveNative(knobsWith({ elevation: "large" })).knobProps.elevation).toBe(44);
243
+ } finally {
244
+ vi.doUnmock("@multiplatform.one/platform");
245
+ vi.resetModules();
246
+ }
247
+ });
248
+ });
249
+
193
250
  // ── interaction state overrides ──────────────────────────────
194
251
 
195
252
  describe("interaction state overrides", () => {
@@ -213,12 +270,16 @@ describe("resolveKnobs", () => {
213
270
  expect(control.hoverKnobProps?.borderRadius).toBe("$12");
214
271
  });
215
272
 
216
- it("empty state overrides produce undefined", () => {
273
+ it("empty state knobs still emit default hover/press layers + focus ring (W12)", () => {
217
274
  const { control } = resolveKnobs(defaultKnobs);
218
- expect(control.hoverKnobProps).toBeUndefined();
219
- expect(control.pressKnobProps).toBeUndefined();
275
+ expect(control.hoverKnobProps).toEqual({ backgroundColor: "$backgroundHover" });
276
+ expect(control.pressKnobProps).toEqual({ backgroundColor: "$backgroundPress" });
220
277
  expect(control.focusKnobProps).toBeUndefined();
221
- expect(control.focusVisibleKnobProps).toBeUndefined();
278
+ expect(control.focusVisibleKnobProps).toMatchObject({
279
+ outlineWidth: 2,
280
+ outlineStyle: "solid",
281
+ outlineColor: "$outlineColor",
282
+ });
222
283
  });
223
284
 
224
285
  it("focus.elevation maps to elevation.focusKnobProps.elevation", () => {
@@ -240,9 +301,9 @@ describe("resolveKnobs", () => {
240
301
  expect(knobProps.panelPadding.padding).toBe("$3");
241
302
  });
242
303
 
243
- it('maps gap "large" → "$4"', () => {
304
+ it('maps gap "large" → "$5"', () => {
244
305
  const { knobProps } = resolveKnobs(knobsWith({ space: "large" }));
245
- expect(knobProps.gap.gap).toBe("$4");
306
+ expect(knobProps.gap.gap).toBe("$5");
246
307
  });
247
308
 
248
309
  it("passes space value through to knobProps", () => {
@@ -262,10 +323,10 @@ describe("resolveKnobs", () => {
262
323
  expect(knobProps.panelPadding.padding).toBe("$3");
263
324
  });
264
325
 
265
- // T2.1c — space "large" still maps gapLg → "$5" regardless of size
266
- it('space "large" maps gapLg → "$5" regardless of size', () => {
326
+ // T2.1c — space "large" still maps gapLg → "$6" regardless of size
327
+ it('space "large" maps gapLg → "$6" regardless of size', () => {
267
328
  const { knobProps } = resolveKnobs(knobsWith({ space: "large", size: "small" }));
268
- expect(knobProps.gapLg.gap).toBe("$5");
329
+ expect(knobProps.gapLg.gap).toBe("$6");
269
330
  });
270
331
  });
271
332
 
@@ -314,9 +375,256 @@ describe("resolveKnobs", () => {
314
375
  it("default knobs (size+space both medium) produce consistent output", () => {
315
376
  const resolved = resolveKnobs(defaultKnobs);
316
377
  expect(resolved.knobProps.sizeToken).toBe("$4");
317
- expect(resolved.knobProps.gap.gap).toBe("$3");
378
+ expect(resolved.knobProps.gap.gap).toBe("$4");
318
379
  expect(resolved.knobProps.panelPadding.padding).toBe("$4");
319
- expect(resolved.knobProps.gapLg.gap).toBe("$4");
380
+ expect(resolved.knobProps.gapLg.gap).toBe("$5");
381
+ });
382
+ });
383
+
384
+ // ── surface tiers ─────────────────────────────────────────
385
+
386
+ describe("surface tier recipes", () => {
387
+ it("cardSurface = bg + radius + panelPadding, no border/shadow", () => {
388
+ const { knobProps } = resolveKnobs(defaultKnobs);
389
+ expect(knobProps.cardSurface).toEqual({
390
+ backgroundColor: "$color1",
391
+ borderRadius: "$4",
392
+ padding: "$4",
393
+ });
394
+ expect(knobProps.cardSurface).not.toHaveProperty("borderWidth");
395
+ expect(knobProps.cardSurface).not.toHaveProperty("elevation");
396
+ });
397
+
398
+ it("cardSurface tracks borderRadius and space knobs (cap bites: large radius 16px > small padding 13px)", () => {
399
+ const { knobProps } = resolveKnobs(knobsWith({ borderRadius: "large", space: "small" }));
400
+ expect(knobProps.cardSurface.borderRadius).toBe(13);
401
+ expect(knobProps.cardSurface.padding).toBe("$3");
402
+ });
403
+
404
+ it("cardSurface keeps the radius token while it fits the padding", () => {
405
+ const { knobProps } = resolveKnobs(knobsWith({ borderRadius: "large", space: "medium" }));
406
+ expect(knobProps.cardSurface.borderRadius).toBe("$6");
407
+ });
408
+
409
+ it("elevatedSurface = surface + radius + elevation", () => {
410
+ const { knobProps } = resolveKnobs(defaultKnobs);
411
+ expect(knobProps.elevatedSurface).toEqual({
412
+ ...knobProps.surface,
413
+ borderRadius: knobProps.borderRadius.borderRadius,
414
+ elevation: knobProps.elevation,
415
+ });
416
+ });
417
+
418
+ it("featureSurface uses big radius, $6 padding, and an ultra-soft shadow", () => {
419
+ const { knobProps } = resolveKnobs(defaultKnobs);
420
+ expect(knobProps.featureSurface.borderRadius).toBe("$10");
421
+ expect(knobProps.featureSurface.padding).toBe("$6");
422
+ expect(knobProps.featureSurface.shadowOffset).toEqual({ width: 0, height: 9 });
423
+ expect(knobProps.featureSurface.shadowRadius).toBe(12);
424
+ expect(knobProps.featureSurface).not.toHaveProperty("borderWidth");
425
+ });
426
+ });
427
+
428
+ // ── container radius cap (Axiom 1 CLIP) ──────────────────
429
+
430
+ describe("containerRadius (Axiom 1 child-clip cap)", () => {
431
+ it("default knobs are untouched (radius $4 = 9px fits medium padding 18px)", () => {
432
+ const { knobProps } = resolveKnobs(defaultKnobs);
433
+ expect(knobProps.containerRadius).toEqual({ borderRadius: "$4" });
434
+ expect(knobProps.cardSurface.borderRadius).toBe("$4");
435
+ });
436
+
437
+ it("caps full radius at the padding px for every space", () => {
438
+ expect(
439
+ resolveKnobs(knobsWith({ borderRadius: "full", space: "small" })).knobProps.containerRadius
440
+ .borderRadius,
441
+ ).toBe(13);
442
+ expect(
443
+ resolveKnobs(knobsWith({ borderRadius: "full", space: "medium" })).knobProps.containerRadius
444
+ .borderRadius,
445
+ ).toBe(18);
446
+ expect(
447
+ resolveKnobs(knobsWith({ borderRadius: "full", space: "large" })).knobProps.containerRadius
448
+ .borderRadius,
449
+ ).toBe(32);
450
+ });
451
+
452
+ it("only bites at large/full: none/small/medium keep their token at every space", () => {
453
+ for (const space of Object.values(Space)) {
454
+ for (const [radius, token] of [
455
+ ["none", "$0"],
456
+ ["small", "$2"],
457
+ ["medium", "$4"],
458
+ ] as const) {
459
+ const { knobProps } = resolveKnobs(knobsWith({ borderRadius: radius, space }));
460
+ expect(knobProps.containerRadius.borderRadius).toBe(token);
461
+ }
462
+ }
463
+ });
464
+
465
+ it("large radius caps only under small space (16px > 13px)", () => {
466
+ expect(
467
+ resolveKnobs(knobsWith({ borderRadius: "large", space: "small" })).knobProps.containerRadius
468
+ .borderRadius,
469
+ ).toBe(13);
470
+ expect(
471
+ resolveKnobs(knobsWith({ borderRadius: "large", space: "medium" })).knobProps
472
+ .containerRadius.borderRadius,
473
+ ).toBe("$6");
474
+ expect(
475
+ resolveKnobs(knobsWith({ borderRadius: "large", space: "large" })).knobProps.containerRadius
476
+ .borderRadius,
477
+ ).toBe("$6");
478
+ });
479
+
480
+ it("mirrored px values agree with the live @tamagui/themes tokens (drift guard)", async () => {
481
+ const { tokens } = await import("@tamagui/themes");
482
+ const radiusTokenByKnob = {
483
+ none: "$0",
484
+ small: "$2",
485
+ medium: "$4",
486
+ large: "$6",
487
+ full: "$12",
488
+ } as const;
489
+ const paddingTokenByKnob = { small: "$3", medium: "$4", large: "$6" } as const;
490
+ for (const [radiusKnob, radiusToken] of Object.entries(radiusTokenByKnob)) {
491
+ for (const [spaceKnob, paddingToken] of Object.entries(paddingTokenByKnob)) {
492
+ const radiusPx = (tokens.radius as any)[radiusToken.slice(1)].val as number;
493
+ const paddingPx = (tokens.space as any)[paddingToken.slice(1)].val as number;
494
+ const expected = radiusPx > paddingPx ? paddingPx : radiusToken;
495
+ expect(
496
+ capContainerRadius(radiusKnob as Knobs["borderRadius"], spaceKnob as Knobs["space"]),
497
+ `radius=${radiusKnob} space=${spaceKnob}`,
498
+ ).toBe(expected);
499
+ }
500
+ }
501
+ });
502
+ });
503
+
504
+ // ── cornerSmoothing (Axiom 15 OPTICS) ─────────────────────
505
+
506
+ describe("cornerSmoothing knob", () => {
507
+ const radiusFragments = [
508
+ "borderRadius",
509
+ "borderRadiusOuter",
510
+ "containerRadius",
511
+ "cardSurface",
512
+ "elevatedSurface",
513
+ "featureSurface",
514
+ ] as const;
515
+
516
+ it('defaults to "round" and emits no class on any fragment', () => {
517
+ expect(defaultKnobs.cornerSmoothing).toBe(CornerSmoothing.Round);
518
+ const { knobProps } = resolveKnobs(defaultKnobs);
519
+ for (const fragment of radiusFragments) {
520
+ expect(knobProps[fragment], fragment).not.toHaveProperty("className");
521
+ }
522
+ });
523
+
524
+ it('treats an absent knob as "round" (optional key, older presets)', () => {
525
+ const { cornerSmoothing: _omit, ...withoutKnob } = defaultKnobs;
526
+ const { knobProps } = resolveKnobs(withoutKnob as Knobs);
527
+ for (const fragment of radiusFragments) {
528
+ expect(knobProps[fragment], fragment).not.toHaveProperty("className");
529
+ }
530
+ });
531
+
532
+ it("smooth × arc classes (small/medium/large): every radius-bearing fragment carries the class", () => {
533
+ for (const radius of ["small", "medium", "large"] as const) {
534
+ const { knobProps } = resolveKnobs(
535
+ knobsWith({ cornerSmoothing: "smooth", borderRadius: radius }),
536
+ );
537
+ for (const fragment of radiusFragments) {
538
+ expect(knobProps[fragment].className, `${radius}/${fragment}`).toBe(
539
+ cornerSmoothClassName,
540
+ );
541
+ }
542
+ }
543
+ });
544
+
545
+ it("smooth × none: nothing to smooth except the fixed-radius featureSurface", () => {
546
+ const { knobProps } = resolveKnobs(
547
+ knobsWith({ cornerSmoothing: "smooth", borderRadius: "none" }),
548
+ );
549
+ // $0 corners have no arc — square world stays square (Axiom 3 NULL).
550
+ expect(knobProps.borderRadius).not.toHaveProperty("className");
551
+ expect(knobProps.borderRadiusOuter).not.toHaveProperty("className");
552
+ expect(knobProps.containerRadius).not.toHaveProperty("className");
553
+ expect(knobProps.cardSurface).not.toHaveProperty("className");
554
+ expect(knobProps.elevatedSurface).not.toHaveProperty("className");
555
+ // featureSurface's radius is fixed ($10, knob-independent) — its arc
556
+ // exists at every knob value, so it smooths.
557
+ expect(knobProps.featureSurface.className).toBe(cornerSmoothClassName);
558
+ });
559
+
560
+ it("smooth × full: pill/circle identity fragments ignore smoothing (R-BINARY/R-PILL)", () => {
561
+ const { knobProps } = resolveKnobs(
562
+ knobsWith({ cornerSmoothing: "smooth", borderRadius: "full" }),
563
+ );
564
+ // Uncapped $12 turns control-tier parts into capsules/circles —
565
+ // identity shapes that squircle ends would distort.
566
+ expect(knobProps.borderRadius).not.toHaveProperty("className");
567
+ expect(knobProps.elevatedSurface).not.toHaveProperty("className");
568
+ // Capped/outer fragments stay plain arcs (cap = padding px, outer ≤ $5)
569
+ // and keep smoothing.
570
+ expect(knobProps.borderRadiusOuter.className).toBe(cornerSmoothClassName);
571
+ expect(knobProps.containerRadius.className).toBe(cornerSmoothClassName);
572
+ expect(knobProps.cardSurface.className).toBe(cornerSmoothClassName);
573
+ expect(knobProps.featureSurface.className).toBe(cornerSmoothClassName);
574
+ });
575
+
576
+ it("smooth keeps the Axiom 1 container cap (clamped radii keep their caps)", () => {
577
+ const smooth = resolveKnobs(
578
+ knobsWith({ cornerSmoothing: "smooth", borderRadius: "full", space: "small" }),
579
+ );
580
+ const round = resolveKnobs(knobsWith({ borderRadius: "full", space: "small" }));
581
+ expect(smooth.knobProps.containerRadius.borderRadius).toBe(13);
582
+ expect(round.knobProps.containerRadius.borderRadius).toBe(13);
583
+ expect(smooth.knobProps.cardSurface.borderRadius).toBe(13);
584
+ expect(smooth.knobProps.containerRadius.className).toBe(cornerSmoothClassName);
585
+ });
586
+
587
+ it("smooth changes nothing but the class (radius/border/padding identical to round)", () => {
588
+ const round = resolveKnobs(knobsWith({ cornerSmoothing: "round" }));
589
+ const smooth = resolveKnobs(knobsWith({ cornerSmoothing: "smooth" }));
590
+ for (const fragment of radiusFragments) {
591
+ const { className: _cls, ...smoothRest } = smooth.knobProps[fragment];
592
+ expect(smoothRest, fragment).toEqual(round.knobProps[fragment]);
593
+ }
594
+ });
595
+
596
+ it("never leaks onto non-radius fragments", () => {
597
+ const { knobProps } = resolveKnobs(knobsWith({ cornerSmoothing: "smooth" }));
598
+ expect(knobProps.surface).not.toHaveProperty("className");
599
+ expect(knobProps.inputSurface).not.toHaveProperty("className");
600
+ expect(knobProps.panelPadding).not.toHaveProperty("className");
601
+ expect(knobProps.gap).not.toHaveProperty("className");
602
+ });
603
+
604
+ it("shipped presets pin the house default (round)", () => {
605
+ expect(defaultPreset.knobs.cornerSmoothing).toBe("round");
606
+ expect(boldPreset.knobs.cornerSmoothing).toBe("round");
607
+ });
608
+
609
+ // Native resolves to `round` for now (SquircleView adoption is phase 2) —
610
+ // no className may reach RN views regardless of the knob.
611
+ it("native emits no class for either value", async () => {
612
+ vi.resetModules();
613
+ vi.doMock("@multiplatform.one/platform", () => ({ isWeb: false }));
614
+ const { resolveKnobs: resolveNative } = await import("./resolveKnobs");
615
+ try {
616
+ for (const cornerSmoothing of ["round", "smooth"] as const) {
617
+ const { knobProps } = resolveNative(knobsWith({ cornerSmoothing }));
618
+ for (const fragment of radiusFragments) {
619
+ expect(knobProps[fragment], `${cornerSmoothing}/${fragment}`).not.toHaveProperty(
620
+ "className",
621
+ );
622
+ }
623
+ }
624
+ } finally {
625
+ vi.doUnmock("@multiplatform.one/platform");
626
+ vi.resetModules();
627
+ }
320
628
  });
321
629
  });
322
630
 
@@ -457,7 +765,7 @@ describe("resolveKnobs", () => {
457
765
  const overridden = resolveKnobs(defaultKnobs, () => ({
458
766
  elevation: "$4",
459
767
  }));
460
- expect(baseline.knobProps.elevation).toBeUndefined();
768
+ expect(baseline.knobProps.elevation).toBe("$1");
461
769
  expect(overridden.knobProps.elevation).toBe("$4");
462
770
  });
463
771
 
@@ -485,8 +793,8 @@ describe("resolveKnobs", () => {
485
793
 
486
794
  const gapMap: Record<string, string> = {
487
795
  small: "$2",
488
- medium: "$3",
489
- large: "$4",
796
+ medium: "$4",
797
+ large: "$5",
490
798
  };
491
799
 
492
800
  const panelPaddingMap: Record<string, string> = {
@@ -497,8 +805,8 @@ describe("resolveKnobs", () => {
497
805
 
498
806
  const gapLgMap: Record<string, string> = {
499
807
  small: "$3",
500
- medium: "$4",
501
- large: "$5",
808
+ medium: "$5",
809
+ large: "$6",
502
810
  };
503
811
 
504
812
  // T4.1a — every Size enum value produces a valid sizeToken
@@ -554,11 +862,11 @@ describe("resolveKnobs", () => {
554
862
  expect(defaultKnobs.space).toBe("medium");
555
863
  });
556
864
 
557
- // T4.1i — emptyStateKnobs is still valid
865
+ // T4.1i — emptyStateKnobs is still valid (W12: default hover layer remains)
558
866
  it("emptyStateKnobs is still valid (no required size field breaks it)", () => {
559
867
  const knobs = knobsWith({ hover: emptyStateKnobs });
560
868
  const { control } = resolveKnobs(knobs);
561
- expect(control.hoverKnobProps).toBeUndefined();
869
+ expect(control.hoverKnobProps).toEqual({ backgroundColor: "$backgroundHover" });
562
870
  });
563
871
 
564
872
  // T4.1j — size "large" does not change borderRadius
@@ -569,3 +877,234 @@ describe("resolveKnobs", () => {
569
877
  });
570
878
  });
571
879
  });
880
+
881
+ // ── Density (DG-DEN-01 / W8) ─────────────────────────────────
882
+
883
+ describe("applyDensity", () => {
884
+ it('defaultKnobs.density is "comfortable"', () => {
885
+ expect(defaultKnobs.density).toBe(Density.Comfortable);
886
+ });
887
+
888
+ it("comfortable density leaves size and space unchanged", () => {
889
+ const knobs = knobsWith({ size: "large", space: "large", density: "comfortable" });
890
+ const applied = applyDensity(knobs);
891
+ expect(applied.size).toBe("large");
892
+ expect(applied.space).toBe("large");
893
+ expect(applied.density).toBe("comfortable");
894
+ });
895
+
896
+ it("compact density steps size and space down one level", () => {
897
+ expect(
898
+ applyDensity(knobsWith({ size: "large", space: "large", density: "compact" })),
899
+ ).toMatchObject({ size: "medium", space: "medium", density: "compact" });
900
+ expect(
901
+ applyDensity(knobsWith({ size: "medium", space: "medium", density: "compact" })),
902
+ ).toMatchObject({ size: "small", space: "small", density: "compact" });
903
+ expect(
904
+ applyDensity(knobsWith({ size: "small", space: "small", density: "compact" })),
905
+ ).toMatchObject({ size: "small", space: "small", density: "compact" });
906
+ });
907
+
908
+ it("compact:true override wins over comfortable density knob", () => {
909
+ const applied = applyDensity(
910
+ knobsWith({ size: "medium", space: "medium", density: "comfortable" }),
911
+ true,
912
+ );
913
+ expect(applied).toMatchObject({ size: "small", space: "small", density: "compact" });
914
+ });
915
+
916
+ it("compact:false override wins over compact density knob", () => {
917
+ const applied = applyDensity(
918
+ knobsWith({ size: "medium", space: "medium", density: "compact" }),
919
+ false,
920
+ );
921
+ expect(applied).toMatchObject({ size: "medium", space: "medium", density: "comfortable" });
922
+ });
923
+
924
+ it("resolveKnobs exposes density on knobProps", () => {
925
+ expect(resolveKnobs(defaultKnobs).knobProps.density).toBe("comfortable");
926
+ expect(resolveKnobs(knobsWith({ density: "compact" })).knobProps.density).toBe("compact");
927
+ });
928
+
929
+ it("compact density produces smaller sizeToken and gap than comfortable", () => {
930
+ const comfortable = resolveKnobs(applyDensity(knobsWith({ size: "medium", space: "medium" })));
931
+ const compact = resolveKnobs(
932
+ applyDensity(knobsWith({ size: "medium", space: "medium", density: "compact" })),
933
+ );
934
+ expect(comfortable.knobProps.sizeToken).toBe("$4");
935
+ expect(compact.knobProps.sizeToken).toBe("$3");
936
+ expect(comfortable.knobProps.gap).toEqual({ gap: "$4" });
937
+ expect(compact.knobProps.gap).toEqual({ gap: "$2" });
938
+ });
939
+
940
+ it("compact density preserves house-decision knobs", () => {
941
+ const knobs = knobsWith({
942
+ density: "compact",
943
+ fieldLabelPlacement: "side",
944
+ requiredMarking: "asterisk",
945
+ tableZebra: "on",
946
+ bulkBarPlacement: "bottom",
947
+ selectAllScope: "filtered",
948
+ timestampStyle: "relative",
949
+ disabledStyle: "dimWhole",
950
+ formAutofocus: "on",
951
+ });
952
+ const applied = applyDensity(knobs);
953
+ expect(applied).toMatchObject({
954
+ density: "compact",
955
+ fieldLabelPlacement: "side",
956
+ requiredMarking: "asterisk",
957
+ tableZebra: "on",
958
+ bulkBarPlacement: "bottom",
959
+ selectAllScope: "filtered",
960
+ timestampStyle: "relative",
961
+ disabledStyle: "dimWhole",
962
+ formAutofocus: "on",
963
+ });
964
+ });
965
+ });
966
+
967
+ // ── House decisions (design-guidelines.md) ────────────────────
968
+
969
+ describe("house decision knobs", () => {
970
+ it("defaultKnobs use recommended house defaults", () => {
971
+ expect(defaultKnobs.fieldLabelPlacement).toBe(FieldLabelPlacement.Top);
972
+ expect(defaultKnobs.requiredMarking).toBe(RequiredMarking.Minority);
973
+ expect(defaultKnobs.tableZebra).toBe(TableZebra.Off);
974
+ expect(defaultKnobs.bulkBarPlacement).toBe(BulkBarPlacement.Top);
975
+ expect(defaultKnobs.selectAllScope).toBe(SelectAllScope.Page);
976
+ expect(defaultKnobs.timestampStyle).toBe(TimestampStyle.Absolute);
977
+ expect(defaultKnobs.disabledStyle).toBe(DisabledStyle.KeepLabel);
978
+ expect(defaultKnobs.formAutofocus).toBe(FormAutofocus.Off);
979
+ });
980
+
981
+ it("resolveKnobs exposes house knobs on knobProps (pass-through)", () => {
982
+ const { knobProps } = resolveKnobs(defaultKnobs);
983
+ expect(knobProps.fieldLabelPlacement).toBe("top");
984
+ expect(knobProps.requiredMarking).toBe("minority");
985
+ expect(knobProps.tableZebra).toBe("off");
986
+ expect(knobProps.bulkBarPlacement).toBe("top");
987
+ expect(knobProps.selectAllScope).toBe("page");
988
+ expect(knobProps.timestampStyle).toBe("absolute");
989
+ expect(knobProps.disabledStyle).toBe("keepLabel");
990
+ expect(knobProps.formAutofocus).toBe("off");
991
+ });
992
+
993
+ it("resolveKnobs reflects house knob overrides", () => {
994
+ const { knobProps } = resolveKnobs(
995
+ knobsWith({
996
+ fieldLabelPlacement: "floating",
997
+ requiredMarking: "optional",
998
+ tableZebra: "on",
999
+ bulkBarPlacement: "bottom",
1000
+ selectAllScope: "filtered",
1001
+ timestampStyle: "relative",
1002
+ disabledStyle: "dimWhole",
1003
+ formAutofocus: "on",
1004
+ }),
1005
+ );
1006
+ expect(knobProps.fieldLabelPlacement).toBe("floating");
1007
+ expect(knobProps.requiredMarking).toBe("optional");
1008
+ expect(knobProps.tableZebra).toBe("on");
1009
+ expect(knobProps.bulkBarPlacement).toBe("bottom");
1010
+ expect(knobProps.selectAllScope).toBe("filtered");
1011
+ expect(knobProps.timestampStyle).toBe("relative");
1012
+ expect(knobProps.disabledStyle).toBe("dimWhole");
1013
+ expect(knobProps.formAutofocus).toBe("on");
1014
+ });
1015
+
1016
+ it("house knobs do not alter structural token resolution", () => {
1017
+ const baseline = resolveKnobs(defaultKnobs);
1018
+ const housey = resolveKnobs(
1019
+ knobsWith({
1020
+ fieldLabelPlacement: "side",
1021
+ tableZebra: "on",
1022
+ formAutofocus: "on",
1023
+ }),
1024
+ );
1025
+ expect(housey.knobProps.sizeToken).toBe(baseline.knobProps.sizeToken);
1026
+ expect(housey.knobProps.gap).toEqual(baseline.knobProps.gap);
1027
+ expect(housey.knobProps.borderRadius).toEqual(baseline.knobProps.borderRadius);
1028
+ expect(housey.knobProps.density).toBe(baseline.knobProps.density);
1029
+ });
1030
+ });
1031
+
1032
+ // ── Disabled treatment recipe (LC-40 DISABLED-VISIBLE) ────────
1033
+
1034
+ describe("disabledState recipe (LC-40)", () => {
1035
+ it("keepLabel (default) resolves a VISIBLE chrome treatment — not a no-op", () => {
1036
+ const { disabledState } = resolveKnobs(defaultKnobs);
1037
+ expect(disabledState.style).toBe("keepLabel");
1038
+ // Text-bearing chrome: fill/border wash to muted ramp steps, no opacity
1039
+ // (inner text keeps its own alpha so it can hold >= AA).
1040
+ expect(disabledState.surfaceKnobProps).toEqual({
1041
+ backgroundColor: "$color3",
1042
+ borderColor: "$color6",
1043
+ cursor: "not-allowed",
1044
+ });
1045
+ expect(disabledState.surfaceKnobProps).not.toHaveProperty("opacity");
1046
+ // Selected-while-disabled tier: one wash step stronger than the plain
1047
+ // wash so the chosen segment stays visibly chosen (Axiom 6).
1048
+ expect(disabledState.selectedSurfaceKnobProps).toEqual({
1049
+ backgroundColor: "$color5",
1050
+ borderColor: "$color8",
1051
+ cursor: "not-allowed",
1052
+ });
1053
+ // Text-free chrome: opacity dim.
1054
+ expect(disabledState.chromeKnobProps).toEqual({ opacity: 0.5, cursor: "not-allowed" });
1055
+ // Inner text re-anchors to the documented lowest AA step ($color11).
1056
+ expect(disabledState.textKnobProps).toEqual({ color: "$color11" });
1057
+ // keepLabel never dims the assembly (label/helper stay fully readable).
1058
+ expect(disabledState.assemblyKnobProps).toEqual({});
1059
+ });
1060
+
1061
+ it("dimWhole dims the assembly once — control fragments carry only the affordance", () => {
1062
+ const { disabledState } = resolveKnobs(knobsWith({ disabledStyle: "dimWhole" }));
1063
+ expect(disabledState.style).toBe("dimWhole");
1064
+ expect(disabledState.assemblyKnobProps).toEqual({ opacity: 0.5 });
1065
+ // No chrome-level opacity: the assembly owner applies the single dim.
1066
+ expect(disabledState.surfaceKnobProps).toEqual({ cursor: "not-allowed" });
1067
+ expect(disabledState.chromeKnobProps).toEqual({ cursor: "not-allowed" });
1068
+ expect(disabledState.textKnobProps).toEqual({});
1069
+ // Exception: the selected tier still washes — consumers drop the live
1070
+ // active fill when disabled, so the assembly dim alone cannot show
1071
+ // which option is chosen.
1072
+ expect(disabledState.selectedSurfaceKnobProps).toEqual({
1073
+ backgroundColor: "$color5",
1074
+ borderColor: "$color8",
1075
+ cursor: "not-allowed",
1076
+ });
1077
+ });
1078
+
1079
+ it("both values expose complete fragments for every anatomy slot", () => {
1080
+ for (const disabledStyle of ["keepLabel", "dimWhole"] as const) {
1081
+ const { disabledState } = resolveKnobs(knobsWith({ disabledStyle }));
1082
+ expect(disabledState).toHaveProperty("surfaceKnobProps");
1083
+ expect(disabledState).toHaveProperty("selectedSurfaceKnobProps");
1084
+ expect(disabledState).toHaveProperty("chromeKnobProps");
1085
+ expect(disabledState).toHaveProperty("textKnobProps");
1086
+ expect(disabledState).toHaveProperty("assemblyKnobProps");
1087
+ // The not-allowed affordance rides the control chrome under both values.
1088
+ expect(disabledState.surfaceKnobProps.cursor).toBe("not-allowed");
1089
+ expect(disabledState.selectedSurfaceKnobProps.cursor).toBe("not-allowed");
1090
+ expect(disabledState.chromeKnobProps.cursor).toBe("not-allowed");
1091
+ // Selected tier is visibly distinct from the plain wash in both models.
1092
+ expect(disabledState.selectedSurfaceKnobProps.backgroundColor).not.toBe(
1093
+ disabledState.surfaceKnobProps.backgroundColor,
1094
+ );
1095
+ }
1096
+ });
1097
+
1098
+ it("disabled tiers are theme-relative ramp steps (no hex, works under intents)", () => {
1099
+ const { disabledState } = resolveKnobs(defaultKnobs);
1100
+ for (const value of [
1101
+ disabledState.surfaceKnobProps.backgroundColor,
1102
+ disabledState.surfaceKnobProps.borderColor,
1103
+ disabledState.selectedSurfaceKnobProps.backgroundColor,
1104
+ disabledState.selectedSurfaceKnobProps.borderColor,
1105
+ disabledState.textKnobProps.color,
1106
+ ]) {
1107
+ expect(value).toMatch(/^\$color\d+$/);
1108
+ }
1109
+ });
1110
+ });