@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,503 @@
1
+ /**
2
+ * Figma token export transform — the code→Figma projection for
3
+ * `agent-os/specs/2026-05-08-figma-v2-token-migration`.
4
+ *
5
+ * Everything here is DERIVED from the canonical theme sources at call time
6
+ * (no hand-copied values):
7
+ *
8
+ * - Palette ramps + shadows: `createThemesBuilder(defaultBaseTheme,
9
+ * defaultAccentTheme, defaultBuilderOptions).themes()` — the exact themes
10
+ * apps run, including tint text neutralization and computed tokens like
11
+ * `outlineColor`.
12
+ * - Semantic aliases: reverse-matched per scheme against the ramp steps of
13
+ * the built base themes (a key only becomes a `colorN` alias when light
14
+ * AND dark agree on the step index; otherwise raw per-scheme values are
15
+ * exported).
16
+ * - Sizing: `tokens` from `@tamagui/themes` (the tokens
17
+ * `createDefaultThemeConfig` installs).
18
+ * - Knob outputs: `resolveKnobs()` is executed per knob mode — the export
19
+ * shows exactly what the runtime resolver produces.
20
+ * - Typography: the built `defaultHeadingFont` / `defaultBodyFont` tables
21
+ * (size / lineHeight / letterSpacing / weight).
22
+ *
23
+ * The transform is pure and deterministic (no Date, no I/O) so it can be
24
+ * golden-tested; the runner script (`scripts/export-figma-tokens.mts`)
25
+ * stamps `generatedAt` when writing to disk.
26
+ */
27
+
28
+ import { tokens } from "@tamagui/themes";
29
+ import { createThemesBuilder, tintHueNames } from "../theme/createThemes";
30
+ import { defaultAccentTheme } from "../theme/defaults/accent";
31
+ import { defaultBaseTheme } from "../theme/defaults/base";
32
+ import { defaultBuilderOptions } from "../theme/defaults/builderOptions";
33
+ import { defaultBodyFont, defaultHeadingFont } from "../theme/defaults/fonts";
34
+ import { type Knobs, defaultKnobs } from "../theme/knobs";
35
+ import { compactSizeMap, compactSpaceMap, resolveKnobs } from "../theme/resolveKnobs";
36
+
37
+ // ── Names / constants ─────────────────────────────────────────
38
+
39
+ /**
40
+ * Theme modes of the `Palette/Theme` Figma collection (spec.md §Variable
41
+ * Collection Structure): semantic intents take the red/yellow/green slots;
42
+ * the remaining stock tints are manual designer picks. 9 modes — inside
43
+ * Figma Pro's 10-modes-per-collection cap with 1 slot of headroom.
44
+ */
45
+ export const FIGMA_THEME_MODES = [
46
+ "base",
47
+ "accent",
48
+ "error",
49
+ "success",
50
+ "warning",
51
+ "orange",
52
+ "blue",
53
+ "purple",
54
+ "pink",
55
+ ] as const;
56
+ export type FigmaThemeMode = (typeof FIGMA_THEME_MODES)[number];
57
+
58
+ /**
59
+ * Figma's per-node corner-smoothing dial value per `cornerSmoothing` knob.
60
+ * `smooth` = 0.6 ≈ `corner-shape: squircle` ≡ superellipse(2) ≈ Apple's
61
+ * continuous corners (see cornerSmoothing.ts). This is a Figma rendering
62
+ * constant, not a Variable — Figma cannot bind cornerSmoothing to Variables.
63
+ */
64
+ export const FIGMA_CORNER_SMOOTHING: Record<string, number> = {
65
+ round: 0,
66
+ smooth: 0.6,
67
+ };
68
+
69
+ /** Tamagui token key (`1.5`, `true`) → Figma-safe variable name segment. */
70
+ export function figmaTokenName(prefix: string, key: string): string {
71
+ return `${prefix}-${String(key).replace(/\./g, "-")}`;
72
+ }
73
+
74
+ // ── Output shape ──────────────────────────────────────────────
75
+
76
+ export interface FigmaPaletteTheme {
77
+ light: string[];
78
+ dark: string[];
79
+ }
80
+
81
+ export interface FigmaShadows {
82
+ light: Record<string, string>;
83
+ dark: Record<string, string>;
84
+ }
85
+
86
+ export type FigmaSemanticEntry =
87
+ | { alias: string; light: string; dark: string }
88
+ | { alias: null; light: string; dark: string };
89
+
90
+ export interface FigmaRadiusMode {
91
+ /** Tamagui radius token resolveKnobs emits for this knob mode (e.g. `$4`). */
92
+ token: string;
93
+ /** `Sizing` collection variable to alias (e.g. `radius-4`). */
94
+ figmaName: string;
95
+ px: number;
96
+ /** Outer/nested radius fragment (`knobProps.borderRadiusOuter`). */
97
+ outerToken: string;
98
+ outerFigmaName: string;
99
+ outerPx: number;
100
+ }
101
+
102
+ export interface FigmaElevationMode {
103
+ /** Tamagui size token backing the elevation shadow scale (null = none). */
104
+ sizeToken: string | null;
105
+ px: number | null;
106
+ }
107
+
108
+ export interface FigmaSpaceMode {
109
+ paddingToken: string;
110
+ paddingFigmaName: string;
111
+ gapToken: string;
112
+ gapFigmaName: string;
113
+ gapLgToken: string;
114
+ gapLgFigmaName: string;
115
+ }
116
+
117
+ export interface FigmaKnobsExport {
118
+ borderRadius: { default: Knobs["borderRadius"]; modes: Record<string, FigmaRadiusMode> };
119
+ borderWidth: { default: Knobs["borderWidth"]; modes: Record<string, number> };
120
+ elevation: { default: Knobs["elevation"]; modes: Record<string, FigmaElevationMode> };
121
+ fontWeight: { default: Knobs["fontWeight"]; modes: Record<string, string> };
122
+ space: { default: Knobs["space"]; modes: Record<string, FigmaSpaceMode> };
123
+ size: { default: Knobs["size"]; modes: Record<string, { token: string; figmaName: string }> };
124
+ cornerSmoothing: { default: string; modes: Record<string, number> };
125
+ density: {
126
+ default: Knobs["density"];
127
+ /** compact steps size/space down one level (applyDensity). */
128
+ compact: { size: Record<string, string>; space: Record<string, string> };
129
+ };
130
+ }
131
+
132
+ export interface FigmaFontExport {
133
+ family: string;
134
+ size: Record<string, number>;
135
+ lineHeight: Record<string, number>;
136
+ letterSpacing: Record<string, number>;
137
+ weight: Record<string, string>;
138
+ }
139
+
140
+ export interface FigmaTypographyExport {
141
+ heading: FigmaFontExport;
142
+ body: FigmaFontExport;
143
+ /**
144
+ * Category font stacks (the `headingFont`/`bodyFont` knob targets), as
145
+ * supplied by the app config (`packages/config/fonts.ts` in this repo).
146
+ * `figmaFamily` is the first family of the stack — Figma cannot resolve
147
+ * CSS fallback chains.
148
+ */
149
+ categories: Record<string, { stack: string; figmaFamily: string }>;
150
+ }
151
+
152
+ export interface FigmaTokensExport {
153
+ $meta: {
154
+ generator: string;
155
+ spec: string;
156
+ note: string;
157
+ };
158
+ palette: {
159
+ themes: Record<string, FigmaPaletteTheme>;
160
+ shadows: FigmaShadows;
161
+ };
162
+ semantic: Record<string, FigmaSemanticEntry>;
163
+ sizing: Record<string, number>;
164
+ knobs: FigmaKnobsExport;
165
+ typography: FigmaTypographyExport;
166
+ }
167
+
168
+ export interface BuildFigmaTokensOptions {
169
+ /** Category font stacks to export (e.g. `fontFamilies` from app config). */
170
+ fontFamilies?: Record<string, string>;
171
+ }
172
+
173
+ // ── Internals ─────────────────────────────────────────────────
174
+
175
+ type BuiltThemes = Record<string, Record<string, string | number>>;
176
+
177
+ function unwrapTokenGroup(group: object | undefined): Record<string, number> {
178
+ const out: Record<string, number> = {};
179
+ if (!group) return out;
180
+ for (const [key, wrapper] of Object.entries(group)) {
181
+ const val =
182
+ wrapper && typeof wrapper === "object" && "val" in wrapper
183
+ ? (wrapper as { val: unknown }).val
184
+ : wrapper;
185
+ if (typeof val === "number") out[key] = val;
186
+ }
187
+ return out;
188
+ }
189
+
190
+ function ramp(themes: BuiltThemes, themeName: string, scheme: "light" | "dark"): string[] {
191
+ const key = themeName === "base" ? scheme : `${scheme}_${themeName}`;
192
+ const theme = themes[key];
193
+ if (!theme) throw new Error(`figmaTokens: built themes are missing "${key}"`);
194
+ const steps: string[] = [];
195
+ for (let i = 1; i <= 12; i++) {
196
+ const value = theme[`color${i}`];
197
+ if (typeof value !== "string") {
198
+ throw new Error(`figmaTokens: theme "${key}" is missing color${i}`);
199
+ }
200
+ steps.push(value);
201
+ }
202
+ return steps;
203
+ }
204
+
205
+ function shadowsOf(themes: BuiltThemes, scheme: "light" | "dark"): Record<string, string> {
206
+ const theme = themes[scheme];
207
+ const out: Record<string, string> = {};
208
+ for (let i = 1; i <= 6; i++) {
209
+ const value = theme?.[`shadow${i}`];
210
+ if (typeof value !== "string") {
211
+ throw new Error(`figmaTokens: base "${scheme}" theme is missing shadow${i}`);
212
+ }
213
+ out[`shadow${i}`] = value;
214
+ }
215
+ return out;
216
+ }
217
+
218
+ const RAMP_STEP_RE = /^color([1-9]|1[0-2])$/;
219
+ const SHADOW_RE = /^shadow[1-6]$/;
220
+ // Raw Radix hue steps spread into the base themes as extras (amber1,
221
+ // blueA12, ...). They are hue scales — Figma gets those via Palette/Theme
222
+ // modes — not semantic role tokens.
223
+ const HUE_STEP_RE = new RegExp(`^(${[...tintHueNames].join("|")})A?\\d+$`);
224
+
225
+ /**
226
+ * Reverse-match a semantic value against the ramp of its own theme: returns
227
+ * `colorN` when the value is exactly step N, else null.
228
+ */
229
+ function stepAliasFor(theme: Record<string, string | number>, value: string): string | null {
230
+ for (let i = 1; i <= 12; i++) {
231
+ if (theme[`color${i}`] === value) return `color${i}`;
232
+ }
233
+ return null;
234
+ }
235
+
236
+ function deriveSemantic(themes: BuiltThemes): Record<string, FigmaSemanticEntry> {
237
+ const light = themes.light;
238
+ const dark = themes.dark;
239
+ if (!light || !dark) throw new Error("figmaTokens: base light/dark themes missing");
240
+ const out: Record<string, FigmaSemanticEntry> = {};
241
+ const keys = Object.keys(light)
242
+ .filter((key) => !RAMP_STEP_RE.test(key) && !SHADOW_RE.test(key) && !HUE_STEP_RE.test(key))
243
+ .filter((key) => typeof light[key] === "string" && typeof dark[key] === "string")
244
+ .sort();
245
+ for (const key of keys) {
246
+ const lightValue = light[key] as string;
247
+ const darkValue = dark[key] as string;
248
+ const lightAlias = stepAliasFor(light, lightValue);
249
+ const darkAlias = stepAliasFor(dark, darkValue);
250
+ const alias = lightAlias !== null && lightAlias === darkAlias ? lightAlias : null;
251
+ out[key] = { alias, light: lightValue, dark: darkValue };
252
+ }
253
+ return out;
254
+ }
255
+
256
+ /**
257
+ * `textAccent` knob → semantic text tier aliases (`text-low/medium/high`),
258
+ * derived by running the resolver and matching its `$token` output against
259
+ * the built base themes.
260
+ */
261
+ function textTierEntries(themes: BuiltThemes): Record<string, FigmaSemanticEntry> {
262
+ const light = themes.light;
263
+ const dark = themes.dark;
264
+ const out: Record<string, FigmaSemanticEntry> = {};
265
+ for (const tier of ["low", "medium", "high"] as const) {
266
+ const resolved = resolveKnobs({ ...defaultKnobs, textAccent: tier });
267
+ const token = String(resolved.knobProps.textAccentColor).replace(/^\$/, "");
268
+ const lightValue = light[token];
269
+ const darkValue = dark[token];
270
+ if (typeof lightValue !== "string" || typeof darkValue !== "string") {
271
+ throw new Error(`figmaTokens: textAccent "${tier}" resolved to unknown token $${token}`);
272
+ }
273
+ const lightAlias = stepAliasFor(light, lightValue);
274
+ const darkAlias = stepAliasFor(dark, darkValue);
275
+ const alias = lightAlias !== null && lightAlias === darkAlias ? lightAlias : null;
276
+ out[`text-${tier}`] = { alias, light: lightValue, dark: darkValue };
277
+ }
278
+ return out;
279
+ }
280
+
281
+ function buildSizing(borderWidthModes: Record<string, number>): Record<string, number> {
282
+ const sizing: Record<string, number> = {};
283
+ const groups: Array<[string, Record<string, number>]> = [
284
+ ["size", unwrapTokenGroup(tokens.size)],
285
+ ["space", unwrapTokenGroup(tokens.space)],
286
+ ["radius", unwrapTokenGroup(tokens.radius)],
287
+ ["zIndex", unwrapTokenGroup((tokens as Record<string, object>).zIndex)],
288
+ ];
289
+ for (const [prefix, group] of groups) {
290
+ if (prefix !== "zIndex" && Object.keys(group).length === 0) {
291
+ throw new Error(`figmaTokens: @tamagui/themes tokens.${prefix} is empty`);
292
+ }
293
+ for (const [key, val] of Object.entries(group)) {
294
+ sizing[figmaTokenName(prefix, key)] = val;
295
+ }
296
+ }
297
+ // borderWidth-* mirrors the borderWidth KNOB outputs (none/small/medium/
298
+ // large) — the old integer 0..3 scale predates the 0.5 hairline step.
299
+ for (const [mode, px] of Object.entries(borderWidthModes)) {
300
+ sizing[`borderWidth-${mode}`] = px;
301
+ }
302
+ return sizing;
303
+ }
304
+
305
+ const RADIUS_KNOB_MODES: Knobs["borderRadius"][] = ["none", "small", "medium", "large", "full"];
306
+ const BORDER_WIDTH_KNOB_MODES: Knobs["borderWidth"][] = ["none", "small", "medium", "large"];
307
+ const ELEVATION_KNOB_MODES: Knobs["elevation"][] = ["none", "small", "medium", "large"];
308
+ const FONT_WEIGHT_KNOB_MODES: Knobs["fontWeight"][] = ["regular", "bold"];
309
+ const SPACE_KNOB_MODES: Knobs["space"][] = ["small", "medium", "large"];
310
+ const SIZE_KNOB_MODES: Knobs["size"][] = ["small", "medium", "large"];
311
+
312
+ function radiusPx(token: string): number {
313
+ const radius = unwrapTokenGroup(tokens.radius);
314
+ const key = token.replace(/^\$/, "");
315
+ const px = radius[key];
316
+ if (typeof px !== "number") throw new Error(`figmaTokens: unknown radius token ${token}`);
317
+ return px;
318
+ }
319
+
320
+ function tokenToFigma(token: string, prefix: string): string {
321
+ return figmaTokenName(prefix, token.replace(/^\$/, ""));
322
+ }
323
+
324
+ /**
325
+ * Normalize the elevation knob output to `{ sizeToken, px }` regardless of
326
+ * platform branch: resolveKnobs emits Tamagui size-token strings on web and
327
+ * the equivalent raw numbers on native (see elevationMap in resolveKnobs.ts).
328
+ */
329
+ function normalizeElevation(value: string | number | undefined): FigmaElevationMode {
330
+ if (value === undefined) return { sizeToken: null, px: null };
331
+ const size = unwrapTokenGroup(tokens.size);
332
+ if (typeof value === "string") {
333
+ const key = value.replace(/^\$/, "");
334
+ const px = size[key];
335
+ if (typeof px !== "number") throw new Error(`figmaTokens: unknown size token ${value}`);
336
+ return { sizeToken: value, px };
337
+ }
338
+ const entry = Object.entries(size).find(([key, px]) => px === value && key !== "true");
339
+ return { sizeToken: entry ? `$${entry[0]}` : null, px: value };
340
+ }
341
+
342
+ function buildKnobs(): FigmaKnobsExport {
343
+ const borderRadius: Record<string, FigmaRadiusMode> = {};
344
+ for (const mode of RADIUS_KNOB_MODES) {
345
+ const { knobProps } = resolveKnobs({ ...defaultKnobs, borderRadius: mode });
346
+ const token = String(knobProps.borderRadius.borderRadius);
347
+ const outerToken = String(knobProps.borderRadiusOuter.borderRadius);
348
+ borderRadius[mode] = {
349
+ token,
350
+ figmaName: tokenToFigma(token, "radius"),
351
+ px: radiusPx(token),
352
+ outerToken,
353
+ outerFigmaName: tokenToFigma(outerToken, "radius"),
354
+ outerPx: radiusPx(outerToken),
355
+ };
356
+ }
357
+
358
+ const borderWidth: Record<string, number> = {};
359
+ for (const mode of BORDER_WIDTH_KNOB_MODES) {
360
+ const { knobProps } = resolveKnobs({ ...defaultKnobs, borderWidth: mode });
361
+ borderWidth[mode] = Number(knobProps.borderRadius.borderWidth);
362
+ }
363
+
364
+ const elevation: Record<string, FigmaElevationMode> = {};
365
+ for (const mode of ELEVATION_KNOB_MODES) {
366
+ const { knobProps } = resolveKnobs({ ...defaultKnobs, elevation: mode });
367
+ elevation[mode] = normalizeElevation(knobProps.elevation as string | number | undefined);
368
+ }
369
+
370
+ const fontWeight: Record<string, string> = {};
371
+ for (const mode of FONT_WEIGHT_KNOB_MODES) {
372
+ const { knobProps } = resolveKnobs({ ...defaultKnobs, fontWeight: mode });
373
+ fontWeight[mode] = String(knobProps.textWeight.fontWeight);
374
+ }
375
+
376
+ const space: Record<string, FigmaSpaceMode> = {};
377
+ for (const mode of SPACE_KNOB_MODES) {
378
+ const { knobProps } = resolveKnobs({ ...defaultKnobs, space: mode });
379
+ const paddingToken = String(knobProps.panelPadding.padding);
380
+ const gapToken = String(knobProps.gap.gap);
381
+ const gapLgToken = String(knobProps.gapLg.gap);
382
+ space[mode] = {
383
+ paddingToken,
384
+ paddingFigmaName: tokenToFigma(paddingToken, "space"),
385
+ gapToken,
386
+ gapFigmaName: tokenToFigma(gapToken, "space"),
387
+ gapLgToken,
388
+ gapLgFigmaName: tokenToFigma(gapLgToken, "space"),
389
+ };
390
+ }
391
+
392
+ const size: Record<string, { token: string; figmaName: string }> = {};
393
+ for (const mode of SIZE_KNOB_MODES) {
394
+ const { knobProps } = resolveKnobs({ ...defaultKnobs, size: mode });
395
+ const token = String(knobProps.sizeToken);
396
+ size[mode] = { token, figmaName: tokenToFigma(token, "size") };
397
+ }
398
+
399
+ return {
400
+ borderRadius: { default: defaultKnobs.borderRadius, modes: borderRadius },
401
+ borderWidth: { default: defaultKnobs.borderWidth, modes: borderWidth },
402
+ elevation: { default: defaultKnobs.elevation, modes: elevation },
403
+ fontWeight: { default: defaultKnobs.fontWeight, modes: fontWeight },
404
+ space: { default: defaultKnobs.space, modes: space },
405
+ size: { default: defaultKnobs.size, modes: size },
406
+ cornerSmoothing: {
407
+ default: defaultKnobs.cornerSmoothing ?? "round",
408
+ modes: { ...FIGMA_CORNER_SMOOTHING },
409
+ },
410
+ density: {
411
+ default: defaultKnobs.density,
412
+ compact: { size: { ...compactSizeMap }, space: { ...compactSpaceMap } },
413
+ },
414
+ };
415
+ }
416
+
417
+ interface FontLike {
418
+ family?: unknown;
419
+ size?: Record<string, unknown>;
420
+ lineHeight?: Record<string, unknown>;
421
+ letterSpacing?: Record<string, unknown>;
422
+ weight?: Record<string, unknown>;
423
+ }
424
+
425
+ function numberTable(table: Record<string, unknown> | undefined, label: string) {
426
+ const out: Record<string, number> = {};
427
+ if (!table) throw new Error(`figmaTokens: font is missing ${label} table`);
428
+ for (const [key, value] of Object.entries(table)) {
429
+ const num = typeof value === "number" ? value : Number(value);
430
+ if (Number.isFinite(num)) out[key] = num;
431
+ }
432
+ if (Object.keys(out).length === 0) throw new Error(`figmaTokens: ${label} table is empty`);
433
+ return out;
434
+ }
435
+
436
+ function exportFont(font: FontLike, label: string): FigmaFontExport {
437
+ const weight: Record<string, string> = {};
438
+ for (const [key, value] of Object.entries(font.weight ?? {})) {
439
+ weight[key] = String(value);
440
+ }
441
+ return {
442
+ family: String(font.family ?? ""),
443
+ size: numberTable(font.size, `${label}.size`),
444
+ lineHeight: numberTable(font.lineHeight, `${label}.lineHeight`),
445
+ letterSpacing: numberTable(font.letterSpacing, `${label}.letterSpacing`),
446
+ weight,
447
+ };
448
+ }
449
+
450
+ function firstFamily(stack: string): string {
451
+ const first = stack.split(",")[0] ?? stack;
452
+ return first.trim().replace(/^['"]|['"]$/g, "");
453
+ }
454
+
455
+ // ── Entry point ───────────────────────────────────────────────
456
+
457
+ export function buildFigmaTokens(options: BuildFigmaTokensOptions = {}): FigmaTokensExport {
458
+ const themes = createThemesBuilder(
459
+ defaultBaseTheme,
460
+ defaultAccentTheme,
461
+ defaultBuilderOptions,
462
+ ).themes() as BuiltThemes;
463
+
464
+ const paletteThemes: Record<string, FigmaPaletteTheme> = {};
465
+ for (const mode of FIGMA_THEME_MODES) {
466
+ paletteThemes[mode] = {
467
+ light: ramp(themes, mode, "light"),
468
+ dark: ramp(themes, mode, "dark"),
469
+ };
470
+ }
471
+
472
+ const semantic = { ...deriveSemantic(themes), ...textTierEntries(themes) };
473
+ const knobs = buildKnobs();
474
+ const sizing = buildSizing(knobs.borderWidth.modes);
475
+
476
+ const categories: FigmaTypographyExport["categories"] = {};
477
+ for (const [category, stack] of Object.entries(options.fontFamilies ?? {})) {
478
+ categories[category] = { stack, figmaFamily: firstFamily(stack) };
479
+ }
480
+
481
+ return {
482
+ $meta: {
483
+ generator: "public/theme/src/figma/figmaTokens.ts (buildFigmaTokens)",
484
+ spec: "agent-os/specs/2026-05-08-figma-v2-token-migration",
485
+ note: "Generated projection of the canonical theme/knob system. Do not edit by hand — rerun `pnpm exec tsx scripts/export-figma-tokens.mts`.",
486
+ },
487
+ palette: {
488
+ themes: paletteThemes,
489
+ shadows: {
490
+ light: shadowsOf(themes, "light"),
491
+ dark: shadowsOf(themes, "dark"),
492
+ },
493
+ },
494
+ semantic,
495
+ sizing,
496
+ knobs,
497
+ typography: {
498
+ heading: exportFont(defaultHeadingFont as FontLike, "heading"),
499
+ body: exportFont(defaultBodyFont as FontLike, "body"),
500
+ categories,
501
+ },
502
+ };
503
+ }
package/src/font.ts CHANGED
@@ -20,14 +20,12 @@ const defaults = {
20
20
  15: 114,
21
21
  16: 124,
22
22
  } as const,
23
- letterSpacing: {
24
- 4: 1,
25
- 5: 3,
26
- 6: 3,
27
- 9: -2,
28
- 10: -3,
29
- 12: -4,
30
- } as const,
23
+ // Neutral by default (LC-11): tracking is a per-family function of size,
24
+ // so a shared constant table is always wrong for someone — the previous
25
+ // Inter-ish table leaked +3px tracking into mono/script faces at $5/$6
26
+ // (see p-results/fixes-polish2-codeblock-density.md). Families that want
27
+ // optical tracking pass their own `letterSpacing` table.
28
+ letterSpacing: {} as Record<string | number, number>,
31
29
  weight: {
32
30
  4: "300",
33
31
  } as const,
@@ -1,9 +1,20 @@
1
- import "@tamagui/font-inter/css/400.css";
2
- import "@tamagui/font-inter/css/700.css";
3
1
  import type { FontLoader, FontLoaderConfig } from "./types";
4
2
 
5
3
  export function createFontLoader(_config: FontLoaderConfig): FontLoader {
6
- // On web, fonts are loaded via CSS @font-face (side-effect imports).
7
- // Inter CSS is imported above. Custom font CSS must be imported by the consumer.
4
+ // On web, Inter is intentionally NOT loaded here. This module previously
5
+ // side-effect imported @tamagui/font-inter/css/{400,700}.css, which embed
6
+ // the faces as base64 woff (font-display: block) — ~270 KB that vite
7
+ // bundles into every consumer's render-blocking extracted CSS (measured
8
+ // 2026-08-12: ~200 KB gz of the SPA's style.css and ~195 KB gz of the
9
+ // storybook iframe CSS were these two faces), duplicating the woff2
10
+ // binaries the shells already preload and letting the later base64
11
+ // declaration win the cascade over the preloaded files.
12
+ //
13
+ // Per LC-33 FONT-STABLE-MOUNT the app shell owns the fonts: declare
14
+ // @font-face with font-display: optional + preload the woff2 binaries in
15
+ // the document head (see apps/one/spa/index.html, apps/storybook
16
+ // .storybook/preview-head.html, and core's CreateRootLayout.web.tsx
17
+ // `interFonts` head flag). Custom font CSS must likewise be imported by
18
+ // the consumer shell, not by this package.
8
19
  return { importFonts: () => ({}) };
9
20
  }
package/src/index.ts CHANGED
@@ -1,3 +1,10 @@
1
1
  export * from "./theme/index";
2
+ export * from "./dates";
3
+ export * from "./devWarn";
4
+ export * from "./keyboardFocusRing";
5
+ export * from "./menuRow";
2
6
  export * from "./font";
3
7
  export * from "./fonts/index";
8
+
9
+ // Thin type re-exports so packages/apps never import @tamagui/web directly.
10
+ export type { GenericFonts, MediaQueryKey, ThemeName } from "@tamagui/web";
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Keyboard-modality tracker for manual focus-ring painting (web only).
3
+ *
4
+ * Group widgets built on roving/redirected focus (tamagui Tabs, ToggleGroup,
5
+ * Group-wrapped button strips) move focus from the container to the current
6
+ * item inside a focusin handler. Chromium drops keyboard modality on that
7
+ * script-initiated focus, so neither CSS :focus-visible nor tamagui's
8
+ * focusVisibleStyle fire for the redirected item — keyboard users get no
9
+ * ring (DG-A11Y-01). Components track modality with these helpers and paint
10
+ * the ring themselves on keyboard-origin focus.
11
+ */
12
+
13
+ let lastInputWasKeyboard = false;
14
+ let hooked = false;
15
+
16
+ /**
17
+ * Install the global modality listeners. Call during render of any component
18
+ * that uses `wasKeyboardFocus`, so the tracker is live before the first
19
+ * keystroke reaches the widget. Idempotent; no-op on native/SSR.
20
+ */
21
+ export function ensureKeyboardModalityTracking() {
22
+ if (hooked || typeof document === "undefined") return;
23
+ hooked = true;
24
+ document.addEventListener("keydown", () => (lastInputWasKeyboard = true), true);
25
+ document.addEventListener("pointerdown", () => (lastInputWasKeyboard = false), true);
26
+ }
27
+
28
+ /**
29
+ * True when the most recent user input was a key press. Call from onFocus to
30
+ * decide whether the focused element should paint a keyboard focus ring.
31
+ * Installs the global listeners on first use; safe to call on native (always
32
+ * false) and during SSR.
33
+ */
34
+ export function wasKeyboardFocus(): boolean {
35
+ ensureKeyboardModalityTracking();
36
+ return lastInputWasKeyboard;
37
+ }
38
+
39
+ /** Standard manual ring props (mirrors the focusVisibleStyle contract). */
40
+ export const keyboardFocusRingProps = {
41
+ outlineColor: "$outlineColor",
42
+ outlineWidth: 2,
43
+ outlineStyle: "solid",
44
+ outlineOffset: -2,
45
+ } as const;
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { menuRowFrame } from "./menuRow";
3
+
4
+ describe("menuRowFrame (LC-16 shared menu-row geometry)", () => {
5
+ it("spans the overlay edge-to-edge with its own horizontal padding (SP-EDGE)", () => {
6
+ expect(menuRowFrame.width).toBe("100%");
7
+ expect(menuRowFrame.paddingHorizontal).toBe("$3");
8
+ });
9
+
10
+ it("keeps flat corners — the overlay container clips to the radius knob", () => {
11
+ expect(menuRowFrame.borderRadius).toBe(0);
12
+ });
13
+
14
+ it("suppresses the focus outline (row surface highlight is the indicator)", () => {
15
+ expect(menuRowFrame.outlineStyle).toBe("none");
16
+ });
17
+
18
+ it("is a centered row with a transparent base surface", () => {
19
+ expect(menuRowFrame.flexDirection).toBe("row");
20
+ expect(menuRowFrame.alignItems).toBe("center");
21
+ expect(menuRowFrame.backgroundColor).toBe("transparent");
22
+ expect(menuRowFrame.userSelect).toBe("none");
23
+ });
24
+
25
+ it("leaves ramp/cursor/gap/vertical sizing to consumers (not in the recipe)", () => {
26
+ expect(menuRowFrame).not.toHaveProperty("cursor");
27
+ expect(menuRowFrame).not.toHaveProperty("gap");
28
+ expect(menuRowFrame).not.toHaveProperty("hoverStyle");
29
+ expect(menuRowFrame).not.toHaveProperty("height");
30
+ expect(menuRowFrame).not.toHaveProperty("paddingVertical");
31
+ });
32
+ });
package/src/menuRow.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * LC-16 ONE-IMPLEMENTATION — the shared menu-row frame geometry.
3
+ *
4
+ * One geometry recipe for every menu/listbox row (DropdownMenu `ItemRow`,
5
+ * Select `SelectRow`, MentionInput `MentionSuggestionItem`): the row spans
6
+ * the full overlay width (edge-to-edge highlight, SP-EDGE), owns its
7
+ * horizontal padding, keeps flat corners (the overlay container clips to the
8
+ * radius knob), and suppresses the focus outline — the row surface highlight
9
+ * IS the focus/active indicator.
10
+ *
11
+ * It lives in `@multiplatform.one/theme` (not `components`) because the
12
+ * dependency direction is `components` → `forms` → `theme`; the two forms
13
+ * consumers cannot import `components` without a cycle.
14
+ *
15
+ * Deliberately NOT part of the recipe — each consumer owns it, by design:
16
+ * - color ramps: menus highlight with `componentColors.interactive.*`,
17
+ * form fields with `formInputColors.background.*` (package-boundary rule);
18
+ * - cursor: `pointer` for menu items, `default` for listbox options;
19
+ * - vertical sizing: height/minHeight from the size token, or row padding;
20
+ * - in-row gap and the state-variant model (selected/active/disabled).
21
+ */
22
+ export const menuRowFrame = {
23
+ flexDirection: "row",
24
+ alignItems: "center",
25
+ width: "100%",
26
+ paddingHorizontal: "$3",
27
+ borderRadius: 0,
28
+ userSelect: "none",
29
+ backgroundColor: "transparent",
30
+ outlineStyle: "none" as any,
31
+ } as const;