@multiplatform.one/theme 7.11.0 → 7.16.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 (54) hide show
  1. package/README.md +22 -2
  2. package/package.json +6 -6
  3. package/src/audit/constraintAudit.spec.ts +180 -0
  4. package/src/audit/constraintAudit.ts +116 -37
  5. package/src/audit/themeMatrix.ts +4 -1
  6. package/src/figma/figmaTokens.spec.ts +1 -2
  7. package/src/numbers.spec.ts +27 -1
  8. package/src/numbers.ts +40 -0
  9. package/src/theme/Surface.spec.tsx +9 -4
  10. package/src/theme/createDefaultThemeConfig.ts +8 -1
  11. package/src/theme/createThemes.ts +43 -6
  12. package/src/theme/index.ts +3 -0
  13. package/src/theme/intent.spec.tsx +9 -7
  14. package/src/theme/layoutTokensHooks.spec.tsx +3 -2
  15. package/src/theme/radiusClass.ts +18 -0
  16. package/src/theme/resolveKnobs.spec.ts +73 -7
  17. package/src/theme/resolveKnobs.ts +99 -25
  18. package/src/theme/semanticEntryText.spec.ts +85 -0
  19. package/src/theme/sizeRecipes.ts +8 -5
  20. package/src/theme/subsetThemes.spec.ts +102 -0
  21. package/src/theme/subsetThemes.ts +56 -0
  22. package/src/theme/theme.tsx +15 -5
  23. package/src/theme/transitionProps.native.ts +15 -0
  24. package/src/theme/transitionProps.spec.ts +14 -0
  25. package/src/theme/transitionProps.ts +16 -0
  26. package/src/theme/useResolvedKnobs.hydration.spec.tsx +141 -0
  27. package/src/theme/useResolvedKnobs.ts +28 -22
  28. package/src/theme/useResolvedKnobsBehavior.spec.tsx +15 -1
  29. package/src/theme/useTouchSurface.ts +8 -0
  30. package/types/audit/constraintAudit.d.ts.map +1 -1
  31. package/types/audit/themeMatrix.d.ts.map +1 -1
  32. package/types/numbers.d.ts +12 -0
  33. package/types/numbers.d.ts.map +1 -1
  34. package/types/theme/createDefaultThemeConfig.d.ts +7 -0
  35. package/types/theme/createDefaultThemeConfig.d.ts.map +1 -1
  36. package/types/theme/createThemes.d.ts.map +1 -1
  37. package/types/theme/index.d.ts +3 -0
  38. package/types/theme/index.d.ts.map +1 -1
  39. package/types/theme/radiusClass.d.ts +14 -0
  40. package/types/theme/radiusClass.d.ts.map +1 -1
  41. package/types/theme/resolveKnobs.d.ts +35 -1
  42. package/types/theme/resolveKnobs.d.ts.map +1 -1
  43. package/types/theme/sizeRecipes.d.ts +8 -5
  44. package/types/theme/sizeRecipes.d.ts.map +1 -1
  45. package/types/theme/subsetThemes.d.ts +39 -0
  46. package/types/theme/subsetThemes.d.ts.map +1 -0
  47. package/types/theme/theme.d.ts.map +1 -1
  48. package/types/theme/transitionProps.d.ts +14 -0
  49. package/types/theme/transitionProps.d.ts.map +1 -0
  50. package/types/theme/transitionProps.native.d.ts +13 -0
  51. package/types/theme/transitionProps.native.d.ts.map +1 -0
  52. package/types/theme/useResolvedKnobs.d.ts.map +1 -1
  53. package/types/theme/useTouchSurface.d.ts +3 -0
  54. package/types/theme/useTouchSurface.d.ts.map +1 -0
@@ -136,7 +136,7 @@ function sanitizeGetTheme(
136
136
  }
137
137
 
138
138
  // Radix hue names whose sub-themes act as decorative TINTS (Tint.tsx cycling,
139
- // the storybook `color` global, `theme="red"` field tinting). Under a tint,
139
+ // the storybook `color` global). Under a tint,
140
140
  // surfaces/borders/solids keep the hue but TEXT must stay readable neutral:
141
141
  // muted/secondary text and user-authored content must never render saturated
142
142
  // accent (SB-D-14/SB-D-20). Exported so theme-identity consumers (chart
@@ -210,6 +210,41 @@ function neutralizeTintText(
210
210
  return out;
211
211
  }
212
212
 
213
+ const semanticIntentNames = new Set(["error", "warning", "success"]);
214
+ const entryControlNames = new Set(["Input", "TextArea"]);
215
+
216
+ /**
217
+ * T-VALUE under a semantic intent (MPO-369). Intents are not tints: an Alert
218
+ * reads `$color12` and a solid Button its on-fill `$color` from the hue, so
219
+ * only entered text (an Input or TextArea value, and the placeholder that
220
+ * stands in for it) goes back to the neutral ink.
221
+ */
222
+ function neutralizeSemanticEntryText(
223
+ themes: Record<string, Record<string, string>>,
224
+ ): Record<string, Record<string, string>> {
225
+ const out: Record<string, Record<string, string>> = { ...themes };
226
+ for (const [name, theme] of Object.entries(themes)) {
227
+ const [scheme, intent, component, ...rest] = name.split("_");
228
+ if (rest.length > 0 || (scheme !== "light" && scheme !== "dark")) continue;
229
+ if (!intent || !semanticIntentNames.has(intent)) continue;
230
+ const base = themes[scheme];
231
+ if (!base) continue;
232
+ if (component === undefined) {
233
+ if (base.placeholderColor != null)
234
+ out[name] = { ...theme, placeholderColor: base.placeholderColor };
235
+ continue;
236
+ }
237
+ if (!entryControlNames.has(component)) continue;
238
+ const neutral = { ...base, ...themes[`${scheme}_${component}`] };
239
+ const patched: Record<string, string> = { ...theme };
240
+ for (const key of tintNeutralTextKeys) {
241
+ if (neutral[key] != null) patched[key] = neutral[key];
242
+ }
243
+ out[name] = patched;
244
+ }
245
+ return out;
246
+ }
247
+
213
248
  const defaultLightShadows: Shadows = {
214
249
  shadow1: "rgba(0,0,0,0.12)",
215
250
  shadow2: "rgba(0,0,0,0.16)",
@@ -401,11 +436,13 @@ export function createThemesBuilder(
401
436
  const initialTheme = tamaguiCreateThemes(
402
437
  createThemesProps as Parameters<typeof tamaguiCreateThemes>[0],
403
438
  );
404
- const themes = neutralizeTintText({
405
- ...grayTheme,
406
- ...tamaguiThemes,
407
- ...initialTheme,
408
- });
439
+ const themes = neutralizeSemanticEntryText(
440
+ neutralizeTintText({
441
+ ...grayTheme,
442
+ ...tamaguiThemes,
443
+ ...initialTheme,
444
+ }),
445
+ );
409
446
  return {
410
447
  themes() {
411
448
  return themes;
@@ -30,10 +30,13 @@ export * from "./resolveKnobs";
30
30
  export * from "./shared";
31
31
  export * from "./themeValue";
32
32
  export * from "./sizeRecipes";
33
+ export * from "./subsetThemes";
33
34
  export * from "./Surface";
34
35
  export * from "./tileSizes";
36
+ export * from "./transitionProps";
35
37
 
36
38
  export * from "./theme";
37
39
  export * from "./Tint";
38
40
  export * from "./useResolvedKnobs";
39
41
  export * from "./useTheme";
42
+ export * from "./useTouchSurface";
@@ -4,7 +4,9 @@ import { renderToString } from "react-dom/server";
4
4
  import { describe, expect, it, vi } from "vitest";
5
5
 
6
6
  // Mock Tamagui's <Theme> so we can inspect the chosen theme name
7
- vi.mock("tamagui", () => ({
7
+ vi.mock("tamagui", async (importOriginal) => ({
8
+ useDidFinishSSR: (await importOriginal<typeof import("tamagui")>()).useDidFinishSSR,
9
+ // These tests inspect intent names with a fixed light scheme.
8
10
  useThemeName: () => "light",
9
11
  Theme: ({ name, children }: { name: string; children: ReactNode }) =>
10
12
  createElement("div", { "data-tamagui-theme": name }, children),
@@ -214,8 +216,8 @@ describe("useResolvedKnobs with Intent", () => {
214
216
  ),
215
217
  );
216
218
 
217
- // "error" → Button: { elevation: "small" } → resolved to "$1"
218
- expect(capture.result.knobProps.elevation).toBe("$1");
219
+ // "error" → Button: { elevation: "small" }, and small paints no control shadow (MPO-96)
220
+ expect(capture.result.knobProps.elevation).toBeUndefined();
219
221
  });
220
222
 
221
223
  it("resolves component-specific intent overrides via explicit option", () => {
@@ -223,8 +225,8 @@ describe("useResolvedKnobs with Intent", () => {
223
225
 
224
226
  renderToHtml(wrapWithPreset(makePreset(), createElement(capture.Probe)));
225
227
 
226
- // "error" → Button: { elevation: "small" } → resolved to "$1"
227
- expect(capture.result.knobProps.elevation).toBe("$1");
228
+ // "error" → Button: { elevation: "small" }, and small paints no control shadow (MPO-96)
229
+ expect(capture.result.knobProps.elevation).toBeUndefined();
228
230
  });
229
231
 
230
232
  it("preset intent overrides take precedence over defaultIntents", () => {
@@ -287,8 +289,8 @@ describe("useResolvedKnobs with Intent", () => {
287
289
 
288
290
  renderToHtml(wrapWithPreset(makePreset(), createElement(capture.Probe)));
289
291
 
290
- // Default knobs: elevation "small" → resolved to "$1"
291
- expect(capture.result.knobProps.elevation).toBe("$1");
292
+ // Default knobs: elevation "small" paints no control shadow (MPO-96)
293
+ expect(capture.result.knobProps.elevation).toBeUndefined();
292
294
  expect(capture.result.knobProps.textAccent).toBe("high");
293
295
  });
294
296
  });
@@ -1,3 +1,4 @@
1
+ import { TestProviders } from "@multiplatform.one/test-utils";
1
2
  /**
2
3
  * Layout hook specs — the live half of layoutTokens (DG-LAY-01/03): the
3
4
  * viewport-driven `useLayoutSizeClass`, the pane budget `useMultiPane`, and
@@ -148,8 +149,8 @@ describe("useMultiPane", () => {
148
149
  describe("useSemanticGaps", () => {
149
150
  it("maps within-group to knob gap and between-groups to the large gap", () => {
150
151
  setViewportWidth(1000);
151
- const { result: knobs } = renderHook(() => useResolvedKnobs());
152
- const { result } = renderHook(() => useSemanticGaps());
152
+ const { result: knobs } = renderHook(() => useResolvedKnobs(), { wrapper: TestProviders });
153
+ const { result } = renderHook(() => useSemanticGaps(), { wrapper: TestProviders });
153
154
  expect(result.current.withinGroup).toEqual(knobs.current.knobProps.gap);
154
155
  expect(result.current.betweenGroups).toEqual(knobs.current.knobProps.gapLg);
155
156
  // The roles must differ, or grouping hierarchy is invisible.
@@ -81,6 +81,24 @@ export function radiusClassProps(
81
81
  return { "data-radius-class": radiusClass, "data-radius-part": part };
82
82
  }
83
83
 
84
+ export interface RadiusResolutionDeclaration {
85
+ "data-radius-resolution": "BINARY";
86
+ }
87
+
88
+ /**
89
+ * Declare that a part resolves through the BINARY row (DG-RAD-03), so the
90
+ * constraint audit measures it as 0 or half its own shorter edge. The token
91
+ * scale has no h/2 value, so an undeclared thumb or track reads off-scale at
92
+ * every stop. Spread it only where the painted radius IS the resolved one: a
93
+ * consumer radius override ejects the part from the class.
94
+ *
95
+ * @example
96
+ * <StyledSwitch.Thumb {...radiusResolutionProps("BINARY")} borderRadius={thumbRadius} />
97
+ */
98
+ export function radiusResolutionProps(radiusClass: "BINARY"): RadiusResolutionDeclaration {
99
+ return { "data-radius-resolution": radiusClass };
100
+ }
101
+
84
102
  // ── Radius resolution (DG-RAD-01..05) ─────────────────────────
85
103
 
86
104
  /**
@@ -24,6 +24,7 @@ import { defaultPreset, boldPreset, heroPreset } from "./presets";
24
24
  import {
25
25
  applyDensity,
26
26
  capContainerRadius,
27
+ containerCapProps,
27
28
  resolveKnobs,
28
29
  resolvePageTitleScale,
29
30
  } from "./resolveKnobs";
@@ -224,8 +225,10 @@ describe("resolveKnobs", () => {
224
225
  expect(resolveKnobs(knobsWith({ elevation: "none" })).knobProps.elevation).toBeUndefined();
225
226
  });
226
227
 
227
- it('maps "small" → "$1"', () => {
228
- expect(resolveKnobs(knobsWith({ elevation: "small" })).knobProps.elevation).toBe("$1");
228
+ it('maps "small" → undefined: controls paint no resting shadow at the default (MPO-96)', () => {
229
+ const { knobProps } = resolveKnobs(knobsWith({ elevation: "small" }));
230
+ expect(knobProps.elevation).toBeUndefined();
231
+ expect(knobProps.elevationChrome).toEqual({});
229
232
  });
230
233
 
231
234
  it("overlay elevation at small is $2, not the control $1 token", () => {
@@ -250,7 +253,7 @@ describe("resolveKnobs", () => {
250
253
  expect(knobProps.cardSurface.backgroundColor).toBe("$background");
251
254
  expect(knobProps.inputBackground).toBe("$background");
252
255
  expect(knobProps.elevatedSurface.shadowColor).toBe("#000");
253
- expect(knobProps.elevatedSurface.shadowOpacity).toBe(0.15);
256
+ expect(knobProps.elevatedSurface.shadowOpacity).toBe(0.12);
254
257
  expect(knobProps.elevationChrome.elevation).toBe(8);
255
258
  });
256
259
 
@@ -298,7 +301,8 @@ describe("resolveKnobs", () => {
298
301
  const small = resolveNative(
299
302
  knobsWith({ elevation: "small", hover: { elevation: "medium" } }),
300
303
  );
301
- expect(small.knobProps.elevation).toBe(20);
304
+ expect(small.knobProps.elevation).toBeUndefined();
305
+ expect(small.knobProps.elevationChrome).toEqual({});
302
306
  expect(small.knobProps.overlayElevation).toBe(28);
303
307
  expect(small.knobProps.elevatedSurface.elevation).toBe(28);
304
308
  expect(small.knobProps.elevatedSurface.elevation).not.toBe(small.knobProps.elevation);
@@ -315,6 +319,52 @@ describe("resolveKnobs", () => {
315
319
  });
316
320
  });
317
321
 
322
+ describe("overlay elevation ladder (MPO-99)", () => {
323
+ const paint = (elevation: "small" | "medium" | "large", scheme: "light" | "dark") =>
324
+ resolveKnobs(knobsWith({ elevation }), undefined, scheme).knobProps;
325
+
326
+ it("overlay medium and large paint the reference popper and dialog shadows", () => {
327
+ expect(paint("medium", "light").overlayElevation).toBe("$3");
328
+ expect(paint("medium", "light").elevatedSurface.style?.boxShadow).toBe(
329
+ "0 4px 8px rgba(0,0,0,0.10), 0 12px 32px rgba(0,0,0,0.08)",
330
+ );
331
+ expect(paint("large", "light").overlayElevation).toBe("$5");
332
+ expect(paint("large", "light").elevatedSurface.style?.boxShadow).toBe(
333
+ "0 12px 24px rgba(0,0,0,0.12)",
334
+ );
335
+ });
336
+
337
+ it("overlay medium and large differ from control medium and large, in both schemes", () => {
338
+ for (const scheme of ["light", "dark"] as const) {
339
+ for (const stop of ["medium", "large"] as const) {
340
+ const knobProps = paint(stop, scheme);
341
+ expect(knobProps.elevatedSurface.style?.boxShadow).toBeTruthy();
342
+ expect(knobProps.elevationChrome.style?.boxShadow).toBeTruthy();
343
+ expect(knobProps.elevatedSurface.style?.boxShadow).not.toBe(
344
+ knobProps.elevationChrome.style?.boxShadow,
345
+ );
346
+ }
347
+ }
348
+ });
349
+
350
+ it("dark keeps each overlay stop's geometry and inverts the hue (LC-85)", () => {
351
+ expect(paint("medium", "dark").elevatedSurface.style?.boxShadow).toBe(
352
+ "0 4px 8px rgba(255,255,255,0.10), 0 12px 32px rgba(255,255,255,0.08)",
353
+ );
354
+ expect(paint("large", "dark").elevatedSurface.style?.boxShadow).toBe(
355
+ "0 12px 24px rgba(255,255,255,0.12)",
356
+ );
357
+ });
358
+
359
+ it("overlays keep a shadow at the default stop while controls go flat (MPO-96)", () => {
360
+ const knobProps = paint("small", "light");
361
+ expect(knobProps.elevationChrome).toEqual({});
362
+ expect(knobProps.elevatedSurface.style?.boxShadow).toBe(
363
+ "0 3px 6px rgba(0,0,0,0.15), 0 2px 4px rgba(0,0,0,0.10)",
364
+ );
365
+ });
366
+ });
367
+
318
368
  // ── interaction state overrides ──────────────────────────────
319
369
 
320
370
  describe("interaction state overrides", () => {
@@ -351,8 +401,8 @@ describe("resolveKnobs", () => {
351
401
  });
352
402
 
353
403
  it("focus.elevation maps to elevation.focusKnobProps.elevation", () => {
354
- const { elevation } = resolveKnobs(knobsWith({ focus: { elevation: "small" } }));
355
- expect(elevation.focusKnobProps?.elevation).toBe("$1");
404
+ const { elevation } = resolveKnobs(knobsWith({ focus: { elevation: "large" } }));
405
+ expect(elevation.focusKnobProps?.elevation).toBe("$4");
356
406
  });
357
407
 
358
408
  it("focusVisible.borderWidth maps to control.focusVisibleKnobProps.borderWidth", () => {
@@ -958,7 +1008,7 @@ describe("resolveKnobs", () => {
958
1008
  const overridden = resolveKnobs(defaultKnobs, () => ({
959
1009
  elevation: "$4",
960
1010
  }));
961
- expect(baseline.knobProps.elevation).toBe("$1");
1011
+ expect(baseline.knobProps.elevation).toBeUndefined();
962
1012
  expect(overridden.knobProps.elevation).toBe("$4");
963
1013
  });
964
1014
 
@@ -1376,3 +1426,19 @@ describe("LC-67 touch height floor via resolveKnobs", () => {
1376
1426
  }
1377
1427
  });
1378
1428
  });
1429
+
1430
+ describe("containerCapProps (DG-RAD-04 provenance)", () => {
1431
+ it("names the knob stop a borderRadiusMap token came from", () => {
1432
+ expect(containerCapProps("PageSection", "$12", "small")).toEqual({
1433
+ "data-constraint-container": "PageSection",
1434
+ "data-radius-knob": "full",
1435
+ "data-space-knob": "small",
1436
+ });
1437
+ });
1438
+
1439
+ it("makes no claim for a radius that did not come from the knob", () => {
1440
+ expect(containerCapProps("TextArea", 13, "small")).toBeUndefined();
1441
+ expect(containerCapProps("TextArea", "$5", "small")).toBeUndefined();
1442
+ expect(containerCapProps("TextArea", "$12", "roomy")).toBeUndefined();
1443
+ });
1444
+ });
@@ -174,6 +174,58 @@ export function capContainerRadius(
174
174
  return radiusPx > paddingPx ? paddingPx : borderRadiusMap[borderRadius];
175
175
  }
176
176
 
177
+ /**
178
+ * The borderRadius knob stop a `borderRadiusMap` token came from, so a part
179
+ * can resolve its DG-RAD class at that stop. Undefined for any value the
180
+ * knob did not produce.
181
+ */
182
+ export function radiusStopFromToken(token: unknown): Knobs["borderRadius"] | undefined {
183
+ return (Object.keys(borderRadiusMap) as Knobs["borderRadius"][]).find(
184
+ (stop) => borderRadiusMap[stop] === token,
185
+ );
186
+ }
187
+
188
+ /**
189
+ * Containers the constraint audit measures against the cap (DG-RAD-04).
190
+ * `StackedRow` is one row of a vertical stack: it also carries
191
+ * `data-stack-position`, and only its outer corners take the cap.
192
+ * `OwnInset` pads with a fixed token instead of panelPadding, so the audit
193
+ * caps it at its measured padding and ignores the space stop.
194
+ */
195
+ export type CappedContainer = "PageSection" | "TextArea" | "StackedRow" | "OwnInset";
196
+
197
+ export interface ContainerCapDeclaration {
198
+ "data-constraint-container": CappedContainer;
199
+ "data-radius-knob": Knobs["borderRadius"];
200
+ "data-space-knob": Knobs["space"];
201
+ }
202
+
203
+ /**
204
+ * Declare a container that spreads `containerRadius`, with the knob stops it
205
+ * resolved, so the constraint audit checks its painted corners against
206
+ * `capContainerRadius` for those stops. The cap is a padding px value, not a
207
+ * radius token, so an undeclared capped container reads off-scale at `full`.
208
+ * Returns nothing when `radiusToken` is not a `borderRadiusMap` token or
209
+ * `space` is not a space stop: a part whose radius did not come from the knob
210
+ * makes no claim.
211
+ *
212
+ * @example
213
+ * <YStack {...knobProps.containerRadius} {...containerCapProps("PageSection", knobProps.borderRadius.borderRadius, knobProps.space)} />
214
+ */
215
+ export function containerCapProps(
216
+ container: CappedContainer,
217
+ radiusToken: unknown,
218
+ space: string,
219
+ ): ContainerCapDeclaration | undefined {
220
+ const radiusKnob = radiusStopFromToken(radiusToken);
221
+ if (!radiusKnob || !Object.hasOwn(panelPaddingPxMap, space)) return undefined;
222
+ return {
223
+ "data-constraint-container": container,
224
+ "data-radius-knob": radiusKnob,
225
+ "data-space-knob": space as Knobs["space"],
226
+ };
227
+ }
228
+
177
229
  const gapMap = {
178
230
  small: "$2",
179
231
  medium: "$4",
@@ -252,14 +304,16 @@ const fontWeightMap = {
252
304
  // tokens.size values Tamagui resolves those tokens to ($1=20, $2=28, $4=44),
253
305
  // so Tamagui consumers render identical shadows either way.
254
306
  //
255
- // Control elevation ($1 at the default small stop) is the in-page Button
256
- // token. Overlay elevation is a DISTINCT token one step above that stop so
257
- // a dialog over a scrim and a Button on the page do not read as the same
307
+ // Controls are flat at the default `small` stop (MPO-96: the reference
308
+ // measures no resting shadow on a button or a card), so `small` paints
309
+ // nothing on the control map and only medium / large lift a control.
310
+ // Overlay elevation is a DISTINCT ladder one step above the control map, so
311
+ // a dialog over a scrim and a Button on the page never read as the same
258
312
  // distance off the surface (DG-OVL-01 / E-OVERLAY). elevatedSurface binds
259
313
  // the overlay token — not the control one.
260
314
  export const elevationMap: Record<string, string | number | undefined> = isWeb
261
- ? { none: undefined, small: "$1", medium: "$2", large: "$4" }
262
- : { none: undefined, small: 20, medium: 28, large: 44 };
315
+ ? { none: undefined, small: undefined, medium: "$2", large: "$4" }
316
+ : { none: undefined, small: undefined, medium: 28, large: 44 };
263
317
 
264
318
  export const overlayElevationMap: Record<string, string | number | undefined> = isWeb
265
319
  ? { none: undefined, small: "$2", medium: "$3", large: "$5" }
@@ -285,43 +339,70 @@ const elevationTintInput: Record<Elevation, string> = {
285
339
  large: "$color5",
286
340
  };
287
341
 
342
+ type ElevationKey = "$1" | "$2" | "$3" | "$4" | "$5";
343
+
288
344
  // Axiom 14 light source: y-offset:blur = 0.5. Metrics match the maps
289
- // InputParts / getElevationWrapperProps already shipped.
345
+ // InputParts / getElevationWrapperProps already shipped. `$3` and `$5` are
346
+ // the overlay-only stops (MPO-99): the reference §8 popper and dialog
347
+ // shadows. Native paints one shadow, so it takes the first web layer.
290
348
  const elevationShadowMetrics: Record<
291
- "$1" | "$2" | "$4",
349
+ ElevationKey,
292
350
  { offsetY: number; radius: number; android: number }
293
351
  > = {
294
352
  $1: { offsetY: 1, radius: 2, android: 2 },
295
353
  $2: { offsetY: 3, radius: 6, android: 4 },
354
+ $3: { offsetY: 4, radius: 8, android: 6 },
296
355
  $4: { offsetY: 10, radius: 20, android: 8 },
356
+ $5: { offsetY: 12, radius: 24, android: 12 },
297
357
  };
298
358
 
299
- const elevationKeyAlias: Record<string, "$1" | "$2" | "$4"> = {
359
+ const elevationKeyAlias: Record<string, ElevationKey> = {
300
360
  $1: "$1",
301
361
  $2: "$2",
302
- $3: "$2",
362
+ $3: "$3",
303
363
  $4: "$4",
304
- $5: "$4",
364
+ $5: "$5",
305
365
  "20": "$1",
306
366
  "28": "$2",
307
- "36": "$2",
367
+ "36": "$3",
308
368
  "44": "$4",
309
- "52": "$4",
369
+ "52": "$5",
310
370
  };
311
371
 
312
- const webShadowLight: Record<"$1" | "$2" | "$4", string> = {
372
+ const webShadowLight: Record<ElevationKey, string> = {
313
373
  $1: "0 1px 2px rgba(0,0,0,0.12), 0 0 1px rgba(0,0,0,0.08)",
314
374
  $2: "0 3px 6px rgba(0,0,0,0.15), 0 2px 4px rgba(0,0,0,0.10)",
375
+ $3: "0 4px 8px rgba(0,0,0,0.10), 0 12px 32px rgba(0,0,0,0.08)",
315
376
  $4: "0 10px 20px rgba(0,0,0,0.15), 0 3px 6px rgba(0,0,0,0.10)",
377
+ $5: "0 12px 24px rgba(0,0,0,0.12)",
316
378
  };
317
379
 
318
- const webShadowDark: Record<"$1" | "$2" | "$4", string> = {
380
+ // LC-85: dark keeps each stop's geometry and inverts the hue.
381
+ const webShadowDark: Record<ElevationKey, string> = {
319
382
  $1: "0 1px 2px rgba(255,255,255,0.08), 0 0 1px rgba(255,255,255,0.06)",
320
383
  $2: "0 3px 6px rgba(255,255,255,0.10), 0 2px 4px rgba(255,255,255,0.06)",
384
+ $3: "0 4px 8px rgba(255,255,255,0.10), 0 12px 32px rgba(255,255,255,0.08)",
321
385
  $4: "0 10px 20px rgba(255,255,255,0.12), 0 3px 6px rgba(255,255,255,0.08)",
386
+ $5: "0 12px 24px rgba(255,255,255,0.12)",
387
+ };
388
+
389
+ const shadowOpacityLight: Record<ElevationKey, number> = {
390
+ $1: 0.12,
391
+ $2: 0.15,
392
+ $3: 0.1,
393
+ $4: 0.15,
394
+ $5: 0.12,
395
+ };
396
+
397
+ const shadowOpacityDark: Record<ElevationKey, number> = {
398
+ $1: 0.08,
399
+ $2: 0.1,
400
+ $3: 0.1,
401
+ $4: 0.12,
402
+ $5: 0.12,
322
403
  };
323
404
 
324
- function elevationTokenKey(token: string | number | undefined): "$1" | "$2" | "$4" | undefined {
405
+ function elevationTokenKey(token: string | number | undefined): ElevationKey | undefined {
325
406
  if (token == null) return undefined;
326
407
  return elevationKeyAlias[String(token)];
327
408
  }
@@ -337,15 +418,7 @@ function elevationPaint(
337
418
  const paint: ElevationPaintProps = {
338
419
  shadowColor: dark ? "$color12" : "#000",
339
420
  shadowOffset: { width: 0, height: metrics.offsetY },
340
- shadowOpacity: dark
341
- ? key === "$4"
342
- ? 0.12
343
- : key === "$2"
344
- ? 0.1
345
- : 0.08
346
- : key === "$1"
347
- ? 0.12
348
- : 0.15,
421
+ shadowOpacity: dark ? shadowOpacityDark[key] : shadowOpacityLight[key],
349
422
  shadowRadius: metrics.radius,
350
423
  };
351
424
  if (isWeb) {
@@ -489,6 +562,7 @@ export function resolveKnobs(
489
562
  knobs: Knobs,
490
563
  override?: KnobPropsOverride,
491
564
  scheme: ColorScheme = "light",
565
+ environment?: { touch?: boolean },
492
566
  ): ResolvedKnobs {
493
567
  const radiusToken = borderRadiusMap[knobs.borderRadius];
494
568
  const nestedRadiusToken = nestedRadiusMap[knobs.borderRadius];
@@ -496,7 +570,7 @@ export function resolveKnobs(
496
570
  const nestedPx = nestedControlPxMap[knobs.size];
497
571
  const nestedHitSlop = pressTargetHitSlop(nestedPx);
498
572
  const sizeRecipe = sizeRecipeForToken(sizeToken, {
499
- touch: isTouchable || isWebTouchable,
573
+ touch: environment?.touch ?? (isTouchable || isWebTouchable),
500
574
  family: knobs.density === "compact" ? "controlCompact" : "control",
501
575
  });
502
576
  const paddingToken = panelPaddingMap[knobs.space];
@@ -0,0 +1,85 @@
1
+ /**
2
+ * MPO-369: fields tint their error state with the semantic `error` theme,
3
+ * which the default subset keeps, instead of the decorative `red` it drops.
4
+ * Entered text stays neutral under it (T-VALUE); everything else keeps the hue.
5
+ */
6
+
7
+ import { describe, expect, it, vi } from "vitest";
8
+
9
+ vi.mock("tamagui", () => ({
10
+ createTamagui: vi.fn((config: Record<string, unknown>) => config),
11
+ }));
12
+ vi.mock("./animations/index", () => ({ animations: {} }));
13
+
14
+ import { createDefaultThemeConfig } from "./createDefaultThemeConfig";
15
+ import { createThemesBuilder } from "./createThemes";
16
+ import { defaultAccentTheme } from "./defaults/accent";
17
+ import { defaultBaseTheme } from "./defaults/base";
18
+ import { defaultBuilderOptions } from "./defaults/builderOptions";
19
+
20
+ type Themes = Record<string, Record<string, string>>;
21
+
22
+ const built = createThemesBuilder(
23
+ defaultBaseTheme,
24
+ defaultAccentTheme,
25
+ defaultBuilderOptions,
26
+ ).themes() as Themes;
27
+
28
+ const schemes = ["light", "dark"] as const;
29
+ const intents = ["error", "warning", "success"] as const;
30
+ const entryText = ["color", "colorHover", "colorPress", "colorFocus", "placeholderColor"] as const;
31
+
32
+ describe("entered text under a semantic intent", () => {
33
+ for (const scheme of schemes) {
34
+ for (const intent of intents) {
35
+ for (const control of ["Input", "TextArea"] as const) {
36
+ it(`${scheme}_${intent}_${control} keeps the neutral ${control}'s value ink and its own hue chrome`, () => {
37
+ const themed = built[`${scheme}_${intent}_${control}`];
38
+ const neutral = built[`${scheme}_${control}`];
39
+ for (const key of entryText) {
40
+ if (neutral[key] != null) expect(themed[key], key).toBe(neutral[key]);
41
+ }
42
+ expect(themed.color11).toBe(built[scheme].color11);
43
+ expect(themed.color12).toBe(built[scheme].color12);
44
+ expect(themed.borderColor).not.toBe(neutral.borderColor);
45
+ expect(themed.background).not.toBe(neutral.background);
46
+ });
47
+ }
48
+
49
+ it(`${scheme}_${intent} keeps its hue text and takes the neutral placeholder`, () => {
50
+ const themed = built[`${scheme}_${intent}`];
51
+ expect(themed.placeholderColor).toBe(built[scheme].placeholderColor);
52
+ expect(themed.color).not.toBe(built[scheme].color);
53
+ expect(themed.color12).not.toBe(built[scheme].color12);
54
+ });
55
+
56
+ it(`${scheme}_${intent}_Button keeps its on-fill label`, () => {
57
+ expect(built[`${scheme}_${intent}_Button`].color).not.toBe(built[`${scheme}_Button`].color);
58
+ });
59
+ }
60
+ }
61
+ });
62
+
63
+ describe("the default subset keeps the field error tint", () => {
64
+ const defaultTints = ["accent", "active", "alt1", "alt2", "error", "success", "warning"];
65
+ const full = createDefaultThemeConfig().tamagui.themes as Record<string, unknown>;
66
+ const subset = createDefaultThemeConfig({
67
+ subset: { components: ["Button", "Input", "TextArea"], tints: defaultTints },
68
+ }).tamagui.themes as Record<string, unknown>;
69
+
70
+ it("builds every theme a field enters in its error state, identical to the full config", () => {
71
+ for (const scheme of schemes) {
72
+ for (const name of [
73
+ `${scheme}_error`,
74
+ `${scheme}_error_Input`,
75
+ `${scheme}_error_TextArea`,
76
+ `${scheme}_error_Button`,
77
+ ]) {
78
+ expect(subset, name).toHaveProperty(name);
79
+ expect(subset[name]).toEqual(full[name]);
80
+ }
81
+ expect(full).toHaveProperty(`${scheme}_red`);
82
+ expect(subset).not.toHaveProperty(`${scheme}_red`);
83
+ }
84
+ });
85
+ });
@@ -217,14 +217,17 @@ export function sizeRecipeForToken(
217
217
  /**
218
218
  * Identity helper for the LC-65 SIZE-RECIPE ESCAPE lint
219
219
  * (`scripts/lint-conventions.mjs`). Wrap a numeric height / minHeight /
220
- * paddingHorizontal / fontSize that must stay a literal:
220
+ * paddingHorizontal / fontSize that must stay a literal, and say why:
221
221
  *
222
- * height={sizeRecipeEscape(20)}
222
+ * height={sizeRecipeEscape(20, "toolbar chrome match")}
223
223
  *
224
- * Or comment `// size-recipe-escape:` on the previous line or same
225
- * statement. Pilot: Button/, InputParts/, fields/Select/.
224
+ * Or write the same reason in a `size-recipe-escape:` line comment on the
225
+ * previous line or same statement. Pilot: Button/, InputParts/,
226
+ * fields/Select/. Either way the file and the reason must be a row in
227
+ * `docs/theme-propagation-spec.md` `## Size-recipe escapes` (MPO-23, `00` L8
228
+ * E8); an undeclared escape fails `lint-conventions`.
226
229
  */
227
- export function sizeRecipeEscape<T>(value: T): T {
230
+ export function sizeRecipeEscape<T>(value: T, _reason?: string): T {
228
231
  return value;
229
232
  }
230
233