@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
@@ -0,0 +1,101 @@
1
+ /**
2
+ * OPTICS hairline (Axiom 15): a divider means "one device pixel", not "one
3
+ * CSS pixel". On high-DPI web screens a 1px CSS divider paints 2–3 device
4
+ * pixels and reads heavy; 0.5px paints a single device pixel on 2x displays.
5
+ *
6
+ * Engine notes (probed against Chromium 147 / WebKit / Gecko):
7
+ * - Blink floors sub-1px BORDER widths up to 1 CSS px, but honors fractional
8
+ * HEIGHT/WIDTH — so standalone rules use the filled `line`/`vline`
9
+ * treatments (crisp everywhere), while container-owned edge borders
10
+ * (`bottom`/`top`/`right`/`left`) render true hairlines on WebKit/Gecko
11
+ * and gracefully stay 1px on Blink.
12
+ * - Low-DPI screens (1dppx) get a 1px fallback via the resolution media
13
+ * query below, so hairlines never fade or vanish there.
14
+ *
15
+ * Scope: DIVIDERS ONLY — separators between rows/sections, menu separators,
16
+ * table head rules, full-bleed panel rules. Control borders (inputs,
17
+ * buttons, control groups) keep the knob-driven `borderWidth` semantics
18
+ * from `resolveKnobs` and MUST NOT use this token.
19
+ *
20
+ * Native twin: `hairline.native.ts` (react-native StyleSheet.hairlineWidth,
21
+ * no CSS classes).
22
+ */
23
+
24
+ /** Divider line width: half a CSS pixel = one device pixel on 2x displays. */
25
+ export const hairlineWidth: number = 0.5;
26
+
27
+ /**
28
+ * Spreadable divider treatments. Each entry pairs the 0.5px style with the
29
+ * fallback class the low-DPI media query targets:
30
+ *
31
+ * <Separator {...hairline.line} /> // horizontal rule element
32
+ * <Separator vertical {...hairline.vline} /> // vertical rule element
33
+ * <View {...hairline.height} /> // already-filled 1px line
34
+ * <YStack {...hairline.bottom} /> // container-owned edge rule
35
+ */
36
+ export const hairline = {
37
+ /**
38
+ * Standalone horizontal rule as a FILLED line (tamagui Separator draws a
39
+ * border, which Blink cannot paint below 1 CSS px — flip it to a filled
40
+ * fractional-height line, crisp in every engine). Consumers may override
41
+ * `backgroundColor` after the spread for non-default divider colors.
42
+ */
43
+ line: {
44
+ height: hairlineWidth,
45
+ maxHeight: hairlineWidth,
46
+ // Separator is a `flex: 1` item, so in a stack the main-axis size comes
47
+ // from flex-basis (height alone is ignored):
48
+ flexBasis: hairlineWidth,
49
+ borderBottomWidth: 0,
50
+ backgroundColor: "$borderColor",
51
+ className: "mp-hairline-h",
52
+ },
53
+ /** Standalone vertical rule as a filled line (see `line`). */
54
+ vline: {
55
+ width: hairlineWidth,
56
+ maxWidth: hairlineWidth,
57
+ flexBasis: hairlineWidth,
58
+ borderRightWidth: 0,
59
+ backgroundColor: "$borderColor",
60
+ className: "mp-hairline-w",
61
+ },
62
+ /** Already-filled 1px line elements (e.g. menu separator views). */
63
+ height: { height: hairlineWidth, className: "mp-hairline-h" },
64
+ // Container-owned edge rules. True hairline on WebKit/Gecko; Blink rounds
65
+ // sub-1px borders up to 1 CSS px (the pre-hairline status quo there).
66
+ bottom: { borderBottomWidth: hairlineWidth, className: "mp-hairline-b" },
67
+ top: { borderTopWidth: hairlineWidth, className: "mp-hairline-t" },
68
+ right: { borderRightWidth: hairlineWidth, className: "mp-hairline-r" },
69
+ left: { borderLeftWidth: hairlineWidth, className: "mp-hairline-l" },
70
+ } as const;
71
+
72
+ const STYLE_TAG_ID = "mp-hairline-styles";
73
+
74
+ /**
75
+ * 1px fallback for screens where 0.5px cannot map to a whole device pixel.
76
+ * (`-webkit-max-device-pixel-ratio` covers Safari versions without
77
+ * `resolution` media-query support.) Exported for unit tests.
78
+ */
79
+ export const hairlineFallbackCss = `@media (max-resolution: 1.49dppx), (-webkit-max-device-pixel-ratio: 1.49) {
80
+ .mp-hairline-b { border-bottom-width: 1px !important; }
81
+ .mp-hairline-t { border-top-width: 1px !important; }
82
+ .mp-hairline-r { border-right-width: 1px !important; }
83
+ .mp-hairline-l { border-left-width: 1px !important; }
84
+ .mp-hairline-h { height: 1px !important; max-height: 1px !important; flex-basis: 1px !important; }
85
+ .mp-hairline-w { width: 1px !important; max-width: 1px !important; flex-basis: 1px !important; }
86
+ .mp-hairline-ie { border-inline-end-width: 1px !important; }
87
+ }`;
88
+
89
+ /** Idempotently mount the low-DPI fallback stylesheet (no-op off-DOM). */
90
+ export function ensureHairlineFallback(): void {
91
+ if (typeof document === "undefined") return;
92
+ if (document.getElementById(STYLE_TAG_ID)) return;
93
+ const tag = document.createElement("style");
94
+ tag.id = STYLE_TAG_ID;
95
+ tag.textContent = hairlineFallbackCss;
96
+ document.head.appendChild(tag);
97
+ }
98
+
99
+ // The fallback is static CSS (no knob/theme dependency), so mount it as soon
100
+ // as the module loads in a DOM environment.
101
+ ensureHairlineFallback();
@@ -1,16 +1,24 @@
1
1
  export * from "./animations/index";
2
+ export * from "./chartPalette";
2
3
  export * from "./colorRules";
3
4
  export * from "./cookies";
5
+ export * from "./cornerSmoothing";
4
6
  export * from "./createDefaultThemeConfig";
5
7
  export * from "./createThemes";
8
+ export * from "./defaults/index";
6
9
  export * from "./devtools/ThemeDevtoolsPanel";
10
+ export * from "./focusState";
11
+ export * from "./FontKnobStyles";
12
+ export * from "./hairline";
7
13
  export * from "./Intent";
8
14
  export * from "./intents";
9
15
  export * from "./knobs";
16
+ export * from "./layoutTokens";
10
17
  export * from "./Preset";
11
18
  export type { Preset as PresetDefinition, IntentOverride } from "./preset.types";
12
19
  export * from "./PresetContext";
13
20
  export * from "./presets";
21
+ export * from "./readableColor";
14
22
  export * from "./recipes";
15
23
  export * from "./resolveKnobs";
16
24
  export * from "./shared";
@@ -269,8 +269,8 @@ describe("useResolvedKnobs with Intent", () => {
269
269
 
270
270
  renderToHtml(wrapWithPreset(makePreset(), createElement(capture.Probe)));
271
271
 
272
- // Default knobs: elevation "none" → resolved to undefined
273
- expect(capture.result.knobProps.elevation).toBeUndefined();
272
+ // Default knobs: elevation "small" → resolved to "$1"
273
+ expect(capture.result.knobProps.elevation).toBe("$1");
274
274
  expect(capture.result.knobProps.textAccent).toBe("high");
275
275
  });
276
276
  });
@@ -1,5 +1,8 @@
1
1
  import type { IntentOverride } from "./preset.types";
2
2
 
3
+ // One emphasis system (SB-E-01): every intent Button renders SOLID like
4
+ // accent — fillStyle "filled" so the solid _Button sub-theme surface applies
5
+ // even under outlined presets.
3
6
  export const defaultIntents: Record<string, IntentOverride> = {
4
7
  accent: {
5
8
  fillStyle: "outlined",
@@ -8,15 +11,16 @@ export const defaultIntents: Record<string, IntentOverride> = {
8
11
  },
9
12
  error: {
10
13
  textAccent: "high",
11
- Button: { elevation: "small" },
14
+ Button: { fillStyle: "filled", elevation: "small" },
12
15
  Input: { borderWidth: "medium" },
13
16
  },
14
17
  warning: {
15
18
  textAccent: "high",
19
+ Button: { fillStyle: "filled" },
16
20
  Input: { borderWidth: "small" },
17
21
  },
18
22
  success: {
19
23
  textAccent: "high",
20
- Button: { elevation: "none" },
24
+ Button: { fillStyle: "filled", elevation: "none" },
21
25
  },
22
26
  };
@@ -12,6 +12,22 @@ export const BorderRadius = {
12
12
  } as const;
13
13
  export type BorderRadius = (typeof BorderRadius)[keyof typeof BorderRadius];
14
14
 
15
+ /**
16
+ * Corner curvature continuity (Axiom 15 OPTICS). `smooth` renders
17
+ * continuous-curvature (superellipse/squircle) corners where the platform
18
+ * can (web: Chromium 139+ via `corner-shape`, others fall back to the plain
19
+ * arc); `round` is today's circular-arc border-radius everywhere.
20
+ * Two values, not a numeric dial: every shipped smoothing system converges
21
+ * on one blessed curve (Apple ≈ Figma 0.6 ≈ superellipse K=2), and a free
22
+ * number invites per-screen drift (Axiom 5). `none` is not needed —
23
+ * `borderRadius: "none"` already covers square corners.
24
+ */
25
+ export const CornerSmoothing = {
26
+ Round: "round",
27
+ Smooth: "smooth",
28
+ } as const;
29
+ export type CornerSmoothing = (typeof CornerSmoothing)[keyof typeof CornerSmoothing];
30
+
15
31
  export const BorderWidth = {
16
32
  None: "none",
17
33
  Small: "small",
@@ -42,6 +58,89 @@ export const Size = {
42
58
  } as const;
43
59
  export type Size = (typeof Size)[keyof typeof Size];
44
60
 
61
+ /** Global density mode — compact steps size+space down one level. */
62
+ export const Density = {
63
+ Comfortable: "comfortable",
64
+ Compact: "compact",
65
+ } as const;
66
+ export type Density = (typeof Density)[keyof typeof Density];
67
+
68
+ // ── House decisions (design-guidelines.md) ─────────────────
69
+ // Behavioral knobs with recommended defaults — not hard constraints.
70
+
71
+ /** Field label placement relative to the control. Default: top. */
72
+ export const FieldLabelPlacement = {
73
+ Top: "top",
74
+ Side: "side",
75
+ Floating: "floating",
76
+ } as const;
77
+ export type FieldLabelPlacement = (typeof FieldLabelPlacement)[keyof typeof FieldLabelPlacement];
78
+
79
+ /**
80
+ * How required/optional fields are marked.
81
+ * `minority` marks whichever is less common in the form (compute from schema).
82
+ */
83
+ export const RequiredMarking = {
84
+ Asterisk: "asterisk",
85
+ Optional: "optional",
86
+ Minority: "minority",
87
+ } as const;
88
+ export type RequiredMarking = (typeof RequiredMarking)[keyof typeof RequiredMarking];
89
+
90
+ /** Alternating table row backgrounds. Default: off. */
91
+ export const TableZebra = {
92
+ On: "on",
93
+ Off: "off",
94
+ } as const;
95
+ export type TableZebra = (typeof TableZebra)[keyof typeof TableZebra];
96
+
97
+ /** Bulk-action bar placement for multi-select tables. Default: top (sticky). */
98
+ export const BulkBarPlacement = {
99
+ Top: "top",
100
+ Bottom: "bottom",
101
+ } as const;
102
+ export type BulkBarPlacement = (typeof BulkBarPlacement)[keyof typeof BulkBarPlacement];
103
+
104
+ /**
105
+ * Timestamp display style.
106
+ * Recommended: absolute in data tables; relative in feeds. Default: absolute.
107
+ */
108
+ export const TimestampStyle = {
109
+ Absolute: "absolute",
110
+ Relative: "relative",
111
+ } as const;
112
+ export type TimestampStyle = (typeof TimestampStyle)[keyof typeof TimestampStyle];
113
+
114
+ /**
115
+ * Table select-all scope (DG-TBL-03): header checkbox selects the current
116
+ * page or all filtered/matching rows. Default: page.
117
+ */
118
+ export const SelectAllScope = {
119
+ Page: "page",
120
+ Filtered: "filtered",
121
+ } as const;
122
+ export type SelectAllScope = (typeof SelectAllScope)[keyof typeof SelectAllScope];
123
+
124
+ /**
125
+ * Disabled control treatment (LC-40 DISABLED-VISIBLE / DG-ST-05). Both
126
+ * values render a VISIBLE treatment via the resolved `disabledState` recipe:
127
+ * `keepLabel` (default, Fluent model) washes the control chrome while
128
+ * label/value text stays ≥ AA; `dimWhole` (Material model) dims the whole
129
+ * assembly, label included. Read-only is a different state and never dims.
130
+ */
131
+ export const DisabledStyle = {
132
+ DimWhole: "dimWhole",
133
+ KeepLabel: "keepLabel",
134
+ } as const;
135
+ export type DisabledStyle = (typeof DisabledStyle)[keyof typeof DisabledStyle];
136
+
137
+ /** Autofocus first field on form mount. Default: off (a11y). */
138
+ export const FormAutofocus = {
139
+ On: "on",
140
+ Off: "off",
141
+ } as const;
142
+ export type FormAutofocus = (typeof FormAutofocus)[keyof typeof FormAutofocus];
143
+
45
144
  // ── Emphasis ───────────────────────────────────────────────
46
145
 
47
146
  export const TextAccent = {
@@ -109,15 +208,38 @@ export type InteractionState = (typeof interactionStates)[number];
109
208
  export interface Knobs {
110
209
  fillStyle: FillStyle;
111
210
  borderRadius: BorderRadius;
211
+ /**
212
+ * Corner curvature continuity (Axiom 15 OPTICS). Optional so existing
213
+ * preset/knob constructions stay valid; resolveKnobs defaults to `round`.
214
+ */
215
+ cornerSmoothing?: CornerSmoothing;
112
216
  borderWidth: BorderWidth;
113
217
  elevation: Elevation;
114
218
  space: Space;
115
219
  size: Size;
220
+ /** Propagating density mode. Default comfortable; compact steps size+space. */
221
+ density: Density;
116
222
  textAccent: TextAccent;
117
223
  headingFont: HeadingFont;
118
224
  bodyFont: BodyFont;
119
225
  fontWeight: FontWeight;
120
226
  animation: Animation;
227
+ /** House: field label placement. Default top. */
228
+ fieldLabelPlacement: FieldLabelPlacement;
229
+ /** House: required/optional marking. Default minority. */
230
+ requiredMarking: RequiredMarking;
231
+ /** House: zebra table rows. Default off. */
232
+ tableZebra: TableZebra;
233
+ /** House: bulk bar placement. Default top. */
234
+ bulkBarPlacement: BulkBarPlacement;
235
+ /** House: table select-all scope. Default page. */
236
+ selectAllScope: SelectAllScope;
237
+ /** House: timestamp style. Default absolute. */
238
+ timestampStyle: TimestampStyle;
239
+ /** House: disabled treatment. Default keepLabel. */
240
+ disabledStyle: DisabledStyle;
241
+ /** House: form autofocus. Default off. */
242
+ formAutofocus: FormAutofocus;
121
243
  hover: StateKnobs;
122
244
  press: StateKnobs;
123
245
  focus: StateKnobs;
@@ -131,15 +253,25 @@ export const emptyStateKnobs: StateKnobs = {};
131
253
  export const defaultKnobs: Knobs = {
132
254
  fillStyle: "filled",
133
255
  borderRadius: "medium",
134
- borderWidth: "small",
135
- elevation: "none",
256
+ cornerSmoothing: "round",
257
+ borderWidth: "medium",
258
+ elevation: "small",
136
259
  space: "medium",
137
260
  size: "medium",
261
+ density: "comfortable",
138
262
  textAccent: "high",
139
263
  headingFont: "sans-serif",
140
264
  bodyFont: "sans-serif",
141
265
  fontWeight: "regular",
142
266
  animation: "quick",
267
+ fieldLabelPlacement: "top",
268
+ requiredMarking: "minority",
269
+ tableZebra: "off",
270
+ bulkBarPlacement: "top",
271
+ selectAllScope: "page",
272
+ timestampStyle: "absolute",
273
+ disabledStyle: "keepLabel",
274
+ formAutofocus: "off",
143
275
  hover: emptyStateKnobs,
144
276
  press: emptyStateKnobs,
145
277
  focus: emptyStateKnobs,
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ MIN_PRESS_TARGET,
4
+ NARROW_BREAKPOINT,
5
+ OVERLAY_BREAKPOINT,
6
+ READING_WIDTH_CH,
7
+ READING_WIDTH_MAX_CH,
8
+ READING_WIDTH_MIN_CH,
9
+ defaultMaxPanes,
10
+ getLayoutSizeClass,
11
+ layoutBreakpoints,
12
+ layoutSizeClassAtLeast,
13
+ pressTargetHitSlop,
14
+ pressTargetStyle,
15
+ readingWidthStyle,
16
+ } from "./layoutTokens";
17
+
18
+ describe("layoutBreakpoints (DG-LAY-01)", () => {
19
+ it("documents FloatingPanel 860 as expanded / overlay", () => {
20
+ expect(layoutBreakpoints.expanded).toBe(860);
21
+ expect(OVERLAY_BREAKPOINT).toBe(860);
22
+ });
23
+
24
+ it("documents DeskShell narrow as medium 640", () => {
25
+ expect(layoutBreakpoints.medium).toBe(640);
26
+ expect(NARROW_BREAKPOINT).toBe(640);
27
+ });
28
+
29
+ it("maps widths to size classes", () => {
30
+ expect(getLayoutSizeClass(360)).toBe("compact");
31
+ expect(getLayoutSizeClass(640)).toBe("medium");
32
+ expect(getLayoutSizeClass(859)).toBe("medium");
33
+ expect(getLayoutSizeClass(860)).toBe("expanded");
34
+ expect(getLayoutSizeClass(1024)).toBe("large");
35
+ expect(getLayoutSizeClass(1280)).toBe("xl");
36
+ });
37
+
38
+ it("defaults to 1 pane below expanded, 2 at expanded+", () => {
39
+ expect(defaultMaxPanes("compact")).toBe(1);
40
+ expect(defaultMaxPanes("medium")).toBe(1);
41
+ expect(defaultMaxPanes("expanded")).toBe(2);
42
+ expect(defaultMaxPanes("large")).toBe(2);
43
+ expect(defaultMaxPanes("xl")).toBe(2);
44
+ });
45
+
46
+ it("compares size classes ordinally", () => {
47
+ expect(layoutSizeClassAtLeast("expanded", "expanded")).toBe(true);
48
+ expect(layoutSizeClassAtLeast("medium", "expanded")).toBe(false);
49
+ expect(layoutSizeClassAtLeast("xl", "large")).toBe(true);
50
+ });
51
+ });
52
+
53
+ describe("readingWidthStyle (DG-LAY-05)", () => {
54
+ it("defaults to ~70ch within 65–75", () => {
55
+ expect(READING_WIDTH_CH).toBeGreaterThanOrEqual(READING_WIDTH_MIN_CH);
56
+ expect(READING_WIDTH_CH).toBeLessThanOrEqual(READING_WIDTH_MAX_CH);
57
+ expect(readingWidthStyle()).toEqual({ maxWidth: "70ch", width: "100%" });
58
+ });
59
+
60
+ it("clamps and supports eject", () => {
61
+ expect(readingWidthStyle(40)).toEqual({ maxWidth: "65ch", width: "100%" });
62
+ expect(readingWidthStyle(90)).toEqual({ maxWidth: "75ch", width: "100%" });
63
+ expect(readingWidthStyle("none")).toEqual({});
64
+ expect(readingWidthStyle(null)).toEqual({});
65
+ });
66
+ });
67
+
68
+ describe("press target (DG-LAY-04)", () => {
69
+ it("exports 44px floor + hitSlop expansion", () => {
70
+ expect(MIN_PRESS_TARGET).toBe(44);
71
+ expect(pressTargetStyle()).toEqual({ minWidth: 44, minHeight: 44 });
72
+ expect(pressTargetHitSlop(24)).toEqual({ top: 10, bottom: 10, left: 10, right: 10 });
73
+ expect(pressTargetHitSlop(44)).toEqual({ top: 0, bottom: 0, left: 0, right: 0 });
74
+ });
75
+ });
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Layout tokens — DG-LAY-01..05 (W11).
3
+ *
4
+ * Canonical size classes for layout decisions (pane count, nav mode, overlay
5
+ * sheet vs float). Primitives consume these classes / helpers — not raw px in
6
+ * call sites.
7
+ *
8
+ * ## Canonical choice (2026-08-12)
9
+ *
10
+ * Hybrid of shipped MP1 behavior + Material window-size naming:
11
+ * - **Names** follow Material (compact → xl).
12
+ * - **Numbers** prefer already-shipped thresholds:
13
+ * - `medium` @ 640 = Tamagui `$sm` / DeskShell narrow (Fluent sm).
14
+ * - `expanded` @ 860 = FloatingPanel sheet↔float pivot (~Material 840).
15
+ * - `large` / `xl` = Tamagui `$lg` / `$xl`.
16
+ *
17
+ * Fluent (480/640/1024/1366/1920) and Material (600/840/1200/1600) stay in the
18
+ * compat table below for mapping — they are not second sources of truth.
19
+ *
20
+ * | Class | Min px | Max panes (default) | Tamagui media | Notes |
21
+ * |-----------|--------|---------------------|---------------|--------------------------------|
22
+ * | compact | 0 | 1 | < `$sm` | phones; drawer nav |
23
+ * | medium | 640 | 1 | `$sm` | DeskShell rail; still 1 pane |
24
+ * | expanded | 860 | 2 | — (custom) | FloatingPanel float; 2 panes |
25
+ * | large | 1024 | 2 | `$lg` | |
26
+ * | xl | 1280 | 2 | `$xl` | |
27
+ *
28
+ * Compat: Material compact≈our compact (600→640), expanded≈our expanded
29
+ * (840→860). Fluent sm=640 matches medium; Fluent lg=1024 matches large.
30
+ */
31
+
32
+ import { useEffect, useState } from "react";
33
+ import { isWeb, useWindowDimensions } from "tamagui";
34
+ import { useResolvedKnobs } from "./useResolvedKnobs";
35
+
36
+ // ── Breakpoint tokens (DG-LAY-01) ─────────────────────────────────────────
37
+
38
+ export type LayoutSizeClass = "compact" | "medium" | "expanded" | "large" | "xl";
39
+
40
+ /** Named min-widths. Prefer these over literals in layout code. */
41
+ export const layoutBreakpoints = {
42
+ /** End of compact / DeskShell narrow drawer threshold (Tamagui `$sm`). */
43
+ medium: 640,
44
+ /** Two-pane + FloatingPanel float threshold (~M3 expanded 840). */
45
+ expanded: 860,
46
+ /** Tamagui `$lg`. */
47
+ large: 1024,
48
+ /** Tamagui `$xl`. */
49
+ xl: 1280,
50
+ } as const;
51
+
52
+ export type LayoutBreakpointName = keyof typeof layoutBreakpoints;
53
+
54
+ /**
55
+ * Alias kept for FloatingPanel / overlay consumers — same value as
56
+ * `layoutBreakpoints.expanded`.
57
+ */
58
+ export const OVERLAY_BREAKPOINT = layoutBreakpoints.expanded;
59
+
60
+ /** DeskShell sidebar collapse — same value as `layoutBreakpoints.medium`. */
61
+ export const NARROW_BREAKPOINT = layoutBreakpoints.medium;
62
+
63
+ export const LAYOUT_SIZE_CLASS_ORDER: readonly LayoutSizeClass[] = [
64
+ "compact",
65
+ "medium",
66
+ "expanded",
67
+ "large",
68
+ "xl",
69
+ ] as const;
70
+
71
+ /** Resolve a viewport width to the canonical layout size class. */
72
+ export function getLayoutSizeClass(width: number): LayoutSizeClass {
73
+ if (width >= layoutBreakpoints.xl) return "xl";
74
+ if (width >= layoutBreakpoints.large) return "large";
75
+ if (width >= layoutBreakpoints.expanded) return "expanded";
76
+ if (width >= layoutBreakpoints.medium) return "medium";
77
+ return "compact";
78
+ }
79
+
80
+ /** Default horizontal pane budget by size class (Material PaneScaffoldDirective). */
81
+ export function defaultMaxPanes(sizeClass: LayoutSizeClass): 1 | 2 {
82
+ return sizeClass === "compact" || sizeClass === "medium" ? 1 : 2;
83
+ }
84
+
85
+ export function layoutSizeClassAtLeast(
86
+ current: LayoutSizeClass,
87
+ minimum: LayoutSizeClass,
88
+ ): boolean {
89
+ return LAYOUT_SIZE_CLASS_ORDER.indexOf(current) >= LAYOUT_SIZE_CLASS_ORDER.indexOf(minimum);
90
+ }
91
+
92
+ /** Web viewport width with an SSR fallback (`medium`). Web-only callers. */
93
+ function readViewportWidth(): number {
94
+ if (typeof window === "undefined") return layoutBreakpoints.medium;
95
+ return window.innerWidth;
96
+ }
97
+
98
+ /**
99
+ * Live layout size class from viewport width — cross-platform:
100
+ * - Web: matchMedia listeners on the class thresholds (re-renders only when
101
+ * the class flips, not per resize pixel). SSR first paint → `medium`.
102
+ * - Native: react-native window dimensions (rotation / split-screen), so
103
+ * phones report `compact` instead of the former pinned `medium`.
104
+ */
105
+ export function useLayoutSizeClass(): LayoutSizeClass {
106
+ // Hook order is unconditional; on web the value is ignored in favor of the
107
+ // matchMedia path below (threshold-only updates).
108
+ const nativeWidth = useWindowDimensions().width;
109
+ const [sizeClass, setSizeClass] = useState<LayoutSizeClass>(() =>
110
+ getLayoutSizeClass(isWeb ? readViewportWidth() : nativeWidth),
111
+ );
112
+
113
+ useEffect(() => {
114
+ if (!isWeb || typeof window === "undefined") return;
115
+
116
+ const queries = [
117
+ window.matchMedia(`(min-width: ${layoutBreakpoints.xl}px)`),
118
+ window.matchMedia(`(min-width: ${layoutBreakpoints.large}px)`),
119
+ window.matchMedia(`(min-width: ${layoutBreakpoints.expanded}px)`),
120
+ window.matchMedia(`(min-width: ${layoutBreakpoints.medium}px)`),
121
+ ];
122
+
123
+ const sync = () => setSizeClass(getLayoutSizeClass(window.innerWidth));
124
+ sync();
125
+ for (const mq of queries) mq.addEventListener("change", sync);
126
+ return () => {
127
+ for (const mq of queries) mq.removeEventListener("change", sync);
128
+ };
129
+ }, []);
130
+
131
+ // Native: derive from the live dimensions; setState bails when unchanged.
132
+ useEffect(() => {
133
+ if (isWeb) return;
134
+ setSizeClass(getLayoutSizeClass(nativeWidth));
135
+ }, [nativeWidth]);
136
+
137
+ return sizeClass;
138
+ }
139
+
140
+ /** True when the viewport may show two side-by-side panes. */
141
+ export function useMultiPane(maxPanes?: 1 | 2): boolean {
142
+ const sizeClass = useLayoutSizeClass();
143
+ const budget = maxPanes ?? defaultMaxPanes(sizeClass);
144
+ return budget >= 2 && defaultMaxPanes(sizeClass) >= 2;
145
+ }
146
+
147
+ // ── Reading width (DG-LAY-05) ─────────────────────────────────────────────
148
+
149
+ /** Ideal prose measure (mid of GOV.UK ≤75 / USWDS ~66). */
150
+ export const READING_WIDTH_CH = 70;
151
+ export const READING_WIDTH_MIN_CH = 65;
152
+ export const READING_WIDTH_MAX_CH = 75;
153
+
154
+ export type ReadingWidthStyle = {
155
+ maxWidth: string;
156
+ width: "100%";
157
+ };
158
+
159
+ /** Style props for prose / long-form columns. Pass `null`/`"none"` to eject. */
160
+ export function readingWidthStyle(
161
+ measure: number | "none" | null = READING_WIDTH_CH,
162
+ ): ReadingWidthStyle | Record<string, never> {
163
+ if (measure === "none" || measure === null) return {};
164
+ const ch = Math.min(READING_WIDTH_MAX_CH, Math.max(READING_WIDTH_MIN_CH, measure));
165
+ return { maxWidth: `${ch}ch`, width: "100%" };
166
+ }
167
+
168
+ // ── Min press target (DG-LAY-04) ──────────────────────────────────────────
169
+
170
+ /** Platform floor for interactive targets (Fluent/HIG web+iOS 44; M3 48 on Android). */
171
+ export const MIN_PRESS_TARGET = 44;
172
+
173
+ export type PressTargetStyle = {
174
+ minWidth: number;
175
+ minHeight: number;
176
+ };
177
+
178
+ export function pressTargetStyle(size: number = MIN_PRESS_TARGET): PressTargetStyle {
179
+ return { minWidth: size, minHeight: size };
180
+ }
181
+
182
+ export type HitSlopInsets = {
183
+ top: number;
184
+ bottom: number;
185
+ left: number;
186
+ right: number;
187
+ };
188
+
189
+ /** Expand hit area when the visual control is smaller than the floor. */
190
+ export function pressTargetHitSlop(
191
+ visualSize: number,
192
+ min: number = MIN_PRESS_TARGET,
193
+ ): HitSlopInsets {
194
+ const pad = Math.max(0, Math.ceil((min - visualSize) / 2));
195
+ return { top: pad, bottom: pad, left: pad, right: pad };
196
+ }
197
+
198
+ // ── Semantic gaps (DG-LAY-03) ─────────────────────────────────────────────
199
+
200
+ /**
201
+ * Semantic spacing roles map onto the space-knob recipes:
202
+ * `within-group` → `knobProps.gap`, `between-groups` → `knobProps.gapLg`.
203
+ */
204
+ export function useSemanticGaps(): {
205
+ withinGroup: { gap: string };
206
+ betweenGroups: { gap: string };
207
+ } {
208
+ const { knobProps } = useResolvedKnobs();
209
+ return {
210
+ withinGroup: knobProps.gap,
211
+ betweenGroups: knobProps.gapLg,
212
+ };
213
+ }